面試高頻題:一眼看穿 SharedPreferences

刺目啊1199發表於2019-01-09

喜歡的朋友,點個讚唄鼓勵鼓勵唄~

本文章針對 Android 7.0 原始碼進行分析

SharedPreferences是 Android 中比較常用的儲存方法,它可以用來儲存一些比較小的鍵值對集合,並最終會在手機的/data/data/package_name/shared_prefs/目錄下生成一個 xml 檔案儲存資料。它的使用非常簡單,是一個 Android 開發者的基本技能,在這裡不加以闡述了。

SharedPreferences帶給我們非常簡單易用的資料儲存讀寫功能的同時,不知大家有沒有好奇過它底層是怎樣實現的呢?

通過ContextImpl.getSharedPreferences方法能夠獲取SharedPreferences物件, 通過getXxx/putXxx方法能夠進行讀寫操作,通過commit方法同步寫磁碟,通過apply方法非同步寫磁碟。其中涉及到如下幾個問題:

  • 獲取SharedPreferences物件過程中,系統做了什麼?
  • getXxx方法做了什麼?
  • putXxx方法做了什麼?
  • commit/apply方法如何實現同步/非同步寫磁碟?

下面,我們來一一解答這些疑惑。


疑問1:獲取SharedPreferences物件,系統做了什麼?

獲取SharedPreferences物件

我們直接看ContextImpl.getSharedPreferences的原始碼:

@Override
public SharedPreferences getSharedPreferences(String name, int mode) {
    // At least one application in the world actually passes in a null
    // name.  This happened to work because when we generated the file name
    // we would stringify it to "null.xml".  Nice.
    if (mPackageInfo.getApplicationInfo().targetSdkVersion <
            Build.VERSION_CODES.KITKAT) {
        if (name == null) {
            name = "null";
        }
    }

    File file;
    synchronized (ContextImpl.class) {
        if (mSharedPrefsPaths == null) {
            mSharedPrefsPaths = new ArrayMap<>();
        }
        file = mSharedPrefsPaths.get(name);
        if (file == null) {
            // 建立一個對應路徑 /data/data/packageName/name 的 File 物件
            file = getSharedPreferencesPath(name);
            mSharedPrefsPaths.put(name, file);
        }
    }

    // 這裡呼叫了 getSharedPreferences(File file, int mode) 方法
    return getSharedPreferences(file, mode);
}複製程式碼
@Override
public SharedPreferences getSharedPreferences(File file, int mode) {
    SharedPreferencesImpl sp;

    // 這裡使用了 synchronized 關鍵字,確保了 SharedPreferences 物件的構造是執行緒安全的
    synchronized (ContextImpl.class) {

        // 獲取SharedPreferences 物件的快取,並複製給 cache
        final ArrayMap<File, SharedPreferencesImpl> cache = getSharedPreferencesCacheLocked();

        // 以引數 file 作為 key,獲取快取物件
        sp = cache.get(file);

        if (sp == null) {  // 如果快取中不存在 SharedPreferences 物件
            checkMode(mode);
            if (getApplicationInfo().targetSdkVersion >= android.os.Build.VERSION_CODES.O) {
                if (isCredentialProtectedStorage()
                        && !getSystemService(UserManager.class)
                        .isUserUnlockingOrUnlocked(UserHandle.myUserId())) {
                    throw new IllegalStateException("SharedPreferences in credential encrypted "
                            + "storage are not available until after user is unlocked");
                }
            }

            // 構造一個 SharedPreferencesImpl 物件
            sp = new SharedPreferencesImpl(file, mode);
            // 放入快取 cache 中,方便下次直接從快取中獲取
            cache.put(file, sp);
            // 返回新構造的 SharedPreferencesImpl 物件
            return sp;
        }
    }

    // 這裡涉及到多程式的邏輯
    if ((mode & Context.MODE_MULTI_PROCESS) != 0 ||
            getApplicationInfo().targetSdkVersion < android.os.Build.VERSION_CODES.HONEYCOMB) {
        // If somebody else (some other process) changed the prefs
        // file behind our back, we reload it.  This has been the
        // historical (if undocumented) behavior.

        // 如果由其他程式修改了這個 SharedPreferences 檔案,我們將會重新載入它
        sp.startReloadIfChangedUnexpectedly();
    }

    // 程式走到這裡,說明命中了快取,SharedPreferences 已經建立,直接返回
    return sp;
}複製程式碼

