同一张巡检交接单,折叠屏展开时左边是条目列表、右边是正在编辑的备注;收起以后,用户希望继续看到刚才输入的那句话。这项需求看似只是适配宽度,真正危险的是:布局切换触发组件重新创建,输入框里的临时文本没有进入业务状态;或者另一端更新了交接单,旧页面在不知道版本变化的情况下把新内容覆盖。

本文使用 FoldDraft 演示项目,选中交接单编号固定为 NOTE-731。示例中的本地基线版本为 12,模拟收到的新版本为 13;演示发生冲突时显示 CONFLICT,保存按钮不执行写入,屏幕提示 保存已阻止。这些数值和日志都为教学构造数据,本文没有把它们写成真机实测。

一、先把“显示在哪里”和“正在改什么”分开

页面宽度改变不等于用户切换文档。假如开发者分别为窄屏和宽屏创建编辑器,并让每个编辑器各自保存一份 @State text,窗口每次重新布局都可能出现两个竞争的编辑态。更隐蔽的问题是,左侧列表点击新文档以后,右侧 TextArea 仍在调用上一份文档的保存回调。屏幕上看着是 A 的文字,写入时却使用 B 的编号,问题通常发生在页面状态和业务状态分裂以后。

这里把“当前选中的文档 ID、文本、基线版本、脏标记、冲突状态”放在页面根组件统一持有。宽屏只决定显示双栏,窄屏只决定显示单栏;任何分支都不能自行决定保存哪个 ID。NOTE-731 是业务身份,不能把列表下标、组件位置或者展开后的列号拿来充当身份。列表顺序变化,业务身份不该变化。

本例的工程目录刻意做得很小:entry/src/main/ets/pages/FoldDraftPage.ets 负责响应式界面,model/DraftRecord.ets 定义快照结构,services/DraftGate.ets 执行版本校验。保留服务层并不是为了堆抽象,而是为了以后接入网络或数据库时,让覆盖判断不依赖按钮长什么样。

二、先让草稿的版本判断不依赖 ArkUI

第一段代码解决“基线版本、编辑内容、新版本”之间的关系。演示中 remoteRevision 是手动注入的模拟远端版本,并不表示存在已经接通的同步服务。DIRTY 说明本地有修改,CONFLICT 说明基线已落后,二者不是同义词。保存结果不能只返回一个模糊的布尔值,否则界面容易把网络失败和版本冲突都显示成“保存成功”。

// services/DraftGate.ets:纯业务状态,展示关键路径
export type EditPhase = 'CLEAN' | 'DIRTY' | 'CONFLICT';
export interface DraftSnapshot {
  id: string;
  revision: number;
  text: string;
}
export class DraftGate {
  id: string = 'NOTE-731';
  baseRevision: number = 12;
  remoteRevision: number = 12;
  text: string = '交接单:夜间巡检待确认,保留原始批注。';
  phase: EditPhase = 'CLEAN';

  edit(next: string): void {
    this.text = next;
    this.phase = this.remoteRevision === this.baseRevision ? 'DIRTY' : 'CONFLICT';
  }
  receiveRemoteRevision(revision: number): void {
    if (revision <= this.remoteRevision) return;
    this.remoteRevision = revision;
    if (this.remoteRevision !== this.baseRevision) this.phase = 'CONFLICT';
  }
  canCommit(): boolean {
    return this.phase === 'DIRTY' && this.baseRevision === this.remoteRevision;
  }
}

DraftGate 没有引用窗口、TextArea 或任何平台持久化接口,因此可以用纯业务单测覆盖版本判断。测试重点应是先编辑、后收到版本 13,再尝试保存;此时 canCommit() 必须返回 false。反过来,只有基线和远端版本相等且确实有改动时,才允许进入后续写入流程。真实服务端仍必须使用条件更新或版本 CAS,不能把客户端的 canCommit() 当成防并发的唯一保险。

这里刻意不把 edit() 写成自动保存。布局回调可能多次出现,输入回调也可能随着用户逐字输入而触发,直接在这些事件里频繁写盘会把 UI 层事件放大为存储负担。phase 只有三个业务值,便于日志输出;如果产品还需要提交中、失败重试、冲突合并,应额外定义状态而不是偷用 CLEAN。

三、把 TextArea 绑定到页面根状态

第二段代码解决页面上的字丢失:输入值必须及时回写到同一个状态来源。官方 ArkUI 关于 TextArea 光标问题的说明明确建议将 onChange 返回的当前文本与状态保持同步,避免在回调里重新拼接、截断或强行把光标推到末尾。这里不为了演示而篡改每次输入的字符,减少额外的编辑副作用。

@Entry
@Component
struct FoldDraftPage {
  @State editorText: string = '交接单:夜间巡检待确认,保留原始批注。';
  @State selectedId: string = 'NOTE-731';
  @State baseRevision: number = 12;
  @State remoteRevision: number = 12;
  @State phase: string = 'CLEAN';
  @State wide: boolean = false;

  private editText(value: string): void {
    this.editorText = value;
    this.phase = this.baseRevision === this.remoteRevision ? 'DIRTY' : 'CONFLICT';
  }

  @Builder
  editor() {
    Column({ space: 12 }) {
      Text(`交接单 ${this.selectedId}`).fontSize(20).fontWeight(FontWeight.Bold)
      TextArea({ text: this.editorText, placeholder: '填写交接说明' })
        .height(220)
        .onChange((value: string) => this.editText(value))
      Text(`版本 ${this.baseRevision} → ${this.remoteRevision} · ${this.phase}`)
    }
    .width(this.wide ? '66%' : '100%')
  }
}

