Android 五子連珠背后的故事

ChetBacon 8年前發布 | 6K 次閱讀 安卓開發 Android開發 移動開發

前段時間呢,因為AlphaGo讓圍棋很火,所以慕課網也邀請我做個棋類的課程,后來我選擇了五子棋,講道理我是不喜歡這個課程的,因為感覺題目比較老舊,在我印象中我初學時就好像學習過,不過當我寫完代碼、備完課,腦子里面簡單過了下想要如何表達之后。然后我就改變了看法,這個課程還是蠻不錯的,如果表達的清楚,還是能說明不少東西的。

ok,那么本文就是對五子棋在編寫過程中的注意事項,我編寫時遇到的坑,以及哪些地方值得去思考這三個方面進行展開。

ok,進入正題。對于五子棋,因為涉及到棋盤、棋子的繪制,所以肯定離不開自定義View。可能有人還會想到,因為是游戲,那么考不考慮SurfaceView,恩,因為五子棋的繪制基本都是和人交互之后產生的,不存需要大面積不斷繪制的部分(游戲場景不斷變化),所以自定義View就可以了。

那么說到自定義View,那么大家都不陌生,五子棋這個View需要涉及到哪些呢?

  • 測量。我們的五子棋棋盤預期是正方形,所以避免不了需要去重寫測量的代碼。

  • 繪制。這個是一定的,因為我們需要繪制棋盤,棋子。

  • 用戶交互(onTouchEvent)。很顯然,我們要下棋哇。

  • 狀態保存。恩,誰也不想下到一半,接個電話之后,棋局不見了。

這么看,五子棋這個View很全面哇,基本包含了自定義View所有的環節,當然還有五子棋自身的一些邏輯,這里我們暫不敘述。接下來針對上述環節一一介紹。

一、測量

說到測量,測量其實是自定義View最難把握的一個環節,不是因為它難,而是因為我們往往想太多。

那么如何把握好測量呢,其實就是分析清楚需求,比如我們的五子棋View,我們的需求是個正方形,并且內部的棋子、棋盤都依賴View的大小進行繪制,那么可以得出個結論,這個View在使用的時候必須指明寬度和高度。

有人那么會說,你不支持 wrap_content 嗎? wrap_content 什么意思呢?意思是View的大小由自己的內容確定,如果你的控件的內容是可測量的,那么支持是沒問題的,比如內部是指定了textSize的文本。還有種情況,是沒辦法測量的,比如我們的10*10棋盤,是依賴外部的View的寬高的,這種情況就沒有辦法支持 wrap_content 。當然,你可以設置棋盤兩行間的最小距離,那么就變成可測量的了,不過這里我們不考慮支持。

ok,說這么多,只是想表明一個意思,自定義View大多都是有著特殊的使用場景和特殊的需求的,所以根據你的使用需求,去判斷測量是否需要支持各種情況,避免不必要的邏輯。

那么我們的測量代碼是:

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec)
{
    int widthSize = MeasureSpec.getSize(widthMeasureSpec);
    int heightSize = MeasureSpec.getSize(heightMeasureSpec);

    int width = Math.min(widthSize, heightSize);
    setMeasuredDimension(width, width);
}

很簡單,獲取用戶設置的寬度和高度的值,取最小值為我們的View的邊長(由于寬高一直,下文統一使用邊長)。

乍一看是沒問題的,因為我們指明了該View在使用過程中用戶必須給我們指明寬高,即支持固定值和match_parent。

不過還有個特殊的情況要注意到,假設我們的View處于ScrollView中,那么對于 layout_width=match_parent 這樣的設置,你去運行,就會驚奇的發現,我們的View不見了,不見了。

沒錯,這種情況下,我們的上述代碼獲取到的heightSize很有可能是0,然后取最小值就徹底為0了。

那么,我們先看處理后的代碼,再談原因:

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec)
{
    int widthSize = MeasureSpec.getSize(widthMeasureSpec);
    int widthMode = MeasureSpec.getMode(widthMeasureSpec);

    int heightSize = MeasureSpec.getSize(heightMeasureSpec);
    int heightMode = MeasureSpec.getMode(heightMeasureSpec);


    int width = Math.min(widthSize, heightSize);

    if (widthMode == MeasureSpec.UNSPECIFIED)
    {
        width = heightSize;
    } else if (heightMode == MeasureSpec.UNSPECIFIED)
    {
        width = widthSize;
    }
    setMeasuredDimension(width, width);
}

