AndResGuard源码阅读

1. 原因

写这篇文章的原因是因为发现网上对这个库的分析文章并不多,而且高质量的更少。所以就自己来记录一下自己的源码阅读收获。

2. 写在前面

  1. 本篇文章涉及的AndResGuard源码版本为1.2.17
  2. 本篇文章不涉及到 resources.arsc文件解析的细节问题,如果需要了解可以参考rsources.arsc格式详解(2020年)这篇文章
  3. AndResGuard是对resources.arsc文件进行混淆的,所以说需要对resources.arsc的格式进行了解,如果不了解可参考rsources.arsc格式详解(2020年)
  4. AndResGuard详细阅读笔记

3. 工程结构

1578907082(1).jpg

自上至下每个文件夹的功能是:

  • AndResGuard-cli: 是命令行的入口,依赖了AndResGuard-core。里面只有一个类 就是CliMain,里面包含入口main方法
  • AndResGuard-core: 是 整个项目的核心,AndResGuard-gradle-plugin 依赖了 AndResGuard-core
  • AndResGuard-example:不用多说,肯定是存放演示代码的地方。
  • AndResGuard-gradle-plugin: 是gradla插件入口,依赖了AndResGuard-core。 只做了Task的创建和配置信息收集两件事
  • SeventZip: 存放zip压缩工具,我们不用关系
  • doc: 存放使用文档和白名单的地方
  • tool_output: 存放的比较杂,和源码分析也没关系,就不说了

上面有提到 AndResGuard-cli只是命令行的入口,核心库是 AndResGuard-core,AndResGuard-gradle-plugin是gradle插件,负责收集和整合基础信息的。因为 AndResGuard-cli 做的事情比较简单,所示我们只对 AndResGuard-gradle-pluginAndResGuard-core进行分析。

4. AndResGuard-gradle-plugin

我们都知道gradle插件中都会有一个Plugin用来注册Task,那么AndResGuard-gradle-plugin中的Plugin都做了哪些工作,我们一起来看看。

4.1 AndResGuardPlugin
class AndResGuardPlugin implements Plugin<Project> {

  public static final String USE_APK_TASK_NAME = "UseApk"

  @Override
  void apply(Project project) {
    //添加 osdetector 插件 用来查找 7zip 库
    project.apply plugin: 'com.google.osdetector'
    //添加 andResGuard 扩展
    project.extensions.create('andResGuard', AndResGuardExtension)
    //添加 sevenzip 扩展
    project.extensions.add("sevenzip", new ExecutorExtension("sevenzip"))

    project.afterEvaluate {
      def android = project.extensions.android
      //直接注册 resguardUseApk
      createTask(project, USE_APK_TASK_NAME)
      ....
      android.buildTypes.all { buildType ->
        def buildTypeName = buildType.name.capitalize()
        createTask(project, buildTypeName)
      }
      ....
      //查找7zip依赖 并依赖给项目
      project.extensions.findByName("sevenzip").loadArtifact(project)
    }
  }

  private static void createTask(Project project, variantName) {
    def taskName = "resguard${variantName}"
    if (project.tasks.findByPath(taskName) == null) {
      def task = project.task(taskName, type: AndResGuardTask)
      if (variantName != USE_APK_TASK_NAME) {
        //依赖 assemble... Task
        task.dependsOn "assemble${variantName}"
      }
    }
  }
}

其实 中的代码是蛮简单的 , 一共做了 3件事。

  1. 添加 andResGuardsevenzip 的扩展,使我们再 .gradle 文件中可以使用
    //添加 andResGuard 扩展
    project.extensions.create('andResGuard', AndResGuardExtension)
    //添加 sevenzip 扩展
    project.extensions.add("sevenzip", new ExecutorExtension("sevenzip"))
  1. 给项目依赖 7zip 压缩库。具体查询和依赖的代码是在 ExecutorExtension.groovy中的loadArtifact()方法,只是在 AndResGuardPluginapply()方法中有调用。
  void loadArtifact(Project project) {
      ...
      def groupId, artifactId, version

      (groupId, artifactId, version) = this.artifact.split(":")
      def notation = [group     : groupId,
                      name      : artifactId,
                      version   : version,
                      classifier: project.osdetector.classifier,
                      ext       : 'exe']

      project.logger.info("[AndResGuard]Resolving artifact: ${notation}")
      //依赖 7zip 库
      Dependency dep = project.dependencies.add(config.name, notation)
      ...
  }
  1. 创建Task,这也是最重要的一步,通过如下代码,最终创建了名字为resguard...,类型为 AndResGuardTask 的Task,例如resguardDebug,resguardRelease
  private static void createTask(Project project, variantName) {
    def taskName = "resguard${variantName}"
    if (project.tasks.findByPath(taskName) == null) {
      def task = project.task(taskName, type: AndResGuardTask)
      if (variantName != USE_APK_TASK_NAME) {
        //依赖 assemble... Task
        task.dependsOn "assemble${variantName}"
      }
    }
  }
