Jetpack Compose 中在屏幕间共享数据的 5 种方案

1. 路由传参

Jetpack Compose 中路由传参的方式有很多种,具体可以参考 Jetpack Compose 中的导航路由

以下是最简单的路由传参测试代码:

import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument

@Composable
fun NavigationArgsSample() {
    val navController = rememberNavController()
    NavHost(
        navController = navController,
        startDestination = "screen1"
    ) {
        composable("screen1") {
            Screen1(onNavigateToScreen2 = {
                navController.navigate("screen2/$it")
            })
        }
        composable(
            route = "screen2/{my_param}",
            arguments = listOf(
                navArgument("my_param") {
                    type = NavType.StringType
                }
            )
        ) {
            val param = it.arguments?.getString("my_param") ?: ""
            Screen2(param = param)
        }
    }
}

@Composable
private fun Screen1(onNavigateToScreen2: (String) -> Unit) {
    Button(onClick = {
        onNavigateToScreen2("Hello world!")
    }) {
        Text(text = "Click me")
    }
}

@Composable
private fun Screen2(param: String) {
    Text(text = param)
}

代码很简单,就是从Screen1路由页面传一个值给Screen2路由页面。

这种方式不能算是真正的共享数据,它只能算是 “把我的数据分享给别人” 这种概念,当参数值到达别的页面以后,就成为该页面的本地静态数据,如果你修改了该数据也不会反馈到原始路由页面中。但是只从分享数据的角度,这种方式足够简单。

另外这种方式有一个好处是可以跨越系统内存不足杀死进程的情况而存活

我们可以通过 Android Studio Logcat 面板的 “Terminate Application” 按钮来模拟系统内存不足杀死进程的情况,但是当我打开我的 Android Studio 之后,不禁发出灵魂拷问:Where did my “Terminate Application” button gone ?

Jetpack Compose 中在屏幕间共享数据的 5 种方案_第1张图片

好家伙,这个按钮居然没有了,如果你使用的是 Android Studio Dolphin 或者 Android Studio Flamingo 一定也会遇到这个问题,这个按钮对于模拟系统终止应用进程的场景还是非常有用的(注意它的作用不同于顶部工具栏中的stop按钮)。该怎么把它找回来呢?

不用慌,经过一番搜索,终于找到了该怎么把它找回来:Settings --> Experimental --> Enable new Logcat tool window. 将这个选项的打勾取消即可。

然后我们运行应用,将路由跳转到Screen2然后回到后台,这时点击 Terminate Application 模拟系统杀进程,效果如下:

可以发现这种方式确实可以在系统回收应用进程后,再次打开应用时,仍然保持之前的页面状态。

这种方式缺点也很明显,假如我们路由导航路径上有 7 个屏幕,不可能将数据从第一个传到第七个,那样太麻烦了。

2.共享ViewModel

我们知道ViewModel可以跨越Activity的配置变更而存活,因此它是一个Activity中不同的Composable组件之间共享数据的绝佳方案:

Jetpack Compose 中在屏幕间共享数据的 5 种方案_第2张图片

实际上ViewModel的作用域被限定为一个离他最近的 ViewModelStoreOwnerLifecycle。它会一直保留在内存中,直到其 ViewModelStoreOwner 永久消失。

ViewModelStoreOwner 对象可能是一个 ActivityFragment 或者一个 Navigation 导航图(NavBackStackEntry),具体取决于使用的场景。

如果我们打算在一个导航图之内的不同路由之间通过ViewModel来共享数据,就可以将其作用域限定为该导航图。

下面开始测试,首先创建一个用于共享的SharedViewModel

import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow

class SharedViewModel: ViewModel() {

    private val _sharedState = MutableStateFlow(0)
    val sharedState = _sharedState.asStateFlow()

    fun updateState() {
        _sharedState.value++
    }

    override fun onCleared() {
        super.onCleared()
        println("ViewModel cleared")
    }
}

测试代码:

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navigation
import com.fly.mycompose.application.examples.sharedata.model.SharedViewModel

