Android SharedPreference 源码解析 & ANR问题

前言

最近有一场阿里一面的电话面试,其中面试官问了一道有关SharedPreference(简称sp)的ANR问题,之前同事有遇到该类问题,不过回答面试的时候没有回答的很好,因此这里总结一下。

SharedPreference源码分析

使用方法

   val sharedPreferences = getSharedPreferences("TEST_SP", Context.MODE_PRIVATE)
   val editor = sharedPreferences.edit()
   editor.putString("KEY_TEST","KEY_TEST")
   // 异步
   editor.apply()
   // 同步
   editor.commit()

ok,就这么简单,我们看下通过Context.getSharedPreference(XX,XX)就可以获取Sharedpreference接口实例对象。文件路径位置在:

    文件路径:data/data/包名/shared_prefs/xxx.xml

    <?xml version='1.0' encoding='utf-8' standalone='yes' ?>
    <map>
       <string name="XXX">xxx</string>
       <string name="XXX">xxx</string>
    </map>
    @Override
    public SharedPreferences getSharedPreferences(String name, int mode) {
        return mBase.getSharedPreferences(name, mode);
    }

说句题外话,这个Context抽象类,ContextWrapper装饰代理类和ContextImpl具体实现类三者关系还蛮有意思的,等等我在写篇文章讲讲这三者之间的关系,我们就知道ContextWrapper中的mBase就是ContextImpl就可以了。

ContextImpl#getSharedPreferences()

    @Override
    public SharedPreferences getSharedPreferences(String name, int mode) {
        if (mPackageInfo.getApplicationInfo().targetSdkVersion <
                Build.VERSION_CODES.KITKAT) {
            if (name == null) {
                name = "null";
            }
        }
        File file;
        // 加锁
        synchronized (ContextImpl.class) {
            if (mSharedPrefsPaths == null) {
                // key为name value为File类的Map
                mSharedPrefsPaths = new ArrayMap<>();
            }
            // 获取name对应的File
            file = mSharedPrefsPaths.get(name);
            if (file == null) {
                // 若根据key拿到的File为空,则我们要进行一次生成File类 name.xml
                file = getSharedPreferencesPath(name);
                // 存储
                mSharedPrefsPaths.put(name, file);
            }
        }
        return getSharedPreferences(file, mode);
    }
@Override
    public SharedPreferences getSharedPreferences(File file, int mode) {
        SharedPreferencesImpl sp;
        // 加锁 保证多线程同步时只返回同一个SPImpl对象
        synchronized (ContextImpl.class) {
            // 一个File XML文件对应一个SharedPreferencesImpl类进行文件操作
            // 这里getSharedPreferencesCacheLocked()方法是根据包名进行隔离,就不详细说了
            final ArrayMap<File, SharedPreferencesImpl> cache = getSharedPreferencesCacheLocked();
            sp = cache.get(file);
            if (sp == null) {
                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 实例创建
                sp = new SharedPreferencesImpl(file, mode);
                cache.put(file, sp);
                return sp;
            }
        }
        // 多进程访问同一个SP文件的时候,如果文件被其他的进程修改,则重新加载文件
        if ((mode & Context.MODE_MULTI_PROCESS) != 0 ||
            getApplicationInfo().targetSdkVersion < android.os.Build.VERSION_CODES.HONEYCOMB) {
            sp.startReloadIfChangedUnexpectedly();
        }
        return sp;
    }

ok,到现在来说,我们已经很清晰了,Context#getSharedPreferences()最终是从ArrayMap中根据Key也就是File,取到SharedPreferencesImpl的实例。

高潮部分

SharedPreferencesImpl

先看看构造方法都有些什么...

  SharedPreferencesImpl(File file, int mode) {
        // .xml
        mFile = file;
        // 用于异常的回滚备份
        mBackupFile = makeBackupFile(file);
        // sp 模式
        mMode = mode;
        // 是否将.xml文件中的内容都读取到了内存中
        mLoaded = false;
        // 我们存档的数据项 key value
        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;
            stat = Os.stat(mFile.getPath());
            if (mFile.canRead()) {
                BufferedInputStream str = null;
                    // 输入流将文件读出来
                    str = new BufferedInputStream(
                            new FileInputStream(mFile), 16 * 1024);
                    // XmlUtils读Xml文件到Map中
                    map = (Map<String, Object>) XmlUtils.readMapXml(str);
                } catch (Exception e) {
                    Log.w(TAG, "Cannot read " + mFile.getAbsolutePath(), e);
                } finally {
                    IoUtils.closeQuietly(str);
                }
            }
        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();
            }
        }
    }

