ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Android Studio纯Java井字棋App课程设计:从工程结构到打包签名完整指南

Android Studio纯Java井字棋App课程设计:从工程结构到打包签名完整指南

简介:这份资源是面向高校学生与Java初学者的一套Android课程设计完整源码,基于Android Studio开发环境,用Java语言实现经典井字棋小游戏,适合作为课程大作业、期末项目或Android入门练手参考。压缩包共65个文件,约248KB,包含7个java源文件承载核心游戏逻辑,28个xml负责界面布局与资源定义,另有webp、png、jpg等图片素材及gradle构建脚本、properties配置、proguard混淆规则等工程文件,目录结构规范,可直接导入Android Studio运行调试。目前已有3035人学习下载,说明其作为教学案例具备一定参考价值。读者可从中了解Activity与布局的配合方式、棋盘状态判断与胜负检测算法、按钮点击事件处理以及项目从构建到打包的完整流程,便于快速理解Android小游戏开发思路并在此基础上二次修改,完成自己的课程设计任务。

1. 从一份课程设计说起:Android Studio 里把井字棋做成能交作业的完整 App

课程设计大作业基于 Java 的 Android Studio 实现井字棋小游戏,这个标题看着简单,真动手做的人才知道坑在哪。井字棋规则三分钟能讲完,但要在 Android Studio 里用纯 Java 写成一个能装到手机上、能点、能判胜负、能重置、还能在答辩时讲清楚结构的 App,涉及的是 Activity 生命周期、布局嵌套、状态保存、事件分发这一整套东西。很多同学卡住的不是算法,而是「棋盘怎么画」「点击怎么响应」「旋转屏幕后棋盘为什么清空了」这类工程问题。这篇笔记面向正在做课程设计、需要一份能跑通、能改、能讲明白的井字棋 App 的人,从工程结构一路写到打包签名,中间把参数、代码和踩过的坑都摊开讲。读完你应该能自己从零搭出一个可交付的版本,而不是复制一份看不懂的源码交上去。

2. 先定架构再写代码:井字棋 App 的工程结构与选型理由

2.1 为什么用纯 Java + 原生 View,而不是 Compose 或游戏引擎

课程设计场景下,选型的第一原则是「可解释、可答辩、可修改」。Jetpack Compose 写起来确实快,但它的声明式状态管理和重组机制对刚接触 Android 的人来说是个黑匣子,答辩时被问一句「状态怎么触发的重组」就容易卡壳。游戏引擎(比如某些 2D 框架)更是杀鸡用牛刀,引入一堆依赖,老师一看就知道不是自己写的。

纯 Java + 原生 View 的组合优势在于:每一行代码都能对应到 Android 的基础概念。Activity负责生命周期,LinearLayout/GridLayout负责布局,Button或自定义View负责棋盘格,OnClickListener负责交互,Bundle负责状态保存。这套东西是 Android 开发的骨架,讲清楚它本身就是课程设计想考察的能力。

我一般会推荐两种棋盘实现路线,各有取舍:

方案实现方式优点缺点适用场景
九宫格 Button3x3 的 Button 数组代码直观,点击事件现成样式受 Button 默认属性影响,改圆角/背景要写 selector时间紧、要求不高的作业
自定义 View继承 View,onDraw 画线和棋子样式完全可控,能画出棋盘线要自己处理触摸坐标换算想拿高分、要展示自定义绘制

如果只是交个能跑的作业,Button 方案足够;如果想让界面好看点、答辩有亮点,自定义 View 更值得投入。下面两条路线我都会给关键代码。

2.2 工程目录与关键文件清单

在 Android Studio 里新建一个 Empty Views Activity 项目,语言选 Java,包名按学校要求填(比如com.example.tictactoe)。核心文件就这几个,别搞太复杂:

app/src/main/java/com/example/tictactoe/ MainActivity.java // 主界面,承载棋盘和逻辑 GameLogic.java // 胜负判定与状态管理(可选,拆出来更清晰) app/src/main/res/layout/ activity_main.xml // 布局文件 app/src/main/res/values/ colors.xml // 颜色定义 strings.xml // 文案 app/src/main/AndroidManifest.xml

