环境:Windows 11 + DevEco Studio 26.0 + OpenHarmony SDK 26 + Emulator 26.0(x86_64,OpenHarmony 7.0.0.107,hdc 目标 127.0.0.1:5555);Go 工具链为鸿蒙适配版(go 1.26.5),交叉编译目标 openharmony;壳层用 ArkTS + ArkWeb + NAPI。
一句话:wails3 不支持鸿蒙,但 wails3 那套「Go 后端 + WebView 前端 + 真实 HTTP」的模式可以移植。本文记录在鸿蒙上自研这套模式的设计、落地,以及踩到的一个经典坑——构建机绝对路径被写进 ELF 依赖。


一、为什么要自研「wails3 模式」

项目是个跨平台应用,架构原则很明确:

  • 业务逻辑一份,住在 Go 后端;

  • 前端资源由 Go 侧 go:embed 打包,前后端只走真实 HTTP(http://127.0.0.1:端口);

  • 各平台壳只负责窗口 / WebView / 系统能力,不碰业务。

这个模式在桌面端由 wails3 承担:wails3 管窗口、管 WebView、管 Go 与前端之间的桥。但 wails3 官方不支持鸿蒙,也没有短期的支持计划。摆在面前的选择:

  1. 为鸿蒙单独写一套前端 + 业务逻辑 → 违背「业务逻辑一份」的原则;

  2. 换一个支持鸿蒙的跨端框架 → 整个架构推倒重来;

  3. 把 wails3 的「模式」抽出来,用鸿蒙原生能力自己实现一遍壳。

选 3。所谓「模式」,核心就三条:Go 后端进程内跑 HTTP、WebView 加载本地端口、壳只做窗口和系统能力。这三条在鸿蒙上都能映射:

wails3 角色鸿蒙上的对应物
窗口 / WebViewArkWeb 组件
Go 与前端桥NAPI 加载 Go 编译出的 c-shared 库
Go 进程内 HTTPGo 侧 ListenAndServe("127.0.0.1:端口")
前端资源Go 侧 go:embed + HTTP serve,ArkWeb 直接访问
应用生命周期Ability 的 onCreate 等回调

映射完之后,鸿蒙壳和桌面 wails3 壳在「业务视角」上完全对等:Go 后端代码零改动,前端代码零改动,只有壳这一层各平台各写各的。


二、模式设计

2.1 Go 侧:c-shared 导出启动函数

Go 后端编译成 c-shared 库,//go:build openharmony 标签下导出启动函数:

go

//export StartServer
func StartServer(dataDir *C.char) C.bool

函数体内做三件事:

  1. 把运行时目录显式指到 App 沙盒:HOME、数据目录、XDG_*、TMPDIR 全部设到 Ability 传进来的 filesDir。鸿蒙的沙盒路径和桌面差异很大,不显式设置,Go 侧默认路径会落到不可写位置。

  2. goroutine 里 ListenAndServe:监听 127.0.0.1:端口,HTTP serve go:embed 进来的前端资源 + 业务 API。

  3. 错误用 log.Printf 不用 log.Fatal:c-shared 库被加载进 App 进程,log.Fatal 会直接 os.Exit,把整个 HAP 弄死。错误必须返回给壳层,由壳层决定怎么处理。

2.2 壳侧:ArkWeb + 启动轮询

壳工程的职责:

  • Ability onCreate 里 import { startServer } from 'libentry.so',传 this.context.filesDir,拉起后端;

  • 页面侧先轮询后端 HTTP 是否就绪(比如 100 次 × 200ms),就绪后再给 ArkWeb 设 URL,避免首启白屏;

  • ArkWeb 访问 http://127.0.0.1:端口,加载的就是 Go 后端 serve 出来的页面。

2.3 权限与网络配置

  • module.json5 声明 ohos.permission.INTERNET;

  • 网络配置允许 cleartext HTTP(因为走的是 127.0.0.1,生产环境不需要 TLS)。

2.4 构建链路

Go 侧用鸿蒙适配的 Go 工具链交叉编译两份 c-shared:

bat

set "GOOS=openharmony"
set "GOARCH=amd64"   rem 模拟器用;真机是 arm64
set "CGO_ENABLED=1"
set "CC=<DevEco SDK>\native\llvm\bin\clang.exe --target=x86_64-linux-ohos"
go build -buildmode=c-shared -o libgo_core.so ./cmd/server

产物 .so 和头文件复制进壳工程的 entry/src/main/cpp/libs/{abi}/ 与 cpp/include/,由 CMake 作为 imported 库引入。HAP 打包、签名、安装走标准流程。

到这里,模式骨架就搭完了。真正花时间的是让它在正式 HAP 里跑起来。


三、关键坑:ELF 头里的构建机绝对路径

3.1 现象

HAP 构建、签名、安装都正常,aa start 之后 App 能起来,但后端启动失败。日志依次出现:

  1. Go backend start failed: {} —— 一个空对象;

  2. TypeError: Cannot read property startServer of undefined;

  3. SyntaxError: the requested module 'entry' does not provide an export name 'startServer'。

到第 3 条,说明 NAPI 的导出根本没注册上。在 napi_init.cpp 三处(RegisterEntryModule / Init / define_properties)加 OH_LOG_Print,结果一条日志都没有。RegisterEntryModule 是在 .so 构造器里执行的,它都没打出来,结论只有一个:libentry.so 在加载阶段就失败了,压根没走到 NAPI 注册。

3.2 定位

hdc shell 把设备侧的 libentry.so 拉回本地,readelf -d 一看:

text

DT_NEEDED: C:/Users/<用户名>/<项目路径>/entry/src/main/cpp/libs/x86_64/libgo_core.so
RUNPATH:   C:/Users/<用户名>/<项目路径>/entry/src/main/cpp/libs/x86_64

CMake 把构建机(Windows)的绝对路径写进了 ELF 的动态依赖里。设备上不存在 C:/Users/...,于是:

  1. 动态链接器按 DT_NEEDED 找 libgo_core.so,路径不存在 → 失败;

  2. libgo_core.so 加载不了 → libentry.so 整体加载失败;

  3. .so 构造器没跑 → RegisterEntryModule 没执行 → startServer 不存在。

前面三个报错全是这条链的下游症状。这恰恰是「自研壳」模式最容易踩的坑:桌面端 wails3 帮我们把原生库的链接细节全处理了,自研壳就得自己对这些细节负责。

3.3 修复

CMakeLists.txt 加两个开关:

cmake

set(CMAKE_SKIP_RPATH TRUE)          # 不生成 RUNPATH

add_library(go_core SHARED IMPORTED GLOBAL)
set_target_properties(go_core PROPERTIES
    IMPORTED_LOCATION ${NATIVERENDER_ROOT_PATH}/libs/${OHOS_ARCH}/libgo_core.so
    IMPORTED_NO_SONAME TRUE)        # 不写入带绝对路径的 SONAME 依赖
  • CMAKE_SKIP_RPATH:阻止 CMake 把构建机的库搜索路径写进 RUNPATH;

  • IMPORTED_NO_SONAME:对 imported 目标不生成基于绝对路径的 SONAME 依赖,DT_NEEDED 里只留裸文件名。

重编重装后再 readelf -d:NEEDED 只剩 libgo_core.so,RUNPATH 消失。启动日志变成:

text

RegisterEntryModule called
NAPI Init called
NAPI exports initialized: status=0
Go backend start requested: <沙盒 filesDir>
StartServer dataDir=<沙盒 filesDir>
Go backend start returned: true

之后 ArkWeb 初始化、轮询到后端就绪、页面加载完成,模拟器上页面正常显示。


四、自研这套模式,绕不开的几个点

4.1 异常可读性先修

第一条日志 Go backend start failed: {} 很误导——JSON.stringify(undefined) 本来就输出 {}。日志里同时打 String(err) 和 JSON.stringify(err),才看清是 TypeError 而不是后端返回了空错误。自研壳没有框架兜底,异常可读性得自己保证。

4.2 命名导出 vs 默认导入

NAPI 生成的 Index.d.ts 是命名导出(export const startServer),ArkTS 侧如果按默认导入拿到的整个是 undefined。改成 import { startServer } from 'libentry.so' 才对。这一点排除后,才把嫌疑收缩到「库没加载」。

4.3 「能跑」和「HAP 里挂」不矛盾

排查初期用最小 C 加载器在模拟器上 dlopen + 调 StartServer 是成功的。那是直接把设备上的 .so 当普通文件加载,路径给对就行;正式 HAP 走的是动态链接器按 ELF 依赖找库的路子。两条路径不同,所以「能跑」和「HAP 里挂」同时成立。差点被这个不一致带偏。

4.4 埋点姿势

  • 异常先确保可读(String(err) + JSON.stringify(err) 都打);

  • 在「注册 / 导出 / 调用」三段都埋 OH_LOG_Print,快速判断卡在哪一段;

  • 一旦怀疑原生库加载,直接 hdc shell 把设备侧产物拉回来 readelf -d,看 NEEDED / RUNPATH / SONAME。

4.5 轮询期失败日志别当结论

后端就绪前,HTTP 探测会打 ECONNREFUSED / 连接超时之类的错误,这是轮询期的正常现象。判断依据要看 start returned: true 和页面加载事件,而不是探测期的报错。


五、这套模式的价值与边界

5.1 价值

  1. 业务逻辑零改动:Go 后端代码在桌面 wails3 和鸿蒙 ArkWeb 两套壳里是同一份,只多了 //go:build openharmony 的启动入口。

  2. 前端零改动:go:embed 的前端资源由 Go 后端 serve,ArkWeb 访问本地端口,和桌面 WebView 加载本地端口是同一个语义。

  3. 壳层薄且可控:鸿蒙壳只负责 ArkWeb + NAPI + Ability 生命周期,不碰业务,后续换壳成本低。

  4. 调试路径一致:桌面和鸿蒙都是「Go 进程内 HTTP + WebView 访问本地端口」,问题定位思路可以复用。

5.2 边界

  • NAPI 桥的能力范围有限:wails3 有完整的 Go↔JS 绑定、事件系统、窗口 API,自研壳目前只做到「拉起后端 + 访问 HTTP」。如果业务需要 Go→JS 主动推送、窗口控制等,得在 NAPI 层逐个补。

  • 构建链路要自己对原生库负责:ELF 依赖、RPATH、SONAME 这些细节,wails3 帮你屏蔽,自研壳得自己管。本文这个坑就是典型。

  • 遗留未验证项:搜索 / 播放的最小链路还没在模拟器上完整验证(首页显示 ≠ 全部通过);宿主侧 hdc fport 之后用 curl 访问端口还有 connection reset 现象,属于宿主调试侧的问题,与 App 内显示成功无关。


六、结论

  1. wails3 不支持鸿蒙,但「Go 后端 + WebView 前端 + 真实 HTTP」的模式可以移植。 映射关系是 ArkWeb ↔ WebView、NAPI ↔ Go 桥、ListenAndServe ↔ 进程内 HTTP、go:embed + HTTP serve ↔ 前端资源,壳层各平台各写各的,业务和前端零改动。

  2. 自研壳的核心成本在「原生细节」。 桌面框架帮你屏蔽的 ELF 依赖、RPATH、SONAME,自研壳得自己管。这次踩的坑就是 CMake 把构建机绝对路径写进了 libentry.so 的 DT_NEEDED / RUNPATH,readelf -d 一拉就现形,CMAKE_SKIP_RPATH + IMPORTED_NO_SONAME 两个开关解决。

  3. 凡是「构建机路径 vs 运行机路径」的差异,先看 ELF 头再怀疑别的。 签名、端口、ArkWeb、NAPI 都排了一遍,最后发现是链接细节,这条经验值得记。

  4. 这套模式适合「业务逻辑单一、前端可复用、壳只需要窗口和系统能力」的场景。 如果业务重度依赖框架提供的事件系统、窗口 API、原生桥,自研壳的工作量会明显上升,需要评估。


七、一句话总结

鸿蒙没有 wails3,但 wails3 的模式可以自研:ArkWeb 当 WebView、NAPI 加载 Go c-shared 库、Go 侧 ListenAndServe 加 go:embed serve 前端,业务和前端零改动。落地时最容易踩的坑不是签名、端口或 NAPI,而是 CMake 把构建机绝对路径写进了 libentry.so 的 ELF 依赖——readelf -d 一看就现形,CMAKE_SKIP_RPATH + IMPORTED_NO_SONAME 两个开关收工。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