細聊 Cocoapods 與 Xcode 工程配置

bestswifter發表於2017-01-10

前言

文章比較長,所以在文章的開頭我打算簡單介紹一下這篇文章將要講述的內容,讀者可以選擇通篇細度,也可以直接找到自己感興趣的部分。

既然是談 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 命令來證明這一點:

111171077-21abc5f09b6ddf72
解壓結果

需要提醒的一點是,光有 .a 檔案還不夠,我們還需要提供標頭檔案給使用者匯入。為了完成這一點,我們需要在專案的 Build Phases 中新增一個 Headers Phase,然後把需要對外暴露的標頭檔案放到 Public 一欄中:

121171077-3616f0094b7caf67
暴露標頭檔案

此時編譯後的標頭檔案會放在 .a 檔案所在目錄下,usr/local/include 目錄中。

接下來開啟 OtherLinkerFlag 這個殼工程,引入 .a 檔案和標頭檔案,執行程式,結果一定是:

這就是經典的 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 的好處有如下幾個:

  1. 避免直接匯入檔案的原始方式,方便後續程式碼升級
  2. 簡化、自動化整合流程,避免不必要的配置
  3. 自動處理庫的依賴關係
  4. 簡化開發者釋出程式碼流程

Cocoapods 工作原理

在我之前的一篇文章: 白話 Ruby 與 DSL 以及在 iOS 開發中的運用 中簡單的介紹過,Cocoapods 是用 Ruby 開發的一套工具。每一份程式碼都是一個 Pod,安裝 Pod 時首先會分析庫的版本和依賴關係,這些都是在 Ruby 層面完成的,本文暫且不表。

我們首先假設已經找到了要下載的程式碼的地址(比如存在 Github 上),從這一步開始,接下來的工作都與 iOS 開發有關。

如果你手頭有一個 Cocoapods 專案,你應該會注意到以下幾個特點:

  1. 主工程中沒有匯入第三方庫的程式碼或靜態庫
  2. 主工程不顯式的依賴各個第三方庫,但是引用了 libPods.a 這個 Cocoapods 庫
  3. 不需要手動編譯第三方庫,直接執行主工程即可,隱式指定了編譯順序

這樣做可以把引入第三方庫對主工程造成的影響降到最低,不過無法完全降為零。比如引入 Cocoapods 以後,專案不得不使用 xworkspace 來開啟,後面會介紹原因。

假設之前的 BSStaticLibraryOne 工程就是下載好的原始碼,現在我們要做的就是把它整合到一個已有的工程,比如叫 ShellProject 中。

我們遇到的第一個問題是,在之前的 demo 中,需要把靜態庫和標頭檔案手動拖入到工程中。但這就和 Cocoapods 的效果不一致,畢竟我們希望主工程完全不受影響。

靜態庫和標頭檔案匯入

如果我們什麼都不做,當然不可能在殼工程中引用另一個專案下的靜態庫和標頭檔案。但這個問題也可以換個方式問:“Xcode 怎麼知道它們可以引用,還是不可以引用呢?”,答案在於 Build Settings 裡面的 Search Paths 這一節。預設情況下,Header Search PathLibrary Search Path 都是空的,也就是說 Xcode 不會去任何目錄下找靜態庫和標頭檔案,除非他們被人為的匯入到工程中來。

因此,只要對上述兩個選項的值略作修改, Xcode 就可以識別了。我們目前的專案結構如下所示:

因此我們要做的是讓殼工程的 Library Search Path 指向 CocoaPodsDemo/BSStaticLibraryOne/Build/Products/Debug-iphonesimulator 這個目錄:

這裡記得寫相對路徑,Xcode 會自動轉成絕對路徑。然後 Header Search Path 也如法炮製:

細心的讀者也許會發現, 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 的錯誤。

配置好以後的工程如下圖所示:

131171077-26553b2f14f0069f
配置搜尋路徑

現在專案中沒有任何第三方的庫或者程式碼,依然可以正常引用第三方的類並執行成功。

引用多個第三方庫

