ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

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

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 决定你能调用哪些新 APIminSdk 决定你兼容到哪一年的设备两者相差越大兼容性检查越要细心。五子棋用的 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 version1.0 encodingutf-8? LinearLayout xmlns:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightmatch_parent android:orientationvertical android:background#E8C97A TextView android:idid/tvStatus android:layout_widthwrap_content android:layout_heightwrap_content android:text轮到黑棋落子 android:textSize18sp android:textColor#3E2723 android:layout_gravitycenter_horizontal android:layout_marginTop16dp android:layout_marginBottom8dp / com.example.gobang.GobangView android:idid/gobangView android:layout_widthmatch_parent android:layout_height0dp android:layout_weight1 android:layout_margin12dp / LinearLayout android:layout_widthwrap_content android:layout_heightwrap_content android:orientationhorizontal android:layout_gravitycenter_horizontal android:layout_marginTop8dp android:layout_marginBottom16dp Button android:idid/btnRestart android:layout_widthwrap_content android:layout_heightwrap_content android:text重新开始 android:layout_marginEnd24dp / Button android:idid/btnUndo android:layout_widthwrap_content android:layout_heightwrap_content android:text悔棋 / /LinearLayout /LinearLayoutandroid:layout_weight1是 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. 五子棋核心逻辑棋盘建模、胜负判定与一个能打的简单 AI4.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:configChangesorientation|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 Stackint[] 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 的完整恢复流程横竖屏状态保持的完整解法不只存棋盘数组悔棋栈、当前玩家、游戏结束标记这些都得存。如果用了Stackint[]序列化前先转成ArrayListint[]因为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它决定安装后在桌面上显示的应用名写成默认的包名会显得很随意。五子棋的图标换成自带棋子样式的图标这种小细节恰恰是答辩时老师很容易注意到的加分项。我做过几年课程设计评审说句实话能稳定跑通、能旋转屏幕不丢棋局、能悔棋、能安装到真机上的项目已经超过七成的水准了再把音效和签名补上完整度直接拉满。做安卓项目最怕的是功能写完了不验证边界情况就交差落子边缘、横竖屏切换、快速点悔棋这几个操作多试几轮比自己反复读代码找问题有效得多。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

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