第17周:ContentProvider 全功能 + 跨进程优化

0 阅读17分钟

Provider 的两道门禁,方向是反着的。查官方文档时我在两句原文上停了很久:一句是"默认情况下,所有应用都可以对提供程序执行读取或写入操作",另一句是 android:exported 在 targetSdkVersion 17 及以上时默认值为 false。前一句听着门户大开,后一句直接把门焊死。拼起来才是完整语义:exported 决定别的应用能不能找到你的门,权限决定进门之后能摸哪些东西。这套组合里任何一环对不上,外部调用方拿到的就是 SecurityException,没有弹窗,也没有日志告诉你到底哪一环配错了。

这个设计张力就是 Provider 的全部主题:它是四大组件里唯一一个为"把数据交给别人"而生的组件,每个配置都绕着"数据怎么安全地交给别人"展开。这一周从零写了一个 Provider:六个方法、两类 URI、两套 MIME、CRUD 全套、调用系统 Provider、ContentObserver 通知,把它丢进独立进程,并为索引和 projection 各设计了一组对照实验。标题里的"优化"指的是三件事:查询慢(索引)、跨进程卡(传输量与线程)、数据不安全(权限)。


ContentProvider 到底是什么

ContentProvider 是 Android 提供的跨进程数据访问标准接口。它把你的数据(SQLite、文件、内存、网络数据都行)封装起来,对外暴露一套统一的增删改查入口,同时定义谁能读、谁能写。官方文档对它的定位描述很直接:把一个进程中的数据与另一个进程中运行的代码连接起来。

先说清楚它不是什么。Provider 不是数据库。它只是访问数据的抽象层,底层用什么存储由你自己定。官方明确提到这种抽象的价值:你可以把 SQLite 换成别的存储方式,只有 Provider 内部受影响,调用方(用 ContentResolver 的那些代码)一行都不用改。第17周 Demo 里的 Week17NoteDbHelper 用的是原生 SQLiteOpenHelper,正是为了体现这一点:以后换成 Room,改的是这个类和 Provider 内部,Activity 里的调用代码不动。

什么时候才需要自己写 Provider?官方的清单是这几条:为其他应用提供复杂数据或文件、让用户能把复杂数据从你的应用复制到其他应用、用搜索框架提供自定义搜索建议、向微件公开应用数据、要实现 AbstractThreadedSyncAdapter / CursorAdapter / CursorLoader。官方对不需要的情形也说得很直接:数据完全在自己应用里用、又不需要上面任何一项功能时,用数据库或其他存储就行,不必提供 Provider。

官方列的这几条场景,去掉"依赖三个类"那条技术性的,业务上归结起来就是跨应用共享和 widget 两大类。为了拿抽象层而写它也不是不行,但要清楚代价:多一层 Binder 调用就多一次序列化和跨进程开销。

系统本身也提供了一批 Provider,android.provider 包里能查到:媒体库(MediaStore)、联系人、日历、用户词典、设置等。Demo 区域③ 调用的就是 MediaStore。


系统的决策规则:URI 怎么匹配,权限怎么校验

机制型的东西先讲规则。Provider 的分发决策可以压缩成三条。

规则一:URI 决定你要哪份数据,UriMatcher 负责翻译。一个完整的 content URI 长这样:

content://com.study.all.provider.week17/notes/15
└──scheme┘└──────────authority──────────┘└path┘└id

authority 是 Provider 的全局唯一标识(惯例用包名加点后缀),path 指明数据集合,id 指明集合里的某一条。UriMatcher 干的事就是把进来的 URI 翻译成一个整数,让你在方法里分派:

private val uriMatcher = UriMatcher(UriMatcher.NO_MATCH).apply {
    addURI(AUTHORITY, "notes", MATCH_NOTES)      // 集合:/notes
    addURI(AUTHORITY, "notes/#", MATCH_NOTE_ID)  // 单条:/notes/15
}

# 匹配数字,* 匹配任意长度的有效字符(这两个通配符的语义是官方明确定义的)。少了这一步分派,你就得自己解析 URI 字符串,一堆 startsWith 判断,改起来要命。

