从拍照到相册,安全高效地处理图片

前言

很多应用都允许用户通过拍照或从相册选择图片来设置头像、发动态,这是非常普遍的功能。

现在,我们来一步步地实现这个功能。

准备工作

新建一个名为 CameraAlbumTestEmpty Views Activity 项目,然后在模块的 build.gradle.kts 文件中开启 View Binding,以便我们安全地访问视图。

kotlin 复制代码
android {
    // ...
    buildFeatures{
        viewBinding = true
    }
}

调用摄像头拍照

先来完成拍照功能。

布局和点击事件

首先,在布局中添加 ButtonImageView 控件,前者用于打开摄像头进行拍照,后者用于将拍到的照片显示出来。

activity_main.xml 文件中的代码如下:

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <Button
        android:id="@+id/takePhotoBtn"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Take Photo" />

    <ImageView
        android:id="@+id/imageView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:layout_marginTop="16dp"
        android:contentDescription="图片预览区域" />
</LinearLayout>

然后,在 MainActivity 中给按钮注册点击事件,代码如下:

kotlin 复制代码
class MainActivity : AppCompatActivity() {
    // ViewBinding
    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        binding.takePhotoBtn.setOnClickListener {
            // 拍照的逻辑
            takePicture()
        }
    }

    private fun takePicture() {
        // 启动相机
    }
}

准备 URI

我们将会在 takePicture() 方法中启动相机。但这里有一个问题,当拍完照后,相机应用该把照片存放在哪?

应该由我们来指定,还是相机应用告诉我们?答案是我们说了算。我们创建一个文件,然后把指向这个文件的安全 URI 传给相机应用,这样相机应用就知道该存哪了。

MainActivity 中添加如下代码:

kotlin 复制代码
class MainActivity : AppCompatActivity() {

    // ...

    /**
     * 用于存储拍照后图片的物理文件路径
     */
    private var latestImageFile: File? = null


    // ... onCreate() 方法 ...

    private fun takePicture() {
        // 创建一个用于存储图片的临时URI
        val imageUri = getTmpFileUri()

        // 启动相机
    }

    /**
     * 创建一个临时的、唯一的图片文件,并返回其内容的 Uri
     */
    private fun getTmpFileUri(): Uri {
        // 使用时间戳创建唯一文件名,避免覆盖问题
        val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
        
        // 在应用的外部缓存目录中创建一个文件
        val imageFile = File(externalCacheDir, "JPEG_${timestamp}.jpg").also {
            // 保留文件引用,以便后续使用
            this.latestImageFile = it
        }
        
        // 使用 FileProvider 生成一个安全的、可共享的 content:// URI
        return FileProvider.getUriForFile(
            applicationContext,
            "com.example.cameraalbumtest.fileprovider",
            imageFile
        )
    }
}

我们在 getTmpFileUri() 方法中,创建了一个图片文件,这个文件存放在 externalCacheDir 应用关联缓存目录下。因为读写这个目录无需申请权限,用户卸载应用后还会自动删除这个目录下的所有文件。文件名使用了当前的时间戳,这样每次拍照的文件都是唯一的,避免了覆盖问题。

接着调用了 FileProvider.getUriForFile() 方法将 File 对象转为了 URI 对象并返回,准备传给相机应用。

那你可能会好奇,为什么不将文件的真实路径直接传给相机应用?

