MultiDex 不得不说的用法与源码解析

前言

  开发Android应用或者SDK方向小伙伴们,经过多版本的迭代,新功能的不断增加,依赖多个开源项目,使用第三方SDK,都会导致Apk大小急速膨胀。最终会导致方法超限这一问题,下面让大家了解下什么是MultiDex,讲讲它如何使用,从源码角度说说它的工作原理。

1.认识一下MultiDex

1.1 方法超限问题

当应用及其引用的库包含的方法数超过 65536 时,会遇到一个构建错误

trouble writing output:Too many field references: 131000; max is 65536.You may try using --multi-dex option.

较低版本的构建应用会出现一个不同的错误,但指向的却是一问题:

Conversion to Dalvik format failed:Unable to execute dex: method ID not in [0, 0xffff]: 65536

这两种错误情况会显示一个共同的数字:65536。Google规定单个dex文件中的方法数量不能超过65536这一限制。

1.2 64K引用限制

为什么65536方法超限呢?是由于Dex文件格式限制,一个Dex文件中method个数采用short类型来索引文件中的方法,这个也给method个数带来了不小麻烦,short类型能表示的最大值是65536,如果method个数超过了这个范围自然会报错,1K 表示 1024(即 2^10),65536 刚好是 64K,因此这一限制称为“64K 引用限制。

1.3 MultiDex由来

针对这个问题Google官方对64K引用限制提供一种方案,相信大家也用过MultiDex,Multi翻译过来就是多的意思,MultiDex就是多个dex,简单的讲:既然你的代码这么多,一个dex装不下,那么拆分成多个dex来处理。这样避免了单个dex方法超限,也能正常的编译打包应用。

2.MultiDex基本用法

2.1 Android 5.0 及更高版本的 MultiDex 支持

Android 5.0(minSdkVersion >=21)及更高版本使用名为 ART 的虚拟机时,它本身支持从 APK 文件加载多个 DEX 文件。ART 在应用安装时执行预编译,扫描 classesN.dex 文件,并将它们编译成单个 .oat 文件,以供 Android 设备执行。因此,如果你的 minSdkVersion 为 21 或更高的值,则默认情况下会启用 MultiDex,并且不需要 MultiDex 支持库。

2.2Android 5.0之前的MultiDex的支持

Android 5.0(minSdkVersion < 21)之前的平台版本使用 Dalvik 运行时执行应用代码。默认情况下,Dalvik 将应用限制为每个 APK 只能使用一个 classes.dex 字节码文件。为了绕过这一限制,可以在项目中添加 MultiDex 支持库:

android {
    defaultConfig {
        minSdkVersion 16 
        targetSdkVersion 28
        multiDexEnabled true    //启用MultiDex库支持
    }
}
dependencies {
    def multidex_version = "2.0.1"
    implementation 'androidx.multidex:multidex:$multidex_version'
}

如果你不用 AndroidX,修改app下的build.gradle 文件以启用 MultiDex,并将 MultiDex 库添加为依赖项,如下所示:

android {
    defaultConfig {
        minSdkVersion 16 
        targetSdkVersion 28
        multiDexEnabled true    //启用MultiDex库支持
    }
}
dependencies {
     implementation 'com.android.support:multidex:1.0.3'   //MultiDex依赖库
}

2.3MultiDex配置

如果无自定义Application,修改清单文件设置 <application> 中的android:name,如下所示:

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

如果有自定义Application,且直接继承Application可以更换为MultiDexApplication

public class MyApplication extends MultiDexApplication {
   public void attachBaseContext(Context base) {
        super.attachBaseContext(base);
    }
}

如果有自定义Application,但无法更改基类(继承其他的Appliaction),则可以添加attachBaseContext()方法,并调用MultiDex.install(this);

public class MyApplication extends SomeOtherApplication {
  @Override
  protected void attachBaseContext(Context base) {
     super.attachBaseContext(base);
     MultiDex.install(this);
  }
}

3.MultiDex原理解析

  注:本次源码基于com.android.support:multidex:1.0.3版本分析

3.1 判断安卓虚拟机的逻辑

 程序入口 MultiDex.install();

public static void install(Context context) {
        Log.i("MultiDex", "Installing application");
        if (IS_VM_MULTIDEX_CAPABLE) {
          //判断VM是否支持Multidex,如果是ART虚拟机默认情况下会启用 MultiDex,并且不需要MultiDex支持库

          Log.i("MultiDex", "VM has multidex support, MultiDex support library is disabled.");
        } else if (VERSION.SDK_INT < 4) {
            //最低兼容SDK版本是4,这样的手机基本都是看不着了吧
            throw new RuntimeException("MultiDex installation failed. SDK " + VERSION.SDK_INT + " is unsupported. Min SDK version is " + 4 + ".");
        } else {
              //执行到这里说明当前是Dalvik虚拟机,该进行多dex拆分
                     doInstallation();
                     ...........
}
}

我们再看看IS_VM_MULTIDEX_CAPABLE如何定义做了什么逻辑:

//System.getProperty("java.vm.version")  获取当前虚拟机版本 例:2.1.0
private static final boolean IS_VM_MULTIDEX_CAPABLE = isVMMultidexCapable(System.getProperty("java.vm.version"));
    /**
     * 1、通过正则表达式将版本号分成major(主版本号)和minor(次版本号)。
     * 2、通过判断主版本和次版本是否大于一个常量来判定虚拟机是否支持MultiDex。
     */
static boolean isVMMultidexCapable(String versionString) {
        boolean isMultidexCapable = false;
        if (versionString != null) {
            Matcher matcher = Pattern.compile("(\\d+)\\.(\\d+)(\\.\\d+)?").matcher(versionString);
            if (matcher.matches()) {
                try {
                    int major = Integer.parseInt(matcher.group(1));
                    int minor = Integer.parseInt(matcher.group(2));
                    isMultidexCapable = major > 2 || major == 2 && minor >= 1;
                } catch (NumberFormatException var5) {
                   
                }
            }
        }

        Log.i("MultiDex", "VM with version " + versionString + (isMultidexCapable ? " has multidex support" : " does not have multidex support"));
        return isMultidexCapable;
    }

isVMMultidexCapable() 返回True 说明是ART虚拟机自身就支持MultiDex不需要再做任何处理,Flase说明是Dalvik虚拟机自身不支持 MultiDex ,该执行doInstallation()

3.1.1有争议的一个问题

网上搜索很多技术大佬的博客,大部分说:可以通过调用 System.getProperty(“java.vm.version”)来检测当前使用的是哪个虚拟机,如果使用的是ART虚拟机的话,属性值会大于等于2.0.0(重点就是这个=2.0.0)

在这里我纠正下,正确的说法是:如果使用的是ART虚拟机的话,属性值应该大于等于2.1.0(>=2.1.0),有什么依据这样说?用真机(模拟器)安卓系统4.4测试,会发现 System.getProperty(“java.vm.version”)=2.0.0 ,把结果带入isVMMultidexCapable()方法里,返回的是false,说明当前应该(api 4.4版本)是Dalvik虚拟机才对,有悖于技术大佬博客上面所说的等于2.0.0就是ART虚拟机。


3.2Dex解压和压缩

