Repo VS Submodule

微店工程结构改造历程

虽然工程结构、版本控制只是日常开发非常小的一块内容,但是在一个现代的大型项目里,如果构建一个更高效地组织形式,是每一个团队都必须正视和解决的问题。

从 16 年的四月份微店 Android 开始了平台化改造,为了配合平台化开发,微店的工程结构经历了多次大的调整。调整之后,稳定使用较长的有两个节点,第一次是全部模块插件化之后,工程结构由一个大的 Git 库改造成了 submodule 的形式;第二次是在使用了较长时间 submodule 之后,遇到了不少问题,经过权衡,改为了 repo 的形式,一直用到现在,目前业务开发反馈良好。

微店工程结构的特点

微店工程插件化之后,工程结构呈现这样的形式:一个宿主 module,若干个子 module,最终打包的时候,需要先将子 module 打成一个二进制的文件,然后使用宿主 module 包裹子 module,将宿主 module 打成一个 APK,在运行的时候,动态地去读取二进制的子 module 文件。

submodule—踩坑之旅

通过对微店工程结构的描述,我们很容易想到使用 submodule 去管理微店工程。在 submodule 里,每个 module 都是一个 Git 库,各个 module 提交代码相互不干扰,同时一个主 module 管理若干个子 module,这样在同一个工程里,不同的 module 既是相互独立的,所有的 module 代码又可以存在同一个工程里。相互独立就意味着我们各个业务并行开发不受干扰,代码共存又让我们在调试的时候可以引用到其他 module 的代码。仿佛 submodule 就是为了并行开发量身定做的,于是我们花了很大力气将工程切到了 submodule 上,然而理想很美好,现实却是残酷的…

在 submodule 的主工程里,每个 module 在 Git 库里的表现形式是这个 module 的 name 加上一串数字和字母,这串数字和字母代表的是 Git 提交记录,我们知道每次 Git 提交都会有一个 sha 记录值,这个 sha 值记录了我们每一次的 Git 提交记录,通过它,我们可以进行克隆、回滚等操作。

举个例子,小王和小刘同时开发 message 这个模块,当前在主 module 里记录的 message 模块的 sha 值是 fc0282f7,也就是 message@fc0282f7 ,然后小王提交了代码,假设这次提交记录的 shaab8762f0,这时候小王主 module 管理的 message 版本就是 ab8762f0,但是主 module 的 remote 库里管理的 message 版本还是 fc0282f7,因此为了保持同步,我们在提交了 message 模块的更新之后,必须要把自己主 module 里的这个更新的 sha 值也提交上去。如果小王忘了提交自己主 module 里的 sha 值,这时候小刘也提交了 message 模块的代码,这时候主 module 的 sha 值就会落后两个版本,这样问题就产生了,如果这时候再更新主 module 代码,这个主 module 的 sha 值就会产生冲突,无法更新、无法回滚、无法合并(Android 组同事印象应该会很深刻)。

由此,可以看到,这些问题是由我们一些根深蒂固的使用习惯造成的,尤其是在多人开发的时候,很难要求每个人都对一个新的技能点能够完全掌握。而且,按照我们的初衷,业务开发只需要负责自己模块的代码就可以了,而 submodule 需要把所有模块的代码都下载到自己本地,并且显式地导入 IDE,这样 Gradle 的配置检查、同步就会很耗时。在使用了近半年之后,这种状况依然没有任何好转,我们只好寻求更好的解决方案。

