高级UI---LSN-5-1-Canvas(变换技巧,状态保存)

前言

在前面两次课我们把Paint关于UI颜色样式的处理进行了学习, 其实真正高级部分就是三个点,渲染,滤镜,图形组合,而我们图形绘制比较重要的另一个对象Canvas也是需要我们去重点掌握的,那么这次课咱们来进行Canvas的深层次的学习,主要了解有两个点1.Canvas的变换使用技巧,2.Canvas的状态,Canvas Layer

1.Canvas基本概念

直面意思是画布,其实是分装的一个工具类(绘制会话,用来和底层沟通最终交给底层绘制)
一个Canvas类对象有四大基本要素
1、一个是用来保存像素的bitmap
2、一个Canvas在Bitmap上进行绘制操作
3、绘制的东西
4、绘制的画笔Paint

1.Canvas变换操作----坐标系概念

在我们进行canvas操作的时候我们会有一个问题产生,在进行图形的平移,旋转操作时,我们没有去更改原始的坐标,只通过了非常简单的几个api就直接进行了
移动,那么中间他的具体到底是发生了什么,通过之前在绘制流程当中draw时我们发现在下面我已经缩减了之后的代码上我门发现, 在绘制之初就产生了一个矩形,并且他通过面板进行了一次初始化

 private void draw(boolean fullRedrawNeeded) {
    Surface surface = mSurface;
   ...

    final Rect dirty = mDirty;
    if (mSurfaceHolder != null) {
        // The app owns the surface, we won't draw.
        dirty.setEmpty();
        if (animating && mScroller != null) {
            mScroller.abortAnimation();
        }
        return;
    }

    if (fullRedrawNeeded) {
        mAttachInfo.mIgnoreDirtyState = true;
        dirty.set(0, 0, (int) (mWidth * appScale + 0.5f), (int) (mHeight * appScale + 0.5f));
    }

    int xOffset = -mCanvasOffsetX;
    int yOffset = -mCanvasOffsetY + curScrollY;
    final WindowManager.LayoutParams params = mWindowAttributes;
    final Rect surfaceInsets = params != null ? params.surfaceInsets : null;
    if (surfaceInsets != null) {
        xOffset -= surfaceInsets.left;
        yOffset -= surfaceInsets.top;

        // Offset dirty rect for surface insets.
        dirty.offset(surfaceInsets.left, surfaceInsets.right);
    }
  ......

            if (!drawSoftware(surface, mAttachInfo, xOffset, yOffset, scalingRequired, dirty)) {
                return;
            }
        }
    }

    if (animating) {
        mFullRedrawNeeded = true;
        scheduleTraversals();
    }
}

那么在上面的代码当中我门可以看到在绘制开始之初,在底层就确定了一个绘制区域,确定了canvas绘制位置的坐标,那么这个就是被称之为我门canvas的坐标系,确定我门canvas绘制图形的位置
那么,当我们进行了

    canvas.translate(50, 50);
    canvas.rotate(45);
    canvas.scale(1.5f, 0.5f);
    canvas.skew(1.73f, 0);

等操作的时候,我们的图形绘制会直接发生改变,那么这个时候我门考虑一个问题,下图中绿色的点移动到红色的点,我门刚才所设置的canvas移动了吗

image.png

其实很多通过会在这里认为我们canvas的坐标进行了移动,其实不然,在
Canvas里面牵扯两种坐标系:Canvas自己的坐标系、绘图坐标系

Canvas的坐标系,

它就在View的左上角,做坐标原点往右是X轴正半轴,往下是Y轴的正半轴,有且只有一个,唯一不变这一个其实就是在我们canvas当中在绘制之初由surface所初始化的那个点

绘图坐标系

它不是唯一不变的,它与Canvas的Matrix有关系,当Matrix发生改变的时候,绘图坐标系对应的进行改变,他有一个特性就是在这个过程中是不可逆的

那么其实实际就是我门在画图的时候,有一块总面板,总面板不动, 而当我在开始进行绘制图形的时候,有一个时时刻刻在动的面板,而这个面板就是具体去绘制我们图形的画板

image.png

那么里层的绘图坐标系他的实际是用一个Matrix矩阵表示的
这个和我门之前的滤镜矩阵表示差不多,只不过,绘图坐标系的矩阵是一个2x2的矩阵传入的值是由我们的canvas进行解析之后将自己想要的数据给底层底层自己计算所得

