在一台新 MacBook 上 git clone 自己的主仓库,等了一分多钟,报了个超时错误。我以为是网络问题,切节点重试,还是失败。
后来排查明白了:仓库已经大到在大多数网络条件下无法完整克隆。
那一刻我知道这个 Monorepo 不能再继续了。
这个仓库是怎么长到这个体量的
最开始很简单——一个 Flutter App,一个仓库。
有了第二个 App 之后,与其新开仓库,不如直接放进 apps/ 目录,方便共用代码。后来第三个、第四个 App 也用同样的方式加进来。
共用代码越来越多,先抽出了 packages/framework,然后是 AI 能力封装、同步引擎、订阅管理、通知推送、媒体组件……共享包越拆越细,最后 packages/ 下有将近 20 个子包。
工具目录放着 App Store 截图合成工具和一键发布脚本。
总量:数个上架 App + 近 20 个共享包 + 若干工具——全部在一个 git 仓库里。
每次 push 都在增肥。每次 git log --oneline 滚得飞快,所有 App 的改动全混在一起。到了某个临界点,仓库体量压死了它本来想解决的"便利性"。
拆的时候,考虑了三个方案
A. 改用 Git Submodule
主仓库通过 submodule 引用各子仓库的特定 commit,版本精确可追溯。
问题是用起来太痛。git submodule update --remote、git push --recurse-submodules,这些命令我每次用都要查文档。新人 clone 带 submodule 的仓库,发现子目录全是空的,不知道发生了什么——这个问题我见过太多次了。
B. 彻底拆开,各自独立
每个包都是完全独立的 git 仓库,没有任何工作区层面的整合。
简单,但失去了工作区视角。没办法一条命令跑所有包的 flutter analyze,没办法一次 bootstrap 装好全部依赖。各个包的依赖版本会悄悄漂移,某天突然发现两个 App 的 drift 锁在了不同版本。
C. 壳仓库 + Melos 工作区
我选了这个。
思路是:用一个轻量的"壳仓库"维护工作区的编排文件(melos.yaml、部署脚本、文档),各个 App 和 Package 是独立的 git 仓库,克隆到工作区对应目录——但壳仓库不追踪这些目录的内容(.gitignore 把 apps/、packages/、tools/ 全部排除)。
Melos 不在乎这堆目录是不是同一个 git 仓库,只看 pubspec.yaml 是否在那里。只要子仓库克隆到位,melos run lint 就能跨所有包执行 flutter analyze,melos run test 跑全部测试。
开发体验上,和 Monorepo 差别不大。
最终拆成了多少个仓库
26 个独立 git 仓库:
- 1 个壳仓库(工作区编排)
- 4 个已上架 App
- 1 个内部管理工具
- 18 个共享 Flutter 包
- 2 个工具脚本仓库
18 个共享包按功能分组大致是:
- 框架层:核心组件库、AI 能力封装、日历组件、图表组件
- 基础设施层:网络、本地存储、云同步、通知推送、设备权限
- 业务层:订阅管理、媒体播放器、本地 HTTP 服务、安全存储
关键文件:setup.sh
拆完之后最重要的文件是 scripts/setup.sh,它是新环境搭建的入口:
#!/bin/bash
set -e
GITHUB="https://github.com/GuntherLau"
clone_if_missing() {
local repo=$1 dir=$2
if [ -d "$dir/.git" ]; then
echo "✅ $dir (已存在)"
else
echo "📦 克隆 $repo → $dir"
git clone "$GITHUB/$repo.git" "$dir"
fi
}
# Apps
clone_if_missing app-gmemor apps/gmemor
clone_if_missing app-gplay apps/gplay
# ... 其余 App
# Packages
clone_if_missing pkg-framework packages/framework
clone_if_missing pkg-framework_ai packages/framework_ai
clone_if_missing pkg-sync_engine packages/sync_engine
# ... 其余 Package
echo "🎉 所有仓库就绪!运行 melos bootstrap 安装依赖。"新设备搭环境只需要三步:
git clone https://github.com/GuntherLau/workspace-client.git
cd workspace-client
./scripts/setup.sh && melos bootstrap:officialMonorepo 时代是一条 git clone 全部搞定,代价是仓库大到失败。现在多了 setup.sh 这一步,换来每个仓库都轻量,克隆全部可靠。
日常维护:push_all.sh
日常工作时,多个子仓库可能同时有改动。scripts/push_all.sh 遍历所有子仓库,批量提交和推送:
#!/bin/bash
REPOS=(
"apps/gmemor"
"apps/gplay"
"packages/framework"
"packages/framework_ai"
# ...
)
for repo in "${REPOS[@]}"; do
cd "$WORKSPACE_ROOT/$repo"
git add -A
git diff --cached --quiet || git commit -m "chore: sync changes"
git push
cd "$WORKSPACE_ROOT"
done不是每次都全量 push,但在需要批量同步所有改动的时候——比如发版前——这个脚本省了大量手工操作。
Melos 的角色变了
Monorepo 时代,Melos 用来跨包执行命令,有点像"豪华版 npm workspaces"。
迁移之后,它承担了另一个更核心的角色:让一堆松散的独立仓库,看起来还像一个整体。
melos.yaml 的 packages: 配置匹配工作区下所有 Dart/Flutter 包:
name: flutter_workspace
packages:
- "apps/*"
- "packages/*"
- "tools/asc"
- "tools/asc_connect"只要子仓库在目录里,melos 命令就能跨所有包生效。这是整套方案能跑通的核心。
拆完之后出现的新问题
实话实说,多仓带来了几个 Monorepo 不存在的问题。
1. 跨仓库改动不能原子提交
以前改一个共享包,一次 commit 覆盖所有影响范围。现在 packages/framework 的改动和 apps/gmemor 里对应的调用必须分成两个 commit、两个 push。
顺序很重要:要先 push package,再 push app。搞反了,CI 会在中间状态拉到破损版本。
改动范围小的时候还好,涉及多个包的重构需要提前想清楚提交顺序。
2. setup.sh 需要持续维护
新增一个子仓库,如果忘了在 setup.sh 里加上对应的 clone_if_missing,下次有人搭环境时会静默遗漏。
Monorepo 时代根本不存在这个问题——新目录 commit 进去就行了。这是一个小但真实的维护负担。
3. pubspec.lock 可能悄悄分歧
各个 App 独立管理自己的 pubspec.lock,某天 App A 更新了一个依赖,App B 还在旧版本,共享的 framework 可能同时被两个不同版本引用。
目前的处理方式是 bootstrap 时强制指定 Pub 源,减少 lock 文件因镜像源不同导致的差异,但这个问题没有完美解法,需要定期手动同步。
值不值得?
对这个规模来说,值得。
克隆失败是硬问题,迟早要解决。更关键的是,拆开之后有几个明显变化:
发布周期真正独立了。 GMemor 可以推 1.0.11,GPlay 同时在修 1.0.16 的审核问题,两件事完全不互相干扰。Monorepo 时代,"某个 App 还没好"会无谓地拖住其他 App 的提交节奏。
每个仓库的历史干净了。 以前 packages/framework 的 git log 里掺着各个 App 的无关改动;现在一个仓库的 commit 100% 都是这个仓库本身的变更,git blame 终于有意义了。
任何一个仓库都可以快速克隆。 最大的单仓库不超过 30MB,慢网络、新设备都不是问题。
代价是 setup.sh 需要维护,跨仓库改动需要有意识地管理提交顺序,以及偶尔要手动同步 lock 文件。
这不是一个提前规划好的架构决策,而是 Monorepo 长到某个临界点之后的被动演进。如果你的仓库体量还没到这一步,没必要提前拆。
但如果 clone 已经开始超时,或者不同 App 的发布节奏已经互相干扰——拖下去只会越来越贵。
Melos 的文档在 melos.invertase.dev,这是整套方案能跑通的最核心的工具,值得花一小时读完。