HotFix原理介绍及使用总结

What is HotFix?

以补丁的方式动态修复紧急Bug,不再需要重新发布App,不再需要用户重新下载,覆盖安装(来自:安卓App热补丁动态修复技术介绍)

HotFix框架汇总

QQ空间HotFix方案原理

首先HotFix原理是基于Android Dex分包方案的,而Dex分包方案的关键就是Android的ClassLoader体系。ClassLoader的继承关系如下:

ClassLoader继承关系

这里我们可以用的是PathClassLoaderDexClassLoader,接下来看看这两个类的注释:

  • PatchClassLoader
/**
  * Provides a simple {@link ClassLoader} implementation that operates on a list
 * of files and directories in the local file system, but does not attempt to
 * load classes from the network. Android uses this class for its system class
 * loader and for its application class loader(s).
 */

这个类被用作系统类加载器和应用类(已安装的应用)加载器。

  • DexClassLoader
/**
 * A class loader that loads classes from {@code .jar} and {@code .apk} files
 * containing a {@code classes.dex} entry. This can be used to execute code not
 * installed as part of an application.
 */

注释可以看出,这个类是可以用来从.jar文件和.apk文件中加载classed.dex,可以用来执行没有安装的程序代码。

通过上面的两个注释可以清楚这两个类的作用了,很显然我们要用的是DexClassLoader,对插件化了解的小伙伴们对这个类肯定不会陌生的,对插件化不了解的也没关系。下面会更详细的介绍。

我们知道了PathClassLoader和DexClassLoader的应用场景,接下来看一下是如何加载类的,看上面的继承关系这里两个类都是继承自BaseDexClassLoader,所以查找类的方法也在BaseDexClassLoader中,下面是部分源码:

/**
 * Base class for common functionality between various dex-based
 * {@link ClassLoader} implementations.
 */
public class BaseDexClassLoader extends ClassLoader {

     /** structured lists of path elements */
     private final DexPathList pathList;

     //...some code
    
     @Override
     protected Class<?> findClass(String name) throws ClassNotFoundException {

         Class clazz = pathList.findClass(name);
         if (clazz == null) {
             throw new ClassNotFoundException(name);
         }

         return clazz;

     }

    //...some code
}

可以看到在findClass()方法中用到了pathList.findClass(name),而pathList的类型是DexPathList,下面看一下DexPathList的findClass()方法源码:

/**
 * A pair of lists of entries, associated with a {@code ClassLoader}.
 * One of the lists is a dex/resource path — typically referred
 * to as a "class path" — list, and the other names directories
 * containing native code libraries. Class path entries may be any of:
 * a {@code .jar} or {@code .zip} file containing an optional
 * top-level {@code classes.dex} file as well as arbitrary resources,
 * or a plain {@code .dex} file (with no possibility of associated
 * resources).
 *
 * <p>This class also contains methods to use these lists to look up
 * classes and resources.</p>
 */
/*package*/ final class DexPathList {
    
     /** list of dex/resource (class path) elements */
     private final Element[] dexElements;

    /**
     * Finds the named class in one of the dex files pointed at by
     * this instance. This will find the one in the earliest listed
     * path element. If the class is found but has not yet been
     * defined, then this method will define it in the defining
     * context that this instance was constructed with.
     *
     * @return the named class or {@code null} if the class is not
     * found in any of the dex files
     */
     public Class findClass(String name) {
         for (Element element : dexElements) {
             DexFile dex = element.dexFile;
             if (dex != null) {
                 Class clazz = dex.loadClassBinaryName(name, definingContext);
                 if (clazz != null) {
                     return clazz;
                 }
             }
         }

         return null;

     }
}

这个方法里面有调用了dex.loadClassBinaryName(name, definingContext),然后我们来看一下DexFile的这个方法:

/**
 * Manipulates DEX files. The class is similar in principle to
 * {@link java.util.zip.ZipFile}. It is used primarily by class loaders.
 * <p>
 * Note we don't directly open and read the DEX file here. They're memory-mapped
 * read-only by the VM.
 */