這段原始碼的流程還是清晰易懂的,註釋已經說得很明白,這裡我們總結一下這個方法的要點:

  • 快取未命中, 才構造SharedPreferences物件,也就是說,多次呼叫getSharedPreferences方法並不會對效能造成多大影響,因為又快取機制
  • SharedPreferences物件的建立過程是執行緒安全的,因為使用了synchronize關鍵字
  • 如果命中了快取,並且引數mode使用了Context.MODE_MULTI_PROCESS,那麼將會呼叫sp.startReloadIfChangedUnexpectedly()方法,在startReloadIfChangedUnexpectedly方法中,會判斷是否由其他程式修改過這個檔案,如果有,會重新從磁碟中讀取檔案載入資料

接著,我們重點關注註釋中的sp = new SharedPreferencesImpl(file, mode);//構造一個SharedPreferencesImpl物件這句程式碼。

構造SharedPreferencesImpl

// SharedPreferencesImpl.java
// 構造方法
SharedPreferencesImpl(File file, int mode) {
    mFile = file;
    // 建立災備檔案,命名為prefsFile.getPath() + ".bak"
    mBackupFile = makeBackupFile(file);
    mMode = mode;
    // mLoaded代表是否已經載入完資料
    mLoaded = false;
    // 解析 xml 檔案得到的鍵值對就存放在mMap中
    mMap = null;
    // 顧名思義,這個方法用於載入 mFile 這個磁碟上的 xml 檔案
    startLoadFromDisk();
}

// 建立災備檔案,用於當使用者寫入失敗的時候恢復資料
private static File makeBackupFile(File prefsFile) {
    return new File(prefsFile.getPath() + ".bak");
}複製程式碼

我們對SharedPreferencesImpl這個類的構造方法做一個總結:

  • 將傳進來的引數file以及mode分別儲存在mFile以及mMode
  • 建立一個.bak備份檔案,當使用者寫入失敗的時候會根據這個備份檔案進行恢復工作
  • 將存放鍵值對的mMap初始化為null
  • 呼叫startLoadFromDisk()方法載入資料

上面四個要點中,最重要的就是最後一步,呼叫startLoadFromDisk()方法載入資料:

// SharedPreferencesImpl.java
private void startLoadFromDisk() {
    synchronized (this) {
        mLoaded = false;
    }

    //注意:這裡我們可以看出,SharedPreferences 是通過開啟一個執行緒來非同步載入資料的
    new Thread("SharedPreferencesImpl-load") {
        public void run() {
            // 這個方法才是真正負責從磁碟上讀取 xml 檔案資料
            loadFromDisk();
        }
    }.start();
}

private void loadFromDisk() {
    synchronized (SharedPreferencesImpl.this) {
        // 如果正在載入資料,直接返回
        if (mLoaded) {
            return;
        }

        // 如果備份檔案存在,刪除原檔案,把備份檔案重新命名為原檔案的名字
        // 我們稱這種行為叫做回滾
        if (mBackupFile.exists()) {
            mFile.delete();
            mBackupFile.renameTo(mFile);
        }
    }

    // Debugging
    if (mFile.exists() && !mFile.canRead()) {
        Log.w(TAG, "Attempt to read preferences file " + mFile + " without permission");
    }

    Map map = null;
    StructStat stat = null;
    try {
        // 獲取檔案資訊,包括檔案修改時間,檔案大小等
        stat = Os.stat(mFile.getPath());
        if (mFile.canRead()) {
            BufferedInputStream str = null;
            try {
                // 讀取資料並且將資料解析為jia
                str = new BufferedInputStream(
                        new FileInputStream(mFile), *);
                map = XmlUtils.readMapXml(str);
            } catch (XmlPullParserException | IOException e) {
                Log.w(TAG, "getSharedPreferences", e);
            } finally {
                IoUtils.closeQuietly(str);
            }
        }
    } catch (ErrnoException e) {
        /* ignore */
    }

    synchronized (SharedPreferencesImpl.this) {
        // 載入資料成功,設定 mLoaded 為 true
        mLoaded = true;
        if (map != null) {
            // 將解析得到的鍵值對資料賦值給 mMap
            mMap = map;
            // 將檔案的修改時間戳儲存到 mStatTimestamp 中
            mStatTimestamp = stat.st_mtime;
            // 將檔案的大小儲存到 mStatSize 中
            mStatSize = stat.st_size;
        } else {
            mMap = new HashMap<>();
        }

        // 通知喚醒所有等待的執行緒
        notifyAll();
    }
}複製程式碼