public void drawRect(@NonNull Rect r, @NonNull Paint paint) {
    throwIfHasHwBitmapInSwMode(paint);
    drawRect(r.left, r.top, r.right, r.bottom, paint);
}

那么在这里我们可以看到在进入底层native方法之前,实现会根据每一种绘制的不同对底层的数据进行传入, 然后会计算出我门的绘制坐标系(此处底层不看,涉及c,我们这里明白这一点就行)

我们通过简单设置translate、rotate、scale、skew来改变我们绘制图形的位置
时他的计算时依赖与另外一个矩阵来对绘图坐标系进行改变
这是一个3x3的矩阵,它里面的九个参数
cosX -sinX translateX
sinX cosX translateY
0 0 scale
其中,sinX和cosX,代表的是旋转角度的sin和cos值。注意旋转的正方向是顺时针方向。translateX和translateY代表的是平移的X和Y。scale代表的是缩放的大小。

我们可以通过getMatrix()的到这个矩阵,而通过看到底层源码,这里我能清晰的看到我们是直接调用底层的矩阵

@Deprecated
public void getMatrix(@NonNull Matrix ctm) {
    nGetMatrix(mNativeCanvasWrapper, ctm.native_instance);
}

那么这里我做了一组测试

 RectF r = new RectF(0, 0, 400, 500);
    paint.setColor(Color.GREEN);
    canvas.drawRect(r, paint);
    float[] fs = new float[10];
            canvas.getMatrix().getValues(fs);
    for (int i = 0;i < fs.length;i++){
        Log.i("barry","fs:"+fs[i]);
    }
    

    //平移
    canvas.translate(50, 50);
    float[] fs2 = new float[10];
    canvas.getMatrix().getValues(fs2);
    for (int i = 0;i < fs2.length;i++){
        Log.i("barry","fs2:"+fs2[i]);
    }

    paint.setColor(Color.BLUE);
    canvas.drawRect(r, paint);
image.png

可以很明显看到,矩阵进行平移之后这个矩阵信息的变化

那么注意,绘图矩阵的坐标系移动是一个不可逆转的状态也就是说,一旦矩阵移动完成之后,那么他不能回到之前的位置,具体效果如下

Canvas坐标系.png

但是在我门的Canvas当中提供了save和restore方法来保存和还原变化操作,

    RectF r = new RectF(0, 0, 400, 500);
    paint.setColor(Color.GREEN);
    //画完之后,绘图坐标系定位在此处
    canvas.drawRect(r, paint);
    //save保存当前坐标
    canvas.save();

    //平移之后,坐标系发生改变
    canvas.translate(50, 50);
    
    paint.setColor(Color.BLUE);
    canvas.drawRect(r, paint);
    //通过restore进行还原到save保存时的坐标系
    canvas.restore();
image.png

但是想要知道这两个方法是怎么进行操作的才能让我们更加深入的去熟悉Canvas的使用技巧,那么我门必须去了解Canvas的状态栈、Layer栈

2.Canvas的状态保存---状态栈、Layer栈
状态栈


在前面我们提到坐标系的转换是一个不可逆转的,而我们可以通过save来进行保存restore进行恢复,其实我们在进行save操作时在canvas当中会将我门save下来的坐标系进行保存到一个栈当中,并且可以通过restore或者是restoreToCount进行操作下面通过一段测试代码我门印证下

