Android自定义View--实现九宫格解锁图案

源码下载

项目中需求用到图案解锁的功能,就自己写了类似的功能:
说下思路:

  • 1.实现一个子类继承View
  • 2.覆盖onDrow()函数,渲染图像
  • 3.覆盖onTouchEvent()函数
  • 4.监听按下、移动,松开手指的动作
  • 5.重新在onDrow()中渲染对应的的图像

在描述功能之前,看一下效果图,理解起来会起到事半功倍的作用

整体效果图.jpg

说明

  • A、B、C、D、E、F、G、H、I代表九个坐标点
  • 左图中的圆由两个同心圆组成.
  • 中图链接起来的圆由四个同心圆组成,增加了两个绿色的圆,最外层绿色的是空心圆,红色连线是带有宽度的直线.
  • 右图线条由红色条变成了绿色.

1.实现UnlockAppView类继承View

实现左图:九个点的坐标,圆的半径及颜色。
空心圆:同圆心不同半径,绘制颜色不同
坐标如何确定:由屏幕的宽高决定,按照比例画出的效果图在各种屏幕中看起来协调.
定义所需参数:

//屏幕的宽度
private int width;
//屏幕的高度
private int height;
//大圆半径
private float rH;
//小圆半径
private int rM;
//A的坐标
private float a1, b1;
//B的坐标
private float a2, b2;
//C的坐标
private float a3, b3;
//D的坐标
private float a4, b4;
//E的坐标
private float a5, b5;
//F的坐标
private float a6, b6;
//G的坐标
private float a7, b7;
//H的坐标
private float a8, b8;
//I的坐标
private float a9, b9;
//绘制大圆用到的画笔
private Paint mPaint;
//绘制小圆用到的画笔
private Paint mPaint0;

参数命名完成,接下来开始赋值:

DisplayMetrics metric = new DisplayMetrics();
getWindowManager().getDefaultDisplay().getMetrics(metric);
//获取屏幕的宽度
width = metric.widthPixels;
//获取屏幕的高度
height = metric.heightPixels;
//以下计算是根据屏幕调试出来的合理大小,不必深究
//计算大圆的半径,
rH = (width / 3) / 5;
//计算小圆的半径,
rM = (width / 3) / 10;
//点A的横坐标,及纵坐标
a1 = (width / 3) / 2;
b1 = (width / 3) / 2 + (height - width) / 2;
//B点坐标
a2 = (width / 3) + (width / 3) / 2;
b2 = b1;
//C点坐标
a3 = (width / 3) * 2 + (width / 3) / 2;
b3 = b1;
//D点坐标
a4 = a1;
b4 = (width / 3) + (width / 3) / 2 + (height - width) / 2;
//E点坐标
a5 = a2;
b5 = b4;
//F点坐标
a6 = a3;
b6 = b4;
//G点坐标
a7 = a1;
b7 = (width / 3) * 2 + (width / 3) / 2 + (height - width) / 2;
//H点坐标
a8 = a5;
b8 = b7;
//I点坐标
a9 = a6;
b9 = b7;
//使位图抗锯齿
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
//颜色,浅灰色
mPaint.setColor(Color.LTGRAY);
//使位图抗锯齿
mPaint0 = new Paint(Paint.ANTI_ALIAS_FLAG);
//颜色,白色
mPaint0 = new Paint(Paint.WHITE);

一切准备就绪,重写onDrow()函数,重新渲染

@Override
protected void onDraw(Canvas canvas) {
//每次绘制清空画布
canvas.drawColor(Color.WHITE);

//渲染大圆,圆心(a1,b1)半径rH,画笔mPaint
canvas.drawCircle(a1, b1, rH, mPaint);
canvas.drawCircle(a2, b2, rH, mPaint);
canvas.drawCircle(a3, b3, rH, mPaint);
canvas.drawCircle(a4, b4, rH, mPaint);
canvas.drawCircle(a5, b5, rH, mPaint);
canvas.drawCircle(a6, b6, rH, mPaint);
canvas.drawCircle(a7, b7, rH, mPaint);
canvas.drawCircle(a8, b8, rH, mPaint);
canvas.drawCircle(a9, b9, rH, mPaint);
//渲染小圆
canvas.drawCircle(a1, b1, rM, mPaint0);
canvas.drawCircle(a2, b2, rM, mPaint0);
canvas.drawCircle(a3, b3, rM, mPaint0);
canvas.drawCircle(a4, b4, rM, mPaint0);
canvas.drawCircle(a5, b5, rM, mPaint0);
canvas.drawCircle(a6, b6, rM, mPaint0);
canvas.drawCircle(a7, b7, rM, mPaint0);
canvas.drawCircle(a8, b8, rM, mPaint0);
canvas.drawCircle(a9, b9, rM, mPaint0);
}

