第 14 章:Web、Desktop、Mobile 多端适配
14.1 三端不是同一应用套三个壳
当前代码形成两条复用路线:
- Web + Desktop:共享 Core、UI、Views,Host 适配路由和系统能力;
- Mobile:共享 API 语义和少量纯类型/函数,但拥有独立数据层、实时层和原生 UI。
这是有意的边界。Desktop 与 Web 都是 DOM/React;Mobile 的生命周期、交互、缓存成本和组件体系不同,强行共享 Runtime Component 往往比复制语义更脆弱。
14.2 多端责任表
| 维度 | Web | Desktop | Mobile |
|---|---|---|---|
| UI Runtime | Next.js / DOM | Electron Renderer / DOM | React Native / Expo |
| 路由 | Next App Router | 单 Router + Tab Session Coordinator | Expo Router |
| 认证 | Cookie 为主,兼容旧 Token | Desktop Auth Session | Secure Store Token |
| Daemon | 外部进程,仅看 Server 状态 | 主进程直接管理本机 Daemon | 不管理 |
| 实时 | Core 集中同步 | Core 集中同步 + IPC Bridge | 独立三层 WS |
| 页面 | packages/views | packages/views + Desktop 页面 | Mobile 自有 Screen |
| 系统能力 | Browser API | IPC、Tray、Updater、文件选择 | iOS/RN Native API |
14.3 Web 组合
Next 入口位于 apps/web/app,web-providers.tsx 把 Host 信息交给 CoreProvider:
- API Base URL;
- 同源推导的 WebSocket URL;
- Web Client Identity 和版本;
- Cookie Auth 模式;
- Browser Cookie Locale Adapter;
- Login/Logout 后的 Cookie 与 Store 清理。
WebSocket 默认从 Page Origin 推导:
- HTTPS → WSS;
- HTTP → WS;
- 路径为
/ws。
因此自托管反向代理需要同时正确转发 HTTP API 和 Upgrade。
14.4 Web 仍兼容旧 Token
WebProviders 会检查 LocalStorage 的旧 multica_token:
- 有旧 Token:本次 Session 保持 Token 模式;
- 没有:使用 Cookie Auth;
- 下一次 Logout/Login 完成迁移。
源码注释明确 LocalStorage Token 暴露于 XSS,所以这只是迁移兼容,不是推荐新架构。
14.5 Web Navigation Adapter
apps/web/platform/navigation.tsx 把 Next Router 变成 Views 所需的 NavigationAdapter:
- push / replace / back;
- pathname / searchParams;
- Shareable URL;
- Prefetch;
- 监听共享内容发出的
multica:navigate事件。
Views 只知道“跳到某个 App Path”,不知道底下是 Next Router。
14.6 Desktop 的三进程边界
Electron 分成:
- Main Process:Window、Daemon、Updater、Tray/System Integration;
- Preload:暴露最小、类型化 IPC;
- Renderer:共享 Core/Views 加 Desktop Shell。
主要入口:
Renderer 不应直接调用 Node API;所有特权动作都经 Preload 暴露的白名单。
14.7 Desktop 为什么直接管理 Daemon
daemon-manager.ts 负责本机 Daemon 生命周期,例如:
- 找到或 Bootstrap CLI;
- 读取 Runtime Config;
- 启动/停止子进程;
- 捕获和解析日志;
- 探测认证;
- 把状态通过 IPC 交给 Renderer。
这让 Desktop 能呈现“本机 Runtime 正在启动/鉴权失败”等 Server 还没来得及观察到的状态。
Renderer 的 daemon-ipc-bridge.ts 把本地 IPC 状态补进 Runtime Query Cache,延迟低于 Server 的离线 Sweeper。
14.8 Desktop Tab 不是多个 Router
当前 Desktop 使用一个 Router,Tab Store 保存多个导航 Session:
- Active Tab 的 URL 是当前 Router Location;
- 切 Tab 时 Coordinator 把 Router 调到该 Session;
- History 由 Session 保存;
- Pin 的 Tab 对跨 Path Push 有特殊规则;
- 同资源可按 Resource Key 去重;
- Workspace 切换由 Tab Group 协调。
相关源码:
一个 Tab 不是一套完整 React Root/QueryClient。这样缓存和实时连接只有一份,但必须认真处理 Active Workspace Singleton。
14.9 Desktop Navigation Adapter 的附加语义
同一个 push(path) 在 Desktop 可能变成:
- 普通 Tab 内导航;
- 切换 Workspace Tab Group;
- 从 Pinned Tab 打开新 Tab;
- 打开 Onboarding/Invitation Window Overlay;
- Login Path 触发 Logout;
- 内容链接打开前景 Tab。
而 Web 只是 Router Push。平台差异被 Adapter 吸收,Views 不写 if electron。
14.10 分享 URL 与应用 URL 分开
Desktop 自己运行在 Electron Origin,但复制给人的链接必须是 Web App URL。Navigation Adapter 从受校验的 Runtime Config 取得 appUrl,用它生成 Shareable URL。
若 Runtime Config 未被 App 接受,Adapter 直接抛出 Invariant Error,而不是生成一个不可用的 file:// 或内部地址。
14.11 Desktop 系统能力
Main Process 还封装:
- Auto Updater 与更新偏好;
- Window State;
- Context Menu;
- 外部 URL 白名单与系统浏览器;
- Navigation Guard;
- Keyboard Shortcuts/Gestures;
- Native Notification Gate;
- Renderer Freeze Recovery;
- Local Directory Picker。
这些文件集中在 apps/desktop/src/main。共享 Views 通过 Platform Adapter/IPC 请求能力,不直接依赖 Electron。
14.12 Mobile 是独立产品表面
- 可
import typeCore Types; - 可复用 Core 的纯函数;
- 其余 Mobile 自己实现。
当前 package.json 的实际依赖快照是:
- Expo 55;
- React Native 0.83.6;
- React 19.2.0;
- Expo Router 55;
- NativeWind 4 + Tailwind 3.4;
- TanStack Query 5;
- Zustand 5;
- Expo Secure Store。
Mobile 规则文档中的部分版本描述与 Package Manifest 已有小幅漂移;分析当前代码时应以 Manifest/Lockfile 为准。
14.13 为什么 Mobile 不直接复用 Views
Mobile 与 DOM Views 有结构性差异:
- Pointer/Hover 与 Touch/Keyboard 不同;
- Browser Sidebar 不适合窄屏;
- iOS Navigation、Sheet、Action Sheet 有原生语义;
- React Native 没有 DOM/CSS;
- App 进入后台后 Socket 和 Query Focus 生命周期不同;
- 蜂窝网络下 Refetch 成本更高。
所以要求的是产品语义一致,而非 JSX 一致。
14.14 Mobile 的“必须一致”
移动端规则锁定四项:
- Count / Visibility:同 Filter/Pagination/Coalescing 下数量一致;
- Permission / Access:镜像 Core 中的权限规则;
- State Enum / Transition:所有状态都有展示和未知值回退;
- Data Identity:ID、Slug、Canonical Fields 不另造。
这比像素一致更重要。一个端显示三条 Inbox、另一个去重后显示一条,会破坏产品心智模型。
14.15 Inbox Incident 揭示的共享边界
Mobile 规则记录过一个典型问题:
- API 返回原始 Inbox Rows;
- Web 会过滤 Archived,并按 Issue 只保留最新通知;
- Mobile 第一版直接渲染原始 Rows;
- 同一个 Issue 因评论、状态、分配出现三个未读点。
正确做法不是导入整套 Web Query,而是在 Mobile 镜像 deduplicateInboxItems 的产品算法,并标注事实源。
这说明“共享 API”不自动等于“共享显示语义”。
14.16 Mobile 数据层
Mobile 在 apps/mobile/data 自己维护:
- ApiClient;
- Zod Schemas;
- Query Key 与 Queries;
- Auth/Workspace Secure Store;
- Chat/Comment Selection Store;
- 各领域 Zustand Store。
它可以对照 Core 的 Endpoint 和算法,但不绑定 Core 的 QueryClient 实例或 Web Cache Shape。
14.17 Mobile Realtime 三层
移动实时链路:
| 层 | 文件 | 责任 |
|---|---|---|
| Socket | ws-client.ts | 单连接、Jitter Backoff、Idle/Active/Paused |
| Provider | realtime-provider.tsx | Auth、Workspace、AppState、NetInfo 生命周期 |
| Feature Hook | data/realtime | 事件过滤与领域 Cache Patch |
它不复用 Web 的集中 useRealtimeSync。
14.18 Mobile Subscription 的两级挂载
- 列表级:随 Workspace Layout 常驻,例如 Inbox、My Issues;
- 记录级:随 Detail Screen 挂载,只过滤当前 Issue/Chat ID,离开页面即退订。
若把所有记录级 Hook 全局挂载,打开过 N 个 Issue 后,每个 Event 会跑 N 次过滤和 Cache 写入。
14.19 Mobile 更偏向 Patch
Web 可以较多 Invalidate;Mobile 在 WS Payload 足够时优先 setQueryData:
- 减少蜂窝网络请求;
- 只在 Payload 不完整、Cache Shape 复杂或 Reconnect 时 Refetch;
- 每个 Feature Hook 在 Reconnect 只失效自己拥有的 Keys;
- 不做全局 Invalidate Sweep。
Mobile Updater 自己绑定 Mobile Query Keys,因为 Web 与 Mobile Cache Shape 已不同。
14.20 Optimistic 与事件冲突
Mobile 规则明确采用“Event Always Wins”:
- 本地 Mutation 可先 Optimistic Patch;
- 若另一端的 Server Event 随后到达,就覆盖 Optimistic State;
- 不用客户端时间戳保护本地猜测。
短暂 Flicker 可以接受,Server 权威性优先。
14.21 原生交互优先级
Mobile 的新增交互按顺序选择:
- iOS/React Native 原生 API;
- React Native Reusables;
- 都不满足时再讨论新 Primitive。
例如 Confirm 用 Alert.alert,Action Sheet 用 ActionSheetIOS,文件用 Expo Document Picker。不要用一个自绘 Modal 模仿系统行为。
14.22 多端版本与发布节奏
三端版本不一定同步:
- Web 可随 Server 部署;
- Desktop 有独立 App/CLI Updater;
- Mobile 使用
mobile-v*Tag、EAS Build/Submit,JS-only 修复可走 OTA; - 主 CI 会排除 Mobile,Mobile 有独立 Verify Workflow。
因此 API 必须支持一定的旧客户端窗口;Zod Fallback、未知枚举 Default 与 Server 向后兼容不是可选项。
14.23 新增跨端功能的落地顺序
- 定义 Server API、权限和 Event;
- 在 Core 实现 Web/Desktop 共享语义;
- 在 Views 完成 DOM 交互;
- 由 Web/ Desktop Adapter 接路由与系统能力;
- Mobile 阅读真实 Core/Views 实现;
- 列出 Count、Permission、State、Identity 的一致点;
- 决定 Mobile 必须不同的交互;
- 建独立 Query/Realtime/Screen;
- 做同账号、同 Workspace 的并排行为验证;
- 检查旧版本客户端降级。
14.24 常见错误
| 错误 | 后果 |
|---|---|
| Views 导入 Next Router | Desktop 无法复用 |
| Renderer 直接用 Node API | Electron 安全边界破坏 |
| Desktop 每 Tab 建 QueryClient | 缓存、WS 和 Workspace 状态重复 |
| Mobile 导入 Web Cache Updater | Query Key/Shape 漂移后静默失效 |
| 用像素一致替代语义一致 | 移动端交互差且数据行为仍可能不同 |
| 假定三端同时升级 | 新枚举或字段让旧端崩溃 |
| 从文档版本而非 Manifest 判断依赖 | 分析到已过期技术栈 |
14.25 本章结论
Multica 的多端策略可以概括为:
- Web 与 Desktop 共享业务内核和 Views;
- Host Adapter 吸收路由、认证与系统差异;
- Desktop 额外拥有 Daemon 与窗口控制;
- Mobile 复制产品语义,不复制 DOM Runtime;
- API 与事件契约允许版本错位。
“共享什么”与“不共享什么”同样是架构成果。