欢迎加入鸿蒙 PC 开发者社区,共同打造开发者工具生态:鸿蒙 PC 开发者社区:https://harmonypc.csdn.net/

LocalSend-ohos 开源项目地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_localsend

欢迎在PC社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper

环境搭建文章:https://blog.csdn.net/lbcyllqj/article/details/142694676

这篇文章记录的是 LocalSend 在 OpenHarmony / HarmonyOS PC 环境中的一次迁移和适配过程。

LocalSend 是一个开源跨平台文件传输工具,定位接近局域网内的 AirDrop。它本身不是一个简单 Flutter UI 项目,而是一个比较典型的跨端工程:

  • 前端界面基于 Flutter。
  • 核心通信用 Dart、Rust 和 HTTP API 共同完成。
  • Rust 侧通过 flutter_rust_bridge 接入 Flutter。
  • 网络请求依赖 rhttp,运行时需要对应平台的动态库。
  • 文件选择、偏好设置、路径、权限、URL 打开等能力依赖 Flutter 插件。
  • 设备发现依赖 UDP multicast、HTTP register、TCP 扫描等网络能力。

所以这次适配不是“把 Flutter 工程跑起来”这么简单。真正的难点在于:Flutter OHOS 工程能构建、Rust 动态库能产出、三方插件能注册、运行时不会因为平台判断失败而崩溃、文件选择能拿到真实可读内容、局域网发现链路能明确区分代码问题和模拟器网络限制。

最终当前状态是:

  • LocalSend 主应用可以构建 OHOS HAP。
  • rhttp 已经适配为项目内本地三方库,并能产出 OHOS .so
  • LocalSend 自身 Rust FFI 动态库可以产出并随 HAP 打包。
  • shared_preferences_ohos 的 Pigeon 返回值问题已修复。
  • uri_contentnetwork_info_plus 在 OHOS 上的启动阻塞已规避。
  • OHOS 文件选择器返回 file://docs/... 后导致“没有权限”的问题已定位并修复。
  • 模拟器上“Mac 搜不到 OHOS 设备”的现象已确认主要来自模拟器 NAT 和 UDP 组播限制,而不是服务没启动。

先放一张最终运行效果,给后面的技术细节做一个参照。

在这里插入图片描述


一、适配环境与项目基线

本次使用的工程目录是:

/localsend-main

LocalSend 仓库本身包含多个模块,核心目录大致如下:

localsend-main/
├── app/                 # Flutter 主应用
│   ├── lib/             # Dart 业务代码
│   ├── ohos/            # OHOS 工程
│   ├── rust/            # LocalSend 自身 Rust 代码
│   └── rust_builder/    # flutter_rust_bridge/cargokit 构建入口
├── common/              # 通用协议、DTO、网络任务
├── core/                # Rust core
└── third_party/         # 本次新增/固化的三方库适配

本次使用的 Flutter OHOS 环境为:

Flutter 3.35.8-ohos-0.0.3

测试设备为:

127.0.0.1:5555
OpenHarmony 5.0.5.316
API 17

常用构建和安装命令如下:

cd ~/XM/localsend-main/app

~/flutter_flutter/bin/flutter build hap --debug --target-platform ohos-arm64

~/flutter_flutter/bin/flutter install -d 127.0.0.1:5555 --debug --device-timeout=10

启动应用和查看日志使用 hdc

/Applications/DevEco-Studio.app/Contents/sdk/default/openharmony/toolchains/hdc \
  -t 127.0.0.1:5555 shell aa start \
  -a EntryAbility \
  -b org.localsend.localsend_app \
  -m entry

/Applications/DevEco-Studio.app/Contents/sdk/default/openharmony/toolchains/hdc \
  -t 127.0.0.1:5555 shell hilog -x

在这里插入图片描述


二、第一个阻塞点:rhttp 没有 OHOS 动态库加载路径

LocalSend 的网络请求依赖 rhttp。在普通 Android、iOS、macOS、Windows、Linux 平台上,rhttp 有对应平台的构建和动态库加载逻辑。但迁移到 OHOS 后,最先遇到的问题就是运行时无法识别平台:

