Android自定義View使用詳細分析與繪制流程全解

image.png

1. 自定義View基礎

1.1 分類

自定義View的實現方式有以下幾種

類型 定義
自定義組合控件 多個控件組合成為一個新的控件,方便多處復用
繼承系統View控件 繼承自TextView等系統控件,在系統控件的基礎功能上進行擴展
繼承View 不復用系統控件邏輯,繼承View進行功能定義
繼承系統ViewGroup 繼承自LinearLayout等系統控件,在系統控件的基礎功能上進行擴展
繼承ViewViewGroup 不復用系統控件邏輯,繼承ViewGroup進行功能定義

1.2 View繪制流程
View的繪制基本由measure()、layout()、draw()這個三個函數完成

函數 作用 相關方法
measure() 測量View的寬高 measure(),setMeasuredDimension(),onMeasure()
layout() 計算當前View以及子View的位置 layout(),onLayout(),setFrame()
draw() 視圖的繪制工作 draw(),onDraw()

1.3 坐標系
在Android坐標系中,以屏幕左上角作為原點,這個原點向右是X軸的正軸,向下是Y軸正軸。如下所示:


image.png

除了Android坐標系,還存在View坐標系,View坐標系內部關系如圖所示。


image.png

View獲取自身高度

由上圖可算出View的高度:

  • width = getRight() - getLeft();
  • height = getBottom() - getTop();

View的源碼當中提供了getWidth()和getHeight()方法用來獲取View的寬度和高度,其內部方法和上文所示是相同的,我們可以直接調用來獲取View得寬高。

View自身的坐標

通過如下方法可以獲取View到其父控件的距離。

  • getTop();獲取View到其父布局頂邊的距離。
  • getLeft();獲取View到其父布局左邊的距離。
  • getBottom();獲取View到其父布局頂邊的距離。
  • getRight();獲取View到其父布局左邊的距離。

1.4 構造函數
無論是我們繼承系統View還是直接繼承View,都需要對構造函數進行重寫,構造函數有多個,至少要重寫其中一個才行。如我們新建TestView

public class TestView extends View {
    /**
     * 在java代碼里new的時候會用到
     * @param context
     */
    public TestView(Context context) {
        super(context);
    }
 
    /**
     * 在xml布局文件中使用時自動調用
     * @param context
     */
    public TestView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    }
 
    /**
     * 不會自動調用,如果有默認style時,在第二個構造函數中調用
     * @param context
     * @param attrs
     * @param defStyleAttr
     */
    public TestView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }
 
 
    /**
     * 只有在API版本>21時才會用到
     * 不會自動調用,如果有默認style時,在第二個構造函數中調用
     * @param context
     * @param attrs
     * @param defStyleAttr
     * @param defStyleRes
     */
    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    public TestView(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
    }
}

1.5 自定義屬性

Android系統的控件以android開頭的都是系統自帶的屬性。為了方便配置自定義View的屬性,我們也可以自定義屬性值。
Android自定義屬性可分為以下幾步:

  1. 自定義一個View
  2. 編寫values/attrs.xml,在其中編寫styleable和item等標簽元素
  3. 在布局文件中View使用自定義的屬性(注意namespace)
  4. 在View的構造方法中通過TypedArray獲取

實例說明

  • 自定義屬性的聲明文件
    <?xml version="1.0" encoding="utf-8"?>
    <resources>
        <declare-styleable name="test">
            <attr name="text" format="string" />
            <attr name="testAttr" format="integer" />
        </declare-styleable>
    </resources>

自定義View類

public class MyTextView extends View {
    private static final String TAG = MyTextView.class.getSimpleName();
 
    //在View的構造方法中通過TypedArray獲取
    public MyTextView(Context context, AttributeSet attrs) {
        super(context, attrs);
        TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.test);
        String text = ta.getString(R.styleable.test_testAttr);
        int textAttr = ta.getInteger(R.styleable.test_text, -1);
        Log.e(TAG, "text = " + text + " , textAttr = " + textAttr);
        ta.recycle();
    }
}

布局文件中使用

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    xmlns:app="http://schemas.android.com/apk/res/com.example.test"
    android:layout_width="match_parent"
    android:layout_height="match_parent" >
 
    <com.example.test.MyTextView
        android:layout_width="100dp"
        android:layout_height="200dp"
        app:testAttr="520"
        app:text="helloworld" />
 
</RelativeLayout>

屬性值的類型format
(1). reference:參考某一資源ID

屬性定義:

<declare-styleable name = "名稱">
     <attr name = "background" format = "reference" />
</declare-styleable>

屬性使用:

<ImageView android:background = "@drawable/圖片ID"/>

(2). color:顏色值
屬性定義:

<attr name = "textColor" format = "color" />

屬性使用:

<TextView android:textColor = "#00FF00" />

(3). boolean:布爾值
屬性定義:

<attr name = "focusable" format = "boolean" />

屬性使用:

<Button android:focusable = "true"/>

(4). dimension:尺寸值

  • 屬性定義:
<attr name = "layout_width" format = "dimension" />
  • 屬性使用:
<Button android:layout_width = "42dip"/> 

(5). float:浮點值

  • 屬性定義:
<attr name = "fromAlpha" format = "float" />
  • 屬性使用:
<alpha android:fromAlpha = "1.0"/>

(6). integer:整型值

  • 屬性定義:
<attr name = "framesCount" format="integer" />
  • 屬性使用:
<animated-rotate android:framesCount = "12"/>

(7). string:字符串

  • 屬性定義:
<attr name = "text" format = "string" />
  • 屬性使用:
<TextView android:text = "我是文本"/>

(8). fraction:百分數

  • 屬性定義:
<attr name = "pivotX" format = "fraction" />
  • 屬性使用:
<rotate android:pivotX = "200%"/>

(9). enum:枚舉值

  • 屬性定義:
<declare-styleable name="名稱">    <attr name="orientation">        <enum name="horizontal" value="0" />        <enum name="vertical" value="1" />    </attr></declare-styleable>
  • 屬性使用:
<LinearLayout android:orientation = "vertical"></LinearLayout>

注意:枚舉類型的屬性在使用的過程中只能同時使用其中一個,不能 android:orientation = “horizontal|vertical"

(10). flag:位或運算

  • 屬性定義:
<declare-styleable name="名稱">    <attr name="gravity">            <flag name="top" value="0x01" />            <flag name="bottom" value="0x02" />            <flag name="left" value="0x04" />            <flag name="right" value="0x08" />            <flag name="center_vertical" value="0x16" />            ...    </attr></declare-styleable>
  • 屬性使用:
<TextView android:gravity="bottom|left"/>

注意:位運算類型的屬性在使用的過程中可以使用多個值

(11). 混合類型:屬性定義時可以指定多種類型值

  • 屬性定義:
<declare-styleable name = "名稱">     <attr name = "background" format = "reference|color" /></declare-styleable>
  • 屬性使用:
<ImageViewandroid:background = "@drawable/圖片ID" />或者:<ImageViewandroid:background = "#00FF00" />

2. View繪制流程

這一章節偏向于解釋View繪制的源碼實現,可以更好地幫助我們掌握整個繪制過程。

View的繪制基本由measure()、layout()、draw()這個三個函數完成

函數 作用 相關方法
measure() 測量View的寬高 measure(),setMeasuredDimension(),onMeasure()
layout() 計算當前View以及子View的位置 layout(),onLayout(),setFrame()
draw() 視圖的繪制工作 draw(),onDraw()

2.1 Measure()

MeasureSpec

MeasureSpec是View的內部類,它封裝了一個View的尺寸,在onMeasure()當中會根據這個MeasureSpec的值來確定View的寬高。

MeasureSpec的值保存在一個int值當中。一個int值有32位,前兩位表示模式mode后30位表示大小size。即MeasureSpec = mode + size

MeasureSpec當中一共存在三種modeUNSPECIFIEDEXACTLY
AT_MOST

對于View來說,MeasureSpec的mode和Size有如下意義

模式 意義 對應
EXACTLY 精準模式,View需要一個精確值,這個值即為MeasureSpec當中的Size match_parent
AT_MOST 最大模式,View的尺寸有一個最大值,View不可以超過MeasureSpec當中的Size值 wrap_content
UNSPECIFIED 無限制,View對尺寸沒有任何限制,View設置為多大就應當為多大 一般系統內部使用

使用方式

    // 獲取測量模式(Mode)    int specMode = MeasureSpec.getMode(measureSpec)     // 獲取測量大小(Size)    int specSize = MeasureSpec.getSize(measureSpec)     // 通過Mode 和 Size 生成新的SpecMode    int measureSpec=MeasureSpec.makeMeasureSpec(size, mode); 

在View當中,MeasureSpace的測量代碼如下:

public static int getChildMeasureSpec(int spec, int padding, int childDimension) {
        int specMode = MeasureSpec.getMode(spec);
        int specSize = MeasureSpec.getSize(spec);
 
        int size = Math.max(0, specSize - padding);
 
        int resultSize = 0;
        int resultMode = 0;
 
        switch (specMode) {
        //當父View要求一個精確值時,為子View賦值
        case MeasureSpec.EXACTLY:
            //如果子view有自己的尺寸,則使用自己的尺寸
            if (childDimension >= 0) {
                resultSize = childDimension;
                resultMode = MeasureSpec.EXACTLY;
                //當子View是match_parent,將父View的大小賦值給子View
            } else if (childDimension == LayoutParams.MATCH_PARENT) {
                resultSize = size;
                resultMode = MeasureSpec.EXACTLY;
                //如果子View是wrap_content,設置子View的最大尺寸為父View
            } else if (childDimension == LayoutParams.WRAP_CONTENT) {
                resultSize = size;
                resultMode = MeasureSpec.AT_MOST;
            }
            break;
 
        // 父布局給子View了一個最大界限
        case MeasureSpec.AT_MOST:
            if (childDimension >= 0) {
                //如果子view有自己的尺寸,則使用自己的尺寸
                resultSize = childDimension;
                resultMode = MeasureSpec.EXACTLY;
            } else if (childDimension == LayoutParams.MATCH_PARENT) {
                // 父View的尺寸為子View的最大尺寸
                resultSize = size;
                resultMode = MeasureSpec.AT_MOST;
            } else if (childDimension == LayoutParams.WRAP_CONTENT) {
                //父View的尺寸為子View的最大尺寸
                resultSize = size;
                resultMode = MeasureSpec.AT_MOST;
            }
            break;
 
        // 父布局對子View沒有做任何限制
        case MeasureSpec.UNSPECIFIED:
            if (childDimension >= 0) {
            //如果子view有自己的尺寸,則使用自己的尺寸
                resultSize = childDimension;
                resultMode = MeasureSpec.EXACTLY;
            } else if (childDimension == LayoutParams.MATCH_PARENT) {
                //因父布局沒有對子View做出限制,當子View為MATCH_PARENT時則大小為0
                resultSize = View.sUseZeroUnspecifiedMeasureSpec ? 0 : size;
                resultMode = MeasureSpec.UNSPECIFIED;
            } else if (childDimension == LayoutParams.WRAP_CONTENT) {
                //因父布局沒有對子View做出限制,當子View為WRAP_CONTENT時則大小為0
                resultSize = View.sUseZeroUnspecifiedMeasureSpec ? 0 : size;
                resultMode = MeasureSpec.UNSPECIFIED;
            }
            break;
        }
    
        return MeasureSpec.makeMeasureSpec(resultSize, resultMode);
    }

