SharedPreferences 的缺陷及一點點思考

[轉(zhuǎn)]一文讀懂 SharedPreferences 的缺陷及一點點思考
SharedPreferences 是系統(tǒng)提供的一個適合用于存儲少量鍵值對數(shù)據(jù)的持久化存儲方案,結(jié)構(gòu)簡單柄沮,使用方便闭树,很多應(yīng)用都會使用到檐什。另一方面旺嬉,SharedPreferences 存在的問題也挺多的判哥,當(dāng)中 ANR 問題就屢見不鮮潭兽,字節(jié)跳動技術(shù)團隊就曾經(jīng)發(fā)布過一篇文章專門來闡述該問題:剖析 SharedPreference apply 引起的 ANR 問題桅咆。到了現(xiàn)在,Google Jetpack 也推出了一套新的持久化存儲方案:DataStore拥知,大有取代 SharedPreferences 的趨勢

本文就結(jié)合源碼來剖析 SharedPreferences 存在的缺陷以及背后的具體原因踏拜,基于 SDK 30 進行分析,讓讀者做到知其然也知其所以然低剔,并在最后介紹下我個人的一種存儲機制設(shè)計方案速梗,希望對你有所幫助.

不得不說的坑

會一直占用內(nèi)存

SharedPreferences 本身是一個接口肮塞,具體的實現(xiàn)類是 SharedPreferencesImpl,Context 中各個和 SP 相關(guān)的方法都是由 ContextImpl 來實現(xiàn)的姻锁。我們項目中的每個 SP 或多或少都是保存著一些鍵值對枕赵,而每當(dāng)我們獲取到一個 SP 對象時,其對應(yīng)的數(shù)據(jù)就會一直被保留在內(nèi)存中位隶,直到應(yīng)用進程被終結(jié)拷窜,因為每個 SP 對象都被系統(tǒng)作為靜態(tài)變量緩存起來了,對應(yīng) ContextImpl 中的靜態(tài)變量 sSharedPrefsCache

class ContextImpl extends Context {
    
    //先根據(jù)應(yīng)用包名緩存所有 SharedPreferences
    //再根據(jù) xmlFile 和具體的 SharedPreferencesImpl 對應(yīng)上
    private static ArrayMap<String, ArrayMap<File, SharedPreferencesImpl>> sSharedPrefsCache;

    //根據(jù) fileName 拿到對應(yīng)的 xmlFile
    private ArrayMap<String, File> mSharedPrefsPaths;

}

每個 SP 都對應(yīng)一個本地磁盤中的 xmlFile涧黄,fileName 則是由開發(fā)者來顯式指定的篮昧,每個 xmlFile 都對應(yīng)一個 SharedPreferencesImpl。所以 ContextImpl 的邏輯是先根據(jù) fileName 拿到 xmlFile笋妥,再根據(jù) xmlFile 拿到 SharedPreferencesImpl懊昨,最終應(yīng)用內(nèi)所有的 SharedPreferencesImpl 就都會被緩存在 sSharedPrefsCache 這個靜態(tài)變量中。此外春宣,由于 SharedPreferencesImpl 在初始化后就會自動去加載 xmlFile 中的所有鍵值對數(shù)據(jù)酵颁,而 ContextImpl 內(nèi)部并沒有看到有清理 sSharedPrefsCache 緩存的邏輯,所以 sSharedPrefsCache 會被一直保留在內(nèi)存中直到進程終結(jié)月帝,其內(nèi)存大小會隨著我們引用到的 SP 增多而加大躏惋,這就可能會持續(xù)占用很大一塊內(nèi)存空間

    @Override
    public SharedPreferences getSharedPreferences(String name, int mode) {
        ···
        File file;
        synchronized (ContextImpl.class) {
            if (mSharedPrefsPaths == null) {
                mSharedPrefsPaths = new ArrayMap<>();
            }
            file = mSharedPrefsPaths.get(name);
            if (file == null) {
                file = getSharedPreferencesPath(name);
                mSharedPrefsPaths.put(name, file);
            }
        }
        return getSharedPreferences(file, mode);
    }
    
    @Override
    public SharedPreferences getSharedPreferences(File file, int 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;
            }
        }
        ···
        return sp;
    }

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

GetValue 可能導(dǎo)致線程阻塞

SharedPreferencesImpl 在構(gòu)造函數(shù)中直接就啟動了一個子線程去加載磁盤文件,這意味著該操作是一個異步操作(我好像在說廢話)嚷辅,如果文件很大或者線程調(diào)度系統(tǒng)沒有馬上啟動該線程的話其掂,那么該操作就需要一小段時間后才能執(zhí)行完畢

final class SharedPreferencesImpl implements SharedPreferences {
    
    @UnsupportedAppUsage
    SharedPreferencesImpl(File file, int mode) {
        mFile = file;
        mBackupFile = makeBackupFile(file);
        mMode = mode;
        mLoaded = false;
        mMap = null;
        mThrowable = null;
        startLoadFromDisk();
    }
    
    @UnsupportedAppUsage
    private void startLoadFromDisk() {
        synchronized (mLock) {
            mLoaded = false;
        }
        new Thread("SharedPreferencesImpl-load") {
            public void run() {
                //加載磁盤文件
                loadFromDisk();
            }
        }.start();
    }
    
}

