# Tinker学习计划(2)-Tinker的原理一

前言

Tinker学习计划(1)-Tinker的集成 这边文章中我们首先学习了如何去集成Tinker热更新框架,去实现我们自己App的热更新功能。这篇文章主要是从架构和源码的角度去理解Tinker。我计划是分成以下几步:

  • Tinker的结构(主要是分析Tinker的架构,从宏观层面来了解Tinker)
  • Tinker代码修复的原理(描述补丁包生效的过程,源码分析)
  • Tinker资源修复的原理(Tinker资源构建的原理,及修复的原理,源码层面)
  • Tinker对.SO是如何处理的(源码层面)
  • tinker-patch-gradle-plugin源码解析
  • DexDiff算法(源码层面)

Tinker的结构

包结构

Tinker的集成 这篇文章,我们的思路基本是按照Tinker Demo里提供的思路来集成,说白了就是通过在Gradle里Compile腾讯上传的代码,这的确能达成目的,但是对于学习却是不方便的,虽然能看到源码,但是.class文件在IDE里操作起来还是很麻烦的,所以在分析结构和学习源码之前,我们重新打个工程,这个工程里不会像下面这样来应用Lib了:

compile('com.tencent.tinker:tinker-android-lib:1.7.11')
provided("com.tencent.tinker:tinker-android-anno:1.7.11")

而是直接把Tinker开源的其他代码来搭建一个包含所有源码的工程,讲起来特别高大上实际实现起来很简单,这里就不细讲了。贴几张图查来大家就了解了。

首先你注释掉 app/build.gradle 中上面的两个依赖库。然后拷贝Github上Tinker的库过来。

下面是整个工程的结构:

settings.gradle,修改成如下即可:

同步时,可能会遇到如下问题:

Error:Unable to find method 'org.gradle.api.internal.artifacts.configurations.ConfigurationInternal.getModule()Lorg/gradle/api/internal/artifacts/ModuleInternal;'.
Possible causes for this unexpected error include:<ul><li>Gradle's dependency cache may be corrupt (this sometimes occurs after a network connection timeout.)
<a href="syncProject">Re-download dependencies and sync project (requires network)</a></li><li>The state of a Gradle build process (daemon) may be corrupt. Stopping all Gradle daemons may solve this problem.
<a href="stopGradleDaemons">Stop Gradle build processes (requires restart)</a></li><li>Your project may be using a third-party plugin which is not compatible with the other plugins in the project or the version of Gradle requested by the project.</li></ul>In the case of corrupt Gradle processes, you can also try closing the IDE and then killing all Java processes.

解决该问题:只需要修改以下两个地方:

可能有的童鞋还是不太了解,索性简单花了一个图来表示各个库的依赖关系:

tinker_android_anno :

工程结构如下:

简单的看了下,实际上这是一个为了在编译期间生成Application的类,至于原理很简单,使用者通过DefaultLifeCycle这个注解来填充一些数据,AnnotationProcessor是集成AbstractProcessor这个类的,这个库的作用也就是说在编译完成以后,自动给工程填充了一个Application类,如下:

关于AbstractProcessor原理介绍,可参考文章:
AbstractProcessor参考

tinker-android-lib

跟Tinker框架相关的类及代码都是在Module中,我们业务开发包括集成也只需要关系这个Module即可。

tinker-commons

从名称就可以看出该Module是业务无关的,里面主要是DexPatch相关的代码,包括Tinker是DexDiff核心的部分,后期我们会详细分析。

third-party:aosp_dexutils

主要包含tinker是如何定义Dex的,这个也是业务无关的

third-party:bsdiff_util

bsdiff相关的代码,在某些情况下,微信的Dex合成实际上用的是Bsdiff算法,当然做了优化。

框架原理

这章我们主要对Tinker的框架进行分析,从宏观层面了解tinker的构造,我尽量讲的详细,这是站在我的角度来思考这个问题,有可能和原作者思想有出入的地方,大家理解。讲解之前先看一张图:

可以看出,集成了tinker的App框架分成了两类进程,一个是原来具体业务中的进程,一个是tinker需要的Patch进程。他们的分工也很明确,我们先说下patch进程,这个很好理解,类似于我们的Demo比较简单,一个按钮作为加载load补丁包的入口,或许线上的项目可能是由于服务端灰度来控制补丁包的下发,然后再去加载,事实上都是一个意思,而这个加载补丁包的过程就是在patch进程中进行的,为什么这么做应该也是因为为了减少业务进程的开销吧。从图中可以看出,patch进程的作用主要是在合并Dex,即通过DexDiff算法来合并原始Dex和补丁Dex,优化Dex及为了避免在进程重启时做这个事,在合并玩Dex后,即完成dexopt的过程。当然做这些事之前都有一些校验的工作。至于patch上报,说白了就是定义了一个生命周期,在做这些事情当中如果发生一些异常行为,可以上报给其他模块或者服务端,tinker这块是支持自定义的。按照规则实现它的接口即可。

具体业务进程的定位很简单,只要是做了tinker框架的初始化,检查是否存在补丁吧,如果有补丁包,通过hook的方式替换原始Dex的加载。校验模块也是必须的,load的上报和patch的上报类似。至于里面还有一些安全模式的校验,我们后面会在谈到。

那这两个进程是怎么进行协同的,实际上他们是通过文件来进行沟通和传递数据的,由于涉及到多进程来访问文件,所以里面也用到了文件锁来避免出现问题。

这个图只是一个抽象,让大家从上帝角度来了解tinker的工作原理,这个时候只需要知道tinker是这种原理工作的。

patch进程工作的流程图:

现在是不是有感觉了,大概知道patch进程的工作原理了。接下来,开始撸源码了。

TinkerInstaller.onReceiveUpgradePatch(getApplicationContext(), "/sdcard/patch.apk");

这个就是补丁包加载的入口,后面的路径就是放补丁包的路径,如果是云端下发的,就是下载的地址。

DefaultPatchListener.java

int returnCode = patchCheck(path);
if (returnCode == ShareConstants.ERROR_PATCH_OK) {
    TinkerPatchService.runPatchService(context, path);
} else {
    Tinker.with(context).getLoadReporter().onLoadPatchListenerReceiveFail(new File(path), returnCode);
}
return returnCode;

protected int patchCheck(String path) {
    Tinker manager = Tinker.with(context);
    //如果有设置项则判断是否启动tinker,包括在Application中传入的参数来决定
    if (!manager.isTinkerEnabled() || !ShareTinkerInternals.isTinkerEnableWithSharedPreferences(context)) {
        return ShareConstants.ERROR_PATCH_DISABLE;
    }
    File file = new File(path);
    //判断文件合理性
    if (!SharePatchFileUtil.isLegalFile(file)) {
        return ShareConstants.ERROR_PATCH_NOTEXIST;
    }
    //patch进程中不处理这个操作
    if (manager.isPatchProcess()) {
        return ShareConstants.ERROR_PATCH_INSERVICE;
    }
    //由于对与一个patch操作,做完了就会kill patch进程,如果当前patch进程正存在,则不处理
    if(TinkerServiceInternals.isTinkerPatchServiceRunning(context)) {
        return ShareConstants.ERROR_PATCH_RUNNING;
    }
    //不支持vm支持jit编译以及apilevel在24以下的机器
    if (ShareTinkerInternals.isVmJit()) {
        return ShareConstants.ERROR_PATCH_JIT;
    }
        return ShareConstants.ERROR_PATCH_OK;
    }
}

这个类很简单,就是做一些基本的验证,如果错了,会丢到LoadReporter这个接口的实现类,至于怎么处理,用户可以自定义。tinker的默认实现只是打印了错误日志而已。这里就不贴代码了。我们按照流程接着往下说,验证成功以后,tinker会启动一个IntentService。处于 com.XXX.XXX:patch 进程中。


