本文主要是學習了極客時間張紹文老師的 Android開發高手課 以及 谷歌官網文章 的啟動優化筆記~
參考文章:
https://time.geekbang.org/column/article/73651 https://mp.weixin.qq.com/s/eaArt5Udc4WZ3NoH5RlEkQ https://juejin.im/post/5874bff0128fe1006b443fa0 https://developer.android.google.cn/topic/performance/vitals/launch-time
應用啟動型別
- 冷啟動
- 場景:開機後第一次啟動應用 或者 應用被殺死後再次啟動
- 生命週期:Process.start->Application建立->attachBaseContext->onCreate->onStart->onResume->Activity生命週期
- 啟動速度:在幾種啟動型別中最慢,也是我們優化啟動速度最大的攔路虎
- 溫啟動
- 場景:應用已經啟動,返回鍵退出
- 生命週期:onCreate->onStart->onResume->Activity生命週期
- 啟動速度:較快
- 熱啟動
- 場景:Home鍵最小化應用
- 生命週期:onResume->Activity生命週期
- 啟動速度:快
從上面的總結可以看出,在應用的啟動過程中,冷啟動是最慢最耗時的,系統以及應用本身都有大量的工作需要處理,所以,冷啟動對於應用的啟動速度是最具挑戰以及最有必要進行優化的。
冷啟動流程
冷啟動指的是應用程式從程式在系統不存在,到系統建立應用執行程式空間的過程。冷啟動通常會發生在一下兩種情況:
- 裝置啟動以來首次啟動應用程式
- 系統殺死應用程式之後再次啟動應用程式
在冷啟動的最開始,系統需要負責做三件事:
- 載入以及啟動app
- app啟動之後立刻顯示一個空白的預覽視窗
- 建立app程式
一旦系統完成建立app程式後,app程式將要接著負責完成下面的工作:
- 建立Application物件
- 建立並且啟動主執行緒ActivityThread
- 建立啟動第一個Activity
- Inflating views
- 佈局螢幕
- 執行第一次繪製
一旦app程式完完成了第一次繪製工作,系統程式就會用main activity替換前面顯示的預覽視窗,這個時候,使用者就可以正式開始與app進行互動了。
從冷啟動的流程看,我們無法干預app程式建立等系統操作,我們能夠干預的有:
- 預覽視窗
- Application生命週期回撥
- Activity生命週期回撥
優化分析測量工具
對研發人員來說,啟動速度是我們的“門面”,它清清楚楚可以被所有人看到,我們都希望自己應用的啟動速度可以秒殺所有競爭對手。
“工欲善其事必先利其器”,我們需要先找到一款適合做啟動優化分析的工具或者方式。
- adb shell am start -W [packageName]/[ packageName. AppstartActivity]
在統計 app 啟動時間時,系統為我們提供了 adb 命令,可以輸出啟動時間。系統在繪製完成後,ActivityManagerService 會回撥該方法,但是能夠方便我們通過指令碼多次啟動測量 TotalTime,對比版本間啟動時間差異。但是統計時間不如 Systrace 準確。
- 程式碼埋點
通過程式碼埋點來準確獲取記錄每個方法的執行時間,知道哪些地方耗時,然後再有針對性地優化。例如通過在 app 啟動生命週期中,關鍵位置加入時間點記錄,達到測量目的;又例如可以在 Application 的 attachBaseContext
方法中記錄開始時間,然後在啟動的第一個 Activity 的 onWindowFocusChanged
方法記錄結束時間。
但是從使用者點選 app Icon 到 Application 被建立,再到 Activity 的渲染,中間還是有很多步驟的,比如冷啟動的程式建立過程,而這個時間用此版本是沒辦法統計了,必須得承受這點資料的不準確性。
Nanoscope
Nanoscope 非常真實,不過暫時只支援 Nexus 6 和 x86 模擬器。
Simpleperf
Simpleperf 的火焰圖並不適合做啟動流程分析。
- TraceView
通過 TraceView 主要可以得到兩種資料:單次執行耗時的方法 以及 執行次數多的方法。但是TraceView 效能耗損太大,不能比較正確反映真實情況。
- Systrace
Systrace 能夠追蹤關鍵系統呼叫的耗時情況,如系統的 IO 操作、核心工作佇列、CPU 負載、Surface 渲染、GC 事件以及 Android 各個子系統的執行狀況等。但是不支援應用程式程式碼的耗時分析。
綜上所述,這幾種方式都各有各的優點以及缺點,我們都要掌握。
但是有沒有一種比較折中比較理想的方案呢?有的。
- “Systrace + 函式插樁”
除了能夠看到例如 GC、System Server、CPU 排程等系統呼叫的耗時,還能夠通過 Android 工程編譯的過程中,在指定的方法前後,自動化插入插樁函式,統計方法執行時間。通過插樁,我們可以看到應用主執行緒和其他執行緒的函式呼叫流程。它的實現原理非常簡單,就是將下面的兩個函式 通過用ASM框架修改位元組碼的方式 分別插入到每個方法的入口和出口。
class TraceMethod {
public static void i() {
Trace.beginSection();
}
public static void o() {
Trace.endSection();
}
}複製程式碼
當然這裡面有非常多的細節需要考慮,比如怎麼樣降低插樁對效能的影響、哪些函式需要被排除掉。函式插樁後的效果如下:
class Test {
public void test() {
TraceMethod.i();
// 原來的工作
TraceMethod.o();
}
}複製程式碼
只有準確的資料評估才能指引優化的方向,這一步是非常重要的。沒有充分評估或者評估使用了錯誤的方法,最終得到了錯誤的方向,會導致最後發現根本達不到預期的優化效果。
啟動優化方法
在拿到整個啟動流程的全景圖之後,我們可以清楚地看到這段時間內系統、應用各個程式和執行緒的執行情況,現在我們要開始真正開始“幹活”了。
具體的優化方式,我把它們分為預覽視窗優化、業務梳理、業務優化、多程式優化、執行緒優化、GC 優化和系統呼叫優化。業務梳理、業務優化、執行緒優化、GC 優化、系統呼叫優化和佈局優化。
預覽視窗優化
當使用者點選應用桌面圖示啟動應用的時候,利用提前展示出來的 Window,快速展示出一個介面,使用者只需要很短的時間就可以看到“預覽頁”,這種完全“跟手”的感覺在高階機上體驗非常好,但對於中低端機,會把總的的閃屏時間變得更長。
如果點選圖示沒有響應,使用者主觀上會認為是手機系統響應比較慢。所以比較推薦的做法是,只在 Android 6.0 或者 Android 7.0 以上才啟用“預覽視窗”方案,讓手機效能好的使用者可以有更好的體驗。
要實現預覽視窗的顯示,只需要在利用 activity 的windowBackground
主題屬性提供一個簡單的自定義 drawable 給啟動的 activity,如下:
Layout XML file:
<layer-list xmlns:android="http://schemas.android.com/apk/res/android" android:opacity="opaque">
<!-- The background color, preferably the same as your normal theme -->
<item android:drawable="@android:color/white"/>
<!-- Your product logo - 144dp color version of your app icon -->
<item>
<bitmap
android:src="@drawable/product_logo_144dp"
android:gravity="center"/>
</item>
</layer-list>複製程式碼
Manifest file:
<activity ...
android:theme="@style/AppTheme.Launcher" />複製程式碼
這樣一個 activity 啟動的時候,就會先顯示一個預覽視窗,給使用者快速響應的體驗。當 activity想要恢復原來 theme,可以通過在呼叫super.onCreate()
和setContentView()
之前呼叫 setTheme(R.style.AppTheme)
,如下:
public class MyMainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
// Make sure this is before calling super.onCreate
setTheme(R.style.Theme_MyApp);
super.onCreate(savedInstanceState);
// ...
}
}複製程式碼
業務梳理
不要一股腦把全部初始化工作放在 Application 中做,需要梳理清楚當前啟動過程正在執行的每一個模組,哪些是一定需要的、哪些可以砍掉、哪些可以懶載入。但是需要注意的是,懶載入要防止集中化,否則容易出現首頁顯示後使用者無法操作的情形。總的來說,用以下四個維度分整理啟動的各個點:
- 必要且耗時:啟動初始化,考慮用執行緒來初始化。
- 必要不耗時:首頁繪製。
- 非必要但耗時:資料上報、外掛初始化。
- 非必要不耗時:不用想,這塊直接去掉,在需要用的時再載入。
把資料整理出來後,按需實現載入邏輯,採取分步載入、非同步載入、延期載入策略,如下圖所示:
一句話概述,要提高應用的啟動速度,核心思想是在啟動過程中少做事情,越少越好。
業務優化
通過梳理之後,剩下的都是啟動過程一定要用的模組。這個時候,我們只能硬著頭皮去做進一步的優化。優化前期需要“抓大放小”,先看看主執行緒究竟慢在哪裡。最理想是通過演算法進行優化,例如一個資料解密操作需要 1 秒,通過演算法優化之後變成 10 毫秒。退而求其次,我們要考慮這些任務是不是可以通過非同步執行緒預載入實現,但需要注意的是過多的執行緒預載入會讓我們的邏輯變得更加複雜。
業務優化做到後面,會發現一些架構和歷史包袱會拖累我們前進的步伐。比較常見的是一些事件會被各個業務模組監聽,大量的回撥導致很多工作集中執行,部分框架初始化“太厚”,例如一些外掛化框架,啟動過程各種反射、各種 Hook,整個耗時至少幾百毫秒。還有一些歷史包袱非常沉重,而且“牽一髮動全身”,改動風險比較大。但是我想說,如果有合適的時機,我們依然需要勇敢去償還這些“歷史債務”。
多程式優化
Android app 是支援多程式的,在 Manifest 中只要在元件宣告中加入android:process
屬性就可以讓元件在啟動時執行在不同的程式中。舉個例子: 對於多程式 app ,可能擁有主程式,外掛程式以及下載程式,但開發者只能在 Manifest 中宣告一個 Application 元件,如果對應不同程式的元件啟動時,系統會建立三個程式,建立三個 Application 物件,同時attachBaseContext
、onCreate
等生命週期回撥方法也會被呼叫三次。
但是每個程式需要初始化的內容肯定是不一樣的,所以,為了防止資源的浪費,我們需要在Application 中區分程式,對應程式只初始化對應的內容。
執行緒優化
執行緒優化分兩方面:
第一,耗時任務非同步化。子執行緒處理耗時任務,主執行緒做的事情越少,越早進入Acitivity繪製階段,介面越早展現。例如不在主執行緒做如 IO 、網路等耗時操作。但是要注意,子執行緒不能阻塞主執行緒。
第二,執行緒池管理執行緒,控制執行緒的數量。執行緒數量太多會相互競爭 CPU 資源,導致分給主執行緒的時間片減少,從而導致啟動速度變慢。執行緒切換的資料我們可以通過卡頓優化中學到的 sched 檔案檢視,這裡特別需要注意 nr_involuntary_switches 被動切換的次數。
proc/[pid]/sched:
nr_voluntary_switches:主動上下文切換次數,因為執行緒無法獲取所需資源導致上下文切換,最普遍的是 IO。
nr_involuntary_switches:被動上下文切換次數,執行緒被系統強制排程導致上下文切換,例如大量執行緒在搶佔 CPU。 複製程式碼
第三,避免主執行緒與子執行緒之間的鎖阻塞等待。有一次我們把主執行緒內的一個耗時任務放到執行緒中併發執行,但是發現這樣做根本沒起作用。仔細檢查後發現執行緒內部會持有一個鎖,主執行緒很快就有其他任務因為這個鎖而等待。通過 Systrace 可以看到鎖等待的事件,我們需要排查這些等待是否可以優化,特別是防止主執行緒出現長時間的空轉。
特別是現在有很多啟動框架,會使用 Pipeline 機制,根據業務優先順序規定業務初始化時機。比如微信內部使用的 mmkernel 、阿里最近開源的 Alpha 啟動框架,它們為各個任務建立依賴關係,最終構成一個有向無環圖。對於可以併發的任務,會通過執行緒池最大程度提升啟動速度。如果任務的依賴關係沒有配置好,很容易出現下圖這種情況,即主執行緒會一直等待 taskC 結束,空轉 2950 毫秒。
第四,設定子執行緒優先順序。不重要任務,設定子執行緒優先順序為 THREAD_PRIORITY_BACKGROUND,這樣子執行緒最多能獲取到10%的時間片,優先保證主執行緒執行。
GC優化
在啟動過程,要儘量減少 GC 的次數,避免造成主執行緒長時間的卡頓,特別是對 Dalvik 來說,我們可以通過 Systrace 單獨檢視整個啟動過程 GC 的時間。
啟動過程避免進行大量的字串操作,特別是序列化跟反序列化過程。一些頻繁建立的物件,例如網路庫和圖片庫中的 Byte 陣列、Buffer 可以複用。如果一些模組實在需要頻繁建立物件,可以考慮移到 Native 實現。
Java 物件的逃逸也很容易引起 GC 問題,我們在寫程式碼的時候比較容易忽略這個點。我們應該保證物件生命週期儘量的短,在棧上就進行銷燬。
系統呼叫優化
部分系統的API使用是阻塞性的,檔案很小可能無法感知,當檔案過大,或者使用頻繁時,可能造成阻塞。例如:SharedPreference.Editor 的提交操作建議使用非同步的 apply,而不是阻塞的 commit。
通過 systrace 的 System Service 型別,我們可以看到啟動過程 System Server 的CPU 工作情況。在啟動過程,我們儘量不要做系統呼叫,例如 PackageManagerService 操作、Binder 呼叫等待。
在啟動過程也不要過早地拉起應用的其他程式,System Server 和新的程式都會競爭 CPU 資源。特別是系統記憶體不足的時候,當我們拉起一個新的程式,可能會成為“壓死駱駝的最後一根稻草”。它可能會觸發系統的 low memorykiller 機制,導致系統殺死和拉起(保活)大量的程式,從而影響前臺程式的 CPU。舉個例子,之前一個程式在啟動過程會拉起下載和視訊播放程式,改為按需拉起後,線上啟動時間提高了 3%,對於 1GB 以下的低端機優化,整個啟動時間可以優化 5%~8%,效果還是非常明顯的。
佈局優化
佈局越複雜,測量佈局繪製的時間就越長。主要做到以下幾點:
- 佈局的層級越少,載入速度越快。
- 一個控制元件的屬性越少,解析越快,刪除控制元件中的無用屬性。
- 使用<ViewStub/>標籤載入一些不常用的佈局,做到使用時在載入。
- 使用<merge/>標籤減少佈局的巢狀層次。
- 儘可能少用wrap_content,wrap_content會增加布局measure時的計算成本,已知寬高為固定值時,不用wrap_content。
啟動優化進階方法
還有什麼方法可以做進一步優化嗎?
資料重排
如果我們在啟動的過程中需要讀一個檔案 test.io 的 1KB 資料,而我們的 buffer 不小心寫成 1byte,那麼總共要讀取 1000 次。系統是否會真的發起 1000 次磁碟 IO 呢?
事實上 1000 次讀操作只是我們發起的次數,並不是真正的磁碟 I/O 次數。你可以參考下面 Linux 檔案 I/O流程。
Linux 檔案系統從磁碟讀檔案的時候,會以 block 為單位去磁碟讀取,一般 block 大小是 4KB。也就是說一次磁碟讀寫大小至少是 4KB,然後會把 4KB 資料放到頁快取 Page Cache 中。如果下次讀取檔案資料已經在頁快取中,那就不會發生真實的磁碟 I/O,而是直接從頁快取中讀取,大大提升了讀的速度。所以上面的例子,我們雖然讀了 1000 次,但事實上只會發生一次磁碟 I/O,其他的資料都會在頁快取中得到。
Dex 檔案用的到的類和安裝包 APK 裡面各種資原始檔一般都比較小,但是讀取非常頻繁。我們可以利用系統這個機制將它們按照讀取順序重新排列,減少真實的磁碟 I/O 次數。
在啟動優化中,資料的重排主要有兩方面:類重排 以及 資原始檔重排。
類重排
類重排的實現通過 ReDex 的 Interdex 調整類在 Dex 中的排列順序。
不明白可以看這篇文章:Redex 初探與 Interdex:Andorid 冷啟動優化
根據interdex官方介紹的原理,我們可以知道要實現這個優化需要解決三個問題:
- 如何獲取啟動時載入類的序列?
redex中的方案是dump出程式啟動時的hprof檔案,再從中分析出載入的類,比較麻煩。這裡我們採用的方案是hook住ClassLoader.findClass方法,在系統載入類時日誌列印出類名,這樣分析日誌就可以得到啟動時載入的類序列了。
class GetClassLoader extends PathClassLoader {
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 將類名 name 記錄到檔案
writeToFile(name, "coldstart_classes.txt");
return super.findClass(name);
}
}複製程式碼
- 如何把需要的類放到主dex中?
redex的做法應該是解析出所有dex中的類,再按配置的載入類序列,從主dex開始重新生成各個dex,所以會打亂原有的dex分佈。而在手q中,分dex規則是編譯指令碼中維護的,因此我們可以修改分包邏輯,將需要的類放到主dex。
- 如何調整主dex中類的順序?
開源就是好。Android編譯時把.class轉換成.dex是依靠dx.bat,這個工具實際執行的是sdk中的dx.jar。我們可以修改dx的原始碼,替換這個jar包,就可以執行自定義的dx邏輯了。簡單說下具體修改方法:
這裡需要對dex的檔案格式做一定了解,不再細說,網上有一篇很好的文章,有興趣可以瞭解下 http://blog.csdn.net/jiangwei0910410003/article/details/50668549
資原始檔重排
Facebook 在比較早的時候就使用“資源熱圖”來實現資原始檔的重排,最近支付寶在《通過安裝包重排布優化 Android 端啟動效能》中也詳細講述了資源重排的原理和落地方法。
類的載入
載入類的過程有一個 verify class 的步驟,它需要需要校驗方法的每一個指令,是一個比較耗時的操作。
verify步驟可以看這篇文章:微信 Android 熱補丁實踐演進之路
我們可以通過 Hook 來去掉 verify 這個步驟,這對啟動速度有幾十毫秒的優化。不過我想說,其實最大的優化場景在於首次和覆蓋安裝時。以 Dalvik 平臺為例,一個 2MB 的 Dex 正常需要 350 毫秒,將 classVerifyMode 設為 VERIFY_MODE_NONE 後,只需要150 毫秒,節省超過 50% 的時間。
// Dalvik Globals.h
gDvm.classVerifyMode = VERIFY_MODE_NONE;
// Art runtime.cc
verify_ = verifier::VerifyMode::kNone;複製程式碼
但是 ART 平臺要複雜很多,Hook 需要相容幾個版本。而且在安裝時大部分 Dex 已經優化好了,去掉 ART 平臺的 verify 只會對動態載入的 Dex 帶來一些好處。Atlas 中的 dalvik_hack-3.0.0.5.jar 可以通過下面的方法去掉 verify,但是當前沒有支援 ART 平臺。
AndroidRuntime runtime = AndroidRuntime.getInstance();
runtime.init(context);
runtime.setVerificationEnabled(false);複製程式碼
這個黑科技可以大大降低首次啟動的速度,代價是對後續執行會產生輕微的影響。同時也要考慮相容性問題,暫時不建議在 ART 平臺使用。
黑科技
保活
講到黑科技,你可能第一個想到的就是保活。保活可以減少 Application 建立跟初始化的時間,讓冷啟動變成溫啟動。不過在 Target 26 之後,保活的確變得越來越難。對於大廠來說,可能需要尋求廠商合作的機會。
外掛化和熱修復
它們真的那麼好嗎?事實上大部分的框架在設計上都存在大量的 Hook 和私有 API 呼叫,帶來的缺點主要有兩個:
- 穩定性。雖然大家都號稱相容 100% 的機型,由於廠商的相容性、安裝失敗、dex2oat 失敗等原因,還是會有那麼一些程式碼和資源的異常。Android P 推出的 non-sdk-interface 呼叫限制,以後適配只會越來越難,成本越來高。
效能。Android Runtime 每個版本都有很多的優化,因為外掛化和熱修復用到的一些黑科技,導致底層 Runtime 的優化我們是享受不到的。Tinker 框架在載入補丁後,應用啟動速度會降低 5%~10%。
總的來說,對於黑科技我們需要慎重,當你足夠了解它們內部的機制以後,可以選擇性的使用。
總結
以上就是本人學習過程中對啟動優化相關內容的總結,謝謝大家能夠閱讀到這裡。
啟動優化,是一項長期的任務,任重而道遠。
開發者要未雨綢繆,在編碼過程中儘量減少給啟動帶來效能損耗的工作,主要注意以下幾個事項:
- 儘量避免啟動時在主執行緒做密集繁重的工作,如:避免 I/O 操作、反序列化、網路操作、鎖等待等。
- 對模組以及第三方庫按需載入,採取分步載入、非同步載入、延期載入等策略。
- 利用執行緒池管理執行緒,避免建立大量執行緒,造成 CPU 競爭,導致主執行緒時間片減少。
- 啟動過程中,儘量避免頻繁建立的大量物件,減少 GC 給啟動效能帶來的卡頓影響。
- 儘量避免在啟動過程中呼叫阻塞性的系統呼叫。
至於“啟動優化進階方法”小節中總結的優化方法要慎重選擇使用,因為這些方法或多或少會帶來一些不好影響。我們在使用這些方法之前,要足夠了解他們的內部實現機制,做好評估工作,進行選擇性使用。
最後附上幾篇好文章幫助理解:
預覽視窗:顯示Activity的啟動視窗
Interdex的介紹:Redex 初探與 Interdex:Andorid 冷啟動優化
載入類過程 verify class 步驟的介紹:微信 Android 熱補丁實踐演進之路
資原始檔重排:支付寶 App 構建優化解析:通過安裝包重排布優化 Android 端啟動效能
外掛化與熱修復:Android熱修復,沒你想的那麼難
初到掘金,人生地不熟,喜歡的朋友,點個贊鼓勵下新手唄~