news 2026/10/10 4:34:16

Android五子棋开发实战:自定义View、触摸事件与AI对弈全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Android五子棋开发实战:自定义View、触摸事件与AI对弈全解析

简介:面向Android开发学习者与计算机专业课程设计的一款五子棋小游戏完整项目,采用Android Studio开发,实现人机对战与人人对战两种玩法。人机对战部分通过棋盘落子得分评估AI决策,单人可以挑战AI并实时记录比分;人人对战支持轮流下棋并记录当前棋子颜色,过程中可随时开始新游戏或悔棋,主界面还提供背景音乐开关与游戏介绍入口。资源共657个文件,以Java源码、XML布局文件、Gradle构建配置、dex与class编译产物、APK成品、PNG图标及JSON配置等为主,压缩包整体30.71MB,项目目录清晰,适合导入Android Studio后直接运行和学习。项目基于Android Studio 4.0.1构建,近两年官方版本均可正常打开。现有2092人已浏览学习,可作为安卓课程设计、移动应用开发实践或棋类AI入门参考,尤其适合需要完整项目源码与交互流程的读者。

1. 计科学生绕不开的五子棋:为什么它是安卓程序设计最好的练手项目

大二大三的安卓程序设计课程设计清单里,五子棋出现的频率常年排第一,不是没有道理的。这个项目听起来简单,但它同时踩中了 Android Studio 开发的几个关键能力点:自定义 View 绘图、触摸事件处理、二维数组建模、状态保存,甚至还能往上加一个人机对战 AI。换句话说,五子棋小到一个人两周能做完,大到能写进简历当作项目经历去聊。对一个计科学生来说,它比计算器、记账本更能证明你理解了安卓应用从界面到逻辑的完整闭环。

做得好的五子棋 App,棋盘不是用图片拼的,棋子不是用按钮摆的,而是通过 Canvas 一像素一像素画出来的。这个过程逼着你搞懂坐标系、View 的测量与重绘、触摸点怎么换算成语义坐标,这些正是安卓程序设计里最容易让新手卡壳的地方。本文就从环境搭建开始,把棋盘绘制、落子逻辑、胜负判定、AI 和避坑整条链路掰开来讲,代码可以直接照着敲。

2. 先解决环境与工程骨架:Android Studio 版本、Gradle 镜像与工程结构

2.1 版本搭配:为什么不要一上来就装最新版

打开 Android Studio 官网下载页面,建议选稳定版,不要追预览版和 Canary 版。五子棋项目用到的是成熟的 Android SDK 和 Gradle 插件,新版预览版往往带着一些未修复的构建问题,反而拖慢你的进度。常见的稳妥组合是 Android Studio Giraffe 或 Hedgehog 配 Android Gradle Plugin 8.x,编译 SDK 用 34。这套组合跑课程设计的项目完全够用,网上搜到的多数教程也基于这个范围。

新建项目时选 Empty Views Activity,语言选 Java 或 Kotlin 都行。如果你跟着老师给的模板走,大概率是 Java,本文后续代码也用 Java 写。工程创建后先别急着写界面,打开build.gradle确认三个数字:compileSdk、minSdk、targetSdk。五子棋不依赖什么新系统特性,minSdk设 21 就够覆盖绝大多数老设备,targetSdk和compileSdk保持一致即可。

android { namespace 'com.example.gobang' compileSdk 34 defaultConfig { applicationId "com.example.gobang" minSdk 21 targetSdk 34 versionCode 1 versionName "1.0" } }

namespace从 AGP 8.0 开始必须手动指定,它替代了旧版的package字段。compileSdk 34表示用 Android 14 的 API 去编译代码,minSdk 21则限定了最低支持到 Android 5.0。这两个值的选择逻辑:编译 SDK 决定你能调用哪些新 API,minSdk 决定你兼容到哪一年的设备,两者相差越大,兼容性检查越要细心。五子棋用的 Canvas、SoundPool 都是老 API,这套配置不会出兼容性问题。

2.2 Gradle 同步慢的解决办法:配置镜像仓库

Gradle 同步卡住是新手第一个劝退点。默认仓库地址在部分网络环境下访问很慢,同步一次要等十几分钟,甚至直接失败。常见做法是给仓库配置镜像地址,把settings.gradle里的pluginManagement和dependencyResolutionManagement的仓库列表改成镜像源。

