欢迎到 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 多账号本地凭证需要怎样的存取边界

image

图2 QQ 登录页

用户在手机上同时保留个人账号和工作账号,切换时希望各自的登录状态还在。应用通常会在本地加密保存服务端签发的访问令牌或刷新凭证,下次启动时再读取、校验。我对本地存储的要求很具体:写入工作账号不能覆盖个人账号,移除个人账号也不能删掉工作账号的凭证。

令牌过期、服务端吊销和多设备互踢仍由登录业务处理,头像与聊天记录也有各自的存储方式。这次我只处理少量凭证的本地读写,不把账号管理逻辑放进插件。

image

图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 中,我核对到三处问题:

  1. 单键操作固定默认前缀。 getKeyFromCall 忽略传入的 preferencesKeyPrefix,导致 personal 和 work 保存同名键 session 时互相覆盖。
  2. 默认调用沿用旧前缀。 ensureOptions() 只在配置非空前缀时更新字段。共享原生实例先处理自定义前缀后,默认调用可能继续使用它。
  3. 批量操作匹配过宽。 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 Studio26.0.0.865
HarmonyOS SDK26.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 保持 [] 即可,不需要申请网络、相册或公共目录权限。

联合插件声明与OhosOptions及宿主配置对照

图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 的范围检查。

原版报错与修复后读写及范围隔离的HiLog日志

图13 关键日志

三、原理与核心代码分析

3.1 请求怎样到达鸿蒙存储

我把调用过程整理成图14,方便对应后面的改动位置。

Flutter主包统一入口到鸿蒙前缀隔离与持久化链路

图14 调用链路

一次请求经过三层:

  1. Dart 主包选配置。 FlutterSecureStorage 接收实例或单次调用传入的 OhosOptions,由 _selectOptions 选出文件名、前缀、算法和 resetOnError,序列化为 Map<String, String>,再交给平台接口。
  2. 鸿蒙插件确定键名。 FlutterSecureStorageOhosPlugin 从方法通道收到参数,更新本次 options。单键方法拼接 ${prefix}_${key};批量方法筛选 ${prefix}_ 开头的键,读取时再去掉前缀。
  3. 系统接口处理密文。 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 接入多账号业务

把示例接回应用时,我会先确认下面几件事:

  1. 先确定需要保留几套凭证。 如果只保留一套,我会用默认实例;需要多个账号并存时,再给它们设置互不嵌套的前缀。想改用独立文件的话,还要先核对 4.1 节的文件切换约束。
  2. 退出时优先删除明确的键。 access_token、refresh_token 等键名已知时,我会逐个删除,方便追踪,也能避开批量删除向全部监听器发送通知的行为。确实要清空当前账号这组键时,再调用 deleteAll()。
  3. 把本地删除和服务端注销分别安排好。 网络请求失败时,先做哪一步、后做哪一步,需要由业务确定。我排查这类问题时,会记录操作名、脱敏后的范围和错误码。本文打印的 fixture 只是合成数据,能用来检查读回结果;接入真实账号后,不能在日志中照样输出令牌。
  4. 给读取失败安排登录入口。 首次安装、清除数据、升级或重装后,读取结果可能为空,也可能抛出 PlatformException。遇到这些情况,我会转到重新认证的流程,让主界面继续启动,不一直等着本地凭证恢复。

小结

在鸿蒙模拟器的双账号页面上,我分别保存、读回两份凭证,再移除个人账号,工作账号的凭证仍能读回。页面继续使用主包统一入口,补丁负责接通主包选项路由,并修正鸿蒙侧的单键前缀和批量范围匹配。至于账号怎样命名、退出时怎样处理,仍要由应用决定。

换到自己的项目里,我会先按本文步骤跑示例,确认目标环境能得到相同结果,再处理旧凭证属于哪个账号、读取失败后怎么办。本文还没有完成真机回归和密码方案审计,这两项仍要单独验证。

参考资料

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