iT邦幫忙

2025 iThome 鐵人賽

DAY 22
0
Mobile Development

Flutter :30天打造念佛App,跨平台應用從Mobile到VR,讓極樂世界在眼前實現!系列 第 22

[ Day 22 ] Flutter 資料儲存 實戰應用篇—穿越到Coding世界的勇者啊,你知道裝備可以放哪嗎(2) #雲端資料庫

  • 分享至 

  • xImage
  •  

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

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();
  }
}


Day22 重點回顧

重點 內容
雲端資料庫 雲端資料託管服務比較
Firestore 綁定Firestore完整流程
核心程式 安裝套件、初始化、寫入、讀取

上一篇
[ Day 21 ] Flutter 資料儲存 實戰應用篇—穿越到Coding世界的勇者啊,你知道裝備可以放哪嗎(1) #本機儲存
下一篇
[ Day 23 ] Flutter 第三方登入 實戰應用篇—穿越到Coding世界的勇者啊,你知道裝備可以放哪嗎(3) #Apple登入 #Google登入
系列文
Flutter :30天打造念佛App,跨平台應用從Mobile到VR,讓極樂世界在眼前實現!24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言