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

news/2024/11/15 0:53:30/

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 ? 😑

在这里插入图片描述

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

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

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

在这里插入图片描述

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

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

2.共享ViewModel

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

在这里插入图片描述

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

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

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

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

import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlowclass 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.countfun 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")
}

运行效果:

在这里插入图片描述

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

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

在这里插入图片描述

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

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

在这里插入图片描述

还有一个使用 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.launchval 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.currentval scope = rememberCoroutineScope()Button(onClick = {scope.launch { snackBarHostState.showSnackbar("Hello world!") }}) {Text(text = "Show SnackBar")}
}

在这里插入图片描述

那么通过 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.dpval 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.currentText(text = "counter: $counter")
}

首先是屏幕旋转:

在这里插入图片描述

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

在这里插入图片描述

很显然, 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.Injectclass 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 nullreturn 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 = "test@test.com"),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")
}

总结

在这里插入图片描述


http://www.ppmy.cn/news/289796.html

相关文章

【秦时明月卡通动漫壁纸图集★(∩_∩)★】

【秦时明月卡通动漫壁纸图集★(∩_∩)★】 桌面壁纸下载

高通骁龙835对比苹果A10 差距究竟在哪里?

随着高通骁龙835手机的上市&#xff0c;更多普通消费者对于这款高端芯片也有了自己的认识。 那说到高通骁龙835&#xff0c;那自然要提及另一款高端芯片——苹果A10 Fusion。两款高端芯片&#xff0c;究竟谁优谁劣&#xff0c;差别又在哪里呢&#xff1f; 基本参数 按照传统&a…

哪吒壁纸来袭,教你不用PS也能制作哪吒1080P超清壁纸,你不看看

哪吒作为火遍全网的一部电影&#xff0c;连作为一个资深宅男的小编我都去了电影院看了一遍这部电影&#xff0c;真的好看。不过本来是奔着哪吒去的&#xff0c;结果没想到被敖丙圈了粉&#xff0c;既然如此今天我们就来制作一张关于哪吒的手机壁纸吧。 首先制作壁纸当然要用剪辑…

ASUS主板电脑安装Ubuntu20.04后无声音解决

1.简单查看声卡型号 2.具体解决方法 &#xff08;1&#xff09;修改 /etc/modprobe.d/alsa-base.conf 文件最后添加 options snd-hda-intel modelasus-zenbook &#xff08;2&#xff09;保存文件后&#xff0c;重启电脑

计算机没有音频驱动程序,电脑有驱动却没有高清晰音频管理器华硕主板应该装什么声卡驱动...

许多人对声卡驱动比较模糊&#xff0c;什么是声卡&#xff1f;声卡驱动管理着我们电脑产生出的声音&#xff0c;是一种多媒体声卡控制程序。那么&#xff0c;如何安装电脑声卡驱动呢&#xff1f;小编带大家操作。 1. 第一步&#xff0c;在你的电脑中找到“驱动人生”&#xff0…

怎么挑选计算机主机型号,怎么查看电脑的声卡型号?查看声卡型号方法介绍

声卡是电脑上的硬件设施&#xff0c;任何电脑上的硬件都需要使用驱动使其运行&#xff0c;那么我们下载驱动的时候就会用到声卡型号&#xff0c;那么怎么查看电脑的声卡型号?下面小编就为大家详细介绍一下&#xff0c;一起来看看吧&#xff01; 使用命令查看 1、首先呢&#x…

linux usb声卡 无声音,记一次解决在Ubuntu 18.04下声卡没有声音的经历

电脑的主板是华硕的B150-PLUS&#xff0c;声卡是瑞昱的intel ALC 887&#xff0c;从Ubuntu 16.04升级到Ubuntu 18.04系统&#xff0c;使用一切正常&#xff0c;但是声卡没有声音。经过查找资料&#xff0c;得出了解决该问题的答案&#xff0c;如果你的Ubuntu 18.04系统也没有声…

华硕bios开启虚拟化linux,手把手处理华硕主板开启虚拟化【搞定方法】

有用户反映说自己在使用电脑时发现华硕主板开启虚拟化【搞定方法】的难题,根据小编的调查并不是所有的朋友都知道华硕主板开启虚拟化【搞定方法】的问题怎么解决,不会的朋友也不用担心,下面我就给大家讲解一下华硕主板开启虚拟化【搞定方法】的完美解决步骤,其实只需要 1:…