【正社員】還元率83%【フリーランス】マージン一律5万円で案件をご紹介させていただきます。 詳細はこちら

【Android】<Kotlin>Composeにおける副作用(Side Effect)とは?安全に扱うためのAPIについて解説!

【Android】<Kotlin>Composeにおける副作用(Side Effect)とは?安全に扱うためのAPIについて解説!
すだ

みなさまこんにちは〜!
メモリアインクのすだです。

KotlinでAndroidアプリを開発するうえで、UI部分にComposeを使う方は多いと思いますが、
今回はこのComposeにおける副作用とその扱い方について解説していきます。

この記事を読んでわかること…
・Composeを使う上での副作用とは
・副作用的処理をCompose内で実装するための方法

目次

環境

  • Kotlin (ver 1.9.0)
  • Android Studio (Giraffe | 2022.3.1 Patch 3)

Compose における「副作用(Side Effect)」とは?

副作用(Side Effect)は、Composable 関数の外側に影響を与える処理のことです。
例えば以下のような処理のこと:

  • ネットワークからデータを取得する
  • データベースを読み書きする
  • ToastやSnackbarを表示する
  • ログを記録する
  • ナビゲーションで画面を移動する

これらはすべて「UIの外に影響を与える処理」であり、副作用(side effect)と呼ばれます。

Composeにおける「副作用を安全に扱うための仕組み」

Jetpack Compose の @Composable 関数は 原則「副作用のない関数(純粋関数)」として設計されています。
つまり、同じ引数を渡せば常に同じ結果(UI)を返すことが保証される関数です。
これは、再コンポーズ(UIの再構築)を高速かつ安全に行うための重要な前提です。
UIの状態が変わるたびに @Composable 関数が再実行されても、予期しない影響(副作用)を生まないことが求められます。

たとえば、

@Composable
fun Greeting(name: String) {
    Text("こんにちは、$nameさん!")
}

この関数は「name に応じた挨拶文」を表示するだけ。
つまり、”花子”と入力すれば「こんにちは、花子さん!」と出力され、”太郎”と入力すれば「こんにちは、太郎さん!」と出力されます。
同じ入力なら同じUIが返る、これが「純粋関数」=副作用がない、という意味です。

Jetpack Compose は UI を再描画する仕組みとして 再コンポーズ(再実行) を使います。
たとえば:

var count by remember { mutableStateOf(0) }

Button(onClick = { count++ }) {
    Text("回数: $count")
}

この count++ によって Text("回数: $count") が再描画(再コンポーズ)されます。
もしここに副作用(例:ログ出力、ネット通信など)が書いてあると、

var count by remember { mutableStateOf(0) }

Button(onClick = { count++ }) {
    Text("回数: $count")
    Log.d("TAG", "Textが描画された") // ← 再コンポーズのたびにログが出続ける
}

意図しないことが起きてバグになったり、無限ループになったりします。(これらは予測が難しい)

つまり:

  • 同じ引数で呼べば、常に同じUIを返す
  • 内部でネット通信や状態変更などはやってはいけない

Jetpack Compose は「見た目は見た目だけ」に集中させて、
データの取得・イベント処理などは副作用として別で管理する、という考え方が基本です。


それでも 現実のアプリでは @Composable関数の中で副作用と呼ばれる処理をやらないといけない場面がたくさんあります。
→ そのために、Composeには「副作用を安全に扱うための仕組み(= API)」が用意されています。

LaunchedEffect

@Composable
fun WelcomeScreen(context: Context) {
    LaunchedEffect(Unit) {
        Toast.makeText(context, "ようこそ!", Toast.LENGTH_SHORT).show()
    }

    // 通常のUI
    Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        Text("ホーム画面")
    }
}

LaunchedEffect は、Composable が画面に「入場(表示)」したときに一度だけ非同期処理を実行するための仕組みです。
これは Compose における 副作用処理専用の Composable 関数であり、内部では launch のようにコルーチンを使って処理を行うことができます。

LaunchedEffect には「キー(key)となる引数」を指定する必要があり、この例では Unit を使っています。

LaunchedEffect(Unit) { ... }

ここで指定された key が変わらない限り、LaunchedEffect の中の処理は再実行されません
Unit は固定値のため、画面が最初に表示されたときだけ処理が実行されるという動作になります。

