- 配置 edge-to-edge 沉浸式状态栏与刘海屏适配 - 新增百度地图权限、AK 配置及定位权限说明 - 接入 MethodChannel 实现返回键退后台热启动优化 - 配置自定义签名并更新构建逻辑 - 完善 token 刷新与鉴权失效统一处理 - 新增地图页与截图页路由 - 修复返回键拦截导致的 PopScope 失效问题
386 lines
19 KiB
Markdown
386 lines
19 KiB
Markdown
# Flutter 项目规范
|
||
|
||
> 严格遵循本文档定义的架构模式、状态管理标准和代码风格。未经授权不得引入新的第三方包或架构模式。优先组合而非继承,业务逻辑不进入 UI 组件。
|
||
|
||
---
|
||
|
||
## 1. 技术栈
|
||
|
||
- **语言:** Dart(SDK `^3.12.2`,严格空安全)
|
||
- **框架:** Flutter(**锁定版本,禁止使用 `flutter upgrade` 自由升级**,详见 [§11 版本管理](#11-版本管理))
|
||
- **状态管理:** `flutter_riverpod`(v2.x,注解 + 代码生成)
|
||
- **路由:** `go_router`(v14.x,含鉴权重定向,详见 [§9 路由鉴权](#9-路由鉴权))
|
||
- **网络请求:** `dio`(配合 `json_annotation` + `freezed` 进行 JSON 序列化)
|
||
- **数据模型:** `freezed` + `build_runner`
|
||
- **本地存储:** `shared_preferences`(非敏感偏好)+ `flutter_secure_storage`(令牌,平台配置见 [§8 安全](#8-安全))+ `mmkv`(高性能键值缓存)
|
||
- **依赖注入:** Riverpod Providers(不使用 GetIt)
|
||
- **国际化:** `flutter_localizations`(l10n / ARB 格式)
|
||
|
||
> ⚠️ **版本一致性约束**:不同开发者执行 `flutter upgrade` 的时间不同会导致 SDK 版本漂移,引发构建差异。Flutter 与 Dart SDK 版本必须以 CI / `.fvmrc` 为准,禁止在本地随意升级。
|
||
|
||
---
|
||
|
||
## 2. 目录结构
|
||
|
||
采用 **特性优先架构(Feature-First)**,特性内部结合 **整洁架构(Clean Architecture)** 分层:
|
||
|
||
```text
|
||
lib/
|
||
├── main.dart # 应用程序入口(ProviderScope + runApp)
|
||
├── app/ # 全局应用配置
|
||
│ ├── app.dart # CupertinoApp.router 入口
|
||
│ ├── router/ # 路由定义(GoRouter,含 redirect 鉴权)
|
||
│ ├── theme/ # 主题、颜色、排版
|
||
│ └── constants/ # 全局常量、API 端点、ICE 配置
|
||
├── core/ # 跨特性共享代码
|
||
│ ├── network/ # dio 客户端、拦截器链、统一错误模型(AppError)
|
||
│ ├── proto/ # ⚠️ 仅放【跨特性共享】的 Protobuf 生成代码(见 §3 边界规则)
|
||
│ ├── storage/ # 本地存储辅助工具(shared_preferences / secure_storage / mmkv 封装)
|
||
│ ├── utils/ # 辅助函数、扩展方法
|
||
│ └── widgets/ # 共享 UI 组件(触摸层)
|
||
├── features/ # 特性模块
|
||
│ ├── auth/ # 登录 / 令牌 / 绑定列表
|
||
│ │ ├── data/ # Repository 实现(dio)
|
||
│ │ ├── domain/ # 仓库接口、状态模型(freezed)
|
||
│ │ └── presentation/ # 控制器、登录对话框
|
||
│ └── connection/ # 信令 / WebRTC / 控制面板
|
||
│ ├── data/ # 信令客户端、WebRTC、编排器、解码器、录制器
|
||
│ ├── domain/ # 信令消息、会话状态(freezed)、【单一特性专用】协议模型
|
||
│ └── presentation/ # 控制器、设置页、控制页、鉴权对话框
|
||
├── l10n/ # 国际化(.arb 文件,命名/组织约定见 §10)
|
||
└── assets/ # 静态资源(目录结构与命名见 §7 资源管理)
|
||
├── images/
|
||
└── fonts/
|
||
```
|
||
|
||
### ⚠️ core/proto 与特性层边界规则
|
||
|
||
目录结构中 `core/proto` 存放 Protobuf 生成代码,但特性层的 `domain` 又标注"协议模型除外",两者边界容易混淆。明确规则如下:
|
||
|
||
- **跨特性共享的 `.proto`**(被两个及以上 feature 使用,或属于全局控制指令协议)→ 放 `core/proto/`。
|
||
- **单一特性专用的协议模型**(如仅 `connection` 使用的信令消息)→ 放 `features/xxx/domain/models/`,不进 `core`。
|
||
- 判断标准:问一句"去掉这个 feature 后,该 proto 还有人用吗?"没人用就留在 feature 内,保持内聚。
|
||
|
||
---
|
||
|
||
## 3. 分层职责
|
||
|
||
### 表示层(`presentation/`)
|
||
|
||
UI 组件 + Riverpod Notifier / AsyncNotifier 控制器。
|
||
|
||
- 只能通过 `ref.watch` 或 `ref.listen` 消费状态。
|
||
- 禁止在 `onPressed` 或 `build()` 中直接调用 API 或编写业务逻辑。
|
||
- 一次性提示通过状态中的 `alert` 字段传递,UI 展示后调用 `consumeAlert()` 消费。
|
||
|
||
### 领域层(`domain/`)
|
||
|
||
纯 Dart 实体(`freezed`)和仓库接口定义。
|
||
|
||
- 零 Flutter/UI 依赖(**单一特性专用的协议模型除外**,跨特性共享的协议模型在 `core/proto`)。
|
||
- 状态类命名避免与 Flutter SDK 冲突(如用 `ConnectionSessionState` 而非 `ConnectionState`)。
|
||
|
||
### 数据层(`data/`)
|
||
|
||
实现仓库接口,通过 dio 处理 API 请求,将 JSON DTO 映射为领域实体。
|
||
|
||
---
|
||
|
||
## 4. 编码规范
|
||
|
||
### 状态管理(Riverpod)
|
||
|
||
- 使用 `@riverpod` 注解 + `build_runner` 代码生成。
|
||
- **`keepAlive: true` 使用约束(⚠️ 重要)**:
|
||
- 仅限**全局单例级状态**(如 `Auth`、`Signaling` 会话控制器)使用,用于避免页面切换时销毁。
|
||
- **特性页面级控制器禁止使用 `keepAlive: true`**,否则会造成状态残留与内存泄漏。
|
||
- `keepAlive: true` 意味着 Provider **永远不会自动 dispose**,必须配套明确的清理策略:
|
||
- 用户**登出 / 断连**时主动调用 `ref.invalidate(provider)` 或 `ref.read(provider.notifier).reset()`(置 `ref.state = null` / 初始值),防止旧状态(token、连接会话)残留。
|
||
- 全局单例的清理动作集中在登出流程(`auth` 控制器或 `app` 层的统一退出入口)中统一触发。
|
||
- 异步操作优先使用 `AsyncNotifierProvider`,配合 `AsyncValue`(Loading / Data / Error)。
|
||
- 业务回调(信令/WebRTC 事件)统一在控制器内映射到状态,不在 UI 层直接持有控制器实例。
|
||
|
||
### 数据建模(freezed)
|
||
|
||
所有数据类/实体必须用 `freezed` 实现不可变,并配置 JSON 序列化:
|
||
|
||
```dart
|
||
@freezed
|
||
class SignalMessage with _$SignalMessage {
|
||
const SignalMessage._();
|
||
|
||
const factory SignalMessage({
|
||
String? type,
|
||
String? payload,
|
||
}) = _SignalMessage;
|
||
|
||
factory SignalMessage.fromJson(Map<String, dynamic> json) =>
|
||
_$SignalMessageFromJson(json);
|
||
|
||
// ⚠️ 安全:toString() 直接 jsonEncode 会泄露 token / password 等敏感字段。
|
||
// 若模型不含敏感字段可保留;否则改为手动排除敏感字段,或干脆不覆盖 toString()。
|
||
@override
|
||
String toString() {
|
||
// 示例:排除敏感字段
|
||
final safe = toJson()..remove('token');
|
||
return 'SignalMessage(${safe.toString()})';
|
||
}
|
||
}
|
||
```
|
||
|
||
> ⚠️ **敏感信息防护**:`jsonEncode(toJson())` 形式的 `toString()` 会把 token、password 等明文写入日志 / 崩溃上报。规范约定:
|
||
> 1. 含敏感字段的模型 **不得** 使用 `jsonEncode(toJson())` 形式的 `toString()`;
|
||
> 2. 改为手动排除敏感字段后输出,或直接不覆盖 `toString()`(依赖调试器查看字段);
|
||
> 3. 若确需调试输出,标注 `@visibleForTesting` 并约定生产环境不调用。
|
||
|
||
### UI 组件
|
||
|
||
- 尽可能使用 `const` 构造函数,避免不必要的重建。
|
||
- 复杂子树提取为独立私有/公有无状态组件,不要写冗长的内联辅助方法。
|
||
- 响应式适配使用 `LayoutBuilder` / `MediaQuery` 或项目统一的屏幕适配工具。
|
||
|
||
---
|
||
|
||
## 5. 命名约定
|
||
|
||
| 类别 | 规范 | 示例 |
|
||
|---|---|---|
|
||
| 文件/文件夹 | `snake_case.dart` | `connection_controller.dart` |
|
||
| 类/枚举 | `PascalCase` | `ConnectionSessionState` |
|
||
| 变量/方法 | `camelCase` | `sendResolutionChange()` |
|
||
| Provider | 以 `Provider` 结尾 | `authRepositoryProvider` |
|
||
| 私有成员 | `_` 前缀 | `_handleSignalMessage()` |
|
||
|
||
---
|
||
|
||
## 6. 代码生成
|
||
|
||
修改或新增带代码生成的模型/控制器后,运行:
|
||
|
||
```bash
|
||
# 一次性生成(freezed / json_serializable / riverpod_generator)
|
||
dart run build_runner build --delete-conflicting-outputs
|
||
|
||
# 监听模式
|
||
dart run build_runner watch --delete-conflicting-outputs
|
||
|
||
# 生成国际化(l10n.yaml 已配置)
|
||
flutter gen-l10n
|
||
```
|
||
|
||
---
|
||
|
||
## 7. 资源管理
|
||
|
||
静态资源统一放在项目根 `assets/` 下,按类型分子目录:
|
||
|
||
```text
|
||
assets/
|
||
├── images/ # 图片资源
|
||
│ ├── common/ # 通用图标 / 占位图
|
||
│ └── feature_xxx/ # 按特性隔离的图片
|
||
└── fonts/ # 自定义字体
|
||
```
|
||
|
||
- **图片命名**:`snake_case`,带用途/状态后缀,如 `ic_back.png`、`bg_login_dark@2x.png`;分辨率变体使用 `@2x` / `@3x` 后缀。
|
||
- **`pubspec.yaml` 引入**:在 `flutter.assets` 中显式声明目录(当前模板已注释示例,新增资源后需补齐),字体在 `flutter.fonts` 中声明 `family` 与 `asset`。
|
||
- 超过 ~100KB 的图片优先走 CDN / 网络加载,避免包体积膨胀。
|
||
|
||
---
|
||
|
||
## 8. 安全
|
||
|
||
- **`flutter_secure_storage` 平台配置**:
|
||
- **iOS**:数据落地于 **Keychain**(系统级加密,不随 app 卸载必然清除,需走钥匙串共享组或登录项)。
|
||
- **Android**:默认使用 **EncryptedSharedPreferences**(需 `minSdkVersion >= 23`;低于此需自定义 `AndroidOptions` 的 `encryptedSharedPreference` 开关)。
|
||
- 仅存放 `refreshToken` 等高危凭证,禁止存明文密码。
|
||
- **敏感字段防护**:见 §4 数据建模中 `toString()` 的约束,token / password 不得进入日志与崩溃上报。
|
||
- **本地偏好**:非敏感配置(主题、语言)用 `shared_preferences`;高频读写缓存可用 `mmkv`。
|
||
- **网络传输**:所有 API 走 HTTPS;信令 WebSocket 使用 `wss://`。
|
||
|
||
---
|
||
|
||
## 9. 路由鉴权
|
||
|
||
基于 `go_router` 实现统一的鉴权重定向:
|
||
|
||
- **受保护路由**:在 `app/router/` 中集中定义路由表,受保护路由(如 `connection/*`)标记为需鉴权。
|
||
- **未登录重定向**:通过 `GoRouter` 的 `redirect` 回调检查登录态(读取 `auth` 控制器的 `AsyncValue` / 本地 token)。未登录时 `redirect` 到 `/login`,并把当前 `location` 写入 `extra` 或 query 参数。
|
||
- **登录回跳**:登录成功后读取 `extra` 中的目标路由,调用 `context.go()` 回跳原页面;无目标时默认跳转首页。
|
||
- **登出清理联动**:登出时除 §4 的 Provider 清理外,路由需 `go('/login')` 清空导航栈。
|
||
|
||
---
|
||
|
||
## 10. 国际化(l10n)
|
||
|
||
采用 ARB 格式,由 `l10n.yaml` 驱动 `flutter gen-l10n` 生成 `AppLocalizations`。
|
||
|
||
- **文件命名 / 组织**:语言文件以 `intl_<locale>.arb` 命名(如 `intl_zh.arb`、`intl_en.arb`),`template-arb-file` 指定模板语言(当前为 `intl_zh.arb`)。
|
||
- **key 命名**:`snake_case` 语义化,带上下文前缀避免冲突,如 `login_title`、`connection_video_off`。
|
||
- **占位符**:使用 `{param}` 占位,对应生成的 getter 会带参数;多语言文案需保持占位符一致。
|
||
- **复数 / 选择**:使用 ARB 的 `plural` / `select` 语法(如 `@count` + `plural`),避免手动拼接。
|
||
- **维护约定**:新增文案须同时更新所有语言 ARB,缺失的 key 以模板语言文案兜底。
|
||
|
||
---
|
||
|
||
## 11. 版本管理
|
||
|
||
- **锁定 Flutter / Dart 版本**:在 `README` 与 CI 中明确记录 `flutter --version` 对应的版本号。
|
||
- **推荐使用 FVM**:项目根放置 `.fvmrc`,团队成员统一 `fvm use`,禁止本地自由 `flutter upgrade`。
|
||
- `pubspec.yaml` 的 `environment.sdk` 已锁定为 `^3.12.2`,依赖版本尽量用 caret 范围并定期 `flutter pub outdated` 审查。
|
||
|
||
---
|
||
|
||
## 12. 错误处理统一规范
|
||
|
||
> 本规范已在 `lib/core/network/` 落地,新增网络请求必须复用以下基础设施,禁止在 Repository 中自行创建 Dio 实例或手写 try/catch 转换异常。
|
||
|
||
- **统一错误模型 `AppError`**(`api_error.dart`,含 `type`:network / unauthorized / forbidden / server / business / unknown,`code`,`statusCode`,`message`,`original`,以及 `isUnauthorized` / `isRetryable` 判定,与统一用户文案 `userMessage`)。数据/网络层统一向上抛 `AppError` 而非裸 `Exception` / `DioException`。
|
||
- **兼容异常 `ApiException`**(`api_exception.dart`):保留 `code` / `message` 字段以兼容既有调用方;新增代码建议直接使用 `AppError`。`DioClient` 对外统一抛出 `ApiException`。
|
||
- **dio 拦截器链**(归属 `core/network/dio_client.dart`):
|
||
1. `_AuthInterceptor`:注入 `Authorization: Bearer <token>`。
|
||
2. `_ResponseInterceptor`:统一解析 `{ code, message, data }`,`code == 0` 提取 `data`,否则抛业务错误。
|
||
3. `_ErrorInterceptor`:将 `DioException` 转换为 `ApiException`;**401 时自动用 `refreshToken` 刷新并重试一次**,刷新失败则向下传递 unauthorized 错误。
|
||
- **统一请求入口**:`DioClient.get/post/put/delete` 已封装,Repository 直接调用并捕获 `ApiException`,无需重复 try/catch。
|
||
- **错误分类与处理策略**:
|
||
- `401` → `_ErrorInterceptor` 自动刷新 token 重试;刷新失败 → 控制器检测到 unauthorized 触发登出清理。
|
||
- `403` → 无权限,展示无权限提示。
|
||
- `4xx` 其他 / 业务错误 → 展示后台 `message`。
|
||
- `5xx` / 网络不可达 → 提示重试(UI 提供 retry 入口)。
|
||
- **统一文案映射**:所有控制器/UI 捕获异常后必须通过 `userMessageOf(e)`(`error_message.dart`)获取展示文案,**禁止直接 `toString()`**(防敏感信息泄露)。
|
||
- **UI 呈现**:`AsyncValue` 的 Error 分支统一渲染——全局错误(如 401 登出)走 alert/Toast,局部错误走内联错误 + retry 按钮,不强制全局错误页。
|
||
|
||
---
|
||
|
||
## 13. 测试规范
|
||
|
||
- **目录镜像**:`test/` 下按 `test/features/<feature>/`、`test/core/` 镜像 `lib/` 结构。
|
||
- **Mock 规范**:使用 `ProviderContainer` + `overrides` 覆盖 Riverpod Provider;Repository / dio 用 `mockito` 或手写 fake;WebRTC / 信令用桩对象。
|
||
- **分层测试**:领域层纯函数单测;控制器用 `Container` override 后 `read(notifier)` 驱动并断言 `state`;UI 用 `pumpWidget(ProviderScope(...))`。
|
||
- **运行门槛**:提交前 `flutter test` 需通过(见 §14)。
|
||
|
||
---
|
||
|
||
## 14. Git / CI
|
||
|
||
- **Commit Message**:遵循 [Conventional Commits](https://www.conventionalcommits.org/)(`feat:` / `fix:` / `refactor:` / `docs:` / `test:` / `chore:`)。
|
||
- **PR 模板**:含变更说明、影响范围、测试步骤、截图(UI 变更)。
|
||
- **CI 流水线**(建议):
|
||
1. `flutter analyze` 无 issue。
|
||
2. `flutter test` 通过。
|
||
3. `dart run build_runner build --delete-conflicting-outputs` 校验生成代码最新。
|
||
4. 多端构建(Android / iOS / Web)产物校验。
|
||
- **协议同步**:`.proto` / 信令 JSON 变更须同步 Android / iOS / Web 各端,并在 PR 中标注。
|
||
|
||
---
|
||
|
||
## 15. 完成标准
|
||
|
||
- 新文件遵循特性优先结构(app / core / features / l10n / assets)。
|
||
- 代码为空安全、完全类型化,并进行 `const` 优化。
|
||
- 对应创建数据层、领域层和表示层组件。
|
||
- `keepAlive: true` 的控制器已配置登出/断连清理策略(见 §4)。
|
||
- 含敏感字段的模型已处理 `toString()` 泄露风险(见 §4、§8)。
|
||
- 提交前 `flutter analyze` 无 issue;`flutter test` 通过。
|
||
- 协议(`.proto` / 信令 JSON)变更需同步 Android / iOS / Web 各端。
|
||
|
||
---
|
||
|
||
## 16. C 端认证接口对接约定(open 模块)
|
||
|
||
> 本节记录 Flutter(`ttstd_family_care`)与后端 `youlai-boot-ttstd` 的 C 端认证接口契约,
|
||
> 对接 `open` 模块(`com.youlai.boot.open`),账号落地 `app_user` 表,与后台管理端 `sys_user` 物理分表隔离。
|
||
|
||
### 16.1 基础地址
|
||
|
||
`AppConstants.kBaseUrl` 为后端 open 模块根路径,**已包含 `/api/v1/open/` 前缀**:
|
||
|
||
```dart
|
||
static const String kBaseUrl = 'http://<host>:<port>/api/v1/open/';
|
||
```
|
||
|
||
仓库层请求路径(如 `/login`)会拼接到该 BaseUrl 之后,即实际请求
|
||
`/api/v1/open/auth/login`。**不要**把 `/api/v1/open/` 或 `/auth` 再拼进 path。
|
||
|
||
> ⚠️ 前端**禁止**将 BaseUrl 改为 `/api/v1/sn/` 前缀。`/api/v1/sn/**` 由后端
|
||
> `MobileApiSignatureFilter` 强制校验设备签名四件套(`X-Device-SN` / `X-Nonce` /
|
||
> `X-Timestamp` / `X-Sign`),仅用于**设备被控端**通信;C 端用户登录走 open 模块,
|
||
> 不经过该过滤器。若误用 `/sn/` 前缀,会返回 `A0801 设备标识不能为空` 等错误。
|
||
|
||
### 16.2 接口端点(POST)
|
||
|
||
所有请求均携带 `Authorization: Bearer <token>`(登录/发验证码等匿名接口可省略)。
|
||
|
||
| 功能 | 路径 | 请求参数 | 说明 |
|
||
|---|---|---|---|
|
||
| 账号密码登录 | `/login` | body `{username, password}` | `username` 为手机号或用户名 |
|
||
| 验证码登录 | `/login/mobile` | body `{mobile, code}` | 需先调用"发送登录验证码" |
|
||
| 发送登录验证码 | `/login/sms/code` | query `mobile` | 验证码存 Redis,5 分钟有效 |
|
||
| 注册 | `/register/mobile` | body `{mobile, code, password}` | 注册成功即签发令牌 |
|
||
| 发送注册验证码 | `/register/sms/code` | query `mobile` | 手机号未注册时发送 |
|
||
| 重置密码 | `/reset-password` | body `{mobile, code, password}` | 需先调用"发送重置密码验证码" |
|
||
| 发送重置密码验证码 | `/reset-password/sms/code` | query `mobile` | 手机号必须已注册 |
|
||
| 刷新令牌 | `/refresh-token` | query `refreshToken` | 换取新访问令牌 |
|
||
| 退出登录 | `/logout` | query `accessToken`(可选) | 注销令牌 |
|
||
|
||
### 16.3 响应结构
|
||
|
||
- 成功:`{ "code": 0, "message": "...", "data": ... }`
|
||
- 失败:`code != 0`,`message` 为错误提示(由 `_ResponseInterceptor` 抛 `ApiException`)。
|
||
|
||
登录/注册/刷新成功时 `data` 为后端 `AuthenticationToken`:
|
||
|
||
```json
|
||
{ "tokenType": "Bearer", "accessToken": "xxx", "refreshToken": "yyy", "expiresIn": 7200 }
|
||
```
|
||
|
||
前端 `LoginResult.fromJson` 已兼容解析 `accessToken`(旧字段 `token` 作兜底)。
|
||
|
||
### 16.4 对应实现位置
|
||
|
||
- 数据层:`lib/features/auth/data/auth_repository_impl.dart`
|
||
- 令牌持久化:`lib/core/storage/token_storage.dart`
|
||
- 统一请求/拦截器:`lib/core/network/dio_client.dart`
|
||
- 领域模型:`lib/features/auth/domain/auth_models.dart`(`LoginResult`、`SmsScene`)
|
||
|
||
### 16.5 场景映射
|
||
|
||
`SmsScene` 决定发送验证码的后端路径,仓库层按场景分流:
|
||
|
||
| `SmsScene` | 后端路径 | 触发页面 |
|
||
|---|---|---|
|
||
| `login` | `/login/sms/code` | 登录页(验证码登录) |
|
||
| `register` | `/register/sms/code` | 注册页 |
|
||
| `resetPassword` | `/reset-password/sms/code` | 忘记密码页 |
|
||
|
||
### 17 解耦适配
|
||
|
||
```dart
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/cupertino.dart';
|
||
```
|
||
都变更为
|
||
```dart
|
||
import 'package:material_ui/material_ui.dart';
|
||
import 'package:cupertino_ui/cupertino_ui.dart';
|
||
```
|
||
|
||
flutter_localizations也被拆分,Material和Cupertino的本地化委托分别移进了对应的包。
|
||
|
||
迁移前:
|
||
```dart
|
||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||
import 'package:flutter/material.dart';
|
||
|
||
localizationsDelegates: const <LocalizationsDelegate<dynamic>>[
|
||
GlobalCupertinoLocalizations.delegate,
|
||
GlobalMaterialLocalizations.delegate,
|
||
GlobalWidgetsLocalizations.delegate,
|
||
],
|
||
```
|
||
迁移后:
|
||
```dart
|
||
import 'package:material_ui/material_ui.dart';
|
||
|
||
localizationsDelegates: GlobalMaterialLocalizations.delegates,
|
||
``` |