Jetpack Compose - Stateful and Stateless

相较於传统的 Android View,Jetpack Compose 在 Android 开发上还有一个新的概念,那就是 Stateful (有状态的) 还有 Stateless (无状态的),想要理解它们的差别,最简单最好理解的例子应该就属於文字输入框了。

对於 Android 开发者来说, EditText 大家一定都很熟悉,该元件的使用方式如下:

  • 设定元件上的文字内容:键盘或是使用 setText
  • 获取文字上的内容:getText().toString()
  • 取得更新状态:addOnTextChangedListener()

这些对於刚入行的开发者来说是很容易理解,也很好使用的 API,但是这样的设计却有一个很大的缺陷!EditText 是无法轻易拥有 Single source of truth 的!假设现在有一份资料从 ViewModel 传送到 View ,并且使用 setText 的方式将这份资料储存在 EditText 中,这时候,如果他们要符合 single source of truth 这概念的话,就只能允许在其中一个地方修改资料,而这个地方很理所当然的会是 ViewModel 这边,但是 EditText 这边却可以经由键盘的输入任意改变 EditText 中的值,在键盘输入的这一瞬间就导致了资料不同步,换句话说,EditText 这个元件一直都保有自己的状态,而这种类型的元件我们叫它 Stateful。

其实还有很多其他元件也是 Stateful 的,像是 CheckBox 还有 RadioButton,相信经验丰富的 Android 开发者一定经历过 RecyclerView 配上 CheckBox 资料不同步的情况吧,往下滑再滑回来一切就长得不一样了,而这也是依赖 Stateful 元件常犯的错误。

那 Jetpack Compose 的文字输入框又是怎样运作的呢?

@Composable
fun TextFieldSample(name: String) {
    Row {
        TextField(value = name, onValueChange = {})
    }
}

Jetpack Compose 的文字输入框是一个叫做 TextField 的元件,其中前两个参数是必填的,但现在可以先不用管它,直接留白,将它跑在手机上看看会发生什麽事:

https://user-images.githubusercontent.com/7949400/133355264-f96b194c-7f60-491d-b935-b239a264b7f6.gif

不管怎麽在键盘上输入,就是不会更新!这到底是发生了什麽事呢?

Stateless V.S. Stateful

文字输入框在 Jetpack Compose 中是一个 Stateless 的元件,也就是说他没有任何状态,也不会主动更新,唯一的更新方式是藉由外面的元件提供的状态,来跟 TextField 互动,下面做一个简单的例子:

@Composable
fun TextFieldSample(name: String) {
    Row {
        StatefulTextField(name)
    }
}

@Composable
fun StatefulTextField(name: String) {
    var text by remember { mutableStateOf(name)}

    TextField(value = text, onValueChange = { text = it })
}

我在这里建立了一个 StatefulTextField 来包装了 TextField ,经由这个包装之後这个元件就变成了一个 Stateful 的元件,行为就变得跟之前的 EditText 一样了。那我做了什麽事呢?首先是我在这个 Composable function 里面新增了一个变数: text ,用来当作这个 component 的状态,他的型别为 String。另外使用了之前讲过的关键字 by 还有 State,来让这个 text 在被改变的时候可以触发 Recomposition,remember 则是让这个 text 在下次 render 的时候还记得 Recomposition 之前的值。

至於 TextField 这边,第一个参数应该不用说明,就是要显示的文字这样而已,第二个参数 onValueChange 呢,则是键盘输入的 callback,这个 callback 所回传的值会是键盘输入改变完之後完整的字串,所以我直接用这边的 callback 来改变原输入:text = it

小提示:除了 by 之外,还可以用内建的 destructure 的方式达到一样的需求:

@Composable
fun StatefulTextField(name: String) {
    val (text, onTextChanged) = remember { mutableStateOf(name)}

    TextField(value = text, onValueChange = onTextChanged)
} 

相较於原本的 EditText, Jetpack Compose 版本的 TextField 给了我们更大的弹性,有了它我们可以很容易的去限制打的字数、显示 [] 当作密码或是输入密码时让 [] 延後再出现,各式各样客制化的需求都变成了可能。在原本的 Android View 我们能怎麽做?当然只能上 stackoverflow 或是去官方文件看能不能找到相对应的 API 了,现在只要稍微动动脑,敲敲几行程序,这些功能都很容易可以做出来。

// 只是多一行 if 判断式就可以限制字数
@Composable
fun LimitedTextField(name: String, textLength: Int) {
    var text by remember { mutableStateOf(name)}

    TextField(value = text, onValueChange = {
        if (it.length <= textLength) text = it 
    })
}

新页面:编辑文字页面

在便利贴应用程序中,其中还有一个功能是编辑便利贴的文字内容,在这里我们将使用一个全版的页面来做文字编辑,UI 完成之後会像下面这样子:

device-2021-09-15-205021.png

超级简单的对吧?接下来就来看看程序码吧!请暂时先忽略 EditTextViewModel 的实作,本篇的重点都在 View 层,明天会讲到 EditTextViewModel 的。

