最近一直在折腾iOS组件化开发,起因其实是工程已经膨胀到让人没法忽视的地步:一个App里堆了上千个源文件,业务模块之间互相import,改一行公共代码,心里都要咯噔一下;git pull之后的编译时间更是“轻则五分钟,重则去倒咖啡”。组件化开发这个概念网上讲得很多,但真正落地的过程有不少细节是资料里不会写清楚的。这篇文章不是概念科普,而是把我这次组件化改造从规划、拆分、落地到踩坑的完整过程记录下来。适合那些团队规模在5人以上、业务模块越来越多、已经明显感觉到工程耦合或编译变慢的iOS开发者,也可以当一份可执行的改造自查清单来用。
1. 为什么做iOS组件化:我遇到的三类痛点
1.1 代码耦合到“不敢改”
最直观的痛点就是模块之间网状依赖。登录模块依赖用户模块,用户模块又依赖订单模块的某个数据结构,订单模块反过来还要用登录模块的工具方法。这种依赖在项目早期根本感觉不到,因为人少、功能少,但到了几十个页面、十几个业务线同时迭代的时候,完全就是灾难。
我印象很深的一次:想改一个底层网络库的超时时间,结果发现十几个模块都直接引用了网络层的内部类,每个模块里还有自己的一套处理逻辑。改完之后,整整花了两天时间验证各种边角场景。组件化之后,模块与模块之间只能通过对外接口通信,内部实现完全封闭,这种“牵连式改代码”的情况会大大减少。
1.2 编译时间慢到影响开发效率
第二个痛点是编译时间。工程里第三方库多、业务代码多、还有一堆没清理的资源文件,我用MacBook Pro跑一次全量编译经常要十几分钟,增量编译有时候也要两三分钟。一个业务分支切过来,光是等编译就浪费大量时间,更别提同时还要处理冲突。
组件化本身不能直接让代码编译变快,但它为二进制化铺了路。业务组件的源码可以打成静态库或者动态framework,壳工程直接使用二进制产物,避开了每次改动主工程都要重新编译所有源码的问题。后面我会专门讲这部分的实践细节。
1.3 团队协作的冲突与交付节奏
第三个痛点是git冲突。两个人同时改同一个文件几乎是常态,尤其是工程入口、公共配置、资源文件这种“路过必改”的内容。每次合并分支都要小心处理冲突,一不留神就把别人的改动覆盖掉。
组件化之后,每个组件是独立的git仓库,团队按组件隔离,大部分改动不再需要在同一个目录下交锋,合并冲突自然减少。同时每个组件都有自己的版本号,业务模块可以按自己的节奏发布,壳工程只要确定依赖哪个版本即可,发布流程比以前清晰很多。
1.4 不是所有项目都适合组件化
组件化不是银弹,我也不建议所有项目都一上来就拆。只有两三个开发、产品还处于快速试错期、App功能非常简单的团队,现阶段硬上组件化只会增加开发成本。
原因很简单:组件化意味着要维护私有Pod仓库、版本发布、组件间的通信中间层,这些对单人/小团队来说都是额外负担。我认为比较合适的切入时机是:业务模块开始出现明显依赖,或者团队成员超过5人,或者编译时间已经影响到日常开发效率。你在动手之前,可以先列一下自己团队的真实痛点,再决定要不要上这套体系。
2. 组件划分与架构分层:先画图,再动手
2.1 先有一张干净的依赖分层图
组件化改造第一步不是写代码,而是把现有工程的依赖关系理清楚。我们可以先用文字在白板上画一张分层图,从上到下依次是:
- 壳工程(App Target):负责注册所有组件、配置启动流程、处理 app 生命周期。
- 业务组件:按业务线拆分,比如登录、商品、订单、个人中心。
- 基础业务组件:不包含具体页面,但带有业务属性,比如账号服务、支付引擎。
- 基础技术组件:网络库、存储、日志、埋点、路由中间件。
- 第三方基础库:AFNetworking、SDWebImage、Masonry这类第三方依赖。
关键原则是:依赖方向必须从上向下,上层可以依赖下层,下层绝对不可以反向依赖上层。同一层之间也尽量不要互相依赖,如果有公共部分,就再沉淀到更下面一层。
2.2 业务组件怎么切才合理
很多人纠结“组件到底拆多细”。我的判断标准很简单:看是否存在一个明确的业务边界和责任人。电商项目就按商品、订单、购物车、用户这种业务线拆;工具类App就按功能域拆,比如账号、消息、设置。一个业务组件至少要包含页面跳转入口、对外服务接口、数据模型、网络请求这四类内容。
切记不要拆成“一个页面一个组件”,那是过度设计。粒度太细会导致组件数量爆炸、通信成本剧增。组件化的目的是降低复杂度,而不是增加复杂度。
2.3 基础组件怎么沉淀
基础组件是业务组件能够稳定运行的底座。哪怕业务组件还没开始拆,我也会先把基础技术组件整理出来,因为它们基本没有业务语义,可以独立编译和测试。
基础组件一般包括:
- 网络层:对URLSession或第三方网络库的二次封装
- 存储层:Keychain、数据库、UserDefaults的封装
- 工具层:日期处理、字符串处理、系统能力封装
- 基础UI:颜色、字体、通用UI控件
- 中间件:路由、通信、组件注册
基础组件的版本迭代要非常克制,不要频繁变更API。一旦发布一个稳定版本,所有上层组件都依赖它,轻易改动会造成连锁反应。
2.4 对依赖关系做一次“体检”
画完分层图之后,接下来要识别出当前代码里的循环依赖。常见的做法是用脚本扫描所有源文件的import或include,再生成依赖关系图,人工检查“A依赖B、B又依赖A”的环。
我当时的做法比较原始但有效:把所有.h文件的import按模块归类,然后用Graphviz工具画成一张图,凡是看到箭头形成环的,就标记出来重点处理。处理循环依赖的办法,一般有两种:把两个模块真正公用的代码下沉到更低层组件;或者用协议把依赖方向反转。比如A需要一个网络接口,B实现了这个接口,那么让A依赖“接口定义”而不是依赖B的具体实现,环就解开了。
3. 组件化开发的核心工具链:CocoaPods、SPM与二进制
3.1 CocoaPods是组件化的事实标准
说到iOS组件化的基础设施,目前最成熟的还是CocoaPods。虽然Swift Package Manager已经越来越完善,但在私有库管理、组件版本发布、二进制集成这几个方面,CocoaPods仍然有不可替代的优势。
用CocoaPods做组件化,核心是两套东西:私有Spec仓库和组件代码仓库。Spec仓库保存每个组件的podspec描述文件,组件仓库保存实际代码和资源。项目里通过Podfile声明依赖,pod install的时候会从Spec仓库解析版本,从代码仓库下载源码或二进制。
创建一个私有Spec仓库很简单,一行命令就行:
pod repo add MySpecs git@your-git-server:ios/MySpecs.git后续组件发布时,执行pod repo push MySpecs MyModule.podspec,CocoaPods会把描述文件推到Spec仓库,其他工程就能通过source字段找到它。
3.2 podspec关键字段解读
一个标准的podspec长这样,我用注释把关键字段讲清楚:
Pod::Spec.new do |s| s.name = 'OrderModule' s.version = '1.0.0' s.summary = '订单模块' s.homepage = 'https://your-git-server:ios/OrderModule' s.author = { 'Team' => 'team@example.com' } s.source = { :git => 'https://your-git-server:ios/OrderModule.git', :tag => s.version.to_s } s.ios.deployment_target = '12.0' s.swift_version = '5.0' s.source_files = 'OrderModule/Classes/**/*.{h,m,swift}' s.resource_bundles = { 'OrderModule' => ['OrderModule/Assets/**/*'] } s.dependency 'NetworkModule' s.dependency 'BaseUI' end这里有几个容易踩坑的点:
s.source里的tag必须和git tag一致,否则pod安装时会拉不到对应版本。source_files一定要用Classes/**/*这种递归写法,漏了子目录会导致部分文件没编译。- 资源要用
resource_bundles而不是resources,因为前者会为每个组件单独生成bundle,避免资源名冲突。 dependency要写清楚,组件依赖谁就是谁,不能隐式依赖。
3.3 SPM与CocoaPods,我站谁?
Swift Package Manager是苹果官方工具,近几年发展很快。对纯Swift代码、公开依赖库来说,SPM确实好用;但涉及私有库、组件二进制化、非源码集成时,SPM的私有源方案还需要搭配Git tag和本地路径依赖,体验不如CocoaPods顺滑。
我给一个自己的选型判断:
| 场景 | 推荐方案 |
|---|---|
| 团队已经重度使用CocoaPods | 继续用CocoaPods,别折腾 |
| 新工程、纯Swift、依赖公开库 | 可以SPM |
| 需要私有业务组件 | 优先CocoaPods |
| 需要二进制化教研 | 优先CocoaPods + vendored_frameworks |
说白了,CocoaPods现在依然是iOS企业级组件化最稳的选择。SPM可以作为未来趋势去关注,但不要因为它“官方”就盲目切换,工具稳定比炫技重要。
3.4 二进制化:让编译时间从十几分钟降到几分钟
组件化之后如果还是全部源码集成,每次pod install依然会把所有组件的源码编译一遍,编译时间改善有限。所以我会在组件稳定后做二进制化:把每个组件的编译产物(静态库或framework)提交到组件仓库,podspec里用vendored_frameworks描述,壳工程就不需要再编译组件源码。
二进制化的简单思路是:在CI(持续集成)上,组件代码合入主干并打好标签后,自动执行xcodebuild build,生成OrderModule.framework,然后上传到组件版本目录或者组件仓库的Binary目录。最后更新podspec:
s.source_files = [] s.vendored_frameworks = 'Binary/OrderModule/1.0.0/OrderModule.framework'这时壳工程依赖OrderModule,会直接从本地或私有源拉取framework,不再编译源码,编译速度自然提升。
当然,二进制化对日常调试不友好。我一般会让Podfile支持一个环境变量,比如USE_BINARY:
USE_BINARY=1 pod install # 用二进制,跑Release包 USE_BINARY=0 pod install # 用源码,方便Debug打断点这样既能享受快速编译,又不会把调试体验牺牲掉。
4. 组件间通信:路由、Target-Action与协议注册
4.1 通信问题的本质
组件拆完之后,原本可以在一个工程里随便import的页面和对象被隔离在不同Pod里了,但业务上仍然需要“从用户模块跳转到订单详情”这种操作。组件间通信要解决的就是:如何在一个组件不直接依赖另一个组件的情况下,调用它的能力。
通信方案有很多,iOS生态里主流的有三种:URL路由、Target-Action、协议注册表。每种都有优缺点,我建议按场景混用。
4.2 URL路由的落地细节
URL路由是最早普及的方案,代表组件有MGJRouter、JLRoutes等。核心思想是:每个页面定义一个URL,组件启动时注册这个URL对应的处理闭包,其他组件跳转时直接打开URL。
我实践中的代码如下:
// 订单组件内部注册 [[Router shared] registerURLPattern:@"app://order/detail" handler:^(NSDictionary *params) { NSString *orderId = params[@"orderId"]; UINavigationController *nav = params[@"nav"]; OrderDetailViewController *vc = [[OrderDetailViewController alloc] initWithOrderId:orderId]; [nav pushViewController:vc animated:YES]; }]; // 任何组件内部跳转 [[Router shared] openURL:@"app://order/detail?orderId=123"];URL路由的好处是跨模块解耦非常彻底,字符串URL可以远程下发,适合配合Push/分享场景。但问题也很明显:URL里的参数是字符串字典,编译期不检查,一旦字段拼错,只有运行到那一步才能发现。对于业务参数传递,我建议在封装层处理成强类型的模型,避免裸字典到处传。
4.3 协议注册表(Protocol-Instances)
协议注册表是近些年更受推荐的做法。组件A定义一个协议,组件B实现并注册这个协议,其他组件通过注册表拿到实现实例后调用,完全不依赖B。
Swift示例:
// 定义协议,一般放在一个中间件或基础组件里 protocol OrderServicing: AnyObject { func orderDetailVC(orderId: String) -> UIViewController } // 订单组件内实现 final class OrderService: OrderServicing { func orderDetailVC(orderId: String) -> UIViewController { return OrderDetailViewController(orderId: orderId) } } // 组件启动时注册 ServiceManager.register(OrderServicing.self, instance: OrderService()) // 任何地方消费 let service = ServiceManager.service(OrderServicing.self) navigationController?.pushViewController(service.orderDetailVC(orderId: "123"), animated: true)协议注册表的优点是类型安全,编译器能帮我们检查方法签名和参数类型,重构的时候更好追踪;缺点是需要一个全局的ServiceManager中间件,并且要防止注册冲突。这个方案非常适合业务服务调用,比如“获取用户信息”“调起支付”。
4.4 Target-Action路由
Target-Action方案由Casa Taloyum推广,核心是用字符串拼接target和action,然后通过NSClassFromString和performSelector动态执行。因为完全不需要在编译期依赖目标类,所以解耦程度也很高。
不过它的动态特性意味着没有任何编译期检查,参数只能靠字典传递,使用起来很复杂。除非是接手老项目,否则我不会在新代码里推广它。作为参考可以了解,但不必作为第一选择。
4.5 我的混合方案
经过这次改造,我最终采用的是:页面跳转统一走URL路由,能力调用走协议注册表,Target-Action只用来兼容旧的调用链。
原因很简单:URL路由适合“页面跳转”这种无状态预期,协议注册表适合“获取某个服务实现”这种有返回值的调用。把两者混用之后,新代码里没有再出现组件之间直接import的具体类,耦合度控制得很好。
5. 从单体工程到组件工程的迁移实操
5.1 第一步:搭建私有Spec仓库与壳工程
无论你用GitLab还是其他代码托管,先把私有Spec仓库建好。这一步非常关键,相当于给所有组件建了一个“货架”。
pod repo add MySpecs git@your-git-server:ios/MySpecs.git然后,把现有工程复制一份作为壳工程。壳工程只保留App入口、启动逻辑、第三方依赖和组件依赖声明,其他业务代码逐步迁走。为了不影响线上发版,我建议不要急着改名字,而是在原工程基础上做减法,保证主干随时可编译。
5.2 第二步:从基础组件开始逐层抽取
组件拆分一定要遵循依赖顺序,先从底往上拆。比如先把网络层、存储层、基础UI层拆成独立Pod,保证这些组件不依赖任何业务模块。
当时我先抽了NetworkModule,做法是:
- 在GitLab上创建
NetworkModule.git仓库。 - 将网络层相关源文件移动到仓库目录。
- 对外暴露统一接口,内部实现全部隐藏。
- 执行
pod lib lint验证podspec合法。 - 打tag并执行
pod repo push MySpecs NetworkModule.podspec发布0.1.0版本。
一次只抽一个组件,抽完立刻改壳工程的import,编译通过后再抽下一个。不要想着一个晚上把所有基础组件全拆完,那样工程会长时间处于不可编译状态,团队压力很大。
5.3 第三步:业务模块按“页面-服务-依赖”三件套拆分
基础组件稳定后,开始拆业务模块。业务模块比基础组件复杂,因为里面既有页面,又有对外服务,还有对下层组件的依赖。
我拆业务模块时的固定动作是:
- 在GitLab建
OrderModule.git仓库。 - 把订单模块的业务文件全部移进去。
- 在模块内实现订单对外服务协议,并在启动时注册。
- 把订单页面依赖的用户信息等数据,改为通过协议从账号组件获取。
- 处理资源文件,放进组件的
resource_bundles。
拆出来的订单组件需要依赖网络层、基础UI等基础组件,但它不需要依赖登录组件,更不需要依赖首页、商品这些同级业务组件。如果某个业务组件发现必须依赖另一个业务组件,说明边界划得不对,要把公共部分继续下沉。
5.4 第四步:版本管理与发布节奏
组件化改造后,所有组件都有了独立版本号。壳工程Podfile可以这样锁定依赖:
platform :ios, '12.0' source 'https://github.com/CocoaPods/Specs.git' source 'git@your-git-server:ios/MySpecs.git' target 'DemoApp' do pod 'NetworkModule', '~> 1.0.0' pod 'UserModule', '~> 2.1.0' pod 'OrderModule', '~> 1.2.0' end~>表示允许补丁版本更新,但不跨次版本。组件版本管理遵循语义化版本:破坏性API用大版本,新增功能用次版本,修复bug用补丁版本。即使不同版本同时存在,CocoaPods也会通过依赖规则自动解析。
CI也要配置好,组件仓库打tag后,触发自动构建、自动lint、自动推送Spec Repo。没有这套自动化,靠手动打tag+publish很容易漏。
5.5 第五步:逐步替换和灰度
迁移不用一步到位。壳工程里那些还没拆的旧代码,可以暂时留在“待迁移”目录;新业务需求直接在新组件或路由方案里做,老页面慢慢改。整个过程可以持续几个迭代,只要方向明确,慢一点没关系。
6. 组件化过程中不得不踩的坑
6.1 循环依赖:一开始就要禁止
循环依赖在单体工程里可能不明显,但拆成独立Pod后,A依赖B、B依赖A,pod install时就会直接报错,即使不报错,运行时也很容易崩溃。
处理方式只有一个:把循环依赖的部分往下拆。比如A和B都依赖同一份用户模型,就把用户模型放到CoreModel组件;如果只是方法调用循环,就定义协议,由上层组合。坚持“下层不知道上层存在”的原则,循环依赖就会消失。
6.2 资源文件冲突:图片、XIB、Assets的归属
这个问题几乎每个组件化团队都会遇到。多个组件里有同名的图片或者XIB,打包时同名文件会被随机覆盖,结果就是“图片显示不对”之类的诡异bug。
解决办法是使用resource_bundles,并为每个组件设置独立前缀。同时所有组件内部资源都要通过对应bundle读取:
NSBundle *bundle = [NSBundle bundleWithURL:[[NSBundle bundleForClass:self.class] URLForResource:@"OrderModule" withExtension:@"bundle"]]; UIImage *image = [UIImage imageNamed:@"order_placeholder" inBundle:bundle compatibleWithTraitCollection:nil];这段代码看起来繁琐,但很有必要。你永远不知道你的图片资源会在哪个组件里重名。
6.3 组件并行开发的Merge泥潭
组件独立仓库能减少不同模块之间的冲突,但同一组件内多人并行修改还是会冲突。解决办法是给每个组件设置负责人(Code Owner),其他成员改这个组件时必须通过负责人review。组件之间不要互相改代码,有需求就通过协议和路由申请。
团队约定比技术方案更重要。我见过不少团队把组件化当“目录整理”,结果拆完之后,业务A照样直接改业务B的代码,耦合又悄悄回来了。所以制度上要守住底线。
6.4 动态库与静态库的混用问题
CocoaPods默认使用静态库集成。当你改用动态framework后,要注意主工程里同一个类可能被重复编译进多个二进制,运行时会暴露“Class XXX is implemented in both”的警告。如果使用静态库,则在链接阶段排查重复符号更困难。
我的建议是:最终App统一采用静态库方式;如果真需要动态库,只针对极少的热更新/插件场景使用,并确保所有组件没有重复符号。否则线上出现莫名其妙的crash,排查起来非常痛苦。
6.5 Debug体验变差
组件二进制化会带来一个很实际的麻烦:断点打不进去,日志看不到源码。为了调试,需要在Debug配置下使用源码版本。我在Podfile里用一个环境变量控制:
if ENV['USE_BINARY'] == '1' pod 'OrderModule', :path => './LocalBinPods/OrderModule' else pod 'OrderModule', :git => 'git@your-git-server:ios/OrderModule.git', :tag => '1.2.0' end每次切源码/二进制,执行一次pod install,其实成本很低。我强烈建议把这种脚本封装成一条命令,否则团队会有人因为嫌麻烦直接跑二进制包调试,届时拿不到源码日志,排查问题效率直线下降。
6.6 工具链不熟导致内耗
组件化过程中,团队成员要习惯Git打tag、pod lib lint、pod repo push这些操作。第一次接触的人很容易漏掉某个步骤,尤其是忘记打tag就push spec,导致别人安装时找不到对应版本。
我整理了一份内部文档,把每一次发布组件的步骤用脚本固化下来,尽量让团队成员少接触原始命令。另外,CI上加了lint校验,git tag和podspec版本自动关联,漏掉的概率就小多了。
最后想说的话
组件化改造如果只看架构图,会觉得一切都挺简单,但真正动手时,你会碰到各种工程历史包袱和团队协作问题。我个人在实际改造中最看重的一点是:拆分节奏宁可慢,也不要有一次合并让主干编译失败。只要主干稳定,团队对这套体系的信任就不会崩塌。如果你正在考虑要不要做iOS组件化开发,建议先从压缩编译时间和解决最痛的一处线上bug入手,让收益能被所有人看见,再逐步推进其他模块。毕竟组件化本身不是目的,让团队开发更顺畅、让工程质量更可控,才是我们真正想要的东西。