android framework层测试微指南

framework测试

framework层测试简介

framework层测试也是android 移动端测试的领域,但是和更上层的应用测试不同,应用测试更偏重于应用是否正确实现了业务逻辑;而framework层测试更偏重于能否正确向上层输出能力。

android framework介绍

做移动测试的,android整体框架图肯定是了然于心的,从底层往上的顺序,Android系统架构由5部分组成,分别是:Linux Kernel、Android Runtime、Libraries、Application Framework、Applications;Framework层正处于应用层之下,这也可以看出它的作用:为应用层输出能力。

输出的能力包括但不限于:为上层应用提供各种api、提供各种组件和服务、管理应用的活动生命周期等等。

framework层测试内容

既然framework是为上层提供能力的,作为我们的测试对象,我们的测试内容自然也和这些息息相关,包括但不限于:framework层接口的测试(功能、稳定性、安全性等等)、底层能力测试(比如私有的按键功能、自定义的输出日志等等)、系统修改测试(比如裁剪系统)等等。

framework层测试方法

实际上,尽管framework层的需求种类繁多,但是在测试方法上,无非也就是两个维度来处理:自上而下的测试,或者是自下而上的测试。

自上而下的测试

自上而下的测试方法,其实也就是站在顶层的视角看需求;无论framework新增或者修改了什么,总归是要给上层输出能力的,或者是在上层有自己的表现方式。要么是上层可以使用到提供的能力,要么是你的修改在上层有直接或者间接体现,那么我们就直接对其“表象”进行验证,间接测试framework层的能力。

如果是对系统底层能力或者对系统修改的验证的话,其实和传统的app测试差不多,因为他们都有表现的实体;app的测试可以直接从ui层看到结果,而对系统能力或者系统修改的测试,一般也可以在系统的ui上看到结果,或者一些是隐形的修改,也可以通过adb命令直接看到结果。

而在framework为上层提供的能力上,往往并没有实体ui可以看到表现,直接使用业务的应用作为载体的话,其复杂度太高,也不符合分层测试的理念,出现问题难以判断是业务应用的问题还是framework层的问题;因此,我们采用“自造”载体的形式,即自己开发一个app作为载体,和业务app不同的是,自造的app保持最小功能,仅通过ui或者广播等形式,将待测framework层的能力暴露出去,通过在ui上直接操控framework层的接口,然后观察其结果,间接测试到framework层的能力。

自下而上的测试

自下而上的测试方法,就比较直接了当了,就是直接针对提供的底层能力测试,例如对framework层的接口测试,通过单元测试的方式,对接口进行各方面的测试,从而在底层保障framework层的能力。

这种方式可能传统的功能测试同学不太熟悉,因此下面着重的介绍一下framework层接口测试的流程和方法。

framework层接口测试

在framework测试中,最为原始的测试需求应该就是对新增或者修改framework层接口的测试,本质上,对framework接口的测试也是接口测试的一种,他可以类比于web的接口测试,更容易让人理解;但和单元测试更为接近。

framework接口测试和web接口测试异同

和web接口测试的相同点在于,二者都是对输入输出的校验,web接口是在网络协议的基础上,对服务器进行请求,可以想象成网络协议是高速公路,请求则是奔驰的汽车,对汽车来说,高速公路是公共建设,很多协议如https、dubbo等都是公共基础,不需要自己再去施工的;而对于framework接口来说,这条路就未必是统一的,因为对于rom级的产品来说,会涉及到很多在framework层新增或者修改的东西,这部分不在android的官方sdk里,因此,需要一些手段自己构造测试条件,也就是自己去把路修好。

framework接口调用方式

如何去修路?首先我们需要地基,也就是请求的环境基础,web接口可以直接借由网络通道去请求到服务器,而framework接口的请求,需要请求端本身在含有修改后的framework层的android环境里。

我们采用的方式,是自己开发一个app,安装于待测的android环境里,通过android junit 或者实现按钮去请求(调用)接口。

这里有个问题,就是我们在本地编译环境下,直接调用新增或者修改的framework层接口的话,是没法调用的,因为本地sdk是android官方sdk,是不含我们私有内容的,因此,我们首先解决本地的编译问题。

