从开发一个插件看,安卓gradle插件适配AGP8.0

前言

相信很多小伙伴项目还没有升级AGP7.0,可是最新的AGP已经到8.2了,适配AGP8.0也要提上日程了,尤其是一些插件项目,因为8.0删除了transform API,所以需要提前做好适配工作。
如果你是一个插件小白,本篇可以教你从0开始在AGP7.0以上如何开发插件。
如果你是一个插件开发者,相信本篇也可以给你适配AGP8.0带来一些帮助。

从零开始,构建一个兼容AGP8.0的插件

首先我们新建一个空项目,然后在项目中开始添加模块。
由于as没有创建插件模块的选项,所以这里我们选择手动添加。
第一步:在app同级目录创建如下文件


创建插件文件夹

然后在setting.gradle配置文件中引入插件

include ':app'
include ':plugin'

接着我们在插件目录的build.gradle文件中添加一些必要的依赖:

plugins {
    id 'java'
    id 'groovy'
    id 'kotlin'
}


dependencies {

    //gradle sdk
    implementation gradleApi()
    //groovy sdk
    implementation localGroovy()
    implementation 'com.android.tools.build:gradle:7.4.2'
    implementation 'com.android.tools.build:gradle-api:7.4.2'
    implementation 'org.ow2.asm:asm:9.1'
    implementation 'org.ow2.asm:asm-util:9.1'
    implementation 'org.ow2.asm:asm-commons:9.1'
}

大家可能注意到了,这里我们依赖的gradle版本并非8.0版本,而是gradle7.4.2版本,为啥不用8.0.0版本呢,这个稍后再解释,我们继续插件的创建。
接着我们开始添加插件的源文件:


添加源文件

在TestPlugin.properties配置中指定插件入口类,同时该配置文件的名称xxx.properties的xxx即为插件的名称,也就是后期我们应用引入该插件时的名称

implementation-class=com.cs.plugin.TestPlugin

这里还需要注意一点,就是创建META-INF.gradle-plugins的文件夹时,一定要创建两个文件夹,千万不要这样创建


image.png
image.png

接下来开始真正的插件代码逻辑了
TestPlugin中添加如下代码:

class TestPlugin  : Plugin<Project> {
    override fun apply(project: Project) {
        //这里appExtension获取方式与原transform api不同,可自行对比
        val appExtension = project.extensions.getByType(
            AndroidComponentsExtension::class.java
        )
        //这里通过transformClassesWith替换了原registerTransform来注册字节码转换操作
        appExtension.onVariants { variant ->
            //可以通过variant来获取当前编译环境的一些信息,最重要的是可以 variant.name 来区分是debug模式还是release模式编译
            variant.instrumentation.transformClassesWith(TimeCostTransform::class.java, InstrumentationScope.ALL) {
            }
            //InstrumentationScope.ALL 配合 FramesComputationMode.COPY_FRAMES可以指定该字节码转换器在全局生效,包括第三方lib
            variant.instrumentation.setAsmFramesComputationMode(FramesComputationMode.COPY_FRAMES)
        }
    }

}

这里我们注册一个TimeCostTransform的字节码转换功能,用来统计方法执行的时长。TimeCostTransform需要实现AsmClassVisitorFactory这个接口,该接口正是用于替换原Transform的API,新API中只需要关注ASM操作的实现即ClassVisitor,大大简化了插件开发的工作。
TimeCostTransform中添加如下代码

abstract class TimeCostTransform : AsmClassVisitorFactory<InstrumentationParameters.None> {
    override fun createClassVisitor(
        classContext: ClassContext,
        nextClassVisitor: ClassVisitor
    ): ClassVisitor {
        //指定真正的ASM转换器
        return TimeCostClassVisitor(nextClassVisitor)
    }

    //通过classData中的当前类的信息,用来过滤哪些类需要执行字节码转换,这里支持通过类名,包名,注解,接口,父类等属性来组合判断
    override fun isInstrumentable(classData: ClassData): Boolean {
        //指定包名执行
        return classData.className.startsWith("com.cs.supportagp80")
    }
}

接着我们创建一个TimeCostClassVisitor的字节码转换器,用来执行在方法开始时及结束时分别插入代码来统计方法耗时,并且打印出来的逻辑

class TimeCostClassVisitor(nextVisitor: ClassVisitor) : ClassVisitor(
    Opcodes.ASM5, nextVisitor) {

    override fun visitMethod(
        access: Int,
        name: String?,
        descriptor: String?,
        signature: String?,
        exceptions: Array<out String>?
    ): MethodVisitor {
        val methodVisitor = super.visitMethod(access, name, descriptor, signature, exceptions)
        if (name == "<clinit>" || name == "<init>") {
            return methodVisitor
        }
        val newMethodVisitor =
            object : AdviceAdapter(Opcodes.ASM5, methodVisitor, access, name, descriptor) {
                private var startTimeLocal = -1 // 保存 startTime 的局部变量索引

                override fun visitInsn(opcode: Int) {
                    super.visitInsn(opcode)
                }

                @Override
                override fun onMethodEnter() {
                    super.onMethodEnter();
                    // 在onMethodEnter中插入代码 val startTime = System.currentTimeMillis()
                    mv.visitMethodInsn(
                        Opcodes.INVOKESTATIC,
                        "java/lang/System",
                        "currentTimeMillis",
                        "()J",
                        false
                    )
                    startTimeLocal = newLocal(Type.LONG_TYPE) // 创建一个新的局部变量来保存 startTime
                    mv.visitVarInsn(Opcodes.LSTORE, startTimeLocal)
                }

                @Override
                override fun onMethodExit(opcode: Int) {
                    // 在onMethodExit中插入代码 Log.e("tag", "Method: $name, timecost: " + (System.currentTimeMillis() - startTime))
                    mv.visitTypeInsn(
                        Opcodes.NEW,
                        "java/lang/StringBuilder"
                    );
                    mv.visitInsn(Opcodes.DUP);
                    mv.visitLdcInsn("Method: $name, timecost: ");
                    mv.visitMethodInsn(
                        Opcodes.INVOKESPECIAL,
                        "java/lang/StringBuilder",
                        "<init>",
                        "(Ljava/lang/String;)V",
                        false
                    );
                    mv.visitMethodInsn(
                        Opcodes.INVOKESTATIC,
                        "java/lang/System",
                        "currentTimeMillis",
                        "()J",
                        false
                    );
                    mv.visitVarInsn(Opcodes.LLOAD, startTimeLocal);
                    mv.visitInsn(Opcodes.LSUB);
                    mv.visitMethodInsn(
                        Opcodes.INVOKEVIRTUAL,
                        "java/lang/StringBuilder",
                        "append",
                        "(J)Ljava/lang/StringBuilder;",
                        false
                    );
                    mv.visitMethodInsn(
                        Opcodes.INVOKEVIRTUAL,
                        "java/lang/StringBuilder",
                        "toString",
                        "()Ljava/lang/String;",
                        false
                    );
                    mv.visitLdcInsn("plugin")
                    mv.visitInsn(Opcodes.SWAP)
                    mv.visitMethodInsn(
                        Opcodes.INVOKESTATIC,
                        "android/util/Log",
                        "e",
                        "(Ljava/lang/String;Ljava/lang/String;)I",
                        false
                    )
                    mv.visitInsn(POP)
                    super.onMethodExit(opcode);
                }
            }
        return newMethodVisitor
    }
}

由于本篇的重点是插件的逻辑,所以字节码转换部分这里不做过多解释。到这里基本就完成了一个简单的插件的开发了。

插件发布

接下来发布插件,由于maven插件在AGP7.0已经废弃了,所以需要使用maven-publish插件来发布我们的插件代码到仓库,这里我们直接发布到本地仓库,修改后插件build.gradle代码如下:

plugins {
    id 'java'
    id 'groovy'
    id 'kotlin'
    id 'maven-publish'
}

dependencies {
    //gradle sdk
    implementation gradleApi()
    //groovy sdk
    implementation localGroovy()
    implementation 'com.android.tools.build:gradle:7.4.2'
    implementation 'com.android.tools.build:gradle-api:7.4.2'
    implementation 'org.ow2.asm:asm:9.1'
    implementation 'org.ow2.asm:asm-util:9.1'
    implementation 'org.ow2.asm:asm-commons:9.1'
}

publishing {
    repositories { RepositoryHandler handler ->
        handler.maven { MavenArtifactRepository mavenArtifactRepository -> //正式仓库
            url '..\\localmaven'
            allowInsecureProtocol = true
            if (url.toString().startsWith("http")) {
                credentials {
                    username = ''
                    password = ''
                }
            }
        }
    }

    publications { PublicationContainer publication ->
        maven(MavenPublication) {
            version '0.0.1'
            artifactId 'Plugin'
            groupId 'com.cs.testplugin'
            from components.java
        }
    }
}

发布到仓库有两种方式
一种是,在as中添加一个gradle执行任务


image.png

image.png

点击执行即可发布插件到maven仓库


image.png

第二种即是直接使用gradle命令发布。
首先需要在项目的gradle.properties配置文件中配置本地jdk路径(仅命令行操作需要)

org.gradle.java.home=D\:\\Android Studio\\jre

在项目文件夹下执行命令.\gradlew publish即可发布

.\gradlew publish

为什么适配AGP8.0没用8.0.0版本?

以上代码,如果将gradle版本替换为8.0.0也完全没有问题,但是这里有一个坑,那就是如果插件使用了8.0以上版本,那就必须使用jdk17来编译。而在应用中引用插件的时候,也必须使用jdk17才可以编译。这样就造成了如果要使用8.0编译的插件,还得把应用升级到使用jdk17,而对于大多数项目可能才刚刚升级到gradle7.0,因为gradle7.0或AS新版本的关系,才使用了jdk11。所以目前来说jdk17的应用普及率还比较低,这样的要求暂时还不太合适。
因此目前市面上已经兼容AGP8.0的插件几乎都是使用AGP7.x的版本来编译的。

到这里我相信大家对使用AsmClassVisitorFactory(官方称之为Instrumentation API)来替换transform API还有一些疑问,比如使用transform 时,可以在一个插件中注册多个转换任务,现在应该怎么做呢?
AsmClassVisitorFactory接口携带的类型是干嘛的?
接下来一一为大家解答:

同一插件如何注册多个转换任务/顺序执行多个转换任务

这个问题目前官方文档目前没有做任何说明,我目前也没有找到其他相关文章。抱着试一试的想法我问了下chatGPT


image.png

大家都知道chatGPT有时候喜欢一本正经的胡说八道,所以我也只能亲自尝试一下。
我copy了TimeCostTransform和TimeCostClassVisitor,改名为MethodTimeTransform和MethodTimeClassVisitor,接着修改了MethodTimeClassVisitor的打印log以区分,最终注册两个transform:

appExtension.onVariants { variant ->
            //可以通过variant来获取当前编译环境的一些信息,最重要的是可以 variant.name 来区分是debug模式还是release模式编译
            variant.instrumentation.transformClassesWith(TimeCostTransform::class.java, InstrumentationScope.ALL) {
            }
            variant.instrumentation.transformClassesWith(MethodTimeTransform::class.java, InstrumentationScope.ALL) {
            }
            //InstrumentationScope.ALL 配合 FramesComputationMode.COPY_FRAMES可以指定该字节码转换器在全局生效,包括第三方lib
            variant.instrumentation.setAsmFramesComputationMode(FramesComputationMode.COPY_FRAMES)
        }

接着maven发布,在空项目应用跑起来看一下


image.png

好家伙,还真行,替换注册顺序,也没毛病


image.png

image.png

InstrumentationParameters,插件配置参数

实现AsmClassVisitorFactory接口需要携带一个InstrumentationParameters类型,看这个类型的描述是参数的意思。也就是说插件运行可以携带一些配置参数。另外我们可以看到注册转换任务时的方法,第三个参数instrumentationParamsConfig也是用作初始化参数配置的


image.png

接下来我详细和大家介绍一下如何使用,以及和传统的方式有何区别。
首先我们创建配置文件ConfigExtension和ConfigExtensionNew,内容完全相同

open class ConfigExtension {
    public var logTag: String = "cs"
    public var includePackages: Array<String> = arrayOf()
    public var includeMethods: Array<String> = arrayOf()
}

创建PluginHelper,传统方式,使用单例存储配置

object PluginHelper {
    var extension: ConfigExtension? = null
}

创建TimeCostConfig,新api的方式,存储配置

interface TimeCostConfig : InstrumentationParameters {
    @get:Input
    val packageNames: ListProperty<String>
    @get:Input
    val methodNames: ListProperty<String>
    @get:Input
    val logTag: Property<String>
}

TestPlugin中添加配置文件相关逻辑

class TestPlugin  : Plugin<Project> {
    override fun apply(project: Project) {
        //这里appExtension获取方式与原transform api不同,可自行对比
        val appExtension = project.extensions.getByType(
            AndroidComponentsExtension::class.java
        )
        //读取配置文件
        project.extensions.create("TestPlugin", ConfigExtension::class.java)
        project.extensions.create("TestPluginNew", ConfigExtensionNew::class.java)
        //这里通过transformClassesWith替换了原registerTransform来注册字节码转换操作
        appExtension.onVariants { variant ->
            //传统方式,配置获取后直接使用单例存储,使用时读取
            PluginHelper.extension = project.extensions.getByType(
                ConfigExtension::class.java
            )
            val extensionNew = project.extensions.getByType(
                ConfigExtensionNew::class.java
            )
            //可以通过variant来获取当前编译环境的一些信息,最重要的是可以 variant.name 来区分是debug模式还是release模式编译
            variant.instrumentation.transformClassesWith(MethodTimeTransform::class.java, InstrumentationScope.ALL) {
            }
            variant.instrumentation.transformClassesWith(TimeCostTransform::class.java, InstrumentationScope.ALL) {
                //配置通过指定配置的类,携带到TimeCostTransform中
                it.packageNames.set(extensionNew.includePackages.toList())
                it.methodNames.set(extensionNew.includeMethods.toList())
                it.logTag.set(extensionNew.logTag)
            }
            //InstrumentationScope.ALL 配合 FramesComputationMode.COPY_FRAMES可以指定该字节码转换器在全局生效,包括第三方lib
            variant.instrumentation.setAsmFramesComputationMode(FramesComputationMode.COPY_FRAMES)
        }
    }
}

在应用中可以设置两个配置代码块,分别对应TestPlugin和TestPluginNew
接下来修改transform任务,添加通过配置执行不同的asm操作
TimeCostTransform中

abstract class TimeCostTransform() : AsmClassVisitorFactory<TimeCostConfig> {
    override fun createClassVisitor(
        classContext: ClassContext,
        nextClassVisitor: ClassVisitor
    ): ClassVisitor {
        //指定真正的ASM转换器,传入配置
        return TimeCostClassVisitor(nextClassVisitor, parameters.get())
    }

    //通过classData中的当前类的信息,用来过滤哪些类需要执行字节码转换,这里支持通过类名,包名,注解,接口,父类等属性来组合判断
    override fun isInstrumentable(classData: ClassData): Boolean {
        //指定包名执行
        //通过parameters.get()来获取传递的配置
        val packageConfig = parameters.get().packageNames.get()
        if (packageConfig.isNotEmpty()) {
            return packageConfig.any { classData.className.contains(it) }
        }
        return true
    }
}

TimeCostClassVisitor中增加读取配置,过滤配置中的方法名,指定log打印配置读取的tag

