HarmonyOS 7 EditableTitleBarV2:保存成功后还有未保存修改,异步回调该认哪一版?

在编辑页输入 A,点右上角的保存,网络还没返回,又把内容改成 B。几秒后页面显示“已保存”,但服务端实际收到的只有 A。这时退出页面,刚刚输入的 B 就可能丢失。

换成 HarmonyOS 7 的 EditableTitleBarV2,也不会自动解决这个问题。标题栏负责展示操作入口,哪一版内容已保存、哪一笔请求仍在执行,要由编辑页自己维护。本文做一个不接真实业务的便签编辑页:允许保存期间继续输入,但同一时刻只发一笔保存请求。重点不是多加一个 loading,而是让回调认得自己提交的快照。

适用范围:EditableTitleBarV2 起始版本为 26.0.0,仅用于 Stage 模型,本文按 HarmonyOS 7 / API 26 文档讨论。官方页面更新于 2026-09-09 14:54,核对日期为 2026-09-27。文中的状态模型和两组断言已在本机 Node.js 运行;ArkTS 页面未在 API 26 SDK 或真机编译运行。模拟存储不会写入文件或服务器,不能把它当成持久化实现。

保存请求与编辑版本的时序示意,展示 A 已提交而 B 仍未保存

先纠正一个容易写错的按钮配置

看起来都是标题栏右侧的按钮,配置却不是同一套。

官方配置实际用途不能混用的地方
EditableSaveButtonV2.onAction内置保存按钮的点击回调不是菜单项的 action
EditableSaveButtonV2.isRequired是否显示内置保存按钮,默认 true不是是否启用,false 会隐藏按钮
EditableTitleBarMenuItemV2.isEnabled自定义菜单项是否可操作不能照搬到 EditableSaveButtonV2 上
EditableTitleV2.mainTitle / subTitle可观察的主副标题改标题不会替业务完成保存
EditableTitleBarStyleV2背景、安全区域等标题栏样式不是保存状态容器

截至这次核对,内置保存按钮的配置表只有 isRequired、defaultFocus、onAction,没有 isEnabled。给标题栏链一个通用 enabled 属性也不稳妥:官方说明通用属性可能挂在额外生成的 Common 节点上,并不直接作用于标题栏本身,不建议这样设置。

因此下面保留内置保存入口,在回调入口拦住重复提交,并在页面上显示状态。它并没有把图标变灰。假如产品要求真正的禁用样式,可以隐藏内置保存按钮,换成支持 isEnabled 的自定义菜单项;但菜单项的回调仍然需要业务保护,不能只靠视觉状态。

把三个问题分开,才不会修好一个又弄坏另一个

一次保存至少涉及三份信息:

  1. 当前编辑版本 revision:内容每发生一次实际变化就递增。
  2. 当前请求 active:这笔请求带走的文本、版本和请求编号。
  3. 最后确认的版本 acknowledgedRevision:收到成功响应的那一版,而不是响应回来时屏幕上的版本。

saving 由“有没有当前请求”推导;dirty 由“当前版本与已确认版本是否相同”推导。两者不是反义词:请求已经结束,仍然可以有未保存修改。

一种常见错误是:回调发现版本过期,直接 return。虽然没有误把 B 标成已保存,但如果 return 前没释放这笔请求,页面会一直处于保存中。正确顺序是:先判断这个回调是否属于当前请求,再结束该请求,最后确认它提交的版本。不能用编辑版本代替请求身份。

可复用的状态模型

将下面代码作为 SaveModel.ets 使用;它不依赖 ArkUI。为方便在电脑上验证,同一段代码也可以保存为 SaveModel.ts,由支持类型擦除的 Node.js 运行。这里没有把编辑版本号冒充服务端的并发版本号。

export class SaveTicket {
  readonly id: number;
  readonly revision: number;
  readonly text: string;
  constructor(id: number, revision: number, text: string) {
    this.id = id;
    this.revision = revision;
    this.text = text;
  }
}

export class SaveModel {
  text: string = '';
  revision: number = 0;
  acknowledgedRevision: number = 0;
  private nextId: number = 0;
  private active: SaveTicket | undefined = undefined;
  private disposed: boolean = false;

  get saving(): boolean { return this.active !== undefined; }
  get dirty(): boolean {
    return this.revision !== this.acknowledgedRevision;
  }

  edit(text: string): void {
    if (this.disposed || text === this.text) return;
    this.text = text;
    this.revision++;
  }

  begin(): SaveTicket | undefined {
    if (this.disposed || this.saving || !this.dirty) return undefined;
    const ticket = new SaveTicket(++this.nextId, this.revision, this.text);
    this.active = ticket;
    return ticket;
  }

