使用貝塞爾曲線實現一個loading控件

前言

上一篇文章:仿微信滑動按鈕

本文是自定義View實踐第二篇,上一篇實現了一個簡單的滑動按鈕,知道了一些自定義View的基本步驟,本文是使用貝塞爾曲線實現的一個加載中控件,接下來進入正文講解。

地址:WaveLoadingView

效果圖

可以看到,WaveLoadingView除了用于loading外,還可以用于顯示進度的場景。

實現方式

在效果圖中,波浪是曲線的形式的,所以我們需要想辦法把曲線畫出來,在數學領域中,用于實現曲線的函數有很多種,但在開發中,比較常用的就是正弦曲線和貝塞爾曲線了,下面簡單介紹一下:

1、正弦曲線

正弦曲線是我們非常熟悉的曲線,它的函數如下:

y = Asin(ωx + φ) + h

A表示振幅,用于表示曲線的波峰和波谷的距離;

ω表示角速度,用于控制正弦曲線的周期;

φ表示初相,用于控制正弦曲線的左右移動;

h表示編劇,用于控制曲線的上下移動.

當A、ω、h取一定的值,φ取不同的值時,就可以讓曲線在水平方向移動起來,如下:

上面是A = 2,ω = 0.8, h = 0, φ不斷變化的正弦曲線。

2、貝塞爾曲線

貝塞爾曲線有一階、二階、... 、n階,一階的貝塞爾曲線是一條直線,從第2階開始才是曲線,n階的貝塞爾曲線可以由(n - 1)階貝塞爾曲線推導出來,關于貝塞爾曲線的推導可以閱讀深入理解貝塞爾曲線。

這里我使用二階貝塞爾曲線,它的函數如下:

f(t) = (1- t)^2 * P0 + 2t(1- t)P1 + t^2 * P2 (0<= t <= 1)

P0、P1、P2都是已知的點,稱為控制點, t是一個變量,范圍為0到1,函數值隨著t的變化而變化.

下面我們取P0 = (x0, y0) = (-20, 0),P1 = (x1, y1) = (-10, 20),P2 = (x2, y2) = (0, 0),然后把這3個點的值代入二階貝塞爾曲線函數,形成的曲線如下:

圖一

這樣就畫出了一條曲線(那兩條直線是用于輔助的),接下來我們繼續取P3 = (x3, y3) = (10, -20),P4 = (x4, y4) = (20, 0),然后把P2、P3、P4再次代入二階貝塞爾曲線函數,形成的曲線如下:

圖二

這樣就有點接近正弦曲線了,只要我們不斷的取控制點,不斷的代入二階貝塞爾曲線函數,就可以形成一條周期的曲線,到這里我們也發現了二階貝塞爾曲線函數不是一個周期函數,所以它不像正向曲線那樣連綿不絕,一個二階貝塞爾曲線函數一次只能通過3個控制點畫出一條曲線。

3、如何選擇?

我們也發現了貝塞爾曲線相對正弦曲線的實現有點復雜,但是,在Android中,貝塞爾曲線已經有了封裝好的api供我們使用,使用起來非常簡單,不需要我們去用代碼實現那個函數,相反正弦曲線就需要我們從零做起,要用代碼去實現正弦函數,還要進行大量計算、范圍檢查等,所以從使用的復雜來看,選用貝塞爾曲線的工作量更小一點。

在Android中,貝塞爾曲線是通過Path來實現的,在Path中,與二階貝塞爾曲線有關的函數是:

path.quadTo(x1, y1, x2, y2)//絕對坐標
path.rQuadTo(x1, y1, x2, y2)//相對坐標

再貼一次圖一的貝塞爾曲線:

圖一

假設坐標系參考圖一的xy軸,即x軸向右,y軸向上,原點是(0, 0), 通過以下代碼就可以畫出上圖的曲線,如下:

var path = Path()
path.moveTo(-20f, 0f)//(x0,  y0) = (-20,  0)
path.quadTo(
    -10f, 20f, //(x1,  y1) = (-10,  20)
    0f, 0f     //(x2,  y2) = (0,  0)
)

//上面是絕對坐標,下面代碼使用相對坐標的方式畫出

var path = Path()
path.moveTo(-20f, 0f)//(x0,  y0) = (-20,  0)
path.rQuadTo(
    10f, 20f,   //(x1,  y1)相對(x0,  y0)為(10, 20)
    20f, 0f     //(x2,  y2)相對(x0, y0)為(20, 0)
)

