安卓2048小游戏毕设实战:核心算法与自定义View
简介面向计算机专业毕业生的安卓毕业设计论文文档以2048小游戏的设计与实现为完整案例帮助读者解决毕业设计选题、论文结构规划与内容撰写难题。文档基于太原理工大学毕业论文模板从摘要、背景意义、开发技术介绍到需求分析、可行性分析、功能分析、业务流程分析、数据库设计含ER图、数据字典、数据流图、详细设计、系统截图与测试再到总结、致谢、参考文献结构完整、层次清晰。资源仅1个doc文件约619KB使用Word即可打开查看和编辑方便根据自身项目替换内容。目前已有221人学习下载适合正在准备计算机毕业论文尤其是Android方向的学生作为参考。这份文档既提供了规范格式范例也展示了从项目分析、模块设计到系统测试的完整论文写作思路结合2048小游戏这一经典案例能够在游戏逻辑、Android开发技术等细节上给出直观示范参考价值较高。1. 毕设题目换来换去最后用安卓2048小游戏兜住了底给安卓方向定毕设题目是最折磨人的一环。想做个带后台的App怕一个人扛不住做个仿某某应用答辩时又容易被问“你自己到底写了什么”。来回折腾之后你会发现一个能在单机上完整跑通、算法有得讲、界面能扩展、数据能落盘的题目才是最优解——安卓2048小游戏恰好卡在这个甜区。它不像计算器那样单薄到没法答辩也不像购物App那样庞大到要熬掉半条命。2048的核心是棋盘合并算法往上是手势交互与渲染往左是状态存档与悔棋往外还能挂排行榜和主题换肤。这个标题里的“530”我理解是某个毕设任务的编号不需要对它做任何过度解读——你真正要交付的东西是一个在Android设备上能滑、能算分、能存进度、代码结构说得清楚的2048游戏App。下面这套路径我前后带过三四个做类似题目的同学走通从工程骨架到核心算法再到界面动画和避坑按这个顺序推进毕业设计的主体工作量大概两到三周能收住。2. 拆分2048的工程骨架先想清楚结构再谈写代码很多人写2048第一步就打开编辑器开始堆代码视图、逻辑、数据全捏在MainActivity里。一个1000多行的Activity确实能跑但答辩时老师让你讲类结构你会发现自己讲不出层次。做毕设和做项目有个本质区别毕设看重的是“你能否把一个功能拆成清晰的模块”。所以在动手前先花半天把工程结构和数据流定死。2.1 Java还是Kotlin毕设语境下的选型理由如果你在校课程教的是Java就用Java如果你已经熟悉Kotlin就用Kotlin两种语言在这个体量下写出来差距不大。常见的做法是Java加一个简单的MVP结构——不是为了追求架构时髦而是为了答辩时能画出模块图。有一点值得注意毕设选题一旦涉及调用系统API、做界面和动画Kotlin确实会更简洁但如果你导师的评审习惯是基于Java的认知代码里大量用Kotlin进阶语法反而可能在答辩问答上替你“加难度”。我一般会建议用Java理由很务实一是资料多碰到问题搜得到二是Java在Android Studio里不做额外配置选中即用减少环境翻车概率三是就算你后面想换Kotlin核心的算法类和界面类代码迁移成本很低。语言选型这件事不要去迷信“Kotlin是未来”。毕设是你在有限时间内独立交付一个可运行的作品选择你最有把握的那个就是最好的选型。2.2 先盘数据模型棋盘、得分、移动方向的职责划分游戏里所有逻辑围绕一个4x4棋盘展开。数据模型分三层棋盘层int[][]数组表示16个格子0代表空格计算层接收方向参数完成块的滑动、合并、计分返回是否发生移动状态层负责记录分数、最高分、当前棋盘快照、历史栈。我习惯把棋盘和移动算法放进独立的GameBoard类Activity只做两件事把用户手势翻译成方向把GameBoard的结果画到界面上。这样划分以后你甚至可以把GameBoard单独写单元测试——这一点在答辩现场非常加分因为你可以主动说“核心算法抽出来了可以直接用JUnit跑测试”。状态层的另外一个职责是序列化。你要存棋盘和分数最简单就是用JSON或者自定义字符串格式。我倾向于用一个简单的方法把二维数组压成逗号分隔的字符串public String serializeBoard(int[][] board) { StringBuilder sb new StringBuilder(); for (int r 0; r 4; r) { for (int c 0; c 4; c) { if (r 0 || c 0) sb.append(,); sb.append(board[r][c]); } } return sb.toString(); }这段代码很好理解把16个数字按行优先的顺序导出成“2,0,4,0,...”这样的字符串空格就是0和数字本身不冲突。解析时再按逗号split循环填回二维数组即可。参数上没有特殊阈值唯一的细节是不要用字母或符号代替数字0否则解析时还要做二次映射。2.3 从包结构到存档目录一个能过查重也能过答辩的工程布局我的分包习惯是这样的按功能分不按类型分com.example.game2048 ├── activity/ —— MainActivity、GameActivity ├── core/ —— GameBoard算法核心不依赖Android ├── view/ —— GameView自定义绘制、NumberCard ├── storage/ —— SaveManager读取/写入存档 └── util/ —— 常量、方向枚举、字符串解析这个分包的好处是一眼能看出模块边界。答辩老师问“核心算法在哪”你直接说core包的GameBoard打开看一眼里面只有纯Java代码没有Android依赖这比你临时在Activity里翻找要体面得多。存档路径方面SharedPreferences够用不需要上SQLite。2048的存档本质是一份棋盘快照加分数数据量不超过1KBSQLite反而显得杀鸡用牛刀。文件命名固定为game2048_data键分别为board_str、score、best_score代码里不要散落字符串全部进常量类。3. 2064行核心算法让方块按你的手指方向合并2048之所以作为毕设选题合理是因为算法难度适中你要实现的不是复杂的AI寻路而是一个有明确规则的二维数组变换。只要把单行合并写对四个方向都是同一套逻辑的复用。这一节是整个项目的大脑值得花最多时间。3.1 单行合并函数压缩、叠加、再压缩先别想二维把问题降到一维。一行四个格子比如 [2, 0, 2, 4]向左合并的结果是 [4, 4, 0, 0]。规则就三步先删掉所有0让非零块靠左然后从最左边开始相邻两个相等就合并成它们的和并且这两个方块这一轮不能再参与合并最后补0回到四个格子的长度。public ListInteger mergeLine(int[] line) { // 1. 取出所有非零数字集中到列表头部 ListInteger compacted new ArrayList(); for (int v : line) { if (v ! 0) compacted.add(v); } // 2. 从左往右相邻相等就合并 ListInteger merged new ArrayList(); for (int i 0; i compacted.size(); i) { if (i 1 compacted.size() compacted.get(i).equals(compacted.get(i 1))) { int newVal compacted.get(i) * 2; merged.add(newVal); i; // 跳过被合并的那个数字 } else { merged.add(compacted.get(i)); } } // 3. 补0至4位 while (merged.size() 4) merged.add(0); return merged; }这段代码没有循环嵌套的复杂结构唯一的技巧在第28行的i——它的作用是让“被合并过的元素”跳过下一次判断。如果不跳[2, 2, 2, 2]会被不合规则地合并成 [8, 0, 0, 0]而不是正确的[4, 4, 0, 0]。这个逻辑在游戏里表现为一次滑动中每个方块最多参与一次合并。这一步就是2048规则里最容易写错的地方后面避坑章节我还会专门展开。3.2 从一行到四方向转置与翻转的技巧单行合并写对了接下来处理方向。常见做法是先按“行”取出二维数组的每一行向左合并写回向右合并则先反转行再合并再反转向上和向下则借助矩阵转置把列变成行合并完再转置回来。public void move(int direction) { int[][] before copyBoard(board); switch (direction) { case LEFT: // 每一行直接合并 for (int r 0; r 4; r) { board[r] mergeLine(board[r]).stream().mapToInt(i - i).toArray(); } break; case RIGHT: // 反转行合并再反转 for (int r 0; r 4; r) { int[] reversed reverse(board[r]); int[] merged mergeLine(reversed).stream().mapToInt(i - i).toArray(); board[r] reverse(merged); } break; case UP: // 转置后按行合并再转置回来 transpose(); for (int r 0; r 4; r) { board[r] mergeLine(board[r]).stream().mapToInt(i - i).toArray(); } transpose(); break; case DOWN: // 转置反转合并反转再转置 transpose(); for (int r 0; r 4; r) { int[] reversed reverse(board[r]); int[] merged mergeLine(reversed).stream().mapToInt(i - i).toArray(); board[r] reverse(merged); } transpose(); break; } // 只有内容真的变化了才生成新方块 if (!isSameBoard(before, board)) { spawnRandomTile(); scoreUpdate(); } }转置的代码就是两层循环交换行与列反向遍历就是倒着读。这段逻辑有一个容易忽视的细节每次合并前要copy一份棋盘作对比只有内容真的产生了变化才允许调用spawnRandomTile()生成新方块。否则用户往没有空位的方向滑动系统也会白白消耗一次随机。这个判断放到方向处理的外层所有分支共用一处。3.3 生成新方块与游戏结束判定三个边界条件新方块生成的位置必须在所有空格子里随机挑一个数值有90%概率是2、10%概率是4。实现可以用Random生成下标再遍历棋盘找空格填入。public void spawnRandomTile() { Listint[] emptyCells new ArrayList(); for (int r 0; r 4; r) { for (int c 0; c 4; c) { if (board[r][c] 0) { emptyCells.add(new int[]{r, c}); } } } if (emptyCells.isEmpty()) return; // 没有空格不生成 int[] cell emptyCells.get(random.nextInt(emptyCells.size())); board[cell[0]][cell[1]] (random.nextInt(10) 0) ? 4 : 2; }这里有第一个边界条件空格列表为空时直接return不能让随机数越界。第二个边界条件是游戏结束判定——棋盘满了且任何相邻格子都不相等才算结束。第三个边界条件是玩家达到2048以后游戏是继续还是弹窗这里我倾向于弹窗但保留继续玩的入口毕竟毕设要求的是功能完整不是一刀切。游戏结束的判定代码在四个方向上调用mergeLine之后只要有一个方向能够产生变化游戏就还能继续。这个判定发生在每次生成新方块之后频率不高性能上完全不需要优化。4. 让方块在屏幕上滑起来界面渲染、手势与动画算法决定游戏好不好玩界面决定答辩老师的第一印象。很多同学做2048喜欢用GridView或者RecyclerView来做棋盘但我觉得自定义View才是这一题的最佳答案——不是因为耗性能而是因为你要的移动动画用它才能做得干净。4.1 渲染方案选型为什么我不用GridViewGridView的适配器写法确实简单16个格子全在一个布局里但是有两个硬伤一个是合并动画很难做你要让方块从A格移动到B格用GridView只能靠notifyDataSetChanged整体刷新肉眼看上去就是数字瞬移缺失了滑动过渡第二个是触摸事件容易和子view的点击事件冲突GridView本身拦截事件的能力较弱需要额外处理。自定义View的优势在于整个棋盘是同一个view触控事件全在onTouchEvent里处理移动动画用属性动画驱动自由度更高。以4x4棋盘为例自定义View要处理的只是根据宽高均分16个格子、绘制背景色、绘制数字文字。代码量不大200行左右能画得像个样子而且答辩时你可以展开讲讲onDraw里怎么根据格子大小动态绘制圆角卡片。Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); float cellSize Math.min(getWidth(), getHeight()) / 4f; for (int r 0; r 4; r) { for (int c 0; c 4; c) { int val gameBoard.getTile(r, c); // 画背景卡片 drawRoundedRect(canvas, c * cellSize, r * cellSize, cellSize, cellSize, bgColorFor(val)); if (val ! 0) { // 在格子中央画数字 drawTextCentered(canvas, String.valueOf(val), c * cellSize cellSize / 2, r * cellSize cellSize / 2, textColorFor(val)); } } } }这里的核心参数是cellSize用Math.min保证横竖屏切换时卡片不会拉伸变形。bgColorFor和textColorFor是根据2048官方配色表做的映射数字越大背景越深网上有公开的色板可以直接用。4.2 手势监听用onTouchEvent还是GestureDetector两种方案都能用但我推荐最简单可靠的onTouchEvent加滑动距离判断。GestureDetector的onFling虽然能识别快速滑动但对慢速拖拽不够灵敏而2048的精髓恰恰是允许你慢慢滑。用onTouchEvent记录手指按下和抬起的位置计算位移向量超过一定阈值就触发方向移动。Override public boolean onTouchEvent(MotionEvent event) { switch (event.getActionMasked()) { case MotionEvent.ACTION_DOWN: downX event.getX(); downY event.getY(); return true; case MotionEvent.ACTION_UP: float dx event.getX() - downX; float dy event.getY() - downY; if (Math.abs(dx) touchSlop Math.abs(dy) touchSlop) { return true; // 幅度太小放弃 } if (Math.abs(dx) Math.abs(dy)) { move(dx 0 ? Direction.RIGHT : Direction.LEFT); } else { move(dy 0 ? Direction.DOWN : Direction.UP); } return true; } return super.onTouchEvent(event); }touchSlop这个参数很关键默认取系统提供的ViewConfiguration.getTouchSlop()大概在8到16像素之间。不要设成0否则手指轻微抖动也会触发移动。我没有用GestureDetector原因就是它把“阈值判断”藏在框架里一旦遇到滑不动、触发不灵敏的反馈你很难定位问题出在系统参数还是在你的业务逻辑里。4.3 移动动画用ValueAnimator让方块平滑过渡没有动画的2048也能玩但有动画感官上完全是另一个作品。常见做法是在mergeLine执行前记录每个非零块的起始位置合并完成后计算它的目标位置然后用ValueAnimator在两个位置之间插值每帧调用invalidate()触发重绘。public void animateTile(int startRow, int startCol, int endRow, int endCol) { ValueAnimator animator ValueAnimator.ofFloat(0f, 1f); animator.setDuration(120); animator.setInterpolator(new DecelerateInterpolator()); animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { Override public void onAnimationUpdate(ValueAnimator animator) { float fraction animator.getAnimatedFraction(); float curX startCol * cellSize (endCol - startCol) * cellSize * fraction; float curY startRow * cellSize (endRow - startRow) * cellSize * fraction; // 把curX,curY保存到临时绘制坐标invalidate触发重绘 tileOffsetX curX; tileOffsetY curY; invalidate(); } }); animator.start(); }动画时长120毫秒是我调过多次之后的体验值——小于80毫秒几乎看不见移动大于200毫秒会让连续滑动感觉粘手。插值器用DecelerateInterpolator方块先快后慢符合手指滑动的物理直觉。注意一个细节新生成的方块弹出动画不要和移动动画共用同一个Animator否则会遮盖“合成新值”的视觉反馈。5. 2048开发避坑实录合并顺序、连续合并与状态存储理论说了一堆到了真正写代码的时候有几个坑几乎是每一版2048都会踩一遍的。这些坑不致命但每个都会浪费你半天到一天的时间去排查提前避开比事后修要划算得多。5.1 从右往左滑的结果不对称方向处理的翻转陷阱现象同一行格子在向左滑时正确合并向右滑却出现类似[4, 0, 4, 0]的不合理结果甚至出现两个2没有合并。原因方向处理时没有对行做反转。向右滑动本质上应该从行的最右边开始合并但如果你直接调用mergeLine它永远从左边开始处理方向逻辑错了。解决统一采用“反转→合并→再反转”策略。向右滑先把整行反转变成从左往右处理合并完再反转回来。向上和向下同理必须借助转置把这个逻辑落地。检查时可以用一行数据手工推演原行为[2,2,4,0]反转得到[0,4,2,2]合并得到[0,4,4,0]反转回去为[0,4,4,0]这个结果和手动从右滑一致。推演一遍之后基本不会在方向上出问题。5.2 一个回合能连续合并两次合并标志位的血泪经验现象[2,2,4,0]合并一次应该得到[4,4,0,0]2和2先合掉但实际代码却输出[8,0,0,0]——2和2合成了4这个新4又和后面的4继续合并了。原因mergeLine的循环里合并后没有跳过被合并的元素。i只加1而合并动作本身占用了两个元素相当于下一个循环又读了同一个数值。解决在合并分支里主动执行i让循环跳过第二个元素。前文中3.1节的代码已经写了这一行。这是一个典型的“代码看起来逻辑正确、跑起来结果不对”的案例写单元测试时一定要覆盖[2,2,4,0]、[2,2,2,2]、[4,4,8,0]这组典型用例。5.3 新方块生成在错误位置随机数与刷新不同步现象滑动后新方块有时生成在已经有数字的格子上或者两次生成的位置完全一样。原因spawnRandomTile里的空格收集发生在mergeLine之前或者空集合判空用的是board遍历但实际board还没有被更新。另一个常见原因是把spawn调用放在了方向分支内部导致多次触发。解决把spawnRandomTile统一放在方向处理完成之后、UI刷新之前并且循环内保证每次都从最新的board取空格。如果你做了isSameBoard判断就把它和spawn放在同一层保证“合并失败就不生成”的语义。5.4 存档存得太频繁内存抖动与IO浪费现象游戏滑动一多界面出现卡顿log里频繁出现垃圾回收标记偶尔在退出时存档损坏。原因每次合并后都立刻把整个棋盘序列化写到磁盘。SharedPreferences本身是异步提交的但如果你在频繁滑动时连续commit会不断触发磁盘IO。游戏的每帧更新本来就很密集叠加上IO直接变成卡顿。解决把存档拆成两级——内存里维护一份LatestSnapshot只有在onPause、生成2048、游戏结束三个时机才写磁盘棋盘状态变化时只更新内存快照不碰磁盘。启动时先读磁盘恢复读取失败时用默认空棋盘兜底这样就算断电、强杀进程最多丢失当前这一步不会整体报废。5.5 动画绘制错位新旧坐标混用导致正方形拖影现象滑动后方块拖着残影走到新位置动画结束后停留在错误坐标。原因onDraw里读取的是动画插值出来的curX、curY但动画结束回调里没有把坐标归位到exact位置。下一次绘制时动画数值已经回到初始值0方块就瞬移回原点。解决在动画结束时把curX、curY显式设置为目标格的坐标再调用invalidate。这一点在AnimatorEndListener里处理不要依赖当前插值结果。6. 从及格到加分三个让答辩现场更稳的收尾技巧做到这里一个基础完整、界面能滑、能存档的2048已经躺进你的工程目录。接下来的进阶不需要铺很大的摊子三处小设计就能把你的项目从“完成”推向“有想法”。第一悔棋功能。实现方式不是回退算法而是维护一个Stackint[][]每次移动前把当前棋盘和分数压栈栈容量限制为20步。点击悔棋时弹栈恢复棋盘和分数并刷新界面。这个功能代码量不超过50行但答辩时你可以把它讲成“数据结构的实际应用”比单纯说一句“我用了ArrayList”要打动人得多。private StackGameSnapshot history new Stack(); private static final int MAX_HISTORY 20; public void moveWithHistory(int direction) { if (history.size() MAX_HISTORY) { history.remove(0); // 超出容量丢最老的一步 } history.push(new GameSnapshot(board, score)); move(direction); } public void undo() { if (history.isEmpty()) return; GameSnapshot snap history.pop(); board snap.board; score snap.score; updateView(); }第二最高分持久化策略。最高分不要和当前分混在同一个SharedPreferences键里否则你做了悔棋后很难判断“当前分”和“历史最高分”的关系。我遇到的常见情况是最高分被别人玩了一局刷新之后自己重开一局分数反而覆盖了它。我的做法是best_score只增不减每次得分后只在超出时才写入。第三答辩演示脚本提前设计你的操作顺序。先展示正常滑动的合并效果让评审建立直觉然后连续滑动制造高分局面按一下悔棋按钮把刚才一步撤回来最后打开飞行模式彻底清空本地存档重启App验证从崩溃中恢复的能力。如果时间充足现场改一下主题色——在自定义View里抽一个setThemeColor方法把颜色参数换成变量演示时让老师点一个按钮换色这一瞬间的互动感比你在台上讲十页PPT都有用。这些习惯是做了几轮毕设指导之后最想提前告诉你的。一次移动只做一次序列化、一次方向处理只允许一次合并、每一个可能返回空的列表都要判空——代码的坑几乎全是这三个原则的违反。希望这篇笔记能帮你少熬几个夜把省下来的时间用在真正加分的细节上。祝你顺利。本文还有配套的精品资源点击获取