news 2026/11/4 14:12:30

HarmonyOS 7 Display Kit:折叠姿态抖动下列表锚点保持

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
HarmonyOS 7 Display Kit:折叠姿态抖动下列表锚点保持

一、问题不是“换一套布局”,而是用户刚读到的位置丢了

FoldRecipe 是我最近做的一个菜谱阅读 Demo。普通直板状态下,它就是一列图片加步骤;展开以后,左边保留目录,右边显示做法。最初的适配看起来很顺:监听折叠状态,切换SINGLE_PANE和DUAL_PANE,预览器里也没报错。真正拿折叠设备来回开合,问题才出现——用户读到“低温醒面”这一条,半折叠后列表突然跳到上一屏;再展开一次,选中项还在,但正文顶部已经偏了六七行。

那天的复现任务记为FOLD-1418。页面是FoldAnchorPage,稳定前的原始事件有 7 次,真正应该提交的布局只有 2 次。最终设备处于HALF_FOLDED,内容区是842 × 1096 vp,双栏模式DUAL_PANE;锚点为recipe_042,条目顶部相对视口的偏移量是36 vp。如果只看最后一次尺寸,这些数字都对,问题恰恰发生在中间过程:折叠姿态回调和窗口尺寸回调先后到达,页面被重复重建,旧恢复任务又在新布局上执行。

我后来把适配目标改成一句更具体的话:布局可以切两次,阅读锚点只能按最新 epoch 恢复一次。这句话比“支持折叠屏”更容易写进代码,也更容易验收。

二、从日志反推:两个正确回调叠在一起也会出错

官方的 Display 能力提供display.isFoldable()、display.on('foldStatusChange')和配套的off;窗口侧又会通过windowSizeChange告知内容区变化。它们分别都没问题,麻烦在于它们描述的是同一次物理动作的不同侧面。铰链经过临界角度时,姿态可能先变成HALF_FOLDED,窗口随后才完成重排;也可能先收到尺寸变化,再收到最终姿态。

第一版代码在两个回调里都直接调用applyLayout()。7 个原始事件就会产生 7 次ListScroller.scrollToIndex(),其中有 5 次已经属于旧布局。HiLog 里能看到状态交错:CAPTURING_ANCHOR还没结束,下一轮RESTORING已经启动;旧任务最后完成时,把新任务刚恢复好的36 vp又覆盖成旧值。

这不是简单加一个 200 毫秒防抖就能解决。防抖只决定“什么时候执行”,不能证明异步恢复回来时仍然是当前任务。我需要同时解决三件事:保存变化前的锚点、合并同一批姿态与尺寸事件、拒绝已经过期的恢复结果。

三、先把监听做成可释放的协调器

第一个改动解决“回调从哪里来、什么时候结束”。我没有在页面里注册匿名函数,而是把两个函数引用保存下来。官方文档也特别提醒,注册监听时反复创建匿名函数会让注销失去对应对象,长期进出页面可能留下监听。

下面这段是 Demo 的FoldSignalCoordinator.ets。它不碰 UI,只把姿态和尺寸归并成一个快照,并给每批事件分配递增的epoch。

import { display, window } from '@kit.ArkUI' export interface FoldSnapshot { epoch: number foldStatus: display.FoldStatus widthVp: number heightVp: number } export class FoldSignalCoordinator { private epoch: number = 30 private timer: number = -1 private status: display.FoldStatus = display.FoldStatus.FOLD_STATUS_UNKNOWN private size: window.Size = { width: 0, height: 0 } private readonly foldListener = (value: display.FoldStatus): void => { this.status = value this.schedule() } private readonly sizeListener = (value: window.Size): void => { this.size = value this.schedule() } constructor(private mainWindow: window.Window, private commit: (snapshot: FoldSnapshot) => void) {} start(): void { display.on('foldStatusChange', this.foldListener) this.mainWindow.on('windowSizeChange', this.sizeListener) } private schedule(): void { if (this.timer !== -1) clearTimeout(this.timer) this.timer = setTimeout(() => { const ui = this.mainWindow.getUIContext() this.epoch += 1 this.commit({ epoch: this.epoch, foldStatus: this.status, widthVp: ui.px2vp(this.size.width), heightVp: ui.px2vp(this.size.height) }) this.timer = -1 }, 120) } stop(): void { if (this.timer !== -1) clearTimeout(this.timer) display.off('foldStatusChange', this.foldListener) this.mainWindow.off('windowSizeChange', this.sizeListener) } }

