1. 项目背景与适配目标:为什么 Flutter 里的 ethereum 库到了鸿蒙跑不起来
如果你的项目恰好也在做“Flutter 跨端 + 鸿蒙 HarmonyOS”的数字资产钱包,大概率会碰到同一个尴尬:官方文档说 Flutter 支持鸿蒙了,第三方依赖也能编译过,可真要把一笔交易从钱包里签名、上链,事情就没那么顺利了。我这次要聊的适配项目,正是围绕 Flutter 三方库 ethereum 展开的鸿蒙分布式区块链数字资产上链钱包适配,目标是把 JSON-RPC 加密管线完整接通,再打通智能合约调用闭环,让直播间的 UI 操作真正落到链上。
刚开始我拿到这个任务时,第一反应是“不就是加一个 MethodChannel 吗”。可真要跑起来才发现,问题根本不在“调用原生方法”,而在数据流的完整性和密码学操作的边界划分。ethereum 这个库在 Flutter 侧本来能帮你构造交易、做 RLP 编码、推算出地址,但它依赖的部分底层能力,到了鸿蒙上要么缺失,要么行为不一致。尤其是私钥管理、密钥派生、哈希签名这类高敏感操作,如果全放在 Dart 内存里,无论是性能还是安全隔离都说不过去。
这次适配的核心思路,是把“Flutter 负责展示和业务流程编排”和“鸿蒙原生负责私钥生命周期 + 网络传输 + 链上交互”拆开。UI 层继续用 Dart 写,资产余额、转账表单、合约调用入口都可以复用。但一旦牵扯到签名和发送交易,就通过平台通道把数据交到 ArkTS 侧处理。这样既保住了 Flutter 的开发效率,又让敏感信息只在鸿蒙原生环境里流动,整个 JSON-RPC 加密管线的安全性才能立得住。
1.1 一个看起来能编译,却什么都调不通的现状
先说你最容易遇到的现象:Flutter 工程能编译,ethereum 库也加进了pubspec.yaml,但启动后一调用eth_call或者eth_sendTransaction,应用就卡住,要不就直接崩掉。这种崩溃多半不是因为 Dart 代码写错了,而是鸿蒙侧没有对应的网络会话权限、证书校验策略不兼容,或者 MethodChannel 传进来的参数类型被原生侧拒绝解析。
我试过把交易数据做成 JSON 字符串传过去,结果 ArkTS 里解析没问题,但 SDK 要求的请求格式里有很多十六进制字符串,比如gasPrice和nonce,如果 Dart 侧用普通 int 存,数值一超过 JavaScript 安全整数范围,鸿蒙侧就会拿到一个精度丢失的数字。还有一次是eth_getTransactionCount返回的数据正常,但因为我们把chainId写死成了 Decimal 字符串,发送交易时节点直接报 -32000。你能想象一个钱包连测试网转账都推不出去是什么感受吗?
所以,这个适配不只是简单的“换个系统”,而是在梳理整条资产上链管线的过程中,把每一段数据的序列化规则、精度范围、异常语义都重新对齐。Flutter 的生态再好,也不能替鸿蒙原生网络栈和系统安全能力做主。真想让 ethereum 库在鸿蒙上干活,就得先把这些底层差异摸清楚。
1.2 适配的关键:把 JSON-RPC 加密管线当成一等公民
很多人会把“适配”理解为“让第三方库能编译”,但真正的适配目标是“让业务闭环能跑通”。在数字资产钱包这个场景里,闭环通常是这样:用户输入转账地址和金额,应用查询 nonce,构造交易,本地签名,广播到节点,轮询回执。整条链路里的每一步都依赖 JSON-RPC 格式的请求和响应。
JSON-RPC 本身不复杂,无非是jsonrpc: 2.0、method、params、id组成的 JSON 对象。复杂的是它背后承载的密码学操作:交易哈希计算、secp256k1 签名、RLP 序列化、合约 ABI 编码。这些操作如果分散在 Flutter 层和鸿蒙层的多个模块里,一旦顺序出错,链上节点就会拒绝交易。这也是为什么我在设计初期就坚持把“加密管线”当作一个独立模块来对待,而不是把签名逻辑散落到 UI 回调里。
所谓加密管线,我理解是一条从明文业务参数到链上最终确认状态的单向流水线。输入是“转给谁、转多少、附带什么调用数据”,输出是“交易哈希 + 回执状态”。中间每个环节都必须有明确的入参出参格式、错误码和超时策略。只有把这个管线先定义清楚,后面写代码才不会越改越乱。这次鸿蒙适配的核心突破,就是让这条管线在 Flutter + ArkTS 的混合架构里完整跑通。
2. 技术选型与分层设计:保留 Flutter UI,把密码学能力下沉到鸿蒙原生
做技术选型之前,我先列了一个约束清单:第一,Dart 侧不允许保存任何私钥;第二,网络请求必须走鸿蒙原生能力,方便后续做网络安全检测;第三,Flutter 和鸿蒙之间只传结构化数据,不互相直接持有可执行对象。基于这三条,最终方案确定为“Flutter 业务层 + 鸿蒙原生加密与网络层”,中间用 MethodChannel 通信。
为什么这么选?首先,Flutter 的 UI 跨端优势太明显了,钱包里大量表单、列表和动效,用 Dart 写比 ArkTS 写省太多时间。其次,鸿蒙系统提供了系统级安全存储和统一的网络栈,私钥放这里面,比放进一个纯 Dart 内存对象里可靠得多。最后,ethereum 库虽然也能在纯 Dart 环境里完成签名,但它的依赖对鸿蒙兼容不佳,强行跑起来容易引入难以排查的崩溃。
2.1 分层思路:Dart 不该承担私钥生命周期管理
我见过一些 Flutter 钱包项目,直接在 Dart 里生成私钥,用shared_preferences存储,或者让用户导出助记词后保存在本地文件。这样做在纯 Android 或 iOS 上可能还能跑,但到了鸿蒙上,安全审核和系统约束会更严格。私钥如果能在原生层完成生成和存储,Dart 层根本不需要看到它,整个攻击面就小了很多。
所以我在这次适配中把 ethereum 库的使用范围限制在“非敏感”操作上:地址格式校验、合约 ABI 编码、交易参数构造、返回数据的解码。真正生成私钥、签名、计算交易哈希的部分,全部下沉到 ArkTS 侧实现。Dart 侧通过一个统一的WalletBridge接口与原生通信,接口里只有buildTransaction、requestSign、submitRawTx这样的方法,完全没有私钥输入参数。
这样设计还有一个好处:后续如果鸿蒙系统持续升级加密能力,比如支持新的安全芯片接口,我们只需要改原生侧,Flutter 侧的业务代码完全不用动。换句话说,Dart 依赖 ethereum 库只负责“生成待签名的原材料”,鸿蒙侧负责“把原材料变成链上认可的交易”。
2.2 MethodChannel 的数据通道设计
MethodChannel 是 Flutter 和原生通信最常规的方式,但在鸿蒙上需要特别注意数据类型。标准 MethodChannel 支持 String、num、bool、Map、List 这些基础类型,但大整数和字节数组需要额外处理。我在项目里统一规定:所有数字类型都以十进制字符串传递,所有字节数据以 Base64 或十六进制字符串传递,杜绝直接传 int。
举个例子,Dart 侧构造好一笔交易后,传给鸿蒙侧签名的结构是这样:
final request = { 'action': 'signAndSend', 'tx': { 'nonce': nonce.toString(), 'gasPrice': gasPrice.toString(), 'gasLimit': gasLimit.toString(), 'to': toAddress, 'value': value.toString(), 'data': contractData, 'chainId': chainId.toString(), }, }; final result = await _channel.invokeMethod<Map>('processTransaction', request);鸿蒙侧拿到后,先用 BigInt 把字符串转回高精度整数,再做后续计算。有人觉得多此一举,直接传字符串更麻烦,但如果真用双精度浮点,几千亿的 Gas 价格就会出现误差。链上交易里,一个 wei 的误差都可能让签名结果完全错误。
通信层的另一个点是超时和重试。MethodChannel 调用如果长时间不返回,Flutter 侧默认会一直等待。我在原生侧设置了 15 秒超时,超时后返回一个结构化错误对象。这样 Flutter 侧可以根据错误码提示用户“节点连接超时”还是“交易已被替换”,而不是让界面傻等。
2.3 管线状态机:一次资产上链请求要走完哪些环节
把整个加密管线展开后,我把它拆成了七个状态:参数校验、构造交易、本地签名、交易广播、等待回执、确认上链、失败回滚。每个状态都有明确的进入条件和退出条件。
| 状态 | 输入 | 处理位置 | 输出 |
|---|---|---|---|
| 参数校验 | 地址、金额、合约方法 | Flutter 侧 | 校验后的业务参数 |
| 构造交易 | nonce、gas、data | Flutter 侧 + ethereum 库 | 待签名交易结构 |
| 本地签名 | 待签名交易结构 | 鸿蒙原生侧 | 签名后的 raw transaction |
| 交易广播 | raw transaction 十六进制串 | 鸿蒙原生侧网络请求 | TX Hash |
| 等待回执 | TX Hash | 鸿蒙原生侧轮询 | 回执状态 |
| 确认上链 | 回执中的 blockNumber | Flutter 侧 | 成功结果 |
| 失败回滚 | 错误码和原因 | 双端协作 | 用户可读错误 |
把这个状态机在前端和后端各维护一份,两边对账时会非常方便。我在 Flutter 侧用枚举记录当前状态,在 ArkTS 侧用回调把最新状态推回 Dart,这样用户可以实时看到“签名中”“广播中”“确认中”的不同阶段,而不是干等一个加载圈。
3. 核心实现:从交易构造到智能合约调用的闭环打通
理论说完了,终于到代码环节。这一部分我拆成四段:先用 Flutter 三方库 ethereum 构造交易参数,然后交给鸿蒙原生完成签名,再讲合约调用 data 的拼装,最后说回执轮询。完整跑通后,你就能在自己的鸿蒙应用里发起一笔数字资产上链交易,并且看到链上的确认结果。
3.1 在 Dart 侧用 ethereum 三方库构造交易
我在 Flutter 侧引入的是 ethereum 这个三方库,它提供了一套相对轻量的交易构建工具。你需要先连接一个 JSON-RPC 节点,读取链上的 nonce 和 gas 价格。这里我不建议把节点地址硬编码在 Dart 层,而是放到鸿蒙原生的网络配置里,方便后续切换测试网和主网。
import 'package:ethereum/ethereum.dart' as eth; // 假设已经通过 jsonRpc 拿到了 nonce、gasPrice、chainId final tx = eth.Transaction( nonce: nonce, gasPrice: gasPrice, gasLimit: gasLimit, to: toAddress, value: valueInWei, data: contractData, chainId: chainId, ); final rlpEncoded = eth.encodeTransaction(tx); final txHash = eth.hashTransaction(tx);这套代码本身是平台无关的,所以不会直接出错。但你要注意,valueInWei和gasPrice必须是 BigInt 类型,不能是普通 int。我在项目里写了一个工具函数,专门把用户输入的 ETH 金额转成 wei 字符串,再交给Transaction构造器。这样后续鸿蒙侧签名才不会因为精度问题产生错误 hash。
构造完成后,Dart 侧并不直接把整个 RLP 编码后的交易发过去,而是把结构体里的字段拆散,用字符串形式传给原生。这么做是为了让原生侧能重新检查字段,避免恶意调用或参数被意外修改。其实这里也可以直接传输rlpEncoded十六进制串,但那样的话,原生侧就无法对 nonce、gasLimit 做安全校验了。
3.2 鸿蒙原生侧完成签名、RLP 编码和广播
鸿蒙侧的签名流程可以分成三步:接收交易结构、用本地私钥签名、重新做 RLP 编码。为什么不在 Dart 侧签名?一方面私钥不能离开原生存储,另一方面鸿蒙提供的加密接口对密钥材料有更好的隔离能力。如果私钥来源是助记词,也建议在原生侧完成助记词到私钥的派生。
在 ArkTS 里,我会先用系统提供的密钥管理能力读取私钥,然后对txHash做签名。这里的关键点是签名对象是交易哈希,而不是交易全文。这样做既能减少签名数据量,又符合以太坊交易签名的标准流程。签名完成后得到 v、r、s 三个值,重新拼接出 raw transaction。
// 伪代码示意:鸿蒙原生侧完成签名和 JSON-RPC 广播 function signAndSend(rawTxFields: Map<string, string>): string { const nonce = BigInt(rawTxFields["nonce"]); const gasPrice = BigInt(rawTxFields["gasPrice"]); const value = BigInt(rawTxFields["value"]); const chainId = BigInt(rawTxFields["chainId"]); const encoded = rlpEncode([nonce, gasPrice, gasLimit, toBytes(value), dataBytes, chainId]); const hash = keccak256(encoded); const signature = signWithSystemKey(hash); const rawTx = rlpEncode([nonce, gasPrice, gasLimit, toBytes(value), dataBytes, chainId, signature.v, signature.r, signature.s]); return jsonRpcCall("eth_sendRawTransaction", [bytesToHex(rawTx)]); }实际开发时,RLP 编码和 keccak256 这类算法可以直接找鸿蒙生态里可用的加密库,不一定自己从零写。如果暂时没有现成封装,可以把以太坊官方实现的 C 库通过 NDK 集成进来。这样虽然会增加包体积,但正确性能得到保证。签名返回的原始交易字符串会作为eth_sendRawTransaction的参数发给节点,节点只会接受符合规范格式的交易。
3.3 智能合约 ABI 编码与 data 字段的拼装
智能合约调用比普通转账多的就是data字段。这部分的核心是 ABI 编码:把函数名和参数按固定规则打包成十六进制字符串。例如调用一个 ERC-20 的transfer(address,uint256),data 由函数选择器和参数编码拼接而成。
函数选择器的计算方式是取函数签名的 Keccak-256 哈希前 4 个字节。transfer(address,uint256)的哈希前 4 字节就是著名的0xa9059cbb。两个参数分别是 32 字节的地址和 32 字节的 uint256,地址需要左补零,金额也需要转为十六进制后左补零到 32 字节。
下面是我在 Dart 侧写的一个拼接逻辑:
String encodeTransferData(String toAddress, BigInt amount) { final selector = 'a9059cbb'; final addressPart = toAddress .replaceFirst('0x', '') .padLeft(64, '0'); final amountPart = amount .toRadixString(16) .padLeft(64, '0'); return '0x$selector$addressPart$amountPart'; }有一个容易忽略的点:地址如果是 EIP-55 校验格式,转成纯十六进制时大小写会影响?不会,因为0x去掉后就是普通十六进制字符。但解析用户输入地址时,最好先做一次地址格式校验,防止用户粘贴带空格或大小写混乱的内容。
构造好data之后,把这串十六进制字符串放进Transaction的data字段。如果是为了预估 gas,可以使用eth_estimateGas方法,传from、to、data等参数,拿到的 gas 估算结果再给用户确认。这样能避免因为 gas 设置过高浪费手续费,也能防止 gas 设置过低导致交易失败。
3.4 回执确认的轮询逻辑与状态返回
广播eth_sendRawTransaction成功后,节点会返回一个交易哈希。但这并不代表交易已经上链,只是表示节点接收了这笔交易。真正确认需要等待矿工把交易打包进区块,这笔交易在链上才算真实存在。
轮询回执我放在鸿蒙原生层实现,因为 Flutter 侧如果频繁发 MethodChannel 调用,开销会比较大。原生侧每 3 秒请求一次eth_getTransactionReceipt,如果返回的 receipt 里status字段为0x1,说明交易成功;如果status为0x0,表示合约执行回滚;如果持续返回 null,说明交易还在交易池里等待。
轮询不能无限进行,我设定最多轮询 20 次,超过后返回“交易可能已广播但尚未确认”的状态给用户。有人会觉得“只要拿到 txHash 就算成功”,但在数字资产钱包里,用户更关心的是“这笔转账是不是真的到账了”。所以回执状态必须完整回传,哪怕最后是超时,也要如实告诉用户。
4. 实测遇到的坑和排查技巧
适配过程不可能一次跑通。下面这些坑是真实踩过的,我把排查思路也一并写出来。如果你也在做 Flutter + 鸿蒙 + ethereum 这个组合,大概率会遇到其中几个。
4.1 MethodChannel 参数类型对 BigInt 不友好
第一个坑来自 MethodChannel 的 int 精度限制。Dart 和 ArkTS 都支持 BigInt,但 MethodChannel 传输并不支持原生 BigInt 对象。最开始我试着把一个 BigInt 值直接放进 Map,结果 Flutter 侧直接抛出异常。后来改成统一用字符串传大数,才解决。
这个坑在理论上很容易避开,但项目里往往会因为大量历史代码而疏漏。排查方法是看崩溃堆栈,如果错误出现在invokeMethod调用附近,优先怀疑参数类型。建议在工程里做一个统一的BigIntCodec,把BigInt序列化成十进制字符串,再在原生侧反序列化回BigInt。这比每次手动转换要可靠得多。
4.2 RPC 节点返回慢、超时和错误码
调用公共 RPC 节点时,超时非常常见。我在 ArkTS 侧用了系统网络库发起 POST 请求,但默认超时时间偏短,节点一忙就中断。后来我把连接超时调到 10 秒,读取超时调到 20 秒,才算稳定一些。
另外,不同节点返回的 JSON-RPC 错误码五花八门。有的是-32005表示请求过多,有的是-32603表示内部错误,还有的直接返回空数组。我建议在原生侧封装一个统一的RpcException,把错误码和 message 一起抛给 Flutter 层,方便 UI 层做提示。不要让用户看到一串看不懂的原始 JSON。
错误排查速查表:
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 交易一直 pending | nonce 太低或 gasPrice 过低 | 重新获取 nonce,提升 gasPrice |
| 返回 insufficient funds | 账户余额不足 | 提示用户充值,停止后续流程 |
| 返回 replacement transaction underpriced | 同 nonce 交易已被接受 | 使用更高 gasPrice 重新签名 |
| 合约回执 status = 0x0 | 合约执行失败 | 检查 data 编码和合约状态 |
| MethodChannel 卡死 | 原生侧未回调 | 加超时机制,统一错误返回 |
4.3 合约调用返回空数据不一定是失败
用eth_call模拟合约调用时,如果返回0x,不一定代表失败。比如某些只读函数返回空字节数组,或者合约本身没有返回值,都会得到空结果。一定要结合函数返回类型和 ABI 解码结果判断。
我遇到过一种情况:调用一个查询函数,返回空数据,但状态码正常。后来发现是函数签名拼写错误,函数选择器不对,导致节点根本没有匹配到对应方法。排查这种问题最好的办法,是用离线 ABI 工具在本地先验证一遍函数选择器,确认编码无误后再上链调用。
4.4 签名依赖冲突和崩溃问题
Flutter 生态里有不少签名相关的库,很多底层都依赖原生加密实现。在鸿蒙上,有些库并没有适配,强行使用会出现UnsatisfiedLinkError或者直接闪退。我一开始尝试在 Dart 侧用某个开源库做 secp256k1 签名,结果在鸿蒙模拟器上完全跑不通。
后面我把签名逻辑全部移到了鸿蒙原生侧,用系统加密接口处理,问题就消失了。这也验证了最初的分层思路:不要把平台相关能力硬塞进一个跨端库。遇到类似崩溃,先看崩溃发生在哪个底层 so 或 dylib 调用,再确认该类库是否有鸿蒙版本。实在没有,就改写原生侧实现。
5. 这次适配沉淀下来的经验与后续扩展
最后说点个人体会。这个项目最让我意外的不是技术难度,而是“边界划分”带来的收益。当我决定让 Flutter 只做 UI 和流程编排、鸿蒙原生负责敏感操作后,很多曾经很棘手的问题,比如私钥泄露、签名库崩溃、数据精度丢失,都自然消失了。不是它们不存在了,而是被限制在一个更可控的范围内。
如果你也想做类似的 Flutter 鸿蒙数字资产钱包适配,我建议先从一条最简单的“查询余额 + 转账”链路开始,跑通后再加入智能合约交互。不要一上来就急着做完整的 DApp 浏览器或复杂的多签合约逻辑。因为数据管线的复杂度是逐步累积的,一次接入太多功能,排查问题时会非常痛苦。
5.1 一些关于安全边界的个人体会
数字资产上链操作不可逆,所以安全边界怎么强调都不过分。我的原则是:私钥不经过 Dart 层、助记词不落本地明文、签名结果不写日志、交易内容需要在 UI 上二次确认。这些规则写进规范里容易,执行起来难,因为每个改动都可能影响开发效率。
但再麻烦也值得。鸿蒙系统的安全能力本来就是一大强项,如果因为开发便利把私钥放在 Flutter 侧,反而浪费了平台优势。真正合理的状态是:Flutter 侧永远拿不到私钥,用户看到的只是“签名中”“已广播”这样的状态,而无法从应用数据目录里翻出任何密钥文件。
另外,在使用测试网做验证时,尽量选择官方推荐的测试网络,并且不要在里面放有真实价值的数字资产。智能合约交互尤其要小心,测出问题可以不断重来,但如果误操作把资产发送到错误地址,结果是不可逆的。
5.2 后续可以怎么扩展
这次打通的是一个最基本的 JSON-RPC 加密管线,基于同样的架构,后面还能扩展不少能力。比如多链支持,可以把“节点配置”抽象成ChainConfig,在不同链之间切换;比如 DApp 内置浏览器,可以把合约调用从“预先拼装 data”升级为“动态解析用户输入的 ABI”;比如更细粒度的权限控制,把某些高风险交易二次校验流程做得更严格。
还有一个方向是把管线做成插件化,把“构造交易”和“签名”两个阶段拆成独立服务,方便以后接入硬件钱包或者系统级安全芯片。这个项目的核心价值不在于某一串代码,而在于把一个原本松散的第三方库调用,梳理成一条路径清晰、安全性可控的资产上链闭环。至少对我个人来说,下次再换一个平台做适配,我都会先画出一条管线,再开始写代码,而不是拿到一个第三方库就直接往工程里堆。