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閱讀 230,825評論 6 546
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,814評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,980評論 0 384
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 64,064評論 1 319
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,779評論 6 414
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 56,109評論 1 330
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 44,099評論 3 450
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,287評論 0 291
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,799評論 1 338
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,515評論 3 361
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,750評論 1 375
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,221評論 5 365
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,933評論 3 351
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,327評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,667評論 1 296
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,492評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,703評論 2 380

推薦閱讀更多精彩內容