上面的原始碼中,我們對startLoadFromDisk()方法進行了分析,有分析我們可以得到以下幾點總結:

  • 如果有備份檔案,直接使用備份檔案進行回滾
  • 第一次呼叫getSharedPreferences方法的時候,會從磁碟中載入資料,而資料的載入時通過開啟一個子執行緒呼叫loadFromDisk方法進行非同步讀取的
  • 將解析得到的鍵值對資料儲存在mMap
  • 將檔案的修改時間戳以及大小分別儲存在mStatTimestamp以及mStatSize中(儲存這兩個值有什麼用呢?我們在分析getSharedPreferences方法時說過,如果有其他程式修改了檔案,並且modeMODE_MULTI_PROCESS,將會判斷重新載入檔案。如何判斷檔案是否被其他程式修改過,沒錯,根據檔案修改時間以及檔案大小即可知道)
  • 呼叫notifyAll()方法通知喚醒其他等待執行緒,資料已經載入完畢

好了,至此,我們就解決了第一個疑問:呼叫ContextImpl.getSharedPreferences方法獲取一個SharedPreferences物件的過程,系統做了什麼工作?

下面給出一個時序流程圖:

面試高頻題:一眼看穿 SharedPreferences


疑問2:getXxx做了什麼?

我們以getString來分析這個問題:

@Nullable
public String getString(String key, @Nullable String defValue) {

    // synchronize 關鍵字用於保證 getString 方法是執行緒安全的
    synchronized (this) {

        // 方法 awaitLoadedLocked() 用於確保載入完資料並儲存到 mMap 中才進行資料讀取
        awaitLoadedLocked();

        // 根據 key 從 mMap中獲取 value
        String v = (String)mMap.get(key);

        // 如果 value 不為 null,返回 value,如果為 null,返回預設值
        return v != null ? v : defValue;
    }
}

private void awaitLoadedLocked() {
    if (!mLoaded) {
        // Raise an explicit StrictMode onReadFromDisk for this
        // thread, since the real read will be in a different
        // thread and otherwise ignored by StrictMode.
        BlockGuard.getThreadPolicy().onReadFromDisk();
    }

    // 前面我們說過,mLoaded 代表資料是否已經載入完畢
    while (!mLoaded) {
        try {
            // 等待資料載入完成之後才返回繼續執行程式碼
            wait();
        } catch (InterruptedException unused) {
        }
    }
}複製程式碼

