深入探索 iOS 啟動速度優化

来源:https://www.cnblogs.com/simplepp/archive/2020/05/13/12882533.html
-Advertisement-
Play Games

介紹 App 的啟動時間是體現其性能優劣的一個重要指標,啟動時間越快用戶的等待時間就越短,提升用戶體驗感,大廠應用甚至會做到“ 毫秒必究 ”。 我們將 App 啟動方式分為: 名稱說明 冷啟動 App 啟動時,應用進程不在系統中(初次打開或程式被殺死),需要系統分配新的進程來啟動應用。 熱啟動 Ap ...


 

 

介紹

App 的啟動時間是體現其性能優劣的一個重要指標,啟動時間越快用戶的等待時間就越短,提升用戶體驗感,大廠應用甚至會做到“ 毫秒必究 ”。

我們將 App 啟動方式分為:

名稱說明
冷啟動 App 啟動時,應用進程不在系統中(初次打開或程式被殺死),需要系統分配新的進程來啟動應用。
熱啟動 App 退回後臺後,對應的進程還在系統中,啟動則將應用返回前臺展示。

本篇文章主要針對冷啟動方式進行優化分析,介紹常用的檢測工具及優化方法。

冷啟動流程

Apple 官方的《WWDC Optimizing App Startup Time》 將 iOS 應用的啟動可分為 pre-main 階段和 main 兩個階段,最佳的啟動速度是400ms以內,最慢不得大於20s,否則會被系統進程殺死(最低配置設備)。

為了更好的區分,筆者將整個啟動流程分為三個階段, App總啟動流程 = pre-main + main函數代理(didFinishLaunchingWithOptions)+ 首屏渲染(viewDidAppear),後兩個階段都屬於 main函數 執行階段。

pre-main 執行內容

此時對應的 App 頁面是閃屏頁的展示。

  • 載入可執行文件

    載入 Mach-O 格式文件,既 App 中所有類編譯後生成的格式為 .o 的目標文件集合。

  • 載入動態庫

    dyld 載入 dylib 會完成如下步驟:

    1. 分析 App 依賴的所有 dylib。
    2. 找到 dylib 對應的 Mach-O 文件。
    3. 打開、讀取這些 Mach-O 文件,並驗證其有效性。
    4. 在系統內核中註冊代碼簽名。
    5. 對 dylib 的每一個 segment 調用 mmap()。

    系統依賴的動態庫由於被優化過,可以較快的載入完成,而開發者引入的動態庫需要耗時較久。

  • Rebase和Bind操作

    由於使用了ASLR 技術,在 dylib 載入過程中,需要計算指針偏移得到正確的資源地址。 Rebase 將鏡像讀入記憶體,修正鏡像內部的指針,消耗 IO 性能;Bind 查詢符號表,進行外部鏡像的綁定,需要大量 CPU 計算。

  • Objc setup

    進行 Objc 的初始化,包括註冊 Objc 類、檢測 selector 唯一性、插入分類方法等。

  • Initializers

    往應用的堆棧中寫入內容,包括執行 +load 方法、調用 C/C++ 中的構造器函數(用 attribute((constructor)) 修飾的函數)、創建非基本類型的 C++ 靜態全局變數等。

main函數代理執行內容

從 main() 函數開始執行到 didFinishLaunchingWithOptions 方法執行結束的耗時。通常會在這個過程中進行各種工具(監控工具、推送、定位等)初始化、許可權申請、判斷版本、全局配置等。

首屏渲染執行內容

首屏 UI 構建階段,需要 CPU 計算佈局並由 GPU 完成渲染,如果數據來源於網路,還需進行網路請求。

優化方案

pre-main階段

檢測方法

獲得 main() 方法執行前的耗時比較簡單,通過 Xcode 自帶的測量方法既可以。將 Xcode 中 Product -> Scheme -> Edit scheme -> Run -> Environment Variables 將環境變數 DYLD_PRINT_STATISTICS 或 DYLD_PRINT_STATISTICS_DETAILS 設為 1 即可獲得執行每項耗時:

// example 
// DYLD_PRINT_STATISTICS
Total pre-main time: 383.50 milliseconds (100.0%)
         dylib loading time: 254.02 milliseconds (66.2%)
        rebase/binding time:  20.88 milliseconds (5.4%)
            ObjC setup time:  29.33 milliseconds (7.6%)
           initializer time:  79.15 milliseconds (20.6%)
           slowest intializers :
             libSystem.B.dylib :   8.06 milliseconds (2.1%)
    libMainThreadChecker.dylib :  22.19 milliseconds (5.7%)
                  AFNetworking :  11.66 milliseconds (3.0%)
                  TestDemo :  38.19 milliseconds (9.9%)

