iOS App冷啓動優化

冷啓動

定義

從用戶點擊App圖標開始到appDelegate didFinishLaunching方法執行完成爲止。html

分爲兩個階段:
T1pre-main階段,即main()函數以前,即操做系統加載App可執行文件到內存,而後執行一系列的加載&連接等工做,最後執行至App的main()函數;
T2main()函數以後,即從main()開始,到appDelegatedidFinishLaunchingWithOptions方法執行完畢前這段時間,主要是構建第一個界面,並完成渲染。ios

從用戶點擊App圖標開始到用戶能看到App主界面內容爲止這個過程,即T1+T2。git

main()函數階段的優化

思路: 在main()函數以後的didFinishLaunchingWithOptions方法裏執行了各類業務,有不少業務不是必定要在這裏執行,咱們能夠延遲加載,防止影響啓動時間。程序員

didFinishLaunchingWithOptions方法裏咱們通常作一下邏輯:github

  • 初始化第三方sdk
  • 配置App運行須要的環境
  • 本身的一些工具類的初始化 等等

main階段的優化大體有如下幾點:shell

  • 減小啓動初始化的流程,能懶加載的懶加載,能放後臺初始化的放後臺,能延遲初始化的延遲,不要卡主線程的啓動時間;
  • 優化代碼邏輯,去除一些非必要的邏輯和代碼,減小每一個流程所消耗的時間;
  • 啓動階段能使用多線程來進行初始化,就使用多線程;
  • 使用純代碼而不是xib或者storyboard來進行UI框架的搭建,尤爲是主UI框架好比TabBarController這種,儘可能避免使用xib或者storyboard,由於它們也仍是要解析成代碼纔去渲染頁面,多了一些步驟;

上面這些優化點,都是前人們總結出來的,在本身的項目實際優化的過程當中,仍是須要結合業務邏輯來處理。緩存

筆者在實際操做的過程當中,先是經過工具檢測出這個過程當中,找出全部方法的耗時時長,而後根據具體的業務邏輯去優化的。性能優化

耗時方法的檢測

其實這階段的優化很明顯,只要咱們找出耗時操做,而後對其進行相應的分析作處理,該延遲調用的延遲,該懶加載的懶加載,便能縮短啓動時間。bash

能夠經過instrumentTime profile工具來分析耗時。以下是我實踐的過程。
首先對Xcode進行配置:
步驟一網絡

步驟二

步驟三: 對項目進行command + shit + k清除操做,而後command + R運行,最後進行instrument工具的喚起,利用快捷鍵command + i便可。

選擇 Time Profiler,點擊 Choose便可。

爲了可以更加直觀的觀察,咱們能夠進行下面的配置

而後點擊左上角的紅色圓圈即可進行耗時檢測,以下圖:

從上面可以很是直觀的看到每一個方法以及對應的耗時,從這裏,咱們便可以找到哪些是咱們所要優化的。

首頁的頁面渲染

若是你在上一步的檢測過程當中,發現TabBarControllerviewDidLoad耗時較長,那麼就要進行下面的檢測。

首頁的viewDidLoad以及viewWillAppear方法中儘可能去嘗試少作,晚作,或者採起異步的方式去作。

以下一段代碼:

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    NSLog(@"didFinishLaunchingWithOptions 開始執行");
    self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
    ISTabBarController *tabBarVc = [[ISTabBarController alloc]init];
    self.window.rootViewController = tabBarVc;
    [self.window makeKeyAndVisible];
    NSLog(@"didFinishLaunchingWithOptions 跑完了");
    return YES;
}
複製代碼

而後來到ISTabBarControllerviewDidLoad方法裏進行他的viewControllers的設置,而後再進入到每一個viewControllerviewDidLoad方法裏進行更多的初始化操做。