而如果我們在初始化 SharedPreferencesImpl 后緊接著就去 getValue 的話,勢必也需要確保子線程已經(jīng)加載完成后才去進行取值操作潦蝇,所以 SharedPreferencesImpl 就通過在每個 getValue 方法中調(diào)用 awaitLoadedLocked()方法來判斷是否需要阻塞外部線程,確保取值操作一定會在子線程執(zhí)行完畢后才執(zhí)行深寥。loadFromDisk()方法會在任務(wù)執(zhí)行完畢后調(diào)用 mLock.notifyAll()喚醒所有被阻塞的線程

    @Override
    @Nullable
    public String getString(String key, @Nullable String defValue) {
        synchronized (mLock) {
            //判斷是否需要讓外部線程等待
            awaitLoadedLocked();
            String v = (String)mMap.get(key);
            return v != null ? v : defValue;
        }
    }

    @GuardedBy("mLock")
    private void awaitLoadedLocked() {
        if (!mLoaded) {
            BlockGuard.getThreadPolicy().onReadFromDisk();
        }
        while (!mLoaded) {
            try {
                //還未加載線程攘乒,讓外部線程暫停等待
                mLock.wait();
            } catch (InterruptedException unused) {
            }
        }
        if (mThrowable != null) {
            throw new IllegalStateException(mThrowable);
        }
    }

    private void loadFromDisk() {
        ···
        synchronized (mLock) {
            mLoaded = true;
            mThrowable = thrown;
            try {
                if (thrown == null) {
                    if (map != null) {
                        mMap = map;
                        mStatTimestamp = stat.st_mtim;
                        mStatSize = stat.st_size;
                    } else {
                        mMap = new HashMap<>();
                    }
                }
            } catch (Throwable t) {
                mThrowable = t;
            } finally {
                //喚醒所有被阻塞的線程
                mLock.notifyAll();
            }
        }
    }

所以說,如果 SP 存儲的數(shù)據(jù)量很大的話惋鹅,那么就有可能導(dǎo)致外部的調(diào)用者線程被阻塞则酝,嚴重時甚至可能導(dǎo)致 ANR。當(dāng)然闰集,這種可能性也只是發(fā)生在加載磁盤文件完成之前沽讹,當(dāng)加載完成后 awaitLoadedLocked()方法自然不會阻塞線程

GetValue 不保證數(shù)據(jù)類型安全

以下代碼在編譯階段是完全正常的,但在運行時就會拋出異常:java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.String武鲁。很明顯爽雄,這是由于同個 key 先后對應(yīng)了不同數(shù)據(jù)類型導(dǎo)致的,SharedPreferences 沒有辦法對這種操作做出限制沐鼠,完全需要依賴于開發(fā)者自己的代碼規(guī)范來進行限制

val sharedPreferences: SharedPreferences = getSharedPreferences("UserInfo", Context.MODE_PRIVATE)
val key = "userName"
val edit = sharedPreferences.edit()
edit.putInt(key, 11)
edit.apply()
val name = sharedPreferences.getString(key, "")

不支持多進程數(shù)據(jù)共享

在獲取 SP 實例的時候需要傳入一個 int 類型的 mode 標記位參數(shù)挚瘟,存在一個和多進程相關(guān)的標記位 MODE_MULTI_PROCESS叹谁,該標記位能起到一定程度的多進程數(shù)據(jù)同步的保障,但作用不大乘盖,且并不保證多進程并發(fā)安全性

val sharedPreferences: SharedPreferences = getSharedPreferences("UserInfo", Context.MODE_MULTI_PROCESS)

上文有講到焰檩,SharedPreferencesImpl 在被加載后就會一直保留在內(nèi)存中,之后每次獲取都是直接使用緩存數(shù)據(jù)订框,通常情況下也不會再次去加載磁盤文件析苫。而 MODE_MULTI_PROCESS 起到的作用就是每當(dāng)再一次去獲取 SP 實例時,會判斷當(dāng)前磁盤文件相對最后一次內(nèi)存修改是否被改動過了穿扳,如果是的話就主動去重新加載磁盤文件衩侥,從而可以做到在多進程環(huán)境下一定的數(shù)據(jù)同步
但是,這種同步本身作用不大纵揍,因為即使此時重新加載磁盤文件了顿乒,后續(xù)修改 SP 值時不同進程中的內(nèi)存數(shù)據(jù)也不會實時同步,且多進程同時修改 SP 值也存在數(shù)據(jù)丟失和數(shù)據(jù)覆蓋的可能泽谨。所以說璧榄,SP 并不支持多進程數(shù)據(jù)共享,MODE_MULTI_PROCESS 也已經(jīng)被廢棄了吧雹,其注釋也推薦使用 ContentProvider 來實現(xiàn)跨進程通信

class ContextImpl extends Context {
    
    @Override
    public SharedPreferences getSharedPreferences(File file, int mode) {
        SharedPreferencesImpl sp;
        synchronized (ContextImpl.class) {
            ···
        }
        if ((mode & Context.MODE_MULTI_PROCESS) != 0 ||
            getApplicationInfo().targetSdkVersion < android.os.Build.VERSION_CODES.HONEYCOMB) {
            //重新去加載磁盤文件
            sp.startReloadIfChangedUnexpectedly();
        }
        return sp;
    }
    
}

不支持增量更新

我們知道骨杂,SP 提交數(shù)據(jù)的方法有兩個:commit()apply(),分別對應(yīng)著同步修改和異步修改雄卷,而這兩種方式對應(yīng)的都是全量更新搓蚪,SP 以文件為最小單位進行修改,即使我們只修改了一個鍵值對丁鹉,這兩個方法也會將所有鍵值對數(shù)據(jù)重新寫入到磁盤文件中妒潭,即 SP 只支持全量更新
我們平時獲取到的 Editor 對象,對應(yīng)的都是 SharedPreferencesImpl 的內(nèi)部類 EditorImpl揣钦。EditorImpl 的每個 putValue 方法都會將傳進來的 key-value 保存在 mModified 中雳灾,暫時還沒有涉及任何文件改動。比較特殊的是 removeclear 兩個方法冯凹,remove 方法會將 this 作為鍵值對的 value谎亩,后續(xù)就通過對比 value 的相等性來知道是要移除鍵值對還是修改鍵值對,clear 方法則只是將 mClear 標記位置為 true

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;
    
        @Override
        public Editor putString(String key, @Nullable String value) {
            synchronized (mEditorLock) {
                mModified.put(key, value);
                return this;
            }
        }
    
        @Override
        public Editor remove(String key) {
            synchronized (mEditorLock) {
                //存入當(dāng)前的 EditorImpl 對象
                mModified.put(key, this);
                return this;
            }
        }

        @Override
        public Editor clear() {
            synchronized (mEditorLock) {
                mClear = true;
                return this;
            }
        }
    
}