public final class DexFile {

    /**
     * See {@link #loadClass(String, ClassLoader)}.
     *
     * This takes a "binary" class name to better match ClassLoader semantics.
     *
     * @hide
     */
     public Class loadClassBinaryName(String name, ClassLoader loader){

         return defineClass(name, loader, mCookie);

     }

     private native static Class defineClass(String name, ClassLoader loader, int cookie);
}

好了,关联的代码全部贴上了,理解起来并不难,总结一下流程:BaseDexClassLoader中有一个DexPathList对象pathList,pathList中有个Element数组dexElements(Element是DexPathList的静态内部类,在Element中会保存DexFile的对象),然后遍历Element数组,通过DexFile对象去查找类。
更通俗的说:

一个ClassLoader可以包含多个dex文件,每个dex文件是一个Element,多个dex文件排列成一个有序的数组dexElements,当找类的时候,会按顺序遍历dex文件,然后从当前遍历的dex文件中找类,如果找类则返回,如果找不到从下一个dex文件继续查找。(出自安卓App热补丁动态修复技术介绍)

so,通过上面介绍,我们可以将patch.jar(补丁包),放在dexElements数组的第一个元素,这样优先找到我们patch.jar中的新类去替换之前存在bug的类。

方案有了,但是我们还差一个步骤,就是防止类被打上CLASS_ISPREVERIFIED的标记

解释一下:在apk安装的时候,虚拟机会将dex优化成odex后才拿去执行。在这个过程中会对所有class一个校验。校验方式:假设A该类在它的static方法,private方法,构造函数,override方法中直接引用到B类。如果A类和B类在同一个dex中,那么A类就会被打上CLASS_ISPREVERIFIED标记。A类如果还引用了一个C类,而C类在其他dex中,那么A类并不会被打上标记。换句话说,只要在static方法,构造方法,private方法,override方法中直接引用了其他dex中的类,那么这个类就不会被打上CLASS_ISPREVERIFIED标记。(引用自Android热补丁动态修复技术(二):实战!CLASS_ISPREVERIFIED问题!)

O..O..OK,现在很清楚了,实现QQ空间热修复方案,我们需要完成两个任务:

  1. 改变BaseDexClassLoader中的dexElements数组,将我们的patch.jar插入到dexElements数组的第一个位置。
  2. 在打包的时候,我们要阻止类被打上CLASS_ISPREVERIFIED标记

AndFix修复方案原理

AndFix的原理需要从源码来一步一步的分析,接下来按照AndFix的使用步骤来分析源码,从而引出原理,一共分为两层:1.Java层 2.Native层(关键步骤)

Java层

首先是patchManager = new PatchManager(context);,来看下PatchManager的构造方法:

/** 
  * @param context 
  *            context 
  */
public PatchManager(Context context) {  
    mContext = context;   
    //初始化AndFixManager()  
     mAndFixManager = new AndFixManager(mContext);   
    //初始化缓存Patch的文件夹 
     mPatchDir = new File(mContext.getFilesDir(), DIR);   
    //初始化存在patch类的集合,即需要修复类的集合  
     mPatchs = new ConcurrentSkipListSet<Patch>();  
     //初始化类对应的classLoader集合   
    mLoaders = new ConcurrentHashMap<String, ClassLoader>();
}

//AndFixManager.java
public AndFixManager(Context context) {   
    mContext = context;   
    //判断是否支持当前机型
    mSupport = Compat.isSupport();   
    if (mSupport) {      
        //初始化安全检查的类
        mSecurityChecker = new SecurityChecker(mContext);      
        //初始化优化的文件夹(该文件夹会存放MD5值,安全检查时候会用)
        mOptDir = new File(mContext.getFilesDir(), DIR);     
        if (!mOptDir.exists() && !mOptDir.mkdirs()) {// make directory fail  
            mSupport = false;        
            Log.e(TAG, "opt dir create error.");      
        } else if (!mOptDir.isDirectory()) {// not directory         
            mOptDir.delete();       
            mSupport = false;     
         }   
    }
}