public class MyView extends View {


private static final String TAG = "BARRY";

private Paint mPaint = null;
private Bitmap mBitmap = null;

public MyView(Context context) {
    this(context, null);
}

public MyView(Context context, AttributeSet attrs) {
    super(context, attires
    mBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.lsj);
    init();
}

public MyView(Context context, AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
}

private void init() {
    mPaint = new Paint();
    mPaint.setColor(Color.RED);
    mPaint.setAntiAlias(true);
    mPaint.setStyle(Paint.Style.FILL);
    mPaint.setStrokeWidth(10);
}

@Override
protected void onDraw(Canvas canvas) {
    //第1次保存,并通过canvas.getSaveCount的到当前状态栈容量
    canvas.save();
    Log.i(TAG, "Current SaveCount = " + canvas.getSaveCount());

    canvas.translate(400, 400);
    RectF rectF = new RectF(0,0,600,600);


    canvas.drawBitmap(mBitmap, null, rectF, mPaint);
    //第2次保存,并通过canvas.getSaveCount的到当前状态栈容量
    canvas.save();
    Log.i(TAG, "Current SaveCount = " + canvas.getSaveCount());

    canvas.rotate(45);

    canvas.drawBitmap(mBitmap, null, rectF, mPaint);
    //第3次保存,并通过canvas.getSaveCount的到当前状态栈容量
    canvas.save();
    Log.i(TAG, "Current SaveCount = " + canvas.getSaveCount());

    canvas.rotate(45);

    canvas.drawBitmap(mBitmap, null, rectF, mPaint);
    //第4次保存,并通过canvas.getSaveCount的到当前状态栈容量
    canvas.save();
    Log.i(TAG, "Current SaveCount = " + canvas.getSaveCount());
    //通过canvas.restoreToCount出栈到第三层状态
    canvas.restoreToCount(3);
    Log.i(TAG, "restoreToCount--Current SaveCount = " + canvas.getSaveCount());

    canvas.translate(0, 200);

    //rectF = new RectF(0,0,600,600);
    canvas.drawBitmap(mBitmap, null, rectF, mPaint);
    //通过canvas.restoreToCount出栈到第1层(最原始的那一层)状态
    canvas.restoreToCount(1);
    Log.i(TAG, "restoreToCount--Current SaveCount = " + canvas.getSaveCount());
    canvas.drawBitmap(mBitmap, null, rectF, mPaint);


}

}

image.png
Screenshot_20180622-112754.png
image.png

那么其实我们这样可以直接明白, 每一次的save其实实际上是用了一个栈保存了我的绘图坐标系,这个栈被我们称之为状态栈起来, 而我门的restore就是一个出栈的过程
save、 restore方法来保存和还原变换操作Matrix以及Clip剪裁


Layer栈

在我们的canvas当中,提供了一个saveLayer的api主要做用是用来新建一个图层
后续的绘图操作都在新建的layer上面进行
当我们调用restore 或者 restoreToCount 时 更新到对应的图层和画布上


Canvas_Layer.png

下面通过这段测试代码的效果我门来验证当前的结论

