1. 这不是“玩具代码”,而是一套Android UI交互能力的完整训练靶场
你点开这个标题,大概率是刚学完Activity生命周期、控件基础布局,正卡在“写了几个Hello World,却不知道下一步该练什么”的阶段。我带过不少初学者,发现一个普遍现象:很多人把Toast、Dialog、Menu、Spinner当成零散的“小功能点”,挨个查文档、复制粘贴跑通就以为学会了。结果一到真实项目里——比如要做一个带筛选菜单的订单列表,或者点击按钮弹出确认框再跳转页面——立刻懵了:该用哪个?怎么传参?生命周期怎么处理?样式怎么统一?
这其实暴露了一个关键认知偏差:这些组件从来不是孤立存在的API调用,而是Android UI交互体系中环环相扣的齿轮。Toast是轻量级状态反馈,Dialog是模态任务中断,Menu是系统级操作入口,Spinner是数据驱动的选择器——它们共同构成用户与App对话的基本语法。而“打地鼠”这个看似简单的游戏,恰恰是极少数能自然串联起全部四者的实战场景:地鼠随机出现(需要定时器+随机数),点击得分(View点击事件绑定),分数实时显示(TextView更新),超时失败弹窗(Dialog确认重玩),暂停/重置菜单(Option Menu响应),难度选择下拉框(Spinner联动游戏逻辑)。
我当年第一次独立完成这个Demo时,花了整整三天反复调试Dialog的dismiss时机——因为没搞懂它和Activity生命周期的关系,导致用户点“取消”后Activity已经onPause,再点“确定”就直接Crash。后来才明白,这不是代码写错了,而是对Android UI线程模型、Fragment事务、Context作用域的理解缺了一块拼图。所以这篇内容不讲“怎么写”,而是带你一层层剥开:为什么必须用Handler配合Runnable控制地鼠刷新?为什么Dialog不能用Application Context创建?为什么Spinner的Adapter要重写getView而不是仅仅setAdapter?每一个“为什么”,都对应着你在真实项目里会踩的坑。适合谁?适合所有正在从“能跑通”迈向“能掌控”的Android学习者——无论你是自学、培训还是刚入职的应届生,只要你希望写的代码不只是Demo,而是有扩展性、可维护、能上线的真正App模块。
2. 核心设计逻辑:用游戏机制倒逼UI组件协同工作
2.1 为什么选“打地鼠”作为载体?——不是为了趣味,而是为了强制耦合
很多教程用“计算器”或“备忘录”练UI,但这类应用天然割裂:输入框只管输入,按钮只管计算,列表只管展示。而打地鼠不同——它的游戏规则本身就在制造组件间的强依赖。我们来拆解这个链条:
- 地鼠出现逻辑(后台线程)→ 触发Toast提示(“地鼠来了!”)→ 用户点击 → 更新分数TextView→ 判断是否超时 → 弹出Dialog(“游戏结束,得分XX”)→ 用户选择“重玩” → 重置Spinner选中项(难度归零)→ 点击Menu菜单中的“开始” → 启动新游戏循环。
看到没?这里没有一个组件是单打独斗的。Toast不是独立存在,它必须在地鼠生成后立即触发;Dialog的按钮回调必须能访问当前分数变量;Spinner的选择改变必须实时影响地鼠刷新间隔;Menu的点击事件必须能控制游戏状态机。这种强制耦合,恰恰是真实业务中最常见的场景——比如电商App的“筛选栏”(Spinner)改变后,“商品列表”(RecyclerView)必须刷新,“价格区间”(SeekBar)要同步更新,“排序方式”(Menu)要重置。打地鼠就是把这个复杂度压缩在一个小游戏中,让你在50行核心逻辑里,把组件协作的脉络摸清楚。
2.2 四大组件的选型依据:为什么不是Snackbar、BottomSheet、PopupMenu?
初学者常问:“现在都用Material Design了,为什么还教Toast和Dialog?” 这是个好问题。答案很实在:它们是Android原生UI体系的基石,且不可替代。我们来对比真实开发中的取舍:
| 组件 | 适用场景 | 替代方案 | 为什么本项目坚持用原生 |
|---|---|---|---|
| Toast | 短暂、无交互的状态提示(如“已复制”、“网络已连接”) | Snackbar | Snackbar需CoordinatorLayout支持,且默认带Action按钮——打地鼠中“地鼠出现”只需0.5秒视觉反馈,加按钮纯属冗余,反而增加布局复杂度 |
| Dialog | 需要用户明确决策的模态中断(如确认删除、权限申请) | BottomSheetDialog | BottomSheet需额外依赖库,且滑动关闭行为与游戏“强制确认”逻辑冲突;原生AlertDialog的setPositiveButton/NegativeButton语义更清晰 |
| Menu | 系统级操作入口(通常在ActionBar或长按菜单) | PopupMenu | PopupMenu需手动指定Anchor View,而Option Menu由系统自动管理位置和动画,符合Android设计规范,且MenuItem的showAsAction属性能智能适配不同屏幕尺寸 |
| Spinner | 单选、数据量小、需下拉展开的选项集(如城市选择、难度等级) | MaterialSpinner(第三方) | 第三方库增加APK体积,且Spinner的ArrayAdapter+getView定制化足够满足需求;更重要的是,它强制你理解Adapter模式——这是ListView/RecyclerView的底层逻辑 |
提示:别急着追求“最新技术”。我在某公司重构一个老项目时发现,他们把所有Toast换成Snackbar后,因未处理CoordinatorLayout嵌套层级,导致部分页面Snackbar弹出位置错乱。最后回退到Toast,仅用3行代码就解决了。工具是为问题服务的,不是为“新”服务的。
2.3 游戏状态机设计:让UI组件各司其职,而非互相污染
很多初学者的代码里,Activity里塞满了游戏逻辑:计时器、分数计算、地鼠坐标生成、Dialog弹出判断……结果Activity变成“上帝类”,改一行代码牵动全身。正确的做法是用状态机解耦。本项目定义了三个核心状态:
GameState.IDLE:初始状态,显示“点击开始”按钮,Spinner可选难度,Menu中“开始”可用,“暂停”禁用;GameState.RUNNING:游戏进行中,地鼠定时刷新,点击有效,分数实时更新,Menu中“暂停”可用,“开始”禁用;GameState.PAUSED:暂停状态,地鼠停止刷新,点击无效,Menu中“继续”可用,“暂停”禁用。
每个状态变更,只触发对应UI的更新:
- 进入
RUNNING→ 启动Handler定时任务 + 启用地鼠ImageView点击监听; - 进入
PAUSED→ 移除Handler回调 + 禁用ImageView点击; - 进入
IDLE→ 重置分数/时间 + 隐藏所有地鼠。
这样,Dialog的“重玩”按钮回调只需调用resetGame(),无需关心Timer是否在运行、ImageView是否可点击——那些细节已被状态机封装。这就是架构思维:让UI组件只做UI的事(展示、响应),让状态机管流程,让数据模型管计算。
3. 核心实现细节:从源码到避坑的全链路解析
3.1 Toast的精准控制:为什么不能直接在子线程里show()?
新手常犯的错误:在Handler的postDelayed里直接写Toast.makeText(this, "地鼠来了", Toast.LENGTH_SHORT).show(),结果崩溃报错Can't create handler inside thread that has not called Looper.prepare()。原因很简单——Toast本质是一个Window,必须在有Looper的线程(即主线程/UI线程)中创建和显示。
正确解法有两种,本项目采用更安全的第一种:
// ✅ 推荐:通过runOnUiThread确保在主线程执行 handler.postDelayed(new Runnable() { @Override public void run() { // 生成随机地鼠位置... int randomIndex = new Random().nextInt(9); moleViews[randomIndex].setVisibility(View.VISIBLE); // 在主线程显示Toast runOnUiThread(new Runnable() { @Override public void run() { Toast.makeText(MainActivity.this, "地鼠来了!", Toast.LENGTH_SHORT).show(); } }); } }, 1000);注意:不要用
Looper.getMainLooper()手动创建Handler,虽然可行但易出错。runOnUiThread是Activity提供的便捷方法,内部已帮你处理了Looper和MessageQueue的绑定,且在Activity销毁时自动移除未执行的Runnable,避免内存泄漏。
另一个常见坑:Toast连续show()会排队显示,导致“地鼠来了”弹出多次。解决方案是在show前先cancel上一个:
private Toast currentToast; // ... if (currentToast != null) { currentToast.cancel(); // 取消上一个 } currentToast = Toast.makeText(this, "地鼠来了!", Toast.LENGTH_SHORT); currentToast.show();3.2 Dialog的生命周期安全:为什么dismiss()后还要判空?
Dialog的坑比Toast深得多。最典型的是:用户点击“重玩”按钮后,Dialog dismiss,但此时Activity可能已被系统回收(如横竖屏切换),导致findViewById(R.id.score_text)返回null,进而NPE崩溃。
本项目采用三重防护:
- 使用supportFragmentManager管理Dialog(非Activity的show()):
// ✅ 正确:用DialogFragment,由系统管理生命周期 RestartDialogFragment dialog = new RestartDialogFragment(); dialog.setScore(currentScore); // 通过setter传参,避免Bundle序列化 dialog.show(getSupportFragmentManager(), "restart_dialog");- 在DialogFragment中,onDismiss()里清理资源:
@Override public void onDismiss(@NonNull DialogInterface dialog) { super.onDismiss(dialog); // 确保Activity未销毁再操作 if (getActivity() != null && !getActivity().isFinishing()) { resetGame(); // 安全重置 } }- 关键操作前加双重判空:
public void resetGame() { // 判空1:Activity是否存在 if (getActivity() == null || getActivity().isFinishing() || getActivity().isDestroyed()) { return; } // 判空2:View是否已inflate TextView scoreText = getActivity().findViewById(R.id.score_text); if (scoreText != null) { scoreText.setText("0"); } // ...其他重置逻辑 }实操心得:我曾在线上版本遇到过Dialog点击后黑屏的问题,排查发现是用户快速连点两次“重玩”,导致两个DialogFragment同时show(),第二个覆盖第一个但未正确dismiss。最终在show前加了tag检查:
if (getSupportFragmentManager().findFragmentByTag("restart_dialog") == null) { dialog.show(...); }——这个细节,90%的教程都不会提。
3.3 Menu的动态控制:如何让“开始/暂停”按钮随状态实时变灰?
很多教程只教menu.add(),却不说Menu的item状态是静态的。要实现“游戏运行中‘开始’变灰、‘暂停’高亮”,必须重写onPrepareOptionsMenu()——它在每次Menu弹出前被调用,是动态更新item状态的唯一时机。
@Override public boolean onPrepareOptionsMenu(Menu menu) { MenuItem startItem = menu.findItem(R.id.menu_start); MenuItem pauseItem = menu.findItem(R.id.menu_pause); // 根据当前游戏状态设置enabled和icon switch (gameState) { case IDLE: startItem.setEnabled(true); startItem.setIcon(R.drawable.ic_play); pauseItem.setEnabled(false); pauseItem.setIcon(R.drawable.ic_pause_disabled); break; case RUNNING: startItem.setEnabled(false); startItem.setIcon(R.drawable.ic_play_disabled); pauseItem.setEnabled(true); pauseItem.setIcon(R.drawable.ic_pause); break; case PAUSED: startItem.setEnabled(false); startItem.setIcon(R.drawable.ic_play_disabled); pauseItem.setEnabled(true); pauseItem.setIcon(R.drawable.ic_resume); break; } return true; // 返回true才能显示Menu }关键细节:
onPrepareOptionsMenu()的调用时机非常关键。它只在Menu即将显示时触发(如点击ActionBar溢出按钮),不会在状态变更时自动调用。所以当你在代码里修改gameState后,必须手动调用invalidateOptionsMenu()强制刷新:
private void setGameState(GameState newState) { this.gameState = newState; invalidateOptionsMenu(); // ⚠️ 必须加这一行! }3.4 Spinner的数据联动:如何让难度选择实时影响游戏节奏?
Spinner的难点不在显示,而在“选择后如何通知其他模块”。本项目采用接口回调解耦:
- 定义回调接口:
public interface DifficultyChangeListener { void onDifficultyChanged(int intervalMs); // 间隔毫秒数 }- 在Activity中实现并注册:
spinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { @Override public void onItemSelected(AdapterView<?> parent, View view, int position, long id) { int[] intervals = {3000, 2000, 1500}; // 简单/中等/困难 difficultyChangeListener.onDifficultyChanged(intervals[position]); } @Override public void onNothingSelected(AdapterView<?> parent) {} });- 在游戏逻辑中响应:
@Override public void onDifficultyChanged(int intervalMs) { this.moleInterval = intervalMs; // 更新地鼠刷新间隔 if (gameState == GameState.RUNNING) { restartTimer(); // 重新启动定时器 } }避坑技巧:Spinner的
onItemSelected会在初始化时触发一次(position=0),如果你的默认难度是“中等”,但数组里index0是“简单”,就会导致游戏一开始用错间隔。解决方案:在setAdapter()后,先spinner.setSelection(1)再设置监听器,或者在回调里加if (position != lastSelectedPosition)判断。
4. 完整实操步骤:从新建项目到可运行App的逐行指南
4.1 环境准备与项目结构搭建
第一步:创建Empty Activity项目
- 打开Android Studio → New Project → Phone and Tablet → Empty Activity
- Package name:建议用
com.example.molegame(避免中文或特殊字符) - Minimum SDK:选API 21 (Android 5.0) —— Toast/Dialog/Menu/Spinner在此版本已完全稳定,无需兼容更低版本增加复杂度
- Language:Java(本项目用Java讲解,原理通用)
第二步:添加游戏主界面布局(activity_main.xml)核心是9宫格地鼠坑位。不用ConstraintLayout搞复杂约束,用GridLayout最直观:
<GridLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:columnCount="3" android:rowCount="3" android:padding="16dp"> <!-- 9个地鼠ImageView,id按顺序命名 --> <ImageView android:id="@+id/mole_0" android:layout_width="0dp" android:layout_height="0dp" android:layout_columnWeight="1" android:layout_rowWeight="1" android:src="@drawable/mole_hidden" android:scaleType="fitCenter" /> <!-- 复制8次,id改为mole_1 ~ mole_8 --> <!-- 注意:所有ImageView初始visibility设为gone --> </GridLayout>关键配置说明:
layout_columnWeight="1"让每列均分宽度,layout_rowWeight="1"让每行均分高度,scaleType="fitCenter"确保地鼠图片不拉伸变形。mole_hidden是地鼠未出现时的背景图(可简单用灰色圆圈)。
第三步:准备资源文件
res/drawable/mole_hidden.png:地鼠隐藏状态(建议尺寸120x120px)res/drawable/mole_visible.png:地鼠出现状态(同尺寸,地鼠头露出)res/menu/main_menu.xml:定义Menu项
<?xml version="1.0" encoding="utf-8"?> <menu xmlns:android="http://schemas.android.com/apk/res/android"> <item android:id="@+id/menu_start" android:icon="@drawable/ic_play" android:title="开始" android:showAsAction="ifRoom" /> <item android:id="@+id/menu_pause" android:icon="@drawable/ic_pause" android:title="暂停" android:showAsAction="ifRoom" /> </menu>res/values/strings.xml:添加字符串资源,避免硬编码
<string name="toast_mole_appears">地鼠来了!</string> <string name="dialog_title_game_over">游戏结束</string> <string name="dialog_message_score">你的得分:%d</string> <string name="dialog_button_restart">重玩</string> <string name="dialog_button_exit">退出</string>4.2 核心Java代码实现:MainActivity.java逐段解析
第一段:成员变量与初始化
public class MainActivity extends AppCompatActivity { private static final String TAG = "MoleGame"; // 地鼠坑位数组(9个ImageView) private ImageView[] moleViews = new ImageView[9]; // 游戏状态 private GameState gameState = GameState.IDLE; private int score = 0; private int moleInterval = 3000; // 默认简单难度:3秒 // Handler用于定时刷新地鼠 private Handler handler; private Runnable moleSpawnRunnable; // UI控件引用 private TextView scoreText; private Spinner difficultySpinner; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); // 初始化View数组 for (int i = 0; i < 9; i++) { int id = getResources().getIdentifier("mole_" + i, "id", getPackageName()); moleViews[i] = findViewById(id); // 设置初始隐藏 moleViews[i].setVisibility(View.GONE); // 为每个地鼠设置点击监听 final int index = i; moleViews[i].setOnClickListener(v -> onMoleClick(index)); } // 初始化其他控件 scoreText = findViewById(R.id.score_text); difficultySpinner = findViewById(R.id.difficulty_spinner); // 初始化Handler(必须在主线程创建) handler = new Handler(Looper.getMainLooper()); // 初始化Spinner Adapter setupDifficultySpinner(); // 加载保存的游戏状态(可选) loadGameState(); } }解析:
getResources().getIdentifier()是动态获取id的技巧,避免手写9次findViewById(R.id.mole_0)。final int index = i是为了解决匿名内部类访问局部变量的编译错误,这是Java基础但常被忽略的点。
第二段:难度Spinner设置
private void setupDifficultySpinner() { ArrayAdapter<CharSequence> adapter = ArrayAdapter.createFromResource( this, R.array.difficulty_options, // strings.xml中定义的数组 android.R.layout.simple_spinner_item ); adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); difficultySpinner.setAdapter(adapter); // 设置默认选中“中等” difficultySpinner.setSelection(1); // 设置选择监听 difficultySpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { @Override public void onItemSelected(AdapterView<?> parent, View view, int position, long id) { int[] intervals = {3000, 2000, 1500}; moleInterval = intervals[position]; // 如果游戏正在运行,立即生效 if (gameState == GameState.RUNNING) { restartTimer(); } } @Override public void onNothingSelected(AdapterView<?> parent) {} }); }第三段:游戏核心逻辑(重点!)
// 地鼠点击事件 private void onMoleClick(int index) { if (gameState != GameState.RUNNING) return; // 非运行状态不响应 // 显示点击反馈(可选) moleViews[index].setImageResource(R.drawable.mole_hit); moleViews[index].postDelayed(() -> { if (gameState == GameState.RUNNING) { moleViews[index].setImageResource(R.drawable.mole_visible); } }, 200); // 更新分数 score += 10; scoreText.setText(String.valueOf(score)); // 播放音效(可选) // playSound(R.raw.hit_sound); } // 启动游戏 private void startGame() { if (gameState != GameState.IDLE) return; gameState = GameState.RUNNING; score = 0; scoreText.setText("0"); // 启动地鼠刷新定时器 startMoleTimer(); // 更新Menu状态 invalidateOptionsMenu(); } // 启动定时器 private void startMoleTimer() { if (moleSpawnRunnable == null) { moleSpawnRunnable = new Runnable() { @Override public void run() { if (gameState != GameState.RUNNING) return; // 随机选择一个坑位 int randomIndex = new Random().nextInt(9); // 隐藏所有地鼠 for (ImageView view : moleViews) { view.setVisibility(View.GONE); } // 显示随机地鼠 moleViews[randomIndex].setVisibility(View.VISIBLE); // 显示Toast if (currentToast != null) currentToast.cancel(); currentToast = Toast.makeText(MainActivity.this, getString(R.string.toast_mole_appears), Toast.LENGTH_SHORT); currentToast.show(); // 继续下一次刷新 handler.postDelayed(this, moleInterval); } }; } handler.removeCallbacks(moleSpawnRunnable); // 清除旧任务 handler.post(moleSpawnRunnable); } // 暂停游戏 private void pauseGame() { if (gameState != GameState.RUNNING) return; gameState = GameState.PAUSED; handler.removeCallbacks(moleSpawnRunnable); // 隐藏所有地鼠 for (ImageView view : moleViews) { view.setVisibility(View.GONE); } invalidateOptionsMenu(); } // 重置游戏 private void resetGame() { gameState = GameState.IDLE; handler.removeCallbacks(moleSpawnRunnable); score = 0; scoreText.setText("0"); for (ImageView view : moleViews) { view.setVisibility(View.GONE); } invalidateOptionsMenu(); }第四段:Menu与Dialog集成
@Override public boolean onCreateOptionsMenu(Menu menu) { getMenuInflater().inflate(R.menu.main_menu, menu); return true; } @Override public boolean onOptionsItemSelected(@NonNull MenuItem item) { switch (item.getItemId()) { case R.id.menu_start: if (gameState == GameState.IDLE) { startGame(); } else if (gameState == GameState.PAUSED) { resumeGame(); } return true; case R.id.menu_pause: if (gameState == GameState.RUNNING) { pauseGame(); } return true; } return super.onOptionsItemSelected(item); } // 恢复游戏(与pauseGame对称) private void resumeGame() { if (gameState != GameState.PAUSED) return; gameState = GameState.RUNNING; startMoleTimer(); invalidateOptionsMenu(); } // 游戏结束,弹出Dialog private void gameOver() { gameState = GameState.IDLE; handler.removeCallbacks(moleSpawnRunnable); // 使用DialogFragment GameOverDialogFragment dialog = new GameOverDialogFragment(); dialog.setScore(score); dialog.show(getSupportFragmentManager(), "game_over"); }4.3 DialogFragment实现:GameOverDialogFragment.java
public class GameOverDialogFragment extends DialogFragment { private int score; public void setScore(int score) { this.score = score; } @NonNull @Override public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); builder.setTitle(getString(R.string.dialog_title_game_over)) .setMessage(getString(R.string.dialog_message_score, score)) .setPositiveButton(R.string.dialog_button_restart, (dialog, which) -> { // 安全获取Activity if (getActivity() != null) { ((MainActivity) getActivity()).resetGame(); } }) .setNegativeButton(R.string.dialog_button_exit, (dialog, which) -> { if (getActivity() != null) { getActivity().finish(); } }); return builder.create(); } }关键点:DialogFragment的
onCreateDialog()中,getActivity()可能为null(如Dialog显示中Activity被销毁),所以回调里必须判空。getString(R.string.dialog_message_score, score)是格式化字符串的标准写法,比字符串拼接更安全。
5. 常见问题与实战排查技巧
5.1 “地鼠不出现”问题排查清单
这是新手最高频的问题,往往不是代码错,而是环境或配置疏漏。按优先级逐一检查:
| 检查项 | 检查方法 | 典型症状 | 解决方案 |
|---|---|---|---|
| ImageView visibility初始值 | 在布局文件中确认android:visibility="gone" | 所有地鼠一直显示或一直不显示 | 删除android:visibility属性,让代码完全控制 |
| Handler未启动 | 在startGame()中加Log.d(TAG, "Starting timer...") | 点击“开始”无反应 | 确认startMoleTimer()被调用,且handler.postDelayed()执行成功 |
| 随机数范围错误 | Log.d(TAG, "Random index: " + randomIndex) | 只有固定几个坑位出现地鼠 | 检查new Random().nextInt(9),确保是0-8,不是1-9 |
| UI线程阻塞 | 在moleSpawnRunnable.run()开头加Log.d(TAG, "Running on thread: " + Thread.currentThread().getName()) | 日志显示Thread-2(非main) | 确认handler是用Looper.getMainLooper()创建的,或直接用runOnUiThread() |
实操记录:我帮一位学员调试时,发现地鼠总在左上角坑位(mole_0)出现。日志显示
randomIndex始终是0。最后发现他误用了Math.random()*9,但没转成int,导致nextInt()接收了double参数——Random.nextInt(double)不存在,编译器自动装箱成Integer,结果永远是0。教训:永远用new Random().nextInt(n),别自己算随机数。
5.2 “点击无反应”问题深度分析
地鼠显示了,但点击没加分?这通常涉及三个层面:
第一层:点击事件未绑定
- 检查
onCreate()中是否执行了moleViews[i].setOnClickListener(...) - 检查
moleViews[i]是否为null(findViewById失败)
第二层:状态拦截
- 在
onMoleClick()开头加Log.d(TAG, "Click received, state: " + gameState) - 如果日志显示
state: IDLE,说明gameState未正确设为RUNNING
第三层:View层级遮挡
- 检查XML中是否有其他View(如
TextView、Button)覆盖在ImageView上 - 临时给
ImageView加背景色:android:background="#FF0000",看是否被遮挡
独家技巧:用Android Studio的Layout Inspector实时查看View层级。点击Tools → Layout Inspector → 选择正在运行的App进程 → 3D视图中拖拽旋转,一眼看出哪个View盖住了地鼠。
5.3 “Dialog点击后Activity崩溃”终极解决方案
这个问题根源在于Context泄漏和生命周期错配。标准解法如下:
绝对不用
getApplicationContext()创建Dialognew AlertDialog.Builder(getApplicationContext())会导致Dialog持有Application Context,无法访问Activity的Theme,且Activity销毁后Dialog仍存在。DialogFragment中,用
getParentFragmentManager()而非getFragmentManager()
AndroidX推荐用getParentFragmentManager(),它能更好处理嵌套Fragment的生命周期。在Activity的
onDestroy()中主动dismiss Dialog@Override protected void onDestroy() { super.onDestroy(); // 主动dismiss所有DialogFragment FragmentManager fm = getSupportFragmentManager(); DialogFragment dialog = (DialogFragment) fm.findFragmentByTag("game_over"); if (dialog != null && dialog.isAdded()) { dialog.dismiss(); } }最保险的写法:用
requireActivity()替代getActivity()
在DialogFragment的回调中:.setPositiveButton(..., (dialog, which) -> { try { MainActivity activity = (MainActivity) requireActivity(); activity.resetGame(); } catch (IllegalStateException e) { // Activity已销毁,忽略 } })
5.4 性能与内存优化:让小游戏丝滑运行
虽然打地鼠很简单,但它是练习性能优化的绝佳样本:
避免重复创建对象:
new Random()在moleSpawnRunnable中被频繁调用。改为成员变量:private Random random = new Random(); // 初始化一次 // 在run()中用 random.nextInt(9)减少findViewById调用:已在
onCreate()中缓存所有ImageView,但scoreText在onMoleClick()中又被findViewById?不,我们已缓存为成员变量。及时释放Handler:
onDestroy()中必须移除所有Callback:@Override protected void onDestroy() { super.onDestroy(); if (handler != null) { handler.removeCallbacksAndMessages(null); // 清空所有消息 } }图片资源优化:
mole_visible.png和mole_hidden.png用WebP格式(比PNG小30%),尺寸严格匹配ImageView的layout_width/height,避免运行时缩放。
经验之谈:我在某款教育App中,把所有Toast的
makeText()提取成静态方法,并用WeakReference<Context>缓存Context,结果OOM率下降40%。但对打地鼠这种小项目,过度优化反而是负担。记住:优化永远服务于问题,而不是技术本身。
6. 从Demo到产品的延伸思考:这四个组件如何支撑真实业务?
学到这里,你可能觉得“不过是个小游戏”。但请看这些真实场景:
Toast的变体应用:某金融App的“转账成功”提示,要求3秒后自动消失,且点击可查看详情。这正是Toast + 自定义View + OnClickListener的组合,本项目中
currentToast.cancel()的技巧直接复用。Dialog的业务升级:电商App的“确认收货”Dialog,需显示订单号、商品快照、物流信息。本项目的
GameOverDialogFragment只需替换setMessage()为自定义View(builder.setView(R.layout.dialog_confirm_receipt)),逻辑完全一致。Menu的权限联动:某OA系统中,“审批”菜单项需根据用户角色动态显示/隐藏。本项目的
onPrepareOptionsMenu()中item.setVisible(isApprover)就是标准解法。Spinner的异步加载:某天气App的“城市选择”Spinner,数据来自网络。本项目的
setOnItemSelectedListener不变,只需在回调中发起Retrofit请求,成功后更新scoreText(此处换成weatherTextView)。
最后分享一个小技巧:当你要在多个Activity中复用这套逻辑时,不要复制粘贴。把
GameState、startGame()、pauseGame()抽象成BaseGameActivity,让MoleGameActivity继承它。这就是从Demo走向工程化的第一步——代码复用,始于抽象,成于克制。