Android类加载机制

Android手写热修复(一)--ClassLoader

什么是类加载?

我们平时编写的.java文件不是可执行文件,需要先编译成.class文件才可以被虚拟机执行。所谓类加载是指通过类加载器把class文件加载到虚拟机的内存空间,具体来说是方法区。类通常是按需加载,即第一次使用该类时才加载。

Java与Android类加载的区别

首先,Java与Android都是把类加载到虚拟机内存中,然后由虚拟机转换成设备识别的机器码。但是由于二者使用的虚拟机不同,所以在类加载方面也是有所区别的。Java的虚拟机是JVM,Android的虚拟机是dalvik/art(5.0以后虚拟机是art,是对dalvik的一种升级)。Java虚拟机运行的是class文件,而Android 虚拟机运行的是dex文件。dex其实是class文件的集合,是对class文件优化的产物,是为了避免出现重复的class。

Android中的类加载器

从上面的讲解中,我们已经知道我们平时写的类是被类加载器加载尽虚拟机内存才能运行。下面就通过Framework源码来为大家讲解Android中最主要的5个类加载器。

  • PathClassLoader :通常用于加载APK中我们自己写的类(含三方库),但不局限于此;
  • DexClassLoader :通常用于执行动态加载,能够加载指定路径的apk/jar/zip/dex文件, 因此很多热修复和插件化方案都是采用DexClassLoader;
  • BaseDexClassLoader :DexClassLoader与PathClassLoader都继承于BaseDexClassLoader;
  • BootClassLoader :用于加载Android系统的类,是ClassLoader的内部类,开发者无法调用;
  • ClassLoader :所有类加载器的基类。
类加载器关系.png

在Activity做个简单验证:

class MainActivity : AppCompatActivity() {
    val TAG = "MainActivity"
  
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        Log.e(TAG, "Activity.class 由:" + Activity::class.java.classLoader + " 加载")
        var classLoader = classLoader
        Log.e(TAG, "MainActivity.class 由:$classLoader 加载")
     
        while (classLoader != null) {
            classLoader = classLoader.parent
            Log.e(TAG, "parent classLoader : $classLoader")
        }
    }

结果:

Activity.class 由:java.lang.BootClassLoader@73e32bd 加载
MainActivity.class 由:dalvik.system.PathClassLoader[DexPathList[[zip file "/data/app/com.zx.classloader-oJi832QF9ZJMhsupSuxMfA==/base.apk"],nativeLibraryDirectories=[/data/app/com.zx.classloader-oJi832QF9ZJMhsupSuxMfA==/lib/arm64, /system/lib64, /system/vendor/lib64]]] 加载
parent classLoader : java.lang.BootClassLoader@73e32bd

可以看出系统类由BootClassLoader加载,apk中的类由PathClassLoader加载,PathClassLoader的父类加载器是BootClassLoader。如果暂时不能理解父类加载器是什么,没关系,后面讲双亲委托机制的时候会理解的。

5个类加载器的源码解析

下面的源码解析基于Android SDK API28,这几个类加载器(除了ClassLoader)没办法直接在AS上查看源码,AS搜索到的是反编译的class的内容,是不可信的,为大家推荐一个在线工具查看,在线查看Android Framework源码

PathClassLoader

package dalvik.system;

/**
 * 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).
 */
public class PathClassLoader extends BaseDexClassLoader {

    public PathClassLoader(String dexPath, ClassLoader parent) {
        super(dexPath, null, null, parent);
    }

    public PathClassLoader(String dexPath, String librarySearchPath, ClassLoader parent) {
        super(dexPath, null, librarySearchPath, parent);
    }
}

用来加载本地文件系统上的文件或目录,通常是用来加载apk中我们自己写的类,而像Activity.class这种系统的类不是由它加载。注意:这里,并不像很多网上文章说的那样只能加载apk,本地的其他目录的文件也是可以的,这一点我会在后面验证说明。

DexClassLoader

package dalvik.system;

/**
 * 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.
 *
 * <p>Prior to API level 26, this class loader requires an
 * application-private, writable directory to cache optimized classes.
 * Use {@code Context.getCodeCacheDir()} to create such a directory:
 * <pre>   {@code
 *   File dexOutputDir = context.getCodeCacheDir();
 * }</pre>
 *
 * <p><strong>Do not cache optimized classes on external storage.</strong>
 * External storage does not provide access controls necessary to protect your
 * application from code injection attacks.
 */
