听说懂Canvas的人运气都不会太差!

1.前言

逛街的时候,看到一篇Android Canvas 方法总结,这篇文章将Canvas一些基本操作介绍的很详细。从零开始的朋友可以先去刷点经验,剩下的同学拿起手术刀,我们一起来将Canvas血腥解剖吧。

2.Canvas简介

官方文档介绍如下

The Canvas class holds the "draw" calls. To draw something, you need 4 basic components:
a Bitmap to hold the pixels, 
a Canvas to host the draw calls (writing into the bitmap), 
a drawing primitive (e.g. Rect, Path, text, Bitmap), 
a paint (to describe the colors and styles for the drawing).

用人话说大概是这样

一个Canvas类对象有四大基本要素
1、用来保存像素的Bitmap
2、用来在Bitmap上进行绘制操作的Canvas
3、要绘制的东西
4、绘制用的画笔Paint

Bitmap和Canvas的关系类似于画板与画布,不理解没有关系,后面还会详细介绍的。

3.Canvas基本绘制

一开始,先来点简单的基础题。因为太懒了不想从头写起,我就假设大家都看过了Android Canvas 方法总结,来写一些这里面没有介绍的方法。

3.1 圆角矩形

画笔初始化

mPaint = new Paint();
        mPaint.setStyle(Paint.Style.STROKE);
        mPaint.setStrokeWidth(10);
        mPaint.setColor(Color.RED);

onDraw()中进行绘制,参数二、三越大,圆角半径越大

 private void drawRoundRect(Canvas canvas) {
        RectF r = new RectF(100, 100, 400, 500);
        //x-radius ,y-radius圆角的半径
        canvas.drawRoundRect(r, 80, 80, mPaint);
    }

效果图如下

圆角矩形.png

3.2 圆角矩形路径

那么有的时候,我不需要那么整齐的圆角,该怎么办呢

private void drawRoundRectPath(Canvas canvas) {
        RectF r = new RectF(100, 100, 400, 500);
        Path path = new Path();
        float radii[] = {80, 80, 80, 80, 80, 80, 20, 20};
        path.addRoundRect(r, radii, Path.Direction.CCW);
        canvas.drawPath(path, mPaint);
    }

这里的radii就定义了四个角的弧度,接着使用Path就可以将其绘制出来。事实上,Path是无所不能的,我们将在以后单独介绍他。

圆角矩形路径.png

3.3 Region区域

Region是区域的意思,它表示的Canvas图层上的一块封闭的区域。

来看看它的构造方法

 /** Create an empty region
    */
    public Region() {
        this(nativeConstructor());
    }

    /** Return a copy of the specified region
    */
    public Region(Region region) {
        this(nativeConstructor());
        nativeSetRegion(mNativeRegion, region.mNativeRegion);
    }

    /** Return a region set to the specified rectangle
    */
    public Region(Rect r) {
        mNativeRegion = nativeConstructor();
        nativeSetRect(mNativeRegion, r.left, r.top, r.right, r.bottom);
    }

    /** Return a region set to the specified rectangle
    */
    public Region(int left, int top, int right, int bottom) {
        mNativeRegion = nativeConstructor();
        nativeSetRect(mNativeRegion, left, top, right, bottom);
    }

看上去挺万金油的,什么都能传进去,那么Region能干嘛呢?我们可以用它来进行一些交并补集的操作,比如下面代码就能展示两个region的交集

region1.op(region2, Region.Op.INTERSECT);//交集部分 region1是调用者A,region2是求交集的B

去源码里看看这个Op,发现是个枚举类

public enum Op {
        DIFFERENCE(0),
        INTERSECT(1),
        UNION(2),
        XOR(3),
        REVERSE_DIFFERENCE(4),
        REPLACE(5);
    ...
    }

可见交并补的类型还挺多,我们用一张图来介绍吧


Region_op.png

那么这里就出现了一个问题,这些Op过后的region都是不规则的了,系统要如何将他们绘制出来呢?
嘿嘿嘿,请回忆起当年被微积分支配的恐惧吧!

