资讯动态

Android贪吃蛇源码深度拆解:网格、循环与触摸事件

发布时间:2026/9/16 9:47:28 来源:尧图企业网站定制
简介一份基于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 更值得先定下来。源码里最常见的实现是ArrayListPoint头插尾删时要把整个数组搬迁这在小规模数据下无所谓但不优雅。更符合“蛇在移动”这一语义的是双端队列Deque头部进一个格子代表蛇头前进尾部出队代表蛇身跟进吃到食物时尾部不出队长度自然加一。private final DequePoint 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)没有任何数组元素搬迁。对比一下三种常见实现的差异数据结构头插复杂度尾删复杂度随机访问适合场景ArrayListO(n)O(1)O(1)蛇长度固定、不频繁增删LinkedListO(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 置 falseresumeGame()判断线程状态后重新 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_widthwrap_contentView 初始尺寸没确定时computeCellSize拿到的宽高都是 0导致cellSize永远是 0绘制时画不出任何东西。这类问题可以从onSizeChanged回调里拿真实尺寸别用构造函数阶段测量。还有一个隐蔽点Activity 重建时如果线程在onDestroy里 join 了但新 Activity 又启动了旧线程引用就会出现两个循环同时跑方向互相抢。统一处理好pauseGame/resumeGame的生命周期比把蛇加长到一百节更值得花时间。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价