loadExternalLibrary failed: Unknown platform=ohos

这类问题的本质不是 Dart 代码写错,而是三方库还没有把 OHOS 当作一个受支持的平台处理。LocalSend 虽然是 Flutter 应用,但 rhttp 底层包含 Rust 产物,运行时必须能找到并加载对应的动态库。

本次处理方式是把 rhttp 固化为项目内的本地三方库:

third_party/rust/rhttp_ohos

然后在 app/pubspec.yaml 中把依赖切到本地路径:

rhttp:
  path: ../third_party/rust/rhttp_ohos

简单说,固化 rhttp 的操作可以分成几步:

  1. 先把当前能用的 rhttp 源码从 pub 缓存或上游仓库拷贝到项目内,比如放到 third_party/rust/rhttp_ohos。这样后续 OHOS 适配改动就跟随项目一起管理,不再依赖机器本地的 .pub-cache
  2. rhttp_ohos/pubspec.yaml 里补齐 Flutter 插件平台声明,核心是给 plugin.platforms 增加 ohos,并声明 ffiPlugin: true。这样 Flutter OHOS 构建时才会把它当成需要 native 构建和打包的 FFI 插件。
  3. rhttp_ohos/ohos/ 下补一个 OHOS HAR 插件工程,至少包含 oh-package.json5build-profile.json5hvigorfile.tssrc/main/module.json5src/CMakeLists.txt。其中 CMakeLists.txt 里通过 cargokit 去编译 rust/ 目录下的 Rust crate。
  4. rhttp 自带的 cargokit 增加 OHOS 目标处理,让它能识别 ohos-arm64,并转换到 Rust 目标 aarch64-unknown-linux-ohos,同时配置 OHOS Native SDK 的 clangsysroot 和必要的 RUSTFLAGS
  5. 修改 Dart 侧动态库加载逻辑。原来的平台判断不认识 ohos,所以会抛 Unknown platform=ohos。适配后在 OHOS 分支显式加载 librhttp.so
  6. 最后回到主应用 app/pubspec.yaml,把 rhttp: 0.x.x 改成本地 path 依赖,然后执行 flutter pub getflutter build hap --debug --target-platform ohos-arm64 验证。

这里有两个检查点很关键:一个是 .flutter-plugins-dependencies 里能看到 rhttpohos 插件记录;另一个是最终 HAP 解包或构建中间产物里能看到 librhttp.so。如果这两个点缺一个,运行时大概率还是会加载失败。

适配完成后,HAP 中可以包含:

librhttp.so

同时运行日志里不再出现:

Unknown platform=ohos

这里有一个很重要的经验:跨平台 Flutter 项目迁移鸿蒙时,不能只看 pubspec.yaml 有没有声明依赖,还要看依赖背后有没有 native 产物。只要插件或 package 底层有 Rust、C/C++、FFI、动态库加载,就必须检查它是否真的支持 OHOS。


三、第二个阻塞点:LocalSend 自身 Rust FFI 动态库

LocalSend 自身也包含 Rust 代码,并通过 flutter_rust_bridge 暴露给 Dart 层。对应 Dart 侧可以看到生成代码:

app/lib/rust/frb_generated.dart
app/lib/rust/frb_generated.io.dart

运行时需要加载的库是:

librust_lib_localsend_app.so

适配重点包括:

  • 让 Rust crate 能够针对 OHOS arm64 编译。
  • rust_builder / cargokit 能参与 OHOS HAP 构建。
  • 确认生成的 .so 被打进 HAP。
  • 确认 Dart 侧能通过 ExternalLibrary.open('librust_lib_localsend_app.so') 加载。