當我們的專案需要引用多個第三方庫的時候,就有兩種思路:

  1. 每份第三方程式碼作為一個工程,分別打出一個靜態庫和標頭檔案。
  2. 所有第三方程式碼放在同一個工程中,建立多個 target,每個 target 對應一個靜態庫。

從直覺來看,第二種組織方式看上去更加集中,易於管理。考慮後面我們還要解決庫的依賴問題,而且專案內的依賴處理比 workspace 中的依賴處理要容易很多(後面會介紹到),所以第二種組織方式更具有可行性。

如果讀者手頭有使用了 Cocoapods 的專案,可以看到它的檔案組織結構如下:

而在我的 demo 中,為了偷懶,沒有把第三方庫放在殼工程目錄下,而是選擇和它平級。這其實沒有太大的區別,只是引用路徑不同而已,不用太關心。我們現在模擬新增一個新的第三方庫,完成後的程式碼結構如下:

首先要新建一個資料夾 BSStaticLibraryTwo 並拖入到專案中,然後新增一個 Target(如下圖所示)。

141171077-ecaed15a8fd044d2
新增 target

在 Xcode 工程中,我們都接觸過 Project。開啟 .xcodeproj 檔案就是開啟一個專案(Project)。Project 負責的是專案程式碼管理。一個 Project 可以有多個 Target,這些 target 可以使用不同的檔案,最後也就可以得出不同的編譯產物。

通過使用多個 target,我們可以用少許不同的程式碼得到不同的 app,從而避免了開多個工程的必要。不過我們這裡的幾個 target 並不含有相同程式碼,而是一個第三方庫對應一個 target。

接下來我們新建一個類,記得要加入到 BSStaticLibraryTwo 這個 target 下,記得和之前一樣修改 Public Headers Folder Path 並新增一個 Build Phase

151483871638
程式碼新增到另一個 Target

在左上角將 Scheme 選擇為 BSStaticLibraryTwo 再編譯,可以看到新的靜態庫已經生成了。

專案內依賴

對於主工程來說,必須在子工程(第三方庫)編譯完後才開始編譯,或者換句話說,我們在主工程中按下 Command + R/B 時,所有子工程必須先被編譯。對於這種跨工程的庫依賴,我們無法直接指明依賴關係,必須隱式的設定依賴關係,我們還是以 Cocoapods 工程舉例:

161483879855
跨工程依賴

主工程中用到了 libPod.a 這個靜態庫,而且它並不是在主工程中生成,而是在 Pods 這個專案中編譯生成。一旦存在這種引用關係,那麼也就建立了隱式的依賴關係。在編譯主工程時,Xcode 會確保它引用的所有靜態庫都先被編譯。

之前我們討論過兩種管理多個靜態庫的方法,如果選擇第一種方法, 每個靜態庫對應一個 Xcode 專案,雖然不是不可以,但主工程看上去就就會比較複雜,這主要是跨專案依賴導致的。

而在專案內部管理 target 的依賴相對而言就簡單很多了。我們只要新建一個總的 target,不妨也叫作 Pod。它什麼也不做,只需要依賴另外兩個靜態庫就可以了,設定 Target Dependencies:

171171077-5f786230f07795d8

此時選擇 Pod 這個 target 編譯,另外兩個靜態庫也會被編譯。因此接下來的任務就是讓主工程直接依賴於 Pod 這個 target,自然也就間接依賴於真正有用的各個第三方靜態庫了。

接下來我們重複之前的步驟,設定好標頭檔案和靜態庫的搜尋路徑,並在 Other Linker Flags 裡面新增: -l"BSStaticLibraryTwo",就可以使用第二個靜態庫了。

Workspace

到目前為止,我們模擬了多個靜態庫的組織,以及如何在主工程中引用他們。不過還存在一些小瑕疵,我截了 Xcode 中的一幅圖:

181171077-35ac57f885909d95
Xcode 識別有問題

從圖中可以很明顯的發現: 第三方庫中的程式碼被認為是系統程式碼,顏色為藍色。而正常的自定義方法應該綠色,會對開發者造成困擾。