2020-02-17 11:17:17.024481+0800 InnotechShop[3776:1477241] didFinishLaunchingWithOptions 開始執行
2020-02-17 11:17:17.034835+0800 InnotechShop[3776:1477241] 開始加載 ISTabBarController 的 viewDidLoad
2020-02-17 11:17:17.034934+0800 InnotechShop[3776:1477241] didFinishLaunchingWithOptions 跑完了
2020-02-17 11:17:17.034965+0800 InnotechShop[3776:1477241] 開始加載 ISViewController 的 viewDidLoad, 而後執行一堆初始化的操做
複製代碼

這種狀況是能保證咱們不在ISTabBarController中操做ISViewControllerview, 若是咱們在ISTabBarController中操做ISViewControllerview的話,那麼調用順序將會是下面這樣:

2020-02-17 11:23:42.018824+0800 InnotechShop[3796:1480231] didFinishLaunchingWithOptions 開始執行
2020-02-17 11:23:42.018883+0800 InnotechShop[3796:1480231] 開始加載 ISTabBarController 的 viewDidLoad
2020-02-17 11:23:42.018957+0800 InnotechShop[3796:1480231] 開始加載 ISViewController 的 viewDidLoad, 而後執行一堆初始化的操做
2020-02-17 11:23:42.019020+0800 InnotechShop[3796:1480231] didFinishLaunchingWithOptions 跑完了
複製代碼

這樣的話,咱們就把界面的初始化、網絡請求、數據解析、視圖渲染等操做都放在了viewDidLoad方法裏,那麼每次啓動App的時候,在用戶看到第一個頁面以前,咱們都要把這些事情所有處理完成,纔會進入到視圖渲染階段。

因爲筆者項目的業務邏輯並非那麼的複雜,因此在實踐中大概作了一下幾點:

  • 把一些沒有必要在didFinishLaunchingWithOptions進行初始化的操做,延遲到首頁渲染完成之後調用
  • 友盟的分享服務,沒有必要在啓動的時候去初始化,初始化任務丟到異步線程解決,大概節省幾百毫秒;
  • 主UI框架tabBarControllerviewDidLoad函數裏,去掉一些沒必要要的函數調用;

優化先後耗時對比:

小結

對於didFinishLaunchingWithOptions,這裏面的初始化是必須執行的,可是咱們能夠適當的根據功能的不一樣對應的適當延遲啓動的時機。對於咱們項目,我將初始化分爲三個類型:

  • 日誌、統計等必須在 APP 一啓動就最早配置的事件
  • 項目配置、環境配置、用戶信息的初始化 、推送、IM等事件
  • 其餘 SDK 和配置事件

對於第一類,因爲這類事件的特殊性,因此必須第一時間啓動,仍然把它留在didFinishLaunchingWithOptions 裏啓動。第二類事件,這些功能在用戶進入APP主體的以前是必需要加載完的,因此咱們能夠把它放在第二批,也就是用戶已經看到廣告頁面,再進行廣告倒計時的時候再啓動。第三類事件,因爲不是必須的,因此咱們能夠放在第一個界面渲染完成之後的viewDidAppear方法裏,這裏徹底不會影響到啓動時間。

閃屏優化

如今許多App在啓動時並不直接進入首頁,而是會向用戶展現一個持續一小段時間的閃屏頁,若是使用恰當,這個閃屏頁就能幫咱們節省一些啓動時間。 下面看兩組閃屏的流程對比便可發現好處:
未優化的閃屏流程:

優化的閃屏流程:

具體能夠參考 這裏

pre-main 階段優化

如下爲iPhone 7p正常啓動消耗的pre-main時間(蘋果提供了內建的測量方法,在 Xcode 中 Edit scheme -> Run -> Auguments -> Environment Variables點擊+添加環境變量 DYLD_PRINT_STATISTICS 設爲 1):

