SharedPreferences源码分析与优化方案

一、概述

SharedPreferencesAndroid中一种用来保存相对比较小键值对数据的存储方式,比较适合保存类似用户偏好设置等小数据。SharedPreferences采用XML文件格式方式保存数据,文件位于data/data/packagename/shared_prefs目录下。SharedPreferences适合轻度使用,重度使用可能导致应用犯病。Android官方也觉得有问题,已在Jetpack下研发一个新的解决方案DataStore,以异步、一致的事务方式存储数据,旨在取代 SharedPreferences,截止目前版本处于1.0.0-alpha04。本文源码来于API 29

问题:

  • SharedPreferencesapply()commit()区别?
  • 会造成ANR吗?
  • 能在多进程下使用吗?

二、简单使用

   //保存数据
    fun save(view: View) {
        val sp = getSharedPreferences("test", Context.MODE_PRIVATE)
        val editor = sp.edit()
        editor.putString("key", "value")
        editor.putInt("num", 1)
        editor.apply()
    }
    //读取数据
    fun read(view: View) {
        val sp = getSharedPreferences("test", Context.MODE_PRIVATE)
        val str = sp.getString("key", "")
        val num = sp.getInt("num",0)
        println(str)
        println(num)
    }

三、SP的创建和缓存,ContextImpl分析

SharedPreferences是一个接口,它的实现类是SharedPreferencesImpl,通过ActivitygetSharedPreferences()方法可以跟踪到它的实现类。 在这里会将创建的xml文件保存起来,以免多次创建。在创建SharedPreferencesImpl实例时,还会检测modeAPI 24后不再支持MODE_WORLD_READABLEMODE_WORLD_WRITEABLE模式。创建后的实例会保存在一个staticmap里,所以在整个app的存活过程中,所有实例化后的数据不会被回收,所以不能保存太多的数据,防止OOM。在多进程的模式下,获取SP的实例会第二次加载,在后面的分析中可以看出这是一个耗时的IO操作,所以多进程模式会降低了SP的效率,而且不可靠。

    ContextWrapper.java
    @Override
    public SharedPreferences getSharedPreferences(String name, int mode) {
        //这里的mBase是ContextImpl.java
        return mBase.getSharedPreferences(name, mode);
    }
    ContextImpl.java
    private ArrayMap<String, File> mSharedPrefsPaths;
    @Override
    public SharedPreferences getSharedPreferences(String name, int mode) {
        // 解决以前问题
        if (mPackageInfo.getApplicationInfo().targetSdkVersion <
                Build.VERSION_CODES.KITKAT) {
            if (name == null) {
                name = "null";
            }
        }
        //按文件名为key,文件为value保存在ArrayMap里
        File file;
        synchronized (ContextImpl.class) {
            if (mSharedPrefsPaths == null) {
                mSharedPrefsPaths = new ArrayMap<>();
            }
            file = mSharedPrefsPaths.get(name);
            if (file == null) {
                //在shared_prefs目录下创建该XML文件
                file = getSharedPreferencesPath(name);
                mSharedPrefsPaths.put(name, file);
            }
        }
        return getSharedPreferences(file, mode);
    }
    @Override
    public File getSharedPreferencesPath(String name) {
        return makeFilename(getPreferencesDir(), name + ".xml");
    }
    @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) {
                //API 24后不再支持MODE_WORLD_READABLE和MODE_WORLD_WRITEABLE模式
                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");
                    }
                }
                //创建并缓存
                sp = new SharedPreferencesImpl(file, mode);
                cache.put(file, sp);
                return sp;
            }
        }
       //多进程模式或API小于11
        if ((mode & Context.MODE_MULTI_PROCESS) != 0 ||
            getApplicationInfo().targetSdkVersion < android.os.Build.VERSION_CODES.HONEYCOMB) {
            //重新加载一次
            sp.startReloadIfChangedUnexpectedly();
        }
        return sp;
    }

    private static ArrayMap<String, ArrayMap<File, SharedPreferencesImpl>> sSharedPrefsCache;
    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;
    }

四、SP的数据加载和读取

1.加载