  succeed(ticket: SaveTicket): boolean {
    if (this.disposed || this.active !== ticket) return false;
    this.active = undefined;
    this.acknowledgedRevision = ticket.revision;
    return true;
  }

  fail(ticket: SaveTicket): boolean {
    if (this.disposed || this.active !== ticket) return false;
    this.active = undefined;
    return true;
  }

  dispose(): void {
    this.disposed = true;
    this.active = undefined;
  }
}

active 持有本次创建的对象,回调必须携带同一个 ticket,旧回调便不能结束后来的请求。调用方不要自行修改模型字段或拼造 ticket;如果接入跨线程或跨进程消息,应改成校验稳定的请求 ID 和编辑会话 ID,不能继续依赖对象引用相等。

这个模型采用保守的 dirty 判定:输入 A 后改成 B,再改回 A,仍视为发生过新编辑。这样不会漏保存,代价是可能多保存一次。要按内容判断是否相同,可以另外记录已确认内容的摘要,但不要取消请求身份校验。

案例一:保存 A 的时候,继续把内容改成 B

复现顺序是固定的,不需要依赖网速碰运气:edit('A') → begin() → edit('B') → succeed(ticket)。用同一份模型跑下面断言。以下两个测试代码块都接在模型代码后执行,不需要再复制一份模型实现。

function check(value: boolean, message: string): void {
  if (!value) throw new Error(message);
}

const first = new SaveModel();
first.edit('A');
const requestA = first.begin();
if (requestA === undefined) throw new Error('A should start');
check(first.begin() === undefined, 'double tap must not submit twice');
first.edit('B');
check(requestA.text === 'A', 'submitted snapshot must remain A');
check(first.succeed(requestA), 'current request should finish');
check(!first.saving, 'A finished: saving must end');
check(first.dirty, 'B has not been acknowledged');
check(first.text === 'B', 'callback must not overwrite the editor');

const requestB = first.begin();
if (requestB === undefined) throw new Error('B should start');
check(first.succeed(requestB), 'B should finish');
check(!first.dirty && !first.saving, 'latest revision is now saved');
check(first.begin() === undefined, 'unchanged text needs no request');
console.log('case 1 passed');

第一笔成功后,页面应该显示“仍有未保存修改”,而不是继续转圈,也不是“全部已保存”。第二次保存发送 B,成功后才消除 dirty。这就是区分两种状态的实际收益:允许继续输入,又不替未提交的文字承诺保存成功。

案例二:失败后重试,旧回调不能结束新请求

普通 Promise 只会兑现一次,但接入重试层、事件回调或超时包装时,同一个操作可能收到多条通知。测试里刻意再送一次旧通知,用来验证边界,不代表 Promise 会先失败再成功。

const second = new SaveModel();
second.edit('待保存内容');
const failed = second.begin();
if (failed === undefined) throw new Error('first attempt should start');
check(second.fail(failed), 'failure must release active request');
check(second.dirty && !second.saving, 'failed content remains editable');

const retry = second.begin();
if (retry === undefined) throw new Error('retry should start');
check(retry.id !== failed.id, 'retry needs a new request identity');
check(!second.succeed(failed), 'late old success must be ignored');
check(!second.fail(failed), 'late old failure must be ignored');
check(second.saving, 'old notification must not release the retry');
check(second.succeed(retry), 'retry succeeds normally');
check(!second.dirty, 'retry acknowledged the submitted revision');

second.edit('离开页面前的新内容');
const leaving = second.begin();
if (leaving === undefined) throw new Error('last request should start');
second.dispose();
check(!second.succeed(leaving), 'closed editor ignores its callback');
console.log('case 2 passed');

本机执行结果为 case 1 passed、case 2 passed。它证明这份状态模型覆盖了双击、继续编辑、失败释放、旧通知和销毁后的回调,不证明网络接口已落库,也不证明 ArkUI 已正确刷新。

接入 EditableTitleBarV2:界面刷新也要有明确来源

下面是页面接入示例,SaveModel.ets 与页面放在同一目录。模型保持普通类,界面用 @Local 字段接收投影结果;每次编辑、开始保存或收到结果,都调用 refresh。这样不用误以为一个普通类内部字段变化会自动更新全部界面。

import {
  EditableTitleBarV2,
  EditableSaveButtonV2,
  EditableTitleBarStyleV2
} from '@kit.ArkUI';
import { SaveModel, SaveTicket } from './SaveModel';