// DYLD_PRINT_STATISTICS_DETAILS
  total time: 614.71 milliseconds (100.0%)
  total images loaded:  401 (380 from dyld shared cache)
  total segments mapped: 77, into 1785 pages with 252 pages pre-fetched
  total images loading time: 337.21 milliseconds (54.8%)
  total load time in ObjC:  12.81 milliseconds (2.0%)
  total debugger pause time: 307.99 milliseconds (50.1%)
  total dtrace DOF registration time:   0.07 milliseconds (0.0%)
  total rebase fixups:  152,438
  total rebase fixups time:   2.23 milliseconds (0.3%)
  total binding fixups: 496,288
  total binding fixups time: 218.03 milliseconds (35.4%)
  total weak binding fixups time:   0.75 milliseconds (0.1%)
  total redo shared cached bindings time: 221.37 milliseconds (36.0%)
  total bindings lazily fixed up: 0 of 0
  total time in initializers and ObjC +load:  43.56 milliseconds (7.0%)
                         libSystem.B.dylib :   3.67 milliseconds (0.5%)
               libBacktraceRecording.dylib :   3.41 milliseconds (0.5%)
                libMainThreadChecker.dylib :  21.19 milliseconds (3.4%)
                              AFNetworking :  10.89 milliseconds (1.7%)
                              TestDemo :   2.37 milliseconds (0.3%)
total symbol trie searches:    1267474
total symbol table binary searches:    0
total images defining weak symbols:  34
total images using weak symbols:  97

優化點

  • 合併動態庫,並減少使用 Embedded Framework,即非系統創建的動態 Framework,如果對包體積要求不嚴格還可以使用靜態庫代替。

  • 刪除無用代碼(未使用的靜態變數、類和方法等)並抽取重覆代碼。

  • 避免在 +load 執行方法,使用 +initialize 代替。

  • 避免使用 attribute((constructor)),可將要實現的內容放在初始化方法中配合 dispatch_once 使用。

  • 減少非基本類型的 C++ 靜態全局變數的個數。(因為這類全局變數通常是類或者結構體,如果在構造函數中有繁重的工作,就會拖慢啟動速度)

main函數代理階段

檢測方法

  • 手動插入代碼計算耗時

    在 man() 函數開始執行時就開始時間:

    CFAbsoluteTime StartTime;  //  記錄全局變數
    int main(int argc, char * argv[]) {
        @autoreleasepool {
            StartTime = CFAbsoluteTimeGetCurrent();
            return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
        }
    }

    再在 didFinishLaunchingWithOptions 返回之前獲取結束時間,兩者的差值即為該階段的耗時:

    extern CFAbsoluteTime startTime; // 申明全局變數
    - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    
        //...
        //...
    
        double launchTime = (CFAbsoluteTimeGetCurrent() - startTime);
        return YES;
    }

    通過這種手動埋點的方式也可以對每個函數進行埋點獲取耗時,但當函數多時也需要不小的工作量,且後續上線還需要移除代碼,不可復用。

  • Time Profiler

    Xcode自帶的工具,原理是定時抓取線程的堆棧信息,通過統計比較時間間隔之間的堆棧狀態,計算一段時間內各個方法的近似耗時。精確度取決於設置的定時間隔。

    通過 Xcode → Open Developer Tool → Instruments → Time Profiler 打開工具,註意,需將工程中 Debug Information Format 的 Debug 值改為 DWARF with dSYM File,否則只能看到一堆線程無法定位到函數。

 

通過雙擊具體函數可以跳轉到對應代碼處,另外可以將 Call Tree 的 Seperate by Thread 和 Hide System Libraries 勾選上,方便查看。

 

正常Time Profiler是每1ms採樣一次, 預設只採集所有在運行線程的調用棧,最後以統計學的方式彙總。所以會無法統計到耗時過短的函數和休眠的線程,比如下圖中的5次採樣中,method3都沒有採樣到,所以最後聚合到的棧里就看不到method3。

 

我們可以將 File -> Recording Options 中的配置調高,即可獲取更精確的調用棧。

 
  • System Trace

    有時候當主線程被其他線程阻塞時,無法通過 Time Profiler 一眼看出,我們還可以使用 System Trace,例如我們故意在 dyld 鏈接動態庫後的回調里休眠10ms:

    static void add(const struct mach_header* header, intptr_t imp) {
        usleep(10000);
    }
    - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
    {
        dispatch_sync(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
            _dyld_register_func_for_add_image(add);
        });
      ....
    }
 

可以看到整個記錄過程耗時7s,但 Time Profiler 上只顯示了1.17s,且看到啟動後有一段時間是空白的。這時通過 System Trace 查看各個線程的具體狀態。

 

