☰
Android 10+ 从 Uri 到真实路径:TaoToken 场景下的文件读取兼容方案
2026/10/9 17:19:30 网站建设 项目流程

1. Android 10+ 分区存储下 Uri 转真实路径到底难在哪

如果你在 Android 10 及以上做文件读取,大概率遇到过这个场景:用户从相册选了一张图,或者从系统文件选择器挑了一个 PDF,回调给你的只有一个content://开头的 Uri。你想拿它做上传、压缩、OCR、传给大模型做多模态解析,结果一调用new File(uri.getPath())就直接崩,或者拿到一个根本不存在的路径。

这不是你代码写错了,而是 Android 10 引入分区存储(Scoped Storage)之后,系统从设计上就不让你随便拿外部文件的绝对路径了。content://这种 Uri 背后可能是 MediaStore 管理的媒体库条目,也可能是 SAF(Storage Access Framework)授权的文档,还可能是某个应用通过 FileProvider 暴露出来的临时共享文件。它们的共同点是:你只有读的权限,没有真实路径。

那实际开发里怎么办?核心思路只有两条:能直接转 File 的(file://开头的沙盒内文件)直接转;不能转的(content://)就通过ContentResolver.openInputStream()把内容复制到应用自己的沙盒缓存目录,再对复制出来的 File 做后续操作。这也是目前 Android 10+ 最稳、兼容性最好的方案。

这篇文章我会给你一套可以直接复制的 Kotlin 工具类,覆盖 SAF 和 MediaStore 两套分支,附上权限声明、真机验证步骤,以及我踩过的几个典型报错。另外,如果你后续要把这些文件接到大模型做对话或编码任务,我会顺带说下 TaoToken 的接入方式,让文件读取和模型调用串起来。

适合谁看:正在做 Android 10+ 文件选择、上传、图片处理,被content://卡住的 Android 开发者;以及想把本地文件喂给大模型做多模态或代码分析的工程师。

2. 前置准备:权限声明与 TaoToken 接入配置

在写工具类之前,先把两件事理清楚:一是 Android 侧的权限和依赖,二是如果你要把读到的文件接到模型侧,TaoToken 的 Key 和 Base URL 怎么配。

2.1 Android 权限声明

Android 10 之后,读取媒体文件不再需要READ_EXTERNAL_STORAGE就能通过 MediaStore 拿到自己创建的媒体,但读取其他应用创建的媒体仍然需要权限。Android 13(API 33)开始又拆成了细分的媒体权限。所以AndroidManifest.xml里建议这样声明:

<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" /> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> <uses-permission android:name="android.permission.READ_MEDIA_VIDEO" /> <uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />

注意READ_EXTERNAL_STORAGE加了maxSdkVersion="32",因为 33 之后这个权限对媒体读取已经失效,留着反而会在部分机型上触发审核提示。运行时请求时按版本分支:

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) }

如果你用的是 SAF 文件选择器(ACTION_OPEN_DOCUMENT),其实不需要申请存储权限,因为用户通过选择器授权后,系统会给你的 Uri 附带临时读权限。这也是 SAF 比直接读 MediaStore 更省心的原因。

2.2 TaoToken 侧配置

文件读出来之后,很多场景是要送给模型处理的,比如把图片转成 base64 做视觉理解,或者把代码文件丢给模型做分析。TaoToken 的接入信息如下:

  • 官网入口:https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=
  • API Base URL:https://taotoken.net/api
  • 模型对话入口:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite
  • 接入文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite

如果你是在 Android 端直接调模型,可以用 OkHttp 发请求,Base URL 填https://taotoken.net/api,Header 里带Authorization: Bearer <你的Key>。Key 在 API Keys 页面生成。这里要提醒一句:Android 客户端直接放 Key 有泄露风险,生产环境建议走自己的后端中转,客户端只跟后端通信。

如果你是在电脑上做长期编码或 Agent 任务,可以用 Coding Plan,入口在 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite 。这块跟 Android 文件读取是两条线,但如果你要把 Android 项目里的文件批量喂给模型做重构,两者会配合使用。

