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 固定为 vnd,subtype 按 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 | 官方定义 |
|---|---|---|
uri | FROM table_name | 映射至 Provider 中名为 table_name 的表 |
projection | col, col, ... | 检索到的每个行所包含的列的数组 |
selection | WHERE col = value | 指定选择行的条件 |
selectionArgs | 没有完全等效项 | 替换选择子句中的 ? 占位符 |
sortOrder | ORDER 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 / 类:
ContentResolver、ContentValues、ContentUris.withAppendedId/parseId、UriMatcher、Cursor.setNotificationUri、CursorLoader - 系统机制:URI 分派、MIME 两种形态、Binder 跨进程调用、进程隔离
- 常见坑:Manifest 漏
exported;getType不实现;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.Media、EXTERNAL_CONTENT_URI、DISPLAY_NAME、MediaScannerConnection - 权限:
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 / 类:
ContentObserver、registerContentObserver/unregisterContentObserver、notifyChange - 系统机制: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()
}
跨应用的批量操作对应的是 ContentProviderOperation 加 ContentResolver.applyBatch(),官方把它列为"批量访问"这一类正式访问形式,适用场景是插入大量行、同一调用里操作多张表、或者需要跨进程边界以事务(原子操作)形式执行一组操作。用法上有两个细节值得记:一是 applyBatch() 传的是 Provider 的 authority 而不是某个具体的 URI,正因为如此,数组里每个 ContentProviderOperation 才能作用于不同的表;二是它返回一个结果数组,每个操作的结果按序对应。原理和本地事务一样:一次原子提交,而不是 N 次来回。
第四条:大数据分页。一次查几万行,窗口装不下就要多次往返,还占内存。用 LIMIT / OFFSET 或者按时间游标分页,一次取一屏够用的量。
实践
- 参考方向:通讯录应用在联系人列表滚动时的分页加载、桌面小组件定时同步应用数据
- 解决问题:列表页首屏渲染时间直接受跨进程查询耗时影响。projection 精简加后台线程是最先该做的两件事,成本最低收益最明显
- 本节落点:Demo 区域⑥ projection 对比 + 所有调用的 IO 线程化
- 取舍判断:
CursorWindow分批传输本身已经很快,小数据量(几十行)下这些优化感知不到。真正在意它们的场景是上万行、或者查询非常频繁的页面,别为了优化把简单查询写复杂
相关技术
- API / 类:
CursorWindow(系统内部)、ContentProviderOperation、ContentResolver.applyBatch()、LIMIT/OFFSET、Dispatchers.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 INDEX、EXPLAIN QUERY PLAN、SQLiteDatabase.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.setNotificationUri 和 notifyChange。少了前者,观察者收不到通知;少了后者,通知根本不发。
调用方一律走 ContentResolver,四个参数里 projection 和 selectionArgs 最容易被图省事写坏。前者传 null 会让跨进程多传不需要的列,后者用字符串拼接是注入入口。
优化有三个层次,按性价比排:批量写入包事务最高,projection 精简次之,索引最后。判断索引该不该建,看 EXPLAIN QUERY PLAN 里的 SEARCH 还是 SCAN,别看耗时数字凭感觉。
技术清零表
| 技术 | 它是什么 | Demo 落点 | 真实项目价值 | 常见坑 |
|---|---|---|---|---|
| ContentProvider | 跨进程数据访问标准接口 | Week17NoteProvider | 对外共享数据的受控入口 | 不共享也硬上,白付 Binder 开销 |
| authority | Provider 全局唯一标识 | com.study.all.provider.week17 | URI 寻址的基础 | 与其他应用重名导致冲突 |
| UriMatcher | URI 到整数的分派器 | addURI("notes") / addURI("notes/#") | 两类 URI 的统一处理 | 手写字符串判断,改一个路径到处改 |
# 与 * | UriMatcher 通配符,# 匹配数字、* 匹配任意长度有效字符 | notes/# 匹配单条 | 单条 URI 的匹配 | 用 * 去匹配 id 导致类型混乱 |
| authority 命名 | 以反向域名为基础,包名加 .provider | com.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 取出 id | query/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.setNotificationUri | Cursor 与 URI 绑定 | Provider 的 query 里 | 观察者能定位到具体数据集 | 漏掉导致自动刷新失效 |
notifyChange | 通知数据变化 | insert/update/delete 后 | 驱动观察者刷新 | 改完数据忘了发通知 |
| ContentObserver | 数据变化观察者 | 区域④ 注册与注销 | 跨进程数据变更的推模式 | onDestroy 不注销导致泄漏;回调里做重查询 |
| MediaStore | 系统媒体 Provider | 区域③ 图片查询 | 读系统媒体的官方路径 | 权限版本搞混;无权限时抛异常 |
READ_MEDIA_IMAGES | Android 13+ 读图权限 | Manifest + 运行时申请 | 合规读取图片 | 旧版本用错权限 |
| CursorWindow | 跨进程批量传输的共享内存 | 区域⑥ 对比的原理 | 决定传输次数与单次数据量 | 以为逐行传输而过度优化 |
| projection 精简 | 只查需要的列 | 区域⑥ 实测对比 | 减少传输量与填充耗时 | 传 null 要全列 |
| 事务批量写入 | 多次写入合并一次提交 | 区域⑤ 2000 条插入 | 批量写入提速最明显 | 逐条提交导致极慢 |
EXPLAIN QUERY PLAN | 查看 SQLite 执行计划 | 区域⑤ 输出 SCAN / SEARCH | 判断索引是否命中的唯一可靠方式 | 靠耗时数字判断走没走索引 |
| 索引 | 加速查询的数据结构 | idx_notes_title | 高频查询条件提速 | 建太多拖慢写入;几千行内没必要 |
applyBatch | 跨进程批量操作 API | 批量优化一节提及 | 跨应用批量写入原子化 | 逐条跨进程调用 |