Total pre-main time: 608.72 milliseconds (100.0%)
         dylib loading time: 308.40 milliseconds (50.6%)
        rebase/binding time:  28.92 milliseconds (4.7%)
            ObjC setup time:  22.50 milliseconds (3.6%)
           initializer time: 248.89 milliseconds (40.8%)
           slowest intializers :
             libSystem.B.dylib :   3.75 milliseconds (0.6%)
    libMainThreadChecker.dylib :  31.74 milliseconds (5.2%)
          libglInterpose.dylib : 135.63 milliseconds (22.2%)
                  HelpDeskLite :  21.11 milliseconds (3.4%)
                     InnoAVKit :  20.81 milliseconds (3.4%)
                  InnotechShop :  29.62 milliseconds (4.8%)
複製代碼

解讀:
一、main()函數以前總共用時608.72ms
二、在608.72ms中,加載動態庫使用了308.4ms,指針重定位用了28.92ms,ObjC類初始化使用了22.50ms,各類初始化使用了248.89ms
三、在初始化用時的248.89ms中,用時較多的幾個初始化是libglInterpose.dylib、ibMainThreadChecker.dylib、InnotechShop、InnoAVKit等

pre-main階段的原理

main()函數以前,基本上全部的工做都是系統完成的,開發者可以處理的地方很少,因此想要對這部分進行優化,那麼就須要瞭解一下這一過程系統都作了哪些事情,(原理部分的內容基本上都是網上摘錄的)。 這部分比較晦澀難懂,須要細品

pre-main

可執行文件的內核流程

如圖,當啓動一個應用程序時,系統最後會根據你的行爲調用兩個函數,forkexecvefork功能建立一個進程;execve功能加載和運行程序。這裏有多個不一樣的功能,好比execl,execv和exect,每一個功能提供了不一樣傳參和環境變量的方法到程序中。在OSX中,每一個這些其餘的exec路徑最終調用了內核路徑execve

一、執行exec系統調用,通常都是這樣,用fork()函數新創建一個進程,而後讓進程去執行exec調用。咱們知道,在fork()創建新進程以後,父進程與子進程共享代碼段,但數據空間是分開的,但父進程會把本身數據空間的內容copy到子進程中去,還有上下文也會copy到子進程中去。
二、爲了提升效率,採用一種寫時copy的策略,即建立子進程的時候,並不copy父進程的地址空間,父子進程擁有共同的地址空間,只有當子進程須要寫入數據時(如向緩衝區寫入數據),這時候會複製地址空間,複製緩衝區到子進程中去。從而父子進程擁有獨立的地址空間。而對於fork()以後執行exec後,這種策略可以很好的提升效率,若是一開始就copy,那麼exec以後,子進程的數據會被放棄,被新的進程所代替

動態連接庫dyld

什麼是dyld?

動態連接庫的加載過程主要由dyld來完成,dyld是蘋果的動態連接器 系統先讀取App的可執行文件(Mach-O文件),從裏面得到dyld的路徑,而後加載dyld,dyld去初始化運行環境,開啓緩存策略,加載程序相關依賴庫(其中也包含咱們的可執行文件),並對這些庫進行連接,最後調用每一個依賴庫的初始化方法,在這一步,runtime被初始化。當全部依賴庫的初始化後,輪到最後一位(程序可執行文件)進行初始化,在這時runtime會對項目中全部類進行類結構初始化,而後調用全部的load方法。最後dyld返回main函數地址,main函數被調用,咱們便來到了熟悉的程序入口。

dyld共享庫緩存

當你構建一個真正的程序時,將會連接各類各樣的庫。它們又會依賴其餘一些framework和動態庫。須要加載的動態庫會很是多。而對於相互依賴的符號就更多了。可能將會有上千個符號須要解析處理,這將花費很長的時間 爲了縮短這個處理過程所花費時間,OS X 和 iOS 上的動態連接器使用了共享緩存,OS X的共享緩存位於/private/var/db/dyld/,iOS的則在/System/Library/Caches/com.apple.dyle/。 對於每一種架構,操做系統都有一個單獨的文件,文件中包含了絕大多數的動態庫,這些庫都已經連接爲一個文件,而且已經處理好了它們之間的符號關係。當加載一個 Mach-O 文件 (一個可執行文件或者一個庫) 時,動態連接器首先會檢查共享緩存看看是否存在其中,若是存在,那麼就直接從共享緩存中拿出來使用。每個進程都把這個共享緩存映射到了本身的地址空間中。這個方法大大優化了 OS X 和 iOS 上程序的啓動時間。

