HarmonyOS 7 ArkUI + Window Kit:折叠屏编辑页的键盘避让快照与焦点恢复时序【鸿蒙心迹】

作者:李游
一次折叠动作之后,编辑框还在,光标也像是存在,输入的文字却落不到原来的位置。这个问题没有崩溃堆栈,单看窗口宽度和键盘高度也都正常。最后是三条相邻日志把时序暴露出来:窗口先变,键盘区域后变,旧页面又抢了一次焦点。

一、06:32,光标被恢复到了"上一版页面"

Demo 名叫 FoldNote Lab ,会话编号是 edit_20261001_06。展开态窗口宽度 840 vp,左侧笔记列表,右侧正文编辑器;折叠以后宽度变为 420 vp,页面切换成单栏。测试动作很简单:把光标放在正文第 18 行,键盘高度 312 vp 时折叠设备,然后继续输入。

现象不是每次都出现。慢慢折叠通常正常,快速合上并立即打字时,第一两个字符偶尔插到标题框;再极端一点,正文滚动到了第 18 行,焦点却已经落到不可见的旧 TextArea。页面看起来只是"键盘闪了一下",实际上同时存在布局代次、键盘避让和焦点归属三个状态。

第一版代码监听窗口宽度,宽度小于 600 vp 就改成单栏;页面 onAppear 里调用 requestFocus('note_body')。两个动作单独都正确,组合起来却有竞态:旧双栏组件还没销毁,新单栏组件已经创建,二者都可能在自己的生命周期回调中请求焦点。

本轮不再讨论列表锚点,也不是跨栏拖拽。工程问题限定为:折叠导致布局树重建时,如何保存编辑快照、让键盘避让使用新窗口数据,并保证只有当前代次能恢复 note_body 焦点。

我把故障现场的日志按毫秒重新排了一遍。06:32:14.120 收到窗口宽度变化,14.137 新单栏组件创建,14.144 旧双栏页面执行了一次迟到的 onAppear,14.151 键盘高度先变成零,14.168 才稳定到 312 vp。原来的焦点请求位于 14.145,正好夹在错误位置。这个排序说明问题不是某个 API 返回错,而是缺少能够淘汰旧事件的会话边界。

为了避免测试只靠肉眼,我在输入法事件后插入字符 K,再读取标题与正文的文本摘要。正确结果是标题摘要不变、正文第 18 行长度增加一;如果只检查页面上键盘仍显示,很容易漏掉字符进入错误输入框的情况。自动化断言因此同时验证 focusKey、selection 和文本变化。

二、先把可恢复内容和瞬时对象分开

我最开始把 TextAreaController 塞进 AppStorage,希望新页面直接接着用。结果控制器绑定的是旧组件实例,布局树重建后继续调用只会让状态更混乱。真正需要保存的是文本、选择区、滚动位置和目标焦点键;控制器、窗口监听器、定时器都属于瞬时资源,不能跨组件复用。

下面这段代码解决"保存了文字,却没有保存编辑语境"的问题。快照是纯数据,可以跨布局形态传递;layoutEpoch 用来判断它是否仍属于当前一次窗口变化。

ts 复制代码
export interface EditorSnapshot {
  sessionId: string
  text: string
  selectionStart: number
  selectionEnd: number
  firstVisibleLine: number
  focusKey: string
  keyboardHeight: number
  layoutEpoch: number
}

export class EditorSnapshotStore {
  private value?: EditorSnapshot

  save(snapshot: EditorSnapshot): void {
    this.value = { ...snapshot }
  }

  take(sessionId: string, epoch: number): EditorSnapshot | undefined {
    if (this.value?.sessionId !== sessionId || this.value.layoutEpoch !== epoch) return
    const current = this.value
    this.value = undefined
    return current
  }
}

take() 是一次性消费,而不是普通 get。新单栏页面恢复成功后,旧页面即使晚到一个 onAppear 也拿不到同一份快照。正式项目如果需要撤销,应另建编辑历史,不能让生命周期快照兼任 undo 数据。

