基於CocoaPods的元件化原理及私有庫實踐

nimomeng發表於2019-02-05

輪子為什麼會存在

智人能在殘酷的進化大戰中存活下來,原因之一就是智人懂得將知識沉澱成外物,輔助彼此之間的合作,從而使得整個群體產生了規模效應,即1+1>2的效果。
從一個角度上說,石器時代是基於石器的元件化的時代,因為老張家的石矛(或其它石頭利器)借給了老王,一樣可以拿去狩獵。要想實現這個目的,一定要保證:

  1. 石矛足夠鋒利。不然冒然拿著石矛去找野獸就變成了給野獸送夜宵。
  2. 石矛容易使用。如果是石矛非常重或者難以抓起,也很難讓人使用。
image.png

一種觀點認為,資訊時代是基於軟體構建起來的,由工程師不斷貢獻智力和體力,從而產生價值的時代。產品需求就好像前文說到的獵物,完成需求類似於成功捕殺獵物,而產品逾期就好比被獵物吃掉。因此在這個時代,也需要一些可以好用且容易使用的功能程式碼段,方便程式設計師拿來快速實現需求,就好比遠古時代的可以複用的石矛。製作這種功能程式碼段的過程叫做元件化,這種方法帶來的產出叫做元件,俗稱輪子。

上古時代的輪子

從本質上說,元件是通過庫的方式來進行封裝從而提供給開發者使用。而庫,就是一種組織一個或多個檔案的方式。在 iOS 8 之前,iOS 只支援以靜態庫的方式來使用第三方的程式碼。

靜態庫

靜態庫,在iOS中會被打包成.a檔案,配合.h標頭檔案一起可以完成功能的呼叫。但是在在概念上,靜態庫是一種All In One的設計思路,因為依賴靜態庫的程式碼會把靜態庫完全連結到App的可執行檔案中。也就是說,靜態庫是在編譯器被連結到App中的,因此如果多個App都引用了同一個靜態庫,則每個App都會把這個靜態庫連結一份,這其實浪費了記憶體。
當然,靜態庫的缺點不止於此。在使用靜態庫時,必須手動一個個連結它依賴的外部庫,例如早期微信支付SDK的靜態庫接入方法中,必須要手動連結上:

SystemConfiguration.framework, 
libz.dylib, 
libsqlite3.0.dylib, 
libc++.dylib,
Security.framework, 
CoreTelephony.framework,
CFNetwork.framework
複製程式碼

有沒有一種需要輪流背誦蒸羊羔、蒸熊掌、蒸鹿尾兒、燒花鴨、燒雛雞、燒子鵝、滷豬。。。的既視感。
而且,靜態庫的特點導致了App每次啟動時都要重新載入靜態庫的記憶體,無法控制載入時機,而且每次啟動都需要重新載入靜態庫,導致二次載入時間無法被優化。
大部分時候,還需要在Other Linker Flags裡填入Objc -all_load來確保靜態庫正常工作。
好吧,聽起來靜態庫很難用。
我們都知道,後期iOS支援了動態庫。那動態庫是不是就能完美解決問題了呢?

動態庫

動態庫,大部分會被打包成.tbd檔案或者.dylib檔案。不同於靜態庫在編譯期連結到App,動態庫是在執行時連結到App的,因此它有了三個好處:

  • 按需載入,什麼時候需要執行什麼時候載入,提高了啟動app的效率
  • 因為存在多個app使用同一個動態庫的情況,因此一旦某個動態庫被載入到記憶體中,下一個app使用時無需再次耗費記憶體載入此動態庫,大家公用一個動態庫。
  • 因為動態庫不需要參與編譯過程,因此不會產生連結時符號衝突的問題。

