更多移動技術(shù)文章請關(guān)注本文集:知乎移動平臺專欄
背景
知乎 Android 客戶端作為一個比較大型的應(yīng)用政冻,由于功能不斷地迭(zeng)代(jia)衣洁,啟動速度也會受到影響参袱,為了提升用戶體驗哪亿,知乎移動平臺團隊把提高 App 啟動速度定為了的一個長期而且重要的 OKR察郁,于是我們在今年的第二季度蔑水,重點對客戶端的啟動做了一系列的優(yōu)化。
雖然在性能優(yōu)化相關(guān)領(lǐng)域我們還處于剛開始的階段,但是在優(yōu)化過程中我們還是總結(jié)出了一些經(jīng)驗可以拿出來分享涧至。所以,今天我們來分享其中一次和 Retrofit 相關(guān)的優(yōu)化經(jīng)歷桑包。
開始之前
我們在做性能優(yōu)化時南蓬,很多人可能苦惱于怎么去檢驗或者說量化優(yōu)化的最終效果。這里面其實是有一定的學(xué)問的哑了,通常我們會選用系統(tǒng)輸出的一些信息來作為指標(biāo)赘方,例如眾所周知的用 GPU 渲染柱狀圖來作為 UI 是否卡頓的指標(biāo)。
而啟動速度的話弱左,我們會用系統(tǒng) ActivityManager 打印的 Activity 啟動的 Log 里面的時間作為指標(biāo):
07-11 15:09:32.519 1440-1502/? I/ActivityManager: Displayed com.zhihu.android/.app.ui.activity.MainActivity: +1s412ms (total +1s978ms)
Log 更具體的含義可以看 Stack Overflow 上的這個 回答蒜焊。為什么要用系統(tǒng)輸出的信息作為指標(biāo)呢?一來是系統(tǒng)輸出的信息采集起來更方便科贬,不需要自己寫代碼去測泳梆,二來是我們同時可以拿到其他 App 對應(yīng)的系統(tǒng)輸出信息鳖悠,方便與其他競品做橫向?qū)Ρ取?br>
言歸正傳,在這次優(yōu)化開始之前优妙,我們需要先測出 App 優(yōu)化前的啟動數(shù)據(jù)乘综。通過對 App 進行多次冷啟動并記錄 Log 里的 Total duration,得出了 App 在優(yōu)化前的平均啟動時間為 1.905s(數(shù)據(jù)來自 OnePlus 3T):
發(fā)現(xiàn) & 分析問題
大多數(shù)情況下套硼,我們是從 App 的 UI 交互反饋上得知性能有問題的卡辰,例如頁面卡頓、啟動時間太長邪意。當(dāng)我們知道有問題之后九妈,光靠 Review 代碼是很難找出具體是哪里出的問題,因為一段代碼的執(zhí)行效率除了受各種內(nèi)因(CPU / IO 操作密集雾鬼,鎖…)的影響萌朱,還有可能受其他外因(系統(tǒng)資源爭奪、GC…)的影響策菜。所以想要精確找出問題晶疼,最好的方法是讓代碼真正執(zhí)行起來,然后去 Profile(監(jiān)測)代碼運行時的情況又憨。這其實是一門很大的學(xué)問翠霍,需要用到大量用于 Profiling 的工具,包括 Android 系統(tǒng)蠢莺、SDK寒匙、甚至 IDE 提供的一些接口或工具,熟練運用這些工具去分析和發(fā)現(xiàn)性能問題是新手玩家進階的必學(xué)技能躏将。針對今天 App 啟動的 Profiling蒋情,我們可以借助以下的一些方式和工具來看看知乎 App 優(yōu)化前的啟動有哪些問題:
Method Tracing
Method Tracing,就是跟蹤 App 某段時間內(nèi)所有調(diào)用過的方法耸携,這是測量應(yīng)用執(zhí)行性能常用的方式之一棵癣,通過它我們可以查出 App 啟動時具體都調(diào)用了方法,都花了多長時間夺衍。這個功能是 Android 系統(tǒng)提供的狈谊,我們可以通過在代碼里手動調(diào)用 android.os.Debug.startMethodTracing() 和 stopMethodTracing() 方法來開始和結(jié)束 Tracing,然后系統(tǒng)會把 Tracing 的結(jié)果保存到手機的 .trace 文件里沟沙。詳情可以看 官方文檔河劝。
此外,除了通過寫代碼來 Trace矛紫,我們也有更方便的方式赎瞎。例如也可以通過 Android Studio Profiler 里的 Method Tracer 來 Trace。但是颊咬,針對 App 的冷啟動务甥,我們則通常會用 Android 系統(tǒng)自帶的 Am 命令來跟蹤牡辽,因為它能準(zhǔn)確的在 App 啟動的時候就開始 Trace:
# 啟動指定 Activity,并同時進行采樣跟蹤
adb shell am start -n com.zhihu.android/com.zhihu.android.app.ui.activity.MainActivity --start-profiler /data/local/tmp/zhihu-startup.trace --sampling 1000
當(dāng) App 冷啟動完畢后(首個 Activity 已經(jīng)繪制到屏幕上)敞临,使用以下命令手動終止跟蹤态辛,并拉取跟蹤結(jié)果到本機的當(dāng)前目錄下:
# 終止跟蹤
adb shell am profile stop
# 拉取 .trace 文件到本機當(dāng)前目錄
adb pull /data/local/tmp/zhihu-startup.trace .
拿到 .trace 文件之后,下一步就是進行可視化了挺尿∽嗪冢可以直接拖動 .trace 文件到 Android Studio 里打開,但 Android Studio 目前版本對 .trace 文件的可視化和交互做得還比較差编矾,所以不推薦熟史。在這里更推薦使用 Android Device Monitor 里的 Traceview 來打開,詳情可以查閱 官方文檔窄俏。
用 Traceview 打開之后的截圖:
所有跟蹤到的方法默認(rèn)按照實際總耗時從大到小向下排序蹂匹,點擊某個方法可以看到它的所有父方法和子方法。通過從上往下一條條排查裆操,可以看到 UserInfoInitialization 類的 initUserInfo() 方法竟然耗時超過 600ms:
我們來看看這個方法對應(yīng)的代碼:
private void initUserInfo() {
NetworkUtils.createService(ProfileService.class)
.getSelf(AppInfo.getAppId())
// ...
.subscribe(response -> {
// ...
}, Debug::e);
}
NetworkUtils.createService() 是我們自己封裝的一個方法怒详,內(nèi)部調(diào)用 Retrofit 來獲取 ProfileService 的動態(tài)代理類炉媒,而 getSelf() 則是 ProfileService 里的一個方法踪区。從 Traceview 中可以看到,159 (0x68f8) 這個是運行時生成的代理方法吊骤,所以可以判斷這里的耗時是因為調(diào)用 getSelf() 引起的缎岗。
繼續(xù)往下跟蹤:
可以看出來,最終主要的耗時在 Jackson 庫的一些方法上白粉,而 Jackson 庫是用來給 Retrofit 序列化和反序列化數(shù)據(jù)(例如 Response Body)用的传泊。
接下來就是深入追查原因了。通過查看 Retrofit 的代碼可以知道鸭巴,在第一次調(diào)用 Retrofit 的某個動態(tài)代理方法時眷细,Retrofit 會新建一個 ServiceMethod 實例來儲存該代理方法相關(guān)的一些東西,里面包括一個用來轉(zhuǎn)換 Response Body 的 Converter鹃祖。而在新建這個 Converter 的時候溪椎,則會根據(jù) Body 內(nèi)容對應(yīng)的 Java Model 類,來生成一個 ObjectReader恬口,如果對應(yīng)的 Java Model 類特別復(fù)雜校读,那么新建 ObjectReader 的時間也會特別長(內(nèi)部會進行一堆反射操作)。而恰恰我們 getSelf() 方法返回的 People Model 是一個字段極其多的類祖能,所以造成第一次調(diào)用該代理方法時特別耗時歉秫。
問題的原因我們已經(jīng)找到了,接下來我們可以用 Systrace 來看看問題發(fā)生時养铸,系統(tǒng)和 App 的真實情況雁芙。
Systrace
Method Tracing 雖然是找出耗時方法的利器轧膘,但是執(zhí)行 Method Tracing 時的運行環(huán)境和用戶最終運行的環(huán)境會有極大的差距,因為 Method Tracing 會嚴(yán)重拖慢 App 的執(zhí)行速度却特。即使使用采樣跟蹤扶供,測量得到的結(jié)果和實際結(jié)果肯定還是有很大偏差,只能作為參考裂明。而且 Method Tracing 更偏向于追查應(yīng)用的內(nèi)因椿浓,對于運行環(huán)境等外因(鎖、GC闽晦、資源匱乏等)的追查顯得很無力扳碍。所以,我們可以借助另一個 Google 官方極力推薦的工具 - 「Systrace」來跟蹤 App 實際運行時的情況仙蛉。詳細介紹可以查看 官方文檔笋敞。
Systrace 的原理是通過 Android 系統(tǒng)自帶的 atrace 工具來捕獲系統(tǒng)以及 App 自身一些關(guān)鍵的信息,然后通過 Chrome 瀏覽器進行可視化荠瘪。
接下來夯巷,我們將通過 Systrace 來跟蹤 App 在執(zhí)行 Retrofit 代理方法時系統(tǒng)的真實情況。首先哀墓,我們需要做一些準(zhǔn)備:
1.為了讓結(jié)果更接近真實情況趁餐,我們需要為 Release 包開啟 App Tracing 的功能,詳情可以查看 這里篮绰。
2.給 Retrofit 的 loadServiceMethod() 方法添加 Tracing Section(PS:可以借助 JarFilterPlugin 來修改 Retrofit 的內(nèi)部代碼):
ServiceMethod<?, ?> loadServiceMethod(Method method) {
Trace.beginSection("Retrofit:" + method.getName());
// ...
Trace.endSection();
return result;
}
3.添加 Proguard 規(guī)則后雷,保證 method.getName() 獲取到正確的方法名。
-keepclassmembernames class * {
@retrofit2.http.GET *;
@retrofit2.http.POST *;
@retrofit2.http.PUT *;
@retrofit2.http.DELETE *;
}
做完準(zhǔn)備后用 Gradle 命令打出一個上線標(biāo)準(zhǔn)的 Release 包吠各,然后就可以開始用 Systrace 來跟蹤了:
systrace -a com.zhihu.android app view res am sched dalvik
開始跟蹤后臀突,冷啟動 App,等到首個 Activity 可見之后點擊回車結(jié)束跟蹤贾漏。跟蹤結(jié)束后會把結(jié)果保存到當(dāng)前目錄下的 trace.html 文件候学,該文件需要用 Chrome 打開:
通過 Chrome 可以很直觀地看到 App 在啟動時的各種關(guān)鍵的 Section。此外纵散,我們還能看系統(tǒng) CPU 每個時期的使用率以及每個核心上執(zhí)行的線程:
通過選定某個線程上面的線程狀態(tài)條梳码,我們可以查看某段時間或 Section 里線程的運行狀態(tài):
從上圖可以看出,在「Retrofit:getSelft」這個 Section 里困食,UI Thread 基本都是 Running 狀態(tài)边翁,說明在執(zhí)行 getSelft() 方法時幾乎做的都是 CPU 密集的操作,很少進入等待狀態(tài)硕盹。這也說明了 getSelft() 方法的耗時是真實的符匾,不是由于其他原因(例如鎖等待)造成。而且有一點需要注意瘩例,這里測出來的實際耗時是 250 多毫秒啊胶,和 Method Tracing 測量出來的 650 毫秒對比可以看出 Method Tracing 測量結(jié)果的誤差還是很大的甸各。
接著我們通過搜索「Retrofit:」關(guān)鍵字可以看到,在啟動期間會多次調(diào)用到 Retrofit 的 loadServiceMethod() 方法:
解決問題
耗時的原因已經(jīng)找到了,而優(yōu)化的思路就是把 loadServiceMethod() 的調(diào)用扔到非 UI 線程上去執(zhí)行某饰。例如儒恋,針對 getSelf() 這個方法,我們可以使用 Observable 的 defer() 操作符把它放到非 UI 線程上去執(zhí)行:
private void initUserInfo() {
Observable.defer(() ->
NetworkUtils.createService(ProfileService.class).getSelf(AppInfo.getAppId())
.subscribeOn(Schedulers.io())
)
.subscribeOn(Schedulers.single())
// ...
.subscribe(response -> {
// ...
}, Debug::e);
}
但要知道黔漂,啟動的時候不單單只有這一處會調(diào)用到 Retrofit 的 loadServiceMethod()诫尽,要把所有地方都加上 defer() 操作修改量有點大。那有沒有更好的辦法呢炬守?
答案就是二次動態(tài)代理:
public final class Net {
public static <T> createService(Class<T> service) {
// ...
return createWrapperService(mRetrofit, service);
}
private static <T> T createWrapperService(Retrofit retrofit, Class<T> service) {
return (T) Proxy.newProxyInstance(service.getClassLoader(),
new Class<?>[]{service}, new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, @Nullable Object[] args)
throws Throwable {
if (method.getReturnType() == Observable.class) {
// 如果方法返回值是 Observable 的話牧嫉,則包一層再返回
return Observable.defer(() -> {
final T service = getRetrofitService();
// 執(zhí)行真正的 Retrofit 動態(tài)代理的方法
return ((Observable) getRetrofitMethod(service, method)
.invoke(service, args))
.subscribeOn(Schedulers.io());
})
.subscribeOn(Schedulers.single());
}
// 返回值不是 Observable 的話不處理
final T service = getRetrofitService();
return getRetrofitMethod(service, method).invoke(service, args);
}
// ...
}
}
其實就是再創(chuàng)建一層動態(tài)代理,然后把底層 Retrofit 代理方法的調(diào)用也包進 Observable 里再返回减途。這么改的好處是可以一勞永逸酣藻,讓所有調(diào)用的地方無需做任何更改,減少了代碼的修改量鳍置。
經(jīng)過這么優(yōu)化之后辽剧,我們重新測試了一遍啟動時長,最終得出優(yōu)化后的啟動時間大概快了 400ms墓捻《督觯可以看出坊夫,這次優(yōu)化是成功的砖第。
結(jié)語
可以看到我們整篇文章里的「發(fā)現(xiàn) & 分析問題」這一章占的篇幅是最大的,這其實也反映了我們在做性能優(yōu)化時的常態(tài)环凿,大多數(shù)時間都會花在 Profile 上梧兼,當(dāng)遇到優(yōu)化瓶頸時,更可能需要同時借助不同的工具花大量時間去抽絲剝繭地分析問題智听。其實上面也講到了羽杰,Profiling 是一門大學(xué)問,本次分享只是挑了些比較簡單但是關(guān)鍵的方式和工具來講到推,實際在進行 Profile 的時候會用到更多方式和工具考赛,甚至自己寫工具去輔助定位問題。希望本篇文章能起到拋磚引玉的作用莉测,讓大家能了解到做啟動優(yōu)化時的一些常用思路颜骤。此外,該次只是我們優(yōu)化知乎 Android 客戶端啟動速度的其中一次經(jīng)歷捣卤,知乎移動平臺團隊還在不斷地為優(yōu)化啟動速度做努力忍抽,也希望未來還能分享更多經(jīng)歷給大家八孝。
由于本人的水平有限,如有錯誤和疏漏鸠项,歡迎各位同學(xué)指正干跛。
另外,知乎移動平臺團隊也在招人中祟绊,歡迎各位小伙伴的加入楼入,和我們一起做一些酷事情!具體招聘信息在這里 Android 基礎(chǔ)架構(gòu)工程師
關(guān)于作者
nekocode牧抽,知乎 Android 基礎(chǔ)架構(gòu)工程師浅辙,Kotlin 早期使用和推廣者。2017 年加入知乎阎姥,目前在移動平臺團隊負(fù)責(zé)知乎 Android App 性能優(yōu)化相關(guān)工作记舆。