
iOS 组件化之使用 Cocoapods 创建本地 Pod做 iOS 组件化改造这段时间踩得最深、也最值得拿出来说的一个环节就是用 Cocoapods 搭一套本地 Pod 的开发调试环境。很多人一开始就把目标定在私有 Pod 仓库、二进制化、远程 Spec Repo 上结果连最基础的“怎么把现有代码拆成一个 Pod”都没理清楚。实际上组件化的第一步应该是先把模块切出来用本地 Pod 把边界定死再考虑后续的远成、版本、二进制分发。这篇文章就把我实际创建本地 Pod 的过程、Podspec 里每个字段怎么填、集成时遇到哪些坑完整过一遍。这套东西适合谁如果你负责的 App 已经超过几十个文件业务模块之间靠隐式依赖互相调用编译越来越慢或者你只是想学习组件化但不想一上来就搭一套复杂的基础设施那本地 Pod 就是最顺手的入口。它不需要你维护 Git 仓库、不需要配置 trunk、不需要 CI 脚本只要一个 Pods 目录就能让工程结构和依赖关系立刻变得清晰很多。1. 组件化思路与本地 Pod 的定位1.1 组件化到底在解决什么问题先说一个我观察了很久的现象很多团队说要组件化但实际代码里还是一个大 Target 堆到底所有文件都编译进同一个 AppView、Model、网络层、工具类混合在一起。这种结构在业务少的时候没问题等业务线多起来痛点会集中在四个地方。第一是编译速度。每改一行代码整个工程都可能触发重新编译尤其是越来越多人往 App 里塞第三方库和分类。第二是团队协作。不同小组改同一个文件、同一个宏定义代码合并时冲突频发谁也不敢动公共代码。第三是测试成本。业务模块互相牵连单元测试很难把某个模块孤立出来跑回归全靠手工点 App。第四是代码复用。App 和扩展、多个 App 之间想共用一段逻辑只能靠复制粘贴修 bug 要改好几个地方。组件化不是玄学它的核心是把代码按业务或功能切成独立模块每个模块只暴露必要的接口模块之间通过明确的依赖关系通信。这样一来编译可以被缓存团队可以独立维护各自的模块测试也可以只拉取模块源码来跑。而 Cocoapods 恰好提供了现成的“模块化容器”每个 Pod 就是一个独立模块。1.2 为什么第一步要落在本地 Pod 上我见过一上来就搭私有 Spec Repo 的团队效果并不理想。原因很简单仓库、权限、CI、版本发布流程还没成熟模块已经拆了一堆结果管理成本比收益还大。本地 Pod 的好处在于你用:path指向一个本地目录CocoaPods 会把这个目录里的源码直接编译进你的工程不需要任何远端基础设施。对刚起步的项目来说本地 Pod 有三点优势非常明显因为源码在本地改完 Pod 里的代码直接生效调试效率跟单工程一模一样。Pod 和宿主工程是两个物理目录天然形成了“模块边界”谁依赖谁一目了然。后续要推到远端仓库只需要给 podspec 配一个 Git 地址改动量很小。所以我的建议是组件化的第一阶段全部用本地 Pod 做模块拆分。先把“边界”这件事做好再谈“版本管理”。1.3 本地 Pod、私有 Pod 与二进制 Pod 的演进关系这里需要把三个概念理清楚。本地 Pod 是通过:path引入的依赖的代码就在你本机某个文件夹里适合开发和调试。私有 Pod 是指 podspec 存放在私有 Spec Repo源码也托管在私有 Git 仓库里其他同事可以通过 pod 命令拉取。二进制 Pod 则是把源码编译成 framework 后作为静态库或动态库打成 zip再挂到 Spec Repo 下适合对编译速度有极端要求的团队。这三者的关系并不是并列的而是组件化成熟度的三个阶段。一开始用本地 Pod业务结构稳定后再把代码推到 Git 仓库、建立私有 Spec Repo等到基础库基本不变时再考虑二进制化。本地 Pod 是这条路径上的起点也是风险最低、最容易验证方案的一步。2. 使用 Cocoapods 创建本地 Pod 的标准姿势2.1 手动创建还是用 pod lib createCocoaPods 提供了一个专门用来生成 Pod 模板的命令pod lib create。它会展开一个交互式询问包括你要用 Swift 还是 Objective-C、要不要生成测试框架、要不要 GUI 调试等然后自动生成完整的目录骨架包括.podspec、README、LICENSE、Example工程和Sources目录。但我实际用下来的感受是pod lib create直接生成一套模板里面有大量当前用不到的文件。比如它会生成一个Example文件夹里面带一个需要用pod install才能跑起来的 Demo 工程还要配置 target。对只是想快速把旧代码拆成 Pod 的场景来说这一步有点重。所以我更推荐的做法是先手动创建最简结构只需要四样东西一个源文件目录、一个.podspec文件、一个存放资源的Assets文件夹可选、一个用来测试的宿主工程。等你能跑通本地 Pod 的接入流程再回头看pod lib create生成的模板也不会一头雾水。2.2 标准的本地 Pod 目录长什么样我通常会在主工程同级放一个Modules文件夹里面按模块名再建子文件夹例如Workspace/ ├── MyApp.xcworkspace ├── MyApp/ │ ├── MyApp.xcodeproj │ ├── Podfile │ └── Sources/ ├── Modules/ │ └── JYNetwork/ │ ├── JYNetwork.podspec │ ├── Sources/ │ │ ├── JYNetwork.h │ │ ├── JYNetworkManager.m │ │ └── JYNetworkManager.h │ └── Assets/ │ └── network_placeholder.png这个结构里MyApp是宿主工程JYNetwork是一个待拆出来的网络模块。每个模块自带 podspec 和源码目录宿主工程完全不直接持有模块源码。有一点要注意目录名、podspec 文件名和 Pod 的名字尽量保持一致。如果你创建了一个JYNetwork文件夹但 podspec 文件叫Network.podspec其他同事看起来就会很混乱。按 CocoaPods 惯例podspec文件应该命名为模块名.podspec。2.3 Podfile 里如何引入本地 Pod接入本地 Pod 的写法很简单在 Podfile 里用:path指向模块目录platform :ios, 11.0 target MyApp do use_frameworks! # 本地模块 pod JYNetwork, :path ../Modules/JYNetwork end这里的关键参数是:path。它告诉 CocoaPods 不要从远程 Spec Repo 拉取这个 Pod 的版本而是直接用指定目录作为源码来源。执行pod install后Pod 的源码并不会拷贝到别的目录而是通过 Pods 工程去引用../Modules/JYNetwork/Sources下的文件。需要特别注意的是:path使用的是相对路径而这个相对路径的基准是 Podfile 所在的目录。如果你把 Podfile 放在MyApp里那:path就应该从MyApp路径开始写。换过电脑、移动过目录后只要这个相对位置不变就能正常pod install。3. Podspec 配置每个字段有什么用填错了会怎样podspec 是 CocoaPods 的“身份证”里面声明了这个 Pod 叫什么、版本多少、包含哪些文件、依赖了谁。很多人直接复制别人的 podspec 模板字段倒是填满了但不知道每个字段背后对应的行为。我挑几个真正影响构建的字段展开讲。3.1 基本信息与版本号Pod::Spec.new do |s| s.name JYNetwork s.version 0.1.0 s.summary A light network layer based on NSURLSession. s.homepage https://example.com/JYNetwork s.license { :type MIT, :file LICENSE } s.author { JY jyexample.com } s.source { :git , :tag s.version.to_s } s.ios.deployment_target 11.0 end这里最容易忽略的是s.version。本地 Pod 在:path模式下不太会严格读取版本号但后续发布到私有 Spec Repo 时podspec 的版本号必须和 Git tag 一一对应。比如 podspec 里写0.1.0Git 就必须打一个0.1.0的 tag否则pod install时找不到对应版本。另外一个容易踩坑的字段是s.source。本地模式时source里写什么几乎不影响编译但如果你把它留空或者填一个不存在的地址将来发布时就会报错。我建议从一开始就填上真实仓库地址没有仓库就先填:git 并在代码注释里提醒自己后续补上。3.2 文件匹配这个 Pod 编译哪些源码资源怎么打包s.source_files Sources/**/*.{h,m} s.public_header_files Sources/**/*.h s.resource_bundles { JYNetwork [Assets/*.png] }source_files定义的是参与编译的源文件模式**表示递归匹配子目录*.{h,m}表示只匹配.h和.m文件。假如你在这个目录里放了 Swift 文件但source_files只匹配h/mSwift 文件就不会被编译进去。同理如果 Pod 是一个纯 Swift 库就得写成*.swift。resource_bundles则是把资源文件单独打成一个 bundle。注意这里我推荐用resource_bundles而不是resources原因是resources会把资源文件直接放进主 bundle容易出现文件重名覆盖resource_bundles会为这个 Pod 单独生成一个JYNetwork.bundle加载方式需要用Bundle(for:)或Bundle(path:)取资源。一个经常出现的问题是图片放在Assets.xcassets里然后通过[UIImage imageNamed:xxx]加载。在本地主工程里这样做没问题但到了 Pod 里资源被打包进独立 bundleimageNamed:默认只往主 bundle 找所以就找不到图了。后面我会在常见问题里说怎么解决。3.3 依赖、系统框架与子组件s.dependency AFNetworking, ~ 4.0 s.frameworks [Security, SystemConfiguration] s.libraries [z, sqlite3]dependency是 Pod 之间的依赖关系声明。宿主 App 引用JYNetworkJYNetwork依赖 AFNetworking那pod install时就会把 AFNetworking 一并拉进来。版本号建议约束在合理范围比如~ 4.0表示大于等于 4.0 且小于 5.0既能保证功能一致又允许小版本更新。如果 Pod 使用了系统框架就得在frameworks里加对应名字。很多人在本地写代码时没报错因为宿主工程其他代码已经 link 过 Security 框架了但 Pod 单独编译或发布后就变成Undefined symbols或者找不到头文件。libraries同理如果用了zlib、sqlite3要显式声明s.libraries [z, sqlite3]。还有一个进阶参数subspec。当你觉得一个 Pod 太大了想拆成“默认只带核心功能可选带扩展功能”时就可以用 subspecs.subspec Core do |core| core.source_files JYNetwork/Core/**/*.{h,m} end s.subspec Mock do |mock| mock.source_files JYNetwork/Mock/**/*.{h,m} mock.dependency JYNetwork/Core end这样其他 Pod 在声明依赖时就可以写dependency JYNetwork/Mock只有这个 Pod 才需要把 Mock 相关代码编译进去。这对提升构建速度、控制模块体积很有帮助但同样也意味着你对模块边界的把控要更明确。3.4 编译选项与 Swift 版本s.swift_version 5.0 s.pod_target_xcconfig { OTHER_LDFLAGS -lObjC }swift_version用来声明 Pod 的 Swift 版本混编项目里尤其重要。如果你在本地机器用 Swift 5.7 编译但其他同事用 Xcode 13 对应的 Swift 5.5就可能出现“目标不支持该 Swift 版本”的提示。pod_target_xcconfig是 CocoaPods 给当前 Pod 的 target 设置的编译参数。最常见的一项是OTHER_LDFLAGS -lObjC它解决的是静态库中 Objective-C 分类Category不加载的问题。如果你的 Pod 里大量使用 Category 扩展系统类不加这一项运行时会看不到扩展方法。4. 本地 Pod 的集成与调试实战4.1 初次接入从零开始跑通链路准备一个全新的测试工程我用最小化流程演示一遍。第一步新建 Xcode 工程MyApp选择 iOS App 模板。这一步的工程路径和是否使用 Core Data 都不重要。第二步在工程文件同级创建Podfile写入platform :ios, 11.0 target MyApp do use_frameworks! pod JYNetwork, :path ../Modules/JYNetwork end第三步在指定路径创建JYNetwork模块目录和 podspec 文件放一个简单的源文件。第四步终端执行pod install。如果终端提示Using JYNetwork (0.1.0)说明本地 Pod 已经被解析。第五步CocoaPods 会生成MyApp.xcworkspace之后必须通过.xcworkspace打开工程而不是.xcodeproj。这里我要提一个很多人栽过跟头的细节pod install和pod update的区别。首次使用本地 Pod 时如果依赖是新增的可以用pod install但如果你改了冒号路径、新增了 subspec或者 podspec 里的 source 发生了变化pod install可能不会重新解析已经存在的依赖项。这时候要执行pod update JYNetwork强制刷新这个 Pod。4.2 在宿主工程里调用 Pod 中的代码假设JYNetwork模块里有一个NetworkManager#import JYNetwork/JYNetworkManager.h [JYNetworkManager sendRequestWithURL:url completion:^{ ... }];编译前CocoaPods 会自动配置 header search path所以你可以直接用尖括号引用 Pod 的头文件。但前提是这些头文件在public_header_files范围内。.m文件里的私有头文件可以放在 podspec 的 source_files 里但不要放进 public_header_files否则会造成接口过度暴露。Swift 项目里调用本地 Pod 的 Swift 类时需要用import JYNetwork调用 Objective-C 类时则依赖 CocoaPods 生成的桥接头文件。一个常见的坑是use_frameworks!打开后Swift 和 OC 混编的 Pod 里类名带了模块名前缀像JYNetwork.NetworkManager写代码时容易漏掉模块名。4.3 改代码后如何快速生效本地 Pod 最大的优势就是“改完就能用”。因为:path是指向源码目录的所以你修改JYNetwork文件夹里的代码后重启 App、重新编译改动就会生效不需要重新pod install。不过有一个例外如果你改的是 podspec 里描述的“文件集合”——比如往Sources/Api/目录里新增了一个.h文件原有source_files已经能递归匹配到它那没问题但如果新增的目录后缀不在匹配范围内你就得改 podspec然后执行pod install让 CocoaPods 重新同步文件引用。另外建议在宿主工程里配置一个Configuration文件把 Debug 环境下的 DEBUG 宏打开这样本地调试时能输出网络日志或者 Mock 数据Release 环境自动关掉。这种开关放在 Pod 里比散落在宿主工程里要规范得多。5. 从本地 Pod 走向规范与发布5.1 本地 Pod 的版本号怎么管理本地模式不会强制校验版本号但我不建议因此偷懒。把版本号从0.1.0开始维护每完成一定量的改动就递增遵循语义化版本规范主版本号不兼容的 API 修改次版本号向后兼容的功能新增修订号向后兼容的问题修复这样做的意义是当你后面把 podspec 推送到远程仓库、开始用 tag 管理版本时历史包袱已经很小。而且宿主工程 Podfile 里可以同时使用:path和指定的版本号比如pod JYNetwork, :path ../Modules/JYNetwork在开发环境不加版本限制但发布的时候 Podfile 会锁住一个精确版本。5.2 依赖关系的可视化与检查一个模块的 Podspec 里依赖了谁基本代表了模块的上游边界。本地 Pod 模式下最好养成的习惯是每个 Pod 只依赖业务无关的基础库不直接依赖别的业务 Pod 的具体实现。想快速看整个工程依赖树用命令pod install --verbose或者在工程目录下打开 Podfile.lock里面会记录当前解析后的 Pod 及版本。如果某个模块依赖关系特别诡异比如基础库依赖了登录模块说明边界设计有问题一定要趁早发现。我还有一个习惯没事就跑一遍pod outdated。本地 Pod 模式下它不一定能检测出变化但至少能帮你确认当前各个 Pod 的来源是路径依赖还是版本依赖避免后面某个模块意外变成了远程版本。5.3 把本地 Pod 推到远程仓库当业务稳定、代码该独立交付时就可以给本地 Pod 增加远程来源。核心步骤只有三步先在 Git 仓库里创建JYNetwork仓库把模块源码推上去并打上0.1.0的 tag。然后 cd 到模块目录用pod lib lint JYNetwork.podspec --allow-warnings做本地校验。校验通过后用pod repo push YOUR_PRIVATE_REPO JYNetwork.podspec把 podspec 推送到私有 Spec Repo。最后修改 Podfilepod JYNetwork, ~ 0.1.0去掉:path然后就变成普通远程 Pod 的用法。这个过程我经历过很多次最大的感受是只要本地阶段把 podspec 写得规范、源码目录结构保持清晰切换到远程只要几分钟。6. 调试本地 Pod 过程中的高频问题速查6.1 pod install 后还是找不到类这种情况一般分两类。第一类是source_files没包含新增的文件检查 podspec 里的路径匹配规则第二类是头文件只在模块内部可见却没被声明为 public外部工程用尖括号导入时自然找不到。查看是否匹配到文件可以在模块目录执行pod install后打开 Pods 工程里的 Development Pods 分组看文件是否出现在对应 target 里。6.2 图片和 xib 资源加载不出来这是本地 Pod 最容易踩的坑。前面我提到过Pod 使用resource_bundles打包资源后资源不在主 bundle 中。这里给出一个通用解决办法以JYNetwork模块里的network_placeholder.png为例正确加载方式是NSBundle *bundle [NSBundle bundleWithURL:[[NSBundle bundleForClass:[self class]] URLForResource:JYNetwork withExtension:bundle]]; UIImage *image [UIImage imageNamed:network_placeholder inBundle:bundle compatibleWithTraitCollection:nil];如果是纯 Swift 片段可以用Bundle(identifier:)或者直接通过Bundle(for:)获取。如果你在使用 xib/storyboard 时也出现找不到 view 的情况大概率是资源 bundle 没对上换成上述方式一般都能解决。6.3 用了 use_frameworks! 之后静态库和动态库的混编问题在 Podfile 里写use_frameworks!后Pod 会编译成动态 framework严格说是静态 framework 或动态 framework取决于配置这会影响#import的方式和启动速度。如果你的工程较多使用 OC且不想引入动态库的额外符号暴露可以把use_frameworks!改为use_frameworks! :linkage :static表示强制使用静态链接。这个配置对本地 Pod 同样生效能省掉一部分动态库的加载开销也能避免部分组件因为动态库机制导致的-ObjC问题。6.4 pod install 时出现 “Unable to find a specification for X”检查是不是某个依赖没有明确来源。如果你本地 Pod 依赖了一个远程私有 Pod但 Podfile 里没有配置对应的sourceCocoaPods 就找不到 spec。解决方法是把仓库地址写到 Podfile 顶部source https://github.com/CocoaPods/Specs.git source gityour-git-server:specs-repo.git注意即使是本地依赖也可以声明多个 sourceCocoaPods 会从这些仓库里拉取其他 Pod 的 spec。6.5 pod install 后 Pods 目录里的文件没更新遇到这种情况先别反复执行pod install因为这不是安装命令的问题。删掉 Pods 目录和 Podfile.lock重新执行pod install通常能解决大部分本地依赖的缓存问题。如果还不行用pod cache clean清理 CocoaPods 的缓存。6.6 本地 Pod 里用 Category 但运行时不生效还记得 3.4 里的OTHER_LDFLAGS -lObjC吧。Category 编译进静态库后如果没有-ObjC链接参数链接器不会加载包含 Category 的 object 文件运行时就会表现成“方法不见了”。遇到这类问题先看编译设置里是否包含-ObjC或-all_load再逐个排查是哪个 pod target 出的问题。6.7 多个本地 Pod 之间互相依赖怎么办这是组件化深入后的必经之路。比如JYHomePage依赖JYNetworkPodspec 里写s.dependency JYNetworkPodfile 里可以同时写pod JYHomePage, :path ../Modules/JYHomePage pod JYNetwork, :path ../Modules/JYNetworkCocoaPods 会自动解析本地路径之间的依赖。此时要特别留意版本号的一致性如果JYHomePage的 podspec 里写了dependency JYNetwork, ~ 1.0而本地 JYNetwork 的 podspec version 还停留在0.9.0CocoaPods 会直接报版本冲突。解决办法是在:path模式下两个 Pod 的 version 都要维护好或者将依赖版本写成不带太多限制的范围比如 0.9.0。最后分享两个我自己的习惯组件化没有标准答案但有些坑是共通的。第一我始终建议 Pod 里不要直接访问宿主工程的单例对象或全局宏。如果模块需要宿主提供配置定义协议让宿主去注入这样模块才不会变成下一个“大杂烩”。第二刚拆出来的 Pod 不要急着去做代码整洁度重构先把文件搬过去、编译能过、行为不变然后再抽接口、删冗余。拆分和重构同时做一旦出问题很难判断是哪一步引入的。我见过太多团队一开始把组件化做成“新建几个文件夹”最后又退回单工程就是因为边界没立住。而本地 Pod 恰恰是那个能把边界变成文件系统、变成 podspec、变成依赖树的方案。至少在我的实践里从第一行pod XXX, :path ../Modules/XXX写完组件化的路就已经往前迈了一大截。多试几个模块等依赖图清晰了后面再做二进制化、CI 自动化都会顺很多。