第2章 IPC机制

  1. 多进程概念和多进程开发模式中常见问题
  2. Android序列化机制和Binder
  3. 详细介绍Bundle、文件共享、AIDL、ContentProvider和Socket等进程通讯方式。
  4. Binder连接池
  5. 各种进程间的通讯方式的优缺点和试用场景。

2.1 Android IPC简介

IPC(Inter-Process Communication),含义为进程间通信或者跨进程通信,是指两个进程之间交换数据的过程。

***进程***、***线程***
按照操作系统的描述:
1. 线程是CPU调度的最小单元,同时线程是一种有限的系统资源。
2. 进程一般指一个执行单元,在PC和移动设备上指一个程序或应用

一个进程可以包含多个线程,即主线程,在Android里面主线程也叫做UI线程,
在UI线程里边才能操作界面元素。
很多时候,一个进程中需要执行大量的耗时任务,这种情况
在pc和移动设系统中都存在,在Android中有一个特殊的名字叫做ANR,即应用无响应。
解决这个问题就需要用到线程,把一些耗时任务放在线程中即可。

IPC不是 Android独有的,任何一个操作系统都需要有响应的IPC机制。
比如:Windows上可以通过剪贴板,管道和邮槽来进行进程间通信;
Linux上通过命名管理、共享内容。信号量来进行进程间通信。

Android是一种基于Linux内核的移动操作系统,它的进程间通信方式并不能完全继承子Linux,相反的它有自己的进程间通信方式。

其中最有特色的就是Binder。通过Binder可以轻松的实现进程间通信。

除此之外还支持Socket,通过Socket也可以实现任意两个终端之间的通信,当然同一个设备上两个进程通过Socket通信自然也是可以的。

说到IPC的使用场景就必须提到多进程,只有面对多进程这种场景下,才需要考虑进程间通信。

有两种情况

  1. 一个应用因为某些原因自身需要采用多进程模式来实现。
    至于原因可能是因为有些模块,由于特殊原因需要运行在单独的进程中。又或者为了加大一个应用可使用的内存,需要通过多进程来获取多份的内存空间。

     Android对单个应用所使用的最大内存做了限制
     早期一些版本可能是16MB,不同设备有不同的大小
    
  2. 另一种情况就是当前应用需要向其它应用获取数据,由于是两个应用,所以必须采取跨进程的方式。

2.2 Android中的多进程模式

2.2.1 开启多进程模式

正常情况下,Android中的多进程是指一个应用中存在多个进程的情况,因此不讨论两个应用之间的多进程状况。

首先,在Android中使用多进程只有一中方法,那就是给四大组件在AndroidMenifest中知道那个android:process属性

除此之外,没有其他办法。也就是说我们无法给一个线程或者一个实体类指定其运行时所在的进程。

其实还有一种非常规的的多进程方法,就是通过JNI在native层fork一个新的进程,但这种情况属于特殊情况,不是常用的创建多进程的方式,暂时不考虑。

下面一个demo,描述如何在android中创建多进程


image.png
  1. 首先SecondActivity和ThirdActivity都设置了自己的process属性这意味着当前应用又增加了两个线程。

  2. 假设当前包名是com.demo.android
    当SecondActivity启动时,系统会为它创建一个单独的进程,进程名为com.demo.android:remote
    ThirdActivity启动时系统会为它创建一个单独的进程,进程名为com.demo.android.remote

  3. MainActivity没有为它指定process属性,那么它运行在默认的进程中,默认进程的进程名是包名

    image.png

除了在DDMS中查看进程信息,还可以用shell来查看,命令为adb shell p或者adb shell ps | grep com.demo.android,其中com.demo.android是包名。如图所示

image.png

注意
SecondActivity和ThirdActivity的android:process属性分别是":process"和"com.demo.android.remote"。这两种方式有区别吗?

区别有两点

  1. ":"的含义是在当前的进程名前,附加上当前的包名。这是一种简写的方法。而对于ThirdActivity的声名方式是一种完整的命名方式,不会附加包信息.
  2. 进程名以":"开头的进程属于当前应用的私有进程,其他应用的组件不可以和他跑在同一个进程中,而进程名不以":"开头的的进程属于全局进程,其他应用可以通过ShareUID方式和他跑在同一个进程中。

我们知道Android系统会为每一个应用分配唯一一个UID,具有相同UID的应用才能共享数据。

