1. 从一次拍照上传的崩溃说起Android 调用系统相机选择图片看起来只是Intent加onActivityResult两段代码的事但真正落到「拍完照要传给 AI 做识别」这条链路上坑会一个接一个冒出来。我见过太多项目卡在同一个地方图片路径拿到了Bitmap也显示出来了结果一上传就报FileNotFoundException或者上传成功但服务端返回 401因为 Key 写死在代码里、换环境就失效。这篇要解决的就是这条完整链路Android 通过 Intent 调用系统相机拍照或从相册选图拿到图片后用 TaoToken 统一 Key 把图片送到多模态模型做识别。适合正在做智能硬件配套 App、电商识图、拍照问答类功能的 Android 开发者。核心检索词就三个android、系统相机、选择图片但我会把「选完图之后怎么办」讲透因为那才是真正决定项目能不能跑通的部分。先明确一个边界Android 调系统相机有两种主流写法一种是ACTION_PICK从相册选一种是ACTION_IMAGE_CAPTURE直接调相机拍。原文用的是前者我会两种都给并且补上 Android 7.0 之后必须处理的FileProvider否则ACTION_IMAGE_CAPTURE在真机上直接崩。图片拿到之后统一走 TaoToken 的 Key 接入把「模型调用」这件事从 App 里解耦出去配置一次后面换模型、换环境都不用改客户端代码。整篇的节奏是先给可复制的settings.json配置骨架再给 TaoToken 统一 Key 的接入步骤然后是拍照回调到图片上传的验证动作最后把几个高频报错逐个拆掉。你可以边看边改配置一次跑通。2. TaoToken 统一 Key 前置准备在写 Android 代码之前先把「Key 从哪来、放哪、怎么用」这件事定下来。很多团队的做法是把 API Key 硬编码在BuildConfig里结果测试包和正式包混用、Key 泄露、换模型要重新打包非常痛苦。TaoToken 的思路是提供一个统一的接入层你只需要一个 Key就能在多个模型之间切换客户端不用关心背后是哪个厂商。2.1 获取统一 Key打开 TaoToken 官网 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 注册后在控制台创建 API Key。这个 Key 就是后面所有请求的凭证格式通常是一串以sk-开头的字符串。创建入口在控制台的 API Keys 页面建议给不同环境开发、测试、生产建不同的 Key方便单独吊销。拿到 Key 之后不要直接写进 Java 代码。推荐放在local.properties或者 CI 的环境变量里通过buildConfigField注入。这样本地调试和线上构建用的是同一套代码只是注入的值不同。2.2 确认接入地址TaoToken 的 API 基础地址是 https://taotoken.net/api 注意这个地址不带任何查询参数是纯粹的接口前缀。所有模型调用都走这个前缀具体路径在文档里查。比如对话补全通常是/v1/chat/completions多模态识图也是同一个入口只是消息体里带图片。这里有个容易踩的坑有人把官网地址当成 API 地址用结果请求打到网页上返回 HTML解析 JSON 直接崩。记住官网是给人看的API 是给程序调的两者域名相同但路径不同。2.3 配置骨架 settings.json虽然 Android 原生项目不直接用settings.json但很多跨端框架比如 React Native、Flutter 的某些插件、或者你自建的配置中心会用 JSON 来管理运行时配置。下面这份骨架可以直接复制把apiKey和baseUrl换成你自己的即可{ taotoken: { baseUrl: https://taotoken.net/api, apiKey: sk-你的统一Key, defaultModel: gpt-4o, timeoutMs: 30000, vision: { enabled: true, maxImageSizeMb: 5, supportedFormats: [jpg, jpeg, png, webp] } }, android: { fileProviderAuthority: com.yourpackage.fileprovider, cameraRequestCode: 1001, galleryRequestCode: 1002 } }这份配置里baseUrl固定指向 TaoToken 的 API 前缀apiKey是统一 KeydefaultModel指定默认走哪个多模态模型。vision段控制图片上传的限制maxImageSizeMb建议设 5因为大多数多模态接口对单图有大小限制超过会被拒。android段里的fileProviderAuthority必须和AndroidManifest.xml里声明的一致否则调相机时会抛IllegalArgumentException。注意settings.json里的 Key 只适合本地开发或服务端下发场景。如果这个文件会打进 APK务必确保它不被反编译泄露生产环境更推荐由后端签发临时凭证。3. 可复制的 Android 配置与代码这一节是重头戏把从调相机、选图片到上传的完整代码给全。你可以新建一个CameraImageHelper类把逻辑收拢进去Activity 里只留回调。3.1 AndroidManifest 与 FileProviderAndroid 6.0 之后相机权限要动态申请7.0 之后file://URI 不能直接暴露给其他应用必须用FileProvider。先在AndroidManifest.xml里声明uses-permission android:nameandroid.permission.CAMERA / uses-permission android:nameandroid.permission.READ_EXTERNAL_STORAGE / uses-permission android:nameandroid.permission.WRITE_EXTERNAL_STORAGE / application provider android:nameandroidx.core.content.FileProvider android:authoritiescom.yourpackage.fileprovider android:exportedfalse android:grantUriPermissionstrue meta-data android:nameandroid.support.FILE_PROVIDER_PATHS android:resourcexml/file_paths / /provider /application然后在res/xml/file_paths.xml里定义可共享的目录?xml version1.0 encodingutf-8? paths external-files-path nameimages pathPictures/ / cache-path namecache_images pathimages/ / /pathsauthorities的值必须和settings.json里的fileProviderAuthority完全一致大小写都不能差。3.2 调用系统相机拍照拍照的关键是先生成一个目标文件的 URI再把这个 URI 通过EXTRA_OUTPUT传给相机应用。代码如下private Uri createImageUri() { File imageDir new File(getExternalFilesDir(Environment.DIRECTORY_PICTURES), images); if (!imageDir.exists()) { imageDir.mkdirs(); } File imageFile new File(imageDir, capture_ System.currentTimeMillis() .jpg); return FileProvider.getUriForFile( this, com.yourpackage.fileprovider, imageFile ); } private void openCamera() { Intent intent new Intent(MediaStore.ACTION_IMAGE_CAPTURE); if (intent.resolveActivity(getPackageManager()) ! null) { currentImageUri createImageUri(); intent.putExtra(MediaStore.EXTRA_OUTPUT, currentImageUri); intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION); startActivityForResult(intent, REQUEST_CAMERA); } }currentImageUri是成员变量拍照成功后从它读取图片而不是从data.getData()取因为ACTION_IMAGE_CAPTURE在指定了EXTRA_OUTPUT之后data里是空的。3.3 从相册选择图片相册选择用ACTION_PICK原文的写法基本可用但要注意 Android 10 之后MediaStore.Images.Media.DATA这个列被标记为废弃直接查路径可能拿到 null。更稳的做法是用ContentResolver打开输入流或者用MediaStore.Images.Media._ID拼 URI。下面给一个兼容写法private void openGallery() { Intent intent new Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI); intent.setType(image/*); startActivityForResult(intent, REQUEST_GALLERY); } Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); if (resultCode ! RESULT_OK) return; if (requestCode REQUEST_CAMERA) { handleImage(currentImageUri); } else if (requestCode REQUEST_GALLERY data ! null) { handleImage(data.getData()); } }handleImage接收一个Uri内部统一转成InputStream或临时文件这样相机和相册两条路径就合并了后面上传逻辑只写一份。3.4 图片压缩与 Base64 编码多模态接口通常接受 Base64 编码的图片或者接受 multipart 上传。Base64 的好处是请求体简单坏处是体积膨胀约 33%。所以上传前一定要压缩。下面这段把Uri转成压缩后的 Base64private String uriToBase64(Uri uri, int maxSizeKb) throws IOException { InputStream input getContentResolver().openInputStream(uri); Bitmap bitmap BitmapFactory.decodeStream(input); if (input ! null) input.close(); int quality 80; ByteArrayOutputStream output new ByteArrayOutputStream(); bitmap.compress(Bitmap.CompressFormat.JPEG, quality, output); while (output.size() / 1024 maxSizeKb quality 20) { quality - 10; output.reset(); bitmap.compress(Bitmap.CompressFormat.JPEG, quality, output); } byte[] bytes output.toByteArray(); return Base64.encodeToString(bytes, Base64.NO_WRAP); }maxSizeKb建议设 800 到 1000对应大约 1MB 以内的原图压缩后通常能压到 200KB 左右足够模型识别。3.5 用统一 Key 发起请求请求部分用OkHttp或HttpURLConnection都行这里给OkHttp的写法因为拦截器加 Header 更方便private void callVisionModel(String base64Image) throws JSONException { OkHttpClient client new OkHttpClient.Builder() .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .build(); JSONObject payload new JSONObject(); payload.put(model, gpt-4o); JSONArray messages new JSONArray(); JSONObject userMsg new JSONObject(); userMsg.put(role, user); JSONArray content new JSONArray(); content.put(new JSONObject().put(type, text).put(text, 描述这张图片)); content.put(new JSONObject() .put(type, image_url) .put(image_url, new JSONObject() .put(url, data:image/jpeg;base64, base64Image))); userMsg.put(content, content); messages.put(userMsg); payload.put(messages, messages); Request request new Request.Builder() .url(https://taotoken.net/api/v1/chat/completions) .addHeader(Authorization, Bearer BuildConfig.TAOTOKEN_API_KEY) .addHeader(Content-Type, application/json) .post(RequestBody.create(payload.toString(), MediaType.parse(application/json))) .build(); client.newCall(request).enqueue(new Callback() { Override public void onFailure(Call call, IOException e) { runOnUiThread(() - tvResult.setText(请求失败: e.getMessage())); } Override public void onResponse(Call call, Response response) throws IOException { String body response.body().string(); runOnUiThread(() - tvResult.setText(body)); } }); }注意Authorization头是Bearer加空格再加 Key少一个空格就是 401。BuildConfig.TAOTOKEN_API_KEY来自build.gradle里的buildConfigField这样 Key 不会出现在源码里。4. 验证请求与成功结果配置写完怎么确认真的跑通了分三步验证每一步都有明确的成功标志。4.1 验证 Key 是否有效先不传图片发一个最简单的文本请求确认 Key 和地址都对curl -X POST https://taotoken.net/api/v1/chat/completions \ -H Authorization: Bearer sk-你的Key \ -H Content-Type: application/json \ -d { model: gpt-4o, messages: [{role: user, content: ping}] }如果返回的 JSON 里有choices字段说明 Key 和地址都没问题。如果返回 401检查 Key 有没有复制完整、有没有多余空格。如果返回 404检查路径是不是/v1/chat/completions别漏了v1。4.2 验证图片上传链路在 App 里点拍照拍一张清晰的文字图片观察 Logcat。成功的话会看到类似这样的响应片段{ choices: [ { message: { role: assistant, content: 图片中显示了一段文字内容是... } } ] }如果返回content是空或者报image too large说明压缩没生效回去检查uriToBase64里的maxSizeKb是不是设太大。4.3 验证不同模型的切换统一 Key 的好处这时候体现出来把settings.json里的defaultModel从gpt-4o改成别的多模态模型重新发一次请求如果也能正常返回说明接入层是通的。这一步能帮你确认「换模型不用改客户端代码」这个目标达成了。5. 本篇常见错排查下面这几个报错基本覆盖了 90% 的翻车场景逐个对照。5.1 FileUriExposedException报错信息android.os.FileUriExposedException: file:///... exposed beyond app through Intent.getData()。原因Android 7.0 之后直接传file://URI 给其他应用会抛这个异常。解决所有跨应用传递的 URI 必须用FileProvider.getUriForFile()生成content://URI并且加FLAG_GRANT_WRITE_URI_PERMISSION。检查file_paths.xml里的路径有没有覆盖你实际存图的目录。5.2 拍照返回 data 为 null现象onActivityResult里data是 null拿不到图片。原因调ACTION_IMAGE_CAPTURE时传了EXTRA_OUTPUT相机把图写到你指定的 URI 了data里自然没有东西。解决用成员变量保存currentImageUri回调时直接读它不要读data.getData()。5.3 401 Unauthorized现象请求返回 401提示 invalid api key。排查顺序第一Key 有没有复制完整前后有没有空格第二Authorization头格式是不是Bearer sk-xxxBearer和 Key 之间必须有一个空格第三Key 是不是被吊销了去控制台确认状态第四请求地址是不是https://taotoken.net/api开头别写成官网地址。5.4 图片过大被拒现象返回 413 或提示 image size exceeds limit。原因Base64 编码后体积膨胀原图 3MB 编码后接近 4MB超过接口限制。解决上传前压缩uriToBase64里的循环压缩逻辑要确保生效。另外可以在settings.json里把maxImageSizeMb调小作为双重保险。5.5 相册选图拿到 null 路径现象MediaStore.Images.Media.DATA查询返回 null。原因Android 10 之后分区存储生效DATA列不再可靠。解决不要查路径直接用ContentResolver.openInputStream(uri)读流或者用MediaStore.Images.Media._ID拼content://media/external/images/media/{id}。上面给的handleImage统一走Uri就是为了避开这个问题。6. 接入文档与后续动作配置跑通之后下一步是把这套逻辑固化到项目里。几个建议把CameraImageHelper抽成独立模块settings.json的读取封装成ConfigManagerKey 通过buildConfigField注入这样换环境只改构建脚本。多模态模型的参数、超时、重试策略都放在配置里别散落在代码各处。如果你在接入过程中遇到 Key 相关的问题比如权限、额度、模型列表可以直接去 API Keys 页面 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 查看和管理。完整的接口参数、请求示例、错误码说明在接入文档 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 里建议对照着把每个字段过一遍。想先快速验证模型效果不写代码的话模型对话页面 https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 可以直接传图试。如果后面要做长期的编码辅助或者 Agent 类功能Coding Plan https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 里有更完整的方案。最后留一个实操技巧调试图片上传时先把 Base64 字符串写到本地文件用adb pull拉出来手动解码看图片有没有损坏。这一步能帮你区分是「图片本身有问题」还是「请求构造有问题」比盲猜快得多。
