AtomGit Flutter鸿蒙客户端:本地存储

设计决策:为什么选择文件存储

移动应用本地持久化有多个成熟方案可选------SQLite、SharedPreferences、MMKV、Hive 等。但 AtomGit Flutter 客户端的本地存储需求极为简单:只存储一个 access_token 字符串

面对这种需求,各种方案的对比:

方案 优势 劣势 对本项目的适合度
文件 JSON 零依赖,人类可读,易调试 不支持查询,并发写需注意 最佳:数据量小,无查询需求
SharedPreferences Android 内置,API 简洁 平台相关,不适合存储复杂结构 可行但不必要
SQLite (sqflite) 支持关系查询,事务 引入 C 依赖,Platform Channel 开销 过度设计:单条数据不需要 SQL
Hive 纯 Dart,性能好 新依赖,需要代码生成或适配器 不需要
MMKV 高性能,支持多进程 需要原生插件,HarmonyOS 可能不支持 不适用

最终选择文件级 JSON 存储------用最少的代码满足当前需求,不引入额外的复杂度和依赖。

单例模式与惰性初始化

dart 复制代码
class LocalStorage {
  static final LocalStorage _instance = LocalStorage._();
  static LocalStorage get instance => _instance;
  LocalStorage._();  // 私有构造函数防止外部实例化

  late String _basePath;

  Future<void> init(String appDocPath) async {
    _basePath = '$appDocPath/.atomgit';
    final dir = Directory(_basePath);
    if (!await dir.exists()) {
      await dir.create(recursive: true);
    }
  }

  String get _defaultPath => Directory.systemTemp.path;
}

单例设计细节

static final + 私有构造函数是 Dart 中实现单例的惯用模式。与 Java 不同,Dart 的 static final 是延迟初始化的------_instance 在首次访问 LocalStorage.instance 时才创建,而非类加载时。

_basePath 使用 late 关键字声明。late 变量在首次读取前必须被赋值,否则抛出 LateInitializationError。这种设计用于强制调用者先执行 init() 再使用其他方法------如果某段代码在 init() 之前尝试读写,应用会立即崩溃,问题在开发阶段暴露而非生产环境中静默失败。

存储目录的选择

getApplicationDocumentsDirectory() 返回应用的私有文档目录。在 HarmonyOS 上,这个路径通常类似:

复制代码
/data/storage/el2/base/haps/<bundle>/files/

HarmonyOS 的应用沙箱机制保证只有该应用可以访问此目录。其他应用无法读取,用户也无法通过系统文件管理器直接访问。这对于存储敏感数据(如 API Token)是合理的默认安全级别。

创建 .atomgit 子目录而非直接在文档目录存储数据,是为了保持文件组织清晰------用户可能会有其他应用数据在文档目录中,使用独立子目录避免文件名冲突。

写入操作

dart 复制代码
Future<void> write(String key, dynamic value) async {
  final file = File('${_basePath}/$key.json');
  final json = jsonEncode(value);
  await file.writeAsString(json);
}

文件命名规范

每个 key 映射到 {key}.json 文件。.json 后缀不是技术必需(文件内容就是 JSON 字符串),但有两个好处:

  • 在调试时可以双击文件用任意 JSON 查看器打开
  • 明确文件内容格式,避免后续维护时猜测

写入的类型支持

jsonEncode 支持的类型:

dart 复制代码
// String → "value"
write('key', 'hello');

// int → 42
write('key', 42);

// Map → {"name":"Alice","age":30}
write('key', {'name': 'Alice', 'age': 30});

// List → [1,2,3]
write('key', [1, 2, 3]);

// bool → true
write('key', true);

// null → null
write('key', null);

不可直接写入的类型(需要手动转换):

dart 复制代码
// DateTime → 需要先转字符串
write('key', DateTime.now().toIso8601String());

// 自定义对象 → 需要 toJson
write('key', userProfile.toJson());

写入的原子性

file.writeAsString() 是同步写入的(相对于文件系统),但在异步上下文中不与其它写入互锁。如果两个 write() 并发调用不同的 key(写入不同文件),它们天然不冲突------这是"每 key 一文件"设计的优势。

但如果两个 write() 并发写入相同的 key(同一个文件),后完成的写入会覆盖先完成的写入。这在当前使用场景下不是问题(只有 AuthProvider 写入 token,且登录/登出操作不可能并发)。

读取操作

dart 复制代码
Future<T?> read<T>(String key) async {
  try {
    final file = File('${_basePath}/$key.json');
    if (!await file.exists()) return null;
    final content = await file.readAsString();
    final decoded = jsonDecode(content);
    return decoded as T;
  } catch (_) {
    return null;
  }
}