如果想要畫出圖二的貝塞爾曲線,只需要在前面曲線的基礎上再加一句quadTo或rQuadTo,如下:

var path = Path()
path.moveTo(-20f, 0f)//(x0,  y0) = (-20,  0)
path.quadTo(
    -10f, 20f, //(x1,  y1) = (-10,  20)
    0f, 0f     //(x2,  y2) = (0,  0)
)
path.quadTo(
    10f, -20f,  //(x3,  y3) = (10,  -20)
    20f, 0f    //(x4,  y4) = (20,  0)
)


//上面是絕對坐標,下面代碼使用相對坐標的方式畫出

var path = Path()
path.moveTo(-20f, 0f)//(x0,  y0) = (-20,  0)
path.rQuadTo(
    10f, 20f,   //(x1,  y1)相對(x0,  y0)為(10, 20)
    20f, 0f     //(x2,  y2)相對(x0, y0)為(20, 0)
)
path.rQuadTo(
    10f, -20f,  //(x3,  y3)相對(x2,  y2)為(10, -20)
    20f, 0f     //(x4,  y4)相對(x2, y2)為(20, 0)
)

絕對坐標的每個點都是以坐標系的原點為參考;而相對坐標是以moveTo方法那個點為原點作為參考,如果只調用了一次moveTo方法,而調用了多次rQuadTo方法,那么從第二次rQuadTo方法開始,它參考上一次rQuadTo方法的最后一個坐標值,例如上面相對坐標計算中,第二次rQuadTo方法的(x3, y3),(x4, y4)是參考(x2, y2)計算出來的,而不是參考(x0, y0)。

上面是為了講解方便把坐標系說成x軸向右,y軸向上,但是在android中,坐標系是x軸向右,y軸向下,原點是View的左上角,這一點要注意。

實現步驟

下面開始講主要的實現步驟:

1、測量控件大小

我使用一個Shape枚舉表示控件的4種形狀,如下:

enum class Shape{
    CIRCLE,//圓形,默認形狀 
    SQUARE, //正方形
    RECT, //矩形
    NONE//沒有形狀約束
}

對于圓形和正方形,控件的測量寬和高應該保持一樣的,而對于矩形和NONE,控件的測量寬和高可以不一樣,如下:

 override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
     val measureWidth = MeasureSpec.getSize(widthMeasureSpec)
     val measureHeight = MeasureSpec.getSize(heightMeasureSpec)
     when(shape){
         Shape.CIRCLE, Shape.SQUARE -> {//圓形或正方形
             val measureSpec = if(measureHeight < measureWidth) heightMeasureSpec else widthMeasureSpec
             //傳入的measureSpec一樣
             super.onMeasure(measureSpec, measureSpec)
         }else -> {//矩形或NONE
             //傳入的measureSpec不一樣
             super.onMeasure(widthMeasureSpec, heightMeasureSpec)
         }
     }
 }

所以如果用戶使用圓形或正方形,但是輸入的寬高不一樣,我就取寬和高的最小值的測量模式去測量控件,這樣就保證了控件的測量寬高一樣;而用戶如果使用矩形或NONE,就保持原來的測量就行了。

一個控件有可能經過多次measure后才確定測量寬高,在多次onMeasure()方法調用后,接下來會調用onSizeChanged()方法,且只會調用一次,這個方法調用后接下來就會調用onLayout()方法確定控件的最終寬高,我在onSizeChanged()里面獲取測量寬高確定了控件作畫的范圍大小和暫時的控件大小,如下:

override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
    //控件作畫的范圍大小
    canvasWidth = measuredWidth
    canvasHeight = measuredHeight
    //控件大小,暫時等于canvas大小,后面在onlayout()中會改變
    viewWidth = canvasWidth
    viewHeight = canvasHeight
    //...
}

控件作畫的范圍大小和控件大小關系如下:

綠色框就是控件作畫的范圍大小,紅色框就是控件大小,也就是說每次控件大小確定之后,我只取中間的部分繪制,很多人會有疑問?為什么只取中間的部分繪制,而不在整個控件范圍繪制?這是因為當控件的父布局是ConstraintLayout,控件寬或高取match_parent時,會出現以下情況:

圖1 :控件大?。簂ayout_width = "match_parent" , layout_height = "200dp"
圖2:控件大?。簂ayout_width = "200dp" , layout_height = "match_parent"

