折叠屏页面最容易在“看起来已经适配”的时刻暴露问题。展开态下,编辑区和预览区左右排列很规整;进入悬停态后,组件重新分区,输入框里的草稿却被重建,底部诊断条也可能压到折痕附近。继续转动设备时,折叠状态、窗口状态和旋转信息又不是同一时刻抵达,页面会短暂经历几组互相矛盾的组合。

这次把问题收缩到一个文章排版 Demo:HoverComposerLab。它使用 HarmonyOS 7 / API 26 新增的 FoldSplitContainer 组织主编辑区、预览区和额外诊断区,但重点不在“会用一个新组件”,而在三个工程判断:内容状态不能绑定分区组件的创建次数;折痕避让不能靠写死高度;迟到的悬停回调不能覆盖新一代窗口状态。

演示任务固定为 FSC-1118-402,报告 ID 为 hover_guard_402。页面在 11:18 记录到悬停态,11 项适配检查中完成 9 项,进度 82%;17 个表单字段全部保留,当前状态为 STABLE_HOVER。这些数字是本文演示数据契约,不冒充真实设备跑分。

一、三分区变化,不等于三份业务状态

FoldSplitContainer 提供 primary、secondary 和可选的 extra Builder,并分别接收展开态、悬停态和折叠态布局选项。API 26 的接口还包含 onHoverStatusChange,回调状态中可以观察折叠状态、是否悬停、应用旋转角度和窗口状态。这个组件解决的是不同形态下的区域组织,不会替业务决定草稿应该存在哪里、哪个异步结果仍然有效。

如果把标题、正文、封面选择和发布选项都写进 primary 子组件的局部 @State,分区被替换或条件分支重新创建时,状态就可能回到初始值。另一个常见做法是每次 onHoverStatusChange 都把当前内容拷贝一遍,看似保险,实际会产生两个真相来源:编辑组件一份,页面容器又一份,回调顺序稍有变化就互相覆盖。

HoverComposerLab 只保留一份 ComposerDraft,由页面级状态仓管理。三个 Builder 只接收可观察引用和当前布局快照,不持有独立副本。这样从展开态切到悬停态时,变化的是内容在哪个区域显示,不是内容本身。

页面约定如下:展开态中,编辑区与预览区按 2:3 比例横向分布,诊断区位于底部;悬停态中,编辑区在上、预览区在下,额外诊断区仍显示在底部;折叠态只保留编辑区和一个紧凑预览入口。比例是演示策略,并非系统固定值,实际项目应根据内容密度、最小触控区域和目标设备测试调整。

二、先固定数据契约,再让布局消费它

第一段代码解决“区域重建导致草稿丢失”的问题。DraftStore 用不可变快照保存 17 个字段,任何写入都增加修订号。Builder 不直接修改原对象,而是通过 patch() 生成新快照。这样状态变化可以被日志追踪,悬停回调也不需要复制表单数据。

interface ComposerDraft {
  title: string
  summary: string
  body: string
  coverPath: string
  category: string
  tags: string[]
  publishOptions: Record<string, boolean>
  extraFields: string[]
}

@ObservedV2
class DraftStore {
  @Trace draft: ComposerDraft
  @Trace revision: number = 1

  constructor(initial: ComposerDraft) {
    this.draft = structuredClone(initial)
  }

  patch(next: Partial<ComposerDraft>): void {
    this.draft = { ...this.draft, ...next }
    this.revision += 1
  }

  countPreservedFields(): number {
    const fixed = 7
    return fixed + this.draft.extraFields.length
  }
}

@ComponentV2
struct EditorRegion {
  @Param store: DraftStore

  build() {
    TextInput({ text: this.store.draft.title })
      .onChange((value: string) => this.store.patch({ title: value }))
  }
}

示例中的 17 个字段由 7 个固定字段与 10 个扩展字段组成。countPreservedFields() 只是演示验收计数,真实项目应按字段标识逐个比对,而不是把非空数量当作完整性。数组和嵌套对象也要避免原地修改,否则观察机制可能无法形成清晰的修订边界。

生命周期上,DraftStore 的存活范围应高于三个区域组件,但不必无限提升到应用全局。把它放在页面级 LocalStorage 或页面持有的可观察对象即可。离开编辑任务后,应清理临时资源与订阅;若草稿需要跨进程或重启恢复,再引入持久化,不要因为折叠适配顺手把所有输入都永久存储。

三、FoldSplitContainer 只描述分区,不承载业务分支

下面代码解决“三种形态使用不同区域比例,又不复制三套页面”的问题。三个 Builder 始终指向同一个状态仓。extra 只显示诊断信息,不承担保存按钮等关键操作;即使某种形态不显示额外区域,核心流程仍然完整。

