1. 總覽
目標
在本程式碼研究室中,您將在 Android 上建構由 Cloud Firestore 支援的餐廳推薦應用程式。您將學習下列內容:
- 從 Android 應用程式讀取資料並寫入至 Firestore
- 即時監聽 Firestore 資料的變更
- 使用 Firebase 驗證和安全性規則保護 Firestore 資料
- 編寫複雜的 Firestore 查詢
事前準備
開始本程式碼研究室之前,請確認您具備下列項目:
- Android Studio Flamingo 以上版本
- 搭載 API 19 以上版本的 Android 模擬器
- Node.js 16 以上版本
- Java 版本 17 以上
2. 建立 Firebase 專案
- 使用 Google 帳戶登入 Firebase 主控台。
- 在 Firebase 控制台中,按一下「新增專案」。
- 如以下螢幕截圖所示,請輸入 Firebase 專案名稱 (例如「Friendly Eats」),然後按一下「繼續」。
- 系統可能會要求您啟用 Google Analytics,但在本程式碼研究室中,您的選擇不影響結果。
- 大約一分鐘後,您的 Firebase 專案就會準備就緒。按一下「繼續」。
3. 設定範例專案
下載程式碼
執行下列指令,複製這個程式碼研究室的範例程式碼。這會在電腦上建立名為 friendlyeats-android
的資料夾:
$ git clone https://github.com/firebase/friendlyeats-android
如果您的電腦上沒有 Git,也可以直接從 GitHub 下載程式碼。
新增 Firebase 設定
- 在 Firebase 控制台中,選取左側導覽面板中的「專案總覽」。按一下「Android」按鈕選取平台。當系統提示您輸入套件名稱時,請使用
com.google.firebase.example.fireeats
- 按一下「註冊應用程式」,然後按照操作說明下載
google-services.json
檔案,並將該檔案移至剛才下載程式碼的app/
資料夾中。然後點選「下一步」。
匯入專案
開啟 Android Studio。依序按一下「File」 >「New」 >「Import Project」,然後選取「friendlyeats-android」資料夾。
4. 設定 Firebase 模擬器
在本程式碼研究室中,您將使用 Firebase 模擬器套件,在本機模擬 Cloud Firestore 和其他 Firebase 服務。這可提供安全、快速且免費的本機開發環境,讓您建構應用程式。
安裝 Firebase CLI
首先,您必須安裝 Firebase CLI。如果您使用的是 macOS 或 Linux,可以執行下列 cURL 指令:
curl -sL https://firebase.tools | bash
如果您使用的是 Windows,請參閱安裝說明,取得獨立二進位檔或透過 npm
安裝。
安裝 CLI 後,執行 firebase --version
應會回報 9.0.0
以上版本:
$ firebase --version 9.0.0
登入
執行 firebase login
,將 CLI 連結至您的 Google 帳戶。系統會開啟新的瀏覽器視窗,完成登入程序。請務必選擇先前用於建立 Firebase 專案的帳戶。
連結專案
在 friendlyeats-android
資料夾中執行 firebase use --add
,將本機專案連結至 Firebase 專案。按照提示選取先前建立的專案,如果系統要求您選擇別名,請輸入 default
。
5. 執行應用程式
接下來,我們要首次執行 Firebase Emulator Suite 和 FriendlyEats Android 應用程式。
執行模擬器
在 friendlyeats-android
目錄的終端機中執行 firebase emulators:start
,即可啟動 Firebase Emulator。您應該會看到類似以下內容的記錄:
$ firebase emulators:start i emulators: Starting emulators: auth, firestore i firestore: Firestore Emulator logging to firestore-debug.log i ui: Emulator UI logging to ui-debug.log ┌─────────────────────────────────────────────────────────────┐ │ ✔ All emulators ready! It is now safe to connect your app. │ │ i View Emulator UI at http://localhost:4000 │ └─────────────────────────────────────────────────────────────┘ ┌────────────────┬────────────────┬─────────────────────────────────┐ │ Emulator │ Host:Port │ View in Emulator UI │ ├────────────────┼────────────────┼─────────────────────────────────┤ │ Authentication │ localhost:9099 │ http://localhost:4000/auth │ ├────────────────┼────────────────┼─────────────────────────────────┤ │ Firestore │ localhost:8080 │ http://localhost:4000/firestore │ └────────────────┴────────────────┴─────────────────────────────────┘ Emulator Hub running at localhost:4400 Other reserved ports: 4500 Issues? Report them at https://github.com/firebase/firebase-tools/issues and attach the *-debug.log files.
您現在已在電腦上執行完整的本機開發環境!請務必在程式碼研究室的後續部分繼續執行這項指令,您的 Android 應用程式需要連線至模擬器。
將應用程式連結至模擬器
在 Android Studio 中開啟 util/FirestoreInitializer.kt
和 util/AuthInitializer.kt
檔案。這些檔案包含邏輯,可在應用程式啟動時,將 Firebase SDK 連結至電腦上執行的本機模擬器。
在 FirestoreInitializer
類別的 create()
方法中,檢查這段程式碼:
// Use emulators only in debug builds
if (BuildConfig.DEBUG) {
firestore.useEmulator(FIRESTORE_EMULATOR_HOST, FIRESTORE_EMULATOR_PORT)
}
我們使用 BuildConfig
,確保應用程式只在 debug
模式下執行時才會連線至模擬器。在 release
模式下編譯應用程式時,這個條件會為 false。
我們可以看到,它使用 useEmulator(host, port)
方法將 Firebase SDK 連結至本機 Firestore 模擬器。在整個應用程式中,我們會使用 FirebaseUtil.getFirestore()
存取這個 FirebaseFirestore
例項,確保在 debug
模式下執行時,一律連線至 Firestore 模擬器。
執行應用程式
如果您已正確新增 google-services.json
檔案,專案現在應該會編譯。在 Android Studio 中,依序按一下「Build」 >「Rebuild Project」,並確認沒有任何錯誤。
在 Android Studio 中,在 Android 模擬器上執行應用程式。一開始會顯示「登入」畫面。您可以使用任何電子郵件和密碼登入應用程式。這個登入程序會連線至 Firebase 驗證模擬器,因此不會傳送任何實際的憑證。
接著,在網路瀏覽器中前往 http://localhost:4000,開啟模擬器 UI。接著按一下「Authentication」分頁標籤,您應該會看到剛剛建立的帳戶:
完成登入程序後,您應該會看到應用程式的主畫面:
我們很快就會新增一些資料,填入主畫面。
6. 將資料寫入 Firestore
在本節中,我們會將部分資料寫入 Firestore,以便填入目前空白的主畫面。
應用程式的主要模型物件是餐廳 (請參閱 model/Restaurant.kt
)。Firestore 資料會分割為文件、集合和子集合。我們會將每間餐廳儲存為文件,並儲存在名為 "restaurants"
的頂層集合中。如要進一步瞭解 Firestore 資料模型,請參閱說明文件中的文件和集合。
為了示範,我們會在應用程式中新增功能,讓使用者在溢位選單中點選「Add Random Items」按鈕時,隨機建立十家餐廳。開啟檔案 MainFragment.kt
,並將 onAddItemsClicked()
方法中的內容替換為:
private fun onAddItemsClicked() {
val restaurantsRef = firestore.collection("restaurants")
for (i in 0..9) {
// Create random restaurant / ratings
val randomRestaurant = RestaurantUtil.getRandom(requireContext())
// Add restaurant
restaurantsRef.add(randomRestaurant)
}
}
請注意以下幾點:
- 我們先取得
"restaurants"
集合的參照。系統會在新增文件時隱含建立集合,因此您不需要在寫入資料前建立集合。 - 您可以使用 Kotlin 資料類別建立文件,我們會使用這些類別建立每個餐廳文件。
add()
方法會將文件新增至集合,並自動產生 ID,因此我們不需要為每間餐廳指定專屬 ID。
現在請再次執行應用程式,然後按一下右上角溢位選單中的「Add Random Items」(新增隨機項目) 按鈕,以叫用您剛才編寫的程式碼:
接著,在網路瀏覽器中前往 http://localhost:4000,開啟模擬器 UI。接著點選「Firestore」分頁,您應該會看到剛新增的資料:
這項資料 100% 是本機電腦的本機資料。事實上,您的實際專案甚至還沒有包含 Firestore 資料庫!也就是說,您可以放心嘗試修改及刪除這類資料,不會造成任何後果。
恭喜,您剛剛將資料寫入 Firestore!在下一個步驟中,我們將說明如何在應用程式中顯示這項資料。
7. 顯示 Firestore 中的資料
在這個步驟中,我們將瞭解如何從 Firestore 擷取資料,並在應用程式中顯示資料。從 Firestore 讀取資料的第一步,就是建立 Query
。開啟檔案 MainFragment.kt
,然後在 onViewCreated()
方法的開頭加入下列程式碼:
// Firestore
firestore = Firebase.firestore
// Get the 50 highest rated restaurants
query = firestore.collection("restaurants")
.orderBy("avgRating", Query.Direction.DESCENDING)
.limit(LIMIT.toLong())
我們現在要監聽查詢,以便取得所有符合條件的文件,並即時收到日後的更新通知。由於最終目標是將這項資料繫結至 RecyclerView
,因此我們需要建立 RecyclerView.Adapter
類別來監聽資料。
開啟已部分實作的 FirestoreAdapter
類別。首先,讓轉接器實作 EventListener
,並定義 onEvent
函式,以便接收 Firestore 查詢的更新內容:
abstract class FirestoreAdapter<VH : RecyclerView.ViewHolder>(private var query: Query?) :
RecyclerView.Adapter<VH>(),
EventListener<QuerySnapshot> { // Add this implements
// ...
// Add this method
override fun onEvent(documentSnapshots: QuerySnapshot?, e: FirebaseFirestoreException?) {
// Handle errors
if (e != null) {
Log.w(TAG, "onEvent:error", e)
return
}
// Dispatch the event
if (documentSnapshots != null) {
for (change in documentSnapshots.documentChanges) {
// snapshot of the changed document
when (change.type) {
DocumentChange.Type.ADDED -> {
// TODO: handle document added
}
DocumentChange.Type.MODIFIED -> {
// TODO: handle document changed
}
DocumentChange.Type.REMOVED -> {
// TODO: handle document removed
}
}
}
}
onDataChanged()
}
// ...
}
在初始載入時,事件監聽器會為每份新文件接收一個 ADDED
事件。隨著查詢的結果集隨時間變動,監聽器會收到更多包含變更的事件。現在,讓我們完成事件監聽器的實作。首先新增三個方法:onDocumentAdded
、onDocumentModified
和 onDocumentRemoved
:
private fun onDocumentAdded(change: DocumentChange) {
snapshots.add(change.newIndex, change.document)
notifyItemInserted(change.newIndex)
}
private fun onDocumentModified(change: DocumentChange) {
if (change.oldIndex == change.newIndex) {
// Item changed but remained in same position
snapshots[change.oldIndex] = change.document
notifyItemChanged(change.oldIndex)
} else {
// Item changed and changed position
snapshots.removeAt(change.oldIndex)
snapshots.add(change.newIndex, change.document)
notifyItemMoved(change.oldIndex, change.newIndex)
}
}
private fun onDocumentRemoved(change: DocumentChange) {
snapshots.removeAt(change.oldIndex)
notifyItemRemoved(change.oldIndex)
}
然後從 onEvent
呼叫這些新方法:
override fun onEvent(documentSnapshots: QuerySnapshot?, e: FirebaseFirestoreException?) {
// Handle errors
if (e != null) {
Log.w(TAG, "onEvent:error", e)
return
}
// Dispatch the event
if (documentSnapshots != null) {
for (change in documentSnapshots.documentChanges) {
// snapshot of the changed document
when (change.type) {
DocumentChange.Type.ADDED -> {
onDocumentAdded(change) // Add this line
}
DocumentChange.Type.MODIFIED -> {
onDocumentModified(change) // Add this line
}
DocumentChange.Type.REMOVED -> {
onDocumentRemoved(change) // Add this line
}
}
}
}
onDataChanged()
}
最後,實作 startListening()
方法來附加事件監聽器:
fun startListening() {
if (registration == null) {
registration = query.addSnapshotListener(this)
}
}
應用程式現在已完全設定,可讀取 Firestore 中的資料。再次執行應用程式,您應該會看到先前步驟中新增的餐廳:
現在,請返回瀏覽器中的模擬器 UI,並編輯其中一個餐廳名稱。你應該會在應用程式中立即看到變更!
8. 排序及篩選資料
應用程式目前會顯示整個資料集內評分最高的餐廳,但在實際的餐廳應用程式中,使用者會想要排序及篩選資料。舉例來說,應用程式應能顯示「費城最佳海鮮餐廳」或「最便宜的披薩」。
按一下應用程式頂端的白色列,即可顯示篩選器對話方塊。在本節中,我們將使用 Firestore 查詢來讓這個對話方塊運作:
讓我們編輯 MainFragment.kt
的 onFilter()
方法。這個方法會接受 Filters
物件,這是我們建立的輔助物件,用於擷取篩選器對話方塊的輸出內容。我們會變更這個方法,以便從篩選條件建構查詢:
override fun onFilter(filters: Filters) {
// Construct query basic query
var query: Query = firestore.collection("restaurants")
// Category (equality filter)
if (filters.hasCategory()) {
query = query.whereEqualTo(Restaurant.FIELD_CATEGORY, filters.category)
}
// City (equality filter)
if (filters.hasCity()) {
query = query.whereEqualTo(Restaurant.FIELD_CITY, filters.city)
}
// Price (equality filter)
if (filters.hasPrice()) {
query = query.whereEqualTo(Restaurant.FIELD_PRICE, filters.price)
}
// Sort by (orderBy with direction)
if (filters.hasSortBy()) {
query = query.orderBy(filters.sortBy.toString(), filters.sortDirection)
}
// Limit items
query = query.limit(LIMIT.toLong())
// Update the query
adapter.setQuery(query)
// Set header
binding.textCurrentSearch.text = HtmlCompat.fromHtml(
filters.getSearchDescription(requireContext()),
HtmlCompat.FROM_HTML_MODE_LEGACY
)
binding.textCurrentSortBy.text = filters.getOrderDescription(requireContext())
// Save filters
viewModel.filters = filters
}
在上方程式碼片段中,我們會附加 where
和 orderBy
子句,以符合指定的篩選器,藉此建構 Query
物件。
再次執行應用程式,然後選取下列篩選器,顯示最受歡迎的低價餐廳:
你現在應該會看到篩選過的餐廳清單,只包含低價餐廳:
到目前為止,您已在 Firestore 上建構出功能完備的餐廳推薦內容查看應用程式!你現在可以即時排序及篩選餐廳。在接下來的幾個章節中,我們會為餐廳新增評論,並在應用程式中新增安全性規則。
9. 整理子集合中的資料
在本節中,我們會在應用程式中新增評分功能,讓使用者評論自己最喜歡 (或最不喜歡) 的餐廳。
集合和子集合
目前,我們已將所有餐廳資料儲存在名為「restaurants」的頂層集合中。當使用者為餐廳評分時,我們希望為餐廳新增 Rating
物件。在這個工作中,我們會使用子集合。您可以將子集合視為附加至文件的集合。因此,每份餐廳文件都會有一個包含評分文件的評分子集合。子集合可協助整理資料,不必讓文件變得臃腫或需要複雜的查詢。
如要存取子集合,請在父項文件上呼叫 .collection()
:
val subRef = firestore.collection("restaurants")
.document("abc123")
.collection("ratings")
您可以存取及查詢子集合,就像使用頂層集合一樣,沒有大小限制或效能變化。如要進一步瞭解 Firestore 資料模型,請參閱本文。
在交易中寫入資料
只要呼叫 .add()
,即可將 Rating
新增至適當的子集合,但我們也需要更新 Restaurant
物件的平均評分和評分數量,以反映新資料。如果我們使用不同的作業來進行這兩項變更,就會出現許多競爭狀態,導致資料過時或不正確。
為確保評分正確新增,我們會使用交易為餐廳新增評分。這筆交易會執行以下幾項動作:
- 讀取餐廳目前的評分,並計算新的評分
- 將評分新增至子集
- 更新餐廳的平均評分和評分數量
開啟 RestaurantDetailFragment.kt
並實作 addRating
函式:
private fun addRating(restaurantRef: DocumentReference, rating: Rating): Task<Void> {
// Create reference for new rating, for use inside the transaction
val ratingRef = restaurantRef.collection("ratings").document()
// In a transaction, add the new rating and update the aggregate totals
return firestore.runTransaction { transaction ->
val restaurant = transaction.get(restaurantRef).toObject<Restaurant>()
?: throw Exception("Restaurant not found at ${restaurantRef.path}")
// Compute new number of ratings
val newNumRatings = restaurant.numRatings + 1
// Compute new average rating
val oldRatingTotal = restaurant.avgRating * restaurant.numRatings
val newAvgRating = (oldRatingTotal + rating.rating) / newNumRatings
// Set new restaurant info
restaurant.numRatings = newNumRatings
restaurant.avgRating = newAvgRating
// Commit to Firestore
transaction.set(restaurantRef, restaurant)
transaction.set(ratingRef, rating)
null
}
}
addRating()
函式會傳回代表整個交易的 Task
。在 onRating()
函式中,事件監聽器會新增至工作,以回應交易結果。
現在請再次執行應用程式,然後點選其中一個餐廳,系統應會顯示餐廳詳細資料畫面。按一下「+」按鈕,即可開始新增評論。選取星級並輸入文字,即可新增評論。
按下「提交」即可啟動交易。交易完成後,你會看到下方顯示的評論,以及餐廳的評論數量更新:
恭喜!您現在已擁有以 Cloud Firestore 建構的行動社交餐廳評論應用程式。聽說這些產品現在很受歡迎。
10. 保護資料安全
目前我們尚未考量這項應用程式的安全性。我們如何知道使用者只能讀取及寫入正確的自身資料?Firestore 資料庫的安全性由名為「安全性規則」的設定檔維護。
開啟 firestore.rules
檔案,並將內容替換為以下內容:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Determine if the value of the field "key" is the same
// before and after the request.
function isUnchanged(key) {
return (key in resource.data)
&& (key in request.resource.data)
&& (resource.data[key] == request.resource.data[key]);
}
// Restaurants
match /restaurants/{restaurantId} {
// Any signed-in user can read
allow read: if request.auth != null;
// Any signed-in user can create
// WARNING: this rule is for demo purposes only!
allow create: if request.auth != null;
// Updates are allowed if no fields are added and name is unchanged
allow update: if request.auth != null
&& (request.resource.data.keys() == resource.data.keys())
&& isUnchanged("name");
// Deletes are not allowed.
// Note: this is the default, there is no need to explicitly state this.
allow delete: if false;
// Ratings
match /ratings/{ratingId} {
// Any signed-in user can read
allow read: if request.auth != null;
// Any signed-in user can create if their uid matches the document
allow create: if request.auth != null
&& request.resource.data.userId == request.auth.uid;
// Deletes and updates are not allowed (default)
allow update, delete: if false;
}
}
}
}
這些規則會限制存取權,確保用戶端只進行安全的變更。舉例來說,餐廳文件的更新內容只能變更評分,而無法變更名稱或任何其他不可變更的資料。只有在使用者 ID 與已登入使用者相符的情況下,才能建立評分,這可防止冒用行為。
如要進一步瞭解安全性規則,請參閱說明文件。
11. 結論
您現在已在 Firestore 上建立功能完整的應用程式。您已瞭解最重要的 Firestore 功能,包括:
- 文件和集合
- 讀取及寫入資料
- 使用查詢排序及篩選
- 子集合
- 交易
瞭解詳情
如要進一步瞭解 Firestore,請參閱以下資源:
本程式碼研究室中的餐廳應用程式是以「Friendly Eats」範例應用程式為基礎。您可以前往這個頁面瀏覽該應用程式的原始碼。
選用:部署至正式環境
到目前為止,這個應用程式只使用 Firebase 模擬器套件。如要瞭解如何將此應用程式部署至實際的 Firebase 專案,請繼續進行下一個步驟。
12. (選用) 部署應用程式
到目前為止,這個應用程式完全在本機執行,所有資料都包含在 Firebase 模擬器套件中。在本節中,您將瞭解如何設定 Firebase 專案,讓這個應用程式能在正式環境中運作。
Firebase 驗證
在 Firebase 控制台中前往「驗證」專區,然後按一下「開始使用」。前往「登入方式」分頁,然後從「原生供應商」選取「電子郵件/密碼」選項。
啟用「電子郵件/密碼」登入方式,然後按一下「儲存」。
Firestore
建立資料庫
前往控制台的「Firestore 資料庫」專區,然後按一下「建立資料庫」:
- 當系統提示您選擇以「正式版模式」啟動安全性規則時,我們會盡快更新這些規則。
- 選擇要用於應用程式的資料庫位置。請注意,選取資料庫位置是永久性決定,如要變更,您必須建立新專案。如要進一步瞭解如何選擇專案位置,請參閱說明文件。
部署規則
如要部署先前編寫的安全性規則,請在程式碼研究室目錄中執行下列指令:
$ firebase deploy --only firestore:rules
這會將 firestore.rules
的內容部署至專案,您可以前往控制台的「規則」分頁確認。
部署索引
FriendlyEats 應用程式有複雜的排序和篩選功能,因此需要多個自訂複合式索引。您可以手動在 Firebase 控制台中建立這些定義,但在 firestore.indexes.json
檔案中編寫定義,然後使用 Firebase CLI 部署定義,會更簡單。
開啟 firestore.indexes.json
檔案後,您會看到已提供必要的索引:
{
"indexes": [
{
"collectionId": "restaurants",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "city", "mode": "ASCENDING" },
{ "fieldPath": "avgRating", "mode": "DESCENDING" }
]
},
{
"collectionId": "restaurants",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "category", "mode": "ASCENDING" },
{ "fieldPath": "avgRating", "mode": "DESCENDING" }
]
},
{
"collectionId": "restaurants",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "price", "mode": "ASCENDING" },
{ "fieldPath": "avgRating", "mode": "DESCENDING" }
]
},
{
"collectionId": "restaurants",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "city", "mode": "ASCENDING" },
{ "fieldPath": "numRatings", "mode": "DESCENDING" }
]
},
{
"collectionId": "restaurants",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "category", "mode": "ASCENDING" },
{ "fieldPath": "numRatings", "mode": "DESCENDING" }
]
},
{
"collectionId": "restaurants",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "price", "mode": "ASCENDING" },
{ "fieldPath": "numRatings", "mode": "DESCENDING" }
]
},
{
"collectionId": "restaurants",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "city", "mode": "ASCENDING" },
{ "fieldPath": "price", "mode": "ASCENDING" }
]
},
{
"collectionId": "restaurants",
"fields": [
{ "fieldPath": "category", "mode": "ASCENDING" },
{ "fieldPath": "price", "mode": "ASCENDING" }
]
}
],
"fieldOverrides": []
}
如要部署這些索引,請執行下列指令:
$ firebase deploy --only firestore:indexes
請注意,建立索引並非一蹴可幾,您可以在 Firebase 主控台中監控進度。
設定應用程式
在 util/FirestoreInitializer.kt
和 util/AuthInitializer.kt
檔案中,我們將 Firebase SDK 設為在偵錯模式下連線至模擬器:
override fun create(context: Context): FirebaseFirestore {
val firestore = Firebase.firestore
// Use emulators only in debug builds
if (BuildConfig.DEBUG) {
firestore.useEmulator(FIRESTORE_EMULATOR_HOST, FIRESTORE_EMULATOR_PORT)
}
return firestore
}
如要使用實際的 Firebase 專案測試應用程式,您可以:
- 在版本模式中建構應用程式,並在裝置上執行該應用程式。
- 將
BuildConfig.DEBUG
暫時替換為false
,然後再次執行應用程式。
請注意,您可能需要登出應用程式,然後重新登入,才能正確連線至正式版。