Android 初代 K-V 存儲框架 SharedPreferences,舊時代的餘暉?

前言

大家好,我是小彭。

SharedPreferences 是 Android 平臺上輕量級的 K-V 存儲框架,亦是初代 K-V 存儲框架,至今被很多應用沿用。

有的小夥伴會說,SharedPreferences 是舊時代的產物,現在已經有 DataStore 或 MMKV 等新時代的 K-V 框架,沒有學習意義。但我認爲,雖然 SharedPreference 這個方案已經過時,但是並不意味着 SharedPreference 中使用的技術過時。做技術要知其然,更要知其所以然,而不是人云亦云,如果要你解釋爲什麼 SharedPreferences 會過時,你能說到什麼程度?

不知道你最近有沒有讀到一本在技術圈非常火爆的一本新書 《安卓傳奇 · Android 締造團隊回憶錄》,其中就講了很多 Android 架構演進中設計者的思考。如果你平時也有從設計者的角度思考過 “爲什麼”,那麼很多內容會覺得想到一塊去了,反之就會覺得無感。


—— 圖片引用自電商平臺

今天,我們就來分析 SharedPreference 源碼,在過程中依然可以學習到非常豐富的設計技巧。在後續的文章中,我們會繼續分析其他 K-V 存儲框架,請關注。

本文源碼分析基於 Android 10(API 31),並關聯分析部分 Android 7.1(API 25)。


思維導圖:


1. 實現 K-V 框架應該思考什麼問題?

在閱讀 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 框架是否良好處理了這些問題?


2. 從 Sample 開始

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>

3. SharedPreferences 的內存緩存

由於磁盤 IO 操作是耗時操作,如果每一次訪問 SharedPreferences 都執行一次 IO 操作就顯得沒有必要,所以 SharedPreferences 會在業務層和磁盤之間增加一層內存緩存。在 ContextImpl 類中,不僅支持獲取 SharedPreferencesImpl 對象,還負責支持 SharedPreferencesImpl 對象的內存緩存。

ContextImpl 中的內存緩存邏輯是相對簡單的:

  • 步驟1:通過文件名 name 映射文件對應的 File 對象;
  • 步驟 2:通過 File 對象映射文件對應的 SharedPreferencesImpl 對象。

兩個映射表:

  • mSharedPrefsPaths: 緩存 “文件名 to 文件對象” 的映射;
  • sSharedPrefsCache: 這是一個二級映射表,第一級是包名到 Map 的映射,第二級是緩存 “文件對象 to SP 對象” 的映射。每個 XML 文件在內存中只會關聯一個全局唯一的 SharedPreferencesImpl 對象

繼續分析發現: 雖然 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;
    }
    ...
}

4. 讀取和解析磁盤文件

在創建 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) {
        }
    }
}

5. SharedPreferences 的事務機制

是的,SharedPreferences 也有事務操作。

雖然 ContextImpl 中使用了內存緩存,但是最終數據還是需要執行磁盤 IO 持久化到磁盤文件中。如果每一次 “變更操作” 都對應一次磁盤 “寫回操作” 的話,不僅效率低下,而且沒有必要。

所以 SharedPreferences 會使用 “事務” 機制,將多次變更操作聚合爲一個 “事務”,一次事務最多隻會執行一次磁盤寫回操作。雖然 SharedPreferences 源碼中並沒有直接體現出 “Transaction” 之類的命名,但是這就是一種 “事務” 設計,與命名無關。

5.1 MemoryCommitResult 事務對象

SharedPreferences 的事務操作由 Editor 接口實現。

SharedPreferences 對象本身只保留獲取數據的 API,而變更數據的 API 全部集成在 Editor 接口中。Editor 中會將所有的 putValue 變更操作記錄在 mModified 映射表中,但不會觸發任何磁盤寫回操作,直到調用 Editor#commitEditor#apply 方法時,纔會一次性以事務的方式發起磁盤寫回任務。

比較特殊的是:

  • 在 remove 方法中:會將 this 指針作爲特殊的移除標記位,後續將通過這個 Value 來判斷是移除鍵值對還是修改 / 新增鍵值對;
  • 在 clear 方法中:只是將 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 個:

  • memoryStateGeneration: 當前的內存版本(在 writeToFile() 中會過濾低於最新的內存版本的無效事務);
  • mapToWriteToDisk: 最終全量覆蓋寫回磁盤的數據。

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();
    }
}

5.2 創建 MemoryCommitResult 事務對象

下面,我們先來分析創建 Editor#commitToMemery() 中 MemoryCommitResult 事務對象的步驟,核心步驟分爲 3 步:

  • 步驟 1 - 準備映射表

首先,檢查 SharedPreferencesImpl#mDiskWritesInFlight 變量,如果 mDiskWritesInFlight == 0 則說明不存在併發寫回的事務,那麼 mapToWriteToDisk 就只會直接指向 SharedPreferencesImpl 中的 mMap 映射表。如果存在併發寫回,則會深拷貝一個新的映射表。