dyld加載過程

dyld的加載過程主要分爲下面幾個步驟:

一、Load dylibs image

在每一個動態庫的加載過程當中,dyld須要作下面工做:

  1. 分析因此來的動態庫
  2. 找到動態庫的mach-o文件
  3. 打開文件
  4. 驗證文件
  5. 在系統核心註冊文件簽名
  6. 對動態庫的每個segment調用mmap()

針對這一步的優化:

  1. 減小非系統庫的依賴
  2. 合併不是系統庫

看下筆者項目依賴的共享動態庫
輸入命令:otool -L XXXX

二、Rebase/Bind image

因爲ASLR(address space layout randomization)的存在,可執行文件和動態連接庫在虛擬內存中的加載地址每次啓動都不固定,因此須要這2步來修復鏡像中的資源指針,來指向正確的地址。 rebase修復的是指向當前鏡像內部的資源指針; 而bind指向的是鏡像外部的資源指針。

rebase步驟先進行,須要把鏡像讀入內存,並以page爲單位進行加密驗證,保證不會被篡改,因此這一步的瓶頸在IO。bind在其後進行,因爲要查詢符號表,來指向跨鏡像的資源,加上在rebase階段,鏡像已被讀入和加密驗證,因此這一步的瓶頸在於CPU計算。

優化該階段的關鍵在於減小__DATA segment中的指針數量。咱們能夠優化的點有:

  1. 減小Objc類數量, 減小selector數量
  2. 減小C++虛函數數量
三、Objc setup

Objc setup主要是在objc_init完成的,objc_init是在libsystem中的一個initialize方法libsystem_initializer中初始化了libdispatch,而後libdispatch_init調用了_os_object_int, 最終調用了_objc_init

void _objc_init(void)
{
    static bool initialized = false;
    if (initialized) return;
    initialized = true;
    
    environ_init();
    tls_init();
    static_init();
    lock_init();
    exception_init();

    _dyld_objc_notify_register(&map_2_images, load_images, unmap_image);
}
複製代碼

經過上面代碼能夠知道,runtime_objc_initdyld綁定了3個回調函數,分別是map_2_images,load_images和unmap_image

一、dyld在binding操做結束以後,會發出dyld_image_state_bound通知,而後與之綁定的回調函數map_2_images就會被調用,它主要作如下幾件事來完成Objc Setup

  • 讀取二進制文件的 DATA 段內容,找到與 objc 相關的信息
  • 註冊 Objc 類
  • 確保 selector 的惟一性
  • 讀取 protocol 以及 category 的信息

二、load_images函數做用就是調用Objc的load方法,它監聽dyld_image_state_dependents_initialize通知
三、unmap_image能夠理解爲map_2_images的逆向操做

因爲以前2步驟的優化,這一步實際上沒有什麼可作的。幾乎都靠 Rebasing 和 Binding 步驟中減小所需 fix-up 內容。由於前面的工做也會使得這步耗時減小。

四、initializers

以上三步屬於靜態調整,都是在修改__DATA segment中的內容,而這裏則開始動態調整,開始在堆和棧中寫入內容。 工做主要有:

一、 Objc的+load()函數
二、 C++的構造函數屬性函數 形如attribute((constructor)) void DoSomeInitializationWork()
三、 非基本類型的C++靜態全局變量的建立(一般是類或結構體)(non-trivial initializer) 好比一個全局靜態結構體的構建,若是在構造函數中有繁重的工做,那麼會拖慢啓動速度

