前言

我在《会议随记 Pro》里处理会议保存、会议删除、联系人新增这些操作时,最早遇到的不是数据库问题,而是页面之间怎么同步的问题。

新建会议以后,会议列表要更新,工作台里的统计数据也要重新计算。删除会议以后,当前列表可以先把这一项移除,但工作台、桌面卡片、其他筛选入口也要知道会议数据已经变化。新增联系人以后,联系人列表要更新,新建会议页里的联系人选择入口也可能需要重新读取数据。

这类问题刚开始很容易用页面之间互相调用来处理。比如新增会议页保存成功后,直接调用会议列表刷新;联系人弹窗新增成功后,直接调用联系人列表刷新;项目编辑完成后,再去通知项目列表。页面少的时候,这种写法能跑通。继续往后,工作台统计、桌面卡片、详情页、列表页、选择器都开始关心同一类数据变化,调用关系就会变成一张很难维护的网。

我后来在项目里采用了一套很轻的刷新信号实现思路。会议相关操作推进 MeetingReloadKey,联系人相关操作推进 ContactReloadKey,项目相关操作推进 ProjectReloadKey。页面监听自己关心的 key,再结合当前 Tab 是否可见、本地版本是否落后,决定马上刷新还是先记录下来。

这里我不会把它包装成通用方案。它更像《会议随记 Pro》当前阶段的一套项目实现思路:数据仍然由 Repository 管理,页面仍然负责自己的分页、搜索和筛选,AppStorage 只承担跨页面版本信号。对一个本地数据为主、页面数量可控、刷新事件不高频的鸿蒙原生应用来说,这个方案足够轻,也方便继续维护。

这套机制里会用到 ArkUI 的 AppStorage@StorageProp@Watch。我在项目里把它们放在工作台、会议列表、联系人列表这些页面之间使用。页面当前可见时马上刷新,页面隐藏时先记录状态,等切回来再检查版本差异。这样可以减少隐藏页面的无效查询,也能让列表、统计、选择器在需要的时候拿到最新数据。

一、刷新信号只表达数据变了

项目里有一个很小的工具类 RefreshUtil。它不查询数据库,也不更新 UI,只负责把全局 key 往前推进一次。

会议相关操作调用 notifyMeetingUpdate(),联系人相关操作调用 notifyContactUpdate(),项目相关操作调用 notifyProjectUpdate()。会议刷新信号会读取当前 MeetingReloadKey,再加一写回 AppStorage;联系人刷新信号也采用同样的处理。项目刷新信号可以直接写入时间戳,因为项目列表只需要感知版本变化,不依赖连续数字。项目源码里的 RefreshUtil 正是按照这个方向处理,会议、联系人和项目分别维护自己的刷新 key。

const KEY_MEETING_RELOAD = 'MeetingReloadKey';
const KEY_CONTACT_RELOAD = 'ContactReloadKey';

export class RefreshUtil {
  static notifyMeetingUpdate() {
    const current = AppStorage.get<number>(KEY_MEETING_RELOAD) || 0;
    AppStorage.setOrCreate(KEY_MEETING_RELOAD, current + 1);
  }

  static notifyContactUpdate() {
    const current = AppStorage.get<number>(KEY_CONTACT_RELOAD) || 0;
    AppStorage.setOrCreate(KEY_CONTACT_RELOAD, current + 1);
  }

  static notifyProjectUpdate() {
    AppStorage.setOrCreate('ProjectReloadKey', Date.now());
  }
}

这段代码轻,但边界很重要。RefreshUtil 只告诉页面数据版本已经变化,不替页面决定怎么加载数据。会议列表怎么分页,联系人列表怎么搜索,工作台怎么统计,这些逻辑都留在各自页面里。

我会把它拆成下面这种关系。

方法 表达的业务含义 不处理的内容
notifyMeetingUpdate() 会议数据已经变化 不查询会议列表,不重置分页
notifyContactUpdate() 联系人数据已经变化 不控制联系人列表,也不打开联系人弹窗
notifyProjectUpdate() 项目数据已经变化 不决定项目详情是否重新加载

这里容易写乱的地方,是把信号和动作混在一起。比如新增会议成功后,顺手让会议列表刷新,短期看起来省事。后面联系人选择器、工作台统计、桌面卡片都要同步时,这个新增页面就会知道太多别的页面细节。项目写到这个阶段,我更愿意让新增页只发出会议数据变化的信号,至于谁刷新、什么时候刷新,由对应页面自己判断。

会议列表里会通过 @StorageProp 接收全局 key,再用 @Watch 监听变化。页面内部还会保存一份 lastLoadedKey,记录自己上一次加载到哪个版本。真实项目的 MeetingListPage 里同时保留了 reloadKeycurrentTabIndexlastLoadedKey,这些状态一起决定页面是否需要重新加载。

@StorageProp('MeetingReloadKey')
@Watch('onReloadKeyChanged')
reloadKey: number = 0;

private lastLoadedKey: number = -1;

lastLoadedKey 这个变量很容易被忽略。没有它,页面只知道全局 key 变化了,却不知道自己是否已经加载过当前版本。用户多次切换 Tab,或者同一个页面多次显示时,就容易重复查询。保留本地版本以后,页面可以先做一次判断:本地版本和全局版本一致,就跳过这次刷新;本地版本落后,再重新加载数据。

联系人列表和项目列表也是同样的思路。它们监听不同的 key,但页面内部都有自己的本地版本。全局信号只负责告诉页面数据变了,页面自己决定要不要重新加载。

二、页面可见时再加载

如果全局 key 一变化,所有页面都立刻查询数据库,代码确实容易写,但这个应用里不合适。