以上完成左图的渲染。

实现中图的效果

跟踪手指划过的痕迹
轨迹是否是否经过圆的区域
说明,这里圆的区域用圆的外切正方形的区域代替。
矩形对象的contains()方法可判断轨迹经过园的区域。
代码实例
rt1.contains(tX, tY)

定义园的外切正方形变量

//九个正方形区域
//左上角坐标(a1 - rH, b1 - rH)及右下角坐标(a1 + rH, b1 + rH)
private RectF rt1 = 
new RectF(a1 - rH, b1 - rH, a1 + rH, b1 + rH);
private RectF rt2 = 
new RectF(a2 - rH, b2 - rH, a2 + rH, b2 + rH);
private RectF rt3 = new RectF(a3 - rH, b3 - rH, a3 + rH, b3 + rH);
private RectF rt4 = new RectF(a4 - rH, b4 - rH, a4 + rH, b4 + rH);
private RectF rt5 = new RectF(a5 - rH, b5 - rH, a5 + rH, b5 + rH);
private RectF rt6 = new RectF(a6 - rH, b6 - rH, a6 + rH, b6 + rH);
private RectF rt7 = new RectF(a7 - rH, b7 - rH, a7 + rH, b7 + rH);
private RectF rt8 = new RectF(a8 - rH, b8 - rH, a8 + rH, b8 + rH);
private RectF rt9 = new RectF(a9 - rH, b9 - rH, a9 + rH, b9 + rH);

使用invalidate()方法,刷新整个画布。
所以需要记录轨迹经过A、B、C、D、E、F、G、H、I九个点经过的先后顺序。

定义一个String变量passwordValue存储经过坐标点的先后顺序;
两圆之间的红色线段,passwordValue记录着经过的圆的先后顺利,根据经过圆的先后顺利绘制线段;
例如:passwordValue ="ACDE"代表经过的圆的顺序圆A->圆C->圆D->圆E,绘制的线段AC、CD、DE;
线段是由起始坐标,终止坐标表示,所以需要定义一个两行两列的二维数组用来存储起始及终止坐标,第一行代表起始坐标,第二行代表终点坐标;
由于每次刷新整个画布,需要把二维数据存储在一个列表中,方便遍历渲染;

圆与线段的渲染分开来讲解,先来看看圆的渲染过程,获取手指滑动坐标,重写onTouchEvent方法

@Override
public boolean onTouchEvent(MotionEvent event)
//捕捉按下的动作
if (event.getAction() == MotionEvent.ACTION_DOWN) {

} else if (event.getAction() == MotionEvent.ACTION_MOVE) {
    //X坐标点
    float tX = event.getX();
    //Y坐标点
    float tY = event.getY();
    //首次经过圆A
    if (rt1.contains(tX, tY) && !passwordValue.contains("A")) {
         passwordValue += "A";
    } else if (rt2.contains(tX, tY) && !passwordValue.contains("B")) {//首次经过圆B
         passwordValue += "B";
    } else if (rt3.contains(tX, tY) && !passwordValue.contains("C")) {//首次经过圆C
         passwordValue += "C";
    } else if (rt4.contains(tX, tY) && !passwordValue.contains("D")) {//首次经过圆D
         passwordValue += "D";
    } else if (rt5.contains(tX, tY) && !passwordValue.contains("E")) {//首次经过圆E
         passwordValue += "E";
    } else if (rt6.contains(tX, tY) && !passwordValue.contains("F")) {//首次经过圆F
         passwordValue += "F";
    } else if (rt7.contains(tX, tY) && !passwordValue.contains("G")) {//首次经过圆G
        passwordValue += "G";
    } else if (rt8.contains(tX, tY) && !passwordValue.contains("H")) {//首次经过圆H
        passwordValue += "H";
    } else if (rt9.contains(tX, tY) && !passwordValue.contains("I")) {//首次经过圆I
        passwordValue += "I";
    }
    invalidate();// 刷新画布,回调onDraw()方法
}  else if (event.getAction() == MotionEvent.ACTION_UP)  {
        
}

