Android NDK 开发:实战案例

0. 前言

如果只学理论,不做实践,不踩踩坑,一般很难发现真正实践项目中的问题的,也比较难以加深对技术的理解。所以延续上篇 JNI 的实战Android NDK开发:JNI实战篇 ,这篇主要是一些 NDK 小项目的练习,由于这些项目网上都有 demo介绍,这里不会具体一步步介绍如何操作,只记录一些个人需要注意的地方或一些主要步骤,详细的介绍或代码可以点击里面的链接查看。

1. 文件加解密和分割合并

1.1 简介

所有文件都是二进制存储的,无论是文本,图片还是视频文件都是以二进制存储在磁盘中。所以可以通过对文件进行二进制运算进行加解密。下面用到的是比较简单的^异或运算来对文件加解密(算是一种对称加密算法)

附:加解密算法扩展:加解密算法 · 区块链技术指南

一般在大文件传输时,如音视频文件,会将文件分割后再传输,从而提高效率。当需要使用时,再将分割后的文件合并即可。

而文件加解密设计到安全,可以使用 NDK 增加反编译的难度。另外文件的分割合并都比较耗性能,可以放到 NDK 处理提高效率。

以下练习参考:NDK开发基础②文件加密解密与分割合并 - 简书

效果图如图,进入界面会拷贝两张 assets 的图片cats.jpg和image.jpg到本地sdcard/NdkSample目录下作为测试。加密后的图片cats_encypt.jpg是无法直接查看的,合成的图片是image.jpeg

效果图

1.2 文件加解密

Java 代码

publicclassFileUtils{privatestaticfinalString FILE_PATH_PREFIX = Environment.getExternalStorageDirectory() + File.separator;privatestaticfinalString FOLDER_NAME ="NdkSample"+ File.separator;publicstaticfinalString FILE_PATH = FILE_PATH_PREFIX + FOLDER_NAME;publicstaticbooleanfileEncrypt(){        String normalFilePath = FILE_PATH +"cats.jpg";        String encryptFilePath = FILE_PATH +"cats_encrypt.jpg";try{returnfileEncrypt(normalFilePath, encryptFilePath);        }catch(Exception e) {            e.printStackTrace();        }returnfalse;    }publicstaticbooleanfileDecode(){        String encryptFilePath = FILE_PATH +"cats_encrypt.jpg";        String decodeFilePath = FILE_PATH +"cats_decode.jpg";try{returnfileDecode(encryptFilePath, decodeFilePath);        }catch(Exception e) {            e.printStackTrace();        }returnfalse;    }privatestaticnativebooleanfileEncrypt(String normalFilePath, String encryptFilePath);privatestaticnativebooleanfileDecode(String encryptFilePath, String decodeFilePath);}

JNI 加密代码实现,注意加文件读写权限

constchar*PASSWORD ="pw";longgetFileSize(char* filePath);extern"C"JNIEXPORT jboolean JNICALLJava_cn_cfanr_ndksample_utils_FileUtils_fileEncrypt(JNIEnv *env, jclass type, jstring normalFilePath_,

                                                    jstring encryptFilePath_){constchar*normalFilePath = env->GetStringUTFChars(normalFilePath_,0);constchar*encryptFilePath = env->GetStringUTFChars(encryptFilePath_,0);intpasswordLen =strlen(PASSWORD);    LOGE("要加密的文件的路径 = %s , 加密后的文件的路径 = %s", normalFilePath, encryptFilePath);//读文件指针FILE *frp = fopen(normalFilePath,"rb");// 写文件指针FILE *fwp = fopen(encryptFilePath,"wb");if(frp ==NULL) {        LOGE("文件不存在");returnJNI_FALSE;    }if(fwp ==NULL) {        LOGE("没有写权限");returnJNI_FALSE;    }// 边读边写边加密intbuffer;intindex =0;while((buffer = fgetc(frp)) != EOF) {// writefputc(buffer ^ *(PASSWORD + (index % passwordLen)), fwp);//异或的方式加密index++;    }// 关闭文件流fclose(fwp);    fclose(frp);    LOGE("文件加密成功");    env->ReleaseStringUTFChars(normalFilePath_, normalFilePath);    env->ReleaseStringUTFChars(encryptFilePath_, encryptFilePath);returnJNI_TRUE;}

解密代码类似。

1.3 文件分割合并

Java 代码实现

publicstaticbooleanfileSplit(){        String splitFilePath = FILE_PATH +"image.jpg";        String suffix =".b";try{returnfileSplit(splitFilePath, suffix,4);        }catch(Exception e) {            e.printStackTrace();        }returnfalse;    }/**    * 文件合并    *    *@return*/publicstaticbooleanfileMerge(){        String splitFilePath = FILE_PATH +"image.jpg";        String splitSuffix =".b";        String mergeSuffix =".jpeg";try{returnfileMerge(splitFilePath, splitSuffix, mergeSuffix,4);        }catch(Exception e) {            e.printStackTrace();        }returnfalse;    }/**    * 文件分割    *    *@paramsplitFilePath 要分割文件的路径    *@paramsuffix        分割文件的扩展名    *@paramfileNum      分割文件的数量    *@return*/privatestaticnativebooleanfileSplit(String splitFilePath, String suffix,intfileNum);/**    * 文件合并    *    *@paramsplitFilePath 分割文件的路径    *@paramsplitSuffix  分割文件的扩展名    *@parammergeSuffix  合并文件的扩展名    *@paramfileNum      分割文件的数量    *@return*/privatestaticnativebooleanfileMerge(String splitFilePath, String splitSuffix, String mergeSuffix,intfileNum);

注意,文件的分割合并需要设置文件扩展名后分割文件数量。分割时,分两种情况,

1)能整除的,直接平均分;

