ASM+Gradle Transfrom API 实现编译期间代码的修改

ASM 是什么?

AOP(面向切面编程),是一种编程思想,但是它的实现方式有很多,比如:APT、AspectJ、JavaAssist、ASM 等。


常见的几种AOP区别

ASM 和 Javassist类似,也是一个 Java 字节码操控框架。它能被用来动态生成类或者增强既有类的功能。ASM 可以直接产生二进制 class 文件,也可以在类被加载入 Java 虚拟机之前动态改变类行为。Java class 被存储在严格格式定义的 .class 文件里,这些类文件拥有足够的元数据来解析类中的所有元素:类名称、方法、属性以及 Java 字节码(指令)。ASM 从类文件中读入信息后,能够改变类行为,分析类信息,甚至能够根据用户要求生成新类。

简单点说,通过 javac 将 .java 文件编译成 .class 文件,.class 文件中的内容虽然不同,但是它们都具有相同的格式,ASM 通过使用访问者(visitor)模式,按照 .class 文件特有的格式从头到尾扫描一遍 .class 文件中的内容,在扫描的过程中,就可以对 .class 文件做一些操作了,有点黑科技的感觉

所以ASM 就是一个字节码操作库,可以大大降低我们操作字节码的难度

Android 的打包过程

android 打包流程

如图所示是Android打包流程,.java文件->.class文件->.dex文件,只要在红圈处拦截住,拿到所有方法进行修改完再放行就可以了,而做到这一步也不难,Google官方在Android Gradle的1.5.0 版本以后提供了 Transfrom API, 允许第三方 Plugin 在打包 dex 文件之前的编译过程中操作 .class 文件,我们做的就是实现Transform进行.class文件遍历拿到所有方法,修改完成对原文件进行替换。


对应细节图

原理概述

我们可以自定义一个Gradle Plugin,然后注册一个Transform对象,在tranform方法里,可以分别遍历目录和jar包,然后我们就可以遍历当前应用程序的所有.class文件,然后在利用ASM框架的相关API,去加载响应的.class 文件,并解析,就可以找到满足特定条件的.class文件和相关方法,最后去修改相应的方法以动态插入埋点字节码,从而达到自动埋点的效果。

DEMO

本范例尝试对点击android中的普通点击事件进行一个拦截,并在其中插入代码。