public class DexClassLoader extends BaseDexClassLoader {
  
    public DexClassLoader(String dexPath, String optimizedDirectory,
            String librarySearchPath, ClassLoader parent) {
        super(dexPath, null, librarySearchPath, parent);
    }
}

也是被用来加载 jar 、apk、dex,通常用来加载未安装到应用中的文件。注意,它需要一个应用私有的可写的目录来存放优化后的dex文件。千万不要选择外部存储路径,因为这样可能会导致你的应用遭到注入攻击。

从构造方法可以看出DexClassLoader和PathClassLoader的实现逻辑基本一样,其实2者都可以加载指定路径的apk/jar/zip/dex,区别在于DexClassLoader多了一个optimizedDirectory参数:用于存放优化后的dex路径,不能为空;而PathClassLoader对应的路径为null,是因为只能使用系统默认的存储路径。

关于dex文件优化,可能很多人还是不理解,水平有限,我简单解释一下,

由于Android程序的apk文件为zip压缩包格式,ART虚拟机每次加载它们时需要从apk中读取classes.dex文件,这样会耗费很长时间,为了解决这个问题,出现了ODEX优化方案。ODEX是OptimizedDEX的缩写,表示经过优化的dex文件,存放在/data/dalvik-cache目录下。而采用odex方式优化的dex文件,已经包含了加载dex必须的依赖库文件列表,ART虚拟机之后也是直接读取目录下的的dex文件,这大大缩短了读取dex文件所需的时间。

BaseDexClassLoader

class BaseDexClassLoader extends ClassLoader{
  
    public BaseDexClassLoader(String dexPath, File optimizedDirectory,
            String librarySearchPath, ClassLoader parent) {
        super(parent);
        this.pathList = new DexPathList(this, dexPath, librarySearchPath, null);
    }
}

构造器参数解释:

  • dexPath :被加载的文件的地址,文件可以是 jar/apk/dex,文件可以有多个,多个文件用:隔开;
  • optimizedDirectory :dex文件优化后的路径,如果为null,使用系统默认的路径。
  • librarySearchPath :ndk相关的so库;
  • parent :该类加载器的父类加载器。

关于optimizedDirectory:
1、这是dex优化后的路径,它必须是一个应用私有的可写的目录否则会存在注入攻击的风险;
2、这个参数在API 26(8.0)之前是有值的,之后的话,这个参数已经没有影响了,因为在调用父构造器的时候这个参数始终为null,也就是说Android 8.0 以后DexClassLoader和PathClassLoader基本一样的来;
3、在加载app的时候,apk内部的dex已经执行过优化了,优化之后放在系统目录/data/dalvik-cache下。

这个构造器的关键是初始化了一个DexPathList对象,这个是后面加载class的关键类。

DexPathList

# dalvik.system.DexPathList

private final Element[] dexElements;

DexPathList(ClassLoader definingContext, String dexPath,
            String librarySearchPath, File optimizedDirectory, boolean isTrusted) {
        ....
        this.definingContext = definingContext;
        ArrayList<IOException> suppressedExceptions = new ArrayList<IOException>();
        // save dexPath for BaseDexClassLoader
        this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory,
                                           suppressedExceptions, definingContext, isTrusted);
        ....
}

这个构造方法等关键是通过makeDexElements()方法来获取Element数组,这个Element数组非常关键,后面查找class就会用到它,也是热修复的关键点之一。

splitDexPath(dexPath)方法是把dexPath目录下的所有文件转换成一个File集合,如果是多个文件的话,会用:作为分隔符。

makeDexElements()