《会议随记 Pro》是一个典型的 Tab 结构。用户当前只会观察一个 Tab,其他页面不在屏幕上。会议列表、联系人列表和工作台都有自己的数据状态。会议列表还有搜索关键词、筛选条件、分页偏移、是否还有更多数据、是否正在加载这些状态。隐藏状态下立刻刷新,不一定符合用户重新回到页面时的预期,也会带来很多看不见的查询。

会议列表里我会先判断当前 Tab。当前页面就是会议列表时,再调用 checkAndLoad()loadFilters()。如果用户当前在工作台或联系人页,会议列表先不查询,只等 Tab 切回来时再检查。

项目里的 onReloadKeyChanged() 会先收到全局 reload 信号,再结合 currentTabIndex 判断当前是否在会议列表 Tab。当前就是会议列表时才执行 checkAndLoad()loadFilters();如果不是当前 Tab,则把刷新留到页面重新显示时处理。

private onReloadKeyChanged(): void {
  if (this.currentTabIndex === MY_TAB_INDEX) {
    this.checkAndLoad();
    this.loadFilters();
  }
}

private onTabIndexChange(): void {
  if (this.currentTabIndex === MY_TAB_INDEX) {
    setTimeout(() => {
      this.checkAndLoad();
    }, 50);
  }
}

这里留了一个短延迟,是为了等 Tab 切换状态完成以后再检查版本。页面刚切回来的瞬间,有些组件状态还在更新,马上查数据不一定有必要。实际项目里,这个延迟很短,只是让页面切换和数据刷新不要挤在同一个时刻。

真正加载前,页面还会比较本地版本和全局版本。

private checkAndLoad(force: boolean = false) {
  if (!force && this.lastLoadedKey === this.reloadKey) {
    return;
  }

  this.loadData(true);
}

项目里的 checkAndLoad() 也是这种逻辑:本地版本和全局版本一致时跳过刷新,本地版本落后时重新加载列表。loadData(true) 会把分页偏移重置为 0,并在刷新成功后把 lastLoadedKey 更新成当前 reloadKey

会议列表执行刷新时,会把分页偏移重置为 0,把 hasMore 重新设为 true,再按当前搜索关键词和筛选条件查询。刷新成功后,页面把 lastLoadedKey 更新成当前 reloadKey。这一步很重要,因为页面只有在真正加载成功后,才能说自己已经同步到最新版本。

我在这里会保留一个页面状态原则:页面层负责判断刷新时机,子组件只负责展示数据。刷新信号可以全局共享,但分页、搜索、筛选、选中项这些状态不要交给全局工具类处理。工具类一旦知道页面怎么分页、怎么筛选、怎么展示,后面每多一个页面,刷新工具都会变得越来越重。

状态 放置位置 原因
MeetingReloadKey AppStorage 多个页面都要知道会议数据变化
lastLoadedKey 页面内部 每个页面自己记录加载到哪个版本
searchKeyword 会议列表页面 只有会议列表知道当前搜索条件
pageOffset 会议列表页面 分页属于列表自己的加载状态
currentTabIndex 主 Tab 传入页面 页面根据可见性决定刷新时机

这个表里最值得留意的是 lastLoadedKey。它不是全局状态,因为每个页面加载节奏不同。会议列表可能已经刷新,工作台可能还没刷新,联系人列表也可能和会议数据没有关系。每个页面保留自己的本地版本,刷新判断才不会互相干扰。

这个思路适合当前项目的一个原因,是会议数据不是高频实时数据。会议新增、删除、编辑都属于低频业务动作,推进一个全局版本号就够用。如果后面做多人协作、云端实时同步、会议实时转写列表,那就不能只靠这种轻量 key 处理了。到那个阶段,数据版本、更新时间、冲突处理都要进入数据层设计。

三、当前页面先响应,其他页面再同步

真实项目里,触发刷新信号的位置很多。新建会议保存、会议详情编辑、会议列表删除、联系人新增、项目新增,都会让一部分页面的数据过期。

会议列表删除一条会议时,我不会等待全局信号再更新当前页面。当前列表已经知道用户删的是哪一条会议,就可以先从数组里移除这一项。删除成功后,再调用 RefreshUtil.notifyMeetingUpdate()。这个信号是给其他会议相关页面看的,比如工作台统计、其他筛选入口、桌面卡片数据。项目里的删除逻辑就是先删除数据库记录,再从当前 meetings 数组移除,最后发送会议刷新信号。

await deleteMeeting(this.hostCtx, meeting.id);

const index = this.meetings.findIndex((m) => m.id === meeting.id);

if (index !== -1) {
  this.meetings.splice(index, 1);
}

RefreshUtil.notifyMeetingUpdate();

这个顺序和用户操作有关。用户刚删除一条会议,当前列表应该马上有反馈。如果页面等待全局 key 变化后重新查询,删除动作会显得慢,尤其是在会议记录多、筛选条件复杂的时候。当前页面先响应,其他页面稍后同步,这个节奏更适合这类列表操作。

联系人新增也类似。联系人编辑弹窗确认后,当前联系人列表可以直接刷新一次,然后再调用 RefreshUtil.notifyContactUpdate()。这个信号不只是给当前页面用,还会影响联系人选择器、会议参会人列表、其他联系人入口。

我一般会按下面这张表处理。

操作场景 当前页面处理 全局信号处理
删除会议 当前列表先移除这一项 通知工作台、其他会议页面数据变化
新建会议 保存后返回上层页面 通知会议列表和统计数据变化
编辑会议标题 当前详情页通过返回回调重新加载 通知列表标题和工作台统计变化
新增联系人 当前联系人页刷新列表 通知联系人选择器和其他联系人入口
新增项目 当前项目页刷新列表 通知项目详情和会议筛选入口

