21、ContentProvider KTX:ContentResolver的KTX扩展,简化数据查询

说实话,ContentProvider 这套 API 在 Android 里算是比较老牌的组件了。我早年做项目时,每次写 ContentResolver 查询都觉得很繁琐——要手动管理 Cursor、要处理各种 Uri 拼接、还要小心别漏掉 close()。嗯,Kotlin 扩展函数出来之后,Google 官方终于给这套 API 做了个「现代化改造」。今天我们就聊聊 ContentProvider KTX 到底帮我们省了哪些事。

为什么需要 ContentProvider KTX?

先想想看,原生 ContentResolver 查询长什么样:

val cursor = contentResolver.query(
    ContactsContract.Contacts.CONTENT_URI,
    null,
    null,
    null,
    null
)
cursor?.use {
    while (it.moveToNext()) {
        val name = it.getString(it.getColumnIndex(ContactsContract.Contacts.DISPLAY_NAME))
        // 处理数据...
    }
}

这段代码其实已经用了 use 来管理 Cursor 生命周期,算是不错的写法了。但问题在于:每次查询都要手动处理 Cursor 遍历,代码重复率太高。我在项目中维护过十几个 ContentProvider 查询,每个都长差不多,改起来还容易漏掉字段索引。

KTX 扩展的核心思路就是:把 Cursor 的遍历和资源释放封装起来,让你只关心数据本身

核心扩展函数一览

ContentProvider KTX 主要提供了三个扩展函数,都在 androidx.core.content 包下。我列个表格方便你对照:

扩展函数 作用 返回值
ContentResolver.query() 执行查询,自动管理 Cursor Cursor?(但配合 use 使用)
Cursor.map() 将 Cursor 的每一行映射为对象 List<T>
Cursor.mapToOne() 将单行 Cursor 映射为对象 T?

说白了,mapmapToOne 才是真正的「语法糖」。它们帮你把 Cursor 的遍历、索引获取、类型转换全部封装了。

实战:用 KTX 简化联系人查询

我们拿最常见的联系人查询来举例。先看原生写法,再看 KTX 写法,对比一下差距。

原生写法(非 KTX)

data class Contact(val id: Long, val name: String, val phone: String?)

fun loadContacts(): List<Contact> {
    val uri = ContactsContract.Contacts.CONTENT_URI
    val projection = arrayOf(
        ContactsContract.Contacts._ID,
        ContactsContract.Contacts.DISPLAY_NAME
    )
    val cursor = contentResolver.query(uri, projection, null, null, null) ?: return emptyList()
    
    val contacts = mutableListOf<Contact>()
    cursor.use { c ->
        val idIndex = c.getColumnIndex(ContactsContract.Contacts._ID)
        val nameIndex = c.getColumnIndex(ContactsContract.Contacts.DISPLAY_NAME)
        while (c.moveToNext()) {
            val id = c.getLong(idIndex)
            val name = c.getString(nameIndex)
            contacts.add(Contact(id, name, null))
        }
    }
    return contacts
}

这段代码其实已经算「规范」了——用了 use,也提前取了列索引。但你看,模板代码还是占了大部分篇幅。真正业务相关的只有 Contact(id, name, null) 这一行。

KTX 写法

fun loadContactsKtx(): List<Contact> {
    val uri = ContactsContract.Contacts.CONTENT_URI
    val projection = arrayOf(
        ContactsContract.Contacts._ID,
        ContactsContract.Contacts.DISPLAY_NAME
    )
    return contentResolver.query(uri, projection, null, null, null)?.use { cursor ->
        cursor.map {
            Contact(
                id = it.getLong(ContactsContract.Contacts._ID),
                name = it.getString(ContactsContract.Contacts.DISPLAY_NAME),
                phone = null
            )
        }
    } ?: emptyList()
}

注意看,cursor.map { ... } 这个 lambda 里的 it 就是当前行的 Cursor 对象。你不需要手动 moveToNext(),也不需要担心索引越界——map 内部已经帮你处理好了。