SharedPreferencesImpl初始化后,会开启一个新的线程去加载文件,然后将xml文件的数据读入map中。

   //初始化
    SharedPreferencesImpl(File file, int mode) {
        mFile = file;
        mBackupFile = makeBackupFile(file);
        mMode = mode;
        mLoaded = false;
        mMap = null;
        mThrowable = null;
        startLoadFromDisk();
    }
   //开始异步加载
    private void startLoadFromDisk() {
        synchronized (mLock) {
            mLoaded = false;
        }
        new Thread("SharedPreferencesImpl-load") {
            public void run() {
                loadFromDisk();
            }
        }.start();
    }
    private void loadFromDisk() {
        synchronized (mLock) {
            if (mLoaded) {//已经加载了就返回
                return;
            }
            if (mBackupFile.exists()) {//有备份文件则直接将备份文件改为此文件
                mFile.delete();
                mBackupFile.renameTo(mFile);
            }
        }
        Map<String, Object> map = null;
        StructStat stat = null;
        Throwable thrown = null;
        try {
            stat = Os.stat(mFile.getPath());
            if (mFile.canRead()) {
                BufferedInputStream str = null;
                try {
                   //通过XmlUtils将文件读进map里
                    str = new BufferedInputStream(
                            new FileInputStream(mFile), 16 * 1024);
                    map = (Map<String, Object>) XmlUtils.readMapXml(str);
                } catch (Exception e) {
                } finally {
                    IoUtils.closeQuietly(str);
                }
            }
        } catch (ErrnoException e) {
        } catch (Throwable t) {
            thrown = t;
        }

        synchronized (mLock) {
            mLoaded = true;
            mThrowable = thrown;
            try {
               //确定没报异常,在对加载的map赋值,记录记载的时间和文件大小
                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();
            }
        }
    }

2.读取数据getXXX()

我们一般会在UI线程读取数据,通过上面分析加载文件是在其它线程,如果文件没加载完(loadFromDisk没执行结束),在这里的读取操作就能看出,UI线程就会wait,如果文件比较大,加载的时间长,就会导致UI线程长时间等待。

    public String getString(String key, @Nullable String defValue) {
        synchronized (mLock) {
            awaitLoadedLocked();
            String v = (String)mMap.get(key);
            return v != null ? v : defValue;
        }
    }
    private void awaitLoadedLocked() {
        if (!mLoaded) {//StrictMode相关检查
            BlockGuard.getThreadPolicy().onReadFromDisk();
        }
        while (!mLoaded) {
            try {
                mLock.wait(); //还没加载完就等起
            } catch (InterruptedException unused) {
            }
        }
        if (mThrowable != null) {//有异常就在里抛出去
            throw new IllegalStateException(mThrowable);
        }
    }

五、数据保存apply和commit

1.待提交

每次调用edit方法会返回一个新的EditorImpl对象,然后在调用putXXX()方法,会把要待提交的数据放进mModified的一个map里。

    public Editor commit() {
        synchronized (mLock) {
            awaitLoadedLocked();
        }
        return new EditorImpl();
    }

    EditorImpl.java
    private final Object mEditorLock = new Object();
    @GuardedBy("mEditorLock")
    private final Map<String, Object> mModified = new HashMap<>();
    private boolean mClear = false;

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

2.commit

commit方法会先将修改的数据提交到内存,放进SPmap里。还会在commitToMemory方法里记录是否真实的发生了改变(keyvalue发生了改变),如果变化了,才会写文件,减少IO操作。commit会在主线程一直等待文件写完,然后返回结果。

从这可以看出commit会在主线程提交数据并等待执行结果,如果数据较大,写入文件时间过于耗时则会阻塞主线程,造成卡顿。

public boolean commit() {
    //将数据更新到SP里
    MemoryCommitResult mcr = commitToMemory();
    //将数据同步到文件里
    SharedPreferencesImpl.this.enqueueDiskWrite( mcr, null);
    try {
        //等文件写完
        mcr.writtenToDiskLatch.await();
    } catch (InterruptedException e) {
        return false;
    } finally {
    }
    //通知监听者
    notifyListeners(mcr);
    return mcr.writeToDiskResult;
}

