Appearance
三、核心架构亮点深度解析 (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 表存储所有待同步的操作:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | INTEGER PK | 自增主键,保证出队顺序 |
| todo_id | TEXT | 操作对象的待办 ID |
| operation | TEXT | 操作类型:create / update / delete |
| payload | TEXT | 操作内容的 JSON 序列化(delete 操作为空) |
| created_at | INTEGER | 入队时间戳(毫秒) |
| user_id | TEXT | 所属用户,访客模式不入队 |
每次用户在断网状态下新建、编辑、删除待办,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() 内部按顺序执行两步:
- 重放离线队列:把 sync_queue 里的操作逐条推送至服务端
- 拉取最新数据:从服务端拉取完整数据,合并到本地 SQLite
3.2 实时最终一致性
3.2.1 为什么选 SSE 而非 WebSocket
| 维度 | SSE | WebSocket |
|---|---|---|
| 通信方向 | 单向(服务端 → 客户端) | 双向 |
| 协议复杂度 | 基于标准 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 | 注册页 | 无 |