
半折叠设备上的“按钮看得见却不好点”往往不是手势系统失灵而是交互组件被放进了折痕区域。折痕不是一条抽象分隔线它由系统返回一个或多个矩形其位置会随显示模式、方向和设备形态变化。把某个机型的 y 坐标写进布局只能得到一张看起来正确的截图无法得到稳定的工程实现。本文围绕演示项目CreaseSafe的CheckoutPage展开。示例任务编号为FOLD-CREASE-0056窗口演示尺寸为1276 × 1840 px。设备从EXPANDED进入HALF_FOLDED后折痕矩形为top878 px, height36 px原确认按钮中心位于y896 px恰好落入折痕。布局回流后按钮中心移动到y822 px并保留24 vp安全间距。文中数值来自可复现的演示夹具不冒充特定量产设备的实测结论。一、现象不是“偶尔点不中”而是命中区域放错了在完全展开状态结算页由商品摘要、地址、优惠信息和底部确认区构成。设计稿把主按钮固定在视觉中心偏下的位置这在普通直板屏和展开态上没有问题。半折叠后页面仍能完整绘制按钮也没有被系统裁掉于是第一眼很容易把故障归因于点击节流、手势竞争或透明遮罩。诊断页给出的线索更直接状态从EXPANDED变为HALF_FOLDED折痕上边界是878 px高度为36 px因此禁入区是[878, 914)。按钮中心896 px正好位于禁入区中间。视觉上它仍属于应用窗口物理上却跨过了铰链及屏幕形变最明显的位置。此时继续放大按钮热区只会把错误布局包装成更大的错误布局。这类问题需要拆成三个判定设备是否可折叠、物理折叠状态是否进入目标形态、当前显示模式下是否真的返回折痕矩形。三者不能互相替代。display.on(foldStatusChange)报告的是物理状态变化官方说明中也明确区分了折叠状态与显示模式变化的时序。工程上应在事件到达后重新读取当前折痕而不是缓存启动时的几何数据。另一个常见误区是只读取creaseRects[0]后永久假设设备只有一条折痕。接口返回数组本身就是重要信号布局算法应能够处理零个、一个或多个矩形。单折设备可以只用第一个矩形演示但数据结构不要提前把多折形态排除掉。二、把折痕转换成页面自己的安全区官方 Display API 返回的矩形单位是 px而 ArkUI 布局常用 vp。若直接把rect.top填进.position()密度变化后会出现看似随机的偏移。CreaseSafe选择在一个CreaseMonitor中完成单位转换再把不可交互区发布给页面。这段代码解决什么问题监听折叠状态并在每次变化后读取当前折痕矩形转换为 ArkUI 可用的 vp 数据。import{display}fromkit.ArkUIimport{hilog}fromkit.PerformanceAnalysisKitexportinterfaceCreaseBand{topVp:numberheightVp:numbersourceTopPx:numbersourceHeightPx:number}exportclassCreaseMonitor{privateui?:UIContextprivatecallback?:(status:display.FoldStatus)voidprivatesink:(status:display.FoldStatus,bands:CreaseBand[])voidconstructor(sink:(status:display.FoldStatus,bands:CreaseBand[])void){this.sinksink}start(ui:UIContext):void{if(this.callback)returnthis.uiuithis.callback(status:display.FoldStatus):voidthis.publish(status)display.on(foldStatusChange,this.callback)this.publish(display.getFoldStatus())}privatepublish(status:display.FoldStatus):void{try{if(!display.isFoldable()||!this.ui){this.sink(status,[])return}constregiondisplay.getCurrentFoldCreaseRegion()constbands:CreaseBand[]region.creaseRects.map((rect:display.Rect)({topVp:this.ui!.px2vp(rect.top),heightVp:this.ui!.px2vp(rect.height),sourceTopPx:rect.top,sourceHeightPx:rect.height}))this.sink(status,bands)}catch(e){hilog.error(0x0000,CreaseSafe,read crease failed: %{public}s,JSON.stringify(e))this.sink(status,[])}}stop():void{if(this.callback)display.off(foldStatusChange,this.callback)this.callbackundefinedthis.uiundefined}}这里刻意保留了 px 原值。vp 用于布局px 用于日志和诊断两者职责不同。若日志只记转换后的数字真机问题回放时很难与系统返回值对齐。start()还做了幂等保护避免页面重复出现时注册多份回调。状态变化是EXPANDED → HALF_FOLDED但安全区数组并不保证同步地从空变为非空。系统服务异常会抛出错误非折叠设备也可能返回无折痕语义。示例采用“读取失败时不做自定义避让同时记录诊断”的保守策略业务若把确认按钮放在固定危险位置则还应在安全区未知时切换到普通流式布局而不是维持绝对定位。最容易漏掉的是释放。监听在aboutToAppear()建立就应在aboutToDisappear()中成对解除。仅把页面变量置空并不能移除系统回调重复进入页面后会出现一条事件触发多次重排的现象。三、不要让一次折叠动作触发四次昂贵重排物理折叠、显示模式、窗口尺寸与方向变化可能在很短时间内连续到达。如果每个回调都立即重算页面树用户会看到按钮先跳到上方、又短暂回落、最后再次上移。CreaseSafe将原始事件先合并为一个布局事务演示中四次输入只提交一次layoutEpoch从21增长到22。这段代码解决什么问题把连续到达的折叠与窗口事件合并避免同一形态切换反复提交布局。exportinterfaceCreaseLayoutSnapshot{status:display.FoldStatus bands:CreaseBand[]windowHeightVp:number}exportclassLayoutCommitter{privatetimer:number-1privatepending?:CreaseLayoutSnapshotprivatereadonlycommit:(value:CreaseLayoutSnapshot)voidconstructor(commit:(value:CreaseLayoutSnapshot)void){this.commitcommit}offer(value:CreaseLayoutSnapshot):void{this.pendingvalueif(this.timer!-1)clearTimeout(this.timer)this.timersetTimeout((){constlatestthis.pendingthis.timer-1this.pendingundefinedif(latest)this.commit(latest)},80)}dispose():void{if(this.timer!-1)clearTimeout(this.timer)this.timer-1this.pendingundefined}}这不是为了“拖慢响应”而是为了让一次物理动作只产生一个稳定快照。80 ms 是演示参数不是平台推荐值实际项目要结合转场动画和窗口回调密度调节。状态在offer()时仍属于待确认只有commit()执行后才成为页面使用的布局状态。这里不能使用持续节流只保留第一帧因为第一帧可能只有折叠状态尚未得到最终窗口尺寸。采用尾部合并后提交的是最新快照。反过来若业务包含需要立即停止的危险操作例如录像或支付确认安全逻辑不应等待 80 ms只应把视觉重排延后。dispose()与监听释放同样重要。页面离开后计时器若继续执行会把已经失效的快照写入下一实例。实际项目中还要考虑多窗口窗口尺寸变化不能只靠全局display推断页面应同时接入当前窗口的区域变化并把它合并到同一个快照中。图中的开发界面是与本文数据一致的演示配图不是实际 IDE 截图或真机测试证据。左侧项目树包含CreaseMonitor.ets与CheckoutPage.ets中间突出getCurrentFoldCreaseRegion()和layoutEpoch右侧模拟器展示半折叠态底部 HiLog 对齐任务FOLD-CREASE-0056。四、命中规避不等于把所有内容挤到上半屏折痕避让的核心不是“碰到就上移”而是把组件按职责分组。预览内容适合停留在上半屏表单和主要操作适合落在下半屏若业务必须维持单列阅读才需要计算最近的安全位置。本文的结算页保留商品摘要在上方将主按钮从896 px回流到822 px其余说明区仍在折痕下方起始位置946 px避免整页突然缩成一团。这段代码解决什么问题计算按钮是否与折痕相交并生成带安全间距的目标位置。interfaceVerticalRange{top:numberbottom:number}functionoverlaps(a:VerticalRange,b:VerticalRange):boolean{returna.topb.bottomb.topa.bottom}functionresolveButtonCenterPx(currentCenter:number,buttonHeight:number,creaseTop:number,creaseHeight:number,safeGapPx:number):number{constbutton:VerticalRange{top:currentCenter-buttonHeight/2,bottom:currentCenterbuttonHeight/2}constcrease:VerticalRange{top:creaseTop,bottom:creaseTopcreaseHeight}if(!overlaps(button,crease))returncurrentCenterreturncreaseTop-safeGapPx-buttonHeight/2}// 演示夹具按钮高 64 px安全间距 24 pxconsttargetCenterresolveButtonCenterPx(896,64,878,36,24)// targetCenter 822相交判断使用半开区间思路边界刚好相切不算覆盖。回流公式不是把按钮顶部贴到折痕上边而是同时扣除按钮半高和安全间距所以目标中心为878 - 24 - 32 822 px。这一结果与页面和图片保持一致。若折痕矩形是横向分隔以上算法有效若未来设备返回竖向或多段折痕则应升级为二维矩形避让不能继续只比较 y 轴。算法也不应覆盖系统已有的布局能力。官方悬停适配资料中提到FolderStack可以按上下区域组织内容页面结构符合其模型时优先使用平台组件通常比自维护绝对坐标更稳。自定义计算更适合已有复杂布局或需要明确控制命中区的页面。运行页把关键事实放在同一屏HALF_FOLDED、折痕878–914 px、按钮中心822 px、任务FOLD-CREASE-0056。红色细线只标出折痕禁入带和按钮移动方向不把整张图变成标注海报。顶部状态栏时间为04:08电量64%。五、用诊断页证明状态闭环而不是只证明 UI 好看仅看最终页面无法判断回流是系统自然布局还是自定义算法生效。诊断页需要保留输入、决策和输出输入是状态、窗口和折痕矩形决策是相交检测与安全间距输出是目标位置、事件合并数和命中测试结果。演示夹具记录四个原始事件折叠状态变化、显示模式变化、窗口高度变化和折痕刷新。合并器只提交一次layoutEpoch 21 → 22。随后用七个落在原危险区附近的坐标进行离线命中回放回流前有7次落在折痕带内回流后为0。这不是对某台真机触控准确率的宣称而是对几何算法的确定性测试。这段代码解决什么问题把页面生命周期与监视器、合并器成对绑定避免重复监听和离场后的延迟提交。EntryComponentstruct CheckoutPage{StatefoldLabel:stringEXPANDEDStatebuttonCenterPx:number896StatelayoutEpoch:number21privatemonitornewCreaseMonitor((status,bands){constfirstbands[0]constsnapshot:CreaseLayoutSnapshot{status,bands,windowHeightVp:this.getUIContext().px2vp(1840)}this.committer.offer(snapshot)if(!first)this.buttonCenterPx896})privatecommitternewLayoutCommitter((snapshot){constbandsnapshot.bands[0]this.foldLabelsnapshot.statusdisplay.FoldStatus.FOLD_STATUS_HALF_FOLDED?HALF_FOLDED:EXPANDEDthis.buttonCenterPxband?resolveButtonCenterPx(896,64,band.sourceTopPx,band.sourceHeightPx,24):896this.layoutEpoch1})aboutToAppear():void{this.monitor.start(this.getUIContext())}aboutToDisappear():void{this.committer.dispose()this.monitor.stop()}}为什么先dispose()再stop()因为停止监听前可能已有快照进入计时器队列先取消延迟任务能避免离场后提交。顺序并非平台硬性规定但要保持一致并写进组件契约。若CreaseMonitor被多个页面共享则应使用引用计数或应用级生命周期不能由任意页面直接关闭全局监听。诊断图与运行图明显不同它展示的是4 events → 1 commit、layoutEpoch 21 → 22、命中失配7 → 0以及折痕原始矩形。红圈用于指出一次提交和零失配而不是装饰。六、边界条件决定这套方案能不能进生产第一非折叠设备必须是正常路径而不是异常路径。display.isFoldable()为 false 时返回空安全区页面继续使用流式布局。不要为普通设备打印高等级错误也不要展示“能力不可用”的打扰提示。第二折痕区域不能只在应用启动时读取。方向、显示模式和多折形态都可能改变矩形事件后重读当前值才符合getCurrentFoldCreaseRegion()的语义。与此同时要处理系统服务错误码而不是假设同步调用永不失败。第三布局安全区与触控热区要使用同一份快照。若视觉位置已经更新点击测试仍引用旧坐标故障会从“按钮压折痕”变成“看得见但点不到”。动画期间尤其要明确命中区跟随策略跟随每帧或暂时禁用主操作不能一半新一半旧。第四多折设备不要只认第一个矩形。本文用一个矩形讲清算法但接口的数组结构必须贯穿模型。可以把每个交互组件与所有折痕矩形求交再选择距离最小的安全方向。第五示例中的1276 × 1840 px、878–914 px、80 ms都是演示数据不是通用常量。生产代码要以运行时查询和设计系统令牌为准。文中没有宣称在某型号真机上完成测试进入发布流程前仍需覆盖展开、半折、闭合、横竖屏、分屏及连续快速开合。1. 测试矩阵要围绕形态转换而不只是静态尺寸普通响应式页面常按若干宽度档位截图验收折叠态页面如果沿用这套方法会漏掉最关键的过程问题。应把测试用例写成“起始形态—动作—结束形态—预期事务数”。例如从展开态缓慢折到半折态预期进入一次稳定回流在半折态旋转到横屏预期重新读取折痕并计算新安全区连续开合三次后停在展开态预期最终快照属于展开态计时器队列为空。对每个用例除了保存界面截图还应记录原始折叠状态、显示模式、窗口宽高、折痕矩形数组、密度换算结果和布局代次。这样一旦出现“按钮多跳了一次”可以判断是系统发送了额外事件还是应用重复注册了监听。只有截图而没有事件轨迹团队很容易在动画参数上反复试错却没有触及状态重复提交的根因。几何测试可以脱离设备运行。把resolveButtonCenterPx()当纯函数为“完全在上方、完全在下方、与上边界相切、与下边界相切、完全覆盖折痕、多折痕逐个求交”建立表驱动用例。演示中的896 → 822只是其中一个夹具。纯函数测试不能替代真机但能保证公式修改后不会把已知边界重新破坏。2. 动画、焦点与无障碍需要同一份安全区视觉回流若使用动画焦点迁移不能滞后。主按钮从折痕中移出时键盘焦点、无障碍焦点和触控命中区都应指向新的几何位置。最稳妥的做法是让语义节点与视觉组件保持同一组件身份只改变布局参数不要删除旧按钮再创建一个长得相同的新按钮否则读屏用户可能听到重复节点键盘用户也可能失去当前焦点。如果回流会改变内容顺序需要检查读屏顺序是否仍符合业务逻辑。视觉上把确认区移到折痕上方并不意味着语义顺序也应跑到地址表单之前。对结算页这类高风险操作主按钮在过渡期间还应避免重复点击布局事务未提交时保持原业务状态提交后再恢复交互而不是在动画两端各保留一个可点击副本。3. 观测指标要区分系统事件与业务提交线上日志不要把每个折叠事件都当错误。更有价值的指标是一次形态切换收到多少原始事件、产生多少布局提交、提交用了多久、是否存在离场后提交以及交互组件最终是否与任一折痕相交。前两项的比值可以发现事件合并失效最后一项可以作为开发版断言或自动化巡检。日志中的折痕坐标属于设备几何信息不需要关联账号、订单或内容数据。诊断字段应围绕任务号、页面、窗口和布局代次避免为了排查一个 UI 问题把不相关的业务隐私写入 HiLog。对于高频形态变化可按会话采样或只在状态稳定时落一条汇总防止日志本身制造性能抖动。4. 交付前的最小检查清单代码评审时可以用六个问题快速拦截回归折痕坐标是否来自当前显示模式px 是否只在系统边界与诊断层出现所有监听和计时器是否成对释放页面离开后是否还可能提交快照视觉、命中和语义焦点是否共享同一布局代次多折痕数组是否被完整遍历。任一问题答不上来都说明实现仍依赖隐含假设。验收人员则不需要理解内部类名只需沿任务FOLD-CREASE-0056查看三组事实折痕为878–914 px按钮最终中心为822 px布局代次只增加一次。若设备环境不同应替换几何数值但验证关系不变——按钮矩形不能与任何折痕矩形相交连续事件最终只能留下最新稳定状态。最后把异常降级路径也纳入验收读取折痕失败时页面仍可滚动并完成操作回调注册失败时不会持续重试打满日志普通设备不展示折叠诊断快速退出页面后不再出现提交记录。成功路径证明功能可用降级路径才证明它不会在设备差异中变成新的故障源。七、结语先把系统几何当事实再谈页面美学折叠屏适配容易被做成宽度断点问题但半折叠态真正棘手的是物理结构进入交互区域。Display Kit 给出了状态和折痕矩形ArkUI 负责把这些事实变成布局。两者之间还需要一层稳定的状态事务转换单位、合并事件、计算相交、同步视觉与命中并在生命周期结束时释放资源。CreaseSafe的判断标准很简单任务FOLD-CREASE-0056中按钮中心从896 px移到822 px折痕仍为878–914 px四个原始事件只产生一次提交几何回放的失配从7降为0。这些是演示夹具可复现的结果不替代真机验收却足以把一个“偶尔点不中”的模糊投诉收敛为可验证、可维护的布局规则。参考资料华为 HarmonyOS 多设备自适应应用资料https://developer.huawei.com/consumer/cn/multidevice/adaptive-apps/OpenHarmony Display API 参考getCurrentFoldCreaseRegion、foldStatusChangehttps://github.com/openharmony/docs/blob/master/zh-cn/application-dev/reference/apis-arkui/js-apis-display.md