这里的 120 毫秒不是通用常量,而是本次设备矩阵里“同一动作的两类回调基本收齐”的经验值。真正保证正确性的不是延迟,而是epoch。每次归并后才加一,页面只认最后一次提交。尺寸换算也使用当前窗口的UIContext,不把旧窗口的密度缓存带过来。

start()和stop()必须成对出现。页面进入后台并不一定销毁,但onWindowStageDestroy()一定要停止窗口相关监听;若产品要求后台仍追踪姿态,也应该把监听上移到更长生命周期的对象,而不是让已不可见的页面继续持有Scroller。

四、锚点不是下标,而是业务 ID 加视口偏移

第二个改动解决“到底恢复什么”。只记列表下标不够稳定:展开态会在左侧插入分组标题,过滤条件也可能改变数组。FoldRecipe 用业务 IDrecipe_042找回条目,再恢复它相对视口顶部的36 vp,这样列表结构变化时仍有明确语义。

interface ReadingAnchor { recipeId: string offsetVp: number } @ObservedV2 export class FoldAnchorStore { @Trace state: string = 'READING' @Trace layoutMode: string = 'SINGLE_PANE' @Trace epoch: number = 30 @Trace staleDropped: number = 0 @Trace restored: number = 0 anchor: ReadingAnchor = { recipeId: 'recipe_042', offsetVp: 36 } capture(visibleId: string, offsetVp: number): ReadingAnchor { this.state = 'CAPTURING_ANCHOR' this.anchor = { recipeId: visibleId, offsetVp } return this.anchor } begin(snapshot: FoldSnapshot): number { this.state = 'COALESCING' this.epoch = snapshot.epoch this.layoutMode = snapshot.widthVp >= 720 ? 'DUAL_PANE' : 'SINGLE_PANE' return this.epoch } accept(epoch: number): boolean { if (epoch !== this.epoch) { this.staleDropped += 1 return false } return true } }

状态链被固定为READING → CAPTURING_ANCHOR → COALESCING → RESTORING → STABLE。这对调试很有帮助:如果看到RESTORING后又回到COALESCING,说明窗口还没稳定;如果staleDropped一直增加,说明阈值太短或者页面重复注册了监听。

在这次任务里,7 次原始事件归并成 2 次提交,旧 epoch 丢弃 5 次,最终epoch=31。staleDropped=5并不表示失败,相反,它说明旧任务确实被识别并阻断。真正异常的是crossEpochCommit大于 0——那意味着旧恢复已经写进新布局。

五、恢复动作要等布局落地,还要允许找不到条目

第三个改动落在页面。layoutMode改变后,ArkUI 需要一个布局周期生成新的子节点。我让恢复动作排到下一帧,再检查 epoch;恢复前重新用 ID 查询下标,条目已被筛掉时回到最近分组,而不是用-1调用滚动接口。

@Entry @ComponentV2 struct FoldAnchorPage { @Local store: FoldAnchorStore = new FoldAnchorStore() private scroller: ListScroller = new ListScroller() private recipes: Array<Recipe> = loadRecipes() private async applySnapshot(snapshot: FoldSnapshot): Promise<void> { const anchor = this.store.capture('recipe_042', 36) const epoch = this.store.begin(snapshot) this.store.state = 'RESTORING' await new Promise<void>((resolve) => setTimeout(resolve, 0)) if (!this.store.accept(epoch)) return const index = this.recipes.findIndex((item) => item.id === anchor.recipeId) if (index < 0) { this.scroller.scrollToIndex(0, true, ScrollAlign.START) this.store.state = 'STABLE' return } this.scroller.scrollToIndex(index, true, ScrollAlign.START) this.scroller.scrollBy(0, -anchor.offsetVp) this.store.restored += 1 this.store.state = 'STABLE' } build() { Column() { Text(`任务 FOLD-1418 · ${this.store.state}`) Text(`${this.store.layoutMode} · epoch ${this.store.epoch}`) List({ scroller: this.scroller }) { ForEach(this.recipes, (item: Recipe) => { ListItem() { RecipeRow({ recipe: item }) } }, (item: Recipe) => item.id) } } } }