這里需要注意,這段代碼只是在為子View設置MeasureSpec參數而不是實際的設置子View的大小。子View的最終大小需要在View中具體設置。

從源碼可以看出來,子View的測量模式是由自身LayoutParam和父View的MeasureSpec來決定的。

在測量子View大小時:

父View mode 子View
UNSPECIFIED 父布局沒有做出限制,子View有自己的尺寸,則使用,如果沒有則為0
EXACTLY 父布局采用精準模式,有確切的大小,如果有大小則直接使用,如果子View沒有大小,子View不得超出父view的大小范圍
AT_MOST 父布局采用最大模式,存在確切的大小,如果有大小則直接使用,如果子View沒有大小,子View不得超出父view的大小范圍

onMeasure()

整個測量過程的入口位于Viewmeasure方法當中,該方法做了一些參數的初始化之后調用了onMeasure方法,這里我們主要分析onMeasure

onMeasure方法的源碼如下:

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
 
        setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
                getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
    }

很簡單這里只有一行代碼,涉及到了三個方法我們挨個分析。

  • setMeasuredDimension(int measuredWidth, int measuredHeight) :該方法用來設置View的寬高,在我們自定義View時也會經常用到。
  • getDefaultSize(int size, int measureSpec):該方法用來獲取View默認的寬高,結合源碼來看。
/**
*   有兩個參數size和measureSpec
*   1、size表示View的默認大小,它的值是通過`getSuggestedMinimumWidth()方法來獲取的,之后我們再分析。
*   2、measureSpec則是我們之前分析的MeasureSpec,里面存儲了View的測量值以及測量模式
*/
public static int getDefaultSize(int size, int measureSpec) {
        int result = size;
        int specMode = MeasureSpec.getMode(measureSpec);
        int specSize = MeasureSpec.getSize(measureSpec);
 
        //從這里我們看出,對于AT_MOST和EXACTLY在View當中的處理是完全相同的。所以在我們自定義View時要對這兩種模式做出處理。
        switch (specMode) {
        case MeasureSpec.UNSPECIFIED:
            result = size;
            break;
        case MeasureSpec.AT_MOST:
        case MeasureSpec.EXACTLY:
            result = specSize;
            break;
        }
        return result;
    }
  • getSuggestedMinimumWidth():getHeight和該方法原理是一樣的,這里只分析這一個。
//當View沒有設置背景時,默認大小就是mMinWidth,這個值對應Android:minWidth屬性,如果沒有設置時默認為0.
//如果有設置背景,則默認大小為mMinWidth和mBackground.getMinimumWidth()當中的較大值。
protected int getSuggestedMinimumWidth() {
        return (mBackground == null) ? mMinWidth : max(mMinWidth, mBackground.getMinimumWidth());
    }

ViewGroup的測量過程與View有一點點區別,其本身是繼承自View,它沒有對Viewmeasure方法以及onMeasure方法進行重寫。

為什么沒有重寫onMeasure呢?ViewGroup除了要測量自身寬高外還需要測量各個子View的大小,而不同的布局測量方式也都不同(可參考LinearLayout以及FrameLayout),所以沒有辦法統一設置。因此它提供了測量子View的方法measureChildren()以及measureChild()幫助我們對子View進行測量。

measureChildren()以及measureChild()的源碼這里不再分析,大致流程就是遍歷所有的子View,然后調用Viewmeasure()方法,讓子View測量自身大小。具體測量流程上面也以及介紹過了


measure過程會因為布局的不同或者需求的不同而呈現不同的形式,使用時還是要根據業務場景來具體分析,如果想再深入研究可以看一下LinearLayoutonMeasure方法。

2.2 Layout()

要計算位置首先要對Android坐標系有所了解,前面的內容我們也有介紹過。

layout()過程,對于View來說用來計算View的位置參數,對于ViewGroup來說,除了要測量自身位置,還需要測量子View的位置。

layout()方法是整個Layout()流程的入口,看一下這部分源碼

/**
*  這里的四個參數l、t、r、b分別代表View的左、上、右、下四個邊界相對于其父View的距離。
*
*/
public void layout(int l, int t, int r, int b) {
        if ((mPrivateFlags3 & PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT) != 0) {
            onMeasure(mOldWidthMeasureSpec, mOldHeightMeasureSpec);
            mPrivateFlags3 &= ~PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT;
        }
 
        int oldL = mLeft;
        int oldT = mTop;
        int oldB = mBottom;
        int oldR = mRight;
 
        //這里通過setFrame或setOpticalFrame方法確定View在父容器當中的位置。
        boolean changed = isLayoutModeOptical(mParent) ?
                setOpticalFrame(l, t, r, b) : setFrame(l, t, r, b);
 
        //調用onLayout方法。onLayout方法是一個空實現,不同的布局會有不同的實現。
        if (changed || (mPrivateFlags & PFLAG_LAYOUT_REQUIRED) == PFLAG_LAYOUT_REQUIRED) {
            onLayout(changed, l, t, r, b);
 
        }
 
    }