把游戏逻辑单独抽到GameLogic.java是个好习惯。答辩时你可以说「我把业务逻辑和 UI 分离了」,这句话本身就加分。GameLogic只负责维护一个 3x3 的二维数组、落子、判胜负、判平局、重置,不碰任何 Android API,这样它甚至能单独写单元测试。

2.3 用二维数组表示棋盘:状态模型先立住

不管 UI 怎么画,棋盘状态就用一个int[3][3]表示:0 表示空,1 表示玩家 X,2 表示玩家 O。这个模型简单到不会出错,而且判胜负就是遍历行、列、两条对角线。

public class GameLogic { public static final int EMPTY = 0; public static final int PLAYER_X = 1; public static final int PLAYER_O = 2; private final int[][] board = new int[3][3]; private int currentPlayer = PLAYER_X; private int moveCount = 0; // 落子,返回是否成功 public boolean place(int row, int col) { if (row < 0 || row > 2 || col < 0 || col > 2) return false; if (board[row][col] != EMPTY) return false; // 该格已有棋子 board[row][col] = currentPlayer; moveCount++; return true; } // 切换玩家 public void switchPlayer() { currentPlayer = (currentPlayer == PLAYER_X) ? PLAYER_O : PLAYER_X; } // 判断当前是否有玩家获胜,返回获胜方,无则返回 EMPTY public int checkWinner() { // 检查三行 for (int r = 0; r < 3; r++) { if (board[r][0] != EMPTY && board[r][0] == board[r][1] && board[r][1] == board[r][2]) { return board[r][0]; } } // 检查三列 for (int c = 0; c < 3; c++) { if (board[0][c] != EMPTY && board[0][c] == board[1][c] && board[1][c] == board[2][c]) { return board[0][c]; } } // 主对角线 if (board[0][0] != EMPTY && board[0][0] == board[1][1] && board[1][1] == board[2][2]) { return board[0][0]; } // 副对角线 if (board[0][2] != EMPTY && board[0][2] == board[1][1] && board[1][1] == board[2][0]) { return board[0][2]; } return EMPTY; } public boolean isDraw() { return moveCount == 9 && checkWinner() == EMPTY; } public int getCurrentPlayer() { return currentPlayer; } public int getCell(int row, int col) { return board[row][col]; } public void reset() { for (int r = 0; r < 3; r++) for (int c = 0; c < 3; c++) board[r][c] = EMPTY; currentPlayer = PLAYER_X; moveCount = 0; } }

这段代码的关键点:place里先做边界和占用检查再落子,避免越界和覆盖;checkWinner用「先判非空再判相等」的顺序,否则三个空格会被误判成获胜;moveCount用来判平局,比每次遍历数空格更省事。参数上没什么玄学,就是行列索引 0 到 2,玩家常量用 1 和 2 而不是 0,是为了和 EMPTY 区分开。

2.4 布局文件:用 GridLayout 搭出 3x3 棋盘

用 Button 方案的话,activity_main.xml可以这样写:

<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:gravity="center" android:padding="24dp"> <TextView android:id="@+id/tvStatus" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="轮到 X 落子" android:textSize="22sp" android:layout_marginBottom="24dp" /> <GridLayout android:id="@+id/gridBoard" android:layout_width="wrap_content" android:layout_height="wrap_content" android:columnCount="3" android:rowCount="3" /> <Button android:id="@+id/btnReset" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="重新开始" android:layout_marginTop="24dp" /> </LinearLayout>

棋盘格不在 XML 里写死九个 Button,而是在MainActivity里动态生成。这样做的好处是可以用循环绑定索引,避免九个findViewById写到手酸,也方便以后改成 NxN。

private void buildBoard() { GridLayout grid = findViewById(R.id.gridBoard); int size = (int) (getResources().getDisplayMetrics().density * 90); // 每格 90dp for (int r = 0; r < 3; r++) { for (int c = 0; c < 3; c++) { Button cell = new Button(this); GridLayout.LayoutParams params = new GridLayout.LayoutParams(); params.width = size; params.height = size; params.setMargins(4, 4, 4, 4); cell.setLayoutParams(params); cell.setTextSize(28); final int row = r, col = c; cell.setOnClickListener(v -> onCellClicked(row, col, (Button) v)); grid.addView(cell); } } }

size用density换算成像素,保证不同屏幕密度下格子大小一致,这是新手最容易忽略的一点——直接写死像素值,在高密度屏上格子会小得点不中。final int row = r, col = c是为了在 lambda 里捕获当前循环变量,Java 的闭包只能捕获 effectively final 的变量,不这样写会编译报错。

2.5 点击响应与胜负判定串起来

onCellClicked把 UI 事件转成逻辑调用,再根据结果刷新界面:

private void onCellClicked(int row, int col, Button cell) { if (gameOver) return; // 游戏已结束,忽略点击 if (!logic.place(row, col)) return; // 该格已有棋子 cell.setText(logic.getCell(row, col) == GameLogic.PLAYER_X ? "X" : "O"); cell.setEnabled(false); // 落子后禁用该格 int winner = logic.checkWinner(); if (winner != GameLogic.EMPTY) { tvStatus.setText(winner == GameLogic.PLAYER_X ? "X 获胜!" : "O 获胜!"); gameOver = true; return; } if (logic.isDraw()) { tvStatus.setText("平局!"); gameOver = true; return; } logic.switchPlayer(); tvStatus.setText(logic.getCurrentPlayer() == GameLogic.PLAYER_X ? "轮到 X 落子" : "轮到 O 落子"); }

这里的顺序很重要:先判胜负再判平局,最后才切换玩家。如果先切换玩家再判胜负,状态栏会显示错误的「轮到谁」。gameOver标志位防止获胜后还能继续落子,这个细节答辩时经常被问到。

3. 让作业更像样:自定义 View 画棋盘与状态保存

3.1 自定义 BoardView:onDraw 里画线和棋子

Button 方案的界面比较朴素,想拿高分可以换成自定义 View。核心是重写onDraw,用Canvas画四条线组成井字,再根据棋盘状态画 X 和 O。

public class BoardView extends View { private Paint linePaint; private Paint xPaint; private Paint oPaint; private GameLogic logic; private float cellSize; public BoardView(Context context, AttributeSet attrs) { super(context, attrs); linePaint = new Paint(Paint.ANTI_ALIAS_FLAG); linePaint.setColor(0xFF333333); linePaint.setStrokeWidth(6f); linePaint.setStyle(Paint.Style.STROKE); xPaint = new Paint(Paint.ANTI_ALIAS_FLAG); xPaint.setColor(0xFFE53935); xPaint.setStrokeWidth(8f); xPaint.setStyle(Paint.Style.STROKE); oPaint = new Paint(Paint.ANTI_ALIAS_FLAG); oPaint.setColor(0xFF1E88E5); oPaint.setStrokeWidth(8f); oPaint.setStyle(Paint.Style.STROKE); } public void setLogic(GameLogic logic) { this.logic = logic; invalidate(); // 数据变了,请求重绘 } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); int w = getWidth(); int h = getHeight(); cellSize = Math.min(w, h) / 3f; // 画两条竖线、两条横线 for (int i = 1; i <= 2; i++) { canvas.drawLine(cellSize * i, 0, cellSize * i, cellSize * 3, linePaint); canvas.drawLine(0, cellSize * i, cellSize * 3, cellSize * i, linePaint); } if (logic == null) return; // 画棋子 for (int r = 0; r < 3; r++) { for (int c = 0; c < 3; c++) { int val = logic.getCell(r, c); if (val == GameLogic.EMPTY) continue; float cx = cellSize * c + cellSize / 2; float cy = cellSize * r + cellSize / 2; float radius = cellSize * 0.3f; if (val == GameLogic.PLAYER_X) { canvas.drawLine(cx - radius, cy - radius, cx + radius, cy + radius, xPaint); canvas.drawLine(cx + radius, cy - radius, cx - radius, cy + radius, xPaint); } else { canvas.drawCircle(cx, cy, radius, oPaint); } } } } }

setLogic里调invalidate()是关键,它触发onDraw重绘。很多人写完发现界面不更新,就是忘了这一步。cellSize用Math.min(w, h) / 3f保证棋盘是正方形,不会因为 View 被拉伸而变形。

3.2 触摸坐标换算:把点击位置映射到行列

自定义 View 要自己处理触摸。重写onTouchEvent,把触摸点的 x、y 除以cellSize得到行列:

@Override public boolean onTouchEvent(MotionEvent event) { if (event.getAction() != MotionEvent.ACTION_DOWN) return true; if (logic == null) return true; int col = (int) (event.getX() / cellSize); int row = (int) (event.getY() / cellSize); if (row < 0 || row > 2 || col < 0 || col > 2) return true; if (listener != null) listener.onCellTouched(row, col); return true; }

注意ACTION_DOWN和ACTION_UP的区别:用 DOWN 响应更快,但容易误触;用 UP 更符合「按下再抬起才算点击」的直觉。课程设计里用 DOWN 就够,但要在文档里说明这个选择。cellSize必须在onDraw里算好,如果 View 还没绘制就被点击,cellSize是 0,会除零崩溃,所以加个判空。

3.3 旋转屏幕不丢状态:onSaveInstanceState 的正确用法

Android 设备旋转会销毁并重建 Activity,棋盘状态如果不保存就全没了。这是课程设计里最常见的翻车点,答辩老师十有八九会转一下屏幕看你怎么处理。

@Override protected void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); outState.putInt("currentPlayer", logic.getCurrentPlayer()); outState.putInt("moveCount", moveCount); outState.putBoolean("gameOver", gameOver); // 把 3x3 棋盘压成一维数组存进去 int[] flat = new int[9]; for (int r = 0; r < 3; r++) for (int c = 0; c < 3; c++) flat[r * 3 + c] = logic.getCell(r, c); outState.putIntArray("board", flat); } @Override protected void onRestoreInstanceState(Bundle savedInstanceState) { super.onRestoreInstanceState(savedInstanceState); int[] flat = savedInstanceState.getIntArray("board"); if (flat != null) { for (int r = 0; r < 3; r++) for (int c = 0; c < 3; c++) logic.setCell(r, c, flat[r * 3 + c]); } moveCount = savedInstanceState.getInt("moveCount"); gameOver = savedInstanceState.getBoolean("gameOver"); // 根据恢复的状态刷新 UI refreshUi(); }

Bundle只能存基本类型和数组,所以二维数组要压成一维。GameLogic需要补一个setCell方法用于恢复。refreshUi负责把恢复后的状态重新画到界面上,Button 方案要遍历设置文字和 enabled,自定义 View 方案调invalidate()即可。

提示:onRestoreInstanceState在onStart之后调用,此时 View 已经创建完毕,可以安全地操作 UI。不要在onCreate里恢复,那时 View 可能还没初始化完。

3.4 加一个简单 AI:让作业有「对战电脑」的选项

纯双人对战显得单薄,加个 AI 立刻提升档次。井字棋的 AI 不需要机器学习,用极小化极大(Minimax)就能做到永不输。核心思路是递归模拟所有走法,选对自己最有利的。

// 返回当前玩家在 board 局面下的最优分数 private int minimax(int[][] board, int player) { int winner = evaluate(board); if (winner == GameLogic.PLAYER_O) return 10; // AI 赢 if (winner == GameLogic.PLAYER_X) return -10; // 玩家赢 if (isFull(board)) return 0; // 平局 int best = (player == GameLogic.PLAYER_O) ? Integer.MIN_VALUE : Integer.MAX_VALUE; for (int r = 0; r < 3; r++) { for (int c = 0; c < 3; c++) { if (board[r][c] != GameLogic.EMPTY) continue; board[r][c] = player; int score = minimax(board, player == GameLogic.PLAYER_O ? GameLogic.PLAYER_X : GameLogic.PLAYER_O); board[r][c] = GameLogic.EMPTY; if (player == GameLogic.PLAYER_O) best = Math.max(best, score); else best = Math.min(best, score); } } return best; }

evaluate判胜负,isFull判满盘。AI 是 O,玩家是 X,所以 AI 要最大化分数,玩家要最小化。这段代码递归深度最多 9 层,性能完全没问题。答辩时能讲清楚 Minimax 的「假设对手也最优」这个前提,就是加分项。

4. 打包、签名与真机调试:把作业交出去前的最后几步

4.1 生成签名 APK 的完整流程

课程设计通常要求提交 APK 文件,不能只交源码。Android Studio 里生成签名 APK 的路径是 Build → Generate Signed Bundle / APK → APK。第一次要创建 keystore:

参数填写建议说明
Key store path项目根目录下tictactoe.jks别放系统临时目录,容易丢
Password自己记住丢了就无法更新同一应用
Key aliastictactoe别名随意,但要记住
Key password可与 store 密码相同简化记忆
Validity25 年以上默认 25 年够用
Certificate随便填课程设计不校验真实信息

选 release 构建变体,勾选 V1 和 V2 签名。V2 是 Android 7.0 以上用的,V1 兼容老设备,两个都勾最稳。生成后 APK 在app/release/目录下。

4.2 真机调试:USB 调试与常见连接问题

模拟器跑得动不代表真机没问题。真机调试步骤:设置 → 关于手机 → 连点版本号七次开启开发者模式 → 开发者选项里打开 USB 调试 → 数据线连电脑 → Android Studio 顶部设备列表里选中真机 → 点运行。

常见问题:设备列表里看不到手机,八成是数据线只能充电不能传数据,换根线;或者 Windows 缺驱动,装个厂商的 USB 驱动;或者手机上弹了「是否允许 USB 调试」的授权框没点允许。这些排查顺序记住,能省很多时间。

4.3 用 Logcat 定位崩溃:一个真实排查思路

App 一运行就闪退,别慌,看 Logcat。在 Android Studio 底部 Logcat 面板,过滤级别选 Error,看红色那行。最常见的崩溃是NullPointerException,通常是findViewById返回 null——要么 id 写错了,要么在setContentView之前就调用了。

// 错误示范:setContentView 之前 findViewById Button btn = findViewById(R.id.btnReset); // 返回 null setContentView(R.layout.activity_main); // 太晚了 // 正确顺序 setContentView(R.layout.activity_main); Button btn = findViewById(R.id.btnReset); // 正常拿到

另一个高频崩溃是数组越界,比如自定义 View 里cellSize为 0 时做除法。Logcat 会给出具体的行号,顺着行号看上下文,基本都能定位。

5. 避坑与常见问题:井字棋课程设计里最容易翻车的五件事

5.1 旋转屏幕后棋盘清空

现象:横竖屏切换,已经下的棋子全没了,状态栏也回到初始。原因:Activity 重建,成员变量全部重新初始化,没有走状态保存。解决:实现onSaveInstanceState和onRestoreInstanceState,把棋盘、当前玩家、游戏结束标志都存进 Bundle。如果嫌麻烦,也可以在AndroidManifest.xml里给 Activity 加android:configChanges="orientation|screenSize"阻止重建,但这是偷懒做法,答辩时容易被追问,不推荐。

5.2 点击已落子的格子还能覆盖

现象:同一个格子点两次,棋子被换成对方的。原因:place方法没做占用检查,或者 UI 层没禁用已落子的格子。解决:逻辑层place里判board[row][col] != EMPTY直接返回 false;UI 层落子后cell.setEnabled(false)。两层都做,双保险。自定义 View 方案则在onTouchEvent里先查logic.getCell是否为空。

5.3 平局判定永远不触发

现象:九个格子下满了,状态栏还显示「轮到 X 落子」,不显示平局。原因:moveCount没在落子时自增,或者判平局的顺序在判胜负之前导致逻辑混乱。解决:确认place里moveCount++执行了;onCellClicked里先判胜负、再判平局、最后切换玩家。isDraw的条件是moveCount == 9 && checkWinner() == EMPTY,两个条件缺一不可。

5.4 自定义 View 点击位置偏移

现象:点棋盘的右上角,棋子却画在左上角。原因:触摸坐标没减去 View 在父容器里的偏移,或者cellSize用了错误的宽高。解决:onTouchEvent里的event.getX()和getY()是相对 View 自身的坐标,一般不需要减偏移;如果 View 有 padding,要减去 padding。cellSize用Math.min(getWidth(), getHeight()) / 3f,不要用getWidth() / 3,否则 View 不是正方形时行列会错位。

5.5 签名 APK 装不上或提示「应用未安装」

现象:APK 传到手机,点击安装提示「应用未安装」或「解析包错误」。原因:签名不一致(手机上已装了同包名但不同签名的版本)、最低 SDK 版本高于手机系统、或者 APK 传输过程损坏。解决:先卸载手机上旧版本再装;检查build.gradle里minSdk是否设得太高(课程设计设 21 或 24 都行,别设 30+);重新生成一次 APK 确保文件完整。用adb install命令装能看到更详细的错误信息。

6. 从能跑到能讲:把井字棋作业变成答辩加分项的几个技巧

代码能跑只是及格线,答辩能不能讲清楚才是分水岭。我自己的习惯是,交作业前先做三件事:把逻辑层和 UI 层彻底分开、给关键方法写注释、准备一张状态流转图(手画也行,别用代码生成)。

逻辑分离这件事前面已经做了,GameLogic不依赖任何 Android API,这意味着你可以单独给它写单元测试。在app/src/test/java/下建一个测试类,用 JUnit 验证胜负判定:

@Test public void testRowWin() { GameLogic logic = new GameLogic(); logic.place(0, 0); logic.switchPlayer(); logic.place(1, 0); logic.switchPlayer(); logic.place(0, 1); logic.switchPlayer(); logic.place(1, 1); logic.switchPlayer(); logic.place(0, 2); // X 连成第一行 assertEquals(GameLogic.PLAYER_X, logic.checkWinner()); }

答辩时如果老师问「你怎么保证判胜负没写错」,你把这个测试跑给他看,比口头解释有说服力得多。这是很多同学想不到的加分点。

再进一步,可以给 AI 加难度分级。简单模式随机落子,困难模式用 Minimax。用一个枚举控制:

public enum Difficulty { EASY, HARD } private void aiMove() { if (difficulty == Difficulty.EASY) { // 随机选一个空格 List<int[]> empty = new ArrayList<>(); for (int r = 0; r < 3; r++) for (int c = 0; c < 3; c++) if (logic.getCell(r, c) == GameLogic.EMPTY) empty.add(new int[]{r, c}); if (empty.isEmpty()) return; int[] pick = empty.get(new Random().nextInt(empty.size())); logic.place(pick[0], pick[1]); } else { // Minimax 选最优 int[] best = findBestMove(); logic.place(best[0], best[1]); } }

难度分级让作业有了「产品感」,答辩时能讲出「我考虑了不同用户的需求」,这句话的分量比多写两百行代码还重。

最后一个技巧是关于演示的。答辩现场网络不一定好,别依赖在线资源;把 APK 提前装到自己的手机上,现场直接演示,比在模拟器上点更流畅。演示顺序建议:先双人对战走一局展示基本功能,再切到人机对战展示 AI,最后旋转屏幕展示状态保存。三个动作覆盖了课程设计最核心的考点,老师一看就知道你是真做懂了。

我踩过最深的坑是第一次交作业时只交了源码没交 APK,老师当场让我编译,结果因为 Android Studio 版本差异 Gradle 同步失败,场面一度很尴尬。从那以后我养成的习惯是:源码、APK、一份简短的 README(写清楚怎么导入、怎么运行、用了什么技术)三样一起交。README 不用长,半页纸就够,但能省掉很多解释成本。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进