@Composable
fun SharedViewModelSample() {
    val navController = rememberNavController()
    NavHost(
        navController = navController,
        startDestination = "onboarding"
    ) {
        navigation(
            startDestination = "personal_details",
            route = "onboarding"
        ) {
            composable("personal_details") { entry ->
                val viewModel = entry.sharedViewModel<SharedViewModel>(navController,)
                val state by viewModel.sharedState.collectAsStateWithLifecycle()

                PersonalDetailsScreen(
                    sharedState = state,
                    onNavigate = {
                        viewModel.updateState()
                        navController.navigate("terms_and_conditions")
                    }
                )
            }
            composable("terms_and_conditions") { entry ->
                val viewModel = entry.sharedViewModel<SharedViewModel>(navController)
                val state by viewModel.sharedState.collectAsStateWithLifecycle()

                TermsAndConditionsScreen(
                    sharedState = state,
                    onOnboardingFinished = {
                        navController.navigate(route = "other_screen") {
                            popUpTo("onboarding") {
                                inclusive = true // 注意这里将 onboarding 整个导航图都弹出了
                            }
                        }
                    }
                )
            }
        }
        composable("other_screen") {
            Text(text = "Hello world")
        }
    }
}

@Composable
inline fun <reified T : ViewModel> NavBackStackEntry.sharedViewModel(
    navController: NavHostController,
): T {
    val navGraphRoute = destination.parent?.route ?: return viewModel()
    val parentEntry = remember(this) {
        navController.getBackStackEntry(navGraphRoute)
    }
    return viewModel(parentEntry)
}

@Composable
private fun PersonalDetailsScreen(
    sharedState: Int,
    onNavigate: () -> Unit
) {
    Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
        Text(text = "State: $sharedState")
        Button(onClick = onNavigate) {
            Text(text = "Click me")
        }
        Text(text = "onboarding: PersonalDetailsScreen")
    }

}

@Composable
private fun TermsAndConditionsScreen(
    sharedState: Int,
    onOnboardingFinished: () -> Unit
) {
    Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
        Text(text = "State: $sharedState")
        Button(onClick = onOnboardingFinished) {
            Text(text = "go to other_screen")
        }
        Text(text = "onboarding: TermsAndConditionsScreen")
    }
}

上面代码中导航图onboarding的两个子路由personal_detailsterms_and_conditions共享一个SharedViewModel中的计数器的值,当在路由页面personal_details点击跳转到terms_and_conditions时,会同时将计数器的值加1,然后在terms_and_conditions就可以看到共享的计数器的值,假如返回,可以看到上一个页面共享的计数器的值跟当前是一致的。当从terms_and_conditions跳转到other_screen时,会把整个onboarding导航图都关闭,此时共享的SharedViewModel会被清除。

运行效果:

这里关键是在导航图的子路由之间共享ViewModel的代码:

@Composable
inline fun <reified T : ViewModel> NavBackStackEntry.sharedViewModel(
    navController: NavHostController,
): T {
    val navGraphRoute = destination.parent?.route ?: return viewModel()
    val parentEntry = remember(this) {
        navController.getBackStackEntry(navGraphRoute)
    }
    return viewModel(parentEntry)
}

在 Jetpack架构组件库:Lifecycle、LiveData、ViewModel 中提到可以将 ViewModel 的作用域限定为指定的 ViewModelStoreOwner,而 Navigation 导航图 也是 ViewModelStoreOwner, 因此这里可以通过viewModel(parentEntry)来限定 ViewModel 的作用域限定为父导航图,这样当该导航图被关闭时, ViewModel 就会被清理(执行onCleared())而不会一直占用全局内存,那样可能导致内存泄漏。另外,注意查找父导航图的 NavBackStackEntry 是通过 navController.getBackStackEntry("parentNavigationRoute") 的方式来查找的。

3.共享单例的StateFlow

第三种方案就是使用全局单例进行共享,为了能够方便Compose观察状态,在单例内部持有一个StateFlow对象作为共享数据源。

// GlobalCounter.kt
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class GlobalCounter @Inject constructor() {

    private val _count = MutableStateFlow(0)
    val count = _count.asStateFlow()

    fun inc() {
        _count.value++
    }
}

注意这里的 GlobalCounter 使用 Hilt 注入为全局单例模式。

// Screen1ViewModel.kt
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject

@HiltViewModel
class Screen1ViewModel @Inject constructor(
    private val counter: GlobalCounter
): ViewModel() {

    val count = counter.count

    fun inc() {
        counter.inc()
    }
}
// Screen2ViewModel.kt
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject

@HiltViewModel
class Screen2ViewModel @Inject constructor(
    private val counter: GlobalCounter
): ViewModel() {

    val count = counter.count
}

测试代码:

