終于看懂了 SharedPreferences 的源碼實現

本文目錄:

  • 寫在前面
  • 獲取 SharedPreferences 實例
  • 加載 xml 數據文件
  • 初次讀取數據的耗時分析
  • commit 和 apply 的對比

寫在前面

SharedPreferences 平時用來持久化一些基本數據類型或者一些可序列化的對象。

根據我們日常的經常,持久化操作是耗時的,涉及到文件的 IO 操作,但是實際使用 SharedPreferences 時,發現只有第一次讀取數據是有概率卡主線程幾十到幾百毫秒,而之后的讀取時間幾乎可以忽略不計。

我們有了這樣的疑問:

  • 為什么初次讀取數據會有概率的阻塞?
  • 為什么除了初次讀取數據可能阻塞,而可以在后面的讀取很快?
  • 為什么都推薦使用 apply 而不是 commit 提交數據?

帶著問題去理解它的實現。

獲取 SharedPreferences 實例

SharedPreferences 是由 Context 返回的,比如我們的 Application,Activity。所以具體的實現每個應用的上下文環境有關,每個應用有自己的單獨的文件夾存放這些數據,對其他應用不可見。

獲取 SharedPreferences 的方法定義在抽象類 Context 中:

    public abstract SharedPreferences getSharedPreferences(String name, int mode);
    public abstract SharedPreferences getSharedPreferences(File file, int mode);

如果查看 Application 或者 Activity 的源碼,會找不到具體的實現。這是因為它們繼承了 ContextWrapper,代理模式,代理 ContextImpl 的實例 mBase 中。ContextImpl 是具體的實現。

兩種獲取 SharedPreferences 的方法中,我們基本上用的是 getSharedPreferences(String name, int mode); ,參數只傳了文件的名字。

查看內部的代碼可以看到,雖然只有一個名字,ContextImpl 會構建出文件的具體路徑。再接著調用 getSharedPreferences(File file, int mode); 方法返回 SharedPreferencesImpl 實例。

所以 SharedPreferences 的操作,本質上就是對文件的操作。最后會落實到一個 xml 文件上:

    @Override
    public File getSharedPreferencesPath(String name) {
        return makeFilename(getPreferencesDir(), name + ".xml");
    }

標準路徑在 /data/data/應用包名/shared_prefs 文件夾中,且都是 xml 文件。

SharedPreferences 文件.png

創建好 File 對象后,會在 getSharedPreferences(File file, int mode) 中打開文件并執行初始操作,把 SharedPreferencesImpl 實例返回:

    @Override
    public SharedPreferences getSharedPreferences(File file, int mode) {
        checkMode(mode);
        SharedPreferencesImpl sp;
        synchronized (ContextImpl.class) {
            final ArrayMap<File, SharedPreferencesImpl> cache = getSharedPreferencesCacheLocked();
            sp = cache.get(file);
            if (sp == null) {
                sp = new SharedPreferencesImpl(file, mode);
                cache.put(file, sp);
                return sp;
            }
        }
        if ((mode & Context.MODE_MULTI_PROCESS) != 0 ||
            getApplicationInfo().targetSdkVersion < android.os.Build.VERSION_CODES.HONEYCOMB) {
            sp.startReloadIfChangedUnexpectedly();
        }
        return sp;
    }

這里我們看到了另一個重要的類 SharedPreferencesImpl,它和 ContextImpl 一樣,是接口的具體實現類。

每一個 File 文件對應一個 SharedPreferencesImpl 實例。為了提高效率,ContextImpl 有做緩存 cache,這里的緩存是強引用,在整個進程的生命周期中都存在,意味著每個文件的 SharedPreferencesImpl 實例在整個進程中只會被創建一次。

這個方法的末尾有一個特殊的處理需要注意一下,是關于模式 Context.MODE_MULTI_PROCESS ,可以看到在這個模式下,會調用:

    sp.startReloadIfChangedUnexpectedly();

這個方法執行下去,會檢查文件是否被修改了,如果文件被修改了,會調用 startLoadFromDisk 來更新文件。因為多進程環境下,這里的文件有可能被其他進程修改。

加載 xml 數據文件

為什么除了初次讀取數據可能卡頓,而可以在后面的讀取很快?

