恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
Android Studio从零打造贪吃蛇:自定义View与状态机实战指南
首页
资讯中心
/
Android Studio从零打造贪吃蛇:自定义View与状态机实战指南
Android Studio从零打造贪吃蛇:自定义View与状态机实战指南
发布时间:2026/9/16 4:02:06
简介贪吃蛇作为经典街机游戏本次基于Android Studio实现的版本移除了墙壁与自身碰撞限制玩家只需专注操控贪吃蛇持续进食成长玩法更显轻松自由。整套资源面向计算机专业学生的安卓课程设计、期末项目或自学练手场景源码可运行、可二次开发能帮助读者理解游戏主循环、触摸事件、碰撞判定及自定义View绘制等核心知识点。压缩包共1057个文件大小约11.11MB除java、kt、class、dex等源码/编译产物外还包含xml界面布局、gradle工程配置、png/jpg图片素材、jar依赖库及可直接安装的app-debug.apk目录结构完整便于在Android Studio中直接打开研读。目前已有1513人学习下载。通过对照源码与构建产物可快速掌握从项目工程搭建、游戏逻辑设计到打包运行的完整链路对完成高分安卓App设计与课程报告也有直接参考价值。1. 为什么每个计算机专业学生都被要求用Android Studio做贪吃蛇贪吃蛇这个项目在Android开发教学里的地位相当于C语言里的学生成绩管理系统。它不死板但足够小能把界面显示、事件处理、数据结构和线程调度这几块最核心的知识点全部串起来。用Android Studio从头写一个能落下左右滑动控制的贪吃蛇不是简单复制代码而是要知道每一个方格的坐标怎么映射到屏幕上知道蛇动起来的本质是定时器驱动的坐标刷新知道为什么暂停时不能直接阻塞主线程。这篇文章就是给你的完整落地路径从建项目到写成能交到安卓app程序设计作业库里拿高分的成品。新手可以照着步骤调通已经写过一遍的人也能在这里看到状态机如何设计、最高分怎么持久化以及答辩时老师通常会盯住的几个细节。2. Android Studio建项目与游戏画面骨架自定义View还是布局文件2.1 用Empty Activity创建最干净的项目结构打开Android Studio新建项目时选择“Empty Views Activity”而不是带Launcher的模板因为贪吃蛇不需要复杂的布局文件也不需要底部导航。语言选Java或Kotlin都行但课程设计多数用Java为了和课本保持一致我下面的代码全是Java版。SDK版本选当前模拟器能跑的API级别即可没有特殊要求。项目建好后默认结构是app/src/main/java/包名/MainActivity.java app/src/main/res/layout/activity_main.xml我们要把activity_main.xml真正用起来但在它里面只放一个FrameLayout游戏画面由后面自定义的GameView直接填满。修改activity_main.xmlFrameLayout xmlns:androidhttp://schemas.android.com/apk/res/android android:idid/game_container android:layout_widthmatch_parent android:layout_heightmatch_parent /这里用FrameLayout而不是LinearLayout的原因很简单游戏视图需要全屏且没有额外内边距FrameLayout能够保证子View直接覆盖整个父容器不能像LinearLayout那样可能被排列方向干扰。match_parent让游戏区域占满可用空间后续计算网格时不用考虑其他控件的占位。2.2 GameView一个View承担绘制、逻辑、输入三件事很多入门教程会把游戏逻辑写在Activity里结果Activity越来越长既不清爽也不好调试。我一般会新建一个GameView继承自View把棋子、蛇、食物全部画在这个View上。这样Activity只负责生命周期GameView负责一切和游戏有关的事情属于标准的MVC模型里的View和Controller合并。创建GameView.javapublic class GameView extends View { private static final int COLS 20; // 网格列数 private static final int ROWS 20; // 网格行数 private static final int MOVE_INTERVAL 200; // 毫秒初始移动间隔 private LinkedListPoint snake new LinkedList(); private Point food; private int direction 1; // 0上 1右 2下 3左 private boolean isRunning false; private Paint paint new Paint(); public GameView(Context context) { this(context, null); } public GameView(Context context, AttributeSet attrs) { super(context, attrs); initGame(); } private void initGame() { snake.clear(); // 初始蛇身4个节点从左上角开始向右 for (int i 3; i 0; i--) { snake.add(new Point(i, 0)); } food new Point(8, 8); direction 1; isRunning true; } }参数说明COLS和ROWS定义游戏区的逻辑尺寸20x20是入门常用的规格如果想提高难度可以改成30但注意规则是蛇身每格正好占用一个网格网格数越多蛇在相同时间内移动的绝对距离不变但可走的空间变大了。MOVE_INTERVAL是帧间隔200毫秒意味着每秒移动5格比较适合新手操作。initGame里把蛇恢复成4节方向向右食物固定在(8,8)这些初始值可以随意调整但必须保证食物不在蛇身上。2.3 从Activity启动GameViewMainActivity中不需要任何XML查找直接Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); GameView gameView new GameView(this); setContentView(gameView); }注意这样做绕过了布局文件好处是修改游戏时不用来回切换XML和Java。坏处是如果将来想在上面叠加暂停按钮必须要用FrameLayout才能混合。所以更稳妥的方式是先用setContentView加载activity_main.xml然后获得容器再添加setContentView(R.layout.activity_main); FrameLayout container findViewById(R.id.game_container); container.addView(new GameView(this));两种写法都行但在交作业时建议用第二种同时在XML里加一个TextView显示分数方便后面扩展。addView时不指定布局参数默认使用FrameLayout.LayoutParams贴左上角但对于match_parent的需求需要手动设置FrameLayout.LayoutParams params new FrameLayout.LayoutParams( FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT); container.addView(new GameView(this), params);这样游戏区域就完全铺满了整个布局。3. 贪吃蛇核心算法蛇身存储、移动、碰撞与食物生成3.1 为什么用LinkedList 保存蛇身蛇的移动规律是头部向前进一格尾部减少一格如果吃到食物尾部不减少。这个操作在数据结构上正好对应双向队列的头部插入和尾部删除。LinkedList实现了DequeofferFirst移除last性能都是O(1)而ArrayList在头部插入需要将全部元素后移效率是O(n)。20x20网格下性能差异不明显但这是程序设计实践课程里一个很好的答辩知识点。定义LinkedListPoint snake new LinkedList();Point是android.graphics.Point字段x和y是公开的直接访问比getter更方便。但注意Point的内存开销不大在20x20的情况下完全不用考虑。移动逻辑private void move() { // 算出新头部 Point head snake.peekFirst(); int newX head.x; int newY head.y; switch (direction) { case 0: newY--; break; case 1: newX; break; case 2: newY; break; case 3: newX--; break; } Point newHead new Point(newX, newY); // 移除尾部如果吃到食物就不移除 snake.offerFirst(newHead); if (newHead.equals(food)) { // 吃到食物不删除尾部同时生成新食物 eatFood(); } else { snake.pollLast(); } }需要注意offerFirst和pollLast配对使用。如果吃到食物尾部不删蛇就变长一节。这段代码里最重要的一点是必须先offerFirst再决定是否pollLast保证新头部永远存在。3.2 碰撞检测自己撞自己、撞墙碰撞检测有两种一种是检查新头部是否超出边界另一种是检查新头部是否出现在snake链表中不能是最后一个因为尾部即将移除但为了简单我们检查去掉尾部之后是否与现有蛇身重叠或者先pollLast再判断但那样食物逻辑会乱。常见做法是在move之前先做合法性校验如果非法直接停止游戏。public boolean isCollision(int x, int y) { // 撞墙 if (x 0 || x COLS || y 0 || y ROWS) { return true; } // 撞到自己注意新头部会和原头部重合吗不会除非方向倒转 for (Point p : snake) { if (p.x x p.y y) { return true; } } return false; }这里有个隐藏陷阱for循环中包含了原来的尾巴但正常移动时尾巴会消失如果不注意可能导致误判。例如蛇长度为2head在(1,0)tail在(0,0)向右移动时新头部是(2,0)显然不会撞到自己。真正危险的是当蛇掉头比如方向是右用户快速按左当前方向还是右那么新头是(2,0)但可能原来尾在别的地方判断没问题。另一种误判是新头部正好移动到当前尾部的位置而这个尾部会在移动后被移除这种情况下应该允许移动。所以要排除尾部private boolean isCollision(int x, int y) { if (x 0 || x COLS || y 0 || y ROWS) return true; for (int i 0; i snake.size() - 1; i) { Point p snake.get(i); if (p.x x p.y y) return true; } return false; }注意for循环遍历到snake.size() - 2不检查最后一个元素因为它是即将消失的尾巴。但如果吃到食物尾巴不会消失此时新头部撞到尾部应该是允许的吗实际中食物不可能生成在尾部而且吃到食物瞬间蛇没有变长前新头部到达食物位置尾部还没动此刻新头部也不可能在尾部位置所以忽略尾部不会造成错误。对LinkedList使用get(i)需要遍历但20x20的规模完全可接受答辩时也解释得清。3.3 食物生成不要生在蛇身上private void spawnFood() { int maxCells COLS * ROWS; if (snake.size() maxCells) { // 胜利 return; } Random random new Random(); int foodX, foodY; do { foodX random.nextInt(COLS); foodY random.nextInt(ROWS); } while (isOnSnake(foodX, foodY)); food new Point(foodX, foodY); } private boolean isOnSnake(int x, int y) { for (Point p : snake) { if (p.x x p.y y) return true; } return false; }随机选点后用do-while循环保证食物不在蛇身上。当蛇几乎占满网格时随机碰撞率会很高循环次数可能很多但一般游戏到不了这个程度。如果想要更专业的做法可以维护一个空闲坐标列表每次从列表里随机取一个但会增加代码量98分不差这个。3.4 绘制画网格和蛇身体重写onDrawOverride protected void onDraw(Canvas canvas) { super.onDraw(canvas); drawBackground(canvas); drawSnake(canvas); drawFood(canvas); }在这里我们还要处理实际屏幕像素和网格坐标的映射。假设View的宽高为W和H我们需要计算每个网格的大小。为了保持正方形网格取cellSize Math.min(W / COLS, H / ROWS)然后让游戏区域居中。绘制时每个网格的左上角坐标 offsetX col * cellSize。private void drawSnake(Canvas canvas) { paint.setColor(Color.GREEN); for (Point p : snake) { float left offsetX p.x * cellSize; float top offsetY p.y * cellSize; canvas.drawRect(left, top, left cellSize, top cellSize, paint); } }这种绘制方法简单直接但方格之间没有间隙看起来是一整块。为了美观可以让蛇身稍微缩小2像素类似圆角矩形。代码之后再说。4. 触摸控制、游戏循环与状态机从“能跑”到“好玩”4.1 用GestureDetector识别滑动方向Android最常见的控制是滑动屏幕改变蛇的方向。在GameView里覆写onTouchEvent配合GestureDetectorprivate GestureDetector detector; public GameView(Context context, AttributeSet attrs) { super(context, attrs); detector new GestureDetector(context, new SimpleOnGestureListener()); } Override public boolean onTouchEvent(MotionEvent event) { detector.onTouchEvent(event); performClick(); return true; } Override public boolean performClick() { super.performClick(); return true; } private class SimpleOnGestureListener extends GestureDetector.SimpleOnGestureListener { 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) Math.abs(dy)) { if (dx 0) setDirection(1); else setDirection(3); } else { if (dy 0) setDirection(2); else setDirection(0); } return true; } }setDirection要处理一个关键问题不能允许180度掉头。比如当前向右用户快速向左滑动如果立即改变方向蛇头会撞到自己的第二节虽然从技术上合法但体验极差。所以setDirection中加入判断public void setDirection(int newDirection) { // 不能直接反向 if (newDirection (direction 2) % 4) { return; } direction newDirection; }这个判断非常关键几乎必问。direction取值0-30上1右2下3左。反向就是2模4例如右(1)的反向是左(3)(12)%43正确。4.2 游戏循环Handler定时器还是Choreographer入门推荐用Handler的postDelayed因为代码直观。我在实际教学项目中通常用一个Runnable自我调度private Handler gameHandler new Handler(Looper.getMainLooper()); private Runnable gameLoop new Runnable() { Override public void run() { if (state GameState.RUNNING) { move(); checkGameOver(); invalidate(); gameHandler.postDelayed(this, MOVE_INTERVAL); } } };启动游戏时调用gameHandler.post(gameLoop)暂停时调用gameHandler.removeCallbacks(gameLoop)。注意这里postDelayed的间隔是固定的200ms也就是说不管每帧移动多快时间间隔不变。如果需要让蛇加速可以在每次吃到食物时减少间隔但不能小于50ms否则手速跟不上。很多刚学者误用Thread.sleep来暂停游戏这是绝对错误的选择因为Thread.sleep会阻塞当前线程如果放在UI线程界面会卡死如果放在子线程又需要频繁用runOnUiThread切回主线程更新UI。用Handler的postDelayed才是Android标准答案把任务排队在消息队列里主线程空闲时执行既不会卡顿又天然线程安全。4.3 状态机运行、暂停、结束只靠一个boolean isRunning不够因为游戏有运行、暂停、结束、重置四个状态状态之间还有转换条件。建议定义枚举public enum GameState { READY, RUNNING, PAUSED, GAMEOVER }READY是游戏刚启动、等待用户第一次滑动很多教程喜欢直接RUNNING但那样蛇会立刻运动用户还没准备好。我的做法是初始为READY第一次滑动改为RUNNING菜单键或按钮进入PAUSED撞墙后进入GAMEOVER并弹出对话框。在Activity里可以监听生命周期比如onPause时暂停游戏Override protected void onPause() { super.onPause(); if (gameView ! null) { gameView.pause(); } }GameView中pause方法将state置为PAUSED并移除回调。这样用户在切后台时蛇不会继续跑回来时还能从暂停状态继续或者选择重新开始。状态机的核心意义在于把“能不能移动”和“能不能改变方向”分开。例如GAMEOVER后触摸不再触发滑动PAUSED时蛇保持静止但仍然可以改变方向——很多游戏允许预输入方向这里为了简单不预输入。5. 冲击98分答辩项目说明书、代码注释、异常处理和扩展点5.1 三个必写的功能亮点分数、最高分保存、速度递增以上代码已经实现了游戏主体但要拿98分光有基本功能还不够。至少需要三个加分项。第一分数计算。每吃一个食物得1分简单直接。在GameView里加一个int score在eatFood()中自增并用onScoreChangeListener回调给Activity这样Activity可以实时更新标题栏或TextView。注意这里不能用int然后invalidate因为分数变化要通知UI线程虽然都在主线程但回调更规范。第二最高分保存。用SharedPreferences保存历史最高分。在GameOver时读取并比较private void saveHighScore() { SharedPreferences sp getContext().getSharedPreferences(snake, Context.MODE_PRIVATE); int high sp.getInt(high, 0); if (score high) { sp.edit().putInt(high, score).apply(); } }一定要用apply()而不是commit()因为apply是异步写磁盘不会阻塞主线程。答辩时老师问为什么用apply你要回答commit返回boolean且会阻塞主线程apply立即更新内存并在后台写盘对游戏场景更安全。第三速度递增。定义speedArray或简单地在每次吃食物后把MOVE_INTERVAL减少10ms最小到50ms。注意Handler的postDelayed参数是在每次post时重新读取MOVE_INTERVAL的所以修改后下一次延迟自动生效。5.2 答辩常问的两个问题内存管理与线程安全性第一个问题GameView里的Handler和Runnable会不会导致内存泄漏标准回答是因为我们在onDetachedFromWindow中removeCallbacks所以不会泄漏。具体实现Override protected void onDetachedFromWindow() { super.onDetachedFromWindow(); gameHandler.removeCallbacksAndMessages(null); }第二个问题多线程安全。所有游戏逻辑都在主线程执行Handler的postDelayed只是把任务加入主线程消息队列并未开新线程因此不存在并发问题。这正说明为什么不应该用ThreadrunOnUiThread的方式那道弧线绕远了。5.3 一个加分技巧用圆角矩形替代方块默认drawRect画出来是硬边如果换成drawRoundRect视觉效果立刻提高一个档次。例如paint.setColor(Color.parseColor(#4CAF50)); canvas.drawRoundRect(left 2, top 2, left cellSize - 2, top cellSize - 2, 8, 8, paint);这里left2和top2留出2像素间距让每个格子独立显示。半径8是圆角半径可以根据cellSize调整。注意drawRoundRect在API 21之后才支持float版本但API 21以上的模拟器覆盖绝大多数学校机房如果项目minSdk低于21需要用API的兼容判断或使用方角。这个细节写在注释里老师会认为你考虑周全。如果能把食物画成圆形并且让蛇头与身体颜色不同整个作品在视觉上就已经是98分团队的审美水平。例如蛇头用深绿色身体用浅绿色食物用红色圆点if (i 0) { paint.setColor(Color.parseColor(#2E7D32)); } else { paint.setColor(Color.parseColor(#8BC34A)); } canvas.drawOval(left, top, left cellSize, top cellSize, paint);这里需要注意必须用同一个Paint对象但不同drawable不受影响。颜色切换只影响后面的绘制不影响之前的。每次onDraw前重新设置颜色即可。最后别忘了在项目里写一个README.md包含运行环境、操作说明、代码结构和设计思路。这个文档虽然不是代码但在课程设计评分中经常占20%以上的分值。README里放一张游戏截图再放几行如何导入项目如何运行到真机上的步骤就已经是优秀作业的样子了。要真正做到98分你还需要在答辩现场跑一遍游戏边跑边说出每一步对应的代码位置。比如“现在蛇吃到食物分数加1速度加快因为MOVE_INTERVAL从200变成了190”这就是老师最想听到的实践感。整个项目下来你的Android Studio工程结构清晰代码整洁注释到位想不拿高分都难。本文还有配套的精品资源点击获取