坑爹的MultiDex

一、问题

1、65535 问题

当 App 的功能越来越丰富、使用的库越来越多时,其包含的 Java 方法总数也越来越多,这时候就会出现 65535 问题。
在构建 apk 的时候限制了一个 dex 文件能包含的方法数,其总数不能超过 65535(则 64K,1K = 2^10 = 1024 , 64 * 1024 = 65535)。MultiDex, 顾名思义,是指多 dex 实现,大多数 App,解压其 apk 后,一般只有一个 classes.dex 文件,采用 MultiDex 的 App 解压后可以看到有 classes.dex,classes2.dex,… classes(N).dex,这样每个 dex 都可以最大承载 64k 个方法,很大限度地缓解了单 dex 方法数限制。

2、LinearAlloc问题

现在这个问题已经不常见了,它多发生在 2.x 版本的设备上,安装时会提示 INSTALL_FAILED_DEXOPT。这个问题发生在安装期间,在使用 Dalvik 虚拟机的设备上安装 APK 时,会通过 DexOpt 工具将 Dex 文件优化为 odex 文件,即 Optimized Dex,这样可以提高执行效率 (不同的设备需要不同的 odex 格式,所以这个过程只能安装 apk 后进行)。
LinearAlloc 是一个固定大小的缓冲区,dexopt 使用 LinearAlloc 来存储应用的方法信息,在 Android 的不同版本中有 4M/5M/8M/16M 等不同大小,目前主流 4.x 系统上都已到 8MB 或 16MB,但是在 Gingerbread 或以下系统(2.2 和 2.3)LinearAlloc 分配空间只有 5M 大小的。当应用的方法信息过多导致超出缓冲区大小时,会造成 dexopt 崩溃,造成 INSTALL_FAILED_DEXOPT 错误。

二、启用MultiDex解决问题

1、配置 build.gradle

android {
    compileSdkVersion 21
    buildToolsVersion "21.1.0"

    defaultConfig {
        ...
        minSdkVersion 14
        targetSdkVersion 21
        ...

        multiDexEnabled true // Enable MultiDex.
    }
    ...
}

dependencies {
  compile 'com.android.support:multidex:1.0.1'
}

2、在代码里启动 MultiDex

在 Java 代码里启动 MultiDex,有两种方式可以搞定。
方式一,使用 MultiDexApplication

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="xxx">
    <application
        ...
        android:name="android.support.multidex.MultiDexApplication">
        ...
    </application>
</manifest>

方式二,在自己的 Application#attachBaseContext(Context) 方法里添加以下代码。

public class MyApplication extends Application {
    protected void attachBaseContext(Context base) {
        super.attachBaseContext(base);
        MultiDex.install(this); // Enable MultiDex.
    }
}

三、实现原理


实现原理.png

MultiDex的入口是MultiDex.install(Context),先从这里入手

1、MultiDex.install

public static void install(Context context) {
    // 经过一系列检查
    try {
        ApplicationInfo applicationInfo = getApplicationInfo(context);
        if (applicationInfo == null) {
            Log.i(TAG, "No ApplicationInfo available, i.e. running on a test Context:"
                  + " MultiDex support library is disabled.");
            return;
        }
        // 调用真正进行dex install的方法
        doInstallation(context,
                       new File(applicationInfo.sourceDir),
                       new File(applicationInfo.dataDir),
                       CODE_CACHE_SECONDARY_FOLDER_NAME,
                       NO_KEY_PREFIX,
                       true);

    } catch (Exception e) {
        Log.e(TAG, "MultiDex installation failure", e);
        throw new RuntimeException("MultiDex installation failed (" + e.getMessage() + ").");
    }
}

经过一系列检查之后调用doInstallation发方法开始真正的dex install操作

