应用于Android无埋点的Gradle插件解析

自定义插件涉及到几个知识点,比如Gradle构建工具、Groovy语法、Gradle插件开发流程等等。这些知识我就默认大家都知道了。想学习或温习的可以参考:

这个插件用来做什么?

试想一下我们代码埋点的过程:首先定位到事件响应函数,例如Button的onClick函数,然后在该事件响应函数中调用SDK数据搜集接口。

Button btnLogin = (Button) findViewById(R.id.btn_login);

btnLogin.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        ...
        NPTracker.getInstance().trackEvent(Constant.EVENT_LOGIN_METHOD);
    }
});

下面,我们介绍使用gradle插件自动在目标响应函数中插入SDK数据搜集代码,达到自动埋点的目的。本文对此的实战即通过字节码插桩,在class文件编译成dex之前(同时也是proguard操作之前),遍历所有要编译的class文件并对其中符合条件的方法进行修改,注入我们要调用的SDK数据搜集代码,从而实现自动埋点的目的。

插件是如何与应用关联起来的?

为了方便开发,新建一个Android项目,然后开发只针对当前项目的Gradle插件。针对当前项目开发Gradle插件相对较简单,但必须注意的是:

新建的Module名称必须为BuildSrc

目录结构示意如下:

插件工程目录

然后自定义Gradle插件类:

package com.codeless.plugin

import com.android.build.gradle.BaseExtension
import com.codeless.plugin.utils.DataHelper
import com.codeless.plugin.utils.Log
import org.gradle.api.Plugin
import org.gradle.api.Project

class InjectPluginImpl implements Plugin<Project> {
   @Override
   void apply(Project project) {
       project.extensions.create('codelessConfig', InjectPluginParams)
       registerTransform(project)
       ...
   }
   
   ...
}

然后在app module的build.gradle中引入自定义插件:

apply plugin: com.codeless.plugin.InjectPluginImpl

codelessConfig {
    //this will determine the name of this plugin transform, no practical use.
    pluginName = 'myPluginTest'
    //turn this on to make it print help content, default value is true
    showHelp = true
    //this flag will decide whether the log of the modifying process be printed or not, default value is false
    keepQuiet = false
    //this is a kit feature of the plugin, set it true to see the time consume of this build
    watchTimeConsume = false

    //this is the most important part, 3rd party JAR packages that want our plugin to inject;
    //our plugin will inject package defined in 'AndroidManifest.xml' and 'butterknife.internal.butterknife.internal.DebouncingOnClickListener' by default.
    //structure is like ['butterknife.internal','com.a.c'], type is HashSet<String>.
    //You can also specify the name of the class;
    //example: ['com.xxx.xxx.BaseFragment']
    targetPackages = ['okhttp3']
}

可能有人对apply plugin: com.codeless.plugin.InjectPluginImpl不是很理解,没关系,那下面这个呢:

apply plugin: 'com.android.library' <==如果是编译 Library,则加载此插件
apply plugin: 'com.android.application' <==如果是编译 Android APP,则加载此插件

Project 的 API 请戳 Project。apply 其实是 Project 实现的 PluginAware 接口定义的:

apply函数

apply函数接收多种参数,上述用法调用的其实是void apply(Map<String,?> options)

apply plugin

plugin 作为Map Key,表示加载指定id的插件。

回到apply plugin: com.codeless.plugin.InjectPluginImpl,它表示加载id为com.codeless.plugin.InjectPluginImpl的插件,也就是我们的自定义插件。

apply plugin: com.codeless.plugin.InjectPluginImpl这一行会导致直接执行com.codeless.plugin.InjectPluginImpl插件的void apply(Project project)方法,传入app module的build.gradle对应的 Project 对象。

知识拓展:

每一个build.gradle文件都会转换成一个 Project 对象。通过 Project 对象可以访问到build.gradle中的各种配置。

拿到了app module的build.gradle对应的 Project 对象,自然就能通过 project.codelessConfig访问传入的一些配置项喽。例如:

project.afterEvaluate {
    Log.setQuiet(project.codelessConfig.keepQuiet);
    Log.setShowHelp(project.codelessConfig.showHelp);
    Log.logHelp();
    if (project.codelessConfig.watchTimeConsume) {
        Log.info "watchTimeConsume enabled"
        project.gradle.addListener(new TimeListener())
    } else {
        Log.info "watchTimeConsume disabled"
    }
}

讲到这里,相信大家已经清楚,我们的自定义插件是如何应用到app module的了吧。

Transform代码注入原理

1.5.0-beta1开始,android的gradle插件引入了com.android.build.api.transform.Transformtransform-api),可以用于在android 打包、class转换成dex过程中,加入开发者自定义的处理逻辑。

初识Transform

Transform的工作流程

Transform的工作流程

Transform每次都是将一个输入进行处理,然后将处理结果输出,而输出的结果将会作为另一个Transform的输入。

理解Transform需要先了解一些概念:

概念 描述
TransformInput 输入文件:DirectoryInput集合与JarInput集合
DirectoryInput 以源码方式参与项目编译的所有目录结构及其目录下的源码文件
JarInput 以jar包方式参与项目编译的所有本地jar包或远程jar包
TransformOutputProvider Transform的输出
Scope 作用域:PROJECT、SUB_PROJECTS、EXTERNAL_LIBRARIES等
ContentType 文件的类型:CLASSES、RESOURCES、DEX、NATIVE_LIBS等

知识拓展:

通过 Scope 和 ContentType 可以组成一个资源流。例如,PROJECT 和 CLASSES,表示了主项目中java 编译成的 class 组成的一个资源流。再如,SUB_PROJECTS 和 CLASSES ,表示的是本地子项目中的 java 编译成的 class 组成的一个资源流。Transform 用来处理和转换这些流。

Transform是一个抽象类,我们需要自定义一个Transform,并实现必要的几个方法:

public class InjectTransform extends Transform {
    @Override
    public String getName() {
        return "xxxxxx";
    }

    @Override
    public Set<QualifiedContent.ContentType> getInputTypes() {
        // 配置 Transform 的输入类型为 Class
        return TransformManager.CONTENT_CLASS;
    }

    @Override
    public Set<QualifiedContent.Scope> getScopes() {
        // 配置 Transform 的作用域为全工程
        return TransformManager.SCOPE_FULL_PROJECT;
    }

    @Override
    public boolean isIncremental() {
        return false;
    }

    @Override
    public void transform(
            @NonNull Context context,
            @NonNull Collection<TransformInput> inputs,
            @NonNull Collection<TransformInput> referencedInputs,
            @Nullable TransformOutputProvider outputProvider,
            boolean isIncremental) throws IOException, TransformException, InterruptedException {
        // ...
    }
}

下面一一解释这几个方法:

  • getName

    指明本Transform的名字,随意

  • getInputTypes

    指明Transform的输入类型,例如,返回 TransformManager.CONTENT_CLASS 表示配置 Transform 的输入类型为 Class。

  • getScopes

    指明Transform的作用域,例如,返回 TransformManager.SCOPE_FULL_PROJECT 表示配置 Transform 的作用域为全工程。

  • isIncremental

    指明是否是增量构建

  • transform

    用于处理具体的输入输出,核心操作都在这里。上例中,配置 Transform 的输入类型为 Class, 作用域为全工程,因此在transform方法中,inputs 会传入工程内所有的 class 文件。

定义好 Transform 后,接下来要在自定义的Plugin中注册该Transform,从而添加到android编译流程中。

class InjectPluginImpl implements Plugin<Project> {
    @Override
    void apply(Project project) {
        ...
        registerTransform(project)
    }

    def static registerTransform(Project project) {
        BaseExtension android = project.extensions.getByType(BaseExtension)
        InjectTransform transform = new InjectTransform(project)
        android.registerTransform(transform)
    }
}

Transform的工作原理

Gradle 包中有一个 TransformManager 的类,用来管理所有的 Transform,其中,TransformManager 包含addTransform方法:

public <T extends Transform> AndroidTask<TransformTask> addTransform(
        @NonNull TaskFactory taskFactory,
        @NonNull TransformVariantScope scope,
        @NonNull T transform,
        @Nullable TransformTask.ConfigActionCallback<T> callback) {
           ...
           transforms.add(transform);
           
           // create the task...
           AndroidTask<TransformTask> task = taskRegistry.create(
                    taskFactory,
                    new TransformTask.ConfigAction<>(
                            scope.getFullVariantName(),
                            taskName,
                            transform,
                            inputStreams,
                            referencedStreams,
                            outputStream,
                            callback));
           ...
           return task;
       }
   }
}

显然,addTransform方法在执行的过程中,会将 T (T extends Transform) 包装成一个 TransformTask 对象,并进一步包装成AndroidTask对象。所以可以理解为一个 Transform 就是一个 Task。该 Task 执行时,gradle引擎会去调用含有@TaskAction注解的方法,TransformTask类拥有Transfrom类型字段,其transform方法被标记为@TaskAction,且TransformTask的transform方法最终调用了Transfrom的transform方法。

/**
 * A task running a transform.
 */
@ParallelizableTask
public class TransformTask extends StreamBasedTask implements Context {

    private Transform transform;
    Collection<SecondaryFile> secondaryFiles = null;

    public Transform getTransform() {
        return transform;
    }

    ...

    @TaskAction
    void transform(final IncrementalTaskInputs incrementalTaskInputs)
            throws IOException, TransformException, InterruptedException {
        ...
        ThreadRecorder.get().record(ExecutionType.TASK_TRANSFORM, executionInfo,
                getProject().getPath(), getVariantName(), new Recorder.Block<Void>() {
            @Override
            public Void call() throws Exception {

                transform.transform(new TransformInvocationBuilder(TransformTask.this)
                        .addInputs(consumedInputs.getValue())
                        .addReferencedInputs(referencedInputs.getValue())
                        .addSecondaryInputs(changedSecondaryInputs.getValue())
                        .addOutputProvider(outputStream != null
                        ? outputStream.asOutput()
                        : null)
                        .setIncrementalMode(isIncremental.getValue())
                        .build());
                return null;
            }
        });
    }
}

Gradle 的包中有一个 TaskManager 类,管理所有的 Task 执行。 其中有一个createPostCompilationTasks方法:

    public void createPostCompilationTasks(
            @NonNull TaskFactory tasks,
            @NonNull final VariantScope variantScope) {
        ...
        // ----- External Transforms -----
        // 添加自定义的 Transform
        List<Transform> customTransforms = extension.getTransforms();
        List<List<Object>> customTransformsDependencies = extension.getTransformsDependencies();

        for (int i = 0, count = customTransforms.size() ; i < count ; i++) {
            Transform transform = customTransforms.get(i);
            AndroidTask<TransformTask> task = transformManager
                    .addTransform(tasks, variantScope, transform);
            ...
        }
        ...
        // ----- Minify next -----
        // minifyEnabled 为 true 表示开启混淆
        // 添加 Proguard Transform
        if (isMinifyEnabled) {
            boolean outputToJarFile = isMultiDexEnabled && isLegacyMultiDexMode;
            createMinifyTransform(tasks, variantScope, outputToJarFile);
        }
        ...
        
        // non Library test are running as native multi-dex
        if (isMultiDexEnabled && isLegacyMultiDexMode) {
            ...
            // 添加 JarMergeTransform
            // create a transform to jar the inputs into a single jar.
            if (!isMinifyEnabled) {
                // merge the classes only, no need to package the resources since they are
                // not used during the computation.
                JarMergingTransform jarMergingTransform = new JarMergingTransform(
                        TransformManager.SCOPE_FULL_PROJECT);
                variantScope.addColdSwapBuildTask(
                        transformManager.addTransform(tasks, variantScope, jarMergingTransform));
            }

            // 添加 MultiDex Transform
            // create the transform that's going to take the code and the proguard keep list
            // from above and compute the main class list.
            MultiDexTransform multiDexTransform = new MultiDexTransform(
                    variantScope,
                    extension.getDexOptions(),
                    null);
            multiDexClassListTask = transformManager.addTransform(
                    tasks, variantScope, multiDexTransform);
            multiDexClassListTask.optionalDependsOn(tasks, manifestKeepListTask);
            variantScope.addColdSwapBuildTask(multiDexClassListTask);
        }
        ...
        // 添加 Dex Transform
        // create dex transform
        DefaultDexOptions dexOptions = DefaultDexOptions.copyOf(extension.getDexOptions());
        ...
    }

该方法在 javaCompile 之后调用, 会遍历所有的 Transform,然后一一添加进 TransformManager。 添加完自定义的 Transform 之后,再添加 Proguard, JarMergeTransform, MultiDex, Dex 等 Transform。所以,现在应该清楚为什么Transform可以在编译之后、class转换成dex之前,加入开发者自定义的处理逻辑了吧。

原理篇就讲这么多了,欲知更多,可以参考:

注入代码的时机

我们可以先看一个transform的例子,它将不做任何处理,只是将输入原样输出:

@Override
public Set<QualifiedContent.ContentType> getInputTypes() {
    // 配置 Transform 的输入类型为 Class
    return TransformManager.CONTENT_CLASS;
}

@Override
public Set<QualifiedContent.Scope> getScopes() {
    // 配置 Transform 的作用域为全工程
    return TransformManager.SCOPE_FULL_PROJECT;
}
    
@Override
void transform(Context context, Collection<TransformInput> inputs,
               Collection<TransformInput> referencedInputs,
               TransformOutputProvider outputProvider, boolean isIncremental)
        throws IOException, TransformException, InterruptedException {
    // Transform的inputs有两种类型,一种是目录,一种是jar包,要分开遍历
    inputs.each { TransformInput input ->
        //对类型为“文件夹”的input进行遍历
        input.directoryInputs.each { DirectoryInput directoryInput ->
            //文件夹里面包含的是我们手写的类以及R.class、BuildConfig.class以及R$XXX.class等

            // 获取output目录
            def dest = outputProvider.getContentLocation(directoryInput.name,
                    directoryInput.contentTypes, directoryInput.scopes,
                    Format.DIRECTORY)

            // 将input的目录复制到output指定目录
            FileUtils.copyDirectory(directoryInput.file, dest)
        }
        //对类型为jar文件的input进行遍历
        input.jarInputs.each { JarInput jarInput ->

            //jar文件一般是第三方依赖库jar文件

            // 重命名输出文件(同目录copyFile会冲突)
            def jarName = jarInput.name
            def md5Name = DigestUtils.md5Hex(jarInput.file.getAbsolutePath())
            if (jarName.endsWith(".jar")) {
                jarName = jarName.substring(0, jarName.length() - 4)
            }
            //生成输出路径
            def dest = outputProvider.getContentLocation(jarName + md5Name,
                    jarInput.contentTypes, jarInput.scopes, Format.JAR)
            //将输入内容复制到输出
            FileUtils.copyFile(jarInput.file, dest)
        }
    }
}

在这个例子中,由于配置了 Transform 的输入类型为 Class, 作用域为全工程,因此在transform方法中,inputs 会传入工程内所有的 class 文件。inputs 为Collection<TransformInput> 集合对象,集合元素 TransformInput 定义如下:

public interface TransformInput {

    /**
     * Returns a collection of {@link JarInput}.
     */
    @NonNull
    Collection<JarInput> getJarInputs();

    /**
     * Returns a collection of {@link DirectoryInput}.
     */
    @NonNull
    Collection<DirectoryInput> getDirectoryInputs();
}

看接口方法可知,inputs 包含了 jar 包和目录。也就是说,transform方法可以遍历到源码目录中java类对应的class文件,也可以遍历到第三方jar包内的class文件。辛辛苦苦摸索到这里,想修改的class文件就在眼前了,此时不注入代码更待何时呢?如下图所示,我们可以分别遍历源码目录&jar包,对满足修改条件的class文件或jar包进行修改,将修改后的文件输出:

遍历目录
遍历jar

ASM实现无埋点

通过前面的分析,我们已经知道在哪里修改class文件了,然后发现又走不下去了,class文件怎么修改呢?难道我们要自己手动编写字节码指令修改二进制文件?好在有ASM这个库,至于ASM语法等知识并非本文的重点,所以,本文只讨论无埋点插件中涉及的部分。需要学习或温习的请猛戳系列博文:

ASM关键知识点

ASM被称为是类的扫描器,它可以扫描到组成一个类的各个结构:

  • 描述类访问控制权限,类名,父类,接口和注解
  • 每个被声明的成员变量,同样包括访问控制权限、名称、类型、注解
  • 方法及构造函数,包括访问控制权限,名称、名称、返回值类型、参数类型、注解等;同时还包含方法体

ASM对class的生成和转换是基于ClassVisitor抽象类的,该类的每个方法都对应class的一个结构,它的完整接口如下:

ClassVisitor

下面重点介绍我们需要用到的几个方法:

  • void visit(int version, int access, String name,
    String signature, String superName, String[] interfaces)

    该方法是当扫描类时第一个访问的方法。各参数代表的含义是:类版本、修饰符、类名、泛型信息、继承的父类、实现的接口。我们只需关心继承的父类和实现的接口,当执行到 visit 方法时,可以通过全局变量保存继承的父类和实现的接口信息。代码示例如下:

    static class MethodFilterClassVisitor extends ClassVisitor {
            private String superName
            private String[] interfaces
            private ClassVisitor classVisitor
    
            public MethodFilterClassVisitor(
                    final ClassVisitor cv) {
                super(Opcodes.ASM5, cv);
                this.classVisitor = cv
            }
    
            @Override
            public void visit(int version, int access, String name,
                              String signature, String superName, String[] interfaces) {
                Log.logEach('* visit *', Log.accCode2String(access), name, signature, superName, interfaces);
                this.superName = superName
                // 记录该类实现了哪些接口
                this.interfaces = interfaces
                super.visit(version, access, name, signature, superName, interfaces);
            }
    }
    
  • MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions)

    当扫描器扫描到类的方法时调用该方法。各参数代表的含义是:修饰符、方法名、方法签名、泛型信息、抛出的异常。其中,方法签名的格式如下:(参数列表)返回值类型;例如void onClick(View v)的方法签名为(Landroid/view/View;)V

  • visitEnd()

    当扫描器完成类扫描时才会调用该方法。

当ASM的ClassReader读取到Method时就转入MethodVisitor接口处理。方法的定义,以及方法中指令的定义都会通过MethodVisitor接口通知给程序。MethodVisitor的具体实现由ClassVisitor的visitMethod返回值指定。下面是MethodVisitor接口的所有方法定义:

MethodVisitor

由于我们的无埋点目前只支持在方法体头部或尾部插入埋点代码,因此我们只需关心下面两个方法:

  • visitCode()

    表示ASM开始扫描这个方法。

  • visitEnd()

    表示方法输出完毕。

ASM-Bytecode工具

由于 JVM 对字节码十分敏感,修改过程中稍微有一丝错误都会导致虚拟机错误,而想要排查错误却是一件比较困难的事情。因此不建议大家手动编写ASM 代码,而是借助 ASM-Bytecode 工具。

ASM-Bytecode 是一个Eclipse插件,插件的地址请戳ASM-Bytecode

也可通过Eclipse Marketplace安装(推荐)

Eclipse Marketplace安装插件

插件效果如下:

ASM-Bytecode

ASM无埋点实例

比如我们希望向View的onClick方法里插入自定义的代码:

com.codeless.tracker.PluginAgent.onClick(v);

假设插入前是这样子的:

public class MainActivity extends AppCompatActivity implements
        View.OnClickListener{
    @Override
    public void onClick(View v) {
    
    }
}

插入后的期望代码:

package com.codeless.demo;

public class MainActivity extends AppCompatActivity implements
        View.OnClickListener{
    @Override
    public void onClick(View v) {
        PluginAgent.onClick(var1);
    }
}

最便捷的方法是,使用 ASM-Bytecode 工具将插入后的期望代码翻译成 ASM 代码:

mv.visitVarInsn(ALOAD, 1);
mv.visitMethodInsn(INVOKESTATIC, "com/codeless/tracker/PluginAgent", "onClick", "(Landroid/view/View;)V", false);
mv.visitInsn(RETURN);
mv.visitMaxs(1, 2);
mv.visitEnd();

从而得到

com.codeless.tracker.PluginAgent.onClick(v);

对应的 ASM 代码语句如下

mv.visitMethodInsn(INVOKESTATIC, "com/codeless/tracker/PluginAgent", "onClick", "(Landroid/view/View;)V", false);

然后,使用 ClassVisitor 扫描源码class文件(当然也包含MainActivity.class啦),将该语句插到void onClick(View v)方法体头部。具体操作请留意代码注释:

public class MethodFilterClassVisitor extends ClassVisitor {
    private String superName
    private String[] interfaces
    private ClassVisitor classVisitor

    public MethodFilterClassVisitor(
            final ClassVisitor cv) {
        super(Opcodes.ASM5, cv);
        this.classVisitor = cv
    }

    @Override
    public void visit(int version, int access, String name,
                      String signature, String superName, String[] interfaces) {
        Log.logEach('* visit *', Log.accCode2String(access), name, signature, superName, interfaces);
        this.superName = superName
        // 记录该类实现了哪些接口
        this.interfaces = interfaces
        super.visit(version, access, name, signature, superName, interfaces);
    }

    @Override
    public MethodVisitor visitMethod(int access, String name,
                                     String desc, String signature, String[] exceptions) {
        MethodVisitor myMv = null;
        if (interfaces != null && interfaces.length > 0) {
            // 根据方法名+方法描述判断是否需要修改方法体;
            // 例如,当前遍历到View的onClick方法时,name是onClick,desc是(Landroid/view/View;)V;
            // 则满足修改条件onClick(Landroid/view/View;)V
            // 当第一个条件满足后,还需进一步判断当前类是否实现了View$OnClickListener接口
            if ('onClick(Landroid/view/View;)V' == (name + desc) && interfaces.contains('android/view/View$OnClickListener')) {
                try {
                    MethodVisitor methodVisitor = cv.visitMethod(access, name, desc, signature, exceptions);
                    myMv = new MethodLogVisitor(methodVisitor) {
                        @Override
                        void visitCode() {
                            super.visitCode();
                            // 在方法体开头插入自定义埋点代码
                            methodVisitor.visitMethodInsn(INVOKESTATIC, "com/codeless/tracker/PluginAgent", "onClick", "(Landroid/view/View;)V", false);
                        }
                    }
                } catch (Exception e) {
                    e.printStackTrace();
                    myMv = null
                }
            }

        }

        if (myMv != null) {
            return myMv;
        } else {
            return cv.visitMethod(access, name, desc, signature, exceptions);
        }
    }
}

总结一下上面的例子,就是当一个ActivityFragment实现了View$OnClickListener接口,使用插件遍历到该ActivityFragment字节码中的onClick(View v)时,向该方法中插入com.codeless.tracker.PluginAgent.onClick(v)com.codeless.tracker.PluginAgent中的onClick(View v)方法即是您想要注入到点击事件响应onClick中的代码。

上面只是简化版的例子,完整项目代码要比这个复杂和丰富一些,有兴趣欢迎forkLazierTracker查看完整代码。

分析到这里,不知道大家清楚了没,如果还有疑问或者有好的建议,可以在LazierTracker下面提issue,就酱。

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

推荐阅读更多精彩内容