private void drawRegion(Canvas canvas){
        RectF r = new RectF(100, 100, 400, 500);
        Path path = new Path();
        float radii[] = {80, 80, 80, 80, 80, 80, 20, 20};
        path.addRoundRect(r, radii, Path.Direction.CCW);

        //创建一块矩形的区域
        Region region = new Region(100, 100, 600, 800);
        Region region1 = new Region();
        region1.setPath(path, region);//path的椭圆区域和矩形区域进行交集

        //结合区域迭代器使用(得到图形里面的所有的矩形区域)
        RegionIterator iterator = new RegionIterator(region1);

        Rect rect = new Rect();
        mPaint.setStrokeWidth(1);
        while (iterator.next(rect)) {
            canvas.drawRect(rect, mPaint);
        }
    }

看看代码,这里通过迭代器用一个个小矩形填满整个region控件,为了方便展示,我们把paint的类型设成stroke,效果图如下

region绘制.png

解释下,这里首先绘制了最大的那个矩形,然后在极限的距离上缩小矩形,再通过这些小矩形将剩下的部分都填充起来。

4.Canvas基本变换

4.1 Canavas坐标系

Canvas里面牵扯两种坐标系:Canvas自己的坐标系、绘图坐标系

Canvas的坐标系,它就在View的左上角,从坐标原点往右是X轴正半轴,往下是Y轴的正半轴,有且只有一个,唯一不变

绘图坐标系,它不是唯一不变的,它与Canvas的Matrix有关系,当Matrix发生改变的时候,绘图坐标系对应的进行改变,同时这个过程是不可逆的(通过相反的矩阵还原),而Matrix又是通过我们设置translate、rotate、scale、skew来进行改变的

上面这段话还是挺好理解的,我们用代码来验证下

 private void drawMatrix(Canvas canvas){
        // 绘制坐标系
        RectF r = new RectF(0, 0, 400, 500);
        mPaint.setColor(Color.GREEN);
        canvas.drawRect(r, mPaint);

        // 第一次绘制坐标轴
        canvas.drawLine(0,0,canvas.getWidth(),0,mPaint);// X 轴
        mPaint.setColor(Color.BLUE);
        canvas.drawLine(0,0,0,canvas.getHeight(),mPaint);// Y 轴

        //平移--即改变坐标原点
        canvas.translate(50, 50);
        // 第二次绘制坐标轴
        mPaint.setColor(Color.GREEN);
        canvas.drawLine(0,0,canvas.getWidth(),0,mPaint);// X 轴
        mPaint.setColor(Color.BLUE);
        canvas.drawLine(0,0,0,canvas.getHeight(),mPaint);// Y 轴

        canvas.rotate(45);
        // 第三次绘制坐标轴
        mPaint.setColor(Color.GREEN);
        canvas.drawLine(0,0,canvas.getWidth(),0,mPaint);// X 轴
        mPaint.setColor(Color.BLUE);
        canvas.drawLine(0,0,0,canvas.getHeight(),mPaint);// Y 轴
    }

运行结果如下

Canvas坐标系

剩下的translate、rotate、scale、skew等方法,在之前推荐的那篇文章里就有,这里就不再重复劳动了

5.Canvas状态保存

5.1 状态栈

状态栈通过save、 restore方法来保存和还原变换操作Matrix以及Clip剪裁,也可以通过restoretoCount直接还原到对应栈的保存状态。需要注意的是,一开始canvas就是在栈1的位置,执行一次save就进栈一次(此时为2),执行一次restore就出栈一次。

 private void saveRestore(Canvas canvas){
        RectF r = new RectF(0, 0, 400, 500);
        mPaint.setColor(Color.GREEN);
        canvas.drawRect(r, mPaint);
        canvas.save();
        //平移
        canvas.translate(50, 50);
        mPaint.setColor(Color.BLUE);
        canvas.drawRect(r, mPaint);
        canvas.restore();
        mPaint.setColor(Color.YELLOW);
        r = new RectF(0, 0, 200, 200);
        canvas.drawRect(r, mPaint);
    }

效果图如下

状态栈

有的同学可能会把状态栈理解为图层,其实这是不对滴。我们来看下save方法的源码

 /**
     * Saves the current matrix and clip onto a private stack.
     * <p>
     * Subsequent calls to translate,scale,rotate,skew,concat or clipRect,
     * clipPath will all operate as usual, but when the balancing call to
     * restore() is made, those calls will be forgotten, and the settings that
     * existed before the save() will be reinstated.
     *
     * @return The value to pass to restoreToCount() to balance this save()
     */
    public int save() {
        return native_save(mNativeCanvasWrapper, MATRIX_SAVE_FLAG | CLIP_SAVE_FLAG);
    }