4.2 AndResGuardTask

AndResGuardTask的工作其实挺简单的就是将 .gradle中配置的和 Gradle 能获取到的东西都传递给 AndResGuard-core,不过有一点需要注意就是如下代码对 whiteList 中配置的资源文件补全了包名。

    //对资源文件补全路径
    configuration.whiteList.each { res ->
      if (res.startsWith("R")) {
        whiteListFullName.add(packageName + "." + res)
      } else {
        whiteListFullName.add(res)
      }
    }

最后就调用 Main.gradleRun(inputParam)这段代码开始执行 AndResGuard-core 中的程序了。

5. AndResGuard-core

5.1 Main.run()

进入到 AndResGuard-core 以后最先被调用的方法是 run()这个方法的 主要工作有三个

  1. 是将 传入的InputParam 转换成AndResGuard-core中使用的Configuration
  2. 调用 resourceProguard()方法
  3. 调用clean()释放内存
5.2 Main.resourceProguard()

这个方法有两个工作

  1. 调用decodeResource() 其实是调用 ApkDecoder.decode() 进行解析,压缩,混淆 资源文件
  2. 调用 buildApk()对压缩混淆后的apk进行签名,默认是V1签名。
5.3 ApkDecoder.decode()

这个方式其实算是个核心方法了,资源的压缩,resources.arsc文件的混淆和重新写入都是在这个方法中生成的。我们一步一步来看一下 都做了哪些事情。

    public void decode() {

        //apk中是否包含resources.arsc 文件
        if (hasResources()) {

            //创建后续需要使用的文件及文件夹,以及修改压缩配置
            ensureFilePath();

            System.out.printf("decoding resources.arsc\n");

            //解析arsc文件 将 typeID 和 具体内容 存放在  mExistTypeNames 这个map中
            RawARSCDecoder.decode(apkFile.getDirectory().getFileInput("resources.arsc"));

            //解析arsc文件 并输出 ResPackage 将混淆后的名字和压缩方式 放入mCompressData中。并且将 字符串偏移量和 混淆后的完整路径 保存到 ARSCDecoder.mTableStringsResguard 中
            ResPackage[] pkgs = ARSCDecoder.decode(apkFile.getDirectory().getFileInput("resources.arsc"), this);

            //把没有纪录在resources.arsc的资源文件也拷进dest目录
            copyOtherResFiles();

            //将混淆写入 到 resources_temp (outDir 下) 中
            ARSCDecoder.write(apkFile.getDirectory().getFileInput("resources.arsc"), this, pkgs);
        }
    }
5.5 ApkDecoder.ensureFilePath()

这个方法中大致就做了两件事

  1. 创建后面会用到的文件或者文件夹
  2. 调用 dealWithCompressConfig()方法 对.gradle文件中配置的 compressFilePattern 中包含的文件类型文件的压缩方式进行更改,后面再重新打apk的时候就会进行压缩,已达到apk瘦身的目的。
5.6 RawARSCDecoder.decode()

该方法的工作很简单,就是解析rsources.arsc文件然后将 资源的typeId资源名字存放到 mExistTypeNames这个静态成员变量中备用。

    // 用于存放 typeID 和 具体内容 的map  例:{1, [abc_fade_in] } , 1=anim
    private static HashMap<Integer, Set<String>> mExistTypeNames;
5.7 ARSCDecoder.decode()

这个方法的作用就是生成混淆后的 路径和文件名称,分为下面几步。

5.7.1 proguardFileName()

