HarmonyOS 上做混合应用开发,绕不开 ArkWeb。最近我们把自己维护的一套个人记账 Web 应用往鸿蒙端迁移,原以为只是套个 Web 容器,结果发现真正的工作量全在原生与 H5 之间的 JSBridge 上。折腾了两周,桥通了,坑也踩了不少。这篇先聊整体思路、ArkWeb 接入方式,以及桥接层的设计与实现,适合正在做鸿蒙化适配、或者准备用 ArkWeb 承接现有 H5 业务的团队参考。内容偏实战,以记账系统作为贯穿案例,代码可以直接抄改。
1. 整体设计与拆解:Hybrid 应用为什么值得鸿蒙化
1.1 记账系统背景与迁移动机
先说我们为什么要做这件事。手里这套个人记账产品,前端是 H5,后端用的是 AGC(AppGallery Connect)提供的认证服务、云数据库和云存储。业务模块很典型:用户认证、账单记录、分类管理、统计分析、预算管理。它不是一个特别重的应用,但胜在迭代快,运营同学隔三差五想改 UI 和加报表,纯原生开发根本扛不住这个节奏。
鸿蒙系统用户量上来以后,我们肯定不能假装看不见。直接放弃 H5 重新写一套 ArkUI 原生界面不现实,团队成员都是前端出身,ArkTS 熟练度也没那么高。更合理的选择是保留 H5 核心业务,用 HarmonyOS 的 ArkWeb 组件做宿主,再通过 JSBridge 把系统能力开放给 Web 侧。这就是典型的 Hybrid 架构迁移,也是“鸿蒙化”最平滑的路径。
迁移过程中我们定了一个原则:能用 H5 解决的业务逻辑,一律留在 H5;必须碰系统能力的,才通过 JSBridge 调原生。比如登录认证我们让原生去调 AGC Auth Service,H5 只负责接收 token;扫码记一笔也让原生唤起相机,H5 拿到条码自动填表。这样 H5 开发和原生开发各管一摊,边界清晰,后面迭代才不吵架。
1.2 ArkWeb 与老 WebView 的核心差异
很多从 Android 转过来的同学会习惯性把 ArkWeb 理解成 WebView 的换皮。实际用下来差别很大。HarmonyOS 早期的 WebView 组件和 Android WebView 体验很接近,但 ArkWeb 是专门为鸿蒙重新设计的 Web 引擎,底层用的是 Chromium 内核,在渲染稳定性、JavaScript 执行效率、多 Web 实例隔离上都更扎实。
我整理了一份对比,方便你做选型判断:
| 维度 | 老 WebView | ArkWeb |
|---|---|---|
| 内核 | 系统自带 WebView | Chromium 内核,跟随鸿蒙系统更新 |
| JS 桥能力 | 依赖注入对象,方式有限 | registerJavaScriptProxy / javaScriptProxy,异步调用更顺手 |
| 多实例 | 支持但资源占用高 | 多 Web 实例隔离更干净,内存复用更好 |
| 调试 | 不方便 | 支持 Web 调试,配合 DevEco 调试更顺 |
| 混合内容 | 限制严格 | 可通过 mixedMode 灵活控制 |
| API 演进 | 基本稳定 | ArkWeb API 迭代快,新特性多 |
如果你是全新项目,没有历史包袱,我建议直接上 ArkWeb,不要纠结老组件兼容。老 WebView 在鸿蒙上属于过渡方案,后续维护价值只会越来越低。
1.3 鸿蒙 Hybrid 应用的分层与通信模型
我们落地后的架构分四层:
- UI 层:用 ArkUI 搭原生壳,包括启动页、主导航框架,以及 Web 组件所在页面。
- 容器层:ArkWeb 组件负责加载 H5 资源,管理页面生命周期和渲染。
- H5 应用层:原有记账前端项目,负责业务交互、图表展示、表单提交。
- 原生能力层:封装 AGC 服务、相机扫码、本地通知、生物认证等能力,通过 JSBridge 暴露给 H5。
通信模型其实就两条路:原生往 Web 发消息,用 runJavaScript;Web 往原生发请求,用注入的 JavaScript 代理对象。我们在此基础上封装了一层消息协议,统一请求和回调,后面接新能力就是加一个 action 的事,H5 端不用每次写重复桥接代码。
这一层设计是整个迁移是否顺利的关键。如果一上来就往 Web 组件里塞各种调用,后面业务一多,桥接代码会乱成一锅粥。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. ArkWeb 基础配置与页面加载实操
2.1 在 DevEco Studio 里跑通第一个 ArkWeb 页面
先在 DevEco Studio 里创建一个空 Ability 工程,然后把本地 H5 构建产物放到 entry/src/main/resources/rawfile 目录下。比如我的记账前端构建后生成 dist 目录,就把 dist 整个拷贝成 rawfile/dist。接着写一个最基础的 Web 页面:
typescript复制import { webview } from '@kit.ArkWeb';
@Entry
@Component
struct WebPage {
controller: webview.WebviewController = new webview.WebviewController();
build() {
Column() {
Web({ src: $rawfile('dist/index.html'), controller: this.controller })
.javaScriptAccess(true)
.domStorageAccess(true)
.fileAccess(true)
.mixedMode(MixedMode.All)
}
.width('100%')
.height('100%')
}
}
这段代码包含几个关键点。$rawfile('dist/index.html') 是本地资源路径写法,编译时会自动处理资源引用。.javaScriptAccess(true) 必须开,否则 H5 里的脚本全部不执行。.domStorageAccess(true) 是让 localStorage 和 sessionStorage 可用,记账应用一般都要存登录态和用户偏好。.mixedMode(MixedMode.All) 是允许混合内容加载,如果你的 H5 页面里还有 HTTP 资源,这个不配就会白屏。
2.2 混合内容与安全配置要提前想清楚
混合内容这个问题,很多人是上了真机才发现。记账系统的 H5 本体放在本地 rawfile,但它会请求 AGC 后端的 HTTPS 接口,这本身没问题。问题出在我们有些遗留页面上图片走了 HTTP 链接,ArkWeb 默认阻止混合内容,于是图片裂开、接口偶发失败。
我建议你在开发阶段就把 MixedMode 配置好,同时管住自己的资源协议。MixedMode.All 适合测试环境,生产环境建议收敛为 MixedMode.Compatible 或干脆只放行 HTTPS,否则存在被中间人篡改页面内容的风险。另外,如果 H5 里需要访问本地文件,记得把 fileAccess 打开,但要注意控制目录范围,别把整个沙箱都暴露给 Web 层。
权限配置也很容易漏。HarmonyOS 工程需要在 module.json5 里声明网络权限:
json复制{
"requestPermissions": [
{ "name": "ohos.permission.INTERNET" }
]
}
不配 INTERNET 权限,本地页面能加载,但远程请求一个都发不出去,而且报错很隐晦,经常是表现为页面白屏或者数据刷不出来。这个坑我印象很深。
2.3 页面加载生命周期与桥接时机
ArkWeb 页面加载回调是调 JSBridge 的核心时间窗口。我们最常用的是四个:
onControllerAttached:WebviewController 与 Web 组件绑定成功,可以开始调 controller 方法。onPageBegin:页面开始加载,此时 DOM 还没准备好,不宜注入脚本。onPageEnd:页面加载完成,H5 的 window 对象已经可用,此时调用runJavaScript最稳。onErrorReceive:加载失败回调,用来切错误提示页。
我强烈建议把“桥接注册”和“首屏初始化”分开处理。比如在 onPageEnd 里先执行一段初始化脚本,告诉 H5 原生环境已就绪,同时把登录态 token 传给前端。前端收到事件后再主动拉用户信息,而不是让原生强塞。这样可以避免页面组件还未绑定监听器时数据就到了的竞态问题。
3. JSBridge 原理与实现
3.1 原生和 Web 之间的通信模型拆解
JSBridge 说白了就解决一个问题:JavaScript 运行在 Web 世界里,原生代码运行在鸿蒙世界里,两边默认老死不相往来。H5 想用相机、想拿系统 token,必须通过一个双方都共识的“桥”。
历史上出现过几种通信方式:拦截 URL Scheme、重写 prompt()、注入 JavaScript API。前两种偏 hack,所有消息都要走 URL 或者弹窗,效率低而且容易被系统拦截。现在主流做法是在页面里注入一个原生对象,H5 直接 window.nativeBridge.xxx() 调原生方法。ArkWeb 对这种方式支持很友好,稳定性和性能都比老方案强。
通信方向要理清楚:
- 原生 -> Web:原生调用
runJavaScript,可以执行任意 JS 表达式或函数。 - Web -> 原生:通过
javaScriptProxy或registerJavaScriptProxy注入对象,H5 调用该对象上的方法,触发原生逻辑。
两方向配合,才能实现完整调用链。
3.2 原生侧调用 Web:runJavaScript 的正确姿势
runJavaScript 最简单的用法是执行一个字符串脚本。比如原生收到用户点击预算超支通知后,想让 H5 跳到统计页,可以这么写:
typescript复制this.controller.runJavaScript(
`window.location.hash = '#/stats?from=push'`,
(error, result) => {
if (error) {
console.error('runJavaScript failed: ' + error);
}
}
);
注意几点。第一是注入脚本时机,必须在 onPageEnd 之后执行,否则目标页面函数还不存在,脚本静默失败。第二是如果脚本里有对象参数,建议 JSON.stringify 后再拼接,避免字符串转义问题。第三是回调里拿到的 result 只能是可序列化的内容,函数和 DOM 节点传不回来。
我们后来把原生调 Web 的代码收敛到一个方法里:
typescript复制function callJs(action: string, payload: Record<string, Object>): void {
const script = `window.bridgeDispatcher && window.bridgeDispatcher('${action}', ${JSON.stringify(payload)})`;
this.controller.runJavaScript(script, (error) => {
if (error) {
console.error(`callJs ${action} error: ${error}`);
}
});
}
这样 H5 只需要在 window 上挂一个 bridgeDispatcher 分发函数,所有原生下发消息都走同一条路,前端代码不用散落各处监听。
3.3 Web 侧调用原生:注入 JavaScript 代理对象
H5 调原生最直接的方式是用 ArkWeb 的 javaScriptProxy。我们在 Web 组件初始化时注入一个桥对象:
typescript复制@Entry
@Component
struct WebPage {
controller = new webview.WebviewController();
private bridgeObject: BridgeObject = new BridgeObject(this);
build() {
Column() {
Web({ src: $rawfile('dist/index.html'), controller: this.controller })
.javaScriptAccess(true)
.domStorageAccess(true)
.javaScriptProxy({
object: this.bridgeObject,
name: 'nativeBridge',
methodList: ['invoke']
})
.onPageEnd(() => {
// H5 环境就绪
})
}
.width('100%')
.height('100%')
}
}
对应原生侧需要一个 BridgeObject 类,它必须有一个 invoke 方法,参数我们统一传字符串和回调 ID:
typescript复制export class BridgeObject {
private page: WebPage;
constructor(page: WebPage) {
this.page = page;
}
invoke(action: string, payload: string, callbackId: number): void {
// 根据 action 分发到具体业务逻辑
// 处理完后回调 page.callJs(`window.bridgeCallback(${callbackId}, ${JSON.stringify(result)})`)
}
}
H5 侧封装成一个 Promise 形式:
javascript复制const callNative = (action, payload) => new Promise((resolve, reject) => {
const msgId = ++window.__bridgeMsgId;
window.__bridgeCallbacks = window.__bridgeCallbacks || {};
window.__bridgeCallbacks[msgId] = { resolve, reject };
window.nativeBridge.invoke(action, JSON.stringify(payload), msgId);
});
window.bridgeCallback = (msgId, err, result) => {
const cb = window.__bridgeCallbacks[msgId];
if (!cb) return;
delete window.__bridgeCallbacks[msgId];
if (err) cb.reject(new Error(err));
else cb.resolve(result);
};
这样 H5 调用原生能力就是一句 callNative('login', { provider: 'huawei' }),体验和调用本地 Promise 一样,业务代码非常清爽。
3.4 消息协议设计:不要写死每个方法
很多团队做 JSBridge 会直接在注入对象上写一堆业务方法:login()、scan()、getToken(),一个个暴露。短期看没问题,业务一多就全是重复代码,而且没法做统一拦截。
我们采用协议化设计:注入对象只暴露一个 invoke(action, payload, callbackId) 方法,action 是能力名称,payload 是参数,原生侧用统一分发器路由到对应 handler。这样做的好处有几点:
- H5 不需要关心原生方法名,只要约定 action 字符串。
- 原生拦截方便,登录态失效、参数校验、埋点上报都可以在分发层集中处理。
- 新增能力不需要改注入配置,只需要增加一个 handler 映射。
- 回调统一,所有异步结果都走
bridgeCallback,H5 侧 Promise 封装一次搞定。
这套设计在记账系统里支撑了十几个 action,后面又加了导出账单到文件、扫码记账、发送通知等新能力,几乎没有动过 JSBridge 基础层。
4. 实战:个人记账系统的鸿蒙化与桥接
4.1 记账系统的模块边界
回到我们开头说的记账系统。它包含用户认证、账单记录、分类管理、统计分析、预算管理五个核心模块,后端用 AGC。鸿蒙化之后,业务模块的归属是这样的:
| 模块 | 载体 | 说明 |
|---|---|---|
| 用户认证 | 原生 + AGC Auth Service | 鸿蒙端登录,token 通过桥传给 H5 |
| 账单记录 | H5 + 原生扫码 | H5 展示表单,扫码调原生相机 |
| 分类管理 | H5 | 纯前端交互,直接调后端接口 |
| 统计分析 | H5 | ECharts 渲染,数据来自 AGC 云数据库 |
| 预算管理 | H5 + 原生通知 | H5 设置预算,原生负责超支本地通知 |
这个表是我们团队做需求拆分时的核心依据。凡是 H5 能做的,不碰原生;凡是涉及系统能力的,一律走桥,并且把能力做成通用动作,不给特定页面写死。
4.2 本地 H5 资源接入与 AGC 后端联调
H5 前端工程构建后,产出通常是 dist 目录。我们把目录直接拷到 rawfile,Web 组件通过 $rawfile('dist/index.html') 加载本地包。这里有个小技巧:你可以在构建脚本里加一步自动拷贝,把 H5 产物同步到 HarmonyOS 工程目录,解放双手,也避免每次都手动复制漏文件。
联调 AGC 后端时,H5 的请求地址要区分环境。开发环境可以直接指向 AGC 的测试环境地址,但要注意 Web 组件默认跨域策略。如果后端接口没开 CORS,H5 从 rawfile 本地包发请求会报跨域错误,这个问题可以通过把接口域名加入后台白名单解决,或者让原生通过桥代理请求。
我们最终拆成了两层请求:简单查询走 H5 直接请求 AGC REST API;需要带用户敏感信息的接口,走原生 HTTP 客户端,把结果再桥回 H5。这样既安全又绕开了跨域限制。
4.3 典型桥接场景一:用户认证
用户认证是记账系统最关键的桥接场景。我们让原生调用 AGC Auth Service 完成华为账号登录,流程如下:
- H5 点击“华为账号登录”,调用
callNative('login', { provider: 'huawei' })。 - 原生收到 action,拉起 AGC 登录授权页。
- 登录成功后,原生获取到 AGC 返回的 token 和用户信息。
- 原生通过
runJavaScript调用window.bridgeCallback(callbackId, null, JSON.stringify({ token, userInfo }))。 - H5 Promise resolve,把 token 存入本地,后续请求都带这个 token。
这个流程的关键是 token 永远不落入 H5 侧存储。H5 拿到 token 只是临时使用,就算页面被注入恶意脚本,也拿不到长期凭证。原生的 credential 保存在系统安全区域,安全性高很多。
4.4 典型桥接场景二:扫码记账与预算通知
还有一个很实用的场景是扫码记账。用户拍商品条码,系统自动带出商品名和默认分类。H5 端:
javascript复制const result = await callNative('scanBarcode', {});
// result = { code: '6901234567890', type: 'CODE_128' }
原生负责调起相机扫码,解码后把条码内容返回给 H5。H5 再根据条码调后端商品库解析商品信息。整个过程用户感知就是“点一下扫描,表单自动填好”,体验接近原生。
预算通知场景则相反,是原生主动找 H5。当用户在某笔账单超支后,原生后台检测到预算余额不足,会弹一个本地通知。用户点击通知时,原生化身打开 H5 页面并传入预算统计路由,让 H5 直接展示超支分析图。这个动作就是之前说的 callJs('navigateTo', { path: '/stats?from=budget' }),实现简单,但价值很明显,用户不再需要自己翻菜单找统计页。
4.5 DevEco Studio 真机调试与 H5 调试
鸿蒙 H5 调试比很多人想象中方便。在 Web 组件上打开调试开关:
typescript复制Web({ src: $rawfile('dist/index.html'), controller: this.controller })
.setWebDebuggingAccess(true)
真机连接 DevEco Studio 运行后,可以通过 Chrome DevTools 的远程调试能力直接调试 H5 页面。断点、Console、Network、Elements 都可以用。需要注意的是不同 HarmonyOS 版本调试方式可能会有调整,调试开关一定要在正式包中关闭。
我们实际联调时最喜欢的方式是“三分屏”:DevEco 看 ArkTS 日志,DevTools 看 H5 日志,模拟器看界面。两边日志可以约定统一前缀,比如原生侧打 [NATIVE],H5 侧打 [WEB],这样过滤起来非常高效。
5. 常见问题与排查技巧实录
5.1 白屏:先查权限,再查混合内容
白屏是 ArkWeb 接入最常见的问题,没有之一。按我的经验,排查顺序应该是:
- 网络权限:
ohos.permission.INTERNET没配,远程资源全部加载失败,只剩白屏。 - mixedMode 配置:页面里有 HTTP 资源,被默认策略拦截,表现为部分区域空白或图片裂开。
- rawfile 路径大小写:
$rawfile('dist/Index.html')和实际文件名大小写不一致,会加载失败。 - domStorageAccess:H5 启动时读写 localStorage 报错,阻塞后续渲染。
我见过有同学把前三个全踩一遍,最后还疑惑代码为什么没问题。建议先写一个最简单的静态页,逐步加功能,定位问题会快很多。
5.2 JSBridge 不生效:查注册时机和方法名
桥接不生效的原因有很多,最常见的是注入对象没注册上。ArkWeb 的代理对象注入时机要和页面加载顺序匹配,如果页面先加载完,又没通过其它方式补注册,H5 调用 window.nativeBridge 就会报 undefined。
排查要点:
- 确认
javaScriptAccess(true)已开启,没开这个 H5 脚本都不执行,更别提桥对象。 - 确认注入对象方法名和
methodList一致,ArkWeb 只能调用白名单里的方法。 - 确认页面 URL 有没有走跨域,跨域环境下注入对象不一定挂载到当前 window 上。
- 生产环境前端代码混淆时,不要把桥对象的调用函数改名,否则会失联。
我们后来做了一个桥接自检:H5 启动后先调 callNative('ping'),原生返回 pong 才算桥就绪。如果 3 秒没收到 pong,页面提示“初始化中,请稍后”,而不是让用户在一片空白里干等。
5.3 回调丢失与页面跳转
用 Promise 封装后,回调丢失是另一个高频问题。最常见的场景是 H5 调用原生能力后,用户立刻跳转了页面,此时页面上下文已经销毁,bridgeCallback 不存在了,原生侧 runJavaScript 执行静默失败。
解决办法有两个方向:
- H5 侧监听页面隐藏事件,在页面跳转前把未完成的 Promise 标记为废弃,避免悬空。
- 原生侧每次执行回调前检查页面状态,WebController 是否仍关联有效页面,无效就直接丢弃。
从设计上,我建议业务代码不要把关键状态寄托在桥回调上。比如支付类、登录类结果,如果页面已经被销毁,就通过原生侧逻辑把结果持久化,等下次进入页面再主动拉取。
5.4 性能与内存:别让 Web 组件成为常驻僵尸
ArkWeb 组件本身挺吃内存。如果应用里同时挂多个 Web 组件,或者页面销毁时没有释放 Web 组件,内存会直线上升。记账系统不复杂,但我们也遇到过统计页大图表把 Web 进程推到高位的情况。
经验是:
- 页面退出时调用
controller.clearHistory()和controller.clearCache(),有需要时手动销毁 Web 实例。 - 避免在 H5 和原生之间频繁
runJavaScript传大对象,拉取列表数据尽量走 HTTP,别走桥。 - 如果只是局部 UI 更新,把它写在 H5 内部,不要每次刷新都通知原生重建页面。
还有一点,Web 组件会创建独立渲染进程,Web 页面数量多的时候,进程数会明显增加。要合理控制在应用里能同时打开的 Web 页面数量,能复用就复用,别每个 Tab 都开一个独立 ArkWeb 页面来回切换。
最后说点个人体会
这次鸿蒙化迁移,最大的收获不是把代码跑通了,而是理解了 JSBridge 的本质:它不只是技术工具,更是原生团队和前端团队之间的协作契约。协议定得越早、越稳定,两边开发效率越高。我们前期花了大半天讨论 action、消息格式、错误码,后面两周基本没有返工。
另外一个体会是,HarmonyOS 的 ArkWeb 能力还在快速演进,很多 API 在不同版本上有差异。做方案设计时不要只看当前版本,多关注官方更新日志,基础桥接库尽量保持简单,不要依赖版本独有的特性,否则系统升级后你还要跟着改。上篇先聊到这,后面如果大家感兴趣,我再把记账系统的 AGC 后端对接、离线包更新和性能监控单独拆出来写,那些坑也不少。
