1. 總覽
目標
在這個程式碼研究室中,您將在 Android 上建構由 Cloud Firestore 支援的 Android 餐廳推薦應用程式。您將學習下列內容:
- 從 Android 應用程式讀取資料並將其寫入 Firestore
- 即時監聽 Firestore 資料異動內容
- 使用 Firebase 驗證和安全性規則保護 Firestore 資料
- 編寫複雜的 Firestore 查詢
事前準備
開始進行本程式碼研究室之前,請確認您已經:
- Android Studio Flamingo 以上版本
- 搭載 API 19 以上版本的 Android 模擬器
- Node.js 16 以上版本
- Java 17 以上版本
2. 建立 Firebase 專案
- 使用 Google 帳戶登入 Firebase 控制台。
- 在 Firebase 控制台,按一下「新增專案」。
- 如下方的螢幕截圖所示,輸入 Firebase 專案名稱 (例如「友善餐點」),然後按一下「繼續」。
- 系統可能會請您啟用 Google Analytics,因此在本程式碼研究室中,您的選擇無關緊要。
- 大約一分鐘後,即可準備 Firebase 專案。按一下 [繼續]。
3. 設定範例專案
下載程式碼
執行下列指令,複製本程式碼研究室的程式碼範例。這項操作會在您的電腦上建立名為 friendlyeats-android
的資料夾:
$ git clone https://github.com/firebase/friendlyeats-android
如果您的機器中沒有 Git,您也可以直接從 GitHub 下載程式碼。
新增 Firebase 設定
- 在 Firebase 控制台,選取左側導覽列的「Project Overview」。按一下「Android」按鈕選取平台。系統提示您輸入套件名稱時,使用
com.google.firebase.example.fireeats
- 按一下「Register App」,然後按照操作說明下載
google-services.json
檔案,然後移至剛才下載程式碼的app/
資料夾。接著點選「下一步」。
匯入專案
開啟 Android Studio。按一下「檔案」>新增 >匯入專案並選取 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 套件和 SessionsEats Android 應用程式。
執行模擬器
從 friendlyeats-android
目錄中的終端機執行 firebase emulators:start
,以啟動 Firebase 模擬器。您應該會看到類似下方的記錄:
$ 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」時,在應用程式中加入新功能,隨機建立 10 家餐廳按鈕。開啟 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 資料類別建立文件,並使用 Kotlin 資料類別建立每份餐廳的說明文件。
add()
方法會使用自動產生的 ID,將文件新增至含有自動產生的 ID 的集合,因此我們不需要為每個餐廳指定專屬 ID。
現在,請再次執行應用程式,並按一下「Add Random Items」溢位選單的右上角按鈕,叫用您剛才編寫的程式碼:
現在,在網路瀏覽器中前往 http://localhost:4000,開啟模擬器 UI。接著按一下「Firestore」分頁標籤,您應該會看到剛剛新增的資料:
這項資料完全來自您的電腦。事實上,您的真實專案甚至還沒有 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 中的資料。再次執行應用程式,您應該會看到在上一步新增的餐廳:
現在,請在瀏覽器中返回 Android Emulator 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. 整理子集合中的資料
本節將新增評分至應用程式,方便使用者評論自己喜愛的 (或最不喜愛的) 餐廳。
集合和子集合
目前為止,所有餐廳資料都儲存在一個名為「餐廳」的頂層集合中。如果使用者為餐廳評分,我們想在餐廳中新增 Rating
物件。在這項工作中,我們會使用子集合。子集合可以視為與文件附加的集合。因此,每份餐廳文件都會有一個評分子集合。子集合可協助整理資料,而且不必為文件帶來麻煩或執行複雜的查詢。
如要存取子集合,請在父項文件上呼叫 .collection()
:
val subRef = firestore.collection("restaurants")
.document("abc123")
.collection("ratings")
您可以存取及查詢子集合,就像使用頂層集合一樣,不會影響大小限製或成效變化。如要進一步瞭解 Firestore 資料模型,請參閱本文。
在交易中寫入資料
如要將 Rating
新增至適當的子集合,只需呼叫 .add()
,但我們也需更新 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. 保護您的資料
目前為止,我們尚未考慮這個應用程式的安全性。Google 如何得知使用者只能讀取及寫入正確的資料?由名為「安全性規則」的設定檔保護 Firestore 資料庫。
開啟 firestore.rules
檔案,應會顯示以下內容:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
//
// WARNING: These rules are insecure! We will replace them with
// more secure rules later in the codelab
//
allow read, write: if request.auth != null;
}
}
}
請變更這些規則,防止不必要的資料存取或變更,請開啟 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,可以先從以下這些優質位置著手:
本程式碼研究室中的餐廳應用程式是以「友善美食」為基礎應用程式範例您可以在這裡瀏覽該應用程式的原始碼。
選用:部署至正式環境
這個應用程式目前只使用 Firebase Emulator 套件。如要瞭解如何將這個應用程式部署至實際的 Firebase 專案,請繼續下一個步驟。
12. (選用) 部署應用程式
到目前為止,這個應用程式已完全在本機環境中,所有資料皆包含在 Firebase 模擬器套件中。本節將說明如何設定 Firebase 專案,讓應用程式在正式環境中運作。
Firebase 驗證
在 Firebase 控制台中,前往「驗證」專區,然後按一下「開始使用」。前往「登入方式」分頁,然後從「原生供應商」中選取「電子郵件/密碼」選項。
啟用「電子郵件/密碼」登入方式,然後按一下「儲存」。
Firestore
建立資料庫
前往控制台的「Firestore 資料庫」部分,然後按一下「建立資料庫」:
- 系統提示您選擇在正式環境模式啟動安全性規則時,我們會盡快更新這些規則。
- 選擇要用於應用程式的資料庫位置。請注意,選取資料庫位置是「永久」的決定,如要變更位置,必須建立新專案。如要進一步瞭解如何選擇專案位置,請參閱說明文件。
部署規則
如要部署您先前撰寫的安全性規則,請在程式碼研究室目錄中執行下列指令:
$ firebase deploy --only firestore:rules
這樣會將 firestore.rules
的內容部署至您的專案。您可以前往控制台的「規則」分頁進行確認。
部署索引
SessionsEats 應用程式提供複雜的排序和篩選功能,需要使用多個自訂化合物索引。您可在 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
,然後再次執行應用程式。
請注意,您可能需要登出應用程式並再次登入,才能正確連線至正式版。