從源碼我們知道,在layout()方法中已經通過setOpticalFrame(l, t, r, b)setFrame(l, t, r, b)方法對View自身的位置進行了設置,所以onLayout(changed, l, t, r, b)方法主要是ViewGroup對子View的位置進行計算。

有興趣的可以看一下LinearLayoutonLayout源碼,可以幫助加深理解。

2.3 Draw()
draw流程也就是的View繪制到屏幕上的過程,整個流程的入口在View的draw()方法之中,而源碼注釋也寫的很明白,整個過程可以分為6個步驟。

如果需要,繪制背景。
有過有必要,保存當前canvas。
繪制View的內容。
繪制子View。
如果有必要,繪制邊緣、陰影等效果。
繪制裝飾,如滾動條等等。
通過各個步驟的源碼再做分析:

    public void draw(Canvas canvas) {
 
       
        int saveCount;
        // 1. 如果需要,繪制背景
        if (!dirtyOpaque) {
            drawBackground(canvas);
        }
 
        // 2. 有過有必要,保存當前canvas。
        final int viewFlags = mViewFlags;
      
        if (!verticalEdges && !horizontalEdges) {
            // 3. 繪制View的內容。
            if (!dirtyOpaque) onDraw(canvas);
 
            // 4. 繪制子View。
            dispatchDraw(canvas);
 
            drawAutofilledHighlight(canvas);
 
            // Overlay is part of the content and draws beneath Foreground
            if (mOverlay != null && !mOverlay.isEmpty()) {
                mOverlay.getOverlayView().dispatchDraw(canvas);
            }
 
            // 6. 繪制裝飾,如滾動條等等。
            onDrawForeground(canvas);
 
            // we're done...
            return;
        }
    }
    
    /**
    *  1.繪制View背景
    */
    private void drawBackground(Canvas canvas) {
        //獲取背景
        final Drawable background = mBackground;
        if (background == null) {
            return;
        }
 
        setBackgroundBounds();
 
        //獲取便宜值scrollX和scrollY,如果scrollX和scrollY都不等于0,則會在平移后的canvas上面繪制背景。
        final int scrollX = mScrollX;
        final int scrollY = mScrollY;
        if ((scrollX | scrollY) == 0) {
            background.draw(canvas);
        } else {
            canvas.translate(scrollX, scrollY);
            background.draw(canvas);
            canvas.translate(-scrollX, -scrollY);
        }
    }
    
    /**
    * 3.繪制View的內容,該方法是一個空的實現,在各個業務當中自行處理。
    */
    protected void onDraw(Canvas canvas) {
    }
    
    /**
    * 4. 繪制子View。該方法在View當中是一個空的實現,在各個業務當中自行處理。
    *  在ViewGroup當中對dispatchDraw方法做了實現,主要是遍歷子View,并調用子類的draw方法,一般我們不需要自己重寫該方法。
    */
    protected void dispatchDraw(Canvas canvas) {
 
    }
  1. 自定義組合控件
    自定義組合控件就是將多個控件組合成為一個新的控件,主要解決多次重復使用同一類型的布局。如我們頂部的HeaderView以及dailog等,我們都可以把他們組合成一個新的控件。

我們通過一個自定義HeaderView實例來了解自定義組合控件的用法。

  1. 編寫布局文件
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:id="@+id/header_root_layout"
    android:layout_height="45dp"
    android:background="#827192">
 
    <ImageView
        android:id="@+id/header_left_img"
        android:layout_width="45dp"
        android:layout_height="45dp"
        android:layout_alignParentLeft="true"
        android:paddingLeft="12dp"
        android:paddingRight="12dp"
        android:src="@drawable/back"
        android:scaleType="fitCenter"/>
 
    <TextView
        android:id="@+id/header_center_text"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerInParent="true"
        android:lines="1"
        android:maxLines="11"
        android:ellipsize="end"
        android:text="title"
        android:textStyle="bold"
        android:textColor="#ffffff"/>
    
    <ImageView
        android:id="@+id/header_right_img"
        android:layout_width="45dp"
        android:layout_height="45dp"
        android:layout_alignParentRight="true"
        android:src="@drawable/add"
        android:scaleType="fitCenter"
        android:paddingRight="12dp"
        android:paddingLeft="12dp"/>
 
</RelativeLayout>
        

布局很簡單,中間是title的文字,左邊是返回按鈕,右邊是一個添加按鈕。

  1. 實現構造方法
//因為我們的布局采用RelativeLayout,所以這里繼承RelativeLayout。
//關于各個構造方法的介紹可以參考前面的內容
public class YFHeaderView extends RelativeLayout {
 
    public YFHeaderView(Context context) {
        super(context);
    }
 
    public YFHeaderView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }
 
    public YFHeaderView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }
 
}
  1. 初始化UI
    //初始化UI,可根據業務需求設置默認值。
    private void initView(Context context) {
        LayoutInflater.from(context).inflate(R.layout.view_header, this, true);
        img_left = (ImageView) findViewById(R.id.header_left_img);
        img_right = (ImageView) findViewById(R.id.header_right_img);
        text_center = (TextView) findViewById(R.id.header_center_text);
        layout_root = (RelativeLayout) findViewById(R.id.header_root_layout);
        layout_root.setBackgroundColor(Color.BLACK);
        text_center.setTextColor(Color.WHITE);
 
    }
  1. 提供對外的方法

