为什么Looper.loop()中的死循环不会导致ANR

如需转载请评论或简信,并注明出处,未经允许不得转载

目录

前言

之前写过一篇文章Android消息机制(Handler、Looper、MessageQueue),这篇文章主要是从Java层入手,分析了和我们实际开发最贴近的消息机制原理。自认为对java层的消息机制流程已经非常熟悉了,但是面试是被问到“Handler是如何实现延迟消息的?”,“为什么Looper.loop()中的死循环不会导致ANR?”这些问题的时候,还是不能完全说出个所以然来,所以就决定再去看看native层到底做了哪些事情。如果你对java层的消息机制还不是特别熟悉的话,建议先看上一篇文章哦

消息队列的创建

从上一章中,我们知道MessageQueue是在Looper的构造方法中创建的,但是我们没有对MessageQueue做更深入的分析

Looper.java

private Looper(boolean quitAllowed) {
    mQueue = new MessageQueue(quitAllowed);
    mThread = Thread.currentThread();
}

跟进一下MessageQueue的构造方法,看看里面做了什么

MessageQueue.java

MessageQueue(boolean quitAllowed) {
    mQuitAllowed = quitAllowed;
    mPtr = nativeInit();
}

这里有一个nativeInit()方法,这是一个native层的方法

core/jni/android_os_MessageQueue.cpp

static jlong android_os_MessageQueue_nativeInit(JNIEnv* env, jclass clazz) {
    NativeMessageQueue* nativeMessageQueue = new NativeMessageQueue();
    ...
    //增加引用计数
    nativeMessageQueue->incStrong(env);
    //使用强制类型转换符reinterpret_cast把NativeMessageQueue指针强转成long类型并返回到java层
    return reinterpret_cast<jlong>(nativeMessageQueue);
}
NativeMessageQueue::NativeMessageQueue() :
        mPollEnv(NULL), mPollObj(NULL), mExceptionObj(NULL) {
    //从当前线程的本地缓存(相当于ThreadLocal)拿到looper
    mLooper = Looper::getForThread();
    if (mLooper == NULL) {
        //如果looper == null,创建一个looper加入到线程本地缓存中
        mLooper = new Looper(false);
        Looper::setForThread(mLooper);
    }
}

我们发现,native层中也会创建一个LooperMessageQueue,且native层的Looper创建和获取方式和java层非常的相似。native层的Looper与Java层的Looper没有必然的关系,只是在native层重实现了一套类似功能的逻辑

system/core/libutils/Looper.cpp

再来看看Looper的构造方法做了什么

Looper::Looper(bool allowNonCallbacks) :
        mAllowNonCallbacks(allowNonCallbacks), mSendingMessage(false),
        mPolling(false), mEpollFd(-1), mEpollRebuildRequired(false),
        mNextRequestSeq(0), mResponseIndex(0), mNextMessageUptime(LLONG_MAX) {
    //创建fd(文件操作符)      
    mWakeEventFd = eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC);
    LOG_ALWAYS_FATAL_IF(mWakeEventFd < 0, "Could not make wake event fd: %s",
                        strerror(errno));

    AutoMutex _l(mLock);
    //重建管道
    rebuildEpollLocked();
}

这个eventfd实际上就是一个文件描述符,那什么是文件描述符呢?

文件描述符:简称fd,它就是一个int值,又叫做句柄,在Linux中,打开或新建一个文件,它会返回一个文件描述符,读写文件需要使用文件描述符来指定待读写的文件,所以文件描述符就是指代被打开的文件,所有对这个文件的IO操作都要通过文件描述符

void Looper::rebuildEpollLocked() {
    //关闭旧的管道
    if (mEpollFd >= 0) {
        close(mEpollFd);
    }
    //创建一个新的epoll文件描述符,并注册wake管道
    mEpollFd = epoll_create(EPOLL_SIZE_HINT);
    struct epoll_event eventItem;
    memset(& eventItem, 0, sizeof(epoll_event)); 
  
    //设置mWakeEventFd的可读事件(EPOLLIN)监听
    eventItem.events = EPOLLIN;
    eventItem.data.fd = mWakeEventFd;

    //将唤醒事件fd(mWakeEventFd)添加到epoll文件描述符(mEpollFd),进行监听
    int result = epoll_ctl(mEpollFd, EPOLL_CTL_ADD, mWakeEventFd, & eventItem);   
    
    //将各种事件,如键盘、鼠标等事件的fd添加到epoll文件描述符(mEpollFd),进行监听
    for (size_t i = 0; i < mRequests.size(); i++) {
        const Request& request = mRequests.valueAt(i);
        struct epoll_event eventItem;
        request.initEventItem(&eventItem);

        int epollResult = epoll_ctl(mEpollFd, EPOLL_CTL_ADD, request.fd, & eventItem);
        if (epollResult < 0) {
            ALOGE("Error adding epoll events for fd %d while rebuilding epoll set: %s",
                  request.fd, strerror(errno));
        }
    }
}
}