不過,蘋果對動態庫的完全支援僅停留在系統的動態庫上,例如UI.framework,對於第三方的動態庫,還是需要embed到系統中。早期的一些熱更新框架,例如JSPatch鑽了漏子通過dlopen來進行熱更新,不過很快被禁掉了。
不過,如果是企業證書,還是可以在自己的app裡靈活的載入第三方動態庫的。

Framework

在解釋靜態庫和動態庫的過程中,我並沒有提framework的字眼。有些開發者覺得framework檔案就是動態庫,其實並不準確。
我們提到的framework,指的是.framework檔案,這既不一定是靜態庫,也不一定是動態庫。實際上這是一種打包方式,將Header(標頭檔案)、Binary(二進位制程式碼檔案)和bundle(資原始檔)一起打包,方便開發者進行接入和呼叫。
因此framework到底是靜態庫還是動態庫,取決於Binary檔案(Mach-O檔案)到底是靜態庫還是動態庫。

痛點

“老一輩”的iOS開發都會記得手動引入靜態庫時,那無止境的編譯錯誤。我簡單總結一下,如果手動引入靜態庫,需要:

  • 將靜態庫和標頭檔案引入工程
  • 新增各依賴庫(不同版本下可能略有不同)
  • 修改Other_linker_flags,例如設定-ObjC,-fno-objc-arc等引數
  • 祈禱
  • 編譯,如果出問題,從第一步進行檢查
  • 如果沒有問題,未來要手動管理更新

程式設計師的創造力很多時候來源於“懶”,終於,CocoaPods橫空出世,從此開啟了一行命令列完成模組整合的時代!

CocoaPods

簡介

CocoaPods是iOS平臺當前最流行的包管理工具,可以將它理解為一個可以自動部署到專案的元件池,而對應的podfile檔案就相當於請求元件的Request。當元件下載到工程後,cocoaPods會自動完成元件整合到現有專案的工作,並完成修改.xcodeproj檔案和建立.xcworkspace檔案。最終將所有元件統一打包成Pods.framework靜態庫,供專案使用。

在CocoaPods中,會存在以下幾種檔案:

  • podspec
    Pod的描述檔案,一般來說表徵你的專案地址,專案使用的平臺和版本等資訊
  • podfile
    使用者編寫的對於期望載入的pod以及對應Target資訊
  • podfile.lock
    記錄了之前pod載入時的一些資訊,包括版本、依賴、CocoaPods版本等
  • mainfest.lock
    記錄了本地pod的基本資訊,實際上是podfile.lock的拷貝
    大部分開發者最熟悉的cocoaPods指令就是pod install,那具體在執行pod install時發生了什麼呢?

pod install 執行原理分析

當我們執行pod install時,會發生:

  • 分析Dependency。
    對比本地pod的version和podfile.lock中的pod version,如果不一致會提示存在風險
  • 對比podfile是否發生了變化。
    如果存在問題,會生成兩個列表,一個是需要Add的Pod(s),一個是需要Remove的Pod(s)。
  • (如果存在remove的)刪除需要Remove的Pods
  • 新增需要的Pod(s)。
    此時,如果是常規的CocoaPods庫(如果基於Git),會先去:
    • Spec下查詢對應的Pod資料夾
    • 找到對應的tag
    • 定位其Podspec檔案
    • git clone下來對應的檔案(根據具體協議的不同,這裡還可能存在以下幾種方式的download:Bazaar、Mercurial、HTTP、SCP、SVN)
    • copy到Pod資料夾中
    • 執行pre-Install hook
  • 生成Pod Project
    • 將該Pod中對應檔案新增到工程中
    • 新增對應的framework、.a庫、bundle等
    • 連結標頭檔案(link headers),生成Target
    • 執行 post-install hook
  • 生成podfile.lock,之後生成此檔案的副本,將其放到Pod資料夾內,命名為manifest.lock
    (如果出現 The sandbox is not sync with the podfile.lock這種錯誤,則表示manifest.lock和podfile.lock檔案不一致),此時一般需要重新執行pod install命令。
  • 配置原有的project檔案(add build phase)
    • 新增了 Embed Pods Frameworks
    • 新增了 Copy Pod Resources

