- 更新接口路径、字段和短信验证码场景,新增忘记密码/重置密码流程 - 增加 401 自动刷新 token 与统一错误处理 - 重构登录页 UI,支持退出确认、协议与法律文档入口
18 KiB
Flutter 项目规范
严格遵循本文档定义的架构模式、状态管理标准和代码风格。未经授权不得引入新的第三方包或架构模式。优先组合而非继承,业务逻辑不进入 UI 组件。
1. 技术栈
- 语言: Dart(SDK
^3.12.2,严格空安全) - 框架: Flutter(锁定版本,禁止使用
flutter upgrade自由升级,详见 §11 版本管理) - 状态管理:
flutter_riverpod(v2.x,注解 + 代码生成) - 路由:
go_router(v14.x,含鉴权重定向,详见 §9 路由鉴权) - 网络请求:
dio(配合json_annotation+freezed进行 JSON 序列化) - 数据模型:
freezed+build_runner - 本地存储:
shared_preferences(非敏感偏好)+flutter_secure_storage(令牌,平台配置见 §8 安全)+mmkv(高性能键值缓存) - 依赖注入: Riverpod Providers(不使用 GetIt)
- 国际化:
flutter_localizations(l10n / ARB 格式)
⚠️ 版本一致性约束:不同开发者执行
flutter upgrade的时间不同会导致 SDK 版本漂移,引发构建差异。Flutter 与 Dart SDK 版本必须以 CI /.fvmrc为准,禁止在本地随意升级。
2. 目录结构
采用 特性优先架构(Feature-First),特性内部结合 整洁架构(Clean Architecture) 分层:
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 序列化:
@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 等明文写入日志 / 崩溃上报。规范约定:
- 含敏感字段的模型 不得 使用
jsonEncode(toJson())形式的toString();- 改为手动排除敏感字段后输出,或直接不覆盖
toString()(依赖调试器查看字段);- 若确需调试输出,标注
@visibleForTesting并约定生产环境不调用。
UI 组件
- 尽可能使用
const构造函数,避免不必要的重建。 - 复杂子树提取为独立私有/公有无状态组件,不要写冗长的内联辅助方法。
- 响应式适配使用
LayoutBuilder/MediaQuery或项目统一的屏幕适配工具。
5. 命名约定
| 类别 | 规范 | 示例 |
|---|---|---|
| 文件/文件夹 | snake_case.dart |
connection_controller.dart |
| 类/枚举 | PascalCase |
ConnectionSessionState |
| 变量/方法 | camelCase |
sendResolutionChange() |
| Provider | 以 Provider 结尾 |
authRepositoryProvider |
| 私有成员 | _ 前缀 |
_handleSignalMessage() |
6. 代码生成
修改或新增带代码生成的模型/控制器后,运行:
# 一次性生成(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/ 下,按类型分子目录:
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):_AuthInterceptor:注入Authorization: Bearer <token>。_ResponseInterceptor:统一解析{ code, message, data },code == 0提取data,否则抛业务错误。_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 / 信令用桩对象。 - 分层测试:领域层纯函数单测;控制器用
Containeroverride 后read(notifier)驱动并断言state;UI 用pumpWidget(ProviderScope(...))。 - 运行门槛:提交前
flutter test需通过(见 §14)。
14. Git / CI
- Commit Message:遵循 Conventional Commits(
feat:/fix:/refactor:/docs:/test:/chore:)。 - PR 模板:含变更说明、影响范围、测试步骤、截图(UI 变更)。
- CI 流水线(建议):
flutter analyze无 issue。flutter test通过。dart run build_runner build --delete-conflicting-outputs校验生成代码最新。- 多端构建(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/ 前缀:
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:
{ "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 |
忘记密码页 |