高级UI<第二十九篇>:Android开发之Path详解

(1)定义

Path顾名思义就是路径的意思,也可以说是轨迹的意思,Path可以帮助view完成一些复杂的动画效果。

(2)基本方法
作用 相关方法 备注
移动起点 moveTo 移动下一次操作的起点位置
设置终点 setLastPoint 重置当前path中最后一个点位置,如果在绘制之前调用,效果和moveTo相同
连接直线 lineTo 添加上一个点到当前点之间的直线到Path
闭合路径 close 连接第一个点连接到最后一个点,形成一个闭合区域
添加内容 addRect, addRoundRect, addOval, addCircle, addPath, addArc, arcTo 添加(矩形, 圆角矩形, 椭圆, 圆, 路径, 圆弧) 到当前Path (注意addArc和arcTo的区别)
是否为空 isEmpty 判断Path是否为空
是否为矩形 isRect 判断path是否是一个矩形
替换路径 set 用新的路径替换到当前路径所有内容
偏移路径 offset 对当前路径之前的操作进行偏移(不会影响之后的操作)
贝塞尔曲线 quadTo, cubicTo 分别为二次和三次贝塞尔曲线的方法
rXxx方法 rMoveTo, rLineTo, rQuadTo, rCubicTo 不带r的方法是基于原点的坐标系(偏移量), rXxx方法是基于当前点坐标系(偏移量)
填充模式 setFillType, getFillType, isInverseFillType, toggleInverseFillType 设置,获取,判断和切换填充模式
提示方法 incReserve 提示Path还有多少个点等待加入(这个方法貌似会让Path优化存储结构)
布尔操作(API19) op 对两个Path进行布尔运算(即取交集、并集等操作)
计算边界 computeBounds 计算Path的边界
重置路径 reset, rewind 清除Path中的内容,reset不保留内部数据结构,但会保留FillType。rewind会保留内部的数据结构,但不保留FillType
矩阵操作 transform 矩阵变换
(3)Paint配置
    private void init(Context mContext){
        mPaint = new Paint();
        mPaint.setColor(Color.BLACK);
        mPaint.setStrokeWidth(10);
        mPaint.setStrokeCap(Paint.Cap.ROUND);
        mPaint.setStyle(Paint.Style.STROKE);
    }

这里需要注意的是:

  • 如果绘制非闭合图形,务必将画笔设置成Paint.Style.STROKE描边模式,否则绘制无效。
  • 如果绘制闭合图形,可以使用三种模式:Paint.Style.STROKEPaint.Style.FILLPaint.Style.FILL_AND_STROKE,分别是描边模式、填充模式、描边并填充模式。
(4)moveTo和lineTo

lineTo(x, y):绘制一条执行。

        mPaint.setColor(Color.BLUE);

        Path path = new Path();
        path.lineTo(200, 200);
        canvas.drawPath(path, mPaint);

lineTo的两个传参是确定某一点的位置,那么两点确定一条直线,这里还有一个点是什么呢?

如下图所示,

图片.png

我们的画笔默认位置是(0, 0),如上图所示,(0, 0)的位置就在红色矩形区域里面的小红点位置,也就是说,画笔从(0, 0)到(200, 200)绘制直线,这样就满足了两点确定一条直线的理念,绘制之后的效果图如下:

图片.png

PathmoveTo方法可以指定画笔的位置,也就是下次绘制的开始位置,下面我们结合moveTo画直线。我们现在画一个假直角坐标系,将画笔位置移动到原点,并绘制直线。

        mPaint.setColor(Color.BLACK);

        //绘制一个假直角坐标系
        canvas.drawLine(0, 800, canvas.getWidth(), 800, mPaint);
        canvas.drawLine(canvas.getWidth() / 2, 0, canvas.getWidth() / 2, canvas.getHeight(),mPaint);

        mPaint.setColor(Color.BLUE);

        Path path = new Path();
        path.moveTo(canvas.getWidth() / 2, 800);
        path.lineTo(200, 200);
        canvas.drawPath(path, mPaint);
图片.png
(5)moveTosetLastPoint

绘制两条直线

        mPaint.setColor(Color.BLUE);

        Path path = new Path();
        path.moveTo(canvas.getWidth() / 2, 800);
        path.lineTo(200, 200);
        path.moveTo(50, 200);
        path.lineTo(100,600);
        canvas.drawPath(path, mPaint);

