今日頭條 ANR 優化實踐系列 - 告別 SharedPreference 等待

簡述

前面系列文章(詳見文末)中介紹了安卓系統 ANR 設計原理以及我們在實際工作中對 ANR 進行監控得到的方案,基於常規的監控治理方案,ANR 問題得到了有效的抑制,但是有些系統組件的設計初衷與開發人員在實際使用過程中實際使用的背離,導致的衝突問題亟待解決,當前文章針對實際開發過程中濫用 sp 導致的 ANR 問題,如何從系統層面跳過 Google 設計缺陷,規避 ANR 問題。

Google 在設計之初爲了方便開發者,實現了一套輕量級的數據持久化方案——SharedPreference(以下簡稱 sp),因爲其簡便的 API,方便的使用方式,得到開發者的青睞,對其依賴越來越重。在應用版本不斷迭代的過程中發現 Google 說的輕量級的數據存儲是有原因的,越是重量級的應用出現的 ANR 問題越嚴重。本文從源碼層面分析在加載和寫入過程中,導致 ANR 問題的原因以及相關的優化解決方案。

SP 導致 ANR 原因分析

經常會遇到兩類關於 SharedPreference 問題,以下分別介紹導致這兩類 ANR 問題的原因和優化方案。

問題一:sp 文件創建以後,會單獨的使用一個線程來加載解析對應的 sp 文件。但是當 UI 線程嘗試訪問 sp 中內容時,如果 sp 文件還未被完全加載解析到內存,此時 UI 線程會被 block,直到 SP 文件被完全加載到內存中爲止。具體 ANR 線程堆棧如下:

主要原因是 SP 文件未被加載或解析到內存中,此時無法直接使用 sp 提供的接口。sp 被創建的時候會同時啓動一個線程加載對應的 sp 文件,執行 startLoadFromDisk();

在 startLoadFromDisk() 時,標記 sp 不可使用狀態,後期無論是嘗試讀數據或者寫數據,讀寫線程都會被 block,直到 sp 文件被全部加載解析完畢。

線程在讀或寫時,都會走到 awaitLoadedLocked() 邏輯,在上圖的 mLoaded 爲 false 即 sp 文件尚未加載解析到內存,此時讀寫線程會直接被 block 到 mLock 鎖上,直到 loadFromDisk() 方法執行完畢。

sp 文件完全加載解析到內存中,直接喚起所有在等待在當前 sp 的讀寫線程。

問題二:Google 系統爲了確保數據的跨進程完整性,前期應用可以使用 sp 來做跨進程通信,在組件銷燬或其他生命週期的同時爲了確保當前這個寫入任務必須在當前這個組件的生命週期完成寫入,此時主線程會在組件銷燬或者組件暫停的生命週期內等待 sp 完全寫入到對應的文件中,如下圖 UI 線程被 block 在了 QueuedWork.waitToFinish() 處,接下來基於源碼從 apply 開始到最後寫入文件整體流程梳理找出問題根源。

具體需要等待文件寫入的消息在 AcitivtyThread 的 H 中,具體消息類型如下:

public static final int SERVICE_ARGS = 115;
public static final int STOP_SERVICE = 116;
public static final int PAUSE_ACTIVITY = 101;
public static final int STOP_ACTIVITY_SHOW = 103;
public static final int SLEEPING  = 137;

由於 Google 官方設計之初是輕量級的數據存儲方式,這種等待行爲不會有什麼問題,但是實際使用過程中由於 sp 過度使用,這個等待的時間被不可控的拉長,直到最後出現 ANR,這種問題越在業務繁重的應用上體現越明顯。具體問題堆棧如下,全是系統堆棧,接下來從 waitToFinish 入手分析,剖析這個 ANR 的根源。具體 ANR 堆棧如下:

前期 sp 接口只有 commit 接口,接口同步寫入文件,這個接口直接影響開發者使用,於是 Google 官方對外提供了異步的 apply 接口,由於開發者認爲這個異步是真正意義上的異步,大規模的使用 sp 的 appy 接口,就是這種 apply 的實現方式導致了業務量大的 APP 深受這個 apply 設計缺陷導致的 ANR 問題影響。

apply 接口整體的詳細設計思路如下圖(基於 Android8.0 及以下版本分析):