authority 的命名官方给了规则:为避免与其他 Provider 冲突,以反向域名为基础,包名加 .provider 后缀。官方示例是包名为 com.example.<appname> 时用 com.example.<appname>.provider。Demo 里的 com.study.all.provider.week17 就是照这个来的。authority 在 Manifest 里对应 android:authorities,是系统用来识别整个 Provider 的符号名,定下来之后不要改。

规则二:MIME 类型跟着 URI 形态走,只有两种。官方规定的格式分两段,type 固定为 vndsubtype 按 URI 形态二选一:

  • 多行(集合 URI):vnd.android.cursor.dir/
  • 单行(单条 URI):vnd.android.cursor.item/

后面再接 Provider 自己的部分 vnd.<name>.<type>。官方建议 <name> 用公司名或应用包名的一部分保证全局唯一,<type> 用来区分 URI 模式。官方给的完整示例长这样:

vnd.android.cursor.dir/vnd.com.example.provider.table1   // 多行
vnd.android.cursor.item/vnd.com.example.provider.table1   // 单行

还有个同名方法容易混:Cursor.getType() 返回的是列的数据类型(整型、浮点、字符串、BLOB 这些),跟 MIME 一点关系都没有。官方在两处分别讲过这两个 getType,看的时候别串台。

override fun getType(uri: Uri): String = when (uriMatcher.match(uri)) {
    MATCH_NOTES -> "vnd.android.cursor.dir/vnd.com.study.all.note"
    MATCH_NOTE_ID -> "vnd.android.cursor.item/vnd.com.study.all.note"
    else -> throw IllegalArgumentException("无法识别的 URI: $uri")
}

规则三:权限在 Manifest 里定,不是 Provider 代码里定。这里有一条官方原文必须先记住:默认情况下,所有应用都可以对你的 Provider 执行读取或写入操作,即使底层数据是私有的,因为默认情况下你的 Provider 未设置权限。也就是说不配权限等于全开放,不是默认拒绝。

可配的权限属性有四个层次,优先级从低到高:

属性作用优先级
android:permission单一权限同时控制读写最低
android:readPermission / android:writePermission读写分开,官方明确它们优先于 android:permission
<path-permission> 子元素针对特定内容 URI 路径授权,官方明确路径级权限优先级高于提供程序级
android:grantUriPermissions + FLAG_GRANT_READ_URI_PERMISSION临时授权(配合 Intent 使用)按需

Demo 用的是第二层(读写分开的 signature 级自定义权限):

<permission
    android:name="com.study.all.permission.READ_NOTES"
    android:protectionLevel="signature" />
​
<provider
    android:name=".Week17NoteProvider"
    android:authorities="com.study.all.provider.week17"
    android:exported="true"
    android:process=":remote"
    android:readPermission="com.study.all.permission.READ_NOTES"
    android:writePermission="com.study.all.permission.WRITE_NOTES" />

protectionLevel="signature" 的意思是只有用同一份签名证书打包的应用才能自动拿到这个权限。所以它既能 exported="true" 对外暴露,又只有自家应用能进。少了权限声明只写 exported="true",等于把数据库门户大开,任何拿到 URI 的应用都能读,也就是官方说的那个默认行为。

android:exported 官方归在"启动和控制属性"里,定义是"可让其他应用使用此提供程序的标志"。它的默认值规则值得单独记:这个属性 API 17 才引入,搭载 API 16 及以下的设备把它当 true 处理;而应用的 targetSdkVersion 到 17 及以上时,默认值是 false。方向上是安全的(不写就不对外开放),但反过来意味着想让外部应用进来,必须显式写 true。顺带澄清一个容易混的规则:Android 12 起"必须显式声明 exported"的强制要求针对的是带 intent-filter 的 activity、service 和 receiver,Provider 走 authorities 不走 intent-filter,不在那条规则里,它有自己的默认值体系。

还有一条和 Demo 直接相关的官方说明:无论指定权限如何,Provider 所在应用的组件始终拥有完整的读取和写入访问权限。我这个 Demo 的 Provider 虽然跑在 :remote 进程,但它和主进程属于同一个应用(同一个 UID),所以主进程调用它不会被自己声明的 signature 权限挡住。权限是挡外部应用的,不挡自己人。

