Tinker学习计划(1)-Tinker的集成

Tinker使用

前言

写在前面的话,在上家公司一直在主导组件框架的开发,所以对Android领域组件化,热更新的发展都有所关注。当时做组件化时,也调研了不少资料,那时候也正是腾讯在宣传Tinker的时机,并宣称准备全部开源,其实是一直有所期待的,原因也是因为微信,如果真如腾讯所说,微信的热更新用的是Tinker,按照微信的体量和要求,Tinker应该是神一般的存在,就如阿里的Atlas一样(虽然说现在Atlas也已开源,但是按照阿里的尿性,估计内部也已经早已改朝换代了),总之都是神一般的框架,值得我们花大把的时间去研究和学习。

玩Dota的人应该清楚,Tinker的来源于英雄Tinker,其大招就是刷新技能和道具,我想这也正是腾讯将其作为热更新框架名字的用意吧,无线刷新App,玩的就是飘逸。

历史

在了解Tinker之前,我们先回顾一下热更新的前世今生,个人觉得先从宏观上去了解这些事还是很有必要的,最起码装逼很有必要。热更新主要分为两个流派:Java和Native。

  • Native代表有阿里的Dexposed,AndFix,据说腾讯内部也有一个KKFIx,不太了解。
  • Java主要代表有Qzone的超级补丁、大众点评的nuwa、百度金融的rocooFix, 饿了么的amigo以及美团的robust。

我们先说Java,万变不离其宗,都离不开Dex替换的原则,不管是拆分dex还是全量dex,说白了就是让classloader先加载新的那段dex代码从而达到改头换面的目的。至于Native,我只知道基本原理用的都是Native Hook的方式。可以参考这篇文章
微信Android热补丁实践演进之路。至于各大厂商的热更新产品,可以看下图:

Tinker Qzone AndFix Robust
类替换 yes yes <font color="Red">no</font> <font color="Red">no</font>
So替换 yes <font color="Red">no</font> <font color="Red">no</font> <font color="Red">no</font>
资源替换 yes yes <font color="Red">no</font> <font color="Red">no</font>
全平台支持 yes yes yes yes
及时生效 <font color="Red">no</font> <font color="Red">no</font> yes yes
性能损耗 较小 较大 较小 较小
补丁包大小 较小 较大 中等 中等
开发透明 yes yes <font color="Red">no</font> <font color="Red">no</font>
复杂度 较低 较低 复杂 复杂
gradle支持 yes <font color="Red">no</font> <font color="Red">no</font> <font color="Red">no</font>
Rom体积 较大 较小 较小 较小
成功率 较高 较高 一般 最高
综上来说:
  • AndFix是Native解决方案,在混合编译出现后遇到最大的问题就是兼容性和稳定性问题,而且无法实现类更换,本身是Native方案,这对开发者来说上手难度也更大
  • Qzone由于插桩的缘故,牺牲了很大的虚拟机性能,而且为了解决后面由于Arrt内存地址错乱只能采用全量替换Dex,也导致补丁包急速增大。
  • AndFix和Robust有一个很大的优点就是能够及时生效,但是Robust的缺点是无法做到类替换,而且无法新增变量和类,导致局限性很小,只能作为bug修复,无法做到功能发布。

说了这么多感觉貌似只有Tinker是最适合的,最牛逼的,当然不是,我们讲没有万能的方案只有适合的方案,试想如果产品要求我们的热更新只需要作为bug修复的手段,功能发布用组件化方案,而且必须要及时生效,那这时候Tinker就无法满足要求了。所以任何一个方案都不是万能的,我相信微信在搞Tinker的时候也是站在巨人的肩膀上。不过既然我们是要研究Tinker,还是先讲讲Tinker的优点,或者说官方发布的优点,至于具体原理是什么,后面的文章会一一说明。

  • 稳定,这点毋庸置疑,当然我不是说其他厂商的开源框架不够稳定,微信的大体量和高日活决定这款产品要有足够的稳定性,这点也保证了Tinker要有足够的稳定性与兼容性
  • 开发社区活跃,Tinker是在15年6月开始,到现在才2年时间,至少现在GitHub上还算活跃,也希望腾讯能够一直保持下去。
  • patch包小,这个也算Tinker的一个亮点吧。自己实现了Diff算法,保证Patch包足够小。