可以根據業務需求對外暴露一些方法。

    //設置標題文字的方法
    private void setTitle(String title) {
        if (!TextUtils.isEmpty(title)) {
            text_center.setText(title);
        }
    }
    //對左邊按鈕設置事件的方法
    private void setLeftListener(OnClickListener onClickListener) {
        img_left.setOnClickListener(onClickListener);
    }
 
    //對右邊按鈕設置事件的方法
    private void setRightListener(OnClickListener onClickListener) {
        img_right.setOnClickListener(onClickListener);
    }
  1. 在布局當中引用該控件
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
 
    <com.example.yf.view.YFHeaderView
        android:layout_width="match_parent"
        android:layout_height="45dp">
 
    </com.example.yf.view.YFHeaderView>
 
</LinearLayout>

到這里基本的功能已經有了。除了這些基礎功能外,我們還可以做一些功能擴展,比如可以在布局時設置我的View顯示的元素,因為可能有些需求并不需要右邊的按鈕。這時候就需要用到自定義屬性來解決了。

前面已經簡單介紹過自定義屬性的相關知識,我們之間看代碼

1.首先在values目錄下創建attrs.xml

內容如下:

<resources>
 
    <declare-styleable name="HeaderBar">
        <attr name="title_text_clolor" format="color"></attr>
        <attr name="title_text" format="string"></attr>
        <attr name="show_views">
            <flag name="left_text" value="0x01" />
            <flag name="left_img" value="0x02" />
            <flag name="right_text" value="0x04" />
            <flag name="right_img" value="0x08" />
            <flag name="center_text" value="0x10" />
            <flag name="center_img" value="0x20" />
        </attr>
    </declare-styleable>
</resources>

這里我們定義了三個屬性,文字內容、顏色以及要顯示的元素。

2.在java代碼中進行設置

    private void initAttrs(Context context, AttributeSet attrs) {
        TypedArray mTypedArray = context.obtainStyledAttributes(attrs, R.styleable.HeaderBar);
        //獲取title_text屬性
        String title = mTypedArray.getString(R.styleable.HeaderBar_title_text);
        if (!TextUtils.isEmpty(title)) {
            text_center.setText(title);
        }
        //獲取show_views屬性,如果沒有設置時默認為0x26
        showView = mTypedArray.getInt(R.styleable.HeaderBar_show_views, 0x26);
        text_center.setTextColor(mTypedArray.getColor(R.styleable.HeaderBar_title_text_clolor, Color.WHITE));
        mTypedArray.recycle();
        showView(showView);
 
    }
    
    private void showView(int showView) {
        //將showView轉換為二進制數,根據不同位置上的值設置對應View的顯示或者隱藏。
        Long data = Long.valueOf(Integer.toBinaryString(showView));
        element = String.format("%06d", data);
        for (int i = 0; i < element.length(); i++) {
            if(i == 0) ;
            if(i == 1) text_center.setVisibility(element.substring(i,i+1).equals("1")? View.VISIBLE:View.GONE);
            if(i == 2) img_right.setVisibility(element.substring(i,i+1).equals("1")? View.VISIBLE:View.GONE);
            if(i == 3) ;
            if(i == 4) img_left.setVisibility(element.substring(i,i+1).equals("1")? View.VISIBLE:View.GONE);
            if(i == 5) ;
        }
 
    }

3.在布局文件中進行設置

    <com.example.yf.view.YFHeaderView
        android:layout_width="match_parent"
        android:layout_height="45dp"
        app:title_text="標題"
        app:show_views="center_text|left_img|right_img">
 
    </com.example.yf.view.YFHeaderView>

OK,到這里整個View基本定義完成。整個YFHeaderView的代碼如下

public class YFHeaderView extends RelativeLayout {
 
    private ImageView img_left;
    private TextView text_center;
    private ImageView img_right;
    private RelativeLayout layout_root;
    private Context context;
    String element;
 
    private int showView;
 
    public YFHeaderView(Context context) {
        super(context);
        this.context = context;
        initView(context);
    }
 
    public YFHeaderView(Context context, AttributeSet attrs) {
        super(context, attrs);
        this.context = context;
        initView(context);
        initAttrs(context, attrs);
    }
 
