有段时间我在折叠屏上看一份长文档,遇到一个挺别扭的细节:展开时是左右两栏,刚读到某一段;合起来以后,页面确实切回单栏了,却把我带回了本章开头。布局看起来适配成功,阅读过程却断了。

如果只截展开态和折叠态两张图,甚至很难发现这个问题。它需要开发者真的带着一段阅读状态,连续做“打开文档—向下阅读—加批注—展开—折叠—返回前台”这一串动作,才容易暴露。比起再增加一个复杂页面,我更想把这个转场做得扎实一点。

这次把实验项目叫作 FoldCanvas:一个围绕正文、目录、批注三块区域组织的阅读工作台。文章聚焦两件事:第一,视口宽度变化时让 ArkUI 调整结构;第二,结构改变后仍能找回同一个内容锚点。项目中的 snapshotRepo、ReaderController 是我定义的业务抽象,不是 HarmonyOS 官方直接提供的 API。代码重点展示衔接方法,最终接入存储与渲染实现仍需按工程补全。

一、布局已经切换,用户却丢了位置

最早版本有一个很自然的写法:根据屏幕是否折叠,选择单栏或双栏页面。展开态把目录固定在左边,正文放在右边;折叠态只保留正文,再给目录加一个抽屉入口。看上去清爽,问题也藏在这种“两个页面分别负责一种形态”的分工里。

页面 A 维护 Scroller,页面 B 又维护自己的 Scroller;A 的滚动距离是 1520vp,B 的内容由于字号和换行发生变化,同样的 1520vp 根本指不到原来的句子。这时如果把 A 的 scroll offset 直接抄给 B,页面虽然有滚动动作,用户的阅读位置已经变了。更麻烦的是,改过字号、打开过批注的用户,会遭遇更大的差异。

于是我把验收目标改了:不是“折起来还能显示”,而是“折起来还在读刚才那一段”。 对 FoldCanvas 来说,定义一个阅读现场需要四个可稳定解释的字段:文档草稿 draft_024、章节 ch_07、段落锚点 p_118、阅读进度 63%。批注 12 条、草稿版本 v17 则用于检查侧边栏与正文是否仍指向相同版本。

这里还要区分两种事实。COMPACT、DUAL 描述的是当前布局模式;折叠、半折叠、展开描述的是设备物理形态。两者相关,但不宜直接画等号。窗口可能处于分屏、自由窗口或其它可变尺寸场景,同一个展开设备也可能只给应用一条狭窄的可用区域。业务层应该根据实际可用宽度决定布局,而不是看到设备展开就认定一定要两栏。

二、FoldStatus 负责观察设备,不负责独断页面结构

华为的折叠状态文档提供 display.isFoldable()、display.getFoldStatus() 和 display.on('foldStatusChange') 等能力。它们对于采集设备变化事件、诊断形态非常有价值。但官方多形态实践也提醒过,折叠状态无法充分描述不同设备的窗口布局需求。因此我把它放进诊断通道,而不是唯一的渲染条件。

这段 ArkTS 展示的是如何订阅折叠状态,并在组件退出时解除订阅。它解决的不是换栏本身,而是追踪“用户什么时候折叠了设备”这一事件,方便随后与断点事件、页面状态放在同一条日志里比对。

import { display } from '@kit.ArkUI'
import { hilog } from '@kit.PerformanceAnalysisKit'
import { Callback } from '@kit.BasicServicesKit'

@Component
struct FoldEventProbe {
  @State currentFoldStatus: display.FoldStatus = display.getFoldStatus()
  private readonly onFoldChanged: Callback<display.FoldStatus> =
    (status: display.FoldStatus): void => {
      this.currentFoldStatus = status
      hilog.info(0x0000, 'FoldCanvas', `fold status=${status}`)
    }

  aboutToAppear(): void {
    if (display.isFoldable()) {
      display.on('foldStatusChange', this.onFoldChanged)
    }
  }

  aboutToDisappear(): void {
    if (display.isFoldable()) {
      display.off('foldStatusChange', this.onFoldChanged)
    }
  }

  build(): void {
    Text(`FoldStatus: ${this.currentFoldStatus}`)
  }
}

我会把它当作一个最小观测组件,而不会在 onFoldChanged() 中直接执行“切换页面并重建 Reader”。原因有三点:设备状态变化可能先于布局完成;连续展收时事件节奏未必与渲染帧完全同步;部分设备不是折叠屏,却同样会遇到窗口宽度变化。因此形态事件适合写日志,真正的布局决策应从窗口断点走。

需要特别留意生命周期:监听注册发生在组件出现时,页面销毁时必须取消;否则多次进入页面之后可能重复记录同一条事件。实机验收时还要检查旋转、分屏和屏幕投射,不要只测一次完整展开和一次完全折叠。