getString方法程式碼很簡單,其他的例如getIntgetFloat方法也是一樣的原理,我們直接對這個疑問進行總結:

  • getXxx方法是執行緒安全的,因為使用了synchronize關鍵字
  • getXxx方法是直接操作記憶體的,直接從記憶體中的mMap中根據傳入的key讀取value
  • getXxx方法有可能會卡在awaitLoadedLocked方法,從而導致執行緒阻塞等待(什麼時候會出現這種阻塞現象呢?前面我們分析過,第一次呼叫getSharedPreferences方法時,會建立一個執行緒去非同步載入資料,那麼假如在呼叫完getSharedPreferences方法之後立即呼叫getXxx方法,此時的mLoaded很有可能為false,這就會導致awaiteLoadedLocked方法阻塞等待,直到loadFromDisk方法載入完資料並且呼叫notifyAll來喚醒所有等待執行緒


疑問3:putXxx方法做了什麼?

說到寫操作方法,首先想到的是通過sharedPreferences.edit()方法返回的SharedPreferences.Editor,所有我們對SharedPreferences寫操作都是基於這個Editor類的。在 Android 系統中,Editor是一個介面類,它的具體實現類是EditorImpl

public final class EditorImpl implements Editor {

    // putXxx/remove/clear等寫操作方法都不是直接操作 mMap 的,而是將所有
    // 的寫操作先記錄在 mModified 中,等到 commit/apply 方法被呼叫,才會將
    // 所有寫操作同步到 記憶體中的 mMap 以及磁碟中
    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;
        }
    }

    public Editor putStringSet(String key, @Nullable Set<String> values) {
        synchronized (this) {
            mModified.put(key, (values == null) ? null : new HashSet<String>(values));
            return this;
        }
    }

    public Editor putInt(String key, int value) {
        synchronized (this) {
            mModified.put(key, value);
            return this;
        }
    }

    public Editor putLong(String key, long value) {
        synchronized (this) {
            mModified.put(key, value);
            return this;
        }
    }

    public Editor putFloat(String key, float value) {
        synchronized (this) {
            mModified.put(key, value);
            return this;
        }
    }

    public Editor putBoolean(String key, boolean value) {
        synchronized (this) {
            mModified.put(key, value);
            return this;
        }
    }

    public Editor remove(String key) {
        synchronized (this) {
            mModified.put(key, this);
            return this;
        }
    }

    ......
    其他方法
    ......
}複製程式碼

EditorImpl類的原始碼我們可以得出以下總結:

  • SharedPreferences的寫操作是執行緒安全的,因為使用了synchronize關鍵字
  • 對鍵值對資料的增刪記錄儲存在mModified中,而並不是直接對SharedPreferences.mMap進行操作(mModified會在commit/apply方法中起到同步記憶體SharedPreferences.mMap以及磁碟資料的作用)


疑問4:commit()/apply()方法如何實現同步/非同步寫磁碟?

commit()方法分析

先分析commit()方法,直接上原始碼:

public boolean commit() {
    // 前面我們分析 putXxx 的時候說過,寫操作的記錄是存放在 mModified 中的
    // 在這裡,commitToMemory() 方法就負責將 mModified 儲存的寫記錄同步到記憶體中的 mMap 中
    // 並且返回一個 MemoryCommitResult 物件
    MemoryCommitResult mcr = commitToMemory();

    // enqueueDiskWrite 方法負責將資料落地到磁碟上
    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;
}複製程式碼

