Flutter 修复 flutter_secure_storage 鸿蒙入口路由与前缀隔离:让多账号登录凭证在手机上独立读写与清理
欢迎到 CPF-Flutter 鸿蒙社区 交流跨端适配中遇到的问题。本文基于上游发布的 flutter_secure_storage 11.2.0 与社区维护的 flutter_secure_storage 鸿蒙适配仓库。我补齐了 Dart 主包在鸿蒙平台上的选项路由,并统一鸿蒙子包多账号自定义前缀的读写与清理范围;完整修复源码与本文同款三入口验证工程已公开提交至个人仓库 abaoge/flutter_secure_storage,位于 fix/ohos-routing-prefix 分支(对应提交 c90ce18d)。
前言
作为一名全栈开发,最近在做一个支持多账号切换的 Flutter 应用,在验证鸿蒙端本地登录状态时,本想沿用之前熟悉的写法,业务层统一引入 flutter_secure_storage,宿主依赖里挂上社区提供的鸿蒙子包 flutter_secure_storage_ohos。如下图所示:

图1 跨端接入流程
应用顺利打出了 HAP 包,装进 Mate X7 模拟器也能正常打开页面,引擎启动日志里甚至已经打印出鸿蒙安全存储插件注册成功的记录。可一旦页面真正调用 write 保存测试凭证,界面立刻捕获到 Unsupported operation: unsupported_platform。查看了源码之后,我发现问题停在进入原生通道之前:已发布的主包在挑选各平台配置参数时,压根没有处理 TargetPlatform.ohos。
于是,就有了这篇文章。
一、使用场景与实际问题
1.1 多账号本地凭证需要怎样的存取边界

图2 QQ 登录页
用户在手机上同时保留个人账号和工作账号,切换时希望各自的登录状态还在。应用通常会在本地加密保存服务端签发的访问令牌或刷新凭证,下次启动时再读取、校验。我对本地存储的要求很具体:写入工作账号不能覆盖个人账号,移除个人账号也不能删掉工作账号的凭证。
令牌过期、服务端吊销和多设备互踢仍由登录业务处理,头像与聊天记录也有各自的存储方式。这次我只处理少量凭证的本地读写,不把账号管理逻辑放进插件。