一般来说,有两种方法:

  1. 由开发直接提供给你接口方法所在的jar包,你在需要调用的地方引用该jar包,直接调用jar包内开发好的接口;
  1. 开发如果没有给jar包的话,可以我们自己按照接口的设计说明文档,实现一个同名的接口类/方法,这样编译可以通过,而在实际环境执行的时候,是会优先找系统内实际的方法的。

framework接口验证途径

我们预备了几个途径验证接口:

  1. 在开发的app内,采用界面ui形式,例如提供表单和按钮,来进行接口参数的输入和验证,这个途径一般用于给功能测试人员,进行快速简单的验证,或者充当工具的角色,通过调用接口快速开启系统提供的某种功能;
  1. 在开发的app内,预留广播,这样可以通过外部shell发送广播来调起相关的接口,而无需在界面打开;这个途径一般用于给其他类型的自动化测试提供接口的使用途径;
  1. 是借用android的单元测试框架,android junit,直接在代码层进行接口测试。

使用junit进行framework层接口测试

下面主要说一下如何用junit进行framework层的接口测试。

测试工程搭建

  1. 新建Android工程
    和web接口测试不同,framework层的接口测试首先需要一个测试环境,这个环境一般使用是新建一个Android工程,也就是创建一个app;这个app将成为测试工程和framework层接口沟通的桥梁,因为android junit测试工程就是在应用的子线程下执行的(@UiThreadTest 时,测试case将在ui线程中执行)。
  1. 集成待测接口
    新建完Android应用工程后,我们需要把我们自己开发或者修改的framework接口集成在工程内方便测试时调用;这里就使用上面介绍的两种方法,即新建待测试接口同名的类/方法,或者直接引用sdk(jar)包调用。
  1. 新建junit测试工程
    android工程新建完成并集成了待测接口后,接下来直接新建junit测试工程即可。

junit基础语法

首先是基础语法,junit和其他的测试框架基本规则都很相似,下面说一下大体的操作。

  • 新建测试类
    测试类由注解提供专门的运行方式,加了指定的注解的类即可成为测试类,基本的测试类类似如下形式:
    @RunWith(AndroidJUnit4.class)
    public class TestClass001  {
      ...
    }
  • 新建测试方法
    测试方法也有指定的注解,只有加了该注解的方法才会被判定为测试类,并用junit框架的规则执行,简单来说就是如果你执行测试类,那么其实他会找到所有加了注解的测试方法并执行,典型的测试方法类似如下形式:
    @Test
    public void testSomething() {
        ...
    }
  • befor和after
    注解@Before和@After用于测试前准备和测试后清理,他们会在测试方法执行前和执行后运行,或者换个名词可能更熟悉些,就是一些常用测试框架内的setup()和teardown(),一般类似如下形式:
    @Before
    public void setUp() throws Exception {
        ...
    }

    @After
    public void tearDown() throws Exception {
        ...
    }
  • 运行测试
    测试类和其中的测试方法写完之后,就可以运行测试了,如果是as编写的话,可以直接点击测试类旁的运行按钮运行测试类,或者点击测试方法旁边的运行按钮运行单个测试方法;如果需要测试多个或者指定的几个测试类,可以借由junit自带的suite管理,新建一个suite类,类似如下形式:
    @RunWith(Suite.class)
    @Suite.SuiteClasses({
        TestClass001.class,
        TestClass002.class,
        ...
        })
    public class ExampleInstrumentedTest {
        ...
    }

在注解里添加你需要运行的测试类,然后直接运行该suite类即可。

设计接口测试用例

用例设计思路

framework接口测试用例的设计和web接口测试用例基本思路都是一致的,大约从以下几个方向考虑:

  1. 单接口的各种正反向接口参数输入,对比接口设计文档检验输出;
  2. 接口之间组合成业务流,形成各种正反向业务场景,对比需求文档检验流程完成后的结果;
  3. 由于移动端场景区别于web,因此还需要考虑接口在各种常见场景下的设计,例如重启、进程被回收、恢复出厂设置等等;
  4. 由于移动端资源较为有限,因此还需要关注接口在长期执行后的系统资源表现,例如cpu、内存等等;
  5. 其他

这里非常推荐设计之前借鉴一下腾讯移动品质中心(TMQ)写的一篇接口测试用例设计【点我打开链接】的文章,虽然不是特别针对framework层接口的,但基本思路总结的非常全面了。

用例编写规范

