29、Content Provider 常见问题与调试:ANR 问题、死锁、日志分析、StrictMode

Content Provider 这东西,平时用着挺顺手。但一旦出问题,往往就是大问题——ANR、死锁、数据错乱。我这些年排查过的 Provider 故障,少说也有几十起了。今天就把这些坑和经验,一次性说清楚。

核心要点:Content Provider 的稳定性,本质上取决于你对跨进程通信和线程模型的理解。不懂这些,调试就是瞎蒙。

29.1 ANR 问题:Provider 的“死罪”

ANR 是 Content Provider 最常见的死法。为什么?因为 Provider 的操作默认跑在 UI 线程上。你想想看,一个数据库查询如果耗时超过 5 秒,系统直接给你弹 ANR 对话框。

我个人习惯,在写 Provider 时第一件事就是确认:这个操作会不会阻塞主线程?

29.1.1 典型 ANR 场景

  • 大表查询:没有加索引,全表扫描几万条数据
  • 跨进程大数据传输:Cursor 返回太多列,Binder 缓冲区撑爆
  • 同步操作网络请求:在 query/insert 里直接调网络 API
  • 死锁等待:两个线程互相等锁,谁也动不了

我曾经接手过一个项目,用户反馈“打开联系人列表就卡死”。查了半天,发现 Provider 的 query 方法里,居然用了一个同步的 HTTP 请求去拉头像。嗯,这设计,只能说勇气可嘉。

29.1.2 如何定位 ANR

ANR 发生时,系统会在 /data/anr/traces.txt 里留下痕迹。这是第一手证据。

// 查看 ANR 日志
adb pull /data/anr/traces.txt .

// 搜索你的进程名
grep "com.your.app" traces.txt

// 重点关注 "main" 线程的堆栈
"main" prio=5 tid=1 Blocked
  at com.your.app.provider.MyProvider.query(MyProvider.java:45)
  - waiting to lock <0x0c4f1e20> (a android.database.sqlite.SQLiteDatabase)
  at android.content.ContentProvider$Transport.query(ContentProvider.java:240)
  ...

看到 Blocked 或者 waiting to lock 了吗?这就是死锁或者长时间等待的迹象。

我的调试技巧:不要等到 ANR 发生才去看 traces.txt。我习惯在开发阶段就定期检查这个文件,特别是做压力测试的时候。提前发现,比事后补救强一百倍。

29.2 死锁:两个线程的“互相伤害”

死锁在 Content Provider 里很隐蔽。为什么?因为 Provider 的 CRUD 方法可能被多个线程同时调用,而 SQLite 本身又有锁机制。

说白了,就是线程 A 拿着锁 1 等锁 2,线程 B 拿着锁 2 等锁 1。两个人都等着对方放手,结果谁也动不了。

29.2.1 常见死锁模式

场景 说明
嵌套事务 在事务 A 中开启事务 B,且 B 等待 A 释放
读写冲突 读线程持有共享锁,写线程等待独占锁;写线程又阻塞了后续读线程
跨 Provider 调用 Provider A 调用 Provider B,B 又回调 A

我记得有一次,团队里一个同事写了个功能:在 ContentProvider.insert() 里调用了另一个 ContentResolver.query()。结果那个 query 又触发了同一个 Provider 的 insert。好家伙,直接死锁,整个应用卡死。这就是典型的“自己调用自己”。

29.2.2 死锁的预防

  • 统一锁顺序:所有线程按相同顺序获取锁
  • 避免嵌套事务:能用单事务就别用嵌套
  • 设置超时:SQLite 支持设置 busy timeout
  • 使用 WAL 模式:Write-Ahead Logging 能大幅减少读写冲突
// 设置 SQLite 忙等待超时
SQLiteDatabase db = mDbHelper.getWritableDatabase();
db.execSQL("PRAGMA busy_timeout = 5000");  // 5秒超时

// 启用 WAL 模式
db.execSQL("PRAGMA journal_mode=WAL");

注意:WAL 模式虽然好,但也不是银弹。它在高并发写入时,checkpoint 操作可能会引起短暂的卡顿。我建议在正式环境前,先做一轮压力测试。

29.3 日志分析:从日志里“破案”

日志是调试 Content Provider 的利器。但前提是——你得知道看什么。

29.3.1 关键日志标签

  • ActivityManager:ANR、进程死亡、Provider 注册信息
  • SQLiteLog:数据库锁、事务、错误信息
  • CursorWindow:Cursor 分配、泄漏、大小超限
  • ContentProvider:Provider 的 CRUD 调用记录
// 过滤 Provider 相关日志
adb logcat -s ActivityManager SQLiteLog CursorWindow ContentProvider