以上是从硬盘中的.xml文件读到内存中的过程。
先看下根据Key获取Value的过程吧。

   @GuardedBy("mLock")
    private Map<String, Object> mMap;

    @Override
    @Nullable
    public String getString(String key, @Nullable String defValue) {
        // 加锁
        synchronized (mLock) {
            // 1
            awaitLoadedLocked();
            // 2
            String v = (String)mMap.get(key);
            return v != null ? v : defValue;
        }
    }

代码2处很简单吧, 就是单纯的从HashMap中根据Key取Value,但注意代码1处,这个方法很关键。还记得我们在构造方法中,开辟了一个子线程进行将磁盘中的.xml文件读取到内存中的过程么?这个awaitLoadedLocked()的目的就是防止其他线程在调用getString()方法时,但是读.xml的工作子线程还没有读完,导致的获取数据问题而存在的。

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

其他的getXXX()方法类似就不详细说明了。

edit()

@Override
public Editor edit() {
        // 锁的原因 同样如getString(key,value)一样
        synchronized (mLock) {
            awaitLoadedLocked();
        }
        // 返回Editor实现类
        return new EditorImpl();
    }
public interface Editor {
      Editor putString(String key, @Nullable String value);
      Editor putInt(String key, int value);  
      // 移除某一个Key  
      Editor remove(String key);
      // 清空所有的内存中的Key Value  下文会提到一个注意事项
      Editor clear();
      // 同步提交 返回是否提交
      boolean commit();
      // 异步提交 无返回结果
      void apply();
      ....
}

Editor是一个接口,里边定义了一些putXXX的方法。

// EditorImpl 实现了Editor接口
public final class EditorImpl implements Editor {
    // 锁住的对象
    private final Object mEditorLock = new Object();
    // 将put进来的数据先保存在内存HashMap集合中
    @GuardedBy("mEditorLock")
    private final Map<String, Object> mModified = new HashMap<>();
    // 标记是否清空内存中的数据
    @GuardedBy("mEditorLock")
    private boolean mClear = false;
    // putXXX
    @Override
    public Editor putString(String key, @Nullable String value) {
            // 加锁
            synchronized (mEditorLock) {
                mModified.put(key, value);
                return this;
            }
        }

     // 这里remove某一个Key,并不是使用Map中remove方法 而是将这个Key对应的value设置为this,在之后进行提交到内存map中的时候不进行深拷贝这个key,目的是避免HashMap remove操作带来的性能损失。
     @Override
     public Editor remove(String key) {
            synchronized (mEditorLock) {
                mModified.put(key, this);
                return this;
            }
        }
   
    @Override
    public Editor clear() {
         synchronized (mEditorLock) {
             // 这里加入Clear标记。
             mClear = true;
             return this;
         }
    }
}

commit()方法

  @Override
        public boolean commit() {
            ...
            //  将修改的内容 mModified提交到内存mMap中
            MemoryCommitResult mcr = commitToMemory();
            // 排队写入磁盘
            SharedPreferencesImpl.this.enqueueDiskWrite(mcr, null);
            try {
                mcr.writtenToDiskLatch.await();
            } catch (InterruptedException e) {
                return false;
            }
            notifyListeners(mcr);
            return mcr.writeToDiskResult;
        }

commitToMemory()

private MemoryCommitResult commitToMemory() {
        ... 省略部分代码
        Map<String, Object> mapToWriteToDisk;
        // SharedPreferencesImpl的对象锁
        synchronized (SharedPreferencesImpl.this.mLock) {
                // 标记这个只是针对于apply方法作用的,因为apply方法是异步的,在主线程多次调用apply()方法会多次调用commitToMemory()方法并且mDiskWritesInFlight加1,但是向磁盘中提交数据可能还为完成,因此当磁盘提交完成后mDiskWritesInFlight 会减1。
                if (mDiskWritesInFlight > 0) {
                    mMap = new HashMap<String, Object>(mMap);
                }
               // 外部的内存中的Key Value
              mapToWriteToDisk = mMap;
              // 标记加一
              mDiskWritesInFlight++;
              // putXXX() 操作只能有一个线程进行操作
              synchronized (mEditorLock) {
              // ... 省略部分代码
              for (Map.Entry<String, Object> e : mModified.entrySet()) {
                        String k = e.getKey();
                        Object v = e.getValue();
                        if (v == this || v == null) {
                            // 这里就是remove操作,还记得remove操作的Value被设置了this,因此在这里要直接略过
                            if (!mapToWriteToDisk.containsKey(k)) {
                                continue;
                            }
                            // 直接remove掉
                            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) {
                            keysModified.add(k);
                        }
                        // 最终还是clear了 所以就不remove了
                        mModified.clear();
                    }
              }
        }
}