pluginManagement { repositories { maven { url 'https://maven.aliyun.com/repository/google' } maven { url 'https://maven.aliyun.com/repository/gradle-plugin' } maven { url 'https://maven.aliyun.com/repository/public' } google() mavenCentral() } } dependencyResolutionManagement { repositories { maven { url 'https://maven.aliyun.com/repository/google' } maven { url 'https://maven.aliyun.com/repository/public' } google() mavenCentral() } }

注意镜像仓库要写在google()和mavenCentral()前面,这样 Gradle 会优先从镜像拉依赖,拉不到再回退官方源。改完配置点 Sync Now,正常情况下几十秒就能完成。我这几年看过不少学生项目,卡在环境这一步的比卡在代码逻辑的还多,把镜像配好能省下大量等待时间。

2.3 布局骨架:一个 LinearLayout 装下棋盘和操作区

五子棋的界面结构其实很简单:一个自定义棋盘 View 占中间主要区域,顶部一行标题或状态提示,底部一排按钮做悔棋、重开操作。用 LinearLayout 竖排就能搞定,不需要引入复杂的 ConstraintLayout 依赖。

<?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:background="#E8C97A"> <TextView android:id="@+id/tvStatus" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="轮到黑棋落子" android:textSize="18sp" android:textColor="#3E2723" android:layout_gravity="center_horizontal" android:layout_marginTop="16dp" android:layout_marginBottom="8dp" /> <com.example.gobang.GobangView android:id="@+id/gobangView" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:layout_margin="12dp" /> <LinearLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:orientation="horizontal" android:layout_gravity="center_horizontal" android:layout_marginTop="8dp" android:layout_marginBottom="16dp"> <Button android:id="@+id/btnRestart" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="重新开始" android:layout_marginEnd="24dp" /> <Button android:id="@+id/btnUndo" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="悔棋" /> </LinearLayout> </LinearLayout>

android:layout_weight="1"是 LinearLayout 里的关键写法,它让棋盘 View 自动占据除去状态栏和按钮区外的所有剩余高度,同时match_parent加layout_margin保证棋盘四周留出边距。GobangView是我们马上要写的自定义 View,注意 XML 里的类名要写完整路径,包名和工程里保持一致。底部的悔棋和重开按钮用横向 LinearLayout 排列,layout_marginEnd控制两个按钮之间的间距。

布局文件里最容易被忽略的是@+id的资源 ID 引用,所有控件 ID 都必须在R.java中注册。如果 build 时提示找不到R.id.gobangView,先检查 XML 里 id 是否拼写正确、是否多文件重复定义。这个阶段犯的错最多,但解决起来也最直接——罗列一下开始写棋盘吧。

3. 用自定义 View 绘制棋盘与棋子:Canvas 绘图与触摸坐标换算

3.1 为什么棋盘必须用自定义 View,而不是九张图片

棋盘和棋子动辄几十个状态要维护,如果用 ImageView 拼棋盘,15×15 的交叉点要摆放几百个可点击区域,逻辑复杂度直接爆炸。自定义 View 的核心优势在于,整个棋盘只是一个 View,它的全部状态存进一个int[][]数组,每一次状态变化调用invalidate()触发重绘即可。这种设计让数据和绘制天然同步,逻辑清晰得多。

自定义 View 的另一个好处是缩放自适应。棋盘宽度跟随父布局变化,通过onMeasure确定尺寸后,所有网格间距、棋子半径都由计算得出,不用为不同屏幕密度的设备准备多套图片资源。这也是安卓程序设计课程里的一个重要考点:理解 View 的测量、布局、绘制三步流程。

3.2 onDraw 绘制棋盘:网格、星位与棋子半径的计算

棋盘采用 15×15 路线,交叉点落子。需要计算的核心参数是cellSize(格子边长)和boardMargin(棋盘边距)。为了让棋盘视觉上居中且留出落子空间,边距设为棋子半径的两倍,保证边线上的棋子有一半露在棋盘线外侧。

