1. 小米相册选图后 cursor 为空,问题到底出在哪
移动开发里有个经典坑:同一段相册选图代码,在华为、OPPO、vivo 上跑得好好的,一到小米手机上就崩,异常栈指向cursor.moveToFirst()抛空指针。你打开 Logcat 一看,Cursor对象是 null,query()返回了空。这不是你代码写错了,而是小米在ACTION_PICK这条路径上返回的Uri形态和别家不一样。
先把结论说清楚:Android 4.4(API 19)之后,系统相册返回的Uri不再是file://开头的绝对路径,而是content://形式的媒体库编码。大部分厂商会帮你把content://media/external/images/media/12345这类 Uri 通过MediaStore.Images.Media.DATA列查回真实路径,但小米在部分版本上,用ACTION_PICK拿到的 Uri 去查_data列时,ContentResolver.query()直接返回 null,于是cursor.moveToFirst()就炸了。
这个场景适合谁?适合所有做 Android 图片选择、头像上传、拍照裁剪的开发者,尤其是需要兼容多厂商 ROM 的团队。核心检索词就三个:小米手机、相册选图、Uri 空指针异常。你要做的是把「拿 Uri」和「转路径」这两步解耦,并且用一套统一的请求通道去管理后续的图片上传或 AI 处理接口。
我试过的处理思路是:不要迷信ACTION_PICK,统一改用ACTION_GET_CONTENT,然后按版本分流——低版本走老的_data查询,高版本走DocumentsContract解析。同时,图片拿到之后如果要走网络请求(比如上传到 AI 图像接口),把 Key 和 Base URL 收敛到一套统一配置里,避免每个模块各写一份。下面就把这套骨架拆开讲。
2. TaoToken 统一 Key 通道的前置准备
图片选完之后,很多场景是要把图片送给模型做识别、OCR 或者内容审核。这时候你会遇到第二个坑:不同 AI 服务的 Key 格式、Base URL、请求头都不一样,散落在各个build.gradle或local.properties里,改一次要翻五个文件。TaoToken 在这里的作用就是提供一套统一的 Key 和 API 通道,让你用同一个 Key、同一个 Base URL 去调不同模型。
官网地址是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 入口是 https://taotoken.net/api ,注意 API 地址不带 UTM 参数,配置的时候别把跟踪参数拼进去,否则部分客户端会报 404。
你需要提前准备的东西不多:一个 TaoToken 账号,在控制台生成一个 API Key;然后确认你的项目里网络层用的是 OkHttp、Retrofit 还是 Ktor,因为后面的配置骨架会按这几种分别给。控制台地址是 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite ,API Key 管理页是 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 。
注意:Key 只生成一次就看不到了,生成后立刻复制到安全的地方。不要硬编码进 Git 仓库,用
local.properties或者环境变量注入。
如果你用的是 Cline、CC Switch 这类编码助手,它们支持自定义 OpenAI 兼容端点,把 Base URL 填成https://taotoken.net/api,Key 填 TaoToken 的 Key,就能直接跑。模型对话入口在 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=model-chat&utm_campaign=rewrite ,可以先用它验证 Key 是否有效,再去改项目配置。
3. 可复制的配置骨架与小米 Uri 处理代码
这一节分两块:一块是 Android 侧的小米 Uri 转路径工具类,一块是网络请求侧的 TaoToken 配置骨架。两块拼起来,才是完整的「选图 → 转路径 → 上传/识别」链路。
3.1 小米 Uri 转绝对路径的工具类
核心逻辑是:先判断是不是DocumentsContract类型的 Uri,再按 authority 分流到外部存储、下载、媒体库三个 Provider,最后用_data列查真实路径。下面这段可以直接放进utils包:
public class MiPictureHelper { public static String getPath(final Context context, final Uri uri) { if (uri == null) return null; final boolean isKitKat = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT; if (isKitKat && DocumentsContract.isDocumentUri(context, uri)) { if (isExternalStorageDocument(uri)) { final String docId = DocumentsContract.getDocumentId(uri); final String[] split = docId.split(":"); final String type = split[0]; if ("primary".equalsIgnoreCase(type)) { return Environment.getExternalStorageDirectory() + "/" + split[1]; } } else if (isDownloadsDocument(uri)) { final String id = DocumentsContract.getDocumentId(uri); final Uri contentUri = ContentUris.withAppendedId( Uri.parse("content://downloads/public_downloads"), Long.valueOf(id)); return getDataColumn(context, contentUri, null, null); } else if (isMediaDocument(uri)) { final String docId = DocumentsContract.getDocumentId(uri); final String[] split = docId.split(":"); final String type = split[0]; Uri contentUri = null; if ("image".equals(type)) { contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI; } else if ("video".equals(type)) { contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI; } else if ("audio".equals(type)) { contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI; } final String selection = "_id=?"; final String[] selectionArgs = new String[]{split[1]}; return getDataColumn(context, contentUri, selection, selectionArgs); } } else if ("content".equalsIgnoreCase(uri.getScheme())) { return getDataColumn(context, uri, null, null); } else if ("file".equalsIgnoreCase(uri.getScheme())) { return uri.getPath(); } return null; } private static String getDataColumn(Context context, Uri uri, String selection, String[] selectionArgs) { Cursor cursor = null; final String column = "_data"; final String[] projection = {column}; try { cursor = context.getContentResolver().query(uri, projection, selection, selectionArgs, null); if (cursor != null && cursor.moveToFirst()) { final int index = cursor.getColumnIndexOrThrow(column); return cursor.getString(index); } } catch (Exception e) { Log.e("MiPictureHelper", "query failed: " + e.getMessage()); } finally { if (cursor != null) cursor.close(); } return null; } private static boolean isExternalStorageDocument(Uri uri) { return "com.android.externalstorage.documents".equals(uri.getAuthority()); } private static boolean isDownloadsDocument(Uri uri) { return "com.android.providers.downloads.documents".equals(uri.getAuthority()); } private static boolean isMediaDocument(Uri uri) { return "com.android.providers.media.documents".equals(uri.getAuthority()); } }调用的时候,在onActivityResult里做空判断,别直接data.getData()就用:
@Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); if (requestCode == PICK_PICTURE && resultCode == RESULT_OK && data != null) { Uri uri = data.getData(); if (uri == null) { Toast.makeText(this, "未获取到图片", Toast.LENGTH_SHORT).show(); return; } String path = MiPictureHelper.getPath(this, uri); if (path == null) { Toast.makeText(this, "图片路径解析失败", Toast.LENGTH_SHORT).show(); return; } // 后续上传或识别 } }关键点:getDataColumn里加了 try-catch,因为小米某些版本 query 会直接抛异常而不是返回 null,捕获之后返回 null,上层再做兜底提示,比直接崩掉体验好得多。
3.2 TaoToken 配置骨架:settings.json 与 config.toml
如果你用 Cline 或 CC Switch,配置一般落在settings.json或config.toml。下面给一份通用骨架,把 Base URL 和 Key 抽出来。
settings.json示例(Cline / VS Code 系):
{ "cline.apiProvider": "openai", "cline.openAiBaseUrl": "https://taotoken.net/api", "cline.openAiApiKey": "sk-your-taotoken-key", "cline.openAiModelId": "gpt-4o-mini", "cline.customInstructions": "图片识别任务请返回 JSON,字段包含 text 和 confidence" }config.toml示例(CC Switch 系):
[provider] name = "taotoken" base_url = "https://taotoken.net/api" api_key = "sk-your-taotoken-key" model = "gpt-4o-mini" timeout_seconds = 60 [request] max_retries = 2 retry_backoff_ms = 500注意:
base_url结尾不要带/v1,TaoToken 的兼容层会自动补全路径。带了反而会变成/v1/v1/chat/completions,报 404。
Android 侧如果用 Retrofit,把 Base URL 和拦截器配好:
val client = OkHttpClient.Builder() .addInterceptor { chain -> val request = chain.request().newBuilder() .addHeader("Authorization", "Bearer ${BuildConfig.TAOTOKEN_KEY}") .addHeader("Content-Type", "application/json") .build() chain.proceed(request) } .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(60, TimeUnit.SECONDS) .build() val retrofit = Retrofit.Builder() .baseUrl("https://taotoken.net/api/") .client(client) .addConverterFactory(GsonConverterFactory.create()) .build()Key 从local.properties读,再通过buildConfigField注入,别写死在代码里。
4. 验证请求:从复现异常到确认返回正常
排查这类问题,最忌讳上来就改代码。先复现,再替换,最后验证,三步走。
第一步,复现异常。找一台小米手机,用原来的ACTION_PICK代码选一张相册图片,看 Logcat 是否出现NullPointerException指向cursor.moveToFirst()。如果复现了,说明 Uri 形态确实是content://且_data列查不到。
第二步,替换配置。把选图入口从ACTION_PICK改成ACTION_GET_CONTENT:
Intent intent = new Intent(Intent.ACTION_GET_CONTENT); intent.setType("image/*"); intent.addCategory(Intent.CATEGORY_OPENABLE); startActivityForResult(intent, PICK_PICTURE);然后在onActivityResult里接上MiPictureHelper.getPath()。这一步做完,路径应该能正常拿到。
第三步,验证网络请求。用拿到的路径读成 Bitmap 或字节流,走 TaoToken 通道发一次请求。最简单的验证方式是用 curl 先测 Key 是否通:
curl -X POST https://taotoken.net/api/chat/completions \ -H "Authorization: Bearer sk-your-taotoken-key" \ -H "Content-Type: application/json" \ -d '{ "model": "gpt-4o-mini", "messages": [{"role": "user", "content": "ping"}] }'返回里如果有choices字段,说明 Key 和通道都正常。再回到 App 里跑一遍完整链路:选图 → 转路径 → 上传 → 拿到识别结果。如果这一步通了,说明小米 Uri 问题和网络配置问题都解决了。
实测下来,ACTION_GET_CONTENT在小米低版本上也能返回可解析的 Uri,配合MiPictureHelper基本能覆盖 MIUI 各个版本。如果遇到极老版本(Android 4.4 以下),DocumentsContract不可用,走content分支的getDataColumn也能兜住。
5. 本篇常见错排查
错误一:cursor为 null 但没做判空。很多人写cursor.moveToFirst()之前不判cursor != null,小米直接返回 null 就崩。正确写法是if (cursor != null && cursor.moveToFirst()),并且整个 query 包在 try-catch 里。
错误二:getColumnIndexOrThrow抛异常。如果 Uri 对应的表里没有_data列,getColumnIndexOrThrow会抛IllegalArgumentException。换成getColumnIndex并判断返回值是否大于等于 0,更稳。
错误三:Base URL 带了/v1。TaoToken 的兼容端点已经包含版本路径,你再拼/v1就重复了。配置里只写https://taotoken.net/api,Retrofit 的baseUrl记得以/结尾。
错误四:Key 写进 Git。用local.properties存 Key,.gitignore里加上这一行。CI 环境用环境变量注入,别图省事硬编码。
错误五:ACTION_PICK和ACTION_GET_CONTENT混用。两个入口返回的 Uri 形态可能不同,统一用一个,减少分支。推荐ACTION_GET_CONTENT,兼容性更好。
错误六:忘记申请存储权限。Android 6.0 以上要动态申请READ_EXTERNAL_STORAGE,Android 13 以上图片选择器改用READ_MEDIA_IMAGES。权限没给,query也会返回 null,别把权限问题误判成 Uri 问题。
6. 后续接入与配置入口
图片路径拿到之后,如果你要接模型做识别、OCR 或者内容审核,建议把请求层统一到 TaoToken 通道,Key 和 Base URL 只维护一份。模型对话可以先在 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=model-chat&utm_campaign=rewrite 里试通,再落到代码里。
长期做编码和 Agent 任务的,可以看 Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite ,里面有针对 Cline、CC Switch 的接入说明。Key 管理在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite ,接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。Claude Code 相关的配置参考 https://taotoken.net/claude-code?utm_source=taotoken_aicg_blog_end&utm_content=claudecode&utm_campaign=rewrite 。
最后留一个实用技巧:在MiPictureHelper.getPath()返回 null 的时候,不要直接放弃,可以退一步用uri.toString()把原始 Uri 打出来,配合ContentResolver.getType(uri)看 MIME 类型。很多时候路径拿不到,但流还能读,用openInputStream直接读字节流上传,反而绕开了路径解析的坑。这个兜底方案在小米部分机型上比转路径更可靠。