安卓2048小游戏源码解析:从核心算法到自定义View实现

发布时间:2026/10/10 8:10:43
安卓2048小游戏源码解析:从核心算法到自定义View实现
简介面向安卓课程设计与期末大作业场景这是一份基于Android Studio的2048小游戏完整源代码。项目由个人手打并获导师认可代码含详细注释逻辑清晰下载解压后简单配置即可运行可作为高分项目范例参考。资源包共141个文件压缩包仅394KB内容以Java、XML为主Java文件承担游戏核心逻辑XML定义界面布局与资源配合Gradle构建脚本、webp图片素材及bat运行脚本结构紧凑便于学习。目前已有269人学习下载适合需要快速上手Android项目或提交大作业的读者。通过详细注释可掌握游戏状态管理、滑动事件监听、数字合成与分数计算等关键实现同时完整构建配置与资源文件能减少环境搭建成本方便对照自查或二次扩展既适合入门模仿也适合作为课程答辩展示素材。1. 从安卓大作业到能跑的 2048这份源代码包解决的是“能讲清”的问题很多人的安卓大作业清单里“基于 Android Studio 实现 2048 小游戏源代码”几乎是绕不过去的一项。它热门不是因为名字响亮而是难度刚好卡在一个能独立完成的位置数据逻辑是一套纯算法界面层只需要一块自绘棋盘一个人一周就能从骨架写到能答辩。这份资源就是一个能直接在 Android Studio 里打开运行的完整工程拆开看是由二维数组、手势识别、自绘 View 组成的三个清晰模块这也是我把它看成“能讲清楚的源码包”而不是“复制粘贴交差包”的原因。如果你只是想找一段代码改个颜色就交作业这份资源反而有点重但如果你想让老师问到底层逻辑时自己不虚它能帮你把 2048 讲成“一个 4×4 数组在四个方向上的状态迁移”而不是“我抄了一个网上很火的游戏”。下载解压后第一步不要急着往自己项目里拷文件用 Android Studio 的 Open 打开根目录先等 Gradle 同步完成、app 能跑起来再开始动手改。跳过这一步的人大概率会在依赖冲突和自己的语法错误之间来回翻车。2. 2048 核心算法先立住一行合并、方向归约与终局判定2.1 棋盘怎么存0、二维数组与随机开局2048 的棋盘就是一块 4×4 的方格最适合的数据结构是int[4][4]其中 0 代表空格2、4、8 这类数字直接存格子当前显示的值。这样做的直接好处是绘制时“数字是什么就画什么”不需要在像素和指数之间来回换算合并时也只需要做数值相等判断和乘法。开局的常见做法是在空棋盘里随机放两个数字大部分源码的实现逻辑都类似private int[][] mBoard new int[4][4]; private Random mRandom new Random(); private void initBoard() { for (int r 0; r 4; r) { Arrays.fill(mBoard[r], 0); // 清零 } addRandomTile(); // 开局放两个数字 addRandomTile(); mScore 0; } private void addRandomTile() { Listint[] empty new ArrayList(); for (int r 0; r 4; r) { for (int c 0; c 4; c) { if (mBoard[r][c] 0) empty.add(new int[]{r, c}); } } if (empty.isEmpty()) return; // 没有空位就不再生成 int[] pos empty.get(mRandom.nextInt(empty.size())); mBoard[pos[0]][pos[1]] mRandom.nextInt(10) 0 ? 4 : 2; }这里有两个值得注意的参数新块 90% 概率生成 2、10% 概率生成 4。这是 2048 系游戏最流行的一组手感参数改大 4 的概率会让游戏节奏变快、难度上升。addRandomTile()在每次有效移动之后都会被调用但要在移动确实改变了棋盘时才调用这一点放到 2.3 小节详细说因为它是很多半成品源码翻车的高发地。2.2 一行合并的三步压缩、合并且只合并一次、再压缩整个 2048 的算法核心其实只有一行把 4 个数字按照规则合并。最清晰的做法不是对 4×4 棋盘写四份不同方向的循环而是先写出“合并一行”的函数再把四个方向都归约成逐行调它。public static int[] mergeLine(int[] line) { int[] tmp new int[line.length]; // 存放第一次压缩的结果 int idx 0; for (int v : line) { if (v ! 0) tmp[idx] v; // 去掉0把所有数字压到左侧 } for (int i 0; i idx - 1; i) { if (tmp[i] tmp[i 1]) { tmp[i] * 2; // 相邻相等就合并 tmp[i 1] 0; i; // 跳过被合并的格子防止二次合并 } } int[] result new int[line.length]; idx 0; for (int v : tmp) { if (v ! 0) result[idx] v; // 把合并留下的0再压掉 } return result; }以[0,2,0,2]为例第一步压缩得到[2,2,0,0]合并循环里 2 和 2 相等得到[4,0,0,0]第二次压缩结果不变。这就是玩家看到的“两个 2 合成一个 4”。注意合并循环里的i如果漏掉它[2,2,2,2]会变成[8,0,0,0]而不是正确的[4,4,0,0]。这个细节是 2048 合并规则的灵魂一排数字里每个数字每轮只能参与一次合并。四个方向的归约做法是左滑就直接对每行调用mergeLine右滑先反转每行合并完再反转回来上滑先转置矩阵做左滑操作再转置回来下滑先转置、再对每行反转做左滑、再反转、再转置。这套“转置 反转 复用同一行函数”的写法比复制粘贴四份循环清晰得多以后想改成 5×5 棋盘也只需要把数组长度从 4 改掉方向逻辑一行都不用动。2.3 新块生成时机与终局判定两个最容易翻车的边界新块生成有个前提必须在“有效移动”之后。有效移动的定义是滑动前后棋盘数组发生了变化。比如[2,0,0,0]左滑还是[2,0,0,0]这就是无效滑动不应该生成新块。有些源码在每次 onTouchEvent 后都无条件调用addRandomTile()结果玩家原地划几下棋盘上突然多出好几个数字。判断有效移动的常见做法是先深拷贝棋盘移动后比较private boolean move(int direction) { int[][] before copyBoard(mBoard); // 深拷贝当前棋盘 // 按 direction 变换、调用 mergeLine、把结果回写 mBoard if (boardEquals(before, mBoard)) { return false; // 棋盘没变化属于无效滑动 } addRandomTile(); // 只有变化了才补一个新块 return true; }深拷贝必须逐格复制成一个新数组而不能直接赋值引用否则 before 和 mBoard 指向同一块内存比较永远相等。游戏结束判定也有一个常见误解以为需要检查上下左右四个方向其实只需要检查“还有没有空格”和“是否存在相邻相等的数字”public boolean canMove() { for (int r 0; r 4; r) { for (int c 0; c 4; c) { if (mBoard[r][c] 0) return true; // 还有空位 if (r 1 4 mBoard[r][c] mBoard[r 1][c]) return true; if (c 1 4 mBoard[r][c] mBoard[r][c 1]) return true; } } return false; }这里只检查下方和右方就已经覆盖了全部相邻关系任意两个相等格子必然一个是另一个的下方或右方。每次移动后调用canMove()返回 false 就进入游戏结束流程。2.4 把算法从 View 里剥出来GameLogic 独立类的作用源码里最值得保留的结构是单独开一个类只放算法函数不碰 Context、不碰 View。我会在类似项目里新建GameLogic.java把mergeLine、move、canMove、spawnRandomTile全放进去里面不 import 任何 android 包。这个独立类带来三个能力JVM 单元测试验证算法、换界面实现不需要重写逻辑、排查问题时空指针概率大幅下降。这套接口不要求固定名字但任何一个结构清晰的 2048 源码里基本都能看到对应的四个方法方法入参返回值作用mergeLineint[] 一行 4 个元素int[] 合并后的一行处理单行合并move方向常量 当前棋盘boolean 是否发生有效移动驱动整盘状态迁移canMove当前棋盘boolean 是否还能继续终局判定spawnRandomTile当前棋盘boolean 是否成功生成随机补充新块如果拿到的源码把这段逻辑和 onDraw 画在同一个类里建议你动手拆出来。拆的时候保持方法签名不变View 的调用处几乎不用修改但后续所有调试都会轻松很多。3. Android 层手势与绘制自定义 View 把数组变成一块能玩的棋盘3.1 手势方向判定DOWN/UP 两段式与 dp 阈值消抖2048 不需要跟手滑动常见方案是 ACTION_DOWN 记下起点ACTION_UP 算位移方向。这里最大的坑是“快速滑动时的坐标抖动”手指按下时可能在几毫秒内移动十几像素如果不设阈值点一下都会变成一个方向。阈值用 dp 换算而不是写死像素值才能在高低密度屏上表现一致。float downX 0, downY 0; 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; int threshold (int) (16 * getResources().getDisplayMetrics().density); if (Math.abs(dx) threshold Math.abs(dy) threshold) 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); }16dp 在主流屏幕上约等于 48 到 64 像素能过滤掉手指轻微移动和点击。觉得游戏“很难滑出方向”把阈值降到 8dp觉得“老是误触”升到 24dp。这两个值就是这份源码里最值得调的第一个参数。斜滑的判断用 dx/dy 绝对值比较而不是先判断横竖这样在接近 45° 的滑动时也能有比较稳定的表现。3.2 自绘棋盘onDraw 里的格子、颜色与字号2048 用自绘 View 而不是 GridView Adapter理由很实际GridView 刷新时要重建整个 Item 列表成本高动画难控制自绘只需要在 onDraw 里画 16 个格子每次滑动后局部重绘即可。对一个画面简洁、状态简单的游戏自绘是最低成本的实现方式这也是“自定义 View”知识点的一个最小实践。Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); int size Math.min(getWidth(), getHeight()); // 取短边做画布 float density getResources().getDisplayMetrics().density; float padding 10 * density; // 外框留白 float gap 4 * density; // 格子间距 float cell (size - padding * 2 - gap * 3) / 4f; // 每格边长 Paint bg new Paint(); bg.setColor(0xFFBBADA0); // 经典棋盘底色 canvas.drawRoundRect(0, 0, size, size, 12 * density, 12 * density, bg); for (int r 0; r 4; r) { for (int c 0; c 4; c) { float left padding c * (cell gap); float top padding r * (cell gap); int value mBoard[r][c]; Paint cellPaint new Paint(); cellPaint.setColor(tileColor(value)); canvas.drawRoundRect(left, top, left cell, top cell, 8 * density, 8 * density, cellPaint); if (value 0) { drawNumber(canvas, String.valueOf(value), left, top, cell); } } } }格子边长的计算必须统一用 pxdp 先乘 density 再参与除法和布局直接拿 dp 和 px 混算在部分机型上会出现格子溢出画布这就是很多人“模拟器正常、真机串位”的血泪经验。数字颜色按格子值映射最流行的一套配色如下格子值背景色 ARGB00xFFCDC1B420xFFEEE4DA40xFFEDE0C880xFFF2B179160xFFF59563320xFFF67C5F640xFFF65E3B1280xFFEDCF722560xFFEDCC615120xFFEDC85010240xFFEDC53F20480xFFEDC22E20480xFF3C3A32字号不要固定按数字位数收缩一位数用格子边长的 0.45两位数用 0.36三位以上再继续缩小。drawNumber里把 Paint 的 TextAlign 设成 CENTER文字垂直居中用baseline top cell / 2 - (fontMetrics.ascent fontMetrics.descent) / 2直接拿getHeight() / 2套会偏上。3.3 MainActivity 接线从 View 挂载到分数刷新回调自绘 View 写好后Activity 的职责就很简单找 View、找分数 TextView、设置重新开始按钮。常见的正确接线方式是这个样子public class MainActivity extends AppCompatActivity { private Game2048View mGameView; private TextView mScoreView; Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); mGameView findViewById(R.id.game_view); mScoreView findViewById(R.id.score_view); mGameView.setOnScoreChangedListener(score - mScoreView.setText(String.valueOf(score))); mGameView.setBestScore(loadBestScore()); } }分数刷新的正确时机是在合并完成之后、invalidate 之前。如果先重绘再通知分数界面会先画旧数字玩家看到“分数慢半拍”的视觉误差。重新开始按钮同样要走完整流程调用 reset() 清空棋盘和分数再 addRandomTile 两次。很多半成品源码只清了棋盘不放回数字点“重新开始”后开局是空棋盘要滑一下才出现数字体验上就像游戏还没就绪。3.4 最高分持久化SharedPreferences 的读写时机游戏状态里最简单的持久化是当前分数和最高分。我的习惯是最高分在每次分数变化时判断并写入而不是统一放在 onPause 里写。这样即使进程被系统杀掉最高分也不会丢。private void updateBestScore(int currentScore) { SharedPreferences sp getContext() .getSharedPreferences(2048_pref, Context.MODE_PRIVATE); int best sp.getInt(best_score, 0); if (currentScore best) { sp.edit().putInt(best_score, currentScore).apply(); } }读取时机放在 onCreate 里显示到最高分 TextView 后再开始游戏。如果只存最高分而不存棋盘旋转屏幕后分数还在但棋盘会丢这个问题放到下一章它是 2048 项目里最容易被问到也最容易答不上来的坑。4. 源码包实战中的常见问题与避坑五条我替你踩过的坑4.1 旋转屏幕后棋盘清零onSaveInstanceState 只救了一半现象横竖屏切换后最高分还在棋盘上的数字却全没了。很多人的第一反应是“游戏重开了”其实是 Activity 重建后自定义 View 里的 mBoard 字段没有跟着恢复。原因自定义 View 的 mBoard 是普通字段系统默认不会保存。给 View 设置了 android:id 后系统确实会调用 View.onSaveInstanceState但它保存的是滚动位置、文本状态这类基础信息不会知道你的 int[][] 棋盘里装着什么。解决在 Activity 里把棋盘扁平化成 int[16] 存进 Bundle重建时还原Override protected void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); int[] flat new int[16]; int[][] board mGameView.getBoard(); for (int r 0; r 4; r) { for (int c 0; c 4; c) { flat[r * 4 c] board[r][c]; } } outState.putIntArray(board, flat); outState.putInt(score, mGameView.getScore()); }onCreate 里拿到savedInstanceState后把 flat 数组传回 View再做一次二维还原。另一个更省心的方案是把棋盘也写进 SharedPreferences每次有效移动后同步存档坏处是滑动一次写一次盘性能略差作业项目里用 Bundle 方案就够了。4.2 [2,2,2,2] 左滑变成 8连续合并的经典错误现象屏幕上四个 2 排成一排左滑后变成一个 8而不是两个 4。这个错误特别隐蔽因为单看结果好像也没问题但 4×4 棋盘里一旦出现这类连续合并游戏进程会明显变快整局难度失衡。原因合并循环里没有“跳过被合并过的格子”。第一次合并把前两个 2 变成 4 后循环指针继续右移又拿这个 4 去和后面的 2 比较一路吃下去就变成了 8。错误写法长这样for (int i 0; i idx - 1; i) { if (tmp[i] tmp[i 1]) { tmp[i] * 2; tmp[i 1] 0; // 少了 i下一步会把刚合并出来的 4 再参与比较 } }解决合并完成后手动 i 跳过被清空的格子。对照 2.2 节的正确写法[2,2,2,2]应该得到[4,4,0,0][4,4,4,0]应该得到[8,4,0,0]。这两组输入输出值得记下来后面写单元测试直接用。4.3 快速滑动误判方向阈值、斜滑与多指干扰现象明明向左滑游戏判定成了向上或者连续快速滑动时偶发方向错乱。这种问题在模拟器上很难复现在真机上特别明显属于输入识别层面的经典翻车。原因阈值设太小比如写死 10px手指按下时轻微抖动就会被算成一次长距离另一个是多指操作——第二根手指落下的瞬间又触发一次 ACTION_DOWN把起始坐标重置了最终位移方向自然不对。解决阈值用 dp 换算并按 3.1 节的代码实现方向判定用绝对值比较多指干扰用getActionMasked()配合ACTION_POINTER_DOWN过滤只以第一根手指的坐标为准。如果发现真机上滑动偏“肉”可以先检查 View 是否被父容器拦截了事件2048 这种全屏自绘 View 通常不会遇到但嵌在 ScrollView 里就会。4.4 分数在涨棋盘不动invalidate 与数据回写现象每次滑动分数都在涨棋盘的格子却几乎没变化或者操作几次后突然数组越界。这类问题的现场最有迷惑性因为分数确实是合并逻辑算出来的看起来像“界面 bug”其实是状态同步断了。原因合并逻辑作用在局部数组上move 结束后没有把结果回写到 mBoard或者数据改了但忘了调用 invalidate()界面永远画的是旧数组。越界则是回写时用了错误的下标。解决把移动流程固定成五步顺序不能乱深拷贝当前棋盘作为 before按方向变换、调 mergeLine、再变换回原坐标把合并结果逐格回写 mBoard比较 before 和 mBoard变化了就 addRandomTile invalidate 刷新分数检查 canMovefalse 就进入游戏结束。回写这一步最容易省掉代码就一行int[] merged GameLogic.mergeLine(mBoard[r]); System.arraycopy(merged, 0, mBoard[r], 0, 4);如果拿到的源码里 move 方法同时“返回分数”和“修改棋盘”要先确认它改完棋盘再返回分数。顺序反了分数会提前累加界面显示同步错乱。4.5 导入源码报资源重复或 SDK 版本不匹配先清 build 目录再看版本现象用 Android Studio 打开别人打包的工程一同步就报resource linking failed或者duplicate resources看起来像是源码本身有问题。原因压缩包里残留了旧电脑的 build 目录、.gradle 缓存或者工程用的 compileSdk 版本和你本机安装的 SDK 不一致。资源重复则通常来自 drawable 里同名不同后缀的文件以及不同 values 目录下的重复定义。解决先关掉项目把工程根目录下的 build/ 和 .gradle/ 整个删掉再重新打开让 IDE 重新构建这一步能解决大半同步报错。剩下编译不过的打开 app/build.gradle把 compileSdk、targetSdk 改成你自己环境里已安装的版本再 Sync 一次。这个操作对任何“从别的电脑移植 Android Studio 项目”都适用。5. 进阶验证与自定义用单测和 adb 滑动把这份源码变成你的作品5.1 用 JUnit 给合并逻辑上保险不靠手感因为第 2 章的 GameLogic 不依赖任何 Android 类它可以直接在 JVM 单元测试里跑。在 app/src/test 目录下新建 GameLogicTest把 2.2 节那两个关键用例固化下来public class GameLogicTest { Test public void mergeLine_twoAndTwo_makesFour() { int[] input {2, 2, 0, 0}; assertArrayEquals(new int[]{4, 0, 0, 0}, GameLogic.mergeLine(input)); } Test public void mergeLine_fourTwos_noDoubleMerge() { int[] input {2, 2, 2, 2}; assertArrayEquals(new int[]{4, 4, 0, 0}, GameLogic.mergeLine(input)); } }运行方式很简单在左侧 Project 窗口里找到 GameLogicTest右键选择 Run不需要模拟器也不需要真机。手动滑动时你很难判断 [2,2,2,2] 到底应该变成什么单测一旦跑通以后改成 5×5 棋盘、改成动画合并逻辑都不会出现“改一个功能坏一个功能”的回归。验证完逻辑再在模拟器里执行adb shell input swipe 300 300 100 300从 (300,300) 滑到 (100,300) 就是一次标准左滑坐标要落在游戏区域里。这一条命令能立刻确认手势方向映射有没有写反比反复用手指滑快得多。5.2 三个低成本加分项动画时长、回退栈与颜色表想给答辩加分的不建议去改玩法而是做三个低成本小点。合并动效用 ValueAnimator 记录每个格子的起始坐标和结束坐标时长 120ms 到 150ms 是主流手感动画结束那一帧提交最终棋盘数组不需要引入第三方库。回退一步用一个Dequeint[]每次有效移动前把当前 16 个格子压栈回退时弹栈并 restore栈深限制在 20 步防止内存增长。主题变化则最简单把 3.2 节的颜色表整体换掉或者把棋盘背景改成圆角渐变逻辑一行不动。这三个点分别对应“动画实现”“数据结构应用”“绘制封装”都能在问答环节讲出实际细节。我的习惯是拿到任何一份 2048 源码后先把 mergeLine 的单测跑一遍再在模拟器里adb shell input swipe验证一次方向映射确认无误才开始改代码。从那以后我每次调 2048 相关项目都强制走这一遍流程再谈改功能基本没在演示中途翻过车。希望帮到你。本文还有配套的精品资源点击获取