public static void runPatchService(Context context, String path) {
try {
      Intent intent = new Intent(context, TinkerPatchService.class);
      intent.putExtra(PATCH_PATH_EXTRA, path);
      intent.putExtra(RESULT_CLASS_EXTRA, resultServiceClass.getName());
      context.startService(intent);
      } catch (Throwable throwable) {
          TinkerLog.e(TAG, "start patch service fail, exception:" + throwable);
      }
    }
}

我们在来看看TinkerPatchService中的OnHanderIntent这个方法。

protected void onHandleIntent(Intent intent) {
        final Context context = getApplicationContext();
        Tinker tinker = Tinker.with(context);
        //patch开始时,上报给patchReporter的实现类
        tinker.getPatchReporter().onPatchServiceStart(intent);

        if (intent == null) {
            TinkerLog.e(TAG, "TinkerPatchService received a null intent, ignoring.");
            return;
        }
        String path = getPatchPathExtra(intent);
        if (path == null) {
            TinkerLog.e(TAG, "TinkerPatchService can't get the path extra, ignoring.");
            return;
        }
        File patchFile = new File(path);

        long begin = SystemClock.elapsedRealtime();
        boolean result;
        long cost;
        Throwable e = null;
        //提升进程优先级,尽快让patch操作执行,tinker用了两种不同的方案,感
        //兴趣的童鞋可以看下
        increasingPriority();
        PatchResult patchResult = new PatchResult();
        try {
            if (upgradePatchProcessor == null) {
                throw new TinkerRuntimeException("upgradePatchProcessor is null.");
            }
            result = upgradePatchProcessor.tryPatch(context, path, patchResult);
        } catch (Throwable throwable) {
            e = throwable;
            result = false;
            tinker.getPatchReporter().onPatchException(patchFile, e);
        }

        cost = SystemClock.elapsedRealtime() - begin;
        //patch结果出来后,上报给patchReporter的实现类
        tinker.getPatchReporter().
            onPatchResult(patchFile, result, cost);

        patchResult.isSuccess = result;
        patchResult.rawPatchFilePath = path;
        patchResult.costTime = cost;
        patchResult.e = e;

        AbstractResultService.runResultService(context, patchResult, getPatchResultExtra(intent));

    }
}

可以看出来,这个主要是调用upgradePatchProcessor这个类的方法tryPatch。分析这个之前,我们先聊下PatchReporter这个接口。


public interface PatchReporter {
    void onPatchServiceStart(Intent intent);
    void onPatchPackageCheckFail(File patchFile, int errorCode);
    void onPatchVersionCheckFail(File patchFile, SharePatchInfo oldPatchInfo, String patchFileVersion);
    void onPatchTypeExtractFail(File patchFile, File extractTo, String filename, int fileType);
    void onPatchDexOptFail(File patchFile, List<File> dexFiles, Throwable t);
    void onPatchResult(File patchFile, boolean success, long cost);
    void onPatchException(File patchFile, Throwable e);
    void onPatchInfoCorrupted(File patchFile, String oldVersion, String newVersion);
}

实际上细心的同学就会发现,tinker里很多这种设计,主要也是为了方便开发者自定义一些行为,tinker封装了最核心的那部分代码。比方说patch操作,如果在patch操作中出现一些问题,开发者可以定义其行为。

好,我们接着看patch的流程:


public boolean tryPatch(Context context, String tempPatchPath, PatchResult patchResult) {
        Tinker manager = Tinker.with(context);
        final File patchFile = new File(tempPatchPath);
        if (!manager.isTinkerEnabled() || !ShareTinkerInternals.isTinkerEnableWithSharedPreferences(context)) {
            TinkerLog.e(TAG, "UpgradePatch tryPatch:patch is disabled, just return");
            return false;
        }
        if (!SharePatchFileUtil.isLegalFile(patchFile)) {
            TinkerLog.e(TAG, "UpgradePatch tryPatch:patch file is not found, just return");
            return false;
        }
        //check the signature, we should create a new checker
        ShareSecurityCheck signatureCheck = new ShareSecurityCheck(context);

        int returnCode = ShareTinkerInternals.checkTinkerPackage(context, manager.getTinkerFlags(), patchFile, signatureCheck);
        if (returnCode != ShareConstants.ERROR_PACKAGE_CHECK_OK) {
            TinkerLog.e(TAG, "UpgradePatch tryPatch:onPatchPackageCheckFail");
            manager.getPatchReporter().onPatchPackageCheckFail(patchFile, returnCode);
            return false;
        }

        String patchMd5 = SharePatchFileUtil.getMD5(patchFile);
        if (patchMd5 == null) {
            TinkerLog.e(TAG, "UpgradePatch tryPatch:patch md5 is null, just return");
            return false;
        }
        //use md5 as version
        patchResult.patchVersion = patchMd5;

        //check ok, we can real recover a new patch
        final String patchDirectory = manager.getPatchDirectory().getAbsolutePath();

        File patchInfoLockFile = SharePatchFileUtil.getPatchInfoLockFile(patchDirectory);
        File patchInfoFile = SharePatchFileUtil.getPatchInfoFile(patchDirectory);

        SharePatchInfo oldInfo = SharePatchInfo.readAndCheckPropertyWithLock(patchInfoFile, patchInfoLockFile);

        //it is a new patch, so we should not find a exist
        SharePatchInfo newInfo;

        //already have patch
        if (oldInfo != null) {
            if (oldInfo.oldVersion == null || oldInfo.newVersion == null || oldInfo.oatDir == null) {
                TinkerLog.e(TAG, "UpgradePatch tryPatch:onPatchInfoCorrupted");
                manager.getPatchReporter().onPatchInfoCorrupted(patchFile, oldInfo.oldVersion, oldInfo.newVersion);
                return false;
            }

            if (!SharePatchFileUtil.checkIfMd5Valid(patchMd5)) {
                TinkerLog.e(TAG, "UpgradePatch tryPatch:onPatchVersionCheckFail md5 %s is valid", patchMd5);
                manager.getPatchReporter().onPatchVersionCheckFail(patchFile, oldInfo, patchMd5);
                return false;
            }
            // if it is interpret now, use changing flag to wait main process
            final String finalOatDir = oldInfo.oatDir.equals(ShareConstants.INTERPRET_DEX_OPTIMIZE_PATH)
                ? ShareConstants.CHANING_DEX_OPTIMIZE_PATH : oldInfo.oatDir;
            newInfo = new SharePatchInfo(oldInfo.oldVersion, patchMd5, Build.FINGERPRINT, finalOatDir);
        } else {
            newInfo = new SharePatchInfo("", patchMd5, Build.FINGERPRINT, ShareConstants.DEFAULT_DEX_OPTIMIZE_PATH);
        }

        //it is a new patch, we first delete if there is any files
        //don't delete dir for faster retry
//        SharePatchFileUtil.deleteDir(patchVersionDirectory);
        final String patchName = SharePatchFileUtil.getPatchVersionDirectory(patchMd5);

        final String patchVersionDirectory = patchDirectory + "/" + patchName;

        TinkerLog.i(TAG, "UpgradePatch tryPatch:patchVersionDirectory:%s", patchVersionDirectory);

        //copy file
        File destPatchFile = new File(patchVersionDirectory + "/" + SharePatchFileUtil.getPatchVersionFile(patchMd5));

        try {
            // check md5 first
            if (!patchMd5.equals(SharePatchFileUtil.getMD5(destPatchFile))) {
                SharePatchFileUtil.copyFileUsingStream(patchFile, destPatchFile);
                TinkerLog.w(TAG, "UpgradePatch copy patch file, src file: %s size: %d, dest file: %s size:%d", patchFile.getAbsolutePath(), patchFile.length(),
                    destPatchFile.getAbsolutePath(), destPatchFile.length());
            }
        } catch (IOException e) {
//            e.printStackTrace();
            TinkerLog.e(TAG, "UpgradePatch tryPatch:copy patch file fail from %s to %s", patchFile.getPath(), destPatchFile.getPath());
            manager.getPatchReporter().onPatchTypeExtractFail(patchFile, destPatchFile, patchFile.getName(), ShareConstants.TYPE_PATCH_FILE);
            return false;
        }

        //we use destPatchFile instead of patchFile, because patchFile may be deleted during the patch process
        if (!DexDiffPatchInternal.tryRecoverDexFiles(manager, signatureCheck, context, patchVersionDirectory, destPatchFile)) {
            TinkerLog.e(TAG, "UpgradePatch tryPatch:new patch recover, try patch dex failed");
            return false;
        }

        if (!BsDiffPatchInternal.tryRecoverLibraryFiles(manager, signatureCheck, context, patchVersionDirectory, destPatchFile)) {
            TinkerLog.e(TAG, "UpgradePatch tryPatch:new patch recover, try patch library failed");
            return false;
        }

        if (!ResDiffPatchInternal.tryRecoverResourceFiles(manager, signatureCheck, context, patchVersionDirectory, destPatchFile)) {
            TinkerLog.e(TAG, "UpgradePatch tryPatch:new patch recover, try patch resource failed");
            return false;
        }

        // check dex opt file at last, some phone such as VIVO/OPPO like to change dex2oat to interpreted
        if (!DexDiffPatchInternal.waitAndCheckDexOptFile(patchFile, manager)) {
            TinkerLog.e(TAG, "UpgradePatch tryPatch:new patch recover, check dex opt file failed");
            return false;
        }

        if (!SharePatchInfo.rewritePatchInfoFileWithLock(patchInfoFile, newInfo, patchInfoLockFile)) {
            TinkerLog.e(TAG, "UpgradePatch tryPatch:new patch recover, rewrite patch info failed");
            manager.getPatchReporter().onPatchInfoCorrupted(patchFile, newInfo.oldVersion, newInfo.newVersion);
            return false;
        }

        TinkerLog.w(TAG, "UpgradePatch tryPatch: done, it is ok");
        return true;
}

