前言
很多应用都允许用户通过拍照或从相册选择图片来设置头像、发动态,这是非常普遍的功能。
现在,我们来一步步地实现这个功能。
准备工作
新建一个名为 CameraAlbumTest
的 Empty Views Activity 项目,然后在模块的 build.gradle.kts
文件中开启 View Binding
,以便我们安全地访问视图。
kotlin
android {
// ...
buildFeatures{
viewBinding = true
}
}
调用摄像头拍照
先来完成拍照功能。
布局和点击事件
首先,在布局中添加 Button
和 ImageView
控件,前者用于打开摄像头进行拍照,后者用于将拍到的照片显示出来。
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")
}