HarmonyOS 7 ArkWeb:WebGL上下文丢失后纹理重建与状态回放
一、页面回来了,博物馆场景却只剩一块灰布
WebSceneRecovery 是一个用 ArkWeb 承载 Three.js 的展陈预览页。模型 museum_lobby.glb 有 9 个材质、14 张纹理,用户可以旋转相机并选中展台。正常进入只需 436 ms,问题出现在应用切到后台再回来:ArkWeb 页面仍在,工具栏也能点击,Canvas 却只显示灰色背景。更糟的一次是场景恢复了,但转动相机明显加速,HiLog 里同一帧出现两次 render。
这不是“重新加载网页”能稳定解决的问题。WebGL 上下文丢失后,旧纹理、buffer 和 program 已失效;页面前后台切换又可能重复启动动画循环。如果 ArkTS 只看到 WebView 还活着,就会把旧场景状态、旧 GPU 资源和新的 requestAnimationFrame 混在一起。

本次 Demo 固定项目 WebSceneRecovery、页面 ModelPreviewPage(界面标题“WebGL 场景恢复”)、任务 GL-2236、时间 22:36,使用 three@0.183.0。故障注入后状态经历 ACTIVE → SUSPENDED → CONTEXT_LOST → RESTORING → READY,generation 12。最终恢复 14/14 纹理、9/9 材质,帧循环保持 1 条,总耗时 684 ms。
二、先把“页面生命周期”和“图形上下文生命周期”分成两本账
项目里原先只有一个 isReady。ArkWeb 加载完成后置 true,页面隐藏时置 false,回前台再调用 start()。这在普通 H5 页面够用,但图形页至少有三层状态:ArkTS 页面是否可见、WebView 文档是否可通信、WebGL 上下文是否有效。任何一层单独为 true,都不能证明可以渲染。
我把状态拆成两本账。宿主账由 ModelPreviewPage 管理 VISIBLE/HIDDEN/DESTROYED;渲染账由网页运行时管理 BOOTING/ACTIVE/SUSPENDED/CONTEXT_LOST/RESTORING/READY。两边通过 taskId、generation 和 commandId 通信,每条命令只有收到对应 ACK 才改变页面展示。
工程结构也跟着调整:
WebSceneRecovery/
├── entry/src/main/ets/pages/ModelPreviewPage.ets
├── entry/src/main/ets/web/SceneBridge.ets
├── entry/src/main/resources/rawfile/viewer/index.html
├── entry/src/main/resources/rawfile/viewer/scene-runtime.ts
├── entry/src/main/resources/rawfile/viewer/ResourceRegistry.ts
├── entry/src/main/resources/rawfile/viewer/SceneSnapshot.ts
└── entry/src/main/resources/rawfile/models/museum_lobby.glb
宿主不保存 Three.js 对象,只保存可序列化快照;网页不猜页面是否可见,只接受显式 SUSPEND/RESUME/DESTROY。这样上下文恢复和页面回前台即使顺序相反,也不会各自启动一次循环。
三、ArkTS 侧的关键不是 runJavaScript,而是命令有代次、有回执
第一段代码解决 ArkWeb 命令晚到的问题。页面隐藏时先发 SUSPEND,回前台再发 RESUME;每次重新装载文档都会递增 generation。旧文档迟到的 ACK 会被丢弃,不能把新页面改成 ACTIVE。
// ModelPreviewPage.ets
@Entry
@Component
struct ModelPreviewPage {
private controller = new webview.WebviewController()
@State snapshot: SceneHostSnapshot = SceneHostSnapshot.booting('GL-2236')
private generation: number = 12
private async send(type: SceneCommandType): Promise<void> {
const commandId = `GL-2236-${this.generation}-${Date.now()}`
const payload = JSON.stringify({ taskId: 'GL-2236', generation: this.generation, commandId, type })
const result = await this.controller.runJavaScript(`window.sceneHost.dispatch(${payload})`)
const ack = JSON.parse(result) as SceneAck
if (ack.generation !== this.generation || ack.commandId !== commandId) return
this.snapshot = this.snapshot.apply(ack)
}
onPageHide(): void { void this.send('SUSPEND') }
onPageShow(): void { void this.send('RESUME') }
aboutToDisappear(): void { void this.send('DESTROY') }
}
这里没有在 onPageShow 里直接改 READY。RESUME 只是表达宿主愿意继续;如果网页仍处于 CONTEXT_LOST,ACK 会返回 waitingForRestore=true,页面保持恢复中。aboutToDisappear 发送 DESTROY 后不等待 UI 更新,只要求网页停止帧循环并释放可释放资源;Ability 被系统终止时也不能依赖异步回执完成业务保存,所以相机快照在每次交互结束后已写入宿主账。
四、context lost 回调里先停帧,再冻结可回放快照
第二段代码位于网页侧。我们监听 webglcontextlost 与 webglcontextrestored,故障发生时立即把 setAnimationLoop(null),保存相机、选中节点与曝光参数,并把状态上报为 CONTEXT_LOST。恢复事件到来后,不复用旧 Texture 引用,而是创建新 renderer、重新加载 GLB,再回放纯数据快照。
// scene-runtime.ts
canvas.addEventListener('webglcontextlost', (event: WebGLContextEvent) => {
event.preventDefault()
runtime.state = 'CONTEXT_LOST'
runtime.contextLostCount += 1
runtime.renderer.setAnimationLoop(null)
runtime.loopActive = false
runtime.frozen = captureSnapshot({
camera, controls, selectedNodeId: 'bench_A', exposure: 1.15
})
bridge.report('CONTEXT_LOST', runtime.metrics())
})
canvas.addEventListener('webglcontextrestored', async () => {
const generation = ++runtime.restoreGeneration
runtime.state = 'RESTORING'
await registry.disposeScene()
await runtime.rebuildRenderer()
await runtime.loadModel('museum_lobby.glb', generation)
runtime.assertRestoreGeneration(generation)
applySnapshot(runtime.frozen)
runtime.startSingleLoop()
runtime.state = 'READY'
bridge.report('READY', runtime.metrics())
})
preventDefault() 表示应用接管 WebGL 上下文丢失处理;恢复后旧 GPU 资源不再有效,因此快照里绝不放 Mesh、Texture 或 Material。restoreGeneration 还处理第二次上下文丢失:第一次恢复的 GLB 请求即使完成,也只能释放自己的结果,不能覆盖更新的恢复代次。
五、纹理恢复和帧循环必须分别设闸门
有一次修复让场景重新出现,却留下两条动画循环。原因是 RESUME 与 webglcontextrestored 都调用了 startLoop()。第三段代码把循环收口成单一入口,并给资源建立注册表。新场景完全装载前,旧注册项不会被新结果覆盖;恢复失败则按逆序 dispose 新建材质、纹理和几何体。
// ResourceRegistry.ts
class SceneRuntime {
private loopActive = false
private restoreGeneration = 0
startSingleLoop(): void {
if (this.loopActive || this.state === 'CONTEXT_LOST') return
this.loopActive = true
this.renderer.setAnimationLoop(() => {
if (!this.loopActive || document.hidden) return
this.controls.update()
this.renderer.render(this.scene, this.camera)
})
}
async disposeScene(): Promise<void> {
this.loopActive = false
this.renderer.setAnimationLoop(null)
this.scene.traverse(object => disposeObjectResources(object))
this.pmremGenerator?.dispose()
this.renderer.dispose()
this.registry.clear()
}
}
Three.js 的 dispose() 不是页面级垃圾回收。共享材质、环境贴图和 PMREM 资源要由注册表按所有权释放,不能遍历一次就对同一 Texture dispose 两次。renderer.info.memory.textures 用来做恢复后的数量检查,但内部复用对象不一定立即归零,所以门禁比较的是当前模型预期的 14 张业务纹理,而不是强行要求所有内部统计为 0。