基于junit框架进行的接口测试是纯代码型的,不像普通的excel或者word文档那样天然具备良好的可读性,因此在用例管理和规范上,需要遵循一定的方式。

  1. 用例名称规则
  • 类名
    按照【Test】【测试对象】【场景】的结构
    例如:
    测试softsim的性能,类名可以为:TestSoftSimPerformance();
  • 方法名
    按照【test】【用例描述(测试目的)】的结构
    例如:
    测试插拔sim卡,方法名可以为testPlugSimCard();
  • 注释
    对于场景流程较长或复杂的用例,建议增加注释,用例内容为步骤描述以及其他须注意点。
  1. 用例结构设计规则
  • 单接口测试用例结构设计
    单接口测试时,建议尽量使用参数化的形式进行测试,以期减少测试用例代码的冗余。
    详细来说,我们推荐先把入参进行归类处理,在测试方法内进行分支判断,然后使用@RunWith(Parameterized.class)注解装饰测试类,在测试类中通过@Parameterized.Parameters注解装饰数据构造方法,给测试用例执行。
  • 多接口组合测试用例结构设计
    非单接口测试时,测试用例的代码设计结构推荐按照分层测试的思想,即积木式的堆叠组合,以期达到最大可复用状态。
    详细来说,我们把api作为最小执行单位,多个api之间关联执行的最小业务逻辑集合封装为步骤(step),在step中,需要包含该业务逻辑或api调用后的基础检查点(Assert);我们把step组合而成的业务逻辑封装为testcase,testcase中包含了该业务逻辑的最终检查点。
    我们把testcase按照测试对象和场景归类,归属到同属性下作为一个测试类(TestClass),由suite执行器执行。
  1. 用例文档映射
    除了在代码里要求的规范外,我们也需要(如果有余力的话)建立用例(代码)和文档的映射关系,这样可以使得用例维护和执行状态等等更方便管理。
    一般而言,我们使用excel管理用例,在excel表格里,如普通case一样的记录,元素也和普通测试用例保持一致,例如用例编号、用例描述、预期结果、执行结果等等;但是额外的再增加一列代码映射关系,在这列里填上该条case在代码工程里对应的测试类和测试方法。

接口测试编码常用工具

在编写接口测试代码的时候,不止是调用待测的接口,很多时候还需要自己写方法提供测试参数或者测试环境,又或者需要和android的环境进行交互,下面阐述一下常用的编码时用到的工具。

  1. 判断系统状态
    系统的状态包括硬件状态、系统属性等等,大部分的状态都可以通过android原始提供的方法如广播、service、原生api等获取,下面列举几个常用的状态获取,如果有更多不在其中的,建议翻阅谷歌官方的android开发手册。
  • 判断系统屏幕状态
    判断屏幕的息屏亮屏状态可以使用广播的形式,注册一个系统广播,示例代码如下:
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    
        intentFilter=new IntentFilter();
        intentFilter.addAction(Intent.ACTION_SCREEN_ON);
        intentFilter.addAction(Intent.ACTION_SCREEN_OFF);
        intentFilter.addAction(Intent.ACTION_USER_PRESENT);
        mScreenReceiver=new ScreenBroadcastReceiver();
        registerReceiver(mScreenReceiver,intentFilter);
    }

    private class ScreenBroadcastReceiver extends BroadcastReceiver{
        private String action=null;
    
        @Override
        public void onReceive(Context context, Intent intent) {
            action=intent.getAction();
            if(Intent.ACTION_SCREEN_ON.equals(action)){
                Toast.makeText(context,"屏幕开屏",Toast.LENGTH_SHORT).show();
    
            }else if(Intent.ACTION_SCREEN_OFF.equals(action)){
                Toast.makeText(context,"屏幕关屏",Toast.LENGTH_SHORT).show();
            }else if(Intent.ACTION_USER_PRESENT.equals(action)){
                Toast.makeText(context,"屏幕解锁",Toast.LENGTH_SHORT).show();
            }
        }
    }
    }
  • 判断系统电池状态
    判断系统电池状态可以直接使用BatteryManager.getLongProperty()获取,其入参是规定的常量,主要有以下参数:
    BATTERY_PROPERTY_CHARGE_COUNTER: 剩余电池容量,单位为微安时
    BATTERY_PROPERTY_CURRENT_NOW: 瞬时电池电流,单位为微安
    BATTERY_PROPERTY_CURRENT_AVERAGE: 平均电池电流,单位为微安
    BATTERY_PROPERTY_CAPACITY: 剩余电池容量,显示为整数百分比
    BATTERY_PROPERTY_ENERGY_COUNTER: 剩余能量,单位为纳瓦时
  • 获取系统属性
    获取系统属性可以直接通过android.os.SystemProperties获取,示例代码如下:
        private String getAndroidOsSystemProperties(String key) {
            String ret;
            try {
                systemProperties_get = Class.forName("android.os.SystemProperties").getMethod("get", String.class);
                ret = (String) systemProperties_get.invoke(null, key);
            } catch (Exception e) {
                return null;
            }
            return ret;
        }
    }
  • 其它...