这里的取舍很明确。当前页面负责把用户刚做的操作反馈出来,全局信号负责通知其他页面版本已经变化。这样页面之间不用互相调用,也不会把刷新逻辑写成一张复杂的调用网。

如果后面要处理更复杂的场景,比如正在编辑中的表单收到全局刷新信号,我不会直接覆盖用户输入。编辑页可以提示数据已经变化,也可以在保存前做冲突检查,但不能因为别的页面发出刷新信号,就把当前输入框里的内容重置掉。列表和统计页适合自动刷新,编辑页要保守一些。

这里也能看出这套方案的边界。它适合通知列表、统计、选择器重新读取数据,不适合承载复杂业务事件。比如会议保存失败、云端同步冲突、录音文件上传进度,这些都不应该塞进一个 reloadKey 里。刷新 key 只表达数据变化,不表达业务过程。

四、用一个页面验证刷新关系

我把这个机制压缩成一个 Index.ets 示例。页面里有三个 Tab:工作台、会议列表、联系人列表。顶部按钮模拟新增会议和新增联系人。每个 Tab 都展示自己的全局 key、本地 key、刷新次数和待刷新状态。

这里没有连接真实数据库,所有数据都保存在页面状态里。这样可以把刷新信号的行为展示得更清楚:当前可见的页面马上刷新,隐藏页面记录待刷新状态,切回对应 Tab 后再比较本地 key 和全局 key。

这个小页面和真实项目的对应关系如下。

小页面里的内容 真实项目里的位置
meetingReloadKey MeetingReloadKey
contactReloadKey ContactReloadKey
meetingListLoadedKey 会议列表里的 lastLoadedKey
contactListLoadedKey 联系人列表里的 lastLoadedKey
checkMeetingList() 会议列表里的 checkAndLoad()
switchTab() 主 Tab 切换后触发页面检查

这个演示页要观察两条路径。

第一条路径是会议数据变化。停留在工作台,点击新增会议,工作台当前可见,会立即同步会议统计;会议列表不在当前 Tab,只记录待刷新。切换到会议列表以后,会议列表发现本地 lastLoadedKey 落后于全局 MeetingReloadKey,再执行刷新。

第二条路径是联系人数据变化。停留在会议列表,点击新增联系人。会议列表不会刷新,因为它只关心会议数据。工作台和联系人列表会记录联系人数据变化。切换到联系人列表以后,联系人列表会根据 ContactReloadKey 完成延迟刷新。

这两个路径能说明一个页面状态原则:全局信号只推动版本变化,页面加载仍然属于页面自己的职责。如果把查询逻辑写进 RefreshUtil,工具类就会知道会议列表怎么分页、联系人列表怎么搜索、工作台怎么统计,边界会越来越模糊。

五、迁回项目时保留边界

回到真实项目时,这个小页面里的按钮和模拟数据都要删掉,保留这套刷新关系就够了。

小页面里的逻辑 真实项目里的处理
meetingReloadKey += 1 RefreshUtil.notifyMeetingUpdate()
contactReloadKey += 1 RefreshUtil.notifyContactUpdate()
页面本地 lastLoadedKey MeetingListPageContactListPageProjectListPage 内部版本记录
Tab 切换后检查版本 onTabIndexChange()
立即刷新和延迟刷新 onReloadKeyChanged() 里的可见性判断
refreshWorkbench() 工作台重新计算会议和联系人统计
refreshMeetingList() 会议列表重新查询分页数据
refreshContactList() 联系人列表重新查询联系人数据

这个刷新机制适合列表、统计、选择器这类读多写少的页面。它不适合直接覆盖正在编辑的表单。比如用户正在编辑会议标题,另一个入口发出了 MeetingReloadKey,编辑页不能马上把输入框覆盖掉。更稳的处理方式是提示数据可能变化,或者在保存前做冲突判断。

我会把刷新信号看成一个版本提醒,而不是强制同步命令。它提醒页面某类数据发生过变化,页面要不要刷新、什么时候刷新、怎么保留当前输入,都应该由页面自己决定。这样 AppStorage 的职责会保持很轻,页面也不会被全局信号牵着走。

后续如果项目里出现更多数据域,比如录音文件上传状态、转写任务状态、云端同步状态,我不会继续把所有内容都塞进 reloadKey。列表刷新仍然可以保留轻量 key,任务进度和同步状态则要用更明确的数据结构来表达。千万不要把刷新信号写成万能事件中心。

总结

这套刷新信号实现思路适合《会议随记 Pro》当前阶段。它没有试图接管所有页面状态,只是让会议、联系人、项目这几类数据变化能被相关页面感知到。RefreshUtil 推进 MeetingReloadKeyContactReloadKeyProjectReloadKey,页面通过 @StorageProp@Watch 接收变化,再结合当前 Tab、分页状态和本地版本决定是否重新加载。

这个边界保留下来以后,新增会议不需要知道会议列表、工作台和桌面卡片谁在监听;会议列表也不需要知道数据来自新建页、编辑页还是删除操作。页面只要比较全局 key 和本地 lastLoadedKey,就能判断自己是否落后。对列表和统计页来说,这个判断已经足够。对正在编辑的表单页,我会继续保守处理,不让全局刷新信号直接覆盖用户输入。

这套方案的适用范围也要放在心里。它适合本地数据为主、页面数量可控、刷新事件不高频的应用。如果后面变成多人协作、云端实时同步或者高频任务进度更新,就要把数据版本、同步状态和冲突处理放到更完整的数据层里,不能继续只靠一个全局 key 承接所有变化。

