1. Android 相册元数据链路:MediaStore 查询与 ExifInterface 写入到底难在哪
如果你正在做 Android 相册类应用,大概率绕不开两件事:一是从系统媒体库里把图片捞出来,二是把图片的 EXIF 信息读出来、改掉、再写回去。前者靠 MediaStore,后者靠 ExifInterface。听起来都是官方 API,文档也不长,但真正落到项目里,坑一个接一个。
MediaStore 本质是一个 ContentProvider,它用类似数据库的方式对外暴露媒体文件。你查询它,拿到的是 Cursor,然后靠列索引去取值。问题在于:Android 10 之后分区存储(Scoped Storage)逐步收紧,MediaStore.Images.Media.DATA这个曾经最常用的绝对路径列,在很多场景下已经不可靠甚至直接返回空。很多老教程还在教你用managedQuery,这个方法在 API 级别较高时已经被废弃,继续照抄会编译告警甚至运行异常。
ExifInterface 这边也不省心。早期它在android.media包下,后来迁移到androidx.exifinterface.media,两个类的行为、支持的标签、写入时机都有差异。更麻烦的是,EXIF 写入不是「设了属性就立刻生效」,你必须调用saveAttributes(),而且写入的图片文件必须可写。如果图片来自 MediaStore 且是只读 Uri,直接写会失败。
再叠加一层现实需求:现在很多相册应用会接入大模型做智能打标、自动生成图片描述、批量整理元数据。这时候模型调用的通道管理又成了新问题——每个模型一个 Key、一套 Base URL,散落在代码里,维护成本高。把模型调用统一走 TaoToken 的 Key/API 通道,可以让 MediaStore 检索 + EXIF 改写 + 模型辅助这条链路更干净。
这篇就按「查询 → 读取 → 改写 → 回读验证 → 通道连通性检查」的完整顺序,给你可复制的代码和配置,重点讲清楚每一步的验证动作和常见报错。
2. TaoToken 前置准备:统一 Key 通道与 Android 端接入配置
在动手写 MediaStore 和 ExifInterface 之前,先把模型调用的通道理顺。TaoToken 的作用是把多家模型的调用收敛到一套 Key 和一套 API 入口上,Android 端只需要认一个 Base URL 和一个 Key,不用为每个模型单独维护配置。
先拿到凭证。打开官网 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,注册后在控制台创建 API Key。控制台地址是 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite ,Key 管理页面在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 。创建完记得立刻复制,页面刷新后完整 Key 不再显示。
API 入口统一用 https://taotoken.net/api ,注意这个地址不带任何查询参数。Android 端不管是 OkHttp 还是 Retrofit,Base URL 都指向它。
模型 ID 怎么选?如果你只是做图片描述生成、EXIF 字段润色这类轻量任务,用通用对话模型就够;如果要做批量相册整理、长时间跑的 Agent 任务,建议走 Coding Plan,地址是 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 。想先验证模型通不通,可以用模型对话页面 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite 直接试。
Android 端接入时,Key 不要硬编码在 Java/Kotlin 源码里。推荐放在local.properties或gradle.properties,再通过BuildConfig注入。下面是一个gradle.properties的片段:
TAOTOKEN_BASE_URL=https://taotoken.net/api TAOTOKEN_API_KEY=sk-你的Key TAOTOKEN_MODEL_ID=你的模型ID然后在build.gradle里读取并生成 BuildConfig 字段:
android { defaultConfig { buildConfigField "String", "TAOTOKEN_BASE_URL", "\"${project.findProperty('TAOTOKEN_BASE_URL')}\"" buildConfigField "String", "TAOTOKEN_API_KEY", "\"${project.findProperty('TAOTOKEN_API_KEY')}\"" buildConfigField "String", "TAOTOKEN_MODEL_ID", "\"${project.findProperty('TAOTOKEN_MODEL_ID')}\"" } }这样代码里用BuildConfig.TAOTOKEN_BASE_URL就能拿到入口,换环境只改配置文件。接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite ,里面有请求格式和鉴权头的说明,建议对照着看一遍。
有一点要提醒:TaoToken 是模型调用的统一通道,不是让你拿它替代 Android 编辑器或构建工具。它的定位是收敛 API 调用,MediaStore 和 ExifInterface 该写的代码一行都不能少。
3. 可复制配置:MediaStore 查询与 ExifInterface 写入片段
这一节给两段能直接用的代码。第一段是 MediaStore 查询,第二段是 ExifInterface 读写。两段都按现代 Android 的写法来,避开废弃 API。
先看 MediaStore 查询。核心是构造列数组、指定 Uri、用 ContentResolver 查询、拿 Cursor 遍历。注意MediaStore.Images.Media.DATA在新版本上可能拿不到真实路径,所以更稳的做法是拿_ID拼 Content Uri,再用输入流读取。
import android.content.ContentResolver; import android.content.ContentUris; import android.database.Cursor; import android.net.Uri; import android.provider.MediaStore; public class MediaQueryHelper { public static final String[] IMAGE_COLUMNS = new String[]{ MediaStore.Images.Media._ID, MediaStore.Images.Media.DISPLAY_NAME, MediaStore.Images.Media.TITLE, MediaStore.Images.Media.DATE_ADDED, MediaStore.Images.Media.SIZE }; public static Cursor queryRecentImages(ContentResolver resolver, long sinceSeconds) { String selection = MediaStore.Images.Media.DATE_ADDED + " > ?"; String[] selectionArgs = new String[]{ String.valueOf(sinceSeconds) }; String orderBy = MediaStore.Images.Media.DATE_ADDED + " ASC"; return resolver.query( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, IMAGE_COLUMNS, selection, selectionArgs, orderBy ); } public static Uri buildImageUri(long id) { return ContentUris.withAppendedId( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id); } }遍历 Cursor 的时候,用getColumnIndexOrThrow拿索引,别用硬编码数字:
Cursor cursor = MediaQueryHelper.queryRecentImages(getContentResolver(), oneHourAgo); if (cursor != null) { int idCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID); int nameCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME); while (cursor.moveToNext()) { long id = cursor.getLong(idCol); String name = cursor.getString(nameCol); Uri uri = MediaQueryHelper.buildImageUri(id); // 用 uri 打开输入流做后续处理 } cursor.close(); }再看 ExifInterface。用 androidx 版本,读和写分开。读的时候注意标签名,写的时候必须saveAttributes()。
import androidx.exifinterface.media.ExifInterface; import java.io.InputStream; import java.io.OutputStream; public class ExifHelper { public static String readDescription(ContentResolver resolver, Uri uri) throws Exception { try (InputStream in = resolver.openInputStream(uri)) { if (in == null) return null; ExifInterface ei = new ExifInterface(in); return ei.getAttribute(ExifInterface.TAG_IMAGE_DESCRIPTION); } } public static void writeDescription(ContentResolver resolver, Uri uri, String desc) throws Exception { try (InputStream in = resolver.openInputStream(uri)) { if (in == null) throw new IllegalStateException("input stream null"); ExifInterface ei = new ExifInterface(in); ei.setAttribute(ExifInterface.TAG_IMAGE_DESCRIPTION, desc); ei.setAttribute(ExifInterface.TAG_SOFTWARE, "MyGalleryApp"); ei.saveAttributes(); } } }注意saveAttributes()在从 InputStream 构造的 ExifInterface 上,写入行为依赖底层实现。更稳妥的方式是拿到可写的文件路径或使用openFileDescriptor配合FileOutputStream。如果你的图片 Uri 来自 MediaStore 且应用没有写权限,写入会抛异常,这时候需要走MediaStore.createWriteRequest申请用户授权。
如果你用 Cline MCP 或 Claude Code 这类工具辅助开发,配置里同样要写全三件套:Base URL 填https://taotoken.net/api,Key 填你的 API Key,Model ID 填你选的模型。Claude Code 的接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite ,里面有 settings 配置示例。Codex 的auth.json也是同样的三件套逻辑,Base URL、Key、Model ID 一个都不能少。
4. 验证请求与成功结果:查询比对、EXIF 回读、通道连通性
代码写完不代表链路通了。这一节给你三个验证动作,逐个做一遍。
第一个验证:MediaStore 查询结果比对。先跑一次查询,把 Cursor 的getCount()打出来,再手动用系统相册数一下最近一小时的图片数量,看是否一致。如果数量对不上,检查DATE_ADDED的单位——它是秒级时间戳,不是毫秒。很多人在这里踩坑,传了毫秒值导致查询结果为空。
long oneHourAgo = System.currentTimeMillis() / 1000 - 3600; Cursor cursor = MediaQueryHelper.queryRecentImages(getContentResolver(), oneHourAgo); Log.d("MediaCheck", "count=" + (cursor == null ? -1 : cursor.getCount()));第二个验证:EXIF 字段回读。写入之后立刻重新读一遍,确认值真的变了。不要只信setAttribute的返回值,它只表示内存里设成功了,不代表落盘成功。
ExifHelper.writeDescription(resolver, uri, "测试描述-2024"); String back = ExifHelper.readDescription(resolver, uri); Log.d("ExifCheck", "readBack=" + back);如果回读是 null 或者还是旧值,先检查文件是否可写,再检查是不是写到了缓存副本上。有些图片库在展示时会生成缩略图缓存,你改的是缓存不是原图。
第三个验证:TaoToken 通道连通性。用 OkHttp 发一个最小请求,确认 Base URL、Key、Model ID 三件套都对。
OkHttpClient client = new OkHttpClient(); MediaType JSON = MediaType.parse("application/json; charset=utf-8"); String body = "{\"model\":\"" + BuildConfig.TAOTOKEN_MODEL_ID + "\"," + "\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}]}"; Request request = new Request.Builder() .url(BuildConfig.TAOTOKEN_BASE_URL + "/v1/chat/completions") .addHeader("Authorization", "Bearer " + BuildConfig.TAOTOKEN_API_KEY) .post(RequestBody.create(body, JSON)) .build(); client.newCall(request).enqueue(new Callback() { @Override public void onFailure(Call call, IOException e) { Log.e("TaoTokenCheck", "fail", e); } @Override public void onResponse(Call call, Response response) throws IOException { Log.d("TaoTokenCheck", "code=" + response.code()); if (response.body() != null) { Log.d("TaoTokenCheck", response.body().string()); } } });成功的话你会看到 HTTP 200,返回体里有choices数组。如果返回 401,说明 Key 不对或没带上;如果返回 404,检查 Base URL 后面拼的路径对不对。模型对话页面 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite 可以对照着确认模型 ID 是否有效。
三个验证都过了,说明「MediaStore 查询 → EXIF 改写 → 模型通道」这条链路是通的。接下来把模型返回的描述写回 EXIF 的ImageDescription,就完成了闭环。
5. 本篇常见错排查:401、local proxy failed、reading choices、OAuth
这一节把实际会撞到的报错列出来,对照着改。
401 Unauthorized。最常见的原因是 Key 没带对。检查Authorization头是不是Bearer sk-xxx格式,中间有没有多余空格。还有一种情况是 Key 复制时带了换行符,Android 端拼接头的时候会出问题。建议在BuildConfig注入后先trim()一下。如果确认 Key 没问题还是 401,去 API Keys 页面 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 看看 Key 是不是被禁用或过期了。
local proxy failed。这个报错通常出现在你本地配了代理工具,但代理没启动或者端口不对。Android 模拟器访问宿主机网络时,localhost指向的是模拟器自己,不是你的电脑。如果你在模拟器里跑,Base URL 不能写localhost,要用10.0.2.2映射到宿主机。不过更推荐的做法是直接用 TaoToken 的公网入口https://taotoken.net/api,不依赖本地代理,省掉这层麻烦。
reading choices 报错。这个一般是在解析返回体时,choices字段不存在或结构不对。先打印完整返回体看看。常见原因是请求体里model字段填错了,或者messages格式不对。还有一种情况是返回了错误信息但你直接按成功结构解析,导致空指针。解析前先判断response.code()和返回体里有没有error字段。
OAuth 相关报错。如果你用 Claude Code 或类似工具接入,可能会遇到 OAuth 流程的问题。这类工具通常支持 API Key 和 OAuth 两种鉴权方式,如果你走的是 Key 方式,就不要触发 OAuth 流程。检查配置文件里是不是同时存在两种鉴权配置,导致冲突。Claude Code 的接入文档 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 里有明确的配置说明,照着改就行。
EXIF 写入后回读不变。除了前面说的缓存问题,还有一个原因是图片格式不支持。ExifInterface 对 JPEG 支持最好,PNG 和 WebP 的支持有限,某些标签写不进去。如果你的图片是 PNG,先转成 JPEG 再试。
MediaStore 查询返回空 Cursor。检查权限。Android 13 之后读媒体文件需要READ_MEDIA_IMAGES,之前是READ_EXTERNAL_STORAGE。权限没申请或者用户拒绝了,查询会返回空。另外EXTERNAL_CONTENT_URI在有些设备上对应的是外部存储,如果图片存在内部存储,要换INTERNAL_CONTENT_URI。
6. 把模型调用接进相册链路:统一通道下的长期编码建议
链路跑通之后,你可以把模型调用真正用起来。比如遍历 MediaStore 查到的图片,把每张图的 EXIF 信息(拍摄时间、设备型号、已有描述)拼成 prompt,发给模型生成一段更友好的描述,再写回ImageDescription。这样相册里的图片就有了可搜索的语义标签。
批量处理的时候注意两点。一是控制并发,别一次性发几百个请求,容易触发限流。建议用队列,每次处理几张,处理完再取下一批。二是做好失败重试,网络抖动或模型超时都可能发生,重试时带上指数退避。
如果你要长期跑这类任务,Coding Plan https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 比按次调用更划算,适合 Agent 类的持续任务。接入文档 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 里有完整的参数说明,遇到不确定的字段先去查一遍再写代码。
最后说一个实用技巧:把 MediaStore 查询、EXIF 读写、模型调用封装成三个独立的类,中间用数据对象传递。这样任何一环出问题,你都能单独替换或 mock,不用动整条链路。我试过在排查 EXIF 写入失败时,就是靠这种分层快速定位到是文件权限问题而不是模型通道问题。