让你的 iOS 库支持 pod 和 carthage
主流的依赖管理有三大开源库:最老牌的 CocoaPods, 新秀 Carthage, 官方的 Swift Package Manager(目前只支持 macOS,不予讨论)。
让我们的库支持这两种依赖管理方式需要特定的工程形式吗?比如一个动态库:
Cocoa Touch Framework.png这不是必需的,CocoaPods 和 Carthage 有自己的规则去获取源代码文件和资源文件,并不依赖这种特殊的封装,对 CocoaPoda 而言,甚至不需要库以工程的形式存在,而对 Carthage 来说,库的代码和资源文件必须通过工程的形式来获取。
CocoaPods
CocoaPods 是最早出现的,也是目前影响力最大的依赖管理库。CocoaPod 的官方教程:Making a CocoaPod。
面对一堆代码和资源文件,需要三个步骤让别人通过 pod 安装:
- 创建 .podspec 配置文件(spec 是 specification 的缩写):库名,版本,托管地址,源码和资源在托管地址的相对位置;
- 托管到网络上,比如 Git;
- 发布到 pod 的 trunk 服务器
第2步结束后,别人就可以通过 pod 来使用你的库,而实现了第3步后,可以实现最简单的安装方式:pod 'libraryName'
。
在托管到网络之前,我们需要测试下库是否能够正常接入和使用;发布后,更新版本前需要进行测试,所以支持本地使用是很重要的,pod 支持。
本地开发阶段
使用 pod 接入第三方库是通过 Podfile 文件配置,而让你的库支持 pod 则是通过 libraryName.podspec 文件进行配置,这两个配置文件不要用 TextEdit 编辑,最好使用 Xcode 来编辑。
在开发阶段的通常做法是:创建一个文件夹作为库的根目录,将库源码单独放在一个子文件夹里,另建一个子文件夹作为 demo 的目录;在根目录下添加 libraryName.podspec 文件(可以通过这个命令创建:pod spec create libraryName
),在 demo 目录下添加一个 Podfile。pod 为此提供了一个模版:pod lib create libraryName
,它创建了一个完整的占位工程以及配置文件,如果是从头开始新的项目,可以用这个命令省去很多工作,如下所示,我通过这个命令创建了一个SDEDownloader
测试库,这个命令会有一些交互式的配置选项,右侧为最终的目录结构:
需要接入这个本地的库时,在 demo 目录下通过pod install
安装 Podfile 中指定的本地库和其它库,Podfile 里的配置如下:
pod 'libraryName', :path => 'podspecFolderPath'
podspecFolderPath
以 Podfile 所在目录为基础路径,以上面的测试库为例的话,这个值为../
(也就是这个库的根目录),而且这个指定的路径下必须有 libraryName.podspec 文件,以及一个LICENSE。
建议使用上面提到的两个命令来创建 .podspce 文件,很多必须的配置项都有了,详细的配置项可参考:Podspec Syntax Reference。其中的关键必需配置项如下:
// .podspec 文件本身的文件名也必须是 libraryName
s.name = 'libraryName'
// 当前库的版本,使用本地库时该值被忽略
s.version = '0.1.0'
// 库的主页,使用本地库时,这个值被忽略
s.homepage = 'https://github.com/seedante/SDEDownloader'
// 库的托管地址,使用本地库时,这个值被忽略。
// 这里使用了 git,还支持 svn, http, hg。
s.source = { :git => 'https://github.com/seedante/libraryName.git', :tag => s.version.to_s }
// 源代码文件,多个值之间使用,分割
s.source_files = 'libraryName/Classes/**/*'
// 排除文件
s.exclude_files = 'Tests'
// 资源文件,还有一个可替代配置 resource_bundles,该如何选择呢?后面来讨论
s.resources = ['Assets/*.xcassets', 'LocalizableFiles/**/*.strings']
podspec 的语法比较宽松,比如上面的值,你会看到有的库里使用''
,有的使用""
,无所谓,选个你喜欢的就行;有多个值时可以放在[]
内,也可以不用。
pod 根据 .podspec 文件里source
提供的库地址结合source_files
, exclude_files
, resources
这几个值(除了source_files
,剩下两个是可选的)去获取文件,这几个值都是使用相对位置,以 libraryName.podspec 所在目录为基础路径。在使用本地库时,source 值被忽略,直接抓取podspecFolderPath
目录下,通过source_files
, exclude_files
, resources
这几个值指定的源文件。在这一阶段,可以检查获取的源文件是否符合预期,如何利用通配符来指定源文件可参考:File patterns。
使用本地库时,代码文件会按照物理目录去整理,而使用基于网络托管的库时,代码文件不再按照物理目录那样去整理,而是统一在一个目录下;对于使用resources
指定的资源文件,都被会集中放在名为 Resources 的逻辑文件夹下。在工程里,通过 pod 安装的库都被集中在 Pods 这个工程下,其中安装的本地库在Development Pods
这个目录下,其它库在Pods
目录下。
托管到网络
代码可以发布后,将其托管到网络上,比如 Git,为了尽可能减少错误,发布之前可以先进行测试,在上一个阶段,可以测试是否按照预期的那样获取源文件,现在可以利用pod spec lint
来过滤掉一些简单的无意义的默认值,在 .podspec 文件所在目录也就是库的根目录下运行测试命令。
下面这些值是使用pod lib create SDEDownloader
创建的 .podspec 的默认值:
s.version = '0.1.0'
s.summary = 'A short description of SDEDownloader.'
s.home = 'https://github.com/seedante/SDEDownloader'
s.source = { :git => 'https://github.com/seedante/SDEDownloader.git', :tag => s.version.to_s }
运行pod spec lint
后会出现1个错误和2个警告:错误是无法访问source
指定的地址,因为我们还没有将代码发布到 Github 上;其中一个警告是要求summary
修改掉默认值,写点有意义的,另外一个警告是home
指定的地址无法访问,理由和source
相同,这里可以填一个可以访问的地址就可以消除警告,当然还是保留这个有意义的地址最好。
这里home
和source
里相关的地址就是我们推送到 Git 后的地址,如果你是自己写,这些地址也是很好推断出来的。如果没有其它问题了,可以发布代码了,这里需要注意的是记得添加 tag 来定制版本号,需要与version
值匹配:
git tag '0.1.0'
git push --tags
推送到 Github 后最好再次运行测试命令pod spec lint
来进行检查并修改,熟悉流程以后这些值都可以提前填好。
现在别人就可以通过 pod 来使用这个库了,直接指定库的地址,语法如下:
pod 'SDEDownloader', :git => 'https://github.com/seedante/SDEDownloader.git', :tag => '0.1.0'
不指定 tag 的话,使用 repo 下 master branch 最新 commit 里的 .podspec 获取源文件和资源;指定 tag 后,则根据指定版本的 .podspec 获取源文件和资源。
这种直接指定库地址的使用方式里,.podspec 里的source
值依然被忽略了,你填个其它地址也没有问题。
关于 .podspec 文件在代码库中的位置,前面提到将其放到根目录下,这是 pod 在File patterns 里硬性规定的:
Podspecs should be located at the root of the repository, and paths to files should be specified relative to the root of the repository as well.
毕竟不放在根目录下的话就太麻烦了,这个在创建 .podspec 文件时文档就应该指出的,我当时想把这个文件挪到其它位置,花了差不多一天时间才试验出来,结果试验完了后才看到这条规定,唉,总是会发生这种事情......
发布到 Trunk 服务器
不发到 Trunk 服务器也可以像上一阶段那样通过指定具体的库地址来使用,不过为什么还要发布呢?我也说不上来,可以看看官方的解释:CocoaPods Trunk。
完成这最后一步非常简单,前提是这个名字还没有被其他人抢注:
pod trunk push SDEDownloader.podspec
如果你没有在 pod 注册过,运行上面这行命令后会得到提示的,按照提示做即可。.podspec 里的source
值应该是在这里起作用,这个库名就和库的地址进行了绑定。
发布到 Trunk 服务器后,不必指定库的地址就可以使用:
pod 'SDEDownloader'
Carthage
CocoaPods 会改变工程结构,将第三方库与当前的工程纳入同一个 workspace 里,而我们其实仅仅需要的是封装好的库,Carthage 做的就是这件事,这样让通过 Carthage 使用第三方库的时候比较麻烦,但是让你的库支持 Carthage 无比简单,不需要配置文件,只需要将需要共享的源码和资源所在的 scheme 标记为Shared
就可以了:
在 Cartfile 里指定第三方库的语法是这样的:
github "seedante/SDEDownloader"
它会到 Github 的这个 repo 根目录下的 .xcworkspace 或者 .xcodeproj 里寻找 shared 的 scheme 里获取源文件和资源,如何确保文件在这个 scheme 里呢,看文件的归属,添加到这个工程的文件基本上是了:
TargetMembership.png这个支持过程太简单了,我当初都没有进行过本地测试,Carthage 当然也支持使用本地的库:
// Use a local project
git "file:///directory/to/project"
不过有几个限制条件:
- 必须纳入 git 管理;
- 指定路径是 .xcworkspace 或者 .xcodeproj 所在目录的绝对路径;
- 在这种没有附加任何条件的情况下,库必须用 tag 来划分版本,即使上面的语法里没有指定版本,而上面的语法将使用最新版本号下的版本,如果你提交了一个新的 commit,但是却没有给这个 commit 添加 tag,上面的语法仍然使用最近的一个 tag 指定的版本而不是最新的 commit。
关于版本的指定,Carthage 还支持 branch 和 commit id,使用非常简单:
// 将获取这个 branch 下最新的 commit 的版本
git "file:///directory/to/project", "branchName"
// 将获取指定的 commit 的版本
git "file:///directory/to/project", "commit_id"
加上 tag,一共三种方法来指定具体的版本,这三种方法不能混合使用。
Carthage 支持多种形式的库地址,详细可以看 example-cartfile。
让你的工程同时支持 CocoaPods 和 Carthage 并没有什么冲突,比较麻烦点的地方是Carthage 要在 repo 的根目录下寻找 .xcworkspace 或者 .xcodeproj 文件,而 CocoaPods 只需要有 .podspec 文件就可以了,而你通过pod lib create SDEDownloader
创建库文件夹时这两种文件是在子目录下的,把它们移动到根目录还是有点麻烦的。
对图像文件的支持
在资源文件中,图像文件有点特殊,比如为了应对不同的设备需要准备多种分辨率的版本。为了更好地管理资源文件,Xcode 引入了 Asset Catalog,使用它来管理图像文件有如下优点:
- 在更方便的界面里管理适应不同设备的图像文件,比在 Xcode Navigationer 里维护多个版本的文件要省心;
- 可以直接在控制面板里设置相关属性,不必再去代码里设置。
- 官方的 App 瘦身技术需要使用 Asset Catalog。
在 Xcode 里 Asset Catalog 以 .xcassets 的格式存在,在进行编译后,主工程和其它库里所有的 .xcassets 文件各自都集中成了一个单独的文件 Assets.car,那么库里的 Assets.car 会和主工程下的 Assets.car 冲突吗,会发生覆盖的情况吗?
没有放在 .xcassets 文件里的图像文件在编译后则依然以原始的形式存在,类似的问题来了,库和主工程会发生同名文件的冲突吗?
pod 有两个属性用于指定资源文件,分别是resources
和resource_bundles
,后者是为了避免命名冲突设计的,它引入了命名空间的概念,我们可以将资源像使用字典分类。官方强烈建议使用resource_bundles
来打包资源,但并没有注明原因以及适用范围。
之前搜到了这篇2015年的文章《给 Pod 添加资源文件》,pod 以往似乎直接将资源文件放主工程里,也就是 app 的根目录下,这样第三方库里的资源文件可能与主工程里的资源文件发生命名冲突,使用resource_bundles
来解决这个问题,它会将资源文件打包成这样的文件:你指定的文件名.bundle,这样基本可以解决命名冲突了。
而我摸索的结果表明这样的手法完全没有必要,不过《给 Pod 添加资源文件》这篇文章作为前期的参考在我写这部分内容时给忘了,直到昨天微信公众号「知识小集」推送了一篇文章《 Pod 中资源引入方式对比》,里面运用了resource_bundles
来解决类似的问题,想起来跟我这篇文章后面的结论相反,于是我下载了文章里的 Demo 进行了一番测试。鼓捣了一番后发现:双方都没有错,但是我们都只考虑了一半,症结在于我们以不同的方式编译库。于是我重写了这部分。
从 iOS 8 和 Xcode 6 开始引入了 Cocoa Touch Framework,也就是我们常说的动态库,它和以往的静态库 Cocoa Touch Static Library 有什么区别呢,这又和这篇文章有什么联系呢?
简单来说,编译后的静态库不包含资源文件,它的资源文件都移动到了 app 的根目录里,所以在 pod 里需要resource_bundles
这种解决方案:库里所有的 .xcassets 文件集中成一个文件 Assets.car,以及其它以原始形式存在的图像文件都以"你指定的文件名.bundle"这样的形式存在,放在 main bundle(app 根目录下);如果不使用这样的方法封装,而是resource
,库里的 Assets.car 不会拷贝到 app 的根目录里,而其它以原始形式的图像文件会被拷贝,如果主工程下有同名的文件,库的同名文件会覆盖这些文件。
动态库处理 .xcassets 文件和原始形式的图像文件的方式和静态库一样,只不过动态库可以包含资源文件,也就是说主工程和动态库独立地存放和管理各自的资源文件,不会发生冲突,所以resource_bundles
这种解决方法就不需要了。
在文件形式上,静态库是 xxx.a 这样的格式,无法在 Finder 里查看;动态库是 xxx.framework 这样的格式,可以在 Finder 里查看它的内容。
Xcode 直到9才支持包含 Swift 代码的静态库,由于之前我的探索是基于动态库,而上面提到的两种文章里都使用的是静态库,我们双方都只探讨了一半内容,现在把两种情况综合一下:
Carthage 目前只支持动态库,当然它也能将库编译为静态库,但如果库里有资源文件,由于在其网页里没明确提及这方面的事情,我还不知道怎么处理(后续有空的话补上这部分内容);在 pod 里编译动态库需要在 Podfile 里添use_frameworks!
,如果没有这句,则编译为静态库。
在 pod 里,使用动态库的话,一切都很简单,使用resouces
打包资源即可;使用静态库时,如果库里使用了 .xcassets,则必须使用resource_bundles
,不然库中 .xcassets 里的图像都无法使用,而以原始形式存在的图像文件,考虑到会与主工程下的文件发生命名冲突,推荐使用resource_bundles
。
访问使用resource_bundles
打包的资源会麻烦一点,而且使用resource_bundles
还需要考虑不同库之间的 bundle 名冲突,建议尽量使用动态库来避免这种麻烦。接下来使用例子来讲解resources
和resource_bundles
两种方案的使用和区别。
CocoaPods: resources, or resource_bundles?
resource_bundles
的语法如下,和resources
一样有复数形式,其实也没那么严格,之前一直没注意,下面是官方的例子,我加了点使用 Asset Catalog 管理的文件:
spec.resource = 'Resources/HockeySDK.bundle'
spec.resources = ['Images/*.png', 'Sounds/*', 'Assets/*.xcassets']
spec.ios.resource_bundle = { 'MapBox' => 'MapView/Map/Resources/*.png' }
spec.resource_bundles = {
'MapBox' => ['MapView/Map/Resources/*.png', 'Assets/*.xcassets'],
'OtherResources' => ['MapView/Map/OtherResources/*.png', 'Assets/*.xcassets']
}
对于resources
,使用静态库时,资源文件直接拷贝到 app 的根目录下;使用动态库时,则放在库文件 LibraryName.framework 的根目录下。
对于resource_bundles
,资源文件时文件被以"MapBox.bundle"和"'OtherResources.bundle"这样的形式封装,编译静态库时,这两个文件存放在 app 的根目录下;使用动态库时,这两个文件放在库文件LibraryName.framework 的根目录下。
可以这样查看这些内容在动态库里是如何组织的:在 Pods 工程下(在Xcode里看) Products 目录下找到 LibraryName.framework,右键菜单中选择"Show in Finder",在 Finder 里点击打开。
pod 对resources
打包后的结构:
LibraryName.framework
--xxxx
--*.png
--Assets.car//所有使用 Asset Catalog 的图像文件都集中成了这一个文件
使用resource_bundles
打包的结构:
LibraryName.framework
--xxxx
--*.png
--MapBox.bundle(other.file/*.png/Assets.car)//MapBox指定的所有 .xcassets 文件也集中成了一个文件
--OtherResources.bundle(other.file/*.png/Assets.car)
如何读取这些图像呢?
UIImage 的方法init?(named: String, in: Bundle?, compatibleWith: UITraitCollection?)
可以指定具体的 bundle(其实就是一个文件夹,相对地每个库也可以视作一个 bundle),init?(named: String)
是这个方法的便捷形式,它在 main bundle (也就是主工程里,app 的根目录)寻找图像,这两个方法优先在 bundle 里的 Assets.car 里寻找,找不到后再在 bundle 根目录下里查找。
使用resources
打包时,库内外的代码这样访问库里的图像文件:
// 找到 frameworkBundle 的所在位置
let frameworkBundle = Bundle(for: classInLibrary.self)
// init?(named:in:compatibleWith:) 这个方法会优先在 frameworkBundle 里面的 Assets.car 里查找,
// 如果没有找到再在 frameworkBundle 的根目录下查找,找到后会缓存起来。
let image = UIImage(named: "imageName", in: frameworkBundle, compatibleWith: nil)
而使用resource_bundles
打包的文件额外打包了一层,无论在库内部还是外部,使用它们需要多一次解包:
// 在 frameworkBunlde 内部的位置,withExtension 参数使用'.bundle'也可以
let mapBoxBundle = Bundle.init(url: frameworkBundle.url(forResource: "MapBox", withExtension: "bundle")!)
let image = UIImage(named: "imageName", in: mapBoxBundle, compatibleWith: nil)
在开发 SDEDownloadManager 这个库时,我将库源码单独用 Cocoa Touch Framework 打包了,在库的内部使用图像文件只需要一次解包,而使用resource_bundles
的话,通过 pod 安装的库则需要多一次解包才能使用内部的资源,这就造成了开发代码和发布代码不一致。所以总的来讲,使用动态库的时候,没有使用resource_bundles
的必要。
这里还有一点比较有趣,如果使用静态库的话,上面的 frameworkBundle 指向的路径和mainBundle
是一样的;而动态库里,frameworkBundle 指向库文件的路径。
One More Thing
使用 Asset Catalog 有诸多优点,比如init?(named:in:compatibleWith:)
会缓存图像,有时候图像只需要使用一次,这时候需要使用init?(contentsOfFile: String)
,这个方法不会缓存数据,每次都会从指定路径(.framework 以及 .bundle 里文件的路径可以利用Bundle
这个类获取)加载图像,但这个方法无法对使用 Asset Catalog 的图像使用,因为 Assets.car 是个不透明的文件格式,无法获取里面的图像文件的路径。所以,使用init?(contentsOfFile: String)
获取图像时,这个图像文件不要放在 xcassets 里。