这套刷新机制已经放进我的《会议随记 Pro》里使用,应用目前已经上架华为应用市场。里面包含会议录音、时间轴笔记、联系人、项目、标签管理和多设备适配这些功能。对鸿蒙原生应用的完整实现感兴趣的话,可以下载体验一下:会议随记 Pro

完整代码

interface RefreshLog {
  id: number;
  source: string;
  content: string;
}

interface MeetingItem {
  id: string;
  title: string;
  summary: string;
  updatedAt: number;
}

interface ContactItem {
  id: string;
  name: string;
  company: string;
  updatedAt: number;
}

enum DemoTab {
  Workbench = 0,
  MeetingList = 1,
  ContactList = 2
}

@Entry
@Component
struct Index {
  @State currentTab: DemoTab = DemoTab.Workbench;

  @State meetingReloadKey: number = 0;
  @State contactReloadKey: number = 0;

  @State workbenchMeetingKey: number = 0;
  @State workbenchContactKey: number = 0;
  @State meetingListLoadedKey: number = -1;
  @State contactListLoadedKey: number = -1;

  @State workbenchRefreshCount: number = 0;
  @State meetingRefreshCount: number = 0;
  @State contactRefreshCount: number = 0;

  @State meetingPending: boolean = false;
  @State contactPending: boolean = false;
  @State workbenchPending: boolean = false;

  @State workbenchMeetingCount: number = 3;
  @State workbenchContactCount: number = 5;

  @State workbenchLastReason: string = '工作台已读取初始统计';
  @State meetingLastReason: string = '会议列表尚未加载';
  @State contactLastReason: string = '联系人列表尚未加载';

  @State meetingDb: MeetingItem[] = [
    {
      id: 'meeting-001',
      title: '产品评审会',
      summary: '确认多设备适配范围',
      updatedAt: 1717819200000
    },
    {
      id: 'meeting-002',
      title: '录音链路复盘',
      summary: '整理录音状态机和保存流程',
      updatedAt: 1717905600000
    },
    {
      id: 'meeting-003',
      title: '桌面卡片讨论',
      summary: '确认卡片刷新和数据入口',
      updatedAt: 1717992000000
    }
  ];

  @State contactDb: ContactItem[] = [
    {
      id: 'contact-001',
      name: '张晨',
      company: '产品组',
      updatedAt: 1717819200000
    },
    {
      id: 'contact-002',
      name: '林夏',
      company: '设计组',
      updatedAt: 1717905600000
    },
    {
      id: 'contact-003',
      name: '周远',
      company: '研发组',
      updatedAt: 1717992000000
    },
    {
      id: 'contact-004',
      name: '陈安',
      company: '测试组',
      updatedAt: 1718078400000
    },
    {
      id: 'contact-005',
      name: '赵宁',
      company: '运营组',
      updatedAt: 1718164800000
    }
  ];

  @State meetingRows: MeetingItem[] = [];
  @State contactRows: ContactItem[] = [];

  @State logSeed: number = 0;
  @State logs: RefreshLog[] = [];

  private addLog(source: string, content: string): void {
    const next: RefreshLog = {
      id: this.logSeed + 1,
      source: source,
      content: content
    };

    this.logSeed = next.id;
    this.logs = [next, ...this.logs].slice(0, 16);
  }

  private getNextMeetingId(nextIndex: number): string {
    return `meeting-${nextIndex.toString().padStart(3, '0')}`;
  }

  private getNextContactId(nextIndex: number): string {
    return `contact-${nextIndex.toString().padStart(3, '0')}`;
  }

  private notifyMeetingUpdate(reason: string): void {
    const nextKey = this.meetingReloadKey + 1;
    const nextIndex = this.meetingDb.length + 1;
    const now = Date.now();

    const nextMeeting: MeetingItem = {
      id: this.getNextMeetingId(nextIndex),
      title: `新增会议 ${nextIndex}`,
      summary: `由按钮模拟创建,会议版本=${nextKey}`,
      updatedAt: now
    };

    const nextMeetingDb: MeetingItem[] = [nextMeeting, ...this.meetingDb];

    this.meetingReloadKey = nextKey;
    this.meetingDb = nextMeetingDb;

    this.addLog('MeetingReloadKey', `${reason},全局会议版本推进到 ${nextKey}`);
    this.receiveMeetingSignal(nextKey, nextMeetingDb);
  }

  private notifyContactUpdate(reason: string): void {
    const nextKey = this.contactReloadKey + 1;
    const nextIndex = this.contactDb.length + 1;
    const now = Date.now();

    const nextContact: ContactItem = {
      id: this.getNextContactId(nextIndex),
      name: `新联系人 ${nextIndex}`,
      company: `模拟来源,联系人版本=${nextKey}`,
      updatedAt: now
    };

    const nextContactDb: ContactItem[] = [nextContact, ...this.contactDb];

    this.contactReloadKey = nextKey;
    this.contactDb = nextContactDb;

    this.addLog('ContactReloadKey', `${reason},全局联系人版本推进到 ${nextKey}`);
    this.receiveContactSignal(nextKey, nextContactDb);
  }

  private receiveMeetingSignal(nextMeetingKey: number, nextMeetingDb: MeetingItem[]): void {
    if (this.currentTab === DemoTab.Workbench) {
      this.refreshWorkbench(
        '工作台当前可见,立即读取会议统计',
        nextMeetingKey,
        this.contactReloadKey,
        nextMeetingDb.length,
        this.workbenchContactCount
      );
    } else {
      this.workbenchPending = true;
      this.addLog('Workbench', '工作台不在当前 Tab,先记录统计待刷新');
    }

    if (this.currentTab === DemoTab.MeetingList) {
      this.refreshMeetingList(
        '会议列表当前可见,立即读取会议列表快照',
        nextMeetingKey,
        nextMeetingDb
      );
    } else {
      this.meetingPending = true;
      this.addLog('MeetingList', '会议列表不在当前 Tab,等待切换回来后再刷新');
    }

    if (this.currentTab === DemoTab.ContactList) {
      this.addLog('ContactList', '联系人列表不依赖会议数据,本页保持当前状态');
    }
  }

