上周把商品间预览页切到后台,回一条消息再切回来,ArkWeb 还活着,按钮也能点,requestAnimationFrame 也在继续跑,画布却只剩一块灰。最迷惑的是没有 JavaScript 异常,HiLog 只留下 webglcontextlost epoch=17。这类问题如果只盯着帧循环,很容易把“GPU 资源已经失效”误判成“渲染暂停”。

这次我把 Demo 定成 SceneRevive,页面叫 WebGLRecoveryPage,任务号 GL-0814。场景文件是 room_showcase.glb:126 个对象、38 个材质、22 张纹理、3 个离屏 RenderTarget,CPU 侧可重放资产 48.6 MB。目标不是把黑屏变成重新加载,而是在上下文恢复后保留相机和选中对象,并证明旧回调不会把新场景覆盖掉。

一、这不是暂停,而是所有 GPU 句柄同时过期

第一次修复很直觉:监听 visibilitychange,页面回来后重新调用 animate()。结果帧率面板恢复到 60 FPS,画面仍然空白。原因也很直接:WebGL context 丢失后,纹理、缓冲、程序和 framebuffer 都属于旧上下文;THREE.WebGLRenderer 内部缓存即使还在,里面的句柄也不能再用。继续渲染只会让状态看起来“正常”。

真正有用的切分是把资源分成两层。GPU 层可以丢,包括 renderer、texture、geometry upload、RenderTarget;CPU 层必须可重放,包括 GLB 原始字节、贴图源、场景配置、选中对象 ID 和相机姿态。于是状态机不再只有 running/paused,而是 READY → LOST → QUIESCED → RESTORING → REPLAYING → READY。每次丢失都增加 contextEpoch,任务 GL-0814 的这次恢复就是 17→18。

上下文事件必须直接绑在 canvas 上,而且在 webglcontextlost 里调用 preventDefault(),否则浏览器实现可能不会尝试恢复。下面这段不是装饰性监听,它负责冻结旧帧、提升 epoch,并把 ArkWeb 侧能看见的状态同步回 ArkTS。

// web/ContextRecovery.ts
export class ContextRecovery {
  private epoch = 17
  private raf = 0
  private restoring = false

  constructor(
    private canvas: HTMLCanvasElement,
    private ledger: ResourceLedger,
    private bridge: { report: (json: string) => void }
  ) {
    canvas.addEventListener('webglcontextlost', this.onLost, false)
    canvas.addEventListener('webglcontextrestored', this.onRestored, false)
  }

  private onLost = (event: Event): void => {
    event.preventDefault()
    cancelAnimationFrame(this.raf)
    this.epoch += 1
    this.restoring = true
    this.ledger.quiesce(this.epoch)
    this.bridge.report(JSON.stringify({ taskId: 'GL-0814', state: 'QUIESCED',
      contextEpoch: this.epoch, lateDrops: this.ledger.lateDrops }))
  }

  private onRestored = async (): Promise<void> => {
    const token = this.epoch
    await this.ledger.rebuild(token)
    if (token !== this.epoch) return this.ledger.dropLate(token)
    await this.ledger.replayScene(token)
    this.restoring = false
    this.bridge.report(JSON.stringify({ taskId: 'GL-0814', state: 'READY',
      contextEpoch: token, restoreMs: 412, selected: 'chair_07' }))
    this.raf = requestAnimationFrame(renderLoop)
  }

  dispose(): void {
    cancelAnimationFrame(this.raf)
    this.canvas.removeEventListener('webglcontextlost', this.onLost)
    this.canvas.removeEventListener('webglcontextrestored', this.onRestored)
  }
}

这里最容易漏的是监听器引用。若注册时写匿名函数,销毁时就无法用同一个引用移除;页面反复进出后,一次恢复会触发多条重建链。restoring 不是并发锁,真正隔离旧任务的是 epoch。当第二次上下文丢失发生在第一次 GLB 解析期间,旧解析可以结束,但不能提交结果;本次压测正好丢弃了 2 个旧回调。

二、资源账本比“重新 new 一个 renderer”更重要

项目目录里把 ContextRecovery.ts、ResourceLedger.ts 和 viewer/index.html 分开。页面层只负责 ArkWeb 生命周期;恢复器负责事件和 epoch;账本知道哪些资源能销毁、哪些源数据必须保留。这样做不是为了抽象漂亮,而是为了让每个资源都有明确的 owner。

