HarmonyOS工单双栏性能诊断与页面资源释放【共创稿事节】
工单双栏工作台发展到最后一个阶段,我最担心的不是列表能否顺利打开,而是工作人员在一个上午内多次切换工单、展开现场照片、调整窗口宽度以后,详情页是否还握着过时的请求、订阅和图片数据。WO-1047是A栋一层电梯厅的电梯异响工单,WO-1048是B栋二层照明异常,WO-1046是消防设施巡查。它们的业务标识稳定,资源占用却应随页面的可见性与使用状态改变。本篇把诊断和回收放到同一套可检查的生命周期设计中。

不把选中状态当成资源所有权
前面几篇分别处理了工单筛选、过期响应、修订冲突和多个窗口的焦点。此时业务状态至少分为三层:持久层保存选中的工单ID与必要的列表条件;会话层保存当前详情请求的代际、编辑草稿与导航位置;资源层保存控制器、事件监听解除函数、图片解码缓存以及短期订阅。三层不能放进一个对象后直接长期保留。否则WO-1047即使被WO-1048替换,旧详情控制器仍可能持有整个页面的回调闭包。
我采用一个很朴素的约定:选中ID是业务状态,不等于详情数据已就绪;详情数据可复用,但必须核对版本和归属;每个创建出来的临时资源都要找到对应的释放点。页面在前台重新显示时,可以继续使用仍然有效的业务ID,而不是把失去效用的监听也恢复回来。列表中的卡片尺寸变化属于布局层,不能触发所有详情订阅的重新建立。
先把诊断对象列出来
排查问题时,我会记录当前页面标识、选中的工单ID、请求代际、待响应任务数量、注册的解除函数数量以及当前详情版本。这样就能把“列表重绘频繁”和“请求没有终止”两类问题分开。列表拖动后卡片位置改变,不一定是资源泄漏;同一页面关闭后资源计数持续增长,才需要追溯注册与清理是否成对。不要用一次页面打开的耗时推断长期运行行为。
诊断表中还要区分可见状态与持有状态。详情页从前台转到不可见状态时,图片大对象、持续订阅和过时网络请求应按使用场景释放;工单号、筛选词和当前修订号可以继续保留。对于图片浏览弹层,关闭弹层的动作应释放其自身持有的数据,不要顺带删除列表缓存。这种细分能避免为了“省内存”而让返回工单时重复下载所有摘要。
真正的资源清理不应依赖开发人员记住每个退出按钮。我把解除订阅、关闭观察器、停止周期读取等函数登记到同一资源容器。登记时使用语义键,重复登记同一键前先释放旧资源;销毁时按逆注册顺序清理,异常单独收集,保证某一个解除函数出错不会阻断后续回收。资源容器关闭后不再接受新登记,防止页面已经退出,异步回调又把监听挂回去。
要解决的问题是为每次详情会话建立可重复调用的释放出口,下面的Node.js业务逻辑可以直接放进普通JavaScript工程中。
function createResourceBag(label) {
const releases = new Map();
let closed = false;
return {
track(key, release) {
if (closed) throw new Error(`会话已关闭: ${label}`);
if (typeof release !== 'function') throw new TypeError('release必须是函数');
if (releases.has(key)) releases.get(key)();
releases.set(key, release);
},
size() { return releases.size; },
dispose() {
if (closed) return [];
closed = true;
const errors = [];
for (const [, release] of [...releases].reverse()) {
try { release(); } catch (error) { errors.push(error); }
}
releases.clear();
return errors;
}
};
}
这段设计有两个容易忽视的细节。第一,dispose必须具备幂等性:窗口关闭和页面退出可能先后触发回收,不能把重复清理变成异常。第二,单个资源抛错时仍执行其他释放函数,错误列表交给上层记录。所有解除动作都应针对建立订阅时得到的句柄,不能只根据相似名称猜测需要清理的对象。这里的资源登记、事件订阅和网络访问都是业务域约定,不涉及未经核对的系统窗口接口。