六、故障注入比等一次偶现灰屏更省时间
测试页增加“模拟上下文丢失”按钮,只在 Debug 构建启用。网页通过 WEBGL_lose_context.loseContext() 触发故障,400 ms 后调用 restoreContext()。这一条路径能稳定复现真实资源失效,而不是用清空 Canvas 冒充。
本轮关键日志如下:
22:36:02.418 WebScene GL-2236 SUSPENDED loop=0 snapshot=bench_A
22:36:02.621 WebScene GL-2236 CONTEXT_LOST count=1 textures=14 loop=0
22:36:02.639 WebScene GL-2236 RESTORING generation=12 model=museum_lobby.glb
22:36:03.305 WebScene GL-2236 READY textures=14/14 materials=9/9 loop=1 total=684ms
684 ms 包含宿主桥接 ACK 18 ms、模型与场景资源重新加载 412 ms、纹理上传 236 ms,以及状态回放 18 ms。恢复后相机位置仍为 [2.4, 1.6, 5.2],target [0, 1.2, 0],选中节点 bench_A,曝光 1.15。它们都来自快照,不是网页重新打开后的默认值。
七、手机页展示恢复证据,不展示一张“看起来正常”的场景图
最终页面状态为 READY,任务 GL-2236,generation 12,模型 museum_lobby.glb,上下文丢失次数 1,纹理 14/14,材质 9/9,活动帧循环 1,恢复总耗时 684 ms。状态轨迹完整保留 ACTIVE → SUSPENDED → CONTEXT_LOST → RESTORING → READY。

页面还显示相机与选中节点快照,方便判断“恢复的是同一浏览上下文”,而不只是重新加载了模型。按钮为“再次注入故障”和“查看资源明细”;恢复过程中两个按钮都禁用,防止把并发注入当成普通重试。
八、边界:能恢复上下文,不等于所有 ArkWeb 页面都该常驻 GPU
这套方案适合需要保持浏览位置的短时前后台切换,不代表 WebView 隐藏后永远保留模型。若页面离开超过 30 秒、系统内存压力升高或用户真正退出预览,宿主会发送 DESTROY,网页释放场景和 renderer;下次进入按冷启动处理。
另一个边界是版本与设备。本文锁定 three@0.183.0 和当前 Demo,WebGL 扩展、纹理压缩格式及 ArkWeb 行为仍要按目标设备验收。上下文恢复失败时不能无限循环,最多自动尝试一次,之后进入 RESTORE_FAILED 并保留快照,让用户手动重新加载。
这次修复最后只留下一个朴素约束:页面可见、文档可通信、WebGL 有效三件事同时成立,帧循环才允许启动;GPU 资源失效后只回放数据,不回放对象。把这条约束落实到 taskId、generation、资源注册表和单循环闸门,灰屏和双倍渲染才真正从偶现问题变成可验证的工程状态。
更多推荐
所有评论(0)