Flutter 中实现“乐高架构”完全指南
几乎所有人都拼过乐高积木,哪怕成年后一套都没买过。把一块积木按到另一块上,听到咔哒一声,就稳了。
你大概从没想过这块积木是怎么成型的、用了什么塑料、出自哪家工厂。那一刻你只关心一件事:凸点能不能对上?
这本指南的核心思想,就藏在这个简单而平凡的瞬间。现在暂时放下乐高,想象一个 Flutter 项目。项目里总有那么一个页面,团队里每个人都暗暗不敢打开。它负责取数据、格式化、校验、渲染,全塞进一个巨大的 build() 方法里。
奇怪的是:它确实能跑。没人想碰它。改一下结账流程,就得滚过三个无关紧要的模块,才能找到那一行需要编辑的代码。
这两种体验之间的差距(乐高积木令人满足的咔哒声,与打开某个文件时的焦虑感)源于同一个习惯:乐高积木的设计使得任何一块都不需要理解其他积木的内部结构,只需关注接口。但大多数代码默认并非如此。
所谓"乐高架构",就是把代码像乐高造积木那样去构建。这本指南会循序渐进地教你这个习惯,从几乎小到不能称为"架构"的起点出发,逐块搭建,直到它能撑起整个 App。
过程中我们还会涉及 Clean Architecture——一种将同样习惯落地为具体、成熟模式的知名方案,并看看两者在哪些地方交汇。
目录
前置知识
你需要熟悉 Flutter 基础 Widget 编写及应用运行,因为早期章节直接基于 StatelessWidget 和普通 Widget 组合展开。
你还应理解 Dart 类、构造函数和抽象类,因为本手册核心概念“契约”和“凸起”本质上就是抽象类和接口。了解依赖注入或服务定位器会很有帮助,但并非必需,相关概念会在文中适时从零介绍。
后面的章节会用到 flutter_bloc、get_it、dio 和 go_router 作为示例包。你不需要事先用过它们,因为每个 import 出现时都会顺带解释。
了解 Clean Architecture 想解决的问题(让业务逻辑独立于框架)会很有帮助,不过本手册也为初次接触的读者准备了快速入门。
monorepo 相关知识同样不作要求,因为讲如何把 LEGO Architecture 与模块化 monorepo 结合的那一章,会从一个空文件夹开始一步步搭建。但如果你想在此之前更深入地了解 monorepo 结构、Melos 和 Dart Workspaces,可以先读读 How to Use Monorepos in Flutter,它解释了团队为什么会选择 monorepo。
"LEGO Architecture" 到底是什么意思
再回头看那块乐高积木,它有两点很值得注意。一是积木本身:它的形状、颜色和用途。二是它的凸点:顶部的标准化连接点和底部的管状结构,让它能扣合到任何遵循同一标准的积木上。
没有人需要知道一块积木是怎么注塑成型的才能把它拼到另一块上,只要凸点匹配就行。
这就是全部理念,而软件几乎可以照搬。积木对应你应用中的一个单元,可以是一个 widget、一个类、一个服务,甚至一整个功能模块。凸点对应这个单元暴露给外界的契约,在代码中通常表现为抽象类、接口或定义清晰的函数签名。
在代码里把两块"积木"拼在一起,意味着应用的一部分只通过契约依赖另一部分,绝不会深入内部去依赖对方的具体实现。
注意,这两个积木之间唯一的交互点就是那个共享契约,它们与外部世界的所有接触都必须经过它。任何一块积木都不需要知道另一端插着的是哪个具体实现。坚持“面向契约而非具体实现”这一原则,正是本手册后续所有内容的核心驱动力。你将在单个 Widget、单个类、单个功能模块乃至整个包中反复遇到它。
在深入代码之前,还有一个更值得内化的观念:一个运转良好的积木,应当能独立自洽,不需要你先翻开其他文件来理解它。你替换它的内部实现时,周围的其他部件应当毫无感知。它只暴露“做什么”,绝不暴露“怎么做”。
牢记这三条原则。从下文开始,所有示例本质上都是这三条原则在 Dart 中的体现。
Widget 层的乐高思维
其实你早就在实践这一思路了,只是可能没给这个名字。看看这行你肯定写过的代码:
Padding(
padding: const EdgeInsets.all(8),
child: const Text('Hello'),
)
Padding 只负责一件事,且对作为 child 传入的具体内容完全无所谓。无论传入 Text、Image、Column 还是其他任何组件,它都能正常工作。这就是隐藏在表面的一个积木和一个连接点(stud):组件 Padding 是积木,它的 child 参数就是连接点,任何能通过这道“门”的组件都可以接入。你从未教过 Padding 如何渲染文本或图片,它也根本无需知道这些。
下面用一个小到能一眼看全的例子,来观察一旦丢掉这个习惯会发生什么。假设你需要一个带圆角和阴影的小盒子来显示价格。
class PriceTag extends StatelessWidget {
final double price;
const PriceTag({super.key, required this.price});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(6),
),
child: Text('\$${price.toStringAsFixed(2)}'),
);
}
}
这是一个完全合格且足够小的 Widget,本身没有任何问题。但仔细看它实际在做什么。它在同一个类里同时决定了两个不相关的东西:内容周围的框长什么样,以及内容本身是什么。
一旦你想把同样的圆角阴影框用在非价格内容上(比如一个写着“促销”的小标签),你就会被卡住。要么把 Container 及其装饰代码复制到新 Widget 里,要么使用 extends 构建一个小小的类层级结构,只为复用六行样式代码。
这两种做法都是本手册一直在劝你避免的强耦合。
解法和之前 Padding 展示过的一样:把框独立抽离出来,让它能接收任意子 Widget。
class SurfaceCard extends StatelessWidget {
final Widget child;
const SurfaceCard({super.key, required this.child});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(6),
),
child: child,
);
}
}
现在 SurfaceCard 只关心一件事:如何呈现为一个小的圆角阴影框。它只有一个“凸点”——即 child,形状与 Padding 的完全相同。PriceTag 因此变得极其简洁,因为它再也不需要了解如何绘制框体了。
class PriceTag extends StatelessWidget {
final double price;
const PriceTag({super.key, required this.price});
@override
Widget build(BuildContext context) {
return SurfaceCard(child: Text('\$${price.toStringAsFixed(2)}'));
}
}
这一处改动就是本节的全部要点。SurfaceCard 现在可以在底下垫一张"促销"标签、一个小头像、一个评分徽章,或者随便什么内容,而且以后再也不用改它了。因为它从来就不关心自己的子组件长什么样。
判断一块这样的"积木"是否真正构建得好的标准很简单:你能不能不复制其中一行代码,就在全新的地方复用它?如果能,那它的凸点就在发挥作用。
想通这一点之后,同样的习惯可以无损地扩展规模——变的只是体量,不是模式。购物应用里的商品卡片其实就是同一个思路,只不过子组件稍大一些。
class ProductThumbnail extends StatelessWidget {
final String imageUrl;
const ProductThumbnail({super.key, required this.imageUrl});
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.network(imageUrl, height: 120, fit: BoxFit.cover),
);
}
}
class ProductCard extends StatelessWidget {
final String name;
final double price;
final String imageUrl;
const ProductCard({
super.key,
required this.name,
required this.price,
required this.imageUrl,
});
@override
Widget build(BuildContext context) {
return SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ProductThumbnail(imageUrl: imageUrl),
Text(name, style: const TextStyle(fontWeight: FontWeight.bold)),
Text('\$${price.toStringAsFixed(2)}'),
],
),
);
}
}
概念上这里没有任何新东西。ProductThumbnail 是一块独立的小积木,只负责加载和裁剪图片。所以以后如果把 Image.network 换成某个缓存图片的包,只需要改一个文件,其他用到它的地方完全感知不到。
ProductCard 本身其实已经不构建任何东西了。它只是在拼装现成的积木(用 SurfaceCard 做盒子、用 ProductThumbnail 放图片),就像你把两个不同盒子里的零件拼成一个小模型那样。
贯穿这三个组件的所有 import 依然是纯粹的 package:flutter/material.dart。到达这里并不需要引入新的包,因为在这一层,LEGO 思维不是指某个库,而是一种关于“积木块”与“内部内容”之间界限的决策。
带凸点的积木:契约而非具体依赖
仅靠组合(Composition)你可以获得可复用的 UI,但还无法实现行为的灵活替换。要做到这一点,需要一个显式的契约——通常是抽象类或函数类型——将其置于积木块与其依赖项之间。
假设 ProductCard 需要在被点击时将商品加入购物车,但你不想让卡片组件自身去关心底层是调用 REST API、写入本地存储,还是在演示时仅仅打印到控制台。
abstract class CartWriter {
Future<void> add(String productId);
}
class ApiCartWriter implements CartWriter {
final Dio client;
ApiCartWriter(this.client);
@override
Future<void> add(String productId) async {
await client.post('/cart/items', data: {'productId': productId});
}
}
class InMemoryCartWriter implements CartWriter {
final List<String> items = [];
@override
Future<void> add(String productId) async {
items.add(productId);
}
}
组件只需与契约交互,无需关心具体实现。
class AddToCartButton extends StatelessWidget {
final String productId;
final CartWriter cartWriter;
const AddToCartButton({
super.key,
required this.productId,
required this.cartWriter,
});
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () => cartWriter.add(productId),
child: const Text('Add to cart'),
);
}
}
abstract class CartWriter 就是那个凸点。它仅声明一种能力——add(String productId),对实现细节一无所知。这正是上一节提到的“契约而非具体实现”原则在代码中的具象化。
ApiCartWriter 是一块积木,通过 Dio 满足契约。在真实项目中,会在该文件顶部导入 import 'package:dio/dio.dart'; 以引入这个常用的 HTTP 客户端库。它掌握所有网络细节,外部无需知晓端点 URL 或请求格式。InMemoryCartWriter 是第二块积木,同样满足该契约,适用于测试、预览或离线演示,且自身零依赖:不用 Dio,也不涉及网络。
AddToCartButton 通过构造函数接收 CartWriter,而不是自行实例化,这就是依赖注入。它让契约真正发挥作用——组件外部递给它一块积木,而不是由自己建造。
这是值得停下来细品的收获:现在你可以编写一个 Widget 测试,传入 InMemoryCartWriter 并断言 cartWriter.items 包含正确的商品,无需任何 mock 框架,也无需网络 stub。
LEGO at the Folder Level
一旦你接受单个类应通过契约拼合,同样的逻辑也适用于文件夹的目录组织。
初期常见的错误是按类型组织:一个 screens 文件夹、一个 widgets 文件夹、一个 services 文件夹并排放置。看起来整洁,却是反 LEGO 思维的做法:理解或改动购物车功能时,要跨三个不相关的文件夹,没有任何机制阻止一个购物车服务文件悄悄从某个商品屏幕文件中导入内容。实际上没有任何模块是自洽的。
LEGO 友好的方式按功能组织:每个功能是一块积木,包含它所需的一切,只暴露其他功能被允许接触的部分。
lib/
features/
product/
product.dart <- "barrel" 文件:对外公开的凸点
src/
widgets/
product_card.dart
product_thumbnail.dart
services/
cart_writer.dart
models/
product.dart
cart/
cart.dart
src/
widgets/
cart_item.dart
services/
cart_repository.dart
core/
theme/
routing/
network/
这里的关键文件是 product.dart,它是一个 barrel 文件,只导出其他 feature 应该使用的部分。
// lib/features/product/product.dart
library product;
export 'src/widgets/product_card.dart';
export 'src/models/product.dart';
// 注意:cart_writer.dart 被有意不导出。
// 它是这个 feature 的内部实现细节。
library product; 这行代码把这个文件声明为 app 中 product 包的入口,这只是约定,本身并不构成硬性边界。export 语句会重新导出选定的文件,凡是没有列在这里的(比如 cart_writer.dart),就只对这个 feature 内部可见。其他 feature 通过 import 'package:app/features/product/product.dart'; 引入时,根本看不到它。
这和真实的 LEGO 理念完全一致:src/ 是积木的内部、注塑成型的塑料,而 barrel 文件就是凸点——其他积木唯一被允许接触的表面。
你可以借助 Dart 的 analysis_options.yaml 配合 import 检查的 lint 包来真正强制这条边界,也可以单纯靠 code review 的纪律:features/cart/src/ 里的任何文件都不应该去 import features/product/ 的 src/ 文件。如果 cart 确实需要用到 product 的东西,就引入 barrel 文件 product.dart,绝不要直接碰内部实现。
模块间的契约:Repository 与 Service Locator
文件夹边界可以防止其他功能模块导入内部实现,但实际应用中,你还需要在这些边界之间注入具体的实现。例如,购物车功能需要一个能获取商品价格的接口,但不能直接依赖商品功能中具体的服务类。这时就需要引入 Repository 模式和 Service Locator(服务定位器)。
首先,接口契约(contract)应该放在共享的中立区域,而不是放在任何一个功能模块内部。
// lib/core/contracts/product_lookup.dart
abstract class ProductLookup {
Future<double> priceOf(String productId);
}
商品功能模块负责提供具体的实现。
// lib/features/product/src/services/product_repository.dart
import 'package:app/core/contracts/product_lookup.dart';
class ProductRepository implements ProductLookup {
final Map<String, double> _cachedPrices;
ProductRepository(this._cachedPrices);
@override
Future<double> priceOf(String productId) async {
return _cachedPrices[productId] ?? 0;
}
}
购物车功能只依赖 ProductLookup,真正的实现通过 Service Locator 进行连接。Service Locator 是一个注册表,可以根据接口类型分发已配置好的实例。get_it 是实现此目的的标准包。
// lib/core/di/service_locator.dart
import 'package:get_it/get_it.dart';
import 'package:app/core/contracts/product_lookup.dart';
import 'package:app/features/product/src/services/product_repository.dart';
final getIt = GetIt.instance;
void setupServiceLocator() {
getIt.registerLazySingleton<ProductLookup>(
() => ProductRepository({'p1': 19.99, 'p2': 4.50}),
);
}
// lib/features/cart/src/services/cart_calculator.dart
import 'package:app/core/contracts/product_lookup.dart';
import 'package:app/core/di/service_locator.dart';
class CartCalculator {
final ProductLookup _productLookup;
CartCalculator({ProductLookup? productLookup})
: _productLookup = productLookup ?? getIt<ProductLookup>();
Future<double> total(List<String> productIds) async {
double sum = 0;
for (final id in productIds) {
sum += await _productLookup.priceOf(id);
}
return sum;
}
}
import 'package:get_it/get_it.dart'; 引入了服务定位器包,而 GetIt.instance 提供了一个全局共享的注册表(单例),整个应用共用这一个实例。
调用 registerLazySingleton<ProductLookup>(...) 是在告诉定位器:当有人请求 ProductLookup 时,就交给它们这一个 ProductRepository 实例,且仅在首次请求时才构建它。
这里的关键在于泛型类型参数,因为注册表是以契约为键,而非 ProductRepository。这正是“依赖契约”这一机制的强制保障所在。
setupServiceLocator() 通常只在 main() 中 runApp() 之前调用一次,它成为应用唯一的组装点——这是唯一允许知晓所有具体实现模块的地方。
CartCalculator 的构造函数接受一个可选的 ProductLookup 参数,默认值由定位器提供。这种可选参数技巧使得该类极易测试:测试时传入一个 ProductLookup 的假实现,而生产环境则让其从 getIt 解析。
注意 cart_calculator.dart 从未导入 features/product/src/ 中的任何内容,它只导入共享契约和定位器。即便产品模块被完全重写(例如将内存映射换成真实的后端调用),cart_calculator.dart 也无需改动一行代码,只要 ProductRepository 仍实现 ProductLookup 契约即可。
这是 LEGO Architecture 在规模化扩展时最重要的技巧。契约位于中立的 core/contracts/ 区域,具体实现模块位于其所属的功能模块内,而唯一的连接点(服务定位器)是唯一同时导入双方的地方。
像乐高套装一样组合完整功能
在与 Clean Architecture 对比之前,最后一层是像乐高说明书指导子组件拼装到底板那样,将整个功能视为可插拔模块,由应用外壳在启动时进行组装。
// lib/core/feature_module.dart
import 'package:go_router/go_router.dart';
abstract class FeatureModule {
List<RouteBase> get routes;
void registerDependencies();
}
// lib/features/cart/cart_module.dart
import 'package:go_router/go_router.dart';
import 'package:app/core/feature_module.dart';
import 'package:app/core/di/service_locator.dart';
import 'src/screens/cart_screen.dart';
import 'src/services/cart_calculator.dart';
class CartModule implements FeatureModule {
@override
void registerDependencies() {
getIt.registerFactory<CartCalculator>(() => CartCalculator());
}
@override
List<RouteBase> get routes => [
GoRoute(path: '/cart', builder: (context, state) => const CartScreen()),
];
}
// lib/app.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'features/cart/cart_module.dart';
import 'features/product/product_module.dart';
import 'core/feature_module.dart';
final List<FeatureModule> modules = [
ProductModule(),
CartModule(),
];
GoRouter buildRouter() {
for (final module in modules) {
module.registerDependencies();
}
return GoRouter(
routes: modules.expand((m) => m.routes).toList(),
);
}
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(routerConfig: buildRouter());
}
}
FeatureModule 是应用中最顶层的"凸点"。任何想接入外壳的功能模块,都必须提供 routes(即它暴露的页面)和 registerDependencies()(即它需要注册到 service locator 里的依赖)。
CartModule 实现了这一契约,并在 registerDependencies() 中把 CartCalculator 注册为 factory——也就是每次请求都会创建一个新实例,这与上一节中的单例 ProductRepository 不同。采用哪种注册方式,由每个功能模块自行决定。
引入 package:go_router/go_router.dart 包后,RouteBase 对象会被转化为一个可运行的导航栈,而 GoRoute(path: ..., builder: ...) 则负责将类 URL 路径映射到具体页面。app.dart 是整个应用真正的组合根。modules 列表充当了“说明书”的角色,它是全应用唯一知晓所有特性存在的文件。该文件遍历每一个模块,允许它们各自注册依赖,并将所有路由扁平化为单一的 GoRouter。
若要为应用添加全新特性,只需编写一个新的 FeatureModule 实现,并在 modules 列表中加一行即可,完全无需触动现有特性文件。这正是乐高架构的完整体现:向套装中放入一块新积木,绝不需要重新改造盒子里原有的积木。
以上就是自底向上的乐高架构。Widget 负责组合,类依赖契约,文件夹强制执行边界,契约通过定位器跨越模块界限,而完整的特性则通过 FeatureModule 契约无缝嵌入应用外壳。接下来,我们简要学习 Clean Architecture,以便在同等条件下对两者进行对比。
Clean Architecture 速成课
Robert C. Martin 推广的 Clean Architecture 是一种特定的分层方案,它围绕一条规则——依赖规则展开:源代码依赖只能指向内部更高层次的策略。内层代码绝不能知晓任何外层细节。
让我们以“根据 ID 获取产品”这一单一特性为例,贯穿所有三层架构,从领域层及其实体开始——这是一个普通的、无框架依赖的对象。
// lib/features/product/domain/entities/product.dart
class Product {
final String id;
final String name;
final double price;
const Product({required this.id, required this.name, required this.price});
}
接下来是领域层的仓库端口,这是领域层定义但未实现的一个接口。
// lib/features/product/domain/repositories/product_repository.dart
import '../entities/product.dart';
abstract class ProductRepository {
Future<Product> getById(String id);
}
然后是领域层的用例,即一个单一的、命名明确的业务操作。
// lib/features/product/domain/usecases/get_product.dart
import '../entities/product.dart';
import '../repositories/product_repository.dart';
class GetProduct {
final ProductRepository repository;
GetProduct(this.repository);
Future<Product> call(String id) => repository.getById(id);
}
然后是数据层,从实现领域端口的仓库开始。
// lib/features/product/data/repositories/product_repository_impl.dart
import 'package:app/features/product/domain/entities/product.dart';
import 'package:app/features/product/domain/repositories/product_repository.dart';
import '../datasources/product_remote_data_source.dart';
class ProductRepositoryImpl implements ProductRepository {
final ProductRemoteDataSource remoteDataSource;
ProductRepositoryImpl(this.remoteDataSource);
@override
Future<Product> getById(String id) async {
final dto = await remoteDataSource.fetchProduct(id);
return Product(id: dto.id, name: dto.name, price: dto.price);
}
}
最后是远程数据源,它负责实际的 HTTP 调用和原始 JSON 结构。
// lib/features/product/data/datasources/product_remote_data_source.dart
import 'package:dio/dio.dart';
class ProductDto {
final String id;
final String name;
final double price;
ProductDto({required this.id, required this.name, required this.price});
factory ProductDto.fromJson(Map<String, dynamic> json) => ProductDto(
id: json['id'],
name: json['name'],
price: (json['price'] as num).toDouble(),
);
}
class ProductRemoteDataSource {
final Dio client;
ProductRemoteDataSource(this.client);
Future<ProductDto> fetchProduct(String id) async {
final response = await client.get('/products/$id');
return ProductDto.fromJson(response.data);
}
}
最后是表现层:一个调用用例的 Cubit。
// lib/features/product/presentation/cubit/product_cubit.dart
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:app/features/product/domain/entities/product.dart';
import 'package:app/features/product/domain/usecases/get_product.dart';
sealed class ProductState {}
class ProductLoading extends ProductState {}
class ProductLoaded extends ProductState {
final Product product;
ProductLoaded(this.product);
}
class ProductError extends ProductState {
final String message;
ProductError(this.message);
}
class ProductCubit extends Cubit<ProductState> {
final GetProduct getProduct;
ProductCubit(this.getProduct) : super(ProductLoading());
Future<void> load(String id) async {
emit(ProductLoading());
try {
final product = await getProduct(id);
emit(ProductLoaded(product));
} catch (e) {
emit(ProductError(e.toString()));
}
}
}
我们按顺序过一遍每一层。
首先,entities/product.dart 没有任何 import,这是有意为之的。这也是领域层最重要的一条规则:它不能 import Flutter、Dio 或任何框架。它是纯 Dart 代码,因此无需任何修改就能直接复用到一个命令行工具或后端服务中。
repositories/product_repository.dart 是一个抽象类,也就是端口。领域层在这里定义了它需要什么——getById——但从不关心数据是怎么取回来的。这与前文提到的 CartWriter 和 ProductLookup 在精神上完全一致。毕竟,依赖倒置并非 Clean Architecture 的发明,它只是把这一原则系统地应用到了每一处边界上。
usecases/get_product.dart 封装了一个具体的业务操作,其中 GetProduct 类实现了 call(String id) 方法,这允许你将其实例像函数一样调用,例如 getProduct('p1')。其构造函数接收 ProductRepository(再次强调,这是抽象端口),而绝不接收具体的 ProductRepositoryImpl。
data/datasources/product_remote_data_source.dart 独占了对 import 'package:dio/dio.dart'; 的引用,以及通过 ProductDto.fromJson 获取的所有关于网络传输格式的知识。在整个特性模块中,这是唯一允许了解服务器返回的原始 JSON 样式的文件。
data/repositories/product_repository_impl.dart 实现了领域层的端口,并负责在不同数据形态间进行转换:接收 ProductDto(数据层形态)并返回 Product(领域层形态)。正是这种转换机制,使得领域层完全无需感知 JSON 的存在。
presentation/cubit/product_cubit.dart 从 package:flutter_bloc/flutter_bloc.dart 引入 Cubit,并导入领域层的 GetProduct 和 Product,但绝不导入 data/ 目录下的任何内容。通过定义包含三个子类(ProductLoading、ProductLoaded 和 ProductError)的 sealed class ProductState,它显式地建模了所有可能的 UI 状态,从而让 Widget 层能够通过 switch 语句精确处理,无需猜测当前状态。
将这些组件组装起来,正是 Clean Architecture 中“组合根”概念的体现,这在前文已有介绍。
// lib/features/product/product_injection.dart
import 'package:dio/dio.dart';
import 'package:get_it/get_it.dart';
import 'domain/repositories/product_repository.dart';
import 'domain/usecases/get_product.dart';
import 'data/datasources/product_remote_data_source.dart';
import 'data/repositories/product_repository_impl.dart';
void registerProductFeature(GetIt getIt) {
getIt.registerLazySingleton(() => Dio());
getIt.registerLazySingleton(() => ProductRemoteDataSource(getIt<Dio>()));
getIt.registerLazySingleton<ProductRepository>(
() => ProductRepositoryImpl(getIt<ProductRemoteDataSource>()),
);
getIt.registerFactory(() => GetProduct(getIt<ProductRepository>()));
}
该文件是整个功能模块中唯一一处同时接触到所有层的地方:领域层、数据层以及具体的 Dio 客户端。这与早期乐高示例中 service_locator.dart 和 CartModule 承担的职责完全一致。
乐高架构与整洁架构的对比
此时,这两种架构的相似之处应该已经很清楚了:两者都建立在依赖倒置原则之上,依赖契约而非具体实现,并且都使用单个接线点来组装具体组件。
区别在于它们各自优化的目标不同。
乐高架构最好被理解为一种关于可组合性和边界的思想或哲学。你可以自由选择组合的基本单元,无论是 Widget、服务还是整个功能模块。
边界可以设在你决定的任何位置:文件夹、桶文件(barrel files)或模块契约。你也决定积木块应该多大或小。首要目标是可互换性,确保替换任何组件时不会破坏其邻居。
乐高架构的入门门槛较低,因为第一级除了 Flutter 本身外不需要任何新概念,并且随着你逐步采用更高级别的特性,复杂度逐渐提升。你需要编写多少样板代码,完全由你决定。
它最适合需要灵活功能边界、多人并行开发以及渐进式采纳的应用场景。其主要风险是“名不副实”的乐高架构:尽管文件夹结构暗示了隔离,但组件之间仍可能悄然侵入彼此内部。
相比之下,整洁架构是一种具体的、有命名的分层方案,具有固定形态:表现层、领域层和数据层,且依赖规则始终指向内侧。
它的组合单元明确为实体、用例和仓储。首要目标是可测试性以及独立于框架、UI 和数据库,默认粒度较细,并为每个功能指定重复使用的结构。
它的前置学习曲线较陡,因为每个功能从第一天起就需要多个文件,且每个功能的样板代码明显更多,包括实体、用例、两层仓储、DTO 以及 Cubit 或类似状态管理器。
它最适合那些业务规则复杂、且必须独立于 UI 和框架变化的应用。它最大的风险是为了模式而堆样板代码:明明一个功能没有任何需要保护的业务逻辑,却照样搭起三层结构。
理解两者关系最有用的角度是:Clean Architecture 是把单个功能"搭成乐高积木"的一种定义得很精细的方式。它的 entity、use case、repository 本身就是带凸点的积木——也就是接口,再通过依赖注入拼接起来。这正是乐高的思路,只不过套用了一个固定且有明确主张的形状。
所以你并不是在"选乐高还是 Clean Architecture",而是在决定在自己的乐高积木中,采用多少 Clean Architecture 的具体形状。
在模块化 Monorepo 中融合两者
到目前为止,所有内容都放在同一个 Flutter 项目的一个文件夹结构里。Barrel 文件防止了各功能模块互相窥探内部实现,但这种边界只是一种约定。实际上,没有什么能真正阻止 features/cart/ 里的文件去 import features/product/src/ 里的文件,靠的只是自律和代码评审。
在生产规模下,一些团队会彻底消除这个漏洞:把每个功能做成独立的 Dart 包,让包系统本身来强制执行边界,而不是靠自觉。
这种做法通常叫 monorepo,在 Flutter 里管理它最常用的工具是 Melos。本节接下来的内容会从零开始、一小步一小步地搭起这套结构,让最终的目录树看起来不是凭空变出来的。
从一个空文件夹开始
在执行任何 Flutter 命令之前,你电脑上只有一个空文件夹,里面没有任何 Flutter 相关的东西。
mkdir my_lego_project
cd my_lego_project
此时的 my_lego_project 还不是一个 Flutter 项目。它没有 pubspec.yaml,没有 lib 文件夹,也没有 android 文件夹。它只是一个普通目录,跟你平时用来放文档的文件夹没什么两样。接下来的一切都会在它里面搭建,而且是一块一块、有意识地搭起来。
给原生代码安个家
手机运行 APP 仍需要真实的 Android 和 iOS 项目。因此,在 `my_lego_project` 中创建的第一个组件是一个普通的 Flutter 应用,使用的指令与以往完全一致:mkdir apps
cd apps
flutter create app_main
`flutter create app_main` 的行为与往常一样。它会在 `apps/app_main/` 目录下生成 `android/`、`ios/`、`lib/main.dart` 和 `pubspec.yaml`。这一步骤本身还没有任何 LEGO 架构特有的内容。目前做出的唯一决定是这个普通应用在磁盘上的位置:它位于 `apps` 文件夹内,而不是项目根目录。
my_lego_project/
apps/
app_main/
android/
ios/
lib/
main.dart
pubspec.yaml
`app_main` 文件夹将是整个项目中唯一包含 `android/` 或 `ios/` 目录的位置。此后创建的所有其他包都故意不包含这些目录。
创建第一块积木
现在退回到项目根目录,创建一个名为 `packages` 的第二个文件夹,与 `apps` 并列。cd ../..
mkdir packages
cd packages
在 `packages` 内部创建你的第一个功能模块——但这次给同一个 `flutter create` 命令加上不同的参数。
flutter create --template=package feature_login
与之前唯一的区别在于 `--template=package` 参数。如果不加它,`flutter create` 会默认你需要一个可运行的应用,并生成原生目录。加上后,Flutter 会生成一个纯库(即只产生 `lib/` 目录、`test/` 目录和 `pubspec.yaml`),并故意省略 `android/`、`ios/` 和 `web/`。这是因为像这样的包永远不会独立启动,它只会作为依赖被引入到那些包含原生目录的应用中。
my_lego_project/
apps/
app_main/ (包含原生目录)
packages/
feature_login/
lib/
test/
pubspec.yaml
在此时此刻,`feature_login` 和 `app_main` 互不知情。它们只是恰好位于磁盘上相近位置的两个无关文件夹。
通过路径依赖将积木包接入主应用
为了让 app_main 使用 feature_login 中的代码,需将其添加为依赖项。这与从 pub.dev 添加普通包的方式类似,区别在于这里指向本地文件夹,而非包名和版本号。
# apps/app_main/pubspec.yaml
name: app_main
description: The actual iOS and Android wrapper application.
dependencies:
flutter:
sdk: flutter
feature_login:
path: ../../packages/feature_login
其中 path: ../../packages/feature_login 是相对于 app_main 自身 pubspec.yaml 位置的相对路径:向上两级,再进入 feature_login。这并非 Melos 的特性,也不是 LEGO Architecture 的创新,而是 Dart 包管理器的原生功能,与你用于指向任何本地包的 path: 依赖写法完全相同。
保存文件后,在 apps/app_main 目录下执行 flutter pub get,即可让 app_main 中的 lib/main.dart 通过 import 'package:feature_login/feature_login.dart'; 导入并使用该包暴露的内容。
值得留意的是,此时整个配置已能正常工作,仅涉及两个包,且尚未提及 Melos。之所以未引入 Melos,是因为包间的边界并非由它创建。边界在 pubspec.yaml 和 path: 依赖约束下已天然存在。Melos 的价值在于当此模式扩展至多个包时提供便利性,这也是后续需要解决的问题。
melos.yaml 从何而来
melos.yaml 不会由任何 Flutter 命令生成,也没有工具会自动创建它。你需要安装该包,并手动在项目根目录编写这个纯文本文件。
首先,在你的机器上全局安装 Melos(只需执行一次):
dart pub global activate melos
接着,在 my_lego_project 根目录下(与 apps 和 packages 文件夹同级),新建名为 melos.yaml 的文件,并填入以下内容:
name: my_lego_project
packages:
- apps/**
- packages/**
my_lego_project/
melos.yaml
apps/
app_main/
packages/
feature_login/
这里的 packages: 列表使用了 glob 模式,也就是说 apps/** 和 packages/** 会告诉 Melos 在这两个文件夹下查找,凡是包含 pubspec.yaml 的子文件夹都会被当作 monorepo 的一个成员。这里没有任何隐藏或自动的逻辑,你是在明确告诉 Melos 去哪里搜索。
melos bootstrap 到底做了什么
只有两个包时,分别在 app_main 和 feature_login 里各跑一次 flutter pub get 并不算负担。但一旦项目里有十个、二十个包,每个都需要解析依赖,还通过本地路径依赖其他几个包,Melos 的价值就体现出来了。不用手动挨个进文件夹,只需在项目根目录运行一条命令:
melos bootstrap
这条命令会读取 melos.yaml,找出 apps/** 和 packages/** 下的所有包,然后一次性对所有包执行相当于 flutter pub get 的操作,并解析其中所有的本地 path: 依赖。它本质上是一个编排工具,构建在已有机制之上(也就是前面提到的普通 pubspec.yaml 和 path: 依赖),本身并没有引入什么新机制。
模块化来自独立的 pubspec.yaml 文件和显式的路径依赖。Melos 的作用只是让你能快速、可复现地在多个包上批量执行命令,之后在 CI 流水线里还能只对真正改动的包跑测试。
app_main 的 lib 文件夹里该放什么
这里自然会冒出一个问题:是不是每个功能都要单独成一个包?毕竟在常规 Flutter 开发中,"包"通常指的是日期选择器这类可复用组件,而不是一整个登录页面。
在这种模式下,答案是肯定的:像登录这样的完整功能会成为独立的包,包括它的页面、状态管理和业务逻辑。原因正是贯穿本手册每一层的那个目标——隔离。
如果 feature_login 是一个独立包,那么在 feature_home 内开发的工程师就无法意外导入 feature_login 中的内容,因为在 feature_home 的 pubspec.yaml 中从未声明过该依赖。编译器会直接拒绝这种导入,无需代码评审靠人工眼力去捕捉。
这引出了第二个问题:如果界面、状态管理和逻辑都位于特征包(feature packages)内部,那么 app_main/lib 里还剩下什么?答案是,app_main/lib 缩小为恰好三项职责。
存放
main.dart,负责启动应用并调用runApp()。存放依赖注入配置。即前文提到的组合根(composition root),在此创建具体实现(如真实的网络客户端)并传递给需要的特征包。
存放主路由。像
feature_login这样的特征包故意不知道feature_home的存在。因此,只有同时依赖两者的app_main具备从一个页面导航到另一个页面的条件。
以下是具体的导航胶水代码(glue code),从特征包内部开始:
// packages/feature_login/lib/login_screen.dart
abstract class LoginNavigationContract {
void onLoginSuccess();
}
class LoginScreen extends StatelessWidget {
final LoginNavigationContract navigator;
const LoginScreen({super.key, required this.navigator});
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () => navigator.onLoginSuccess(),
child: const Text('Submit'),
);
}
}
feature_login 定义了 LoginNavigationContract,这是一个只有一个方法 onLoginSuccess() 的抽象类。LoginScreen 通过构造函数接收其实现,而不是直接导入其他特征包。
这与本手册中处处采用的契约模式相同,只是应用在了包边界而非类边界上。feature_login 声明了接下来需要发生什么,但从不声明“接下来”具体在哪里。
app_main 是唯一个知道 feature_login 和 feature_home 同时存在的包,因此它负责解答这个问题。
// apps/app_main/lib/app_navigator.dart
import 'package:feature_login/feature_login.dart';
import 'package:feature_home/feature_home.dart';
import 'package:flutter/material.dart';
class AppNavigator implements LoginNavigationContract {
final BuildContext context;
AppNavigator(this.context);
@override
void onLoginSuccess() {
Navigator.push(context, MaterialPageRoute(builder: (_) => const HomeScreen()));
}
}
AppNavigator 实现了 LoginNavigationContract,也是唯一同时导入 feature_login 和 feature_home 的地方。当 onLoginSuccess() 触发时,它推送 HomeScreen,这是位于 feature_home 内部的一个 Widget。在 main.dart 中完成对运行中应用的集成。
// apps/app_main/lib/main.dart
import 'package:flutter/material.dart';
import 'package:feature_login/feature_login.dart';
import 'app_navigator.dart';
void main() => runApp(const App());
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: LoginScreen(navigator: AppNavigator(context)),
);
}
}
这就是全貌。feature_login 拥有自己的界面、验证逻辑,以及登录成功后应执行的操作,后者仅以契约形式表达。
app_main,且仅有 app_main,拥有具体的实现方案,同时管理 android/、ios/、main.dart、依赖注入和路由。packages/ 中的其他所有包都遵循与 feature_login 相同的结构:包含 lib/ 文件夹、test/ 文件夹,以及带有显式 path: 依赖的 pubspec.yaml,并且完全没有 native 文件夹,因为这些内容在整个项目中只存在于一处。
my_lego_project/
melos.yaml
apps/
app_main/
android/ <- 仅此处
ios/ <- 仅此处
lib/
main.dart 负责:启动、依赖注入、路由
app_navigator.dart
pubspec.yaml 依赖所有功能包
packages/
feature_login/
lib/ 负责:登录页面和逻辑
pubspec.yaml 不依赖任何具体功能包
feature_home/
lib/ 负责:首页页面和逻辑
pubspec.yaml
所有包就位之后,最关键的一点仍是前面讲过的那条:每个功能包的 pubspec.yaml 只列出它真正被允许依赖的包。feature_home 绝不会出现在 feature_login 的 pubspec.yaml 里,所以 feature_login 就算想引用它也做不到。这条规则由 Dart 包系统本身强制执行,不靠评审人员去人工把关。
这就是 Flutter 中 LEGO Architecture 最彻底的实现方式:积木块就是一个个独立版本化的包,凸点就是 pubspec.yaml 里声明的包依赖,而帮你强制执行规则的,是编译器而不是代码评审。
可替换的状态管理积木
还有一个更进阶的 LEGO 技巧值得了解:把状态管理库本身也做成可替换的积木。这在团队要从 Bloc 迁移到 Riverpod,或者过渡期内想同时支持两者时非常有用。
技巧和本手册通篇使用的是同一个:定义一个 UI 所依赖的契约,然后让两种不同的状态管理实现都去满足它。
// lib/features/product/presentation/product_presenter.dart
abstract class ProductPresenter {
ProductUiState get state;
Stream<ProductUiState> get stateStream;
Future<void> load(String id);
}
class ProductUiState {
final bool isLoading;
final String? name;
final String? error;
const ProductUiState({this.isLoading = false, this.name, this.error});
}
基于 Bloc 的实现大概长这样:
```htmlclass BlocProductPresenter implements ProductPresenter {
final ProductCubit _cubit;
BlocProductPresenter(this._cubit);
@override
ProductUiState get state => _mapState(_cubit.state);
@override
Stream<ProductUiState> get stateStream => _cubit.stream.map(_mapState);
@override
Future<void> load(String id) => _cubit.load(id);
ProductUiState _mapState(ProductState s) => switch (s) {
ProductLoading() => const ProductUiState(isLoading: true),
ProductLoaded(product: final p) => ProductUiState(name: p.name),
ProductError(message: final m) => ProductUiState(error: m),
};
}
在此示例中,Widget 层仅导入 ProductPresenter 和 ProductUiState,绝不直接依赖 ProductCubit、Bloc 或 Riverpod。
BlocProductPresenter 充当适配器积木,将 Bloc 特有的 ProductState 结构转换为 UI 可理解的通用 ProductUiState。它利用 Dart 的 switch 模式匹配功能,针对前文定义的 sealed class 层级进行操作。日后若团队改用基于 Riverpod 的 Presenter,Widget 代码无需任何改动,因为仅组合根部的接线逻辑会决定向 Widget 树传递哪个 Presenter。
这正是乐高原则在其最易变依赖项上的体现:状态管理库本身也变成了可互换的积木。
完整实例:以乐高风格实现产品功能,内部层次清晰
让我们将所有内容整合到一个连贯的功能模块中,展示完整的文件树结构及各部分的连接方式。
lib/
core/
contracts/
product_lookup.dart <- 共享接口
di/
service_locator.dart
feature_module.dart <- 应用外壳契约
features/
product/
product.dart <- 桶文件 / 公共接口
product_module.dart <- 实现 FeatureModule
domain/
entities/product.dart
repositories/product_repository.dart
usecases/get_product.dart
data/
datasources/product_remote_data_source.dart
repositories/product_repository_impl.dart
presentation/
cubit/product_cubit.dart
widgets/product_card.dart <- 组合式 UI 积木
模块文件将各个层级集中绑定在一起。
```// lib/features/product/product_module.dart
import 'package:dio/dio.dart';
import 'package:go_router/go_router.dart';
import 'package:app/core/feature_module.dart';
import 'package:app/core/di/service_locator.dart';
import 'package:app/core/contracts/product_lookup.dart';
import 'domain/repositories/product_repository.dart';
import 'domain/usecases/get_product.dart';
import 'data/datasources/product_remote_data_source.dart';
import 'data/repositories/product_repository_impl.dart';
import 'presentation/screens/product_screen.dart';
class ProductModule implements FeatureModule {
@override
void registerDependencies() {
getIt.registerLazySingleton(() => Dio());
getIt.registerLazySingleton(
() => ProductRemoteDataSource(getIt<Dio>()),
);
getIt.registerLazySingleton<ProductRepository>(
() => ProductRepositoryImpl(getIt<ProductRemoteDataSource>()),
);
// this repository ALSO satisfies the cross-feature ProductLookup
// contract, so cart (or any other feature) can use it
// without ever importing anything from this feature's src/.
getIt.registerLazySingleton<ProductLookup>(
() => getIt<ProductRepository>() as ProductLookup,
);
getIt.registerFactory(() => GetProduct(getIt<ProductRepository>()));
}
@override
List<RouteBase> get routes => [
GoRoute(
path: '/product/:id',
builder: (context, state) =>
ProductScreen(productId: state.pathParameters['id']!),
),
];
}
这个文件只做一件事(组装)。它配置的每个依赖都遵循单一流向:从 data 层,经过 domain 层,最终到 presentation 层,这与之前展示的 Clean Architecture 结构图完全一致。
同时,它满足了组合功能部分定义的 FeatureModule 契约,这意味着 app.dart 将以与 CartModule 完全相同的方式处理 ProductModule。它只是 modules 列表中新增的一块砖。
这里有一个值得注意的设计决策:ProductRepositoryImpl 同时实现了两个接口——面向本模块内部使用的 ProductRepository,以及面向跨模块使用的 ProductLookup。其他功能(如购物车)仅需调用本模块功能的一个窄切片,即可通过 ProductLookup 实现解耦调用。
