Layout and State

Jetpack Compose

跟随 官方文档 学习 Compose 的第二篇。第一篇:Thinking in Compose

Layout in Compose

Theming

Android 之前的主题系统非常复杂,而且也很难用,我相信这是大多数开发者的共识。Android 开发团队当然也注意到了这一点,在设计 Compose 时,抛弃了(但也兼容)旧的系统。因此,在使用 Compose 时,应用 Material Design 主题或者自定义主题变得前所未有地简单和高效。

Compose 框架内置了 Material Design 主题,所有的组件都构建在该主题之上,与此同时,也非常易于自定义。MaterialThemecolortypographyshape 三部分组成,所有的主题都是以树型的方式自顶向下应用到组件之上的,多个主题可以嵌套使用,因此,我们可以非常灵活地应用、修改、重用主题。又因为 Compose 中自定义组件非常常见,所以我们可以修改、应用主题到单个组件的级别。

Modifier

Modifier 有点像 React-Native 中的 StyleSheet,你可以使用它修改 composable 组件的外观、行为,添加额外信息等。Modifier 和普通的 Kotlin 对象没什么区别,可以作为变量使用,也可以和其它 Modifier 组合起来使用。

相比传统的 View 的属性,Modifier 还有一个额外的优势,那就是可以根据当前上下文推断出那些属性可用,防止你用错。另外,Modifer 属性应用的顺序也会直接影响到组件的外观。比如,Modifier.padding(8.dp).clickable{}Modifier.clickable{}.padding(8.dp) 的可点击区域的大小是不同的,前者可点击区域在 padding 之内,后者可点击区域包括了 padding。

Slots APIs

Compose 中绝大多数内置组件的最后一个参数都是 content: @Composable () -> Unit,结合 Koltin 的 trailing lambda 的语法,我们就可以很方便地写出相互嵌套的组件:

Button {
    Row {
        MyImage()
        Spacer(4.dp)
        Text("Button")
    }
}

Custom Layout

Compose 中最基础的组件是 ColumnRowBox,通过它们我们可以组合出复杂的布局。但是,我们有时候可能也会用到一些更复杂的布局,这个时候就需要通过自定义 Layout 组件了。

Compose 中的布局原则

每个 composable 函数在执行之后都会释放出 UI 组件并被添加到视图树中,每个元素都有一个父元素以及多个(可能的)子元素,而且该元素有一个相对父元素的坐标 (x,y) 和大小 (width & height)。每个元素在被添加到视图树之前,都需要被测量一次,并且需要满足父元素的限制条件,比如最小和最大的宽高。如果元素具有子元素,那么还需要测量其所有的子元素决定其自身的大小之后再被绘制出来。

Compose 不允许元素多次测量,也就是说所有的元素只能测量一次,这么做的原因当然是为了性能考虑。因为 recomposition 的存在,如果同一个元素多次被测量会造成极大的性能浪费,尤其是在 UI 树嵌套很深的情况下,如果所有元素都测量多次以上,那么整个视图树 recomposition 的性能消耗将是巨大的。

自定义 LayoutModifier

我们可以通过实现(扩展函数)Modifler 的 layout() 函数来修改如何显示一个元素。

fun Modifier.customLayoutModifier(...) = Modifier.layout { measurable, constraints ->
  ...
}

实现 LayoutModifier 需要用到两个参数:

  • measurable:代表需要被测量和放置的元素
  • constraints:元素最小和最大的宽和高等

除此之外,我们通过调用 layout() 返回一个 MeasureResult 供 LayoutModifer 确定测量结果(大小、对齐方式、位置等):

fun Modifier.customLayoutModifier(...) = Modifier.layout { measurable, constraints ->
    // measure UI
    val placeable = measurable.measure(constraints)

    // do other things

    layout(placeable.width, height) {
        // place it
        placeable.placeRelative(0, paddingY)
    }
}

注意在 layout() 函数中,必须调用 placeXXX() 函数,否则元素将不可见。

自定义 Layout

除了自定义如何排列元素之外,我们还可以自定义 Layout。与自定义 LayoutModifier 类似,首先需要测量所有子元素的位置,然后根据 constraints 放置到合适的位置。

@Composable
fun CustomLayout(
    modifier: Modifier = Modifier,
    // custom layout attributes goes here
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        
        // Measure each child
        val placeables = measurables.map { measurable ->
            measurable.measure(constraints)
        }
        
        // Set the size
        layout(constraints.maxWidth, constraints.maxHeight) {
            placeables.forEach { placeable ->
                // Position each item
                placeable.placeRelative(x = 0, y = calculated)
            }
        }
    }
}

