Android冷啟動(dòng)優(yōu)化的3個(gè)小案例分享
背景
為了提高App的冷啟動(dòng)耗時(shí),除了在常規(guī)的業(yè)務(wù)側(cè)進(jìn)行耗時(shí)代碼優(yōu)化之外,為了進(jìn)一步縮短啟動(dòng)耗時(shí),需要在純技術(shù)測(cè)做一些優(yōu)化探索,本期我們從類預(yù)加載、Retrofit 、ARouter方面進(jìn)行了進(jìn)一步的優(yōu)化。從測(cè)試數(shù)據(jù)上來看,這些優(yōu)化手段的收益有限,可能在中端機(jī)上加起來也不超過50ms的收益,但為了冷啟動(dòng)場(chǎng)景的極致優(yōu)化,給用戶帶來更好的體驗(yàn),任何有收益的優(yōu)化手段都是值得嘗試的。
類預(yù)加載
一個(gè)類的完整加載流程至少包括 加載、鏈接、初始化,而類的加載在一個(gè)進(jìn)程中只會(huì)觸發(fā)一次,因此對(duì)于冷啟動(dòng)場(chǎng)景,我們可以異步加載原本在啟動(dòng)階段會(huì)在主線程觸發(fā)類加載過程的類,這樣當(dāng)原流程在主線程訪問到該類時(shí)就不會(huì)觸發(fā)類加載流程。
Hook ClassLoader 實(shí)現(xiàn)
在Android系統(tǒng)中,類的加載都是通過PathClassLoader 實(shí)現(xiàn)的,基于類加載的父類委托機(jī)制,我們可以通過Hook PathClassLoader 修改其默認(rèn)的parent 來實(shí)現(xiàn)。
首先我們創(chuàng)建一個(gè)MonitorClassLoader 繼承自PathClassLoader,并在其內(nèi)部記錄類加載耗時(shí)
class MonitorClassLoader( dexPath: String, parent: ClassLoader, private val onlyMainThread: Boolean = false, ) : PathClassLoader(dexPath, parent) { val TAG = "MonitorClassLoader" override fun loadClass(name: String?, resolve: Boolean): Class<*> { val begin = SystemClock.elapsedRealtimeNanos() if (onlyMainThread && Looper.getMainLooper().thread!=Thread.currentThread()){ return super.loadClass(name, resolve) } val clazz = super.loadClass(name, resolve) val end = SystemClock.elapsedRealtimeNanos() val cost = end - begin if (cost > 1000_000){ Log.e(TAG, "加載 ${clazz} 耗時(shí) ${(end - begin) / 1000} 微秒 ,線程ID ${Thread.currentThread().id}") } else { Log.d(TAG, "加載 ${clazz} 耗時(shí) ${(end - begin) / 1000} 微秒 ,線程ID ${Thread.currentThread().id}") } return clazz; } }
之后,我們可以在Application attach階段 反射替換 application實(shí)例的classLoader 對(duì)應(yīng)的parent指向。
核心代碼如下:
companion object { @JvmStatic fun hook(application: Application, onlyMainThread: Boolean = false) { val pathClassLoader = application.classLoader try { val monitorClassLoader = MonitorClassLoader("", pathClassLoader.parent, onlyMainThread) val pathListField = BaseDexClassLoader::class.java.getDeclaredField("pathList") pathListField.isAccessible = true val pathList = pathListField.get(pathClassLoader) pathListField.set(monitorClassLoader, pathList) val parentField = ClassLoader::class.java.getDeclaredField("parent") parentField.isAccessible = true parentField.set(pathClassLoader, monitorClassLoader) } catch (throwable: Throwable) { Log.e("hook", throwable.stackTraceToString()) } } }
主要邏輯為
- 反射獲取原始 pathClassLoader 的 pathList
- 創(chuàng)建MonitorClassLoader,并反射設(shè)置 正確的 pathList
- 反射替換 原始pathClassLoader的 parent指向 MonitorClassLoader實(shí)例
這樣,我們就獲取啟動(dòng)階段的加載類了
基于JVMTI 實(shí)現(xiàn)
除了通過 Hook ClassLoader的方案實(shí)現(xiàn),我們也可以通過JVMTI 來實(shí)現(xiàn)類加載監(jiān)控。
通過注冊(cè)ClassPrepare Callback, 可以在每個(gè)類Prepare階段觸發(fā)回調(diào)。
當(dāng)然這種方案,相比 Hook ClassLoader 還是要繁瑣很多,不過基于JVMTI 還可以做很多其他更強(qiáng)大的事。
類預(yù)加載實(shí)現(xiàn)
目前應(yīng)用通常都是多模塊的,因此我們可以設(shè)計(jì)一個(gè)抽象接口,不同的業(yè)務(wù)模塊可以繼承該抽象接口,定義不同業(yè)務(wù)模塊需要進(jìn)行預(yù)加載的類。
/** * 資源預(yù)加載接口 */ public interface PreloadDemander { /** * 配置所有需要預(yù)加載的類 * @return */ Class[] getPreloadClasses(); }
之后在啟動(dòng)階段收集所有的 Demander實(shí)例,并觸發(fā)預(yù)加載
/** * 類預(yù)加載執(zhí)行器 */ object ClassPreloadExecutor { private val demanders = mutableListOf<PreloadDemander>() fun addDemander(classPreloadDemander: PreloadDemander) { demanders.add(classPreloadDemander) } /** * this method shouldn't run on main thread */ @WorkerThread fun doPreload() { for (demander in localDemanders) { val classes = demander.preloadClasses classes.forEach { val classLoader = ClassPreloadExecutor::class.java.classLoader Class.forName(it.name, true, classLoader) } } } }
收益
第一個(gè)版本配置了大概90個(gè)類,在終端機(jī)型測(cè)試數(shù)據(jù)顯示 這些類的加載需要消耗30ms左右的cpu時(shí)間,不同類加載的消耗時(shí)間差異主要來自于類的復(fù)雜度 比如繼承體系、字段屬性數(shù)量等, 以及類初始化階段的耗時(shí),比如靜態(tài)成員變量的立即初始化、靜態(tài)代碼塊的執(zhí)行等。
方案優(yōu)化思考
我們目前的方案 配置的具體類列表來源于手動(dòng)配置,這種方案的弊端在于,類的列表需要開發(fā)維護(hù),在版本快速迭代變更的情況下 維護(hù)成本較大, 并且對(duì)于一些大型App,存在著非常多的AB實(shí)驗(yàn)條件,這也可能導(dǎo)致不同的用戶在類加載上是會(huì)有區(qū)別的。
在前面的小節(jié)中,我們介紹了使用自定義的 ClassLoader可以手動(dòng)收集 啟動(dòng)階段主線程的類列表,那么 我們是否可以在端上 每次啟動(dòng)時(shí) 自動(dòng)收集加載的類,如果發(fā)現(xiàn)這個(gè)類不在現(xiàn)有 的名單中 則加入到名單,在下次啟動(dòng)時(shí)進(jìn)行預(yù)加載。 當(dāng)然 具體的策略還需要做詳細(xì)設(shè)計(jì),比如 控制預(yù)加載名單的列表大小, 被加入預(yù)加載名單的類最低耗時(shí)閾值, 淘汰策略等等。
Retrofit ServiceMethod 預(yù)解析注入
背景
Retrofit 是目前最常用的網(wǎng)絡(luò)庫框架,其基于注解配置的網(wǎng)絡(luò)請(qǐng)求方式及Adapter的設(shè)計(jì)模式大大簡(jiǎn)化了網(wǎng)絡(luò)請(qǐng)求的調(diào)用方式。 不過其并沒有采用類似APT的方式在編譯時(shí)生成請(qǐng)求代碼,而是采用運(yùn)行時(shí)解析的方式。
當(dāng)我們調(diào)用Retrofit.create(final Class service) 函數(shù)時(shí),會(huì)生成一個(gè)該抽象接口的動(dòng)態(tài)代理實(shí)例。
接口的所有函數(shù)調(diào)用都會(huì)被轉(zhuǎn)發(fā)到該動(dòng)態(tài)代理對(duì)象的invoke函數(shù),最終調(diào)用loadServiceMethod(method).invoke 調(diào)用。
在loadServiceMethod函數(shù)中,需要解析原函數(shù)上的各種元信息,包括函數(shù)注解、參數(shù)注解、參數(shù)類型、返回值類型等信息,并最終生成ServiceMethod 實(shí)例,對(duì)原接口函數(shù)的調(diào)用其實(shí)最終觸發(fā)的是 這個(gè)生成的ServiceMethod invoke函數(shù)的調(diào)用。
從源碼實(shí)現(xiàn)上可以看出,對(duì)ServiceMethod的實(shí)例做了緩存處理,每個(gè)Method 對(duì)應(yīng)一個(gè)ServiceMethod。
耗時(shí)測(cè)試
這里我模擬了一個(gè)簡(jiǎn)單的 Service Method, 并調(diào)用archiveStat 觀察首次調(diào)用及其后續(xù)調(diào)用的耗時(shí),注意這里的調(diào)用還未觸發(fā)網(wǎng)絡(luò)請(qǐng)求,其返回的是一個(gè)Call對(duì)象。
從測(cè)試結(jié)果上看,首次調(diào)用需要觸發(fā)需要消耗1.7ms,而后續(xù)的調(diào)用 只需要消耗50微妙左右。
優(yōu)化方案
由于首次調(diào)用接口函數(shù)需要觸發(fā)ServiceMethod實(shí)例的生成,這個(gè)過程比較耗時(shí),因此優(yōu)化思路也比較簡(jiǎn)單,收集啟動(dòng)階段會(huì)調(diào)用的 函數(shù),提前生成ServiceMethod實(shí)例并寫入到緩存中。
serviceMethodCache 的類型本身是ConcurrentHashMap,所以它是并發(fā)安全的。
但是源碼中 進(jìn)行ServiceMethod緩存判斷的時(shí)候 還是以 serviceMethodCache為L(zhǎng)ock Object 進(jìn)行了加鎖,這導(dǎo)致 多線程觸發(fā)同時(shí)首次觸發(fā)不同Method的調(diào)用時(shí),存在鎖等待問題
這里首先需要理解為什么這里需要加鎖,其目的也是因?yàn)閜arseAnnotations 是一個(gè)好事操作,這里是為了實(shí)現(xiàn)類似 putIfAbsent的完全原子性操作。 但實(shí)際上這里加鎖可以以 對(duì)應(yīng)的Method類型為鎖對(duì)象,因?yàn)楸旧聿煌琈ethod 對(duì)應(yīng)的ServiceMethod實(shí)例就是不同的。 我們可以修改其源碼的實(shí)現(xiàn)來避免這種場(chǎng)景的鎖競(jìng)爭(zhēng)問題。
當(dāng)然針對(duì)我們的優(yōu)化場(chǎng)景,其實(shí)不修改源碼也是可以實(shí)現(xiàn)的,因?yàn)?ServiceMethod.parseAnnotations 是無鎖的,畢竟它是一個(gè)純函數(shù)。 因此我們可以在異步線程調(diào)用parseAnnotations 生成ServiceMethod 實(shí)例,之后通過反射 寫入 Retrofit實(shí)例的 serviceMethodCache 中。這樣存在的問題是 不同線程可能同時(shí)觸發(fā)了一個(gè)Method的解析注入,但 由于serviceMethodCache 本身就是線程安全的,所以 它只是多做了一次解析,對(duì)最終結(jié)果并無影響。
ServiceMethod.parseAnnotations是包級(jí)私有的,我們可以在當(dāng)前工程創(chuàng)建一個(gè)一樣的包,這樣就可以直接調(diào)用該函數(shù)了。 核心實(shí)現(xiàn)代碼如下
package retrofit2 import android.os.Build import timber.log.Timber import java.lang.reflect.Field import java.lang.reflect.Method import java.lang.reflect.Modifier object RetrofitPreloadUtil { private var loadServiceMethod: Method? = null var initSuccess: Boolean = false // private var serviceMethodCacheField:Map<Method,ServiceMethod<Any>>?=null private var serviceMethodCacheField: Field? = null init { try { serviceMethodCacheField = Retrofit::class.java.getDeclaredField("serviceMethodCache") serviceMethodCacheField?.isAccessible = true if (serviceMethodCacheField == null) { for (declaredField in Retrofit::class.java.declaredFields) { if (Map::class.java.isAssignableFrom(declaredField.type)) { declaredField.isAccessible =true serviceMethodCacheField = declaredField break } } } loadServiceMethod = Retrofit::class.java.getDeclaredMethod("loadServiceMethod", Method::class.java) loadServiceMethod?.isAccessible = true } catch (e: Exception) { initSuccess = false } } /** * 預(yù)加載 目標(biāo)service 的 相關(guān)函數(shù),并注入到對(duì)應(yīng)retrofit實(shí)例中 */ fun preloadClassMethods(retrofit: Retrofit, service: Class<*>, methodNames: Array<String>) { val field = serviceMethodCacheField ?: return val map = field.get(retrofit) as MutableMap<Method,ServiceMethod<Any>> for (declaredMethod in service.declaredMethods) { if (!isDefaultMethod(declaredMethod) && !Modifier.isStatic(declaredMethod.modifiers) && methodNames.contains(declaredMethod.name)) { try { val parsedMethod = ServiceMethod.parseAnnotations<Any>(retrofit, declaredMethod) as ServiceMethod<Any> map[declaredMethod] =parsedMethod } catch (e: Exception) { Timber.e(e, "load method $declaredMethod for class $service failed") } } } } private fun isDefaultMethod(method: Method): Boolean { return Build.VERSION.SDK_INT >= 24 && method.isDefault; } }
預(yù)加載名單收集
有了優(yōu)化方案后,還需要收集原本在啟動(dòng)階段會(huì)在主線程進(jìn)行Retrofit ServiceMethod調(diào)用的列表, 這里采取的是字節(jié)碼插樁的方式,使用的LancetX 框架進(jìn)行修改。
目前名單的配置是預(yù)先收集好,在配置中心進(jìn)行配置,運(yùn)行時(shí)根據(jù)配置中寫的配置 進(jìn)行預(yù)加載。 這里還可以提供其他的配置方案,比如 提供一個(gè)注解用于標(biāo)注該Retrofit函數(shù)需要進(jìn)行預(yù)解析,
之后,在編譯期間收集所有需要預(yù)加載的Service及函數(shù),生成對(duì)應(yīng)的名單,不過這個(gè)方案需要一定開發(fā)成本,并且需要去修改業(yè)務(wù)模塊的代碼,目前的階段還處于驗(yàn)證收益階段,所以暫未實(shí)施。
收益
App收集了啟動(dòng)階段20個(gè)左右的Method 進(jìn)行預(yù)加載,預(yù)計(jì)提升10~20ms。
ARouter
背景
ARouter框架提供了路由注冊(cè)跳轉(zhuǎn) 及 SPI 能力。為了優(yōu)化冷啟動(dòng)速度,對(duì)于某些服務(wù)實(shí)例可以在啟動(dòng)階段進(jìn)行預(yù)加載生成對(duì)應(yīng)的實(shí)例對(duì)象。
ARouter的注冊(cè)信息是在預(yù)編譯階段(基于APT) 生成的,在編譯階段又通過ASM 生成對(duì)應(yīng)映射關(guān)系的注入代碼。
而在運(yùn)行時(shí)以獲取Service實(shí)例為例,當(dāng)調(diào)用navigation函數(shù)獲取實(shí)例最終會(huì)調(diào)用到 completion函數(shù)。
當(dāng)首次調(diào)用時(shí),其對(duì)應(yīng)的RouteMeta 實(shí)例尚未生成,會(huì)繼續(xù)調(diào)用 addRouteGroupDynamic函數(shù)進(jìn)行注冊(cè)。
addRouteGroupDynamic 會(huì)創(chuàng)建對(duì)應(yīng)預(yù)編譯階段生成的服務(wù)注冊(cè)類并調(diào)用loadInto函數(shù)進(jìn)行注冊(cè)。而某些業(yè)務(wù)模塊如何服務(wù)注冊(cè)信息比較多,這里的loadInto就會(huì)比較耗時(shí)。
整體來看,對(duì)于獲取Service實(shí)例的流程, completion的整個(gè)流程 涉及到 loadInto信息注冊(cè)、Service實(shí)例反射生成、及init函數(shù)的調(diào)用。 而completion函數(shù)是synchronized的,因此無法利用多線程進(jìn)行注冊(cè)來縮短啟動(dòng)耗時(shí)。
優(yōu)化方案
這里的優(yōu)化其實(shí)和Retroift Service 的注冊(cè)機(jī)制類似,不同的Service注冊(cè)時(shí),其對(duì)應(yīng)的元信息類(IRouteGroup)其實(shí)是不同的,因此只需要對(duì)對(duì)應(yīng)的IRouteGroup加鎖即可。 另外由于這部分代碼現(xiàn)在可能多線程同時(shí)在進(jìn)行,部分邏輯需要進(jìn)行二次判斷,
在completion的后半部分流程中,針對(duì)Provider實(shí)例生產(chǎn)的流程也需要進(jìn)行單獨(dú)加鎖,避免多次調(diào)用init函數(shù)。
收益
根據(jù)線下收集的數(shù)據(jù) 配置了20+預(yù)加載的Service Method, 預(yù)期收益 10~20ms (中端機(jī)) 。
其他
后續(xù)將繼續(xù)結(jié)合自身業(yè)務(wù)現(xiàn)狀以及其他一線大廠分享的樣例,在 x2c、class verify、禁用JIT、 disableDex2AOT等方面繼續(xù)嘗試優(yōu)化。
以上就是Android冷啟動(dòng)優(yōu)化的3個(gè)小案例分享的詳細(xì)內(nèi)容,更多關(guān)于Android冷啟動(dòng)優(yōu)化的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Android實(shí)現(xiàn)手機(jī)游戲隱藏虛擬按鍵
這篇文章主要為大家詳細(xì)介紹了Android實(shí)現(xiàn)手機(jī)游戲隱藏虛擬按鍵,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2018-08-08Android 中讀取SD卡文件時(shí)拋出NullPointerException錯(cuò)誤解決辦法
這篇文章主要介紹了Android 中讀取SD卡文件時(shí)拋出NullPointerException錯(cuò)誤解決辦法的相關(guān)資料,需要的朋友可以參考下2017-05-05Android判斷某個(gè)權(quán)限是否開啟的方法
今天小編就為大家分享一篇Android判斷某個(gè)權(quán)限是否開啟的方法,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過來看看吧2018-07-07Android5.x中的陰影效果elevation和translationZ的實(shí)現(xiàn)方法
這篇文章主要介紹了 android5.x中的陰影效果elevation和translationZ的相關(guān)資料,需要的朋友可以參考下2016-12-12以一個(gè)著色游戲展開講解Android中區(qū)域圖像填色的方法
這篇文章主要介紹了Android中實(shí)現(xiàn)區(qū)域圖像顏色填充的方法,文中以一個(gè)著色游戲?yàn)槔v解了邊界的填充等各種填色操作,需要的朋友可以參考下2016-02-02Android制作登錄頁面并且記住賬號(hào)密碼功能的實(shí)現(xiàn)代碼
這篇文章主要介紹了Android制作登錄頁面并且記住賬號(hào)密碼功能的實(shí)現(xiàn)代碼,本文通過實(shí)例代碼給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2020-04-04Android實(shí)現(xiàn)網(wǎng)易新聞客戶端側(cè)滑菜單(2)
這篇文章主要為大家詳細(xì)介紹了Android實(shí)現(xiàn)網(wǎng)易新聞客戶端側(cè)滑菜單第二篇,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2016-11-11Flutter開發(fā)setState能否在build中直接調(diào)用詳解
這篇文章主要為大家介紹了Flutter開發(fā)setState能否在build中直接調(diào)用詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-10-10Android實(shí)現(xiàn)按鈕點(diǎn)擊事件的三種方法總結(jié)
Button是程序用于和用戶進(jìn)行交互的一個(gè)重要控件。既然有Button,那肯定有onClick方法,下面就教大家三種實(shí)現(xiàn)點(diǎn)擊事件的方法,感興趣的可以了解一下2022-04-04android中使用SharedPreferences進(jìn)行數(shù)據(jù)存儲(chǔ)的操作方法
本篇文章介紹了,在android中使用SharedPreferences進(jìn)行數(shù)據(jù)存儲(chǔ)的操作方法。需要的朋友參考下2013-04-04