commit()方法的主體結構很清晰簡單:

  • 首先將寫操作記錄同步到記憶體的SharedPreferences.mMap中(將mModified同步到mMap
  • 然後呼叫enqueueDiskWrite方法將資料寫入到磁碟上
  • 同步等待寫磁碟操作完成(這就是為什麼commit()方法會同步阻塞等待的原因)
  • 通知監聽者(可以通過registerOnSharedPreferenceChangeListener方法註冊監聽)
  • 最後返回執行結果:true or false


看完了commit(),我們接著來看一下它呼叫的commitToMemory()方法:

private MemoryCommitResult commitToMemory() {
    MemoryCommitResult mcr = new MemoryCommitResult();
    synchronized (SharedPreferencesImpl.this) {
        // We optimistically don't make a deep copy until
        // a memory commit comes in when we're already
        // writing to disk.
        if (mDiskWritesInFlight > 0) {
            // We can't modify our mMap as a currently
            // in-flight write owns it.  Clone it before
            // modifying it.
            // noinspection unchecked
            mMap = new HashMap<String, Object>(mMap);
        }

        // 將 mMap 賦值給 mcr.mapToWriteToDisk,mcr.mapToWriteToDisk 指向的就是最終寫入磁碟的資料
        mcr.mapToWriteToDisk = mMap;

        // mDiskWritesInFlight 代表的是“此時需要將資料寫入磁碟,但還未處理或未處理完成的次數”
        // 將 mDiskWritesInFlight 自增1(這裡是唯一會增加 mDiskWritesInFlight 的地方)
        mDiskWritesInFlight++;

        boolean hasListeners = mListeners.size() > 0;
        if (hasListeners) {
            mcr.keysModified = new ArrayList<String>();
            mcr.listeners =
                    new HashSet<OnSharedPreferenceChangeListener>(mListeners.keySet());
        }

        synchronized (this) {

            // 只有呼叫clear()方法,mClear才為 true
            if (mClear) {
                if (!mMap.isEmpty()) {
                    mcr.changesMade = true;

                    // 當 mClear 為 true,清空 mMap
                    mMap.clear();
                }
                mClear = false;
            }
            
            // 遍歷 mModified
            for (Map.Entry<String, Object> e : mModified.entrySet()) {
                String k = e.getKey(); // 獲取 key
                Object v = e.getValue(); // 獲取 value
                
                // 當 value 的值是 "this" 或者 null,將對應 key 的鍵值對資料從 mMap 中移除
                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);
                }
            }
            
            // 將 mModified 同步到 mMap 之後,清空 mModified 歷史記錄
            mModified.clear();
        }
    }
    return mcr;
}複製程式碼

