JNI/NDK学习笔记(一)

*** 说明:本文不代表博主观点,均是由以下资料整理的读书笔记。 ***

【参考资料】

1、向您的Android Studio项目添加C/C++代码
2、Google开发者文档 -- 添加C++代码到现有Android Studio项目中
3、JNI Tips 英文原版
4、JNI Tips 中文
5、极客学院 JNI/NDK 开发指南
6、极客学院 深入理解 JNI
7、使用CMake构建JNI环境
8、使用C和C++的区别
9、Google官方 NDK 文档
10、极客学院 NDK开发课程
11、ndk-build 构建 JNI 环境
12、开发自己的NDK程序
13、JNI/NDK开发教程
14、JNI层修改参数值
15、JNI引用和垃圾回收
16、《Android高级进阶》-- 顾浩鑫
17、《Android C++ 高级编程 -- 使用 NDK》 -- Onur Cinar


一、概述

1.1 JNI

Java Native Interface(Java 本地接口),它是为了方便Java调用C、C++等本地代码所封装的一层接口。

1.2 NDK

Native Development Kit(本地开发工具包),通过NDK可以在Android中更加方便的通过JNI来访问本地代码;并提供众多平台库,让您可以管理原生 Activity 和访问物理设备组件,例如传感器和触摸输入,比如百度开放平台提供的定位服务、搜索服务、LBS 服务、推送服务的Android SDK,除了Java接口的jar包之外,还有一个.so文件,这个.so就是实现了Java层定义的native接口的动态库。NDK 能自动将 so 和 Java 应用一起打包成 apk,它集成了交叉编译器,并提供了相应的 mk 文件隔离 CPU、平台、ABI 等差异,开发人员只需要简单修改 mk 文件(指出“哪些文件需要编译”、“编译特性要求”等),就可以创建出 so。

1.3 JNI 开发的平台差异

开发 JNI 程序会受到系统环境的限制,因为用 C/C++ 语言写出来的代码或模块,编译过程当中要依赖当前操作系统环境所提供的一些库函数,并和本地库链接在一起。而且编译后生成的二进制代码只能在本地操作系统环境下运行,因为不同的操作系统环境,有自己的本地库和 CPU 指令集,而且各个平台对标准 C/C++ 的规范和标准库函数实现方式也有所区别。这就造成使用了 JNI 接口的 JAVA 程序,不再像以前那样自由的跨平台。如果要实现跨平台,就必须将本地代码在不同的操作系统平台下编译出相应的动态库。


二、Android中C/C++项目的两种构建方式

2.1 CMake

这是Android Studio的默认方式。通过CMakeLists.txt和gradle来构建原生代码。

2.2 ndk-build

可以将现有的ndk-build库导入到Android Studio项目中。
PS:如果是创建新的C++库,建议使用CMake。

2.3 调试

Android Studio 可以使用 LLDB 工具来调试C++代码。

2.4 使用Android Studio创建支持C/C++的CMake项目

** 使用2.2或以上版本的Android Studio可以创建CMake项目,与创建普通的AS项目类似,需要以下几个额外步骤: **

1、在向导的 Configure your new project 部分,选中 Include C++ Support 复选框。
2、在向导的 Customize C++ Support 部分,您可以使用下列选项自定义项目:
(1)C++ Standard:使用下拉列表选择您希望使用哪种 C++ 标准。选择 Toolchain Default 会使用默认的 CMake 设置。
(2)Exceptions Support:如果您希望启用对 C++ 异常处理的支持,请选中此复选框。如果启用此复选框,Android Studio 会将 -fexceptions 标志添加到模块级 build.gradle 文件的 cppFlags 中,Gradle 会将其传递到 CMake。
(3)Runtime Type Information Support:如果您希望支持 RTTI,请选中此复选框。如果启用此复选框,Android Studio 会将 -frtti 标志添加到模块级 build.gradle 文件的 cppFlags 中,Gradle 会将其传递到 CMake。

** 创建好的新项目,会比普通的AS项目多了 cpp 组和 External Build Files 组: **

1、在 cpp 组中,可以找到属于项目的所有原生源文件、标头和预构建库。对于新项目,Android Studio 会创建一个示例 C++ 源文件 native-lib.cpp。默认模板提供了一个 C++ 函数 stringFromJNI(),以返回字符串“Hello from C++”。
2、在 External Build Files 组中,可以找到 CMake 构建脚本。与 build.gradle 文件指示 Gradle 如何构建应用一样,CMake 需要一个构建脚本来了解如何构建原生库。对于新项目,Android Studio 会创建一个 CMake 构建脚本 CMakeLists.txt,并将其置于模块的根目录中。

