简介:这份毕业设计文档面向计算机相关专业的本科生,尤其是需要完成安卓方向毕业设计的同学,提供一套围绕 2048 小游戏展开的完整论文范本。内容以 Android 平台为技术底座,从选题背景、开发环境搭建、Java 与 Android 关键技术介绍,到需求分析、可行性论证、功能与业务流程梳理,再到数据库设计、ER 图、数据字典与数据流图,形成较完整的论述链条;详细设计章节针对游戏界面布局、数字合并算法、模式选择与数据存储逐项展开,并配有启动、运行、退出等系统截图与功能、性能、安全测试记录,最后附总结、致谢与参考文献,可直接对照借鉴章节组织与写作口径。资源包共 1 个 doc 文件,约 619KB,为排版规范的成稿论文,便于查阅、摘录与格式参考。目前已有 223 人学习,适合作为开题、撰写与答辩准备阶段的结构参照。
1. 从一块 4×4 棋盘说起:这套 Android 2048 小游戏 App 毕业设计到底交付什么
一块 4×4 的棋盘、16 个格子、只有上下左右四个动作,规则两分钟就能讲完——但真要把它做成一份能通过答辩的毕业设计,难点从来不在玩法,而在「需求怎么定、界面怎么画、滑动怎么识别、合并怎么算、分数怎么存、测试怎么写」。这套基于 Android 平台的 2048 小游戏设计与实现方案,走的是原生 Java + 自定义 View 的路线,不依赖任何游戏引擎,覆盖从环境搭建、需求分析、总体设计、详细设计到编码实现与测试验收的完整链路。它适合正在做移动应用方向毕设或课程设计的学生直接对照结构交作业,也适合想快速摸清 Android 自定义控件与手势处理套路的开发者拿来改。往下读,你会看到一套真正跑得通的工程拆法,而不是一份贴满截图的论文模板。
2. 需求边界与技术选型:为什么这套方案不引入游戏引擎
2.1 功能需求与非功能需求的划定
做 2048 这类小游戏,最容易犯的错是需求写得太满。论文里动不动就写「支持排行榜、支持联网对战、支持云存档」,结果代码一行没写,答辩老师翻到功能分析那一页随手一问就露馅。我一般的做法是先把需求切到「一个人一台手机、离线玩、关掉再打开还能看到最高分」这个最小闭环上,再往外交付。
功能需求这一层,这套方案明确交付四件事。第一是启动流程:点开图标进入欢迎界面,短暂停留或点击后进入游戏主界面。第二是核心玩法:四方向滑动、相同数字合并、每次有效移动后随机生成一个新数字、无路可走时判定结束。第三是模式选择:提供「经典数字」和「酷车一族」两套皮肤,满足论文里对趣味性的描述。第四是分数体系:当前分数实时刷新、历史最高分落盘保存、提供重新开始入口。
非功能需求往往被学生忽略,但恰恰是答辩时能加分的地方。响应速度上,一次滑动的界面反馈要在人眼可接受范围内完成,不能出现手指抬起了棋盘还没动的迟滞感;屏幕适配上,不能只在自己那台模拟器上好看,换个分辨率就错位;资源占用上,这是个小游戏,不该出现启动要等好几秒的情况。把这些写进需求分析章节,比堆十页背景介绍有用得多。
提示:需求分析章节建议配一张功能结构表,左边列功能点,右边列验收方式。写论文时这张表可以直接放在需求章节,测试章节再引用一次,前后呼应,逻辑链就完整了。
2.2 开发链路的取舍:Eclipse + ADT + SDK 还是换新工具
这套方案的技术栈是 JDK + Eclipse + Android SDK + ADT 插件 + AVD 模拟器,属于比较经典的一套组合。现在回看,很多人会问为什么不换更现代的构建工具。答案分两面:如果这份设计是要交付一份有明确技术脉络的论文,那这套链路的每一步都能在论文里讲清楚原理——JDK 负责 Java 运行环境,SDK 提供 Android 平台 API 与调试工具,ADT 把 Android 工程能力挂进 Eclipse,AVD 负责在没有真机时提供运行环境。四个部件各司其职,环境搭建这一章自然就有了内容,而且每一步都有可验证的成功标志。
从工程角度说,这套链路对 2048 这种规模的项目完全够用。整个工程只有两三个 Activity、一个自定义 View、若干资源文件,不需要复杂的模块化和依赖管理。真正的成本在于环境配置本身,JDK 版本、SDK 路径、ADT 版本三者对不上,就会出现插件装了但工程向导出不来、模拟器建了但启动卡在开机画面这类问题。血泪经验是:先装 JDK 并配好环境变量,用命令行验证 Java 可执行;再装 Eclipse 并确认能新建普通 Java 工程;然后才装 SDK 和 ADT,把 Android 这一层叠上去。顺序反了,出问题时你根本分不清是哪一层坏了。
环境搭建的验收标准很朴素但必须写进文档:能成功创建一个 Android 工程、能启动一个 AVD、能把工程跑在这个 AVD 上、改一行界面文字后重新运行能看到变化。这四步全过,环境才算真的通了,后面写代码才不会一边写一边怀疑人生。
2.3 双皮肤模式:把「酷车一族」做成可配置资源
论文里提到游戏提供两个模式,一个是传统数字,一个是「酷车一族」。这个设计点如果实现得草率,很容易变成两套复制粘贴的代码;实现得好,则是论文里少数能体现设计思维的亮点。
我的处理思路是把「显示什么」和「棋盘是什么」彻底分开。棋盘永远只认 2、4、8、16 这些数值,是一个纯数字矩阵;皮肤只负责回答一个问题——某个数值对应什么颜色、什么图案、什么文字。这样一来,模式切换本质上只是切换一张映射表,游戏逻辑一行都不用改。映射关系可以放进资源文件或静态数组,甚至可以把两套皮肤的颜色表都写死在常量里,通过一个模式标记去索引。
这个拆法还有个附带好处:论文里「界面设计」这一节可以画出清晰的分层图——数据层是 4×4 整数矩阵,表现层是颜色与图形映射,中间由自定义 View 的绘制方法衔接。分层图画得出来,说明设计想清楚了;画不出来,说明代码里大概率和成一团。
3. 总体设计与数据模型:三层结构撑起一个 4×4 棋盘
3.1 模块划分与三层体系结构
这套方案的总体设计采用三层结构,从上到下依次是表现层、逻辑层、数据层。表现层负责界面呈现与用户交互,包括欢迎界面和游戏主界面;逻辑层负责棋盘状态维护、移动合并运算、新数字生成与结束判定;数据层负责最高分等少量数据的持久化。三层之间单向依赖,表现层调用逻辑层,逻辑层不感知界面长什么样。
模块上切成四块,职责边界必须写清楚,否则后面写代码会出现「这段该放哪」的反复纠结:
| 模块 | 主要职责 | 对应工程文件 |
|---|---|---|
| 启动模块 | 展示欢迎界面、延时或点击后跳转 | 欢迎 Activity 及其布局 |
| 游戏主界面模块 | 承载棋盘、显示分数、提供重开入口 | 游戏 Activity 及其布局 |
| 棋盘视图模块 | 绘制 16 个格子、识别手势、触发移动 | 自定义 View 类 |
| 游戏核心逻辑模块 | 合并算法、随机生成、结束判定、最高分读写 | 逻辑类与持久化工具 |
这张表建议原样放进论文的总体设计章节,再配一段说明每个模块对外暴露什么、依赖谁。评审看设计章节,最想确认的就是你有没有把一个大问题拆成几个能独立说清楚的小问题。
3.2 游戏状态的数据模型:矩阵、分数与存档
整个游戏的状态可以用三个东西描述完整:一个 4×4 的二维整数数组记录每格数值,0 表示空;一个整型记录当前得分;一个整型记录历史最高分。就这么多,不需要数据库,不需要复杂对象图。
public class GameCore { // 棋盘只有 4×4,用定长数组而不是集合,取值不用装箱也更省内存 private int[][] board = new int[4][4]; private int score; // 本局得分 private int bestScore; // 历史最高分,启动时从持久化存储读入 public int[][] getBoard() { return board; } public int getScore() { return score; } public int getBestScore() { return bestScore; } }之所以强调「不需要数据库」,是因为很多同类论文在文档里写了半章数据库设计、画了 ER 图和数据字典,代码里却只有几个内存变量。这个矛盾在答辩时非常危险,第 5 章会专门讲怎么处理。就这个项目而言,真正需要跨进程保留的只有最高分一个整数,用轻量级的键值存储完全够用,硬套关系型数据库反而是过度设计。
数据模型还有一个隐含要求:棋盘状态的变更必须是原子的。也就是说,一次滑动要么完整生效(棋盘变了、分数加了、新数字生成了),要么完全不生效(方向无效,棋盘不动)。这条约束不写在纸上也必须在代码里守住,否则会出现「滑动没变化但分数莫名其妙涨了」这种玄学 bug。
3.3 界面流程设计:欢迎页到主界面的跳转闭环
界面流程不复杂,但要在论文里画清楚,因为它体现了你对页面生命周期的理解。完整的流转是这样:应用启动进入欢迎界面,展示应用名与版本信息,停留一小段时间后自动跳转,或者用户点击任意位置立即跳转;进入游戏主界面后,顶部显示当前分数与最高分,中间是棋盘,底部提供重新开始按钮;用户在棋盘上滑动即触发一次移动;游戏无法继续时弹出提示,用户可选择重开或退出。
跳转用意图对象完成,页面之间不传业务数据,保持松耦合。欢迎界面的延时跳转要注意一件事:页面被销毁时必须把待执行的延时任务取消掉,否则用户已经退出应用了,那个延时任务还在后台跑,醒来后去启动一个已经不存在的页面,轻则日志报错,重则崩溃。这个坑我在不止一个项目里见过。
// 欢迎页延时跳转:先发一个延时消息,页面销毁时再把它摘掉 private final Handler handler = new Handler(); private final Runnable jumpTask = new Runnable() { @Override public void run() { startActivity(new Intent(WelcomeActivity.this, GameActivity.class)); finish(); // 关掉欢迎页,避免用户按返回键又回到这里 } }; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_welcome); handler.postDelayed(jumpTask, 1500); // 停留 1.5 秒 } @Override protected void onDestroy() { super.onDestroy(); handler.removeCallbacks(jumpTask); // 关键:页面销毁时撤销延时任务 }这里的postDelayed是延时入队,removeCallbacks是撤销入队,两个方法成对出现才安全。延时长度取 1.5 秒是个折中:太短用户看不清品牌信息,太长每次启动都要等。重新开始按钮的处理则是把棋盘矩阵清零、分数归零、重新生成两个初始数字,然后请求重绘——注意是重置状态而不是重建页面,避免频繁创建销毁带来的开销。
3.4 核心算法设计:四个方向归一成一次左移
2048 的算法如果老老实实写四套(上滑一套、下滑一套、左滑一套、右滑一套),代码量翻四倍,出错概率也翻四倍。惯用的做法是写一套「向左合并」,其余三个方向通过旋转棋盘转成向左,算完再转回去。
以 4×4 为例,把棋盘顺时针旋转 90 度,原来的「上滑」就变成了「左滑」;旋转 180 度,右滑变左滑;旋转 270 度,下滑变左滑。合并完成后按相反方向转回来,棋盘看起来就是被那个方向推过去的。这个思路写进详细设计章节,能明显看出设计者是不是真的想过问题,而不是照着别人的代码抄。
单行的向左合并又分三步:先去掉零把非零数字挤到左边,再从左往右扫描相邻相同的一对合并并累加分数,最后再挤一次把合并产生的空位补齐。第二步有个细节必须处理——合并出来的新数字不能再参与本轮合并。也就是说2 2 2 2一次滑动应该变成4 4而不是8,2 2 4应该变成4 4而不是8。这个细节是 2048 算法里最经典的陷阱,下一章给完整代码时会重点标出来。
设计阶段还要定一条规则:只有当棋盘状态真的发生变化时,这次滑动才算「有效」,才允许生成新数字。如果用户往左滑但所有数字已经贴在最左边且无法合并,那这次操作无效,不生成新数字,也不加分。这条规则决定了游戏体验是否公平,也直接影响结束判定的正确性。
4. 关键实现:卡片绘制、手势识别与合并算法的落地代码
4.1 工程骨架与全局配置
工程结构按 Android 惯例组织:源码放在 src 目录下按包名分层,界面布局放在布局资源目录,字符串、颜色、尺寸等常量放在值资源目录,应用级配置写在清单文件里。清单文件是这个项目的「总开关」,两个页面都要在这里注册,主入口用启动过滤器标记,屏幕方向锁成竖屏避免旋转导致布局重排。
<application android:icon="@drawable/ic_launcher" android:label="@string/app_name" android:theme="@android:style/Theme.NoTitleBar.Fullscreen"> <!-- 欢迎页:唯一带启动过滤器的页面,系统据此生成桌面图标入口 --> <activity android:name=".WelcomeActivity" android:screenOrientation="portrait"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> <!-- 游戏主界面:不设过滤器,只能由应用内部跳转进入 --> <activity android:name=".GameActivity" android:screenOrientation="portrait" /> </application>这里三个属性的含义值得在论文里点一句:screenOrientation锁定竖屏,是因为 4×4 棋盘按正方形绘制,横竖屏切换会触发配置变更导致页面重建,游戏状态容易丢;theme用全屏无标题主题,避免顶部标题栏挤压棋盘空间;一个应用只能有一个带启动过滤器的入口,多写一个会导致桌面出现两个图标。这些都是配置层面的常识,但写进文档能体现你对工程整体的把握。
4.2 自定义 View 绘制 16 张卡片
棋盘不用 16 个独立的控件拼,而是通过一个自定义视图自己画。原因很实在:16 个控件意味着 16 次测量、16 次布局、16 次事件分发,滑动时要同步 16 个控件的状态,复杂且容易错位;自己画则是一次画布绘制,坐标自己算,屏幕适配也好做——格子边长直接按视图宽度除以 4 得出,什么分辨率都自适应。
绘制逻辑分两步:先画底板和空格子,再遍历矩阵把有数字的格子画上颜色和文字。颜色不写成长串的 if-else,而是用一张按数值对应下标的静态表,取色时用「以 2 为底的对数」把数值换算成下标,越界就取最后一个颜色。
// 数值 -> 背景色,下标 0 对应空格,1 对应 2,2 对应 4,以此类推 private static final int[] TILE_COLOR = { 0xFFCDC1B4, 0xFFEEE4DA, 0xFFEDE0C8, 0xFFF2B179, 0xFFF59563, 0xFFF67C5F, 0xFFF65E3B, 0xFFEDCF72, 0xFFEDCC61, 0xFFEDC850, 0xFFEDC53F, 0xFFEDC22E }; private void drawTiles(Canvas canvas) { float size = getWidth() / 4f; // 格子边长随视图宽度自适应 for (int i = 0; i < 4; i++) { for (int j = 0; j < 4; j++) { int value = board[i][j]; // 用对数把 2/4/8/16 映射成 1/2/3/4,避免写 11 个分支 int level = value == 0 ? 0 : (int) (Math.log(value) / Math.log(2)); level = Math.min(level, TILE_COLOR.length - 1); Paint paint = new Paint(); paint.setColor(TILE_COLOR[level]); float left = j * size + GAP; // GAP 是格子之间的间隙 float top = i * size + GAP; canvas.drawRoundRect(new RectF(left, top, left + size - GAP * 2, top + size - GAP * 2), 8, 8, paint); if (value != 0) { paint.setColor(value <= 4 ? 0xFF776E65 : 0xFFFFFFFF); // 浅色格子配深字 paint.setTextSize(size * 0.4f); paint.setTextAlign(Paint.Align.CENTER); float cx = left + (size - GAP * 2) / 2f; float cy = top + (size - GAP * 2) / 2f - (paint.descent() + paint.ascent()) / 2f; canvas.drawText(String.valueOf(value), cx, cy, paint); } } } }两个参数值得一提:size由视图实测宽度算出来,所以换设备不用改代码;文字垂直居中用了(descent + ascent) / 2的基线校正,不这么做文字会整体偏上,这是自定义绘制里最常见的视觉问题之一。颜色表的下标上限用Math.min兜住,是为了防止玩到大数字时数组越界——虽然实际很难玩到那一步,但越界崩溃是低级错误,没必要留。
4.3 触摸事件:把一次滑动翻译成一个方向
手势识别的核心是在按下时记住起点坐标,抬起时算出位移量,然后比较横向位移和纵向位移谁的绝对值大,谁大就判定为哪个轴上的滑动,再根据正负号确定具体方向。听上去简单,但有三个地方必须处理,否则用户会明显感觉「手感不对」。
第一,按下事件必须返回真,否则后续的移动和抬起事件根本不会派发给这个视图。第二,要有最小位移阈值,位移太小就当作误触或点击,不触发移动,否则手指稍微一抖棋盘就动了。第三,也是很多人忽略的,一次完整滑动只应在抬起时结算一次,不要在移动过程中反复触发,否则用户拖着手指滑过去,棋盘会连续动好几步。
private float startX, startY; private static final float MIN_SWIPE = 30f; // 最小识别位移,单位像素 @Override public boolean onTouchEvent(MotionEvent event) { switch (event.getAction()) { case MotionEvent.ACTION_DOWN: startX = event.getX(); startY = event.getY(); return true; // 必须返回 true,否则收不到后续事件 case MotionEvent.ACTION_UP: float dx = event.getX() - startX; float dy = event.getY() - startY; // 阈值过滤:位移太小视为点击,不结算 if (Math.abs(dx) < MIN_SWIPE && Math.abs(dy) < MIN_SWIPE) { return true; } if (Math.abs(dx) > Math.abs(dy)) { move(dx > 0 ? DIR_RIGHT : DIR_LEFT); // 横向占优 } else { move(dy > 0 ? DIR_DOWN : DIR_UP); // 纵向占优 } invalidate(); // 状态变了,请求重绘 return true; } return super.onTouchEvent(event); }MIN_SWIPE取 30 像素是个经验值,实际调的时候可以按屏幕密度折算成 dp,让不同设备手感一致。invalidate()的作用是标记视图需要重绘,系统会在合适的时机回调绘制方法,不要在移动逻辑里直接调用绘制,那样会打乱渲染节奏。move方法是逻辑层的入口,返回当前这次移动是否真的改变了棋盘,界面层根据这个返回值决定要不要刷新分数显示。
4.4 合并算法的完整实现
这是整个项目的技术核心,也是论文详细设计章节篇幅最该给足的地方。先把单行左移合并写扎实,再套上旋转解决四个方向。
/** * 对长度固定为 4 的一行做向左压缩合并 * 返回合并后的行,同时把本次得分累加到成员变量 score */ private int[] mergeLine(int[] line) { int[] tmp = new int[4]; int idx = 0; // 第一步:把非零数字往左挤,等价于消除中间的空格 for (int i = 0; i < 4; i++) { if (line[i] != 0) { tmp[idx++] = line[i]; } } // 第二步:相邻相同则合并,合并后跳过下一格,防止连锁合并 for (int i = 0; i < 3; i++) { if (tmp[i] != 0 && tmp[i] == tmp[i + 1]) { tmp[i] = tmp[i] * 2; score += tmp[i]; // 合并出的数值计入得分 tmp[i + 1] = 0; i++; // 关键一行:本次合并出的格子不再参与本轮 } } // 第三步:合并会产生新的空位,再挤一次补齐 int[] result = new int[4]; idx = 0; for (int i = 0; i < 4; i++) { if (tmp[i] != 0) { result[idx++] = tmp[i]; } } return result; }第二步里的i++是整段代码唯一一处「看起来多余、删掉就出事」的语句。假设输入是2 2 2 2,走到i = 0时前两个 2 合并成 4,如果不跳过,i自增到 1,此时tmp[1]已经被置零,不会误判;但如果输入是2 2 2 0,合并后tmp变成4 0 2 0,不跳过的话i = 1判断tmp[1] == tmp[2]是 0 和 2,不相等,侥幸也不会错。真正出事的是4 2 2 4这类,跳过机制保证4 4 4不会被合并成8 4之外的错误形态。这条规则我见过太多人写成连锁合并,测试用例只用2 2一测就过,玩到后期才发现数字长得不对。
四个方向的统一处理靠旋转:
/** 把棋盘按顺时针旋转 times 个 90 度,times 取 0~3 */ private int[][] rotate(int[][] src, int times) { int[][] dst = new int[4][4]; for (int i = 0; i < 4; i++) { for (int j = 0; j < 4; j++) { switch (times % 4) { case 0: dst[i][j] = src[i][j]; break; // 不转,对应左滑 case 1: dst[i][j] = src[j][3 - i]; break; // 转 90 度,对应上滑 case 2: dst[i][j] = src[3 - i][3 - j]; break; // 转 180 度,对应右滑 case 3: dst[i][j] = src[3 - j][i]; break; // 转 270 度,对应下滑 } } } return dst; }move方法的骨架就是:先按方向把棋盘旋转到「左滑」形态,逐行调用mergeLine合并,再把结果转回原方向,最后比对旋转前后的棋盘是否一致来判断本次移动是否有效。旋转次数和方向的对应关系建议在论文里画一张小表,答辩时老师问「为什么上滑要转 90 度」你能立刻答上来。
4.5 新卡片生成、结束判定与最高分持久化
移动有效之后要生成一个新数字。生成规则是:收集所有空格坐标,随机选一个,90% 概率填 2、10% 概率填 4。之所以不是各 50%,是因为 4 出现太频繁会让棋盘过早被大数占满,难度曲线失衡。如果收集到的空格列表为空,说明棋盘已满,此时要先做结束判定再决定是否提示游戏结束。
/** 在随机空格生成一个新数字,返回是否成功 */ private boolean addRandomTile() { List<int[]> empty = new ArrayList<>(); for (int i = 0; i < 4; i++) { for (int j = 0; j < 4; j++) { if (board[i][j] == 0) empty.add(new int[]{i, j}); } } if (empty.isEmpty()) return false; // 没有空格,交给结束判定 int[] pos = empty.get(new Random().nextInt(empty.size())); board[pos[0]][pos[1]] = Math.random() < 0.9 ? 2 : 4; // 2 与 4 的比例 9:1 return true; } /** 结束判定:有空格,或存在相邻相同,就还能继续 */ private boolean canContinue() { for (int i = 0; i < 4; i++) { for (int j = 0; j < 4; j++) { if (board[i][j] == 0) return true; // 有空格 if (j < 3 && board[i][j] == board[i][j + 1]) return true; // 右边相邻相同 if (i < 3 && board[i][j] == board[i + 1][j]) return true; // 下边相邻相同 } } return false; // 满盘且四邻皆不同,死局 }结束判定这段代码看着朴素,但它是唯一一处「漏判就永远等不到游戏结束、误判就提前弹出提示」的地方。逐格检查右边和下边就够了,因为任意一对相邻格子,要么是某格的右边,要么是某格的下边,遍历完不会遗漏。
最高分持久化用轻量级键值存储,写入时机是每次得分后与历史值比较,超过才写:
private static final String SP_NAME = "game_2048"; private static final String KEY_BEST = "best_score"; private void saveBestIfNeeded(int current) { SharedPreferences sp = getContext().getSharedPreferences(SP_NAME, Context.MODE_PRIVATE); int best = sp.getInt(KEY_BEST, 0); if (current > best) { // 用 apply 异步落盘,避免主线程等待磁盘写入 sp.edit().putInt(KEY_BEST, current).apply(); } }getSharedPreferences的第一个参数是文件名,第二个参数是访问权限,用私有模式即可,别的应用读不到也写不了。apply是异步提交,commit是同步提交并返回结果,游戏里没有「必须立即确认写入成功」的需求,用apply更合适。
5. 避坑与排查:五个最容易让答辩翻车的点
5.1 现象:滑动没反应,或者只响应第一次
现象很典型——手指在棋盘上划,第一次能动,第二次就毫无反应,退出重进又好了。原因基本锁定在按下事件没有返回真,或者抬起事件被上层容器拦截。视图的事件分发是链式的,按下时不消费,后续的移动和抬起事件就收不到;如果棋盘外面裹了一层可滚动容器,容器会把滑动手势抢走。
解决方式是三件事一起做:ACTION_DOWN分支末尾明确返回真;在抬起分支里处理完逻辑后同样返回真,表示这个事件我已经消费了;如果外层确实有滚动容器,在棋盘视图上调用请求父容器不拦截的接口,把这块区域的滑动归属权拿到自己手里。排查时最直接的手段是在三个分支各打一条日志,看抬起事件到底有没有进来——没有进来就是分发问题,进来了没动就是算法问题,两条线别混着查。
5.2 现象:一次滑动,数字长得不对、分数还翻倍
表现为2 2 2 2一滑变成8,或者明明只合并了一次,分数却加了两次。原因就是前面说的连锁合并没拦住,合并出来的新数字又和后面相同的数组合了一次;分数翻倍则是合并逻辑被调用了两遍,常见于抬起事件里既调了移动又调了重绘,而重绘里顺手又算了一次。
解决办法是回到合并的三步结构上:第二步合并后必须跳过下一格,让本轮新生成的数字不参与判断;同时确认move只在抬起分支里被调用一次,绘制方法只负责画、不负责算。测试用例要覆盖2 2 2 2、2 2 4、4 2 2 4、2 0 2 2这四种,能全过基本就没问题。这类 bug 光看界面很难发现,写完算法先单独测这一层,比在手机上滑一百次高效得多。
5.3 现象:退出应用再进来,最高分没了
原因是最高分只存在内存变量里,进程一销毁就清零。还有一种变体是写进去了但读的时候用了不同的文件名或键名,等于写了个孤儿数据。更隐蔽的一种是每次启动都把「历史最高分」当成 0 初始化,然后拿本局分数去覆盖,一局打完历史记录直接被冲掉。
解决思路是把读写路径收敛到一处:启动时读一次、拿到值后立刻赋给成员变量;每次分数变化时先与成员变量比较,大于才写盘,写盘后同步更新成员变量。文件名和键名定义成常量,读和写都引用同一个常量,杜绝拼错。测试方法是打完一局记住分数,完全退出进程(不是按返回键),重进查看是否还在。
5.4 现象:文档里写了数据库设计和 ER 图,代码里一行都没有
这是同类论文里最高频的结构性问题。文档按模板铺了数据库设计、ER 图、数据字典、数据流图整整几章,可这个项目真正需要持久化的只有一个最高分整数,用键值存储就够了。评审老师翻到那一章问一句「你的表建在哪、字段是什么」,现场很难圆。
处理办法有两个方向,选一个走到底。要么把这些章节简化成「数据存储设计」,老实写清楚存了什么、存在哪、为什么用键值存储而不是关系型数据库,反而显得你懂取舍;要么真的引入本地数据库,把最高分、历史对局、用时记录下来,做出真实的表结构、字段说明和查询语句,让文档和代码对得上。最忌讳的是文档照抄模板、代码原地不动,这两者不一致,翻车只是时间问题。
注意:论文里的每一张图、每一张表,都要能在代码或运行结果里找到对应物。答辩前拿这份文档反查一遍代码,凡是查不到出处的章节,要么补实现,要么删章节。
5.5 现象:换台设备或者换个模拟器,棋盘就错位
在自己电脑的模拟器上格子整整齐齐,换一台分辨率不同的设备,格子挤在一起或者超出屏幕,文字压在边框上。根因是把格子边长、间隙、字号写成了固定像素值,而像素密度因设备而异。
解决方式是把绘制参数全部改成相对值:格子边长按视图实测宽度除以 4 计算,间隙用密度换算后的值,字号按格子边长的比例算。布局文件里棋盘视图的宽高比设成 1:1 的正方形,这样不管屏幕多宽多高,棋盘永远是正方形,比例不会走样。适配这件事没有捷径,多开几个不同分辨率的模拟器各跑一遍,问题会自己冒出来。
6. 验收与答辩:用一张测试表把功能讲清楚
到了最后阶段,真正决定分数的不是你写了多少行代码,而是你能不能用一套可复现的方式证明它能跑、跑得对。我的做法是围绕「输入—操作—期望输出」建一张测试表,把功能点逐条落到可观察的结果上,测试章节直接引用这张表,答辩时也照着它讲。
表要覆盖四类:流程类、算法类、边界类、持久化类。流程类验证启动、跳转、重开、退出;算法类验证四个方向的移动与合并结果;边界类验证满盘死局、单方向无效操作、连续快速滑动;持久化类验证最高分的写入与读取。每条用例都要写清楚前置条件,比如「棋盘初始为两张 2」这种,否则复现不了。
| 编号 | 测试项 | 操作 | 期望结果 |
|---|---|---|---|
| T01 | 启动跳转 | 点击桌面图标 | 显示欢迎界面,约 1.5 秒后进入游戏主界面 |
| T02 | 左滑合并 | 棋盘一行设为 2 2 2 2,向左滑 | 该行变为 4 4,得分加 8 |
| T03 | 无效滑动 | 所有数字已贴左且无可合并,向左滑 | 棋盘不变,不生成新数字,得分不变 |
| T04 | 新数字生成 | 任意有效滑动 | 随机空格出现一个 2 或 4 |
| T05 | 游戏结束 | 构造满盘且四邻皆不同 | 弹出结束提示,提供重开入口 |
| T06 | 最高分持久化 | 打破纪录后完全退出再进入 | 最高分显示为退出前的数值 |
| T07 | 屏幕适配 | 在两种不同分辨率模拟器运行 | 棋盘为正方形,文字不溢出格子 |
答辩环节的常见问法其实很集中:为什么用自定义视图而不是控件拼、四个方向怎么统一、连锁合并怎么避免、最高分存哪。前三个问题的答案就在第 4 章的代码里,第四个答案在第 4.5 节。提前把这四个问题各准备一段 30 秒的口头回答,比背论文摘要有效得多。另外提醒一句,演示时不要现场从零开始玩到高分,键盘一紧张手就抖,提前存好一个接近死局的棋盘状态,直接展示合并、结束判定和最高分刷新,效率最高。
从那以后我每次带这类小游戏项目,都会在写完合并算法的当天就把那四组测试用例跑一遍,绝不留到联调阶段——算法层的问题越往后查,越容易被界面的表象带偏,最后花两天时间才发现是一个自增操作放错了位置。这套 2048 的完整方案、文档结构和测试表,希望帮到你。
本文还有配套的精品资源,点击获取