1. 为什么我要把 p2plib 搬上鸿蒙:一个并不轻松的决定
这几年做 Flutter 跨端应用,最让我头疼的其实不是 UI 层,而是底层通信库的适配。Flutter 生态里好用的 P2P 加密通讯库本来就少,p2plib 算是我一直比较看好的一个:它把端到端加密、分布式节点发现、去中心化数据流传输打包到了一起,用 Dart 写了一层相对干净的 API,底层又可以用原生代码跑性能敏感的部分。前段时间团队接了鸿蒙端的项目,我原以为靠 Flutter 的跨端能力,把 p2plib 搬过去不过是改改依赖版本的事,结果真正动手才发现,鸿蒙的运行时环境、系统 API、动态库加载方式跟安卓/iOS 都有差异,直接编译根本过不去。
1.1 我遇到的具体场景
我们做的是一款面向局域网和弱网环境的协同工具,设备之间需要直接交换消息和文件,不走中心化服务器。核心诉求有三条:一是消息必须端到端加密,服务端即使被劫持也看不到明文;二是节点要能自动发现,设备连上同一个 Wi-Fi 就能互相找到,不需要手动输入 IP;三是数据流要支持去中心化的分发,例如一个节点把文件分片传给多个节点,再由这些节点互相补传。
这套需求在安卓端我们用 p2plib 跑得很顺。它内部把传输层、加密层、发现层拆开了,默认传输走 TCP 或 QUIC,端到端加密走 Noise 协议框架,节点发现支持 mDNS 和分布式哈希表,数据流走 publish/subscribe 模式。迁移到鸿蒙之后,第一个问题就是:p2plib 原生层编译时依赖的 POSIX socket、epoll、pthread 这些接口,在 OpenHarmony 的 NDK 里虽然大部分都有,但编译参数和数据模型跟安卓 NDK 并不完全一致,链接器报错能报出一长串。
1.2 p2plib 到底解决了哪三类问题
先帮还没用过这个库的朋友梳理一下。p2plib 的核心价值不是简单帮你"建立两个设备之间的连接",而是把一套完整的去中心化通信方案做成开箱即用的库:
- 端到端加密通讯:设备建立连接时通过 X25519 密钥交换协商会话密钥,数据加密用 AES-256-GCM,身份认证用 Ed25519 签名。加密对上层透明,你只需要调用 send 和 receive,不用自己管理密钥生命周期。
- 分布式节点发现:没有中心服务器的情况下,新节点上线要能"喊一声"让附近节点听到,也要能通过 DHT 找到不在同一个局域网的节点。p2plib 把这两种发现机制统一成了一个接口。
- 去中心化数据流传输:数据不是简单地点对点发一遍,而是允许一条消息通过节点转发扩散,同时支持流式传输大文件,最终做到"一端发送、多端接收、断网续传"。
这三块正好命中鸿蒙设备多、组网需求强的场景,也是我坚持要适配它的原因。如果只是简单传输,直接用 WebSocket 或者系统自带的 Socket API 就好了,没必要冒着踩坑风险去适配一个三方库。
1.3 鸿蒙化不等于"再编译一次"
我一开始的错误认知是:把源码 clone 下来,改用鸿蒙的 CMake 工具链编译,再把动态库放到 jniLibs 同名目录下,应该就行了。结果犯了三个方向性错误:
第一,Flutter 鸿蒙分支的插件加载机制和安卓不一样。安卓用 PlatformView 和 openAsset 读取动态库,鸿蒙侧有自己的一套资源管理和 Native 库查找路径,直接沿用会导致DynamicLibrary.open找不到 so 文件。
第二,底层网络权限模型不同。鸿蒙的应用沙箱对本地网络、组播、Wi-Fi 状态感知有明确的权限声明,漏掉任何一个,节点发现阶段就会出现"能 ping 通但 p2plib 发现不了对方"的诡异现象。
第三,鸿蒙的 Flutter 引擎本身还在快速迭代,Dart SDK 版本、C++ ABI、线程模型跟上游 Flutter 不完全同步。这意味着 p2plib 里但凡用了 Dart 2.x 以后新增的 isolate 或dart:io特性,都需要逐个验证兼容性。
所以说,把 p2plib 鸿蒙化,本质上是一个"底层原生库 + Dart 桥接层 + Flutter 平台通道"三层同时适配的工程,不是敲几条命令就能解决的。
2. 适配前必须搞懂的底层原理:加密握手、节点发现和数据流路由
很多人在适配三方库时习惯于"一把梭":直接拉到项目里编译,报错就改,改完就跑。但 p2plib 这种网络库不行,它的失败模式非常隐蔽,单纯看编译错误根本定位不到问题。我建议在动代码之前,先把这个库的内部机制摸清楚,尤其是它怎么处理加密、发现、路由这三件事。
2.1 加密通讯部分:Noise 协议框架与 X25519
p2plib 的加密层我翻过源码,它是基于 Noise 协议框架做的二次封装。Noise 协议不是一个固定算法,而是一套协议框架,类似 HTTP 里的状态机,允许你组合不同的密钥交换算法、对称加密算法和哈希算法。p2plib 的默认配置是:
- 密钥交换:X25519,即 Curve25519 椭圆曲线 Diffie-Hellman,性能高且实现不容易出侧信道问题。
- 对称加密:AES-256-GCM,带认证的加密,既能加密又能防篡改。
- 哈希与签名:BLAKE2b 做会话密钥派生,Ed25519 做节点身份签名。
一次典型的 p2plib 握手大概是这样的:发起方生成一个临时 X25519 密钥对,把自己的公钥和身份签名发给接收方;接收方验证签名后,用对方的临时公钥和自己的密钥计算共享密钥;然后双方用这个共享密钥作为 Noise 握手的基础,继续交换最终会话密钥。这个过程的好处是,即便中间有人劫持了消息,也不能伪造节点身份,因为没有 Ed25519 私钥。
在鸿蒙上适配时,要特别注意 BoringSSL 或 OpenSSL 的版本。p2plib 原生层如果用的是系统自带的加密库,那么鸿蒙 NDK 提供的 libcrypto 版本跟安卓不一定相同,某些算法实现可能被裁剪或改名。我踩过的一个坑就是EVP_chacha20_poly1305在鸿蒙 NDK 版本里没有默认导出,后来将配置改成 AES-GCM 才稳定。
2.2 分布式节点发现:mDNS、DHT 与 NAT 穿透
节点发现是 p2plib 最有意思的部分。它同时跑三条发现路径:
- 局域网内用 mDNS:类似"喊话",新节点上线就广播自己的设备名和公钥指纹,同一网段内的其他节点监听并回包。这个机制依赖 UDP 组播,对鸿蒙来说头号问题是组播权限。
- 广域网用 DHT:无中心节点,每个节点维护一部分路由表,通过 Kademlia 协议在分布式哈希表里查找其他节点。它依赖大量 UDP 小包通信。
- 打洞辅助 STUN/TURN:如果两个节点都不在同一个局域网,p2plib 会尝试 UDP 打洞,不行就通过 TURN 中继。TURN 需要服务器,但在纯去中心化场景里可以关掉。
在鸿蒙上,"发现不到节点"这个症状,90% 的根因不是代码问题,而是网络权限或组播限制。鸿蒙对 UDP 组播、多播、Wi-Fi 状态感知都有单独的权限项,比如ohos.permission.GET_WIFI_INFO用于读取当前 Wi-Fi 信息,ohos.permission.INTERNET用于基础网络访问。这两个权限看着简单,但漏掉任何一个,mDNS 的组播包就发不出去。
另外,鸿蒙的隐私模式如果开启了"对未知应用隐藏",也可能导致组播包被系统直接丢弃。这不是技术问题,而是使用环境问题,我在实测中遇到过一次,换成默认隐私模式就正常了。
2.3 去中心化数据流:DAG 路由与 Gossip 传播
p2plib 的数据流层不是简单的 socket 收发,它借鉴了 IPFS 的思路,把数据包组织成带哈希索引的块,通过 DAG 记录块之间的依赖关系,再通过 gossip 协议在节点之间扩散。这样做有三个好处:
- 数据可以分片传输,大文件不用一次发完,断点续传天然支持。
- 多个节点可以同时承担分发任务,减轻单一节点的带宽压力。
- 消息发布时可以选择只发给邻居节点,由邻居决定是否转发,这就是去中心化数据流传输的含义。
在鸿蒙上做 native 移植时,这个模块几乎不需要改,因为它是纯 Dart 实现的,底层只是调用了dart:io的 UDP/TCP。但问题恰恰出在dart:io上:Flutter 的鸿蒙分支对RawDatagramSocket的中文注释不多,且部分网络事件回调在鸿蒙引擎上触发时机不太一样,导致我在测试时出现"消息隔了很久才收到"的情况。后来我干脆把底层 UDP socket 操作下沉到原生层,通过 FFI 调用鸿蒙的 socket 接口,事件分发再走 Flutter 的 EventChannel,这个隔了十几秒才收到消息的问题才消失。
3. 鸿蒙平台的现实约束:Flutter 分支、OHOS SDK 与原生库交付
搞清楚了底层原理,接下来就是硬碰硬的适配环节。这一章我先讲鸿蒙平台的现实约束,你不提前规划,后面每一步都会被卡住。
3.1 Flutter 在 OpenHarmony/HarmonyOS 的现状
鸿蒙应用开发目前有两条路线:一条是 HarmonyOS NEXT 的 ArkTS 原生路线,另一条是 OpenHarmony 兼容 Flutter 的路线。p2plib 是 Dart 库,所以只能走 Flutter 路线。
目前 Flutter 官方并不直接支持 OpenHarmony,但 OpenHarmony SIG 组织维护了一个独立的 Flutter 分支,叫flutter_flutter,它跟随上游 Flutter 的版本节奏,同时把引擎层适配到了 OpenHarmony。你需要在项目里把 Flutter SDK 换成这个分支,并且用flutter-ohos相关工具链创建鸿蒙平台目录。
实践中最常见的坑是版本错配。OpenHarmony 分支的 Flutter 版本通常落后上游 1~2 个 minor 版本,p2plib 如果依赖了新版本 Dart 的特性,比如 records 或者 pattern matching,在旧版本上就会编译不过。解决办法是让 p2plib 的 Dart 依赖锁定在 OpenHarmony 分支支持的语法范围内,不能盲目追求最新版。
3.2 p2plib 依赖链切割
p2plib 不是一个单一的包,它通常会依赖多个底层库。适配时要先把它拆开,看看哪些是纯 Dart 实现、哪些是 C/C++ 原生实现、哪些依赖系统能力:
- 纯 Dart 部分:例如 DHT 路由表、消息序列化、流控制,这部分理论上跨平台,但需要回归测试。
- C/C++ 原生部分:例如加密算法、带状态的网络协议栈,要重新交叉编译。
- 系统能力依赖:例如获取设备 Wi-Fi 状态、获取本机 IP、电量感知,这部分要用鸿蒙平台通道替换原实现。
我建议在项目里为 p2plib 建一个p2plib_ohos的适配层,而不是直接改上游源码。这样做的原因是未来上游升级时,你可以只重放适配补丁,不需要手工合并所有改动。我们会把原生层代码放到ohos/native目录,Dart 桥接层放到lib/src/ohos,平台通道放到ohos/plugin,三条线互不干扰。
3.3 权限与网络策略
鸿蒙应用的权限声明统一放在module.json5里。我在适配 p2plib 时的最小权限集如下:
{ "module": { "name": "entry", "type": "entry", "requestPermissions": [ { "name": "ohos.permission.INTERNET" }, { "name": "ohos.permission.GET_WIFI_INFO" }, { "name": "ohos.permission.ACCESS_WIFI_STATE" }, { "name": "ohos.permission.GET_NETWORK_INFO" } ] } }这里要特别提醒:ohos.permission.INTERNET是声明必须放在最前面的,漏掉它,整个库所有 socket 操作都会以权限错误失败;而GET_WIFI_INFO是 mDNS 发现的关键,漏掉它,你的设备能上网,但组播包发不出去,节点发现静默失败。
另一个约束是后台网络策略。鸿蒙系统对后台应用的网络访问限制比安卓更激进的,应用退到后台后,纯 Dart 层的定时器可能被挂起,UDP socket 也可能被系统回收。如果你们的 P2P 通信需要长时间后台保持,建议在鸿蒙侧申请长时任务权限,并把节点发现的核心逻辑放在前台 Service 里,不要指望 Flutter 引擎退到后台还在跑。
4. 手把手适配流程:从交叉编译到 Dart FFI 再到插件封装
前面全是准备工作,这一章进入实操。我尽量按我实际执行过的顺序写,每一步都会说明为什么要这么做,而不是只给命令。
4.1 交叉编译:把 p2plib 原生层编译成鸿蒙可用的动态库
p2plib 的 C/C++ 原生代码要针对 OpenHarmony NDK 交叉编译。OpenHarmony NDK 提供了官方的 CMake 工具链文件,通常位于:
$OHOS_NDK_HOME/build/cmake/ohos.toolchain.cmake我的编译脚本大致长这样:
export OHOS_NDK_HOME=/path/to/ohos-sdk/linux/native cmake -B build/ohos \ -DCMAKE_TOOLCHAIN_FILE=$OHOS_NDK_HOME/build/cmake/ohos.toolchain.cmake \ -DOHOS_ARCH=arm64-v8a \ -DOHOS_PLATFORM=ohos \ -DBUILD_SHARED_LIBS=ON \ -DCMAKE_BUILD_TYPE=Release cmake --build build/ohos几个关键参数的意思:
OHOS_ARCH=arm64-v8a:当前绝大多数鸿蒙手机和平板都是 arm64 架构,不要交叉编译到 x86_64,那样在真机上跑不起来。OHOS_PLATFORM=ohos:让 NDK 按鸿蒙的 API level 和 libc 差异去链接。BUILD_SHARED_LIBS=ON:p2plib 要被 Flutter 通过 Dart FFI 加载,必须是动态库,静态库没法在运行时加载。
编译完成后会生成libp2plib.so。下一步是把它放到 Flutter 鸿蒙项目的ohos/libs/arm64-v8a目录下,然后在鸿蒙的 CMake 里把它打包进应用。
4.2 Dart FFI 封装:让 Dart 层直接调用原生能力
有些 p2plib 能力原生层已经实现了,但没有对 Dart 暴露接口。这种情况下我建议直接写一个精简的 C API,再通过 Dart FFI 调用。先看一个最简单的初始化函数:
import 'dart:ffi'; import 'package:ffi/ffi.dart'; typedef P2PInitNative = Pointer<Void> Function(Int32 flags); typedef P2PInit = Pointer<Void> Function(int flags); final DynamicLibrary _lib = DynamicLibrary.open('libp2plib.so'); final P2PInit _p2pInit = _lib.lookupFunction<P2PInitNative, P2PInit>( 'p2p_init', ); Pointer<Void> initP2P(int flags) => _p2pInit(flags);这里有个经验:DynamicLibrary.open的路径要写libp2plib.so,不带目录前缀。如果你在鸿蒙侧把 so 放到了libs/arm64-v8a,系统加载器会自动搜索应用私有目录,不要写完整路径,否则在部分鸿蒙版本上会因为路径解析规则不同而加载失败。
Dart FFI 的代价是:每次跨语言调用都有一定的开销,不适合高频小包传输。所以我在设计时只把"初始化密钥库""启动节点发现""发送一条完整消息""接收一条完整消息"这类粗粒度操作放到 FFI 层,细粒度的数据流仍在 Dart 层做,尽量让每次 FFI 调用携带更多数据。
4.3 MethodChannel/EventChannel 封装:把鸿蒙系统能力交给 Dart
p2plib 的 Dart 层需要知道当前设备的 IP、Wi-Fi 状态、网络类型。这些信息在鸿蒙上只能通过系统 API 获取,所以必须走 Flutter 平台通道。
我通常会建两个通道:
- MethodChannel:用于主动查询,比如"获取当前设备 IP""获取 Wi-Fi 信号强度""开启节点发现"。
- EventChannel:用于被动监听,比如"节点上线""节点离线""收到新消息"。
在 Dart 侧,开启节点发现可以这样设计:
import 'package:flutter/services.dart'; class P2PNodeDiscovery { static const _methodChannel = MethodChannel('com.example.p2plib/methods'); static const _eventChannel = EventChannel('com.example.p2plib/events'); Future<void> start() async { await _methodChannel.invokeMethod('startDiscovery'); } Stream<dynamic> get onNodeFound { return _eventChannel.receiveBroadcastStream(); } }在鸿蒙原生侧,EventChannel 的 sink 要记得在合适的时机关闭。我踩过的坑是:页面销毁后 EventChannel 还在继续往外发事件,导致 Dart 侧出现"LateInitializationError"或者内存泄漏。正确做法是在onDetachedFromEngine回调里 cancel 掉所有正在发布的流事件。
4.4 验证用例:先跑通最小闭环
适配完成后,先别急着接业务。搭一个最小验证程序,两个模拟器或两台真机,验证四条基本链路:
| 验证项 | 预期结果 | 检查点 |
|---|---|---|
| 初始化 | 双方返回相同版本的握手指纹 | 密钥库生成成功,无异常 |
| 局域网发现 | 3 秒内发现对方节点 | mDNS 组播包能互通 |
| 加密通信 | 发送"hello"能收到密文 | 抓日志确认经过 Noise 握手 |
| 数据流传输 | 发送 10MB 文件能完整接收 | 分片重组无丢失,哈希一致 |
这一套跑通后,再接入业务逻辑。否则你后面排查问题时,很难确认是 p2plib 的适配问题,还是你们业务代码的问题。
5. 实测中踩过的坑与排查链路
这一章我把自己在鸿蒙适配 p2plib 时遇到过的典型问题按"现象-定位-解决"的格式整理出来,每个问题都对应一个比较典型的排查思路,希望帮大家节省时间。
5.1 动态库加载失败:找不到符号libc.so中的epoll_create1
- 现象:
DynamicLibrary.open('libp2plib.so')抛ArgumentError: Dynamic library not found。 - 定位:用 hdc 进到沙箱目录,执行
ldd或readelf -d查看动态库依赖,发现它引用了一个鸿蒙 NDK 没有的符号。 - 根因:p2plib 原生代码中有一段条件编译只在安卓的
__ANDROID_API__宏下启用,鸿蒙 NDK 不定义这个宏,导致编译器走错分支,选用了不存在的 libc 接口。 - 解决:在 p2plib 源码的 CMakeLists 中显式传入
-DPLATFORM_OHOS -D_GNU_SOURCE,并在代码里用__OHOS__宏控制条件编译。
这个问题的经验教训是:不要把"安卓能用"等同于"鸿蒙能用",交叉编译前先跑一遍readelf检查动态库符号。
5.2 节点发现失败:双方在同一 Wi-Fi 但 mDNS 永远找不到对方
- 现象:Dart 层 startDiscovery 返回成功,但 onNodeFound 始终没有事件。
- 定位:先用
hdc shell在设备上执行ping,ping 通说明网络层通;再检查 UDP 5344 端口(p2plib 默认 mDNS 端口)是否有组播包发出。 - 根因:权限配置少了一项。鸿蒙的
GET_WIFI_INFO和GET_NETWORK_INFO是分开的,只申请了 INTERNET,组播包虽然能发出去,但系统网络策略直接丢弃了未声明的组播请求。 - 解决:在
module.json5里补上ohos.permission.GET_WIFI_INFO和ohos.permission.ACCESS_WIFI_STATE,重新编译安装后,3 秒内就发现对方了。
5.3 EventChannel 数据丢失:节点离线事件经常延迟数分钟
- 现象:设备 A 关掉 Wi-Fi,设备 B 要过几分钟才能感知到 A 离线。
- 定位:看 EventChannel 在鸿蒙侧的 sink 是否持有了 Dart 层 stream 之外的副本;同时检查
keepAlive机制是否默认关闭。 - 根因:Flutter 鸿蒙分支对 EventChannel 的流事件做了合并发送优化,低频事件可能被延迟;另一方面 p2plib 的节点心跳默认 120 秒一次,在鸿蒙上还额外叠加了系统省电策略。
- 解决:把 p2plib 的心跳间隔调到 30 秒,并在鸿蒙原生侧把 EventChannel 的事件改成独立发送,不走合并队列。
5.4 内存持续上涨:长时间运行后 Native 层内存翻倍
- 现象:App 运行 4 小时后,native 内存从 80MB 涨到 200MB。
- 定位:用 hdc 抓取 p2plib 进程的 native heap 快照,发现大量的 sockaddr 结构和加密上下文没有释放。
- 根因:p2plib 在每次握手失败时创建了加密上下文,但异常分支没有走析构逻辑。在安卓上不至于崩溃,是僵尸对象堆积到一定程度也不触发系统回收,但在鸿蒙的 VM 更容易暴露。
- 解决:给 p2plib 的握手函数打补丁,在失败分支显式调用
EVP_PKEY_CTX_free,并在 Dart FFI 层增加一个句柄回收函数,周期调用。
6. 适配后的性能表现与下一步扩展建议
最后聊聊我把 p2plib 鸿蒙化之后实测到的一些性能数据,以及后续可以怎么扩展。
6.1 性能对比:FFI 桥接对吞吐的影响
同样是传输 1MB 随机数据,我在鸿蒙开发板上跑了几轮对比:
| 场景 | 吞吐量 | CPU 占用 | 备注 |
|---|---|---|---|
| 纯 Dart UDP 直发 | 22 MB/s | 18% | 未加密,仅用于基线 |
| p2plib 原生 + FFI 调用 | 15 MB/s | 24% | 含 AES-256-GCM 加密 |
| p2plib 原生直连(无 Flutter) | 17 MB/s | 21% | 同一个引擎,无桥接 |
FFI 桥接的损耗大约在 12%~15%,对 P2P 加密通讯来说完全可以接受。如果你对吞吐很敏感,可以把大块数据的内存指针直接传给 FFI,而不是在 Dart 层做一次Uint8List拷贝,实测下来还能再提升 8% 左右。
6.2 下一步扩展方向
p2plib 鸿蒙化后,我建议在三个方向继续玩:
- 离线消息队列:利用 DHT 存储部分消息指纹,当接收方离线时由邻居节点暂存,接收方上线后再拉取。
- 局域网多设备 mesh:用在会议室投屏、临时文件互传、多人白板同步等场景,不依赖外网。
- 端上模型分发:把一个训练好的模型分片预分发到多个鸿蒙设备,再通过 DHT 互相补全,避免所有设备都从同一个中心服务器下载。
每个方向都不需要重写 p2plib,主要是利用它已经具备的加密、发现、数据流能力做业务层封装。
6.3 我个人的一点适配建议
如果你也在把 p2plib 或者其他 Dart 三方库搬到鸿蒙,我给你几个掏心窝的建议:
不要一上来就追求"全部功能可用"或者说"一行不改"。先按最小闭环跑通核心链路,再逐步补次要功能,耐心排查每个失败点。readelf和hdc shell是我每天必用的两个工具,前者检查动态库,后者直接进到沙箱里看网络状态和日志,比任何 IDE 日志面板都直观。
另外,要接受"适配层"这个思路。不要幻想一个库能直接跑在鸿蒙上,现实是总有 API 差异、权限模型差异、线程模型差异。老老实实做一个薄薄的适配层,把差异隔离在里面,上层业务代码保持干净,后续 p2plib 升级时你也能快速跟进。
最后,别小看权限声明和系统网络策略这两个不起眼的部分。我在适配过程中反复踩坑的,几乎都跟代码逻辑无关,而是这些系统层面的隐性约束。把鸿蒙的权限模型和网络策略当成适配的一部分来重视,你的进度会顺畅很多。