private static void doInstallation(Context mainContext, File sourceApk, File dataDir,
                                   String secondaryFolderName, String prefsKeyPrefix,
                                   boolean reinstallOnPatchRecoverableException) throws IOException {
    //保证方法仅调用一次,如果这个方法已经调用过一次,就不能再调用了。
    synchronized (installedApk) {
        if (installedApk.contains(sourceApk)) {
            return;
        }
        installedApk.add(sourceApk);
        // 如果当前Android版本>20已经自身支持了MultiDex,依然可以执行MultiDex操作,但是会有警告。
        if (Build.VERSION.SDK_INT > MAX_SUPPORTED_SDK_VERSION) {
            Log.w(TAG, "MultiDex is not guaranteed to work in SDK version "
                  + Build.VERSION.SDK_INT + ": SDK version higher than "
                  + MAX_SUPPORTED_SDK_VERSION + " should be backed by "
                  + "runtime with built-in multidex capabilty but it's not the "
                  + "case here: java.vm.version=\""
                  + System.getProperty("java.vm.version") + "\"");
        }
        // 获取当前的ClassLoader实例,后面要做的工作,就是把其他dex文件加载后,
        // 把其DexFile对象添加到这个ClassLoader里
        ClassLoader loader = getDexClassloader(mainContext);
        if (loader == null) {
            return;
        }

        try {
            // 清除旧的dex文件,这里不是清除上次加载的dex文件缓存。
            // 获取dex缓存目录是,会优先获取/data/data/${packageName}/code-cache作为缓存目录。
            // 如果获取失败,则使用/data/data/${packageName}/files/code-cache目录。
            // 这里清除的是第二个目录。
            clearOldDexDir(mainContext);
        } catch (Throwable t) {
            Log.w(TAG, "Something went wrong when trying to clear old MultiDex extraction, "
                  + "continuing without cleaning.", t);
        }
        //获取一个存放dex的目录,路径是"/data/data/${packageName}/code_cache/secondary-dexes",用来存放优化后的dex文件
        File dexDir = getDexDir(mainContext, dataDir, secondaryFolderName);
        // 使用MultiDexExtractor这个工具类把APK中的dex提取到dexDir目录中
        MultiDexExtractor extractor = new MultiDexExtractor(sourceApk, dexDir);
        IOException closeException = null;
        try {
            //返回的files集合有可能为空,表示没有secondaryDex
            //不强制重新加载,也就是说如果已经提取过了,可以直接从缓存目录中拿来使用,这么做速度比较快
            List<? extends File> files =
                extractor.load(mainContext, prefsKeyPrefix, false);
            try {
                // 如果提取的文件是有效的,就安装secondaryDex
                installSecondaryDexes(loader, dexDir, files);
            } catch (IOException e) {
                if (!reinstallOnPatchRecoverableException) {
                    throw e;
                }
                //如果提取出的文件是无效的,那么就强制重新加载,这么做的话速度就慢了一点,有一些IO开销
                files = extractor.load(mainContext, prefsKeyPrefix, true);
                installSecondaryDexes(loader, dexDir, files);
            }
        } finally {
            try {
                extractor.close();
            } catch (IOException e) {
                // Delay throw of close exception to ensure we don't override some exception
                // thrown during the try block.
                closeException = e;
            }
        }
        if (closeException != null) {
            throw closeException;
        }
    }
}

方法开始使用synchronized关键字保证方法仅调用一次,如果这个方法已经调用过一次,就不能再调用了。如果当前Android版本>20已经自身支持了MultiDex,依然可以执行MultiDex操作,但是会有警告。开始提取dex文件之前先调用 clearOldDexDir 清除旧的dex文件,这里不是清除上次加载的dex文件缓存。这里清除的文件目录是/data/data/${packageName}/files/code-cache (getDexDir 获取dex缓存目录是,会优先获取/data/data/${packageName}/code-cache作为缓存目录,如果获取失败,则使用/data/data/${packageName}/files/code-cache目录)。
使用MultiDexExtractor这个工具类把APK中的dex提取到dexDir目录中,MultiDexExtractor返回的files集合有可能为空,表示没有secondaryDex,
不强制重新加载,也就是说如果已经提取过了,可以直接从缓存目录中拿来使用,这么做速度比较快

2、提取Dex文件

再来看一下从APK文件中抽取出.dex文件的逻辑。下面是MultiDexExtractor的load()方法:

List<? extends File> load(Context context, String prefsKeyPrefix, boolean forceReload)
    throws IOException {
    //加上文件锁,防止多进程冲突。
    if (!cacheLock.isValid()) {
        throw new IllegalStateException("MultiDexExtractor was closed");
    }

    List<ExtractedDex> files;
    // sourceApk 路径为"/data/app/${packageName}-xxx/base.apk"
    // 先判断是否强制重新解压,这里第一次会优先使用已解压过的dex文件,如果加载失败就强制重新解压。
    // 此外,通过crc和文件修改时间,判断如果Apk文件已经被修改(覆盖安装),就会跳过缓存重新解压dex文件
    if (!forceReload && !isModified(context, sourceApk, sourceCrc, prefsKeyPrefix)) {
        try {
            // 加载缓存的dex文件
            files = loadExistingExtractions(context, prefsKeyPrefix);
        } catch (IOException ioe) {
            Log.w(TAG, "Failed to reload existing extracted secondary dex files,"
                  + " falling back to fresh extraction", ioe);
            // 加载失败的话重新解压,并保存解压出来的dex文件的信息。
            files = performExtractions();
            putStoredApkInfo(context, prefsKeyPrefix, getTimeStamp(sourceApk), sourceCrc,
                             files);
        }
    } else {
        if (forceReload) {
            Log.i(TAG, "Forced extraction must be performed.");
        } else {
            Log.i(TAG, "Detected that extraction must be performed.");
        }
        //重新解压,并保存解压出来的dex文件的信息。
        files = performExtractions();
        putStoredApkInfo(context, prefsKeyPrefix, getTimeStamp(sourceApk), sourceCrc,
                         files);
    }

    Log.i(TAG, "load found " + files.size() + " secondary dex files");
    return files;
}

这个过程主要是获取可以安装的dex文件列表,可以是上次解压出来的缓存文件,也可以是重新从Apk包里面提取出来的。需要注意的是,如果是重新解压,这里会有明显的耗时,而且解压出来的dex文件,会被压缩成.zip压缩包,压缩的过程也会有明显的耗时(这里压缩dex文件可能是为了节省空间)。
如果dex文件是重新解压出来的,则会保存dex文件的信息,包括解压的apk文件的crc值、修改时间以及dex文件的数目,以便下一次启动直接使用已经解压过的dex缓存文件,而不是每一次都重新解压。
根据前后顺序的话,App第一次运行的时候需要从APK中提取取dex文件,先来看一下MultiDexExtractor的performExtractions()方法:

