# Flutter 项目规范 > 严格遵循本文档定义的架构模式、状态管理标准和代码风格。未经授权不得引入新的第三方包或架构模式。优先组合而非继承,业务逻辑不进入 UI 组件。 --- ## 1. 技术栈 - **语言:** Dart(严格空安全,使用最新稳定版本) - **框架:** Flutter(使用最新稳定版本) - **状态管理:** `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 格式) --- ## 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 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_.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 SDK 为最新稳定版,可随时执行 `flutter upgrade` 获取更新。 - **依赖版本**:`pubspec.yaml` 的依赖版本尽量使用 caret 范围,并定期通过 `flutter pub outdated` 审查与升级到最新兼容版本。 - **环境约束**:保持 `environment.sdk` 为合理的 Dart SDK 下限,确保与最新稳定版兼容。 --- ## 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 `。 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//`、`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://:/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 `(登录/发验证码等匿名接口可省略)。 | 功能 | 路径 | 请求参数 | 说明 | |---|---|---|---| | 账号密码登录 | `/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 >[ GlobalCupertinoLocalizations.delegate, GlobalMaterialLocalizations.delegate, GlobalWidgetsLocalizations.delegate, ], ``` 迁移后: ```dart import 'package:material_ui/material_ui.dart'; localizationsDelegates: GlobalMaterialLocalizations.delegates, ```