Android 資料儲存知識梳理(3) SharedPreference 原始碼解析

澤毛發表於2017-12-21

一、概述

SharedPreferences在開發當中常被用作儲存一些類似於配置項這類輕量級的資料,它採用鍵值對的格式,將資料儲存在xml檔案當中,並儲存在data/data/{應用包名}/shared_prefs下:

Android 資料儲存知識梳理(3)   SharedPreference 原始碼解析
今天我們就來一起研究一下SP的實現原理。

二、SP 原始碼解析

2.1 獲取 SharedPreferences 物件

在通過SP進行讀寫操作時,首先需要獲得一個SharedPreferences物件,SharedPreferences是一個介面,它定義了系列讀寫的介面,其實現類為SharedPreferencesImpl、在實際過程中,我們一般通過Application、Activity、Service的下面這個方法來獲取SP物件:

public SharedPreferences getSharedPreferences(String name, int mode)
複製程式碼

來獲取SharedPreferences例項,而它們最終都是呼叫到ContextImplgetSharedPreferences方法,下面是整個呼叫的結構:

Android 資料儲存知識梳理(3)   SharedPreference 原始碼解析
ContextImpl當中,SharedPreferences是以一個靜態雙重ArrayMap的結構來儲存的:

private static ArrayMap<String, ArrayMap<String, SharedPreferencesImpl>> sSharedPrefs;
複製程式碼

下面,我們看一下獲取SP例項的過程:

    public SharedPreferences getSharedPreferences(String name, int mode) {
        SharedPreferencesImpl sp;
        synchronized (ContextImpl.class) {
            if (sSharedPrefs == null) {
                sSharedPrefs = new ArrayMap<String, ArrayMap<String, SharedPreferencesImpl>>();
            }
            //1.第一個維度是包名.
            final String packageName = getPackageName();
            ArrayMap<String, SharedPreferencesImpl> packagePrefs = sSharedPrefs.get(packageName);
            if (packagePrefs == null) {
                packagePrefs = new ArrayMap<String, SharedPreferencesImpl>();
                sSharedPrefs.put(packageName, packagePrefs);
            }
            //2.第二個維度就是呼叫get方法時傳入的name,並且如果已經存在了那麼直接返回
            sp = packagePrefs.get(name);
            if (sp == null) {
                File prefsFile = getSharedPrefsFile(name);
                sp = new SharedPreferencesImpl(prefsFile, mode);
                packagePrefs.put(name, sp);
                return sp;
            }
        }

        return sp;
    }
複製程式碼

在上面,我們看到SharedPreferencesImpl的構造傳入了一個和name相關聯的File,它就是我們在第一節當中所說的xml檔案,在建構函式中,會去預先讀取這個xml檔案當中的內容:

SharedPreferencesImpl(File file, int mode) {
        //..
        startLoadFromDisk(); //讀取xml檔案的內容
}
複製程式碼

這裡啟動了一個非同步的執行緒,需要注意的是這裡會將標誌位mLoad置為false,後面我們會談到這個標誌的作用:

    private void startLoadFromDisk() {
        synchronized (this) {
            mLoaded = false;
        }
        new Thread("SharedPreferencesImpl-load") {
            public void run() {
                synchronized (SharedPreferencesImpl.this) {
                    loadFromDiskLocked();
                }
            }
        }.start();
    }
複製程式碼

loadFromDiskLocked中,將xml檔案中的內容儲存到Map當中,在讀取完畢之後,喚醒之前有可能阻塞的讀寫執行緒:

    private Map<String, Object> mMap;

    private void loadFromDiskLocked() {
        //1.如果已經在載入,那麼返回.
        if (mLoaded) {
            return;
        }

        //...
        //2.最終儲存到map當中
        map = XmlUtils.readMapXml(str);
        mMap = map;

        //...
        //3.由於讀寫操作只有在mLoaded變數為true時才可進行,因此它們有可能阻塞在呼叫讀寫操作的方法上,因此這裡需要喚醒它們。
        notifyAll();
    }
複製程式碼

SP物件的獲取過程來看,我們可以得出下面幾個結論:

  • 與某個name所對應的SP物件需要等到呼叫getSharedPreferences才會被建立
  • 對於同一程式而言,在Activity/Application/Service獲取SP物件時,如果name相同,它們實際上獲取到的是同一個SP物件
  • 由於使用的是靜態容器來儲存,因此即使Activity/Service銷燬了,它之前建立的SP物件也不會被釋放,而SP中的資料又是用Map來儲存的,也就是說,我們只要呼叫了某個name相關聯的getSharedPreferences方法,那麼和該name對應的xml檔案中的資料都會被讀到記憶體當中,並且一直到程式被結束。

2.2 通過 SharedPreferences 進行讀取操作

讀取的操作很簡單,它其實就是從之間預先讀取的mMap當中去取出對應的資料,以getBoolean為例:

    public boolean getBoolean(String key, boolean defValue) {
        synchronized (this) {
            awaitLoadedLocked();
            Boolean v = (Boolean)mMap.get(key);
            return v != null ? v : defValue;
        }
    }
複製程式碼

這裡唯一需要關心的是awaitLoadedLocked方法:

    private void awaitLoadedLocked() {
        //這裡如果判斷沒有載入完畢,那麼會進入無限等待狀態
        while (!mLoaded) {
            try {
                wait();
            } catch (InterruptedException unused) {}
        }
    }