2)不能整除的,fileSize % ( n -1),前 n -1 个平均分,剩余的留给最后一个;

合并时,需要注意的是,必须按照分割的顺序合并

其余 JNI 实现代码略,可以到 GitHub 查看具体源码:NdkSample/native_file_handler.cpp

2. Android 增量更新

2.1 简介

所谓增量更新,是服务器将新旧版本的 apk 做差分处理,生成一个差分包 patch,下发到客户端;客户端再用 patch 包和本地的 apk 合并成新的 apk,再安装。很显然,这样在一定程度上可以减少更新 apk 时消耗的流量。目前在很多应用市场也有用到这种技术。增量更新技术主要解决是安装包文件过大的问题。

2.2 优缺点

优点:节省流量,下载 apk 时,只需要下载差分包,不用下载完整包;

缺点:

客户端和服务端都需要加入相关的支持。每次新版本发布,服务器需要根据新版本对以前所有老版本生成对应的差分包,而且还要维护不同渠道的包;另外客户端请求时,上传当前版本号,服务器返回对应的差分包和新版本 apk的 md5 值,作为合并新 apk 后的校验;所以整体流程会有点繁琐;

合成差分包会有点耗时(最好用单独线程处理)和耗内存的,内存不足的手机或本地 apk 损坏的 apk 无法进行增量更新;另外 apk 包之间差异比较小(2m 以下)时,生成的差分包仍然有几百 k;

2.3 差分包的生成与合并

需要用工具对文件进行 diff 和 patch 处理,一般可以通过bsdiff实现

具体使用可以参考 Hongyang 的文章Android 增量更新完全解析  是增量不是热修复 - Hongyang,在这里就不啰嗦了

注意,在执行 make 命令,可能报以下错误,(以下环境都是在 Mac 上)

bspatch.c:39:21: error: unknown type name'u_char'; did you mean'char'?staticoff_tofftin(u_char *buf)^~~~~~char

可以通过在bspatch.c文件加上#include <sys/types.h>头文件(Hongyang 没说明清楚),参考:编译和使用bsdiff - 木头平 - 博客园

主要掌握几个命令:

执行make命令,使 makefile  生成 bsdiff 和 bspatch 可执行文件;

执行./bsdiff old.apk new.apk update.patch,生成差分包;

执行./bspatch old.apk new2.apk update.patch,合成新 apk;

执行md5 xxx.apk查看 apk 的 md5 值;

2.4 服务端操作

服务端需要返回一个文件和新版本 apk md5值给客户端。

用 2.3 生成的 bsdiff 可执行文件生成新版本 apk 和老版本的 apk 的差分包 update.patch;(可以编写个脚本处理)

使用 md5 命令查看新 apk 的 md5,并保存,之后需要返回给客户端;

2.5 客户端操作

主要实现是如何制造 bspatch 的 so 文件,由于网上详细的步骤都比较完善了,这里也不啰嗦了,只简单说下需要注意的问题。

由于按照 AS 的默认的 CMake 建 NDK 的方式,C/C++ 的文件是在 cpp 目录的,Hongyang 的是在 jni 目录,两者配置方式不太一样,如果同时使用,只会编译 CMake 的配置,所以需要将bspatch.c放到 cpp 目录,同时还需要下载bzip源码