private MemoryCommitResult commitToMemory() {
    long memoryStateGeneration;
    List<String> keysModified = null;
    Set<OnSharedPreferenceChangeListener> listeners = null;
    Map<String, Object> mapToWriteToDisk;

    synchronized (SharedPreferencesImpl.this.mLock) {
        if (mDiskWritesInFlight > 0) {
            mMap = new HashMap<String, Object>(mMap);
        }
        mapToWriteToDisk = mMap;
        mDiskWritesInFlight++;

        boolean hasListeners = mListeners.size() > 0;
        if (hasListeners) {//监听key变化的监听者
            keysModified = new ArrayList<String>();
            listeners = new HashSet<OnSharedPreferenceChangeListener>(mListeners.keySet());
        }

        synchronized (mEditorLock) {
            //是否真的发生了变化
            boolean changesMade = false;
            if (mClear) {//如果标记了清空,则直接把map清空
                if (!mapToWriteToDisk.isEmpty()) {
                    changesMade = true;
                    mapToWriteToDisk.clear();
                }
                mClear = false;
            }

            for (Map.Entry<String, Object> e : mModified.entrySet()) {
                String k = e.getKey();
                Object v = e.getValue();
                //value的值为editor对象本身或者null意味着要删除这个key
                if (v == this || v == null) {
                    if (!mapToWriteToDisk.containsKey(k)) {
                        continue;
                    }
                    mapToWriteToDisk.remove(k);
                } else {
                    if (mapToWriteToDisk.containsKey(k)) {
                        Object existingValue = mapToWriteToDisk.get(k);
                        //检测是否真的发生了变化
                        if (existingValue != null && existingValue.equals(v)) {
                            continue;
                        }
                    }
                    mapToWriteToDisk.put(k, v);
                }
                //真变了
                changesMade = true;
                if (hasListeners) {//这个key变了,记录
                    keysModified.add(k);
                }
            }
            //把刚才提交在editor的数据清空(已经提交到SP的map里了)
            mModified.clear();

            if (changesMade) {
                mCurrentMemoryStateGeneration++;
            }

            memoryStateGeneration = mCurrentMemoryStateGeneration;
        }
    }
    return new MemoryCommitResult(memoryStateGeneration, keysModified, listeners,
            mapToWriteToDisk);
}
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) {//commit没有,apply有
                    postWriteRunnable.run();
                }
            }
        };
    if (isFromSyncCommit) {//commit直接在这执行写入文件
        boolean wasEmpty = false;
        synchronized (mLock) {
            //在commitToMemory方法中该值被加1,所以这为true
            wasEmpty = mDiskWritesInFlight == 1;
        }
        if (wasEmpty) {
            //直接把writeToDiskRunnable在该线程执行了然后返回
            writeToDiskRunnable.run();
            return;
        }
    }
    //apply走这
    QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);
}


3.apply

applycommit不一样的是没有返回值,把数据写到内存后,直接把写文件任务放到其它线程排队执行。

public void apply() {
    //将数据更新到SP里
    final MemoryCommitResult mcr = commitToMemory();
    //这里的一串Runable有点多,主要的作用还是把任务放进QueuedWork里去执行。必要的时候需要阻塞主线程等一等。
    final Runnable awaitCommit = new Runnable() {
            @Override
            public void run() {
                try {
                    mcr.writtenToDiskLatch.await();
                } catch (InterruptedException ignored) {
                }
            }
        };

    QueuedWork.addFinisher(awaitCommit);

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

    SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);
    notifyListeners(mcr);
}

4. writeToFile

把数据写入文件中,在这会检测是否真正需要写入文件到磁盘,commit会直接写入文件,apply会检测这是不是最后一次提交,这只需要把最后一次的数据写入磁盘即可。FileUtils.sync(str)操作确定数据落盘,而不是写到缓冲区中,确保数据不会因为设备断电等原因丢失。