1、创建android工程,只写一个简单点击事件即可(

代码..略

2、创建plugin lib module

1、修改plugin的gradle

apply plugin: 'groovy'
apply plugin: 'maven'
dependencies {
    compile gradleApi()
    compile localGroovy()

    compile 'org.ow2.asm:asm:6.0'
    compile 'org.ow2.asm:asm-commons:6.0'
    compile 'org.ow2.asm:asm-analysis:6.0'
    compile 'org.ow2.asm:asm-util:6.0'
    compile 'org.ow2.asm:asm-tree:6.0'
    compileOnly 'com.android.tools.build:gradle:3.2.1', {//这里注意需要保持版本一致,否则会报错
        exclude group:'org.ow2.asm'
    }
}
repositories {
    jcenter()
}

//调试模式下在本地生成仓库(也可推入自己已有的maven仓库)
uploadArchives {
    repositories.mavenDeployer {
        //本地仓库路径,以放到项目根目录下的 repo 的文件夹为例
        repository(url: uri('../repo'))

        //groupId ,自行定义
        pom.groupId = 'com.canzhang.android'

        //artifactId
        pom.artifactId = 'bury-point-com.canzhang.plugin'

        //插件版本号
        pom.version = '1.0.0-SNAPSHOT'
    }
}

2、在main目录下新建groovy包

groovy 是一种语言,和java语法比较类似

image.png

3、创建transform类
这个类的作用就是在被编译成dex之前能够拦截到.class文件,然后找到匹配我们需求的,进行修改调整。

/**
 * Google官方在Android Gradle的1.5.0 版本以后提供了 Transfrom API,
 * 允许第三方 Plugin 在打包 dex 文件之前的编译过程中操作 .class 文件,
 * 我们做的就是实现Transform进行.class文件遍历拿到所有方法,修改完成对原文件进行替换。
 */
class AnalyticsTransform extends Transform {
    private static Project project
    private AnalyticsExtension analyticsExtension

    AnalyticsTransform(Project project, AnalyticsExtension analyticsExtension) {
        this.project = project
        this.analyticsExtension = analyticsExtension
    }

    /**
     * /返回该transform对应的task名称(编译后会出现在build/intermediates/transform下生成对应的文件夹)
     * @return
     */
    @Override
    String getName() {
        return AnalyticsSetting.PLUGIN_NAME
    }

    /**
     * 需要处理的数据类型,有两种枚举类型
     * CLASSES 代表处理的 java 的 class 文件,RESOURCES 代表要处理 java 的资源
     * @return
     */
    @Override
    Set<QualifiedContent.ContentType> getInputTypes() {
        return TransformManager.CONTENT_CLASS
    }

    /**
     * 指 Transform 要操作内容的范围,官方文档 Scope 有 7 种类型:
     * 1. EXTERNAL_LIBRARIES        只有外部库
     * 2. PROJECT                   只有项目内容
     * 3. PROJECT_LOCAL_DEPS        只有项目的本地依赖(本地jar)
     * 4. PROVIDED_ONLY             只提供本地或远程依赖项
     * 5. SUB_PROJECTS              只有子项目。
     * 6. SUB_PROJECTS_LOCAL_DEPS   只有子项目的本地依赖项(本地jar)。
     * 7. TESTED_CODE               由当前变量(包括依赖项)测试的代码
     * @return
     */
    @Override
    Set<QualifiedContent.Scope> getScopes() {
        //点进去可以看到这个包含(项目、项目依赖、外部库)
        //Scope.PROJECT,
        //Scope.SUB_PROJECTS,
        //Scope.EXTERNAL_LIBRARIES
        return TransformManager.SCOPE_FULL_PROJECT
//        return Sets.immutableEnumSet(
//                QualifiedContent.Scope.PROJECT,
//                QualifiedContent.Scope.SUB_PROJECTS)
    }

    @Override
    boolean isIncremental() {//是否增量构建
        return false
    }

    //这里需要注意,就算什么都不做,也需要把所有的输入文件拷贝到目标目录下,否则下一个Task就没有TransformInput了,
    // 如果是此方法空实现,最后会导致打包的APK缺少.class文件
    @Override
    void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
        _transform(transformInvocation.context, transformInvocation.inputs, transformInvocation.outputProvider, transformInvocation.incremental)
    }

    void _transform(Context context, Collection<TransformInput> inputs, TransformOutputProvider outputProvider, boolean isIncremental) throws IOException, TransformException, InterruptedException {
        if (!incremental) {
            outputProvider.deleteAll()
        }

        /**Transform 的 inputs 有两种类型,一种是目录,一种是 jar 包,要分开遍历 */
        inputs.each { TransformInput input ->
            /**遍历目录*/
            input.directoryInputs.each { DirectoryInput directoryInput ->
                /**当前这个 Transform 输出目录*/
                File dest = outputProvider.getContentLocation(directoryInput.name, directoryInput.contentTypes, directoryInput.scopes, Format.DIRECTORY)
                File dir = directoryInput.file

                if (dir) {
                    HashMap<String, File> modifyMap = new HashMap<>()
                    /**遍历以某一扩展名结尾的文件*/
                    dir.traverse(type: FileType.FILES, nameFilter: ~/.*\.class/) {
                        File classFile ->
                            if (AnalyticsClassModifier.isShouldModify(classFile.name, analyticsExtension)) {
                                File modified = AnalyticsClassModifier.modifyClassFile(dir, classFile, context.getTemporaryDir())
                                if (modified != null) {
                                    /**key 为包名 + 类名,如:/cn/data/autotrack/android/app/MainActivity.class*/
                                    String ke = classFile.absolutePath.replace(dir.absolutePath, "")
                                    modifyMap.put(ke, modified)//修改过后的放到一个map中然后在写回源目录,覆盖原来的文件
                                }
                            }
                    }
                    FileUtils.copyDirectory(directoryInput.file, dest)
                    modifyMap.entrySet().each {
                        Map.Entry<String, File> en ->
                            File target = new File(dest.absolutePath + en.getKey())
                            if (target.exists()) {
                                target.delete()
                            }
                            FileUtils.copyFile(en.getValue(), target)
                            en.getValue().delete()
                    }
                }
            }

            /**遍历 jar*/
            input.jarInputs.each { JarInput jarInput ->
                String destName = jarInput.file.name

                /**截取文件路径的 md5 值重命名输出文件,因为可能同名,会覆盖*/
                def hexName = DigestUtils.md5Hex(jarInput.file.absolutePath).substring(0, 8)
                /** 获取 jar 名字*/
                if (destName.endsWith(".jar")) {
                    destName = destName.substring(0, destName.length() - 4)
                }

                /** 获得输出文件*/
                File dest = outputProvider.getContentLocation(destName + "_" + hexName, jarInput.contentTypes, jarInput.scopes, Format.JAR)

                def modifiedJar = AnalyticsClassModifier.modifyJar(jarInput.file, context.getTemporaryDir(), true, analyticsExtension)
                if (modifiedJar == null) {
                    modifiedJar = jarInput.file
                }
                FileUtils.copyFile(modifiedJar, dest)
            }
        }
    }

  
}

