Android SharedPreferences 實現原理解析

Android架構發表於2019-03-19

序言

Android 中的 SharedPreference 是輕量級的資料儲存方式,能夠儲存簡單的資料型別,比如 String、int、boolean 值等。其內部是以 XML 結構儲存在 /data/data/包名/shared_prefs 資料夾下,資料以鍵值對的形式儲存。下面有個例子:

<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
    <float name="isFloat" value="1.5" />
    <string name="isString">Android</string>
    <int name="isInt" value="1" />
    <long name="isLong" value="1000" />
    <boolean name="isBoolean" value="true" />
    <set name="isStringSet">
        <string>element 1</string>
        <string>element 2</string>
        <string>element 3</string>
    </set>
</map>
複製程式碼

這裡不討論 API 的使用方法,主要是從原始碼角度分析 SharedPreferences (以下簡稱 SP) 的實現方式。

1. 初始化

首先我們使用 context 的 getSharedPreferences 方法獲取 SP 例項,它是一個介面物件。 SharedPreferences testSp = getSharedPreferences("test_sp", Context.MODE_PRIVATE); Context 是一個抽象類,其核心實現類是 ContextImpl ,找到裡面的 getSharedPreferences 方法。

    @Override
    public SharedPreferences getSharedPreferences(String name, int mode) {
        SharedPreferencesImpl sp;
        synchronized (ContextImpl.class) {
            if (sSharedPrefs == null) {
                // sSharedPrefs 是 ContextImpl 的靜態成員變數,通過 Map 維護著當前包名下的 SP Map 集合
                sSharedPrefs = new ArrayMap<String, ArrayMap<String, SharedPreferencesImpl>>();
            }

            final String packageName = getPackageName();
            ArrayMap<String, SharedPreferencesImpl> packagePrefs = sSharedPrefs.get(packageName);
            if (packagePrefs == null) {
                packagePrefs = new ArrayMap<String, SharedPreferencesImpl>();
                sSharedPrefs.put(packageName, packagePrefs);
            }

            // 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) {
                // name 引數為 null 時,檔名使用 null.xml
                if (name == null) {
                    name = "null";
                }
            }

            sp = packagePrefs.get(name);
            if (sp == null) {
                // SP 集合是一個以 SP 的名字為 key , SP 為值的 Map
                File prefsFile = getSharedPrefsFile(name);
                // SP 的實現類是 SharedPreferencesImpl
                sp = new SharedPreferencesImpl(prefsFile, mode);
                packagePrefs.put(name, sp);
                return sp;
            }
        }
        // Android 3.0 以下或者支援 MODE_MULTI_PROCESS 模式時,如果檔案被改動,就重新從檔案讀取,實現多程式資料同步,但是實際使用中效果不佳,可能會有很多坑。
        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.
            sp.startReloadIfChangedUnexpectedly();
        }
        return sp;
    }
複製程式碼

首次使用 getSharedPreferences 時,記憶體中不存在 SP 以及 SP Map 快取,需要建立 SP 並新增到 ContextImpl 的靜態成員變數(sSharedPrefs)中。 下面來看 SharedPreferencesImpl 的構造方法,

 SharedPreferencesImpl(File file, int mode) {
        mFile = file;
        mBackupFile = makeBackupFile(file);
        mMode = mode;
        mLoaded = false;
        mMap = null;
        startLoadFromDisk();
    }
複製程式碼

makeBackupFile 用來定義備份檔案,該檔案在寫入磁碟時會用到,繼續看 startLoadFromDisk 方法。

