
1. 为什么 MediaStore 查询总在真机上翻车Android 的 MediaStore 是系统给所有 App 提供的一个「公共媒体数据库」相册里的图片、视频、录音、下载文件本质上都是通过它来索引的。它能做什么一句话让你不用自己遍历/sdcard就能拿到系统已经扫描好的媒体条目并且通过ContentResolver做增删改查。适合谁做相册、文件管理、图片编辑、短视频剪辑、上传头像这类需要读写用户媒体文件的 Android 开发者。但很多人第一次写 MediaStore 都会遇到同一个场景在模拟器上跑得好好的一上真机就返回空 Cursor或者插入图片后相册里死活不显示。我试过最典型的一次query返回的cursor.getCount()是 0日志干干净净没有任何异常。问题不在代码语法而在三个地方权限没申请全、DATA字段在 Android 10 之后被限制、插入时漏了IS_PENDING标记。这篇就按「查询 → 写入 → 验证 → 排错」的完整链路走一遍代码可以直接复制。同时我会把 AI 辅助生成 MediaStore 代码的环节接进来用 TaoToken 的统一 Key 调模型让它在你不确定某个字段名或某个版本行为时快速给出可用的片段而不是去翻半天文档。整个流程我会给出可复制的配置和验证命令确保你能跑通。需要先明确一个版本分界线这决定了你后面所有代码的写法Android 版本关键行为影响Android 9 及以下DATA字段可用直接拿绝对路径老代码能跑Android 10API 29引入分区存储DATA被标记废弃查询仍可读写入受限Android 11API 30强制分区存储DATA基本不可依赖必须用Uri 流所以下面所有查询代码我都会用_ID拼ContentUris的方式拿 Uri而不是依赖DATA。这是能跨版本稳定跑的关键。2. TaoToken 前置拿到统一 Key 并配好调用入口在写代码之前先把 AI 辅助这一环配好。TaoToken 的作用是给你一个统一的 API Key 和 Base URL让你用同一套凭证去调不同的模型省得每个模型单独申请。官网入口是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 地址是 https://taotoken.net/api 。第一步进控制台创建 Key。打开 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 登录后在 API Keys 页面新建一个复制出来。这个 Key 后面既用于命令行验证也用于你在 IDE 插件里配置。第二步如果你只是想先验证模型能不能通用模型对话页面最快 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 。在这里选一个模型直接发一句「用 Kotlin 写一个 MediaStore 查询图片的示例」看返回是否正常。这一步能排除 Key 本身的问题。第三步如果你打算长期用 AI 辅助写 Android 代码建议直接上 Coding Plan https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 。它适合把模型接进编辑器做补全和对话MediaStore 这种字段多、版本差异大的 API有补全会省很多查文档的时间。第四步如果你用的是 Claude Code 这类命令行 Agent接入文档在这里 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里面有 Base URL 和鉴权头的写法。API Key 管理页统一在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 。这里要提醒一句TaoToken 是给你提供模型调用入口的不是替代 Android Studio 的编辑器也不是让你把生产数据库直连上去。它的定位是「统一 Key 统一入口」你该写的 MediaStore 代码还是得自己写、自己测。配好之后你可以用一条 curl 验证 Key 是否可用把$TAOTOKEN_KEY换成你自己的curl https://taotoken.net/api/v1/chat/completions \ -H Authorization: Bearer $TAOTOKEN_KEY \ -H Content-Type: application/json \ -d { model: claude-sonnet-4-20250514, messages: [ {role: user, content: 用一句话说明 Android MediaStore 的 IS_PENDING 字段作用} ] }返回里能看到choices[0].message.content就说明通了。这一步过了后面让 AI 帮你生成 MediaStore 片段才有意义。3. 可复制配置权限清单与查询写入代码这一节是核心全部代码可直接粘贴。先看权限配置AndroidManifest.xml里按版本声明uses-permission android:nameandroid.permission.READ_MEDIA_IMAGES / uses-permission android:nameandroid.permission.READ_MEDIA_VIDEO / uses-permission android:nameandroid.permission.READ_MEDIA_AUDIO / uses-permission android:nameandroid.permission.READ_EXTERNAL_STORAGE android:maxSdkVersion32 / uses-permission android:nameandroid.permission.WRITE_EXTERNAL_STORAGE android:maxSdkVersion28 /注意READ_MEDIA_*是 Android 13API 33引入的13 以下用READ_EXTERNAL_STORAGE。WRITE_EXTERNAL_STORAGE在 Android 10 之后对媒体库写入其实不再必需但为了兼容 9 及以下保留并用maxSdkVersion限制。运行时申请用ActivityResultContracts别再用旧的onRequestPermissionsResultprivate val permissionLauncher registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ) { result - val allGranted result.values.all { it } if (allGranted) { queryImages() } else { Log.w(MediaStore, 权限未全部授予: $result) } } private fun requestMediaPermission() { val permissions if (Build.VERSION.SDK_INT Build.VERSION_CODES.TIRAMISU) { arrayOf( Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_VIDEO ) } else { arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE) } permissionLauncher.launch(permissions) }查询图片用_ID拼 Uri不依赖DATAprivate fun queryImages() { val projection arrayOf( MediaStore.Images.Media._ID, MediaStore.Images.Media.DISPLAY_NAME, MediaStore.Images.Media.SIZE, MediaStore.Images.Media.MIME_TYPE, MediaStore.Images.Media.DATE_ADDED ) val selection ${MediaStore.Images.Media.MIME_TYPE} LIKE ? val selectionArgs arrayOf(image/%) val sortOrder ${MediaStore.Images.Media.DATE_ADDED} DESC contentResolver.query( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, projection, selection, selectionArgs, sortOrder )?.use { cursor - val idCol cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID) val nameCol cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME) val sizeCol cursor.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE) while (cursor.moveToNext()) { val id cursor.getLong(idCol) val uri ContentUris.withAppendedId( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id ) Log.d(MediaStore, name${cursor.getString(nameCol)} uri$uri size${cursor.getLong(sizeCol)}) } } }写入一张图片到媒体库关键是IS_PENDING先插入占位写完数据再置 0否则相册可能不刷新private fun insertImage(bitmap: Bitmap, displayName: String) { val values ContentValues().apply { put(MediaStore.Images.Media.DISPLAY_NAME, displayName) put(MediaStore.Images.Media.MIME_TYPE, image/png) put(MediaStore.Images.Media.IS_PENDING, 1) } val collection MediaStore.Images.Media.EXTERNAL_CONTENT_URI val uri contentResolver.insert(collection, values) ?: return contentResolver.openOutputStream(uri)?.use { out - bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) } values.clear() values.put(MediaStore.Images.Media.IS_PENDING, 0) contentResolver.update(uri, values, null, null) Log.d(MediaStore, 插入完成: $uri) }如果你用 AI 辅助生成可以把上面这段贴给模型让它帮你改成查询视频或音频。用 TaoToken 的 Coding Plan 时配置里三件套要写全Base URL 填https://taotoken.net/apiKey 填你创建的Model ID 填你选的模型名。缺任何一个都会报鉴权或模型不存在。4. 验证请求跑通查询与写入并确认结果代码写完怎么确认真的跑通了分两步验证。第一步查询验证。在queryImages里打日志运行后看 Logcat 过滤MediaStore标签。正常情况下你会看到类似D/MediaStore: nameIMG_20240101_120000.jpg uricontent://media/external/images/media/100001 size2456789如果cursor.getCount()是 0先别怀疑代码去系统相册确认设备里确实有图片再检查权限是否真的授予了。可以在申请回调里打印result看是不是被拒了。第二步写入验证。调用insertImage后打开系统相册看新图片是否出现。如果没出现检查IS_PENDING是否被置回 0。这一步是新手最容易漏的插入了但没更新IS_PENDING系统会认为这个条目还在写入中不对外展示。如果你想用 AI 帮你验证字段名对不对可以在模型对话页面发一句「Android MediaStore.Images.Media 里表示文件显示名的字段是哪个」正常会返回DISPLAY_NAME。如果返回的是TITLE或DATA说明模型版本较旧换一个模型再问。这一步能帮你快速确认 API 细节不用去翻 AOSP 源码。再补一个视频查询的验证逻辑和图片一致只是换MediaStore.Video.Mediaval videoUri MediaStore.Video.Media.EXTERNAL_CONTENT_URI val projection arrayOf( MediaStore.Video.Media._ID, MediaStore.Video.Media.DISPLAY_NAME, MediaStore.Video.Media.DURATION ) contentResolver.query(videoUri, projection, null, null, null)?.use { c - while (c.moveToNext()) { val id c.getLong(c.getColumnIndexOrThrow(MediaStore.Video.Media._ID)) val uri ContentUris.withAppendedId(videoUri, id) Log.d(MediaStore, video uri$uri duration${c.getLong(c.getColumnIndexOrThrow(MediaStore.Video.Media.DURATION))}) } }跑通这两个说明你的查询和写入链路是通的。接下来就是排错环节。5. 本篇常见错排查401、Cursor 为空、OAuth 报错这一节对照真实报错来。先说 AI 调用侧的再说 MediaStore 侧的。AI 调用侧最常见的三个401 Unauthorized一般是 Key 没带对或过期。检查Authorization: Bearer $TAOTOKEN_KEY里的 Key 是否完整有没有多余空格。如果是在 IDE 插件里配的确认 Base URL 填的是https://taotoken.net/api不是首页地址。local proxy failed通常是本地网络或代理配置问题。先确认你的请求能直连到taotoken.net用 curl 单独测一次。如果 curl 通、插件不通那就是插件里的 Base URL 或端口写错了。reading choices报错一般是返回体不是预期的 JSON 结构可能是模型名写错导致返回了错误信息。检查 Model ID 是否和你在模型对话页面选的一致。如果你用 Claude Code 或 Codex 这类工具配置里三件套必须齐全Base URL、Key、Model ID。以 Codex 的auth.json为例结构大致是{ base_url: https://taotoken.net/api, api_key: 你的Key, model: claude-sonnet-4-20250514 }少任何一项都会报鉴权失败或模型不存在。OAuth 相关报错通常出现在用账号登录而非 Key 的场景如果你走的是 Key 模式一般不会碰到碰到了就回到 API Keys 页面重新生成一个 Key 再试。MediaStore 侧最常见的两个Cursor 返回空。按顺序排查权限是否授予打印result、设备里是否真有媒体文件、selection条件是否写得太严比如 MIME 过滤写错。Android 13 上如果只申请了READ_MEDIA_IMAGES却去查视频也会返回空。插入后相册不显示。九成是IS_PENDING没置回 0或者MIME_TYPE和实际写入的数据不匹配。比如你声明image/png却写入了 JPEG 数据系统可能拒绝索引。还有一个隐蔽的坑在 Android 10 上用DATA字段拿路径然后直接File读写会抛FileNotFoundException或权限异常。解决办法就是全程用UriopenInputStream/openOutputStream不要碰绝对路径。6. 把 AI 辅助接进你的日常 MediaStore 开发流跑通上面的示例后你可以把 AI 辅助固定成两个动作。第一个动作是「字段确认」当你记不清某个 Media 字段名时直接问模型比翻文档快。第二个动作是「版本适配」把一段 Android 9 的老代码贴给模型让它改成兼容 Android 13 的写法重点检查DATA和权限声明。长期做 Android 媒体相关开发的话建议用 Coding Plan 把模型接进编辑器这样写ContentValues、projection数组时能直接补全减少字段拼写错误。入口还是 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 。如果你只是想临时验证某个 API 行为用模型对话页面就够 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 。最后留一个我踩过的坑MediaStore 的DATE_ADDED单位是秒不是毫秒排序时别拿它和System.currentTimeMillis()直接比。这个细节模型有时候也会答错所以生成完代码关键字段还是自己扫一眼。