** 构建和运行的过程 **

1、Gradle 调用外部构建脚本 CMakeLists.txt。
2、CMake 按照构建脚本中的命令将 C++ 源文件 native-lib.cpp 编译到共享的对象库中,并命名为 libnative-lib.so,Gradle 随后会将其打包到 APK 中。
3、运行时,应用的 MainActivity 会使用 System.loadLibrary() 加载原生库,加载成功之后,应用就可以使用库的原生函数 stringFromJNI() 了。
4、MainActivity.onCreate() 调用 stringFromJNI(),这将返回“Hello from C++”。

2.5 向现有项目添加 C/C++ 代码

1、创建新的 C/C++ 源文件或提供现有的原生库,并将其添加到的 Android Studio 项目中。
2、创建 CMake 构建脚本或 ndk-build 构建脚本,将原生源代码构建到库中。

  • ** CMake **

CMake 构建脚本是一个纯文本文件,必须将其命名为 CMakeLists.txt,在 gradle 的 externalNativeBuild 中指向其路径。下面是几个常见的CMake命令:(详见:https://developer.android.google.cn/studio/projects/add-native-code.html#existing-project

(1)cmake_minimum_required:指定最低版本号。
(2)add_library:包含三个参数,分别是共享库的名称,设置为共享库或静态库,入口文件的路径;在 Java 代码中使用 System.loadLibrary(“native-lib”) 来加载该共享库。可以使用多个 add_library 命令关联多个共享库。
(3)find_library、target_link_libraries:这两个命令可以将已有的的 NDK 库关联到 CMake中,默认实例中添加了原生的 log 库。
(4)例子:NDK 还以源代码的形式包含一些库,可以使用 add_library() 命令,将源代码编译到原生库中。要提供本地 NDK 库的路径,使用 ANDROID_NDK 路径变量,Android Studio 会自动为你定义此变量。以下命令可以指示 CMake 构建 android_native_app_glue.c,后者会将 NativeActivity 生命周期事件和触摸输入置于静态库中并将静态库关联到 native-lib。

add_library( app-glue
             STATIC
             ${ANDROID_NDK}/sources/android/native_app_glue/android_native_app_glue.c )

# You need to link static libraries against your shared native library.
target_link_libraries( native-lib app-glue ${log-lib} )
  • ** ndk-build **

提供一个指向您的 Android.mk 文件的路径,将 Gradle 关联到原生库。使用ndk-build构建请参考这两篇:
NDK笔记(二)-在Android Studio中使用ndk-build

JNI/NDK开发指南 - 开发自己的 NDK 程序

下面是Android.mk和pplication.mk文件的语法说明:
如果要使用stl,则需要建立一个Application.mk,里面写上:
APP_STL := stlport_shared
APP_STL := stlport_static

3、提供一个指向 CMake 或 ndk-build 脚本文件的路径,将 Gradle 关联到原生库。Gradle 使用构建脚本将源代码导入 Android Studio 项目并将原生库(SO 文件)打包到 APK 中。


三、JNI头文件解释

看这个最简单的例子:

extern "C"
JNIEXPORT jstring JNICALL
Java_com_scu_miomin_learncmake_MainActivity_stringFromJNI(
        JNIEnv *env,
        jobject /* this */) {
    std::string hello = "Hello from C++ " + getStringValue();
    return env->NewStringUTF(hello.c_str());
}

3.1 JNIEXPORT 和 JNICALL

JNIEXPORT 和 JNICALL 是定义在跨平台头文件目录(Mac os x系统下的目录名为 darwin,在 Windows 下目录名为 win32,linux 下目录名为 linux),用于标识函数用途的两个宏。从 Linux 下的jni_md.h头文件可以看出来,JNIEXPORT 和 JNICALL 是一个空定义,所以在 Linux 下 JNI 函数声明可以省略这两个宏。

3.2 JNIEnv*

是定义任意 native 函数的第一个参数,指向 JVM 函数表的指针,函数表中的每一个入口指向一个 JNI 函数,每个函数用于访问 JVM 中特定的数据结构。

3.3 jobject

调用 Java 中 native 方法的实例或 Class 对象,如果这个 native 方法是实例方法,则该参数是 jobject,如果是静态方法,则是 jclass

3.4 JavaVM 及 JNIEnv

JNI定义了两种关键数据结构,“JavaVM”和“JNIEnv”。它们本质上都是指向函数表指针的指针(在C++版本中,它们被定义为类,该类包含一个指向函数表的指针,以及一系列可以通过这个函数表间接地访问对应的JNI函数的成员函数)。JavaVM提供“调用接口(invocation interface)”函数, 允许你创建和销毁一个JavaVM。理论上你可以在一个进程中拥有多个JavaVM对象,但安卓只允许一个。

JNIEnv提供了大部分JNI功能。你定义的所有本地函数都会接收JNIEnv作为第一个参数。

JNIEnv是用作线程局部存储。因此,你不能在线程间共享一个JNIEnv变量。如果在一段代码中没有其它办法获得它的JNIEnv,你可以共享JavaVM对象,使用GetEnv来取得该线程下的JNIEnv(如果该线程有一个JavaVM的话;见下面的AttachCurrentThread)。

3.5 jclass, jmethodID, jfieldID

如果你想在本地代码中访问一个对象的字段(field),你可以像下面这样做:

  • 对于类,使用FindClass获得类对象的引用
  • 对于字段,使用GetFieldId获得字段ID
  • 使用对应的方法(例如GetIntField)获取字段下面的值

如果性能是你看重的,那么一旦查找出这些值之后在你的本地代码中缓存这些结果是非常有用的。因为每个进程当中的JavaVM是存在限制的,存储这些数据到本地静态数据结构中是非常合理的。类引用(class reference),字段ID(field ID)以及方法ID(method ID)在类被卸载前都是有效的。如果与一个类加载器(ClassLoader)相关的所有类都能够被垃圾回收,但是这种情况在安卓上是罕见甚至不可能出现,只有这时类才被卸载。注意虽然jclass是一个类引用,但是必须要调用NewGlobalRef保护起来(见后文)。

当一个类被加载时如果你想缓存些ID,而后当这个类被卸载后再次载入时能够自动地更新这些缓存ID,正确做法是在对应的类中添加一段像下面的代码来初始化这些ID,当这个类被初始化时这段代码将会执行一次。当这个类被卸载后而后再次载入时,这段代码将会再次执行:

/*
 * 我们在一个类初始化时调用本地方法来缓存一些字段的偏移信息
 * 这个本地方法查找并缓存你感兴趣的class/field/method ID
 * 失败时抛出异常
 */
private static native void nativeInit();

static {
    nativeInit();
}

四、JNI 与 Java 的数据类型映射

在调用 Java native 方法将实参传递给 C/C++ 函数的时候,会自动将 java 形参的数据类型自动转换成 C/C++ 相应的数据类型,所以我们在写 JNI 程序的时候,必须要明白它们之间数据类型的对应关系。

4.1 基础类型

boolean -- jboolean
byte -- jbyte
char -- bchar
short -- jshort
int -- jint
long -- jlong
float -- jfloat
double -- jdouble

4.2 引用类型

JNI 如果使用 C++ 语言编写的话,所有引用类型派生自 jobject,使用 C++ 的继承结构特性,使用相应的类型。如下所示:

class _jobject {};  
class _jclass : public _jobject {};  
class _jstring : public _jobject {};  
class _jarray : public _jobject {};  
class _jbooleanArray : public _jarray {};  
class _jbyteArray : public _jarray {};  

JNI 把 Java 中的所有对象当作一个C指针传递到本地方法中,这个指针指向 JVM 中的内部数据结构,而内部的数据结构在内存中的存储方式是不可见的。只能从 JNIEnv 指针指向的函数表中选择合适的 JNI 函数来操作 JVM 中的数据结构。

注意:JNI 层拿到的参数数据是以拷贝的形式存在,所以修改修改 JNI 中形参的值,不会引起 Java 层实参值的变化。如果一定要修改实参的值,必须以对象的方式传递参数,JNI 层对 jobject 参数进行修改,具体实现参考:http://www.cnblogs.com/CCBB/p/3980856.html


五、JNI 字符串处理

一个简单的例子:

extern "C"
JNIEXPORT jstring JNICALL
Java_com_scu_miomin_learncmake_NativeLib_stringFromJNI(
        JNIEnv *env,
        jclass /* this */,
        jstring j_str_first,
        jstring j_str_test) {

    jstring j_second = env->NewStringUTF(getStringValue().c_str());
    char *c_second = Jstring2CStr(env, j_second);
    char *c_str = Jstring2CStr(env, j_str_first);
    const char *c_str_test = env->GetStringUTFChars(j_str_test, NULL);

    if (c_second == NULL || c_str == NULL || c_str_test == NULL) {
        return NULL;
    }

    strcat(c_str, c_second);
    LOGV("j_str_test = %s", c_str_test);

    env->ReleaseStringUTFChars(j_str_test, c_str_test);

    return env->NewStringUTF(c_str);
}

这段代码从 Java 传递 String 参数止 JNI 层,JNI 再从 C++ 库获得另一个 string,在JNI层将这两个字符串拼接后返回给 Java。

5.1 访问字符串

在这个例子中,访问 java.lang.String 对应的 JNI 类型 jstring 时,没有像访问基本数据类型一样直接使用,因为它在 Java 是一个引用类型,所以在本地代码中只能通过 GetStringUTFChars 这样的 JNI 函数来访问字符串的内容。

GetStringUTFChars() 和 Jstring2CStr() 都是用于将 jstring 转换成 char* 类型的函数,前者是系统提供,后者是自己实现,区别在于返回值是否为 const。

5.2 异常检查

调用完 GetStringUTFChars 之后不要忘记安全检查,因为 JVM 需要为新诞生的字符串分配内存空间,当内存空间不够分配的时候,会导致调用失败,失败后 GetStringUTFChars 会返回 NULL,并抛出一个OutOfMemoryError 异常。JNI 的异常和 Java 中的异常处理流程是不一样的,Java 遇到异常如果没有捕获,程序会立即停止运行。而 JNI 遇到未决的异常不会改变程序的运行流程,也就是程序会继续往下走,这样后面针对这个字符串的所有操作都是非常危险的,因此,我们需要用 return 语句跳过后面的代码,并立即结束当前方法。

5.3 释放字符串

在调用 GetStringUTFChars 函数从 JVM 内部获取一个字符串之后,JVM 内部会分配一块新的内存,用于存储源字符串的拷贝,以便本地代码访问和修改。即然有内存分配,用完之后马上释放是一个编程的好习惯。通过调用ReleaseStringUTFChars 函数通知 JVM 这块内存已经不使用了,可以清除了。注意:这两个函数是配对使用的,用了 GetXXX 就必须调用 ReleaseXXX,而且这两个函数的命名也有规律,除了前面的 Get 和 Release 之外,后面的都一样。

5.4 创建字符串

通过调用 NewStringUTF 函数,会构建一个新的 java.lang.String 字符串对象。这个新创建的字符串会自动转换成 Java 支持的 Unicode 编码。如果 JVM 不能为构造 java.lang.String 分配足够的内存,NewStringUTF 会抛出一个 OutOfMemoryError 异常,并返回 NULL。一般来说不必检查它的返回值,如果NewStringUTF 创建 java.lang.String 失败,OutOfMemoryError 会在 Java 调用层中被抛出。如果 NewStringUTF 创建 java.lang.String 成功,则返回一个 JNI 引用,这个引用指向新创建的java.lang.String 对象。

5.5 其它字符串处理函数

(1)GetStringChars和ReleaseStringChars

这对函数和 Get/ReleaseStringUTFChars 函数功能差不多,用于获取和释放以 Unicode 格式编码的字符串。而后者是用于获取和释放 UTF-8 编码的字符串。

(2)GetStringLength

由于 UTF-8 编码的字符串以'\0'结尾,而 Unicode 字符串不是。如果想获取一个指向 Unicode 编码的 jstring 字符串长度,在 JNI 中可通过这个函数获取。

(3)GetStringUTFLength

获取 UTF-8 编码字符串的长度,也可以通过标准 C 函数 strlen 获取。

(4)GetStringCritical和ReleaseStringCritical

Get/ReleaseStringChars 和 Get/ReleaseStringUTFChars 这对函数返回的源字符串会后分配内存,如果有一个字符串内容相当大,有 1M 左右,而且只需要读取里面的内容打印出来,用这两对函数就有些不太合适了。此时用 Get/ReleaseStringCritical 可直接返回源字符串的指针应该是一个比较合适的方式。不过这对函数有一个很大的限制,在这两个函数之间的本地代码不能调用任何会让线程阻塞或等待 JVM 中其它线程的本地函数或 JNI 函数。因为通过 GetStringCritical 得到的是一个指向 JVM 内部字符串的直接指针,获取这个直接指针后会导致暂停 GC 线程,当 GC 被暂停后,如果其它线程触发 GC 继续运行的话,都会导致阻塞调用者。所以在 Get/ReleaseStringCritical 这对函数中间的任何本地代码都不可以执行导致阻塞的调用或为新对象在 JVM 中分配内存,否则,JVM 有可能死锁。另外一定要记住检查是否因为内存溢出而导致它的返回值为 NULL,因为 JVM 在执行 GetStringCritical 这个函数时,仍有发生数据复制的可能性,尤其是当 JVM 内部存储的数组不连续时,为了返回一个指向连续内存空间的指针,JVM 必须复制所有数据。下面代码演示这对函数的正确用法:

JNIEXPORT jstring JNICALL Java_com_study_jnilearn_Sample_sayHello  
  (JNIEnv *env, jclass cls, jstring j_str) {

    const jchar* c_str= NULL;  
    char buff[128] = "hello ";  
    char* pBuff = buff + 6;  
    /*
     * 在GetStringCritical/RealeaseStringCritical之间是一个关键区。
     * 在这关键区之中,绝对不能呼叫JNI的其他函数和会造成当前线程中断或是会让当前线程等待的任何本地代码,
     * 否则将造成关键区代码执行区间垃圾回收器停止运作,任何触发垃圾回收器的线程也会暂停。
     * 其他触发垃圾回收器的线程不能前进直到当前线程结束而激活垃圾回收器。
     */  
     // 返回源字符串指针的可能性  
    c_str = (*env)->GetStringCritical(env,j_str,NULL);  
    // 验证是否因为字符串拷贝内存溢出而返回NULL   
    if (c_str == NULL) {  
        return NULL;  
    }  
    while(*c_str) {  
        *pBuff++ = *c_str++;  
    }  
    (*env)->ReleaseStringCritical(env,j_str,c_str);  
    return (*env)->NewStringUTF(env,buff);  
}

JNI 中没有 Get/ReleaseStringUTFCritical 这样的函数,因为在进行编码转换时很可能会促使 JVM 对数据进行复制,因为 JVM 内部表示的字符串是使用 Unicode 编码的。

(5)GetStringRegion和GetStringUTFRegion

分别表示获取 Unicode 和 UTF-8 编码字符串指定范围内的内容。这对函数会把源字符串复制到一个预先分配的缓冲区内。下面代码用 GetStringUTFRegion 重新实现 sayHello 函数:

JNIEXPORT jstring JNICALL Java_com_study_jnilearn_Sample_sayHello  
  (JNIEnv *env, jclass cls, jstring j_str) {  

    jsize len = (*env)->GetStringLength(env,j_str);  // 获取unicode字符串的长度  
    printf("str_len:%d\n",len);  
    char buff[128] = "hello ";  
    char* pBuff = buff + 6;  
    // 将JVM中的字符串以utf-8编码拷入C缓冲区,该函数内部不会分配内存空间  
    (*env)->GetStringUTFRegion(env,j_str,0,len,pBuff);  
    return (*env)->NewStringUTF(env,buff);  
}

GetStringUTFRegion 这个函数会做越界检查,如果检查发现越界了,会抛出StringIndexOutOfBoundsException 异常,这个方法与 GetStringUTFChars 比较相似,不同的是,GetStringUTFRegion 内部不分配内存,不会抛出内存溢出异常。

注意:GetStringUTFRegion 和 GetStringRegion 这两个函数由于内部没有分配内存,所以 JNI 没有提供ReleaseStringUTFRegion 和 ReleaseStringRegion 这样的函数。

5.6 字符串操作建议

  • 对于小字符串来说,GetStringRegion 和 GetStringUTFRegion 这两对函数是最佳选择,因为缓冲区可以被编译器提前分配,而且永远不会产生内存溢出的异常。当你需要处理一个字符串的一部分时,使用这对函数也是不错。因为它们提供了一个开始索引和子字符串的长度值。另外,复制少量字符串的消耗 也是非常小的。
  • 使用 GetStringCritical 和 ReleaseStringCritical 这对函数时,必须非常小心。一定要确保在持有一个由 GetStringCritical 获取到的指针时,本地代码不会在 JVM 内部分配新对象,或者做任何其它可能导致系统死锁的阻塞性调用。
  • 获取 Unicode 字符串和长度,使用 GetStringChars 和 GetStringLength 函数。
  • 获取 UTF-8 字符串的长度,使用 GetStringUTFLength 函数。
  • 创建 Unicode 字符串,使用 NewStringUTF 函数。
  • 从 Java 字符串转换成 C/C++ 字符串,使用 GetStringUTFChars 函数。
  • 通过 GetStringUTFChars、GetStringChars、GetStringCritical 获取字符串,这些函数内部会分配内存,必须调用相对应的 ReleaseXXXX 函数释放内存。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,716评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,558评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,431评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,127评论 0 209
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,511评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,692评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,915评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,664评论 0 202
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,412评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,616评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,105评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,424评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,098评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,096评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,869评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,748评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,641评论 2 271

推荐阅读更多精彩内容