前提:基于 Android API 30
1. 認識 SharedPreference
SharedPreference
是 Android 提供的輕量級的,線程安全的數據存儲機制,使用 key-value 鍵值對的方式將數據存儲在 xml 文件中,存儲路徑為
/data/data/yourPackageName/shared_prefs
存儲的數據是持久化(persistent)的,應用重啟后仍然可以獲取到存儲的數據。由于SharedPreference
是輕量級的,所以不適合存儲過多和過大的數據的場景,這種情況下應該考慮數據庫
2. 獲取 SharedPreference
SharedPreference
是一個接口,實現類是 android.app.SharedPreferencesImpl
,獲取 SharedPreference
的方法由 Context 提供
val sp = context.getSharedPreferences("FreemanSp", MODE_PRIVATE)
第一個參數是 name,無論哪個 context 對象,只要傳入的 name 一樣,獲取到的就是同一個SharedPreference
對象
第二個參數是 mode,Android 提供 4 中模式可選,其中三種已明確標識為過期
- MODE_PRIVATE,私有模式,只能由當前應用讀寫
- MODE_WORLD_READABLE,其他應用可讀,已過期
- MODE_WORLD_WRITEABLE,其他應用可寫,已過期
- MODE_MULTI_PROCESS,同一應用存在多進程情況下共享,不可靠,已過期
聲明 MODE_WORLD_READABLE
和 MODE_WORLD_WRITEABLE
這兩中模式,其他應用可以完全讀寫自己應用的數據,這是及其危險的,谷歌推薦使用 ContentProvier
方式去做數據共享,共享的數據可以完全由 provider 定義的 uri 做出限制。
甚至在 android N 后指定這兩種方式,應用直接報錯
private void checkMode(int mode) {if (getApplicationInfo().targetSdkVersion >= Build.VERSION_CODES.N) {if ((mode & MODE_WORLD_READABLE) != 0) {throw new SecurityException("MODE_WORLD_READABLE no longer supported");}if ((mode & MODE_WORLD_WRITEABLE) != 0) {throw new SecurityException("MODE_WORLD_WRITEABLE no longer supported");}}}
基于 mode 的限制,本文一切都已
MODE_PRIVATE
展開
創建 SharedPreferencesImpl 對象時,會起一個子線程,將 xml 文件解析的數據全量保存在內存中
private void startLoadFromDisk() {synchronized (mLock) {mLoaded = false;}new Thread("SharedPreferencesImpl-load") {public void run() {loadFromDisk();}}.start();}
如果存儲著大量數據時會有占用較大內存
3. 寫入數據
SharedPreference
通過調用edit()
方法獲取到一個Editor
對象,其實現類是android.app.SharedPreferencesImpl.EditorImpl
。寫入數據提供了同步 commit
和異步 apply
兩個提交方法
3.1 commit()
調用方式
sp.edit().putLong("currentTime", System.currentTimeMillis()).commit()
該方法是個同步方法,IDE 會出現警告提示
Commit your preferences changes back from this Editor to the SharedPreferences object it is editing. This atomically performs the requested modifications, replacing whatever is currently in the SharedPreferences.
Note that when two editors are modifying preferences at the same time, the last one to call commit wins.
If you don’t care about the return value and you’re using this from your application’s main thread, consider using apply instead
結論是如果不考慮返回結果或者在主線程,應該考慮用 apply
方法替代,原因來看下原碼
@Overridepublic boolean commit() {long startTime = 0;if (DEBUG) {startTime = System.currentTimeMillis();}// 先提交到內存MemoryCommitResult mcr = commitToMemory();// 寫入磁盤SharedPreferencesImpl.this.enqueueDiskWrite(mcr, null /* sync write on this thread okay */);try {// 當前線程 await,確保 mcr 已經提交到了磁盤mcr.writtenToDiskLatch.await();} catch (InterruptedException e) {return false;} finally {if (DEBUG) {Log.d(TAG, mFile.getName() + ":" + mcr.memoryStateGeneration+ " committed after " + (System.currentTimeMillis() - startTime)+ " ms");}}notifyListeners(mcr);return mcr.writeToDiskResult;}
再詳細看下 commitToMemory()
,這個方法的主要作用有兩個:
- 寫入內存
- 構造寫入磁盤的map,需要注意的是這個map是全量寫入的
private MemoryCommitResult commitToMemory() {long memoryStateGeneration;boolean keysCleared = false;List<String> keysModified = null; // 需要提交存儲的鍵值對Set<OnSharedPreferenceChangeListener> listeners = null;Map<String, Object> mapToWriteToDisk; // 需要寫入磁盤的鍵值對,這里會是整個 xml 文件的全量數據synchronized (SharedPreferencesImpl.this.mLock) {// We optimistically don't make a deep copy until// a memory commit comes in when we're already// writing to disk.if (mDiskWritesInFlight > 0) { // 多線程提交時,對 mMap 做深拷貝,這個 mMap 就是整個 xml 文件的全量鍵值對// We can't modify our mMap as a currently// in-flight write owns it. Clone it before// modifying it.// noinspection uncheckedmMap = new HashMap<String, Object>(mMap);}mapToWriteToDisk = mMap;// 同時要寫入磁盤的提交計數標記為 +1,如果同時有異步多個提交,后面的 commit 有可能會提交到子線程,并且由于版本控制,最后一個 commit 的才會寫入到磁盤mDiskWritesInFlight++; boolean hasListeners = mListeners.size() > 0;if (hasListeners) {keysModified = new ArrayList<String>();listeners = new HashSet<OnSharedPreferenceChangeListener>(mListeners.keySet());}synchronized (mEditorLock) {boolean changesMade = false;if (mClear) { // 只有在調用了 Editor#clear() 方法才會進入這段代碼,因為 mEditorLock 鎖if (!mapToWriteToDisk.isEmpty()) {changesMade = true;mapToWriteToDisk.clear();}keysCleared = true;mClear = false;}// 將用戶要修改的數據合并到 mapToWriteToDiskfor (Map.Entry<String, Object> e : mModified.entrySet()) {String k = e.getKey();Object v = e.getValue();// "this" is the magic value for a removal mutation. In addition,// setting a value to "null" for a given key is specified to be// equivalent to calling remove on that key.if (v == this || v == null) {if (!mapToWriteToDisk.containsKey(k)) {continue;}mapToWriteToDisk.remove(k);} else {if (mapToWriteToDisk.containsKey(k)) {Object existingValue = mapToWriteToDisk.get(k);if (existingValue != null && existingValue.equals(v)) {continue;}}mapToWriteToDisk.put(k, v);}changesMade = true;if (hasListeners) {keysModified.add(k);}}mModified.clear();if (changesMade) {mCurrentMemoryStateGeneration++;}// 寫入的版本管理,優化寫入磁盤的寫入次數,避免資源浪費和數據覆蓋memoryStateGeneration = mCurrentMemoryStateGeneration;}}return new MemoryCommitResult(memoryStateGeneration, keysCleared, keysModified,listeners, mapToWriteToDisk);}
寫入內存后接著就要執行寫入磁盤
SharedPreferencesImpl.this.enqueueDiskWrite(mcr, null /* sync write on this thread okay */);
enqueueDiskWrite
方法的主要作用是
- 同步寫入磁盤
- 如果此時有新的
commit
提交,舊提交將會被拋棄,新提交則有可能提交到子線程
private void enqueueDiskWrite(final MemoryCommitResult mcr,final Runnable postWriteRunnable) {final boolean isFromSyncCommit = (postWriteRunnable == null); // 同步commit,isFromSyncCommit = truefinal Runnable writeToDiskRunnable = new Runnable() {@Overridepublic void run() {synchronized (mWritingToDiskLock) {/** 真正執行寫入到磁盤的操作,但是 writeToFile 會有 memoryStateGeneration 控制,如果當前的 mcr 版本和 memoryStateGeneration 不一致,則當前的寫入會被對其* 這也是 commit 方法注釋上寫的:* Note that when two editors are modifying preferences at the same time, the last one to call commit wins*/ writeToFile(mcr, isFromSyncCommit);}synchronized (mLock) {mDiskWritesInFlight--;}if (postWriteRunnable != null) {postWriteRunnable.run();}}};// Typical #commit() path with fewer allocations, doing a write on// the current thread.if (isFromSyncCommit) { // apply 方法不會進入到這個代碼塊boolean wasEmpty = false;synchronized (mLock) {// 如果此時有新的 commit 執行到 commitToMemory ,則 mDiskWritesInFlight 則有可能大于 1,此時 wasEmpty = falsewasEmpty = mDiskWritesInFlight == 1;}if (wasEmpty) { // 如果 wasEmpty 為 true,則直接在當前線程執行寫入磁盤writeToDiskRunnable.run();return;}}// 將寫入磁盤的操作加入到子線程QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);}
總結
- 如果主線程執行
commit
會在主線程執行IO
操作,數據量太大可能會造成 ANR edit()
方法每次都會返回一個新的Editor
對象,應該緩存一個Editor
對象重復使用,SP
是線程安全的- 如果實在需要使用
commit
,可以考慮將頻繁寫入的key
單獨放到一個SP
,這樣避免資源浪費 - 盡量避免多次調用
commit
,可以多次put
數據,在合適實際執行一次commit
- 避免使用
commit
3.2 apply()
調用方式
sp.edit().putLong("currentTime", System.currentTimeMillis()).apply()
該方法是個異步方法,Android 中推薦使用這個方法存儲數據,直接看源碼
public void apply() {final long startTime = System.currentTimeMillis();final MemoryCommitResult mcr = commitToMemory(); // 寫入內存,構造寫入磁盤的map,同commit一樣,這個map是全量寫入的final Runnable awaitCommit = new Runnable() { // 這個 Runnable 的作用就是為了統計寫入磁盤的耗時@Overridepublic void run() {try {mcr.writtenToDiskLatch.await(); // block 待 mcr 寫入磁盤完成后 writtenToDiskLatch 降為0,await()立刻返回,awaitCommit 繼續執行} catch (InterruptedException ignored) {}if (DEBUG && mcr.wasWritten) {Log.d(TAG, mFile.getName() + ":" + mcr.memoryStateGeneration+ " applied after " + (System.currentTimeMillis() - startTime)+ " ms");}}};QueuedWork.addFinisher(awaitCommit); // 將 awaitCommit 加入到 QueuedWork 子線程的消息隊列// postWriteRunnable 會在寫入磁盤完成后觸發執行Runnable postWriteRunnable = new Runnable() {@Overridepublic void run() {awaitCommit.run(); // 觸發 awaitCommit ,統計寫入耗時QueuedWork.removeFinisher(awaitCommit);}};// postWriteRunnable 會在真正執行寫入磁盤的 runnable 調用SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);// Okay to notify the listeners before it's hit disk// because the listeners should always get the same// SharedPreferences instance back, which has the// changes reflected in memory.notifyListeners(mcr);
}
同commit
一樣,apply
也是先寫入內存后并構建全量寫入磁盤的map
。不同的是在調用 enqueueDiskWrite
時傳入了一個postWriteRunnable
,enqueueDiskWrite
通過判斷這個參數來區別是否是異步執行,該方法在commit
流程中已分析過一部分,這里再來看apply
的流程
private void enqueueDiskWrite(final MemoryCommitResult mcr,final Runnable postWriteRunnable) { // apply 傳入的 postWriteRunnable 不為 nullfinal boolean isFromSyncCommit = (postWriteRunnable == null); // isFromSyncCommit = falsefinal Runnable writeToDiskRunnable = new Runnable() {@Overridepublic void run() {synchronized (mWritingToDiskLock) {// 如果 memoryStateGeneration 一致,將完成磁盤的寫入writeToFile(mcr, isFromSyncCommit);}synchronized (mLock) {mDiskWritesInFlight--;}if (postWriteRunnable != null) {postWriteRunnable.run(); // 通知寫入完成}}};// Typical #commit() path with fewer allocations, doing a write on// the current thread.if (isFromSyncCommit) {boolean wasEmpty = false;synchronized (mLock) {wasEmpty = mDiskWritesInFlight == 1;}if (wasEmpty) {writeToDiskRunnable.run();return;}}// writeToDiskRunnable 直接加入子線程消息隊列QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);}
總結
edit()
方法每次都會返回一個新的Editor
對象,應該緩存一個Editor
對象重復使用,SP
是線程安全的- 多次
apply
也不會造成多次寫入磁盤,因為有版本控制 - 推薦使用
apply
4. SharedPreference 造成ANR的原因
- 在主線程
commit
寫入大量的數據 - 在 ActivityThread 共用四個地方會調用
QueuedWork.waitToFinish();
,分別是:
- handleServiceArgs
- handleStopService
- handlePauseActivity
- handleStopActivity
這些都是Android組件的生命周期管理,如果QueuedWork
有遺留的runnable
沒有執行完,則會將剩余的runnable
在主線程執行,導致產生了ANR的風險
解決辦法
- 避免使用
commit
,使用apply
- 將頻繁寫入的
key
放到獨立的SharedPreference
- 使用 MMKV 遷移或者代替
SharedPreference
5. MMKV 簡單分析
- mmkv 提供了 importFromSharedPreference 方法用來做數據遷移
- mmkv 不支持 getAll 方法,因為通過 protoBuf 二進制存取,在其他存取操作時都會指定數據類型,比如getInt, getBoolean。但是如果使用getAll,mmkv不知道每個key對應的數據類型,所以無法decode。為了能夠代理 getAll,并避免第三方sdk有使用該方法,可以將類型轉換上移,在使用mmkv存取時統一用 String 類型,再拿出值時再由業務自身去判斷(因為業務知道自己在寫入時,每個key對應的數據類型),SharedPreference 自身也只是返回了一個 Map<String, Object> 對象
- 支持多進程讀寫
- 每次都是增量寫入,本身不提供緩存。當寫入時,發現內存不夠會動態申請內存空間