扣丁書屋

抖音 Android 性能優化系列:啟動優化實踐

啟動性能是 APP 使用體驗的門面,啟動過程耗時較長很可能使用戶削減使用 APP 的興趣,抖音通過對啟動性能做劣化實驗也驗證了其對于業務指標有顯著影響。抖音有數億的日活,啟動耗時幾百毫秒的增長就可能帶來成千上萬用戶的留存縮減,因此,啟動性能的優化成為了抖音 Android 基礎技術團隊在體驗優化方向上的重中之重。

在上一篇[啟動性能優化之理論和工具篇] 中,已經從原理、方法論、工具的角度對抖音的啟動性能優化進行了介紹,本文將從實踐的角度通過具體的案例分析介紹抖音啟動優化的方案與思路。

前言

啟動是指用戶從點擊 icon 到看到頁面首幀的整個過程,啟動優化的目標就是減少這一過程的耗時。

啟動過程比較復雜,在進程與線程維度,它涉及到多次跨進程的通信與多個線程之間的切換;在耗時成因維度,它包括 CPU、CPU 調度、IO、鎖等待等多類耗時。雖然啟動過程比較復雜,但是我們最終可以把它抽象成主線程的一個線性過程。因此對于啟動性能的優化,就是去縮短主線程的這個線性過程。

接下來,我們將按照主線程直接優化、后臺線程間接優化、全局優化的邏輯,介紹團隊在啟動優化的實踐中遇到的一些比較典型的案例,其間對于業界一些比較優秀的方案也會進行簡要介紹。

優化案例解析

1. 主線程直接優化

對于主線程的優化,我們將按照生命周期先后的順序展開介紹。

1.1 MutilDex 優化

首先我們來看第一個階段,也就是 Application 的 attachBaseContext 階段。這個階段由于 Applicaiton Context 賦值等問題,一般不會有太多的業務代碼,預期中也不會有多少耗時。但在實際測試過程中,我們發現在某些機型上,應用安裝后的首次啟動耗時非常嚴重,經過初步定位,主要耗時在MultiDex.install。

在經過詳細分析后,我們確定該問題集中在4.x的機型上,其影響首次安裝及后續更新后的首次啟動。

造成這一問題的原因為:dex 的指令格式設計并不完善,單個 dex 文件中引用的 Java 方法總數不能超過 65536 個,在方法數超過 65536 的情況下,將拆分成多個 dex。一般情況下 Dalvik 虛擬機只能執行經過優化后的 odex 文件,在 4.x 設備上為了提升應用安裝速度,其在安裝階段僅會對應用的首個 dex 進行優化。對于非首個 dex 其會在首次運行調用 MultiDex.install 時進行優化,而這個優化是非常耗時的,這就造成了 4.x 設備上首次啟動慢的問題。

造成這個問題的必要條件有多個,它們分別是:dex 被拆分成多個 dex 文件、安裝過程中僅對首個 dex 進行的優化、啟動階段調用 MultiDex.install 以及 Dalvik 虛擬機需要加載 odex。

很明顯前兩個條件我們是沒辦法破壞的——對于抖音來說,我們很難將其優化成只有單 dex,系統的安裝過程我們也無法改變。啟動階段調用MultiDex.install這個條件也比較難以破壞——首先,隨著業務的膨脹我們很難做到用一個 dex 去承載啟動階段的代碼;其次,即使做到了后續也比較難以維護。

因此我們選擇破壞“Dalvik 虛擬機需要加載 odex”這一限制,即繞過 Dalvik 的限制直接加載未經優化的 dex。這個方案的核心在 Dalvik_dalvik_system_DexFile_openDexFile_bytearray 這個 native 函數,它支持加載未經優化后的 dex 文件。具體的優化方案如下:

  1. 首先從 APK 中解壓獲取原始的非首個 dex 文件的字節碼;
  2. 調用 Dalvik_dalvik_system_DexFile_openDexFile_bytearray,逐個傳入之前從 APK 獲取的 DEX 字節碼,完成 DEX 加載,得到合法的 DexFile 對象;
  3. 將 DexFile 都添加到 APP 的 PathClassLoader 的 DexPathList 里;
  4. 延后異步對非首個 dex 進行 odex 優化。