public class MyView extends View {

Paint mPaint;
float mItemSize = 0;
float mItemHorizontalOffset = 0;
float mItemVerticalOffset = 0;
float mCircleRadius = 0;
float mRectSize = 0;
int mCircleColor = 0xffffcc44;//黄色
int mRectColor = 0xff66aaff;//蓝色
float mTextSize = 25;

private static final Xfermode[] sModes = {
        new PorterDuffXfermode(PorterDuff.Mode.CLEAR),
        new PorterDuffXfermode(PorterDuff.Mode.SRC),
        new PorterDuffXfermode(PorterDuff.Mode.DST),
        new PorterDuffXfermode(PorterDuff.Mode.SRC_OVER),
        new PorterDuffXfermode(PorterDuff.Mode.DST_OVER),
        new PorterDuffXfermode(PorterDuff.Mode.SRC_IN),
        new PorterDuffXfermode(PorterDuff.Mode.DST_IN),
        new PorterDuffXfermode(PorterDuff.Mode.SRC_OUT),
        new PorterDuffXfermode(PorterDuff.Mode.DST_OUT),
        new PorterDuffXfermode(PorterDuff.Mode.SRC_ATOP),
        new PorterDuffXfermode(PorterDuff.Mode.DST_ATOP),
        new PorterDuffXfermode(PorterDuff.Mode.XOR),
        new PorterDuffXfermode(PorterDuff.Mode.DARKEN),
        new PorterDuffXfermode(PorterDuff.Mode.LIGHTEN),
        new PorterDuffXfermode(PorterDuff.Mode.MULTIPLY),
        new PorterDuffXfermode(PorterDuff.Mode.SCREEN)
};

private static final String[] sLabels = {
        "Clear", "Src", "Dst", "SrcOver",
        "DstOver", "SrcIn", "DstIn", "SrcOut",
        "DstOut", "SrcATop", "DstATop", "Xor",
        "Darken", "Lighten", "Multiply", "Screen"
};

public MyView(Context context) {
    super(context);
    init(null, 0);
}

public MyView(Context context, AttributeSet attrs) {
    super(context, attires
    init(attrs, 0);
}

public MyView(Context context, AttributeSet attrs, int defStyle) {
    super(context, attrs, defStyle);
    init(attrs, defStyle);
}

private void init(AttributeSet attrs, int defStyle) {
    if(Build.VERSION.SDK_INT >= 11){
        setLayerType(LAYER_TYPE_SOFTWARE, null);
    }
    mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
    mPaint.setTextSize(mTextSize);
    mPaint.setTextAlign(Paint.Align.CENTER);
    mPaint.setStrokeWidth(2);
}

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    //设置背景色
    canvas.drawARGB(255, 139, 197, 186);

    int canvasWidth = canvas.getWidth();
    int canvasHeight = canvas.getHeight();

    for(int row = 0; row < 4; row++){
        for(int column = 0; column < 4; column++){
            canvas.save();
            //此处是建立新的图层
            int layer = canvas.saveLayer(0, 0, canvasWidth, canvasHeight, null, Canvas.ALL_SAVE_FLAG);
            mPaint.setXfermode(null);
            int index = row * 4 + column;
            float translateX = (mItemSize + mItemHorizontalOffset) * column;
            float translateY = (mItemSize + mItemVerticalOffset) * row;
            canvas.translate(translateX, translateY);
            //画文字
            String text = sLabels[index];
            mPaint.setColor(Color.BLACK);
            float textXOffset = mItemSize / 2;
            float textYOffset = mTextSize + (mItemVerticalOffset - mTextSize) / 2;
            canvas.drawText(text, textXOffset, textYOffset, mPaint);
            canvas.translate(0, mItemVerticalOffset);
            //画边框
            mPaint.setStyle(Paint.Style.STROKE);
            mPaint.setColor(0xff000000);
            canvas.drawRect(2, 2, mItemSize - 2, mItemSize - 2, mPaint);
            mPaint.setStyle(Paint.Style.FILL);
            //画圆
            mPaint.setColor(mCircleColor);
            float left = mCircleRadius + 3;
            float top = mCircleRadius + 3;
            canvas.drawCircle(left, top, mCircleRadius, mPaint);
            mPaint.setXfermode(sModes[index]);
            //画矩形
            mPaint.setColor(mRectColor);
            float rectRight = mCircleRadius + mRectSize;
            float rectBottom = mCircleRadius + mRectSize;
            canvas.drawRect(left, top, rectRight, rectBottom, mPaint);
            mPaint.setXfermode(null);
            //canvas.restore();
            canvas.restoreToCount(layer);
        }
    }
}

@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
    super.onSizeChanged(w, h, oldw, old);)
    mItemSize = w / 4.5f;
    mItemHorizontalOffset = mItemSize / 6;
    mItemVerticalOffset = mItemSize * 0.426f;
    mCircleRadius = mItemSize / 3;
    mRectSize = mItemSize * 0.6f;
}
}
没有加saveLayer的效果.png
加了saveLayer的效果.png

这段代码我门可以看到,其实实际上就是我们上次课当中,xfermode的演示代码,而在这段代码当中我才用了saveLayer进行操作,通过上面两个结果,一个是我加了saveLayer的,一个是没加的, 那么从中我门可以明显看到在没有加的时候,xfermode的像素输出效果直接将外层背景色也给清空了,而加入之后没有,那么其实我门可以很明显的明白如果用了layer那么其实实际上我们是在当前这个canvas图形上面新建了一个图层当我们调用restore 或者 restoreToCount 时 我们的绘制会更新到当前图层

那么这个时候我门来详细分析saveLayer的参数

canvas.saveLayer(0, 0, canvasWidth, canvasHeight, null, Canvas.ALL_SAVE_FLAG);
/**
 * Helper version of saveLayer() that takes 4 values rather than a RectF.
 *
 * @deprecated Use {@link #saveLayer(float, float, float, float, Paint)} instead.
 */
public int saveLayer(float left, float top, float right, float bottom, @Nullable Paint paint,
        @Saveflags int saveFlags) {
    return nSaveLayer(mNativeCanvasWrapper, left, top, right, bottom,
            paint != null ? paint.getNativeInstance() : 0,
            saveFlags);
}

