智绘鸿蒙 · 如 7 而至端侧 AI 能力实战手记

左屏共享PPT右屏写批注

智绘鸿蒙·如 7 而至

前言

线上会议最常见的一个动作:主讲人共享一页 PPT,参会者在另一侧写批注。平板横过来用平行视界分栏,正好一边一个。

配置层面该做的(easy_go.jsonhomePage/relatedPage、比例、全屏页)都已经配好了,页面也确实左右并排显示。真正的问题从这一步才开始:右屏写了一条批注,左屏那行会不会跟着变?

不会。而且原因有两层,都不是"平行视界不支持"这么简单。下面用一组 A/B 实验把这两层拆开。

实验设置

demo 是一个行情列表:左屏 pages/Index(自选列表),右屏 pages/StockDetail(详情)。给每只标的加一个批注列表,右屏提供两个写入按钮:

按钮 行为
裸写 NoteStore 里加一条,动 AppStorage
写入并通知 加一条,并把 AppStorage 里的 noteVer 自增 1

左屏每行末尾渲染 批注N,其中 N 来自 NoteStore.count(code)。设备为 MatePad Pro(HarmonyOS 7 / API 26),横屏,右屏自报宽度 878vp(isEasySplit=true)。

第一层:数据写进去了,界面不刷新

先给 NoteStore 一个最朴素的实现——模块级 Map 加一个静态计数器,左屏用 @StorageLink('noteVer') 声明了版本号,但渲染函数里没有引用它

@StorageLink('noteVer') noteVer: number = 0;   // 声明了

Text(`批注${NoteStore.count(item.code)}`)      // 这里没读 noteVer

结果:

右屏:本页计数 1 · 裸写 0 次 / 通知 1 次 · ver=1
左屏:批注0  批注0  批注0  批注0  批注0  批注0

右屏自己刷新了(因为它的渲染树里真的读了 this.noteVer),左屏六个行全部停在 0,即使 ver 已经变成 1。

把左屏那一行的模板改成真的读一次版本号:

Text(`批注${NoteStore.count(item.code)}${this.noteVer >= 0 ? '' : ''}`)

再点一次"写入并通知":

左屏:批注1  批注0  批注1  批注0 …(贵州茅台、紫金矿业各 1 条)

立刻正确。**结论:@StorageLink 建立的是"渲染依赖",不是"订阅"。**只在组件里声明而不在渲染路径上读取,框架不会把这个组件登记为观察者。这条规则在不分栏的单页场景同样成立,只是分栏让两个页面同时可见,才让这个差异一眼能看出来。

写法上更干净的做法是把版本号做成参数传给 @Builder,或者干脆把每行的计数存成 @ObservedV2 的字段——但只要"渲染时没读到那个状态变量",结果就一样是不刷新。

第二层:页面模块的顶层变量,每个实例一份

为了观察右屏是不是同一个页面实例,我在 StockDetail.ets 里加了一个模块级计数器:

let INST_SEQ: number = 0;          // 页面文件顶层

@Entry @Component struct StockDetail {
  aboutToAppear(): void {
    INST_SEQ = INST_SEQ + 1;
    this.inst = INST_SEQ;
  }
}

连续切换 5 只标的,aboutToAppear 每次都触发(日志 5 行),但 INST_SEQ 的值是:

inst=1  inst=1  inst=1  inst=1  inst=1

同一个文件里的 NoteStore(定义在 model/Stocks.ets,被两个页面 import)却明显是共享的:

换到第 3 只标的写完后:裸写 0 次 / 通知 2 次 · ver=2

通知次数从 1 累加到 2,跨页面实例、跨标的切换都还在。同样是"模块级状态",页面文件的会重置,普通 model 文件的不会。

