前提
微信經過多次版本迭代,產生不少冗余代碼和無用資源。之前微信也沒有很好的手段知道哪個模塊增量多少。另外去年10月微信開始做ARC支持,目的是為了減少野指針帶來的Crash,但代價是可執行文件增大20%左右。而蘋果規定今年6月提交給Appstore的應用必須支持64位,32位和64位兩個架構的存在使得可執行文件增加了一倍多。安裝包大小優化迫在眉睫。
Appstore安裝包是由資源和可執行文件兩部分組成,安裝包瘦身也是從這兩部分進行。
資源瘦身
資源瘦身主要是去掉無用資源和壓縮資源,資源包括圖片、音視頻文件、配置文件以及多語言wording。無用資源是指資源在工程文件裡,但沒有被代碼引用。檢查方法是,用資源關鍵字(通常是文件名,圖片資源需要去掉@2x @3x),搜索代碼,搜不到就是沒有被引用。當然,有些資源在使用過程中是拼接而成的(如loading_xxx.png),需要手工過濾。
資源壓縮主要對png進行無損壓縮,用的是ImageOptim工具和compress命令(需要安裝XQuartz-2.7.5.dm插件)。不建議對資源做有損壓縮,有損壓縮需要設計一個個檢查,通常壓縮後效果不盡人意。
Xcode's Link Map File
在講可執行文件瘦身之前先介紹Xcode的LinkMap文件。LinkMap文件是Xcode產生可執行文件的同時生成的鏈接信息,用來描述可執行文件的構造成分,包括代碼段(__TEXT)和數據段(__DATA)的分布情況。只要設置Project->Build Settings->Write Link Map File為YES,並設置Path to Link Map File,build完後就可以在設置的路徑看到LinkMap文件了:
每個LinkMap由3個部分組成,以微信為例:
1. Object files:
[ 0] linker synthesized
[ 1] /xxxx/WCPayInfoItem.o
[ 2] /xxxx/GameCenterFriendRankCell.o
[ 3] /xxxx/WloginTlv_0x168.o
...
第一部分列舉可執行文件裡所有.obj文件,以及每個文件的編號。
2. Sections:
第二部分是可執行文件的段表,描述各個段在可執行文件中的偏移位置和大小。第一列是段的偏移量,第二列是段占用大小,Address(n)=Address(n-1)+Size(n-1);第三列是段類型,代碼段和數據段;第四列是段名字,如__text是可執行機器碼,__cstring是字符串常量。有關段的概念可參考蘋果官方文檔《OS X ABI Mach-O File Format Reference》
3. Symbols:
# Address Size File Name
0x100005A50 0x00000074 [ 1] +[WCPayInfoItem initialize]
...
0x10231C120 0x00000018 [ 1] literal string: I16@?0@"WCPayInfoItem"8
...
0x10252A41A 0x0000000E [ 1] literal string: WCPayInfoItem
...
第三部分詳細描述每個obj文件在每個段的分布情況,按第二部分Sections順序展示。例如序號1的WCPayInfoItem.o文件,+[WCPayInfoItem initialize]方法在__TEXT.__text地址是0x100005A50,占用大小是116字節。根據序號累加每個obj文件在每個段的占用大小,從而計算出每個obj文件在可執行文件的占用大小,進而算出每個靜態庫、每個功能模塊代碼占用大小。這裡要注意的地方是,由於__DATA.__bbs是代表未初始化的靜態變量,Size表示應用運行時占用的堆大小,並不占用可執行文件,所以計算obj占用大小時,要排除這個段的Size。
可執行文件瘦身
回到我們的可執行文件瘦身問題,LinkMap文件可以幫助我們尋找優化點。
1. 查找無用selector
以往C++在鏈接時,沒有被用到的類和方法是不會編進可執行文件裡。但Objctive-C不同,由於它的動態性,它可以通過類名和方法名獲取這個類和方法進行調用,所以編譯器會把項目裡所有OC源文件編進可執行文件裡,哪怕該類和方法沒有被使用到。
結合LinkMap文件的__TEXT.__text,通過正則表達式([+|-][.+\s(.+)]),我們可以提取當前可執行文件裡所有objc類方法和實例方法(SelectorsAll)。再使用otool命令otool -v -s __DATA __objc_selrefs逆向__DATA.__objc_selrefs段,提取可執行文件裡引用到的方法名(UsedSelectorsAll),我們可以大致分析出SelectorsAll裡哪些方法是沒有被引用的(SelectorsAll-UsedSelectorsAll)。注意,系統API的Protocol可能被列入無用方法名單裡,如UITableViewDelegate的方法,我們只需要對這些Protocol裡的方法加入白名單過濾即可。
另外第三方庫的無用selector也可以這樣掃出來的。
2. 查找無用oc類
查找無用oc類有兩種方式,一種是類似於查找無用資源,通過搜索"[ClassName alloc/new"、"ClassName *"、"[ClassName class]"等關鍵字在代碼裡是否出現。另一種是通過otool命令逆向__DATA.__objc_classlist段和__DATA.__objc_classrefs段來獲取當前所有oc類和被引用的oc類,兩個集合相減就是無用oc類。
3. 掃描重復代碼
可以利用第三方工具simian掃描。南非支付copy代碼就是這樣被發現的。但除此成果之外,掃描出來的結果過多,重構起來也不方便,不如砍功能需求效果好。
4. protobuf精簡改造
protobuf是Google推出的一種輕量高效的結構化數據存儲格式,在微信用於網絡協議和本地文件序列化。但google默認工具生成的代碼比較冗余,像序列化、反序列化、計算序列化大小等方法都生成在具體的pb類裡,每個類的實現大同小異。通過代碼分析以及結合protobuf原理,要想把這些方法抽象到基類,派生類提供每個字段相關信息就夠了:
field number
field label, optional, required or repeated
wire type, double, float, int, etc
是否packed
repeated的數據類型
typedef struct { Byte _fieldNumber; Byte _fieldLabel; Byte _fieldType; BOOL _isPacked; int _enumInitValue; union { __unsafe_unretained NSString* _messageClassName; __unsafe_unretained Class _messageClass; // ClassName對應的Class IsEnumValidFunc _isEnumValidFunc; // 檢測枚舉值是否合法函數指針 }; } PBFieldInfo;
另外通過無用selector列表,發現不少pb類屬性的getter或setter沒有被使用。原先的pb類屬性是用@synthesize修飾,編譯器會自動生成getter和setter。如果不想編譯器生成,則要用@dynamic。甚至我們可以把pb類的成員變量去掉。做法如下:
基類增加id類型數組ivarValues(參考了objc_class結構體ivars做法),用於存放對象的屬性值。對象屬性值統一用oc對象表示,如果類型是基礎類型(primitive,如int、float等),則用NSValue存
重載methodSignatureForSelector:方法,返回屬性getter、setter的方法簽名
重載forwardInvocation:方法,分析invocation.selector類型。如果是getter,從ivarValues獲取屬性值並設置為invocation的returnValue;如果是setter,從invocation第二個argument獲取屬性值,並存放到ivarValues裡
重載setValue:forUndefinedKey:、valueForUndefinedKey:,防止通過KVO訪問屬性Crash
做下性能優化,如pb類在initialize做一次初始化,緩存屬性名的hash值,屬性的getter、setter方法的objcType等;屬性值不用std::map(屬性名->屬性值),而是改用數組;MRC代替ARC(有些時候ARC自動添加的retain/release挺影響性能的);等等
class PBClassInfo { public: PBClassInfo(Class cls, PBFieldInfo* fieldInfo); ~PBClassInfo(); public: unsigned int _numberOfProperty; std::string* _propertyNames; size_t* _propertyNameHashes; std::string* _getterObjCTypes; std::string* _setterObjCTypes; PBFieldInfo* _fieldInfos; }; @interface WXPBGeneratedMessage () { uint32_t _has_bits_[3]; // 最多96個屬性,表示屬性是否有賦值 int32_t _serializedSize; PBClassInfo* _classInfo; id* _ivarValues; } - (NSMethodSignature*) methodSignatureForSelector:(SEL) aSelector; - (void) forwardInvocation:(NSInvocation*) anInvocation; - (void) setValue:(id) value forUndefinedKey:(NSString*) key; - valueForUndefinedKey:(NSString*) key; @end
把冗余代碼去掉後,整個類清爽多了。像GameResourceReq只有3個屬性的proto結構體,類方法代碼行數由以前的127行變成現在的8行。protobuf精簡改造中,精簡類方法減少了可執行文件8.8M,去掉類成員變量和類屬性改用@dynamic減少了2.5M。
message GameResourceReq { required BaseRequest BaseRequest = 1; required int32 PropsCount = 2; repeated uint32 PropsIdList = 3[packed=true]; } // 老實現 @implementation GameResourceReq @synthesize hasBaseRequest; @synthesize baseRequest; @synthesize hasPropsCount; @synthesize propsCount; @synthesize mutablePropsIdListList; @dynamic propsIdList; - (id) init {...} - (void) SetBaseRequest:(BaseRequest*) value {...} - (void) SetPropsCount:(int32_t) value {...} - (NSArray*) propsIdListList {...} - (NSMutableArray*)propsIdList {...} - (void)setPropsIdList:(NSMutableArray*) values {...} - (BOOL) isInitialized {...} - (void) writeToCodedOutputStream:(PBCodedOutputStream*) output {...} - (int32_t) serializedSize {...} + (GameResourceReq*) parseFromData:(NSData*) data {...} - (GameResourceReq*) mergeFromCodedInputStream:(PBCodedInputStream*) input {...} - (void) addPropsIdList:(uint32_t) value {...} - (void) addPropsIdListFromArray:(NSArray*) values {...} @end // 新實現 @implementation GameResourceReq PB_PROPERTY_TYPE baseRequest; PB_PROPERTY_TYPE opType; PB_PROPERTY_TYPE brandUserName; + (void) initialize { static PBFieldInfo _fieldInfoArray[] = { {1, FieldLabelRequired, FieldTypeMessage, NO, 0, ._messageClassName = STRING_FROM(BaseRequest)}, {2, FieldLabelRequired, FieldTypeInt32, NO, 0, 0}, {3, FieldLabelRepeated, FieldTypeUint32, NO, 0, 0}, }; initializePBClassInfo(self, _fieldInfoArray); } @end
5. 編譯選項優化
Strip Link Product設成YES,WeChatWatch可執行文件減少0.3M
Make Strings Read-Only設為YES,也許是因為微信工程從低版本Xcode升級過來,這個編譯選項之前一直為NO,設為YES後可執行文件減少了3M
去掉異常支持,Enable C++ Exceptions和Enable Objective-C Exceptions設為NO,並且Other C Flags添加-fno-exceptions,可執行文件減少了27M,其中__gcc_except_tab段減少了17.3M,__text減少了9.7M,效果特別明顯。可以對某些文件單獨支持異常,編譯選項加上-fexceptions即可。但有個問題,假如ABC三個文件,AC文件支持了異常,B不支持,如果C拋了異常,在模擬器下A還是能捕獲異常不至於Crash,但真機下捕獲不了(有知道原因可以在下面留言:)。去掉異常後,Appstore後續幾個版本Crash率沒有明顯上升。個人認為關鍵路徑支持異常處理就好,像啟動時NSCoder讀取setting配置文件得要支持捕獲異常,等等
6. 其他可探索途徑
iOS8 Embed-Framework:提取WeChatWatch、ShareExtention和微信主工程的公共代碼,可執行文件可以減少5M+,不過這特性需要最低版本iOS8才能用,iOS7設備啟動會crash
iOS9 App Thinning:嚴格來說App Thinning不會讓安裝包變小,但用戶安裝應用時,蘋果會根據用戶的機型自動選擇合適的資源和對應CPU架構的二進制執行文件(也就是說用戶本地可執行文件不會同時存在armv7和arm64),安裝後空間占用更小
7. 建立監控
通過對LinkMap文件的分析,可以得知每個模塊可執行文件占用大小。再對比兩個版本,就知道業務模塊的增量大小。參考如下: