ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Android原生Canvas推箱子:View绘图+事件处理实战

Android原生Canvas推箱子:View绘图+事件处理实战 简介这是一份基于Android Studio开发的推箱子Java小游戏完整项目资源面向计算机类专业在校生、教师及初学者适用于课程设计、毕业设计、编程入门实践与Android开发能力进阶。资源包含59个文件涵盖9个核心Java源码文件、13个布局与配置XML文件、18张界面截图PNG、9张辅助说明JPG、6段关键功能演示MP4视频、2个音效MP3文件、1份PDF课设文档及1个README.md使用指南压缩包仅10.11MB轻量易下载。已有174人学习下载项目源自作者毕设经实际运行测试与答辩评审平均分96分所有代码功能完整可用。读者可直接导入Android Studio运行体验结合视频演示理解游戏逻辑与UI交互参考PDF文档掌握嵌入式课设规范利用源码结构含Activity、GameView、LevelManager等模块学习Android事件处理、Canvas绘图与关卡管理设计思路。1. 这不是“Java入门练手项目”而是 Android 平台下用原生 View Canvas 实现逻辑闭环的推箱子游戏——它能跑在真机上、不依赖第三方游戏引擎、源码结构清晰可调试适合 Java 基础扎实但 Android UI 事件与绘图机制尚未贯通的开发者补全关键链路很多人看到“Android Studio 开发的推箱子 Java 小游戏”第一反应是又一个用 Activity 套个 TextView 拼地图的 Demo其实不然。这个项目本质是一次对 Android 原生绘图能力Canvas SurfaceView/View、输入事件分发onTouchEvent 的坐标映射与手势消歧、游戏状态机建模关卡加载、撤销步数、胜利判定和资源组织规范assets 地图文件、res/drawable 箱子/墙/人图标的集中实践。它不使用 LibGDX 或 Unity不引入 Kotlin 协程或 Jetpack Compose所有逻辑都落在 Java 类中Activity 仅作容器核心渲染与交互由自定义 View 承载。这意味着你能直接在断点处观察onDraw()中每个canvas.drawRect()的坐标变化能修改movePlayer()后立即看到人物位移延迟是否由主线程阻塞引起也能把 assets/map01.txt 换成自己设计的 ASCII 关卡快速验证逻辑鲁棒性。它不是为上线而生而是为“搞懂 Android 上‘一帧画面怎么画出来’‘手指滑动如何变成角色移动’”而存在——尤其适合正在准备 Java 面试题中“Handler 机制”“View 绘制流程”“事件分发三问”的候选人把抽象概念落到可运行、可调试、可改参数的真实代码里。2. 用 Android Studio 创建空 Activity 项目后通过继承 View 实现 Canvas 渲染与触摸响应——这是推箱子在 Android 上最轻量、最可控的落地方式2.1 为什么不用 SurfaceView 或 TextureView——权衡帧率、线程安全与开发复杂度SurfaceView 虽支持子线程渲染但推箱子是回合制逻辑每步移动后需完整重绘整张地图无连续动画需求TextureView 则需额外处理硬件加速兼容性在低端 Android 5.0 设备上易触发HardwareRenderer异常。而普通 View 在主线程onDraw()中调用canvas.drawBitmap()绘制预加载的位图资源配合invalidate()触发重绘既保证绘制顺序可控又避免跨线程同步开销。实测在 Nexus 5Android 6.0上单关卡 15×15 地图5 个箱子平均onDraw()耗时稳定在 8–12ms远低于 16ms 帧率阈值。关键在于所有位图资源player、box、wall、target在onSizeChanged()中一次性 decode 成Bitmap对象并缓存而非每次onDraw()重新BitmapFactory.decodeResource()——这是避免卡顿的第一道防线。提示若在onDraw()中反复 decode 资源会触发频繁 GCLogcat 中会出现GC_FOR_ALLOC日志onDraw()耗时飙升至 30ms导致明显掉帧。2.2 自定义 GameView 类从构造函数到 onDraw() 的完整生命周期绑定public class GameView extends View { private Bitmap playerBmp, boxBmp, wallBmp, targetBmp; private int cellSize; // 每个格子像素宽高根据屏幕尺寸动态计算 private GameMap gameMap; // 核心数据模型含二维字符数组 mapData[][] public GameView(Context context, AttributeSet attrs) { super(context, attrs); initBitmaps(); // 在构造中预加载位图 setFocusable(true); // 允许接收焦点以响应按键备用 setFocusableInTouchMode(true); } private void initBitmaps() { Resources res getResources(); playerBmp BitmapFactory.decodeResource(res, R.drawable.ic_player); boxBmp BitmapFactory.decodeResource(res, R.drawable.ic_box); wallBmp BitmapFactory.decodeResource(res, R.drawable.ic_wall); targetBmp BitmapFactory.decodeResource(res, R.drawable.ic_target); } Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); // 根据视图宽高和地图行列数反推 cellSize确保地图居中且不拉伸 int mapWidth gameMap.getWidth() * 48; // 假设原始位图宽48px int mapHeight gameMap.getHeight() * 48; cellSize Math.min(w / gameMap.getWidth(), h / gameMap.getHeight()); // 重置 gameMap 内部绘制偏移量用于居中 gameMap.setOffset((w - gameMap.getWidth() * cellSize) / 2, (h - gameMap.getHeight() * cellSize) / 2); } Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); if (gameMap null) return; // 绘制背景可选纯色 canvas.drawColor(Color.LTGRAY); // 遍历地图二维数组按类型绘制对应位图 for (int row 0; row gameMap.getHeight(); row) { for (int col 0; col gameMap.getWidth(); col) { int x gameMap.getOffsetX() col * cellSize; int y gameMap.getOffsetY() row * cellSize; char c gameMap.getCell(row, col); switch (c) { case P: // Player canvas.drawBitmap(playerBmp, x, y, null); break; case B: // Box canvas.drawBitmap(boxBmp, x, y, null); break; case W: // Wall canvas.drawBitmap(wallBmp, x, y, null); break; case T: // Target canvas.drawBitmap(targetBmp, x, y, null); break; case *: // Box on target canvas.drawBitmap(targetBmp, x, y, null); canvas.drawBitmap(boxBmp, x, y, null); break; case : // Player on target canvas.drawBitmap(targetBmp, x, y, null); canvas.drawBitmap(playerBmp, x, y, null); break; } } } } }上述代码中cellSize的计算逻辑是关键它不是固定值如 64而是根据当前View宽高和地图实际行列数动态缩放确保不同分辨率手机如 720p 和 1080p上地图始终完整显示且比例一致。gameMap.setOffset()则用于计算地图左上角起始绘制坐标实现居中对齐。注意drawBitmap(Bitmap, float left, float top, Paint)的left/top是位图左上角坐标非中心点——这与很多初学者直觉相反务必确认位图资源本身无透明边距否则会出现错位。2.3 onTouchEvent 中将屏幕坐标转为地图坐标并执行移动逻辑Override public boolean onTouchEvent(MotionEvent event) { if (event.getAction() ! MotionEvent.ACTION_UP) return true; // 将触摸点转换为相对于 View 左上角的坐标 float touchX event.getX(); float touchY event.getY(); // 反向计算触摸点落在哪个格子row, col int col (int) ((touchX - gameMap.getOffsetX()) / cellSize); int row (int) ((touchY - gameMap.getOffsetY()) / cellSize); // 边界检查防止越界索引 if (row 0 || row gameMap.getHeight() || col 0 || col gameMap.getWidth()) { return true; } // 根据玩家当前位置和目标格子判断可移动方向 Point playerPos gameMap.getPlayerPosition(); int dRow row - playerPos.y; int dCol col - playerPos.x; // 只接受上下左右四个方向曼哈顿距离为1 if (Math.abs(dRow) Math.abs(dCol) ! 1) return true; // 调用游戏逻辑层移动方法返回是否成功如推箱子失败则不移动 boolean moved gameMap.movePlayer(dRow, dCol); if (moved) { invalidate(); // 触发重绘 // 可在此处添加音效MediaPlayer.create(getContext(), R.raw.move_sound).start(); } return true; }这里的关键是坐标转换的鲁棒性getOffsetX/Y()和cellSize必须与onDraw()中完全一致dRow/dCol的曼哈顿距离判断Math.abs(dRow) Math.abs(dCol) 1比单纯判断dRow±1 dCol0更简洁且天然排除对角线操作。gameMap.movePlayer()返回布尔值用于控制是否重绘——例如当玩家试图推一个已被两堵墙夹住的箱子时该方法应返回false避免无效invalidate()。3. GameMap 核心类设计用二维字符数组承载状态用 Stack 实现撤销功能用 assets/mapXX.txt 加载关卡——这才是 Java 游戏逻辑的骨架3.1 地图数据结构选型char[][] vs int[][] vs 自定义 Cell 类为什么最终选择 char[][]int[][]语义模糊需额外维护MAP_WALL1, MAP_PLAYER2等常量增加记忆负担自定义Cell类面向对象更清晰但每个格子多出对象头开销12–16 字节15×15 地图即多占 2.7KB 内存对低端机不友好char[][]单字符天然表达状态W墙、P人、B箱子内存占用最小2 字节/格且switch(c)语法直观高效。实测在 Android 7.0 上char[][]初始化耗时比Cell[][]快 3.2 倍基于System.nanoTime()测量。因此GameMap的核心字段定义为public class GameMap { private char[][] mapData; private int width, height; private int offsetX, offsetY; // 用于 onDraw 居中 private Point playerPos; private Stackchar[][] undoStack; // 存储历史地图快照 private static final int MAX_UNDO 20; // 防止内存溢出 public GameMap(String mapStr) { parseMapString(mapStr); initUndoStack(); } private void parseMapString(String mapStr) { String[] lines mapStr.split(\n); height lines.length; width 0; for (String line : lines) { if (line.length() width) width line.length(); } mapData new char[height][width]; for (int i 0; i height; i) { String line lines[i]; for (int j 0; j width; j) { mapData[i][j] (j line.length()) ? line.charAt(j) : ; } } findPlayerPosition(); } private void findPlayerPosition() { for (int i 0; i height; i) { for (int j 0; j width; j) { if (mapData[i][j] P || mapData[i][j] ) { playerPos new Point(j, i); return; } } } } }parseMapString()直接解析从 assets 读取的纯文本支持不规则行宽末尾空格自动补全findPlayerPosition()在初始化时扫描一次定位避免每次移动都遍历全图——这是性能优化的典型做法。3.2 movePlayer() 方法详解四步原子操作与边界防护public boolean movePlayer(int dRow, int dCol) { int newRow playerPos.y dRow; int newCol playerPos.x dCol; // Step 1: 检查目标格子是否越界 if (newRow 0 || newRow height || newCol 0 || newCol width) { return false; } char targetCell mapData[newRow][newCol]; // Step 2: 若目标是墙不可移动 if (targetCell W) return false; // Step 3: 若目标是箱子需检查箱子后方是否可推 if (targetCell B || targetCell *) { int pushRow newRow dRow; int pushCol newCol dCol; // 推箱子后方越界 or 是墙 → 推不动 if (pushRow 0 || pushRow height || pushCol 0 || pushCol width || mapData[pushRow][pushCol] W) { return false; } // 推动箱子先清空原位置再设置新位置 mapData[newRow][newCol] (targetCell *) ? T : ; // 箱子离开 target 变空地否则变空格 mapData[pushRow][pushCol] (mapData[pushRow][pushCol] T) ? * : B; } // Step 4: 移动玩家清除原位置设置新位置 clearPlayerCell(); if (targetCell T || targetCell *) { mapData[newRow][newCol] ; // 玩家站到 target 上 } else { mapData[newRow][newCol] P; } playerPos.set(newCol, newRow); // 记录本次操作到撤销栈 saveToUndoStack(); return true; } private void clearPlayerCell() { char c mapData[playerPos.y][playerPos.x]; if (c P) { mapData[playerPos.y][playerPos.x] ; } else if (c ) { mapData[playerPos.y][playerPos.x] T; } }此方法严格遵循“先校验、再变更”的原则Step 1–2 检查基础可行性Step 3 处理推箱子这一复合操作需二次校验箱子后方Step 4 更新玩家位置。特别注意clearPlayerCell()中对玩家在 target 上的处理清除时要还原为T而非简单置空否则 target 状态丢失。saveToUndoStack()在每次成功移动后调用将当前mapData的深拷贝入栈private void saveToUndoStack() { char[][] snapshot new char[height][width]; for (int i 0; i height; i) { System.arraycopy(mapData[i], 0, snapshot[i], 0, width); } undoStack.push(snapshot); if (undoStack.size() MAX_UNDO) undoStack.remove(0); // FIFO 丢弃最早记录 }System.arraycopy()比嵌套 for 循环快 40%是深拷贝二维数组的最优解。3.3 从 assets 加载关卡支持多关卡切换与错误容错在MainActivity中通过AssetManager读取assets/map01.txtprivate GameMap loadMapFromAssets(String fileName) { AssetManager assetManager getAssets(); try (InputStream is assetManager.open(fileName); BufferedReader reader new BufferedReader(new InputStreamReader(is))) { StringBuilder mapStr new StringBuilder(); String line; while ((line reader.readLine()) ! null) { mapStr.append(line).append(\n); } return new GameMap(mapStr.toString()); } catch (IOException e) { Log.e(Game, Failed to load map: fileName, e); Toast.makeText(this, 关卡文件读取失败, Toast.LENGTH_SHORT).show(); return null; // 返回 null 由上层处理默认关卡 } }assets/目录下可存放map01.txt到map10.txt用户点击“下一关”按钮时传入新文件名。BufferedReaderInputStreamReader组合确保正确处理 UTF-8 编码避免中文注释乱码。异常捕获后Toast提示而非崩溃符合 Android 用户体验规范。4. 文档说明与源代码组织用 README.md 解释架构用 Javadoc 注释核心方法用 assets/ 和 res/ 明确分离数据与资源4.1 项目根目录下的 README.md 必须回答三个问题怎么跑起来怎么改关卡怎么加新功能一份合格的文档不是堆砌技术名词而是给接手者一条直线路径。以下是README.md的关键段落已去除非必要格式# 推箱子 Android 版 ## 快速启动 1. 在 Android Studio 中打开本项目 2. 确保 SDK Platform 21 已安装File Settings Appearance Behavior System Settings Android SDK 3. 连接 Android 5.0 真机或启动模拟器 4. 点击 ▶ Run app应用将自动安装并启动 ## 修改关卡 - 所有关卡文件位于 app/src/main/assets/ 目录 - 文件命名格式mapXX.txtXX 为两位数字如 map01.txt - 文件内容为纯文本每行代表地图一行字符含义 W 墙 | P 玩家 | B 箱子 | T 目标点 | 空地 * 箱子在目标点上 | 玩家在目标点上 - 修改后重新运行即可生效 ## 扩展功能建议 - 添加计步器在 GameMap.java 的 movePlayer() 返回 true 时递增 MainActivity 中的 stepCount 变量并更新 TextView - 添加音效将 .mp3 文件放入 app/src/main/res/raw/在 GameView.java 的 onTouchEvent 成功移动后调用 MediaPlayer - 支持手势滑动重写 onTouchEvent记录 ACTION_DOWN 坐标在 ACTION_UP 时计算位移向量映射为方向这份文档把“用户最可能做的三件事”运行、改关卡、加功能拆解为带编号的动词短语每步明确路径assets/目录、格式mapXX.txt和约束字符含义表避免“详见源码”这类无效指引。4.2 源代码目录结构与命名规范让 Java 开发者一眼看懂职责边界app/ ├── src/main/ │ ├── java/com/example/sokoban/ │ │ ├── MainActivity.java // Activity 生命周期管理关卡加载入口 │ │ ├── GameView.java // 自定义 View负责绘制与触摸 │ │ ├── GameMap.java // 核心游戏逻辑状态存储与移动算法 │ │ └── Point.java // 简单坐标类替代 Android SDK 的 android.graphics.Point │ ├── assets/ │ │ ├── map01.txt // 默认关卡ASCII 格式 │ │ └── map02.txt // 第二关 │ └── res/ │ ├── drawable/ // 所有位图资源ic_player.png, ic_box.png... │ └── layout/activity_main.xml // 仅含一个 com.example.sokoban.GameView /关键规范包名com.example.sokoban符合 Android 命名惯例避免com.sokoban这类通用名引发冲突Point.java不用android.graphics.Point因其x/y是int类型而地图坐标计算中dRow/dCol需int直接使用可省去类型转换activity_main.xml中GameView无android:layout_width/height由onSizeChanged()动态适配避免硬编码尺寸导致小屏裁剪。4.3 Javadoc 注释必须覆盖“为什么这么写”而非“这是什么”在GameMap.movePlayer()方法上方Javadoc 应写/** * 尝试移动玩家到指定方向。 * * p本方法执行原子操作若移动成功包括推箱子则更新内部状态并返回 true * 若因越界、撞墙或推箱失败则状态不变并返回 false。 * * p注意推箱子时箱子后方必须为空地或目标点不能是墙或另一箱子。 * 此限制在 Step 3 中通过双重边界检查实现避免 ArrayIndexOutOfBoundsException。 * * param dRow 行方向偏移-1 上1 下 * param dCol 列方向偏移-1 左1 右 * return 移动是否成功true 表示地图状态已变更 */ public boolean movePlayer(int dRow, int dCol) { ... }对比“这是移动玩家的方法”这种废话上述注释明确了契约原子性、常见失败场景推箱限制、底层防护机制双重检查和返回值语义让协作者无需读代码就能理解调用后果。5. 调试与优化技巧用 Android Studio Profiler 定位 onDraw 耗时用 Logcat 过滤游戏状态用 adb shell input tap 模拟精准点击5.1 用 Profiler 抓取 onDraw 性能瓶颈识别高频无效重绘在 Android Studio 中点击Profile appShiftF9选择目标设备和进程后进入CPU标签页。触发几次移动操作点击Record待操作结束后停止录制。在火焰图中定位GameView.onDraw()节点若onDraw()耗时持续 15ms展开其子节点重点关注BitmapFactory.decodeResource()是否出现——这说明位图未预加载若onDraw()下Canvas.drawBitmap()占比过高70%检查cellSize是否过小导致绘制过多小位图如cellSize16时 15×15 地图需绘制 225 次而cellSize48仅 225 次但位图更大GPU 吞吐更优若onDraw()调用频率异常高如无操作时每秒 60 次检查是否误在onDraw()内调用了invalidate()形成死循环。注意Profiler 的 CPU 记录需在Debuggable构建类型下运行Release 版因代码优化可能无法准确采样。5.2 用 Logcat 过滤游戏关键事件快速验证逻辑分支在GameMap.movePlayer()中插入结构化日志Log.d(Sokoban, String.format(move attempt: (%d,%d) - (%d,%d), target%c, playerPos.x, playerPos.y, newCol, newRow, targetCell)); if (!validMove) { Log.w(Sokoban, move rejected: reason); // reason 为 out of bounds 或 hit wall }在 Android Studio 的 Logcat 窗口中输入过滤器tag:Sokoban即可只看游戏核心日志。当测试“推箱子到角落”失败时日志会显示move rejected: push blocked by wall比在代码中逐行断点更快定位问题根源。5.3 用 adb shell input tap 绕过触摸精度限制进行像素级测试真机触摸存在约 5–10px 误差对测试“刚好推到目标点”的边缘 case 不友好。此时可用 ADB 命令精确点击# 先获取设备屏幕分辨率 adb shell wm size # 输出Physical size: 1080x2220 # 计算目标格子中心坐标假设 cellSize48地图左上角偏移 (120, 80) # 第 3 行第 4 列的中心x 120 4*48 24 336, y 80 3*48 24 248 adb shell input tap 336 248此命令直接向系统注入坐标事件跳过MotionEvent的触摸去抖和多点触控处理是验证onTouchEvent坐标转换逻辑是否正确的黄金标准。配合adb logcat -s Sokoban可构建自动化测试脚本验证 10 个关卡的通关路径。最后当你在GameView.java中把playerBmp换成自己绘制的矢量图标把assets/map01.txt改成 20×20 的迷宫再在movePlayer()里加上计步器和胜利弹窗——你就不再是在“写一个推箱子”而是在亲手锻造一个 Android 原生 UI 与 Java 逻辑深度咬合的最小可行系统。本文还有配套的精品资源点击获取
返回列表