代码分析:

  • 期初画笔位置是(0, 0),执行path.moveTo(canvas.getWidth() / 2, 800)之后画笔位置变成了(canvas.getWidth() / 2, 800);
  • lineTo(200, 200): 绘制直线,画笔将从(canvas.getWidth() / 2, 800)开始画直线,直到(200, 200)停下;
  • moveTo(50, 200): 画笔将从(200, 200)移动到(50, 200);
  • lineTo(100,600): 绘制直线,画笔将从(50, 200)开始画直线,直到(100,600)停下;
  • drawPath: 开始绘制,这一步才开始绘制,前面的只是设置轨迹而已。

效果图:

图片.png

下面开始解释下setLastPointsetLastPoint的意思就是重置最近一次画笔位置。

        mPaint.setColor(Color.BLUE);

        Path path = new Path();
        path.moveTo(canvas.getWidth() / 2, 800);
        path.lineTo(200, 200);
        path.setLastPoint(50, 200);
        path.lineTo(100,600);
        canvas.drawPath(path, mPaint);

代码分析:

  • 期初画笔位置是(0, 0),执行path.moveTo(canvas.getWidth() / 2, 800)之后画笔位置变成了(canvas.getWidth() / 2, 800);
  • lineTo(200, 200): 绘制直线,画笔将从(canvas.getWidth() / 2, 800)开始画直线,直到(200, 200)停下;
  • setLastPoint(50, 200): 此时画笔的位置是(200, 200),setLastPoint将重置画笔的位置,使得上一次画笔的位置变成了(50, 200);
  • lineTo(100,600): 绘制直线,画笔将从(50, 200)开始画直线,直到(100,600)停下;
  • drawPath: 开始绘制,这一步才开始绘制,前面的只是设置轨迹而已。

效果图如下:

图片.png
(6)close

Path有个close方法,可以将第一个点和第二个点相连,形成闭合区域。

        mPaint.setColor(Color.BLUE);

        Path path = new Path();
        path.moveTo(canvas.getWidth() / 2, 800);
        path.lineTo(200, 200);
        path.lineTo(100,600);
        path.close();
        canvas.drawPath(path, mPaint);

如图所示

图片.png

如果是Paint的样式修改成Paint.Style.FILL或者Paint.Style.FILL_AND_STROKE,那么可以不需要执行close()也可以达到闭合效果。

图片.png
(7)addXXX系列
图片.png

这些方法大致都是添加路劲(弧路径圆路径椭圆路径矩形路径圆角矩形路径等等)

这里唯一需要说明的是Path.Direction.CWPath.Direction.CCW

一些方法中有个参数:
Path.Direction.CW: 顺时针
Path.Direction.CCW: 逆时针

我们来画一个圆

顺时针:

        mPaint.setColor(Color.BLUE);

        Path path = new Path();
        path.addCircle(0, 0, 200, Path.Direction.CW);
        canvas.drawPath(path, mPaint);

如图:

图片.png

画笔起始点是(200, 0),结束点是(0, -200)。

那么, 我们可以利用setLastPoint来画一个桃子

        path.setLastPoint(200, -200);
图片.png

逆时针:

path.addCircle(0, 0, 200, Path.Direction.CCW);

起始点是(200, 0),结束点是(0, 200),桃子在下面

path.setLastPoint(200, 200);
图片.png

其它的路径就不举例了,总之,如果是闭合区域,我们首先需要确定的是顺时针还是逆时针,进而推敲出路径的起始点和结束点。

(8)addArcarcTo

addArc:直接添加一个圆弧到path中

arcTo: 直接添加一个圆弧到path中,并且将当前路径的起始点和上一个路径的结束点连接。

arcTo(RectF oval, float startAngle, float sweepAngle, boolean forceMoveTo) 
arcTo(RectF oval, float startAngle, float sweepAngle)
arcTo(float left, float top, float right, float bottom, float startAngle, float sweepAngle, boolean forceMoveTo)

addArcarcTo都是圆弧,这里主要展示一下它们的区别?

使用lineTo和addArc绘制直线和圆弧

        path.lineTo(100, 100);
        RectF rectF = new RectF();
        rectF.left = 150;
        rectF.top = 100;
        rectF.right = 300;
        rectF.bottom = 200;
        path.addArc(rectF, 30,60);
        canvas.drawPath(path, mPaint);
