← 返回蜂巢洞察

如何在Flutter中实现LEGO架构[完整手册]

几乎每个人在某个时候都试过把两块乐高积木拼在一起,即使成年后从未拥有过任何一套乐高玩具。你把一块积木压在另一块上面,听到“咔嗒”一声,它们就固定在一起了。 你很可能从未想过这些积木是如何被制造出来的,使用了什么样的塑料材料,又出自哪家工厂。在那一刻,你只关心一件事:那些凸出的连接部分是否对齐了? 这个看似平凡的小动作,其实就是编写这本手册的整个出发点。现在,请暂时把乐高放在一边,想象一下Flutter项目吧。在这个项目中,肯定存在某个文件,团队里的每个人都暗自害怕去打开它。这个文件负责获取数据、对其进行格式化处理、进行验证,最后将结果呈现出来——而所有这些操作都是在一个巨大的`build()`

几乎每个人在某个时候都试过把两块乐高积木拼在一起,即使成年后从未拥有过任何一套乐高玩具。你把一块积木压在另一块上面,听到“咔嗒”一声,它们就固定在一起了。

你很可能从未想过这些积木是如何被制造出来的,使用了什么样的塑料材料,又出自哪家工厂。在那一刻,你只关心一件事:那些凸出的连接部分是否对齐了?

这个看似平凡的小动作,其实就是编写这本手册的整个出发点。现在,请暂时把乐高放在一边,想象一下Flutter项目吧。在这个项目中,肯定存在某个文件,团队里的每个人都暗自害怕去打开它。这个文件负责获取数据、对其进行格式化处理、进行验证,最后将结果呈现出来——而所有这些操作都是在一个巨大的`build()`方法中完成的。

问题在于:这种方法确实有效。但没人愿意去修改它。因为修改代码的流程往往意味着要浏览大量无关的内容,才能找到那行需要修改的代码。

这两种体验之间的区别,其实就在于一个习惯。乐高积木的设计使得人们只需要了解它们之间的连接方式,而无需理解其他部分的内部结构;但大多数代码默认并不是这样设计的。

“乐高架构”其实就是决定用类似乐高积木组装的方式来编写代码。而这本手册会慢慢地教你这种习惯,从一些看起来甚至不足以被称为“架构”的小细节开始,逐步建立起一套能够支撑整个应用程序的编码体系。

在这个过程中,我们还会探讨“清晰架构”这一具体的、广为人知的实践方法,并了解这两种方法在哪些地方是相通的。

目录

先决条件

您应该已经能够熟练地编写基本的Flutter组件并运行Flutter应用程序,因为前面的内容都是基于StatelessWidget以及普通的组件组合方式来讲解的。

同时,您也需要了解Dart中的类、构造函数以及抽象类,因为本手册所讨论的各种设计原则本质上就是抽象类和接口的具体应用。虽然熟悉依赖注入或服务定位机制会有一定的帮助,但并不是必须的——因为在后续的内容中会从零开始详细讲解这些概念。

后面的章节会以flutter_blocget_itdiogo_router为例来说明相关用法。您之前不需要使用过这些库,因为每当遇到新的导入语句时,都会对其进行详细的解释。

了解“清晰架构”试图实现的目标(即让业务逻辑与开发框架分离)也会对学习有所帮助。不过,对于初次接触这一概念的读者,手册中也提供了简要的介绍。

此外,您也不需要事先了解单仓库架构的相关知识。因为在讲解如何将“LEGO架构”与模块化的单仓库系统结合时,会从最基础的角度开始介绍这个概念。但如果您希望在深入学习单仓库结构、Melos框架以及Dart工作区之前先掌握相关基础知识,那么可以先阅读《如何在Flutter中使用单仓库架构》这篇文章,它会帮助您了解团队为何会选择采用单仓库架构。

“LEGO架构”究竟意味着什么

让我们再回到那个乐高积木上来。实际上,这个积木有两个值得注意的地方:首先,它本身具有特定的形状、颜色和用途;其次,它的顶部有标准化的连接点,底部则有相应的插槽,这样就可以让其他符合相同标准的积木与之拼接在一起。

人们并不需要了解某个积木是如何被制造出来的,他们只需要确保这些连接点能够匹配即可。

软件设计也可以完全借鉴这一原理。在应用程序中,一个积木可以代表一个组件、一个类、一个服务,甚至是一个完整的功能模块;而那些连接点则相当于这个组件向外部暴露出的“接口”——在代码中,这通常表现为抽象类、接口或定义明确的函数签名。

在代码中,“将两个组件拼接在一起”意味着应用程序的某一部分仅通过这些接口与另一部分进行交互,而完全不需要了解另一部分的内部实现细节。

示意图显示了两个具体的实现模块A和模块B,它们通过虚线箭头连接到一个共同的抽象类或接口上。

请注意,这两块“砖”之所以能够与外界进行交互,完全是因为它们之间存在着某种“契约”。其中任何一块都无需知道另一块的具体构造。这种总是去获取“契约”而不是直接操作具体组件的习惯,正是本手册中后续所有内容得以实现的根本原理。你会一次又一次地遇到这一原则:最初是在单个组件中,然后是类中,接着是整个功能模块,最终甚至是整个软件包中。

