鸿蒙Flutter实战:放弃sqflite选纯Dart JSON文件存储
Flutter 应用中做本地持久化,第一反应通常是 sqflite。它成熟、稳定、API 友好——但它是原生插件,需要 Android 的 SQLite 库,需要 iOS 的 FMDB。

前言
Flutter 应用中做本地持久化,第一反应通常是 sqflite。它成熟、稳定、API 友好——但它是原生插件,需要 Android 的 SQLite 库,需要 iOS 的 FMDB。
问题来了:鸿蒙 OHOS 没有 SQLite 原生支持。或者说,至少没有标准化的、与 sqflite 插件兼容的 SQLite 绑定。如果要让鸿蒙也能跑 sqflite,意味着自己写一整套 OHOS 端的 FFI 绑定——这对于一个个人备忘录应用来说,投入产出比失衡。
于是做了一个关键架构决策:放弃 sqflite,用纯 Dart 的 dart:io + dart:convert 实现 JSON 文件存储。
本文详述这个决策背后的工程权衡和完整实现。
项目仓库:todo_flutter_harmony
决策分析:JSON 文件 vs SQLite
| 维度 | sqflite | 纯 Dart JSON 文件 |
|---|---|---|
| 鸿蒙兼容性 | ❌ 需要原生 FFI 绑定 | ✅ dart:io 天然支持 |
| 查询能力 | ✅ SQL 完整查询 | ⚠️ Dart 内存过滤 |
| 写入性能 | ✅ 增量写入 | ⚠️ 全量重写 JSON |
| 数据一致性 | ✅ 事务支持 | ⚠️ 手动保证 |
| 并发安全 | ✅ 连接池 | ⚠️ 单线程无竞态 |
| 代码量 | 中等(需要 migration) | 少(100 行左右) |
| 适合数据量 | 万级以上 | 千级以下 |
对于个人备忘录应用(预计数据量 < 1000 条),JSON 文件的劣势不明显,而鸿蒙兼容性的优势是决定性的。
架构设计:单例 + 内存缓存
核心思路:所有数据在内存中维护一份 Map<String, dynamic> 缓存,CRUD 操作修改缓存后全量写回 JSON 文件。
class DatabaseHelper {
// 单例模式
static final DatabaseHelper instance = DatabaseHelper._();
DatabaseHelper._();
// 内存缓存
Map<String, dynamic> _cache = {};
// 自增 ID 计数器
int _nextMemoId = 1;
int _nextTodoId = 1;
int _nextDiaryId = 1;
int _nextCategoryId = 1;
// 是否已初始化
bool _initialized = false;
初始化:加载 JSON 文件到内存
Future<void> init() async {
if (_initialized) return;
final dir = await StoragePath.getAppDir();
final dataDir = Directory('$dir/.memo_app');
final dataFile = File('$dir/.memo_app/data.json');
// 确保目录存在
if (!await dataDir.exists()) {
await dataDir.create(recursive: true);
}
// 读取已有数据
if (await dataFile.exists()) {
final content = await dataFile.readAsString();
if (content.isNotEmpty) {
_cache = jsonDecode(content) as Map<String, dynamic>;
}
}
// 初始化缓存结构
_cache.putIfAbsent('memos', () => []);
_cache.putIfAbsent('todos', () => []);
_cache.putIfAbsent('diaries', () => []);
_cache.putIfAbsent('categories', () => []);
// 初始化自增 ID
_nextMemoId = _getMaxId(_cache['memos']) + 1;
_nextTodoId = _getMaxId(_cache['todos']) + 1;
_nextDiaryId = _getMaxId(_cache['diaries']) + 1;
_nextCategoryId = _getMaxId(_cache['categories']) + 1;
_initialized = true;
}
int _getMaxId(List list) {
if (list.isEmpty) return 0;
return list.fold<int>(0, (max, item) {
final id = (item as Map)['id'] as int? ?? 0;
return id > max ? id : max;
});
}
CRUD 操作:以 Memo 为例
// ========== Memo CRUD ==========
Future<List<Memo>> getAllMemos() async {
await init();
final list = _cache['memos'] as List;
return list.map((json) => Memo.fromMap(json as Map<String, dynamic>)).toList();
}
Future<void> insertMemo(Memo memo) async {
await init();
final memos = _cache['memos'] as List;
final newMemo = memo.toMap()..['id'] = _nextMemoId++;
memos.add(newMemo);
await _persistToFile();
}
Future<void> updateMemo(Memo memo) async {
await init();
final memos = _cache['memos'] as List;
final index = memos.indexWhere((m) => m['id'] == memo.id);
if (index != -1) {
memos[index] = memo.toMap();
await _persistToFile();
}
}
Future<void> deleteMemo(int id) async {
await init();
final memos = _cache['memos'] as List;
memos.removeWhere((m) => m['id'] == id);
await _persistToFile();
}
全量持久化
Future<void> _persistToFile() async {
final dir = await StoragePath.getAppDir();
final file = File('$dir/.memo_app/data.json');
final jsonString = const JsonEncoder.withIndent(' ').convert(_cache);
await file.writeAsString(jsonString);
}
JsonEncoder.withIndent(' ') 生成格式化 JSON,方便开发者调试时直接打开 data.json 查看数据。生产环境可以用不带缩进的版本减小文件体积。
文件锁和并发安全
由于 Flutter/Dart 是在单个 isolate 中运行(不涉及多线程并发),不存在两个操作同时写入文件的问题。所有异步操作在 event loop 上排队执行,天然的串行保证。
但如果应用未来引入 Isolate 做后台处理,需要考虑文件锁:
Future<void> _persistToFile() async {
final dir = await StoragePath.getAppDir();
final file = File('$dir/.memo_app/data.json');
// 先写入临时文件,再原子替换
final tempFile = File('$dir/.memo_app/data.tmp.json');
final jsonString = jsonEncode(_cache);
await tempFile.writeAsString(jsonString);
await tempFile.rename(file.path); // 原子操作
}
"写临时文件再 rename"是一种常见的原子写入策略:如果在写入过程中应用崩溃,损坏的是临时文件,正式文件保持完整。
StoragePath:获取应用目录
class StoragePath {
static const _channel = MethodChannel('com.memo.app/storage');
static Future<String> getAppDir() async {
try {
// 鸿蒙 OHOS:通过 MethodChannel 获取 filesDir
final dir = await _channel.invokeMethod<String>('getFilesDir');
if (dir != null && dir.isNotEmpty) return dir;
} catch (e) {
// MethodChannel 不可用时静默降级
}
// Android / iOS / Desktop:使用 path_provider
try {
final dir = await getApplicationDocumentsDirectory();
return dir.path;
} catch (e) {
// 最后的降级:当前目录
return Directory.current.path;
}
}
}
三层降级策略:
- 优先尝试 MethodChannel(鸿蒙)
- 降级到
path_provider(标准平台) - 最后降级到
Directory.current.path(桌面/测试)
数据量评估
每行 JSON 按 300 字符(中文 + 元数据),1000 条数据约 300KB。全量读写的性能:
- 读取 300KB JSON + 解析:< 10ms(Dart 的 JSON 解析器是 C++ 实现的)
- 写入 300KB JSON:< 20ms(SSD)
在备忘录这种场景下,用户完全感知不到延迟。
迁移到 SQLite 的考虑
如果未来 App 用户增长、数据量达到万级,JSON 文件方案的性能会下降。此时需要一个迁移策略:
- 读取
data.json中的所有数据 - 批量写入 SQLite
- 修改
DatabaseHelper的实现(接口保持不变) - 删除或保留
data.json作为备份
因为所有数据访问都经过 DatabaseHelper 单例,替换底层存储方式不需要修改任何 Provider 或 UI 代码——这是分层架构的最大收益。
鸿蒙兼容性
dart:io 的 File、Directory 类在鸿蒙 OHOS 上依赖 Flutter OHOS 引擎提供的文件系统绑定。@ohos/flutter_ohos 引擎已经实现了这些绑定,因为它是运行 Flutter 最基本的要求。JSON 编码解码在 Dart VM 层完成,与平台无关。
总结
为了鸿蒙兼容性放弃 sqflite、选择纯 Dart JSON 文件存储,这是一个工程权衡的经典案例。核心判断依据是:个人备忘录的数据量在 JSON 文件方案的性能承受范围内,而鸿蒙兼容性是不可放弃的硬需求。
100 行代码的 DatabaseHelper 实现了完整的 CRUD + 自增 ID + 原子写入,没有任何平台绑定——这就是 Flutter “write once, run anywhere” 理念的最佳实践。
完整项目代码见:todo_flutter_harmony
更多推荐


所有评论(0)