private void writeToFile(MemoryCommitResult mcr, boolean isFromSyncCommit) {
    long startTime = 0;
    long existsTime = 0;
    long backupExistsTime = 0;
    long outputStreamCreateTime = 0;
    long writeTime = 0;
    long fsyncTime = 0;
    long setPermTime = 0;
    long fstatTime = 0;
    long deleteTime = 0;
    boolean fileExists = mFile.exists();

    // 重命名当前文件,以便在下次读取时将其用作备份
    if (fileExists) {
        boolean needsWrite = false;//是否需要写文件

        // 确保发生了变化
        if (mDiskStateGeneration < mcr.memoryStateGeneration) {
            if (isFromSyncCommit) {//commit方式要写文件
                needsWrite = true;
            } else {//apply方式只需要写最后一次提交的就行
                synchronized (mLock) {
                    if (mCurrentMemoryStateGeneration == mcr.memoryStateGeneration) {
                        needsWrite = true;
                    }
                }
            }
        }
        if (!needsWrite) {//不需要写磁盘就返回
            mcr.setDiskWriteResult(false, true);
            return;
        }

        boolean backupFileExists = mBackupFile.exists();

        if (!backupFileExists) {
            if (!mFile.renameTo(mBackupFile)) {//文件备份
                Log.e(TAG, "Couldn't rename file " + mFile
                      + " to backup file " + mBackupFile);
                mcr.setDiskWriteResult(false, false);
                return;
            }
        } else {
            mFile.delete();
        }
    }

   //尝试写入文件、删除备份并尽可能原子地返回 true。如果出现任何异常,删除新文件; 下次我们将从备份中恢复
    try {
        FileOutputStream str = createFileOutputStream(mFile);
        if (str == null) {
            mcr.setDiskWriteResult(false, false);
            return;
        }
        XmlUtils.writeMapXml(mcr.mapToWriteToDisk, str);
        writeTime = System.currentTimeMillis();
       //确保文件落盘
        FileUtils.sync(str);

        fsyncTime = System.currentTimeMillis();

        str.close();
        ContextImpl.setFilePermissionsFromMode(mFile.getPath(), mMode, 0);
        try {
            final StructStat stat = Os.stat(mFile.getPath());
            synchronized (mLock) {//记录文件的时间和大小
                mStatTimestamp = stat.st_mtim;
                mStatSize = stat.st_size;
            }
        } catch (ErrnoException e) {
            // Do nothing
        }
        // 写入成功,把备份文件删除
        mBackupFile.delete();
        mDiskStateGeneration = mcr.memoryStateGeneration;
        //写入成功,释放锁,返回结果
        mcr.setDiskWriteResult(true, true);

        long fsyncDuration = fsyncTime - writeTime;
        mSyncTimes.add((int) fsyncDuration);
        mNumSync++;
        return;
    } catch (XmlPullParserException e) {
       
    } catch (IOException e) {
    }
    //写入失败,把文件删除
    if (mFile.exists()) {
        if (!mFile.delete()) {
            Log.e(TAG, "Couldn't clean up partially-written file " + mFile);
        }
    }
    mcr.setDiskWriteResult(false, false);
}

5.startReloadIfChangedUnexpectedly

modeMODE_MULTI_PROCESS情况下,会判断文件是否发生了变化,如果变化了则重新加载一次文件到SP中。所以多进程下,使用SP会降低效率,因为SP里的同步是线程同步,进程间使用也不能保证数据的正确性,所以不能在多进程下使用。官方也已经将MODE_MULTI_PROCESS标记为Deprecated

void startReloadIfChangedUnexpectedly() {
    synchronized (mLock) {
        if (!hasFileChangedUnexpectedly()) {
            return;
        }
        startLoadFromDisk();
    }
}
private boolean hasFileChangedUnexpectedly() {
    synchronized (mLock) {
        if (mDiskWritesInFlight > 0) {
            if (DEBUG) Log.d(TAG, "disk write in flight, not unexpected.");
            return false;
        }
    }

    final StructStat stat;
    try {
        BlockGuard.getThreadPolicy().onReadFromDisk();
        stat = Os.stat(mFile.getPath());
    } catch (ErrnoException e) {
        return true;
    }
    synchronized (mLock) {//通过文件的更新时间和大小来判断文件是否发生了变化
        return !stat.st_mtim.equals(mStatTimestamp) || mStatSize != stat.st_size;
    }
}

六、QueuedWork.java

正常的流程,apply会通过queue方法将任务添加进一个LinkedListsWork中,然后通过HandlerThread单线程顺序的执行写入任务。