private List<ExtractedDex> performExtractions() throws IOException {
    // 抽取出的dex文件名前缀是"${apkName}.classes"
    final String extractedFilePrefix = sourceApk.getName() + EXTRACTED_NAME_EXT;

    clearDexDir();

    List<ExtractedDex> files = new ArrayList<ExtractedDex>();

    final ZipFile apk = new ZipFile(sourceApk);
    try {

        int secondaryNumber = 2;
        
        // 获取"classes${secondaryNumber}.dex"格式的文件
        ZipEntry dexFile = apk.getEntry(DEX_PREFIX + secondaryNumber + DEX_SUFFIX);
        // 如果dexFile不为null就一直遍历
        while (dexFile != null) {
            // 抽取后的文件名是"${apkName}.classes${secondaryNumber}.zip"
            String fileName = extractedFilePrefix + secondaryNumber + EXTRACTED_SUFFIX;
            // 创建文件
            ExtractedDex extractedFile = new ExtractedDex(dexDir, fileName);
            // 添加到集合中
            files.add(extractedFile);

            Log.i(TAG, "Extraction is needed for file " + extractedFile);
            // 抽取过程中存在失败的可能,可以多次尝试,使用isExtractionSuccessful作为是否成功的标志
            int numAttempts = 0;
            boolean isExtractionSuccessful = false;
            while (numAttempts < MAX_EXTRACT_ATTEMPTS && !isExtractionSuccessful) {
                numAttempts++;

                // 抽出去apk中对应序号的dex文件,存放到extractedFile这个zip文件中,只包含它一个dex文件
                // extract方法就是一个IO操作
                extract(apk, dexFile, extractedFile, extractedFilePrefix);

                // 判断是够抽取成功
                try {
                    extractedFile.crc = getZipCrc(extractedFile);
                    isExtractionSuccessful = true;
                } catch (IOException e) {
                    isExtractionSuccessful = false;
                    Log.w(TAG, "Failed to read crc from " + extractedFile.getAbsolutePath(), e);
                }

                // Log size and crc of the extracted zip file
                Log.i(TAG, "Extraction " + (isExtractionSuccessful ? "succeeded" : "failed")
                      + " '" + extractedFile.getAbsolutePath() + "': length "
                      + extractedFile.length() + " - crc: " + extractedFile.crc);
                if (!isExtractionSuccessful) {
                    // Delete the extracted file
                    extractedFile.delete();
                    if (extractedFile.exists()) {
                        Log.w(TAG, "Failed to delete corrupted secondary dex '" +
                              extractedFile.getPath() + "'");
                    }
                }
            }
            if (!isExtractionSuccessful) {
                throw new IOException("Could not create zip file " +
                                      extractedFile.getAbsolutePath() + " for secondary dex (" +
                                      secondaryNumber + ")");
            }
            secondaryNumber++;
            dexFile = apk.getEntry(DEX_PREFIX + secondaryNumber + DEX_SUFFIX);
        }
    } finally {
        try {
            apk.close();
        } catch (IOException e) {
            Log.w(TAG, "Failed to close resource", e);
        }
    }

    return files;
}

当MultiDexExtractor的performExtractions()方法调用完毕的时候就把APK中所有的dex文件抽取出来,并以一定文件名格式的zip文件保存在缓存目录中。然后再把一些关键的信息通过调用putStoredApkInfo(Context context, long timeStamp, long crc, int totalDexNumber)方法保存到SP中。
当APK之后再启动的时候就会从缓存目录中去加载已经抽取过的dex文件。接着来看一下MultiDexExtractor的loadExistingExtractions()方法:

private List<ExtractedDex> loadExistingExtractions(
    Context context,
    String prefsKeyPrefix)
    throws IOException {
    Log.i(TAG, "loading existing secondary dex files");
    // 抽取出的dex文件名前缀是"${apkName}.classes"
    final String extractedFilePrefix = sourceApk.getName() + EXTRACTED_NAME_EXT;
    SharedPreferences multiDexPreferences = getMultiDexPreferences(context);
    // 从SharedPreferences中获取.dex文件的总数量,调用这个方法的前提是已经抽取过dex文件,所以SP中是有值的
    int totalDexNumber = multiDexPreferences.getInt(prefsKeyPrefix + KEY_DEX_NUMBER, 1);
    final List<ExtractedDex> files = new ArrayList<ExtractedDex>(totalDexNumber - 1);
    // 从第2个dex开始遍历,这是因为主dex由Android系统自动加载的,从第2个开始即可
    for (int secondaryNumber = 2; secondaryNumber <= totalDexNumber; secondaryNumber++) {
        // 文件名,格式是"${apkName}.classes${secondaryNumber}.zip"
        String fileName = extractedFilePrefix + secondaryNumber + EXTRACTED_SUFFIX;
        // 根据缓存目录和文件名得到抽取后的文件
        ExtractedDex extractedFile = new ExtractedDex(dexDir, fileName);
        // 如果是一个文件就保存到抽取出的文件列表中
        if (extractedFile.isFile()) {
            extractedFile.crc = getZipCrc(extractedFile);
            long expectedCrc = multiDexPreferences.getLong(
                prefsKeyPrefix + KEY_DEX_CRC + secondaryNumber, NO_VALUE);
            long expectedModTime = multiDexPreferences.getLong(
                prefsKeyPrefix + KEY_DEX_TIME + secondaryNumber, NO_VALUE);
            long lastModified = extractedFile.lastModified();
            if ((expectedModTime != lastModified)
                || (expectedCrc != extractedFile.crc)) {
                throw new IOException("Invalid extracted dex: " + extractedFile +
                                      " (key \"" + prefsKeyPrefix + "\"), expected modification time: "
                                      + expectedModTime + ", modification time: "
                                      + lastModified + ", expected crc: "
                                      + expectedCrc + ", file crc: " + extractedFile.crc);
            }
            files.add(extractedFile);
        } else {
            throw new IOException("Missing extracted secondary dex file '" +
                                  extractedFile.getPath() + "'");
        }
    }

    return files;
}

