Skip to content

三、核心架构亮点深度解析 (Core Design)

3.1 离线优先(Offline-First)

3.1.1 设计思路:为什么用 Outbox Pattern

大多数应用的写操作是"网络优先"的:先发请求,成功后再更新 UI。这意味着一旦断网,用户什么也做不了。

TodoApp 反过来——本地优先:所有写操作先写本地 SQLite,UI 立即响应,网络请求是后台异步进行的附加动作。这套模式在分布式系统领域有一个正式名字:Outbox Pattern(发件箱模式)

Outbox Pattern 的核心思想是:

把"我想做的操作"先记录在一个本地发件箱(sync_queue)里,由后台进程负责把发件箱里的操作可靠地投递出去。应用本体不等待投递结果,继续正常运行。

这和你写邮件的体验一致——点击发送后邮件进入发件箱,你可以继续写下一封,投递是邮件客户端后台完成的事情。


3.1.2 离线队列数据结构

sync_queue 表存储所有待同步的操作:

字段类型说明
idINTEGER PK自增主键,保证出队顺序
todo_idTEXT操作对象的待办 ID
operationTEXT操作类型:create / update / delete
payloadTEXT操作内容的 JSON 序列化(delete 操作为空)
created_atINTEGER入队时间戳(毫秒)
user_idTEXT所属用户,访客模式不入队

每次用户在断网状态下新建、编辑、删除待办,TodoRepository 在写本地 SQLite 的同时,会把这次操作的类型和数据序列化后写入 sync_queue。联网后按 id 升序逐条重放,保证操作顺序与用户操作顺序一致。


3.1.3 队列生命周期

mermaid
flowchart LR
    A([用户写操作]) --> B[写入本地 SQLite]
    B --> C{是否联网?}
    C -->|联网| D[直接发送 REST 请求]
    C -->|断网| E[写入 sync_queue]
    D --> F{服务端响应}
    F -->|成功| G([完成])
    F -->|失败| E
    E --> H([等待联网])
    H --> I[SyncService 重放队列]
    I --> J{服务端响应}
    J -->|成功| K[从 sync_queue 删除]
    J -->|失败| H
    K --> G

值得注意的是,联网时的请求失败也会写入队列——网络抖动、服务端临时不可用等情况下,操作不会丢失,下次联网时自动重试。


3.1.4 冲突处理策略

当多端对同一条待办进行了不同的修改,存在数据冲突的可能。本项目采用服务端数据为权威来源的策略:

  • 本地操作先入队,联网后按序推送至服务端
  • 服务端处理后,客户端拉取服务端最新数据覆盖本地
  • upsertFromServer 方法执行合并:本地不存在则插入,已存在则直接覆盖

这种策略简单可靠,适合待办事项这类"最后写入胜出"(Last Write Wins)语义合理的场景——用户在手机上改了标题,又在电脑上改了截止时间,两次操作都能保留,不存在真正的语义冲突。


3.1.5 网络状态监听

使用 connectivity_plus 插件监听设备网络状态变化:

dart
Connectivity().onConnectivityChanged.listen((results) {
  final hasNetwork = results.any((r) => r != ConnectivityResult.none);
  if (hasNetwork && AuthService.instance.isLoggedIn) {
    SyncService.instance.syncAll();  // 联网后触发全量同步
  }
});

syncAll() 内部按顺序执行两步:

  1. 重放离线队列:把 sync_queue 里的操作逐条推送至服务端
  2. 拉取最新数据:从服务端拉取完整数据,合并到本地 SQLite

3.2 实时最终一致性

3.2.1 为什么选 SSE 而非 WebSocket

维度SSEWebSocket
通信方向单向(服务端 → 客户端)双向
协议复杂度基于标准 HTTP,无需升级需要协议握手升级
Nginx 配置关闭 proxy_buffering 即可需要配置 Upgrade 头
断线重连HTTP 客户端自动处理需要手动实现
适用场景服务端推送通知聊天、实时协作

本项目的实时需求是单向推送:某个设备写了数据,服务端通知其他设备去拉取。客户端不需要通过长连接向服务端发送消息,WebSocket 的双向能力完全用不上,引入它只会增加实现复杂度。