三、断点改变的是排列,不是阅读对象

FoldCanvas 的宽度判断以可用窗口为中心。案例使用 520vp 作为业务示例断点:低于阈值进入 COMPACT,较宽时进入 DUAL。这个数字是 Demo 里的产品取舍,不是系统针对所有折叠设备给出的通用标准。在正式产品里还要按字号、无障碍缩放、左右留白和侧栏最小宽度重新验证。

ArkUI 的 GridRow/GridCol 支持以 BreakpointsReference.WindowSize 为基准设置断点。相比手动复制两个完全独立的阅读页,我更希望它只管理排版区域的宽度分配,而 ReaderCard 继续复用同一个 chapterId、anchorId 和批注仓库。

@Entry
@Component
struct WorkspacePage {
  @State layoutMode: string = 'COMPACT'
  @State chapterId: string = 'ch_07'
  @State anchorId: string = 'p_118'
  @State progress: number = 63

  build(): void {
    Column() {
      GridRow({
        columns: 12,
        breakpoints: {
          value: ['520vp'],
          reference: BreakpointsReference.WindowSize
        }
      }) {
        GridCol({ span: { xs: 12, sm: 3 } }) {
          ChapterPanel({ chapterId: this.chapterId })
        }
        GridCol({ span: { xs: 12, sm: 9 } }) {
          ReaderCard({
            chapterId: this.chapterId,
            anchorId: this.anchorId,
            progress: this.progress
          })
        }
      }
      .onBreakpointChange((name: string) => {
        this.layoutMode = name === 'xs' ? 'COMPACT' : 'DUAL'
        hilog.info(0x0000, 'FoldCanvas',
          `layout mode changed: ${this.layoutMode}`)
      })
    }
  }
}

这里的 ChapterPanel 与 ReaderCard 是业务组件名,代码片段有意省略它们的定义。关键点是:同一个 ReaderCard 接收相同的语义状态,不应因为换了分栏数量就重新生成一份业务数据。实际产品可以在紧凑模式把 ChapterPanel 改成弹层入口,而不是机械地把目录直接堆在正文上方,避免小屏第一屏都被导航占满。

我还会给布局改变加一个很明确的日志约定:窗口宽度、旧模式、新模式、当前内容锚点必须在同一条诊断事件中出现。比如这轮示例从展开时的 784vp / DUAL 进入折叠后的 412vp / COMPACT,但 ch_07 / p_118 不改变。事件记录可以帮助判断:究竟是断点没有命中,还是命中后内容恢复出了错。

图 02 中央是 WorkspacePage.ets 的断点处理,左边保留工程目录,右边是紧凑模式的运行页面,底部记录 layout COMPACT width=412vp、snapshot ch_07/p_118 v17 和 anchor restored progress=63%。这些是为了对应后文设计的演示数据,不是声称已经通过真实设备采样获得的性能日志。

四、把滚动偏移改成内容锚点,是这次重构最值钱的一步

只有一个进度百分比还不够。比如文档前面插入了新段落,原本 63% 的位置会发生漂移;同样,单栏与双栏的可见字符数量不同,百分比并不能准确代表用户正在读的那个句子。所以我最终把“段落锚点”作为恢复依据,进度百分比只作为辅助展示。

为了控制对象边界,我做了一个较小的快照结构。这里保存的是阅读现场,而不是 UI 布局本身:

interface ReadingSnapshot {
  draftId: string
  chapterId: string
  anchorId: string
  progress: number
  noteCount: number
  revision: number
  savedAt: number
}

function makeSnapshot(): ReadingSnapshot {
  return {
    draftId: 'draft_024',
    chapterId: 'ch_07',
    anchorId: 'p_118',
    progress: 63,
    noteCount: 12,
    revision: 17,
    savedAt: Date.now()
  }
}

这份对象应在“段落进入稳定可见位置”时更新,而不是在每一个滚动帧都落盘。频繁写入不仅浪费 I/O,还容易产生这样的时序问题:页面进入折叠态后,新布局暂时计算出的可见段落反而覆盖了刚保存的有效锚点。我的策略是让滚动事件先进入内存层的节流缓冲,重要节点——切后台、切章节、形态变换前后——再提交稳定快照。

revision = 17 还有一个作用:解决“页面拿着过期内容,却想回放新位置”。如果服务器或本地编辑器已经把草稿推进到 v18,段落 p_118 被删除,那么简单回放旧锚点可能定位失败。这个时候应该有降级规则:先寻找锚点映射,再尝试最近有效段落,最后才退到章节起点。退回章节起点应被记录为一次恢复降级,不能假装恢复成功。

五、展开后保留哪一段,折叠后也必须保留

