Android自定义View实战:五子棋项目从绘制到交互的完整拆解
发布时间:2026/10/10 1:38:09 锦皓数字建站

简介面向Android初学者的五子棋完整项目演示了从XML界面设计到Android游戏逻辑落地的全过程可帮助开发者熟悉Android Studio的工程结构、资源管理与模拟器调试方法压缩包共49个文件以17个XML布局与配置、15张PNG/JPG棋盘棋子素材、4个Kotlin核心类为主辅以Gradle构建脚本及Git忽略规则整体877KB轻量且结构清晰。已有2305人学习下载。项目按职责拆分为MainActivity界面控制、棋盘状态记录、落子合法性校验和五子连珠胜负判定等模块配合触摸事件监听、视图重绘及重新开始/保存加载功能完整还原双人对战场景读者可借此掌握自定义棋盘绘制、点击事件分发、五子连珠检测算法以及状态管理思想并可在此基础上扩展人机AI或悔棋功能。对于正在完成Android课程设计或入门游戏开发的读者这是一份可直接导入运行、边读边改的实用参考。1. Android Studio五子棋一个练手项目为何值得拆很多人在学完四大组件之后想找个能串起自定义View、触摸事件、状态管理的项目练手五子棋通常是第一选择。但你跟着网上的教程敲完往往发现模拟器上还能跑一装到真机就棋盘变形、触摸失灵、落子位置对不上格子。问题不在算法而在你对Android的绘制流程和事件分发基本功是否扎实。这次拆的这份Android Studio五子棋项目正好覆盖了从自定义View绘制棋盘、Canvas坐标转换、触摸落子、胜负判定到悔棋的完整链路。它不是一个炫技的成品而是一份能让你把绘制与交互底层逻辑走通的教学型工程。适合刚学完基础、想验证自己能不能独立完成一个小型交互应用的人。2. 技术选型与工程骨架SurfaceView、自定义View和事件分发的取舍2.1 为什么是自定义 View 而不是布局堆控件五子棋界面第一反应是用GridLayout铺ImageView每个交叉点放一个棋子图片黑白两种状态来回切。这种做法确实能做出来但落子时要动态增删子View悔棋时要重排整个布局棋盘越下越重界面会在十几手之后明显卡顿。更关键的是这种方案把业务状态和UI绑死在XML里后期想加AI、回放、难度调节都很别扭。自定义View是更合理的路径棋盘、棋子、坐标线全部用Canvas在onDraw里画出来数据层用二维数组维护每次落子调用invalidate()触发重绘。View的绘制流程是onMeasure到onLayout再到onDraw在五子棋这个场景里尺寸测量不需要复杂的逻辑你只需要在onSizeChanged里算好格子边长其余交给Canvas。为什么不选SurfaceView因为SurfaceView适合高频刷新、需要独立线程绘制的场景比如游戏引擎里每帧都在变化的画面。五子棋是低频交互一局棋最多两百多次落子onDraw在主线程几毫秒就能画完用SurfaceView反而要自己处理线程同步和生命周期属于给自己挖坑。2.2 工程骨架与 Gradle 配置最小可运行项目长什么样一份能直接跑的Android Studio五子棋工程结构上不需要复杂。核心是一个GameActivity、一个自定义的GobangView、一个用于判定和状态的GameLogic类再加上资源文件。app/src/main/java/com/example/gobang/ GameActivity.java GobangView.java GameLogic.java app/src/main/res/layout/activity_game.xml app/build.gradle如果你手头有网页版五子棋的HTML源码移植思路也很直接DOM绘制换成Canvas15x15的棋盘逻辑映射到二维数组胜负判定那段JS逻辑几乎可以原样搬成Java。UI层重写逻辑层平移这是这类移植项目最快落地的做法。app/build.gradle里建议这样配置android { compileSdk 34 defaultConfig { applicationId com.example.gobang minSdk 21 targetSdk 34 versionCode 1 versionName 1.0 } compileOptions { sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } } dependencies { implementation androidx.appcompat:appcompat:1.6.1 }minSdk 21覆盖了绝大多数存量设备targetSdk 34是当前主流的编译目标Java 17配合新版Android Studio的JDK版本不会出现兼容性告警。依赖只保留appcompat像五子棋这种纯自绘项目不需要引入其他UI框架减少一层依赖就少一层编译风险。2.3 事件分发与 onTouchEvent 的边界自定义View接收触摸有两种常见写法一种是给View设置OnClickListener另一种是重写onTouchEvent。棋盘这种需要精确坐标的场景我一般用onTouchEvent因为点击事件拿不到原始坐标的精度而且要自己补performClick。处理触摸事件时要注意事件流的边界手指按下、移动、抬起是一组完整事件落子动作放在ACTION_UP里处理最合适因为用户有可能在移动过程中改变主意按下的位置和抬起的位置并不一样。另外onTouchEvent的返回值必须为true表示消费了这次事件。如果返回false后续的MOVE和UP事件不会继续传给这个View你会发现日志里只有ACTION_DOWN松手时落不了子。这个坑在模拟器上不太明显在真机上很容易复现。3. 棋盘绘制与落子交互Canvas坐标转换和触控参数3.1 棋盘与棋子的绘制从测量到坐标计算棋盘View的核心是尺寸计算。一个15路棋盘四周留边距格子数固定为14段。在onSizeChanged里做初始化拿到View的宽高后取最小值保证棋盘是正方形防止父布局拉伸导致棋盘变形。public class GobangView extends View { private final int BOARD_SIZE 15; private final Paint linePaint new Paint(Paint.ANTI_ALIAS_FLAG); private final Paint blackPaint new Paint(Paint.ANTI_ALIAS_FLAG); private final Paint whitePaint new Paint(Paint.ANTI_ALIAS_FLAG); private int boardWidth; private float blockSize; private float padding; private int[][] board new int[BOARD_SIZE][BOARD_SIZE]; private final StackInteger history new Stack(); private int currentPlayer 1; private static final int EMPTY 0; private static final int BLACK 1; private static final int WHITE 2; private boolean gameOver; public GobangView(Context context, AttributeSet attrs) { super(context, attrs); linePaint.setColor(0xFF8B5A2B); linePaint.setStrokeWidth(2); blackPaint.setColor(0xFF000000); blackPaint.setStyle(Paint.Style.FILL); whitePaint.setColor(0xFFFFFFFF); whitePaint.setStyle(Paint.Style.FILL); } Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); boardWidth Math.min(w, h); padding boardWidth * 0.05f; blockSize (boardWidth - 2 * padding) / (BOARD_SIZE - 1); } Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); // 画棋盘线 for (int i 0; i BOARD_SIZE; i) { float start padding i * blockSize; canvas.drawLine(start, padding, start, boardWidth - padding, linePaint); canvas.drawLine(padding, start, boardWidth - padding, start, linePaint); } // 画棋子 for (int row 0; row BOARD_SIZE; row) { for (int col 0; col BOARD_SIZE; col) { if (board[row][col] BLACK) { canvas.drawCircle(padding col * blockSize, padding row * blockSize, blockSize * 0.45f, blackPaint); } else if (board[row][col] WHITE) { canvas.drawCircle(padding col * blockSize, padding row * blockSize, blockSize * 0.45f, whitePaint); } } } } }尺寸计算为什么要放在onSizeChanged里因为onDraw每一帧都会被调用在绘制方法里重复计算格子边长纯属浪费。onSizeChanged只在View尺寸变化时触发一次正好满足棋盘尺寸固定不变的需求。棋子半径取0.45倍blockSize而不是0.5倍是为了让相邻两颗棋子之间保留视觉间隙否则贴在一起看起来像是连成了一片。绘制参数里还有两个细节Paint必须设置ANTI_ALIAS_FLAG抗锯齿否则斜线和圆边缘会呈现明显的锯齿棋盘线的颜色用0xFF8B5A2B这样的ARGB十六进制比Color.rgb可读性更好也方便统一调色板。3.2 坐标转换与落子判定onTouchEvent 里的一步关键换算onTouchEvent拿到的event.getX()和event.getY()是View坐标系里的像素值需要把它换算成棋盘上的行列号。换算公式是网格索引 (触摸坐标 - 边距) / 格子边长。这里最容易出错的地方是取整方式。Override public boolean onTouchEvent(MotionEvent event) { if (event.getAction() MotionEvent.ACTION_UP !gameOver) { float x event.getX(); float y event.getY(); int col Math.round((x - padding) / blockSize); int row Math.round((y - padding) / blockSize); // 越界判断 if (col 0 || row 0 || col BOARD_SIZE || row BOARD_SIZE) { return true; } // 容差过滤距离交叉点太远时不落子 float cx padding col * blockSize; float cy padding row * blockSize; if (Math.abs(x - cx) blockSize / 3 || Math.abs(y - cy) blockSize / 3) { return true; } // 该位置已有棋子 if (board[row][col] ! EMPTY) { return true; } board[row][col] currentPlayer; history.push(row * BOARD_SIZE col); currentPlayer 3 - currentPlayer; invalidate(); } return true; }两个关键点一是Math.round而不是强转int。直接强转相当于向下取整触摸点在交叉点右侧半个格子内时落子会偏向左边整盘棋会看起来整体偏移。round是四舍五入取最近交叉点落子手感会准很多。二是容差过滤允许用户点按在两格交界处时忽略这次触摸避免用户手指稍偏就落到旁边格子。这个容差参数可以按手感调整。默认blockSize / 3意味着允许偏移约33%的格距实际体验接近标准五子棋App的手感。想更严格就改成blockSize / 4想更宽松就改成blockSize / 2但超过一半会导致相邻两个交叉点的判定区域重叠。3.3 最后一步高亮与终端状态体验细节里的参数选择棋盘能下棋之后交互体验上还有一个常见需求最后一步落子位置要高亮显示方便对弈双方快速定位。实现方式不复杂在onDraw里记录最后一步的坐标画一个醒目的标记圈。private int lastRow -1; private int lastCol -1; // 落子时更新 lastRow row; lastCol col; // onDraw 中绘制高亮 if (lastRow 0) { canvas.drawCircle(padding lastCol * blockSize, padding lastRow * blockSize, blockSize * 0.2f, markPaint); }高亮半径取0.2倍blockSize比棋子半径小一半左右刚好在内圈位置不会和棋子轮廓重叠。颜色用红色或对比度高的橙色避免和黑白两色棋子混淆。做完这一步棋盘的基本交互才算完整。4. 对弈核心与状态管理落子逻辑、胜负判定和悔棋机制4.1 胜负判定算法四方向扫描与边界收敛五子棋的胜负判定最常见的写法是对整个棋盘全盘扫描逐行逐列检查是否有连续五子。这种做法在15路棋盘上性能也能接受但代码冗长且不好维护。更优雅的做法是只检查当前落子位置从最后一步出发沿四个方向水平、垂直、两条对角线向两侧延伸统计同色连续棋子数量总数达到5即胜出。private static final int[][] DIRS { {0, 1}, // 水平方向 {1, 0}, // 垂直方向 {1, 1}, // 主对角线 {1, -1} // 副对角线 }; private boolean checkWin(int row, int col) { int player board[row][col]; for (int[] dir : DIRS) { int count 1; count countDir(row, col, dir[0], dir[1], player); count countDir(row, col, -dir[0], -dir[1], player); if (count 5) { return true; } } return false; } private int countDir(int row, int col, int dr, int dc, int player) { int count 0; int r row dr; int c col dc; while (r 0 r BOARD_SIZE c 0 c BOARD_SIZE board[r][c] player) { count; r dr; c dc; } return count; }为什么只检查最后一步因为每一步棋都可能形成五连而五连必然包含最后刚落下的这颗子。只检查这次落子位置周围的连线把判定时间复杂度从O(n^2)降到O(1)在15路棋盘上差距不大但代码逻辑更清晰也方便后续扩展更复杂的棋型判断。注意countDir里的边界条件。r和c同时判断上下限确保数组访问不越界。边界判断放在while条件里而不是循环体内部避免进入循环后再break代码更紧凑。count初始值为1因为当前落子本身就算一颗。4.2 悔棋与状态恢复栈结构里的每一步悔棋功能在棋类应用里属于高频操作实现的关键是记录下棋顺序。用Stack存储每一步的位置悔棋时从栈顶弹出最后一步清除棋盘状态并切换回上一名玩家的回合。public void undo() { if (history.isEmpty() || gameOver) { return; } int pos history.pop(); int row pos / BOARD_SIZE; int col pos % BOARD_SIZE; board[row][col] EMPTY; currentPlayer 3 - currentPlayer; if (lastRow row lastCol col) { lastRow -1; lastCol -1; } invalidate(); }栈里存的为什么要用一维整数而不是二维数组坐标因为一个int可以同时保存行和列pos / BOARD_SIZE得到行号pos % BOARD_SIZE得到列号。相比存两个int栈内元素占用减半push和pop操作也更快。悔棋后还要清掉最后一步高亮标记否则棋盘上会出现一个孤零零的红色圈悬在空交叉点上。currentPlayer 3 - currentPlayer是一个小技巧。如果约定黑方为1、白方为2那么1 2 3用3减去当前玩家就能得到对手。比写if-else切换更简洁也避免引入额外的状态变量。重开一局时只需要清空board数组和history栈重置currentPlayer为黑方。4.3 回合切换与终局状态胜负揭示后的防御落子后需要立即检查是否获胜胜出后设置gameOver标志禁止棋盘继续响应触摸事件。常见做法是落子后先更新棋盘再执行checkWin若返回true则弹窗提示并锁定棋盘。board[row][col] currentPlayer; history.push(row * BOARD_SIZE col); lastRow row; lastCol col; if (checkWin(row, col)) { gameOver true; String winner (currentPlayer BLACK) ? 黑方 : 白方; Toast.makeText(getContext(), winner 获胜, Toast.LENGTH_SHORT).show(); } else { currentPlayer 3 - currentPlayer; } invalidate();注意切换回合的顺序。先判断胜负再切换玩家否则落子方和获胜方会对不上。gameOver的粒度是整局游戏一旦置为trueonTouchEvent里的事件处理整个跳过不管是落子还是悔棋都不再响应。还有一点容易被忽略如果棋盘下满仍无人获胜需要单独处理平局。可以在落子后判断history.size()是否等于BOARD_SIZE * BOARD_SIZE达到上限且无人获胜即为和棋。5. 避坑排查实录重绘失效、坐标错位和资源重复的三类现场5.1 棋盘一片空白布局高度为 0 还是计算时机不对现象模拟器打开App棋盘区域一片空白Log里能看到onDraw被调用但界面上什么都没有。原因最常见的是自定义View没有被分配有效尺寸。布局里如果写成wrap_content且没有在onMeasure里做处理View可能实际高度为0。另一种情况是onSizeChanged没有被触发blockSize保持默认值0drawLine的起点终点全是0画出来就是一条看不见的线。解决在activity_game.xml里给GobangView指定明确的宽高或使用等比的FrameLayout包裹。更保险的做法是在onDraw里加防御if (blockSize 0 || boardWidth 0) { return; }尺寸没初始化就直接return比画一条不可见的线要直观得多排查问题时也容易定位到尺寸计算的环节。5.2 invalidate() 调了画面不走线程与重绘触发的边界现象Log确认invalidate()执行了但棋盘画面没有任何变化。原因invalidate()只能在UI线程调用。如果你在子线程里落子update棋盘后直接调invalidate()系统不会安排重绘画面自然不动。另外如果你传入的invalidate(Rect)区域与实际变化区域不匹配也会出现只重绘部分区域、视觉效果上像没更新。解决子线程更新棋盘后使用postInvalidate()它会通过Handler切回主线程触发重绘。如果使用了带Rect的重载版本确认矩形覆盖了需要更新的范围或者干脆使用无参invalidate()。五子棋棋盘刷新是轻量操作全量重绘没有性能压力。5.3 R 资源重复导致编译失败同名文件惹的祸现象编译报错提示“Resource ... already defined”或者“Attribute ... already defined”。原因Android工程里资源文件不允许同名即使扩展名不同也会冲突。比如res/drawable下有一个gobang_bg.png同时res/mipmap里也有一个gobang_bg.xmlR类生成时就会报重复。如果是模块化工程多个module各自引入了同名icon合并资源阶段同样会撞车。解决所有资源文件统一加项目前缀。五子棋工程里的背景图、图标、字符串都以gobang_开头从源头避免和其他模块冲突。出现报错时优先看R类生成日志它一般会明确提示冲突文件路径逐个重命名即可。5.4 真机棋盘拉伸变形dp 与 px 混用的代价现象模拟器上显示正常装到真机上棋盘变成长方形横竖格子长度不一致。原因模拟器的像素密度和你做适配时用的参考设备不一致如果自定义View里直接使用了固定像素值或者父布局用权重拉伸了View的宽高比棋盘就会变形。棋盘需要正方形View的宽和高必须保持一致。解决在onSizeChanged里用Math.min(w, h)取小值作为棋盘边长绘制时在这个范围内计算。外部布局用match_parent时宽高会被父布局决定此时可以在onMeasure里强制设置正方形尺寸Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int size Math.min( MeasureSpec.getSize(widthMeasureSpec), MeasureSpec.getSize(heightMeasureSpec)); setMeasuredDimension(size, size); }onMeasure里强制把View锁成正方形这样无论父布局怎么拉伸View本身都不会变形。5.5 触摸坐标偏离格子取整方式与容差参数现象点击某个交叉点棋子总落在交叉点偏左上方向越靠近棋盘边缘越明显。原因坐标转换里用了强转int做取整。Java的int转换直接截断小数触摸点在0.6个格子位置时会被当成第0格而不是四舍五入到第1格所有落子都向左上偏移。解决改用Math.round()做四舍五入同时加上远离交叉点的容差过滤。改完后测试几个边缘交叉点左上角边界触控应该落在第0行第0列右下角落在第14行第14列。如果发现边界处总是难以落子可以适当调小容差但不要小于blockSize / 4否则手指略微偏移就会落到错误位置。6. 进阶玩法与验证清单人机AI、动态重放和自定义难度基础对弈跑通之后这份资源的价值主要体现在三个可扩展的方向上。第一个是加入简易的人机AI不引入复杂算法只是对每个空位做评分。评分规则是沿四个方向统计该位置左右各有多少连续同色棋子如果是己方棋子则加权为攻击分对方棋子则加权为防守分总分为两者之和选择分数最高的点落子。这种启发式AI没有搜索深度下不过高手但作为入门的人机对战完全够用而且评分逻辑和checkWin共用了同一套方向遍历代码复用率很高。第二个方向是落子回放。既然history栈已经记录了每一步的坐标顺序只要在悔棋时把弹出的坐标存进另一个回放列表再提供一个“下一手”按钮就能实现完整的对局回放。这个方法比重开一局再模拟落子更简单也方便复盘。第三个方向是棋盘尺寸可配置。把BOARD_SIZE从常量改为成员变量通过构造方法或setter传入就能在8路、13路、15路之间切换。这个改动会同时影响胜负判定和坐标转换因为格子数变了blockSize的计算公式不变但判定阈值和边距需要随之调整。验证这套改动是否可靠我通常按下面的清单过一遍每路棋盘的四个角和中心点各落一子确认落子位置准确随机生成两万步模拟落子强制触发checkWin看会不会出现漏判或误判悔棋到空棋盘状态确认lastRow和lastCol被清空且currentPlayer回到黑方。验证项操作方式预期结果落子精度点击每个交叉点棋子落在目标交叉点圆心胜负判定手动连成五子触发获胜事件并锁定棋盘悔棋恢复连续悔棋至开局棋盘清空、玩家回合复位边界情况棋盘下满无五连触发平局处理从这个项目里收获最大的不是五子棋算法而是建立了一套自己的调试习惯。从那以后我每次新建Android项目都会先让最小可玩版本跑通再往上堆AI和回放这些附加功能这个顺序帮我省掉了大量排查时间。这次拆解的完整工程源码已经打包代码注释比文章里更全GobangView和GameLogic两个核心类可以直接拿来做底子改造想省去逐文件手敲时间的话直接用这份工程对照修改最省事。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。