说明:两个应用通过ShareUID跑在同一个进程中是有要求的,
需要两个应用有相同的ShareUID并且签名相同才可以。
在这种情况下,他们可以相互访问对方的私有数据,比如data目录,组件信息等,不管它们是否跑在同一个进程中。
当然,如果它们跑在同一个进程中,那么除了能共享data目录,组件信息,还可以共享内存数据,或者应用的两个部分。

2.2.2 多进程模式运行机制

先给出一个例子,来简单看一下多进程的特点。

新建一个类,UserManager,这个类中有一个public的静态成员变量。

image.png

然后在MainActivity中,改变该变量的值,打印log。

image.png

最后我们启动,SecondActivity。

image.png

观察结果:

image.png
image.png

由于SecondActivity开启了多进程模式,导致该静态变量的值不同。所以开启多进程会有很多问题。

出现这种问题的原因是因为SecondActivity运行在一个单独的进程中,Android为每一个进程都分配的独立的虚拟机,不同的虚拟机在内存分配上有不同的地址空间,这就导致在不同的虚拟机中方位同一个类的对象,会产生多份副本。

回到我们这个例子,进程com.demo.android(29646)和进程com.demo.android:remote中都存在一个UserManager类,并且这两个类互不干扰,在一个进程中修改sUserId的值,只会影响当前进程,对其他进程不会造成任何影响

这样我们就可以理解为什么在MainActivity中修改sUserId的值,而SecondActivity中的sUserId的值却没有发生改变的原因。

所有运行在不同进程中的四大组件,只要他们之间通过内存来共享数据,都会共享失败,这也是多进程所带来的主要影响正常情况下,四大组件中间不可能不通过一些中间层来共享数据,那么通过简单的指定进程名来开启多进程都会无法正确运行。

当然特殊情况下,某些组件中间不需要共享数据,这个时候可以直接指定android:process属性来开启多进程,但是这种场景是不可见的,几乎所有的情况都需要共享数据。

一般来说,使用多进程会造成如下方面的几个原因

  1. 静态成员变量和单例模式完全失效
  2. 线程同步机制完全失效
  3. SharePreferences的可靠性下降。
  4. Application会多次创建。

本节分析了多进程所带来的问题,为了解决这个问题,系统提供了很多跨进程通信方法,虽然不能直接的共享内存,但是通过跨进程通信,我们还是可以实现数据交互

实现跨进城通信的方式有很多,比如通过Intent来传递数据,共享文件和SharePreferences,基于Binder的Messenger和AIDL以及Socket等,但是为了更好地理解各种IPC方式,我们需要先熟悉一些基本的概念,比如序列化相关的Serializable和Parcelable接口,以及Binder概念

2.3 IPC基础概念介绍

主要包括三方面内容

  1. Serializable接口
  2. Parcelable接口
  3. Binder

序列化: 简单来说就是将对象转换为可以传输的二进制流(二进制序列)的过程,这样我们就可以通过序列化,转化为可以在网络传输或者保存到本地的流(序列),从而进行传输数据

反序列化:就是从二进制流(序列)转化为对象的过程.

<pre>

Parcelable是Android为我们提供的序列化的接口,Parcelable相对于Serializable的使用相对复杂一些,但Parcelable的效率相对Serializable也高很多,这一直是Google工程师引以为傲的,有时间的可以看一下Parcelable和Serializable的效率对比
Parcelable vs Serializable 号称快10倍的效率

2.3.1 Serializable接口

Serializable是Java所提供的一个序列化接口,它是一个空接口,为对象提供标准的序列化和反序列化操作。

image.png

使用方法

只需要在类的声明中指定一个类似下面的标识,即可以自动实现默认的序列化过程。
private static final long serialVersionUID = 8711368828010083044L

在Android中也提供了新的序列化方式,那就是Parcelable接口,使用Parcelable来实现对象的序列号,过程稍微复杂。本节先介绍Serializable接口。

上面提到,想让一个对象实现序列化,只需要这个类实现Serializable接口,并声明一个serialVersionUID即可。实际上,甚至这个serialVersionUID也不是必须的。

我们不声明这个serialVersionUID同样也可以实现序列化,但是这会对反序列化产生影响。下面举个例子。

image.png

下边是如何进行序列化和反序列化


image.png

上述代码演示了采用serializable方式序列化对象的典型过程,很简单,只需要把实现了serializable接口的User对象写到文件中,就可以快速恢复了。恢复后的对象newUser和User的内容完全一样,但是两者并不是同一个对象。