private static Element[] makeDexElements(List<File> files, File optimizedDirectory,
            List<IOException> suppressedExceptions, ClassLoader loader, boolean isTrusted) {
      Element[] elements = new Element[files.size()];
      int elementsPos = 0;
      /*
       * Open all files and load the (direct or contained) dex files up front.
       */
      //遍历指定目录的所有文件,并且预先加载dex或者包含dex的文件
      for (File file : files) {
          if (file.isDirectory()) {
              // We support directories for looking up resources. Looking up resources in
              // directories is useful for running libcore tests.
              //Element是支持传递目录的,但是只测试libCore的时候有用
              //这个可以不用管,我们一般都是文件
              elements[elementsPos++] = new Element(file);
          } else if (file.isFile()) {
              String name = file.getName();

              DexFile dex = null;
              //如果是原始的dex文件,先调用loadDexFile获取DexFile对象,再把它包装成Element对象,加到数组中。
              if (name.endsWith(DEX_SUFFIX)) {
                  // Raw dex file (not inside a zip/jar).
                  try {
                      dex = loadDexFile(file, optimizedDirectory, loader, elements);
                      if (dex != null) {
                          elements[elementsPos++] = new Element(dex, null);
                      }
                  } catch (IOException suppressed) {
                      System.logE("Unable to load dex file: " + file, suppressed);
                      suppressedExceptions.add(suppressed);
                  }
              } else {
                  //如果是apk/jar/zip,同样调用loadDexFile()方法,只是说
                  //这种情况dex包含在文件内部,加载方式有所不同,大概是需要先解压吧。
                  try {
                      dex = loadDexFile(file, optimizedDirectory, loader, elements);
                  } catch (IOException suppressed) {
                      /*
                       * IOException might get thrown "legitimately" by the DexFile constructor if
                       * the zip file turns out to be resource-only (that is, no classes.dex file
                       * in it).
                       * Let dex == null and hang on to the exception to add to the tea-leaves for
                       * when findClass returns null.
                       */
                      suppressedExceptions.add(suppressed);
                  }
                  //如果加载的事apk/jar/zip的话,需要判断其内部有没有包含dex文件,
                  //这里分2种情况去创建Element对象
                  if (dex == null) {
                      elements[elementsPos++] = new Element(file);
                  } else {
                      elements[elementsPos++] = new Element(dex, file);
                  }
              }
              if (dex != null && isTrusted) {
                dex.setTrusted();
              }
          } else {
              System.logW("ClassLoader referenced unknown path: " + file);
          }
      }
      if (elementsPos != elements.length) {
          elements = Arrays.copyOf(elements, elementsPos);
      }
      return elements;
    }

小结一下,这个方法就是把指定目录下的文件apk/jar/zip/dex按不同的方式封装成Element对象,然后按顺序添加到Element[]数组中。