// 更精细的过滤,只看你的包名
adb logcat | grep "com.your.app"

我一般会写一个日志辅助类,在 Provider 的每个方法入口和出口都打日志。这样一旦出问题,我能立刻知道是哪个操作卡住了。

public class LoggingProvider extends ContentProvider {
    private static final String TAG = "MyProvider";
    
    @Override
    public Cursor query(Uri uri, String[] projection, String selection,
                        String[] selectionArgs, String sortOrder) {
        Log.d(TAG, "query start: " + uri);
        long start = System.currentTimeMillis();
        try {
            Cursor cursor = super.query(uri, projection, selection, selectionArgs, sortOrder);
            Log.d(TAG, "query end: " + (System.currentTimeMillis() - start) + "ms");
            return cursor;
        } catch (Exception e) {
            Log.e(TAG, "query failed: " + uri, e);
            throw e;
        }
    }
}

29.3.2 日志分析实战

假设你看到这样的日志:

SQLiteLog: (284) automatic index on table 'users' for 'SELECT ...'
CursorWindow: Window is full. Requested row 100, last row 99

第一行告诉你:查询没有走索引,SQLite 自动建了个临时索引。这说明你的表缺索引,查询效率堪忧。

第二行告诉你:CursorWindow 满了。默认的 CursorWindow 大小是 2MB,如果你一次查询返回太多数据,就会触发这个警告。解决方案?分页查询,别一次拉太多。

我的习惯:每次发布前,我都会跑一遍全量功能,然后 grep 一下日志里有没有 "automatic index" 和 "Window is full"。这两个警告,基本就是性能问题的前兆。

29.4 StrictMode:你的“代码警察”

StrictMode 是 Android 提供的一个开发工具。它能帮你抓到那些“不该在主线程做的事”。说白了,就是你的代码警察。

29.4.1 如何开启 StrictMode

// 在 Application 的 onCreate 中开启
public class MyApp extends Application {
    @Override
    public void onCreate() {
        super.onCreate();
        
        StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder()
                .detectDiskReads()      // 检测主线程读磁盘
                .detectDiskWrites()     // 检测主线程写磁盘
                .detectNetwork()        // 检测主线程网络请求
                .penaltyLog()           // 违规时打印日志
                .penaltyDeath()         // 违规时直接崩溃(开发阶段用)
                .build());
        
        StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder()
                .detectLeakedSqlLiteObjects()   // 检测 SQLite 对象泄漏
                .detectLeakedClosableObjects()  // 检测 Closeable 对象泄漏
                .detectActivityLeaks()          // 检测 Activity 泄漏
                .penaltyLog()
                .build());
    }
}

我个人建议,在开发阶段把 penaltyDeath() 打开。虽然会频繁崩溃,但能逼着你把问题修掉。等上线前再关掉,只保留 penaltyLog()

29.4.2 StrictMode 在 Provider 中的应用

Content Provider 的 CRUD 方法默认跑在调用者的线程上。如果调用者是 UI 线程,那你的 Provider 操作就相当于在主线程执行。

开启 StrictMode 后,一旦你在 Provider 里做了磁盘 I/O 或者网络请求,日志里会立刻出现:

StrictMode: StrictMode policy violation; ~duration=320 ms: 
android.os.StrictMode$StrictModeDiskReadViolation: 
policy=31 violation=2
    at com.your.app.provider.MyProvider.query(MyProvider.java:42)
    ...

看到这个,你就知道:哦,这里不该在主线程跑。

注意:StrictMode 只在 debug 版本生效。release 版本会自动忽略这些策略。所以放心大胆地在开发阶段用,不会影响线上用户。

29.5 知识体系总览

下面这张图,是我对 Content Provider 调试体系的总结。你可以把它当作一个检查清单,遇到问题时对照着排查。

Content Provider 调试体系 Provider 问题 ANR 问题 死锁 日志分析 StrictMode 检测 traces.txt 主线程阻塞 嵌套事务 / 读写冲突 WAL / busy_timeout SQLiteLog / CursorWindow 自动索引 / 窗口满 ThreadPolicy VmPolicy 预防 > 发现 > 修复:把问题扼杀在开发阶段

这张图把 Content Provider 的调试分成了四个维度。ANR 和死锁是“症状”,日志分析和 StrictMode 是“诊断工具”。你想想看,如果能在开发阶段就用 StrictMode 抓到问题,何必等到线上用户反馈 ANR 呢?

好了,关于 Content Provider 的调试,我就讲这么多。记住一句话:好的调试习惯,比任何高级技巧都重要。每次写完 Provider 代码,跑一遍 StrictMode,看一眼日志,花不了几分钟,但能省下你后面几天的排查时间。