图3 平台支持
我沿用 flutter_secure_storage 的统一读写接口。上图是上游包的 pub.dev 页面,列出的平台不含鸿蒙;鸿蒙原生实现由社区子包提供。在这份实现里,两项系统能力各有分工:
@kit.ArkData的preferences负责应用沙箱内的键值持久化。@kit.CryptoArchitectureKit负责对称与非对称加解密。
插件把 Dart 调用交给鸿蒙实现,再按存储文件名(sharedPreferencesName)和键名前缀(preferencesKeyPrefix)确定读写范围。我用不同前缀区分两个账号,业务页面仍负责决定何时保存、读取或移除凭证。
这里的前缀只是同一个应用沙箱内部用来划分键名范围的命名约定,它既不是操作系统级的多用户隔离,也不能阻止同一应用内其他代码主动传入相同配置去读取数据。
1.2 三个来源的适配情况
动手前,我核对了 pub.dev、oh-Flutter 和 CPF-Flutter 三个来源,先把版本关系理清。
这里需要特别注意:Git 标签不等于 pub.dev 发布版本。 社区标签 11.2.0-ohos.1 内的鸿蒙子包版本是 1.2.2,但它没有发布到 pub.dev;同名包的发布版本仍是 1.0.0。
| 来源 | 核对版本 | 鸿蒙适配情况 |
|---|---|---|
| pub.dev 上游主包 | 11.2.0 | 缺少 Dart 层的鸿蒙平台路由 |
| oh-Flutter 社区仓库 | 标签 11.2.0-ohos.1;main 提交 3775a374 | 已有鸿蒙原生实现,但核对的 main 仍缺主包路由 |
| CPF-Flutter 开发分支 | br_v11.2.0_ohos_dev,提交 79fefc87 | 已修正单键动态前缀,尚未纳入上述标签;批量过滤与键名切分仍有问题 |
表1 版本核查
我用上游主包 11.2.0 与社区标签 11.2.0-ohos.1 作为原版对照。单键前缀修正参考社区开发分支已有实现;我新增的是主包路由和批量前缀精确过滤补丁。
复现原版问题时,测试工程使用的完整 pubspec.yaml 如下。这里显式并列引入了 pub.dev 上的 11.2.0 主包与社区固定 tag 的鸿蒙子包,目的是让构建工具把鸿蒙原生插件编译并注册进宿主应用,从而验证真实运行时的调用表现:
name: pgc_secure_pubstable
publish_to: none
environment:
sdk: '>=3.8.0 <4.0.0'
dependencies:
flutter:
sdk: flutter
flutter_secure_storage: 11.2.0
flutter_secure_storage_ohos:
git:
url: https://atomgit.com/oh-flutter/flutter_secure_storage.git
ref: 11.2.0-ohos.1
path: flutter_secure_storage_ohos
flutter:
uses-material-design: true
1.3 主包入口与前缀隔离的两处问题
我运行原版工程后,先遇到主包报错;补齐入口再测试,才发现鸿蒙子包的前缀处理也不一致。
主包缺少鸿蒙平台路由。 业务调用 FlutterSecureStorage 时,主包先通过 _selectOptions 选择平台配置,再发送到 MethodChannel。原版支持其他六个平台,却没有处理 TargetPlatform.ohos,因此在发送请求前就抛出 unsupported_platform。原生插件注册成功,并不代表业务调用已经能够到达它。
直接导入鸿蒙子包 flutter_secure_storage_ohos 可以绕开主包入口,但会改变公共业务代码的接入方式。我希望继续使用主包的 FlutterSecureStorage,所以选择补齐 OhosOptions 和平台路由。
鸿蒙子包没有统一使用本次调用的前缀。 在公开标签 11.2.0-ohos.1 中,我核对到三处问题:
- 单键操作固定默认前缀。
getKeyFromCall忽略传入的preferencesKeyPrefix,导致personal和work保存同名键session时互相覆盖。 - 默认调用沿用旧前缀。
ensureOptions()只在配置非空前缀时更新字段。共享原生实例先处理自定义前缀后,默认调用可能继续使用它。 - 批量操作匹配过宽。
includes会把other_scope_session也选进scope的范围;split('_')[1]则会截断含下划线的前缀或业务键,例如demo_personal、refresh_token。
我把修复分成两步:先让主包把请求交给鸿蒙子包,再对齐子包读写时使用的前缀。
二、修复前后的运行效果
2.1 同一台模拟器上的统一入口读写对照
我在 11 月 11 日整理交付分支后,用独立包名 com.example.securestoragepublication 重新构建并运行了三个入口。下文修复后的静态截图来自这次复核;前期 GIF 和日志摘录保留为故障定位记录。两轮使用相同的合成值和读回断言,不能将此前 HUKS 实验的结果套到这个分支。
我在同一台 Mate X7(API 26)模拟器上分别跑了原版和修复版。页面沿用同一份 lib/main.dart,写入的也都是合成值 fixture,两版均从主包 flutter_secure_storage 导入。对照时,我只换被测主包和子包;为了让两版各用各的沙箱,包名也分开了,其余依赖与宿主配置保持一致。
从桌面启动应用后,页面会自动执行五项检查。两版的运行过程分别录在下面的动图里:

图4 原版报错

图5 修复读写
我也保留了同一轮结束时的截图,方便看清每项输出:

图6 原版结果

图7 修复结果
两版显示的平台都是 TargetPlatform.ohos。接下来逐项比较调用结果:
| 检查项 | 通过条件 | 原版 | 修复版 |
|---|---|---|---|
write | 写入 pgc-test=fixture,不抛异常 | 平台路由报错 | 通过 |
read | 回读值等于 fixture | 平台路由报错 | 通过 |
delete | 删除后回读为 null | 平台路由报错 | 通过 |
reseed | 重新写入 fixture | 平台路由报错 | 通过 |
write-null-delete | 确认旧值存在,写入 null 后回读为空 | 前置读取报错 | 通过 |
表2 入口对照
原版的几项检查都报了 Unsupported operation: unsupported_platform。表里最后一项需要单独解释:测试页先读取旧值,这一步就报错了,后面的 write(value: null) 根本没有执行。因此,这一行仍是入口路由问题,不能据此认定空值删除还有一个独立故障。
我判断结果时还会看数据有没有真正变化。原标签的部分原生方法捕获异常后仍返回 result.success(null),所以 await 正常结束,也可能没有写入或删除成功。我在测试页里加了回读断言,读取值和删除后的值都要与预期对上。
2.2 双前缀同文件隔离与双账号页面实测
默认读写跑通后,我接着运行 lib/scope.dart,把 personal、work、scope 和 other_scope 四组键放在同一个专用文件 PGCUnifiedScopeSyntheticOnly 里。测试页每次给前缀附加微秒时间戳,结束时在 finally 中逐键清理,用来避免前一次残留数据影响本轮判断。

图8 范围动图

