AppStore & AppStore Connect 對安裝包大小的限制
App Store OTA 下載大小限制
蘋果公司為了避免用戶超出運營商套餐流量,限制了用戶通過流量從 AppStore 下載 App 的最大大小, 簡稱 OTA 下載大小限制。其歷史沿革:
-
2017 年 9 月,限制從 100MB 提升到了 150MB;
-
2019 年 5 月下旬,蘋果把 OTA 下載限制放寬到 200MB;
-
iOS 13 發布之后 iOS13 及以上用戶可以使用流量下載超出 200MB 的 App, 但需要用戶「設置」選擇策略,默認為「超過 200MB 請求許可」,而 iOS13 以下用戶仍然無法下載。
Apple __TEXT 段大小限制
除了上邊的限制之外 Apple 對可執行文件__TEXT 段的限制則更為嚴苛,如果超出這個限制 APP 將無法通過 AppStore 審核。這個限制簡而言之,如果要支持 iOS8 的設備, App 單架構主二進制 __TEXT 段上限為 60MB(以 1000KB 為 1M,而不是 1024),放棄對 iOS8 的支持二進制大小限制則變為安裝包內最大二進制所有架構的總和不超過 500MB。
安裝包大小增長的影響
AppStore 下載大小如果在 OTA 下載限制內增長,對用戶新增、留存等指標影響不大。而一旦超過 OTA 下載限制,則對整體指標產生明顯影響。之前統計的劣化數據指標:當限制在 150MB 並且無法下載的時候,對用戶的新增有 10%的影響。由於 iOS13 限制的寬松化,所以在 iOS13 之后設備上這個數據將低於 10%。此數據僅供參考並不能一概而論,對於不同類型的 App 首次安裝的場景會呈現差異,比如生活服務、出行類 App 對應蜂窩下載場景會多於影音類、游戲類 App。
其次對仍然需要支持 iOS8 以下的 App, 超出 __TEXT 段大小的限制將會很大程度上影響審核以及發版進度。當然可以通過一些手段進行救急,比如拆分動態庫的方式繞過。但是這些手段可能導致安裝包整體變得更大。
除了 Apple 的限制外,包大小的劣化一定程度上意味着更加慢的啟動速度;更多的的代碼邏輯;更低研發效率;過於復雜的代碼還會帶來對代碼修改的風險將對穩定性產生負面影響;讓性能等基礎體驗變差,所以包大小不是一個孤立的指標,它從側面的反映出 App 的健康狀態。
Part 2. 安裝包的構成以及如何分析安裝包
安裝包的構成
當通過 Archieve 打包的安裝包並 unzip 解壓之后,通常可以看到如下的安裝包結構:
-
Payload
-
-
TheApp.app
-
OnDemandResources
-
-
Symbols
而主要影響下載和安裝大小的內容都集中在 .app 中。而解壓后的占用 .app 大部分的大小的文件如下:
-
主二進制(和.app 同名的 MachO 文件);
-
Frameworks(App 自身引入的動態庫);
-
Plugins (App Extension 本質依然是動態的可執行文件);
-
xxx.lproj(原生的翻譯資源);
-
各種資源 Bundle;
安裝包分析
通過分析安裝包,了解安裝包中可執行文件占用大小、資源占用大小,了解安裝包的現狀。明確從哪里入手可以獲得 ROI 最高的優化手段。而在做包大小分析過程中比較難的是,怎么樣通過線下的安裝包衡量對下載大小的影響。
但由於上傳到 AppStore Connect 到之后,Apple 對安裝包做了一些調整,線下安裝包的變化無法對應到真正的下載大小變化的變更。而這部分調整包括:
-
App Slicing 對於不同架構的裁剪,可執行文件只剩下單架構;
-
Asset.car 中圖片只留下設備需要的特定尺寸和壓縮算法的變體;
-
二進制部分__TEXT 段的通過 FirePlay 進行加密導致 __TEXT 段的壓縮比為 1( iOS 13+ 以上設備下載變體中蘋果移除了這個加密 );
所以線下評估的時候,通過刪除 Asset.car 中圖片帶來 10MB 的包大小的減小,但對最后的下載大小影響可能遠遠小於 10MB 。而當增加的 2MB 的代碼 ,最后的下載大小實打實地增長了 2MB。
可執行文件分析
安裝包中的可執行文件,占了安裝包中很大一部分空間,而這部分不光和代碼有關還和編譯、鏈接過程中添加的參數,編譯的機器環境、Xcode 版本等等都有關系。而常常通過 LinkMap 來對可執行文件進行分析。
LinkMap 中包含了可執行文件的架構信息,段表,鏈接了的所有文件,以及文件中各符號占用的大小。其實通過分析 LinkMap 就基本得到了可執行文件中包含了哪些東西。這部分數據也有助於針對性的進行一些優化。
Part 3. 常見的包大小優化手段
可執行文件部分的優化
重命名部分段繞過 __TEXT 段 FirePlay 加密:
雖然 Apple 在 iOS13 + 去掉了對可執行文件的 __TEXT 段加密,但對於 iOS 低版本的設備而言超出 OTA 的下載限制比高版本影響還要大。所以可以將可執行文件中一部分段從 __TEXT 段中移動到其他段來繞過加密帶來提高壓縮比。而且在啟動時候 Page Load 解密 __TEXT 段也是比較大的性能損耗,能同時優化啟動時間。目前比較穩定的可以移動的段包括:
__TEXT,__cstring__TEXT,__const__TEXT,__gcc_except_tab__TEXT,__objc_methname__TEXT,__objc_classname__TEXT,__objc_methtype
可在OTHER_LDFLAGS
中添加如下參數進行移動:
$(inherited) -Wl,-rename_p,__TEXT,__cstring,__RODATA,__cstring -Wl,-rename_p,__TEXT,__const,__RODATA,__const -Wl,-rename_p,__TEXT,__gcc_except_tab,__RODATA,__gcc_except_tab -Wl,-rename_p,__TEXT,__objc_methname,__RODATA,__objc_methname -Wl,-rename_p,__TEXT,__objc_classname,__RODATA,__objc_classname -Wl,-rename_p,__TEXT,__objc_methtype,__RODATA,__objc_methtype -w
符號表的裁剪: 對於 App 的主二進制而言,對外是不需要暴露符號信息的。而對外暴露的符號名稱對 App 整體安全來講也存在一些風險。通常通過設置 STRIP_STYLE= all
來裁剪所有符號。但通過:
objdump -exports-trie /path/to/MyApp.app/MyApp
還能獲取到可執行文件中的符號,這部分可以通過設置 EXPORTED_SYMBOLS_FILE
為一個空文件解決 EXPORTED_SYMBOLS_FILE=/path/to/emptyfile.txt
。
對於自己構建的動態庫。只需要保留未定義的符號以及全局的符號其他的都可以去除。通常設置STRIP_STYLE=non-global
。
多個可執行文件中存在多份的相同代碼: 有時候在寫代碼的時候為了實現一個簡單的功能就引入了一個很大的庫。而這個庫是采用靜態鏈接的方式被鏈接到可執行文件中時。如果不同的可執行文件都用了這個庫那么這個庫將存在多份。
舉個例子:對於 iOS 場景下, Extension 是一個獨立的可執行文件,在 Extension 想發送一個網絡請求的時候,習慣性的是直接依賴了業務層的網絡框架。而這個依賴會導致業務層網絡框架在多個二進制中存在。
死代碼裁剪: 在構建完成之后如果是 C、C++ 等靜態的語言的代碼、一些常量定義,如果發現沒有被使用到將會被標記為 Dead code。開啟 DEAD_CODE_STRIP = YES
這些 Dead code 將不會被打包到安裝包中。在 LinkMap 這些符號也會被標記為 <<dead>>
。
編譯期優化參數: GCC_OPTIMIZATION_LEVEL
定義了 clang 用什么優化等級進行編譯優化。根據不同的 Configuration, Xcode 默認的 Debug 使用 -O0, Release 使用 -Os。
在 Xcode 11 之后 Xcode 提供了一種更加激進的編譯優化參數 -Oz,它通過識別單個編譯單元中跨函數的相同代碼序列來減少代碼大小。這些序列在單個編譯器生成的函數中被封裝(Outlined)。每個原始代碼序列都被替換為調用該 Outlined 函數。會減小相同代碼存在多份問題,但是也會使得的函數調用存在更深的調用棧,會影響性能。對性能略有影響具體參考下圖。抖音開啟了 Oz 之后對二進制有 6M 左右的優化。
需要注意的是在 ARC 場景 objc_retainAutoreleaseReturnValue
被外聯之后會導致一個本來不需要被放入 autoreleasepool 中的對象被放入了 autoreleasepool。這將導致一些有問題的寫法出現更壞的結果比如出現延遲釋放導致 BAD_ACCESS 或者被 @autoreleasepool
包裹的對象延時釋放導致的內存暴漲,所以在開啟的時候需要進行測試。
具體的信息左側為開啟了 Oz ,右側為 Os:
可見,下面的代碼移動到了一個外聯函數中:
mov x29, x29bl imp___stubs__objc_retainAutoreleasedReturnValue
變成了一句對外聯函數的調用:
bl _OUTLINED_FUNCTION_0
而 mov x29, x29
本質沒有任何實際意義,其作用是在 objc 對 autorelease 對象優化中作為一個標志。在編譯器發現對返回的 autorelease 對象進行 objc_retainAutoreleaseReturnValue
時就會插入的標記。
而一般創建一個對象並返回的時候代碼是:
return objc_autoreleaseReturnValue(ret);
當 objc_autoreleaseReturnValue
檢查返回地址處有此標志 ,如果存在意味着返回的對象會被立馬 retain ,所以沒必要放到 autoreleasepool 中。僅僅在 Thread Local Storage 中設置一個標記,直接對返回的對象引用記數 +1 就可以了。objc_retainAutoreleasedReturnValue
看到這個標志也無需額外 retain,兩者配合從而優化掉了一次 autorelease 和 retain 操作,但這是優化細節,語義上仍然應該把它當作一個 autorelease 對象。而開啟了 Oz 之后標記被移動到另外一個外聯函數內部,導致這個優化失效對象被放到了 autoreleasepool 中出現延時釋放,從而引發一些內存問題。
鏈接期優化參數: LLVM 提供鏈接期編譯優化,通過設置工程中的 Link-Time Optimization 進行控制。
提供以下幾個選項:
No
不開啟鏈接期優化;
Monolithic
生成單個 LTO 文件,每次鏈接重新生成,無緩存高內存消耗,參數 LLVM_LTO=YES;
Incremental
生成多個 LTO 文件,增量生成,低內存消耗,參數 LLVM_LTO=YES_THIN;
本地調試和對時間敏感的構建流程不建議開啟 LTO。會增加很多的構建時間。
這里需要注意的是 LTO 雖然是鏈接期優化,但是仍然需要編譯期參與,加入了 LTO 的編譯出來的 .a 本質是 LLVM 的 BitCode,如果使用未開啟 LTO 構建出來的的 .a 直接是機器碼了。直接鏈接是無法完成 LTO 優化的。
開啟 LTO 之后跨編譯單元的重復代碼會被鏈接器單獨生成以 .lto.o
為后綴的目標文件進行鏈接。尤其是對於 Objc Runtime 需要的一些結構 比如方法簽名的 literal string, protocol 的結構等有比較大的優化。同時開啟 Oz 和 LTO 可以讓外聯函數都只存在一份能夠最大限度的優化安裝包體積。
如果你的項目中大量的使用了 Protocol 建議還是開啟這個選項。抖音全部開啟 Oz & LTO 得到的收益是減小二進制大小 18MB。
資源文件部分的優化:
無用資源的移除: 通過掃描代碼中的字符串的常量和資源名稱。可以求差集可以得到未使用到的資源並對資源進行處理。
資源的動態化: 可以將一些低頻場景下的資源放到雲端,在進入 app 安裝之后再去雲端按需獲取。
ODR 的資源獲取方案: Apple 提供了按需資源(On-Demand Resource)的方式來幫助減小安裝包首次下載的大小, 當有一些由於審核原因必須要內置在安裝包中,但又可以走下發的情況可以嘗試以下這種解決方案。當然 ODR 中的資源也需要符合 App Store 的審核標准,否則也會存在拒審風險。
資源壓縮: 當我們一定要在安裝包內置某個資源的話,應該在可接受的范疇之內,盡可能的小。比如我們安裝包中內置的視頻、音頻資源可以采用降低清晰度、碼率等等方式進行壓縮。iOS 原生的多語言方案比較消耗空間,可以考慮自研更加緊湊的方案。
Asset.car 中圖片的優化: Assets.car 編譯過程中有時會選擇一些圖片,拼湊成一張大圖(ZZZZPackedAsset)來提高圖片的加載效率。被放進這張大圖的小圖會變為通過偏移量的引用。
建議使用頻率高且小的圖片放到 Asset.car 中,Asset.car 能保證其加載和渲染的速度最優。而大的圖片比如背景圖之類的,長寬尺寸就有上千個像素,而這種放到 Asset.car 中會大大的增加安裝包的大小。建議實踐中比如頁面背景圖,或者其他 png 格式超過 100KB 大小的圖片都使用 WebP 的方式引入。相較於 PNG 格式,WebP 具有更加優秀的圖像數據壓縮算法,能帶來更小的圖片體積,而且擁有肉眼識別無差異的圖像質量。
當我們在構建過程中,Xcode 會通過自己的壓縮算法重新對圖片進行處理。這也是為什么我們通過對圖片無損壓縮來優化包大小沒有效果的原因。對於放入 Asset.car 中的圖片如果圖片沒有半透明效果,使用 70% 的有損壓縮是一個不錯的方式,既能保證圖片清晰度的同時獲得更小的大小。如果對於有半透明效果的圖片,采用 70% 的有損壓縮會導致半透明的地方出現噪點,所以壓縮過后的圖片最好找設計師同學再確認一次。
我們通過對 Asset.car 進行了逆向研究,同一張圖片,在不同設備、iOS 系統上 Xcode 采用了不同的壓縮算法這也導致了下載時候不同的設備針對圖片出現大小的區別。
截止目前 Xcode 會使用的壓縮算法有 lzfse
、palette_img
、deepmap2
、deepmap_lzfse
、zip
。
以 iPhoneX 為例子:
iOS 11.x 版本:對應的壓縮算法為 lzfse
、zip
;
iOS 12.0.x - iOS 12.4.x: 對應的壓縮算法為 deepmap_lzfse
、palette_img
;
iOS 13.x: 對應的壓縮算法為deepmap2
;按照壓縮比來講 lzfse
< palette_img
~= deepmap_lzfse
< deepmap2
。
在 BuildSetting 中如果設置了 ASSETCATALOG_COMPILER_OPTIMIZATION=space
那么低版本的使用 lzfse
壓縮算法的圖片會變成 zip
的算法可減少 iOS11.x 及以下的 iOS 設備圖片的占用大小。其他 iOS 版本的壓縮算法不受這個配置的影響。
無用代碼的清理:
一般的無用代碼篩查方式可以分為動態和靜態兩種方式。靜態的方式主要是通過代碼掃描、參與編譯構建過程或者分析最終產物來確認哪些代碼沒有被用到。而動態的方式主要是靠插樁或者運行時信息來獲取哪些代碼沒有執行。由於 Objc 強大的動態特性,我們在樣本量足夠大的場景使用動態方式會比靜態方式准確率高很多。
靜態篩查篩查方案:
比較簡單的方式是:基於 otool dump 最終產物中的 __objc_class_list & __objc_class_refs 做差集找到未使用的 Objc 類。
如果代碼采用 C 、C++ 等靜態語言編寫代碼時,編譯期已經確定了基本的代碼邏輯,所以編譯器會幫助我們將沒有使用到的代碼標記為 Dead code 最終不會打包到安裝包中。但 Objc 是典型的動態語言,很多邏輯都是在運行時決議的,我們通過靜態掃描的方式掃描出來的誤差會比較大,抖音對於這靜態結果初篩的得到未使用類的准確性只有 24% (總樣本 264 個,命中 64 個)
Objc 動態特性引入的的主要的問題包括:
-
實際用到了但被掃描成無用類:
-
-
一個類確實沒有被其他地方使用, 但是本身邏輯依賴
+load
、+initialize
、__attribute__((constructor))
在啟動時調用; -
通過 string 動態調用;
-
抽象基類、基類等會被認為是無用類;
-
通過運行時動態生成的代碼引用了某個類;
-
一個類專門作為通知處理類;
-
MTLModel 等,通過運行時消息機制 assign value 的無法通過 classref 統計;
-
典型的 DI 場景。如果一個類聲明遵循了某個 Protocol,外部使用的時候使用了這個 Protocol 進行方法調用。
-
-
實際沒用到但被認為有用到:
-
- 某個對象被另外一個對象引用,但是另外一個對象本身未被使用到。這時候會遺漏掉這個對象所屬 Class 的檢查。
動態篩查方案:
基於插樁的行級別代碼覆蓋率:
基於 GCOV 或者 LLVM Profile 二進制的插樁方案可以實現在運行時收集插樁數據來指導無用代碼的刪除。但插樁方案局限性也顯而易見,插樁會劣化二進制本身的大小和性能,同時原生的插樁方案是無法過審上線。數據收集只能局限於線下。
基於 Runtime 的輕量級運行時「類覆蓋率」方案:
Objc 的類首次調用類初始化時,+initialize
被執行,系統會自動標記已被調用,在 metaClass 中 data 的 flags 字段第 29 位就存着這個這個狀態。可以使用 flags & RW_INITIALIZED
獲取。iOS14 之后這個值的獲取方式有變化。具體參考:WWDC:Advancements in the Objective-C runtime (https://developer.apple.com/videos/play/wwdc2020/10163/)。
#define RW_INITIALIZED (1<<29)bool isInitialized() { return getMeta()->data()->flags & RW_INITIALIZED;}
上報的數據可以讓我們了解我們線上真實的 Class 使用情況,對得到的數據不僅可以用來刪減未使用的代碼。還可以分辨使用率低的場景,如果是低頻且必須的場景可以考慮使用跨端技術這種對原生包大小影響比較小的方案實現。而如果這些場景是某個滲透率很低的需求可以考慮直接下線為其他需求做置換。
以上方案都在抖音上進行了落地。還有一些因為工程和歷史原因不能上線的優化,列在下面供大家參考:
- 動態庫的段壓縮。將動態庫中一些段進行壓縮存入到文件中,在動態庫加載的時候將這部分手動加載到內存中,將犧牲一部分啟動性能。
Part 4. 影響包大小的一些編碼習慣和建議
由於 Objc 語言的特性,編寫的代碼會在編譯過程中出現生成各種類結構、方法簽名,protocol 結構體等等副產物。這些產物在我們工程很大很復雜的時候常常會導致我們安裝包大小極具劣化。所以在保證不影響編碼,嘗試一些對包大小友好的編碼方式。積少成多長期對包大小有正向影響。
Class Method vs C 函數
通常我們對於一些基礎和通用的函數會采用工具類的方式對外暴露。使用類方法完成功能。但當我們采用 Class Method, 這種方式在編譯的時候需要生成 Class 的類結構。調用的方法會通過 objc_methodSend。如果采用 C 函數的方式可以減小這部分的開銷。如果只是自己組件內部使用的私有的功能性函數還是建議使用 C 函數的方式實現。
Property vs IVAR
Objc 對於 Class 的 property,會自動的生成 set、get 方法,比如這個 property 是 Class 的私有屬性的時候,我們可以直接使用 ivar 來代替 property。減小這部分的包大小開銷。這里需要注意,當我們使用 property 的 getter 實現 LazyLoad 或者 setter 存在一些其他副作用的時候還是需要保留 property 的。
減少 Block 的使用
我們知道 Block 是一個特殊的 OC 對象。需要占用部分二進制空間來表征一個 Block 對象。所以在非必要使用 Block 的場景。去掉 Block 實現可以優化不少包大小,常見的比如 Masonry 通過 Block 實現的鏈式調用。
縮減方法參數 & 函數參數的個數
我們調用一個函數的時候,傳入的參數會存在一個參數列表中。當我們調用的時候傳入參數很多的時候會對我們安裝包大小有較大的影響,尤其是類似網絡請求的的方法,動輒 7、8 個參數,而且調用的地方又很多。所以在對外 API 設計的時候如果參數超過 3 個嘗試通過構造對象解決傳參問題。
高頻率使用的宏不要使用多行的方式
這個問題常見於我們組件化依賴注入場景、Log 記錄等。當一個展開為 3 行的宏,經過上千次的調用之后最后占用的大小也是非常恐怖的。
盡量避免代碼的復制粘貼
如果組件化做得不好或者一些大型的 app 存在一些業務閉環的中台業務場景時候,代碼里頭會存在不少的重復代碼。有些可能是改了前綴或者命名空間。可以考慮通過 PMD 對工程中的源碼進行掃描。將重復代碼進行統一。
參考文檔:
1. Apple App Thinning 文檔
https://help.apple.com/xcode/mac/current/#/devbbdc5ce4f
2. On-Demand Resources Guide
3. Oz 優化- 外聯函數(Outlined Function)
http://lists.llvm.org/pipermail/llvm-dev/2016-August/104170.html
https://mnt.io/2016/12/06/reducing-code-size-using-outlining/
原文作者:字節跳動技術團隊
如有侵權,請聯系作者,我們將第一時間刪除!
原文地址:https://blog.csdn.net/ByteDanceTech/article/details/112504772?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522161077545216780255239973%2522%252C%2522scm%2522%253A%252220140713.130102334.pc%255Fall.%2522%257D&request_id=161077545216780255239973&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2allfirst_rank_v2~rank_v29-13-112504772.first_rank_v2_pc_rank_v29&utm_term=iOS%E5%BC%80%E5%8F%91