在开始编写任何代码之前,还有一件事非常重要需要牢记:一个能够正常发挥作用的组件,应该本身就具有清晰的意义,而不需要你先去打开其他多个文件才能理解它的功能。你应该能够随意更换与它配合使用的组件,而它的“邻居”组件却丝毫不会察觉到这种变化;同时,这个组件也不应该向它的“邻居”展示它是如何实现具体功能的,而只需要让它们知道最终的结果即可。

请始终牢记这三点原则。从现在开始,每一个示例实际上都是在用Dart语言来体现这些原则而已。

在组件层面运用“乐高式思维”

这里有个秘密:你其实已经在不知不觉中运用了这种思维方式,只不过可能没有给它起专门的名称罢了。看看这段代码,你几乎肯定曾经写过类似的内容:

Padding(
  padding: const EdgeInsets.all(8),
  child: const Text('Hello'),
)

Padding这个组件只负责做一件事情,而且它完全不在乎你传递给它的child参数是什么——无论是TextImage还是其他任何类型的组件,它都完全可以接受。这就是所谓的“砖”和“连接件”的关系: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)}'),
    );
  }
}

这是一个完全正常、结构也非常简洁的组件,但仔细观察它的实现方式,你会发现它在一个类中同时处理了两个毫不相关的事情:一是决定盒子的外观样式,二是确定盒子内部要显示的内容。

然而,一旦你需要为那些并非价格的信息也创建这样的圆角阴影盒子——比如一个写着“促销”的小标签——你就会遇到麻烦。要么你得把整个Container组件及其装饰样式复制到另一个新的组件中;要么你就得使用extends关键字来构建一个新的类层次结构,仅仅只是为了重复使用那六行代码而已。

这两者都属于那种紧密耦合的情况,而本手册正是试图劝你避免使用这种设计方式。

解决这个问题的方法,其实就是Padding已经向你展示过的那个方法:将这个组件单独提取出来,让它能够接受任何类型的子组件。

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 Product Thumbnail({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)}'),
        ],
      ),
    );
  }
}

从概念上来说,这里并没有什么新的东西。Product Thumbnail本身就是一个独立的组件,它的唯一职责就是加载并显示图片。因此,如果你后来将Image.network替换为缓存图像相关的库,那么只需要修改一个文件而已,而使用这个组件的其他部分根本不会受到影响。

ProductCard本身已不再创建任何新的组件。它只是将现有的组件(用于显示盒子的SurfaceCard和用于显示图片的ProductThumbnail)按照某种方式组合起来,就如同将两个来自不同“仓库”的部件拼接成一个完整的模型一样。

在这三个组件中,所使用的导入语句依然是普通的package:flutter/material.dart。实现这一功能并不需要引入任何新的包,因为从逻辑上讲,LEGO框架并不是一个传统的库,而更像是一种规定——它明确了“盒子”与“盒子里装的内容”之间的界限。

带连接装置的组件:契约而非具体的依赖关系

仅依靠组合机制,我们就能获得可复用的用户界面,但这样的界面还无法实现组件的可互换性。为此,我们需要一个明确的“契约”——通常表现为一个抽象类或函数类型——来规定某个组件与其所依赖的其他组件之间的关系。

假设ProductCard需要在用户点击按钮时将相应产品添加到购物车中,但我们不希望这个组件自己知道具体应该通过调用REST API、写入本地存储,还是仅仅在演示过程中在控制台输出信息来完成任务。

abstract class CartWriter {
  Future add(String productId);
}

class ApiCartWriter implements CartWriter {
  final Dio client;
  ApiCartWriter(this.client);

  @override
  Future add(String productId) async {
    await client.post('/cart/items', data: {'productId': productId});
  }
}

class InMemoryCartWriter implements CartWriter {
  final List items = [];

  @override
  Future add(String productId) async {
    items.add(productId);
  }
}

实际上,这个组件只与上述“契约”进行交互。

class AddToCartButton extends StatelessWidget {
  final StringproductId;
  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('加入购物车'),
    );
  }
}

abstract class CartWriter就是这种“连接装置”。它明确声明了自己只具备一种功能,即add(String productId),但并未说明这一功能的具体实现方式。这就是上一节中提到的“契约优先于具体实现”的原则在代码中的体现。

ApiCartWriter就是满足这一契约的其中一个组件。它使用了流行的HTTP客户端库Dio来实现这一功能;在实际项目中,我们可以通过在文件开头添加import 'package:dio/dio.dart';这句话来引入这个库。ApiCartWriter负责处理所有的网络请求细节,因此外部代码完全不需要了解端点地址或请求的具体格式。InMemoryCartWriter则是另一个满足相同契约的组件。它适用于测试、预览或离线演示场景,而且本身没有任何依赖关系——既不依赖于Dio,也不涉及任何网络操作。

AddToCartButton通过其构造函数接收一个CartWriter对象,而不是自行创建这个对象。这种设计被称为依赖注入,正是这种机制使得契约真正发挥了作用——因为该组件是从外部获取所需的资源,而非自己构建这些资源。

这一点值得我们仔细思考:现在你可以编写测试用例来验证使用InMemoryCartWriter时,cartWriter.items中确实包含了正确的商品信息,而且完全不需要借助任何模拟框架或网络接口模拟工具。

架构图显示了AddToCartButton如何依赖于CartWriter抽象类。CartWriter作为共享契约,负责处理网络操作时与ApiCartWriter的交互,而在内存测试时则使用InMemoryCartWriter。

按功能分类的组织结构

