Kotlin Flow响应式编程,StateFlow和SharedFlow

2023-10-18 17:26:06 浏览数 (3)

大家好,今天是Kotlin Flow响应式编程三部曲的最后一篇。

其实回想一下我写这个Kotlin Flow三部曲的初衷,主要还是因为我自己想学这方面的知识。

虽然Kotlin我已经学了很多年了,但是对于Flow我却一直没怎么接触过。可能是因为工作当中一直用不上吧,我现在工作的主语言依然还是Java。

而我一直都是这个样子,写博客基本上不是为了谁而写的,大部分都只是因为我自己想学。但是学了不用很快又会忘记,所以经常就会通过文章的形式把它记录下来,算是助人又助己了。

而Kotlin Flow在可预见的时间里,我也上不太可能能在工作当中用得到,所以这个系列也就基本是属于我个人的学习笔记了。

今天的这一篇文章,我准备讲一讲StateFlow和SharedFlow的知识。内容和前面的两篇文章有一定的承接关系,所以如果你还没有看过前面两篇文章的话,建议先去参考 Kotlin Flow响应式编程,基础知识入门 和 Kotlin Flow响应式编程,操作符函数进阶 。

Flow的生命周期管理

首先,我们接着在 Kotlin Flow响应式编程,基础知识入门 这篇文章中编写的计时器例子来继续学习。

之前在编写这个例子的时候我有提到过,首要目的就是要让它能跑起来,以至于在一些细节方面的写法甚至都错误的。

那么今天我们就要来看一看,之前的计时器到底错在哪里了。

如果只是直观地从界面上看,好像一切都是可以正常工作的。但是,假如我们再添加一些日志来进行观察的话,问题就会浮出水面了。

那么我们在MainActivity中添加一些日志,如下所示:

代码语言:javascript复制
class MainActivity : AppCompatActivity() {

    private val mainViewModel by viewModels<MainViewModel>()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val textView = findViewById<TextView>(R.id.text_view)
        val button = findViewById<Button>(R.id.button)
        button.setOnClickListener {
            lifecycleScope.launch {
                mainViewModel.timeFlow.collect { time ->
                    textView.text = time.toString()
                    Log.d("FlowTest", "Update time $time in UI.")
                }
            }
        }
    }
}

这里每当计时器更新一次的时候,我们同时打印一行日志来方便进行进行观察。

另外,MainViewModel中的代码这里我也贴上吧,虽然它是完全没有改动的:

代码语言:javascript复制
class MainViewModel : ViewModel() {

    val timeFlow = flow {
        var time = 0
        while (true) {
            emit(time)
            delay(1000)
            time  
        }
    }
    
}

运行程序看一看效果:

一开始的时候,界面上计时器每更新一次,同时控制台也会打印一行日志,这还算是正常。

可接下来,当我们按下Home键回到桌面后,控制台的日志依然会持续打印。好家伙,这还得了?

这说明,即使我们的程序已经不在前台了,UI更新依然在持续进行当中。这是非常危险的事情,因为在非前台的情况下更新UI,某些场景下是会导致程序崩溃的。

也就是说,我们并没有很好地管理Flow的生命周期,它没有与Activity的生命周期同步,而是始终在接收着Flow上游发送过来的数据。

那这个问题要怎么解决呢?lifecycleScope除了launch函数可以用于启动一个协程之外,还有几个与Activity生命周期关联的launch函数可以使用。比如说,launchWhenStarted函数就是用于保证只有在Activity处于Started状态的情况下,协程中的代码才会执行。

那么我们用launchWhenStarted函数来改造一下上述代码:

代码语言:javascript复制
class MainActivity : AppCompatActivity() {

    private val mainViewModel by viewModels<MainViewModel>()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val textView = findViewById<TextView>(R.id.text_view)
        val button = findViewById<Button>(R.id.button)
        button.setOnClickListener {
            lifecycleScope.launchWhenStarted {
                mainViewModel.timeFlow.collect { time ->
                    textView.text = time.toString()
                    Log.d("FlowTest", "Update time $time in UI.")
                }
            }
        }
    }
}

