iT邦幫忙

2025 iThome 鐵人賽

DAY 21
0
Mobile Development

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

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

  • 分享至 

  • xImage
  •  

2025 iThome鐵人賽
「 Flutter :30天打造念佛App,跨平台從Mobile到VR,讓極樂世界在眼前實現 ! 」
Day 21
「 Flutter 資料儲存 實戰應用篇—穿越到Coding世界的勇者啊,你知道裝備可以放哪嗎(1)


前言

昨天我們已經完成了音訊轉換及念佛計數的單元測試,
今天我們要來接著探索「如何在手機裝置端進行儲存」!
如此一來,使用者下次開啟App時,之前的使用紀錄都能完整保存

Day21 文章目錄:
一、本機儲存
二、實作選擇
三、實作核心


一、本機儲存

1. 簡介

本機資料儲存是將App資料從記憶體寫到裝置內的持久化媒介
由Android、iOS 平台提供 檔案系統/資料庫 API,
讓 App 在沙盒內使用這些API進行讀寫。

2. 重要概念

(1) App沙盒機制

  • App只能存取自身的沙盒目錄(不能讀取其他App的資料)
  • 常見子目錄職責:
    • Documents / Application Support:長期保存、可備份。
    • Caches:快取,空間不足時系統可清除。
    • tmp:臨時檔,隨時可能被清理。

(2) 安全分層

  • 資料分類 → 存放對應等級 → 遵守最小揭露、最小權限。
  • 資料分類:
    • 公開/一般:非敏感、可重建的資料
    • 一般個人化:與個人使用有關,但風險低
    • 敏感:Token、API KEY
    • 高度敏感:個資、健康資訊、商業機密

(3) 效能

  • 小而頻繁的寫入會造成
    • 效能差(I/O 次數多)、耗電(儲存控制器一直被喚醒)、
      磨損(Flash 壽命跟寫入次數相關)
  • 解法:
    • 併批/緩衝→ 多筆累積或固定間隔再寫入;熱資料快取降低 I/O。

備註:

  • 熱資料(hot data):會被頻繁讀取的資料。
  • 冷資料(cold data):不常用的資料。
  • 熱資料放記憶體快取,搭配過期時間(TTL)或最近少使用(LRU),讀取免 I/O,速度快、耗電少。

(4) 中途閃退防護

  • 緩衝 + 原子寫入,降低遺失與壞檔機率。
  • 追加式事件日誌,可截斷修復。

(5) 單寫者序列化

  • 任何對「同一資源」的寫入請求,都丟進同一條序列化隊列依序處理,
    避免併發覆蓋與遺失

二、實作選擇

BLOB 是以二進位形式儲存的大型資料,常見於音訊、影像、影片、壓縮檔等。
BLOB可以存放在資料庫欄位裡,也可以外放在檔案系統。
實務上會建議將中大型檔外放,避免資料庫膨脹、CRUD與備份變慢。

CRUD 是資料存取最基本的四種操作:
Create(建立):新增一筆資料。
Read(讀取):查詢或取得資料。
Update(更新):修改既有資料。
Delete(刪除):移除資料。

類別 套件 / 方式 資料模型 二進位/大檔 備份/還原難度 典型規模/性能 最佳適用場景
鍵值偏好 shared_preferences Key-Value(無查詢) 不適合 極易 極小量本機、存取快速 偏好設定
安全鍵值 flutter_secure_storage Key-Value(加密儲存) 不適合(僅放小型機敏資料) 易(但金鑰綁裝置,跨裝置需重新登入) 極小量本機、存取中速 Token、私鑰、使用者憑證
純檔案 dart:io + path_provider 自訂(無內建查詢) 最適合(支援 BLOB/大檔) 最易(整夾壓縮/還原) 視檔案大小與結構設計 可讀可攜、媒體/錄音/圖片原檔
NoSQL Isar 物件模型 小中型 Blob 可 中大型本機、查寫極快 清單/篩選/快查,高頻存取
NoSQL ObjectBox 物件模型 小中型 Blob 可 中大型本機、查寫極快 高性能物件資料
SQLite sqflite 關聯模型 小型 Blob 可 中大型本機、可穩定複雜查寫 複雜查詢/報表/歷史檢索
SQLite drift 關聯模型 小型 Blob 可 中大型本機、可穩定複雜查寫 複雜查詢 + 良好維運
加密 DB sqflite_sqlcipher 關聯模型;整庫加密 小型 Blob 可(有加解密成本) 中~難(需金鑰管理) 中大型本機、略慢於 SQLite(加解密開銷) 在地端也需保密的資料

三、實作核心

念佛 App 的資料屬性是「長時間、持續累積、偶爾查詢」。
使用者一次的念佛時間可能是數個小時、累積上萬聲佛號;

以可離線運作、可移轉/備份與雲端同步的基準,
採用「緩衝 → 單寫者序列化 → 原子寫入」的檔案式流程,
將寫入頻率、I/O 風險與資料一致性做好平衡。

1. 資料模型 storage/models.dart

class SessionSnapshot {
  final String sessionId;
  final String userId;
  final String userName;
  final DateTime startedAt;
  final DateTime lastAt;
  final int amitabhaCount;

  SessionSnapshot({
    required this.sessionId,
    required this.userId,
    required this.userName,
    required this.startedAt,
    required this.lastAt,
    required this.amitabhaCount,
  });

  Map<String, dynamic> toJson() => {
    'schemaVersion': 1,
    'sessionId': sessionId,
    'userId': userId,
    'userName': userName,
    'startedAt': startedAt.toUtc().toIso8601String(),
    'lastAt': lastAt.toUtc().toIso8601String(),
    'amitabhaCount': amitabhaCount,
  };

