做 Flutter 鸿蒙适配,真正让人头皮发麻的往往不是 Flutter 引擎本身,而是那一堆藏在纯 Dart 层下面的平台插件。前两天我把at_server_status这个库迁移到鸿蒙工程里,前后折腾了近两天。这库看着不起眼,却是@protocol去中心化身份体系里非常关键的一环:它负责实时感知 atServer 的在线状态、响应延迟、SSH 密钥配对情况,以及鉴权链路的健康度。应用侧只有拿到这些透明、实时的状态,才能在用户毫无感知的情况下完成自动重连、密钥轮换和异常告警。这篇文章不打算讲空话,就记录我从依赖审计、插件壳创建、平台通道设计到最终跑通flutter run -d hdc的完整过程,顺手把那些网上搜半天也搜不到的问题列出来。
1. 先搞明白 at_server_status 到底在做什么
1.1 去中心化身份服务器状态感知是什么
@protocol这套体系里,每个用户都拥有一台被称为 atServer 的个人数据服务器,用户的身份、备书、共享数据都存放在这里。既然是个人服务器,它的运行状态就不是云厂商那种“可用性 99.99%”可以一概而论的:可能你家里的路由器重启了,可能服务器所在机房出口被封了,也可能 SSH 密钥因为换机重新生成导致握手失败。这时候,客户端如果没有一个清晰的状态感知层,用户体验就会变成“莫名其妙连不上、也看不到哪里出了问题”。
at_server_status解决的问题就是这个。它不是简单的 ping 一下 IP,而是从三个层面去做状态判断:第一是网络连通性,也就是 TCP 层能不能连上;第二是 atServer 协议层的响应是否正常,比如能否返回@开头握手响应;第三是鉴权链路的可用性,通常会校验 SSH 密钥对与服务器本地密钥是否仍然匹配。这三个层面组合在一起,才算是“感知到了真实状态”。
在鸿蒙上做这件事,麻烦点不在于 Dart 层的逻辑,而在于你没法假设底层网络栈和密钥存储行为跟 Android 完全一致。HarmonyOS NEXT 把 AOSP 那一套剥离之后,很多原本在 Android 上“顺手能用”的东西,到了鸿蒙上就得换成系统原生能力去补位。
1.2 原版库的依赖和平台通道
原版的at_server_status从代码结构看是一个偏纯 Dart 的库,核心逻辑集中在状态机、超时控制和结果归集上。网络请求部分主要依赖http和web_socket_channel,SSH 密钥操作则借助ssh_key和asn1lib完成。理论上,这种纯 Dart 依赖是可以直接跑在 OpenHarmony 的 Flutter 运行时上的。
但真跑到真机上,问题就暴露了:鸿蒙系统的网络策略、DNS 解析行为、安全存储接口跟 Android/iOS 有差异,尤其当你需要做“透明”状态监控时,不能简单依赖 HTTP 层头进行探测,而要拿到更底层的 socket 连接状态和握手耗时。所以我在适配时做了一个很克制的决定:Dart 层尽量保留原本的 API,只新增一个极薄的 PlatformChannel,用于获取原生网络探测能力和密钥存储兜底。
为什么说“克制”?因为很多团队一做适配就忍不住把整个库重写成原生逻辑,这完全走偏了。at_server_status的核心价值在状态机逻辑和业务语义,原生层只需要提供“连接是否可达”“加密握手耗时”这类底层原子能力。保留 Dart 层还带来一个额外好处:后续鸿蒙 Flutter 引擎升级,或者 OpenHarmony 分支底层实现变化,Dart 逻辑不需要再动。
2. 鸿蒙化适配前的环境与策略准备
2.1 Flutter on OpenHarmony 的落地方案
目前跑鸿蒙的 Flutter 方案不是官方 Flutter SDK 直接支持,而是 OpenHarmony SIG 维护的flutter_flutter仓库。这块环境搭建有几个容易踩坑的点,我按顺序说。
首先,安装鸿蒙 Flutter SDK。这里注意别用 Flutter 官方渠道的flutter命令去执行flutter doctor,否则你永远看不到ohos平台。正确做法是拉取flutter_flutter的分支代码,切到oh-xxx对应版本,然后把bin目录加入 PATH。
其次,鸿蒙侧需要安装 DevEco Studio,并且 SDK 版本要与 Flutter 分支要求的 ohos API 版本对齐。我用的是 API 12 的 SDK,对应 Flutter 3.22 的分支,整体兼容性是目前比较稳的组合。如果你直接上 API 14 或者更新的 DevEco,有些中间产物路径会变,插件编译时容易找不到ohos-sdk。
最后,在pubspec.yaml里不需要额外标记平台,生成ohos目录需要执行flutter create --template=module --platforms=ohos .。这个命令在老版本 Flutter 分支里可能不存在,需要确认你拉的分支是否已经内置了 ohos 模板。如果没内置,就手动创建ohos目录,再写oh-package.json5,跟标准 OpenHarmony 工程结构对齐。
2.2 依赖审计与替代方案选择
开始改造之前,先把at_server_status的依赖树拉出来看一遍:
dependencies: at_server_status: path: packages/at_server_status我实际用flutter pub deps --style=compact看到的依赖包括http、web_socket_channel、ssh_key、asn1lib、meta。这些包里面,http和web_socket_channel是纯 Dart 实现,理论上跨平台没有问题,但鸿蒙的 Flutter 对dart:io的支持并不完全一致,特别是 socket 的一些原生行为。ssh_key这个包在生成密钥时会用到系统随机数,Android 上直接走Random.secure(),鸿蒙上底层能力不同,偶尔会出现密钥生成速度极慢的情况。
我的替代方案是:
- 用
dart:io的Socket.connect做一个底层 TCP 探测通道,不经过http,这样能拿到更纯粹的连接耗时。 - 把密钥存储从
shared_preferences和本地文件改为通过 Flutter 插件调用鸿蒙的@ohos.security.asset,避免密钥裸存在沙箱文件里被清掉。 - SSH 握手的校验逻辑保留在 Dart 层,但连接层探测完全交给原生通道,因为鸿蒙网络栈对
connectTimeout的处理和 Android 不一致。
这条策略的核心是“能纯 Dart 解决的不动,必须动平台能力的就隔离成接口”。如果一开始不把策略定下来,后面改着改着就会变成“为了适配而适配”,状态机的可测试性会被严重破坏。
2.3 适配原则:不碰Dart层,只补平台缺口
我自己定下的适配原则只有三条:第一,Dart 层的现有 API 命名和同步模式一概不换,保证老业务逻辑零迁移;第二,凡是可能被系统拦截的底层调用,全部收口到AtServerStatusPlatform抽象类里,用 FederatedPlugin 的形态放到ohos目录;第三,原生层只做测量和上报,不做任何状态决策。
为什么这样做?at_server_status本身在业务侧已经被很多项目用了,如果我把 API 从AtServerStatus改成AtServerStatusOhos,会导致调用方全部重写。保持 API 不动,同时允许动态注册平台实现,才是最稳妥的插件化方案。在 Dart 侧,只需要这样一段注册逻辑:
class AtServerStatus { static void _registerPlatform() { if (Platform.isAndroid || Platform.isIOS) { // 原有实现 } else if (Platform.isOhos) { AtServerStatusNative.instance = OhosAtServerStatusNative(); } } }这块看起很朴素,但直接避免了“if (Platform.isOhos) else”满代码飞的情况。后面新增 Windows 或者 macOS 支持,也只需要再补一个平台实现类。
3. 核心改造过程实录
3.1 创建 ohos 插件壳
我选择用 Flutter 插件模板来管理原生代码,这样at_server_status既可以作为本地路径依赖,也可以后续发布到鸿蒙仓库。先建一个专门放平台适配代码的插件包,结构大致如下:
at_server_status_ohos/ ├── ohos/ │ ├── build-profile.json5 │ ├── oh-package.json5 │ └── src/main/ │ ├── ets/ │ │ ├── AtServerStatusPlugin.ets │ │ └── AtServerStatusNative.ets │ └── module.json5 ├── pubspec.yaml └── lib/ └── at_server_status_ohos.dartohos插件本质上是标准 OpenHarmony 模块,oh-package.json5里要声明 Flutter 插件的依赖:
{ "name": "at_server_status_ohos", "version": "1.0.0", "main": "Index.ets", "dependencies": { "@ohos/flutter_ohos": "file:./flutter" } }如果你把它集成到宿主工程,这个flutter依赖路径要跟你的 Flutter 引擎模块对齐,不然会报找不到Plugin的编译错误。这也是最容易被忽略的一环,很多人折腾半天发现是依赖路径写错。
3.2 把 at_server_status 打进鸿蒙工程
宿主 Flutter 工程里需要同时引用at_server_status和at_server_status_ohos两个包。在pubspec.yaml里这样写:
dependencies: at_server_status: path: ../packages/at_server_status at_server_status_ohos: path: ../plugins/at_server_status_ohos然后执行flutter pub get。这里有个坑:由于flutter_flutter是社区分支,pub 命令对 ohos 平台识别有时不完整,如果你在pubspec.yaml的flutter: plugin: platforms:里没有声明ohos,原生插件就不会被自动注册。
所以插件的pubspec.yaml里要显式声明:
flutter: plugin: platforms: ohos: pluginClass: AtServerStatusPlugin dartPluginClass: AtServerStatusOhosPlugindartPluginClass是鸿蒙适配特别有用的一点,允许你在 Dart 侧写一个统一入口,原生pluginClass只是负责注册 MethodChannel 和 EventChannel。
3.3 网络探测、密钥存储、鉴权监控的鸿蒙实现
网络探测这块,我在原生层通过Socket做 TCP 连接,并且把时间点记录到微秒级。核心 intention 是:不把探测逻辑复杂化,只返回四个字段:connectSpentMillis、reachable、localAddress、errorDetail。
下面是鸿蒙侧用 ArkTS 写的 TCP 探测核心方法,省略了错误分支,但保留了关键路径:
import { socket } from '@kit.NetworkKit'; import { connection } from '@kit.NetworkKit'; async probeTCP(host: string, port: number): Promise<Record<string, Object>> { const start = performance.now(); let result: Record<string, Object> = { reachable: false, connectSpentMillis: 0, errorDetail: '', }; const conn = connection.getDefaultSync(); const netHandle = await conn.getDefaultNet(); const tcpSocket: socket.TCPSocket = await socket.constructTCPSocketInstance(); try { await tcpSocket.connect({ address: { address: host, port }, timeout: 5000 }); result.reachable = true; result.connectSpentMillis = Math.floor(performance.now() - start); } catch (err) { result.errorDetail = JSON.stringify(err); } finally { tcpSocket.close(); } return result; }注意,鸿蒙上socket.connect的timeout字段单位是毫秒,但如果你传了超时参数还额外在 Dart 侧用自带 timeout 包一层,两层超时会有优先级不一致的隐患。我的建议是,原生层只设一个较大的兜底超时,精确的业务超时控制交给 Dart 状态机,不然很难排查“到底是鸿蒙超时了还是 Dart 超时了”。
密钥存储则用鸿蒙的asset接口。原版库为了兼顾多平台,把 ssh key 写到应用沙箱目录,在 Android 上没问题,但在鸿蒙 NEXT 上应用沙箱规则更严,某些路径你写进去容易,读出来没问题,可一旦应用被系统清理,密钥就没了。所以我改成走 Asset Store:
import { asset } from '@kit.AssetStoreKit'; async function storeKey(alias: string, keyData: Uint8Array): Promise<void> { const query = { label: alias, data: keyData, accessControl: asset.AccessControl.NORMAL_ACCESS, }; await asset.add(query); }密钥轮换场景还要先remove再add,不能直接覆盖。这点跟 Android 的KeyStore行为有差异,稍不留神就会出现“旧密钥还在但新密钥写不进去”的情况。
鉴权监控跟网络探测稍有不同,它更偏向业务层:需要拿 atServer 的pkam握手响应和时间戳。这部分我保留在 Dart 层做,因为要用到@protocol的签名逻辑,原生层不参与签名。原生层只把 TCP 探测结果、本地时间戳和密钥读取结果传给 Dart,由 Dart 完成状态聚合。
3.4 实时状态更新的 EventChannel 设计
“实时”这个要求,通过拉模式是做不到的。原版库主要靠轮询,轮询的时间间隔通常在 2 到 5 秒。在鸿蒙上我增加了两种推送通道:一种是鸿蒙网络状态变化的广播,比如网络从 Wi-Fi 切换到蜂窝网络时主动触发一次探测;另一种是本地 socket 断开事件,由原生层监听后立刻上报。
使用 EventChannel 把原生事件流引到 Dart 侧:
EventChannel _statusEventChannel = const EventChannel('at_server_status/status_events'); Stream<AtServerStatusSnapshot> get statusStream { return _statusEventChannel.receiveBroadcastStream().map((event) { return AtServerStatusSnapshot.fromJson(Map<String, dynamic>.from(event as Map)); }).handleError((e) { return AtServerStatusSnapshot.unknown(); }); }原生 ArkTS 侧用EventSink持续推送:
let eventSink: EventSink | null = null; const channel = new EventChannel('at_server_status/status_events', (req) => { eventSink = req.eventSink; return true; });推送逻辑上有一点要注意:鸿蒙的 EventChannel 在应用进入后台后,消息发送频率会被系统节流。如果你把后台状态息屏也算作离线,就会产生误报。我最后的处理是,在 Dart 侧对 AppLifecycleState 做一层过滤,只有前台才记录状态变化事件,后台只保留最后一个快照。
这样做的原因是,鸿蒙的省电策略会在后台收紧网络连接,socket 断开事件在后台并不代表 atServer 真的离线,而更可能只是本地进程被冻结。如果不加过滤,很多用户会看到“状态在后台疯狂跳变”的糟糕体验。
4. 运行效果与性能实测
4.1 测试环境与监测指标
我这边测试设备是华为 Mate 60 Pro 和一台 Dayu 200 开发板,鸿蒙 API 12,Flutter 分支基于 3.22,DevEco Studio 5.0.0。监测指标分四块:首次握手耗时、TCP 探测成功率、状态机聚合耗时、内存增量。
首屏场景是 App 启动后自动拉取 atServer 状态。这里比较关键的是“透明”体验:不能让用户等到状态结果出来才看到界面,所以 UI 层先展示缓存态,状态流到达后无缝刷新。实测下来,冷启动到第一个状态快照输出约 680ms,其中 Tokens 加载占了 300ms 左右,TCP 探测占了 200ms,剩下 180ms 是状态聚合和事件分发。
TCP 探测成功率在正常 Wi-Fi 环境下是 100%,在弱网环境(信号强度 -95dBm)会下降到 84%,但这 84% 不是误判,而是真正的 TCP 连接超时。关键在于,错误信息能准确区分“DNS 解析失败”“TCP 超时”“TLS 握手失败”,这三类错误在errorDetail字段里会被明确标记,Dart 侧就可以针对不同错误做不同的重试策略。
4.2 透明度和实时性的权衡策略
透明意味着用户能感知状态变化,但频繁的通知会变成噪音。我在鸿蒙上采用了一个很简单的策略,却意外地有效:连续两次状态变化间隔低于 1.5 秒时,不推送事件,只更新内部快照;只有状态“稳定变化”才推送。
什么叫“稳定变化”?比如从connected变到reconnecting,如果 1.5 秒后又变回connected,那么只保留最终状态,不推送中间态。这样既保证了用户可以感知异常,又不会被抖动搞得心慌。
实时性这块,EventChannel 的事件延迟在真机上平均 30ms 到 80ms,基本可以忽略。但要注意的是,事件推送的频率不能超过原生层 500ms 一次,因为鸿蒙的 IPC 调用也有开销。我在原生层加了简单的节流阀,同一状态下重复事件间隔小于 500ms 的直接丢弃。
4.3 内存、耗电和延迟数据
跑了一个小时持续监控,App 的内存增量大约 12MB,这个增量主要来自 EventChannel 的临时缓存和 Dart 对象快照,不属于泄漏。GC 之后基本回到基线。
耗电方面,鸿蒙后台任务会把网络探测频率压得很低。我的实测结果:前台 3 秒探测一次,一小时增加耗电 5% 左右;后台 30 秒探测一次,几乎可以忽略。如果一直保持前台高频探测,耗电肯定是硬伤。最后的方案是,前台用 3 秒一个周期,后台退到 15 秒,并在系统广播网络变化时主动唤醒。
延迟数据做个表格,方便后面优化时对照:
| 阶段 | 最小耗时 | 平均耗时 | 最大耗时 | 说明 |
|---|---|---|---|---|
| TCP 连接 | 18ms | 45ms | 280ms | 弱网下明显升高 |
| 协议握手 | 8ms | 24ms | 130ms | 受服务器负载影响 |
| 状态聚合 | 1ms | 3ms | 12ms | Dart 层纯计算 |
| 原生到 Dart 事件分发 | 15ms | 38ms | 110ms | IPC 耗时变化 |
这组数据也说明,鸿蒙真实网络栈的 TCP 连接耗时并不比 Android 差太多,但握手阶段因为涉及 atServer 密钥交换,大头还是在网络 RTT 上,优化的重心应该放在避免无效重试上。
5. 常见问题与排查技巧实录
5.1 编译期出现的 TypeError 和找不到模块
鸿蒙 Flutter 插件最常见的编译错误是Cannot find module '@ohos/hypium'或者Cannot find name 'EventChannel'。前者是因为缺少测试依赖,在oh-package.json5的devDependencies里补上 hypium 就行。后者通常是因为 Flutter 引擎模块没有正确导入,需要在module.json5的dependencies里加入@ohos/flutter_ohos。
另外还有一个很隐蔽的错误:ArkTS 的严格模式不允许使用Object作为无类型 JSON 的 catch 参数。很多从 TypeScript 转过来的开发者会写catch (e),这在 ArkTS 里会抛 “catch parameter must be typed” 的编译错误。我全部改成了:
} catch (err) { const typedErr = err as BusinessError; result.errorDetail = `${typedErr.code}: ${typedErr.message}`; }5.2 运行时鉴权失败与密钥问题
适配后第一次跑真实 atServer,返回的鉴权结果是pkamVerification: false。排查下来不是库的逻辑问题,而是鸿蒙上沙箱路径变了。原版库在 Android 上习惯把 atKeys 文件放在getApplicationSupportDirectory(),鸿蒙当前版本对这个目录的写权限有调整,写入后进程重启可能丢失,导致 SSH 私钥读出来是空字符串。
解决办法是把密钥读取路径统一收敛到原生 Asset Store,然后通过 MethodChannel 提供给 Dart。注意要保留多份密钥备份,因为服务器密钥轮换需要同时使用新旧密钥,傻乎乎只存一份会导致轮换失败。这算是我这次适配中最值得写出来的经验。
5.3 鸿蒙权限配置清单
如果你的应用要做 TCP 探测,必须在module.json5里声明ohos.permission.INTERNET。这个权限没什么坑,但真正容易漏的是网络状态监听权限ohos.permission.GET_NETWORK_INFO,以及后台访问网络的特殊权限。没有GET_NETWORK_INFO,connection.getDefaultNet()可能会返回一个空句柄,排查问题时非常误导。
鸿蒙的权限配置位置有两种:src/main/module.json5里的requestPermissions字段,以及在acls里配置受限权限。一般网络探测只涉及普通权限,不需要特殊 ACL。如果你在开发板上调试,还需要注意设备是否已经开启“允许后台应用联网”的设置,这个开关在设置里的位置比较深,实际测试时常被忽略。
5.4 配套的排查速查表
| 症状 | 可能原因 | 处理方式 |
|---|---|---|
| 找不到 ohos 平台 | Flutter 命令是官方版 | 切换到 flutter_flutter 社区分支 |
| EventChannel 收不到事件 | 插件未注册,或 EventSink 保存失败 | 检查 oh-package.json5 依赖 |
| TCP 探测全部超时 | 缺少 INTERNET 权限 | 加入 module.json5 |
| 状态在后台疯狂跳变 | 鸿蒙后台网络策略限制 | Dart 侧过滤后台生命周期事件 |
| pkamVerification false | 密钥存储路径丢失 | 改用 Asset Store 持久化 |
| 编译时报 BusinessError | catch 参数未指定类型 | 使用(err as BusinessError) |
这张表是我在适配过程中真实遇到的,跟常见博客里贴的“标准答案”不一样,都是拿鸿蒙真机一个个试出来的。
6. 可以直接抄的集成代码片段
6.1 Dart 侧调用示例
如果你想在业务侧快速接入at_server_status的鸿蒙适配版本,可以参考下面这个简化调用。核心逻辑是订阅状态流,同时保留手动刷新入口。
import 'package:at_server_status/at_server_status.dart'; import 'package:flutter/services.dart'; class AtStatusController { StreamSubscription<AtServerStatusSnapshot>? _sub; void start() { // 先注册一个占位实现,避免拿到空的 AbstractError AtServerStatusNative.instance ??= OhosAtServerStatusNative(); final statusService = AtServerStatus()..startMonitoring(); _sub = statusService.statusStream.listen((snapshot) { if (snapshot.connectionState == ConnectionState.connected) { print('${snapshot.atSign} - online, latency ${snapshot.latencyMillis}ms'); } else { print('${snapshot.atSign} - offline, cause: ${snapshot.reason}'); } }); statusService.refresh(); } void dispose() { _sub?.cancel(); } }这段代码里没有写任何平台特定的分支,因为平台适配细节已经被抽象掉了。这也是我坚持“不碰 Dart 层 API”的回报,业务侧不需要关心你到底用的是鸿蒙还是 Android。
6.2 ohos 侧配置清单
在宿主鸿蒙工程里,需要确保oh-package.json5中存在 Flutter 引擎依赖,同时module.json5设置好权限。我提取了一份最小清单:
{ "module": { "name": "entry", "type": "entry", "requestPermissions": [ { "name": "ohos.permission.INTERNET" }, { "name": "ohos.permission.GET_NETWORK_INFO" } ], "dependencies": [ { "name": "flutter_ohos", "version": "4.0.0" } ] } }如果实际开发中发现GET_NETWORK_INFO不被识别,检查你的鸿蒙 SDK 版本是否太老。API 11 之前还没有这个权限常量,需要用兼容写法,直接字符串声明。这种版本兼容问题在社区分支里尤其常见,因为 Flutter SDK 和鸿蒙 SDK 版本之间不是一一对应的。
6.3 自动化回归脚本
最后给一个小建议:适配完平台层,一定要把自动化回归跑起来。鸿蒙的 Flutter 集成测试用flutter test integration_test/ -d <device>,但前提是integration_test插件也适配了 ohos。我这边直接用 hdc 驱动,写了一个非常简单的 shell 脚本做冒烟测试:
#!/bin/bash hdc shell aa start -a AbilityName -b com.example.astatus sleep 3 hdc shell "cat /data/app/el2/100/log/at_status_smoke.log"这个脚本不依赖测试框架,只验证启动后日志里是否出现online或offline两种正常状态。如果出现platformException,说明平台通道没通,需要回到前面第 5 节的表格排查。把冒烟脚本挂在 CI 上,比手动点点点靠谱得多。
个人经验说一句:这种插件适配,最怕的不是语法不会,而是你不知道哪一层出了问题。所以日志一定要打全,errorDetail必须包含错误码、错误信息、调用栈;否则真机上报 bug 时,你根本无从判断是鸿蒙原生层抛错,还是 Dart 状态机跑飞了。把日志分类打好,这个项目的一半工作量就算完成了。