Melos 单仓多包与依赖管理

用 Melos 管理 Flutter/Dart 单仓多包:工作区(Workspace)初始化、melos.yaml 配置、依赖统一与 bootstrap 解析、跨包脚本编排、版本发布与变更日志、CI 集成与常见踩坑,给出可直接落地的工程化方案。

当一个 Flutter 项目从「一个 app」长成「一个 app + 三个内部包 + 两个插件 + 一套设计系统」时,最先崩掉的往往不是代码,而是依赖管理:path 依赖互相指来指去、pubspec.yaml 里同一个包在不同子项目写了五个不同版本、改一个公共包要手动 flutter pub get 五遍。单仓多包(Monorepo)能解决代码复用与原子提交的问题,但它把「版本一致性」和「批量操作」两个新问题摆到了台面上。

Melos 是 Dart/Flutter 生态里最主流的单仓管理工具。它不做构建,只做三件事:识别工作区里所有包、统一解析它们的相互依赖、按拓扑顺序批量执行命令。本文从零搭建一个 Melos 工作区,讲清 melos.yaml 的每一段配置、bootstrap 背后的符号链接机制、脚本编排的并发控制,以及发布与 CI 集成中容易踩的坑。如果你的团队正在为多包依赖发愁,跨语言场景也可以对照 前端 Monorepo 方案对比 里 npm workspaces/pnpm 的思路,本质一致。

一、为什么需要 Melos 而不是手写脚本

先看一个典型的多包目录结构。没有工具时,每个包都是独立仓库或独立目录,依赖靠相对路径硬编码:

my_app_workspace/
├── apps/
│   ├── mobile_app/
│   └── admin_app/
├── packages/
│   ├── core_network/
│   ├── design_system/
│   └── feature_auth/
└── plugins/
    └── device_info_plus_fork/

