主流的依賴管理有三大開源庫:最老牌的 CocoaPods, 新秀 Carthage, 官方的 Swift Package Manager(目前只支持 macOS,不予討論)。
讓我們的庫支持這兩種依賴管理方式需要特定的工程形式嗎?比如一個動態庫:
這不是必需的,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 里呢,看文件的歸屬,添加到這個工程的文件基本上是了:
這個支持過程太簡單了,我當初都沒有進行過本地測試,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 里。