diff --git a/docs/architecture.md b/docs/architecture.md index 0040d24..cfefe64 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,5 +1,67 @@ # 架构概览 +> **阅读提示**:每节先有 ASCII 快速参考图(终端/纯文本友好),后有 Mermaid 深度图(GitHub / GitLab / Obsidian 渲染)。 + +--- + +## 架构设计原则 + +> 在阅读具体架构图前,先理解这三条原则——它们解释了"为什么这样设计",而非"是什么"。 + +### 依赖倒置(DIP) + +**核心思想**:高层模块(Presentation)依赖抽象(`abstract interface Repository`),低层模块(Data)实现抽象。依赖箭头方向与控制流方向相反。 + +``` +Presentation ──→ Domain(接口)←── Data(实现) + ↑ + [稳定,不变,纯 Dart] +``` + +实际效果: +- `AuthNotifier` 持有 `AuthRepository`(接口),不知道 `AuthRepositoryImpl` 的存在 +- 替换网络层(Dio → GraphQL)只改 `Data` 层,`Domain` 和 `Presentation` 零修改 +- 单元测试直接 Mock 接口,无需真实网络或数据库 + +| 平台 | 对应实现 | +|------|---------| +| Android | MVVM + Repository(Google AAC);ViewModel 持有 Repository 接口;Hilt 注入实现类 | +| iOS | Protocol-oriented 编程;ViewModel 持有 Protocol;SwiftUI `@EnvironmentObject` 或 Swinject 注入实现 | + +### 单一真源(SSOT) + +每份数据只有一个权威来源,其他位置只能读取或镜像: + +| 数据 | 唯一真源 | 镜像位置 | +|------|---------|---------| +| accessToken | SecureStorage(Keychain / EncryptedPrefs)| 无(HTTP Header 是临时注入)| +| refreshToken | SecureStorage | UsersTable.refreshToken(只读可观测)| +| 认证状态 | `authStatusProvider`(ValueNotifier)| GoRouter redirect 函数 | +| 主题配色 | SharedPreferences `theme_scheme` | ThemeNotifier.state(内存缓存)| +| 错误记录 | Drift `error_logs` 表 | Sentry 云端(异步上报)| + +| 平台 | 对应实现 | +|------|---------| +| Android | `StateFlow` / `LiveData` 作为 UI 状态真源;Repository 作为数据真源;`DataStore` 持久化配置 | +| iOS | `@Published` / `@State` 作为 UI 真源;`@AppStorage` / `UserDefaults` 持久化;Core Data 管理本地结构化数据 | + +### 关注点分离(SoC) + +三层之间严格禁止跨层直接依赖: + +| 层 | 知道什么 | 不知道什么 | +|---|---------|----------| +| Domain | 业务规则、实体结构、接口契约 | JSON 字段名、HTTP 状态码、Widget | +| Data | JSON 反序列化、网络/DB 调用方式 | Widget 渲染、路由逻辑 | +| Presentation | UI 状态、用户交互 | `snake_case` 字段、SQL 语法 | + +**违反后的典型故障**:Presentation 直接调用 `UsersDao`(跨越 Data 层)→ 缓存逻辑散落各处 → 离线状态不一致 → 用户看到"幽灵"数据。 + +| 平台 | 对应实现 | +|------|---------| +| Android | Jetpack Compose View 只消费 `StateFlow`,不直接调用 Retrofit;ViewModel 协调 Repository | +| iOS | SwiftUI View 只消费 `@Published`;ViewModel 协调 Service;Service 封装 URLSession / Core Data | + --- ## 整体分层 @@ -33,6 +95,57 @@ └─────────┘ └──────────┘ ``` +```mermaid +flowchart TD + subgraph P["Presentation 层"] + PW["ConsumerWidget
ConsumerStatefulWidget"] + PN["@riverpod Notifier
state: sealed XxxState"] + PW -->|"ref.watch(notifier)"| PN + PN -->|"state 变化 → rebuild"| PW + end + + subgraph D["Domain 层(纯 Dart,无框架)"] + DR["abstract interface
XxxRepository"] + DE["@freezed XxxEntity
纯值对象,可跨端复用"] + end + + subgraph DA["Data 层"] + DM["@freezed XxxModel
+ fromJson + toEntity()"] + DS["@riverpod RemoteDatasource
只调用 Dio,返回 Model"] + DI["@riverpod RepositoryImpl
实现接口,Model → Entity"] + DI --> DS + DI --> DM + end + + PN -->|"调用接口方法"| DR + DI -.->|"实现"| DR + DS -->|"parseEnvelope"| DM + DM -->|"toEntity()"| DE + PN -->|"持有 Entity"| DE + + subgraph Infra["基础设施"] + DIO["Dio
7 层拦截器"] + DRIFT["Drift
SQLCipher AES-256"] + end + + DS -->|"HTTP"| DIO + DI -->|"DAO"| DRIFT + + style P fill:#D6EAF8 + style D fill:#D5F5E3 + style DA fill:#FCF3CF + style Infra fill:#F5CBA7 +``` + +**设计决策**: +- **Domain 层零依赖**:`UserEntity`、`AuthRepository` 只依赖 Dart SDK,不 import `dio`/`drift`/`flutter_riverpod`。这让 Domain 层可在服务端 Dart CLI 复用,也便于单元测试(无需 Mock 框架)。 +- **`toEntity()` 桥接**:`Model.toEntity()` 是跨越 Data/Domain 边界的唯一通道,防止 JSON 字段名称(`snake_case`)泄露到 Domain 层。 +- **Notifier 不持有 Model**:Notifier 的 `state` 类型是 `Entity` 或 `sealed State`,不是 `Model`,确保 UI 层与序列化格式解耦。 + +> **📱 原生对比** +> - **Android**:`ViewModel`(Presentation)→ `Repository`(Domain 接口)→ `Room DAO / Retrofit Service`(Data 实现)是 Google AAC 推荐分层。`Hilt` 负责依赖注入,对应 Riverpod 的 Provider 角色;`Flow` 对应 `AsyncNotifier` 的 `state`。 +> - **iOS**:`View`(SwiftUI)→ `@ObservableObject ViewModel`(Presentation)→ `Protocol Repository`(Domain)→ `URLSession / Core Data`(Data)。`@EnvironmentObject` / `@StateObject` 注入 ViewModel,对应 `ConsumerWidget` + `ref.watch`;Combine `Publisher` 对应 Dart `Stream`。 + --- ## 认证流程(Auth Flow) @@ -70,6 +183,49 @@ GoRouter.redirect() # 监听 authStatus.listenable(ValueNotifier └── loggedIn == true → push /home(若当前在 /login) ``` +```mermaid +sequenceDiagram + participant M as main() + participant CR as CrashReporter + participant Sentry as SentrySetup + participant DB as AppDatabase + participant PS as ProviderScope + participant AS as authStatusProvider + participant SS as SecureStorage + participant GR as GoRouter + + M->>CR: preInit()(准备崩溃目录) + CR-->>M: pendingCrash(上次崩溃,可为 null) + M->>Sentry: init(appRunner) + Sentry->>CR: installHooks()(接管 FlutterError + Zone) + Sentry->>DB: open()(探测→PRAGMA key→后台 Isolate) + DB-->>Sentry: AppDatabase 实例 + Sentry->>PS: runApp(ProviderScope) + PS->>AS: 创建(keepAlive) + AS->>SS: getToken()(异步) + + alt token 非空 + SS-->>AS: "xxx_token" + AS->>GR: markLoggedIn() → ValueNotifier(true) + GR->>GR: redirect() → 当前在/login → push /home + else token 为空 / 读取异常 + SS-->>AS: null + AS->>GR: markLoggedOut() → ValueNotifier(false) + GR->>GR: redirect() → 非公开路径 → push /login + end + + Note over AS,GR: bootstrap 期间 auth.value==null
redirect 返回 null 不动(避免闪烁) +``` + +**设计决策**: +- **三态 `bool?`**:`null` 作为"未知"态,防止启动时 `redirect` 在 Bootstrap 完成前误跳页面(白屏/闪烁问题)。 +- **`ValueNotifier` 而非 Riverpod Provider**:GoRouter 的 `refreshListenable` 接受 `Listenable`(`ValueNotifier` 实现了它),用 Riverpod Provider 则需要额外适配层,`ValueNotifier` 更直接。 +- **`keepAlive: true`**:认证状态必须全局唯一且永不销毁,避免路由切换时 Provider 被回收导致状态丢失。 + +> **📱 原生对比** +> - **Android**:Navigation Component + `AuthManager`(单例)控制目的地访问权限;`NavController.navigate()` 配合 `LoginGraph` 实现跳转;也可用 `Hilt` 注入的全局 `SessionManager` 监听登录状态。Jetpack Compose 下 `NavHost` 的 `route guard lambda` 类似 GoRouter 的 `redirect`。 +> - **iOS**:SwiftUI 通过 `@EnvironmentObject AuthState` + `.sheet`/`.fullScreenCover` 控制页面展示;UIKit 下通过 `AppCoordinator` 切换 `rootViewController`;NavigationStack(iOS 16+)的 `NavigationPath` 类似 GoRouter 的声明式路由栈。 + --- ## 网络请求链(7 拦截器顺序固定) @@ -109,6 +265,46 @@ Dio.request() 否则 → 无操作(Release 包零日志) ``` +```mermaid +flowchart TD + REQ(["📤 Dio.request()
业务代码发起请求"]) + + subgraph Chain["拦截器链(onRequest 从上到下,onError 从下到上)"] + direction TB + I1["[1] CertPinningInterceptor
🔐 TLS 指纹校验
PINNED_FINGERPRINTS 为空 → 跳过
不匹配 → NetworkFailure(badCertificate)"] + I2["[2] AuthInterceptor
🔑 Token 注入
skip_auth=true → 跳过
否则读 SecureStorage → header[tokenName]=token"] + I3["[3] TokenRefreshInterceptor(onError)
🔄 401 自动刷新
业务码分类 → 不消耗非 token 类错误
Completer 互斥防并发重复消耗"] + I4["[4] RetryInterceptor(onError)
🔁 指数退避重试
408/429/5xx → 最多3次
200ms→400ms→800ms"] + I5["[5] ErrorInterceptor(onError)
🗺️ 异常映射
DioException → sealed Failure
写入 err.error 供下游读取"] + I6["[6] AuthLogoutInterceptor(onError)
🚪 强制登出
err.error is AuthFailure → clearAll()
→ markLoggedOut() → GoRouter 跳 /login"] + I7["[7] LogInterceptor
📋 请求日志
Dev/Internal → TalkerDioLogger
Release → noop(零日志泄漏)"] + end + + NET(["🌐 服务器"]) + RESP(["📥 Response
成功响应返回业务代码"]) + + REQ --> I1 --> I2 --> NET + NET --> RESP + NET -- 错误 --> I7 --> I6 --> I5 --> I4 --> I3 + + style I1 fill:#E74C3C,color:#fff + style I2 fill:#3498DB,color:#fff + style I3 fill:#9B59B6,color:#fff + style I4 fill:#F39C12,color:#fff + style I5 fill:#1ABC9C,color:#fff + style I6 fill:#E67E22,color:#fff + style I7 fill:#7F8C8D,color:#fff +``` + +**设计决策**: +- **顺序即语义**:`ErrorInterceptor`([5])必须在 `AuthLogoutInterceptor`([6])之前,因为 AuthLogout 通过 `e.error is AuthFailure` 判断类型,而 `e.error` 只有经过 ErrorInterceptor 映射后才是 `AuthFailure`。调换顺序会导致自动登出失效。 +- **`Options.extra` 信号机制**:拦截器间通过 `RequestOptions.extra` Map 传递信号(`skip_auth`、`_token_refreshed`、`_auth_not_refreshable`),避免拦截器之间直接引用,保持松耦合。 +- **onError 逆序**:Dio 的 onError 按**逆序**执行拦截器(与 onRequest 方向相反),这是 Dio 框架设计,本项目拦截器排列已考虑此特性。 + +> **📱 原生对比** +> - **Android**:OkHttp 的 `Interceptor` 接口(`chain.proceed(request)`)构成完全相同的链式拦截模型。`addInterceptor()`(Application Interceptor,全程可见)vs `addNetworkInterceptor()`(Network Interceptor,仅在实际网络层),对应 Dio 的 `onRequest`/`onError` 触发阶段。Retrofit 本身没有拦截器,依赖 OkHttp 层。 +> - **iOS**:Alamofire 的 `RequestInterceptor`(`adapt` 注入 token + `retry` 处理 401)+ `EventMonitor`(日志)组合等价于本项目的 7 拦截器。URLSession 原生只有 `URLSessionDelegate`,功能有限,企业级 iOS 项目几乎都依赖 Alamofire 的拦截层。 + --- ## 错误传播链 @@ -130,6 +326,47 @@ Dio.request() └─→ context.showError(ref, e, st)(Toast 展示 + 自动写日志) ``` +```mermaid +flowchart LR + EX["异常来源
DioException
DriftException
RsaEncryptionException
其他 Exception"] + + EM["ExceptionMapper
.fromUnknown(e, st)"] + + subgraph SF["sealed Failure(编译期强制穷举)"] + NF["NetworkFailure
kind: timeout/noNetwork
/badCertificate/cancelled/unknown"] + AF["AuthFailure
kind: unauthorized/forbidden
/refreshFailed/cryptoFailed"] + SVF["ServerFailure
code: A0400/A0500...
statusCode: 4xx/5xx"] + CF["CacheFailure
Drift/SQLCipher/IO"] + UF["UnknownFailure
兜底(不应频繁出现)"] + end + + UI["UI 层
failure.userMessage
→ AppToast/ErrorView"] + LOG["ErrorLogger(fire-and-forget)
failure.message(技术细节)
→ Drift error_logs"] + SEN["Sentry
failure.toString()
→ 云端错误聚合"] + + EX --> EM + EM --> NF & AF & SVF & CF & UF + + NF & AF & SVF & CF & UF --> UI + NF & AF & SVF & CF & UF --> LOG + UF --> SEN + + style NF fill:#E74C3C,color:#fff + style AF fill:#9B59B6,color:#fff + style SVF fill:#E67E22,color:#fff + style CF fill:#F39C12,color:#fff + style UF fill:#7F8C8D,color:#fff +``` + +**设计决策**: +- **`sealed` 强制穷举**:`switch (failure)` 不加 `default` 分支,当新增 Failure 子类时,所有 switch 点在编译期报错,不会有遗漏处理的情况。 +- **`userMessage` 与 `message` 分离**:`message` 含技术细节(适合 Sentry/Talker),`userMessage` 是用户可读文案(适合 Toast)。`ServerFailure` 对 5xx 额外屏蔽后端堆栈,防止信息泄露。 +- **`ErrorLogger` fire-and-forget**:`unawaited(ErrorLogger.log(failure))`,不阻塞主流程,本地存储的错误日志作为 Sentry 的补充(离线场景)。 + +> **📱 原生对比** +> - **Android**:Kotlin `sealed class Result` 或标准库 `kotlin.Result` 承担相同角色。`when(result)` 对 sealed class 强制穷举,与 Dart `switch(failure)` 语义完全等价。`NetworkResult` / `ApiResponse` 等封装类是 Android 社区常见模式(如 [sandwich](https://github.com/skydoves/sandwich) 库)。 +> - **iOS**:Swift `enum NetworkError: Error { case timeout; case unauthorized; ... }` 或 `Result` 泛型承担相同角色。Swift `switch` 对枚举同样强制穷举(无 `default` 时编译报错)。Swift 的关联值(associated values)对应 Dart sealed class 的子类字段(如 `NetworkFailure.kind`)。 + --- ## Token 刷新互斥(Completer 模式) @@ -154,6 +391,51 @@ Dio.request() └─────────┴──▶ B、C 收到结果,用新 token 重放请求 ``` +```mermaid +sequenceDiagram + participant A as 请求 A(首个 401) + participant B as 请求 B(并发 401) + participant C as 请求 C(并发 401) + participant TRI as TokenRefreshInterceptor + participant SS as SecureStorage + participant SRV as 服务端 /auth/refresh + + A->>TRI: onError(401) + Note over TRI: _refreshing == null + TRI->>TRI: new Completer()
_refreshing = completer + + B->>TRI: onError(401) + Note over TRI: _refreshing != null + TRI->>TRI: await _refreshing.future(挂起等待) + + C->>TRI: onError(401) + Note over TRI: _refreshing != null + TRI->>TRI: await _refreshing.future(挂起等待) + + TRI->>SS: getRefreshToken() + SS-->>TRI: "refresh_xyz" + TRI->>SRV: POST /auth/refresh
{refresh_token: "refresh_xyz"} + SRV-->>TRI: {code:"00000", data:{access_token:"new_token"}} + TRI->>SS: setToken("new_token") + + Note over TRI: 先清空 _refreshing,再 complete! + TRI->>TRI: _refreshing = null + TRI->>TRI: completer.complete(true) + + Note over B,C: B 和 C 从 future 拿到 true + B->>TRI: 用新 token 重放原请求 ✅ + C->>TRI: 用新 token 重放原请求 ✅ + A->>TRI: 用新 token 重放原请求 ✅ +``` + +**为什么先 `_refreshing = null` 再 `completer.complete(true)`**: + +如果先 `complete` 再清空 `_refreshing`,B/C 醒来后可能有新的 401 进入,看到 `_refreshing != null`(旧 completer 已完成),`await` 会立即返回旧的 `true`,跳过新一轮刷新。顺序颠倒虽然概率低,但属于 race condition。先清空确保新 401 进入时看到 `null`,走正常刷新路径。 + +> **📱 原生对比** +> - **Android**:OkHttp `Authenticator` 接口处理 401,但官方不处理并发互斥——需要配合 `@Synchronized` 或 Kotlin Coroutines `Mutex`(`kotlinx.coroutines`)手动实现。`mutex.withLock { ... }` 对应 `Completer` 的作用。也可用 `SharedFlow`(`replay=0`)+ `flatMapLatest` 组合实现 "多个订阅者共享同一次刷新结果"的语义。 +> - **iOS**:Swift `actor` 天然提供 Actor Isolation(串行访问保障),将 refresh 逻辑放入 `actor TokenRefresher` 中,并发调用自动排队,无需手动锁。Alamofire 的 `RequestInterceptor.retry()` 内部已集成基于 `DispatchSemaphore` 的互斥机制。 + --- ## 数据库表结构(schemaVersion 1) @@ -190,9 +472,53 @@ ErrorLogsTable(自增 id) └── appBuild TEXT nullable ``` +```mermaid +flowchart TD + subgraph DB["AppDatabase(SQLCipher AES-256)"] + subgraph UT["UsersTable"] + U1["userId TEXT PK"] + U2["username TEXT?"] + U3["phone TEXT?"] + U4["avatar TEXT?"] + U5["refreshToken TEXT? ← 只读镜像"] + U6["[SyncColumns]
syncStatus / localUpdatedAt
serverUpdatedAt / conflictPayload"] + end + + subgraph EL["ErrorLogsTable"] + E1["id INT PK AUTOINCREMENT"] + E2["kind TEXT
'network'|'server'|'auth'
'cache'|'unknown'"] + E3["message TEXT(技术细节)"] + E4["displayMessage TEXT(用户文案)"] + E5["occurredAt DATETIME"] + E6["reported BOOL DEFAULT false"] + E7["deviceModel / osVersion
appVersion / appBuild"] + end + end + + SS["SecureStorage
(Keychain / EncryptedPrefs)
Token 真源"] + EL_Reporter["ErrorLogger
写入 error_logs
清理 > 100 条旧记录"] + Batch["批量上报
error_report_datasource
标记 reported=true"] + + SS -->|"token/refreshToken 唯一真源"| UT + UT -->|"refreshToken 镜像(可观测性)"| SS + EL_Reporter --> EL + EL --> Batch + + style DB fill:#F0F3F4 + style SS fill:#E8F8F5 +``` + > **token 不存 DB**:accessToken 的唯一真源是 SecureStorage(Keychain / EncryptedSharedPrefs)。 > UsersTable.refreshToken 仅作可观测性镜像,TokenRefreshInterceptor 始终从 SecureStorage 读取。 +**设计决策**: +- **Token 不存 DB 的原因**:SQLCipher 密钥存在 SecureStorage,若 Token 也存 DB,则 Token 的安全性等同于 DB 密钥的安全性——循环依赖。SecureStorage 是操作系统级安全保障(Keychain Enclave / Android KeyStore TEE),比应用层数据库更可信。 +- **refreshToken DB 镜像**:只用于调试和可观测性(如查看用户登录状态),不参与认证逻辑,仅在 `AuthRepositoryImpl._persistUser()` 时顺带写入。 + +> **📱 原生对比** +> - **Android**:Room(Jetpack)是官方 ORM 层,`@Database`/`@Dao`/`@Entity` 注解对应 Drift 的 `@DriftDatabase`/`@DriftAccessor`/表定义。Room 默认禁止主线程 DB 操作(强制 Coroutines + Dispatcher.IO),对应 Drift 的后台 Isolate。加密方案:SQLCipher for Android(zetetic/android-database-sqlcipher)接入方式与本项目高度一致;或使用 [Room + SQLCipher](https://www.zetetic.net/sqlcipher/sqlcipher-for-android/) 官方文档。 +> - **iOS**:Core Data + `NSPersistentContainer` 是官方 ORM 方案;`newBackgroundContext()` 对应 Drift 后台 Isolate(禁止主线程写操作)。轻量替代:GRDB.swift / SQLite.swift。加密:SQLCipher for iOS(zetetic)或 Realm 内置加密配置(`Realm.Configuration.encryptionKey`)。`NSManagedObject` 对应 Drift 生成的 Data Class(`@DataClassName`)。 + --- ## API Envelope 格式 @@ -209,6 +535,18 @@ ErrorLogsTable(自增 id) `parseEnvelope(body, fromJsonT)` 解析此结构;`apiResp.unwrapVoid()` 用于无数据响应。 +```mermaid +flowchart LR + R["HTTP Response
{code, msg, data}"] + PE["parseEnvelope«T»
检查 code == '00000'
否则 throw ServerFailure
是则 fromJsonT(data)"] + UV["unwrapVoid()
只检查 code
不解析 data
用于 DELETE/logout 等"] + T["T(业务对象)"] + V["void(操作成功)"] + + R --> PE --> T + R --> UV --> V +``` + token 类错误码(触发 TokenRefreshInterceptor): - `A0401`(unauthorized) - `TOKEN_EXPIRED` / `TOKEN_INVALID` @@ -229,3 +567,154 @@ ThemeNotifier (@Riverpod, keepAlive) └── 读/写 SharedPreferences('theme_scheme' + 'theme_mode') └── app.dart 的 MaterialApp.router 监听 themeProvider + themeModeProvider ``` + +```mermaid +flowchart LR + SP["SharedPreferences
'theme_scheme': 'blue'
'theme_mode': 'system'"] + + TN["ThemeNotifier
@Riverpod keepAlive
state: AppColorScheme"] + TMN["ThemeModeNotifier
@Riverpod keepAlive
state: ThemeMode"] + + AT_L["AppTheme.light(scheme)
→ ThemeData(light)"] + AT_D["AppTheme.dark(scheme)
→ ThemeData(dark)"] + + APP["MaterialApp.router
theme: light
darkTheme: dark
themeMode: ThemeMode.system"] + + SP -->|"启动读取"| TN + SP -->|"启动读取"| TMN + TN -->|"setScheme() → 写入"| SP + TMN -->|"setMode() → 写入"| SP + TN --> AT_L & AT_D + AT_L & AT_D --> APP + TMN -->|"系统/手动切换"| APP +``` + +**5 套色板切换**(运行时热切换,无需重启): + +```dart +// 任意页面 +ref.read(themeNotifierProvider.notifier).setScheme(AppColorScheme.red); +// → SharedPreferences 持久化 +// → themeProvider 通知 +// → App 重建(只有 App widget,成本极低) +// → MaterialApp.router 使用新 ThemeData +``` + +--- + +## 认证模块 Clean Architecture 类图 + +```mermaid +classDiagram + class AuthRepository { + <> + +loginWithPhone(phone, code) Future~UserEntity~ + +loginWithPassword(phone, pwd) Future~UserEntity~ + +logout() Future~void~ + +getCurrentUser() Future~UserEntity?~ + +sendSmsCode(phone) Future~String?~ + +fetchUserProfile() Future~UserEntity~ + } + + class AuthRepositoryImpl { + -AuthRemoteDatasource _remote + -UsersDao _dao + -SecureStorageService _storage + +loginWithPhone(phone, code) Future~UserEntity~ + +loginWithPassword(phone, pwd) Future~UserEntity~ + -_persistUser(UserModel) Future~void~ + } + + class AuthRemoteDatasource { + -Dio _dio + +loginWithPhone(phone, code) Future~UserModel~ + +loginWithPassword(phone, pwd) Future~UserModel~ + +sendSmsCode(phone) Future~String?~ + +logout() Future~void~ + +getUserProfile() Future~UserModel~ + } + + class AuthNotifier { + <> + -int _generation + +state: AuthState + +loginWithPhone(phone, code) Future~void~ + +loginWithPassword(phone, pwd) Future~void~ + +logout() Future~void~ + +sendSmsCode(phone) Future~void~ + +refreshUserInfo() Future~void~ + } + + class AuthStatusController { + -ValueNotifier~bool?~ _notifier + +value: bool? + +listenable: ValueNotifier + +markLoggedIn() void + +markLoggedOut() void + } + + class UserModel { + <> + +userId: String + +username: String? + +token: String? + +tokenName: String? + +refreshToken: String? + +toEntity() UserEntity + } + + class UserEntity { + <> + +userId: String + +username: String? + +phone: String? + +avatar: String? + } + + class AuthState { + <> + initial() + loading() + authenticated(UserEntity) + unauthenticated() + error(String) + } + + AuthRepository <|.. AuthRepositoryImpl : implements + AuthRepositoryImpl --> AuthRemoteDatasource : uses + AuthRepositoryImpl --> UserModel : receives + UserModel --> UserEntity : toEntity() + AuthNotifier --> AuthRepository : calls + AuthNotifier --> AuthState : state + AuthNotifier --> AuthStatusController : markLoggedIn/Out + AuthStatusController --> GoRouter : refreshListenable +``` + +--- + +## 数据库启动与密钥派生流程 + +```mermaid +flowchart TD + A["AppDatabase.open()"] --> B{"Platform.isAndroid?"} + B -- Yes --> C["applyWorkaroundToOpenSqlCipherOnOldAndroidVersions()
确保 Java 先通过 System.loadLibrary
加载 libsqlcipher.so 到进程内存"] + B -- No --> D + C --> D["_sqlCipherIsolateSetup()(主 Isolate)
注册 SQLCipher open override
→ 探测时 sqlite3.open 走 SQLCipher"] + D --> E["DbKeyProvider.key
→ SecureStorage.read('db_key')
→ 有则直接用
→ 无则 Random.secure().nextBytes(32) + base64Url + 写入"] + E --> F{"app.db 文件已存在?"} + F -- Yes --> G["_canOpenWithKey(path, escapedKey)
主 Isolate 同步探测
PRAGMA key = '...' + PRAGMA user_version"] + G -- 失败 --> H["file.deleteSync()
密钥不匹配/文件损坏 → 删除重建
(开发阶段无不可恢复的用户数据)"] + G -- 成功 --> I + F -- No --> I + H --> I["NativeDatabase.createInBackground()
isolateSetup: _sqlCipherIsolateSetup ← 顶层函数!
setup: db.execute(PRAGMA key = '...')"] + I --> J["后台 Isolate 注册 SQLCipher
PRAGMA key 解锁
所有 Drift 查询透明 AES-256"] + J --> K["AppDatabase 就绪
注入 ProviderScope"] + + style C fill:#F39C12,color:#fff + style H fill:#E74C3C,color:#fff + style I fill:#27AE60,color:#fff +``` + +**关键约束**: +- `isolateSetup` 必须传**顶层函数**引用(非 lambda/闭包):Drift 通过 `Isolate.spawn` 将其传递给后台 Isolate,经过 `SendPort.send` 序列化。闭包可能捕获外部变量,跨 Isolate 传递时会静默失败(无报错,加密不生效)。 +- PRAGMA key 使用**字符串格式**(`'key'`),而非 raw hex 格式(`x'hexkey'`):当前 `RandomKeyStrategy` 产出 base64Url(32 bytes) ≈ 43 字符,不符合 raw key 要求的 64 hex 字符。切换格式会破坏已加密 DB。 diff --git a/docs/flutter-guide.md b/docs/flutter-guide.md new file mode 100644 index 0000000..c79d2c3 --- /dev/null +++ b/docs/flutter-guide.md @@ -0,0 +1,1268 @@ +# Flutter 企业级开发指南(小白入门) + +> 本文档面向**初次接触 Flutter / Dart 的开发者**,系统讲解语言基础、框架原理、生态库, +> 以及 sunny_mochi 脚手架中每个核心组件的设计原理和使用方式。 +> +> **阅读建议**:按章顺序读一遍建立心智模型,之后按需跳阅第三、四章查阅具体库和组件。 +> 如果你有 Android 或 iOS 原生开发背景,每节的"📱 原生对比"可以帮你快速建立映射关系。 + +--- + +## 目录 + +- [第一章:Dart 语言核心](#第一章dart-语言核心) +- [第二章:Flutter 框架原理](#第二章flutter-框架原理) +- [第三章:生态库逐一解析](#第三章生态库逐一解析) +- [第四章:项目核心组件深度解读](#第四章项目核心组件深度解读) +- [第五章:新增 Feature 的标准流程](#第五章新增-feature-的标准流程) + +--- + +## 第一章:Dart 语言核心 + +> Dart 是 Flutter 的唯一编程语言。Flutter 框架的 Widget 系统、Riverpod 的响应式机制、 +> Drift 的类型安全查询,底层都是 Dart 特性在发挥作用。读懂本章,后续所有库的用法 +> 自然就能理解。 + +### 1.1 类型系统与空安全(Null Safety) + +Dart 是**强类型、静态类型**语言,从 Dart 2.12 起引入**空安全(Sound Null Safety)**。 +空安全意味着编译器在编译期就能捕获空指针错误,而不是等到运行时崩溃。 + +```dart +// 不允许为 null 的类型(默认) +String name = 'Alice'; +// name = null; // ❌ 编译错误 + +// 允许为 null 的类型(加 ?) +String? nickname; +nickname = null; // ✅ 合法 + +// 使用前必须判空 +if (nickname != null) { + print(nickname.length); // 自动 smart cast,不需要 ! +} + +// 非空断言(确信不为 null 时用,运行时失败会抛异常) +print(nickname!.length); + +// 空值合并运算符 +final display = nickname ?? '匿名用户'; + +// 可选链(null 时短路,不崩溃) +final len = nickname?.length; +``` + +**项目中的典型用法**: + +```dart +// failures.dart — cause 可为 null(某些错误没有原因对象) +const Failure({required this.message, this.cause, this.stackTrace}); +final Object? cause; + +// token_refresh_interceptor.dart — Completer 可为 null(未在刷新中) +Completer? _refreshing; +``` + +> **📱 原生对比** +> Kotlin 有完全相同的空安全语法(`?`、`!!`、`?.`、`?:`),Dart 的设计明显借鉴了它。 +> Swift 用 `Optional` / `T?` 实现相同语义,`guard let` 和 `if let` 等价于 Dart 的 `if (x != null)`。 + +--- + +### 1.2 异步编程(Future / Stream / async-await) + +Dart 是**单线程**的(与 JavaScript 相同),通过事件循环(Event Loop)处理并发。 +`Future` 代表"将来某时的一个值",`Stream` 代表"将来某时的一系列值"。 + +```dart +// Future:一次性异步结果 +Future fetchToken() async { + await Future.delayed(Duration(seconds: 1)); // 模拟网络延迟 + return 'my_token_abc123'; +} + +// await:等待 Future 完成(只能在 async 函数内使用) +final token = await fetchToken(); +print(token); // my_token_abc123 + +// 错误处理 +try { + final token = await fetchToken(); +} on NetworkException catch (e) { + // 处理特定类型异常 +} on Object catch (e, st) { + // 兜底(本项目标准写法) +} + +// Stream:持续的数据流 +Stream counter() async* { + for (int i = 0; i < 3; i++) { + await Future.delayed(Duration(seconds: 1)); + yield i; // 逐个发出值 + } +} + +// 监听 Stream +await for (final value in counter()) { + print(value); // 0, 1, 2 +} +``` + +**`unawaited`**:启动 Future 但不等待它——通常用于"fire-and-forget"操作: + +```dart +// main.dart — 不 await sync 启动,不阻塞 UI 渲染 +unawaited(ref.read(syncServiceProvider).start()); +``` + +**`Completer`**:手动控制 Future 的完成时机,本项目用于 Token 刷新互斥: + +```dart +// token_refresh_interceptor.dart 精简版 +Completer? _refreshing; + +// 第一个进来的 401 +final completer = Completer(); +_refreshing = completer; +final result = await _runRefresh(token); +_refreshing = null; +completer.complete(result); // 通知所有等待者 + +// 后来的 401 直接等已有的 Future +final ok = await _refreshing!.future; +``` + +> **📱 原生对比** +> Android:`suspend fun`(Kotlin Coroutines)等价于 `async`/`await`,`Flow` 等价于 `Stream`,`Deferred` 等价于 `Completer`。 +> iOS:`async/await`(Swift 5.5+)语法与 Dart 几乎相同,`AsyncStream` 等价于 `Stream`,Swift 的 `actor` 提供类似 `Completer` 的互斥能力。 + +--- + +### 1.3 Sealed Class、Extension、Mixin + +#### Sealed Class(Dart 3.0+) + +`sealed` 关键字让编译器知道子类集合是**封闭的**,在 `switch` 时强制穷举所有情况: + +```dart +// failures.dart +sealed class Failure implements Exception { + const Failure({required this.message, this.cause, this.stackTrace}); + final String message; + final Object? cause; + final StackTrace? stackTrace; +} + +class NetworkFailure extends Failure { /* ... */ } +class AuthFailure extends Failure { /* ... */ } +class ServerFailure extends Failure { /* ... */ } +class CacheFailure extends Failure { /* ... */ } +class UnknownFailure extends Failure { /* ... */ } +``` + +使用时编译器确保你处理了所有情况: + +```dart +switch (failure) { + case NetworkFailure(:final kind): + showToast('网络错误:$kind'); + case AuthFailure(): + router.go('/login'); + case ServerFailure(:final code): + showToast('服务器错误 $code'); + case CacheFailure(): + showToast('本地数据异常'); + case UnknownFailure(): + showToast('未知错误'); +} +// 如果少写任何一个 case → 编译错误! +``` + +> **📱 原生对比** +> Kotlin 有完全相同的 `sealed class`(Dart 的 `sealed` 语义来自 Kotlin)。 +> Swift 用带关联值的 `enum` 实现同等效果:`enum Failure { case network(kind: NetworkKind), case auth, ... }`,`switch` 同样强制穷举。 + +#### Extension(扩展方法) + +为**已有类型**添加方法,无需继承,无需修改源码: + +```dart +extension ContextX on BuildContext { + void showError(WidgetRef ref, Object error, StackTrace st) { + // 统一展示错误 + 写日志 + } +} + +// 使用时就像原生方法一样 +context.showError(ref, failure, st); +``` + +> **📱 原生对比** +> Kotlin 有同名的 `extension function`,语法略有不同。iOS Swift 的 `extension` 关键字功能完全相同,是 Swift 的一等公民特性。 + +#### Mixin + +在不同类之间复用方法,比继承更灵活(一个类可以 mixin 多个): + +```dart +mixin SmsCountdownMixin on State { + int _seconds = 0; + void startCountdown() { /* 60s 倒计时逻辑 */ } +} + +class _LoginPageState extends ConsumerState with SmsCountdownMixin { + // 直接使用 startCountdown() 和 _seconds +} +``` + +> **📱 原生对比** +> Kotlin 用 `interface` + `default implementation` 实现类似效果(Kotlin 没有 mixin 关键字)。 +> Swift 的 `protocol` + `extension` 组合是完整等价物:在 `extension` 里为 protocol 提供默认实现,`class` 遵循 protocol 即可复用。 + +--- + +### 1.4 代码生成(build_runner / @riverpod / @freezed) + +Dart 通过**注解 + 代码生成**消除样板代码。项目用到三套: + +| 注解 | 生成器 | 生成文件 | 作用 | +|------|--------|---------|------| +| `@riverpod` | riverpod_generator | `.g.dart` | Provider 工厂函数 | +| `@freezed` | freezed | `.freezed.dart` | 值对象 + copyWith + JSON | +| `@TypedGoRoute` | go_router_builder | `.g.dart` | 类型安全路由类 | +| `@DriftDatabase` | drift_dev | `.g.dart` | SQL 查询代码 | + +**运行代码生成**: + +```bash +# 一次性生成(修改 .dart 文件后必跑) +make gen +# 等价于 +flutter pub run build_runner build --delete-conflicting-outputs +``` + +> **黄金法则**:永远不要手动修改 `.g.dart` 或 `.freezed.dart` 文件——它们在下次 `make gen` 时会被覆盖。 + +> **📱 原生对比** +> Android:KSP(Kotlin Symbol Processing)+ Room/Hilt/Retrofit 注解处理器,与 build_runner 机制完全相同。 +> iOS:Swift 宏(Swift 5.9+)提供类似能力;传统方案是 Sourcery(代码生成工具)或 Codegen 脚本。 + +--- + +## 第二章:Flutter 框架原理 + +> **从原生视角理解 Flutter** +> Android 开发者:Flutter 的 Widget 树对应 Android 的 View 树,但 Widget 是**不可变的配置描述**, +> 而不是 View 实例本身。Flutter 自己调用 Skia/Impeller 绘制,不复用平台原生控件。 +> iOS 开发者:Flutter 的 Widget 对应 SwiftUI 的 `View`(结构体,不可变,声明式)。 +> UIKit 的 `UIViewController` 生命周期对应 `StatefulWidget` 的 `State`。 + +### 2.1 三棵树:Widget / Element / RenderObject + +Flutter 渲染界面靠三层树协同工作: + +```mermaid +flowchart TD + W["Widget 树
(配置描述,不可变,轻量)"] + E["Element 树
(实例持有者,持久化,生命周期载体)"] + R["RenderObject 树
(真正测量/绘制,昂贵)"] + + W -->|"首次构建: createElement()"| E + E -->|"首次挂载: createRenderObject()"| R + W -->|"rebuild: didUpdateWidget()"| E + E -->|"布局/绘制变化: markNeedsLayout()"| R + + style W fill:#4A90D9,color:#fff + style E fill:#27AE60,color:#fff + style R fill:#E67E22,color:#fff +``` + +- **Widget**:描述"我想要什么"(配置),是不可变对象,每次 `setState` / `ref.watch` 都可能重建 +- **Element**:Widget 的实例,负责"连接新旧 Widget"并决定是否需要更新 RenderObject +- **RenderObject**:实际执行测量(`performLayout`)和绘制(`paint`),创建/销毁成本高 + +**关键洞察**:Widget 重建(rebuild)**≠** 界面重绘。Flutter 足够聪明,当新旧 Widget 类型和 key 相同时,只更新 Element 和 RenderObject 中变化的部分。 + +> **📱 原生对比** +> Android:View 树只有一层,View 对象同时承担"配置"和"渲染"两个职责,这也是 Android View 系统性能问题的根源之一。Jetpack Compose 的设计与 Flutter 更接近(声明式、不可变配置 + 运行时状态树)。 +> iOS UIKit:UIView 同样是"配置+渲染"合一;SwiftUI 的 `View` 协议(结构体)则和 Flutter Widget 概念完全对齐——轻量不可变的配置,框架自己管理渲染树。 + +--- + +### 2.2 三种 Widget 类型 + +| Widget 类型 | 何时使用 | 本项目用法 | Android 类比 | iOS 类比 | +|-------------|---------|-----------|------------|--------| +| `StatelessWidget` | 纯展示,无状态 | `AppRoot`、大多数展示组件 | `@Composable` fun(无状态)| SwiftUI `View`(纯 `let` 属性)| +| `StatefulWidget` | 有本地状态(动画、表单) | `_SyncBootstrap` | `@Composable` + `remember {}` | `@State` 属性 | +| `ConsumerWidget` | 需要读取 Riverpod Provider | `App`、功能页面 | `@Composable` + `viewModel()` | `@ObservedObject` / `@EnvironmentObject` | +| `ConsumerStatefulWidget` | 同时需要本地状态和 Provider | `LoginPage` | `@Composable` + `remember` + `viewModel()` | `@StateObject` + `@State` | + +```dart +// ConsumerWidget 是最常用的 Widget 类型(来自 flutter_riverpod) +class App extends ConsumerWidget { + const App({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // ref 用于读取/监听 Provider + final router = ref.watch(appRouterProvider); + final themeMode = ref.watch(themeModeProvider); + final scheme = ref.watch(themeProvider); + + return MaterialApp.router( + routerConfig: router, + themeMode: themeMode, + theme: AppTheme.light(scheme: scheme), + darkTheme: AppTheme.dark(scheme: scheme), + ); + } +} +``` + +--- + +### 2.3 BuildContext 与 InheritedWidget + +`BuildContext` 是 Widget 在 Element 树中的位置标识,通过它可以: + +```dart +// 向上查找最近的 InheritedWidget(MediaQuery、Theme 都是 InheritedWidget) +final mediaQuery = MediaQuery.of(context); +final theme = Theme.of(context); + +// 导航(GoRouter) +context.go('/home'); +context.push('/detail'); + +// 显示 SnackBar / Dialog +ScaffoldMessenger.of(context).showSnackBar(SnackBar(...)); +showDialog(context: context, builder: (_) => AlertDialog(...)); +``` + +**注意**:`BuildContext` 只在 `build()` 方法内可靠使用。在异步回调中使用时,需先检查 `mounted`: + +```dart +Future onTap() async { + await someAsyncOp(); + if (!mounted) return; // Widget 可能已卸载 + context.go('/next'); +} +``` + +> **📱 原生对比** +> Android:`Context` 是 Android 的核心概念,用于访问资源、启动 Activity、获取系统服务——与 `BuildContext` 功能重叠但范围更广(Android Context 贯穿整个 App 生命周期,BuildContext 只在 Widget 树范围内有效)。 +> iOS:没有直接等价物;SwiftUI 的 `Environment` 和 `EnvironmentValues` 实现了类似的"向上查找"机制(`@Environment(\.colorScheme) var colorScheme`)。 + +--- + +### 2.4 生命周期 + +```mermaid +stateDiagram-v2 + [*] --> Created : new Widget() + Created --> Mounted : createState() + initState() + Mounted --> Built : build() + Built --> Rebuilt : setState() / ref.watch 变化 + Rebuilt --> Built : build() + Built --> Disposed : 离开 Widget 树 + Disposed --> [*] + + note right of Mounted : initState() 只调用一次
可以启动 Animation、订阅 Stream + note right of Disposed : dispose() 必须释放资源
Timer、StreamSubscription、Controller +``` + +> **📱 原生对比** +> Android Activity/Fragment:`onCreate → onStart → onResume → onPause → onStop → onDestroy`。`initState()` ≈ `onCreate()`,`dispose()` ≈ `onDestroy()`。 +> iOS UIViewController:`viewDidLoad → viewWillAppear → viewDidAppear → viewWillDisappear → viewDidDisappear → deinit`。`initState()` ≈ `viewDidLoad()`,`dispose()` ≈ `deinit`。 + +--- + +### 2.5 布局系统 + +Flutter 布局的核心约束是:**父 Widget 向子 Widget 传入约束(constraints),子 Widget 在约束范围内决定自己的尺寸**。 + +``` +父约束向下传递: + BoxConstraints(minW=0, maxW=375, minH=0, maxH=812) + ↓ + 子 Widget 决定尺寸: + Size(200, 50) + ↑ + 子尺寸向上返回 +``` + +| Widget | 用途 | Android 类比 | iOS 类比 | +|--------|------|------------|--------| +| `Column` / `Row` | 垂直/水平排列 | `LinearLayout` (vertical/horizontal) | `VStack` / `HStack` | +| `Expanded` / `Flexible` | 按比例分配空间 | `layout_weight` | `.frame(maxWidth: .infinity)` | +| `Stack` / `Positioned` | 绝对定位层叠 | `FrameLayout` / `ConstraintLayout` | `ZStack` / `.offset` | +| `SizedBox` | 固定尺寸/间距 | `Space` / `View(match_parent)` | `Spacer` / `.frame(width:height:)` | +| `ListView` / `GridView` | 可滚动列表/网格 | `RecyclerView` | `List` / `LazyVGrid` | +| `CustomScrollView` + `Sliver` | 复杂滚动(吸顶、动态 Header)| `CoordinatorLayout` + `NestedScrollView` | `ScrollView` + `LazyVStack` | + +--- + +## 第三章:生态库逐一解析 + +> **学习建议**:本章库的讲解顺序经过设计——**Freezed(3.1)先于 Riverpod(3.2)**, +> 因为 Riverpod 的状态类(`sealed AuthState`)都是 Freezed 生成的数据类, +> 理解 Freezed 后再看 Riverpod 代码会豁然开朗。 + +### 平台生态对比总览 + +| 功能 | Flutter 库 | Android 官方/主流 | iOS 官方/主流 | +|------|-----------|-----------------|-------------| +| 值对象/数据类 | Freezed | `data class` + kotlinx.serialization | `Codable` (Decodable/Encodable) | +| 状态管理 | Riverpod | ViewModel + StateFlow (AAC) | `@StateObject` + Combine | +| 依赖注入 | Riverpod ProviderScope | Hilt / Dagger 2 | Swinject / Factory(三方)| +| 路由 | GoRouter | Jetpack Navigation Component | NavigationPath (SwiftUI) / Coordinator | +| HTTP 客户端 | Dio | OkHttp + Retrofit | URLSession + Alamofire(三方)| +| ORM / 数据库 | Drift | Room | Core Data / GRDB(三方)| +| 数据库加密 | SQLCipher | SQLCipher for Android / EncryptedRoom | SQLCipher for iOS | +| 安全存储 | flutter_secure_storage | EncryptedSharedPreferences + Keystore | Keychain Services | +| 日志 | Talker | Timber + Logcat | OSLog / CocoaLumberjack(三方)| +| 错误追踪 | Sentry | Firebase Crashlytics / Sentry Android | Firebase Crashlytics / Sentry iOS | +| 国际化 | Slang | String Resources (res/values/strings.xml) | Localizable.strings + SwiftGen(三方)| +| 屏幕适配 | flutter_screenutil | dp/sp + ConstraintLayout 百分比 | Auto Layout + Size Classes | +| RSA 加密 | pointycastle + asn1lib | JCE (`javax.crypto`) / Android Keystore | Security framework / CryptoKit | + +--- + +### 3.1 Freezed —— 值对象与联合类型 + +> **为什么先讲 Freezed**:本项目里 Riverpod 的状态类(`AuthState`)、所有实体(`UserEntity`)、 +> 所有网络模型(`UserModel`)都是 Freezed 生成的。理解 Freezed 是理解后续所有 `@riverpod` 代码的前提。 + +**是什么**:代码生成库,为 Dart 类自动生成 `==`、`hashCode`、`copyWith`、`toString`、`toJson`/`fromJson`,并支持联合类型(Union Types / Discriminated Union)。 + +**解决什么问题**:Dart 原生没有 `data class`,手写 `==` 和 `copyWith` 极其繁琐且容易出错。 + +```dart +// user_entity.dart — 值对象 +@freezed +class UserEntity with _$UserEntity { + const factory UserEntity({ + required String userId, + String? username, + String? phone, + String? avatar, + }) = _UserEntity; +} + +// 自动生成的能力 +final user = UserEntity(userId: 'u1', username: 'Alice'); +final updated = user.copyWith(username: 'Bob'); // 不变性 +print(user == updated); // false(值比较,不是引用比较) + +// auth_state.dart — 联合类型(状态机) +@freezed +sealed class AuthState with _$AuthState { + const factory AuthState.initial() = _Initial; + const factory AuthState.loading() = _Loading; + const factory AuthState.authenticated(UserEntity user)= _Authenticated; + const factory AuthState.unauthenticated() = _Unauthenticated; + const factory AuthState.error(String message) = _Error; +} + +// 使用时穷举所有状态(编译期安全) +switch (state) { + case AuthState.loading(): + return CircularProgressIndicator(); + case AuthState.authenticated(:final user): + return Text('欢迎,${user.username}'); + case AuthState.error(:final message): + return Text('错误:$message'); + // ... +} +``` + +> **📱 原生对比** +> **Android**:Kotlin 的 `data class` 原生提供 `equals`/`hashCode`/`copy`(对应 `copyWith`),`sealed class` 原生支持穷举 `when`——Freezed 的功能在 Kotlin 里几乎是语言内置的。 +> **iOS**:Swift 的 `struct` + `Codable` 提供值语义和 JSON 序列化;`enum` with associated values 提供联合类型。Swift 没有内置 `copyWith`,通常手写或用 `@dynamicMemberLookup` 变通。 + +--- + +### 3.2 Riverpod —— 响应式状态管理 + +**是什么**:Flutter 社区最主流的状态管理方案,基于"Provider"概念,解决了原 Provider 包的诸多痛点(类型安全、测试隔离、自动处理异步等)。它同时承担**状态管理**和**依赖注入**两个职责。 + +**解决什么问题**:在多个 Widget 之间共享状态,避免"状态提升"导致的层层传递 `callback`,同时保证 UI 响应式更新。 + +```dart +// 定义 Provider(以主题为例) +@Riverpod(keepAlive: true) +class ThemeNotifier extends _$ThemeNotifier { + @override + AppColorScheme build() => AppColorScheme.blue; // 初始值 + + void setScheme(AppColorScheme s) => state = s; +} + +// 使用 Provider(在 ConsumerWidget 中) +class MyPage extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + // watch:监听变化,变化时重建 Widget + final scheme = ref.watch(themeProvider); + + // read:只读一次,不监听变化(用于事件回调) + ref.read(themeNotifierProvider.notifier).setScheme(AppColorScheme.red); + + // listen:监听变化,执行副作用(导航、Toast) + ref.listen(themeProvider, (prev, next) { + print('主题变了: $next'); + }); + } +} +``` + +**Provider 类型对照**: + +| 注解写法 | 生成的 Provider | 适合场景 | +|---------|----------------|---------| +| `@riverpod Future foo(Ref)` | `FutureProvider` | 异步一次性数据(网络请求) | +| `@riverpod Stream foo(Ref)` | `StreamProvider` | 持续数据流(WebSocket) | +| `@riverpod T foo(Ref)` | `Provider` | 同步只读计算值 | +| `class Foo extends _$Foo` | `NotifierProvider` | 可变状态 + 方法 | +| `@Riverpod(keepAlive: true)` | 永不销毁的 Provider | 全局单例(路由、DB、Dio 实例) | + +> **📱 原生对比** +> **Android**:ViewModel(AAC)提供类似"状态容器",`StateFlow` 对应 `AsyncValue`(Riverpod 的异步状态包装),`viewModels()` / Hilt `@Inject` 对应 `ref.watch/read`。Riverpod 的 `ProviderScope` ≈ Hilt 的 `@HiltAndroidApp`(应用级依赖注入容器)。 +> **iOS**:`@StateObject` + `ObservableObject`/`@Published` 对应 Riverpod Notifier;`@EnvironmentObject` 对应 `keepAlive: true` 的全局 Provider;Combine `Publisher` 对应 `StreamProvider`。TCA(The Composable Architecture)是功能上更完整的对标方案。 + +--- + +### 3.3 GoRouter —— 声明式路由 + +**是什么**:Flutter 官方推荐的路由库,支持深链接(Deep Link)、Web URL 路由、类型安全路由、权限守卫(redirect)。 + +**解决什么问题**:原生 `Navigator.push/pop` 无法用 URL 描述路由状态,深链接处理复杂,无法做全局路由守卫。 + +```dart +// routes.dart — 类型安全路由定义 +@TypedGoRoute(path: '/login') +class LoginRoute extends GoRouteData with $LoginRoute { + const LoginRoute(); + @override + Widget build(BuildContext context, GoRouterState state) => const LoginPage(); +} + +// 跳转(类型安全,不再用字符串) +LoginRoute().go(context); // 替换当前页面 +LoginRoute().push(context); // 压栈 + +// app_router.dart — 全局路由守卫 +GoRouter( + refreshListenable: auth.listenable, // 登录态变化时重新执行 redirect + redirect: (context, state) { + final loggedIn = auth.value; + if (loggedIn == null) return null; // 启动中,不动 + if (!loggedIn) return '/login'; // 未登录,跳登录 + if (loggedIn && state.matchedLocation == '/login') return '/home'; + return null; + }, +) +``` + +> **📱 原生对比** +> **Android**:Jetpack Navigation Component + Safe Args(类型安全参数传递)是直接对标;`NavController.navigate()` ≈ `context.go()`;Navigation Graph XML 的全局 action ≈ GoRouter `redirect`。 +> **iOS**:SwiftUI 的 `NavigationPath` + `.navigationDestination(for:destination:)` 是声明式路由的原生方案;UIKit 的 `UINavigationController` + Coordinator pattern 是更成熟的命令式替代。 + +--- + +### 3.4 Dio —— HTTP 客户端 + +**是什么**:Flutter 最流行的 HTTP 客户端,支持拦截器链、文件上传、取消请求、FormData 等。 + +**解决什么问题**:原生 `http` 包功能简单,Dio 提供完整的中间件(拦截器)机制,可以统一处理 Token 注入、错误映射、重试等横切关注点。 + +**Options.extra 信号机制**(本项目独特设计): + +拦截器之间通过 `RequestOptions.extra` Map 传递信号,避免拦截器之间直接耦合: + +```dart +// 登录接口调用时设置,告知 AuthInterceptor 跳过 Token 注入 +dio.post('/auth/login', options: Options(extra: {'skip_auth': true})); + +// TokenRefreshInterceptor 防止无限重试: +// 重放请求时打标记 +err.requestOptions.extra['_token_refreshed'] = true; +// 下一次进入拦截器时检查 +final retried = err.requestOptions.extra['_token_refreshed'] == true; +if (retried) return handler.next(err); // 不再重试 +``` + +> **📱 原生对比** +> **Android**:OkHttp 的 `Interceptor` 接口与 Dio 拦截器机制几乎完全相同(连"应用层拦截器 vs 网络层拦截器"的分类思路都一致)。Retrofit 基于 OkHttp 提供注解式 API 定义。`OkHttpClient.Builder().addInterceptor()` 对应 `dio.interceptors.add()`。 +> **iOS**:URLSession 原生没有拦截器链;Alamofire 的 `RequestInterceptor`(`adapt` + `retry`)和 `EventMonitor` 提供等价能力;Moya(基于 Alamofire)提供更高层的抽象。 + +--- + +### 3.5 Drift —— 类型安全 ORM + +**是什么**:Flutter/Dart 的类型安全 SQLite ORM,通过代码生成将 Dart 类映射为数据库表,查询语句在编译期检查。 + +**解决什么问题**:直接写 SQL 字符串没有类型检查,容易出现拼写错误和运行时崩溃。Drift 让你用 Dart 类型化的 API 写查询。 + +```dart +// 表定义(users_table.dart) +class Users extends Table { + TextColumn get userId => text()(); // TEXT NOT NULL + TextColumn get username => text().nullable()(); // TEXT NULL + IntColumn get gender => integer().nullable()(); +} + +// DAO(数据访问对象) +@DriftAccessor(tables: [Users]) +class UsersDao extends DatabaseAccessor with _$UsersDaoMixin { + Future findById(String userId) => + (select(users)..where((t) => t.userId.equals(userId))) + .getSingleOrNull(); // 编译期类型安全 + + Future upsert(UsersCompanion companion) => + into(users).insertOnConflictUpdate(companion); +} +``` + +**后台 Isolate**:Drift 的 `NativeDatabase.createInBackground` 在独立 Isolate 中运行数据库操作,不阻塞 UI 线程: + +```dart +// app_database.dart +NativeDatabase.createInBackground( + file, + isolateSetup: _sqlCipherIsolateSetup, // 必须是顶层函数! + setup: (db) { + db.execute("PRAGMA key = '$escaped';"); // 解锁加密 + }, +) +``` + +> **📱 原生对比** +> **Android**:Room 是直接对标——同样注解式、同样代码生成 DAO、同样禁止主线程 IO(Room 默认在 IO Dispatcher 执行)。`@Entity` ≈ Drift `Table`,`@Dao` ≈ `@DriftAccessor`,`@Database` ≈ `@DriftDatabase`。 +> **iOS**:Core Data 是官方 ORM(基于 NSManagedObject,面向对象风格);GRDB.swift(三方)更接近 Drift 的风格(类型安全 SQL)。两者都支持后台 `managedObjectContext` 避免主线程阻塞。 + +--- + +### 3.6 Flutter Secure Storage —— 安全存储 + +**是什么**:跨平台安全键值存储,iOS 使用 Keychain,Android 使用 EncryptedSharedPreferences(AES-256 + Android Keystore)。 + +**解决什么问题**:普通 `SharedPreferences` 存储 Token 在 Android 上明文可读(rooted 设备),Secure Storage 的加密存储符合安全规范。 + +```dart +// 零容忍规则:Token 只能从 SecureStorage 读写,禁止存 SharedPreferences +class SecureStorageService { + Future getToken() => _storage.read(key: _kToken); + Future setToken(String token) => _storage.write(key: _kToken, value: token); + Future clearAll() => _storage.deleteAll(); // 退出登录时调用 +} +``` + +> **📱 原生对比** +> **Android**:`EncryptedSharedPreferences`(Jetpack Security)直接对标,底层用 Android Keystore + AES-256-GCM;也可直接操作 Android Keystore 存证书/密钥。 +> **iOS**:Keychain Services 是原生安全存储,`SecItemAdd` / `SecItemCopyMatching` 是底层 API;大多数项目用 `KeychainAccess`(三方)简化调用。 + +--- + +### 3.7 Talker —— 结构化日志 + +**是什么**:Flutter 的结构化日志库,支持按级别过滤(verbose/debug/info/warning/error)、颜色输出、集成 Dio 和 Riverpod 的自动日志。 + +**解决什么问题**:`print()` 没有级别、没有颜色、Release 包无法关闭,会泄露敏感信息。 + +```dart +// 使用(零容忍:禁止 print/debugPrint) +appTalker.info('App 启动'); +appTalker.warning('[TokenRefresh] 刷新失败', error, stackTrace); +appTalker.error('[Database] 打开失败', error, stackTrace); +appTalker.verbose('[Router] path=/home loggedIn=true'); +// Release 包自动关闭,Dev Panel 中可查看历史日志 +``` + +> **📱 原生对比** +> **Android**:Timber(三方)是最流行的日志库,`Timber.d/w/e()` 对应 Talker 的各级别,`DebugTree` vs `ReleaseTree` 实现 Release 环境静默——与 Talker 设计理念完全一致。 +> **iOS**:`OSLog`(官方,iOS 14+)是结构化日志的现代方案;`CocoaLumberjack`(三方)提供更丰富的级别过滤和文件输出,功能上更接近 Talker。 + +--- + +### 3.8 Sentry —— 云端错误追踪 + +**是什么**:错误监控 SaaS,将 App 崩溃和异常上报到云端,提供错误聚合、影响用户数统计、堆栈还原(ProGuard / dSYM)。 + +**本项目重点:PII 脱敏** + +```dart +// sentry_setup.dart — beforeSend 钩子过滤敏感信息 +SentryFlutter.init( + (options) { + options.beforeSend = (event, hint) => _sanitize(event); + // 手机号脱敏、Token 完全移除、身份证号脱敏 + }, +); +// DSN 留空 = 不上报(dev 环境默认不上报) +``` + +> **📱 原生对比** +> **Android / iOS**:Firebase Crashlytics 是最主流的替代(Google 出品,免费,与 Firebase 生态集成深);Sentry 同样提供 Android SDK 和 iOS SDK,功能对等。两者都支持 `beforeSend` 钩子脱敏。 + +--- + +### 3.9 Slang —— 编译期 i18n + +**是什么**:基于代码生成的国际化方案,翻译文件(JSON)在编译期转为类型安全的 Dart 类,拼错翻译键名会在编译期报错。 + +```dart +// 使用(context.t 是类型安全的) +Text(context.t.auth.login) // "登录" +Text(context.t.auth.phoneHint) // "请输入手机号"(camelCase 自动转换) +// 修改翻译后:make gen-i18n +``` + +> **📱 原生对比** +> **Android**:String Resources(`res/values/strings.xml`)是官方方案,通过 `R.string.xxx` 访问(编译期检查)。无需三方库,但键名是字符串常量,IDE 支持弱于 Slang 的类型链式访问。 +> **iOS**:`Localizable.strings` + `NSLocalizedString("key", comment:)` 是传统方案;SwiftGen(三方)与 Slang 最接近,从资源文件生成类型安全的 Swift 访问器。 + +--- + +### 3.10 flutter_screenutil —— 屏幕适配 + +**是什么**:基于设计稿尺寸自动缩放 UI 元素,确保在不同屏幕尺寸的设备上界面比例一致。 + +**本项目配置**:设计基准 375×812(与 iOS 6s 逻辑分辨率相同),`minTextAdapt: true` 防止文字在小屏上过小。 + +```dart +// 使用 +Container( + width: 200.w, // 200 / 375 * 屏幕宽度 + height: 50.h, // 50 / 812 * 屏幕高度 + padding: EdgeInsets.all(16.r), // 按最小边缩放 + child: Text('登录', style: TextStyle(fontSize: 16.sp)), +) +``` + +> **📱 原生对比** +> **Android**:dp(density-independent pixels)+ sp(scale-independent pixels)是系统内置适配方案;ConstraintLayout 的百分比约束和 `guideline` 实现比例布局。无需三方库。 +> **iOS**:Auto Layout + Size Classes 是官方适配方案(设置 leading/trailing/top/bottom 约束相对于 Safe Area);SnapKit(三方)简化约束书写,功能上更贴近 ScreenUtil 的开发体验。 + +--- + +### 3.11 SQLCipher —— 数据库加密 + +**是什么**:SQLite 的加密扩展,在 SQLite 基础上增加 AES-256 全库加密,文件在文件系统层面不可读。 + +**与 Drift 集成**: + +``` +普通 SQLite 裸库 + ↓ 替换为 +SQLCipher(libsqlcipher.so / SQLCipher.xcframework) + ↓ 在 setup 回调中解锁 +PRAGMA key = 'your_key'; + ↓ 之后所有操作透明加密 +Drift ORM(正常使用,感知不到加密层) +``` + +**关键坑**:Drift 后台 Isolate 的 setup 函数必须是**顶层函数**(不能是 lambda),否则 Isolate 间序列化失败,加密静默不生效。 + +> **📱 原生对比** +> **Android**:`SQLCipher for Android`(同一家公司的 SDK)可直接替换 `android.database.sqlite`;Jetpack Security 的 `EncryptedFile` + Room 组合提供另一种加密路径(实验性,不如 SQLCipher 成熟)。 +> **iOS**:`SQLCipher for iOS`(同一 SDK 体系);Core Data 不原生支持加密,需要通过 SQLCipher 替换底层存储或使用 SQLite 层加密。 + +--- + +### 3.12 pointycastle + asn1lib —— RSA 加密传输 + +**是什么**:纯 Dart 密码学库,`pointycastle` 提供 RSA/AES 等算法实现,`asn1lib` 解析 ASN.1/DER 格式的密钥。 + +**本项目用途**:登录密码在客户端用服务端公钥加密后传输,防止明文密码在网络中传输(即使 HTTPS 中间人攻击场景下也有额外保护层)。 + +```dart +// rsa_helper.dart +// 失败时抛 RsaEncryptionException(禁止 fallback 明文,零容忍) +static String encrypt(String plainText, String spkiBase64) { + // Base64 解码 → DER → ASN.1 解析 → RSA 公钥 → PKCS#1 v1.5 加密 → Base64 输出 +} +``` + +> **📱 原生对比** +> **Android**:`javax.crypto`(JCE)+ `java.security`(JCA)是 JVM 原生加密 API;Android Keystore System 提供硬件级密钥保护。Bouncy Castle 是最流行的三方密码学库(pointycastle 是其 Dart 移植版)。 +> **iOS**:`Security` framework(`SecKey`、`SecKeyCreateWithData`)处理 RSA 操作;Swift 5.5+ 的 `CryptoKit` 提供更现代的 API(但 RSA PKCS#1 v1.5 不在 CryptoKit 支持范围,需用 Security framework)。 + +--- + +## 第四章:项目核心组件深度解读 + +> 本章是第三章各库的**项目级集成讲解**——从单个库的"怎么用"升级到"为什么这样组合"。 +> 每节会标注"参见第三章 X.X"以便回查库的基础知识。 + +### 4.1 启动序列(main.dart) + +(参见:3.7 Talker、3.8 Sentry、3.5 Drift、3.2 Riverpod) + +```mermaid +sequenceDiagram + participant M as main() + participant CR as CrashReporter + participant S as SentrySetup + participant DB as AppDatabase + participant RS as ProviderScope + participant SB as _SyncBootstrap + + M->>CR: preInit()(准备崩溃写入路径) + CR-->>M: pendingCrash(上次崩溃数据) + M->>S: init(appRunner)(空 DSN 直接执行 appRunner) + S->>CR: installHooks()(接管 FlutterError + Zone 异常) + S->>DB: open()(AES-256 解锁 SQLite) + DB-->>S: AppDatabase 实例 + S->>RS: runApp(ProviderScope(...)) + RS->>SB: initState() + SB->>SB: syncService.start()(后台同步,unawaited) + SB->>SB: errorLogger.pruneOld()(清理旧日志,unawaited) +``` + +**为什么顺序不能颠倒**: +1. `CrashReporter.preInit()` 必须最早——它准备崩溃文件写入路径,后续任何阶段崩溃都能被捕获 +2. `SentrySetup.init()` 包裹整个 `appRunner`——确保 Sentry 捕获到启动期异常 +3. `AppDatabase.open()` 必须在 `ProviderScope` 之前——`appDatabaseProvider` 用 `overrideWithValue` 注入,需要实例准备好 + +--- + +### 4.2 七层拦截器网络栈 + +(参见:3.4 Dio 拦截器链) + +```mermaid +flowchart TD + REQ(["📤 Dio.request()
业务代码发起请求"]) + + subgraph Chain["拦截器链(onRequest 从上到下,onError 从下到上)"] + direction TB + I1["[1] CertPinningInterceptor
🔐 TLS 指纹校验
PINNED_FINGERPRINTS 为空 → 跳过
不匹配 → NetworkFailure(badCertificate)"] + I2["[2] AuthInterceptor
🔑 Token 注入
skip_auth=true → 跳过
否则读 SecureStorage → header[tokenName]=token"] + I3["[3] TokenRefreshInterceptor(onError)
🔄 401 自动刷新
业务码分类 → 不消耗非 token 类错误
Completer 互斥防并发重复消耗"] + I4["[4] RetryInterceptor(onError)
🔁 指数退避重试
408/429/5xx → 最多3次
200ms→400ms→800ms"] + I5["[5] ErrorInterceptor(onError)
🗺️ 异常映射
DioException → sealed Failure
写入 err.error 供下游读取"] + I6["[6] AuthLogoutInterceptor(onError)
🚪 强制登出
err.error is AuthFailure → clearAll()
→ markLoggedOut() → GoRouter 跳 /login"] + I7["[7] LogInterceptor
📋 请求日志
Dev/Internal → TalkerDioLogger
Release → noop(零日志泄漏)"] + end + + NET(["🌐 服务器"]) + RESP(["📥 Response
成功响应返回业务代码"]) + + REQ --> I1 --> I2 --> NET + NET --> RESP + NET -- 错误 --> I7 --> I6 --> I5 --> I4 --> I3 + + style I1 fill:#E74C3C,color:#fff + style I2 fill:#3498DB,color:#fff + style I3 fill:#9B59B6,color:#fff + style I4 fill:#F39C12,color:#fff + style I5 fill:#1ABC9C,color:#fff + style I6 fill:#E67E22,color:#fff + style I7 fill:#7F8C8D,color:#fff +``` + +--- + +### 4.3 sealed Failure 错误体系 + +(参见:1.3 Sealed Class、3.1 Freezed) + +```mermaid +flowchart LR + NET["网络/DB 异常
DioException
DriftException
RsaEncryptionException"] + EM["ExceptionMapper
.fromUnknown(e, st)"] + + subgraph SF["sealed Failure(编译期强制穷举)"] + NF["NetworkFailure
kind: timeout/noNetwork
/badCertificate/cancelled"] + AF["AuthFailure
kind: unauthorized/forbidden
/refreshFailed/cryptoFailed"] + SF2["ServerFailure
code: 业务错误码
statusCode: HTTP 状态码"] + CF["CacheFailure
本地数据库 IO 异常"] + UF["UnknownFailure
兜底"] + end + + UI1["UI 层
failure.userMessage
→ AppToast"] + UI2["ErrorLogger
failure.message(技术细节)
→ Drift error_logs 表"] + UI3["Sentry
failure.toString()
→ 云端聚合"] + + NET --> EM + EM --> NF & AF & SF2 & CF & UF + NF & AF & SF2 & CF & UF --> UI1 + NF & AF & SF2 & CF & UF --> UI2 + UF --> UI3 + + style NF fill:#E74C3C,color:#fff + style AF fill:#9B59B6,color:#fff + style SF2 fill:#E67E22,color:#fff + style CF fill:#F39C12,color:#fff + style UF fill:#7F8C8D,color:#fff +``` + +--- + +### 4.4 认证模块 Clean Architecture + +(参见:3.1 Freezed、3.2 Riverpod、3.3 GoRouter、3.4 Dio、3.6 SecureStorage) + +```mermaid +classDiagram + class AuthRepository { + <> + +loginWithPhone(phone, code) Future~UserEntity~ + +loginWithPassword(phone, pwd) Future~UserEntity~ + +logout() Future~void~ + +getCurrentUser() Future~UserEntity?~ + +sendSmsCode(phone) Future~String?~ + +fetchUserProfile() Future~UserEntity~ + } + + class AuthRepositoryImpl { + -AuthRemoteDatasource _remote + -UsersDao _dao + -SecureStorageService _storage + +loginWithPhone(phone, code) Future~UserEntity~ + +loginWithPassword(phone, pwd) Future~UserEntity~ + -_persistUser(UserModel) Future~void~ + } + + class AuthRemoteDatasource { + -Dio _dio + +loginWithPhone(phone, code) Future~UserModel~ + +loginWithPassword(phone, pwd) Future~UserModel~ + +sendSmsCode(phone) Future~String?~ + +logout() Future~void~ + +getUserProfile() Future~UserModel~ + } + + class AuthNotifier { + <> + -int _generation + +state: AuthState + +loginWithPhone(phone, code) Future~void~ + +loginWithPassword(phone, pwd) Future~void~ + +logout() Future~void~ + +sendSmsCode(phone) Future~void~ + +refreshUserInfo() Future~void~ + } + + class UserModel { + <> + +userId: String + +username: String? + +token: String? + +tokenName: String? + +refreshToken: String? + +toEntity() UserEntity + } + + class UserEntity { + <> + +userId: String + +username: String? + +phone: String? + +avatar: String? + } + + class AuthState { + <> + initial() + loading() + authenticated(UserEntity) + unauthenticated() + error(String) + } + + AuthRepository <|.. AuthRepositoryImpl : implements + AuthRepositoryImpl --> AuthRemoteDatasource : uses + AuthRepositoryImpl --> UserModel : receives + UserModel --> UserEntity : toEntity() + AuthNotifier --> AuthRepository : calls + AuthNotifier --> AuthState : state +``` + +**分层原则**: +- **Domain 层**(`UserEntity`、`AuthRepository`):纯 Dart,无框架依赖,可在服务端/命令行复用 +- **Data 层**(`UserModel`、`AuthRepositoryImpl`):负责 JSON 解析和持久化,`UserModel.toEntity()` 是跨层桥梁 +- **Presentation 层**(`AuthNotifier`、`LoginPage`):只依赖 `AuthRepository` 接口,测试时可 Mock + +--- + +### 4.5 SQLCipher 加密数据库启动流程 + +(参见:3.5 Drift、3.11 SQLCipher、3.6 SecureStorage) + +```mermaid +flowchart TD + A["AppDatabase.open()"] --> B{"Platform.isAndroid?"} + B -- Yes --> C["applyWorkaroundToOpenSqlCipherOnOldAndroidVersions()
确保 Java 先加载 libsqlcipher.so"] + B -- No --> D + C --> D["_sqlCipherIsolateSetup()
主 Isolate 注册 SQLCipher open override"] + D --> E["DbKeyProvider.key
从 SecureStorage 读取/生成 AES-256 密钥
(RandomKeyStrategy:首次随机 32 字节)"] + E --> F{"app.db 文件已存在?"} + F -- Yes --> G["_canOpenWithKey()
用当前密钥探测是否可打开"] + G -- 失败 --> H["file.deleteSync()
删除损坏/密钥不匹配的旧文件"] + G -- 成功 --> I + F -- No --> I + H --> I["NativeDatabase.createInBackground()
后台 Isolate:isolateSetup=_sqlCipherIsolateSetup(顶层函数!)
setup: PRAGMA key = 'escaped_key'"] + I --> J["AppDatabase 就绪
所有读写透明 AES-256 加密"] +``` + +--- + +### 4.6 GoRouter 三态认证跳转 + +(参见:3.3 GoRouter、1.2 Completer 与异步) + +```mermaid +stateDiagram-v2 + [*] --> Bootstrapping : App 启动 + + Bootstrapping --> CheckStorage : authStatusProvider 创建 + CheckStorage --> LoggedIn : SecureStorage 有 token + CheckStorage --> LoggedOut : SecureStorage 无 token + CheckStorage --> LoggedOut : 读取异常 + + state LoggedIn { + [*] --> Home + Home --> [*] + } + + state LoggedOut { + [*] --> Login + Login --> [*] + } + + state Bootstrapping { + [*] --> Waiting : auth.value == null + Waiting --> [*] : redirect 返回 null(不跳转) + } + + LoggedIn --> LoggedOut : logout()
→ markLoggedOut()
→ GoRouter 重新 redirect + LoggedOut --> LoggedIn : login 成功
→ markLoggedIn()
→ GoRouter 重新 redirect +``` + +--- + +### 4.7 崩溃日志系统 + +(参见:3.8 Sentry、3.5 Drift) + +```mermaid +sequenceDiagram + participant M as main() + participant CR as CrashReporter + participant FH as FlutterError.onError + participant ZH as Zone onError + participant CG as CrashGate(UI) + + M->>CR: preInit()(创建崩溃文件目录) + CR-->>M: pendingCrash(读取上次遗留的崩溃记录) + Note over M,CG: pendingCrash 注入 ProviderScope,CrashGate 自动弹窗 + + M->>CR: installHooks() + CR->>FH: 接管 FlutterError.onError(Widget 层异常) + CR->>ZH: 接管 runZonedGuarded(Dart 层未捕获异常) + + Note over FH,ZH: 崩溃发生时 + FH->>CR: 同步写入 crash.json(不依赖 async) + ZH->>CR: 同步写入 crash.json + + Note over CR,CG: 下次启动 + CR->>CR: consumePending()(读 crash.json 并删除) + CR-->>CG: pendingCrash 非 null → 弹对话框询问上报 +``` + +**"同步写入"是关键设计**:崩溃发生时 Dart 运行时处于不稳定状态,异步操作(`await`)可能无法完成。`CrashReporter` 使用同步文件写入,确保崩溃信息在进程退出前落盘。 + +--- + +### 4.8 主题系统 + +(参见:3.2 Riverpod、3.10 flutter_screenutil) + +```mermaid +flowchart LR + SP["SharedPreferences
'theme_scheme'
'theme_mode'"] + + TN["ThemeNotifier
@Riverpod keepAlive
→ themeProvider
(AppColorScheme)"] + + TMN["ThemeModeNotifier
@Riverpod keepAlive
→ themeModeProvider
(ThemeMode)"] + + AT["AppTheme
.light(scheme)
.dark(scheme)
→ ThemeData"] + + APP["App(ConsumerWidget)
ref.watch(themeProvider)
ref.watch(themeModeProvider)
→ MaterialApp.router"] + + SP -->|"启动读取"| TN + SP -->|"启动读取"| TMN + TN -->|"setScheme() → 写入"| SP + TMN -->|"setMode() → 写入"| SP + TN --> AT + AT --> APP + TMN --> APP +``` + +**5 套色板切换**(运行时热切换,无需重启): + +```dart +ref.read(themeNotifierProvider.notifier).setScheme(AppColorScheme.red); +// → SharedPreferences 持久化 +// → themeProvider 通知 +// → App 重建(只有 App widget,成本极低) +// → MaterialApp.router 使用新 ThemeData +``` + +--- + +## 第五章:新增 Feature 的标准流程 + +### 5.1 目录结构模板 + +``` +lib/features/{feature_name}/ +├── domain/ +│ ├── entities/{name}_entity.dart # @freezed,纯 Dart,无框架依赖 +│ └── repositories/{name}_repository.dart # abstract interface,定义业务契约 +├── data/ +│ ├── models/{name}_model.dart # @freezed + fromJson + toEntity() +│ ├── datasources/{name}_remote_datasource.dart # @riverpod,只调用 Dio +│ └── repositories/{name}_repository_impl.dart # @riverpod,实现接口 +└── presentation/ + ├── notifiers/{name}_notifier.dart # @riverpod class XxxNotifier + └── pages/{name}_page.dart # ConsumerWidget 或 ConsumerStatefulWidget +``` + +### 5.2 完整示例(以"订单列表"为例) + +**Step 1:Domain 层** + +```dart +// lib/features/order/domain/entities/order_entity.dart +@freezed +class OrderEntity with _$OrderEntity { + const factory OrderEntity({ + required String orderId, + required String title, + required double amount, + required String status, + }) = _OrderEntity; +} + +// lib/features/order/domain/repositories/order_repository.dart +abstract interface class OrderRepository { + Future> getOrders({int page = 1}); +} +``` + +**Step 2:Data 层** + +```dart +// lib/features/order/data/models/order_model.dart +@freezed +class OrderModel with _$OrderModel { + const factory OrderModel({ + @JsonKey(name: 'order_id') required String orderId, + required String title, + required double amount, + required String status, + }) = _OrderModel; + + factory OrderModel.fromJson(Map json) => + _$OrderModelFromJson(json); +} + +extension OrderModelX on OrderModel { + OrderEntity toEntity() => OrderEntity( + orderId: orderId, + title: title, + amount: amount, + status: status, + ); +} + +// lib/features/order/data/repositories/order_repository_impl.dart +@riverpod +OrderRepository orderRepository(Ref ref) => OrderRepositoryImpl( + ref.read(orderRemoteDatasourceProvider), +); + +class OrderRepositoryImpl implements OrderRepository { + OrderRepositoryImpl(this._remote); + final OrderRemoteDatasource _remote; + + @override + Future> getOrders({int page = 1}) async { + final models = await _remote.getOrders(page: page); + return models.map((m) => m.toEntity()).toList(); + } +} +``` + +**Step 3:Presentation 层** + +```dart +// lib/features/order/presentation/notifiers/order_notifier.dart +@freezed +sealed class OrderState with _$OrderState { + const factory OrderState.initial() = _Initial; + const factory OrderState.loading() = _Loading; + const factory OrderState.loaded(List orders) = _Loaded; + const factory OrderState.error(String message) = _Error; +} + +@riverpod +class OrderNotifier extends _$OrderNotifier { + @override + OrderState build() => const OrderState.initial(); + + Future load() async { + state = const OrderState.loading(); + try { + final orders = await ref.read(orderRepositoryProvider).getOrders(); + state = OrderState.loaded(orders); + } on Object catch (e, st) { + final failure = ExceptionMapper().fromUnknown(e, st); + state = OrderState.error(failure.message); + } + } +} +``` + +**Step 4:注册路由** + +```dart +// lib/core/router/routes.dart 新增 +@TypedGoRoute(path: '/orders') +class OrderRoute extends GoRouteData with $OrderRoute { + const OrderRoute(); + @override + Widget build(BuildContext context, GoRouterState state) => const OrderPage(); +} +``` + +**Step 5:代码生成** + +```bash +make gen # 生成 order_model.g.dart / order_notifier.g.dart 等 +``` + +--- + +### 5.3 常见坑与最佳实践 + +| 坑 | 原因 | 正确做法 | +|----|------|---------| +| `initState` 里 `ref.watch` 后立即报错 | `initState` 时 Widget 还未 build,Provider 依赖图未稳定 | 用 `addPostFrameCallback` 延迟到 build 后 | +| Drift 后台 Isolate 不生效(数据明文)| `isolateSetup` 传了 lambda 而非顶层函数 | 必须是**顶层函数**(定义在类外层)| +| `context.go('/login')` 在 `async` 后崩溃 | 异步操作后 Widget 已卸载 | `await` 后加 `if (!mounted) return;` | +| `ref.watch` 在非 `build` 方法调用 | `watch` 只在 `build` 里有效 | 事件回调用 `ref.read`,监听用 `ref.listen` | +| `print()` 被 Lint 报错 | `very_good_analysis` 禁止 `print` | 用 `appTalker.info/warning/error()` | +| `Navigator.push()` 破坏路由栈 | GoRouter 管理整个导航栈 | 用 `XxxRoute().go(context)` | +| 手动修改 `.g.dart` 文件被覆盖 | `make gen` 覆盖所有生成文件 | 只修改手写源文件,重新 `make gen` | +| `ApiPaths` 中路径为空字符串 | 脚手架占位,未配置 | 接手时填入真实 API 路径 | + +--- + +> **下一步**:阅读 [docs/architecture.md](architecture.md) 了解整体架构决策, +> 阅读 [docs/setup-project.md](setup-project.md) 完成新项目配置。