切换工单时谁负责结束旧请求
当工作人员从WO-1048转到WO-1047,旧请求有可能比新请求返回得更晚。请求取消用于减少不必要的工作,请求代际校验用于保证迟到结果不会污染当前页面;二者应配合,而不是互相替代。AbortController能给支持取消信号的JavaScript请求提供控制入口,但是某些数据适配器也可能在取消后返回已缓存结果,因此写入详情前必须再次核对选中ID和代际。
另一个常见缺口来自finally。旧请求结束后如果无条件把loading设为false,新请求的加载指示会被旧任务误关掉。正确做法是只有当返回的请求代际仍是当前代际时,才更新等待状态。下方会话控制器将每次打开工单视为新的所有权周期;它不依赖具体的系统网络模块,接口由外部注入。
function createDetailSession(fetchDetail, publish) {
let generation = 0;
let selectedId = null;
let pending = null;
let closed = false;
async function open(id) {
if (closed) throw new Error('页面已关闭');
pending?.abort();
const own = ++generation;
selectedId = id;
const controller = new AbortController();
pending = controller;
try {
const detail = await fetchDetail(id, { signal: controller.signal });
if (closed || controller.signal.aborted || own !== generation ||
selectedId !== id || detail.id !== id) return null;
publish(detail);
return detail;
} finally {
if (own === generation) pending = null;
}
}
return {
open,
getSelectedId: () => selectedId,
dispose() { closed = true; ++generation; pending?.abort(); pending = null; }
};
}
状态变化形成明确的先后顺序:点击工单时先提升代际,再取消旧请求、建立新控制器;回包时先判断是否关闭,再判断取消信号、代际与业务ID;页面退出时终止控制器并使代际失效。这样即使回调之后继续到达,也只能返回空结果。数据缓存若要用于复用,还应附带工单ID及服务端修订号,不能把仅有页面下标的缓存当成详情主键。
页面隐藏、退出与清理时机
Navigation中的NavDestination提供页面生命周期通知。官方文档区分了隐藏、即将销毁和销毁等阶段,不能把一次遮挡当成彻底移除。我的处理策略是:短时隐藏时暂停不需要继续运行的观察器,保留小体积状态;确认退出页面时调用会话dispose、移除监听并释放该详情页独占的图片引用。重新显示时由当前选中工单触发按需加载,而不是重用已经失效的控制器。
尤其在大屏双栏中,左侧列表和右侧详情的生命周期不一定相同。右侧切换WO-1047详情,不应重建左栏全部数据源。窗口变化也只需重新计算布局和可见内容。若使用长列表,华为开发者的List文档明确区分ForEach、LazyForEach和Repeat对应的创建、布局与复用时机;通过合理的稳定键和懒加载策略,减少被渲染的卡片数量。设置缓存行数时应结合列表密度,而不是无限提高缓存范围。

用可重复的断言代替猜测
诊断脚本首先验证重复释放是否安全,然后验证旧请求迟到时不会覆盖最新详情。在工程中还应对输入样本分开记录:相同工单的连续点击、不同工单快速切换、详情打开后立刻退出、图片窗关闭后主窗仍在、页面隐藏后重新显示。这些路径应观察的是状态归属和资源计数是否归零,而不是预设某个固定耗时值。性能数值应在明确设备、构建配置与采样方法后再比较。
const assert = require('node:assert/strict');
async function checkLifecycle() {
let released = 0;
const bag = createResourceBag('WO-1047');
bag.track('listener', () => { released++; });
bag.track('photos', () => { released++; });
assert.equal(bag.size(), 2);
assert.deepEqual(bag.dispose(), []);
assert.deepEqual(bag.dispose(), []);
assert.equal(released, 2);
const resolves = new Map();
const shown = [];
const session = createDetailSession(
id => new Promise(resolve => resolves.set(id, resolve)),
detail => shown.push(detail.id)
);
const old = session.open('WO-1048');
const current = session.open('WO-1047');
resolves.get('WO-1047')({ id: 'WO-1047' });
await current;
resolves.get('WO-1048')({ id: 'WO-1048' });
await old;
assert.deepEqual(shown, ['WO-1047']);
session.dispose();
}
checkLifecycle().catch(error => { console.error(error); process.exitCode = 1; });
在本地排查里,我还会单独观察资源登记数、事件解绑次数和请求代际,不把HiLog的一段输出当成性能结论。如果页面每进入一次就新增一次全局监听,但退出后没有对应解除,可以沿着登记键直接定位;若资源数正常、界面依旧卡顿,则转去检查列表项内的复杂计算、重复图片解码以及数据变更触发的全量渲染。对缓存要设置可解释的失效条件,不允许因为资源释放而把另一窗口正在使用的详情对象提前关闭。
结束这一条工单工程主线
八篇连载从双栏路由、快速切换、选择持久化一直走到修订冲突、窗口焦点和生命周期,最终需要回答的是同一个问题:业务数据由谁负责,临时资源又由谁负责。选中工单ID可以穿越界面变化,未完成的请求和订阅却应有清晰的结束点。把业务状态和资源所有权拆开后,诊断才有可靠依据,清理也不会变成粗暴地销毁全部缓存。开发中遇到高频切换问题,我会先画清每个资源的创建与销毁路径,再调渲染缓存参数。
参考华为开发者文档:NavDestination页面生命周期、List列表的创建与懒加载、ContainerReader容器断点。
更多推荐


所有评论(0)