private void startLoadFromDisk() {
        synchronized (this) {
            mLoaded = false;
        }
        // 開啟非同步執行緒從磁碟讀取檔案,加鎖防止多執行緒併發操作
        new Thread("SharedPreferencesImpl-load") {
            public void run() {
                synchronized (SharedPreferencesImpl.this) {
                    loadFromDiskLocked();
                }
            }
        }.start();
    }

 private void loadFromDiskLocked() {
        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 {
                    str = new BufferedInputStream(
                            new FileInputStream(mFile), 16*1024);
                    // 從 XML 裡面讀取資料返回一個 Map,內部使用了 XmlPullParser
                    map = XmlUtils.readMapXml(str);
                } catch (XmlPullParserException e) {
                    Log.w(TAG, "getSharedPreferences", e);
                } catch (FileNotFoundException e) {
                    Log.w(TAG, "getSharedPreferences", e);
                } catch (IOException e) {
                    Log.w(TAG, "getSharedPreferences", e);
                } finally {
                    IoUtils.closeQuietly(str);
                }
            }
        } catch (ErrnoException e) {
        }
        mLoaded = true;
        if (map != null) {
            mMap = map;
            mStatTimestamp = stat.st_mtime;
            mStatSize = stat.st_size;
        } else {
            mMap = new HashMap<String, Object>();
        }
         // 喚醒等待的執行緒,到這檔案讀取完畢
        notifyAll();
}
複製程式碼

看到這,基本明白了 getSharedPreferences 的原理,應用首次使用 SP 的時候會從磁碟讀取,之後快取在記憶體中。

2. 讀資料

下面分析 SP 讀取資料的方法,就以 getString 為例。

@Nullable
public String getString(String key, @Nullable String defValue) {
      synchronized (this) {
          awaitLoadedLocked();
          String v = (String)mMap.get(key);
          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();
      }
      while (!mLoaded) {
          try {
              wait();
          } catch (InterruptedException unused) {
          }
      }
}
複製程式碼

首先取得 SharedPreferencesImpl 物件鎖,然後同步等待從磁碟載入資料完成,最後返回資料。這裡有個問題,如果單個 SP 儲存的內容過多,導致我們使用 getXXX 方法的時候阻塞,特別是在主執行緒呼叫的時候,所以建議在單個 SP 中儘量少地儲存資料,雖然操作時間是毫秒級別的,使用者基本上感覺不到。

3. 寫資料

SP 寫入資料的操作是通過 Editor 完成的,它也是一個介面,實現類是 EditorImpl,是 SharedPreferencesImpl 的內部類。 通過 SP 的 edit 方法獲取 Editor 例項,等到載入完畢直接返回一個 EditorImpl 物件。

public Editor edit() {
      // TODO: remove the need to call awaitLoadedLocked() when
      // requesting an editor.  will require some work on the
      // Editor, but then we should be able to do:
      //
      //      context.getSharedPreferences(..).edit().putString(..).apply()
      //
      // ... all without blocking.
      synchronized (this) {
          awaitLoadedLocked();
      }
      return new EditorImpl();
}
複製程式碼

比如我們要儲存某個 String 的值,呼叫 putString 方法。

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

mModified 是一個 editor 中的一個 Map,儲存著要修改的資料,在將改動儲存到 SP 的 Map(變數 mMap,裡面儲存著使用中的鍵值對 ) 後被清空。put 完成後就要呼叫 commit 或者 apply 進行儲存。

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

        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);
        }
複製程式碼

可以看到,commit 和 apply 操作首先執行了 commitToMemory,顧名思義就是提交到記憶體,返回值是 MemoryCommitResult 型別,裡面儲存著本次提交的狀態。然後 commit 呼叫 enqueueDiskWrite 會阻塞當前執行緒,而 apply 通過封裝 Runnable 把寫磁碟之後的操作傳遞給 enqueueDiskWrite 方法。

        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.
                // mDiskWritesInFlight  表示準備操作磁碟的程式數
                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);
                }
                mcr.mapToWriteToDisk = mMap;
                mDiskWritesInFlight++;
                //  把註冊的 listeners 放到 mcr 中去,以便在資料寫入的時候被回撥
                boolean hasListeners = mListeners.size() > 0;
                if (hasListeners) {
                    mcr.keysModified = new ArrayList<String>();
                    mcr.listeners =
                            new HashSet<OnSharedPreferenceChangeListener>(mListeners.keySet());
                }

                synchronized (this) {
                    if (mClear) {
                        if (!mMap.isEmpty()) {
                            mcr.changesMade = true;
                            mMap.clear();
                        }
                        mClear = false;
                    }

                    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.
                        // 當值是 null 時,表示移除該鍵值對,在 editor 的 remove 實現中,並不是真正地移除,
                        // 而是把 value 賦值為當前 editor 物件
                        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);
                        }
                    }
                    // 新增完成後把 editor 裡的 map 清空
                    mModified.clear();
                }
            }
            return mcr;
        }