@Composable
fun EditTextScreen(
    editTextViewModel: EditTextViewModel,
    onLeaveScreen: () -> Unit,
) {
    val text by editTextViewModel.text.subscribeAsState(initial = "") // [1] text from ViewModel
    editTextViewModel.leavePage
        .toMain()
        .subscribeBy( onNext = { onLeaveScreen() }) // [3]

    Box(modifier = Modifier
        .fillMaxSize()
        .background(Color.White)
        .background(TransparentBlack)
    ) {
        TextField(
            value = text, 
            onValueChange = editTextViewModel::onTextChanged, // [2] pass lambda from ViewModel
            modifier = Modifier
                .align(Alignment.Center)
                .fillMaxWidth(fraction = 0.8f),
            colors = TextFieldDefaults.textFieldColors(
                backgroundColor = Color.Transparent,
                textColor = Color.White
            ),
            textStyle = MaterialTheme.typography.h5
        )

        // Close icon
        IconButton(
            modifier = Modifier.align(Alignment.TopStart),
            onClick = editTextViewModel::onCancelClicked
        ) {
            val painter = painterResource(id = R.drawable.ic_close)
            Icon(painter = painter, contentDescription = "Close", tint = Color.White)
        }

        // Check icon
        IconButton(
            modifier = Modifier.align(Alignment.TopEnd),
            onClick = editTextViewModel::onConfirmClicked
        ) {
            val painter = painterResource(id = R.drawable.ic_check)
            Icon(painter = painter, contentDescription = "Check", tint = Color.White)
        }
    }
}

跟 EditorScreen 一样,EditTextScreen 这边我也搭配了一个 ViewModel 给它: EditTextViewModel 。为了让状态可控可管,这边文字的内容是由 EditTextViewModel 控制的,文字的内容是藉由 subscribe editTextViewModel.text 这一个 Observable 来收到最新的资料,至於键盘输入的 callback ,也会再去呼叫 editTextViewModel::onTextChanged 这个函式,这个模式是不是有点眼熟呢?没错,我只是将刚刚 StatefulTextField 所做的事情照样搬到 ViewModel 里面去罢了。因此很明显的EditTextScreen 在这里也是一个 stateless UI 元件。

至於其他程序码的内容,大致上都是相对容易理解的,除了 editTextViewModel.leavePage 这部分之外 [3],我在这里写了一个 custom extension function :subscribeBy,主要是因为我想做的事情只能发生一次,所以不能用 State 接起来,不然会因为记住这状态而让同样的事件一再发生,如果还不是很能理解的话,这里有个 Android 开发者比较熟悉的元件可以做类比,他就是 LiveData 的 SingleLiveEvent (连结)。

所以为了达到“只发生一次”的这个需求,我必须的要用到 Jetpack Compose 的 Side effect - DisposableEffectDisposableEffect 会在当下的 Composable function 被回收时执行,以目前来说,这个 Composable function 就是 EditTextScreen 。我们也可以把这个 side effect 想像成是 Activity 的 onDestroy(),当EditTextScreen 结束要被回收时才会去执行。以下是 subscribeBy 的实作内容:

@Composable
fun <R, T : R> Observable<T>.subscribeBy(
    onNext: (T) -> Unit = {},
    onError: (Throwable) -> Unit = {},
    onComplete: () -> Unit = {},
) {
    DisposableEffect(this) {
        val disposable = subscribe(onNext, onError, onComplete)
        onDispose { disposable.dispose() }
    }
}

关於 Jetpack Compose 的 side effect 想要更深入研究的可以参考官方文件:https://developer.android.com/jetpack/compose/side-effects

小结

今天主要带大家认识 Stateful 跟 Stateless 的概念,以一般的原则来说,是尽量要设计出 Stateless 的元件为优先,以达到最大化重用,最大化弹性的目的,但是也不能因此解读为“Stateful 的 composable function 是不好的”,因为就是有一些情况需要用到 Stateful 元件,而且他们也扮演了很重要的角色,在我看来, Stateful 跟 Stateless 就是另外一种层面的职责分离。对於刚接触的人来说,今天的范例也许很少没什麽感觉,但在之後的章节中这个职责分离会再次出现的,相信到时候的范例会比这个更有说服力!


<<:  再次尝试的汇率爬虫

>>:  Day11回圈(Ⅰ)

Day 17: Structural patterns - Proxy

目的 将实际执行的服务遮蔽,取而代之的,建立一个代理人负责对外窗口的身份,以及对内与该服务沟通。 说...

Multiple objects (下)

大家好,我是西瓜,你现在看到的是 2021 iThome 铁人赛『如何在网页中绘制 3D 场景?从 ...

Day 22 ctop 好用的 docker 容器监控工具

图形化介面对於人们来说,若将其用於监测用途上,比起密密麻麻的数字表格,会是相当友善的工具。若想要监测...

Re: 新手让网页 act 起来: Day09 - 简单却不是很容易懂的 key(2)

昨天我们介绍了 key 的基本使用方式,今天我们就一起来了解为什麽需要 key 吧! 为什麽需要 k...

【第二十八天 - 系统设计 介绍】

Q1. 系统设计 是什麽 在业界基本上都是团队开发专案,每个人负责实作部分功能,而 Leetcode...