最后补一句官方提示:如果以 Android 11 或更高版本为目标平台,访问其他应用暴露的 Provider 还要看包可见性的配置要求。本周 Demo 只访问自己的 Provider,不涉及这一层。

顺带说 android:process=":remote"。声明之后 Provider 跑在独立进程里,主进程每次调用都是一次真实的跨进程 Binder 调用。Demo 区域① 会把调用方进程名和 Provider 进程名都显示出来,两个名字不一样。


从零写一个 Provider:六个方法与两类 URI

Provider 要覆写的方法一共六个,分成三组。

class Week17NoteProvider : ContentProvider() {
​
    override fun onCreate(): Boolean {
        dbHelper = Week17NoteDbHelper(requireNotNull(context))
        return true   // 返回 false 表示初始化失败,Provider 不会被装载
    }
​
    override fun query(
        uri: Uri,
        projection: Array<out String>?,   // 要哪些列
        selection: String?,               // WHERE 条件,用 ? 占位
        selectionArgs: Array<out String>?,
        sortOrder: String?
    ): Cursor { ... }
​
    override fun insert(uri: Uri, values: ContentValues?): Uri? { ... }
    override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int { ... }
    override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int { ... }
    override fun getType(uri: Uri): String { ... }
}

四个地方值得单独说。

onCreate 跑在 Provider 所在进程的主线程,而且它比 Application.onCreate 还早,是系统在 Provider 创建后、ContentResolver 尝试访问它时调用的。官方的原文要求很硬:避免在 onCreate() 中执行冗长的操作,将初始化任务推迟到实际需要时执行。理由也写明了:在 onCreate() 里做长任务会减慢 Provider 的启动,进而减慢它对其他应用请求的响应速度。所以这里只做最轻的初始化,真正开库连接留到第一次 query 时。

顺带一条容易忽略的官方要求:onCreate() 外,其余五个方法都可以被多个线程同时调用,所以它们必须是线程安全的。Provider 可能被多个进程、多个线程并发访问,方法里放共享的可变状态而不加锁,就是给自己埋偶发的数据错乱。Demo 里靠 SQLiteOpenHelper 自己保证连接层的线程安全,方法内没有额外共享状态。

query 的返回值官方也有约定:必须返回 Cursor 对象,失败时抛 Exception;查询不匹配任何行时返回 getCount() 为 0 的实例;只有在查询过程中发生内部错误时才返回 null。这个约定直接影响调用方的写法,你得处理"空 Cursor"这个正常分支,别把它当异常。

query 里有一行容易被漏掉:

cursor.setNotificationUri(context?.contentResolver, uri)

它是把 Cursor 和 URI 绑定起来。少了这行,后面 notifyChange 的时候,持有这个 Cursor 的界面收不到自动刷新通知(CursorLoader 就靠这个工作)。

insert 的返回值是带 id 的单条 URI,不是影响行数:

val id = db.insert(Week17NoteDbHelper.TABLE_NOTES, null, values ?: ContentValues())
if (id <= 0) return null
val insertedUri = ContentUris.withAppendedId(CONTENT_URI, id)
context?.contentResolver?.notifyChange(insertedUri, null)
return insertedUri

ContentUris.withAppendedId 把 id 拼到集合 URI 后面。调用方拿到返回的 URI,可以立刻用 ContentUris.parseId(uri) 取出刚插入那条的自增 id,不用再查一遍。notifyChange 那行如果删掉,区域④ 注册的观察者就永远不会有回调。


CRUD 全套调用:ContentResolver 的四个参数

调用方不直接碰 Provider,一律走 ContentResolver。查询的四个参数语义要分清:

val cursor = contentResolver.query(
    Week17NoteProvider.CONTENT_URI,                 // uri:查哪份数据
    null,                                           // projection:要哪些列,null = 全要
    null,                                           // selection:WHERE 条件
    null,                                           // selectionArgs:条件里的参数值
    "${Week17NoteDbHelper.COL_ID} DESC"             // sortOrder:排序
)
cursor?.use {                                       // use 保证 Cursor 一定被关闭
    while (it.moveToNext()) {
        val id = it.getLong(it.getColumnIndexOrThrow(Week17NoteDbHelper.COL_ID))
        val title = it.getString(it.getColumnIndexOrThrow(Week17NoteDbHelper.COL_TITLE))
    }
}