构造方法里的代码都加了注释很清晰,接下来看patchManager.init(appversion);//current version方法:

/** 
 * initialize 
 *  
 * @param appVersion
 *            App version 
 */
public void init(String appVersion) {   
    //判断是否存在构造方法中创建的文件夹
    if (!mPatchDir.exists() && !mPatchDir.mkdirs()) {// make directory fail      
        Log.e(TAG, "patch dir create error.");      
        return;   
    } else if (!mPatchDir.isDirectory()) {// not directory      
        mPatchDir.delete();      
        return;   
    }   
    
    //获取SharedPreferences对象,用来缓存版本号
    SharedPreferences sp = mContext.getSharedPreferences(SP_NAME,Context.MODE_PRIVATE);   
    String ver = sp.getString(SP_VERSION, null);  
     //如果没有缓存的版本号或者版本号不一致
     if (ver == null || !ver.equalsIgnoreCase(appVersion)) {   
        // 清除缓存文件夹里面的所有文件
        cleanPatch();      
        //缓存新的版本号
        sp.edit().putString(SP_VERSION, appVersion).commit();  
     } else {      
        initPatchs();  
     }
}

/**
 *  清除缓存文件夹里面的所有文件
 *
 */
private void cleanPatch() {  
    File[] files = mPatchDir.listFiles();   
    for (File file : files) {      
        //AndFixManager的方法,移除缓存的MD5指纹
        mAndFixManager.removeOptFile(file);      
        if (!FileUtil.deleteFile(file)) {         
            Log.e(TAG, file.getName() + " delete error.");      
        }   
    }
}

/**
 *初始化补丁文件
 */
private void initPatchs() {   
    File[] files = mPatchDir.listFiles();   
    for (File file : files) {      
        // 从缓存文件夹添加补丁文件
        addPatch(file);   
    }
}

/** 
 * add patch file 
 *  
 * @param file 
 * @return patch 
 */
private Patch addPatch(File file) {   
    Patch patch = null;   
    if (file.getName().endsWith(SUFFIX)) {      
        try {         
            //Patch类会将文件中的信息解析出来
            patch = new Patch(file);       
            //添加到集合中  
            mPatchs.add(patch);      
        } catch (IOException e) {         
            Log.e(TAG, "addPatch", e);      
        }   
    }   
    return patch;
}

接下来先分析一下另一个addPatch(String path)方法,这个方法在加载补丁的时候调用:

/** 
 * add patch at runtime 
 *  
 * @param path 
 *            patch path 
 * @throws IOException 
 */
public void addPatch(String path) throws IOException {   
    File src = new File(path);   
    File dest = new File(mPatchDir, src.getName());   
    if(!src.exists()){      
        throw new FileNotFoundException(path);   
    }   
    if (dest.exists()) {      
        Log.d(TAG, "patch [" + path + "] has be loaded.");      
        return;   
    }   
    //将补丁复制一份到缓存文件夹
    FileUtil.copyFile(src, dest);// copy to patch's directory   
    //这里也是调用的上面的addPatch(File file)方法
    Patch patch = addPatch(dest);   
    if (patch != null) {     
        //加载补丁 
        loadPatch(patch);   
    }
}

好了,重点的方法终于要来了~激动么?来看patchManager.loadPatch();方法:

/** 
* load patch,call when application start 
*  
*/
public void loadPatch() {   
    mLoaders.put("*", mContext.getClassLoader());// wildcard 
    Set<String> patchNames;   
    List<String> classes;   
    for (Patch patch : mPatchs) {      
        patchNames = patch.getPatchNames();      
        for (String patchName : patchNames) {        
            //获取补丁内Class的集合 
            classes = patch.getClasses(patchName);   
            //重点方法:修复的方法      
            mAndFixManager.fix(patch.getFile(), mContext.getClassLoader(),               classes);      
        }   
    }
}

