289M→259M得物包體積治理實踐
一、前言
iOS應(yīng)用的包體積大小是衡量得物性能的重要指標,過大包體積會降低用戶對應(yīng)用的下載意愿,還會增加用戶的下載等待時間以及用戶手機的存儲空間,本文重點介紹在包體積治理中的新思路以及原理與實踐。
二、原理介紹
Macho產(chǎn)物測試
我們拿測試工程單獨依賴一個組件,比如DemoModule,進行編譯MarchO得出整合前的大?。?8929120Byte。同時為了方便分析,我們也導出Linkmap.txt文件。
Linkmap文件中記錄MachO文件中每個符號所占用的體積大小,因此通過分析Linkmap可以分析MachO具體符號占用變化,由于Linmap介紹不是本文重點,不多做贅述,更多詳情可參考網(wǎng)上文章https://juejin.cn/post/6844904168096792583。
隨后將組件工程中的文件編碼整合10~20個,得出整合后MachO的大?。?8894688Byte。(下圖為編碼前和編碼后的MarchO占用磁盤大?。?/p>
圖片
圖片
LinkMap分析
整合后的文件變小了34K,我們繼續(xù)分析產(chǎn)物導出的Linkmap,具體查看是哪里變小了。
- 通過對比Linkmap.txt發(fā)現(xiàn):Text段減小10.6K、en_frame段減小了2K。
Linkmap.txt文件第一列展示的是符號的起始地址,第二列展示的是大小,16進制,將16進制轉(zhuǎn)換為10進制,即是大小。相減得出變化大小。
__text段存儲的機器編譯后的代碼。
en_frame存儲了函數(shù)調(diào)用入口幀信息。具體查看https://refspecs.linuxfoundation.org/LSB_3.0.0/LSB-Core-generic/LSB-Core-generic/ehframechpt.html。
Linkmap符號變化
繼續(xù)解析Linkmap每個組件的變化,我們發(fā)現(xiàn),DemoModule組件減小15K、連接器自動生成符號的變化減小2K。
圖片
左圖為組建變化,右圖為鏈接符號變化
通過對Linkmap的分析,確實存儲代碼段和函數(shù)入口幀信息減小使得編譯后的.o文件變小了,那么.o文件編碼整合為DemoModule.a文件也隨之變小了,那么到底是哪塊代碼變小了呢?我們繼續(xù)往下分析。
Mach-o代碼內(nèi)容分析
Mach-o代碼內(nèi)容分析
通過上面Linkmap的分析,我們知道了是代碼段以及函數(shù)調(diào)用符號占用的體積變小了,我們通過objcdump將MarchO符號進行導出。
objdump --macho -d --start-address=0x10025FDD0 --stop-address=0x100257668 ~/Desktop/IPATestProj > ~/Desktop/result.txt
objdump --macho -d --start-address=0x10025FDD0 --stop-address=0x100257668 ~/Desktop/IPATestProj-after > ~/Desktop/result-after.txt
對比發(fā)現(xiàn)
- 針對s13DemoModule0A29TSearchHotRecommendDemoModuleCACycfC優(yōu)化了28Byte。
- allocWithZone以及objc的init方法。調(diào)用了DemoModule0A21FollowBrandDemoModuleCACycfC, DemoModule0A21FollowBrandDemoModuleCACycfC 調(diào)用了s13DemoModule0A17PaySendDemoModuleCACycfC,s13DemoModule0A17PaySendDemoModuleCACycfC里實現(xiàn)了alloc with zone和init方法。也就是說編譯器優(yōu)化了經(jīng)過編碼的alloc with zone方法,只會有一個alloc with zone 的實現(xiàn)。
- 針對s13DemoModule0A29TSearchHotRecommendDemoModuleCMr
- DemoModule.ExampleModule.__deallocating_deinit優(yōu)化了32Byte
- 優(yōu)化了meta的deinit與尋找metaclass的過程。s13DemoModule0A29DemoModuleCMa調(diào)用了s13DemoModule0A31tDemoModuleCMaTm - ,而 和SearchDemoModule同時繼承了CustomRequestDemoModule。
Alloc with zone前后對比
- 整合編碼之前的逆向機器碼
_$s13DemoModule0A29TSearchHotRecommendDemoModuleCACycfC:
1002fe8c8: fd 7b bf a9 stp x29, x30, [sp, #-16]!
1002fe8cc: fd 03 00 91 mov x29, sp
1002fe8d0: e0 03 14 aa mov x0, x20
1002fe8d4: 3d 2a 20 94 bl 0x100b091c8 ; symbol stub for: _objc_allocWithZone
1002fe8d8: 28 64 00 b0 adrp x8, 3205 ; 0x100f83000
1002fe8dc: 01 79 44 f9 ldr x1, [x8, #2288] ; Objc selector ref: init
1002fe8e0: fd 7b c1 a8 ldp x29, x30, [sp], #16
1002fe8e4: 7b 2a 20 14 b 0x100b092d0 ; Objc message: -[x0 init]
_$s13DemoModule0A29TSearchHotRecommendDemoModuleCACycfc
- 整合編碼之后的逆向機器碼
1002577a0: b2 ff ff 97 bl _$s13DemoModule0A29TSearchHotRecommendDemoModuleCACycfc
1002577a4: fd 7b 41 a9 ldp x29, x30, [sp, #16]
1002577a8: f4 4f c2 a8 ldp x20, x19, [sp], #32
1002577ac: c0 03 5f d6 ret
_$s13DemoModule0A29TSearchHotRecommendDemoModuleCfD:
1002577b0: 60 fe ff 10 adr x0, #-52
1002577b4: 1f 20 03 d5 nop
1002577b8: f2 fd ff 17 b _$s13DemoModule0A31IdentComTrendDelLightDemoModuleCfDTm
_$s13DemoModule0A26TMeasureRecordAiUpdateSkinCACycfc:
- Deinit前后對比-整合編碼之前
_$s13DemoModule0A29TSearchHotRecommendDemoModuleCMa:
1002fe9fc: fd 7b bf a9 stp x29, x30, [sp, #-16]!
1002fea00: e8 03 00 aa mov x8, x0
1002fea04: 89 71 00 b0 adrp x9, 3633 ; 0x10112f000
1002fea08: 20 7d 40 f9 ldr x0, [x9, #248]
1002fea0c: 80 00 00 b4 cbz x0, 0x1002fea1c
1002fea10: 01 00 80 d2 mov x1, #0
1002fea14: fd 7b c1 a8 ldp x29, x30, [sp], #16
1002fea18: c0 03 5f d6 ret
1002fea1c: 41 4c 00 90 adrp x1, 2440 ; 0x100c86000
1002fea20: 21 80 24 91 add x1, x1, #2336
1002fea24: e0 03 08 aa mov x0, x8
1002fea28: f7 2c 20 94 bl 0x100b09e04 ; symbol stub for: _swift_getSingletonMetadata
1002fea2c: fd 7b c1 a8 ldp x29, x30, [sp], #16
1002fea30: c0 03 5f d6 ret
- Deinit前后對比-整合編碼之后
0x10025777C 0x00000014 [583] _$s13DemoModule0A29TSearchHotRecommendDemoModuleCMa
由此可以得出結(jié)論。
- 編譯器針對不同的Class,經(jīng)過編碼整合后,編譯時會觸發(fā)編譯優(yōu)化,alloc with zone、deinit尋找metaclass方法。將文件編碼后整合會優(yōu)化為一個。
- 同時相關(guān)的尋址和寄存器的addr,以及mov、內(nèi)存地址的存儲已隨之刪除,具體對比結(jié)果可以看上面的產(chǎn)物對比。
三、落地實踐
經(jīng)過上文的原理探究,整合一個組件有34K的收益,得物全工程是一個由1100+組件組成的Swift工程,那么我們基于組件的維度,將1100+組件做整合,那么就能拿到收益了,為了做到文件編碼整合,拿到收益,我們需要在穩(wěn)定性的基礎(chǔ)上做到如下的目標。
- 需要滿足線上包、灰度包、測試包等所有CI流程出的包都是文件編碼整合后的包,并且需要保證相同的版本,文件編碼整合的一致性。
得物工程的組件化CI是使用Cocoapods來實現(xiàn)的,因此需要改造Cocoapods 的download流程,將文件編碼整合嵌入到所有的發(fā)版與打包的CI中。
為了滿足大家的正常使用,需要為pod定制命令,比如--megre-file --clean-sanbox,正常開發(fā)默認命令不生效,為打包機等CI任務(wù)配置命令,做到開發(fā)無感知,發(fā)版無縫整合。
- 需要判斷整個工程盤點出可能存在的風險點,并在整合前做改造,盤點出主要的改造點:
項目中存在同名的public或者open聲明的extension方法,之前存在于不同的文件編碼中,不會造成編譯報錯,經(jīng)過編碼后之后會出現(xiàn)大量的編譯報錯,整合后通過編譯器去識別項目中同名的方法,在改造發(fā)版,每次改造編譯源碼都需要大量的時間,這顯然是不現(xiàn)實的,因此我們需要通過indexstore-db與 SwiftSytax將項目中所有的同名extension方法做識別統(tǒng)一改造,并一次性的編譯
項目中存在已#fileID、#file、#line方法與業(yè)務(wù)耦合,做調(diào)用位置判斷,由于文件編碼整合行號與,打包時的文件名都發(fā)生的變化,因此我們也需要通過SwiftSytax將所有方法導出,并做甄別改造。
需要分節(jié)奏,分版本,做好充分的灰度測試,灰度上線逐步拿到收益。
為了做到組件分版本灰度,需要為cocoapod bin pod命令增加版本的概念,為每個上線的組件配置好版本號,滿足配置的組件在執(zhí)行整合,不滿足的組件走原有download流程。
如何滿足上述3項目標,下面為大家逐一介紹。
Cocoapods原理與實踐
介紹流程:當我們執(zhí)行pod install時,會在你的電腦發(fā)生如下步驟。
1. 執(zhí)行pod install時會進入到本機電腦的/usr/local/bin/pod我們發(fā)現(xiàn)是一個快捷方式。
圖片
2. 右鍵點擊顯示原項目,我們就進入到了真正的執(zhí)行指令的入口目錄。
圖片
由于筆者的pod是使用homebrew裝的,因此pod可執(zhí)行文件在homebrew的安裝目錄: /opt/homebrew/Cellar,這個pod文件本質(zhì)是個bash sh文件,咱們將文件已編輯器打開有如下的內(nèi)容。
GEM_HOME="/opt/homebrew/Cellar/ruby環(huán)境根目錄" exec "/opt/homebrew/Cellar/真實的cocoapods目錄/bin/pod" "$@"
繼續(xù)打開后面的執(zhí)行文件,發(fā)現(xiàn)這個pod文件就是cocoapods安裝文件下的pod,pod是一個ruby文件,也就是cocoapods最終的命令入口,內(nèi)容如下:
#!/usr/bin/env ruby
require 'rubygems'
if Gem.respond_to?(:activate_bin_path)
load Gem.activate_bin_path('cocoapods', 'pod', version)
else
gem "cocoapods", version
load Gem.bin_path("cocoapods", "pod", version)
end
3. 隨后就進入到了cocoapods的cocoapods/cocoapods.rb,cocoapods.rb引入了 核心類,比如:
Xcodeproj::PlainInformative.send(:include, CLAide::InformativeError)
autoload :Command, 'cocoapods/command' # 命令行入口
autoload :ExternalSources, 'cocoapods/external_sources' # git 依賴,本地依賴處理類
autoload :Installer, 'cocoapods/installer' # pod install核心 類
cocoapods/command是一個基類,每個命令pod install、pod update、pod repo add都會有相應(yīng)command重寫??刹榭聪旅娴慕貓D:
圖片
4. 我們單純拿pod install看文件里的內(nèi)容。就知道我們?nèi)绾谓opod命令傳遞參數(shù)了。
- 從文件內(nèi)容可以看到class Install繼承了 Command,def initialize中定義需要傳遞的參數(shù)其中clean_install就是我們常用pod install --clean-install命令。
- def run函數(shù)進入了install的流程,下面也為大家簡單注釋了每個函數(shù)的作用。
def initialize(argv)
super
@deployment = argv.flag?('deployment', false)
@clean_install = argv.flag?('clean-install', false)
end
def run
verify_podfile_exists! # 校驗 工程目錄Podfile 文件是否存在
installer = installer_for_config # 根據(jù)config 生成 installer
installer.repo_update = repo_update?(:default => false) # 配置是否需要更新索引庫
installer.update = false # 由于是install 因此 update 是false
installer.deployment = @deployment
installer.clean_install = @clean_install
installer.install! # 進入真正的install 流程
end
那installer.install里面做了什么呢?我們繼續(xù)往下看:
5.下面是installer.install的源碼,我們可以簡單將install分為以下步驟:
圖片
# install 源代碼
def install!
prepare
resolve_dependencies # 依賴鏈分析
download_dependencies #
validate_targets
clean_sandbox
if installation_options.skip_pods_project_generation?
show_skip_pods_project_generation_message
run_podfile_post_install_hooks
else
integrate
end
write_lockfiles
perform_post_install_actions
end
- resolve_sependencies會更新索引庫,得到pods target對應(yīng)的數(shù)據(jù),得到aggregate target對應(yīng)的數(shù)組,并提前加載git依賴與本地依賴的組件。為下載pod依賴做環(huán)境準備。
1. 為了能將:git、:branch、:commit依賴與本地:path依賴做代碼編碼并整合,我們需要再resolve_dependencies中加載git依賴與本地依賴階段時做hook,滿足本地依賴與git依賴組件集成時做代碼編碼整合。
2. 為了能對正常pod"Example"組件做整合,我們需要再download_dependencies中對組件做整合。具體實現(xiàn)整合與定制參數(shù)傳入,我們繼續(xù)往下看。
Pod命令改造:引入pod update --transform-local --transform-file。
1. 上文我們了解了,每個command都有一個命令類,為了不污染cocoapods的源碼,使得能正常隨著cocoapods更新進行升級,我們模仿cocoapods設(shè)計的想法,在cocoapods/cocoapods.rb核心類引入hook/hook_option.rb,cocoapods.rb加入的內(nèi)容如下:
# hook_file用于統(tǒng)一管理du_hook文件
# 判斷有沒有hook_file
if File.exist?(File.join(__dir__, 'cocoapods/du_hook/hook_file.rb'))
require 'cocoapods/hook/hook_file'
end
2. 在hook_file中,Cat同學為cocoapods做了熱更新機制,同時引入了main_hook.rb、main_hook.rb中引入了得物為cocoapods做的魔改部分。代碼如下:
熱更機制簡單理解就是每次執(zhí)行pod命令時會執(zhí)行g(shù)it操作,將魔改部分的倉庫代碼保持到最新,Cat這一巧妙的設(shè)計讓得物iOSer都能實時享受到cocoapods改造帶來的新功能。
require 'cocoapods/hook/cocoapods-hook/cocoapods_concurrent_hook' # 魔改的高并發(fā)下載
require 'cocoapods/hook/cocoapods-hook/cocoapods_option' # 為cocoapod 加入 命令參數(shù)的入口
3. 我們繼續(xù)往下看,在cocoapods_option.rb文件中。咱們模仿cocoapods的設(shè)計邏輯,對命令解析。如果有傳入--transform-file --transform-local 參數(shù),那么就引入 cocoapods_transform_file.rb 文件,進入文件編碼整合的入口。
module Pod
class Command
module Options
module Demo
module Options
def initialize(argv)
# 每個電腦都有一個全局的環(huán)境變量,在執(zhí)行命令的生命周期內(nèi)是一直存在的,給環(huán)境變量傳入配置,不改動cocoapods的config源配置文件,不入侵cocoapods的源代碼。
ENV['transform_FILE'] = '1' if @transform_file
ENV['ransform_LOCAL'] = '1' if @transform_local
super
end
end
end
end
end
Ruby是一個運行時的動態(tài)語言,在required cocoapods_transform_file文件中,將指定的cocoapods函數(shù)進行重寫,就能實現(xiàn)HOOK的功能。
- 因此在cocoapods_transform_file.rb文件中覆蓋cocoapods/external_sources/path_source.rb 下class PathSource的fetch方法就能定制為本地依賴的組件、git依賴的組件的組件執(zhí)行定制的整合能力
- 覆蓋cocoapods/downlod.rb下的Module Downloader self.download module類方法就能定制為pod"Example"的組件執(zhí)行定制的整合。
具體的整合思路我們繼續(xù)往下看。
Pod組件編碼整合介紹
- 我們?yōu)槊總€組件配置了整合的版本號,每次需要進行整合時會傳入版本號,默認是不整合,當一個組件進入download流程。會優(yōu)先判斷組件配置的版本號是否滿足。
- 如果不滿足那么不進行整合,正常執(zhí)行常規(guī)的下載流程。
- 如果滿足:
會繼續(xù)判斷是否在存在已經(jīng)緩存好的文件夾,如果存在,直接將整合好的緩存文件Copy到Pods文件夾
Cococoapods的組件緩存目錄在~/Library/Caches/Cocoapods/Pods/Release/<版本號>-hash
我們?yōu)榱四芴岣哒系男蕿槊總€整合好的組件也進行緩存,這樣能明顯提高cocoapods的下載效率。命令規(guī)則會在原目錄下多一份~/Library/Caches/Cocoapods/Pods/Release/<版本號>-hash-setuped。
- 如果不存在緩存文件,那么解析podspec,拿到待整合文件數(shù)組,執(zhí)行整合,保存整合后的緩存,并將整合后的緩存Copy到Pods文件夾。
圖片
本地依賴組件整合介紹
- 當本地依賴組件進入fetch方法,判斷組件配置的版本號是否滿足,不滿足不執(zhí)行整合。
- 滿足則執(zhí)行整合,并將整合的內(nèi)容保存到新文件中,保存到pod target數(shù)組中以備后續(xù)cocoapods生成本地組件的pod targets。
圖片
Native代碼整改
為什么要改造?
- 針對所有文件做編碼并整合,會使得分散在不同文件中的同名方法名稱沖突,使得工程無法編譯成功,因此需要掃描出工程中所有的同名方法,并掃描出同名方法的上層調(diào)用。
如何改造?
- 掃描工程中所有的方法可以借助swift-syntax或者SwiftLint自定義規(guī)則具體掃描代碼可參考如下。
SwiftLint中依賴了Swiftsytax,本質(zhì)都是借助Swiftsyntax進行詞法分析,掃描出工程的所有extension同名函數(shù),并進行改造。
override func visitPost(_ node: ExtensionDeclSyntax) {
let functionList = _isFunctionDecl(node)
guard !functionList.isEmpty else { return }
for funcItem in functionList {
// 如果是private function 那么不納入考慮范圍
guard !_isPrivateFunction(funcItem) else { continue }
// 如果不是public的extension,并且函數(shù)也不是public 那么這個函數(shù)就不是公開函數(shù),也可以忽略
if !isPublicExtension && !_isPublicFunction(node: funcItem) {
continue
}
violations.insert(ReasonedRuleViolation(position: funcItem.position, reason: funcItem.resolvedName(), severity: .warning), at: violations.count)
}
}
- 掃描出同名方法后,使用indexstore-db將方法簽名傳入,通過掃描產(chǎn)物,可得出方法的上層調(diào)用,并進行統(tǒng)一改造,indexstore-db使用可參考如下
Indexstore-db是一個用于存儲和管理源代碼索引數(shù)據(jù)的開源工具。indexstore-db工具可以收集和存儲源代碼的元數(shù)據(jù)信息,包括符號、模塊依賴關(guān)系、引用關(guān)系等,以便在開發(fā)工具(如Xcode)中進行快速的代碼導航和搜索。它在構(gòu)建大型代碼庫時尤其有用,可以提高代碼編輯、查找引用、代碼重構(gòu)等操作的效率。
func testExtensionSymbol() throws {
// indexstore-db 的動態(tài)加載庫
let libIndexStore = try! IndexStoreLibrary(dylibPath: "/Applications/Xcode.app/xxx/libIndexStore.dylib")
// 生成indexstore 實例
let indexWait = try IndexStoreDB(storePath: "/Users/xxx/Library/Developer/Xcode/DerivedData/.../DataStore", databasePath: "/Users/xxx/Downloads/aaa", library: libIndexStore, waitUntilDoneInitializing: true)
indexWait.pollForUnitChangesAndWait()
// 假設(shè)我們需要掃描如下的文件
let symbols = indexWait.symbols(inFilePath: "/Users/xxx/Project/String+Demo.swift")
for symbol in symbols {
// 假設(shè)我們需要掃描searchAtRange函數(shù)。
guard symbol.name == "searchAtRange()" else { continue}
let res = indexWait.occurrences(ofUSR: symbol.usr, roles: .reference)
for x in res {
debugPrint(x.relations.compactMap({ symbol in
return symbol.symbol.usr
}))
}
}
}
組件發(fā)版流程重構(gòu)
為什么要改造?
- 將cocoapods與同名方法改造完后,我們進行全工程源碼編譯是可以通過的,而且由于做了編碼整合,編譯時長也降低了5~8分鐘,但是當發(fā)布組件發(fā)布CI時發(fā)現(xiàn),未整合的組件二進制與整合的源碼會出現(xiàn)link時符號不對齊的問題。
未整合的組件二進制符號是確定的,調(diào)用下游的符號簽名也是確定的,Swift有 fileprivate的函數(shù)定義,當函數(shù)由A文件經(jīng)過編碼遷移到整合后的文件時,函數(shù)的簽名也會變化。因此會出現(xiàn)函數(shù)簽名符號不對齊。
如何改造?
- 得物工程每個版本都有一個源碼索引庫和二進制索引庫,因此在組件發(fā)版時,我們需要再創(chuàng)建一個索引庫,編碼整合后的二進制索引庫,并重新建立一套編碼整合的二進制的CICD打包流程。具體流程可參考如下。
開發(fā)者開發(fā)時使用正常的二進制制作任務(wù)。發(fā)版與出包的打包機會使用整合二進制索引庫。這樣設(shè)計使得對日常開發(fā)無感知,而且能保證對外提測的任務(wù)都是整合后的包。
圖片
整合符號表
- 上述的改造解決了編譯和出包的問題,但編譯后的報錯工程師閱讀會比較困難,為了解決這個問題,引入了整合符號表,能根據(jù)符號表,反推出源工程的文件名以及行號,這就解決了編譯報錯閱讀難的問題。
圖片
四、總結(jié)與收益
經(jīng)過了深度的治理以及組件編碼整合,期間cocoapods的改造與ruby原理的學習得益與Cat的請教,并得到各個iOS開發(fā)伙伴的無條件支持,同時將整個構(gòu)建打包流程做了重構(gòu),以滿足組件編碼,經(jīng)過多個版本的治理,得物的包大小在業(yè)務(wù)代碼迭代有增量的前提下,從289.3M降低至259.3M。
圖片
圖片
下面列出每個階段治理做個小結(jié)。