import {
  FoldSplitContainer,
  ExtraRegionPosition,
  PresetSplitRatio,
  HoverModeStatus
} from '@kit.ArkUI'

@ComponentV2
struct HoverComposerPage {
  @Local store: DraftStore = new DraftStore(makeInitialDraft())
  @Local hoverSnapshot: HoverSnapshot = HoverSnapshot.initial()

  @Builder primaryRegion() {
    EditorRegion({ store: this.store })
  }

  @Builder secondaryRegion() {
    PreviewRegion({ store: this.store })
  }

  @Builder extraRegion() {
    HoverDiagnosticBar({ snapshot: this.hoverSnapshot })
  }

  build() {
    FoldSplitContainer({
      primary: () => { this.primaryRegion() },
      secondary: () => { this.secondaryRegion() },
      extra: () => { this.extraRegion() },
      expandedLayoutOptions: {
        horizontalSplitRatio: PresetSplitRatio.LAYOUT_2V3,
        verticalSplitRatio: PresetSplitRatio.LAYOUT_1V1,
        extraRegionPosition: ExtraRegionPosition.BOTTOM
      },
      hoverModeLayoutOptions: {
        horizontalSplitRatio: PresetSplitRatio.LAYOUT_1V1,
        showExtraRegion: true,
        extraRegionPosition: ExtraRegionPosition.BOTTOM
      },
      foldedLayoutOptions: {
        verticalSplitRatio: PresetSplitRatio.LAYOUT_1V1
      },
      onHoverStatusChange: (status: HoverModeStatus) => {
        this.acceptHoverStatus(status)
      }
    })
  }
}

这里使用的枚举和值来自 API 26 的当前接口声明。若项目使用不同 API 级别,应先检查 SDK 声明与设备支持范围,不能仅凭导入成功就假定目标设备具备相同行为。组件的布局参数也不应该由业务页面散落维护,最好放进一个可测试的布局策略对象。

额外区域的位置只表示分区策略,不能自动证明折痕安全。悬停态下,上下区域之间的物理折痕、系统安全区和当前窗口尺寸仍需结合真实设备观察。诊断条使用底部区域,是因为它可以被隐藏且不影响主流程;标题输入、发布按钮等关键控件不应贴近折痕或依赖额外区域才能操作。

DevEco Studio 风格配图展示同一数据契约:工程目录是 HoverComposerLab,中心代码为 FoldSplitContainer 与 acceptHoverStatus,右侧模拟器显示任务 FSC-1118-402、进度 82% 和 STABLE_HOVER,底部 HiLog 显示 generation=42、fields=17/17、lateDropped=2。它是演示图,不是实际 IDE 截图或真机证据。

四、折叠、旋转、窗口变化不是同一个事件

进入悬停态时,系统可能先报告折叠状态变化,再报告窗口尺寸或旋转变化;退出悬停态时顺序也可能不同。若每个回调都立即重算布局、保存草稿、请求预览刷新,短时间内会触发多次无效工作,旧预览结果还可能覆盖新形态页面。

HoverComposerLab 不把单个事件直接解释为最终状态。它建立一个 80 ms 的稳定窗口:每次观察到新状态只更新候选快照并增加 generation;定时器到期后,如果代次仍一致,再提交为稳定快照。80 ms 是演示值,需要通过设备日志调整,而不是通用标准。

第三段代码解决“迟到回调覆盖新形态”的问题。状态对象只提取业务真正需要的字段;generation 同时交给预览任务,结果返回时必须匹配当前代次。

interface HoverSnapshot {
  foldStatus: string
  isHoverMode: boolean
  appRotation: number
  windowStatus: string
  generation: number
}

private candidate?: HoverSnapshot
private settleTimer: number = -1
private generation: number = 41

private acceptHoverStatus(status: HoverModeStatus): void {
  const generation = ++this.generation
  this.candidate = {
    foldStatus: String(status.foldStatus),
    isHoverMode: status.isHoverMode,
    appRotation: status.appRotation,
    windowStatus: String(status.windowStatusType),
    generation
  }
  if (this.settleTimer >= 0) clearTimeout(this.settleTimer)
  this.settleTimer = setTimeout(() => {
    if (!this.candidate || this.candidate.generation !== generation) return
    this.hoverSnapshot = this.candidate
    this.requestPreview(generation)
    this.settleTimer = -1
  }, 80)
}

private applyPreview(result: PreviewResult): void {
  if (result.generation !== this.hoverSnapshot.generation) {
    this.lateDropped += 1
    return
  }
  this.preview = result
}

aboutToDisappear(): void {
  if (this.settleTimer >= 0) clearTimeout(this.settleTimer)
  this.settleTimer = -1
}

