一般来讲, 总是会有一些内部私有代码是有复用价值的, 比如一些Utils方法, 一些公用的UI组件, 一些对各种框架或者库的封装. 本文介绍几种复用的方式, 分别是使用多target, 使用workspace集成多个project, 以及利用cocoapods来集成私有代码. 每种方式都各有利弊, 没有最好, 只有合适.

假设我们已经知道哪些代码是公用的, 并且已经拆分完毕, 现在我们有多个逻辑模块, 一个是Common表示公用的代码, 其他是A, B等, 表示依赖Common的模块. 为了方便, Common简写为C, 而且假设上层模块只有AB, 公用代码只有一个C.

稍微有点儿样子的工程, C一般都是有第三库依赖的, 也就是说A, B, C都有各自的第三方库依赖, 如何处理依赖冲突也是需要考虑的.

一个repo管理

一个project多target, 和一个workspace多project没有太大区别, 这里讨论放在一个repo里面管理的情况. 多个repo的情况就需要用git submodule, 更新比较麻烦, 后文再谈.

多target

XCode支持在一个project中创建多个target, 我们可以把C的代码放置到targetC中, AB的代码分别放置到targetAtargetB中. targetC配置为输出framework或者static lib, 而AB都链接targetC.

多project

Xcode也提供了多个工程整合的方式, 即使用workspace. 我们把3个模块的代码放置到3个不同的工程, 然后都添加到同一个workspace中, C仍然输出framework或者static lib, AB需要动点儿手脚才能正确添加C.framework作为其embed framework.

估计是xcode的bug, 添加embed framework时路径是绝对路径, 不过可以直接编辑工程文件来修改, 也可以绕一下, 让C编译完成后copy包到某一个相对路径, 比如C的工程目录下, 然后再引入此包. 这样不管什么scheme编译完成都会直接覆盖此包, 保持最新. AB还需要配置一下scheme来保证每次build前都要先build C.

分析

此种方法最简单, 更新代码的时候非常方便, C中开发了新代码, 可单元测试, 也可立即和A或者B联调, 因为它们在一个repo里面, 所有改动都是立即可见的. 这一点对代码持续演进和集成非常友好.

C有自己的第三库依赖, 我们一般采用cocoapods管理, 这里比较特殊的一点是C自己也是个lib, 当我们用framework时不能在C.framework中嵌套embed的framework, 可以观察targetC的build phase, 并没有Embed Pods Frameworks. 就算我们手动加上, 也就模拟器能正常, build到设备上依然要挂, 提示no suitable image found, 然后巴拉巴拉. 所以我们需要考虑解决依赖的问题. 目前cocoapods已经发布1.0版本, 我的解决办法是使用abstract_target:

abstract_target 'common' do
    pod 'Alamofire'
    pod 'RxSwift' 

    target 'A' do
      workspace 'my.workspace'
      project 'A/A.xcodeproj'
      pod 'RxCocoa'
      pod 'XAutoLayout'
    end

    target 'B' do
      workspace 'my.workspace'
      project 'B/B.xcodeproj'
      pod 'Socket.IO-Client-Swift'
      pod 'XAutoLayout'
    end

    target 'C' do
      workspace 'my.workspace'
      project 'C/C.xcodeproj'
    end
end

common作为parent target并且是个抽象的, 也就是说不会真正有这么个target存在, 但是parent的身份让内部的A, BC这几个target都继承pod配置. 因此, 这里common描写C需要的库, 而不是在C那里单独配置, 否则A将不能正确处理C的依赖.

这里还涉及到依赖的冲突, A依赖的RxCocoa依赖了RxSwift, common里面也描述了依赖RxSwift, 继承后只要pod能正确处理冲突就没什么问题.

最终编译出来的C.framework只会包含C这个模块的代码, 依赖的第三方库会在A或者B引入.