可以看到主線程有段時間被阻塞住了,存在一個互斥鎖,切換到 Events:Thread State觀察阻塞的下一條指令,發現0x5d39c 執行完成釋放鎖後,主線程才開始執行。

 

接著我們觀察 0x5d39c 線程,發現在主線程阻塞的這段時間,該線程執行了多次10ms的 sleep 操作,到此就找到了主線程被子線程阻塞導致啟動緩慢的原因。

 

今後,當我們想更清楚的看到各個線程之間的調度就可以使用 System Trace,但還是建議優先使用 Time Profiler,使用簡單易懂,排查問題效率更高。

  • App Launch

    Xcode11 之後新出的工具,功能相當於 Time Profiler 和 System Trace 的整合。

  • Hook objc_msgSend

    可以對 objc_msgSend 進行 Hook 獲取每個函數的具體耗時,優化在啟動階段耗時多的函數或將其置後調用。實現方法可查看 通過objc_msgSend實現iOS方法耗時監控

優化點

  • 通過檢測工具找到耗時多的函數,拆分其功能,將優先順序低的功能延後執行。
  • 梳理業務邏輯,把可以延遲執行的邏輯,做延遲執行處理。比如檢查新版本、註冊推送通知等邏輯。
  • 梳理各個二方/三方庫,找到可以延遲載入的庫,做延遲載入處理,比如放到首頁控制器的viewDidAppear方法後。

首屏渲染階段

檢測方法

記錄首屏 viewDidLoad 開始時間和viewDidAppear 開始時間,兩者的差值即為整個首屏渲染耗時,如果要獲得具體每個步驟耗時,則可同main函數代理階段使用 Time Profiler 或 Hook objc_msgSend

優化點

  • 使用簡單的廣告頁作為過渡,將首頁的計算操作及網路請求放在廣告頁展示時非同步進行。
  • 涉及活動需變更頁面展示時(例如雙十一),提前下發數據緩存。
  • 首頁控制器用純代碼方式來構建,而不是 xib/Storyboard,避免佈局轉換耗時。
  • 避免在主線程進行大量的計算,將與首屏無關的計算內容放在頁面展示後進行,縮短 CPU 計算時間。
  • 避免使用大圖片,減少視圖數量及層級,減輕 GPU 的負擔。
  • 做好網路請求介面優化(DNS 策略等),只請求與首屏相關數據。
  • 本地緩存首屏數據,待渲染完成後再去請求新數據。

其它優化

二進位重排

去年年底二進位重排的概念被宇宙廠帶火了起來,個人覺得噱頭大於效果,詳細內容可參考文章

總結

啟動優化不應該是一次性的,最好的方案也不是在出現才去解決,而應該包括:

  • 解決現存的問題
  • 後續開發的管控
  • 完整的監控體系

只有在開發的前中後同時介入,才能保證 App 的出品質量,畢竟開發是前人挖坑給後人填坑的過程

您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 一、簡單介紹 SQLite 觸發器(Trigger)是資料庫的回調函數,它會在指定的資料庫事件發生時自動執行/調用。以下是關於 SQLite 的觸發器(Trigger)的要點: SQLite 觸發器(Trigger)可以指定在特定的資料庫表發生 DELETE、INSERT 或 UPDATE 時觸發, ...
  • 1. 2,點擊其他--新建目錄--輸入目錄路徑....dmp的目錄 3,新建一個表空間, 其他--表空間--新建表空間 點擊保存 4...點擊數據泵,,數據泵導入 5...點擊生成sql,運行, (運行裡面有ora報錯的話,自行百度) 6...創建一個用戶可能登陸的,用戶--用戶--新建用戶 7.. ...
  • 表結構 student(StuId,StuName,StuAge,StuSex) 學生表 teacher(TId,Tname) 教師表 course(CId,Cname,C_TId) 課程表 sc(SId,S_CId,Score) 成績表 問題十一:查詢至少有一門課與學號為“1001”的同學所學相同 ...
  • 1. 概述 Apache Cassandra將數據存儲在表中,每個表都由行和列組成。CQL(Cassandra查詢語言)用於查詢存儲在表中的數據。Apache Cassandra數據模型基於查詢並針對查詢進行了優化。Cassandra不支持用於關係資料庫的關係數據建模。Cassandra數據建模專註 ...
  • 從北斗衛星時鐘(北斗校時器)發展縱論世界衛星導航新格局 從北斗衛星時鐘(北斗校時器)發展縱論世界衛星導航新格局 更多資料添加京準電子科技官微——ahjzsz 世紀初,世界衛星導航領域建成並提供服務的衛星導航系統,主要有美國GPS、俄羅斯GLONASS和我國北斗衛星導航系統。其中,美國GPS系統向全球 ...
  • (一)跳躍表 跳躍表是一種有序的數據結構,它通過每個節點中維持多個指向其他節點的指針,從而達到快速訪問節點的目的。 Redis使用跳躍表作為有序集合鍵的底層實現之一,如果一個有序集合包含的元素數量比較多,或者有序集合中元素的成員是比較長的字元串時,Redis就會使用跳躍表作為有序集合鍵的底層實現。 ...
  • 前言: 本文詳細介紹了 HBase QualifierFilter 過濾器 Java&Shell API 的使用,並貼出了相關示例代碼以供參考。QualifierFilter 基於列名進行過濾,在工作中涉及到需要通過HBase 列名進行數據過濾時可以考慮使用它。比較器細節及原理請參照之前的更文: " ...
  • 情況 App採用Glide做載入網路圖片功能。穩定版本的App,突然有很多圖片無法載入出來,經排查,除了Glide框架已經緩存過的圖片其他圖片都觸發了Glide.onError 異常為 大意是系統無法生成GlideModule。 我的編譯環境: 第一個解決方案: 無效 第二個解決方案 用 Java ...