  private receiveContactSignal(nextContactKey: number, nextContactDb: ContactItem[]): void {
    if (this.currentTab === DemoTab.Workbench) {
      this.refreshWorkbench(
        '工作台当前可见,立即读取联系人统计',
        this.meetingReloadKey,
        nextContactKey,
        this.workbenchMeetingCount,
        nextContactDb.length
      );
    } else {
      this.workbenchPending = true;
      this.addLog('Workbench', '工作台不在当前 Tab,先记录统计待刷新');
    }

    if (this.currentTab === DemoTab.ContactList) {
      this.refreshContactList(
        '联系人列表当前可见,立即读取联系人列表快照',
        nextContactKey,
        nextContactDb
      );
    } else {
      this.contactPending = true;
      this.addLog('ContactList', '联系人列表不在当前 Tab,等待切换回来后再刷新');
    }

    if (this.currentTab === DemoTab.MeetingList) {
      this.addLog('MeetingList', '会议列表不依赖联系人数据,本页保持当前状态');
    }
  }

  private switchTab(target: DemoTab): void {
    this.currentTab = target;

    if (target === DemoTab.Workbench) {
      this.addLog('Tab', '切换到工作台,检查会议和联系人版本差异');
      this.checkWorkbench();
      return;
    }

    if (target === DemoTab.MeetingList) {
      this.addLog('Tab', '切换到会议列表,检查 MeetingReloadKey');
      this.checkMeetingList();
      return;
    }

    this.addLog('Tab', '切换到联系人列表,检查 ContactReloadKey');
    this.checkContactList();
  }

  private checkWorkbench(): void {
    if (this.workbenchMeetingKey !== this.meetingReloadKey ||
      this.workbenchContactKey !== this.contactReloadKey) {
      this.refreshWorkbench(
        '工作台重新显示,发现统计依赖的数据已经变化',
        this.meetingReloadKey,
        this.contactReloadKey,
        this.meetingDb.length,
        this.contactDb.length
      );
      return;
    }

    this.workbenchPending = false;
    this.addLog('Workbench', '工作台本地统计已经同步,不需要重新加载');
  }

  private checkMeetingList(): void {
    if (this.meetingListLoadedKey !== this.meetingReloadKey) {
      this.refreshMeetingList(
        '会议列表重新显示,发现会议版本已经变化',
        this.meetingReloadKey,
        this.meetingDb
      );
      return;
    }

    this.meetingPending = false;
    this.addLog('MeetingList', '会议列表本地版本已经同步,不需要重新加载');
  }

  private checkContactList(): void {
    if (this.contactListLoadedKey !== this.contactReloadKey) {
      this.refreshContactList(
        '联系人列表重新显示,发现联系人版本已经变化',
        this.contactReloadKey,
        this.contactDb
      );
      return;
    }

    this.contactPending = false;
    this.addLog('ContactList', '联系人列表本地版本已经同步,不需要重新加载');
  }

  private refreshWorkbench(
    reason: string,
    meetingKey: number,
    contactKey: number,
    meetingCount: number,
    contactCount: number
  ): void {
    this.workbenchMeetingKey = meetingKey;
    this.workbenchContactKey = contactKey;
    this.workbenchMeetingCount = meetingCount;
    this.workbenchContactCount = contactCount;
    this.workbenchRefreshCount += 1;
    this.workbenchPending = false;
    this.workbenchLastReason = reason;

    this.addLog(
      'Workbench',
      `${reason},会议=${meetingCount},联系人=${contactCount},刷新次数 ${this.workbenchRefreshCount}`
    );
  }

  private refreshMeetingList(reason: string, meetingKey: number, sourceRows: MeetingItem[]): void {
    this.meetingRows = sourceRows.slice(0, 8);
    this.meetingListLoadedKey = meetingKey;
    this.meetingRefreshCount += 1;
    this.meetingPending = false;
    this.meetingLastReason = reason;

    this.addLog(
      'MeetingList',
      `${reason},列表快照=${sourceRows.slice(0, 8).length} 条,刷新次数 ${this.meetingRefreshCount}`
    );
  }

  private refreshContactList(reason: string, contactKey: number, sourceRows: ContactItem[]): void {
    this.contactRows = sourceRows.slice(0, 8);
    this.contactListLoadedKey = contactKey;
    this.contactRefreshCount += 1;
    this.contactPending = false;
    this.contactLastReason = reason;

    this.addLog(
      'ContactList',
      `${reason},列表快照=${sourceRows.slice(0, 8).length} 条,刷新次数 ${this.contactRefreshCount}`
    );
  }

  @Builder
  private TabButton(label: string, target: DemoTab) {
    Button(label)
      .layoutWeight(1)
      .height(38)
      .fontSize(13)
      .fontColor(this.currentTab === target ? Color.White : '#334155')
      .backgroundColor(this.currentTab === target ? '#2563EB' : '#E2E8F0')
      .borderRadius(19)
      .onClick(() => {
        this.switchTab(target);
      })
  }