快照中的键盘高度只是观测值,不是新布局必须照搬的 padding。折叠前记录 312 vp,是为了在新避让数据到达前保持编辑区不突跳;一旦新页面测得真实区域,就用新值覆盖。把旧高度长期固定下来,会在横竖屏和浮动键盘场景留下空白。

文本量较大时,保存快照也不能每个字符都复制整篇正文。当前 Demo 在布局事务开始时读取一次最终文本,平时编辑由页面状态持有;如果正文来自文档模型,则快照只保存文档版本号与 selection,恢复时从同一个版本取内容。版本已经变化就放弃自动 selection 恢复,避免把旧光标位置套在新文本上。

选择区需要同时处理中文输入法的组合态。用户正在拼音候选阶段折叠设备时,尚未提交的组合文本不一定属于普通 selection。Demo 的策略是先结束当前输入会话,再捕获已提交内容;正式编辑器若要无感保留组合态,需要输入法能力提供明确支持,不能自行拼接候选字符串。这里宁可少保留一段未确认输入,也不制造重复文字。

三、窗口变化只开启事务,不立即抢焦点

工程目录按事件来源拆分:window 负责宽度与 epoch,keyboard 负责区域测量,editor 负责快照,页面只订阅聚合后的 FoldEditState。

text 复制代码
entry/src/main/ets
├── pages/FoldNotePage.ets
├── components/SplitEditor.ets
├── components/StackEditor.ets
├── editor/EditorSnapshotStore.ets
├── keyboard/KeyboardAvoidCoordinator.ets
├── window/LayoutEpochController.ets
└── model/FoldEditState.ets

第二段代码解决"窗口事件一到就切布局,焦点恢复早于新组件挂载"的问题。控制器把一次折叠处理成事务:先递增 epoch 并保存快照,再更新布局模式,等新组件报告 ready 后才允许恢复。

ts 复制代码
async onWindowWidthChanged(widthVp: number): Promise<void> {
  const nextMode = widthVp >= 600 ? LayoutMode.SPLIT : LayoutMode.STACK
  if (nextMode === this.state.mode) return

  const epoch = ++this.state.layoutEpoch
  this.state.phase = EditPhase.SNAPSHOTTING
  this.snapshots.save(this.editor.capture({
    sessionId: 'edit_20261001_06',
    keyboardHeight: this.keyboard.height,
    layoutEpoch: epoch
  }))

  this.state.focusEnabled = false
  this.state.mode = nextMode
  this.state.widthVp = widthVp
  this.state.phase = EditPhase.WAITING_LAYOUT
  await this.layoutReady.wait(epoch)
  await this.restoreFocus(epoch)
}

这里先关闭 focusEnabled,避免组件自动聚焦。layoutReady.wait(epoch) 不是固定延时:页面根节点 onAreaChange 确认尺寸稳定后,以 epoch 回报 ready。固定 setTimeout(100) 在一台设备上可能有效,换到动画时长不同的设备又会复现。

重复窗口回调也是边界。折叠过程中宽度可能连续变化,只要布局模式仍是 STACK 就不新建事务;若模式再次变化,旧 epoch 的等待会被判定过期。这样 840→590→420 vp 只触发一次模式切换,不会保存三份互相覆盖的快照。

layoutReady 的完成条件也不是"组件已经构建"这么宽泛。它要求根节点宽度与目标模式一致、编辑器可见区域高度大于零,并且本次回调携带当前 epoch。组件构建结束但尺寸仍是旧值时提前放行,后面的 scrollToLine(18) 仍会按双栏高度计算。把 ready 定义成可测量状态,比在生命周期名称上猜测更稳定。

四、键盘避让需要接受"先零、后真实值"

新页面刚挂载时,自定义键盘根节点可能先上报高度 0,随后才到 312 vp。如果把 0 当作最终值,正文会先铺到底部,再被键盘顶起;同时请求焦点,光标滚动就可能以错误的可视高度计算。

这里没有把 Window Kit 的系统栏避让和自定义键盘高度混成一个数。状态栏、导航区域来自窗口避让信息;键盘高度来自键盘组件 onAreaChange。最终底部 padding 取键盘高度与导航区域的合理组合,并保留最小业务间距。