librust_lib_localsend_app.so 的生成链路也可以简单理解成这样:

  1. LocalSend 的 Rust 代码在 app/rust/,crate 名称是 rust_lib_localsend_appCargo.toml 里把库类型声明为 cdylib / staticlib,所以它可以被编译成动态库给 Dart FFI 加载。
  2. flutter_rust_bridge.yaml 指定 Rust 输入和 Dart 输出,比如 rust_input: crate::apirust_root: rust/dart_output: lib/rust。生成后的 Dart 绑定就在 app/lib/rust/frb_generated*.dart
  3. app/pubspec.yaml 里声明了一个本地插件依赖:
rust_lib_localsend_app:
  path: rust_builder
  1. app/rust_builder/pubspec.yaml 把这个本地插件声明为 FFI 插件,并补了 ohos: ffiPlugin: true。这样主应用构建 HAP 时,Flutter 工具会把 rust_builder/ohos 当作一个 OHOS native 插件模块参与构建。
  2. rust_builder/ohos/src/CMakeLists.txt 里通过 cargokit 调用 Cargo,入口大致是 apply_cargokit(... ../../../rust rust_lib_localsend_app ...)。也就是说,OHOS 的 CMake/Hvigor 构建过程会反向触发 Rust crate 编译。
  3. cargokit 根据目标平台把 OHOS arm64 映射为 aarch64-unknown-linux-ohos,再用 OHOS Native SDK 里的 clangsysroot 做链接,最终生成 librust_lib_localsend_app.so,并放到 OHOS 插件的 libs/arm64-v8a/ 这类 native 库目录下。
  4. HAP 打包时,这个 .so 会随 rust_lib_localsend_app 插件一起进入包内。应用启动后,frb_generated.io.dart 里的加载逻辑通过 ExternalLibrary.open('librust_lib_localsend_app.so') 找到它,Dart 层才能正常调用 Rust 侧能力。

所以这个 .so 不是手工写出来的,也不是 Dart 编译出来的,而是 Flutter OHOS 构建过程中,由 rust_builder 插件、cargokit、Cargo 和 OHOS Native SDK 串起来自动产出的 Rust FFI 动态库。调试这类问题时,可以优先看三处:app/rust/Cargo.toml 的 crate 配置、app/rust_builder/ohos/src/CMakeLists.txt 的 cargokit 入口,以及最终 HAP 里有没有打进 librust_lib_localsend_app.so

这一步还遇到了 WebRTC 相关依赖问题。LocalSend 的部分 WebRTC 功能依赖 Rust 生态里的网络和系统调用库,其中 nix 0.26.4 对 OHOS 支持不足,导致这条链路无法直接完整启用。

这次采取的是“先保证主传输链路可用”的策略:

  • HTTP 发送、接收、发现优先适配。
  • WebRTC 相关能力在 OHOS 下先 stub / 禁用。
  • 保留后续继续适配 WebRTC 的空间。

这样做是为了避免一个低优先级能力阻塞整个应用迁移。对于 LocalSend 来说,局域网 HTTP 传输是更核心的基础能力,优先让它跑通更符合迁移节奏。


四、第三个阻塞点:shared_preferences_ohos 的 Pigeon 返回值不匹配

应用启动后,另一个比较隐蔽的问题出现在偏好设置插件上:

RangeError (length): Valid value range is empty: 0

从现象上看,它像是 Dart 侧访问数组越界;但根因在 OHOS 插件的 ArkTS 返回值。

shared_preferences_ohos 的 Dart 侧通过 Pigeon 生成的通道调用 OHOS 侧方法。Dart 侧期望写入成功后能收到一个包含布尔值的返回数组,例如:

[true]

但 OHOS ETS 侧部分成功写入路径返回了空数组:

[]

于是 Dart 侧解析返回值时访问第 0 项,直接触发 RangeError

本次做法是把 shared_preferences_ohos 复制到项目内:

third_party/flutter/shared_preferences_ohos

然后在 app/pubspec.yaml 中使用本地版本:

shared_preferences_ohos:
  path: ../third_party/flutter/shared_preferences_ohos

修复点主要集中在:

third_party/flutter/shared_preferences_ohos/ohos/src/main/ets/shared_preferences/SharedPreferencesOhosPlugin.ets
third_party/flutter/shared_preferences_ohos/ohos/src/main/ets/shared_preferences/Messages.ets