複製程式碼

這是 MemoryCommitResult 類,主要用於提交到記憶體後返回結果,然後在寫入磁碟時作為引數傳遞。

 private static class MemoryCommitResult {
        public boolean changesMade;  // any keys different?
        public List<String> keysModified;  // may be null
        public Set<OnSharedPreferenceChangeListener> listeners;  // may be null
        public Map<?, ?> mapToWriteToDisk;
        public final CountDownLatch writtenToDiskLatch = new CountDownLatch(1);
        public volatile boolean writeToDiskResult = false;

        public void setDiskWriteResult(boolean result) {
            writeToDiskResult = result;
            writtenToDiskLatch.countDown();
        }
    }
複製程式碼

下面看儲存到磁碟的操作,enqueueDiskWrite 方法,引數有 MemoryCommitResult 和 Runnable,mcr 剛才說過,就看這個 Runnable 是幹嘛的。在 commit 方法中呼叫 enqueueDiskWrite 方法是傳入的 Runnable 是null,它會在當前執行緒直接執行寫檔案的操作,然後返回寫入結果。而如果 Runnable 不是 null,那就使用 QueueWork 中的單執行緒執行。這就是 apply 和 commit 的根本區別:一個同步執行,有返回值;一個非同步執行,沒有返回值。大多數情況下,我們使用 apply 就夠了,這也是官方推薦的做法。

    private void enqueueDiskWrite(final MemoryCommitResult mcr,
                                  final Runnable postWriteRunnable) {
        final Runnable writeToDiskRunnable = new Runnable() {
                public void run() {
                    synchronized (mWritingToDiskLock) {
                        // 真正寫入檔案
                        writeToFile(mcr);
                    }
                    synchronized (SharedPreferencesImpl.this) {
                        mDiskWritesInFlight--;
                    }
                    if (postWriteRunnable != null) {
                        postWriteRunnable.run();
                    }
                }
            };

        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) {
                wasEmpty = mDiskWritesInFlight == 1;
            }
            if (wasEmpty) {
                writeToDiskRunnable.run();
                return;
            }
        }
        // 把寫檔案的操作放到執行緒池中執行
        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 = 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);
    }
複製程式碼

4. 總結

通過 getSharedPreferences 可以獲取 SP 例項,從首次初始化到讀到資料會存在延遲,因為讀檔案的操作阻塞呼叫的執行緒直到檔案讀取完畢,如果在主執行緒呼叫,可能會對 UI 流暢度造成影響。 commit 會在呼叫者執行緒同步執行寫檔案,返回寫入結果;apply 將寫檔案的操作非同步執行,沒有返回值。可以根據具體情況選擇性使用,推薦使用 apply。 雖然支援設定 MODE_MULTI_PROCESS 標誌位,但是跨程式共享 SP 存在很多問題,所以不建議使用該模式。

最後

在這裡我總結出了網際網路公司Android程式設計師面試簡歷模板,面試涉及到的絕大部分面試題及答案做成了文件和架構視訊資料免費分享給大家【包括高階UI、效能優化、架構師課程、NDK、Kotlin、混合式開發(ReactNative+Weex)、Flutter等架構技術資料】,希望能幫助到您面試前的複習且找到一個好的工作,也節省大家在網上搜尋資料的時間來學習。

資料獲取方式:加入Android架構交流QQ群聊:513088520 ,進群即領取資料!!!

點選連結加入群聊【Android移動架構總群】:加入群聊

資料大全

相關文章