用户把折叠屏从展开态合上,列表没有崩,数据也没有丢,但阅读位置会先闪回顶部,然后又跳到原来的第 36 条。功能上只差 16 毫秒,视觉上却像整个页面被重新打开了一次。
这个问题来自一个叫FoldAnchor Lab的笔记列表。展开态是左侧长列表、右侧预览的双栏布局;折叠后变成单栏,列表占满宽度。页面切换本身没有问题,真正的错误是我在两个布局分支中分别创建了List,它们看起来使用同一份数据,却不是同一个滚动容器。
本次调试会话编号为fold_20261001_09。切换前窗口宽度 840 vp,折叠过程到 672 vp,再展开回 840 vp。阅读锚点是note_036,索引 36,条目内偏移 12 vp。修正后状态为RESTORED,首帧恢复耗时 16 ms,跳顶次数 0。这几个数会同时出现在正文、DevEco Studio 图和手机运行图里。
一、看起来是滚动问题,根上是组件身份变了
我最初的页面写法很直观:宽度超过断点时返回Row { NoteList(); NotePreview() },否则只返回NoteList()。两个NoteList都传入同一个数组,也都显示了note_036,但断点切换时,ArkUI 会销毁一个分支,再创建另一个分支。Scroller的位置不是业务数组的一部分,所以新列表自然从顶部开始。
我曾经在窗口变化回调中立即调scrollToIndex(36)。日志显示方法确实执行了,但那一刻新分支还没完成布局,滚动请求不是被忽略,就是按旧容器的尺寸计算。后面加了延时器,偶尔又能恢复,这更容易让人误以为只是时间设得不够长。
实际需要明确三个时刻:旧容器何时记录锚点,新容器何时可以恢复,恢复过程中页面是否已经暴露了顶部的首帧。只有把这三个点分开,“日志上恢复成功,眼睛看着仍然闪了一下”才能解释。
二、记录的不是 index,而是稳定的业务锚点
索引 36 在当前列表中有意义,但如果切换布局时同步进来了一条置顶笔记,原来的第 36 条会变成第 37 条。只记 index 会把页面恢复到“差不多的位置”,但用户正在阅读的内容已经变了。
下面这段代码解决的是“窗口变化前,如何记住真正的阅读位置”。它把第一个可见条目的业务 id、当前索引和条目内偏移放到同一份快照中。
exportinterfaceScrollAnchor{noteId:stringindex:numberoffsetVp:numbercapturedAt:number}exportclassAnchorTracker{privateanchor?:ScrollAnchorcapture(notes:NoteItem[],firstIndex:number,offsetVp:number):ScrollAnchor|undefined{constitem=notes[firstIndex]if(!item)returnundefinedthis.anchor={noteId:item.id,index:firstIndex,offsetVp,capturedAt:Date.now()}returnthis.anchor}resolve(notes:NoteItem[]):ScrollAnchor|undefined{if(!this.anchor)returnundefinedconstlatestIndex=notes.findIndex(item=>item.id===this.anchor?.noteId)returnlatestIndex<0?undefined:{...this.anchor,index:latestIndex}}}noteId用来抵抗列表插入、删除和重排,index用来快速定位,offsetVp用来保留条目内的精细位置。当note_036在切换期间被删除,resolve()返回空,页面才回退到最近的合法 index,不会拿一个已经失效的 id 反复恢复。
当前 Demo 从onScrollIndex记录索引,在滚动稳定后再通过Scroller取偏移。正式项目如果列表项高度差异很大,不要根据平均行高估算偏移,否则文字放大后会明显偏离。
三、窗口回调只提交意图,不在回调里强行滚动
窗口尺寸变化会连续发生。用户在折叠过程中,宽度可能从 840 vp 经过多个中间值到 672 vp。如果每次回调都销毁分支、恢复列表,页面会把一次形态切换做成五六次布局重建。
下面这段代码解决的是“窗口回调频繁,恢复请求重入”。它不在windowSizeChange回调里直接滚动,只保存最后一个布局意图,并为这次切换生成递增代号。
exportclassFoldTransitionController{privategeneration:number=0privatetimer:number=-1state:RestoreState='IDLE'request(widthVp:number,anchor?:ScrollAnchor):void{constcurrent=++this.generationthis.state='WAIT_LAYOUT'if(this.timer>=0)clearTimeout(this.timer)this.timer=setTimeout(()=>{if(current!==this.generation)returnconstmode:LayoutMode=widthVp>=720?'DUAL':'SINGLE'this.commit({generation:current,mode,anchor})},80)}cancel():void{++this.generationif(this.timer>=0)clearTimeout(this.timer)this.state='IDLE'}}80 ms 用来合并折叠过程的中间尺寸,它不是等布局完成的延时。窗口最终停在 672 vp 后,控制器仅提交一次SINGLE。实际恢复依然要等新List通知自己已可用,两件事不能混成一个更长的setTimeout。
页面退出时会执行cancel(),避免定时回调在页面销毁后仍去操作Scroller。重复进入页面时,代号从新会话重新计算,不复用上一个页面实例的待恢复任务。
四、恢复锚点要卡在布局就绪之后、首帧暴露之前
如果页面先把新列表从 index 0 绘制出来,下一帧再调到 index 36,逻辑状态虽然正确,用户仍会看到一次跳动。我给列表外层加了一个极短的“恢复遮罩”,它不是加载页,也不阻塞数据;只在WAIT_LAYOUT与RESTORING期间保留前一帧快照,完成滚动后立即移除。
下面这段代码解决的是“恢复成功了,但用户仍先看到列表顶部”。它在新列表报告尺寸后执行一次非动画定位,再按 12 vp 的条目内偏移做精调。
@StateprivaterestoreState:RestoreState='WAIT_LAYOUT'privatelistScroller:Scroller=newScroller()privaterestoreGeneration:number=0privateonListReady():void{constcurrent=++this.restoreGenerationconstanchor=this.anchorTracker.resolve(this.notes)if(!anchor){this.restoreState='EMPTY'return}this.restoreState='RESTORING'conststartedAt=Date.now()this.listScroller.scrollToIndex(anchor.index,false,ScrollAlign.START)setTimeout(()=>{if(current!==this.restoreGeneration)returnthis.listScroller.scrollBy(0,anchor.offsetVp)this.restoreState='RESTORED'this.metrics.restoreMs=Date.now()-startedAt},0)}这个onListReady()由新列表容器的尺寸就绪回调触发,而不是由窗口回调直接调用。setTimeout(..., 0)只让条目内偏移落在scrollToIndex完成布局后的下一个任务回合,它不承担“猜测布局需要多久”的职责。核心仍然是:scrollToIndex不带动画,恢复完成前不暴露 index 0,并且代号不匹配时不再改状态。页面销毁或用户开始新滚动时会递增restoreGeneration,已排队的回调因此无法回写旧状态。
高度不固定的 ListItem 还有一个边界:scrollToIndex只能把目标项带到对齐位置,条目内精确偏移要等其已经布局。如果备用高度估算值,文字字号、多语言与图片异步加载都会带来偏差。FoldAnchor Lab 的列表项使用稳定的摘要高度,所以 12 vp 能一次恢复;不定高项则需要在目标项尺寸稳定后再做一次校正。
1. 数据更新和布局切换要有同一个裁决顺序
列表锚点恢复很容易在静态 Demo 中显得完美,一接入真实数据同步就开始偏。原因是布局切换和列表刷新各自都对,两者同时发生却没有规定谁先生效。例如开始折叠时捕获了 index 36,同步通知紧接着在头部插入一条置顶笔记。如果先按旧 index 恢复,再用新数组刷新,页面会在恢复后再向下挪一行。
FoldAnchor Lab 的规则是:锚点捕获后不冻结数组,但恢复前必须对最新数组再做一次noteId -> index解析。这样列表可以正常接收更新,锚点又不会被旧 index 绑死。如果同步结果删除了note_036,恢复策略会从捕获时的相邻 id 中选择仍存在的一个,并把原因记为ANCHOR_MISSING。它不会将这种业务变化误报成滚动 API 失败。
这里还有一个实际取舍:恢复期间要不要锁住列表。我没有锁住整屏,只是在约一帧的窗口中暂时不接受旧容器的滚动回调。新容器就绪后,用户新手势的优先级高于自动恢复,手指一旦落下,待恢复代号立即失效。这比“必须恢复完才允许操作”更符合用户预期。
2. 16 ms 不是定时器的值,而是一段可观测路径
最初我在窗口回调和scrollToIndex周围各打一条日志,两个时间点相减得到 12 ms,看起来已经很快。录屏慢放却仍能看到顶部闪帧,因为这 12 ms 只测了方法调用,没有测到新列表首帧何时真正呈现。
后来的指标起点是控制器提交稳定的SINGLE / DUAL意图,中间记录旧容器销毁、新容器尺寸就绪、锚点解析和两次滚动调用,终点是遮罩移除并进入RESTORED。restore=16ms因此是完整路径的耗时,不是某个人为写死的延迟值。
跳顶计数也不靠肉眼。恢复期间如果可见首项短暂变为 index 0,即使下一次回调马上回到 36,jumpTop也会加一。这个指标让自动化回归能抓到“最终位置正确,中间曾经闪错”的短暂状态,也避免了不同人对“是不是抖了一下”的主观争论。
DevEco Studio 图中的项目目录不再是一个通用的pages / model / utils,而是按问题拆成anchor、layout、metrics和页面层。中间代码显示note_036 / 36 / 12vp,右侧模拟器停在 672 vp 单栏恢复结果,底部 HiLog 记录840 -> 672 -> 840 vp、RESTORING -> RESTORED和restore=16ms jumpTop=0。
五、把“定位正确”和“视觉稳定”分开验收
最终运行页没有只显示一个“恢复成功”的绿色对勾。它把锚点 id、索引、偏移、宽度路径、恢复耗时和跳顶次数放在同一屏。这样才能区分两类问题:锚点对了但首帧抖了,或者首帧没抖但恢复到了错的条目。
16:12 的纯手机截图中,页面状态是RESTORED,会话fold_20261001_09,当前宽度 840 vp,宽度路径840 -> 672 -> 840 vp。核心卡片显示note_036、index 36、offset 12 vp,恢复 16 ms,跳顶 0。红色批注只指向业务锚点和跳顶计数,它们分别证明“内容没换”和“首帧没闪”。
我给这个页面加了四组重复用例。第一组在 index 36 静止切换 20 次,偏移误差不超过 1 vp;第二组在快速滚动中折叠,取窗口稳定前最后一次合法锚点;第三组在切换期间插入置顶笔记,note_036仍然恢复正确;第四组删除当前锚点,页面回退到相邻条目并记录ANCHOR_MISSING,不进入无限重试。
六、这类修复最容易被忽略的边界
第一,不要把物理折叠态直接等同于页面布局。分屏、自由窗口和横竖屏都会改变实际可用宽度。FoldAnchor Lab 最终以窗口的 vp 宽度决定SINGLE / DUAL,折叠姿态只是诊断信息,不是唯一分支条件。
第二,列表键必须稳定。如果ForEach使用 index 作为 key,数据插入以后组件身份本身就错位,再精确的滚动锚点也只是恢复到一个被错认的组件。
第三,恢复期间要处理用户新手势。用户如果在RESTORING阶段已经主动滚动,新手势应取消旧锚点,而不是恢复逻辑在下一帧又把列表拉回去。
第四,不要在每次onScroll中持久化锚点。本轮的锚点只用于跨布局切换,保存在页面级内存中就够了。只有需要跨进程恢复阅读位置时,才应在滚动停止后做节流持久化。
第五,恢复遮罩不能成为隐藏慢布局的黑布。如果耗时超过一帧,要进一步分析 ListItem 构建、图片解码与同步计算,而不是把遮罩保留 300 ms 就当问题不存在。
第六,字体缩放要作为独立回归维度。用户把系统字体调大后,列表项高度发生变化,相同的 12 vp 偏移仍然有意义,但按固定行高推算出的绝对位置已经不可信。因此测试不能只在默认字号下做 20 次折叠,还要覆盖大字号、英文长标题和图片延迟加载。只要条目高度会在恢复后变化,就应在尺寸稳定后再做一次小范围校正,而不是把误差当成折叠动画的正常现象。
第七,多窗口下锚点不能是全局单例。同一个笔记应用可能同时打开两个窗口,它们各自阅读不同列表位置。AnchorTracker必须跟页面或窗口会话绑定,窗口销毁时一起释放。如果把它放进进程级容器,一个窗口捕获的note_036可能覆盖另一个窗口的锚点,这类错误在单窗口模拟器里几乎不会出现。
七、折叠屏连续性,往往藏在一帧之内
这次修复没有改数据库,也没有改笔记列表的业务模型,只是把“哪一条在眼前”从List内部状态提成了一份可验证的页面快照。快照在旧布局销毁前捕获,在新布局可用后恢复,再用代号阻止过期任务回写。
实际用下来,我更在意的不是双栏和单栏长得多整齐,而是用户折起设备后,刚才看到的那句话还在原位。fold_20261001_09的 16 ms 未必能在所有页面复制,但note_036 / 36 / 12vp / jumpTop=0这组证据让连续性从“感觉差不多”变成了可以回归的工程指标。
参考资料:
- HarmonyOS 多设备通用适配指南
- HarmonyOS 多设备开发最佳实践
- ArkUI List 组件与 ListScroller API
- HarmonyOS 窗口管理指南