HarmonyOS ArkUI 窗口缩放不丢表单:草稿快照、焦点恢复与提交幂等

在平板分屏或自由窗口中编辑表单,用户拖动窗口边缘后,布局可能从单列变成双列。真正让人恼火的不是卡片换了位置,而是输入框里的内容突然清空、焦点跳到第一个字段,或者连续点两次提交造成重复记录。

这类问题常被误归结为“窗口缩放会重建页面”。尺寸变化事件、组件尺寸变化、页面生命周期和数据持久化是不同层次,不应该混为一谈。更可靠的方案是让业务草稿独立于布局,按明确版本恢复;焦点只作为可选的界面提示;提交以草稿修订号作幂等身份。本文给出可测试的应用侧状态模型,不声称已经完成 ArkUI 编译、系统联调或设备验收。

ArkUI 表单在窄窗与宽窗布局间切换但保留草稿状态

华为自由窗口说明提到,窗口尺寸缩放时若界面未适配,可能出现截断、遮挡或控件叠加;官方 FAQ 也介绍了通过窗口尺寸变化事件触发布局调整。自由窗口简介 窗口尺寸变化与动态布局 FAQ 以下讨论的是业务状态如何与布局解耦,不把事件监听等同于表单恢复能力。

1. 先区分尺寸变化、页面重建和业务恢复

窗口宽度变化可能只需要重新计算布局;页面被回收、导航切换或应用进程结束,则可能需要从更长寿命的数据源重建草稿。把每次尺寸变化都当成页面重启,会导致不必要的保存/读取和输入闪烁;只依赖内存又无法覆盖真正离开页面后的恢复。

先为状态定义作用域:窗口尺寸属于呈现层;字段值、附件和业务校验结果属于表单层;服务端基线版本属于数据层。只有确定某状态在当前生命周期边界后会丢失,才决定是否持久化。

状态典型寿命建议所有者
窗口宽度、布局列数当前窗口布局页面布局适配器
未提交字段值、附件引用草稿有效期表单状态 / 草稿仓库
当前服务端实体版本数据更新周期Repository
焦点字段、光标范围当前编辑会话UI 会话快照

2. 草稿只存语义数据,不存布局树

草稿快照应保存表单 ID、schema 版本、字段值、服务端基线版本和保存时间。不要序列化组件实例、控制器对象或像素坐标;这些值依赖当前渲染树和窗口尺寸,无法跨重建稳定解释。

interface FormDraft {
  formId: string;
  schemaVersion: number;
  baseRevision: string;
  draftRevision: number;
  values: Record<string, string | boolean | number | null>;
  savedAt: number;
}

interface FormIdentity {
  formId: string;
  schemaVersion: number;
  baseRevision: string;
}

字段值需要按业务类型处理。例如日期、金额、枚举应保存规范值,显示格式由当前布局渲染;敏感字段则不能因为“恢复方便”默认落盘。存储范围和保留期限需要结合产品隐私策略决定。

3. 窗口断点只控制呈现,不重置表单

布局类应由容器可用宽度推导,不要将列数反向写入草稿,也不要在断点切换时重新创建业务状态。具体断点取值要根据页面内容和目标设备验证;下面让阈值作为配置参数,避免把示例数字冒充为通用 HarmonyOS 设计标准。

type LayoutClass = 'compact' | 'medium' | 'expanded';

export function classifyWidth(widthVp: number, compactMax: number, mediumMax: number): LayoutClass {
  if (!Number.isFinite(widthVp) || widthVp <= 0) throw new Error('width must be positive');
  if (compactMax <= 0 || mediumMax <= compactMax) throw new Error('invalid breakpoints');
  if (widthVp <= compactMax) return 'compact';
  if (widthVp <= mediumMax) return 'medium';
  return 'expanded';
}

UI 收到窗口尺寸变化后更新布局类,表单 ViewModel 保持原有字段值。实际项目可以采用容器断点、窗口回调或组合方式,需按 API 版本核对并在目标窗口模式中验证。

4. 恢复前校验草稿身份、schema 和有效期

“存在草稿”不等于“应该自动恢复”。用户可能切换了表单对象,字段结构也可能升级,旧草稿还可能超过保留期限。恢复判定应显式比较 formId、schemaVersion、服务端基线版本和时间范围;不匹配时进入迁移、冲突处理或明确丢弃流程。

