同一个资料库,左边是文档目录,右边是可编辑的审核批注。窗口足够宽时并排显示,窄屏时只显示当前文档。真正容易出错的地方并不是两栏如何排列,而是用户正在编辑甲文档时切到乙文档,随后甲文档上一轮保存请求才返回。此时如果只写一个页面级 isSaved,右侧很可能出现“已保存”的假提示,甚至把甲文档的文字盖到乙文档输入框里。这轮用 DualAnnotation 解释一个很小但关键的合同:文本以文档 ID 隔离,保存回执以文档 ID 和本地修订号裁决,布局只改变展示方式。示例任务是 DA-410,目录有 DOC-014 与 DOC-027。我们假设 DOC-014 连续产生 r7、r8 两次保存请求,而 r8 的成功确认先回来,r7 的成功确认后回来。页面最终显示 DOC-014 已保存 r8,另一个 DOC-027 仍为 未保存 r3;旧回执被标为 STALE_ACK_IGNORED。这些只是刻意编排的应用层示例,不是真实服务器或设备日志。## 一、把“当前输入框”与“文档身份”拆开通常先出现的写法是给页面准备一个 @State noteText,点击目录就替换它。这个写法能显示内容,却没有说明谁拥有这段内容。更严重的是,一旦保存函数闭包只记住当前 noteText 而没记住请求发出时的文档 ID,回调时目录已经换过,误写就变得难以排查。这里的稳定身份是文档编号,不是左右栏的组件索引,也不是当前焦点。DOC-014 进入编辑态以后,输入内容和修订号都存放在该文档的记录里。页面上的 editorText 只是投影:切换目录时从记录读取,输入变化时再写回记录。这样双栏、单栏或重新构建右侧组件都不应改变这份业务归属。第一个代码段处理“每份草稿到底属于谁”。它不依赖平行视界的私有接口,纯粹是可以独立检查的 ArkTS 业务对象;为了让示例聚焦,存储先使用内存 Map,没有假装数据已经落盘。tsinterface DraftRecord { id: string; text: string; localRev: number; savedRev: number;}interface SaveAck { docId: string; localRev: number; applied: boolean;}class DraftLedger { private records: Map<string, DraftRecord> = new Map(); seed(id: string, text: string, localRev: number, savedRev: number): void { this.records.set(id, { id, text, localRev, savedRev }); } get(id: string): DraftRecord | undefined { return this.records.get(id); } edit(id: string, text: string): void { const doc = this.records.get(id); if (!doc) { return; } doc.text = text; doc.localRev += 1; }}````localRev` 表示用户端已发生的编辑轮次,`savedRev` 表示页面已确认的轮次。它们不是系统 API,也不能当作云端数据库版本号。代码在 `edit` 中只改指定 ID 的记录,不接受一个不带身份的“全局最新内容”;这正是避免切栏串稿的第一道门。实际项目还需补上数据规模上限、草稿过期清理和应用退出后的可靠持久化,否则重启后内存状态会消失。## 二、旧成功回执为什么也要丢弃一个容易忽略的反例是:r7 和 r8 都返回 `success`。如果页面接到任意成功响应就设 `saved=true`,r7 的回执即使晚来,也能把显示状态压回旧版本。更糟的是,用户在 r8 之后继续输入 r9,迟到的 r8 此时也不应把 r9 标成已保存。回执必须携带**发起请求那一刻冻结的 `docId` 与 `localRev`**。回调先查是否是已知文档,再查后端是否报告应用成功,最后查修订号是否仍对应当前待确认文本。页面级的“当前文档”不参与裁决。下面第二段扩展 `DraftLedger`;放进同一个类中即可,拆开呈现只是便于解释规则。ts// 放入上面的 DraftLedger 类内applyAck(ack: SaveAck): ‘COMMITTED’ | ‘STALE_ACK_IGNORED’ | ‘REJECTED’ { const doc = this.records.get(ack.docId); if (!doc || !ack.applied) { return ‘REJECTED’; } if (ack.localRev < doc.savedRev || ack.localRev !== doc.localRev) { return ‘STALE_ACK_IGNORED’; } doc.savedRev = ack.localRev; return ‘COMMITTED’;}在构造顺序中,初始 `DOC-014` 的 `localRev=8`、`savedRev=6`;先送入 r8 的成功回执,状态升为已保存 r8;再送 r7,因 `7 < 8` 被拒绝。`DOC-027` 是另一条记录,`localRev=3`、`savedRev=2`,因此仍未保存。调试时应该盯“文档 ID / 请求修订 / 当前修订 / 已确认修订”四个字段,而不是盯一次按钮点击是否成功。这里有一条很重要的边界:**客户端丢掉旧回执,不等于服务端没有被旧请求覆盖**。如果服务器允许 r7 在 r8 之后写回同一字段,页面会认为安全,实际存储却已经回退。工程上必须让写入端执行修订前置条件(例如带版本的比较并交换),或为每份文档按序提交,并返回真实的已应用版本。本文 `applied` 仅代表假设的业务网关回执,不构成网络接口或持久化保障。离线重试、跨设备合并与同一账号多端编辑,也应在后端合同中解决。![](https://i-blog.csdnimg.cn/direct/ed3650a93f4343798ab7aa6720ce2a6d.png)## 三、TextArea 只承接当前文档的投影左右栏切换时,避免让临时输入态成为第二份权威数据。`@State` 用来驱动当前文本、状态标签和目录选择;每次选中文档先从 `DraftLedger` 读取,再刷新视图。组件重绘不应自动触发保存。特别是窄窗口下展开详情或从列表返回时,不应顺手给文本自增一次版本,那会把纯粹的布局变化误算成用户编辑。第三段把前面的对象接到 ArkUI 页面。为了让演示可复核,`aboutToAppear` 明确构造了两份记录和乱序回执;按钮只展示“待发送”提示,没有偷偷调用不存在的 SDK 保存 API。将它接入正式服务时,要把示例回执移出初始化逻辑,并让网关反馈实际写入版本。ts@Entry@Componentstruct DualAnnotationPage { private ledger: DraftLedger = new DraftLedger(); @State activeId: string = ‘DOC-014’; @State editorText: string = ‘’; @State saveLabel: string = ‘’; @State lastAck: string = ‘STALE_ACK_IGNORED’; aboutToAppear(): void { this.ledger.seed(‘DOC-014’, ‘待补充验收日期’, 8, 6); this.ledger.seed(‘DOC-027’, ‘核对合同附件’, 3, 2); this.ledger.applyAck({ docId: ‘DOC-014’, localRev: 8, applied: true }); this.lastAck = this.ledger.applyAck({ docId: ‘DOC-014’, localRev: 7, applied: true }); this.refresh(); } private refresh(): void { const doc = this.ledger.get(this.activeId); if (!doc) { return; } this.editorText = doc.text; this.saveLabel = doc.localRev === doc.savedRev ? 已保存 r${doc.savedRev} : 未保存 r${doc.localRev}; } build() { Column({ space: 12 }) { Text(DA-410 · ${this.activeId}) Text(this.saveLabel) TextArea({ text: this.editorText, placeholder: ‘填写批注’ }) .onChange((value: string) => { this.ledger.edit(this.activeId, value); this.refresh(); }) Button(‘保存当前批注’).onClick(() => { this.saveLabel = ‘待发送(演示)’; }) Button(‘切换到 DOC-027’).onClick(() => { this.activeId = ‘DOC-027’; this.refresh(); }) Text(旧回执:${this.lastAck}) }.padding(20) }}```正式项目不建议把 DraftLedger 和所有保存回调直接写进页面:可以把草稿仓库、异步网关与修订裁决分别抽成文件。示例只是为了让 TextArea.onChange、@State 与业务状态之间的通路足够直观。异步回调回来时也要检查页面是否已经卸载,避免用失效的 UI 引用继续写状态。目录可以在窄屏时折叠显示,但不要在 onAreaChange 之类的尺寸变化回调中清空批注。## 四、验收时要刻意交换返回顺序演示界面固定在 01:21:DOC-014 显示 已保存 r8,DOC-027 显示 未保存 r3,旧回执结果是 STALE_ACK_IGNORED。这是用人工数据构造的预期状态,不表示已经在真实平行视界、DevEco 或设备上实测。配图右侧模拟器显示该预期界面;下方的 HiLog 同样是示意诊断,不是日志采集证据。真实联调建议至少重复三种顺序:r7 后 r8、r8 后 r7,以及 r8 返回之前用户又输入 r9。再加入切到 DOC-027 后才返回甲文档回执的情况,确认提示没有串到当前页面。若服务端没有修订门禁,测试必须标成“客户端显示防护通过、服务器覆盖风险未消除”,不能因页面恰好没变就算完成。除了乱序回执,还应给这种页面准备一组极端操作:快速点击不同目录、同一个输入框持续粘贴长文本、保存按钮被连续触发、应用暂时进入后台再回到前台。前两种主要验证草稿身份,第三种验证请求是否被合并或排队,最后一种验证页面销毁后是否还持有旧回调。每个场景都应有可读的请求编号,不要用一条“保存成功”覆盖所有测试维度。尤其在多窗口并存时,相同文档可能同时出现在两个 UI 实例中,内存 Map 的隔离范围需要明确,不能自动假定它是跨实例共享的数据源。用户真正需要的反馈也要分层:输入内容留在内存只能写“本地草稿”,收到业务网关确认才写“服务器已接收”,后端带条件提交成功并返回修订号才可以写“已保存 r8”。网络离线、限流或权限过期时,旧值应该继续留在草稿列表,但不能把“待同步”包装成“已保存”。这些文案虽然不涉及高级 API,却会决定读者能否正确判断一个技术实现有没有越权承诺。DualAnnotation 的关键结论不是“给 TextArea 加一个 @State”,而是把身份、文本、修订与保存确认绑成同一个可核对的业务记录。ArkUI 布局负责展示,版本裁决负责认领保存结果,数据网关负责真实落盘;三层职责不混,平行视界里的切栏操作才不会顺手制造一条假成功记录。参考资料与限制:华为 ArkUI 文本输入/自定义键盘资料(用于核对 TextArea 事件模式):https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/arkts-customize-keyboard ;@State 更新示例:https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/arkts-watch ;多设备自适应场景:https://developer.huawei.com/consumer/cn/multidevice/adaptive-apps/ 。未虚构具体 SDK 构建号;示例 Map 和修订网关都是应用层逻辑,不是官方平行视界保存接口。

Logo

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

更多推荐