commit()apply()兩個方法都會通過調(diào)用 commitToMemory()方法拿到修改后的全量數(shù)據(jù)commitToMemory()采用了 diff 算法宇姚,SP 包含的所有鍵值對數(shù)據(jù)都存儲在 mapToWriteToDisk 中匈庭,Editor 改動到的所有鍵值對數(shù)據(jù)都存儲在 mModified 中。如果 mClear 為 true浑劳,則會先清空 mapToWriteToDisk阱持,然后再遍歷 mModified,將 mModified 中的所有改動都同步給 mapToWriteToDisk魔熏。最終 mapToWriteToDisk 就保存了要重新寫入到磁盤文件中的全量數(shù)據(jù)紊选,SP 會根據(jù) mapToWriteToDisk 完全覆蓋掉舊的 xml 文件

        // Returns true if any changes were made
        private MemoryCommitResult commitToMemory() {
            long memoryStateGeneration;
            boolean keysCleared = false;
            List<String> keysModified = null;
            Set<OnSharedPreferenceChangeListener> listeners = null;
            Map<String, Object> mapToWriteToDisk;
            synchronized (SharedPreferencesImpl.this.mLock) {
                // 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);
                }
                //拿到內(nèi)存中的全量數(shù)據(jù)
                mapToWriteToDisk = mMap;
                mDiskWritesInFlight++;
                boolean hasListeners = mListeners.size() > 0;
                if (hasListeners) {
                    keysModified = new ArrayList<String>();
                    listeners = new HashSet<OnSharedPreferenceChangeListener>(mListeners.keySet());
                }
                synchronized (mEditorLock) {
                    //用于標記最終是否改動到了 mapToWriteToDisk
                    boolean changesMade = false;
                    if (mClear) {
                        if (!mapToWriteToDisk.isEmpty()) {
                            changesMade = true;
                            //清空所有在內(nèi)存中的數(shù)據(jù)
                            mapToWriteToDisk.clear();
                        }
                        keysCleared = true;
                        //恢復(fù)狀態(tài)啼止,避免二次修改時狀態(tài)錯位
                        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.
                        if (v == this || v == null) { //意味著要移除該鍵值對
                            if (!mapToWriteToDisk.containsKey(k)) {
                                continue;
                            }
                            mapToWriteToDisk.remove(k);
                        } else { //對應(yīng)修改鍵值對值的情況
                            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);
                        }
                    }
                    //恢復(fù)狀態(tài),避免二次修改時狀態(tài)錯位
                    mModified.clear();
                    if (changesMade) {
                        mCurrentMemoryStateGeneration++;
                    }
                    memoryStateGeneration = mCurrentMemoryStateGeneration;
                }
            }
            return new MemoryCommitResult(memoryStateGeneration, keysCleared, keysModified,
                    listeners, mapToWriteToDisk);
        }

Clear 的反直覺用法

看以下例子兵罢。按照語義分析的話献烦,最終 SP 中應(yīng)該是只剩下 blog 一個鍵值對才符合直覺,而實際上最終兩個鍵值對都會被保留卖词,且只有這兩個鍵值對被保留下來

val sharedPreferences: SharedPreferences = getSharedPreferences("UserInfo", Context.MODE_PRIVATE)
val edit = sharedPreferences.edit()
edit.putString("name", "業(yè)志陳").clear().putString("blog", "https://juejin.cn/user/923245496518439")
edit.apply()

造成該問題的原因還需要看commitToMemory()方法巩那。clear()會將 mClear 置為 true,所以在執(zhí)行到第一步的時候就會將內(nèi)存中的所有鍵值對數(shù)據(jù) mapToWriteToDisk 清空此蜈。當(dāng)執(zhí)行到第二步的時候即横,mModified 中的所有數(shù)據(jù)就都會同步到 mapToWriteToDisk 中,從而導(dǎo)致最終 name 和 blog 兩個鍵值對都會被保留下來裆赵,其它鍵值對都被移除了
所以說东囚,Editor.clear() 之前不應(yīng)該連貫調(diào)用 putValue 語句,這會造成理解和實際效果之間的偏差

        // Returns true if any changes were made
        private MemoryCommitResult commitToMemory() {
            long memoryStateGeneration;
            boolean keysCleared = false;
            List<String> keysModified = null;
            Set<OnSharedPreferenceChangeListener> listeners = null;
            Map<String, Object> mapToWriteToDisk;
            synchronized (SharedPreferencesImpl.this.mLock) {
                // 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);
                }
                //拿到內(nèi)存中的全量數(shù)據(jù)
                mapToWriteToDisk = mMap;
                mDiskWritesInFlight++;
                boolean hasListeners = mListeners.size() > 0;
                if (hasListeners) {
                    keysModified = new ArrayList<String>();
                    listeners = new HashSet<OnSharedPreferenceChangeListener>(mListeners.keySet());
                }
                synchronized (mEditorLock) {
                    boolean changesMade = false;
                    if (mClear) { //第一步
                        if (!mapToWriteToDisk.isEmpty()) {
                            changesMade = true;
                            //清空所有在內(nèi)存中的數(shù)據(jù)
                            mapToWriteToDisk.clear();
                        }
                        keysCleared = true;
                        //恢復(fù)狀態(tài)战授,避免二次修改時狀態(tài)錯位
                        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.
                        if (v == this || v == null) { //意味著要移除該鍵值對
                            if (!mapToWriteToDisk.containsKey(k)) {
                                continue;
                            }
                            mapToWriteToDisk.remove(k);
                        } else { //對應(yīng)修改鍵值對值的情況
                            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);
                        }
                    }
                    //恢復(fù)狀態(tài)扁凛,避免二次修改時狀態(tài)錯位
                    mModified.clear();
                    if (changesMade) {
                        mCurrentMemoryStateGeneration++;
                    }
                    memoryStateGeneration = mCurrentMemoryStateGeneration;
                }
            }
            return new MemoryCommitResult(memoryStateGeneration, keysCleared, keysModified,
                    listeners, mapToWriteToDisk);
        }