变动就只有这一处,我们使用launchWhenStarted函数替换了之前的launch函数,其余部分都是保持不变的。

现在重新运行一下程序,效果如下图所示:

可以看到,这次当我们将程序切到后台的时候,日志就会停止打印,说明刚才的改动生效了。而当我们将程序重新切回到前台时,计时器会接着刚才切出去的时间继续计时。

那么现在程序终于一切正常了吗?

很遗憾,还没有。

还有什么问题呢?上图其实已经将问题显现出来了。

现在的主要问题在于,当我们将程序从后台切回到前台时,计时器会接着之前切出去的时间继续计时。

这说明了什么?说明程序在后台的时候,Flow的管道中一直会暂存着一些的旧数据,这些数据不仅可能已经失去了时效性,而且还会造成一些内存上的问题。

要知道,我们使用flow构建函数构建出的Flow是属于冷流,也就是在没有任何接受端的情况下,Flow是不会工作的。但是上述例子当中,即使程序切到了后台,Flow依然没有中止,还是为它保留了过期数据,这就是一种内存上的浪费。

当然,我们这个例子非常简单,在实际项目中一个Flow可能又是由多个上游Flow合并而成的。在这种情况下,如果程序进入了后台,却仍有大量Flow依然处于活跃的状态,那么内存问题会变得更加严重。

为此,Google推荐我们使用repeatOnLifecycle函数来解决这个问题,写法如下:

代码语言:javascript复制
class MainActivity : AppCompatActivity() {

    private val mainViewModel by viewModels<MainViewModel>()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val textView = findViewById<TextView>(R.id.text_view)
        val button = findViewById<Button>(R.id.button)
        button.setOnClickListener {
            lifecycleScope.launch {
                repeatOnLifecycle(Lifecycle.State.STARTED) {
                    mainViewModel.timeFlow.collect { time ->
                        textView.text = time.toString()
                        Log.d("FlowTest", "Update time $time in UI.")
                    }
                }
            }
        }
    }
}

repeatOnLifecycle函数接受一个Lifecycle.State参数,这里我们传入Lifecycle.State.STARTED,同样表示只有在Activity处于Started状态的情况下,协程中的代码才会执行。

使用repeatOnLifecycle函数改造之后,运行效果会完全不一样,我们来看一下:

可以看到,当我们将程序切到后台之后,日志打印就停止了。当我们将程序重新切回前台时,计时器会从零开始重新计时。

这说明什么?说明Flow在程序进入后台之后就完全停止了,不会保留任何数据。程序回到前台之后Flow又从头开始工作,所以才会从零开始计时。

正确使用repeatOnLifecycle函数,这样才能让我们的程序在使用Flow的时候更加安全。

StateFlow的基本用法

即使你从来没有使用过Flow,但是我相信你一定使用过LiveData。

而如果谈到在Flow的所有概念当中,最最接近LiveData的,那毫无疑问就是StateFlow了。

可以说,StateFlow的基本用法甚至能够做到与LiveData完全一致。对于广大Android开发者来说,我认为这是一个非常容易上手的组件。

下面我们就通过一个例子来学习一下StateFlow的基本用法。例子非常简单,就是复用了刚才计时器的例子,并稍微进行了一下改造。

首先是对MainViewModel的改造,代码如下所示:

代码语言:javascript复制
class MainViewModel : ViewModel() {

    private val _stateFlow = MutableStateFlow(0)

    val stateFlow = _stateFlow.asStateFlow()

    fun startTimer() {
        val timer = Timer()
        timer.scheduleAtFixedRate(object : TimerTask() {
            override fun run() {
                _stateFlow.value  = 1
            }
        }, 0, 1000)
    }
}

可以看到,这里我们采取了和基础知识入门篇完全不一样的计时器实现策略。

之前我们是借助Flow和协程的延迟机制来实现计时器效果的,而这里则改成了借助Java的Timer类来实现。

现在,只要调用了startTimer()函数,每隔一秒钟Java的Timer定时器都会执行一次。那么执行了要干什么呢?这就非常关键了,我们每次都给StateFlow的value值加1 。