apply()

        // 省略部分代码
        @Override
        public void apply() {
            final long startTime = System.currentTimeMillis();
            final MemoryCommitResult mcr = commitToMemory();
            final Runnable awaitCommit = new Runnable() {
                    @Override
                    public void run() {
                        try {
                            // 1
                            mcr.writtenToDiskLatch.await();
                        } catch (InterruptedException ignored) {
                        }       
                    }
                };
            QueuedWork.addFinisher(awaitCommit);
            Runnable postWriteRunnable = new Runnable() {
                    @Override
                    public void run() {
                        // writtenToDiskLatch是CountDownLaunch,await()方法等待所有线程执行完毕之后再继续执行后面的
                        // 上方的代码1处调用await()
                        awaitCommit.run();
                        
                        QueuedWork.removeFinisher(awaitCommit);
                    }
                };
            // 这里传入了Runnable方法参与异步的构成
            SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);
            notifyListeners(mcr);
        }

enqueueDiskWrite()

// 注意这个方法的参数2 就是用来区分是异步提交还是同步提交
    private void enqueueDiskWrite(final MemoryCommitResult mcr, final Runnable postWriteRunnable) {
         // Runnable 参数是否为空 为空就是同步方法
         final boolean isFromSyncCommit = (postWriteRunnable == null);
         final Runnable writeToDiskRunnable = new Runnable() {
                @Override
                public void run() {
                    synchronized (mWritingToDiskLock) {
                        // 写入磁盘 就是拿到File的输出流,利用XmlUtil写入。
                        writeToFile(mcr, isFromSyncCommit);
                    }
                    synchronized (mLock) {
                        // 写入磁盘操作以后 标记减1 之前有提过
                        mDiskWritesInFlight--;
                    }
                    if (postWriteRunnable != null) {
                        // 执行
                        postWriteRunnable.run();
                    }
                }
            };
         // 同步方法
         if (isFromSyncCommit) {
                boolean wasEmpty = false;
                synchronized (mLock) {
                    // 由于是同步的 所以写入标记一定为1
                    wasEmpty = mDiskWritesInFlight == 1;
                }
                if (wasEmpty) {
                    // 执行
                    writeToDiskRunnable.run();
                    return;
                }
            }
            // 加入异步队列中写入磁盘 apply方法需要执行的
            QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);
    }

writeToFile()

private void writeToFile(MemoryCommitResult mcr, boolean isFromSyncCommit) {
     // ... 省略部分代码
     if (fileExists) {
            boolean needsWrite = false;
            // 每次更新内存写入内存版本号加 1 用于判断是不是最新的
            if (mDiskStateGeneration < mcr.memoryStateGeneration) {
                if (isFromSyncCommit) {
                    needsWrite = true;
                } else {
                    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)) {
                    mcr.setDiskWriteResult(false, false);
                    return;
                }
            } else {
                mFile.delete();
            }
        }
           /******** 开始写入  ********/
           XmlUtils.writeMapXml(mcr.mapToWriteToDisk, str);

            writeTime = System.currentTimeMillis();

            FileUtils.sync(str);

            fsyncTime = System.currentTimeMillis();

            str.close();

            ContextImpl.setFilePermissionsFromMode(mFile.getPath(), mMode, 0);
            // 写入成功结果
            mcr.setDiskWriteResult(true, true);
}

QueuedWork#queue()

    // 写入磁盘操作任务队列
    private static final LinkedList<Runnable> sWork = new LinkedList<>();
    public static void queue(Runnable work, boolean shouldDelay) {
        // 1
        Handler handler = getHandler();
        // 加锁
        synchronized (sLock) {
            // 加入到Runnable队列中
            sWork.add(work);
            if (shouldDelay && sCanDelay) {
                // 通过Handler发送执行消息
                handler.sendEmptyMessageDelayed(QueuedWorkHandler.MSG_RUN, DELAY);
            } else {
                handler.sendEmptyMessage(QueuedWorkHandler.MSG_RUN);
            }
        }
    }