这个函数体有几个重要的步骤,一个是签名检查:ShareSecurityCheck。然后是MD5检查,然后是Patch的核心部分,也就是DexDiffPatchInternal、BsDiffPatchInternal、ResDiffPatchInternal,他们都是集成BasePatchInternal。我们先看ShareSecurityCheck:

ShareSecurityCheck
他主要是做了这些工作,第一是判断patch包的签名和当前安装的apk的签名是否一致,详细代码在ShareSecurityCheck类的verifyPatchMetaSignature函数。第二就是判断patch包的tinker_id是否和基准包的tinker_id是否一致。这个很好理解,各自取出manefest中的tinker_id的值来坐下equals判断即可。昨晚这些以后,tinker还做了一个判断,如下:


public static int checkPackageAndTinkerFlag(ShareSecurityCheck securityCheck, int tinkerFlag) {
        if (isTinkerEnabledAll(tinkerFlag)) {
            return ShareConstants.ERROR_PACKAGE_CHECK_OK;
        }
        HashMap<String, String> metaContentMap = securityCheck.getMetaContentMap();
        //check dex
        boolean dexEnable = isTinkerEnabledForDex(tinkerFlag);
        if (!dexEnable && metaContentMap.containsKey(ShareConstants.DEX_META_FILE)) {
            return ShareConstants.ERROR_PACKAGE_CHECK_TINKERFLAG_NOT_SUPPORT;
        }
        //check native library
        boolean nativeEnable = isTinkerEnabledForNativeLib(tinkerFlag);
        if (!nativeEnable && metaContentMap.containsKey(ShareConstants.SO_META_FILE)) {
            return ShareConstants.ERROR_PACKAGE_CHECK_TINKERFLAG_NOT_SUPPORT;
        }
        //check resource
        boolean resEnable = isTinkerEnabledForResource(tinkerFlag);
        if (!resEnable && metaContentMap.containsKey(ShareConstants.RES_META_FILE)) {
            return ShareConstants.ERROR_PACKAGE_CHECK_TINKERFLAG_NOT_SUPPORT;
        }

        return ShareConstants.ERROR_PACKAGE_CHECK_OK;
    }

