Files
iboard/docs/V0.2/离线编辑模块/即时同步方案.md
T
jif2.zhang fd4079c9c3
Docker Build and Push / build-image (push) Failing after 1m48s
feat(任务): 即时同步 —— 订阅本地库变更, 跨端改动无需切页自动刷新
现象: 多端同时打开同一项目时, 一端改完, 另一端必须切到别的页面再切回来才更新。

根因: 不是同步没到, 而是 UI 从未订阅本地库变化 ——
PowerSync 的下行推送 (PG 逻辑复制 → sync service → 客户端本地 SQLite) 本身正常,
但任务模块的读全是命令式 db().getAll() (api/task.js), 读完不再看;
lib/powersync/react.jsx 虽然导出了 useQuery/useStatus, 全项目一次都没用过;
TaskPage/ProjectView 只在 useEffect 里 load() 一次, 连 useRefreshOnVisible 都没接
(只有打卡与宏观页面在用)。「切面板能刷新」纯粹是因为组件重新挂载又 load 了一次。

实现 (docs/V0.2/离线编辑模块/即时同步方案.md, 方案 C):
- 新增 lib/powersync/useDbSignal.js: 用 db.watch() 的表级过滤 (tables) + 节流
  (throttleMs) + AbortSignal, 订阅本地库变更; 跳过订阅时的首次通知, 避免与
  挂载时的 load() 重复。
  (初版设计是手写 count/sum/max(updatedAt) 的「信号 SQL」, 实施时发现 SDK 原生
   支持 tables 过滤, 更可靠, 已改为表过滤并在文档中记录变更。)
- api/task.js 新增 TASK_TABLES / PROJECT_TABLES 常量 (表名须与客户端 schema 一致)。
- ProjectView: 订阅任务域 (Task/BoardColumn/TaskMember/TaskTag) → 重算;
  TaskPage: 订阅项目域 (Project/ProjectMember) → 重算。
- 不打断操作: BoardView/ListView 上报 onDragActiveChange, 拖拽期间收到变更只记
  pending, 松手后补一次刷新, 避免卡片在手指下跳位。
- 回前台兜底: 两页都接 useRefreshOnVisible (WebView 后台可能节流订阅回调)。

验证 (真实 Windows 外壳 + CDP, 服务端直接改库模拟另一端):
- 服务端改任务标题后, 页面在**无任何操作**的情况下自动更新 (三次复测均成功);
- 改回原标题后同样自动还原; 测试数据已恢复。

已知瓶颈 (不在 UI 层, 另见文档 §7.0):
- 实测端到端延迟约 13~14s, 未达 ≤1s 目标;
- CDP Network 观察客户端: 无 WebSocket, 仅 /sync/stream 长连接;
- NPM (baishe nginx-app-1, proxy_host/5.conf) 未关闭 proxy_buffering,
  流式响应被 nginx 缓冲后按批下发, 与观测到的固定批次延迟吻合;
- 修复属运维配置 (proxy_buffering off 等), 需单独评估后执行。
2026-10-27 20:57:05 +08:00

14 KiB
Raw Blame History

任务模块「即时同步」方案

状态:方案设计,未实施(本文只描述做法,不含代码改动) 范围:任务模块(Project / BoardColumn / Task / TaskMember / TaskTag) 相关文档:0.2.1 实施方案、PowerSync 可行性研究报告 编写时间:2026-10-27


1. 问题与现状

1.1 现象

多端(Windows 外壳 / Android / Web)同时打开同一项目时:

  • A 端点完成或改状态,B 端不会自动更新;
  • 必须切到别的页面再切回来(组件重新挂载触发重新查询)才能看到;
  • 看板分组(按 columnId)与详情状态(按 status)也会出现一段时间的显示不一致。

1.2 根因:不是"同步没到",而是"到了没人通知界面"

PowerSync 的下行链路本身是推送式的(服务端 PG 逻辑复制 → sync service → WebSocket → 客户端写入本地 SQLite),延迟通常在亚秒级。问题出在消费端:

层 现状 结论
传输层 PowerSyncProvider 已在 src/App.jsx 挂载,连接正常 ✅ 正常
数据层 变化已写入本地 SQLite ✅ 正常
UI 层 全部是命令式读取:db().getAll(...),读完就不再看 ❌ 断点在这