我给 ReaderController 约定了两个入口:captureVisibleAnchor() 负责在结构变化前拿到当前内容锚点,scrollToAnchor() 负责在新布局完成后恢复它。两者都是应用侧接口,不是系统自动替开发者完成的动作。页面只负责时序控制,不关心滚动实现细节。

这段代码解决的是“异步恢复期间用户又操作了页面”的情况。每开始一轮恢复,就自增一次 generation。如果先前任务晚返回,发现自己的代次已落后,就不再把旧位置覆盖到新页面。

class ReadingReplayCoordinator {
  private generation: number = 0

  async replay(snapshot: ReadingSnapshot,
    reader: ReaderController): Promise<boolean> {
    const myGeneration = ++this.generation
    await reader.waitUntilLayoutStable()
    if (myGeneration !== this.generation) {
      return false
    }
    const target = await reader.resolveAnchor(
      snapshot.chapterId, snapshot.anchorId, snapshot.revision)
    if (myGeneration !== this.generation) {
      return false
    }
    await reader.scrollToAnchor(target)
    return myGeneration === this.generation
  }

  cancel(): void {
    this.generation++
  }
}

这里有一个经常被跳过的小细节:waitUntilLayoutStable() 必须等待新排版完成。不能在断点事件刚到时就拿旧布局坐标恢复,否则双栏改成单栏后,新的段落高度还没有计算出来,调用滚动会命中旧位置。工程里可以通过组件完成布局后的通知或一次布局提交调度实现等待,但不能把固定 200ms 超时当成可靠协议;机器快慢、文本长短和图片加载都会改变耗时。

另一个边界是用户主动操作。假设页面正在恢复 p_118,用户马上点了目录跳到 ch_08,这时应调用 cancel() 使旧回放作废。否则恢复回调慢半拍,最终会把用户从第八章拉回第七章。这种“异步任务正确执行,却执行在错误时机”的问题,在折叠屏连续交互里尤其常见。

六、两张运行图里,我真正想核对的不是颜色和卡片

图 03 是紧凑模式阅读页。重点不在视觉皮肤,而是页面头部同时放出 COMPACT、412vp,并保留了 ch_07 / p_118、63% 阅读进度、12 条批注和草稿版本 v17。从正文到状态卡应该表达一回事:布局换了,阅读内容没有重新开始。

这张图对应的回归动作是:展开设备,在双栏下打开草稿 draft_024;读到第七章 p_118;保存 12 条批注;缩小窗口或折叠设备;核对 COMPACT 与锚点。没有真实折叠设备时,也可以先改变模拟窗口大小去覆盖断点逻辑,再用具备折叠能力的设备验证系统事件与真实渲染时序。

图 04 则刻意换了一种信息结构,做成回放诊断页。左边是展开态 784vp / DUAL,右边是折叠态 412vp / COMPACT;像素滚动量不复用,内容锚点仍为 p_118。诊断日志在 10:19:02 记录窗口变化和读取 v17 快照,在 10:19:03 记录锚点恢复与进度对齐。这样即使用户说“折叠之后位置丢了”,工程人员也能查出:有没有存锚点、有没有进入回放、有没有最终成功。

我会把这几项写进手工验收表:普通展开到折叠、快速连续展收、后台回来后再折叠、改变字号后展收、图片未加载完全时展收,以及用户在恢复期间主动切章节。这六类路径覆盖的不是同一种 bug。前三类主要测时序与生命周期,后两类测内容重排和冲突控制。只跑一个“展开—折叠”成功视频,远远不够。

七、日志对齐是定位问题的开始,不能代替用户体验

测试过程中我还给日志加了一层约束:每次布局回放都有唯一 replayId,并记录 windowWidth、layoutMode、draftId、chapterId、anchorId、revision 和 outcome。避免有人只凭一句“restore success”就判断问题已经解决。对于段落内容被删除、锚点找不到的情况,应该是 RESTORED_WITH_FALLBACK,不能与正常回放混为一谈。

日志之外,还需要肉眼检查焦点:光标是否意外消失,阅读无障碍焦点有没有跳到页头,批注抽屉是否挡住正文,以及是否出现转场时的白闪。尤其是文字阅读产品,位置正确但闪烁严重,同样算体验问题。界面状态一致性和真实可读性,要分别验收。

关于性能,我不建议凭一张截图写“切换耗时 16ms”一类无法复测的数字。本示例没有提供真实设备上的帧率、渲染耗时与内存曲线,文章也不把拟真图片中的日志当成性能测试依据。如果要做后续调优,应对同一台设备、相同文档与固定展收动作做重复采样,记录长尾,而不是挑最好的一次。

八、实际项目要收住哪些边界