因为在 Android 7.0 系统以上,如果直接使用真实文件路径(如 file://)的 URI 传递给其他应用,会抛出 FileUriExposedException 异常。而使用 FileProvider 生成的 Content URI 能将文件的访问权限临时授权给拍照应用。

现在我们运行程序并点击按钮,会报错:java.lang.IllegalArgumentException: Couldn't find meta-data for provider with authority com.example.cameraalbumtest.fileprovider

这是正常的,因为使用了 FileProvider,就要在清单文件中注册它。

配置 FileProvider

AndroidManifest.xml 文件中注册 FileProvider,让相机应用能够安全访问我们创建的文件。代码如下:

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<manifest ...>
    <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>

android:name 属性的值是固定的,android:authorities 值中的 applicationId 是一个占位符,编译时会自动替换为应用的包名。

同时,为了不硬编码,回到 getTmpFileUri() 方法中,在 authority 字符串中也嵌入变量:

kotlin 复制代码
return FileProvider.getUriForFile(
    applicationContext,
    "${packageName}.fileprovider", // 动态获取包名
    imageFile
)

另外,我们使用了 <meta-data> 标签指定了 Uri 的共享路径,其中引用了 @xml/file_paths 资源。这个资源还不存在,我们来创建它。

创建 res/xml/file_paths.xml 文件,代码如下:

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<paths>
    <external-cache-path name="my_images" path="." />
</paths>

外部存储上的专属缓存目录对应的标签是 <external-cache-path>name 属性的值可以随便填,path 属性用来指定共享的具体路径,这里的 . 表示共享专属缓存目录的根路径下的所有文件。

现在你运行程序并点击按钮,程序并不会崩溃。

启动相机并显示图片

我们使用 Activity Result API 来启动相机应用(传递 Uri)并处理其返回结果。

MainActivity 的代码如下:

kotlin 复制代码
class MainActivity : AppCompatActivity() {

    // ...

    /**
     * 用于存储拍照后图片的物理文件路径
     */
    private var latestImageFile: File? = null

    /**
     * Activity Result API 启动器,处理相机拍照结果。
     */
    private val takePictureLauncher = registerForActivityResult(
        ActivityResultContracts.TakePicture()
    ) { success: Boolean ->
        // 当结果返回时,会执行这里的代码
        if (success) {
            // 拍照成功了
            // 加载图片并显示到 ImageView 控件上
            println("拍照成功,图片文件路径: ${latestImageFile?.absolutePath}")
        }
    }


    // ...

    private fun takePicture() {
        val imageUri = getTmpFileUri()

        // 启动相机
        takePictureLauncher.launch(imageUri)
    }

    // ... 
}

当我们关闭相机应用后,系统会调用 registerForActivityResult 回调,传入表示拍照是否成功的 success 值。

现在运行程序并点击按钮,可以看到相机应用被打开了:

现在我们就来加载图片,但你可不要直接这样写:

kotlin 复制代码
if (success) {
    val bitmap = BitmapFactory.decodeFile(latestImageFile?.absolutePath)
    binding.imageView.setImageBitmap(bitmap)
}

因为手机拍摄出来的原图分辨率很高,文件非常大。直接加载会占用很大内存,容易引入内存溢出错误(OOM),导致应用崩溃。所以我们需要将图片进行缩放后才加载。

并且由于手机拍照时,会记录旋转角度,而使用 BitmapFactory 进行解码时会忽略这个信息,导致加载的图片可能不是正向的。所以我们创建 rotateIfRequired() 方法修正图片的方向。

MainActivity 中的代码如下:

kotlin 复制代码
class MainActivity : AppCompatActivity() {

    // ...

    /**
     * 从文件安全地加载一个经过采样缩放的 Bitmap,以避免OOM。
     * 此方法包含二次采样和EXIF旋转修正。
     * @param reqWidth 期望的目标宽度。
     * @param reqHeight 期望的目标高度。
     * @return 加载并处理后的 Bitmap,或在失败时返回 null。
     */
    private fun loadSampledBitmapFromFile(reqWidth: Int, reqHeight: Int): Bitmap? {
        val imageFile = latestImageFile ?: return null
        return try {
            // 仅解码边界,获取原始图片尺寸
            val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
            BitmapFactory.decodeFile(imageFile.absolutePath, options)

            // 根据目标尺寸计算采样率
            options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight)

            // 使用计算出的采样率解码完整的 Bitmap
            options.inJustDecodeBounds = false
            val bitmap = BitmapFactory.decodeFile(imageFile.absolutePath, options)

            // 根据EXIF信息旋转图片
            return rotateIfRequired(bitmap)
        } catch (e: IOException) {
            e.printStackTrace()
            null
        }
    }

    /**
     * 计算 BitmapFactory.Options.inSampleSize 的值
     * @param options 包含原始图片尺寸的 BitmapFactory.Options
     * @param reqWidth 期望的宽度
     * @param reqHeight 期望的高度
     * @return 合适的 inSampleSize 值
     */
    private fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
        val (height: Int, width: Int) = options.run { outHeight to outWidth }
        var inSampleSize = 1
        if (height > reqHeight || width > reqWidth) {
            val halfHeight: Int = height / 2
            val halfWidth: Int = width / 2
            // 计算最大的 inSampleSize 值,该值是2的幂,并保持高度和宽度都大于请求的高度和宽度
            while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) {
                inSampleSize *= 2
            }
        }
        return inSampleSize
    }


    /**
     * 根据 EXIF 信息判断图片是否需要旋转,并返回旋转后的 Bitmap。
     */
    private fun rotateIfRequired(bitmap: Bitmap?): Bitmap? {
        if (bitmap == null) return null

        val imageFile = latestImageFile ?: return bitmap // 获取文件路径
        // 从物理文件路径读取 EXIF 信息
        val ei = ExifInterface(imageFile.absolutePath)
        val orientation = ei.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)

        return when (orientation) {
            ExifInterface.ORIENTATION_ROTATE_90 -> rotateBitmap(bitmap, 90)
            ExifInterface.ORIENTATION_ROTATE_180 -> rotateBitmap(bitmap, 180)
            ExifInterface.ORIENTATION_ROTATE_270 -> rotateBitmap(bitmap, 270)
            else -> bitmap
        }
    }

    /**
     * 旋转 Bitmap
     * @param bitmap 原始 Bitmap
     * @param degree 旋转角度
     * @return 旋转后的 Bitmap
     */
    private fun rotateBitmap(bitmap: Bitmap, degree: Int): Bitmap {
        val matrix = Matrix()
        matrix.postRotate(degree.toFloat())
        val rotatedBitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
        return rotatedBitmap
    }

}