泛型返回值

返回类型 T? 由调用方在泛型参数中指定:

dart 复制代码
// 读取字符串
final token = await storage.read<String>('access_token');

// 读取 Map
final cache = await storage.read<Map<String, dynamic>>('user_cache');

注意 decoded as T 是 Dart 的类型转换------它在运行时并不真正检查类型(泛型擦除)。如果 JSON 中存储的是 String 但调用方指定了 read<Map>(),运行时会抛出 _CastError。但 try-catch 捕获了所有异常并返回 null,所以不会崩溃。

更安全的实现可以加运行时类型检查:

dart 复制代码
final decoded = jsonDecode(content);
if (decoded is T) {
  return decoded;
}
return null;

当前实现选择了更简洁的 as T + try-catch 包裹,在数据格式稳定的场景下效果相同。

文件不存在时的处理

!await file.exists() 检查后返回 null。这比 read → catch FileSystemException 更高效,因为避免了不必要的 I/O 错误处理。

解析失败的处理

如果 JSON 文件被意外损坏(如存储空间满导致写入不完整),jsonDecode 会抛出 FormatException。try-catch 捕获后返回 null,调用方会认为没有缓存数据,从头加载。

删除与存在检查

dart 复制代码
Future<void> delete(String key) async {
  final file = File('${_basePath}/$key.json');
  if (await file.exists()) {
    await file.delete();
  }
}

Future<bool> has(String key) async {
  final file = File('${_basePath}/$key.json');
  return file.exists();
}

删除操作先检查文件是否存在再删除,避免无效的删除调用。has 方法不需要 try-catch,因为 file.exists() 不会抛异常------文件不存在时返回 false。

当前使用场景:Token 持久化

目前 LocalStorage 只在 AuthProvider 中用于 token 的持久化:

dart 复制代码
// 登录时写入
Future<void> setTokenFromManualInput(String token) async {
  _apiClient.setAccessToken(token);
  await LocalStorage.instance.write('access_token', token);
  _isLoggedIn = true;
  notifyListeners();
}

// 启动时读取恢复
Future<void> tryRestoreSession() async {
  final token =
      await LocalStorage.instance.read<String>('access_token');
  if (token != null && token.isNotEmpty) {
    _apiClient.setAccessToken(token);
    _isLoggedIn = true;
    notifyListeners();
  }
}

// 登出时删除
Future<void> logout() async {
  _apiClient.setAccessToken(null);
  await LocalStorage.instance.delete('access_token');
  _isLoggedIn = false;
  notifyListeners();
}

应用启动初始化链

初始化顺序至关重要,必须保证 LocalStorage 在 AuthProvider 之前就绪:

dart 复制代码
// main.dart
void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // 步骤 1:获取应用文档目录路径
  final appDocPath = await getApplicationDocumentsDirectory();

  // 步骤 2:初始化 LocalStorage
  // 在 AuthProvider 创建之前完成,因为 AuthProvider 需要恢复 token
  await LocalStorage.instance.init(appDocPath);

  // 步骤 3:初始化 HarmonyOS 平台通道
  OhosPlatform.instance.init();

  // 步骤 4:启动应用
  // AuthProvider 的 create 回调中会调用 tryRestoreSession
  runApp(const AtomGitApp());
}

如果调换了步骤 2 和步骤 4 的顺序(先 runApp 后 init LocalStorage),AuthProvider 的 tryRestoreSession 会在 LocalStorage 的 _basePath 赋值之前执行。由于 _basePathlate 变量,这会抛出 LateInitializationError,导致应用启动崩溃。

扩展:缓存更多数据

LocalStorage 的设计支持无缝扩展。以下是一些实用的扩展场景:

缓存用户信息

dart 复制代码
// 写入缓存
final userData = {
  'login': user.login,
  'name': user.name,
  'avatar_url': user.avatarUrl,
  'cached_at': DateTime.now().toIso8601String(),
};
await LocalStorage.instance.write('user_cache', userData);

// 读取缓存(带过期检查)
final cached = await LocalStorage.instance
    .read<Map<String, dynamic>>('user_cache');
if (cached != null) {
  final cachedAt = DateTime.parse(cached['cached_at'] as String);
  if (DateTime.now().difference(cachedAt).inHours < 24) {
    _user = UserProfile.fromJson(cached);
    return; // 使用缓存,不发起网络请求
  }
}
// 缓存过期或不存在,从 API 加载

搜索历史

