模块 09:调试、测试与问题排查
目标:掌握 Android Studio 的调试工具,学会阅读崩溃日志,能进行基本的单元测试。
前端对照:调试工具
| 前端 | Android | 说明 |
|---|---|---|
| Chrome DevTools Console | Logcat | 日志输出 |
console.log() | Log.d(TAG, msg) | 打印日志 |
debugger 语句 | 断点(Breakpoint) | 暂停执行 |
| Network 面板 | OkHttp Logging Interceptor | 网络请求调试 |
| Application 面板 | Device File Explorer | 查看应用数据 |
| Performance 面板 | Android Profiler | 性能分析 |
| Jest / Vitest | JUnit | 单元测试框架 |
| Playwright / Cypress | Espresso | UI 测试框架 |
1. Logcat 日志系统
1.1 日志级别
import android.util.Log;
// 日志级别(从低到高)
Log.v("TAG", "Verbose 日志"); // 最详细,通常关闭
Log.d("TAG", "Debug 日志"); // 调试信息
Log.i("TAG", "Info 日志"); // 一般信息
Log.w("TAG", "Warning 日志"); // 警告
Log.e("TAG", "Error 日志"); // 错误
Log.wtf("TAG", "WTF 日志"); // 严重错误(What a Terrible Failure)
// 带异常信息
try {
// ...
} catch (Exception e) {
Log.e("TAG", "操作失败", e); // 第三个参数传入异常,会打印堆栈
}1.2 Logcat 过滤
在 Android Studio 的 Logcat 面板中:
# 按级别过滤
level:ERROR # 只显示 Error 及以上
# 按 TAG 过滤
tag:MainActivity # 只显示特定 TAG
# 按关键词搜索
"network error" # 搜索关键词
# 按进程过滤
pid:12345 # 按进程 ID
# 常用组合
level:ERROR tag:Retrofit # 显示 Retrofit 的错误日志1.3 实用 TAG 约定
// 方式 1:类名作为 TAG(最常见)
private static final String TAG = "MainActivity";
// 方式 2:自动获取类名
private static final String TAG = MyActivity.class.getSimpleName();
// 方式 3:全局 TAG
public class AppConstants {
public static final String LOG_TAG = "MyApp";
}1.4 日志性能与生产环境
// ❌ 避免:在循环中大量打印日志,或拼接大对象
for (User user : users) {
Log.d(TAG, "user=" + user.toString()); // 即使 Release 也会执行拼接
}
// ✅ 推荐:使用 Log.isLoggable 避免 Release 下的字符串拼接
if (Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(TAG, "user=" + user.toString());
}
// ✅ 更推荐:使用 Timber 等日志框架,Release 时种植空实现树
dependencies {
implementation 'com.jakewharton.timber:timber:5.0.1'
}前端对照:类似前端在
console.log外包裹if (process.env.NODE_ENV === 'development'),避免生产环境泄漏调试信息或造成性能损耗。
2. 崩溃日志分析
2.1 常见崩溃类型
# NullPointerException(空指针,最常见)
java.lang.NullPointerException: Attempt to invoke virtual method
'void android.widget.TextView.setText(java.lang.CharSequence)'
on a null object reference
at com.example.app.MainActivity.onCreate(MainActivity.java:42)
# 分析:
# - 原因:TextView 为 null,可能忘记 findViewById 或 ID 写错
# - 位置:MainActivity.java 第 42 行
# IllegalStateException(状态异常)
java.lang.IllegalStateException: Fragment not attached to a context.
at androidx.fragment.app.Fragment.requireContext(Fragment.java:909)
# 分析:
# - 原因:Fragment 已经 detach 但还在访问 Context
# - 常见于异步回调中 Fragment 已销毁
# OutOfMemoryError(内存溢出)
java.lang.OutOfMemoryError: Failed to allocate a 52428812 byte allocation
# 分析:
# - 原因:加载了过大的图片或创建了过多对象
# - 解决:使用 Glide 加载图片、减少内存占用
# ANR(Application Not Responding)
"main" thread blocked for 6000ms
# 分析:
# - 原因:主线程执行了耗时操作(网络、数据库、文件)
# - 解决:将耗时操作移到后台线程2.2 崩溃排查步骤
- 看异常类型:
NullPointerException、IllegalStateException等 - 看异常信息:错误描述,如 "on a null object reference"
- 看堆栈顶部:你的代码中最先出现的那一行(通常是
com.example.app.Xxx开头的) - 看行号:跳转到对应文件的对应行
- 向上追溯:找到调用链中导致问题的根源
2.3 ANR 与主线程阻塞排查
ANR(Application Not Responding)通常由主线程阻塞引起,典型场景:
// ❌ 错误:在主线程执行网络请求
public void onClick(View v) {
URL url = new URL("https://api.example.com/data");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.getInputStream(); // NetworkOnMainThreadException 或 ANR
}使用 StrictMode 在 Debug 阶段主动发现主线程违规:
public class MyApplication extends Application {
@Override
public void onCreate() {
super.onCreate();
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder()
.detectDiskReads()
.detectDiskWrites()
.detectNetwork()
.penaltyLog()
.penaltyDialog()
.build());
}
}
}前端对照:StrictMode 类似 Chrome 的 Long Tasks API 或 React 的 Profiler,用于定位阻塞主线程/渲染线程的代码。
3. Android Studio 调试器
3.1 断点调试
- 设置断点:在代码行号左侧单击,出现红色圆点
- 启动调试:点击 Debug 按钮(虫子图标)或
Shift+F9 - 调试操作:
| 按钮 | 快捷键 | 说明 |
|---|---|---|
| Step Over | F8 | 执行当前行,不进入方法 |
| Step Into | F7 | 进入方法内部 |
| Step Out | Shift+F8 | 跳出当前方法 |
| Resume | F9 | 继续执行到下一个断点 |
| Stop | Ctrl+F2 | 停止调试 |
3.2 变量查看
- Variables 面板:查看当前作用域的所有变量
- Watches:添加自定义表达式监控
- Evaluate Expression (
Alt+F8):在断点处执行表达式
3.3 条件断点
右键断点 → Condition:
// 只在特定条件下暂停
position == 5
user.getName().equals("张三")3.4 布局检查器(Layout Inspector)
Tools → Layout Inspector:类似 Chrome DevTools 的 Elements 面板
- 查看当前界面的视图层级
- 检查每个 View 的属性(尺寸、边距、颜色等)
- 3D 视图旋转查看层级关系
3.5 Database Inspector
View → Tool Windows → App Inspection → Database:
- 实时查看 Room/SQLite 数据库内容
- 执行 SQL 查询
- 观察 LiveData 查询结果的变化
3.6 网络请求调试(OkHttp Logging Interceptor)
来自 android3 的 OkHttp 调用示例中,回调里经常需要确认请求头和响应体。添加日志拦截器可快速定位网络问题:
// app/build.gradle
dependencies {
implementation 'com.squareup.okhttp3:logging-interceptor:4.12.0'
}HttpLoggingInterceptor logging = new HttpLoggingInterceptor();
logging.setLevel(HttpLoggingInterceptor.Level.BODY);
OkHttpClient client = new OkHttpClient.Builder()
.addInterceptor(logging)
.build();| Level | 输出内容 |
|---|---|
NONE | 无日志 |
BASIC | 请求方法、URL、状态码、响应大小 |
HEADERS | 加上请求/响应头 |
BODY | 加上请求/响应体(最详细,适合 Debug) |
前端对照:相当于 Chrome DevTools Network 面板中的请求详情,或在 Axios 中配置
interceptors.request/response打印日志。
4. 性能分析工具
4.1 Android Profiler
View → Tool Windows → Profiler:
| 面板 | 用途 | 前端对应 |
|---|---|---|
| CPU | 方法执行时间、线程状态 | Performance 面板 |
| Memory | 内存分配、GC、内存泄漏 | Memory 面板 |
| Network | 网络请求时间线 | Network 面板 |
| Energy | 电量消耗分析 | 无对应 |
4.2 内存泄漏检测
// 常见泄漏场景:
// 1. 静态引用 Activity
public class BadUtils {
static Activity activity; // ❌ 永远不要这样做
}
// 2. 匿名内部类持有外部引用
handler.postDelayed(new Runnable() { // 匿名类持有 Activity 引用
@Override
public void run() {
textView.setText("delayed"); // 如果 Activity 已销毁,泄漏
}
}, 10000);
// 3. 未注销监听器
@Override
protected void onCreate(Bundle savedInstanceState) {
someService.addListener(this); // 注册
}
// 忘记在 onDestroy 中注销 → 泄漏
@Override
protected void onDestroy() {
super.onDestroy();
someService.removeListener(this); // 注销
}使用 LeakCanary 自动检测内存泄漏:
// app/build.gradle
dependencies {
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.13'
}4.3 更多内存泄漏模式
// 4. Handler 持有 Activity 引用
public class MainActivity extends AppCompatActivity {
private Handler handler = new Handler(Looper.getMainLooper()) {
@Override
public void handleMessage(Message msg) {
// 匿名内部类隐式持有外部 MainActivity
}
};
}
// 修复:使用静态内部类 + WeakReference,或在 onDestroy 中移除消息
// 5. AsyncTask 持有 Activity
new AsyncTask<Void, Void, String>() {
@Override protected String doInBackground(Void... voids) { /* 耗时 */ return ""; }
@Override protected void onPostExecute(String s) {
textView.setText(s); // Activity 销毁后崩溃或泄漏
}
}.execute();
// 修复:使用静态内部类 / ViewModel + Executor / Kotlin Coroutines
// 6. RxJava 订阅未注销
disposable = observable.subscribe();
// 修复:在 onDestroy 中调用 disposable.dispose()前端对照:与前端闭包捕获 DOM/组件引用导致无法回收类似,Android 中要特别注意匿名内部类、Handler、异步任务对 Context 的隐式持有。
5. 单元测试
5.1 JUnit 基础
// src/test/java/com/example/app/UserTest.java
import org.junit.Test;
import org.junit.Before;
import static org.junit.Assert.*;
public class UserTest {
private User user;
@Before
public void setUp() {
user = new User("张三", "zhangsan@example.com");
}
@Test
public void testGetName() {
assertEquals("张三", user.getName());
}
@Test
public void testEmail() {
assertNotNull(user.getEmail());
assertTrue(user.getEmail().contains("@"));
}
@Test
public void testValidation() {
assertThrows(IllegalArgumentException.class, () -> {
user.setAge(-1);
});
}
}5.2 Mockito(模拟依赖)
import static org.mockito.Mockito.*;
public class UserListPresenterTest {
@Mock
private UserListContract.View mockView;
@Mock
private UserRepository mockRepository;
private UserListPresenter presenter;
@Before
public void setUp() {
MockitoAnnotations.openMocks(this);
presenter = new UserListPresenter(mockRepository);
presenter.attachView(mockView);
}
@Test
public void testLoadUsers_success() {
List<User> expectedUsers = Arrays.asList(
new User("张三", "zhang@example.com"),
new User("李四", "li@example.com")
);
// 模拟 repository 的行为
doAnswer(invocation -> {
DataCallback<List<User>> callback = invocation.getArgument(0);
callback.onSuccess(expectedUsers);
return null;
}).when(mockRepository).getUsers(any());
// 执行
presenter.loadUsers();
// 验证
verify(mockView).showLoading();
verify(mockView).hideLoading();
verify(mockView).showUsers(expectedUsers);
verify(mockView, never()).showError(anyString());
}
@Test
public void testLoadUsers_error() {
doAnswer(invocation -> {
DataCallback<List<User>> callback = invocation.getArgument(0);
callback.onError(new RuntimeException("网络错误"));
return null;
}).when(mockRepository).getUsers(any());
presenter.loadUsers();
verify(mockView).showError("网络错误");
verify(mockView, never()).showUsers(any());
}
@After
public void tearDown() {
presenter.detachView();
}
}5.3 运行测试
# 运行所有单元测试
./gradlew test
# 运行特定测试类
./gradlew test --tests "com.example.app.UserTest"
# 在 Android Studio 中:右键测试类 → Run 'UserTest'5.4 Espresso UI 测试入门
// src/androidTest/java/com/example/app/LoginActivityTest.java
@RunWith(AndroidJUnit4.class)
public class LoginActivityTest {
@Rule
public ActivityScenarioRule<LoginActivity> activityRule =
new ActivityScenarioRule<>(LoginActivity.class);
@Test
public void login_showsHomeScreen() {
onView(withId(R.id.et_username)).perform(typeText("admin"));
onView(withId(R.id.et_password)).perform(typeText("123456"));
onView(withId(R.id.btn_login)).perform(click());
onView(withText("Welcome")).check(matches(isDisplayed()));
}
}// app/build.gradle
dependencies {
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
}前端对照:Espresso 类似 Playwright/Cypress,通过查找视图并模拟点击/输入进行端到端 UI 测试。
6. 常见问题排查清单
6.1 Gradle Sync 失败
| 问题 | 解决方案 |
|---|---|
SDK location not found | 检查 local.properties 中的 sdk.dir |
Could not find com.android.tools.build:gradle | 检查 AGP 版本和网络 |
Unsupported class file major version | 升级/降级 JDK 版本 |
Dependency resolution failed | 检查仓库镜像配置 |
6.2 编译错误
| 问题 | 解决方案 |
|---|---|
cannot find symbol class R | Clean 项目:Build → Clean Project |
Program type already present | 依赖冲突,检查重复依赖 |
Method count exceeded 64K | 启用 multidex |
AAPT: resource not found | 检查资源文件名和 ID |
6.3 运行时错误
| 问题 | 解决方案 |
|---|---|
ClassNotFoundException | ProGuard 混淆了类,添加 keep 规则 |
NetworkOnMainThreadException | 网络请求必须在后台线程 |
PermissionDeniedException | 检查 Manifest 权限声明 + 运行时权限 |
IllegalStateException: Fragment not attached | Fragment 生命周期问题 |
6.4 常用 adb 调试命令
# 查看已连接设备
adb devices
# 安装/卸载 APK
adb install app-debug.apk
adb uninstall com.example.app
# 查看当前 Activity(用于确认打开的页面)
adb shell dumpsys activity activities | grep mResumedActivity
# 查看应用日志(生产环境常见用法)
adb logcat -s MyApp:D
# 截取屏幕/录制视频
adb shell screencap /sdcard/screen.png
adb pull /sdcard/screen.png ./screen.png
adb shell screenrecord /sdcard/demo.mp4
# 重启 adb 服务(解决连接异常)
adb kill-server && adb start-server前端对照:adb 类似 Node 生态中的
npx和浏览器 DevTools 命令行工具的组合,是与运行中设备交互的瑞士军刀。
7. 运行时权限
// Android 6.0+ 危险权限需要运行时请求
private static final int CAMERA_PERMISSION_CODE = 100;
// 检查权限
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
!= PackageManager.PERMISSION_GRANTED) {
// 请求权限
ActivityCompat.requestPermissions(this,
new String[]{Manifest.permission.CAMERA},
CAMERA_PERMISSION_CODE);
} else {
// 已有权限,直接操作
openCamera();
}
// 处理权限结果
@Override
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requestCode == CAMERA_PERMISSION_CODE) {
if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
openCamera();
} else {
Toast.makeText(this, "需要相机权限才能使用此功能", Toast.LENGTH_SHORT).show();
}
}
}实战提示:
android3中的chapter06/src/main/java/com/example/chapter06/util/PermissionUtil.java将权限检查、请求和结果校验封装为工具类,老项目接管时可借鉴这种统一封装,避免每个 Activity 重复样板代码。
前端开发者备忘
| 前端调试概念 | Android 对应 | 备注 |
|---|---|---|
console.log() | Log.d(TAG, msg) | 日志输出 |
| Chrome Console | Logcat 面板 | 日志查看器 |
debugger | 断点 | 暂停执行 |
| Elements 面板 | Layout Inspector | UI 检查 |
| Application 面板 | Device File Explorer | 文件浏览 |
| Network 面板 | OkHttp LoggingInterceptor | 网络调试 |
npm test | ./gradlew test | 运行测试 |
Jest expect() | JUnit assertEquals() | 断言 |
jest.fn() / sinon.spy() | Mockito mock() / verify() | 模拟和验证 |
| Lighthouse | Android Profiler | 性能分析 |
| Error Boundary | try-catch + 崩溃上报 | 错误处理 |
下一步
掌握了调试和测试技能后,最后学习 模块 10:老项目实战指南与 Kotlin 迁移,获取接手实际项目的完整指南。