HarmonyOS 7 · ArkWeb 混合应用开发手记 01
做混合应用时,很多人第一次接 ArkWeb,代码大概都是这样:
Web({src:'https://example.com',controller:this.controller})页面能开,任务似乎就完成了。
但只要项目真的开始跑,你很快会碰到一串问题:首屏一直转圈,加载失败后白屏,返回页面又重新请求,H5明明已经打开了,原生侧还在显示 Loading;更麻烦的是,有些逻辑放在aboutToAppear里能跑,换到另一个页面就不稳定。
这时候问题通常不在"H5 能不能加载",而在于:我们根本没有把 Web
页的加载过程和 ArkUI 页面的生命周期分清楚。
这篇就从这里开始。
1. 先别急着封装,先搞清楚两套生命周期
一个 ArkWeb 页面里,其实同时存在两套节奏。
第一套是 ArkUI 页面自己的生命周期。比如组件什么时候出现、什么时候离开。
第二套是 Web 内容的加载生命周期。比如 Web控制器什么时候真正挂上组件、页面什么时候开始加载、什么时候结束、什么时候收到错误。
这两套东西有关联,但不是一回事。最容易踩的坑,就是把它们硬绑在一起。
比如:
aboutToAppear():void{this.loading=true}这段代码没错,但它只能说明 ArkUI 组件准备出现了,并不能说明某个 H5 URL此刻真的开始加载。
如果用户在当前 Web 组件里从 A 页面跳到 B 页面,aboutToAppear
不一定重新执行,但 Web 页面已经开始了新一轮加载。所以 Loading 状态如果只靠aboutToAppear控制,迟早会乱。
更稳的思路是:ArkUI 页面生命周期负责页面级资源和状态;Web
组件自己的加载回调负责 URL加载、进度、成功、失败和重试。职责拆开以后,很多"偶现问题"会直接消失。
2. WebviewController 不是装饰品
先从最小页面开始。
import{webview}from'@kit.ArkWeb'@Entry@Componentstruct WebPage{privatecontroller:webview.WebviewController=newwebview.WebviewController()build(){Column(){Web({src:'https://example.com',controller:this.controller}).width('100%').height('100%')}}}这里真正值得注意的不是src,而是controller。
WebviewController可以理解成原生侧握住 Web页面的"方向盘"。后面刷新、加载新地址,以及其他针对 Web页面的控制动作,都会围绕它展开。
所以我不建议在业务代码里到处临时创建 controller。一个 Web容器对应一个清晰的 controller,后续排查问题会轻松很多。
例如做一个最简单的刷新:
Button('重新加载').onClick(()=>{this.controller.refresh()})或者切换地址:
Button('打开帮助页').onClick(()=>{this.controller.loadUrl('https://example.com/help')})这比通过修改一堆外部状态"逼着 Web 重建"更直接。
但这里还有一个关键点:controller 创建出来,不等于它已经可以随便操作Web。
3.onControllerAttached:先确认方向盘真的装上了
ArkWeb 提供了onControllerAttached。它很适合处理那些"必须等 controller和 Web 组件建立关联以后再做"的事情。
Web({src:this.url,controller:this.controller}).onControllerAttached(()=>{console.info('[ArkWeb] controller attached')})我在项目里通常会把这个节点单独记日志。
原因很简单:如果以后碰到"controller明明有值,调用却不符合预期"的问题,你至少能先判断控制器有没有完成挂载,而不是对着URL 和网络抓瞎。
可以再加一个状态:
@StatecontrollerReady:boolean=false.onControllerAttached(()=>{this.controllerReady=true})然后对依赖 controller 的主动操作做保护:
privatereloadPage():void{if(!this.controllerReady){console.warn('[ArkWeb] controller is not ready')return}this.controller.refresh()}别嫌这个判断啰嗦。混合页面最烦的就是"十次有九次正常"。把时序条件写清楚,比靠运气稳定得多。
4.onPageBegin和onPageEnd才适合管 Loading
接下来处理最常见的加载状态。
@Stateloading:boolean=false@StatecurrentUrl:string=''Web({src:this.url,controller:this.controller}).onPageBegin((event)=>{this.loading=truethis.currentUrl=event?.url??''console.info(`[ArkWeb] begin:${this.currentUrl}`)}).onPageEnd((event)=>{this.loading=falseconsole.info(`[ArkWeb] end:${event?.url??''}`)})思路很直接:onPageBegin到了,说明一次页面加载开始了,onPageEnd到了,把 Loading 收掉。
然后 UI 层只负责展示状态:
Stack(){Web({src:this.url,controller:this.controller})if(this.loading){Column(){LoadingProgress().width(32).height(32)Text('页面加载中…').fontSize(14).margin({top:12})}.width('100%').height('100%').justifyContent(FlexAlign.Center)}}这样做最大的好处,是 Loading 跟着 Web的实际加载走,而不是跟着"我猜它应该开始加载了"走。
不过要提醒一句:onPageEnd适合当作页面加载阶段的重要结束信号,但业务上所谓的"页面完全可用"可能还有自己的条件。
比如 H5 首屏出来以后,还要异步拉接口、渲染列表。那就不能简单地把onPageEnd当成"业务页面全部准备完毕"。原生加载状态和 H5
业务状态,最好继续分开。
5. 别只处理成功,白屏往往是错误态没接住
很多 Demo 到onPageEnd就结束了,线上项目不能这么写。
网络断开、域名异常、超时、资源错误,都可能让用户看到一个很尴尬的页面。最差的处理方式就是:Loading一直转,或者直接留一块白色区域。
我们加一个错误状态。
@Stateloading:boolean=false@StateloadError:boolean=falseprivatestartLoading():void{this.loading=truethis.loadError=false}privatefinishLoading():void{this.loading=false}再接到 Web 上:
Web({src:this.url,controller:this.controller}).onPageBegin(()=>{this.startLoading()}).onPageEnd(()=>{this.finishLoading()}).onErrorReceive((event)=>{this.loading=falsethis.loadError=trueconstmessage=event.error.getErrorInfo()console.error(`[ArkWeb] load error:${message}`)})UI 上别搞太复杂,一个错误提示加重试按钮就够用:
if(this.loadError){Column({space:12}){Text('页面暂时打不开').fontSize(18)Text('检查网络后再试一次').fontSize(14)Button('重新加载').onClick(()=>{this.loadError=falsethis.controller.refresh()})}.width('100%').height('100%').justifyContent(FlexAlign.Center)}注意顺序。重试时先把错误层收掉,再调用refresh()。新的加载开始后,onPageBegin会再次接管Loading。这样状态流是闭环的,不需要在按钮里复制一堆加载逻辑。
6. 再加一个超时兜底,别让用户无限等
真实网络环境没那么讲武德。
有时请求没有马上给你一个漂亮的失败结果,但用户已经等了十几秒。技术上还在"加载",体验上其实已经"挂了"。
可以加一个简单的超时兜底。
privateloadTimer:number=-1privatestartLoadTimer():void{this.clearLoadTimer()this.loadTimer=setTimeout(()=>{this.loading=falsethis.loadError=trueconsole.error('[ArkWeb] load timeout')},15000)}privateclearLoadTimer():void{if(this.loadTimer!==-1){clearTimeout(this.loadTimer)this.loadTimer=-1}}然后把它放进加载节点:
.onPageBegin(()=>{this.loading=truethis.loadError=falsethis.startLoadTimer()}).onPageEnd(()=>{this.clearLoadTimer()this.loading=false}).onErrorReceive(()=>{this.clearLoadTimer()this.loading=falsethis.loadError=true})15秒不是标准答案。你的页面如果是公司内网、弱网业务或者首屏资源特别重,这个值要按真实数据调整。这里的重点不是"必须15 秒",而是一定要有退出机制。
否则 LoadingProgress 转得再丝滑,也只是一个精致的死循环。
7. 页面离开以后,定时器和临时状态要收
前面说了,ArkUI 生命周期和 Web 加载生命周期不要混在一起。但这不代表ArkUI 生命周期没用了。
组件离开时,正适合处理页面级资源。
aboutToDisappear():void{this.clearLoadTimer()}如果页面里还有事件监听、业务侧回调、临时资源,也应该在对应的生命周期里解除。
这里有个很实用的判断方法:它是"跟着一次 URL 加载"的,还是"跟着这个ArkUI 页面实例"的?
跟 URL 加载走的,放到 Web 加载回调附近。跟页面实例走的,放到 ArkUI生命周期附近。一旦按这个标准拆,代码会清楚很多。
8. 我更喜欢把加载状态收成一个小状态机
写到这里,你会发现几个布尔值很容易互相打架。
比如loading = true和loadError = true理论上可以同时出现,但 UI到底显示 Loading还是错误页?项目再复杂一点,还会有空页面、登录过期、离线页。布尔值越堆越多,组合就越离谱。
所以实际项目里,我更喜欢直接定义状态:
enumWebLoadState{Idle,Loading,Success,Error}@StatewebState:WebLoadState=WebLoadState.Idle生命周期里只切状态:
.onPageBegin(()=>{this.webState=WebLoadState.Loading}).onPageEnd(()=>{this.webState=WebLoadState.Success}).onErrorReceive(()=>{this.webState=WebLoadState.Error})UI 也跟着状态走:
if(this.webState===WebLoadState.Loading){LoadingProgress()}if(this.webState===WebLoadState.Error){Button('重新加载').onClick(()=>this.controller.refresh())}这就是一个很小的状态机。听起来像个"大词",其实一点都不玄学:一个时刻只允许页面处于一种明确状态。
9. 给日志加统一前缀,排错效率差很多
混合应用还有一个典型痛点:日志太杂。ArkTS 一份,H5一份,网络一份。真出问题的时候,控制台滚得像弹幕。
最简单的改进,是先统一前缀:
privatelog(stage:string,message:string=''):void{console.info(`[ArkWeb][${stage}]${message}`)}使用时:
.onControllerAttached(()=>this.log('ATTACHED')).onPageBegin((event)=>this.log('BEGIN',event?.url??'')).onPageEnd((event)=>this.log('END',event?.url??'')).onErrorReceive((event)=>{this.log('ERROR',event.error.getErrorInfo())})一次正常加载,你希望看到的顺序大致是:
[ArkWeb][ATTACHED] [ArkWeb][BEGIN] https://example.com [ArkWeb][END] https://example.com如果只有BEGIN没有后续,你就知道应该继续看网络、超时或页面加载过程。如果连ATTACHED都没看到,那排查方向就完全不同。
这也是为什么我前面一直强调:不要只看"页面有没有显示",要把关键节点留下来。
10. 最后整理成一个能继续扩展的版本
把前面的逻辑合起来,核心结构可以保持得很干净:
import{webview}from'@kit.ArkWeb'enumWebLoadState{Idle,Loading,Success,Error}@Entry@Componentstruct ArkWebPage{privatecontroller:webview.WebviewController=newwebview.WebviewController()@StatewebState:WebLoadState=WebLoadState.Idle@StatecontrollerReady:boolean=falseprivateurl:string='https://example.com'privateloadTimer:number=-1privateclearTimer():void{if(this.loadTimer!==-1){clearTimeout(this.loadTimer)this.loadTimer=-1}}privatestartTimer():void{this.clearTimer()this.loadTimer=setTimeout(()=>{this.webState=WebLoadState.Error},15000)}aboutToDisappear():void{this.clearTimer()}build(){Stack(){Web({src:this.url,controller:this.controller}).width('100%').height('100%').onControllerAttached(()=>{this.controllerReady=trueconsole.info('[ArkWeb][ATTACHED]')}).onPageBegin((event)=>{this.webState=WebLoadState.Loadingthis.startTimer()console.info(`[ArkWeb][BEGIN]${event?.url??''}`)}).onPageEnd((event)=>{this.clearTimer()this.webState=WebLoadState.Successconsole.info(`[ArkWeb][END]${event?.url??''}`)}).onErrorReceive((event)=>{this.clearTimer()this.webState=WebLoadState.Errorconsole.error(`[ArkWeb][ERROR]${event.error.getErrorInfo()}`)})if(this.webState===WebLoadState.Loading){LoadingProgress().width(36).height(36)}if(this.webState===WebLoadState.Error){Column({space:12}){Text('页面加载失败').fontSize(18)Button('重新加载').onClick(()=>{if(this.controllerReady){this.controller.refresh()}})}.width('100%').height('100%').justifyContent(FlexAlign.Center)}}.width('100%').height('100%')}}这个版本还不算"企业级封装",但已经把最关键的边界理顺了:controller有自己的就绪节点;Web 加载有自己的开始、结束和失败节点;ArkUI页面离开时负责清理页面级资源;UI 不再到处改loading,而是根据一个明确的加载状态渲染。
11. 几个特别容易写错的地方
第一,不要用aboutToAppear代替 Web 的加载开始。
它们描述的不是同一件事。页面实例出现,不等于每一次 URL
导航都从这里开始。
第二,不要看到 controller 已经new了,就默认 Web 已经可操作。
对时序敏感的操作,先关注 controller 与 Web 的挂载节点。
第三,不要只写成功路径。错误页、重试、超时兜底最好在第一版 Web容器里就放进去。等线上白屏以后再补,通常会补得很狼狈。
第四,不要让多个布尔值随便组合。
页面状态一多,尽早改成枚举状态。代码看起来多了两行,后面会少很多 if。
第五,不要把onPageEnd直接理解成"业务全部加载完成"。它描述的是Web 页面加载过程中的节点。你的 H5 如果还有接口请求和业务渲染,业务 Ready应该有自己的定义。
小结
这一篇没有讲 JSBridge,也没有讲缓存、预加载和性能优化。
因为这些东西都建立在一个前提上:你得先知道 Web 组件现在处于什么状态。
把WebviewController、onControllerAttached、onPageBegin、onPageEnd、错误处理和页面清理这几个点串起来,ArkWeb
就不再是一个"能打开网页就行"的黑盒,而是一个可以观察、可以控制、也可以排错的业务容器。
你可以给自己留一个小练习:把示例里的错误页再加一个"复制当前
URL"或者"返回上一页"的动作,同时把每次加载耗时打印出来。
下一篇再往前走:H5 和 ArkTS 到底怎么稳定通信,JSBridge
应该在哪个时机注册。