發(fā):從算法到UI的全流程實(shí)踐)
1. 項(xiàng)目背景與核心目標(biāo)掃雷游戲作為Windows系統(tǒng)自帶的經(jīng)典游戲承載了一代人的記憶。這個(gè)看似簡(jiǎn)單的游戲背后其實(shí)蘊(yùn)含著算法設(shè)計(jì)、交互邏輯和狀態(tài)管理等多個(gè)計(jì)算機(jī)科學(xué)領(lǐng)域的知識(shí)要點(diǎn)。在移動(dòng)互聯(lián)網(wǎng)時(shí)代將掃雷游戲移植到Android平臺(tái)不僅是對(duì)經(jīng)典游戲的致敬更是學(xué)習(xí)Android應(yīng)用開(kāi)發(fā)的絕佳實(shí)踐項(xiàng)目。這個(gè)項(xiàng)目的核心目標(biāo)是通過(guò)Android Studio開(kāi)發(fā)一個(gè)功能完整的掃雷游戲?qū)崿F(xiàn)以下關(guān)鍵特性可自定義的棋盤(pán)大小如9×9初級(jí)、16×16中級(jí)、30×16高級(jí)符合經(jīng)典掃雷規(guī)則的雷區(qū)生成與數(shù)字提示系統(tǒng)支持點(diǎn)擊翻開(kāi)、長(zhǎng)按標(biāo)記地雷等標(biāo)準(zhǔn)交互操作計(jì)時(shí)器和剩余雷數(shù)顯示等游戲狀態(tài)管理游戲勝利/失敗的判定與界面反饋選擇這個(gè)項(xiàng)目進(jìn)行開(kāi)發(fā)主要基于以下幾個(gè)技術(shù)學(xué)習(xí)價(jià)值掌握Android基礎(chǔ)UI組件的使用特別是GridView和自定義View理解MVC/MVVM架構(gòu)在游戲開(kāi)發(fā)中的應(yīng)用學(xué)習(xí)觸摸事件處理和手勢(shì)識(shí)別實(shí)踐游戲狀態(tài)管理和數(shù)據(jù)持久化熟悉Android Studio開(kāi)發(fā)環(huán)境和調(diào)試工具提示雖然掃雷看起來(lái)簡(jiǎn)單但要實(shí)現(xiàn)一個(gè)體驗(yàn)良好的版本需要考慮很多細(xì)節(jié)問(wèn)題比如性能優(yōu)化、不同屏幕尺寸適配、狀態(tài)恢復(fù)等這些都是很好的學(xué)習(xí)機(jī)會(huì)。2. 開(kāi)發(fā)環(huán)境與技術(shù)選型2.1 Android Studio配置要點(diǎn)開(kāi)發(fā)Android掃雷游戲首推使用Android Studio作為IDE。當(dāng)前穩(wěn)定版本如Giraffe 2022.3.1已經(jīng)能很好地支持本項(xiàng)目的所有開(kāi)發(fā)需求。安裝時(shí)需要注意JDK版本建議使用JDK 17LTS版本這是Android Studio Giraffe的推薦配置Android SDK確保安裝API 34Android 14和API 33Android 13兩個(gè)版本Gradle配置使用Gradle 8.0以上版本build.gradle中配置如下android { compileSdk 34 defaultConfig { minSdk 24 targetSdk 34 versionCode 1 versionName 1.0 } }2.2 游戲引擎選擇對(duì)于掃雷這類邏輯密集型游戲我們有以下幾種技術(shù)實(shí)現(xiàn)方案方案優(yōu)點(diǎn)缺點(diǎn)適用場(chǎng)景純Canvas繪制性能好完全控制渲染開(kāi)發(fā)效率低追求極致性能View系統(tǒng)GridView開(kāi)發(fā)簡(jiǎn)單復(fù)用現(xiàn)有組件靈活性受限快速原型開(kāi)發(fā)Jetpack Compose聲明式UI現(xiàn)代化學(xué)習(xí)曲線較陡長(zhǎng)期維護(hù)項(xiàng)目考慮到掃雷的單元格交互需求本項(xiàng)目采用GridView自定義Adapter的方案這是平衡開(kāi)發(fā)效率和性能的最佳選擇。GridView天生適合棋盤(pán)類布局其回收機(jī)制能很好地處理大尺寸雷區(qū)。2.3 項(xiàng)目結(jié)構(gòu)設(shè)計(jì)采用分層架構(gòu)設(shè)計(jì)核心模塊劃分如下com.example.minesweeper/ ├── model/ # 數(shù)據(jù)模型 │ ├── Cell.kt # 單元格數(shù)據(jù)類 │ ├── Game.kt # 游戲狀態(tài)管理 │ └── Board.kt # 雷區(qū)生成算法 ├── view/ # 界面相關(guān) │ ├── CellView.kt # 自定義單元格視圖 │ └── GameActivity.kt # 主界面 ├── adapter/ # 適配器 │ └── BoardAdapter.kt # GridView適配器 └── utils/ # 工具類 ├── Preferences.kt # 游戲設(shè)置存儲(chǔ) └── SoundManager.kt # 音效管理這種結(jié)構(gòu)清晰分離了游戲邏輯和界面表現(xiàn)便于后續(xù)擴(kuò)展和維護(hù)。3. 核心游戲邏輯實(shí)現(xiàn)3.1 雷區(qū)生成算法掃雷游戲的核心算法之一是隨機(jī)生成雷區(qū)。這里需要考慮兩個(gè)關(guān)鍵點(diǎn)確保首次點(diǎn)擊位置一定是安全的不踩雷雷的分布要真正隨機(jī)避免出現(xiàn)明顯的模式實(shí)現(xiàn)代碼示例Kotlinfun generateMines(firstClickPos: Int, mineCount: Int, totalCells: Int): SetInt { val mines mutableSetOfInt() val random Random(System.currentTimeMillis()) // 確保首次點(diǎn)擊位置周圍8格都沒(méi)有雷 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 數(shù)字提示計(jì)算每個(gè)非雷單元格需要顯示周圍8格中的地雷數(shù)量。這個(gè)計(jì)算可以在生成雷區(qū)時(shí)同步完成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 空白區(qū)域展開(kāi)算法當(dāng)玩家點(diǎn)擊一個(gè)周圍沒(méi)有雷的空白單元格時(shí)需要自動(dòng)展開(kāi)所有相鄰的空白區(qū)域直到遇到數(shù)字邊界。這需要使用廣度優(yōu)先搜索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. 用戶界面設(shè)計(jì)與實(shí)現(xiàn)4.1 游戲主界面布局使用ConstraintLayout作為根布局包含以下幾個(gè)關(guān)鍵部分androidx.constraintlayout.widget.ConstraintLayout !-- 游戲狀態(tài)欄 -- 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 !-- 雷區(qū)網(wǎng)格 -- 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 / !-- 游戲結(jié)束彈窗 -- include layoutlayout/game_over_dialog / /androidx.constraintlayout.widget.ConstraintLayout4.2 自定義單元格視圖每個(gè)格子需要根據(jù)狀態(tài)顯示不同樣式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) { // 繪制數(shù)字 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 手勢(shì)事件處理掃雷需要區(qū)分點(diǎn)擊和長(zhǎng)按兩種操作board_grid.setOnItemClickListener { _, _, position, _ - viewModel.onCellClicked(position) } board_grid.setOnItemLongClickListener { _, _, position, _ - viewModel.onCellLongClicked(position) true // 消費(fèi)事件 }在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) { // 踩到雷游戲結(jié)束 revealAllMines() _gameState.value GameState.LOST } else { // 安全區(qū)域 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. 高級(jí)功能與優(yōu)化5.1 游戲狀態(tài)持久化為了防止意外退出導(dǎo)致游戲進(jìn)度丟失需要實(shí)現(xiàn)游戲狀態(tài)保存// 保存狀態(tài) override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putParcelable(game_state, viewModel.saveGameState()) } // 恢復(fù)狀態(tài) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (savedInstanceState ! null) { viewModel.restoreGameState(savedInstanceState.getParcelable(game_state)) } } // ViewModel中的實(shí)現(xiàn) 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 性能優(yōu)化技巧視圖回收優(yōu)化在自定義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 }減少對(duì)象創(chuàng)建在游戲邏輯中使用基本類型而非對(duì)象// 使用二維數(shù)組代替對(duì)象列表存儲(chǔ)單元格狀態(tài) private val cellStates Array(rows) { IntArray(columns) }異步計(jì)算大型雷區(qū)生成放在后臺(tái)線程viewModelScope.launch(Dispatchers.Default) { val mines generateMines(firstClickPos, mineCount, rows * columns) withContext(Dispatchers.Main) { updateBoard(mines) } }5.3 難度設(shè)置與自定義游戲通過(guò)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 } } // 保存自定義設(shè)置... }5.4 動(dòng)畫(huà)與音效增強(qiáng)為提升游戲體驗(yàn)可以添加簡(jiǎn)單的動(dòng)畫(huà)和音效// 單元格翻轉(zhuǎn)動(dòng)畫(huà) 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. 測(cè)試與調(diào)試6.1 單元測(cè)試關(guān)鍵算法使用JUnit測(cè)試?yán)讌^(qū)生成算法class BoardGeneratorTest { private val generator BoardGenerator() Test fun generateMines should not place mines near first click() { val firstClick 40 // 假設(shè)點(diǎn)擊中間位置(4,4) in 9x9 val mines generator.generateMines(firstClick, 10, 81) // 檢查點(diǎn)擊位置及其周圍8格都沒(méi)有雷 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測(cè)試關(guān)鍵交互使用Espresso測(cè)試游戲流程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())) // 點(diǎn)擊中間位置 // 驗(yàn)證沒(méi)有立即顯示游戲結(jié)束 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())) // 驗(yàn)證該位置顯示旗幟 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 常見(jiàn)問(wèn)題排查GridView顯示不全或滾動(dòng)問(wèn)題確保GridView的layout_height設(shè)置為match_parent或固定高度檢查單元格大小計(jì)算是否正確避免因小數(shù)像素導(dǎo)致的布局問(wèn)題觸摸事件不響應(yīng)確認(rèn)沒(méi)有父視圖攔截了觸摸事件檢查自定義View的clickable和focusable屬性設(shè)置性能問(wèn)題大型雷區(qū)卡頓使用Android Profiler檢查內(nèi)存使用情況確保Adapter的getView方法高效重用convertView考慮使用RecyclerView替代GridView以獲得更好的性能屏幕旋轉(zhuǎn)導(dǎo)致游戲重置實(shí)現(xiàn)onSaveInstanceState正確保存游戲狀態(tài)在ViewModel中使用SavedStateHandle保持?jǐn)?shù)據(jù)音效延遲提前初始化SoundPool不要等到第一次播放時(shí)才加載考慮使用MediaPlayer替代SoundPool播放背景音樂(lè)7. 項(xiàng)目擴(kuò)展方向7.1 多主題支持通過(guò)資源替換實(shí)現(xiàn)不同視覺(jué)風(fēng)格!-- res/values/styles.xml -- style nameTheme.Minesweeper parentTheme.Material3.DayNight !-- 默認(rèn)主題顏色 -- /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實(shí)現(xiàn)簡(jiǎn)單的分?jǐn)?shù)排行榜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 成就系統(tǒng)實(shí)現(xiàn)游戲內(nèi)成就解鎖功能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 手勢(shì)控制擴(kuò)展為平板設(shè)備添加更多手勢(shì)支持class GameGestureListener(private val viewModel: GameViewModel) : GestureDetector.SimpleOnGestureListener() { override fun onDoubleTap(e: MotionEvent): Boolean { // 實(shí)現(xiàn)雙擊快速展開(kāi)安全區(qū)域 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 { // 實(shí)現(xiàn)滑動(dòng)切換難度 if (abs(velocityX) 1000) { viewModel.switchDifficulty(if (velocityX 0) NEXT else PREVIOUS) return true } return false } } // 在Activity中設(shè)置 private val gestureDetector GestureDetector(this, GameGestureListener(viewModel)) override fun onTouchEvent(event: MotionEvent): Boolean { return gestureDetector.onTouchEvent(event) || super.onTouchEvent(event) }8. 項(xiàng)目總結(jié)與經(jīng)驗(yàn)分享在完成這個(gè)Android掃雷游戲項(xiàng)目的過(guò)程中有幾個(gè)關(guān)鍵經(jīng)驗(yàn)值得分享算法與UI分離的重要性將游戲核心邏輯如雷區(qū)生成、數(shù)字計(jì)算與界面表現(xiàn)完全分離使得后期更換UI框架如遷移到Compose變得非常容易。這種架構(gòu)設(shè)計(jì)也大大簡(jiǎn)化了單元測(cè)試的編寫(xiě)。性能優(yōu)化的平衡最初嘗試使用自定義View繪制整個(gè)棋盤(pán)雖然性能更好但開(kāi)發(fā)效率低下。最終選擇GridView方案在保證流暢度的前提下大幅提升了開(kāi)發(fā)速度。對(duì)于9×9到30×16的標(biāo)準(zhǔn)尺寸這種妥協(xié)是完全值得的。測(cè)試驅(qū)動(dòng)開(kāi)發(fā)的實(shí)踐先為雷區(qū)生成算法編寫(xiě)測(cè)試用例再實(shí)現(xiàn)具體代碼這種方式幫助發(fā)現(xiàn)了許多邊界條件問(wèn)題比如首次點(diǎn)擊在角落時(shí)的雷區(qū)生成邏輯。狀態(tài)管理的復(fù)雜性游戲狀態(tài)的保存與恢復(fù)看似簡(jiǎn)單實(shí)際涉及大量細(xì)節(jié)。特別是計(jì)時(shí)器的暫停與恢復(fù)需要仔細(xì)處理生命周期事件。最終采用ViewModelSavedStateHandle的方案最為可靠。交互細(xì)節(jié)的打磨標(biāo)準(zhǔn)的點(diǎn)擊和長(zhǎng)按操作對(duì)于老玩家很自然但對(duì)新手可能不夠直觀。添加簡(jiǎn)單的操作指引和動(dòng)畫(huà)反饋后用戶體驗(yàn)明顯提升。這個(gè)項(xiàng)目雖然不大但涵蓋了Android開(kāi)發(fā)的多個(gè)重要方面UI設(shè)計(jì)、事件處理、狀態(tài)管理、數(shù)據(jù)持久化等。對(duì)于想要學(xué)習(xí)Android開(kāi)發(fā)的同學(xué)實(shí)現(xiàn)一個(gè)功能完整的掃雷游戲是非常好的練習(xí)項(xiàng)目。它既有足夠的挑戰(zhàn)性又不會(huì)過(guò)于復(fù)雜導(dǎo)致難以完成。