通过上诉方法的注释,以及代码我门明显知道,前面四个参数,为上下左右四个点构成一个图层区,Paint画笔也可以继承过来,而最后一个参数表示的是我们当前的保存形式,总共下面6种,这六个模式其实实际上讲的就是告诉canvas当前保存那些信息

MATRIX_SAVE_FLAG:只保存图层的matrix矩阵 save,saveLayer
CLIP_SAVE_FLAG:只保存大小信息 save,saveLayer
HAS_ALPHA_LAYER_SAVE_FLAG:表明该图层有透明度,和下面的标识冲突,都设置时以下面的标志为准 saveLayer
FULL_COLOR_LAYER_SAVE_FLAG:完全保留该图层颜色(和上一图层合并时,清空上一图层的重叠区域,保留该图层的颜色) saveLayer
CLIP_TO_LAYER_SAVE_:创建图层时,会把canvas(所有图层)裁剪到参数指定的范围,如果省略这个flag将导致图层开销巨大(实际上图层没有裁剪,与原图层一样大)
ALL_SAVE_FLAG:保存所有信息 save,saveLayer

从源码当中我发现其他几种模式在高版本当中已经剔除,只保留了一种。就是我门的all_save_flag

  /** @hide */
@IntDef(flag = true,
        value = {
            ALL_SAVE_FLAG
        })
@Retention(RetentionPolicy.SOURCE)
public @interface Saveflags {}

那么这个时候我们来测试一下

 public class MyView3 extends View {

public MyView3(Context context) {
    super(context);
}

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);

    RectF rectF = new RectF(0,0,400,500);
    Paint paint = new Paint();
    paint.setStyle(Paint.Style.STROKE);
    paint.setStrokeWidth(10);
    paint.setColor(Color.GREEN);

    canvas.drawRect(rectF, paint);
    canvas.translate(50,50);

    canvas.saveLayer(0,0,canvas.getWidth(),canvas.getHeight(),null,Canvas.ALL_SAVE_FLAG);
    //canvas.save();
    canvas.drawColor(Color.BLUE);// 通过drawColor可以发现saveLayer是新建了一个图层,
                                // 同时结合Lsn5的16种Xfermode叠加形式Demo可以验证是新建的透明图层
    paint.setColor(Color.YELLOW);
    canvas.drawRect(rectF,paint);
    //canvas.restore();
    canvas.restore();

    RectF rectF1 = new RectF(10,10,300,400);
    paint.setColor(Color.RED);
    canvas.drawRect(rectF1,paint);

}
加了saveLayer.png
没加saveLayerpng

那么这段代码也验证了我门上诉的理论,在加了saveLayer之后,背景色被绘制到了另外一个图层导致前面有一节空白的,同时也得出了一个有趣的结论,貌似,平移操作也被继承了,其实这里我们的出一个结论saveLayer会将之前的一些Canvas状态操作延续过来。这里是通过之前的最后一个参数设置成ALL_SAVE_FLAG完成。他在新建图层的时候完成了保留当前所有信息状态的操作.

总结

Canvas里面牵扯两种坐标系:Canvas自己的坐标系、绘图坐标系
Canvas的坐标系
    它就在View的左上角,做坐标原点往右是X轴正半轴,往下是Y轴的正半轴,有且只有一个,唯一不变
绘图坐标系
    它不是唯一不变的,它与Canvas的Matrix有关系,当Matrix发生改变的时候,绘图坐标系对应的进行改变,
    同时这个过程是不可逆的(save和restore方法来保存和还原变化操作)
    Matrix又是通过我们设置translate、rotate、scale、skew来进行改变的
  Canvas的状态保存---状态栈、Layer栈
状态栈--save、 restore方法来保存和还原变换操作Matrix以及Clip剪裁
    也可以通过restoretoCount直接还原到对应栈的保存状态
Layer栈--- saveLayer的时候都会新建一个透明的图层(离屏Bitmap-离屏缓冲),并且会将saveLayer之前的一些Canvas操作延续过来
      后续的绘图操作都在新建的layer上面进行
      当我们调用restore 或者 restoreToCount 时 更新到对应的图层和画布上

著作:Kerwin Barry
邮箱:kerwin0210@sina.com
原创博客,转载请注明出处.....

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

推荐阅读更多精彩内容