存量Hybrid项目做鸿蒙适配,最怕的就是把ArkWeb当成WebView来用。我最近刚在一个混合应用迁移项目里完整走了一遍HarmonyOS NEXT的ArkWeb接入和JSBridge落地,把H5那套页面几乎原封不动塞进了鸿蒙壳子。这篇文章是这个系列的上篇,重点拆解三个事:ArkWeb到底是什么、怎么把Web组件接入工程、以及怎么手写一套可靠的双向JSBridge。如果你手里正好有一个WebView为主的业务,正被鸿蒙化改造卡住,这篇应该能帮你省掉不少试错时间。
先说结论:ArkWeb是HarmonyOS的系统级Web组件,基本能力上确实和WebView是同类,但它的API设计、生命周期模型、调试链路完全是另一套体系。把它当平替用的团队,后面大概率会踩到路由返回失灵、JS注入失效、调试无从下手这些坑。这篇文章会把这些问题逐个讲透。
1. 迁移前先想清楚:ArkWeb不是又一个WebView
1.1 存量Hybrid工程做鸿蒙化的本质:换壳还是重写
很多团队接到鸿蒙化需求后的第一反应是"把WebView替换成ArkWeb就跑通了",这是最大的误解。Hybrid应用的鸿蒙化其实有三个层级,不同层级对应的工作量天差地别。
第一层是纯壳迁移。原生的Tab、导航、启动页用ArkUI重写,中间的业务页面继续走H5,H5和原生之间只有一个WebView壳和一套固定的JSBridge协议。这一层最接近"换壳",但前提是你的JSBridge协议够标准化,不依赖Android或iOS的私有能力。
第二层是能力补齐。壳子迁过去以后,你会发现很多原来WebView里顺手的操作在ArkWeb里不是默认就有的,比如文件上传、拍照、地理位置授权、视频全屏播放,这些都需要用ArkTS重新写一套原生能力通道,再通过JSBridge暴露给H5。这一层才是大多数项目真正的工作量所在。
第三层是体验对齐。包括预加载、骨架屏、缓存策略、白屏监控、性能埋点,这些在WebView时代你可能有现成的方案,但在ArkWeb上很多要重新适配。
所以,动手之前先盘一下自己的JSBridge协议干不干净、H5页面依赖了多少原生私有能力、原生壳本身的复杂度有多高。我的建议是:把鸿蒙化当作一次在保持H5资产不变的前提下重写原生壳的机会,而不是一次简单的SDK替换。
1.2 ArkWeb和WebView的五个关键差异
这里不列官方文档的参数表,只讲迁移时最容易踩的五个差异点:
第一个是线程模型。Android WebView的很多操作可以放子线程,ArkWeb的WebviewController绝大部分接口必须跑在UI主线程上,启动阶段尤其明显。刚开始写代码时很容易习惯性地在异步回调里操作controller,直接报错。后面会专门讲这个问题。
第二个是生命周期归属。WebView跟着Activity走,ArkWeb的Web组件跟着Page/Component走。这意味着页面的onPageShow、onPageHide、aboutToDisappear这些ArkUI生命周期,你要手动和Web内容的状态做同步。如果你的H5页面依赖visibilitychange事件,那就要特别注意。
第三个是JS注入时机的坑。Android的addJavascriptInterface在页面加载后就能用,ArkWeb用registerJavaScriptProxy注册桥对象时,如果页面还没加载完成,调用时机不对就会静默失效。API 12之后提供了WebOptions里的javaScriptProxy注入,这个方式更稳,我后面会细说。
第四个是调试工具链完全不同。Android有chrome://inspect,iOS有Safari开发者工具。ArkWeb需要先打开webDebuggingAccess开关,再用DevEco Studio里的DevTools能力调试H5页面。很多人第一步就卡在这,以为Web组件直接就能被开发者工具抓到。
第五个是返回键和路由栈。WebView时代你习惯了onBackPressed去判断canGoBack,ArkWeb里要同时管理Web的历史栈和ArkUI的页面路由,处理不当会出现返回键直接退出应用而不是网页后退。
1.3 技术选型:自研壳还是套现成容器
现在社区里有一些鸿蒙Hybrid容器方案,比如跨端框架的鸿蒙适配产物。要不要直接套,取决于你的现状。
如果存量H5业务量很大,且JSBridge协议已经沉淀了三四年,我建议壳子自研。原因是桥的语义是私有资产,第三方容器不可能完全理解你现有的协议,最终你还是要做一层协议转换,那不如直接自己控制Web组件。
如果业务是从零启动,H5有几个页面但没沉淀协议,那可以考虑套现成容器,主要图省事。但你要接受一个问题:容器升级节奏被上游锁死,遇到系统适配bug时只能等版本。
我现在的项目选的是自研壳。核心思路是:Web组件只负责渲染和交互,所有原生能力通过统一Bridge层暴露,Bridge内部再做能力分发和参数序列化。这套结构下,就算以后换Web引擎,H5侧感知不到变化。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 接入ArkWeb的第一步:工程配置和首屏加载
2.1 环境与工程准备:DevEco Studio、API版本、权限声明
先交代一下基础环境。我用的是DevEco Studio 5.x,目标API建议直接上API 12及以上。API 11就开始有ArkWeb,但很多能力在API 12才补齐,比如更稳的JS注入方式、web组件调试增强,老项目真没必要守着API 11。
新建模块的时候选Empty Ability就行,但注意module.json5里的权限声明。访问网络页面必须加INTERNET权限:
json复制{
"module": {
"requestPermissions": [
{
"name": "ohos.permission.INTERNET"
}
],
"abilities": [
{
"name": "EntryAbility",
"srcEntry": "./ets/entryability/EntryAbility.ets",
"exported": true,
"skills": [
{
"entities": ["entity.system.home"],
"actions": ["action.system.home"]
}
]
}
]
}
}
这里有个容易被忽略的细节:如果你的H5页面还需要访问定位、相机、相册,权限要提前在module.json5里声明,但真正弹窗授权还是由Native侧去触发,Web组件内部不会自动帮你弹系统授权框。也就是说,H5里调getUserMedia这类接口,最终还是要桥接到原生去走权限流程。
2.2 Web组件的最小可用示例:Controller、src、事件回调
ArkWeb的Web组件用法和Android WebView差别很大。最小示例贴一下:
typescript复制import { webview } from '@kit.ArkWeb';
@Entry
@Component
struct WebPage {
private controller: webview.WebviewController = new webview.WebviewController();
build() {
Column() {
Web({ src: 'https://www.example.com', controller: this.controller })
.width('100%')
.height('100%')
.javaScriptAccess(true)
.domStorageAccess(true)
.fileAccess(true)
.onPageBegin((event) => {
console.info('Web onPageBegin', JSON.stringify(event));
})
.onPageEnd((event) => {
console.info('Web onPageEnd', JSON.stringify(event));
})
.onErrorReceive((event) => {
console.error('Web onErrorReceive', JSON.stringify(event));
})
}
}
}
几个要点:Web组件创建时必须传入WebviewController实例,后面所有controller操作都是基于这个实例;src参数可以直接是https链接,也可以加载本地rawfile里的页面,比如$rawfile('index.html');如果你不确定加载本地资源的配置,这里最容易忘的是fileAccess,默认false,不改的话本地H5可能根本打不开。
加载本地页面的时候建议这样:
typescript复制Web({ src: $rawfile('dist/index.html'), controller: this.controller })
这种方式的资源打包进应用,天然离线可用,适合放壳工程首页或者内置兜底页。但要注意rawfile目录下不要放太大体积的资源,会影响包的体积和构建速度,一般只放必须的骨架资源,其余走在线加载。
2.3 关键能力项的开关矩阵
ArkWeb的Web组件有很多能力开关,分散在Builder参数和链式调用里。我整理了一份实际项目里最常用的开关表:
| 配置项 | 作用 | 建议 |
|---|---|---|
| javaScriptAccess(true/false) | 是否允许执行JavaScript | 业务页面必须开,纯展示页可关 |
| domStorageAccess(true/false) | 是否启用DOM Storage | 依赖localStorage的页面必须开 |
| fileAccess(true/false) | 是否允许加载本地文件 | 按需开启,不建议全局打开 |
| onlineImageAccess(true/false) | 是否允许加载在线图片 | 默认受控,建议按需开 |
| mixedMode(MixedMode.All) | https页面内是否加载http资源 | 默认阻塞,开发环境可放开,生产必关 |
| textZoom(100) | 页面文字缩放比例 | 用于统一不同设备上的字号表现 |
| overScrollMode(OverScrollMode.NEVER) | 边缘回弹模式 | 建议NEVER,减少跟手感和原生页的割裂 |
| webDebuggingAccess(true/false) | 是否允许Web调试 | 仅Debug包开启,Release必须关 |
| javaScriptProxy({...}) | 页面加载前注入Native桥对象 | API 12推荐方式 |
实际项目里,domStorageAccess和javaScriptAccess基本是固定开的。mixedMode要注意:如果你的H5页面仍有少量http资源没改造完,可以考虑在开发阶段开MixedMode.All,但发布前一定要收敛到AllBlock,否则App Store审核和用户数据安全都会有问题。这个不是ArkWeb特有的,但ArkWeb的默认策略比Android WebView严,Android上很多http资源没暴露出来,搬过来就白屏了。
2.4 首屏加载优化:预连接、缓存策略与本地资源
H5在鸿蒙壳里最容易出现的问题是首屏白屏时间比Android长,如果不做干预。我用的优化手段有三个。
第一个是预热Web实例。在用户进入Web页面之前,提前创建一个隐藏的ArkWeb容器,让Web组件提前初始化并加载页面,等用户真正点击时把预建的Web组件挂载到当前页面。ArkWeb组件本身支持这种容器级复用,但要注意持有多个WebviewController时的内存开销。Android上大家习惯"预创建WebView到容器复用",ArkWeb也可以这么做,但官方更推荐用preconnect网络预连接来辅助。
第二个是网络预连接。用webview.WebCookieManager持久的Cookie之后再配合系统网络能力,提前把静态资源域名解析好。ArkWeb没有直接暴露类似WebView的setNetworkAvailable之外的预连接API,但可以通过Web组件的onServerTrustChange时机来"摸"一下服务器,或者利用浏览器内核自身的preconnect hint。如果你的H5在HTML里就加了<link rel="preconnect" href="...">,内核会自己优化一部分。
第三个是把首屏静态资源打进本地包。我是把入口HTML、首屏JS/CSS、关键图片通过rawfile方式内置,在线资源只做增量加载。这样首屏完全不依赖网络DNS解析和建连,实测首屏白屏时间能压缩到原来的40%左右。不过本地资源版本更新要自己设计个版本号机制,不然每次发版都要等应用商店审核,就很被动。
3. 手写一套JSBridge:从协议设计到注入落地
JSBridge是整个Hybrid壳的心脏。这里我直接分享一套已经跑了几十条线上业务线的方案,你可以直接抄。
3.1 为什么这里不建议直接上第三方桥
业界确实有一些通用JSBridge实现。但我的建议是:Hybrid架构里,桥的协议是业务资产,不是通用组件。第三方桥解决的是"怎么传",但解决不了"传什么语义",最终还得包一层业务协议。
而且ArkWeb的注入能力和WebView不完全一致,第三方库可能适配得不彻底。比如某些库依赖window.webkit.messageHandlers这类WebKit概念,在ArkWeb里根本没有。与其花时间适配第三方桥的怪癖,不如直接基于ArkWeb官方能力写一个轻量桥,核心代码也就一百多行。
3.2 注册Native方法到Web侧:两种注入方式的取舍
API 12开始,ArkWeb提供两种注入方式。第一种是WebOptions里的javaScriptProxy,在Web组件初始化时就完成注入,注入时机最早,页面一加载就能拿到桥对象。第二种是registerJavaScriptProxy,通过controller在页面加载后动态注册,注册完通常要调用refresh()才能让注入的代理对象生效,容易踩时机的坑。
推荐优先用WebOptions里的方式,示例:
typescript复制import { webview } from '@kit.ArkWeb';
class BridgeObject {
private bridgeImpl: BridgeImpl;
constructor(bridgeImpl: BridgeImpl) {
this.bridgeImpl = bridgeImpl;
}
postMessage(message: string): string {
return this.bridgeImpl.handleMessage(message);
}
}
@Entry
@Component
struct WebPage {
private controller: webview.WebviewController = new webview.WebviewController();
private bridgeImpl: BridgeImpl = new BridgeImpl();
build() {
Column() {
Web({
src: 'https://www.example.com',
controller: this.controller,
javaScriptProxy: {
object: new BridgeObject(this.bridgeImpl),
name: 'harmonyBridge',
methodList: ['postMessage'],
async: true
}
})
.javaScriptAccess(true)
.domStorageAccess(true)
.width('100%')
.height('100%')
}
}
}
注意methodList里列出的方法必须是object上真实存在的方法,H5侧通过window.harmonyBridge.postMessage(...)调用。async标记为true时,Web侧会异步调用Native方法,避免阻塞Web侧渲染线程。
3.3 统一调用协议与回调ID管理:H5调用Native
桥对象的方法只做一件事:接收字符串消息,解析协议,分发处理。协议格式我是这样设计的:
json复制{
"callId": "8f3a...",
"action": "device.getInfo",
"params": {
"key1": "value1"
},
"timeout": 5000
}
H5侧暴露的调用方式:
typescript复制// H5侧封装代码
function callNative(action: string, params: any, timeout: number = 5000): Promise<any> {
return new Promise((resolve, reject) => {
const callId = generateUniqueId();
pendingCallbacks.set(callId, { resolve, reject });
const message = JSON.stringify({
callId,
action,
params
});
if (window.harmonyBridge && typeof window.harmonyBridge.postMessage === 'function') {
window.harmonyBridge.postMessage(message);
} else {
reject({ code: -1, message: 'bridge not ready' });
}
if (timeout > 0) {
setTimeout(() => {
if (pendingCallbacks.has(callId)) {
pendingCallbacks.delete(callId);
reject({ code: -2, message: 'call timeout' });
}
}, timeout);
}
});
}
这里有几个关键设计:callId必须全局唯一,我用的是随机数加时间戳,避免高并发下碰撞;每个callId在回调未返回前保存在一个Map里,超时后统一清理,防止内存泄漏;action命名采用"模块.方法"的万国码形式,比如"device.getInfo"、"network.request"、"storage.setItem",后面Native侧做路由分发时很方便。
Native侧BridgeImpl的handleMessage主要做协议解析、路由分发和回调回收:
typescript复制class BridgeImpl {
private handlerMap: Map<string, (params: any, callId: string) => void> = new Map();
handleMessage(message: string): void {
try {
const req = JSON.parse(message);
const handler = this.handlerMap.get(req.action);
if (handler) {
handler(req.params, req.callId);
} else {
console.warn(`action not found: ${req.action}`);
}
} catch (e) {
console.error('handleMessage error', e);
}
}
handleCallResult(callId: string, result: any): void {
this.controller.runJavaScript(
`window.__resolveHarmonyBridge(${JSON.stringify({ callId, result })})`,
(error, data) => {}
);
}
}
Native处理完业务后,通过runJavaScript执行H5侧的回调函数。H5侧需要预先挂一个全局函数window.__resolveHarmonyBridge来接收Native的返回值,再根据callId找到Promise的resolve或reject。
3.4 Native主动调用H5:runJavaScript的返回值处理
Native到H5方向,核心API就是runJavaScript。我用它把原生事件(比如App切后台、网络变化、登录态失效)推给H5。
typescript复制this.controller.runJavaScript('window.onNativeEvent
