我这次处理的不是“折叠屏怎么做双栏”这种大题,而是一个更容易在真实项目里被忽略的小问题:列表已经滚到八十多条,设备从折叠态切到展开态以后,页面没有回到顶部,但用户正在看的那一条还是挪了位置。
Demo 叫Fold Feed Anchor Lab。我把数据固定成 200 条内容,复现时滚到feed_084,折叠态窗口宽度是 720 vp,展开后变成 1280 vp。第一次看好像没什么——列表还在 80 多条附近;仔细对比会发现,原来靠近屏幕上沿的feed_084被挤到中间去了。对于信息流、收藏夹、长文目录这类页面,这种“没有跳走,但上下文丢了”的感觉其实很明显。
一、真正需要保存的不是 scrollY,而是“谁在这里”
一开始我也想过直接保存滚动距离。后来实际跑了一遍就放弃了。
折叠态和展开态的布局宽度不同,卡片高度会因为标题换行、图片比例、左右留白发生变化。假设折叠态累计滚了 12640 vp,展开后前 83 条内容的总高度已经不是 12640 vp,再把旧的绝对偏移塞回去,结果一定会漂。
所以这个 Demo 把恢复依据拆成三部分:
Anchor ID = feed_084,确认用户当时看到的是哪条数据;First Visible Index = 83,作为快速恢复的索引入口;Offset = 36 vp,记录这条内容距离列表顶部的局部偏移。
这三个值比单独的scrollY更像一个“视觉锚点”。窗口宽度变化后,先把feed_084找回来,再补 36 vp 的局部距离。即使前面的卡片高度都重新计算了,也不会把用户带去另一段内容。
本次运行我固定了这些数据:Session ID = anchor_20261001_08,最终窗口宽度1280 vp,锚点feed_084,首可见索引83,局部偏移36 vp。状态从TRACKING → RESIZE_PENDING → RESTORING → RESTORED。
二、先把“正在看哪一条”稳定记录下来
这里第一个问题,是不要在每一帧滚动时都写 Preferences。滚动过程中首可见项一直变,持续落盘既没必要,也会把恢复状态写得很碎。
我采用的方式是:onScrollIndex只更新内存里的首尾索引;等滚动停止以后,再提交一次锚点快照。官方资料里onScrollIndex本身就是拿可见项索引的重要入口,配合Scroller的定位能力很适合做这类恢复逻辑。
这段代码解决的是“锚点采样频率”问题:
@State private anchorId: string = 'feed_001' @State private firstVisibleIndex: number = 0 @State private offsetVp: number = 0 private scroller: Scroller = new Scroller() private anchorStore: ScrollAnchorStore = new ScrollAnchorStore() private onVisibleRangeChange(first: number, last: number): void { this.firstVisibleIndex = first this.anchorStore.updateVisible(first, last) } private commitAnchor(): void { const item = this.feed[this.firstVisibleIndex] if (!item) { return } this.anchorId = item.id this.anchorStore.commit({ id: item.id, index: this.firstVisibleIndex, offsetVp: this.offsetVp }) }页面里的List只负责把两个时机接起来:滚动过程中更新索引,滚动停止后再提交。
List({ scroller: this.scroller }) { ForEach(this.feed, (item: FeedItem) => { ListItem() { FeedItemView({ item: item }) } }, (item: FeedItem) => item.id) } .onScrollIndex((first: number, last: number) => { this.onVisibleRangeChange(first, last) }) .onScrollStop(() => { this.commitAnchor() })这里有两个细节。第一,ForEach的 key 用数据 ID,不用索引。恢复期间如果数据源做了小范围插入,feed_084仍然能被识别。第二,Demo 把局部 offset 单独维护,正式项目里可以通过组件位置变化、列表容器位置和当前锚点的几何信息计算,不建议把它和整个页面的绝对滚动距离混成一个值。
三、折叠态切换最麻烦的不是 resize,而是连续 resize
实际测试里我发现一次“折叠 → 展开”并不总是只有一个尺寸变化事件。布局重新计算、窗口过渡和系统动画可能连续触发几次。我的这次日志里一共记录了Resize Events = 4。
如果每次宽度变化都立刻scrollToIndex,页面会出现另一种抖动:第一次恢复还没完成,第二次恢复又把列表拉了一次。最后看上去像列表自己在回弹。
因此第二段代码解决的是“合并恢复请求”。我不把窗口变化直接连到 Scroller,而是先进入RESIZE_PENDING,180ms 内只保留最后一次。
private resizeTimer: number = -1 private resizeEvents: number = 0 private droppedRestoreRequests: number = 0 private scheduleRestore(widthVp: number): void { this.resizeEvents++ this.state = 'RESIZE_PENDING' if (this.resizeTimer !== -1) { clearTimeout(this.resizeTimer) this.droppedRestoreRequests++ } this.resizeTimer = setTimeout(() => { this.mode = widthVp >= 1000 ? 'EXPANDED' : 'FOLDED' this.restoreAnchor() this.resizeTimer = -1 }, 180) }最终统计里Resize Events = 4,真正执行恢复Restore Count = 1,另外 3 次请求被合并掉,所以手机运行图里会看到Dropped Restore Requests = 3。
这不是说 180ms 是一个固定标准。它只是当前 Demo 的经验值。正式项目应该结合页面复杂度、折叠动画长度和实际设备测试调整。关键不是 180 这个数字,而是“恢复动作必须串成一次最终提交”。
四、恢复时先找 ID,再相信旧 index
我还专门测试了一个边界:折叠过程中后台数据刚好插入了一条新内容。如果只保存index = 83,展开后第 83 条可能已经不是feed_084。
因此恢复前会先用 ID 做一次修正。旧 index 是快速路径,ID 才是最终身份。
这段代码解决“数据源发生小变化以后如何不恢复错人”:
private restoreAnchor(): void { const snapshot = this.anchorStore.current() if (!snapshot) { return } this.state = 'RESTORING' let targetIndex = snapshot.index if (this.feed[targetIndex]?.id !== snapshot.id) { const found = this.feed.findIndex((item: FeedItem) => item.id === snapshot.id) if (found >= 0) { targetIndex = found } } this.scroller.scrollToIndex(targetIndex, false, ScrollAlign.START) this.scroller.scrollBy(0, snapshot.offsetVp) this.restoreCount++ this.state = 'RESTORED' }先scrollToIndex找到目标项,再用一个很小的scrollBy补局部偏移。这样做还有一个好处:恢复逻辑跟卡片总高度无关。哪怕展开态下标题从三行变一行,前面所有 item 的高度都重排,最终锚点还是围绕feed_084恢复。
这里也有一个现实边界:如果feed_084已经被删除,那就不能假装“精确恢复”。我在正式项目里会降级到旧 index 附近,并把恢复状态标成 fallback,而不是继续显示 RESTORED。Demo 数据固定,所以这次Lost Anchor = 0。
五、DevEco 里我重点盯的是“恢复次数”而不是最终位置
调这个问题时,只看手机界面不够。最终位置正确,并不能证明中间没有恢复四次。
所以我在 HiLog 里固定打印这几组信息:
window width: 720 -> 1280 vp anchor=feed_084 index=83 offset=36vp resizeEvents=4 dropped=3 restoreCount=1 State: RESIZE_PENDING -> RESTORING -> RESTORED这组日志让我能判断两件事:第一,锚点有没有变;第二,连续 resize 有没有真正被合并。如果restoreCount跟resizeEvents一样大,即使界面最后看起来对,也说明实现还在做无意义的重复工作。
我还会在页面上直接暴露调试卡片,显示 Session、模式、窗口宽度、Anchor ID、First Visible Index、Offset、恢复次数。开发阶段多占几十行 UI 没关系,能让一次折叠测试的结果当场可见,比来回翻日志省时间。
六、恢复成功的标准,不是“还在第 80 条附近”
这次最终运行状态是:
Mode = EXPANDEDWindow Width = 1280 vpAnchor ID = feed_084First Visible Index = 83Offset = 36 vpResize Events = 4Dropped Restore Requests = 3Restore Count = 1State = RESTORED
手机图里#084 城市的黄昏仍然是当前锚点,它和折叠前保持的是“阅读语义位置”,不是某个绝对像素值。
实际产品里,我会把验收再做得更严格一些。比如连续折叠展开 20 次,随机在切换过程中插入数据;列表项包含不同长度文本、异步图片和可展开卡片;同时测试从后台回前台以后再发生尺寸变化。只测一个静态列表,很容易把问题做得过于理想化。
七、异步图片加载会把“已经恢复好”的位置再次推走
长列表还有一个很现实的问题:恢复动作完成时,图片可能还没加载完。文字卡片先按占位高度参与布局,随后真实图片解码完成,高度变化又会把feed_084向下推。这样日志明明已经是RESTORED,用户却会在半秒后看到页面又动了一下。
我在 Demo 的第二轮测试里专门把图片延迟拉到 300~800ms。解决方式不是恢复两次,而是尽量让列表项在图片完成前后保持可预测高度:封面图使用固定比例容器,异步内容只替换内部像素,不改变外层几何尺寸。对于确实会动态展开的卡片,则把它当成另一类状态变化,重新评估锚点,而不是偷偷在原来的 RESTORED 后面再滚一次。
这也是我现在判断“锚点恢复是否可靠”的一个条件:恢复以后 1 秒内,锚点项的屏幕位置不应该因为图片、字体或二次测量产生明显漂移。单纯看scrollToIndex调用成功没有意义,最终几何位置稳定才算结束。
八、页面销毁、后台恢复和数据刷新要分开处理
恢复逻辑里还有三个生命周期边界容易混在一起。
第一,页面销毁时必须清掉resizeTimer。否则页面已经退出,180ms 后旧回调还可能访问 Scroller。Demo 在aboutToDisappear中做清理,并把未完成状态从RESIZE_PENDING标记为CANCELLED,正式项目还可以额外带上页面 generation,避免旧任务回写。
第二,从后台回来不等于发生折叠。后台期间如果窗口尺寸没变,我不会主动恢复列表;否则用户刚在后台停留几秒,回来却被重新定位一次,体验反而更怪。
第三,数据刷新要区分“增量插入”和“整表重建”。增量插入还能用 ID 校正 index;如果服务端已经换了一套 feed,原来的feed_084根本不存在,就应该按产品规则降级到顶部、最近阅读位置或分类入口,而不是死守旧 index。
我最后给这个 Demo 定了一个验收表:连续折叠/展开 20 次;每次 resize 触发 3~5 个尺寸事件;随机插入 1~3 条数据;图片延迟加载;前后台切换;页面退出后不再出现恢复日志。只有这些都通过,我才会认为“列表位置保持”从 Demo 走到了可复用组件。
九、为什么我没有选“屏幕中心项”做锚点
还有一个取舍我反复试过:到底保存首可见项,还是保存屏幕中心那一条。中心项看上去更符合“用户正在看哪里”,但在展开态变成双列、卡片高度差异变大以后,中心线对应的 item 很容易变化,而且中心项上方的可见上下文也不稳定。
首可见项虽然不是视觉焦点,却有一个工程优势:它和列表的滚动边界关系最明确,恢复后再补一个局部 offset,就能把用户原来的上下文重新拼出来。对于新闻流、相册流、收藏列表,我更愿意把首可见项作为基础锚点,再根据业务需要额外记录 selectedId、播放中的视频 ID 或正在展开的卡片 ID。
如果页面从单列变成双列,我也不会直接假设index=83仍然是左上角。正式组件里会把布局列数写进快照:columns=1或columns=2。恢复时先判断布局模式是否变化,再把锚点转换到目标行。例如双列情况下row = Math.floor(index / 2),真正滚动的是锚点所在行,而不是机械地按旧 index 定位。Demo 为了把问题聚焦在折叠前后位置保持,没有把双列算法继续展开,但接口已经预留了layoutMode。
这也是我后来把逻辑抽成ScrollAnchorStore + ResizeRestoreCoordinator两个类的原因。前者只负责“我现在看到哪”,后者只负责“什么时候可以恢复”。页面只接收最终状态,不再把定时器、索引、ID、Scroller 调用全塞在一个组件里。等后续换成 Grid 或 WaterFlow,只需要替换锚点定位适配器,而不是把整个折叠屏逻辑重写。
十、这次改动最后留下来的三个判断
做完这个 Demo,我对折叠屏长列表的状态保持有三个更明确的判断。
第一,响应式布局只解决“怎么重新排”,不自动解决“用户刚才看到哪里”。这两个问题必须分开设计。
第二,列表恢复最好保存“业务 ID + 首可见索引 + 局部偏移”,而不是把整个页面当成一根长尺子,只记一个 scrollY。
第三,尺寸变化事件一定要做合并。折叠态切换是一个过程,不是单个瞬时事件。只要恢复动作会修改 UI 状态,就应该考虑重复触发、未完成恢复被覆盖、页面销毁后的定时器清理等生命周期问题。
当前 Demo 在页面销毁时会清理resizeTimer,Scroller 只存在于页面生命周期内,锚点快照则可以按业务需要放在内存或 Preferences。正式项目如果跨 Ability、跨进程恢复,还需要给快照增加数据版本、列表版本和过期策略。
这个问题不算“炫”的折叠屏能力,但实际用下来,它直接决定了多形态切换是不是自然。页面没崩、布局没乱,只是用户刚才看的那一条悄悄跑了位置,这种细节反而最容易暴露适配是否真正做完整。