つまりこの実装では、たとえ WelcomeScreen が 画面遷移して戻ってきたなどで再コンポーズされても、
LaunchedEffect のキーが変わらないため、中のトースト表示は再度実行されません。

結果として、「初回表示時に1度だけトーストを表示する処理」を安全かつ確実に実現できています。

LaunchedEffectは、Composableの初回表示時や再表示時に非同期処理で何かしたいときに有効です。

rememberCoroutineScope

@Composable
fun SaveButton() {
    val context = LocalContext.current
    val scope = rememberCoroutineScope()

    Button(onClick = {
        scope.launch {
            // 1秒待ってからトースト表示(例:保存完了の通知など)
            delay(1000)
            Toast.makeText(context, "保存が完了しました", Toast.LENGTH_SHORT).show()
        }
    }) {
        Text("保存")
    }
}

rememberCoroutineScopeは、「このComposableが表示されている間だけ有効なCoroutineScopeを提供してくれる関数」です。

通常の Kotlin コードでは、launch {} を使うには CoroutineScope が必要です。
たとえば:

viewModelScope.launch { ... }  // ViewModel専用
lifecycleScope.launch { ... }  // LifecycleOwner専用(Activity / Fragment)

しかし、Composable 関数内では viewModelScopelifecycleScope は使えません

そこで使うのが rememberCoroutineScope() です。
これをを使うことで、「このComposable内で安全に launch を使える」ようにするためのスコープが得られます。

remember によってスコープが再生成されるのを防ぎつつ、
このスコープは、Composableが画面から消えると自動でキャンセルされます(=メモリリークを防げる)。
(通常の GlobalScope だと、処理が画面外でも走り続ける)

rememberCoroutineScopeは、ボタンなど、ユーザー操作などで明示的に非同期処理を起動したいときに有効です。

DisposableEffect

@Composable
fun LifecycleAwareComponent() {
    val lifecycleOwner = LocalLifecycleOwner.current

    DisposableEffect(Unit) {
        val observer = LifecycleEventObserver { _, event ->
            when (event) {
                Lifecycle.Event.ON_START -> {
                    Log.d("Lifecycle", "画面が表示された(START)")
                }
                Lifecycle.Event.ON_STOP -> {
                    Log.d("Lifecycle", "画面が非表示になった(STOP)")
                }
                else -> Unit
            }
        }

        lifecycleOwner.lifecycle.addObserver(observer)

        // Composableが削除(非表示)されたとき
        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }

    Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        Text("ライフサイクル監視中")
    }
}

DisposableEffectComposableが画面に“入場”したときに「セットアップ」、画面から“退場”したときに「クリーンアップ」したいときに使います。

たとえば、 LifecycleEventObserver(Composeのライフサイクルの変化をキャッチできるクラス) を使って、
画面の入場時・退場時でログ出力や処理の制御を行うようなコードに書き換えることができます。

上記処理では、
LocalLifecycleOwner.currentで現在の画面のライフサイクルを取得

DisposableEffect(Unit) { ...}内で、
LifecycleEventObserverを使って画面表示時(ON_START)と画面非表示時(ON_STOP)をそれぞれ検知して処理を書く

addObserver()で、
作ったLifecycleEventObserverを画面にライフサイクルに登録

onDispose {...}内で
Composableが画面から“退場”(=不要になった)ときに登録したオブザーバを解除する処理を書く
observer を付けっぱなしにしておくとメモリリークなどが発生するため)

という実装を行っています。

DisposableEffectでも、LaunchedEffectのように引数にキー(Unit)を持たせることによって
Recompositionによる再起動を防ぐことができます。

DisposableEffectは、表示されたときに処理を開始し、画面から消えたら確実に終了したい処理を作るのに有効です。

LaunchedEffect
・Composableの入場時(またはkey変更時)に起動
・コルーチンで非同期処理を開始できる
・明示的な終了処理を書く場所はない

DisposableEffect
・Composableの入場時(またはkey変更時)に起動
・コルーチンで非同期処理はできない
・明示的な終了処理を書く場所がある

rememberUpdatedState

たとえば、「5秒後にメッセージを表示する仕組み」を作るとき、
その間にメッセージが変わっていたら、最新のメッセージを出したい。
こういった場合、rememberUpdatedState を使えば実現できます。