rebuildEpollLocked()方法的主要作用其实就是通过epoll机制设置了mWakeEventFd的可读事件和其他各种事件的监听

epoll机制是Linux最高效的I/O复用机制,使用一个文件描述符管理多个描述符

I/O复用机制:多人聊天室就是一种非常适合I/O多路复用的例子,可能会同时有很多个用户登录,但是不会同时在同一个时刻发言。如果用普通I/O模型,则需要开很多的线程,大部分线程是空闲的,而且在处理多个客户的消息的时候需要切换线程,对系统来讲也是比较重的。而使用I/O多路复用则可以重复使用一条线程,减少线程空闲和切换的情况

epoll模型主要就是三个部分

epoll_create:创建epoll文件描述符

epoll_ctl:添加文件描述符监听(如监听mWakeEventFd的EPOLLIN事件)

epoll_wait:阻塞监听add进来的描述符,只要其中任意一个或多个描述符可用或者超时就会返回(这个我们稍后会介绍)

在进一步分析消息机制的native层原理之前,我们可以先画一个基本的系统架构图,捋一捋他们之间的关系

发送消息

我们应用层调用Handlersendmessage()sendEmptyMessageDelayed()postDelayed()post()最终都会调用到MessageQueue#enqueueMessage()

MessageQueue.java

boolean enqueueMessage(Message msg, long when) {
    synchronized (this) {
        ....
        //将消息延迟时间赋值给msg的成员变量
        msg.when = when;
        Message p = mMessages;
        boolean needWake;
        //① p == null,代表消息队列为空
        //② when == 0,当我们调用sendMessageAtFrontOfQueue的时候
        //③ when < p.when,新来的消息比消息队列中第一个消息还早
        if (p == null || when == 0 || when < p.when) {
            //插到头结点
            msg.next = p;
            mMessages = msg;
            needWake = mBlocked;
        } else {
                    //如果不能插到头结点,按时间将消息进行排序
            needWake = mBlocked && p.target == null && msg.isAsynchronous();
            Message prev;
            for (;;) {
                prev = p;
                p = p.next;
                if (p == null || when < p.when) {
                    break;
                }
                if (needWake && p.isAsynchronous()) {
                    needWake = false;
                }
            }
            msg.next = p;
            prev.next = msg;
        }

        if (needWake) {
            //发送一个eventFd可读事件
            nativeWake(mPtr);
        }
    }
    return true;
}

从这个函数中我们了解到,所谓的延迟消息,并不是延迟去发送消息,而是延迟去处理消息。延迟消息和普通消息一样,都会在第一时间发送到消息队列中

core/jni/android_os_MessageQueue.cpp

static void android_os_MessageQueue_nativeWake(JNIEnv* env, jclass clazz, jlong ptr) {
    NativeMessageQueue* nativeMessageQueue = reinterpret_cast<NativeMessageQueue*>(ptr);
    nativeMessageQueue->wake();
}

void NativeMessageQueue::wake() {
    mLooper->wake();
}

system/core/libutils/Looper.cpp

void Looper::wake() {
    uint64_t inc = 1;
    //使用write函数往mWakeEventFd写入字符inc,epoll就会收到可读事件,被唤醒
    ssize_t nWrite = TEMP_FAILURE_RETRY(write(mWakeEventFd, &inc, sizeof(uint64_t)));
    ...
}

还记得这个mWakeEventFd是什么吗?它就是我们一开始在native Looper的构造方法中创建的用于可读事件通知的文件操作符

总结一下发送消息就是根据消息的时间戳顺序,往消息队列中插入消息,每插入一条消息,就会往mWakeEventFd写入字符inc,epoll就会收到可读事件,被唤醒

接下来我们看看epoll收到可读事件后,会发生什么?

读取消息

Looper.loop()内部实际上调用的是MessageQueue#next()来读取消息