我們進入 SharedPreferences 的加載流程,就是把文件的內容載入內存的過程。

載入文件的方法在 startLoadFromDisk 中,顧名思義,就是開始從磁盤加載數據。

調用該方法有兩個地方:

  • 構造函數里會被調用。所以第一次創建 SharedPreferencesImpl 會馬上把文件內容載入內存。
  • Context.MODE_MULTI_PROCESS 下,文件發生修改時被調用。目的就是多進程下更新數據。

startLoadFromDisk 方法如下:

    private void startLoadFromDisk() {
        synchronized (this) {
            mLoaded = false;
        }
        new Thread("SharedPreferencesImpl-load") {
            public void run() {
                loadFromDisk();
            }
        }.start();
    }

可以看到直接開啟一個新線程,調用 loadFromDisk 加載文件:

    private void loadFromDisk() {
        ...
        str = new BufferedInputStream(
                new FileInputStream(mFile), 16*1024);
        map = XmlUtils.readMapXml(str);
        ...
    }

本質上,就是讀取一個 xml 文件,被內容解析為 Map 對象。這個 map 包含了我們之前保存的所有鍵值對的數據。并且把 map對象保存為 mMap 成員變量,直接在內存中常駐:

    synchronized (SharedPreferencesImpl.this) {
        mLoaded = true;
        if (map != null) {
            mMap = map
            ...
        } else {
            mMap = new HashMap<>();
        }
        notifyAll();
    }

這里可以解釋我們的疑問,為什么 SharedPreferences 的讀取非常快,載入完成后,后面的讀操作都是針對 mMap 的,響應速度是內存級別的非常快。比如 getString:

    public String getString(String key, @Nullable String defValue) {
        synchronized (this) {
            awaitLoadedLocked();
            String v = (String)mMap.get(key);
            return v != null ? v : defValue;
        }
    }

我們也就可以理解為什么 SharedPreferences 不希望存大量數據了,一個很重要的原因也是內存緩存,如果數據量很大的話,這里會占據很大一塊內存

初次讀取數據的耗時分析

為什么初次讀取數據會有概率的阻塞?

對應用性能監控中發現,SharedPreferences 初次讀取數據的會發現概率發生阻塞,一般會被卡 20~40 ms。如果系統 IO 原本就繁忙的話,甚至可能會卡好幾秒。

所以在應用啟動中,我們去獲取一些配置,不得不在主線程對 SharedPreferences 進行初次操作。如果在短時間內讀取多個 不同的 SharedPreferences,應用的啟動會耗費很長的時間。

這和一個鎖有關,就是 SharePreferencesImpl.this。在初始化加載文件的時候,和讀取數據的時候都會用到這個鎖。

在 SharePreferencesImpl 構造中,調用 loadFromDisk ,加鎖保護了對 mLoad 和 mMap 的讀寫:

    private void loadFromDisk() {
        synchronized (SharedPreferencesImpl.this) {
            ...
        }
    }

而每次讀取數據的時候,也加了這個鎖去保護這些成員,比如 getString:

    public String getString(String key, @Nullable String defValue) {
        synchronized (this) {
            awaitLoadedLocked();
            String v = (String)mMap.get(key);
            return v != null ? v : defValue;
        }
    }

所以這里形成了一個競爭關系,如果在本地 xml 文件的加載過程中,先執行了 loadFromDisk,那么 getString 就會阻塞等待。

loadFromDisk 是 IO 耗時操作,雖然 loadFromDisk 操作被分配到另一個線程執行,但因為讀取數據的時候,爭用了這個鎖,會發生概率卡頓。

commit 和 apply 的對比

為什么都推薦使用 apply 而不是 commit 提交數據?

先看我們平時修改 SharedPreferences 的姿勢:

    SharedPreferences sp = context.getSharedPreferences("test", Mode.PRIVATE);
    Editor editor = sp.edit();
    editor.putString("key", "Hello World!");
    editor.commit(); 或者 editor.apply();

可以看到具體修改被它的內部類 EditorImpl 接管,最后才調用 commit 或者 apply,而這兩者的區別就是我們要討論的。

EditorImpl 內部有一個內存緩存,用來保存用戶修改后的操作:

    private final Map<String, Object> mModified = Maps.newHashMap();

