
简介一份面向Android初学者的贪吃蛇游戏完整源码适合想入门Android游戏开发、学习SurfaceView绘图与游戏循环机制的开发者。资源虽然精简但覆盖了蛇的移动、食物随机生成、碰撞检测、游戏状态管理等核心逻辑并附有可运行APK方便边看边练。压缩包共39个文件包括4个Java源码、13个class编译文件、5个XML布局与配置、7张PNG游戏示例图以及APK、项目配置文件等整体仅204KB结构紧凑便于快速阅读与二次开发。目前已有701人学习下载。通过分析该源码可掌握Android游戏的基本框架、触摸/按键交互、资源加载和界面布局等实用技能。尤其适合在学习中尝试增加计分、难度等级或音效反馈进一步理解游戏循环与碰撞检测的细节为后续开发更复杂的游戏打下基础。1. 从一份带gen目录的旧工程说起拿到这份源码时最先注意到的不是Snake.java本身而是压缩包里保留了gen、default.properties、.settings这些在 Android Studio 时代几乎绝迹的目录。这意味着它是一份标准的 Eclipse ADT 工程而不是后来迁移过的 Gradle 项目。对学习而言这反而是好事gen目录下的R.java是构建期自动生成的你能直观看到资源 ID 和 Java 代码之间的绑定关系省去了 Gradle 按需生成的黑盒环节。核心玩法并不复杂——网格地图、方向向量、蛇身链表、碰撞判定——这几件事加起来不到三百行 Java 代码。但正因为简单它把 SurfaceView 的线程模型、触摸事件分发、状态机切换这些 Android 游戏开发的基础设施完整暴露在了你面前。适合想补 SurfaceView 和 Canvas 绘制短板的人也适合需要一份无依赖、可直接编译运行的代码来改造毕业设计的人。2. 游戏循环与 SurfaceView 画面刷新2.1 为什么是 SurfaceView 而不是 View普通View的onDraw()由主线程的 Choreographer 驱动你无法精确控制刷新频率一旦在回调里做耗时计算掉帧和 UI 卡顿会同时出现。SurfaceView不一样它独立拥有一块Surface可以开一条子线程自己控制绘制节奏。贪吃蛇对实时性的要求不高但逻辑帧率必须稳定——如果蛇移动速度受系统负载影响忽快忽慢游戏就失去节奏感了。这套源码的SnakeView继承的就是SurfaceView并实现了SurfaceHolder.Callbackpublic class SnakeView extends SurfaceView implements SurfaceHolder.Callback { private SurfaceHolder holder; private DrawThread drawThread; private boolean isRunning; public SnakeView(Context context) { super(context); holder getHolder(); holder.addCallback(this); } Override public void surfaceCreated(SurfaceHolder holder) { isRunning true; drawThread new DrawThread(); drawThread.start(); } Override public void surfaceDestroyed(SurfaceHolder holder) { isRunning false; try { drawThread.join(); } catch (InterruptedException e) { e.printStackTrace(); } } private class DrawThread extends Thread { Override public void run() { while (isRunning) { long start System.currentTimeMillis(); updateGame(); // 更新蛇位置、食物、碰撞状态 renderGame(); // 锁定 Canvas绘制 long cost System.currentTimeMillis() - start; long sleepTime 100 - cost; if (sleepTime 0) { try { Thread.sleep(sleepTime); } catch (InterruptedException e) { e.printStackTrace(); } } } } } }代码里做了核心的两层分离updateGame()只改内存里的坐标数据renderGame()用Canvas把数据画到屏幕上。两个方法都加上了耗时统计用Thread.sleep()把每一帧的物理耗时顶到 100ms——这是贪吃蛇典型的帧间隔意味着每秒走 10 格。如果你的手感偏快或偏慢调整这个值即可不需要动任何逻辑代码。2.2 双缓冲与lockCanvas的坑renderGame()里有一个所有 SurfaceView 新手都会踩的坑忘记finally中解锁画布。private void renderGame() { Canvas canvas null; try { canvas holder.lockCanvas(); if (canvas ! null) { canvas.drawColor(Color.BLACK); drawSnake(canvas); drawFood(canvas); } } finally { if (canvas ! null) { holder.unlockCanvasAndPost(canvas); } } }lockCanvas()拿到的画布其实就是内部缓冲区的映射如果不在finally里unlockCanvasAndPostSurface 的缓冲区会一直被占用轻则画面不刷新重则下次lockCanvas抛异常导致线程崩溃。这里还想提醒一点不同 Android 版本的 SurfaceView 在页面切换时surfaceDestroyed的时机并不一样。有的机器切后台时会立刻销毁有的会延后因此线程的while标记位要单独用 volatile 布尔值不能依赖surfaceDestroyed参数的传入值。3. 蛇身数据模型、移动与碰撞判定3.1 坐标和链表的对应关系贪吃蛇最经典的数据结构就是双向链表。蛇头是业务上的核心——所有输入都作用于蛇头蛇身只是被拖着走。源码里用了ArrayListPoint而不是LinkedList这个选择值得商榷但它实现起来更直观public class GameEngine { private ListPoint snake new ArrayList(); private Point food; private int direction DIRECTION_RIGHT; // 0上, 1右, 2下, 3左 private int nextDirection DIRECTION_RIGHT; private int score 0; public void move() { direction nextDirection; Point head snake.get(0); Point newHead new Point(head.x, head.y); 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.equals(food)) { snake.add(0, newHead); // 吃到食物头插入尾不删 generateFood(); score 10; } else { snake.add(0, newHead); // 正常移动头插入 snake.remove(snake.size() - 1); // 尾删除 } } }这段逻辑里最重要的思想是“方向先存后取”。direction是当前帧实际使用的方向nextDirection是这一帧内玩家按下的最新方向。如果不区分这两个变量会出现同一帧内玩家连续按两个方向导致蛇瞬移的情况。蛇头永远通过坐标加减来产生新位置eat的判断就是newHead.equals(food)两个Point对象只要 x 和 y 相等就判定为命中这个优先级比“头是否撞到旧蛇身”要高。3.2 碰撞检测的两种实现方式源码里对“蛇头撞到蛇身”的检测是在每帧绘制前执行的。最简单的方案就是遍历链表方案时间复杂度特点遍历蛇身O(n)实现简单调试直观适合 n 小于几百的场景HashSet 记录身体坐标O(1)需要重写 equals/hashCode处理删除节点时同步移除public boolean isHitSelf() { Point head snake.get(0); for (int i 1; i snake.size(); i) { if (snake.get(i).equals(head)) { return true; } } return false; }遍历法在贪吃蛇这个场景中性能完全够用——蛇身最长不过百来个点循环耗时微秒级。但有一个细节容易被忽略判断碰撞的位置必须在move()之后、绘制之前。如果在move()之前判那么蛇头还没移动上一帧的蛇尾和蛇头可能重叠在下一帧的路径上会造成误判。还有一个容易漏的点越界检测要写在move()内部放在新蛇头生成后立刻判断。如果延迟到遍历蛇身时才统一处理画面会出现蛇头已经消失在屏幕外一帧的情况。3.3 食物生成的“不在蛇身上”保证食物的随机位置生成不是简单的random.nextInt(gridX)和random.nextInt(gridY)否则有很大概率生成到蛇身上——特别是当蛇很长时。一个健壮的做法是把所有空闲格子收集起来再抽样public void generateFood() { ListPoint freeCells new ArrayList(); for (int x 0; x GRID_WIDTH; x) { for (int y 0; y GRID_HEIGHT; y) { Point p new Point(x, y); if (!snake.contains(p)) { freeCells.add(p); } } } if (freeCells.isEmpty()) { gameState GameState.WIN; // 蛇填满全屏玩家赢了 return; } food freeCells.get(new Random().nextInt(freeCells.size())); }这里snake.contains(p)依赖Point的equals方法——Android的android.graphics.Point已经实现了基于 x、y 的equals所以可以直接用。如果自己写坐标类必须重写equals和hashCode否则contains永远走引用比较判断永远不成立。另外当freeCells为空时说明蛇已经填满整个网格此时不应当生成食物而是直接判定胜利这是个边界状态但不少实现会忽略导致死循环反复生成食物。4. 输入事件分发与游戏状态机4.1 触摸滑动与方向键的响应优先级源码通过两种途径接收输入屏幕滑动和物理方向键。触摸事件的本质是onTouchEvent中记录按下点和抬起点然后比较位移的绝对值大小来确定方向。键盘事件则通过onKeyDown直接映射。需要特别注意的是触摸事件的判断必须带“最小滑动距离阈值”否则手指轻微抖动会被解析成转向指令。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) 20 || Math.abs(dy) 20) { if (Math.abs(dx) Math.abs(dy)) { onDirectionChange(dx 0 ? DIRECTION_RIGHT : DIRECTION_LEFT); } else { onDirectionChange(dy 0 ? DIRECTION_DOWN : DIRECTION_UP); } } break; } } return true; } public void onDirectionChange(int newDirection) { // 禁止直接反向例如当前向右时不允许瞬间向左 if ((direction DIRECTION_RIGHT newDirection DIRECTION_LEFT) || (direction DIRECTION_LEFT newDirection DIRECTION_RIGHT) || (direction DIRECTION_UP newDirection DIRECTION_DOWN) || (direction DIRECTION_DOWN newDirection DIRECTION_UP)) { return; } nextDirection newDirection; }滑动阈值设为 20px低于这个值的抬手动作视为误触直接忽略。return true表示消费整个事件序列否则事件会继续传递给父布局或别的控件。禁止直接反向的四个条件必须和当前direction比较而不是和nextDirection比较——因为如果蛇正在向右走玩家快速按了下、左两个方向第一帧nextDirection变成下第二帧左就会被判定为“右的反向”如果不拦就会穿头自杀。4.2 状态机的状态转移条件游戏循环里需要有一个全局状态来控制哪些逻辑可以在当前状态下执行。源码里使用的是枚举状态机public enum GameState { READY, // 等待开始 RUNNING, // 游戏进行中 PAUSED, // 暂停 GAME_OVER, // 游戏结束 WIN // 通关填满网格 }当前状态可转移状态触发方式READYRUNNING点击屏幕任意位置RUNNINGPAUSED按暂停键或切换后台RUNNINGGAME_OVER撞墙或撞到自身RUNNINGWIN蛇身填满全部网格PAUSEDRUNNING点击继续按钮GAME_OVERREADY点击“重新开始”updateGame()的开头就需要做状态过滤public void updateGame() { if (gameState ! GameState.RUNNING) { return; } move(); if (isHitSelf() || isOutOfBound()) { gameState GameState.GAME_OVER; return; } // 触发下一帧绘制 }这段过滤逻辑如果在run()里判断会导致问题当游戏处于PAUSED状态时Thread.sleep(100)仍然执行只是不更新坐标。这没问题。但是当GAME_OVER时updateGame()会一直空转线程白白消耗 CPU。更合理的做法是当状态不是RUNNING时把线程挂起——用一个wait/notify或者干脆在状态切换时调用Thread.interrupt()来唤醒。源码里没做这一步也算可以理解但自己扩展时值得补上。另外onPause()里的状态同步要执行Activity 失焦时自动把RUNNING切到PAUSED否则游戏在前台切后台再切回来时蛇会自己多走好几步。5. 网格步长、机型适配与数据持久化5.1 绘制时动态计算单元格尺寸贪吃蛇的经典绘制方式是在drawSnake()里用网格坐标换算像素位置。这里有一个适配问题手机屏幕宽高比多种多样如果硬编码单元格像素尺寸不同分辨率的机器上地图大小就会不一致。我一般会在surfaceChanged回调中根据屏幕尺寸动态计算Override public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) { int cellSize Math.min(width / GRID_WIDTH, height / GRID_HEIGHT); offsetX (width - cellSize * GRID_WIDTH) / 2; offsetY (height - cellSize * GRID_HEIGHT) / 2; this.cellSize cellSize; }GRID_WIDTH和GRID_HEIGHT定义了逻辑坐标系的大小。比如GRID_WIDTH 20、GRID_HEIGHT 20意味着游戏区域是 20x20 的网格无论屏幕是 720p 还是 2K逻辑层始终只关心网格坐标。这就是游戏分层的好处GameEngine不需要知道任何像素单位绘制层负责换算。offsetX/offsetY用于居中偏移避免宽屏手机游戏区偏向一侧。单元格颜色可以用canvas.drawRect填充蛇头用亮色、蛇身用深色、食物用红色总绘制耗时不会超过 2ms。5.2 SharedPreferences 保存历史最高分源码包没有单独的内存数据库这完全符合贪吃蛇这种轻量游戏的诉求。最高分只需要一个SharedPreferences文件就能搞定public class ScoreManager { private static final String PREF_NAME snake_score; private static final String KEY_HIGH_SCORE high_score; public static void saveHighScore(Context context, int score) { SharedPreferences sp context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE); int old sp.getInt(KEY_HIGH_SCORE, 0); if (score old) { sp.edit().putInt(KEY_HIGH_SCORE, score).commit(); } } public static int getHighScore(Context context) { return context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) .getInt(KEY_HIGH_SCORE, 0); } }commit()是同步写盘在游戏结束这种低频场景下没问题但不要在主线程频繁调用。现在的 Android 版本推荐把commit()换成apply()做异步持久化两者的差异在于返回值commit()返回写入结果apply()立即返回但保证最终写入。针对贪吃蛇场景GameActivity的onDestroy()里调用saveHighScore即可。5.3 提高可玩性的变速与穿墙模式基础流程跑通后可以从游戏性维度快速扩展。一个最常见的做法是让蛇的速度随得分递增初始 150ms 一帧每吃 5 个食物减少 10ms直到下限 60ms。修改方式很简单把第 2 章的sleepTime计算里写死的时间改成动态变量即可。如果要对这个经典案例做深度练习可以在不改变数据结构的前提下增加“穿墙模式”newHead.x小于 0 时改为GRID_WIDTH - 1大于等于GRID_WIDTH时归零y 轴同理。这完全不影响碰撞检测因为自撞检测只关心蛇身坐标。另一个值得打磨的细节是食物素材复用度低的问题。源码里食物是红色方块如果你想把食物换成一张位图或者一套序列帧动画需要修改drawFood()中canvas.drawBitmap的绘制参数——注意位图资源的density要与屏幕匹配低密度图片直接拉伸会模糊。扩展建议是把GameEngine抽成独立类与SnakeView完全解耦这样无论是后面接 Google Play 游戏服务排行、还是改成多人同屏对战都只需要改渲染层而不用动核心逻辑。本文还有配套的精品资源点击获取