CameraXとMLKitを組み合わせてQRコードを読み取る

CameraXはまだベータですが、その精度の高さには驚かされるばかりです。

本記事は[Android Advent Calendar 2019](https://qiita.com/advent-calendar/2019/android)の2019/12/04分です。
とてもQRコードリーダーを作れちゃうので、その方法を紹介します。

今回は、Firebase MLKitと組み合わせて「QRコードリーダー」を作ってみます。
※Firebaseプロジェクトの準備は別途ご用意ください

# 下準備
## Gradleに必要なものを追加
CameraXとMLKitを追加しましょう

```gradle:build.gradle
implementation "androidx.camera:camera-core:1.0.0-alpha06" 
implementation "androidx.camera:camera-camera2:1.0.0-alpha06"

implementation "com.google.firebase:firebase-ml-vision:24.0.1"
``` 

## 描画する領域を追加
それではレウアウトのXMLに、描画するための領域（TextureView）を追加します

```xml:activity_main.xml
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <TextureView
        android:id="@+id/textureView"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

</LinearLayout>
```

# MLKitを組み込んだAnalyzerを作成する
カメラが起動された際に「QRコードを読み取って解析する」ためのAnalyzerを作成します。
クラス名は `QrCodeAnalyzer` とでもしましょう。

```kotlin:QrCodeAnalyzer.kt
private val TAG = QrCodeAnalyzer::class.java.simpleName

class QrCodeAnalyzer : ImageAnalysis.Analyzer {
    var barcodeStr = ObservableField<String>()

    override fun analyze(image: ImageProxy, rotationDegrees: Int) {
        val img = image.image ?: return
        runBarcodeScanner(img, degreesToFirebaseRotation(rotationDegrees))
    }

    private fun runBarcodeScanner(src: Image, rotationDegrees: Int) {
        try {
            val image = FirebaseVisionImage.fromMediaImage(src, rotationDegrees)

            // 読み取るバーコードのタイプを指定する
            val barcodeFormatQr = FirebaseVisionBarcode.FORMAT_QR_CODE
            val options = FirebaseVisionBarcodeDetectorOptions.Builder()
                .setBarcodeFormats(barcodeFormatQr)
                .build()

            // 画像の解析
            FirebaseVision.getInstance().getVisionBarcodeDetector(options).detectInImage(image)
                .addOnSuccessListener { firebaseVisionBarcodeList ->
                    if (firebaseVisionBarcodeList.isNotEmpty()) {
                        // QRを複数読み込んだ場合に1つだけを対象にする
                        val barcode = firebaseVisionBarcodeList[0]
                        when (barcode.valueType) {
                            FirebaseVisionBarcode.TYPE_URL -> {
                                barcode.displayValue?.let {
                                    if (barcodeStr.get() != it) {
                                        barcodeStr.set(it)
                                    }
                                    return@addOnSuccessListener
                                }
                            }
                        }
                    }
                }
                .addOnFailureListener {
                    Log.e(TAG, "something wrong...")
                }
        } catch (e: Exception) {
            Log.e(TAG, e.message)
        }
    }

    /**
     * 画像の回転を ML Kit の ROTATION_ 定数のいずれかに変換する
     */
    private fun degreesToFirebaseRotation(degrees: Int): Int = when (degrees) {
        0 -> FirebaseVisionImageMetadata.ROTATION_0
        90 -> FirebaseVisionImageMetadata.ROTATION_90
        180 -> FirebaseVisionImageMetadata.ROTATION_180
        270 -> FirebaseVisionImageMetadata.ROTATION_270
        else -> throw Exception("Rotation must be 0, 90, 180, or 270.")
    }
}
```
「QRコードを読み取るんだ！」という目的のためにやることは、さほど多くはありません。
画像を解析する際にオプションをセットできますが、その際に `FirebaseVisionBarcode.FORMAT_QR_CODE` を指定してあげるだけで終わりです。
もちろんこのタイプの指定はひとつではなく、複数セットが可能です。

ここではObservableFieldを使って読み取った（ObservableField内のデータが書き換わった）ことを通知するように工夫していますが、おそらくCoroutinesを使ったらもっとキレイに書けます。
（一度Coroutinesを使ったんですが、ワケあってやめたのです）


# カメラをスタートして画像を読み取る
それでは、実際にカメラを起動し、上で作成したAnalyzerを使って画像を解析する部分を作っていきます。

少しQiitaに書くコードとしては大きくなってしまいますが、これが全てです。
これだけでQRコードリーダーがほぼ完成です。
（※細かなTODOはまだまだありますが）

```kotlin:MainActivity.kt
private val TAG = MainActivity::class.java.simpleName

@RuntimePermissions
class MainActivity : AppCompatActivity() {
    companion object {
        val analyzerThread = HandlerThread(
            "CameraXAnalysis"
        ).apply { start() }
    }

    val textureView by lazy {
        findViewById<TextureView>(R.id.textureView)
    }

    private var fitPreview: FitPreview? = null
    private var analyzerUseCase: ImageAnalysis? = null

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

        launchCameraWithPermissionCheck()

        textureView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
            updateTransform()
        }

    }

    override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<String>,
        grantResults: IntArray
    ) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
        if (grantResults[0] != PackageManager.PERMISSION_GRANTED) {
            Toast.makeText(this, "Permission has not granted", Toast.LENGTH_LONG).show()
        }
        onRequestPermissionsResult(requestCode, grantResults)
    }

    @NeedsPermission(Manifest.permission.CAMERA)
    fun launchCamera() {
        textureView.post { startCamera() }
    }

    private fun startCamera() {
        try {
            val textureViewWidth = textureView.width
            val textureViewHeight = textureView.height
            val targetResolution = Size(textureViewWidth, textureViewHeight)
            val previewConfig = PreviewConfig.Builder()
                .setTargetResolution(targetResolution)
                .setTargetAspectRatio(Rational(textureViewWidth, textureViewHeight))
                .setLensFacing(CameraX.LensFacing.BACK)
                .build()

            fitPreview = FitPreview(textureView, previewConfig)

            val analyzerConfig = ImageAnalysisConfig.Builder().apply {
                setCallbackHandler(Handler(analyzerThread.looper))
                setTargetResolution(targetResolution)
                setImageReaderMode(ImageAnalysis.ImageReaderMode.ACQUIRE_LATEST_IMAGE)
            }.build()

            analyzerUseCase = ImageAnalysis(analyzerConfig).apply {
                val qrCodeAnalyzer = QrCodeAnalyzer()
                qrCodeAnalyzer.barcodeStr.addOnPropertyChangedCallback(object :
                    Observable.OnPropertyChangedCallback() {
                    override fun onPropertyChanged(sender: Observable, propertyId: Int) {
                        val observableField =
                            sender as ObservableField<String>
                        observableField.get()?.let { barcodeStr ->
                            Log.d(TAG, "barcodeStr = $barcodeStr")
                        }
                    }
                })

                analyzer = qrCodeAnalyzer
            }

            CameraX.bindToLifecycle(this, fitPreview, analyzerUseCase)

        } catch (e: Exception) {
            Log.e(TAG, e.message)
        }
    }

    private fun updateTransform() {
        val matrix = Matrix()

        // Compute the center of the view finder
        val centerX = textureView.width / 2f
        val centerY = textureView.height / 2f

        // Correct preview output to account for display rotation
        val rotationDegrees = when (textureView.display.rotation) {
            Surface.ROTATION_0 -> 0
            Surface.ROTATION_90 -> 90
            Surface.ROTATION_180 -> 180
            Surface.ROTATION_270 -> 270
            else -> return
        }
        matrix.postRotate(-rotationDegrees.toFloat(), centerX, centerY)

        // Finally, apply transformations to our TextureView
        textureView.setTransform(matrix)
    }
}
```

メインは `startCameraメソッド` です。
startCameraメソッド以外は、カメラ起動のためのPermissionなど必要な手続きばかりです。

ここでは触れてはいませんが、注意が必要なことがいくつかあります。

* CameraXでは、カメラをズームする場合は `CameraX.bindToLifecycle()の後` でやる
* 富士通の端末の場合、電池残量が10%未満の場合は `RuntimeException(camera low battery)` となる
* カメラを止めたり再開したり（エラーダイアログを出してる最中は画像解析を止めるとか）する場合は、CameraXのライフサイクルに気をつけないと簡単にクラッシュする

他にも色々とありますが、挙げ出したらキリがないかもしれません…

# まとめ
ここまで「CameraXはええで！MLKitと組み合わせたらめっちゃええで！」とばかり述べてきましたが、やはりまだ過去のAndroidのカメラが通ってきた闇の歴史を乗り越えきれてはいないように感じます。

Xperiaでは非常に画像解析の精度が低いですし、カメラのズームをすると画像がガビガビになったりする端末もありました。
個人的にCameraXには非常に注目しており好んではいますが、Xzingなどといった歴史のあるライブラリのほうが、今はプロダクトとしては選択肢として強い気がします。

## P.S.
ここで述べてきたことは、CameraXのCodelabsでかなり触れられているので、そちらを試してみたらよいかと思います。
https://codelabs.developers.google.com/codelabs/camerax-getting-started/