这段代码还有两个边界。第一,scrollBy()的单位要和页面记录的偏移保持一致,不能一边存 px、一边按 vp 恢复。第二,真实项目不要把recipe_042写死,应从可见区域回调或业务埋点里得到首个有效条目;示例固定它,是为了让正文、日志和运行图可以被逐项核对。

DevEco Studio 的这次调试里,左侧项目目录能看到FoldSignalCoordinator.ets、FoldAnchorStore.ets和FoldAnchorPage.ets;中间停在accept(epoch)前的保护分支;右侧模拟器显示HALF_FOLDED / DUAL_PANE;底部两行日志分别记录事件归并与锚点恢复。图里的任务号、尺寸和正文使用的是同一批数据,不是另外跑的一套示例。

六、我最后验收的不是动画,而是四个不变量

这类问题很容易被“看起来没跳”误导。动画把滚动遮住,不代表状态正确。我最后固定了四个验收项。

第一,当前布局只能由最新 epoch 写入,crossEpochCommit=0。第二,recipe_042在折叠前后都位于视口顶部下方36 vp,允许 2 vp 的渲染误差。第三,进入并退出页面 20 次后,折叠监听和窗口监听都只存在一份,销毁时注销数量为 2。第四,条目在过滤后不存在时,页面回到安全位置并产生ANCHOR_MISSING诊断,不崩溃、不滚到随机下标。

最终运行状态是STABLE:原始事件 7、提交 2、丢弃旧任务 5、恢复 2、错误 0。手机运行图保留了最有价值的诊断字段,而不是只展示一张漂亮的菜谱页面。

七、这套做法适合什么,不适合什么

它适合折叠、旋转、自由窗口缩放都会触发结构切换的长列表,也适合阅读器、商品目录和设置页。核心不是折叠屏专用控件,而是“信号归并 + 业务锚点 + epoch 提交”这三个约束。

但它不应该被滥用成全局防抖框架。视频预览、相机画面这类需要实时响应姿态的页面,120 毫秒可能已经太慢;瀑布流高度会在图片解码后继续变化,仅恢复一次偏移也不够,还需要在资源加载完成后进行二次校正。页面若使用LazyForEach,还要确认目标条目已经构建,否则应先滚动到下标,再在可见回调里微调偏移。

这次复盘让我更确定:多形态适配最难的部分通常不是“选哪套布局”,而是切换过程中谁有资格提交状态。把这个资格写成 epoch,再把监听和页面生命周期对齐,很多偶发跳动才会从“玄学复现”变成可以统计、可以阻断的工程问题。

八、把偶发跳动变成可以重复的测试脚本

修复后我没有只靠手工开合设备。手工操作的速度、停留角度和窗口动画每次都不一样,很难判断是代码稳定了,还是这一次刚好没有踩中时序。FoldRecipe 增加了一组调试注入点:测试脚本不伪造系统折叠状态,而是把协调器的“姿态输入”和“尺寸输入”抽成可记录事件,再按真实设备采样到的间隔重放。原始序列是 0 毫秒收到HALF_FOLDED,18 毫秒收到旧尺寸,46 毫秒收到新尺寸,93 毫秒出现一次重复姿态,随后又有三次窗口微调。这样每轮都能稳定得到 7 个原始事件。

重放时我观察三个阶段。首先是捕获阶段,锚点必须在layoutMode修改之前读取,否则记录的是新布局中的位置。其次是合并阶段,120 毫秒窗口内的新事件应该取消旧定时器,不能堆出多个待执行闭包。最后是恢复阶段,即使旧 Promise 比新 Promise 更晚完成,accept(epoch)也要让它只增加staleDropped,绝不能操作Scroller。这三个阶段分别对应“数据是否正确”“调度是否唯一”“提交资格是否仍有效”,定位问题时不会再混成一句“列表跳了”。

测试还覆盖快速返回。用户在折叠动画尚未结束时离开页面,stop()会取消未触发的定时器并注销两个监听;已经进入下一帧的恢复任务仍可能回来,因此页面还会维护一个disposed标记。生产代码中的accept()同时检查 epoch 与 disposed,页面销毁后的回调只记一条调试日志。这个细节没有放进核心示例,是因为它属于宿主生命周期而非锚点算法,但实际工程里不能省略。

