跳到主要内容

第 14 章:Web、Desktop、Mobile 多端适配

14.1 三端不是同一应用套三个壳

当前代码形成两条复用路线:

  • Web + Desktop:共享 Core、UI、Views,Host 适配路由和系统能力;
  • Mobile:共享 API 语义和少量纯类型/函数,但拥有独立数据层、实时层和原生 UI。

这是有意的边界。Desktop 与 Web 都是 DOM/React;Mobile 的生命周期、交互、缓存成本和组件体系不同,强行共享 Runtime Component 往往比复制语义更脆弱。

14.2 多端责任表

维度WebDesktopMobile
UI RuntimeNext.js / DOMElectron Renderer / DOMReact Native / Expo
路由Next App Router单 Router + Tab Session CoordinatorExpo Router
认证Cookie 为主,兼容旧 TokenDesktop Auth SessionSecure Store Token
Daemon外部进程,仅看 Server 状态主进程直接管理本机 Daemon不管理
实时Core 集中同步Core 集中同步 + IPC Bridge独立三层 WS
页面packages/viewspackages/views + Desktop 页面Mobile 自有 Screen
系统能力Browser APIIPC、Tray、Updater、文件选择iOS/RN Native API

14.3 Web 组合

Next 入口位于 apps/web/appweb-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 分成:

  1. Main Process:Window、Daemon、Updater、Tray/System Integration;
  2. Preload:暴露最小、类型化 IPC;
  3. 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 是独立产品表面

apps/mobile/CLAUDE.md 明确:

  • import type Core 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 的“必须一致”

移动端规则锁定四项:

  1. Count / Visibility:同 Filter/Pagination/Coalescing 下数量一致;
  2. Permission / Access:镜像 Core 中的权限规则;
  3. State Enum / Transition:所有状态都有展示和未知值回退;
  4. 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 三层

移动实时链路:

文件责任
Socketws-client.ts单连接、Jitter Backoff、Idle/Active/Paused
Providerrealtime-provider.tsxAuth、Workspace、AppState、NetInfo 生命周期
Feature Hookdata/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 的新增交互按顺序选择:

  1. iOS/React Native 原生 API;
  2. React Native Reusables;
  3. 都不满足时再讨论新 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 新增跨端功能的落地顺序

  1. 定义 Server API、权限和 Event;
  2. 在 Core 实现 Web/Desktop 共享语义;
  3. 在 Views 完成 DOM 交互;
  4. 由 Web/ Desktop Adapter 接路由与系统能力;
  5. Mobile 阅读真实 Core/Views 实现;
  6. 列出 Count、Permission、State、Identity 的一致点;
  7. 决定 Mobile 必须不同的交互;
  8. 建独立 Query/Realtime/Screen;
  9. 做同账号、同 Workspace 的并排行为验证;
  10. 检查旧版本客户端降级。

14.24 常见错误

错误后果
Views 导入 Next RouterDesktop 无法复用
Renderer 直接用 Node APIElectron 安全边界破坏
Desktop 每 Tab 建 QueryClient缓存、WS 和 Workspace 状态重复
Mobile 导入 Web Cache UpdaterQuery Key/Shape 漂移后静默失效
用像素一致替代语义一致移动端交互差且数据行为仍可能不同
假定三端同时升级新枚举或字段让旧端崩溃
从文档版本而非 Manifest 判断依赖分析到已过期技术栈

14.25 本章结论

Multica 的多端策略可以概括为:

  • Web 与 Desktop 共享业务内核和 Views;
  • Host Adapter 吸收路由、认证与系统差异;
  • Desktop 额外拥有 Daemon 与窗口控制;
  • Mobile 复制产品语义,不复制 DOM Runtime;
  • API 与事件契约允许版本错位。

“共享什么”与“不共享什么”同样是架构成果。