3、安装Dex文件

提取完dex后,接下来就是安装过程

private static void installSecondaryDexes(ClassLoader loader, File dexDir,
                                          List<? extends File> files){
    if (!files.isEmpty()) {
        if (Build.VERSION.SDK_INT >= 19) {
            V19.install(loader, files, dexDir);
        } else if (Build.VERSION.SDK_INT >= 14) {
            V14.install(loader, files);
        } else {
            V4.install(loader, files);
        }
    }
}

因为在不同的SDK版本上,DexClassLoader加载dex文件的方式有所不同,所以这里做了V4/V14/V19的兼容
下面主要分析SDK19以上安装过程:

private static final class V19 {

    static void install(ClassLoader loader,
                        List<? extends File> additionalClassPathEntries,
                        File optimizedDirectory){
        // 反射获取到DexClassLoader的pathList字段;
        Field pathListField = findField(loader, "pathList");
        Object dexPathList = pathListField.get(loader);
        ArrayList<IOException> suppressedExceptions = new ArrayList<IOException>();
        // 将刚刚提取出来的zip文件包装成Element对象,并扩展DexPathList中的dexElements数组字段;
        expandFieldArray(dexPathList, "dexElements", makeDexElements(dexPathList,
                                                                     new ArrayList<File>(additionalClassPathEntries), optimizedDirectory,
                                                                     suppressedExceptions));
        if (suppressedExceptions.size() > 0) {
            for (IOException e : suppressedExceptions) {
                Log.w(TAG, "Exception in makeDexElement", e);
            }
            Field suppressedExceptionsField =
                findField(dexPathList, "dexElementsSuppressedExceptions");
            IOException[] dexElementsSuppressedExceptions =
                (IOException[]) suppressedExceptionsField.get(dexPathList);

            if (dexElementsSuppressedExceptions == null) {
                dexElementsSuppressedExceptions =
                    suppressedExceptions.toArray(
                    new IOException[suppressedExceptions.size()]);
            } else {
                IOException[] combined =
                    new IOException[suppressedExceptions.size() +
                                    dexElementsSuppressedExceptions.length];
                suppressedExceptions.toArray(combined);
                System.arraycopy(dexElementsSuppressedExceptions, 0, combined,
                                 suppressedExceptions.size(), dexElementsSuppressedExceptions.length);
                dexElementsSuppressedExceptions = combined;
            }

            suppressedExceptionsField.set(dexPathList, dexElementsSuppressedExceptions);

            IOException exception = new IOException("I/O exception during makeDexElement");
            exception.initCause(suppressedExceptions.get(0));
            throw exception;
        }
    }

    private static Object[] makeDexElements(
        Object dexPathList, ArrayList<File> files, File optimizedDirectory,
        ArrayList<IOException> suppressedExceptions)
        throws IllegalAccessException, InvocationTargetException,
    NoSuchMethodException {
        // 反射调用DexPathList对象中的makeDexElements方法,将刚刚提取出来的zip文件包装成Element对象
        Method makeDexElements =
            findMethod(dexPathList, "makeDexElements", ArrayList.class, File.class,
                       ArrayList.class);

        return (Object[]) makeDexElements.invoke(dexPathList, files, optimizedDirectory,
                                                 suppressedExceptions);
    }
}

反射获取ClassLoader中的pathList字段;
反射调用DexPathList对象中的makeDexElements方法,将刚刚提取出来的zip文件包装成Element对象;
将包装成的Element对象扩展到DexPathList中的dexElements数组字段里;
makeDexElements中有dexopt的操作,是一个耗时的过程,产物是一个优化过的odex文件。
至此:提取出来的dex文件也被加到了ClassLoader里,而那些Class也就可以被ClassLoader所找到并使用。

