1. 安卓读取相册图片为什么老代码一跑就崩getContentResolver是 Android 里访问其他应用数据的入口相册图片就存在MediaStore这个系统级 ContentProvider 里。你不需要自己遍历/sdcard/DCIM只要拿ContentResolver去查MediaStore.Images.Media.EXTERNAL_CONTENT_URI就能拿到图片名、大小、路径这些信息。适合谁做图片选择器、相册预览、上传前本地扫描的安卓开发者尤其是还在用BitmapFactory.decodeFile那套老写法的同学。问题出在哪很多网上流传的示例还是 Android 6 之前的写法MediaStore.Images.Media.DATA直接当路径用getBlob转字符串权限只在 Manifest 里声明一下。放到 Android 10 以上尤其是 Android 13 的READ_MEDIA_IMAGES这套代码要么查出来是空 Cursor要么直接抛SecurityException要么DATA列返回 null。我试过把老项目迁到 targetSdk 34第一件事就是相册列表全空。这篇就按真实链路走一遍运行时权限怎么申请、MediaStore 查询语句怎么写、Cursor 怎么解析、真机上怎么验证读到图片。同时把多工具开发时最容易乱掉的 Key 配置用 TaoToken 统一管起来避免每个 AI 工具各存一份密钥。核心检索词先记住安卓、getContentResolver、相册图片读取。2. TaoToken 前置统一 Key 管理别让配置散落各处写安卓项目时我经常同时开着几个 AI 辅助工具一个补全代码、一个查报错、一个生成测试用例。每个工具都要填 API Key时间一长自己都记不清哪个 Key 对应哪个服务换机器还得重新配一遍。TaoToken 的思路是给你一个统一的 Key多个工具共用同一份配置改一处就全生效。官网入口在这里https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。API 地址是 https://taotoken.net/api 注意这个不带 UTM 参数配置里直接写它就行。你需要先拿到 Key去控制台创建https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 。创建完在 API Keys 页面复制https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 。如果只是想先验证模型通不通用模型对话页面最快https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 。长期做编码、跑 Agent 任务的建议直接看 Coding Planhttps://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 。接入细节和参数说明在文档里https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。Claude Code 相关的配置参考https://taotoken.net/claudecode?utm_sourcetaotoken_aicg_blog_endutm_contentclaudecodeutm_campaignrewrite 。注意Key 只存在本地配置文件里不要硬编码进 Android 源码提交到仓库。下面第 3 节的 settings.json 是给开发机上的 AI 工具用的跟 App 运行时的网络请求是两回事。3. 可复制配置权限片段 查询代码骨架3.1 AndroidManifest 权限片段先按系统版本分权限。Android 13API 33开始读图片要用READ_MEDIA_IMAGES旧的READ_EXTERNAL_STORAGE在 33 上对图片不再生效。manifest xmlns:androidhttp://schemas.android.com/apk/res/android !-- Android 12 及以下读外部存储 -- uses-permission android:nameandroid.permission.READ_EXTERNAL_STORAGE android:maxSdkVersion32 / !-- Android 13 及以上读媒体图片 -- uses-permission android:nameandroid.permission.READ_MEDIA_IMAGES / application android:allowBackuptrue android:labelMediaDemo activity android:name.MediaProviderActivity android:exportedtrue intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity /application /manifestmaxSdkVersion32这个属性很关键它让旧权限只在 12 及以下申请33 以上系统不会因为声明了废弃权限而报错。3.2 运行时权限申请Manifest 声明只是第一步Android 6 以后必须运行时申请。下面这段用registerForActivityResult比老的onRequestPermissionsResult干净。class MediaProviderActivity : AppCompatActivity() { private val requestPermission registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted - if (granted) { loadImages() } else { Toast.makeText(this, 没有相册权限无法读取图片, Toast.LENGTH_SHORT).show() } } private fun checkAndLoad() { val permission if (Build.VERSION.SDK_INT Build.VERSION_CODES.TIRAMISU) { Manifest.permission.READ_MEDIA_IMAGES } else { Manifest.permission.READ_EXTERNAL_STORAGE } if (ContextCompat.checkSelfPermission(this, permission) PackageManager.PERMISSION_GRANTED) { loadImages() } else { requestPermission.launch(permission) } } }3.3 ContentResolver 查询与 Cursor 解析这是核心。查询时只取需要的列别用null拉全部列性能差还容易踩到废弃字段。DATA列在 Android 10 之后不可靠改用ContentUris.withAppendedId拼 Uri。private fun loadImages() { val imageList mutableListOfImageItem() val projection arrayOf( MediaStore.Images.Media._ID, MediaStore.Images.Media.DISPLAY_NAME, MediaStore.Images.Media.SIZE, MediaStore.Images.Media.DATE_ADDED ) val sortOrder ${MediaStore.Images.Media.DATE_ADDED} DESC val cursor: Cursor? contentResolver.query( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, projection, null, null, sortOrder ) cursor?.use { c - val idCol c.getColumnIndexOrThrow(MediaStore.Images.Media._ID) val nameCol c.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME) val sizeCol c.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE) while (c.moveToNext()) { val id c.getLong(idCol) val name c.getString(nameCol) val size c.getLong(sizeCol) val uri ContentUris.withAppendedId( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id ) imageList.add(ImageItem(id, name, size, uri)) Log.d(MediaDemo, name$name size$size uri$uri) } } Log.d(MediaDemo, 共读取到 ${imageList.size} 张图片) }cursor?.use {}会自动关闭 Cursor避免内存泄漏。getColumnIndexOrThrow比getColumnIndex更安全列名写错会立刻抛异常而不是返回 -1。3.4 用 Uri 加载 Bitmap拿到 Uri 后不要再用decodeFile用ContentResolver.openInputStream或图片加载库。private fun loadBitmap(uri: Uri): Bitmap? { return try { contentResolver.openInputStream(uri)?.use { input - BitmapFactory.decodeStream(input) } } catch (e: IOException) { Log.e(MediaDemo, 读取图片失败: ${e.message}) null } }3.5 TaoToken 统一 Key 的 settings.json 示例开发机上多个 AI 工具共用一份配置把 Key 集中管理。下面这份是给支持 settings.json 的工具用的base_url指向 TaoToken 的 API 地址。{ provider: taotoken, base_url: https://taotoken.net/api, api_key: sk-你的TaoToken密钥, models: { default: claude-sonnet, code: claude-sonnet, chat: gpt-4o }, timeout: 60, retry: 2 }把api_key换成你在 API Keys 页面复制的那串。这样补全工具、对话工具、Agent 工具都读同一份换 Key 只改这一个文件。4. 真机验证从点击到看到图片4.1 验证步骤第一步把 App 装到真机模拟器相册可能没图建议真机。打开后点「读取相册」按钮系统弹出权限对话框选「允许」。第二步看 Logcat 过滤MediaDemo标签。正常输出类似D/MediaDemo: nameIMG_20240115_103022.jpg size2456789 uricontent://media/external/images/media/1000000032 D/MediaDemo: nameScreenshot_20240114.png size512340 uricontent://media/external/images/media/1000000031 D/MediaDemo: 共读取到 128 张图片第三步把 Uri 传给ImageView或列表项确认图片能正常显示。如果列表有数据但图片空白多半是 Bitmap 太大导致 OOM需要加采样。4.2 采样压缩避免大图 OOM相册里动辄几千万像素的照片直接decodeStream很容易崩。先读边界再按比例采样private fun decodeSampled(uri: Uri, reqWidth: Int, reqHeight: Int): Bitmap? { val options BitmapFactory.Options().apply { inJustDecodeBounds true } contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, options) } options.inSampleSize calculateInSampleSize(options, reqWidth, reqHeight) options.inJustDecodeBounds false return contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, options) } } private fun calculateInSampleSize( options: BitmapFactory.Options, reqW: Int, reqH: Int ): Int { var sampleSize 1 val (halfW, halfH) options.outWidth / 2 to options.outHeight / 2 while (halfW / sampleSize reqW halfH / sampleSize reqH) { sampleSize * 2 } return sampleSize }4.3 参数对照表参数/常量作用注意事项EXTERNAL_CONTENT_URI外部存储图片集合 Uri不要用INTERNAL_CONTENT_URI查相册_ID图片唯一 ID用它拼 Uri别用 DATADISPLAY_NAME文件名稳定可用SIZE文件字节数可用于排序或过滤DATE_ADDED加入时间戳秒排序常用DATA旧版绝对路径Android 10 不可靠弃用READ_MEDIA_IMAGESAndroid 13 图片权限33 以下用 READ_EXTERNAL_STORAGE5. 本篇常见错排查5.1 Cursor 返回 null 或空最常见的原因是权限没给。先确认运行时权限真的申请了再看 Manifest 里 33 以上是不是漏了READ_MEDIA_IMAGES。还有一种情况查询用了INTERNAL_CONTENT_URI那查的是内部存储相册图片不在里面。改成EXTERNAL_CONTENT_URI。5.2 SecurityException: Permission Denial日志里出现Permission Denial: reading MediaProvider说明权限没到位。检查两点一是targetSdkVersion对应的权限名对不对33 用READ_MEDIA_IMAGES二是用户是不是在设置里手动关了权限这种情况要引导去应用设置页重新开。5.3 DATA 列返回 nullAndroid 10 引入分区存储后MediaStore.Images.Media.DATA对很多图片返回 null。别再解析这个字段用_ID拼ContentUris.withAppendedId得到 Uri再用openInputStream读。5.4 图片显示空白或 OOM列表能查到数据但图片不显示先看是不是没加采样。大图直接解码会 OOM按 4.2 的采样逻辑处理。另外确认ImageView的宽高不是wrap_content配超大 Bitmap容易撑爆内存。5.5 权限申请后回调没触发用registerForActivityResult时注册必须在onCreate里、STARTED之前完成。如果放在按钮点击里注册回调不会触发。把注册提到成员变量初始化位置。5.6 多工具 Key 冲突如果你同时用几个 AI 工具每个都填了不同的 Key改起来容易漏。统一走 TaoToken 的 settings.jsonbase_url写https://taotoken.net/apiKey 只维护一份。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 遇到 401 先核对 Key 有没有复制全。6. 继续往下走相册读取这条链路跑通后下一步通常是做图片选择器或者上传。上传前记得把 Uri 转成 InputStream 或临时文件别直接传路径。如果你在接 AI 能力做图片描述、OCR 之类的功能Key 管理建议一开始就用统一方案去 API Keys 页面 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 建一个配置写进 settings.json。验证模型能不能通用模型对话页面 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 最快不用写代码就能试。长期跑编码任务的Coding Plan 页面 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 有更完整的方案说明。最后留一个我踩过的坑getColumnIndexOrThrow在列名拼错时会直接崩调试阶段这是好事能立刻发现但上线前最好确认 projection 里的列在目标 API 级别都存在DATE_ADDED在很老的设备上可能没有做兼容时用getColumnIndex加判空更稳。