你会发现,这个例子中展示的StateFlow的用法几乎和LiveData是完全一致。同样都是通过给value变量赋值来更新数据,甚至同样都是创建一个Mutable的private版本来进行内部操作(一个叫MutableStateFlow,一个叫MutableLiveData),再转换一个public的外部版本进行数据观察(一个叫StateFlow,一个叫LiveData)。

如此来看,在MainViewModel层面确实是非常好理解的。

接下来看一下MainActivity中的代码改造:

代码语言:javascript复制
class MainActivity : AppCompatActivity() {

    private val mainViewModel by viewModels<MainViewModel>()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val textView = findViewById<TextView>(R.id.text_view)
        val button = findViewById<Button>(R.id.button)
        button.setOnClickListener {
            mainViewModel.startTimer()
        }
        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                mainViewModel.stateFlow.collect {
                    textView.text = it.toString()
                }
            }
        }
    }
}

当点击按钮时,我们会调用MainViewModel中的startTimer()函数开启定时器。

然后,这里通过lifecycleScope启动了一个协程作用域,并开始对我们刚才定义的StateFlow进行监听。上述代码中的collect函数相当于LiveData中的observe函数。

StateFlow的基本用法就是这样了,现在让我们来运行一下程序吧:

看上去计时器已经可以正常工作了,非常开心。

StateFlow其中一个重要的价值就是它和LiveData的用法保持了高度一致性。如果你的项目之前使用的是LiveData,那么终于可以放宽了心,零成本地迁移到Flow上了吧?

StateFlow的高级用法

虽说我们使用StateFlow改造的计时器已经可以成功运行了,但是有没有觉得刚才的写法有点太过于传统了,看着非常得不响应式(毕竟用法和LiveData完全一致)。

实际上,StateFlow也有更加响应式的用法,借助stateIn函数,可以将其他的Flow转换成StateFlow。

不过,为了能够更好地讲解stateIn函数,我们还需要对之前的例子进行一下改造。

首先将MainViewModel中的代码还原到最初版本:

代码语言:javascript复制
class MainViewModel : ViewModel() {

    val timeFlow = flow {
        var time = 0
        while (true) {
            emit(time)
            delay(1000)
            time  
        }
    }
    
}

然后修改MainActivity中的代码:

代码语言:javascript复制
class MainActivity : AppCompatActivity() {

    private val mainViewModel by viewModels<MainViewModel>()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val textView = findViewById<TextView>(R.id.text_view)
        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                mainViewModel.timeFlow.collect { time ->
                    textView.text = time.toString()
                }
            }
        }
    }
}

这里我们移除了对Button点击事件的监听,而是在onCreate函数中直接让计时器就开始工作。

为什么要做这样的修改呢?

因为这会暴露出我们之前代码中隐藏的另外一个问题,观察如下效果图:

可以看到,原来除了程序进入后台之外,手机发生横竖屏切换也会让计时器重新开始计时。

出现这个情况的原因是,手机横竖屏切换会导致Activity重新创建,重新创建就会使得timeFlow重新被collect,而冷流每次被collect都是要重新执行的。

但这并不是我们想看到的现象,因为横竖屏切换是很迅速的事情,在这种情况下我们没必要让所有的Flow都停止工作再重新启动。

那么该怎么解决呢?现在终于可以引入stateIn函数了,先上代码,我再进行讲解。修改如下:

代码语言:javascript复制
class MainViewModel : ViewModel() {

    private val timeFlow = flow {
        var time = 0
        while (true) {
            emit(time)
            delay(1000)
            time  
        }
    }

    val stateFlow =
        timeFlow.stateIn(
            viewModelScope,
            SharingStarted.WhileSubscribed(5000), 
            0
        )
}

前面已经介绍了,stateIn函数可以将其他的Flow转换成StateFlow。那么这里,我们就是将之前的timeFlow转换成了StateFlow。

stateIn函数接收3个参数,其中第1个参数是作用域,传入viewModelScope即可。第3个参数是初始值,计时器的初始值传入0即可。

而第2个参数则是最有意思的了。刚才有说过,当手机横竖屏切换的时候,我们不希望Flow停止工作。但是再之前又提到了,当程序切到后台时,我们希望Flow停止工作。

