JetPack Compose 之 state

和所有响应式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的方法,这种方式有以下缺点

  1. UI状态和Views紧密结合,导致难以进行单测
  2. 当有很多事件需要更新state时,可能会忘记更新state
  3. 当每个state变化时,都要手动去更新UI,如果忘记了就会导致UI显示异常
  4. 导致代码逻辑复杂

单向数据流

为了解决这个问题,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。
这样既达成了解耦的成就,也实现了我们所说的单向数据流。


image.png

这样做有以下几点好处

  1. 可测试-UI和state分离,容易分别测试ViewModel和Activity
  2. state封装-只能通过ViewModel来更改state,可以避免局部state更新造成的bug
  3. 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重组时的调用栈


image.png

倒数第二行 出现了 Choreographer,这个类和屏幕的刷新机制息息相关,Compose其实就是在收到屏幕的刷新信号时做的重组。

Compose 如何确定重组范围

Compose 在编译期分析出会受到某 state 变化影响的代码块,并记录其引用,当此 state 变化时,会根据引用找到这些代码块并标记为 Invalid 。在下一渲染帧到来之前 Compose 会触发 recomposition,并在重组过程中执行 invalid 代码块。

Invalid 代码块即编译器找出的下次重组范围。能够被标记为 Invalid 的代码必须是非 inline 且无返回值的 @Composalbe function/lambda,必须遵循 重组范围最小化 原则。

为何是 非 inline 且无返回值(返回 Unit)?
对于 inline 函数,由于在编译期会在调用处中展开,因此无法在下次重组时找到合适的调用入口,只能共享调用方的重组范围。

而对于有返回值的函数,由于返回值的变化会影响调用方,因此无法单独重组,而必须连同调用方一同参与重组,因此它不能作为入口被标记为 invalid。

范围最小化原则
只有会受到 state 变化影响的代码块才会参与到重组,不依赖 state 的代码不参与重组。

image.png

在这个例子中,重组的只是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)
}

再次断点


image.png

就再次论证了刚才的观点,重组源头不是Text,而是包裹着Text的方法。

当我们尝试用Column包裹一下


image.png

再次断点,发现重组的源头还是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 技术原理,在下不才,如有疑惑之处请移步这两篇文章。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,547评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,399评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,428评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,599评论 1 274
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,612评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,577评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,941评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,603评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,852评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,605评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,693评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,375评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,955评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,936评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,172评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,970评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,414评论 2 342

推荐阅读更多精彩内容