CameraXGoogle 为了简化 Androidcamera 开发而提供的一个库. 这个库包含在 Jetpack 中. 在 2019 Google IO 大会 上发布了 alpha 版本, 并且在今年发布了 beta 版本.

本文介绍使用 CameraX 来预览摄像头图像, 拍照并分析来自相机的图像流

CameraX What you'll do

新建项目

打开 Android Studio 新建一个 Android 项目. (语言选择 Kotlin)

添加 Gradle 依赖

打开 build.gradle(Module: app) 文件, 添加 CameraX 的依赖:

1
2
3
4
5
6
7
8
implementation fileTree(dir: "libs", include: ["*.jar"])
def camerax_version = "1.0.0-beta06"
// CameraX core library using camera2 implementation
implementation "androidx.camera:camera-camera2:$camerax_version"
// CameraX Lifecycle Library
implementation "androidx.camera:camera-lifecycle:$camerax_version"
// CameraX View class
implementation "androidx.camera:camera-view:1.0.0-alpha13"

CameraX 需要一些 Java 8 的方法, 所以在 buildTypes 里要设置编译参数:

1
2
3
4
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}

修改完成后点击同步, 就可以在 app 中调用 CameraX 的依赖了.

创建布局

先创建一个 values/strings.xml 文件:

1
2
3
4
<resources>
<string name="app_name">CameraX 示例</string>
<string name="takePhoto">拍照</string>
</resources>

然后打开 layout/activity_main.xml, 删除其中的 <Text/> 标签, 加上一个 Button 和一个 androidx.camera.view.PreviewView(预览摄像头画面)

修改后代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">

<Button
android:id="@+id/camera_capture_button"
android:layout_width="100dp"
android:layout_height="100dp"
android:layout_marginBottom="50dp"
android:scaleType="fitCenter"
android:text="@string/takePhoto"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:elevation="2dp" />

<androidx.camera.view.PreviewView
android:id="@+id/viewFinder"
android:layout_width="match_parent"
android:layout_height="match_parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

设置 MainActivity.kt

用下面的骨架代码替换 MainActivity.kt 原有代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.Manifest
import android.content.pm.PackageManager
import android.net.Uri
import android.util.Log
import android.widget.Button
import android.widget.Toast
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import java.util.concurrent.Executors
import androidx.camera.core.*
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import kotlinx.android.synthetic.main.activity_main.*
import java.io.File
import java.nio.ByteBuffer
import java.text.SimpleDateFormat
import java.util.*
import java.util.concurrent.ExecutorService
typealias LumaListener = (luma: Double) -> Unit

class MainActivity : AppCompatActivity() {
private var preview: Preview? = null
private var imageCapture: ImageCapture? = null
private var imageAnalyzer: ImageAnalysis? = null
private var camera: Camera? = null

private lateinit var outputDirectory: File
private lateinit var cameraExecutor: ExecutorService

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

// Request camera permissions
if (allPermissionsGranted()) {
startCamera()
} else {
ActivityCompat.requestPermissions(
this, REQUIRED_PERMISSIONS, REQUEST_CODE_PERMISSIONS)
}

// Setup the listener for take photo button
camera_capture_button.setOnClickListener { takePhoto() }

outputDirectory = getOutputDirectory()

cameraExecutor = Executors.newSingleThreadExecutor()
}

private fun startCamera() {
// TODO
}

private fun takePhoto() {
// TODO
}

private fun allPermissionsGranted() = false

fun getOutputDirectory(): File {
val mediaDir = externalMediaDirs.firstOrNull()?.let {
File(it, resources.getString(R.string.app_name)).apply { mkdirs() } }
return if (mediaDir != null && mediaDir.exists())
mediaDir else filesDir
}

companion object {
private const val TAG = "CameraXBasic"
private const val FILENAME_FORMAT = "yyyy-MM-dd-HH-mm-ss-SSS"
private const val REQUEST_CODE_PERMISSIONS = 10
private val REQUIRED_PERMISSIONS = arrayOf(Manifest.permission.CAMERA)
}
}

骨架包括导入语句, 将要实例化的变量, 将要实现的函数和常量. onCreate() 已经实现了检查相机权限, 启动相机功能, 按钮的监听onClickListener(), 并实现 outputDirectorycameraExecutor. 目前相机也将无法工作, 需要在之后实现那些方法.

现在的程序运行后可以看到:

Create Project

获得摄像头访问权限

在调用摄像头前, 需要获得摄像头访问权限

声明权限

首先打开 AndroidManifest.xml 然后把下面的代码粘贴在 application 标签前面:

1
2
<uses-feature android:name="android.hardware.camera.any" />
<uses-permission android:name="android.permission.CAMERA" />

第一行使用 android.hardware.camera.any 可确保该设备具有摄像头, 而 .any 则意味着它可以是前置摄像头或后置摄像头.如果你没有使用 .any, 马尔如果你在没有后置摄像头的设备上运行程序就不会工作

第二行才是真正 app 添加了访问该摄像机的权限

判断权限申请状态