下面这段代码解决"旧 epoch 的键盘回调污染新布局"。协调器只接受当前 epoch,并在连续两次稳定测量后发布;隐藏键盘时则允许零立即生效。

ts 复制代码
export class KeyboardAvoidCoordinator {
  height: number = 0
  private candidate: number = -1
  private hits: number = 0

  update(measuredVp: number, epoch: number, currentEpoch: number): boolean {
    if (epoch !== currentEpoch) return false
    if (measuredVp === 0) {
      this.height = 0
      this.candidate = -1
      this.hits = 0
      return true
    }
    if (Math.abs(measuredVp - this.candidate) <= 1) {
      this.hits++
    } else {
      this.candidate = measuredVp
      this.hits = 1
    }
    if (this.hits < 2) return false
    this.height = measuredVp
    return true
  }
}

连续两次不是通用真理,而是这个 Demo 对自定义键盘动画的工程选择。系统键盘、浮动键盘和分离键盘可能需要不同策略,正式项目应以实际事件源为准。关键是把"测量候选"和"已提交避让值"分开,而不是每个瞬时高度都驱动整页重排。

高度变为零时立即发布,是为了收键盘后不残留大块空白;但页面正在折叠事务中时,零值仍带 epoch 校验。监听必须在组件消失时注销,否则旧 SplitEditor 会继续把自己的 312 vp 写进 StackEditor。

窗口系统栏的避让区域还存在高度为零的合法情况,例如系统元素隐藏或某些窗口形态。代码不能把零一律当异常,也不能继续沿用上一帧系统栏高度。键盘候选需要稳定策略,是因为它参与动画;系统栏则按当前窗口返回值更新。把不同来源放进同一个"非零才更新"函数,正是早期版本留下底部空白的原因。

列表与编辑器并排时,右侧编辑器的可用高度由窗口安全区域和键盘共同决定,左侧列表却不必跟随键盘上移。折叠成单栏后,两者变成同一页面,策略随布局模式改变。这也是协调器输出业务语义 editorBottomInset,而不是向所有组件广播一个全局 bottomPadding 的原因。

开发截图中,左侧展开的是 editor / keyboard / window 三组目录;中间代码停在 restoreFocus(epoch) 的代次判断;右侧模拟器显示 FoldNote Lab 单栏编辑页;底部日志依次是 session=edit_20261001_06、width=840->420vp、mode=SPLIT->STACK、keyboard=312vp、caretLine=18、epoch=7、restore=34ms state=FOCUS_RESTORED。

五、恢复焦点的最后一步必须再次检查代次

layoutReady 返回以后,页面先恢复文本和选择区,再把第 18 行滚进可视范围,最后通过当前 UIContext 的焦点控制器请求 note_body。顺序不能反过来:如果先请求焦点,键盘弹起会触发一次滚动;随后再恢复选择区,又会触发第二次滚动,用户看到的就是跳动。

恢复前还要再次比较 epoch,因为等待布局期间可能发生第二次窗口变化。只有 epoch === state.layoutEpoch、页面仍可见、会话 ID 一致时才执行。请求焦点返回成功也不代表编辑器一定可输入,测试里还会检查当前 focusKey 和 selection,二者都正确才把状态改为 FOCUS_RESTORED。

生命周期上,页面 aboutToAppear 注册窗口和键盘监听,aboutToDisappear 先递增 epoch 让所有等待失效,再注销监听、取消 ready Promise。若只是弹出临时菜单,不销毁编辑页面,就不应该清空快照。资源释放的判断依据是组件是否真正离开,而不是视觉上是否被遮挡。

我还保留了失败路径:34 ms 内没有等到布局稳定时,状态进入 RESTORE_DEFERRED,文本不会丢,页面也不强行抢焦点;用户下一次点击编辑器即可自然恢复。工程上,宁可少一次自动聚焦,也不要在页面不可见时把键盘重新拉起来。

