我拿一个很普通的笔记应用 FoldNote 做了折叠屏适配。真正难的地方并不是把一列改成两列,而是设备从折叠态切到展开态时,用户刚刚选中的笔记、列表滚动位置、草稿状态和路由关系都不能被“顺手重置”。

折叠屏适配经常从“宽度变大了,多放一列”开始。这个理解没错,但只做这一层,很容易得到一个看起来能适配、用起来却不连续的页面。

我这次做的 FoldNote 就遇到了这种情况。折叠态下,用户在列表里打开编号 #1042 的《HarmonyOS 7 适配实践》,列表已经滚到 scrollOffset = 268。把设备展开后,我希望左侧继续停在原来的列表位置,右侧直接显示同一条详情,而不是回首页、跳第一条、重新刷新一遍数据。

这看起来只是几个变量,但背后牵扯的是三个层次:窗口空间变化、Navigation 展示模式变化、业务状态保持。三者如果绑得太死,折叠一次就像重开页面;如果完全不关联,宽屏又不能发挥双栏价值。

HarmonyOS 的多设备适配思路本身就强调界面随窗口空间动态响应。Navigation 也支持单栏、分栏和自适应等模式。对我来说,这次适配的核心不是“识别设备是不是折叠屏”,而是识别当前窗口给了我多少可用空间,然后决定信息层级怎么摆,同时尽量不破坏用户刚才的操作现场。

一、我先删掉了“设备类型决定布局”这条判断

早期版本里我写过类似这样的逻辑:如果是折叠屏展开态,就显示双栏;普通手机就显示单栏。后来很快发现这条规则不稳。

原因很简单:同一台设备也可能处在分屏、自由窗口、横竖屏切换等不同空间里。设备是折叠屏,不代表应用永远拿到宽窗口;普通平板或大屏窗口,也完全可能适合分栏。真正影响页面的是当前窗口尺寸,而不是设备名字。

所以 FoldNote 后来只保留一个布局判断:小窗口走 sm,显示 NavigationMode.Stack;进入更宽的窗口后走 md,切到 NavigationMode.Split。文章里的 Demo 为了便于对照,把折叠态调试窗口记为宽度 598,展开后的示例窗口记为 1120 × 840。

这里的数值只是当前 Demo 的调试输入,不应该直接复制成所有业务的标准断点。真实项目应该根据页面最小可读宽度、列表最小宽度、详情内容密度以及产品设计一起确定。

这段代码解决什么问题:把窗口宽度映射成业务断点,而不是把“折叠屏”写死成设备判断。

type BreakPoint = 'sm' | 'md' | 'lg'

class LayoutPolicy {
  static resolve(widthVp: number): BreakPoint {
    if (widthVp <= 600) {
      return 'sm'
    }
    if (widthVp <= 840) {
      return 'md'
    }
    return 'lg'
  }

  static navigationMode(bp: BreakPoint): NavigationMode {
    return bp === 'sm' ? NavigationMode.Stack : NavigationMode.Split
  }
}

这段代码看起来很朴素,但它把后面的复杂度压低了。业务页面不再关心当前设备是什么,只接收 BreakPoint。以后窗口从折叠态进入展开态,或者用户把应用拖进更窄的自由窗口,本质都是一次 BreakPoint 变化。

真正需要注意的是:断点不要只在页面创建时算一次。窗口尺寸是动态的,分屏、悬停、旋转、自由窗口都可能改变可用空间。工程里应该把窗口变化监听与布局策略连接起来,在尺寸变化时重新计算,而不是等用户重新进入页面。

二、Navigation 切成 SPLIT 很容易,状态不丢才是重点

Navigation 的单栏和分栏能力非常适合列表 + 详情类页面。折叠态下,列表和详情顺序进入路由栈;展开态下,列表可以常驻左侧,当前详情显示在右侧。

如果只看 UI,改 mode 就够了。但我第一次切换时出现了一个很明显的问题:从折叠态展开以后,右侧详情回到了默认笔记,左侧列表也跳回顶部。

这不是 Navigation 本身“丢状态”,而是我把 selectedNoteId 和 scrollOffset 放在了会跟布局分支一起重建的局部组件里。布局结构变化之后,组件实例发生改变,业务状态自然跟着重置。

后来我给 FoldNote 定了一个原则:布局状态可以重算,用户状态不能依赖布局实例。

布局状态包括 breakPoint、NavigationMode、左右栏比例;用户状态包括 selectedNoteId = 1042、scrollOffset = 268、draftState = SAVED。前者跟窗口走,后者跟业务会话走。

这段代码解决什么问题:把需要跨形态保留的用户状态从页面布局中抽出来。

@Observed
class NoteSession {
  selectedNoteId: number = 1042
  scrollOffset: number = 268
  draftState: 'CLEAN' | 'EDITING' | 'SAVED' = 'SAVED'

  selectNote(id: number): void {
    this.selectedNoteId = id
  }

  updateOffset(offset: number): void {
    this.scrollOffset = offset
  }
}

@Entry
@Component
struct Index {
  @State breakPoint: BreakPoint = 'sm'
  private session: NoteSession = new NoteSession()

  private get currentMode(): NavigationMode {
    return LayoutPolicy.navigationMode(this.breakPoint)
  }
}

实际大型项目可以把会话状态放进更合适的状态管理层,甚至持久化关键草稿。这里用 NoteSession 只是为了强调边界:不要把“选中了哪条笔记”写进 if (isSplit) { ... } 的局部状态里。

三、我不再为单双栏写两套页面

另一个常见做法是:小屏写一套 ListPage → DetailPage,大屏再单独写一个 ListDetailPage。短期看很快,后面维护会很痛苦。