图片.png

我们发现直线和圆弧互不相连。现在将addArc替换成arcTo

        path.lineTo(100, 100);
        RectF rectF = new RectF();
        rectF.left = 150;
        rectF.top = 100;
        rectF.right = 300;
        rectF.bottom = 200;
        path.arcTo(rectF, 30, 60);
        canvas.drawPath(path, mPaint);
图片.png

我们发现,当前圆弧路径的起始点和上一个路径的终点相连了。

在方法里面有个参数forceMoveTo

true: 不相连,相当于addArc
false: 当前圆弧路径的起始点和上一个路径的终点相连;

(9) computeBounds

计算path的边界。

computeBounds(RectF bounds, boolean exact)

bounds:矩形边界
exact:这个参数已经没用了

        mPaint.setColor(Color.BLUE);

        Path path = new Path();
        path.lineTo(200, 200);
        path.moveTo(-200, -200);
        path.lineTo(-300,100);
        RectF rectF = new RectF();
        path.computeBounds(rectF, false);
        path.addRect(rectF, Path.Direction.CW);
        canvas.drawPath(path, mPaint);

效果图如下:

图片.png

代码中画了两条线段,然后画了一个矩形,但是奇怪的是,这个矩形没有设置任何边界大小,computeBounds是一个很有意思的方法,它可以自动计算两条线段所在的边界范围,所以当绘制这个矩形的时候其边界不是(0,0,0,0),当Path的轨迹所占数量为0或者1时,绘制这个矩形的时候其边界是(0,0,0,0)。

(10)incReserve(int extraPtCount)

提示路径以准备添加更多点。这可以允许更有效地分配其存储的路径。

extraPtCount: 可以添加到这个的额外点数

(11)isEmpty()

判断Path的路径是否为空,如果Path没有路径,则说明Path的路径是空的。

(12)isRect

判断Path是否是矩形路径。

        Path path = new Path();
        path.lineTo(0, 0);
        path.lineTo(200, 0);
        path.lineTo(200, 200);
        path.lineTo(0,200);
        boolean isRect = path.isRect(rectF);
        //path.addRect(rectF, Path.Direction.CW);
        canvas.drawPath(path, mPaint);

computeBounds有点类似,都是计算当前路径的边界,但是又和 computeBounds不同:

  • isRect只是判断当前Path的路径是否是矩形;
  • isRect传递一个rectF参数,如果返回true,则被计算之后的rectF和computeBounds效果一样,添加path.addRect(rectF, Path.Direction.CW)同样可以绘制出矩形边界;
  • isRect传递一个rectF参数,如果返回false(Path的路径非矩形),rectF大小就是(0,0,0,0),此时rectF将被忽略,如果这时再添加path.addRect(rectF, Path.Direction.CW),rectF将不再被忽略,rectF将被绘制出来,由于rectF的大小是(0,0,0,0),所以之前的非矩形路径随之被隐藏。
(13)isConvex()

判断曲线是否具有凸性。

首先我们绘制两条直线,两条直线的结束点和起始点相连,如图所示

图片.png

其中(0,0)我们称之为曲线的拐点,下面我们设置一下Path效果,让这个曲线更像一个曲线吧

        mPaint.setPathEffect(new CornerPathEffect(200));
图片.png

定义: 如果曲线上任意两点都在曲线的方,则这个曲线具有上凸特性。

我们再画一个曲线,如下图:

图片.png

定义: 如果曲线上任意两点都在曲线的方,则这个曲线具有下凸特性。

再画一个曲线,如下图:

图片.png

像这样的曲线既不满足上凸的特性,也不满足下凸的特性,所以该曲线没有凸性

再画一个,如下图:

        RectF rect = new RectF(0,0,400,400);
        path.addRect(rect, Path.Direction.CCW);
图片.png

那么这个矩形是否符合凸性呢?

想要搞清楚这个问题,必须搞清楚凸性的起点和终点,我们可以通过setLastPoint方法来找出凸性的起点和终点。

第一次实验:

        RectF rect = new RectF(0,0,400,400);
        path.addRect(rect, Path.Direction.CCW);

        path.setLastPoint(100, 200);
图片.png

