请添加图片描述

前言

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

三层降级策略:

  1. 优先尝试 MethodChannel(鸿蒙)
  2. 降级到 path_provider(标准平台)
  3. 最后降级到 Directory.current.path(桌面/测试)

数据量评估

每行 JSON 按 300 字符(中文 + 元数据),1000 条数据约 300KB。全量读写的性能:

  • 读取 300KB JSON + 解析:< 10ms(Dart 的 JSON 解析器是 C++ 实现的)
  • 写入 300KB JSON:< 20ms(SSD)

在备忘录这种场景下,用户完全感知不到延迟。

迁移到 SQLite 的考虑

如果未来 App 用户增长、数据量达到万级,JSON 文件方案的性能会下降。此时需要一个迁移策略:

  1. 读取 data.json 中的所有数据
  2. 批量写入 SQLite
  3. 修改 DatabaseHelper 的实现(接口保持不变)
  4. 删除或保留 data.json 作为备份

因为所有数据访问都经过 DatabaseHelper 单例,替换底层存储方式不需要修改任何 Provider 或 UI 代码——这是分层架构的最大收益。

鸿蒙兼容性

dart:ioFileDirectory 类在鸿蒙 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

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