我个人习惯把这种写法叫做「声明式查询」:你只管描述「每一行长什么样」,剩下的遍历和释放交给 KTX。

map 和 mapToOne 的区别

这两个函数很容易混淆。我直接说结论:

  • map:用于多行结果,返回 List<T>。如果 Cursor 为空,返回空列表。
  • mapToOne:用于单行结果,返回 T?。如果 Cursor 为空或没有数据,返回 null。

举个例子,查询某个特定联系人的信息:

fun loadContactById(contactId: Long): Contact? {
    val uri = ContentUris.withAppendedId(
        ContactsContract.Contacts.CONTENT_URI, contactId
    )
    val projection = arrayOf(
        ContactsContract.Contacts._ID,
        ContactsContract.Contacts.DISPLAY_NAME
    )
    return contentResolver.query(uri, projection, null, null, null)?.use { cursor ->
        cursor.mapToOne {
            Contact(
                id = it.getLong(ContactsContract.Contacts._ID),
                name = it.getString(ContactsContract.Contacts.DISPLAY_NAME),
                phone = null
            )
        }
    }
}

这里用 mapToOne 就很合适——因为你知道查询结果最多只有一行。如果用了 map,你还要手动取 firstOrNull(),多此一举。

注意: mapToOne 内部会检查 Cursor 的行数。如果 Cursor 有多行,它会取第一行并忽略其余行。这在逻辑上可能隐藏 bug——如果你预期只有一行但实际返回了多行,mapToOne 不会报错。我曾经在项目中遇到过类似问题,后来加了个日志检查才定位到。

SVG 流程图:ContentProvider KTX 查询流程

下面这张图展示了 KTX 扩展如何简化查询流程。你可以看到,开发者只需要关注「数据映射」这一步,其他都由扩展函数自动完成。

ContentProvider KTX 查询流程 调用 query() 自动管理 Cursor 生命周期 (use 自动 close) map / mapToOne (自动遍历每一行) 返回 List<T> 或 T? 开发者只需关注 数据映射逻辑 KTX 自动处理 Cursor 遍历 + 释放

避坑指南:我踩过的几个坑

KTX 扩展虽然好用,但也不是银弹。我分享几个实际项目中遇到的坑:

  • Cursor 列索引问题map 内部仍然使用 getColumnIndex() 来获取列索引。如果你在 projection 中漏掉了某个字段,运行时就会返回 -1,然后 getString(-1) 会抛异常。我曾经在重构时删了一个字段但忘了更新 projection,结果 crash 了。建议在 map lambda 里用 getColumnIndexOrThrow() 来提前暴露问题。
  • 大数据量查询map 会把所有数据加载到内存中。如果你查询几千条记录,每个记录又包含大字段(比如图片 blob),内存可能会爆。我建议大数据量场景还是用传统的 while (moveToNext()) 配合分页加载。
  • 线程安全:ContentResolver 查询默认在主线程执行。KTX 扩展没有改变这一点。如果你在 UI 线程做耗时查询,依然会 ANR。记得配合 withContext(Dispatchers.IO) 使用。
小技巧: 如果你经常查询同一个 ContentProvider,可以封装一个扩展函数,把 projection、selection 等参数默认化。比如:
fun ContentResolver.queryContacts() = query(ContactsContract.Contacts.CONTENT_URI, ...)
这样调用方只需要写 contentResolver.queryContacts()?.use { ... },更简洁。

总结

ContentProvider KTX 的核心价值就一句话:把 Cursor 的遍历和资源管理封装起来,让你只关心数据映射。它不是什么黑科技,但确实能减少大量模板代码。我个人在项目里全面替换了旧写法,代码量减少了大概 30%,而且可读性明显提升。

不过也要记住:map 适合中小量数据查询,大数据量还是得回归传统写法。工具是死的,人是活的——选对场景用对工具,才是工程师该有的态度。

公众号:蓝海资料掘金营,微信 deep3321