DexPathList#loadDexFile()

 private static DexFile loadDexFile(File file, File optimizedDirectory, ClassLoader loader,Element[] elements)throws IOException {
        if (optimizedDirectory == null) {
            // 如果dex优化目录为null就直接创建一个DexFile对象
            return new DexFile(file, loader, elements);
        } else {
            //根据odex目录和文件名得到一个优化后的缓存文件路径
        String optimizedPath = optimizedPathFor(file, optimizedDirecto
            String optimizedPath = optimizedPathFor(file, optimizedDirectory);
             // 调用DexFile的loadDex()方法来获取DexFile对象
            return DexFile.loadDex(file.getPath(), optimizedPath, 0, loader, elements);
        }
    }

DexFile

# dalvik.system.DexFile 

/*
     * Private version of loadDex that also takes a class loader.
     *
     * @param sourcePathName
     *  Jar or APK file with "classes.dex".  (May expand this to include
     *  "raw DEX" in the future.)
     * @param outputPathName
     *  File that will hold the optimized form of the DEX data.
     * @param flags
     *  Enable optional features.  (Currently none defined.)
     * @param loader
     *  Class loader that is aloading the DEX file.
     * @param elements
     *  The temporary dex path list elements from DexPathList.makeElements
     * @return
     *  A new or previously-opened DexFile.
     * @throws IOException
     *  If unable to open the source or output file.
     */
    static DexFile loadDex(String sourcePathName, String outputPathName,
        int flags, ClassLoader loader, DexPathList.Element[] elements) throws IOException {

        /*
         * TODO: we may want to cache previously-opened DexFile objects.
         * The cache would be synchronized with close().  This would help
         * us avoid mapping the same DEX more than once when an app
         * decided to open it multiple times.  In practice this may not
         * be a real issue.
         */
        //调用了DexFile的一个构造方法
        return new DexFile(sourcePathName, outputPathName, flags, loader, elements);
    }

 private DexFile(String sourceName, String outputName, int flags, ClassLoader loader,
            DexPathList.Element[] elements) throws IOException {
        if (outputName != null) {
            try {
                String parent = new File(outputName).getParent();
                if (Libcore.os.getuid() != Libcore.os.stat(parent).st_uid) {
                    throw new IllegalArgumentException("Optimized data directory " + parent
                            + " is not owned by the current user. Shared storage cannot protect"
                            + " your application from code injection attacks.");
                }
            } catch (ErrnoException ignored) {
                // assume we'll fail with a more contextual error later
            }
        }

        mCookie = openDexFile(sourceName, outputName, flags, loader, elements);
        mInternalCookie = mCookie;
        mFileName = sourceName;
        //System.out.println("DEX FILE cookie is " + mCookie + " sourceName=" + sourceName + " outputName=" + outputName);
    }

/*
     * Open a DEX file.  The value returned is a magic VM cookie.  On
     * failure, an IOException is thrown.
     */
    private static Object openDexFile(String sourceName, String outputName, int flags,
            ClassLoader loader, DexPathList.Element[] elements) throws IOException {
        // Use absolute paths to enable the use of relative paths when testing on host.
        return openDexFileNative(new File(sourceName).getAbsolutePath(),
                                 (outputName == null)
                                     ? null
                                     : new File(outputName).getAbsolutePath(),
                                 flags,
                                 loader,
                                 elements);
    }

private static native Object openDexFileNative(String sourceName, String outputName, int flags,
            ClassLoader loader, DexPathList.Element[] elements);

可以看到DexFile最终是调用了openDexFile、native方法openDexFileNative去打开Dex文件的,如果outputName为空,则自动生成一个缓存目录,具体来说是/data/dalvik-cache/xxx@classes.dex。openDexFileNative这个native方法就不具体分析了,主要是对dex文件进行了优化操作,将优化后得odex文件通过mmap映射到内存中。感兴趣的同学可以参考:
《DexClassLoader和PathClassLoader加载Dex流程》

现在在回头看看DexClassLoader与PathClassLoader的区别。DexClassLoader可以指定odex的路径,而PathClassLoader则采用系统默认的缓存路径,在8.0以后没有区别。

BaseDexClassLoader小结
1、首先在BaseDexClassLoader构造方法内创建了PathDexList对象;
2、然后在DexPathList构造方法中,通过makeDexElements()等方法经过一些列调用,把dex文件做优化再缓存到指定目录,如果是包含dex的apk/jar/zip等压缩文件的话,会先解压再优化缓存,最后得到DexFile对象;
3、将DexFile对象包装成Element对象,然后加到Element[] 数组。

ClassLoader

public abstract class ClassLoader {
 private final ClassLoader parent;
private ClassLoader(Void unused, ClassLoader parent) {
        this.parent = parent;
    }

    protected ClassLoader(ClassLoader parent) {
        this(checkCreateClassLoader(), parent);
    }

    protected ClassLoader() {
        this(checkCreateClassLoader(), getSystemClassLoader());
    }
}

ClassLoader是一个抽象类,有3个构造方法,最终调用的还是第一个构造方法,主要功能是保存实现类传入的parent参数,也就是父类加载器。ClassLoader的实现类主要有2个,一个是前面讲过的BaseDexClassLoader,另一个是BootClassLoader。

BootClassLoader是ClassLoader的内部类,而且继承了ClassLoader。

加载类的过程

#ClassLoader

 public Class<?> loadClass(String name) throws ClassNotFoundException {
        return loadClass(name, false);
    }

  
    protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
            // 1. First, check if the class has already been loaded
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                try {
                    if (parent != null) {
                        //2. parent load class
                        c = parent.loadClass(name, false);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }

                if (c == null) {
                    // 3. If still not found, then invoke findClass in order
                    // to find the class.
                    c = findClass(name);
                }
            }
            return c;
    }

这是加载一个类的入口,流程如下:
1、 先检查这个类是否已经被加载,有的话直接返回Class对象;
2、如果没有加载过,通过父类加载器去加载,可以看出parent是通过递归的方式去加载class的;
3、如果所有的父类加载器都没有加载过,就由当前的类加载器去加载。