详细步骤可以参考,Android增量更新与CMake构建工具 - 亚特兰蒂斯 - CSDN博客

PS:这里的 CMakeLists.txt 是放在 cpp 目录下,特别需要注意的是,由于 CMakeLists.txt 目录改变了,必须修改 C/C++ 源文件的路径,去掉src/main/cpp,同时也要修改build.gradle的 cmake 文件的路径,不然会编译失败

# Sets the minimum version of CMake required to build the native library.cmake_minimum_required(VERSION 3.4.1)#支持-std=gnu++11set(CMAKE_VERBOSE_MAKEFILE on)set(CMAKE_CXX_FLAGS"${CMAKE_CXX_FLAGS}-std=gnu++11 -Wall -DGLM_FORCE_SIZE_T_LENGTH")set(CMAKE_CXX_FLAGS"${CMAKE_CXX_FLAGS}-DGLM_FORCE_RADIANS")#设置生成的so动态库最后输出的路径set(CMAKE_LIBRARY_OUTPUT_DIRECTORY${PROJECT_SOURCE_DIR}/../jniLibs/${ANDROID_ABI})#添加bzip2目录,为构建添加一个子路径set(bzip2_src_DIR${CMAKE_SOURCE_DIR})add_subdirectory(${bzip2_src_DIR}/bzip2)add_library( native-lib            SHARED# Provides a relative path to your source file(s). 注意,CMakeLists.txt 在 cpp 目录下,此处不需要加路径前缀 src/main/cppnative_file_handler.cpp            bspatch.c            )find_library(log-liblog)target_link_libraries(native-lib${log-lib})

build.gradle 文件

externalNativeBuild {        cmake {            path"src/main/cpp/CMakeLists.txt"}    }

另外,修改的bspatch.c文件增加的 JNI 代码中,第一个参数是 so 库的名字,注意一定要保持一致

//……JNIEXPORT jint JNICALLJava_cn_cfanr_ndksample_utils_BsPatch_bspatch(JNIEnv *env, jclass jcls, jstring oldApk_,jstring newApk_, jstring patch_){constchar*oldApkPath = (*env)->GetStringUTFChars(env, oldApk_,0);constchar*newApkPath = (*env)->GetStringUTFChars(env, newApk_,0);constchar*patchPath = (*env)->GetStringUTFChars(env, patch_,0);intargc =4;char* argv[argc];    argv[0] ="native-lib";//注意此处是 so 库名字argv[1] = oldApkPath;    argv[2] = newApkPath;    argv[3] = patchPath;    jint ret = patchMethod(argc, argv);    (*env)->ReleaseStringUTFChars(env, oldApk_, oldApkPath);    (*env)->ReleaseStringUTFChars(env, newApk_, newApkPath);    (*env)->ReleaseStringUTFChars(env, patch_, patchPath);returnret;}//……

其他代码逻辑:

1)从服务器下载差分包 update.patch 保存到本地,并请求获取新版 apk 的 md5值;

2)提取本地的 apk 文件;

3)使用 JNI 方法public static native int bspatch(String oldApk, String newApk, String patch)将 update.patch 和本地旧的 apk 合并成新的 apk;

4)校验生成的新 apk 的 md 值是否和服务器返回的一样;

5)检测新 apk 和服务器提供的一致后,安装新的 apk 文件

不过 demo 是没有写从服务器下载差分包的逻辑的,这里是将差分包通过adb push patch路径 /sdcard/NdkSample命令放到手机来测试的

具体代码可以查看 Github:NdkSample/PatchUpdateActivity.java

3.  Android 封装 libjpeg 库

3.1  编译 libjpeg.so 库

1.克隆 libjpeg-trubo Android 版到本地,并解压

gitclonegit://git.linaro.org/people/tomgall/libjpeg-turbo/libjpeg-turbo.git -b linaro-android

2.在配置好 ndk-build 环境后(具体步骤略),开始编译 libjpeg-trubo 库

按照网上大多数教程的步骤都是执行以下命令

ndk-build APP_ABI=armeabi-v7a,armeabi

但可能由于我本地配置的版本是 ndk-14的,直接执行这个命令并没有奏效,以下是我遇到的一些错误:

如果你没进入 libjpeg-turbo 目录就执行命令,可能会报以下错误,也就是找不到 Android.mk

Android NDK: Your APP_BUILD_SCRIPT points to an unknown file: ./Android.mk

