和所有响应式UI框架一样,Compose 也是使用State来更新UI的
我们通常都是用下面的结构来开发:
class HelloCodelabActivity : AppCompatActivity() {
private lateinit var binding: ActivityHelloCodelabBinding
var name = ""
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
binding.textInput.doAfterTextChanged {text ->
name = text.toString()
updateHello()
}
}
private fun updateHello() {
binding.helloText.text = "Hello, $name"
}
}
这种方式就是典型的命令式编程,想要改变UI就必须得调用更新UI的方法,这种方式有以下缺点
- UI状态和Views紧密结合,导致难以进行单测
- 当有很多事件需要更新state时,可能会忘记更新state
- 当每个state变化时,都要手动去更新UI,如果忘记了就会导致UI显示异常
- 导致代码逻辑复杂
单向数据流
为了解决这个问题,Android 推出了ViewModel 和LivaData
通过ViewModel 我们可以从UI中提取state,也可以定义更新UI state的事件。
看下面的例子
class HelloCodelabViewModel: ViewModel() {
// LiveData holds state which is observed by the UI
// (state flows down from ViewModel)
private val _name = MutableLiveData("")
val name: LiveData<String> = _name
// onNameChanged is an event we're defining that the UI can invoke
// (events flow up from UI)
fun onNameChanged(newName: String) {
_name.value = newName
}
}
class HelloCodeLabActivityWithViewModel : AppCompatActivity() {
private val helloViewModel by viewModels<HelloCodelabViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
binding.textInput.doAfterTextChanged {
helloViewModel.onNameChanged(it.toString())
}
helloViewModel.name.observe(this) { name ->
binding.helloText.text = "Hello, $name"
}
}
}
在这个例子中我们把state从Activity中转移到了ViewModel中。state代表一个抽象的概念,在ViewModel中 state通过LiveData来表现,也可以说是一种数据模型,只不过这个数据模型可以当做UI的状态,用来更新UI。
其实整体上和上面的代码差别不大,只是中间多了个ViewModel来中转数据,其实也可以是其他的observeable,只不过谷歌给大家封装好了,就叫ViewModel。
这样既达成了解耦的成就,也实现了我们所说的单向数据流。
这样做有以下几点好处
- 可测试-UI和state分离,容易分别测试ViewModel和Activity
- state封装-只能通过ViewModel来更改state,可以避免局部state更新造成的bug
- UI一致性-state改变之后,所有观察该state的UI会马上更新
单向数据流就是指 符合事件向上传递而状态向下传递的设计模式。
例如 在ViewModel中,事件通过UI的调用向上传递给ViewModel,而状态通过LiveData 的 setValue 向下传递。
就像刚才说的,单向数据流不仅仅是描述ViewModel的术语,任何属于这种设计的能被称之为单向数据流。
Compose 中的state
在前面我们了解了什么是单向数据流模型,Compose也是遵循这个模型的一个UI框架,在Compose中推荐用MutableState 来管理状态,而不是LiveData。
在Compose中通常这样声明state
val name by mutableStateOf("Compose")
这里用到了Kotlin的by关键字,name的类型,取决于mutableStateOf方法传进去的类型,在这里其实就是String类型,通过by引用的对象,在取值和赋值的时候均会调用 代理类的getValue 和 setValue方法方法,这两个方法分别在State接口和 MutableState 中声明。
State 中的getValue
inline operator fun <T> State<T>.getValue(thisObj: Any?, property: KProperty<*>): T = value
MutableState 中的setValue
inline operator fun <T> MutableState<T>.setValue(thisObj: Any?, property: KProperty<*>, value: T) {
this.value = value
}
注意 这两个方法是通过扩展方法实现的,所以要进行导入
import androidx.compose.runtime.setValue
import androidx.compose.runtime.getValue
在这两个接口中只是简单的实现了一下代理方法,看着没有任何逻辑。
其实在setValue中赋值的时候,最终会调用到 this.value的set方法,在SnapshotMutableStateImpl中有实现。
override var value: T
get() = next.readable(this).value
set(value) = next.withCurrent {
if (!policy.equivalent(it.value, value)) {
next.writable(this) { this.value = value }
}
}
仿照Flutter 写个Counter
@Composable
fun Counter() {
var count by mutableStateOf(1)
Button(onClick = {
count ++
}) {
Text(text = count.toString())
}
}
在这段代码中,用代理模式,将int类型的count代理给了mutableStateOf 返回的state,然后对count进行set操作的时候就会触发 recompose,然后对Counter进行重新绘制。
上面的代码是有问题的,Compose 不像Flutter,每个Widget都是一个类,state可以作为一个类的属性,但是在Compose中,每个组件其实就是一个函数,在这个函数里,state只作为了局部变量,当state变化的时候,会重新调用函数,导致state重新初始化,就失去了state的意义。官方推荐的做法是:
@Composable
fun Counter() {
val count = remember {
mutableStateOf(0)
}
Button(onClick = { count.value ++ }) {
Text(text = count.value.toString())
}
}
state 使用remember包裹起来,remember方法会对state实例进行保存,每次recompose 的时候会把暂存的state取出来。由于remember的返回值只能试val类型,下面又要对count更新,所以不能用by,只能用“=” 得到的是个 State,所以下面要调用.value 来更新。
其实很多时候数据的更新并不是那么简单,比如网络请求,之前那一套MVVM 在Compose框架里也完全适用。我们可以将这个简单的Counter改为mvvm架构的。
首先定义ViewModel
class HelloViewModel : ViewModel() {
var cout by mutableStateOf(0)
private set
fun plus() {
cout ++
}
}
由于是从ViewModel中取数据,所以state就没必要使用remember进行包裹
修改Counter,将viewModel作为入参传入
@Composable
fun Counter(viewModel: HelloViewModel) {
Button(onClick = { viewModel.plus() }) {
Text(text = viewModel.cout.toString())
}
}
State改变之后是怎么recompose的
通过打断点可以看到Compose重组时的调用栈
倒数第二行 出现了 Choreographer,这个类和屏幕的刷新机制息息相关,Compose其实就是在收到屏幕的刷新信号时做的重组。
Compose 如何确定重组范围
Compose 在编译期分析出会受到某 state 变化影响的代码块,并记录其引用,当此 state 变化时,会根据引用找到这些代码块并标记为 Invalid 。在下一渲染帧到来之前 Compose 会触发 recomposition,并在重组过程中执行 invalid 代码块。
Invalid 代码块即编译器找出的下次重组范围。能够被标记为 Invalid 的代码必须是非 inline 且无返回值的 @Composalbe function/lambda,必须遵循 重组范围最小化 原则。
为何是 非 inline 且无返回值(返回 Unit)?
对于 inline 函数,由于在编译期会在调用处中展开,因此无法在下次重组时找到合适的调用入口,只能共享调用方的重组范围。
而对于有返回值的函数,由于返回值的变化会影响调用方,因此无法单独重组,而必须连同调用方一同参与重组,因此它不能作为入口被标记为 invalid。
范围最小化原则
只有会受到 state 变化影响的代码块才会参与到重组,不依赖 state 的代码不参与重组。
在这个例子中,重组的只是Text,Button并没有重组,因为重组只发生在 state read的函数中,write的函数并不在重组范围内。真正重组的起始不是Text方法,而是Button 后面的lambda。
如果我们稍微改写一下
@Composable
fun Counter(viewModel: HelloViewModel) {
Log.d("Counter", "recompose")
Button(
onClick = { viewModel.plus() })
{
Text(text = "按钮")
}
Text(text = viewModel.cout.toString(), color = Color.Black)
}
再次断点
就再次论证了刚才的观点,重组源头不是Text,而是包裹着Text的方法。
当我们尝试用Column包裹一下
再次断点,发现重组的源头还是Counter方法,而不是Column,那是因为
Column、Row、Box 乃至 Layout 这种容器类 Composable 都是 inline 函数,所以在运行时就相当于没有Column这一层,所以如果想通过缩小重组范围提高性能的话可以通过自定义Composable
@Composable
fun Wrapper(content: @Composable () -> Unit) {
Log.d(TAG, "Wrapper recomposing")
Box {
Log.d(TAG, "Box")
content()
}
}
总结:
此文只是简单介绍了Compose中的state是什么,为什么要设计state,以及简单的介绍了一下recompose的过程,并未说明recompose到底是怎么触发的,以及怎么确定的recompose的作用域。本文大量参考了Compose CodeLab,和Compose 技术原理,在下不才,如有疑惑之处请移步这两篇文章。