export function canRestoreDraft(
  draft: FormDraft | null,
  identity: FormIdentity,
  now: number,
  ttlMs: number,
): boolean {
  if (!draft || !Number.isFinite(now) || !Number.isFinite(draft.savedAt)) return false;
  if (draft.formId !== identity.formId) return false;
  if (draft.schemaVersion !== identity.schemaVersion) return false;
  if (draft.baseRevision !== identity.baseRevision) return false;
  if (!Number.isSafeInteger(draft.draftRevision) || draft.draftRevision < 0) return false;
  if (now < draft.savedAt || ttlMs < 0 || now - draft.savedAt > ttlMs) return false;
  return true;
}

服务端基线版本变化时,不要静默用旧草稿覆盖新数据。可先保留本地副本并提示用户选择合并、丢弃或查看差异;冲突策略属于产品决策,不能由通用恢复函数替用户决定。

5. 保存采用修订号,避免慢写覆盖新草稿

自动保存和用户继续输入可能并发。若写入 A 慢于写入 B,A 最后落盘就会把较新的字段覆盖掉。每次修改产生递增 draftRevision,仓库只接受比已保存版本新的快照;异步写入返回时,UI 也要检查它仍对应当前修订。

export function shouldAcceptDraftWrite(currentRevision: number, incomingRevision: number): boolean {
  return Number.isSafeInteger(currentRevision)
    && Number.isSafeInteger(incomingRevision)
    && incomingRevision > currentRevision;
}

export function markSaveResult(savedRevision: number, activeRevision: number): 'saved' | 'stale_result' {
  return savedRevision === activeRevision ? 'saved' : 'stale_result';
}

防抖只减少写入次数,不提供并发正确性。持久化层仍需原子比较或序列化队列;保存失败时保留内存草稿并显示可恢复状态,不能将失败吞掉后显示“已保存”。

保存草稿、切换断点、校验焦点并提交的状态流程

6. 焦点和光标是尽力恢复的 UI 提示

保存 focusedFieldId 和光标范围可以提升连续输入体验,但焦点信息比字段值脆弱:断点切换后字段可能被折叠、隐藏、禁用或被条件表单移除。恢复时先检查字段仍存在且可编辑,再将光标位置限制到当前文本范围;无法恢复就保持表单内容完整并退回默认焦点策略。

interface FocusSnapshot {
  fieldId: string | null;
  selectionStart: number;
  selectionEnd: number;
}

export function resolveFocus(
  snapshot: FocusSnapshot,
  fields: Array<{ id: string; enabled: boolean; valueLength: number }>,
): { fieldId: string | null; selectionStart: number; selectionEnd: number } {
  const field = fields.find((item) => item.id === snapshot.fieldId && item.enabled);
  if (!field) return { fieldId: null, selectionStart: 0, selectionEnd: 0 };
  const start = Math.max(0, Math.min(field.valueLength, snapshot.selectionStart));
  const end = Math.max(start, Math.min(field.valueLength, snapshot.selectionEnd));
  return { fieldId: field.id, selectionStart: start, selectionEnd: end };
}

恢复焦点不等于恢复键盘状态;键盘弹出、焦点请求时机和视口避让要在 UI 集成层核验。不要为了追求“看起来没变化”而抢焦点,尤其当用户已点击了另一个字段时。

7. 提交身份绑定草稿修订,而不是按钮点击

窗口缩放过程中,按钮位置或可见面积会变化;用户也可能重复点击或触发键盘提交。若每个点击都生成新请求身份,同一份草稿就可能被保存两次。提交键应关联业务表单 ID 与不可变草稿修订号;用户修改内容形成新修订后,才产生新的提交身份。

export function submissionKey(formId: string, draftRevision: number): string {
  if (!formId.trim() || !Number.isSafeInteger(draftRevision) || draftRevision < 0) {
    throw new Error('submission identity is invalid');
  }
  return JSON.stringify([formId, draftRevision]);
}

export function shouldSubmit(status: 'idle' | 'submitting' | 'done', key: string, lastKey: string | null): boolean {
  return status === 'idle' && key.length > 0 && key !== lastKey;
}