藍色框就是手機屏幕,黑色背景就是控件大小,你還記得我上面在onMeasure()方法講過,如果控件的形狀是圓形,那么控件的測量寬高應該相等的,并取最小值為基準,所以如果控件大小輸入是layout_width = "match_parent" ,layout_height = "200dp" 或 layout_width = "200dp" ,layout_height = "match_parent",經過測量后控件大小應該是寬 = 高 = 200dp,效果應該都是如下圖:

圖3

可實際情況卻不是圖3,而是圖1或圖2,這是因為ConstraintLayout布局會讓子控件的setMeasuredDimension()失效,所以導致 measuredHeight 和 height 不一樣,寬同理,所以在遇到父布局是ConstraintLayout時,并且控件的寬或高設置了“match_parent”,并且你自定義了測量過程,就會導致自定義View過程中測量出來大小不等于View最終大小,即getMeasureHeigth()或getMeasureWidth() != getWidth()或getHeigth(),為什么ConstraintLayout就會有這種情況而其他如Linearlayout就沒有?我也不知道,可能需要大家通過源碼了解了,而我的解決辦法就是讓每次作畫的范圍在控件的中心,就像圖1和圖2一樣,這樣就不會那么難看。

2、裁剪畫布形狀

怎么把控件弄成圓形、正方形、矩形這些形狀,如果控件形狀是正方形或矩形,還可以設置圓角,一個方法是通過BitmapShader實現,使用BitmapShader要經過3步:

1、新建Bitmap;

2、以1新建的Bitmap創建一個Canvas,在Canvas上畫出波浪;

3、最后新建一個BitmapShader與1的Bitmap關聯,然后設置給畫筆,用畫筆在onDraw方法傳進來的Canvas上畫一個形狀出來,然后這個形狀就會含有波浪.

但我沒有使用BitmapShader,因為波浪的移動需要開啟一個無限循環動畫,就會不斷的調用onDraw()方法,而在onDraw()方法不斷的新建對象是一個不推薦的做法,雖然Bitmap可以通過recycler()復用,但是還是避免不了每次都要新建Canvas對象, 所以為了減少對象分配,我使用了Canvas的clipPathAPI來把畫布裁剪成我想要的形狀,然后把波浪畫在裁剪后的畫布上,這樣也能實現與BitmapShader同樣的效果,如下:

private fun preDrawShapePath(w: Int, h: Int) {                                                     
    clipPath.reset()                                                                               
    when (shape) {                                                                                 
        Shape.CIRCLE -> {                                                                                       //...
            //path路徑為圓形
            clipPath.addCircle(                                                                     
                shapeCircle.centerX, shapeCircle.centerY,                                           
                shapeCircle.circleRadius,                                                           
                Path.Direction.CCW                                                                 
            )                                                                                         
        }                                                                                           
        Shape.SQUARE -> {                                                                           
            //...
            //path路徑為正方形或圓角正方形
            if (shapeCorner == 0f)                                                                 
            clipPath?.addRect(shapeRect, Path.Direction.CCW)                                   
            else                                                                                   
            clipPath.addRoundRect(                                                             
                shapeRect,                                                                     
                shapeCorner, shapeCorner,                                                       
                Path.Direction.CCW     
            )                                                                                   
        }                                                                                           
        Shape.RECT -> {                                                                             
            //...
        }                                                                                           
    }                                                                                               
}                                                                                                     

preDrawShapePath()中根據Shape來add不同的形狀給Path來把這些路徑信息預先保存下來,前面已經講過每次作畫的范圍都在控件的中心,//...省略的都是居中計算,保存好形狀的Path將在onDraw方法中使用,如下:

override fun onDraw(canvas: Canvas?) {
    clipCanvasShape(canvas)           
    //...                 
}    

private fun clipCanvasShape(canvas: Canvas?) {    
    //調用canvas的clipPath方法裁剪畫布
    if (shape != Shape.NONE) canvas?.clipPath(clipPath)
    //...            
}                                                      

在onDraw方法中使用canvas.clipPath()方法傳入Path裁剪畫布,這樣以后作畫的范圍都被限定在這個畫布形狀之內。

3、畫波浪

使用貝塞爾曲線畫波浪,如下:

private fun preDrawWavePath() {
    wavePath.reset()
    //波長等于畫布的寬度
    val waveLen = canvasWidth
    //波峰
    val waveHeight = (waveAmplitude * canvasHeight).toInt()
    //波浪的起始y坐標
    waveStartY = calculateWaveStartYbyProcess()
    //把path移到起始位置,這里使用了path.moveTo()方法
    wavePath.moveTo(-canvasWidth * 2f, waveStartY)
    //下面就是畫波浪的過程,都使用了path.rXX()方法,表示把上一次結束點的坐標作為原點,從而簡化計算量
    val rang = -canvasWidth * 2..canvasWidth
    for (i in rang step waveLen) {
        wavePath.rQuadTo(
            waveLen / 4f, waveHeight / 2f,
            waveLen / 2f, 0f
        )
        wavePath.rQuadTo(
            waveLen / 4f, -waveHeight / 2f,
            waveLen / 2f, 0f
        )
    }
    //波浪的深度就是畫布的高度
    wavePath.rLineTo(0f, canvasHeight.toFloat())
    wavePath.rLineTo(-canvasWidth * 3f, 0f)
    //最后使用path.close()把波浪的路徑關閉,使整個波浪圍起來
    wavePath.close()
}

preDrawWavePath() 中把波浪路徑的信息保存在path中,下面一張圖很好的說明波浪的整個路徑,如下:

我把控件大小充滿了父容器,所以控件的作畫范圍就是綠色框的大小,波浪的波長就是一個畫布的寬度即綠色框的寬度,我把波浪的起始點移到屏幕范圍外,從起始點開始,畫了三個波長,把波浪畫出屏幕的范圍,從而方便的待會的波浪的上下移動,最后記得使用path.close()把波浪的路徑關閉,使整個波浪圍起來。

保存好波浪路徑的信息的Path在onDraw方法中使用,如下:

override fun onDraw(canvas: Canvas?) {
    clipCanvasShape(canvas)
    drawWave(canvas)
    //...
}

private fun drawWave(canvas: Canvas?) {
    wavePaint.style = Paint.Style.FILL_AND_STROKE
    wavePaint.color = waveColor
    //...
    //使用canvas的drawPath()方法把波浪畫在畫布上
    canvas?.drawPath(wavePath, wavePaint)
}

使用canvas的drawPath()方法直接把波浪畫在畫布上,這時在屏幕上顯示的效果如下:

這樣就畫出了一條波浪了,第二條波浪呢?可以再用另外一個Path按照上述preDrawWavePath()方法的流程再畫一條,只要波浪的起始點坐標不同就行,但我沒有用這種辦法,我是通過Canvas的translate()方法平移畫布,利用兩次平移的偏移量不一樣,畫出了第二條,如下:

private fun drawWave(canvas: Canvas?) {
    wavePaint.style = Paint.Style.FILL_AND_STROKE
    
    //首先保存兩次畫布狀態,記為畫布1、2
    canvas?.save()//畫布1
    canvas?.save()//畫布2
    
    //記當前畫布為畫布3
    //調用canvas的translate()方法水平平移一下畫布3
    canvas?.translate(canvasSlowOffsetX, 0)
    wavePaint.color = adjustAlpha(waveColor, 0.7f)
    //首先在畫布3畫出第一條波浪
    canvas?.drawPath(wavePath, wavePaint)
    
    //恢復保存的畫布2狀態
    canvas?.restore()
    
    //下面是在畫布2上作畫
    //調用canvas的translate()方法水平平移一下畫布2
    canvas?.translate(canvasFastOffsetX, 0)
    wavePaint.color = waveColor
    //然后在畫布2上畫出第二條波浪
    canvas?.drawPath(wavePath, wavePaint)
    
    //恢復保存的畫布1狀態
    canvas?.restore()
    
    //后面都是在畫布1上作畫
}

熟悉Canvas的save()、restore()方法都知道,每調用一次save(),可以理解為畫布的一次入棧(保存),每調用一次restore(),可以理解為畫布的出棧(恢復),畫布3是默認就有的,畫布1、2是我保存生成的,所以上述畫布1,2,3之間是獨立的,互不影響的,而canvasSlowOffsetX和canvasFastOffsetX兩個值是不一樣的,這樣就造成了畫布2和3平移時偏移量不一樣,所以用同一個Path畫在兩個偏移量不一樣的畫布上就可以形成兩條波浪,效果圖如下:

4、讓波浪動起來

讓波浪移動起來很簡單,使用一個無限循環動畫,在動畫的進度回調中計算畫布的偏移量,然后調用invalidate()就行,如下:

waveValueAnim.apply {
    duration = ANIM_TIME
    repeatCount = ValueAnimator.INFINITE//無限循環
    repeatMode = ValueAnimator.RESTART
    addUpdateListener{ animation ->
        //...  
        canvasFastOffsetX = (canvasFastOffsetX + fastWaveOffsetX) % canvasWidth
        canvasSlowOffsetX = (canvasSlowOffsetX + slowWaveOffsetX) % canvasWidth
        invalidate()
     }
}

在適當的時機啟動動畫,如下:

override fun onDraw(canvas: Canvas?) {
    clipCanvasShape(canvas)
    drawWave(canvas)
    //...
    //啟動動畫
    startLoading()
}

fun startLoading(){
   if(!waveValueAnim.isStarted) waveValueAnim.start()
}

到這里整個控件就完成了。

5、優化

大家都知道手機的資源都是非常有限的,我在做自定義View時,特別是涉及到無限循環的動畫時,要注意優化我們的代碼,因為一般的屏幕刷新周期是16ms,這意味著在這16ms內你要把有關動畫的所有計算和流程完成,不然就會造成掉幀,從而卡頓,在自定義View時我想到可以從下面幾點做一些優化,提高效率:

5.1、減少對象的內存分配,盡可能做到對象復用

每次系統GC的時候都會暫停系統ms級別的時間,而無限循環的動畫的邏輯代碼會在短時間內被循環往復的調用, 這樣如果在邏輯代碼中在堆上創建過多的臨時變量,會導致內存的使用量在短時間內上升,從而頻繁的引發系統的GC行為,這樣無疑會拖累動畫的效率,讓動畫變得卡頓。

在自定義View涉及到無限循環動畫時,我們不能忽略對象的內存分配,不要經常在onDraw()方法中new對象:如果這些臨時變量每次的使用都是固定,完全不需要每次循環執行的時候重復創建,我們可以考慮將它們從臨時變量轉為成員變量,在動畫初始化或View初始化時將這些成員變量初始化好,需要的時候直接調用即可;對于不規則圖形的繪制我們會需要到Path,并且對于越復雜的 Path,Canvas 在繪制的時候,也會更加的耗時,因此我們需要做的就是盡量優化 Path 的創建過程, 還有Path 類中本身提供reset()和rewind()方法用于復用Path對象, reset()方法是用于對象的復位,rewind()方法在對象的復位基礎上還可以讓Path對象不釋放之前已經分配的內存就,重用之前分配的內存。

5.2、抽取重復運算,盡可能減少浮點運算

在自定義View的時候不難免遇到大量的運算,特別在做無限循環動畫時,其邏輯代碼會在短時間內被循環往復的調用, 這樣如果在邏輯代碼中在做過多的重復運算無疑會降低動畫的效率,特別是在做浮點運算時,CPU 在處理浮點運算時候、會變的特別的慢,要多個指令周期才能完成。

因此我們還應該努力減少浮點運算,在不考慮精度的情況下,可以將浮點運算轉成整型來運算,同時我們還應該把重復的運算從邏輯代碼中抽取出來,不用每次都運算,例如在WaveLoadingView中, 我創建Path的過程的計算大部分都是在onLayout()中成,把重復運算的結果提前用Path保存好,然后在onDraw()中使用,因為onDraw()在做動畫時會被頻繁的被調用。

5.3、考慮使用SurfaceView

傳統的View的測量、布局、繪制都是在UI線程中完成的,而Android 的UI線程除了View的繪制之外,還需要進行額外的用戶處理邏輯、輪詢消息事件等,這樣當View的繪制和動畫比較復雜,計算量比較大的情況,就不再適合使用 View 這種方式來繪制了。這時候我們可以考慮使用SurfaceView ,SurfaceView 能夠在非 UI 線程中進行圖形繪制,釋放了 UI 線程的壓力。當然WaveLoadingView也可以使用SurfaceView 來實現。

結語

WaveLoadingView的實現就講解完畢,本次自定義View的過程都使用了kotlin進行編寫,整體的代碼量的確比java的減少了許多,但語言畢竟只是一個工具,我們主要是學習自定義View的實踐過程,當你經常動手實踐后,你會發現自定義View沒有想象那么難,來來去去就那幾個方法,大部分時間都是花在實現的細節和運算上,更多實現請查看文末地址。

地址:WaveLoadingView

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,283評論 6 530
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 97,947評論 3 413
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,094評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,485評論 1 308
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,268評論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,817評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 42,906評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,039評論 0 285
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,551評論 1 331
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,502評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,662評論 1 366
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,188評論 5 356
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 43,907評論 3 345
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,304評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,563評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,255評論 3 389
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,637評論 2 370