Better UI building with Compose
Author:Leland Richardson
现如今,大家对于UI开发的期望越来越高,如果没有包含一些动画之类等交互优美的用户界面,很难做到满足用户需求了。但是在当前这套UI工具集创建的时候,用户对于UI的需求比较低。为了迅速高效的解决开发优美UI的技术难题,我们引入了Jetpack Compose——这是一个新的UI工具集,可以让APP开发者在这片新的土地上取的成功。
我们将通过两篇文章介绍引入Compose的好处同时关注Compose到底是怎么工作的。这篇文章一开始,我们将讨论Compose解决了哪些难题,我们这样设计背后是什么原因,以及这些设计能给开发者提供什么样的帮助。另外我们还会讨论Compose的思维模型,你应该怎么开始着手Compose的编码,以及如何设计你的API。
Compose 解决了哪些难题
关注点分离是众所周知的软件设计原则。它是我们作为一名APP开发者需要学习的基础知识。尽管被大家熟知,但却在实际开发过程中很难去掌握到底有没有做到这个原则。如果从“耦合”和“内聚”的角度来理解这个原则的话可能要稍微简单一点。
我们在编写代码的时候,会创建很多的模块,每个模块包多个单元。耦合指的是不同模块之间各个单元之间的依赖关系,反应了一个模块的某些部分可能会影响其他模块的某些部分。与之不同,内聚是一个模块内各单元之间的联系,并指示模块中个单元的分组程度。
在编写可维护的软件的时候,我们的目标是高内聚,低耦合。
如果各个模块耦合的很严重,那样会导致为了修改一处同时得修改其他模块的好几个地方。更糟糕的是,耦合经常是隐式的,因为一个看似完全无关的修改会导致无法预料的事情发生。
关注点分离是把相关联的代码尽可能的组织到一起,这样代码才更容易和维护和随着APP的增长也更容易扩展。
让我们从当前Android开发实际来看这个问题,就拿ViewModel和XML布局来进行举例说明。
ViewModel给布局提供数据,你会发现这里有跟多隐藏的依赖问题:ViewModel和layout之间存在很多的耦合。可以看到这个清单较为熟悉的方式是通过API,而这些API需要你对布局的形状和内容有一定的了解,比如findViewByID。
使用这些API需要了解XML是怎么定义的,与此同时也在ViewModel和XMl之间产生了耦合。当我们的APP随着时间的增长,我们得确保这些依赖没有过时。
现在很多APP都是动态的展示UI,在运行的过程中不断变化。这样一来,不仅需要确认这些依赖是否能满足XML,同时也要满足应用程序的生命周期。如果运行时视图层的一个元素缺失了,它对应的依赖就可能出问题进而导致想空指针的异常。
通常ViewModel是用当前的编程语言比如Kotlin,布局是用XML。因为语言的不同,这样就有一个强制的分隔线,尽管有时候ViewModel和布局的XML之间有着密切的联系。换句话说它们紧密的耦合。
这就引入一个问题:如果我们将布局(UI的结构)用相同的语言来编写会是什么情况呢?如果这个语言就用Kotlin呢?
因为我们将会用相同的编程语言,这样之前的一些隐式的依赖问题就变成显示的了。我们还可以重构这部分代码,迁移一些代码减少其耦合提高内聚性。
走到这一步,你可能会觉得实在推荐把逻辑的部分写到UI里面。其实现实就是这样的,不管你怎么组织代码,你的应用UI相关的逻辑代码总是会有。这是框架本身无法改变的。
但是框架能做的是给你提供工具让你可以更简单的分离他们:这个工具就是Composable函数。函数可能是你为了做到关注点分离已经在项目里面用了很久的一个东西。那些你需要去做重构,编写可靠的、可维护的,简洁代码需要的技能,同样适用于Composable函数。
解析Composable函数
下面是一个Composable函数的例子。
@Composable
fun App(appData: AppData) {
val derivedData = compute(appData)
Header()
if (appData.isOwner) {
EditButton()
}
Body {
for (item in derivedData.items) {
Item(item)
}
}
}
这个例子里面,函数通过AppData类作为参数来获取数据。理想的情况下,这个数据是不可变的,Composable函数也不去改变它。Composable函数应该像一个数据的转换函数。因此,我们可以通过Kotlin的代码来拿这个数据同时用它来描述视图的层级结构,比如这里的Header()和Body()的调用。
这就一意味我们可以调用其他Composable函数,并且这些调用代表着UI的层级结构。我们可以用Kotlin这门语言所有的源语去动态的做一些事情。还可以把if和for循环控制流来处理更复杂的UI逻辑。
Composable函数经常利用Kotlin的尾闭包的语法,因此Body()就是一个有Composable lambda作为参数的这样一个Composable函数。它表示视图的层次结构或者结构,因此在这里Body()包含了很多元素。
声明式UI
声明式是一个流行词,当然也很重要。当我们谈论声明式编程的时候,都是相对命令式编程来说的。下面让我们看一个具体的例子。
假设有一个邮箱APP有一个消息图标。如果没有消息,APP显示一个空的信封。如果有几条消息,信封里面会显示几张纸,如果有100条信息信封上会显示一个小火把。
在命令式接口的情况下,我们可能会像下面这个函数一样更新数量:
fun updateCount(count: Int) {
if (count > 0 && !hasBadge()) {
addBadge()
} else if (count == 0 && hasBadge()) {
removeBadge()
}
if (count > 99 && !hasFire()) {
addFire()
setBadgeText("99+")
} else if (count <= 99 && hasFire()) {
removeFire()
}
if (count > 0 && !hasPaper()) {
addPaper()
} else if (count == 0 && hasPaper()) {
removePaper()
}
if (count <= 99) {
setBadgeText("$count")
}
}
在这个代码里面,收到一个新的数量必须理清楚到底要怎么更新Ui去反应那个状态。这样会有跟多边界点需要考虑,这个逻辑并不简单,尽管这是一个相对比较简单的例子。
或者,我们用声明式接口来编写这块逻辑,将会是下面这样:
@Composable
fun BadgedEnvelope(count: Int) {
Envelope(fire=count > 99, paper=count > 0) {
if (count > 0) {
Badge(text="$count")
}
}
}
稍微解释一下:
- 如果数量超过99,显示火把
- 如果数量大于0,显示纸
- 如果数量大于0,显示一个数量的徽章
这就是声明式API的含义。我们写的代码直接描述了我们想要的UI,而不是描述具体怎么转变到对应的状态。这里关键的是,像这样编写响应式的代码,你不再需要关系UI之前是什么状态,你只需要明确当前是什么状态就行。框架会控制一个状态到另一个状态的转变,因此我们不再需要去考虑了。
组合 vs 继承
在软件开发的过程中,组合是多个简单的单元组合到一起去构建一个更复杂的单元。在面向对象的编程模型下,最常用的组合形式是类继承。在Jetpack Compose的世界,因为我们用的是函数而不是类,组合的方式有很大的不同。但是相对于继承有很多优点,让我们一起看衣蛾例子。
假设我们有一个View,我们想要添加一个输入。在继承模型下我们的代码可能会是下面这样:
class Input : View() { /* ... */ }
class ValidatedInput : Input() { /* ... */ }
class DateInput : ValidatedInput() { /* ... */ }
class DateRangeInput : ??? { /* ... */ }
View 是这里的基类。ValidatedInput 是Input的一个子类。为了校验一个日期的有效性,DateInput是ValidatedInput的子类。但是这里有一个问题:如果我们想要创建一个范围的输入,也就是校验两个日期——一个开始一个结束。你可能会继承DateInput,但是需要继承两次不然没办法做到。这也就是继承受限的地方,一个类只能有一个父类。
而在组合模型下,这就不是问题了。假设开始就有一个基础的Input Composable:
@Composable
fun <T> Input(value: T, onChange: (T) -> Unit) {
/* ... */
}
当我们创建ValidatedInput的时候,只需要在函数体里面调用Input,然后就可以实现校验的功能了。
@Composable
fun ValidatedInput(value: T, onChange: (T) -> Unit, isValid: Boolean) {
InputDecoration(color=if(isValid) blue else red) {
Input(value, onChange)
}
}
对于DataInput直接调用ValidatedInput。
@Composable
fun DateInput(value: DateTime, onChange: (DateTime) -> Unit) {
ValidatedInput(
value,
onChange = { ... onChange(...) },
isValid = isValidDate(value)
)
}
当运行到时间范围输入的时候不再是问题,一次调用改为两次就可以了。
@Composable
fun DateRangeInput(value: DateRange, onChange: (DateRange) -> Unit) {
DateInput(value=value.start, ...)
DateInput(value=value.end, ...)
}
在Compose的组合模型里面不再只能组合一个父类,这就解决了在继承模型下的问题。
另外一个组合类型的问题是多个类型的抽象,为了说明这个问题,我们看下下面这个继承模型的l例子。
class FancyBox : View() { /* ... */ }
class Story : View() { /* ... */ }
class EditForm : FormView() { /* ... */ }
class FancyStory : ??? { /* ... */ }
class FancyEditForm : ??? { /* ... */ }
FancyBox
是一个装饰其他View的View,这个案例里面装饰的是Story
和EditForm
。我们想要编写一个FancyStory
和FancyEditForm
,但是现在我们是继承FancyBox
还是继承Story
呢?我们又陷入两难,因为继承链条上只能有一个父类。
相反,Compose能很好的解决这个问题。
@Composable
fun FancyBox(children: @Composable () -> Unit) {
Box(fancy) { children() }
}
@Composable fun Story(…) { /* ... */ }
@Composable fun EditForm(...) { /* ... */ }
@Composable fun FancyStory(...) {
FancyBox { Story(…) }
}
@Composable fun FancyEditForm(...) {
FancyBox { EditForm(...) }
}
通过一个Composable的lambda的子类,这样我们能够定义一个嵌套其他类的类。 当我们想要创建一个FancyStory
,我们在FancyBox
的子类里面调用Story
就好了。同样的FancyEditForm
也可以这么干。这就是Compose的组合模型。
封装
另一个Compose做的比较好的是封装。当你要提供公共API的是比需要考虑到这些问题:Composable的公共API就是一个接收参数的集合,而能去操作这些参数。另一方面,Composable可以穿件和管理状态,然后可以记把这些状态和它接收到的数据作为参数传递给其他的Composable。
既然Composable能管理状态,所以如果你想改变状态的话,你可以让你的子Composable将这些变化的信号通过回调的形式传递回来。
重组
我们认为Composable的函数可以在任何时候被重新调用。如果你有一个非常大的Composable的层级结构,当其中一部分Composable层级发生了变化,你并不想整个层级结构都去要重新计算。因此说Composable的函数是可以重启的,你可以用这一特性实现一些强大的功能。
比如这是一个绑定函数,这个在现如今的Android开发里面能看到。
fun bind(liveMsgs: LiveData<MessageData>) {
liveMsgs.observe(this) { msgs ->
updateBody(msgs)
}
}
有这样一个LiveData,我们想用来订阅View。为了达到则价格目的我们通过一个生命周期,传了一个lambda调用了observe函数。lambda每次在livedata更新的时候被触发,同时我们也想去更新view。
使用Compose,我们可以反转这种关系。
@Composable
fun Messages(liveMsgs: LiveData<MessageData>) {
val msgs by liveMsgs.observeAsState()
for (msg in msgs) {
Message(msg)
}
}
有一个相似的接收LiveData的Composable消息,并且能调用Compose的observeAsState
方法。改observeAsState
方法将LiveData<T>
转换成State<T>
。这就意味着你可以在函数体范围内使用这个值。State的实例被LiveData监听,意味着说任何时候LiveData更新State也会触发更新。也就是说无论State的状态在哪被读取,Composable函数周围那些读取该实例的函数都将自动被订阅。最后的结果就是,不再需要指定LifecycleOwner
或者更新回调,因为Compose可以隐式的服务这两个。
最后的想法
Compose提供一种现代的方式去定义UI,让你能够有效的分离关注。因为Composeable函数跟普通kotlin函数非常类似,你编写和重构的工具跟你现在的Android开发技能套件很契合。
在下一篇文章里面,我讲把重点放在Compose和它的编译器的一些实现细节。有关Compose的其他资源,点此处发现更多。