其中,pre-install hook和post-install hook可以理解成回撥函式,是在podfile裡對於install之前或者之後(生成工程但是還沒寫入磁碟)可以執行的邏輯,邏輯為:

pre_install do |installer| 
    # 做一些安裝之前的hook
end

post_install do |installer| 
    # 做一些安裝之後的hook
end
複製程式碼

CocoaPods第三方庫下載邏輯

CocoaPods的下載流程
  • 首先,CocoaPods會根據Podfile中的描述進行依賴分析,最終得出一個扁平的依賴表。
    這裡,CocoaPods使用了一個叫做 Milinillo 的依賴關係解決演算法。簡單說就是使用了回溯法來整理出所有第三方庫的一個依賴列表出來,據說是CoocaPods的開發工程師原創的演算法,在解決問題上應該是夠用,但是貌似如果第三方庫複雜的時候會有效能問題。這裡美團技術團隊對此有專門的優化,詳情請見 美團外賣iOS多端複用的推動、支撐與思考
  • 針對列表中的每一項,回去Spec的Repo中檢視其podSpec檔案,找到其地址
  • 通過downloader進行對應庫的下載。如果地址為git+tag,則此步驟為git clone xxxx.git
    注意,此時必須要保證需要下載的pod版本號和git倉庫的tag標籤號一致。

所有依賴庫下載之後,便進入了和Xcode工程的融合步驟。

Xcode工程有什麼變化

Xcode工程上有什麼變化

在cocoaPods和Xcode工程進行整合的過程中,會有有以下流程

  • creat workspace
    建立xcworkspace檔案。其實xcworkspace檔案本質上只是xcodeproject的集合,資料結構如下:
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
   version = "1.0">
   <FileRef
      location = "group:Demo/Demo.xcodeproj">
   </FileRef>
   <FileRef
      location = "group:Pods/Pods.xcodeproj">
   </FileRef>
</Workspace>
複製程式碼
  • create group
    在工程中建立group資料夾,邏輯上隔離一些檔案

  • create pod project & add pod library
    建立pod.xcodeproject工程,並且將在podfile中定義的第三方庫引入到這個工程之中。

  • add embed frameworks script phase
    新增了[CP] Embed Pods Frameworks,相應的,多了pods_xxx的group,下列xxx.framework.sh,來完成將內部第三方庫打包成.a靜態庫檔案(在Podfile中如果選擇了!use_frameworks,則此步驟會打包成.framework)

    [CP] Embed Pods Frameworks
  • remove embed frameworks script phase
    如果本次podfile刪除了部分第三方庫,則此步驟會刪除掉不需要的第三方庫,將其的引用關係從Pod.xcodeproject工程中拿走。

  • add copy resource script phase
    如果第三方庫存在資源bundle,則此步驟會將資原始檔進行復制到集中的目錄中,方便統一進行打包和封裝。相應的,會新增[CP] Copy Pods Resources指令碼。

    [CP] Copy Pods Resources
  • add check manifest.lock script phase
    前文提到過,manifest.lock其實是podfile.lock的副本。此步驟會進行diff,如果存在不一致,則會提示著名的那句The sandbox is not sync with the podfile.lock錯誤。

  • add user script phase
    此步驟是對原有project工程檔案進行改造。在執行過pod install後,再次開啟原有工程會發現無法編譯通過,因為已經做了改動。

    • 首先,新增了對Pod工程的依賴,具體為引用中多了libPods_xxx.a檔案。此步驟的.a檔案(或者.framework檔案)為上述步驟中xxx.framework.sh打包出來的檔案,也就是說,cocoaPods會把所有第三方的元件封裝為一個.a檔案(或者.framework檔案)!

      靜態檔案引入
    • 建立了Pods的group,內含pods-xxx-debug.xconfig和pods-xxx.release.xconfig檔案。這兩個檔案是對應工程的build phase的配置。相應的,主工程的Iinfo->Configurations的debug和release配置會對應上述兩個配置檔案。

      Configurations
    • 上述兩個配置都做了什麼?包括:
      Header_search_path,指向了Pod/Headers/public/xxx,新增了Pods檔案編譯後的標頭檔案地址
      Other_LDFLAGS,新增了-ObjC等等
      一些Pods變了,例如Pods_BUILD_DIR等