Commit局冰、apply 可能導(dǎo)致 ANR

commit() 方法會通過 commitToMemory() 方法拿到本次修改后的全量數(shù)據(jù)强法,即 MemoryCommitResult蟹肘,然后向 enqueueDiskWrite 方法提交將全量數(shù)據(jù)寫入磁盤文件的任務(wù),在寫入完成前調(diào)用者線程都會由于 CountDownLatch 一直阻塞等待著楣导,方法返回值即本次修改操作的成功狀態(tài)

        @Override
        public boolean commit() {
            long startTime = 0;
            if (DEBUG) {
                startTime = System.currentTimeMillis();
            }
           //拿到修改后的全量數(shù)據(jù)
            MemoryCommitResult mcr = commitToMemory();
           //提交寫入磁盤文件的任務(wù)
            SharedPreferencesImpl.this.enqueueDiskWrite(
                mcr, null /* sync write on this thread okay */);
            try {
                //阻塞等待废境,直到 xml 文件寫入完成(不管成功與否)
                mcr.writtenToDiskLatch.await();
            } catch (InterruptedException e) {
                return false;
            } finally {
                if (DEBUG) {
                    Log.d(TAG, mFile.getName() + ":" + mcr.memoryStateGeneration
                            + " committed after " + (System.currentTimeMillis() - startTime)
                            + " ms");
                }
            }
            notifyListeners(mcr);
            return mcr.writeToDiskResult;
        }

enqueueDiskWrite 方法就是包含了具體的磁盤寫入邏輯的地方了,由于外部可能存在多個線程在同時執(zhí)行 apply()commit() 兩個方法筒繁,而對應(yīng)的磁盤文件只有一個噩凹,所以 enqueueDiskWrite 方法就必須保證寫入操作的有序性,避免數(shù)據(jù)丟失或者覆蓋毡咏,甚至是文件損壞
enqueueDiskWrite 方法的具體邏輯:

  1. writeToDiskRunnable 使用到了內(nèi)部鎖 mWritingToDiskLock 來保證 writeToFile 操作的有序性栓始,避免多線程競爭
  2. 對于 commit 操作,如果當(dāng)前只有一個線程在執(zhí)行提交修改的操作的話血当,那么直接在該線程上執(zhí)行 writeToDiskRunnable,流程結(jié)束
  3. 對于其他情況(apply 操作禀忆、多線程同時 commit 或者 apply)臊旭,都會將 writeToDiskRunnable 提交給 QueuedWork 執(zhí)行
  4. QueuedWork 內(nèi)部使用到了 HandlerThread 來執(zhí)行 writeToDiskRunnable,HandlerThread 本身也可以保證多個任務(wù)執(zhí)行時的有序性
    private void enqueueDiskWrite(final MemoryCommitResult mcr,
                                  final Runnable postWriteRunnable) {
        final boolean isFromSyncCommit = (postWriteRunnable == null);
        final Runnable writeToDiskRunnable = new Runnable() {
                @Override
                public void run() {
                    synchronized (mWritingToDiskLock) {
                        //寫入磁盤文件
                        writeToFile(mcr, isFromSyncCommit);
                    }
                    synchronized (mLock) {
                        mDiskWritesInFlight--;
                    }
                    if (postWriteRunnable != null) {
                        postWriteRunnable.run();
                    }
                }
            };
        // Typical #commit() path with fewer allocations, doing a write on
        // the current thread.
        if (isFromSyncCommit) { //commit() 方法會走進這里面
            boolean wasEmpty = false;
            synchronized (mLock) {
                wasEmpty = mDiskWritesInFlight == 1;
            }
            if (wasEmpty) {
                //wasEmpty 為 true 說明當(dāng)前只有一個線程在執(zhí)行提交操作箩退,那么就直接在此線程上完成任務(wù)
                writeToDiskRunnable.run();
                return;
            }
        }
        QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);
    }