图9 范围结果
测试页输出图9 中的 RESULT=PASS 之前,会依次核对下面这些结果:
- 我让
personal和work都保存名为session的键,再分别读取,两边拿到的是各自写入的值。接着调用personal.readAll(),结果只有本空间的数据,返回的键名也还原为原业务键名。 - 再先读一次工作凭证,随后清空个人前缀。个人值成了
null,工作值仍是fixture-work。我把这两个操作接在一起,是要检查共享实例切换前缀后,会不会清错范围。 - 最后清空
scope,再读other_scope,值仍是fixture-other。前缀名称中间包含同一段文字,这次没有把它一起选中。
日志中的 upgrade-reason=unsupportedPlatform 是 checkUpgradeStatus() 对非 Android 平台的约定返回值。这项检查确认接口按契约返回,不代表鸿蒙已实现密钥升级诊断。
我还用 lib/accounts.dart 做了一个双账号页面。个人账号的前缀是 demo_personal,工作账号是 demo_work,两份数据都存进 secure_storage_account_example 文件。我先保存两份合成凭证,再点移除个人账号,最后刷新本地状态,界面的变化如下:

图10 双号保存

图11 单号移除
本轮我还在两份凭证保存后停止进程,再重新启动页面,确认两张卡片仍显示“已保存”。随后移除个人凭证,个人卡片变成了“未保存”,工作卡片还显示“已保存”。这两个前缀都含下划线,也覆盖了原版键名切分容易出错的命名方式。
2.3 运行环境、工程配置与关键 HiLog
以下结果来自官方模拟器。模板名称是 Mate X7,系统回读型号为 emulator,不能作为实体手机验证结果。
| 环境项 | 本次实测记录值 |
|---|---|
| Flutter 鸿蒙发行版 | 3.41.9+ohos-1.0.2(提交 62357a93d6) |
| Dart 版本 | 3.11.5 |
| DevEco Studio | 26.0.0.865 |
| HarmonyOS SDK | 26.0.0.105 Release,API 26 |
| 原版主包基线 | pub.dev 正式版 flutter_secure_storage 11.2.0 |
| 原版鸿蒙子包基线 | Git tag 11.2.0-ohos.1(提交 e6e94377,子包源码版本 1.2.2) |
| 平台接口包版本 | flutter_secure_storage_platform_interface 2.1.1 |
| 模拟器模板与规格 | Mate X7,arm64,4 核 CPU / 4 GB 内存,2210×2416(density 500) |
| 模拟器镜像与系统回读 | 镜像 7.0.0.107;系统回读 OpenHarmony-7.0.0.105 |
| 构建与安装方式 | 无签名 debug HAP(flutter build hap --debug --no-codesign) |
表3 运行环境
下面的配置图是按源码整理的对照图,日志图是原始记录的摘录。接入时需要核对的主包声明、原生类入口、OhosOptions 和宿主 module.json5,我放在图12 中对照。这个示例只访问应用沙箱里的首选项,requestPermissions 保持 [] 即可,不需要申请网络、相册或公共目录权限。

图12 配置对照
原版的启动日志里有插件和通道处理器的注册记录:
11-10 18:22:59.963 27392 27392 W A000FF/com.example.pgcsecurepubstable/Flutter: FlutterEngineCxnRegistry --> Adding plugin: SecureStorage::FlutterSecureStoragePlugin
11-10 18:22:59.963 27392 27392 D A000FF/com.example.pgcsecurepubstable/Flutter: DartMessenger --> Setting handler for channel 'plugins.it_nomads.com/flutter_secure_storage'
图13 第一段的 main-before.hilog(PID 6962)来自最早那轮三项检查。后来我加上重新写入和空值删除,复测五项,报的还是同一个错误。main-after-final.hilog(PID 18795)记录修复后的五项通过,scope-final.hilog(PID 23402)对应图9 的范围检查。

图13 关键日志
三、原理与核心代码分析
3.1 请求怎样到达鸿蒙存储
我把调用过程整理成图14,方便对应后面的改动位置。