projection 传 null 表示要全部列,省事但代价是跨进程要传的数据更多(后面专门讲)。

官方把 query 的参数和 SQL 做了对照,这组对应关系比任何解释都清楚:

参数对应的 SQL官方定义
uriFROM table_name映射至 Provider 中名为 table_name 的表
projectioncol, col, ...检索到的每个行所包含的列的数组
selectionWHERE col = value指定选择行的条件
selectionArgs没有完全等效项替换选择子句中的 ? 占位符
sortOrderORDER BY col, ...返回的 Cursor 中各行的显示顺序

selection 里写 ? 占位、值放 selectionArgs,是官方明确要求的防注入写法,原文的说法是"这样用户输入就会直接绑定到查询,而不是被解释为 SQL 语句的一部分"。字符串拼接不仅容易被特殊字符搞坏,还是 SQL 注入的入口。selection 传 null 则返回所有行。

Cursor 必须关闭,官方原文写的是:不再需要 Cursor 对象时必须将其关闭以便更快释放资源,可以调用 close(),也可以用 Kotlin 的 use() 函数。Demo 里用的是 use {},它就是官方推荐的那种写法。Cursor 是跨进程资源的句柄,不关会一直占着 Provider 那边的窗口资源。

调用方要能读到数据,前提是持有 Provider 要求的权限。这里有一条和运行时权限完全不同的规则,官方原文是:如需从 Provider 检索数据,你的应用需具备读取访问权限,你无法在运行时请求此权限,必须使用 <uses-permission> 元素和 Provider 定义的确切权限名称在清单中指明。也就是说 Provider 的读写权限是安装时授予的,不能像相机、定位那样弹窗申请。权限名写错一个字母,结果就是访问被拒,而且没有弹窗提示。

更新和删除支持两种 URI,语义完全不同:

// 单条:用带 id 的 URI,只影响那一条
val uri = ContentUris.withAppendedId(Week17NoteProvider.CONTENT_URI, firstId)
val count = contentResolver.update(uri, values, null, null)
​
// 集合:用集合 URI 加条件,影响所有匹配的行
val count = contentResolver.update(
    Week17NoteProvider.CONTENT_URI, values, "title = ?", arrayOf("旧标题")
)

Demo 区域② 五个按钮对应 insert、query、update、delete、getType,每条操作的结果都会打在日志区里,包括 insert 返回的 URI 长什么样、getType 返回的两种 MIME 分别是什么。

实践

  • 参考方向:通讯录/日历类应用对外共享数据、桌面小组件读取应用数据、企业套件里同一厂商多个应用共享账号或配置
  • 解决问题:这些场景的共同点是"数据在一个应用里,另一个应用要用,但不能直接开数据库文件给它"。Provider 给的是受控入口加权限校验,比共享文件安全得多
  • 本节落点:Demo 区域② 的 CRUD 全套 + 区域① 的独立进程验证
  • 取舍判断:只在本应用内部用数据,不要为了"规范"硬上 Provider。多一层 Binder 就多一次序列化和跨进程开销,内部访问直接用数据库或 Repository 更快也更简单

相关技术

  • API / 类:ContentResolverContentValuesContentUris.withAppendedId/parseIdUriMatcherCursor.setNotificationUriCursorLoader
  • 系统机制:URI 分派、MIME 两种形态、Binder 跨进程调用、进程隔离
  • 常见坑:Manifest 漏 exportedgetType 不实现;Cursor 不关闭;selection 用字符串拼接(注入风险);忘了 notifyChange

调用系统 Provider:MediaStore

自己写 Provider 是一半,另一半是调用系统提供的。Demo 区域③ 查的是 MediaStore 里的图片:

val projection = arrayOf(
    MediaStore.Images.Media._ID,
    MediaStore.Images.Media.DISPLAY_NAME
)
val cursor = contentResolver.query(
    MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
    projection,
    null, null,
    "${MediaStore.Images.Media.DATE_ADDED} DESC"
)