除了成功返回值,还同步修复了 StringList 相关通道名:

setEncodedStringList
setDeprecatedStringList

这个问题说明,Flutter 插件迁移时要同时检查三层:

Dart API
Pigeon 生成代码
ArkTS / native 实现

只要任意一层的消息格式不一致,应用就可能在启动早期崩溃,而且错误看起来不一定像插件问题。


五、第四个阻塞点:Android 插件逻辑不能直接套到 OHOS

LocalSend 原有代码里有一些平台判断是面向 Android 的。迁移到 OHOS 后,如果简单把 OHOS 当成 Android,短期可能能绕过编译问题,但运行时会出现更难排查的问题。

典型例子有两个。

第一个是 uri_content。原代码会在 Android 下传入:

AndroidUriContentStreamResolver()

但 OHOS 不是 Android,这个 resolver 在 OHOS 下没有意义。最终调整为只在 Android 下使用:

uriContentStreamResolver: checkPlatform([TargetPlatform.android])
    ? AndroidUriContentStreamResolver()
    : null,

第二个是 network_info_plus。这个插件在 OHOS 下没有 wifiIPAddress 实现,启动时会出现 MissingPlugin 相关问题。最终处理方式是在 OHOS 下跳过 NetworkInfo().getWifiIP(),直接使用 Dart native 的 NetworkInterface.list() 枚举网络地址。

对应日志中可以看到:

Network state: [10.0.2.15]

这说明 OHOS 侧已经能拿到本机地址,后续设备发现、HTTP 服务绑定都可以基于这个地址继续排查。


六、第五个阻塞点:文件选择器“没有权限”其实不是权限问题

这个问题非常典型。用户在 OHOS 模拟器中选择文件后,界面弹出类似“没有权限”的提示。第一反应很容易是去查 module.json5 权限、申请读写权限、签名权限等。

但日志揭示了真实原因:

FileSelectorApiImpl --> documentPickerSelect select files successfully,
documentPicker uris: file://docs/storage/Users/currentUser/Download/img3.png

PathNotFoundException: Cannot retrieve modification time,
path = 'file://docs/storage/Users/currentUser/Download/img3.png'

文件选择器其实已经成功返回了文件 URI。问题在于 LocalSend 后续把这个 URI 当成普通本地文件路径处理了。

原来的通用转换逻辑类似这样:

static Future<CrossFile> convertXFile(XFile file) async {
  return CrossFile(
    name: file.name,
    fileType: file.name.guessFileType(),
    size: await file.length(),
    path: kIsWeb ? null : file.path,
    bytes: kIsWeb ? await file.readAsBytes() : null,
    lastModified: kIsWeb ? null : await file.lastModified(),
    lastAccessed: null,
  );
}

在桌面端,file.path 通常是 /Users/.../xxx.png 这种真实路径;但 OHOS 文件选择器返回的是:

file://docs/storage/Users/currentUser/Download/img3.png

Dart 的 File API 不能把它当作普通路径读取,因此 length()lastModified() 就会失败。外层 catch 到异常后统一弹出 NoPermissionDialog,于是用户看到的就是“没有权限”。

修复方式是新增 OHOS 专用转换逻辑:先把选择器返回的内容保存到 App 自己的缓存目录,再把缓存中的真实路径交给 LocalSend 后续发送流程。

新增文件:

app/lib/util/native/ohos_file_cache.dart

核心逻辑:

Future<Directory> getOhosSelectedFileCacheDirectory() async {
  final directory = Directory(
    '${await getCacheDirectory()}/localsend_ohos_selected_files',
  );
  if (!await directory.exists()) {
    await directory.create(recursive: true);
  }
  return directory;
}

新增转换器:

static Future<CrossFile> convertOhosXFile(XFile file) async {
  final displayName = file.name.isEmpty ? 'selected_file' : file.name;
  final cachedFile = await createOhosSelectedFileCacheFile(displayName);
  await file.saveTo(cachedFile.path);
  final size = await cachedFile.length();

  return CrossFile(
    name: displayName,
    fileType: displayName.guessFileType(),
    size: size,
    thumbnail: null,
    asset: null,
    path: cachedFile.path,
    bytes: null,
    lastModified: null,
    lastAccessed: null,
  );
}

然后在文件选择入口中做平台分发:

converter: checkPlatformIsOhos()
    ? CrossFileConverters.convertOhosXFile
    : CrossFileConverters.convertXFile,

同时清缓存时也清理:

localsend_ohos_selected_files

这个修复完成后,OHOS 选择文件不再把 file://docs/... 直接交给 Dart File,也就不会再误报“没有权限”。

在这里插入图片描述


七、第六个阻塞点:模拟器里能看到 Mac,但 Mac 搜不到模拟器

LocalSend 的设备发现依赖 UDP multicast 和 HTTP register。迁移完成后,模拟器上的 LocalSend 可以看到 Mac 端 LocalSend,但 Mac 端 LocalSend 搜不到模拟器。

这个现象很容易被误判为:

  • OHOS 端服务没启动。
  • UDP 绑定失败。
  • rhttp 仍然没适配好。
  • 防火墙或权限没有打开。

但实际日志显示,OHOS 端服务是正常启动的:

Network state: [10.0.2.15]
Bind UDP multicast port (ip: [10.0.2.15], group: 224.0.0.167, port: 53317)
Server started. (Port: 53317, HTTPS only)

在模拟器内还可以看到端口监听:

tcp  0  0 0.0.0.0:53317  0.0.0.0:*  LISTEN
udp  0  0 0.0.0.0:53317  0.0.0.0:*

进一步通过 hdc fport 转发端口:

/Applications/DevEco-Studio.app/Contents/sdk/default/openharmony/toolchains/hdc \
  -t 127.0.0.1:5555 fport tcp:53318 tcp:53317

然后在 Mac 上访问:

curl -k https://127.0.0.1:53318/api/localsend/v2/info

可以得到类似返回:

{
  "alias": "新鲜的桃子",
  "version": "2.1",
  "deviceModel": "HarmonyOS",
  "deviceType": "desktop",
  "download": false
}

这说明 OHOS 端 LocalSend 服务本身是可用的。Mac 搜不到模拟器,核心原因是模拟器网络是 NAT。OHOS 模拟器看到自己的地址是:

10.0.2.15

这个地址对 Mac 所在的真实 Wi-Fi 局域网通常不可达。UDP multicast 也不能像真实局域网设备那样双向工作。因此会出现“模拟器能看到 Mac,但 Mac 看不到模拟器”的单向发现现象。

这不是 rhttp 或 Rust 动态库问题,也不是 LocalSend 服务没有暴露。更准确的结论是:

八、构建、签名与安装

本次用户已经手动完成签名配置,因此适配过程中没有覆盖签名文件。构建使用 Flutter OHOS 命令:

cd ~/XM/localsend-main/app

~/flutter_flutter/bin/flutter build hap --debug --target-platform ohos-arm64

成功后产物为:

app/build/ohos/hap/entry-default-signed.hap

本次构建产物大小约为:

173M

安装命令:

~/flutter_flutter/bin/flutter install \
  -d 127.0.0.1:5555 \
  --debug \
  --device-timeout=10

安装成功日志:

Installing entry-default-signed.hap to 127.0.0.1:5555...
Uninstalling old version...
installing hap. bundleName: org.localsend.localsend_app

验证命令:

~/flutter_flutter/bin/flutter analyze lib

结果:

No issues found!

在这里插入图片描述


九、最终验证链路

