
简介这是一份基于 Android Studio 开发的 2048 小游戏完整源代码面向高校学生与安卓初学者可用于课程设计、期末大作业或自学练手帮助解决从零搭建安卓项目、理解游戏逻辑与界面布局的实际问题。压缩包共 141 个文件约 394KB以 50 个 xml 布局与资源文件、21 个 java 逻辑代码、30 个 webp 图片素材为主另含 gradle 构建脚本、properties 配置及少量 jar 依赖结构完整、开箱即用。代码中带有详细注释新手也能看懂每一步实现思路简单部署即可运行体验。目前已有 269 人学习下载适合需要一份可直接参考、导师认可度较高的安卓项目案例的读者既能作为作业提交模板也能借此掌握 Activity 生命周期、自定义 View 绘制、手势滑动与分数存储等核心知识点。1. 从一份 2048 源码说起安卓大作业为什么总拿它当模板每年到了期末总有人翻出 Android Studio 新建一个空项目然后对着 2048 的界面发呆。这个标题看着简单——基于 Android Studio 实现的 2048 小游戏源代码安卓大作业——但它背后其实藏着一条完整的安卓入门链路从工程结构、自定义 View 绘制、手势识别到状态保存和 APK 打包。2048 之所以成为经典作业题不是因为它简单而是因为它把安卓开发里最核心的几块知识压缩在了一个不到千行的项目里。你写一遍等于把 Activity 生命周期、触摸事件分发、SharedPreferences 持久化全过了一遍。这篇文章不讲空泛的“安卓开发概述”而是直接按一份可运行的 2048 源码结构把每个模块拆开讲清楚代码怎么组织、参数怎么调、哪里容易翻车、怎么编译成 APK 交作业。适合正在找安卓大作业选题的学生也适合想用一个小项目快速熟悉 Android Studio 工作流的开发者。2. 工程结构与核心类拆解2048 源码里到底有几个文件2.1 一个能跑的 2048 项目最少需要哪些文件很多人拿到一份 2048 源代码第一反应是直接导入 Android Studio 然后点运行。但如果你不知道每个文件负责什么改一个颜色都可能引发编译报错。一个标准的 2048 安卓项目核心文件其实只有六七个剩下的都是 Gradle 配置和资源文件。先看工程目录的典型结构app/src/main/java/com/example/game2048/ MainActivity.java // 入口 Activity承载游戏视图 GameView.java // 自定义 View负责绘制和触摸 GameManager.java // 游戏逻辑合并、移动、计分 Card.java // 单个数字格子的数据模型 app/src/main/res/ layout/activity_main.xml // 布局文件通常只放一个 GameView values/colors.xml // 数字方块的颜色定义 values/strings.xml // 分数、标题等文本 app/build.gradle // 模块级构建配置这个结构不是唯一的有些源码会把逻辑全部塞进一个 GameView 里代码量看起来少但后期改起来非常痛苦。我一般建议按“数据模型 → 游戏逻辑 → 视图渲染 → Activity 宿主”四层来分这样每个类的职责清晰调试的时候也容易定位问题。MainActivity 的职责非常轻它只做三件事设置布局、初始化 GameView、处理返回键时的状态保存。真正复杂的是 GameView 和 GameManager 之间的配合。GameView 继承自 View重写 onDraw 和 onTouchEvent前者负责把 4x4 的格子画出来后者负责识别滑动方向。GameManager 则是一个纯逻辑类不依赖任何安卓 SDK输入是一个 4x4 的整型数组输出是移动后的新数组和得分增量。这种分离的好处是你可以在电脑上用 JUnit 单独测试 GameManager 的合并逻辑不需要启动模拟器。很多同学的 2048 跑不起来问题往往出在 GameView 的坐标计算上而不是逻辑本身。2.2 自定义 View 的绘制流程与坐标映射GameView 是整个项目里最容易出 bug 的地方。它的核心任务是把一个 4x4 的二维数组映射到屏幕上的像素坐标。假设你的 View 宽度是w高度是h那么每个格子的边长cardSize应该是Math.min(w, h) / 4再减去间隙。这里有一个常见的坑如果直接用w / 4在横屏或者不同宽高比的手机上格子会变成矩形数字就歪了。下面是一段典型的 onDraw 实现Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); // 计算格子边长取宽高较小值保证正方形 int cardSize Math.min(getWidth(), getHeight()) / 4; // 计算整体偏移让棋盘居中 int offsetX (getWidth() - cardSize * 4) / 2; int offsetY (getHeight() - cardSize * 4) / 2; for (int row 0; row 4; row) { for (int col 0; col 4; col) { int value cards[row][col].getValue(); // 计算左上角坐标 float left offsetX col * cardSize; float top offsetY row * cardSize; // 根据数值选颜色 paint.setColor(getColorForValue(value)); // 留出 4px 间隙避免方块粘连 canvas.drawRoundRect(left 4, top 4, left cardSize - 4, top cardSize - 4, 8, 8, paint); // 绘制数字 if (value 0) { paint.setColor(Color.BLACK); paint.setTextSize(cardSize / 3f); paint.setTextAlign(Paint.Align.CENTER); float textY top cardSize / 2f - (paint.descent() paint.ascent()) / 2; canvas.drawText(String.valueOf(value), left cardSize / 2f, textY, paint); } } } }这段代码里有两个参数值得注意。一个是cardSize / 3f这是数字的字体大小。如果你设成cardSize / 2f四位数比如 2048 就会超出格子边界。另一个是drawRoundRect里的 4px 间隙这个值在小屏幕上合适但在平板上一格几百像素4px 几乎看不见方块会连成一片。我一般会把它改成cardSize / 20这样在不同屏幕上间隙比例一致。坐标映射的逻辑说穿了就是先算格子大小再算居中偏移最后用行列号乘以格子大小加上偏移量。任何一步算错方块就会跑偏。如果你发现方块全部挤在左上角大概率是 offset 没加如果方块大小不对检查是不是用了getWidth()而不是Math.min。2.3 触摸事件与滑动方向判定2048 的操作只有四个方向上、下、左、右。在安卓里你需要重写onTouchEvent记录手指按下的起点和抬起的终点然后比较横向和纵向的位移差。private float startX, startY; Override public boolean onTouchEvent(MotionEvent event) { switch (event.getAction()) { case MotionEvent.ACTION_DOWN: startX event.getX(); startY event.getY(); return true; case MotionEvent.ACTION_UP: float dx event.getX() - startX; float dy event.getY() - startY; // 设置一个最小滑动阈值防止误触 if (Math.abs(dx) 50 Math.abs(dy) 50) { return true; } if (Math.abs(dx) Math.abs(dy)) { // 横向滑动 if (dx 0) moveRight(); else moveLeft(); } else { // 纵向滑动 if (dy 0) moveDown(); else moveUp(); } invalidate(); // 触发重绘 return true; } return super.onTouchEvent(event); }这里的 50 是滑动阈值单位是像素。设得太小手指轻微抖动就会触发移动设得太大滑动很用力才有反应。在 1080p 手机上50 到 80 之间比较合适。如果你想让手感更跟手可以改成基于屏幕密度的计算int threshold (int) (50 * getResources().getDisplayMetrics().density);。还有一个容易忽略的点onTouchEvent必须返回 true否则后续的 ACTION_UP 事件不会传进来。很多新手只处理了 DOWN 和 UP但忘了在 DOWN 里返回 true结果滑动完全没反应。3. 游戏逻辑实现合并算法与状态管理3.1 4x4 矩阵的移动与合并规则2048 的核心逻辑可以用一句话概括把所有非零数字往一个方向推相邻且相等的合并合并后的数字翻倍。但真正写起来边界条件比想象的多。以向左移动为例每一行的处理分三步先把非零数字挤到左边然后从左到右扫描相邻相等就合并最后再挤一次。为什么合并后还要再挤一次因为像[2, 2, 2, 2]这种情况第一次合并变成[4, 4, 0, 0]如果不重新挤右边的 4 就不会继续参与合并。但按照 2048 的规则一次移动中每个格子只能合并一次所以[2, 2, 2, 2]向左应该变成[4, 4, 0, 0]而不是[8, 0, 0, 0]。下面是一个向左移动的完整实现private boolean moveLeft() { boolean moved false; for (int row 0; row 4; row) { int[] line new int[4]; int index 0; // 第一步把非零数字挤到左边 for (int col 0; col 4; col) { if (cards[row][col].getValue() ! 0) { line[index] cards[row][col].getValue(); } } // 第二步相邻合并每个格子只合并一次 for (int i 0; i 3; i) { if (line[i] ! 0 line[i] line[i 1]) { line[i] * 2; line[i 1] 0; score line[i]; moved true; } } // 第三步再次挤到左边 int[] result new int[4]; int idx 0; for (int i 0; i 4; i) { if (line[i] ! 0) { result[idx] line[i]; } } // 写回棋盘同时判断是否有变化 for (int col 0; col 4; col) { if (cards[row][col].getValue() ! result[col]) { moved true; } cards[row][col].setValue(result[col]); } } return moved; }这段代码里moved变量很关键。它决定了这次移动是否有效如果棋盘没有任何变化就不应该生成新数字。很多源码忘了返回这个标志导致玩家往左滑但棋盘已经满了还是会冒出一个新数字游戏直接卡死。向右、向上、向下的逻辑完全一样只是遍历顺序不同。向右就是从右往左扫向上就是按列处理。你可以写四个方法也可以写一个通用方法加方向参数。我倾向于写四个独立方法虽然代码重复但调试的时候一眼就能看出是哪个方向出了问题。3.2 随机生成新数字与游戏结束判定每次有效移动后需要在空白格子里随机选一个位置填入 2 或 4。2 的概率是 90%4 是 10%。这个比例不是随便定的它直接影响游戏难度。如果你改成 50% 出 4棋盘会很快被大数字填满游戏体验非常差。private void addRandomCard() { Listint[] emptyCells new ArrayList(); for (int row 0; row 4; row) { for (int col 0; col 4; col) { if (cards[row][col].getValue() 0) { emptyCells.add(new int[]{row, col}); } } } if (emptyCells.isEmpty()) return; int[] pos emptyCells.get(new Random().nextInt(emptyCells.size())); // 90% 概率生成 210% 生成 4 int value new Random().nextFloat() 0.9f ? 2 : 4; cards[pos[0]][pos[1]].setValue(value); }游戏结束的判定条件是棋盘已满且没有任何相邻的格子数值相等。这个判断必须同时检查横向和纵向。private boolean isGameOver() { // 还有空格子肯定没结束 for (int row 0; row 4; row) { for (int col 0; col 4; col) { if (cards[row][col].getValue() 0) return false; } } // 检查横向是否有相邻相等 for (int row 0; row 4; row) { for (int col 0; col 3; col) { if (cards[row][col].getValue() cards[row][col 1].getValue()) { return false; } } } // 检查纵向是否有相邻相等 for (int col 0; col 4; col) { for (int row 0; row 3; row) { if (cards[row][col].getValue() cards[row 1][col].getValue()) { return false; } } } return true; }这里有一个性能上的小细节isGameOver在每次移动后都会被调用但它遍历了整个棋盘。对于 4x4 的规模这点开销可以忽略不计。但如果你以后想扩展成 5x5 或 6x6最好把空格子数量缓存起来避免每次都全量扫描。3.3 用 SharedPreferences 保存最高分和游戏状态大作业通常要求“退出后重新打开最高分还在”。安卓里最简单的持久化方案就是 SharedPreferences。它适合存少量键值对比如最高分、当前分数、棋盘状态。// 保存最高分 SharedPreferences sp getSharedPreferences(game2048, MODE_PRIVATE); int highScore sp.getInt(high_score, 0); if (score highScore) { sp.edit().putInt(high_score, score).apply(); } // 保存棋盘状态把 4x4 数组转成字符串 StringBuilder sb new StringBuilder(); for (int row 0; row 4; row) { for (int col 0; col 4; col) { sb.append(cards[row][col].getValue()).append(,); } } sp.edit().putString(board_state, sb.toString()).apply();读取的时候反向解析即可。注意apply()是异步写入commit()是同步写入。对于游戏状态保存用apply()就够了不会阻塞主线程。但如果你在onPause()里保存而用户马上杀进程apply()有可能还没写完。保险起见可以在onPause()里用commit()。棋盘状态的字符串格式我一般用逗号分隔解析的时候用split(,)。但要注意如果最后一个字符也是逗号split会产生一个空字符串需要额外处理。更稳妥的做法是用 JSON但为了一个大作业引入 Gson 有点重手动拼字符串完全够用。4. 避坑与排查2048 安卓大作业里最容易翻车的 5 个地方4.1 模拟器上跑得好好的真机上方块错位现象在 Android Studio 自带的 Pixel 模拟器上棋盘居中、方块大小正常。装到自己的手机上方块要么偏左要么大小不对。原因模拟器的分辨率是固定的而真机有各种屏幕尺寸和状态栏高度。如果你的 GameView 在布局里用了match_parent但 Activity 又设置了全屏或者沉浸式状态栏getHeight()返回的值会包含状态栏区域导致offsetY计算偏大。解决在onDraw里不要直接用getWidth()和getHeight()而是用getPaddingLeft()、getPaddingTop()减去内边距。或者在布局里给 GameView 加一个固定宽高比的容器比如ConstraintLayout里设置layout_constraintDimensionRatio1:1让 View 始终是正方形。4.2 滑动一次触发了多次移动现象手指从左滑到右棋盘上的数字往右移动了两三次甚至直接游戏结束。原因onTouchEvent里没有做防抖处理。ACTION_MOVE 事件在滑动过程中会触发几十次如果你在 MOVE 里也调用了移动逻辑就会重复执行。解决只在 ACTION_UP 里判断方向并执行移动ACTION_MOVE 里什么都不做。或者加一个布尔标志isMoving移动开始前设为 true动画结束后设为 false在 false 的时候才响应新的滑动。4.3 合并后的数字没有动画看起来像闪了一下现象数字合并时直接跳变没有缩放或者渐变动画体验很生硬。原因onDraw是即时重绘没有过渡帧。很多源码为了简单合并后直接invalidate()视觉上就是瞬间变化。解决如果只是大作业不加动画也能过。但如果想拿高分可以用ValueAnimator做一个简单的缩放效果。在合并发生时记录合并位置然后在onDraw里根据动画进度调整方块大小。注意动画期间要持续调用invalidate()否则不会刷新。4.4 编译成 APK 时提示签名错误现象在 Android Studio 里点运行没问题但点 Build → Build APK 时提示Keystore file not found或者SigningConfig is missing。原因调试版本用的是自动生成的 debug 签名但发布 APK 需要你手动创建一个签名文件。很多同学直接点 Build APK没有配置 signingConfig。解决在 Build → Generate Signed Bundle / APK 里新建一个 keystore 文件记住密码和别名。然后在build.gradle的android块里加上signingConfigs { release { storeFile file(your.keystore) storePassword your_password keyAlias your_alias keyPassword your_password } } buildTypes { release { signingConfig signingConfigs.release minifyEnabled false } }如果只是交作业用 debug 签名也够但老师如果要求装到手机上debug APK 在某些机型上会被拦截。4.5 游戏结束后点重新开始分数没有清零现象游戏结束后弹出对话框点“重新开始”棋盘重置了但分数还是上一局的。原因重置逻辑只清了cards数组忘了把score变量归零或者忘了刷新 UI。解决写一个resetGame()方法把棋盘、分数、游戏结束标志全部重置然后调用invalidate()。注意如果用了 SharedPreferences 存最高分重置时不要清最高分只清当前分数。5. 从能跑到好用给 2048 加一个撤销功能和性能优化5.1 用栈实现一步撤销2048 玩到后期一步走错就满盘皆输。加一个撤销功能大作业的完成度会高很多。实现思路很简单每次有效移动前把当前棋盘状态和分数压入一个栈撤销时弹出栈顶恢复。private Stackint[][] historyStack new Stack(); private StackInteger scoreStack new Stack(); private void saveState() { int[][] snapshot new int[4][4]; for (int row 0; row 4; row) { for (int col 0; col 4; col) { snapshot[row][col] cards[row][col].getValue(); } } historyStack.push(snapshot); scoreStack.push(score); // 限制栈深度防止内存无限增长 if (historyStack.size() 20) { historyStack.remove(0); scoreStack.remove(0); } } private void undo() { if (historyStack.isEmpty()) return; int[][] snapshot historyStack.pop(); score scoreStack.pop(); for (int row 0; row 4; row) { for (int col 0; col 4; col) { cards[row][col].setValue(snapshot[row][col]); } } invalidate(); }这里用Stack的remove(0)来限制深度是因为Stack继承自Vector支持按索引删除。但remove(0)的时间复杂度是 O(n)对于 20 层的栈来说可以忽略。如果你追求极致可以用LinkedList实现一个固定大小的队列。注意撤销功能只能撤销有效移动如果玩家滑了一下但棋盘没变不应该压栈。所以在调用saveState()之前先判断moveLeft()等方法的返回值是否为 true。5.2 减少 onDraw 里的对象创建onDraw会被频繁调用如果里面每次都new Paint()或者new Rect()GC 压力会很大表现为滑动时偶尔卡顿。正确的做法是把 Paint 和 Rect 作为成员变量在构造函数里初始化一次。private Paint paint new Paint(); private RectF rect new RectF(); Override protected void onDraw(Canvas canvas) { // 复用 paint 和 rect不在循环里 new for (int row 0; row 4; row) { for (int col 0; col 4; col) { rect.set(left, top, right, bottom); canvas.drawRoundRect(rect, 8, 8, paint); } } }另外getColorForValue方法里不要用switch每次重新计算颜色可以提前用一个SparseIntArray把数值和颜色映射缓存起来。这些优化在模拟器上看不出差别但在低端真机上滑动流畅度会有明显提升。5.3 验证游戏逻辑正确性的三个测试用例写完逻辑后不要只靠手玩来验证。手玩很容易漏掉边界情况。我一般会写三个 JUnit 测试用例直接在电脑上跑 GameManager。测试场景输入棋盘向左移动期望输出验证点连续相同数字[2, 2, 2, 2][4, 4, 0, 0]每个格子只合并一次间隔相同数字[2, 0, 2, 0][4, 0, 0, 0]非相邻不合并但会挤到一起已满且无合并[2, 4, 2, 4][2, 4, 2, 4]返回 false不生成新数字这三个用例覆盖了合并逻辑里最容易出错的三种情况。如果你写的moveLeft在第一个用例输出[8, 0, 0, 0]说明合并后没有限制单次合并如果在第二个用例输出[2, 2, 0, 0]说明挤到左边的逻辑写错了。5.4 打包 APK 前必须检查的清单交作业之前按这个清单过一遍能省掉很多返工build.gradle里的applicationId改成自己的学号或姓名拼音避免和同学撞包名。versionCode和versionName填上有些老师会看。在真机上装一次 release 包确认没有崩溃。如果用了SharedPreferences确认卸载重装后数据能正常初始化。游戏界面的横竖屏锁定在AndroidManifest.xml里给 Activity 加上android:screenOrientationportrait否则横屏时布局会乱。我自己的习惯是每次改完逻辑先跑一遍 JUnit再在模拟器上滑几次最后装到手机上玩到 512 分。这套流程走下来基本不会出现“在电脑上好好的交上去打不开”的情况。希望帮到你。本文还有配套的精品资源点击获取