调用proguardFileName()生成混淆后的资源路径 并保存到 mOldFileName这个map中备用

           if (config.mUseKeepMapping) {
                ....
                //遍历文件 如果文件包含在 fileMapping 中,那么直接使用fileMapping中的配置,如果不包含则直接 获取一个混淆名称 保存到 mOldFileName中
                for (File resFile : resFiles) {
                    String raw = "res" + "/" + resFile.getName();
                    if (fileMapping.containsKey(raw)) {
                        mOldFileName.put(raw, fileMapping.get(raw));
                    } else {
                        mOldFileName.put(raw, resRoot + "/" + mResguardBuilder.getReplaceString());
                    }
                }
            } else {
                for (int i = 0; i < resFiles.length; i++) {
                    // 这里也要用linux的分隔符,如果普通的话,就是r。这里替换的是 res 下文件夹的 名称
                    mOldFileName.put("res" + "/" + resFiles[i].getName(), TypedValue.RES_FILE_PATH + "/" + mResguardBuilder.getReplaceString());
                }
            }
 //保存的是 res下文件全路径 和 混淆后名称的关系
    private final Map<String, String> mOldFileName;
5.7.2 readValue()

这个方法的作用有两个

  1. 将混淆后的完整资源路径 存放到 ApkDecoder.mCompressData 中备用
                ....
                //这里用的是linux的分隔符
                HashMap<String, Integer> compressData = mApkDecoder.getCompressData();
                if (compressData.containsKey(raw)) {
                    //这里将 混淆后的名称 和压缩方式 也放入 compressData中
                    compressData.put(result, compressData.get(raw));
                } 
                ....
    //保存 文件完整路径(包含混淆路径和非混淆路径)和 压缩方式的 对应关系
    private HashMap<String, Integer> mCompressData;
  1. 将 资源的 资源项目名称index 和 混淆后的完整资源路径 存放到 mTableStringsResguard这个静态变量中备用
             if (!resRawFile.exists()) {
                    Utils.logARSC("can not find res file, you delete it? path: resFile=%s", resRawFile.getAbsolutePath());
                } else {
                    if (!mergeDuplicatedRes && resDestFile.exists()) {
                        throw new AndrolibException(String.format("res dest file is already  found: destFile=%s",
                                resDestFile.getAbsolutePath()
                        ));
                    }
                    if (filterInfo == null) {
                        //将没有混淆的文件内容 copy 到混淆的文件中
                        FileOperation.copyFileUsingStream(resRawFile, resDestFile);
                        Utils.logARSC("resRawFile= %s \n resDestFile= %s",resRawFile,resDestFile);
                    }
                    //already copied
                    mApkDecoder.removeCopiedResFile(resRawFile.toPath());
                    //放入 mTableStringsResguard 中
                    mTableStringsResguard.put(data, result);
                }
//存放 资源项目名称index((资源项目名称index (下标)) 通过这个再加上 全局资源项目池 就可以拿到对应 文件的全路径 如res/anim/abc_slide_in_bottom.xml 可能为无效值)   和混淆后字符串
    public static Map<Integer, String> mTableStringsResguard = new LinkedHashMap<>();

5.7.3 initResGuardBuild()
还记得 5.6 中提到的 mExistTypeNames吗?它被使用在了 initResGuardBuild()中,防止 混淆后出现 重复的资源名称

    private void initResGuardBuild(int resTypeId) {
        // we need remove string from resguard candidate list if it exists in white list
        HashSet<Pattern> whiteListPatterns = getWhiteList(mType.getName());
        // init resguard builder (防止 mResguardBuilder 中包含白名单内容)
        mResguardBuilder.reset(whiteListPatterns);
        //避免 混淆后有重复的 String,所以要剔除 重复的名字
        mResguardBuilder.removeStrings(RawARSCDecoder.getExistTypeSpecNameStrings(resTypeId));
        // 如果是保持mapping的话,需要去掉某部分已经用过的mapping
        reduceFromOldMappingFile();
    }

5.8 copyOtherResFiles

这一步是 把没有纪录在resources.arsc的资源文件也拷进dest目录,保证项目的完整性

 private void copyOtherResFiles() throws Exception {
        if (mRawResourceFiles.size() > 0) {//说明还有文件
            //获取 原资源文件对象(temp下的res) 的Path
            Path resPath = mRawResFile.toPath();
            //获取混淆后的res/r文件Path
            Path destPath = mGuardResDir.toPath();

            for (Path path : mRawResourceFiles) {
                Path relativePath = resPath.relativize(path);//使 path 相对于 resPath 相对化
                Path dest = destPath.resolve(relativePath);//使 relativePath 相对于 destPath绝对化

                System.out.printf("copy res file not in resources.arsc file:%s\n", relativePath.toString());
                FileOperation.copyFileUsingStream(path.toFile(), dest.toFile());
            }
        }
    }