当然,对于 submodule 的使用,如果能注意以下几点,也能用的很顺畅:

  1. 最最重要的一个原则,也是使用 Git 最基本的一个原则:先更新再提交,先更新再提交,先更新再提交;
  2. 尽量使用命令行终端,至少要学会基本的 Git 命令,不要太依赖 GUI 工具;
  3. 在子 module 里 push 了代码之后,一定要提交主 module 里对应的 sha 值;
  4. 在主 module 里 git pull 之后,一定要执行 git status,要确认 submodule 是否有修改,如有修改,需执行 git submodule update
  5. git submodule update 并不会将子 module 切到任何一个分支,而是一个游离状态的 HEAD state(这一点跟 repo 类似,后面也会讲到),因此在做任何子 module 的操作之前,一定要确认子 module 已经在需要操作的分支上;
  6. 假如说在游离的 HEAD state 下进行了代码修改,然后又提交了,这时候可以先切到需要提交的分支,然后再用 git cherry-pick xxx,再 push 即可;
  7. 请尽量使用 git submodule foreach 'pwd && git pull',这样的组合命令,单引号里的 git pull 可以换成其他的终端命令,而不仅仅是 git 命令(这也和 repo 里的类似)。

repo—重新上路

针对我们使用 submodule 过程中遇到的问题,我们希望能找到一种既能满足像 submodule 那样,各模块相互独立以便于开发,各模块的组织结构形式能方便调试,又能避免像 submodule 那样,模块提交代码对其他模块造成影响。于是我们又重新上路了。经过长时间的考察,我们发现 repo 正是这样一种方案。

repo 引入了一个新角色,这个新角色来管理所有的 module,而不是像 submodule 那样,建立一种主从关系,用主 module 管理子 module。在 repo 里,所有 module 都是平级关系,每个 module 的版本管理完全独立于任何其他 module,不会像 submodule 那样,提交了子 module 代码,也会对主 module 造成影响。

实际上,我们在使用过程中,还发现了另外一些好处:

  1. 剥离了主 module 和子 module 的关系,检出、同步、提交等操作都比 sumodule 要快了好多倍;
  2. 只需要检出需要开发的模块的代码,代码量减少了很多,正常情况下,本来要检出 30 多个模块代码,现在只需要一个模块代码,考虑到 Gradle 构建生命周期,整个构建时间在我们插件化改造之后又一次大大降低了;
  3. 模块管理配置由一个陌生的 .gitmodules 变成了所有人都更熟悉的 xml 文件,便于配置管理。

在 repo 真正的工程代码同一级,多出了一个 .repo 目录,默认情况下是隐藏目录,这里面存储了整个 repo 工程的信息。针对 repo,也有一些使用建议:

  1. 最好新建一个空目录,在这个新建的空目录里检出整个 repo 工程,保证 repo 工程与其他目录隔离开来;
  2. 正常情况下,我们没办法把 repo 的工程配置文件 default.xml 和软链接 manifest.xml 导入到 IDE 里,同步不同模块的代码就不太方便,可以新建一个 default.xml 的软链接放在代码工程里,这样就可以在 IDE 操作;
  3. 默认情况下只保留必备的几个 git 库,开发需要哪个模块,就将哪个模块 git 库解开注释;
  4. 请尽量使用 repo forall -c 'pwd && git pull' 这样的组合命令执行代码同步,这一点跟 submodule 类似;
  5. 类似 submodule,使用 repo 把代码下载下来之后,每个 module 同样是一个游离的 HEAD state,需要手动切换到 master 分支(或需要操作的分支),执行 repo forall -c git checkout master 即可,这里有一个坑,可能执行一遍并不能切换到主分支,执行完切换分支任务,需要 repo forall -c 'pwd && git branch' check 一下。

以后的工作

在工程模块化的今天,虽然我们已经踩了很多坑,做了很多优化,现在也逐渐适应了 repo 的工作方式,但是还存在如下一些问题:

  1. 一些先进的经验往往伴随着一些新技能的引入,如何让使团队里每个开发人员都尽快接受,这个是重中之重;
  2. 因为权限的问题,我们有非常多的教训,如何对团队里每个人的代码的权限和模块发布权限进行控制,这一点需要不断的完善;
  3. 非自己开发的模块,由源码依赖改为二进制包之后,如何保证调试效率,这是我们至今尚未完美解决的一个难题。

接下来,我们还会对现有的不断地改进,以期能够达到对日常业务开发更加友好的状态。