3. 可复制配置:Kotlin 工具类与 SAF/MediaStore 分支

这一节是核心,直接给你能跑的代码。整体结构是一个UriToFileHelper工具类,内部按 scheme 分支:file直接转,content走复制到沙盒。

3.1 完整 Kotlin 工具类

import android.content.ContentResolver import android.content.Context import android.net.Uri import android.os.Build import android.provider.OpenableColumns import android.webkit.MimeTypeMap import androidx.annotation.RequiresApi import java.io.File import java.io.FileOutputStream import java.io.IOException import java.util.Locale import kotlin.random.Random object UriToFileHelper { /** * 统一入口:把 Uri 转成可读的 File * - file:// 直接转 * - content:// 复制到 cacheDir 后返回 */ fun uriToFile(context: Context, uri: Uri?): File? { if (uri == null) return null return when (uri.scheme) { ContentResolver.SCHEME_FILE -> { uri.path?.let { File(it) } } ContentResolver.SCHEME_CONTENT -> { copyContentUriToCache(context, uri) } else -> null } } /** * content:// 复制到沙盒缓存目录 * 文件名优先取 OpenableColumns.DISPLAY_NAME,取不到再用时间戳兜底 */ private fun copyContentUriToCache(context: Context, uri: Uri): File? { val resolver = context.contentResolver val displayName = queryDisplayName(resolver, uri) ?: buildFallbackName(resolver, uri) val targetDir = File(context.cacheDir, "uri_cache").apply { if (!exists()) mkdirs() } val targetFile = File(targetDir, displayName) return try { resolver.openInputStream(uri)?.use { input -> FileOutputStream(targetFile).use { output -> input.copyTo(output) } } if (targetFile.exists() && targetFile.length() > 0) targetFile else null } catch (e: IOException) { e.printStackTrace() null } catch (e: SecurityException) { e.printStackTrace() null } } /** * 查询原始文件名,可能耗时,放在 IO 线程调用 */ private fun queryDisplayName(resolver: ContentResolver, uri: Uri): String? { return try { resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor -> if (cursor.moveToFirst()) { val idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) if (idx >= 0) cursor.getString(idx) else null } else null } } catch (e: Exception) { null } } /** * 兜底文件名:时间戳 + 随机数 + 从 MimeType 推断的扩展名 */ private fun buildFallbackName(resolver: ContentResolver, uri: Uri): String { val ext = MimeTypeMap.getSingleton() .getExtensionFromMimeType(resolver.getType(uri)) ?.lowercase(Locale.ROOT) ?: "bin" val stamp = System.currentTimeMillis() val rand = Random.nextInt(0, 9999) return "${stamp}_$rand.$ext" } }

这段代码有几个关键点值得说清楚。

第一,queryDisplayName会走一次ContentResolver.query,这个操作在部分机型上比较慢,尤其是云盘类 Provider。所以我在注释里标了「放在 IO 线程调用」。如果你只是要个临时文件做上传,其实可以跳过查询,直接用兜底名,省一次 IPC。

第二,复制目标放在cacheDir/uri_cache子目录,而不是直接扔在cacheDir根目录。这样方便你后续统一清理,不会跟其他缓存混在一起。系统在存储紧张时会自动清理cacheDir,所以别把需要长期保留的文件放这里,要长期保留就放filesDir。

第三,openInputStream返回 null 的情况要处理。有些 Provider 在权限失效后会返回 null,这时候直接返回 null,让上层决定怎么提示用户。

3.2 SAF 分支:ACTION_OPEN_DOCUMENT

SAF 选择器返回的 Uri 是content://com.android.providers.downloads.documents/...这类,用上面的工具类直接能转。启动选择器的代码:

val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { addCategory(Intent.CATEGORY_OPENABLE) type = "*/*" // 或 "image/*"、"application/pdf" } launcher.launch(intent)

回调里拿到 Uri 后,建议立刻调用takePersistableUriPermission,否则应用重启后权限就没了:

contentResolver.takePersistableUriPermission( uri, Intent.FLAG_GRANT_READ_URI_PERMISSION )

注意:ACTION_OPEN_DOCUMENT才支持持久化权限,ACTION_GET_CONTENT不支持。如果你需要跨重启访问同一个文件,必须用前者。

3.3 MediaStore 分支:相册选图

从相册选图通常用ACTION_PICK或PickVisualMedia(Android 13+ 推荐)。返回的 Uri 是content://media/external/images/media/12345这种。用工具类同样能转,但有一点要注意:MediaStore 的 Uri 在 Android 10+ 上通过openInputStream读取是没问题的,但如果你试图用MediaStore.Images.Media.DATA字段去拿路径,会拿到 null 或者抛异常。这个字段在 Android 10 之后已经被标记为废弃,不要再用了。

如果你确实需要原始文件路径(比如某些第三方 SDK 强制要求路径),唯一的办法就是复制到沙盒,把复制后的 File 路径给它。这也是为什么工具类里统一走复制逻辑。

3.4 配置片段:settings 与依赖

如果你在项目里用 Gradle Kotlin DSL,build.gradle.kts里确保有这些:

android { compileSdk = 34 defaultConfig { minSdk = 24 targetSdk = 34 } } dependencies { implementation("androidx.core:core-ktx:1.12.0") implementation("androidx.activity:activity-ktx:1.8.2") }

activity-ktx是为了用registerForActivityResult,比旧的onActivityResult更安全,不会因为生命周期问题丢回调。

4. 验证请求:真机跑通 Uri 转 File 的完整流程

代码写完了,得在真机上验证。我用一台 Android 13 的机器和一台 Android 10 的机器分别测过,下面说下步骤和预期结果。

4.1 验证步骤

第一步,在 Activity 里注册选择器:

private val pickImage = registerForActivityResult( ActivityResultContracts.PickVisualMedia() ) { uri -> if (uri != null) { lifecycleScope.launch(Dispatchers.IO) { val file = UriToFileHelper.uriToFile(this@MainActivity, uri) withContext(Dispatchers.Main) { if (file != null) { tvResult.text = "路径: ${file.absolutePath}\n大小: ${file.length()} 字节" } else { tvResult.text = "转换失败" } } } } }

第二步,触发选择:

pickImage.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))

第三步,选一张相册里的图,观察tvResult的输出。预期是打印出类似/data/user/0/你的包名/cache/uri_cache/1712345678_1234.jpg的路径,并且文件大小跟原图一致。

第四步,验证文件可读。拿到 File 后,用BitmapFactory.decodeFile(file.absolutePath)解码,能出图就说明复制成功。

4.2 验证 SAF 文档

把上面的PickVisualMedia换成ActivityResultContracts.OpenDocument(),type 传arrayOf("application/pdf"),选一个 PDF。预期输出路径在uri_cache下,扩展名是pdf,文件大小跟原文件一致。

4.3 验证 file:// 分支

如果你有应用内部生成的文件,比如拍照后存在filesDir里的,它的 Uri 是file://开头。用Uri.fromFile(file)构造,传给工具类,预期直接返回原 File,不走复制。

4.4 把文件接到 TaoToken 做验证

如果你想验证文件读取后能顺利送给模型,可以拿读到的图片转 base64,发到 TaoToken 的模型对话接口。请求体大致是:

{ "model": "gpt-4o", "messages": [ { "role": "user", "content": [ {"type": "text", "text": "描述这张图片"}, {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,<你的base64>"}} ] } ] }

Base URL 用https://taotoken.net/api,Header 带Authorization: Bearer <Key>。如果返回正常,说明从 Uri 读取到模型调用的整条链路是通的。模型对话入口在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite ,Key 在那里生成。

5. 本篇常见错排查:401、local proxy failed、reading choices、OAuth

这一节把我在真机和接入过程中遇到的典型报错列出来,对照着排查。

5.1 401 Unauthorized

这个报错通常出现在调 TaoToken 接口时。原因一般是 Key 没带、带错,或者 Header 格式不对。正确格式是:

Authorization: Bearer sk-xxxxxxxx

注意Bearer和 Key 之间有一个空格。另外检查 Key 是不是复制时带了换行或空格。如果你用的是 OkHttp,可以加个拦截器打印实际发出的 Header,确认无误。

5.2 local proxy failed

这个报错一般出现在你本地配了代理工具,但代理没启动或者端口不对。Android 模拟器访问宿主机需要特殊地址,真机则要保证手机和电脑在同一网络。如果你在 OkHttp 里手动设了Proxy,检查 host 和 port 是否正确。最省事的做法是先把代理配置去掉,直连测试。

5.3 reading choices 相关报错

如果你在解析模型返回的 JSON 时报reading 'choices'之类的错,说明返回体结构跟你预期的不一样。常见原因是请求失败但你没检查 HTTP 状态码,直接去解析 body。正确做法是先判断response.isSuccessful,失败时打印response.body?.string()看真实错误信息。TaoToken 的返回格式跟主流接口一致,choices[0].message.content是文本内容。

5.4 OAuth 相关报错

如果你在用 Claude Code 或类似工具接入,遇到 OAuth 报错,通常是认证方式没选对。这类工具一般支持 API Key 和 OAuth 两种模式,用 TaoToken 的话选 API Key 模式,Base URL 填https://taotoken.net/api。如果你在配置文件里同时写了 OAuth 和 API Key,可能会冲突,删掉 OAuth 那段。

5.5 Uri 转 File 返回 null

这是 Android 侧最常见的。排查顺序:先看uri.scheme是什么,如果是content,看openInputStream是否返回 null。返回 null 一般是权限问题,检查有没有调takePersistableUriPermission,或者用户是不是撤销了授权。另外,如果 Uri 来自其他应用通过 FileProvider 暴露的文件,而那个应用已经卸载,也会读不到。

5.6 复制出来的文件大小为 0

这种情况一般是openInputStream读到了空流,或者复制过程中异常被吞了。检查input.copyTo(output)有没有抛异常,以及目标目录有没有写权限。cacheDir一般不需要额外权限,但如果你改成了外部存储路径,就要注意分区存储的限制。

5.7 三件套配置对照

如果你在 Android 项目里集成模型调用,或者用 Cline、Codex 这类工具,配置时记住三件套:Base URL、Key、Model ID。以 Codex 的auth.json为例:

{ "base_url": "https://taotoken.net/api", "api_key": "sk-你的Key", "model": "gpt-4o" }

三个字段缺一不可。Base URL 不要带末尾斜杠,Model ID 要跟平台支持的名称一致。如果你用 CC Switch 或 Cline MCP,配置项名称可能不同,但核心就是这三样。

6. 把文件读取接到模型工作流:API Keys 与文档入口

文件读出来只是第一步,真正产生价值是在后续处理。如果你只是偶尔验证一下模型能力,用模型对话页面就够了;如果是长期做编码或 Agent 任务,建议用 Coding Plan。

具体入口我整理一下,方便你按需取用:

  • 生成 API Key、快速验证模型对话:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite
  • 查看接入文档、参数说明:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite
  • 长期编码、Agent 任务用 Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite
  • 控制台管理:https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite

回到 Android 文件读取本身,最后给你一个实用建议:工具类里的uri_cache目录要定期清理。可以在 Application 启动时或者页面退出时删掉超过一定时间的缓存文件,避免用户手机存储被占满。清理逻辑很简单:

fun clearUriCache(context: Context, maxAgeMs: Long = 24 * 60 * 60 * 1000L) { val dir = File(context.cacheDir, "uri_cache") val now = System.currentTimeMillis() dir.listFiles()?.forEach { file -> if (now - file.lastModified() > maxAgeMs) file.delete() } }

这样既保证了文件读取的兼容性,又不会留下存储垃圾。整套方案在 Android 10 到 14 上都验证过,你可以直接拿去用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询