注释上说的很明白,save只是保存了matrix和clip的状态,并没有保存一个真正的图层。真正的图层是在Layer栈中保存的。

5.2 Layer栈

Layer栈通过saveLayer新建一个透明的图层,并且会将saveLayer之前的一些Canvas操作延续过来,后续的绘图操作都在新建的layer上面进行,当我们调用restore或者 restoreToCount 时更新到对应的图层和画布上。

下面这段代码要仔细看

private void saveLayer(Canvas 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.drawColor(Color.BLUE);// 通过drawColor可以发现saveLayer是新建了一个图层,
        paint.setColor(Color.YELLOW);
        canvas.drawRect(rectF, paint);
        canvas.restore();

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

    }

先上效果图

saveLayer

我们慢慢解释。一开始画了个绿色的框,之后移动了(50,50)的距离,再通过saveLayer新建了一个图层。注意这里用到了Canvas.ALL_SAVE_FLAG,别的FLAG还有只保存Matrix的,只保存Clip的等等,大家可以自己去看。接着在新的图层上画了蓝色背景和黄色的矩形,从结果可以看出之前的一些Canvas操作会被延续到新的图层。调用restore后,两个图层合二为一,由于图层2是蓝色背景,因此就把图层1的绿色边框覆盖了。最后再绘制另一个红色的框,此时只有一个图层了,所以就绘制在当前图层的最上方。

文章开头说Bitmap和Canvas的关系类似于画板与画布,就是因为每次Canvas执行saveLayer时都会新建一个透明的图层,与之前的图层叠加后更新到Bitmap上,从而将绘制的内容展示出来。

这些大概就是save与saveLayer的区别所在了,如果觉得自己明白了,就去看看给女朋友化妆系列的代码,看看是否可以理解其中的save与saveLayer操作,以及为什么要这样做。

6.Drawable与Canvas

6.1 Drawable简介

下面我们将用一个例子来加深学习效果,不过开始前还需要将Drawable这位兄弟介绍给大家。首先上一段官方注释:

A Drawable is a general abstraction for "something that can be drawn."

顾名思义,Drawable就是可以被画出来的一个东西,这是一个抽象类,继承自它的实现类如下(windows中查询类继承关系的快捷键是Ctrl+H)

drawable继承结构.png

是不是发现有些熟悉的字眼?比如layer、shape、color等等。对啦,就是可以在drawable文件夹中进行定义的xml资源文件,系统会将这些xml转换成都解析成相应的drawable对象。

由于drawable是可绘制的对象,canvas是绘制的画纸,因此这两位是密不可分的好基友。除去上图中系统实现的drawable外,我们还可以根据需要自定义drawable。事实上自定义drawable才是日常会用到的东西,下面一起来看看这种基本操作。

6.1 基本操作

这个自定义控件的功能不太好描述,先展示下效果图

效果图.png

最外层是一个HorizontalScrollView,里面包裹着许多ImageView,可以拖动,中间选中的区域呈现灰色,其余的显示彩色。这些灰色、彩色的图是两套资源。

要实现上述功能,我们需要两个控件,一个是外层控制触摸事件的ViewGroup,可以通过继承HorizontalScrollView来完成。另一个是用来注入ImageView变换颜色的Drawable,这就需要自定义来实现了。

6.1.1 RevealDrawable

创建RevealDrawable继承自Drawable,需要重写public void draw(@NonNull Canvas canvas)方法。

对于每一部分的图片而言,会有以下几种绘制情况:

1.灰色
2.彩色
3.左灰右彩
4.左彩右灰

灰色和彩色好办,直接将两种图片当做参数传入RevealDrawable中,在需要时通过draw(canvas)绘制出来即可。那么混合色该怎么做?又要如何去判断左右或者说灰彩各占的比例呢?

对于问题一,我们可以用之前所说的canvas裁剪来完成,需要注意save()restore()的调用;至于问题二,Drawable源码中有这么一行参数:private int mLevel = 0;,很显然,Google早已考虑到Drawable的这种使用场景,而mLevel就是用来确定比例的,其值为0~10000,可以由我们在外层动态去设置。