class TimeCostClassVisitor(nextVisitor: ClassVisitor,val config: TimeCostConfig) : ClassVisitor(
    Opcodes.ASM5, nextVisitor) {

    override fun visitMethod(
        access: Int,
        name: String?,
        descriptor: String?,
        signature: String?,
        exceptions: Array<out String>?
    ): MethodVisitor {
        val methodVisitor = super.visitMethod(access, name, descriptor, signature, exceptions)
        if (name == "<clinit>" || name == "<init>") {
            return methodVisitor
        }
        //如果不在配置的方法名列表中,不执行
        val methodNameConfig = config.methodNames.get()
        if (methodNameConfig.isNotEmpty()) {
            if (methodNameConfig.none { name == it }) {
                return methodVisitor
            }
        }
        //从配置中读取tag
        val tag = config.logTag.get()
        val newMethodVisitor =
            object : AdviceAdapter(Opcodes.ASM5, methodVisitor, access, name, descriptor) {
                private var startTimeLocal = -1 // 保存 startTime 的局部变量索引

                override fun visitInsn(opcode: Int) {
                    super.visitInsn(opcode)
                }

                @Override
                override fun onMethodEnter() {
                    super.onMethodEnter();
                    // 在onMethodEnter中插入代码 val startTime = System.currentTimeMillis()
                    mv.visitMethodInsn(
                        Opcodes.INVOKESTATIC,
                        "java/lang/System",
                        "currentTimeMillis",
                        "()J",
                        false
                    )
                    startTimeLocal = newLocal(Type.LONG_TYPE) // 创建一个新的局部变量来保存 startTime
                    mv.visitVarInsn(Opcodes.LSTORE, startTimeLocal)
                }

                @Override
                override fun onMethodExit(opcode: Int) {
                    // 在onMethodExit中插入代码 Log.e("tag", "Method: $name, timecost: " + (System.currentTimeMillis() - startTime))
                    mv.visitTypeInsn(
                        Opcodes.NEW,
                        "java/lang/StringBuilder"
                    );
                    mv.visitInsn(Opcodes.DUP);
                    mv.visitLdcInsn("Method: $name, timecost: ");
                    mv.visitMethodInsn(
                        Opcodes.INVOKESPECIAL,
                        "java/lang/StringBuilder",
                        "<init>",
                        "(Ljava/lang/String;)V",
                        false
                    );
                    mv.visitMethodInsn(
                        Opcodes.INVOKESTATIC,
                        "java/lang/System",
                        "currentTimeMillis",
                        "()J",
                        false
                    );
                    mv.visitVarInsn(Opcodes.LLOAD, startTimeLocal);
                    mv.visitInsn(Opcodes.LSUB);
                    mv.visitMethodInsn(
                        Opcodes.INVOKEVIRTUAL,
                        "java/lang/StringBuilder",
                        "append",
                        "(J)Ljava/lang/StringBuilder;",
                        false
                    );
                    mv.visitMethodInsn(
                        Opcodes.INVOKEVIRTUAL,
                        "java/lang/StringBuilder",
                        "toString",
                        "()Ljava/lang/String;",
                        false
                    );
                    //从配置中读取tag
                    mv.visitLdcInsn(tag)
                    mv.visitInsn(Opcodes.SWAP)
                    mv.visitMethodInsn(
                        Opcodes.INVOKESTATIC,
                        "android/util/Log",
                        "e",
                        "(Ljava/lang/String;Ljava/lang/String;)I",
                        false
                    )
                    mv.visitInsn(POP)
                    super.onMethodExit(opcode);
                }
            }
        return newMethodVisitor
    }
}

MethodTimeTransform中和MethodTimeClassVisitor中大同小异,只是将配置读取变为了从PluginConfingHelper中读取,这里就不贴代码了。
接下来我们发布仓库,然后在应用中添加配置文件:

TestPlugin {
    includePackages = ['com.cs.supportagp80']
    includeMethods = ["test","onCreate"]
    logTag = 'Plugin'
}
TestPluginNew {
    includePackages = ['com.cs.supportagp80']
    includeMethods = ["test","onCreate"]
    logTag = 'Plugin'
}

在MainActivity中添加一个测试方法

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        test()
    }

    private fun test(){
        print("test")
    }
}

运行代码,查看日志


image.png

很好,两个配置块都生效了,看到这里肯定有人说,这两种配置不是都能生效吗?为什么要有新的配置方式。别急,马上我们就可以看到他们之间的区别。
接下来我们修改TestPluginNew 配置块中的配置为:

TestPluginNew {
    includePackages = ['com.cs.supportagp80']
    includeMethods = ["test"]
    logTag = 'Plugin-new'
}

然后重新执行代码,日志打印如下:


image.png

我们可以看到,配置生效了,tag变成了Plugin-new,且只打印了test方法的执行时间。
然后我们再修改TestPlugin配置块中的配置为:

TestPlugin {
    includePackages = ['com.cs.supportagp80']
    includeMethods = ["test"]
    logTag = 'Plugin-old'
}

然后执行代码,日志打印如下:


image.png