系统 Provider 的调用方式和自定义的完全一样,区别只在 URI 和列名是系统定义好的常量。要注意的是权限:Android 13(API 33)起读图片需要 READ_MEDIA_IMAGES,旧版本用 READ_EXTERNAL_STORAGE,两个都声明时给旧的加 maxSdkVersion="32"

<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission
    android:name="android.permission.READ_EXTERNAL_STORAGE"
    android:maxSdkVersion="32" />

Demo 里查询前会检查并申请对应版本的权限。少了权限,query 会直接抛异常,而不是返回空 Cursor,这个行为差异值得记一下。

实践

  • 参考方向:头像/图片选择器、相册类应用、需要扫描本地音乐和视频的应用
  • 解决问题:直接遍历文件系统既慢又拿不到系统索引过的元数据,还可能因为分区存储(Scoped Storage)碰壁。走 MediaStore 是官方唯一推荐路径
  • 本节落点:Demo 区域③ MediaStore 查询图片文件名
  • 取舍判断:MediaStore 只能拿到系统索引过的内容,自己应用私有目录下的媒体如果要对外可见,得通过 MediaStore 插入或者用 MediaScannerConnection 主动扫描

相关技术

  • API / 类:MediaStore.Images.MediaEXTERNAL_CONTENT_URIDISPLAY_NAMEMediaScannerConnection
  • 权限:READ_MEDIA_IMAGES(33+)、READ_EXTERNAL_STORAGE(≤32)
  • 常见坑:版本权限搞混;无权限时 query 抛异常而非返回空;DATA 列在分区存储后不可靠(要用 ContentResolver.openInputStream(uri) 读内容)

ContentObserver:数据变了怎么通知

Provider 数据变了,正在看这份数据的界面要能知道。这一节要如实说明来源:官方的 Content provider 总览、基础知识和创建 Provider 三页都没有专门讲 ContentObserver 的章节,下面的内容按 API 的通用行为写,不是引用官方指南原文。机制本身是观察者模式,两端各一行代码。

Provider 侧在数据变更后发通知:

context?.contentResolver?.notifyChange(insertedUri, null)

调用侧注册观察者:

noteObserver = object : ContentObserver(Handler(Looper.getMainLooper())) {
    override fun onChange(selfChange: Boolean, uri: Uri?) {
        // 数据变了,重新查询刷新界面
    }
}
contentResolver.registerContentObserver(
    Week17NoteProvider.CONTENT_URI, true, noteObserver!!
)

第二个参数 true 表示子 URI 的变化也通知(/notes/15 变了,/notes 的观察者也收得到)。Demo 区域④ 的操作顺序是:先注册观察者,再回区域② 插入一条,日志区会自己冒出 ContentObserver 收到变化 那一行。

注销别忘。观察者在 onDestroy 里不注销,它的 Handler 持有 Activity 引用,又是一条泄漏路径:

override fun onDestroy() {
    noteObserver?.let { contentResolver.unregisterContentObserver(it) }
    noteObserver = null
    super.onDestroy()
}

实践

  • 参考方向:通讯录同步后的列表刷新、下载管理器进度更新、相册应用在别处拍了照回来自动刷新
  • 解决问题:数据可能在任何进程被修改(同步服务、另一个应用、后台任务),靠调用方自己轮询既费电又延迟。观察者是系统级的推模式
  • 本节落点:Demo 区域④ 注册与注销 + 插入触发回调
  • 取舍判断:观察者的回调在主线程 Handler 上跑,回调里直接做大查询会卡界面。正确做法是收到通知后丢给后台线程重新查,查完再回主线程刷新

相关技术

  • API / 类:ContentObserverregisterContentObserver/unregisterContentObservernotifyChange
  • 系统机制:URI 树形通知(notifyForDescendants)、主线程 Handler 回调
  • Jetpack / 三方库:CursorLoader(内部就是靠这套机制自动重载)
  • 常见坑:忘记注销导致泄漏;回调里做重查询卡主线程;Provider 侧忘了 notifyChange 导致观察者永不触发

跨进程通信优化:数据怎么传,为什么会卡

这是本周"优化"里最实的一部分。跨进程调用 Provider 时,数据不是一行一行传回来的,而是系统用 CursorWindow 这个共享内存块批量装好再传。理解这一点,优化方向就清楚了:让每次传输的数据量变小,让传输次数变少