因为列表筛选、选中态、详情工具栏、收藏状态都会出现两份。业务改一次,两个页面都要改;其中一边漏掉,很快就会变成“折叠态和展开态行为不一致”。

FoldNote 后来把“列表内容”和“详情内容”拆成可复用组件,Navigation 只负责容器形态和路由关系。这样单栏时详情作为目标页面进入,分栏时同一份详情内容直接显示在右侧。

这段代码解决什么问题:同一份列表与详情组件,在 Stack 和 Split 两种布局中复用。

@Builder
function NoteListPane(session: NoteSession) {
  Column() {
    Text('我的笔记').fontSize(28).fontWeight(FontWeight.Bold)
    // 列表组件根据 session.selectedNoteId 绘制选中态
    // 滚动变化时调用 session.updateOffset(offset)
  }
  .width('100%')
}

@Builder
function NoteDetailPane(session: NoteSession) {
  Column() {
    Text(`笔记 #${session.selectedNoteId}`)
    Text('HarmonyOS 7 适配实践')
    Text(`草稿状态:${session.draftState}`)
  }
  .width('100%')
}

@Component
struct NoteWorkspace {
  @Prop mode: NavigationMode
  session: NoteSession

  build() {
    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;右侧模拟器已经是双栏;底部日志继续保留 selectedNoteId=1042 和 scrollOffset=268。也就是说,布局变了,用户状态没变。

四、折叠态最容易忽略的是“返回”语义

在小屏里,用户从列表点进详情后,返回就是回列表。这是很自然的栈式导航。

但展开成双栏后,列表和详情同时存在。如果这时候仍然机械地执行“返回上一页”,体验可能会变得奇怪:右侧详情消失,左侧列表还在,用户会疑惑自己到底退到了哪里。

我的处理方式是把“系统返回”和“业务关闭详情”区分开。

折叠态下:详情属于当前导航栈顶部,返回正常 pop。

展开态下:左侧列表属于工作区稳定结构,右侧详情是当前选中项的内容视图。此时如果用户点击列表里的其他条目,只更新 selectedNoteId,不制造一长串重复详情路由。这样左右两栏的关系更像“主从视图”,而不是把手机导航栈生搬到宽屏。

这也是为什么我说多形态适配不能只做 CSS 意义上的排版。屏幕变宽以后,信息架构和交互语义也可能需要一起调整。

五、折叠的一瞬间,真正危险的是重复初始化

把设备从展开态合上时,页面会经历布局空间变化。最开始我在断点回调里做了太多事情:重新拉列表、重新加载详情、重新初始化搜索条件。结果就是每折叠一次,页面都闪一下,网络层还多一次请求。

后来我把断点回调限制得很死:它只更新“布局需要知道的状态”,不主动重跑业务初始化。

类似这样:

private onWindowWidthChanged(widthVp: number): void {
  const next = LayoutPolicy.resolve(widthVp)
  if (next === this.breakPoint) {
    return
  }

  console.info(`[LAYOUT] ${this.breakPoint} -> ${next}`)
  this.breakPoint = next

  // 不在这里重新请求笔记列表
  // 不重置 selectedNoteId
  // 不把 scrollOffset 归零
}

这段代码解决的不是布局,而是副作用。响应式页面里最容易出现的性能问题之一,就是把“尺寸变化”错误理解成“页面重新进入”。如果用户只是在折叠设备,业务数据本身并没有失效,就不应该默认重新请求。

在折叠态截图中,我故意把选中项 #1042、断点 sm、Navigation STACK、scrollOffset 268 都放在同一屏。红圈标的是《HarmonyOS 7 适配实践》这条笔记。它的意义是建立“切换前现场”:后面展开后,应该还是这条,而不是重新选中第一条。

六、展开后,我只验四个状态

适配完成以后,我没有用“看着差不多”作为验收,而是给形态切换做了四个非常具体的断言。

第一,断点从 sm 变成 md,Navigation 从 STACK 变成 SPLIT。

第二,selectedNoteId 仍然是 1042。

第三,列表 scrollOffset 仍然是 268,左侧不是突然跳回第一项。

第四,草稿状态还是 SAVED。如果用户刚才正在编辑,则应该保持对应编辑状态,而不是因为布局变了就覆盖内容。

这四个检查分别对应“布局变了”和“用户现场没变”。只检查前两个还不够,因为很多适配 Bug 就藏在滚动、输入、选中态这些细节里。

展开态调试页里,断点已经是 md,Navigation 是 SPLIT,左右比例 40% / 60%,设备状态 EXPANDED,窗口示例值 1120 × 840。红圈里仍然是 1042 与 268,这就是我这次适配最核心的验收结果。

七、悬停态不是“再加一个 if”

多形态设备还有悬停态。很多人看到这里第一反应是继续增加分支:折叠、展开、悬停三套页面。我的经验是尽量别这么做。

悬停态首先还是一个空间问题:上下两块区域怎么分配,折痕附近是否需要避让,关键操作是不是落在更顺手的一侧。真正需要新增的是“布局策略”,而不是复制整套业务页面。

比如视频、相机、会议这类应用,悬停态可能天然适合上下分区;但 FoldNote 是阅读与编辑工具,强行按上下屏拆内容未必有价值。对它来说,悬停时保证正文可读、键盘不遮挡、工具栏可操作,比展示一个花哨的上下双区更重要。

这也是我对多形态适配越来越明确的一个判断:能力支持不等于业务必须使用。 设计应该从用户任务出发,而不是为了“适配了某形态”制造新的界面复杂度。

八、不要把折叠屏适配写成一堆魔法宽度

响应式项目做久以后,最容易积累的是这种代码:

if (width > 700) { ... }
if (width > 780) { ... }
if (width > 900) { ... }

过几个月没人知道 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
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