近年來,社區(qū)充斥著關(guān)于 Android 性能優(yōu)化的各種誤區(qū),本文本著誤區(qū)終結(jié)者的精神,使用具體的性能檢測工具,結(jié)合真實案例仔細分析這些情況,并對比它們的測試結(jié)果,也會聚焦 Android 開發(fā)者平時在編碼過程的實際場景,用實際數(shù)據(jù)告訴你在實際編碼之前請,一定要進行必要的性能檢測。
誤區(qū) 1:Kotlin 比 Java 更消耗性能
Google 云端硬盤團隊目前已將其應(yīng)用程序從 Java 全面替換為 Kotlin,重構(gòu)范圍涉及 170 多個文件,超過 16,000 行代碼,包含 40 多個編譯產(chǎn)物,在團隊監(jiān)控的指標(biāo)中,第一要素是啟動時間,測試結(jié)果如下:

如圖所示,使用 kotlin 并沒有對性能造成實質(zhì)的影響,而且在整個基準測試過程中,Google 團隊也都沒有觀察到明顯的性能差異,即使編譯時間和編譯后的代碼大小略有增加,但都保持在 2% 之內(nèi),完全可以忽略不計。而得益于 kotlin 簡潔的語法,團隊的代碼行卻減少了大約 25%,也變得更易讀和易維護。
還比較值得一提的是,使用 kotlin 時,我們也可以使用像 R8 這樣的代碼縮減工具,對代碼進行進一步的優(yōu)化。
誤區(qū)二:Getters 和 setters 方法更耗時
因為擔(dān)心性能下降,有些開發(fā)者會選擇在類中直接使用 public 修飾字段,而不去寫 getter 和 setter 方法,如下面這段代碼,這里的 getFoo () 方法就是變量 foo 的 getter 函數(shù):
<pre class="custom" style="margin-top: 10px; margin-bottom: 10px; border-radius: 5px; box-shadow: rgba(0, 0, 0, 0.55) 0px 2px 10px;">public class ToyClass { public int foo; public int getFoo() { return foo; } } ToyClass tc = new ToyClass(); 復(fù)制代碼</pre>
直接使用 tc.foo 獲取變量顯然已經(jīng)破壞了面向?qū)ο蟮姆庋b性,而在性能方面,我們在配備 Android 10 的 Pixel 3 上使用 Jetpack Benchmark 對 tc.getFoo () 與 tc.foo 兩個方法進行了基準測試,該庫提供了預(yù)熱代碼的功能,最終的穩(wěn)定測試結(jié)果如下:

getter 方法的性能與直接 access 變量的性能也并沒有多大差別,結(jié)果并不奇怪,因為 Android RunTime (ART) 內(nèi)聯(lián)了代碼中所有的 getter 方法,因此,在 JIT 或 AOT 編譯后執(zhí)行的代碼是相同的,正因如此,在 kotlin 中即使我們默認需要使用 getter 或 setter 獲得變量,性能也并不會有所下降,如果使用 Java,除非特殊需要,否則就不應(yīng)該使用這種方式破壞代碼的封裝性。
誤區(qū)三:Lambda 比內(nèi)部類慢
Lambda(尤其是在引入 Stream API 的情況下)是一種非常方便的語法,可實現(xiàn)非常簡潔的代碼。如下這段代碼,對對象數(shù)組的內(nèi)部字段值求和,這里,使用了 Stream API 搭配 map-reduce 操作:
<pre class="custom" style="margin-top: 10px; margin-bottom: 10px; border-radius: 5px; box-shadow: rgba(0, 0, 0, 0.55) 0px 2px 10px;">ArrayList<ToyClass> array = build(); int sum = array.stream().map(tc -> tc.foo).reduce(0, (a, b) -> a + b); 復(fù)制代碼</pre>
第一個 lambda 會將對象轉(zhuǎn)換為整數(shù),第二個 lambda 會將產(chǎn)生的兩個值相加。
下面代碼中,我們再將 lambda 表達式換成內(nèi)部類:
<pre class="custom" style="margin-top: 10px; margin-bottom: 10px; border-radius: 5px; box-shadow: rgba(0, 0, 0, 0.55) 0px 2px 10px;">ToyClassToInteger toyClassToInteger = new ToyClassToInteger(); SumOp sumOp = new SumOp(); int sum = array.stream().map(toyClassToInteger).reduce(0, sumOp); 復(fù)制代碼</pre>
這里,有兩個內(nèi)部類:一個是 toyClassToInteger,它可以將對象轉(zhuǎn)換為整數(shù),第二個 SumOp 用來做求和運算。
從語法上看,第一個帶有 lambda 的示例顯然更優(yōu)雅,也更易讀。那么,性能差異又如何呢?我們再次在 Pixel 3 上使用了 Jetpack Benchmark,也沒有發(fā)現(xiàn)性能差異:

從圖中可以看到,我們還定義了單獨的外部 (top-level) 類一起來做比較,發(fā)現(xiàn)性能都沒有什么差異,原因就是 lambda 表達式最終也會被轉(zhuǎn)換為匿名內(nèi)部類。因此,為了代碼的簡潔易讀,在這種場景下 lambda 表達式就是第一選擇。
誤區(qū)四:對象分配開銷過大,應(yīng)該使用對象池
Android 內(nèi)置了最先進的內(nèi)存分配和垃圾回收機制,如下圖所示,幾乎每個版本的更新都在對象分配方面做各式各樣的更新。

各個版本之間的垃圾收集性能都有顯著的改善,如今,垃圾收集對應(yīng)用程序的流暢已經(jīng)幾乎沒有影響了。下圖展示了 Google 官方在 Android 10 中對具有分代并發(fā)收集的對象收集所做的改進,新版本的 Android 11 中也有明顯的改進。

在 GC 基準測試(例如 H2)中,吞吐量大幅提高了 170% 以上,而在實際應(yīng)用(如 Google Sheets)中,吞吐量也提高了 68%。
如果認為垃圾收集效率低下并且內(nèi)存分配負擔(dān)很重,那么就相當(dāng)于認為創(chuàng)建的垃圾越少,垃圾收集工作就越少,因此,代替每次使用時都創(chuàng)建新對象,我們可以維護一個經(jīng)常使用的類型的對象池,然后從池中獲取已創(chuàng)建的對象,如下:
<pre class="custom" style="margin-top: 10px; margin-bottom: 10px; border-radius: 5px; box-shadow: rgba(0, 0, 0, 0.55) 0px 2px 10px;">Pool<A> pool[] = new Pool<>[50]; void foo() { A a = pool.acquire(); … pool.release(a); } 復(fù)制代碼</pre>
這里省略了代碼細節(jié),大體就是就是定義了一個 pool,從 pool 中獲取對象,然后最終釋放。
要測試這種場景,我們使用微基準測試 (microbenchmark):從池中測試分配對象的開銷,以及 CPU 的開銷,來確定垃圾回收是否會影響應(yīng)用程序的性能。
在這種情況下,我們依然可以在裝有 Android 10 的 Pixel 2 XL 上循環(huán)運行了數(shù)千次分配對象的代碼,因為對于小型或大型對象,性能可能會有所不同,我們還通過添加不同的字段來模擬不同的對象大小,最終的開銷結(jié)果如下:

用于垃圾回收的 CPU 開銷的結(jié)果如下:

從圖中可以看出,標(biāo)準分配和池化對象之間的差異也很小,但是,當(dāng)涉及到較大對象的垃圾回收時,池解決方案略微高一點。
這個結(jié)果并不意外,因為池化對象會增加應(yīng)用的內(nèi)存占用量,此時,應(yīng)用突然占用了太多的內(nèi)存,即使由于池化對象減少了垃圾回收調(diào)用的數(shù)量,每個垃圾回收調(diào)用的成本也更高,因為垃圾收集器必須遍歷更多的內(nèi)存才能確定哪些對象需要被收集,哪些對象需要保留。
那么,對象是否應(yīng)該被池化,這還是主要取決于應(yīng)用的需求。如果不考慮到代碼復(fù)雜性,池化對象有如下缺點:
- 提高內(nèi)存占用量
- 使對象存活變長
- 需要非常完善的對象池機制
但是,池的方法對于大并且耗時的對象分配可能確實是有效的,關(guān)鍵是要記住在選擇方案之前進行充分的測試。
誤區(qū)五:debug 模式下進行性能分析
在 debug 的同時對應(yīng)用進行性能分析非常方便,畢竟,我們通常也是在 debug 模式下進行編碼的,并且,即使 debug 應(yīng)用中的性能分析不準確,也可以更快地進行迭代修改提高效率,然后事實是并沒有。
為了驗證這一誤解,我們分析了 Activity 相關(guān)的常見操作過程過的測試結(jié)果,如下圖:

在某些測試(例如反序列化)中,debug 與否對性能沒有影響,但是,有些結(jié)果卻有 50% 甚至以上的差別,我們甚至發(fā)現(xiàn)結(jié)果速度可能會慢 100% 的例子,這是因為 runtime 在 debug 模式下時對代碼幾乎沒有優(yōu)化,因此與用戶在生產(chǎn)設(shè)備上運行的代碼有很大不同。
在 debug 模式下進行性能分析的結(jié)果是可能會誤導(dǎo)優(yōu)化方向,導(dǎo)致浪費時間來優(yōu)化不需要優(yōu)化的內(nèi)容。
疑點
現(xiàn)在,我們需要有意識的逃避上述提到的五大誤區(qū),下面我們再來看一下一些日常開發(fā)中不太明顯,但我們經(jīng)常會有的疑惑的問題,事實結(jié)果可能也與我們想的大相徑庭。
疑點 1:Multidex:是否影響應(yīng)用性能?
如今的 APK 文件越來越大,因為大型應(yīng)用通常會超出 Android 限定的方法數(shù)量,從而使用 Multidex 方案打破傳統(tǒng)的 dex 規(guī)范。
問題是,多少方法可以稱之為多?而且如果應(yīng)用包含大量 dex 是否對性能產(chǎn)生影響?很多時候我們也并不是因為應(yīng)用太大,而是為了根據(jù)功能拆分 dex 文件來方便團隊開發(fā)而使用 Multidex。
為了測試多個 dex 文件對性能的影響,我們使用了計算器應(yīng)用,默認情況下,它只包含單個 dex 文件,我們可以根據(jù)其程序包邊界將其拆分為五個 dex 文件,來根據(jù)功能部件模擬拆分。
首先,測試啟動應(yīng)用的性能,結(jié)果如下:

因此,拆分 dex 文件對此處并沒有影響,對于其他應(yīng)用,可能會因為某些因素而產(chǎn)生輕微的開銷:應(yīng)用程序的大小以及拆分方式。但是,只要合理地分割 dex 文件并且不添加成百個 dex 文件,對啟動時間的影響應(yīng)該不大。
接下來是 APK 的大小和內(nèi)存消耗:


如圖所示,APK 大小和應(yīng)用的運行時內(nèi)存占用量都略有增加,這是因為將應(yīng)用程序拆分為多個 dex 文件時,每個 dex 文件都會有一些符號表和緩存表中的重復(fù)數(shù)據(jù)。
但是,我們可以通過減少 dex 文件之間的依賴關(guān)系來最大限度地避免這種情況,在這個案例中,并沒有將 dex 包量化,我們可以使用 R8 和 D8 之類的工具合理分析項目結(jié)構(gòu)并使用最小化的依賴關(guān)系,這些工具可以自動拆分 dex 文件,并幫助我們避免常見的錯誤,最大程度地減少依賴關(guān)系,如創(chuàng)建的 dex 文件數(shù)量不會超過指定的數(shù)量,并且不會將所有啟動類都放置在主文件中。但是,如果我們對 dex 文件進行自定義拆分,請確保合理分析。
疑點 2:無用代碼
使用 ART 這樣的即時編譯器的好處之一就是可以在運行時分析代碼,并對其進行優(yōu)化。有一種說法是,如果解釋器 / JIT 系統(tǒng)沒有對代碼進行概要分析,就可能不會執(zhí)行該代碼。為了驗證這一理論,我們檢查了 Google 應(yīng)用生成的 ART 配置文件,發(fā)現(xiàn)許多代碼并沒有被 JIT 做概要分析,這就表明許多代碼實際上從未在設(shè)備上執(zhí)行過。
有幾種類型的代碼可能無法剖析:
- 錯誤處理代碼,希望它不會執(zhí)行太多。
- 兼容性代碼,并非在所有設(shè)備上都執(zhí)行的代碼,尤其是 Android 5 以上版本的設(shè)備。
- 不常用功能的代碼。
但是,從結(jié)果分布來看,應(yīng)用程序中還是會存在很多不必要的代碼。R8 可以幫助我們快速,簡便,免費地刪除不必要的代碼,來縮小這部分的開銷。如果不這么做,我們也可以將應(yīng)用打包成 Android App Bundle,這種格式只會使用特定設(shè)備所需的代碼和資源來運行應(yīng)用。
總結(jié)
本文,我們分析了 Android 性能優(yōu)化的五大誤區(qū),但某些情況下數(shù)據(jù)的結(jié)果還并不清晰,我們需要做的就是在優(yōu)化和修改代碼之前盡量做好性能測試。
目前,已經(jīng)有很多工具可以幫助我們分析評估如何優(yōu)化應(yīng)用了,如 Android Studio 中的 profilers,它也提供了電池和網(wǎng)絡(luò)的監(jiān)測功能。也可以用一些工具做更深入的探究,如 Perfetto 和 Systrace,這些工具會提供更加詳細的功能,例如在應(yīng)用啟動或執(zhí)行過程中發(fā)生的具體情況。
Jetpack Benchmark 摒棄了監(jiān)測和基準測試的所有復(fù)雜操作,官方強烈建議我們在持續(xù)集成系統(tǒng)中使用它來跟蹤性能,并查看應(yīng)用在添加功能的行為,最后需要注意的一點是,不要在 debug 模式下分析應(yīng)用性能。
作者:Meandni
鏈接:https://juejin.im/post/6884030809515229198