这篇文章讨论一个很容易被"响应式布局已经适配"掩盖的问题:折叠屏从窄窗切到展开态后,页面确实变成了双栏,商品列表却跳回第一条。布局没有溢出,控件也没有错位,但用户刚刚浏览到的第 37 条内容消失了。
本文使用 AnchorBoard 演示工程和 CatalogContinuityPage 页面,把这个问题拆成一次可复现的布局事务。演示任务 ID 为 FOLD-ANCHOR-0064,断点从 md 切换到 lg,切换前首个可见索引为 37,稳定锚点为 sku-1037,条目顶部相对视口偏移为 18vp。文中的数值用于说明验收方法,不冒充真实商业项目的线上测试结果。

一、先看结果:布局变了,阅读位置没有变
AnchorBoard 的目标并不是记住一段绝对滚动距离,而是记住用户正在看的业务对象。窗口从 md 变到 lg 后,左侧列表宽度变化,文字重新换行,单个卡片高度也会跟着变化。此时把旧的像素偏移原样写回,理论上就已经不可靠。
修复后的演示状态链是 STABLE_MD → CAPTURING → RELAYOUT_LG → RESTORED。切换前 firstVisible=37,恢复后仍为 37,anchorId 仍是 sku-1037,relativeOffset 保持 18vp。连续到达的五次尺寸事件只提交一次布局事务,generation 从 23 增加到 24。
如果不做处理,双栏布局创建新 List 后会从 initialIndex 的默认值开始,诊断页记录 firstVisible 37→0。这个结果不能算系统组件故障。List 只负责当前组件树里的滚动,应用主动替换结构时,阅读位置的所有权也回到了应用。
真正需要保存的是三件事:稳定业务 ID、可见索引的临时提示、锚点相对视口的位置。索引方便快速定位,业务 ID 用来抵抗数据插入和排序变化,相对偏移负责恢复用户看到的那一小段上下文。
二、为什么绝对 offset 在断点切换后会失真
窄窗中列表卡片宽度较小,标题可能占三行;展开后左栏变宽,同一标题变成两行。第 37 条之前只要有十几个卡片高度发生变化,旧的累计 offset 就无法指向原来的内容。数据没有变化,几何条件已经变化。
List 的 currentOffset 可以描述当前时刻的滚动位置,却不能天然跨越另一套布局结构。官方文档也提醒,使用 initialIndex 时,未参与布局的子组件大小可能只能估算。把估算值当成跨断点的稳定坐标,会让误差随列表长度增加。
相反,业务 ID 不受组件高度影响。应用可以先用 sku-1037 在新数据中寻找索引,再调用 ListScroller.scrollToIndex 将它带回可视区域,最后用小范围 scrollBy 补回 18vp。恢复过程分成"找对象"和"校正位置"两步,故障更容易定位。
还要注意排序变化。若尺寸切换期间刚好完成了一次筛选,旧 index=37 可能对应另一件商品。恢复逻辑必须先查 anchorId;找不到时再降级到邻近索引,并把 degraded=true 写入诊断,而不是悄悄跳到一个看似接近的位置。
三、给视口快照定义最小数据模型
这段代码解决什么问题:把可恢复信息从 List 组件状态中抽离,形成不依赖具体布局树的视口快照。
ts
interface ViewportAnchor {
taskId: string
generation: number
breakpoint: string
anchorId: string
visibleIndex: number
relativeOffsetVp: number
capturedAt: number
}
class AnchorStore {
private latest?: ViewportAnchor
save(value: ViewportAnchor): void {
this.latest = { ...value }
}
read(taskId: string): ViewportAnchor | undefined {
if (this.latest?.taskId !== taskId) {
return undefined
}
return this.latest
}
clear(): void {
this.latest = undefined
}
}
快照没有保存整个商品对象,也没有保存页面组件引用。这样做可以避免旧组件树被状态对象间接持有。taskId 防止另一个页面误用快照,generation 用来拒绝过期恢复,capturedAt 则用于限制快照有效期。
relativeOffsetVp 不是列表总偏移,而是锚点条目顶部相对 List 视口顶部的位置。演示值为 18vp,意味着 sku-1037 顶部距离可视区域顶部 18vp。这个小范围值在卡片重排后仍然有意义。
容易出错的是把 visibleIndex 当作最终事实。它只是捕获瞬间的数据位置。实际工程中如果列表支持插入、删除、置顶和筛选,恢复时必须以 anchorId 重新索引。只有数据源版本未变化时,才可以直接复用索引。
四、捕获时机要避开惯性滚动
List 的 onScrollIndex 会告诉应用可见区域的起止索引,适合持续更新候选锚点。不过折叠动作可能发生在惯性滚动过程中,最后一次回调并不一定代表用户真正停留的位置。
AnchorBoard 维护 currentFirstVisible 和 currentOffsetCandidate。收到尺寸变更后,不立刻重建页面,而是先把当前候选冻结为 CAPTURING。若 List 仍处于 Fling,允许等待一个很短的稳定窗口;超过预算则使用最近一次可见信息,不能无限阻塞布局。
这段代码解决什么问题:把连续滚动回调转成可用于布局事务的稳定锚点,同时避免保存过期 generation。
ts
class ViewportProbe {
private firstVisible: number = 0
private relativeOffsetVp: number = 0
private dataGeneration: number = 0
updateVisible(first: number, offsetVp: number, generation: number): void {
if (generation !== this.dataGeneration) {
return
}
this.firstVisible = first
this.relativeOffsetVp = offsetVp
}
resetForData(generation: number): void {
this.dataGeneration = generation
this.firstVisible = 0
this.relativeOffsetVp = 0
}
snapshot(taskId: string, breakpoint: string, ids: string[]): ViewportAnchor {
const safeIndex = Math.max(0, Math.min(this.firstVisible, ids.length - 1))
return {
taskId,
generation: this.dataGeneration,
breakpoint,
anchorId: ids[safeIndex] ?? '',
visibleIndex: safeIndex,
relativeOffsetVp: this.relativeOffsetVp,
capturedAt: Date.now()
}
}
}
为什么要把数据 generation 和布局 generation 分开?数据列表可能在尺寸切换前后更新,布局也可能被多次触发。两者合成一个数字后,很难判断快照失效的原因。演示为了简化图片只展示 layout generation 24,代码内部仍建议分别维护。
捕获阶段不应执行网络请求,也不应重新计算所有条目高度。它只是读取已有状态并生成轻量快照。超过一帧的大量工作会放大折叠动画期间的卡顿。
五、GridRow 负责结构,事务协调器负责连续性
GridRow 与 GridCol 很适合按 sm、md、lg 断点改变列跨度。它们解决"空间怎么分",却不负责"旧 List 的用户上下文怎么迁移"。如果把恢复逻辑散落在每个 GridCol 的显示分支中,连续尺寸事件会产生多次 scrollToIndex。
AnchorBoard 把断点变化先交给 LayoutTransactionCoordinator。相同目标断点的事件会合并;新的目标断点到来时,旧事务 generation 失效。只有当前 generation 能从 RELAYOUT_LG 提交为 RESTORED。
这段代码解决什么问题:把断点切换组织为可取消的单次事务,避免五次尺寸事件触发五次滚动恢复。
ts
class LayoutTransactionCoordinator {
private generation: number = 23
private timer: number = -1
schedule(target: string, work: (generation: number, target: string) => void): void {
this.generation += 1
const current = this.generation
if (this.timer >= 0) {
clearTimeout(this.timer)
}
this.timer = setTimeout(() => {
if (current !== this.generation) {
return
}
work(current, target)
this.timer = -1
}, 80)
}
cancel(): void {
this.generation += 1
if (this.timer >= 0) {
clearTimeout(this.timer)
this.timer = -1
}
}
}
80ms 是演示防抖窗口,不是平台固定建议。窗口过短可能无法合并铰链变化与窗口重算,过长又会让布局显得迟钝。项目应通过事件时间线确定预算,并在不同设备上验证。
页面销毁时必须调用 cancel。只清除 timer 不增加 generation 仍不够,因为 work 可能已经进入异步阶段。generation 增加后,即使旧恢复回调继续执行,也没有资格提交状态。

