☰
调用系统相机进行拍照并保存:TaoToken 统一 Key 通道下的移动端图像采集实践
2026/10/7 14:29:52 网站建设 项目流程

1. 移动端拍照保存的真实痛点:为什么你的相机回调总是拿不到图

做移动端开发的朋友大概率都遇到过这个场景:产品经理说"加个拍照上传功能",你心想这不就是调个系统相机的事吗,结果真机一跑,问题全来了。Android 上onActivityResult回来了但data.getData()是 null,iOS 上UIImagePickerController拍完照存到临时目录过一会儿就没了,用户拍完切后台再回来图片直接消失。这些坑我在过去几年做智能硬件配套 App 的时候几乎踩了个遍。

调用系统相机拍照并保存,听起来简单,实际上是一条完整的链路:权限申请 → 拉起相机 Intent/AVCapture → 拿到临时文件 → 转存到相册或沙盒目录 → 保留 EXIF 信息 → 失败时回滚清理。任何一环出问题,用户看到的就是"拍照没反应"或者"图片丢了"。

这篇文章聚焦 Android 和 iOS 双端,把这条链路拆开讲透。适合谁看?正在做 App 内图像采集的移动端开发者、做智能硬件配网/识别需要拍照上传的工程师、以及被FileUriExposedException和PHPhotoLibrary权限折磨过的同学。我会给出可直接复制的权限声明、拍照回调、文件保存代码,并附上真机验证步骤和常见异常排查清单。

另外提一句,如果你在做的是 AI 应用,拍照之后往往还要把图片送给大模型做识别或理解,这时候一个统一的 Key 通道能省掉不少对接成本。TaoToken 就是干这个的,后面会讲怎么把拍照链路和模型调用串起来。

先说结论:Android 侧的核心是FileProvider+MediaStore,iOS 侧的核心是PHPickerViewController或UIImagePickerController+PHPhotoLibrary保存。下面逐个拆。

2. TaoToken 前置准备:统一 Key 通道怎么接入拍照后的 AI 链路

在讲具体代码之前,先把这个前置环节说清楚,因为很多同学拍照功能做完之后,下一步就是要把图片送去 AI 处理,比如 OCR 识别、物体检测、图像描述。如果每个模型都单独申请 Key、单独配 Base URL,维护成本很高。

TaoToken 提供的是一个统一的 API 通道,你只需要一个 Key,就能调用多种模型。官网地址是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 端点是 https://taotoken.net/api 。

接入步骤很直接:

第一步,注册后在控制台创建 API Key。控制台地址:https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite 。创建完记得复制保存,Key 只显示一次。

第二步,如果你用的是 Claude Code 这类编码工具,可以直接用 Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 。它把 Base URL 和 Key 的配置都简化了。

第三步,验证 Key 是否可用。最直接的方式是用模型对话页面测一下:https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite 。输入一句话看有没有正常返回。

对于拍照场景,你拿到图片的 base64 或者 URL 之后,就可以通过统一的 API 端点发给模型。比如图像理解类请求,请求体里带上model和messages,图片以 base64 编码放在 content 里。这样你的 App 拍照链路和 AI 处理链路就串起来了,不用为每个模型单独维护一套鉴权逻辑。

需要说明的是,TaoToken 在这里的角色是统一调用通道,不是替代你的编辑器或相机功能,它解决的是"拍完照之后怎么方便地调模型"这个问题。如果你暂时不需要 AI 处理,这一节可以先跳过,直接看后面的拍照代码。

3. Android 可复制配置:FileProvider 声明与拍照保存完整代码

Android 这块是重灾区,尤其是 Android 7.0 之后file://URI 被禁止,必须用FileProvider。我见过太多项目还在用Uri.fromFile(),在低版本上跑得好好的,一到新机型就崩。

3.1 权限声明与 FileProvider 配置

先在AndroidManifest.xml里声明权限和 Provider:

<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.takephoto"> <uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" /> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> <uses-feature android:name="android.hardware.camera" android:required="false" /> <application ...> <provider android:name="androidx.core.content.FileProvider" android:authorities="${applicationId}.fileprovider" android:exported="false" android:grantUriPermissions="true"> <meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" /> </provider> </application> </manifest>

然后在res/xml/file_paths.xml里定义可共享的路径:

<?xml version="1.0" encoding="utf-8"?> <paths> <external-files-path name="images" path="Pictures/" /> <cache-path name="cache_images" path="images/" /> <external-cache-path name="ext_cache" path="images/" /> </paths>

注意WRITE_EXTERNAL_STORAGE加了maxSdkVersion="28",因为 Android 10 之后分区存储生效,写自己沙盒目录不需要这个权限了。READ_MEDIA_IMAGES是 Android 13 读取相册需要的。