2.模拟系统交互
在测试中往往需要模拟系统交互或者更改系统属性,如模拟按键输入、模拟按键点击、开启设备节点等等;一般而言shell的执行可以模拟大部分情况,或者使用Instrumentation也可以达成目的,下面举例几个常见使用方式。

  • 执行shell命令
    在android工程内,可以通过Runtime.getRuntime().exec(shell命令)的方式直接执行shell命令;因此可以利用这一点,通过shell模拟系统交互,例如,通过shell发送keyevent,模拟按键操作、发送input text输入文本等等。
  • 使用Instrumentation框架
    Instrumentation框架是是Android自带一个单元测试框架,在这个框架下,你的测试应用程序可以精确控制应用程序。
    使用Instrumentation, 你可以在主程序启动之前,创建模拟的系统对象,如Context;控制应用程序的多个生命周期;发送UI事件给应用程序;在执行期间检查程序状态。 Instrumentation框架通过将主程序和测试程序运行在同一个进程来实现这些功能。
    下面以模拟点击按键示例,代码如下:
    private void sendKeyCode(final int keyCode) throws InterruptedException {
        Thread t1 = new Thread () {
            public void run() {
                try {
                    Instrumentation inst = new Instrumentation();
                    inst.sendKeyDownUpSync(keyCode);
                } catch (Exception e) {
                    Log.e("Exception when sendPointerSync", e.toString());
                }
            }
        } ;
        t1.start();
        t1.join();
    }
  • 其它...

错误记录和分析

谈及测试方法之后,当然免不了对测试后问题的记录和分析,虽然往往最终的bug修复工作都是开发来做,但我们仍然可以力所能及的承担问题前期分析工作。在android framework层的测试中,除了用例本身的断言提示,我们还要借助很多log和工具进行辅助分析,下面介绍一下这些。

Android log日志类

Android环境中,存在各种各样的log,下面介绍一下它们的用法。

logcat

logcat是最基础的android log,基本上最常用的也是它,由于设备的缓冲区有限,出了问题如果没有及时的记录就会被冲刷掉,一般而言,我们会在测试开始前就开启log输出并转储到本地。

  • logcat存储内容
    logcat主要有四个缓冲区,分别存储了Radio:输出通信系统的log、System:输出系统组件的log、Event:输出event模块的log、Main:所有java层的log,以及不属于上面3层的log。
    由于我们往往是测试系统层api,因此一般这四个缓冲区都需要记录下来。
  • logcat日志等级
    logcat一般分为V –Verbose(最低优先级)、D – Debug、I – Info、W – Warning、E – Error、F – Fatal、S – Silen;排名越后优先级越高,在实际的分析里,我们可以优先按照Fatal过滤日志查看。
  • logcat输出记录
    logcat可以直接通过adb命令输出并记录,例如“adb logcat -b radio -b main -b system -b events -b kernel -v time> E:%filename%”这样的形式,如果想要特别只记录测试时间段内的数据,可以先执行 “adb logcat -c”,清除缓冲区日志。
traces.txt

在应用发生anr时,ActivityManagerService的appNotResponding方法就会被调用,然后在/data/anr/traces.txt文件中写入ANR相关信息,因此对traces.txt的分析可以得出anr时的过程。

  • traces.txt存储内容
    traces.txt保存了发生ANR的进程id、时间和进程名称等;线程的调度信息、上下文信息、调用栈信息等;以及系统当时的整体使用情况等。
  • traces.txt输出记录
    traces.txt可以直接通过adb命令输出,日志默认保存3天的信息,可以通过“adb pull data/anr/traces.txt d:\log”的形式拉取出来。
dmesg