第一条:projection 只写真正要用的列。Demo 区域⑥ 的实测对比很直白,同一张表查全部列和只查 _id 一列,后者更快,因为窗口里装的数据少,填充和读取都省。很多人图省事传 null 要全列,跨进程场景下这就是白付的开销。

第二条:query 是同步阻塞调用,不要在主线程跑。这一条最容易被忽略。官方文档在讲客户端查询时专门加了一句:"但在实际代码中,请在单独的线程上异步执行查询",官方推荐的做法是用 CursorLoader 在后台跑异步查询。数据量一大,主线程上的 query 直接 ANR。Demo 里所有 Provider 调用都放在 Dispatchers.IO

lifecycleScope.launch(Dispatchers.IO) {
    val cursor = contentResolver.query(uri, projection, selection, args, sortOrder)
    // 处理结果
    withContext(Dispatchers.Main) {
        // 回主线程刷新界面
    }
}

第三条:批量操作走事务或批量 API。逐条 insert 时每条都是一次独立的跨进程调用加一次磁盘提交。Demo 区域⑤ 插 2000 条时包了事务:

db.beginTransaction()
try {
    repeat(2000) { index ->
        values.clear()
        values.put(Week17NoteDbHelper.COL_TITLE, "note_$index")
        // ...
        db.insert(Week17NoteDbHelper.TABLE_NOTES, null, values)
    }
    db.setTransactionSuccessful()
} finally {
    db.endTransaction()
}

跨应用的批量操作对应的是 ContentProviderOperationContentResolver.applyBatch(),官方把它列为"批量访问"这一类正式访问形式,适用场景是插入大量行、同一调用里操作多张表、或者需要跨进程边界以事务(原子操作)形式执行一组操作。用法上有两个细节值得记:一是 applyBatch() 传的是 Provider 的 authority 而不是某个具体的 URI,正因为如此,数组里每个 ContentProviderOperation 才能作用于不同的表;二是它返回一个结果数组,每个操作的结果按序对应。原理和本地事务一样:一次原子提交,而不是 N 次来回。

第四条:大数据分页。一次查几万行,窗口装不下就要多次往返,还占内存。用 LIMIT / OFFSET 或者按时间游标分页,一次取一屏够用的量。

实践

  • 参考方向:通讯录应用在联系人列表滚动时的分页加载、桌面小组件定时同步应用数据
  • 解决问题:列表页首屏渲染时间直接受跨进程查询耗时影响。projection 精简加后台线程是最先该做的两件事,成本最低收益最明显
  • 本节落点:Demo 区域⑥ projection 对比 + 所有调用的 IO 线程化
  • 取舍判断:CursorWindow 分批传输本身已经很快,小数据量(几十行)下这些优化感知不到。真正在意它们的场景是上万行、或者查询非常频繁的页面,别为了优化把简单查询写复杂

相关技术

  • API / 类:CursorWindow(系统内部)、ContentProviderOperationContentResolver.applyBatch()LIMIT/OFFSETDispatchers.IO
  • 系统机制:共享内存批量传输、Binder 同步调用、主线程 ANR 时间预算
  • 性能工具:StrictMode(检测主线程磁盘与网络访问)、Android Studio Profiler 的 trace 分析
  • 常见坑:主线程 query 大表 ANR;projection 传 null 全列传输;逐条 insert 不包事务;一次查全量不分页

查询性能优化:索引与事务

第二个优化方向是查询本身。Demo 里的表结构特意做了不对称设计:title 列建了索引,content 列没有:

CREATE TABLE notes (
    _id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    content TEXT,
    created_at INTEGER
);
CREATE INDEX idx_notes_title ON notes (title);

先插 2000 条(区域⑤ 第一个按钮,包事务),再点对比按钮,会看到按 title 查和按 content 查的耗时差别。同时 Demo 会把 SQLite 的执行计划也打出来:

db.rawQuery(
    "EXPLAIN QUERY PLAN SELECT * FROM notes WHERE title = ?",
    arrayOf("note_1500")
)