serialVersionUID是用来辅助序列化和反序列化过程的。原则上序列化后的数据中的SerialVersionUID只有和当前类的SerialVersionUID相同才能够正常的被反序列化。

工作原理:序列化的时候,系统会把当前类的SerialVersionUID写入序列化的文件中,也可能是其他的中介,当反序列化的时候去检测文件中的SerialVersionUID,看它是否和当前类的SerialVersionUID一致,如果一直就说明序列化的类的版本和当前类的版本相同的,这个时候可以成功反序列化。

注意:

  1. 静态成员变量属于类,不属于对象。所以不会参与序列化的过程
  2. 用transient关键字标记的成员变量不参与序列化过程

2.3.2 Parcelable接口

Parcelable也是一个接口,只要实现了这个接口,一个类的对象就可以
实现序列化并可以通过intent和Binder传递。

下边看典型的例子

public class User implements Parcelable {

    public int userId;
    public String userName;
    public boolean isMale;
    public Book book;

    protected User(Parcel in) {
        userId = in.readInt();
        userName = in.readString();
        isMale = in.readByte() != 0;
        book = in.readParcelable(Thread.currentThread().getContextClassLoader());
    }
    @Override
    public void writeToParcel(Parcel dest, int flags) {
        dest.writeInt(userId);
        dest.writeString(userName);
        dest.writeByte((byte) (isMale ? 1 : 0));
        dest.writeParcelable(book, flags);
    }
    @Override
    public int describeContents() {
        return 0;
    }
    public static final Creator<User> CREATOR = new Creator<User>() {
        @Override
        public User createFromParcel(Parcel in) {
            return new User(in);
        }

        @Override
        public User[] newArray(int size) {
            return new User[size];
        }
    };
}

先说一下Parcel,它的内部包装了可序列化的数据,可以在Binder中自由传输。

从代码中看出,在序列化过程中需要实现的功能有:序列化,反序列化和内容描述。

  1. 序列化功能由writeToParcel方法来完成,最终是通过Parcel中一系列的writer方法来完成。
  2. 反序列化功能由CREATOR来完成,其内部标明了如何创建序列化对象和数组,并通过Parcel的一系列read方法来完成反序列化过程;
  3. 内容描述功能由describeContents方法来完成,几乎所有情况在这个方法都应返回0,仅当当前对象中存在文件描述符时,此方法返回1.

需要注意是,在User(Parcel in)方法中,由于book是另一个可序列化的对象,所以它的反序列化过程需要传递当前线程的上下文类加载器,否则会报无法找到类的错误。

详细的方法说明

  1. createFromParcel(Parcel in):从序列化的后的对象中创建原始对象
  2. newArray(int size):创建指定长度的原始对象数组
  3. User(Parcel in):从序列化后的对象中创建原始对象
  4. writeToParcel(Parcel out,int flags):将当前对象写入序列化结构中,其中标识flags有两种:0或者1。为1时,表示当前对象需要作为返回值返回,不能立即释放资源,几乎所有的情况都为0。
  5. describeContents:返回当前对象的内容描述。如果含有文件描述符,返回1,否则返回0.几乎所有的情况都为0。

选择

既然两者都可以实现序列化并且都可以用于intent间的数据传递,那么该如何选取?

Serializable java中的序列化接口,使用简单,开销大,序列化和反序列化过程需要大量的I/O操作。
Parcelable Android中的序列化方式,更适合android设备。使用起来较麻烦,但是效率高,因此我们首选Parcelable。

通过Parcelable将对象序列化到存储设备上或者将对象序列化后通过网络传输也都是可以的,但是这个过程会稍显复杂,因此这两种情况下,建议大家使用Serializable。

2.3.3 Binder

本节侧重点:Binder的使用以及上层原理

什么是Binder

  1. 直观来说,是android中的一个类,它实现了IBinder接口
  2. 从IPC来说,是Android中的一中夸进程通信方式
  3. 还可以理解为一种虚拟的物理设备,它的驱动是/dev/binder,该通信方式在linux中没有。
  4. 从Android Framework角度来说,Binder是ServiceManager链接各种manager(ActivityManager、windowManager,等等)和相应的ManagerService的桥梁
  5. 从Android应用层来说,Binder是客户端和服务端进行通信的媒介,当bindService的时候,服务端会返回一个包含了服务端业务调用的Binder对象,通过这个Binder对象,客户端就可以获取服务端提供的服务或者数据,这里的服务包括普通服务和基于AIDL服务。