總的來說,commitToMemory()方法主要做了這幾件事:

  • mDiskWritesInFlight自增1(mDiskWritesInFlight代表“此時需要將資料寫入磁碟,但還未處理或未處理完成的次數”,提示,整個SharedPreferences的原始碼中,唯獨在commitToMemory()方法中“有且僅有”一處程式碼會對mDiskWritesInFlight進行增加,其他地方都是減)
  • mcr.mapToWriteToDisk指向mMapmcr.mapToWriteToDisk就是最終需要寫入磁碟的資料
  • 判斷mClear的值,如果是true,清空mMap(呼叫clear()方法,會設定mCleartrue
  • 同步mModified資料到mMap中,然後清空mModified
  • 最後返回一個MemoryCommitResult物件,這個物件的mapToWriteToDisk引數指向了最終需要寫入磁碟的mMap

需要注意的是,在commitToMemory()方法中,當mCleartrue,會清空mMap,但不會清空mModified,所以依然會遍歷mModified,將其中儲存的寫記錄同步到mMap中,所以下面這種寫法是錯誤的:

sharedPreferences.edit()
    .putString("key1", "value1")    // key1 不會被 clear 掉,commit 之後依舊會被寫入磁碟中
    .clear()
    .commit();複製程式碼


分析完commitToMemory()方法,我們再回到commit()方法中,對它呼叫的enqueueDiskWrite方法進行分析:

private void enqueueDiskWrite(final MemoryCommitResult mcr, final Runnable postWriteRunnable) {
    // 建立一個 Runnable 物件,該物件負責寫磁碟操作
    final Runnable writeToDiskRunnable = new Runnable() {
        public void run() {
            synchronized (mWritingToDiskLock) {
                // 顧名思義了,這就是最終通過檔案操作將資料寫入磁碟的方法了
                writeToFile(mcr);
            }
            synchronized (SharedPreferencesImpl.this) {
                // 寫入磁碟後,將 mDiskWritesInFlight 自減1,代表寫磁碟的需求減少一個
                mDiskWritesInFlight--;
            }
            if (postWriteRunnable != null) {
                // 執行 postWriteRunnable(提示,在 apply 中,postWriteRunnable 才不為 null)
                postWriteRunnable.run();
            }
        }
    };

    // 如果傳進的引數 postWriteRunnable 為 null,那麼 isFromSyncCommit 為 true
    // 溫馨提示:從上面的 commit() 方法原始碼中,可以看出呼叫 commit() 方法傳入的 postWriteRunnable 為 null
    final boolean isFromSyncCommit = (postWriteRunnable == null);

    // Typical #commit() path with fewer allocations, doing a write on the current thread.
    if (isFromSyncCommit) {
        boolean wasEmpty = false;
        synchronized (SharedPreferencesImpl.this) {
            // 如果此時只有一個 commit 請求(注意,是 commit 請求,而不是 apply )未處理,那麼 wasEmpty 為 true
            wasEmpty = mDiskWritesInFlight == 1;
        }
        
        if (wasEmpty) {
            // 當只有一個 commit 請求未處理,那麼無需開啟執行緒進行處理,直接在本執行緒執行 writeToDiskRunnable 即可
            writeToDiskRunnable.run();
            return;
        }
    }
    
    // 將 writeToDiskRunnable 方法執行緒池中執行
    // 程式執行到這裡,有兩種可能:
    // 1. 呼叫的是 commit() 方法,並且當前只有一個 commit 請求未處理
    // 2. 呼叫的是 apply() 方法
    QueuedWork.singleThreadExecutor().execute(writeToDiskRunnable);
}複製程式碼

上面的註釋已經說得很明白了,在這裡就不總結了,接著來分析下writeToFile這個方法:

private void writeToFile(MemoryCommitResult mcr) {
    // Rename the current file so it may be used as a backup during the next read
    if (mFile.exists()) {
        if (!mcr.changesMade) {
            // If the file already exists, but no changes were
            // made to the underlying map, it's wasteful to
            // re-write the file.  Return as if we wrote it
            // out.
            mcr.setDiskWriteResult(true);
            return;
        }
        if (!mBackupFile.exists()) {
            if (!mFile.renameTo(mBackupFile)) {
                Log.e(TAG, "Couldn't rename file " + mFile
                        + " to backup file " + mBackupFile);
                mcr.setDiskWriteResult(false);
                return;
            }
        } else {
            mFile.delete();
        }
    }

    // Attempt to write the file, delete the backup and return true as atomically as
    // possible.  If any exception occurs, delete the new file; next time we will restore
    // from the backup.
    try {
        FileOutputStream str = createFileOutputStream(mFile);
        if (str == null) {
            mcr.setDiskWriteResult(false);
            return;
        }
        XmlUtils.writeMapXml(mcr.mapToWriteToDisk, str);
        FileUtils.sync(str);
        str.close();
        ContextImpl.setFilePermissionsFromMode(mFile.getPath(), mMode, 0);
        try {
            final StructStat stat = Libcore.os.stat(mFile.getPath());
            synchronized (this) {
                mStatTimestamp = stat.st_mtime;
                mStatSize = stat.st_size;
            }
        } catch (ErrnoException e) {
            // Do nothing
        }
        // Writing was successful, delete the backup file if there is one.
        mBackupFile.delete();
        mcr.setDiskWriteResult(true);
        return;
    } catch (XmlPullParserException e) {
        Log.w(TAG, "writeToFile: Got exception:", e);
    } catch (IOException e) {
        Log.w(TAG, "writeToFile: Got exception:", e);
    }
    // Clean up an unsuccessfully written file
    if (mFile.exists()) {
        if (!mFile.delete()) {
            Log.e(TAG, "Couldn't clean up partially-written file " + mFile);
        }
    }
    mcr.setDiskWriteResult(false);
}複製程式碼
writeToFile這個方法大致分為三個過程:
  • 先把已存在的老的 SP 檔案重新命名(加“.bak”字尾),然後刪除老的 SP 檔案,這相當於做了備份(災備)
  • mFile中一次性寫入所有鍵值對資料,即mcr.mapToWriteToDisk(這就是commitToMemory所說的儲存了所有鍵值對資料的欄位) 一次性寫入到磁碟。 如果寫入成功則刪除備份(災備)檔案,同時記錄了這次同步的時間
  • 如果往磁碟寫入資料失敗,則刪除這個半成品的 SP 檔案

通過上面的分析,我們對commit()方法的整個呼叫鏈以及它幹了什麼都有了認知,下面給出一個圖方便記憶理解:

面試高頻題:一眼看穿 SharedPreferences


apply()方法分析

分析完commit()方法,再去分析apply()方法就輕鬆多了:

public void apply() {

    // 將 mModified 儲存的寫記錄同步到記憶體中的 mMap 中,並且返回一個 MemoryCommitResult 物件
    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);
        }
    };
    
    // 將資料落地到磁碟上,注意,傳入的 postWriteRunnable 引數不為 null,所以在
    // enqueueDiskWrite 方法中會開啟子執行緒非同步將資料寫入到磁碟中
    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);
}  複製程式碼