5.9 ARSCDecoder.write()

这一步的目的是将 上面生成的混淆后的 路径和名称。写入一个新的 resources.arsc文件中。也分了很多步。这里只记录里面比较重要的几步,这里因为代码不多长就不粘贴了,有需要可以去 AndResGuard详细阅读笔记 里面查看

5.9.1 StringBlock.writeTableNameStringBlock()

作用是 将混淆好的 文件全路径 写入 全局字符串池中。

5.9.2 StringBlock.writeSpecNameStringBlock()

作用是 将混淆好的路径信息 写入 资源项名称字符串池

5.9.3 StringBlock.reWriteTable()

作用是 将 混淆后的各块大小 写入arsc文件中备用,到这里整个混淆过程就完成了,后面就会调用 5.3中提到的 buildApk() 生成混淆后的 apk。

6. 总结

6.1 混淆流程调用链

Main.run()--> Mian.resourceProguard()--> Main.decodeResource()--> ApkDecoder.decode()--> ApkDecoder.ensureFilePath()--> RawARSCDecoder.decode()--> RawARSCDecoder.readTable()--> ARSCDecoder.decode--> ARSCDecoder.readTable()--> ARSCDecoder.write()--> ARSCDecoder.writeTable()--> Main.buildApk()--> ResourceApkBuilder.buildApkWithV2sign--> ResourceApkBuilder.addNonSignatureFiles--> ResourceApkBuilder.use7zApk--> ResourceApkBuilder.alignApk--> Main.clean()->

6.2 混淆流程重要步骤 含义
  1. RawARSCDecoder.readTablePackage 中解析 资源项名称字符串池(用于存储 资源名称,比如layout的名字,string的名字)并赋值给 mSpecNames

  2. RawARSCDecoder.readSingleTableTypeSpec 中 解析 TableTypeSpec 并将 type, mResId 和 typeName 和 packageName进行记录

  3. RawARSCDecoder.readEntry 中解析出资源项名称index(specNamesId)然后调用 putTypeSpecNameStrings 通过 type , mSpecNames 和 specNamesId 找到具体的 String 并保存在 mExistTypeNames中 备用

  4. ARSCDecoder.proguardFileName()方法中 对路径进行混淆并保存在 mOldFileName 中。比如 res/anim 混淆为 r/a

  5. ARSCDecoder.initResGuardBuild()方法中过滤掉步骤3生成的 mExistTypeNames存在的名称

  6. ARSCDecoder.dealWithNonWhiteList()方法中 混淆文件名称 并保存到 ResPackage 中的 mSpecNamesReplace map中

  7. ARSCDecoder.readValue()方法中 对 4,5 步生成的 混淆路径和混淆名称进行拼接 并保存到 ApkDecoder的 compressData Map 中。并且将 字符串偏移量和 混淆后的完整路径 保存到 mTableStringsResguard 中 备用

  8. StringBlock.writeTableNameStringBlock()将混淆好的 文件全路径 写入 全局字符串池中

  9. StringBlock.writeSpecNameStringBlock()将混淆好的路径信息 写入 资源项名称字符串池

  10. ARSCDecoder.reWriteTable() 将 混淆后的各块大小 写入arsc文件中备用

7. 阅读中遇到的问题及解答

  1. 入口在哪里
    答: 有两种入口一个是 gradlePlugin一个是命令行。不多真正程序还是执行的入口是 core中的Main
  2. resguardUseApk Task 是干嘛的?
    答:直接对apk进行资源压缩的task
  3. resguard...task 是如何保证在打包后运行的(隐士依赖?)
    答:不是的是在AndResGuardPlugin的createTask中进行的依赖
  private static void createTask(Project project, variantName) {
    def taskName = "resguard${variantName}"
    if (project.tasks.findByPath(taskName) == null) {
      def task = project.task(taskName, type: AndResGuardTask)
      if (variantName != USE_APK_TASK_NAME) {
        task.dependsOn "assemble${variantName}"
      }
    }
  }
  1. 怎么压缩图片的?
    答:更改图片文件压缩格式为压缩后,使用7zip进行压缩的
  2. StringBlock 中 int size = ((stylesOffset == 0) ? chunkSize : stylesOffset) - stringsOffset;这段代码 求出的 size是什么?
    答:获取字符串池所占总体占用字节数
  3. ResTypePackage 中 这段代码是干啥的?看起来像是某种特殊情况的判断
        // TypeIdOffset was added platform_frameworks_base/@f90f2f8dc36e7243b85e0b6a7fd5a590893c827e
        // which is only in split/new applications.
        int splitHeaderSize = (2 + 2 + 4 + 4 + (2 * 128) + (4 * 5)); // short, short, int, int, char[128], int * 4
        if (mHeader.headerSize == splitHeaderSize) {
            mTypeIdOffset = mIn.readInt();
            System.out.printf("mTypeIdOffset= %s\n", mTypeIdOffset);
        }
