iOS APP安裝包瘦身
參考公眾號:WeMobileDev
APP開發中,總會想要去儘可能的優化專案,這是我們作為程式員最基本的追求之一。
而安裝包瘦身,是最能讓使用者感知的一類優化。大部分APP對使用者來說是非必需品,但是如果安裝包高達50多M,會嚇退一大波的使用者。
而且,現在的APP追求小而精,小程式的火熱也減少了傳統APP的使用量,安裝包瘦身迫在眉睫。
AppStore安裝包由資源和可執行檔案兩部分組成,安裝包瘦身也是從這兩部分入手。
一、資源瘦身
資源瘦身主要是去除無用資源或者壓縮資源。
資源主要包括圖片、音訊、視訊、多語言包、配置檔案等。
無用資源指的是,專案中沒有被引用的資源,找到的辦法就是,去專案中搜索該檔名,圖片資源去掉@2x,@3x,沒有搜尋到的就是無用資源。
當然,資源名在專案中另外拼接的,特殊處理,所以一個APP最好有一個統一的拼接格式。
壓縮資源,一般指圖片的壓縮, 圖片資源控制在80k左右(@3x的全屏圖片) ;
還有就是配置檔案的壓縮,比如內建的離線資源等。
二、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的資料型別
<pre> typedef struct {Byte _fieldNumber;Byte _fieldLabel;Byte _fieldType;BOOL _isPacked;int _enumInitValue;union {__unsafe_unretained NSString* _messageClassName;__unsafe_unretained Class _messageClass; // ClassName對應的ClassIsEnumValidFunc _isEnumValidFunc; // 檢測列舉值是否合法函式指標};} PBFieldInfo; </pre>
另外通過無用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挺影響效能的);等等
<pre style="">
`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`
</pre>
把冗餘程式碼去掉後,整個類清爽多了。像GameResourceReq只有3個屬性的proto結構體,類方法程式碼行數由以前的127行變成現在的8行。protobuf精簡改造中,精簡類方法減少了可執行檔案8.8M,去掉類成員變數和類屬性改用@dynamic減少了2.5M。
<pre style="">
message GameResourceReq { required BaseRequest BaseRequest = 1; required int32 PropsCount = 2; repeated uint32 PropsIdList = 3[packed=true]; }
</pre>
<pre style="">
`// 老實現
@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`
</pre>
<pre style="">
`// 新實現
@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`
</pre>
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檔案的分析,可以得知每個模組可執行檔案佔用大小。再對比兩個版本,就知道業務模組的增量大小。參考如下:

image