ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Android原生贪吃蛇实战:自定义View与事件分发核心解析

Android原生贪吃蛇实战:自定义View与事件分发核心解析 简介这是一份面向安卓初学者的贪吃蛇小游戏完整工程涵盖图形绘制、事件处理、游戏逻辑、线程管理、状态保存与恢复、动画效果等安卓游戏开发核心知识点。压缩包共1673个文件包含16个Java源文件、16张PNG图标、多个APK、DEX、CLASS、JAR资源以及Gradle构建配置、JSON配置和XML布局文件可支撑从源码阅读、编译调试到安装运行的全流程学习。包体约15.45MB文件类型覆盖Java/Kotlin代码、构建脚本、资源描述与Git配置体现了一个真实安卓工程应有的目录结构已有1236人浏览学习。资源除了可直接运行调试的工程外还附有kotlin_module、properties、gitignore等辅助文件对理解安卓项目构建、依赖管理也有一定帮助。作为入门级小游戏实例适合配合博客讲解逐段阅读Java代码动手实践SurfaceView绘图、手势操控、碰撞检测、分数统计等典型玩法与逻辑也适合作为课程设计或面试练手项目。 做Android开发有些年头了一直觉得贪吃蛇这种经典小游戏是练习基本功最好的项目之一。别看它玩法简单一个蛇身的移动、食物的随机生成、碰撞检测写下来状态管理、事件分发、Canvas绘制、自定义View这些Android核心知识全都能过一遍。所以这次我用Android Studio从零实现了一个可以在手机上跑的贪吃蛇游戏没有依赖任何第三方库全部用原生API手写。这篇文章就把完整的实现思路和编码过程分享出来适合刚接触Android开发、想用一个小项目验证自己是否掌握了自定义View和事件处理的朋友。1. 项目整体设计与思路拆解1.1 先拆解玩法再拆解代码贪吃蛇这个游戏剥开所有包装核心就三件事蛇按照定时器节奏移动、吃到食物后蛇身变长、撞墙或撞到自己游戏结束。这三个逻辑对应到代码层面分别是游戏循环、数据结构操作、碰撞检测。除此之外还有一个经常被忽略的点——游戏状态管理比如暂停、恢复、重新开始这直接关系到玩家体验。把这些逻辑想清楚之后我决定把整个游戏封装成一个自定义View叫GameView。它既负责绘制也负责接收触摸事件还负责维护游戏循环。之所以不拆分出Activity做大量逻辑是因为贪吃蛇的游戏逻辑并不复杂强行做成MVP或者MVVM反而显得臃肿。对新手来说一个View搞定所有事情反而更容易理解整个数据流动过程。1.2 技术选型原生View还是游戏引擎在开始写代码之前我其实犹豫过用什么方案。市面上的做法无非几种用SurfaceView做专门的游戏绘制、用自定义View的onDraw做绘制、甚至用OpenGL或者游戏引擎比如libGDX、Unity。贪吃蛇的绘制强度非常低一格一格的方块刷新几十毫秒刷新一次普通View的onDraw完全能扛住没必要引入SurfaceView增加复杂度。游戏引擎就更不用说了为一个几十KB的游戏引入上百MB的引擎属于杀鸡用牛刀。最后我的选择是自定义View Handler定时循环所有绘制交给onDraw触摸用onTouchEvent处理。1.3 目录结构与模块划分我把代码分成了三个主要文件GameView.java负责绘制和游戏逻辑MainActivity.java负责承载View并处理系统交互比如返回键、生命周期还有Config.java存放格子数、刷新速度这类可配置参数。这样做的好处是以后想调整游戏难度只需要改Config不用去翻业务代码。比如把COLS从20改成30棋盘密度完全变化难度也随之改变把初始速度从200毫秒改成100毫秒游戏节奏立刻快一倍。所有可变参数集中管理对后续扩展也友好——如果想加入更高难度等级只需要在Config中预置多组配置切换时一键生效。2. 核心细节解析与实操要点2.1 蛇身的数据结构选择蛇身其实是一串连续的格子坐标。我一开始用的是ArrayList 后来发现如果频繁删除头部插入尾部ArrayList会有数组拷贝的开销。虽然贪吃蛇的蛇身长度最多也就几十个点这点开销可以忽略但从代码语义上我更倾向于用LinkedList 头部addFirst、尾部removeLast完全对应蛇爬行的动作。但实际上最后写的时候我还是用了ArrayList因为贪吃蛇的蛇身长度上限也就棋盘格子数对移动端来说根本构不成性能瓶颈而ArrayList在绘制时需要随机访问反而效率更高。这一点算是个取舍优先考虑语义还是优先考虑绘制访问。在实际工程里这种细节往往会反复权衡我的体会是——性能问题要用数据说话不要靠感觉。2.2 方向控制滑动手势的正确处理方向控制是所有新手最容易踩坑的地方。常见的做法是在onTouchEvent里记录按下点和抬起点然后计算差值判断滑动方向。这里有一个关键问题滑动方向的坐标系。Android的坐标原点在屏幕左上角x轴向右为正y轴向下为正。所以向右滑动时dx0向下滑动时dy0。很多初学者在这里把方向搞反就是没注意坐标系方向。注意方向控制的“反向拦截”一定要在updateDirection里做而不是等到update()里再做。如果等到蛇真正移动时才发现方向冲突可能已经错过了一帧蛇已经回头了。另外一个非常经典的坑是蛇正在向右移动时玩家快速向左滑这时候如果代码没有做“方向与当前方向相反则忽略”的拦截蛇就会直接180度掉头一头撞进自己身体里游戏立刻结束。所以我在处理方向时专门加了一个判断只有新方向与原方向不构成直线反向时才更新方向。这里建议用枚举或者常量来表示上下左右四个方向逻辑会清晰很多。2.3 食物生成随机数必须落在非蛇身格子上食物生成的逻辑不复杂但有个细节需要注意生成的位置不能与蛇身重合。如果蛇身占了四分之一的格子每次随机在棋盘上找一个点有四分之一的概率会生成在蛇身上。直接塞到蛇身上当然不行。我的处理方式是先遍历一遍棋盘把所有未被蛇身占用的格子收集到一个列表里再从列表里随机选一个。这种做法的好处是概率均等而且不用反复重试。缺点是要额外遍历一次棋盘不过棋盘一般就几百个格子开销可以忽略。如果你玩过的贪吃蛇比较多可能会遇到一种情况食物生成在角落里蛇要绕很大一圈才能吃到。这个其实是正常现象只要食物位置是随机的就必然会出现这种局面不需要额外处理。3. 实操过程与核心环节实现3.1 项目创建与环境准备我用的是Android Studio Hedgehog版本这个版本对应AGP 8.x创建项目时直接选Empty Views Activity模板语言选Java。贪吃蛇不需要任何网络权限所以不需要在AndroidManifest里加额外权限声明。minSdk我设置为21足以覆盖绝大多数安卓设备。创建完成后我先把Activity的布局文件改成一个空的FrameLayout然后把GameView在onCreate里动态添加进去。不直接在XML里写GameView标签的原因是我想在Java代码里持有View引用方便处理生命周期等逻辑。3.2 核心类GameView的实现先把关键代码放出来下面是GameView中核心部分public class GameView extends View { private LinkedListPoint snake; private Point food; private int direction DIRECTION_RIGHT; private int nextDirection DIRECTION_RIGHT; private boolean isRunning false; private Handler handler new Handler(Looper.getMainLooper()); private Runnable gameLoop new Runnable() { Override public void run() { update(); invalidate(); handler.postDelayed(this, speed); } }; Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); drawBackground(canvas); drawFood(canvas); drawSnake(canvas); } private void update() { // 应用最新方向 direction nextDirection; Point head snake.getFirst(); Point newHead new Point(head); switch (direction) { case DIRECTION_UP: newHead.y--; break; case DIRECTION_DOWN: newHead.y; break; case DIRECTION_LEFT: newHead.x--; break; case DIRECTION_RIGHT: newHead.x; break; } // 撞墙检测 if (newHead.x 0 || newHead.x COLS || newHead.y 0 || newHead.y ROWS) { gameOver(); return; } snake.addFirst(newHead); // 吃到食物 if (newHead.equals(food)) { generateFood(); speed Math.max(speed - 10, 80); } else { snake.removeLast(); } // 撞自己检测 for (int i 1; i snake.size(); i) { if (snake.get(i).equals(newHead)) { gameOver(); return; } } } }这段代码的核心逻辑在update()里先取蛇头根据方向计算新蛇头坐标加入链表头部再根据是否吃到食物决定是否移除尾部。注意我用了两个方向变量direction是当前帧实际移动方向nextDirection是玩家手势设置的目标方向。这样做的原因是一帧内玩家滑动多次时只取最后一次手势的方向同时避免在一次更新中连续改变方向导致蛇直接回头。3.3 绘制细节方块大小、网格、颜色绘制时首先根据View的宽度和高度减去padding后除以列数得到每个格子的像素尺寸。需要注意屏幕适配问题不同手机宽高比不同如果格子尺寸直接用固定像素在小屏幕手机上棋盘可能超出屏幕。我的做法是动态计算int cellWidth (getWidth() - getPaddingLeft() - getPaddingRight()) / COLS; int cellHeight (getHeight() - getPaddingTop() - getPaddingBottom()) / ROWS; int cellSize Math.min(cellWidth, cellHeight); int offsetX (getWidth() - cellSize * COLS) / 2; int offsetY (getHeight() - cellSize * ROWS) / 2;这样棋盘居中而且格子是正方形不会出现横向拉长的情况。蛇身绘制时我在每个格子内部留出2像素的间距画成圆角矩形视觉效果会好很多不至于方块之间完全连成一片。3.4 触摸事件处理与游戏状态切换onTouchEvent用来接收滑动手势同时我也处理了点击事件如果游戏处于Game Over状态点击屏幕任意位置重新开始游戏。手势判断采用按下点与抬起点坐标差值的方式为了让方向判断更准确我比较了dx和dy的绝对值取绝对值大的方向作为滑动方向Override public boolean onTouchEvent(MotionEvent event) { switch (event.getAction()) { case MotionEvent.ACTION_DOWN: startX event.getX(); startY event.getY(); break; case MotionEvent.ACTION_UP: float dx event.getX() - startX; float dy event.getY() - startY; if (Math.abs(dx) Math.abs(dy)) { if (dx 0) updateDirection(DIRECTION_RIGHT); else updateDirection(DIRECTION_LEFT); } else { if (dy 0) updateDirection(DIRECTION_DOWN); else updateDirection(DIRECTION_UP); } break; } return true; }updateDirection里需要加入反向拦截判断避免蛇掉头撞到自己private void updateDirection(int newDir) { if ((direction DIRECTION_LEFT newDir DIRECTION_RIGHT) || (direction DIRECTION_RIGHT newDir DIRECTION_LEFT) || (direction DIRECTION_UP newDir DIRECTION_DOWN) || (direction DIRECTION_DOWN newDir DIRECTION_UP)) { return; } nextDirection newDir; }游戏整体状态我用一个简单的枚举READY、RUNNING、PAUSED、GAME_OVER。READY状态下屏幕显示“点击开始”GAME_OVER显示得分和重新开始提示。Activity的onPause里调用gameView.pause()onResume里调用gameView.resume()这样切后台时游戏循环会暂停不会在用户看不见的地方空转。3.5 完整流程跑通后的表现把项目部署到真机上实测整体手感符合预期。刷新速度初始为200毫秒移动一格吃到食物后每次减少10毫秒最低80毫秒。这个速度曲线在蛇身短时感觉偏慢蛇身长起来后得心应手不会突然快得让人反应不过来。分数直接取蛇身长度画在屏幕顶部。实际操作下来玩到五六十分长度的时候速度已经比较考验反应了再配合方向反转拦截基本不会出现莫名其妙死掉的情况。4. 常见问题与排查技巧实录4.1 问题速查表我把开发过程中遇到的和在社区里经常看到的问题整理成一个表格方便遇到问题时直接对照现象原因解决思路滑动后蛇不转向或方向错乱坐标系方向判断错误dy正负号反了检查屏幕坐标系y轴向下为正下滑dy为正蛇瞬间掉头撞死没有拦截反向操作在updateDirection中判断新方向与原方向是否相反游戏切后台再回来蛇突然加速Handler的postDelayed在Activity恢复后重复执行onPause时removeCallbacksonResume重新post保证只有一个循环绘制出现闪烁在onDraw里做了耗时操作或没有做双缓冲自定义View本身有硬件加速避免频繁创建对象食物经常生成在蛇身上直接在棋盘随机格子上取点先收集空位列表再随机挑选不同手机棋盘大小不一致格子像素固定没有动态计算用View宽高动态计算格子尺寸并居中4.2 内存与性能Handler的正确使用姿势使用Handler做定时循环时最容易出问题的就是内存泄漏。匿名Runnable会持有外部View的引用如果Activity销毁时没有移除回调Handler消息队列里还挂着这个Runnable导致Activity无法被回收。解决方式就是在Activity的onDestroy里调用handler.removeCallbacksAndMessages(null)。另外如果需要在子线程中更新UIHandler必须关联主线程Looper。我的handler是用Looper.getMainLooper()创建的所以直接在Runnable里调用invalidate()是安全的。这个做法虽然简单但属于每个Android开发者都应该养成习惯的基础操作。4.3 状态保存转屏与进程被回收贪吃蛇玩到一半手机屏幕旋转Activity重建如果没做状态保存游戏直接回到初始状态。虽然这个项目是练习用的但作为一个合格的开发习惯我用onSaveInstanceState把蛇身坐标、当前方向、食物坐标、得分保存到了Bundle里Activity重建后读取并恢复。做法不复杂因为Point类没有实现Parcelable我换成了两个ArrayList 来保存x和y坐标恢复时再重新组装成LinkedList。如果不用这个办法也可以直接注释掉Activity的configChanges属性要求系统不重建Activity但这不是推荐的做法能正确保存状态才是正解。4.4 更多细节屏幕适配与沉浸式显示为了更好的游戏体验我开启了沉浸式状态栏隐藏了系统状态栏和导航栏这样棋盘可以用满整个屏幕。实现方式是在Activity的onWindowFocusChanged里设置SYSTEM_UI_FLAG_IMMERSIVE_STICKY。这个设置要注意只要用户从屏幕边缘滑动系统栏会临时出现几秒后自动隐藏不会影响游戏操作。做完这个项目我的感受是贪吃蛇虽然看起来只是一个简单的小游戏但完整做下来几乎把Android自定义View、事件分发、生命周期、内存管理这些基础知识全部串起来了。特别是方向控制和状态恢复这几个点刚开始写的时候也觉得很简单真正调起来才发现坑不少。如果你也在学Android开发强烈建议自己动手写一遍这个项目别只看教程代码只有自己敲过才记得住。最后分享一个我自己的习惯每做完一个小项目把遇到的问题和解决过程整理成笔记后面再遇到类似问题的时候翻笔记往往比重新查资料快得多。本文还有配套的精品资源点击获取
返回列表