如果是使用static lib, 那么C.a就会包含第三方库的符号表, 再以此集成到其他target, 这样就不能用abstract_target, 而应该小心地避免符号表冲突. 而当遇到类似A依赖的RxCocoa需要依赖RxSwift, 而C也要依赖RxSwift时, 就只能把RxCocoa作为C的依赖来处理, 否则RxSwift将出现符号表冲突. 所以还是用framework吧.

开发流程

大家都在一个repo, 原则上我们提交一个patch需要保证每个模块都正常编译, 因此当C有改动要提交, 我们需要所有模块上都能测试通过. 当A要开发新需求, 我们创建一个devA的branch, 然后AC都有改动, 甚至一些C的改动还导致B也需要改动, 虽然这个时候并不是为了开发B. B也有devB在开发, 改动C时也有可能导致A被改动.

合并需要及时, 如果devA开发完毕了应尽早合并回主分支, 然后再继续从主分支合并到devB, 此时需要解决一些冲突. 这些冲突里面就会包含在devAdevB中对AB进行的不同的修改, 哪怕只是当时为了编译通过的修改都有可能和另一个分支冲突.

如果在同一个dev分支上进行AB的开发, 只要任何时候有实验性质的feature要尝试, 就需要多个分支, 实际上现实生活中不可能只有一个dev分支的, 因此上述合并分支的问题总是需要考虑的.

导致冲突增加, 合并复杂的原因主要是3个模块在一个repo相互不独立, 提交patch时是同时影响多个模块, 而且我们要贯彻提交patch保证编译通过的原则, 因此一些可以滞后的合并和修改就被提前了, 并且这种提前并不总是有效的.

如果不贯彻编译通过再提交的原则将引起更多的问题, 这个就不用多解释了.

多个repo管理

如果将这些模块分散到多个repo, 各自提交patch, 则需要一些手段才能将他们相互关联起来. 这里讨论使用cocoapods和git submodule. 使用cocoapods又分为两种用法, 分别是用私有spec repo来记录C的各个tag版本, 以及直接指定C的branch和commit.

私有spec repo + tag发版

如果C比较独立, 真的不涉及多少业务逻辑, 变化频率不高, 和A B的联调需求很小, 我觉得可以考虑让C单独开发, 并在需要的时候发版. 不用完全跟着A或者B的节奏走.

第一次创建的流程如下:

  1. C要先写好一个podspec文件, 描述了自己如何被集成, 并指定了source为某一个tag号.
  2. 发布C到私有spec repo中.
  3. 所有team member更新C.

之后更新C的时候只需给新的code打tag, 然后重复步骤2和3就好了. pod在install或者update的时候都会check缓存, 如果更新code后不更新tag将会使用cache的代码, 新代码是下不来的. 当然也可以把原来的tag删掉, 重新打一样的, 然后删掉pod的cache再update, 不过这看起来很无趣.

如果一切都像想的那么好就真的太好了. 我们肯定会遇到C的更新导致AB需要修改的情况, 如何联调测试? 他们都不在一个repo了, 所以我们需要都下载下来, 但是问题是C要发版才能被pod更新, 因此我们就需要打很多无用的tag来不停地循环:提交代码 -> 发布C -> 更新AB -> 调试 -> 修改代码 -> 提交代码. 终于调试完毕了, 然后删掉那一堆没用的tag.

除非用一个其他方式来在开发C的电脑上集成AB, 如何考虑到要分散到各个repo的话, 估计就只能用submodule+workspace集成了. 这个后文再谈.

直接指定branch + commit

pod支持直接指定这两者来下载code, 所以我们只要保证C的repo能访问就可以了, 不需要创建私有spec repo. 这种方式的branch + commit 的作用跟tag一样, 目的都是为了指向一个不可变的代码集. 集成C也很简单, 只要C的repo先准备好, 并且repo里面有podspec文件.

这里有个小限制, podspec里面指定的source一定要能访问, 否则pod检查过不了, 但是然并卵, 我们在podfile里面指定了branch和commit, pod就会从我们指定的位置下载repo, 读取podspec并忽略掉source配置.

