原文
前言
文章比較長,所以在文章的開頭我打算簡單介紹一下這篇文章將要講述的內容,讀者可以選擇通篇細度,也可以直接找到自己感興趣的部分。
既然是談 Cocoapods,那首先要搞明白它出現的背景。有經驗的開發者都知道 Cocoapods 在實際使用中,經常遇到各種問題,存在一定的使用成本,因此衡量 Cocoapods 的成本和收益就顯得很關鍵。
Cocoapods 的本質是一套自動化工具。那麼了解自動化流程背後的原理就很重要,如果我們能手動的模擬 Cocoapods 的流程,無論是對 Cocoapods 還是 Xcode 工程配置的學習都大有裨益。比如之前曾經和同事研究過靜態庫嵌套的問題,很遺憾當時沒能解決,現在想來還是對相關知識理解還不夠到位。這一部分主要是介紹 Xcode 的工程配置,以及 target/project/workspace 等名詞的概念。
最後,我會結合實際的例子,談談如何發布自己的 Pod,提供給別人使用。算是對 Cocoapods 的實踐總結。
由於實踐性的操作比較多,我為本文制作了一個 demo,提交在 我的 Github: CocoaPodsDemo 上,感興趣的讀者可以下載下來,研究一下提交歷史,或者自己操作一遍。友情提醒: 本文所涉及的靜態庫均為模擬器制作,請勿真機運行。
為什麼要使用 Cocoapods
我們知道,再大的項目最初都是從 Xcode 提供的一個非常簡單的工程模板慢慢演化來的。在項目的演化過程中,為了實現新的功能,不斷有新的類被創建,新的代碼被添加。不過除了自己添加代碼,我們也經常會直接把第三方的開源代碼導入到項目中,從而避免重復造輪子,節約開發時間。
直接把代碼導入到項目中看起來很容易,但在實踐過程中,會遇到諸多問題。這些問題會困擾代碼的使用者,大大的增加了集成代碼的難度。
使用者的困擾
最直接的問題就是代碼的後續維護。假設代碼的發布者在未來的某一天更新了代碼,修復了一個重大 bug 或者提供了新的功能,那麼使用者就很難集成這些變動。
代碼有增有刪,如果把代碼編譯成靜態庫再提供給使用者, 就可以省掉很多問題。然而如果這麼做的話,就會遇到另一個經典的問題: "Other linker flag"。
舉個例子來說,可以在 Demo 的 BSStaticLibraryOne 這個項目中看到,這個靜態庫一共有兩個類,其中一個是拓展 Extension。項目編譯後就會得到一個 .a 文件。
我們都知道靜態庫的格式可以是 .framework,也可以是 .a。如果深究的話,.a 文件可以理解為一種歸檔文件,或者說是壓縮文件。其中存儲的是經過編譯的 .o 格式的目標文件。我們可以通過 ar -x 命令來證明這一點:
ar -x libBSStaticLibraryOne.a
需要提醒的一點是,光有 .a 文件還不夠,我們還需要提供頭文件給使用者導入。為了完成這一點,我們需要在項目的 Build Phases 中新增一個 Headers Phase,然後把需要對外暴露的頭文件放到 Public 一欄中:
此時編譯後的頭文件會放在 .a 文件所在目錄下,usr/local/include 目錄中。
接下來打開 OtherLinkerFlag 這個殼工程,引入 .a 文件和頭文件,運行程序,結果一定是:
-[BSStaticLibraryOne sayOtherThing]: unrecognized selector sent to instance xxx
這就是經典的 linker flag 問題。首先,我們知道 .a 其實是編譯好的目標文件的集合,因此問題出在鏈接這一步,而非編譯。Objective-C 在使用靜態庫時,需要知道哪些文件需要鏈接進來,它依據的就是之前圖中所示的 __.SYMDEF SORTED 文件。
可惜的是,這個文件不會包含所有的 .o 目標文件,而只是包含了定義了類的目標文件。我們可以執行 cat __.SYMDEF\ SORTED 來驗證一下,你會看到其中並沒有拓展類的信息。這樣一來,BSStaticLibraryOne+Extension.o 雖然存在,但是不被鏈接到最終的可執行文件中,從而導致了找不到方法的錯誤。
解決上述問題的方法是調用者在 Build Settings 中找到 other linker flag,並寫上 -ObjC 選項,這個選項會鏈接所有的目標文件。然而根據文檔描述,如果靜態庫只有分類,而沒有類, 即使加了 -ObjC 選項也會報錯,應該使用 -force_load 參數。
由於第三方的代碼使用分類幾乎是必然事件,因此幾乎每個使用者都要做如上配置,增加了復雜度和出錯的幾率。
除此以外,第三方的代碼很有可能使用了系統的動態庫。因此使用者還必須手動引入這些動態庫(請記住這一點,靜態庫不支持遞歸引用,這是個很麻煩的事情,後面會介紹),我們以百度地圖 SDK 的集成為例,讀者可以自行對比手動導入和 Cocoapods 集成的步驟區別: 配置開發環境iOS SDK。
因此,我總結的使用 Cocoapods 的好處有如下幾個:
避免直接導入文件的原始方式,方便後續代碼升級
簡化、自動化集成流程,避免不必要的配置
自動處理庫的依賴關系
簡化開發者發布代碼流程
Cocoapods 工作原理
在我之前的一篇文章: 白話 Ruby 與 DSL 以及在 iOS 開發中的運用 中簡單的介紹過,Cocoapods 是用 Ruby 開發的一套工具。每一份代碼都是一個 Pod,安裝 Pod 時首先會分析庫的版本和依賴關系,這些都是在 Ruby 層面完成的,本文暫且不表。
我們首先假設已經找到了要下載的代碼的地址(比如存在 Github 上),從這一步開始,接下來的工作都與 iOS 開發有關。
如果你手頭有一個 Cocoapods 項目,你應該會注意到以下幾個特點:
主工程中沒有導入第三方庫的代碼或靜態庫
主工程不顯式的依賴各個第三方庫,但是引用了 libPods.a 這個 Cocoapods 庫
不需要手動編譯第三方庫,直接運行主工程即可,隱式指定了編譯順序
這樣做可以把引入第三方庫對主工程造成的影響降到最低,不過無法完全降為零。比如引入 Cocoapods 以後,項目不得不使用 xworkspace 來打開,後面會介紹原因。
假設之前的 BSStaticLibraryOne 工程就是下載好的源碼,現在我們要做的就是把它集成到一個已有的工程,比如叫ShellProject 中。
我們遇到的第一個問題是,在之前的 demo 中,需要把靜態庫和頭文件手動拖入到工程中。但這就和 Cocoapods 的效果不一致,畢竟我們希望主工程完全不受影響。
靜態庫和頭文件導入
如果我們什麼都不做,當然不可能在殼工程中引用另一個項目下的靜態庫和頭文件。但這個問題也可以換個方式問:“Xcode 怎麼知道它們可以引用,還是不可以引用呢?”,答案在於 Build Settings 裡面的 Search Paths 這一節。默認情況下,Header Search Path 和 Library Search Path 都是空的,也就是說 Xcode 不會去任何目錄下找靜態庫和頭文件,除非他們被人為的導入到工程中來。
因此,只要對上述兩個選項的值略作修改, Xcode 就可以識別了。我們目前的項目結構如下所示:
- CocoaPodsDemo(根目錄) - BSStaticLibraryOne (被引用的靜態庫) - Build/Products/Debug-iphonesimulator (編譯結果的目錄) - libBSStaticLibraryOne.a (靜態庫) - usr/local/include (頭文件目錄) - BSStaticLibraryOne.h - BSStaticLibraryOne+Extension.h - ShellProject (殼工程)
因此我們要做的是讓殼工程的 Library Search Path 指向CocoaPodsDemo/BSStaticLibraryOne/Build/Products/Debug-iphonesimulator 這個目錄:
Library Search Path = $PROJECT_DIR/../BSStaticLibraryOne/Build/Products/Debug-iphonesimulator/
這裡記得寫相對路徑,Xcode 會自動轉成絕對路徑。然後 Header Search Path 也如法炮制:
Header Search Path = $PROJECT_DIR/../BSStaticLibraryOne/Build/Products/Debug-iphonesimulator/LibOne
細心的讀者也許會發現, LibOne 這個文件夾完全不存在。是這樣的,因為我覺得 usr/local/include 這個路徑太深,太丑,所以可以在靜態庫的項目配置中,在 Packaging 這一節中,找到 Public Headers Folder Path,將它的值從usr/local/include 修改為 LibOne,然後重新編譯,這時就會看到生成的頭文件位置發生了變化。
當然,這時候還是無法直接引用靜態庫的。因為我們只是告訴 Xcode 可以去對應路徑去找,但並沒有明確聲明要用,所以需要在 Other Linker Flags 中添加一個選項: -l"BSStaticLibraryOne",引號中的內容就是靜態庫的工程名。
需要提醒的是, 靜態庫編譯出來的 .a 文件會被手動加上 lib 前綴,在寫入到 Other Linker Flags 的時候千萬要注意去掉這個前綴,否則就會出現 Library not found 的錯誤。
配置好以後的工程如下圖所示:
現在項目中沒有任何第三方的庫或者代碼,依然可以正常引用第三方的類並運行成功。
引用多個第三方庫
當我們的項目需要引用多個第三方庫的時候,就有兩種思路:
每份第三方代碼作為一個工程,分別打出一個靜態庫和頭文件。
所有第三方代碼放在同一個工程中,建立多個 target,每個 target 對應一個靜態庫。
從直覺來看,第二種組織方式看上去更加集中,易於管理。考慮後面我們還要解決庫的依賴問題,而且項目內的依賴處理比 workspace 中的依賴處理要容易很多(後面會介紹到),所以第二種組織方式更具有可行性。
如果讀者手頭有使用了 Cocoapods 的項目,可以看到它的文件組織結構如下:
- ShellProject(根目錄,殼工程) - ShellProject (項目代碼) - ShellProject.xcodeproj (項目文件) - Pods (第三方庫的根目錄) - Pods.xcodeproj (第三方庫的總工程) - AFNetworking (某個第三方庫) - Mantle (另一個第三方庫) - ……
而在我的 demo 中,為了偷懶,沒有把第三方庫放在殼工程目錄下,而是選擇和它平級。這其實沒有太大的區別,只是引用路徑不同而已,不用太關心。我們現在模擬添加一個新的第三方庫,完成後的代碼結構如下:
- CocoaPodsDemo(根目錄) - BSStaticLibraryOne (第三方庫總的文件夾,相當於 Pods,因為偷懶,名字就不改了) - BSStaticLibraryOne (第一個第三方庫) - BSStaticLibraryTwo (新增一個第三方庫) - BSStaticLibraryOne.xcodeproj (第三方庫的項目文件) - Build/Products/Debug-iphonesimulator (編譯結果的目錄) - ShellProject (殼工程)
首先要新建一個文件夾 BSStaticLibraryTwo 並拖入到項目中,然後新增一個 Target(如下圖所示)。
在 Xcode 工程中,我們都接觸過 Project。打開 .xcodeproj 文件就是打開一個項目(Project)。Project 負責的是項目代碼管理。一個 Project 可以有多個 Target,這些 target 可以使用不同的文件,最後也就可以得出不同的編譯產物。
通過使用多個 target,我們可以用少許不同的代碼得到不同的 app,從而避免了開多個工程的必要。不過我們這裡的幾個 target 並不含有相同代碼,而是一個第三方庫對應一個 target。
接下來我們新建一個類,記得要加入到 BSStaticLibraryTwo 這個 target 下,記得和之前一樣修改 Public Headers Folder Path 並添加一個 Build Phase。
在左上角將 Scheme 選擇為 BSStaticLibraryTwo 再編譯,可以看到新的靜態庫已經生成了。
項目內依賴
對於主工程來說,必須在子工程(第三方庫)編譯完後才開始編譯,或者換句話說,我們在主工程中按下 Command + R/B 時,所有子工程必須先被編譯。對於這種跨工程的庫依賴,我們無法直接指明依賴關系,必須隱式的設置依賴關系,我們還是以 Cocoapods 工程舉例:
主工程中用到了 libPod.a 這個靜態庫,而且它並不是在主工程中生成,而是在 Pods 這個項目中編譯生成。一旦存在這種引用關系,那麼也就建立了隱式的依賴關系。在編譯主工程時,Xcode 會確保它引用的所有靜態庫都先被編譯。
之前我們討論過兩種管理多個靜態庫的方法,如果選擇第一種方法, 每個靜態庫對應一個 Xcode 項目,雖然不是不可以,但主工程看上去就就會比較復雜,這主要是跨項目依賴導致的。
而在項目內部管理 target 的依賴相對而言就簡單很多了。我們只要新建一個總的 target,不妨也叫作 Pod。它什麼也不做,只需要依賴另外兩個靜態庫就可以了,設置 Target Dependencies:
此時選擇 Pod 這個 target 編譯,另外兩個靜態庫也會被編譯。因此接下來的任務就是讓主工程直接依賴於 Pod 這個 target,自然也就間接依賴於真正有用的各個第三方靜態庫了。
接下來我們重復之前的步驟,設置好頭文件和靜態庫的搜索路徑,並在 Other Linker Flags 裡面添加: -l"BSStaticLibraryTwo",就可以使用第二個靜態庫了。
Workspace
到目前為止,我們模擬了多個靜態庫的組織,以及如何在主工程中引用他們。不過還存在一些小瑕疵,我截了 Xcode 中的一幅圖:
從圖中可以很明顯的發現: 第三方庫中的代碼被認為是系統代碼,顏色為藍色。而正常的自定義方法應該綠色,會對開發者造成困擾。
除了這個小瑕疵以外,在之前談到的跨項目依賴中,一個項目不僅僅需要引用另一個項目的產物,還有一個先決條件: 把這兩個項目放入同一個 Workspace 中。Workspace 的作用是組織多個 Project,使得各個 Project 直接可以有引用依賴關系,同時也能讓 Xcode 識別出各個 Project 中的代碼和頭文件。
按住 Command + Control + N 可以新建一個 Workspace:
完成以後就會看到一個完全空白的項目,在左側按下右鍵,選擇 Add Files to:
然後選中靜態庫項目和主工程的 .xcodeproj 文件,把這兩個工程都加進來:
需要提醒的是,切換到 Workspace 以後, Xcode 會把 Workspace 所在目錄當做項目根目錄,因此靜態庫的編譯結果會放在 /CocoaPodsDemo/Build/Products/...,而不再是之前的/CocoaPodsDemo/BSStaticLibraryOne/Build/Products/...,因此需要手動對主工程中的搜索路徑做一下調整。
做好上述改動後,即使我們刪除掉 BSStaticLibraryOne 這個項目的編譯結果,只在 Workspace 中編譯主項目,Xcode 也會自動為我們編譯被依賴的靜態庫。這就是為什麼我們只需要執行 pod install 下載好代碼,就可以不用做別的操作,直接在主項目中運行。
當然,代碼顏色錯誤的小問題也在 Workspace 恢復正常了。
靜態庫嵌套
到這裡,基本上關於 Cocoapods 的工作原理就算是分析完了。上述操作除了文件增加,基本上都是修改 .pbxproj 文件。所有的 Xcode 都會在該文件中得到反映,同理,只要修改該文件,也能達到上述手動操作的效果。而 Cocoapods 開發了一套 Ruby 工具,用來封裝這些修改,從而實現了自動化。
文章開頭,我們提到作為代碼提供者,如果自己的代碼還引用別的第三方庫,那麼提供代碼會變得很麻煩,這主要是由於靜態庫不會遞歸引用導致的。我們已經知道靜態庫其實就是一堆編譯好的目標文件(.o 文件)的打包形式,它需要配合頭文件來使用。所謂的不會遞歸引用是指,假設項目 A 引用了靜態庫 B(或者是動態庫,也是一樣),那麼 A 編譯後得到的靜態庫中,並不含有靜態庫 B 的目標文件。如果有人拿到這樣的靜態庫 A,就必須補齊靜態庫 B,否則就會遇到 "Undefined symbol" 錯誤。
如果我們提供的代碼引用了系統的動態庫,問題還比較簡單,只要在文檔裡面注明,讓使用者自己導入即可。但如果是第三方代碼,那麼這簡直是一起災難。即使使用者找到了提供者使用的靜態庫,那個靜態庫也很有可能已經進行了升級,而版本不一致的靜態庫可能具有完全不同的 API。也就是說代碼提供者還要在文檔中注明使用的靜態庫的版本,然後由使用者去找到這個版本。我想,這才是 Cocoapods 真正致力於解決的任務。
CocoaPods 的做法比較簡單,因為他有一套統一的版本表示規則,也可以自動分析依賴關系,而且每個版本的代碼都有記錄。後面會介紹 Cocoapods 的相關實踐,這裡我們先思考一下如何手動解決靜態庫嵌套的問題。
既然靜態庫只是目標文件的打包形式,那麼我只需要找到被嵌套的靜態庫,拿到其中的目標文件,然後和外層的靜態庫放在一起重新打包即可。這個過程比較簡單, 我也就沒有做 demo,用代碼應該就可以說明得很清楚。假設我們有靜態庫 A.a 和 B.a,其中 A 需要引用 B,現在我希望對外發布 A,並且集成 B:
lipo A.a -thin x86_64 output A_64.a # 如果是多 CPU 架構,先提取出某一種架構下的 .a 文件 lipo B.a -thin x86_64 output B_64.a ar -x A_64.a # 解壓 A 中的目標文件 ar -x B_64.a # 解壓 B 中的目標文件 libtool -static -o Together.a *.o # 把所有 .o 文件一起打包到 Together.a 中
這時候 Together.a 文件就可以當做完整版的靜態庫 A 給別人使用了。
Cocoapods 使用
本來 Cocoapods 的使用就比較簡單。尤其是了解完原理後,使用起來應該更加得心應手了,對於一些常見的錯誤也有了分析能力。不過有個小細節還是需要注意一下:
Podfile.lock
關於 Cocoapods 文件是否要加入版本控制並沒有明確的答案。我以前的習慣是不加入版本控制。因為這樣會讓提交歷史明顯變得復雜,如果不同分支上使用的不同版本的 pod,在合並分支時就會出現大量沖突。
然而官方的推薦是把它加入到版本控制中去。這樣別人不再需要執行 pod install,而且能夠確保所有人的代碼一定一致。
然而雖然不強制把整個 Pod 都加入版本控制,但是 Podfile.lock 無論如何必須添加到版本控制系統中。為了解釋這個問題,我們先來看看 Cocoapods 可能存在的問題。
假設我們在 Podfile 中寫上: pod 'AFNetWorking',那麼默認是安裝 AFNetworking 的最新代碼。這就導致用戶 A 可能裝的是 3.0 版本,而用戶 B 再安裝就變成了 4.0 版本。即使我們在 Podfile 中指定了庫的具體版本,那也不能保證不出問題。因為一個第三方庫還有可能依賴其他的第三方庫,而且不保證它的依賴關系是具體到版本號的。
因此 Podfile.lock 存在的意義是將某一次 pod install 時使用的各個庫的版本,以及這個庫依賴的其他第三方庫的版本記錄下來,以供別人使用。這樣一來,pod install 的流程其實是:
判斷 Podfile.lock 是否存在,如果不存在,按照 Podfile 中指定的版本安裝
如果 Podfile.lock 存在,檢查 Podfile 中每一個 Pod 在 Podfile.lock 中是否存在
如果存在, 則忽略 Podfile 中的配置,使用 Podfile.lock 中的配置(實際上就是什麼都不做)
如果不存在,則使用 Podfile 中的配置,並寫入 Podfile.lock 中
而另一個常用命令 pod update 並不是一個日常更新命令。它的原理是忽略 Podfile.lock 文件,完全使用 Podfile 中的配置,並且更新 Podfile.lock。一旦決定使用 pod update,就必須所有團隊成員一起更新。因此在使用 update 前請務必了解其背後發生的事情和對團隊造成的影響,並且確保有必要這麼做。
發布自己的 Pod
很多教程都有介紹開源 Pod 的流程,我在實踐的時候主要參考了以下兩篇文章。相對來說比較詳細,條理清晰,也推薦給大家:
Cocoapods系列教程(二)——開源主義接班人
Cocoapods系列教程(三)——私有庫管理和模塊化管理
如果要創建公司內部的私有庫,首先要建立一個自己的倉庫,這個倉庫在本地也會有存儲:
如圖中所示,master 是官方倉庫,而 baidu 則是我用來測試的私有倉庫。倉庫中會存有所有 Pod 的信息,每個文件夾下都按照版本號做了區分,每個版本對應一個 podspec 文件。從圖中可以看到,cocoapods 會緩存所有的 podspec 到本地,但不會緩存每個 Pod 的具體代碼。每當我們執行 pod install 時,都會先從本地查找 podspec 緩存是否存在,如果不存在則會去中央倉庫下載。
我們經常遇到的 pod install 很慢就是因為默認情況下會更新整個 master。此時 master 不僅僅存儲著本地使用 Pod 的 PodSpec 文件,而是存儲了所有的已有的 Pod。所以這個更新過程看起來異常緩慢。有些解決方案是使用:
pod install --verbose --no-repo-update
這其實是治標不治本的姑息治療方法,因為本地的倉庫遲早要被更新,否則就拿不到最新的 PodSpec。要想徹底解決這一問題,除了定期更新外,還可以選擇其他速度較快的鏡像倉庫。
podspec 文件是我們開源 Pod 時需要填寫的文件,主要是描述了 Pod 的基礎信息。除了一些無關緊要的配置和介紹信息外,最重要的填寫 source_files 和 dependency。前者用來規定哪些文件會對外公布,後者則指定此 Pod 依賴於哪些其他 Pod。比如在上圖中,我的 PrivatePod 就依賴於 CorePod,在公司內部的項目中使用 PodS 依賴可以大量簡化代碼的集成流程。一個典型的 PodSpec 可能長這樣:
填寫好上述信息後,我們只要先 lint 一下 podspec,確保格式無誤,就可以提交了。