至此,原有xcode工程和新建的Pod工程完成了整合和融合。

好了,cocoaPods的好處和原理已經介紹的差不多了。大部分時間,我們通過引用github上的元件就夠用了。但是有時候處於業務需要,我們需要來實現私有Pod庫。所以接下來我們來介紹下如何在公司內網來實現一個私有庫,實現一個私有元件。

利用CocoaPods實現私有元件

準備工作

  • 安裝好XCode
  • 配置好CocoaPods,並且可以pod update 以及 pod install 成功
  • 已經獲得CocoaPods的Repo的地址,以及對應pod的Git地址(這裡以git.xxx.com上申請的repo為例)
  • 涉及到的所有操作,請儘量在Terminal中進行,包括CocoaPods的相關操作(不要在CocoaPods官方客戶端操作)
  • 本文涉及到的Demo,可以去git.xxx.com/XXX_SPA_XXX…去圍觀

私有Spec Repo

所謂Spec Repo,就是Pods的索引。一旦在podfile中設定source為某個私有repo的git地址,在進行pod update的時候就會去這個repo中進行檢索,如果檢索到對應的pod,會讀取該Pod的podspec從而進行安裝。
一個Spec Repo的目錄結構如下:

image.png

之後我們去git.xxx.com上新建一個相應的Repo地址,之後新增repo到本地,該repo地址是為了後面提交podspec使用。

# pod repo add [Private Repo Name] [GitHub HTTPS clone URL]
pod repo add XXXCocoaPodsRepo git@git.xxx.com:XXX_SPA_XXX/iOS_CocoaPods_Repo.git
複製程式碼

成功後可以進入~/.cocoapods/repos目錄下檢視XXXCocoaPodsRepo這個目錄了。

建立並Clone目標Pod地址

這裡,我們以HelloXXXPod為例。
去git.xxx.com上去新建專案,之後獲取地址,為:

git@git.xxx.com:XXX_SPA_XXX/HelloXXXPod.git
複製程式碼

此時clone到本地,命令為:

git clone git@git.xxx.com:XXX_SPA_XXX/HelloXXXPod.git
複製程式碼

建立Pod專案工程檔案(原始碼方式)

這裡建議通過CocoPods的官方命令來進行Pod專案的建立,以測試專案HelloXXXPod為例,命令如下:

pod lib create HelloXXXPod
複製程式碼

不出意外地話,會提問你六個問題(cocoaPods v1.5.3版本):

1.What platform do you want to use? [ iOS / macOS ]

2.What language do you want to use? [ Swift / ObjC ]

3.Would you like to include a demo application with your library? [ Yes / No ]

4.Which testing frameworks will you use? [ Specta / Kiwi / None ]

5.Would you like to do view based testing? [ Yes / No ]

6.What is your class prefix?
複製程式碼

