免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

Android 获取手机视频、图片列表:TaoToken 统一 Key 接入实战

Android 获取手机视频、图片列表:TaoToken 统一 Key 接入实战 1. Android 读取本地媒体库的真实痛点为什么你的视频图片列表总是拿不全做 Android 相册类、短视频剪辑类、文件管理类应用时读取手机本地的视频和图片列表几乎是绕不开的第一步。很多开发者第一次写这块逻辑直觉就是遍历Environment.getExternalStorageDirectory()然后递归扫文件。这个思路在 Android 6.0 之前勉强能用但从 Android 10 引入分区存储Scoped Storage开始直接扫路径基本拿不到东西Android 11 之后更是被彻底收紧。你会在 Logcat 里看到一堆EACCES (Permission denied)或者干脆返回空列表。正确的做法是走MediaStore。它是 Android 系统提供的内容提供者ContentProvider系统在后台扫描完媒体文件后会把索引写进数据库我们只需要查询这个数据库就能拿到视频、图片、音频的元数据。相比自己扫盘MediaStore 有几个明显优势查询速度快、不用关心文件真实路径、系统会自动维护索引。代价是权限模型变复杂了不同 Android 版本要申请的权限不一样查询用的字段也有差异。这篇内容聚焦的是完整链路从权限声明、MediaStore 查询、Cursor 解析到把数据组装成列表渲染出来。同时我会把 TaoToken 统一 Key 接入的部分串进来——因为很多相册类应用在拿到本地列表之后需要调用云端能力做图片理解、视频摘要、内容审核这时候一个统一的 API 通道能省掉大量鉴权对接工作。TaoToken 在这里扮演的角色是统一鉴权网关你只需要一个 Key 就能调用多种模型能力不用为每个服务商单独维护密钥。适合谁看正在做相册、短视频、文件管理类 App 的 Android 开发者已经写过 MediaStore 查询但列表数据不全、排序错乱、权限反复被拒的同学以及想把本地媒体列表和云端 AI 能力打通的工程师。下面从环境准备开始一步步把代码跑通。2. TaoToken 统一 Key 前置准备一个 Key 打通媒体列表后的云端调用在写 MediaStore 查询之前先把云端调用的前置工作做掉这样后面本地列表拿到数据后可以直接接上。TaoToken 的核心价值是把多家模型服务的鉴权收敛成一个 Key你不需要在 App 里维护一堆不同厂商的 secret也不用担心某个服务商的密钥格式和签名方式不一样。第一步是拿到 API Key。访问控制台地址创建密钥https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite登录后在 API Keys 页面点创建复制生成的 Key。这个 Key 就是后面所有请求的凭证。注意 Key 只在创建时完整显示一次记得存好。第二步是确认接入地址。TaoToken 的 API 基础地址是https://taotoken.net/api所有模型调用都走这个 Base URL具体路径根据你要用的能力拼接。比如对话类接口、图片理解接口路径不同但域名和鉴权方式一致。第三步是选模型。如果你只是做图片内容识别选一个支持视觉输入的模型如果做视频摘要需要先抽帧再送图。模型 ID 在文档里能查到接入时作为请求参数传进去。文档地址https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite这里有个关键点TaoToken 的鉴权头是标准的Authorization: Bearer 你的Key和 OpenAI 兼容格式一致。这意味着你在 Android 端用 OkHttp 或 Retrofit 发请求时拦截器里统一加这个头就行不用为每个接口写不同的签名逻辑。我建议在项目里把 Key 放在local.properties或者 BuildConfig 里不要硬编码进源码提交到仓库。Android 端读取方式// build.gradle.kts android { buildConfigField(String, TAOTOKEN_KEY, \${project.findProperty(TAOTOKEN_KEY)}\) buildConfigField(String, TAOTOKEN_BASE, \https://taotoken.net/api\) }然后在local.properties里加一行TAOTOKEN_KEY你的Key。这样 Key 不会进版本控制团队协作时各自配置。如果你后续要做长期的编码类或 Agent 类任务可以考虑 Coding Plan它适合需要持续调用、批量处理的场景https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite前置准备到这里就够了。核心就三样Base URL、Key、Model ID。记住这三件套后面无论调哪个能力都是这套组合。3. 可复制的 MediaStore 查询配置权限声明与视频图片列表代码这一节是重头戏把权限、查询、解析三块拆开讲。先看权限声明这是最容易踩坑的地方因为 Android 版本差异太大。在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 /READ_MEDIA_IMAGES、READ_MEDIA_VIDEO、READ_MEDIA_AUDIO是 Android 13API 33引入的细分权限分别对应图片、视频、音频。Android 12 及以下用READ_EXTERNAL_STORAGE通过maxSdkVersion32限制它只在旧版本生效。这样声明后系统会根据运行版本自动选择正确的权限。运行时申请权限的代码private fun requestMediaPermissions() { 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) } ActivityCompat.requestPermissions(this, permissions, REQ_MEDIA) }注意 Android 14API 34又加了READ_MEDIA_VISUAL_USER_SELECTED用于部分授权场景。如果你的应用只需要用户选中的那几张图可以申请这个权限用户体验更好。但如果你要做完整的相册列表还是申请完整权限。接下来是查询代码。先定义数据模型data class MediaItem( val id: Long, val path: String, val name: String, val group: String, val size: Long, val lastModified: Long, val duration: Long 0L, val isVideo: Boolean false )查询视频列表的方法fun queryVideos(context: Context): ListMediaItem { val result mutableListOfMediaItem() val projection arrayOf( MediaStore.Video.Media._ID, MediaStore.Video.Media.DATA, MediaStore.Video.Media.DISPLAY_NAME, MediaStore.Video.Media.SIZE, MediaStore.Video.Media.DATE_MODIFIED, MediaStore.Video.Media.DURATION, MediaStore.Video.Media.BUCKET_DISPLAY_NAME ) val sortOrder ${MediaStore.Video.Media.DATE_MODIFIED} DESC val collection if (Build.VERSION.SDK_INT Build.VERSION_CODES.Q) { MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL) } else { MediaStore.Video.Media.EXTERNAL_CONTENT_URI } context.contentResolver.query(collection, projection, null, null, sortOrder)?.use { cursor - val idCol cursor.getColumnIndexOrThrow(MediaStore.Video.Media._ID) val dataCol cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DATA) val nameCol cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DISPLAY_NAME) val sizeCol cursor.getColumnIndexOrThrow(MediaStore.Video.Media.SIZE) val dateCol cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DATE_MODIFIED) val durCol cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DURATION) val bucketCol cursor.getColumnIndexOrThrow(MediaStore.Video.Media.BUCKET_DISPLAY_NAME) while (cursor.moveToNext()) { result.add( MediaItem( id cursor.getLong(idCol), path cursor.getString(dataCol) ?: , name cursor.getString(nameCol) ?: , group cursor.getString(bucketCol) ?: 未知, size cursor.getLong(sizeCol), lastModified cursor.getLong(dateCol), duration cursor.getLong(durCol), isVideo true ) ) } } return result }图片列表同理把MediaStore.Video换成MediaStore.Images去掉DURATION字段即可。这里有个细节DATA字段在 Android 10 之后虽然还能读但官方已经不推荐用它来访问文件因为分区存储下路径可能不可用。更稳妥的做法是用_ID拼ContentUris.withAppendedId()得到 Uri然后用ContentResolver.openInputStream()读取。但如果你只是展示缩略图用DATA路径配合 Glide 加载通常也能工作。关于排序excerpt 里用的是DATE_MODIFIED DESC这是按修改时间倒序最新的排前面。如果你想要按拍摄时间排用DATE_TAKEN。注意DATE_MODIFIED单位是秒不是毫秒展示时要乘 1000。分组逻辑用BUCKET_DISPLAY_NAME直接拿到文件夹名比从路径里File(path).parentFile.name更可靠因为分区存储下路径可能拿不到。按 group 聚合后就能做「按文件夹分组」的相册界面。4. 验证请求与成功结果接口连通性检查与列表渲染本地列表拿到后先验证一下数据是否正确。最简单的办法是打日志val videos queryVideos(this) Log.d(MediaScan, 视频总数: ${videos.size}) videos.take(5).forEach { Log.d(MediaScan, 名称${it.name} 大小${it.size} 时长${it.duration} 分组${it.group}) }跑起来后看 Logcat如果数量和你手机相册里的视频数对得上说明查询没问题。如果数量偏少检查是不是权限没给全或者查询的 collection 用错了。接下来验证 TaoToken 接口连通性。写一个简单的 OkHttp 请求suspend fun checkTaoToken(): Boolean withContext(Dispatchers.IO) { val client OkHttpClient() val body { model: 你的模型ID, messages: [{role: user, content: ping}], max_tokens: 10 } .trimIndent().toRequestBody(application/json.toMediaType()) val request Request.Builder() .url(${BuildConfig.TAOTOKEN_BASE}/v1/chat/completions) .addHeader(Authorization, Bearer ${BuildConfig.TAOTOKEN_KEY}) .addHeader(Content-Type, application/json) .post(body) .build() try { client.newCall(request).execute().use { resp - Log.d(TaoToken, code${resp.code} body${resp.body?.string()}) resp.isSuccessful } } catch (e: Exception) { Log.e(TaoToken, 请求失败, e) false } }如果返回 200 并且 body 里有正常的响应内容说明 Key 和 Base URL 都对。如果返回 401检查 Key 是否复制完整、有没有多余空格。如果返回 404检查路径是不是/v1/chat/completions不同模型的路径可能不同以文档为准。列表渲染部分用 RecyclerView 配合 Glide 加载缩略图class MediaAdapter(private val items: ListMediaItem) : RecyclerView.AdapterMediaAdapter.VH() { class VH(view: View) : RecyclerView.ViewHolder(view) { val thumb: ImageView view.findViewById(R.id.thumb) val name: TextView view.findViewById(R.id.name) } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH { val view LayoutInflater.from(parent.context) .inflate(R.layout.item_media, parent, false) return VH(view) } override fun onBindViewHolder(holder: VH, position: Int) { val item items[position] holder.name.text item.name val uri ContentUris.withAppendedId( if (item.isVideo) MediaStore.Video.Media.EXTERNAL_CONTENT_URI else MediaStore.Images.Media.EXTERNAL_CONTENT_URI, item.id ) Glide.with(holder.thumb) .load(uri) .centerCrop() .into(holder.thumb) } override fun getItemCount() items.size }用ContentUris.withAppendedId拼 Uri 比直接用文件路径更稳Glide 能直接加载 content:// 类型的 Uri。视频缩略图 Glide 也能处理它会自动取第一帧。实测下来这套流程在 Android 10 到 14 的机器上都能跑通。Android 13 以上记得申请细分权限否则查询返回空。Android 14 如果用户只给了部分授权列表里只会出现用户选中的媒体这是预期行为。5. 本篇常见错误排查401、local proxy failed、reading choices 报错对照这一节把实际开发中高频出现的报错列出来对照着排查。401 UnauthorizedTaoToken 返回 401 基本是 Key 的问题。检查三点Key 有没有复制完整前后不要有空格、请求头是不是Authorization: Bearer xxx格式、Key 有没有被禁用或过期。如果你把 Key 放在 BuildConfig 里确认local.properties里的值被正确读取了有时候 Gradle 同步没生效会导致读到空字符串。local proxy failed / connection refused这类错误通常是网络层的问题。先确认设备能正常访问外网然后检查 Base URL 有没有写错。TaoToken 的地址是https://taotoken.net/api注意是 https 不是 http路径不要多加斜杠。如果你在模拟器里跑确认模拟器的网络配置正常。另外检查 OkHttp 有没有设置超时默认超时较短网络慢的时候容易报连接失败建议设置connectTimeout(30, TimeUnit.SECONDS)。reading choices 报错 / JSON 解析失败这个错误说明请求发出去了服务端也返回了但返回结构和你解析的模型对不上。常见原因是模型 ID 写错了或者接口路径不对导致返回了错误信息而不是正常的 choices 结构。先把原始响应 body 打出来看不要直接反序列化。如果 body 里是{error: {...}}说明请求本身有问题如果是正常的{choices: [...]}检查你的数据类字段名和 JSON 是否匹配。MediaStore 查询返回空列表先确认权限申请成功了用checkSelfPermission检查。然后确认查询的 collection 对不对视频用MediaStore.Video.Media.EXTERNAL_CONTENT_URI图片用MediaStore.Images.Media.EXTERNAL_CONTENT_URI。Android 10 以上建议用getContentUri(MediaStore.VOLUME_EXTERNAL)。如果还是空可能是设备上确实没有媒体文件或者系统媒体库还没扫描完可以手动触发一次扫描。Cursor 字段索引为 -1getColumnIndex返回 -1 说明 projection 里没有这个字段。用getColumnIndexOrThrow会在字段缺失时直接抛异常方便定位。注意不同 Android 版本支持的字段不一样比如DURATION在视频和音频里有图片里没有。OAuth / 鉴权相关报错如果你用的是 Claude Code 或类似工具接入遇到 OAuth 报错检查配置文件里的 Base URL 和 Key 是否对应。Claude Code 的配置在~/.claude/settings.jsonCodex 的在~/.codex/auth.jsonCline 的在 MCP 配置里。这三件套Base URL、Key、Model ID任何一个写错都会导致鉴权失败。如果你用 CC Switch 管理多个配置确认当前激活的是正确的那个。排查的核心思路先看错误码401 查 Key404 查路径500 查服务端解析错误查响应结构。把原始请求和原始响应都打出来大部分问题一眼就能定位。6. 从本地列表到云端能力把 MediaStore 数据接上 TaoToken 的实用路径本地列表跑通之后下一步通常是把这些媒体数据用起来。相册类应用常见的需求是图片内容识别、视频摘要生成、敏感内容审核这些都需要调用云端模型。TaoToken 在这里的价值是让你不用为每个能力单独对接一家服务商一个 Key 走通所有调用。具体做法拿到MediaItem后用ContentResolver.openInputStream()读取图片字节转成 Base64 或者直接走 multipart 上传。请求发到 TaoToken 的对应接口鉴权头统一带Authorization: Bearer Key。返回结果解析后更新 UI。如果你想先验证模型效果可以到模型对话页面直接测试https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentchatutm_campaignrewrite在页面上传一张图片看看模型能不能正确识别内容确认效果后再写进代码。对于需要批量处理媒体文件的场景比如一次性分析几百张图片建议用 Coding Plan它的调用配额和并发更适合这种任务https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewriteAPI Key 管理入口在这里需要新建或轮换 Key 的时候用https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite接入文档里有各能力的详细参数说明路径和请求体格式都在里面https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite一个实用技巧在 Android 端做图片上传前先压缩避免大图导致请求超时。用BitmapFactory.Options设置inSampleSize做降采样或者用 Glide 的override()拿到压缩后的 Bitmap。视频的话先抽帧取第一帧或关键帧送图不要直接传视频文件。最后提醒一点MediaStore 查询和云端调用都要放在子线程Android 主线程不允许做网络请求和耗时查询。用 Kotlin 协程的话withContext(Dispatchers.IO)包起来就行。列表数据量大时记得分页不要一次性查几万条用LIMIT配合OFFSET或者用 Paging 库。
返回列表