由于矩形是按照逆时针的方式绘制,并且setLastPoint之后图形变成上图的样子,那么可以证明,图形的起始点(0,0),原来终点是(400,0),现在终点是(100,200),由于矩形是闭合区域所以起点终点相连之后形成了闭合图形,现在我们不让它闭合,擦除多余的部分:

图片.png

此时,该曲线完全符合上凸特性,我们称之为,当矩形按照逆时针绘制后的图形,具有凸性

第二次试验:

        RectF rect = new RectF(0,0,400,400);
        path.addRect(rect, Path.Direction.CW);

        path.setLastPoint(200, 100);
图片.png

由于矩形是按照顺时针的方式绘制,并且setLastPoint之后图形变成上图的样子,那么可以证明,图形的起始点(0,0),原来终点是(0,400),现在终点是(200,100),由于矩形是闭合区域所以起点终点相连之后形成了闭合图形,现在我们不让它闭合,擦除多余的部分:

图片.png

此时,该曲线完全符合下凸特性,我们称之为,当矩形按照顺时针绘制后的图形,具有凸性

isConvex总结:
  • isConvex是API 21新增的接口,其使用量也相对较少;
  • 判断一个图形是否具有凸性,并不是靠猜,而是有方法的;
  • 需要对数学几何的上凸下凸的特性具有一定的了解;
  • 需要找到图形的起点终点,如果是封闭区域,需要擦除起点终点连接(path.close())的区域,让图形变成不再闭合,最终判断曲线是否符合凸性
  • 可以结合setLastPoint方法寻找图形的起点终点
  • 上面判断图形是否具有凸性的方法写的很明白了,其它图形(比如:圆) 也可通过这个方法判断是否具有凸性
(14)setFillTypeisInverseFillType

setFillType: 设置Path的填充类型,指定内部的计算方式;

其填充类型有:
FillType .WINDING: 填充每一个封闭路径。
FillType .EVEN_ODD: 填充每个封闭路径不重合的地方。
FillType .INVERSE_WINDING:WINDING相反,WINDING填充每个封闭路径的内部,而INVERSE_WINDING填充每个封闭路径的外部空间。
FillType .INVERSE_EVEN_ODD:EVEN_ODD相反,它填充封闭路径之外的空间和路径和路径相交的空间。

演示这些填充类型之前,需要将Paint的样式改为填充模式

        mPaint.setStyle(Paint.Style.FILL_AND_STROKE);

或者

        mPaint.setStyle(Paint.Style.FILL);
  • FillType.WINDING
图片.png
  • FillType.EVEN_ODD
    图片.png
  • FillType.INVERSE_WINDING
图片.png
  • FillType.INVERSE_EVEN_ODD
图片.png

isInverseFillType: 判断是否为反转填充类型。

反转填充类型有两种FillType.INVERSE_WINDINGFillType.INVERSE_EVEN_ODD,我们看一下源码

/**
 * Returns true if the filltype is one of the INVERSE variants
 *
 * @return true if the filltype is one of the INVERSE variants
 */
public boolean isInverseFillType() {
    final int ft = nGetFillType(mNativePath);
    return (ft & FillType.INVERSE_WINDING.nativeInt) != 0;
}

核心语句是(ft & FillType.INVERSE_WINDING.nativeInt) != 0;,核心算法是按位与计算取值范围,按位与(&)使数字分组:

第一组: 取值范围是 20
第二组: 取值范围是 [21,22
第三组: 取值范围是 [22,23
第四组: 取值范围是 [23,24
依次类推...

算法特性: 同一范围内的两数的&运算,等于当前范围的最小数(比如5&6=4),不同范围的&运算结果为0;

根据这个算法特性,我们再来看下代码;
分析:

  • 填充类型取值分别是:0,1,2,3
  • FillType.INVERSE_WINDING的取值是2,FillType.INVERSE_EVEN_ODD的取值是3,这两个反转填充类型的取值正好都在第二组范围。(我想接下来不需要我解释了吧)
(15)set(Path src)

将原有Path,替换为src。

(16)offset

将Path平移到指定点。

offset(float dx, float dy)
offset(float dx, float dy, @Nullable Path dst)

offset有两个方法。

方法一:

        Path path = new Path();
        path.addCircle(0,0,200, Path.Direction.CW);
        path.offset(100, 100);
        canvas.drawPath(path, mPaint);

效果如下:


图片.png

方法二:

        Path path = new Path();
        path.addCircle(0,0,200, Path.Direction.CW);
        Path path1 = new Path();
        path.offset(100, 100, path1);
        canvas.drawPath(path, mPaint);
        canvas.drawPath(path1, mPaint);

效果如下:

图片.png
(17)Op
op(Path path, Op op)
op(Path path1, Path path2, Op op)

组合两条路径时可以执行的逻辑操作。

逻辑操作有:

Op.DIFFERENCE: 从第一条路径中减去第二条路径。

        Path path = new Path();
        path.addCircle(-100,-100,200, Path.Direction.CW);
        Path newPath = new Path();
        newPath.addCircle(100,100,200, Path.Direction.CW);
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
            path.op(newPath, Path.Op.DIFFERENCE);
        }
        canvas.drawPath(path, mPaint);
图片.png

Op.INTERSECT: 两条路径相交。

        Path path = new Path();
        path.addCircle(-100,-100,200, Path.Direction.CW);
        Path newPath = new Path();
        newPath.addCircle(100,100,200, Path.Direction.CW);
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
            path.op(newPath, Path.Op.INTERSECT);
        }
        canvas.drawPath(path, mPaint);
图片.png

Op.UNION: 把这两条路联合起来。

        Path path = new Path();
        path.addCircle(-100,-100,200, Path.Direction.CW);
        Path newPath = new Path();
        newPath.addCircle(100,100,200, Path.Direction.CW);
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
            path.op(newPath, Path.Op.UNION);
        }
        canvas.drawPath(path, mPaint);
图片.png

Op.XOR: 排他或两条路。

        Path path = new Path();
        path.addCircle(-100,-100,200, Path.Direction.CW);
        Path newPath = new Path();
        newPath.addCircle(100,100,200, Path.Direction.CW);
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
            path.op(newPath, Path.Op.XOR);
        }
        canvas.drawPath(path, mPaint);
图片.png

Op.REVERSE_DIFFERENCE: 从第二条路径中减去第一条路径。

        Path path = new Path();
        path.addCircle(-100,-100,200, Path.Direction.CW);
        Path newPath = new Path();
        newPath.addCircle(100,100,200, Path.Direction.CW);
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
            path.op(newPath, Path.Op.REVERSE_DIFFERENCE);
        }
        canvas.drawPath(path, mPaint);
图片.png
(18)reset()rewind()

将Path清空。

reset: 不保留内部数据结构,但会保留FillType。
rewind: 会保留内部的数据结构,但不保留FillType。

一般使用reset

(19)toggleInverseFillType

切换填充规则(即原有规则与反向规则之间相互切换)

FillType .WINDINGFillType .INVERSE_WINDING相互切换。
FillType .EVEN_ODDFillType .INVERSE_EVEN_ODD相互切换。

(20)transform
transform(Matrix matrix)
transform(Matrix matrix, Path dst)

对Path进行矩阵操作,我们就拿矩阵的旋转操作来演示,代码如下:

        mPaint.setColor(Color.BLUE);

        Path path = new Path();
        
        path.addCircle(100,100,200, Path.Direction.CW);
        path.addCircle(-100,-100,200, Path.Direction.CW);

        Matrix matrix = new Matrix();
        matrix.setRotate(degrees);
        path.transform(matrix);

        canvas.drawPath(path, mPaint);

        degrees = degrees + 2;

        invalidate();
54.gif

另外,第二个方法有个参数dst,意思就是:Path在矩阵操作之后,在Path保存到dst对象。

(21)rMoveTorLineTo
  • moveTorMoveTo的区别?

moveTo: 移动的是画笔的位置;
rMoveTo: 不仅移动画笔的位置,而且直角坐标系也随之移动,此时画笔的位置相当于没有变化。

  • lineTorLineTo的区别?

moveTo: 移动的是画笔的位置;
rMoveTo: 不仅移动画笔的位置,而且直角坐标系也随之移动,此时画笔的位置相当于没有变化。

(22)quadTo、cubicTo、rQuadTo、rCubicTo

贝赛尔曲线是Path的一个非常重要的知识点,我给它单独整理了一篇文章,如下:

高级UI<第二十八篇>:贝赛尔曲线

[本章完...]

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