这里代码1 我们来看下是如何创建的子线程来执行任务队列的。

  private static Handler getHandler() {
        synchronized (sLock) {
            if (sHandler == null) {
                // 这里利用了Android提供的HandlerThread帮我们封装好的子线程与主线程通信的类,本质上是继承于Thread,并且为Thread创建了Looper继而创建了MessageQueue。
                HandlerThread handlerThread = new HandlerThread("queued-work-looper",
                        Process.THREAD_PRIORITY_FOREGROUND);
                handlerThread.start();
                // 这里又单独创建了一个Handler但是传入的Looper是HandlerThread中的Looper。
                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;
        // 1
        synchronized (sProcessingWork) {
            LinkedList<Runnable> work;
            // 2
            synchronized (sLock) {
                // 一旦获取到本类的锁 对执行队列克隆一份
                work = (LinkedList<Runnable>) sWork.clone();
                // 清除执行队列
                sWork.clear();
                getHandler().removeMessages(QueuedWorkHandler.MSG_RUN);
            }
            if (work.size() > 0) {
                for (Runnable w : work) {
                    // 遍历执行
                    w.run();
                }
            }
        }
    }

代码1,2处被锁了两次,这里为什么要锁两次呢?注释中给出了答案。

    /** Lock for this class */
    private static final Object sLock = new Object();

    /**
     * Used to make sure that only one thread is processing work items at a time. This means that
     * they are processed in the order added.
     *
     * This is separate from {@link #sLock} as this is held the whole time while work is processed
     * and we do not want to stall the whole class.
     */
    private static Object sProcessingWork = new Object();

简单的说就是确保在同一时间只有一个线程对其进行操作,并且也意味着写入磁盘操作时按照任务队列的顺序进行执行的,sProcessingWork 所住的时整个类,只能有一个线程进入sProcessingWork锁代码块,然后让这有且只有这一个Thread去等待sLock锁的释放。
ok,到了这里,我们已经对SP有个深入的理解了。

ANR 问题

其实面试官考察的一般会问有没有遇到过apply()方法导致的ANR问题,但我们也别忘了commit()方法也会导致ANR问题。出现ANR一般是在低端机型以及Android 8.0以下会复现出来(Android8.0理论上也会出现),所以我们切换到Android 7.0看看

apply()

回头我们再来看下该方法。

   @Override
        public void apply() {
            final MemoryCommitResult mcr = commitToMemory();
            final Runnable awaitCommit = new Runnable() {
                    @Override
                    public void run() {
                        try {
                            // 2
                            mcr.writtenToDiskLatch.await();
                        } catch (InterruptedException ignored) {
                        }
                    }
                };
            // 1
            QueuedWork.add(awaitCommit);
            Runnable postWriteRunnable = new Runnable() {
                    @Override
                    public void run() {
                        awaitCommit.run();
                        QueuedWork.remove(awaitCommit);
                    }
                };
            SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);
            notifyListeners(mcr);
        }

apply()方法并没有什么问题,最大的变化在于QueuedWork类。

    public static void waitToFinish() {
        Runnable toFinish;
        while ((toFinish = sPendingWorkFinishers.poll()) != null) {
            // 1 
            toFinish.run();
        }
    }

由于我们apply的数据过于大而又频繁,所以在我们跳转Activity的过程中ActivityThread#handlePauseActivity()调用了WorkQueue()为了确保数据已经向磁盘提交成功,但是这时候CountDownLaunch还在等待子线程结束写入,所以一直在堵塞主线程,因此导致的ANR。

ActivityThread#handlePauseActivity()

    @Override
    public void handlePauseActivity(IBinder token, boolean finished, boolean userLeaving,
            int configChanges, PendingTransactionActions pendingActions, String reason) {
        ActivityClientRecord r = mActivities.get(token);
        if (r != null) {
            if (userLeaving) {
                performUserLeavingActivity(r);
            }

            r.activity.mConfigChangeFlags |= configChanges;
            performPauseActivity(r, finished, reason, pendingActions);

            // 确保数据写入完成
            if (r.isPreHoneycomb()) {
                QueuedWork.waitToFinish();
            }
            mSomeActivitiesChanged = true;
        }
    }

ok,我们看到在主线程Activity各个生命周期都会等待写入磁盘任务执行完毕时再去执行相应的生命周期,因为我们将任务加入队列中是通过应用托管运行的,可能由于系统内存吃紧,进程被回收或者用户主动杀死的情况下,导致写入失败,在这种不确定的情况下,所以会在生命周期的方法完成写入磁盘的操作。

ANR的解决办法

一种办法就是我们可以Hook,ActivityThread中的handler变量,处理Callback,然后反射拿到WorkQueue对象,清空等待队列,具体代码看字节跳动分析SP ANR问题这篇文章。

使用建议

1、我们知道commit()方法是同步的,所以在工作线程的时候我们就使用commit()即可,开销小。
2、主线程用apply。
3、保存的文件尽量不要太大,分模块读写sp文件。
4、存储的文件要小,不要存储大量的数据配置信息,例如长串的JSON字符串。
5、连续调用putXXX()时,不需要立即读取的情况下,最后一次调用commit或者apply方法。
6、终极办法改用MMKV腾讯出品

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