源码里好几个loadPatch()重载的方法,这里只列出一个,其他接收参数和内部实现略有不同,但最终都去调用了mAndFixManger.fix(...)方法,而fix()方法开始是一堆的验证,文件校验之类的安全检查,在这就不贴了,最后调用了fixClass(Class<?> clazz, ClassLoader classLoader)方法,直接贴这个方法:

/** 
* fix class 
*  
* @param clazz 
*            class 
*/
private void fixClass(Class<?> clazz, ClassLoader classLoader) { 
    // 反射找到clazz中的所有方法
    Method[] methods = clazz.getDeclaredMethods();   
    //注解
    MethodReplace methodReplace;   
    String clz;   
    String meth;   
    for (Method method : methods) {    
        //遍历所有方法,找到有MethodReplace注解的方法,即需要替换的方法  
        methodReplace = method.getAnnotation(MethodReplace.class);      
        if (methodReplace == null)         
            continue;      
        clz = methodReplace.clazz();      
        meth = methodReplace.method();    
        //找到需要替换的方法后调用replaceMethod替换方法  
        if (!isEmpty(clz) && !isEmpty(meth)) {               
            replaceMethod(classLoader, clz, meth, method);      
        }   
    }
}

replaceMethod(ClassLoader classLoader, String clz, String meth, Method method)方法:

/** 
* replace method 
*  
* @param classLoader classloader 
* @param clz class 
* @param meth name of target method  
* @param method source method 
*/
private void replaceMethod(ClassLoader classLoader, String clz,String meth, Method method) {   
    try {      
        String key = clz + "@" + classLoader.toString(); 
        // 根据key,查找缓存中的数据,该缓存记录了已经被修复过的class对象。     
        Class<?> clazz = mFixedClass.get(key);      
        if (clazz == null) {// class not load     
            // initialize target class   
            //找不到则表示该class没有被修复过,则通过类加载器去加载。  
            Class<?> clzz = classLoader.loadClass(clz);  
            // 通过C层,改写accessFlags,把需要替换的类的所有方法(Field)改成了public         
            clazz = AndFix.initTargetClass(clzz);      
        }     
        if (clazz != null) {// initialize class OK    
            mFixedClass.put(key, clazz);         
            // 反射得到修复前老的Method对象    
            Method src = clazz.getDeclaredMethod(meth,method.getParameterTypes());         
            AndFix.addReplaceMethod(src, method);     
         }   
    } catch (Exception e) {      
        Log.e(TAG, "replaceMethod", e);   
    }
}

AndFix.addReplaceMethod(src,method)方法调用了native的replaceMethod()方法:

/** 
* replace method's body 
*  
* @param src 
*            source method 
* @param dest 
*            target method 
*  
*/
public static void addReplaceMethod(Method src, Method dest) {   
    try {      
        replaceMethod(src, dest);      
        initFields(dest.getDeclaringClass());   
    } catch (Throwable e) {     
        Log.e(TAG, "addReplaceMethod", e);   
    }
}

private static native void replaceMethod(Method dest, Method src);

Native层

接下来是Native层的分析,由于自己对c代码不是太了解,所以Native层分析来自从AndFix源码分析JNI Hook热修复原理

//andfix.cpp
static void replaceMethod(JNIEnv* env, jclass clazz, jobject src, jobject dest) { 
    if (isArt) { 
        art_replaceMethod(env, src, dest); 
    } else { 
        dalvik_replaceMethod(env, src, dest); 
    }
}

从代码看来Art和dalvik的处理逻辑不一样,这里只分析一下Art:

//art_method_replace.cpp
extern void __attribute__ ((visibility ("hidden"))) art_replaceMethod( 
                  JNIEnv* env, jobject src, jobject dest) { 
    if (apilevel > 22) { 
        replace_6_0(env, src, dest); 
    } else if (apilevel > 21) { 
        replace_5_1(env, src, dest); 
    } else { 
        replace_5_0(env, src, dest); 
    }
}

根据不同的API版本执行不同的方法,来看5.0的替换方法:

//art_method_replace_5_0.cpp
void replace_5_0(JNIEnv* env, jobject src, jobject dest) {
    // 通过jni.h中的FromReflectedMethod方法反射得到源方法和替换方法的ArtMethod指针(ArtMethod的数据结构定义在头文件中,接下来会分析)
     art::mirror::ArtMethod* smeth = 
                  (art::mirror::ArtMethod*) env->FromReflectedMethod(src);

    art::mirror::ArtMethod* dmeth = 
                  (art::mirror::ArtMethod*) env->FromReflectedMethod(dest);

    // 替换方法所在类的类加载器 
    dmeth->declaring_class_->class_loader_ = 
                  smeth->declaring_class_->class_loader_; //for plugin classloader
    // 替换用于检查递归调用<clinit>的线程id 
    dmeth->declaring_class_->clinit_thread_id_ = 
                  smeth->declaring_class_->clinit_thread_id_;
    // 把目标方法所在类的初始化状态值设置成源方法的状态值-1  
    dmeth->declaring_class_->status_ = smeth->declaring_class_->status_-1;

// 把原方法的各种属性都改成补丁方法的 
    smeth->declaring_class_ = dmeth->declaring_class_; 
    smeth->access_flags_ = dmeth->access_flags_; 
    smeth->frame_size_in_bytes_ = dmeth->frame_size_in_bytes_;       
    smeth->dex_cache_initialized_static_storage_ = dmeth->dex_cache_initialized_static_storage_; 
    smeth->dex_cache_resolved_types_ = dmeth->dex_cache_resolved_types_; 
    smeth->dex_cache_resolved_methods_ = dmeth->dex_cache_resolved_methods_; 
    smeth->vmap_table_ = dmeth->vmap_table_; 
    smeth->core_spill_mask_ = dmeth->core_spill_mask_; 
    smeth->fp_spill_mask_ = dmeth->fp_spill_mask_; 
    smeth->mapping_table_ = dmeth->mapping_table_; 
    smeth->code_item_offset_ = dmeth->code_item_offset_;

    // 最重要的两个方法指针替换,下面两个entry_point指针代表了ART运行时执行方法的两种模式(compiled_code,interpreter),Andfix根据方法不同的调用机制通过这两个指针做方法替换 
    //方法执行方式为本地机器指令的指针入口 
    smeth->entry_point_from_compiled_code_ = dmeth->entry_point_from_compiled_code_;

    // 方法执行方式为解释执行的指针入口 
    smeth->entry_point_from_interpreter_ = dmeth->entry_point_from_interpreter_;

    smeth->native_method_ = dmeth->native_method_; 
    smeth->method_index_ = dmeth->method_index_; 
    smeth->method_dex_index_ = dmeth->method_dex_index_; 

    LOGD("replace_5_0: %d , %d", smeth->entry_point_from_compiled_code_, dmeth->entry_point_from_compiled_code_);
}

在头文件art_5_0.h中找到了ArtMethod的定义,也看到了上面代码中替换的所有变量的定义

// art_5_0.h
class ArtMethod: public Object {
public: 
    // Field order required by test "ValidateFieldOrderOfJavaCppUnionClasses". 
    // The class we are a part of 
    Class* declaring_class_; 

    // short cuts to declaring_class_->dex_cache_ member for fast compiled code access 
    void* dex_cache_initialized_static_storage_; 

    // short cuts to declaring_class_->dex_cache_ member for fast compiled code access 
    void* dex_cache_resolved_methods_; 

    // short cuts to declaring_class_->dex_cache_ member for fast compiled code access 
    void* dex_cache_resolved_types_; 

    // short cuts to declaring_class_->dex_cache_ member for fast compiled code access 
    void* dex_cache_strings_; 

    // Access flags; low 16 bits are defined by spec. 
    uint32_t access_flags_; 

    // Offset to the CodeItem. 
    uint32_t code_item_offset_; 

    // Architecture-dependent register spill mask 
    uint32_t core_spill_mask_; 

    // compiled_code调用方式,本地机器指令入口 
    // Compiled code associated with this method for callers from managed code. 
    // May be compiled managed code or a bridge for invoking a native method. 
    // TODO: Break apart this into portable and quick. const void* entry_point_from_compiled_code_; 