Android开发中Binder主要 用在service中,包括AIDL和Messenger。其中普通service中的Binder不涉及进程间通信,较为简单,无法触及Binder核心,而messenger的底层其实就是AIDL,这里我们选用AIDL来分析Binder的工作机制。

下边看例子,这部分例子现在看来只是为了加深印象,实际应用现在并没有接触。暂且忽略。如果有强烈的求知欲可以去看一下原书,并跟着敲一下代码,反正我是敲了。

2.4 Android中的IPC方式

上一节,我们详细介绍了ipc几个基础知识:序列化和Binder,本节开始详细分析各种跨进程通信方式。

具体方式

  1. 通过在Intent中添加extra来传递信息,
  2. 通过共享文件的方式来共享数据,
  3. 通过Binder方式来夸进程通信,
  4. ContentProvider天生就是支持跨进程访问的,因此我们也可以才用它来进行ipc。
  5. 通过网络通信也是可以实现数据传递,所以socket也可以实现ipc。

2.4.1 使用Bundle

四大组件中的三大组件(activity,service,receiver)都是支持在Intent中传递Bundle数据,由于Bundle实现了Parcelable接口,所以它可以方便的在不同的进程中传输。

基于这一点,当我们在一个进程中启动了另一个进程的activity,service和receiver,我们就可以在Bundle中附加我们需要传输给远程进程的信息并通过intent发送出去,这些传输的数据必须能够被序列化。bundle不支持的类型,我们无法通过它在进程中传递数据。这是一种最简单的进程间通信方式。

除了直接的传递数据这种典型的使用场景,它还有一种特殊的使用场景。

比如A进程正在进行一个计算,计算完成后它要启动B进程的一个组件并把计算结果传递给B进程,可是遗憾的是这个计算结果不支持放入Bundle中,因此无法通过intent来传递数据,这个时候如果使用其他ipc会显得略显复杂可以考虑如下:
我们通过intent启动B进程的一个service组件(比如intentService),让service在后台进行计算,计算完毕后再启动B进程中真正要启动的目标组件,由于service也运行在B进程中,所以目标组件就可以直接获取计算结果,这样一来就可以轻松解决夸进程的问题。
这种方式的核心思想在于,将原本需要在A进程的计算任务,转移到B进程的后台service中去执行,这样就成功的避免了进程间通信问题,而且只用了很小的代价。

2.4.2 使用文件共享

共享文件也是一种不错的进程间通信方式,两个进程通过读/写同一个文件来交换数据,比如A进程把数据写入文件,B进程通过读取这个文件来获取数据。我们知道,在windows上,一个文件如果被加了排斥锁将会导致其他线程无法对其访问,包括读和写,而 Android系统基于Linux,使得其并发读/写文件可以没有限制的进行,甚至两个线程同时对一个文件进行读写操作都是允许的,尽管这可能出问题。

通过文件交换数据很好用,除了可以交换一些文本信息外,我们还可以序列化一个对象到文件系统中的同时从另一个进程中恢复这个对象,下面就展示这种使用方法。