所以规则可以写成一句:要跨分栏页面共享的可变状态,不要放在 pages/*.ets 的顶层,放到独立的 model 模块或 AppStorage 里。 前者每个页面实例一份,看起来像"全局变量",实际是"实例变量",而且不会报错,只会静默不累加。

顺带一个观察:private static seq 写在 struct 内部也是同一个效果——测出来同样是 1,说明 struct 上的 static 在这个场景里并不能当全局用。

第三层:右屏每次都是新页面

aboutToAppear 在每次点击左行时都触发(5 次点击 5 次日志),说明导航模式下右屏是重建而不是复用。这对批注功能有直接影响:

  • 右屏里的 @State(比如输入框草稿)在切换标的时会丢;
  • 任何"只初始化一次"的假设都不成立,包括在 aboutToAppear 里做订阅——必须配对的 aboutToDisappear 取消订阅,否则切五次标的就挂五个监听。

草稿要保住的正确做法是把草稿也放进 NoteStore(按 code 存),而不是指望页面实例活着。

实测数据

场景 左屏显示 右屏 ver 判定
裸写 1 条 批注0 0 数据已写,界面不刷新
通知写 1 条(渲染未读版本号) 批注0 1 依然不刷新
通知写 1 条(渲染已读版本号) 批注1 1 正确刷新
切标的后再通知写 1 条 批注1 / 批注1(分属两只) 2 按 code 精确显示
连续切换 5 只标的 aboutToAppear 触发 5 次,INST_SEQ 恒为 1
项目 实测值
设备 MatePad Pro(HarmonyOS 7 / API 26,序列号略),横屏
右屏页面宽度 878vp(isEasySplit=true,屏幕 1318vp)
单次跨屏刷新 点按钮到左屏数字变化,肉眼无延迟(同一帧内)
批注写入总次数 裸写 0 / 通知 2

左屏两行显示批注1,右屏是另一只标的的批注输入区

左屏放大:按 code 精确计数,未写入的行仍为 0

总结

这套"左列表 + 右详情 + 共享 model + AppStorage 版本号"的结构,适合一切分栏下的双向联动:会议批注、订单详情改状态、邮件列表标已读、商品详情改库存。它的成本只是把一个版本号读进渲染表达式。

不适合跨进程、跨设备。AppStorage 的作用域是单个 Ability 进程,平行视界只是同一进程里的两个页面;一旦要跨设备同步批注,就得换成分布式数据对象或后端。

💡 一句话:分栏下两个页面同时可见,最容易暴露一个平时看不见的规则——状态变量必须在渲染里被读到,界面才会跟着变。

这类联动怎么验收

把上面三条转成可执行的检查,每次改分栏配置或状态层都能跑一遍:

  1. 只写不通知。 右屏改数据、不碰 AppStorage,看左屏是否变化。变了说明你其实依赖的是别的刷新时机(比如列表自身滚动),这种"碰巧对"在真实数据量下一定会失效。
  2. 通知但渲染不读。 故意把版本号从渲染表达式里去掉,确认左屏确实不刷新。这一步是防止有人日后"顺手清理无用变量",把同步逻辑删掉 yet 测试全绿。
  3. 切 5 次标的再回来看草稿。 草稿丢了是预期行为,但要确认批注没丢——它存在 model 里,不在页面里。
  4. 竖屏回来再看一次。 退出分栏后两个页面合成一个,@StorageLink 的依赖关系不变,但右屏实例会被销毁;如果第 3 步的订阅没配对取消,这里最容易暴露重复回调。

总结

这套"左列表 + 右详情 + 共享 model + AppStorage 版本号"的结构,适合一切分栏下的双向联动:会议批注、订单详情改状态、邮件列表标已读、商品详情改库存。它的成本只是把一个版本号读进渲染表达式。

不适合跨进程、跨设备。AppStorage 的作用域是单个 Ability 进程,平行视界只是同一进程里的两个页面;一旦要跨设备同步批注,就得换成分布式数据对象或后端。

💡 一句话:分栏下两个页面同时可见,最容易暴露一条平时看不见的规则——状态变量必须在渲染里被读到,界面才会跟着变。

参考文献

  1. 平行视界(最佳实践):https://developer.huawei.com/consumer/cn/doc/best-practices/bpta-easygo-parallel
  2. 多设备界面开发(最佳实践):https://developer.huawei.com/consumer/cn/doc/best-practices/bpta-multi-device-page
  3. module.json5 配置文件:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/module-configuration-file
  4. UIContext(isEasySplit)API 参考:https://developer.huawei.com/consumer/cn/doc/harmonyos-references/arkts-apis-uicontext-uicontext