Objc的load函數和C++的靜態構造器採用由底向上的方式執行,來保證每一個執行的方法,均可以找到所依賴的動態庫

一、 dyld開始將程序二進制文件初始化
二、 交由ImageLoader讀取image,其中包含了咱們的類、方法等各類符號
三、 因爲runtime向dyld綁定了回調,當image加載到內存後,dyld會通知runtime進行處理
四、 runtime接手後調用map images作解析和處理,接下來load images中調用 callloadmethods方法,遍歷全部加載進來的Class,按繼承層級依次調用Class+load方法和其 Category+load方法

整個事件由dyld主導,完成運行環境的初始化後,配合ImageLoader 將二進制文件按格式加載到內存,動態連接依賴庫,並由runtime負責加載成objc 定義的結構,全部初始化工做結束後,dyld調用真正的main函數

這一步可作的優化有:

  • 使用+initialize來代替+load
  • 不要使用atribute((constructor)) 將方法顯式標記爲初始化器,而是讓初始化方法調用時才執行。好比使用 dispatch_once()、pthread_once() 或 std::once()。也就是在第一次使用時才初始化,推遲了一部分工做耗時。也儘可能不要用到C++的靜態對象。

pre-main階段具體優化

一、刪除無用代碼(未被調用的靜態變量、類和方法)

  • 可使用AppCode對工程進行掃描,刪除無用代碼
  • 刪減一些無用的靜態變量
  • 刪減沒有被調用或者已經廢棄的方法

二、+load方法處理

+load()方法,用於在App啓動執行一些操做,+load()方法在Initializers階段被執行,但過多的+load()方法則會拖慢啓動速度。 分析+load()方法,看是否能夠延遲到App冷啓動後的某個時間節點。

筆者在處理這個問題的過程當中遇到一個坑,項目裏有防crash的類,裏面有大量的系統類的load方法,針對系統的load方法,咱們不用去優化,由於在啓動的過程當中,有可能initialize方法也會被調用,並起不到優化的做用,反而仍是出現各類各樣的問題;另一點須要注意的問題是initialize的重複調用問題,能用dispatch_once()來完成的,就儘可能不要用到load方法

三、針對減小沒必要要的庫

統計了各個庫所佔的size(安裝包size優化的腳本),基本上一個公共庫越大,類越多,啓動時在pre-main階段所需的時間也越多。 統計結果以下:

pod有源碼的庫(靜態庫):

第三方framework(其實也是靜態庫,只是腳本分開統計):

筆者項目中使用cocoapods並無設置use_frameworks,因此pod管理的有源碼的第三方庫都是靜態庫的形式,而framework形式的靜態庫基本都是第三方公司提供的服務

這個過程並無作任何優化,對庫進行了逐一排查,均爲正在使用,顧這個環節沒有優化,只是記錄了一下。

四、合併功能相似的類和擴展(Category)

因爲Category的實現原理,和ObjC的動態綁定有很強的關係,因此實際上類的擴展是比較佔用啓動時間的。儘可能合併一些擴展,會對啓動有必定的優化做用。不過我的認爲也不能由於它佔用啓動時間而去逃避使用擴展,畢竟程序員的時間比CPU的時間值錢,這裏只是強調要合併一些在工程、架構上沒有太大意義的擴展。

五、壓縮資源圖片

壓縮圖片爲何能加快啓動速度呢?由於啓動的時候大大小小的圖片加載個十來二十個是很正常的,圖片小了,IO操做量就小了,啓動固然就會快了。

以上內容就是本次在作啓動時間優化所涉及的內容,理論知識都是從網上查詢得知,具體實踐,筆者都一一嘗試,做爲記錄。

參考資料:
美團外賣iOS App冷啓動治理
iOS啓動優化-凌雲的博客
iOS啓動時間優化-第七章
iOS啓動時間優化-PerTerbin
iOS App 啓動性能優化

相關文章
相關標籤/搜索