MessageQueue.java

这里最重要的就是nativePollOnce()方法,他是一个native层方法,它会一直阻塞,这里面就包含了epoll接收可读事件的相关逻辑

Message next() {
    final long ptr = mPtr;
    if (ptr == 0) {
        return null;
    }
    ...
    //nextPollTimeoutMillis表示nativePollOnce的超时时间
    int nextPollTimeoutMillis = 0;
    for (;;) {
        if (nextPollTimeoutMillis != 0) {
            Binder.flushPendingCommands();
        }

        //这是个native层方法,会一直阻塞,直到下一条可用的消息返回
        //ptr就是NativeMessageQueue的指针
        nativePollOnce(ptr, nextPollTimeoutMillis);

        synchronized (this) {
            final long now = SystemClock.uptimeMillis();
            Message msg = mMessages;
            if (msg != null) {
                if (now < msg.when) {
                    //如果消息触发时间还没到,计算还需要等多久
                    nextPollTimeoutMillis = (int) Math.min(msg.when - now, Integer.MAX_VALUE);
                } else {
                  //如果触发时间到了,取出消息
                   mMessages = msg.next;
                   msg.next = null;
                   return msg;
                }
            } else {
                //nextPollTimeoutMillis设置成-1代表没有消息,nativePollOnce就会无限等待
                nextPollTimeoutMillis = -1;
            }
          
          ...
    }
}

core/jni/android_os_MessageQueue.cpp

static void android_os_MessageQueue_nativePollOnce(JNIEnv* env, jobject obj,
        jlong ptr, jint timeoutMillis) {
    NativeMessageQueue* nativeMessageQueue = reinterpret_cast<NativeMessageQueue*>(ptr);
    nativeMessageQueue->pollOnce(env, obj, timeoutMillis);
}

system/core/libutils/Looper.cpp

int Looper::pollOnce(int timeoutMillis, int* outFd, int* outEvents, void** outData) {
    int result = 0;
    for (;;) {
                ...
        //当result不等于0时,就会跳出循环,返回到java层
        if (result != 0) {
            return result;
        }

        //处理内部轮询
        result = pollInner(timeoutMillis);    
    }
}

该方法内部是一个死循环,核心在于调用了pollInner方法,pollInner方法返回一个int值result,代表着本次轮询是否成功处理了消息,当result不等于0时,就会跳出循环,返回到java层继续处理java层消息

接下来我们重点看一下pollInner()做了什么,这是整个消息机制的核心

system/core/libutils/Looper.cpp

int Looper::pollInner(int timeoutMillis) {    
    //...
    //事件集合(eventItems),EPOLL_MAX_EVENTS为最大事件数量,它的值为16
    struct epoll_event eventItems[EPOLL_MAX_EVENTS];
    
    //1.epoll_wait有四种情况
    //① 当timeoutMillis == -1或timeoutMillis > 0,进入休眠等待
    //② 如果有事件发生,且timeoutMillis == 0,就从管道中读取事件放入事件集合(eventItems)返回事件个数
    //③ 如果休眠timeoutMillis时间后还没有被唤醒,就会返回0
    //④ 如果出错,就会返回-1
    int eventCount = epoll_wait(mEpollFd, eventItems, EPOLL_MAX_EVENTS, timeoutMillis);  
 
        ....      
    //获取锁
    mLock.lock();
    //2.遍历事件集合(eventItems)
    for (int i = 0; i < eventCount; i++) {
        int fd = eventItems[i].data.fd;
        uint32_t epollEvents = eventItems[i].events;
        //如果文件描述符为mWakeEventFd
        if (fd == mWakeEventFd) {
            //并且事件类型为EPOLLIN(可读事件),这说明当前线程关联的管道的另外一端写入了新数据
            if (epollEvents & EPOLLIN) {
                //调用awoken方法不断的读取管道数据
                awoken();
            } else {
                ALOGW("Ignoring unexpected epoll events 0x%x on wake event fd.", epollEvents);
            }
        } else {
            //如果是其他文件描述符,就进行它们自己的处理逻辑
            ...
        }
    }
  
    //3、下面是处理Native的Message
    Done:;
    mNextMessageUptime = LLONG_MAX;
    //mMessageEnvelopes是一个Vector集合,它代表着native中的消息队列
    while (mMessageEnvelopes.size() != 0) {
        nsecs_t now = systemTime(SYSTEM_TIME_MONOTONIC);
        //取出MessageEnvelope,MessageEnvelop有收件人Hanlder和消息内容Message
        const MessageEnvelope& messageEnvelope = mMessageEnvelopes.itemAt(0);
        //判断消息的执行时间
        if (messageEnvelope.uptime <= now) {//消息到达执行时间
            {
                //获取native层的Handler
                sp<MessageHandler> handler = messageEnvelope.handler;
                //获取native层的消息
                Message message = messageEnvelope.message;
                mMessageEnvelopes.removeAt(0);
                mSendingMessage = true;
                //释放锁
                mLock.unlock();
                //通过MessageHandler的handleMessage方法处理native层的消息
                handler->handleMessage(message);
            }
            mLock.lock();
            mSendingMessage = false;
            //result等于POLL_CALLBACK,表示某个监听事件被触发
            result = POLL_CALLBACK;
        } else {//消息还没到执行时间
            mNextMessageUptime = messageEnvelope.uptime;
            //跳出循环,进入下一次轮询
            break;
        }
    }
    //释放锁
    mLock.unlock();
    ...
    return result;
}

总结一下,pollInner主要做了三件事情:

  1. 执行epoll_wait方法,等待事件发生或者超时(重要)

1)当timeoutMillis == -1或timeoutMillis > 0,进入休眠等待
2)如果有事件发生,且timeoutMillis == 0,就从管道中读取事件放入事件集合(eventItems)返回事件个数
3)如果休眠timeoutMillis时间后还没有被唤醒,就会返回0
4)如果出错,就会返回-1

  1. 遍历事件集合(eventItems),检测哪一个文件描述符发生了IO事件