import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.fly.mycompose.application.examples.sharedata.model.Screen1ViewModel
import com.fly.mycompose.application.examples.sharedata.model.Screen2ViewModel

@Composable
fun SingletonDependencySample() {
    val navController = rememberNavController()
    NavHost(
        navController = navController,
        startDestination = "screen1"
    ) {
        composable("screen1") {
            val viewModel = hiltViewModel<Screen1ViewModel>()
            val count by viewModel.count.collectAsStateWithLifecycle()

            Screen1(
                count = count,
                onNavigateToScreen2 = {
                    viewModel.inc()
                    navController.navigate("screen2")
                }
            )
        }
        composable(
            route = "screen2"
        ) {
            val viewModel = hiltViewModel<Screen2ViewModel>()
            val count by viewModel.count.collectAsStateWithLifecycle()

            Screen2(count)
        }
    }
}

@Composable
private fun Screen1(
    count: Int,
    onNavigateToScreen2: () -> Unit
) {
    Button(onClick = {
        onNavigateToScreen2()
    }) {
        Text(text = "Count on screen1: $count")
    }
}

@Composable
private fun Screen2(count: Int) {
    Text(text = "Count on screen2: $count")
}

运行效果:

Jetpack Compose 中在屏幕间共享数据的 5 种方案_第3张图片

虽然使用全局单例能够在不同屏幕的Composable之间方便的共享数据,但是共享单例非常危险,这是因为单例类就是一个普通类,与其他类(如ViewModel)相比,它没有任何超能力,因此不能跨越Activity重建或者进程重建而存活,换句话说,一旦App进程意外终止,那么重新启动进程后,单例类内部的一切状态都归零,因为它没有采取任何恢复手段。

我们可以按照方案 1 中的模拟系统杀进程的方法进程测试:

可以看到在系统内存不足杀死App进程时,进程重建后并没有恢复之前的页面状态,我们保存的单例中的状态丢失了,也就是说单例此时处于初始化状态。

另外,在这个例子中,由于我们使用了 StateFlow ,它可以跨越屏幕旋转而存活,这是属于StateFlow 的特性,而非单例的特性。(为什么StateFlow是这样设计的呢?可以参考这里)

Jetpack Compose 中在屏幕间共享数据的 5 种方案_第4张图片

还有一个使用 StateFlow 带来的有趣现象是,当你短暂退出应用时,界面中从 StateFlow 收集的状态不会丢失,这也是 StateFlow 的特性, StateFlow 属于热流,在GC回收它之前,它的数据始终存在于内存之中,除非我们手动终止进程或者进程被系统回收。(更多关于StateFlow的内容可以参考这里)

可以看到,使用持有 StateFlow 的单例,除了不能跨越进程而存活外,也可以给我们带来一定的好处,即短暂的退出应用界面状态保持以及屏幕旋转的界面状态保持。但这不意味着它是安全的。

4.CompositionLocal

CompositionLocal 是一种可以组件树的上下游之间隐式传参的一种数据共享方案,一般用于主题、上下文之类的共享。需要共享数据的Composable之间需要满足父子嵌套关系才行,不能是同级的。

下面是测试代码:

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import kotlinx.coroutines.launch

val LocalSnackBarHostState = compositionLocalOf { SnackbarHostState() }

@Composable
fun AppRoot() {
    val snackBarHostState by remember { mutableStateOf(SnackbarHostState()) }
    CompositionLocalProvider(LocalSnackBarHostState provides snackBarHostState) {
        Scaffold(
            snackbarHost = { SnackbarHost(hostState = LocalSnackBarHostState.current) }
        ) { padding ->
            Box(modifier = Modifier.padding(padding)) {
                MyScreen()
            }
        }
    }
}

@Composable
private fun MyScreen() {
    val snackBarHostState = LocalSnackBarHostState.current
    val scope = rememberCoroutineScope()
    Button(
        onClick = {
            scope.launch { snackBarHostState.showSnackbar("Hello world!") }
        }
    ) {
        Text(text = "Show SnackBar")
    }
}

Jetpack Compose 中在屏幕间共享数据的 5 种方案_第5张图片

那么通过 CompositionLocal 共享的这种方案,能够跨越屏幕旋转和系统内存回收这种情况而保持状态吗?

为了便于观察,换一个计数器的测试代码示例:

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.unit.dp

val LocalCounter = compositionLocalOf { 0 }

