Kotlin在推出多年之后已经变得非常普及了。相信现在至少有80%的Android项目已经在使用Kotlin开发,或者有部分功能使用Kotlin开发。
关于Kotlin方面的知识,我其实分享的文章并不算多,主要内容都是集中在《第一行代码 第3版》这本书当中。看完这本书,相信你一定可以很好地上手Kotlin这门语言。
其实由于《第一行代码 第3版》这本书只有Kotlin版本,销量受到了很大的影响,远不及第2版的销量。出版社数次跟我沟通过,希望我能再出一个面向Java语言的版本,因为有很多的读者,尤其是高校群体,还是想看Java语言的书,但是都被我拒绝了。
我之所以会拒绝,是因为Kotlin对于Android开发者来说已经非常重要了。如果你真的希望成为一名优秀的Android开发者(这个标准在几年后会降低为合格的Android开发者),那么Kotlin就必学不可。
因为现代化Android开发技术栈里面涉及到的方方面面的新知识,几乎已经全面Kotlin化。如果还守着Java不放,那就意味着像协程、Compose等未来主流的Android技术栈都将完全与你无关。
而现在随着Kotlin的普及率越来越高,我也终于打算去写一些基于Kotlin语言的进阶技术内容了。目前的计划是把Flow和Compose的相关内容都写一写,先从Flow开始写起。那么我们的Kotlin Flow系列就此正式展开了。
我打算通过3篇文章,从Flow的基础入门知识开始写起,逐渐教会大家Flow的常见用法,适用场景,以及容易被人忽视的坑点和注意事项。希望大家通过学习这个系列的文章之后,都能比较熟练地使用Flow。
另外需要注意的是,Flow基于Kotlin和协程这两项技术。而本篇文章并不会介绍这两项技术,所以如果你还没有入门Kotlin以及协程的话,建议还是先去阅读《第一行代码 第3版》进行基础知识部分的学习。
前言就说到这里,那么我们正式开始吧。
Flow和响应式编程
先说说响应式编程。
从大概四五年前开始,响应式编程逐渐进入到移动开发领域,并且变得越来越火热。比较有代表性的那应该就是在Android领域无人不知,无人不晓的RxJava框架。
其实我对于RxJava并不算很熟悉,当初在网上也学过各种教程和文章,但由于工作上一直没能用得上,所以我现在还能记得住的知识点已经不太多了。
但是RxJava留给我至今的印象就是上手困难。这个响应式编程的思维,它和传统意义上比较简单直观的程序顺序执行的思维就是不太一样。
那么既然这种编程思维上手如此困难,为什么我们还要去学习和使用它呢?
为了要证明响应式编程到底有多好,网上已经有数不清的教程和文章在费尽心思去解释。因此这里我也就不再另辟蹊径拍脑袋再去原创一个了,我直接就引用Google官方的讲解示例。官方讲解视频链接:https://youtu.be/fSB6_KE95bU
比如说有一头小牛住在山脚下,山上有一个湖,小牛每天需要跑很远的路拎着水桶去湖边打水。
每天要跑很远的路就算了,关键是这个湖还时不时会干枯掉,有时小牛到了湖边发现湖已经干了,就完全白跑了一趟。
时间久了明眼人都能发现,这种打水的方式太愚蠢了。为什么不多花点时间去搞好基建,架一条从湖边到山脚下的水管,这样小牛就再也不用跑很远的路去打水了,每次想喝水只要打开水龙头就可以了。而且判断湖有没有干枯也可以通过打开水龙头看看有没有水来判断。
并且架设好了一条管道之后,以后也可以再去轻松架接其他管道。对于最终的用水端而言,这个过程甚至可以是无感知的,因为他只需要负责打开和关闭水龙头即可。
在上述的这个例子当中,拎着水桶去湖边打水就可以类比为我们平时一般的编程方式,需要什么东西就去调用对应的函数。而通过架设水管引流,在水龙头接水则可以类比为当下最流行的响应式编程。
哇,看到这么形象的对比和这么巨大的反差,是不是觉得响应式编程的理念屌爆了,瞬间觉得自己以前的编程方式好low?
其实我第一次看到这种类比的时候也感慨怎么早没发明出来这么牛逼的编程方式。但是后来经过思考之后,我发现Google举的这个例子其实也是经不住推敲的。
在现在生活中,拎个水桶去打水这种又苦又累的活当然谁都不想干,拧拧水龙头多轻松。但是在程序世界中,我们平时调用一个函数可不是这种又苦又累的话。相反,调用一个函数非常简单,只需要调用它获取它的返回值即可。而看似轻松的水龙头,你想要在程序里实现类似的功能(也就是所谓的响应式编程),却并不简单,这个水龙头的开关没那么容易把控。
所以,很多程序员尝试了响应式编程之后会觉得这都是什么玩意,好好的简单代码非要写得这么复杂。
没错,我也觉得响应式编程的思维对初学者不够友好,能把本来简单的代码复杂化,但它却也确实能解决一些本来不太容易解决的问题。
还拿刚才打水的例子来说,调用一个函数去打水这很简单,但如果这个打水的过程是非常耗时的怎么办?在主线程里调用可能就会让程序卡死了。因此这个时候你就需要考虑开子线程去打水,然后还要处理线程回调结果等一些事务。
但如果是响应式编程的话,你需要做的仍然只是开开水龙头就可以了。
总之,我个人的感觉是,随着项目越来越复杂,你就越来越能感受到响应式编程所带来的优势。而如果项目比较简单的话,很多时候使用响应式编程就是自己给自己找麻烦。
好了,以上就是我对于响应式编程的一些分析。那么在Android领域,之前影响力最大的响应式编程框架就是RxJava。但是你也发现了,它是Rx Java(虽然它也可以在Kotlin上使用)。这让Kotlin怎么忍呢?于是,Kotlin团队又开发出了一套专门用于在Kotlin上使用的响应式编程框架,也就是我们这个系列的主角了:Flow。
Flow的基本用法
本篇文章中,我准备通过一个最简单的例子来让大家快速上手Flow的基本用法。由于过于简单了,在一些细节方面甚至都是错误的。但是没关系,细节方面我会在后面的文章中再深入介绍,当前我们的目标就是,能跑起来就行。
在Android Studio当中新建一个FlowTest的项目,然后我们开始吧。
那么到底是一个什么例子呢?非常简单,就是在Android中实现一个计时器的效果,每秒钟更新一次时间。但是必须要使用Flow的技术来实现。
首先第一步是添加依赖库,想要在Android项目中使用Flow,以下依赖库是需要添加到项目当中的:
代码语言:javascript复制dependencies {
...
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.1"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.1"
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.5.1'
implementation "androidx.activity:activity-ktx:1.6.0"
implementation "androidx.fragment:fragment-ktx:1.5.3"
}
其中前两项是协程库,因为Flow是构建在Kotlin协程基础之上的,因此协程依赖库必不可少。第三项是用来提供协程作用域的,同样必不可少。
后两项是ktx的扩展库,这些倒不是必须的,但是能帮忙我们简化不少代码的书写,因此也建议添加上。
接下来开始定义布局,布局文件activity_main.xml中的内容也非常简单,一个Button用于开始计时,一个TextView用于显示时间:
代码语言:javascript复制<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:id="@ id/text_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0"
android:textSize="20sp"
app:layout_constraintVertical_chainStyle="packed"
app:layout_constraintBottom_toTopOf="@ id/button"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Button
android:id="@ id/button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="Start"
app:layout_constraintVertical_chainStyle="packed"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/text_view" />
</androidx.constraintlayout.widget.ConstraintLayout>
写完这些,我们基本就将准备工作都做好了,那么下面就要使用Flow技术来实现定时器功能了。
回想一下刚才的类比,响应式编程就像是使用水龙头来接水一样。那么整个过程中最重要的部分一共有3处:水源、水管和水龙头。
其中,水源也就是我们的数据源,这部分是需要我们自己处理的。
水龙头是最终的接收端,可能是要展示给用户的,这部分也需要我们自己处理。
而水管则是实现响应式编程的基建部分,这部分是由Flow封装好提供给我们的,并不需要我们自己去实现。
因此这下就清楚了,我们需要编写的就是水源和水龙头这两部分。
先从水源开始写起,定义一个MainViewModel类,并继承自ViewModel,代码如下所示:
代码语言:javascript复制class MainViewModel : ViewModel() {
val timeFlow = flow {
var time = 0
while (true) {
emit(time)
delay(1000)
time
}
}
}
这里使用flow构建函数构建出了一个timeFlow对象。
在flow构建函数的函数体内部,我们写了一个while死循环,每次循环都会将time变量加1,同时每次循环都会调用delay函数延迟1秒执行。
这里的delay函数是一个协程当中的挂起函数,只有在协程作用域或其他挂起函数中才能调用。因此可以看出,flow构建函数还会提供一个挂起函数的上下文给到函数体内部。
剩下的emit函数可以理解为一个数据发送器,它会把传入的参数发送到水管当中。
总共就这么几行代码,是不是非常简单?这样我们就把水源部分搞定了。
可能有的朋友会说,这个timeFlow变量是定义成的全局变量,一开始就会执行,会不会我们还没打算开始接水,这边的水源就在源源不断开始送水了?
在这种场景下不会。因为使用flow构建函数构建出的Flow是属于Code Flow,也叫做冷流。所谓冷流就是在没有任何接受端的情况下,Flow是不会工作的。只有在有接受端(水龙头打开)的情况下,Flow函数体中的代码就会自动开始执行。
好了,那么接下来我们开始去实现水龙头部分,代码如下所示:
代码语言: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()
}
}
}
}
}
这段代码最重点的部分在于,我们调用了MainViewModel中定义的timeFlow的collect函数。调用collect函数就相当于把水龙头接到水管上并打开,这样从水源发送过来的任何数据,我们在水龙头这边都可以接收到,然后再把接收到的数据更新到TextView上面即可。
这段代码虽然看上去很简单,但是存在着很多隐形的坑。由于Flow的collect函数是一个挂起函数,因此必须在协程作用域或其他挂起函数中才能调用。这里我们借助lifecycleScope启动了一个协程作用域来实现。
另外,只要调用了collect函数之后就相当于进入了一个死循环,它的下一行代码是永远都不会执行到的。因此,如果你的代码中有多个Flow需要collect,下面这种写法就是完全错误的:
代码语言:javascript复制lifecycleScope.launch {
mainViewModel.flow1.collect {
...
}
mainViewModel.flow2.collect {
...
}
}
这种写法flow2中的数据是无法得到更新的,因为它压根就执行不到。
正确的写法应该是借助launch函数再启动子协程去collect,这样不同子协程之间就互不影响了:
代码语言:javascript复制lifecycleScope.launch {
launch {
mainViewModel.flow1.collect {
...
}
}
launch {
mainViewModel.flow2.collect {
...
}
}
}
其实上述的代码还有一些坑在里面,但正如我前面所说,我们本篇文章的目标是能跑起来就行,剩下的坑我们后面的文章再详细讨论。
现在可以运行一下程序了,点击界面上的Button,效果如下图所示:
可以看到,计时器功能已经成功实现了。
流速不均匀问题
关于Flow最基本的用法我感觉差不多就是这些,但最后我认为还有一个知识点是值得讲的。
由于Flow是一种基于观察者模式的响应式编程模型,水源发出了一个数据,水龙头这边就会收到一个数据。但是水龙头处理数据的速度不一定和水源发出数据的速度是一致的,如果水龙头处理速度过慢,就可能出现管道阻塞的现象。
响应式编程框架都可能会遇到这种问题,RxJava中还有专门的背压策略来处理这类问题。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()
delay(3000)
}
}
}
}
}
这里在timeFlow的collect函数处理中加了一个delay逻辑,让它延迟3秒钟。
要知道,在水源处我们是每秒种发送一条数据,结果在水龙头这里要3秒钟才能处理一条数据。那么结果会是什么样的呢?我们来看下效果吧:
可以看到,现在每3秒钟计时器才会更新一次。如此一来,我们的计时器就完全不准了。
那么要如果解决这个问题呢?
这个问题的本质是水龙头处理数据速度过慢,导致管道中存在大量的积压数据,并且积压的数据会一个个继续传递给水龙头,即使这些数据已经过期了。
客户端应该保持在界面上始终显示最新的数据,如果是已经过期的数据,再展示给用户是没有价值的。
因此,只要有更新的数据过来,如果上次的数据还没有处理完,那么我们就直接把它取消掉,立刻去处理最新的数据即可。
在Flow当中实现这样的功能,只需要借助collectLatest函数就能做到,如下所示:
代码语言: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.collectLatest { time ->
textView.text = time.toString()
delay(3000)
}
}
}
}
}
可以看到,这里我们稍微改动了一下水龙头处的实现,不再调用collect函数去收集数据,而是改成了collectLatest函数。
那么从名字上就能看出,collectLatest函数只接收处理最新的数据。如果有新数据到来了而前一个数据还没有处理完,则会将前一个数据剩余的处理逻辑全部取消。
重新运行一下程序,我们再来看一次效果:
没有问题,现在计时器又能恢复正常工作了。
好了,到这里为止,Kotlin Flow系列的第一篇文章差不多就可以结束了。掌握这些内容我认为已经足以称得上算是Flow入门了,那么更多关于Flow的知识,我们本系列的下一篇文章见。
如果想要学习Kotlin和最新的Android知识,可以参考我的新书 《第一行代码 第3版》,点击此处查看详情。