整體的思路簡單梳理如下:

  1. sp.apply(),寫入內存同時得到需要同步寫入文件的數據集合 MemoryCommitResult:

  1. 將 MemoryCommitResult 封裝成 Runnable 拋到子線程 queued-work-looper 中;

  2. 在子線程中將 MemoryCommitResult 中的 mapToWriteToDisk 對應的 key-value 寫入到文件中去;

  3. 文件寫入完成以後,會執行 MemoryCommitResult 的 setDiskWriteResult 方法,關鍵的步驟 writtenToDiskLatch.countDown() 出現了;

  4. 如下當主線中執行到 QueuedWork.waitToFinish() 的時候;

  1. 主線程到底在幹什麼,這個時候得從 QueuedWork.add(Runnable finisher) 入手,具體 Runnable 如下圖,這個地方就是啥也沒幹,直接等在了 mcr.writtenToDiskLatch.await() 上,這裏大家應該有點印象,就是步驟 4 中子線程在寫完文件以後直接釋放的那個鎖

結論: 儘管整體 API 的流程分析異常的複雜,把一個 runnable 封裝了一層又一層,從這個線程拋到那個線程,子線程執行完寫入文件以後會釋放鎖,主線程執行到某些地方得等待子線程把寫入文件的行爲執行完畢,但是整體的思路還是比較簡單的。造成這個問題的根源就是太多 pending 的 apply 行爲沒有寫入到文件,主線程在執行到指定消息的時候會有等待行爲,等待時間過長就會出現 ANR。

儘管 Google 官方在 Android 8.0 及以後版本對 sp 寫入邏輯進行優化,期望是在上述步驟 6 中 UI 線程不是傻傻的等,而是幫助子線程一起寫入,但是由於是保守協助,並沒有很好的解決這個問題。

解決方案

**問題一:**針對加載很慢的問題,一般使用的比較多的是採用預加載的方式來觸發到這個 sp 文件的加載和解析,這樣在真正使用的時候大概率 sp 已經加載解析完畢了;真正需要處理的是核心場景的 sp 一定不能太大,Google 官方的聲明還是有必要遵守一下,輕量級的數據持久化存儲方式,不要存太多數據,避免文件過大,導致前期的加載解析耗時過久。

**問題二:**至於 Google 爲什麼要這麼設計,提出了自己的幾個猜想:

  1. Google 希望做到數據可能儘可能及時的寫入文件,但是這樣等待沒有任何意義,主線程直接等待並不會提升寫入的效率;

  2. 期望 sp 實時寫入文件,以方便跨進程的時候可以實時的訪問到 sp 內的文件,這種異步寫入方式本身就沒辦法確保實時性;

  3. 可能是在組件發生狀態切換的時候,這個時候如果進程內沒有啥組件,進程的優先級可能降低,存在進程會在系統資源喫緊的時候被系統幹掉,這種概率極低,幾乎可以忽略不計;

  4. 感覺最大的可能性就是 Google 官方當時是爲了從 commit 無縫的切換到 apply,依然模擬原來的 commit 行爲,只是將原來的每次寫入文件一次改成多次 commit 行爲最後一次性 apply 在主線程等待所有的寫入行爲一次性全部寫入。

通過以上假設,發現這裏的主線程等待子線程寫入根本沒有什麼意義,因此希望可以通過一些必要的手段跳過這種無用的等待行爲,在研究了所有的 SharedPreference 相關的邏輯後找到以下入手點。以下是 8.0 以下版本的優化策略,8.0 及以上版本處理方式類似:

如果需要主線程在 waitToFinish 的時候直接跳過去,讓 toTinish.run() 執行完畢,顯然不可能,如果能讓 sPendingWorkFinishers.poll() 返回爲 null,則這裏的等待行爲直接就跳過去了,sPendingWorkFinishers 是個 ConcurrentLinkedQueue 集合,可以直接動態代理這個集合,複寫 poll 方法,讓其永遠返回 null,這個時候 UI 永遠不會等待子線程寫入文件完畢。事實證明這種方式簡單有效。

針對這種寫入等待的 ANR 問題,還有一種就是全局替換寫入方式,通過插樁的方式,替換所有的 API 實現,採用其他的存儲方式,這種方式修復成本和風險較大,但是後期可以隨機的替換存儲方式,使用比較靈活。

方案收益

通過在字節系多個產品的驗證,方案穩定有效,相應堆棧導致的 ANR 問題消滅殆盡,ANR 收益明顯,相應的界面跳轉等場景流暢性得到了明顯的改善。

展望

Google 新增加了一個新 Jetpack 的成員 DataStore,主要用來替換 SharedPreferences, DataStore 應該是開發者期待已久的庫,DataStore 是基於 Flow 實現的,一種新的數據存儲方案。詳細介紹網上有很多參考資料。

優化實踐更多參考

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/kfF83UmsGM5w43rDCH544g