开发示意图中的左侧工程包含 ViewportAnchor.ets、ViewportProbe.ets、LayoutTransactionCoordinator.ets 和 CatalogContinuityPage.ets。中间代码标出了 anchorId、generation 与 scrollToIndex;右侧模拟器显示 FOLD-ANCHOR-0064、RELAYOUT_LG 和 82%;底部 HiLog 使用同一组 firstVisible=37、anchorId=sku-1037 与 events=5→1。
这张图是与正文数据一致的演示配图,不是实际设备测试证据。真正验收仍应记录设备形态、系统版本、窗口宽度和数据源版本。
六、恢复必须等新 List 完成布局
旧 List 销毁和新 List 创建不是同一个瞬间。若在 breakpoint 状态刚改变时立即调用 scrollToIndex,新 List 可能尚未绑定 ListScroller,调用没有效果;或者锚点条目已经出现,但卡片高度还没有稳定,随后又发生一次偏移。
恢复分为三步。第一步在新数据中查找 anchorId,得到目标索引 37。第二步无动画 scrollToIndex(37, false, ScrollAlign.START),确保目标条目进入视口。第三步在布局稳定后补偿 relativeOffsetVp=18。
这段代码解决什么问题:在新组件树绑定完成后恢复业务锚点,并对过期事务与缺失条目做降级处理。
ts
class AnchorRestorer {
constructor(private scroller: ListScroller) {}
restore(
anchor: ViewportAnchor,
ids: string[],
currentGeneration: number,
onDone: (index: number, degraded: boolean) => void
): void {
if (anchor.generation !== currentGeneration) {
return
}
const matched = ids.indexOf(anchor.anchorId)
const degraded = matched < 0
const target = degraded
? Math.max(0, Math.min(anchor.visibleIndex, ids.length - 1))
: matched
this.scroller.scrollToIndex(target, false, ScrollAlign.START)
this.scroller.scrollBy(0, anchor.relativeOffsetVp)
onDone(target, degraded)
}
}
实际代码中,restore 的调用点应放在新 List 已绑定控制器之后。不同页面结构可以通过组件回调、状态机或一帧后调度完成,不能把 setTimeout 固定时长当成通用布局完成信号。
scrollBy 的方向要以当前 List 坐标约定验证。若条目顶部要下移 18vp,正负号不能靠经验猜测。演示应同时记录恢复前后 itemRect,确认相对位置,而不是只看索引一致。
七、运行页把"连续性"变成可见状态

