↩️

Compose Multiplatformでアプリのバックグラウンド切り替えを検知するには

に公開

こんにちは!sugitaniと申します。ブラックキャット・カーニバル(略称ブラキャニ)というCompose Multiplatformで作られたSNSアプリを開発しています。

本稿はブラキャニの開発で得られた"あれどうやるんだっけ" を備忘録も兼ねて共有していくシリーズの1作目です。

フォアグラウンド/バックグラウンド切り替えを検知する方法

バックグラウンド切り替えを検知する場合、iOS/Androidでアプローチが異なります。よってexpect/actualを使って個別に実装していくことになります

状態が切り替わるたびにテキストが表示されていくアプリを実装してみましょう。

commonMainでの実装

package cc.bcc.cmpexamples.example001.lifecycle

import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.compositionLocalOf
import kotlinx.coroutines.flow.StateFlow

interface AppLifecycleTracker {
    fun start()

    fun stop()

    val isInForeground: StateFlow<Boolean>
}

@Composable
expect fun buildAppLifecycleTracker(): AppLifecycleTracker

@Composable
fun rememberAppLifecycleTracker(): AppLifecycleTracker {
    val lifecycleTracker = buildAppLifecycleTracker()

    DisposableEffect(lifecycleTracker) {
        lifecycleTracker.start()

        onDispose {
            lifecycleTracker.stop()
        }
    }

    return lifecycleTracker
}

val LocalAppLifecycleTracker =
    compositionLocalOf<AppLifecycleTracker> {
        throw Exception("LocalAppLifecycleTracker is not initialized")
    }

やっていること

  1. 状態変更を追跡する AppLifecycleTracker のinterface定義を行います
  2. そのインスタンス作成を行う buildAppLifecycleTracker()を定義します。Android側でContextが絡むので@Composableをつけています
  3. 作ったAppLifecycleTrackerインスタンスをよしなに開始終了する rememberAppLifecycleTracker()を定義します
  4. 作ったAppLifecycleTrackerインスタンスを全体に共有するための LocalAppLifecycleTracker を定義します

Androidの実装

package cc.bcc.cmpexamples.example001.lifecycle

import android.app.Activity
import android.app.Application
import android.os.Bundle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow

@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
class AppLifecycleTrackerImpl(
    private val activity: Activity,
) : AppLifecycleTracker {
    private val _isInForeground = MutableStateFlow(true)

    override val isInForeground: StateFlow<Boolean> = _isInForeground.asStateFlow()

    private val activityLifecycleCallbacks =
        object : Application.ActivityLifecycleCallbacks {
            override fun onActivityStarted(activity: Activity) {
                if (this@AppLifecycleTrackerImpl.activity == activity) {
                    _isInForeground.value = true
                }
            }

            override fun onActivityStopped(activity: Activity) {
                if (this@AppLifecycleTrackerImpl.activity == activity) {
                    _isInForeground.value = false
                }
            }

            override fun onActivityCreated(
                activity: Activity,
                savedInstanceState: Bundle?,
            ) {
            }

            override fun onActivityResumed(activity: Activity) {}

            override fun onActivityPaused(activity: Activity) {}

            override fun onActivitySaveInstanceState(
                activity: Activity,
                outState: Bundle,
            ) {
            }

            override fun onActivityDestroyed(activity: Activity) {}
        }

    override fun start() {
        activity.registerActivityLifecycleCallbacks(activityLifecycleCallbacks)
    }

    override fun stop() {
        activity.unregisterActivityLifecycleCallbacks(activityLifecycleCallbacks)
    }
}

@Composable
actual fun buildAppLifecycleTracker(): AppLifecycleTracker {
    val activity = LocalContext.current as Activity
    return remember(activity) {
        AppLifecycleTrackerImpl(activity)
    }
}

やっていること

  • registerActivityLifecycleCallbacksでコールバックを呼び出してもらうようにします
  • onActivityStarted / onActivityStopped を監視してStateFlowに状態を保存しています

