跳转至

编写第一个功能模块

本页用一个完整的 登录功能 演示如何把模板封装好的积木串起来写业务。每一步都对应一个封装点,照着做你就能在任何功能里复用同一套路。

先生成骨架:

fluzer new login            # 默认 BLoC 范式
fluzer new login --cubit    # 可选:Cubit 范式(MVVM,presentation/cubit/,无 event)

生成后目录(默认 BLoC 范式;若用 --cubitpresentation/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.dartwith 四个 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.dartis 认领自己关心的 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:

{
  "loginSuccess": "登录成功",
  "loginFailed": "登录失败"
}

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.dartState 的纯函数:只读 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. 运行

flutter gen-l10n
dart run build_runner build
flutter run

完成后的数据流:

点击登录 → 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)

套路小结(复制即用)

任意功能模块都遵循同一套:

  1. fluzer new <name> 生成骨架(默认 BLoC 范式,可选 --cubit 生成 Cubit 范式)。
  2. Event / State 用 freezed;状态不可变。
  3. Repository extends BaseRepository,用 dio 直接发请求并手动解析响应、按需 throw Exception,框架不做任何解析假设。
  4. Bloc with 四个 Mixin:onAwait 做可等待操作,runCatching 做统一错误处理,emitEffect(LoadingEffect/ToastEffect) 做一次性副作用。
  5. effects/<name>_effect_handle.dart 只翻译自定义 l10nCode,其余交默认 handle。
  6. 页面只渲染 State、只发射意图。

更细的封装用法见 BLoC 四个 Mixin错误处理与 ResultEffect 与 Notifiers


本页原文:docs/zh/getting-started/your-first-feature.md

报告本页错误