Skip to content

模块 09:调试、测试与问题排查

目标:掌握 Android Studio 的调试工具,学会阅读崩溃日志,能进行基本的单元测试。

前端对照:调试工具

前端Android说明
Chrome DevTools ConsoleLogcat日志输出
console.log()Log.d(TAG, msg)打印日志
debugger 语句断点(Breakpoint)暂停执行
Network 面板OkHttp Logging Interceptor网络请求调试
Application 面板Device File Explorer查看应用数据
Performance 面板Android Profiler性能分析
Jest / VitestJUnit单元测试框架
Playwright / CypressEspressoUI 测试框架

1. Logcat 日志系统

1.1 日志级别

java
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 约定

java
// 方式 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 日志性能与生产环境

java
// ❌ 避免:在循环中大量打印日志,或拼接大对象
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 崩溃排查步骤

  1. 看异常类型NullPointerExceptionIllegalStateException
  2. 看异常信息:错误描述,如 "on a null object reference"
  3. 看堆栈顶部:你的代码中最先出现的那一行(通常是 com.example.app.Xxx 开头的)
  4. 看行号:跳转到对应文件的对应行
  5. 向上追溯:找到调用链中导致问题的根源

2.3 ANR 与主线程阻塞排查

ANR(Application Not Responding)通常由主线程阻塞引起,典型场景:

java
// ❌ 错误:在主线程执行网络请求
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 阶段主动发现主线程违规:

java
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 断点调试

  1. 设置断点:在代码行号左侧单击,出现红色圆点
  2. 启动调试:点击 Debug 按钮(虫子图标)或 Shift+F9
  3. 调试操作
按钮快捷键说明
Step OverF8执行当前行,不进入方法
Step IntoF7进入方法内部
Step OutShift+F8跳出当前方法
ResumeF9继续执行到下一个断点
StopCtrl+F2停止调试

3.2 变量查看

  • Variables 面板:查看当前作用域的所有变量
  • Watches:添加自定义表达式监控
  • Evaluate Expression (Alt+F8):在断点处执行表达式

3.3 条件断点

右键断点 → Condition:

java
// 只在特定条件下暂停
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 调用示例中,回调里经常需要确认请求头和响应体。添加日志拦截器可快速定位网络问题:

groovy
// app/build.gradle
dependencies {
    implementation 'com.squareup.okhttp3:logging-interceptor:4.12.0'
}
java
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 内存泄漏检测

java
// 常见泄漏场景:
// 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 自动检测内存泄漏:

groovy
// app/build.gradle
dependencies {
    debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.13'
}

4.3 更多内存泄漏模式

java
// 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 基础

java
// 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(模拟依赖)

java
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 运行测试

bash
# 运行所有单元测试
./gradlew test

# 运行特定测试类
./gradlew test --tests "com.example.app.UserTest"

# 在 Android Studio 中:右键测试类 → Run 'UserTest'

5.4 Espresso UI 测试入门

java
// 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()));
    }
}
groovy
// 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 RClean 项目:Build → Clean Project
Program type already present依赖冲突,检查重复依赖
Method count exceeded 64K启用 multidex
AAPT: resource not found检查资源文件名和 ID

6.3 运行时错误

问题解决方案
ClassNotFoundExceptionProGuard 混淆了类,添加 keep 规则
NetworkOnMainThreadException网络请求必须在后台线程
PermissionDeniedException检查 Manifest 权限声明 + 运行时权限
IllegalStateException: Fragment not attachedFragment 生命周期问题

6.4 常用 adb 调试命令

bash
# 查看已连接设备
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. 运行时权限

java
// 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 ConsoleLogcat 面板日志查看器
debugger断点暂停执行
Elements 面板Layout InspectorUI 检查
Application 面板Device File Explorer文件浏览
Network 面板OkHttp LoggingInterceptor网络调试
npm test./gradlew test运行测试
Jest expect()JUnit assertEquals()断言
jest.fn() / sinon.spy()Mockito mock() / verify()模拟和验证
LighthouseAndroid Profiler性能分析
Error Boundarytry-catch + 崩溃上报错误处理

下一步

掌握了调试和测试技能后,最后学习 模块 10:老项目实战指南与 Kotlin 迁移,获取接手实际项目的完整指南。

面向前端开发者的 Java Android 开发教程