public class GobangView extends View { private static final int BOARD_SIZE = 15; private int cellSize; private float boardMargin; private int[][] chessArray = new int[BOARD_SIZE][BOARD_SIZE]; private Paint linePaint; private Paint blackPaint; private Paint whitePaint; public GobangView(Context context, AttributeSet attrs) { super(context, attrs); linePaint = new Paint(); linePaint.setColor(0xFF3E2723); linePaint.setAntiAlias(true); linePaint.setStrokeWidth(2f); blackPaint = new Paint(); blackPaint.setColor(0xFF000000); blackPaint.setAntiAlias(true); whitePaint = new Paint(); whitePaint.setColor(0xFFFFFFFF); whitePaint.setAntiAlias(true); } @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 onDraw(Canvas canvas) { super.onDraw(canvas); int width = getWidth(); int boardSize = width - (int) boardMargin * 2; cellSize = boardSize / (BOARD_SIZE - 1); // 画网格线 for (int i = 0; i < BOARD_SIZE; i++) { float startX = boardMargin; float startY = boardMargin + i * cellSize; float endX = width - boardMargin; float endY = startY; canvas.drawLine(startX, startY, endX, endY, linePaint); canvas.drawLine(startY, startX, endY, endX, linePaint); } // 画棋子 for (int row = 0; row < BOARD_SIZE; row++) { for (int col = 0; col < BOARD_SIZE; col++) { if (chessArray[row][col] != 0) { float cx = boardMargin + col * cellSize; float cy = boardMargin + row * cellSize; canvas.drawCircle(cx, cy, cellSize * 0.42f, chessArray[row][col] == 1 ? blackPaint : whitePaint); } } } } }

onMeasure里用setMeasuredDimension(size, size)强制棋盘成为一个正方形,这是为了防止在竖屏布局中被拉伸成长方形。cellSize的计算基于“棋盘总尺寸减去两侧边距,再除以 14 段”,这是 15 条线 15 个交叉点之间的正确间距。棋子半径取cellSize * 0.42f,留出相邻棋子之间的视觉间隙,比 0.5 倍半径视觉效果更好,不会挤成一团。

Paint 的setAntiAlias(true)是关键,没有抗锯齿的圆形棋子边缘会呈锯齿状,一眼显得粗糙。颜色值用 ARGB 十六进制表示,0xFF000000是不透明的纯黑,0xFFFFFFFF是不透明的纯白,棋盘底色由 XML 里background="#E8C97A"提供,这个暖黄色调模拟木质棋盘质感。如果把底色和棋子颜色黑白对比拉得太强,面对亮屏幕看久了眼睛会累。

3.3 onTouchEvent 触摸事件:把手指坐标换算成棋盘坐标

绘制解决了显示,触摸解决了交互。手指落在屏幕上得到的是相对于 View 左上角的像素坐标,需要把它换算成“第几行第几列”。换算公式不复杂:先减掉棋盘边距,再除以格子边长,然后四舍五入取整,最后检查结果是否越界和是否已有棋子。

@Override public boolean onTouchEvent(MotionEvent event) { if (event.getAction() == MotionEvent.ACTION_DOWN) { int col = (int) ((event.getX() - boardMargin) / cellSize + 0.5f); int row = (int) ((event.getY() - boardMargin) / cellSize + 0.5f); if (row >= 0 && row < BOARD_SIZE && col >= 0 && col < BOARD_SIZE && chessArray[row][col] == 0 && !isGameOver) { chessArray[row][col] = currentPlayer; if (checkWin(row, col, currentPlayer)) { isGameOver = true; if (onGameEventListener != null) { onGameEventListener.onGameWin(currentPlayer); } } else { currentPlayer = currentPlayer == 1 ? 2 : 1; if (onGameEventListener != null) { onGameEventListener.onPlayerChanged(currentPlayer); } } invalidate(); return true; } } return super.onTouchEvent(event); }

加0.5f再取整是实现四舍五入的常用技巧。假如 cellSize 算出来是 24px,手指落在距离起始线 35px 的位置,35 / 24 = 1.46,加 0.5 后是 1.96,取整得到 1,也就是第二个交叉点。这个偏移量处理不好,棋子落点会显得“歪”,这也是很多新手说触摸坐标偏了一个格子的根源。

判断条件里chessArray[row][col] == 0保证不能在已有棋子的位置重复落子,!isGameOver防止棋局结束后继续下。onGameEventListener是一个自定义接口回调,用来把胜负结果和轮次变化通知给 Activity,这样自定义 View 只负责绘制和落子,业务逻辑留在 Activity 层,符合单一职责的划分方式。返回值return true表示这个事件已被消费,否则系统会把事件继续向上抛给父容器。

4. 五子棋核心逻辑:棋盘建模、胜负判定与一个能打的简单 AI

4.1 二维数组建模:用 0、1、2 表示空位、黑子、白子

棋盘状态用一个 15×15 的二维数组int[][]表示,0 表示空交叉点,1 表示黑棋,2 表示白棋。数组的下标row和col天然对应棋盘的行列,不需要额外的数据结构。每次落子就是给数组元素赋值,每次判胜就是扫描数组。这种建模方式直观、调试方便,打印数组就能看到整个棋盘的全貌。

有了这个数组模型,很多操作变得非常简洁:悔棋只需要记录每一步的落子坐标,撤销时把对应数组元素重置为 0;判断某位置是否可落子只需要检查数组值。五子棋的规则简单到几乎不需要复杂的类结构,面向过程地写也完全可行,但建议给棋盘模型单独开一个类,把判定逻辑封装进去,Activity 和 View 只调接口。

4.2 胜负判定算法:四方向直线扫描的边界陷阱

胜负判定的标准做法是从刚落下的棋子出发,沿水平、垂直、主对角线、副对角线四个方向向两端数同色棋子数量,总数大于等于 5 即为胜。很多人一开始写成八方向扫描,每个方向单独数一遍,这样代码量翻倍还容易漏判,正确做法是一条直线两个方向同时数,一次搞定。

public boolean checkWin(int row, int col, int player) { int[][] directions = {{0, 1}, {1, 0}, {1, 1}, {1, -1}}; for (int[] dir : directions) { int count = 1; // 正方向 count += countDirection(row, col, dir[0], dir[1], player); // 反方向 count += countDirection(row, col, -dir[0], -dir[1], player); if (count >= 5) { return true; } } return false; } private int countDirection(int row, int col, int dRow, int dCol, int player) { int count = 0; int r = row + dRow; int c = col + dCol; while (r >= 0 && r < BOARD_SIZE && c >= 0 && c < BOARD_SIZE && chessArray[r][c] == player) { count++; r += dRow; c += dCol; } return count; }

四个方向向量覆盖了所有可能的五连方向:{0,1}是水平向右,{1,0}是竖直向下,{1,1}是主对角线,{1,-1}是副对角线。countDirection里的边界判断放在循环条件中,防止数组越界访问,这是最容易写漏的地方。数组越界时程序直接崩溃,而且不是每次落子都触发,往往是在棋盘边缘落子时才出现,这就显得很玄学,实际上是自己忘了边界保护。

用count >= 5而不是== 5是有讲究的。如果棋盘上已经形成六连甚至七连的棋型,== 5会误判为未胜,继续下会出现逻辑矛盾。正式比赛中六连确实算胜,所以用>=最稳妥。

4.3 给 AI 一个简单的打分函数:既会进攻也会堵截

单人模式的 AI 不需要做完整的博弈树搜索,通过权值打分就能实现一个水平够用的对手。基本思路是遍历每个空位,模拟在该位置放黑棋和白棋,分别统计四个方向上的连子数、空位和端点情况,然后按权值表打分,黑棋分高则进攻,白棋分高则防守,最后选综合分值最高的点落子。

public int[] getAIMove() { int bestScore = -1; int bestRow = -1; int bestCol = -1; for (int row = 0; row < BOARD_SIZE; row++) { for (int col = 0; col < BOARD_SIZE; col++) { if (chessArray[row][col] != 0) { continue; } int score = evaluatePoint(row, col, 1) + evaluatePoint(row, col, 2); if (score > bestScore) { bestScore = score; bestRow = row; bestCol = col; } } } return new int[]{bestRow, bestCol}; } private int evaluatePoint(int row, int col, int player) { int score = 0; int[][] directions = {{0, 1}, {1, 0}, {1, 1}, {1, -1}}; for (int[] dir : directions) { int count = 1; int emptyEnds = 0; count += countDirection(row, col, dir[0], dir[1], player); count += countDirection(row, col, -dir[0], -dir[1], player); if (isSideEmpty(row, col, dir[0], dir[1], player)) emptyEnds++; if (isSideEmpty(row, col, -dir[0], -dir[1], player)) emptyEnds++; if (count >= 5) { score += 10000; } else if (count == 4 && emptyEnds > 0) { score += 1000; } else if (count == 3 && emptyEnds > 0) { score += 100; } else if (count == 2 && emptyEnds > 0) { score += 10; } } return score; }

打分力度按指数级区分,活四 1000 分,冲四或活三数百,这样 AI 会优先做成活四而不是去堵一个活二,棋力虽然算不上多强,但对付没系统练过的同学已经够用了。evaluatePoint(row, col, 1) + evaluatePoint(row, col, 2)把进攻和防守价值相加,AI 在多数情况下会优先堵你的活三,自己有机会冲四时也会果断出手。这个 AI 的局限性在于只看一步,不会思考连续两回合的交换,想加强就要引入极小化极大搜索加 alpha-beta 剪枝,但课程设计的体量做到这个程度已经超出平均水准了。

5. 提交前必看:安卓程序设计中常翻车的 5 个典型问题

5.1 横竖屏切换后棋局清空:Activity 重建机制惹的祸

现象:下到一半把手机从竖屏转成横屏,棋盘直接清空回到初始状态,甚至直接闪退。原因:默认情况下,配置变化会让 Activity 被销毁并重建,所有内存中的棋盘数组、当前玩家、悔棋栈全部丢失。解决:比较两种方案。一是给 Activity 配置android:configChanges="orientation|screenSize"让系统不重建 Activity,省事但治标不治本;二是实现onSaveInstanceState保存数据,这是正规做法,也是面试常问的知识点。

@Override protected void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); outState.putSerializable("chessArray", chessArray); outState.putInt("currentPlayer", currentPlayer); outState.putBoolean("isGameOver", isGameOver); }

putSerializable要求棋盘数组对应的类型实现Serializable接口,int[][]数组本身就支持序列化,不需要额外处理。恢复操作在onCreate里加判断,savedInstanceState != null时从 Bundle 取出数据重新赋值。课程设计验收时老师往往会旋转屏幕测状态保持,这一项做好了印象分能拉不少。

5.2 触摸点偏了一个格子:onMeasure 与布局尺寸不一致

现象:点两格子中间的空隙,棋子落在旁边格子上,或者点在边缘线上的棋子落不上去。原因:onDraw里计算公式写了两套,onMeasure计算的cellSize和实际绘制时用的cellSize不同,导致触摸换算和绘制坐标系错位。解决:保证onDraw和onTouchEvent里计算cellSize的逻辑完全一致,最好在 View 的尺寸确定后统一计算一次并缓存。

常见做法是在onSizeChanged方法里一次性算好所有几何参数,onDraw只负责读取,onTouchEvent也只读取同一个cellSize。代码里多次重复计算相同的值,看起来没毛病,实际上尺寸一变就容易出现微小的取整误差,积累几次就能肉眼可见地偏出去,这类问题肉眼排查很难发现,还一个格子一个格子地试,属实浪费时间。数据驱动绘制,这是安卓开发里最朴素的解。

5.3 按钮点击崩溃:findViewById 返回 null 的排查

现象:点击“悔棋”按钮,应用闪退,Logcat 抛空指针异常。原因:按钮 XML 写在了另一个布局文件里,而setContentView加载的是主布局;或者按钮 id 写错,用了@id/btnUndo而不是@+id/btnUndo。解决:先核对setContentView(R.layout.activity_main)和按钮所在文件是否一致,再检查 id 引用。Android Studio 里按 Ctrl 点击R.id.btnUndo能直接跳转到 XML 定义处,跳转不了就是 id 写错了。这个坑看似低级,但新手阶段非常常见,而且报错信息能直接看到是哪个变量为 null,顺着栈往上找就能定位。

5.4 悔棋逻辑失效:数组重置了但界面没变

现象:点了悔棋,棋盘上的棋子还在,但落子逻辑已经乱了。原因:数组里的值重置了,但触发了错误的 View 刷新时机,或者缓存的历史坐标栈处理顺序有误。解决:检查悔棋方法是否调用了invalidate(),没有重绘界面自然不变。再看悔棋栈是否用了后进先出的顺序,这一步是典型的栈操作,写成了remove(0)就是先进先出,悔棋永远悔掉第一颗棋。

private Stack<int[]> historyStack = new Stack<>(); public void undo() { if (historyStack.isEmpty()) { return; } int[] lastMove = historyStack.pop(); chessArray[lastMove[0]][lastMove[1]] = 0; isGameOver = false; currentPlayer = currentPlayer == 1 ? 2 : 1; invalidate(); }

每次落子时historyStack.push(new int[]{row, col}),悔棋时pop出最后一步,将对应数组位置改回 0,同时把游戏结束状态复位,把轮次交给悔棋方。这里有三个细节必须同步做:数组状态、胜负状态、当前玩家。漏了任何一个,下一手都会出现逻辑错乱。如果你用 ArrayList 的add和remove(size-1)模拟栈也可以,但Stack的语义更清晰,不用纠结索引对不对。

5.5 模拟器运行卡顿与画面闪烁:invalidate 调用时机

现象:真机上运行流畅,模拟器上卡成幻灯片,棋盘区域偶尔闪烁。原因:模拟器的图形渲染性能本来就不如真机,加上每次落子都触发全棋盘重绘。解决:回归代码逻辑,检查是否调用了过多的invalidate(),每次落子只需一次。如果你在一次落子过程中因为各类回调重复调用了多次invalidate(),会强制多次重绘,造成闪烁。正确做法是在所有数据更新完成后再调用一次invalidate()。另外确认 XML 里没有嵌套多层不必要的布局,简单场景用 LinearLayout 足够,嵌套太深会放大模拟器的性能问题,这个优化在课程设计答辩演示时非常救命。顺带一提,画棋子的循环里每帧都新建 Paint 对象也是常见的性能浪费点,把 Paint 对象都提升为成员变量,一劳永逸。狠狠查一遍这个位置,能省不少电和内存,模拟器能明显感觉顺滑下来。

6. 收官打磨:状态恢复、落子音效与签名 APK 的发布细节

6.1 onSaveInstanceState 的完整恢复流程

横竖屏状态保持的完整解法不只存棋盘数组,悔棋栈、当前玩家、游戏结束标记这些都得存。如果用了Stack<int[]>,序列化前先转成ArrayList<int[]>,因为Stack序列化后反序列化的顺序在某些 Android 版本上有兼容性隐患。恢复时把数组一个个push回栈里。逻辑简单但要仔细,缺一项就回归原样,这是最容易在验收时暴露的硬伤。

```kotlin` 风格在这里不讨论,Java 的序列化逻辑最稳妥:

@Override protected void onRestoreInstanceState(Bundle savedInstanceState) { super.onRestoreInstanceState(savedInstanceState); int[][] savedArray = (int[][]) savedInstanceState.getSerializable("chessArray"); if (savedArray != null) { chessArray = savedArray; gobangView.setChessArray(chessArray); } currentPlayer = savedInstanceState.getInt("currentPlayer"); isGameOver = savedInstanceState.getBoolean("isGameOver"); }

恢复时用同一个savedInstanceState对象,键名和保存时保持一致。getSerializable返回的是Object,需要强转成int[][]。强转前判空是最佳习惯,万一保存时机没触发,比如进程被系统回收时没来得及走onSaveInstanceState,恢复时取到的就是 null,直接强转会崩。Data 恢复后在给自定义 View 设数组的同时,记得同步刷新状态提示文字。

6.2 给落子加上音效:SoundPool 的轻量用法

SoundPool 适合短促的音效,落子响声、按钮单击声都是它的适用场景。MediaPlayer 更适合背景音乐,把它用在落子上会导致加载慢、延迟高。

SoundPool soundPool; int dropSoundId; // 初始化,API 21+ 推荐使用 Builder 构造 soundPool = new SoundPool.Builder() .setMaxStreams(4) .build(); dropSoundId = soundPool.load(this, R.raw.drop, 1); // 落子时播放 soundPool.play(dropSoundId, 1f, 1f, 1, 0, 1f);

setMaxStreams(4)表示最多同时播放 4 个流,超出后系统会中断最早的那个,避免连续快速落子时声音叠成一团。load方法返回一个 soundId,后续play用这个 id 定位音频资源。音量参数左右声道都设 1f 是满音量,如果你觉得落子声太响,可以调到 0.6f。音频文件放在res/raw目录,格式推荐 wav,音效短小、加载快,mp3 也可以但要稍微注意加载延迟。别忘了在 Activity 的onDestroy里调用soundPool.release()释放资源,不释放的话长时间运行会累积内存占用,这在课程设计的评审里算是一个能主动说的加分优化点。

6.3 生成签名 APK:课程提交和答辩前最后一关

课程设计通常要求提交可安装的 APK 文件,而不是只在模拟器里能跑。打包签名流程是 Android Studio 菜单里的 Build 下选择 Generate Signed Bundle or APK,选 APK,新建一个 keystore,填写签名信息。签名文件建议备份好,以后更新版本还得用同一个 keystore,丢了就只能换应用签名,无法覆盖安装在旧版本上。

keytool -genkeypair -v -keystore gobang.keystore -alias gobang -keyalg RSA -keysize 2048 -validity 10000

在 Android Studio 图形界面里创建签名时,这些参数会自动生成,只填表就行。-validity 10000单位是天,大约 27 年,课程设计和毕业后两三年内都不会过期。签名完成后在 build 菜单里选 Build APK,产物路径在app/build/outputs/apk/debug/下。拿这个 APK 去安装到真机上做最终验证,比模拟器更接近真实用户体验。

最后再检查一遍 AndroidManifest.xml 里的android:label,它决定安装后在桌面上显示的应用名,写成默认的包名会显得很随意。五子棋的图标换成自带棋子样式的图标,这种小细节恰恰是答辩时老师很容易注意到的加分项。我做过几年课程设计评审,说句实话,能稳定跑通、能旋转屏幕不丢棋局、能悔棋、能安装到真机上的项目,已经超过七成的水准了,再把音效和签名补上,完整度直接拉满。做安卓项目最怕的是功能写完了不验证边界情况就交差,落子边缘、横竖屏切换、快速点悔棋,这几个操作多试几轮,比自己反复读代码找问题有效得多。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/10 4:34:13

深度学习入门:从神经网络原理到PyTorch手写数字识别实战

很多朋友问我&#xff0c;经常刷到"深度学习"这个词&#xff0c;想学却不知道从哪下手。我也经历过那个阶段&#xff1a;看了几篇帖子&#xff0c;装了框架&#xff0c;跑了示例&#xff0c;然后被一堆术语按在地上摩擦。这篇作为"深度学习1"的开篇&#x…

作者头像 李华
网站建设 2026/10/10 4:33:06

16G显存跑40GB大模型:量化、CPU卸载与混合加载实战

这阵子我一直盯着硬盘里那个体积逼近 40GB 的模型文件&#xff0c;再看看手边这块只有 16G 显存的显卡&#xff0c;脑子里反复蹦出来的就两个字&#xff1a;硬上。事情是这样的。我平时主要拿笔记本接显卡坞干活&#xff0c;机器本身没有独显&#xff0c;全靠一块雷电坞拖着一块…

作者头像 李华
网站建设 2026/10/10 4:33:06

给Claude装上长期记忆:跨会话上下文持久化实战解析

用过 Claude 的人大概都有过这种体验&#xff1a;今天聊的方案&#xff0c;明天打开新会话&#xff0c;它一脸茫然&#xff1b;刚调好的偏好设置&#xff0c;过两天一问&#xff0c;忘得一干二净&#xff1b;同一个项目背景&#xff0c;每次都要从头解释一遍。这不是错觉&#…

作者头像 李华
网站建设 2026/10/10 4:31:54

简易安卓项目开发指南:从零搭建到APK打包的全流程详解

简介&#xff1a;面向安卓初学者的简易安卓项目完整源码包&#xff0c;依托安卓开发工具构建&#xff0c;涵盖用户登录与注册、数据存储、选项卡切换、信息表注册及圆形头像处理等核心功能。登录注册功能涉及输入控件、按钮事件与网络请求&#xff0c;可帮助理解前后端交互流程…

作者头像 李华
网站建设 2026/10/10 4:31:51

Windows截图快捷键原理与全场景实战指南

1. 别再死记硬背了&#xff1a;截图快捷键的本质是“系统级信号路由”你有没有过这种经历&#xff1a;明明刚在同事面前演示完“CtrlPrint Screen”截全屏&#xff0c;转头自己想截当前窗口时&#xff0c;手指却鬼使神差按出“CtrlAltDelete”&#xff0c;结果弹出任务管理器&a…

作者头像 李华
网站建设 2026/10/10 4:31:32

Python装饰器必备:functools.wraps原理与实战

1. 从一次排查事故说起先讲个我自己的故事。几年前在某项目组做接口层改造&#xff0c;用的 Python。某天线上报了一个 bug&#xff0c;定位到某个视图函数里抛了异常&#xff0c;但日志里记录的报错堆栈第一行是in wrapper而不是函数名。我下意识用函数名去 grep 代码&#xf…

作者头像 李华