Constraint Layout

尽管在 Compose 框架中,布局嵌套的深度对性能的影响并不是太大,但是我们依旧可以在一些布局复杂的场景中使用 Constraint Layout 来简化布局的实现。

@Composable
fun TestConstraintLayout() {
    ConstraintLayout(modifier = Modifier.fillMaxHeight()) {
        val (button1, button2, button3, button4, text) = createRefs()

        Button(onClick = { /*TODO*/ },
            modifier = Modifier.constrainAs(button1) {
                start.linkTo(parent.start, 16.dp)
                top.linkTo(parent.top, 16.dp)
            }) {
            Text(text = "Button1")
        }

        Button(onClick = { /*TODO*/ },
            modifier = Modifier.constrainAs(button2) {
                top.linkTo(button1.top)
                end.linkTo(parent.end, 16.dp)
            }) {
            Text(text = "Button2")
        }

        Button(onClick = { /*TODO*/ },
            modifier = Modifier.constrainAs(button3) {
                start.linkTo(button1.start)
                bottom.linkTo(parent.bottom, 16.dp)
            }) {
            Text(text = "Button3")
        }

        Button(onClick = { /*TODO*/ },
            modifier = Modifier.constrainAs(button4) {
                top.linkTo(button3.top)
                end.linkTo(button2.end)
            }) {
            Text(text = "Button4")
        }

        Text(text = "Center", modifier = Modifier.constrainAs(text) {
            top.linkTo(parent.top)
            bottom.linkTo(parent.bottom)
            start.linkTo(parent.start)
            end.linkTo(parent.end)
        })

        Button(onClick = { /*TODO*/ },
            modifier = Modifier.constrainAs(createRef()) {
                centerHorizontallyTo(parent)
                top.linkTo(text.bottom, 24.dp)
            }) {
            Text(text = "Button5")
        }

        val barrier = createEndBarrier(button1, text)
        Text(
            text = "To Be or Not To Be, that's a question",
            modifier = Modifier.constrainAs(createRef()) {
                top.linkTo(text.top)
                start.linkTo(barrier, 8.dp)
                end.linkTo(parent.end)

                // wrap content according to the constraints
                width = Dimension.preferredWrapContent
            })
    }
}
constraint_layout_example.png

Compose 中的 Constraint Layout 的用法和 View 系统中的 ConstraintLayout 的用法差不多,主要包括以下几个部分:

  • 使用 createRef() 或者 createRefs() 来创建引用,其中 parent 是默认会被创建的引用
  • 使用 Modifier.constrainAs(referenceName) 来创建约束内容
  • 使用 linkTocenterHorizontallyTo 等方法约束布局,还可以使用 width 等自定义 Dimension
  • 使用 createXxxBarriercreateGuidelineFromXxxcreateHorizontalChain 等方法创建帮助约束

除此之外,我们还可以使用动态约束,比如下面这个例子:

@Composable
fun DecoupledConstraintLayout() {
    // 根据不同的屏幕方向使用不同的 Constraint sets
    BoxWithConstraints {
        val constraints = if (maxWidth < maxHeight) {
            decoupledConstraints(margin = 16.dp) // Portrait constraints
        } else {
            decoupledConstraints(margin = 32.dp) // Landscape constraints
        }

        ConstraintLayout(constraints) {
            Button(
                onClick = { /* Do something */ },
                modifier = Modifier.layoutId("button")
            ) {
                Text("Button")
            }

            Text("Text", Modifier.layoutId("text"))
        }
    }
}

/**
 * Create your own constraint sets
 * */
private fun decoupledConstraints(margin: Dp): ConstraintSet {
    return ConstraintSet {
        val button = createRefFor("button")
        val text = createRefFor("text")

        constrain(button) {
            top.linkTo(parent.top, margin = margin)
        }
        constrain(text) {
            top.linkTo(button.bottom, margin)
        }
    }
}

Intrinsic

之前说过自定义布局的时候,Compose 只允许我们测量一次,否则会报错,但是,当我们需要在 measure 之前就知道布局的宽高信息的时候,该怎么办呢?这个时候就需要用到 Intrinsic 信息了,包括:

  • (min|max)IntrinsicWidth:在当前高度下,最小或最大的可用布局宽度
  • (min|max)IntrinsicHeight:在当前宽度下,最小或最大的可用布局高度

使用例子:

@Composable
fun TwoText(modifier: Modifier = Modifier, text1: String, text2: String) {
    Row(modifier = modifier.height(IntrinsicSize.Min)) {
        Text(text = text1,
            modifier = Modifier.weight(1f).wrapContentWidth(Alignment.CenterHorizontally))

        Divider(color = Color.Black,
            modifier = Modifier
            .fillMaxHeight()
            .width(1.dp))

        Text(text = text2,
            modifier = Modifier.weight(1f).wrapContentWidth(Alignment.CenterHorizontally))
    }    
}

此时,Row 的高度是由子元素中高度最高的元素决定的,而 Divider 因为使用了 fillMaxHeight() 所以其高度会填充此最小高度。

State in Compose

什么是 State

State in an application is any value that can change over time.

For example it may be a value stored in a Room database, a variable on a class, or even the current value read from an accelerometer.

官方的解释是,应用中任何会随时间发生变化的值都可以被称作 State,比如网络状态,应用数据,UI 动画等等。

创建 State

我们可以通过使用 mutableStateOf() 给 composable 函数添加内部状态,当状态改变的时候,依赖该状态的 composable 函数会自动再次执行。创建 State 主要有以下几种方法:

  • val state = remember { mutableStateOf(default) },直接取得 MutableState
  • var value by remember { mutableStateOf(default) },通过关键字 by,以委托的方式初始化,需要导入 androidx.compose.runtime.getValueandroidx.compose.runtime.setValue
  • val (value, setValue) = remember { mutableStateOf(default) },通过解构 MutableState 获得 setter 和 getter 函数

记住 State

注意到上面创建 State 的方法中,全都使用了 remember() 函数,该函数用于记住该状态,如果不记住状态,那么该状态每次都会在 recomposation 的过程中被重新初始化。

val color = remember(key1 = todo.id, calculation = { randomTint() })

remember() 函数接受 key 和 calculation 函数,如果不传 key 则只有在 composition 才调用 calculation,否则会先比较 key 是否发生变化,然后再决定是否调用。

在使用 remember() 之前,需要问自己的一件事是:该状态是否有可能需要暴露给外界?如果是,那么就把它定义为 composable 函数的参数;否则,才使用局部变量。

另外,还需要注意到,当 Compose 组件被移除的时候,其「记住」的状态也会被移除,这在 LazyColumn 等组件中尤其需要注意,当列表长度很长并在可见区域之外时,组件可能会被移除掉并在回到原位置后重新渲染组件,因此记住的状态也会丢失。所以 remember() 只适用于记住一些暂时的状态。

单向数据流

Unidirectional data flow,即事件向上流动(事件输入),状态向下流动(更新状态)。比如 ViewModel 中通过方法调用传送事件,最后通过 LiveData 将状态更新通知给 UI。

unidirectional_data_flow

Compose 中所有的内置组件都被设计成是单向数据流的,也即都是 Stateless 的。

结合 ViewModel 的使用

为了实现单向数据流和达到解耦的目的,我们可以将事件处理放到 ViewModel 中,再通过 LiveData 实现对状态更新的监听:

class CounterViewModel : ViewModel() {
    private val _count = MutableLiveData(0)
    val count: LiveData<Int> = _count

    fun updateCount(count: Int) {
        _count.value = count
    }
}

class CounterActivity : AppCompatActivity() {
    val viewModel by viewModels<CounterViewModel>()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            Surface {
                Counter(count = viewModel.count.observeAsState().value!!,
                    updateCount = { viewModel.updateCount(it) })
            }
        }
    }
}

可以看到,这里关键的一步是对 count 值的监听,使用 observeAsState() 将 ViewModel 中的 LiveData 转换为可触发 recomposition 的 State。

状态提升

有时候,composable 函数可能需要将状态暴露给调用方,比如调用方需要通过获取状态进行一些操作或者为了方便测试,这种做法称为状态提升(state hoisting)。状态提升可以避免重复状态的出现以及引入 bug,因为如果在一个 composable 函数中使用了过多状态,通常会增加代码复杂度从而使得代码难以维护和容易产生 bug。

我们可以通过在 composable 函数中添加状态值(Value)和状态改变器(onValueChange: (T) -> Unit)来实现状态提升:

@Composable
fun Counter(count: Int, updateCount: (Int) -> Unit) {
    Button(onClick = { updateCount(count + 1) }) {
        Text("I've been clicked $count times")
    }
}

使用状态提升的好处是只有创建该 composable 函数的地方,才能修改其状态。我们可以通过状态提升将一个 Stateful 组件转换成一个 Stateless 组件,而 Stateless 组件的优势是可以更方便地组合重用。

另外,在使用状态提升时,第一步需要考虑组件的状态树,尤其是在组件是由多个子组件嵌套形成的时候,需要考虑该组件的使用场景,以及应该将该状态提升到哪一级。

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