手写 for d in packages/*; do (cd $d && flutter pub get); done 能跑,但解决不了三个核心痛点:

痛点手工脚本Melos
跨包依赖版本每个包手写 path: ../core_network,路径改一次要全局替换统一声明 dependencies,bootstrap 自动建立链接
命令执行顺序无所谓顺序,公共包改了也不会自动重建按依赖拓扑排序,被依赖者先执行
版本与变更日志手动改 6 个 pubspec.yaml 的 versionmelos version 自动联动版本与 CHANGELOG
批量过滤无法只对「有改动的包」跑测试--since / --scope 精准筛选

Melos 的价值不是「少敲几条命令」,而是把「工作区」这个概念变成一等公民:它知道你有哪些包、谁依赖谁,从而让版本、测试、发布这些跨包操作有了统一的编排层。这与 GitHub Actions 的 monorepo 策略 里「按改动路径触发子流水线」是同一套思路的上下游。

还有一类隐性收益:依赖图可视化。melos list --graph 能打印出包之间的依赖关系,新同学入职时看一眼就知道「改 design_system 会影响哪些 app」。手写脚本永远给不了这个视图。

二、安装与工作区初始化

Melos 本身是一个 Dart 命令行工具,推荐用全局激活而非写进每个包的 dev_dependencies:

# 方式一:全局激活(推荐,版本随团队约定锁定)
dart pub global activate melos 6.3.2

# 确认可执行文件在 PATH 中
export PATH="$PATH:$HOME/.pub-cache/bin"
melos --version

# 方式二:作为 dev 依赖(可被 CI 通过 dart run 调用)
dart pub add dev:melos
dart run melos --version

工作区初始化的关键,是在根目录创建 melos.yaml。Melos 6 之后同时支持两种声明工作区成员的方式,推荐显式列出而不是靠 glob:

# melos.yaml(放在仓库根目录)
name: my_app_workspace

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

# Melos 6 起可选的 pub 工作区(Dart 3.6+ 原生 workspace 支持)
# 若使用 dart pub workspace,需要在各包 pubspec 里写 resolution: workspace

初始化完成后,用 melos list 确认包识别结果。这一步经常出问题——packages/* 会把 packages/.dart_tool 之类的隐藏目录也算进来吗?不会,Melos 只识别含 pubspec.yaml 的目录,但如果你的 example/ 子目录里也有 pubspec.yaml(Flutter 插件常见),它会被当成独立包。此时应显式排除:

packages:
  - packages/**
  - "!packages/**/example/**"   # 排除插件自带的 example 工程

! 前缀是 Melos 的否定 glob,顺序敏感——排除规则必须写在包含规则之后。

常用工作区命令速查:

melos list                  # 列出所有包(可加 --graph 看依赖图)
melos list --json           # 机器可读输出,供 CI 解析
melos list --since=main     # 只列出自 main 以来有改动的包
melos clean                 # 清理所有包的 build 与 .dart_tool
melos bootstrap             # 解析依赖并建立本地链接
melos run <script>          # 执行 melos.yaml 里定义的脚本
melos exec -- <cmd>         # 对每个包执行任意命令

三、melos.yaml 的核心配置段

一个生产级 melos.yaml 通常包含四块:工作区定义、命令脚本、版本策略、IDE/环境约定。下面逐段拆解:

name: my_app_workspace

packages:
  - apps/*
  - packages/*

# 全局注入到所有包的 dev_dependencies,避免每个包重复声明
command:
  bootstrap:
    # 使用 pub 的依赖覆盖,把工作区内的同名包强制指向本地路径
    usePubspecOverrides: true
    # 需要联网或私有源的包,在此声明环境
    environment:
      PUB_HOSTED_URL: https://pub.example.com

  version:
    # 版本发布时统一使用的分支
    branch: main
    # 生成 CHANGELOG 时链接到 git 提交
    linkToCommits: true
    # 允许在 CI 里以非交互方式运行
    workspaceChangelog: true

scripts:
  analyze:
    description: 对全工作区运行静态分析
    exec: dart analyze .
    packageFilters:
      dirExists: lib

  test:
    description: 运行所有包的测试
    run: melos exec -- flutter test
    packageFilters:
      dirExists: test

  format:
    exec: dart format --set-exit-if-changed .
    packageFilters:
      noPrivate: true

三个要点:

  • scripts 下的 run 与 exec 区别:run 在当前 shell 执行(可包含 && 等 shell 语法),exec 对每个包分别执行并自动注入包目录上下文。
  • packageFilters 是过滤器的核心,dirExists: test 表示「只对存在 test/ 目录的包跑这条脚本」,避免对没有测试的包报错。
  • usePubspecOverrides: true 会为每个包生成 .dart_tool/package_config.json 之外的 pubspec_overrides.yaml,把工作区内依赖指向本地路径。

packageFilters 支持的条件比想象中丰富,熟练使用能省掉大量 if-else:

过滤器含义示例
dirExists包内存在指定目录dirExists: test
fileExists包内存在指定文件fileExists: build.yaml
dependsOn依赖了指定包dependsOn: build_runner
noPrivate排除 publish_to: none 的私有包noPrivate: true
flutter只保留 Flutter 包(含 flutter 依赖)flutter: true
ignore显式排除某些包ignore: [mobile_app]

四、bootstrap 与依赖统一机制

melos bootstrap 是整个工作区的地基。它的工作流程如下:

  1. 扫描 packages 列表,收集所有包的 pubspec.yaml。
  2. 构建包之间的依赖图(谁依赖谁,通过包名匹配而非路径)。
  3. 对每个包,若其依赖出现在工作区内,则改写为本地路径依赖(写入 pubspec_overrides.yaml)。
  4. 按拓扑顺序对每个包执行 dart pub get。

这意味着跨包依赖只需写包名,不写路径:

# packages/feature_auth/pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  core_network: ^0.1.0      # 不是 path: ../core_network
  design_system: ^0.1.0

bootstrap 时 Melos 看到 core_network 也在工作区内,就把 ^0.1.0 解析成本地目录。发布时又还原成版本号,这是它比手写 path: 高明的地方——本地开发用路径,发布用语义化版本,一份 pubspec 两用。

如果不想让 Melos 自动改写,可以关闭并手动管理:

# 只解析依赖,不生成 overrides(用于排查依赖冲突)
melos bootstrap --no-use-pubspec-overrides

# 强制重新解析(忽略缓存)
melos bootstrap --force

# 清理所有包的构建产物与 .dart_tool
melos clean

依赖冲突排查时,melos exec -- dart pub deps --style=compact 能一次性打印所有包的依赖树,比逐包执行高效得多。若某个传递依赖出现版本冲突,可以借助 dependency_overrides 在工作区根统一钉住版本,但要注意 overrides 会绕过版本约束校验,只在临时排查时使用。

# 根 pubspec.yaml(仅用于统一 overrides,不参与发布)
dependency_overrides:
  meta: 1.15.0

如果冲突来自两个包对同一依赖的区间约束互不相交(例如 A 要 ^1.0.0、B 要 ^2.0.0),说明 API 已经不兼容,此时应升级其中一个包而不是用 overrides 掩盖。overrides 只解决「版本区间有交集但解析器选不中」的场景。

五、脚本编排与并发控制

Melos 的 exec 支持并发执行,默认并发数是 CPU 核心数。跑测试或代码生成时这个默认值往往过大——同时启动 8 个 flutter test 会耗尽内存。可以显式限制:

# 串行执行(调试时用)
melos exec --concurrency=1 -- flutter test

# 只对改动的包执行(对比 main 分支)
melos exec --since=main -- flutter test

# 按 scope 过滤(只跑依赖 core_network 的包)
melos exec --scope=core_network --dependents -- flutter test

# 在指定包中运行某命令
melos exec --scope=design_system -- flutter pub run build_runner build --delete-conflicting-outputs

--since=main 是 CI 提速的关键:它通过 git diff 判断哪些包自 main 分支以来有改动,只对这些包(及其下游依赖者,配合 --dependents)执行命令。一个 20 包的工作区,只改了一个 UI 包时,测试时间可以从几分钟降到几十秒。这套「按改动范围执行」的策略与 monorepo CI 策略 中「影响面分析」完全对应。

exec 的几个关键参数值得记住:

参数作用
--concurrency=N并发进程数,默认 CPU 核心数
--since=REF只对自 REF 以来有改动的包执行
--scope=PKG只对指定包执行
--ignore=PKG排除指定包
--dependents连同依赖目标包的下游包一起执行
--fail-fast任一包失败即中止
--no-private跳过私有包

几个实用的脚本组合:

scripts:
  gen:
    description: 对所有需要代码生成的包跑 build_runner
    run: melos exec --depends-on=build_runner -- dart run build_runner build --delete-conflicting-outputs
    packageFilters:
      dependsOn: build_runner

  ci:
    description: CI 流水线入口
    run: melos run analyze && melos run gen && melos run test

  upgrade:
    description: 升级所有包的依赖到最新兼容版本
    run: melos exec -- dart pub upgrade --major-versions
    packageFilters:
      noPrivate: true

注意 --depends-on 与 packageFilters.dependsOn 的语义不同:前者过滤「依赖了某包」的包,后者在脚本级别做同样的过滤。两者都依赖 bootstrap 建立的依赖图,所以必须先 melos bootstrap 再跑脚本。

六、版本管理与发布

多包发布最烦的是版本联动:改了 core_network 的 API,依赖它的 feature_auth 和 mobile_app 都要升版本、写 CHANGELOG。melos version 把这件事自动化:

# 交互式选择每个包的版本号(Conventional Commits 可自动推导)
melos version

# 自动根据 commit message 推导版本(feat: → minor,fix: → patch)
melos version --conventional-commits

# 预发布版本
melos version --preid=beta

# 只升版本不发布
melos version --no-git-tag-version

# 发布到 pub.dev(会逐个包执行 dart pub publish)
melos publish --no-dry-run

版本联动规则写在 command.version 里。若希望依赖方自动跟着升级,可开启:

command:
  version:
    # 依赖的包版本变化时,自动提升依赖方的版本
    updateDependents: true
    # 私有包不发布(如 app 工程)
    privatePackages: false

privatePackages: false 表示私有包(publish_to: none)只改版本不发布——app 工程就该这么配。发布前务必用 --dry-run 检查一遍,确认没有把内部包误推到 pub.dev。

melos version 生成 CHANGELOG 的规则基于 Conventional Commits:

feat:      → minor 版本(0.1.0 → 0.2.0)
fix:       → patch 版本(0.1.0 → 0.1.1)
feat!:     → major 版本(0.1.0 → 1.0.0)
BREAKING CHANGE: → major 版本

因此团队必须统一 commit message 规范(配合 commitlint + husky),否则版本推导会退化成一堆 patch。发布流程建议走「本地 melos version 生成版本提交与 tag → 推送到远端 → CI 检测 tag 自动 melos publish」,避免本地直接发布导致凭据泄漏。

七、CI 集成与常见陷阱

在 CI 中,Melos 的典型用法是「bootstrap 一次,后续命令复用」:

# .github/workflows/flutter.yml(节选)
- name: Bootstrap workspace
  run: melos bootstrap

- name: Analyze changed packages
  run: melos exec --since=origin/main --dependents -- dart analyze .

- name: Test changed packages
  run: melos exec --since=origin/main --dependents --concurrency=2 -- flutter test --coverage

CI 上跑 Melos 有几个反复出现的坑:

  • 浅克隆导致 --since 失效:GitHub Actions 默认 fetch-depth: 1,melos exec --since=main 找不到比较基线会退化成「全量执行」。必须显式设置 fetch-depth: 0。
  • Dart SDK 版本不一致:每个包的 environment.sdk 约束不同,CI 上应统一到工作区声明的最高版本,避免某个包解析失败导致整个 bootstrap 中断。
  • pubspec_overrides.yaml 被提交:这个文件是 bootstrap 生成的,必须加进 .gitignore,否则会把本地绝对路径泄漏进仓库。
  • 锁文件冲突:pubspec.lock 在 app 工程里应提交,在 library 包里应忽略(library 不该锁定传递依赖)。
# 根目录 .gitignore 片段
**/pubspec_overrides.yaml
.dart_tool/
packages/**/pubspec.lock