确定了圆的顺序,刷新画布,渲染轨迹坐标经过的圆的效果及红色直线的效果

protected void onDraw(Canvas canvas) {
...
...
//轨迹经过圆A
if (passwordValue.contains("A")) {// (a1,b1)
     canvas.drawCircle(a1, b1, rL, mPaintOKM);
     canvas.drawCircle(a1, b1, rH, mPaintOKH);
}
//轨迹经过圆B
if (passwordValue.contains("B")) {// (a2,b2)
     canvas.drawCircle(a2,b2, rL, mPaintOKM);
     canvas.drawCircle(a2,b2, rH, mPaintOKH);
}
//轨迹经过圆C
if (passwordValue.contains("C")) {// (a3,b3)
     canvas.drawCircle(a3,b3, rL, mPaintOKM);
     canvas.drawCircle(a3,b3, rH, mPaintOKH);
}
//轨迹经过圆D
if (passwordValue.contains("D")) {// (a4,b4)
     canvas.drawCircle(a4,b4, rL, mPaintOKM);
     canvas.drawCircle(a4,b4, rH, mPaintOKH);
}
//轨迹经过圆E
if (passwordValue.contains("E")) {// (a5,b5)
     canvas.drawCircle(a5,b5, rL, mPaintOKM);
     canvas.drawCircle(a5,b5, rH, mPaintOKH);
}
//轨迹经过圆F
if (passwordValue.contains("F")) {// (a6,b6)
     canvas.drawCircle(a6,b6, rL, mPaintOKM);
     canvas.drawCircle(a6,b6, rH, mPaintOKH);
}
//轨迹经过圆G
if (passwordValue.contains("G")) {// (a7,b7)
     canvas.drawCircle(a7,b7, rL, mPaintOKM);
     canvas.drawCircle(a7,b7, rH, mPaintOKH);
}
//轨迹经过圆H
if (passwordValue.contains("H")) {// (a8,b8)
     canvas.drawCircle(a8,b8, rL, mPaintOKM);
     canvas.drawCircle(a8,b8, rH, mPaintOKH);
}
//轨迹经过圆I
if (passwordValue.contains("I")) {// (a9,b9)
     canvas.drawCircle(a9,b9, rL, mPaintOKM);
     canvas.drawCircle(a9,b9, rH, mPaintOKH);
}

线段的渲染过程,获取线段的端点坐标,重写onTouchEvent方法

//存储线段起始及终止坐标的二维数组
float[][] lineCoordinate = new float[2][2]
//存储二维数据的列表
List<Float[][]> listCoordinate = new ArrayList();
//经过圆的数量,num < 4 线段颜色为红色 num >= 4线段颜色为绿色
int num = 0;

@Override
public boolean onTouchEvent(MotionEvent event) 
if (event.getAction() == MotionEvent.ACTION_DOWN) {

} else if (event.getAction() == MotionEvent.ACTION_MOVE) {
   //X坐标点
    float tX = event.getX();
    //Y坐标点
    float tY = event.getY();
    //首次经过圆A
    if (rt1.contains(tX, tY) && !passwordValue.contains("A")) {
         passwordValue += "A";
         //num经过的圆的数量
         //num != 0代表不是第一个经过的圆,第一个经过的圆只能是线段的起始坐标不能是线段的终止坐标
         if (num != 0) {      
            //线段的终止坐标  
             fts[1] = new float[]{a1, b1};    
            //存储线段起及始终止坐标的二维数组存储到列表中
            listCoordinate.add(fts);
         }
        //初始化存储线段坐标的二维数组
         fts = new float[2][2];
        //线段的起始坐标
        fts[0] = new float[]{a1, b1};
         num += 1;
    } else if (rt2.contains(tX, tY) && !passwordValue.contains("B")) {//首次经过圆B
         passwordValue += "B";
         if (num != 0) {      
            //线段的终止坐标  
             fts[1] = new float[]{a2, b2};    
             listCoordinate.add(fts);
         }
        //初始化存储线段坐标的二维数组
         fts = new float[2][2];
        //线段的起始坐标
        fts[0] = new float[]{a2, b2};
        num += 1;
    } else if (rt3.contains(tX, tY) && !passwordValue.contains("C")) {//首次经过圆C
         passwordValue += "C";
         if (num != 0) {      
            //线段的终止坐标  
             fts[1] = new float[]{a3, b3};    
             listCoordinate.add(fts);
         }
        //初始化存储线段坐标的二维数组
         fts = new float[2][2];
        //线段的起始坐标
        fts[0] = new float[]{a3, b3};
        num += 1;
    } else if (rt4.contains(tX, tY) && !passwordValue.contains("D")) {//首次经过圆D
         passwordValue += "D";
         if (num != 0) {      
            //线段的终止坐标  
             fts[1] = new float[]{a4, b4};    
             listCoordinate.add(fts);
         }
        //初始化存储线段坐标的二维数组
         fts = new float[2][2];
        //线段的起始坐标
        fts[0] = new float[]{a4, b4};
        num += 1;
    } else if (rt5.contains(tX, tY) && !passwordValue.contains("E")) {//首次经过圆E
         passwordValue += "E";
         if (num != 0) {      
            //线段的终止坐标  
             fts[1] = new float[]{a5, b5};    
             listCoordinate.add(fts);
         }
        //初始化存储线段坐标的二维数组
         fts = new float[2][2];
        //线段的起始坐标
        fts[0] = new float[]{a5, b5};
        num += 1;
    } else if (rt6.contains(tX, tY) && !passwordValue.contains("F")) {//首次经过圆F
         passwordValue += "F";
         if (num != 0) {      
            //线段的终止坐标  
             fts[1] = new float[]{a6, b6};    
             listCoordinate.add(fts);
         }
        //初始化存储线段坐标的二维数组
         fts = new float[2][2];
        //线段的起始坐标
        fts[0] = new float[]{a6, b6};
        num += 1;
    } else if (rt7.contains(tX, tY) && !passwordValue.contains("G")) {//首次经过圆G
        passwordValue += "G";
         if (num != 0) {      
            //线段的终止坐标  
             fts[1] = new float[]{a7, b7};    
             listCoordinate.add(fts);
         }
        //初始化存储线段坐标的二维数组
         fts = new float[2][2];
        //线段的起始坐标
        fts[0] = new float[]{a7, b7};
        num += 1;
    } else if (rt8.contains(tX, tY) && !passwordValue.contains("H")) {//首次经过圆H
        passwordValue += "H";
         if (num != 0) {      
            //线段的终止坐标  
             fts[1] = new float[]{a8, b8};    
             listCoordinate.add(fts);
         }
        //初始化存储线段坐标的二维数组
         fts = new float[2][2];
        //线段的起始坐标
        fts[0] = new float[]{a8, b8};
        num += 1;
    } else if (rt9.contains(tX, tY) && !passwordValue.contains("I")) {//首次经过圆I
        passwordValue += "I";
         if (num != 0) {      
            //线段的终止坐标  
             fts[1] = new float[]{a9, b9};    
             listCoordinate.add(fts);
         }
        //初始化存储线段坐标的二维数组
         fts = new float[2][2];
        //线段的起始坐标
        fts[0] = new float[]{a9, b9};
        num += 1;
    }
    invalidate();// 刷新画布,回调onDraw()方法
} else if (event.getAction() == MotionEvent.ACTION_UP) {

}

刷新画布,渲染红色线段,

//初始化渲染红色线段画笔
//Paint.ANTI_ALIAS_FLAG使图像抗锯齿
mPaintCancelM = new Paint(Paint.ANTI_ALIAS_FLAG)
//颜色红色
mPaintCancelM.setColor(Color.RED);
//画笔的宽度
mPaintCancelM.setStrokeWidth(rM);

protected void onDraw(Canvas canvas) {
...
...
for (int i = 0; i < listCoordinate.size(); i++) {
      float[][] lineCoordinate = listCoordinate.get(i);
      float startX = lineCoordinate[0][0];
      float startY = lineCoordinate[0][1];
      float stopX = lineCoordinate[1][0];
      float stopY = lineCoordinate[1][1];
     //渲染红色线段
     canvas.drawLine(startX, startY, stopX, stopY, mPaintCancelM)
}
...
...

右图跟中图的渲染过程一样,区别在于经过的圆的数量大于等于4,画笔的颜色设置成绿色

至此,以上左中右图的渲染实现过程完毕,但还有两个中间状态

不完整的线段效果图.jpg

右图跟左图的渲染过程一样,讲解左图的实现过程,我们称该状态线段为不完整线段,以区分之前的线段。

手指滑动未到达圆所在的区域时,线段的起始坐标是轨迹经过的最后一个圆的圆心坐标,我们只需记录终点坐标就可实现以上图中的状态。

//存储不完整线段起始终止坐标的二维数组
float[][] lineCrdinateImperfect = new float[2][2]
lineCrdinateImperfect[0] = new float[2];
lineCrdinateImperfect[1] = new float[2];
@Override
public boolean onTouchEvent(MotionEvent event) {    
if (event.getAction() == MotionEvent.ACTION_DOWN) {

} else if (event.getAction() == MotionEvent.ACTION_MOVE) {
       float tX = event.getX();
       float tY = event.getY();
       //不完整线段终点坐标赋值
       lineCrdinateImperfect[1] [0] = tX;
       lineCrdinateImperfect[1] [1] = tY;
   //首次经过圆A
    if (rt1.contains(tX, tY) && !passwordValue.contains("A")) {
         passwordValue += "A";
       //不完整线段起始坐标赋值
       lineCrdinateImperfect[0] [0] = a1;
       lineCrdinateImperfect[0] [1] = b1;
    } else if (rt2.contains(tX, tY) && !passwordValue.contains("B")) {//首次经过圆B
         passwordValue += "B";
       //不完整线段起始坐标赋值
       lineCrdinateImperfect[0] [0] = a2;
       lineCrdinateImperfect[0] [1] = b2;
    } else if (rt3.contains(tX, tY) && !passwordValue.contains("C")) {//首次经过圆C
         passwordValue += "C";
       //不完整线段起始坐标赋值
       lineCrdinateImperfect[0] [0] = a3;
       lineCrdinateImperfect[0] [1] = b3;
    } else if (rt4.contains(tX, tY) && !passwordValue.contains("D")) {//首次经过圆D
         passwordValue += "D";
       //不完整线段起始坐标赋值
       lineCrdinateImperfect[0] [0] = a4;
       lineCrdinateImperfect[0] [1] = b4;
    } else if (rt5.contains(tX, tY) && !passwordValue.contains("E")) {//首次经过圆E
         passwordValue += "E";
       //不完整线段起始坐标赋值
       lineCrdinateImperfect[0] [0] = a5;
       lineCrdinateImperfect[0] [1] = b5;
    } else if (rt6.contains(tX, tY) && !passwordValue.contains("F")) {//首次经过圆F
         passwordValue += "F";
       //不完整线段起始坐标赋值
       lineCrdinateImperfect[0] [0] = a6;
       lineCrdinateImperfect[0] [1] = b6;
    } else if (rt7.contains(tX, tY) && !passwordValue.contains("G")) {//首次经过圆G
        passwordValue += "G";
       //不完整线段起始坐标赋值
       lineCrdinateImperfect[0] [0] = a7;
       lineCrdinateImperfect[0] [1] = b7;
    } else if (rt8.contains(tX, tY) && !passwordValue.contains("H")) {//首次经过圆H
        passwordValue += "H";
       //不完整线段起始坐标赋值
       lineCrdinateImperfect[0] [0] = a8;
       lineCrdinateImperfect[0] [1] = b8;
    } else if (rt9.contains(tX, tY) && !passwordValue.contains("I")) {//首次经过圆I
        passwordValue += "I";
       //不完整线段起始坐标赋值
       lineCrdinateImperfect[0] [0] = a9;
       lineCrdinateImperfect[0] [1] = b9;
    }
    invalidate();// 刷新画布,回调onDraw()方法
} else if (event.getAction() == MotionEvent.ACTION_UP) {

}

刷新画布,渲染不完整线段

@Override
protected void onDraw(Canvas canvas) {
...
...
      //不完整线段坐标赋值
      float startXImperfect = lineCrdinateImperfect[0][0];
      float startYImperfect = lineCrdinateImperfect[0][1];
      float stopXImperfect= lineCrdinateImperfect[1][0];
      float stopYImperfect = lineCrdinateImperfect[1][1];
      //渲染不完整直线
      canvas.drawLine(startXImperfect, startYImperfect, stopXImperfect, stopYImperfect, mPaintCancelM);
...
...
}

注意:从一个圆(A)出发,绕过一个圆(B),到达圆另一个圆(C),这样会忽略中间的圆(B),经过的圆的顺序A->C,这样不合理,明明经过了中间圆(B),轨迹应该是A->B->C才对。
解决思路:计算两圆心坐标中点坐标是否为其他圆的圆心坐标。

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

推荐阅读更多精彩内容