dart 复制代码
Future<void> addSearchHistory(String query) async {
  final history = await LocalStorage.instance
          .read<List<dynamic>>('search_history') ??
      [];

  // 去重:移除已存在的相同关键词
  history.removeWhere((item) => item == query);

  // 添加到最前面
  history.insert(0, query);

  // 限制最多保存 20 条
  if (history.length > 20) {
    history.removeRange(20, history.length);
  }

  await LocalStorage.instance.write('search_history', history);
}

应用偏好设置

dart 复制代码
// 主题偏好
await LocalStorage.instance.write('pref_theme', 'dark');
// 语言偏好
await LocalStorage.instance.write('pref_language', 'zh');
// 首页默认 Tab
await LocalStorage.instance.write('pref_default_tab', 0);

安全考量

当前安全级别

Token 以明文 JSON 存储在应用私有目录中。HarmonyOS 的沙箱机制提供基础保护------其他应用无法读取该目录。对于开源客户端应用,这是合理的起点。

安全风险

  1. 设备 root/越狱:如果 HarmonyOS 设备被 root,攻击者可以绕过沙箱读取文件
  2. 备份恢复:如果应用数据被备份到外部,Token 可能随备份泄露
  3. 调试日志:开发阶段的日志输出可能意外包含 Token

安全升级路径

平台密钥链(HarmonyOS KeyStore)。HarmonyOS 提供 HUKS(Harmony Universal KeyStore)密钥管理服务:

typescript 复制代码
// 使用 HUKS 安全存储
import huks from '@ohos.security.huks';
// 生成密钥、加密数据、存储密文

加密存储。在写入前使用 AES 加密,读取时解密:

dart 复制代码
Future<void> writeSecure(String key, dynamic value) async {
  final json = jsonEncode(value);
  final encrypted = _encrypt(json, _getEncryptionKey());
  await file.writeAsString(base64.encode(encrypted));
}

内存优先。不在文件系统中持久化 Token,只保存在内存中:

dart 复制代码
// 不调用 LocalStorage.write
// Token 仅在应用进程存活期间有效
// 应用重启后需要重新登录

生产环境建议

对于计划公开发布的应用,建议按以下优先级升级安全性:

  1. 切换到 HUKS 存储(利用平台安全硬件)
  2. 添加 Token 过期后的自动刷新(不依赖永久 Token)
  3. 实现 OAuth PKCE 扩展(防止授权码拦截攻击)

设计哲学:YAGNI 与可扩展性

LocalStorage 的设计遵循 YAGNI 原则(You Aren't Gonna Need It)------当前只需要存储一个 String 类型的 Token,所以实现只做了 String 的读写。没有添加以下功能,因为当前用不到:

  • 批量写入(事务)
  • 写入监听器(onValueChanged 回调)
  • 自动序列化/反序列化适配器
  • 内存缓存层(避免每次读磁盘)
  • 数据加密
  • 数据迁移/版本管理

这不是说这些功能不好,而是说在不需要的时候不添加才是好的工程实践。每行代码都有维护成本------代码审查、测试、Bug 修复、未来重构。如果 20 行代码能解决的问题,不应该引入 200 行。

当新需求出现时(如需要缓存多个用户、需要加密存储、需要离线支持),LocalStorage 的设计提供了清晰的扩展方向------新增方法或修改现有实现,不会影响调用方的接口。

相关推荐
伶俜661 小时前
# ✨ 零基础学 ArkUI 动画(专题一):从 animateTo 到 Lottie,一篇吃透全部
学习·华为·harmonyos
李二。1 小时前
HarmonyOS NEXT 屏幕取色器设计与实现详解
华为·harmonyos
●VON1 小时前
AtomGit Flutter鸿蒙客户端:Provider状态管理
flutter·华为·跨平台·harmonyos·鸿蒙
伶俜661 小时前
# [特殊字符] 零基础学 ArkUI 数据持久化(专题三):5 种存储方案深度对比
学习·华为·wpf·harmonyos
FrameNotWork2 小时前
HarmonyOS6.1 图像分类应用完整实战:从模型到界面
人工智能·分类·数据挖掘·harmonyos
MemoriKu2 小时前
Flutter 相册 APP 视频模态稳定化实战:从视频抽帧、Embedding 元数据到 Android 真机启动修复
android·开发语言·前端·flutter·架构·音视频·embedding
nice先生的狂想曲2 小时前
flutter页面滚动TabBar+TabBarView
flutter·客户端
带刺的坐椅2 小时前
SolonCode(编码智能体)支持鸿蒙 PC
java·web·ai编程·harmonyos·soloncode·鸿蒙 pc