完成上述适配后,建议按下面顺序验证:

  1. 启动应用,确认没有 Unknown platform=ohosRangeErrorMissingPlugin wifiIPAddress 等启动错误。
  2. 查看日志,确认 HttpUploadIsolate 已就绪。
  3. 查看日志,确认 Network state 能输出 OHOS 本机 IP。
  4. 在 OHOS 端选择文件,确认不再弹“没有权限”。
  5. 从 OHOS 端选择 Mac 设备并发送文件,确认 Mac 端能收到请求。
  6. 打开 OHOS 端接收开关,确认服务端口启动。
  7. 如果 Mac 搜不到 OHOS 模拟器,用 hdc fport/api/localsend/v2/info 验证服务是否可访问。

启动成功日志中比较关键的几行:

Provider initialized: [Provider<PersistenceService>]
Child isolate is ready: HttpUploadIsolate
Network state: [10.0.2.15]
Server started. (Port: 53317, HTTPS only)

文件选择成功后,关键日志类似:

Cached OHOS selected file:
file://docs/storage/Users/currentUser/Download/img3.png
-> .../localsend_ohos_selected_files/..._img3.png

在这里插入图片描述


十、迁移过程中的经验总结

这次 LocalSend 迁移最重要的经验,是不要把所有问题都归类成“鸿蒙不兼容”。跨平台项目迁移时,问题通常分布在不同层级。

第一类是三方库平台适配问题。比如 rhttp,Dart 层依赖看起来正常,但 native 动态库加载逻辑不认识 OHOS,就会在运行时失败。解决这类问题,需要把三方库本地化,补齐 OHOS 构建和加载路径。

第二类是 Flutter 插件通道问题。比如 shared_preferences_ohos,表面是 Dart RangeError,根因却是 ArkTS 侧 Pigeon 返回值不符合 Dart 侧预期。解决这类问题,要沿着 Dart API、Pigeon 生成代码、ArkTS 实现一路查。

第三类是平台语义差异问题。OHOS 不是 Android,不能简单复用 Android 的 content resolver、Wi-Fi IP 插件、权限模型和文件路径假设。尤其是文件选择器返回的 file://docs/...,它不是普通磁盘路径,需要通过选择器提供的能力读出来,或者复制到 App 可控缓存目录。

第四类是模拟器网络限制问题。LocalSend 这种局域网工具高度依赖 UDP multicast 和真实局域网地址。模拟器 NAT 环境下,服务可以启动,HTTP 也可以通过端口转发验证,但 Mac 不一定能自动发现模拟器。这类问题要通过日志和端口验证区分,不能误判成业务代码失败。

第五类是能力取舍问题。WebRTC 相关 Rust 依赖在 OHOS 下还存在系统调用兼容问题,这次没有强行一次性适配所有能力,而是优先保证 HTTP 文件传输链路可用。迁移复杂项目时,先建立可运行、可验证的最小闭环,比一开始追求全能力更稳。


十一、结论

LocalSend 迁移鸿蒙 PC 的难点,不在 Flutter 页面本身,而在跨平台底层能力的完整闭环:

Flutter UI
  ↓
Flutter 插件
  ↓
Dart 网络与文件 API
  ↓
Rust FFI / flutter_rust_bridge
  ↓
三方 Rust 动态库
  ↓
OHOS HAP 打包、签名、运行时加载
  ↓
局域网发现和文件传输

本次适配后,LocalSend 已经具备在 OHOS 环境中启动、加载 Rust 动态库、保存偏好设置、枚举本机 IP、选择文件、启动 HTTP 服务和向 Mac 发送文件的基础能力。

仍然需要明确的边界是:在 OpenHarmony 模拟器 NAT 网络中,Mac 官方 LocalSend 搜不到模拟器并不等价于应用适配失败。日志和 hdc fport 已经证明 OHOS 端服务在 53317 端口正常工作;要验证双向自动发现,应该放到真实鸿蒙 PC 设备或桥接网络环境中继续测试。

这也是迁移局域网工具时最容易踩的坑:有些问题是代码问题,有些问题是插件问题,有些问题是三方 native 库问题,还有一些只是模拟器网络拓扑的问题。只有把日志、动态库、插件通道、文件路径和网络链路分开验证,才能真正把应用从“能构建”推进到“能使用”。

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