遍历事件集合中,如果是mWakeEventFd,就调用awoken方法不断的读取管道数据,直到清空管道,如果是其他的文件描述符发生了IO事件,让它们自己处理相应逻辑

  1. 处理native层的Message

只要epoll_wait方法返回后,都会进入Done标记位的代码段,就开始处理处理native层的Message,处理完native层消息后,又会返回到java层处理java层的消息

用一个图总结一下前面的过程

到此为止,native源码分析的差不多了,对于native层消息机制,我们已经具备了不错的理论基础,接下来开始回答文章一开始的问题吧

如何实现延迟消息

我们先来看看应用层是如何调用的,这个大家应该都非常清楚

SystemClock.uptimeMillis()是从开机到当前的毫秒数,也就是说最终传给Looper::pollInner(int timeoutMillis)的时间是以开机时间为基准的一个时间戳

public final boolean sendMessageDelayed(Message msg, long delayMillis){
    if (delayMillis < 0) {
        delayMillis = 0;
    }
    return sendMessageAtTime(msg, SystemClock.uptimeMillis() + delayMillis);
}

我们上面介绍epoll_wait的时候,说过这个方法的四种情况

1)当timeoutMillis == -1或timeoutMillis > 0,进入休眠等待
2)如果有事件发生,且timeoutMillis == 0,就从管道中读取事件放入事件集合(eventItems)返回事件个数
3)如果休眠timeoutMillis时间后还没有被唤醒,就会返回0
4)如果出错,就会返回-1

所以当epoll收到一条延迟消息的时候,timeoutMillis > 0,所以就符合第一条,这时候就会epoll进入休眠等待,直到休眠了timeoutMillis的时间,就会被唤醒并返回。也就是说此时nativePollOnce不再阻塞,就会从消息队列中取出消息进行分发。所以,消息延迟就是基于epoll_wait的超时时间来实现的

为什么Looper.loop()中的死循环不会导致ANR

首先,我们得需要知道ANR是怎么产生的,有的人可能会说主线程执行了耗时操作、Activity的最长执行时间是5秒、BroadcastReceiver的最长执行时间则是10秒、Service的最长执行时间是20秒等等这些。但是我们今天要说的,其实是要更深一步

ANR是怎么产生的

ANR的时候,我们会看到系统会弹出一个对话框,我们先找到这个对话框是从哪里弹出来的

ActivityManagerService.java

final void appNotResponding(ProcessRecord app, ActivityRecord activity,
         ActivityRecord parent, boolean aboveSystem, final String annotation) {
  ...
  Message msg = Message.obtain();
  msg.what = ActivityManagerService.SHOW_NOT_RESPONDING_UI_MSG;
  msg.obj = new AppNotRespondingDialog.Data(app, activity, aboveSystem);

  mService.mUiHandler.sendMessage(msg);
}