这段是页面根状态和编辑器的核心片段,实际工程还要把 editor() 放入 build();下段代码给出布局与按钮位置。开发者容易漏掉的细节是 onChange 只管文本变更,不能顺手把 selectedId 改成列表当前下标。selectedId 一旦切换,必须先确认上一条是否有未保存草稿,才能载入下一条的快照,否则新旧输入会相互覆盖。

如果改成父子组件拆分,可以通过显式传参或合适的状态装饰器共享数据,但不要让窄屏编辑器和宽屏编辑器分别发明自己的业务副本。页面生命周期也要明确:这份示例只演示单次页面内存状态,不声称后台被系统回收后仍可恢复;正式应用需在离开前把草稿写入持久化存储,并使用版本字段识别重新进入页面时的冲突。

四、展开与收起只改变排版

onAreaChange 接收到的是组件区域变化信息,可通过 newValue.width 换算数值。这里用 640vp 作为 Demo 自定义的双栏阈值,不把它说成 HarmonyOS 官方固定断点。只有横向可用宽度满足阈值才展现列表与编辑器的并排视图,避免窗口虽处于展开设备但被分屏压窄时仍挤成双栏。

第三段代码把布局变更与模拟冲突接在一起。点击“模拟收到版本 13”只改变演示数据,不会真的发起网络调用;“保存”先做版本门禁,若冲突就留在当前编辑内容,显式显示“保存已阻止”。示例日志固定为 NOTE-731 base=12 remote=13 phase=CONFLICT,这也是开发演示图的重点。

// 接续 FoldDraftPage 内部
private simulateRemote(): void {
  this.remoteRevision = 13;
  if (this.remoteRevision !== this.baseRevision) this.phase = 'CONFLICT';
  console.info(`NOTE-731 base=${this.baseRevision} remote=${this.remoteRevision} phase=${this.phase}`);
}
private trySave(): void {
  if (this.baseRevision !== this.remoteRevision) {
    this.phase = 'CONFLICT';
    console.info('NOTE-731 save=BLOCKED reason=REVISION_CONFLICT');
    return;
  }
  // Demo 不连接持久化存储;此处只能进入真实写入流程,不能宣称已提交。
  console.info('NOTE-731 save=ELIGIBLE');
}
build() {
  Column({ space: 12 }) {
    Text('FoldDraft · 草稿冲突检查').fontSize(22).fontWeight(FontWeight.Bold)
    Row({ space: 16 }) {
      if (this.wide) {
        Column() { Text('交接单列表'); Text('NOTE-731 · 夜间巡检') }.width('32%')
      }
      this.editor()
    }
    Button('模拟收到版本 13').onClick(() => this.simulateRemote())
    Button('保存交接单').onClick(() => this.trySave())
    if (this.phase === 'CONFLICT') Text('保存已阻止:请先核对新版本').fontColor(Color.Red)
  }
  .width('100%')
  .onAreaChange((oldValue, newValue) => {
    const w = Number(newValue.width);
    if (Number.isFinite(w) && this.wide !== (w >= 640)) this.wide = w >= 640;
  })
}

这张白色主题的开发环境配图只做布局与日志的合成演示:左边是工程树,中间突出 TextArea.onChange 和 onAreaChange,右边是模拟器,底部放入示例日志。它不代表 DevEco Studio 已编译通过。如果真正落到工程中,建议先静态检查各部分是否位于类内,再用当前目标 SDK 做编译与窗口缩放验证。

五、用能复现的顺序验证冲突,不靠截图猜测

推荐的人工验证步骤很具体:先选中 NOTE-731,初始版本显示 12 → 12;输入末尾批注,状态应从 CLEAN 变为 DIRTY;缩小窗口至低于 640vp,让列表退出排版,但不改变 selectedId 与输入文本;再扩大至高于阈值,确认原文仍存在。最后点击“模拟收到版本 13”,期望状态变成 12 → 13 · CONFLICT,再点“保存交接单”,只出现保存阻断日志,而不是写入完成消息。

手机图仅呈现窄屏最后一步的预期界面:任务 NOTE-731、版本 12 → 13、状态 CONFLICT、提示 保存已阻止。与其把它当成实际成功截图,不如把它当作 UI 验收卡片:产品和测试能根据同样的字段复核实现是否忠实于设计。宽屏还需补做左右栏切换、窗口旋转、分屏窄宽变化、键盘弹起和快速切换不同交接单的用例。

容易遗漏的是网络时序。客户端的 remoteRevision=13 可能来自通知,也可能来自重新拉取;如果通知延迟或离线,服务端真实版本可能已经变为 14。实际提交时,必须携带本地基线版本 12进行原子比较;服务端发现版本不一致时返回冲突,并保留用户本地草稿。把版本号仅显示在界面上,并不能保证安全。

六、这套方案真正解决的边界

本方案解决的是视图重排时状态归属,以及提交前发现可见的版本冲突;它没有实现自动合并、不提供跨设备最终一致性,也没有声称能够抵御设备突然断电。实际业务还应考虑草稿加密、持久化周期、用户退出确认、服务端条件更新以及合并差异界面。尤其要避免因为检测到宽屏就把服务端新数据直接覆盖 TextArea.text,这会让用户难以找回刚写好的句子。

技术判断可以归结为一句:布局宽度决定渲染形式,文档 ID 和版本决定编辑权限。 当两者分开,折叠屏适配不必变成一次隐蔽的数据写入。

参考资料:华为官方《TextArea 光标问题及 onChange 状态同步》https://developer.huawei.com/consumer/cn/doc/doccenter-dev-faq/faqs-arkui-1615;华为官方《组件区域变化事件 / onAreaChange》https://developer.huawei.com/consumer/en/doc/harmonyos-faqs-V14/faqs-arkui-9-V14 。

Logo

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

更多推荐