我们可以看到,TestPlugin 的配置块修改并没有生效,依旧是上一次的配置。
到这里我相信大家应该已经看明白了两种配置文件的配置方式到底有何区别,就是新方式设置配置,在修改后可以及时生效,老方式却不能。
最终经过我多次试验,总结得到的一个不太严谨的结论是,老方式的配置,只有在对应的类文件发生变化,需要重新编译时才会生效。

更新

我看到有小伙伴评论说多个AsmClassVisitorFactory 是并行执行的,我们可以来简单验证一下,看下是否真的如小伙伴所说是并行执行的,还是顺序执行的

验证方式很简单,我们直接在执行asm操作的ClassVisitor中获取当前线程,然后在log中打印出来。其实可以直接在插件中log打印,在插件执行中就可在控制台看到,这里为了方便最后查看我们直接放到了应用log中打印。

        ......
        //从配置中读取tag
        val tag = config.logTag.get()
        val pluginExecuteThreadName = Thread.currentThread().toString()
        ......
        //在原日志的地方增加打印插件执行的线程
        mv.visitLdcInsn("PluginThread: $pluginExecuteThreadName Method: $name, timecost: ");

这里只贴了修改的两行代码,两个ClassVisitor均相同,就不贴相信代码了,感兴趣可以查看demo源码
下面是最终执行结果:


098ce2413246e4f0ffefbf025c53983.png

可以很清晰的看到,两个ClassVisitor是在同一线程中执行的,既然是在同一线程中执行,而且从结果来看代码插入顺序又与注册时的先后顺序相同,那么我们应该是可以得出AsmClassVisitorFactory就是按照注册顺序先后执行这样的结论。

以上方式虽然可以简单验证,但是总觉得差了那么一点意思。不够直观,不够清晰。那么有没有办法可以直观的看到或者分析多个ClassVisitor到底是怎么执行的呢?
办法肯定是有的,最直接的方式就是看源码,但是因为本来对这块就不熟悉看源码简直无从下手怎么办。
我们可以通过查看ClassVisitor执行时的调用栈来作为入口分析。
接下来我们直接在ClassVisitor的visitMethod方法中打印调用栈

......
val pluginExecuteThreadName = Thread.currentThread().toString()
Thread.dumpStack()
......

我们在两个ClassVisitor中均添加如上代码打印调用栈,接下来打包插件,依赖插件,编译即可看到调用栈输出了。

需要注意的是,这里是在插件中打印的,所以需要在编译控制台查看日志输出


8122d76a5d05fa2c6853cc9f595c774.png

通过调用栈可以看到:

1、MethodTimeClassVisitor中打印了自己的调研栈

2、TimeCostClassVisitor中打印的调用栈中包含了MethodTimeClassVisitor的调用栈

这说明什么,说明代码是先执行到TimeCostClassVisitor.visitMethod方法中,然后通过super.visitMethod方法执行到了MethodTimeClassVisitor.visitMethod方法中,因为代码中是先执行supper方法,再执行我们添加的代码逻辑的。
所以最终打印即执行我们添加的代码顺序则为:
MethodTimeClassVisitor.visitMethod -> TimeCostClassVisitor.visitMethod


4c144324cdbdfae4d741af485cd6a1f.png

如果继续注册添加多个AsmClassVisitorFactory,那么新增的ClassVisitor会继续在外层叠加,最终执行是按照添加的顺序执行。

嗯,这样分析下来,再说多个AsmClassVisitorFactory是按照注册顺序执行的,我相信大家应该没有什么疑义了吧。

总结

以上就是如何开发兼容AGP8.0插件的全部内容了。
本文详细介绍了如何使用gradle7.4.2版本开发一个兼容gradle8.0的插件,并且分析了如何使用transformClassesWith注册多个转换任务,顺序执行。最后分析了新API下如何配置插件的配置参数,与原有方式配置参数有何区别。

实例代码

本篇全部代码可见:supportAGP8.0

参考链接

Android Gradle 插件 API 更新
Transform 被废弃,ASM 如何适配?
现在准备好告别Transform了吗? | 拥抱AGP7.0
神策数据官方 Android 埋点插件

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

推荐阅读更多精彩内容