1. 多包同仓这件事为什么最后都绕不开工具化Flutter 项目从单包长到多包是个几乎不可逆的过程。一开始一个 pubspec.yaml 什么都放得下业务、组件、工具类混在一起改一行代码全量回归也认了。等到团队里有人开始问这个弹窗组件能不能给另一个 App 也用、这个接口层要不要独立出来多包结构的种子就埋下了。真正动手拆的时候大部分人的第一反应是建一个仓库把 packages 目录塞进去每个子包各自一个 pubspec.yaml然后本地用 path 依赖互相引用看起来就完事了。这套做法能跑但只在你一个人、一台机器、一次只改一个包的时候能跑。一旦变成三五个人协作问题会集中爆发A 改了 shared_ui 的某个接口B 的 App 包 next 拉代码直接编译不过因为依赖版本没对齐CI 里跑测试要在每个子包里 cd 进去执行 flutter test脚本越写越长发版时要把五个包按依赖顺序一个个 pub publish顺序错了就失败还得手动回滚重来。这些事单独看都不难叠在一起就是纯粹的机械劳动而且每次都可能出错。Melos 解决的就是这一层。它是 Dart 生态里的 monorepo 管理工具核心能力是把一个仓库里的多个 Dart/Flutter 包识别成一个工作区workspace然后在这个工作区上做批量的依赖管理、脚本执行和版本发布。说得直白一点它让你不必再在 packages 目录里来回 cd也不必手写一堆遍历脚本而是用一套统一的命令在整仓范围内操作。这篇内容我会从目录结构怎么定、依赖版本怎么统一、脚本怎么组织、发版流程怎么跑通这几个角度把 Melos 在 Flutter 项目里的实际用法拆开讲中间会说到几个我自己踩过的坑比如 path 依赖和 pub 依赖混用导致的版本幻觉以及发布时 changelog 生成的顺序问题。适合已经有多包需求、或者正在犹豫要不要拆包的 Flutter 开发者看最后一部分也适合负责 CI 的同学。2. 先把目录和配置文件定下来比急着敲命令重要2.1 一个能撑住三年的仓库骨架长什么样Melos 对目录结构其实很宽容只要它能通过配置找到各个包的 pubspec.yaml 就行。但宽容不等于随便我见过把 packages 和 apps 混在一起放的仓库半年之后没人说得清哪个目录是发布产物、哪个是内部私有包。我的建议是从一开始就把可发布和不可发布分开这是最省心的分法。一个比较稳的骨架是这样的repo_root/ ├── melos.yaml ├── pubspec.yaml ├── apps/ │ ├── app_consumer/ │ │ └── pubspec.yaml │ └── app_admin/ │ └── pubspec.yaml ├── packages/ │ ├── shared_ui/ │ │ └── pubspec.yaml │ ├── shared_network/ │ │ └── pubspec.yaml │ └── shared_utils/ │ └── pubspec.yaml ├── tools/ │ └── ci_scripts/ └── docs/apps 下面放最终交付的 Apppackages 下面放可复用、理论上可以对外发布的库。这个分法的好处在后面配置 scripts 时会体现出来你可以针对 packages 目录跑全量测试和发布针对 apps 目录跑集成测试两条流水线互不干扰。根目录那个 pubspec.yaml 是很多人会漏掉的东西。Melos 在较新版本里推荐使用 workspace 模式根 pubspec 需要声明 workspace 成员子包的 pubspec 里则要用 resolution: workspace 配合。如果你的仓库还停留在老式配置只用 melos.yaml 的 packages 字段去 glob 匹配目录也可以跑但依赖解析会退化成每个包独立解析速度慢而且容易出现同一个包被解析出两套依赖树的情况。我个人的选择是新项目一律用 workspace 模式老项目在改动量可控时逐步迁过去。2.2 melos.yaml 里那几个真正影响日常的字段配置文件里的字段不多但有几个直接决定你后面用得顺不顺。name: my_flutter_workspace packages: - apps/** - packages/** command: bootstrap: usePubspecOverrides: true runPubGetInParallel: truename是工作区名字随便起但别改来改去某些缓存路径会用到它。packages是 glob 列表注意**和*的区别packages/*只匹配一级子目录packages/**会递归匹配如果你的包有嵌套层级比如 packages/features/xxx必须用双星。usePubspecOverrides这个开关值得单独说。开启后 Melos 在 bootstrap 时会生成 pubspec_overrides.yaml把工作区内部的 path 依赖写进去而不是直接改你的 pubspec.yaml。这解决了一个很实际的问题以前为了本地联调开发者会在 pubspec.yaml 里把版本依赖临时改成 path 依赖提交代码时忘了改回来CI 上就崩了。用 overrides 之后pubspec.yaml 里永远是正经的版本号本地解析走 overrides 指向相邻目录两边都干净。这个文件要记得加进 .gitignore它是生成物不该进版本库。runPubGetInParallel不用多解释包一多串行执行 pub get 的时间会明显拉长开并行能省下不少等待。不过如果你的机器磁盘 IO 比较吃紧或者某些包的 pub get 本身就要编译原生依赖并行反而可能互相拖慢这种时候就关掉。2.3 子包 pubspec 的写法差异别照抄老教程workspace 模式下的子包 pubspec 有几个细节容易写错。一个内部包的典型写法name: shared_ui environment: sdk: ^3.0.0 flutter: 3.10.0 resolution: workspace dependencies: flutter: sdk: flutter shared_utils: ^1.2.0关键在resolution: workspace这一行。它的含义是这个包属于某个 workspace依赖解析交给根统一处理。少了它Melos 的 workspace 模式不会把这个包纳入统一解析你会看到明明加了依赖却解析不到或者报了莫名其妙的版本冲突。还有一点shared_utils: ^1.2.0这里写的是版本号而不是 path。很多人第一次配 workspace 时习惯性写成path: ../shared_utils结果和 workspace 机制冲突。正确理解是源码层面用版本号声明依赖意图本地开发时由 pubspec_overrides.yaml 自动重定向到目录。这两件事分开之后你提交的代码才是这个包依赖那个包的 1.2.0 版本而不是这个包依赖我电脑上那个目录后者对 CI 和外部使用者都是灾难。2.4 根 pubspec 的职责以及常被忽略的 SDK 约束根目录的 pubspec.yaml 在 workspace 模式下不承载业务代码它的作用是声明工作区成员和统一的 SDK 约束name: my_flutter_workspace publish_to: none environment: sdk: ^3.0.0 workspace: - apps/app_consumer - apps/app_admin - packages/shared_ui - packages/shared_network - packages/shared_utilspublish_to: none必须加否则这个根包会被当成可发布对象。workspace 列表要显式列出每个成员这里不支持 glob加新包时别忘了回来补一行。这是 workspace 模式相对 melos.yaml glob 的一个退步但换来的是解析的确定性和速度我觉得划算。根 pubspec 的environment.sdk会成为整个工作区的约束锚点。如果子包里写了比自己根更宽的 SDK 范围Melos 在 bootstrap 时会报错。我就遇到过一次某个包的 SDK 约束还停留在2.17.0 3.0.0根上写的是^3.0.0bootstrap 直接失败报错信息指向的是依赖解析而不是约束冲突排查了一阵才定位到。所以加新包的时候SDK 约束对齐根 pubspec 是第一件要做的事。3. bootstrap 到底做了什么以及它为什么经常失败3.1 从一条命令到依赖树落地的完整链路melos bootstrap有的版本里melos bs是它的别名是日常使用频率最高的命令。它的执行逻辑大概是这样的先扫描配置里声明的所有包读取各自的 pubspec.yaml构建出包之间的依赖关系图然后检查内部依赖的版本声明是否互相兼容发现工作区内 A 依赖 B 但 B 的当前版本不满足 A 声明的约束就会给出警告或直接失败接着为每个包生成 pubspec_overrides.yaml把工作区内部的依赖重定向到本地目录最后并行执行 pub get。理解这条链路很重要因为它的失败点几乎都在这几步里。第一类失败是版本约束不匹配比如 shared_ui 声明依赖 shared_utils 的^1.2.0但 shared_utils 的 pubspec 里 version 还是1.1.0这时候 Melos 会提示你版本对不上本地解析会失败。这个设计其实是在保护你因为如果不检查本地能跑通发布出去之后依赖方拉到的是错版本。第二类失败是 pub get 本身的问题比如某个包的依赖拉不下来、SDK 版本不满足、或者某个第三方包和 Flutter 版本冲突。这类报错信息通常比较明确按提示改就行。第三类也是最烦的一类是缓存和残留状态导致的解析异常。典型表现是明明改了 pubspecbootstrap 之后依赖还是旧的或者昨天好好的今天突然报某个包找不到。这时候的处理顺序是先删掉根目录和各子包目录下的 pubspec_overrides.yaml再删掉 .dart_tool 目录然后重新 bootstrap。Melos 本身有melos clean命令做这件事它会清理生成的临时文件比手动删安全。我个人的习惯是只要改了任何子包的依赖声明或者版本号就先melos clean melos bootstrap不要指望增量生效。多花的那几十秒比后面花半小时排查一个莫名其妙的解析错误划算得多。3.2 版本约束的幻觉为什么本地跑通不代表发出去能用这是我在多包项目里见过最多的坑值得展开讲。场景是这样的shared_utils 当前版本是 1.1.0你在 shared_ui 里加了新功能用到了 shared_utils 里刚加的一个方法顺手把 shared_ui 的依赖声明改成shared_utils: ^1.1.0。本地 bootstrapoverrides 把依赖指向了本地目录新方法当然能用测试也过了。然后你发布了 shared_ui 1.0.1但忘了先发布 shared_utils 1.2.0。这时候外部用户拉取 shared_ui 1.0.1依赖解析到 shared_utils 的最新版本是 1.1.0^1.1.0是满足的所以能装上但那个新方法不存在编译直接报错。这就是版本幻觉本地的 path 重定向掩盖了依赖的实际发布版本还不存在这个事实。Melos 对这个场景有检查能力melos version和发布流程会提示工作区内依赖是否需要同步升版本但前提是你用了它的版本管理功能。如果你完全手动改版本号这个保护是没有的。所以我把这条写进团队规范任何跨包的接口改动先升被依赖包的版本再改依赖方的约束顺序不能反。具体到 semver加方法属于 minor改签名或删方法属于 major内部包虽然不对外发布也建议老老实实按这个走因为内部这个边界随着团队扩张随时会变。3.3 依赖覆盖文件该不该进版本库答案是不该但要说清楚为什么因为有人会担心别人 clone 下来没有这个文件会不会跑不起来。不会。pubspec_overrides.yaml 是 bootstrap 的产物每个开发者在自己的机器上执行 bootstrap 时会重新生成。它进版本库的唯一后果是制造无意义的冲突——不同人的本地路径结构不同生成的内容可能不一样每次 pull 都要处理冲突。.gitignore 里加上这几行.dart_tool/ pubspec_overrides.yaml **/pubspec_overrides.yaml .packages注意**/pubspec_overrides.yaml这一行因为子包目录下也会生成同名文件只写根目录那一条是不够的。这个细节我见过不止一个仓库漏掉结果提交历史里飘着一堆 overrides 文件。3.4 大仓库下 bootstrap 的性能实测与调优包数量到十几个之后bootstrap 的耗时会有明显感知。我实测过一个 14 个包的工作区冷启动清空所有缓存全量 bootstrap 在两分钟左右热启动有 pub 缓存大概二十秒。冷启动那一分钟基本花在拉取第三方依赖上这部分没法省。热启动的二十秒里pub get 本身的执行占大头。可调的选项有几个。runPubGetInParallel开着的收益在包多时比较明显我测下来 14 个包从串行的四十多秒降到二十秒左右。但如果你的机器核心数少并行度太高反而会互相抢占Melos 支持配置并发数可以按机器情况调。另一个能省时间的是melos bootstrap --offline在依赖都已经缓存的情况下跳过网络检查。CI 里如果依赖锁定得很死用这个能省几秒到十几秒但如果缓存没命中会直接失败所以要权衡。我的做法是本地开发用普通 bootstrapCI 里第一次跑普通模式填充缓存后续的 job 用 offline 模式失败则回退到普通模式重试一次。4. 脚本配置把重复劳动从人手里挪走4.1 scripts 段的组织逻辑和变量替换Melos 的 scripts 配置是它最被低估的功能。很多人只把它当一个快捷命令别名实际上它能做的事情不少按包过滤、按依赖顺序串行执行、传递变量、组合多个命令。配置长这样scripts: analyze: description: 对全部包执行静态分析 run: melos exec -- flutter analyze exec: concurrency: 4 failFast: true test:unit: description: 跑 packages 下的单元测试 run: melos exec --dir-existstest -- flutter test packageFilters: scope: shared_* build:app: description: 构建指定 App run: melos exec -- flutter build apk --release packageFilters: dirExists: androidmelos exec是核心它把后面的命令在每个包目录下执行一遍。--dir-existstest这个过滤条件很实用只对存在 test 目录的包跑测试避免在没有测试的包上执行命令然后报找不到测试文件。failFast: true让它在第一个包失败时立刻停下而不是继续跑完所有包再汇总错误——在 CI 里这个设置能帮你更快拿到失败信号。packageFilters的scope支持通配符用它来区分 package 和 app 是常见做法。上面那个scope: shared_*只匹配名字以 shared_ 开头的包命名规范在这里变成了实际的筛选能力这也是我建议包名统一前缀的原因之一。还有个细节是变量传递。Melos 支持在 scripts 里用$MELOS_PACKAGE_NAME、$MELOS_PACKAGE_PATH这类内置变量在 run 命令里引用当前包的名称或路径。写自定义脚本时用得上比如要把每个包的覆盖率文件按包名归档就能用--coverage --coverage-pathcoverage/$MELOS_PACKAGE_NAME.lcov。4.2 依赖顺序执行构建和发布不能乱序melos exec默认的执行顺序不保证依赖关系但加上--order-dependents或--order-dependencies就按拓扑排序来了。两个参数方向相反前者让被依赖者先执行后者让依赖者先执行。构建产物聚合、发布流程这类场景用错方向会出问题。举例来说你要把 packages 下所有库打包成产物如果 shared_ui 依赖 shared_utils打包 shared_ui 时可能需要 shared_utils 的产物已经就位那就得用--order-dependencies依赖方在前被依赖方先跑完。而如果是做全仓的 lint 检查顺序无所谓不加参数反而快。scripts: publish:packages: description: 按依赖顺序发布可发布包 run: melos publish --no-dry-run packageFilters: noPrivate: truenoPrivate: true排除了写了publish_to: none的包这个过滤在混合仓库里几乎是必配的不然发布命令会去尝试发布 App 包然后失败。4.3 一个真实场景CI 里怎么把脚本串起来给一个 CI 里实际在用的流程主流程分四步步骤命令说明预估耗时1melos bootstrap全量依赖解析20s有缓存2melos run analyze全包静态分析40s3melos run test:unitpackages 下单元测试90s4melos run build:app构建目标 App4min关键点在于第 1 步失败时整个流程要立刻中断别让它带着不完整的依赖树往下跑那样第 3 步报的错会是某个包找不到掩盖了真正的依赖问题。另外第 3 步和第 4 步之间应该有个门禁条件测试覆盖率不达标或测试失败就不进入构建否则一个坏包会一路走到出包的环节浪费构建时间。CI 上还有个小技巧把melos.yaml的哈希值作为缓存 key 的一部分配置没变就复用~/.pub-cache和.dart_tool。配置变了就重新 bootstrap。这样大多数提交都能命中缓存CI 时长能压下来不少。5. 版本与发布最容易出错的一段路5.1 版本号的集中管理方式Melos 的版本管理有两种模式一种是每个包自己维护版本号melos version只是批量执行版本升级和 changelog 生成另一种是用version配置在 melos.yaml 里集中管理。后者更适合内部包不单独对外的情况。command: version: branch: main linkToCommits: true workspaceChangelog: truelinkToCommits: true会在生成的 CHANGELOG 里附上提交链接这在一个几百次提交的仓库里对追溯变更原因很有用前提是你的提交信息写了有意义的内容。workspaceChangelog: true会生成一个汇总所有包变更的根 CHANGELOG发版公告可以直接用它。实际执行时是melos version --bump或者带--prerelease走预发布。它会根据各包自上次 tag 以来的提交结合 conventional commits 的写法feat、fix、breaking change自动判定版本升级幅度。所以提交信息规范化在这里不是风格问题是功能依赖——你提交时写fix: 修复空指针它才知道这是 patch 升级写 改了点东西它就只能当噪声跳过或者让你手动指定。5.2 发布顺序和 CHANGELOG 的那个坑发布不能一次性把所有包同时推上去原因前面提过依赖方发布时被依赖方的新版本必须已经在仓库上存在否则外部用户解析会拿到旧版本。Melos 的melos publish会按依赖顺序处理但如果你手动执行或者拆成多个步骤顺序必须自己保证。CHANGELOG 有个容易踩的坑跨包依赖升级时如果只升了依赖方的版本号被依赖方的 CHANGELOG 里不会记录这次变更但依赖方那边会写一条升级了 shared_utils 到 1.2.0。这本身没错问题在于排查线上问题时你先看 shared_utils 的 CHANGELOG 发现什么都没变就跳过了这个包而问题恰恰出在它身上它的变更在 1.2.0 的条目里你从 1.1.0 跳到 1.2.0 却只看了依赖方的日志。所以我在团队里要求被依赖包的 CHANGELOG 条目要描述本次变更对外部使用者的影响而不是只写内部重构。5.3 预发布和灰度内部包也要走一遍内部包很容易被当成随便发然后某天出了个问题需要回滚才发现没人记得上一版是哪个 commit 打的 tag。所以即使是内部包也建议走--prerelease打一个1.2.0-dev.1的预发布版本在消费方 App 里先验一轮确认没问题再发正式版。melos version --prerelease --yes melos publish --no-dry-run--yes跳过交互确认适合在 CI 里跑本地跑的时候别加不然容易误操作。预发布版本号在 semver 里的排序是低于正式版的^1.2.0这样的约束不会匹配到1.2.0-dev.1所以消费方需要显式指定预发布版本或者用1.2.0-dev.1 1.3.0这种范围。这一点要提前和消费方沟通好不然他们会以为新版本没发出来。6. 那些文档里不会写的实际问题6.1 循环依赖报错信息不会告诉你根因包之间出现循环依赖时Melos 的报错往往不够直白可能表现为解析超时、或者某个包 inexplicably 找不到。循环依赖的产生通常很隐蔽shared_a 依赖 shared_b后来有人在 shared_b 里为了用一个小工具函数引了 shared_a本地因为缓存的缘故还能跑bootstrap 才暴露。排查手段是画依赖图。Melos 没有内置的图输出命令但你可以用melos list --graph这类输出或者干脆写个脚本遍历每个包的 pubspec把内部依赖抽出来做拓扑排序发现排不出顺序的就是有环。更简单的办法是melos list --cycles如果版本支持它会直接列出循环依赖的链。根治的办法是引入分层规范utils 层不依赖任何内部包network 层只依赖 utilsui 层可以依赖前两者app 层依赖所有。一旦发现反向依赖就要考虑把共享的部分下沉到更低层或者把那个工具函数复制一份——是的有时候复制比抽象更便宜尤其是那个函数只有十行的时候。6.2 包命名和职责边界的经验判断我见过太多的仓库里有个叫 common 或者 core 的包最后变成什么都往里塞的垃圾桶依赖图变成一团乱麻。判断一个包该不该独立我用的标准是两个一是有没有独立的发布节奏二是有没有明确的、可以用一句话说清的职责。如果一个包没法用一句话描述它是干什么的那它大概率不该独立存在。命名上建议加统一前缀前面提到过这能配合 packageFilters 做筛选。前缀的作用还在于 IDE 里搜索时能聚在一起pubspec 里扫一眼就知道哪些是内部包。具体的命名风格看团队习惯重要的是稳定别今天叫 shared_ui 明天改叫 ui_kit。6.3 IDE 和代码提示的兼容问题workspace 模式在某些 IDE 版本上会有索引异常表现为跳转到定义跳到了 pub cache 里的旧版本而不是本地源码。原因是 IDE 的 Dart 分析器还在按老的 package_config 解析。解决办法是执行一次melos bootstrap之后在 IDE 里做一次 Restart Analysis Server或者重启 IDE让它重新读取 .dart_tool/package_config.json。如果多个包同时在 IDE 里打开开发体验会好很多因为分析器能跨包跳转。但包多了之后内存占用会上去机器配置一般的话可以考虑只打开正在改的那几个包加上它们的直接依赖。6.4 从单包迁移到 monorepo 的实操顺序最后说说迁移。直接新建一个仓库把代码拷过去风险最大因为 git 历史丢了出问题时没法追溯。比较稳的做法是在原仓库里就地改造先建 packages 目录把代码挪进去保留 git 历史用git mv然后加根 pubspec 和 melos.yaml跑一次 bootstrap 解决所有解析问题确认能构建能测试之后再拆下一个包。一次只拆一个每次拆完都要跑一遍完整的 CI 流程。迁移过程中最容易忽略的是资源文件路径。Flutter 包里的 assets 在搬动目录后pubspec 里的 assets 路径和代码里引用路径都要跟着改而且有些包的 build 脚本里硬编码了相对路径改完记得搜一遍。迁移完成后原来的单包仓库可以保留一段时间做只读备份等新结构稳定运行两三个版本之后再归档。7. 写在最后的几句实操体会Melos 这个工具的价值不在于它有多少命令而在于它把多包协作里那些每次都要手动做、每次都怕做错的事情收敛成了配置。但工具本身不能替你决定包的边界怎么划、版本号怎么升、依赖方向怎么控这些还是得靠人。我个人的经验是前期在目录结构和依赖分层上多花两天后面能省下几十次排查循环依赖和版本冲突的时间。还有一点别把 Melos 的能力用满。它的脚本系统能做很复杂的编排但如果你发现自己在 melos.yaml 里写了一屏多的 shell 逻辑那说明这些逻辑更应该放进独立的脚本文件里由 melos 去调用而不是塞在配置里。配置保持简短可读是长期维护的关键。