总结一下,整体思路就是HorizontalScrollView根据Scroll的距离为RevealDrawable动态设置level,而RevealDrawable则根据被设置的level展示出不同的图像效果。剩下的就是数学问题了。

这里就展示其核心的绘制方法,注释都有,完整的代码等闲了整理下一起放到大型同性交友平台上。

@Override
    public void draw(Canvas canvas) {
        // 绘制
        int level = getLevel();//from 0 (minimum) to 10000 
        //三个区间
        //右边区间和左边区间--设置成灰色
        if(level == 10000|| level == 0){
            mUnselectedDrawable.draw(canvas);
        }
        else if(level==5000){//全部选中--设置成彩色
            mSelectedDrawable.draw(canvas);
        }else{
            //混合效果的Drawable
            /**
             * 将画板切割成两块-左边和右边
             */
            final Rect r = mTmpRect;
            //得到当前自身Drawable的矩形区域
            Rect bounds = getBounds();
            {
                //1.先绘制灰色部分
                //level 0~5000~10000
                //比例
                float ratio = (level/5000f) - 1f;
                int w = bounds.width();
                if(mOrientation==HORIZONTAL){
                    w = (int) (w* Math.abs(ratio));
                }
                int h = bounds.height();
                if(mOrientation==VERTICAL){
                    h = (int) (h* Math.abs(ratio));
                }
                
                int gravity = ratio < 0 ? Gravity.LEFT : Gravity.RIGHT;
                //从一个已有的bounds矩形边界范围中抠出一个矩形r
                Gravity.apply(
                        gravity,//从左边还是右边开始抠
                        w,//目标矩形的宽 
                        h, //目标矩形的高
                        bounds, //被抠出来的rect
                        r);//目标rect
                
                canvas.save();//保存画布
                canvas.clipRect(r);//切割
                mUnselectedDrawable.draw(canvas);//画
                canvas.restore();//恢复之前保存的画布
            }
            {
                //2.再绘制彩色部分
                //level 0~5000~10000
                //比例
                float ratio = (level/5000f) - 1f;
                int w = bounds.width();
                if(mOrientation==HORIZONTAL){
                    w -= (int) (w* Math.abs(ratio));
                }
                int h = bounds.height();
                if(mOrientation==VERTICAL){
                    h -= (int) (h* Math.abs(ratio));
                }
                
                int gravity = ratio < 0 ? Gravity.RIGHT : Gravity.LEFT;
                //从一个已有的bounds矩形边界范围中抠出一个矩形r
                Gravity.apply(
                        gravity,//从左边还是右边开始抠
                        w,//目标矩形的宽 
                        h, //目标矩形的高
                        bounds, //被抠出来的rect
                        r);//目标rect
                canvas.save();//保存画布
                canvas.clipRect(r);//切割
                mSelectedDrawable.draw(canvas);//画
                canvas.restore();//恢复之前保存的画布
            }       
        }
    }
6.1.2 GalleryHorizontalScrollView

外层的GalleryHorizontalScrollView继承自HorizontalScrollView,需要处理滑动事件与level设置,别忘了ScrollView的子View必须是ViewGroup

 private void init() {
        LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
                ViewGroup.LayoutParams.WRAP_CONTENT,
                ViewGroup.LayoutParams.WRAP_CONTENT
        );
        container = new LinearLayout(getContext());
        container.setLayoutParams(params);
        setOnScrollChangeListener(this);

    }

onLayout()的作用是在控件初始化时设置Padding值,以便于一开始,将第一个ImageView展示在正中间的位置(为了好看,没什么特别的用处)

 @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        super.onLayout(changed, l, t, r, b);
        View v = container.getChildAt(0);
        icon_width = v.getWidth();//单个图片的宽度
        centerX = getWidth() / 2;//整个sv的宽度
        centerX = centerX - icon_width/2;
        container.setPadding(centerX, 0, centerX, 0);
    }
起始图.png

起初触摸事件是在touch方法中完成的,后来发现这个方法精度太高,滑动抖动明显,因此换在scroll方法中执行。