上面加载类的过程就是我们经常听到的双亲委托机制。这种设计模式的好处在于:
1、保证class只会被加载一次,也就是说类的数据结构只会在第一次创建的时候被加载进内存(方法区),以后要创建这个类的对象的时候,直接用方法区中的class在堆内存创建一个对象即可,这样的话创建对象就会比较快;
2、保证系统类的安全性。因为在启动应用进程的时候就已经加载好了系统类(BootClassLoader),那后面运行期就不可能通过恶意伪造加载的方式去造成一些系统安全问题。

通常我们自己写的类是通过当前类加载器调用findClass方法去加载的,但是在ClassLoader中这是个空方法,具体的实现在它的子类BaseDexClassLoader中。

BaseDexClassLoader # findClass

#BaseDexClassLoader 
@Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
        Class c = pathList.findClass(name, suppressedExceptions);
        if (c == null) {
            ClassNotFoundException cnfe = new ClassNotFoundException(
                    "Didn't find class \"" + name + "\" on path: " + pathList);
            for (Throwable t : suppressedExceptions) {
                cnfe.addSuppressed(t);
            }
            throw cnfe;
        }
        return c;
    }

可以看到是通过pathList去查找class的,这个对象其实之前讲过,它是在BaseDexClassLoader 的构造方法中初始化的,它实际上是一个DexPathList对象。

DexPathList # findClass()

#DexPathList

public Class<?> findClass(String name, List<Throwable> suppressed) {
        // 遍历Element
        for (Element element : dexElements) {
            //之前讲加载dex文件的时候说过,dexFile会被包装成Element对象,
          //这里是 获取内部的DexFile对象,然后通过它去加载class
            Class<?> clazz = element.findClass(name, definingContext, suppressed);
            if (clazz != null) {
                return clazz;
            }
        }

        if (dexElementsSuppressedExceptions != null) {
            suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
        }
        return null;
    }

#Element

 public Class<?> findClass(String name, ClassLoader definingContext,
                List<Throwable> suppressed) {
            return dexFile != null ? dexFile.loadClassBinaryName(name, definingContext, suppressed)
                    : null;
        }

对Element数组遍历,再通过Element对象的findClass方法去查找class,有的话就直接返回这个class,找不到则返回null。这里可以看出获取Class是通过DexFile来实现的,而各种类加载器操作的是Dex。Android虚拟机加载的dex文件,而不是class文件。

总结

1、加载一个类是通过双亲委托机制来实现的。
2、如果是第一次加载class,那是通过BaseDexClassLoader中的findClass方法实现的;接着进入DexPathList中的findClass方法,内部通过遍历Element数组,从Element对象中去查找类;Element实际上是对Dex文件的包装,最终还是从dexfile去查找的class。
3、一般app运行主要用到2个类加载器,一个是PathClassLoader:主要用于加载自己写的类;另一个是BootClassLoader:用于加载Framework中的类;
4、热修复和插件化一般是利用DexClassLoader来实现。
5、PathClassLoader和DexClassLoader其实都可以加载apk/jar/dex,区别是 DexClassLoader 可以指定 optimizedDirectory,也就是 dex2oat 的产物 .odex 存放的位置,而 PathClassLoader 只能使用系统默认位置。但是在8.0 以后二者是没有区别的,只能使用系统默认的位置了。

这张图来源于:
Android虚拟机框架:类加载机制

加载dex和查找class

预告

在类加载流程分析中,我们已经知道,查找class是通过DexPathList来完成的,实际上DexPathList最终还是遍历其Element数组,获取DexFile对象来加载Class文件。由于数组是有序的,如果2个dex文件中存在相同类名的class,那么类加载器就只会加载数组前面的dex中的class。如果apk中出现了有bug的class,那只要把修复的class打包成dex文件并且放在DexPathList中Element数组`的前面,就可以实现bug修复了。下一篇为大家带来的手写热修复。

Android类加载机制的细枝末节
从JVM到Dalivk再到ART(class,dex,odex,vdex,ELF)
类加载机制系列2——深入理解Android中的类加载器
Android 热修复核心原理,ClassLoader类加载

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

推荐阅读更多精彩内容