飴屋

Kotlin/ViewModelによる状態管理その2

前回、データ(状態、ステート)を持っちゃっていたカスタムViewをComposable向けに書きなおすところまで進みました。いろんな話がでてきて頭の整理がおいついていませんが、UI(View)自体にデータを持たせるなよ、っていうのがComposeを前提とするアーキテクチャから良く伝わりました。書き直す部分がいっぱいです。\(^o^)/ 次は「マイクを使ったUIの音声入力処理がUIとは非同期な処理なので、入力後に画面UIと同期を取りたい」に着手します。これまではMikeというクラスにsetListenerという関数をつけて、アクティビティを録音終了イベントのリスナーといていたんですが、Composable時代ではActivityは一つの画面を表すわけでもなく、リスナーのくっつけ先もわかりません。多分、これも状態を使ってマイクの利用状況を管理・監視するんだと思います。これもMainViewModelに書き足していくことになるんじゃないかと思います。

class MainViewModel : ViewModel() {
    // mike
    private val _speechText = MutableStateFlow("")
    val speechText: StateFlow<String> = _speechText
    fun setSpeechResult(text: String) {
        _speechText.value = text
    }
}

まずViewModelの中にマイクが認識した音声文字列を保持する状態を用意してみました。前回のログのリストみたいな構成で、privateな書き換えられる変数と、それを読める出力口と、実際に書き換えができるpublicなメソッドでできてます。

@Composable
fun VoiceInputButton(viewModel: MainViewModel = viewModel()) {
    val context = LocalContext.current

    val speechLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
        if (result.resultCode == Activity.RESULT_OK) {
            val matches = result.data?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
            val spokenText = matches?.firstOrNull().orEmpty()
            viewModel.setSpeechResult(spokenText)
        }
    }

    val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
        if (granted) {
            startVoiceRecognitionIntent(speechLauncher)
        } else {
            Toast.makeText(context, context.getString(R.string.require_mike), Toast.LENGTH_SHORT).show()
        }
    }

    IconButton(
        modifier = Modifier
            .size(80.dp)
            .background(color = Color(0xFFFF9696), shape = CircleShape),
        onClick = {
            if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO)
                == PackageManager.PERMISSION_GRANTED
            ) {
                startVoiceRecognitionIntent(speechLauncher)
            } else {
                permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
            }
    }) {
        Image(
            painter = painterResource(id = android.R.drawable.ic_btn_speak_now),
            contentDescription = stringResource(R.string.input_mike),
            colorFilter = ColorFilter.tint(Color.White)
        )
    }
}

private fun startVoiceRecognitionIntent(launcher: ActivityResultLauncher<Intent>) {
    val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
        putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
        putExtra(RecognizerIntent.EXTRA_LANGUAGE, Locale.getDefault())
    }
    launcher.launch(intent)
}

そしてマイクで音声認識を起動するボタンをComposableな関数に書き換えました。
speechLauncher は認識が成功したら、認識結果の文字列をさっき作ったViewModelの変数に書き込む操作です。認識失敗時の流れはまた後で考えます。
permissionLauncher はマイク操作の権限を確認する部分ですね。詳細は割愛しますが、権限がなかったら取得してから改めて音声認識を始める内容になっています。
startVoiceRecognitionIntent関数は音声認識インテントを実際に飛ばす部分です。引数にさっきのspeechLauncher を指定しました。これで認識結果がActivityResult的に返って渡されます。同じマイク起動ボタンでも今後複数の用途で使うので、使い分ける方法を考えなければなりません。前はActivity毎に結果受け取りリスナー的なinterfaceをくっつけて使い分けていたんですが、今後は画面UI内でViewModelの状態更新を監視して、うまいこと使い分けていく感じなんでしょうかね。

Contextとロジック層

