当Flutter应用程序的发展规模超出了单一移动应用的范畴时,开发团队会迅速面临一系列新的问题。共享的业务逻辑开始在不同的项目中被重复使用,UI组件也会出现不同步的情况,某个应用中的修复措施无法顺利应用到其他应用中,对共享代码进行版本控制也变得十分麻烦,持续集成流程的数量更是呈指数级增长,从而导致开发人员的效率大幅下降。

幸运的是,正是为了解决这些问题,人们才创建了单仓库架构。

在本指南中,我们将通过一个实际案例来讲解如何构建、维护一个Flutter单仓库系统。这个案例是一个打车平台,它包含乘客端移动应用、司机端移动应用以及Web管理后台。通过这个案例,你将了解什么是单仓库架构,在Dart和Flutter中共享包是如何工作的,Melos在这个系统中扮演什么角色,Dart工作区实际上能提供哪些功能,以及这些工具在真实的生产环境中是如何相互配合使用的。

读完本指南后,你将会对如何设计并运作一个可用于生产的Flutter单仓库系统有一个清晰而实用的理解。

目录

  • 先决条件

  • 多仓库架构带来的问题

  • 理解单仓库解决方案

  • 为什么大型科技公司会使用单仓库架构

  • 打车平台的应用案例

  • 单仓库架构的高层结构

  • 使用Melos构建工作流程

  • Flutter单仓库架构的关键优势

  • Dart工作区

  • 实施指南

  • 最佳实践

  • 常见错误

  • 结论

  • 参考资料

  • 先决条件

    要有效遵循本指南,您应对Flutter和Dart有中级水平的了解。您应该能够熟练创建新的应用程序、编辑pubspec.yaml文件,并使用终端。

    您还需要安装Dart SDK。虽然早期版本也支持单仓库架构,但我建议使用Dart SDK 3.6.0或更高版本,以便充分利用现代Dart工作区的各项功能。

    您还应该安装Flutter,并通过flutter doctor验证其是否已正确安装;同时,为了进行版本控制,您也需要安装Git。

    您不需要具备关于单仓库架构的先验经验,不过熟悉Dart中本地路径依赖关系的处理方式会很有帮助。

    多仓库架构存在的问题

    假设您正在开发一个网约车平台。起初,您创建了一个乘客端应用程序;后来又添加了司机端应用程序,以及管理员控制面板。每个项目都对应着一个独立的仓库。很快,您就会发现代码重复的问题:计费逻辑出现在多个地方,行程模型存在细微差异,API客户端也被反复复制和修改。

    为减少代码重复,您可能会将一些通用逻辑提取到一个单独的仓库中。这样一来,所有应用程序都会依赖这个仓库中的代码包。但每次对代码进行修改后,都需要发布新版本、更新依赖关系设置,并确保各组件之间的兼容性。由于这一过程较为繁琐,您的团队往往不愿意对共享代码进行重构,而这种阻力会阻碍创新的发展。

    理解单仓库架构的解决方案

    单仓库架构,即将多个项目的代码存储在同一个版本控制仓库中的开发模式,与将所有代码编译成单一二进制文件的“单体应用程序”不同。在单仓库架构中,您仍然可以部署不同的应用程序,但这些应用程序的源代码是共存的。

    这种架构能够有效解决诸如跨应用程序重复编写业务逻辑、UI组件不一致,以及应用程序独立发展时版本控制复杂等问题。

    以我们的网约车例子来说,乘客端应用程序负责处理乘客的请求和支付事宜,司机端应用程序负责接单和导航操作,而管理员控制面板则用于管理用户信息、行程数据及分析报告。这些应用程序都共享诸如行程模型、计费逻辑和用户认证机制等核心功能,因此使用单仓库架构可以避免代码重复,同时确保变更能够被及时传播到所有相关系统中。

    理解单仓库架构的解决方案

    为什么大型科技公司会使用单仓库架构

    像谷歌、Facebook和微软这样的大型科技公司,之所以会在数十亿行的代码中采用单仓库架构,是因为这种架构能够确保各个服务之间的变更可以原子级地进行管理。

    如果谷歌的某个平台工程师修改了核心库中的安全协议,他们就能立即看到所有因此出现问题的下游项目。然后他们可以在同一次提交中修复这些问题。这样就可以避免“依赖地狱”现象——即不同团队因为升级过于困难而继续使用旧版本的库。

    在Flutter的开发环境中,像FlutterFire和Flame这样的项目也会采用这种机制,以实现统一的依赖管理及工具使用。

    打车服务应用场景

    在本指南中,我们将假设我们要开发三个应用程序:

    1. “乘客端应用”是供乘客用来请求用车、追踪司机以及完成支付的Flutter移动应用。

    2. “司机端应用”是供司机用来接单、导航以及管理收入的Flutter移动应用。

    3. “管理员控制台”是供工作人员用来管理用户、司机、行程、定价信息以及分析数据的Flutter网页应用。

    这三个应用程序都共享相同的业务逻辑、数据模型以及统一的用户界面设计风格。因此,它们非常适合使用单仓库架构进行开发。

    高级单仓库结构

    在实际应用中,Flutter的单仓库通常会将各个应用程序与共享库分开存储。在仓库的根目录下,会存放配置文件和开发工具;而在其下方,则将各种应用程序和共享库分门别类地存放在不同的目录中。

    ride_hailing_monorepo/
    ├── pubspec.yaml
    ├── melos.yaml
    ├── apps/
    │   ├── rider_app/
    │   ├── driver_app/
    │   └── admin_web/
    └── packages/
        ├── core/
        ├── shared_models/
        ├── shared_services/
        └── shared_ui/
    

    上图展示了该单仓库在硬盘上的实际存储结构。根目录下包含pubspec.yaml文件(用于定义项目配置)和melos.yaml文件(用于配置脚本执行顺序);

    apps目录中存放着可执行的应用程序:rider_app是乘客端应用,driver_app是司机端应用,admin_web则是管理员控制台。这些文件夹都遵循标准的Flutter项目结构,其中包含libtest目录。

    packages目录才是真正实现功能复用的地方:core包包含了诸如验证器和格式化工具之类的纯Dart代码;shared_models包定义了用户、行程等数据结构;shared_services包负责处理API调用;shared_ui包则确保所有应用程序中的按钮样式和颜色保持一致。这种结构遵循了一个简单的原则:应用程序依赖于共享库,但共享库永远不会依赖任何特定的应用程序。

    高级单仓库结构

    使用 Melos 的工作流程

    如果没有专门的工具来管理单仓库项目,那么这一过程将会非常繁琐且容易出错。虽然你可以将各个文件夹放在彼此旁边,但要对它们进行操作却相当困难。

    例如,如果你想要运行单元测试,就必须手动进入 Rider 应用程序文件夹,运行测试命令,然后再退出;接着进入 Core 包目录,再次运行测试命令,如此反复地针对每个包执行这些操作。如果你忘记了某个步骤,就可能会部署出有问题的代码。而这时,Melos 就成为了你的 Flutter 单仓库项目中至关重要的协调工具。

    Melos 是由 Invertase 团队开发的一款命令行工具,这个团队也是 FlutterFire 的开发者。它专门为管理包含多个包的 Dart 和 Flutter 项目而设计,能够自动执行脚本、管理包的发布过程,并提供高级过滤功能,确保你只针对代码库中真正需要执行操作的部分来运行相应任务。

    了解配置文件

    Melos 需要在你的仓库根目录下创建一个名为 melos.yaml 的配置文件。这个文件就是你单仓库项目的控制中心,它决定了 Melos 应该在何处查找包,并定义了团队每天会使用的自定义脚本。

    对于我们的网约车应用程序来说,标准的 melos.yaml 文件内容如下:

    name: ride_hailing_monorepo
    
    packages:
      - apps/**
      - packages/**
    
    scripts:
      analyze:
        run: melos exec -- flutter description: 在整个代码库中运行静态分析。
    
      test:
        run: melos --dir-exists="test" -- test
        description: 在所有包含测试目录的包中运行单元测试。
    

    过滤的功能与优势

    在大型单体仓库中,对每个包执行所有命令可能会非常耗时。如果你只是想要修复Driver应用程序中的某个错误,显然不希望等待管理员控制台上的测试完成才继续操作。Melos提供了强大的过滤系统来解决这个问题。

    你可以根据目录的存在情况进行过滤。在上述的test脚本中,我们使用了--dir-exists="test"这一选项。Melos会检查某个包是否存在名为test的文件夹,只有当该文件夹存在时才会执行相应的命令。这样就可以避免在那些根本没有这个文件夹的包上尝试运行测试而引发错误。

    你还可以根据范围来进行过滤。--scope参数允许你按名称来指定具体的包进行操作。例如,如果你执行melos exec --scope="core" --flutter test命令,Melos会忽略所有名为core以外的应用程序和包,从而让你在开发过程中能够更精确地控制操作对象。

    版本控制与变更日志

    单体仓库中最复杂的环节之一就是版本控制。当你更新了core包时,从技术上讲就需要为其版本号进行升级。Melos通过名为melos version的命令来自动完成这一操作。

    Melos遵循Conventional Commits规范。如果你使用标准的格式编写Git提交信息,比如feat: add new fare calculator,Melos会分析你的Git历史记录,从而判断是否添加了新功能,并自动更新该包的版本号。随后,它会生成一个CHANGELOG.md文件,详细列出所有变更内容,并为此次发布创建一个Git标签。这样一来,原本繁琐且容易出错的发布流程就被简化成了一个简单的命令而已。

    Flutter单体仓库的主要优势

    采用这种架构后,Flutter生态系统会带来三个主要的优势。

    第一个优势就是“单一真实来源”。如果没有单体仓库,Rider应用程序可能会使用版本1.0的API客户端,而Driver应用程序却使用版本2.0,这样就会导致一些无法复现的错误。而在单体仓库中,所有组件都使用同一版本的代码,因此当你更新API客户端时,所有相关组件都会同时得到更新。

    第二个优势是“统一的工具链”。你可以用一个命令在公司的所有包上执行flutter test测试,也可以对整个代码库进行静态分析。这样就能确保负责开发UI库的初级开发者与负责核心支付逻辑的高级工程师遵循相同的代码质量标准。

    第三个优势是“原子级的重构能力”。如果你决定将User.id重命名为User.uuid,你可以使用集成开发环境一次性完成在Rider应用程序、Driver应用程序以及管理员控制面板中的所有修改操作,而无需打开三个不同的窗口或提交三份不同的拉取请求。

    Dart工作区

    过去,要管理多个包中的依赖关系及相关工具,往往需要使用复杂的外部解决方案。然而,随着Dart 3.6的发布,该开发生态系统引入了原生的Pub工作区功能。

    一个工作区能够让多个包共享同一个依赖解析环境。这意味着这些包会在根目录下共享同一个`pubspec.lock`文件,从而确保所有应用和包都使用完全相同的依赖版本。如果`shared_services`需要`http: ^1.0.0`这个依赖,而`rider_app`也需要同样的依赖版本,那么工作区会确保它们都解析为相同的版本,比如1.2.0。

    此外,Dart分析器也能将整个单仓库视为一个整体进行处理。你的集成开发环境不再需要为每个包单独启动分析服务器,这样一来,内存使用量会大大降低,而且在整个仓库范围内进行“跳转到定义”或“查找引用”等操作也会变得非常快捷。

    工作区与Melos的配合方式

    你可能会想知道,既然Dart工作区已经可以处理依赖关系的链接问题,那么是否还需要使用Melos呢?答案是肯定的,因为它们是互补的工具。

    Dart工作区负责处理底层的依赖解析及文件链接工作。它们能确保代码生成的结构是合法的,并且各个包能够在磁盘上相互找到对方,而无需将代码发布到pub.dev平台上。

    Melos则负责处理更高层次的工作流程协调任务。它可以运行脚本、管理版本控制以及生成变更日志。此外,它还允许用户对命令进行过滤。例如,你可以使用Melos来执行“只运行自上次提交以来发生变化的包中的测试”这样的操作。而Dart工作区本身并不具备这种功能。Dart工作区负责让代码能够正常编译,而Melos则使整个开发流程更加高效。

    实施指南

    现在,我们将一步步指导你如何从零开始构建这样的架构。

    初始化仓库

    首先,我们需要为项目创建一个目录,并将其初始化为一个Git仓库。这样就能确定我们的文件结构的根目录位置了。

    mkdir ride_hailing_monorepo
    cd ride_hailing_monorepo
    git init
    

    配置根工作区

    接下来,我们需要告诉Dart这个目录就是工作区的根目录。我们可以通过在顶层创建一个`pubspec.yaml`文件来实现这一目标。

    name: ride_hailing_monorepo
    environment:
      sdk: ^3.6.0
    
    workspace:
      - apps/rider_app
      - apps/driver_app
      - apps/admin_web
      - packages/core
      - packages/shared_models
      - packages/shared_services
      - packages/shared_ui
    

    这个文件非常重要。workspace键实际上是一个字符串列表,每个字符串都指向一个相对路径,这些路径就是包或应用程序所在的位置。需要注意的是,尽管我们还没有创建相应的文件夹,但现在就已经定义了这些路径。这种预先配置的做法有助于我们清晰地了解整个结构。要使用这一功能,SDK版本必须设置为3.6.0或更高版本。

    安装并配置Melos

    Melos是一种工具,它能帮助我们执行这些包中的各种命令。我们将使用Dart在机器上全局安装它。

    dart pub global activate melos

    接下来,我们需要在项目根目录下创建一个melos.yaml文件。这个文件会告诉Melos在哪里查找包以及我们需要运行哪些脚本。

    name: ride_hailing_monorepo
    
    packages:
      - apps/**
      - packages/**
    
    scripts:
      analyze:
        run: melos exec -- flutter description: 在所有包中运行分析脚本。
    
      test:
        run: melos exec --dir-exists="test" -- test
        description: 仅在执行了测试脚本的包中运行测试。
    

    packages键使用了通配符。apps/**表示“查找apps文件夹及其所有子目录”。scripts部分允许我们定义自定义命令。analyze脚本会使用melos exec命令,该命令会遍历所有找到的包,并在每个包内部运行flutter analyze。而test脚本也会做同样的事情,但它添加了一个过滤条件--dir-exists="test"——这个条件会让Melos跳过那些没有测试文件夹的包,从而节省时间并避免错误。

    创建一个共享核心包

    现在我们开始创建实际的代码模块。首先从core包入手,这个包包含了纯粹的Dart业务逻辑。我们会创建相应的目录并生成相关的包文件。

    mkdir -p packages/core
    cd packages/core
    dart create --template=package .
    

    创建完这些文件后,我们必须修改packages/core/pubspec.yaml文件,以便让这个包能够纳入工作空间中。

    name: core
    description: 核心业务逻辑 version: 1.0resolution: 工作空间
    
    environment:
      sdk: ^3.6.0
    

    这里的关键行是resolution: workspace。这一设置告诉Dart不要尝试单独为这个包解析依赖关系,而是应该参考根目录下的pubspec.yaml文件,从而参与整个系统的共享依赖关系管理。

    我们可以在packages/core/lib/core.dart文件中添加一些简单的逻辑:

    library core;
    
    class FareCalculator static double calculate(double km) {
        return km * 2.5;
      }
    }
    

    现在,FareCalculator这个类就可以在我们系统的任何地方被重复使用了。

    创建共享UI包

    接下来,我们将创建一个UI包。与核心包不同,这个UI包依赖于Flutter框架,因为它包含了各种UI组件。

    cd ../..
    mkdir -p packages/shared_ui
    cd packages/shared_ui
    flutter create --template=package .
    

    现在我们需要编辑packages/shared/ui/pubspec.yaml文件,确保这个包能够被纳入工作空间中:

    name: description: resolution: environment:
      sdk: ^3.6.0
    
    dependencies:
      flutter:
        sdk: import class PrimaryButton extends StatelessWidget {
      final String label;
      final VoidCallback onPressed;
    
      const PrimaryButton({
        super.key, 
        required this.label, 
        required this.onPressed
      });
    
      @override
      Widget build(BuildContext context) {
        return ElevatedButton(
          onPressed: onPressed,
          child: Text(label),
        );
      }
    }
    

    这个PrimaryButton组件的设计确保了:如果我们以后更改了品牌标识,只需要更新这一个文件,所有应用程序都会自动反映这些变化。

    创建Rider应用程序

    现在我们要创建这些组件的使用者:即Rider应用程序。请导航到“apps”文件夹,然后生成一个标准的Flutter应用程序。

    cd../..
    mkdir apps
    cd apps
    flutter create rider_app
    

    我们必须将这个应用程序与我们共享的包链接起来。打开apps/rider_app/pubspec.yaml文件,配置其中的依赖关系。

    name: rider_app
    description: Rider应用程序
    resolution: environment:
      sdk: ^3.6.0
    
    dependencies:
      flutter:
        sdk: core:
        path: shared_ui:
        path: 搭建单仓库开发环境
    

    目前,我们已经创建了所需的文件,但还没有安装任何依赖库。让我们返回到仓库的根目录,然后运行以下命令:

    flutter pub get

    这个命令非常强大。由于工作区的配置设置,它会分析根目录下的`pubspec.yaml`文件,找出我们列出的所有依赖包,再查看这些包各自的`pubspec.yaml`文件,从而确定每个库的版本,并确保最终选择的版本不存在冲突。最后,它会在根目录下生成一个`pubspec.lock`文件。

    使用共享代码

    现在,我们可以在Rider应用程序中使用这些共享代码了。打开`apps/rider_app/lib/main.dart`文件:

    import // 我们像从pub.dev下载依赖包一样来导入这些库
    import import void main() {
      runApp(const MaterialApp(home: HomeScreen()));
    }
    
    class HomeScreen extends StatelessWidget const HomeScreen({super.key});
    
      @override
      Widget build(BuildContext context) {
        // 我们在这里使用了共享代码逻辑
        final double price = FareCalculator.calculate(12.5);
    
        return Scaffold(
          appBar: AppBar(title: const Text('预计车费:\$$price'),
                const SizedBox(height: 20),
                // 我们使用了共享的UI组件来显示按钮
                PrimaryButton(
                  label: '请求乘车',
                  onPressed: () {
                    print(最佳实践
    

    为维护一个健康的单仓库系统,你应该严格遵守一些规则。UI包绝对不应该导入那些会调用API的服务包。这种职责分离能够确保你的UI部分仅专注于展示功能,从而便于进行测试和预览。

    另一个最佳实践是利用Melos过滤机制。随着代码库规模的扩大,运行所有测试会变得非常耗时。通过使用melos run test --scope="rider_app"命令,你可以让Melos仅执行rider_app包内的测试脚本,而忽略其他包的测试,这样就能保持开发流程的高效率。

    你还应该在整个项目中统一代码格式。你可以在melos.yaml文件中添加一个format脚本,让它执行dart format .命令。通过运行melos run format,就能确保所有包中的文件都遵循相同的格式规范,从而减少代码审查时的麻烦。

    常见错误

    一个常见的错误是产生循环依赖关系。例如,如果包A导入了包B,而包B又导入了包A,就会形成编译器无法解决的循环。

    为避免这种情况,你应该将依赖关系结构设计成树形结构,让依赖关系呈自下而上的方向传递。核心代码包位于最底层,服务包依赖于核心代码包,而应用程序则依赖于服务包。

    另一个常见的错误是创建所谓的“万能包”。有些开发人员为了方便管理,会将所有共享代码都放在一个名为sharedcommon的包中。这种做法会导致包文件变得庞大,编译速度变慢,而且也难以追踪哪些代码被用在了哪里。

    正确的做法应该是将代码划分为诸如analyticsauththemenetworking这样的独立包,这样应用程序就可以只导入它们真正需要的部分。

    结论

    单仓库系统并不是一种潮流,而是一种经过验证的架构模式,专门用于管理多应用程序系统中的复杂性。通过围绕共享代码包和明确的职责边界来构建你的叫车平台,你将获得一致性、更快的开发速度、更安全的代码重构能力,以及更好的长期扩展性。

    Dart Workspaces负责处理依赖关系解析,而Melos则用于协调工作流程,这种组合为任何Flutter团队提供了坚实的基础。关键在于要明白:应用程序实际上只是将各种共享代码包连接在一起的工具而已。一旦你接受了这种架构模式,构建复杂系统就会变得容易得多。

    参考资料

    在编写这份指南时,我参考了以下官方资源和文档。建议大家进一步阅读这些资料,以加深对相关内容的理解:

    Melos

    • Melos文档(Invertase提供) – 由Invertase维护的Melos CLI工具官方文档,涵盖了安装方法、脚本使用以及Dart和Flutter单仓库系统的生命周期管理内容。

    • Melos包(pub.dev平台提供) – Melos包在pub.dev平台上的注册信息,包括版本历史、安装命令和配置指南。

    Dart工作区与包管理

    • Dart工作区指南 – Dart官方文档,介绍了原生工作区功能(该功能在Dart 3.6版本中被引入)。文中解释了解析上下文以及`pubspec`配置文件的作用。

    • 依赖项与路径包 – 详细说明了Dart如何处理本地路径依赖项,这也是在单一代码库中链接各个包所采用的底层机制。

    Flutter包与插件

    • 开发Flutter包与插件 – Flutter团队提供的全面指南,介绍了如何在单一代码库中创建、组织及维护可重用的Dart及Flutter包。
Comments are closed.