手机运行图时间为 07:34,状态栏包含 Wi‑Fi、5G、信号与 71% 电量。页面标题 AnchorBoard,任务 ID 为 FOLD-ANCHOR-0064,当前断点 md→lg,状态 RELAYOUT_LG,进度 82%。
页面显示锚点 sku-1037、firstVisible 37 和 offset 18vp。红色箭头标出"业务锚点,不是绝对距离",另一个标注说明"5 次事件合并为 1 次提交"。这些字段比展示一张正常的双栏截图更有价值,因为它们直接对应恢复算法。
进度 82% 是应用布局事务的阶段权重,不是系统折叠动画进度。CAPTURING 完成后为 30%,新结构建立后为 70%,滚动恢复开始时为 82%,校验完成才进入 100%。没有系统进度回调时,不应把估算值写成平台能力。
生产界面不一定暴露诊断卡,但开发版本最好保留。用户报告"展开后跳走"时,开发者可以先看 anchor 是否捕获,再看新数据是否找到 ID,最后检查滚动提交是否被新 generation 取消。
八、诊断页验证的是位置语义

诊断页同样显示 07:34 和 71% 电量,但内容与运行页不同。状态链为 STABLE_MD → CAPTURING → RELAYOUT_LG → RESTORED。修复前 firstVisible 37→0;修复后 37→37,anchorId sku-1037,relativeOffset 18vp,delta 0,events 5→1,generation 24。
红圈放在 37→37,红色箭头指向 delta 0。delta 0 表示演示中的目标条目相对位置校验通过,不表示所有像素完全一致。字体缩放、系统栏变化和卡片内容更新仍可能改变视觉结果。
日志至少需要 taskId、sourceBreakpoint、targetBreakpoint、anchorId、sourceIndex、resolvedIndex、relativeOffset、generation、degraded 和 commitReason。不要把完整商品标题、用户筛选词或账号信息写入日志。
如果 anchorId 找不到,诊断页应明确显示 DEGRADED_INDEX,而不是仍然写 RESTORED。降级也是结果,但它的可信度与按业务 ID 恢复不同。
九、测试矩阵要同时覆盖布局、数据与交互
第一类测试只改变窗口宽度,数据保持不动。分别覆盖 md→lg、lg→md、连续往返和悬停态窗口抖动,检查事务是否合并、最终断点是否正确。
第二类测试在 CAPTURING 与 RESTORED 之间插入数据。目标 ID 前新增三条后,目标索引应从 37 变成 40,但 anchorId 不变;若仍强行恢复到 37,测试必须失败。
第三类测试在切换期间删除锚点。系统应选择邻近索引并标记 degraded=true,不能抛出越界异常,也不能静默回到顶部。数据为空时,List 在新版本中的可见回调可能返回 -1,业务逻辑要显式处理空态。
第四类测试覆盖用户仍在滑动时折叠。恢复后不应继续旧惯性动画,也不能让旧 onScrollIndex 回调覆盖新快照。页面退出、任务切换和数据刷新都要触发 generation 失效。
性能检查关注两件事:尺寸事件数与实际提交数,目标是演示中的 5→1;恢复耗时则从新 List 绑定到校验完成计算。不要把整个折叠动画时间算进应用恢复耗时。
1. 数据更新与布局更新必须分账
最难复现的情况通常不是单纯折叠,而是折叠过程中刚好完成一次分页。假设 CAPTURING 时 sku-1037 位于索引 37,网络响应在 RELAYOUT_LG 前插入了三条推荐内容,新索引会变成 40。只要恢复阶段按照 anchorId 重新查找,用户仍能回到同一对象;若只保存 index,页面就会稳定地跳错三条。
因此诊断快照要同时记录 layoutGeneration 和 dataGeneration。前者判断布局回调是否过期,后者说明列表集合是否变化。dataGeneration 不一致不代表一定失败,它只是要求恢复逻辑重新解析 ID。只有数据源声明"完全替换且不保持连续性"时,协调器才清空锚点。
分页加载也不能被滚动恢复误触发。scrollToIndex 把目标带回可见区域时,可能命中 onReachEnd 或预加载阈值。数据层应区分 USER_SCROLL 与 ANCHOR_RESTORE 两种触发来源,在恢复事务完成前抑制重复分页,随后再根据真实剩余距离决定是否加载。否则一次折叠会意外发起网络请求,新的数据更新又触发第二次恢复。
2. 动态高度条目需要二次校正
列表卡片可能包含网络图片、异步标签或字体缩放。scrollToIndex 执行时,目标条目进入视口,但图片解码后卡片高度再次变化,18vp 的相对位置可能漂移。解决办法不是不断调用 scrollBy,而是给恢复事务一个有限的校正预算。
AnchorBoard 允许最多两次校正。第一次在新 List 初次布局后执行,第二次在目标条目关键资源就绪后读取 itemRect。若误差绝对值小于 2vp,直接提交 RESTORED;超过阈值才补偿。两次之后仍不稳定,则记录 POSITION_UNSTABLE 并停止自动滚动,避免页面在用户眼前持续抖动。
校正期间若用户开始手势滚动,应立即取消事务。用户输入优先级高于程序恢复。继续执行第二次 scrollBy 会产生"手指向下滑,页面却被拉回"的明显冲突。取消日志写 userOverride=true,不把它算作恢复失败。
3. 双栏结构不要复制两个独立列表状态
展开态常见做法是左侧目录、右侧详情。如果开发者为了布局方便,在 md 与 lg 分支各创建一套 ViewModel,就会出现筛选条件、选中项和锚点互相不同步。响应式分支可以拥有不同组件树,但业务状态应该只有一份。
CatalogContinuityPage 将 selectedId、filterKey、dataGeneration 与 ViewportAnchor 放在页面级状态中。窄窗详情页返回后仍能看到 sku-1037;宽窗则把详情放到右栏,不再创建第二份目录数据。这样断点变化只是视图投影变化,不会被误当成一次业务导航。
需要特别处理的是详情关闭。lg 下用户取消选择时,右栏可以展示占位态,左侧锚点不清空;md 下从详情返回目录,则应恢复进入详情前保存的锚点。两个动作视觉相似,语义不同,不能都绑定到同一个 reset 方法。
4. 可访问性焦点与滚动锚点要协作
视口恢复完成后,不应无条件请求焦点到 sku-1037。触摸用户只需要阅读位置连续,强制焦点会触发读屏播报或显示键盘焦点框。只有切换前该条目确实持有可访问性焦点,恢复后才考虑迁移焦点。
焦点恢复也要晚于滚动恢复。先请求焦点可能触发 ArkUI 自动滚动,随后 AnchorRestorer 再 scrollToIndex,两套机制会互相覆盖。建议状态顺序为 POSITION_RESTORED → OPTIONAL_FOCUS_RESTORED → VERIFIED,并给焦点任务使用同一个 generation。
若目标组件在 lg 结构中不再可聚焦,例如条目被改成纯选择状态,则保留滚动锚点即可,把焦点交给列表容器或当前详情标题。连续性不是机械复制旧焦点,而是保持用户任务可继续。
5. 诊断开关必须与正式逻辑解耦
为了展示 firstVisible、offset 和 generation,演示页增加了诊断卡。正式构建可以隐藏卡片,但不能让锚点逻辑依赖卡片存在。所有指标从协调器的只读快照获得,UI 只订阅,不反向修改事务。
日志采用环形缓冲,保留最近 40 条匿名事件。尺寸事件可能非常密集,逐条写磁盘会干扰性能观察。HiLog 输出采样后的阶段变化,完整时间线留在内存,用户主动导出时再生成脱敏报告。
验收报告要区分"组件 API 已调用"和"业务语义已恢复"。scrollToIndex 返回并不等于成功,最终必须读取可见索引与相对位置进行校验。只有 resolvedId=sku-1037、firstVisible=37、delta=0 且 generation=24 同时成立,演示才进入 RESTORED。
十、边界:连续性不是把一切状态都保存下来
视口锚点适合长列表、目录、消息和商品流,不代表所有页面都应保持同一条目。搜索条件切换、用户主动返回顶部、数据集合完全替换时,旧锚点可能已经失去语义,应该清空。
GridRow 的响应式能力负责组件排列,ListScroller 负责当前 List 的滚动控制,Window 尺寸事件负责提供变化事实。本文增加的事务协调器只是应用层组织方式,不是 HarmonyOS 新增系统接口。
快照也不能长期持久化为"恢复一切"的通用缓存。它与 taskId、数据版本和页面生命周期绑定。页面真正离开时清理,进程重启后是否恢复则由产品需求决定。
工程落地时还应约束锚点 ID 的稳定性。若服务端每次分页都重新生成临时 ID,应用无法区分"同一对象换了位置"和"出现了一个新对象"。列表模型需要提供跨排序、跨分页仍稳定的主键;缺少稳定主键时,只能退化为索引与内容摘要组合,并在诊断中明确标记低可信恢复。
自动化测试可以把窗口宽度序列写成固定脚本:720vp、910vp、860vp、940vp、960vp,模拟一次边界附近抖动。断点最终落在 lg,日志允许收到五个原始事件,却只能出现一次 commit generation 24。随后读取可见首项与 itemRect,断言 sku-1037、37 和 18vp,而不是仅比较页面截图。
最后还要检查右到左语言、字体放大与系统显示缩放。这些设置会显著改变卡片高度,却不应改变业务锚点。只要恢复算法以 ID 为主、相对偏移为辅,几何变化会被限制在校正阶段;如果算法依赖总 offset,这些无障碍设置往往会放大偏差。
最值得保留的判断是:多形态适配不止让页面装得下,还要让用户的任务上下文连续。宽度改变后,绝对坐标会失真,稳定业务 ID 才能连接两套布局。捕获、重排、恢复和验证形成一次事务后,"偶尔跳回顶部"才从视觉问题变成可测量、可回归的工程问题。
参考资料(核对日期:2026-10-01):