@Composable
fun DelayedMessageExample(message: String) {
    val currentMessage by rememberUpdatedState(message)

    LaunchedEffect(Unit) {
        delay(5000)
        Log.d("DelayedMessage", "5秒後のメッセージ: $currentMessage")
    }
}

// 呼び出し元
var message by remember { mutableStateOf("初回メッセージ") }
Button(onClick = { message = "ボタンが押された!" }) {
    Text("変更")
}
DelayedMessageExample(message)

上記はボタンを押した回数に応じて、5秒後にメッセージを出す処理です。
初回で画面が表示されたとき、ボタンを何も押さなければ5秒後にそのまま「初回メッセージ」が表示されますが、
この5秒間の間にボタンが押された場合、メッセージの内容は変わります。

例えば以下のように、

fun DelayedMessageExample(message: String) {
    LaunchedEffect(Unit) {
        delay(5000)
        Log.d("DelayedMessage", "5秒後のメッセージ: $message")
    }
}

メッセージの出力にval currentMessage by rememberUpdatedState(message)を使わなかった場合、
5秒前に渡された「初回メッセージ」しか表示されません。
理由は、LaunchedEffect の中は 引数のキーの値が変わらない限り 再コンポーズされても再実行されないからです。

しかし、

fun DelayedMessageExample(message: String) {
    val currentMessage by rememberUpdatedState(message)

    LaunchedEffect(Unit) {
        delay(5000)
        Log.d("DelayedMessage", "5秒後のメッセージ: $currentMessage")
    }
}

このようにrememberUpdatedStateの引数に値を入れることで、Composableが再コンポーズされても最新の値を保持してくれます

5秒の間にボタンを数回押しても、delay の処理はそのまま続行され、メッセージだけ最新になるというわけです。

rememberUpdatedStateは、LaunchedEffect や SideEffect の中で「今の状態」を確実に使いたいときに有効です。

SideEffect

@Composable
fun LogName(name: String) {
    SideEffect {
        Log.d("SideEffect", "名前が更新された: $name")
    }
    Text(name)
}

SideEffect を使うと、「再コンポーズのたびに確実に処理が走る」ようになります。

Compose の @Composable 関数は原則「副作用のない関数(=純粋関数)」です。
なので普通に書いたこういうコード:

@Composable
fun LogName(name: String) {
    Log.d("TAG", "名前が変わった: $name") // 意図通り毎回呼ばれないことがある
    Text(name)
}

これは 再コンポーズのたびに必ず呼ばれるとは限りません。
Compose は 再コンポーズ時に最適化して処理を省くからです。
name"佐藤""鈴木" に変わると、再コンポーズが発生しますが、Log.d(...) のような副作用的な処理は最適化の対象から外されることがあります。)
つまり「描画に関係ない処理」は再実行されないことがあります。

こういった場合に、SideEffectで副作用の処理を囲ってあげることで
UI と関係なくても、毎回実行してねと明示できるのです。

また、SideEffectは、@Composable 関数が 正常に再コンポーズを完了したあとでSideEffect の中の処理が実行されます。
そのため、Composable 関数内の他の処理で例外(エラー)が発生して再コンポーズが中断された場合、SideEffect の処理は実行されません。
これは、UIが壊れている状態で副作用処理を走らせないという安全性につながります。

まとめ

おつかれさまでした。いかがでしたでしょうか!

Composeで副作用的処理を扱ったことがない方にとっては少し難しく感じるかもしれませんが
慣れてしまえば便利さに気がつくと思うので ぜひ実践してみてください!

すだ

技術者としてのキャリアパスを次のレベルへと進めたい皆様、
未経験からIT・Webエンジニアを目指すなら【ユニゾンキャリア】
を通じて、
自分の市場価値をさらに向上させてみませんか?

それではまた次回の記事でお会いしましょう!

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

弊社テックブログをご愛読いただきありがとうございます。
当テックブログを運用している株式会社メモリアインクは、
【正社員】還元率83%
【フリーランス】マージン一律5万円で案件のご紹介
と、エンジニアの皆様に分かりやすい形で稼げる仕組みを構築し提供させていただいております。

コメント

コメントする

目次