资讯动态

Android数独游戏开发实战:Canvas绘制与Fragment状态持久化

发布时间:2026/9/10 13:02:59 来源:尧图企业网站定制
简介Android Studio 实现的数独小游戏完整项目源码适合安卓课程设计与毕业设计答辩场景。游戏主界面包含继续游戏、新游戏、关于和退出四大功能下拉菜单可进入设置新游戏支持简单、中等、困难三档难度中途退出后可通过“继续游戏”恢复上次状态。界面采用 3×3 方格实线分隔并集成背景音乐开关与游戏提示控制。项目覆盖 AlertDialog、KeyEvent、MediaPlayer、Canvas、Paint、Parcelable、Fragment、Menu、PreferenceManager、Bundle 等知识点代码结构清晰便于讲解与二次开发。压缩包共 480 个文件以 Java 源码、XML 布局、Gradle 配置、APK 安装包及调试资源为主约 23.01MB解压后可直接导入 Android Studio 运行。已有 1729 人学习下载适合需要完整安卓课设项目或初学者仿照练手的开发者。1. 数独大作业的 Android 实现: 为什么是 Canvas 和 Fragment 的试金石很多同学的 Android Studio 课设作业停在“能跑就行”, 但数独这个题目正好是评估安卓基础的最佳样本: 它不需要复杂的后端和第三方库, 却覆盖了自定义 View 绘制、页面导航、设置持久化、媒体播放与生命周期。题目本身规则清晰, 但“新游戏选难度-随时退出-继续游戏恢复棋盘”这套流程, 会把 Fragment 状态、Bundle 传参、Parcelable 序列化和 SharedPreferences 全部串起来。这里按一个课程设计项目的实际拆解方式来写, 从棋盘绘制讲到状态恢复, 再讲到答辩前容易被问倒的细节, 新手可以照着做, 老手也能在参数和边界上查漏补缺。源码包里的 resources-debug.ap_ 只是构建产物, 核心要读的是 Java 与资源文件。2. 棋盘数据与 Canvas 绘制: 用 Paint 画出 3x3 宫格分隔线2.1 用 int[9][9] 还是自定义 Cell 类数独棋盘在外人看来是一个 9x9 的格子, 在代码里最直接的映射就是二维数组。0 表示空格, 1-9 表示已填数字。我在课程设计里不推荐一上来就建 SudokuCell 类, 虽然它看起来更“面向对象”, 但 81 个单元格的集合里, 真正需要额外维护的只有两种状态: 这个格是不是题目预置格, 以及当前有没有被用户选中。用两个 boolean 数组配合二维数组, 比让每个格持有对象简单得多。这里有个容易被忽略的边界: 游戏过程中不仅要存当前盘面, 还要存初始盘面, 否则“继续游戏”之后, 用户无法分辨哪个是题目给的、哪个是自己填的。我一般这样定义字段:public class SudokuBoard { public static final int SIZE 9; public int[][] cells new int[SIZE][SIZE]; public boolean[][] fixed new boolean[SIZE][SIZE]; public int[][] original new int[SIZE][SIZE]; }original 在生成题目时写死, fixed 由 original 算出来, cells 是工作副本。这样“新游戏”可以基于 original 重新开始, “继续游戏”只需要读 cells 并对照 fixed 决定哪些格可编辑。fixed 数组也应该保存在 Bundle 里, 不要只在生成题目时临时算, 否则恢复时会把用户新填的数字当成预置数字锁死。2.2 在自定义 View 里画宫格线棋盘放在自定义 View 里最稳。直接继承 View, 重写 onDraw; 而不是用 LinearLayout 拼 81 个 TextView, 那样布局开销大, 绘制也不均匀。难点只在于: 如何让普通格线和 3x3 宫格线在视觉上区分开。每个 3x3 方块区域都要用实线分隔, 所以我把画笔分成两套:画笔线宽颜色用途normalPaint1dp#CCCCCC普通单元格边框boldPaint2dp#3333333x3 宫格边界selectPaint3dp#FF9800选中高亮矩形textPaint16sp#333333用户输入的数字fixedPaint16sp#666666预置数字注意这里写的 dp 和 sp 在代码里要转换成 px, 不能直接把 1 传给 Paint.setStrokeWidth, 否则在不同 density 屏幕上粗细会不一致。接下来是 onDraw 的核心绘制:Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); float cellSize Math.min(getWidth(), getHeight()) / 9f; canvas.drawColor(backgroundColor); for (int i 0; i 9; i) { boolean bold (i % 3 0); Paint paint bold ? boldPaint : normalPaint; float y i * cellSize; canvas.drawLine(0, y, 9 * cellSize, y, paint); float x i * cellSize; canvas.drawLine(x, 0, x, 9 * cellSize, paint); } }i % 3 0时 i 为 0、3、6、9, 正好是宫格边界; 普通格线在第 1、2、4、5、7、8 行/列。cellSize 用 9f 而不是 9, 是为了让除法结果保留小数, 避免最后一条线因为整数截断而错位。如果你的设备宽度不能被 9 整除, 右侧和下侧会多出几像素空白, 把画板宽度取成Math.min(getWidth(), getHeight())可以保证棋盘是正方形。2.3 高亮选中格与数字绘制点击棋盘时需要把当前选中格高亮出来。高亮矩形要画在网格线之前, 这样边框不会被覆盖。我通常在 onDraw 里先画背景, 再画高亮, 再画线条, 最后画数字。顺序不同会产生视觉差异, 答辩时如果被问到, 可以指出这个绘制层级。if (selectedRow 0 selectedCol 0) { canvas.drawRect( selectedCol * cellSize, selectedRow * cellSize, (selectedCol 1) * cellSize, (selectedRow 1) * cellSize, selectPaint); }drawRect 的四个参数是 left、top、right、bottom, 这里用 (selectedCol 1) 保证矩形覆盖完整单元格。接下来遍历 cells 数组绘制数字:for (int row 0; row 9; row) { for (int col 0; col 9; col) { int value board.cells[row][col]; if (value 0) { float cx col * cellSize cellSize / 2f; float baseline row * cellSize cellSize / 2f - (textPaint.ascent() textPaint.descent()) / 2f; canvas.drawText(String.valueOf(value), cx, baseline, paint); } } }baseline 用ascent() descent()的一半来偏移, 比固定加一个像素值更通用。如果你需要把冲突数字标红, 可以准备一个 boolean[][] conflict 数组, 在 drawText 前选择 redPaint, 这个功能可以对应设置里的“游戏提示”开关: 开的时候标红, 关的时候只弹一次对话框或者不提示。3. 游戏状态持久化: Fragment、Menu 与 PreferenceManager 的组合方案3.1 用 Menu 和 Fragment 把主页面拆开主页面四个功能全部堆在 MainActivity 里也能完成, 但用 Fragment 的好处是“继续游戏”和“新游戏”可以复用同一套 GameFragment, 只是传入数据不同。我在项目里这样拆: HomeFragment 显示“继续游戏、新游戏、关于、退出”四个按钮, GameFragment 承载棋盘, SettingsFragment 承载设置项。Activity 只负责 FragmentManager 的 replace 和 addToBackStack。下拉菜单里的“设置”需要在 Activity 里加载菜单资源。菜单文件放在 res/menu/main_menu.xml:menu xmlns:androidhttp://schemas.android.com/apk/res/android item android:idid/action_settings android:title设置 android:orderInCategory100 / /menu然后在 MainActivity 中重写 onCreateOptionsMenu 加载这个菜单, 在 onOptionsItemSelected 里跳转到 SettingsFragment 或启动 SettingsActivity。这里我推荐用 Fragment 嵌套, 因为设置项返回后还能回到原来的主页面, 不会额外创建 Activity 记录。Fragment 切换的核心代码是:getSupportFragmentManager() .beginTransaction() .replace(R.id.fragment_container, new GameFragment()) .addToBackStack(null) .commit();replace 会销毁旧 Fragment, addToBackStack 会把这次替换放到返回栈; 如果没有 addToBackStack, 返回键会直接退出应用而不是回到主页面。这一点在演示“新游戏 → 退出 → 继续游戏”时尤其明显。3.2 Bundle Parcelable 保存进行中的棋盘“继续游戏”能否恢复取决于你怎么保存状态。我先说最容易写错的地方: 二维数组在 Parcelable 里不能直接 writeIntArray, 而要先 writeInt 行数, 再逐行写入。这里给一个 GameState 的骨架:public class GameState implements Parcelable { public int[][] cells; public int[][] original; public int difficulty; public long elapsedTime; protected GameState(Parcel in) { cells readMatrix(in); original readMatrix(in); difficulty in.readInt(); elapsedTime in.readLong(); } private int[][] readMatrix(Parcel in) { int rows in.readInt(); int[][] matrix new int[rows][]; for (int i 0; i rows; i) { matrix[i] in.readIntArray(); } return matrix; } private void writeMatrix(Parcel dest, int[][] matrix) { dest.writeInt(matrix.length); for (int[] row : matrix) { dest.writeIntArray(row); } } Override public void writeToParcel(Parcel dest, int flags) { writeMatrix(dest, cells); writeMatrix(dest, original); dest.writeInt(difficulty); dest.writeLong(elapsedTime); } public static final CreatorGameState CREATOR new CreatorGameState() { Override public GameState createFromParcel(Parcel in) { return new GameState(in); } Override public GameState[] newArray(int size) { return new GameState[size]; } }; }这里 original 也必须一起写, 否则恢复后 fixed 数组无法重建。createFromParcel 里不能碰 final 字段, 所以 GameState 的字段都设成 public 普通字段, 看起来不够优雅, 但写课设代码时最不容易出错。如果你愿意多写一点, 可以把判断一次输入是否合法也放进 GameState, 让纯逻辑与 UI 分离。传递 GameState 时, 用 Fragment 的 setArguments 而不是构造函数传值:GameFragment gameFragment new GameFragment(); Bundle args new Bundle(); args.putParcelable(game_state, state); gameFragment.setArguments(args);setArguments 里的 Bundle 在 Fragment 被系统重建时不会丢, 而直接调用 setter 会随 Fragment 对象一起消失。这是很多源码里隐藏的 bug: 旋转屏幕后, 从任务列表恢复, Fragment 重建, 构造函数被调用, 原来的 setter 值全没了。3.3 PreferenceManager 与设置项读写设置里只有两个开关: 背景音乐、游戏提示。我直接用 SharedPreferences, 没有建 PreferenceFragment, 因为布局简单, 手动绑定 CheckBox 更直观。注意不要用 Activity.getPreferences, 那是 Activity 私有的文件, 在 Fragment 里访问不到; 推荐用 PreferenceManager.getDefaultSharedPreferences。SharedPreferences prefs PreferenceManager.getDefaultSharedPreferences(getContext()); boolean soundEnabled prefs.getBoolean(sound_enabled, true); boolean hintEnabled prefs.getBoolean(hint_enabled, true); prefs.edit() .putBoolean(sound_enabled, !soundEnabled) .apply();apply 是异步写盘, commit 是同步写盘, 在 UI 线程更新设置用 apply 就行。为了存档恢复, 我还会在 MainActivity 保存当前难度和剩余时间, 这些键是固定的:key类型默认值说明sound_enabledbooleantrue背景音乐开关hint_enabledbooleantrue提示开关current_difficultyint10 简单 / 1 中等 / 2 困难last_boardStringnull棋盘数据的序列化结果last_board 可以用 JSON 而不是 Parcelable 持久化, 因为 SharedPreferences 存 Parcel 编码不够直观, 也不方便用 adb 查看。但内存间传递用 Parcelable 更快, 所以“内存间传 GameState, 落盘用 JSON”是合理的分工。到这里, 继续游戏的数据闭环就通了: 点退出时写 last_board, 点继续游戏时读 last_board 并解析成 GameState, 再 setArguments 传给 GameFragment。4. 输入、弹窗与背景音乐: AlertDialog、KeyEvent、MediaPlayer 实战4.1 数字输入与冲突检测数独输入的校验是答辩必问。用户点击棋盘上某个空白格, 然后通过软键盘或屏幕下方数字按钮输入 1-9。每输入一个数字, 需要检查行、列、所在 3x3 宫是否已经存在该数字。可以写一个独立的校验函数:public boolean isValid(int row, int col, int value) { for (int i 0; i 9; i) { if (i ! col board.cells[row][i] value) return false; if (i ! row board.cells[i][col] value) return false; } int boxRow (row / 3) * 3; int boxCol (col / 3) * 3; for (int r boxRow; r boxRow 3; r) { for (int c boxCol; c boxCol 3; c) { if (r ! row c ! col board.cells[r][c] value) { return false; } } } return true; }行和列检查里的i ! col与i ! row是避免把自己刚输入的值也算进去; 宫格范围(row / 3) * 3得到 0、3、6 三个起点, 很多初写者会误写成 row % 3, 那样只检查了宫格内一行。如果开启提示, 在检测到冲突时用 AlertDialog 提示“该数字已在同一行/列/宫中”, 并把冲突格标红。提示关闭后, 建议也不再标红, 只忽略这次输入。4.2 AlertDialog: 关于与退出确认“关于”对话框内容不多, 主要用来介绍规则, 这里不展开。真正要注意的是退出确认: 从游戏页按返回键时, 先弹窗询问是否保存进度并退出。如果用 Fragment 处理返回, 可以在 MainActivity 重写 onBackPressed:Override public void onBackPressed() { if (currentFragment instanceof GameFragment) { new AlertDialog.Builder(this) .setTitle(退出游戏) .setMessage(当前进度会被保存, 确定退出吗?) .setPositiveButton(保存并退出, (dialog, which) - { saveGameState(); finish(); }) .setNegativeButton(取消, null) .show(); } else { super.onBackPressed(); } }setNegativeButton 传 null 表示只关闭弹窗, 不执行任何动作。这里把 saveGameState 放在正按钮, 是为了让评审老师明确看到你处理了“随时退出”“继续游戏”这两个需求。如果 targetSdk 升级到 33 以上, onBackPressed 已标记废弃, 但课设工程通常仍可编译运行, 考试答辩并不影响。4.3 KeyEvent 监听实体键盘虽然手机端主要用触摸, 但很多数独课设演示会接到外接键盘或者用 Android TV, 所以监听数字键能加分。在 Activity 里重写 onKeyUp:Override public boolean onKeyUp(int keyCode, KeyEvent event) { int number -1; if (keyCode KeyEvent.KEYCODE_1 keyCode KeyEvent.KEYCODE_9) { number keyCode - KeyEvent.KEYCODE_1 1; } else if (keyCode KeyEvent.KEYCODE_DEL) { number 0; } if (number 0) { gameFragment.enterNumber(number); return true; } return super.onKeyUp(keyCode, event); }keyCode 从 KEYCODE_1 到 KEYCODE_9 是连续递增的, 所以减去 KEYCODE_1 再加 1 就能得到数字; DEL 键对应清空当前格。返回 true 代表事件被消费, 否则系统会把按键事件继续传给界面, 可能触发按钮焦点或输入框的默认行为。如果想要更好的兼容性, 也可以改用 dispatchKeyEvent 监听, 两者的区别在于 onKeyUp 只作用于有焦点的 Activity, 而 dispatchKeyEvent 在更早的管道处理。4.4 MediaPlayer 与设置开关联动背景音乐的逻辑不建议直接写在 Activity 里, 我一般写一个 MusicManager 来管理 MediaPlayer 的生命周期:public class MusicManager { private MediaPlayer player; public void update(Context context, boolean enabled) { if (player null) { player MediaPlayer.create(context, R.raw.bgm_sudoku); player.setLooping(true); } if (enabled !player.isPlaying()) { player.start(); } else if (!enabled player.isPlaying()) { player.pause(); } } public void release() { if (player ! null) { player.stop(); player.release(); player null; } } }MediaPlayer.create 返回的实例是未开始状态, 需要 setLooping 才会循环播放。关闭音乐时用 pause 而不是 stop, 这样再次打开可以继续而不是从头开始。生命周期对应关系如下:生命周期操作原因onCreate创建 MediaPlayer 并预加载防止首次点击卡顿onResume根据设置 update从后台回来恢复播放onPause暂停音乐切后台不继续响onDestroyrelease释放 native 资源写代码时一定要在 onDestroy 里 release, 否则退出游戏后音乐可能继续播, 还会在 Logcat 里看到 MediaPlayer 相关的异常。如果同时设置开关, 在开关回调里直接调用 musicManager.update() 就能实时生效。5. 用 adb 模拟杀进程验证存档: 答辩前必做的一组自查游戏功能做完以后, 我建议在答辩前一晚只做一件事: 用 adb 模拟强制杀进程, 验证“继续游戏”是否能恢复。很多演示翻车不是代码逻辑错, 而是没区分 onSaveInstanceState 和持久化落盘的区别。屏幕旋转时系统会调用 onSaveInstanceState, 把状态写进内存里的 Bundle; 但如果应用进程真正被系统回收, 这个 Bundle 也会消失。所以“继续游戏”必须把棋盘写到 SharedPreferences 或文件里, 而不是只依赖 Fragment 的 arguments。5.1 先验证进程被杀后的恢复在模拟器里跑起应用, 进入新游戏, 选择中等难度, 在任意位置填几个数字, 然后回到终端执行:adb shell am force-stop com.example.sudoku adb shell am start -n com.example.sudoku/.MainActivityforce-stop 会把整个进程杀掉, 再次启动时一切从 Application 重建。点击“继续游戏”, 如果能出现刚才填的盘面, 说明你的 last_board 持久化生效了。如果恢复不了, 先用下面命令确认 SharedPreferences 内容:adb shell run-as com.example.sudoku ls shared_prefs/ adb shell run-as com.example.sudoku cat shared_prefs/sudoku_pref.xmlrun-as 只能在 debuggable 应用上使用, 所以用 debug 包测试。cat 出来的 XML 里应当能看到 last_board 字段。如果看不到, 说明保存逻辑没有执行, 优先检查 saveGameState 是否被调用, 以及 SharedPreferences 的 key 是不是写错了。5.2 再验证旋转屏幕的场景模拟器上按 Ctrl F11 可以切换横竖屏, 触发 Activity 重建。旋转后 selectRow 和 selectCol 这类 UI 状态要放进 onSaveInstanceState, 否则棋盘虽然恢复, 但选中格高亮丢失, 这个细节在答辩演示时非常显眼。在你的 GameFragment 里:Override public void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); outState.putInt(selected_row, boardView.getSelectedRow()); outState.putInt(selected_col, boardView.getSelectedCol()); }恢复时在 onViewCreated 里读取这两个值, 再调用 boardView.setSelectedCell。注意 Fragment 的 onSaveInstanceState 会在 Activity 被保存时一并触发, 不要只在 Activity 里存。5.3 演示顺序建议按“填数 → 退出 → 继续游戏 → 旋转屏幕恢复”的顺序排一次演示, 比只展示“能玩”更有说服力。把 force-stop 验证放在最后, 如果它通过了, 再切一次横竖屏, 你的存档逻辑才算经得住答辩演示。如果编译时遇到 AS 构建报 tag number over 30 is not supported, 先去检查 AGP 版本和 compileSdk 是否匹配, 这是环境问题, 和数独代码无关。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价