  build() {
    Scroll() {
      Column({ space: 18 }) {
        Column({ space: 8 }) {
          Text('全局刷新信号实验')
            .fontSize(26)
            .fontWeight(FontWeight.Bold)
            .fontColor('#0F172A')

          Text('用 MeetingReloadKey 和 ContactReloadKey 模拟真实项目里的跨页面刷新。数据源变化后,当前相关页面立即刷新自己的快照,隐藏页面等切换回来后再补刷新。')
            .fontSize(14)
            .fontColor('#475569')
            .lineHeight(22)
        }
        .alignItems(HorizontalAlign.Start)
        .width('100%')

        Row({ space: 10 }) {
          Button('新增会议')
            .layoutWeight(1)
            .height(42)
            .fontColor(Color.White)
            .backgroundColor('#2563EB')
            .borderRadius(21)
            .onClick(() => {
              this.notifyMeetingUpdate('模拟新增会议');
            })

          Button('新增联系人')
            .layoutWeight(1)
            .height(42)
            .fontColor(Color.White)
            .backgroundColor('#0F766E')
            .borderRadius(21)
            .onClick(() => {
              this.notifyContactUpdate('模拟新增联系人');
            })
        }
        .width('100%')

        Row({ space: 8 }) {
          this.TabButton('工作台', DemoTab.Workbench)
          this.TabButton('会议列表', DemoTab.MeetingList)
          this.TabButton('联系人列表', DemoTab.ContactList)
        }
        .width('100%')

        Column() {
          if (this.currentTab === DemoTab.Workbench) {
            WorkbenchDemoPanel({
              meetingReloadKey: $meetingReloadKey,
              contactReloadKey: $contactReloadKey,
              workbenchMeetingKey: $workbenchMeetingKey,
              workbenchContactKey: $workbenchContactKey,
              workbenchMeetingCount: $workbenchMeetingCount,
              workbenchContactCount: $workbenchContactCount,
              workbenchRefreshCount: $workbenchRefreshCount,
              workbenchPending: $workbenchPending,
              workbenchLastReason: $workbenchLastReason
            })
          } else if (this.currentTab === DemoTab.MeetingList) {
            MeetingListDemoPanel({
              meetingReloadKey: $meetingReloadKey,
              meetingListLoadedKey: $meetingListLoadedKey,
              meetingRefreshCount: $meetingRefreshCount,
              meetingPending: $meetingPending,
              meetingLastReason: $meetingLastReason,
              meetingRows: $meetingRows
            })
          } else {
            ContactListDemoPanel({
              contactReloadKey: $contactReloadKey,
              contactListLoadedKey: $contactListLoadedKey,
              contactRefreshCount: $contactRefreshCount,
              contactPending: $contactPending,
              contactLastReason: $contactLastReason,
              contactRows: $contactRows
            })
          }
        }
        .width('100%')

        RefreshLogPanel({
          logs: $logs
        })
      }
      .width('100%')
      .padding(20)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#EEF2F7')
  }
}

@Component
struct WorkbenchDemoPanel {
  @Link meetingReloadKey: number;
  @Link contactReloadKey: number;
  @Link workbenchMeetingKey: number;
  @Link workbenchContactKey: number;
  @Link workbenchMeetingCount: number;
  @Link workbenchContactCount: number;
  @Link workbenchRefreshCount: number;
  @Link workbenchPending: boolean;
  @Link workbenchLastReason: string;

  @Builder
  private SectionTitle(title: string, desc: string) {
    Column({ space: 8 }) {
      Text(title)
        .fontSize(24)
        .fontWeight(FontWeight.Bold)
        .fontColor('#0F172A')
        .width('100%')

      Text(desc)
        .fontSize(14)
        .fontColor('#475569')
        .lineHeight(22)
        .width('100%')
    }
    .alignItems(HorizontalAlign.Start)
    .width('100%')
  }

  @Builder
  private PendingTag() {
    if (this.workbenchPending) {
      Text('待刷新')
        .fontSize(11)
        .fontColor('#B45309')
        .padding({
          left: 8,
          right: 8,
          top: 3,
          bottom: 3
        })
        .backgroundColor('#FEF3C7')
        .borderRadius(10)
    }
  }

  @Builder
  private MeetingCountCard() {
    Column({ space: 6 }) {
      Row() {
        Text('会议统计快照')
          .fontSize(13)
          .fontColor('#64748B')

        Blank()

        this.PendingTag()
      }
      .width('100%')

      Text(`${this.workbenchMeetingCount} 场会议`)
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .fontColor('#0F172A')

      Text(`本地会议 key=${this.workbenchMeetingKey},全局会议 key=${this.meetingReloadKey}`)
        .fontSize(12)
        .fontColor('#94A3B8')
        .lineHeight(18)
    }
    .alignItems(HorizontalAlign.Start)
    .width('100%')
    .padding(14)
    .backgroundColor(Color.White)
    .borderRadius(16)
    .shadow({
      radius: 10,
      color: '#12000000',
      offsetX: 0,
      offsetY: 3
    })
  }

  @Builder
  private ContactCountCard() {
    Column({ space: 6 }) {
      Row() {
        Text('联系人统计快照')
          .fontSize(13)
          .fontColor('#64748B')

        Blank()

        this.PendingTag()
      }
      .width('100%')

      Text(`${this.workbenchContactCount} 位联系人`)
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .fontColor('#0F172A')

      Text(`本地联系人 key=${this.workbenchContactKey},全局联系人 key=${this.contactReloadKey}`)
        .fontSize(12)
        .fontColor('#94A3B8')
        .lineHeight(18)
    }
    .alignItems(HorizontalAlign.Start)
    .width('100%')
    .padding(14)
    .backgroundColor(Color.White)
    .borderRadius(16)
    .shadow({
      radius: 10,
      color: '#12000000',
      offsetX: 0,
      offsetY: 3
    })
  }