恢复成功后不会立即删除所有诊断信息。最近一次 session、epoch、目标焦点、恢复耗时和失败原因保留到本次编辑会话结束,便于用户反馈后导出;文本内容与输入字符不进日志,只记录长度和摘要。这样既能还原时序,又不会把笔记正文写入调试文件。

连续折叠测试跑了五十次,其中十次在键盘动画中反向展开。修复前出现七次焦点漂移和三次底部空白,修复后五十次都满足标题不变、正文字符增加、旧 epoch 无写入。这个数字只代表当前设备组合,不是跨设备结论,因此测试报告仍保留设备形态和输入法版本。

六、运行结果要能证明"输入落在正确位置"

06:32 的运行页显示窗口 840 → 420 vp、布局 SPLIT → STACK、键盘 312 vp、光标第 18 行、焦点键 note_body、epoch 7、恢复耗时 34 ms,最终状态 FOCUS_RESTORED。底部保留一行刚输入的验证文本,确认字符落在正文而不是标题框。

红色批注只指向"epoch 7"和"焦点已恢复"。这两个字段分别证明旧回调被隔离、当前编辑器获得输入权。手机图是折叠后的单栏结果,不再额外塞一张展开态对比,避免把运行截图做成拼图。

七、这类问题不是布局宽度问题,而是事件所有权问题

回头看,窗口宽度、键盘高度和光标行号都没有算错。错误发生在三个合法事件争抢同一个结果:旧页面想恢复焦点,新页面想建立布局,键盘动画想调整可视区域。没有 epoch 时,每个回调都以为自己仍然有效。

FoldNote Lab 最后采用的判断很克制:窗口变化开启事务,快照只保存纯数据,键盘测量先稳定再提交,焦点恢复永远在布局和选择区之后,并在执行前后核对当前代次。它没有试图让所有设备共享一个延时常数,也没有把控制器跨页面搬运。

正式产品还要覆盖外接键盘、分屏自由窗口、输入法切换和进程重建。外接键盘可能没有 312 vp 避让区,但焦点恢复仍然需要;进程重建只能从持久化文本恢复,不能恢复旧控制器。测试矩阵应至少包含展开到折叠、折叠到展开、键盘显示与隐藏四种组合,并加入快速连续切换。

这次排查让我更在意一条日志是否能说明"谁拥有当前页面"。state=FOCUS_RESTORED 前必须同时带 session、epoch 和 focusKey,缺一项都可能只是偶然成功。页面不跳、文字不丢只是表象,旧事件无法修改新页面,才是这次修复真正守住的边界。

参考资料:

相关推荐
李游Leo2 小时前
HarmonyOS 7 HSP + Localization Kit:共享组件资源导出、语言回退与相对路径失效诊断【鸿蒙心迹】
harmonyos
m0_738185822 小时前
Flutter 鸿蒙化实战:flutter_nfc_kit 适配 OpenHarmony,NFC 读写一步到位
flutter·华为·harmonyos·鸿蒙
垆边人似月.2 小时前
华为机考题:字符个数统计
数据结构·算法·华为
李游Leo3 小时前
HarmonyOS 7 TaskPool + Node-API + zstd:Native 批量压缩任务的主线程隔离、进度回传与资源收口【鸿蒙心迹】
java·华为·harmonyos
李游Leo3 小时前
HarmonyOS ArkUI Navigation + GridRow:折叠态切换后的双栏路由恢复与返回栈收敛【鸿蒙心迹】
华为·harmonyos
HwJack203 小时前
【共创稿事节】HarmonyOS 7是3DGS 端侧重建:空间建模类应用的设计机会
3d·华为·harmonyos
传奇开心果编程3 小时前
【ArkUI进阶练中学】第5课:编译优化与包体积进
学习·ui·华为·harmonyos
m0_738185823 小时前
Flutter 鸿蒙化实战:flutter_quill 适配 OpenHarmony,富文本编辑器
flutter·华为·harmonyos·鸿蒙
m0_738185823 小时前
Flutter 鸿蒙化实战:flutter_quick_video_encoder 适配 OpenHarmony,逐帧编码视频
flutter·华为·音视频·harmonyos·鸿蒙