Skip to content

相机与相册

源:Android CameraX | iOS UIImagePickerController

相机拍照和相册选择是移动应用的常见功能。Android 和 iOS 提供了不同的 API,本文将展示如何抽象统一的媒体选择接口。

平台差异对比

平台拍照 API相册选择 API权限要求返回格式
AndroidCameraX / Camera2 / IntentMediaStore / SAFCAMERAREAD_MEDIA_IMAGESURI / File Path
iOSUIImagePickerController / AVFoundationPHPickerViewControllerNSCameraUsageDescription、NSPhotoLibraryUsageDescriptionUIImage / PHAsset
Desktop无原生支持FileChooser无需权限File Path

expect/actual 实现方案

API 核心签名说明

  • data class MediaFile
  • enum class MediaType
  • expect class MediaPicker
  • suspend 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() 在某些设备上有数量限制。 :::

解决方案:
使用自定义相册选择器或第三方库。