3、创建插件类

/**
 * 可以通过配置主工程目录中的gradle.properties 中的
 * canPlugin.disablePlugin字段来控制是否开启此插件
 */
class AnalyticsPlugin implements Plugin<Project> {
    void apply(Project project) {

        //这个AnalyticsExtension 以及canPlugin名称,可以提供我们在外层配置一些参数,从而支持外层扩展
        AnalyticsExtension extension = project.extensions.create("canPlugin", AnalyticsExtension)

        //这个可以读取工程的gradle.properties 里面的can.disablePlugin 字段,控住是否注册此插件
        boolean disableAnalyticsPlugin = false
        Properties properties = new Properties()
        if (project.rootProject.file('gradle.properties').exists()) {
            properties.load(project.rootProject.file('gradle.properties').newDataInputStream())
            disableAnalyticsPlugin = Boolean.parseBoolean(properties.getProperty("disablePlugin", "false"))
        }

        if (!disableAnalyticsPlugin) {
            println("------------您开启了全埋点插桩插件--------------")
            AppExtension appExtension = project.extensions.findByType(AppExtension.class)
            //注册我们的transform类
            appExtension.registerTransform(new com.canzhang.plugin.AnalyticsTransform(project, extension))
        } else {
            println("------------您已关闭了全埋点插桩插件--------------")
        }
    }
}

到这里插件和gradle的tranform类我们都创建好了,下面需要看该怎么修改我们想修改的类了。
4、ASM中的ClassVisitor
ClassVisitor:主要负责遍历类的信息,包括类上的注解、构造方法、字段等等。
所以我们可以在这个类中筛选出符合我们条件的类或者方法,然后去修改,实现我们的目的。
比如我们本例子就是为了找到实现了View$OnClickListener接口的类,然后遍历这个类,并找到重写后的onClick(View v)方法。

这里就细节贴代码了,不懂得地方可以看注释

/**
 * 使用ASM的ClassReader类读取.class的字节数据,并加载类,
 * 然后用自定义的ClassVisitor,进行修改符合特定条件的方法,
 * 最后返回修改后的字节数组
 */
class AnalyticsClassVisitor extends ClassVisitor implements Opcodes {

//插入的外部类具体路径
    private String[] mInterfaces
    private ClassVisitor classVisitor
    private String mCurrentClassName

    AnalyticsClassVisitor(final ClassVisitor classVisitor) {
        super(Opcodes.ASM6, classVisitor)
        this.classVisitor = classVisitor
    }

    private
    static void visitMethodWithLoadedParams(MethodVisitor methodVisitor, int opcode, String owner, String methodName, String methodDesc, int start, int count, List<Integer> paramOpcodes) {
        for (int i = start; i < start + count; i++) {
            methodVisitor.visitVarInsn(paramOpcodes[i - start], i)
        }
        methodVisitor.visitMethodInsn(opcode, owner, methodName, methodDesc, false)
    }

    /**
     * 这里可以拿到关于.class的所有信息,比如当前类所实现的接口类表等
     * @param version 表示jdk的版本
     * @param access 当前类的修饰符 (这个和ASM 和 java有些差异,比如public 在这里就是ACC_PUBLIC)
     * @param name 当前类名
     * @param signature 泛型信息
     * @param superName 当前类的父类
     * @param interfaces 当前类实现的接口列表
     */
    @Override
    void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
        super.visit(version, access, name, signature, superName, interfaces)
        mInterfaces = interfaces
        mCurrentClassName = name