另一个容易漏掉的场景是系统自由窗口。窗口尺寸改变并不一定伴随折叠状态变化,反过来折叠状态变化也不保证内容区立刻获得最终尺寸。协调器因此不能等待“两种信号必须同时到齐”,它只保留各自最近值,在静默窗口结束时生成快照。非折叠设备上display.isFoldable()返回 false,代码只注册窗口监听,foldStatus维持 UNKNOWN;布局仍可以按宽度断点切换,锚点机制照常工作。

九、为什么没有把状态全部塞进页面装饰器

最初我把姿态、窗口宽度、锚点、epoch 和计数器都写成页面的@State。这样做代码短,但任何字段变化都可能触发组件刷新,刷新又会改变列表可见范围,最终让“记录锚点”本身干扰锚点。现在只有需要展示的state、layoutMode、epoch进入观察系统,定时器、监听引用和临时快照留在普通类里。UI 状态负责渲染,协调状态负责时序,两者通过一次commit(snapshot)相交。

这个拆分也便于排查内存问题。页面销毁后,如果协调器仍被窗口监听持有,引用链会很明确;反之把匿名函数、Scroller 和页面闭包绑在一起,泄漏只会表现成“进出几次以后越来越卡”。压力测试连续进入页面 20 次、执行 40 次开合,活动监听始终是 2,退出后回到 0。线上只聚合rawEvents/committed/staleDropped/crossEpochCommit,不持续打印每次窗口微调,避免日志反过来影响时序。

参考资料:

  • HarmonyOS Display 屏幕属性与折叠状态监听:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/screenproperty-guideline
  • HarmonyOS 多窗口布局适配:https://developer.huawei.com/consumer/en/doc/harmonyos-guides-V14/multi-window-layout-adapt-V14
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/25 5:06:09

OpenShell:打造统一会话管理的新一代终端工作台

OpenShell 这个名字第一次出现时&#xff0c;我并没有太在意&#xff0c;以为又是一个套了 Web 界面的终端模拟器。直到我把自己的日常运维工作全部迁移到它上面之后&#xff0c;才发现这东西的定位比我想象中要有意思得多。它不是一个简单的 shell 替代品&#xff0c;而是把多…

作者头像 李华
网站建设 2026/10/24 7:48:42

CameraLink接口全解析:从LVDS差分原理到MDR26引脚接线与实战避坑

1. 工业相机高速传输的底层逻辑 搞机器视觉的兄弟对CameraLink肯定不陌生&#xff0c;这套接口标准在工业相机领域已经服役了二十多年&#xff0c;至今仍然是中高端工业相机的首选传输方案。我第一次接触CameraLink是在一个半导体检测项目上&#xff0c;当时用的Basler sprint系…

作者头像 李华
网站建设 2026/11/2 17:12:32

AI IDE试用(一):用TaoToken统一Key跑通VSCode与Trae的Base URL配置

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/25 11:26:55

Lattice HSB相机方案实战:低功耗FPGA高速图像采集与Diamond保留信号技巧

1. 为什么要在Lattice平台上做HSB相机方案 第一次接触Lattice HSB相机方案是在一个工业检测项目里&#xff0c;客户要求用低功耗FPGA实现高速图像采集&#xff0c;同时把数据通过高速串行接口送到后端处理。当时市面上能选的方案不多&#xff0c;Xilinx和Altera的器件功耗和成本…

作者头像 李华
网站建设 2026/11/4 1:09:37

QuickBlue AI应用底座:基于Spring Cloud与JDK 21的微服务化AI工程实践

1. 从一个真实困境说起&#xff1a;为什么“能跑起来的AI Demo”和“能上线的AI应用”之间隔了一整个团队过去一年&#xff0c;我参与过三个不同规模企业的AI应用落地项目&#xff0c;从十几个人的创业团队到上千人的传统企业数字化部门都有。一个反复出现的场景是&#xff1a;…

作者头像 李华
网站建设 2026/11/4 1:29:44

QuickBlue AI应用底座:从Demo到规模化落地的微服务架构实战

1. 从一个真实困境说起&#xff1a;为什么“能跑起来的 AI Demo”和“能上线的 AI 应用”之间隔了一整个团队过去一年多&#xff0c;我参与过好几个企业内部的 AI 项目&#xff0c;从最早的“给客服团队做个知识问答机器人”&#xff0c;到后来的“把大模型能力嵌进审批流、工单…

作者头像 李华