  static SessionSnapshot fromJson(Map<String, dynamic> j) => SessionSnapshot(
    sessionId: j['sessionId'],
    userId: j['userId'],
    userName: j['userName'],
    startedAt: DateTime.parse(j['startedAt']).toUtc(),
    lastAt: DateTime.parse(j['lastAt']).toUtc(),
    amitabhaCount: j['amitabhaCount'] ?? 0,
  );
}

class DailySummary {
  final String yyyymmdd;
  final String userId;
  final String userName;
  final int amitabhaCount;

  DailySummary({
    required this.yyyymmdd,
    required this.userId,
    required this.userName,
    required this.amitabhaCount,
  });

  Map<String, dynamic> toJson() => {
    'schemaVersion': 1,
    'date': yyyymmdd,
    'userId': userId,
    'userName': userName,
    'amitabhaCount': amitabhaCount,
  };

  static DailySummary fromJson(Map<String, dynamic> j) => DailySummary(
    yyyymmdd: j['date'],
    userId: j['userId'],
    userName: j['userName'],
    amitabhaCount: j['amitabhaCount'] ?? 0,
  );
}

2. 緩衝器 storage/buffered_hits.dart

import 'dart:async';

class BufferedHits {
  final Duration flushEvery;  //最長等待多久就flush
  final int maxBuffer;        //滿多少筆就flush
  final Future<void> Function(List<DateTime>) onFlush;
  final _buf = <DateTime>[];
  Timer? _t;

  BufferedHits({
    required this.flushEvery,
    required this.maxBuffer,
    required this.onFlush,
  });

  void add(DateTime t) {
    _buf.add(t);                       //將命中佛號的時間添加到緩衝器    
    if (_buf.length >= maxBuffer) { _flush(); } //量到上限就flush
    else { _t ??= Timer(flushEvery, _flush); }  //時間到就flush
  }
 
  Future<void> _flush() async {
    _t?.cancel(); _t = null;
    if (_buf.isEmpty) return;
    final copy = List<DateTime>.from(_buf);
    _buf.clear();
    await onFlush(copy);
  }

  Future<void> close() => _flush();
}

3. ASR ⭢ 緩衝器

if (hitAdd > 0) {
  setState(() {
    _asrHitCount += hitAdd;
    _asrLastHitAt = DateTime.now();
    debugPrint('[ASR] 阿彌陀佛 +$hitAdd -> $_asrHitCount @ ${_asrLastHitAt!.toIso8601String()}');
  });
  for (int i = 0; i < hitAdd; i++) {
    _buffer.add(DateTime.now()); //佛號命中時添加至緩衝器
  }
}

4. 原子寫入 storage/atomic_io.dart

原子(atomic):
一個操作不是全部成功,就是完全沒發生,不會出現「寫到一半」的中間狀態。
常用「先寫暫存檔 → 一步置換成正式檔」的手法,來達到近似原子的效果。

import 'dart:convert';
import 'dart:io';

//原子寫入
Future<void> atomicWriteJson(File file, Object jsonObj) async {
  final dir = file.parent;
  if (!await dir.exists()) {
    await dir.create(recursive: true);
  }

  final tmp = File('${file.path}.tmp');
  try {
    final jsonStr = const JsonEncoder.withIndent('  ').convert(jsonObj);
    await tmp.writeAsString(jsonStr, flush: true);
    await tmp.rename(file.path);
  } catch (e) {
    if (await tmp.exists()) {
      try { await tmp.delete(); } catch (_) {}
    }
    rethrow;
  }
}

/// 讀取 JSON
Future<Map<String, dynamic>> readJsonOrEmpty(File file) async {
  if (!await file.exists()) return {};
  String text;
  try {
    text = await file.readAsString();
  } on IOException {
    return {};
  }

  if (text.isEmpty) return {};

  try {
    final obj = jsonDecode(text);
    if (obj is Map<String, dynamic>) return obj;
    return {};
  } on FormatException {
    return {};
  }
}


5. 單寫者序列化 storage/single_writer.dart

class SingleWriter {
  Future<void> _last = Future.value(); //正在執行或已排序的工作,後續工作都會接後面
  Future<T> run<T>(Future<T> Function() job) { //逐一執行
    final next = _last.then((_) => job()); //last完成,才呼叫job
    _last = next.catchError((_) {}); 
    return next;
  }
}
final singleWriter = SingleWriter();

6. 每日彙整 storage/daily_repo.dart

import 'atomic_io.dart';
import 'app_paths.dart';
import 'models.dart';
import 'single_writer.dart';

class DailyRepository {
  Future<void> addCount(String yyyymmdd, String userId, String userName, int delta) async {
    final file = await AppPaths.daily(yyyymmdd);
    await singleWriter.run(() async {
      final j = await readJsonOrEmpty(file);
      if (j.isEmpty) {
        final d = DailySummary(
          yyyymmdd: yyyymmdd,
          userId: userId,
          userName: userName,
          amitabhaCount: delta,
        );
        await atomicWriteJson(file, d.toJson());
      } else {
        final d = DailySummary.fromJson(j);
        final updated = DailySummary(
          yyyymmdd: d.yyyymmdd,
          userId: d.userId,
          userName: d.userName,
          amitabhaCount: d.amitabhaCount + delta,
        );
        await atomicWriteJson(file, updated.toJson());
      }
    });
  }
}


Day21 重點回顧

重點 內容
本機儲存 App沙盒、安全分層、效能
實作選擇 鍵值、純檔案、NoSQL、SQLite
實作核心 緩衝→ 單寫者序列化→ 原子寫入

上一篇
[ Day 20 ] Flutter 單元測試 — 專案必備的綠色乖乖,程式守門員登場!
系列文
Flutter :30天打造念佛App,跨平台應用從Mobile到VR,讓極樂世界在眼前實現!21
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言