如果上一步判断是Dalvik虚拟机,执行到了doInstallation()

   /**
    * applicationInfo.sourceDir获取应用APK所在目录  /data/app/{packageName}-s_ZR1N24kyfFdRoazc7SLw==/base.apk
    * applicationInfo.dataDir获取数据所在目录   /data/user/0/{packageName}
   */
  private static void doInstallation(Context mainContext, File sourceApk, File dataDir, String secondaryFolderName, String prefsKeyPrefix, boolean reinstallOnPatchRecoverableException) throws IOException, IllegalArgumentException, IllegalAccessException, NoSuchFieldException, InvocationTargetException, NoSuchMethodException, SecurityException, ClassNotFoundException, InstantiationException {
        Set var6 = installedApk;
        //考虑到多线程并发下加锁,保证执行一次
        synchronized(installedApk) {
            //如果应用 没有安装,把installedApk添加到集合中,安装应用的路径:/data/app/packageName/base.apk
            if (!installedApk.contains(sourceApk)) {
                installedApk.add(sourceApk);
                if (VERSION.SDK_INT > 20) {
                    Log.w("MultiDex", "MultiDex is not guaranteed to work in SDK version " + VERSION.SDK_INT + ": SDK version higher than " + 20 + " 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 loader;
                try {
                    loader = mainContext.getClassLoader(); //上下文对象中获取ClassLoader对象,提取出来的Dex需要通过ClassLoader真正的被加载执行;
                } catch (RuntimeException var25) {
                    Log.w("MultiDex", "Failure while trying to obtain Context class loader. Must be running in test mode. Skip patching.", var25);
                    return;
                }

                if (loader == null) {//说明获取ClassLoader 对象失败
                    Log.e("MultiDex", "Context class loader is null. Must be running in test mode. Skip patching.");
                } else {
                    try {
                        clearOldDexDir(mainContext);//清理老的缓存DEX文件
                    } catch (Throwable var24) {
                        Log.w("MultiDex", "Something went wrong when trying to clear old MultiDex extraction, continuing without cleaning.", var24);
                    }
                    //创建一个存放dex的目录   getDexDir()有详细的注释
                    File dexDir = getDexDir(mainContext, dataDir, secondaryFolderName);

                    // 把APK中的dex提取到dexDir目录中,返回的files集合有可能为空,表示没有secondaryDex
                    //apk路径:data/app/packageName/base.apk
                    //dexDir 路径: data/user/0/packageName/code_cache/secondary-dexes
                    MultiDexExtractor extractor = new MultiDexExtractor(sourceApk, dexDir);
                    IOException closeException = null;

                    try {
                   //  调用MultiDexExtractor.load方法,第一次是没有缓存的,需要IO操作,会非常耗时 返回dex文件列表
                        List files = extractor.load(mainContext, prefsKeyPrefix, false);

                        try {
                            installSecondaryDexes(loader, dexDir, files);  //安装提取出来的Dex文件。
                        } catch (IOException var26) {
                            if (!reinstallOnPatchRecoverableException) {
                                throw var26;
                            }
                            //出现异常 重新提取dex文件,并安装提取出来的dex文件
                            Log.w("MultiDex", "Failed to install extracted secondary dex files, retrying with forced extraction", var26);
                            files = extractor.load(mainContext, prefsKeyPrefix, true);
                            installSecondaryDexes(loader, dexDir, files);
                        }
                    } finally {
                        try {
                            extractor.close();
                        } catch (IOException var23) {
                            closeException = var23;
                        }
                    }

                    if (closeException != null) {
                        throw closeException;
                    }
                }
            }
        }
    }

上面代码进行各种预校验以及获取需要的信息,重点方法MultiDexExtractor.load():提取dex

 List<? extends File> load(Context context, String prefsKeyPrefix, boolean forceReload) throws IOException {
        Log.i("MultiDex", "MultiDexExtractor.load(" + this.sourceApk.getPath() + ", " + forceReload + ", " + prefsKeyPrefix + ")");

        if (!this.cacheLock.isValid()) {  //文件锁是否还有效,无效抛异常
            throw new IllegalStateException("MultiDexExtractor was closed");
        } else {
            List files;

            //forceReload判断文件是否重新加载,isModified()是判断sourceApk文件是否做过修改(简单点说这个条件就是没有覆盖安装过)
            if (!forceReload && !isModified(context, this.sourceApk, this.sourceCrc, prefsKeyPrefix)) {
                try {
                    files = this.loadExistingExtractions(context, prefsKeyPrefix);//加载之前已经解压过的dex(可以理解缓存过的)
                } catch (IOException var6) {
                    Log.w("MultiDex", "Failed to reload existing extracted secondary dex files, falling back to fresh extraction", var6);
                    files = this.performExtractions();    //出现异常重新执行提取dex
                    putStoredApkInfo(context, prefsKeyPrefix, getTimeStamp(this.sourceApk), this.sourceCrc, files);//出现异常保存apk时间戳 Crc码等信息缓存下来用于下次比对。
                }
            } else {
                if (forceReload) {
                    Log.i("MultiDex", "Forced extraction must be performed.");
                } else {
                    Log.i("MultiDex", "Detected that extraction must be performed.");
                }
                files = this.performExtractions();//走到else{}说明 没有缓存,本质上提取的是dex文件
                putStoredApkInfo(context, prefsKeyPrefix, getTimeStamp(this.sourceApk), this.sourceCrc, files);//把apk 信息缓存下来
            }

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

看上面的代码是不是有点懵我先给大家梳理下大概的逻辑:load()方法里面有两种逻辑,缓存过的loadExistingExtractions()和没缓存过的performExtractions(),第一次获取dex,没有缓存过任何信息,应先执行performExtractions()这是一个IO耗时操作(下面会细说),完成这个操作后把信息缓存下来(因为IO很耗时 不能每次都去操作),下一次则读取缓存的loadExistingExtractions(),速度会更快些。

private List<MultiDexExtractor.ExtractedDex> performExtractions() throws IOException {  
        //  格式:base.apk.classes"
        String extractedFilePrefix = this.sourceApk.getName() + ".classes";
        this.clearDexDir();  //清理dex文件
        List<MultiDexExtractor.ExtractedDex> files = new ArrayList();

        ZipFile apk = new ZipFile(this.sourceApk);// 把.apk转换成.zip

        try {
            int secondaryNumber = 2;
            //apk本质上就是归档文件上面步骤已经把apk变成了zip文件 ,for循环遍历zip文件 ,获取的dex文件
            // classes2.dex  classesN.dex
            for(ZipEntry dexFile = apk.getEntry("classes" + secondaryNumber + ".dex"); dexFile != null; dexFile = apk.getEntry("classes" + secondaryNumber + ".dex")) {
                //获取的应该是base.apk.classes2.zip
                String fileName = extractedFilePrefix + secondaryNumber + ".zip";
                //创建base.apk.classes2.zip 文件
                MultiDexExtractor.ExtractedDex extractedFile = new MultiDexExtractor.ExtractedDex(this.dexDir, fileName);//
                // 添加到文件列表(base.apk.classes2.zip 添加到/data/user/0/packageName/files/code_cache/secondary-dexes文件下)
                files.add(extractedFile);
                Log.i("MultiDex", "Extraction is needed for file " + extractedFile);
                int numAttempts = 0;
                boolean isExtractionSuccessful = false; //是否提取成功

                while(numAttempts < 3 && !isExtractionSuccessful) {
                    ++numAttempts;

                    //将classes2.dex文件写到压缩文件classes2.zip里去,最多重试三次
                    extract(apk, dexFile, extractedFile, extractedFilePrefix);

                    try {
                        extractedFile.crc = getZipCrc(extractedFile);
                        isExtractionSuccessful = true;
                    } catch (IOException var18) {
                        isExtractionSuccessful = false;
                        Log.w("MultiDex", "Failed to read crc from " + extractedFile.getAbsolutePath(), var18);
                    }

                    Log.i("MultiDex", "Extraction " + (isExtractionSuccessful ? "succeeded" : "failed") + " '" + extractedFile.getAbsolutePath() + "': length " + extractedFile.length() + " - crc: " + extractedFile.crc);

                    if (!isExtractionSuccessful) {
                        //未校验通过则删除。
                        extractedFile.delete();
                        if (extractedFile.exists()) {
                            Log.w("MultiDex", "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;
            }
        } finally {
            try {
                apk.close();
            } catch (IOException var17) {
                Log.w("MultiDex", "Failed to close resource", var17);
            }
        }
        return files; //返回dex的压缩文件列表
    }

上面的逻辑就是解压apk(apk来自applicationInfo.sourceDir()),遍历出里面的dex文件,例如 classes.dex, classesN.dex,然后又压缩成classes.zip,classesN.zip,然后返回zip文件列表。

总结:第一次加载才会执行耗时IO操作,第二次进来读取缓存中保存的dex信息,直接返回文件列表,所以第一次启动的时候比较耗时。

3.3 安装dex

dex列表已经返回了,该执行 installSecondaryDexes();进行dex安装

private static void installSecondaryDexes(ClassLoader loader, File dexDir, List<? extends File> files) throws IllegalArgumentException, IllegalAccessException, NoSuchFieldException, InvocationTargetException, NoSuchMethodException, IOException, SecurityException, ClassNotFoundException, InstantiationException {
         //针对不同的api  分别进行逻辑处理
        if (!files.isEmpty()) {
            if (VERSION.SDK_INT >= 19) {
                MultiDex.V19.install(loader, files, dexDir);
            } else if (VERSION.SDK_INT >= 14) {
                MultiDex.V14.install(loader, files);
            } else {
                MultiDex.V4.install(loader, files);
            }
        }
    }

看下 api19(v14 v4这是对不同版本做了处理 )dex安装处理了什么逻辑 MultiDex.V19.install()

 private static final class V19 {
        private V19() {
        }

        static void install(ClassLoader loader, List<? extends File> additionalClassPathEntries, File optimizedDirectory) throws IllegalArgumentException, IllegalAccessException, NoSuchFieldException, InvocationTargetException, NoSuchMethodException, IOException {
            //反射获取ClassLoader 的 pathList 字段
            Field pathListField = MultiDex.findField(loader, "pathList");
            Object dexPathList = pathListField.get(loader);
            ArrayList<IOException> suppressedExceptions = new ArrayList();
            //生成的Dex文件对应的Element数组
            //将Element数组插入到原有的dexElments数组后面
            MultiDex.expandFieldArray(dexPathList, "dexElements", makeDexElements(dexPathList, new ArrayList(additionalClassPathEntries), optimizedDirectory, suppressedExceptions));
            if (suppressedExceptions.size() > 0) {
                Iterator var6 = suppressedExceptions.iterator();

                while(var6.hasNext()) {
                    IOException e = (IOException)var6.next();
                    Log.w("MultiDex", "Exception in makeDexElement", e);
                }
                //反射获取到dexElements字段
                Field suppressedExceptionsField = MultiDex.findField(dexPathList, "dexElementsSuppressedExceptions");
                IOException[] dexElementsSuppressedExceptions = (IOException[])((IOException[])suppressedExceptionsField.get(dexPathList));
                if (dexElementsSuppressedExceptions == null) {
                    dexElementsSuppressedExceptions = (IOException[])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((Throwable)suppressedExceptions.get(0));
                throw exception;
            }
        }

        private static Object[] makeDexElements(Object dexPathList, ArrayList<File> files, File optimizedDirectory, ArrayList<IOException> suppressedExceptions) throws IllegalAccessException, InvocationTargetException, NoSuchMethodException {
            Method makeDexElements = MultiDex.findMethod(dexPathList, "makeDexElements", ArrayList.class, File.class, ArrayList.class);
            return (Object[])((Object[])makeDexElements.invoke(dexPathList, files, optimizedDirectory, suppressedExceptions));
        }
    }

上面代码做了以下的几个操作

1 .反射获取到 pathList 字段

  1. 找到pathList 字段对应的类的makeDexElements 方法(也用到了反射)

  2. 通过MultiDex.expandFieldArray 这个方法扩展 dexElements 数组

就是创建一个新的数组,把主dex要增加的内容(classerdex2、classerdexN)拷贝进去,反射替换原来的dexElements为新的数组。

3.3.1 类加载机制(简单说说概念,本文重点是MultiDex)

不管是 PathClassLoader还是DexClassLoader,都继承自BaseDexClassLoader。系统会默认创建一个PathClassLoader ,PathClassLoader有个重要成员变量pathList,pathList内部包含一个Element[],数组每一个元素对应这一个dex文件(classes.dex)。

默认情况下 系统只会加载apk第一个classes.dex文件,一般来说element数组只会存在一个元素对应一个classes.dex,运行需要加载某个类时,pathClassLoader 通过pathList的 element数组 从前往后遍历所有元素,去看哪一个dex文件有对应类,有则返回。


3.4详细源码注释

github地址有需要可以去clone :https://github.com/AndroidProg/MultiDexSources

技术思考:

   1. 为了更好的了解一个程序或者原理,找程序入口下断点分析非常有必要的

   2. 优秀的解决方案都是从源码中获取的

   3. 知道的越多不知道的更多(说多都是泪)

结语

  记录下自己的学习和工作经验,分享给有需要的人。如果有那里写的不对或者不理解,欢迎大家的指正。

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