一旦你接受了“各个类应该通过契约相互连接”这一理念,那么这种逻辑同样适用于文件夹的组织方式。

一个常见的初期错误是按照类型来组织文件,比如创建一个screens文件夹、一个widgets文件夹以及一个services文件夹。虽然这样看起来很整齐,但实际上这与LEGO的设计理念背道而驰。要想理解或修改某个功能,你必须在这三个毫无关联的文件夹之间来回切换;而且没有任何机制能阻止某个服务文件从产品相关的文件中导入所需资源。实际上,没有任何组件是真正独立存在的。

而符合LEGO设计原则的组织方式应该是按照功能来分类的。每个功能都应该像一块独立的积木一样,包含它自身所需的一切,并且只暴露出其他功能可以访问的部分。

lib/
  features/
    product/
      product.dart          <- “公共接口文件”
      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——这个文件只暴露出其他功能所需使用的接口。

// lib/features/product/product.dart
library product;

export 'src/widgets/product_card.dart';
export 'src/models/product.dart';
// 注意:cart_writer.dart故意没有被导出。
// 它只是这个功能内部的实现细节而已。

library product;表明该文件是你的应用程序中“product”包的入口点,这是一种约定俗成的做法,并不代表存在严格的界限。后面的export语句用于重新导出指定的文件,因此那些没有在这里列出的文件(比如cart_writer.dart)就只会被该功能内部使用。

这完全符合乐高设计的初衷,因为src/文件夹代表积木的内部结构,也就是那些成型的塑料部分,而product.dart文件则相当于那些可以让其他积木接触到的“凸起部分”——即其他积木能够与之连接的部位。

你可以利用Dart的analysis_options.yaml文件以及代码审查机制来严格执行这一规则:位于features/cart/src/文件夹内的任何文件都不应该从features/product/文件夹导入src/文件夹中的文件。如果cart功能确实需要使用product功能中的某些资源,它应该直接导入product.dart文件,而绝不应该直接访问其内部实现代码。

架构图展示了如何通过<code>product.dart</code>文件来访问私有的内部实现细节,同时暴露该功能的公共API接口。

模块之间的契约:仓库与服务定位器

文件夹边界可以防止其他功能导入你的内部实现代码,但实际应用中仍然需要在这些边界之间传递实现逻辑。例如,cart功能需要某种能够获取产品价格的功能,但它不应该依赖于product功能所使用的具体服务类。这时,仓库模式和服务定位器就派上了用场。

首先,这些契约会被保存在一个共享的、中立的位置,而不是位于任何某个具体的功能模块中。

// lib/core/contracts/product_lookup.dart
abstract class ProductLookup {
  Future 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 _cachedPrices;
  ProductRepository(this._cachedPrices);

  @override
  Future priceOf(String productId) async {
    return _cachedPrices[productId] ?? 0;
  }
}

cart功能仅依赖于ProductLookup这个契约,而具体的实现代码则是通过服务定位器来获取的。服务定位器实际上是一个注册机制,它可以根据契约类型来提供相应的实例。get_it就是用于实现这一功能的标准包。

// lib/core/di/servicelocator.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(
    () => ProductRepository({'p1': 19.99, 'p2': 4.50}),
  );
}

最后,具体的功能模块会使用这个服务定位器来获取ProductLookup实例。

// lib/features/cart/src/services/cart_calculator.dart
import 'package:app/core/contracts/product_lookup.dart';
import 'package:app/core/di/servicelocator.dart';

class CartCalculator {
  final ProductLookup _product Lookup;

  CartCalculator({ProductLookup? productLookup})
      : _productLookup = productLookup ?? getIt();