FoldCanvas 的工程取舍其实很克制。不要为每种折叠形态复制一套业务页面,也不要把所有折叠事件都视为必须强制重建布局;首先确认窗口真实可用宽度,再由响应式容器决定结构。内容状态保存稳定的 ID,不保存依赖排版的绝对坐标;回放过程具备取消能力,迟到结果不能覆盖后来的用户操作。

如果有多端同步,快照还要带上文档修订版本与冲突策略。例如手机上读到了 p_118,平板离线时又读到了 p_125,同步时不能简单用“最后写入”代替业务判断。阅读位置可以按最近用户行为合并,批注内容则可能需要独立版本管理。它们都不是 ArkUI 断点系统自动解决的问题。

最后把这篇的结论压缩成一句话:折叠屏适配不是把两栏缩成一栏,而是在屏幕结构变化时,尽可能保住用户已经建立的上下文。 做到这一点,设备形态变化才不会变成任务中断。

九、从一次回放走到长期稳定,还需要解决三种隐藏冲突

在做完整工程时,我会把最后一轮验收拆成三个经常被忽略的冲突场景。第一个是内容数据的修订冲突。如果用户一边修改笔记,一边从单栏切到双栏,阅读页面的布局变化不应该顺带提交一份旧的笔记快照。笔记与阅读位置要有独立的写入版本,前者由编辑事务管理,后者由滚动锚点更新。这样即使两类操作都发生在 10:19:03,也不会互相覆盖。

第二个是页面导航的所有权冲突。假设 WorkspacePage 正在恢复阅读位置,系统同时收到了深链进入另一章的请求。这时导航事件代表明确的用户意图,应该优先级更高。我的处理原则是:深链切章节先取消旧的阅读回放,再更新 chapterId,最后由新章节页面重新读取相应锚点。如果反过来先让旧回放继续,用户就会看到页面短暂进入新章节,随即被拉回旧章节。日志里应记录 replay canceled by navigation,让后续排查有据可查。

第三个是图片与富文本带来的排版高度冲突。一篇纯文字文章恢复很快,可是正文中夹杂了图片、代码块、表格后,段落在布局初期的高度可能不稳定。仅通过一次页面出现回调触发滚动,往往会在图片解码后再次偏移。我会给阅读容器一个“内容已完成本轮测量”的业务信号;如果资源继续增量加载,就在不打断用户主动滚动的前提下修正锚点附近的位置。这里需要慎重限制修正次数,否则页面会像被看不见的手拖拽一样跳动。

还有一种容易遗漏的状态是选中文本。长按选中的句子、正在输入的批注、软件键盘显示状态,都不适合无条件跟随布局快照同步。选区可以在必要时临时收起;尚未提交的批注文本则必须先保存在编辑草稿中。尤其是键盘弹起时,可用窗口高度显著减小,不应因此误判设备折叠或偷偷切成另一套页面。用窗口宽度做结构断点,正是为了降低这种影响。

1. 用可复现脚本代替随手测试

我会给测试同学一条固定动作链:进入 draft_024 的 ch_07,定位到 p_118,新增一条批注使计数稳定为 12,记录当前 v17,随后从 784vp 调到 412vp,再切后台返回。测试前保留真实起始快照,测试后导出回放事件。只要章节、段落锚点、修订版本任何一项出现不一致,就算回归失败,不能只因为页面可见便放行。

这条脚本也适合做自动化的骨架,但自动化测试最好对业务状态进行断言,而不是根据某个像素点截图计算阅读位置。屏幕密度、字体环境和设备实际宽度都会改变像素结果;同一段内容的语义 ID 才是更可靠的断言对象。至于是否需要把目录与批注设为固定侧栏,还应该交给交互设计在不同宽度段做实际阅读测试,而不是一条断点规则决定所有产品体验。

2. 留一条明确的失败路径

我很反对把所有回放失败都吞掉再写“已恢复”。如果 p_118 在新版本中不存在,页面可以降级到同章节的相邻段落,但调试页必须显示 RESTORED_WITH_FALLBACK,并说明原锚点已失效。如果连章节都找不到,应该停在文档目录或显示恢复提示,让用户明确知道当前定位不是之前的阅读现场。可靠的系统不是永远成功,而是在失败时不隐藏事实。

把这些冲突场景加入回归后,FoldCanvas 的目标就不再是几个漂亮的适配截图。它是一条能解释问题发生位置的状态链:设备事件只是线索,窗口断点改变结构,快照锚点承载现场,协调器保证时序,日志与用户可见结果共同构成验收。

参考资料与实施说明

文中两张“运行截图”和 DevEco 图均为与工程方案对应的拟真示意图。项目名、锚点和数据用于解释测试预期;代码中的业务控制器与仓库为需要在实际工程中实现的抽象,不能据此声称已经进行实机验收。

Logo

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

更多推荐