HarmonyOS 7.0 / API 26 LazyForEach 卡顿复现:用构建计数器抓出断点切换后的重复渲染
先看开发现场
HarmonyOS 7.0 / API 26 做多设备页面时,折叠屏展开或平板窗口拖拽以后,LazyForEach 列表突然闪一下,图片重新加载,滚动位置也有概率跳动。这个问题最烦的地方是:本地看起来只是“偶尔卡一下”,但真到用户手里就是体验不稳。
我不建议一上来就猜图片缓存、网络慢、组件性能差。更直接的办法是加一个构建计数器,先证明列表项是不是在断点切换后被重新构建了。只要构建次数异常上涨,根因就往 key、数据源引用、父容器重建这三个方向查。
适用范围
这篇按 **HarmonyOS 7.0 / API 26** 的多设备适配场景来写,重点是 ArkUI 列表在 DynamicLayout、折叠屏、平板分屏和窗口态变化下的稳定性。示例代码用 ArkTS/TypeScript 风格表达,核心逻辑可以直接放进调试工具类或测试页里验证。
先建一个构建计数器
不要只靠肉眼看闪不闪。列表项有没有重复构建,应该能打印出来。
interface BuildRecord { key: string; count: number; lastReason: string; updatedAt: number; } export class ListItemBuildCounter { private records = new Map<string, BuildRecord>(); markBuild(key: string, reason: string): void { const old = this.records.get(key); const next: BuildRecord = { key, count: old ? old.count + 1 : 1, lastReason: reason, updatedAt: Date.now() }; this.records.set(key, next); } snapshot(): BuildRecord[] { return Array.from(this.records.values()).sort((a, b) => b.count - a.count); } findHotKeys(limit: number = 5): BuildRecord[] { return this.snapshot().filter(item => item.count > 1).slice(0, limit); } }这个计数器很简单,但它能把问题说清楚:是所有 item 都重新构建,还是只有部分 item 抖动;是切断点时构建,还是数据刷新时构建。
错误复现:把布局模式拼进 key
type LayoutMode = 'singleColumn' | 'masterDetail'; interface ArticleRow { id: string; title: string; cover: string; } class UnstableKeyBuilder { build(item: ArticleRow, mode: LayoutMode): string { return mode + ':' + item.id; } } const rows: ArticleRow[] = [ { id: 'a1001', title: 'ArkUI 列表性能排查', cover: 'cover-a.png' }, { id: 'a1002', title: 'DynamicLayout 多设备适配', cover: 'cover-b.png' } ]; const badKey = new UnstableKeyBuilder(); const before = rows.map(item => badKey.build(item, 'singleColumn')); const after = rows.map(item => badKey.build(item, 'masterDetail')); console.info(before[0]); // singleColumn:a1001 console.info(after[0]); // masterDetail:a1001 console.info(before[0] === after[0]); // false这里已经复现了根因:同一条数据,断点切换前后 key 不一样。LazyForEach 无法确认它是同一个 item,就会倾向于重新构建。
正确做法:key 只认业务身份
class StableKeyBuilder { build(item: ArticleRow): string { return item.id; } } interface RenderPolicy { mode: LayoutMode; lanes: number; imageRatio: number; titleMaxLines: number; } class LayoutRenderPolicyResolver { resolve(widthVp: number): RenderPolicy { if (widthVp >= 900) { return { mode: 'masterDetail', lanes: 2, imageRatio: 16 / 10, titleMaxLines: 2 }; } return { mode: 'singleColumn', lanes: 1, imageRatio: 16 / 9, titleMaxLines: 2 }; } }业务身份和布局策略必须分开。key 只用 item.id;单双栏、图片比例、列数、标题行数都放进 RenderPolicy。这样断点切换时变的是展示策略,不是数据身份。
案例一:用计数器验证 key 是否稳定
const counter = new ListItemBuildCounter(); const stableKey = new StableKeyBuilder(); const policyResolver = new LayoutRenderPolicyResolver(); function renderRows(widthVp: number, reason: string): void { const policy = policyResolver.resolve(widthVp); rows.forEach(item => { const key = stableKey.build(item); counter.markBuild(key, reason + ':' + policy.mode); }); } renderRows(390, 'first-render'); renderRows(980, 'foldable-expanded'); console.info(counter.snapshot()); // 期望输出:a1001 和 a1002 的 count 都是 2,但 key 没有变化 // 如果 key 变了,计数器里会出现 singleColumn:a1001 和 masterDetail:a1001 两条记录注意,这里 count 变成 2 不一定是问题。关键要看 key 是否稳定。如果同一个业务 ID 变成了多个 key,才是列表复用失败的信号。
案例二:断点拖拽时合并布局事件
窗口拖拽时,如果每个 width 变化都触发列表重排,构建次数也会暴涨。需要把高频变化合并。
interface BreakpointEvent { widthVp: number; reason: string; timestamp: number; } export class BreakpointEventMerger { private lastBucket: string = 'compact'; resolveBucket(widthVp: number): string { if (widthVp >= 1200) return 'expanded'; if (widthVp >= 900) return 'medium'; return 'compact'; } shouldApply(event: BreakpointEvent): boolean { const bucket = this.resolveBucket(event.widthVp); if (bucket === this.lastBucket) { return false; } this.lastBucket = bucket; return true; } } const merger = new BreakpointEventMerger(); const events: BreakpointEvent[] = [ { widthVp: 910, reason: 'dragging', timestamp: 1 }, { widthVp: 930, reason: 'dragging', timestamp: 2 }, { widthVp: 960, reason: 'dragging', timestamp: 3 }, { widthVp: 1210, reason: 'dragging', timestamp: 4 } ]; const applied = events.filter(event => merger.shouldApply(event)); console.info(applied.map(item => item.widthVp)); // 期望输出:910、1210。同一断点内的 930、960 不触发完整布局更新这个例子解决的是拖拽窗口时的抖动。宽度一直变,但断点没有变,就不要让列表跟着完整刷新。
ArkUI 页面里怎么接
@Component struct StableLazyListPage { @Prop rows: ArticleRow[]; @State widthVp: number = 390; private keyBuilder = new StableKeyBuilder(); private policyResolver = new LayoutRenderPolicyResolver(); private counter = new ListItemBuildCounter(); build() { const policy = this.policyResolver.resolve(this.widthVp); List() { LazyForEach(this.rows, (item: ArticleRow) => { ListItem() { ArticleCard({ item, imageRatio: policy.imageRatio, titleMaxLines: policy.titleMaxLines }) } .onAppear(() => { this.counter.markBuild(this.keyBuilder.build(item), 'onAppear:' + policy.mode); }) }, (item: ArticleRow) => this.keyBuilder.build(item)) } .lanes(policy.lanes) } }这段页面代码有两个检查点:LazyForEach 的 key 稳定,onAppear 里能记录构建情况。上线前可以把 counter 输出接到日志里,确认断点切换没有把列表全部打散。
排查顺序
| 顺序 | 检查点 | 通过标准 |
| 1 | item key | 只使用业务 ID,不拼 layoutMode |
| 2 | 数据源引用 | 断点变化不重新 new 一份列表 |
| 3 | 图片缓存 key | 不把 widthBucket 拼进同一图片资源 |
| 4 | 断点事件 | 同一断点内拖拽不触发完整刷新 |
| 5 | 构建计数器 | 热点 key 数量可解释,不出现重复业务身份 |
这张表适合收藏,因为后面遇到列表闪烁时可以直接按顺序查。
最后给一个判断标准
如果列表卡顿只发生在折叠屏展开、平板分屏和窗口拖拽时,优先查断点切换,不要先查网络。只要 key 稳定、数据源稳定、断点事件合并,LazyForEach 的复用能力才有发挥空间。
如果你也遇到过“手机正常,大屏一切就闪”的问题,可以先把设备形态、窗口宽度和 key 输出贴出来,基本能很快判断是不是断点切换把列表打散了。