窗口展开时,任务列表和详情并排;窗口收窄时,详情被移动到独立页面。这一切看上去只是 ArkUI 的布局工作,真正容易丢掉的却是用户正在输入的备注:点了几个字、折叠屏幕,输入框又回到上一次保存的内容。这里的问题不是 TextArea 不会保存文字,而是我们让一个随布局反复创建的子组件独自承担了业务草稿。

前三阶段已经把工作台的 selectedId、NavPathStack 与列表刷新分开了。第四阶段沿用 T-101、T-102 等稳定任务编号,额外管理每条任务的编辑草稿。目标有三个:宽窄布局切换不丢字;从 T-102 切到 T-103 再回来能够恢复各自内容;草稿与已经提交的业务数据明确区分。窗口断点只能决定怎么摆组件,不能决定当前编辑记录活多久。

折叠分屏下表单草稿保持技术封面

先把生命周期边界画清楚

原来的列表与详情是主从结构。为了让紧凑窗口可操作,if/else 会在单栏与双栏间切换,也可能由 Navigation 创建或移除详情页。如果草稿写在详情组件的 @State note,重新创建组件通常就从初始化值开始;某些窗口变化并不会触发重建,于是测试时还容易得到看似随机的结果。更有迷惑性的是,输入框显示的最后几个字和服务器已保存的说明可能相同,但它们在业务上并不是同一状态。

在现有 TaskReducer.ets 的 WorkTask 中继续使用稳定 id,不重定义任务身份。将备注草稿提升到工作台页面的父状态,子表单只接收“当前值”和“修改回调”。这让组件是否被创建、销毁、移动,都不能擅自替换草稿的所有权。selectedId 决定当前编辑哪一条,drafts 负责保存不同任务的待提交文字,breakpoint 和 detailVisibleOnSmall 只负责选择展示路径。前一阶段的过期响应仲裁继续维护任务列表,不应顺手清空 drafts。

窗口呈现状态与草稿所有权的分层示意

这里必须分清两种“保留”。第一种是同一个工作台页面仍然存在时,折叠、分屏或临时切换任务之后保留输入;父级 @State 能承担这部分。第二种是退出应用、进程终止后再次打开仍有未提交内容,这属于持久化,不能由 @State 保证。工程可以再接入 Preferences 或专门的草稿表,但不该把二者混为一个笼统的“永不丢失”。

父组件负责草稿,输入框只负责输入

先定义最小模型。为避免对 Map 的原地修改是否触发 UI 更新产生依赖,这里用数组并在每次编辑时构造新的数组引用。数据结构很小,适合有限数量的当前待编辑任务;如果同时编辑成千上万个任务,应另建持久化存储和缓存淘汰规则。

// entry/src/main/ets/model/TaskDraft.ets
export interface TaskDraft {
  taskId: string;
  note: string;
  updatedAt: number;
}

export function noteOf(items: TaskDraft[], id: string): string {
  const found: TaskDraft | undefined = items.find((d: TaskDraft) => d.taskId === id);
  return found === undefined ? '' : found.note;
}

export function changeNote(items: TaskDraft[], id: string, note: string): TaskDraft[] {
  const rest: TaskDraft[] = items.filter((d: TaskDraft) => d.taskId !== id);
  return [...rest, { taskId: id, note: note, updatedAt: Date.now() }];
}

updatedAt 是草稿修改的业务时间,用于提示是否有未提交改动,而不是同步冲突的权威时钟。写入 changeNote 时不修改 WorkTask.description:后者应代表任务系统认可的正式说明。否则用户尚未按下提交,列表刷新就可能把临时编辑当成已保存结果,甚至在发生提交失败时显示一个并不存在的业务状态。

子组件可以很薄:接收文本、展示 TextArea、把每一次变化交还父级。@Prop 提供父到子的输入值,onNoteChange 是普通回调,输入区不再私藏第二份备注。这个组件在双栏右侧和紧凑窗口详情中都可以复用。

// entry/src/main/ets/components/TaskNoteEditor.ets
@Component
export struct TaskNoteEditor {
  @Prop note: string = '';
  onNoteChange: (text: string) => void = (text: string): void => {};

  build() {
    Column({ space: 10 }) {
      Text('处理备注').fontSize(18).fontWeight(FontWeight.Medium)
      TextArea({ text: this.note, placeholder: '记录处理过程或待办事项' })
        .width('100%').height(200).fontSize(16)
        .onChange((value: string) => this.onNoteChange(value))
      Text('编辑中 · 尚未提交').fontSize(13).fontColor('#6B7280')
    }
    .alignItems(HorizontalAlign.Start)
    .padding(16).width('100%')
  }
}

下面是页面核心。此例让 GridRow.onBreakpointChange 只改变 breakpoint;实际项目可沿用前面建立的 Navigation 和 NavPathStack,详情区调用同一 TaskNoteEditor 即可。为了让代码单独易读,页面保留三条静态任务作为导航对象,重点展示草稿管理与双栏切换,不在本篇重复网络请求链路。

// entry/src/main/ets/pages/DraftWorkbench.ets
import { TaskDraft, noteOf, changeNote } from '../model/TaskDraft';
import { TaskNoteEditor } from '../components/TaskNoteEditor';

interface TaskBrief { id: string; title: string; }

@Entry
@Component
struct DraftWorkbench {
  @State private breakpoint: string = 'sm';
  @State private selectedId: string = 'T-102';
  @State private detailVisibleOnSmall: boolean = false;
  @State private drafts: TaskDraft[] = [];
  private tasks: TaskBrief[] = [
    { id: 'T-101', title: '巡检表单校验' },
    { id: 'T-102', title: '多窗口布局优化' },
    { id: 'T-103', title: '状态管理重构' }
  ];