總結一下apply()方法:

  • commitToMemory()方法將mModified中記錄的寫操作同步回寫到記憶體 SharedPreferences.mMap 中。此時, 任何的getXxx方法都可以獲取到最新資料了
  • 通過enqueueDiskWrite方法呼叫writeToFile將方法將所有資料非同步寫入到磁碟中

下面也給出一個apply()時序流程圖幫助記憶理解:

面試高頻題:一眼看穿 SharedPreferences


總結

  • SharedPreferences是執行緒安全的,它的內部實現使用了大量synchronized關鍵字
  • SharedPreferences不是程式安全的
  • 第一次呼叫getSharedPreferences會載入磁碟 xml 檔案(這個載入過程是非同步的,通過new Thread來執行,所以並不會在構造SharedPreferences的時候阻塞執行緒,但是會阻塞getXxx/putXxx/remove/clear等呼叫),但後續呼叫getSharedPreferences會從記憶體快取中獲取。 如果第一次呼叫getSharedPreferences時還沒從磁碟載入完畢就馬上呼叫 getXxx/putXxx, 那麼getXxx/putXxx操作會阻塞,直到從磁碟載入資料完成後才返回
  • 所有的getXxx都是從記憶體中取的資料,資料來源於SharedPreferences.mMap
  • apply同步回寫(commitToMemory())記憶體SharedPreferences.mMap,然後把非同步回寫磁碟的任務放到一個單執行緒的執行緒池佇列中等待排程。apply不需要等待寫入磁碟完成,而是馬上返回
  • commit同步回寫(commitToMemory())記憶體SharedPreferences.mMap,然後如果mDiskWritesInFlight(此時需要將資料寫入磁碟,但還未處理或未處理完成的次數)的值等於1,那麼直接在呼叫commit的執行緒執行回寫磁碟的操作,否則把非同步回寫磁碟的任務放到一個單執行緒的執行緒池佇列中等待排程。commit會阻塞呼叫執行緒,知道寫入磁碟完成才返回
  • MODE_MULTI_PROCESS是在每次getSharedPreferences時檢查磁碟上配置檔案上次修改時間和檔案大小,一旦所有修改則會重新從磁碟載入檔案,所以並不能保證多程式資料的實時同步
  • 從 Android N 開始,,不支援MODE_WORLD_READABLE & MODE_WORLD_WRITEABLE。一旦指定, 直接拋異常


使用注意事項

  • 不要使用SharedPreferences作為多程式通訊手段。由於沒有使用跨程式的鎖,就算使用MODE_MULTI_PROCESSSharedPreferences在跨程式頻繁讀寫有可能導致資料全部丟失。根據線上統計,SP 大約會有萬分之一的損壞率

  • 每個 SP 檔案不能過大。SharedPreference的檔案儲存效能與檔案大小相關,我們不要將毫無關聯的配置項儲存在同一個檔案中,同時考慮將頻繁修改的條目單獨隔離出來

  • 還是每個 SP 檔案不能過大。在第一個getSharedPreferences時,會先載入 SP 檔案進記憶體,過大的 SP 檔案會導致阻塞,甚至會導致 ANR

  • 依舊是每個 SP 檔案不能過大。每次apply或者commit,都會把全部的資料一次性寫入磁碟, 所以 SP 檔案不應該過大, 影響整體效能
喜歡的朋友,點個讚唄鼓勵鼓勵唄~

相關文章