Android扫雷游戏开发:从算法到UI的全流程实践

发布时间:2026/10/25 7:08:34
Android扫雷游戏开发:从算法到UI的全流程实践 1. 项目背景与核心目标扫雷游戏作为Windows系统自带的经典游戏承载了一代人的记忆。这个看似简单的游戏背后其实蕴含着算法设计、交互逻辑和状态管理等多个计算机科学领域的知识要点。在移动互联网时代将扫雷游戏移植到Android平台不仅是对经典游戏的致敬更是学习Android应用开发的绝佳实践项目。这个项目的核心目标是通过Android Studio开发一个功能完整的扫雷游戏实现以下关键特性可自定义的棋盘大小如9×9初级、16×16中级、30×16高级符合经典扫雷规则的雷区生成与数字提示系统支持点击翻开、长按标记地雷等标准交互操作计时器和剩余雷数显示等游戏状态管理游戏胜利/失败的判定与界面反馈选择这个项目进行开发主要基于以下几个技术学习价值掌握Android基础UI组件的使用特别是GridView和自定义View理解MVC/MVVM架构在游戏开发中的应用学习触摸事件处理和手势识别实践游戏状态管理和数据持久化熟悉Android Studio开发环境和调试工具提示虽然扫雷看起来简单但要实现一个体验良好的版本需要考虑很多细节问题比如性能优化、不同屏幕尺寸适配、状态恢复等这些都是很好的学习机会。2. 开发环境与技术选型2.1 Android Studio配置要点开发Android扫雷游戏首推使用Android Studio作为IDE。当前稳定版本如Giraffe 2022.3.1已经能很好地支持本项目的所有开发需求。安装时需要注意JDK版本建议使用JDK 17LTS版本这是Android Studio Giraffe的推荐配置Android SDK确保安装API 34Android 14和API 33Android 13两个版本Gradle配置使用Gradle 8.0以上版本build.gradle中配置如下android { compileSdk 34 defaultConfig { minSdk 24 targetSdk 34 versionCode 1 versionName 1.0 } }2.2 游戏引擎选择对于扫雷这类逻辑密集型游戏我们有以下几种技术实现方案方案优点缺点适用场景纯Canvas绘制性能好完全控制渲染开发效率低追求极致性能View系统GridView开发简单复用现有组件灵活性受限快速原型开发Jetpack Compose声明式UI现代化学习曲线较陡长期维护项目考虑到扫雷的单元格交互需求本项目采用GridView自定义Adapter的方案这是平衡开发效率和性能的最佳选择。GridView天生适合棋盘类布局其回收机制能很好地处理大尺寸雷区。2.3 项目结构设计采用分层架构设计核心模块划分如下com.example.minesweeper/ ├── model/ # 数据模型 │ ├── Cell.kt # 单元格数据类 │ ├── Game.kt # 游戏状态管理 │ └── Board.kt # 雷区生成算法 ├── view/ # 界面相关 │ ├── CellView.kt # 自定义单元格视图 │ └── GameActivity.kt # 主界面 ├── adapter/ # 适配器 │ └── BoardAdapter.kt # GridView适配器 └── utils/ # 工具类 ├── Preferences.kt # 游戏设置存储 └── SoundManager.kt # 音效管理这种结构清晰分离了游戏逻辑和界面表现便于后续扩展和维护。3. 核心游戏逻辑实现3.1 雷区生成算法扫雷游戏的核心算法之一是随机生成雷区。这里需要考虑两个关键点确保首次点击位置一定是安全的不踩雷雷的分布要真正随机避免出现明显的模式实现代码示例Kotlinfun generateMines(firstClickPos: Int, mineCount: Int, totalCells: Int): SetInt { val mines mutableSetOfInt() val random Random(System.currentTimeMillis()) // 确保首次点击位置周围8格都没有雷 val excludePositions getAdjacentPositions(firstClickPos) firstClickPos while (mines.size mineCount) { val pos random.nextInt(totalCells) if (pos !in excludePositions) { mines.add(pos) } } return mines } private fun getAdjacentPositions(pos: Int): ListInt { val row pos / columns val col pos % columns val adjacent mutableListOfInt() for (r in (row-1)..(row1)) { for (c in (col-1)..(col1)) { if (r in 0 until rows c in 0 until columns !(r row c col)) { adjacent.add(r * columns c) } } } return adjacent }3.2 数字提示计算每个非雷单元格需要显示周围8格中的地雷数量。这个计算可以在生成雷区时同步完成fun calculateAdjacentMines(mines: SetInt, position: Int): Int { if (position in mines) return -1 // 是雷 var count 0 for (adjPos in getAdjacentPositions(position)) { if (adjPos in mines) count } return count }3.3 空白区域展开算法当玩家点击一个周围没有雷的空白单元格时需要自动展开所有相邻的空白区域直到遇到数字边界。这需要使用广度优先搜索BFS算法fun expandEmptyArea(clickedPos: Int) { val queue LinkedListInt() val visited BooleanArray(totalCells) queue.offer(clickedPos) visited[clickedPos] true while (queue.isNotEmpty()) { val current queue.poll() revealCell(current) if (getAdjacentMines(current) 0) { for (neighbor in getAdjacentPositions(current)) { if (!visited[neighbor] !isRevealed(neighbor)) { visited[neighbor] true queue.offer(neighbor) } } } } }4. 用户界面设计与实现4.1 游戏主界面布局使用ConstraintLayout作为根布局包含以下几个关键部分androidx.constraintlayout.widget.ConstraintLayout !-- 游戏状态栏 -- LinearLayout android:idid/status_bar app:layout_constraintTop_toTopOfparent app:layout_constraintStart_toStartOfparent app:layout_constraintEnd_toEndOfparent TextView android:idid/mines_count / Button android:idid/reset_btn / TextView android:idid/timer / /LinearLayout !-- 雷区网格 -- GridView android:idid/board_grid app:layout_constraintTop_toBottomOfid/status_bar app:layout_constraintBottom_toBottomOfparent app:layout_constraintStart_toStartOfparent app:layout_constraintEnd_toEndOfparent android:numColumnsinteger/board_columns / !-- 游戏结束弹窗 -- include layoutlayout/game_over_dialog / /androidx.constraintlayout.widget.ConstraintLayout4.2 自定义单元格视图每个格子需要根据状态显示不同样式class CellView JvmOverloads constructor( context: Context, attrs: AttributeSet? null, defStyleAttr: Int 0 ) : View(context, attrs, defStyleAttr) { private var cellState: CellState CellState.COVERED private var adjacentMines 0 private var isMine false override fun onDraw(canvas: Canvas) { when (cellState) { CellState.COVERED - drawCoveredCell(canvas) CellState.FLAGGED - drawFlaggedCell(canvas) CellState.REVEALED - { if (isMine) drawMineCell(canvas) else drawNumberCell(canvas) } CellState.EXPLODED - drawExplodedCell(canvas) } } private fun drawNumberCell(canvas: Canvas) { // 绘制背景 canvas.drawColor(ContextCompat.getColor(context, R.color.cell_revealed)) if (adjacentMines 0) { // 绘制数字 val textPaint Paint().apply { color getNumberColor(adjacentMines) textSize resources.getDimension(R.dimen.cell_text_size) textAlign Paint.Align.CENTER } val x width / 2f val y height / 2f - (textPaint.descent() textPaint.ascent()) / 2f canvas.drawText(adjacentMines.toString(), x, y, textPaint) } } // 其他绘制方法... }4.3 手势事件处理扫雷需要区分点击和长按两种操作board_grid.setOnItemClickListener { _, _, position, _ - viewModel.onCellClicked(position) } board_grid.setOnItemLongClickListener { _, _, position, _ - viewModel.onCellLongClicked(position) true // 消费事件 }在ViewModel中处理游戏逻辑fun onCellClicked(position: Int) { if (gameState.value ! GameState.PLAYING) return when (val cell cells.value?.get(position)) { null - return !isRevealed - { if (cell.isFlagged) return if (cell.isMine) { // 踩到雷游戏结束 revealAllMines() _gameState.value GameState.LOST } else { // 安全区域 if (cell.adjacentMines 0) { expandEmptyArea(position) } else { revealCell(position) } checkWinCondition() } } } } fun onCellLongClicked(position: Int) { if (gameState.value ! GameState.PLAYING) return val cell cells.value?.get(position) ?: return if (!cell.isRevealed) { toggleFlag(position) } }5. 高级功能与优化5.1 游戏状态持久化为了防止意外退出导致游戏进度丢失需要实现游戏状态保存// 保存状态 override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putParcelable(game_state, viewModel.saveGameState()) } // 恢复状态 override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (savedInstanceState ! null) { viewModel.restoreGameState(savedInstanceState.getParcelable(game_state)) } } // ViewModel中的实现 fun saveGameState(): GameStateBundle { return GameStateBundle( cells _cells.value?.map { it.toBundle() }, mineCount _mineCount.value, elapsedTime _elapsedTime.value, gameState _gameState.value ) } fun restoreGameState(bundle: GameStateBundle?) { bundle?.let { _cells.value it.cells?.map { cellBundle - Cell.fromBundle(cellBundle) } _mineCount.value it.mineCount _elapsedTime.value it.elapsedTime _gameState.value it.gameState if (it.gameState GameState.PLAYING) { startTimer() } } }5.2 性能优化技巧视图回收优化在自定义Adapter中重用convertViewoverride fun getView(position: Int, convertView: View?, parent: ViewGroup): View { val cellView convertView as? CellView ?: CellView(context).apply { layoutParams GridView.LayoutParams(cellSize, cellSize) } val cell getItem(position) cellView.updateState(cell) return cellView }减少对象创建在游戏逻辑中使用基本类型而非对象// 使用二维数组代替对象列表存储单元格状态 private val cellStates Array(rows) { IntArray(columns) }异步计算大型雷区生成放在后台线程viewModelScope.launch(Dispatchers.Default) { val mines generateMines(firstClickPos, mineCount, rows * columns) withContext(Dispatchers.Main) { updateBoard(mines) } }5.3 难度设置与自定义游戏通过SharedPreferences保存用户偏好object GamePreferences { private const val PREFS_NAME minesweeper_prefs private const val KEY_DIFFICULTY difficulty private const val KEY_CUSTOM_ROWS custom_rows private const val KEY_CUSTOM_COLS custom_cols private const val KEY_CUSTOM_MINES custom_mines fun saveDifficulty(context: Context, difficulty: Difficulty) { context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit { putString(KEY_DIFFICULTY, difficulty.name) } } fun getDifficulty(context: Context): Difficulty { val prefs context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) return try { Difficulty.valueOf(prefs.getString(KEY_DIFFICULTY, Difficulty.MEDIUM.name)!!) } catch (e: Exception) { Difficulty.MEDIUM } } // 保存自定义设置... }5.4 动画与音效增强为提升游戏体验可以添加简单的动画和音效// 单元格翻转动画 private fun revealWithAnimation(position: Int) { val cellView board_grid.getChildAt(position) as? CellView ?: return cellView.animate() .rotationY(90f) .setDuration(100) .withEndAction { cellView.updateState(cells.value[position]) cellView.rotationY 270f cellView.animate() .rotationY(360f) .setDuration(100) .start() } .start() SoundManager.playRevealSound() } // 音效管理单例 object SoundManager { private var soundPool: SoundPool? null private val soundMap mutableMapOfInt, Int() fun init(context: Context) { soundPool SoundPool.Builder() .setMaxStreams(3) .build() soundMap[R.raw.reveal_sound] soundPool?.load(context, R.raw.reveal_sound, 1) ?: -1 // 加载其他音效... } fun playRevealSound() { soundMap[R.raw.reveal_sound]?.let { soundId - soundPool?.play(soundId, 1f, 1f, 1, 0, 1f) } } }6. 测试与调试6.1 单元测试关键算法使用JUnit测试雷区生成算法class BoardGeneratorTest { private val generator BoardGenerator() Test fun generateMines should not place mines near first click() { val firstClick 40 // 假设点击中间位置(4,4) in 9x9 val mines generator.generateMines(firstClick, 10, 81) // 检查点击位置及其周围8格都没有雷 val adjacent generator.getAdjacentPositions(firstClick, 9, 9) firstClick assertTrue(adjacent.none { it in mines }) } Test fun calculateAdjacentMines should return correct count() { val mines setOf(0, 1, 2, 9, 11, 18, 19, 20) assertEquals(8, generator.calculateAdjacentMines(mines, 10, 9, 9)) // 中心位置 assertEquals(3, generator.calculateAdjacentMines(mines, 3, 9, 9)) // 边缘位置 } }6.2 UI测试关键交互使用Espresso测试游戏流程RunWith(AndroidJUnit4::class) class GameActivityTest { get:Rule val activityRule ActivityScenarioRule(GameActivity::class.java) Test fun firstClickShouldBeSafe() { onView(withId(R.id.board_grid)) .perform(actionOnItemAtPosition(40, click())) // 点击中间位置 // 验证没有立即显示游戏结束 onView(withId(R.id.game_over_dialog)) .check(matches(not(isDisplayed()))) } Test fun longClickShouldToggleFlag() { val position 0 onView(withId(R.id.board_grid)) .perform(actionOnItemAtPosition(position, longClick())) // 验证该位置显示旗帜 onView(withId(R.id.board_grid)) .check(matches(atPosition(position, hasFlag()))) } } fun hasFlag(): MatcherView { return object : BoundedMatcherView, CellView(CellView::class.java) { override fun describeTo(description: Description) { description.appendText(has flag) } override fun matchesSafely(item: CellView): Boolean { return item.isFlagged } } }6.3 常见问题排查GridView显示不全或滚动问题确保GridView的layout_height设置为match_parent或固定高度检查单元格大小计算是否正确避免因小数像素导致的布局问题触摸事件不响应确认没有父视图拦截了触摸事件检查自定义View的clickable和focusable属性设置性能问题大型雷区卡顿使用Android Profiler检查内存使用情况确保Adapter的getView方法高效重用convertView考虑使用RecyclerView替代GridView以获得更好的性能屏幕旋转导致游戏重置实现onSaveInstanceState正确保存游戏状态在ViewModel中使用SavedStateHandle保持数据音效延迟提前初始化SoundPool不要等到第一次播放时才加载考虑使用MediaPlayer替代SoundPool播放背景音乐7. 项目扩展方向7.1 多主题支持通过资源替换实现不同视觉风格!-- res/values/styles.xml -- style nameTheme.Minesweeper parentTheme.Material3.DayNight !-- 默认主题颜色 -- /style !-- res/values-night/styles.xml -- style nameTheme.Minesweeper parentTheme.Material3.DayNight !-- 暗色主题颜色 -- /style !-- res/values/attrs.xml -- attr namecellStyle formatenum enum nameclassic value0 / enum namemodern value1 / enum namepixel value2 / /attr !-- 在自定义View中使用 -- val style context.theme.obtainStyledAttributes(attrs, R.styleable.CellView, 0, 0) val cellStyle try { style.getInt(R.styleable.CellView_cellStyle, 0) } finally { style.recycle() }7.2 在线排行榜使用Firebase实现简单的分数排行榜class LeaderboardManager(private val context: Context) { private val firestore Firebase.firestore private val leaderboardRef firestore.collection(leaderboard) fun submitScore(name: String, time: Int, difficulty: Difficulty) { val score hashMapOf( name to name, time to time, difficulty to difficulty.name, timestamp to FieldValue.serverTimestamp() ) leaderboardRef.add(score) .addOnSuccessListener { Toast.makeText(context, Score submitted!, Toast.LENGTH_SHORT).show() } .addOnFailureListener { Toast.makeText(context, Submission failed, Toast.LENGTH_SHORT).show() } } fun getTopScores(difficulty: Difficulty, callback: (ListScoreEntry) - Unit) { leaderboardRef .whereEqualTo(difficulty, difficulty.name) .orderBy(time) .limit(10) .get() .addOnSuccessListener { result - val scores result.map { doc - ScoreEntry( name doc.getString(name) ?: Anonymous, time doc.getLong(time)?.toInt() ?: 0 ) } callback(scores) } } }7.3 成就系统实现游戏内成就解锁功能class AchievementManager(private val context: Context) { private val prefs context.getSharedPreferences(achievements, Context.MODE_PRIVATE) fun checkAchievements(gameStats: GameStats) { if (gameStats.totalGames 10 !prefs.getBoolean(veteran, false)) { unlockAchievement(veteran, Played 10 games) } if (gameStats.fastestTime 30 !prefs.getBoolean(speed_demon, false)) { unlockAchievement(speed_demon, Complete a game in under 30 seconds) } // 其他成就检查... } private fun unlockAchievement(id: String, description: String) { prefs.edit().putBoolean(id, true).apply() showAchievementToast(description) } private fun showAchievementToast(description: String) { Toast.makeText(context, Achievement Unlocked: $description, Toast.LENGTH_LONG).show() } }7.4 手势控制扩展为平板设备添加更多手势支持class GameGestureListener(private val viewModel: GameViewModel) : GestureDetector.SimpleOnGestureListener() { override fun onDoubleTap(e: MotionEvent): Boolean { // 实现双击快速展开安全区域 val position viewModel.getPositionFromCoordinates(e.x, e.y) if (position ! -1) { viewModel.onCellDoubleClicked(position) } return true } override fun onFling(e1: MotionEvent, e2: MotionEvent, velocityX: Float, velocityY: Float): Boolean { // 实现滑动切换难度 if (abs(velocityX) 1000) { viewModel.switchDifficulty(if (velocityX 0) NEXT else PREVIOUS) return true } return false } } // 在Activity中设置 private val gestureDetector GestureDetector(this, GameGestureListener(viewModel)) override fun onTouchEvent(event: MotionEvent): Boolean { return gestureDetector.onTouchEvent(event) || super.onTouchEvent(event) }8. 项目总结与经验分享在完成这个Android扫雷游戏项目的过程中有几个关键经验值得分享算法与UI分离的重要性将游戏核心逻辑如雷区生成、数字计算与界面表现完全分离使得后期更换UI框架如迁移到Compose变得非常容易。这种架构设计也大大简化了单元测试的编写。性能优化的平衡最初尝试使用自定义View绘制整个棋盘虽然性能更好但开发效率低下。最终选择GridView方案在保证流畅度的前提下大幅提升了开发速度。对于9×9到30×16的标准尺寸这种妥协是完全值得的。测试驱动开发的实践先为雷区生成算法编写测试用例再实现具体代码这种方式帮助发现了许多边界条件问题比如首次点击在角落时的雷区生成逻辑。状态管理的复杂性游戏状态的保存与恢复看似简单实际涉及大量细节。特别是计时器的暂停与恢复需要仔细处理生命周期事件。最终采用ViewModelSavedStateHandle的方案最为可靠。交互细节的打磨标准的点击和长按操作对于老玩家很自然但对新手可能不够直观。添加简单的操作指引和动画反馈后用户体验明显提升。这个项目虽然不大但涵盖了Android开发的多个重要方面UI设计、事件处理、状态管理、数据持久化等。对于想要学习Android开发的同学实现一个功能完整的扫雷游戏是非常好的练习项目。它既有足够的挑战性又不会过于复杂导致难以完成。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询