1. 程式人生 > 實用技巧 >解鎖!95%的Android程式設計師做效能優化時,存在的五大誤區和兩大疑點!

解鎖!95%的Android程式設計師做效能優化時,存在的五大誤區和兩大疑點!

本文翻譯自:Busting Android performance myths,作者:Calin Juravle,譯文「Meandni」

近年來,社群充斥著關於 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 函式:

public class ToyClass {
   public int foo;
   public int getFoo() { return foo; }
}

ToyClass tc = new ToyClass();

直接使用 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 操作:

ArrayList<ToyClass> array = build();

int sum = array.stream().map(tc -> tc.foo).reduce(0, (a, b) -> a + b);

第一個 lambda 會將物件轉換為整數,第二個 lambda 會將產生的兩個值相加。

下面程式碼中,我們再將 lambda 表示式換成內部類:

ToyClassToInteger toyClassToInteger = new ToyClassToInteger();

SumOp sumOp = new SumOp();

int sum = array.stream().map(toyClassToInteger).reduce(0, sumOp);

這裡,有兩個內部類:一個是 toyClassToInteger,它可以將物件轉換為整數,第二個 SumOp 用來做求和運算。

從語法上看,第一個帶有 lambda 的示例顯然更優雅,也更易讀。那麼,效能差異又如何呢?我們再次在 Pixel 3 上使用了 Jetpack Benchmark,也沒有發現效能差異:

從圖中可以看到,我們還定義了單獨的外部 (top-level) 類一起來做比較,發現效能都沒有什麼差異,原因就是 lambda 表示式最終也會被轉換為匿名內部類。因此,為了程式碼的簡潔易讀,在這種場景下 lambda 表示式就是第一選擇。

誤區四:物件分配開銷過大,應該使用物件池

Android 內建了最先進的記憶體分配和垃圾回收機制,如下圖所示,幾乎每個版本的更新都在物件分配方面做各式各樣的更新。

各個版本之間的垃圾收集效能都有顯著的改善,如今,垃圾收集對應用程式的流暢已經幾乎沒有影響了。下圖展示了 Google 官方在 Android 10 中對具有分代併發收集的物件收集所做的改進,新版本的 Android 11 中也有明顯的改進。

在 GC 基準測試(例如 H2)中,吞吐量大幅提高了 170% 以上,而在實際應用(如 Google Sheets)中,吞吐量也提高了 68%。

如果認為垃圾收集效率低下並且記憶體分配負擔很重,那麼就相當於認為建立的垃圾越少,垃圾收集工作就越少,因此,代替每次使用時都建立新物件,我們可以維護一個經常使用的型別的物件池,然後從池中獲取已建立的物件,如下:

Pool<A> pool[] = new Pool<>[50];

void foo() {
   A a = pool.acquire();
   …
   pool.release(a);
}

這裡省略了程式碼細節,大體就是就是定義了一個 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 模式下分析應用效能。

【360°全方位效能調優】
效能優化相關所有知識點已經為你們整理成了PDF,從理論著手,利用大量實戰案例帶你全方位掌握APP效能優化的各個知識點和思路,需要的小夥伴點贊+關注後我的GitHub即可直接免費下載獲取~

感謝大家關注我,分享Android乾貨,交流Android技術。
對文章有何見解,或者有何技術問題,都可以在評論區一起留言討論,我會虔誠為你解答。
也歡迎大家來我的B站找我玩,有各類Android架構師進階技術難點的視訊講解,助你早日升職加薪。
B站直通車:https://space.bilibili.com/544650554