3.2 拍照 Intent 与临时文件创建

关键点:临时文件要放在getExternalFilesDir()或cacheDir下,不要直接往DCIM写,否则权限和路径都会出问题。

private lateinit var photoUri: Uri private lateinit var tempFile: File private fun createImageFile(): File { val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) val storageDir = getExternalFilesDir(Environment.DIRECTORY_PICTURES) return File.createTempFile("JPEG_${timeStamp}_", ".jpg", storageDir) } private fun takePhoto() { try { tempFile = createImageFile() } catch (e: IOException) { Toast.makeText(this, "创建文件失败", Toast.LENGTH_SHORT).show() return } photoUri = FileProvider.getUriForFile( this, "${applicationId}.fileprovider", tempFile ) val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply { putExtra(MediaStore.EXTRA_OUTPUT, photoUri) addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) } startActivityForResult(intent, REQUEST_TAKE_PHOTO) }

这里EXTRA_OUTPUT指定了输出 URI,相机拍完直接写到这个文件,回调里data可能是 null,这是正常的,别去data.getData()。

3.3 回调处理与保存到相册

拍完之后,临时文件在沙盒里,用户在图库看不到。要持久化到相册,用MediaStore插入:

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (requestCode == REQUEST_TAKE_PHOTO && resultCode == RESULT_OK) { saveToGallery(tempFile) } } private fun saveToGallery(file: File) { val values = ContentValues().apply { put(MediaStore.Images.Media.DISPLAY_NAME, file.name) put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + "/MyApp") put(MediaStore.Images.Media.IS_PENDING, 1) } } val resolver = contentResolver val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) uri?.let { resolver.openOutputStream(it)?.use { out -> FileInputStream(file).use { input -> input.copyTo(out) } } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { values.clear() values.put(MediaStore.Images.Media.IS_PENDING, 0) resolver.update(it, values, null, null) } } // 保存成功后清理临时文件 file.delete() }

IS_PENDING这个标记很重要,Android 10+ 上写入过程中其他应用看不到这张图,写完置 0 才可见,避免半截文件被扫描。

3.4 EXIF 保留

如果你需要保留拍摄时间、GPS、方向等信息,用ExifInterface读取临时文件再写回目标文件:

val srcExif = ExifInterface(tempFile.absolutePath) val dstExif = ExifInterface(savedFilePath) val orientation = srcExif.getAttribute(ExifInterface.TAG_ORIENTATION) dstExif.setAttribute(ExifInterface.TAG_ORIENTATION, orientation) dstExif.saveAttributes()

方向信息尤其重要,很多手机拍出来是横的,就是 EXIF orientation 没处理。

4. iOS 可复制配置:UIImagePickerController 与 PHPhotoLibrary 保存

iOS 相对规范一些,但权限和保存逻辑也有讲究。这里用UIImagePickerController举例,PHPickerViewController是更新的方案,但拍照场景UIImagePickerController更直接。

4.1 Info.plist 权限声明

<key>NSCameraUsageDescription</key> <string>需要使用相机拍摄照片用于识别</string> <key>NSPhotoLibraryAddUsageDescription</key> <string>需要保存拍摄的照片到相册</string> <key>NSPhotoLibraryUsageDescription</key> <string>需要访问相册选择照片</string>

三个都要写,少一个审核会被拒或者运行时崩溃。

4.2 拉起相机与回调

import UIKit import Photos class CameraViewController: UIViewController, UIImagePickerControllerDelegate, UINavigationControllerDelegate { func openCamera() { guard UIImagePickerController.isSourceTypeAvailable(.camera) else { print("模拟器不支持相机") return } let picker = UIImagePickerController() picker.sourceType = .camera picker.delegate = self picker.cameraCaptureMode = .photo present(picker, animated: true) } func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { picker.dismiss(animated: true) guard let image = info[.originalImage] as? UIImage else { return } saveToPhotoLibrary(image) } func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { picker.dismiss(animated: true) } }

4.3 保存到相册与沙盒

func saveToPhotoLibrary(_ image: UIImage) { PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in guard status == .authorized || status == .limited else { print("无相册写入权限") return } PHPhotoLibrary.shared().performChanges { PHAssetChangeRequest.creationRequestForAsset(from: image) } completionHandler: { success, error in if success { print("保存相册成功") } else { print("保存失败: \(error?.localizedDescription ?? "")") } } } }

如果不想存相册,只存沙盒,直接写文件:

func saveToSandbox(_ image: UIImage) -> URL? { guard let data = image.jpegData(compressionQuality: 0.9) else { return nil } let dir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let url = dir.appendingPathComponent("photo_\(Int(Date().timeIntervalSince1970)).jpg") do { try data.write(to: url) return url } catch { print("写入失败: \(error)") return nil } }

4.4 EXIF 保留

iOS 用CGImageSource读取原始 EXIF,再写入新文件:

func preserveExif(from srcURL: URL, to dstURL: URL) { guard let src = CGImageSourceCreateWithURL(srcURL as CFURL, nil), let type = CGImageSourceGetType(src), let dst = CGImageDestinationCreateWithURL(dstURL as CFURL, type, 1, nil) else { return } let properties = CGImageSourceCopyPropertiesAtIndex(src, 0, nil) CGImageDestinationAddImageFromSource(dst, src, 0, properties) CGImageDestinationFinalize(dst) }

5. 真机验证与常见异常排查清单

代码写完,真机跑一遍才算数。下面是我整理的验证步骤和踩过的坑。

5.1 真机验证步骤

Android 侧:装到真机,点拍照按钮,确认相机拉起;拍完返回,打开系统相册看有没有新图;用adb shell进getExternalFilesDir目录确认临时文件已删除;旋转手机拍一张,检查图片方向是否正确。

iOS 侧:真机运行,首次会弹相机权限和相册权限,都允许;拍完去"照片"App 确认;切后台再回来确认图片还在;用 Xcode 的 Devices 窗口看沙盒目录。

5.2 常见报错对照

报错信息原因解决
FileUriExposedExceptionAndroid 7+ 用了file://URI改用FileProvider.getUriForFile
onActivityResult中data为 null指定了EXTRA_OUTPUT直接用自己创建的photoUri,别读 data
local proxy failed网络请求走了本地代理但代理没起检查网络配置,确认 API 端点可达
401 UnauthorizedAPI Key 无效或未带检查请求头Authorization: Bearer <key>
reading choices相关解析错误模型返回格式与预期不符检查请求体 JSON 结构,确认 model 字段正确
OAuth相关报错鉴权方式不匹配确认用的是 API Key 而非 OAuth 流程
iOS 保存相册无反应缺NSPhotoLibraryAddUsageDescription补上 plist 声明
图片方向不对EXIF orientation 丢失读取并写回 orientation 属性

5.3 失败回滚

拍照失败或保存失败时,一定要清理临时文件,否则沙盒会越积越大:

private fun rollback() { if (::tempFile.isInitialized && tempFile.exists()) { tempFile.delete() } }

在resultCode != RESULT_OK时调用,以及保存相册异常时调用。

5.4 接入 AI 处理时的配置三件套

如果你拍完照要调模型,不管是 Claude Code、Cline MCP 还是 Codex,配置都离不开三件套:Base URL、Key、Model ID。以 Codex 的auth.json为例:

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

Cline MCP 的配置类似,在设置里填 Base URL 和 Key,选好 Model ID。Claude Code 的话,用 Coding Plan 页面给的配置直接替换即可。三件套缺一不可,尤其是 Model ID 写错会直接报模型不存在。

6. 从拍照到 AI 识别:把图像采集链路接到统一通道

拍照保存只是第一步,真正的价值在于拍完之后能做什么。比如智能硬件 App 拍设备铭牌做 OCR、拍商品做识别、拍文档做摘要,这些都需要把图片送给模型。

接入方式很简单:拍照拿到图片文件后,转成 base64 或上传拿到 URL,然后通过统一 API 端点发请求。请求头带上你的 Key,请求体里指定 model 和 messages,图片放在 content 数组里。

curl https://taotoken.net/api/v1/messages \ -H "Authorization: Bearer sk-你的Key" \ -H "Content-Type: application/json" \ -d '{ "model": "claude-sonnet-4-20250514", "messages": [ { "role": "user", "content": [ {"type": "text", "text": "描述这张图片的内容"}, {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": "..."}} ] } ] }'

这样你的拍照链路和 AI 链路就打通了。Key 的管理、模型的切换都在一个通道里完成,不用为每个模型单独维护配置。

需要 Key 的话去控制台创建:https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite 。接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite ,里面有各语言的示例。想先测模型效果就去模型对话页:https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite 。长期做编码和 Agent 的可以看 Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 。

最后说个实用技巧:拍照临时文件一定要放在getExternalFilesDir或cacheDir,别放DCIM;保存相册用MediaStore的IS_PENDING机制;EXIF 的 orientation 一定要处理,不然用户拍竖图显示横图。这三点做到,移动端拍照保存基本就稳了。

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

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

立即咨询