编写第一个功能模块¶
本页用一个完整的 登录功能 演示如何把模板封装好的积木串起来写业务。每一步都对应一个封装点,照着做你就能在任何功能里复用同一套路。
先生成骨架:
fluzer new login # 默认 BLoC 范式
fluzer new login --cubit # 可选:Cubit 范式(MVVM,presentation/cubit/,无 event)
生成后目录(默认 BLoC 范式;若用 --cubit 则 presentation/bloc/ 变为 presentation/cubit/,且不含 event.dart):
lib/features/login/
├── login_module.dart # DI 注册(只注册 Repository)
├── data/
│ ├── models/login_model.dart # 数据模型(freezed)
│ └── repositories/login_repository.dart
└── presentation/
├── bloc/
│ ├── login_bloc.dart # 已混入四个 Mixin
│ ├── login_event.dart # freezed 空壳
│ └── login_state.dart # freezed 空壳
├── effects/login_effect_handle.dart # 业务副作用处理器
└── pages/
├── login_page.dart # BlocProvider + EffectListener
└── login_body.dart # 纯渲染内容
1. 定义 Intent(事件)与 ViewState(状态)¶
login_event.dart / login_state.dart 用 freezed 定义,状态永远不可变、只 copyWith:
// login_event.dart
@freezed
abstract class LoginEvent with _$LoginEvent {
const factory LoginEvent.usernameChanged(String value) =
LoginUsernameChanged;
const factory LoginEvent.passwordChanged(String value) =
LoginPasswordChanged;
const factory LoginEvent.submit() = LoginSubmit;
}
// login_state.dart
@freezed
abstract class LoginState with _$LoginState {
const factory LoginState({
@Default('') String username,
@Default('') String password,
@Default(false) bool isSubmitting,
@Default(false) bool isSuccess,
String? nickname,
String? error,
}) = _LoginState;
}
为什么状态不持有 DTO?
模板接受「状态直接持有数据层 DTO」的主流取舍(见 架构总览)。若你追求最严格分层,可在 Bloc 内把 XxxModel 映射成 XxxUiModel 再进 State。
2. 写 Repository(继承 BaseRepository)¶
仓库负责网络与解析。框架不替你解析响应,所有「发请求 / 解析 / 判断业务错误 / 抛异常」都在 LoginRepository 的公开方法里自行决定(见 错误处理与 Result)。BaseRepository 只持有 Dio,不提供任何 parse* 辅助方法。
// login_repository.dart
class LoginRepository extends BaseRepository {
const LoginRepository({required super.dio});
Future<String> login({
required String username,
required String password,
CancelToken? cancelToken,
}) async {
final response = await dio.post('/login', data: {
'username': username,
'password': password,
});
// 假设后端返回 {code:0, message:'ok', data:'昵称'}
// 框架不替你解析:响应结构、业务码判断、如何抛错都由你决定。
final body = response.data as Map<String, dynamic>;
if (body['code'] != 0) {
// 业务码非 0 → 抛 Exception,文案来自后端 message,交给 Bloc 的 failure 分支
throw Exception(body['message']?.toString() ?? 'login failed');
}
return body['data'] as String; // 昵称
}
}
- HTTP 非 2xx → Dio 直接抛
DioException;如需统一提示,可在 Bloc 的failure分支用ex.toToastEffect()直接展示(框架不做自动翻译)。 - HTTP 200 但业务码非 0 → 抛
Exception(文案来自后端message),交给上层 Bloc 的failure分支处理。 - 纯客户端校验(如空用户名)也可直接
throw Exception('用户名或密码不能为空')。
3. 写 Bloc(四个 Mixin 协同)¶
login_bloc.dart 已 with 四个 Mixin。把它们组合起来完成一次「可等待 + 带 Loading + 统一错误处理」的登录:
class LoginBloc extends Bloc<LoginEvent, LoginState>
with
BlocAwaitMixin<LoginEvent, LoginState>,
BlocEffectMixin<LoginState>,
BlocErrorHandlerMixin<LoginState>,
BlocCancelTokenMixin<LoginState> {
LoginBloc({required this.repository}) : super(const LoginState()) {
on<LoginUsernameChanged>(_onUsernameChanged);
on<LoginPasswordChanged>(_onPasswordChanged);
onAwait<LoginSubmit>(_onSubmit); // 自动收尾的 await
}
final LoginRepository repository;
/// 页面可 await 这次提交(如登录成功后跳转)。
/// 重复调用会合并到同一趟提交上等待,不会重复发起请求。
Future<void> submit() =>
runAwait(event: const LoginEvent.submit());
Future<void> _onSubmit(LoginSubmit event, Emitter<LoginState> emit) async {
emit(state.copyWith(isSubmitting: true, error: null));
// 1) 副作用:显示全局 Loading(交给框架默认 handle)
emitEffect(const LoadingEffect(show: true));
// 2) 统一错误处理:成功 / 失败 / 取消 三态显式
final result = await runCatching(
() => repository.login(
username: state.username,
password: state.password,
cancelToken: token('login')
),
);
// 3) 副作用:隐藏 Loading
emitEffect(const LoadingEffect(show: false));
result.when(
success: (nickname) {
emit(state.copyWith(isSubmitting: false, isSuccess: true, nickname: nickname));
emitEffect(const ToastEffect(l10nCode: 'loginSuccess'));
},
failure: (ex) {
emit(state.copyWith(isSubmitting: false, error: ex.message));
emitEffect(const ToastEffect(l10nCode: 'loginFailed'));
},
cancel: () => emit(state.copyWith(isSubmitting: false)),
);
}
}
要点:
runCatching取代手写try/catch:网络异常 / 取消被包装为三态Result,你只需处理三态;Failure携带原始Exception,不做归一化。LoadingEffect不经过业务 handle,由框架默认 handle 调LoadingService显示/隐藏全局 Loading。ToastEffect(l10nCode: ...)用自定义本地化键,由业务 handle 翻译(见第 4 步)。若只想显示服务端文案,可直接用ToastEffect(message: ex.message)或ex.toToastEffect()。
4. 写业务副作用处理器(l10nCode → 文本)¶
login_effect_handle.dart 用 is 认领自己关心的 l10nCode,不穷尽 switch;其余交给框架默认 handle:
bool loginEffectHandle(BuildContext context, UIEffect effect) {
if (effect is ToastEffect && effect.l10nCode != null) {
final service = getIt<ToastService>();
final l = context.l;
switch (effect.l10nCode) {
case 'loginSuccess':
service.showSuccess(l.loginSuccess);
return true;
case 'loginFailed':
service.showError(l.loginFailed);
return true;
default:
return false;
}
}
return false; // 其余交给框架默认 handle
}
并在 l10n/app_zh.arb / app_en.arb 增加对应 key:
context.l是模板提供的便捷扩展,等价于AppLocalizations.of(context)。
5. 接 Page(BlocProvider + EffectListener)¶
骨架的 login_page.dart 已经接好:
BlocProvider(
create: (_) => LoginBloc(repository: getIt<LoginRepository>()),
child: const EffectListener<LoginBloc, LoginState>(
effectsHandles: [loginEffectHandle],
child: LoginBody(),
),
)
login_body.dart 是 State 的纯函数:只读 context.watch<LoginBloc>(),只通过 context.read<LoginBloc>().add(...) / .submit() 发射意图:
final bloc = context.watch<LoginBloc>();
final state = bloc.state;
// 渲染 state.username / state.isSubmitting ...
// 提交:await bloc.submit(); // 可等待
// 或:context.read<LoginBloc>().add(const LoginEvent.submit());
6. 运行¶
完成后的数据流:
点击登录 → bloc.submit()(可 await)
→ emit(isSubmitting:true) + emitEffect(LoadingEffect(show:true))
→ runCatching(repository.login)
→ emitEffect(LoadingEffect(show:false))
→ result.when: 成功→emit(state)+Toast(l10nCode:loginSuccess)
失败→emit(state)+Toast(l10nCode:loginFailed)
取消→emit(isSubmitting:false)
套路小结(复制即用)¶
任意功能模块都遵循同一套:
fluzer new <name>生成骨架(默认 BLoC 范式,可选--cubit生成 Cubit 范式)。Event/State用 freezed;状态不可变。Repository extends BaseRepository,用dio直接发请求并手动解析响应、按需throw Exception,框架不做任何解析假设。- Bloc
with四个 Mixin:onAwait做可等待操作,runCatching做统一错误处理,emitEffect(LoadingEffect/ToastEffect)做一次性副作用。 effects/<name>_effect_handle.dart只翻译自定义l10nCode,其余交默认 handle。- 页面只渲染
State、只发射意图。
更细的封装用法见 BLoC 四个 Mixin、错误处理与 Result、Effect 与 Notifiers。