具体地:

  • src/api/task.js 的 projectApi.list/detail、taskApi.list 都是一次性 getAll(), 且在 JS 里做二次装配(子任务、成员、标签、列),没有任何订阅;
  • src/lib/powersync/react.jsx 虽然 re-export 了 useQuery/useStatus, 但全项目一次都没用过(grep useQuery src 只命中该文件的导出语句);
  • src/pages/task/TaskPage.jsx、ProjectView.jsx 只在 useEffect(..., [load]) 里加载一次;
  • 唯一的刷新机制是 src/hooks/useRefreshOnVisible.js(回前台/窗口聚焦时重查), 而且任务模块这两个页面根本没有用它(只有打卡 MergedView/ListView 与宏观各页在用)。

所以「切面板能刷新」只是因为它重新挂载组件 → 重新执行一次 load()。

1.3 现状刷新机制盘点

页面 首次加载 回前台刷新 数据变化实时反映
TaskPage useEffect ❌ 无 ❌
ProjectView useEffect ❌ 无 ❌(仅本地操作后手动 load())
TaskDetail 打开时传入快照 — ❌(快照可能过期)
MergedView(打卡) useEffect ✅ ❌(且打卡走 REST,非 PowerSync)

2. 目标与非目标

2.1 目标

  1. 跨端即时:一端修改,其它端在 ≤1s 内自动呈现(不含网络中断场景);
  2. 不打断操作:正在输入的详情表单、正在拖拽的卡片不被远端变更打断;
  3. 离线可用:断网期间照常读写本地库,恢复后自动补齐并刷新;
  4. 改动可控:优先复用现有装配逻辑(taskApi 的 shape),不做大规模重写;
  5. 可回滚:通过开关或逐步替换,出问题能退回"手动刷新"。

2.2 非目标

  • 不做多人协同编辑(同一字段并发写仍按 LWW 后到覆盖);
  • 不解决打卡/清单模块的实时性(它们走 REST,见 §7);
  • 不引入 WebSocket 之外的额外实时通道(如 SSE)。

3. 方案选型

方案 做法 优点 缺点 结论
A. 轮询 定时(如 3s)重跑 load() 改动最小 无谓查询、电量/流量、仍不"即时" ❌
B. 全量响应式 把 taskApi.list 整体改成一条大 SQL + useQuery 最"正统",细粒度 现有装配(子任务/成员/标签)要在 SQL 里重写;改动面大、回归风险高 ⏳ 后续
C. 变更信号 + 重算(推荐) 用 db.watch() 订阅一个轻量"信号查询",信号变化时重跑现有 load() 复用现有装配逻辑;改动集中且小;天然合并多次变更 每次重算一次列表(数据量小,可接受) ✅ P0 采用
D. 表级广播 监听"某表有变更"事件触发重查 实现最简单 无法区分项目/范围,易全页面重查 作为 C 的退化实现

结论:P0 用方案 C;跑稳后再评估把高频视图(看板)迁到 B 的增量查询。


4. 推荐方案详设(方案 C)

4.1 核心思路

本地 SQLite  ←(PowerSync 下行写入)— 任一端/服务端变更
      │
      └─▶ db.watch(<信号查询>)  ── 变化 ──▶ 节流合并 ──▶ 重跑 load()
                                                      └─▶ setState 渲染

"信号查询" 是一段廉价但能覆盖所有相关表的 SQL:只要任务域的任一相关行变化, 其结果就会变;它不承担业务语义,只负责触发。

4.2 订阅范围:用 SDK 的表级过滤(已实施)

初版设计是手写一段"信号 SQL"(count / sum / max(updatedAt) 汇总), 实施时发现 db.watch() 原生支持 tables 表名过滤 与 throttleMs 节流, 由 SDK 判断"结果是否真的变了",比自己做汇总更可靠,因此改为表级过滤。

CommonPowerSyncDatabase.watch(sql, params, handler, options) 的 options:

选项 用途
tables: string[] 只关心这些表的变更,SDK 内部按表失效
throttleMs 两次回调之间的最小间隔,天然合并同一批同步写入
signal: AbortSignal 卸载时取消订阅

订阅内容(src/lib/powersync/useDbSignal.js):