在執行 commit 或者 apply 前,比如上面的 editor.putString("key","Hello World!") 會把修改存儲在 mModified 中:

    public Editor putString(String key, @Nullable String value) {
        synchronized (this) {
            mModified.put(key, value);
                return this;
            }
        }
    }

到這里,只是把修改緩存在了內存中。然后調用 commit 和 apply 把修改持久化。

這兩個方法都會調用一個 commitToMemory 方法,做兩件事情:

  • 一個是把修改提交到內存
  • 創建 MemoryCommitResult 用來做后面的本地 IO。

修改很簡單,就是遍歷 mModified,把修改的內容全部同步給 mMap。

    for (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 (!mMap.containsKey(k)) {
                continue;
            }
            mMap.remove(k);
        } else {
            if (mMap.containsKey(k)) {
                Object existingValue = mMap.get(k);
                if (existingValue != null && existingValue.equals(v)) {
                    continue;
                }
            }
            mMap.put(k, v);
        }

        mcr.changesMade = true;
        if (hasListeners) {
            mcr.keysModified.add(k);
        }
    }

而 MemoryCommitResult 是一個數據容器,記錄著一些后面進行磁盤寫入操作需要使用到的數據,比如有:

  • boolean changesMade ,標記變量,用來標記數據是否發生改變。
  • Map<?, ?> mapToWriteToDisk , 最終要寫入到本地的數據,會指向 SharedPreferencesImpl 的內存緩存 mMap

同步修改到 mMap

經過這個階段,內存的數據就被更新了。并創建好 MemoryCommitResult 對象后,接下來就是不一樣的操作。

先看 commit 方法:

    public boolean commit() {
        MemoryCommitResult mcr = commitToMemory();
        SharedPreferencesImpl.this.enqueueDiskWrite(
            mcr, null /* sync write on this thread okay */);
        try {
            mcr.writtenToDiskLatch.await();
        } catch (InterruptedException e) {
            return false;
        }
        notifyListeners(mcr);
        return mcr.writeToDiskResult;
    }

在調用 enqueueDiskWrite 的時候,因為沒有構建 postWriteRunnable,最終會在當前線程直接執行寫入操作:

    private void enqueueDiskWrite(final MemoryCommitResult mcr,
                                  final Runnable postWriteRunnable) {
        ...
        if (isFromSyncCommit) {
            boolean wasEmpty = false;
            synchronized (SharedPreferencesImpl.this) {
                wasEmpty = mDiskWritesInFlight == 1;
            }
            if (wasEmpty) {
                writeToDiskRunnable.run();
                return;
            }
        }
        ...
    }

直接調用 writeToDiskRunnable.run() 沒有再開線程,直接阻塞寫入。

apply 方法:

        public void apply() {
            final MemoryCommitResult mcr = commitToMemory();
            final Runnable awaitCommit = new Runnable() {
                    public void run() {
                        try {
                            mcr.writtenToDiskLatch.await();
                        } catch (InterruptedException ignored) {
                        }
                    }
                };

            QueuedWork.add(awaitCommit);

            Runnable postWriteRunnable = new Runnable() {
                    public void run() {
                        awaitCommit.run();
                        QueuedWork.remove(awaitCommit);
                    }
                };

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

可以看到先構造了一個 postWriteRunnable 傳入 enqueueDiskWrite。

在方法的執行中,可以看到最后會在一個單線程線程池 QueuedWork.singleThreadExecutor() 中執行寫入操作:

    private void enqueueDiskWrite(final MemoryCommitResult mcr,
                                  final Runnable postWriteRunnable) {
        ...
        QueuedWork.singleThreadExecutor().execute(writeToDiskRunnable);
    }

所以,commit 是阻塞的,apply 是非阻塞的。

平時使用的時候,盡量使用 apply 避免卡主主線程。因為寫入前都已經更新修改到緩存了,不用擔心讀到臟數據。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,461評論 6 532
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,538評論 3 417
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,423評論 0 375
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,991評論 1 312
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,761評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,207評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,268評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,419評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,959評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,782評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,983評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,528評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,222評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,653評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,901評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,678評論 3 392
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,978評論 2 374

推薦閱讀更多精彩內容