简介:这份文档是面向计算机专业学生与Android入门开发者的贪吃蛇毕业设计论文,对应经典手机游戏的完整课题实现思路,适合用作课程设计、毕业设计参考或Java与Android基础练手。压缩包共1个文件,为301KB的doc文档,集中承载论文正文、摘要与关键词等内容,体量轻便,便于直接查阅与打印。目前已有500人学习下载。论文从Java面向对象基础与Android平台特点讲起,梳理开放手机联盟与智能手机游戏的市场前景,随后重点拆解Drawable、Canvas、Thread等核心类在蛇身绘制、画面动态刷新与游戏主循环中的具体用法,并说明触摸事件如何控制转向、边界与自身碰撞如何判定,同时涉及动画过渡与多分辨率屏幕适配等工程细节。读者可据此理清一款单机小游戏从需求分析、技术选型到编码实现的完整脉络,借鉴论文的章节组织与论述方式,也能直接参考其中的类与控件讲解来完成自己的同类项目。
1. 从文档到工程:这份 Android 贪吃蛇毕业设计论文能直接用的部分
压缩包里躺着的android贪吃蛇毕业设计论文.doc,名字看着像交完就封存的作业,真正翻进去会发现它把一个小游戏从需求、模块划分一路写到了类职责:Snake管数据和规则,TileView管画面,SnakeView管刷新节奏。这条链路恰好是 Android 自定义 View 加游戏主循环的最小可运行骨架,比看十篇「自定义 View 入门」都直观。
它适合三类人:一是要交课程设计但必须能讲清每一行的人;二是刚学完 Java、想找一个不依赖第三方库的完整小项目练手的人;三是被onDraw和子线程刷新绕晕、想找一个能跑起来的参照物的人。下面按「拆结构—立坐标—接绘制—控节奏—避坑—验收」推一遍,目标是让你把论文里的类图变成能编译、能触摸操作、能记分的真工程。
2. 三类分工:Snake / TileView / SnakeView 的边界与工程骨架
论文第 4 章给了一张模块划分图,落到代码上就是三个类加一个 Activity。很多人抄这份论文翻车的第一个点,不是代码不会写,而是把逻辑、绘制、线程全塞进一个View里,最后既卡顿又难调。这一章先把边界划清楚,再搭出能编译的空壳。
2.1 为什么必须把「数据、绘制、节奏」拆开
Android 的View.onDraw()跑在 UI 线程,而游戏的逻辑更新如果也放在 UI 线程,一帧的绘制时间就会被逻辑计算挤占,蛇越长、循环越多,掉帧越明显;反过来,如果把invalidate()放在子线程直接调用,等到的是CalledFromWrongThreadException。所以正确的切法是:逻辑在子线程算,状态在主线程写,绘制由主线程触发。
论文里三个类的职责正好对应这个切法,我把它整理成一张对照表,抄代码前先对一遍:
| 类 | 继承关系 | 负责什么 | 不该做什么 |
|---|---|---|---|
Snake | 普通 Java 类 | 蛇身坐标、食物坐标、当前方向、碰撞判定、分数 | 不持有 Context,不碰 Canvas,不 new View |
TileView | extends View | 把二维格子数组翻译成位图,做离屏缓冲 | 不含游戏规则,不启动线程 |
SnakeView | extends TileView | 刷新线程、延迟计算、把Snake的状态同步进格子数组 | 不直接画蛇,不处理 Activity 生命周期 |
MainActivity | extends Activity | 生命周期、按键/触摸分发、分数持久化 | 不写游戏循环 |
这张表看着朴素,但它决定了后面所有排错的路径:画面不对去TileView,蛇行为不对去Snake,节奏不对去SnakeView,启动退出不对去MainActivity。血泪经验是,一旦你把if (吃到食物)写进onDraw里,后面每一次改需求都会变成改绘制代码。
2.2 把论文的模块划分落成工程目录
论文用的还是早期的 IDE 加 ADT 插件那套,现在用官方 IDE 新建一个空 Activity 工程即可,不需要引任何第三方依赖。包结构按下面的方式摆,文件名和论文里的类名对齐,方便你一边看文档一边对代码:
# 工程内 java 源码目录,包名按你自己的来 app/src/main/java/com/example/snakelab/ ├── MainActivity.java # 生命周期、输入分发、最高分读写 ├── SnakeView.java # 刷新线程 + 一局游戏的状态机 ├── TileView.java # 格子到像素的绘制层 ├── Snake.java # 纯逻辑:移动、吃食、碰撞 └── res/ ├── layout/main.xml # 一个 FrameLayout 套自定义 View + 分数 TextView └── drawable/ # 蛇头、蛇身、食物、墙,四张位图骨架代码先立住,MainActivity只做四件事:把布局挂上、把SnakeView取出来、把分数控件交给它、在生命周期里开关线程。写法如下:
public class MainActivity extends Activity { private SnakeView mSnakeView; private TextView mStatus; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.main); mSnakeView = (SnakeView) findViewById(R.id.snake); mStatus = (TextView) findViewById(R.id.status); mSnakeView.setStatusView(mStatus); // 让 SnakeView 能回调刷新分数 mSnakeView.loadBestScore(this); // 从 SharedPreferences 读历史最高分 } @Override protected void onResume() { super.onResume(); mSnakeView.resume(); // 线程只在这里启动 } @Override protected void onPause() { super.onPause(); mSnakeView.pause(); // 先停线程,再存档 mSnakeView.saveBestScore(this); } }这里有两个参数性选择值得说明。第一,直接继承Activity而不是兼容库里的 Activity,是因为这个项目零依赖,编译更快,也和你手上的论文保持一致;如果你的工程模板默认生成了兼容版,把继承改回去、同时确认主题不依赖兼容库即可。第二,resume()放在onResume()而不是onCreate(),是因为onCreate()之后还可能经历onPause(),一旦线程在onCreate()里起、在onPause()里停,第二次回到前台就再也没人把它拉起来了——这个坑后面第 5 章会单独讲。
2.3 布局与清单:两个容易漏的声明
布局本身很简单,一个容器加一个自定义 View 加一个分数文本。关键是自定义 View 必须写全限定类名,否则运行时 inflation 会直接抛InflateException:
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent"> <!-- 自定义 View 必须写全限定名,少写包名会 InflateException --> <com.example.snakelab.SnakeView android:id="@+id/snake" android:layout_width="match_parent" android:layout_height="match_parent" /> <TextView android:id="@+id/status" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="top|start" android:padding="12dp" android:textSize="16sp" /> </FrameLayout>清单文件里补两行声明,能省掉后面一半的「怎么又重开一局」问题:
<activity android:name=".MainActivity" android:screenOrientation="portrait" android:configChanges="orientation|screenSize|keyboardHidden" android:exported="true" />screenOrientation锁竖屏,是因为这个游戏的格子是正方形布局,横竖屏切换会触发onSizeChanged()重算mTileSize,蛇的坐标却还是按旧格子数算的,画面会错位。configChanges则是防止旋转或弹出软键盘时 Activity 被销毁重建——重建意味着onCreate()又走一遍,蛇直接回到初始三节。注意这两条是「先锁死再谈适配」,真要支持横屏,得配合onSaveInstanceState()存蛇身坐标,那是第 6 章的事。
3. 坐标模型:二维格子、方向缓冲与碰撞判定
画面能出来之后,下一步是把蛇的运动算对。论文里对Snake类的描述偏重「它有哪些属性」,实际写的时候真正决定成败的是三件事:用什么坐标系、方向什么时候生效、碰撞检测的边界怎么取。
3.1 像素坐标还是格子坐标:先定格子数
新手最容易犯的错是拿像素当坐标:蛇头x += 20,吃到食物判断Math.abs(x - foodX) < 20。这套写法在一种分辨率上勉强能跑,换台机器就全乱,因为每格像素数变了。
正确做法是全程只用格子坐标,像素只出现在绘制层。格子数建议固定成12 × 12,屏幕大小只影响每格边长:mTileSize = min(width / 12, height / 12)。这样同一局游戏在不同手机上,蛇的节数、食物位置、难度完全一致,画面上只是格子变大变小、四周留边。
提示:格子数固定还有一个隐性好处——碰撞判定和食物投放都是整数运算,不存在浮点误差导致的「明明没撞上却死了」。
3.2 Snake 类的数据结构与一步移动
数据结构用两个ArrayList<Point>:蛇身和食物。蛇身约定索引 0 永远是蛇头,这个约定要贯彻到绘制、碰撞、加速所有地方,中途改约定是灾难。方向用四个整数常量,配合两个位移数组:
public class Snake { public static final int NORTH = 0, EAST = 1, SOUTH = 2, WEST = 3; // 与上面常量顺序一一对应:北东南西 private static final int[] DX = {0, 1, 0, -1}; private static final int[] DY = {-1, 0, 1, 0}; private final ArrayList<Point> mTrail = new ArrayList<>(); // 索引 0 是蛇头 private final ArrayList<Point> mApples = new ArrayList<>(); private final Random mRandom = new Random(); private int mCols, mRows; private int mDirection = EAST; // 当前生效方向 private int mPendingDirection = EAST; // 玩家最后一次按键,等下一 tick 才生效 private int mScore; public void reset(int cols, int rows) { mCols = cols; mRows = rows; mTrail.clear(); int cx = cols / 2, cy = rows / 2; mTrail.add(new Point(cx, cy)); mTrail.add(new Point(cx - 1, cy)); mTrail.add(new Point(cx - 2, cy)); mDirection = mPendingDirection = EAST; mScore = 0; mApples.clear(); addApple(); } }mDirection和mPendingDirection拆成两个变量,是为了解决一个经典 bug:玩家在两次刷新之间快速按「上」「左」,如果直接在按键回调里改mDirection,蛇会在同一 tick 内完成 180 度掉头,一头撞进自己的第二节。拆开之后,按键只写 pending,真正生效在step()里:
public void pushDirection(int dir) { // 与当前方向相同、或正好相反(相差 2)的方向直接丢弃 if (dir == mDirection || (dir + 2) % 4 == mDirection) return; mPendingDirection = dir; } public boolean step() { mDirection = mPendingDirection; // 方向在此刻提交,一 tick 最多转 90 度 Point head = mTrail.get(0); Point next = new Point(head.x + DX[mDirection], head.y + DY[mDirection]); if (next.x < 0 || next.x >= mCols || next.y < 0 || next.y >= mRows) return false; // 尾巴这一格不算,因为它下一步就移走了 for (int i = 0; i < mTrail.size() - 1; i++) { if (mTrail.get(i).equals(next)) return false; } mTrail.add(0, next); if (mApples.remove(next)) { // remove(Object) 走 equals,按坐标移除 mScore += 10; addApple(); // 吃到就不删尾,蛇变长 } else { mTrail.remove(mTrail.size() - 1); } return true; }参数说明与几个易错点:(dir + 2) % 4 == mDirection用的是「反向差两步」的性质,前提是四个常量必须按北东南西的顺时针顺序定义,顺序一乱这个判断就失效;mApples.remove(next)必须传对象,如果写成remove(int)会被当成索引,删掉列表里第 n 个苹果;碰撞循环的终止条件是size() - 1,把尾巴排除了,否则蛇头追着尾巴走时会误判死亡——这个细节很多复刻版本都没写对。
3.3 食物投放的边界条件
食物投放看着简单,random.nextInt(cols)就完事,但蛇长到二十几节之后,随机点落在蛇身上的概率会显著上升,重试次数不够就会出现「刷不出食物,循环卡死」的假死。稳妥写法是给重试上限,超限后线性扫描找空位:
private void addApple() { for (int attempt = 0; attempt < 100; attempt++) { Point p = new Point(mRandom.nextInt(mCols), mRandom.nextInt(mRows)); if (!mTrail.contains(p) && !mApples.contains(p)) { mApples.add(p); return; } } // 兜底:棋盘快被占满时,按行扫描第一个空位 for (int y = 0; y < mRows; y++) { for (int x = 0; x < mCols; x++) { Point p = new Point(x, y); if (!mTrail.contains(p) && !mApples.contains(p)) { mApples.add(p); return; } } } }这里的 100 是经验值,格子数 12×12 时蛇再长也远不到 100 次重试;如果你改大格子数,把这个值提到格子总数的一半会更稳。兜底分支的存在意义不是性能,而是让「棋盘几乎占满」这个极端情况有一个确定行为,而不是默默卡在随机循环里。
3.4 速度曲线:分数怎么映射到刷新间隔
论文里只写了「随着分数增加速度加快」,没说怎么加。最省事的做法是连续函数,但那样每吃一个苹果节奏都变一点,玩家手感很飘。我一般用分档:
public long currentDelay() { // 基线 220ms,每满 50 分减 15ms,最低 90ms return Math.max(90, 220 - (mScore / 50) * 15); }220ms是初始节奏,对应每秒约 4.5 格,人手跟得上;每 50 分降 15ms,也就是吃五个苹果提速一档,玩家能明显感觉到但不会被突然加速打乱;90ms是下限,低于这个值单帧的绘制成本开始占主导,逻辑上能跑、画面上已经看不清蛇头了。这三个数都可以调,但顺序别反:先定下限,再定分档粒度,最后定基线。
4. 绘制与刷新:Canvas、Drawable 与刷新线程的协作链
这一层是整份论文里技术含量最高、也最容易抄错的部分。论文提到了Drawable、Canvas、Thread三个类,但没说清它们之间的调用顺序,照着写经常出现全黑屏或者卡成幻灯片。
4.1 TileView:把格子数组翻译成位图
TileView的核心是一个二维整型数组mGrid,存的是「这个格子该画哪张图」的编号,0表示空,1..n对应mTileArray的下标减一。这个编码约定必须写死在注释里,否则以后加一个「蛇身拐弯」的贴图,编号对不上就是满屏错位。
public class TileView extends View { protected static final int TILE_EMPTY = 0; protected int mCols = 12, mRows = 12; protected int[][] mGrid = new int[mCols][mRows]; private Drawable[] mTileArray; private Bitmap mTileBitmap; private Canvas mTileCanvas; protected int mTileSize; @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); // 取宽高能容纳的较小边长,保证格子是正方形,多余空间留边 mTileSize = Math.min(w / mCols, h / mRows); if (mTileSize <= 0) return; // 离屏位图尺寸 = 格子数 × 边长,与屏幕无关 mTileBitmap = Bitmap.createBitmap(mTileSize * mCols, mTileSize * mRows, Bitmap.Config.ARGB_8888); mTileCanvas = new Canvas(mTileBitmap); bindTiles(); // 尺寸变了,所有 Drawable 的 bounds 必须重设 } }onSizeChanged()的调用时机是布局完成、尺寸确定之后,这也是为什么不能在构造函数里算mTileSize——那时候宽高都还是 0,除出来直接是 0,后面所有绘制都会画在一张 0×0 的位图上,表现就是「界面全黑但程序没崩」。bindTiles()里对每张图调用setBounds(0, 0, mTileSize, mTileSize),Drawable不自带尺寸,不设 bounds 它就以 0 大小绘制,同样表现为不显示。
4.2 离屏 Bitmap 做双缓冲:onDraw 里只做一次 blit
为什么绕一层离屏位图?因为onDraw()每帧都会被执行,如果在里面逐格drawBitmap到屏幕,144 个格子就是 144 次绘制调用,加上save/translate/restore的开销,中低端机上直接掉到二十几帧。正确做法是先把整帧画到离屏 Canvas 上,最后一次性贴到屏幕:
@Override protected void onDraw(Canvas canvas) { if (mTileBitmap == null) return; mTileCanvas.save(); for (int y = 0; y < mRows; y++) { for (int x = 0; x < mCols; x++) { int idx = mGrid[x][y]; if (idx != TILE_EMPTY) { Drawable d = mTileArray[idx - 1]; if (d != null) d.draw(mTileCanvas); } mTileCanvas.translate(mTileSize, 0); // 画完一格整体右移,省掉每格 save/restore } mTileCanvas.translate(-mTileSize * mCols, mTileSize); // 换行:回到行首并下移 } mTileCanvas.restore(); canvas.drawBitmap(mTileBitmap, 0, 0, null); // 一帧只贴一次 }逻辑说明:循环内部靠连续translate定位,比每个格子都save()/restore()少一半调用;行末的translate(-mTileSize * mCols, mTileSize)把画笔拨回行首并下移一格,这个负数必须是「列数 × 边长」,写成-mTileSize只会左移一格,画面会斜着排出去。参数上,位图用ARGB_8888,因为蛇身有透明边缘;如果贴图完全不透明,换成RGB_565能省一半内存。
注意:
onDraw()里不要new Paint()、不要new Rect()、更不要做业务判断。这些对象每次重建都会产生垃圾,GC 一触发就是肉眼可见的卡顿。需要的话全提到成员变量里初始化。
4.3 刷新线程:延迟要用「目标间隔减实际耗时」
论文里用的是Thread,思路是死循环里sleep(延迟)然后刷新。直接sleep(200)的问题在于它把这一帧的绘制时间也算进去了,实际循环周期是200 + 绘制耗时,画面会越来越慢。正确算法是记开始时间、算耗时、只睡剩下的部分:
public class SnakeView extends TileView implements Runnable { private Thread mThread; private volatile boolean mRunning; // 跨线程可见,必须 volatile private volatile long mDelay = 220; private final Snake mSnake = new Snake(); @Override public void run() { while (mRunning) { long begin = SystemClock.uptimeMillis(); final boolean alive = mSnake.step(); // 逻辑在子线程算 mDelay = mSnake.currentDelay(); post(new Runnable() { // 状态写入和 invalidate 回主线程 @Override public void run() { if (!alive) { onGameOver(); return; } syncToGrid(); // 主线程改 mGrid,与 onDraw 同线程 invalidate(); } }); long cost = SystemClock.uptimeMillis() - begin; SystemClock.sleep(Math.max(0, mDelay - cost)); // 补足差额,而不是固定睡 } } }这里有三个关键参数和行为。volatile修饰mRunning和mDelay,因为它们是子线程写、主线程读(或反过来),不加就可能读到缓存里的旧值,表现为「点了暂停没反应」。post()把mGrid的修改搬回主线程,是为了避免和onDraw()的读取产生数据竞争——mGrid是二维数组,加锁成本高,换到同一线程最省心。Math.max(0, mDelay - cost)保证耗时超过目标间隔时不再额外等待,宁可掉帧也不累积延迟,否则游戏会越玩越慢,像开了慢动作。
如果你更习惯Handler那套,把post换成mHandler.postDelayed(this, mDelay)也能跑,本质是同一个循环,只是用消息队列替代了 sleep,暂停时记得removeCallbacks。
4.4 输入、暂停与线程开关绑在一起
输入分两路:物理方向键走onKeyDown,触摸走onTouchEvent,两者都只做一件事——把方向交给pushDirection(),不直接改游戏状态:
@Override public boolean onKeyDown(int keyCode, KeyEvent event) { switch (keyCode) { case KeyEvent.KEYCODE_DPAD_UP: pushDirection(Snake.NORTH); return true; case KeyEvent.KEYCODE_DPAD_DOWN: pushDirection(Snake.SOUTH); return true; case KeyEvent.KEYCODE_DPAD_LEFT: pushDirection(Snake.WEST); return true; case KeyEvent.KEYCODE_DPAD_RIGHT: pushDirection(Snake.EAST); return true; default: return super.onKeyDown(keyCode, event); } } @Override public boolean onTouchEvent(MotionEvent e) { if (e.getAction() == MotionEvent.ACTION_DOWN) { // 以屏幕中心为原点,看触点落在哪个象限,取主轴方向 float dx = e.getX() - getWidth() / 2f; float dy = e.getY() - getHeight() / 2f; if (Math.abs(dx) > Math.abs(dy)) { pushDirection(dx > 0 ? Snake.EAST : Snake.WEST); } else { pushDirection(dy > 0 ? Snake.SOUTH : Snake.NORTH); } return true; } return super.onTouchEvent(e); }线程开关必须成对写,并且pause()要能真正打断sleep:
public void pause() { mRunning = false; Thread t = mThread; mThread = null; if (t != null) { t.interrupt(); // 打断 sleep,让循环尽快看到 mRunning=false try { t.join(500); } catch (InterruptedException ignored) { } } } public void resume() { if (mThread != null) return; // 幂等,重复调用不会起两个线程 mRunning = true; mThread = new Thread(this, "snake-loop"); mThread.start(); }join(500)的 500 毫秒是兜底:正常情况下interrupt()之后线程会在一次循环内退出,但如果它正好卡在post里,主线程最多等半秒就继续,不至于让onPause()卡住导致 ANR。resume()里的判空则防住一个常见现象——用户快速切前后台,onResume()连着调两次,起了两个线程,蛇的移动速度直接翻倍。
5. 避坑与排查:五个让贪吃蛇跑不动的现场
这一章按「现象 → 原因 → 解决」记录五个高频翻车点,前两个偏编译与资源,后三个偏运行时和生命周期。
5.1 编译与资源类问题
现象一:Cannot resolve symbol 'Point',或者布局一加载就InflateException。
原因通常有两层:一是漏了import android.graphics.Point,Point不是java.awt里的那个;二是布局里的自定义 View 只写了SnakeView,没写全限定包名,运行期找不到类。
解决:Snake类顶部补import android.graphics.Point;;布局 XML 里改成<com.example.snakelab.SnakeView ...>,包名与AndroidManifest.xml里的package保持一致。改完先执行一次Clean再构建,避免旧的R文件残留导致改完仍报错。
现象二:界面全黑,程序不崩,日志也没有异常。
原因基本锁定在绘制尺寸上:mTileSize为 0(onSizeChanged()没触发,或算出来除不尽小于 1),或者Drawable没设bounds就被draw(),再或者setTile()在resetTiles()之前调用,mTileArray还是 null。
解决:在onSizeChanged()里加一行Log.d("SnakeView", "tileSize=" + mTileSize + ", cols=" + mCols);,把实际值打出来;确保所有setTile()调用都在resetTiles()之后;对所有传入的Drawable统一调setBounds(0, 0, mTileSize, mTileSize),别指望图自带尺寸。
5.2 运行时逻辑与生命周期问题
现象三:游戏一开局,蛇刚走一步就判死。
原因是对「反向」的判断写错了。常见写法是在按键回调里直接比对mPendingDirection,玩家快速按「上」再按「左」,两次都通过,下一 tick 蛇从向右直接变成向左,撞上自己的身体第二节。
解决:比较对象必须是当前生效方向mDirection,而不是mPendingDirection,并且只在step()里把 pending 提交给 direction。改完之后可以在pushDirection()里加一行临时日志,观察每次按键被接受还是被丢弃,通常看两局就能确认逻辑正确。
现象四:切后台再回来,蛇已经跑出去十几格;或者退出页面后手机发烫。
原因是线程没停。只在onDestroy()里停线程是不够的——切后台走的是onPause(),此时onDestroy()未必执行,循环还在跑,post还在往 UI 线程塞任务。
解决:pause()必须在onPause()里调,resume()在onResume()里调,onDestroy()里再兜一次pause()。判断是否生效很简单,切后台挂着,用adb shell top | grep 包名看 CPU 占用,正常应该很快掉到 0;如果一直有几个百分点,说明线程还活着。
现象五:分数永远显示 0,或者切一次后台就归零。
分数不刷新,多半是在子线程直接改了TextView,抛CalledFromWrongThreadException之后被吞掉了,或者弹出的 toast 太短没看见;分数归零,则是存和读用的 key 不一致,或者只在onCreate()里读、onResume()里没重读。
解决:把分数回调统一走主线程,SnakeView里用post包一层再调mStatus.setText(...);存取用同一个常量 key,写在一处:
private static final String KEY_BEST = "best_score"; public void saveBestScore(Context ctx) { SharedPreferences sp = ctx.getSharedPreferences("snake", Context.MODE_PRIVATE); // 写入前先比较,避免把低分覆盖掉历史最高分 int old = sp.getInt(KEY_BEST, 0); if (mSnake.getScore() > old) { sp.edit().putInt(KEY_BEST, mSnake.getScore()).apply(); } }apply()是异步写盘,在onPause()里调用不会阻塞生命周期;如果用commit(),主线程要等磁盘,切后台时会明显感到迟滞。这个比较再写入的细节很关键,直接putInt会把一次低分局的分数覆盖掉历史最高分,玩家会觉得「最高分怎么还会掉」。
6. 进阶:帧率验证、屏幕适配与交付前的验收清单
代码跑通只是及格线,要拿去当课程设计答辩或者放进作品集,还得能证明它「在不同机器上表现一致」。这一章给三个可执行的验证手段。
6.1 用系统自带的帧统计看真实帧时间
不用装任何工具,系统就带了渲染统计。连续玩三十秒,然后执行:
# 清空统计,开始玩一局,结束后抓取 adb shell dumpsys gfxinfo com.example.snakelab reset adb shell dumpsys gfxinfo com.example.snakelab输出里关注Draw、Prepare、Process、Execute四段的平均值。Draw一列就是onDraw()的耗时,正常应该稳定在 2~4ms;如果频繁超过 8ms,说明离屏位图那层没生效,或者你在onDraw()里创建了对象。把dumpsys的输出和游戏里的实际手感对照一次,比凭感觉调参数靠谱得多。
6.2 屏幕适配:只有两个参数需要关心
格子数固定成 12×12 之后,适配就退化成一道除法题:tileSize = min(w / 12, h / 12)。不同屏幕上算出来的值大致如下,可以直接对照你手上真机的分辨率验证:
| 屏幕宽高(px) | 计算出的 tileSize | 有效绘制区域 | 留边情况 |
|---|---|---|---|
| 720 × 1280 | 60 | 720 × 720 | 上下各留约 280px |
| 1080 × 1920 | 90 | 1080 × 1080 | 上下各留约 420px |
| 1440 × 2560 | 120 | 1440 × 1440 | 上下各留约 560px |
只要每格的边长是整数、绘制区域是正方形,玩法就完全一致。真正需要额外处理的只有刘海屏和手势条:给根布局加android:fitsSystemWindows="true",或者把分数文本的 padding 提到状态栏高度以上,否则分数会被挡住。
6.3 交付前的验收清单
答辩前建议按这张表逐项过一遍,每项都能用一句话说清「为什么这样设计」,基本就不会被问住:
| 检查项 | 通过标准 | 失败时的排查方向 |
|---|---|---|
| 首帧显示 | 进游戏 1 秒内看到完整棋盘 | onSizeChanged()是否触发、mTileSize是否大于 0 |
| 反向保护 | 快速连按「上」「左」不会自杀 | 比较的是mDirection还是mPendingDirection |
| 长蛇表现 | 蛇长 30 节以上帧率无明显下降 | onDraw()内是否有对象创建、是否走了离屏位图 |
| 前后台切换 | 切回后从暂停处继续,不是重开 | pause()/resume()是否成对、resume()是否幂等 |
| 最高分 | 切后台、杀进程后重进仍在 | key 是否一致、是否做了「大于才写」的比较 |
最后说个我自己的习惯:每改一次Snake.step()或onDraw(),我都会先把帧统计抓一遍再收工,因为这两个方法是最容易出现「逻辑对了但画面卡了」的地方,肉眼在真机上未必看得出来。当年第一次做这个项目,就是没管这一步,答辩现场演示到蛇长二十几节时卡成了幻灯片。从那以后,改完核心循环先跑三十秒gfxinfo,成了固定动作。希望帮到你。
本文还有配套的精品资源,点击获取