10 · 页面开发实战
目标:端到端走通「路由常量 → Freezed 模型 → ViewModel → 页面 → 接口」, 读完后能独立交付一个完整业务模块。
开篇:前端概念对照
| 前端(Vue/React 项目) | 本项目 |
|---|---|
在 router/index.js 加路由 | lib/routing/routes.dart 加常量 + router.dart 注册 |
| 定义 TS interface | @freezed 模型(ui/<feature>/models/) |
| 写 Pinia store / 自定义 hook | @riverpod class ViewModel |
写 .vue / .jsx 页面 | views/*_page.dart |
调 api/xxx.js | ViewModel 调 Repository |
| 拆子组件 | widgets/*.dart |
| loading / error / empty 三态 | LoadingStateHandler |
一、标准目录结构
一个 feature 模块长这样(以 lib/ui/device/ 为参照,37 个文件):
lib/ui/<feature>/
├── models/ UI 状态模型(@freezed)
│ ├── <feature>_list_state.dart
│ └── <feature>_filter_state.dart
├── view_models/ 业务逻辑与状态
│ ├── <feature>_list_view_model.dart
│ └── <feature>_manage_mixin.dart 可复用的行为(可选)
├── views/ 页面
│ ├── <feature>_list_page.dart
│ └── <feature>_detail_page.dart
├── widgets/ 本页面专属组件(不可复用则放这里)
│ ├── <feature>_card.dart
│ └── <feature>_filter_sheet.dart
└── <feature>_dimens.dart 页面专属尺寸常量(可选)目录归属判断
| 组件会被多个页面用吗 | 放哪 |
|---|---|
| 是 | lib/he_components/src/widgets/(要做成 He 组件) |
| 否,只在本 feature 用 | lib/ui/<feature>/widgets/ |
| 否,只在本页面用 | 直接写在页面文件里的私有 Widget |
判断原则:先复用 he_components(88 个组件),没有再造轮子。详见 11-组件与主题。
二、主教材:agent_home_page.dart 逐段拆解
lib/ui/home/views/agent_home_page.dart 的文件头注释本身就是规范说明:
// 守 coding-standards:HeListScaffold + PagedListBody(禁手写 EasyRefresh 装配)、
// AppTokens、he_components barrel、RuntimeI18n、HeLoading、GoRouter。这七个词就是写一个页面的全部规范。逐条拆:
① 导入区(六类 import)
import 'package:flutter/material.dart';
import 'package:haierenergy/core/i18n/runtime_i18n.dart'; // 1 多语言
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; // 2 路由
import 'package:haierenergy/generated/assets.gen.dart'; // 3 图片强类型
import 'package:haierenergy/he_components/he_components.dart'; // 4 组件库
import '../../../config/dependencies.dart';
import '../../../data/services/api/models/plant_dto.dart'; // 5 DTO
import '../../../domain/models/result.dart';
import '../../../routing/routes.dart';
import '../../core/models/paged_state.dart'; // 6 分页状态
import '../../core/widgets/paged_list_body.dart';
import '../../auth/view_models/auth_view_model.dart';
import '../view_models/agent_home_view_model.dart';
import '../widgets/home_banner.dart';② 取上下文三件套
@override
Widget build(BuildContext context) {
final l10n = RuntimeI18n.ofNonNull(context); // 文案
final tokens = AppTokens.of(context); // 主题 token
final canAddPlant =
ref.watch(authViewModelProvider).hasPermission('app:b:addPlant'); // 权限
...
}这三行几乎出现在每个页面的开头。
③ 页面骨架:HeListScaffold
HeListScaffold(
title: l10n.t('home.title'),
searchHint: l10n.t('home.searchHint'),
subheader: _buildSortFilterRow(...), // 固定子标题栏
actions: [HeNavBarAction(icon: ..., onTap: _onLogout)],
body: PagedListBody<PlantDto>(...), // 或 tabs + tabBodies
)构造参数(lib/he_components/src/widgets/he_list_scaffold.dart:48-67):
| 参数 | 说明 |
|---|---|
title | 导航栏标题 |
searchHint | 搜索框占位文案 |
tabs / tabBodies | Tab 模式(两者必须等长) |
body | 单 body 模式(tabs == null 时必填) |
subheader | 搜索框下方的固定栏 |
controller | 外部 TabController(可选) |
onSearch | 搜索回调(共享单 keyword,不区分 Tab) |
onTabChange | Tab 切换回调 |
actions | 导航栏右侧动作 |
showBack | 是否显示返回(默认 true) |
⚠️ 断言约束(he_list_scaffold.dart:61-67):
tabs == null 时 body 必填;tabs != null 时 tabBodies 必填且与 tabs 等长④ 列表体:PagedListBody
class _PlantListBody extends PagedListBody<PlantDto> {
const _PlantListBody();
@override
AsyncValue<PagedState<PlantDto>> watchState(WidgetRef ref) =>
ref.watch(agentHomeViewModelProvider);
@override
Future<void> refresh(WidgetRef ref) =>
ref.read(agentHomeViewModelProvider.notifier).refresh();
@override
Future<void> loadMore(WidgetRef ref) =>
ref.read(agentHomeViewModelProvider.notifier).loadMore();
@override
Widget itemBuilder(BuildContext context, PlantDto item, int index) =>
PlantCard(plant: item, ...);
@override
Widget? buildListHeader(BuildContext context, WidgetRef ref) =>
const Column(children: [HomeBanner(), HomeStatsBar()]);
}只需实现 4 个方法,下拉刷新、上拉加载、骨架屏、空态、错误重试、尾部判终全部自动。详见 12-分页与列表。
⑤ 副作用监听
ref.listen<int>(homeShowTickProvider, (previous, next) {
if (ref.read(detailClickProvider)) {
ref.read(detailClickProvider.notifier).state = false;
} else {
_onPlantDataChanged();
}
});三、端到端:从零做一个新页面
假设要做一个「电站告警列表」页。按序执行:
Step 1:加路由常量
lib/routing/routes.dart:
abstract class Routes {
Routes._();
/// 电站告警列表(query: plantId)。
static const String plantAlarm = '/plant-alarm';
/// 告警详情(extra: {alarmId})。
static const String plantAlarmDetail = '/plant-alarm-detail';
}⚠️ 用 abstract class + Routes._() 私有构造,禁止实例化。 注释里写清楚传参方式(本项目惯例)。
Step 2:定义 UI 模型
lib/ui/plant_alarm/models/alarm_item.dart:
import 'package:freezed_annotation/freezed_annotation.dart';
part 'alarm_item.freezed.dart';
/// 告警列表项(UI 模型,由 DTO 转换而来)。
@freezed
class AlarmItem with _$AlarmItem {
const factory AlarmItem({
required String id,
required String title,
required String levelText,
required bool isUrgent,
}) = _AlarmItem;
/// DTO → UI Model 转换。
factory AlarmItem.fromDto(AlarmDto dto) => AlarmItem(
id: dto.id,
title: dto.alarmName,
levelText: _levelText(dto.level),
isUrgent: dto.level >= 3,
);
}要点:
- UI 模型放
ui/<feature>/models/,不复用 DTO - 转换逻辑写在模型的
factory里(或 ViewModel 里) - 加
part 'xxx.freezed.dart';
然后跑:
fvm dart run build_runner build --delete-conflicting-outputsStep 3:写 ViewModel
lib/ui/plant_alarm/view_models/alarm_list_view_model.dart:
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../../config/providers/alarm_providers.dart';
import '../../../domain/models/result.dart';
import '../../core/models/paged_state.dart';
import '../../core/notifiers/paged_notifier_mixin.dart';
import '../models/alarm_item.dart';
part 'alarm_list_view_model.g.dart';
@riverpod
class AlarmListViewModel extends _$AlarmListViewModel
with PagedNotifierMixin<AlarmItem, AlarmDto> {
@override
Future<PagedState<AlarmItem>> build() => initialLoad();
@override
Future<Result<PageDTO<AlarmDto>>> fetchPage(
int page, {
String? keyword,
String? status,
CancelToken? cancelToken,
}) =>
ref.read(alarmRepositoryProvider).getAlarmPage(
AlarmPageRequest(pageNum: page, pageSize: PageDefaults.defaultSize),
cancelToken: cancelToken,
);
@override
AlarmItem mapDto(AlarmDto dto) => AlarmItem.fromDto(dto);
}用 PagedNotifierMixin 只需实现两个方法——分页逻辑全免费。
Step 4:写页面
lib/ui/plant_alarm/views/alarm_list_page.dart:
import 'package:flutter/material.dart';
import 'package:haierenergy/core/i18n/runtime_i18n.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:haierenergy/he_components/he_components.dart';
import '../../core/widgets/paged_list_body.dart';
import '../view_models/alarm_list_view_model.dart';
import '../widgets/alarm_card.dart';
class AlarmListPage extends StatelessWidget {
const AlarmListPage({super.key});
@override
Widget build(BuildContext context) {
final l10n = RuntimeI18n.ofNonNull(context);
return HeListScaffold(
title: l10n.t('alarm.title'),
searchHint: l10n.t('alarm.searchHint'),
body: const _AlarmListBody(),
);
}
}
class _AlarmListBody extends PagedListBody<AlarmItem> {
const _AlarmListBody();
@override
AsyncValue<PagedState<AlarmItem>> watchState(WidgetRef ref) =>
ref.watch(alarmListViewModelProvider);
@override
Future<void> refresh(WidgetRef ref) =>
ref.read(alarmListViewModelProvider.notifier).refresh();
@override
Future<void> loadMore(WidgetRef ref) =>
ref.read(alarmListViewModelProvider.notifier).loadMore();
@override
Widget itemBuilder(BuildContext context, AlarmItem item, int index) =>
AlarmCard(alarm: item);
}Step 5:注册路由
lib/routing/router.dart:
GoRoute(
path: Routes.plantAlarm,
builder: (context, state) => const AlarmListPage(),
),
GoRoute(
path: Routes.plantAlarmDetail,
builder: (context, state) {
final alarmId = state.extra as String;
return AlarmDetailPage(alarmId: alarmId);
},
),Step 6:接接口
按 09-数据层链路 的 8 步清单做(ApiPaths → DTO → ApiService → build_runner → Repository 三件套 → Provider)。
Step 7:加文案
按 14-国际化 的标准步骤加词条。
四、交互模式:筛选的「草稿-确认」
场景
筛选面板里选条件,点「确定」才生效,点「取消」回滚。
前端类比
类似表单的 draft 与 committed 两份数据。
本项目的实现
DeviceListViewModel 的做法(简化):
// 草稿状态
DeviceFilterState _draft = const DeviceFilterState();
// 打开筛选面板 → 用当前生效值初始化草稿
void openFilter() => _draft = state.filter;
// 面板内修改草稿
void updateDraft(DeviceFilterState next) => _draft = next;
// 确认 → 提交草稿并刷新
Future<void> confirmFilter() async {
state = state.copyWith(filter: _draft);
await refresh(forceLoading: true);
}
// 取消 → 丢弃草稿(什么都不做)
void cancelFilter() => _draft = state.filter;前端类比:Vue 里 draftForm 与 form 两份数据,submit 时 Object.assign(form, draftForm)。
五、交互模式:乐观更新
场景
删除列表项时,先立即从 UI 移除,再发请求。失败则回滚。
实现
Future<void> deleteDevice(String id) async {
final previous = state.list.items;
// 1. 乐观:立刻从 UI 移除
state = state.copyWith(
list: state.list.copyWith(
items: previous.where((e) => e.id != id).toList(),
),
);
// 2. 发请求
final result = await _repo.deleteDevices(...);
// 3. 失败回滚
switch (result) {
case Ok():
break; // 成功,保持现状
case ErrorResult(error: final e):
state = state.copyWith(list: state.list.copyWith(items: previous));
throw e; // 让上层弹 toast
}
}前端类比:React Query 的 onMutate + onError 回滚模式。
⚠️ 注意:乐观更新要处理好失败回滚,否则 UI 与后端数据不一致。
六、交互模式:权限控制渲染
final canAdd = ref.watch(authViewModelProvider).hasPermission('app:b:addPlant');
return Column(
children: [
if (canAdd) HeButton(text: l10n.t('add'), onPressed: _onAdd),
],
);两种风格:
| 风格 | 适用 |
|---|---|
if (canAdd) Widget(...) | 无权限就隐藏 |
Widget(enabled: canAdd, onTap: canAdd ? fn : () => toast('无权限')) | 无权限就置灰 + 提示 |
后者参见 lib/ui/plant_detail/views/plant_equipment_tab.dart:432-436:
void _onAddDevice(BuildContext context, RuntimeI18n l10n, bool hasPermission) {
if (!hasPermission) {
HeLoading.showToast(l10n.t('noPermissionShort'));
return;
}
...
}七、交互模式:loading 态
页面级 loading
交给 LoadingStateHandler(AsyncValue 三态自动处理)。
按钮级 loading
HeLoadingButton(
isLoading: state.isLoading,
onPressed: _onSubmit,
text: l10n.t('submit'),
)全局 toast / loading
HeLoading.showToast('提示');
HeLoading.showSuccess('成功');
HeLoading.showError('失败');
HeLoading.show(); // loading 遮罩
HeLoading.dismiss();八、页面开发检查清单
写完一个页面,逐项自查:
- [ ] 路由常量加在
routes.dart,注释写清传参方式 - [ ]
router.dart注册了路由 - [ ] UI 模型用
@freezed,且没有在 UI 层直接用 DTO - [ ] 跑了
build_runner,.freezed.dart/.g.dart已生成并提交 - [ ] ViewModel 用
@riverpod class,build()内联首次加载 - [ ]
build()里有CancelToken+ref.onDispose - [ ] 用
switch匹配Result,ErrorResult分支throw e - [ ] 页面用
HeListScaffold/ 项目脚手架,没有手写 EasyRefresh - [ ] 所有文案走
l10n.t(...),没有硬编码中文 - [ ] 所有尺寸/颜色走
AppTokens,没有字面量数字 - [ ] 图片用
Assets.images.xxx,没有Image.asset字面串 - [ ] 图标用
AppIcon,不是Icon(Icons.xxx) - [ ] 权限控制用
hasPermission('app:b:xxx') - [ ] 日志用
AppLogger,没有print() - [ ]
fvm flutter analyze0 error