0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[Android]カメライベントからのAPI呼び出しを効率化する実装パターン

Posted at

アプリ開発をしていると、スマートフォンに搭載されているカメラやセンサーからイベントを検出し、それをトリガーにAPI通信を呼び出すような実装を行う機会もあると思います。

ボタン押下イベントからAPIを呼び出す場合には、単純に呼び出し処理を実装すれば良いケースが多いですが、HWイベントがトリガーとなる場合には、予期せぬ回数やタイミングでイベント検出されることがあります。そのため、単純にAPI呼び出し処理を実装すると、通信やサーバーに過剰な負荷をかけてしまうことがあります。

そこで、今回の記事では、カメラから書籍のバーコードを読み取り、ISBN番号を検出した際に、その番号を用いて書籍検索APIを呼び出す処理を実装する際の注意点について解説します。

良くない実装例

ViewModel.kt
    /**
     * 引数で指定されたISBN番号での書籍検索
     *
     * @param isbn ISBN番号
     */
    fun searchIsbn(isbn: String) {
        viewModelScope.launch {
            apiService.searchIsbnItems(isbn, appId).enqueue(object : retrofit2.Callback<BookData> {
                override fun onResponse(call: retrofit2.Call<BookData>?, response: retrofit2.Response<BookData>) {
                    if (response.isSuccessful) {
                        response.body()?.let {
                            setBookList(it)
                        }
                    }
                }
            })
        }
    }
Fragment.kt
    /**
     * 取得した各フレームを ML Kit で解析し、バーコードを検出します。
     * 検出に成功すると最初のバーコード文字列を取得し、結果画面へ遷移します。
     *
     * @param imageProxy カメラから渡される単一フレーム
     */
    private fun processImage(imageProxy: ImageProxy) {
        val mediaImage = imageProxy.image
        if (mediaImage != null) {
            val input = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
            scanner.process(input)
                .addOnSuccessListener { barcodes ->
                    if (barcodes.isNotEmpty()) {
                        val value = barcodes.first().rawValue ?: ""
                        viewModel.searchIsbn(value)
                    }
                }
                .addOnCompleteListener { imageProxy.close() }
        } else imageProxy.close()
    }

カメラからのバーコード読み取りにはMK Kitライブラリを利用しており、scanner.process(input).addOnSuccessListenerから直接viewModel.searchIsbn(value)を呼び出しています。
この実装の良くない点は以下の通りです。

バーコードを検出するとミリ秒単位でリスナーイベントが発火し、そのたびにAPIが呼び出されてしまう

同じISBN番号でも繰り返し検出されるため、同じ番号でAPIが何度も呼び出されてしまう

その結果、API通信が高頻度かつ同じ値で無駄に発生し、通信やサーバーに負荷をかけてしまう実装になっています。

良い実装例

ViewModel.kt
    /** ISBN入力用の内部変数 */
    private val _isbnInput = MutableSharedFlow<String>(extraBufferCapacity = 1)
    
    /**
     * 指定された時間内の最初のイベントのみを通過させるFlow拡張関数
     *
     * @param T Flowの型
     * @param windowMillis 時間(ミリ秒)
     * @return Flow<T>
     */
    private fun <T> Flow<T>.throttleFirst(windowMillis: Long): Flow<T> = flow {
        var lastTime = 0L
        collect { value ->
            val now = System.currentTimeMillis()
            if (now - lastTime >= windowMillis) {
                lastTime = now
                emit(value)
            }
        }
    }

    init {
        viewModelScope.launch {
            _isbnInput
                .throttleFirst(API_PERIOD_TIME)   // 一定時間以内の連続イベントをまとめる
                .distinctUntilChanged() // 同じ値の連続イベントをまとめる
                .collect { isbn ->
                    // ここで初めて API を呼び出す
                    searchIsbnInternal(isbn)
                }
        }
    }

    /**
     * 引数で指定されたISBN番号での書籍検索
     *
     * @param isbn ISBN番号
     */
    fun searchIsbn(isbn: String) {
        _isbnInput.tryEmit(isbn)
    }

    /**
     * 引数で指定されたISBN番号での書籍検索内部処理
     *
     * @param isbn ISBN番号
     */
    private suspend fun searchIsbnInternal(isbn: String) {
        viewModelScope.launch {
            rakutenApiService.searchIsbnItems(isbn, appId).enqueue(object : retrofit2.Callback<RakutenBookData> {
                override fun onResponse(call: retrofit2.Call<RakutenBookData>?, response: retrofit2.Response<RakutenBookData>) {
                    if (response.isSuccessful) {
                        response.body()?.let {
                            viewModelScope.launch {
                                setBookList(it)
                            }
                        }
                    }
                }
            })
        }
    }
Fragment.kt
    /**
     * 取得した各フレームを ML Kit で解析し、バーコードを検出します。
     * 検出に成功すると最初のバーコード文字列を取得し、結果画面へ遷移します。
     *
     * @param imageProxy カメラから渡される単一フレーム
     */
    private fun processImage(imageProxy: ImageProxy) {
        val mediaImage = imageProxy.image
        if (mediaImage != null) {
            val input = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
            scanner.process(input)
                .addOnSuccessListener { barcodes ->
                    if (barcodes.isNotEmpty()) {
                        val value = barcodes.first().rawValue ?: ""
                        viewModel.searchIsbn(value)
                    }
                }
                .addOnCompleteListener { imageProxy.close() }
        } else imageProxy.close()
    }

Fragment.ktは何も変わっていないのですが、scanner.process(input).addOnSuccessListenerから呼び出されるviewModel.searchIsbn(value)のメソッド内で、_isbnInput変数のMutableSharedFlowにイベントを流すことでイベント制御を行っています。
これにより以下の効果があります。

throttleFirstメソッドにより、最初のイベント発火から一定時間以内のイベントをまとめることで、ミリ秒単位での過剰なAPI呼び出しを防止する

distinctUntilChangedメソッドにより、同じISBN番号が連続して検出された場合にイベントをまとめ、同じ番号での重複API呼び出しを防止する

これらの処理により、API通信を高頻度かつ重複して発生させることを防ぎ、通信やサーバーに不要な負荷をかけない実装が可能になります。

Tips:throttleFirst と debounce の違い

独自メソッドthrottleFirstは「最初のイベントを検出して皇族のイベントをまとめる」処理ですが、debounceという既存のメソッドを使うと「イベントをまとめて最後のイベントを検出する」こともできます

実装全体の参考コード

上記のリポジトリで、ソースコード全体を公開しております。

まとめ

カメライベントのように高頻度で発生するトリガーからAPIを呼び出す場合、そのまま処理してしまうと通信やサーバーに過剰な負荷をかけてしまいます。
今回紹介したように、一度Flowに流してthrottleFirstdistinctUntilChangedを組み合わせて制御することで、安全かつ効率的なAPI呼び出しが可能になります。

この考え方はカメラに限らず、センサーや位置情報など「イベント頻度が高い入力」を扱う場面でも応用できるので、ぜひ活用してみてください。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?