@Composable
fun CompositionLocalCounter() {
    var counter by remember { mutableStateOf(0) }
    CompositionLocalProvider(LocalCounter provides counter) {
        Column(
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.spacedBy(15.dp)
        ) {
            MyScreen()
            Button(onClick = { counter++ }) { Text("Add counter") }
        }
    }
}

@Composable
private fun MyScreen() {
    val counter = LocalCounter.current
    Text(text = "counter: $counter")
}

首先是屏幕旋转:

Jetpack Compose 中在屏幕间共享数据的 5 种方案_第6张图片

然后是模拟系统内存回收:

很显然, CompositionLocal 在这两种情况下都不能保持状态。

更多关于 CompositionLocal 的使用和介绍请参考 Jetpack Compose 中的 CompositionLocal

5.持久化存储

Jetpack Compose 中的状态持久化存储方案有很多,除了 DataStore 和 Room,你还可以使用第三方的存储库或者直接使用文件。

通过这种方式在屏幕间共享数据的好处也很明显:可以跨越任意形式的进程终止而永久存在,超越一切生死。 但是缺点可能就是比较慢。

下面是使用 SharedPreferences 来实现本地存储用户User信息的一个简单示例:

// Session.kt
data class Session(val user: User, val token: String, val expiresAt: Long)
data class User(val firstName: String, val lastName: String, val email: String)
interface SessionCache {
    fun saveSession(session: Session)
    fun getActiveSession(): Session?
    fun clearSession()
}
// SessionCacheImpl.kt
import android.content.SharedPreferences
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import javax.inject.Inject

class SessionCacheImpl @Inject constructor(
    private val sharedPreferences: SharedPreferences
): SessionCache {

    private val moshi = Moshi.Builder()
        .add(KotlinJsonAdapterFactory())
        .build()
    private val adapter = moshi.adapter(Session::class.java)

    override fun saveSession(session: Session) {
        sharedPreferences.edit()
            .putString("session", adapter.toJson(session))
            .apply()
    }

    override fun getActiveSession(): Session? {
        val json = sharedPreferences.getString("session", null) ?: return null
        return adapter.fromJson(json)
    }

    override fun clearSession() {
        sharedPreferences.edit().remove("session").apply()
    }
}
// PersistentViewModel1.kt
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject

@HiltViewModel
class PersistentViewModel1 @Inject constructor(
    private val sessionCache: SessionCache
): ViewModel() {

    val session get() = sessionCache.getActiveSession()

    fun saveSession() {
        sessionCache.saveSession(
            session = Session(
                user = User(
                    firstName = "Philipp",
                    lastName = "Lackner",
                    email = "[email protected]"
                ),
                token = "sample-token",
                expiresAt = 12345678910
            )
        )
    }
}
// PersistentViewModel2.kt
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject

@HiltViewModel
class PersistentViewModel2 @Inject constructor(
    private val sessionCache: SessionCache
): ViewModel() {
    val session get() = sessionCache.getActiveSession()
}

测试代码:

// PersistentStorageSample.kt
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.fly.mycompose.application.examples.sharedata.model.PersistentViewModel1
import com.fly.mycompose.application.examples.sharedata.model.PersistentViewModel2

@Composable
fun PersistentStorageSample() {
    val navController = rememberNavController()
    NavHost(
        navController = navController,
        startDestination = "screen1"
    ) {
        composable("screen1") {
            val viewModel = hiltViewModel<PersistentViewModel1>()
            LaunchedEffect(Unit) {
                println("Session: ${viewModel.session}")
            }
            Screen1(
                onNavigateToScreen2 = {
                    viewModel.saveSession()
                    navController.navigate("screen2")
                }
            )
        }
        composable(
            route = "screen2"
        ) {
            val viewModel = hiltViewModel<PersistentViewModel2>()
            LaunchedEffect(Unit) {
                println("Session: ${viewModel.session}")
            }
            Screen2()
        }
    }
}

@Composable
private fun Screen1(
    onNavigateToScreen2: () -> Unit
) {
    Button(onClick = {
        onNavigateToScreen2()
    }) {
        Text(text = "Go to next screen")
    }
}

@Composable
private fun Screen2() {
    Text(text = "Hello world")
}

总结

Jetpack Compose 中在屏幕间共享数据的 5 种方案_第7张图片

你可能感兴趣的:(Jetpack,Compose,android,Jetpack,Compose,Compose,共享数据)