  @Builder
  private RefreshCountCard() {
    Column({ space: 6 }) {
      Text('工作台刷新次数')
        .fontSize(13)
        .fontColor('#64748B')

      Text(this.workbenchRefreshCount.toString())
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .fontColor('#0F172A')

      Text(this.workbenchLastReason)
        .fontSize(12)
        .fontColor('#94A3B8')
        .lineHeight(18)
    }
    .alignItems(HorizontalAlign.Start)
    .width('100%')
    .padding(14)
    .backgroundColor(Color.White)
    .borderRadius(16)
    .shadow({
      radius: 10,
      color: '#12000000',
      offsetX: 0,
      offsetY: 3
    })
  }

  build() {
    Column({ space: 14 }) {
      this.SectionTitle(
        '工作台',
        '工作台展示的是自己的统计快照。会议或联系人变化时,如果工作台当前可见,会立即接收最新数量;如果隐藏,就等切回来再补一次刷新。'
      )

      this.MeetingCountCard()
      this.ContactCountCard()
      this.RefreshCountCard()
    }
    .width('100%')
  }
}

@Component
struct MeetingListDemoPanel {
  @Link meetingReloadKey: number;
  @Link meetingListLoadedKey: number;
  @Link meetingRefreshCount: number;
  @Link meetingPending: boolean;
  @Link meetingLastReason: string;
  @Link meetingRows: MeetingItem[];

  @Builder
  private SectionTitle(title: string, desc: string) {
    Column({ space: 8 }) {
      Text(title)
        .fontSize(24)
        .fontWeight(FontWeight.Bold)
        .fontColor('#0F172A')
        .width('100%')

      Text(desc)
        .fontSize(14)
        .fontColor('#475569')
        .lineHeight(22)
        .width('100%')
    }
    .alignItems(HorizontalAlign.Start)
    .width('100%')
  }

  @Builder
  private PendingTag() {
    if (this.meetingPending) {
      Text('待刷新')
        .fontSize(11)
        .fontColor('#B45309')
        .padding({
          left: 8,
          right: 8,
          top: 3,
          bottom: 3
        })
        .backgroundColor('#FEF3C7')
        .borderRadius(10)
    }
  }

  @Builder
  private StatCard(title: string, value: string, desc: string, pending: boolean) {
    Column({ space: 6 }) {
      Row() {
        Text(title)
          .fontSize(13)
          .fontColor('#64748B')

        Blank()

        if (pending) {
          this.PendingTag()
        }
      }
      .width('100%')

      Text(value)
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .fontColor('#0F172A')
        .maxLines(2)
        .textOverflow({ overflow: TextOverflow.Ellipsis })

      Text(desc)
        .fontSize(12)
        .fontColor('#94A3B8')
        .lineHeight(18)
    }
    .alignItems(HorizontalAlign.Start)
    .width('100%')
    .padding(14)
    .backgroundColor(Color.White)
    .borderRadius(16)
    .shadow({
      radius: 10,
      color: '#12000000',
      offsetX: 0,
      offsetY: 3
    })
  }

  @Builder
  private MeetingRow(item: MeetingItem) {
    Column({ space: 6 }) {
      Row() {
        Text(item.title)
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
          .fontColor('#0F172A')
          .layoutWeight(1)

        Text(item.id)
          .fontSize(11)
          .fontColor('#64748B')
      }
      .width('100%')

      Text(item.summary)
        .fontSize(12)
        .fontColor('#64748B')
        .lineHeight(18)
        .width('100%')

      Text(`updatedAt=${item.updatedAt}`)
        .fontSize(11)
        .fontColor('#94A3B8')
        .width('100%')
    }
    .width('100%')
    .padding(12)
    .backgroundColor('#F8FAFC')
    .borderRadius(14)
  }

  build() {
    Column({ space: 14 }) {
      this.SectionTitle(
        '会议列表',
        '会议列表维护自己的列表快照。当前 Tab 可见时,新增会议会立即刷新这份快照;隐藏时只记录待刷新,切回来以后再读取。'
      )

      this.StatCard(
        '全局 MeetingReloadKey',
        this.meetingReloadKey.toString(),
        '新增、删除、编辑会议都会推进这个版本',
        false
      )

      this.StatCard(
        '本地 lastLoadedKey',
        this.meetingListLoadedKey.toString(),
        '列表刷新成功后,本地版本会追平全局版本',
        this.meetingPending
      )

      this.StatCard(
        '会议列表刷新次数',
        this.meetingRefreshCount.toString(),
        this.meetingLastReason,
        false
      )

      Column({ space: 10 }) {
        Row() {
          Text('会议列表快照')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor('#0F172A')

          Blank()

          Text(`${this.meetingRows.length} 条`)
            .fontSize(12)
            .fontColor('#64748B')
        }
        .width('100%')

        if (this.meetingRows.length === 0) {
          Text('会议列表还没有加载。切换到会议列表时会根据 MeetingReloadKey 拉取一次本地快照。')
            .fontSize(13)
            .fontColor('#94A3B8')
            .lineHeight(20)
            .width('100%')
            .padding(12)
            .backgroundColor('#F8FAFC')
            .borderRadius(14)
        } else {
          ForEach(this.meetingRows, (item: MeetingItem) => {
            this.MeetingRow(item)
          }, (item: MeetingItem) => `${item.id}-${item.updatedAt}`)
        }
      }
      .width('100%')
      .padding(14)
      .backgroundColor(Color.White)
      .borderRadius(18)
    }
    .width('100%')
  }
}

@Component
struct ContactListDemoPanel {
  @Link contactReloadKey: number;
  @Link contactListLoadedKey: number;
  @Link contactRefreshCount: number;
  @Link contactPending: boolean;
  @Link contactLastReason: string;
  @Link contactRows: ContactItem[];

