本文已收錄到 AndroidFamily,技術和職場問題,請關注公眾號 [彭旭銳] 提問。
大家好,我是小彭。
SharedPreferences 是 Android 平臺上輕量級的 K-V 儲存框架,亦是初代 K-V 儲存框架,至今被很多應用沿用。
有的小夥伴會說,SharedPreferences 是舊時代的產物,現在已經有 DataStore 或 MMKV 等新時代的 K-V 框架,沒有學習意義。但我認為,雖然 SharedPreference 這個方案已經過時,但是並不意味著 SharedPreference 中使用的技術過時。做技術要知其然,更要知其所以然,而不是人云亦云,如果要你解釋為什麼 SharedPreferences 會過時,你能說到什麼程度?
不知道你最近有沒有讀到一本在技術圈非常火爆的一本新書 《安卓傳奇 · Android 締造團隊回憶錄》,其中就講了很多 Android 架構演進中設計者的思考。如果你平時也有從設計者的角度思考過 「為什麼」,那麼很多內容會覺得想到一塊去了,反之就會覺得無感。
小彭的 Android 交流群 02 群已經建立啦,公眾號回覆 「加群」 加入我們~
—— 圖片參照自電商平臺
今天,我們就來分析 SharedPreference 原始碼,在過程中依然可以學習到非常豐富的設計技巧。在後續的文章中,我們會繼續分析其他 K-V 儲存框架,請關注。
本文原始碼分析基於 Android 10(API 31),並關聯分析部分 Android 7.1(API 25)。
思維導圖:
在閱讀 SharedPreference 的原始碼之前,我們先思考一個 K-V 框架應該考慮哪些問題?
問題 1 - 執行緒安全: 由於程式一般會在多執行緒環境中執行,因此框架有必要保證多執行緒並行安全,並且優化並行效率;
問題 2 - 記憶體快取: 由於磁碟 IO 操作是耗時操作,因此框架有必要在業務層和磁碟檔案之間增加一層記憶體快取;
問題 3 - 事務: 由於磁碟 IO 操作是耗時操作,因此框架有必要將支援多次磁碟 IO 操作聚合為一次磁碟寫回事務,減少存取磁碟次數;
問題 4 - 事務序列化: 由於程式可能由多個執行緒發起寫回事務,因此框架有必要保證事務之間的事務序列化,避免先執行的事務覆蓋後執行的事務;
問題 5 - 非同步寫回: 由於磁碟 IO 是耗時操作,因此框架有必要支援後臺執行緒非同步寫回;
問題 6 - 增量更新: 由於磁碟檔案內容可能很大,因此修改 K-V 時有必要支援區域性修改,而不是全量覆蓋修改;
問題 7 - 變更回撥: 由於業務層可能有監聽 K-V 變更的需求,因此框架有必要支援變更回撥監聽,並且防止出現記憶體漏失;
問題 8 - 多程序: 由於程式可能有多程序需求,那麼框架如何保證多程序資料同步?
問題 9 - 可用性: 由於程式執行中存在不可控的異常和 Crash,因此框架有必要儘可能保證系統可用性,儘量保證系統在遇到異常後的資料完整性;
問題 10 - 高效性: 效能永遠是要考慮的問題,解析、讀取、寫入和序列化的效能如何提高和權衡;
問題 11 - 安全性: 如果程式需要儲存敏感資料,如何保證資料完整性和保密性;
問題 12 - 資料遷移: 如果專案中存在舊框架,如何將資料從舊框架遷移至新框架,並且保證可靠性;
問題 13 - 研發體驗: 是否模板程式碼冗長,是否容易出錯。
提出這麼多問題後:
你覺得學習 SharedPreferences 有沒有價值呢?
如果讓你自己寫一個 K-V 框架,你會如何解決這些問題呢?
新時代的 MMKV 和 DataStore 框架是否良好處理了這些問題?
SharedPreferences 採用 XML 檔案格式持久化鍵值對資料,檔案的儲存位置位於應用沙盒的內部儲存 /data/data/<packageName>/shared_prefs/
位置,每個 XML 檔案對應於一個 SharedPreferences 物件。
在 Activity、Context 和 PreferenceManager 中都存在獲取 SharedPreferences 物件的 API,它們最終都會走到 ContextImpl 中:
ContextImpl.java
class ContextImpl extends Context {
// 獲取 SharedPreferences 物件
@Override
public SharedPreferences getSharedPreferences(String name, int mode) {
// 後文詳細分析...
}
}
範例程式碼
SharedPreferences sp = getSharedPreferences("prefs", Context.MODE_PRIVATE);
// 建立事務
Editor editor = sp.edit();
editor.putString("name", "XIAO PENG");
// 同步提交事務
boolean result = editor.commit();
// 非同步提交事務
// editor.apply()
// 讀取資料
String blog = sp.getString("name", "PENG");
prefs.xml 檔案內容
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
<string name="name">XIAO PENG</string>
</map>
由於磁碟 IO 操作是耗時操作,如果每一次存取 SharedPreferences 都執行一次 IO 操作就顯得沒有必要,所以 SharedPreferences 會在業務層和磁碟之間增加一層記憶體快取。在 ContextImpl 類中,不僅支援獲取 SharedPreferencesImpl 物件,還負責支援 SharedPreferencesImpl 物件的記憶體快取。
ContextImpl 中的記憶體快取邏輯是相對簡單的:
兩個對映表:
繼續分析發現: 雖然 ContextImpl 實現了 SharedPreferencesImpl 物件的快取複用,但沒有實現快取淘汰,也沒有提供主動移除快取的 API。因此,在 APP 執行過程中,隨著存取的業務範圍越來越多,這部分 SharedPreferences 記憶體快取的空間也會逐漸膨脹。這是一個需要注意的問題。
在 getSharedPreferences() 中還有 MODE_MULTI_PROCESS 標記位的處理:
如果是首次獲取 SharedPreferencesImpl 物件會直接讀取磁碟檔案,如果是二次獲取 SharedPreferences 物件會複用記憶體快取。但如果使用了 MODE_MULTI_PROCESS 多程序模式,則在返回前會檢查磁碟檔案相對於最後一次記憶體修改是否變化,如果變化則說明被其他程序修改,需要重新讀取磁碟檔案,以實現多程序下的 「資料同步」。
但是這種同步是非常弱的,因為每個程序本身對磁碟檔案的寫回是非實時的,再加上如果業務層快取了 getSharedPreferences(…) 返回的物件,更感知不到最新的變化。所以嚴格來說,SharedPreferences 是不支援多程序的,官方也明確表示不要將 SharedPreferences 用於多程序環境。
SharedPreferences 記憶體快取示意圖
流程圖
ContextImpl.java
class ContextImpl extends Context {
// SharedPreferences 檔案根目錄
private File mPreferencesDir;
// <檔名 - 檔案>
@GuardedBy("ContextImpl.class")
private ArrayMap<String, File> mSharedPrefsPaths;
// 獲取 SharedPreferences 物件
@Override
public SharedPreferences getSharedPreferences(String name, int mode) {
// 1、檔名轉檔案物件
File file;
synchronized (ContextImpl.class) {
// 1.1 查詢對映表
if (mSharedPrefsPaths == null) {
mSharedPrefsPaths = new ArrayMap<>();
}
file = mSharedPrefsPaths.get(name);
// 1.2 快取未命中,建立 File 物件
if (file == null) {
file = getSharedPreferencesPath(name);
mSharedPrefsPaths.put(name, file);
}
}
// 2、獲取 SharedPreferences 物件
return getSharedPreferences(file, mode);
}
// -> 1.2 快取未命中,建立 File 物件
@Override
public File getSharedPreferencesPath(String name) {
return makeFilename(getPreferencesDir(), name + ".xml");
}
private File getPreferencesDir() {
synchronized (mSync) {
// 檔案目錄:data/data/[package_name]/shared_prefs/
if (mPreferencesDir == null) {
mPreferencesDir = new File(getDataDir(), "shared_prefs");
}
return ensurePrivateDirExists(mPreferencesDir);
}
}
}
檔案物件 to SP 物件:
ContextImpl.java
class ContextImpl extends Context {
// <包名 - Map>
// <檔案 - SharedPreferencesImpl>
@GuardedBy("ContextImpl.class")
private static ArrayMap<String, ArrayMap<File, SharedPreferencesImpl>> sSharedPrefsCache;
// -> 2、獲取 SharedPreferences 物件
@Override
public SharedPreferences getSharedPreferences(File file, int mode) {
SharedPreferencesImpl sp;
synchronized (ContextImpl.class) {
// 2.1 查詢快取
final ArrayMap<File, SharedPreferencesImpl> cache = getSharedPreferencesCacheLocked();
sp = cache.get(file);
// 2.2 未命中快取(首次獲取)
if (sp == null) {
// 2.2.1 檢查 mode 標記
checkMode(mode);
// 2.2.2 建立 SharedPreferencesImpl 物件
sp = new SharedPreferencesImpl(file, mode);
// 2.2.3 快取
cache.put(file, sp);
return sp;
}
}
// 3、命中快取(二次獲取)
if ((mode & Context.MODE_MULTI_PROCESS) != 0 ||
getApplicationInfo().targetSdkVersion < android.os.Build.VERSION_CODES.HONEYCOMB) {
// 判斷當前磁碟檔案相對於最後一次記憶體修改是否變化,如果時則重新載入檔案
sp.startReloadIfChangedUnexpectedly();
}
return sp;
}
// 根據包名獲取 <檔案 - SharedPreferencesImpl> 對映表
@GuardedBy("ContextImpl.class")
private ArrayMap<File, SharedPreferencesImpl> getSharedPreferencesCacheLocked() {
if (sSharedPrefsCache == null) {
sSharedPrefsCache = new ArrayMap<>();
}
final String packageName = getPackageName();
ArrayMap<File, SharedPreferencesImpl> packagePrefs = sSharedPrefsCache.get(packageName);
if (packagePrefs == null) {
packagePrefs = new ArrayMap<>();
sSharedPrefsCache.put(packageName, packagePrefs);
}
return packagePrefs;
}
...
}
在建立 SharedPreferencesImpl 物件時,建構函式會啟動一個子執行緒去讀取本地磁碟檔案,一次性將檔案中所有的 XML 資料轉化為 Map 雜湊表。
需要注意的是: 如果在執行 loadFromDisk()
解析檔案資料的過程中,其他執行緒呼叫 getValue 查詢資料,那麼就必須等待 mLock
鎖直到解析結束。
如果單個 SharedPreferences 的 .xml
檔案很大的話,就有可能導致查詢資料的執行緒被長時間被阻塞,甚至導致主執行緒查詢時產生 ANR。這也輔證了 SharedPreferences 只適合儲存少量資料,檔案過大在解析時會有效能問題。
讀取示意圖
SharedPreferencesImpl.java
// 目標檔案
private final File mFile;
// 備份檔案(後文詳細分析)
private final File mBackupFile;
// 模式
private final int mMode;
// 鎖
private final Object mLock = new Object();
// 讀取檔案標記位
@GuardedBy("mLock")
private boolean mLoaded = false;
SharedPreferencesImpl(File file, int mode) {
mFile = file;
mBackupFile = makeBackupFile(file);
mMode = mode;
mLoaded = false;
mMap = null;
mThrowable = null;
// 讀取並解析檔案資料
startLoadFromDisk();
}
private void startLoadFromDisk() {
synchronized (mLock) {
mLoaded = false;
}
// 子執行緒
new Thread("SharedPreferencesImpl-load") {
public void run() {
loadFromDisk();
}
}.start();
}
// -> 讀取並解析檔案資料(子執行緒)
private void loadFromDisk() {
synchronized (mLock) {
if (mLoaded) {
return;
}
// 1、如果存在備份檔案,則恢復備份資料(後文詳細分析)
if (mBackupFile.exists()) {
mFile.delete();
mBackupFile.renameTo(mFile);
}
}
Map<String, Object> map = null;
if (mFile.canRead()) {
// 2、讀取檔案
BufferedInputStream str = new BufferedInputStream(new FileInputStream(mFile), 16 * 1024);
// 3、將 XML 資料解析為 Map 對映表
map = (Map<String, Object>) XmlUtils.readMapXml(str);
IoUtils.closeQuietly(str);
}
synchronized (mLock) {
mLoaded = true;
if (map != null) {
// 使用解析的對映表
mMap = map;
} else {
// 建立空的對映表
mMap = new HashMap<>();
}
// 4、喚醒等待 mLock 鎖的執行緒
mLock.notifyAll();
}
}
static File makeBackupFile(File prefsFile) {
return new File(prefsFile.getPath() + ".bak");
}
查詢資料可能會阻塞等待:
SharedPreferencesImpl.java
public String getString(String key, @Nullable String defValue) {
synchronized (mLock) {
// 等待 mLoaded 標記位
awaitLoadedLocked();
// 查詢資料
String v = (String)mMap.get(key);
return v != null ? v : defValue;
}
}
private void awaitLoadedLocked() {
// 「檢查 - 等待」 模式
while (!mLoaded) {
try {
mLock.wait();
} catch (InterruptedException unused) {
}
}
}
是的,SharedPreferences 也有事務操作。
雖然 ContextImpl 中使用了記憶體快取,但是最終資料還是需要執行磁碟 IO 持久化到磁碟檔案中。如果每一次 「變更操作」 都對應一次磁碟 「寫回操作」 的話,不僅效率低下,而且沒有必要。
所以 SharedPreferences 會使用 「事務」 機制,將多次變更操作聚合為一個 「事務」,一次事務最多隻會執行一次磁碟寫回操作。雖然 SharedPreferences 原始碼中並沒有直接體現出 「Transaction」 之類的命名,但是這就是一種 「事務」 設計,與命名無關。
SharedPreferences 的事務操作由 Editor 介面實現。
SharedPreferences 物件本身只保留獲取資料的 API,而變更資料的 API 全部整合在 Editor 介面中。Editor 中會將所有的 putValue 變更操作記錄在 mModified
對映表中,但不會觸發任何磁碟寫回操作,直到呼叫 Editor#commit
或 Editor#apply
方法時,才會一次性以事務的方式發起磁碟寫回任務。
比較特殊的是:
this
指標作為特殊的移除標記位,後續將通過這個 Value 來判斷是移除鍵值對還是修改 / 新增鍵值對;mClear
標記位置位。可以看到: 在 Editor#commit 和 Editor#apply 方法中,首先都會呼叫 Editor#commitToMemery()
收集需要寫回磁碟的資料,並封裝為一個 MemoryCommitResult 事務物件,隨後就是根據這個事務物件的資訊寫回磁碟。
SharedPreferencesImpl.java
final class SharedPreferencesImpl implements SharedPreferences {
// 建立修改器物件
@Override
public Editor edit() {
// 等待磁碟檔案載入完成
synchronized (mLock) {
awaitLoadedLocked();
}
// 建立修改器物件
return new EditorImpl();
}
// 修改器
// 非靜態內部類(會持有外部類 SharedPreferencesImpl 的參照)
public final class EditorImpl implements Editor {
// 鎖物件
private final Object mEditorLock = new Object();
// 修改記錄(將以事務方式寫回磁碟)
@GuardedBy("mEditorLock")
private final Map<String, Object> mModified = new HashMap<>();
// 清除全部資料的標記位
@GuardedBy("mEditorLock")
private boolean mClear = false;
// 修改 String 型別鍵值對
@Override
public Editor putString(String key, @Nullable String value) {
synchronized (mEditorLock) {
mModified.put(key, value);
return this;
}
}
// 修改 int 型別鍵值對
@Override
public Editor putInt(String key, int value) {
synchronized (mEditorLock) {
mModified.put(key, value);
return this;
}
}
// 移除鍵值對
@Override
public Editor remove(String key) {
synchronized (mEditorLock) {
// 將 this 指標作為特殊的移除標記位
mModified.put(key, this);
return this;
}
}
// 清空鍵值對
@Override
public Editor clear() {
synchronized (mEditorLock) {
// 清除全部資料的標記位
mClear = true;
return this;
}
}
...
@Override
public void apply() {
// commitToMemory():寫回磁碟的資料並封裝事務物件
MemoryCommitResult mcr = commitToMemory();
// 同步寫回,下文詳細分析
}
@Override
public boolean commit() {
// commitToMemory():寫回磁碟的資料並封裝事務物件
final MemoryCommitResult mcr = commitToMemory();
// 非同步寫回,下文詳細分析
}
}
}
MemoryCommitResult 事務物件核心的欄位只有 2 個:
writeToFile()
中會過濾低於最新的記憶體版本的無效事務);SharedPreferencesImpl.java
private static class MemoryCommitResult {
// 記憶體版本
final long memoryStateGeneration;
// 需要全量覆蓋寫回磁碟的資料
final Map<String, Object> mapToWriteToDisk;
// 同步計數器
final CountDownLatch writtenToDiskLatch = new CountDownLatch(1);
@GuardedBy("mWritingToDiskLock")
volatile boolean writeToDiskResult = false;
boolean wasWritten = false;
// 後文寫回結束後呼叫
void setDiskWriteResult(boolean wasWritten, boolean result) {
this.wasWritten = wasWritten;
// writeToDiskResult 會作為 commit 同步寫回的返回值
writeToDiskResult = result;
// 喚醒等待鎖
writtenToDiskLatch.countDown();
}
}
下面,我們先來分析建立 Editor#commitToMemery() 中 MemoryCommitResult 事務物件的步驟,核心步驟分為 3 步:
首先,檢查 SharedPreferencesImpl#mDiskWritesInFlight
變數,如果 mDiskWritesInFlight == 0 則說明不存在並行寫回的事務,那麼 mapToWriteToDisk 就只會直接指向 SharedPreferencesImpl 中的 mMap
對映表。如果存在並行寫回,則會深拷貝一個新的對映表。
mDiskWritesInFlight
變數是記錄進行中的寫回事務數量記錄,每執行一次 commitToMemory() 建立事務物件時,就會將 mDiskWritesInFlight 變數會自增 1,並在寫回事務結束後 mDiskWritesInFlight 變數會自減 1。
其次,遍歷 mModified
對映表將所有的變更記錄(新增、修改或刪除)合併到 mapToWriteToDisk 中(此時,Editor 中的資料已經同步到記憶體快取中)。
這一步中的關鍵點是:如果發生有效修改,則會將 SharedPreferencesImpl 物件中的 mCurrentMemoryStateGeneration
最新記憶體版本自增 1,比最新記憶體版本小的事務會被視為無效事務。
最後,使用 mapToWriteToDisk 和 mCurrentMemoryStateGeneration 建立 MemoryCommitResult 事務物件。
事務示意圖
SharedPreferencesImpl.java
final class SharedPreferencesImpl implements SharedPreferences {
// 進行中事務計數(在提交事務是自增 1,在寫回結束時自減 1)
@GuardedBy("mLock")
private int mDiskWritesInFlight = 0;
// 記憶體版本
@GuardedBy("this")
private long mCurrentMemoryStateGeneration;
// 磁碟版本
@GuardedBy("mWritingToDiskLock")
private long mDiskStateGeneration;
// 修改器
public final class EditorImpl implements Editor {
// 鎖物件
private final Object mEditorLock = new Object();
// 修改記錄(將以事務方式寫回磁碟)
@GuardedBy("mEditorLock")
private final Map<String, Object> mModified = new HashMap<>();
// 清除全部資料的標記位
@GuardedBy("mEditorLock")
private boolean mClear = false;
// 獲取需要寫回磁碟的事務
private MemoryCommitResult commitToMemory() {
long memoryStateGeneration;
boolean keysCleared = false;
List<String> keysModified = null;
Set<OnSharedPreferenceChangeListener> listeners = null;
Map<String, Object> mapToWriteToDisk;
synchronized (SharedPreferencesImpl.this.mLock) {
// 如果同時存在多個寫回事務,則使用深拷貝
if (mDiskWritesInFlight > 0) {
mMap = new HashMap<String, Object>(mMap);
}
// mapToWriteToDisk:需要寫回的資料
mapToWriteToDisk = mMap;
// mDiskWritesInFlight:進行中事務自增 1
mDiskWritesInFlight++;
synchronized (mEditorLock) {
// changesMade:標記是否發生有效修改
boolean changesMade = false;
// 清除全部鍵值對
if (mClear) {
// 清除 mapToWriteToDisk 對映表(下面的 mModified 有可能重新增加鍵值對)
if (!mapToWriteToDisk.isEmpty()) {
changesMade = true;
mapToWriteToDisk.clear();
}
keysCleared = true;
mClear = false;
}
// 將 Editor 中的 mModified 修改記錄合併到 mapToWriteToDisk
// mapToWriteToDisk 指向 SharedPreferencesImpl 中的 mMap,所以記憶體快取越會被修改
for (Map.Entry<String, Object> e : mModified.entrySet()) {
String k = e.getKey();
Object v = e.getValue();
if (v == this /*使用 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();
// 如果發生有效修改,記憶體版本自增 1
if (changesMade) {
mCurrentMemoryStateGeneration++;
}
// 記錄當前的記憶體版本
memoryStateGeneration = mCurrentMemoryStateGeneration;
}
}
return new MemoryCommitResult(memoryStateGeneration, keysCleared, keysModified, listeners, mapToWriteToDisk);
}
}
}
步驟 2 - 合併變更記錄中,存在一種 「反直覺」 的 clear() 操作:
如果在 Editor 中存在 clear() 操作,並且 clear 前後都有 putValue 操作,就會出現反常的效果:如以下範例程式,按照直觀的預期效果,最終寫回磁碟的鍵值對應該只有
出現這個 「現象」 的原因是:SharedPreferences 事務中沒有保持 clear 變更記錄和 putValue 變更記錄的順序,所以 clear 操作之前的 putValue 操作依然會生效。
範例程式
getSharedPreferences("user", Context.MODE_PRIVATE).let {
it.edit().putString("name", "XIAOP PENG")
.clear()
.putString("age", "18")
.apply()
}
小結一下 3 個對映表的區別:
在獲得事務物件後,我們繼續分析 Editor 介面中的 commit 同步寫回策略和 apply 非同步寫回策略。
Editor#commit 同步寫回相對簡單,核心步驟分為 4 步:
commitToMemory()
建立 MemoryCommitResult
事務物件;enqueueDiskWrite(mrc, null)
提交磁碟寫回任務(在當前執行緒執行);commit 同步寫回示意圖
其實嚴格來說,commit 同步寫回也不絕對是在當前執行緒同步寫回,也有可能在後臺 HandlerThread 執行緒寫回。但不管怎麼樣,對於 commit 同步寫回來說,都會呼叫 CountDownLatch#await() 阻塞等待磁碟寫回完成,所以在邏輯上也等價於在當前執行緒同步寫回。
SharedPreferencesImpl.java
public final class EditorImpl implements Editor {
@Override
public boolean commit() {
// 1、獲取事務物件(前文已分析)
MemoryCommitResult mcr = commitToMemory();
// 2、提交磁碟寫回任務
SharedPreferencesImpl.this.enqueueDiskWrite(mcr, null /* 寫回成功回撥 */);
// 3、阻塞等待寫回完成
mcr.writtenToDiskLatch.await();
// 4、觸發回撥監聽器
notifyListeners(mcr);
return mcr.writeToDiskResult;
}
}
Editor#apply 非同步寫回相對複雜,核心步驟分為 5 步:
commitToMemory()
建立 MemoryCommitResult
事務物件;awaitCommit
Ruunnable 並提交到 QueuedWork 中。awaitCommit 中會呼叫 CountDownLatch#await() 阻塞等待磁碟寫回完成;postWriteRunnable
Runnable,在 run() 中會執行 awaitCommit 任務並將其從 QueuedWork 中移除;enqueueDiskWrite(mcr, postWriteRunnable)
提交磁碟寫回任務(在子執行緒執行);可以看到不管是呼叫 commit 還是 apply,最終都會呼叫 SharedPreferencesImpl#enqueueDiskWrite()
提交磁碟寫回任務。
區別在於:
postWriteRunnable
寫回結束的回撥物件,enqueueDiskWrite() 內部就是根據第 2 個引數來區分 commit 和 apply 策略。apply 非同步寫回示意圖
SharedPreferencesImpl.java
@Override
public void apply() {
// 1、獲取事務物件(前文已分析)
final MemoryCommitResult mcr = commitToMemory();
// 2、提交 aWait 任務
// 疑問:postWriteRunnable 可以理解,awaitCommit 是什麼?
final Runnable awaitCommit = new Runnable() {
@Override
public void run() {
// 阻塞執行緒直到磁碟任務執行完畢
mcr.writtenToDiskLatch.await();
}
};
QueuedWork.addFinisher(awaitCommit);
// 3、建立寫回成功回撥
Runnable postWriteRunnable = new Runnable() {
@Override
public void run() {
// 執行 aWait 任務
awaitCommit.run();
// 移除 aWait 任務
QueuedWork.removeFinisher(awaitCommit);
}
};
// 4、提交磁碟寫回任務,並繫結寫回成功回撥
SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable /* 寫回成功回撥 */);
// 5、觸發回撥監聽器
notifyListeners(mcr);
}
QueuedWork.java
// 提交 aWait 任務(後文詳細分析)
private static final LinkedList<Runnable> sFinishers = new LinkedList<>();
public static void addFinisher(Runnable finisher) {
synchronized (sLock) {
sFinishers.add(finisher);
}
}
public static void removeFinisher(Runnable finisher) {
synchronized (sLock) {
sFinishers.remove(finisher);
}
}
這裡有一個疑問:
在 apply() 方法中,在執行 enqueueDiskWrite() 前建立了 awaitCommit 任務並加入到 QueudWork 等待佇列,直到磁碟寫回結束才將 awaitCommit 移除。這個 awaitCommit 任務是做什麼的呢?
我們稍微再回答,先繼續往下走。
可以看到,不管是 commit 還是 apply,最終都會呼叫 SharedPreferencesImpl#enqueueDiskWrite() 提交寫回磁碟任務。雖然 enqueueDiskWrite() 還沒到真正呼叫磁碟寫回操作的地方,但確實建立了與磁碟 IO 相關的 Runnable 任務,核心步驟分為 4 步:
其中步驟 2 是真正執行磁碟 IO 的地方,邏輯也很好理解。不好理解的是,我們發現除了 「同步寫回而且不存在並行寫回事務」 這種特殊情況,其他情況都會交給 QueuedWork
再排程一次。
在通過 QueuedWork#queue
提交任務時,會將 writeToDiskRunnable 任務追加到 sWork 任務佇列中。如果是首次提交任務,QueuedWork 內部還會建立一個 HandlerThread
執行緒,通過這個子執行緒實現非同步的寫回任務。這說明 SharedPreference 的非同步寫回相當於使用了一個單執行緒的執行緒池,事實上在 Android 8.0 以前的版本中就是使用一個 singleThreadExecutor 執行緒池實現的。
提交任務示意圖
SharedPreferencesImpl.java
private void enqueueDiskWrite(final MemoryCommitResult mcr, final Runnable postWriteRunnable) {
// 1、根據是否有 postWriteRunnable 回撥區分是 commit 和 apply
final boolean isFromSyncCommit = (postWriteRunnable == null);
// 2、建立磁碟寫回任務
final Runnable writeToDiskRunnable = new Runnable() {
@Override
public void run() {
synchronized (mWritingToDiskLock) {
// 2.1 寫入磁碟檔案
writeToFile(mcr, isFromSyncCommit);
}
synchronized (mLock) {
// 2.2 mDiskWritesInFlight:進行中事務自減 1
mDiskWritesInFlight--;
}
if (postWriteRunnable != null) {
// 2.3 觸發寫回成功回撥
postWriteRunnable.run();
}
}
};
// 3、同步寫回且不存在並行寫回,則直接在當前執行緒
// 這就是前文提到 「commit 也不是絕對在當前執行緒同步寫回」 的原始碼出處
if (isFromSyncCommit) {
boolean wasEmpty = false;
synchronized (mLock) {
// 如果存在並行寫回的事務,則此處 wasEmpty = false
wasEmpty = mDiskWritesInFlight == 1;
}
// wasEmpty 為 true 說明當前只有一個執行緒在執行提交操作,那麼就直接在此執行緒上完成任務
if (wasEmpty) {
writeToDiskRunnable.run();
return;
}
}
// 4、交給 QueuedWork 排程(同步任務不可以延遲)
QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit /*是否可以延遲*/ );
}
@GuardedBy("mWritingToDiskLock")
private void writeToFile(MemoryCommitResult mcr, boolean isFromSyncCommit) {
// 稍後分析
}
QueuedWork 排程:
QueuedWork.java
@GuardedBy("sLock")
private static LinkedList<Runnable> sWork = new LinkedList<>();
// 提交任務
// shouldDelay:是否延遲
public static void queue(Runnable work, boolean shouldDelay) {
Handler handler = getHandler();
synchronized (sLock) {
// 入隊
sWork.add(work);
// 傳送 Handler 訊息,觸發 HandlerThread 執行任務
if (shouldDelay && sCanDelay) {
handler.sendEmptyMessageDelayed(QueuedWorkHandler.MSG_RUN, DELAY /* 100ms */);
} else {
handler.sendEmptyMessage(QueuedWorkHandler.MSG_RUN);
}
}
}
private static Handler getHandler() {
synchronized (sLock) {
if (sHandler == null) {
// 建立 HandlerThread 後臺執行緒
HandlerThread handlerThread = new HandlerThread("queued-work-looper", Process.THREAD_PRIORITY_FOREGROUND);
handlerThread.start();
sHandler = new QueuedWorkHandler(handlerThread.getLooper());
}
return sHandler;
}
}
private static class QueuedWorkHandler extends Handler {
static final int MSG_RUN = 1;
QueuedWorkHandler(Looper looper) {
super(looper);
}
public void handleMessage(Message msg) {
if (msg.what == MSG_RUN) {
// 執行任務
processPendingWork();
}
}
}
private static void processPendingWork() {
synchronized (sProcessingWork) {
LinkedList<Runnable> work;
synchronized (sLock) {
// 建立新的任務佇列
// 這一步是必須的,否則會與 enqueueDiskWrite 衝突
work = sWork;
sWork = new LinkedList<>();
// Remove all msg-s as all work will be processed now
getHandler().removeMessages(QueuedWorkHandler.MSG_RUN);
}
// 遍歷 ,按順序執行 sWork 任務佇列
if (work.size() > 0) {
for (Runnable w : work) {
w.run();
}
}
}
}
比較不理解的是:
同一個檔案的多次寫回序列化可以理解,對於多個檔案的寫回序列化意義是什麼,是不是可以用多執行緒來寫回多個不同的檔案?或許這也是 SharedPreferences 是輕量級框架的原因之一,你覺得呢?
現在我們可以回答 6.1 中遺留的問題:
在 apply() 方法中,在執行 enqueueDiskWrite() 前建立了 awaitCommit 任務並加入到 QueudWork 等待佇列,直到磁碟寫回結束才將 awaitCommit 移除。這個 awaitCommit 任務是做什麼的呢?
要理解這個問題需要管理分析到 ActivityThread 中的主執行緒訊息迴圈:
可以看到,在主執行緒的 Activity#onPause、Activity#onStop、Service#onStop、Service#onStartCommand 等生命週期狀態變更時,會呼叫 QueudeWork.waitToFinish():
ActivityThread.java
@Override
public void handlePauseActivity(...) {
performPauseActivity(r, finished, reason, pendingActions);
// Make sure any pending writes are now committed.
if (r.isPreHoneycomb()) {
QueuedWork.waitToFinish();
}
...
}
private void handleStopService(IBinder token) {
...
QueuedWork.waitToFinish();
ActivityManager.getService().serviceDoneExecuting(token, SERVICE_DONE_EXECUTING_STOP, 0, 0);
...
}
waitToFinish() 會執行所有 sFinishers 等待佇列中的 aWaitCommit 任務,主動等待所有磁碟寫回任務結束。在寫回任務結束之前,主執行緒會阻塞在等待鎖上,這裡也有可能發生 ANR。
主動等待示意圖
至於為什麼 Google 要在 ActivityThread 中部分生命週期中主動等待所有磁碟寫回任務結束呢?官方並沒有明確表示,結合頭條和抖音技術團隊的文章,我比較傾向於這 2 點解釋:
當然這兩個解釋並不全面,因為就算要求主動等待,也不能保證跨程序實時同步,也不能保證不產生 Crash。
抖音技術團隊觀點
QueuedWork.java
@GuardedBy("sLock")
private static Handler sHandler = null;
public static void waitToFinish() {
boolean hadMessages = false;
Handler handler = getHandler();
synchronized (sLock) {
if (handler.hasMessages(QueuedWorkHandler.MSG_RUN)) {
// Delayed work will be processed at processPendingWork() below
handler.removeMessages(QueuedWorkHandler.MSG_RUN);
}
// We should not delay any work as this might delay the finishers
sCanDelay = false;
}
// Android 8.0 優化:幫助子執行緒執行磁碟寫回
// 作用有限,因為 QueuedWork 使用了 sProcessingWork 鎖保證同一時間最多隻有一個執行緒在執行磁碟寫回
// 所以這裡應該是嘗試在主執行緒執行,可以提升執行緒優先順序
processPendingWork();
// 執行 sFinshers 等待佇列,等待所有寫回任務結束
try {
while (true) {
Runnable finisher;
synchronized (sLock) {
finisher = sFinishers.poll();
}
if (finisher == null) {
break;
}
// 執行 mcr.writtenToDiskLatch.await();
// 阻塞執行緒直到磁碟任務執行完畢
finisher.run();
}
} finally {
sCanDelay = true;
}
}
Android 7.1 QueuedWork 原始碼對比:
public static boolean hasPendingWork() {
return !sPendingWorkFinishers.isEmpty();
}
最終走到具體呼叫磁碟 IO 操作的地方了!
writeToFile() 的邏輯相對複雜一些了。經過簡化後,剩下的核心步驟只有 4 大步驟:
步驟 1:過濾無效寫回事務:
步驟 2:檔案備份:
步驟 3:全量覆蓋寫回磁碟:
步驟 4:後處理: 刪除寫至半途的無效檔案。
繼續分析發現,SharedPreference 的寫回操作並不是簡單的呼叫磁碟 IO,在保證 「可用性」 方面也做了一些優化設計:
如前文所述,commit 和 apply 都可能出現並行修改同一個檔案的情況,此時在連續修改同一個檔案的事務序列中,舊的事務是沒有意義的。為了過濾這些無意義的事務,在建立 MemoryCommitResult
事務物件時會記錄當時的 memoryStateGeneration
記憶體版本,而在 writeToFile() 中就會根據這個欄位過濾無效事務,避免了無效的 I/O 操作。
由於寫回檔案的過程存在不確定的異常(比如核心崩潰或者機器斷電),為了保證檔案的完整性,SharedPreferences 採用了檔案備份機制。在執行寫回操作之前,會先將舊檔案重新命名為 .bak
備份檔案,在全量覆蓋寫入新檔案後再刪除備份檔案。
如果寫回檔案失敗,那麼在後處理過程中會刪除寫至半途的無效檔案。此時磁碟中只有一個備份檔案,而真實檔案需要等到下次觸發寫回事務時再寫回。
如果直到應用退出都沒有觸發下次寫回,或者寫回的過程中 Crash,那麼在前文提到的建立 SharedPreferencesImpl 物件的構造方法中呼叫 loadFromDisk() 讀取並解析檔案資料時,會從備份檔案恢復資料。
在寫回檔案成功後,SharedPreference 會呼叫 FileUtils.sync()
強制作業系統將頁快取寫回磁碟。
寫回示意圖
SharedPreferencesImpl.java
// 記憶體版本
@GuardedBy("this")
private long mCurrentMemoryStateGeneration;
// 磁碟版本
@GuardedBy("mWritingToDiskLock")
private long mDiskStateGeneration;
// 寫回事務
private static class MemoryCommitResult {
// 記憶體版本
final long memoryStateGeneration;
// 需要全量覆蓋寫回磁碟的資料
final Map<String, Object> mapToWriteToDisk;
// 同步計數器
final CountDownLatch writtenToDiskLatch = new CountDownLatch(1);
// 後文寫回結束後呼叫
// wasWritten:是否有執行寫回
// result:是否成功
void setDiskWriteResult(boolean wasWritten, boolean result) {
this.wasWritten = wasWritten;
writeToDiskResult = result;
// 喚醒等待鎖
writtenToDiskLatch.countDown();
}
}
// 提交寫回事務
private void enqueueDiskWrite(final MemoryCommitResult mcr, final Runnable postWriteRunnable) {
...
// 建立磁碟寫回任務
final Runnable writeToDiskRunnable = new Runnable() {
@Override
public void run() {
synchronized (mWritingToDiskLock) {
// 2.1 寫入磁碟檔案
writeToFile(mcr, isFromSyncCommit);
}
synchronized (mLock) {
// 2.2 mDiskWritesInFlight:進行中事務自減 1
mDiskWritesInFlight--;
}
if (postWriteRunnable != null) {
// 2.3 觸發寫回成功回撥
postWriteRunnable.run();
}
}
};
...
}
// 寫回檔案
// isFromSyncCommit:是否同步寫回
@GuardedBy("mWritingToDiskLock")
private void writeToFile(MemoryCommitResult mcr, boolean isFromSyncCommit) {
boolean fileExists = mFile.exists();
// 如果舊檔案存在
if (fileExists) {
// 1. 過濾無效寫回事務
// 是否需要執行寫回
boolean needsWrite = false;
// 1.1 磁碟版本小於記憶體版本,才有可能需要寫回
// (只有舊檔案存在才會走到這個分支,但是舊檔案不存在的時候也可能存在無意義的寫回,
// 猜測官方是希望首次建立檔案的寫回能夠及時儘快執行,畢竟只有一個後臺執行緒)
if (mDiskStateGeneration < mcr.memoryStateGeneration) {
if (isFromSyncCommit) {
// 1.2 同步寫回必須寫回
needsWrite = true;
} else {
// 1.3 非同步寫回需要判斷事務物件的記憶體版本,只有最新的記憶體版本才有必要執行寫回
synchronized (mLock) {
if (mCurrentMemoryStateGeneration == mcr.memoryStateGeneration) {
needsWrite = true;
}
}
}
}
if (!needsWrite) {
// 1.4 無效的非同步寫回,直接結束
mcr.setDiskWriteResult(false, true);
return;
}
// 2. 檔案備份
boolean backupFileExists = mBackupFile.exists();
if (!backupFileExists) {
// 2.1 如果不存在備份檔案,則將舊檔案重新命名為備份檔案
if (!mFile.renameTo(mBackupFile)) {
// 備份失敗
mcr.setDiskWriteResult(false, false);
return;
}
} else {
// 2.2 如果存在備份檔案,則刪除無效的舊檔案(上一次寫回出並且後處理沒有成功刪除的情況)
mFile.delete();
}
}
try {
// 3、全量覆蓋寫回磁碟
// 3.1 開啟檔案輸出流
FileOutputStream str = createFileOutputStream(mFile);
if (str == null) {
// 開啟輸出流失敗
mcr.setDiskWriteResult(false, false);
return;
}
// 3.2 將 mapToWriteToDisk 對映表全量寫出
XmlUtils.writeMapXml(mcr.mapToWriteToDisk, str);
// 3.3 FileUtils.sync:強制作業系統將頁快取寫回磁碟
FileUtils.sync(str);
// 關閉輸出流
str.close();
ContextImpl.setFilePermissionsFromMode(mFile.getPath(), mMode, 0);
// 3.4 寫入成功,則刪除被封檔案(如果沒有走到這一步,在將來讀取檔案時,會重新恢復備份檔案)
mBackupFile.delete();
// 3.5 將磁碟版本記錄為當前記憶體版本
mDiskStateGeneration = mcr.memoryStateGeneration;
// 3.6 寫回結束(成功)
mcr.setDiskWriteResult(true, true);
return;
} catch (XmlPullParserException e) {
Log.w(TAG, "writeToFile: Got exception:", e);
} catch (IOException e) {
Log.w(TAG, "writeToFile: Got exception:", e);
}
// 在 try 塊中丟擲異常,會走到這裡
// 4、後處理:刪除寫至半途的無效檔案
if (mFile.exists()) {
if (!mFile.delete()) {
Log.e(TAG, "Couldn't clean up partially-written file " + mFile);
}
}
// 寫回結束(失敗)
mcr.setDiskWriteResult(false, false);
}
// -> 讀取並解析檔案資料
private void loadFromDisk() {
synchronized (mLock) {
if (mLoaded) {
return;
}
// 1、如果存在備份檔案,則恢復備份資料(後文詳細分析)
if (mBackupFile.exists()) {
mFile.delete();
mBackupFile.renameTo(mFile);
}
}
...
}
至此,SharedPreferences 核心原始碼分析結束。
SharedPreferences 還有其他細節值得學習。
SharedPreferences 是執行緒安全的,但它的執行緒安全並不是直接使用一個全域性的鎖物件,而是採用多種顆粒度的鎖物件實現 「鎖細化」 ,而且還貼心地使用了 @GuardedBy
註解標記欄位或方法所述的鎖級別。
使用 @GuardedBy 註解標記鎖級別
@GuardedBy("mLock")
private Map<String, Object> mMap;
物件鎖 | 功能呢 | 描述 |
---|---|---|
1、SharedPreferenceImpl#mLock | SharedPreferenceImpl 物件的全域性鎖 | 全域性使用 |
2、EditorImpl#mEditorLock | EditorImpl 修改器的寫鎖 | 確保多執行緒存取 Editor 的競爭安全 |
3、SharedPreferenceImpl#mWritingToDiskLock | SharedPreferenceImpl#writeToFile() 的互斥鎖 | writeToFile() 中會修改記憶體狀態,需要保證多執行緒競爭安全 |
4、QueuedWork.sLock | QueuedWork 的互斥鎖 | 確保 sFinishers 和 sWork 的多執行緒資源競爭安全 |
5、QueuedWork.sProcessingWork | QueuedWork#processPendingWork() 的互斥鎖 | 確保同一時間最多隻有一個執行緒執行磁碟寫回任務 |
SharedPreference 提供了 OnSharedPreferenceChangeListener 回撥監聽器,可以在主執行緒監聽鍵值對的變更(包含修改、新增和移除)。
SharedPreferencesImpl.java
@GuardedBy("mLock")
private final WeakHashMap<OnSharedPreferenceChangeListener, Object> mListeners =
new WeakHashMap<OnSharedPreferenceChangeListener, Object>();
SharedPreferences.java
public interface SharedPreferences {
public interface OnSharedPreferenceChangeListener {
void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key);
}
}
比較意外的是: SharedPreference 使用了一個 WeakHashMap 弱鍵雜湊表儲存監聽器,並且將監聽器物件作為 Key 物件。這是為什麼呢?
這是一種防止記憶體漏失的考慮,因為 SharedPreferencesImpl 的生命週期是全域性的(位於 ContextImpl 的記憶體快取),所以有必要使用弱參照防止記憶體漏失。想想也對,Java 標準庫沒有提供類似 WeakArrayList 或 WeakLinkedList 的容器,所以這裡將監聽器物件作為 WeakHashMap 的 Key,就很巧妙的複用了 WeakHashMap 自動清理無效資料的能力。
提示: 關於 WeakHashMap 的詳細分析,請閱讀小彭說 · 資料結構與演演算法 專欄文章 《WeakHashMap 和 HashMap 的區別是什麼,何時使用?》
在讀取和寫入檔案後記錄 mStatTimestamp 時間戳和 mStatSize 檔案大小,在檢查時檢查這兩個欄位是否發生變化
SharedPreferencesImpl.java
// 檔案時間戳
@GuardedBy("mLock")
private StructTimespec mStatTimestamp;
// 檔案大小
@GuardedBy("mLock")
private long mStatSize;
// 讀取檔案
private void loadFromDisk() {
...
mStatTimestamp = stat.st_mtim;
mStatSize = stat.st_size;
...
}
// 寫入檔案
private void writeToFile(MemoryCommitResult mcr, boolean isFromSyncCommit) {
...
mStatTimestamp = stat.st_mtim;
mStatSize = stat.st_size;
...
}
// 檢查檔案
private boolean hasFileChangedUnexpectedly() {
synchronized (mLock) {
if (mDiskWritesInFlight > 0) {
// If we know we caused it, it's not unexpected.
if (DEBUG) Log.d(TAG, "disk write in flight, not unexpected.");
return false;
}
}
// 讀取檔案 Stat 資訊
final StructStat stat = Os.stat(mFile.getPath());
synchronized (mLock) {
// 檢查修改時間和檔案大小
return !stat.st_mtim.equals(mStatTimestamp) || mStatSize != stat.st_size;
}
}
至此,SharedPreferences 全部原始碼分析結束。
可以看到,雖然 SharedPreferences 是一個輕量級的 K-V 儲存框架,但的確是一個完整的儲存方案。從原始碼分析中,我們可以看到 SharedPreferences 在讀寫效能、可用性方面都有做一些優化,例如:鎖細化、事務化、事務過濾、檔案備份等,值得細細品味。
在下篇文章裡,我們來盤點 SharedPreferences 中存在的 「缺點」,為什麼 SharedPreferences 沒有乘上新時代的船隻。請關注。