mDiskWritesInFlight 變量是記錄進行中的寫回事務數量記錄,每執行一次 commitToMemory() 創建事務對象時,就會將 mDiskWritesInFlight 變量會自增 1,並在寫回事務結束後 mDiskWritesInFlight 變量會自減 1。

  • 步驟 2 - 合併變更記錄

其次,遍歷 mModified 映射表將所有的變更記錄(新增、修改或刪除)合併到 mapToWriteToDisk 中(此時,Editor 中的數據已經同步到內存緩存中)。

這一步中的關鍵點是:如果發生有效修改,則會將 SharedPreferencesImpl 對象中的 mCurrentMemoryStateGeneration 最新內存版本自增 1,比最新內存版本小的事務會被視爲無效事務。

  • 步驟 3 - 創建事務對象

最後,使用 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 操作,就會出現反常的效果:如以下示例程序,按照直觀的預期效果,最終寫回磁盤的鍵值對應該只有 <age>,但事實上最終 <name> 和 <age> 兩個鍵值對都會被寫回磁盤。

出現這個 “現象” 的原因是:SharedPreferences 事務中沒有保持 clear 變更記錄和 putValue 變更記錄的順序,所以 clear 操作之前的 putValue 操作依然會生效。

示例程序

getSharedPreferences("user", Context.MODE_PRIVATE).let {
    it.edit().putString("name", "XIAOP PENG")
        .clear()
        .putString("age", "18")
        .apply()
}

小結一下 3 個映射表的區別:

  • 1、mMap 是 SharedPreferencesImpl 對象中記錄的鍵值對數據,代表 SharedPreferences 的內存緩存;
  • 2、mModified 是 Editor 修改器中記錄的鍵值對變更記錄;
  • 3、mapToWriteToDisk 是 mMap 與 mModified 合併後,需要全量覆蓋寫回磁盤的數據。

6. 兩種寫回策略

在獲得事務對象後,我們繼續分析 Editor 接口中的 commit 同步寫回策略和 apply 異步寫回策略。

6.1 commit 同步寫回策略

Editor#commit 同步寫回相對簡單,核心步驟分爲 4 步:

  • 1、調用 commitToMemory() 創建 MemoryCommitResult 事務對象;
  • 2、調用 enqueueDiskWrite(mrc, null) 提交磁盤寫回任務(在當前線程執行);
  • 3、調用 CountDownLatch#await() 阻塞等待磁盤寫回完成;
  • 4、調用 notifyListeners() 觸發回調監聽。

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;
    }
}

6.2 apply 異步寫回策略

Editor#apply 異步寫回相對複雜,核心步驟分爲 5 步:

  • 1、調用 commitToMemory() 創建 MemoryCommitResult 事務對象;
  • 2、創建 awaitCommit Ruunnable 並提交到 QueuedWork 中。awaitCommit 中會調用 CountDownLatch#await() 阻塞等待磁盤寫回完成;
  • 3、創建 postWriteRunnable Runnable,在 run() 中會執行 awaitCommit 任務並將其從 QueuedWork 中移除;
  • 4、調用 enqueueDiskWrite(mcr, postWriteRunnable) 提交磁盤寫回任務(在子線程執行);
  • 5、調用 notifyListeners() 觸發回調監聽。

可以看到不管是調用 commit 還是 apply,最終都會調用 SharedPreferencesImpl#enqueueDiskWrite() 提交磁盤寫回任務。

區別在於:

  • 在 commit 中 enqueueDiskWrite() 的第 2 個參數是 null;
  • 在 apply 中 enqueueDiskWrite() 的第 2 個參數是一個 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 任務是做什麼的呢?

我們稍微再回答,先繼續往下走。

6.3 enqueueDiskWrite() 提交磁盤寫回事務

可以看到,不管是 commit 還是 apply,最終都會調用 SharedPreferencesImpl#enqueueDiskWrite() 提交寫回磁盤任務。雖然 enqueueDiskWrite() 還沒到真正調用磁盤寫回操作的地方,但確實創建了與磁盤 IO 相關的 Runnable 任務,核心步驟分爲 4 步:

  • 步驟 1:根據是否有 postWriteRunnable 回調區分是 commit 和 apply;
  • 步驟 2:創建磁盤寫回任務(真正執行磁盤 IO 的地方):
    • 2.1 調用 writeToFile() 執行寫回磁盤 IO 操作;
    • 2.2 在寫回結束後對前文提到的 mDiskWritesInFlight 計數自減 1;
    • 2.3 執行 postWriteRunnable 寫回成功回調;
  • 步驟 3:如果是異步寫回,則提交到 QueuedWork 任務隊列;
  • 步驟 4:如果是同步寫回,則檢查 mDiskWritesInFlight 變量。如果存在併發寫回的事務,則也要提交到 QueuedWork 任務隊列,否則就直接在當前線程執行。