        AnalyticsUtils.logD("当前的类是:" + name)
        AnalyticsUtils.logD("当前类实现的接口有:" + mInterfaces)
    }

    /**
     * 这里可以拿到关于method的所有信息,比如方法名,方法的参数描述等
     * @param access 方法的修饰符
     * @param name 方法名
     * @param desc 方法签名(就是(参数列表)返回值类型拼接)
     * @param signature 泛型相关信息
     * @param exceptions 方法抛出的异常信息
     * @return
     */
    @Override
    MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
        MethodVisitor methodVisitor = super.visitMethod(access, name, desc, signature, exceptions)

        String nameDesc = name + desc

        methodVisitor = new AnalyticsDefaultMethodVisitor(methodVisitor, access, name, desc) {

            @Override
            void visitEnd() {
                super.visitEnd()
            }

            @Override
            void visitInvokeDynamicInsn(String name1, String desc1, Handle bsm, Object... bsmArgs) {
                super.visitInvokeDynamicInsn(name1, desc1, bsm, bsmArgs)
            }

            @Override
            protected void onMethodExit(int opcode) {//方法退出节点
                super.onMethodExit(opcode)
            }

            @Override
            protected void onMethodEnter() {//方法进入节点
                super.onMethodEnter()

                if ((mInterfaces != null && mInterfaces.length > 0)) {
                    //如果当前类实现的接口有View$OnClickListener,并且当前进入的方法是onClick(Landroid/view/View;)V
                    //这里如果不知道怎么写,可以写个demo打印一下,就很快知道了,这里涉及一些ASM和Java中不同的写法。
                    if ((mInterfaces.contains('android/view/View$OnClickListener') && nameDesc == 'onClick(Landroid/view/View;)V')) {
                        AnalyticsUtils.logD("插桩:OnClickListener nameDesc:" + nameDesc + " currentClassName:" + mCurrentClassName)
                         //这里就是插代码逻辑了
                        methodVisitor.visitVarInsn(ALOAD, 1)
                        methodVisitor.visitMethodInsn(INVOKESTATIC, "com/canzhang/asmdemo/sdk/MySdk", "onViewClick", "(Landroid/view/View;)V", false)
                    }
                }
            }

            @Override
            AnnotationVisitor visitAnnotation(String s, boolean b) {
                return super.visitAnnotation(s, b)
            }
        }
        return methodVisitor
    }
}

要插入的代码

public class MySdk {
    /**
     * 常规view 被点击,自动埋点
     *
     * @param view View
     */
    @Keep
    public static void onViewClick(View view) {
        Log.e("Test","成功插入 666666:"+view);
    }
}

核心代码分析

            @Override
            protected void onMethodEnter() {//方法进入节点
                super.onMethodEnter()

                if ((mInterfaces != null && mInterfaces.length > 0)) {
                    //如果当前类实现的接口有View$OnClickListener,并且当前进入的方法是onClick(Landroid/view/View;)V
                    //这里如果不知道怎么写,可以写个demo打印一下,就很快知道了,这里涉及一些ASM和Java中不同的写法。
                    if ((mInterfaces.contains('android/view/View$OnClickListener') && nameDesc == 'onClick(Landroid/view/View;)V')) {
                        AnalyticsUtils.logD("插桩:OnClickListener nameDesc:" + nameDesc + " currentClassName:" + mCurrentClassName)

                        //这里就是插代码逻辑了
                        methodVisitor.visitVarInsn(ALOAD, 1)
                        methodVisitor.visitMethodInsn(INVOKESTATIC, "com/canzhang/asmdemo/sdk/MySdk", "onViewClick", "(Landroid/view/View;)V", false)
                    }
                }
            }

当方法进入的时候,如果判断符合我们的条件,则进行方法插入。

  • 问题1:nameDesc为啥这么写。
    nameDesc == 'onClick(Landroid/view/View;)V'为什么是这样写的,后面的V是个什么东东。
    首先grovvy中是可以使用==号来判断字符串是否相等的,其次方法名是和java有一些差异,这个我们可以深入去了解这些差异学习,就可以理解为何这么写。还有一种简单的方法,可以直接打印日志的方式来快速知道我们需要的方法应该怎么写。
    入参对应关系表
    image.png

例子