执行计划的 detail 列里,看到 SEARCH ... USING INDEX idx_notes_title 就是命中索引,看到 SCAN notes 就是全表扫描。这比盯着耗时数字有用得多,因为它直接告诉你查询走没走索引。Demo 的对照设计就建立在这个确定性上:title 建了索引,计划必然走 SEARCH;content 没建,必然 SCAN。跑一次对比按钮,两种形态会同时出现在结果里。

索引不是白拿的,两个代价得算清楚。一是占空间,每建一个索引多一份数据结构。二是写入变慢,每次 insert / update / delete 都要同步维护索引。所以给高频查询条件建索引,给那种偶尔查一次的字段建索引就是给自己找麻烦。

还有一条容易被当成索引问题的优化:批量写入包事务。2000 次独立 insert 是 2000 次磁盘提交,包进事务后变成一次。这个提升通常比建索引还明显,而且没有副作用。

实践

  • 参考方向:本地消息/会话列表按时间或会话 id 查询、离线缓存表按业务 key 查询
  • 解决问题:列表页每次进入都要查库,几万行数据下全表扫描能差出几十毫秒到几百毫秒,直接体现在首屏渲染时间上
  • 本节落点:Demo 区域⑤ 的 2000 条测试数据、索引对比与 EXPLAIN QUERY PLAN 输出
  • 取舍判断:先确认慢查询真的存在,再建索引。SQLite 在几千行以内的数据上全表扫描很快,过早建索引只是把写入变慢。用 EXPLAIN QUERY PLAN 看一眼是 SCAN 还是 SEARCH,比凭感觉加索引靠谱

相关技术

  • API / 类:CREATE INDEXEXPLAIN QUERY PLANSQLiteDatabase.beginTransaction/setTransactionSuccessful/endTransaction
  • 系统机制:B 树索引、全表扫描与索引查找、事务的磁盘提交合并
  • 性能工具:EXPLAIN QUERY PLAN、Android Studio Database Inspector、adb shell sqlite3
  • 常见坑:给所有列建索引(写入变慢);批量插入不包事务;用耗时数字判断而不看执行计划

方案决策矩阵

需求方案理由
本应用内部读写数据直接用数据库 / Repository不需要跨进程,少一层 Binder
向其他应用共享数据自建 Provider + exported + 权限唯一受控的跨应用数据入口
只给自己多个应用共享Provider + signature 级权限同签名自动授权,外部应用进不来
给桌面小组件供数据Provider(或 Glance 的现代方案)小组件运行在另一个进程
读系统媒体/联系人/日历MediaStore 等系统 Provider官方唯一推荐路径,已索引过
数据变了要通知界面ContentObserver / Flow推模式,比轮询省电
上万行列表查询索引 + projection 精简 + 分页 + 后台线程四项组合才有效
批量写入上千条事务 / applyBatch一次提交代替 N 次

第17周真正带走的东西

Provider 是四大组件里唯一为"把数据交给别人"而生的,它的每个设计都指向同一件事:数据怎么安全地交给别人。理解这一点,exported、读写权限、URI 分派这些配置就不是零散的样板代码,而是一套访问控制设计。两道门禁的方向感要建立起来:exported 默认关(targetSdk 17+ 默认 false),权限不设则全开(官方原文明说了),一收一放全靠你显式配置。

六个方法分成三组:onCreate 做最轻的初始化且跑在 Provider 进程主线程,query/insert/update/delete 是数据入口,getType 返回两种 MIME 形态。两行代码最容易漏:Cursor.setNotificationUrinotifyChange。少了前者,观察者收不到通知;少了后者,通知根本不发。

调用方一律走 ContentResolver,四个参数里 projectionselectionArgs 最容易被图省事写坏。前者传 null 会让跨进程多传不需要的列,后者用字符串拼接是注入入口。

优化有三个层次,按性价比排:批量写入包事务最高,projection 精简次之,索引最后。判断索引该不该建,看 EXPLAIN QUERY PLAN 里的 SEARCH 还是 SCAN,别看耗时数字凭感觉。


技术清零表

