HarmonyOS 7 ArkUI + Navigation:折叠屏动态分栏与状态连续性适配【鸿蒙心迹】 我拿一个很普通的笔记应用FoldNote做了折叠屏适配。真正难的地方并不是把一列改成两列而是设备从折叠态切到展开态时用户刚刚选中的笔记、列表滚动位置、草稿状态和路由关系都不能被“顺手重置”。折叠屏适配经常从“宽度变大了多放一列”开始。这个理解没错但只做这一层很容易得到一个看起来能适配、用起来却不连续的页面。我这次做的 FoldNote 就遇到了这种情况。折叠态下用户在列表里打开编号#1042的《HarmonyOS 7 适配实践》列表已经滚到scrollOffset 268。把设备展开后我希望左侧继续停在原来的列表位置右侧直接显示同一条详情而不是回首页、跳第一条、重新刷新一遍数据。这看起来只是几个变量但背后牵扯的是三个层次窗口空间变化、Navigation 展示模式变化、业务状态保持。三者如果绑得太死折叠一次就像重开页面如果完全不关联宽屏又不能发挥双栏价值。HarmonyOS 的多设备适配思路本身就强调界面随窗口空间动态响应。Navigation 也支持单栏、分栏和自适应等模式。对我来说这次适配的核心不是“识别设备是不是折叠屏”而是识别当前窗口给了我多少可用空间然后决定信息层级怎么摆同时尽量不破坏用户刚才的操作现场。一、我先删掉了“设备类型决定布局”这条判断早期版本里我写过类似这样的逻辑如果是折叠屏展开态就显示双栏普通手机就显示单栏。后来很快发现这条规则不稳。原因很简单同一台设备也可能处在分屏、自由窗口、横竖屏切换等不同空间里。设备是折叠屏不代表应用永远拿到宽窗口普通平板或大屏窗口也完全可能适合分栏。真正影响页面的是当前窗口尺寸而不是设备名字。所以 FoldNote 后来只保留一个布局判断小窗口走sm显示NavigationMode.Stack进入更宽的窗口后走md切到NavigationMode.Split。文章里的 Demo 为了便于对照把折叠态调试窗口记为宽度598展开后的示例窗口记为1120 × 840。这里的数值只是当前 Demo 的调试输入不应该直接复制成所有业务的标准断点。真实项目应该根据页面最小可读宽度、列表最小宽度、详情内容密度以及产品设计一起确定。这段代码解决什么问题把窗口宽度映射成业务断点而不是把“折叠屏”写死成设备判断。typeBreakPointsm|md|lgclassLayoutPolicy{staticresolve(widthVp:number):BreakPoint{if(widthVp600){returnsm}if(widthVp840){returnmd}returnlg}staticnavigationMode(bp:BreakPoint):NavigationMode{returnbpsm?NavigationMode.Stack:NavigationMode.Split}}这段代码看起来很朴素但它把后面的复杂度压低了。业务页面不再关心当前设备是什么只接收BreakPoint。以后窗口从折叠态进入展开态或者用户把应用拖进更窄的自由窗口本质都是一次BreakPoint变化。真正需要注意的是断点不要只在页面创建时算一次。窗口尺寸是动态的分屏、悬停、旋转、自由窗口都可能改变可用空间。工程里应该把窗口变化监听与布局策略连接起来在尺寸变化时重新计算而不是等用户重新进入页面。二、Navigation 切成 SPLIT 很容易状态不丢才是重点Navigation 的单栏和分栏能力非常适合列表 详情类页面。折叠态下列表和详情顺序进入路由栈展开态下列表可以常驻左侧当前详情显示在右侧。如果只看 UI改mode就够了。但我第一次切换时出现了一个很明显的问题从折叠态展开以后右侧详情回到了默认笔记左侧列表也跳回顶部。这不是 Navigation 本身“丢状态”而是我把selectedNoteId和scrollOffset放在了会跟布局分支一起重建的局部组件里。布局结构变化之后组件实例发生改变业务状态自然跟着重置。后来我给 FoldNote 定了一个原则布局状态可以重算用户状态不能依赖布局实例。布局状态包括breakPoint、NavigationMode、左右栏比例用户状态包括selectedNoteId 1042、scrollOffset 268、draftState SAVED。前者跟窗口走后者跟业务会话走。这段代码解决什么问题把需要跨形态保留的用户状态从页面布局中抽出来。ObservedclassNoteSession{selectedNoteId:number1042scrollOffset:number268draftState:CLEAN|EDITING|SAVEDSAVEDselectNote(id:number):void{this.selectedNoteIdid}updateOffset(offset:number):void{this.scrollOffsetoffset}}EntryComponentstruct Index{StatebreakPoint:BreakPointsmprivatesession:NoteSessionnewNoteSession()privategetcurrentMode():NavigationMode{returnLayoutPolicy.navigationMode(this.breakPoint)}}实际大型项目可以把会话状态放进更合适的状态管理层甚至持久化关键草稿。这里用NoteSession只是为了强调边界不要把“选中了哪条笔记”写进if (isSplit) { ... }的局部状态里。三、我不再为单双栏写两套页面另一个常见做法是小屏写一套ListPage → DetailPage大屏再单独写一个ListDetailPage。短期看很快后面维护会很痛苦。因为列表筛选、选中态、详情工具栏、收藏状态都会出现两份。业务改一次两个页面都要改其中一边漏掉很快就会变成“折叠态和展开态行为不一致”。FoldNote 后来把“列表内容”和“详情内容”拆成可复用组件Navigation 只负责容器形态和路由关系。这样单栏时详情作为目标页面进入分栏时同一份详情内容直接显示在右侧。这段代码解决什么问题同一份列表与详情组件在 Stack 和 Split 两种布局中复用。BuilderfunctionNoteListPane(session:NoteSession){Column(){Text(我的笔记).fontSize(28).fontWeight(FontWeight.Bold)// 列表组件根据 session.selectedNoteId 绘制选中态// 滚动变化时调用 session.updateOffset(offset)}.width(100%)}BuilderfunctionNoteDetailPane(session:NoteSession){Column(){Text(笔记 #${session.selectedNoteId})Text(HarmonyOS 7 适配实践)Text(草稿状态${session.draftState})}.width(100%)}Componentstruct NoteWorkspace{Propmode:NavigationMode session:NoteSessionbuild(){Navigation(){NoteListPane(this.session)}.mode(this.mode).navBarWidth(40%).minContentWidth(360)}}示例为了突出核心关系把 NavDestination 和 NavPathStack 的完整路由注册省略了。真实工程里依然应该使用统一路由栈管理详情而不是只靠条件渲染拼出“看起来像导航”的效果。当前 HarmonyOS 的 Navigation 分栏能力除了 Stack、Split还提供 Auto 等自适应模式并能控制 navBar 宽度、范围和最小内容宽度。对业务来说这意味着“40/60”不是唯一答案。FoldNote 之所以固定左 40%、右 60%是因为列表标题和摘要需要足够空间而详情正文更需要连续阅读宽度。这张 DevEco 截图是我最关心的一张。中间代码里只有一个关键判断sm走STACK否则走SPLIT右侧模拟器已经是双栏底部日志继续保留selectedNoteId1042和scrollOffset268。也就是说布局变了用户状态没变。四、折叠态最容易忽略的是“返回”语义在小屏里用户从列表点进详情后返回就是回列表。这是很自然的栈式导航。但展开成双栏后列表和详情同时存在。如果这时候仍然机械地执行“返回上一页”体验可能会变得奇怪右侧详情消失左侧列表还在用户会疑惑自己到底退到了哪里。我的处理方式是把“系统返回”和“业务关闭详情”区分开。折叠态下详情属于当前导航栈顶部返回正常 pop。展开态下左侧列表属于工作区稳定结构右侧详情是当前选中项的内容视图。此时如果用户点击列表里的其他条目只更新selectedNoteId不制造一长串重复详情路由。这样左右两栏的关系更像“主从视图”而不是把手机导航栈生搬到宽屏。这也是为什么我说多形态适配不能只做 CSS 意义上的排版。屏幕变宽以后信息架构和交互语义也可能需要一起调整。五、折叠的一瞬间真正危险的是重复初始化把设备从展开态合上时页面会经历布局空间变化。最开始我在断点回调里做了太多事情重新拉列表、重新加载详情、重新初始化搜索条件。结果就是每折叠一次页面都闪一下网络层还多一次请求。后来我把断点回调限制得很死它只更新“布局需要知道的状态”不主动重跑业务初始化。类似这样privateonWindowWidthChanged(widthVp:number):void{constnextLayoutPolicy.resolve(widthVp)if(nextthis.breakPoint){return}console.info([LAYOUT]${this.breakPoint}-${next})this.breakPointnext// 不在这里重新请求笔记列表// 不重置 selectedNoteId// 不把 scrollOffset 归零}这段代码解决的不是布局而是副作用。响应式页面里最容易出现的性能问题之一就是把“尺寸变化”错误理解成“页面重新进入”。如果用户只是在折叠设备业务数据本身并没有失效就不应该默认重新请求。在折叠态截图中我故意把选中项#1042、断点sm、Navigation STACK、scrollOffset 268都放在同一屏。红圈标的是《HarmonyOS 7 适配实践》这条笔记。它的意义是建立“切换前现场”后面展开后应该还是这条而不是重新选中第一条。六、展开后我只验四个状态适配完成以后我没有用“看着差不多”作为验收而是给形态切换做了四个非常具体的断言。第一断点从sm变成mdNavigation 从STACK变成SPLIT。第二selectedNoteId仍然是1042。第三列表scrollOffset仍然是268左侧不是突然跳回第一项。第四草稿状态还是SAVED。如果用户刚才正在编辑则应该保持对应编辑状态而不是因为布局变了就覆盖内容。这四个检查分别对应“布局变了”和“用户现场没变”。只检查前两个还不够因为很多适配 Bug 就藏在滚动、输入、选中态这些细节里。展开态调试页里断点已经是mdNavigation 是SPLIT左右比例40% / 60%设备状态EXPANDED窗口示例值1120 × 840。红圈里仍然是1042与268这就是我这次适配最核心的验收结果。七、悬停态不是“再加一个 if”多形态设备还有悬停态。很多人看到这里第一反应是继续增加分支折叠、展开、悬停三套页面。我的经验是尽量别这么做。悬停态首先还是一个空间问题上下两块区域怎么分配折痕附近是否需要避让关键操作是不是落在更顺手的一侧。真正需要新增的是“布局策略”而不是复制整套业务页面。比如视频、相机、会议这类应用悬停态可能天然适合上下分区但 FoldNote 是阅读与编辑工具强行按上下屏拆内容未必有价值。对它来说悬停时保证正文可读、键盘不遮挡、工具栏可操作比展示一个花哨的上下双区更重要。这也是我对多形态适配越来越明确的一个判断能力支持不等于业务必须使用。设计应该从用户任务出发而不是为了“适配了某形态”制造新的界面复杂度。八、不要把折叠屏适配写成一堆魔法宽度响应式项目做久以后最容易积累的是这种代码if(width700){...}if(width780){...}if(width900){...}过几个月没人知道 780 为什么存在。FoldNote 这次我把断点判断集中到了LayoutPolicy页面只读取语义化的sm / md / lg。如果以后设计把 600 调成 620只需要改策略层。同样左右栏比例也集中管理而不是在各页面散落40%、60%。这类代码量很小却直接决定项目后期还能不能维护。更理想的做法是让断点、最小内容宽度、导航模式、侧栏宽度形成一组可测试的策略。测试输入不同窗口宽度验证输出布局模式而不是每次都靠人工拖窗口观察。九、调试时我会故意做“连续折叠”单次从折叠到展开没有问题不代表真的稳定。我最后会连续做几轮折叠 → 展开 → 折叠 → 横屏 → 展开 → 进入分屏 → 恢复全屏。过程中一直盯四类日志[LAYOUT]、[NAV]、[STATE]、数据请求日志。理想情况是布局日志随着窗口变化发生导航模式按断点切换selectedNoteId与scrollOffset保持稳定而数据请求日志不会每次形态变化都重复出现。如果折叠一次就重新请求接口说明副作用边界还没收好如果列表位置越来越偏说明滚动位置恢复逻辑有累计误差如果双栏切回单栏后返回行为错乱就要检查路由栈与当前选中项之间是不是出现了两份真相。这种连续操作比单纯截图更容易把问题逼出来。十、这类适配最后拼的是“状态设计”做完 FoldNote 以后我对折叠屏适配的理解更偏工程化了。视觉层当然重要。小屏单栏、大屏分栏、悬停态避让、短屏可滚动这些决定页面是否舒服。但真正影响“像不像一个成熟应用”的往往是用户从一种形态切到另一种形态时刚刚做的事情有没有被尊重。用户不关心NavigationMode.Stack还是NavigationMode.Split。他只知道自己刚才在看 #1042列表已经滚到那里草稿也写了一半。展开设备以后这些东西还在体验就是连续的如果全部归零再漂亮的双栏也只是一个会重置的 Demo。所以我现在做多形态适配会把问题顺序改成先确定用户状态接着定义布局状态再决定二者之间允许发生哪些转换。布局可以随空间重算用户状态尽量稳定只有这个边界划清楚折叠屏、平板、分屏和自由窗口才有可能共用一套真正可维护的代码。十一、我会把适配测试写成“状态迁移表”折叠屏最难测的地方是问题往往发生在“变化过程中”而不是某个静态页面。所以我最后没有只列设备清单而是列状态迁移。比如sm/STACK → md/SPLIT、md/SPLIT → sm/STACK、md/SPLIT → 自由窗口窄屏每一种迁移都检查同一组业务状态。对于 FoldNote这组状态就是selectedNoteId、scrollOffset、draftState和当前路由。测试人员不需要理解全部实现只要切换形态后检查四个值有没有被意外重置。这样比“看页面有没有变形”更容易发现连续性问题。我还会特意加入输入场景打开 #1042进入编辑状态输入一段尚未提交的文字然后展开设备。如果这时候因为组件树变化导致输入框重建光标位置、未提交文本甚至输入法状态都可能变化。对于阅读页这只是小问题对于表单、聊天、创作工具就是明显的数据风险。所以状态分层最好再细一点可由数据源重新计算的状态、需要短期保留的会话状态、必须持久化的用户数据。布局重建可以丢第一类缓存却不应该随意碰后两类。十二、调试工具也要跟着多形态思路走这次我在页面底部加“开发者调试信息”并不是准备把它留给正式用户而是因为多形态问题特别适合做现场观测。只看 HiLog 时你知道窗口宽度变了却不一定知道页面当前到底绑定了哪条笔记只看 UI又不知道 Navigation 当前模式和路由栈。把两类信息临时放在同一屏定位速度会快很多。我习惯把调试字段控制在少数几项当前断点、Navigation 模式、窗口宽高、selectedNoteId、scrollOffset、draftState。字段太多反而失去重点。出现问题时先截图再去日志里查同一时间点通常就能判断是布局策略错了还是业务状态错了。另外DevEco Studio 的布局分析能力也很适合在这类问题里使用。视觉上看起来“被挤没了”的组件可能其实仍然存在只是约束、最小宽度或父容器尺寸不合理。把组件边界和属性拉出来看比继续试数字有效得多。多形态适配做得越深我越觉得调试界面本身也是工程能力的一部分。它不一定进入正式包但在开发阶段能把不可见的状态暴露出来能显著减少“反复折叠然后凭感觉猜”的时间。参考资料华为开发者鸿蒙应用多设备通用适配指南https://developer.huawei.com/consumer/cn/multidevice/adaptive-apps/华为开发者Navigation 分栏开发https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/arkts-navigation-split-mode华为开发者折叠屏设计原则与体验连续性说明https://developer.huawei.com/consumer/cn/doc/doccenter-ux-design/design-principles-0000001957023989华为开发者SplitLayout 与响应式布局相关参考https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/api/ohos-arkui-advanced-splitlayout