四、存在的问题

MultiDex 并不是万全的方案,Google 貌似不太热衷于旧版本的兼容工作,通过阅读 MultiDex Support 库的源码,我们也能发现其代码写得貌似没有那么严谨。
目前来说,使用 MultiDex 可能存在以下问题。

1、NoClassDefFoundError

如果你在调用 MultiDex#install(Context) 做了别的工作,而这些工作需要用到的类却存在于别的 dex 文件里面(Secondary Dexes),就会出现类找不到的运行时异常。
正确的做法是把这些需要用到的类标记在 multidex.keep 清单文件里面,再在 build.gradle 里面启用该清单文件。

android {

  defaultConfig {
    multiDexEnabled true
    multiDexKeepProguard file('multidex.pro')
    multiDexKeepFile file('main_dex.txt')
   }
}

dependencies {
  compile 'com.android.support:multidex:1.0.3'
}

multiDexKeepProguard使用的是类似于混淆文件的过滤规则,除了这个配置项之外还有multiDexKeepFile,这个要求你在清单文件里把所有的类都罗列出来。

2、卡顿/ANR问题

目前 Android 5.0 以上的设备已经自身支持了 MultiDex 功能,也就是说在安装 apk 的时候,系统已经会帮我们把 apk 里面的所有 dex 文件都做好 Optimize 处理,所以不需要我们在代码里启用 MultiDex 了。但是对于 Android 5.0 以下的设备,依然要求我们启用 MultiDex。而这些系统的设备在第一次运行 App 的时候,需要对所有的 Secondary Dexes 文件都进行一次解压以及 Optimize 处理(生成 odex 文件),这段时间会有明显的耗时,所有会产生明显的卡顿现象


dex异步加载.png

1、在Application的attachBaseContext启动新进程执行dexOpt

protected void attachBaseContext(Context base) {
    // 只有5.0以下需要执行 MultiDex.install
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
        MULTI_DEX = MULTI_DEX + "_" + getVersionCode(base);
        if (SystemUtil.isInMainProcess(base)) {
            // 判断有没有执行过dexOpt
            if (!dexOptDone(base)) {
                preLoadDex(base);
            }
        }
        if (!KwaiApp.isMultiDeXProcess(base)) {
            MultiDex.install(base);
        }
    }
    super.attachBaseContext(base);
}

/**
   * 是否进行过DexOpt操作。
   * 
   * @param context
   * @return
   */
private boolean dexOptDone(Context context) {
    SharedPreferences sp = context.getSharedPreferences(MULTI_DEX, MODE_MULTI_PROCESS);
    return sp.getBoolean(MULTI_DEX, false);
}

/**
   * 在单独进程中提前进行DexOpt的优化操作;主进程进入等待状态。
   *
   * @param base
   */
public void preLoadDex(Context base) {
    // 在新进程中启动PreLoadDexActivity
    Intent intent = new Intent(base, PreLoadDexActivity.class);
    intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
    base.startActivity(intent);
    while (!dexOptDone(base)) {
        try {
            // 主线程开始等待;直到优化进程完成了DexOpt操作。
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

2、 子进程中执行dexOpt

public class PreLoadDexActivity extends Activity {
  @Override
  public void onCreate(Bundle savedInstanceState) {
    requestWindowFeature(Window.FEATURE_NO_TITLE);
    super.onCreate(savedInstanceState);
    // 取消掉系统默认的动画。
    overridePendingTransition(0, 0);
    setContentView(R.layout.tv_splash_layout);
    new Thread() {
      @Override
      public void run() {
        try {
          // 在子线程中调用
          MultiDex.install(getApplication());
          SharedPreferences sp = getSharedPreferences(App.MULTI_DEX, MODE_MULTI_PROCESS);
          sp.edit().putBoolean(App.MULTI_DEX, true).commit();
          finish();
        } catch (Exception e) {
          finish();
        }
      }
    }.start();
  }

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