此外离熏,還有一個比較重要的知識點需要注意下。在 writeToFile 方法中會對本次任務(wù)進行校驗戴涝,避免連續(xù)多次執(zhí)行無效的磁盤任務(wù)滋戳。當(dāng)中钻蔑,mDiskStateGeneration 代表的是最后一次成功寫入磁盤文件時的任務(wù)版本號,mCurrentMemoryStateGeneration 是當(dāng)前內(nèi)存中最新的修改記錄版本號奸鸯,mcr.memoryStateGeneration 是本次要執(zhí)行的任務(wù)的版本號咪笑。通過兩次版本號的對比,就避免了在連續(xù)多次 commit 或者 apply 時造成重復(fù)執(zhí)行 I/O 操作的情況娄涩,而是只會執(zhí)行最后一次窗怒,避免了無效的 I/O 任務(wù)

    @GuardedBy("mWritingToDiskLock")
    private void writeToFile(MemoryCommitResult mcr, boolean isFromSyncCommit) {
        ···
        if (fileExists) {
            boolean needsWrite = false;

            // Only need to write if the disk state is older than this commit
            //判斷版本號
            if (mDiskStateGeneration < mcr.memoryStateGeneration) {
                if (isFromSyncCommit) {
                    needsWrite = true;
                } else {
                    synchronized (mLock) {
                        // No need to persist intermediate states. Just wait for the latest state to
                        // be persisted.
                        //判斷版本號
                        if (mCurrentMemoryStateGeneration == mcr.memoryStateGeneration) {
                            needsWrite = true;
                        }
                    }
                }
            }
            
            if (!needsWrite) {
                //當(dāng)前版本號并非最新,無需執(zhí)行蓄拣,直接返回即可
                mcr.setDiskWriteResult(false, true);
                return;
            }
        ···
    }

再回過頭看 commit() 方法扬虚。不管該方法關(guān)聯(lián)的 writeToDiskRunnable 最終是在本線程還是 HandlerThread 中執(zhí)行,await()方法都會使得本線程阻塞等待直到 writeToDiskRunnable 執(zhí)行完畢球恤,從而實現(xiàn)了 commit()同步提交的效果

        @Override
        public boolean commit() {
            long startTime = 0;
            if (DEBUG) {
                startTime = System.currentTimeMillis();
            }
           //拿到修改后的全量數(shù)據(jù)
            MemoryCommitResult mcr = commitToMemory();
           //提交寫入磁盤文件的任務(wù)
            SharedPreferencesImpl.this.enqueueDiskWrite(
                mcr, null /* sync write on this thread okay */);
            try {
                //阻塞等待辜昵,直到 xml 文件寫入完成(不管成功與否)
                mcr.writtenToDiskLatch.await();
            } catch (InterruptedException e) {
                return false;
            } finally {
                if (DEBUG) {
                    Log.d(TAG, mFile.getName() + ":" + mcr.memoryStateGeneration
                            + " committed after " + (System.currentTimeMillis() - startTime)
                            + " ms");
                }
            }
            notifyListeners(mcr);
            return mcr.writeToDiskResult;
        }

而對于 apply() 方法,其本身具有異步提交的含義咽斧,I/O 操作應(yīng)該都是交由給了子線程來執(zhí)行才對堪置,按道理來說只需要調(diào)用 enqueueDiskWrite 方法提交任務(wù)且不等待任務(wù)完成即可,可實際上apply()方法反而要比commit()方法復(fù)雜得多
apply()方法包含一個 awaitCommit 任務(wù)收厨,用于阻塞其執(zhí)行線程直到磁盤任務(wù)執(zhí)行完畢晋柱,而 awaitCommit 又被包裹在 postWriteRunnable 中一起提交給了 enqueueDiskWrite 方法,enqueueDiskWrite 方法又會在 writeToDiskRunnable 執(zhí)行完畢后執(zhí)行 enqueueDiskWrite

        @Override
        public void apply() {
            final long startTime = System.currentTimeMillis();

            final MemoryCommitResult mcr = commitToMemory();
            final Runnable awaitCommit = new Runnable() {
                    @Override
                    public void run() {
                        try {
                            //阻塞線程直到磁盤任務(wù)執(zhí)行完畢
                            mcr.writtenToDiskLatch.await();
                        } catch (InterruptedException ignored) {
                        }

                        if (DEBUG && mcr.wasWritten) {
                            Log.d(TAG, mFile.getName() + ":" + mcr.memoryStateGeneration
                                    + " applied after " + (System.currentTimeMillis() - startTime)
                                    + " ms");
                        }
                    }
                };

            QueuedWork.addFinisher(awaitCommit);

            Runnable postWriteRunnable = new Runnable() {
                    @Override
                    public void run() {
                        awaitCommit.run();
                        QueuedWork.removeFinisher(awaitCommit);
                    }
                };

            //提交任務(wù)
            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);
        }

單獨看以上邏輯會顯得十分奇怪诵叁,從上文就可以得知 writeToDiskRunnable 最終是會交由 HandlerThread 來執(zhí)行的雁竞,那按照流程看 awaitCommit 最終也是會由 HandlerThread 調(diào)用,那么 awaitCommit 的等待操作就顯得十分奇怪了拧额,因為 awaitCommit 肯定是會在磁盤任務(wù)執(zhí)行完畢才被調(diào)用碑诉,就相當(dāng)于 HandlerThread 在自己等待自己執(zhí)行完畢。此外侥锦,HandlerThread 屬于子線程进栽,按道理來說子線程即使執(zhí)行了耗時操作也不會導(dǎo)致主線程 ANR 才對
要理解以上操作,還需要再看看 ActivityThread 這個類恭垦。當(dāng) Service 和 Activity 的生命周期處于 handleStopService() 快毛、handlePauseActivity()handleStopActivity() 的時候番挺,ActivityThread 會調(diào)用 QueuedWork.waitToFinish() 方法

    private void handleStopService(IBinder token) {
        Service s = mServices.remove(token);
        if (s != null) {
            try {
                ···
                //重點
                QueuedWork.waitToFinish();
                ···
            } catch (Exception e) {
                ···
            }
        } else {
            Slog.i(TAG, "handleStopService: token=" + token + " not found.");
        }
        //Slog.i(TAG, "Running services: " + mServices);
    }

QueuedWork.waitToFinish()方法會主動去執(zhí)行所有的磁盤寫入任務(wù)唠帝,并執(zhí)行所有的 postWriteRunnable,這就造成了 Activity 或 Service 在切換生命周期的過程中有可能因為存在大量的磁盤寫入任務(wù)而被阻塞住玄柏,最終導(dǎo)致 ANR

    public static void waitToFinish() {
        long startTime = System.currentTimeMillis();
        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);
                if (DEBUG) {
                    hadMessages = true;
                    Log.d(LOG_TAG, "waiting");
                }
            }
            // We should not delay any work as this might delay the finishers
            sCanDelay = false;
        }
        StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskWrites();
        try {
            //執(zhí)行所有的磁盤寫入任務(wù)
            processPendingWork();
        } finally {
            StrictMode.setThreadPolicy(oldPolicy);
        }
        try {
            //執(zhí)行所有的 postWriteRunnable
            while (true) {
                Runnable finisher;
                synchronized (sLock) {
                    finisher = sFinishers.poll();
                }
                if (finisher == null) {
                    break;
                }
                finisher.run();
            }
        } finally {
            sCanDelay = true;
        }
        synchronized (sLock) {
            long waitTime = System.currentTimeMillis() - startTime;
            if (waitTime > 0 || hadMessages) {
                mWaitTimes.add(Long.valueOf(waitTime).intValue());
                mNumWaits++;
                if (DEBUG || mNumWaits % 1024 == 0 || waitTime > MAX_WAIT_TIME_MILLIS) {
                    mWaitTimes.log(LOG_TAG, "waited: ");
                }
            }
        }
    }

