简介一份基于安卓开发环境的五子棋小游戏完整项目面向安卓课程设计、期末大作业与入门学习者。项目不仅包含核心游戏代码还配有后台数据模块和详细使用说明代码注释清晰逻辑直观便于理解棋盘绘制、落子交互、胜负判定等关键流程也可在此基础扩展AI对战或联网功能。压缩包共121个文件以32个Java源码、19个XML界面布局、48张PNG图片素材为主另附可安装APK、构建脚本及依赖库整包约11.47MB结构清晰、部署简便。已有128人学习下载。下载后即可获得可运行的五子棋安卓项目配套后台数据管理设计与使用文档能帮助快速完成课程设计或期末大作业是获取高分的实用参考。1. 一个 15×15 棋盘背后先把胜负判定写对期末前两周你从网上下载了一个安卓五子棋高分项目打开 Android Studio 却被一串 build error 拦在门口。点进去看类名没少、布局文件也在真正出问题的是 Gradle 版本和两份古董 jar 包在打架。这个项目本身不复杂15×15 棋盘、双人对弈、五连即胜核心就三件事——把胜负判定写干净、把落子响应画流畅、把构建配置调稳定外加一点简单的 AI 评分就能在答辩时多讲十分钟。下面按我拆旧课设代码的习惯从棋盘数据结构、自定义 View 绘制、SDK 兼容排错到拿高分功能完整过一遍适合准备安卓大作业、也适合第一次接手别人安卓工程时不知道该先看哪几个文件的读者。2. 棋盘数据模型与五子连珠判定方向向量扫描法2.1 为什么用二维数组而不是图结构五子棋棋盘固定是 15×15所以最稳的数据结构就是一个int[][]。用二维数组的好处是落子、查棋都只需要一次数组寻址时间复杂度 O(1)写起来也不容易绕弯。有人喜欢用ListPoint存落子记录来省空间但课程设计的棋盘总共只有 225 个格子空间优化没有任何必要反而让胜负判定时要反向推导棋盘状态代码复杂度直线上升。我在这个项目里保留了一个moveCount字段和一个snapshot()方法。前者用来判断平局225 手结束就是和棋后者返回棋盘的深拷贝给后面做复盘和序列化保存用。注意snapshot()里用的是System.arraycopy而不是双重 for 循环对 225 个格子差别不大但数组拷贝写习惯了以后接手别的项目也不会踩浅拷贝的坑。2.2 落子与胜负判定四个方向向量胜负判定的核心逻辑不是遍历整个棋盘数棋子而是以最后一手棋为原点向四个方向双向展开统计连续同色棋子数。四个方向分别是水平、垂直、两条对角线每个方向定义一组增量即可。反向不需要额外定义取负就是相反方向。public class Board { public static final int SIZE 15; // 标准五子棋棋盘 15 路 public static final int EMPTY 0; // 空格 public static final int BLACK 1; // 黑子 public static final int WHITE 2; // 白子 // 四个正向方向水平、垂直、主对角线、副对角线 private static final int[][] DIRECTIONS { {0, 1}, // 行不变列递增 {1, 0}, // 列不变行递增 {1, 1}, // 右下 {1, -1} // 左下 }; private final int[][] grid new int[SIZE][SIZE]; private int moveCount; public boolean put(int row, int col, int player) { if (row 0 || row SIZE || col 0 || col SIZE) { return false; // 越界直接拒绝 } if (grid[row][col] ! EMPTY) { return false; // 已有棋子落子无效 } grid[row][col] player; moveCount; return true; } public boolean checkWin(int row, int col, int player) { for (int[] d : DIRECTIONS) { int count 1; // 当前落子本身就占一个 count countFrom(row, col, d[0], d[1], player); count countFrom(row, col, -d[0], -d[1], player); if (count 5) { return true; } } return false; } private int countFrom(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 SIZE c 0 c SIZE grid[r][c] player) { count; r dr; c dc; } return count; } public int get(int row, int col) { return grid[row][col]; } public int getMoveCount() { return moveCount; } public int[][] snapshot() { int[][] copy new int[SIZE][SIZE]; for (int i 0; i SIZE; i) { System.arraycopy(grid[i], 0, copy[i], 0, SIZE); } return copy; } }这段代码里DIRECTIONS只定义了四个正方向反向通过-d[0]和-d[1]得到。countFrom内部用 while 循环沿一个方向走到边界或者遇到不同色的棋子循环条件里先判断行列下标越界再访问数组顺序不能反否则会抛ArrayIndexOutOfBoundsException。checkWin中count 5意味着长连也算赢这是无禁手规则下的标准做法和正式比赛禁手规则不同文档里要写清楚。2.3 一个能用的低配 AI评分表选点课程设计里加了人机对战功能是加分项但不需要实现蒙特卡洛或者深度学习那么重的东西。常见做法是评分表法对每个空位分别评估放黑子时对黑棋的价值和放白子时对白棋的价值两者加权求和就是该点的总得分AI 落在得分最高的位置。public class GomokuAI { private static final int WIN_SCORE 100000; // 成五 private static final int SCORE_OPEN_FOUR 50000; // 活四 private static final int SCORE_OPEN_THREE 10000; // 活三 private static final int SCORE_SLEEP_THREE 1000; // 眠三 private static final int SCORE_OPEN_TWO 100; // 活二 public int[] bestMove(Board board, int aiPlayer) { int[] bestPoint {-1, -1}; int bestScore Integer.MIN_VALUE; for (int row 0; row Board.SIZE; row) { for (int col 0; col Board.SIZE; col) { if (board.get(row, col) ! Board.EMPTY) { continue; } // 只评估周围 3x3 内有棋子的空位减少无效计算 if (!hasNeighbor(board, row, col)) { continue; } int attack evaluatePoint(board, row, col, aiPlayer); int defend evaluatePoint(board, row, col, opponentOf(aiPlayer)); int score attack (int) (defend * 0.9); if (score bestScore) { bestScore score; bestPoint new int[]{row, col}; } } } return bestPoint; } private int evaluatePoint(Board board, int row, int col, int player) { int total 0; for (int[] d : DIRECTIONS) { int count 1; // 假设该点已经落下 player 的棋子 int openEnds 0; int r row d[0]; int c col d[1]; while (inBoard(r, c) board.get(r, c) player) { count; r d[0]; c d[1]; } if (inBoard(r, c) board.get(r, c) Board.EMPTY) { openEnds; } r row - d[0]; c col - d[1]; while (inBoard(r, c) board.get(r, c) player) { count; r - d[0]; c - d[1]; } if (inBoard(r, c) board.get(r, c) Board.EMPTY) { openEnds; } total scoreFor(count, openEnds); } return total; } private int scoreFor(int count, int openEnds) { if (count 5) { return WIN_SCORE * 2; } if (openEnds 0) { return 0; } if (count 4) { return openEnds 2 ? SCORE_OPEN_FOUR : SCORE_SLEEP_THREE * 2; } if (count 3) { return openEnds 2 ? SCORE_OPEN_THREE : SCORE_SLEEP_THREE; } if (count 2) { return openEnds 2 ? SCORE_OPEN_TWO : 20; } if (count 1) { return 5; } return 0; } private boolean hasNeighbor(Board board, int row, int col) { for (int r row - 1; r row 1; r) { for (int c col - 1; c col 1; c) { if (inBoard(r, c) board.get(r, c) ! Board.EMPTY) { return true; } } } return false; } private boolean inBoard(int r, int c) { return r 0 r Board.SIZE c 0 c Board.SIZE; } private int opponentOf(int player) { return player Board.BLACK ? Board.WHITE : Board.BLACK; } }这个 AI 的调参逻辑可以总结成一张表答辩时把这张表贴出来比贴代码更有说服力。防守分数乘以 0.9 是让 AI 在有风险时优先挡对手但进攻机会非常明确时仍然会直接落子成五。连子数开放端数量局面描述分数档位 5任意已成五连20000042活四下一手必胜5000041冲四对方必须堵200032活三再一手成活四1000031眠三威胁有限100022活二远期棋形1003. 自定义 View 绘制棋盘与触摸落子UI 层如何和逻辑层解耦3.1 用自定义 View 而不是 ImageView 加 Bitmap这个项目的界面表现力直接决定课程设计的评分印象。很多学生喜欢把棋盘做成一张静态图片再用ImageView点击坐标换算但这样棋子只能画在一层Bitmap上每次落子都要重新创建 Bitmap占内存且容易闪烁。更好的做法是写一个GomokuView继承自View在onDraw里用 Canvas 画线、画棋子。棋盘是静态元素棋子是动态元素两者在同一个onDraw里按顺序绘制落子后调用invalidate()触发重绘即可。这样 UI 层只负责「把 Board 状态画出来」不直接修改游戏数据。3.2 onMeasure 和 onSizeChanged 里的棋盘几何计算自定义 View 第一个要处理的是尺寸。五子棋棋盘必须是正方形所以重写onMeasure拿到父容器给的两个 MeasureSpec 后取较小值。onSizeChanged是比onDraw更早回调且只回调一次的时机适合在这里预先算好cellSize和boardMargin避免每帧重复计算。public class GomokuView extends View { private final Board board; 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 final Paint lastPaint new Paint(Paint.ANTI_ALIAS_FLAG); private float boardMargin; // 棋盘外边框到 View 边缘的距离 private float cellSize; // 相邻两格线之间的间距 public GomokuView(Context context, AttributeSet attrs) { super(context, attrs); linePaint.setColor(0xFF8B5A2B); linePaint.setStrokeWidth(2f); blackPaint.setColor(0xFF000000); whitePaint.setColor(0xFFFFFFFF); lastPaint.setStyle(Paint.Style.STROKE); lastPaint.setStrokeWidth(3f); lastPaint.setColor(0xFFFF0000); } Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int width MeasureSpec.getSize(widthMeasureSpec); int height MeasureSpec.getSize(heightMeasureSpec); int size Math.min(width, height); setMeasuredDimension(size, size); } Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { boardMargin w * 0.06f; cellSize (w - boardMargin * 2f) / (Board.SIZE - 1); } }棋盘参数里三个关键值在后续绘制和触摸换算中都要用到boardMargin控制留白按宽度 6% 取比较接近真实棋盘的观感cellSize用(w - 2 * margin) / 14计算因为 15 条线之间有 14 个间隔棋子半径取cellSize * 0.42f略小于半格视觉上棋子之间不会粘连。3.3 onDraw 绘制棋盘、星位和棋子棋盘绘制分三步先画 15×15 的横竖线再画五个星位小点最后遍历Board画已落下的所有棋子。最后一步只处理非空格子不要每帧把 225 个格子全部走一遍还执行无意义的绘制操作虽然 225 次循环对现代手机来说不算什么但养成「先查状态再绘制」的习惯在复杂界面上很有用。Override protected void onDraw(Canvas canvas) { drawBoardLines(canvas); drawStarPoints(canvas); drawPieces(canvas); } private void drawBoardLines(Canvas canvas) { float start boardMargin; float end boardMargin cellSize * (Board.SIZE - 1); for (int i 0; i Board.SIZE; i) { canvas.drawLine(start, boardMargin i * cellSize, end, boardMargin i * cellSize, linePaint); canvas.drawLine(boardMargin i * cellSize, start, boardMargin i * cellSize, end, linePaint); } } private void drawStarPoints(Canvas canvas) { int[] starCols {3, 3, 7, 11, 11}; int[] starRows {3, 11, 7, 3, 11}; float radius cellSize * 0.1f; for (int i 0; i starCols.length; i) { float cx boardMargin starCols[i] * cellSize; float cy boardMargin starRows[i] * cellSize; canvas.drawCircle(cx, cy, radius, blackPaint); } } private void drawPieces(Canvas canvas) { for (int row 0; row Board.SIZE; row) { for (int col 0; col Board.SIZE; col) { int player board.get(row, col); if (player Board.EMPTY) { continue; } float cx boardMargin col * cellSize; float cy boardMargin row * cellSize; Paint paint player Board.BLACK ? blackPaint : whitePaint; canvas.drawCircle(cx, cy, cellSize * 0.42f, paint); if (player Board.BLACK) { // 黑子加高光提升棋子立体感 canvas.drawCircle(cx - cellSize * 0.13f, cy - cellSize * 0.13f, cellSize * 0.06f, whitePaint); } } } }星位坐标这里直接用数组写死比在循环里用取模计算更直观。黑子高光是评分中「界面美观」的常见细节成本只有一次drawCircle但视觉上比纯色圆明显精致。注意白子不需要画高光白棋本身偏亮加了高光反而看不清轮廓。3.4 触摸事件中的坐标换算与回调触摸落子的核心是把event.getX()和event.getY()从像素坐标换算成棋盘行列索引。换算公式是(像素坐标 - boardMargin) / cellSize后四舍五入四舍五入能保证点在两条线之间时落到最近的那条网格线上。public class GomokuView extends View { public interface GameCallback { void onMove(int row, int col); void onWin(int player); } private GameCallback callback; Override public boolean onTouchEvent(MotionEvent event) { if (event.getAction() ! MotionEvent.ACTION_DOWN) { return true; } float x event.getX(); float y event.getY(); int col Math.round((x - boardMargin) / cellSize); int row Math.round((y - boardMargin) / cellSize); // 触摸点落在棋盘外时直接忽略不把非法坐标传给逻辑层 if (row 0 || row Board.SIZE || col 0 || col Board.SIZE) { return true; } if (callback ! null) { callback.onMove(row, col); } return true; } public void setCallback(GameCallback callback) { this.callback callback; } }在 Activity 里实现GameCallback接口先调Board.put判合法性合法再checkWin判胜负最后调invalidate()刷新。把「像素坐标转行列」这件事放在 View 里做把「这次落子是否合法、是否获胜」放在 Activity 或单独的管理类里做这样不会把游戏规则和界面绘制耦合死后面也方便把GomokuView换成 Compose 或者其他控件。4. 拿到项目先调通构建Gradle、AGP 和第三方 SDK 的兼容排错4.1 从 build.gradle 和 gradle-wrapper.properties 判断项目年份很多下载的课程设计项目是两三年前建的工程里除了游戏代码还留着open_sdk_3.5.12.2_r97423a8_lite.jar和mta-sdk-2.0.0.jar这样的老第三方库。打开项目第一件事不是看代码而是看三个文件gradle/wrapper/gradle-wrapper.properties里的distributionUrl、根目录build.gradle里声明的 AGP 版本、app/build.gradle里的compileSdk和targetSdk。这三个值决定了整个项目是否能在当前版本的 Android Studio 里打开。# 查看 wrapper 对应的 Gradle 版本 grep distributionUrl gradle/wrapper/gradle-wrapper.properties # 查看项目使用哪个版本的 Android Gradle Plugin grep -A 3 classpath build.gradle # 尝试编译并输出完整错误栈 ./gradlew :app:assembleDebug --stacktracedistributionUrl里写的是gradle-6.7.1-bin.zip这类字符串6.x 和 8.x 之间差了不止一个版本。直接拿 Gradle 8 去跑 AGP 4.2 的项目会报Minimum supported Gradle version is 8.7或者相反的错误。这时候不要盲目把distributionUrl改成最新版因为 AGP 版本是约束 Gradle 版本的上限最好是「AGP 升到与你 Android Studio 匹配的版本Gradle 跟着 AGP 走」。4.2 AGP、Gradle、JDK 版本对照与热修复我处理旧项目时最常用的一组版本对照如下表。这组数据不是官方全部支持矩阵但覆盖了课程设计项目最常遇到的场景。Gradle 版本最低 AGP 版本建议 JDK 版本适用 Android Studio6.7.14.2.2JDK 11Arctic Fox 2020.3.17.47.3JDK 17Electric Eel 2022.18.98.7JDK 17Koala 2024.1如果是新安装的 Android Studio 打开这个五子棋老项目直接改版本还不够compileSdk太低会提示SDK component platforms;android-XX is not installed。我一般一股脑升到compileSdk 34。如果编译报Namespace is not specified那说明 AGP 8 之后强制要求namespace在app/build.gradle里补一行即可android { namespace com.example.gomoku compileSdk 34 defaultConfig { applicationId com.example.gomoku minSdk 21 targetSdk 34 } }namespace在 AGP 7.3 及以前不是必填项8.0 开始变成必填而且旧项目原来写在AndroidManifest.xml里的package属性会被忽略。顺手看一眼settings.gradle如果里面没有pluginManagement块建议把仓库地址从jcenter()迁到mavenCentral()和google()jcenter 已经停止更新部分依赖在旧地址上拉不下来会直接构建失败。4.3 老 SDK jar 引发的类冲突和 native 库加载问题这个工程里的mta-sdk-2.0.0.jar是移动统计 SDKopen_sdk类 jar 通常用于第三方分享和登录它们共同依赖同一套基础类在 Gradle 8 下编译时容易报Duplicate class。如果项目中实际没用到分享和统计功能最省事的做法是从libs目录删掉其中一份 jar或者在依赖声明里排除冲突传递依赖dependencies { implementation files(libs/open_sdk_3.5.12.2_r97423a8_lite.jar) implementation(files(libs/mta-sdk-2.0.0.jar)) { exclude group: com.qq.taf } }另一种常见报错长得像dlopen failed: ... has unexpected tag number 30发生在运行到某个接入 native 库的模块时。这个报错不是项目五子棋逻辑引起的而是老.so文件的 ELF 元数据标记无法被新版 NDK 工具链识别。处理思路是缩小abiFilters并固定ndkVersionandroid { ndkVersion 21.4.7075529 defaultConfig { ndk { abiFilters arm64-v8a, armeabi-v7a } } }abiFilters只保留arm64-v8a和armeabi-v7a两个主流架构x86模拟器镜像如果遇到加载问题直接换 ARM 镜像的虚拟设备即可。ndkVersion固定到 21.x 是因为新装 Android Studio 默认带的是 r26 以后的 NDK老 so 在 r26 的llvm-readelf下可能直接报 tag 超限。实际项目中如果确认没有原生代码去掉ndk配置并且不用 so 库反而是最干净的方案。4.4 国内镜像与首次构建提速旧项目首次构建下载 Gradle 和依赖通常要几分钟到十几分钟卡在gradle-wrapper.jar下载的情况也不少见。如果公司或学校网络访问官方仓库慢在用户目录~/.gradle/init.gradle写入镜像仓库配置让所有项目解析依赖时先走国内镜像可大幅减少失败概率allprojects { repositories { maven { url https://maven.aliyun.com/repository/public } maven { url https://maven.aliyun.com/repository/google } maven { url https://maven.aliyun.com/repository/gradle-plugin } } }注意init.gradle是全局生效的配置好后新开任意 Android 项目都会走镜像。如果只想对当前项目生效也可以把仓库配置写进项目根目录build.gradle的buildscript.repositories和allprojects.repositories里不推荐这种做法因为会让工程带上一份自己机器的私有配置传到 GitHub 后别人构建反而多一层不确定性。5. 拿高分的关键悔棋、复盘和棋局持久化5.1 悔棋用动作栈而不是直接清空棋盘高分项目和普通项目的最大差异不在主界面而在交互细节。悔棋是最常见的加分功能。核心是用一个ArrayDequeMove记录每一步的落子位置和玩家悔棋时弹出栈顶把对应格子复位为空。注意悔棋后要同时更新moveCount并调用invalidate()否则界面和Board状态不一致。public class Move { public final int row; public final int col; public final int player; public Move(int row, int col, int player) { this.row row; this.col col; this.player player; } } public void undo(Board board) { Move last history.pollLast(); if (last null) { return; } board.undoMove(last.row, last.col); }Board里补一个undoMove方法内部只做两件事把grid[row][col]置为EMPTYmoveCount--。5.2 棋局持久化直接用 org.json 序列化历史记录不需要引入 GsonAndroid 自带org.json包足够保存棋局。把history队列序列化成 JSON 数组字符串写入SharedPreferences下次打开 App 时询问玩家是否继续上一局。public void save(Context context) { JSONArray array new JSONArray(); for (Move move : history) { JSONObject obj new JSONObject(); obj.put(row, move.row); obj.put(col, move.col); obj.put(player, move.player); array.put(obj); } context.getSharedPreferences(gomoku, Context.MODE_PRIVATE) .edit() .putString(history, array.toString()) .apply(); }SharedPreferences里只存落子序列不存整个二维数组这样恢复时按顺序重放每一步即可逻辑上完全等价。序列化字段名建议固定为row、col、player如果后续要加复盘动画这个结构也能直接作为时间轴数据源。5.3 提交前用边界用例校准胜负判定测试胜负判定不要只看「赢了没赢」要覆盖边界情况。下面这组用例是我每次都会手动验证的用例落子序列期望结果斜向获胜(7,7)黑(8,8)黑(9,9)黑(10,10)黑(11,11)黑黑胜副对角线获胜(7,11)白(8,10)白(9,9)白(10,8)白(11,7)白白胜五连之后继续落子黑先五连黑再落一子游戏已结束落子被拦截长连边界黑(7,3)到(7,7)共五子再补(7,8)第六子不允许落下最后一个用例要特别注意如果按无禁手规则长连在一步内一次性出现时才判赢如果玩家已经五连获胜游戏状态机应该在checkWin返回 true 后把棋盘设为终局后续put直接返回 false。我的处理方式是 Activity 里维护一个gameOver布尔值胜利或者平局后置 true且不再把触摸事件转发给Board.put。配合这组边界用例基本能把最容易被答辩老师追问的输赢逻辑覆盖完整。如果想进一步扩展把Board.SIZE改成 19这套判定和绘制代码可以直接变成围棋棋盘的状态管理矩形方向向量扫描对 19 路棋盘同样适用。本文还有配套的精品资源点击获取