一周排行
    -Advertisement-
    Play Games
  • 1. 說明 /* Performs operations on System.String instances that contain file or directory path information. These operations are performed in a cross-pla ...
  • 視頻地址:【WebApi+Vue3從0到1搭建《許可權管理系統》系列視頻:搭建JWT系統鑒權-嗶哩嗶哩】 https://b23.tv/R6cOcDO qq群:801913255 一、在appsettings.json中設置鑒權屬性 /*jwt鑒權*/ "JwtSetting": { "Issuer" ...
  • 引言 集成測試可在包含應用支持基礎結構(如資料庫、文件系統和網路)的級別上確保應用組件功能正常。 ASP.NET Core 通過將單元測試框架與測試 Web 主機和記憶體中測試伺服器結合使用來支持集成測試。 簡介 集成測試與單元測試相比,能夠在更廣泛的級別上評估應用的組件,確認多個組件一起工作以生成預 ...
  • 在.NET Emit編程中,我們探討了運算操作指令的重要性和應用。這些指令包括各種數學運算、位操作和比較操作,能夠在動態生成的代碼中實現對數據的處理和操作。通過這些指令,開發人員可以靈活地進行算術運算、邏輯運算和比較操作,從而實現各種複雜的演算法和邏輯......本篇之後,將進入第七部分:實戰項目 ...
  • 前言 多表頭表格是一個常見的業務需求,然而WPF中卻沒有預設實現這個功能,得益於WPF強大的控制項模板設計,我們可以通過修改控制項模板的方式自己實現它。 一、需求分析 下圖為一個典型的統計表格,統計1-12月的數據。 此時我們有一個需求,需要將月份按季度劃分,以便能夠直觀地看到季度統計數據,以下為該需求 ...
  • 如何將 ASP.NET Core MVC 項目的視圖分離到另一個項目 在當下這個年代 SPA 已是主流,人們早已忘記了 MVC 以及 Razor 的故事。但是在某些場景下 SSR 還是有意想不到效果。比如某些靜態頁面,比如追求首屏載入速度的時候。最近在項目中回歸傳統效果還是不錯。 有的時候我們希望將 ...
  • System.AggregateException: 發生一個或多個錯誤。 > Microsoft.WebTools.Shared.Exceptions.WebToolsException: 生成失敗。檢查輸出視窗瞭解更多詳細信息。 內部異常堆棧跟蹤的結尾 > (內部異常 #0) Microsoft ...
  • 引言 在上一章節我們實戰了在Asp.Net Core中的項目實戰,這一章節講解一下如何測試Asp.Net Core的中間件。 TestServer 還記得我們在集成測試中提供的TestServer嗎? TestServer 是由 Microsoft.AspNetCore.TestHost 包提供的。 ...
  • 在發現結果為真的WHEN子句時,CASE表達式的真假值判斷會終止,剩餘的WHEN子句會被忽略: CASE WHEN col_1 IN ('a', 'b') THEN '第一' WHEN col_1 IN ('a') THEN '第二' ELSE '其他' END 註意: 統一各分支返回的數據類型. ...
  • 在C#編程世界中,語法的精妙之處往往體現在那些看似微小卻極具影響力的符號與結構之中。其中,“_ =” 這一組合突然出現還真不知道什麼意思。本文將深入剖析“_ =” 的含義、工作原理及其在實際編程中的廣泛應用,揭示其作為C#語法奇兵的重要角色。 一、下劃線 _:神秘的棄元符號 下劃線 _ 在C#中並非 ...