# 左屏共享PPT右屏写批注 # 智绘鸿蒙·如 7 而至#

## 前言

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

配置层面该做的（`easy_go.json` 的 `homePage`/`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')` 声明了版本号，但**渲染函数里没有引用它**。

```typescript
@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。

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

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

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

```
左屏：批注1  批注0  批注1  批注0 …（贵州茅台、紫金矿业各 1 条）
```

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

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

## 第二层：页面模块的顶层变量，每个实例一份

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

```typescript
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，右屏是另一只标的的批注输入区](assets/01-synced.png)

![左屏放大：按 code 精确计数，未写入的行仍为 0](assets/02-left.png)

## 总结

这套"左列表 + 右详情 + 共享 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
