07 · 项目架构总览
目标:读完能在 449 个文件的目录树里10 秒内定位到任意功能,并理解一次请求的完整旅程。
开篇:前端概念对照
| 前端项目概念 | 本项目对应 |
|---|---|
src/api/ | lib/data/services/api/ |
src/store/(Pinia/Vuex) | lib/config/providers/ + lib/ui/*/view_models/ |
src/views/ | lib/ui/<feature>/views/ |
src/components/ | lib/he_components/ + lib/ui/<feature>/widgets/ |
src/router/ | lib/routing/ |
src/i18n/ | lib/core/i18n/ + assets/i18n/ |
src/utils/ | lib/utils/ |
main.js | lib/main.dart |
vite.config.js | pubspec.yaml + scripts/build.sh |
| UI 组件库(antd/vant) | lib/he_components/(自研)+ tdesign_flutter |
一、目录地图
lib/ 共 449 个 Dart 文件。逐目录职责:
lib/
├── main.dart / main_prod.dart / main_test.dart ← 三个入口(见第三节)
│
├── config/ 41 个 依赖注入与环境配置
│ ├── providers/ 34 个 Riverpod provider 注册中心 ⭐
│ ├── dependencies.dart GoRouter provider + 启动 overrides
│ ├── post_privacy_bootstrap.dart 隐私同意后的 SDK 初始化(Phase 2)
│ ├── auth_bridge.dart 路由与鉴权的桥接
│ └── app_startup_observer.dart 启动性能观测
│
├── core/ 38 个 跨模块基础设施(不含业务)
│ ├── i18n/ 国际化运行时引擎 ⭐
│ ├── iconfont/ 远程 iconfont 字体加载
│ ├── permission/ 系统权限守卫 ⭐
│ ├── platform/ 13 个 平台能力(WebView / PDF / 权限实现)
│ ├── webview/ WebView 组件与 JS Bridge
│ ├── pdf/ PDF 预览
│ └── app_constants.dart
│
├── data/ 346 个 ⭐ 最大目录
│ ├── repositories/ 48 个 Repository 三件套(abstract/remote/mock)
│ └── services/
│ ├── api/ 291 个 Retrofit 接口、DTO、拦截器、SSE
│ └── local/ TokenStorage 等本地存储
│
├── domain/ 41 个 跨层共享领域模型
│ └── models/ Result<T>、Role、PageDTO 等
│
├── generated/ flutter_gen 生成物(assets.gen.dart)
│
├── he_components/ 97 个 ⭐ 自研 UI 组件库
│ ├── src/widgets/ 84 个 组件实现
│ ├── src/skeleton/ 4 个 骨架屏
│ └── src/themes/ 8 个 AppTokens / AppThemeData ⭐
│
├── i18n/ l10n/ 国际化资源
├── routing/ GoRouter 配置 ⭐
├── utils/ 10 个 AppLogger 等工具
│
└── ui/ 348 个 ⭐ 业务页面,按 feature 组织
├── home/ 33 个 首页(B 端安装商 / C 端用户)
├── device/ 37 个 设备管理
├── device_detail/ 23 个 设备详情
├── plant_detail/ 39 个 电站详情
├── paramSet/ 32 个 参数下发
├── auth/ 34 个 登录 / 注册 / 找回密码
├── user/ 20 个 用户中心
├── advancedFunction/ 19 个 高级功能
├── core/ 14 个 跨 feature 的通用 UI(分页骨架等)⭐
├── devtools/ 78 个 开发者工具(调试面板、组件展示)
└── family/ plant_add/ plant_select/ privacy/ station_data_detail/ update/定位口诀
| 我要找… | 去哪 |
|---|---|
| 某个页面长什么样 | lib/ui/<feature>/views/ |
| 页面的数据和逻辑 | lib/ui/<feature>/view_models/ |
| 页面的数据结构 | lib/ui/<feature>/models/ |
| 接口定义 | lib/data/services/api/ |
| 接口返回的数据模型 | lib/data/services/api/models/ |
| 数据来源(真实/Mock) | lib/data/repositories/<feature>/ |
| 全局状态、DI 注册 | lib/config/providers/ |
| 一个通用组件 | lib/he_components/src/widgets/ |
| 颜色/间距/字体 | lib/he_components/src/themes/app_tokens.dart |
| 路由路径 | lib/routing/routes.dart |
二、分层职责
┌────────────────────────────────────────────────────────┐
│ View lib/ui/<feature>/views/*.dart │
│ ConsumerWidget / ConsumerStatefulWidget │
│ 职责:渲染 UI、捕获用户交互 │
│ ≈ React 组件 │
└──────────────────┬─────────────────────────────────────┘
│ ref.watch(读状态)
│ ref.read(x.notifier).method()(触发动作)
┌──────────────────▼─────────────────────────────────────┐
│ ViewModel lib/ui/<feature>/view_models/*.dart │
│ @riverpod class XxxViewModel │
│ 职责:业务逻辑、持有状态、调 Repository、DTO→UI Model │
│ ≈ Vuex action + state │
└──────────────────┬─────────────────────────────────────┘
│ ref.read(repositoryProvider)
┌──────────────────▼─────────────────────────────────────┐
│ Repository lib/data/repositories/<feature>/ │
│ abstract + Remote + Mock 三件套 │
│ 职责:数据源抽象、错误包装为 Result<T> │
│ ≈ API service 层 │
└──────────────────┬─────────────────────────────────────┘
│ 调用
┌──────────────────▼─────────────────────────────────────┐
│ ApiService lib/data/services/api/ │
│ Retrofit @RestApi 抽象类 │
│ 职责:声明 HTTP 接口 │
│ ≈ Axios 封装 │
└──────────────────┬─────────────────────────────────────┘
│ Dio
┌──────────────────▼─────────────────────────────────────┐
│ 拦截器链 auth → error → logging │
│ 职责:注入 token、错误处理、日志 │
│ ≈ Axios interceptors │
└──────────────────┬─────────────────────────────────────┘
│ HTTP
▼
后端 API职责边界表
| 层级 | 做什么 | 不做什么 |
|---|---|---|
| View | 渲染、交互、临时 UI 状态(动画控制器、输入框控制器) | 业务逻辑、直接调 Repository、直接用 DTO |
| ViewModel | 业务逻辑、状态持有、DTO→UI Model 转换 | 直接操作 UI、持有 BuildContext |
| Repository | 数据源选择、错误包装成 Result<T> | UI 逻辑、持有状态 |
| ApiService | 声明接口、序列化 | 错误处理(交给 Repository 的 guard 函数) |
⚠️ 最重要的两条红线
① UI 层禁止直接使用 DTO
DTO(lib/data/services/api/models/) → ViewModel 转换 → UI Model(lib/ui/<feature>/models/)DTO 是后端契约,可能变化;UI Model 是页面需要的形态。ViewModel 负责转换。
② View 不直接调 Repository
必须经 ViewModel。这样业务逻辑可测试、可复用。
三、启动流程:三阶段编排
lib/main.dart(440 行)是理解全局的关键。它把启动拆成三个阶段。
为什么要分阶段
隐私合规要求:用户同意隐私协议之前,不能初始化任何 SDK、不能发任何请求、不能读写凭证。
所以启动被切成:
Phase 1 仅本地初始化(SharedPreferences 读主题/语言/环境)
↓
隐私弹窗(未同意时)
↓
Phase 2 同意后:SDK 初始化、凭证清理、路由挂载
↓
全 App 运行状态机
lib/main.dart:260-274:
@override
Widget build(BuildContext context) {
final Widget app;
if (!_themeLoaded || _lightTheme == null || _darkTheme == null) {
app = _buildLoadingApp(); // 主题未就绪 → loading(启动图仍盖着)
} else if (!(widget.privacyAlreadyAgreed || _agreedThisSession)) {
app = _buildGateApp(); // 隐私未同意 → 门控 App(只弹窗)
} else if (!_postPrivacyInitDone) {
app = _buildLoadingApp(); // Phase 2 进行中 → loading
} else {
app = _buildFullApp(); // 完成 → 全 App(MaterialApp.router)
}
return app;
}四个状态,三棵 App 树——注意 _buildGateApp() 是独立的 MaterialApp,不挂路由、不构造 AuthViewModel。
Phase 1:main() → appMain()
lib/main.dart:74-115:
Future<void> appMain({Environment? forcedEnvironment}) async {
final widgetsBinding = WidgetsFlutterBinding.ensureInitialized();
FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding); // 保留启动图
Future.delayed(const Duration(seconds: 5), FlutterNativeSplash.remove); // 5s 兜底
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); // 锁竖屏
AppLogger.I.init();
NetworkInspectorConfig.apply(); // chucker 抓包(debug/profile)
TDTheme.needMultiTheme(); // TDesign 多主题支持
_configureEasyLoading(AppThemeData.light);
final overrides = await bootstrapOverrides(forcedEnvironment: forcedEnvironment);
final prefs = await SharedPreferences.getInstance();
final privacyAlreadyAgreed = prefs.getBool(PrivacyKeys.agreed) ?? false;
runApp(ProviderScope(
overrides: overrides,
observers: [AppStartupObserver()],
child: AppBootstrap(...),
));
}三个入口文件
| 文件 | 作用 | 命令 |
|---|---|---|
lib/main.dart | 默认入口,环境走运行时解析 | fvm flutter run |
lib/main_test.dart | 强制锁定测试环境后端 | fvm flutter run -t lib/main_test.dart |
lib/main_prod.dart | 强制锁定生产环境后端 | fvm flutter run -t lib/main_prod.dart |
⚠️ main_test.dart 的 test 指的是「后端测试环境」,不是单元测试。这是最容易误解的命名。
// lib/main_test.dart 全文
void main() => appMain(forcedEnvironment: Environment.test);Phase 2:runPostPrivacyInit
用户点击「同意」后触发(lib/main.dart:247-257):
Future<void> _runPostPrivacyInit() async {
await runPostPrivacyInit(isExistingInstall: widget.isExistingInstall);
unawaited(
ref.read(i18nSyncServiceProvider)
.sync(resolveEffectiveLocale(ref.read(localeNotifierProvider))),
);
if (mounted) setState(() => _postPrivacyInitDone = true);
}注意 mounted 检查(呼应 03 章)。
全 App 树的关键装配
lib/main.dart:334-399 的 _buildFullApp() 装配顺序(从外到内):
MaterialApp.router
└─ builder: MediaQuery(textScaler: noScaling) ← 固定字体缩放 1.0
└─ EasyLoading.init() ← 全局 toast/loading overlay
└─ ScreenUtilInit(designSize: 750×1624) ← 等比缩放
└─ Stack
├─ child(路由页面)
├─ NetworkOfflineBanner ← 离线横幅
├─ _IconfontBootstrap ← 后台加载远程字体
└─ DevFloatingButton(!kReleaseMode)← 开发调试悬浮按钮!kReleaseMode 的用法值得记住:debug 和 profile 都会装载开发工具,只有 release 不装。
依赖注入:bootstrapOverrides
lib/config/dependencies.dart:39-52:
Future<List<Override>> bootstrapOverrides({Environment? forcedEnvironment}) async {
await ThemeModeNotifier.init();
await UseMockApi.init();
await LocaleNotifier.init();
final appEnv = forcedEnvironment ?? await EnvironmentConfig.getEnvironment();
RuntimeEnvironment.current = appEnv;
return [];
}为什么要预加载:让 ThemeModeNotifier / UseMockApi / LocaleNotifier 能同步读取 SharedPreferences 值,避免首帧闪烁。
自检
- [ ] 能说出启动的三个阶段及其存在原因(隐私合规)
- [ ] 知道
main_test.dart是「测试环境后端」入口,不是单元测试 - [ ] 知道
_buildGateApp()是不挂路由的独立 App - [ ] 知道
!kReleaseMode用于装载开发工具
四、一次请求的完整旅程
以「首页加载电站列表」为例:
flowchart TD
V["View<br/>AgentHomePage<br/>ref.watch(agentHomeViewModelProvider)"] -->|1 ref.watch| VM["ViewModel<br/>AgentHomeViewModel<br/>@riverpod class"]
V -->|"2 ref.read(vm.notifier).refresh()"| VM
VM -->|3 ref.read| R["Repository 抽象<br/>PlantRepository"]
R -->|4 DI 切换| RR["PlantRepositoryRemote<br/>guardApiObject()"]
R -->|4' Mock 模式| RM["PlantRepositoryMock<br/>读 assets/mock_data/*.json"]
RR -->|5| A["ApiService<br/>PlantApiService<br/>Retrofit @RestApi"]
A -->|6| I["Dio 拦截器链<br/>auth → error → logging"]
I -->|7 HTTP| S["后端 API"]
S -->|8 响应| I
I -->|9| RR
RR -->|"10 Result<T>"| VM
VM -->|"11 AsyncValue 三态"| V逐步拆解
| 步 | 做什么 | 关键代码 |
|---|---|---|
| 1 | View 订阅状态 | ref.watch(agentHomeViewModelProvider) |
| 2 | 用户下拉刷新 | ref.read(vmProvider.notifier).refresh() |
| 3 | ViewModel 拿 Repository | ref.read(plantRepositoryProvider) |
| 4 | DI 决定用 Remote 还是 Mock | 见 lib/config/providers/ |
| 5 | Repository 调 ApiService | guardApiObject(() => _api.getXxx()) |
| 6 | Retrofit 发请求 | 生成的 .g.dart 实现 |
| 7 | 拦截器注入 token、记录日志 | auth / error / logging |
| 8-9 | 响应沿拦截器链返回 | |
| 10 | 错误被包装成 Result<T> | Ok(value) / ErrorResult(error) |
| 11 | ViewModel 转成 AsyncValue 三态 | View 自动重建 |
第 10 步是关键设计:Repository 不抛异常,而是把错误包进 Result<T>。ViewModel 用模式匹配处理。详见 09-数据层链路。
五、跨模块基础设施
lib/core/ 下的能力,业务代码按需取用:
i18n 国际化
lib/core/i18n/
├── runtime_i18n.dart RuntimeI18n 访问器(`ofNonNull(context)`)
├── runtime_i18n_delegate.dart LocalizationsDelegate 实现
├── runtime_i18n_engine.dart 查表/插值/复数引擎(零 Flutter 依赖)
├── i18n_cache.dart 远程词条磁盘缓存
├── i18n_remote_source.dart Dio 拉取后端词条
├── i18n_sync_service.dart 同步编排
├── app_languages.dart 支持的语言列表
└── i18n_utils.dart词条资源在 assets/i18n/<tag>.json。详见 14-国际化。
iconfont 图标
lib/core/iconfont/ 远程加载 iconfont 字体,使用方式:
AppIcon(name: 'icon_search', size: 20, color: tokens.textPrimary)不用 Icon(Icons.search)。
permission 权限
lib/core/permission/
├── permission_guard.dart 权限请求守卫(说明弹窗 → 请求 → 引导设置)
├── permission_status.dart 权限状态枚举 + extension
├── permission_rationale.dart 权限说明文案模型
└── permission_service.dart ⚠️ 废弃中(见下)⚠️ permission_service.dart 是废弃链路。它只出现在 5 个文件(定义 + DI 注册),业务页面零使用。详见 15-权限与设备能力。
platform / webview / pdf
lib/core/platform/(13 个):平台能力抽象与实现(权限、设备信息)lib/core/webview/:WebView 组件 + JS Bridge(H5 与原生通信)lib/core/pdf/:PDF 预览页
六、主教材范例:agent_home_page.dart
这是全项目最值得精读的一个文件——它的文件头注释本身就是一份规范说明。
lib/ui/home/views/agent_home_page.dart:1-15:
// HeListScaffold(标题 + 搜索「请输入电站」+ 登出 action)+ subheader(排序/筛选
// 按钮行,固定)+ PagedListBody<PlantDto>(buildListHeader: banner 轮播
// HomeBanner + 统计卡片行 HomeStatsBar,随列表滚动;对齐 uni-app
// scroll-list 内 banner->统计->列表 同滚动结构)。
// ...
// 守 coding-standards:HeListScaffold + PagedListBody(禁手写 EasyRefresh 装配)、
// AppTokens、he_components barrel、RuntimeI18n、HeLoading、GoRouter。导入区的规范(agent_home_page.dart:17-42)
import 'package:flutter/material.dart';
import 'package:haierenergy/core/i18n/runtime_i18n.dart'; // ① 多语言
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:haierenergy/generated/assets.gen.dart'; // ② 图片强类型
import 'package:haierenergy/he_components/he_components.dart'; // ③ 组件库 barrel
import '../../../config/dependencies.dart';
import '../../../data/services/api/api_response_helpers.dart';
import '../../../data/services/api/models/plant_dto.dart'; // ④ DTO
import '../../../domain/models/result.dart';
import '../../core/utils/api_error_message.dart';
import '../../../routing/routes.dart';
import '../../core/models/paged_state.dart'; // ⑤ 分页状态
import '../../core/widgets/paged_list_body.dart'; // ⑥ 分页 body
import '../../auth/models/auth_state.dart';
import '../../auth/view_models/auth_view_model.dart';
import '../view_models/agent_home_view_model.dart';
import '../widgets/home_banner.dart';六种 import 缺一不可,这就是「一个新页面应该长什么样」的答案。
权限控制渲染(:57-62)
final l10n = RuntimeI18n.ofNonNull(context);
final tokens = AppTokens.of(context);
final canAddPlant =
ref.watch(authViewModelProvider).hasPermission('app:b:addPlant');
final canDeletePlant =
ref.watch(authViewModelProvider).hasPermission('app:b:deletePlant');模式:先算出权限布尔值,再传给子组件决定渲染。详见 15-权限与设备能力。
状态监听做副作用(:69-75)
ref.listen<int>(homeShowTickProvider, (previous, next) {
if (ref.read(detailClickProvider)) {
ref.read(detailClickProvider.notifier).state = false;
} else {
_onPlantDataChanged();
}
});ref.listen 用于「状态变化触发一次性动作」(弹 toast、刷新、导航),不用于渲染。
七、自检清单
- [ ] 能说出
lib/下 7 个一级目录各自职责和文件量级 - [ ] 面对「找某个功能」能在 10 秒内定位目录
- [ ] 能画出一次请求从 View 到后端再返回的链路
- [ ] 知道为什么启动要分三阶段
- [ ] 知道
main_test.dart的真实含义 - [ ] 知道 UI 层禁止直接用 DTO、禁止直接调 Repository
- [ ] 能说出
agent_home_page.dart导入区的六类 import