db.watch('SELECT 1 AS s', [], { onResult, onError }, {
  tables: ['Task', 'BoardColumn', 'TaskMember', 'TaskTag'],
  throttleMs: 250,
  signal: ac.signal,
})

查询本身(SELECT 1)没有业务含义,只借 watch 的变更通知能力。表名集合定义在 src/api/task.js:

常量 内容 用于
TASK_TABLES Task, BoardColumn, TaskMember, TaskTag 项目内任务列表 / 看板
PROJECT_TABLES Project, ProjectMember 项目列表、成员

之所以不需要 max(updatedAt):表级过滤关心的是"行有没有被写过", 而不是"结果集内容是否变化",所以改标题、改状态这类不增删的写入同样会触发。

4.3 UI 层接入

提供两个小工具,避免每个页面各写一套:

工具 位置(建议) 作用
useDbSignal(sql, params, onChange) src/lib/powersync/useDbSignal.js(新增) 封装 db.watch() + 节流 + 卸载清理
useTaskListSignal(projectId, onChange) src/api/task.js 或同目录(新增) 拼好上述信号 SQL,页面只关心"变了要干嘛"

接入示意(ProjectView):

// 现状
useEffect(() => { setTasks(null); load() }, [load])

// 改造后(示意)
useEffect(() => { setTasks(null); load() }, [load])
useDbSignal(TASK_LIST_SIGNAL_SQL, [projectId, projectId, projectId, projectId, projectId, projectId], () => {
  if (dragActiveRef.current) return   // 见 §4.4
  load()
})

TaskPage 同理,信号为项目表。

4.4 变更合并与"不打断"策略

这是本方案的关键,否则会出现「打字打到一半表单被重置」「拖到一半卡片跳走」。

场景 策略
连续多次变更(一次同步写多行) useDbSignal 内部 150~300ms 防抖,合并成一次 load()
看板/列表拖拽进行中 用 dragActiveRef 跳过本次刷新;拖拽结束后补一次 load()
任务详情页打开中 不自动刷新表单;改为监听该任务是否变化,变化时在详情顶部显示「该任务已在他端更新,点击刷新」
正在编辑标题/描述 同上传:不覆盖输入框,只提示
本地乐观更新进行中(updateTask 未完成) 以本地 patch 为准,load() 延后到请求结束(现有 updateTask 已有 snapshot 回滚,保留)

4.5 同步状态指示

用已导出的 useStatus() 在任务页顶部显示一个极轻量的状态点:

状态 展示
connected && dataFlowStatus.connected 不显示(无干扰)
正在下载/上传 细小的同步图标
离线 / 连接失败 「离线,改动将在联网后同步」

说明:当前外壳里 PowerSync 会打 Multiple tab support is not enabled 的提示, 属预期(已在 0.2.1 关闭多标签以避免 Windows reload 后本地库挂起),不影响本方案。

4.6 离线与重连

  • 断网:db.watch() 仍监听本地库,本地写入照常触发刷新(体验一致);
  • 恢复:PowerSync 自动重连并回放,写入本地库后信号触发刷新,无需额外逻辑;
  • 应用回到前台:保留 useRefreshOnVisible 作为兜底(WebView 可能挂起渲染线程), 与 db.watch() 并存不冲突(两者都只是调 load())。

5. 改造清单(按文件)

文件 改动 状态
src/lib/powersync/useDbSignal.js 新增:db.watch + 表过滤 + 节流 + 卸载清理 ✅ P0 已完成
src/api/task.js 新增 TASK_TABLES / PROJECT_TABLES 常量 ✅ P0
src/pages/task/ProjectView.jsx 订阅任务域;拖拽中延后刷新;回前台兜底 ✅ P0
src/pages/task/TaskPage.jsx 订阅项目域;回前台兜底 ✅ P0
src/pages/task/BoardView.jsx / ListView.jsx 上报 onDragActiveChange ✅ P0
src/pages/task/TaskDetail.jsx 「已在他端更新」提示(不覆盖表单) ⏳ P1
同步状态指示 用 useStatus() 显示离线/同步中 ⏳ P1

不需要改动:server/、sync-config.yaml、数据库结构。


6. 分期与验收

6.1 分期