图14 调用链路
一次请求经过三层:
- Dart 主包选配置。
FlutterSecureStorage接收实例或单次调用传入的OhosOptions,由_selectOptions选出文件名、前缀、算法和resetOnError,序列化为Map<String, String>,再交给平台接口。 - 鸿蒙插件确定键名。
FlutterSecureStorageOhosPlugin从方法通道收到参数,更新本次options。单键方法拼接${prefix}_${key};批量方法筛选${prefix}_开头的键,读取时再去掉前缀。 - 系统接口处理密文。
StorageCipher调用@kit.CryptoArchitectureKit加密业务值,将密文编码为 Base64,再通过@kit.ArkData的 Preferences 保存。写入、删除后的变更用flush()刷到磁盘。
路由补丁解决请求进不了通道的问题,前缀补丁决定请求进入通道后读写哪一组键,两者分别作用于不同层。
3.2 补齐平台路由与选项透传
我在主包的 lib/options/ohos_options.dart 中增加了 OhosOptions,通过 part 导出。文件名、前缀、两种算法、encryptedSharedPreferences 和 resetOnError 的字段与默认值均沿用鸿蒙子包定义,路由补丁不改变默认加密算法。
接着在构造函数和公开方法中加入可选参数 ohOptions,让 _selectOptions 能选择鸿蒙配置。完整函数如下:
Map<String, String> _selectOptions(
AppleOptions? iOptions,
AndroidOptions? aOptions,
OhosOptions? ohOptions,
LinuxOptions? lOptions,
WebOptions? webOptions,
AppleOptions? mOptions,
WindowsOptions? wOptions,
) {
if (kIsWeb) {
return webOptions?.params ?? this.webOptions.params;
} else if (defaultTargetPlatform == TargetPlatform.linux) {
return lOptions?.params ?? this.lOptions.params;
} else if (defaultTargetPlatform == TargetPlatform.iOS) {
return iOptions?.params ?? this.iOptions.params;
} else if (defaultTargetPlatform == TargetPlatform.android) {
return aOptions?.params ?? this.aOptions.params;
} else if (defaultTargetPlatform == TargetPlatform.windows) {
return wOptions?.params ?? this.wOptions.params;
} else if (defaultTargetPlatform == TargetPlatform.macOS) {
return mOptions?.params ?? this.mOptions.params;
} else if (defaultTargetPlatform.name == 'ohos') {
// 通过枚举名识别鸿蒙,保证源码也能在标准 Flutter SDK 上编译。
return ohOptions?.params ?? this.ohOptions.params;
} else {
throw UnsupportedError(_unsupportedPlatform);
}
}
我用 defaultTargetPlatform.name == 'ohos' 判断平台,因为标准 Flutter SDK 没有 TargetPlatform.ohos 枚举成员。按名称判断,可以让这份主包代码继续在标准 SDK 下做静态检查和单元测试;原有的 Web 优先判断及其他平台分支保持不变。
这里还要覆盖 write(value: null):按原包约定,它实际执行删除,也必须携带相同的鸿蒙选项。
Future<void> write({
required String key,
required String? value,
AppleOptions? iOptions,
AndroidOptions? aOptions,
OhosOptions? ohOptions,
LinuxOptions? lOptions,
WebOptions? webOptions,
AppleOptions? mOptions,
WindowsOptions? wOptions,
}) async {
if (value == null) {
await _platform.delete(
key: key,
options: _selectOptions(
iOptions,
aOptions,
ohOptions,
lOptions,
webOptions,
mOptions,
wOptions,
),
);
} else {
await _platform.write(
key: key,
value: value,
options: _selectOptions(
iOptions,
aOptions,
ohOptions,
lOptions,
webOptions,
mOptions,
wOptions,
),
);
}
_callListenersForKey(key, value);
}
如果只给正常写入传 ohOptions,空值分支就会丢失单次调用配置,出现写入自定义前缀、删除却使用实例默认前缀的错位。因此,我也检查了 read、containsKey、delete、readAll、deleteAll 和 checkUpgradeStatus 的参数透传。
3.3 统一单键前缀与缺省回退
原版 onMethodCall 已收到本次 options,但 getKeyFromCall 仍使用固定常量拼键。我把这一步交给存储实例,让键名随当前配置计算:
private getKeyFromCall(call: MethodCall): string {
if (this.secureStorage !== null) {
const map: ESObject = call.args;
const key: string = this.secureStorage.addPrefixToKey(map.get("key") as string);
return key;
} else {
return '';
}
}
在 FlutterSecureStorage.ets 中,configuredKeyPrefix() 统一处理有效前缀,供单键与批量方法共用:
public addPrefixToKey(key: string): string {
// 单键与批量操作都应使用本次调用的配置,不能固定成默认前缀。
this.ensureOptions();
return this.getKeyPrefix + '_' + key;
}
private configuredKeyPrefix(): string {
const prefix: string | undefined = this.options.get("preferencesKeyPrefix") as string | undefined;
return prefix && prefix.length > 0 ? prefix : FlutterSecureStorage.DEFAULT_KEY_PREFIX;
}
protected ensureOptions() {
if (this.options.has("sharedPreferencesName") && this.options.get("sharedPreferencesName")) {
this.SHARED_PREFERENCES_NAME = this.options.get("sharedPreferencesName") as string;
}
// 缺省配置恢复原默认值,不继承先前的自定义前缀。
this.setKeyPrefix = this.configuredKeyPrefix();
}
调用 addPrefixToKey 时,我先刷新本次配置,再拼接业务键。这样,personal 和 work 写入的 session 分别落到 personal_session 与 work_session。
这里我也处理了不传自定义前缀的调用。默认实例传来的是空字符串,代码要在这时返回原库的 DEFAULT_KEY_PREFIX。如果只更新非空配置,上一次留下的 work 就还在;默认实例接下来读到哪个范围,反而取决于此前访问过哪个账号。
3.4 精确筛选批量操作的键
接下来,我改了 readAll()、deleteAll() 选键的方式:原来的 includes 换成 startsWith(prefix + '_'),读出结果时再按前缀长度截出业务键。下面摘录 FlutterSecureStorage.ets 中修改后的完整 readAll() 函数(同文件中的 deleteAll() 同样用 prefixWithSeparator 与 startsWith 筛选目标键后执行 deleteSync 与 flush(),其余方法见插件仓库完整源码):
public async readAll(): Promise<Map<string, string>>{
// 固定本次范围;后续其他调用更新共享 options 时不改变该字符串。
const prefixWithSeparator: string = this.configuredKeyPrefix() + '_';
let all: Map<string, string> = new Map();
try {
this.ensureInitialized();
if(!this.dataPreferences) throw new Error("Storage is not initialized");
let raw: Object = this.dataPreferences.getAllSync();
let rawMap = convertObjectToMap(raw);
let promises: Promise<void>[] = [];
if(!rawMap.size) return all;
rawMap.forEach((rawVal, rawKey) => {
const keyWithPrefix: string = rawKey;
if (keyWithPrefix.startsWith(prefixWithSeparator)) {
const identifier = rawKey.substring(prefixWithSeparator.length);
const valuePromise: Promise<string> = this.decodeRawValue(rawVal);
promises.push(valuePromise.then((value: string) => {
all.set(identifier, value);
}))
}
})
await Promise.all(promises);
} catch (e) {
console.error(TAG, `readAll failure: error code ${e.code}`);
throw new Error("Secure storage operation failed");
}
return all;
}
这里还要把读取失败与没有记录分开。readAll() 解密出错时会抛出异常,由插件通道回传给 Dart;它不会把错误变成空集合。deleteAll() 也在 flush() 成功后才返回成功,异常只说明操作未正常完成,不能据此推断文件已被清空。遇到写盘错误,页面应停止连续重试,保留现场并重新核对数据。
读这段实现时,可以留意下面几处:
- 先取出本次调用的前缀。 函数入口保存
prefixWithSeparator,本次筛选始终使用这段字符串,不依赖上一次初始化留下的缓存前缀。图9 先读工作账号、再清理个人账号,检查的就是调用顺序是否影响范围。这项局部处理不能代替多引擎并发验证。 - 匹配时包含分隔符。
startsWith('work')会选中worker_session,而startsWith('work_')不会;scope_也不会命中other_scope_session。不过,嵌套前缀仍有歧义,第四章会说明命名限制。 - 按长度截取,不按下划线拆分。
demo_personal_长 14 个字符,从demo_personal_refresh_token的索引 14 开始截取,才会完整保留refresh_token。前缀和业务键中已有的下划线都不需要额外转义。
3.5 多账号页面的消费方式与回读断言
插件修好之后,应用侧依然从主包引入 FlutterSecureStorage,不再需要区分平台导入子包。在图10、图11 对应的双账号页面 example/lib/accounts.dart 中,每个账号实例只传入固定的存储文件名和自己的非嵌套前缀,下面摘录 _ExampleAccount 中构造存储实例的完整 storage 属性:
FlutterSecureStorage get storage => FlutterSecureStorage(
ohOptions: OhosOptions(
sharedPreferencesName: 'secure_storage_account_example',
preferencesKeyPrefix: prefix,
resetOnError: false,
),
);
页面保存与移除单个账号凭证时,不单凭 await 结束就宣布成功,而是立即调一次回读确认实际落盘状态。下面是 _AccountsPageState 中负责状态回读、写入与移除的三个完整方法(页面布局与状态栏渲染见 example/lib/accounts.dart,对应图6—图9 的两个自动化核验页完整代码见 example/lib/main.dart 与 example/lib/scope.dart):
Future<bool> _readStatus(_ExampleAccount account) async {
final value = await account.storage.read(key: _key);
if (value == null) return false;
if (value != account.value) {
throw StateError('示例凭证与预期不一致');
}
return true;
}
Future<void> _save(_ExampleAccount account) => _run(() async {
// 测试值由示例固定生成,不包含真实账号或凭证。
await account.storage.write(key: _key, value: account.value);
final saved = await _readStatus(account);
if (!saved) throw StateError('写入后未能读回');
if (mounted) setState(() => _saved[account.prefix] = true);
return '${account.title}的示例凭证已保存并读回';
});
Future<void> _remove(_ExampleAccount account) => _run(() async {
await account.storage.delete(key: _key);
final stillPresent = await _readStatus(account);
if (stillPresent) throw StateError('删除后仍能读到凭证');
if (mounted) setState(() => _saved[account.prefix] = false);
return '${account.title}的示例凭证已移除';
});
在 scope.dart 做批量隔离回归时,我也沿用了同样的回读原则:先读回 personal、work 的值,两边都和写入时一致,测试才会继续调用 readAll 和 deleteAll。原版的单键覆盖会让这一步直接报错。这样,即使个人数据没有存住,测试也不会把后面的空值当成“删除成功”。检查子串边界时,同样先确认 scope 与 other_scope 两份值都在,再删除目标范围并核对另一份值是否保留;测试结束后在 finally 中逐键清理本轮合成数据。
四、踩坑与边界条件
4.1 前缀命名与多文件切换
补丁沿用了 ${prefix}_${key} 的存储格式,账号前缀仍然不能互相嵌套。拿 personal_work_session 来说,它既可能是 personal_work 保存的 session,也可能是 personal 保存的 work_session。底层只有这一串键名,对 personal 执行批量操作时,仍会选中它。
所以,给账号分配前缀时,我建议统一命名格式,并检查它们是否互相嵌套。定长哈希或带结束标记的 acct-1001-end 都是可选写法。如果打算改用独立文件,还得继续检查文件切换:原公开 tag 的 ensureInitialized() 一旦发现 dataPreferences 已存在,就会直接返回。此时只改 sharedPreferencesName,还不能确认底层已经打开了另一个文件。这次整理的完整修复按文件名保存存储实例,并对已经初始化的算法配置做校验。本文仍用同文件的两个前缀验证账号隔离;我没有把文件名变化本身当作迁移旧凭证的方法。
页面上的状态也不能只看监听通知。原版 deleteAll() 会向所有注册键广播 null,其他账号收到这个通知,并不等于它的凭证被删了。我会让页面再读取一次对应键,用读回结果更新状态。
4.2 类型转换与异常回执
联调时,我还遇到了路径、依赖和参数转换的问题,按当时的现象整理在表4 中。
| 现象 | 原因 | 处理方式 |
|---|---|---|
HAP 构建报 00306003 | 本轮 Hvigor 不接受中文工程路径 | 完整工程复制到 ASCII 路径 |
resetOnError: false 被判为真 | ArkTS 中 !!"false" 为 true | 严格比较 raw === true || raw === "true" |
| 改了代码却仍运行旧子包 | Git 依赖与锁文件仍指向旧提交 | 主包引用同仓子包,重新解析依赖并核对锁文件 |
| 没有报错,数据却没写入 | 原版部分 catch 只记日志,仍返回成功 | 修复版回传异常,页面再做读回校验 |
表4 排坑要点
这里我会特别检查 resetOnError 传到原生层后的值。主包把 options 定义为 Map<String, String>,布尔值序列化后变成字符串。旧实现把非空的 "false" 判成真,遇到异常就可能走到 deleteAll()。因此,Dart 侧配置了 false 还不够,原生层也得按它实际收到的类型解析,才能避免把保留数据的设置读反。
4.3 数据保留与密钥管理边界
这次交付保留原来的 RSA/CBC 数据格式。我将 HUKS、GCM 与恢复实验留在独立工程,没有把它们混进本文修复分支。下面的覆盖升级与重装现象属于此前实验记录,不能代替当前分支的升级验收。
覆盖升级。 在 Mate X7 模拟器上,我先用旧版向两个 Preferences 文件写入 AES-CBC 合成值,再保留沙箱、以 install -r 同包名覆盖安装候选包。冷启动后,两份旧值均可读回。不过,旧版若已把两个账号的同名键写到同一默认键下,早先的值就已被覆盖。补丁不能恢复这部分数据;旧键归属不明时,应引导重新登录。
密钥管理与重装。 原公开 tag 用 AES 加密业务值,再用 RSA 保护 AES 密钥。RSA 私钥经 Base64 编码,保存在应用私有的 FlutterSecureRSAKeyStorage 首选项文件中。文件受应用沙箱保护,但 Base64 是编码,不能据此宣称硬件级密钥保护。
在 latest-unified 实验中,我尝试用 @kit.UniversalKeystoreKit(HUKS)管理 RSA 包装密钥,并以 fileIo.File.tryLock 验证同进程双 FlutterEngine 的竞争拒绝与交接。另一次 Pura X View(API 26)模拟器实验中,执行 uninstall -k 保留数据卸载、再安装后,首选项仍在,HUKS 密钥别名却已丢失。读取因此报密钥不可用,应停止自动重试并重新认证。
整理发布源码时,我保留了与数据不被误删直接相关的保护:同一插件实例把请求排队,避免后一次调用提前改写前一次调用的配置;不同 engine 通过文件锁限定单个活跃持有者,拿不到锁的实例明确返回占用错误。锁必须等在途读写结束后才释放,不能在 engine 脱离时立刻交给下一个实例。这套策略接受“第二个实例暂时不能读写”的限制,并不承诺多个 engine 并行共享。
另外,修复版读取旧密文时若缺少密钥,会返回错误,不会新建一把钥匙盖过旧记录。生成新密钥时,只有包装记录成功刷盘后才允许它参与业务加密;一旦写盘结果不确定,就停止当前运行时的后续自动操作。即使调用方设了 resetOnError: true,本分支也不自动清空数据。这样做保住的是排查和重新认证的机会,并没有凭空找回丢失的密钥。
这些结果还不足以证明完整的恢复能力。HUKS 迁移、AES-GCM 切换、多引擎长时并发及断电恢复,仍需单独设计和审计;本篇不把它们列为已完成的生产级安全能力。
4.4 验证范围
| 维度 | 已验证 | 尚未覆盖或需规避 |
|---|---|---|
| 统一入口 | Mate X7(API 26)模拟器的读、写、删除与空值删除 | 手机、平板、2in1 真机签名及其他 API 版本 |
| 前缀隔离 | 同文件非嵌套前缀的单键与批量操作 | personal / personal_work 等嵌套命名 |
| 主机检查 | Dart 路由 8 项通过、1 项 Web 条件跳过;范围 6 项、异常 17 项及租约检查通过 | Preferences 与加解密模块使用替身,不能替代设备调用 |
| 其他平台 | 保留 Android、iOS、Linux、macOS、Web、Windows 原分支与签名 | 本轮只验收鸿蒙,未做其他六端运行回归 |
表5 验证边界
五、接入与复用建议
5.1 工程与复现步骤
我把本文交付收束为一个完整仓库:flutter_secure_storage/ 保存主包路由,flutter_secure_storage_ohos/ 保存鸿蒙实现,example/ 保存三张实际核验页面,validation/ 保存主机检查。根 README 记录版本、许可和运行步骤。此前的 HUKS 实验不作为这次接入入口。
主包在 pubspec.yaml 中声明 path: ../flutter_secure_storage_ohos,指向同一份仓库快照里的子包。这样,读者取得主包时不会又解析到社区原版鸿蒙子包,也不需要靠 Example 的临时覆盖配置修正依赖。这里的 path 有明确前提:两个目录需要一同保留,不能只复制主包目录。
我还用独立消费者工程对公开远端仓库做了 Git 子目录依赖检查:只声明主包,解析结果中的主包与鸿蒙子包都指向远端同一提交 c90ce18dbda650dfd21f9723c9862f02ca570790(核心修复提交 82c88114),没有回落到旧包。业务工程接入时可直接使用下面的固定提交配置(也可将 ref 设为 fix/ohos-routing-prefix 跟踪分支):
dependencies:
flutter_secure_storage:
git:
url: https://atomgit.com/abaoge/flutter_secure_storage.git
ref: c90ce18dbda650dfd21f9723c9862f02ca570790
path: flutter_secure_storage
先配置工具链。 我将完整仓库放在 ASCII 路径下,使用前文列出的社区 Flutter 与 DevEco 版本。按本机安装位置设置以下变量:
: "${FLUTTER_OHOS_HOME:?请设置社区 Flutter 3.41.9+ohos 安装目录}"
: "${DEVECO_STUDIO_CONTENTS:?请设置 DevEco Studio 的 Contents 目录}"
export DEVECO_SDK_HOME="$DEVECO_STUDIO_CONTENTS/sdk"
export JAVA_HOME="$DEVECO_STUDIO_CONTENTS/jbr/Contents/Home"
export PATH="$FLUTTER_OHOS_HOME/bin:$DEVECO_STUDIO_CONTENTS/tools/node/bin:$DEVECO_STUDIO_CONTENTS/tools/ohpm/bin:$DEVECO_STUDIO_CONTENTS/tools/hvigor/bin:$DEVECO_STUDIO_CONTENTS/sdk/default/openharmony/toolchains:$PATH"
从仓库根目录运行检查。 主机检查使用系统接口替身,用来定位前缀、异常和锁的逻辑问题;运行通过后,还需要在鸿蒙模拟器核对实际读写。
node validation/probe.cjs patched
node validation/reset_check.cjs
node validation/lease_host_test.cjs
cd flutter_secure_storage
flutter pub get
flutter test test/ohos_platform_routing_test.dart
flutter analyze lib test/ohos_platform_routing_test.dart
cd ../example
flutter pub get
flutter analyze lib/main.dart lib/scope.dart lib/accounts.dart
flutter build hap --debug --no-codesign -t lib/main.dart
每次构建一个入口,随后安装运行。 main.dart 检查五项基本读写;换成 scope.dart 检查前缀范围;换成 accounts.dart 打开双账号页面。如果连续构建三次才安装,输出路径里只剩最后一次产物,不能据此说三个页面都跑过了。
hdc list targets
: "${HARMONY_DEVICE_ID:?请选择本次官方模拟器的设备 ID}"
hdc -t "$HARMONY_DEVICE_ID" install -r ohos/entry/build/default/outputs/default/entry-default-unsigned.hap
hdc -t "$HARMONY_DEVICE_ID" shell aa start -b com.example.securestoragepublication -a EntryAbility
这次复核使用独立的测试包名,不覆盖已有实验应用。页面只写合成凭证,前缀测试结束后逐键清理本轮数据。这里的无签名安装适用于已验证的模拟器,不能作为真机签名步骤。
我保留了主包 BSD-3-Clause、鸿蒙子包 MIT 许可,以及原生文件中的 Apache-2.0 声明。业务项目升级时建议保留 pubspec.lock 并核对主包与鸿蒙子包均解析至上述固定提交。
5.2 接入多账号业务
把示例接回应用时,我会先确认下面几件事:
- 先确定需要保留几套凭证。 如果只保留一套,我会用默认实例;需要多个账号并存时,再给它们设置互不嵌套的前缀。想改用独立文件的话,还要先核对 4.1 节的文件切换约束。
- 退出时优先删除明确的键。
access_token、refresh_token等键名已知时,我会逐个删除,方便追踪,也能避开批量删除向全部监听器发送通知的行为。确实要清空当前账号这组键时,再调用deleteAll()。 - 把本地删除和服务端注销分别安排好。 网络请求失败时,先做哪一步、后做哪一步,需要由业务确定。我排查这类问题时,会记录操作名、脱敏后的范围和错误码。本文打印的
fixture只是合成数据,能用来检查读回结果;接入真实账号后,不能在日志中照样输出令牌。 - 给读取失败安排登录入口。 首次安装、清除数据、升级或重装后,读取结果可能为空,也可能抛出
PlatformException。遇到这些情况,我会转到重新认证的流程,让主界面继续启动,不一直等着本地凭证恢复。
小结
在鸿蒙模拟器的双账号页面上,我分别保存、读回两份凭证,再移除个人账号,工作账号的凭证仍能读回。页面继续使用主包统一入口,补丁负责接通主包选项路由,并修正鸿蒙侧的单键前缀和批量范围匹配。至于账号怎样命名、退出时怎样处理,仍要由应用决定。
换到自己的项目里,我会先按本文步骤跑示例,确认目标环境能得到相同结果,再处理旧凭证属于哪个账号、读取失败后怎么办。本文还没有完成真机回归和密码方案审计,这两项仍要单独验证。
参考资料
- abaoge/flutter_secure_storage(fix/ohos-routing-prefix 分支):本文主包路由补齐、前缀隔离修复与同款三入口验证工程公开源码。
- flutter_secure_storage 11.2.0:Flutter 上游主包接口说明与平台支持列表。
- oh-flutter/flutter_secure_storage:社区维护的主包与鸿蒙子包适配仓库(含公开 tag
11.2.0-ohos.1)。 - CPF-Flutter 开发分支 br_v11.2.0_ohos_dev:社区单键前缀修正参考分支。
- HarmonyOS 用户首选项 (@ohos.data.preferences):鸿蒙应用沙箱键值访问与持久化 API 参考。
- Universal Keystore Kit(密钥管理服务)简介:鸿蒙系统级密钥生成与管理能力说明。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)