3.2.2 SSE 连接管理:EventBroadcaster

后端用 EventBroadcaster 单例管理所有用户的 SSE 连接,核心数据结构是一个嵌套 Map:

connections: Map<userId, List<StreamController>>

每个用户可以有多个设备同时在线,对应多个 StreamController。广播时遍历该用户下所有连接逐一推送,连接断开时自动从列表中清除。


3.2.3 Client-ID 过滤:防止发起端重复渲染

没有过滤机制时,设备 A 发起写操作后,服务端广播事件,设备 A 自己也会收到这条事件,触发一次不必要的增量同步——本地数据明明已经是最新的,却再拉取一遍服务端数据。

解决方案:每台设备启动时生成一个唯一的 deviceId(存入本地持久化),每次请求都在 Header 里携带 X-Device-Id。服务端广播事件时把 deviceId 一并带上,客户端收到事件后判断:

dart
if (eventDeviceId == myDeviceId) {
  // 自己发起的变更,本地已是最新,忽略
  return;
}
// 其他设备的变更,触发增量同步
SyncService.instance.syncIncremental();

3.2.4 指数退避重连策略

SSE 连接断开后,客户端不会立即重连(避免服务端瞬间收到大量重连请求),而是采用指数退避策略:

重连次数等待时间
第 1 次3 秒
第 2 次6 秒
第 3 次12 秒
第 4 次及以后最长 30 秒
dart
final delaySecs = min(30, 3 * pow(2, retryCount - 1)).toInt();

3.2.5 心跳机制:防止代理超时断连

Nginx 等反向代理默认会在连接一段时间没有数据传输时主动断开。为了保持 SSE 长连接不被代理层切断,后端每 20 秒向所有活跃连接发送一行注释:

: ping

这是 SSE 协议规定的注释格式(以 : 开头),客户端会忽略它的内容,但它让连接保持活跃,骗过代理的超时检测。


3.3 跨平台响应式外壳

3.3.1 PlatformUtil 平台判断工具

dart
class PlatformUtil {
  static bool get isMobile =>
      defaultTargetPlatform == TargetPlatform.android ||
      defaultTargetPlatform == TargetPlatform.iOS;

  static bool get isDesktop =>
      defaultTargetPlatform == TargetPlatform.windows ||
      defaultTargetPlatform == TargetPlatform.macOS ||
      defaultTargetPlatform == TargetPlatform.linux;
}

整个项目所有需要区分平台的地方都通过 PlatformUtil 统一判断,避免在各处散落 Platform.isAndroid / Platform.isWindows 的判断逻辑。


3.3.2 Win11:NavigationRail 侧边栏布局

桌面端采用左侧导航栏布局,符合 Windows 桌面应用的使用习惯:

  • 左侧固定宽度的 NavigationRail,展示分类标签(全部 / 待处理 / 优先 / 已完成)
  • 右侧主内容区域随窗口尺寸自适应伸缩
  • 点击待办卡片弹出详情弹窗,编辑和删除操作在详情弹窗内完成
  • 关闭按钮拦截:点击窗口关闭按钮触发最小化到系统托盘,而非退出应用

3.3.3 Android:BottomNavigationBar 底栏布局

移动端采用底部导航栏布局,符合 Android Material Design 规范:

  • 底部 BottomNavigationBar,拇指可轻松触达
  • 右滑手势(flutter_slidable)展开编辑 / 删除按钮,符合移动端交互习惯
  • 切换标签时所有已展开的滑动操作自动收起(SlidableAutoCloseBehavior
  • 弹窗内边距根据平台动态调整,防止移动端内容溢出屏幕

3.3.4 共享核心业务组件

两端共享同一套业务组件,平台差异只在外层"壳"里处理:

组件功能平台差异
TodoListPanel待办列表 + 搜索 + 排序
TodoItemCard单条待办卡片桌面端点击进详情 / 移动端 Slidable 右滑
TodoFormDialog新建 / 编辑弹窗弹窗内边距按平台调整
TodoDetailDialog待办详情弹窗弹窗宽度按平台调整
LoginScreen登录页
RegisterScreen注册页