阶段 内容 预估
P0 useDbSignal + ProjectView/TaskPage 接入 + 拖拽保护 0.5~1 天
P1 详情页「已在他端更新」提示 + 同步状态指示 0.5 天
P2 看板改增量查询(方案 B);打卡/清单的实时性另议 按需

6.2 验收标准(可测)

  1. 跨端延迟:A 端改状态/拖列/改标题,B 端 ≤1s 自动更新(同一局域网/公网各测一次);
  2. 不打断:B 端正在输入标题时,A 端改同任务描述 → B 端输入框内容不被清空,仅出现提示;
  3. 拖拽安全:B 端拖拽中收到远端变更 → 卡片不跳位,松手后列表更新;
  4. 离线:断网期间本地操作正常,恢复后自动补齐并刷新;
  5. 回滚:置开关关闭后,行为退回当前"手动刷新",无副作用;
  6. 性能:单项目 100 条任务、连续 20 次远端写入,页面无卡顿、无重复渲染风暴(可用 Performance 面板核对)。

6.3 度量

  • 在 load() 内打点(可临时 console.time),统计重算耗时与触发次数;
  • 目标:一次远端变更只触发 1 次 重算;100 条任务时单次重算 < 60ms。

7. 已知边界与后续

7.0 实测:UI 已自动刷新,但传输层延迟约 14s(待修)

P0 落地后实测(真实 Windows 外壳 + 服务端直接改库):

项 结果
是否还需要切页面 不需要 —— 服务端改完标题,页面在无任何操作的情况下自动更新 ✅
端到端延迟 约 13~14s(三次复测一致),远超 ≤1s 目标 ❌

排查结论:延迟不在 UI 层。用 CDP Network 观察客户端,发现:

  • 没有任何 WebSocket 连接;
  • 只有两次 https://iboard.honor3.com/sync/stream 请求。

而 NPM(baishe 上的 nginx-app-1,配置 /data/nginx/proxy_host/5.conf)对 iboard.honor3.com 只设置了 Upgrade / Connection / proxy_http_version 1.1, 没有关闭 proxy_buffering(nginx 默认开启)。PowerSync 的 /sync/stream 是长连接流式(NDJSON)响应,被 nginx 缓冲后按批下发 —— 与观测到的固定 ~14s 批次延迟吻合。

待办(运维,非代码):为 iboard.honor3.com 的 /sync 增加

proxy_buffering off;
proxy_cache off;
proxy_read_timeout 1h;      # 避免长连接被掐断
chunked_transfer_encoding on;

改完需重载 nginx,并复测延迟。注意该 NPM 实例同时承载 gitea / homepage 等站点, 改动应只作用于该 proxy_host,避免影响其它服务。

项 说明
打卡 / 清单 走 REST(src/api/checkin.js),不经过本地库,本项目方案不覆盖;如需实时,可复用现有 useRefreshOnVisible + 定时轮询,或后续把这些模块也迁到 PowerSync
并发写冲突 仍为 LWW(updatedAt 后到覆盖);本文不引入人工合并
排序并发 taskApi.reorder 会整批重写 sortOrder,两端同时拖拽可能相互覆盖;建议 P2 再引入"版本号/操作序列"
大列表 方案 C 每次全量重算,任务数极大(>1000)时需转方案 B 的增量查询
WebView 后台节流 应用在后台时 watch 回调可能被延后,回前台由 useRefreshOnVisible 兜底

8. 附:关键 API 备忘

API 来源 用途
db.watch(sql, params, options) @powersync/common CommonPowerSyncDatabase 订阅查询结果,返回 AsyncIterable
useQuery(sql, params, options) @powersync/react React 版订阅;options.rowComparator 可做增量更新(P2 用)
useStatus() @powersync/react 连接/同步状态
getPowersyncDb() src/lib/powersync/db.js 本地库单例(当前唯一入口)

注:src/lib/powersync/react.jsx 已 re-export useQuery/useStatus/usePowerSync, 本次改造无需新增依赖。


9. 结论

当前"必须切面板才刷新"不是同步没到,而是 UI 从未订阅本地库变化。 采用「变更信号 + 复用现有装配重算」(方案 C)可在改动面很小的情况下获得亚秒级跨端同步, 并把"不打断编辑/拖拽"作为硬性要求一并纳入。P0 约半天到一天即可上线验证。