private void reveal() {
        // 渐变效果
        //得到hzv滑出去的距离
        int scrollX = getScrollX();
        Log.d(TAG, "reveal: "+scrollX);
        //找到两张渐变的图片的下标--左,右
        int index_left = scrollX/icon_width;
        int index_right = index_left + 1;
        //设置图片的level
        for (int i = 0; i < container.getChildCount(); i++) {
            if(i==index_left||i==index_right){
                //变化
                //比例:
                float ratio = 5000f/icon_width;
                ImageView iv_left = (ImageView) container.getChildAt(index_left);
                //scrollX%icon_width:代表滑出去的距离
                //滑出去了icon_width/2  icon_width/2%icon_width
                iv_left.setImageLevel(
                        (int)(5000-scrollX%icon_width*ratio)
                );
                //右边
                if(index_right<container.getChildCount()){
                    ImageView iv_right = (ImageView) container.getChildAt(index_right);
                    //scrollX%icon_width:代表滑出去的距离
                    //滑出去了icon_width/2  icon_width/2%icon_width
                    iv_right.setImageLevel(
                            (int)(10000-scrollX%icon_width*ratio)
                    );
                }
            }else{
                //灰色
                ImageView iv = (ImageView) container.getChildAt(i);
                iv.setImageLevel(0);
            }
        }
    }

最后是添加图片的方法

public void addImageViews(Drawable[] revealDrawables){
        for (int i = 0; i < revealDrawables.length; i++) {
            ImageView img = new ImageView(getContext());
            img.setImageDrawable(revealDrawables[i]);
            container.addView(img);
            if(i==0){
                img.setImageLevel(5000);
            }
        }
        addView(container);
    }

7.Canvas缓存

在上面的例子中我们介绍了Canvas与自定义Drawable的配合使用,接下来我们上一个Canvas与Bitmap混合实现缓存的效果。其实这句话是废话,因为创建Canvas时就必须传入Bitmap为参,毕竟Bitmap才是真正保存像素的地方。

缓存的思想就是先将像素保存到CacheCanvas的CacheBitmap中,再将这个CacheBitmap保存到View的Canvas上。

我们在初始化方法中创建缓存对象。

private void init() {
        //创建一个与该VIew相同大小的缓冲区
        cacheBitmap = Bitmap.createBitmap(VIEW_WIDTH, VIEW_HEIGHT, Bitmap.Config.ARGB_8888);
        //创建缓冲区Cache的Canvas对象
        cacheCanvas = new Canvas();
        path = new Path();
        //设置cacheCanvas将会绘制到内存的bitmap上
        cacheCanvas.setBitmap(cacheBitmap);
        paint = new Paint();
        paint.setColor(Color.RED);
        paint.setFlags(Paint.DITHER_FLAG);
        paint.setStyle(Paint.Style.STROKE);
        paint.setStrokeWidth(5);
        paint.setAntiAlias(true);
        paint.setDither(true);//防抖动,比较清晰
    }

接着onTouch()中根据手势进行绘制,注意是绘制到缓存canvas上

 @Override
    public boolean onTouchEvent(MotionEvent event) {
        //获取拖动时间的发生位置
        float x = event.getX();
        float y = event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                path.moveTo(x, y);
                preX = x;
                preY = y;
                break;
            case MotionEvent.ACTION_MOVE:
                path.quadTo(preX, preY, x, y);//绘制圆滑曲线
                preX = x;
                preY = y;
                break;
            case MotionEvent.ACTION_UP:
                //这是是调用了cacheBitmap的Canvas在绘制
                cacheCanvas.drawPath(path, paint);
                path.reset();
                break;
        }
        invalidate();//在UI线程刷新VIew
        return true;
    }

invalidate()会回调draw()方法,此时再将缓存bitmap绘制到View的canvas中。

  @Override
    protected void onDraw(Canvas canvas) {
        Paint p = new Paint();
        //将cacheBitmap绘制到该View
        canvas.drawBitmap(cacheBitmap, 0, 0, p);
    }

这样一来,手指滑动轨迹就会略有延迟后再绘制到用户界面上,类似于写字板的效果。

cache缓存.png

8.总结

关于canvas的介绍就到此为止,了解canvas的基本绘制,知道它的两个坐标系,学会和drawable、bitmap混合使用基本就差不多了。希望能给大家带来好运。

前段时间秋招找工作实习什么的断更好久,从今天开始,立个FLAG在此,一天一篇!

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

推荐阅读更多精彩内容