ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Android 4.4+ 通过 Uri 获取图片/视频真实路径:TaoToken 配置与验证全流程

Android 4.4+ 通过 Uri 获取图片/视频真实路径:TaoToken 配置与验证全流程 1. 为什么 Android 4.4 之后content://拿不到真实路径如果你做过相册选图、拍照回传、视频上传这类功能大概率踩过这个坑onActivityResult里拿到的data.getData()是一个content://media/external/images/media/12345这样的 Uri你兴冲冲地调uri.getPath()结果得到一串没有意义的 ID或者干脆返回 null。想拿真实文件路径去上传、压缩、裁剪直接卡死在这一步。这个问题的根源在于 Android 4.4API 19引入的 Storage Access FrameworkSAF。在这之前系统相册、文件管理器返回的往往是file://开头的 UrigetPath()能直接拿到/storage/emulated/0/DCIM/xxx.jpg。4.4 之后出于权限隔离和沙箱安全的考虑系统改成了返回content://Uri它只是一个指向 ContentProvider 的引用不是文件系统里的真实路径。你没法直接把它当文件用必须通过ContentResolver去查询对应的数据列。更麻烦的是不同来源的content://Uri 背后是不同的 Provider从相册选图走的是 MediaProvider从「下载」里选文件走的是 DownloadsProvider从「文件」App 里选走的是 ExternalStorageProvider还有第三方网盘、微信文件选择器等等。每种 Provider 的 authority 不一样查询方式也不一样。这就是为什么网上那段UriUtils代码会被反复转载——它把这几类情况都覆盖了。这篇内容我会把 4.4 到 14 都兼容的解析方案完整拆开讲包括权限声明、ContentResolver查询骨架、真机验证步骤。同时结合 TaoToken 的统一 Key 配置把「解析路径 → 调用模型/上传接口」这条链路串起来让你拿到路径之后能直接进入下一步业务。适合正在做媒体上传、图片处理、AI 接口对接的 Android 开发者。2. TaoToken 前置统一 Key 与接入准备在讲路径解析之前先说清楚为什么这里要提 TaoToken。很多同学拿到图片/视频路径之后下一步就是调用多模态模型做识别、打标、内容审核或者走 Coding Plan 做批量处理脚本。如果每个模型都单独申请 Key、单独配 baseUrl管理起来很乱。TaoToken 的思路是用一个统一 Key 对接多个模型baseUrl 固定切换模型只改 model 字段。你需要先拿到一个 API Key。登录官网 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 进入控制台在 API Keys 页面创建一个新 Key。创建时建议按项目命名比如android-media-upload方便后续排查是哪个端在调用。拿到 Key 之后接入地址统一用 https://taotoken.net/api 注意这个地址不带任何查询参数。请求格式兼容 OpenAI 的 Chat Completions 规范所以你在 Android 端用 OkHttp 或者 Retrofit 都能直接发。配置项值说明baseUrlhttps://taotoken.net/api固定不带 UTMapiKey控制台创建建议按项目命名model按需选择多模态/文本模型切换只改这里请求头Authorization: Bearer标准 Bearer 认证如果你只是想先验证模型能不能通可以直接用模型对话页面发一条测试消息不用写代码。等路径解析和上传链路都通了再回到代码里集成。对于长期做编码、Agent 批量任务的场景可以了解下 Coding Plan它更适合高频调用和自动化流程。注意Key 不要硬编码在客户端代码里。Android APK 是可以被反编译的建议把 Key 放在自己的后端客户端只调自己的服务由后端转发到 TaoToken。如果确实要在端上直连做 Demo至少用local.propertiesBuildConfig注入别提交到 Git。3. 可复制配置权限声明与 UriUtils 完整实现3.1 AndroidManifest 权限声明先解决权限。4.4 到 14 的存储权限模型变化很大需要分版本处理。Android 10API 29引入了分区存储Android 13API 33又把读媒体权限拆成了READ_MEDIA_IMAGES、READ_MEDIA_VIDEO、READ_MEDIA_AUDIO。所以清单里要同时声明新旧权限运行时再按版本请求。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 / uses-permission android:nameandroid.permission.READ_MEDIA_VIDEO / uses-permission android:nameandroid.permission.READ_MEDIA_AUDIO / !-- 拍照场景Android 11 需要 -- uses-permission android:nameandroid.permission.CAMERA / !-- Android 10 及以下写外部存储保存拍照结果 -- uses-permission android:nameandroid.permission.WRITE_EXTERNAL_STORAGE android:maxSdkVersion28 / application android:requestLegacyExternalStoragetrue ... /application /manifestrequestLegacyExternalStoragetrue这个属性只对 Android 10 有效用来临时退回旧存储模型方便过渡。Android 11 及以上会忽略它。3.2 运行时权限请求清单声明只是第一步运行时还要动态申请。下面这段按版本分流你可以直接放进 Activity 或 Fragment。private fun requestMediaPermission() { val permissions when { Build.VERSION.SDK_INT Build.VERSION_CODES.TIRAMISU - { arrayOf( Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_VIDEO ) } Build.VERSION.SDK_INT Build.VERSION_CODES.M - { arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE) } else - emptyArray() } if (permissions.isEmpty()) { openGallery() return } val denied permissions.filter { ContextCompat.checkSelfPermission(this, it) ! PackageManager.PERMISSION_GRANTED } if (denied.isEmpty()) { openGallery() } else { ActivityCompat.requestPermissions(this, denied.toTypedArray(), REQ_MEDIA) } }3.3 UriUtils 核心解析骨架这是整篇的重点。下面这段代码覆盖了 ExternalStorageProvider、DownloadsProvider、MediaProvider 以及普通 content/file 四类情况兼容 4.4 到 14。object UriUtils { fun getPath(context: Context, uri: Uri): String? { val isKitKat Build.VERSION.SDK_INT Build.VERSION_CODES.KITKAT // DocumentProviderSAF 返回的 Uri if (isKitKat DocumentsContract.isDocumentUri(context, uri)) { when { isExternalStorageDocument(uri) - { val docId DocumentsContract.getDocumentId(uri) val split docId.split(:) val type split[0] if (primary.equals(type, ignoreCase true)) { return Environment.getExternalStorageDirectory() .toString() / split[1] } // 非 primary 卷如 SD 卡需要按 volume 查 return getPathFromVolume(context, split) } isDownloadsDocument(uri) - { val id DocumentsContract.getDocumentId(uri) // 部分机型返回 raw:/storage/... 形式 if (id.startsWith(raw:)) { return id.removePrefix(raw:) } val contentUri ContentUris.withAppendedId( Uri.parse(content://downloads/public_downloads), id.toLong() ) return getDataColumn(context, contentUri, null, null) } isMediaDocument(uri) - { val docId DocumentsContract.getDocumentId(uri) val split docId.split(:) val type split[0] val contentUri when (type) { image - MediaStore.Images.Media.EXTERNAL_CONTENT_URI video - MediaStore.Video.Media.EXTERNAL_CONTENT_URI audio - MediaStore.Audio.Media.EXTERNAL_CONTENT_URI else - null } ?: return null val selection _id? val selectionArgs arrayOf(split[1]) return getDataColumn(context, contentUri, selection, selectionArgs) } } } // MediaStore 及普通 content Uri if (content.equals(uri.scheme, ignoreCase true)) { // Android 10 部分 Uri 不支持 _data 列需要走文件描述符 return getDataColumn(context, uri, null, null) ?: copyToCacheAndGetPath(context, uri) } // file Uri if (file.equals(uri.scheme, ignoreCase true)) { return uri.path } return null } private fun getDataColumn( context: Context, uri: Uri, selection: String?, selectionArgs: ArrayString? ): String? { val column _data val projection arrayOf(column) var cursor: Cursor? null try { cursor context.contentResolver.query( uri, projection, selection, selectionArgs, null ) if (cursor ! null cursor.moveToFirst()) { val index cursor.getColumnIndexOrThrow(column) return cursor.getString(index) } } catch (e: Exception) { // Android 10 可能抛 IllegalArgumentException return null } finally { cursor?.close() } return null } private fun isExternalStorageDocument(uri: Uri) com.android.externalstorage.documents uri.authority private fun isDownloadsDocument(uri: Uri) com.android.providers.downloads.documents uri.authority private fun isMediaDocument(uri: Uri) com.android.providers.media.documents uri.authority }3.4 Android 10 的兜底方案上面getDataColumn在 Android 10 及以上可能查不到_data列因为分区存储下这个列对普通应用不可见。这时候最稳的做法是把内容拷贝到应用私有缓存目录再拿缓存文件的路径。private fun copyToCacheAndGetPath(context: Context, uri: Uri): String? { return try { val inputStream context.contentResolver.openInputStream(uri) ?: return null val fileName media_${System.currentTimeMillis()} val cacheFile File(context.cacheDir, fileName) cacheFile.outputStream().use { output - inputStream.copyTo(output) } inputStream.close() cacheFile.absolutePath } catch (e: Exception) { null } }这个方案的好处是路径一定可读、可上传缺点是占一份缓存空间。记得在合适时机清理cacheDir或者用File.createTempFile配合定期清理。4. 验证请求真机跑通路径解析与接口调用4.1 真机验证路径解析代码写完了怎么确认它真的能拿到路径我建议按来源分场景测别只测相册。第一步在onActivityResult里打印 Uri 和解析结果override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (resultCode ! Activity.RESULT_OK) return val uri data?.data ?: return Log.d(UriTest, raw uri $uri) Log.d(UriTest, authority ${uri.authority}) Log.d(UriTest, scheme ${uri.scheme}) val path UriUtils.getPath(this, uri) Log.d(UriTest, resolved path $path) if (path ! null) { val file File(path) Log.d(UriTest, exists ${file.exists()}, size ${file.length()}) } }第二步分别从这几个入口选文件观察日志来源典型 authority预期结果系统相册media解析出 /storage/emulated/0/DCIM/...文件 Appcom.android.externalstorage.documents解析出真实路径下载com.android.providers.downloads.documents解析出下载目录路径微信文件第三方 authority走缓存兜底第三步重点看exists和size。如果existsfalse说明路径拼错了或者权限没给够如果size0可能是拷贝过程出了问题。4.2 调用 TaoToken 接口验证链路拿到路径之后把它转成 Base64 或者用 multipart 上传调用 TaoToken 的多模态接口做一次识别验证整条链路。下面用 OkHttp 发一个最简单的请求fun callTaoToken(imagePath: String, apiKey: String) { val bitmap BitmapFactory.decodeFile(imagePath) ?: return val baos ByteArrayOutputStream() bitmap.compress(Bitmap.CompressFormat.JPEG, 80, baos) val base64 Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP) val json JSONObject().apply { put(model, your-multimodal-model) put(messages, JSONArray().apply { put(JSONObject().apply { put(role, user) put(content, JSONArray().apply { put(JSONObject().apply { put(type, text) put(text, 描述这张图片的内容) }) put(JSONObject().apply { put(type, image_url) put(image_url, JSONObject().put(url, data:image/jpeg;base64,$base64)) }) }) }) }) } val request Request.Builder() .url(https://taotoken.net/api/v1/chat/completions) .addHeader(Authorization, Bearer $apiKey) .addHeader(Content-Type, application/json) .post(json.toString().toRequestBody(application/json.toMediaType())) .build() OkHttpClient().newCall(request).enqueue(object : Callback { override fun onFailure(call: Call, e: IOException) { Log.e(TaoToken, request failed, e) } override fun onResponse(call: Call, response: Response) { Log.d(TaoToken, code ${response.code}) Log.d(TaoToken, body ${response.body?.string()}) } }) }如果返回 200 并且 body 里有正常的模型回复说明「路径解析 → 读取文件 → 编码 → 调用接口」整条链路是通的。如果返回 401检查 Key 是否正确返回 404检查 model 字段是否拼错。5. 本篇常见错排查5.1getPath返回 null最常见的原因有三个。一是权限没给ContentResolver.query直接抛异常被 catch 吞掉了日志里看不到。建议在 catch 里加Log.e别静默返回 null。二是 Android 10 的_data列不可见需要走copyToCacheAndGetPath兜底。三是 Uri 来自第三方 App比如微信、钉钉authority 不在我们判断的三类里也会返回 null同样走缓存兜底。5.2column _data does not exist这个报错说明当前 Provider 不提供_data列。Android 10 之后 MediaStore 对普通应用隐藏了这个列。解决办法就是前面说的拷贝到缓存目录或者用ContentResolver.openFileDescriptor拿到 fd 直接读取不依赖路径。5.3 非 primary 卷SD 卡路径拼错isExternalStorageDocument分支里如果type不是primary说明文件在 SD 卡上。这时候不能简单拼Environment.getExternalStorageDirectory()需要用StorageManager.getStorageVolumes()遍历找到对应卷的路径。下面是一个简化实现private fun getPathFromVolume(context: Context, split: ArrayString): String? { val type split[0] val storageManager context.getSystemService(Context.STORAGE_SERVICE) as StorageManager for (volume in storageManager.storageVolumes) { val volumePath volume.directory?.absolutePath ?: continue if (volumePath.endsWith(type)) { return $volumePath/${split[1]} } } return null }5.4 拍照返回的 Uri 解析失败用MediaStore.ACTION_IMAGE_CAPTURE拍照时如果你传了EXTRA_OUTPUTonActivityResult里的data可能是 null真实 Uri 是你自己传进去的那个。这时候别去读data.getData()直接用你保存的 Uri 变量。另外 Android 7.0 要用FileProvider生成content://Uri不能再用file://否则会抛FileUriExposedException。5.5 上传时文件已被删除解析出路径不代表文件还在。用户可能在选完之后、上传之前把原图删了。所以拿到路径后先File(path).exists()判断一下不存在就提示用户重新选择别直接抛异常。6. 拿到路径之后接入与后续路径解析只是第一步真正让功能跑起来还要把媒体文件接到你的业务接口上。如果你在做多模态识别、图片打标、视频内容审核这类功能建议把 TaoToken 的 Key 配置和路径解析封装成两个独立模块一个负责「Uri → 本地可读路径」一个负责「本地路径 → 接口调用」。这样职责清晰换模型、换 Provider 都不影响另一边。需要创建 Key 或者管理多个项目的 Key去控制台 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 操作API Keys 页面可以按项目分组。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 里面有各语言的请求示例和参数说明遇到 401、404、429 这类状态码可以先查文档里的错误码表。如果你只是想快速验证某个模型对图片的理解效果不用写代码直接用模型对话页面传图测试就行。等确认模型效果符合预期再回到 Android 端集成。对于需要长期跑批量任务、Agent 自动化的场景Coding Plan 会比按次调用更划算适合把「解析路径 → 调用模型 → 处理结果」做成流水线。最后提醒一句copyToCacheAndGetPath产生的缓存文件记得清理。可以在onDestroy里删也可以用WorkManager定期清理cacheDir下超过 24 小时的文件。别让缓存把用户手机塞满了。
返回列表