ok,我們加了些 MeasureSpec.UNSPECIFIED 的判斷,如果處于ScrollView里面,heightMode就可能為UNSPECIFIED,那么我們以寬度上的尺寸為標準,反之對于寬度也同樣處理。

那么有同學會問,我判斷0可以嗎?

例如這樣:

if (widthSize == 0)
{
    width = heightSize;
} else if (heightSize == 0)
{
    width = widthSize;
}

ok,這種方式以前我是沒有發現問題的,但是在API 23我發現,這個size表現發生變化了。

ViewGroup#getChildMeasureSpec(API 23)
case MeasureSpec.UNSPECIFIED:
if (childDimension == LayoutParams.MATCH_PARENT) {
    // Child wants to be our size... find out how big it should
    // be
    resultSize = View.sUseZeroUnspecifiedMeasureSpec ? 0 : size;
    resultMode = MeasureSpec.UNSPECIFIED;
}

ok,早起的版本是這樣的:

ViewGroup#getChildMeasureSpec(API 19)

case MeasureSpec.UNSPECIFIED:
if (childDimension == LayoutParams.MATCH_PARENT) {
    // Child wants to be our size... find out how big it should
    // be
    resultSize = 0;//從這里也能看出自定義View接收到的可能是0
    resultMode = MeasureSpec.UNSPECIFIED;
}

可以看到對于resultSize處理,發生了變化,這里了解下就行了,所以對于處理 MeasureSpec.UNSPECIFIED 這類的邏輯,盡可能不要去依賴size(感興趣的也可以去模擬場景,然后測試打印下)。

關于測量,我們扯了很多,一方面是如何把握測量,另一方面是對于部分特殊場景特殊的處理。

測量完成之后,接下來干嘛呢,直奔繪制嗎?繪制需要依賴很多尺寸值,這些值可以在 onSizeChanged 去確定。

二、部分尺寸參數的確定

@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh)
{
    super.onSizeChanged(w, h, oldw, oldh);

    mPanelWidth = w;//棋盤邊長
    mLineHeight = mPanelWidth * 1.0f / MAX_LINE;//每個棋盤間的距離

    //單個棋子的邊長
    int pieceWidth = (int) (mLineHeight * ratioPieceOfLineHeight);

    mWhitePiece = Bitmap.createScaledBitmap(mWhitePiece, pieceWidth, pieceWidth, false);
    mBlackPiece = Bitmap.createScaledBitmap(mBlackPiece, pieceWidth, pieceWidth, false);
}

ok,在這里可以看到,我們得到了棋盤的邊長,行高(棋盤兩條線間的距離),棋子的邊長,以及兩個棋子的bitmap(這里進行了scale,縮放至pieceWidth大小)。

可能大家會有一個問題, ratioPieceOfLineHeight 這個變量是干嘛的?

因為我們的棋子是落在交叉線上, ratioPieceOfLineHeight 是我們設置的一個比例: 3 * 1.0f / 4 .這樣保證我們的棋子略小于行高(如果等于行高,兩個棋子就頭碰頭了,不美觀)。

ok,那么大家在自定義View,涉及到一些尺寸的計算(依賴寬高的),可以考慮在onSizeChanged中進去確定。

有了這些參數之后,棋盤已經可以繪制了。

三、繪制棋盤

繪制之前,先看一眼繪制后的效果圖:

然后再看代碼,因為不看效果圖,部分代碼不好解釋。紅色的區域是我故意打的背景色,可以很清晰的看到我們的View所處的位置,大家自定義View也可以在構造方法里面設置個透明的背景色,例如(0x44ff0000),寫完再刪掉就好了。

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

private void drawBoard(Canvas canvas)
{
    int w = mPanelWidth;
    float lineHeight = mLineHeight;

    for (int i = 0; i < MAX_LINE; i++)
    {
        int startX = (int) (lineHeight / 2);
        int endX = (int) (w - lineHeight / 2);
        int y = (int) ((0.5 + i) * lineHeight);
        canvas.drawLine(startX, y, endX, y, mPaint);
        canvas.drawLine(y, startX, y, endX, mPaint);
    }
}

先看橫向的:

可以看到我們的startX是行高的一半,因為我們的第一列的線上就可以落字,所以在線外圍空出了半個行高的距離。

endX沒什么說的,固定的,只要最外行留半個行高的距離即可。

剩下就是找每一行縱坐標y的規律了,這里規律是 (0.5 + i) * lineHeight ,相信也很容易看出來。

ok,縱向呢,就自己觀察吧。接下來看繪制棋子,棋子是交互產生的,也就是說涉及到onTouchEvent.

四、用戶交互