这该怎么区分分别是哪种场景呢?

Google给出的方案是使用超时机制来区分。

因为横竖屏切换通常很快就能完成,这里我们通过stateIn函数的第2个参数指定了一个5秒的超时时长,那么只要在5秒钟内横竖屏切换完成了,Flow就不会停止工作。

反过来讲,这也使得程序切到后台之后,如果5秒钟之内再回到前台,那么Flow也不会停止工作。但是如果切到后台超过了5秒钟,Flow就会全部停止了。

这点开销还是完全可以接受的。

好的,接下来我们在MainActivity中改成对StateFlow进行collect,从而完成这个例子吧:

代码语言:javascript复制
class MainActivity : AppCompatActivity() {

    private val mainViewModel by viewModels<MainViewModel>()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val textView = findViewById<TextView>(R.id.text_view)
        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                mainViewModel.stateFlow.collect { time ->
                    textView.text = time.toString()
                }
            }
        }
    }
}

现在重新运行一下程序看一看效果:

可以看到,现在手机横竖屏切换计时器依然是可以正常计时的,说明关联的Flow也都在继续工作,符合我们的预期。

到这里,StateFlow的相关内容基本就都讲完了。接下来还有今天的最后一块主题,SharedFlow。

SharedFlow

要想轻松理解SharedFlow,首先我们得要先理解粘性这个概念。

如果你接触过EventBus,应该对粘性不会感到陌生吧?

这是一个响应式编程中专有的概念。响应式编程是一种发送者和观察者配合工作的编程模式,由发送者发出数据消息,观察者接收到了消息之后进行逻辑处理。

普通场景下,这种发送者和观察者的工作模式还是很好理解的。但是,如果在观察者还没有开始工作的情况下,发送者就已经先将消息发出来了,稍后观察者才开始工作,那么此时观察者还应该收到刚才发出的那条消息吗?

不管你觉得是应该还是不应该,这都不重要。这里我抛出这个问题是为了引出粘性的定义。如果此时观察者还能收到消息,那么这种行为就叫做粘性。而如果此时观察者收不到之前的消息,那么这种行为就叫做非粘性。

EventBus允许我们在使用的时候通过配置指定它是粘性的还是非粘性的。而LiveData则不允许我们进行指定,它的行为永远都是粘性的。

刚才我们也说过,StateFlow和LiveData具有高度一致性,因此可想而知,StateFlow也是粘性的。

怎么证明呢?通过一个非常简单的例子即可证明。

修改MainViewModel中的代码,如下所示:

代码语言:javascript复制
class MainViewModel : ViewModel() {

    private val _clickCountFlow = MutableStateFlow(0)

    val clickCountFlow = _clickCountFlow.asStateFlow()

    fun increaseClickCount() {
        _clickCountFlow.value  = 1
    }
}

这里我们使用了一个名为clickCountFlow的StateFlow来进行简单的计数功能。然后定义了一个increaseClickCount()函数,用于将计数值加1。

接下来修改MainActivity中的代码:

代码语言:javascript复制
class MainActivity : AppCompatActivity() {

    private val mainViewModel by viewModels<MainViewModel>()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val textView = findViewById<TextView>(R.id.text_view)
        val button = findViewById<Button>(R.id.button)
        button.setOnClickListener {
            mainViewModel.increaseClickCount()
        }
        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                mainViewModel.clickCountFlow.collect { time ->
                    textView.text = time.toString()
                }
            }
        }
    }
}

可以看到,每当点击一次按钮时,我们都调用increaseClickCount()函数来让计数值加1。

另外就是使用前面学习过的写法,对clickCountFlow进行collect。

现在运行一下程序,效果如下图所示:

这里需要关注的重点是,当手机发生横竖屏切换时,计数器的数字仍然会保留在屏幕上。

你觉得这很正常?其实则不然。因为当手机发生横竖屏切换时,整个Activity都重新创建了,则此调用clickCountFlow的collect函数之后,并没有什么新的数据发送过来,但我们仍然能在界面上显示之前计数器的数字。

由此说明,StateFlow确实是粘性的。

粘性特性在绝大多数场景下都非常好使,这也是为什么LiveData和StateFlow都设计成粘性的原因。