ActivityThread 為什么要主動去觸發(fā)執(zhí)行所有的磁盤寫入任務(wù)我無從得知襟衰,字節(jié)技術(shù)跳動團隊給出的猜測是:Google 在 Activity 和 Service 調(diào)用 onStop 之前阻塞主線程來處理 SP,我們能猜到的唯一原因是盡可能的保證數(shù)據(jù)的持久化粪摘。因為如果在運行過程中產(chǎn)生了 crash瀑晒,也會導(dǎo)致 SP 未持久化绍坝,持久化本身是 IO 操作,也會失敗
綜上所述苔悦,由于 SP 本身只支持全量更新轩褐,如果 SP 文件很大,即使是小數(shù)據(jù)量的 apply/commit 操作也有可能導(dǎo)致 ANR

正反面

SharedPreferencesImpl 在不同的系統(tǒng)版本中有著比較大的差別间坐,例如 writeToFile 方法對于任務(wù)版本號的校驗也是從 8.0 系統(tǒng)開始的灾挨,在 8.0 系統(tǒng)之前對于連續(xù)的 commit 和 apply 每次都會觸發(fā) I/O 操作,所以在 8.0 系統(tǒng)之前 ANR 問題會更加容易復(fù)現(xiàn)竹宋。我們需要根據(jù)系統(tǒng)版本來看待以上列舉出來的各個缺陷

需要強調(diào)的是劳澄,SP 本身的定位是輕量級數(shù)據(jù)存儲,設(shè)計初衷是用于存儲簡單的數(shù)據(jù)結(jié)構(gòu)(基本數(shù)據(jù)類型)蜈七,且提供了按模塊分區(qū)存儲的功能秒拔。如果開發(fā)者能夠嚴格遵守這一個規(guī)范的話,那么其實以上所述的很多“缺陷”都是可以避免的飒硅。而 SP 之所以現(xiàn)在看起來問題很多砂缩,也是因為如今大部分應(yīng)用的業(yè)務(wù)比以前復(fù)雜太多了,有些時候為了方便就直接用來存儲非常復(fù)雜的數(shù)據(jù)結(jié)構(gòu)三娩,或者是沒有做好數(shù)據(jù)分區(qū)存儲庵芭,導(dǎo)致單個文件過大,這才是造成問題的主要原因

如何做好持久化

以下的示例代碼估計是很多開發(fā)者的噩夢

val sharedPreference = getSharedPreferences("user_preference", Context.MODE_PRIVATE)
val name = sharedPreference.getString("name", "")

以上代碼存在什么問題呢雀监?我覺得至少有五點:

  • 強引用到了 SP双吆,導(dǎo)致后續(xù)需要切換存儲庫時需要全局搜索替換,工作量非常大
  • key 值難維護会前,每次獲取 value 時都需要顯式聲明 key 值
  • 可讀性差好乐,鍵值對的含義基本只能靠 key 值進行表示
  • 只支持基本數(shù)據(jù)類型,在存取自定義數(shù)據(jù)類型時存在很多重復(fù)工作瓦宜。要向 SP 存入自定義的 - JavaBean 對象時蔚万,只能將 Bean 對象轉(zhuǎn)為 Json 字符串后存入 SP,在取值時再手動反序列化
  • 數(shù)據(jù)類型不明確临庇,基本只能靠注釋來引導(dǎo)開發(fā)者使用正確的數(shù)據(jù)類型

開發(fā)者往往是會聲明出各種 SpUtils 類進行多一層封裝反璃,但也沒法徹底解決以上問題。SP 的確是存在著一些設(shè)計缺陷假夺,但對于大部分應(yīng)用開發(fā)者來說其實并沒有多少選擇淮蜈,我們只能選擇用或者不用,并沒有多少余地可以來解決或者避免其存在的問題侄泽,我們往往只能在遇到問題后切換到其它的持久化存儲方案
目前有兩個比較知名的持久化存儲方案:Jetpack DataStore 和騰訊的 MMKV,我們當(dāng)然可以選擇將項目中的 SP 切換為這兩個庫之一蜻韭,但這也不禁讓人想到一個問題悼尾,如果以后這兩個庫也遇到了問題甚至是直接被廢棄了柿扣,難道我們又需要再來全局替換一遍嗎?我們應(yīng)該如何設(shè)計才能使得每次的替換成本降到最低呢闺魏?
在我看來未状,開發(fā)者在為項目引入一個新的依賴庫之前就應(yīng)該為以后移除該庫做好準備,做好接口隔離析桥,屏蔽具體的底層邏輯(當(dāng)然司草,也不是每個依賴庫都可以做到)。筆者的項目之前也是使用 SP 來存儲配置信息泡仗,后來我也將其切換到了 MMKV埋虹,下面就來介紹下筆者當(dāng)時是如何設(shè)計存儲結(jié)構(gòu)避免硬編碼的

目前的效果

我將應(yīng)用內(nèi)所有需要存儲的鍵值對數(shù)據(jù)分為了三類:用戶強關(guān)聯(lián)數(shù)據(jù)、應(yīng)用配置數(shù)據(jù)娩怎、不可二次變更的數(shù)據(jù)搔课。每一類數(shù)據(jù)的存儲區(qū)域各不相同,互不影響截亦。進行數(shù)據(jù)分組的好處就在于可以根據(jù)需要來清除特定數(shù)據(jù)爬泥,例如當(dāng)用戶退登后我們可以只清除 UserKVHolder,而 PreferenceKVHolder 和 FinalKVHolder 則可以一直保留
IKVHolder 接口定義了基本的存取方法崩瓤,MMKVKVHolder 通過 MMKV 實現(xiàn)了具體的存儲邏輯