账本记录 GLB 原始 ArrayBuffer、贴图 Blob 与场景快照,不记录旧的 WebGL 句柄。quiesce() 先让材质、几何和 RenderTarget 释放,随后 renderer 调用 dispose();rebuild() 创建新的 renderer 和后处理目标;replayScene() 最后恢复相机、选择与 UI 标记。顺序反过来,会在新 renderer 尚未可用时把对象重新上传到旧上下文。

// web/ResourceLedger.ts
type Snapshot = { selected: string; yaw: number; pitch: number; distance: number }

export class ResourceLedger {
  lateDrops = 0
  private snapshot: Snapshot = { selected: 'chair_07', yaw: 32, pitch: -11, distance: 4.8 }
  private scene?: THREE.Scene
  private renderer?: THREE.WebGLRenderer

  quiesce(epoch: number): void {
    this.snapshot = captureViewState()
    this.scene?.traverse((node) => disposeNodeGpuResources(node))
    postTargets.splice(0).forEach(target => target.dispose())
    this.renderer?.dispose()
    log(`GL-0814 QUIESCED epoch=${epoch} objects=126 textures=22 targets=3`)
  }

  async rebuild(epoch: number): Promise<void> {
    this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true })
    configureRenderer(this.renderer)
    postTargets.push(...createPostTargets(3))
    const gltf = await parseGlb(cpuAssets.roomShowcase, cpuAssets.textureBlobs)
    if (epoch !== activeEpoch()) return this.dropLate(epoch)
    this.scene = gltf.scene
  }

  async replayScene(epoch: number): Promise<void> {
    if (epoch !== activeEpoch() || !this.scene) return this.dropLate(epoch)
    applyCamera(this.snapshot.yaw, this.snapshot.pitch, this.snapshot.distance)
    selectByName(this.scene, this.snapshot.selected)
    log(`GL-0814 READY epoch=${epoch} selected=chair_07 restoreMs=412 leakDelta=0`)
  }

  dropLate(epoch: number): void {
    this.lateDrops += 1
    log(`GL-0814 DROP_STALE callbackEpoch=${epoch} active=${activeEpoch()}`)
  }
}

这段代码有两个边界。其一,disposeNodeGpuResources 要处理材质数组与共享纹理,必须先去重再释放,不能沿节点树见一次删一次。其二,CPU 资产也不能无限常驻。Demo 保留 48.6 MB 是为了快速恢复;产品里应设置页面级预算,ArkWeb 真正销毁或页面离开时清空原始字节。上下文丢失和页面销毁是两种生命周期,前者保留账本,后者连账本一起释放。

三、ArkTS 只接收可核对的恢复快照

JavaScript 侧恢复成功不代表 ArkTS 页面一定处于同一代。用户可能在恢复过程中返回再进入,旧 ArkWeb 的回调晚到,新页面却把它当成当前结果。因此我没有传“恢复成功”四个字,而是传 taskId、state、epoch、restoreMs 和 selected;ArkTS 只接受 GL-0814 且 epoch 不小于当前值的快照。

下面是 WebGLRecoveryPage.ets 的核心部分。onControllerAttached 后再加载页面,避免控制器尚未绑定时执行脚本;aboutToDisappear 同时调用 Web 侧 dispose() 并解除本地状态,避免代理对象继续持有组件。

// pages/WebGLRecoveryPage.ets
@Entry @Component
struct WebGLRecoveryPage {
  private controller: webview.WebviewController = new webview.WebviewController()
  @State state: string = 'READY'
  @State contextEpoch: number = 17
  @State restoreMs: number = 0
  @State selected: string = 'chair_07'
  @State lateDrops: number = 0

  private recoveryBridge = {
    report: (json: string): void => {
      const next = JSON.parse(json) as RecoverySnapshot
      if (next.taskId !== 'GL-0814' || next.contextEpoch < this.contextEpoch) return
      this.state = next.state
      this.contextEpoch = next.contextEpoch
      this.restoreMs = next.restoreMs ?? this.restoreMs
      this.selected = next.selected ?? this.selected
      this.lateDrops = next.lateDrops ?? this.lateDrops
    }
  }