其中步驟 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.4 主動等待寫回任務結束

現在我們可以回答 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 點解釋:

  • 解釋 1 - 跨進程同步(主要): 爲了保證跨進程的數據同步,要求在組件跳轉前,確保當前組件的寫回任務必須在當前生命週期內完成;
  • 解釋 2 - 數據完整性: 爲了防止在組件跳轉的過程中可能產生的 Crash 造成未寫回的數據丟失,要求當前組件的寫回任務必須在當前生命週期內完成。

當然這兩個解釋並不全面,因爲就算要求主動等待,也不能保證跨進程實時同步,也不能保證不產生 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();
}

7. writeToFile() 姍姍來遲

最終走到具體調用磁盤 IO 操作的地方了!

7.1 寫回步驟

writeToFile() 的邏輯相對複雜一些了。經過簡化後,剩下的核心步驟只有 4 大步驟:

  • 步驟 1:過濾無效寫回事務:

    • 1.1 事務的 memoryStateGeneration 內存版本小於 mDiskStateGeneration 磁盤版本,跳過;
    • 1.2 同步寫回必須寫回;
    • 1.3 異步寫回事務的 memoryStateGeneration 內存版本版本小於 mCurrentMemoryStateGeneration 最新內存版本,跳過。
  • 步驟 2:文件備份:

    • 2.1 如果不存在備份文件,則將舊文件重命名爲備份文件;
    • 2.2 如果存在備份文件,則刪除無效的舊文件(上一次寫回出並且後處理沒有成功刪除的情況)。
  • 步驟 3:全量覆蓋寫回磁盤:

    • 3.1 打開文件輸出流;
    • 3.2 將 mapToWriteToDisk 映射表全量寫出;
    • 3.3 調用 FileUtils.sync() 強制操作系統頁緩存寫回磁盤;
    • 3.4 寫入成功,則刪除被封文件(如果沒有走到這一步,在將來讀取文件時,會重新恢復備份文件);
    • 3.5 將磁盤版本記錄爲當前內存版本;
    • 3.6 寫回結束(成功)。
  • 步驟 4:後處理: 刪除寫至半途的無效文件。

7.2 寫回優化

繼續分析發現,SharedPreference 的寫回操作並不是簡單的調用磁盤 IO,在保證 “可用性” 方面也做了一些優化設計:

  • 優化 1 - 過濾無效的寫回事務:

如前文所述,commit 和 apply 都可能出現併發修改同一個文件的情況,此時在連續修改同一個文件的事務序列中,舊的事務是沒有意義的。爲了過濾這些無意義的事務,在創建 MemoryCommitResult 事務對象時會記錄當時的 memoryStateGeneration 內存版本,而在 writeToFile() 中就會根據這個字段過濾無效事務,避免了無效的 I/O 操作。

  • 優化 2 - 備份舊文件:

由於寫回文件的過程存在不確定的異常(比如內核崩潰或者機器斷電),爲了保證文件的完整性,SharedPreferences 採用了文件備份機制。在執行寫回操作之前,會先將舊文件重命名爲 .bak 備份文件,在全量覆蓋寫入新文件後再刪除備份文件。

如果寫回文件失敗,那麼在後處理過程中會刪除寫至半途的無效文件。此時磁盤中只有一個備份文件,而真實文件需要等到下次觸發寫回事務時再寫回。

如果直到應用退出都沒有觸發下次寫回,或者寫回的過程中 Crash,那麼在前文提到的創建 SharedPreferencesImpl 對象的構造方法中調用 loadFromDisk() 讀取並解析文件數據時,會從備份文件恢復數據。

  • 優化 3 - 強制頁緩存寫回:

在寫回文件成功後,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 核心源碼分析結束。


8. SharedPreferences 的其他細節

SharedPreferences 還有其他細節值得學習。

8.1 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() 的互斥鎖 確保同一時間最多隻有一個線程執行磁盤寫回任務

8.2 使用 WeakHashMap 存儲監聽器

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 的區別是什麼,何時使用?》

8.3 如何檢查文件被其他進程修改?

在讀取和寫入文件後記錄 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 全部源碼分析結束。


9. 總結

可以看到,雖然 SharedPreferences 是一個輕量級的 K-V 存儲框架,但的確是一個完整的存儲方案。從源碼分析中,我們可以看到 SharedPreferences 在讀寫性能、可用性方面都有做一些優化,例如:鎖細化、事務化、事務過濾、文件備份等,值得細細品味。

在下篇文章裏,我們來盤點 SharedPreferences 中存在的 “缺點”,爲什麼 SharedPreferences 沒有乘上新時代的船隻。請關注。


參考資料

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章