    public YFHeaderView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        this.context = context;
        initView(context);
        initAttrs(context, attrs);
    }
 
    private void initAttrs(Context context, AttributeSet attrs) {
        TypedArray mTypedArray = context.obtainStyledAttributes(attrs, R.styleable.HeaderBar);
        String title = mTypedArray.getString(R.styleable.HeaderBar_title_text);
        if (!TextUtils.isEmpty(title)) {
            text_center.setText(title);
        }
        showView = mTypedArray.getInt(R.styleable.HeaderBar_show_views, 0x26);
        text_center.setTextColor(mTypedArray.getColor(R.styleable.HeaderBar_title_text_clolor, Color.WHITE));
        mTypedArray.recycle();
        showView(showView);
 
    }
 
    private void showView(int showView) {
        Long data = Long.valueOf(Integer.toBinaryString(showView));
        element = String.format("%06d", data);
        for (int i = 0; i < element.length(); i++) {
            if(i == 0) ;
            if(i == 1) text_center.setVisibility(element.substring(i,i+1).equals("1")? View.VISIBLE:View.GONE);
            if(i == 2) img_right.setVisibility(element.substring(i,i+1).equals("1")? View.VISIBLE:View.GONE);
            if(i == 3) ;
            if(i == 4) img_left.setVisibility(element.substring(i,i+1).equals("1")? View.VISIBLE:View.GONE);
            if(i == 5) ;
        }
 
    }
 
    private void initView(final Context context) {
        LayoutInflater.from(context).inflate(R.layout.view_header, this, true);
        img_left = (ImageView) findViewById(R.id.header_left_img);
        img_right = (ImageView) findViewById(R.id.header_right_img);
        text_center = (TextView) findViewById(R.id.header_center_text);
        layout_root = (RelativeLayout) findViewById(R.id.header_root_layout);
        layout_root.setBackgroundColor(Color.BLACK);
        text_center.setTextColor(Color.WHITE);
 
        img_left.setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View view) {
                Toast.makeText(context, element + "", Toast.LENGTH_SHORT).show();
            }
        });
    }
 
    private void setTitle(String title) {
        if (!TextUtils.isEmpty(title)) {
            text_center.setText(title);
        }
    }
 
 
    private void setLeftListener(OnClickListener onClickListener) {
        img_left.setOnClickListener(onClickListener);
    }
 
    private void setRightListener(OnClickListener onClickListener) {
        img_right.setOnClickListener(onClickListener);
    }
 
}
  1. 繼承系統控件
    繼承系統的控件可以分為繼承View子類(如TextVIew等)和繼承ViewGroup子類(如LinearLayout等),根據業務需求的不同,實現的方式也會有比較大的差異。這里介紹一個比較簡單的,繼承自View的實現方式。

業務需求:為文字設置背景,并在布局中間添加一條橫線。

因為這種實現方式會復用系統的邏輯,大多數情況下我們希望復用系統的onMeaseur和onLayout流程,所以我們只需要重寫onDraw方法 。實現非常簡單,話不多說,直接上代碼。

public class LineTextView extends TextView {
 
    //定義畫筆,用來繪制中心曲線
    private Paint mPaint;
    
    /**
     * 創建構造方法
     * @param context
     */
    public LineTextView(Context context) {
        super(context);
        init();
    }
 
    public LineTextView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        init();
    }
 
    public LineTextView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }
 
    private void init() {
        mPaint = new Paint();
        mPaint.setColor(Color.BLACK);
    }
 
    //重寫draw方法,繪制我們需要的中間線以及背景
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        int width = getWidth();
        int height = getHeight();
        mPaint.setColor(Color.BLUE);
        //繪制方形背景
        RectF rectF = new RectF(0,0,width,height);
        canvas.drawRect(rectF,mPaint);
        mPaint.setColor(Color.BLACK);
        //繪制中心曲線,起點坐標(0,height/2),終點坐標(width,height/2)
        canvas.drawLine(0,height/2,width,height/2,mPaint);
    }
}

對于View的繪制還需要對Paint()、canvas以及Path的使用有所了解,不清楚的可以稍微了解一下。

這里的實現比較簡單,因為具體實現會與業務環境密切相關,這里只是做一個參考。

  1. 直接繼承View
    直接繼承View會比上一種實現方復雜一些,這種方法的使用情景下,完全不需要復用系統控件的邏輯,除了要重寫onDraw外還需要對onMeasure方法進行重寫。

我們用自定義View來繪制一個正方形。

首先定義構造方法,以及做一些初始化操作

public class RectView extends View{
    //定義畫筆
    private Paint mPaint = new Paint();
 
    /**
     * 實現構造方法
     * @param context
     */
    public RectView(Context context) {
        super(context);
        init();
    }
 
    public RectView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        init();
    }
 
    public RectView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }
 
    private void init() {
        mPaint.setColor(Color.BLUE);
 
    }
 
}

重寫draw方法,繪制正方形,注意對padding屬性進行設置

/**
     * 重寫draw方法
     * @param canvas
     */
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        //獲取各個編劇的padding值
        int paddingLeft = getPaddingLeft();
        int paddingRight = getPaddingRight();
        int paddingTop = getPaddingTop();
        int paddingBottom = getPaddingBottom();
        //獲取繪制的View的寬度
        int width = getWidth()-paddingLeft-paddingRight;
        //獲取繪制的View的高度
        int height = getHeight()-paddingTop-paddingBottom;
        //繪制View,左上角坐標(0+paddingLeft,0+paddingTop),右下角坐標(width+paddingLeft,height+paddingTop)
        canvas.drawRect(0+paddingLeft,0+paddingTop,width+paddingLeft,height+paddingTop,mPaint);
    }

之前我們講到過View的measure過程,再看一下源碼對這一步的處理

    public static int getDefaultSize(int size, int measureSpec) {
        int result = size;
        int specMode = MeasureSpec.getMode(measureSpec);
        int specSize = MeasureSpec.getSize(measureSpec);
 
        switch (specMode) {
        case MeasureSpec.UNSPECIFIED:
            result = size;
            break;
        case MeasureSpec.AT_MOST:
        case MeasureSpec.EXACTLY:
            result = specSize;
            break;
        }
        return result;
    }

在View的源碼當中并沒有對AT_MOST和EXACTLY兩個模式做出區分,也就是說View在wrap_content和match_parent兩個模式下是完全相同的,都會是match_parent,顯然這與我們平時用的View不同,所以我們要重寫onMeasure方法。

