摘要:本文主要介紹如何通過對Mach-O文件的解析以及反匯編的應(yīng)用實現(xiàn)OC&Swift的無用代碼檢測崭篡,重點介紹Swift的檢測方案。本文作為Swift Mach-O的應(yīng)用篇畸悬,建議先閱讀《從Mach-O角度談?wù)凷wift和OC的存儲差異》和《Swift Hook新思路--虛函數(shù)表》了解相關(guān)概念和結(jié)構(gòu)顽腾。相關(guān)代碼已經(jīng)開源:WBBlades,如果感覺工具或方案對您有幫助不妨幫忙點個star忧设。
背景
近期很多大型APP都在做支持Swift與Objective-C的混編開發(fā)的工作,58集團(tuán)旗下的各個APP也在積極探索使用Swift語言開發(fā)颠通。因此可以預(yù)見址晕,在未來的幾年里集團(tuán)內(nèi)各個iOS項目中Swift代碼的占比會越來越高。因此我們需要考慮Swift代碼激增后所帶來的一些問題顿锰。如何檢測混編項目中無用代碼是我們面臨的諸多問題之一谨垃。
關(guān)于無用代碼檢測
無用代碼需不需要檢測?需不需要刪除硼控?無用代碼刪除在所有的性能優(yōu)化手段里基本上是ROI最低的刘陶。但是幾乎所有ROI較高的技術(shù)手段都是一次性優(yōu)化方案,經(jīng)過幾個版本迭代后再做優(yōu)化就會比較乏力牢撼。相比之下匙隔,針對代碼的檢測和刪除在很長的一段時間內(nèi)提供了很大的優(yōu)化空間。我們以58APP的10.15.1版本為例熏版,iPhone 7設(shè)備上的App Store正式包中主二進(jìn)制文件的大小占APP包大小的66%纷责,動態(tài)庫占15%捍掺,而資源占比不足20%。
在越獄設(shè)備上獲取從App Store下載的包可以準(zhǔn)確查看當(dāng)臺設(shè)備上的包構(gòu)成(個人認(rèn)為這是最準(zhǔn)確的測算方式)再膳。58APP的資源占比較小是因為我們主要使用xcassert存儲圖片挺勿,這可以充分利用分片下發(fā)的能力。如果你的圖片存儲依舊使用bundle存儲喂柒,那么可能資源的比例會相對高一些不瓶,在這種情況下建議先將資源轉(zhuǎn)存到xcassert。
除了包大小優(yōu)化外灾杰,及時刪除無用代碼對啟動優(yōu)化也有一定的幫助蚊丐。另外,無用代碼檢測和刪除在項目維護(hù)上起到了很重要的作用艳吠。冗余的代碼往往意味著開發(fā)者需要更多額外的精力來評估需求的影響范圍麦备,及時刪除廢棄的代碼可以從一定程度上提升開發(fā)效率。無用代碼的靜態(tài)檢測并不是要將項目中的所有無用代碼都檢測出來讲竿,而是能為后續(xù)的檢測流程在龐大的源碼庫中提供一個圈選能力泥兰。因此靜態(tài)檢測需要提供相當(dāng)數(shù)量的疑似無用代碼集合,并且在這個集合中無用代碼的比例應(yīng)該盡可能的高题禀。靜態(tài)檢測的準(zhǔn)確度有限鞋诗,并不能作為單一手段,因此只能起到前置過濾的作用迈嘹。在58同城中削彬,除了WBBlades檢測外,還有根據(jù)業(yè)務(wù)代碼特征的二次過濾以及運(yùn)行時判斷等手段秀仲。
混編項目無用代碼檢測的幾種手段
在OC開發(fā)環(huán)境中融痛,無用代碼的檢測方案比較多,但是OC&Swift混編環(huán)境的無用代碼檢測方案相對較少神僵。原因是OC與Swift無論是在編譯前端還是編譯后的二進(jìn)制文件上雁刷,都存在較大的差異。這就導(dǎo)致OC的檢測方案不一定適用于Swift保礼,而Swift的檢測方案也不一定適用于OC沛励。目前業(yè)界常用的技術(shù)手段包括AppCode工具檢測以及以例如Pecker
這樣的基于 IndexStoreDB
和SwiftSyntax
的靜態(tài)檢測方案,通過SwiftSyntax
獲取所有符號并通過IndexStoreDB
獲取符號之間的索引關(guān)系炮障,從而確定哪些代碼之間的引用關(guān)系目派,得到無用代碼集合。當(dāng)然除了這兩種技術(shù)方案外胁赢,還有很多其他的方案企蹭,例如:源碼文本分析、針對framework目標(biāo)文件優(yōu)化的技術(shù)方案、基于Mach-O文件分析的技術(shù)等等谅摄。至于選擇哪種技術(shù)方案主要取決于當(dāng)前的工具在什么場景下使用徒河,甚至不同代碼量的APP最優(yōu)方案也不同。58APP在接入Swift語言之前就已經(jīng)確定了基于Mach-O分析的無用代碼檢測方案螟凭,這主要是該方案比較容易接入到版本流程中虚青。因此它呀,為了保持技術(shù)方案的統(tǒng)一螺男,在項目混編后我們依舊采用基于Mach-O文件分析的方式來實現(xiàn)無用代碼檢測。
OC是如何實現(xiàn)無用代碼檢測的纵穿?
OC的無用代碼檢測和優(yōu)化方案有很多種下隧,優(yōu)化方案遍布編譯、鏈接谓媒、Product淆院、運(yùn)行等各個階段。58同城采用的是對Product的掃描以及運(yùn)行時核驗的雙重保障機(jī)制句惯。其中對Product的掃描就是通過WBBlades掃描Mach-O文件來實現(xiàn)的土辩。基本思路就是對classlist和classrefs做差集抢野,形成初步的無用類集合拷淘,并根據(jù)業(yè)務(wù)代碼特征做二次適配。例如:作為基類或者成員變量的類指孤、通過完整字符串實現(xiàn)動態(tài)調(diào)用的類启涯、RN或者Hybrid的Module通過load方法注冊的類等都會被當(dāng)做有用的代碼,不會出現(xiàn)在無用代碼集合中恃轩,減少了二次核驗的成本结洼。但是此套方案無法直接應(yīng)用與Swift語言開發(fā)的項目,接下來我們來探討下原因及解決方案叉跛。
Swfit的類調(diào)用
在OC的檢測方案中松忍,很大程度上是依賴classlist和classrefs做差集來實現(xiàn)的。其他技術(shù)手段不過是作為補(bǔ)充技術(shù)手段筷厘。如果沒有classrefs這樣一個section為我們提供主要信息鸣峭,那么整個方案的技術(shù)基礎(chǔ)就會受到動搖。那我們首先要弄清楚類如何使用會被存儲到classrefs中敞掘。首先我們來看個示例:
WBBladesClass *b = nil;
id c = [WBBladesClass new];
Class d = NSClassFromString(@"WBBladesClass");
上面示例中只有通過[WBBladesClass new]顯式的方法調(diào)用時叽掘,OC的類才會被存儲classrefs中。
那Swift的類是不是也存在這樣的特性呢玖雁?
在Swift調(diào)用環(huán)境中更扁,被顯式調(diào)用的類并不會被加入的classrefs這個section中。下面的代碼經(jīng)過編譯鏈接后,查看MachOView發(fā)現(xiàn)TestClass0和TestClass1這兩個類并不在classrefs中浓镜。
class TestClass0: NSObject {
dynamic func hello() {
let obj = TestClass0.init()
}
}
class TestClass1 {
func hello() {
let obj = TestClass1.init()
}
}
但是溃列,如果類被導(dǎo)出到OC環(huán)境中使用,那么這個Swift類就會被加入到classrefs中膛薛。
class TestClass2 : NSObject{}
//在OC環(huán)境中調(diào)用則會被加入到classrefs中
+ (void)load{
id obj = [TestClass2 new];
}
因此可以說明classrefs只適用于OC的語言環(huán)境听隐,即使刨除Struct、enum等類型不談哄啄,classlist和classrefs做差集的方案也不適用于Swift的無用代碼檢測雅任。
那如何才能識別出來一個Swift類型被調(diào)用呢?
那么問題來了咨跌,如果沒有classrefs做記錄沪么,如何才能知道一個Swift的類被使用了呢?之前我們在《從Mach-O角度談?wù)凷wift和OC的存儲差異》和 《Swift Hook新思路--虛函數(shù)表》中詳細(xì)介紹了Swift的類的存儲結(jié)構(gòu)锌半。
struct ClassContextDescriptor{
uint32_t Flag;
uint32_t Parent;
int32_t Name;
int32_t AccessFunction;
int32_t FieldDescriptor;
int32_t SuperclassType;
uint32_t MetadataNegativeSizeInWords;
uint32_t MetadataPositiveSizeInWords;
uint32_t NumImmediateMembers;
uint32_t NumFields;
uint32_t FieldOffsetVectorOffset;
<泛型簽名> //字節(jié)數(shù)與泛型的參數(shù)和約束數(shù)量有關(guān)
<MaybeAddResilientSuperclass>//有則添加4字節(jié)
<MaybeAddMetadataInitialization>//有則添加4*3字節(jié)
VTableList[]//先用4字節(jié)存儲offset/pointerSize禽车,再用4字節(jié)描述數(shù)量,隨后N個4+4字節(jié)描述函數(shù)類型及函數(shù)地址刊殉。
OverrideTableList[]//先用4字節(jié)描述數(shù)量殉摔,隨后N個4+4+4字節(jié)描述當(dāng)前被重寫的類、被重寫的函數(shù)描述记焊、當(dāng)前重寫函數(shù)地址逸月。
}
在這里可能有同學(xué)會有疑問,上述結(jié)構(gòu)與調(diào)試時的結(jié)構(gòu)不相符亚亲。調(diào)試時彻采,Swift的類的結(jié)構(gòu)應(yīng)該如下所示:
struct SwiftMetadataClass {
NSInteger kind;
id superclass;
NSInteger reserveword1;
NSInteger reserveword2;
NSUInteger rodataPointer;
UInt32 classFlags;
UInt32 instanceAddressPoint;
UInt32 instanceSize;
UInt16 instanceAlignmentMask;
UInt16 runtimeReservedField;
UInt32 classObjectSize;
UInt32 classObjectAddressPoint;
NSInteger nominalTypeDescriptor;
NSInteger ivarDestroyer;
...//N個函數(shù)地址
};
在runtime中我們通過類.self
獲取到的是struct SwiftMetadataClass
,而我們提到存儲結(jié)構(gòu)指的是ClassContextDescriptor
捌归,兩者并不是一個結(jié)構(gòu)肛响。
//通過這樣的強(qiáng)制轉(zhuǎn)換能清楚的發(fā)現(xiàn)TestClass2的supperclass等信息
struct SwiftMetadataClass* swiftClass =
(__bridge struct SwiftMetadataClass * )(TestClass2.self);
SwiftMetadataClass
是Swift的運(yùn)行態(tài)數(shù)據(jù),在Mach-O文件中惜索,類的SwiftMetadataClass
結(jié)構(gòu)體存儲在DATA段中特笋。細(xì)心的同學(xué)會發(fā)現(xiàn),Swift的Mach-O相比OC多了一個名為(__ TEXT,__const)
的section巾兆。這個section中存儲的就是Swift的TypeContextDescriptor
(ClassContextDescriptor
的父類)結(jié)構(gòu)猎物。TypeContextDescriptor
較SwiftMetadataClass
而言更接近源碼形態(tài),通過TypeContextDescriptor
我們能很容易知道這個代碼在哪個Module中定義的角塑、有多少屬性蔫磨、屬性都是什么類型、是否是泛型圃伶、有多少函數(shù)堤如、又有哪些函數(shù)是重寫父類的等等蒲列。但是由于MachOView并沒有很好地適配好Swift的Mach-O文件,我們看到的這個section是未經(jīng)格式化展示的二進(jìn)制數(shù)據(jù)搀罢。
ClassContextDescriptor和SwiftMetadataClass又有什么關(guān)系呢蝗岖?
簡而言之SwiftMetadataClass.nominalTypeDescriptor
指向的就是這個類的ClassContextDescriptor
,而ClassContextDescriptor
則是通過ClassContextDescriptor.AccessFunction
的函數(shù)調(diào)用獲取到對應(yīng)的SwiftMetadataClass地址榔至。
let tclass = TestClass1.self
斷點查看執(zhí)行就會發(fā)現(xiàn)抵赢,在獲取到類地址之前,總會先調(diào)用TestClass1的metadata accessor函數(shù)(其實就是TestClass1的ClassContextDescriptor.AccessFunction
)
bl 0x100a3b32c ; type metadata accessor for BBB.TestClass1
這就意味著唧取,我們只要在匯編代碼中找到某個類的AccessFunction被調(diào)用了也就知道這個類是被使用的類铅鲤。
如何在匯編代碼中查找AccessFunction?
在Mach-O文件中兵怯,代碼都是以機(jī)器指令的形式存儲的彩匕,我們不能直接獲取到助記符和操作數(shù)腔剂。因此需要借助反匯編庫進(jìn)行反匯編操作媒区,將指令轉(zhuǎn)為匯編代碼。有了匯編代碼掸犬,我們只要在每個函數(shù)的指令區(qū)間內(nèi)查找是否有某個類的AccessFunction地址袜漩,就能知道這個函數(shù)中是否調(diào)用了某個類。
怎么才能知道每個函數(shù)的函數(shù)指令區(qū)間湾碎?
這一步很簡單宙攻,在Debug模式的Mach-O文件中,符號表會告訴我們每個符號對應(yīng)的地址介褥。函數(shù)作為符號的一種座掘,當(dāng)然符號表也記錄了函數(shù)的地址,即下面結(jié)構(gòu)體中的n_value柔滔。
/*
* This is the symbol table entry structure for 64-bit architectures.
*/
struct nlist_64 {
union {
uint32_t n_strx; /* index into the string table */
} n_un;
uint8_t n_type; /* type flag, see below */
uint8_t n_sect; /* section number or NO_SECT */
uint16_t n_desc; /* see <mach-o/stab.h> */
uint64_t n_value; /* value of this symbol (or stab offset) */
};
但是每個符號只知道這個符號的名字以及符號的起始地址溢陪。以函數(shù)為例,函數(shù)通過符號表只能知道函數(shù)名以及函數(shù)的起始地址睛廊,雖然可以通過靜態(tài)分析ret指令來粗略地判斷函數(shù)結(jié)尾形真,但在Swift的匯編代碼中這種方式存在較大的偏差。因此需要換種方案超全,采用較為直接的方式咆霜,借助符號表將匯編指令切割分段來實現(xiàn)函數(shù)指令區(qū)間的判斷,這也是WBBlades需要分析Debug包的原因之一嘶朱。
具體做法是蛾坯,首先對符號表按地址進(jìn)行排序,然后把下個符號的起始地址當(dāng)做當(dāng)前函數(shù)的截止點疏遏。這樣就實現(xiàn)了函數(shù)指令區(qū)間的切割脉课。
遇到的有意思的問題
在為WBBlades做Swift適配時發(fā)現(xiàn)了很多有意思的問題挂疆,也是開發(fā)過程中踩到的一系列坑。
- section判斷不嚴(yán)謹(jǐn)下翎。
之前字節(jié)跳動發(fā)過一篇文章《今日頭條優(yōu)化實踐: iOS 包大小二進(jìn)制優(yōu)化缤言,一行代碼減少 60 MB 下載大小》,可能有些APP做了section遷移视事。如果APP做了section遷移的話胆萧,原本處于一個段的兩個section變成了不同segment中的兩個section。由于不同的段的base address可能不同俐东,因此一旦地址計算出現(xiàn)跨段的時候跌穗,都需要做base address地址修正,否則文件的偏移地址可能會取錯虏辫。另外蚌吸,由于段名可能存在自定義的情況,因此也不能通過段名+節(jié)名的方式來確定唯一的一個section砌庄。需要通過段的權(quán)限+節(jié)名來確定section羹唠。
if ((segmentCommand.maxprot & (VM_PROT_WRITE | VM_PROT_READ)) ==
(VM_PROT_WRITE | VM_PROT_READ)) {
//能夠具有讀寫權(quán)限的的段,即可認(rèn)為為__DATA,__CONST_DATA,__AUTH_CONST等
}
當(dāng)然娄昆,這種判斷方式也不是完全準(zhǔn)確佩微,因為section遷移后,新增的段默認(rèn)是讀寫權(quán)限萌焰,這也意味著原先的TEXT中的數(shù)據(jù)哺眯,遷移后可能變成了VM_PROT_WRITE | VM_PROT_READ
。這也是段遷移后需要重新設(shè)置權(quán)限的原因扒俯。
- 獲取類名循環(huán)遍歷Parent可能發(fā)生異常
//類似這樣的代碼(Type的Parent可能不屬于Type)
func extensions(of value: Any) {
struct Extensions : AnyExtensions {}
return
}
Swift與OC有個區(qū)別就是在Swift中很多地方都可以定義類或結(jié)構(gòu)體奶卓。例如上面的代碼中,就是在一個函數(shù)中定義了一個結(jié)構(gòu)體撼玄。這時在遍歷Extensions這個結(jié)構(gòu)體時需要注意夺姑,它的Parent并不是一個Model Type類型,因此需要在套用結(jié)構(gòu)體解析二進(jìn)制的時候需要判斷處理下互纯。
- 復(fù)雜的泛型結(jié)構(gòu)
之所以說泛型復(fù)雜是因為泛型的簽名是不定長的數(shù)據(jù)瑟幕。它取決于泛型的參數(shù)格式和條件個數(shù)。泛型到底占多少字節(jié)留潦,可以參考下面的布局說明只盹。
內(nèi)容 | 字節(jié)數(shù) | 備注 |
---|---|---|
addMetadataInstantiationCache | 4B | class only |
addMetadataInstantiationPattern | 4B | class only |
GenericParamCount | 2B | |
GenericRequirementCount | 2B | |
GenericKeyArgumentCount | 2B | |
GenericExtraArgumentCount | 2B | |
params | GenericParamCount | |
pandding | (unsigned)-GenericParamCount & 3 | 填補(bǔ),4字節(jié)對齊 |
EachParam | 3 * 4 * GenericRequirementCount |
- Anonymous布局
Anonymous官方解釋如下
/// This context descriptor represents an anonymous possibly-generic context
/// such as a function body.
Anonymous = 2,
與類兔院、結(jié)構(gòu)體等布局不同殖卑,Anonymous在二進(jìn)制中的布局如下:
Flag(4Byte) + Parent(4Byte) + 泛型簽名(不定長)+ mangleName(4Byte)
但是Anonymous 不一定會存在mangleName,因此在解析Anonymous 還需要判斷是否存在 mangleName坊萝。
/// Flags for anonymous type context descriptors. These values are used as the
/// kindSpecificFlags of the ContextDescriptorFlags for the anonymous context.
class AnonymousContextDescriptorFlags : public FlagSet<uint16_t> {
enum {
/// Whether this anonymous context descriptor is followed by its
/// mangled name, which can be used to match the descriptor at runtime.
HasMangledName = 0,
};
...
};
如果此時TypeContext為Anonymous孵稽,那么需要查看Flag的前2字節(jié)是否為0许起。如果為0則Anonymous 沒有mangleName。
- 其他
在Swift中通過fileprivate菩鲜、open等修飾的代碼园细,都會多少些許不同,我們在開源代碼中都做了適配處理接校。另外猛频,有些時候Swift訪問并不是通過AccessFunc,而是直接訪問類的地址蛛勉。這種情況一般會在符號表中存在demangling cache variable for type metadata for
開頭的符號鹿寻。
支持范圍
WBBlades做二進(jìn)制掃描檢測時,對APP中包含以下情況的代碼作了測試诽凌。示例中的? 的代碼能被識別為被使用到毡熏。其中V1.1是在適配Swift二進(jìn)制之前,V2.0是經(jīng)過適配之后侣诵。
使用方法
- 需要檢測的APP需要在Debug環(huán)境下打出一個arm64真機(jī)包痢法。
- 編譯WBBlades,生成WBBlades可執(zhí)行文件窝趣。https://github.com/wuba/WBBlades
- 將WBBlades可執(zhí)行文件拖入系統(tǒng)終端疯暑,并輸入-unused ,再將真機(jī)包拖入終端哑舒。
- Enter,等待幾分鐘幻馁,會在桌面輸出結(jié)果文件洗鸵。如果Swift代碼較多,可能耗時較長仗嗦。
應(yīng)用情況及展望
目前58同城APP中大概存在2w+個類膘滨,1k+個Swift的類型定義。靜態(tài)檢測后發(fā)現(xiàn)OC代碼的無用代碼比例在8%左右稀拐,Swift代碼的無用代碼比例相對較低火邓,大概在2%左右。通過人工復(fù)核后我們發(fā)現(xiàn)部分業(yè)務(wù)線的代碼檢測準(zhǔn)確度較高德撬,準(zhǔn)確率80%+铲咨,而部分業(yè)務(wù)線的篩查結(jié)果準(zhǔn)確度較低。造成準(zhǔn)確率降低的主要原因是多個字符串拼接成類名進(jìn)行動態(tài)調(diào)用以及在Swift中使用反射導(dǎo)致蜓洪,這種情況如果不知道代碼的拼接規(guī)則很難通過通用的手段來檢測纤勒。后續(xù)我們會逐漸完善工具,在輸出掃描結(jié)果的同時給出每個無用代碼在二進(jìn)制文件中的字節(jié)數(shù)隆檀,方便開發(fā)者做決策使用摇天。
總結(jié)
Swift是一門非常神奇且深奧的語言粹湃,上層使用靈活是以底層復(fù)雜適配為代價的。筆者也是在逐漸摸索和學(xué)習(xí)中泉坐,因此可能難免帶著OC的思維來看Swift這門語言为鳄,例如在工具開發(fā)之前筆者一直考慮的是如何檢測Swift無用類,但是實際上Struct腕让、Enum等類型等在開發(fā)中同樣重要济赎。因此,WBBlades還在持續(xù)優(yōu)化记某。目前WBBldes得到了58集團(tuán)內(nèi)外共14個團(tuán)隊或個人的協(xié)助司训,正在持續(xù)的體驗和收集問題。如果您有好的想法或者問題液南,可以在GitHub上留言溝通壳猜。
作者介紹
鄧竹立:用戶價值增長中心-平臺技術(shù)部-iOS技術(shù)部 資深開發(fā)工程師,WBBlades開源工具作者
參考文獻(xiàn)
https://developer.apple.com/documentation/kernel/nlist_64
https://github.com/apple/swift/blob/d68d406dae39ea1677d586714b3991b8f2037dab/lib/IRGen/GenMeta.cpp
http://www.reibang.com/p/158574ab8809
http://www.reibang.com/p/ef0ff6ee6bc6
https://mp.weixin.qq.com/s/egrQxxJSympB-L6BdVDQVA
https://github.com/alibaba/HandyJSON
http://www.reibang.com/p/0cbbbe783ac9
https://juejin.cn/post/6939763940243030024