除了這個小瑕疵以外,在之前談到的跨專案依賴中,一個專案不僅僅需要引用另一個專案的產物,還有一個先決條件: 把這兩個專案放入同一個 Workspace 中。Workspace 的作用是組織多個 Project,使得各個 Project 直接可以有引用依賴關係,同時也能讓 Xcode 識別出各個 Project 中的程式碼和標頭檔案。

按住 Command + Control + N 可以新建一個 Workspace:

191171077-57f177b185388105
新建 Workspace

完成以後就會看到一個完全空白的專案,在左側按下右鍵,選擇 Add Files to:

201171077-2545bf30b8717f75
新增檔案

然後選中靜態庫專案和主工程的 .xcodeproj 檔案,把這兩個工程都加進來:

211171077-5a1e6742a341ae45

需要提醒的是,切換到 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:

這時候 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 的流程其實是:

  1. 判斷 Podfile.lock 是否存在,如果不存在,按照 Podfile 中指定的版本安裝
  2. 如果 Podfile.lock 存在,檢查 Podfile 中每一個 Pod 在 Podfile.lock 中是否存在
  3. 如果存在, 則忽略 Podfile 中的配置,使用 Podfile.lock 中的配置(實際上就是什麼都不做)
  4. 如果不存在,則使用 Podfile 中的配置,並寫入 Podfile.lock 中

而另一個常用命令 pod update 並不是一個日常更新命令。它的原理是忽略 Podfile.lock 檔案,完全使用 Podfile 中的配置,並且更新 Podfile.lock。一旦決定使用 pod update,就必須所有團隊成員一起更新。因此在使用 update 前請務必瞭解其背後發生的事情和對團隊造成的影響,並且確保有必要這麼做。

釋出自己的 Pod

很多教程都有介紹開源 Pod 的流程,我在實踐的時候主要參考了以下兩篇文章。相對來說比較詳細,條理清晰,也推薦給大家:

  1. Cocoapods系列教程(二)——開源主義接班人
  2. Cocoapods系列教程(三)——私有庫管理和模組化管理

如果要建立公司內部的私有庫,首先要建立一個自己的倉庫,這個倉庫在本地也會有儲存:

241171077-b3bd9acf08901228
倉庫

如圖中所示,master 是官方倉庫,而 baidu 則是我用來測試的私有倉庫。倉庫中會存有所有 Pod 的資訊,每個資料夾下都按照版本號做了區分,每個版本對應一個 podspec 檔案。從圖中可以看到,cocoapods 會快取所有的 podspec 到本地,但不會快取每個 Pod 的具體程式碼。每當我們執行 pod install 時,都會先從本地查詢 podspec 快取是否存在,如果不存在則會去中央倉庫下載。

我們經常遇到的 pod install 很慢就是因為預設情況下會更新整個 master。此時 master 不僅僅儲存著本地使用 Pod 的 PodSpec 檔案,而是儲存了所有的已有的 Pod。所以這個更新過程看起來異常緩慢。有些解決方案是使用:

這其實是治標不治本的姑息治療方法,因為本地的倉庫遲早要被更新,否則就拿不到最新的 PodSpec。要想徹底解決這一問題,除了定期更新外,還可以選擇其他速度較快的映象倉庫。

podspec 檔案是我們開源 Pod 時需要填寫的檔案,主要是描述了 Pod 的基礎資訊。除了一些無關緊要的配置和介紹資訊外,最重要的填寫 source_filesdependency。前者用來規定哪些檔案會對外公佈,後者則指定此 Pod 依賴於哪些其他 Pod。比如在上圖中,我的 PrivatePod 就依賴於 CorePod,在公司內部的專案中使用 PodS 依賴可以大量簡化程式碼的整合流程。一個典型的 PodSpec 可能長這樣:

251171077-1e637b5b33b0276a

填寫好上述資訊後,我們只要先 lint 一下 podspec,確保格式無誤,就可以提交了。

打賞支援我寫出更多好文章,謝謝!

打賞作者

打賞支援我寫出更多好文章,謝謝!

任選一種支付方式

細聊 Cocoapods 與 Xcode 工程配置 細聊 Cocoapods 與 Xcode 工程配置

相關文章