分別解釋一下

  • What platform do you want to use?? [ iOS / macOS ]
    問元件化應用在哪個平臺上,一般我們選iOS

  • What language do you want to use? [ Swift / ObjC ]
    使用何種語言,可以根據專案是OC還是Swift自行選擇

  • Would you like to include a demo application with your library? [ Yes / No ]
    問是否需要一個Demo工程,方便除錯Pod。如果是第一次做元件化,建議選Yes,方便pod的除錯

  • Which testing frameworks will you use? [ Specta / Kiwi / None ]
    問是否需要UT測試框架,可選擇Specta和Kiwi,或者選擇不要。

  • Specta是OC的一個輕量級TDD/BDD框架,參考github/specta

  • Kiwi是一個iOS的一個BDD框架,可以簡單地部署和使用。github/kiwi
    UT測試框架如果要選擇的話,建議選擇Kiwi,可以參考我之前寫的調研kiwi上手體驗
    本次的Demo,暫時選None

  • Would you like to do view based testing? [ Yes / No ]
    如果上一步選擇了Specta ,這步會生成一部分有利於做自動化測試的邏輯和程式碼

  • What is your class prefix?
    這裡可以指定你的專案字首,這樣在new一個類時會自動加上字首

之後我們執行pod install,生成的檔案目錄樹結構如下:

$ tree HelloXXXPod -L 2

HelloXXXPod
├── Example
│   ├── Build
│   ├── Podfile
│   ├── Podfile.lock
│   ├── Pods
│   ├── Tests
│   ├── helloXXXPod
│   ├── helloXXXPod.xcodeproj
│   └── helloXXXPod.xcworkspace
├── LICENSE
├── README.md
├── _Pods.xcodeproj -> Example/Pods/Pods.xcodeproj
├── helloXXXPod
│   ├── Assets
│   └── Classes
└── helloXXXPod.podspec
複製程式碼

開發

這時候可以在剛才生成的Example工程內做開發,這時候記得把新建的程式碼放到Classes目錄下。如果有圖片資源,建議放到Assets下。

開發、除錯完成之後,就可以去編輯podspec檔案了。按以下方式來修改,不明白的欄位請參考官方文件

這裡給出本次Demo的podspec供各位參考:


Pod::Spec.new do |s|
  s.name             = `helloXXXPod`
  s.version          = `0.1.0`
  s.summary          = `A short description of helloXXXPod.`
  s.description      = <<-DESC
TODO: Add long description of the pod here.
                       DESC

  s.homepage         = `https://git.xxx.com/XXX_SPA_XXX/HelloXXXPod`
  s.license          = { :type => `MIT`, :file => `LICENSE` }
  s.author           = { `nimomeng` => `nimomeng@tencent.com` }
  s.source           = { :git => `git@git.xxx.com:XXX_SPA_XXX/HelloXXXPod.git`, :tag => s.version.to_s }

  s.ios.deployment_target = `8.0`
  s.source_files = `helloXXXPod/Classes/**/*`
end

複製程式碼

其中,注意修改這幾個欄位:

  • s.name
  • s.homepage
  • s.source (非常重要)
  • s.source_files (如果不放在Classes下,記得在這裡指定檔案目錄)

本地除錯

如果是通過pod lib create命令建立的Pod,會在Example中自動配置好該pod的本地除錯指令碼,如下:

use_frameworks!

platform :ios, `8.0`

target `helloXXXPod_Example` do
  pod `helloXXXPod` :path => `../`

  target `helloXXXPod_Tests` do
    inherit! :search_paths

    
  end
end
複製程式碼

其中,pod `helloXXXPod` :path => `../`的含義是說,在上層目錄來下載helloXXXPod這個pod。這是本地除錯Pod的一種。
同樣的,可以實現類似方式除錯的方法,還有通過:podspec命令來指定,指定pod所在的podspec檔案位置即可

其中,path語法精確到目錄即可;podspec語法必須要精確到檔案。

設定好podfile之後,在Example檔案下執行pod install,則可以發現新的檔案已經出現在專案工程的pods資料夾之下了。

image.png

注意,通過path語法進行更新後,Pod中程式碼並不在Pod資料夾中,而是在一個叫 Development Pods中。

開發完成,需要本地驗證podspec,確保其有效:

pod lib lint helloXXXPod.podspec
複製程式碼

同步到Git上

