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());
}
});
}
}
重點 | 內容 |
---|---|
本機儲存 | App沙盒、安全分層、效能 |
實作選擇 | 鍵值、純檔案、NoSQL、SQLite |
實作核心 | 緩衝→ 單寫者序列化→ 原子寫入 |