这里的SHOW_NOT_RESPONDING_UI_MSG消息最终会执行到AppErrors#handleShowAnrUi(),从而打开一个AppNotRespondingDialog

AppErrors.java

void handleShowAnrUi(Message msg) {
        ...
        Dialog dialogToShow = new AppNotRespondingDialog(mService, mContext, data);
        ...
        dialogToShow.show()
}

接下来我们来看看这个超时是如何触发的,这里拿Service举例

ActiveService.java

这是Service开始启动的方法

private final void realStartServiceLocked(ServiceRecord r,
ProcessRecord app, boolean execInFg){
  ...
  bumpServiceExecutingLocked(r, execInFg, "create")
  ...
  app.thread.scheduleCreateService(...)
    ...
}
private final void bumpServiceExecutingLocked(ServiceRecord r, boolean fg, String why) {
    ...
    scheduleServiceTimeoutLocked(r.app);
    ...
}
static final int SERVICE_TIMEOUT = 20*1000;
// How long we wait for a service to finish executing.
static final int SERVICE_BACKGROUND_TIMEOUT = SERVICE_TIMEOUT * 10;

void scheduleServiceTimeoutLocked(ProcessRecord proc) {
    ...
    Message msg = mAm.mHandler.obtainMessage(
            ActivityManagerService.SERVICE_TIMEOUT_MSG);
    msg.obj = proc;
    //发送一个超时的消息,这个超时消息如果最终被接收,将会执行appNotResponding()
    mAm.mHandler.sendMessageDelayed(msg,
            proc.execServicesFg ? SERVICE_TIMEOUT : SERVICE_BACKGROUND_TIMEOUT);
}

通过看源码我们发现,实际上就是在Service启动的时候,会往消息队列中发送一个20s的延迟消息。如果这个消息在20s之内没有被remove(),那就会弹出ANR的弹窗!为了不弹出这个ANR的弹窗,如果Android系统交给你们设计,你们会怎么做呢?

我想你们应该很容易想到吧,Andorid系统在Service启动完成后就会remove消息,同样的道理,输入事件,BroadcastReceiver启动等都会发送一个延迟消息,之后等到成功响应后就会remove这个延迟消息

我们通过两个图来对比一下启动Service正常的情况和发生ANR的情况

正常情况
发生ANR

looper为什么不会ANR

了解了ANR发生的原理,我们再来回答looper()为什么不会ANR应该会更准确一些

我认为这里要回答两点,一点是epoll的原理,还有一点就是ANR的触发原理

  1. 在Linux中,文件、socket、管道(pipe)等可以进行IO操作的对象都可以称之为流,既然是IO流,那肯定会有两端:read端和write端,我们可以创建两个文件描述符wiretFd和readFd,对应read端和write端,当流中没有数据时,读线程就会阻塞(休眠)等待,当写线程通过wiretFd往流的wiret端写入数据后,readFd对应的read端就会感应到,唤醒读线程读取数据,大概就是这样的一个读写过程。读线程进入阻塞后,并不会消耗CPU时间,这是epoll机制高效的原因之一

  2. Activity启动事件时一个Message,ANR也是一个延迟Message,当Activity启动完成后就移除ANR的Message,这是多么自然的事情,怎么会导致ANR呢?

其他问题

主线程中进行耗时操作一定会ANR吗

通过阅读源码可以找到四种ANR类型(下面推荐了四篇文章,感兴趣的可以跟着代码看一看)

如果触发了上面这些情景,就会发生ANR,反之即使在主线程做了耗时操作,你也看不到ANR弹窗

可以做下面这个实验

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
          try {
            Thread.sleep(10000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

    }

}

这样仅仅会让这个Activtiy延迟了10s才真正启动,但是由于Activity没有接收输入时间,所以不会出现ANR弹窗。但是如果在Service中sleep超过20秒,就会出现ANR弹窗,这里Service和Activity其实是有区别的

总结

本文分析了native层MessageQueueLooper的创建过程,也通过了解epoll的唤醒和阻塞机制,解决了“Handler是如何实现延迟消息的?”,“为什么Looper.loop()中的死循环不会导致ANR?”这两个问题。看源码有时候虽然辛苦,但是看完之后感觉还是有不少收获的。所以大家以后有什么非常不解的问题,不妨研究研究源码吧

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

推荐阅读更多精彩内容