简介:Android Studio五子棋项目是一份面向Android初学者的完整源码与工程文件,涵盖从界面布局到游戏规则实现的典型开发链路。项目基于Android Studio IDE构建,包含MainActivity、棋盘逻辑、胜负判断与落子校验等核心模块,适合用于学习事件监听、自定义View绘制、状态维护及基础算法设计。压缩包内含49个文件,以xml布局与配置、png棋子素材、kt源代码及gradle构建脚本为主,另有gradlew、properties等工程支撑文件,整体约877KB,结构规整便于直接导入与二次修改。该工程在CSDN已有2305人学习,经过实际下载验证。通过研读项目,可快速掌握五子棋棋盘绘制、触摸落子、五子连珠判定、和棋处理等要点,并获得一套可运行的Android游戏模板,有助于将Android Studio常用操作与游戏逻辑设计融会贯通,适合课程设计或入门练手时使用。
1. Android Studio五子棋:为什么说它是Android自定义View练手的第一道坎
在Android Studio里做五子棋,听起来只是一个课程项目,但真正从零走一遍你才会发现:它把自定义View、触摸事件分发、游戏状态管理和算法判断四件事一次性全串起来了。很多新手卡在棋盘画出来了但点不上去,或者能落子但连成五颗不提示赢家,问题往往不在游戏规则本身,而在对View生命周期和坐标换算的理解。这篇笔记就是站在完整走通一遍的角度,把从新建工程到棋盘绘制、落子判定、悔棋、以及加一版简单人机对战的路径拆给你。适合刚学完Android基础、准备用一个小项目检验自定View能力的人,照步骤做,两三天能跑通一个能正常对弈的版本。
2. 棋盘与落子:用自定义View把五子棋的绘制逻辑拆开
2.1 画棋盘之前:格子尺寸、边距与onSizeChanged
自定义View的核心是拿到准确的宽高再画,不要在构造方法里计算依赖屏幕尺寸的值,那时候View还没有完成测量。常见做法是重写onSizeChanged,在视图大小确定后一次性算好棋盘参数。Android Studio里新建一个继承View的内部类,再重写onDraw画线,是最小可运行的方案。五子棋一般用15路棋盘,比19路更适合手机屏幕宽度,关键点的计算逻辑两者一致。
public class GobangView extends View { private static final int LINE_COUNT = 15; private int mMargin; private int mCellSize; private final Paint mLinePaint = new Paint(); public GobangView(Context context, AttributeSet attrs) { super(context, attrs); mLinePaint.setColor(Color.BLACK); mLinePaint.setStrokeWidth(2f); mLinePaint.setAntiAlias(true); } @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { int size = Math.min(w, h); mMargin = size / 10; mCellSize = (size - mMargin * 2) / (LINE_COUNT - 1); } @Override protected void onDraw(Canvas canvas) { int boardEnd = mMargin + (LINE_COUNT - 1) * mCellSize; for (int i = 0; i < LINE_COUNT; i++) { int pos = mMargin + i * mCellSize; canvas.drawLine(mMargin, pos, boardEnd, pos, mLinePaint); canvas.drawLine(pos, mMargin, pos, boardEnd, mLinePaint); } } }这段代码里最关键的是mCellSize的计算方式。很多人会把格子尺寸算成size / LINE_COUNT,结果最后一根线画到视图边缘外,棋盘看着像被切掉一截。我用(size - 2*mMargin)/(LINE_COUNT - 1)是为了让第0条线和第14条线都完整落在边距内,四边留白对称。mMargin取size / 10而不是固定dp,是为了让不同屏幕密度下棋盘的视觉占比一致。竖屏时取宽高较小值做依据,横屏时棋盘就不会被拉伸变形。
onDraw里画线统一用boardEnd作为终点,好处是只要mMargin和mCellSize算对,横竖线天然交在整数坐标上,不会因为小数像素产生线宽不均匀的抖动。画笔记得开抗锯齿,五子棋的棋盘线本来就细,不开抗锯齿在低分辨率模拟器上会看到明显毛边。落子后要刷新界面,只需要在状态变化处调用invalidate,系统会重新走一遍onDraw。
2.2 触摸落子与坐标映射:把手指点按换算成行列
棋盘画好以后,下一步是让手指点到交叉点附近时正确落子。最容易翻车的地方是坐标换算顺序写反:正确的顺序是先减mMargin、再除以mCellSize、最后四舍五入取整。另外,触摸事件处理完ACTION_DOWN之后要返回true,后续的MOVE和UP才会继续交给这个View处理,否则会出现手指拿起来之前棋盘毫无反应的假象。
@Override public boolean onTouchEvent(MotionEvent event) { if (event.getAction() == MotionEvent.ACTION_DOWN) { int col = Math.round((event.getX() - mMargin) / mCellSize); int row = Math.round((event.getY() - mMargin) / mCellSize); if (col < 0 || col >= LINE_COUNT || row < 0 || row >= LINE_COUNT) { return true; } float centerX = mMargin + col * mCellSize; float centerY = mMargin + row * mCellSize; float distance = (float) Math.hypot(event.getX() - centerX, event.getY() - centerY); if (distance <= mCellSize * 0.4f && mBoard[row][col] == 0) { mBoard[row][col] = mCurrentPlayer; mHistory.push(new int[]{row, col}); invalidate(); if (checkWin(row, col, mCurrentPlayer)) { // 这里通过回调通知Activity弹胜利提示,View不直接持有Dialog } } } return true; }这里我特意用Math.hypot计算点到交叉点的距离,而不是只判断单轴误差。只判断Math.abs(dx)和Math.abs(dy)的话,点按在两格之间的缝隙时会被错误分配到最近格,落子位置跟手指视觉偏差很明显。用距离判断后,有效点击半径约0.4个格子,既不会太严格也不会误触相邻交叉点。
四舍五入用Math.round而不是强转int。强转int是直接截断小数,0.9格的偏移会被归到上一格,手感和落点位置对不上。mBoard是一个int[LINE_COUNT][LINE_COUNT]的二维数组,0表示空位,1表示黑子,2表示白子。这个状态表后面悔棋和胜负判定都会用到,它是整个游戏逻辑的数据底座。
2.3 棋子状态存储:用int数组还是用集合类
游戏逻辑里需要频繁判断某个交叉点有没有棋子、是谁的棋子,还要遍历整个棋盘找某一方的所有棋子。二维数组在这种场景下是首选,下标就是坐标,定位时间是常数级,循环遍历也直观。ArrayList 只适合记录最近几步操作历史,做全盘判断时要反复contains查询,性能差且代码冗长。
private int[][] mBoard = new int[LINE_COUNT][LINE_COUNT]; private int mCurrentPlayer = 1; private Stack<int[]> mHistory = new Stack<>();数组存储带来的一个隐蔽坑是:棋盘状态是可变对象,悔棋时如果只改mBoard不同步mHistory,下一步落子历史就会错位。我的做法是每次落子时把{row, col}压入栈,悔棋时弹栈并清零对应位置,双方轮流交替的逻辑靠mCurrentPlayer切换维持。mHistory用Stack<int[]>而不是Stack ,是为了避免频繁压栈弹栈时产生对象包装开销。练手项目里这点性能差异感知不强,但写成int[]更贴近棋类程序惯用的紧凑数据结构,后面扩展成人机对战的评分搜索时,这种状态表示可以直接喂给评估函数。
3. 胜负判定与悔棋:五子棋状态机设计不翻车的两个关键点
3.1 四方向扫描的五子连珠判定:为什么八方向会重复计数
判定五子连珠的常见做法是围绕刚落下的那一手,沿着四个方向分别扫描两侧,数出连续同色棋子的总数。这里有个新手容易踩的坑:写成八个方向各扫一次,然后对每个方向单独判断是否达到5颗。这样做的结果是斜线上的同一组连子会被正向反向各算一遍,五次连珠计数变成两倍。判断结果碰巧也对,但一旦把计数逻辑复用去做带权重的棋型评估,错误就会被放大,AI会误以为斜线棋型比实际强得多。
private 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 + countDirection(row, col, player, dir[0], dir[1]) + countDirection(row, col, player, -dir[0], -dir[1]); if (count >= 5) { return true; } } return false; } private int countDirection(int row, int col, int player, int dr, int dc) { int count = 0; int r = row + dr; int c = col + dc; while (r >= 0 && r < LINE_COUNT && c >= 0 && c < LINE_COUNT && mBoard[r][c] == player) { count++; r += dr; c += dc; } return count; }四个方向数组分别代表水平、垂直、主对角线、副对角线。每个方向用正负两个增量把两侧都扫一遍,count初始为1是因为当前落下的这颗棋子本身就是连子的一部分。这里的边界检查不能省,从棋盘角落落子时,dir[1]为-1会立刻发生数组越界,while条件里四个比较就是在拦截这类情况。
我一般会把判定逻辑从View里拆成一个独立的GobangRule类,返回boolean的同时记录是哪条方向线达到了五连。这样单元测试可以直接构造规则对象反复调用,完全不需要UI环境。后面第六节要写的测试用例,就是建立在把判定逻辑抽出来的基础上。Android Studio里用Refactor > Extract Class或Extract Method都能帮你完成这个拆分,手动敲一遍也不过几十行代码。
3.2 悔棋的栈结构设计:双人模式和AI模式下如何区别处理
悔棋的通用做法是维护一个落子历史栈,悔棋时弹出一手,把对应位置清零,再切换当前玩家。双人对弈时按下一手悔一步的逻辑走即可。但如果你打算在同一个View里既支持双人又支持人机对战,悔棋规则就得区分:人机模式下只允许用户悔棋,悔棋后AI的最后一手也要跟着弹掉,否则轮到AI继续计算时,它还在用已经删除的棋盘状态做评估。
public void undo() { if (mHistory.isEmpty()) { return; } int[] lastMove = mHistory.pop(); mBoard[lastMove[0]][lastMove[1]] = 0; mCurrentPlayer = 3 - mCurrentPlayer; if (mIsAiMode) { // 玩家悔棋后,AI的上一手一并弹掉 if (!mHistory.isEmpty()) { int[] aiMove = mHistory.pop(); mBoard[aiMove[0]][aiMove[1]] = 0; mCurrentPlayer = 3 - mCurrentPlayer; } } invalidate(); }这里mCurrentPlayer取值1和2,用3 - mCurrentPlayer实现轮流切换。AI模式下玩家悔棋时,栈顶其实是AI刚下的那手,用户想回到的是自己上一次落子之前的局面,所以要连续弹两手。如果只弹一手,界面上会看到AI的棋子消失而自己的棋子还在,下一轮AI又会在残缺状态上落子,局面直接错乱。这个细节是很多人做了双人模式后加上AI发现悔棋失灵的原因。
悔棋按钮是否可用的判断同样依赖栈的empty状态。可以做成View的回调,让Activity根据undo方法的返回值更新按钮的enabled状态,避免栈空时点击毫无反应给用户造成困惑。我在项目里习惯把这种状态回调集中放在一个OnGameStateChangeListener接口里,胜利、悔棋不可用、棋盘已满都通过它通知外层,View始终只负责绘制和状态变更。
3.3 人机对战的评分策略:从落子点到四方向棋型扫描
人机对战常见的实现是让AI遍历所有空位,对每个空位按四个方向扫描棋型,黑棋和白棋各给一套权重,再把得分汇总取最大值。这个思路本质上是把上一节的方向扫描复用起来,只是从判断五连变成统计活三、活四、冲四等棋型。练手阶段不需要做深度学习或者蒙特卡洛树,一个带权重的贪心评分就能让AI有还手之力。
private int evaluatePoint(int row, int col, int aiPlayer) { if (mBoard[row][col] != 0) { return -1; } int humanPlayer = 3 - aiPlayer; int score = 0; int[][] directions = { {0, 1}, {1, 0}, {1, 1}, {1, -1} }; for (int[] dir : directions) { score += countPattern(row, col, aiPlayer, dir[0], dir[1]) * 10; score += countPattern(row, col, humanPlayer, dir[0], dir[1]) * 8; } return score; }防守权重放在进攻权重的0.8倍左右,AI会更激进地找自己的成五机会,同时不会完全无视对手的威胁。这里的人机对战做到一次搜索一层就够了,用户连走两步骗AI不看后续的手段先不处理,后续想加强可以给评分加上两步以上的深度。评分函数跑在UI线程时,15路棋盘全空格遍历一次的耗时在毫秒级,不会卡顿,但如果加了递归搜索,就要考虑放到后台线程避免掉帧。
4. Android Studio环境与资源避坑:安装、中文设置与资源重复错误排查
4.1 安装与中文设置的两个高频坑
现象一:下载了最新预览版,安装中文插件后IDE启动崩溃,或者菜单栏出现乱码。原因:预览版的插件接口与稳定版不一致,JetBrains官方中文语言包发布节奏跟不上预览版的更新频率。解决:卸载预览版,到Android Studio官网下载正式渠道的稳定版,安装完成后再装语言包。安装教程里常见的第一步先装JDK,在新版Studio里其实可以省略,IDE自带了OpenJDK,命令行里java -version不是必需项。
现象二:新装的Android Studio界面全英文,想设置中文却在Settings > Appearance里找不到语言选项。原因:新版的中文支持改成了插件机制,不再提供全局语言切换下拉框。解决:打开File > Settings > Plugins,在Marketplace里搜索Chinese,安装JetBrains官方的Chinese Language Pack,重启IDE后菜单就变成中文了。设置中文后代码、日志和异常堆栈仍保持英文,这不会影响搜索问题。
版本选择上我的建议是不要追求最新。Android Studio本身的更新节奏很快,插件、Gradle插件版本、SDK三者之间的兼容关系在稳定版上最稳妥,老一点的3.5版本打开旧工程反而容易,新版本强依赖新Gradle插件版本,移植老项目时升级成本更高。
4.2 资源重复错误的定位与修复
现象:编译报Duplicate resources,说多个文件定义了同一个资源名称,点击报错跳转到两个不同的values目录或drawable副本。原因:多半是拷贝工程时把build目录和src下的res一起带了进来,或者两个模块的包名相同导致资源合入时冲突。如果直接改资源名,改完发现还有十几个类似错误,明显不是逐个改名的局面。
解决路径分两步。先在项目根目录执行Clean Project,删除所有build中间产物,再执行Rebuild Project让AS重新生成一遍。清理后仍然报错的话,打开Gradle面板运行mergeDebugResources任务,日志里会列出每个同名资源的来源路径,顺着路径能看到是include的模块重复了,还是res目录下存在带旧文件名的副本。删除重复文件的同时检查settings.gradle里include的模块路径是否指向了同一个文件夹。
这个问题在移植Android Studio项目时出现率极高,尤其是从压缩包解压、从旧电脑复制整个工程目录的场景。养成新建工程后用Git管理、不把build目录纳入版本控制的习惯,能从根源上避开大半资源重复问题。
4.3 自定义组件在布局预览里显示失败
现象:在XML布局里写下GobangView的全限定类名,Design预览面板显示ClassNotFoundException或者渲染异常,但真机运行完全正常。原因:Android Studio的Preview渲染使用的是LayoutLib,它不会完整执行你应用的初始化逻辑,一旦自定义View的构造方法里访问了SharedPreferences、读取了资源文件,或者依赖了某个只在真机环境才有的服务,预览就会崩。
解决:给自定义View至少提供(Context, AttributeSet)双参构造方法,构造方法里只做画笔创建这类纯内存操作。资源初始化挪到onSizeChanged或者onAttachedToWindow里执行。同时在XML布局根节点上声明xmlns:tools命名空间,并给根布局加上tools:context指定对应的Activity,Preview才能拿到正确的主题和上下文。
如果这些都做了预览还是显示Exception,可以关掉Design页切到Split模式,直接用模拟器验证界面。Preview渲染本身就有一些已知稳定性问题,与其反复刷新纠结,不如靠真机调试。这里的状态也很玄学,有时候改一行无关代码,Preview就自己好了,多数是LayoutLib缓存没刷新的问题。
5. 移植与重构:把旧五子棋工程迁移到新版Android Studio
5.1 Gradle与SDK版本匹配:三个文件决定能不能Sync
从师兄那里拷来的五子棋工程,或者自己在旧版本IDE里建的工程,用新版Android Studio打开后最常见的现象是Gradle Sync一直转圈或者直接报错。问题基本集中在三处:gradle-wrapper.properties里的distributionUrl、工程级build.gradle声明的Android Gradle Plugin版本、app模块里的compileSdk和targetSdk。新版AS打开老工程时会提示升级,点名要你把Gradle插件版本升上去,但你一点升级就要承受依赖项兼容性变动的后果,我一般选择让老工程保持原样,只把distributionUrl里的Gradle版本升到当前AS支持的范围内。
// gradle-wrapper.properties distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip // 工程级build.gradle buildscript { repositories { google() mavenCentral() } dependencies { classpath "com.android.tools.build:gradle:7.3.1" } }老工程里常见的jcenter()仓库已经停止服务,Sync报错时先检查是否还挂着jcenter()。google()和mavenCentral()是新版AS的默认配置,缺失时AndroidX库根本拉不下来。修改完这三个文件后让AS重新Sync,通过后再看模块级build.gradle里有没有老旧的ndk配置或者废弃的compile写法,这些会在编译阶段才暴露。如果是3.5版本时代的老工程,还会遇到appcompat版本过低导致的资源链接失败,把androidx相关的依赖统一升到当时AS自动生成的版本即可。
5.2 抽取方法快捷键与代码整理:让逻辑判断和绘制分离
移植过来的自定义View如果写了三四百行,你迟早要重构。android studio的抽取方法快捷键是搜索长尾里的高频需求:选中一段逻辑,按Ctrl+Alt+M打开Extract Method对话框,输入方法名就能把选中块抽出去,macOS对应Cmd+Option+M。抽取的位置很有讲究,我把触摸事件里的坐标映射抽成toBoardPosition,把胜负判定抽成checkWin,把悔棋抽成undo,抽完后onTouchEvent只剩十几行,可读性立刻改善。
private int[] toBoardPosition(float x, float y) { int col = Math.round((x - mMargin) / mCellSize); int row = Math.round((y - mMargin) / mCellSize); return new int[]{row, col}; }抽取方法时IDE会自动分析局部变量的引用关系,如果某段代码修改了外部变量,它会提示你先封装成返回值或调整代码结构。这是很好的代码质量信号,说明那块逻辑状态耦合紧密,本来就应该拆出来。抽取完以后跑一遍对局,确认识别、落子、悔棋行为和重构前完全一致,再继续下一步。不要想着一次把整个类重构到位,一次抽一个方法,每步都能编译运行,比大改之后黑匣子式排错省太多时间。
5.3 资源与依赖项整理:重复res、无用依赖和lint检查
重构完代码之后我会过一遍app模块的res目录,删掉drawable-v24这类重复副本,检查values里有没有同名但不同内容的字符串资源。这两个问题在移植工程里极其常见,原因和4.2节说的一样,大多来自带build目录拷贝工程。用Clean Project清理一次,再执行Analyze > Inspect Code跑一次Lint检查,能发现未使用资源、重复资源引用和硬编码尺寸。Lint报告里有一项HardcodedText很实用,它会标记布局文件里直接写的文字,这些文字是应该在strings.xml里管理的。
硬编码尺寸也是值得处理的一项。棋子半径如果直接写死在代码里,换屏幕密度后棋子大小比例会失调,抽成dimen资源能让同一套代码在不同设备上表现一致。Lint默认只报错不阻断编译,但我会把新发现的未使用资源手动删掉而不是点击一键移除,因为一键操作有时会误删通过反射加载的资源,运行时才发现问题。这类问题属于典型的翻车点,日志不直观,排查起来耗耐心,养成小步提交的习惯能少踩很多坑。
6. 用单元测试与手势回放验证五子棋逻辑:最省时间的一个调试技巧
6.1 用JUnit写胜负判定的边界用例
判定逻辑抽成GobangRule类之后,就可以脱离UI写JUnit测试了。五子棋判定的边界主要在这些位置:棋盘四条边和四个角的落子、横竖斜四个方向的成五、四子时不能误判、中间有断子时不能连算。
@Test public void horizontalFiveInARow_onEdge_shouldWin() { GobangRule rule = new GobangRule(); for (int i = 0; i < 5; i++) { rule.place(0, 10 + i, 1); } assertTrue(rule.checkWin(0, 14, 1)); } @Test public void diagonalFourWithGap_shouldNotWin() { GobangRule rule = new GobangRule(); int[][] moves = { {7, 7}, {8, 8}, {9, 9}, {11, 11} }; for (int[] m : moves) { rule.place(m[0], m[1], 2); } assertFalse(rule.checkWin(11, 11, 2)); }第一个用例验证横线贴边走五连仍然判定胜利,这是边界条件里最容易漏掉的情况。第二个用例故意在斜线中间留一个空位,模拟四颗同色但有间断的盘面,确保countDirection只沿空位后的断层继续扫,不会把跨空位的棋型误算。运行测试用Gradle面板里app模块的testDebugUnitTest任务,断言失败会把实际count值打印出来,方便定位是方向数组写错还是边界条件漏了。
6.2 手势回放:把触摸事件批量喂给View做UI回归
单元测试覆盖了规则逻辑,但触摸映射和绘制效果没法直接用JUnit验证。我的替代方案是把一次对局过程中收到的MotionEvent坐标保存下来,在代码里用dispatchTouchEvent逐个回放给GobangView,同时用日志打印每次落子的行列和判定结果。这个做法能稳定复现某个bug,不用每次手动点击十几步去触发一条特定路线。
List<MotionEvent> replayList = new ArrayList<>(); // 回放时逐个转发 for (MotionEvent event : replayList) { gobangView.dispatchTouchEvent(event); }dispatchTouchEvent和onTouchEvent的区别在于它会走完整的触摸事件分发链路,包括父View的拦截逻辑,比直接调用onTouchEvent更接近真实操作。回放用的MotionEvent需要从原事件流里拷贝坐标和时间戳,用MotionEvent.obtain(originalEvent)浅拷贝即可,回放完要调用recycle避免对象复用引发的问题。以前我做五子棋时,把判定函数写成了八方向独立判断,斜线连五弹了胜利提示但落子状态没同步,排查半天。后来才发现是坐标换算里减边距的顺序写反了,棋子全画偏了一格。自那以后我养成了先把规则抽出来单测、再回放手势验证的习惯,调试时间缩短了一半。如果你也准备在Android Studio里做五子棋,建议从一开始就把GobangRule独立出来,别让判定逻辑埋在View里,希望帮到你。
本文还有配套的精品资源,点击获取