C更新代码的时候, 如果AB需要跟进, 则更新一下branch和commit就好, 如果忽略掉commit, 将默认是HEAD. 我们可以利用这点来简化开发流程.

我们采用这种方式是考虑到C的更新驱动AB更新时, 使用tag方式带来的各种不爽. 假设基本配置都已经完成, 进入了一个稳定状态. 之后的开发流程如下:

  1. A新建branch devA进行开发, 由于需要改动C, C也创建了branch devC.
  2. 联调的次数比较多, 在devA上将podfile中指向的C去掉commit, 仅保留branch信息指向devC, 这样默认就是使用devC的最新代码, 然后每次改动C以后就update一下, A就能获取到最新的代码.
  3. 开发完毕后, C先合并回主分支, 并记录下commit, 然后A再合并, 并在podfile中修改branch为C的主分支以及添加commit信息.
  4. 如果处于2状态的时候B也要开发, 可以让C从最开始的稳定状态再branch一个分支devC2, 也如2那般开发.
  5. A合并完成后, 需要将C的主分支再合并到devC2以跟进最新改动.
  6. 步骤4中也可不创建新分支, 而直接引用devC进行开发.

这里着重讨论一下4和6的问题. 如果AB引用同一个C的dev分支, 就能够非常及时地用上新code并且不用重新开发, 不过这又会导致AB互相推着对方走. 如果引用不同的分支, 则devC上开发的新功能想要在devC2上用, 就需要merge或者cherry-pick一下, 但如果这样做会引入新问题的话, 则只能重新实现一下了.

引用不同的C的分支, 则整个结构和用workspace的方案很像, 分支的管理也基本一样. 区别就是workspace方案不能单独让C的代码回退, 换个说法就是没办法指定使用某个版本的C, 因为大家的patch是交叠在一起的, 回退某一个模块几乎是不可能的.

依赖的解决比较简单, podspec中描述好依赖就好, pod会自动解决.

git submodule

A的repo用submodule引用C, B也一样. 然后用子工程或者workspace来集成AC的工程. 这样做并没有解决太多问题, 因为用submodule, C的更新每次都需要A或者B提交一个跟进的patch, 切换分支和更新C什么的也挺麻烦. 而且还需要多考虑一个依赖的问题.

如果不用pod来集成C, 那么C的依赖怎么添加到A中? 手动解决将十分麻烦了. 仅剩的一个方案就是用development Pod, 让podfile里面的C使用path指向submodule的路径, 这样仍然需要写一个podspec文件来描述C的集成. pod在引用development pod时有点儿tricky, 它没有copy而是引用, 因此当更新C里面的代码时是立即可以用的, 不需要update. 只有当创建或者删除了文件时才需要update一下. 另外因为是用submodule, 每次C更新后都需要尽快到A中提交一个patch以记录C的更新, 还有一系列submodule的坑等在后面, 就不多说了. 总之是不推荐使用submodule.

总结

一张表概述一下:

方案 优点 缺点 建议
targets & workspace 代码更新和调试友好 所有模块共进退 适合模块数量少, 更新较频繁
private pod using tags 版本控制良好 代码更新和调试较麻烦 适合模块数量多, 更新不那么频繁
private pod using branch & commit 版本控制良好 代码更新和调试还能忍受 适合模块数量多, 更新频率一般

文中的规模是很容扩展的, 比如有多个上层模块和多个公共模块, 需要思考的点都是一样的:

  1. 如何集成
  2. 如何开发和联调
  3. 如何将公共模块的改动恰当地同步到各个上层模块

当然, 如果模块数量很少, 代码量也不大, 模块共进退在项目早期也是可以接受的, 采用上述的各种方案都是可以很方便地迁移到其他方案的. 还有一点, 毕竟公共代码存在的目的就是为了被公用, 如果不是所有代码都公用那公共代码为什么还叫公共代码? 所以大家在考虑如何选择复用方案的时候一定不要忘了这点, 不要把问题想得过于复杂.

本文主要是记录一下我自己的思考过程, 总结不到位或者有错的地方还请见谅.