//和用戶強綁定的數(shù)據(jù)袍啡,在退出登錄時需要全部清除,例如 UserBean
//設(shè)置 encryptKey 以便加密存儲
private val UserKVHolder: IKVHolder = MMKVKVHolder("user", "加密key")

//和用戶不強關(guān)聯(lián)的數(shù)據(jù)却桶,在退出登錄時無需清除境输,例如夜間模式、字體大小等
private val PreferenceKVHolder: IKVHolder = MMKVKVHolder("preference")

//用于存儲不會二次變更只用于歷史溯源的數(shù)據(jù)肾扰,例如應(yīng)用首次安裝的時間畴嘶、版本號、版本名等
private val FinalKVHolder: IKVHolder = MMKVKVFinalHolder("final")

之后我們就可以利用 Kotlin 強大的語法特性來定義鍵值對了
例如集晚,對于和用戶強關(guān)聯(lián)的數(shù)據(jù)窗悯,每個鍵值對都定義為 UserKV 的一個屬性字段,鍵值對的含義和作用通過屬性名來進行標識偷拔,且鍵值對的 key 必須和屬性名保持一致蒋院,這樣可以避免 key 值重復(fù)。每個 getValue 操作也都支持設(shè)置默認值莲绰。IKVHolder 內(nèi)部通過 Gson 來實現(xiàn)序列化和反序列化欺旧,這樣 UserKV 就可以直接存儲 JavaBean、JavaBeanList蛤签,Map 等數(shù)據(jù)結(jié)構(gòu)了

object UserKV : IKVHolder by UserKVHolder {

    var name: String
        get() = get("name", "")
        set(value) = set("name", value)

    var blog: String
        get() = get("blog", "")
        set(value) = set("blog", value)

    var userBean: UserBean?
        get() = getBeanOrNull("userBean")
        set(value) = set("userBean", value)

    var userBeanOfDefault: UserBean
        get() = getBeanOrDefault(
            "userBeanOfDefault",
            UserBean("業(yè)志陳", "https://juejin.cn/user/923245496518439")
        )
        set(value) = set("userBeanOfDefault", value)

    var userBeanList: List<UserBean>
        get() = getBean("userBeanList")
        set(value) = set("userBeanList", value)

    var map: Map<Int, String>
        get() = getBean("map")
        set(value) = set("map", value)

}

此外辞友,我們也可以在 setValue 方法中對 value 進行校驗,避免無效值

object UserKV : IKVHolder by UserKVHolder {

    var age: Int
        get() = get("age", 0)
        set(value) {
            if (value <= 0) {
                return
            }
            set("age", value)
        }

}

之后我們在存取值時,就相當(dāng)于在直接讀寫 UserKV 的屬性值称龙,也支持動態(tài)指定 Key 進行賦值取值留拾,在易用性和可讀性上相比 SharedPreferences 都有很大的提升,且對于外部來說完全屏蔽了具體的存儲實現(xiàn)邏輯

//存值
UserKV.name = "xxxxx"
UserKV.blog = "https://juejin.cn/user/923245496518439"

//取值
val name = UserKV.name
val blog = UserKV.blog

//動態(tài)指定 Key 進行賦值和取值
UserKV.set("name", "xxx")
val name = UserKV.get("name", "")

如何設(shè)計的

首先鲫尊,IKVHolder 定義了基本的存取方法痴柔,除了需要支持基本數(shù)據(jù)類型外,還需要支持自定義的數(shù)據(jù)類型疫向。依靠 Kotlin 的 擴展函數(shù)內(nèi)聯(lián)函數(shù) 這兩個語法特性咳蔚,我們在存取自定義類型時都無需聲明泛型類型,使用上十分簡潔搔驼。JsonHolder 則是通過 Gson 實現(xiàn)了基本的序列化和反序列化方法

interface IKVHolder {

    companion object {

        inline fun <reified T> IKVHolder.getBean(key: String): T {
            return JsonHolder.toBean(get(key, ""))
        }

        inline fun <reified T> IKVHolder.getBeanOrNull(key: String): T? {
            return JsonHolder.toBeanOrNull(get(key, ""))
        }

        inline fun <reified T> IKVHolder.getBeanOrDefault(key: String, defaultValue: T): T {
            return JsonHolder.toBeanOrDefault(get(key, ""), defaultValue)
        }

        fun toJson(ob: Any?): String {
            return JsonHolder.toJson(ob)
        }

    }

    //數(shù)據(jù)分組谈火,用于標明不同范圍內(nèi)的數(shù)據(jù)緩存
    val keyGroup: String

    fun verifyBeforePut(key: String, value: Any?): Boolean

    fun get(key: String, default: Int): Int

    fun set(key: String, value: Int)

    fun <T> set(key: String, value: T?)

    fun containsKey(key: String): Boolean

    fun removeKey(vararg keys: String)

    fun allKeyValue(): Map<String, Any?>

    fun clear()
    
    ···

}

BaseMMKVKVHolder 實現(xiàn)了 IKVHolder 接口,內(nèi)部引入了 MMKV 作為具體的持久化存儲方案

/**
 * @param selfGroup 用于指定數(shù)據(jù)分組匙奴,不同分組下的數(shù)據(jù)互不關(guān)聯(lián)
 * @param encryptKey 加密 key堆巧,如果為空則表示不進行加密
 */
