调了 dispose 还花屏,HarmonyOS 7 图形资源释放的隐式时序
先说一个现象。你用 FrameNode 加 NDK 做图表自定义渲染,页面走 Navigation 缓存栈,退出时不销毁,只隐藏。你在 onDispose 里老老实实调了 PixelMap.release(),Profiler 里显存该降了吧?没有。来回进出页面十几次,显存一路爬坡,偶尔切回来还给你闪一下旧纹理或者花屏。你盯着代码看了半宿,release 明明写了,dispose 也调了,问题到底出在哪?
答案不在你“调了什么”,而在你“以为它保证什么”。dispose() 的文档写得很克制,它只承诺解除当前对象对底层实体节点的引用,不承诺渲染队列已经消费完你交给它的那块 buffer。PixelMap.release() 的前置条件更苛刻:该实例关联的异步操作已经完成,且实例不再被需要。两句话放在一起,问题就露出来了:你在“停止使用”的信号刚发出时就执行了“最终释放”,而 RS 渲染线程可能还在队列里引用那块纹理。
dispose 只断开引用,不管渲染队列排没排完
FrameNode 有三个容易混在一起的操作:visibility = Hidden、removeChild、dispose。很多人把它们当成“程度不同的隐藏”,实际上三者在渲染管线里的含义完全不一样。
visibility = Hidden 之后,节点还在树上,后端实体节点还在,RS 仍然持有它关联的纹理和 buffer。removeChild 把节点从父节点子树里摘出去,但 RS 渲染队列中可能还挂着对这块资源的引用,不是同步清空。只有 dispose 会主动销毁后端实体节点,让 RS 侧的引用逐步释放。但“逐步释放”四个字是关键词:dispose 返回的时候,RS 不一定已经把队列里所有引用都处理完了。
所以你在 onDispose 里紧跟着 release PixelMap,等于在渲染线程还在读那块数据的时候把它标记为可回收。后果不是立刻崩溃,而是偶发的花屏、旧纹理残留,或者显存曲线在“应该降”的地方反而往上走了一点。
一个反直觉但很重要的区分:onHidden 是页面隐藏通知,invalidate() 是触发重绘,FrameNode.dispose() 是解除对象对实体节点的引用——它们都不是“渲染队列已完成使用”的信号。没有任何一个 ArkTS 侧回调天然等于 GPU 侧完成了对某块 buffer 的消费。
把“停止使用”和“最终释放”拆成两个阶段
不要试图在一个回调里同时做完“别再用它了”和“把它还回去”。这两件事在渲染管线里的时间尺度不一样。
页面隐藏或节点即将离开可视区时,第一步只做“停止使用”:停掉新的绘制任务,停掉定时刷新,把仍在执行的异步任务标记出来,防止它们完成后把旧资源写回新页面。这一步的目标是保证不再有新的使用者出现,而不是立刻回收。
真正的 PixelMap.release() 要等到你确认所有已知的异步绘制、Surface 回调、NDK 侧持有者都退出之后。如果使用 NDK 的 SurfaceHolder 家族,释放顺序有讲究:先移除并销毁 SurfaceCallback,再销毁 SurfaceHolder,最后才调用 nodeAPI->disposeNode。顺序颠倒会直接引发 crash。
下面是一个 ArkTS 侧可运行的骨架,演示“停止使用”与“延迟释放”的分层处理。
typescript
// ChartRenderController.ets
import { FrameNode } from '@kit.ArkUI';
class ChartRenderController {
private frameNode: FrameNode | null = null;
private pixelMap: image.PixelMap | null = null;
private pendingTasks: number = 0;
private isHidden: boolean = false;
// 页面隐藏时调用:只停止新的绘制,不释放资源
onPageHidden(): void {
this.isHidden = true;
// 停止定时刷新、取消未开始的异步任务
clearInterval(this.refreshTimer);
// 标记“隐藏中”,旧任务完成时不得写回新画面
}
// 异步绘制任务的入口:记录在途任务数
async drawAsync(): Promise<void> {
if (this.isHidden) {
return; // 隐藏后不再发起新绘制
}
this.pendingTasks++;
try {
const newPixelMap = await this.renderToPixelMap();
if (this.isHidden) {
// 任务完成时页面已隐藏,旧资源直接释放,不写回
await newPixelMap.release();
return;
}
const oldPixelMap = this.pixelMap;
this.pixelMap = newPixelMap;
if (oldPixelMap) {
// 旧图确认不再被 Image/Canvas 引用后才释放
await oldPixelMap.release();
}
} finally {
this.pendingTasks--;
}
}
// 真正放弃节点时调用:确认无在途任务后再释放
async finalize(): Promise<void> {
if (this.pendingTasks > 0) {
// 不能在这里等一个固定延时,要等任务真正结束
// 实际工程中配合 Promise 或计数器通知
return;
}
if (this.pixelMap) {
await this.pixelMap.release();
this.pixelMap = null;
}
if (this.frameNode && !this.frameNode.isDisposed()) {
this.frameNode.dispose();
this.frameNode = null;
}
}
}
关键语句有两处。drawAsync 里在 await renderToPixelMap() 之后重新检查 this.isHidden,是因为异步任务启动时页面可能是可见的,完成时状态已经变了;不检查就会把旧资源写回一个已经隐藏的页面,下一轮显示时你看到的就是上一轮的残留。finalize 里 pendingTasks > 0 时直接返回,不是偷懒,是因为在渲染任务还没结束的时候调 release 恰恰是花屏的源头。真实工程里需要把“任务结束”的通知补上,而不是用 setTimeout 赌一帧。
onDispose 不是万能释放点,Navigation 缓存下它可能根本不触发
还有一个更隐蔽的情况。你的页面走 Navigation 复用栈,退出时实例不销毁、只隐藏。在这种场景下,FrameNode 从来没有被 dispose,onDispose 可能根本不会触发。你以为写了释放逻辑,实际上那段代码在缓存复用路径上就是死代码。
所以不要把释放逻辑绑死在 onDispose 上。释放的触发条件应该是“你确认这个节点和它的资源不再需要了”,而不是“系统回调告诉你某个生命周期到了”。对于缓存页面,页面隐藏时做“停止使用”,真正退出复用栈或者资源需要让位时再做 dispose 和 release。
typescript
// 错误写法:把释放全押在 onDispose 上
class BadNodeController extends NodeController {
private pm: image.PixelMap | null = null;
makeNode(): FrameNode {
return new FrameNode(this.getUIContext());
}
aboutToDisappear(): void {
// 在 Navigation 缓存场景下,这个方法不一定按你期望的时机触发
this.pm?.release();
}
}
这不是说 aboutToDisappear 没用,而是说不能把它当成唯一的释放保证。显式管理资源比等回调可靠。
另外提一句背景:HarmonyOS 7 上 PSS 的查杀阈值比 6 收紧了一些,同样的内存占用在旧版本能跑,在新版本可能更早触发系统回收。图形资源泄漏在 6 上可能只是“显存高一点”,到了 7 上更容易以 OOM 或异常退出的形式暴露出来。这不是你的代码突然变差了,是系统的容忍窗口变窄了。
综上所述,dispose 和 release 的文档没有说谎,它们只是没有替你承诺“渲染队列已经清空”。把“停止使用”和“最终释放”拆开,不把 onDispose 当成唯一的安全网,确认在途异步任务全部退出之后再碰 release——这三件事做到,那类偶发花屏和显存缓慢爬坡的问题会从根上收住。
更多推荐


所有评论(0)