
设计决策:为什么选择文件存储
移动应用本地持久化有多个成熟方案可选------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 赋值之前执行。由于 _basePath 是 late 变量,这会抛出 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 的沙箱机制提供基础保护------其他应用无法读取该目录。对于开源客户端应用,这是合理的起点。
安全风险
- 设备 root/越狱:如果 HarmonyOS 设备被 root,攻击者可以绕过沙箱读取文件
- 备份恢复:如果应用数据被备份到外部,Token 可能随备份泄露
- 调试日志:开发阶段的日志输出可能意外包含 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 仅在应用进程存活期间有效
// 应用重启后需要重新登录
生产环境建议
对于计划公开发布的应用,建议按以下优先级升级安全性:
- 切换到 HUKS 存储(利用平台安全硬件)
- 添加 Token 过期后的自动刷新(不依赖永久 Token)
- 实现 OAuth PKCE 扩展(防止授权码拦截攻击)
设计哲学:YAGNI 与可扩展性
LocalStorage 的设计遵循 YAGNI 原则(You Aren't Gonna Need It)------当前只需要存储一个 String 类型的 Token,所以实现只做了 String 的读写。没有添加以下功能,因为当前用不到:
- 批量写入(事务)
- 写入监听器(onValueChanged 回调)
- 自动序列化/反序列化适配器
- 内存缓存层(避免每次读磁盘)
- 数据加密
- 数据迁移/版本管理
这不是说这些功能不好,而是说在不需要的时候不添加才是好的工程实践。每行代码都有维护成本------代码审查、测试、Bug 修复、未来重构。如果 20 行代码能解决的问题,不应该引入 200 行。
当新需求出现时(如需要缓存多个用户、需要加密存储、需要离线支持),LocalStorage 的设计提供了清晰的扩展方向------新增方法或修改现有实现,不会影响调用方的接口。