sealed class BaseMMKVKVHolder constructor(
    selfGroup: String,
    encryptKey: String
) : IKVHolder {

    final override val keyGroup: String = selfGroup

    override fun verifyBeforePut(key: String, value: Any?): Boolean {
        return true
    }

    private val kv: MMKV? = if (encryptKey.isBlank()) MMKV.mmkvWithID(
        keyGroup,
        MMKV.MULTI_PROCESS_MODE
    ) else MMKV.mmkvWithID(keyGroup, MMKV.MULTI_PROCESS_MODE, encryptKey)

    override fun set(key: String, value: Int) {
        if (verifyBeforePut(key, value)) {
            kv?.putInt(key, value)
        }
    }

    override fun <T> set(key: String, value: T?) {
        if (verifyBeforePut(key, value)) {
            if (value == null) {
                removeKey(key)
            } else {
                set(key, toJson(value))
            }
        }
    }

    override fun get(key: String, default: Int): Int {
        return kv?.getInt(key, default) ?: default
    }

    override fun containsKey(key: String): Boolean {
        return kv?.containsKey(key) ?: false
    }

    override fun removeKey(vararg keys: String) {
        kv?.removeValuesForKeys(keys)
    }

    override fun allKeyValue(): Map<String, Any?> {
        val map = mutableMapOf<String, Any?>()
        kv?.allKeys()?.forEach {
            map[it] = getObjectValue(kv, it)
        }
        return map
    }

    override fun clear() {
        kv?.clearAll()
    }

    ···

}

BaseMMKVKVHolder 有兩個子類,其區(qū)別只在于 MMKVKVFinalHolder 保存鍵值對后無法再次更改值泼菌,用于存儲不會二次變更只用于歷史溯源的數(shù)據(jù)谍肤,例如應(yīng)用首次安裝時的時間戳、版本號哗伯、版本名等

/**
 * @param selfGroup 用于指定數(shù)據(jù)分組荒揣,不同分組下的數(shù)據(jù)互不關(guān)聯(lián)
 * @param encryptKey 加密 key,如果為空則表示不進行加密
 */
class MMKVKVHolder constructor(selfGroup: String, encryptKey: String = "") :
    BaseMMKVKVHolder(selfGroup, encryptKey)

/**
 * 存儲后值無法二次變更
 * @param selfGroup 用于指定數(shù)據(jù)分組焊刹,不同分組下的數(shù)據(jù)互不關(guān)聯(lián)
 * @param encryptKey 加密 key系任,如果為空則表示不進行加密
 */
class MMKVKVFinalHolder constructor(selfGroup: String, encryptKey: String = "") :
    BaseMMKVKVHolder(selfGroup, encryptKey) {

    override fun verifyBeforePut(key: String, value: Any?): Boolean {
        return !containsKey(key)
    }

}

通過接口隔離,UserKV 就完全不會接觸到具體的存儲實現(xiàn)機制了虐块,對于開發(fā)者來說也只是在讀寫 UserKV 的一個屬性字段而已俩滥,當(dāng)后續(xù)我們需要替換存儲方案時,也只需要去改動 MMKVKVHolder 的內(nèi)部實現(xiàn)即可贺奠,上層應(yīng)用完全不需要進行任何改動

KVHolder

KVHolder 的實現(xiàn)思路還是十分簡單的霜旧,再加上 Kotlin 本身強大的語法特性就進一步提高了易用性和可讀性 ???? 我也將其發(fā)布為開源庫,感興趣的讀者可以直接遠程導(dǎo)入依賴

allprojects {
    repositories {
        maven { url "https://jitpack.io" }
    }
}

dependencies {
    implementation 'com.github.leavesC:KVHolder:latest_version'
}

GitHub 點擊這里:KVHolder

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末儡率,一起剝皮案震驚了整個濱河市挂据,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌儿普,老刑警劉巖崎逃,帶你破解...
    沈念sama閱讀 216,919評論 6 502
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異眉孩,居然都是意外死亡个绍,警方通過查閱死者的電腦和手機勒葱,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,567評論 3 392
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來巴柿,“玉大人错森,你說我怎么就攤上這事±航啵” “怎么了?”我有些...
    開封第一講書人閱讀 163,316評論 0 353
  • 文/不壞的土叔 我叫張陵殃姓,是天一觀的道長袁波。 經(jīng)常有香客問我,道長蜗侈,這世上最難降的妖魔是什么篷牌? 我笑而不...
    開封第一講書人閱讀 58,294評論 1 292
  • 正文 為了忘掉前任,我火速辦了婚禮踏幻,結(jié)果婚禮上枷颊,老公的妹妹穿的比我還像新娘。我一直安慰自己该面,他們只是感情好夭苗,可當(dāng)我...
    茶點故事閱讀 67,318評論 6 390
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著隔缀,像睡著了一般题造。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上猾瘸,一...
    開封第一講書人閱讀 51,245評論 1 299
  • 那天界赔,我揣著相機與錄音,去河邊找鬼牵触。 笑死淮悼,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的揽思。 我是一名探鬼主播袜腥,決...
    沈念sama閱讀 40,120評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼绰更!你這毒婦竟也來了瞧挤?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 38,964評論 0 275
  • 序言:老撾萬榮一對情侶失蹤儡湾,失蹤者是張志新(化名)和其女友劉穎特恬,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體徐钠,經(jīng)...
    沈念sama閱讀 45,376評論 1 313
  • 正文 獨居荒郊野嶺守林人離奇死亡癌刽,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,592評論 2 333
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片显拜。...
    茶點故事閱讀 39,764評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡衡奥,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出远荠,到底是詐尸還是另有隱情矮固,我是刑警寧澤,帶...
    沈念sama閱讀 35,460評論 5 344
  • 正文 年R本政府宣布譬淳,位于F島的核電站档址,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏邻梆。R本人自食惡果不足惜守伸,卻給世界環(huán)境...
    茶點故事閱讀 41,070評論 3 327
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望浦妄。 院中可真熱鬧尼摹,春花似錦、人聲如沸剂娄。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,697評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽阅懦。三九已至惠赫,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間故黑,已是汗流浹背儿咱。 一陣腳步聲響...
    開封第一講書人閱讀 32,846評論 1 269
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留场晶,地道東北人混埠。 一個月前我還...
    沈念sama閱讀 47,819評論 2 370
  • 正文 我出身青樓,卻偏偏與公主長得像诗轻,于是被迫代替她去往敵國和親钳宪。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 44,665評論 2 354

推薦閱讀更多精彩內(nèi)容