ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

iOS组件化开发:拆分方案、通信机制与编译优化实践

iOS组件化开发:拆分方案、通信机制与编译优化实践 听“组件化开发”这个标题在 iOS 圈子里已经快被聊烂了但每次团队真正动手做的时候还是会有一堆人把它简单理解成“搭个私有 Pod 仓库”、“搞一套路由跳转”。我在经历了好几个项目的组件化改造之后越来越觉得组件化本质上不是技术问题而是工程治理问题——它是把一个越来越难维护的巨型工程重新切分成一个个可以独立演进、独立验证、独立交付的小工程。这篇文章我就围绕这个思路聊聊拆分方案、通信机制、编译优化这些核心环节以及我们在落地过程中趟过的坑。如果你所在的项目已经大到编译十分钟往上、每次合并代码都要解决一堆冲突那这篇内容应该能给你一个相对完整的地图。1. 先搞清楚组件化到底解决什么问题1.1 大型 iOS 工程的三大痛点先说痛点。很多团队做组件化是被逼上梁山的而不是为了追技术潮流。我在团队里听到最多的抱怨基本集中在三件事。第一是代码冲突。当一个仓库里同时有十几个人在改同一个project.pbxproj文件每次合并代码都在解决冲突甚至出现过把别人新建的 target 不小心删掉的状况。pbxproj本身是类 plist 格式的文本文件它记录的是工程文件树、编译配置、target 关系一旦多人同时添加文件冲突几乎是必然的而且手工修复这个文件非常容易出错。第二是编译时间。我们当时的项目单纯编译一个 release 包全量编译要十三到十五分钟。这个时间其实已经超过了大多数人的忍耐极限。开发同学改一行代码想要跑起来看效果光是等编译就够刷好几条短视频。组件化加上二进制化是除了换电脑之外最有效的“加速器”。第三是模块间耦合。业务模块之间没有边界OrderModule里的类可以直接调用UserModule的内部实现看起来很方便但后果是没人能说清楚这个工程的依赖关系到底是什么样的。想重构一个模块会担心“牵一发动全身”。代码规模上来之后迭代速度会肉眼可见地下降。所以组件化要解决的问题总结起来就三个并行开发效率、代码复用率、构建速度。后面的所有方案、所有选型都是围绕这三个目标展开的不是为了拆而拆。1.2 组件边界的划分逻辑业务组件与基础组件组件边界怎么划分是组件化里争论最多的问题。我的建议是不要按 UI 界面去划分不要按页面跳转关系去划分要按“业务生命周期”去划分。什么意思一个订单从创建、支付、到售后这一整条链路可能涉及多个页面但它们属于同一个业务闭环应该优先放进同一个业务组件。相反购物车里的商品卡片和推荐流里的商品卡片虽然长得很像但它们服务的是不同业务不应该为了复用 UI 而强行拆成一个组件。整体分层我习惯用三层模型来理解。基础组件层是跟业务无关的底层能力比如网络库、存储封装、日志系统、UI 基础控件、工具类。这一层的特点是稳定、通用、不依赖任何业务模块是所有组件的地基。中间服务组件层是面向业务的公共服务比如账号服务、支付服务、埋点服务。它们有明确的业务领域边界但又不属于某个具体业务线被多个上层业务组件复用。业务组件层是真正承载业务迭代的组件比如首页、购物车、订单、个人中心。这一层的变化最频繁也是组件化之后收益最明显的地方因为不同小组可以各自维护自己的业务组件互不阻塞。我见过不少团队在拆分的时候喜欢把“看似可以复用的代码”全部抽到一个通用组件里结果这个通用组件慢慢变成了一个“垃圾回收站”什么跟业务沾点边的代码都往里塞。到最后这个组件的依赖比主工程还复杂谁都不敢动它。组件划分的原则应该是先按业务目标拆再按复用价值抽。一个代码如果只有一个业务在使用哪怕它很“通用”也先留在业务组件里等出现第二个使用者再下沉。2. 技术底座选型CocoaPods 私有库是主流玩法2.1 为什么选 CocoaPods 而不是其他方案组件化的技术底座有很多种最简单的方案是直接把代码文件夹分好路由和协议层面的解耦依然沿用老一套再往上走就是维护多个 Xcode 工程用 git submodule 集成目前行业里最主流、最好落地的是 CocoaPods 私有库方案。为什么不推荐 git submodule因为它解决的是“代码依赖”问题而不是“编译管理”问题。子模块拉下来之后还要手动配置工程引用关系更新策略也不够优雅。多个 Xcode 工程之间的配置同步稍不注意就出现Missing required module这种问题排查成本很高。而且 submodule 不会自动处理依赖传递你拉了一个子模块还得手动去拉它的依赖子模块这在组件多的时候根本没法维护。CocoaPods 的优势在于它把“依赖声明、依赖拉取、工程配置、版本管理”都标准化了。你用pod AccountModule, 0.2.1声明依赖CocoaPods 会自动帮你创建Workspace、配置 target 依赖、设置 header search path。更重要的是它天然支持私有 Spec Repo也就是一套只在公司内部使用的组件版本索引库。组件的版本发布、升级都通过这套机制管理团队协作成本最低。其实还有 Carthage 和 Swift Package Manager 可以选择。Carthage 的核心理念是“不侵入工程文件”但它编译和链接的自动化程度弱一些SPM 现在生态已经比以前好很多但如果你是混合开发、还有大量第三方库依赖SPM 在部分场景下依然没有 CocoaPods 那么顺手。所以从落地成本、团队熟悉度、生态成熟度来看CocoaPods 私有库仍然是组件化改造里最稳的选择。我之前甚至见过一个团队用 Swift Package Manager 做了组件化结果改造到一半因为公司网络和 SPM 的二进制缓存问题不得不回退非常痛苦。2.2 私有 Spec Repo 与 podspec 规范用 CocoaPods 做组件化需要两个东西一个是组件代码仓库比如gitgitlab.example.com:ios/AccountModule.git这个仓库存的是真正的组件代码和podspec文件另一个是私有 Spec 索引仓库比如gitgitlab.example.com:ios/PrivateSpecs.git这个仓库存的是所有组件的.podspec文件索引。搭建过程很简单一条命令注册本地私有索引仓库pod repo add PrivateSpecs gitgitlab.example.com:ios/PrivateSpecs.git以后组件发布新版本就把组件的.podspec文件推送到这个仓库pod repo push PrivateSpecs AccountModule.podspec --allow-warnings不过这里有个细节要提醒你pod repo push会在推送前对 podspec 做一次校验默认要求本地所有关联代码都能编译通过。如果你的组件里有一些编译告警不加--allow-warnings是推不上去的。但不要盲目用这个参数它会把真正的错误也一并忽略掉建议先在本地执行pod lib lint看完整输出。再说podspec文件的写法。一份规范的 podspec 至少要包含这几块Pod::Spec.new do |s| s.name AccountModule s.version 0.2.1 s.summary 账号业务模块 s.homepage https://gitlab.example.com/ios/AccountModule s.author { YourName youexample.com } s.source { :git gitgitlab.example.com:ios/AccountModule.git, :tag 0.2.1 } s.ios.deployment_target 11.0 s.source_files AccountModule/Classes/**/* s.resource_bundles { AccountModule [AccountModule/Assets/**/*] } s.dependency NetworkingModule, ~ 1.2.0 s.dependency XXXCategories end很多新手写 podspec 容易忽略两点。第一是resource_bundles。如果你组件里用到了图片、XIB、其他资源文件不把它们打进 bundle运行时就会出现“图片找不到”的诡异问题。用resource_bundles而不是resources是为了避免主工程把组件资源文件覆盖掉。第二是版本号规范。我建议用“主版本.次版本.补丁版本”三段式并且在 git tag 和 podspec version 之间保持一致。发版流程是先打 tag 推代码再执行pod repo push最后在宿主工程的 Podfile 里指定依赖版本范围。千万不要直接引用分支名比如pod AccountModule, :git ..., :branch develop。这样做的后果是组件一旦更新所有依赖它的工程在pod install时都会拉到最新代码版本完全不可控。关于目录结构每个组件仓库内部我习惯这样组织AccountModule/ ├── AccountModule.podspec ├── README.md └── AccountModule/ ├── Classes/ │ ├── API/ │ ├── Models/ │ ├── ViewControllers/ │ └── Views/ └── Assets/ ├── Images.xcassets └── AccountModule.bundle代码放在Classes下面按功能分子目录资源统一放Assets这样 podspec 的source_files和resource_bundles都很好配置后续接入二进制化也方便。3. 组件间通信方案路由、Target-Action 还是面向协议3.1 路由方案的核心价值与边界组件拆分之后最直接的问题就是组件 A 怎么跳转到组件 B 的页面很多团队第一反应是上路由也就是基于 URL 的集中式注册转发方案。路由方案的思路很简单每个组件在启动时向路由中心注册自己负责的 URL比如app://order/detail?id123其他组件需要跳转时只要调用路由中心的方法Router.open(app://order/detail?id123)。这样调用方不需要 import 目标页面的类组件之间的依赖就解开了。路由方案的最大优点是灵活URL 可以由服务端下发也可以由 Push 推送携带也可以跨 App 跳转基本上所有“外部拉起内部页面”的场景它都能覆盖。而且 URL 是字符串方便打点统计和日志排查。但它的缺点同样明显。URL 是弱类型契约参数以字典形式传递调用方写错了参数名编译期发现不了只能等运行时页面表现异常。而且如果把所有页面跳转都交给路由代码里会到处是app://xxx/yyy这样的魔法字符串重构起来非常痛苦如果你改名了某个 URL静态搜索甚至找不全所有调用点。路由表的维护也需要成本新页面忘注册会直接白屏。我自己的判断是路由适合用来解决“跨业务、偏外部触发”的跳转场景比如推送、H5 Bridge、短链这类场景天然需要一串 URL。但如果你只是想在一个纯 App 内部让模块 A 调用模块 B 的能力路由不是最优雅的方案。3.2 Target-Action 方案的运行时本质为了解决路由弱类型的问题中间出现了一批基于 Target-Action 模式的开源方案最典型的就是 CTMediator 以及类似思路的 MGJRouter。Target-Action 的核心思想是不要直接依赖目标类的类型而是通过运行时消息机制按照约定的“类名 方法名”去调用目标。调用方只依赖 Mediator 这个中间层不依赖具体业务组件。简化的示例是这样的// Mediator 核心逻辑 func perform(target: String, action: String, params: [String: Any]) - Any? { guard let className Bundle.main.infoDictionary?[CFBundleExecutable] as? String, let cls NSClassFromString(className . target) as? NSObject.Type else { return nil } let instance cls.init() let selector NSSelectorFromString(action) if instance.responds(to: selector) { return instance.perform(selector, with: params) } return nil }调用方传入组件目标的类名前缀和方法名Mediator 帮忙创建对象、调用方法大部分框架还会利用objc_msgSend实现方法动态转发甚至支持在方法被调用前统一拦截面板。某些权衡也比较聪明组件间的编译依赖被去掉了但运行时依然保留着直接调用的能力。它的优点是很明显的代码写起来比 URL 路由直接参数不再塞在 URL 里可以用原生对象。缺点也非常显著——这套方案太依赖“约定”了。Target 类名写错了、Action 方法名拼错了编译期完全不知道只有线上运行到这条路径时才会崩。而且它绕过了编译器的类型检查本质上是在利用 Objective-C 的运行时动态特性对 Swift 纯工程的兼容性和安全性都要打折扣。我并不是说 Target-Action 不能用很多公司都在用但如果你的团队以 Swift 为主我建议谨慎采用。Swift 会更强调类型安全和编译期检查这种纯运行时的写法维护成本会随着组件数量增加而上升。3.3 面向协议方案的今天第三种方案也是我自己现在比较推荐的一个方向面向协议的服务注册模式。它的思路是这样的把组件对外提供的能力抽象成一个协议定义在独立的“接口组件”里。业务组件实现这个协议并向服务中心注册。调用方只依赖“接口组件”里的协议不依赖业务组件的实现。举个例子。账号组件对外提供的能力包括获取登录状态、展示登录页、退出登录。我们定义一个协议// 定义在 AccountServiceInterface 组件中 public protocol AccountServiceProtocol: AnyObject { func isLogin() - Bool func showLoginPage(from viewController: UIViewController) func logout() }然后账号组件内部实现这个协议// AccountModule 实现协议并注册服务 public class AccountService: AccountServiceProtocol { public func isLogin() - Bool { ... } public func showLoginPage(from viewController: UIViewController) { ... } public func logout() { ... } }服务注册中心维护一个协议和实现的映射表ServiceRegistry.register(AccountServiceProtocol.self) { AccountService() }调用方获取服务的时候只需知道协议类型let accountService ServiceRegistry.service(AccountServiceProtocol.self) accountService?.showLoginPage(from: self)这个方案的好处是调用关系是类型安全的。协议作为编译期契约方法名、参数类型、返回值都受到编译器保护任何破坏约定的改动编译时就会报错不会拖到线上才暴露。而且它让组件的 API 边界变得非常清晰你只需要暴露该暴露的那几个协议方法内部实现细节全部隐藏。坏处也比较实际组件之间共享的数据模型如果也依赖协议传递需要给模型单独建仓库架构层级又多了一层。对于团队人员的代码能力和抽象能力要求更高。但说句实在话如果你都开始做组件化了团队的整体工程素养应该是可以达到这个水平的。三种方案我自己的选型经验是方案类型安全适用场景维护成本URL 路由弱外部跳转、H5 Bridge、Push中需要维护路由表Target-Action弱纯 Objective-C 老工程追求透明调用高依赖约定与代码规范面向协议强Swift 为主、长期演进低模型需要额外拆分如果再加一条组合拳的建议就是外层跳转场景用路由、内部服务调用用协议两种方案并存。只要约定清楚路由只负责页面跳转协议负责能力调用就不会乱。4. 编译时间优化组件化之后的二进制化改造4.1 为什么组件化要配合二进制化很多人以为组件化做完编译时间就一定变短。实际上纯源码形式的组件化如果只是把单工程拆成多个 Pod 库Pod install 之后所有源码依旧会被主工程整体编译编译时间基本没有变化。真正让编译变快的大招是二进制化。二进制化理解起来并不复杂。我们把每个组件事先编译成静态库或动态库放到组件仓库的某个目录里主工程依赖组件时不再去编译源码而是直接链接已经编译好的二进制。这样首次全量构建时依赖的组件不需要重新编译大幅缩短编译时间。举个例子。我们的宿主工程一共依赖了二十多个业务组件和四十多个基础组件没有做二进制化之前每次全量编译大约需要十五分钟。做了二进制化之后全量编译直接降到三分半钟左右。增量编译的效果更夸张因为二进制组件的头文件和产物没有变化时主工程只编译自己改动的部分很多改动场景下编译时间甚至能压到一分钟以内。这里涉及到一个很多人会问的点为什么不让组件之间永远使用二进制因为二进制包不利于调试你设置断点进不了组件源码看 Log 也没有源码线级。所以行业里常见的做法是“Debug 模式加载源码Release 模式加载二进制”。平时开发调试我们用源码便于定位问题出包和 CI 构建用二进制保证速度和稳定性。4.2 二进制化实施流程与注意事项二进制化的落地路径我们分了几步来走。第一步先用脚本把组件源码编译成 framework 或静态库并把产物统一放到组件仓库的Binary/目录下。这一步可以借助pod package命令也可以自己写脚本本质上就是xcodebuild build。我推荐用脚本固化因为组件发版和二进制产物要同步更新靠手工容易漏。第二步改造 Podfile让组件依赖支持源码/二进制切换。当时我们在 Podfile 里加了环境变量控制if ENV[USE_BINARY] pod AccountModule, :binary true else pod AccountModule, :path ../AccountModule end注意这个:binary true并不是系统原生语法需要配合 cocoapods-binary 这个插件使用或者在组件里配置条件依赖。我们当时用的是 cocoapods-binary它会自动把指定的 Pod 源码替换成预先编译好的 framework并在安装时自动修改工程配置。第三步处理二进制组件的资源文件。这块是个大坑。cocoapods-binary 默认把 podspec 里的resource_bundles打进 framework 里但如果你在代码里通过Bundle.main加载资源就会找不到。需要在代码里使用正确的 bundle 获取方式let bundle Bundle(for: AccountService.self) // 通过 bundle 加载资源图片 let image UIImage(named: icon_arrow_right, in: bundle, compatibleWith: nil)第四步统一签名、描述文件和链接参数。二进制组件需要跟着 App 一起签名一般来说 framework 在编译时要用-fembed-bitcode之类的方式保留必要的编译信息否则上架 App Store 时可能会遇到冲突。真机调试的时候还可能出现签名证书不一致导致的CodeSign error这个只能靠整理的脚本自动导入描述文件来解决。最后给一个实用建议二进制化不要一次性把所有组件都切过去太危险。先挑几个依赖面广、稳定少改的基础组件做比如网络库、工具库等这套流程跑通了再把业务组件逐个切过去。我们当时第一步只切了三个基础组件验证完编译效率和调试链路都没问题之后才逐步扩大范围。5. 组件化过程中的常见坑与避坑经验5.1 依赖关系失控“蜘蛛网”依赖组件化最理想的依赖形态是单向、分层的。基础层被上层依赖上层不能反过来依赖下层业务组件之间尽量平级不互相依赖。但实际推进过程中依赖关系非常容易失控。印象最深的一次是我们某个业务组件为了复用另一个业务组件的几个工具方法直接 dependency 了对方结果两个组件的版本被牢牢绑定在一起CD 发版时不得不每次都同时发布。后来另一个组件也想复用它于是又多了新的依赖边。到最后这个业务组件仓库的 podspec 里 dependency 写了七八个工程变成了一张蜘蛛网。要避免这种情况我建议在拆分的时候就立两条规矩。第一业务组件之间禁止直接依赖需要共享能力时走协议服务或者下沉到中间服务层。第二定期用工具扫描组件依赖关系我们当时自己写了一个脚本每天 CI 跑一次检查是否出现跨层依赖和循环依赖一旦发现直接挂红灯强制相关同学修正。不要觉得这样太严格依赖管理这种问题靠自觉根本维持不了。5.2 版本管理与发布联动第二个常见坑是版本号乱用。很多同学习惯引用:path或者:branch来本地调试调试完就忘记改回正式的 tag 版本然后把代码提交上去。别人pod install的时候可能会出现一模一样的代码在不同电脑上拉到的依赖版本不同导致各种“在我电脑上明明是好的”。我们的处理方案是本地调试允许用:path但代码提交前必须改回 tag 依赖把它作为 code review 的一个检查项。同时组件发布的版本号遵循语义化规范破坏性变更加主版本新增 API 加次版本修复 bug 加补丁版本。Podfile.lock 必须入库确保同一时间所有人安装的依赖版本一致。组件联动更新也是一个痛点。比如NetworkingModule从 1.0.0 升到 1.1.0所有依赖它的上层组件理论上最好也要跟着发一版否则接不上新能力。我们后面约定了一个原则上层组件在 podspec 里依赖基础组件时不要写死精确版本用~ 1.1.0这种兼容版本范围这样基础组件的兼容升级不会被阻塞同时也不会引入不兼容的大版本变更。5.3 组件拆分时机的判断还有一个常被问的问题新项目要不要一上来就组件化我的建议是不要。太新的项目业务模型还在快速变化你今天拆的边界可能下周就不成立了。强行组件化只会让团队陷入无休止的接口调整里。但有些信号一旦出现就该认真考虑组件化了。比如主工程编译时间突破十分钟多人协作时频繁发生代码冲突业务模块之间的循环 import 开始出现你发现自己不敢轻易改动某个模块因为不知道会影响谁。出现这些信号说明代码结构已经阻碍团队前进速度了组件化改造就是值得投入的投资。具体到改造顺序我们当时是先选一个独立的、完整的业务链路作为试点把它完整剥离成组件过程中把基础依赖一并抽出来跑通流程之后再逐步扩大。千万不要一上来就搞“天下大吉”把所有代码同时拆完那会造成一场持续数月的架构灾难业务方和团队都会对你失去信心。6. 组件化改造的落地路线规划6.1 自底向上的分层改造步骤组件化改造的落地路线我建议自底向上分四步走。第一步梳理代码依赖分清哪些是基础能力哪些是业务逻辑。方式是把工程里已有的工具类、Category、基础服务类全部列出来看它们是否被多个业务模块引用。如果只是某一个业务在用就先留在原地。第二步搭建私有 Spec Repo把基础组件逐个抽出来。这一步可以先不引入路由和服务注册因为它们本来就不依赖业务组件拆分风险最小。第三步启动中间服务组件的拆分。账号、支付、埋点这类的服务能力因为要被上层业务复用它们的接口设计在这一步就必须考虑清楚了。这里也正好用上前面说的面向协议的思路先定义好XxxServiceProtocol再让组件内部实现。第四步业务组件拆分与通信机制建设。这一步才正式引入路由和服务注册中心将业务组件之间的页面跳转改为路由调用将业务组件之间的能力调用改为协议调用。每一步拆分都要保证拆分完工程仍然能编译通过、核心流程仍然能跑起来。我见过有人一次性拆了七八个组件结果连编译都过不了回归了大半个月才修回来。组件化不是一次性的“大爆炸”它是一场持续的“管道改造”必须保证主管道始终在供水。6.2 改造完成后如何评估收益改造完成怎么量化收益不能只说“好像变得好维护了”要有数据支撑。我建议重点盯四个指标。第一是主工程全量编译时间我们是从十五分钟左右降到了三分半钟这个数据不要只测一次取连续一周的平均值因为机器负载、缓存状态会影响结果。第二是合并冲突次数拉取 Git 历史统计组件化前后两个月内冲突发生率正常情况下应该明显降低。第三是发版频率和交付周期组件化之后理想情况是每周可以发更多次迭代版本因为各业务小组的并行能力变强了。第四是线上崩溃率组件化过程中如果引入通信方案调整、资源加载方式变化有可能会引入新的崩溃要盯紧版本上线后的稳定性数据。如果这四个指标里三个都没有明显变化那你需要反思一下是不是拆分粒度有问题或者团队没有按照既定规范在推进。组件化不是一个“做完就完事”的动作它是一个持续演进的架构形态。解决掉一批问题必然会有新的问题冒出来比如组件数量膨胀、过度设计的问题但这些都比原来“一坨代码谁都改不动”的处境要好得多。最后再分享一个我个人一直在用的习惯每个组件仓库里我都建议放一份 README写清楚这个组件是什么、依赖了谁、谁在依赖它、发布版本的时候需要跑哪些命令。很多人觉得这没什么用但当你维护的 Pod 超过三十个的时候这份 README 就是帮你找回记忆的唯一线索。组件化的长期维护靠的不是某一个人的记忆力而是团队共同遵守的规范和文档沉淀。
返回列表