解鎖 Android 性能優化的五大誤區和兩大疑點!

近年來,社區充斥著關于 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

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,461評論 6 532
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,538評論 3 417
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,423評論 0 375
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,991評論 1 312
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,761評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,207評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,268評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,419評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,959評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,782評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,983評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,528評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,222評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,653評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,901評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,678評論 3 392
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,978評論 2 374