技术它是什么Demo 落点真实项目价值常见坑
ContentProvider跨进程数据访问标准接口Week17NoteProvider对外共享数据的受控入口不共享也硬上,白付 Binder 开销
authorityProvider 全局唯一标识com.study.all.provider.week17URI 寻址的基础与其他应用重名导致冲突
UriMatcherURI 到整数的分派器addURI("notes") / addURI("notes/#")两类 URI 的统一处理手写字符串判断,改一个路径到处改
#*UriMatcher 通配符,# 匹配数字、* 匹配任意长度有效字符notes/# 匹配单条单条 URI 的匹配* 去匹配 id 导致类型混乱
authority 命名以反向域名为基础,包名加 .providercom.study.all.provider.week17避免与其他 Provider 冲突定下来后修改导致调用方全部失效
线程安全要求除 onCreate 外五个方法可被多线程并发调用Provider 方法内无共享可变状态防偶发数据错乱方法里放共享可变状态不加锁
android:permission单一权限同时控制读写权限优先级表读写要求相同时的最简配置与 read/write 权限混用时优先级搞错
<path-permission>针对特定 URI 路径授权,优先级高于 Provider 级权限优先级表精细到某张表或某类记录以为 Provider 级权限能覆盖路径级
临时授权grantUriPermissions + FLAG_GRANT_READ_URI_PERMISSION权限优先级表临时把某条数据交给别的应用长期开放 URI 而不走临时授权
包可见性Android 11+ 访问其他应用 Provider 的配置要求本周 Demo 未涉及跨应用访问前的合规检查只配权限忘了可见性配置
applyBatch跨进程批量操作,传 authority 而非具体 URI,返回结果数组批量优化一节多表批量写入原子化逐条跨进程调用;传了具体 URI 导致只能操作单表
getType返回 URI 对应数据的 MIME区域② getType 按钮系统隐式匹配需要它不实现或两种形态写反
vnd.android.cursor.dir集合 URI 的 MIME 前缀getType 返回标明返回多行与 item 前缀混用
vnd.android.cursor.item单条 URI 的 MIME 前缀getType 返回标明返回单行与 dir 前缀混用
ContentUris.withAppendedId把 id 拼到集合 URI 后insert 返回值调用方直接拿到新记录 id手动拼字符串拼错格式
ContentUris.parseId从单条 URI 取出 idquery/update/delete 里解析单条操作的定位对集合 URI 调用得到错误值
android:exported是否允许外部访问(API 17 引入,targetSdk 17+ 默认 false)Manifest provider 声明外部共享的总开关想让外部访问忘记显式写 true;误以为受 Android 12 intent-filter 必填规则约束
signature 权限同签名应用自动获得的权限级别protectionLevel="signature"开放给自家应用、挡住外部用 normal 级导致任意应用可访问
android:process=":remote"让组件跑在独立进程Provider 声明验证真实的跨进程调用忘了跨进程后调用成本上升
ContentResolver.query 四参数projection/selection/args/sortOrder区域② 查询决定查什么、怎么筛、怎么排projection 传 null;selection 拼接注入
Cursor.setNotificationUriCursor 与 URI 绑定Provider 的 query 里观察者能定位到具体数据集漏掉导致自动刷新失效
notifyChange通知数据变化insert/update/delete 后驱动观察者刷新改完数据忘了发通知
ContentObserver数据变化观察者区域④ 注册与注销跨进程数据变更的推模式onDestroy 不注销导致泄漏;回调里做重查询
MediaStore系统媒体 Provider区域③ 图片查询读系统媒体的官方路径权限版本搞混;无权限时抛异常
READ_MEDIA_IMAGESAndroid 13+ 读图权限Manifest + 运行时申请合规读取图片旧版本用错权限
CursorWindow跨进程批量传输的共享内存区域⑥ 对比的原理决定传输次数与单次数据量以为逐行传输而过度优化
projection 精简只查需要的列区域⑥ 实测对比减少传输量与填充耗时传 null 要全列
事务批量写入多次写入合并一次提交区域⑤ 2000 条插入批量写入提速最明显逐条提交导致极慢
EXPLAIN QUERY PLAN查看 SQLite 执行计划区域⑤ 输出 SCAN / SEARCH判断索引是否命中的唯一可靠方式靠耗时数字判断走没走索引
索引加速查询的数据结构idx_notes_title高频查询条件提速建太多拖慢写入;几千行内没必要
applyBatch跨进程批量操作 API批量优化一节提及跨应用批量写入原子化逐条跨进程调用