但确实在一些场景下,粘性又会导致出现某些问题。而LiveData并没有提供非粘性的版本,所以网上甚至还出现了一些用Hook技术来让LiveData变成非粘性的方案。

相比之下,Flow则人性化了很多。想要使用非粘性的StateFlow版本?那么用SharedFlow就可以了。

在开始介绍SharedFlow的用法之前,我们先来看一下到底是什么样的场景不适用于粘性特性。

假设我们现在正在开发一个登录功能,点击按钮开始执行登录操作,登录成功之后弹出一个Toast告知用户。

首先修改MainViewModel中的代码,如下所示:

代码语言:javascript复制
class MainViewModel : ViewModel() {

    private val _loginFlow = MutableStateFlow("")

    val loginFlow = _loginFlow.asStateFlow()

    fun startLogin() {
        // Handle login logic here.
        _loginFlow.value = "Login Success"
    }
}

这里我们定义了一个startLogin函数,当调用这个函数时开始执行登录逻辑操作,登录成功之后向loginFlow进行赋值来告知用户登录成功了。

接着修改MainActivity中的代码,如下所示:

代码语言:javascript复制
class MainActivity : AppCompatActivity() {

    private val mainViewModel by viewModels<MainViewModel>()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val button = findViewById<Button>(R.id.button)
        button.setOnClickListener {
            mainViewModel.startLogin()
        }
        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                mainViewModel.loginFlow.collect {
                    if (it.isNotBlank()) {
                        Toast.makeText(this@MainActivity, it, Toast.LENGTH_SHORT).show()
                    }
                }
            }
        }
    }
}

这里当点击按钮时,我们调用MainViewModel中的startLogin函数开始执行登录。

然后在对loginFlow进行collect的地方,通过弹出一个Toast来告知用户登录已经成功了。

现在运行一下程序,效果如下图所示:

可以看到,当点击按钮开始执行登录时,弹出了一个Login Success的Toast,说明登录成功了。到这里都还挺正常的。

接下来当我们尝试去旋转一下屏幕,此时又会弹出一个Login Success的Toast,这就不对劲了。

而这,就是粘性所导致的问题。

现在我们明白了在某些场景下粘性特性是不太适用的,接下来我们就学习一下如何使用SharedFlow这个非粘性的版本来解决这个问题。

修改MainViewModel中的代码,如下所示:

代码语言:javascript复制
class MainViewModel : ViewModel() {

    private val _loginFlow = MutableSharedFlow<String>()

    val loginFlow = _loginFlow.asSharedFlow()

    fun startLogin() {
        // Handle login logic here.
        viewModelScope.launch {
            _loginFlow.emit("Login Success")
        }
    }
}

SharedFlow和StateFlow的用法还是略有不同的。

首先,MutableSharedFlow是不需要传入初始值参数的。因为非粘性的特性,它本身就不要求观察者在观察的那一刻就能收到消息,所以也没有传入初始值的必要。

另外就是,SharedFlow无法像StateFlow那样通过给value变量赋值来发送消息,而是只能像传统Flow那样调用emit函数。而emit函数又是一个挂起函数,所以这里需要调用viewModelScope的launch函数启动一个协程,然后再发送消息。

总体改动就是这么多,MainActivity中的代码是不需要做修改的,现在让我们重新运行一下程序吧:

可以看到,这次当我们再旋转一下屏幕,不会再像刚才那样又弹出一次Toast了,说明SharedFlow的改动已经生效了。

当然,其实SharedFlow的用法还远不止这些,我们可以通过一些参数的配置来让SharedFlow在有观察者开始工作之前缓存一定数量的消息,甚至还可以让SharedFlow模拟出StateFlow的效果。

但是我觉得这些配置会让SharedFlow更难理解,就不打算讲了。还是让它们之间的区别更纯粹一些,通过粘性和非粘性的需求来选择你所需要的那个版本即可。

好了,到这里,Kotlin Flow三部曲全剧终。

虽不敢说通过这三篇文章你就能成为Flow大神了,但是相信这些知识已经足够你解决工作中遇到了绝大多数问题了。

0 人点赞