客户端键只帮助识别同一修订。真正的副作用去重需要服务端或数据仓库支持相同键的重复请求语义;若后端不支持,就不能声称达成端到端恰好一次。

8. 验证失败不应清掉用户输入

断点变化不会改变业务校验规则。布局从纵向变成并排后,错误消息可能移动,但错误字段、校验结果和原值必须继续对应同一个字段 ID。将错误按数组索引保存会在字段动态显示或隐藏时串到别的控件上。

校验状态最好携带草稿修订号:只有结果对应当前修订时才显示。用户修改某字段后,旧的异步校验结果不再可信;提交过程中若草稿继续变化,也要阻止旧提交结果覆盖正在编辑的新内容。

场景保留内容需要重新计算
仅窗口宽度变化字段值、脏标记、服务端基线布局断点、控件排列
表单 schema 升级可映射字段默认值、字段校验
服务端基线变化本地副本冲突合并/用户确认
用户修改字段当前新值该字段及相关字段校验

9. 草稿保留策略应由数据风险决定

不是每个表单都应该自动写入本地存储。普通筛选或临时备注可能只需页面会话;包含身份、医疗、支付等敏感内容时,应明确最少化存储、加密、退出清理和用户可见告知。本文不假定任何敏感表单可落盘。

即使只保存在内存,也要规定页面离开、账户切换和显式清空时的失效规则。若持久化,则存储 TTL、schema 迁移和删除动作必须与产品隐私说明一致;不要把自动保存当成无风险默认行为。

10. 响应式布局需要验证“状态不变、呈现可变”

官方自由窗口资料强调适配窗口宽高变化,动态布局的验收不能只截图比较两个宽度。需要同时确认字段值、脏状态、校验错误、当前编辑目标和提交结果在切换前后语义一致,并检查窄窗是否发生截断、遮挡或控件覆盖。

  • 从紧凑宽度缩到更窄,再恢复原宽度,业务字段值保持一致。
  • 跨断点切换时,布局类变化但草稿修订号不无故增长。
  • 焦点字段被隐藏或禁用时,不错误地抢焦点。
  • 异步保存乱序返回时,旧修订不能覆盖新草稿。
  • 重复提交同一修订只产生一个有效业务结果。
  • schema 或服务端版本变化时进入显式迁移/冲突流程。
  • 明确记录纯逻辑、页面集成与目标设备验证的边界。

纯逻辑测试可以证明这些判定函数的输入输出关系;ArkUI 尺寸回调、实际焦点恢复、键盘避让和分屏体验仍须在目标 API 版本及设备上单独验证。没有执行的内容应标成待验证,而不是根据模型推断为已通过。

窗口观察、表单 ViewModel、草稿仓库、焦点适配和提交协调器的责任边界

11. 常见现象与排查顺序

现象先检查建议处理
缩窗后内容清空布局回调是否重建了表单状态将字段值移入独立 ViewModel/草稿所有者
值还在但焦点错位是否保存了字段 ID 而非组件位置先校验字段仍可见、可编辑,再尽力恢复
旧自动保存覆盖新输入写入有没有修订号比较单调 revision + 原子提交或串行队列
放大窗口后错误提示串行校验结果是否按字段 ID/修订绑定忽略与当前修订不符的旧结果
同一表单出现两条记录提交键是否绑定业务修订使用稳定提交身份,确认接收端去重能力

排查时先检查数据身份与版本,再检查布局适配,最后才调焦点和动效。若先用界面补丁掩盖数据边界问题,下一次旋转、分屏或窗口缩放仍会复现。

12. 结语:布局可以重排,用户意图不能丢

稳定的表单体验不是把整个页面冻结,而是区分语义草稿、服务端基线、布局类别、编辑焦点和提交身份。窗口变化时可以重排组件;数据恢复必须校验身份与版本;焦点恢复允许失败;提交则需要防止同一修订重复产生副作用。

把这几类责任做成小而纯的判定函数,先用逻辑用例覆盖,再接入项目的 ArkUI 页面、窗口事件和持久化实现。最终的缩放、分屏、键盘与生命周期效果仍以实际项目验证为准。

Logo

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

更多推荐