上个月接了个活要把一个基于 Flutter 写的轻量级 Polkadot 钱包迁到鸿蒙设备上。攒了不到两万行 Dart 代码本以为在鸿蒙的 Flutter 分支上跑一遍、修修编译错误就完事结果第一次真机启动之后账户列表是空的链上余额加载不出来治理投票页面直接白屏。查了一圈问题都集中在一个开源库上面polkadart。polkadart是 Dart 生态里跟 Polkadot 链打交道最核心的 SDK——连接 RPC 节点、解析 SCALE 编解码、构造交易、签名广播、读取治理模块状态全都靠它。可这个库当初是按 Android/iOS/Desktop 的环境假设写的鸿蒙这套运行时的网络行为、密算能力、沙箱规则跟它有微妙但致命的不一致。这篇文章就把这次鸿蒙化适配的完整过程拆开讲。适合手里已经有 Flutter 版 Web3 或 Polkadot 应用、正准备往鸿蒙迁移的开发者也适合刚接触链上开发、想理解跨端适配里底层能力差异到底长什么样的读者。1. 先给鸿蒙化适配定位polkadart 的哪几处依赖必然翻车拿到适配任务第一天我没有急着改代码而是先把polkadart的依赖地图画了出来。后来证明这个动作省了至少一周的返工时间。因为凡是涉及系统能力的地方在鸿蒙上几乎都要重新对齐一遍。polkadart严格说不是单个包而是一组包的统称核心包负责 API 抽象和 JSON-RPC 调用polkadart_scale_codec负责链上数据的紧凑序列化签名与密钥派生则由若干加密包承担。再加上连接层的话整个调用链可以切成六七个模块。在鸿蒙的 Flutter 运行时上天然能直接工作的只有纯 Dart 实现的编解码和部分密码学计算其余涉及系统能力的地方全都要过一道替换。1.1 盘点 polkadart 的五个运行时依赖逐个和鸿蒙对齐我按运行时能力把依赖分成了五类每一类在 Android 上的常见方案和鸿蒙化替换思路如下表所示运行时能力Android 常见方案鸿蒙化替换思路HTTP 请求dart:io HttpClient鸿蒙网络框架或保留 dart:io 但补齐权限声明WebSocket 长连接web_socket_channel / dart:io鸿蒙 webSocket API回调转为 Stream签名 / 密钥派生sr25519、ed25519 等纯 Dart 实现纯 Dart 实现保留密钥托管走鸿蒙加解密框架安全随机数dart:math Random.secure鸿蒙安全随机数接口本地存储shared_preferences / 文件系统鸿蒙沙箱文件目录 keyStore 密钥保护这五个点里最容易让人误判的是第一项。很多人觉得 HTTP 请求在鸿蒙的 Flutter 运行时上应该直接能用反正都是 socket 加 TLS。实际跑下来dart:io的 HttpClient 在鸿蒙上确实能发出请求但证书校验策略和 Android 不完全一致遇到自签名证书或某些企业级中间证书时表现特别诡异。权限问题也藏在里面鸿蒙要求应用在module.json5里显式声明ohos.permission.INTERNETFlutter 工程的默认模板不会自动生成这项声明。WebSocket 也类似。它不是能不能连的问题而是连上了能不能保住的问题。鸿蒙对长连接的保活策略比 Android 更激进应用退后台几秒钟系统就可能把 socket 回收掉。这个行为差异在模拟器上几乎测不出来真机上一跑就现原形。1.2 我第一次构建就暴露的环境差异权限、沙箱与网络栈第一次真机构建的失败日志我到现在还记得很清楚。刚开始只是页面白屏登录按钮点了没反应我一度以为是 Dart 层出了异常。后来抓了鸿蒙侧的系统日志才看到网络请求压根没发出去——应用没有声明网络权限。补上权限声明之后HTTP 通了但 WebSocket 又开始反复断连。再往深处追发现鸿蒙的网络栈对长连接的管理方式跟 Android 不同应用切到后台socket 并没有立即断开但系统会挂起它的数据收发回到前台后再恢复已经过了服务端的保活超时时间一条死链就这么留着。于是客户端一直认为连接是好的发出去的消息却没有任何回包。这些差异都不是polkadart的 bug但对这个 SDK 的运行假设冲击很大。我的建议是动手之前先花一晚上在目标设备上打印一份哪个环节依赖系统能力的清单再决定替换优先级。直接一头扎进代码里改函数名大概率会在权限、沙箱、网络栈之间来回打转。2. 网络层动手改造HTTP 与 WebSocket 的鸿蒙替换法网络是所有链上交互的命脉所以适配顺序上我把它排在最前面。这里的核心思路是不动polkadart上层 API只在底层做注入。上层代码继续用api.query、api.tx这些抽象底层真正发请求的通道换成鸿蒙能力。2.1 找到 Provider 抽象把 RPC 通道接管过来polkadart里有一个 Provider 抽象层职责是负责把 JSON-RPC 请求发到节点并取回结果。它有两种常态实现一种基于 HTTP适合普通 RPC 调用一种基于 WebSocket适合链上订阅。我改造的做法是写一个新的适配 Provider内部把网络出口拆成两层普通 RPC 走鸿蒙的 HTTP 客户端订阅走鸿蒙 WebSocket 通道。这个改造最需要耐心的地方是先读懂 Provider 接口的并发语义。比如 JSON-RPC 请求里的 id 字段必须和请求实例一一对应。如果实现里把 id 写死或者复用并发调用时响应就会串数据——一个查询余额的请求可能拿到一个查询账号信息的响应。第一次实现时我就栽在这里页面上的余额数字偶尔会跳到另一个账户名上排查了很久才发现是 id 分配逻辑出了问题。核心替换代码的骨架可以长这样class HarmonyProvider extends Provider { HarmonyProvider(this.url); override FutureMapString, dynamic send(String method, Listdynamic params) async { final payload jsonEncode({ jsonrpc: 2.0, method: method, params: params, id: _nextId, }); final response await _post(url, payload); final decoded jsonDecode(response) as MapString, dynamic; if (decoded.containsKey(error)) { throw ProviderException(decoded[error]); } return decoded; } override StreamObject subscribe(String method, Listdynamic params) { // WebSocket 订阅内部维护连接返回事件流 } }这里的_post内部是鸿蒙网络 API 的桥接返回的响应体需要先按 JSON 解析再交给上层。重点不是把代码照抄而是要理解 JSON-RPC 的 id 管理是 Provider 正确性的命门。2.2 WebSocket 回调用转 Stream订阅合约的一次重写polkadart的很多功能比如余额变化监听、新块头订阅都依赖 WebSocket 的持续推送。鸿蒙的 WebSocket 提供了事件回调风格onMessage会收到服务端推来的每一条消息而 Dart 侧消费方期望的往往是一个 Stream。一开始我图省事直接把每次回调事件抛进一个 StreamController简单转发给上层。结果很快出现丢事件、乱序、重复推送等问题。后来翻到polkadart内部的订阅实现才发现它有一个 subscription 语义需要严格维护订阅成功后服务端会返回一个订阅 ID之后推上来的每一条消息里都带着这个 ID客户端必须按 ID 把消息分发到对应的订阅处理器上。多个订阅同时存在时如果少了这层分发事件就会串流。改造后的消息分发逻辑大致如下void _onWsMessage(Object message) { final frame jsonDecode(message as String) as MapString, dynamic; final subId frame[params][subscription]; final controller _subControllers[subId]; if (controller ! null) { controller.add(frame[params][result]); } }这个params.subscription字段不是客户端生成的而是 RPC 节点分配的标识符。订阅成功后收到的第一条响应会带上它后续推送也带着它。不理解这一层很多订阅功能在鸿蒙上就会表现出偶尔能收到、偶尔收不到的玄学问题。2.3 断线重连前台恢复比启动重连更关键断线重连在模拟器上几乎测不出来真机一跑就现原形。前面提到鸿蒙设备在应用退后台时会挂起 socket回到前台后连接可能已经断了。问题在于应用代码看不到断开事件它还以为连接活着于是所有订阅都静默失败页面数据就这么卡住不动。我在生命周期回调里加了一个连通性检测回到前台时先发一个轻量级 RPC比如system_health如果失败就重建整个 Provider并重新订阅所有活跃订阅项。这个重建逻辑必须幂等否则连续切前台十次就会重复建立十套订阅服务端会累积资源泄漏。写这段逻辑时还要注意顺序重建 Provider 后必须先等待连接就绪再重新拉取一次关键状态数据最后才恢复订阅。如果顺序错了会出现一个很尴尬的中间状态——订阅恢复了但本地缓存的状态还停留在断线前的旧值用户看到的数据是错的直到下一次新块推送才修正。对一个资产类应用来说这种短暂显示旧数据的现象是绝对不能接受的。3. 加密与签名链路不能照搬但也不必上 Rust整个适配里最有趣的一段是加密和签名。Polkadot 的默认签名算法是 sr25519这是一个基于 Ristretto 曲线的 Schnorr 签名变体很多开发者一听就觉得必须写 Rust 或 C 扩展才能在鸿蒙上跑。实际上有一个中间路线。3.1 sr25519 是绕不开的坎先想清楚三种可选方案我把当时的方案对比列出来方案开发量安全性性能可行性纯 Dart 的 sr25519 实现低中密钥暴露于进程足够高NAPI 封装 Rust/C 签名库高高更好中鸿蒙原生密算框架硬算 sr25519极高高视曲线支持而定低为什么第三项可行性低因为鸿蒙的密算框架支持的是既有对称和公钥算法体系sr25519 这类链上新算法不在标准列表里强行用它等于自己实现整个曲线运算和签名协议。密码学实现的工程量巨大而且极易出错稍有一个字节的编码差异签名就验证不过。对大多数项目来说这不是该走的路。所以实际选择是保留纯 Dart 的 sr25519 签名实现把密钥生成和托管交给鸿蒙的加解密能力。签名只在内存里发生落盘的是被加密保护过的密文。这个取舍很现实——安全等级从裸密钥文件提升到了系统级密钥保护 内存私钥短生命周期开发成本则从重写密码学底层降到了写几层封装。3.2 用鸿蒙密算能力守护助记词和私钥具体做法分三步。第一步生成助记词的熵不再用 Dart 的Random.secure。这个接口在鸿蒙上随机源不可控我换成鸿蒙安全随机数接口产出 256 位熵。从生成源头就把随机性交给系统级能力避免伪随机或者可预测性问题。第二步助记词通过 BIP39 标准生成后立即用 AES-GCM 加密。加密用的密钥由鸿蒙 keyStore 生成并绑定在应用沙箱上。这样即使沙箱里的密文文件被拖走没有 keyStore 的授权也解不开。第三步私钥派生出来之后只在签名时加载到内存签名完成后立即把引用置空。Dart 没有真正意义上的内存清零但可以通过及时释放引用、触发 GC 来缩短私钥暴露时间。这里要跟产品团队讲清楚这是工程性缓解不是绝对安全边界不要指望它挡住有系统级权限的攻击者。代码骨架可以这样写// 用鸿蒙安全随机数生成熵 final entropy await securityRandomGenerate(32); // 生成助记词 final mnemonic bip39.generateMnemonic(entropy: entropy); // AES-GCM 加密后写入沙箱 final encrypted await aesGcmEncrypt(mnemonicBytes, keyFromKeyStore); await writeToSandbox(encrypted);一个很容易忽略的细节AES-GCM 的 AuthTag 必须和密文一起保存。解密的时候如果只拿密钥和密文不带上当初生成的 AuthTag是解不出来的。这个 Tag 本身不算敏感数据但丢了它等于把密文变成废文件所以存储时要放在同一个记录里。3.3 地址校验出的诡异结果字节序与 SS58 前缀适配完签名之后我写了一个自动化测试生成 50 组助记词派生地址然后把地址拿去和链上公开工具比对。结果前 20 个全部正常后面随机出现几个字符串和参照工具完全不一样。查了很久才发现问题出在字节序上。我参考的一份旧文档把公钥的 little-endian 直接拿去算了 SS58 编码但正确做法是要先统一成 big-endian 格式再做 base58。这类 bug 非常隐蔽因为大部分地址长度相同、格式相似前几位甚至都一样只有后十几位不同不拿交叉验证根本看不出来。这里也给所有做链上兼容的团队提个醒适配签名与地址生成后必须接一个生成→派生→链上查询→恢复的闭环测试。只停留在能签名、能本地验证是远远不够的一定要让生成的地址拿到链上真实环境中走一遍查询。4. 账户生命周期从助记词生成到沙箱恢复的完整闭环钱包类应用的核心不是链上交互而是密钥管理。这个模块在 Android 上有成熟的 Keystore 方案在鸿蒙上则要重新梳理一遍沙箱路径和密钥库的差异。4.1 生成助记词的随机源与抄写交互第一次做助记词生成时我按传统方式写了个 Flutter 页面展示 12 个英文单词让用户照着抄。结果真机上出现了两个体验问题。一是系统的软键盘弹出后把展示区顶得缩成一团用户在抄到第二词的时候上半部分单词已经被顶出屏幕了。二是部分鸿蒙系统输入法自带英文纠错会把用户辛辛苦苦输完的单词自动纠正成另一个形似的词。用户在毫无感知的情况下抄写结果就错了。后来我把抄写输入改成逐词选择模式——页面上给每个位置提供四个候选词用户从候选里点选而不是打字。这个改动很小但误输入率下降得非常明显。助记词是资产恢复的唯一凭证抄写环节的任何体验瑕疵都可能变成用户永久损失资产的入口。移动端钱包的备份交互值得投入比功能开发更多的时间打磨。4.2 加密存储与擦除策略助记词和私钥的存储路径我选的是鸿蒙沙箱内的私有目录类似文件系统里的应用专属文件目录其他应用没有权限访问。关键是不管放在哪个目录落盘前必须先加密密钥走 keyStore这是底线。我还给擦除策略留下了明确入口当用户连续输错密码超过阈值或者设置里主动清除钱包时触发一次彻底删除。这个彻底删除要按顺序执行先删沙箱里的密文文件再删 keyStore 里的密钥别名最后清空内存里的引用。顺序很重要。如果倒过来——先把 keyStore 里的密钥别名删了再去删文件——系统在执行删除操作时可能已经丢失了解密所需的密钥材料文件本身变成一串无法处理的数据。虽然最终结果都是数据不可用但无法确认密钥材料是否彻底销毁残留窗口的隐患就在这里。4.3 恢复流程的三段式校验恢复账户是钱包里最容易被用户弄砸的入口。我把恢复流程做成三段式校验每一步不通过就直接停住不给用户继续操作的机会。第一段是词数检查。12 词或者 24 词数量必须精确匹配词与词之间的分隔空格也做了规范化处理避免用户复制粘贴时带上多余换行符。第二段是 checksum 校验。BIP39 助记词的最后几位是校验位由前面所有词联合计算出来。如果用户抄错其中一个词校验位大概率对不上这时直接提示助记词校验失败而不是进入下一步。第三段是派生地址预览。用助记词派生第一个账户地址显示给用户确认和用户记忆中的地址做比对。三段全部通过以后才把恢复后的账户加入本地列表这时候再去调链上余额接口。这个流程看起来繁琐但它的价值在于把复制错了一个单词、重建出完全不同的账户这种事拦在链上交互之前。恢复流程一旦放水用户在链上看到资产归零时心理冲击远远大于多花两分钟做校验。5. 治理玩法落地查询公投、构造投票、等待最终确认标题里的精密 Polkadot 治理实战讲的是链上的 referendum 和 conviction voting。polkadart对这类模块的支持相对高层但落到代码里有不少细节需要手工补。5.1 先理解 OpenGov 在 polkadart 里的模块分布Polkadot 的治理现在主要走 OpenGov核心模块包括 referenda、convictionVoting、whitelist 等。在polkadart里这些模块以 runtime 调用的形式暴露出来查询链上状态走 state_query构造交易走 tx 构造器。刚开始很容易在文档里迷路因为旧的 democracy 模块在很多资料里还占着位置。判断是否找对模块的简单方法是看交易类型名如果出现convictionVoting.vote说明走的是新治理路径否则很可能被旧文档误导了。我整理了一个最小的查询集覆盖了治理参与的主干链路referenda.referendumInfoFor拿公投列表convictionVoting.votingFor拿某账户在当前公投上的投票情况convictionVoting.vote发起投票。这里不用把 OpenGov 的所有模块都接进来治理功能的上线节奏可以先把看公投、投一票这两个核心动作打磨好。5.2 构造并广播一笔投票交易时我踩过的坑投票交易不是单纯地把支持/反对两个布尔值发上去。Polkadot 的投票要附带 conviction 倍率——这是锁定代币后可获得的投票权重倍数从 0.1 倍到 6 倍不等。不同的倍率对应不同的锁定期。第一次实现时我直接把倍率写死为 6结果用户测试发现账户余额被锁定得很厉害产品那边急得跳脚。后来我把倍率选择放到了 UI 上并且用链上的锁定期信息动态解释每个倍率的意义让用户自己权衡。构造投票交易的核心代码大致是这个形态final accountVote AccountVote.standard( vote: Vote(aye: isAye, conviction: Conviction.locked6x), ); final extrinsic api.tx.convictionVoting.vote(referendumIndex, accountVote); final hash await extrinsic.signAndSend(signer, params: ...); // 等待 finalized await for (final event in watchExtrinsic(hash)) { if (event is ExtrinsicFinalized) { break; } }这里最容易被忽略的是签名载荷。polkadart的 signer 会处理大部分字段但某些版本里如果不显式指定genesisHash和specVersion签名出来的交易可能在另一条链或旧版本节点上被误用。这需要应用在初始化时把 runtime 版本拉下来缓存交易构造时统一带上。这不是签名机制本身的问题但很容易被当成签名不对来排查浪费大量时间。广播交易之后不能只看 RPC 返回的 tx hash。我第一次实现时投票操作点击之后直接提示成功结果用户说链上根本没有记录。排查半天才发现RPC 返回的 hash 只是接受了交易但如果手续费设置过低或者交易优先级不够它会停留在交易池里迟迟不被打包。正确做法是等待交易的 finalized 事件或者在轮询状态里看到 finalized 再给用户强反馈。对一个转账功能这个机制更重要对一个投票功能它决定了用户的参与是否真的生效。5.3 提交流程比投票多一道 preimage 门槛如果只是投票流程还算顺。要发起一个公投提案则要先提交 preimage——把你要执行的链上 call 完整编码并上传得到一个哈希再拿这个哈希去发起 referendum。polkadart提供了一个 preimage 的入口但 call 数据构造是整个流程里最难受的地方。你需要手动把嵌套的 call、参数和权重编码成 SCALE 格式。一个转账提案参数可能就嵌套了两三层先指定目标和金额再套上代理信息。编码错一个字段preimage 在链上就解不出原始 call。我的建议是在产品初期不要一股脑把发起提案做进应用。先把投票和委托做好把 preimage 的构造做成一个离线工具由有经验的高级用户使用等确有必要再集成到移动端。这是务实的工程取舍不是技术做不出来而是移动端的输入屏幕和前置条件太多主打一个操作流畅和低门槛提案这种重操作放给桌面端更合适。6. 真机验证与走查清单别在模拟器里自信收工代码全部跑通之后离交付还差一轮决定性的验证。这一轮不是功能测试而是针对鸿蒙设备特性的专项走查。6.1 我列出的六项必测场景我把必测场景整理成了一张清单每一项都在真机上完整走一遍场景内容核心关注点新用户创建账户生成助记词、备份、二次确认随机源是否来自系统安全接口老用户恢复账户三段式校验、余额加载网络切换时恢复流程是否卡死查询资产主网和测试网切换Provider 是否按环境切换 RPC 端点发起转账自定义手续费、签名、等待 finalized签名载荷是否完整、哈希等待是否超时参与投票查询公投、选择 conviction、锁定说明构造投票的交易是否携带正确的 origin后台/前台切换连续切换 10 次订阅是否能自动恢复且无重复订阅模拟器里的 TCP 链路和网络栈跟真机完全不同WebSocket 的保活和重连逻辑只有在真机上才有意义。我见过有人在模拟器里测了一整天切后台都正常结果真机一跑连最基本的余额订阅都保不住。6.2 弱网、前后台切换与设备兼容性的一手观察适配完成后我拿了几台不同档位的鸿蒙设备做弱网测试发现两件值得记录的事。一件是老设备上的 TLS 握手耗时明显偏高导致 HTTP Provider 的超时阈值要放宽。默认配置下dart:io的 HttpClient 超时在 10 秒左右老设备在弱网环境里经常触发超时于是我把超时时间调到了 25 秒配合断线重连弱网下的成功率才基本稳定。另一件是不同厂商的系统对后台省电策略的处理差异很大。有的设备会在应用切后台时立刻冻结 socket有的则会保留几分钟。这个差异没法靠一套代码解决只能通过前台恢复时的主动重连来兜底。后来我把重连检测从每次回到前台都做改成了先看上次网络活动时间超过阈值才重连减少不必要的重建开销也避免用户频繁切换页面时反复断连。还有一个小细节弱网环境下WebSocket 的 ping/pong 保活机制比 HTTP 的超时重试更可靠。我会在应用层实现一个定时 ping间隔 12 秒如果连续三次没有 pong 响应就主动断开重连。这是很多长连接应用忽略的部分——只在系统层依赖 TCP keepalive应用层不做任何心跳一旦链路进入半开状态连接永远不会自己恢复。6.3 如果只让我给后来者留一句话适配polkadart到鸿蒙困难不在 Polkadot 的复杂度也不在 Flutter 本身而在于每次系统差异排查都可能演变成对现有抽象层的一次重新理解。最好的做法是先跑通一条最小编码链路——建账户、查余额、发一笔 0 金额转账、投票一次——把这条链路在鸿蒙上打磨到稳再铺开其他业务。全功能开放时要保证每个入口都有对应的真机测试用例线上出问题才能快速定位到是网络层、签名层还是存储层。如果让我再稳一步我会在正式上线前留一周来做破坏性测试不停切换网络环境、杀掉进程重启、调整系统时间和时区观察钱包在极端情况下的行为。这类测试暴露的问题往往比功能测试更多也更能磨出一个贴近真实使用的鸿蒙级钱包体验。这次适配给我的最大体会是跨端迁移最值钱的部分永远是那些你一开始觉得用不着改的底层假设。