近年來,社區充斥著關于 Android 性能優化的各種誤區,本文本著誤區終結者的精神,使用具體的性能檢測工具,結合真實案例仔細分析這些情況,并對比它們的測試結果,也會聚焦 Android 開發者平時在編碼過程的實際場景,用實際數據告訴你在實際編碼之前請,一定要進行必要的性能檢測。
誤區 1:Kotlin 比 Java 更消耗性能
Google 云端硬盤團隊目前已將其應用程序從 Java 全面替換為 Kotlin,重構范圍涉及 170 多個文件,超過 16,000 行代碼,包含 40 多個編譯產物,在團隊監控的指標中,第一要素是啟動時間,測試結果如下:
如圖所示,使用 kotlin 并沒有對性能造成實質的影響,而且在整個基準測試過程中,Google 團隊也都沒有觀察到明顯的性能差異,即使編譯時間和編譯后的代碼大小略有增加,但都保持在 2% 之內,完全可以忽略不計。而得益于 kotlin 簡潔的語法,團隊的代碼行卻減少了大約 25%,也變得更易讀和易維護。
還比較值得一提的是,使用 kotlin 時,我們也可以使用像 R8 這樣的代碼縮減工具,對代碼進行進一步的優化。
誤區二:Getters 和 setters 方法更耗時
因為擔心性能下降,有些開發者會選擇在類中直接使用 public 修飾字段,而不去寫 getter 和 setter 方法,如下面這段代碼,這里的 getFoo () 方法就是變量 foo 的 getter 函數:
<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(); 復制代碼
</pre>
直接使用 tc.foo 獲取變量顯然已經破壞了面向對象的封裝性,而在性能方面,我們在配備 Android 10 的 Pixel 3 上使用 Jetpack Benchmark 對 tc.getFoo () 與 tc.foo 兩個方法進行了基準測試,該庫提供了預熱代碼的功能,最終的穩定測試結果如下:
getter 方法的性能與直接 access 變量的性能也并沒有多大差別,結果并不奇怪,因為 Android RunTime (ART) 內聯了代碼中所有的 getter 方法,因此,在 JIT 或 AOT 編譯后執行的代碼是相同的,正因如此,在 kotlin 中即使我們默認需要使用 getter 或 setter 獲得變量,性能也并不會有所下降,如果使用 Java,除非特殊需要,否則就不應該使用這種方式破壞代碼的封裝性。
誤區三:Lambda 比內部類慢
Lambda(尤其是在引入 Stream API 的情況下)是一種非常方便的語法,可實現非常簡潔的代碼。如下這段代碼,對對象數組的內部字段值求和,這里,使用了 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); 復制代碼
</pre>
第一個 lambda 會將對象轉換為整數,第二個 lambda 會將產生的兩個值相加。
下面代碼中,我們再將 lambda 表達式換成內部類:
<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); 復制代碼
</pre>
這里,有兩個內部類:一個是 toyClassToInteger,它可以將對象轉換為整數,第二個 SumOp 用來做求和運算。
從語法上看,第一個帶有 lambda 的示例顯然更優雅,也更易讀。那么,性能差異又如何呢?我們再次在 Pixel 3 上使用了 Jetpack Benchmark,也沒有發現性能差異:
從圖中可以看到,我們還定義了單獨的外部 (top-level) 類一起來做比較,發現性能都沒有什么差異,原因就是 lambda 表達式最終也會被轉換為匿名內部類。因此,為了代碼的簡潔易讀,在這種場景下 lambda 表達式就是第一選擇。
誤區四:對象分配開銷過大,應該使用對象池
Android 內置了最先進的內存分配和垃圾回收機制,如下圖所示,幾乎每個版本的更新都在對象分配方面做各式各樣的更新。
各個版本之間的垃圾收集性能都有顯著的改善,如今,垃圾收集對應用程序的流暢已經幾乎沒有影響了。下圖展示了 Google 官方在 Android 10 中對具有分代并發收集的對象收集所做的改進,新版本的 Android 11 中也有明顯的改進。
在 GC 基準測試(例如 H2)中,吞吐量大幅提高了 170% 以上,而在實際應用(如 Google Sheets)中,吞吐量也提高了 68%。
如果認為垃圾收集效率低下并且內存分配負擔很重,那么就相當于認為創建的垃圾越少,垃圾收集工作就越少,因此,代替每次使用時都創建新對象,我們可以維護一個經常使用的類型的對象池,然后從池中獲取已創建的對象,如下:
<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); } 復制代碼
</pre>
這里省略了代碼細節,大體就是就是定義了一個 pool,從 pool 中獲取對象,然后最終釋放。
要測試這種場景,我們使用微基準測試 (microbenchmark):從池中測試分配對象的開銷,以及 CPU 的開銷,來確定垃圾回收是否會影響應用程序的性能。
在這種情況下,我們依然可以在裝有 Android 10 的 Pixel 2 XL 上循環運行了數千次分配對象的代碼,因為對于小型或大型對象,性能可能會有所不同,我們還通過添加不同的字段來模擬不同的對象大小,最終的開銷結果如下:
用于垃圾回收的 CPU 開銷的結果如下:
從圖中可以看出,標準分配和池化對象之間的差異也很小,但是,當涉及到較大對象的垃圾回收時,池解決方案略微高一點。
這個結果并不意外,因為池化對象會增加應用的內存占用量,此時,應用突然占用了太多的內存,即使由于池化對象減少了垃圾回收調用的數量,每個垃圾回收調用的成本也更高,因為垃圾收集器必須遍歷更多的內存才能確定哪些對象需要被收集,哪些對象需要保留。
那么,對象是否應該被池化,這還是主要取決于應用的需求。如果不考慮到代碼復雜性,池化對象有如下缺點:
- 提高內存占用量
- 使對象存活變長
- 需要非常完善的對象池機制
但是,池的方法對于大并且耗時的對象分配可能確實是有效的,關鍵是要記住在選擇方案之前進行充分的測試。
誤區五:debug 模式下進行性能分析
在 debug 的同時對應用進行性能分析非常方便,畢竟,我們通常也是在 debug 模式下進行編碼的,并且,即使 debug 應用中的性能分析不準確,也可以更快地進行迭代修改提高效率,然后事實是并沒有。
為了驗證這一誤解,我們分析了 Activity 相關的常見操作過程過的測試結果,如下圖:
在某些測試(例如反序列化)中,debug 與否對性能沒有影響,但是,有些結果卻有 50% 甚至以上的差別,我們甚至發現結果速度可能會慢 100% 的例子,這是因為 runtime 在 debug 模式下時對代碼幾乎沒有優化,因此與用戶在生產設備上運行的代碼有很大不同。
在 debug 模式下進行性能分析的結果是可能會誤導優化方向,導致浪費時間來優化不需要優化的內容。
疑點
現在,我們需要有意識的逃避上述提到的五大誤區,下面我們再來看一下一些日常開發中不太明顯,但我們經常會有的疑惑的問題,事實結果可能也與我們想的大相徑庭。
疑點 1:Multidex:是否影響應用性能?
如今的 APK 文件越來越大,因為大型應用通常會超出 Android 限定的方法數量,從而使用 Multidex 方案打破傳統的 dex 規范。
問題是,多少方法可以稱之為多?而且如果應用包含大量 dex 是否對性能產生影響?很多時候我們也并不是因為應用太大,而是為了根據功能拆分 dex 文件來方便團隊開發而使用 Multidex。
為了測試多個 dex 文件對性能的影響,我們使用了計算器應用,默認情況下,它只包含單個 dex 文件,我們可以根據其程序包邊界將其拆分為五個 dex 文件,來根據功能部件模擬拆分。
首先,測試啟動應用的性能,結果如下:
因此,拆分 dex 文件對此處并沒有影響,對于其他應用,可能會因為某些因素而產生輕微的開銷:應用程序的大小以及拆分方式。但是,只要合理地分割 dex 文件并且不添加成百個 dex 文件,對啟動時間的影響應該不大。
接下來是 APK 的大小和內存消耗:
如圖所示,APK 大小和應用的運行時內存占用量都略有增加,這是因為將應用程序拆分為多個 dex 文件時,每個 dex 文件都會有一些符號表和緩存表中的重復數據。
但是,我們可以通過減少 dex 文件之間的依賴關系來最大限度地避免這種情況,在這個案例中,并沒有將 dex 包量化,我們可以使用 R8 和 D8 之類的工具合理分析項目結構并使用最小化的依賴關系,這些工具可以自動拆分 dex 文件,并幫助我們避免常見的錯誤,最大程度地減少依賴關系,如創建的 dex 文件數量不會超過指定的數量,并且不會將所有啟動類都放置在主文件中。但是,如果我們對 dex 文件進行自定義拆分,請確保合理分析。
疑點 2:無用代碼
使用 ART 這樣的即時編譯器的好處之一就是可以在運行時分析代碼,并對其進行優化。有一種說法是,如果解釋器 / JIT 系統沒有對代碼進行概要分析,就可能不會執行該代碼。為了驗證這一理論,我們檢查了 Google 應用生成的 ART 配置文件,發現許多代碼并沒有被 JIT 做概要分析,這就表明許多代碼實際上從未在設備上執行過。
有幾種類型的代碼可能無法剖析:
- 錯誤處理代碼,希望它不會執行太多。
- 兼容性代碼,并非在所有設備上都執行的代碼,尤其是 Android 5 以上版本的設備。
- 不常用功能的代碼。
但是,從結果分布來看,應用程序中還是會存在很多不必要的代碼。R8 可以幫助我們快速,簡便,免費地刪除不必要的代碼,來縮小這部分的開銷。如果不這么做,我們也可以將應用打包成 Android App Bundle,這種格式只會使用特定設備所需的代碼和資源來運行應用。
總結
本文,我們分析了 Android 性能優化的五大誤區,但某些情況下數據的結果還并不清晰,我們需要做的就是在優化和修改代碼之前盡量做好性能測試。
目前,已經有很多工具可以幫助我們分析評估如何優化應用了,如 Android Studio 中的 profilers,它也提供了電池和網絡的監測功能。也可以用一些工具做更深入的探究,如 Perfetto 和 Systrace,這些工具會提供更加詳細的功能,例如在應用啟動或執行過程中發生的具體情況。
Jetpack Benchmark 摒棄了監測和基準測試的所有復雜操作,官方強烈建議我們在持續集成系統中使用它來跟蹤性能,并查看應用在添加功能的行為,最后需要注意的一點是,不要在 debug 模式下分析應用性能。
作者:Meandni
鏈接:https://juejin.im/post/6884030809515229198