Tinker的已知问题:

  • 不支持修改AndroidManifest.xml。这也就决定了Tinker不支持新增四大组件
  • Android N上,补丁对应用启动时间有影响(暂时不知,后面看到了会说明)
  • 在某些三星 android-21手机上 还有兼容性问题
  • 资源替换中,不支持修改remoteView,transition动画,通知栏图标和应用图标

希望Tinker的开源也会让热更新领域发展的越来越好。

Tinker的集成

在还没有阅读Tinker源码之前,我们先来集成一下Tinker,不管怎样,先用起来再说。

Tinker Github地址:Tinker

Android Studio clone tinker源码,如下所示


不得不说,腾讯这次真是业界良性,开源了所有东西,包括插件源码。

话不多说,首先建立一个Demo工程。

接入指南

  1. 当然是添加依赖了,在项目的build.gradle中添加如下依赖,即引用tinker的patch插件

     buildscript {
         dependencies {
             classpath ('com.tencent.tinker:tinker-patch-gradle-plugin:1.7.11')
         }
     }
    
  2. APP 的 build.gradle中添加如下依赖

     dependencies {
         //自己的依赖
         ...
         compile('com.tencent.tinker:tinker-android-lib:1.7.11')
         ...
     }
     ...
     compile('com.tencent.tinker:tinker-android-lib:1.7.11')
    

    sync project后会报错,如下:

    不慌,往下看

  3. 添加Tinkid。这里解释一下TinkId,运行过程中,Tinker需要验证基准apk包的tinkerId是否等于补丁包的tinkerId。这个是决定补丁包能运行在哪些基准包上面。说白了就是id匹配的作用,保持基准包和pacth包的兼容。所以这里建议大家的方式,可以看下github demo的APP gradle文件。网上很多人的做法是复制整个文件,然后运行,貌似可行,实际上并未达到学习的效果,我在改文件里标注了所有的gradle的注释,大家可以看下。

     apply plugin: 'com.android.application'
    
     android {
         compileSdkVersion 25
         buildToolsVersion "25.0.3"
         defaultConfig {
             applicationId "com.netease.xu.tinkerdemo"
             minSdkVersion 10
             targetSdkVersion 25
             versionCode 1
             versionName "1.0"
             testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
    
     //设置BuildConfig.java里message字段,没什么特殊的作用,可有可无
     buildConfigField "String", "MESSAGE", "\"I am the base apk\""
     //在BuildConfig.java里设置TinkerID字段,实际上如果代码里没有调用这个,也可以不设置
     buildConfigField "String", "TINKER_ID", "\"${getTinkerIdValue()}\""
     //同上
     buildConfigField "String", "PLATFORM",  "\"all\""
         }
         buildTypes {
             release {
                 minifyEnabled false
                 proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
             }
         }
             }
    
     dependencies {
         compile fileTree(dir: 'libs', include: ['*.jar'])
         androidTestCompile('com.android.support.test.espresso:espresso-core:2.2.2', {
             exclude group: 'com.android.support', module: 'support-annotations'
         })
         compile 'com.android.support:appcompat-v7:25.3.1'
         compile 'com.android.support.constraint:constraint-layout:1.0.2'
         //tinker的核心库
         compile('com.tencent.tinker:tinker-android-lib:1.7.11')
     }
     
     //定义基准apk构建的位置
     def bakPath = file("${buildDir}/bakApk/")
    
     //额外属性,实际上这些也是可以不用写的,腾讯真是良心,可以支持Gradle脚本自定义,这些值实际上都可以在gradle.properites中自定义
     ext {
         //是否支持tinker构建
         tinkerEnabled = true
    
         //如果需要构建patch包,这里需要天上同一个其基准包地址
         tinkerOldApkPath = "${bakPath}/app-debug-1018-17-32-47.apk"
         //如果需要构建patch包,这里需要天上同一个其基准包的混淆文件
         tinkerApplyMappingPath = "${bakPath}/app-debug-1018-17-32-47-mapping.txt"
         //如果差分包有修改资源文件,则必须需要输入以前基准包的R文件,主要是为了固定id来用的。
         tinkerApplyResourcePath = "${bakPath}/app-debug-1018-17-32-47-R.txt"
     
         //给打渠道包配置的,这里是学习阶段,暂时注释
             //    tinkerBuildFlavorDirectory = "${bakPath}/app-1018-17-32-47"
             }
     
             if (ext.tinkerEnabled) {
     
         //引用patch插件
         apply plugin: 'com.tencent.tinker.patch'
     
         //tinkerPath任务,补丁关键任务,源码也有,后面我会有详细撰述,这里只知道怎么用即可
         //直接使用基准apk包与新编译出来的apk包做差异,得到最终的补丁包
         tinkerPatch {
             oldApk = getTinkerOldApkPath()
             /**
              * 是否忽略警告
              * 值为false将中断编译。因为这些情况可能会导致编译出来的patch包带来风险:
              * 1. minSdkVersion小于14,但是dexMode的值为"raw";  dexmode下面会有介绍
              * 2. 新编译的安装包出现新增的四大组件(Activity, BroadcastReceiver...);
              * 3. 定义在dex.loader用于加载补丁的类不在main dex中;
              * 4. 定义在dex.loader用于加载补丁的类出现修改;
              * 5. resources.arsc改变,但没有使用applyResourceMapping编译。
              */
             ignoreWarning = false
     
             /**
              * 是否签名,保证基准包和补丁包的签名一致,代码里有判断逻辑
              */
             useSign = true
     
             /**
              * 编译相关配置项
              */
             buildConfig {
                 //在编译新的apk时候,通过保持旧apk的proguard混淆方式,从而减少补丁包的大小。这个只是推荐设置,不设置applyMapping也不会影响任何的assemble编译
                 applyMapping = getTinkerApplyMappingPath()
                 //在编译新的apk时候,通过旧apk的R.txt文件保持ResId的分配,这样不仅可以减少补丁包的大小,同时也避免由于ResId改变导致remote view异常。
                 applyResourceMapping = getTinkerApplyResourcePath()
     
                 //tinkerID
                 tinkerId = getTinkerIdValue()
     
                 //如果有多个dex,编译补丁时可能会由于类的移动导致变更增多。若打开keepDexApply模式,补丁包将根据基准包的类分布来编译。
                 keepDexApply = false
             }
     
             dex {
                 //只能是'raw'或者'jar'。
                 //对于'raw'模式,将会保持输入dex的格式。
                 //对于'jar'模式,将会把输入dex重新压缩封装到jar。如果你的minSdkVersion小于14,你必须选择‘jar’模式,而且它更省存储空间,但是验证md5时比'raw'模式耗时。默认我们并不会去校验md5,一般情况下选择jar模式即可。
                 dexMode = "jar"
     
                 //需要处理dex路径,支持*、?通配符,必须使用'/'分割。路径是相对安装包的,例如assets/...
                 pattern = ["classes*.dex",
                            "assets/secondary-dex-?.jar"]
                 /**
                  * 这一项非常重要,它定义了哪些类在加载补丁包的时候会用到。这些类是通过Tinker无法修改的类,也是一定要放在main dex的类。
                  这里需要定义的类有:
                  1. 你自己定义的Application类;
                  2. Tinker库中用于加载补丁包的部分类,即com.tencent.tinker.loader.*;
                  3. 如果你自定义了TinkerLoader,需要将它以及它引用的所有类也加入loader中;
                  4. 其他一些你不希望被更改的类,例如Sample中的BaseBuildInfo类。这里需要注意的是,这些类的直接引用类也需要加入到loader中。或者你需要将这个类变成非preverify。
                  5. 使用1.7.6版本之后版本,参数1、2会自动填写。
                  */
                 loader = [
                         //use sample, let BaseBuildInfo unchangeable with tinker
             //                    "tinker.sample.android.app.BaseBuildInfo"
                 ]
             }
     
             lib {
                 /**
                  * 需要处理lib路径,支持*、?通配符,必须使用'/'分割。与dex.pattern一致, 路径是相对安装包的,例如assets/...
                  */
                 pattern = ["lib/armeabi/*.so"]
             }
     
             res {
                 /**
                  *需要处理res路径,支持*、?通配符,必须使用'/'分割。与dex.pattern一致, 路径是相对安装包的,例如assets/...,务必注意的是,只有满足pattern的资源才会放到合成后的资源包。
                  */
                 pattern = ["res/*", "assets/*", "resources.arsc", "AndroidManifest.xml"]
     
                 /**
                  * 支持*、?通配符,必须使用'/'分割。若满足ignoreChange的pattern,在编译时会忽略该文件的新增、删除与修改。 最极端的情况,ignoreChange与上面的pattern一致,即会完全忽略所有资源的修改。
                  */
                 ignoreChange = ["assets/sample_meta.txt"]
     
                 /**
                  * 对于修改的资源,如果大于largeModSize,我们将使用bsdiff算法。这可以降低补丁包的大小,但是会增加合成时的复杂度。默认大小为100kb
                  */
                 largeModSize = 100
             }
     
             packageConfig {
                 /**
                  * configField("key", "value"), 默认我们自动从基准安装包与新安装包的Manifest中读取tinkerId,并自动写入configField。在这里,你可以定义其他的信息,在运行时可以通过TinkerLoadResult.getPackageConfigByName得到相应的数值。但是建议直接通过修改代码来实现,例如BuildConfig。
                  */
                 configField("patchMessage", "tinker is sample to use")
                 /**
                  * just a sample case, you can use such as sdkVersion, brand, channel...
                  * you can parse it in the SamplePatchListener.
                  * Then you can use patch conditional!
                  */
                 configField("platform", "all")
                 /**
                  * patch version via packageConfig
                  */
                 configField("patchVersion", "1.0")
             }
     
             /**
              *7zip路径配置项,执行前提是useSign为true
              */
             sevenZip {
                 /**
                  * 例如"com.tencent.mm:SevenZip:1.1.10",将自动根据机器属性获得对应的7za运行文件,推荐使用
                  */
                 zipArtifact = "com.tencent.mm:SevenZip:1.1.10"
                 /**
                  * 系统中的7za路径,例如"/usr/local/bin/7za"。path设置会覆盖zipArtifact,若都不设置,将直接使用7za去尝试。
                  */
         //        path = "/usr/local/bin/7za"
             }
         }
     
         List<String> flavors = new ArrayList<>();
         project.android.productFlavors.each {flavor ->
             flavors.add(flavor.name)
         }
         boolean hasFlavors = flavors.size() > 0
         /**
          * bak apk and mapping
          * 渠道包相关配置。
          */
         android.applicationVariants.all { variant ->
             /**
              * task type, you want to bak
              */
             def taskName = variant.name
             //def date = new Date().format("MMdd-HH-mm-ss")
             def date = new Date().format("mm-ss")
     
             tasks.all {
                 if ("assemble${taskName.capitalize()}".equalsIgnoreCase(it.name)) {
     
                     it.doLast {
                         copy {
                             def fileNamePrefix = "${project.name}-${variant.baseName}"
                             def newFileNamePrefix = hasFlavors ? "${fileNamePrefix}" : "${fileNamePrefix}-${date}"
     
                             def destPath = hasFlavors ? file("${bakPath}/${project.name}-${date}/${variant.flavorName}") : bakPath
                             from variant.outputs.outputFile
                             into destPath
                             rename { String fileName ->
                                 fileName.replace("${fileNamePrefix}.apk", "${newFileNamePrefix}.apk")
                             }
     
                             from "${buildDir}/outputs/mapping/${variant.dirName}/mapping.txt"
                             into destPath
                             rename { String fileName ->
                                 fileName.replace("mapping.txt", "${newFileNamePrefix}-mapping.txt")
                             }
     
                             from "${buildDir}/intermediates/symbols/${variant.dirName}/R.txt"
                             into destPath
                             rename { String fileName ->
                                 fileName.replace("R.txt", "${newFileNamePrefix}-R.txt")
                             }
                         }
                     }
                 }
             }
         }
         //渠道构建相关,暂时不考虑
             //    project.afterEvaluate {
             //        //sample use for build all flavor for one time
             //        if (hasFlavors) {
             //            task(tinkerPatchAllFlavorRelease) {
             //                group = 'tinker'
             //                def originOldPath = getTinkerBuildFlavorDirectory()
             //                for (String flavor : flavors) {
             //                    def tinkerTask = tasks.getByName("tinkerPatch${flavor.capitalize()}Release")
             //                    dependsOn tinkerTask
             //                    def preAssembleTask = tasks.getByName("process${flavor.capitalize()}ReleaseManifest")
             //                    preAssembleTask.doFirst {
             //                        String flavorName = preAssembleTask.name.substring(7, 8).toLowerCase() + preAssembleTask.name.substring(8, preAssembleTask.name.length() - 15)
             //                        project.tinkerPatch.oldApk = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-release.apk"
             //                        project.tinkerPatch.buildConfig.applyMapping = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-release-mapping.txt"
             //                        project.tinkerPatch.buildConfig.applyResourceMapping = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-release-R.txt"
             //
             //                    }
             //
             //                }
             //            }
             //
             //            task(tinkerPatchAllFlavorDebug) {
             //                group = 'tinker'
             //                def originOldPath = getTinkerBuildFlavorDirectory()
             //                for (String flavor : flavors) {
             //                    def tinkerTask = tasks.getByName("tinkerPatch${flavor.capitalize()}Debug")
             //                    dependsOn tinkerTask
             //                    def preAssembleTask = tasks.getByName("process${flavor.capitalize()}DebugManifest")
             //                    preAssembleTask.doFirst {
             //                        String flavorName = preAssembleTask.name.substring(7, 8).toLowerCase() + preAssembleTask.name.substring(8, preAssembleTask.name.length() - 13)
             //                        project.tinkerPatch.oldApk = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-debug.apk"
             //                        project.tinkerPatch.buildConfig.applyMapping = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-debug-mapping.txt"
             //                        project.tinkerPatch.buildConfig.applyResourceMapping = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-debug-R.txt"
             //                    }
             //
             //                }
             //            }
             //        }
             //    }
             }
             
             def getTinkerApplyResourcePath() {
                 return ext.tinkerApplyResourcePath
             }
             
             def getTinkerApplyMappingPath() {
                 return ext.tinkerApplyMappingPath
             }
             
             def isTinkerEnabled() {
                 return hasProperty("TINKER_ENABLE") ? TINKER_ENABLE : ext.tinkerEnabled
             }
             
             def getTinkerOldApkPath() {
                 return hasProperty("OLD_APK") ? OLD_APK : ext.tinkerOldApkPath
             }
             
             def getTinkerIdValue() {
                 return hasProperty("TINKER_ID") ? TINKER_ID : 1
     }
    

