# 任务模块「即时同步」方案 > 状态:**方案设计,未实施**(本文只描述做法,不含代码改动) > 范围:任务模块(Project / BoardColumn / Task / TaskMember / TaskTag) > 相关文档:[0.2.1 实施方案](./0.2.1实施方案.md)、[PowerSync 可行性研究报告](./PowerSync可行性研究报告.md) > 编写时间: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`): ```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`): ```jsx // 现状 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 已自动刷新,但端到端延迟仍有 15~30s(未解决) P0 落地后实测(真实 Windows 外壳 + 服务端直接改库模拟另一端): | 项 | 结果 | | --- | --- | | 是否还需要切页面 | **不需要** —— 服务端改完标题,页面在无任何操作的情况下自动更新 ✅ | | 端到端延迟 | 反复测量为 **15~30s 且波动**,远超 ≤1s 目标 ❌ | **排查过程与结论(注意:初版猜测"nginx 缓冲"是错的,已撤回)** | 步骤 | 证据 | 结论 | | --- | --- | --- | | 客户端是否建立 WebSocket | CDP Network:**无 WebSocket**,只有 `POST /sync/stream` | PowerSync 走 HTTP 流式,属正常 | | 是否 nginx 缓冲导致 | 为 `iboard.honor3.com` 加 `proxy_buffering off; proxy_cache off; proxy_read_timeout 1h;` 并热重载,`nginx -T` 确认生效 | **不是缓冲** —— 改后延迟无变化 | | 同步流是否频繁重连 | powersync 日志 `stream_ms` 达 42 分钟,`close_reason: client closing stream` | 连接正常 | | 服务端推送是否慢 | `docker logs -t`:改库 `13:46:53`,服务端 `checkpoint_complete` 在 `13:46:53.738`(另一次约 6s) | **服务端推送很快**(亚秒~数秒) | | 客户端本地库何时更新 | 临时把 db 挂到 `window.__psdb` 轮询 | 数据确实到达客户端本地库 | | 界面何时更新 | 同一轮询里 DOM 更新明显晚于数据到达 | **瓶颈在客户端「应用→通知→渲染」这段** | 即:**服务端推得快、数据也确实进了客户端本地库,但 `db.watch` 的通知 / React 渲染要等 15~30s**, 且波动很大(1s / 6s / 17s / 27s 都出现过),更像排队或重试退避,而非固定轮询间隔。 **已做的运维改动(保留)**:`iboard.honor3.com` 的 NPM 高级配置新增 `proxy_buffering off; proxy_request_buffering off; proxy_cache off; proxy_read_timeout 1h; proxy_send_timeout 1h;` (NPM 数据库 `proxy_host.advanced_config` 与当前生效 conf 均已更新,零停机热重载)。 它没能解决本问题,但对长连接流式接口属合理配置,故保留。 **下一步(未做)**: 1. 给 `useDbSignal` 临时加时间戳日志,区分「watch 回调延迟」与「`load()` 之后渲染延迟」; 2. 若 watch 回调本身滞后,改用 `useQuery`(`@powersync/react` 的 watched query); 3. 对比 Android 端(无 SharedWorker、走独立 worker)的同一测量,判断是否与 worker 拓扑相关; 4. 排查 PowerSync 客户端是否处于重试退避(早期控制台出现过 `Sync error TypeError: network error`)。 | 项 | 说明 | | --- | --- | | 打卡 / 清单 | 走 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 约半天到一天即可上线验证。