/Users/cfanr/Library/Android/sdk/ndk-bundle/build/core/add-application.mk:198: *** Android NDK: Aborting...    .  Stop.

如果报找不到应用项目的目录,如下:

Android NDK: Couldnotfind application project directory !Android NDK: Please define the NDK_PROJECT_PATH variable to point to it./Users/cfanr/Library/Android/sdk/ndk-bundle/build/core/build-local.mk:151: *** Android NDK: Aborting    .  Stop.

就需要设置下NDK_PROJECT_PATH指定需要编译的代码的工程目录,这里给出的是当前目录,还有,APP_BUILD_SCRIPT是Android makefile文件的路径,如果你还有Application.mk文件的话,则可以添加NDK_APP_APPLICATION_MK=./Application.mk,参考:Android开发实践:在任意目录执行NDK编译 - Jhuster的专栏

如果 NDK 版本过高,可能会报以下错误,

Android.mk:11: Extraneous text after `ifeq' directiveAndroid NDK: WARNING: Unsupported source file extensions in Android.mkformodulejpegAndroid NDK:  turbojpeg-mapfile/Users/cfanr/Library/Android/sdk/ndk-bundle/build/core/build-binary.mk:687: Android NDK: Module jpeg depends on undefined modules: cutils/Users/cfanr/Library/Android/sdk/ndk-bundle/build/core/build-binary.mk:700: *** Android NDK: Aborting (setAPP_ALLOW_MISSING_DEPS=trueto allow missing dependencies)    .  Stop.

所以,我最终的解决方法是用指定的低版本的 ndk (ndk-r11c)去编译,而不是用我在系统配置 ndk。正确的命令,使用指定NDK版本编译

~/NDK/android-ndk-r11c/ndk-build NDK_PROJECT_PATH=. APP_BUILD_SCRIPT=./Android.mk APP_ABI=armeabi-v7a,armeabi

检测已编译成功:编译成功后,会在 libjpeg-turbo 生成 libs 和 obj 文件夹,里面分别会有你设置的 ABI 类型的  libjpeg.so 库和其他生成的文件,需要拷贝到项目中的是 libs 文件下的 libjpeg.so 库。

3.2 使用 libjpeg.so 库编写压缩图片的 native 方法

参考:Android使用libjpeg实现图片压缩 - BlueBerry的专栏 - CSDN博客

1.拷贝 libjpeg.so 和头文件到项目中

首先将不同 ABI 的 libjpeg.so 拷贝到项目的 libs 目录下,再将上面下载的 libjpeg-turbo 的源码的所有头文件拷贝到 cpp/include 目录下

2.配置CMakeLists文件

练习时,要注意 CMakeLists 的配置,不然可能会发生以下错误(博主就是因为没看清楚文章,没配置好,出错后,一直搜索,浪费不少时间 🤷‍♀️)

1)如果 CMakeLists.txt 文件关联到Android的 Bitmap 相关库jnigraphics,会报以下错误,未定义 Bitmap

Error:(39)undefinedreference to'AndroidBitmap_getInfo'Error:(43)undefinedreference to'AndroidBitmap_lockPixels'Error:(85)undefinedreference to'AndroidBitmap_unlockPixels'Error:error: linker command failedwithexit code1(use -v to see invocation)

2)如果只是添加了 libjpeg.so 库add_library(libjpeg SHARED IMPORTED ),未设置关联库target_link_libraries(),会报类似以下未定义某些属性的错误:

Error:(99)undefinedreference to'jpeg_std_error'Error:(106)undefinedreference to'jpeg_CreateCompress'Error:(114)undefinedreference to'jpeg_stdio_dest'Error:(122)undefinedreference to'jpeg_set_defaults'Error:(126)undefinedreference to'jpeg_set_quality'

看来不熟悉理解 CMake 构建脚本还是挺容易踩坑的,下篇会详细介绍 CMake 构建脚本的使用。

完整构建脚本如下:(如果 libjpeg.so 或头文件放置的目录和我的不一样,下面就需要修改下)

# Sets the minimum version of CMake required to build the native library.cmake_minimum_required(VERSION 3.4.1)#设置生成的so动态库最后输出的路径set(CMAKE_LIBRARY_OUTPUT_DIRECTORY${PROJECT_SOURCE_DIR}/src/main/jniLibs/${ANDROID_ABI})#指定要引用的libjpeg.so的头文件目录set(LIBJPEG_INCLUDE_DIR src/main/cpp/include)include_directories(${LIBJPEG_INCLUDE_DIR})#导入libjpeg动态库 SHARED;静态库为STATICadd_library(libjpeg SHARED IMPORTED)#对应so目录,这里为了简单设置的是绝对路径(注意要先 add_library,再 set_target_properties)set_target_properties(libjpeg PROPERTIES IMPORTED_LOCATION /Users/cfanr/AndroidStudioProjects/DemoProjects/NDKSample/compress/libs/${ANDROID_ABI}/libjpeg.so)add_library(            compress            SHARED            src/main/cpp/compress.c            )find_library(graphics jnigraphics)find_library(log-liblog)target_link_libraries(compress libjpeg${log-lib}${graphics})

3.编写Java 层 native 方法

publicclassImageCompress{static{        System.loadLibrary("compress");    }privateImageCompress(){    }publicstaticnativebooleancompressBitmap(Bitmap bitmap, String dstPath,intquality,booleanisOptimize);}

4.实现 JNI 逻辑

1)将 Android 的 Bitmap 解码转化为 RGB 数据;

2)为 JPEG 对象分配空间并初始化;

3)获取文件信息,然后指定压缩数据源;

4)为压缩设定参数,包括图像大小,颜色空间;

5)开始压缩;

6)压缩完毕后,释放资源;

具体代码查看: navyifanr/NdkSample: compress.c

效果图:

效果图

4. NDK技术在 Android 的应用场景简述

4.1 首先需要了解 NDk 有什么作用和特点?

NDK 作用是 Google 提供了交叉编译工具链,能够在 linux 平台编译出在 arm 平台下执行的二进制库文件;

NDK 特点:(来自:Android:JNI 与 NDK到底是什么?- Carson_Ho的博客 - CSDN博客

NDK 特点

4.2 应用场景

优化密集运算和消耗资源较大模块的性能,如音视频解码,图像操作等

需要提高安全性的地方,编译成 so  库不容易被反编译;如文件加密、核心算法模块等;

跨平台应用的需要;

一些 Android NDK 的具体应用场景:

跨平台的音视频解码库 FFmpeg;

Android 增量更新技术;

Android 加固和防逆向技术;

一些热修复技术;

人脸识别,OpenCV 等

Android 平台的游戏开发等;

附:

C/C++代码被编译成库文件之后,才能执行,库文件分为动态库和静态库两种:

so库文件类型

库文件来源:C/C++代码进行编译链接操作之后,才会生成库文件,不同类型的CPU 操作系统生成的库文件是不一样;

CPU分类:arm结构,嵌入式设备处理器; x86结构,pc 服务器处理器; 不同的CPU指令集不同;

交叉编译:windows x86编译出来的库文件可以在arm平台运行的代码;

交叉编译工具链:Google提供的 NDK 就是交叉编译工具链,可以在linux环境下编译出在ARM 平台下执行的二进制库文件;

补充:添加 .so 库的方法

简单粗暴的方法

直接在 project 或 module 创建一个 src/main/jniLibs 文件夹,然后拷贝 .so 文件到该文件夹下(当然 so 库要指定 ABI 类型),如

-----src--------main------------jniLibs-----------------armabi---------------------libcompress.so

添加到 libs 的方法

直接拷贝含 abi 类型的 so 库到 project-root/libs 或 module-root/libs 下,如

-----app--------libs------------armabi------------------libcompress.so

然后在该 project 或 module 的 build.gradle 文件下添加 jnilibs 的目录路径:

android {//……sourceSets {        main {            jniLibs.srcDirs = ['libs']        }    }}

最后 build 以下即可,其实最终还是和第一种方法一致的,切换项目的预览模式为‘android’,可以看到多了一个 jniLibs 的文件夹,里面放的内容和 libs 的一样。

参考资料:

NDK开发基础②文件加密解密与分割合并 - 简书

Android 增量更新完全解析  是增量不是热修复 - Hongyang - CSDN博客

Android增量更新与CMake构建工具 - 亚特兰蒂斯 - CSDN博客

JNI开发实例-封装libjpeg库 保证图片质量压缩图片 - 猫的阁楼 - CSDN博客

Android使用libjpeg实现图片压缩 - BlueBerry的专栏 - CSDN博客

Android:JNI 与 NDK到底是什么?- Carson_Ho的博客 - CSDN博客

作者:cfanr

链接://www.greatytc.com/p/c32132784392

來源:简书

简书著作权归作者所有,任何形式的转载都请联系作者获得授权并注明出处。

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

推荐阅读更多精彩内容