定时器创建与释放必须成对出现。页面消失后如果仍让回调写状态,不只会造成日志噪声,还可能把旧页面数据写入复用的仓库。预览任务无法物理取消时,代次校验是最低限度的逻辑隔离;若底层支持取消,还应同时发送取消信号,节省计算与内存。

五、82% 是检查覆盖率,不是折叠动画进度

运行页把 11 项检查拆成“区域结构、状态驻留、折痕避让、事件收口”四组。当前已完成 9 项,进度是 9 / 11 = 81.8%,显示为 82%。未完成的两项是真机旋转压力测试和分屏状态复核,因此状态写成 STABLE_HOVER,而不是“全部通过”。

页面顶部状态栏固定为 11:18、Wi‑Fi、5G、信号和 81% 电量。摘要区显示任务 FSC-1118-402、报告 hover_guard_402、字段保留 17 / 17、当前代次 42。红色细圈只标记 82% 与 17/17,目的是让读者把检查覆盖与数据驻留分开理解。

03 是总览页。它回答的是“悬停态现在是否稳定、还有多少检查没做”。不能在这张页面塞进所有回调字段,否则用户只会看到一块日志墙。真正的事件顺序和迟到结果应放到诊断页。

六、诊断页记录状态组合,而不是猜系统顺序

详情页展示稳定快照:foldStatus=HALF_FOLDED、isHoverMode=true、appRotation=0、windowStatus=FULL_SCREEN、generation=42。演示中 6 次密集通知被收口为 1 次稳定提交,2 个旧代次预览被拒绝,17 个字段仍全部保留。

这些枚举文字是演示适配层输出,真实日志应同时保留原始枚举值,避免 SDK 命名变化后无法还原。详情页顶部仍是 11:18 和 81% 电量,任务 ID 与报告 ID不变;与 03 的区别很明确:03 展示结果,04 展示形成结果的事件证据。

日志建议只保留一次状态转换需要的字段:taskId、generation、foldStatus、isHoverMode、rotation、windowStatus、draftRevision、lateDropped。每个子组件都打印一遍相同对象没有价值。若要分析时序,应给同一次事件链使用同一个 generation,而不是依赖毫秒时间戳猜关联。

七、折痕避让不是把中间空出固定像素

折叠设备存在不同形态、方向和窗口状态,固定写一个 24 vp 或 40 vp 的空白无法覆盖所有情况。FoldSplitContainer 提供的分区是更高层的布局工具,但关键控件仍应遵循安全区、窗口尺寸和设计规范。标题输入、拖拽把手、确认按钮不放在区域边缘;预览内容允许裁切时,也要明确裁切策略。

在悬停态中,主编辑区需要保住最小输入高度,预览区可以降低信息密度,诊断区则可以完全隐藏。这个优先级比追求固定 1:1 更重要。实际验收至少覆盖:从展开态带草稿进入悬停、悬停时旋转、悬停转折叠、后台回前台、分屏后退出分屏、大字体与键盘弹出。

键盘是容易遗漏的一层。悬停态上半区编辑时,软键盘可能进一步压缩可用高度。页面应保证当前焦点字段可见,并避免因为键盘尺寸变化再次清空草稿。若键盘触发窗口状态变化,也应进入同一个稳定快照逻辑,而不是另开一套互相竞争的布局开关。

八、能力边界与验收结论

FoldSplitContainer 减少了三种设备形态之间的重复布局代码,但不会自动保存业务状态,也不会替应用处理异步结果。onHoverStatusChange 给出观察入口,不意味着每次回调都应该立即执行业务。布局比例、额外区域位置和动画参数仍需要在目标设备上测试。

本文演示没有声称已经通过真实设备矩阵。图片中的 82%、17/17、generation 42 和两个迟到结果,是为了让代码、日志与页面可以互相核对的固定样例。真实项目应由自动化日志与人工设备测试产生数据,再替换演示记录。

这次改造最终形成三条可复用规则:业务状态放在分区之上;设备状态经过短暂稳定窗口再提交;所有异步结果按代次验收。做到这三点后,悬停适配才从“组件换了位置”变成“内容、事件和资源都有明确边界”。

九、参考资料

  • 华为开发者联盟,ArkUI API 26 FoldSplitContainer 新增接口与布局选项:https://developer.huawei.com/consumer/cn/doc/doccenter-release-notes/js-apidiff-arkui-b031
  • 华为开发者联盟,多设备布局基础与折叠屏窗口状态:https://developer.huawei.com/consumer/cn/doc/doccenter-ux-design/design-layout-basics-0000001795579413
  • 华为开发者联盟,多设备通用适配指南:https://developer.huawei.com/consumer/cn/multidevice/adaptive-apps/
Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