  private edit(note: string): void {
    this.drafts = changeNote(this.drafts, this.selectedId, note);
  }

  @Builder
  TaskList() {
    Column({ space: 8 }) {
      ForEach(this.tasks, (item: TaskBrief) => {
        Button(item.title)
          .width('100%')
          .backgroundColor(item.id === this.selectedId ? '#3167D4' : '#64748B')
          .onClick(() => {
            this.selectedId = item.id;
            this.detailVisibleOnSmall = true;
          })
      }, (item: TaskBrief) => item.id)
    }.width('100%').padding(12)
  }

  @Builder
  TaskDetail() {
    Column({ space: 12 }) {
      if (this.breakpoint === 'sm') {
        Button('返回任务列表')
          .onClick(() => this.detailVisibleOnSmall = false)
      }
      Text('当前任务:' + this.selectedId).fontSize(20)
      TaskNoteEditor({
        note: noteOf(this.drafts, this.selectedId),
        onNoteChange: (text: string) => this.edit(text)
      })
    }.width('100%')
  }

  build() {
    GridRow() {
      GridCol({ span: { sm: 4, md: 8, lg: 12 } }) {
        if (this.breakpoint === 'sm') {
          if (this.detailVisibleOnSmall) {
            this.TaskDetail()
          } else {
            this.TaskList()
          }
        } else {
          Row() {
            Column() { this.TaskList() }.width('36%')
            Column() { this.TaskDetail() }.layoutWeight(1)
          }.alignItems(VerticalAlign.Top).width('100%')
        }
      }
    }
    .onBreakpointChange((value: string) => this.breakpoint = value)
    .width('100%').height('100%')
  }
}

代码刻意没有把草稿放在 TaskNoteEditor 的字段里。紧凑分支从“详情”切到“列表”,它即使销毁了输入组件,drafts 仍由父工作台持有。另一个细节是 ForEach 使用任务 id 作为稳定键;列表顺序调整不会让输入内容跟随索引串到另一条记录。在更复杂的 Navigation 页面里,应将草稿仓库上移到不会随着详情目的页切换而销毁的层级,不能照搬此页面层级后假设路由出栈也安全。

同一任务在紧凑与宽窗口间切换的草稿保持过程

真正退出页面时,草稿交给谁

上面解决的是页面生命周期内的状态保留。用户按系统返回离开整个工作台、被系统回收进程,或点击应用退出时,仍需要决定是否保留输入。对于少量普通备注,可以用 ArkData Preferences 做单设备草稿恢复。应用敏感字段、多人共享草稿、频繁批量编辑则不应把 Preferences 当成业务数据库;有权限与加密要求时还要另做安全设计。

// entry/src/main/ets/data/DraftPreferenceStore.ets
import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';

export class DraftPreferenceStore {
  private store?: preferences.Preferences;

  async open(context: common.UIAbilityContext): Promise<void> {
    this.store = await preferences.getPreferences(context, 'task_drafts');
  }

  async save(taskId: string, note: string): Promise<void> {
    if (this.store === undefined) { throw new Error('草稿存储尚未初始化'); }
    await this.store.put('note_' + taskId, note);
    await this.store.flush();
  }

  async read(taskId: string): Promise<string> {
    if (this.store === undefined) { throw new Error('草稿存储尚未初始化'); }
    return String(await this.store.get('note_' + taskId, ''));
  }
}

该存储类需要在页面获取 UIAbilityContext 后调用 open(),并在用户点击“保存草稿”时调用 save(selectedId, noteOf(drafts, selectedId)),重新进入编辑页时再执行 read() 并写回父状态。put 更新内存缓存后还应等待 flush 完成;如果保存失败,界面必须保留未提交内容并提供重试。这里将持久化做成明确的用户操作,是为了不把每次键盘输入都变成一次磁盘写入。若产品要求自动保存,应另设计防抖、单任务串行写入以及离开页面时的补写策略,同时明确最后一次编辑和最后一次成功落盘的区别。

Preferences 在同一个应用进程内有缓存语义,不应将它理解为多个进程或多设备之间的实时同步通道;多窗口同时编辑同一条任务还需要冲突处理规则。建议最小方案是以草稿 updatedAt 呈现冲突提示,再由用户选择使用哪个版本,而不是用一次“最后写入胜出”悄悄覆盖别的窗口。

页面草稿、持久化草稿与正式提交数据的职责边界

按事件回归,而不是只在宽屏看效果

验收时至少走完这些操作链:先选 T-102,输入没有提交的备注;把窗口从宽屏缩到紧凑布局,再展开;切换 T-103 输入另一段文字,返回 T-102;随后刷新任务列表、更改排序,观察 T-102 的草稿有没有串到 T-103。还要测试目标任务被删除的情况:上一阶段的 selectedMissing 应给出失效提示,草稿仍可作为待复制内容保留,但不得自动写入另一任务。

对于进程退出后的恢复,要把“明确点击保存草稿”和“只在编辑框里输入”分成两个测试条件。前者能够通过 Preference 的 read() 检查磁盘持久化结果;后者如果没有自动保存策略,不应宣传具备恢复保证。表单里的图片附件、光标位置、撤销栈也不会自动因为保存了纯文本而恢复,这些属于另一个层次的编辑器需求。

这一阶段最有价值的成果是确定状态归属:selectedId 是业务身份,布局只负责显示,drafts 是临时业务编辑,正式提交与持久化又有各自的确认点。下一阶段处理大字体和无障碍布局时,只需要更换组件排列与交互入口,不必重新设计备注数据的生存周期。

参考资料:ArkUI GridRow 响应式页面示例、TextArea 输入变化与状态绑定、ArkData Preferences API。

Logo

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

更多推荐