之後要做的就是把庫同步到Git上去了。這時候需要去git.xxx.com上建立一個對應的倉庫,例如:

http://git.xxx.com/XXX_SPA_XXX/HelloXXXPod.git (替換為自己的實際git地址)
複製程式碼

然後將程式碼同步到此Git上。

git add .

git commit -m "Init"

git remote add origin http://git.xxx.com/XXX_SPA_XXX/HelloXXXPod.git(替換為自己的實際git地址)

git push --set-upstream origin master

複製程式碼

podSpec檔案需要版本控制資訊,所以我們要打一個Tag.

git tag -m "first demo" 0.1.0

git push --tags
複製程式碼

向Spec Repo提交podspec

在執行本歩之前,確保最新程式碼已經提交到了Git上,且已經打好了tag.

向Spec Repo提交podspec的命令:

pod repo push XXXCocoaPodsRepo HelloXXXPod.podspec --allow-warnings
複製程式碼

在經過三輪的使用者校驗之後,提交成功!這時候我們去~/.cocoapods/repos/XXXCocoaPodsRepo中檢視,我們的的podspec已經在裡面了!

此時通過pod search HelloXXXPod 已經可以查到了!

image.png

最後,為了保證本地的repo已經被更新,執行pod update來更新repo

如何在外部專案中使用

我們可以在想要使用的專案中的Podfile里加入如下程式碼:

pod `helloXXXPod`
複製程式碼

即可。
當然,由於我們的是私有CocoaPods庫,因此最好告訴系統這個庫的source在哪裡,因此在Podfile檔案上部也請加上Spec Repo的git地址。同時,為了確保公共的cocoaPod可以被正常下載,請新增外部CocoaPod的庫:

# For inner pods
source `git@git.xxx.com:XXX_SPA_XXX/iOS_CocoaPods_Repo.git`

# For public pods
source `https://github.com/CocoaPods/Specs.git`

複製程式碼

整個的Podfile檔案看起來是這樣的:

use_frameworks!

platform :ios, `8.0`

# source `git@git.xxx.com:XXX_SPA_XXX/iOS_CocoaPods_Repo.git`

# For public pods
source `https://github.com/CocoaPods/Specs.git`

target `helloXXXPod_Example` do
  pod `helloXXXPod`

  target `helloXXXPod_Tests` do
    inherit! :search_paths
	
  end
end

複製程式碼

之後執行pod install 即可安裝對應的Pods

驗證

我們可以複用Example專案,只不過這次不再通過:path命令或者:podspec命令來做本地呼叫,而是完全使用安裝外部pod的方式,即:

  pod `helloXXXPod`
複製程式碼

注意:雖然pod已經推送到線上,但是本地一定要先更新pod的repo,不然還是無法找到最新的pod。確保先做pod update操作。

Example專案中,我們呼叫在Pod中寫好的方法,檢視是否輸入對應的log即可驗證:

image.png

至此,Pod建立完成。

常見問題

  • 如果pod中用到framework,應該在哪裡新增?

    如果pod中用到framework,如AVFoundation,直接在podspec檔案中新增s.frameworks = ‘AVFoundation’或者s.frameworks = [‘AVFoundation’,`MapKit`],而不應該新增在專案的Link Binary With Libraries下面。

  • 怎麼取更新私有 pod?

    更新私有pod的過程和建立pod的步驟一致,但是要記得在更改程式碼後要記得一定重新run一下aggregate,更改podspec裡的s.version(因為tag不能重複提交), 重新pod repo push

  • 如果出現這個錯誤怎麼辦:

[!] An unexpected version directory `Assets` was encountered for the `/Users/nimo/.cocoapods/repos/xxxx` Pod in the `xxxx` repository.
複製程式碼

這個錯誤,請檢視:

  • podspec 是否未上傳到伺服器
  • Podfile的source地址是否是Spec Repo的地址,而不是具體某一個Pod的地址。

參考文章

相關文章