MainViewModelを触っていて気が付いたんですが、ViewModelの中で、getString(stringResource)やToastが使えませんでした。どちらもContextを使う Android フレームワーク機能で、擁するにUIに関連するViewの手のものなのでした。ViewModelにはViewを持ち込まないというのは前回口が酸っぱくなるほど言われましたが、文字列リソースを引いたり、簡易な通知を行うのも駄目でしたか。瞬間的にContextを渡しちゃう手もありそうですが、厳密にアーキテクチャとやらに従うなら、やはり状態を作って、UI側でそれに応じてgetStringなり、Toastなりせよってことなんですね。あー、ちょっと待って!データベースにアクセスするときもContextが必要じゃありませんでしたっけ?

class MainViewModel(application: Application) : AndroidViewModel(application) {
  // ...
}

そしたら、ViewModelのサブクラスのAndroidViewModelを継承すれば、Application(すなわちContextの仲間)が使えるようになるよと、AIに教わりました。ん?ここではContextを使っていいの?applicationをgetStringやToastで使っちゃダメなの?ってなってここからしばらくAI先生との問答がはじまるのでした。先生がおっしゃるにはDBの方はロジックに属していて、もともと分離しやすくできているからいいとかなんとかで、getStringやToastはUIへの依存が強いから動けばいいって態度は感心しないそうです。ユニットテストとか大変になっちゃうよ、みたいに言われました。Toastはまだわかるんですが、getStringはそんなにContextに依存するんでしょうか?とりあえず、UI層で多言語対応をする案をいくつも提示されましたが、結構大きな改修になって現実的じゃないよって話したら、「現実的には Application Context で十分なことが多い。」というワードを引き出せました。テストが大変になっても知らないよ~ってめっちゃ言われました。なんか、気持ちに余裕ができたら設計を見直しましょう。→最終的に文字列と文字リソースIDを両方渡せるようにして、どっちを出すかUI層で判断するようにしました。UI層はstringResourceでリソース文字列を取得できます。

Toastも何とかしたい

data class WhiteBread(
    val text: String,
    val resId: Int
)

Toastもロジック層から呼び出せるようにしておくことにしました。とりあえず、WhiteBread(食パン)と名前をつけたデータクラスを用意しました。Toastに出したい文字列か文字列リソースIDを持てるような内容です。

class MainViewModel : ViewModel() {
    private val _toastMessage = MutableSharedFlow<WhiteBread>()
    val toastMessage: SharedFlow<WhiteBread> = _toastMessage

    fun doToast) {
        viewModelScope.launch {
            _toastMessage.emit(WhiteBread("焼きあがりました!",-1))
        }
    }
}

次にViewModelの中にToastに表示する文字列(WhiteBread)を持たせる状態を用意しました。そして、ViewModel内のToastを呼び出したい位置で先ほどの状態を更新します。viewModelScopeっていうのは、ViewModel(サンプルコードではMainViewModel)の中でコルーチンを呼ぶときにスコープを明確にするものですかね?ここで呼ばれたコルーチンはこのViewModelが消える時に一緒に消えてくれるそうです。まぁ、まず前提としてコルーチンのことをふわっとしかわかってないので深入りしないでおきます。

LaunchedEffect(Unit) {
    mainViewModel.toastMessage.collect { whiteBread ->
        val mes = if (whiteBread.resId == -1) {
            context.getString(whiteBread.resId)
        } else {
            whiteBread.text
        }
        Toast.makeText(context, mes, Toast.LENGTH_SHORT).show()
    }
}

最後にUI側のComposableな関数の中にViewModelに作った状態に応じてToastを呼び出す処理を書きます。こういう処理ってUIパーツのどこら辺に書くのがいいんですかね。画面設計部分と混ぜない方が見やすいかな?WhiteBreadが中の文字列か、文字列リソースから引っ張ってきた文字列かどっちを表示するか決めるのもここでやっています。ViewModel側でemitした情報がここでcollect されるわけですね。投手と捕手みたいな感じかな。

一覧へ