標籤:
原文Memory optimization for feeds on Android——[Udi Cohen](https://www.facebook.com/udinic)。
數以百萬的人在Android裝置上運行FaceBook,瀏覽著新聞,資訊,大事,頁面,朋友圈和他們關心的資訊。一個叫Android Feed Platform的團隊建立了一個新平台來提供這些資訊。因此任何對這個平台的最佳化,都惠及我們的應用。我們下面著重於滾動的效率,希望能在滿足人們求知慾的同時享受如絲順滑的體驗。
為了實現這個目標,我們做了幾個自動化工具來測試平台在不同情境和裝置上啟動並執行效能,以此衡量出代碼在運行時的記憶體使用量率,幀率等。當使用其中一個工具,TraceView,測試發現對Long.valueOf()有頻發的調用,使記憶體中堆積的對象過多,導致崩潰。這篇文章描述了如何解決這個問題,我們權衡的潛在的解決方案的過程,和我們對平台所做的最終最佳化。
方便的缺點
通過TraceView發現Long.valueOf()的頻發調用後,我們進一步地測試發現,在滾動新聞時,意料之外的是這個方法被更頻發地調用了。
當我們查看呼叫堆疊時,探索方法調用者不是Facebook的代碼而是編譯器的隱式代碼插入。調用這個方法用於把long裝箱成Long。Java既支援複雜類型,也支援基本類型(像integer,long等關鍵字),並提供無縫轉換。這個特性稱為自動裝箱,把一個基本類型轉化為對應的複雜資料類型。雖然是很便利的特性,同時也建立了對開發人員透明的對象。
這些未知對象佔用記憶體總和。
app的heap dump檢測出來Long對象佔用了很大一部分記憶體;但是每個對象本身並不大,數量之多令人咂舌。特別使用Dalvik時容易發生問題,不比新一代的Android運行時ART,擁有分代記憶體回收機制,能選擇更合適的時機回收數量眾多的小對象。當我們將新聞列表上下滾動時,大量對象被建立,垃圾收回策略會讓應用暫停,去清理記憶體。越多的對象堆積,記憶體回收就更為頻繁,導致應用卡頓甚至崩潰,給使用者留下劣質的體驗。
幸運的是,擁有TraceView和Allocation Tracker這樣的好工具,幫我們分析出卡頓的原因,問題處在自動裝箱上。我們發現大部分的操作,是把long插入到HashSet<Long>時發生的。(我們使用Hashset<Long>來儲存新聞的雜湊值,校正新聞是否唯一)。HashSet提供快速的對象擷取,因為雜湊值計算出來由long表示,然而HashSet<Long>是與複雜物件互動的,當我們調用setStories.put(lStoryHash)時,自動裝箱無可避免。
解決方案是,繼承Set,使其能與簡單類型互動,結果並未如我們想象那麼簡單。
可行的方案
存在一些與簡單類型互動的Set子類,這些庫幾乎都是十年前的當Java還是J2ME的年代。為了彰顯新時代的活力,我們決定在Dalvik/ART上進行測試,確保他們能在更苛刻的條件下表現良好。我們寫了個輕量級的測試架構,這些老庫將與HashSet一較高下。結果顯示這些老庫都比HashSet速度快,佔用記憶體少,但是它們內部還是會建立對象,例如TLongHashSet, Trove庫的一個類,用1000個例子測試大概分配了2MB記憶體。
其他測試庫,比如PCJ和Colt,結果幾乎一致。
已存在的輪子並不符合我們的需求,所以就為Android建立一個合適的Set輪子。看看HashSet的源碼實現,簡單地使用HashMap來實現複雜的功能。
public class HashSet<E> extends AbstractSet<E> implements Set<E>, ... { transient HashMap<E, HashSet<E>> backingMap; ... @Override public boolean add(E object) { return backingMap.put(object, this) == null; } @Override public boolean contains(Object object) { return backingMap.containsKey(object); } ... }
往HashSet裡添加對象意味這往HashMap裡面添加鍵和HashSet自己的執行個體作為值。HashSet通過內部HashMap是否包含相同的索引值來校正插入的對象。可以選擇使用Android最佳化過的Map來最佳化HashSet。
介紹LongArraySet
也許你很熟悉LongSparseArray,它是Android提供的一個以long為鍵的Map。可以這樣用:
LongSparseArray<String> longSparseArray = new LongSparseArray<>(); longSparseArray.put(3L, "Data"); String data = longSparseArray.get(3L); // the value of data is "Data"
LongSparseArray的不同於HashMap,當調用mapHashmap.get(KEY5),HashMap是這樣查詢的
整個取值的過程是,用索引值的雜湊值作為下標,在列表中擷取值。時間複雜度是O(1)。
而LongSparseArray取值是這樣的。
LongSparseArray通過二分尋找法在有序的鍵列表中找到鍵,時間複雜度是O(log N),通過鍵的下標在值隊列中取到對應值。
HashMap需要額外分配空間來避免衝突,導致定址變慢。LongSparseArray建立兩個列表使佔用記憶體小,但為了支援二分尋找法,需要一塊連續的記憶體空間。當添加的數量大於當前連續記憶體數時,需要一整塊新的連續記憶體。當總長度超過1000時,LongSparseArray的表現就不太理想啦,存在巨大的效能問題(參考官方文檔或者看Google的短視頻)
因為LongSparseArray的鍵是簡單類型的,我們可以創造一個新的資料結構,把LongSparseArray作為HashSet的內部類來使用。
就叫LongArraySet吧!
新的資料結構看起來是有希望的,但最佳化的第一條規則是“測試”。通過先前的測試架構,我們把新的資料結構與HashSet進行比對,通過添加X個item來測試,檢查它們內部的結構,隨即移除他們。在添加不同數量(X=10,X=100,X=1000….),同時平均下來每次的操作時間,結果是:
我們發現Contains和Remove操作在運行環境下都有效能提升。隨著數量的增加,Add操作耗費更多的時間。這個上面LongSparseArray內部結構造成的——當數量超過1000時,表現就比不上HashMap。在我們自己的應用中,只需要處理幾百個item,值得替換一下。
我們也看到記憶體使用量的提升,在看Heap和Allocate Tracker時,我們發現對象建立的數量減少了,下面是
HashSet和LongArraySet在20次迴圈中添加1000個item的對比結果。
LongArraySet能很好地避免建立Long對象,在這個情境下減少了30%的記憶體配置。
結論
通過深入瞭解其他資料結構,我們能夠建立出更適合自身的資料結構。記憶體回收啟動並執行次數越少,掉幀的情況發生就越少。使用新的資料結構LongArraySet,和以int為鍵的IntArraySet,就能最佳化整個應用的記憶體使用量。
這個例子表明任何可行的方法都需要衡量得出最優解。我們也接受這個方案並不是放之四海而皆準的,對於重量級資料而言,這個提升是有限的,但對於我們來說,是更優解。
你可以在這裡找到源碼,我們也為接下來面臨的挑戰而感到興奮,希望日後還有機會分享更多的乾貨。
[譯]FaceBook出品:基於Android的記憶體最佳化