談到onTouchEvent,那么最主要的就是要理解Android View的touch機制了。

那么首先要明確的是,你是自定義View還是ViewGroup,當然我們這里只是個簡單的View,那么我們所要做的就是判斷自己是否消耗用戶touch事件,因為我們是五子棋,用戶點擊,我們落子,肯定是消耗的。

只要確定是能夠消耗事件的,那么首先復寫onTouchEvent,讓 ACTION_DOWN 的時候返回true:

@Override
public boolean onTouchEvent(MotionEvent event)
{

    int action = event.getAction();
    if (action == MotionEvent.ACTION_DOWN)
    {
        return true
    }

    //...
}

因為 ACTION_DOWN 時候,父控件會遍歷子View,能處理當前手勢的View,能處理意味著,(x,y)落在該View身上,這個View有消耗事件的能力。

View消耗事件的能力怎么看呢?默認就是調用view.dispatchTouchEvent是否返回true,這個方法內部又會調用View.onTouchEvent.

這么看來, ACTION_DOWN 可以說是我們表明態度的時候,我們能夠消耗事件就一定要的返回true。

千萬不要想著,你的View自由在MOVE的時候才會觸發一些事件,DOWN和你沒關系。用戶的手勢是包含 DOWN-MOVE*-UP 的,應該看成一個整體。如果你DOWN沒有表明自己的消耗事件的能力,那么你也就失去了成為targetView的機會,接下來的所有的MOVE-UP事件只會傳遞給targetView(這個說的正常邏輯流程,且暫不考慮攔截問題)。

ok,下面繼續回到五子棋,剛才確定了我們五子棋有消耗事件的能力,且 ACTION_DOWN 的時候表明了自己的態度(return true)。

但是呢,我們的棋子的添加與重繪并不適合寫到DOWN里面,為什么呢?

因為DOWN的話我們只是告知父View我們有處理事件的能力,而真正的棋子添加與重繪,我們選擇在UP中進行。如果寫在DOWN中,會帶來一些問題,其中之一就是,假設外層是ScrollView,界面是可以滑動的,如果你寫在DOWN中就可能造成用戶本意是滑動UI,卻同時繪制了一個棋子。

那么看代碼:

//白棋先手,當前輪到白棋
private boolean mIsWhite = true;
private ArrayList<Point> mWhiteArray = new ArrayList<>();
private ArrayList<Point> mBlackArray = new ArrayList<>();

@Override
public boolean onTouchEvent(MotionEvent event)
{
     int action = event.getAction();
    if (action == MotionEvent.ACTION_UP)
    {
        int x = (int) event.getX();
        int y = (int) event.getY();

        Point p = getValidPoint(x, y);
        if (mWhiteArray.contains(p) || mBlackArray.contains(p))
        {
            return false;
        }

        if (mIsWhite)
        {
            mWhiteArray.add(p);
        } else
        {
            mBlackArray.add(p);
        }
        invalidate();
        mIsWhite = !mIsWhite;

    }
    return true;
}

private Point getValidPoint(int x, int y)
{
    return new Point((int) (x / mLineHeight), (int) (y / mLineHeight));
}

代碼很簡單,UP的時候,我們首先根據(x,y)坐標,轉化為可落子點的坐標,即類似(0,0),(1,1)這種。然后判斷改點沒有被任何棋子占據,中間一個mIsWhite變量控制當前棋子的顏色,檢查完畢后加入到我們的集合中,最后調用invalidate()觸發重繪;

接下里就看棋子的繪制了~

五、繪制棋子

private void drawPieces(Canvas canvas)
{
    for (int i = 0, n = mWhiteArray.size(); i < n; i++)
    {
        Point whitePoint = mWhiteArray.get(i);
        canvas.drawBitmap(mWhitePiece,
                (whitePoint.x + (1 - ratioPieceOfLineHeight) / 2) * mLineHeight,
                (whitePoint.y + (1 - ratioPieceOfLineHeight) / 2) * mLineHeight, null);
    }

    for (int i = 0, n = mBlackArray.size(); i < n; i++)
    {
        Point blackPoint = mBlackArray.get(i);
        canvas.drawBitmap(mBlackPiece,
                (blackPoint.x + (1 - ratioPieceOfLineHeight) / 2) * mLineHeight,
                (blackPoint.y + (1 - ratioPieceOfLineHeight) / 2) * mLineHeight, null);
    }

}

可以看到呢,棋子繪制也非常簡單,唯一需要計算的就是棋子繪制左上角的坐標。

簡單看下圖:

我們白子對應的point是(0,0),那么它左上角的橫坐標是:

((1 - ratioPieceOfLineHeight) / 2)* mLineHeight

做外層豎線空隙為1/2 lineheight,棋子的半個寬度為(1 - ratioPieceOfLineHeight)/2 lineHeight , 那么橫坐標即為:

//外層空隙-棋子一半的寬度
1/2 * mLineHeight - (1 - ratioPieceOfLineHeight)/2 * mLineHeight

按照上述推理,找到規律應該不難。

ok,到這就可以落子了

那么最后我們再關注一下View狀態的存儲于恢復。

六、View狀態存儲于恢復

首先聊一聊為什么要存儲與恢復狀態。

比如大家正在下棋,此時女朋友來電話了,接個電話后,切回來棋局不見了,是不是很不能接受。很多View都需要去存儲和恢復狀態,比如EditText,你寫了大篇文章以后,因為看了會QQ記錄,切回來文字不見了,可以腦補下場景。

原因呢,大家可能也清楚,主要是我們的Activity置于后臺,由于內存等原因被殺死了,等再次進入后會重建。那么一般Activity我們會考慮在onSaveInstanceState、onRestoreInstanceState中進行狀態存儲與恢復,View也有類似的方法。

還有個問題,關于測試,內存原因被殺這個很難模擬,大家可以選擇旋轉屏幕去測試View的狀態是否正確存儲與恢復,所以在開發過程中沒事旋轉一下。

下面看我們的狀態存儲與恢復的代碼:

private static final String INSTANCE = "instance";
private static final String INSTANCE_GAME_OVER = "instance_game_over";
private static final String INSTANCE_WHITE_ARRAY = "instance_white_array";
private static final String INSTANCE_BLACK_ARRAY = "instance_black_array";

@Override
protected Parcelable onSaveInstanceState()
{
    Bundle bundle = new Bundle();
    bundle.putParcelable(INSTANCE, super.onSaveInstanceState());
    bundle.putBoolean(INSTANCE_GAME_OVER, mIsGameOver);
    bundle.putParcelableArrayList(INSTANCE_WHITE_ARRAY, mWhiteArray);
    bundle.putParcelableArrayList(INSTANCE_BLACK_ARRAY, mBlackArray);
    return bundle;
}

@Override
protected void onRestoreInstanceState(Parcelable state)
{
    if (state instanceof Bundle)
    {
        Bundle bundle = (Bundle) state;
        mIsGameOver = bundle.getBoolean(INSTANCE_GAME_OVER);
        mWhiteArray = bundle.getParcelableArrayList(INSTANCE_WHITE_ARRAY);
        mBlackArray = bundle.getParcelableArrayList(INSTANCE_BLACK_ARRAY);
        super.onRestoreInstanceState(bundle.getParcelable(INSTANCE));
        return;
    }
    super.onRestoreInstanceState(state);
}

在onSaveInstanceState中去使用bundle保存需要保存的變量,注意一點,有時候我們是繼承別的View,而這個View它可能已經做了部分的狀態存儲,所以不要忘了將原本的狀態也存儲下,即:

bundle.putParcelable(INSTANCE, super.onSaveInstanceState());

有了存儲,對應看恢復的代碼也簡單了。

這里存儲與恢復的代碼,基本上對于任何的View都可以這么寫,區別只是保存的變量不同,比如說progressbar保存的可能是progress,TextView保存的可能是text。

究竟該保存哪些呢?

一般都是運行過程中產生的變化,對于那些在構造中初始化的就不要去保存了。

最后,寫完不代表就一定能存儲與恢復了,記得你的View在布局文件中聲明一定要有一個Id.

<com.imooc.wuziqi.WuziqiPanel
    android:id="@+id/id_wuziqi"
    android:layout_centerInParent="true"
    android:layout_width="match_parent"
    android:layout_height="match_parent"/>

ok,還剩下輸贏判斷這些并不涉及到Android平臺上的特性,就不贅述了,本篇文章并不是介紹如何去編寫五子棋的,而是想通過這個五子棋來說下自定義View中需要注意的問題,如果你對整個五子棋的編寫感興趣,可以通過課程學習,課程地址:http://www.imooc.com/learn/641,部分素材地址:https://github.com/hongyangAndroid/mooc_hyman.

 

來自:http://mp.weixin.qq.com/s?__biz=MzAxMTI4MTkwNQ==&mid=402946490&idx=1&sn=1ddacffd0f861fa0ab50921a71639a2f&scene=23&srcid=0506KP9VHEecMo3bfl61x98Y#rd

 

 本文由用戶 ChetBacon 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!