14.AndFix热修复的使用和源码分析(客户端修复逻辑)

使用方法:

1.下载AndFix源码:https://github.com/alibaba/AndFix
2.生成两个apk文件,一个是含有bug的old.apk 一个是修复之后的new.apk
3.进入AndFix目录中的tools文件夹找到apkpatch工具,这个工具负责生成差分包,将它解压
4.在命令行中进入解压后的文件夹,我的这里是这样的C:\Users\renzhenming\Desktop\AndFix-master\tools\apkpatch-1.0.3
5.使用命令生成差分包

apkpatch -f <new> -t <old> -o <output> -k <keystore> -p <***> -a <alias> -e <***>

 -a,--alias <alias>     keystore entry alias.
 -e,--epassword <***>   keystore entry password.
 -f,--from <loc>        new Apk file path.
 -k,--keystore <loc>    keystore path.
 -n,--name <name>       patch name.
 -o,--out <dir>         output dir.
 -p,--kpassword <***>   keystore password.
 -t,--to <loc>          old Apk file path.

我的命令如下,注意每个命令参数代表的含义

apkpatch -f C:\Users\renzhenming\Desktop\after.apk 
-t C:\Users\renzhenming\Desktop\before.apk -o C:\Users\renzhenming\Desktop\patch 
-k C:\Users\renzhenming\Desktop\renzhenming.jks -p renzhenming -a renzhenming 
-e renzhenming

可以看到执行后还打印了这句话,表示发生异常的类

add modified Method:V  bump(Landroid/view/View;)  in Class:Lcom/app/rzm/test/TestFixDexActivity;

6.客户端进行合称,修复问题。运行apk,你会发现bug消失了,操作立即生效,无需重启

        //初始化阿里热修复
        mPatchManger = new PatchManager(this);
        //获取当前应用版本
        mPatchManger.init(AppUtils.getVersionName(this));
        mPatchManger.loadPatch();

        //获取下载到的patch包
        File patchFile = new File(Environment.getExternalStorageDirectory().getAbsolutePath(),"fix.apatch");
        if (patchFile != null){
            try {
                mPatchManger.addPatch(patchFile.getAbsolutePath());
                Toast.makeText(this,"AndFix修复成功",Toast.LENGTH_SHORT).show();
            } catch (IOException e) {
                e.printStackTrace();
                Toast.makeText(this,"AndFix修复失败",Toast.LENGTH_SHORT).show();
            }
        }

7.另外,如果你的项目是多成员开发,可能存在每个小组都修复自己的问题生成了多个差分包,那么你可以通过命令将多个差分包合并成一个

apkpatch -m <apatch_path...> -o <output> -k <keystore> -p <***> -a <alias> -e <***>
 -a,--alias <alias>     keystore entry alias.
 -e,--epassword <***>   keystore entry password.
 -k,--keystore <loc>    keystore path.
 -m,--merge <loc...>    path of .apatch files.
 -n,--name <name>       patch name.
 -o,--out <dir>         output dir.
 -p,--kpassword <***>   keystore password.
AndFix的局限性

AndFix支持ARM和X86平台,支持Dalvik虚拟机和Art虚拟机,但是Android 版本只能支持到7.0,对于目前的8.0版本不提供支持,而且从GitHub上可以看到,已经停止维护将近两年,所以如果要实现热修复,AndFix已经不再是首选方案,目前我们公司在使用腾讯的Tinker,但是我觉得还是有必要对AndFix原理有一个了解

使用时需要注意

1.生成之后一定要测试,确保未知问题存在
2.尽量不要分包,不要分多个dex
3.混淆时,注意native方法和注解不要混淆

-keep class * extends java.lang.annotation.Annotation
-keepclasseswithmembernames class * {
    native <methods>;
}

4.如果生成之后要加固,差分包一定要在加固之前生成

源码分析

AndFix实现热修复的流程如下


AndFix热修复实现流程.png

AndFix热修复包括两个核心点,第一对发生bug的apk和修复后的apk进行分析,通过AndFix内置的一个工具生成差分包,客户端下载到差分包后将差分包和本地包合并实现修复bug的目的。差分包和问题包合并的原理是方法的替换,差分包会将发生bug的方法添加注解,通过这个注解找到问题所在,然后在运行的时候运行正确的方法,从而绕过bug


AndFix 实现原理.png

那么接下来我们具体来看代码中的实现

PatchManager patchManger = new PatchManager(this);
初始化一个AndFixManager,一个目录mPatchDir 和两个集合,你就把它们分别当作Set和Map集合就行,这两个集合的内部原理使用优点不是今天的内容

    public PatchManager(Context context) {
        mContext = context;
        mAndFixManager = new AndFixManager(mContext);
        mPatchDir = new File(mContext.getFilesDir(), DIR);
        mPatchs = new ConcurrentSkipListSet<Patch>();
        mLoaders = new ConcurrentHashMap<String, ClassLoader>();
    }

我们看看AndFixManager初始化的时候做了什么,可以看到主要就是检测当前app是否支持AndFix的时候,如果不支持也就不了了之了,而且也不会抛个异常提醒你,只会打个log,这一点我认为做的并不完善,提醒的不到位

    public AndFixManager(Context context) {
        mContext = context;
        //检测系统,版本等是否支持AndFix
        mSupport = Compat.isSupport();
        if (mSupport) {
            //签名相关的一些检测
            mSecurityChecker = new SecurityChecker(mContext);
            //初始化一个目录,和刚才初始化的目录在同一个位置下 file文件夹
            ,= 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;
            }
        }
    }
    //在这个方法中我们可以看到AndFix所支持的条件
    //1.支持Android系统,不支持阿里云系统
    //2.SDK版本从支持大于等于8小于等于24,也就是Android2.3到7.0
    // setup方法是native方法实现的,我们可以看看C++端的代码,在andfix.cpp中
    public static synchronized boolean isSupport() {
        if (isChecked)
            return isSupport;

        isChecked = true;
        // not support alibaba's YunOs
        if (!isYunOS() && AndFix.setup() && isSupportSDKVersion()) {
            isSupport = true;
        }

        if (inBlackList()) {
            isSupport = false;
        }

        return isSupport;
    }

andfix.cpp
传入的参数isart表示是否是Art虚拟机,apilevel是当前SDK版本,可以看到,AndFix不但支持art虚拟级,也支持dalvik虚拟级,但是art是无条件的支持,dalvik却是在一定限制下才能支持的,具体我们看代码

static jboolean setup(JNIEnv* env, jclass clazz, jboolean isart,
        jint apilevel) {
    isArt = isart;
    LOGD("vm is: %s , apilevel is: %i", (isArt ? "art" : "dalvik"),
            (int )apilevel);
    if (isArt) {
        return art_setup(env, (int) apilevel);
    } else {
        return dalvik_setup(env, (int) apilevel);
    }
}
//在art_method_replace.cpp中找到这个方法,可以看到直接返回的true
extern jboolean __attribute__ ((visibility ("hidden"))) art_setup(JNIEnv* env,
        int level) {
    apilevel = level;
    return JNI_TRUE;
}

//dalvik虚拟机的初始化方法相对复杂,我们在dalvik_method_replace.cpp中找到这个方法
extern jboolean __attribute__ ((visibility ("hidden"))) dalvik_setup(
        JNIEnv* env, int apilevel) {
    //加载系统的libdvm.so库,如果没有加载到,则不支持
    //libdvm.so是Dalvik的库文件之一,位于system/lib/下,如果是art虚拟机
    //则没有libdvm.so而是libart.so,从4.4开始,已经开始使用art虚拟机了
    //dlopen,打开一个库,并为使用该库做些准备,通过dlopen动态
    //的打开动态库,动态库加载完成后,返回一个句柄,然后把句柄传给
    //dlsym定位到你需要执行的函数指针,函数指针拿到了,就可以使用
    //这个函数了。
    
    void* dvm_hand = dlopen("libdvm.so", RTLD_NOW);
    if (dvm_hand) {
        //dlsym,在打开的库中查找符号的值,根据版本不同,查找不同的值
        dvmDecodeIndirectRef_fnPtr = dvm_dlsym(dvm_hand,
                apilevel > 10 ?
                        "_Z20dvmDecodeIndirectRefP6ThreadP8_jobject" :
                        "dvmDecodeIndirectRef");
        //如果没有找到,则返回false
        if (!dvmDecodeIndirectRef_fnPtr) {
            return JNI_FALSE;
        }
        //继续找另一个值
        dvmThreadSelf_fnPtr = dvm_dlsym(dvm_hand,
                apilevel > 10 ? "_Z13dvmThreadSelfv" : "dvmThreadSelf");
        //找不到则返回false
        if (!dvmThreadSelf_fnPtr) {
            return JNI_FALSE;
        }
        jclass clazz = env->FindClass("java/lang/reflect/Method");
        jClassMethod = env->GetMethodID(clazz, "getDeclaringClass",
                        "()Ljava/lang/Class;");

        return JNI_TRUE;
    } else {
        return JNI_FALSE;
    }
}

所以支持dalvik虚拟机的条件如下:
1.系统文件中有libdvm.so这个库
2.apilevel > 10的状态下,可以从libdvm.so中找到_Z20dvmDecodeIndirectRefP6ThreadP8_jobject和_Z13dvmThreadSelfv这两个符号
3.apilevel <= 10的状态下,可以从libdvm.so中找到dvmDecodeIndirectRef和dvmThreadSelf这两个符号

言归正穿,我们继续往下看代码

mPatchManger.init(AppUtils.getVersionName(this));

public void init(String appVersion) {
        //file/apatch文件夹创建失败则返回
        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 sp = mContext.getSharedPreferences(SP_NAME,
                Context.MODE_PRIVATE);
        String ver = sp.getString(SP_VERSION, null);
        //判断当前apk的版本和差分包的版本是否相同,如果不同则删除差分包
        //热修复生成的差分包版本要和当前修复的apk一致
        if (ver == null || !ver.equalsIgnoreCase(appVersion)) {
            cleanPatch();
            sp.edit().putString(SP_VERSION, appVersion).commit();
        } else {
            initPatchs();
        }
    }

接下来,如果情况正常,那么遍历file/apatch文件夹下的差分包,将每一个差分包文件封装成Patch对象加入mPatchs集合中

    private void initPatchs() {
        File[] files = mPatchDir.listFiles();
        for (File file : files) {
            addPatch(file);
        }
    }

    private Patch addPatch(File file) {
        Patch patch = null;
        if (file.getName().endsWith(SUFFIX)) {
            try {
                patch = new Patch(file);
                mPatchs.add(patch);
            } catch (IOException e) {
                Log.e(TAG, "addPatch", e);
            }
        }
        return patch;
    }

所以init方法只是将差分包存入集合中,还没有开始修复。然后开始loadPatch

    public void loadPatch() {
        //存储ClassLoader
        mLoaders.put("*", mContext.getClassLoader());// wildcard
        Set<String> patchNames;
        List<String> classes;
        //遍历差分包集合
        for (Patch patch : mPatchs) {
            patchNames = patch.getPatchNames();
            for (String patchName : patchNames) {
                //从patch对象中获取到一个集合然后开始fix
                classes = patch.getClasses(patchName);
                mAndFixManager.fix(patch.getFile(), mContext.getClassLoader(),
                        classes);
            }
        }
    }

从patch中获取到的集合中是什么,为了搞清楚这个问题,我们需要回过头看看封装patch的时候做了什么

    public Patch(File file) throws IOException {
        mFile = file;
        init();
    }

    @SuppressWarnings("deprecation")
    private void init() throws IOException {
        JarFile jarFile = null;
        InputStream inputStream = null;
        try {
            jarFile = new JarFile(mFile);
            JarEntry entry = jarFile.getJarEntry(ENTRY_NAME);
            inputStream = jarFile.getInputStream(entry);
            Manifest manifest = new Manifest(inputStream);
            Attributes main = manifest.getMainAttributes();
            mName = main.getValue(PATCH_NAME);
            mTime = new Date(main.getValue(CREATED_TIME));

            mClassesMap = new HashMap<String, List<String>>();
            Attributes.Name attrName;
            String name;
            List<String> strings;
            //Manifest是以键值对的形式存储了差分包的信息
            for (Iterator<?> it = main.keySet().iterator(); it.hasNext();) {
                attrName = (Attributes.Name) it.next();
                name = attrName.toString();
                if (name.endsWith(CLASSES)) {
                    strings = Arrays.asList(main.getValue(attrName).split(","));
                    if (name.equalsIgnoreCase(PATCH_CLASSES)) {
                        mClassesMap.put(mName, strings);
                    } else {
                        mClassesMap.put(
                                name.trim().substring(0, name.length() - 8),// remove
                                                                            // "-Classes"
                                strings);
                    }
                }
            }
        } finally {
            if (jarFile != null) {
                jarFile.close();
            }
            if (inputStream != null) {
                inputStream.close();
            }
        }

    }

fix方法从加载到的差分包dex文件中获取到加了注解的class,差分包生成时会给发生bug的类生成一个xxx_CF类名的类,在这个类中给发生bug的方法添加了注解,比如@MethodReplace(clazz="com.app.rzm.test.TestFixDexActivity", method="bump"),生成差分包的逻辑后边再说。这里获取到这个类后在执行fixClass方法

    public synchronized void fix(File file, ClassLoader classLoader,
            List<String> classes) {
        if (!mSupport) {
            return;
        }
        //签名校验之类的
        if (!mSecurityChecker.verifyApk(file)) {// security check fail
            return;
        }

        try {
            File optfile = new File(mOptDir, file.getName());
            boolean saveFingerprint = true;
            if (optfile.exists()) {
                //如果文件夹下已经存在这个文件,那么进行校验,防止被攻击
                // need to verify fingerprint when the optimize file exist,
                // prevent someone attack on jailbreak device with
                // Vulnerability-Parasyte.
                // btw:exaggerated android Vulnerability-Parasyte
                // http://secauo.com/Exaggerated-Android-Vulnerability-Parasyte.html
                if (mSecurityChecker.verifyOpt(optfile)) {
                    saveFingerprint = false;
                } else if (!optfile.delete()) {
                    return;
                }
            }
            //打开file这个dex文件,并把它写入到optfile这个文件中
            final DexFile dexFile = DexFile.loadDex(file.getAbsolutePath(),
                    optfile.getAbsolutePath(), Context.MODE_PRIVATE);

            if (saveFingerprint) {
                mSecurityChecker.saveOptSig(optfile);
            }

            ClassLoader patchClassLoader = new ClassLoader(classLoader) {
                @Override
                protected Class<?> findClass(String className)
                        throws ClassNotFoundException {
                    Class<?> clazz = dexFile.loadClass(className, this);
                    if (clazz == null
                            && className.startsWith("com.alipay.euler.andfix")) {
                        return Class.forName(className);// annotation’s class
                                                        // not found
                    }
                    if (clazz == null) {
                        throw new ClassNotFoundException(className);
                    }
                    return clazz;
                }
            };
            Enumeration<String> entrys = dexFile.entries();
            Class<?> clazz = null;
            while (entrys.hasMoreElements()) {
                String entry = entrys.nextElement();
                if (classes != null && !classes.contains(entry)) {
                    continue;// skip, not need fix
                }
                clazz = dexFile.loadClass(entry, patchClassLoader);
                if (clazz != null) {
                    fixClass(clazz, classLoader);
                }
            }
        } catch (IOException e) {
            Log.e(TAG, "pacth", e);
        }
    }

这里会从bug类中获取到添加的注解,从这个注解上可以得到当前类的完整类名和崩溃发生的方法的方法名,然后开始执行replaceMethod

     /**
     * fix class
     * 
     * @param clazz
     *            class
     */
    private void fixClass(Class<?> clazz, ClassLoader classLoader) {
        Method[] methods = clazz.getDeclaredMethods();
        MethodReplace methodReplace;
        String clz;
        String meth;
        for (Method method : methods) {
            methodReplace = method.getAnnotation(MethodReplace.class);
            if (methodReplace == null)
                continue;
            clz = methodReplace.clazz();
            meth = methodReplace.method();
            if (!isEmpty(clz) && !isEmpty(meth)) {
                replaceMethod(classLoader, clz, meth, method);
            }
        }
    }
    private void replaceMethod(ClassLoader classLoader, String clz,
            String meth, Method method) {
        try {
            String key = clz + "@" + classLoader.toString();
            Class<?> clazz = mFixedClass.get(key);
            if (clazz == null) {// class not load
                Class<?> clzz = classLoader.loadClass(clz);
                // initialize target class
                //对class进行的预处理,下边会去看jni的实现
                clazz = AndFix.initTargetClass(clzz);
            }
            if (clazz != null) {// initialize class OK
                //以ClassLoader的处理后字符串为key,以class为value存储
                mFixedClass.put(key, clazz);
                Method src = clazz.getDeclaredMethod(meth,
                        method.getParameterTypes());
                //jni层开始替换错误的方法
                AndFix.addReplaceMethod(src, method);
            }
        } catch (Exception e) {
            Log.e(TAG, "replaceMethod", e);
        }
    }

接下来我们看两个涉及到jni层的处理,一个是AndFix.initTargetClass(clzz),看它对class做了什么,一个是AndFix.addReplaceMethod(src, method)看它是如何修正方法的.可以看到initTargetClass是对当前class中的Field进行的处理

    public static Class<?> initTargetClass(Class<?> clazz) {
        try {
            Class<?> targetClazz = Class.forName(clazz.getName(), true,
                    clazz.getClassLoader());

            initFields(targetClazz);
            return targetClazz;
        } catch (Exception e) {
            Log.e(TAG, "initTargetClass", e);
        }
        return null;
    }
    private static void initFields(Class<?> clazz) {
        Field[] srcFields = clazz.getDeclaredFields();
        for (Field srcField : srcFields) {
            Log.d(TAG, "modify " + clazz.getName() + "." + srcField.getName()
                    + " flag:");
            setFieldFlag(srcField);
        }
    }
    private static native void setFieldFlag(Field field);

在andfix.cpp中找到setFieldFlag对应的方法,可以看到这里也分了art和dalvik虚拟机的两种不同处理方式

static void setFieldFlag(JNIEnv* env, jclass clazz, jobject field) {
    if (isArt) {
        art_setFieldFlag(env, field);
    } else {
        dalvik_setFieldFlag(env, field);
    }
}

先看art的处理,可以看到这里又区分了不同的sdk版本,我们以>23为例看一下

extern void __attribute__ ((visibility ("hidden"))) art_setFieldFlag(
        JNIEnv* env, jobject field) {
    if (apilevel > 23) {
        setFieldFlag_7_0(env, field);
    } else if (apilevel > 22) {
        setFieldFlag_6_0(env, field);
    } else if (apilevel > 21) {
        setFieldFlag_5_1(env, field);
    } else  if (apilevel > 19) {
        setFieldFlag_5_0(env, field);
    }else{
        setFieldFlag_4_4(env, field);
    }
}

对field的access_flags_ 变量进行了处理,这个操作不影响我们看原理,所以扫一眼即可,重点关注addReplaceMethod这个方法,这个方法中两个参数,一个是修复后的正确的方法Method对象,一个是有bug的Method对象

void setFieldFlag_7_0(JNIEnv* env, jobject field) {
    art::mirror::ArtField* artField =
            (art::mirror::ArtField*) env->FromReflectedField(field);
    artField->access_flags_ = artField->access_flags_ & (~0x0002) | 0x0001;
    LOGD("setFieldFlag_7_0: %d ", artField->access_flags_);
}

还是直接以7.0为例来看

void replace_7_0(JNIEnv* env, jobject src, jobject dest) {
    art::mirror::ArtMethod* smeth =
            (art::mirror::ArtMethod*) env->FromReflectedMethod(src);

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

//  reinterpret_cast<art::mirror::Class*>(smeth->declaring_class_)->class_loader_ =
//          reinterpret_cast<art::mirror::Class*>(dmeth->declaring_class_)->class_loader_; //for plugin classloader
    reinterpret_cast<art::mirror::Class*>(dmeth->declaring_class_)->clinit_thread_id_ =
            reinterpret_cast<art::mirror::Class*>(smeth->declaring_class_)->clinit_thread_id_;
    reinterpret_cast<art::mirror::Class*>(dmeth->declaring_class_)->status_ =
            reinterpret_cast<art::mirror::Class*>(smeth->declaring_class_)->status_ -1;
    //for reflection invoke
    reinterpret_cast<art::mirror::Class*>(dmeth->declaring_class_)->super_class_ = 0;

    //可以看到这里进行了一系列的指针操作,让修复后的方法和发生
    //bug的类发生了关联,也就是正确的方法替换了错误的方法,当代码
    //执行时就会绕过错误的方法从而实现修复的目的        
    smeth->declaring_class_ = dmeth->declaring_class_;
    smeth->access_flags_ = dmeth->access_flags_  | 0x0001;
    smeth->dex_code_item_offset_ = dmeth->dex_code_item_offset_;
    smeth->dex_method_index_ = dmeth->dex_method_index_;
    smeth->method_index_ = dmeth->method_index_;
    smeth->hotness_count_ = dmeth->hotness_count_;

    smeth->ptr_sized_fields_.dex_cache_resolved_methods_ =
            dmeth->ptr_sized_fields_.dex_cache_resolved_methods_;
    smeth->ptr_sized_fields_.dex_cache_resolved_types_ =
            dmeth->ptr_sized_fields_.dex_cache_resolved_types_;

    smeth->ptr_sized_fields_.entry_point_from_jni_ =
            dmeth->ptr_sized_fields_.entry_point_from_jni_;
    smeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_ =
            dmeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_;

    LOGD("replace_7_0: %d , %d",
            smeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_,
            dmeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_);

}

看到这里基本上阿里热修复的客户端操作我们看完了

总结一下

1.客户端下载到差分包后会从差分包中加载到发生bug的类
2.从这个类中找到添加了注解的方法,这个方法就是异常的方法
3.将正确的方法通过指针的变换,替换掉发生异常的方法实现修复的目的

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

推荐阅读更多精彩内容

  • 前言 热修复也叫热更新,又叫做动态加载、动态修复、动态更新,是指不通过重新安装新的APK安装包的情况下修复一些线上...
    小楠总阅读 1,610评论 11 10
  • “你们今天打球嘛!打球的话我给你们去买……卧槽!!!”下课铃一响,约莫四五个男孩子像离弦的箭一样冲出后门,快得仿...
    倒地睿阅读 266评论 0 0
  • 今天,工会发三八节礼物。社会对女性的尊重在节日有了最为明显的表示。 经常说女人顶着半边天,所谓巾帼不让须眉。估计很...
    fe4e49a813e1阅读 261评论 0 0
  • 大雨的时候 总觉得街上的霓虹灯更加亮了 冒雨下楼买了汽水 曲曲折折地回到家 打开 窗外的路灯下 走过一个熟悉的身影...
    长马阅读 315评论 0 2