關于 MutilDex 優化的更多細節可以參照之前的一篇[公眾號文章] ,目前該方案已經開源,具體見該項目的 github 地址(https://github.com/bytedance/BoostMultiDex)。

1.2 ContentProvider 優化

接下來介紹的是ContentProvider的相關優化,ContentProvider 作為 Android 四大組件之一,其在生命周期方面有著獨特性——Activity、Service、BroadcastReceiver 這三大組件都只有在它們被調用到時,才會進行實例化,并執行它們的生命周期;ContentProvider 即使在沒有被調用到,也會在啟動階段被自動實例化并執行相關的生命周期。在進程的初始化階段調用完 Application 的 attachBaseContext 方法后,會再去執行 installContentProviders 方法,對當前進程的所有 ContentProvider 進行 install。

這個過程將會對當前進程的所有 ContentProvider 通過 for 循環的方式逐一進行實例化、調用它們的 attachInfo 與 onCreate 生命周期方法,最后將這些 ContentProvider 關聯的 ContentProviderHolder 一次性 publish 到 AMS 進程。

ContentProvider 這種在進程初始化階段自動初始化的特性,使得在其作為跨進程通信組件的同時,也被一些模塊用來進行自動初始化,這其中最為典型的就是官方的 Lifecycle 組件,其初始化就是借助了一個叫 ProcessLifecycleOwnerInitializer 的 ContentProvider 進行初始化的。

LifeCycle 的初始化只是進行了 Activity 的 LifecycleCallbacks 的注冊耗時不多,我們在邏輯層面上不需要做太多的優化。值得注意的是,如果這類用于進行初始化的 ContentProvider 非常多,ContentProvider 本身的創建、生命周期執行等堆積起來也會非常耗時。針對這個問題,我們可以通過 JetPack 提供的 Startup 將多個初始化的 ContentProvider 聚合成一個來進行優化。

除了這類耗時很少的 ContentProvider,在實際優化過程中我們也發現了一些耗時較長的 ContentProvider,這里大致介紹一下我們的優化思路。

public class ProcessLifecycleOwnerInitializer extends ContentProvider {
    @Override
    public boolean onCreate() {
        LifecycleDispatcher.init(getContext());
        ProcessLifecycleOwner.init(getContext());
        return true;
    }
}

對于我們自己的 ContentProvider,如果初始化耗時我們可以通過重構的方式將自動初始化改為按需初始化。對于一些三方甚至是官方的 ContentProvider,則無法直接通過重構的方式進行優化。這里以官方的 FileProvider 為例,來介紹我們的優化思路。

FileProvider 使用

FileProvider 是 Android7.0 引入的用于進行文件訪問權限控制的組件,在引入 FileProvider 之前我們對于拍照等一些跨進程的文件操作,是以直接傳遞文件 Uri 的方式進行的;在引入 FileProvider 后,我們的整個過程則為:

  1. 首先繼承 FileProvider 實現一個自定義的 FileProvider,并把這個 Provider 在 manifest 中進行注冊,為其 FILE_PROVIDER_PATHS 屬性關聯一個 file path 的 xml 文件;
  2. 使用方法通過 FileProvider 的 getUriForFile 方法將文件路徑轉化為 Content Uri,然后去調用 ContentProvider 的 query、openFile 等方法。
  3. 當 FileProvider 被調用到時,將會首先去進行文件路徑的校驗,判斷其是否在第 1 步定義的 xml 中,文件路徑校驗通過則繼續執行后續的邏輯。

耗時分析

從上面的過程來看,只要我們在啟動階段沒有 FileProvider 的調用,是不會有 FileProvider 的相關耗時的。但實際上從啟動 trace 來看,我們的啟動階段是存在 FileProvider 相關耗時的,具體的耗時則是在 FileProvider 的生命周期方法 attachInfo 方法中,FileProvider 的 attachInfo 方法除了會去調用我們最為熟悉的 onCreate 方法,同時還會去調用 getPathStrategy 方法,我們的耗時則是集中在這個 getPathStrategy 方法中。

從實現來看, getPathStrategy 方法主要是進行 FileProvider 關聯 xml 文件的解析,解析結果將會賦值給 mStrategy 變量。進一步分析我們會發現 mStrategy 會在 FileProvider 的 query、getType、openFile 等接口進行文件路徑校驗時用到,而我們的 query、getType、openFile 等接口在啟動階段是不會被調用到的,因此 FileProvider attachInfo 方法中的 getPathStrategy 是完全沒有必要的,我們完全可以在 query、getType、openFile 等接口被調用到的時候再去執行 getPathStrategy 邏輯。

優化方案

FileProvider 是 androidx 中的代碼,我們無法直接修改,但是它會參與我們的代碼編譯,我們可以在編譯階段通過修改字節碼的方式去修改它的實現,具體的實現方案為:

  1. 對 ContentProvider 的 attachInfo 方法進行插樁,在執行原有實現前將參數 ProviderInfo 的 grantUriPermissions 設置為 false,然后調用原實現并進行異常捕獲,在調用完成后再對 ProviderInfo 的 grantUriPermissions 設置回 true,利用 grantUriPermissions 的檢查繞過 getPathStrategy 的執行。(這里之所以沒有使用 ProviderInfo 的 exported 異常檢測繞過 getPathStrategy 調用是因為在 attachInfo 的 super 方法中會對 ProviderInfo 的 exported 屬性進行緩存)

public void attachInfo(@NonNull Context context, @NonNull ProviderInfo info) {
    super.attachInfo(context, info);

    // Sanity check our security
    if (info.exported) {
        throw new SecurityException("Provider must not be exported");
    }
    if (!info.grantUriPermissions) {
        throw new SecurityException("Provider must grant uri permissions");
    }

    mStrategy = getPathStrategy(context, info.authority);
}

2 . 對 FileProvider 的 query、getType、openFile 等方法進行插樁,在調用原方法之前首先進行 getPathStrategy 的初始化,完成初始化之后再調用原始實現。

單個 FileProvider 的耗時雖然不多,但是對于一些大型的 app,為了模塊解耦其可能會有多個 FileProvider,在這種情況下 FileProvider 優化的收益還是比較可觀的。與 FileProvider 類似,Google 提供的 WorkManager 也會存在初始化的 ContentProvider,我們可以采用類似的方式進行優化。

1.3 啟動任務重構與任務調度

啟動的第三個階段是 Application 的 onCreate 階段,這個階段是啟動任務執行的高峰階段,該階段的優化就是針對各類啟動任務的優化,具有極強的業務關聯性,這里簡單介紹一下我們優化的大概思路。

抖音啟動任務優化的核心思想是代碼價值最大化資源利用率最大化。其中代碼價值最大化主要是確定哪些任務應該在啟動階段執行,它的核心目標是將不應該在啟動階段執行的任務從啟動階段去除掉;資源利用率最大化則是在啟動階段任務已經確定的情況下,盡可能多的去利用系統資源以達到減少任務執行耗時的目的。對于單個任務而言,我們需要去優化它的內部實現,減少它本身的資源消耗以提供更多資源給其他任務執行,對于多個任務則是通過合理的調度以充分利用系統的資源。

從落地角度而言我們主要圍繞兩個事情開展:啟動任務重構與任務調度。

啟動任務重構

由于業務復雜度較高且前期對啟動任務的管控較為寬松,抖音啟動階段的任務有超過 300 個,這種情況下對啟動階段的任務進行調度能夠在一定程度上提升啟動速度,但是仍然比較難將啟動速度提升到一個較高的水平,因此啟動優化中非常重要的一個方向就是減少啟動任務。

為此我們將啟動任務分成了配置任務、預加載任務和功能任務三大類。其中配置任務主要用于對各類 sdk 進行初始化,在它沒有執行之前相關的 sdk 是無法工作的;預加載任務主要是為了對后續的某些功能進行預熱,以提升后續功能的執行速度;功能任務則是在進程啟動這一生命周期執行的與功能相關的任務。對于這三類任務我們采用了不同的改造方式:

  • 配置任務:對于配置任務我們最終目標是把它們從啟動階段去除掉,這樣做主要有兩個原因,首先部分配置任務仍然存在一定的耗時,將它們從啟動任務移除掉可以提升我們的啟動速度;其次配置任務在沒有執行前相關 sdk 無法正常使用,這會對我們的功能可用性、穩定性以及優化過程中的調度造成影響。為了達到去除配置任務的目的,我們對配置任務進行了原子化的改造,將原本需要主動調用向 sdk 中注入 context、callback 等各類參數的實現,通過 spi(服務發現)的方式改為了按需調用的方式——對于抖音自己的代碼我們在需要使用 context、callback 等參數時通過 spi 的方式去請求應用上層進行獲取,對于我們無法修改代碼的三方 sdk,我們則對它們進行一個中間層封裝,后續對于三方 sdk 的使用都通過封裝的中間層,在中間層相關接口被調用時再執行 sdk 的配置任務。通過這樣的方式我們可以把配置任務從啟動階段移除掉,實現使用時再按需執行。
  • 預加載任務:對于預加載任務,我們則對它們進行了規范化改造,以確保預加載任務在被降級情況下功能的正確性,同時對過期的預加載任務以及預加載任務中冗余的邏輯進行去除,以提升預加載任務的價值。
  • 功能任務:對于功能性的啟動任務,我們則是對它們進行了粒度拆解與瘦身,去除啟動階段非必須的邏輯,同時對功能任務添加了調度與降級能力支持,以供后續的調度與降級。

任務調度

關于任務調度業界有過比較多的介紹,這里對于任務的依賴分析、任務排布等不再進行介紹,主要介紹抖音在實踐過程中一些可能的創新點:

  • 基于落地頁進行調度:抖音啟動除了進入首頁,還有授權登錄、push 拉活等不同的落地頁,這些不同的落地頁在任務的執行上是有比較大差異的,我們可以在 Application 階段通過反射主線程消息隊列中的消息獲取待啟動的目標頁面,基于落地頁進行針對性的任務調度;
  • 基于設備性能調度:采集設備的各類性能數據在后臺對設備進行打分與歸一化處理,將歸一化之后的結果下發到端上,端上根據所在的性能等級進行任務的調度;
  • 基于功能活躍度調度:統計用戶對各個功能的使用情況,為用戶計算出每個功能的一個活躍度數據,并將他們下發到端上,端上根據功能活躍度高低來進行調度;
  • 基于端智能的調度:在端上通過端智能的方式預測用戶的后續行為,為后續功能進行預熱等;
  • 啟動功能降級:對于部分性能較差的設備與用戶,對啟動階段的任務、功能進行降級,將其延后到啟動之后再去執行,甚至完全不執行,以保證整體體驗。

1.4 Activity 階段優化

之前的幾個階段都屬于 Application 階段,接下來看一下 Activity 階段的相關優化,這個階段我們將介紹 Splash 與 Main 合并、反序列化優化兩個典型例子。

1.4.1 Splash 與 Main 合并

首先來看一下 SplashActivity 與 MainActivity 的合并,在之前的版本中抖音的 launcher activity 是 SplashActivity,它主要承載著廣告、活動等開屏相關邏輯。一般情況下我們的啟動流程為:

  1. 進入 SplashActivity,在 SplashActivity 中判斷當前是否有待展示的開屏;
  2. 如果有待展示的開屏則展示開屏,等待開屏展示結束再跳轉到 MainActivity,如果沒有開屏則直接跳轉到 MainActivity。

在這個流程下,我們的啟動需要經歷兩個 Activity 的啟動,如果把這兩個 Activity 進行合并,我們可以取得兩方面的收益

  1. 減少一次 Activity 的啟動過程;
  2. 利用讀取開屏信息的時間,做一些與 Activity 強關聯的并發任務,比如異步 View 預加載等。

要實現 Splash 與 Main 合并,我們需要解決的問題主要有 2 個:

  • 合并后如何解決外部通過 Activity 名稱跳轉的問題;
  • 如果解決 LaunchMode 與多實例的問題。

第 1 個問題比較容易解決,我們可以通過 activity-alias+targetActivity 將 SplashActivity 指向 MainActivity 解決。接下來我們來看一下第二個問題。

launchMode 問題

在 Splash 與 Main 合并之前,SplashActivity 與 MainActivity 的 LaunchMode 分別是 standard 和 sinngletask,這種情況下我們能夠確保 MainActivity 只有一個 實例,并且在我們從應用 home 出去再次進入時,能夠重新回到之前的頁面。

將 SplashActivity 與 MainActivity 合并以后,我們的 launcher Activity 變成了 MainActivity,如果繼續使用 singletask 這個 launchMode,當我們從二級頁面 home 出去再次點擊 icon 進入時,我們將無法回到二級頁面,而會回到 Main 頁面,因此合并后 MainActivity 的 launch mode 將不再能夠使用 singletask。經過調研,我們最終選擇了使用 singletop 作為我們的 launchMode。

多實例問題

1、內部啟動多實例的問題

使用 singletop 雖然能夠解決 home 出去再次進入無法回到之前頁面的問題,但是隨之而來的是 MainActivity 多實例的問題。在抖音的邏輯中存在一些與 MainActivity 生命周期強關聯的邏輯,如果 MainActivity 存在多個實例,這部分邏輯將會受到影響,同時多個 MainActivity 的實現,也會導致我們不必要的資源開銷,與預期是不符的,因此我們希望能夠解決這個問題。

針對這個問題我們的解決方案是,對于應用內所有啟動 MainActivity 的 Intent 增加 FLAG_ACTIVITY_NEW_TASK 與 FLAG_ACTIVITY_CLEAR_TOP 的 flag,以實現類似于 singletask 的 clear top 的特性。

使用 FLAG_ACTIVITY_NEW_TASK + FLAG_ACTIVITY_CLEAR_TOP 的方案,我們基本能夠解決內部啟動 MainActivity 多實例的問題,但是實際測試過程中,我們發現在部分系統上,即使實現了 clear top 的特性,依然存在多實例的問題。

經過分析,我們發現在這部分系統上,即使通過 activity-alias+targetActivity 方式將 SplashActivity 指向了 MainActivity,但是在 AMS 側它仍然認為啟動的是 SplashActivity,后續再啟動 MainActivity 時會認為之前是不存在 MainActivity 的,因此會再次啟動一個 MainActivity。

針對這個問題我們的解決方案是,修改啟動 MainActivity Intent 的 Component 信息,將其改從 MainActivity 改為 SplashActivity,這樣我們就徹底解決了內部啟動 MainActivity 導致的多實例的問題。

為了盡可能少的侵入業務,同時也防止后續迭代再出現內部啟動導致 MainActivity 問題,我們對 Context startActivity 的調用進行了插樁。對于啟動 MainActivity 的調用,在完成向 Intent 中添加 flag 和替換 Component 信息后再調用原有實現。之所以選擇插樁方式實現,是因為抖音的代碼結構比較復雜,存在多個基類 Activity,且部分基類 Activity 無法直接修改到代碼。對于沒有這方面問題的業務,可以通過重寫基類 Activtity 及 Application 的 startActivity 方法的方式實現。

2、外部啟動多實例問題

以上解決 MainActivity 多實例的方案,是建立在啟動 Activity 之前去修改待啟動 Activity 的 Intent 的方式實現的,這種方式對于應用外部啟動 MainActivity 導致的 MainActivity 多實例的問題顯然是無法解決的。那么針對外部啟動 MainActivity 導致的多實例問題,我們是否有其他解決方案呢?

我們先回到解決 MainActivity 多實例問題的出發點。之所以要避免 MainActivity 多實例,是為了防止同時出現多個 MainActivity 對象,出現不符合預期的 MainActivity 生命周期的執行。因此只要確保不會同時出現多個 MainActivity 對象,一樣可以解決 MainActivity 多實例問題。

避免同時出現多個 MainActivity 對象,我們首先需要知道當前是否已經存在 MainActivity 對象,解決這個問題的思路比較簡單,我們可以去監聽 Activity 的生命周期,在 MainActivity 的 onCreate 和 onDestroy 中分別去增加減少 MainActivity 的實例數。如果 MainActivity 實例數為 0 則認為當前不存在 MainActivity 對象。

解決了 MainActivity 對象數統計的問題,接下來我們就需要讓 MainActivity 同時存在的對象數永遠保持在 1 個以下。要解決這個問題我們需要回顧一下 Activity 的啟動流程,啟動一個 Activity 首先會經過 AMS,AMS 會再調用到 Activity 所在的進程,在 Activity 所在的進程會經過主線程的 Handler post 到主線程,然后通過 Instrumentation 去創建 Activity 對象,以及執行后續的生命周期。對于外部啟動 MainActivity ,我們能夠控制的是從 AMS 回到進程之后的部分,這里可以選擇以 Instrumentation 的 newActivity 作為入口。

具體來說我們的優化方案如下:

  1. 繼承 Instrumentation 實現一個自定義的 Instrumentaion 類,以代理轉發方式重寫里面的所有方法;
  2. 反射獲取 ActivityThread 中 Instrumentaion 對象,并以其為參數創建一個自定義的 Instrumentaion 對象,通過反射方式用自定義的 Instrumentaion 對象替換 ActivityThread 原有的 Instrumentaion;
  3. 在自定義 Instrumentaion 類的 newActivity 方法中,進行判斷當前待創建的 Activity 是否為 MainActivity,如果不是 MainActivity 或者當前不存在 MainActivity 對象,則調用原有實現,否則替換其 className 參數將其指向一個空的 Activity,以創建一個空的 Activity;
  4. 在這個空的 Activity 的 onCreate 中 finish 掉自己,同時通過一個添加了 FLAG_ACTIVITY_NEW_TASK 和 FLAG_ACTIVITY_CLEAR_TOP flag 的 Intent 去啟動一下 SplashActivity。

需要注意的是我們這里 hook Instrumentaion 的實現方案,在高版本的 Android 系統上我們也可以以 AppComponentFactory instantiateActivity 的方式替換。

1.4.2 反序列化優化

抖音 Activity 階段另一個典型的優化是反序列化的優化——在抖音使用過程中會在本地序列化一部分數據,在啟動過程中需要對這部分數據進行反序列化,這個過程會對抖音的啟動速度造成影響。在之前的優化過程中,我們從業務層面對 block 邏輯進行了異步化、快照化等 case by case 的優化,取得了不錯的效果,但是這樣的方式維護起來比較麻煩,迭代過程也經常出現劣化,因此我們嘗試以正面優化反序列化的方式進行優化。

抖音啟動階段的反序列化問題具體來說就是 Gson 數據解析耗時問題,Gson 是 Google 推出的一個 json 解析庫,其具有接入成本低、使用便捷、功能擴展性良好等優點,但是其也有一個比較明顯的弱點,那就是對于它在進行某個 Model 的首次解析時會比較耗時,并且隨著 Model 復雜程度的增加,其耗時會不斷膨脹。

Gson 的首次解析耗時與它的實現方案有關,在 Gson 的數據解析過程中有一個非常重要的角色,那就是 TypeAdapter,對于每一個待解析的對象的 Class,Gson 會首先為其生成一個 TypeAdapter,然后利用這個 TypeAdapter 進行解析,Gson 默認的解析方案采用的是 ReflectiveTypeAdapterFactory 創建的 TypeAdapter 的,其創建與解析過程中涉及到大量的反射調用,具體流程為:

  1. 首先通過反射獲取待解析對象的所有 Field,并逐個讀取去讀取它們的注解,生成一個從 serializeName 到 Filed 映射 map;
  2. 解析過程中,通過讀取到的 serializeName,到生成的 map 中找到對應的 Filed 信息,然后根據 Filed 的數據類型采用特定類型的方式進行解析,然后通過反射方式進行賦值。

因此對于 Gson 解析耗時優化的核心就是減少反射,這里具體介紹一下抖音中使用到的一些優化方案。

自定義 TypeAdapter 優化

通過對 Gson 的源碼分析,我們知道 Gson 的解析采用的是責任鏈的形式,如果在 ReflectiveTypeAdapterFactory 之前已經有 TypeAdapterFactory 能夠處理某個 Class,那么它是不會執行到 ReflectiveTypeAdapterFactory 的,而 Gson 框架又是支持注入自定義的 TypeAdapterFactory 的,因此我們的一種優化方案就是注入一個自定義的 TypeAdapterFactory 去優化這個解析過程。

這個自定義 TypeAdapterFactory 會在編譯期為每個待優化的 Class 生成一個自定義的 TypeAdapter,在這個 TypeAdapter 中會為 Class 的每個字段生成相關的解析代碼,以達到避免反射的目的。

生成自定義 TypeAdapter 過程中的字節碼處理,我們采用了抖音團隊開源的字節碼處理框架 Bytex(https://github.com/bytedance/ByteX/blob/master/README_zh.md),具體的實現過程如下:

  1. 配置待優化 Class:在開發階段,通過注解、配置文件的方式對我們需要優化的 Class 進行加白;
  2. 收集待優化 Class 信息:開始編譯后,我們從配置文件中讀取通過配置文件配置 Class;在遍歷工程中所有的 class 的 traverse 階段,我們通過 ASM 提供的 ClassVisitor 去讀取通過注解配置的 Class。對于所有需要優化的 Class,我們利用 ClassVisitor 的 visitField 方法收集當前 Class 的所有 Filed 信息;
  3. 生成自定義 TypeAdapter 和 TypeAdapterFactory:在 trasform 階段,我們利用收集到的 Class 和 Field 信息生成自定義的 TypeAdapter 類,同時生成創建這些 TypeAdapter 的自定義 TypeAdapterFactory;
public class GsonOptTypeAdapterFactory extends BaseAdapterFactory {

    protected BaseAdapter createTypeAdapter(String var1) {
        switch(var1.hashCode()) {
        case -1939156288:
            if (var1.equals("xxx/xxx/gsonopt/model/Model1")) {
                return new TypeAdapterForModel1(this.gson);
            }
            break;
        case -1914731121:
            if (var1.equals("xxx/xxx/gsonopt/model/Model2")) {
                return new TypeAdapterForModel2(this.gson);
            }
            break;
        return null;
    }
}

public abstract class TypeAdapterForModel1 extends BaseTypeAdapter {

    protected void setFieldValue(String var1, Object var2, JsonReader var3) {
    Object var4;
    switch(var1.hashCode()) {
    case 110371416:
        if (var1.equals("field1")) {
            var4 = this.gson.getAdapter(String.class).read(var3);
            ((Model1)var2).field1 = (String)var4;
            return true;
        }
        break;
    case 1223751172:
        if (var1.equals("filed2")) {
            var4 = this.gson.getAdapter(String.class).read(var3);
            ((Model1)var2).field2 = (String)var4;
            return true;
        }
    }
    return false;
}
}

優化 ReflectiveTypeAdapterFactory 實現

上面這種自定義 TypeAdapter 的方式可以對 Gson 的首次解析耗時優化 70%左右,但是這個方案需要在編譯期增加解析代碼,會增加包體積,具有一定的局限性,為此我們也嘗試了對 Gson 框架的實現進行了優化,為了降低接入成本我們通過修改字節碼的方式去修改 ReflectiveTypeAdapterFactory 的實現。

原始的 ReflectiveTypeAdapterFactory 在進行實際數據解析之前,會首先去反射 Class 的所有字段信息,再進行解析,而在實際解析過程中并不是所有的字段都是會使用到的,以下面的 Person 類為例,在進行 Person 解析之前,會對 Person、Hometown、Job 這三個類都進行解析,但是實際輸入可能只是簡單的 name,這種情況下對于 Hometown、Job 的解析就是完全沒有必要的,如果 Hometown、Job 類的實現比較復雜,這將導致較多不必要的時間開銷。

class Person {
    @SerializedName(value = "name",alternate = {"nickname"})
    private String name;
    private Hometown hometown;
    private Job job;
}

class Hometown {
    private String name;
    private int code;
}

class Job {
    private String company;
    private int type;
}
//實際輸入
{
    "name":"張三"
}

針對這類情況我們的解決方案就是“按需解析”,以上面的 Person 為例我們在解析 Person 的 Class 結構時,對于基本數據類型的 name 字段會進行正常的解析,對于復雜類型的 hometown 和 job 字段,會去記錄它們的 Class 類型,并且返回一個封裝的 TypeAdapter;在實際進行數據解析時,如果確實包含 hometown 和 job 節點,我們再去進行 Hometown 與 Job 的 Class 結構解析。這種優化方案對于 Class 結構復雜但是實際數據節點缺失較多情況下效果尤為明顯,在抖音的實踐過程中某些場景優化幅度接近 80%。

其他優化方案

上面介紹了兩種比較典型的優化方案,在抖音的實際優化過程中還嘗試了其他的優化方案,在特定的場景也取得了不錯的優化效果,大家可以參考:

  • 統一 Gson 對象:Gson 會對解析過的 Class 進行 TypeAdapter 的緩存,但是這個緩存是 Gson 對象級別的,不同 Gson 對象之間不會進行復用,通過統一 Gson 對象可以實現 TypeAdapter 的復用;
  • 預創建 TypeAdapter:對于有足夠的并發空間場景,我們在異步線程提前創建相關 Class 的 TypeAdapter,后續則可以直接使用預創建的 TypeAdapter 進行數據解析;
  • 使用其他協議:對于本地數據的序列化與反序列化我們嘗試使用了二進制順序化的存儲方式,將反序化耗時減少了 95%。在具體實現上我們采用的是 Android 原生提供的 Parcel 方案,對于跨版本數據不兼容的情況,我們通過版本控制的方式回滾為版本兼容的 Gson 解析方式。

1.5 UI 渲染優化

介紹完 Activity 階段的優化我們再來看一下 UI 渲染階段的相關優化,這個階段我們將介紹 View 加載的相關優化。

一般來說創建 View 有兩種方式,第一種方式就是直接通過代碼構建 View,第二種方式就是 LayoutInflate 去加載 xml 文件,這里將重點介紹LayoutInflate 加載 xml 的優化。LayoutInflate 進行 xml 加載包括三個步驟:

  1. 將 xml 文件解析到內存中 XmlResourceParser 的 IO 過程;
  2. 根據 XmlResourceParser 的 Tag name 獲取 Class 的 Java 反射過程;
  3. 創建 View 實例,最終生成 View 樹。

這 3 個步驟整體上是比較耗時的。在業務層面上,我們可以通過優化 xml 層級、使用 ViewStub 方式進行按需加載等方式進行優化,這些優化可以在一定程度上優化 xml 的加載時長。

這里我們介紹另一種比較通用優化方案——異步預加載方案,以下圖中 fragment 的 rootview 為例,它是在 UI 渲染的 measure 階段被 inflate 出來的,而從應用啟動到 measure 是有一定的時間 gap 的,我們完全可以利用這段時間在后臺線程提前將這些 view 加載到內存,在 measure 階段再直接從內存中進行讀取。

x2c 解決鎖的問題

在 androidx 中已經有提供了 AsyncLayoutInflater 用于進行 xml 的異步加載,但是實際使用下來會發現直接使用 AsyncLayoutInflater 很容易出現鎖的問題,甚至導致了更多的耗時。

通過分析我們發現,這是因為在 LayoutInflate 中存在著對象鎖,并且即使通過構建不同的 LayoutInflate 對象繞過這個對象鎖,在 AssetManager 層、Native 層仍然會有其他鎖。我們的解決方案就是 xml2code,在編譯期為添加了注解的 xml 文件生成創建 View 的代碼,然后異步進行 View 的預創建,通過 x2c 方案在解決了多線程鎖的問題的同時,也提升了 View 的預創建效率。目前該方案正在打磨中,后續在打磨完畢后將會進行詳細介紹。

LayoutParams 的問題

異步 Inflate 除了多線程鎖的問題,另一個問題就是 LayoutParams 問題。

LayoutInflater 對 View LayoutParam 處理主要依賴于 root 參數,對于 root 不為 null 的情況,在 inflate 的時候將會為 View 構造一個 root 關聯類型的 LayoutParams,并且為其設置 LayoutParams,但是我們在進行異步 Inflate 的時候是拿不到根布局的,如果傳入的 root 為 null,那么被 Inflate 的 View 的 LayoutParams 將會為 null,在這個 View 被添加到父布局時會采用默認值,這會導致被 Inflate view 的屬性丟失,解決這個問題的辦法就是在進行預加載時候 new 一個相應類型的 root,以實現對待 inflate view 屬性的正確解析。

public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
    // 省略其他邏輯
    if (root != null) {
        // Create layout params that match root, if supplied
        params = root.generateLayoutParams(attrs);
        if (!attachToRoot) {
            // Set the layout params for temp if we are not
            // attaching. (If we are, we use addView, below)
            root.setLayoutParams(params);
        }
    }
}

public void addView(View child, int index) {
    LayoutParams params = child.getLayoutParams();
    if (params == null) {
        params = generateDefaultLayoutParams();
        if (params == null) {
            throw new IllegalArgumentException("generateDefaultLayoutParams() cannot return null");
        }
    }
    addView(child, index, params);
}

其他問題

除了上面提到的多線程鎖的問題和 LayoutParams 的問題,在進行預加載過程中還遇到了一些其他的問題,這些問題具體如下:

  1. inflate 線程優先級的問題:一般情況下后臺線程的優先級會比較低,在進行異步 inflate 時可能會因為 inflate 線程優先級過低導致來不及預加載甚至比不進行預加載更耗時的情況,在這種情況下建議適當提升異步 inflate 線程的優先級。
  2. 對 Handler 問題:存在一些自定義 View 在創建的時候會去創建 handler,這種情況下我們需要去修改創建 Handler 的代碼,為其指定主線程的 Looper。
  3. 對線程有要求:典型的就是自定義 View 里使用了動畫,動畫在 start 時會校驗是否是 UI 線程主線程,這種情況我們需要去修改業務代碼,將相關邏輯移動到后續真正添加到 View tree 時。
  4. 需要使用 Activity context 的場景:一種解決辦法就是在 Activity 啟動之后再進行異步預加載,這種方式無需專門處理 View 的 context 問題,但是預加載的并發空間可能會被壓縮;另一種方式就是在 Application 階段利用 Applicaiton 的 context 進行預加載,但是在 add 到 view tree 之前將預加載 View 的 context 替換為 Activity 的 context,以滿足 Dialog 顯示、LiveData 使用等場景對 Activity context 的需求。

1.6 主線程耗時消息優化

以上我們基本介紹了主線程各大生命周期的相關優化,在抖音的實際優化過程中我們發現一些被 post 在這些生命周期之間的主線程耗時消息也會對啟動速度造成影響。比如 Application 和 Activity 之間、Activity 和 UI 渲染之間。這些主線程消息會導致我們后續的生命周期被延后執行,影響啟動速度,我們需要對它們進行優化。

1.6.1 主線程消息調度

對于自己工程中的代碼,我們可以比較方便的優化;但是有些是第三方 SDK 內部的邏輯,我們比較難以進行優化;即使是方便優化掉的消息后期的防止劣化成本也非常高。我們嘗試從另外一個角度解決這個問題,在優化部分往主線程 post 消息的同時,對主線程消息隊列進行調整,讓啟動相關的消息優先執行。

我們的核心原理是根據 App 啟動流程確定核心啟動路徑,利用消息隊列調整來保證冷啟動場景涉及相關消息優先調度,進而提高啟動速度,具體來說包括如下:

  1. 創建自定義的 Printer 通過 Looper 的 setMessageLogging 接口替換原有的 Printer,并對原始的 Printer 進行轉發;
  2. 在 Application 的 onCreate、MainActivity 的 onResume 中更新下一個待調度的消息,Application 的 onCreate 之后預期的目標消息是 Launch Activity,MainActivity 的 onResume 之后的預期消息則是渲染相關的 doFrame 消息。為了縮小影響范圍,在啟動完成或者執行了非正常路徑后則會對 disable 掉消息調度;
  3. 消息調度的具體執行則是在自定義 Printer 的 println 方法中進行的,在 println 方法中遍歷主線程消息隊列,根據 message.what 和 message.getTarget()判斷在消息隊列中是否存在目標消息,如果存在則將其移動到頭部優先執行;
1.6.2 主線程耗時消息優化

通過主線程消息調度,我們可以在一定程度上解決主線程消息對啟動速度的影響,但是其也存在一定的局限性:

  1. 只能調整已經在消息隊列中的消息,比如在 MainActivity onResme 之后存在一個耗時的主線程消息,而此時 doFrame 的消息還沒有進入主線程的消息隊列,那我們則需要執行完我們的耗時消息才能執行 doFrame 消息,其仍然會對啟動速度有所影響;
  2. 治標不治本,雖然我們將主線程耗時消息從啟動階段移走,但是在啟動后仍然會有卡頓存在。

基于這兩個原因我們需要對啟動階段主線程的耗時消息進行優化。

一般來說主線程耗時消息大部分是業務強相關的,可以直接通過 trace 工具輸出的主線程的堆棧發現問題邏輯并進行針對性的優化,這里主要介紹一個其他產品也可能會遇到的 case 的優化——WebView 初始化造成的主線程耗時。

在我們的優化過程中發現一個主線程較大的耗時,其調用堆棧第一層為 WebViewChromiumAwInit.startChromiumLocked,是系統 Webview 中的代碼,通過分析 WebView 代碼發現其是在 WebViewChromiumAwInit 的 ensureChromiumStartedLocked 中 post 到主線程的,在每個進程周期首次使用 Webview 都會執行一次,無論是在主線程還是子線程調用最終都會被 post 到主線程造成耗時,因此我們無法通過修改調用線程解決主線程卡頓的問題;同時由于是系統代碼我們也無法通過修改代碼實現的方式去進行解決,因此我們只能從業務層從使用的角度嘗試是否可以進行優化。

void ensureChromiumStartedLocked(boolean onMainThread) {
       //省略其他邏輯
        // We must post to the UI thread to cover the case that the user has invoked Chromium
        // startup by using the (thread-safe) CookieManager rather than creating a WebView.
        PostTask.postTask(UiThreadTaskTraits.DEFAULT, new Runnable() {
            @Override
            public void run() {
                synchronized (mLock) {
                    startChromiumLocked();
                }
            }
        });
        while (!mStarted) {
            try {
                // Important: wait() releases |mLock| the UI thread can take it :-)
                mLock.wait();
            } catch (InterruptedException e) {
            }
        }
    }

問題定位

從業務角度優化我們首先需要找到業務的使用點,雖然我們通過分析代碼定位到耗時消息是 Webview 相關的,但是我們仍然無法定位到最終的調用點。要定位最終的調用點,我們需要對WebView 相關調用流程有所了解。系統的 WebView 是一個獨立的 App,其他應用對于 Webview 的使用都需要經過一個叫 WebViewFactory 的 framework 類,在這個類中首先會通過 Webview 的包名獲取到 Webview 應用的 Context,然后通過獲取到的 Context 獲得 Webview 應用的 Classloader,最后通過 ClassLoader 去加載 Webview 的相關 so,反射加載 Webview 中的 WebViewFactoryProvider 的實現類并進行實例化,后續對于 WebiView 的相關調用都是通過 WebViewFactoryProvider 接口進行的。

通過后續分析發現對于 WebViewFactoryProvider 接口的 getStatics、 getGeolocationPermission、createWebView 等多個方法的首次調用都會觸發 WebViewChromiumAwInit 的 ensureChromiumStartedLocked 往主線程 post 一個耗時消息,因此我們的問題就變成了對于WebViewFactoryProvider 相關方法的調用定位。

一種定位辦法就是通過插樁的方式實現,由于 WebViewFactoryProvider 并不是應用能夠直接訪問到的類,因此我們對于 WebViewFactoryProvider 的調用必然是通過調用 framework 其他代碼實現的,這種情況下我們需要去分析 framework 中所有對于 WebViewFactoryProvider 的調用點,然后把應用中所有對于這些調用點的調用都進行插樁,進行日志輸出以進行定位。很顯然這種方式成本是比較高的,比較容易出現漏掉的情況。

事實上對于 WebViewFactoryProvider 的情況我們可以采用一個更便捷的方式。在前面的分析中我們知道 WebViewFactoryProvider 是一個接口,我們是通過反射的方式獲得其在 Webview 應用中實現的方式獲得的,因此我們完全可以通過動態代理方式生成一個 WebViewFactoryProvider 對象,去替換 WebViewFactory 中的 WebViewFactoryProvider,在生成的 WebViewFactoryProvider 類的 invoke 方法中通過方法名過濾,對于我們的白名單方法輸出其調用棧。通過這樣的方式我們最終定位到觸發主線程耗時邏輯的是我們的 WebView UA 的獲取。

解決方案

確認到我們的耗時是由獲取 WebView UA 引起的,我們可以采用本地緩存的方式解決:考慮到 WebView UA 記錄的是 Webview 的版本等信息,其在絕大部分情況下是不會發生變化的,因此我們完全可以把 Webview UA 緩存在本地,后續直接從本地進行讀取,并且在每次應用切到后臺時,去獲取一次 WebView UA 更新到本地緩存,以避免造成使用過程中的卡頓。

緩存的方案在 Webview 升級等造成 Webview UA 發生變化的情況下可能會出現更新不及時的情況,如果對 WebView 的實時性要求非常高,我們也可以通過調用子進程 ContentProvider 的方式在子進程去獲取 WebView UA,這樣雖然會影響到子進程的的主線程但是不會影響到我們的前臺進程。當然這種方式由于需要啟動一個子進程同時需要走完整的 Webview UA 讀取,相對本地緩存的方式在讀取速度方面是有明顯的劣勢的,對于一些對讀取速度有要求的場景是不太適合的,我們可以根據實際需要采用相應的方案。

2. 后臺任務優化

前面的案例基本都是主線程相關耗時的優化,事實上除了主線程直接的耗時,后臺任務的耗時也是會影響到我們的啟動速度的,因為它們會搶占我們前臺任務的 cpu、io 等資源,導致前臺任務的執行時間變長,因此我們在優化前臺耗時的同時也需要優化我們的后臺任務。一般來說后臺任務的優化與具體的業務有很強的關聯性,不過我們也可以整理出來一些共性的優化原則

  1. 減少后臺線程不必要的任務的執行,特別是一些重 CPU、IO 的任務;
  2. 對啟動階段線程數進行收斂,防止過多的并發任務搶占主線程資源,同時也可以避免頻繁的線程間調度降低并發效率。

除了這些通用的原則,這里也介紹兩個抖音中比較典型的后臺任務優化的案例。

2.1 進程啟動優化

我們優化過程中除了需要關注當前進程后臺線程的運行情況,也需要關注后臺進程的運行情況。目前絕大部分應用都會有 push 功能,為了減少后臺耗電、避免因為占用過多內存導致進程被殺,一般情況下會把 push 相關功能放在獨立的進程。如果在啟動階段去啟動 push 進程,其也會對我們的啟動速度造成比較大的影響,我們盡量對 push 進程的啟動去進行適當延遲,避免在啟動階段啟動。

在線下情況下我們可以通過對 logcat 中“Start proc”等關鍵字進行過濾,去發現是否存在啟動階段啟動子進程的情況,以及獲得觸發子進程啟動的組件信息。對于一些復雜的工程或者是三方 sdk,我們即使知道了啟動進程的組件,也比較難定位到具體的啟動邏輯,我們可以通過對 startService、bindService 等啟動Service、Recevier、ContentProvider組件調用進行插樁,輸入調用堆棧的方式,結合“Start proc”中組件的去精準定位我們的觸發點。除了在 manifest 中生命的進程可能還存在一些 fork 出 native 進程的情況,這種進程我們可以通過adb shell ps的方式去進行發現。

2.2 GC 抑制

后臺任務影響啟動速度中還有還有另一個比較典型的 case 就是 GC,觸發 GC 后可能會搶占我們的 cpu 資源甚至導致我們的線程被掛起,如果啟動過程中存在大量的 GC,那么我們的啟動速度將會受到比較大的影響。

解決這個問題的一個方法就是減少我們啟動階段代碼的執行,減少內存資源的申請與占用,這個方案需要我們去改造我們的代碼實現,是解決 gc 影響啟動速度的最根本辦法。同時我們也可以通過 GC 抑制的通用辦法去減少 GC 對啟動速度的影響,具體來說就是在啟動階段去抑制部分類型的 GC,以達到減少 GC 的目的。

近期公司的 Client Infrastructure-App Health 團隊調研出了 ART 虛擬機上的 GC 抑制方案,在公司的部分產品上嘗試對應用的啟動速度有不錯的優化效果,詳細的技術細節在后續打磨完成后將會在“字節跳動終端技術”公眾號分享出來。

3. 全局優化

前面介紹的案例基本都是針對某個階段一些比較耗時點的優化,實際上我們還存在一些單次耗時不那么明顯,但是頻率很高可能會影響到全局的點,比如我們業務中的高頻函數、比如我們的類加載、方法執行效率等,這里我們將對抖音在這些方面的優化嘗試做一些介紹。

3.1 類加載優化

3.1.1 ClassLoader 優化

首先我們來看一下抖音在類加載方面的一個優化案例。談到類加載我們就離不開類加載的雙親委派機制,我們簡單回顧一下這種機制下的類加載過程:

  1. 首先從已加載類中查找,如果能夠找到則直接返回,找不到則調用 parent classloader 的 loadClass 進行查找;
  2. 如果 parent clasloader 能找到相關類則直接返回,否則調用 findClass 去進行類加載;
protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
{
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            try {
                if (parent != null) {
                    c = parent.loadClass(name, false);
                } else {
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
            }

            if (c == null) {
                c = findClass(name);
            }
        }
        return c;
}

Android 中的 ClassLoader

雙親委派機制中很重要的一個點就是 ClassLoader 的父子關系,我們再來看一下 Android 中 ClassLoader 情況。一般情況下 Android 中有兩個 ClassLoader,分別是 BootClassLoader 和 PathClassLoader,BootClassLoaderart 負責加載 android sdk 的類,像我們的 Activity、TextView 等都由 BootClassLoader 加載。PathClassLoader 則負責加載 App 中的類,比如我們的自定義的 Activity、support 包中的 FragmentActivity 這些會被打進 app 中的類則由 PathClassLoader 進行加載。BootClassLoader 是 PathClassLoader 的 parent。

ART 虛擬機對類加載的優化

ART 虛擬機在類加載方面仍然遵循雙親委派的原則,不過在實現上做了一定的優化。一般情況下它的大致流程如下:

  1. 首先調用 PathClassLoader 的 findLoadedClass 方法去查找已加載的類中查找,這個方法將會通過 jni 調用到 ClassLinker 的 LookupClass 方法,如果能夠找到則直接返回;
  2. 在已加載類中找不到的情況下,不會立刻返回到 java 層,其會在 native 層去調用 ClassLinker 的 FindClassInBaseDexClasLoader 進行類查找;
  3. 在 FindClassInBaseDexClasLoader 中,首先會去判斷當前 ClassLoader 是否為 BootClassLoader,如果為 BootClasLoader 則嘗試從當前 ClassLoader 的已加載類中查找,如果能夠找到則直接返回,如果找不到則嘗試使用當前 ClassLodaer 進行加載,無論能否加載到都返回;
  4. 如果當前 ClassLoader 不是 BootClassLoader,則會判斷是否為 PathClasLoader,如果不是 PathClassLoader 則直接返回;
  5. 如果當前 ClassLoader 為 PathClassLoader,則會去判斷當前 PathClassLoader 是否存在 parent,如果存在 parent 則將 parent 傳入遞歸調用 FindClassInBaseDexClasLoader 方法,如果能夠找到則直接返回;如果找不到或者當前 PathClassLoader 沒有 parent 則直接在 native 層通過 DexFile 直接進行類加載。

可以看到當 PathClassLoader 到 BootClassLoader 的 ClassLoadeer 鏈路上只有 PathClassLoader 時,java 層的 findLoadedClass 方法調用后,并不止如其字面含義的去已加載的類中查找,其還會在 native 層直接通過 DexFile 去加載類,這種方式相對于回到 java 層調用 findClass 再調回 native 層通過 DexFile 加載可以減少一次不必要的 jni 調用,在運行效率上是更高的,這是 art 虛擬機對類加載效率的一個優化。

抖音中 ClassLoader 模型

在前面我們介紹了 Android 中的類加載相關機制,那么我們究竟在類加載方面做了哪些優化,要解答這個問題我們需要了解一下抖音中的ClassLoader 模型。在抖音中為了減少包體積,一些非核心功能我們通過插件化的方式進行了動態下發。在接入插件化框架后抖音中的 ClassLoader 模型如下:

  1. 除了原有的 BootClassLoader 和 PathClassLoader 另外引入了 DelegateClassLoader 和 PluginClasLoader;
  2. DelegateClassloader 全局 1 個,它是 PathClassLoader 的 parent,它的 parent 為 BootClassLoader;
  3. PluginClassLoader 每個插件一個,它的 parent 為 BootClassLoader;
  4. DelegateClassLoader 會持有 PluginClassLoader 的引用,PluginClassLoader 則會持有 PathClasloader 的引用;

這種 ClassLoader 模型有一個非常明顯的優點,那就是它能夠非常方便的同時支持類的隔離、復用以及插件化與組件化的切換;

  1. 類的隔離:如果在宿主和多個插件中存在同名類,在宿主中使用某個類則會首先從宿主 apk 加載,在插件中使用某個類,則會優先從當前插件的 apk 中加載,這種加載機制單 ClassLoader 模型的插件框架是無法支持的;
  2. 類的復用:在宿主中使用某個插件中特有的類時,我們可以在 DelegateClassLoader 中檢測到類加載失敗,進而使用 PluginClassLoader 去插件中加載,實現宿主復用插件中的類;在插件中使用某個宿主特有的類時,可以在 PluginClassLoader 中檢測到類加載失敗,進而使用 PathClassLoader 去進行加載,實現插件復用宿主中的類,這種復用機制其他多 ClassLoader 模型的插件框是無法支持的;
  3. 插件化與組件化自由切換:這種 ClassLoader 模型下,我們加載宿主/插件中的類時無需任何顯示的 ClassLoader 的指定,我們可以很方便的在直接依賴的組件化方式以及 compileonly+插件化的方式之間切換;

ART 類加載優化機制被破壞

上面介紹了抖音的 ClassLoader 模型的優點,但是其也有一個比較隱蔽的不足,那就是它會破壞 ART 虛擬機對類加載的優化機制。

通過前面的介紹我們了解,當 PathClassLoader 到 BootClassLoader 的 ClassLoader 鏈路上只有 PathClassLoader 時,則可以在 native 層進行類的加載,以減少一次 jni 的調用。在抖音的 ClassLoader 模型中,PathClassLoader 與 BootClassLoader 之間存在一個 DelegateClassLoader,它的存在會導致“PathClassloader 到 BootClassLoader 的 ClassLoader 鏈路上只有 PathClassLoader”這一條件被破壞,這導致我們 app 中所有類的首次加載都需要多一次 jni 的調用。一般情況下多一次 jni 的調用不會帶來多少消耗,但是對于啟動階段大量類加載的場景,這個影響也是比較大的,會對我們的啟動速度造成一定的影響。

非侵入式優化方案:延遲注入

了解插件化對類加載造成負向的原因,優化思路也就比較清晰了——將 DelegateClassLoader 從 PathCLasLoader 和 BootClassLoader 之間移除掉。

通過前面的分析,我們知道引入 DelegateClassLoader 是為了在使用 PathClassLoader loadClass 失敗時,可以使用 PluginClassloader 去插件中加載,因此對于不使用插件的場景,DelegateClassloader 是完全沒有必要的,我們完全可以在需要用到插件功能時再進行 DelegateClassloader 的注入。

但在實際執行過程中,這種完全進行按需注入會比較困難,因為我們無法精確掌握插件加載時機,比如我們的可能通過是通過 compileonly 的方式隱式的依賴、加載插件的類,也可能在 xml 中使用某個插件的 view 的方式觸發插件的加載,如果要進行適配會對業務開發帶來比較大的侵入。

這里嘗試換一個思路進行優化——我們雖然沒法精確地知道插件加載時機,但卻可以知道哪里沒有插件加載。比如 Application 階段是沒有插件加載的,那么完全可以等 Applicaiton 階段執行完成再進行 DelegateClassloader 的注入。事實上在啟動過程中,類的加載主要集中在 Application 階段,通過在 Applicaiton 執行完成再去進行 DelegateClassloader 進行注入,可以極大地減少插件化方案對啟動速度的影響,同時也可以避免對業務的侵入。

侵入式優化方案:改造 ClassLoader 模型

上面的方案無需侵入業務改造成本很小,但是它只是優化了 Application 階段的類加載,后續階段 ART 對類加載的優化仍然無法享受到,從極致性能的角度我們做了進一步的優化。我們優化的核心思想就是把 DelegateClassloader 從 PathClassLoader 和 BootClassLoader 之間徹底去除掉,通過其他方式來解決宿主加載插件類的問題。通過分析我們可以知道宿主加載插件的類主要有幾種方式:

  1. 通過 Class.forName 的方式去反射加載插件的類;
  2. 通過 compileOnly 隱式依賴插件的類,運行時直接加載插件的類;
  3. 啟動插件的四大組件時加載插件的組件類;
  4. 在 xml 中使用插件的類;

因此我們的問題就變成了在不注入 ClassLoader 的情況下,如何實現宿主加載插件的這四大類。

首先是Class.forName 的方式,解決這種方式下插件類加載的問題最直接的解決辦法就是調用 Class.forName 時顯示的去指定 ClassLoader 為 DelegateClassloader,不過這樣的方式對業務開發不夠友好,且存在一些三方 sdk 中代碼我們無法修改的問題。我們最終的解決辦法就是對 Class.forName 調用進行字節碼插樁,在類加載失敗時再嘗試使用 DelegateClassloader 去進行加載。

接下來是compileOnly 的隱式依賴,這種方式比較難進行通用處理,因為我們無法找到一個合適的時機去對類加載失敗進行兜底。針對這個問題我們的解決辦法就是進行業務的改造,將 compileOnly 的隱式依賴調用的方式改成通過 Class.forName 的方式,之所以進行這樣的改造主要是基于幾下幾點考慮:

  1. 首先抖音中 compileOnly 隱式依賴調用的方式非常少,改造成本相對可控;
  2. 其次 compileOnly 的方式在插件的使用上雖然便捷,但是它在入口上不夠收斂,在插件加載管控、問題排查、插件宿主版本間兼容上都存在一定的問題,通過 Class.forName + 接口化的方式可以較好的解決這些問題。

插件四大組件類的加載和 xml 中使用插件類的問題都可以通過同一個方案來解決——將 LoadedApk 中的 ClassLoader 替換為DelegateClassLoader,這樣無論是四大組件 class 的加載還是 LayoutInflate 加載 xml 時的 class 加載都會使用 DelegateClassLoader 加載,關于這部分的原理大家可以參考 DroidPlugin、Replugin 等相關插件化原理解析,這里就不展開介紹了。

3.1.2 Class verify 優化

對于 ClassLoader 的優化,優化的是類加載過程中的 load 階段,對于類加載的其他階段也可以進行一定的優化,比較典型的一個案例就是classverify的優化,classverify 過程主要是校驗 class 是否符合 java 規范,如果不符合規范則會在 verify 階段拋出 verify 相關的異常。

一般情況下 Android 中的 class 在應用安裝或插件加載時就會進行 verify,但是存在一些特定 case,比如 Android10 之后的插件、插件編譯采用 extract filter 類型、宿主與插件相互依賴導致靜態 verify 失敗等情況,則需要在運行時進行 verify。運行 verify 的過程除了會校驗 class,還會觸發它所依賴 class 的 load,從而造成耗時。

事實上 classverify 主要是針對網絡下發的字節碼進行校驗,對于我們的插件代碼其在編譯的過程中就會去校驗 class 的合法性,而且即使真的出現了非法的 class,最多也是將 verify 階段拋出的異常轉移到 class 使用的時候。

因此我們可以認為,運行時的 classverify 是沒有必要的,可以通過關閉 classverrify來優化這些類的加載。關于關閉 classverify 目前業界已經有一些比較優秀的方案,比如運行時在內存中定位出 verify_所在內存地址,然后將其設置成跳過 verify 模式以實現跳過 classverify。

 // If kNone, verification is disabled. kEnable by default.
  verifier::VerifyMode verify_;


  // If true, the runtime may use dex files directly with the interpreter if an oat file is not available/usable.
  bool allow_dex_file_fallback_;


  // List of supported cpu abis.
  std::vector<std::string> cpu_abilist_;


  // Specifies target SDK version to allow workarounds for certain API levels.
  int32_t target_sdk_version_;

當然關閉 classverify 的優化方案并不一定對所有的應用都有價值,在進行優化之前可以通過 oatdump 命令輸出一下宿主、插件中在運行時進行 classverify 的類信息,對于存在大量類在運行時 verify 的情況可以采用上面介紹的方案進行優化。

oatdump --oat-file=xxx.odex > dump.txt
cat dump.txt  | grep -i "verified at runtime" |wc -l

3.2 其他全局優化

在全局優化方面,還有一些其他比較通用的優化方案,這里也進行一些簡單的介紹,以供大家參考:

  • 高頻方法優化:對服務發現(spi)、實驗開關讀取等高頻調用方法進行優化,將原本在運行時的注解讀取、反射等操作前置到編譯階段,通過編譯階段直接生成目標代碼替換原有調用實現執行速度的提升;
  • IO 優化:通過減少啟動階段不必要的 IO、對關鍵鏈路上的 IO 進行預讀以及其他通用的 IO 優化方案提升 IO 效率;
  • binder 優化:對啟動階段一些會多次調用的 binder 進行結果緩存以減少 IPC 的次數,比如我們應用自身的 packageinfo 的獲取、網絡狀態獲取等;
  • 鎖優化:通過去除不必要的鎖、降低鎖粒度、減少持鎖時間以及其他通用的方案減少鎖問題對啟動的影響
  • 字節碼執行優化:通過方法調用內聯的方式,減少一些不必要的字節碼的執行,目前已經以插件的方式集成在抖音的字節碼開源框架 Bytex 中(詳見 Bytex 介紹);
  • 預加載優化:充分利用系統的并發能力,通過用戶畫像、端智能預測等方式在異步線程對各類資源進行精準精準預加載,以達到消除或者減少關鍵節點耗時的目的,可供預加載的內容包括 sp、resource、view、class 等;
  • 線程調度優化:通過任務的動態優先級調整以及在不同 CPU 核心上的負載均衡等手段,降低 Sleeping 狀態和 Uninterrupible Sleeping 耗時,在不提高 CPU 頻率的情況下,提高 CPU 時間片的利用率(由 Client Infrastructure-App Health 團隊提供解決方案);
  • 廠商合作:與廠商合作通過 CPU 綁核、提頻等方式獲取到更多的系統資源,以達到提升啟動速度的目的;

總結與展望

至此,我們已經對抖音啟動優化中比較典型、通用的案例進行了介紹,希望這些案列能夠為大家的啟動優化提供一些參考?;仡櫠兑粢酝乃袉酉嚓P的優化,通用的優化只是占了其中一小部分,更多的是與業務相關的優化,這部分優化有著極強的業務關聯性,其他業務無法直接進行遷移,針對這部分優化我們總結了一些優化的方法論,具體可以參見“[啟動性能優化之理論和工具篇] ”。最后從實踐的角度對我們的啟動優化做一些總結與展望, 希望能對大家有所幫助。

持續迭代

啟動優化是一個需要持續迭代與打磨的的過程,一般來說最開始的是“快、猛”的快速優化階段,這個階段優化空間會比較大,優化粒度會相對較粗,在投入不多的人力情況下就能取得不錯的收益;第二個階段難點攻堅階段,這個階段需要的投入相對第一個階段要大,最終的提升效果也取決于難點的攻堅情況;第三個階段是防劣化與持續的精細化優化過程,這個過程是最為持久的一個過程,對于快速迭代的產品,這個階段也非常重要,是我們通向極致化啟動性能的必經之路。

場景泛化

啟動優化也需要進行一定擴展與泛化的,一般情況下我們關注的是用戶點擊 icon 到首頁首幀的時間,但是隨著商業化開屏、push 點擊等場景的增加,我們也需要擴展到這些場景。另外很多時候雖然頁面的首幀出來了,但用戶還是無法看到想看的內容,因為用戶關注的可能不是頁面首幀的時間,而是有效內容加載出來的時間。以抖音為例,我們在關注啟動速度的同時,也會去關注視頻首幀的時間,從 AB 實驗來看這個指標甚至比啟動速度更重要,其他產品也可以結合自己的業務,去定義一些對應的指標,驗證對用戶體驗的影響,決定是否需要進行優化。

全局意識

一般來說,我們以啟動速度來衡量啟動性能。為了提升啟動速度,我們可能會把一些原本在啟動階段執行的任務進行延后或者按需,這種方式能夠有效優化啟動速度,但同時也可能損害后續的使用體驗。比如,如果將某個啟動階段的后臺任務延后到后續使用時,如果首次使用是在主線程,則可能會造成使用卡頓。因此,我們在關注啟動性能的同時,也需要關注其他可能影響的指標。

性能上我們需要有一個能體現全局性能的宏觀指標,以防止局部最優效應。業務上我們需要建立啟動性能與業務的關系,具體來說就是在優化過程中盡可能對一些較大的啟動優化支持 AB 能力,這樣做一方面可以實現對優化的定性分析,防止一些有局部性能收益但是對全局體驗有損害的負優化被帶到線上去;另一方面也可以利用實驗的定性分析能力,量化各個優化對業務的效果,從而為后續的優化方向提供指導。同時也可以對一些可能造成穩定性或者功能異常的改動,提供回滾能力以及時止損。

目前,字節跳動旗下的企業級技術服務平臺火山引擎已經對外開放了 AB 實驗能力,感興趣的同學可以到火山引擎官網進行了解。

全覆蓋與精細化運營

未來抖音的啟動優化有兩個大的目標,第一個目標是啟動優化的覆蓋率做到最大化:架構方面我們希望啟動階段的代碼能夠做到依賴簡單、清晰,模塊粒度盡可能的小,后續優化與迭代成本低;體驗方面在做好性能優化的同時做好交互、內容質量等功能優化,提升功能的觸達效率與品質;場景方面做到冷啟動、溫啟動、熱啟動等各類啟動方式、落地頁的全面覆蓋;優化方向上覆蓋 CPU、IO、內存、鎖、UI 渲染等各類優化方向。第二個目標是實現啟動優化精細化運營,做到千人千時千面,對于不同的用戶、不同的設備性能與狀況、不同的啟動場景等采用不同的啟動策略,實現體驗優化的最大化。


https://mp.weixin.qq.com/s/-3uY3aSF67xTzWEJsa7e-A

最多閱讀

簡化Android的UI開發 2年以前  |  515113次閱讀
Android 深色模式適配原理分析 1年以前  |  26416次閱讀
Android 樣式系統 | 主題背景覆蓋 1年以前  |  7953次閱讀
Android Studio 生成so文件 及調用 1年以前  |  5587次閱讀
30分鐘搭建一個android的私有Maven倉庫 3年以前  |  4751次閱讀
Android設計與開發工作流 2年以前  |  4413次閱讀
Google Enjarify:可代替dex2jar的dex反編譯 3年以前  |  4397次閱讀
Android多渠道打包工具:apptools 3年以前  |  4028次閱讀
移動端常見崩潰指標 2年以前  |  4014次閱讀
Google Java編程風格規范(中文版) 3年以前  |  3942次閱讀
Android-模塊化-面向接口編程 1年以前  |  3857次閱讀
Android內存異常機制(用戶空間)_NE 1年以前  |  3824次閱讀
Android UI基本技術點 3年以前  |  3790次閱讀
Android死鎖初探 2年以前  |  3734次閱讀

手機掃碼閱讀
18禁止午夜福利体验区,人与动人物xxxx毛片人与狍,色男人窝网站聚色窝
<蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <文本链> <文本链> <文本链> <文本链> <文本链> <文本链>