
简介面向Android初学者的贪吃蛇小游戏开发资源以经典游戏为载体系统演示了Activity/SurfaceView界面搭建、Canvas图形绘制、触摸与按键事件处理、蛇身移动与碰撞检测的游戏逻辑、后台线程刷新机制等关键知识点可作为Android游戏开发入门的配套练习项目。压缩包共1673个文件大小15.45MB以java/kotlin源码、gradle构建配置、xml界面资源、json配置及编译生成的dex/class/jar、apk安装包为主目录中包含完整工程结构和构建产物便于直接导入Android Studio查看运行。已有1236人学习下载。资源内含完整的Android贪吃蛇项目源代码与资源文件覆盖从游戏主循环、蛇与食物绘制、分数与状态管理到暂停恢复的完整实现对照代码可深入理解Android图形渲染与事件分发机制也可在此基础上扩展关卡、音效等功能适合新手边看边练。 直接投入时间写一篇干货向的实践总结吧。这次我会围绕“Android 贪吃蛇”这个项目把从数据结构选型、SurfaceView 绘制、方向控制、碰撞检测到游戏循环的整套逻辑拆开讲穿插很多我在实际开发中踩过的坑和优化心得。1. 项目整体设计与思路拆解1.1 为什么选贪吃蛇作为 Android 练手项目很多人在学 Android 开发时习惯照着教程撸一遍计算器或者 TodoList但做完之后往往感觉什么都没留下。贪吃蛇不太一样它虽然规则简单却几乎覆盖了自定义 View、事件分发、算法设计、状态管理、资源适配这几个 Android 开发的核心模块而且游戏本身有明确的反馈机制改一处代码立刻能感受到效果学起来不枯燥。从功能拆解来看一个完整可玩的贪吃蛇需要解决这么几个问题地图与蛇身的绘制方案、蛇的移动与转向逻辑、食物随机生成、碰撞检测撞墙和撞自己、游戏状态管理运行中、暂停、结束。每个点单独拎出来都不难但组合在一起就非常考验代码组织能力。如果一开始就抱着“能跑就行”的心态乱写很快就会发现逻辑纠缠在一起加一个功能就要改三处地方。1.2 技术选型View 还是 SurfaceView这是贪吃蛇项目里第一个需要做的决定。网上不少教程直接用自定义 View 加invalidate()刷新代码确实简洁但问题在于invalidate()只能在 UI 线程调用而且刷新时机受系统 VSync 信号控制一旦游戏逻辑复杂一点或者界面卡顿就会出现蛇身一顿一顿的情况。我最终选择了SurfaceView 子线程刷新的方案。SurfaceView的优点在于它拥有一块独立的绘图表面可以在子线程中任意时刻进行绘制不会阻塞主线程的 UI 操作。代价是需要自己管理线程生命周期和同步锁但这一点对于提升游戏整体流畅度和帧率稳定性非常有帮助。如果你只是写个 demo 交作业用 View 就够了但如果想让游戏运行得更顺滑、后续加功能不憋屈我建议直接上 SurfaceView。提示使用 SurfaceView 时一定要在surfaceDestroyed()中及时停止子线程否则 Activity 销毁后线程还在跑轻则内存泄漏重则直接崩溃。2. 核心细节解析与实操要点2.1 数据结构选型为什么不用数组而用双向链表贪吃蛇移动的本质是“尾节点移除头节点新增”。用数组实现这一过程最直接的方法是移动后把每个节点往后移一位再把新坐标赋给头部时间复杂度是 O(n)n 是蛇长。听起来还能接受但蛇身长到几十格之后每帧都要做大量数组元素的移位操作加上游戏还要处理绘制、检测、食物生成等逻辑帧率会有明显下降。正确选择是使用双向链表。每次移动时只需要让尾部节点“跑到”头部前面更新它的坐标再把它设为新头节点时间复杂度降为 O(1)。虽然 Java 里LinkedList可以直接用但我更推荐手写一个简单的链表或者用数组加头尾指针模拟因为这样你才能真正理解“蛇在移动时到底发生了什么”出了问题也能很快定位。蛇身移动的核心逻辑是这样的每次移动前先记住当前尾巴节点然后根据方向更新头部坐标创建新节点加入头部最后再把原来的尾巴节点从链表尾部移除。由于贪吃蛇的各个节点在视觉上是同一颜色的方块连成一条线所以“移除尾巴再添加头”这种操作在视觉上就表现为整条蛇向前移动了一格。2.2 方向处理最容易翻车的两个细节方向控制是贪吃蛇项目里最容易出 Bug 的地方主要问题集中在两点。第一是“反向操作”问题。当蛇正在向右移动时如果玩家快速按了左方向而你的代码没有做任何拦截蛇就会直接掉头撞到自己身体这在真实游戏中几乎等于自杀。正确的做法是维护一个“当前方向”变量每次收到新的方向指令时先判断新方向与当前方向是否是相反方向如果是就直接忽略这次输入。第二是“同帧多次按键”问题。玩家可能在同一个游戏循环周期内连续按下两次方向键比如先按上再按左。如果每次按键都立即修改方向那么本来应该先向上走一步、再向左走一步结果变成了蛇以“当前方向为左”的状态直接向左走了中间的向上动作被吞掉了。这个问题在手机触屏上尤其常见因为手指点击速度远快于游戏帧率。我的处理方式是维护一个“方向队列”。每次按键只把新方向加入队列游戏循环每次只从队列头取出一个方向应用到下一次移动然后移除。这样既能保证输入不丢失也不会出现一帧内方向被连续修改导致逻辑错乱的情况。2.3 食物生成与随机性处理食物生成看似简单就是随机取一个格子坐标实则有两个坑。第一个坑是随机位置可能生成在蛇身上。虽然这种情况的概率随着蛇变长而增大但如果只做一次随机就草率决定很可能刷出一个“食物在蛇肚子里面”的尴尬局面。解决办法是生成后遍历链表检查冲突冲突就重新随机直到生成一个空闲格子为止。更高效的做法是维护一个当前空格子集合从这个集合里随机取一个但考虑到贪吃蛇的地图通常不会很大比如 20x20简单的“随机冲突重试”完全够用。第二个坑是“食物不可达”问题。当蛇身很长时地图上可能只剩下一个空格子而这个空格子刚好处在蛇头下一步必经之路之外游戏无论如何都无法吃到食物。这种情况通常在专业游戏中会给出新的游戏状态比如胜利但对于练手的项目直接把地图做大一点比如 30x20就基本不会遇到这个问题了。2.4 碰撞检测的三层检查碰撞检测是整个游戏判断逻辑的核心我把它拆成了三层第一层是边界碰撞蛇头坐标是否超出地图范围x 0 或 x 列数y 0 或 y 行数。超出即游戏结束。第二层是自体碰撞蛇头坐标是否与身体任意一个节点坐标相同。这里有一个细节蛇身最后一个节点尾巴在移动时会被移除所以比较时不需要包含尾巴节点否则会出现移动后“莫名死亡”的误判。第三层是食物碰撞蛇头坐标是否与食物坐标相同。相同则蛇长度加一分数增加重新生成食物这一步的顺序很重要必须在移动逻辑完成之后执行。三层检查的顺序不是随意的。边界检查和自体检查一旦触发就意味着游戏结束必须放在最前面食物检查放在最后因为它属于正常事件的触发点。如果把食物检查放在边界检查前面会出现蛇头已经越过边界但食物恰好生成在边界外一格导致玩家看到蛇“出界却没死”的诡异画面。3. 实操过程与核心环节实现3.1 搭建游戏循环固定时间步长 vs 可变时间步长游戏循环的设计直接决定手感。最简单的方案是死循环里不断刷新每次刷新之间用Thread.sleep()控制帧率。这种方式实现简单但存在一个致命问题——不同手机的屏幕刷新率和系统调度差异会导致实际移动速度不同同一条蛇在不同设备上跑起来速度不一样。我采用的方案是固定时间步长加累积器。基本思路是设定一个固定的时间步长比如 100ms也就是每秒移动 10 格每帧累加真实流逝的时间当累积时间超过步长时就执行一次游戏逻辑更新然后绘制。这样无论屏幕刷新率是 60Hz 还是 120Hz蛇的实际移动速度都稳定在每秒 10 格。游戏难度可以通过调整步长来实现。每吃到一个食物就把步长缩短几个毫秒比如从 200ms 逐渐降到 80ms游戏速度会平滑提升比突然加速带来的体验好得多。步长的最小值建议控制在 50ms 左右再快的话手指跟不上了难度曲线会急剧上升反而失去了可玩性。3.2 核心代码结构Activity SurfaceView GameThread我推荐把项目拆成三个类MainActivity负责界面与生命周期管理GameView继承 SurfaceView 并实现 SurfaceHolder.CallbackGameThread继承 Thread 负责运行游戏循环。这样职责清晰后续扩展功能比如加暂停按钮也不会动到核心逻辑。GameView的核心方法有这几个initGame()初始化游戏状态蛇的位置、方向、食物、分数update()更新游戏逻辑移动蛇、检测碰撞、处理食物render(Canvas canvas)绘制所有元素背景网格、蛇身、食物、分数handleTouchEvent()处理触摸输入改变方向、开始/暂停游戏。下面给出一个简化版的核心代码骨架方便读者理解整体流程public class GameThread extends Thread { private final SurfaceHolder holder; private boolean running; private long lastTime System.currentTimeMillis(); private long stepTime 100; // 毫秒 Override public void run() { while (running) { long now System.currentTimeMillis(); long elapsed now - lastTime; lastTime now; if (elapsed stepTime) { synchronized (holder) { gameView.update(); Canvas canvas holder.lockCanvas(); if (canvas ! null) { gameView.render(canvas); holder.unlockCanvasAndPost(canvas); } } // 根据食物数量动态调整速度 long newStepTime Math.max(80, 200 - gameView.getScore() * 5); stepTime newStepTime; } else { try { Thread.sleep(stepTime - elapsed); } catch (InterruptedException e) { // 线程中断通常是界面销毁 } } } } }这段代码里有几个细节值得注意。第一lockCanvas()返回的 Canvas 可能为 null必须判空。第二Thread.sleep(stepTime - elapsed)的作用是让线程在未达到步长时间时休眠减少 CPU 空转省电也降低发热。第三running标志位是 volatile 的确保多线程环境下能正确感知状态变化。3.3 地图绘制网格对齐是观感基础贪吃蛇的地图绘制看起来简单但一个常见的视觉问题是蛇身移动时会出现位置偏移仔细一看原来是网格没有对齐。要解决这个问题最好的办法是给地图做精确的像素计算。我采用的是固定格子大小加边距偏移的方式。假设每个格子是 32x32 像素地图宽度为 20 格那么地图总宽度就是 640 像素。在onMeasure或初始化时计算屏幕宽高确定地图的起始坐标居中放置绘制时所有元素的像素坐标都通过格子坐标 * 格子大小 偏移量来换算保证蛇身、食物、边界线完全重合在同一套网格上。绘制蛇身时我会给每个方块添加 1 像素的内边距这样蛇身在视觉上会有明显的格子感而不是一整块没有缝隙的色块。这个细节虽然小但对观感影响很大很多人做出来的贪吃蛇看起来“怪怪的”多半就是因为没做这个处理。3.4 触摸控制滑动还是点击触控方案有两个方向点击按钮和滑动屏幕。滑动屏幕更符合现代手机的操作习惯但实现起来需要处理 ACTION_DOWN 和 ACTION_UP 两个事件的坐标差判断滑动方向还要设定最小滑动距离阈值比如 50 像素来过滤微小抖动。我这里用了滑动控制方案。核心逻辑是记录 ACTION_DOWN 时的坐标ACTION_UP 时计算两个坐标的差值取绝对值更大的那个方向作为滑动方向然后调用changeDirection()函数。要注意的是每次滑动只应改变一次方向所以不要在 ACTION_MOVE 中反复触发方向修改。public boolean onTouchEvent(MotionEvent event) { switch (event.getAction()) { case MotionEvent.ACTION_DOWN: startX event.getX(); startY event.getY(); break; case MotionEvent.ACTION_UP: float deltaX event.getX() - startX; float deltaY event.getY() - startY; if (Math.abs(deltaX) 50 || Math.abs(deltaY) 50) { if (Math.abs(deltaX) Math.abs(deltaY)) { gameView.changeDirection(deltaX 0 ? Direction.RIGHT : Direction.LEFT); } else { gameView.changeDirection(deltaY 0 ? Direction.DOWN : Direction.UP); } } break; } return true; }在changeDirection()内部我把方向指令加入一个队列而不是直接修改当前方向这样做的好处是避免了同帧多次按键产生的输入丢失问题。队列最大长度设为 3超过直接丢弃防止玩家快速乱滑导致队列膨胀游戏逻辑反而处理不过来。4. 常见问题与排查技巧实录4.1 游戏卡顿线程与 UI 不同步表现是游戏运行一段时间后画面开始闪烁、拖影甚至直接黑屏。排查后发现是lockCanvas()获取的 Canvas 被多个线程同时使用或者线程没有正确同步。SurfaceView 的绘制线程和主线程是分开的如果两个线程同时对画布进行操作就必然出现资源竞争。解决方法是在线程中使用synchronized块包裹update()和lockCanvas()/unlockCanvasAndPost()操作保证同一时刻只有一个线程在提交绘制内容。我在代码里已经展示过了。这里的synchronized (holder)是关键SurfaceHolder是所有线程共享的锁对象用它做同步可以保证操作互斥。4.2 线程泄漏Activity 销毁后游戏还在跑新手最容易犯的错误是在onDestroy()中没有停止子线程。当我开发第一版时旋转屏幕或者退出应用后后台线程依然在刷帧界面虽然看不到但内存和电量都在持续消耗。标准做法是在SurfaceHolder.Callback的surfaceDestroyed()回调中设置running false然后join()等待线程结束。这里有个坑如果游戏循环中使用了Thread.sleep()join()可能会等很久因为线程正在休眠中。解决办法是在surfaceDestroyed()中interrupt()线程让sleep()立刻抛出InterruptedException从而快速退出循环。4.3 分数和速度显示漂移我在做分数显示时遇到过一个问题——分数文本在蛇移动时会出现残影。原因是没有在重绘前清空画布。SurfaceView 不像普通 View 会自动清屏它保留上一帧的内容所以每次绘制前必须先用背景色填充整个画布。此外分数文本如果不设置抗锯齿在部分低分辨率设备上会显示为毛边。解决很简单绘制文本时设置Paint.setAntiAlias(true)以及用Paint.setTextSize()和Paint.setColor()明确设置字体大小和颜色避免使用系统默认值在不同设备上表现不一致。4.4 触摸方向误触斜向滑动没反应有用户反馈快速斜向滑动时蛇不转向。原因是我的方向判断逻辑只比较 X 和 Y 的绝对值但如果两个值的差值很小比如对角线滑动方向判断就会犹豫不决系统默认取 X 方向用户以为自己在斜向上滑结果蛇却水平走了。优化方案是增加方向判定阈值比例只有当一个方向的滑动距离大于另一个方向的 1.5 倍时才认为是该方向否则忽略这次滑动。这样既减少了误触也避免了对角线滑动的歧义体验更接近真实游戏。4.5 速度调节与帧率控制的平衡很多人在调节速度时直接修改stepTime的值导致的问题是速度突然变化时蛇会出现一卡一卡的情况。原因在于速度变了但累积的时间并没有相应调整下一帧刷新时可能同时触发了两次移动逻辑。我的做法是只有在游戏循环的下一次迭代开始时才读取新的stepTime值并且每次只调整很小的幅度比如 5ms这样速度变化是渐进的不会出现跳变。另外速度调节时最好同步调整累积时间否则会出现“这次的循环多了 20ms下次只有 50ms”的波动视觉上表现为蛇一小格一小格地抽搐。5. 扩展玩法与优化方向5.1 加入分数排行榜与本地持久化当基础玩法跑通之后下一步自然的扩展是加入排行榜功能。这个功能主要涉及本地数据持久化最简单的方案是用SharedPreferences保存最高分或者用 SQLite 存储多局历史记录。用SharedPreferences只需要保存一个整数代码量很少适合入门如果想展示多局排名就建议 SQLite界面可以加一个简单列表。5.2 音效与动画反馈没有声音的贪吃蛇总感觉少了点什么。添加音效的方式很简单在吃到食物、死亡、转向时播放不同的音效用MediaPlayer或SoundPool实现。SoundPool更适合短音效延迟低不会因为加载而卡顿。但要注意音效文件的体积剪辑成 100KB 以内的短音频避免 APK 体积膨胀。5.3 道具系统与障碍物如果再挑战一点可以加入食物道具比如加速道具、减速道具、反向控制道具等。反向控制是这个游戏中非常有趣的玩法它要求玩家在道具生效期间反向操作方向键打破了玩家的惯性操作可玩性瞬间提升。但这一步涉及游戏状态机的扩展建议在基础版本稳定之后再实现。5.4 暂停与恢复暂停是手机游戏的刚需尤其是一局玩到一半突然来电话、来消息的情况。暂停功能的核心是保存当前游戏状态并停止游戏循环同时把步长累积时间清零否则恢复运行时会出现一次“跳步”现象。另外暂停时最好弹出一个半透明的遮罩层避免玩家误触导致恢复后立即转向。写在最后的实操心得从零开始做一个 Android 贪吃蛇我最大的体会是这个项目麻雀虽小五脏俱全它逼着你去思考代码的边界问题、线程安全问题、状态管理问题这些都是平时看教程学不到的东西。真正把每一行代码都吃透比浮光掠影地刷十个项目都管用。如果你打算自己写一遍我建议按照这个顺序来先把蛇能移动起来再解决转向和碰撞然后加食物和分数最后才优化流畅度和扩展玩法。不要一上来就想做得多完善一个小功能一个小功能地叠加每个阶段都能玩、能测、能发现问题这才是最理想的上手节奏。还有一个小技巧想分享给大家——游戏开发中遇到状态相关的 Bug不要急着打日志先在纸上画出状态转移图把每种情况下应该发生什么写清楚再对照代码去查。这个小习惯帮我避开了很多隐蔽的 Bug对以后做更复杂项目的帮助也很大。本文还有配套的精品资源点击获取