ArkUI + HarmonyOS 多窗口任务工作台:折叠分屏下表单草稿保持(连载04/06)【共创稿事节】
窗口展开时,任务列表和详情并排;窗口收窄时,详情被移动到独立页面。这一切看上去只是 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。
更多推荐

所有评论(0)