NRIネットコム Blog

NRIネットコム社員が様々な視点で、日々の気づきやナレッジを発信するメディアです

Androidアプリの手書き表現とInk APIについて

はじめに

こんにちは、最近キャリア採用でNRIネットコムに入社した磯川です。

今回初ブログということで、一番技術的に明るいAndroidアプリの内容にしたいと思います。

最近Jetpackライブラリにアルファ版が追加されたInk APIを触ってみたので、そちらについてまとめました。

Ink APIとは

Ink APIは、Androidアプリに手書き入力やその描画機能といったインク表現をシンプルに実装するための新しいAPIとなっています。

今までは線を引くなどのドローイングの実装には以下のような課題があり、それらを解決するライブラリです。

従来の課題

  • 描画ロジックの開発

CanvasやSurfaceViewを用いた描画ロジックの作り込み、もしくはサードパーティのライブラリを利用するといった必要があり開発コストが高い。

  • 描画性能の低さ

自前で実装した描画ロジックでは、描画処理が重くなり、特に複雑な描画やリアルタイムな描画において、カクカクとした動きになることが多く、UXを損なう原因となってる。

Ink APIが提供するもの

  • 高レベルなAPI
    Canvasクラスを直接操作する代わりにInk APIが提供する高レベルなAPIを利用することで、複雑な描画処理を簡単に実装できる。

  • 最適化された描画エンジン
    Ink APIは、低レイテンシのandroidx.graphicsライブラリをベースに構築されており、モーション予測ライブラリandroid.inputの利用を前提に、スムーズで応答性の高い描画を実現できる。

    • androidx.graphics
      スタイラスの入力と画面レンダリングの処理時間を短縮するライブラリ。フロントバッファでのレンダリングにより、画面のごく小さい部分を素早くレンダリングできる。
    • androidx.input
      カルマンフィルターアルゴリズムを用いて、スタイラスの入力を予測するライブラリ。予測した入力を先に描画することで、スタイラスの移動と線が追従するまでの遅延を削減しUXを向上させる。
      • カルマンフィルタアルゴリズム
        移動する物体の正確な位置を知るための手法。
        飛行機や衛星の動きを予測するのに利用されたりするらしい。

サンプルコードを作成して動作を確認してみる

では実際に、公式ドキュメントにある以下のサンプルコードで、画面にスタイラスや指で線を引いていくサンプルを作成してみます。

@SuppressLint("ClickableViewAccessibility")
@Composable
fun DrawingSurface(
    inProgressStrokesView: InProgressStrokesView
) {
    val currentPointerId = remember { mutableStateOf<Int?>(null) }
    val currentStrokeId = remember { mutableStateOf<InProgressStrokeId?>(null) }
    val defaultBrush = Brush.createWithColorIntArgb(
        family = StockBrushes.pressurePenLatest,
        colorIntArgb = Color.Black.toArgb(),
        size = 5F,
        epsilon = 0.1F
    )

    Box(modifier = Modifier.fillMaxSize()) {
        AndroidView(
            modifier = Modifier.fillMaxSize(),
            factory = { context ->
                val rootView = FrameLayout(context)
                inProgressStrokesView.apply {
                    layoutParams =
                        FrameLayout.LayoutParams(
                            FrameLayout.LayoutParams.MATCH_PARENT,
                            FrameLayout.LayoutParams.MATCH_PARENT,
                        )
                }
                val predictor = MotionEventPredictor.newInstance(rootView)
                val touchListener =
                    View.OnTouchListener { view, event ->
                        predictor.record(event)
                        val predictedEvent = predictor.predict()

                        try {
                            when (event.actionMasked) {
                                MotionEvent.ACTION_DOWN -> {
                                    // NOTE: タップされたら、イベントを元に新規の線(Stroke)を開始する
                                    // First pointer - treat it as inking.
                                    view.requestUnbufferedDispatch(event)
                                    val pointerIndex = event.actionIndex
                                    val pointerId = event.getPointerId(pointerIndex)
                                    currentPointerId.value = pointerId
                                    currentStrokeId.value =
                                        inProgressStrokesView.startStroke(
                                            event = event,
                                            pointerId = pointerId,
                                            brush = defaultBrush
                                        )
                                    true
                                }

                                MotionEvent.ACTION_MOVE -> {
                                    // NOTE: タップした位置が動いた場合、
                                    // 実際のイベントと予測のイベントを用いて線(Stroke)を更新
                                    val pointerId = checkNotNull(currentPointerId.value)
                                    val strokeId = checkNotNull(currentStrokeId.value)

                                    for (pointerIndex in 0 until event.pointerCount) {
                                        if (event.getPointerId(pointerIndex) != pointerId) continue
                                        inProgressStrokesView.addToStroke(
                                            event,
                                            pointerId,
                                            strokeId,
                                            predictedEvent
                                        )
                                    }
                                    true
                                }

                                MotionEvent.ACTION_UP -> {
                                    // NOTE: タップが終了した場合、線(Stroke)を確定する
                                    val pointerIndex = event.actionIndex
                                    val pointerId = event.getPointerId(pointerIndex)
                                    check(pointerId == currentPointerId.value)
                                    val currentStrokeId = checkNotNull(currentStrokeId.value)
                                    inProgressStrokesView.finishStroke(
                                        event,
                                        pointerId,
                                        currentStrokeId
                                    )
                                    view.performClick()
                                    true
                                }

                                MotionEvent.ACTION_CANCEL -> {
                                    // NOTE: タップがキャンセルされた場合、線(Stroke)を破棄
                                    val pointerIndex = event.actionIndex
                                    val pointerId = event.getPointerId(pointerIndex)
                                    check(pointerId == currentPointerId.value)

                                    val currentStrokeId = checkNotNull(currentStrokeId.value)
                                    inProgressStrokesView.cancelStroke(currentStrokeId, event)
                                    true
                                }

                                else -> false
                            }
                        } finally {
                            predictedEvent?.recycle()
                        }

                    }
                rootView.setOnTouchListener(touchListener)
                rootView.addView(inProgressStrokesView)
                rootView
            },
        ) {

        }
    }
}