image.png

  • 问题2: 这插入的是什么鬼,怎么有点看不懂,如何知道怎么插。
    ASM就是帮助我们操作字节码的,封装了一些api可供我们调用,这个转换可以使用一个插件 ASM Bytecode outline ,android studio 可以下载此插件(参考教程
    )。

5、创建配置文件
按照如图所示创建对应路径和配置文件com.canzhang.plugin.properties,这里需要注意

  • 配置文件的名字:com.canzhang.plugin就是插件的名称,就是稍后我们生成插件后,引用此插件的module需要声明的那个:apply plugin: 'com.canzhang.plugin'
  • 配置内容就是我们插件的的包名和类名
# 此文件名为插件引用名,下面这行则是对应的插件路径
implementation-class=com.canzhang.plugin.AnalyticsPlugin
image.png

6、然后我们就可以运行构建plugin了


image.png

构建好之后我们就可以在本地看到这样一个文件夹


image.png

这里如果想开放此插件给到其他工程使用,则可以提交repo到githup,然后按照下方配置流程进行配置(步骤7),另外需要额外配置仓库地址

 maven { url "https://raw.githubusercontent.com/gudujiucheng/ASMDemo/master/repo" }

其中:https://raw.githubusercontent.com/为固定路径,gudujiucheng为Github用户名,ASMDemo为项目名,master/repo为仓库相对路径。

7、使用插件

  • 项目gradle配置(配置本地仓库、并引入插件)
buildscript {
    
    repositories {
        google()
        jcenter()
        //本地调试仓库
        maven {
            url uri('repo')
        }
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:3.2.0'

        //引用插件
        classpath 'com.canzhang.android:canzhang_plugin:1.0.0-SNAPSHOT'

        // NOTE: Do not place your application dependencies here; they belong
        // in the individual module build.gradle files
    }
}
  • 主module gradle 配置
apply plugin: 'com.canzhang.plugin'

然后运行编译之后,就可以看到我们插桩的代码了。
插桩前的代码:

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        findViewById(R.id.tv_test).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                Toast.makeText(MainActivity.this, "普通点击事件", Toast.LENGTH_SHORT).show();
            }
        });
    }
}

如下图所示,可以看到具体插桩后的字节码,可以点击查看(注意如果插桩的class是jar包内的,则需要自行反编译jar进行查看(推荐一个简单易用的反编译工具:https://github.com/linchaolong/ApkToolPlus),或者调整插件,使输出一份class到指定文件夹查看)。

插桩后

更多细节待续....

注意事项:

  • 没有生成插件之前,要把依赖去掉,不然跑不起来
    主module屏蔽
apply plugin: 'com.canzhang.plugin'

主工程的gradle屏蔽

classpath 'com.canzhang.android:canzhang_plugin:1.0.0-SNAPSHOT'

屏蔽之后先build项目成功后,在触发生成插件,然后在放开屏蔽的两项,就可以了


生成插架

生成的插件
  • 关于混淆:关于混淆可以不用担心。混淆其实是个ProguardTransform,在自定义的Transform之后执行。

  • 插件插入不存在的代码也是不会报错的,因为是在编译后插入的,直到运行的时候才会报错,所以要注意插入代码的正确性。

  • 出现莫名其妙的错误,如RuntimeException
    这里asm不同版本的api,有时候会做api版本限制,要检查下,自己的api版本是否错误:(发生这些错误的原因,主要是因为我们写死的版本,和项目实际应用的asm版本不相同导致的)

    我们的代码

比如下面这些版本限制触发的异常:(这里只是抛出了异常,并没有很细致的提示,所以需要留意看错误日志)


asm api

asm api

其他细节

  • 上文是用groovy来写的(groovy的编译错误提示不是很好,建议用其他语言写),也可以使用java或者kotlin来写,可以选择自己熟悉的语法,这几种语言最后都会转换成字节码,通过jvm来执行。
  • 如果用于项目,可以考虑参考其他框架进行一些增量编译和多线程并发处理文件等方面的优化,提高编译速度,可参考:https://github.com/Leaking/Hunter

参考文章:

本文主要是用于记录,参考自神策全埋点教程
//www.greatytc.com/p/9039a3e46dbc
//www.greatytc.com/p/c2c1d350d245
//www.greatytc.com/p/16ed4d233fd1

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

推荐阅读更多精彩内容