Android Studio五子棋开发实战:自定义View与AI对战详解

发布时间:2026/10/6 5:36:51
Android Studio五子棋开发实战:自定义View与AI对战详解 简介面向计科专业学生与安卓初学者的五子棋小游戏完整项目基于Android Studio 4.0.1开发可适配近年官方版本覆盖双人对弈与单机挑战AI两种模式涵盖棋盘绘制、落子判定、得分记录与悔棋重开等核心环节能够帮助理解安卓界面绘制、事件响应与项目工程组织方式。资源包共657个文件压缩后约30.71MB包含Java源码、XML布局、Gradle工程配置、APK成品及图片音频等资源属于国内课设常见的标准项目结构。目前已有2092人学习下载。项目在人机对战中通过棋盘各点得分进行AI决策实时记录比分人人对战轮流落子并跟踪棋子颜色同时支持开始新游戏、悔棋及主界面背景音乐开关游戏状态管理较为完整。项目内置可直接安装体验的APK成品与完整工程源码便于对照学习自定义棋盘绘制、游戏循环、音频控制等实现思路适合作为安卓课程设计、毕业设计或移动开发练手项目。1. 用 Android Studio 实现五子棋小游戏为什么计科生都绕不开它用 Android Studio 实现五子棋小游戏是很多计科学生第一次把“会写代码”变成“做成一个 App”的转折点。这个项目看着不大但它把安卓程序设计里最常见的三件事全占了自定义 View 绘图、触摸事件的坐标换算、一局游戏的状态管理。做完它你会发现自己突然能看懂市面上大部分“小游戏”App 的套路了——无非是画界面、接收输入、维护状态这三件事的组合。更现实的是五子棋是课程设计和面试作品里的常客。它不需要服务端不需要第三方 SDK一个 Activity 加一个自定义 View 就能跑起来你能完整讲清楚每行代码为什么存在。这篇笔记我会把棋盘绘制、落子判断、胜负检测、悔棋、简单 AI 和几个必踩的坑一次讲完每一步都给你能直接抄的 Java 代码。新手照着能跑通熟手可以直接跳到避坑章节看边界条件。2. 棋盘绘制与落子交互用自定义 View 解决“画”和“点”两件事2.1 项目结构一个 Activity 怎么把游戏串起来我一般不会一上来就分层分出一堆包。五子棋这种规模的安卓程序设计最稳的结构是MainActivity 负责界面容器ChessView 继承 View 负责绘制和触摸GameLogic 类单独管棋盘数组、当前执棋方、悔棋栈和胜负判断。三层各管一件事后面加人机对战、加动画都不用伤筋动骨。在 Android Studio 里新建项目时模板会自带一个 MainActivity 和布局文件。这个项目最省心的依赖配置是只在 app 模块的 build.gradle 里保留 AppCompat其他什么 RecyclerView、Room 都不要加。依赖越少gradle 同步和打包出错面越小。常见做法是dependencies { implementation androidx.appcompat:appcompat:1.6.1 }这里特意不引入 Material 组件库因为五子棋界面需要的控件极少一个 TextView 显示当前轮到谁一个 Button 悔棋一个 Button 重开就够。Activity 布局里放一个 ChessView占屏幕宽度的大半下面一行控制按钮这是多数课程设计验收时最稳妥的界面方案。2.2 在 Canvas 上画棋盘网格、坐标和棋子尺寸棋盘的核心是坐标换算。我们约定 15×15 的格子黑子先手用 1 表示白子后手用 2 表示0 表示空。要保证棋盘在任何屏幕上都完整显示不能写死格子像素值而是在 View 的 onSizeChanged 里根据实际宽度算格子边长。第一步是让棋盘 View 保持正方形。我习惯重写 onMeasure取宽高的较小值作为边长Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int size Math.min( MeasureSpec.getSize(widthMeasureSpec), MeasureSpec.getSize(heightMeasureSpec)); setMeasuredDimension(size, size); }参数说明MeasureSpec.getSize 拿到的是父布局给出的可用尺寸取最小值后 setMeasuredDimension保证不管屏幕横竖棋盘都不会被拉伸成椭圆。注意如果你在 XML 里给 ChessView 设置了固定高度这里也要保证高度权重足够否则可能测量结果偏小。接下来在 onSizeChanged 里算好格子参数Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); mCellSize w * 1f / (mBoardSize 1); mBoardPadding mCellSize; }这里 mBoardSize 是 15mCellSize 是每个格子的边长mBoardPadding 是棋盘最外圈线到 View 边缘的距离。把 padding 也设成一个格子边长是为了让第 0 列棋子和第 14 列棋子都不贴边视觉上更接近真实棋盘。绘制部分的核心代码Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); if (mCellSize 0f) return; // 画 15 条横线和 15 条竖线 for (int i 0; i mBoardSize; i) { float offset mBoardPadding i * mCellSize; canvas.drawLine(mBoardPadding, offset, mBoardPadding (mBoardSize - 1) * mCellSize, offset, mGridPaint); canvas.drawLine(offset, mBoardPadding, offset, mBoardPadding (mBoardSize - 1) * mCellSize, mGridPaint); } // 画已经落下的黑白棋子 for (int r 0; r mBoardSize; r) { for (int c 0; c mBoardSize; c) { int player mLogic.getAt(r, c); if (player 0) continue; float cx mBoardPadding c * mCellSize; float cy mBoardPadding r * mCellSize; canvas.drawCircle(cx, cy, mCellSize * 0.42f, player 1 ? mBlackPaint : mWhitePaint); } } }逻辑说明画网格时横线的 y 坐标是 offsetx 从棋盘左边缘 mBoardPadding 到右边缘mBoardPadding (mBoardSize - 1) * mCellSize。注意端点不是 View 宽度而是棋盘第一根线和最后一根线的位置这样 15 根线刚好形成 14 个格子间隔。棋子半径取mCellSize * 0.42f而不是 0.5因为相邻两颗棋子之间要留出格子线空隙半径超过一半就会重叠。很多教程会把画笔和棋子封装进同一个 onDraw 里一次次创建 Paint那样性能很差。我一般把 mGridPaint、mBlackPaint、mWhitePaint 都设为成员变量在构造方法里初始化一次。onDraw 每帧都可能执行里面的对象创建越少越好。2.3 处理触摸落子点按区域的网格吸附触摸处理的难点不是拿到坐标而是把手指点的像素位置换算成“第几行第几列”。换算公式很简单用点击坐标减去棋盘偏移量再除以格子边长最后四舍五入。Override public boolean onTouchEvent(MotionEvent event) { if (mLogic.isGameOver()) return true; if (event.getActionMasked() MotionEvent.ACTION_DOWN) { int col Math.round((event.getX() - mBoardPadding) / mCellSize); int row Math.round((event.getY() - mBoardPadding) / mCellSize); if (row 0 row mBoardSize col 0 col mBoardSize) { if (mLogic.place(row, col)) { invalidate(); } } } return true; }参数说明getActionMasked 是为了把多指触控这种边界情况忽略掉只用单指按下事件触发落子。p 为什么不处理 ACTION_UP因为如果用抬起事件用户手指滑动半格再抬起落点会在奇怪的位置按下就落子更符合“点到哪下到哪”的直觉。这也是很多新手翻车的地方后面避坑章节会展开。Math.round 在这里是网格吸附的关键。假设你点在第 3 列和第 4 列中间稍微偏右的位置除以格子边长得到 3.6四舍五入到 4棋子会落在第 4 列。用 float 除法的原因是如果直接用 int 除法3.6 会被截断成 3落点永远便宜一个格这就是网上常见“棋子偏了半个格子”问题的根源。3. 胜负判断与悔棋逻辑把规则和状态分给两个类3.1 五子棋的胜负判定四个方向连子扫描胜负判断是这类棋类游戏最容易写错的部分。不要试图一次写八个方向的循环而是固定四个方向数组向右、向下、右下、左下。每个方向朝两个端点延伸把两次延伸的连子数加起来再加当前这颗子自身就是该方向的连续棋子数。public class WinChecker { private static final int SIZE 15; private int[][] board; // 四个方向右、下、右下、左下 private int[][] dirs {{0, 1}, {1, 0}, {1, 1}, {1, -1}}; public int check(int row, int col) { int player board[row][col]; if (player 0) return 0; for (int[] d : dirs) { int count 1; count countLine(row, col, d[0], d[1], player); count countLine(row, col, -d[0], -d[1], player); if (count 5) return player; } return 0; } private int countLine(int r, int c, int dr, int dc, int player) { int cnt 0; int nr r dr, nc c dc; while (nr 0 nr SIZE nc 0 nc SIZE board[nr][nc] player) { cnt; nr dr; nc dc; } return cnt; } }逻辑说明每次下子后不需要扫描整个棋盘只需要从新落子的位置往四个方向延伸检查。比如竖直方向往上数同色棋子往下数同色棋子如果总数达到 5直接返回当前玩家编号。这样做的时间复杂度接近常数棋盘再大也扛得住。注意 countLine 里每走一步都要做边界检查。新手最容易漏掉的是副对角线方向{1, -1}这个方向列坐标是递减的如果只想着列坐标加一左下角方向永远数不对。我建议你在纸上画一个 5×5 的小棋盘把四个方向都写一遍再抄代码。3.2 悔棋与重新开始用栈回溯棋局状态悔棋是“五子棋可玩性”的底线没有悔棋按钮的话新手误触一步就得重开整局。GameLogic 里用 ArrayList 模拟栈按顺序记录每一步的坐标。落子时 push 一步悔棋时 pop 最后一步并把棋盘对应位置清空。public boolean place(int row, int col) { if (winner ! 0 || board[row][col] ! 0) return false; board[row][col] currentPlayer; moves.add(new int[]{row, col}); winner checkWinner(row, col); if (winner 0) { currentPlayer (currentPlayer 1) ? 2 : 1; } return true; } public boolean undo() { if (moves.isEmpty()) return false; int[] last moves.remove(moves.size() - 1); board[last[0]][last[1]] 0; currentPlayer (currentPlayer 1) ? 2 : 1; winner 0; return true; }参数说明currentPlayer 用 1 和 2 表示黑子和白子悔棋后要换回上一步的执棋方所以(currentPlayer 1) ? 2 : 1这个三元表达式就是“黑白互换”。这里有个特别容易踩的细节如果一局已经结束place 会拒绝任何落子但 undo 必须无视胜负状态允许玩家悔棋之后继续下。winner 字段也需要在 undo 时重置为 0。不然你刚赢了之后悔棋GameLogic 仍认为游戏结束棋盘上就再也没法落子。这个问题在避坑章节里还会提到。3.3 平局判定下到 225 个格子15×15 棋盘总共 225 个格子没人连成五子但棋盘满了就是平局。很多人把这个边界漏了导致棋盘下满后既不能落子也不能正常结束只能杀进程。GameLogic 里加一个判断public boolean isDraw() { return !isGameOver() moves.size() SIZE * SIZE; }这里的 SIZE 是 15moves.size() 225时说明棋盘已满。这个判断要在每次 place 成功后检查一次并在 Activity 里提示“平局”。我见过不少项目把胜负判断和平局判断放在同一个方法里返回一个 int但我更推荐拆成两个方法因为有助你后面加 AI 时单独判断“还能不能下”。4. 让 AI 跟你下一盘一个不靠复杂算法的评分对抗4.1 先做一个会“追堵”的 AI逐点评分法让电脑会下棋不用上深度学习。常见做法是“落子评分”遍历棋盘每个空点临时把 AI 的棋子放上去数这个点在四个方向上能形成多少连子再临时把对手棋子放上去数一遍两个分数加权后取最高。分数越高的位置越值得下。public int[] findBestMove(int aiPlayer, int humanPlayer) { int bestScore -1; int[] best null; for (int r 0; r SIZE; r) { for (int c 0; c SIZE; c) { if (board[r][c] ! 0) continue; int score evalPoint(r, c, aiPlayer, humanPlayer); if (score bestScore) { bestScore score; best new int[]{r, c}; } } } return best; } private int evalPoint(int r, int c, int aiPlayer, int humanPlayer) { board[r][c] aiPlayer; int attack lineScore(r, c, aiPlayer); board[r][c] humanPlayer; int defend lineScore(r, c, humanPlayer); board[r][c] 0; return Math.max(attack, (int) (defend * 0.9f)); }逻辑说明evalPoint 先把当前空点临时改成 AI 的棋子计算进攻分数再把同一个位置临时改成人类棋子计算防守分数计算完马上恢复为 0。为什么防守分要乘 0.9因为直接取最大值的话AI 会一直在防守几乎没有主动进攻意识乘一个略小于 1 的系数能让 AI 在“自己能赢”和“对手要赢”两种情况下优先选择赢棋同时保持防守能力。这个系数算是一个很实用的调参口。4.2 连子打分表让 AI 分得清“快赢了”和“还早”lineScore 的逻辑是统计该点在四个方向上形成的连子长度再给不同长度分配不同分值。连子越长分数增长是跳变的而不是线性增加。private int lineScore(int r, int c, int player) { int total 0; for (int[] d : dirs) { int cnt countLine(r, c, d[0], d[1], player) countLine(r, c, -d[0], -d[1], player) 1; if (cnt 5) total 10000; else if (cnt 4) total 1000; else if (cnt 3) total 300; else total cnt * 10; } return total; }这里有张参数表是让 AI “聪明”起来的关键当前点形成的连子数得分代表的棋形5 及以上10000已经赢棋必下41000冲四下一步有机会成五3300活三需要持续威胁1 或 2连子数 × 10布局阶段按线性给分为什么连五直接给 10000因为只要评估到这一步AI 选这个点就赢了分数必须大到压过其他所有防守分。这个评分模型非常粗糙它分不出“活三”和“眠三”遇到双三、双四这种复杂棋形会很弱但作为课程设计和入门人机对战已经足够让玩家觉得“电脑会堵我”。4.3 把 AI 放进五子棋流程注意主线程卡顿AI 计算虽然只有 225 次评分但每次评分要扫描棋盘在低端模拟器上也能感觉到轻微停顿。加 AI 时不能直接在 onTouchEvent 里同步调用 findBestMove否则每次玩家落子都会卡一下。我习惯这样处理玩家落子后用一个 Handler 延迟 300 毫秒再让 AI 走棋既模拟“思考时间”又避免在 UI 线程里做大量循环。private void triggerAiMove() { if (!mLogic.isGameOver() mLogic.isAiTurn()) { handler.postDelayed(() - { int[] move mLogic.findBestMove(2, 1); if (move ! null) { mLogic.place(move[0], move[1]); invalidate(); } }, 300); } }这里 mLogic.isAiTurn 是判断当前是否轮到 AI你在 GameLogic 里维护一个玩家类型字段就行。Handler 的延迟让界面有时间刷新玩家刚落的子AI 的分数计算也不会把触摸事件堵住。如果你以后想把这个 AI 换成搜索树只要保证计算不在主线程这部分结构不用改动。5. 避坑备忘五子棋项目最常见的四个坑5.1 一次点击落下多枚棋子棋盘出现“黑白叠子”现象在棋盘上点一下有时会落下两颗甚至三颗棋子黑白交替出现看起来像闹鬼。原因onTouchEvent 里同时处理了 ACTION_DOWN 和 ACTION_UP一次点击会触发两次落子逻辑。更隐蔽的情况是GameLogic 的 place 方法没有做“该位置是否已落子”的判断第二次落子写进了同一个格子导致覆盖或连下。解决第一触摸事件只保留 ACTION_DOWN 分支不要理 ACTION_UP。第二place 方法第一行必须判断board[row][col] ! 0直接返回 false这是最后一道防线。两处都堵住之后无论触摸怎么抖动都不会出现多重落子。5.2 屏幕旋转后棋盘清空悔棋栈也没了现象模拟器上默认开启屏幕旋转转一下手机刚下到一半的棋局全部消失回到开局状态。原因旋转屏幕会销毁并重建 ActivityGameLogic 是 Activity 内部的普通对象随 Activity 一起被回收。这是安卓生命周期的基础机制不知道这个坑的人会以为代码写错了。解决让 ChessView 自己实现状态保存。安卓的 View 有 onSaveInstanceState 机制但前提是 View 有 id。在 Activity 里给 ChessView set id然后重写 onSaveInstanceStateOverride protected Parcelable onSaveInstanceState() { Bundle state new Bundle(); state.putSerializable(moves, mLogic.getMoves()); state.putInt(currentPlayer, mLogic.getCurrentPlayer()); state.putParcelable(parentState, super.onSaveInstanceState()); return state; } Override protected void onRestoreInstanceState(Parcelable state) { Bundle bundle (Bundle) state; mLogic.restore((ArrayListint[]) bundle.getSerializable(moves), bundle.getInt(currentPlayer)); super.onRestoreInstanceState(bundle.getParcelable(parentState)); invalidate(); }GameLogic 的 restore 方法会清空棋盘、重建悔棋栈然后按顺序重放每一手棋public void restore(ArrayListint[] savedMoves, int savedCurrentPlayer) { board new int[SIZE][SIZE]; moves.clear(); winner 0; currentPlayer savedCurrentPlayer; for (int[] m : savedMoves) { place(m[0], m[1]); } }注意 restore 里先清空再循环调用 place让 place 内部自己处理胜负判断和颜色交替。这样保存的只是 moves 列表不需要把整个棋盘序列化代码更短而且不会漏掉状态。5.3 子线程里调用 invalidate 导致闪退现象给 AI 加了耗时计算后程序运行一段时间突然崩溃日志里出现 “Cant create handler inside thread that has not called Looper.prepare”。原因把 AI 计算放到子线程后在子线程里直接调用了 ChessView 的 invalidate 方法。invalidate 只能在主线程执行子线程刷新 View 要用 postInvalidate。解决如果 AI 是用 Thread 启动的最少要改成 view.postInvalidate()或者用 runOnUiThread 把落子逻辑切回主线程。我上面的用法是 Handler.postDelayed本身已经回到主线程所以不会踩这个坑。你做异步 AI 时记住“计算可以离线画界面必须在线”这句话就能少翻车。5.4 网格越画越偏棋子对不上格子线现象棋盘左边线贴屏幕边缘右边线却超出屏幕或者棋子点下去落点总在格子线交叉点的左上角。原因onDraw 里画网格时用了canvas.drawLine(0, pos, getWidth(), pos, paint)没有考虑棋盘偏移量。棋子圆心的计算用了c * mCellSize没有加上 mBoardPadding。两个错误叠加导致棋盘整体偏到 View 左上角。解决全项目统一使用一组坐标换算公式不要在多个地方各写一套。棋盘左边缘永远是 mBoardPadding棋子圆心永远是mBoardPadding c * mCellSize。建议把换算封装成两个方法 pixelToCol(float x) 和 colToPixel(int col)触摸和绘制都调用它们这是我从这个项目里最想让你带走的好习惯。6. 进阶体验落子动画与一局结束的细节收尾6.1 用 ValueAnimator 让最后一颗棋子“弹”出来棋盘有了、规则有了、AI 也能跑了但棋子落下是瞬间出现的整体体验会很生硬。给落子加一个缩放动画成本低、效果好。我习惯在 ChessView 里维护两个字段lastDropRow 和 lastDropCol记录最近一次落子位置再用 ValueAnimator 控制一个 0 到 1 的缩放比例。private void playDropAnim(int row, int col) { lastDropRow row; lastDropCol col; if (dropAnim ! null dropAnim.isRunning()) { dropAnim.cancel(); } dropAnim ValueAnimator.ofFloat(0f, 1f); dropAnim.setDuration(200); dropAnim.setInterpolator(new OvershootInterpolator(1.2f)); dropAnim.addUpdateListener(animation - { dropScale animation.getAnimatedFraction(); invalidate(); }); dropAnim.start(); }然后在 onDraw 里画最后一颗棋子时半径乘以 dropScaleif (r lastDropRow c lastDropCol) { canvas.drawCircle(cx, cy, mCellSize * 0.42f * dropScale, paint); } else { canvas.drawCircle(cx, cy, mCellSize * 0.42f, paint); }参数说明OvershootInterpolator(1.2f) 会让棋子半径先冲过正常值再回弹看起来像真的“放”下去而不是“贴”上去。动画时长 200 毫秒足够短不会让玩家觉得等待明显。6.2 一局结束后的状态复位与真机打包验证游戏结束时不要立刻清空棋盘应该先弹提示让玩家看清楚最后一手棋在哪。我一般通过回调接口把胜负结果传给 MainActivityActivity 负责弹 Dialog 或 ToastChessView 只负责把棋盘变灰。重开按钮要做的事就三件调用 GameLogic.reset() 清空棋盘和悔棋栈重置 lastDropRow 和 lastDropCol最后 invalidate 一下。这一步做完项目就可以进 Android Studio 的 Build 菜单生成 APK装到真机上验证触摸精度和动画流畅度。模拟器上的点按坐标通常和真机存在密度差异如果你发现模拟器落点偏优先检查布局里是否对棋盘 View 设置了 padding 或 margin这两个值会改变 View 的实际绘图区域。我最早做这个项目时把动画写成了一个死循环线程每帧手动调 invalidate结果主线程卡成幻灯片。后来换成 ValueAnimator 才明白安卓动画的正确打开方式是让系统按时间驱动帧而不是自己在后台画。这个观念转变比动画本身更重要希望帮到你。本文还有配套的精品资源点击获取