iOS側の実装


@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
class AppLifecycleTrackerImpl : AppLifecycleTracker {
    private val _isInForeground =
        MutableStateFlow(
            UIApplication.sharedApplication.applicationState ==
                UIApplicationState.UIApplicationStateActive,
        )
    override val isInForeground: StateFlow<Boolean> = _isInForeground.asStateFlow()

    private var didBecomeActiveObserver: Any? = null
    private var didEnterBackgroundObserver: Any? = null

    override fun start() {
        val notificationCenter = NSNotificationCenter.defaultCenter

        didBecomeActiveObserver =
            notificationCenter.addObserverForName(
                UIApplicationDidBecomeActiveNotification,
                null,
                NSOperationQueue.mainQueue,
            ) { _ ->
                _isInForeground.value = true
            }

        didEnterBackgroundObserver =
            notificationCenter.addObserverForName(
                UIApplicationDidEnterBackgroundNotification,
                null,
                NSOperationQueue.mainQueue,
            ) { _ ->
                _isInForeground.value = false
            }
    }

    override fun stop() {
        val notificationCenter = NSNotificationCenter.defaultCenter

        didBecomeActiveObserver?.let {
            notificationCenter.removeObserver(it)
            didBecomeActiveObserver = null
        }

        didEnterBackgroundObserver?.let {
            notificationCenter.removeObserver(it)
            didEnterBackgroundObserver = null
        }
    }
}

@Composable
actual fun buildAppLifecycleTracker(): AppLifecycleTracker = remember { AppLifecycleTrackerImpl() }

やっていること

  • NotificationCenterを使って UIApplicationDidBecomeActiveNotificationUIApplicationDidEnterBackgroundNotification を監視し、StateFlowを適宜更新しています

使い方

アプリのエントリポイントに近いところでAppLifecycleTrackerを準備しておきます

@Composable
internal fun App() {
    val tracker = rememberAppLifecycleTracker()
    CompositionLocalProvider(LocalAppLifecycleTracker provides tracker) {
        TrackCheck()
    }
}

あとは trackerの isInForeground を監視するだけです


@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TrackCheck(modifier: Modifier = Modifier) {
    val tracker = LocalAppLifecycleTracker.current
    val logs = mutableStateListOf<String>()

    LaunchedEffect(Unit) {
        tracker.isInForeground.collect { isInForeground ->
            val entry = "App is in foreground: $isInForeground"
            println(entry)
            logs.add(entry)
        }
    }

    MaterialTheme {
        Scaffold(
            topBar = {
                CenterAlignedTopAppBar(
                    title = { Text("App Lifecycle Tracker Example") },
                )
            },
            modifier = modifier,
        ) { innerPadding ->
            Column(
                modifier =
                    Modifier
                        .padding(innerPadding)
                        .fillMaxSize(),
                horizontalAlignment = Alignment.CenterHorizontally,
            ) {
                logs.forEach { log ->
                    Text(text = log, modifier = Modifier.padding(vertical = 4.dp))
                }
            }
        }
    }
}

注意点としてiOSでは collectAsState() がバックグラウンドでは動作しないので、以下のようにしてしまうと正しく動作しません (参考: https://youtrack.jetbrains.com/issue/CMP-3889 )

//    // This approach doesn't work on iOS with Compose Multiplatform.
//    val isInForeground by tracker.isInForeground.collectAsState()
//    LaunchedEffect(isInForeground) {
//        val entry = "App is in foreground: $isInForeground"
//        println(entry)
//        logs.add(entry)
//    }

サンプルプロジェクト

本稿のソースコード、および動作するコードは
https://github.com/blackcat-carnival/cmp-examples/tree/main/001.detect_background
にあります。

免責事項

このコードはあくまで"自分はこう実装した"という例ですので、よりよい方法がある可能性があります。見つけたら教えてください!

以下宣伝

ブラックキャット・カーニバル

Discussion