首页 / 资讯中心 / 文章详情

Flutter Monorepo 工程化实践:用 Melos 统一多包依赖管理与版本发布

Flutter Monorepo 工程化实践:用 Melos 统一多包依赖管理与版本发布 ★ FEATURED ARTICLE
1. 先把“为什么”聊透Flutter Monorepo 到底在解决什么问题1.1 拆分多个包以后生活并没有变得更美好一个 Flutter 项目走到中后期几乎都会往“多包”方向走设计系统单独拆一个包网络层封一个包埋点、路由、状态管理模板各占一个业务模块之间还想互相隔离。拆的初衷很朴素——边界清楚了谁改谁负责编译也不用全量跑。但真正拆完之后你很快会发现麻烦才刚刚开始包与包之间的依赖是路径依赖还是版本依赖改一下底层组件库上层五个业务包要不要挨个flutter pub get某个包发版了其他包什么时候跟更别提每个包都有一套analysis_options.yaml、一套 CI 脚本、一份 CHANGELOG重复劳动堆得比业务代码还多。我见过最夸张的一个仓库十几个 Flutter 包散在目录里靠一份半年前的 README 说明“哪些包要一起改”实际上早就过期了新人接手第一周基本在猜。这些问题单独看都不致命但叠在一起就变成持续性损耗。1.2 Monorepo 是把“协调”这件事收拢到一处Monorepo 的思路并不是把所有东西塞进一个大包而是让多个包共享同一份版本控制、同一套工具链、同一个依赖解析上下文。它解决的不是代码组织问题而是协作成本问题。把包放在同一个仓库里你能拿到几个非常实际的好处一次提交可以同时改多个包且能原子回滚本地依赖用路径指向改完底层包上层立刻能看到效果不需要“发一个预发布版本 → 等 CI → 上层升级”这条漫长链路公共的 lint 规则、CI 流程、发布脚本只需要维护一份。代价也很明确仓库会变大git clone变慢全量安装依赖的时间变长工具链一旦配错就是全员受影响。所以 Monorepo 是取舍不是升级。判断标准很简单——如果你的包之间几乎不互相依赖各自独立发版那分仓库反而更清爽只要出现“改 A 必须同步改 B”这类高频联动Monorepo 的收益就会快速超过成本。1.3 Melos 站在工具链的哪一层Melos 是 Flutter/Dart 生态里针对 Monorepo 场景的编排工具注意这个定位——它不是构建系统也不是包管理器。pub负责依赖解析Flutter 负责构建Melos 负责的是“把一堆包当成一个整体来操作”识别工作区里有哪些包、构建包之间的依赖图、批量执行命令、按约定推进版本号、生成变更日志、按顺序发布。你可以把它理解成一个懂 Flutter 项目结构的命令行调度器。它最大的价值是把那些本来要写一堆 shell 循环的活儿变成一句melos exec -- flutter test而且这个exec是感知依赖顺序的。很多团队一开始用自研 shell 脚本扛着跑到后面发现版本推进、CHANGELOG 合并、发布顺序这些细节根本扛不住最后还是会回到 Melos 这类专用工具上。搞清楚它管什么、不管什么后面配置的时候就不会期待错方向。2. 动手之前先搞清楚几个核心概念2.1 工作区的边界怎么划Melos 通过配置文件里的packages字段用 glob 匹配出所有包目录凡是包含pubspec.yaml的目录都会被识别为一个 package。这一步看着简单实际上是最容易出问题的地方。常见错误是把example目录、测试夹具、临时生成的模板全都扫进来结果melos exec跑一圈跑了一堆你根本不关心的东西。我的建议是从一开始就显式写清楚packages: - apps/** - packages/** - tools/** ignore: - **/build/** - **/.dart_tool/** - packages/**/example/**ignore的优先级高于packages所以用它可以精确剔除。划边界的原则是只有需要一起参与依赖解析、需要一起跑的包才进工作区。不参与的就果断排除别为了“看起来完整”把无关目录扫进来。另外提醒一句根目录本身也可以有一个pubspec.yaml用来放开发依赖和公共脚本但它通常设成publish_to: none不参与发布。2.2 依赖图与路径覆盖本地改动凭什么立刻生效这是 Melos 最核心的机制也是最多人没搞明白的地方。假设packages/ui_kit被apps/main_app依赖正常情况下main_app的pubspec.yaml里写的是ui_kit: ^1.2.0走的是 pub 仓库。但你在本地改了ui_kit的源码main_app怎么立刻用上答案在 Melos 的 bootstrap 阶段它会扫描依赖图把所有工作区内部的依赖改写成路径依赖写进pubspec_overrides.yaml这个约定文件里而不是直接改你提交到版本库的pubspec.yaml。形如# packages/main_app/pubspec_overrides.yaml自动生成不进版本库 dependency_overrides: ui_kit: path: ../../packages/ui_kit这样做的妙处在于版本约束保持原样发布时不会带着路径依赖出去同时本地开发又是路径指向改动即时生效。dependency_overrides在 pub 里本来就是最高优先级的覆盖机制所以能压过版本约束。这里的关键认知是——永远不要手写pubspec_overrides.yaml也不要把它提交进仓库它是 bootstrap 产物手改会被下一次 bootstrap 冲掉提交上去会让 CI 拿到一套错误的路径。2.3 版本推进依赖约定式提交Melos 的version命令不是靠你手填版本号而是读 git 提交历史。它解析类似feat: 新增 xx、fix: 修正 xx、feat!: 破坏性变更这类约定式提交信息自动判断每个包应该升 major、minor 还是 patch然后更新pubspec.yaml里的版本、写入CHANGELOG.md、创建提交和 tag。这意味着提交信息规范不是“团队文化”层面的软要求而是工具链的硬依赖。我从一个项目里踩过的坑是前期提交信息随便写等到要发版的时候发现 changelog 全是空的只能手动翻 git log 补一次发版花了半天。规范这件事最好在仓库初始化那天就定下来配一个提交校验钩子比事后补救成本低得多。另外要注意Melos 是通过包所在目录的 git 提交范围来判断变更归属的所以目录结构要清晰不要出现多个包共用同一个目录的奇怪摆法否则解析会失真。3. 从零搭一个能跑起来的工作区3.1 安装方式的选择Melos 有两种装法各自适用不同场景。第一种是全局激活dart pub global activate melos好处是任何目录下直接敲melos就能用适合个人机器上多个项目都想用。坏处是版本跟着全局走团队成员之间容易出现版本不一致——你本地是 6.x同事是 5.x配置文件解析行为可能不同排查起来极其折磨人。第二种是把 Melos 作为根目录的开发依赖# 根 pubspec.yaml name: workspace_root publish_to: none environment: sdk: ^3.5.0 dev_dependencies: melos: ^6.0.0然后用dart run melos command调用。这种方式把版本锁在仓库里谁 clone 下来都是同一个版本CI 也是同一个版本一致性有保障。我更倾向第二种代价是命令长了点可以在melos.yaml里配个脚本别名或者用 shell alias 兜一下。团队规模超过三个人就别省这一步了。3.2 melos.yaml 逐项拆开看配置文件的每一项都对应实际行为我按重要程度拆name: my_flutter_workspace packages: - apps/** - packages/** command: bootstrap: usePubspecOverrides: true runPubGetInParallel: true environment: sdk: 3.4.0 4.0.0 version: linkToCommits: true workspaceChangelog: true branch: main publish: hooks: pre: dart run melos run analyze scripts: analyze: run: dart analyze --fatal-infos . description: 全仓静态分析包含 info 级别 test: exec: flutter test --coverage packageFilters: dirExists: testusePubspecOverrides决定是否用覆盖文件方式管理本地依赖保持开启这是 Monorepo 体验的基础。runPubGetInParallel在多核机器上能明显缩短安装时间包越多收益越明显。environment.sdk是统一注入的 SDK 约束避免每个包各写一套导致解析冲突。linkToCommits: true让 changelog 里的条目带上提交链接方便回溯。workspaceChangelog: true会在根目录额外生成一份汇总日志release 说明直接拿它改就行。branch: main限定只在主分支上允许发版防止有人在特性分支误操作。publish.hooks.pre是发布前置钩子我一般挂静态分析分析不过直接中止发布比事后回滚省事。3.3 用脚本把重复劳动集中起来scripts是 Melos 最被低估的部分。注意run和exec的区别run在根目录执行一次适合全仓级别的静态分析、格式化exec在每个匹配到的包里各执行一次适合跑测试、跑代码生成。配合packageFilters可以非常精确scripts: gen: exec: dart run build_runner build --delete-conflicting-outputs packageFilters: dependsOn: build_runner dirExists: lib test:changed: exec: flutter test packageFilters: since: origin/main dirExists: testdependsOn过滤出依赖了某个包的 package比手写包名列表可靠得多——新增包时只要依赖对了脚本自动覆盖到。since是增量执行的钥匙下一节细说。这套脚本一旦定下来新人入职只需要记住三四个melos run xxx而不是去翻每个包的 README。我习惯在仓库根目录放一份简短命令表随配置一起改比口头传达靠谱。4. 日常开发流与发版流水线4.1 版本推进的判定逻辑melos version的执行链路值得拆开讲。它会先检查工作区是否干净有未提交改动会拒绝执行除非显式跳过然后遍历每个包取“上次发布 tag 到当前 HEAD”之间的提交范围按约定式提交前缀归类fix:归 patchfeat:归 minor带!或BREAKING CHANGE:脚注的归 major。归类完会做一次依赖传播——如果 A 升了 minor依赖 A 的 B 即使自己没有代码改动也要跟着升一个 patch 并更新版本约束否则发布出去 B 依赖的版本范围还是旧的。这一步是手工做最痛苦的环节也是最容易漏的。正式跑之前务必先 dry-rundart run melos version --dry-run输出会列出每个包从哪个版本升到哪个版本以及 changelog 会写进去哪些条目。我通常会对着提交记录核一遍特别是合并提交、squash 之后信息被改写的情况。确认无误再去掉--dry-run实际执行。执行完它会产生一批提交和一个版本 tag接着推送git push --follow-tags。注意--follow-tags别漏tag 没推上去下一轮版本计算就找不到基线会把上一版的变更又算一遍。4.2 CHANGELOG 的坑与人工打磨自动生成的 changelog 是“能看”级别不是“好看”级别。它按类型分组列条目但提交信息写得随意的话读起来就是一堆碎片。我的做法是发版提交产生后打开根目录的 workspace changelog手工整理一遍再合进代码库——合并同类项、删掉重排性质的提交、把面向用户的改动往上提。这一轮打磨大概十分钟但对下游使用者的友好度提升非常明显。另外要留意chore:、test:、ci:这类前缀默认不进 changelog如果你希望某些类型也出现需要在配置里调整。还有个细节如果某个包长期没变更版本推进时它会被跳过不会产生空版本这是合理的别强行给它升版本。4.3 发布前的检查清单发布是唯一不可逆的环节我固定走这套流程顺序检查项目的1melos clean后重新 bootstrap排除本地残留产物2melos run analyze静态分析必须零告警3melos run test全量测试通过4检查各包publish_to字段私有包不能误发5melos publish --dry-run预览发布顺序与内容6正式melos publish按依赖顺序依次发布第 4 步值得强调内部包一定要写publish_to: none否则publish会尝试推到公共仓库。第 6 步的顺序由依赖图决定底层包先发这样上层包解析依赖时能找到新版本。如果发布到私有仓库需要在每个包的publish_to里配好地址且本地凭证有效——凭证过期通常会在序列中间某一步失败导致“发了一半”处理起来很麻烦所以我习惯先--dry-run加手动核对凭证。5. CI 里怎么只跑改动的包5.1--since的语义与两个陷阱全量跑测试在包数量上来之后会变成瓶颈--since是解决办法dart run melos exec --sinceorigin/main -- flutter test它会筛出相对于指定 ref 有文件变更的包以及依赖这些包的上游包。注意“以及依赖它的上游包”这一点很关键——底层包改了上层包虽然没动文件但行为可能受影响所以必须一起测。第一个陷阱是 CI 上的浅克隆默认fetch-depth: 1拿不到origin/main--since会算错甚至直接报错。解决办法是显式拉取基线git fetch origin main --depth200或者在 checkout 时设置足够的深度。第二个陷阱是 monorepo 里的“共享变更”——根目录的analysis_options.yaml、melos.yaml、CI 脚本本身变了任何包的文件都没变--since会筛出空集合测试一条都不跑。这很危险。我的处理方式是加一层判断检测到根目录配置文件变更时强制全量跑。5.2 一个能跑通的 CI 骨架#!/usr/bin/env bash set -euo pipefail git fetch origin main --depth200 dart pub global activate melos 6.0.0 export PATH$PATH:$HOME/.pub-cache/bin melos bootstrap melos run analyze if git diff --name-only origin/main...HEAD | grep -qE ^(melos\.yaml|analysis_options\.yaml|\.github/); then echo 根配置变更全量执行 melos exec -- flutter test else melos exec --sinceorigin/main --dir-existstest -- flutter test fimelos bootstrap在 CI 里其实等价于逐包pub get但它会一并处理覆盖关系别跳过换成手写循环。--dir-existstest顺手过滤掉没有测试目录的包减少噪声。另外建议把melos list --json的输出缓存下来做依赖图可视化排查“为什么这个包被跑到了”的时候特别有用。6. 踩坑实录与排查速查表6.1 依赖解析冲突约束不一致是根因最典型的报错是melos bootstrap阶段某个传递依赖无法解析。根因几乎都是多个包对同一个第三方包写了互斥的版本约束——A 包写^2.0.0B 包写^3.0.0。Melos 本身不解决这个它只是把冲突暴露出来。排查步骤先dart run melos list确认工作区识别无误再逐包检查pubspec.yaml里可疑的版本约束用dart pub deps --stylecompact看解析树。根治办法是在根目录统一定义environment.sdk和关键第三方依赖的约束范围各包写宽一点把收紧的动作放到根配置。我吃过的一个亏是某个包为了“稳定”把版本钉死在1.2.3结果另外两个包需要1.3.x冲突排查了半天才发现问题在一个角落里的补丁包。6.2 bootstrap 跑完了 IDE 还在飘红命令行flutter test能过编辑器里却一片红——这个现象太常见了。原因通常是 IDE 的 Dart 分析器没有识别到 workspace 结构。在 VS Code 里如果打开的是仓库根目录Dart 插件默认不会深入扫描所有子目录需要在设置里加大搜索深度{ dart.projectSearchDepth: 4, dart.analysisExcludedFolders: [**/build, **/.dart_tool] }另一个常见原因是pubspec_overrides.yaml生成之后分析服务器没有重新加载。手动触发一次 “Dart: Restart Analysis Server” 通常就恢复了。还有个小概率情况你曾经在根目录跑过dart pub get生成了根级的package_config.json分析器误把根当成单包项目。删掉根目录的.dart_tool再重跑 bootstrap 即可。6.3 版本推进结果不符合预期melos version --dry-run的输出和你想的不一样常见三种原因。一是提交信息前缀不合规范比如写成feature:或者修复了xxx解析器认不出来这个包就被判定为无变更。二是基线 tag 丢失上一版发布后没有推送 tag导致版本计算范围被拉长把历史变更重复计入。三是依赖传播导致的“被动升版”——某个包自己没改但因为依赖的包升了 minor它被连带升了 patch。这个行为是设计如此不是 bug看到日志里写着“依赖变更”就说明是这个原因。遇到不确定的情况先看 dry-run 输出里每个包的变更条目来源逐条对到 git log基本都能定位。6.4 常见问题速查表现象大概率原因处理动作melos exec报找不到命令子进程没继承 PATH用完整路径或melos exec -- flutter ...显式指定改了底层包上层没反应覆盖文件没生成重跑melos bootstrap检查覆盖文件是否存在发布到一半失败私有仓库凭证过期更新凭证后从失败的那个包续发changelog 条目重复tag 基线缺失补推 tag 后重算--since筛出零个包浅克隆缺少基线加大 fetch depth 或完整拉取全仓格式化后 diff 暴涨各包 formatter 配置不一致统一根目录analysis_options.yaml与行宽设置再说两个不太容易被记录下来的经验。第一melos exec默认是并行执行的某些包共享同一个生成目录比如都往同一个位置写文件时会出现偶发竞争遇到莫名其妙的间歇性失败先试--concurrency1串行跑一遍确认。第二仓库变大之后git status会变慢把build/、.dart_tool/之类全部塞进根.gitignore别指望各包自己管漏一个就会拖慢全仓操作。我个人在几个项目里跑下来最深的体会是Melos 的配置本身不难写难的是前期把仓库结构、提交规范、包边界这三件事一次性定清楚。这三件事定到位后面的命令基本都是顺水推舟定不清楚再漂亮的配置文件也只能帮你把混乱自动化一遍。
阅读完成 · 觉得有帮助?
咨询建站