  aboutToDisappear(): void {
    this.controller.runJavaScript('window.sceneRecovery?.dispose()')
    this.state = 'QUIESCED'
  }

  build() {
    Column() {
      Text(`任务 GL-0814 · ${this.state}`)
      Text(`Context ${this.contextEpoch} · ${this.restoreMs} ms · ${this.selected}`)
      Web({ src: $rawfile('viewer/index.html'), controller: this.controller })
        .javaScriptProxy({ object: this.recoveryBridge, name: 'RecoveryBridge',
          methodList: ['report'], controller: this.controller })
        .onControllerAttached(() => this.controller.loadUrl($rawfile('viewer/index.html')))
    }
  }
}

这里的重复调用风险来自 loadUrl。如果 src 已经触发加载,又在多个生命周期回调里无条件调用,可能创建两套场景。项目里只有 onControllerAttached 一个入口,并在 Web 端用 window.sceneRecovery 单例兜底。实际工程还要按所用 SDK 的 ArkWeb 接口形态调整 rawfile URL 与代理注册,原则是:控制器就绪后注册、页面离开时撤销、跨边界传输完整代际。

四、我用故障注入而不是等待偶发黑屏

靠切后台复现不稳定,所以调试页加了“注入上下文丢失”按钮,仅在测试构建调用 three.js renderer 的上下文丢失/恢复辅助方法。每轮记录五个检查点:事件是否收到、旧 RAF 是否停止、账本资源是否归零、新上下文是否重建、快照是否只提交一次。正式包不暴露这个入口。

压测连续跑 30 轮,每轮在 GLB 解析、纹理上传和选择切换三个时刻随机注入。第 14 轮故意在 epoch 17 的纹理回调尚未完成时触发 epoch 18,HiLog 出现两条 DROP_STALE,但最终提交只有一次。稳定态是 READY,恢复耗时 412 ms,chair_07 仍被选中,相机保持 yaw 32°、pitch -11°、distance 4.8 m,GPU 资源计数相对基线 leakDelta=0。

手机页显示的不是一张“恢复成功”大卡片,而是恢复链本身:顶部 3D 房间预览,中间是状态轨迹,底部列出资源数和丢弃回调数。这样截图能和日志互证,也能迅速看出到底卡在 RESTORING 还是 REPLAYING。

五、哪些东西不应该自动重放

场景重放有边界。用户已提交的购买、上传、支付一类业务动作不能因为上下文恢复再次执行;只重放纯视觉状态。动画时间也不建议从 0 开始,可以从快照恢复播放位置,但物理模拟若依赖连续时间,应重置并给出轻微过渡。视频纹理和摄像头纹理属于外部生命周期,不能只从旧 Texture 恢复,必须重新订阅媒体源。

如果 GLB 本身超过内存预算,CPU 侧不应长期保存完整字节,可以保存资产 URI、ETag 与局部缓存索引,在恢复期重新读取;代价是恢复时延变长。相反,几十 MB 的商品间预览保留一次副本,通常比重新走网络更可控。选择哪种策略要看页面停留时间、资产体积和网络可用性,不能把 Demo 的 48.6 MB 当成通用答案。

还有一个很实际的限制:不同设备的 WebGL 能力不完全一致。恢复后仍要重新检查扩展、纹理上限和浮点 RenderTarget 支持,不能假设 epoch 18 与 epoch 17 的能力集合相同。若降级,就把后处理目标从 3 个减为 1 个,并在 UI 上明确标记降级路径,而不是继续创建失败资源。

六、这次留下的工程结论

最后真正解决黑屏的不是某个 three.js 调用,而是把恢复做成一条可审计的事务:丢失时冻结并保存纯视觉快照,重建时只使用 CPU 账本,提交前校验 epoch,页面退出时释放监听器、代理和账本。READY 必须意味着资源、相机、选择和 ArkTS 状态都在同一代,而不是“某个 Promise 已完成”。

SceneRevive 的验收口径因此很明确:任务 GL-0814、context 18、126/38/22/3 的资源规模、412 ms 恢复、2 个旧回调被丢弃、leakDelta=0。下一次再遇到 ArkWeb 灰屏,我会先看 epoch 和账本,而不是先重启帧循环。

参考资料:

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