Google在設計之初為了友善開發者,實作了一套輕量級的資料持久化方案---SharedPreference,因為其簡便的API,得到了開發者的青睐,對其依賴也越來越嚴重。随着版本疊代可以發現,越是重量級的應用,出現ANR的問題越來越嚴重。
SP導緻ANR原因分析
問題一:
sp檔案建立後,會單獨使用一個線程來加載解析對應的sp檔案,但是當UI線程嘗試通路sp中内容時,如果sp檔案還未被完全加載解析到記憶體,此時UI線程會被block,直到sp檔案被完全加載到記憶體中為止。具體ANR的情況如下:

主要原因是sp檔案未被加載或解析到記憶體中,此時無法直接使用sp提供的接口。sp被建立的時候會同時啟動一個線程加載對應的sp檔案,執行startLoadFromDisk():
SharedPreferencesImpl(File file,int mode){
mFile = file;
mBackupFile = makeBackupFile(file);
mMode = mode;
mLoaded = false;
mMap = null;
startLoadFromDisk();
}
在startLoadFromDisk()時,标記sp不可使用狀态,後期無論是嘗試讀資料還是寫資料,讀寫線程都會被block,直到sp檔案被全部加載解析完畢。
線程在讀或者寫時,都會走到awaitLoadedLocked()邏輯,在上圖的mLoaded為false即sp檔案尚未加解析到記憶體,此時讀寫線程會直接被block到mLock鎖上,直到loadFromDisk()方法執行完畢。
sp檔案完全加載解析到記憶體中,直接喚起所有在等待目前sp的讀寫線程。
問題二:
Google系統為了確定資料的跨程序完整性,前期應用可以使用sp來做跨程序通信,在元件銷毀或其他聲明周期的同時為了確定目前這個寫入任務必須在目前這個元件的生命周期完成寫入,此時主線程會在元件銷毀或者元件暫停的生命周期内等待sp完全寫入到對應的檔案中,如下圖UI線程被block在了QueuedWork.waitToFinish()處,接下來基于源碼從apply開始到最後寫入檔案整體流程梳理找出問題的根源。
具體需要等待檔案寫入的消息在ActivityThread的H中,具體消息類型如下:
public static final int PAUSE_ACTIVITY = 101;
由于Google官方設計之初是輕量級的資料存儲方式,這種等待行為不會有什麼問題,但是實際使用過程中由于sp過度使用,這個等待時間被不可控的拉長,直到最後出現ANR,這種問題越在業務繁重的應用上展現越明顯。具體堆棧問題如下,接下來從waitToFinish
入手,剖析這個ANR的根源,具體堆棧如下:
前期sp接口隻有commit接口,接口同步寫入檔案,這個接口直接影響開發者使用,于是Google官方對外提供了異步的apply接口,由于開發者認為這個異步是真正意義上的異步,大規模的使用sp的apply接口,就是這種apply的實作方式導緻了業務量大的App深受這個apply設計缺陷導緻的ANR問題影響。apply接口整體的設計思路如下圖(基于8.0及以下版本分析):
整體的設計思路如下:
- sp.apply(),寫入記憶體同時得到需要同步寫入檔案的資料集合MemoryCommitResult;
- 将MemoryCommitResult封裝成Runnable抛到子線程queued-work-looper中;
- 在子線程中将MemoryCommitResult中的mapToWriteDisk對應的key-value寫入到檔案中去;
- 檔案寫入完成以後,會執行MemoryCommitResult的setDiskWriteResult方法,關鍵的步驟writtenToDiskLatch.countDown()出現了;
- 如下當主線程中執行到QueuedWork.waitToFinish()的時候
public static void waitToFinish(){ Runnable toFinish; while((toFinish = sPendingWorkFinishers.poll()) != null){ toFinish.run(); } }
- 主線程到底在幹什麼,這個時候得從QueuedWork.add(Runnable finisher)入手,具體Runnable如下圖,這個地方就是啥也沒幹,直接等在了mcr.writtenToDiskLatch.await()上,就是步驟4中子線程在寫完檔案以後直接釋放的那個鎖
結論:盡管整體API的流程分析比較複雜,把一個runnable封裝了一層又一層,從這個線程抛到那個線程,子線程執行完寫入檔案以後會釋放鎖,主線程執行到某些地方得等待子線程把寫入檔案的行為執行完畢,但是整體思路還是比較簡單的。造成問題的原因就是太多pending的apply行為沒有寫入檔案,主線程在執行到指定消息的時候會有等待行為,等待時間過長就會出現ANR。盡管Google官方在Android 8.0及以後版本對sp寫入邏輯進行優化,期望是上述步驟6中UI線程不是傻傻的等,而是幫助子線程一起寫入,但是由于保守協助,并沒有很好的解決這個問題。final Runnable awaitCommit = new Runnable(){ public void run(){ try{ mcc.writtenToDiskLatch.await(); }catch(InterruptedException ignored){ } if(DEBUG && mcc.wasWritten){ ... } } }; QueueWork.addFinisher(awaitCommit);
問題一解決方案:
針對加載慢的問題,一般使用的比較多的是采用預加載的方式來觸發到這個sp檔案的加載和解析,這樣在真正使用的時候大機率sp已經加載解析完畢了,真正需要處理的是,核心場景的sp一定不能太大,輕量級的資料存儲方式不要存太多資料,避免前期加載耗時太久。
問題二解決方案:
Google當時為了從commit無縫的切換到apply,依然模拟原來的commit行為,隻是将原來的每次寫入檔案一次改成多次commit行為最後一次性apply在主線程等待所有的寫入行為一次性的全部寫入。但主線程等待子線程寫入根本沒有什麼意義,是以希望可以通過一些必要的手段跳過這種無用的等待行為,在研究了所有的SharedPrefence相關的邏輯後找到了以下入手點。以下是8.0以下版本的優化政策,8.0及以上版本處理方式類似:
public static void waitToFinish(){
Runnable toFinish;
while((toFinish = sPendingWorkFinishers.poll()) != null){
toFinish.run();
}
}
如果需要主線程在waitToFinish的時候直接跳過去,讓toFinish.run()執行完畢,顯然不可能,如果能讓sPendingWorkFinishers.poll()傳回為null,則這裡的等待行為就直接跳過去了,sPendingWorkFinishers是個ConcurrentLinkedQueue集合,可以直接動态代理這個集合,複寫poll方法,讓其永遠傳回null,這個時候UI永遠不會等待子線程寫入檔案完畢,事實證明,這種方式簡單有效。
針對這種寫入等待的ANR問題,還有一種就是全局替換寫入方式,通過插樁的方式,替換所有的API實作,采用其他 存儲方式,這種方式修複成本和風險比較大,但是後期可以随機的替換存儲方式,使用比較靈活。