答:如果PackageHeader的大小 等于 splitHeaderSize 这就说明PackageHeader中包含 mTypeIdOffset。 如果包含的话 我们在解析 RES_TABLE_LIBRARY_TYPE 的typeId时要得到真正的 typeId 就要减去 typeIdOffset
  1. RawARSCDecoder.Header.TYPE_LIBRARY(在ResourceTypes.h中的RES_TABLE_LIBRARY_TYPE) 是用来做什么的?虽然在ResourceTypes.h中也看到了定义但是不知道是做什么的。
    答:==resources.arsc中会通过 RES_TABLE_LIBRARY_TYPE 中会记录自己的依赖的库==

  2. ApkDecoder.decode()中RawARSCDecoder.decode(apkFile.getDirectory().getFileInput("resources.arsc"));这行代码是在干什么?感觉没有用啊
    答:这次解析 产生的 mExistTypeNames 是为了防止在生成混淆字符串时 和 文件名产生重复。

  3. ARSCDecoder.initResGuardBuild()中 mResguardBuilder.reset(whiteListPatterns); 这段代码 是在干什么
    答: 重置混淆字符串生成器,因为 文件名和路径名可以重复,所以可以重置

  4. ARSCDecoder.readValue()方法中的

    HashMap<String, Integer> compressData = mApkDecoder.getCompressData();
    if (compressData.containsKey(raw)) {
        //就是在这里替换了 混淆后的文件名!!!!
        compressData.put(result, compressData.get(raw));
    } else {
        System.err.printf("can not find the compress dataresFile=%s\n", raw);
    }

这段代码是不是 替换了 混淆后的文件名?那么是怎么生效的呢?
答:不止的 这里只是将 混淆后的路径名和 压缩方式 也存入compressData中。 混淆生效是在 ARSCDecoder.writeTable()完成的

  1. 该项目是如何遭到混淆的?
    答: 一次解析 获取 所有文件名和type的对应关系-》二次解析(得到混淆后的名称)-》三次解析及写入新arsc

  2. ResPackage中的mSpecNamesBlock Map 是用来记录什么的?
    答:记录 arsc name列的名称和 混淆后文件名 对应关系

8. 注意事项

  1. 使用glide直接加载资源图片,最终也是去获取 未混淆前的资源名称,所以 如果要使用这种方式需要要将 该图片加入到 白名单中。如下是 glide源码。
ResourceLoader.class


    @Override
    public DataFetcher<T> getResourceFetcher(Integer model, int width, int height) {
        Uri uri = null;
        try {
          uri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://"
                  + resources.getResourcePackageName(model) + '/'
                  + resources.getResourceTypeName(model) + '/'
                  + resources.getResourceEntryName(model));
        } catch (Resources.NotFoundException e) {
            if (Log.isLoggable(TAG, Log.WARN)) {
                Log.w(TAG, "Received invalid resource id: " + model, e);
            }
        }

        if (uri != null) {
            return uriLoader.getResourceFetcher(uri, width, height);
        } else {
            return null;
        }
    }

  1. ShareSDK 中 某些资源也是通过 getIdentifier 方法获取的 具体是在 com.mob.tools.utils.ResHelper类中,所以我们也需要将 ShareSDK涉及到的资源加入白名单,配置如下
    whiteList = [
  ...
            //shareSDK
            "R.id.ssdk*",
            "R.string.mobcommon*",
            "R.string.ssdk*",
            "R.string.mobdemo*",
            "R.drawable.mobcommon*",
            "R.drawable.ssdk*",
            "R.layout.mob*",
            "R.style.mobcommon*",
    ]

如果你发现 在使用 AndResGuard后 导致 微信等分享调不起来,则你需要添加 如果配置到白名单中

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

推荐阅读更多精彩内容