dmesg是内核的log信息。

  • dmesg存储内容
    dmesg是用来显示内核相关信息的,它从内核环形缓冲区中获取数据的,主要存储硬件相关的error和warning、守护进程相关的信息、系统的启动信息等等。
  • dmesg抓取方式
    dmesg可以直接通过adb命令输出,例如“adb shell dmesg >D:/Kernel.log”这样的形式;或者直接执行“adb shell”命令,在shell内执行“cat /proc/kmsg”。
bugreport

bugreport是android上用于调试的、一个官方的调试信息聚合工具,它的内容包含了多种调试信息。

  • bugreport存储内容
    bugreport包含了庞大的调试信息种类,实际上他本身是个工具,作用就是对各种信息进行聚合并形成一个统一文件;它包含了基本的logcat(包括各个缓冲区)、vm trace、system property、系统资源情况(dumpsys checkin相关、dumpsys app相关等)、system server crash 和 system app crash 信息等等。
  • bugreport抓取方式
    bugreport可以直接通过adb命令抓取,例如“adb bugreport > bugreport_out.txt”这样的形式。

  • bugreport读取方式
    bugreport往往是个庞大的文件,直接读的话可能会比较费时,可以使用Google官方的开源分析工具bettery historian进行分析,它会展现一个类似web的界面,更加简便易懂。

coredump

coredump是linux原生的记录系统产生异常的日志,一般在死机或者系统线程异常时记录,需要说明的是,并不是所有设备都有此日志,需要开启这个功能的日志才可以获取到,而且存储的方式和位置也是根据具体的实现方式而定的,因此这里不多作介绍。

ramdump

ramdump指内存转储,也就是整个DRAM的运行时内容数据,当系统发生崩溃性异常时候,通过一种机制实现将DRAM中的数据保存起来,保留了异常现场,待离线分析用。

  • ramdump存储内容
    ramdump中保留了异常时候的DRAM中的信息,包括各种全局变量、局部变量、进程状态等等。
  • ramdump抓取方式
    由于ramdump是死机(崩溃)时日志,因此一般无法通过adb获取了;我们可以直接通过高通平台的qpst抓取,方式也很简单,安装qpst工具后,直接打开其下的QPST Configuration软件,在tab页切换到Ports即可,发生死机(崩溃)后,机器重启时,QPST会自动抓取日志。

Android资源信息类

除了Android内的各种日志类外,在我们进行长时间的测试类型例如压力或者稳定性测试的时候,不止是产生错误需要记录分析,对被测接口整体的资源占用情况更需要记录,这一块也有多种方式实现,下面挑几种常见的介绍。

Android Studio Profiler

如果是使用AS的方式进行的app开发和接口测试,无疑AS自带的Profiler页是最为结合紧密的,在运行测试之后,我们只需要切到在AS底部的Profiler页,在SEESION栏选择好需要监控的进程即可,一共分为四个可监控项,分别是CPU、Memory、NETWORK、ENERGY。

Android Studio Profiler 只提供对资源信息的大致预览,如果需要更细的分析,需要dump相关的heap。

DDMS

DDMS是android sdk内自带的工具集,基本入口在sdk内tools目录下的monitor.bat,双击该文件即可打开。

相比于Android Studio Profiler,DDMS一般用于分析更细一层的东西, 他可以实时的看到heap、threads、network的详细使用情况; 并且在System Information内,可以看到cpu load、mermory usage、frame render的详细分配情况;同样,它也支持把资源相关的heap文件dump下来详细分析。

当我们更进一步的想要分析问题时,可以使用DDMS。

ADB

adb是android调试协议桥的简称,除了上面提到的可以记录log之外,也有很多命令可以查看资源使用情况;例如获取应用的堆内存文件“
adb shell am dumpheap <packagename> /data/local/tmp/name.hprof”、获取应用的线程文件“
adb shell run-as <packagename> kill -3 pid adb pull /data/anr/traces.txt”等等,更多用法,可以自行百度。

第三方监控工具(平台)

除了android生态内自带的工具集外,有很多大厂也开源了他们的资源监控方案,例如腾讯的GT、讯飞的Itest、蚂蚁金服的SoloPi等等,他们往往也都兼具了基本的资源监控,例如cpu、内存、fps、电量、温度、网络上下行等等;甚至可以进行简单的压力模拟,例如内存填充、cpu压力模拟等等。

如果对android生态内工具不熟悉的话,建议直接使用这些,更为简便易上手。

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

推荐阅读更多精彩内容