public static void queue(Runnable work, boolean shouldDelay) {
    Handler handler = getHandler();

    synchronized (sLock) {
        //添加进列表里
        sWork.add(work);
        //发送消息给任务线程执行写文件任务
        if (shouldDelay && sCanDelay) {
            handler.sendEmptyMessageDelayed(QueuedWorkHandler.MSG_RUN, DELAY);
        } else {
            handler.sendEmptyMessage(QueuedWorkHandler.MSG_RUN);
        }
    }
}
private static Handler getHandler() {
    synchronized (sLock) {
        if (sHandler == null) {
            HandlerThread handlerThread = new HandlerThread("queued-work-looper",
                    Process.THREAD_PRIORITY_FOREGROUND);
            handlerThread.start();

            sHandler = new QueuedWorkHandler(handlerThread.getLooper());
        }
        return sHandler;
    }
}
private static class QueuedWorkHandler extends Handler {
    static final int MSG_RUN = 1;

    QueuedWorkHandler(Looper looper) {
        super(looper);
    }

    public void handleMessage(Message msg) {
        if (msg.what == MSG_RUN) {
            processPendingWork();
        }
    }
}
private static void processPendingWork() {
    long startTime = 0;
    synchronized (sProcessingWork) {
        LinkedList<Runnable> work;

        synchronized (sLock) {
            work = (LinkedList<Runnable>) sWork.clone();
            sWork.clear();
            getHandler().removeMessages(QueuedWorkHandler.MSG_RUN);
        }
        //循环执行任务
        if (work.size() > 0) {
            for (Runnable w : work) {
                w.run();
            }
        }
    }
}

另外的一种情况,在QueuedWork有个waitToFinish方法,它在ActivityThreadhandleStopServicehandleStopActivity等方法中有调用,调用处主要有ActivityonPauseBroadcastReceiveronReceiveServicestop等情况下。通过代码可以看出,在这些情况下,如果写文件任务还有没完成的,直接把写任务放在主线程执行了,取消其它线程执行该任务。这样可以保证写文件任务完成,防止数据丢失。在API 8.0以前waitToFinish方式实现不一样,以前直接阻塞主线程等待任务完成。

public static void waitToFinish() {
    long startTime = System.currentTimeMillis();
    boolean hadMessages = false;

    Handler handler = getHandler();

    synchronized (sLock) {
        if (handler.hasMessages(QueuedWorkHandler.MSG_RUN)) {
            // 下面要直接执行,所以在这删除了
            handler.removeMessages(QueuedWorkHandler.MSG_RUN);
        }
        // 不能延迟等了
        sCanDelay = false;
    }

    StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskWrites();
    try {
        //直接把任务拿出来执行
        processPendingWork();
    } finally {
        StrictMode.setThreadPolicy(oldPolicy);
    }

    try {
        while (true) {//把apply添加的清尾工作finisher执行了
            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: ");
            }
        }
    }
}

七、总结

综上,

  • commitapply一个有返回值,一个没有,一个同步提交,一个异步提交。
  • 一个文件里的key过多会导致文件大,加载变慢,写入变慢。读取数据时没加载完就会阻塞主线程,造成卡顿,严重就会ANR,可以将key按功能拆分文件。SP内存不会回收,所以文件多了也不行,本身定位就是小数据保存。
  • 不能在多进程使用
  • 不需要结果时,尽量使用applycommit会在主线程写文件。但是apply也不一定可靠,在waitToFinish里叙述了。
  • 每个版本可能实现不一样,表现不一样,加大了bug修复难度,所以类似放进Jetpack里单独可升级感觉有必要。

本文参考:
全面剖析SharedPreferences
Android 之不要滥用 SharedPreferences(下)
SharedPreferences ANR问题分析和解决 & Android 8.0的优化
剖析 SharedPreference apply 引起的 ANR 问题

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 198,154评论 5 464
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,252评论 2 375
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 145,107评论 0 327
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,985评论 1 268
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,905评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 47,256评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,978评论 3 388
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,611评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,891评论 1 293
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,910评论 2 314
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,736评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,516评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,995评论 3 301
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,132评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,447评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,034评论 2 343
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,242评论 2 339

推荐阅读更多精彩内容