  @Builder
  private SectionTitle(title: string, desc: string) {
    Column({ space: 8 }) {
      Text(title)
        .fontSize(24)
        .fontWeight(FontWeight.Bold)
        .fontColor('#0F172A')
        .width('100%')

      Text(desc)
        .fontSize(14)
        .fontColor('#475569')
        .lineHeight(22)
        .width('100%')
    }
    .alignItems(HorizontalAlign.Start)
    .width('100%')
  }

  @Builder
  private PendingTag() {
    if (this.contactPending) {
      Text('待刷新')
        .fontSize(11)
        .fontColor('#B45309')
        .padding({
          left: 8,
          right: 8,
          top: 3,
          bottom: 3
        })
        .backgroundColor('#FEF3C7')
        .borderRadius(10)
    }
  }

  @Builder
  private StatCard(title: string, value: string, desc: string, pending: boolean) {
    Column({ space: 6 }) {
      Row() {
        Text(title)
          .fontSize(13)
          .fontColor('#64748B')

        Blank()

        if (pending) {
          this.PendingTag()
        }
      }
      .width('100%')

      Text(value)
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .fontColor('#0F172A')
        .maxLines(2)
        .textOverflow({ overflow: TextOverflow.Ellipsis })

      Text(desc)
        .fontSize(12)
        .fontColor('#94A3B8')
        .lineHeight(18)
    }
    .alignItems(HorizontalAlign.Start)
    .width('100%')
    .padding(14)
    .backgroundColor(Color.White)
    .borderRadius(16)
    .shadow({
      radius: 10,
      color: '#12000000',
      offsetX: 0,
      offsetY: 3
    })
  }

  @Builder
  private ContactRow(item: ContactItem) {
    Column({ space: 6 }) {
      Row() {
        Text(item.name)
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
          .fontColor('#0F172A')
          .layoutWeight(1)

        Text(item.id)
          .fontSize(11)
          .fontColor('#64748B')
      }
      .width('100%')

      Text(item.company)
        .fontSize(12)
        .fontColor('#64748B')
        .lineHeight(18)
        .width('100%')

      Text(`updatedAt=${item.updatedAt}`)
        .fontSize(11)
        .fontColor('#94A3B8')
        .width('100%')
    }
    .width('100%')
    .padding(12)
    .backgroundColor('#F8FAFC')
    .borderRadius(14)
  }

  build() {
    Column({ space: 14 }) {
      this.SectionTitle(
        '联系人列表',
        '联系人列表维护自己的列表快照。新增联系人时,如果联系人列表当前可见,会立即刷新;如果隐藏,就等页面重新显示后再读取。'
      )

      this.StatCard(
        '全局 ContactReloadKey',
        this.contactReloadKey.toString(),
        '新增、编辑、删除联系人都会推进这个版本',
        false
      )

      this.StatCard(
        '本地 lastLoadedKey',
        this.contactListLoadedKey.toString(),
        '联系人列表刷新成功后,本地版本会追平全局版本',
        this.contactPending
      )

      this.StatCard(
        '联系人列表刷新次数',
        this.contactRefreshCount.toString(),
        this.contactLastReason,
        false
      )

      Column({ space: 10 }) {
        Row() {
          Text('联系人列表快照')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor('#0F172A')

          Blank()

          Text(`${this.contactRows.length} 条`)
            .fontSize(12)
            .fontColor('#64748B')
        }
        .width('100%')

        if (this.contactRows.length === 0) {
          Text('联系人列表还没有加载。切换到联系人列表时会根据 ContactReloadKey 拉取一次本地快照。')
            .fontSize(13)
            .fontColor('#94A3B8')
            .lineHeight(20)
            .width('100%')
            .padding(12)
            .backgroundColor('#F8FAFC')
            .borderRadius(14)
        } else {
          ForEach(this.contactRows, (item: ContactItem) => {
            this.ContactRow(item)
          }, (item: ContactItem) => `${item.id}-${item.updatedAt}`)
        }
      }
      .width('100%')
      .padding(14)
      .backgroundColor(Color.White)
      .borderRadius(18)
    }
    .width('100%')
  }
}

@Component
struct RefreshLogPanel {
  @Link logs: RefreshLog[];

  build() {
    Column({ space: 12 }) {
      Text('刷新日志')
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .fontColor('#0F172A')
        .width('100%')

      if (this.logs.length === 0) {
        Text('还没有刷新记录')
          .fontSize(13)
          .fontColor('#94A3B8')
          .width('100%')
          .padding(14)
          .backgroundColor('#F8FAFC')
          .borderRadius(14)
      } else {
        ForEach(this.logs, (item: RefreshLog) => {
          Row({ space: 10 }) {
            Text(item.source)
              .fontSize(11)
              .fontColor('#1D4ED8')
              .padding({
                left: 8,
                right: 8,
                top: 3,
                bottom: 3
              })
              .backgroundColor('#DBEAFE')
              .borderRadius(10)

            Text(item.content)
              .fontSize(13)
              .fontColor('#334155')
              .lineHeight(20)
              .layoutWeight(1)
          }
          .width('100%')
          .alignItems(VerticalAlign.Top)
          .padding(12)
          .backgroundColor('#F8FAFC')
          .borderRadius(14)
        }, (item: RefreshLog) => item.id.toString())
      }
    }
    .width('100%')
    .padding(16)
    .backgroundColor(Color.White)
    .borderRadius(20)
  }
}
Logo

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

更多推荐