一个完整的 CI 校验清单:

#!/usr/bin/env bash
set -euo pipefail

melos bootstrap                          # 1. 解析依赖
melos run format                         # 2. 格式检查
melos run analyze                        # 3. 静态分析
melos exec --since=origin/main --dependents -- flutter test   # 4. 增量测试

配合 https://plumephp.com/flutter-ci-cd/ 的流水线,把 Melos 的 ci 脚本作为入口,可以让「改一个包触发全量重建」变成「改一个包只测影响面」。对于进一步拆分包结构、把公共能力沉淀为可发布组件的团队,可以参考 https://plumephp.com/flutter-package-development/ 里关于 pub 包组织与 API 设计的部分。

小结

Melos 解决的是单仓多包里的「协调成本」问题,它的三条主线值得记住:bootstrap 统一依赖解析(本地路径 vs 发布版本一份 pubspec 两用)、exec 的拓扑排序与过滤(--since/--scope/--dependents 决定跑什么)、version 的版本联动(自动推导 + 依赖方升级)。落地时的最小配置是 packages 列表 + bootstrap.usePubspecOverrides + 一个 ci 脚本;等包数量超过 10 个,再引入 --since 增量执行和 conventional commits 版本推导。最后提醒一句:.gitignore 里漏掉 pubspec_overrides.yaml,是 Melos 新手最常见也最难排查的一次性事故。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「Flutter」更多文章

  1. 崩溃监控与线上可观测性
  2. 蓝牙 BLE 与外设集成
  3. 包体积与启动优化