4.配置完成后,在MainActivity中加入以下代码:

MainActivity.java
activity_main.xml

这些都比较简单的demo,做好这些以后,在命令行里敲入:

     ./gradlew assembleDebug

5.由于我没有开混淆,而且前面gradle里也定义了输出文件的路径,所以在改路径下生成了两个文件:


基准包和一个R.txt文件,R是APK用到的资源索引
大家可以看下R.txt文件,实际上里面也包括所有的系统资源,这里就不做撰述

细心的同学会发现,manifest.xml会多了一行:



这个1实际上也是我们自己配置的tinkerid。这边我直接写死了,如果是线上项目,很多是用git的提交号来。

BuildConfig.java中也多了在gradle里配置的字段


6.这些做完够了么,当然不够,可以构建出APK出来,但是APK里不包含热更新相关的处理逻辑,最起码有load patch包,以及冷启动相关代码逻辑。

在做这些之前,我们先回顾一下上面的过程,显然Tinker干预了打包的过程,实际上补丁包的生成也是有单独的命令的。从这一点来看,tinker的侵入性还是比较强的。

我们接着来添加代码已满足需求:

  • load 补丁包: 说白了就是给一个补丁包的地址,然后Tinker去load。这个地址应该是sd卡和files都可以,sd卡最后也会拷贝到files下去加载。所以我在xml里加一个一个button显示的load补丁包。至于补丁包的地址写死吧。 /sdcard/patch.apk 代码如下:


当运行时会报如下错误:



显然是由于Tinker没有安装导致的,一般框架也是这个尿性,所以这时候要对Tinker进行初始化,说到这里,发现Tinker的侵入性又+1。不管了,既然是集成,也只能按照它的协议往下做了。

一般人集成也许第一步就初始化了,我的角度是站在普通人的思路去做这个事,发现问题再去解决问题,这样才能去分析这个框架,更大可能的去理解创造者的思想,当然网上很多人直接复制粘贴,这个就不推荐了。
  • Tinker初始化。在官方Demo的ReadMe中找到:

也只能是这个尿性了,这不是重点。重点是这个SampleApplicationLike。微信为什么高出这个呢,他应该是一个代理,这个里面做Tinker热跟新相关的处理,后面我们分析源码的时候会讲到。这里不做撰述,照葫芦画瓢即可。实际上内部也是有一个Default的实现的,这里为了学习,就自定义了一个。按部就班的来,最后运行的时候发现报一下错误



竟然说我的Application类重复了,诡异。继续找原因:
原因应该是SampleApplicationLike中的注解自动生成了一个Application导致和代码里重写的注解@DefaultCycle导致的。所以我尝试取消这个注解。编译可以通过,但是运行的时候出现以下崩溃



哎,难道Tinker不支持自定义Application。暂时不管了,删除自己写的Application,添加注解。就可以编译安装了。
<font color="red">这个设计,有点反人类。侵入性严重。难受...</font>

7.做完这些事以后,构建出一个基准包(./gradlew assembleDebug),并且安装。由于需要用到sd卡权限,所以

manefest里要加上sd卡读写权限,省的重来一次。

运行后,在 app/build/bakApk/ 目录下会生成一个Apk以及相应的R.txt文件,会带有一个数字,如下:



通过adb 安装这个基准包。然后修改一行代码,这个随便自己。只要能看出来当前修改和上次基准包有区别就可以。我这边就是修改了一下toast弹出的文案。

8.通过上面的7构建出来基准包后,修改app/build.gralde脚本。



这个路径很好理解。至于那个Mappingpath。由于我用的是debug包,直接忽略,删除都可以。运行 ./gradlew tinkerPatchDebug 构建补丁包竟然需要一分钟以上。吐槽一下。成功以后,在 app/build/outputs/tikerPath/目录下会生成补丁包,有带签名和不带签名的。


9.通过adb push导入补丁包到 sd卡。打开应用在关闭屏幕,点击按钮弹出toast。就是新的t文案了。

结束

上面完成了Tinker的集成。虽然说工作量不大,但是从中也遇到了一些问题,有些已经找到原因,有些缺不知为何。后期会在去研究。

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

推荐阅读更多精彩内容