    // 通过interpreter方式调用方法 解释执行入口 
    // Called by the interpreter to execute this method. 
    void* entry_point_from_interpreter_; 

    // Architecture-dependent register spill mask 
    uint32_t fp_spill_mask_; 

    // Total size in bytes of the frame 
    size_t frame_size_in_bytes_; 

    // Garbage collection map of native PC offsets (quick) or dex PCs     (portable) to reference bitmaps. 
    const uint8_t* gc_map_; 

    // Mapping from native pc to dex pc 
    const uint32_t* mapping_table_; 

    // Index into method_ids of the dex file associated with this method 
    uint32_t method_dex_index_; 

    // For concrete virtual methods, this is the offset of the method in Class::vtable_. 
    // 
    // For abstract methods in an interface class, this is the offset of the method in 
    // "iftable_->Get(n)->GetMethodArray()". 
    // 
    // For static and direct methods this is the index in the direct methods table. 
    uint32_t method_index_; 

    // The target native method registered with this method 
    const void* native_method_; 

    // When a register is promoted into a register, the spill mask holds which registers hold dex 
    // registers. The first promoted register's corresponding dex register is vmap_table_[1], the Nth 
    // is vmap_table_[N]. vmap_table_[0] holds the length of the table.     
    const uint16_t* vmap_table_; 

    static void* java_lang_reflect_ArtMethod_;
    };
}

代码就分析这里,通过代码我们来总结一下:

  • java层:实现加载补丁文件,安全验证等操作,然后根据补丁中的注解找到将要替换的方法然后交给native层去处理替换方法的操作。
  • native层:利用java hook的技术来替换要修复的方法

so...so...so,AndFix原理就是:在Native层使用指针替换的方式替换bug方法,以达到修复bug的目的。

微信热补丁原理

微信的原理详细见这篇文章:微信Android热补丁实践演进之路

Eclipse使用HotFix

Eclipse上使用HotFix,这个问题我研究了一个多星期,一开始的思路是使用QQ空间的方案,但是在插桩那一步卡住了~不知道要怎么注入代码(如果有大神实现了,可以留言,小弟感激不尽),后来又去研究AndFix,果然没让我失望,终于在eclipse上实现了AndFix的热补丁。

下面来说一下具体的实现步骤:

  1. 下载andfix-0.4.0.aar 0.4.0版本的aar文件到本地,然后将文件的扩展名改为zip,用压缩文件打开
  2. 因为其他文件夹都是空的,我们只需要将jni文件夹的so文件和classes.jar(可以改下名字)导入到libs下面
  3. 按照AndFixgithub上的使用教程,集成api就可以了

目前eclipse只写了demo,混淆还未测试,机型也未做测试。

RoccoFix使用问题

附上一个RoccoFix使用demo,里面有使用步骤的视频,更加可视化,而且地址里面有很多问题的解决办法,demo中还有在线补丁流程思路。地址:https://github.com/shoyu666/derocoodemo

UPDATE

今天看到了这篇文章Android Patch 方案与持续交付,觉得很吊的样子~希望能开源出来。

参考

安卓App热补丁动态修复技术介绍
Android dex分包方案
Android 热补丁动态修复框架小结
Android热补丁动态修复技术(一):从Dex分包原理到热补丁
Android热补丁动态修复技术(二):实战!CLASS_ISPREVERIFIED问题!
Android热补丁动态修复技术(三)—— 使用Javassist注入字节码,完成热补丁框架雏形(可使用)
Android热补丁动态修复技术(四):自动化生成补丁——解决混淆问题
AndFix使用说明
向每一个错误致敬—— AndFix学习记录
微信Android热补丁实践演进之路
Android热补丁之AndFix原理解析
各大热补丁方案分析和比较
从AndFix源码分析JNI Hook热修复原理
Android Patch 方案与持续交付
QFix探索之路——手Q热补丁轻量级方案

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

推荐阅读更多精彩内容