我们通过 inJustDecodeBounds 实现二次采样来避免内存溢出,并通过 ExifInterface 读取照片元数据来修正图片方向。

最后,在拍照成功的回调中,安全地加载图片,代码如下:

kotlin 复制代码
if (success) {
    // 拍照成功了
    // 加载图片
    val bitmap = loadSampledBitmapFromFile(800, 800)
    // 显示到 ImageView 控件上
    binding.imageView.setImageBitmap(bitmap)
}

现在运行程序,点击应用进行拍照。拍照完成后,即可在应用中看到我们刚刚拍摄的照片:

从相册中选择图片

我们现在来实现从相册中选择一张已有的图片。

布局

首先在布局中添加一个按钮,用于从相册中选择图片。

activity_main.xml 文件中的代码如下:

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout ...>

    <Button
        android:id="@+id/takePhotoBtn" ... />

    <Button
        android:id="@+id/fromAlbumBtn"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="From Album" />

    <ImageView ... />
</LinearLayout>

启动相册并显示图片

同样地,我们定义一个 Activity Result API 相册选择启动器。然后在"From Album"按钮的点击事件中,触发启动器去打开内容选择器,并且只显示图片类型的文件。

MainActivity 中添加如下代码:

kotlin 复制代码
class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding

    /**
     * 处理相册选择结果
     */
    private val selectImageLauncher = registerForActivityResult(
        ActivityResultContracts.GetContent()
    ) { uri: Uri? ->
        // 参数为用户所选图片的 Uri
        if (uri != null) {
            // 用户成功选择
            println("从相册选择了图片,URI: $uri")
            // 加载图片并显示
        }
    }


    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        // ...

        // 相册选择按钮点击事件
        binding.fromAlbumBtn.setOnClickListener {
            // 启动相册选择器,并指定MIME类型为图片
            selectImageLauncher.launch("image/*")
        }
    }

}

现在运行程序并点击 "From Album" 按钮,可以看到系统的内容选择器被打开了:

图片选择完成后,会触发 selectImageLauncher 回调,会得到一个指向着选择图片的 Content Uri,注意:和处理相机照片一样,我们也是不能直接解码加载图片的,也是需要对图片进行缩放。

因为这个 Uri 是从相册返回的,我们没有它的真实文件路径,所以我们要使用 ContentResolver 通过输入流(InputStream)来加载图片。MainActivity 中的代码如下:

kotlin 复制代码
class MainActivity : AppCompatActivity() {

    // ...