@Entry
@ComponentV2
struct SaveSnapshotPage {
  private model: SaveModel = new SaveModel();
  private alive: boolean = true;
  @Local text: string = '';
  @Local stateText: string = '没有未保存修改';
  @Local failNext: boolean = false;

  private refresh(): void {
    this.stateText = this.model.saving ? '保存中,可以继续输入' :
      (this.model.dirty ? '仍有未保存修改' : '当前内容已确认');
  }

  private mockStore(ticket: SaveTicket, fail: boolean): Promise<void> {
    // Only simulates delay and failure; does not persist ticket.text.
    return new Promise<void>((resolve, reject) => {
      setTimeout(() => {
        if (fail) reject(new Error('模拟保存失败'));
        else resolve();
      }, 3000);
    });
  }

  private save(): void {
    const ticket = this.model.begin();
    if (ticket === undefined) return;
    const shouldFail = this.failNext;
    this.failNext = false;
    this.refresh();
    this.mockStore(ticket, shouldFail).then(() => {
      if (this.alive && this.model.succeed(ticket)) this.refresh();
    }).catch(() => {
      if (this.alive && this.model.fail(ticket)) {
        this.refresh();
        this.stateText = '保存失败,内容保留,可再次保存';
      }
    });
  }

  aboutToDisappear(): void {
    this.alive = false;
    this.model.dispose();
  }

  build() {
    Column({ space: 16 }) {
      EditableTitleBarV2({
        title: '便签编辑',
        saveButton: new EditableSaveButtonV2({
          onAction: () => { this.save(); }
        }),
        options: new EditableTitleBarStyleV2()
      })
      TextArea({ text: this.text, placeholder: '输入 A 后保存,再改为 B' })
        .height(200)
        .onChange((value: string) => {
          this.text = value;
          this.model.edit(value);
          this.refresh();
        })
      Text(this.stateText)
      Toggle({ type: ToggleType.Switch, isOn: this.failNext })
        .onChange((value: boolean) => { this.failNext = value; })
      Text('开启开关后,下一次模拟保存失败')
    }.width('100%')
  }
}

页面示例把 aboutToDisappear 视为编辑实例结束,销毁后不复用这个模型。如果采用缓存页面、导航隐藏后再次显示的结构,要先明确“隐藏”和“会话结束”的区别,再把 dispose 放到真正的结束位置,不能照搬到所有生命周期回调。

在支持 API 26 的环境里,应至少人工核对以下结果:输入 A 点保存,三秒内改成 B,返回后显示仍有修改;再点一次,返回后显示当前内容已确认。开启失败开关后保存,失败提示出现,文本不丢失;重新点击能开始新请求。连续点击保存时,存储层只应被调用一次。这里是待设备端执行的验收步骤,不是已完成的真机结果。

三种方案怎么选

方案好处代价与适用边界
保存期间锁住编辑最容易保证界面内容与提交快照一致慢网络下阻断输入,长文本体验差
允许编辑,串行保存快照不阻断输入,请求归属简单完成后可能仍需再次保存,本文采用它
每次输入都自动保存操作省心要处理节流、失败重试、写入顺序和服务端冲突,不能只加 debounce

对一个有明确保存按钮的编辑页,我优先选第二种。这个选择并不是性能优化结论,而是状态复杂度与输入体验之间的取舍。客户端串行化只能约束当前编辑实例,多设备同时修改同一条记录仍可能互相覆盖。接入真实后端时,应使用服务端版本条件或等价的冲突检测,失败重试也要有幂等约定。请求超时只表示客户端没拿到结果,不等于服务器没有写入;不能据此承诺重试绝不会重复写。

真正可以复用的是 SaveModel 这层,而不是整个便签页面。替换 mockStore 为存储适配器时,传 ticket.text,不要在请求真正执行时重新读取 this.text;只有持久化语义明确成功,才调用 succeed。上传附件、后台同步、草稿自动保存也能借用“请求身份+快照版本”的思路,但各自的事务边界必须另行定义。

下次遇到“保存状态不对”,按这个顺序查

先看按钮配置有没有用错类,再看请求带走的是快照还是可变对象。随后检查回调是否只处理自己的请求、失败是否释放 active、成功是否只确认提交版本、页面结束后是否还在写 UI。最后才考虑加自动保存或更复杂的缓存。

这个例子解决的不是“让标题栏多显示一个勾”,而是让这个勾不再对未提交的内容作出错误承诺。若你遇到过保存后文字回退,可以对照日志中的请求编号、提交版本、当前版本,先判断是旧回调污染,还是服务端并发覆盖,两者的修法不一样。

官方参考

文中的请求状态模型是应用侧设计,不是平台内置保存机制;示意图也不是设备截图。

Logo

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

更多推荐