首先修改 allPermissionsGranted() 方法, 在骨架代码中这个方法默认返回 false, 重写这个方法:

1
2
3
4
private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all {       
ContextCompat.checkSelfPermission(
baseContext, it) == PackageManager.PERMISSION_GRANTED
}

MainActivity.kt 中重写 onRequestPermissionsResult 来获取权限请求的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
override fun onRequestPermissionsResult(
requestCode: Int, permissions: Array<String>, grantResults:
IntArray) {
if (requestCode == REQUEST_CODE_PERMISSIONS) {
if (allPermissionsGranted()) {
startCamera()
} else {
Toast.makeText(this,
"Permissions not granted by the user.",
Toast.LENGTH_SHORT).show()
finish()
}
}
}

上面的代码首先使用:

1
2
3
if (requestCode == REQUEST_CODE_PERMISSIONS) {

}

调用上面修改的 allPermissionsGranted() 方法来检查请求代码是否正确, 如果不正确则忽略. 如果正确则检查用户是否授予了权限.

  • 如果授权则启动摄像头:

    1
    2
    3
    if (allPermissionsGranted()) {
    startCamera()
    }
  • 如果未授予权限就用 Tosat 通知用户未授予权限:

    1
    2
    3
    4
    5
    6
    else {
    Toast.makeText(this,
    "Permissions not granted by the user.",
    Toast.LENGTH_SHORT).show()
    finish()
    }

现在重新运行程序, 首次打开的时候会在应用内请求访问摄像头的权限:

Permission Request

显示摄像头预览图像

在得到访问摄像头的权限后, 会调用 startCamera() 方法来启动摄像头, 这里在 startCamera() 方法实现获取摄像头实例并把摄像头预览图像显示出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
private fun startCamera() {
val cameraProviderFuture = ProcessCameraProvider.getInstance(this)

cameraProviderFuture.addListener(Runnable {
// Used to bind the lifecycle of cameras to the lifecycle owner
val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()

// Preview
preview = Preview.Builder()
.build()

// Select back camera
val cameraSelector = CameraSelector.Builder().requireLensFacing(CameraSelector.LENS_FACING_BACK).build()

try {
// Unbind use cases before rebinding
cameraProvider.unbindAll()

// Bind use cases to camera
camera = cameraProvider.bindToLifecycle(
this, cameraSelector, preview)
preview?.setSurfaceProvider(viewFinder.createSurfaceProvider())
} catch(exc: Exception) {
Log.e(TAG, "Use case binding failed", exc)
}

}, ContextCompat.getMainExecutor(this))
}

首先创建一个 ProcessCameraProvider 的实例:

1
val cameraProviderFuture = ProcessCameraProvider.getInstance(this)

这用于绑定摄像机的生命周期. CameraX 库接管了摄像头的生命周期, 因此使用的时候不需要关心打开和关闭相机.

然后在 cameraProviderFuture 添加一个监听:

第一个参数是 Runnable {}, 第二个参数是 ContextCompat.getMainExecutor(). ContextCompat.getMainExecutor() 将会返回一个在主线程上运行的 Executor

1
cameraProviderFuture.addListener(Runnable {}, ContextCompat.getMainExecutor(this))

在上面地一个参数 Runnable 中, 添加 ProcessCameraProvider, 用于把摄像头的生命周期绑定到应用程序的 LifecycleOwner:

1
val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()

上面的代码已经实现了托管摄像头的生命周期, 接下来要实现摄像头画面预览就只需要实例化一个 Preview 对象, 并绑定到摄像头的生命周期内即可. 首先初始化一个 Preview 对象:

1
preview = Preview.Builder().build()

然后创建一个 CameraSelector 对象,然后使用它的 CameraSelector.Builder.requireLensFacing 方法调用你想访问的摄像头(CameraSelector.LENS_FACING_BACK 表示后置摄像头)

1
val cameraSelector = CameraSelector.Builder().requireLensFacing(CameraSelector.LENS_FACING_BACK).build()

最后编写一个 try-catch 代码块. 在该代码块内, 先使用 unbindAll 确保没有任何内容绑定到 cameraProvider, 然后将前面的 cameraSelector 和预览对象 unbindAll 绑定到 cameraProvider. 再将 viewFinderSurfaceProvider 设置为 previewSurfaceProvider:

1
2
3
4
5
6
try {
cameraProvider.unbindAll()
camera = cameraProvider.bindToLifecycle(
this, cameraSelector, preview)
preview?.setSurfaceProvider(viewFinder.createSurfaceProvider(camera?.cameraInfo))
}

运行程序, 你就可以看到摄像头捕获的预览画面:

Implement Preview

实现图像捕获的功能

在骨架代码中, 预留了一个按钮用于捕获图片. 在按钮点击后会运行 takePhoto() 方法, 所以只需要在该方法实现图像捕获的业务逻辑即可.

实现 takePhoto() 方法

首先获取 ImageCapture 对象, 如果获取不到则直接返回:

1
val imageCapture = imageCapture ?: return

然后创建一个 File 对象用于指定储存一会捕获到的图片的位置和文件名:

1
2
3
4
val photoFile = File(
outputDirectory,
SimpleDateFormat(FILENAME_FORMAT, Locale.US
).format(System.currentTimeMillis()) + ".jpg")

创建一个 OutputFileOptions 对象用于制定输出. 这里需要将输出保存在我们刚刚创建的文件中, 因此刚才创建的 photoFile:

1
val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build()

最后就可以调用 imageCapture 对象的 takePicture() 方法. 传入 outputOptions, 执行程序 {...} 和保存图像的 callback 方法:

1
2
3
imageCapture.takePicture(
outputOptions, ContextCompat.getMainExecutor(this), object : ImageCapture.OnImageSavedCallback {}
)

如果图像捕获失败或保存图像捕获失败:

1
2
3
override fun onError(exc: ImageCaptureException) {
Log.e(TAG, "Photo capture failed: ${exc.message}", exc)
}

如果图像捕获成功, 则照片保存到之前创建的文件中, 并打印保存路径:

1
2
3
4
5
6
override fun onImageSaved(output: ImageCapture.OutputFileResults) {
val savedUri = Uri.fromFile(photoFile)
val msg = "Photo capture succeeded: $savedUri"
Toast.makeText(baseContext, msg, Toast.LENGTH_SHORT).show()
Log.d(TAG, msg)
}

最后 takePhoto() 完整的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
private fun takePhoto() {
// Get a stable reference of the modifiable image capture use case
val imageCapture = imageCapture ?: return

// Create timestamped output file to hold the image
val photoFile = File(
outputDirectory,
SimpleDateFormat(FILENAME_FORMAT, Locale.US
).format(System.currentTimeMillis()) + ".jpg")

// Create output options object which contains file + metadata
val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build()

// Setup image capture listener which is triggered after photo has
// been taken
imageCapture.takePicture(
outputOptions, ContextCompat.getMainExecutor(this), object : ImageCapture.OnImageSavedCallback {
override fun onError(exc: ImageCaptureException) {
Log.e(TAG, "Photo capture failed: ${exc.message}", exc)
}

override fun onImageSaved(output: ImageCapture.OutputFileResults) {
val savedUri = Uri.fromFile(photoFile)
val msg = "Photo capture succeeded: $savedUri"
Toast.makeText(baseContext, msg, Toast.LENGTH_SHORT).show()
Log.d(TAG, msg)
}
})
}

创建 ImageCapture 对象并绑定到摄像头生命周期

前面图像捕获的功能用到了 ImageCapture, 所以之前的 Preview 一样, 要用相同的步骤把 ImageCapture 也绑定到生命周期:

startCamera() 方法的创建 Preview 代码下添加创建 ImageCapture 的代码:

1
2
imageCapture = ImageCapture.Builder()
.build()

然后在 cameraProvider.bindToLifecycle 中把上面的创建的 ImageCapture 实例绑定到摄像头生命周期上:

1
camera = cameraProvider.bindToLifecycle(this, cameraSelector, preview, imageCapture)

现在运行程序, 点击按钮就会保存捕获图片, 并且你可以在本地图库找到保存的图片.

实现图像分析功能

上面介绍了 CameraX 库进行摄像头预览和摄像头图片捕获的功能, 最后一个图像分析的功能和上面过程类似. 也是创建一个图像分析的对象, 然后绑定到摄像头生命周期里.

添加平均亮度分析类

首先在 MainActivity 里添加一个内部类 LuminosityAnalyzer 用于分析图像的平均亮度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private class LuminosityAnalyzer(private val listener: LumaListener) : ImageAnalysis.Analyzer {

private fun ByteBuffer.toByteArray(): ByteArray {
rewind() // Rewind the buffer to zero
val data = ByteArray(remaining())
get(data) // Copy the buffer into a byte array
return data // Return the byte array
}

override fun analyze(image: ImageProxy) {

val buffer = image.planes[0].buffer
val data = buffer.toByteArray()
val pixels = data.map { it.toInt() and 0xFF }
val luma = pixels.average()

listener(luma)

image.close()
}
}

创建 ImageAnalysis 对象并绑定到摄像头生命周期

图像分析对象的绑定方法和画面预览, 图像捕获对象绑定方法一样, 都是首先实例化一个对象. 在 startCamera() 方法中, 在实例化 ImageCapture 对象下面添加实例化 ImageAnnalysis 对象的代码:

1
2
3
4
5
6
7
imageAnalyzer = ImageAnalysis.Builder()
.build()
.also {
it.setAnalyzer(cameraExecutor, LuminosityAnalyzer { luma ->
Log.d(TAG, "Average luminosity: $luma")
})
}

实例化 ImageAnnalysis 对象的时候, 传入了一个 LuminosityAnalyzer 对象用于分析i并打印图片平均亮度的结果.

最后在 cameraProvider.bindToLifecycle 方法里添加实例化的 ImageAnalysis 对象:

1
camera = cameraProvider.bindToLifecycle(this, cameraSelector, preview, imageCapture, imageAnalyzer)

运行程序, 可以看到 logcat 中输出图片分析的结果:

Image Analysis