2025 iThome鐵人賽
「 Flutter :30天打造念佛App,跨平台從Mobile到VR,讓極樂世界在眼前實現 ! 」
Day 22
「 Flutter 資料儲存 實戰應用篇—穿越到Coding世界的勇者啊,你知道裝備可以放哪裡嗎(2) 」
昨天我們已經實作本機資料儲存,讓使用者的佛號計數都能完整保存在裝置內。
今天我們要來接著實作雲端資料庫儲存,多一份備份、多一份保障!
Day22 文章目錄:
一、雲端資料庫
二、Firestore
三、核心程式
1.簡介
雲端資料庫 是指雲端託管的結構化/半結構化資料存放(SQL / NoSQL)。
而實務上會先將檔案原件交給物件儲存(下方表格第一列),
把中繼/索引/關聯資料放在資料庫(SQL 或 NoSQL)。
這樣能同時平衡成本、效能與可維護性。
2.物件儲存與雲端資料庫
大分類 | 子分類 | 代表服務商 | 適合檔案類型 | App 端連接方式 |
---|---|---|---|---|
檔案 | 物件儲存 (Object Storage) | Amazon S3、Google Cloud Storage (GCS)、Cloudflare R2 | 圖片/音訊/影片/大型備份與靜態資源 | 預簽 URL 直傳,下載走 CDN |
資料庫 | 關聯式 (SQL / Relational) | Amazon RDS (Postgres/MySQL)、Cloud SQL (Postgres/MySQL)、Azure SQL Database | 非檔案:交易/訂單/帳務/使用者資料;中繼資料(檔名、URL、權限、大小) | 自建後端 API |
NoSQL:文件型 (Document) | Cloud Firestore、MongoDB Atlas、Amazon DynamoDB | 非檔案:JSON 、清單、設定、索引與中繼資料 | Firestore 走官方 SDK;Mongo/Dynamo 多數自建 API | |
NoSQL:鍵值 (Key-Value) | Redis(AWS ElastiCache)、Redis(GCP Memorystore)、Redis(Azure Cache for Redis) | 非檔案:快取、Session、排行榜、臨時狀態 | 由後端使用 Redis;App 只打後端 API(不直接連 Redis) |
Firestore 是 Google 提供的 雲端 NoSQL 文件型資料庫。
資料以「集合 (collection) → 文件 (document) → 子集合 (subcollection)」的
樹狀結構儲存。
支援以下功能:
- 即時同步(資料更新可推播到客戶端)
- 離線快取(行動端預設開啟;斷網可讀/寫、復網自動同步)
- 可擴展查詢(條件索引/排序/分頁)
- 細緻安全規則(以使用者身份與文件內容進行授權判斷)
1. 建立專案
2. iOS 添加Google-Service-Info
3. Android 添加Google-Services.json
android/build.gradle 宣告工具依賴,讓Gradle解析並下載Google Services plugin。
plugins {
id("com.google.gms.google-services") version "4.4.2" apply false
}
android/app/build.gradle 將plugin套用到app模組
plugins {
id("com.google.gms.google-services")
}
4. 添加存取規則
存取規則:僅限使用者本人(UID)存取
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
function isSignedIn() { return request.auth != null; }
function isOwner(uid) { return isSignedIn() && request.auth.uid == uid; }
match /users/{uid} {
allow read, write: if isOwner(uid);
match /{document=**} {
allow read, write: if isOwner(uid);
}
}
}
}
1.添加套件 pubspec.yaml
# pubspec.yaml
dependencies:
firebase_core: ^3.6.0 # 初始化 Firebase SDK 的核心套件
firebase_auth: ^5.3.1
cloud_firestore: ^5.5.1
connectivity_plus: ^6.1.0 # 偵測網路連線狀態,可作為觸發器(無網→有網,嘗試 sync())
2.初始化 main.dart
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
runApp(const MyApp());
}
3.登入 firebase_auth.dart
import 'package:firebase_auth/firebase_auth.dart';
Future<User> ensureSignedIn() async {
final auth = FirebaseAuth.instance;
return auth.currentUser ?? (await auth.signInAnonymously()).user!;
}
4.寫入 cloud_firestore.dart
import 'package:cloud_firestore/cloud_firestore.dart';
class CloudSessionRepository {
final _db = FirebaseFirestore.instance;
final String uid;
CloudSessionRepository(this.uid);
Future<void> upsertSession({
required String sessionId,
required int hits,
required DateTime lastAt,
DateTime? startedAt,
DateTime? endedAt,
required int version,
}) async {
final ref = _db.collection('users').doc(uid)
.collection('sessions').doc(sessionId);
await _db.runTransaction((txn) async {
final snap = await txn.get(ref);
final remoteVersion = snap.data()?['version'] as int? ?? 0;
if (remoteVersion != version) {
throw StateError('Version conflict: local=$version remote=$remoteVersion');
}
txn.set(ref, {
if (startedAt != null) 'startedAt': Timestamp.fromDate(startedAt),
if (endedAt != null) 'endedAt': Timestamp.fromDate(endedAt),
'hits': hits,
'lastAt': Timestamp.fromDate(lastAt),
'updatedAt': FieldValue.serverTimestamp(),
'version': FieldValue.increment(1),
}, SetOptions(merge: true));
});
}
Future<void> upsertDaily({
required String dateStr,
required int deltaHits,
}) async {
final ref = _db.collection('users').doc(uid)
.collection('daily').doc(dateStr);
await _db.runTransaction((txn) async {
final snap = await txn.get(ref);
final current = snap.data()?['hits'] as int? ?? 0;
final version = snap.data()?['version'] as int? ?? 0;
txn.set(ref, {
'date': dateStr,
'hits': FieldValue.increment(deltaHits),
'updatedAt': FieldValue.serverTimestamp(),
'version': FieldValue.increment(1),
}, SetOptions(merge: true));
});
}
}
5.讀取、監聽 cloud_session_reader.dart
使用 snapshots()進行監聽:
- 建立與firestore 的 watch 串流
- 當其他裝置/雲端有變更 → 雲端會透過串流推送到本機監聽回調。
- 當本機變更進行寫入 → 先觸發本機快照,
雲端確認後,若內容有變或有啟用 includeMetadataChanges,
會再推一版權威快照。
- 離線時的寫入會先產生本機快照;恢復連線後由 SDK 上傳 pending writes,
雲端整合後再推回權威結果。
- 僅監聽指定目標
(符合路徑的 doc / 符合路徑及查詢條件的 collection、collectionGroup )- 子集合不會被自動包含(監聽某個collection時,其子集合不會被監聽),
必須對各子集合各自建立監聽,例如:
_db.collection('users').doc(uidA).collection('sessions').snapshots();
或是跨路徑監聽同名子集合,例如:
_db.collectionGroup('sessions').where('ownerUid', isEqualTo: uidA).snapshots();
import 'package:cloud_firestore/cloud_firestore.dart';
class CloudSessionReader {
final _db = FirebaseFirestore.instance;
final String uid;
CloudSessionReader(this.uid);
//讀取單筆 Session
Future<Map<String, dynamic>?> getSession(String sessionId) async {
final snap = await _db
.collection('users').doc(uid)
.collection('sessions').doc(sessionId)
.get();
return snap.data();
}
//分頁列出 Sessions
Future<QuerySnapshot<Map<String, dynamic>>> listSessionsPage({
DocumentSnapshot? startAfter,
int limit = 20,
}) {
var q = _db
.collection('users').doc(uid)
.collection('sessions')
.orderBy('updatedAt', descending: true) //新到舊排序
.limit(limit); //限制每頁幾筆數量
//若有上頁最後一筆session,從它之後開始
if (startAfter != null) q = q.startAfterDocument(startAfter);
return q.get();
}
//即時監聽列表(同一帳戶在本機或其他裝置的變更,監聽器都會收到。)
Stream<QuerySnapshot<Map<String, dynamic>>> watchLatestSessions({
int limit = 20,
}) {
return _db
.collection('users').doc(uid)
.collection('sessions')
.orderBy('updatedAt', descending: true)
.limit(limit) //只看最新的幾筆
.snapshots();
}
// 讀取每日彙整
Future<Map<String, dynamic>?> getDaily(String dateStr) async {
final snap = await _db
.collection('users').doc(uid)
.collection('daily').doc(dateStr)
.get();
return snap.data();
}
}
重點 | 內容 |
---|---|
雲端資料庫 | 雲端資料託管服務比較 |
Firestore | 綁定Firestore完整流程 |
核心程式 | 安裝套件、初始化、寫入、讀取 |