Android 五子连珠背后的故事

ChetBacon 7年前
   <p>前段时间呢,因为AlphaGo让围棋很火,所以慕课网也邀请我做个棋类的课程,后来我选择了五子棋,讲道理我是不喜欢这个课程的,因为感觉题目比较老旧,在我印象中我初学时就好像学习过,不过当我写完代码、备完课,脑子里面简单过了下想要如何表达之后。然后我就改变了看法,这个课程还是蛮不错的,如果表达的清楚,还是能说明不少东西的。</p>    <p>ok,那么本文就是对五子棋在编写过程中的注意事项,我编写时遇到的坑,以及哪些地方值得去思考这三个方面进行展开。</p>    <p>ok,进入正题。对于五子棋,因为涉及到棋盘、棋子的绘制,所以肯定离不开自定义View。可能有人还会想到,因为是游戏,那么考不考虑SurfaceView,恩,因为五子棋的绘制基本都是和人交互之后产生的,不存需要大面积不断绘制的部分(游戏场景不断变化),所以自定义View就可以了。</p>    <p>那么说到自定义View,那么大家都不陌生,五子棋这个View需要涉及到哪些呢?</p>    <ul>     <li> <p>测量。我们的五子棋棋盘预期是正方形,所以避免不了需要去重写测量的代码。</p> </li>     <li> <p>绘制。这个是一定的,因为我们需要绘制棋盘,棋子。</p> </li>     <li> <p>用户交互(onTouchEvent)。很显然,我们要下棋哇。</p> </li>     <li> <p>状态保存。恩,谁也不想下到一半,接个电话之后,棋局不见了。</p> </li>    </ul>    <p>这么看,五子棋这个View很全面哇,基本包含了自定义View所有的环节,当然还有五子棋自身的一些逻辑,这里我们暂不叙述。接下来针对上述环节一一介绍。</p>    <h2><strong>一、测量</strong></h2>    <p>说到测量,测量其实是自定义View最难把握的一个环节,不是因为它难,而是因为我们往往想太多。</p>    <p>那么如何把握好测量呢,其实就是分析清楚需求,比如我们的五子棋View,我们的需求是个正方形,并且内部的棋子、棋盘都依赖View的大小进行绘制,那么可以得出个结论,这个View在使用的时候必须指明宽度和高度。</p>    <p>有人那么会说,你不支持 wrap_content 吗? wrap_content 什么意思呢?意思是View的大小由自己的内容确定,如果你的控件的内容是可测量的,那么支持是没问题的,比如内部是指定了textSize的文本。还有种情况,是没办法测量的,比如我们的10*10棋盘,是依赖外部的View的宽高的,这种情况就没有办法支持 wrap_content 。当然,你可以设置棋盘两行间的最小距离,那么就变成可测量的了,不过这里我们不考虑支持。</p>    <p>ok,说这么多,只是想表明一个意思,自定义View大多都是有着特殊的使用场景和特殊的需求的,所以根据你的使用需求,去判断测量是否需要支持各种情况,避免不必要的逻辑。</p>    <p>那么我们的测量代码是:</p>    <pre>  <code class="language-java">@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);  }</code></pre>    <p>很简单,获取用户设置的宽度和高度的值,取最小值为我们的View的边长(由于宽高一直,下文统一使用边长)。</p>    <p>乍一看是没问题的,因为我们指明了该View在使用过程中用户必须给我们指明宽高,即支持固定值和match_parent。</p>    <p>不过还有个特殊的情况要注意到,假设我们的View处于ScrollView中,那么对于 layout_width=match_parent 这样的设置,你去运行,就会惊奇的发现,我们的View不见了,不见了。</p>    <p>没错,这种情况下,我们的上述代码获取到的heightSize很有可能是0,然后取最小值就彻底为0了。</p>    <p>那么,我们先看处理后的代码,再谈原因:</p>    <pre>  <code class="language-java">@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);  }</code></pre>    <p>ok,我们加了些 MeasureSpec.UNSPECIFIED 的判断,如果处于ScrollView里面,heightMode就可能为UNSPECIFIED,那么我们以宽度上的尺寸为标准,反之对于宽度也同样处理。</p>    <p>那么有同学会问,我判断0可以吗?</p>    <p>例如这样:</p>    <pre>  <code class="language-java">if (widthSize == 0)  {      width = heightSize;  } else if (heightSize == 0)  {      width = widthSize;  }</code></pre>    <p>ok,这种方式以前我是没有发现问题的,但是在API 23我发现,这个size表现发生变化了。</p>    <pre>  <code class="language-java">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;  }</code></pre>    <p>ok,早起的版本是这样的:</p>    <pre>  <code class="language-java">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;  }</code></pre>    <p>可以看到对于resultSize处理,发生了变化,这里了解下就行了,所以对于处理 MeasureSpec.UNSPECIFIED 这类的逻辑,尽可能不要去依赖size(感兴趣的也可以去模拟场景,然后测试打印下)。</p>    <p>关于测量,我们扯了很多,一方面是如何把握测量,另一方面是对于部分特殊场景特殊的处理。</p>    <p>测量完成之后,接下来干嘛呢,直奔绘制吗?绘制需要依赖很多尺寸值,这些值可以在 onSizeChanged 去确定。</p>    <h2><strong>二、部分尺寸参数的确定</strong></h2>    <pre>  <code class="language-java">@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);  }</code></pre>    <p>ok,在这里可以看到,我们得到了棋盘的边长,行高(棋盘两条线间的距离),棋子的边长,以及两个棋子的bitmap(这里进行了scale,缩放至pieceWidth大小)。</p>    <p>可能大家会有一个问题, ratioPieceOfLineHeight 这个变量是干嘛的?</p>    <p>因为我们的棋子是落在交叉线上, ratioPieceOfLineHeight 是我们设置的一个比例: 3 * 1.0f / 4 .这样保证我们的棋子略小于行高(如果等于行高,两个棋子就头碰头了,不美观)。</p>    <p>ok,那么大家在自定义View,涉及到一些尺寸的计算(依赖宽高的),可以考虑在onSizeChanged中进去确定。</p>    <p>有了这些参数之后,棋盘已经可以绘制了。</p>    <h2><strong>三、绘制棋盘</strong></h2>    <p>绘制之前,先看一眼绘制后的效果图:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/9cb2a1788e97238014a21094a3c8e3db.jpg"></p>    <p>然后再看代码,因为不看效果图,部分代码不好解释。红色的区域是我故意打的背景色,可以很清晰的看到我们的View所处的位置,大家自定义View也可以在构造方法里面设置个透明的背景色,例如(0x44ff0000),写完再删掉就好了。</p>    <pre>  <code class="language-java">@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);      }  }</code></pre>    <p>先看横向的:</p>    <p>可以看到我们的startX是行高的一半,因为我们的第一列的线上就可以落字,所以在线外围空出了半个行高的距离。</p>    <p>endX没什么说的,固定的,只要最外行留半个行高的距离即可。</p>    <p>剩下就是找每一行纵坐标y的规律了,这里规律是 (0.5 + i) * lineHeight ,相信也很容易看出来。</p>    <p>ok,纵向呢,就自己观察吧。接下来看绘制棋子,棋子是交互产生的,也就是说涉及到onTouchEvent.</p>    <h2><strong>四、用户交互</strong></h2>    <p>谈到onTouchEvent,那么最主要的就是要理解Android View的touch机制了。</p>    <p>那么首先要明确的是,你是自定义View还是ViewGroup,当然我们这里只是个简单的View,那么我们所要做的就是判断自己是否消耗用户touch事件,因为我们是五子棋,用户点击,我们落子,肯定是消耗的。</p>    <p>只要确定是能够消耗事件的,那么首先复写onTouchEvent,让 ACTION_DOWN 的时候返回true:</p>    <pre>  <code class="language-java">@Override  public boolean onTouchEvent(MotionEvent event)  {         int action = event.getAction();      if (action == MotionEvent.ACTION_DOWN)      {          return true      }            //...  }</code></pre>    <p>因为 ACTION_DOWN 时候,父控件会遍历子View,能处理当前手势的View,能处理意味着,(x,y)落在该View身上,这个View有消耗事件的能力。</p>    <p>View消耗事件的能力怎么看呢?默认就是调用view.dispatchTouchEvent是否返回true,这个方法内部又会调用View.onTouchEvent.</p>    <p>这么看来, ACTION_DOWN 可以说是我们表明态度的时候,我们能够消耗事件就一定要的返回true。</p>    <p>千万不要想着,你的View自由在MOVE的时候才会触发一些事件,DOWN和你没关系。用户的手势是包含 DOWN-MOVE*-UP 的,应该看成一个整体。如果你DOWN没有表明自己的消耗事件的能力,那么你也就失去了成为targetView的机会,接下来的所有的MOVE-UP事件只会传递给targetView(这个说的正常逻辑流程,且暂不考虑拦截问题)。</p>    <p>ok,下面继续回到五子棋,刚才确定了我们五子棋有消耗事件的能力,且 ACTION_DOWN 的时候表明了自己的态度(return true)。</p>    <p>但是呢,我们的棋子的添加与重绘并不适合写到DOWN里面,为什么呢?</p>    <p>因为DOWN的话我们只是告知父View我们有处理事件的能力,而真正的棋子添加与重绘,我们选择在UP中进行。如果写在DOWN中,会带来一些问题,其中之一就是,假设外层是ScrollView,界面是可以滑动的,如果你写在DOWN中就可能造成用户本意是滑动UI,却同时绘制了一个棋子。</p>    <p>那么看代码:</p>    <pre>  <code class="language-java">//白棋先手,当前轮到白棋  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));  }</code></pre>    <p>代码很简单,UP的时候,我们首先根据(x,y)坐标,转化为可落子点的坐标,即类似(0,0),(1,1)这种。然后判断改点没有被任何棋子占据,中间一个mIsWhite变量控制当前棋子的颜色,检查完毕后加入到我们的集合中,最后调用invalidate()触发重绘;</p>    <p>接下里就看棋子的绘制了~</p>    <h2><strong>五、绘制棋子</strong></h2>    <pre>  <code class="language-java">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);      }    }</code></pre>    <p>可以看到呢,棋子绘制也非常简单,唯一需要计算的就是棋子绘制左上角的坐标。</p>    <p>简单看下图:</p>    <p><img src="https://simg.open-open.com/show/996811a1c2c49d302eabf67c47457604.jpg"></p>    <p>我们白子对应的point是(0,0),那么它左上角的横坐标是:</p>    <p>((1 - ratioPieceOfLineHeight) / 2)* mLineHeight</p>    <p>做外层竖线空隙为1/2 lineheight,棋子的半个宽度为(1 - ratioPieceOfLineHeight)/2 lineHeight , 那么横坐标即为:</p>    <pre>  <code class="language-java">//外层空隙-棋子一半的宽度  1/2 * mLineHeight - (1 - ratioPieceOfLineHeight)/2 * mLineHeight</code></pre>    <p>按照上述推理,找到规律应该不难。</p>    <p>ok,到这就可以落子了</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/50fe86740c04e57acee6ed5847f4cdd5.gif"></p>    <p>那么最后我们再关注一下View状态的存储于恢复。</p>    <h2><strong>六、View状态存储于恢复</strong></h2>    <p>首先聊一聊为什么要存储与恢复状态。</p>    <p>比如大家正在下棋,此时女朋友来电话了,接个电话后,切回来棋局不见了,是不是很不能接受。很多View都需要去存储和恢复状态,比如EditText,你写了大篇文章以后,因为看了会QQ记录,切回来文字不见了,可以脑补下场景。</p>    <p>原因呢,大家可能也清楚,主要是我们的Activity置于后台,由于内存等原因被杀死了,等再次进入后会重建。那么一般Activity我们会考虑在onSaveInstanceState、onRestoreInstanceState中进行状态存储与恢复,View也有类似的方法。</p>    <p>还有个问题,关于测试,内存原因被杀这个很难模拟,大家可以选择旋转屏幕去测试View的状态是否正确存储与恢复,所以在开发过程中没事旋转一下。</p>    <p>下面看我们的状态存储与恢复的代码:</p>    <pre>  <code class="language-java">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);  }</code></pre>    <p>在onSaveInstanceState中去使用bundle保存需要保存的变量,注意一点,有时候我们是继承别的View,而这个View它可能已经做了部分的状态存储,所以不要忘了将原本的状态也存储下,即:</p>    <pre>  <code class="language-java">bundle.putParcelable(INSTANCE, super.onSaveInstanceState());</code></pre>    <p>有了存储,对应看恢复的代码也简单了。</p>    <p>这里存储与恢复的代码,基本上对于任何的View都可以这么写,区别只是保存的变量不同,比如说progressbar保存的可能是progress,TextView保存的可能是text。</p>    <p>究竟该保存哪些呢?</p>    <p>一般都是运行过程中产生的变化,对于那些在构造中初始化的就不要去保存了。</p>    <p>最后,写完不代表就一定能存储与恢复了,记得你的View在布局文件中声明一定要有一个Id.</p>    <pre>  <code class="language-java"><com.imooc.wuziqi.WuziqiPanel      android:id="@+id/id_wuziqi"      android:layout_centerInParent="true"      android:layout_width="match_parent"      android:layout_height="match_parent"/></code></pre>    <p>ok,还剩下输赢判断这些并不涉及到Android平台上的特性,就不赘述了,本篇文章并不是介绍如何去编写五子棋的,而是想通过这个五子棋来说下自定义View中需要注意的问题,如果你对整个五子棋的编写感兴趣,可以通过课程学习,课程地址:http://www.imooc.com/learn/641,部分素材地址:https://github.com/hongyangAndroid/mooc_hyman.</p>    <p> </p>    <p>来自:http://mp.weixin.qq.com/s?__biz=MzAxMTI4MTkwNQ==&mid=402946490&idx=1&sn=1ddacffd0f861fa0ab50921a71639a2f&scene=23&srcid=0506KP9VHEecMo3bfl61x98Y#rd</p>    <p> </p>