我们在MainActivity的onResume中序列化一个User对象到sdcard上的一个文件里,然后在SecondActivity中能够正确的恢复User对象的值。关键代码如下:

    //在mainActivity中的修改

    @Override
    protected void onResume() {
        super.onResume();
        persistToFile();

    }

    private void persistToFile() {
        new Thread(new Runnable() {
            @Override
            public void run() {
                User user = new User(1, "hello world", false);

                File file = new File(getFilesDir().getAbsolutePath() + File.separator + "User");
                if (!file.exists()) {
                    file.mkdirs();
                }

                File cachedFile = new File(getFilesDir().getAbsolutePath() +
                        File.separator + "User" + File.separator + "usercache");
                ObjectOutputStream outputStream = null;
                try {
                    outputStream = new ObjectOutputStream(new FileOutputStream(cachedFile));
                    outputStream.writeObject(user);
                    Log.i("zhou===>", "persist user:" + user);
                } catch (IOException e) {
                    e.printStackTrace();
                } finally {
                    try {
                        if (outputStream != null) {
                            outputStream.close();
                        }
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }

            }
        }).start();
    }

在secondActivity中的修改

@Override
    protected void onResume() {
        super.onResume();
        Log.d("zhou", "onResume");
        recoverFromFile();

    }

    private void recoverFromFile() {
        new Thread(new Runnable() {
            @Override
            public void run() {
                User user = null;
                File cachedFile = new File(getFilesDir().getAbsolutePath() + File.separator + "User"
                        + File.separator + "usercache");

                ObjectInputStream inputStream = null;
                    try {
                        inputStream = new ObjectInputStream(new FileInputStream(cachedFile));
                        user = (User) inputStream.readObject();
                        Log.i("zhou====>", "recover user" + user);
                    } catch (IOException e) {
                        e.printStackTrace();
                    } catch (ClassNotFoundException e) {
                        e.printStackTrace();
                    } finally {
                        try {
                            if (inputStream != null) {
                                inputStream.close();
                            }
                        } catch (IOException e) {
                            e.printStackTrace();
                        }

                    }
            }
        }).start();
    }

User类

public class User implements Serializable {

    public int userId;
    public String userName;
    public boolean isMale;
    public Book book;

    public User(int userId, String userName, boolean isMale) {
        this.userId = userId;
        this.userName = userName;
        this.isMale = isMale;
    }

    public User() {
    }



    @Override
    public String toString() {
        return "User{" +
                "userId=" + userId +
                ", userName='" + userName + '\'' +
                ", isMale=" + isMale +
                ", book=" + book +
                '}';
    }
}

然后AndroidManifest.xml文件中的设置,在SecondActivity中开启多进程 android:process=":remote"

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="com.demo.android">

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
        <activity android:name=".MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>

                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
        </activity>
        <activity
            android:name=".activity.FirstActivity"
            android:label="@string/app_first"/>
        <activity
            android:name=".activity.SecondActivity"
            android:configChanges="screenLayout"
            android:label="@string/app_second"
            android:process=":remote"/>
        <activity
            android:name=".activity.ThirdActivity"
            android:configChanges="screenLayout"
            android:label="@string/app_third"
            android:process="com.demo.android.remote"/>
    </application>

</manifest>

我们跟踪log


image.png
image.png

发现成功的从文件中,恢复了之前存储的user对象的内容。

通过文件共享的方式来共享数据,对文件格式是没有具体要求的。比如可以是文本文件,也可以是xml文件,只要读/写双方约定数据格式即可。

局限性:并发读/写问题,像上边的例子,如果并发读/写,那么我们独处的内容就有可能不是最新的,如果是并发写的话,就更严重了。

因此我们要尽量避免并发写这种情况的发生。或者考虑使用线程同步来限制多个线程的写操作。

通过以上分析,我们知道,文件共享的方式适合在对数据同步要求不高的进程之间进行通信,并且要妥善处理并发读/写的问题

sharedPreferences是个特例,众所周知,SharePreferences是Android中提供的轻量级存储方案,
它通过键值对的方式来存储数据,在底层实现上,它采用XML文件来存储键值对,
每个应用的SharePreferences文件都可以在当前包所在地data目录下查看到。
对应目录/data/data/package name/shared_prefs目录下,其中package name是当前应用的包名。
但是系统对它的读/写有一定的缓存策略,即在内存中会有一份SharedPreferences文件的缓存,因此在多进程模式下,系统对它的读/写就变得不可靠,当面对高并发的读/写访问,Sharedpreferences有很大的几率会丢失数据。因此不建议在 进程间通信中使用SharePreferences

2.4.2 使用Messenger

Messenger可以翻译为信使,是一种轻量级的IPC方案,通过它可以在不同进程中传递Message对象。
它的底层实现是AIDL。

通过查看源码

public final class Messenger implements Parcelable {
    private final IMessenger mTarget;


    public Messenger(Handler target) {
        mTarget = target.getIMessenger();
    }
    
    public Messenger(IBinder target) {
        mTarget = IMessenger.Stub.asInterface(target);
    }


   ......省略部分代码
    
}

查看它的两个构造方法可以看到AIDL的痕迹,不管是IMessenger还是Stub.asInterface这种使用方法都表明他的底层是AIDL。

它的使用方法很简单,它对AIDL进行了封装,使我们可以更方便地进行进程间通信。同时,由于它一次处理一个请求,因此我们在服务端不用考虑线程同步的问题,这是因为服务端不存在并发执行的情况,实现一个Messenger有如下几个步骤,分为客户端和服务端。

先看服务端代码

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

推荐阅读更多精彩内容