重寫onMeasure方法

    /**
     * 重寫onMeasure方法
     *
     * @param widthMeasureSpec
     * @param heightMeasureSpec
     */
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
 
        //處理wrap_contentde情況
        if (widthMode == MeasureSpec.AT_MOST && heightMode == MeasureSpec.AT_MOST) {
            setMeasuredDimension(300, 300);
        } else if (widthMode == MeasureSpec.AT_MOST) {
            setMeasuredDimension(300, heightSize);
        } else if (heightMode == MeasureSpec.AT_MOST) {
            setMeasuredDimension(widthSize, 300);
        }
    }

整個自定義View的代碼如下:

public class RectView extends View {
    //定義畫筆
    private Paint mPaint = new Paint();
 
    /**
     * 實現構造方法
     *
     * @param context
     */
    public RectView(Context context) {
        super(context);
        init();
    }
 
    public RectView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        init();
    }
 
    public RectView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }
 
    private void init() {
        mPaint.setColor(Color.BLUE);
 
    }
 
    /**
     * 重寫onMeasure方法
     *
     * @param widthMeasureSpec
     * @param heightMeasureSpec
     */
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
 
        if (widthMode == MeasureSpec.AT_MOST && heightMode == MeasureSpec.AT_MOST) {
            setMeasuredDimension(300, 300);
        } else if (widthMode == MeasureSpec.AT_MOST) {
            setMeasuredDimension(300, heightSize);
        } else if (heightMode == MeasureSpec.AT_MOST) {
            setMeasuredDimension(widthSize, 300);
        }
    }
 
    /**
     * 重寫draw方法
     *
     * @param canvas
     */
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        //獲取各個編劇的padding值
        int paddingLeft = getPaddingLeft();
        int paddingRight = getPaddingRight();
        int paddingTop = getPaddingTop();
        int paddingBottom = getPaddingBottom();
        //獲取繪制的View的寬度
        int width = getWidth() - paddingLeft - paddingRight;
        //獲取繪制的View的高度
        int height = getHeight() - paddingTop - paddingBottom;
        //繪制View,左上角坐標(0+paddingLeft,0+paddingTop),右下角坐標(width+paddingLeft,height+paddingTop)
        canvas.drawRect(0 + paddingLeft, 0 + paddingTop, width + paddingLeft, height + paddingTop, mPaint);
    }
}

整個過程大致如下,直接繼承View時需要有幾點注意:

1、在onDraw當中對padding屬性進行處理。
2、在onMeasure過程中對wrap_content屬性進行處理。
3、至少要有一個構造方法。

6. 繼承ViewGroup

自定義ViewGroup的過程相對復雜一些,因為除了要對自身的大小和位置進行測量之外,還需要對子View的測量參數負責。

需求實例

實現一個類似于Viewpager的可左右滑動的布局。

代碼比較多,我們結合注釋分析。

public class HorizontaiView extends ViewGroup {
 
    private int lastX;
    private int lastY;
 
    private int currentIndex = 0;
    private int childWidth = 0;
    private Scroller scroller;
    private VelocityTracker tracker;
 
    
    /**
     * 1.創建View類,實現構造函數
     * 實現構造方法
     * @param context
     */
    public HorizontaiView(Context context) {
        super(context);
        init(context);
    }
 
    public HorizontaiView(Context context, AttributeSet attrs) {
        super(context, attrs);
        init(context);
    }
 
    public HorizontaiView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(context);
    }
 
    private void init(Context context) {
        scroller = new Scroller(context);
        tracker = VelocityTracker.obtain();
    }
 
    /**
     * 2、根據自定義View的繪制流程,重寫`onMeasure`方法,注意對wrap_content的處理
     * 重寫onMeasure方法
     * @param widthMeasureSpec
     * @param heightMeasureSpec
     */
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        //獲取寬高的測量模式以及測量值
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        //測量所有子View
        measureChildren(widthMeasureSpec, heightMeasureSpec);
        //如果沒有子View,則View大小為0,0
        if (getChildCount() == 0) {
            setMeasuredDimension(0, 0);
        } else if (widthMode == MeasureSpec.AT_MOST && heightMode == MeasureSpec.AT_MOST) {
            View childOne = getChildAt(0);
            int childWidth = childOne.getMeasuredWidth();
            int childHeight = childOne.getMeasuredHeight();
            //View的寬度=單個子View寬度*子View個數,View的高度=子View高度
            setMeasuredDimension(getChildCount() * childWidth, childHeight);
        } else if (widthMode == MeasureSpec.AT_MOST) {
            View childOne = getChildAt(0);
            int childWidth = childOne.getMeasuredWidth();
            //View的寬度=單個子View寬度*子View個數,View的高度=xml當中設置的高度
            setMeasuredDimension(getChildCount() * childWidth, heightSize);
        } else if (heightMode == MeasureSpec.AT_MOST) {
            View childOne = getChildAt(0);
            int childHeight = childOne.getMeasuredHeight();
            //View的寬度=xml當中設置的寬度,View的高度=子View高度
            setMeasuredDimension(widthSize, childHeight);
        }
    }
 
    /**
     * 3、接下來重寫`onLayout`方法,對各個子View設置位置。
     * 設置子View位置
     * @param changed
     * @param l
     * @param t
     * @param r
     * @param b
     */
    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        int childCount = getChildCount();
        int left = 0;
        View child;
        for (int i = 0; i < childCount; i++) {
            child = getChildAt(i);
            if (child.getVisibility() != View.GONE) {
                childWidth = child.getMeasuredWidth();
                child.layout(left, 0, left + childWidth, child.getMeasuredHeight());
                left += childWidth;
            }
        }
    }
}

