在 Day 14 完成 Firebase Authentication 後,今天我們要深入探討 Cloud Firestore,學習如何設計和優化即時資料庫。在 CrewUp 專案中,我們需要處理活動資料、聊天訊息、通知等多種即時資料,Firestore 提供了完整的解決方案。
從實際開發經驗來看,Firestore 的即時同步功能讓我們可以輕鬆建立動態的社群應用,使用者可以即時看到活動更新、新訊息通知等。結合我們在 Day 1-13 建立的 Clean Architecture、狀態管理等基礎設施,讓我們可以專注在核心功能開發上。
在 CrewUp 專案中,我們選擇 Firestore 的考量主要有這幾個面向:
🚀 即時同步很實用
📱 離線支援很強大
🔧 查詢功能很靈活
在設計 Firestore 資料結構時,我們面臨一個重要的設計決策:正規化 vs 反正規化。
🎯 我們的選擇:反正規化策略
在 CrewUp 專案中,我們選擇了反正規化的設計方式:
// 活動文件結構(反正規化)
{
"id": "activity_123",
"title": "週末爬山活動",
"description": "一起來爬山吧!",
"founderId": "user_456",
"founderName": "張小明", // 嵌入創建者姓名
"founderPhotoUrl": "https://...", // 嵌入創建者頭像
"createdAt": "2024-01-15T10:00:00Z",
"participants": [
{
"userId": "user_456",
"name": "張小明", // 嵌入參與者姓名
"photoUrl": "https://..." // 嵌入參與者頭像
}
]
}
📊 權衡分析:
✅ 優點(讀取效能優化)
user_profiles
集合❌ 缺點(維護複雜度)
💡 解決方案:Cloud Functions 自動同步
為了處理反正規化帶來的維護問題,我們可以使用 Cloud Functions 來實現自動同步:
// Cloud Function:使用者資料變更時自動同步
exports.syncUserData = functions.firestore
.document('user_profiles/{userId}')
.onUpdate(async (change, context) => {
const newData = change.after.data();
const oldData = change.before.data();
// 如果姓名或頭像有變更,同步更新相關活動
if (newData.displayName !== oldData.displayName ||
newData.photoUrl !== oldData.photoUrl) {
const batch = admin.firestore().batch();
// 更新使用者創建的活動
const activitiesSnapshot = await admin.firestore()
.collection('activities')
.where('founderId', '==', context.params.userId)
.get();
activitiesSnapshot.docs.forEach(doc => {
batch.update(doc.ref, {
founderName: newData.displayName,
founderPhotoUrl: newData.photoUrl
});
});
await batch.commit();
}
});
📋 設計建議:
在 CrewUp 專案中,我們使用了以下核心依賴:
# pubspec.yaml
dependencies:
cloud_firestore: ^6.0.1 # Firestore 資料庫
firebase_core: ^4.1.0 # Firebase 核心
connectivity_plus: ^6.1.0 # 網路狀態檢測
flutter_riverpod: ^2.6.1 # 狀態管理
在 CrewUp 專案中,我們遵循 Clean Architecture 原則,將 Firestore 操作分層實作:
🔧 Data Layer - ActivityFirebaseDataSource
// lib/features/activity/data/datasources/activity_firebase_datasource.dart
// (imports omitted)
class ActivityFirebaseDataSource implements ActivityDataSource {
final FirebaseFirestore _firestore;
final UserInfoService _userInfoService;
static const String _collectionName = 'activities';
ActivityFirebaseDataSource({
FirebaseFirestore? firestore,
required UserInfoService userInfoService,
}) : _firestore = firestore ?? FirebaseFirestore.instance,
_userInfoService = userInfoService;
@override
Future<List<Activity>> getAllActivities() async {
// ⚠️ 注意:此方法僅用於開發測試,生產環境應使用分頁查詢
// 一次性讀取整個集合會隨著資料增長導致效能和費用問題
try {
final querySnapshot = await _firestore
.collection(_collectionName)
.orderBy('createdAt', descending: true)
.limit(50) // 限制讀取數量,避免過度消耗
.get();
final activities = querySnapshot.docs
.map((doc) => _documentSnapshotToActivity(doc))
.where((activity) => activity != null)
.cast<Activity>()
.toList();
return activities;
} on FirebaseException catch (e) {
throw NetworkException('Failed to fetch activities: ${e.message}');
}
}
/// 分頁查詢活動(推薦用於生產環境)
///
/// ✅ 使用分頁查詢的優點:
/// - 控制每次讀取的資料量,避免效能問題
/// - 減少網路傳輸和費用
/// - 提供更好的使用者體驗(漸進式載入)
Future<List<Activity>> getActivitiesWithPagination({
int limit = 20,
DocumentSnapshot? lastDocument,
}) async {
try {
Query<Map<String, dynamic>> query = _firestore
.collection(_collectionName)
.orderBy('createdAt', descending: true)
.limit(limit);
if (lastDocument != null) {
query = query.startAfterDocument(lastDocument);
}
final querySnapshot = await query.get();
final activities = querySnapshot.docs
.map((doc) => _documentToActivity(doc))
.where((activity) => activity != null)
.cast<Activity>()
.toList();
return activities;
} on FirebaseException catch (e) {
throw NetworkException('Failed to fetch activities with pagination: ${e.message}');
}
}
}
📡 即時監聽活動變化
// lib/features/activity/data/datasources/activity_firebase_datasource.dart
// (imports omitted)
/// 即時監聽所有活動變化
///
/// ⚠️ 重要:即時監聽器會產生費用,每次文件變更都會觸發讀取
/// ✅ 使用 Riverpod 的 StreamProvider 可以自動管理監聽器生命週期
Stream<List<Activity>> watchAllActivities() {
return _firestore
.collection(_collectionName)
.orderBy('createdAt', descending: true)
.snapshots()
.map((snapshot) {
final activities = snapshot.docs
.map((doc) => _documentSnapshotToActivity(doc))
.where((activity) => activity != null)
.cast<Activity>()
.toList();
developer.log('📡 Real-time update - ${activities.length} activities');
return activities;
}).handleError((error) {
developer.log('❌ Real-time listener failed: $error');
throw NetworkException('Real-time listener failed: $error');
});
}
/// 即時監聽特定活動變化
Stream<Activity?> watchActivityById(String id) {
return _firestore
.collection(_collectionName)
.doc(id)
.snapshots()
.map((snapshot) {
if (!snapshot.exists) {
developer.log('⚠️ Activity not found in real-time: $id');
return null;
}
final activity = _documentSnapshotToActivity(snapshot);
developer.log('📡 Real-time update for activity: ${activity?.title}');
return activity;
}).handleError((error) {
developer.log('❌ Real-time listener failed: $error');
throw NetworkException('Real-time listener failed: $error');
});
}
🔄 Riverpod 自動生命週期管理
使用 Riverpod 的 StreamProvider 可以非常優雅地自動管理監聽器生命週期:
// lib/features/activity/presentation/providers/activity_provider.dart
// (imports omitted)
@riverpod
Stream<List<Activity>> activitiesStream(ActivitiesStreamRef ref) {
final dataSource = ref.read(activityFirebaseDataSourceProvider);
return dataSource.watchAllActivities();
}
// 在 UI 中使用
class ActivityListScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final activitiesAsync = ref.watch(activitiesStreamProvider);
return activitiesAsync.when(
data: (activities) => ListView.builder(
itemCount: activities.length,
itemBuilder: (context, index) => ActivityCard(activity: activities[index]),
),
loading: () => const CircularProgressIndicator(),
error: (error, stack) => Text('錯誤: $error'),
);
}
}
✅ 生命週期管理的好處:
✅ 自動資源管理:當沒有任何 UI 元件監聽時,Riverpod 會自動銷毀 StreamProvider
✅ 避免記憶體洩漏:防止監聽器在背景持續運行
✅ 節省費用:避免不必要的 Firestore 讀取費用
✅ 提升效能:減少背景資源消耗
在 CrewUp 專案中,我們使用 LocalizationService
來提供多國語系的錯誤訊息:
// lib/features/activity/data/datasources/activity_firebase_datasource.dart
import 'package:crew_up/app/core/services/localization_service.dart';
@override
Future<void> joinActivity(String activityId, String userId) async {
try {
await _firestore.runTransaction((transaction) async {
final activityDoc = await transaction.get(activityRef);
if (!activityDoc.exists) {
throw AppException.notFound(LocalizationService.instance.current.activityNotFound);
}
// 檢查用戶是否已經參與
final isAlreadyParticipant = participants.any((p) => p['id'] == userId);
if (isAlreadyParticipant) {
throw AppException.validation(LocalizationService.instance.current.userAlreadyJoined);
}
// 檢查活動是否已滿
if (participants.length >= maxParticipants) {
throw AppException.validation(LocalizationService.instance.current.activityFull);
}
});
} on FirebaseException catch (e) {
// 根據 Firebase 錯誤碼提供更具體的錯誤訊息
switch (e.code) {
case 'permission-denied':
throw AppException.authentication(LocalizationService.instance.current.activityPermissionDenied);
case 'not-found':
throw AppException.notFound(LocalizationService.instance.current.activityNotFound);
case 'failed-precondition':
throw AppException.validation(LocalizationService.instance.current.activityFull);
case 'unavailable':
throw NetworkException(LocalizationService.instance.current.serviceUnavailable);
default:
throw NetworkException(LocalizationService.instance.current.activityJoinFailed);
}
}
}
🌍 多國語系錯誤訊息的優點:
✅ 使用者體驗優化:根據使用者語言顯示適當的錯誤訊息
✅ 維護性提升:集中管理所有錯誤訊息,便於更新
✅ 一致性保證:確保所有錯誤訊息使用相同的語系
✅ 擴展性良好:容易添加新的語言支援
📄 多國語系檔案範例:
// lib/l10n/app_en.arb
{
"activityNotFound": "Activity not found",
"activityFull": "Activity is full, cannot join",
"userAlreadyJoined": "You have already joined this activity",
"activityJoinFailed": "Failed to join activity",
"activityLeaveFailed": "Failed to leave activity",
"serviceUnavailable": "Service temporarily unavailable, please try again later"
}
// lib/l10n/app_zh.arb
{
"activityNotFound": "找不到活動",
"activityFull": "活動已滿,無法加入",
"userAlreadyJoined": "您已經加入此活動",
"activityJoinFailed": "加入活動失敗",
"activityLeaveFailed": "退出活動失敗",
"serviceUnavailable": "服務暫時不可用,請稍後再試"
}
在 CrewUp 專案中,我們使用 Riverpod 的 StreamProvider:
// lib/features/activity/presentation/screens/activity_list_screen.dart
// (imports omitted)
class ActivityListScreen extends ConsumerWidget {
const ActivityListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final activitiesAsync = ref.watch(activitiesStreamProvider);
return Scaffold(
appBar: AppBar(title: const Text('活動列表')),
body: activitiesAsync.when(
data: (activities) {
if (activities.isEmpty) {
return const Center(child: Text('目前沒有活動'));
}
return ListView.builder(
itemCount: activities.length,
itemBuilder: (context, index) {
final activity = activities[index];
return ActivityCard(activity: activity);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('載入失敗: $error'),
ElevatedButton(
onPressed: () => ref.invalidate(activitiesStreamProvider),
child: const Text('重新載入'),
),
],
),
),
),
);
}
}
在 CrewUp 專案中,我們建立的規則:
// firestore.rules
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// =================================
// 輔助函式 (Helper Functions)
// =================================
// 檢查使用者是否已登入
function isSignedIn() {
return request.auth != null;
}
// 檢查使用者是否為資源的擁有者
function isOwner(userId) {
return isSignedIn() && request.auth.uid == userId;
}
// 檢查使用者是否為聊天室成員
function isChatParticipant(chatId) {
return isSignedIn() && request.auth.uid in get(/databases/$(database)/documents/chats/$(chatId)).data.participants;
}
// =================================
// 集合規則 (Collection Rules)
// =================================
match /user_profiles/{userId} {
// 擁有者才能讀取自己的資料
allow read: if isOwner(userId);
// 任何人都能建立自己的帳號,但需通過資料驗證
allow create: if isOwner(userId)
&& request.resource.data.email == request.auth.token.email // 確保 email 與 token 一致
&& request.resource.data.displayName is string
&& request.resource.data.displayName.size() > 0
&& request.resource.data.displayName.size() <= 50
&& request.resource.data.createdAt == request.time // 強制使用伺服器時間
&& request.resource.data.updatedAt == request.time;
// 擁有者才能更新自己的資料,且部分資料不可變
allow update: if isOwner(userId)
&& request.resource.data.email == resource.data.email // email 不可變
&& request.resource.data.createdAt == resource.data.createdAt // createdAt 不可變
&& request.resource.data.displayName is string
&& request.resource.data.displayName.size() > 0
&& request.resource.data.displayName.size() <= 50
&& request.resource.data.updatedAt == request.time; // updatedAt 必須是伺服器時間
}
match /activities/{activityId} {
// 登入使用者才能讀取活動(收緊公開讀取權限)
allow read: if isSignedIn();
// 登入使用者才能建立活動,且 founderId 必須是自己
allow create: if isSignedIn()
&& request.resource.data.founderId == request.auth.uid
&& request.resource.data.title is string
&& request.resource.data.title.size() > 0
&& request.resource.data.title.size() <= 100
&& request.resource.data.createdAt == request.time
&& request.resource.data.updatedAt == request.time;
// 只有創建者可以更新或刪除
allow update: if isSignedIn()
&& resource.data.founderId == request.auth.uid
&& request.resource.data.founderId == resource.data.founderId // founderId 不可變
&& request.resource.data.createdAt == resource.data.createdAt // createdAt 不可變
&& request.resource.data.updatedAt == request.time;
allow delete: if isSignedIn() && resource.data.founderId == request.auth.uid;
}
match /chats/{chatId} {
// 只有聊天室成員可以讀、寫、刪
allow read, update, delete: if isChatParticipant(chatId);
// 建立聊天室時,建立者必須是自己且在參與者列表中
allow create: if isSignedIn()
&& request.resource.data.createdBy == request.auth.uid
&& request.auth.uid in request.resource.data.participants
&& request.resource.data.participants is list
&& request.resource.data.participants.size() >= 2
&& request.resource.data.createdAt == request.time;
}
}
}
🛡️ 最小權限原則
🔒 資料驗證與不可變性
⚙️ 函式化設計
📋 分離 Create 與 Update 驗證
createdAt
只在建立時設定,updatedAt
在每次更新時刷新🔐 收緊公開資料存取權限
allow read: if true
改為 allow read: if isSignedIn()
📧 Email 與 Token 一致性驗證
在 CrewUp 專案中,我們建立了專門的網路狀態監聽服務:
// lib/common/services/network_status_service.dart
// (imports omitted)
/// 網路狀態監聽服務
///
/// 提供網路狀態檢測和監聽功能,讓 App 的 UI 能夠感知網路狀態變化
/// 注意:Firestore 的離線快取和同步完全由其 SDK 自動處理,此服務僅用於 UI 狀態提示
class NetworkStatusService {
final FirebaseFirestore _firestore;
final Connectivity _connectivity;
NetworkStatusService({
FirebaseFirestore? firestore,
Connectivity? connectivity,
}) : _firestore = firestore ?? FirebaseFirestore.instance,
_connectivity = connectivity ?? Connectivity();
/// 檢查網路狀態
Future<bool> isOnline() async {
try {
final connectivityResult = await _connectivity.checkConnectivity();
final isOnline = !connectivityResult.contains(ConnectivityResult.none);
developer.log('🌐 網路狀態: ${isOnline ? "線上" : "離線"}');
return isOnline;
} on Exception catch (e) {
developer.log('❌ 檢查網路狀態失敗: $e');
return false;
}
}
/// 監聽網路狀態變化
Stream<bool> watchNetworkStatus() => _connectivity.onConnectivityChanged.map((result) {
final isOnline = !result.contains(ConnectivityResult.none);
developer.log('🌐 網路狀態變化: ${isOnline ? "線上" : "離線"}');
return isOnline;
});
}
📱 Firestore 自動離線處理
🔄 UI 狀態提示
NetworkStatusService
監聽網路狀態⚡ 離線時的功能
在 CrewUp 專案中,我們建立了完整的索引設定:
// firestore.indexes.json
{
"indexes": [
{
"collectionGroup": "activities",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "category",
"order": "ASCENDING"
},
{
"fieldPath": "createdAt",
"order": "DESCENDING"
}
]
},
{
"collectionGroup": "activities",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "status",
"order": "ASCENDING"
},
{
"fieldPath": "createdAt",
"order": "DESCENDING"
}
]
},
{
"collectionGroup": "messages",
"queryScope": "COLLECTION_GROUP",
"fields": [
{
"fieldPath": "chatId",
"order": "ASCENDING"
},
{
"fieldPath": "timestamp",
"order": "DESCENDING"
}
]
}
]
}
📄 分頁查詢的重要性
getActivitiesWithPagination()
進行分頁查詢getAllActivities()
一次性讀取整個集合🔍 分頁查詢實作
limit()
限制每次查詢的資料量startAfterDocument()
實作游標分頁offset()
在大資料集上的效能問題🔗 複合查詢
💾 快取策略
在 CrewUp 專案中,我們為 Firestore 模組實作了完整的測試覆蓋:
單元測試:測試 Firestore 操作的核心邏輯
整合測試:測試完整的資料流程
即時監聽測試:測試 Stream 的即時更新
測試涵蓋了正常查詢、錯誤處理、離線支援、即時同步等各種場景,確保 Firestore 系統的穩定性和可靠性。
透過今天的實戰探索,我們學到了以下核心觀念:
✅ Firestore 即時同步是強大的功能
snapshots()
建立即時監聽器✅ 安全規則設計很重要
✅ 離線支援提升使用者體驗
✅ 查詢優化影響效能
🚀 開發效率大幅提升
📱 使用者體驗優化
🔧 系統穩定性保障
明天,我們將深入探討 Firebase Storage,學習檔案上傳與管理的最佳實踐,並結合本專案的實際需求進行應用。
期待與您在 Day 16 相見!