我本职是 Android 开发,这几年用 Flutter 做的项目也有好几个了。不知道你有没有类似的感受:每开一个新项目就要重新配一遍基础库、搭一遍架构、写一遍那些长得差不多的样板代码。BLoC 的 State 和 Event 定义、freezed 注解、Repository 的 CRUD 封装、DI 注册、路由配置……每次都是体力活。
之前学过一段时间的 Go 语言,有个开源框架叫 go-zero,我非常喜欢。它通过 goctl 命令行工具生成模板代码,API、RPC、数据库操作全部一条命令搞定,整个项目骨架不用手写。当时我就想,Flutter 为什么没有这种东西?
查了一圈,确实有一些,但要么封装不够彻底(只是生成个空文件夹),要么已经走向商业化的付费模板路线,要么文档缺失得让人无从下手。既然没有满意的,那就自己做一个吧。一是为了学习,二是为了方便自己以后开新项目——虽然比不上人家商业化的产品,但自己用足够了。
因为我是 Android 出身,整体封装其实偏向 Android 的开发习惯。选的 MVI 架构也跟 Android 的 MVI 一脉相承:Intent(Event)驱动 State 变化,View 单向消费 State,副作用通过独立通道分发。
整个 Flutter Zero 工具链由四个仓库组成:CLI 工具 fluzer(已发布到 pub.dev)、Mason 模板源 flutter_zero_template、示例 App flutter_zero_app,以及一个双语文档站点。这篇文章不打算逐行讲源码,而是聚焦在模板生成出来的项目里,那套 MVI-BLoC 架构中最值得说道的几个设计。如果你在用 flutter_bloc 做中大型项目,或者对 BLoC 如何通过 Mixin 组合扩展能力感兴趣,希望能给你一些参考。
简略说一下 CLI
装好 fluzer,创建项目:
dart pub global activate fluzer
fluzer create my_app
进到项目目录,生成一个功能模块:
cd my_app
fluzer new user_center
一行命令会在 lib/features/user_center/ 下生成一整套文件:BLoC(bloc + state + event,freezed 结构)、Repository(继承 BaseRepository)、Effect 处理器、Page 页面(BlocProvider + EffectListener 已组装好)、DI 注册模块(UserCenterModule)。并且自动在 injection_base.dart 里插入 UserCenterModule.register(getIt),不需要手动改任何文件。
CLI 还提供了 gen-l10n 命令,基于 Flutter 原生的 .arb 文件自动生成类型安全的 L10nCode 调用代码——这个后面会细说。
好,CLI 就到这。下面进入正题。
核心架构:四个 Mixin 的职责切分
先看一张整体架构图,心里有个大概:
┌──────────────────────────────────────────────────────┐
│ UI Layer (Page) │
│ BlocProvider ─→ BlocBuilder (消费 State) │
│ ─→ EffectListener (消费 Effect, 责任链)│
└──────────────────────┬───────────────────────────────┘
│
┌──────────────────────▼───────────────────────────────┐
│ BLoC Layer │
│ ┌─────────────┬──────────────┬────────────────────┐ │
│ │BlocAwait │BlocEffect │BlocCancelToken │ │
│ │Mixin │Mixin │Mixin │ │
│ │(await事件) │(副作用Stream) │(Dio CancelToken) │ │
│ ├─────────────┴──────────────┴────────────────────┤ │
│ │ BlocErrorHandlerMixin │ │
│ │ (runToResult → Result<T>) │ │
│ └─────────────────────────────────────────────────┘ │
└──────────────────────┬───────────────────────────────┘
│
┌──────────────────────▼───────────────────────────────┐
│ Data Layer │
│ Repository (BaseRepository) → DioClient → Server │
│ AppException ← ErrorHandler ← DioException │
└──────────────────────────────────────────────────────┘
传统的 BLoC 写法里,一个 on<LoginSubmitted> 里面塞了业务逻辑、错误处理、loading 状态切换、Toast 提示……写到后面自己都看不懂。Flutter Zero 的做法是把四个正交的能力拆成独立的 Mixin,每个只做一件事,通过组合拼出一个完整的 BLoC:
// flutter_zero_app/lib/core/bloc/ 目录下四个文件
class HomeBloc extends Bloc<HomeEvent, HomeState>
with
BlocAwaitMixin<HomeEvent, HomeState>,
BlocEffectMixin<HomeState>,
BlocCancelTokenMixin<HomeState>,
BlocErrorHandlerMixin<HomeState> {
// 只剩下纯粹的业务逻辑
}
四个 Mixin 互不依赖,按需组合。CounterBloc 不需要网络请求就不用混入 BlocCancelTokenMixin;最简单的 Cubit 也照样能用后三个。
下面逐个拆开,说清楚它们各自解决什么问题、怎么设计的、有哪些边界情况值得注意。
BlocAwaitMixin —— 让 UI 能 await BLoC 事件
flutter_bloc 的 add(Event) 是 fire-and-forget 的。事件发出去了,BLoC 内部什么时候处理完、结果是什么,外部一概不知。这个问题在 RefreshIndicator.onRefresh 里最扎眼——它需要一个 Future<void> 返回值来决定下拉动画什么时候停。
一种常见解法是在 State 里加 isRefreshing 字段,UI 层根据这个字段判断。但这把两件事混在一起了:加载状态是 UI 状态,应该由 State 表达;但"操作何时结束"是控制流问题,应该由 Future 表达。而且这个方案还有个隐患——如果刷新逻辑里有 early return(比如 hasReachedMax 直接返回),isRefreshing 永远不会被设回 false。
BlocAwaitMixin 的做法更直接:用 Completer 桥接 BLoC 内部的异步操作和 UI 层的 Future。
// flutter_zero_app/lib/core/bloc/bloc_await_mixin.dart (完整实现约 90 行)
mixin BlocAwaitMixin<Event, State> on Bloc<Event, State> {
final Map<String, List<Completer<void>>> _awaitCompleters = {};
// UI 层调用:发送事件 + 返回 Future
Future<void> runAwait({
required Event event,
required String key,
Duration timeout = const Duration(seconds: 30),
}) {
final completer = Completer<void>();
_awaitCompleters.putIfAbsent(key, () => []).add(completer);
add(event);
return completer.future.timeout(timeout);
}
// BLoC 内部调用:事件处理完毕,完成所有等待这个 key 的 Future
void completeAwait(String key) {
final completers = _awaitCompleters.remove(key);
for (final c in completers ?? <Completer<void>>[]) {
if (!c.isCompleted) c.complete();
}
}
}
UI 层的用法很干净:
// flutter_zero_app/lib/features/home/presentation/bloc/home_bloc.dart
// BLoC 提供一个 refresh() 方法,返回 Future<void>
Future<void> refresh() => runAwait(
event: const HomeEvent.refresh(),
key: _awaitKeyHomeRefresh,
);
// 页面层直接 await
RefreshIndicator(
onRefresh: () => context.read<HomeBloc>().refresh(),
child: ListView(...),
)
或者直接在UI层
// 或者在ui层
// 页面层直接 await
RefreshIndicator(
onRefresh: () => context.read<HomeBloc>.runAwait(
event: const HomeEvent.refresh(),
key: 'home_refresh',
),
child: ListView(...),
)
到这里还没完。手动调用 completeAwait 有隐患——如果 BLoC 内部因为 early return 跳过了 finally,或者异常被 catch 掉但 finally 里忘了调,Completer 就永远挂在那。
起先我觉得写好文档提醒就行,后来想到 go-zero 里有个理念:框架应该消灭一切"可能忘记"的事情。于是加了 onAwait 方法:
// flutter_zero_app/lib/core/bloc/bloc_await_mixin.dart
void onAwait<E extends Event>(
String key,
Future<void> Function(E event, Emitter<State> emit) handler,
) {
on<E>((event, emit) async {
try {
await handler(event, emit);
} finally {
completeAwait(key); // 无论正常、异常还是 early return,都会走到这里
}
});
}
对比一下两种写法的区别:
// 手动管理 —— 容易漏
on<RefreshPosts>((event, emit) async {
try {
final posts = await repository.fetch();
emit(state.copyWith(posts: posts));
} catch (e) {
emit(state.copyWith(error: e.toString()));
} finally {
completeAwait('refresh'); // 这一行漏了 = 下拉动画永远转
}
});
// onAwait 自动收尾 —— 不存在"忘了"
onAwait<RefreshPosts>('refresh', (event, emit) async {
final posts = await repository.fetch();
emit(state.copyWith(posts: posts));
// try/finally 由框架处理,异常时 completeAwait 也会被调用
});
还有几个边界设计值得一说。
value 是 List 而不是单个 Completer。 BlocAwaitMixin._awaitCompleters 的类型是 Map<String, List<Completer<void>>>。 为什么不是 Map<String, Completer<void>>?因为同一个 key 可能被并发 await。比如页面快速切后台又切回来,didChangeDependencies 触发了两次刷新,如果 value 是单个 Completer,第二次会覆盖第一个,第一个 Future 永远不完成。 用 List 收集,completeAwait 遍历完成所有。
默认 30 秒超时。 runAwait 的 timeout 参数默认 30 秒。这层保险防的是极端的边界情况:比如事件处理里抛了 Error(非 Exception),Dart 的 try/catch 可能抓不到(取决于 Dart 版本和错误类型),Completer 就永久挂起。超时是最后的兜底。
close 时主动完成所有未决 Completer。 覆写了 close(),遍历所有剩余 Completer 并 completeError,不让 Future 泄漏:
// flutter_zero_app/lib/core/bloc/bloc_await_mixin.dart
@override
Future<void> close() {
for (final entry in _awaitCompleters.entries) {
for (final completer in entry.value) {
if (!completer.isCompleted) {
completer.completeError(
StateError('BLoC closed before operation ${entry.key} completed'),
);
}
}
}
_awaitCompleters.clear();
return super.close();
}
页面退出时 BLoC 被 dispose → close 被调用 → 所有没完成的 Completer 抛出 StateError。调用方如果还在 await,至少不会无限挂起。
BlocEffectMixin —— 副作用与状态彻底分家
在 MVI 里,Toast 提示、弹窗、页面跳转这些东西是"一次性副作用"。它们跟业务状态没有直接关系——"显示一个 Toast"不是 UI 状态,它是一个动作,执行完就算了。
传统做法是把副作用塞进 State 对象里——比如 state.copyWith(toastMessage: '操作成功'),然后在 UI 层检测到这个字段非空就弹 Toast,同时再发送一个事件把它清掉。这个模式有几个问题:
- 单槽覆盖。State 是不可变的,只能表达"当前"的副作用。如果短时间内发出两次 Toast,第二次会覆盖第一次,第一个就被吞了。
- 需要手动清理。弹完 Toast 必须清掉那个字段,否则 Widget rebuild 时会再弹一次。清得不好就是 bug。
- 语义混淆。"当前显示什么"是状态,"要弹一个提示"是动作。把它们塞进同一个对象是在用状态模拟动作。
Flutter Zero 的做法是把副作用从 State 里完全抽出来,放到独立 Stream 上:
// flutter_zero_app/lib/core/bloc/bloc_effect_mixin.dart (约 50 行)
mixin BlocEffectMixin<S> on BlocBase<S> {
final StreamController<UIEffect> _effectController =
StreamController<UIEffect>.broadcast();
Stream<UIEffect> get effectStream => _effectController.stream;
void emitEffect(UIEffect effect) => _effectController.add(effect);
@override
Future<void> close() {
unawaited(_effectController.close());
return super.close();
}
}
用 broadcast 而非普通 StreamController,因为它可以被多个监听者同时订阅。同时它天然就不存在"覆盖"问题——每 add 一次就是一次独立的事件,不会被下一个覆盖。
UIEffect 不是 sealed class,而是开放的 abstract class:
// flutter_zero_app/lib/core/effect/ui_effect.dart
abstract class UIEffect {
const UIEffect();
}
框架内置了三种子类型:
ToastEffect:一次性提示。三个优先级字段——message(直接文本/服务端文案,最高优先)、l10nCode(国际化的本地化键)、code(HTTP 状态码或内部哨兵码,兜底映射)DialogEffect:对话框。只携带type(业务标识字符串)和extra(可选附加数据),不定义具体的 UILoadingEffect:全局 loading。show: true/false控制显隐,extra可传状态文案
这里有一个故意为之的设计选择——用 abstract class 而不是 sealed class。
sealed 的好处是编译期穷举检查,switch 的时候编译器能确保所有子类型都被处理。但代价是:所有 Effect 子类必须在同一个 Dart 库里声明。什么意思呢?如果你的 feature 模块想加一个 BannerEffect 表示顶部横幅提示,用 sealed class 的话,你就必须去改 ui_effect.dart 这个框架文件。
用 abstract class,任何 feature 都可以在自己的目录里 extends UIEffect 定义专属的副作用类型。框架代码零修改。
那消费者怎么区分处理不同类型的 Effect 呢?不是 switch,而是用一个责任链:
// flutter_zero_app/lib/core/effect/effect_listener.dart (约 100 行)
typedef EffectHandle = bool Function(BuildContext context, UIEffect effect);
class EffectListener<B extends BlocBase<S>, S> extends StatelessWidget {
const EffectListener({
required this.child,
this.effectsHandles = const [],
super.key,
});
final List<EffectHandle> effectsHandles;
final Widget child;
@override
Widget build(BuildContext context) {
// 链顺序:业务 handle 在前,框架兜底在后
// 第一个返回 true 的处理器胜出,链停止
final handles = [
...effectsHandles, // 业务自定义
defaultToastHandle, // 框架兜底: Toast
defaultDialogHandle, // 框架兜底: Dialog
defaultLoadingHandle, // 框架兜底: Loading
];
final bloc = context.read<B>();
if (bloc is! BlocEffectMixin<S>) {
throw StateError(
'$B must use BlocEffectMixin<$S> to support effects.',
);
}
return _EffectStreamListener<S>(
bloc: bloc,
effectsHandles: handles,
child: child,
);
}
}
_EffectStreamListener 内部订阅 bloc.effectStream,每收到一个 Effect,遍历 handles 数组,第一个返回 true 的获胜:
// flutter_zero_app/lib/core/effect/effect_listener.dart
void _onEffect(UIEffect effect) {
for (final handle in widget.effectsHandles) {
if (handle.call(context, effect)) {
return; // 命中即停
}
}
}
业务层用法:
// flutter_zero_app/lib/features/counter/presentation/effects/counter_effect_handle.dart
bool counterEffectHandle(BuildContext context, UIEffect effect) {
if (effect is ToastEffect && effect.l10nCode == 'counterMaxReached') {
// 业务自定义处理这个特定的 Toast
showDialog(...);
return true; // 认领了,后面的 handler(包括框架兜底)不再执行
}
return false; // 不认识,继续往下传
}
框架三个兜底处理器各司其职:
defaultToastHandle:按优先级解析message > l10nCode > code,委托给注入的ToastService。这个服务本身也是抽象的(abstract class ToastService),实现可以是 EasyLoading、Toastification 或任何第三方库,DI 层注入哪个就用哪个。defaultDialogHandle:渲染一个最小通用 AlertDialog,只有关闭按钮。它不做复杂的业务对话框渲染,只是保证没有被认领的 DialogEffect 不会静默丢弃。真正的业务对话框应该由业务 handle 渲染。defaultLoadingHandle:委托给注入的LoadingService,bloc 里emitEffect(const LoadingEffect(show: true))一行控制全局 loading。
实际上真正需要你处理的只有Dialog弹窗副作用。Toast和Loading框架做了默认处理。
抽象类 + 责任链 + 策略注入,这一套组合拳让 Effect 系统做到了一条:新增任何副作用类型,只需要业务层 extends UIEffect + 在责任链上加一个 handler,框架代码零改动。这不叫"灵活",这叫"必须这么设计才说得过去"。
有个细节容易忽略——EffectListener.build() 里有一行运行时检查:
if (bloc is! BlocEffectMixin<S>) {
throw StateError('$B must use BlocEffectMixin<$S> to support effects.');
}
框架生成的每个 BLoC 都自动带了 BlocEffectMixin,但万一有人手动创建 BLoC 忘了加这个 Mixin 就套上 EffectListener,这行会在调试期直接抛异常,而不是运行时静默失败——那种"为什么我明明 emitEffect 了但没反应"的 bug 最难排查。
BlocCancelTokenMixin —— 让网络请求的生命周期跟着页面走
这个 Mixin 是四个里代码最少的一个,但实用价值一点不低。核心思路:每个 BLoC 维护一个命名 CancelToken 的 Map,页面销毁时自动取消所有进行中的请求。
// flutter_zero_app/lib/core/bloc/bloc_cancel_token_mixin.dart (约 100 行含文档)
mixin BlocCancelTokenMixin<State> on BlocBase<State> {
final Map<String, CancelToken> _tokens = {};
CancelToken token([String key = 'default']) {
_tokens[key]?.cancel(); // 先取消同 key 的旧 token
return _tokens[key] = CancelToken(); // 再创建新的
}
void cancel([String key = 'default']) => _tokens.remove(key)?.cancel();
void cancelAll() {
for (final t in _tokens.values) { t.cancel(); }
_tokens.clear();
}
@override
Future<void> close() {
cancelAll();
return super.close();
}
}
有三个使用场景,分别对应三个方法:
场景一:自动去重。 搜索框里的防抖(debounce)。用户在搜索框里快速打字,"a" → "ab" → "abc" → "abcd",每秒触发一次搜索请求。如果不用 CancelToken,四次请求全部发出去,网络慢的时候后发的可能先回来,先发的可能后回来——结果就是"abc"的结果覆盖了"abcd"的结果。
// flutter_zero_app/lib/features/search 里面的用法
Future<void> _onSearch(SearchQueryChanged event, Emitter emit) async {
final result = await runToResult(
() => repository.search(
event.query,
cancelToken: token('search'), // 每次都复用 'search' 这个 key
),
);
// token('search') 内部会先 cancel 上一个,再创建新的
// 所以只有最后一个请求的结果会被处理
}
这个设计的精妙之处在于:不需要在业务代码里显式管理 cancel 逻辑。token('search') 的内部实现"先取消旧的后创建新的",意味着每次调用都是安全的——不会产生僵尸请求,也不需要手动写 cancel('search')。
场景二:页面退出时自动清理。 BLoC 的 close() 生命周期绑定页面退出。用户从列表页点进去详情页,列表页的 BLoC 被 dispose → close 被调用 → cancelAll 取消所有未完成的请求。这防止了一个隐蔽的内存泄漏:页面已经销毁了,但旧的网络响应回来后试图 setState,经典的"僵尸回调"。
场景三:用户主动取消。 比如一个上传大文件的页面,用户点了取消按钮:
void onCancelUpload() => cancel('upload');
操作之间通过 key 隔离——token('list') 和 token('upload') 互不影响。
另外,BlocCancelTokenMixin 的泛型参数只有一个 <State>。它不需要知道 Event 类型,因为它只操作 CancelToken 的 Map,不调用 add(event)。这意味着即使没有 Event 的纯 Cubit 也能用它。
BlocErrorHandlerMixin —— 从 try/catch 到 Result 模式
这是四个 Mixin 里最"重"的一个,也是架构里信息密度最高的部分。它做了两件紧密相关的事:错误归一化(各种原始异常 → 统一的 AppException 类型树),以及通过 Result<T> 把"成功/失败/取消"三种结果显式化。
先看 Mixin 的核心接口:
// flutter_zero_app/lib/core/bloc/bloc_error_handler_mixin.dart (约 80 行)
mixin BlocErrorHandlerMixin<S> on BlocBase<S> {
ErrorHandler get errorHandler => ErrorHandler(); // getter,子类可覆盖
AppException? handleError(Object error, [StackTrace? stackTrace])
=> errorHandler.handle(error, stackTrace);
bool isCancelled(Object error) => errorHandler.isCancelled(error);
Future<Result<T>> runToResult<T>(Future<T> Function() action) async {
try {
return Success(await action());
} on Object catch (e, stackTrace) {
final ex = handleError(e, stackTrace);
if (ex == null) return const Cancel(); // 取消操作返回 Cancel,不走 Failure
return Failure<T>(ex);
}
}
}
Result<T> 的设计:
// flutter_zero_app/lib/core/result/result.dart
sealed class Result<T> { const Result(); }
final class Success<T> extends Result<T> {
const Success(this.value);
final T value;
}
final class Failure<T> extends Result<T> {
const Failure(this.exception);
final AppException exception;
}
final class Cancel<T> extends Result<T> {
const Cancel();
}
// 扩展方法
extension ResultWhen<T> on Result<T> {
R? when<R>({
required R Function(T value) success,
required R Function(AppException ex) failure,
R Function()? cancel, // 可选的 cancel 分支
}) {
switch (this) {
case Success<T>(:final value): return success(value);
case Failure<T>(:final exception): return failure(exception);
case Cancel<T>(): return cancel?.call();
}
}
}
很多项目里取消和失败是混在一起的——都用同一个 catch 分支处理,"出错了"和"用户主动取消了"在代码层面没有区别。但它们的语义完全不同:失败需要提示用户、更新错误 UI;取消应该静默忽略,什么都不做。Result 把 Cancel 设为和 Success、Failure 对等的第三个子类型,用 when() 强制调用方分别处理:
// flutter_zero_app/lib/features/home/presentation/bloc/home_bloc.dart
final result = await runToResult(() => _fetchPage(0));
result.when(
success: (items) => emit(state.copyWith(pagination: ...)),
failure: (ex) {
emit(state.copyWith(pagination: ..., hasError: true, error: ex.message));
emitEffect(ex.toToastEffect()); // 弹出错误 Toast
},
// cancel 分支刻意不提供回调 —— 取消请求什么都不需要做
);
注意 when() 里 cancel 参数是可选的(R Function()?)。这不是偷懒——大量的业务场景里取消确实不需要任何处理。如果强制性要求,每个 BLoC 都得写 cancel: () {},反而增加无意义的噪音。但框架同时提供了 isCancel getter 和 isSuccess/isFailure,如果某个场景确实要处理取消(比如取消后需要恢复按钮状态),可以手动判断。
错误归一化链条是:Dart 原生异常 → DioException → AppException 类型树。这个转换全发生在 ErrorHandler 里:
// flutter_zero_app/lib/core/error/error_handler.dart
class ErrorHandler {
ServerMessageExtractor serverMessageExtractor;
ErrorHandler({ServerMessageExtractor? serverMessageExtractor})
: serverMessageExtractor = serverMessageExtractor ?? ServerMessageExtractor();
AppException? handle(Object error, [StackTrace? stackTrace]) {
if (error is DioException) return _handleDioException(error);
if (error is AppException) return error; // 已是 AppException,直接透传
// 未知异常:不写死文案,交给 UI 按 code(unknown) 兜底翻译
return UnknownException(null, code: AppErrorCodes.unknown, originalError: error);
}
bool isCancelled(Object error) =>
error is DioException && error.type == DioExceptionType.cancel;
}
// _handleDioException 内部按 DioExceptionType 分发:
// cancel → null (表示主动取消)
// connectionTimeout/sendTimeout/receiveTimeout → TimeoutException(code: 408)
// connectionError → NetworkException(code: -5)
// badResponse → AuthException(401/403) / ServerException(其他 4xx/5xx)
// default → UnknownException(code: -1)
AppException 也是 abstract class 而非 sealed,跟 UIEffect 的设计哲学一致——外部可以自由扩展:
// flutter_zero_app/lib/core/error/app_exception.dart
abstract class AppException implements Exception {
const AppException(this.message, {this.code, this.originalError});
final String? message; // 服务端返回的可读文案
final int? code; // HTTP 状态码 或 内部哨兵码
final Object? originalError;
ToastEffect toToastEffect() => ToastEffect(message: message, code: code);
}
// 7 个内建子类:
// NetworkException(code:-5), ServerException, AuthException(401/403),
// TimeoutException(408), ParseException, BusinessException, UnknownException(-1)
toToastEffect() 是一个便捷的桥接方法。它把 AppException 转成 ToastEffect,优先传服务端的 message,没有就用 code 让 UI 层本地化兜底。这意味着框架层没有一行硬编码的用户可见文案——所有文案要么来自服务端(服务端决定),要么按错误码在 .arb 文件里本地化(客户端决定)。
但不同后端的错误消息字段名不一样。有的叫 message,有的叫 error,有的叫 msg。怎么写死都不对。Flutter Zero 用了策略模式:
// flutter_zero_app/lib/core/error/server_message_extractor.dart
class ServerMessageExtractor {
const ServerMessageExtractor({
this.candidateKeys = const ['message', 'error', 'errorMsg', 'msg'],
});
final List<String> candidateKeys;
String? extract(Map<String, dynamic>? data) {
if (data == null) return null;
for (final key in candidateKeys) {
final value = data[key];
if (value is String && value.isNotEmpty) return value;
}
return null;
}
}
默认按四个字段依次尝试,不符合团队规范的话在 DI 层替换:
@override
ErrorHandler get errorHandler => ErrorHandler(
serverMessageExtractor: ServerMessageExtractor(['errorMsg', 'detail']),
);
替换策略不需要改任何框架代码,只改 DI 注册。这也是 ErrorHandler 作为 getter 暴露而非硬编码的目的——把"怎么提取错误消息"这件事从框架的实现细节中抽离出来,让它成为一个可注入的策略。
架构的其他组成部分
Mixin 是骨架,但一个完整的架构骨架还需要填上肌肉。下面这些模块和 Mixin 一起构成了项目的基础设施。
DI 的三文件模板方法
依赖注入用了 get_it,但直接散落一堆 getIt.registerSingleton(...) 没法维护。Flutter Zero 把 DI 拆成三层:
flutter_zero_app/lib/core/di/
get_it_instance.dart # 全局单例: final getIt = GetIt.instance
injection_base.dart # 模板方法基类:定义注册顺序
injection.dart # 子类实现:用户只改这个
injection_base.dart 用模板方法模式锁死注册顺序:
// flutter_zero_app/lib/core/di/injection_base.dart
abstract class InjectionBase {
Future<void> registerAll() async {
await registerBaseDependencies(); // 1. 基础设施(按层拆分)
await registerFeatureModules(); // 2. Feature 模块(CLI 自动维护)
await registerUserDependencies(); // 3. 用户自定义扩展
await getIt.allReady(); // 4. 等待所有异步单例就绪
}
@protected
Future<void> registerFeatureModules() async {
// fluzer new xxx 会自动在这里插入 XxxModule.register(getIt);
SharesRepositories.register(getIt); // 共享仓库注册
HomeModule.register(getIt); // Generated for home
CounterModule.register(getIt); // Generated for counter
// ...
}
}
关键在于 registerFeatureModules()。fluzer CLI 在执行 fluzer new user 时,不会让开发者手动到这里加一行——它会用 AST 操作自动在方法体末尾追加 UserModule.register(getIt)。使用 CodeMod 的 InsertAtMethodEndTransform 保证幂等:已经有这行就不重复插入。
injection.dart 里基础设施注册再按层拆分:
Future<void> registerBaseDependencies() async {
await _registerStorageLayer(); // SharedPreferences + SecureStorage
await _registerAuthLayer(); // TokenStorage(缓存优先读取)
_registerNotifiersLayer(); // ToastService + LoadingService
_registerNetworkLayer(); // DioClient + 拦截器(Auth + Locale)
await _registerLocalizationLayer(); // LocaleProvider(从存储恢复语言)
await _registerThemeLayer(); // ThemeProvider(从存储恢复主题)
}
每层一个方法,测试时只需 mock 某一层。比如测试网络层的时候,可以单独替换 _registerNetworkLayer 的实现注入一个 Mock DioClient。
Repository 的归属规则
项目里有两个放 Repository 的位置:core/data/repositories/ 和 features/<name>/data/repositories/。怎么决定放哪?
规则很简单:只被一个 feature 使用的 Repository 放在 feature 自己的 data 目录;一旦被两个或以上 feature 共用,上移到 core,通过 SharesRepositories 统一注册。
这条规则的核心目的是防止 feature 之间互相 import 对方的内部实现。假设 UserRepository 同时被 LoginFeature 和 SettingsFeature 使用,如果它放在 login/data/,那 settings 就需要 import login 的内部模块——模块边界就此打破。放到 core 后,两个 feature 都只依赖 core,互相不知道对方的存在。
BaseRepository 继承了一个抽象基类,提供了几个通用的响应解析方法:
// flutter_zero_app/lib/core/storage/base_repository.dart
abstract class BaseRepository {
const BaseRepository({required this.client});
final DioClient client;
// 解析数组响应: Response<List<dynamic>> → List<T>
List<T> parseList<T>(Response<dynamic>, T Function(Map<String, dynamic>) fromJson);
// 解析单对象响应: Response<Map<String, dynamic>> → T
T? parseSingle<T>(Response<dynamic>, T Function(Map<String, dynamic>) fromJson);
// 解析嵌套响应: Response<{data: ...}> → T
T parseResponse<T>(Response<dynamic>, T Function(Map<String, dynamic>) fromJson);
// 解析包装型响应: HTTP 200 但业务状态码失败 → BusinessException
T parseBusinessResponse<T, B>(..., {
required B Function(dynamic) parseBody,
required bool Function(B) isSuccess,
required T Function(B) extractData,
});
}
parseBusinessResponse 的设计用了闭包注入而非抽象方法。因为不同后端的包装响应格式千差万别({code, message, data} 还是 {status, msg, result}),与其在抽象类里定义死字段名,不如让调用方通过闭包传入自己的解析逻辑。
freezed 的 part 文件约定
每个 feature 的 BLoC 用几个文件组成一个编译单元,遵循固定的 pattern:
flutter_zero_app/lib/features/user_center/presentation/bloc/
user_center_bloc.dart # 主文件
├── part 'user_center_bloc.freezed.dart'; # 生成的代码
├── part 'user_center_event.dart'; # 事件定义
└── part 'user_center_state.dart'; # 状态定义
user_center_event.dart # part of 'user_center_bloc.dart';
user_center_state.dart # part of 'user_center_bloc.dart';
user_center_bloc.freezed.dart # build_runner 生成
event.dart 和 state.dart 内容极其干净——没有额外的 import,没有额外的 part 声明,只有 part of 'user_center_bloc.dart' 一行加上 freezed 类定义。所有外部依赖的 import 全部集中在主文件里。
这样做的好处不只是整洁。freezed 的代码生成依赖 import 的正确性——如果 state.dart 里自己 import 了什么东西,生成器可能因为依赖缺失而报错。所有 import 集中在一个入口,保证生成的 .freezed.dart 能正确解析所有类型引用。
State 和 Event 的定义也用 freezed:
// flutter_zero_app/lib/features/home/presentation/bloc/home_state.dart
part of 'home_bloc.dart';
@freezed
abstract class HomeState with _$HomeState {
const factory HomeState({
@Default(PaginationState<PostModel>()) PaginationState<PostModel> pagination,
@Default(false) bool simulateError,
}) = _HomeState;
const HomeState._();
}
// flutter_zero_app/lib/features/home/presentation/bloc/home_event.dart
part of 'home_bloc.dart';
@freezed
abstract class HomeEvent with _$HomeEvent {
const factory HomeEvent.fetch() = HomeFetch;
const factory HomeEvent.refresh() = HomeRefresh;
const factory HomeEvent.loadMore() = HomeLoadMore;
const factory HomeEvent.toggleError() = HomeToggleError;
}
Event 用 sealed union 的好处是每个 const factory 对应一个独立的子类,BLoC 里 on<HomeFetch>(...) 用 Dart 的 exhaustiveness check 确保所有事件都被处理。
HomeState 里的 PaginationState<T> 是一个泛型 Freezed 类:
@Freezed(genericArgumentFactories: true)
sealed class PaginationState<T> with _$PaginationState<T> {
const factory PaginationState({
@Default([]) List<T> items,
@Default(0) int currentPage,
@Default(20) int pageSize,
@Default(false) bool isLoading,
@Default(false) bool isLoadingMore,
@Default(false) bool isRefreshing,
@Default(false) bool hasReachedMax,
@Default(false) bool hasError,
String? error,
@Default(false) bool hasLoadMoreError,
String? loadMoreError,
}) = _PaginationState;
const PaginationState._();
bool get isEmpty => items.isEmpty;
bool get isNotEmpty => items.isNotEmpty;
bool get isAnyLoading => isLoading || isLoadingMore || isRefreshing;
}
注意 @Freezed(genericArgumentFactories: true) 注解,这是 freezed 3.x 处理泛型的方式。不写这个注解的话 freezed 生成的 copyWith 方法不知道如何处理泛型参数。这个细节在 freezed 的文档里不太显眼,但实际开发中只要用到泛型 State 就会碰到。
路由的轻量化设计
路由用 go_router,但配置很简单:
// flutter_zero_app/lib/router/app_router.dart
class AppRoutes {
AppRoutes._();
static const String home = '/';
static const String counter = '/counter';
static const String search = '/search';
static const String login = '/login';
static const String settings = '/settings';
}
class AppRouter {
static final GoRouter router = GoRouter(
initialLocation: AppRoutes.home,
debugLogDiagnostics: true,
routes: [
GoRoute(path: AppRoutes.home, builder: (_, __) => const HomePage()),
GoRoute(path: AppRoutes.counter, builder: (_, __) => const CounterPage()),
// ...
],
);
}
fluzer new xxx命令没有自动注册路由的功能,需要命令结束后手动添加路由。原因是:
- 业务模块获取需要携带路由参数。
- 嵌套路由的设计,无法做代码定位自动注入路由。
几个值得单独说的设计亮点
前面是各个模块的拆解,下面抽出几个贯穿整个项目的设计思路。
1. 泛型约束的精准粒度
看四个 Mixin 的泛型声明:
mixin BlocAwaitMixin<Event, State> on Bloc<Event, State> { ... }
mixin BlocEffectMixin<S> on BlocBase<S> { ... }
mixin BlocCancelTokenMixin<State> on BlocBase<State> { ... }
mixin BlocErrorHandlerMixin<S> on BlocBase<S> { ... }
注意 on 子句的宿主类型:BlocAwaitMixin 约束的是 Bloc<Event, State>(需要调用 add(event)),而其他三个约束的是更宽松的 BlocBase<S>。这个区分是刻意的——如果后三个也约束 Bloc<Event, State>,那纯 Cubit(只有 State 没有 Event)就不能用了。每个 Mixin 的泛型参数数量正好等于它实际使用的数量,多一个都不要。
2. gen-l10n 的类型安全生成
fluzer gen-l10n 这个命令是 CLI 里设计最复杂的一个,但业务层用它极其简单。输入是 Flutter 原生的 .arb 文件:
{
"counterMaxReached": "计数已达最大值",
"@counterMaxReached": { "description": "计数器达到上限时的提示" },
"hello": "你好 {name}",
"@hello": { "placeholders": { "name": { "type": "String" } } }
}
输出是三个自动生成的文件。l10n_code.dart 为每个 ARB key 生成类型安全的 L10nCode 常量或 factory 构造:
// 自动生成(无参 key)
const counterMaxReached = L10nCode(name: 'counterMaxReached');
// 自动生成(有参 key,factory 构造带类型检查)
factory L10nCode.hello(String name) =>
L10nCode(name: 'hello', parameters: {'name': name});
配合 l10n_code_ext.dart 的扩展方法,BLoC 里发 Toast 完全类型安全:
// Toast 类型有四种语义:S(success) / E(error) / I(info) / W(warning)
emitEffect(ToastEffect(
l10nCode: L10nCode.counterMaxReached.typeW().toString()
));
// 有参的也是类型安全
emitEffect(ToastEffect(
l10nCode: L10nCode.hello('Dboy').typeI().toString()
));
不用手写字符串 key,重构 .arb 文件后跑一遍 gen-l10n,所有拼写错误在编译期就能发现。背后是 CLI 用括号计数扫描 Dart AST、提取所有 abstract 成员、加上 L10nParamType 注册表处理参数序列化——所有这些复杂度对业务层完全透明。
L10nCode 的设计意图:把国际化标识从翻译逻辑中解耦出来
上面说了 gen-l10n 怎么生成 L10nCode,但还没说清楚为什么需要这个东西。
在一般的 Flutter 项目里,国际化文案的调用路径是这样的:UI 层通过 AppLocalizations.of(context)!.someKey 拿到翻译后的字符串,然后展示。这条路线上有两个隐性约束:第一,必须有 BuildContext,第二,翻译动作发生在调用点。对 UI 层来说这没问题——Widget 天然持有 context,拿到翻译直接用就行。
但 BLoC 层不行。BLoC 里没有 context,也不应该有 context。如果在 BLoC 里写 AppLocalizations.of(context)!,等于把业务逻辑和 Flutter 的 widget 树绑死了,单元测试根本没法写。
一个直觉的替代方案是:在 BLoC 里直接传翻译好的字符串。比如 Repository 抛出一个异常,BLoC catch 到之后,手动把异常信息映射成中文文案,然后 emitEffect(ToastEffect(message: '网络连接失败'))。这能跑,但引入了另一个问题——多语言怎么办?如果用户切换成英文,BLoC 里硬编码的中文文案就完全对不上了。
L10nCode 要解决的就是这个问题:在 BLoC 层只传递"我要展示哪条文案"的标识符,而不传递具体的翻译文本。翻译动作延迟到 UI 层、在有 context 的环境下执行。
// BLoC 层:只传递标识符,不涉及任何翻译逻辑
emitEffect(ToastEffect(l10nCode: L10nCode.counterMaxReached.typeW().toString()));
// 有参的也一样:只传标识符 + 参数,让 UI 层去拼
emitEffect(ToastEffect(l10nCode: L10nCode.hello('Dboy').typeI().toString()));
L10nCode 本身是一个值对象,它的 toString() 返回一个可序列化的字符串(类似 l10n:counterMaxReached?type=W),经过 Stream 传递到 UI 层的 EffectListener,再由 defaultToastHandle 反序列化后调用 L10nCode.parse() 还原,最后按当前 locale 取出对应语言的翻译文本。
这个设计带来了几个好处:
非 UI 层也能触发国际化提示。 BLoC 不需要知道当前语言是什么、不需要知道翻译文件在哪、甚至不需要知道 Flutter 的存在。它只需要知道"这个业务场景应该显示 counterMaxReached 这条消息",剩下的全交给 UI 层。后端推送的消息也可以映射成 L10nCode——WebSocket 收到一个 error_code: 1001,BLoC 把它转成 L10nCode.error1001,UI 层按 locale 翻译。
切换语言无需重启 BLoC。 因为翻译动作发生在 UI 层的 handle 里,每次 Effect 流过责任链时都是按当前 locale 重新翻译的。用户切语言后,下一条 Toast 自动用新语言,不需要重建 BLoC、不需要刷新状态。
可序列化,可跨进程传递。 L10nCode 的 toString()/parse() 机制让它可以在 Stream 中传递而不丢失类型信息。有参的 L10nCode(比如 L10nCode.hello('小杜'))的参数会被编码进 query string 里,parse 时完整还原。这意味着理论上 Notification、DeepLink 也可以携带 L10nCode,在 UI 层解析展示——不局限于 BLoC → EffectListener 这一条路径。
ToastEffect 里为什么用 l10nCode 而不是直接传翻译文本
顺着上面的思路,可能会有一个疑问:既然 ToastEffect 有三个字段(message、l10nCode、code),那为什么不统一用 message 传文案?在 BLoC 层拿到翻译字符串塞进去不就行了?
这个设计的核心原因是分层职责的边界。
翻译是 UI 层的事,不是业务逻辑层的事。BLoC 的职责是说清楚"发生了什么业务事件"——比如"计数器到达上限了"——而不应该关心"这句提示在中文里是 7 个字还是 10 个字"。如果把翻译放在 BLoC 层,那 BLoC 就需要依赖 AppLocalizations,需要持有 context,需要在单元测试里 mock 整个国际化模块。这一切都是因为它想做一件本不属于它的事。
用 l10nCode 代替翻译文本,本质上是在说:BLoC 只负责"语义",UI 层负责"表达"。
// 这是对的:BLoC 传递语义标识
emitEffect(const ToastEffect(l10nCode: 'counterMaxReached'));
// 这是错的:BLoC 在替 UI 层做翻译决定
emitEffect(const ToastEffect(message: '计数已达最大值'));
两种写法在运行结果上可能一模一样,但在架构层面完全不同。前者把"怎么表达"留给了 UI 层的 handler,后者替 UI 层做了决定。
那 message 字段是干什么用的?它有一个明确的、不可替代的场景:服务端返回的文案。比如后端的业务异常直接带了一句面向用户的消息——"库存不足,当前仅剩 3 件"——这种文案的翻译是服务端做的,不是客户端做的。BLoC 拿到之后直接透传 message,UI 层原样展示。message 字段就是为这种"服务端已翻译"的场景预留的。
所以 ToastEffect 三个字段的职责边界是这样的:
message:服务端已翻译的文本,直接展示。用于后端决定的文案。l10nCode:客户端本地化标识符,由 UI 层按 locale 翻译。用于前端决定的文案。code:错误码兜底。用于"有错误码但没有具体文案"的场景,UI 层按码映射通用提示。
这三层优先级保证了一个原则:在任何场景下,BLoC 都不需要猜测当前语言的翻译结果。
3. BLoC 不直接调用 ToastService 和 LoadingService 的原因 —— 依赖倒置
上面花了很大篇幅讲 Effect 系统的机制,但还有一个架构层面的"为什么"没展开——为什么 BLoC 要通过 Effect 来触发 Toast 和 Loading,而不是直接持有 ToastService 和 LoadingService 的引用?
最直观的写法可能是这样的:
// 如果这么写,会很"方便"
class HomeBloc extends Bloc<HomeEvent, HomeState> {
final ToastService toastService; // 直接注入
final LoadingService loadingService; // 直接注入
Future<void> _onFetch(event, emit) async {
loadingService.show(); // 直接调用
try {
final data = await repository.fetch();
emit(state.copyWith(items: data));
toastService.showSuccess('加载成功'); // 直接调用
} catch (e) {
toastService.showError('加载失败'); // 直接调用
} finally {
loadingService.hide(); // 直接调用
}
}
}
这种写法的问题不是"跑不起来"——它能跑,而且看起来更直接。问题出在几个层面上。
第一个问题:BLoC 的职责边界被打破了。
BLoC 的本职工作是管理业务状态——收到事件、调用仓库、产出新状态。Toast 提示是不是业务状态?不是。Loading 动画是不是业务状态?也不是。它们是 UI 表现,是"展示层"的事。当 BLoC 直接调用 toastService.showSuccess('加载成功'),它就不再是一个纯粹的状态管理器了——它开始告诉 UI 层"你应该怎么展示",这和直接在 BLoC 里操作 Widget 没有本质区别。
Effect 机制把这件事纠正了过来:BLoC 只负责发出 LoadingEffect(show: true) 和 ToastEffect(message: '加载成功') 这些声明式的意图,至于"展示 Loading 是用 EasyLoading 还是 Toastification"、"展示 Toast 是用 SnackBar 还是第三方组件"——BLoC 完全不知道,也不需要知道。
// BLoC 只表达意图,不指定实现
emitEffect(const LoadingEffect(show: true));
final result = await runToResult(() => repository.fetch());
result.when(
success: (data) {
emit(state.copyWith(items: data));
emitEffect(const ToastEffect(
l10nCode: 'homeRefreshSuccess', // 语义标识
));
},
failure: (ex) => emitEffect(ex.toToastEffect()),
);
emitEffect(const LoadingEffect(show: false));
第二个问题是可测试性。
直接注入 ToastService 的 BLoC 在单元测试里,你必须 mock 整个 Toast 服务的所有方法——showSuccess、showError、showInfo、dismissAll……而且你测试的是"BLoC 有没有在正确的时机调用正确的 Toast 方法",而不是"BLoC 有没有发出正确的状态和 Effect"。
用 Effect 机制之后,单元测试变成了这样:
blocTest<HomeBloc, HomeState>(
'should emit loading effect and success toast on fetch',
build: () => HomeBloc(repository: mockRepository),
act: (bloc) => bloc.add(const HomeEvent.fetch()),
expect: () => [
// State 断言:只关心业务状态
isA<HomeState>().having((s) => s.pagination.isLoading, 'isLoading', true),
isA<HomeState>().having((s) => s.items.length, 'items', greaterThan(0)),
],
verify: (_) {
// Effect 断言:只关心发出了什么 Effect,不关心谁来处理它
verify(() => mockRepository.fetchPosts(page: 0)).called(1);
},
);
你不需要 mock ToastService,不需要 mock LoadingService,甚至不需要 mock BuildContext。你只关心两件事:State 变没变对、Effect 发没发出。至于 Effect 最后怎么渲染——那是 UI 层集成测试的事。
第三个问题:可替换性。
如果项目一开始用的 Toast 库是 EasyLoading,后来想换成 Toastification 或者 Flutter 原生的 SnackBar,怎么办?
直接注入 ToastService 的方案:你需要找到每一个 BLoC,修改它的构造参数和所有调用点。如果项目有 30 个 feature、每个 feature 有一个 BLoC——30 个文件要改。
Effect 机制的方案:框架的 defaultToastHandle 写死了委托给 ToastService,但 ToastService 是一个抽象类:
// flutter_zero_app/lib/core/notifiers/toast_service.dart
abstract class ToastService {
void showSuccess(String msg);
void showError(String msg);
void showInfo(String msg);
void showWarning(String msg);
void dismissAll();
Widget build(BuildContext context, Widget child); // Host Widget
}
换实现只需要在 DI 层改一行注册代码:
// 改前:EasyLoading 实现
getIt.registerLazySingleton<ToastService>(
() => EasyLoadingToastService(),
);
// 改后:Toastification 实现
getIt.registerLazySingleton<ToastService>(
() => ToastificationToastService(),
);
BLoC 层 30 个文件,一行不用改。因为 BLoC 从来不知道底层用的是哪个 Toast 库——它只知道 emitEffect(ToastEffect(...))。
这是典型的依赖倒置原则:高层模块(BLoC)不依赖低层模块(具体的 Toast 实现),两者都依赖抽象(UIEffect / ToastService 抽象类)。
加一张图说明这个关系:
┌─────────────────────┐
│ BLoC Layer │
│ emitEffect(...) │ ← 只依赖 UIEffect 抽象
└─────────┬───────────┘
│ Stream<UIEffect>
▼
┌─────────────────────┐
│ EffectListener │ ← 责任链分发
│ (UI Layer) │
└─────────┬───────────┘
│
▼
┌─────────────────────┐ ┌──────────────────────────┐
│ defaultToastHandle│─────▶│ ToastService (抽象) │
│ defaultLoadingHandle│───▶│ LoadingService (抽象) │
└─────────────────────┘ └──────────┬───────────────┘
│ DI 注入
┌─────────▼───────────────┐
│ EasyLoadingToastService │
│ ToastificationToastSvc │ ← 具体实现
│ ... │
└─────────────────────────┘
BLoC 和具体的 UI 库之间隔了两层抽象(UIEffect + ToastService),任意替换底层实现都不影响业务逻辑。这也是为什么框架可以把 defaultToastHandle 和 defaultLoadingHandle 作为兜底处理器写死在责任链最末端——它们是"默认实现",不是"唯一实现"。业务层如果对默认的 Toast 样式不满意,完全可以在自己的 handle 里用 is ToastEffect 认领,替换成任何展示方式。
后续规划:从 go-zero 身上还能学到什么
fluzer 现在的功能集中在项目创建和模块生成,对标 go-zero 的 goctl api new。但 goctl 有 28 个命令,覆盖了 API 定义到容器部署的完整链条。映射到 Flutter 语境下,我最想做的有三件事:
API 驱动的代码生成。 这是 fluzer 目前最缺的一块。goctl 的核心能力是"写一个 .api 定义文件 → 一行命令生成完整服务代码"。对于 fluzer,这意味着从 OpenAPI/Swagger 文档自动生成 Freezed 数据模型 + Repository + Dio 调用代码。现在 fluzer new 给的是空骨架,加上这个之后,生成出来的模块直接就能发网络请求。
从 SQL 生成模型。 goctl 能从 MySQL DDL 生成数据层代码。fluzer 可以把这个能力映射到 Flutter 的本地数据库场景——项目用 drift 或 sqflite 时,从 SQL schema 直接生成 Dart 数据类和 DAO 操作代码。
项目校验与自动修复。 goctl 有 api validate 和 api format。fluzer 需要 check 命令(检查配置兼容性、DI 注册完整性、ARB 一致性等)和 fix 命令(自动修复可确定的问题),适合放进 CI 流程。
AI 在这整个过程中帮了什么忙
这个项目的代码、架构文档、以及你现在看到的这篇文章,都有 AI 参与。但参与的方式可能和你想象的不太一样。
架构设计阶段,我会把自己对 MVI 的理解和几个备选方案描述给 AI,让它分析每个方案的 trade-off,帮我发现盲区。Effect 系统的责任链 vs sealed class 的取舍、Result 的三态设计 vs 传统的 try/catch,这些决定都是我在理解了两边的利弊之后选的——AI 提供的是分析和对比,不是"直接告诉我答案"。
编码阶段,AI 帮我生成了 freezed 的模板代码、Dio 拦截器的样板逻辑、AST 解析的括号计数算法。这些东西写起来繁琐、容易出错,但又没有多少"创造性"可言。我把这些交给 AI,自己的精力花在理解设计模式、推敲架构边界、验证生成代码的正确性上。
写文档和这篇文章的时候,AI 先生成初稿,我再逐段修改,加入自己的踩坑经历、做决策时的上下文、和具体的代码注释。文章里的每一段代码都经过实际运行验证,每一个架构选择背后都有具体的理由——不是"这里用了责任链模式因为它是最佳实践",而是"这里用责任链模式是因为项目需要开放扩展某个类型,而 sealed class 限制了这种扩展"。
用 AI 不是为了偷懒。恰恰相反,它让我可以把认知资源集中在真正需要人类判断力的事情上——架构怎么分层、Mixin 的职责怎么切分、API 怎么设计才不容易被误用——而把机械性的代码生成、文档填充交给机器。最终产出的代码我还是会一行一行过、一个一个提交看 diff——但开发一个完整工具链的速度,确实比纯手写快了几个数量级。
Flutter Zero 还在持续迭代。CLI 的版本检查机制、模板注册表的兼容性策略(minCliVersion 门禁)、gen-l10n 的 AST patcher(幂等接线),这些都是在实际使用中逐步暴露需求后再加上的。如果这套架构思路对你有参考价值,或者你觉得某个设计选择做错了、有更好的方案,欢迎去 GitHub 一起讨论。