到這里我們的View布局就已經基本結束了。但是要實現Viewpager的效果,還需要添加對事件的處理。事件的處理流程之前我們有分析過,在制作自定義View的時候也是會經常用到的,不了解的可以參考之前的文章Android Touch事件分發超詳細解析

    /**
     * 4、因為我們定義的是ViewGroup,從onInterceptTouchEvent開始。
     * 重寫onInterceptTouchEvent,對橫向滑動事件進行攔截
     * @param event
     * @return
     */
    @Override
    public boolean onInterceptTouchEvent(MotionEvent event) {
        boolean intercrpt = false;
        //記錄當前點擊的坐標
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_MOVE:
                int deltaX = x - lastX;
                int delatY = y - lastY;
                //當X軸移動的絕對值大于Y軸移動的絕對值時,表示用戶進行了橫向滑動,對事件進行攔截
                if (Math.abs(deltaX) > Math.abs(delatY)) {
                    intercrpt = true;
                }
                break;
        }
        lastX = x;
        lastY = y;
        //intercrpt = true表示對事件進行攔截
        return intercrpt;
    }
    
    /**
     * 5、當ViewGroup攔截下用戶的橫向滑動事件以后,后續的Touch事件將交付給`onTouchEvent`進行處理。
     * 重寫onTouchEvent方法
     * @param event
     * @return
     */
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        tracker.addMovement(event);
        //獲取事件坐標(x,y)
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_MOVE:
                int deltaX = x - lastX;
                int delatY = y - lastY;
                //scrollBy方法將對我們當前View的位置進行偏移
                scrollBy(-deltaX, 0);
                break;
            //當產生ACTION_UP事件時,也就是我們抬起手指
            case MotionEvent.ACTION_UP:
                //getScrollX()為在X軸方向發生的便宜,childWidth * currentIndex表示當前View在滑動開始之前的X坐標
                //distance存儲的就是此次滑動的距離
                int distance = getScrollX() - childWidth * currentIndex;
                //當本次滑動距離>View寬度的1/2時,切換View
                if (Math.abs(distance) > childWidth / 2) {
                    if (distance > 0) {
                        currentIndex++;
                    } else {
                        currentIndex--;
                    }
                } else {
                    //獲取X軸加速度,units為單位,默認為像素,這里為每秒1000個像素點
                    tracker.computeCurrentVelocity(1000);
                    float xV = tracker.getXVelocity();
                    //當X軸加速度>50時,也就是產生了快速滑動,也會切換View
                    if (Math.abs(xV) > 50) {
                        if (xV < 0) {
                            currentIndex++;
                        } else {
                            currentIndex--;
                        }
                    }
                }
                //對currentIndex做出限制其范圍為【0,getChildCount() - 1】
                currentIndex = currentIndex < 0 ? 0 : currentIndex > getChildCount() - 1 ? getChildCount() - 1 : currentIndex;
                //滑動到下一個View
                smoothScrollTo(currentIndex * childWidth, 0);
                tracker.clear();
                break;
        }
        lastX = x;
        lastY = y;
        return true;
    }
 
 
    private void smoothScrollTo(int destX, int destY) {
        //startScroll方法將產生一系列偏移量,從(getScrollX(), getScrollY()),destX - getScrollX()和destY - getScrollY()為移動的距離
        scroller.startScroll(getScrollX(), getScrollY(), destX - getScrollX(), destY - getScrollY(), 1000);
        //invalidate方法會重繪View,也就是調用View的onDraw方法,而onDraw又會調用computeScroll()方法
        invalidate();
    }
 
    //重寫computeScroll方法
    @Override
    public void computeScroll() {
        super.computeScroll();
        //當scroller.computeScrollOffset()=true時表示滑動沒有結束
        if (scroller.computeScrollOffset()) {
            //調用scrollTo方法進行滑動,滑動到scroller當中計算到的滑動位置
            scrollTo(scroller.getCurrX(), scroller.getCurrY());
            //沒有滑動結束,繼續刷新View
            postInvalidate();
        }
    }

這部分代碼比較多,為了方便閱讀,在代碼當中進行了注釋。
之后就是在XML代碼當中引入自定義View

<com.example.yf.view.HorizontaiView
        android:id="@+id/test_layout"
        android:layout_width="match_parent"
        android:layout_height="400dp">
        <ListView
            android:id="@+id/list1"
            android:layout_width="match_parent"
            android:layout_height="match_parent">
 
        </ListView>
 
        <ListView
            android:id="@+id/list2"
            android:layout_width="match_parent"
            android:layout_height="match_parent">
 
        </ListView>
 
        <ListView
            android:id="@+id/list3"
            android:layout_width="match_parent"
            android:layout_height="match_parent">
 
        </ListView>
 
    </com.example.yf.view.HorizontaiView>

好了,可以運行看一下效果了。

總結

本篇文章對常用的自定義View的方式進行了總結,并簡單分析了View的繪制流程。對各種實現方式寫了簡單的實現。

原文鏈接:http://www.lxweimin.com/p/705a6cb6bfee

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

推薦閱讀更多精彩內容