複製程式碼

在這個方法中,會去檢查mLoaded標誌位是否為true,如果不為true,那麼說明沒有載入完畢,該執行緒會釋放它所持有的鎖,進入等待狀態,直到loadFromDiskLocked載入完xml檔案中的內容呼叫notifyAll()後,該執行緒才被喚醒。

從讀取操作來看,我們可以得出以下兩個結論:

  • 任何時刻讀取操作,讀取的都是記憶體中的值,而並不是xml檔案的值。
  • 在呼叫讀取方法時,如果建構函式中的預讀取執行緒沒有執行完畢,那麼將會導致讀取的執行緒進入等待狀態。

2.3 通過 SharedPreferences 進行寫入操作

2.3.1 獲取 EditorImpl

當我們需要通過SharedPreferences寫入資訊時,那麼首先需要通過.edit()獲得一個Editor物件,這裡和讀取操作類似,都是需要等到預載入的執行緒執行完畢:

    public Editor edit() {
        synchronized (this) {
            awaitLoadedLocked();
        }
        return new EditorImpl();
    }
複製程式碼

Editor的實現類為EditorImpl,以putString為例:

    public final class EditorImpl implements Editor {

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

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

由上面的程式碼可以看出,當我們呼叫EditorputXXX方法時,實際上並沒有儲存到SPmMap當中,而僅僅是儲存到通過.edit()返回的EditorImpl的臨時變數當中。

2.3.2 apply 和 commit 方法

我們通過editor寫入的資料,最終需要等到呼叫editorapplycommit方法,才會寫入到記憶體和xml這兩個地方。

(a) apply

下面,我們先看比較常用的apply方法:

        public void apply() {
            //1.將修改操作提交到記憶體當中.
            final MemoryCommitResult mcr = commitToMemory();
           
            //2.寫入檔案當中
            SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable); //postWriteRunnable在寫入檔案完成後進行一些收尾操作.
            
            //3.只要寫入到記憶體當中,就通知監聽者.
            notifyListeners(mcr);
        }
複製程式碼

整個apply分為三個步驟:

  • 通過commitToMemory寫入到記憶體中
  • 通過enqueueDiskWrite寫入到磁碟中
  • 通知監聽者

其中第一個步驟很好理解,就是根據editor中的內容,確定哪些是需要更新的資料,然後把SP當中的mMap變數進行更新,之後將變化的內容封裝成MemoryCommitResult結構體。

我們主要看一下第二步,是如何寫入磁碟當中的:

    private void enqueueDiskWrite(final MemoryCommitResult mcr,
                                  final Runnable postWriteRunnable) {
        //1.寫入磁碟任務的runnable.
        final Runnable writeToDiskRunnable = new Runnable() {
                public void run() {
                    //1.1 寫入磁碟
                    synchronized (mWritingToDiskLock) {
                        writeToFile(mcr);
                    }
                    //....執行收尾操作.
                    if (postWriteRunnable != null) {
                        postWriteRunnable.run();
                    }
                }
            };
        
        //2.這裡如果是通過apply方法呼叫過來的,那麼為false
        final boolean isFromSyncCommit = (postWriteRunnable == null);

        if (isFromSyncCommit) { //apply 方法不走這裡
                //...
                writeToDiskRunnable.run();
                return;
        }

        QueuedWork.singleThreadExecutor().execute(writeToDiskRunnable);
    }
複製程式碼

可以看出,如果呼叫apply方法,那麼對於xml檔案的寫入是在非同步執行緒當中進行的。

(b) commit

如果呼叫的commit方法,那麼執行的是如下操作:

       public boolean commit() {
            //1.寫入記憶體
            MemoryCommitResult mcr = commitToMemory();
            //2.寫入檔案
            SharedPreferencesImpl.this.enqueueDiskWrite(mcr, null); //由於是同步進行,所以把收尾操作放到Runnable當中.
            //在這裡執行收尾操作..
            //3.通知監聽
            notifyListeners(mcr);
            return mcr.writeToDiskResult;
        }
複製程式碼

當使用commit方法時,和apply類似,都是三步操作,只不過第二步在寫入檔案的時候,傳入的Runnablenull,因此,對於寫入檔案的操作是同步的,因此,如果我們在主執行緒當中呼叫了commit方法,那麼實際上是在主執行緒進行IO操作。

(c) 回撥時機

  • 對於apply方法,由於它對於檔案的寫入是非同步的,但是notifyListener方法不會等到真正寫入完成時才通知監聽者,因此監聽者在收到回撥或者apply返回時,對於SP資料的改變只是寫入到了記憶體當中,並沒有寫入到檔案當中。
  • 對於commit方法,由於它對於檔案的寫入是同步的,因此可以保證監聽者收到回撥時或者commit方法返回後,改變已經被寫入到了檔案當中。

2.4 監聽 SP 的變化

如果希望監聽SP的變化,那麼可以通過下面的這兩個方法:

    public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {
        synchronized(this) {
            mListeners.put(listener, mContent);
        }
    }

    public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {
        synchronized(this) {
            mListeners.remove(listener);
        }
    }
複製程式碼

由於對應於NameSP在程式中是實際上是一個單例模式,因此,我們可以做到在程式中的任何地方改變SP的資料,都能收到監聽。

相關文章