简介:一份基于Android平台的经典贪吃蛇游戏完整源码包,面向刚开始接触Android游戏开发的学习者,也适合作为移动开发课堂的入门教学案例。包内提供可直接运行的APK、Android工程源码及相关资源,可对照源码理解SurfaceView游戏循环、碰撞检测、食物随机生成、蛇身移动与增长、触摸/按键交互以及开始、暂停、结束等状态管理,覆盖了Android小游戏开发的核心链路。配合代码可梳理从界面绘制、逻辑更新到事件响应的完整流程,便于后续在此基础上扩展难度、计分或音效功能。压缩包共39个文件,整体仅204KB,主要文件类型包括Java源文件、XML布局与工程配置、PNG图片素材、class/dex编译产物和APK安装包,结构比较精简,便于快速检视和复用。目前已有701人学习或下载,适合想要通过一款完整小游戏快速上手Android游戏开发流程的读者。
1. 先从“简单”说起:Android贪吃蛇源码到底难在哪
第一次在 Android 上做贪吃蛇的工程师,通常不是被玩法难住,而是被“简单”两个字骗了。转向、吃豆、撞墙、增长,这些逻辑拆开看都不难,一旦放进游戏循环、触摸事件和 Activity 生命周期里,就会出现方向失控、尾部误判、后台恢复后线程还活着这些奇怪问题。网上搜 Android 贪吃蛇源码能拿到一堆可运行工程,但能直接看懂并改出自己想要效果的不多,尤其是从 C 语言贪吃蛇转过来的人,经常被 View、线程、坐标系、生命周期这一套搞懵。这篇不给你一个黑盒工程,而是把整份源码拆成网格建模、绘制线程、输入控制和碰撞计分四块,用能直接落地的代码把关键参数与边界条件讲透。适合初次接触 Android 游戏开发、或想借经典小游戏练手源码设计的人。
2. 拆源码骨架:网格建模、绘制与 View 选型
2.1 先别急着写绘制代码,用网格代替像素坐标
在 Android Studio 里新建一个 Empty Views Activity,先抛掉“蛇是用连续像素画出来的”这个直觉。贪吃蛇的经典做法是走网格:屏幕被切成固定行列的小格子,蛇头、蛇身、食物全都落在格点上。这样碰撞判定从几何问题变成了整数比较,只需要比较两个格子的行号和列号。
画布尺寸和网格数量的比例是第一个要调的参数。行数太多,蛇移动看起来一卡一卡;行数太少,蛇走一步跨太大,操作手感会很生硬。一般手机竖屏我会取 20 列、30 行,横屏则按屏幕比例反过来取 30 列、20 行。下面是坐标换算的核心类:
public class GridBoard { public static final int COL = 20; public static final int ROW = 30; private int cellSize; public void computeCellSize(int viewWidth, int viewHeight) { cellSize = Math.min(viewWidth / COL, viewHeight / ROW); if (cellSize < 1) cellSize = 1; } public Point gridToPixel(Point grid) { return new Point(grid.x * cellSize, grid.y * cellSize); } }这段代码的关键在computeCellSize。Math.min保证格子不会因为屏幕比例被拉成矩形,如果直接拿viewWidth / COL,横竖屏切换时蛇身会被拉伸。cellSize计算一次后,整个游戏期间不再变化,所有绘制和触摸事件都先经过gridToPixel转换成像素,后续逻辑层的代码完全不需要关注屏幕宽高。
网格方案选型时还有个常见疑问:普通 View、SurfaceView 和 TextureView 到底用哪个。从源码依赖角度看,小游戏用普通 View 就够,但要注意 onDraw 里不能做耗时操作。
| 方案 | 重绘方式 | 适用场景 | 潜在问题 |
|---|---|---|---|
| 普通 View | 主线程 onDraw | 方格数量少、逻辑简单 | onDraw 耗时会导致掉帧 |
| SurfaceView | 独立线程 draw | 频繁刷新、粒子效果 | 需要自己处理生命周期同步 |
| TextureView | 硬件加速 + 回调 | 动画与相机叠加 | 兼容性和内存开销略高 |
我做这个贪吃蛇时用的是普通 View。原因很简单:蛇身最多几十个格子,一次 onDraw 画几十个圆角矩形,成本远低于 1ms,没必要为了这种负载引入 SurfaceView 的线程复杂度。
2.2 用 Deque 和 Paint 把蛇画出来,绘制对象要复用
蛇的数据结构比 UI 更值得先定下来。源码里最常见的实现是ArrayList<Point>,头插尾删时要把整个数组搬迁,这在小规模数据下无所谓,但不优雅。更符合“蛇在移动”这一语义的是双端队列Deque:头部进一个格子代表蛇头前进,尾部出队代表蛇身跟进,吃到食物时尾部不出队,长度自然加一。
private final Deque<Point> snake = new ArrayDeque<>(); private final Paint bodyPaint = new Paint(Paint.ANTI_ALIAS_FLAG); private final RectF cellRect = new RectF(); // 复用对象,避免每帧 new private void drawSnake(Canvas canvas) { int index = 0; for (Point p : snake) { int left = p.x * cellSize; int top = p.y * cellSize; cellRect.set(left + 1, top + 1, left + cellSize - 1, top + cellSize - 1); if (index == 0) { canvas.drawRoundRect(cellRect, 8, 8, headPaint); } else { canvas.drawRoundRect(cellRect, 4, 4, bodyPaint); } index++; } }注意cellRect在 for 循环外创建,每次循环只调用set()更新坐标,这是从源码里最容易看出的性能素养。如果每次在 for 里new RectF(),一百帧下来就是几百个短生命周期对象,触发 GC 后游戏会不定期卡一下。格子之间留了 1px 内边距,视觉上是蛇节之间有一条缝,不至于涂成一大块。
Paint 的颜色和抗锯齿标志只在构造时设置一次。头部用浅绿色、身体用深绿色,或者反过来,都只是个人喜好;真正要关注的是头部必须和身体有明显色差,因为玩家的一切操作反馈都依赖快速认出哪一格是头。
2.3 源码里的坐标单位,最容易绕晕的是“为什么有 0.5f”
解析别人源码时,最常见的困惑是left = (int) (p.x * cellSize + 0.5f)这类写法。这个+0.5f是四舍五入。网格坐标算出来的像素坐标很可能带小数,直接强转int会丢失精度,多次换算后蛇会逐渐偏离应落在的格子,最终摩擦到墙壁边缘提前判死。
想避开这个坑,可以采用一个约定:所有逻辑层坐标都是整数,只有转换到 Canvas 绘制时才接触 float。在绘制时先做一次mathRound,彻底避免累积误差:
public RectF gridToPixelRect(Point p, int gap) { float left = p.x * cellSize + gap; float top = p.y * cellSize + gap; cellRect.set(left + 0.5f, top + 0.5f, (p.x + 1) * cellSize - gap + 0.5f, (p.y + 1) * cellSize - gap + 0.5f); return cellRect; }这里把 gap 也作为参数,不同模块可以按需调整边距,而不必在调用处重复计算。到了这一步,整个绘制骨架已经立住:网格负责坐标体系,Deque 负责运动数据,Paint 复用负责渲染效率。下一步要让蛇真正动起来。
3. 驱动蛇动起来:游戏循环、移动与速度等级的取舍
3.1 一个 tick 一次 move,为什么不能把逻辑写在 onDraw 里
onDraw 是“被调用”的,不是“循环”的。如果直接在 onDraw 里改蛇的位置,同一帧里绘制两三次,蛇就一次走两格,速度直接翻倍。正规做法是独立维护一个游戏循环,每隔固定时间执行一次 update(移动、判断、生成食物),然后请求重绘。
public class GameLoop implements Runnable { private volatile boolean running = true; private volatile long tickDelay = 160; private long lastTick = SystemClock.uptimeMillis(); @Override public void run() { while (running) { long now = SystemClock.uptimeMillis(); if (now - lastTick >= tickDelay) { update(); lastTick = now; } try { Thread.sleep(2); } catch (InterruptedException e) { Thread.currentThread().interrupt(); running = false; } } } }tickDelay是相邻两次移动的间隔毫秒数,这是控制游戏速度的唯一参数。Thread.sleep(2)是为了避免 while 空转占满 CPU。注意running用volatile修饰,因为线程会在 Activity 销毁时从主线程修改这个字段,不加 volatile 可能读不到最新值。
这种写法比postDelayed更可控。postDelayed是单次任务,每次执行完要手动重新提交,而且回调在主线程执行,一旦逻辑变重,会直接卡住 UI 绘制。独立线程中只做逻辑和更新,重绘用postInvalidateOnAnimation()回到主线程,渲染和逻辑各管一段。
3.2 移动的三个实现层次:ArrayList、Deque 和“不复制”
先看最精简的移动逻辑。蛇的移动其实只有三步:算出新头坐标、头部入队、尾部出队。
public void step() { Point head = snake.peekFirst(); int nx = head.x + currentDir.dx; int ny = head.y + currentDir.dy; snake.offerFirst(new Point(nx, ny)); if (nx == food.x && ny == food.y) { score += 10; spawnFood(); } else { snake.pollLast(); } postInvalidateOnAnimation(); }peekFirst()只查不改,offerFirst()在头部插入,pollLast()删除尾部。用LinkedList的 Deque 接口时会发现入队出队都是 O(1),没有任何数组元素搬迁。对比一下三种常见实现的差异:
| 数据结构 | 头插复杂度 | 尾删复杂度 | 随机访问 | 适合场景 |
|---|---|---|---|---|
| ArrayList | O(n) | O(1) | O(1) | 蛇长度固定、不频繁增删 |
| LinkedList | O(1) | O(1) | O(n) | 天然适合头尾操作 |
| 数组双指针 | O(1) | O(1) | O(1) | 性能极致,需自己管环形缓冲 |
如果你追求极致性能,可以预分配一个Point[]数组,维护 headIndex 和 tailIndex 两个指针,走环形缓冲。但我觉得做个贪吃蛇用不上这种复杂度,LinkedList在几万帧的规模下看不出性能差异,代码可读性反而更好。网上很多源码用ArrayList然后每次add(0, newHead),前面说过这是 O(n) 的头插,蛇长到一百节时会感觉到轻微停滞,没必要学。
3.3 速度等级参数怎么设:从一秒五步到一秒二十步
速度感是贪吃蛇体验的核心。速度等级一般不做成线性增长,而是初始慢、加速快。按键操作需要一两秒适应期,但吃到五个食物后玩家已经熟悉手感,再保持龟速会让整个过程显得拖沓。
private static final int[] SPEED_TABLE = {220, 180, 150, 120, 95, 70, 50, 35};每吃一个食物,把当前等级对应的tickDelay取出来更新循环参数。选 220ms 作为初始值,约每秒 4.5 格,配合滑动手势时不会觉得反应迟钝;末级 35ms 每秒约 28 格,这个速度下蛇身越长越刺激,但碰撞判定频率也更高,要求触摸事件处理必须足够快。这里有个容易被忽略的点:速度表是毫秒数,不是帧率。35ms 意味着每秒 28 次逻辑更新,如果只在 onDraw 里刷 UI,实际得到的是逻辑速度与渲染速度脱节。所以我在 GameLoop 里加一句:
public void setTickDelay(int delay) { tickDelay = delay; }速度表本身只是一组“经验值”,你完全可以根据自己的手感改成{200, 160, 130, 100…}。游戏行业叫这个“难度曲线”,贪吃蛇源码里最值得调的也就是这张表。
4. 输入、碰撞和计分:把玩法状态完整落到工程里
4.1 手势滑动与方向缓存,同帧两次手势会丢方向
触摸输入用GestureDetector的onFling实现。直接用onTouchEvent里的ACTION_MOVE算位移也可以,但很容易被用户手指轻微移动触发误判。onFling 自带速度参数,只有快速滑动才响应,和“点一下”“按住”能自然区分开。
@Override public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { float dx = e2.getX() - e1.getX(); float dy = e2.getY() - e1.getY(); if (Math.abs(dx) < touchSlop && Math.abs(dy) < touchSlop) { return false; } Direction next; if (Math.abs(dx) > Math.abs(dy)) { next = dx > 0 ? Direction.RIGHT : Direction.LEFT; } else { next = dy > 0 ? Direction.DOWN : Direction.UP; } if (currentDir == next || currentDir.isReverseOf(next)) { return true; } pendingDirections.add(next); return true; }touchSlop从ViewConfiguration.get(getContext()).getScaledTouchSlop()获取,这是系统级的最小滑动阈值,不要在源码里写死成 10 或 20 像素,不同屏幕密度的体验差异很大。isReverseOf检查的是 RIGHT 和 LEFT、UP 和 DOWN 这两对反向关系,反向直接忽略。最大的坑在于pendingDirections这个队列:一次 update 周期内可能滑了两次,第二次手势还没被消费,第一次的方向就被覆盖了,结果蛇直直冲进自己身体。用队列缓存后,每个 tick 只消费一个方向,其余自然忽略,整个控制逻辑就确定下来了。
4.2 碰撞判定必须排除蛇尾,这是源码里最容易翻车的地方
碰撞分为撞墙和撞身体。撞墙好写,比较新头的行列号是否越界。撞身体要小心一个边界条件:上一帧蛇尾即将移开的那一格,在移动开始时仍然属于蛇身。如果把整条蛇遍历一遍判断碰撞,蛇头会遇到一个“瞬移尾巴”,明明下一步尾会走开,却提前判死了。
public boolean isCollision(int headX, int headY) { if (headX < 0 || headX >= COL || headY < 0 || headY >= ROW) { return true; } int index = 0; int size = snake.size(); for (Point p : snake) { if (index < size - 1 && p.x == headX && p.y == headY) { return true; } index++; } return false; }index < size - 1就是排除尾巴那一格。实现时要区分“蛇头自己”和“蛇身”:队列第一个元素是头,头和头自己比较永远成立,所以遍历也从index = 0跳过。如果你在别人源码里看到这个位置写的是index > 0,那说明它对尾巴的判断可能有问题,除非它在移动逻辑里先把尾巴挪开再进行碰撞检测。
4.3 计分与状态监听,View 不应持有 Activity 的引用
游戏状态机至少要有 RUNNING、PAUSED、GAME_OVER 三种。状态用什么管理都可以,关键是 View 不能直接拿到 Activity 去改 TextView。常见的做法是定义监听接口:
public class SnakeGameView extends View { public interface ScoreListener { void onScoreChanged(int score, int length); void onGameOver(int score); } private ScoreListener scoreListener; public void setScoreListener(ScoreListener listener) { this.scoreListener = listener; } private void notifyScore() { if (scoreListener != null) { scoreListener.onScoreChanged(score, snake.size()); } } }MainActivity 里view.setScoreListener(...),在回调里刷新 TextView 和弹 Game Over 对话框。这个解耦的价值在于,就算以后把数值界面改成 Fragments 或 Compose,游戏逻辑层一行都不用动。
游戏结束时调用resetGame(),把 Deque 清空、重新初始化蛇身、生成新食物、置零计分。选 resetGame 而不是直接 new 一个 View,是为了保留同一个 view 实例,避免触摸监听失效。
4.4 生命周期绑定:暂停不是线程 stop,后台要放行
Activity 进入后台时,线程如果继续跑,蛇会继续移动甚至撞墙,回来一看 game over。正确做法是在onPause()里把running设为 false,在onResume()里重新起线程。但注意线程不能直接 start 两次,要分开两个方法:
@Override protected void onPause() { super.onPause(); gameView.pauseGame(); } @Override protected void onResume() { super.onResume(); gameView.resumeGame(); }pauseGame()把 running 置 false,resumeGame()判断线程状态后重新 new 一个 Thread 启动,同时重置lastTick,否则线程恢复的第一次 update 会立刻执行,造成一睁眼蛇就瞬移一步的体验。
5. 落地的三个动作:调试坐标、跑通检查与接入现有工程
5.1 在日志里看蛇头坐标,比肉眼盯着屏幕好使
写一个小方法,把每个 tick 的关键信息打出来,绑定到一次触摸按钮或模拟敏感输入时触发:
Log.d("SnakeDebug", String.format("head=(%d,%d) dir=%s food=(%d,%d) size=%d", head.x, head.y, currentDir, food.x, food.y, snake.size()));跑起来后执行adb logcat -s SnakeDebug,连续滑动几个方向,对照日志里 head 的坐标变化是否与滑动手势一致。这能一次性筛掉方向枚举写反、坐标轴颠倒、触摸事件重复触发三类问题。坐标是一件“多对一”的工作,肉眼盯着蛇头跑几局也未必能定位,日志直接把每帧状态摊开,配合“速度从最慢的 220ms 开始”的初始配置,基本不费脑。
5.2 最小跑通检查清单:先确保逻辑正确,再谈手感
接入自己工程时按这个顺序检查:第一,冷启动后蛇静止且不自动判死;第二,吃到食物后长度加一、分数增加;第三,连续快速打两个垂直方向滑动手势,蛇按第二次方向走,不抽搐;第四,Home 键退出再回来,游戏暂停在之前状态,不闪退;第五,撞墙和撞自己都触发 Game Over。这五条在真机上跑一遍只需要三分钟,但能覆盖 80% 的常见源码 bug。模拟器也能跑,但触摸滑动的手感数据和真机差异较大,速度参数最好以真机为准。
5.3 接入旧工程最容易翻的三个点:资源文件、尺寸为 0 和重复线程
把源码复制到已有工程时,最常见的是找不到 R 文件或资源名冲突。贪吃蛇类小型游戏一般只有颜色、字符串和布局,改起来快;真正麻烦的是自定义 View 在布局里写了layout_width="wrap_content",View 初始尺寸没确定时computeCellSize拿到的宽高都是 0,导致cellSize永远是 0,绘制时画不出任何东西。这类问题可以从onSizeChanged回调里拿真实尺寸,别用构造函数阶段测量。还有一个隐蔽点:Activity 重建时如果线程在onDestroy里 join 了,但新 Activity 又启动了旧线程引用,就会出现两个循环同时跑,方向互相抢。统一处理好pauseGame/resumeGame的生命周期,比把蛇加长到一百节更值得花时间。
本文还有配套的精品资源,点击获取