    /**
     * 从 Content URI 安全地加载一个经过采样缩放的 Bitmap。
     * 适用于从相册等 Content Provider 获取的图片。
     * @param uri 图片的 Content Uri。
     * @param reqWidth 期望的目标宽度。
     * @param reqHeight 期望的目标高度。
     * @return 加载并缩放后的 Bitmap,或在失败时返回 null。
     */
    private fun loadSampledBitmapFromUri(uri: Uri, reqWidth: Int, reqHeight: Int): Bitmap? {
        return try {
            // 使用 ContentResolver 打开来自 URI 的输入流
            contentResolver.openInputStream(uri)?.use { inputStream ->
                // 解码边界,获取原始尺寸
                val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
                BitmapFactory.decodeStream(inputStream, null, options)

                // 计算采样率
                options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight)

                // 使用采样率解码
                options.inJustDecodeBounds = false
                // 注意:输入流在第一次解码后已被消耗,必须重新打开
                contentResolver.openInputStream(uri)?.use { finalInputStream ->
                    return BitmapFactory.decodeStream(finalInputStream, null, options)
                }
            }
        } catch (e: IOException) {
            e.printStackTrace()
            null
        }
    }

}

这里我们并没有对图片进行旋转操作,因为一般相册中的图片方向都是正确的。

最后在 selectImageLauncher的回调中,加载图片并显示到视图中:

kotlin 复制代码
/**
 * 处理相册选择结果
 */
private val selectImageLauncher = registerForActivityResult(
    ActivityResultContracts.GetContent()
) { uri: Uri? ->
    // 参数为用户所选图片的 Uri
    uri?.let {
        // 用户成功选择
        // 加载图片
        val bitmap = loadSampledBitmapFromUri(it, 800, 800)
        // 显示到 ImageView 控件上
        binding.imageView.setImageBitmap(bitmap)
    }
}

现在运行程序并点击 "From Album" 按钮,就会打开系统的内容选择器。随便选取一张图片,即可在界面中看到刚刚选择的图片:

使用 Coil 图片加载库

在真实项目中,推荐使用成熟的图片加载库,比如 Coil。它有着后台加载、缓存、显示占位图、动画效果等功能,还解决了我们之前遇到的问题:自动处理图片旋转、内存安全。

如果使用 Coil 实现,上述代码将会是这样的:

kotlin 复制代码
/**
 * 处理相机拍照结果。
 */
private val takePictureLauncher = registerForActivityResult(
    ActivityResultContracts.TakePicture()
) { success: Boolean ->
    // 当结果返回时,会执行这里的代码
    if (success) {
        // 拍照成功了,加载图片到 ImageView 控件上
        binding.imageView.load(latestImageFile)
    }
}

/**
 * 处理相册选择结果
 */
private val selectImageLauncher = registerForActivityResult(
    ActivityResultContracts.GetContent()
) { uri: Uri? ->
    // 参数为用户所选图片的 Uri
    uri?.let {
        // 用户成功选择,加载图片到 ImageView 控件上
        binding.imageView.load(it)
    }
}

可以看到,无需添加各种辅助函数,只需一行代码,即可完成图片的加载。并且 load() 方法可以接收多种类型的参数(如 File、网络 URL 等)。当然 Coil 的强大远不止于此,更多请查阅官方文档,这里只是简单一提。

使用 Coil 需要在 build.gradle.kts 文件中添加依赖:

kotlin 复制代码
dependencies {
    implementation("io.coil-kt:coil:2.6.0")
}
相关推荐
whysqwhw1 小时前
Egloo 中Kotlin 多平台中的 expect/actual
android
用户2018792831671 小时前
《Android 城堡防御战:ProGuard 骑士的代码混淆魔法》
android
用户2018792831672 小时前
🔐 加密特工行动:Android 中的 AES 与 RSA 秘密行动指南
android
liang_jy3 小时前
Android AIDL 原理
android·面试·源码
用户2018792831673 小时前
Android开发的"魔杖"之ADB命令
android
_荒3 小时前
uniapp AI流式问答对话,问答内容支持图片和视频,支持app和H5
android·前端·vue.js
冰糖葫芦三剑客3 小时前
Android录屏截屏事件监听
android
东风西巷4 小时前
LSPatch:免Root Xposed框架,解锁无限可能
android·生活·软件需求
用户2018792831675 小时前
图书馆书架管理员的魔法:TreeMap 的奇幻之旅
android
androidwork5 小时前
Kotlin实现文件上传进度监听:RequestBody封装详解
android·开发语言·kotlin