Skip to content

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.jsViewModel 调 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 的文件头注释本身就是规范说明

dart
// 守 coding-standards:HeListScaffold + PagedListBody(禁手写 EasyRefresh 装配)、
// AppTokens、he_components barrel、RuntimeI18n、HeLoading、GoRouter。

这七个词就是写一个页面的全部规范。逐条拆:

① 导入区(六类 import)

dart
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';

② 取上下文三件套

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

dart
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 / tabBodiesTab 模式(两者必须等长)
body单 body 模式(tabs == null 时必填)
subheader搜索框下方的固定栏
controller外部 TabController(可选)
onSearch搜索回调(共享单 keyword,不区分 Tab
onTabChangeTab 切换回调
actions导航栏右侧动作
showBack是否显示返回(默认 true)

⚠️ 断言约束(he_list_scaffold.dart:61-67):

tabs == null 时 body 必填;tabs != null 时 tabBodies 必填且与 tabs 等长

④ 列表体:PagedListBody

dart
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-分页与列表

⑤ 副作用监听

dart
ref.listen<int>(homeShowTickProvider, (previous, next) {
  if (ref.read(detailClickProvider)) {
    ref.read(detailClickProvider.notifier).state = false;
  } else {
    _onPlantDataChanged();
  }
});

三、端到端:从零做一个新页面

假设要做一个「电站告警列表」页。按序执行:

Step 1:加路由常量

lib/routing/routes.dart

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

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';

然后跑:

bash
fvm dart run build_runner build --delete-conflicting-outputs

Step 3:写 ViewModel

lib/ui/plant_alarm/view_models/alarm_list_view_model.dart

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

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

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-国际化 的标准步骤加词条。


四、交互模式:筛选的「草稿-确认」

场景

筛选面板里选条件,点「确定」才生效,点「取消」回滚

前端类比

类似表单的 draftcommitted 两份数据。

本项目的实现

DeviceListViewModel 的做法(简化):

dart
// 草稿状态
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 里 draftFormform 两份数据,submitObject.assign(form, draftForm)


五、交互模式:乐观更新

场景

删除列表项时,先立即从 UI 移除,再发请求。失败则回滚。

实现

dart
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 与后端数据不一致。


六、交互模式:权限控制渲染

dart
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

dart
void _onAddDevice(BuildContext context, RuntimeI18n l10n, bool hasPermission) {
  if (!hasPermission) {
    HeLoading.showToast(l10n.t('noPermissionShort'));
    return;
  }
  ...
}

七、交互模式:loading 态

页面级 loading

交给 LoadingStateHandlerAsyncValue 三态自动处理)。

按钮级 loading

dart
HeLoadingButton(
  isLoading: state.isLoading,
  onPressed: _onSubmit,
  text: l10n.t('submit'),
)

全局 toast / loading

dart
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 classbuild() 内联首次加载
  • [ ] build() 里有 CancelToken + ref.onDispose
  • [ ] 用 switch 匹配 ResultErrorResult 分支 throw e
  • [ ] 页面用 HeListScaffold / 项目脚手架,没有手写 EasyRefresh
  • [ ] 所有文案走 l10n.t(...)没有硬编码中文
  • [ ] 所有尺寸/颜色走 AppTokens没有字面量数字
  • [ ] 图片用 Assets.images.xxx没有 Image.asset 字面串
  • [ ] 图标用 AppIcon,不是 Icon(Icons.xxx)
  • [ ] 权限控制用 hasPermission('app:b:xxx')
  • [ ] 日志用 AppLogger没有 print()
  • [ ] fvm flutter analyze 0 error

下一步

11 · 组件与主题