当一个 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 的 version | melos 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 是整个工作区的地基。它的工作流程如下:
- 扫描
packages列表,收集所有包的pubspec.yaml。 - 构建包之间的依赖图(谁依赖谁,通过包名匹配而非路径)。
- 对每个包,若其依赖出现在工作区内,则改写为本地路径依赖(写入
pubspec_overrides.yaml)。 - 按拓扑顺序对每个包执行
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 新手最常见也最难排查的一次性事故。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。