tinkerFlag是Application申明的时候我们初始化的,主要的含义就是当前tinker支持哪些热更新,dex,so,res,还是全部,或者一个都不。那这个函数主要的作用的是什么呢,实际上tinker在构建patch包时,如果资源有更新,会在asset下生成一个文件dex_meta.txt。


如果有资源或者so的更新也会对应生成,so_meta.txt和res_meta.txt。这样就很好理解了,比如说当前tinker我们设置成不支持代码更新,但是代码确在patch包修改了,那当前的patch包我们认为是一个无效的patch包,随之放弃更新。

我们接着说MD5的校验、说这个之前,我们先彻底的分析一下,tinker存储patch的文件结构,下面是一个做过一次热更新的时候的app files下文件结构图。

  • info.lock这个文件是为了解决跨进程读写patch.info这个文件所建立的文件锁
  • patch.info说白了是一个配置文件,里面是处理完补丁包以后的一些配置信息,当进程重启以后,patch以外的其他进程会去读这个文件数据来获取当前是否要加载patch的合成包,cat一下这个文件


    说白了就是一个�key-value形式的ini配置文件,上面#号开头的是注释,可以不用管,dir对应的是dex目录,print是当前rom信息,为了判断OTA升级用的,new和old是版本,tinker的版本是以MD5来做处理的。

  • patch-xxxx 目录下面存储的是补丁包的具体数据,patch后面那串数字实际上是补丁包MD5的0到8位。至于为什么是0-8位,是协议层面的事情,暂不撰述。
  • patch-xxxx.apk就是补丁包,从云端下载复制到改目录下的。
  • dex目录,目录中是从patch.apk中抽取的dex文件,至于为什么以jar结尾(实际上也是dex_meta.txt这个文件中来控制的),原因暂且不明,而为什么在编译的的时候做这个事,猜测可能是由于兼容性(davalik和art).
  • odex目录,是通过dex目录中dex文件经过优化Dex2oat而来。两种方式,一种是DexFile.loadDex(**),第二种是通过在代码中构建命令行来进行优化。

那如果是多个补丁包呢?

实际上就是在patch-xxxx目录并列的层次下面多一个patch-xxxx目录而已,后面的xxxx就是那个补丁包的md5取 0-8位。只不过patch.info里的old和new字段对应的是最新的那个补丁包的md5值。

说完了文件结构,我们接着上面的MD5校验继续撸。

MD5在Tinker里的作用主要以下几个方面

  • 安全校验,这个很好理解
  • 作为当前patch的版本号,以及文件夹以MD5作为标识,类似于Patch-xxxx
  • 多个补丁包更新时,判断两个补丁包是否一致

获取MD5是通过下面这个方法来完成的:


public final static String getMD5(final InputStream is) {
        if (is == null) {
            return null;
        }
        try {
            BufferedInputStream bis = new BufferedInputStream(is);
            MessageDigest md = MessageDigest.getInstance("MD5");
            StringBuilder md5Str = new StringBuilder(32);

            byte[] buf = new byte[ShareConstants.MD5_FILE_BUF_LENGTH];
            int readCount;
            while ((readCount = bis.read(buf)) != -1) {
                md.update(buf, 0, readCount);
            }

            byte[] hashValue = md.digest();

            for (int i = 0; i < hashValue.length; i++) {
                md5Str.append(Integer.toString((hashValue[i] & 0xff) + 0x100, 16).substring(1));
            }
            return md5Str.toString();
        } catch (Exception e) {
            return null;
}

那现在配置文件,源文件都准备好了,就要做下面的dex合成,资源合成了。这块我们留到下篇文章接着细说。

总结

好的,今天的文章就到这里,主要分析了Tinker的结构,tinker的启动流程,以及在做dex合成之前,tinker做了哪些事情。下一节我们继续分析。

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

推荐阅读更多精彩内容