おおまかにはMotionEventで取得したタッチイベントからInProgressStrokesViewのstartStroke(), addStroke(), finishStroke()で線の描画を行なう、という流れになっています。 MotionEventは、対応していればペンの傾きや筆圧などの値なども取得できます。

動作キャプチャ - 右: Ink-APIのサンプルコード / 左: Canvasで実装

こちらが実際の動作キャプチャで右がInk-APIのサンプルコード、左が比較用にCanvasでシンプルな線を引く実装を用意したものになります。 ブログへ載せる都合上GIFなので実際の挙動が表現しきれていない点はご了承ください。

Ink-APIの方はカクカクとした線や遅延が発生することなく滑らかにしっかりと描ける印象です。スタイラスや指での追従性も問題なかったです。

Canvasで実装した方は、ややカクカクとした印象があり追従性も少し遅いと感じる場合があります。 だたし、こちらは線を引くだけの最低限の実装なので作り込めば改善できる可能性はあります。

val predictor = MotionEventPredictor.newInstance(rootView)
rootView.setOnTouchListener { view, event ->
    predictor.record(event)
    val predictedEvent = predictor.predict()
    // ...
}    

モーション予測については、上記の部分でMotionEventPredictorインスタンスにタッチイベントをレコード、predict()により予測のMotionEventを作成しています。

inProgressStrokesView.addToStroke(
    event, // 実際のMotionEvent
    pointerId,
    strokeId,
    predictedEvent // 予測のMotionEvent
)

予測のMotionEventはaddToStroke()に渡すことで実際の描画に反映されます。この予測イベントは正確ではないので、ペンの追従遅延を軽減するために使われ最終的な描画では利用されません。

ちなみにpredictedEventパラメータは必須ではないのでコメントアウトして挙動を確認してみましたが、残念ながら自分の環境ではペンと描画遅延などの差は確認できませんでした。

描画負荷の高いアプリになるとおそらくペンの追従体験が変わってくると思います。

さいごに

上記のサンプルの範囲でも、自分で作り込むのに比べて座標計算や描画ロジックの作り込みの必要なくドローイングの体験が実装できました。

線の管理などInk-APIが提供する機能を独自に実装する場合と考えるとコード量もかなり削減できるのではないでしょうか。

また、ブラシのカスタマイズや消しゴムなども提供されており、まだアルファ版ではありますが公式のAPIでシンプルにパフォーマンスよく実装できたので大変使いやすそうです。

こちらのInk APIは、最新OS向けにAndroidで登場した「Circle to Search」(かこって検索)機能にも利用されているそうで、メモ、イラストのようなアプリ意外でもドローイング操作、インク表現の選択肢を広げられそうです!

タブレット市場においては現状AppleのiPadが強いイメージですが、このようなAPIの追加やタブレットに向けたOS更新もあり、今後のAndroidタブレットへの期待が高まりました。

今回は新規のAPIに触れる程度でしたが、今後も定期的にアプリ開発についてのアウトプットをしていきたいと思います!

参考文献

執筆者: 磯川淳志
フロントエンド、特にAndroidアプリ開発の経験が多いです。