  Future total(List 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(...)这条指令告诉服务定位器:当有人请求获取Product Lookup时,应该返回一个ProductRepository实例。实际上,这个实例只会在第一次被请求时才会被创建。

在这里,泛型类型参数起着关键作用,因为注册机制是根据契约来区分不同的对象的,而不是根据ProductRepository》的具体实现。这种基于契约的机制正是实现解耦和可扩展性的关键所在。

setupServiceLocator()这个方法通常会在main()函数中被调用,在runApp()之前执行。它成为了整个应用程序中唯一一个能够了解所有具体组件的地方。

CartCalculator类的构造函数接受一个可选的ProductLookup参数,如果没有提供这个参数,系统会使用服务定位器提供的默认值。这种设计使得该类非常容易进行测试:在测试环境中可以使用假的Product Lookup》对象,而在生产环境中则会让系统从getIt中获取实际的数据。

需要注意的是,cart_calculator.dart文件从未导入过features/product/src/目录下的任何文件。它只导入了共享的契约和服务定位器模块。即使产品功能的实现方式被完全改写,将内存中的数据结构替换为真实的后端接口调用,cart_calculator.dart文件也不需要做任何修改,只要ProductRepository仍然遵守原有的契约规范即可。

这就是LEGO架构在大规模应用中最为重要的设计原则:契约被保存在core/contracts/目录中,具体的实现代码则位于使用该契约的功能模块中,而服务定位器这个唯一的连接点,则负责将这两者联系起来。

像组装LEGO积木一样构建整个功能模块

在与“清洁架构”进行比较之前,最后一步就是将整个功能模块视为可插拔的组件,让应用程序在启动时自动将这些组件组合起来。这种设计方式与LEGO说明书中指导人们如何将各个子部件组装到主板上的过程是完全相同的。

// lib/core/feature_module.dart
import 'package:go_router/go-router.dart';

abstract class FeatureModule {
  List 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/servicelocator.dart';
import 'src/screens/cart_screen.dart';
import 'src/services/cart_calculator.dart';

class CartModule implements FeatureModule {
  @override
  void registerDependencies() {
    getIt.registerFactory() => CartCalculator();
  }

  @override
  List 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 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()方法,用来说明该模块需要哪些其他组件才能正常运行。

CartModule遵循这一规范:在registerDependencies()方法中,它会将CartCalculator注册为一种工厂模式。这意味着每次请求时都会创建CartCalculator》的新实例,这与前文提到的单例模式ProductRepository不同。每个功能模块都可以自行决定如何进行这样的注册操作。

导入package:go-router/go-router.dart这一行代码会引入go_router包。这个包能够将RouteBase对象转换成可使用的导航结构,而GoRoute(path: ..., builder: ...)方法则可以将类似URL的路径映射到具体的界面页面。app.dart才是整个应用程序的核心组件;modules列表则起到了指令手册的作用——它是整个项目中唯一一个知晓所有功能模块存在的文件。程序会遍历这些模块,让它们各自注册所需的依赖关系,然后将所有的路由信息整合到一个GoRouter对象中。

如果想要为应用程序添加新的功能,只需编写一个新的FeatureModule实现类,并将其添加到modules列表中即可,而现有的任何功能模块文件都不会因此受到修改。这正是LEGO积木的设计理念:添加新积木时完全不需要重新整理盒子里已有的积木。

架构图显示:作为应用程序的入口点,该组件负责注册依赖关系、构建导航结构,并从各个独立的功能模块中收集所需的路由信息。

这就是从基础层面来看的LEGO架构。组件通过组合来构建功能,类之间通过契约关系进行交互,文件夹用于划分不同的功能区域,契约机制能够跨越不同模块的边界来实现组件之间的协作,而整个功能模块则是通过FeatureModule契约被集成到应用程序框架中的。现在让我们来看看“清晰架构”理念,这样我们就可以将这两种架构方式进行对比了。

简洁架构速成课程

由罗伯特·C·马丁推广的简洁架构,是一种基于一条特定规则构建的层次结构方案。这条规则被称为“依赖规则”:源代码中的依赖关系只能指向内部,即更高层次的组件或逻辑。处于内部层的任何部分都不应该了解外部层的情况。

架构图显示了表示层和数据层如何指向内部的领域层,以此来依赖并实现该领域层,从而体现了这一核心依赖规则。

让我们从领域层及其对应的实体开始,通过这三个层次来构建一个简单的功能——根据产品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 {
  FutureById(String id);
}

然后是领域层的用例类,它代表了一个具体的业务操作。

// libfeatures/product/domain/usecases/get_product.dart
import '../entities/product.dart';
import '../repositories/product_repository.dart';

class GetProduct {
  final ProductRepository repository;
  GetProduct(this.repository);

  Future call(String id) => repositoryById(id);
}

现在来看数据层,首先是从实现仓库接口的角度来开始构建这一层的代码。

// lib/features/product/data/repositories/product_repository_impl.dart
import 'package:app/features/product/domainentities/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
  FutureById(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 json) => ProductDto(
        id: json['id'],
        name: json['name'],
        price: (json['price'] as num).toDouble(),
      );
}

class ProductRemoteDataSource {
  final Dio client;
  ProductRemoteDataSource(this.client);

  Future 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 'packageflutter_bloc/flutter_bloc.dart';
import 'package:app_features/product/domainentities/product.dart';
import 'package:appfeatures/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 {
  final GetProduct getProduct;
  ProductCubit(this_product) : super(ProductLoading());

  Future load(String id) async {
    emit(ProductLoading());
    try {
      final product = await getProduct(id);
      emit(ProductLoaded(product));
    } catch (e) {
      emit(ProductError(e.toString()));
    }
  }
}

让我们按顺序来分析每一层的作用。

首先,entities/product.dart这个文件没有导入任何外部库。这是有意为之的,因为领域层有一条非常重要的规则:它不能导入Flutter、Dio或任何其他框架。这个文件完全是用纯Dart语言编写的,因此它可以被直接用于命令行工具或后端服务中,而无需进行任何修改。

repositories/product_repository.dart是一个抽象类,它定义了领域层所需的功能——比如getById方法——但并没有说明这些功能是如何实现的。这种设计方式与前面提到的CartWriterProductLookup类似。毕竟,“清晰架构”模式本身并不是发明了依赖倒置这一概念,而是系统地在各个层面应用这一原则。

usecases/get_product.dart文件实现了一个具体的业务操作,而GetProduct类则实现了call(String id)方法,这使得我们可以像调用函数一样使用这个类,例如getProduct('p1')。它的构造函数接收的是一个ProductRepository对象(也就是抽象层定义的接口),而不是具体的ProductRepositoryImpl实现。

data/datasources/product_remote_data_source.dart文件导入了package:dio/dio.dart库,并通过ProductDto.fromJson方法掌握了服务器返回的原始JSON数据的结构。在整个项目中,只有这个文件被允许了解服务器发送的JSON数据的具体格式。

data/repositories/product_repository_impl.dart实现了领域层定义的接口,它将数据层的ProductDto对象转换成领域层所需的Product对象。正是这种转换机制使得领域层完全不需要了解JSON数据的具体结构。

presentation/cubit/product_cubit.dart文件导入了packageflutter_bloc/flutter_bloc.dart中的Cubit库,以及领域层相关的GetProductProduct类,但并没有导入data/目录下的任何文件。其中定义的sealed class ProductState及其三个子类ProductLoadingProductLoadedProductError明确地模拟了所有可能的用户界面状态,这样 widget层就可以直接根据这些状态进行切换,而无需进行任何猜测或额外的处理。

将这些组件连接在一起,其实就是实现了“清洁架构”中早先提到的那种组合结构。

// 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( 
    () =&> ProductRepositoryImpl(getIt GetProduct(getIt). 
}

在整个功能实现过程中,只有这个文件会同时涉及到领域层、数据层以及具体的`Dio`客户端。这种职责划分,与之前在LEGO示例中`servicelocator.dart`和`CartModule`所承担的角色完全相同。

LEGO架构与清洁架构的对比

到目前为止,这两种架构之间的相似之处应该已经非常明显了:它们都基于依赖倒置原则进行设计,都依赖于契约而非具体的实现细节,并且都会通过一个统一的连接点来组合各个组件。

不同之处在于,这两种架构各自优化的是不同的目标。

LEGO架构更像是一种关于组合性与边界划分的思想或哲学。你可以自行选择组合的基本单位,这些单位可以是小部件、服务,也可以是整个功能模块。

边界的设定完全取决于你的决定——可以将其放在文件夹中、使用 barrel 文件来管理,或者通过模块契约来明确界定。你也可以自由控制每个组件的规模大小。其主要目标就是实现互操作性,这样任何组件都可以被替换掉,而不会影响到其他组件的正常运行。

刚开始学习LEGO架构时,其上手难度相对较低,因为第一层架构只需要Flutter本身即可构建;随着你逐渐采用更高层的架构设计,它的可扩展性也会逐步增强。你可以根据需要自行决定添加多少样板代码。

这种架构特别适合那些需要灵活定义功能边界、团队需要并行协作以及采用渐进式开发方式的应用程序。但它的主要风险在于:有时这些组件会悄悄地相互干扰,尽管文件夹结构看起来似乎将它们分隔开了。

相反,清洁架构是一种具有固定结构的层次化设计方案,其核心层次包括表示层、领域层和数据层,而依赖关系始终是朝向内部方向建立的。

在清洁架构中,组合的基本单位分别是实体、用例和仓库。它的主要目标是提高代码的可测试性,并使其独立于框架、用户界面和数据库等外部因素。默认情况下,这种架构的粒度通常比较细,每个功能模块都会遵循相同的结构进行设计。

这种开发方式在初期的学习曲线较为陡峭,因为从第一天开始,每个功能都需要多个文件来支持实现,而且每个功能所包含的样板代码量也明显较多,这些样板代码包括实体、用例、两个仓库层、数据传输对象等等。

它最适合用于那些具有复杂业务规则的应用程序,这类应用程序的业务逻辑必须与用户界面或框架的变化保持独立。其主要风险在于过度使用样板代码:例如,为一个实际上并不需要复杂业务逻辑的功能去构建三层结构。

理解这两者之间关系的最佳方式是这样来看:清晰架构是一种将单一功能分解为多个组成部分的、规范明确的开发方法。其中的实体、用例和仓库本身就可以被视为这些“组成部分”,它们通过依赖注入等方式相互连接。这其实就是乐高积木的构建原理,只不过在这里被应用成了一种固定且具有明确结构的开发模式。

你并不是在选择使用乐高积木还是清晰架构,而是在决定在自己的项目中究竟应该运用多少清晰架构中的具体规范和结构。

在模块化的单仓库系统中结合这两种方法

到目前为止,所有的开发流程都使用了一个统一的文件夹结构来组织Flutter项目中的文件。虽然“桶状文件夹”结构有助于防止不同功能之间的代码相互干扰,但这种划分方式实际上只是一种约定而已。从技术上来说,并没有什么东西能阻止`features/cart/`目录中的文件导入`features/product/src/`目录中的文件,除非开发者自觉遵守相关规范或进行代码审查。

在大规模生产环境中,有些团队会将每个功能独立打包成一个Dart包,这样包装边界就会由包管理系统本身来强制维护,而不再依赖于开发者的自律性。

这种开发模式通常被称为“单仓库架构”,而在Flutter项目中,最常用于管理这类仓库的工具是Melos。本节的后续内容将会一步步地指导你搭建这样的开发环境,确保最终形成的文件夹结构看起来是完全合乎逻辑的,而不是偶然形成的。

从空文件夹开始构建项目

在运行任何Flutter命令之前,你的电脑上只有一个普通的文件夹,其中没有任何与Flutter相关的文件。

mkdir my_lego_project
cd my_lego_project

在这个阶段,`my_lego_project`还不是一个真正的Flutter项目。它没有`pubspec.yaml`文件,也没有`lib`目录或`android`目录,只是一个普通的文件夹而已。后续的所有开发工作都是从这个文件夹开始,有意识地、一步一步地逐步构建起来的。

为项目分配存储原生代码的空间

手机要想正常运行应用程序,仍然需要对应的Android项目和iOS项目。因此,在`my_lego_project`目录中创建的第一个项目就是一个普通的Flutter应用,使用的方法与你一直以来使用的命令完全相同。

mkdir apps  
cd apps  
flutter create app_main  
flutter create app_main 的操作方式与以往完全相同。它会生成 `android/`、`ios/`、`lib/main.dart` 以及 `pubspec.yaml`,所有这些文件都会被存放在 `apps/app_main/` 目录下。目前,这个步骤与 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: 实际的 iOS 和 Android 包装应用程序。

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工具安装起来:

dart pub global activate melos

然后,在`my_lego_project`项目的根目录下,创建一个名为`melos.yaml`的文件,并在其中输入以下内容:

name: my_lego_project

packages:
  - apps/**
  - packages/**

这样,项目结构就会显示为:

my_lego_project/
  melos.yaml
  apps/
    app_main/
  packages/
    feature_login/

这里使用的`packages:`列表采用了通配符模式,`apps/**`和`packages/**`告诉“Melos”去这两个文件夹中查找所有包含`pubspec.yaml`文件的子文件夹,并将它们都视为这个项目的一部分。整个过程没有任何自动化的处理——你都是明确地指定了“Melos”需要搜索的位置。

“melos bootstrap”命令的实际作用

当项目中只有两个包时,在`app_main`和`feature_login`这两个目录中分别运行一次`flutter pub get`命令并不会带来任何麻烦。但当包的数量增加到十个或二十个时,情况就不同了——每个包都需要解析依赖关系,而且其中一些包还会通过本地路径依赖于其他包。此时,如果你还是手动去访问每一个文件夹,就会非常繁琐。而使用“melos bootstrap”命令,你只需在项目根目录下运行一个命令,就能完成所有这些操作:

melos bootstrap

这个命令会读取`melos.yaml`文件,找到`apps/**`和`packages/**`文件夹下的所有包,然后同时为它们全部运行`flutter pub get`命令,从而解决其中所有的本地依赖关系。其实,“melos bootstrap”只是一个用来优化开发流程的工具罢了——它建立在已经存在的机制之上(也就是上面的`pubspec.yaml`文件和`path:`依赖关系),而不是某种全新的技术或机制。

这种模块化结构实际上是通过独立的 `pubspec.yaml` 文件以及明确的路径依赖关系来实现的。Melos的存在就是为了让在众多这样的模块中执行命令变得快速且可重复;而在后续的持续集成流程中,它还能确保只有那些真正发生了变化的包才会被用来运行测试。

究竟哪些内容应该放在 `app_main` 的 `lib` 文件夹中?

在这个阶段,一个自然而然的问题就是:是否每个功能都应该单独成为一个包。毕竟,在普通的 Flutter 开发中,一个包通常指的是某种可复用的组件,比如日期选择器,而不是整个登录界面。

在这种设计模式下,确实如此——像登录这样的完整功能会成为一个独立的包,其中包含该功能的界面、状态管理逻辑以及业务逻辑。之所以要这样做,原因还是为了实现那种隔离机制,而这正是本手册所强调的核心原则。

如果 `feature_login` 是一个独立的包,那么在 `feature_home` 内部工作的开发者就不会不小心导入 `feature_login` 中的任何内容,因为 `feature_home` 的 `pubspec.yaml` 文件中根本没有将 `feature_login` 规定为依赖项。编译器会直接拒绝这样的导入操作,而不会让审核人员去手动检查这些问题。

这就引出了第二个问题:如果所有的界面、状态管理逻辑都存在于各个功能包中,那么 `app_main/lib` 文件夹里还剩下什么呢?答案是:app_main/lib 文件夹仅承担三项职责而已。

  1. 它包含了 `main.dart` 文件,这个文件负责启动应用程序并调用 `runApp()` 方法。

  2. 它还负责配置依赖注入机制。也就是说,它会创建那些具体的实现类(比如网络请求模块),然后把这些类提供给需要使用它们的功能包。

  3. 最后,它还包含了主路由逻辑。因为像 `feature_login` 这样的功能包根本不知道 `feature_home` 的存在,所以只有同时依赖于这两个包的 `app_main` 才能实现各功能包之间的跳转。

下面具体说明了这种导航机制是如何在各个功能包内部实现的:

// 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`这个组件推送到导航系统中,而`HomeScreen`实际上属于`feature_home`组件。将其集成到正在运行的应用程序中,是在`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`)才掌握了具体的实现方案,还包括与平台相关的代码、`main.dart`文件、依赖注入机制以及路由配置。`packages/`目录下的其他所有包,其结构都与`feature_login`相同:它们都有一个`lib/`文件夹和一个`test/`文件夹,都会在`pubspec.yaml`文件中明确列出所需的依赖项,而且这些包都不会包含任何本地的源代码文件,因为这类文件在整个项目中只存在一个副本。

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架构”模型。在这里,各个组件实际上就是独立版本化的包,而它们之间的依赖关系则通过`pubspec.yaml`文件明确指定;规则的执行是由编译器完成的,而非代码审查过程。

可替换的状态管理组件

还有一个值得了解的“高级技巧”是:甚至可以将你的状态管理库也设计成可以随意替换的组件。当一个团队从使用Bloc迁移到使用Riverpod时,或者希望在过渡期间同时支持这两种状态管理框架时,这个技巧就非常有用。

实现这一目标的方法与本手册中介绍的其他方法是一样的:首先定义一个UI组件所依赖的契约,然后让两种不同的状态管理实现方式都能满足这个契约要求。

// lib/features/product/presentation/product_presenter.dart
abstract class ProductPresenter {
  ProductUiState get state;
  Stream get stateStream;
  Future load(String id);
}

class ProductUiState {
  final bool isLoading;
  final String? name;
  final String? error;
  const ProductUiState({thisisLoading = false, this.name, this.error});
}

基于Bloc的实现方式可能如下所示:

class BlocProductPresenter implements ProductPresenter {
  final ProductCubit _cubit;
  BlocProductPresenter(this._cubit);

  @override
  ProductUiState get state => _mapState(_cubit.state);

  @override
  Stream get stateStream => _cubit.stream.map(_mapState);

  @override
  Future 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`这个组件起到了适配器的作用:它利用Dart的`switch`语句以及之前定义的`sealed class`层次结构,将Bloc特有的`ProductState`类型转换成了UI能够理解的通用`ProductUiState`类型。因此,如果团队后来改用Riverpod来实现状态管理功能,widget代码本身也不会发生任何变化——因为只有组合根节点中的连接关系会发生变化,从而决定最终传递给widget树的到底是哪个状态管理组件。

这就是将LEGO的设计原则应用到应用程序中最不稳定的依赖关系上——因为状态管理库本身也只不过是另一个可以随意替换的组件罢了。

一个完整的示例:采用LEGO风格设计、内部结构清晰的应用程序

让我们把所有这些组件整合到一个统一的系统中,展示整个文件结构以及各个部分之间的连接关系。

lib/
  core/
    contracts/
      product_lookup.dart          <- 公共接口
    di/
      servicelocator.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  <- 组合而成的用户界面组件

这个模块文件将所有层级的内容整合在了一个地方。

// 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/servicelocator.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>> ),
    );
    // 这个数据仓库同时也满足了跨模块的ProductLookup接口要求,
    // 因此购物车功能(或任何其他功能)都可以直接使用它,
    // 而无需从该模块的源代码中导入任何内容。
    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']!),
        ),
      ];
}

这个文件只负责完成一项任务——将所有依赖关系连接起来。所有这些依赖关系的连接方向都是统一的:从数据层开始,依次经过业务逻辑层和用户界面层,完全符合之前介绍的清晰架构设计原则。

同时,它也满足了“组合特性”部分中规定的FeatureModule契约要求,这意味着app.dart会将ProductModuleCartModule视为完全相同的组件。它只不过是modules列表中的又一块“积木”而已。

值得特别指出的是,ProductRepositoryImpl同时实现了两个接口:一个是该特性内部使用的本地接口ProductRepository,另一个则是被其他特性(如购物车功能)所使用的跨特性接口ProductLookup。这些其他特性只需要该组件具备的部分功能而已。

这种设计模式其实是一种常见的“高级LEGO架构”模式——一个具体的组件会暴露出多个形态各异的接口,这样不同的使用者就可以只看到与自己相关的部分功能,而无需相互依赖或依赖于整个组件的全部功能。

架构图显示:该组件是一个具体实现,同时实现了两个接口;其中一个接口仅在该特性内部使用,而另一个接口则被其他特性(如购物车)所利用。

何时使用哪种架构模式,以及常见的陷阱

对于那些开发周期较短、业务规则较少的小型应用来说,采用较为基础的LEGO架构模式是合适的。可以组合各种组件,定义一些契约来规定哪些实现是可以被替换的(比如网络客户端或认证模块),同时避免将不必要的实体、用例或数据传输对象强加到那些仅仅用于显示列表并允许用户点击项目的特性中。

对于一个规模不断扩大的开发团队来说,如果有多人共同维护同一个代码库,那么采用包含“桶文件”和FeatureModule契约的模块化架构模式,就可以有效防止合并冲突以及不必要的跨特性耦合现象的发生。

对于那些具有复杂业务逻辑的应用来说,如果这些逻辑需要长期存在于应用程序中,或者后端团队可能会重复使用这些逻辑,那么在每个特性中都引入完整的Clean Architecture设计模式,就会显得非常有必要。这类逻辑通常包括折扣计算、税收规则处理、资格验证以及状态机管理等功能。

对于那些由多个独立团队开发的系统,或者那些需要在多个应用程序中共同使用的设计体系来说,采用模块化的单仓库架构模式也是合理的。因为这样一来,各个特性就真正成为了独立的包,编译器也会自动确保这些特性之间的边界得到遵守,而无需依赖代码审查来保证这一点。

然而,在实际应用中,这种架构模式也可能会出现两种问题。第一种情况是“名存实亡”的情况——虽然某个文件夹被命名为features/cart/,但实际上其中的文件却直接引用了../../product/src/services/product_repository.dart文件。一旦有文件越过了其他特性的“桶文件”范围,进入了其src/目录,那些独立的组件结构就不再存在了,最终整个系统就会变成一个庞大的单体应用。解决这个问题的方法总是相同的:需要通过core/contracts/目录中的契约来规范这些依赖关系。

第二种情况是过度追求“整洁架构”的形式主义。本来只需要实现一个简单功能——比如从服务器获取数据列表并对其进行渲染——但却最终发展成了包含实体类、仓库接口、仓库实现类、数据传输对象、用例以及各种辅助结构的复杂系统。对于一个实际上并不需要任何业务逻辑的界面来说,这样的设计会生成六份文件和三层结构,完全是多余的。

这种做法本身并不是错误的,但纯粹是在浪费精力。因为依赖规则的核心意义本来就是保护那些容易随框架更新而发生变化的业务逻辑,而在这个例子中根本不存在需要保护的业务逻辑。

如果一个功能仅仅只是负责展示服务器返回的数据,那么直接让仓库返回数据传输对象的结构即可,完全没有必要添加实体类或用例这些结构。只有当真正需要实现复杂的业务逻辑时,再添加这些层才是合理的,这样就不会浪费时间在预先构建这些无关结构上。

总结

可以把“乐高架构”看作是一种组织代码的方法,而不是某种需要被安装或复制的东西。

在开始开发之前,首先需要明确应用程序的各个部分应该如何相互连接,规定每个部分可以依赖哪些其他部分,以及它应该向其他部分暴露哪些信息。

一旦这些规则确定下来,就可以围绕这些规则来构建具体的类和实现代码,同时要确保每个组件的内部细节都是私有的,这样应用程序的其他部分才能仅通过它的公共接口与之交互。

最后,将所有这些组件在某个明确的地点组装起来,而不是在整个代码库中随意创建依赖关系。

当你在每个功能模块中都遵循这种规范,并使用经过充分测试的结构(如实体类、用例和仓库)时,就实现了“整洁架构”。

今天就可以从一个简单的开始:将那些用于装饰界面的逻辑提取出来,将其放入一个独立的SurfaceCard风格的组件中。下次编写服务类时,让它继承一个抽象类而不是被直接调用。本手册中提到的功能模块、服务定位器、模块化单仓库以及整洁架构的各层结构,其实都是这种习惯在更大规模上的应用罢了。

参考资料

Robert C. Martin撰写的《整洁架构》博客: https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html

Flutter官方的应用架构指南: https://docs.flutter.dev/app-architecture

Flutter的架构设计模式教程: https://docs.flutter.dev/app-architecture/design-patterns

get_it包的文档: https://pub.dev/packages/get\_it

go_router包的文档: https://pub.dev/packages/go\_router

flutter_bloc包的文档说明: https://pub.dev/packages/flutter\bloc

dio包的文档说明: https://pub.dev/packages/dio

Melos:一个用于管理Dart及Flutter项目的工具: https://melos.invertase.dev/

Effective Dart:官方提供的风格与结构指导: https://dart.dev/effective-dart

相关文章

技术实践

如何使用 Vercel AI SDK 与 Shadcn/ui 来构建人工智能聊天应用程序界面

如今,你打开的每一款AI产品几乎都具有相同的界面结构:消息列表、底部的文本输入框,以及以单个词元形式逐条显示的信息。这种设计看起来很简单,但实际上要将其构建得完善却并非易事。 你必须处理诸如数据流状态管理、部分词元的处理、工具调用、重试机制、Markdown格式的渲染、滚动位置的设置,以及其他众多细节问题……同时还要确保界面易于使用且运行速度足够快。如果使用不合适的工具来开发这些功能,你将会花费大量时间去修复各种技术问题,而根本无暇专注于产品的核心功能建设。 在本教程中,你将使用两款专为彼此设计的工具来构建一个真正的AI聊天界面:Vercel AI SDK用于处理数据流逻辑和模型运算,而sha

阅读全文
技术实践

如何负责任地使用Lovable产品

过去,开发应用程序往往就像在没有任何说明书的情况下组装家具,而且还会缺少一半的螺丝。如今,像Lovable这样的人工智能工具可以帮助你用简单明了的语言描述自己的需求,从而将一个想法转化为可运行的网页应用。 这确实很令人兴奋——但同时也意味着一种责任。 Lovable能帮助你快速行动、尝试各种想法,并创造出实用的软件。不过,速度绝不能取代周密的思考。由人工智能生成的程序可能会存在安全问题、导致用户使用体验混乱、包含不准确的信息,或者其代码在演示环境中可以正常运行,但在实际使用中却会出故障。 在这份指南中,你将学习到如何在实际使用Lovable的过程中兼顾安全性、隐私性、可访问性以及用户的安全。我

阅读全文
技术实践

《设计模式手册:通过C#代码示例学习常见的设计模式》

设计模式是针对软件设计中常见问题的、可复用的解决方案。可以把它们看作是蓝图:并非完整的代码,而是经过验证的模板,你可以根据自己的需求将其调整过来,用于解决自己代码库中的特定问题。 这本手册旨在帮助大家切实理解软件设计模式。我编写这本书是为了所有开发者,无论你使用哪种编程语言。书中的示例是用C#编写的,但这里提到的每一个概念同样适用于Python、Java、TypeScript、Go等语言。 源代码可以在这里找到: github.com/Clifftech123/design-patterns-handbook 。 需要注意的事项: 设计模式本身并不是代码。 它们是一种思考代码结构的方式,是解决

阅读全文
技术实践

jQuery的发展历程:这个小小的库是如何彻底改变网页开发领域的?

jQuery由John Resig创建,于2006年正式发布。它是一个JavaScript库,能够简化HTML操作、事件处理、动画效果以及Ajax功能的实现。由于提供了跨浏览器的通用API,jQuery极大地便利了网页开发工作。尽管随着现代框架的兴起,其使用频率有所下降,但如今仍有大量网站在使用jQuery。 作者:Daniel Curtis

阅读全文