相机与相册
源:Android CameraX | iOS UIImagePickerController
相机拍照和相册选择是移动应用的常见功能。Android 和 iOS 提供了不同的 API,本文将展示如何抽象统一的媒体选择接口。
平台差异对比
| 平台 | 拍照 API | 相册选择 API | 权限要求 | 返回格式 |
|---|---|---|---|---|
| Android | CameraX / Camera2 / Intent | MediaStore / SAF | CAMERA、READ_MEDIA_IMAGES | URI / File Path |
| iOS | UIImagePickerController / AVFoundation | PHPickerViewController | NSCameraUsageDescription、NSPhotoLibraryUsageDescription | UIImage / PHAsset |
| Desktop | 无原生支持 | FileChooser | 无需权限 | File Path |
expect/actual 实现方案
API 核心签名说明
data class MediaFileenum class MediaTypeexpect class MediaPickersuspend fun MediaPicker.takePh oto(): MediaFile?suspend fun MediaPicker.pickFromGallery(type: MediaType): List<MediaFile>suspend fun MediaPicker.pickMultipleFromGallery(type: MediaType, maxCount: Int): List<MediaFile>
标准代码块
kotlin
data class MediaFile(
val path: String,
val uri: String,
val mimeType: String,
val size: Long
)
enum class MediaType {
IMAGE,
VIDEO,
ALL
}
expect class MediaPicker {
suspend fun takePhoto(): MediaFile?
suspend fun pickFromGallery(type: MediaType = MediaType.IMAGE): MediaFile?
suspend fun pickMultipleFromGallery(
type: MediaType = MediaType.IMAGE,
maxCount: Int = 10
): List<MediaFile>
}
// 业务层使用
class AvatarUploader(private val mediaPicker: MediaPicker) {
suspend fun selectAvatar(): MediaFile? {
// 弹出选择对话框:拍照或从相册选择
return mediaPicker.pickFromGallery(MediaType.IMAGE)
}
suspend fun takeNewPhoto(): MediaFile? {
return mediaPicker.takePhoto()
}
suspend fun selectMultiplePhotos(): List<MediaFile> {
return mediaPicker.pickMultipleFromGallery(
type = MediaType.IMAGE,
maxCount = 9
)
}
}kotlin
import android.content.Context
import android.net.Uri
import android.provider.MediaStore
import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.FileProvider
import kotlinx.coroutines.suspendCancellableCoroutine
import java.io.File
import kotlin.coroutines.resume
actual class MediaPicker(private val activity: ComponentActivity) {
private var photoUri: Uri? = null
actual suspend fun takePhoto(): MediaFile? = suspendCancellableCoroutine { continuation ->
val photoFile = createImageFile(activity)
photoUri = FileProvider.getUriForFile(
activity,
"${activity.packageName}.fileprovider",
photoFile
)
val launcher = activity.registerForActivityResult(
ActivityResultContracts.TakePicture()
) { success ->
if (success && photoUri != null) {
val mediaFile = MediaFile(
path = photoFile.absolutePath,
uri = photoUri.toString(),
mimeType = "image/jpeg",
size = photoFile.length()
)
continuation.resume(mediaFile)
} else {
continuation.resume(null)
}
}
launcher.launch(photoUri)
}
actual suspend fun pickFromGallery(type: MediaType): MediaFile? =
suspendCancellableCoroutine { continuation ->
val mimeType = when (type) {
MediaType.IMAGE -> "image/*"
MediaType.VIDEO -> "video/*"
MediaType.ALL -> "*/*"
}
val launcher = activity.registerForActivityResult(
ActivityResultContracts.GetContent()
) { uri ->
if (uri != null) {
val mediaFile = uriToMediaFile(activity, uri)
continuation.resume(mediaFile)
} else {
continuation.resume(null)
}
}
launcher.launch(mimeType)
}
actual suspend fun pickMultipleFromGallery(
type: MediaType,
maxCount: Int
): List<MediaFile> = suspendCancellableCoroutine { continuation ->
val mimeType = when (type) {
MediaType.IMAGE -> "image/*"
MediaType.VIDEO -> "video/*"
MediaType.ALL -> "*/*"
}
val launcher = activity.registerForActivityResult(
ActivityResultContracts.GetMultipleContents()
) { uris ->
val mediaFiles = uris.take(maxCount).mapNotNull { uri ->
uriToMediaFile(activity, uri)
}
continuation.resume(mediaFiles)
}
launcher.launch(mimeType)
}
private fun createImageFile(context: Context): File {
val timestamp = System.currentTimeMillis()
val storageDir = context.getExternalFilesDir(null)
return File.createTempFile("IMG_${timestamp}_", ".jpg", storageDir)
}
private fun uriToMediaFile(context: Context, uri: Uri): MediaFile? {
val mimeType = context.contentResolver.getType(uri) ?: return null
context.contentResolver.query(
uri,
arrayOf(MediaStore.MediaColumns.SIZE, MediaStore.MediaColumns.DISPLAY_NAME),
null, null, null
)?.use { cursor ->
if (cursor.moveToFirst()) {
val sizeIndex = cursor.getColumnIndex(MediaStore.MediaColumns.SIZE)
val size = if (sizeIndex >= 0) cursor.getLong(sizeIndex) else 0L
return MediaFile(
path = uri.path ?: "",
uri = uri.toString(),
mimeType = mimeType,
size = size
)
}
}
return null
}
}kotlin
import platform.UIKit.*
import platform.Photos.*
import platform.Foundation.*
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
actual class MediaPicker {
actual suspend fun takePhoto(): MediaFile? = suspendCancellableCoroutine { continuation ->
val picker = UIImagePickerController().apply {
sourceType = UIImagePickerControllerSourceTypeCamera
delegate = object : NSObject(), UIImagePickerControllerDelegateProtocol {
override fun imagePickerController(
picker: UIImagePickerController,
didFinishPickingMediaWithInfo: Map<Any?, *>
) {
val image = didFinishPickingMediaWithInfo[UIImagePickerControllerOriginalImage] as? UIImage
if (image != null) {
val mediaFile = saveImageToFile(image)
continuation.resume(mediaFile)
} else {
continuation.resume(null)
}
picker.dismissViewControllerAnimated(true, null)
}
override fun imagePickerControllerDidCancel(picker: UIImagePickerController) {
continuation.resume(null)
picker.dismissViewControllerAnimated(true, null)
}
}
}
val rootVC = UIApplication.sharedApplication.keyWindow?.rootViewController
rootVC?.presentViewController(picker, animated = true, completion = null)
}
actual suspend fun pickFromGallery(type: MediaType): MediaFile? =
suspendCancellableCoroutine { continuation ->
// iOS 14+ 推荐使用 PHPickerViewController
// 这里使用传统的 UIImagePickerController 简化示例
val picker = UIImagePickerController().apply {
sourceType = UIImagePickerControllerSourceTypePhotoLibrary
mediaTypes = when (type) {
MediaType.IMAGE -> listOf("public.image")
MediaType.VIDEO -> listOf("public.movie")
MediaType.ALL -> listOf("public.image", "public.movie")
}
delegate = object : NSObject(), UIImagePickerControllerDelegateProtocol {
override fun imagePickerController(
picker: UIImagePickerController,
didFinishPickingMediaWithInfo: Map<Any?, *>
) {
val image = didFinishPickingMediaWithInfo[UIImagePickerControllerOriginalImage] as? UIImage
if (image != null) {
val mediaFile = saveImageToFile(image)
continuation.resume(mediaFile)
} else {
continuation.resume(null)
}
picker.dismissViewControllerAnimated(true, null)
}
override fun imagePickerControllerDidCancel(picker: UIImagePickerController) {
continuation.resume(null)
picker.dismissViewControllerAnimated(true, null)
}
}
}
val rootVC = UIApplication.sharedApplication.keyWindow?.rootViewController
rootVC?.presentViewController(picker, animated = true, completion = null)
}
actual suspend fun pickMultipleFromGallery(
type: MediaType,
maxCount: Int
): List<MediaFile> {
// UIImagePickerController 不支持多选
// 需要使用 PHPickerViewController (iOS 14+) 或第三方库
return emptyList()
}
private fun saveImageToFile(image: UIImage): MediaFile {
val timestamp = NSDate().timeIntervalSince1970.toLong()
val filename = "IMG_${timestamp}.jpg"
val documentsPath = NSSearchPathForDirectoriesInDomains(
NSDocumentDirectory,
NSUserDomainMask,
true
).first() as String
val filePath = "$documentsPath/$filename"
val imageData = UIImageJPEGRepresentation(image, 0.8)
imageData?.writeToFile(filePath, atomically = true)
return MediaFile(
path = filePath,
uri = "file://$filePath",
mimeType = "image/jpeg",
size = imageData?.length?.toLong() ?: 0L
)
}
}kotlin
import java.io.File
import javax.swing.JFileChooser
import javax.swing.filechooser.FileNameExtensionFilter
actual class MediaPicker {
actual suspend fun takePhoto(): MediaFile? {
// Desktop 无相机支持
return null
}
actual suspend fun pickFromGallery(type: MediaType): MediaFile? {
val chooser = JFileChooser().apply {
fileFilter = when (type) {
MediaType.IMAGE -> FileNameExtensionFilter(
"图片文件",
"jpg", "jpeg", "png", "gif", "bmp"
)
MediaType.VIDEO -> FileNameExtensionFilter(
"视频文件",
"mp4", "avi", "mov", "mkv"
)
MediaType.ALL -> FileNameExtensionFilter(
"媒体文件",
"jpg", "jpeg", "png", "gif", "mp4", "avi", "mov"
)
}
}
return if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) {
val file = chooser.selectedFile
MediaFile(
path = file.absolutePath,
uri = file.toURI().toString(),
mimeType = guessMimeType(file),
size = file.length()
)
} else {
null
}
}
actual suspend fun pickMultipleFromGallery(
type: MediaType,
maxCount: Int
): List<MediaFile> {
val chooser = JFileChooser().apply {
isMultiSelectionEnabled = true
fileFilter = FileNameExtensionFilter("媒体文件", "jpg", "png", "mp4")
}
return if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) {
chooser.selectedFiles.take(maxCount).map { file ->
MediaFile(
path = file.absolutePath,
uri = file.toURI().toString(),
mimeType = guessMimeType(file),
size = file.length()
)
}
} else {
emptyList()
}
}
private fun guessMimeType(file: File): String {
return when (file.extension.lowercase()) {
"jpg", "jpeg" -> "image/jpeg"
"png" -> "image/png"
"gif" -> "image/gif"
"mp4" -> "video/mp4"
"avi" -> "video/x-msvideo"
else -> "application/octet-stream"
}
}
}代码封装示例
以下是带图片压缩和缓存的完整封装:
kotlin
// commonMain
interface ImageCompressor {
suspend fun compress(file: MediaFile, maxWidth: Int, quality: Int): MediaFile
}
class MediaManager(
private val picker: MediaPicker,
private val compressor: ImageCompressor
) {
suspend fun selectAndCompressImage(
maxWidth: Int = 1080,
quality: Int = 80
): MediaFile? {
val original = picker.pickFromGallery(MediaType.IMAGE) ?: return null
return compressor.compress(original, maxWidth, quality)
}
suspend fun takePhotoAndCompress(
maxWidth: Int = 1080,
quality: Int = 80
): MediaFile? {
val original = picker.takePhoto() ?: return null
return compressor.compress(original, maxWidth, quality)
}
}第三方库推荐
Moko Media Picker
IceRock 开发的跨平台媒体选择器。
优势:
- ✅ 统一 API,支持拍照和相册选择
- ✅ 支持多选
- ✅ 自动处理权限请求
使用示例:
kotlin
val mediaPicker = createMediaPicker()
val image = mediaPicker.pickImage()
val video = mediaPicker.pickVideo()
val media = mediaPicker.pickMedia()依赖补充
Android 依赖
kotlin
kotlin {
sourceSets {
androidMain.dependencies {
implementation("androidx.activity:activity-ktx:1.8.2")
}
}
}AndroidManifest.xml 配置
xml
<!-- 相机权限 -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- Android 13+ 相册权限 -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<!-- Android 12 及以下 -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<!-- FileProvider 配置 -->
<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>res/xml/file_paths.xml
xml
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-files-path name="images" path="/" />
<cache-path name="cache" path="/" />
</paths>iOS Info.plist 配置
xml
<key>NSCameraUsageDescription</key>
<string>需要访问相机以拍摄照片</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>需要访问相册以选择照片</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>需要保存照片到相册</string>Moko Media Picker(推荐)
kotlin
kotlin {
sourceSets {
commonMain.dependencies {
implementation("dev.icerock.moko:media:0.9.0")
}
}
}最新版本查看链接:https://github.com/icerockdev/moko-media/releases
实战坑点
Android FileProvider 配置缺失
FileUriExposedException
Android 7.0+ 直接传递 file:// URI 会抛出 FileUriExposedException。
解决方案:
使用 FileProvider 生成 content:// URI:
kotlin
val photoUri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
photoFile
)Android 相册权限变更
权限名称变化
Android 13 (API 33) 将 READ_EXTERNAL_STORAGE 拆分为 READ_MEDIA_IMAGES 和 READ_MEDIA_VIDEO。
兼容代码:
kotlin
val permission = if (Build.VERSION.SDK_INT >= 33) {
Manifest.permission.READ_MEDIA_IMAGES
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
}iOS 图片内存占用
内存溢出
UIImage 在内存中未压缩,高分辨率图片可能占用数十 MB。
解决方案:
立即转换为压缩的 JPEG 数据:
kotlin
val imageData = UIImageJPEGRepresentation(image, 0.8)
imageData?.writeToFile(path, atomically = true)
// 不再持有 UIImage 引用iOS 14+ 隐私提示
PHPickerViewController
iOS 14+ 推荐使用 PHPickerViewController,无需相册权限,用户体验更好。
迁移示例:
swift
// Swift 侧封装
let configuration = PHPickerConfiguration()
configuration.selectionLimit = 10
let picker = PHPickerViewController(configuration: configuration)Android 多选限制
::: caution GetMultipleContents 限制 ActivityResultContracts.GetMultipleContents() 在某些设备上有数量限制。 :::
解决方案:
使用自定义相册选择器或第三方库。