前言
对于 Compose 这类声明式 UI ,可能接触过前端的客户端同学一看官方文档就会有种熟悉感,因为现代的声明式 UI 已经成为了近些年来前端的主流了,然而对于Android 客户端传统的 UI 开发思路却是颠覆的。本文中希望能通过一个比较实际的例子——定制化页面来抛砖引玉。为不同维度的用户展示定制化页面是APP中很常见的一个需求。在学习实践了 Compose 几个月后,我发现 Compose 这类的声明式 UI 库对于这种数据变化大需求相较于传统 view 有天然的优势,因为声明式 UI 的页面展示就是随着数据变化而变化的。“根据信息(数据)去展示页面”是如 Compose 之类的声明式 UI 开箱即用的功能,或者说是一种范式。
本文对与定制化页面的实现思路是:后端向客户端传递页面信息的数据,客户端解析数据并从本地组件库找到对应组件进行组合并展示。效果图如下:
接下来一章中,我会通过一些例子简单地介绍一下本文中需要用到的相关概念,例如 @Compoable 函数、无状态组件和单向数据流。之后的一章再详细地介绍如何使用 Compose 实现灵活的定制化页面的功能的。
简单认识一下Compose
为了之后的功能实现更好理解,本节中,我会通过的俩个示例来介绍 Compose 的部分特性,想看实现细节的可以直接看下一章。
@Compoable 函数
先下定义, @Compoable 函数并不是普通的kotlin 函数,这类函数会在编译期被编译成正常函数。这种函数更像是 xml 之类的构建页面的 DSL( 领域特定语言 ),只不过长得像 Kotlin 函数。
为什么不说是注解?因为注解严格来说只会增加原有代码基础上增加代码,而不是把代码改得“面目全非”。
所以 @Compoable 函数可以被理解为组件的描述,就像 xml 一样。【组件的描述】 这一概念很关键。可以看下下面的代码,我现在需要将字符串 hello 在屏幕上展示出来,代码会这样写:
// Text.kt
@Composable
fun Hello(str:String){
Text(text = str)
}
@Preview
@Composable
fun Test(){
val str = "hello!"
Hello(str = str)
}
// MainActivity.kt
override fun onCreate(savedInstanceState: Bundle?) {
setContent {
Hello(str = "hello!")
}
}
在 MainActivity 中我们使用了setContent 函数,这个是 Compose 提供的用于展示 Compose UI 的入口,就像是填充一个 xml 一样,参数需要的是@Composable 函数。这里我们给的是一个简单 @Composable 函数 Hello,它内部是一个 Compose 库提供的展示字符串的 @Composable 的函数 Text。此时,我们点击编译,“hello!”就展示在屏幕上了。可以看到还有个函数 Test,它还标注了@Preview,这也是 Compose 库提供的,它可以为我们提供组件效果的预览,如下图。
目前看起来 @Composable 函数就像是一个 XML 元素,我们给什么参数,它就展示什么。不过它是一个函数,会有更多有用的功能,例如【重组】。上面的例子太过于简单,根本无法表现重组的能力,接下来看另一个例子。
下面我们将实现下面这种多彩的效果。
代码如下:
@Composable
fun ColorfulPoster(data: List<ColorfulPosterBean>, onClick: () -> Unit) {
Column {
for (it in data)
PosterItem(data = it) { click() }
}
}
@Composable
fun PosterItem(data: ColorfulPosterBean, click: () -> Unit) {
Row(
modifier = Modifier.height(40.dp)
.background(color = data.toColor(data.bgColor))
.clickable { click() },
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = data.content,
color = data.toColor(data.contentColor),
)
}
}
先介绍一下代码, Column 和 Row 这些是 Compose 组件库提供的布局容器,从名字可以看出前者是纵向布局,后者是横向布局,每个布局都有些独特的属性方便使用。像 for if when 也是可以使用的,这一点很大程度上增加了编程的效率。modifier 是每个组件都会有的属性。 该属性包含了所有组件的所有基础属性,常见的如宽高。值得注意的是,上面代码中,每一个的背景颜色也是跟参数对象中的值相关的!!!数据可以直接成为属性,或者说是状态,所以数据源的三条数据对应着图中的三行文字状态。
这时有个小问题,当在运行时,我给以上组件传入一个修改了任意一条数据的新数据源(列表)后,那被刷新状态的文字是一行还是三行?
就以上代码来看,你肯定认为整个数据源相关的组件全都会刷新一遍,然而答案是仅会刷新一行的状态,原因是上面提及的【重组】。
Compose 的【重组】是乐观的。 这是官方的定义。我白话一下就是,Compose 维护着一个组件的数据-状态树,只有节点的数据改变了,才会去刷新一个节点的状态。
无状态组件
在 Compose 中无状态组件是最基础的概念之一,也是 Compose 对于每个组件的规范,也正是有这个规范才使得我们的代码更易维护。在你理解这个概念之前,你去学习 Compose 时可能会觉得有些 demo 过度设计了。
状态一词在声明式 UI 中指代 ui 组件的在某一时间的状态,状态可以包括组件的属性和数据,以及对于内部组件的状态管理等等。
无状态组件并非指的是组件“无状态”,所有无状态组件内部都会有些硬编码的属性和数据,以及状态管理的。无状态组件指的 ui 组件不持有任何一个外部对象,类似于纯函数。声明式的基础是函数式。无状态组件能极大地提高组件的可复用性和可测试性,对于前期的开发和后期的维护有着巨大的提升,这也是声明式 UI 流行的主要原因之一。那么我们该如何写一个无状态组件呢?
如上图,当我们将 HelloContent 组件中需要的状态交给函数的入参,例如内容颜色或内容数据等,这样 HelloContent 的调用方就能指定 HelloContent 的状态了。HelloContent 中若是有用户触发的事件,也可以通过回调的方式,传给调用方。这样大概就是一个无状态组件的结构了。这种组件的状态和事件都交给调用方的方式被官方称之为提升状态。 以下是官方对于提升状态的建议:
提升状态时,有三条规则可帮助您弄清楚状态应去向何处:
- 状态应至少提升到使用该状态(读取)的所有可组合项的最低共同父项。
- 状态应至少提升到它可以发生变化(写入)的最高级别。
- 如果两种状态发生变化以响应相同的事件,它们应一起提升。
以上是对无状态组件简短的介绍,在 Compose 中无状态组件类似于一个个纯函数,不会持有任何外部对象,这样的组件在性能上会有些许代价,但换来了更好的测试性和复用性。而且当你习惯了这种开发范式之后,构建 UI 也将会更快,因为 ui 控制方面会少很多代码。接下来,我将介绍基于无状态组件的一种具体实践——单向数据流。
单向数据流
Compose 是 Android 版的现代 UI 开发库。其他的现代 UI 开发库(如 React)已经验证出一系列优秀的开发实践规则,这其中最受推崇的可能就是单向数据流模型了。官方也建议使用这一模型,这一模型可以分为俩部分:状态下沉(下图1),事件上升(下图2)。
“状态下沉”可以用下面伪代码表示:
// MainActivity.kt
override fun onCreate(savedInstanceState: Bundle?) {
setContent {
Screen(data = vm.data)
}
}
@Composable
fun Screen(data: List<ColorfulPosterBean>){
NewsFeed(data)
}
@Composable
fun NewsFeed(data: List<ColorfulPosterBean>){
StoryWidget(data)
}
@Composable
fun StoryWidget(data: List<ColorfulPosterBean>){
ColorfulPoster(data)
}
如代码所示,数据会层层向下传递到需要用到数据的组件上。在上面的示例已经介绍了数据的改变会触发相应组件状态的改变,此时我们仅需要改变 vm.data 的值,绑定数据的组件状态也会发生改变。让我们再来看一下“事件上升”。
// MainActivity.kt
override fun onCreate(savedInstanceState: Bundle?) {
setContent {
Screen(data = vm.data){
toast("from ColorfulPoster")
}
}
}
@Composable
fun Screen(data: List<ColorfulPosterBean>, click: () -> Unit){
NewsFeed(data,click)
}
@Composable
fun NewsFeed(data: List<ColorfulPosterBean>, click: () -> Unit){
StoryWidget(data,click)
}
@Composable
fun StoryWidget(data: List<ColorfulPosterBean>, click: () -> Unit){
ColorfulPoster(data,click)
}
从“事件上升”的代码中可以看到,ColorfulPoster 的点击事件可以一层一层回调给 Activity,再后面的事件处理就看具体情况了 。
看了以上简单的单向数据流范式的示例代码后,相信聪明的你应该能看出来该范式的意图了吧。它跟所有的设计架构(mvc、mvp)的意图一样,要把所有与 ui 不相关的数据和事件都从 UI 系统中抽离出来,既 Screen 中不应该有任何 Screen 外部的逻辑操作。每个组件都是一个独立的单元,这样的范式能极大程度地降低我们前期单元测试的难度,以及有个很清晰的代码结构便于维护。
技术细节
我的方案是在客户端内置足够多的满足业务的组件库,解析数据找到组件在页面中组合,并传入状态。
经过上面对一些概念的铺垫,使用 Compose 之后,可以说后端传给客户端的就是页面的状态了。除了部分标识信息,其他的数据都是页面的状态。数据结构如下:
{
...「1」,
"uiConfigs": [
{
"name": "搜索框",
"type": 5,
"style": 0,
"content": {
...「3」
}
},
...「2」
]
}
以上的数据结构是简写的JSON,这里大致分为三层数据结构:
最外层是应当是数据的标识,上面 Json 数据中「1」处省略部分为页面数据的标识:时间、版本、页面名称等。
我们重点关注的字段应该是 uiConfigs 这个集合。整条数据大部分是用来描述页面的状态的,通过上一节,你应该知道什么是组件的状态,此处的页面的状态也就是一个页面所有组件的状态集合。(「2」处省略的是其他的组件信息)为了让我们能更方便和更快地找到组件,每个组件都会包含 name、type、style、content 这些通用参数,我们需要通过 type 和 style 去找到待定的组件。「3」处省略的是组件的详细信息,这里的数据也就是组件的状态了。
可以看出,以上的数据结构是一个很自然的页面描述结构,用传统 view 方式开发这种灵活性很高的功能很困难,代码也会很庞杂,然而 Compose 对于数据的契合度似乎是与生俱来的,变化后的数据即来即用。
从一个组件开始
由浅入深,我们可以从一个组件开始构建整个功能。以一个新闻组件为例:
我想要给它实现像下面这段 Json 数据描述的样子。
"data": {
"lPadding": 16,
"tPadding": 4,
"rPadding": 16,
"bPadding": 8,
"title": "如果在太阳系开一次冬奥会,各大星球会如何参加?",
"imgUrl": "https://pica.zhimg.com/v2-d29a886573707c99bfb051dd6c6ce0ac.jpg?source=8673f162",
"author": "中国航天科技集团",
"newUrl": "http://daily.zhihu.com/story/9744520",
"newsId": 10,
"isSubscribed": false
}
简单地将属性定义为数据类:
data class NewsInfo(
val title: String = "",
val author: String = "",
val imgUrl: String = "",
val newsId: String = "",
val isSubscribed: Boolean = false,
val lPadding: Int = 0,
val tPadding: Int = 0,
val rPadding: Int = 0,
val bPadding: Int = 0,
)
在整个状态传入下面这个 @Composable 函数的时候,Compose 就会为我们渲染出来画面了。
NewsComposableS0.kt(只保留必要代码)
@Composable
fun NewsComposableS0(data: NewsInfo) {
val painter = rememberImagePainter(
data = data.imgUrl,
builder = {}
)
Row(
modifier = Modifier
.padding(
start = data.lPadding.dp,
end = data.rPadding.dp,
top = data.tPadding.dp,
bottom = data.bPadding.dp
)
) {
Column {
Text( text = data.title )
Row {
Text(
text = data.author,
)
Text(
text = if (data.isSubscribed) "已订阅" else "订阅",
)
}
}
Image(
painter = painter
)
}
}
当 NewsComposableS0 传入新的 data 后, NewsComposableS0 的状态也会更新的。
不知道你注意到了,构建组件的一开始是先将想要的组件状态构建成数据,再去实现组件结构。这在传统view 开发中可能很奇怪,而在这里你会感觉到非常合理,就是声明式 UI,数据是怎么样,状态就应该是怎么样。当然,以上的组件很简单,我们在设计时需要根据业务预留更多的状态属性。
上文提到了,组件有俩大属性:状态和行为。介绍完了如何实现灵活的状态,那行为该如何变得灵活呢?
这个我们可以参考指令集的概念。我们也可以像组件一样,在客户端预备大量的行为,组件的行为标识和内容跟着数据一起发送到客户端,让客户端匹配并执行。就像计算机系统中的指令一样,客户端预备一个指令集。
NewInfo.kt
data class NewsInfo(
... // 上文重复的内容
val actions: List<ComposableAction> = emptyList()
)
data class ComposableAction(
val action: String = "",
val param: Map<String, Any> = emptyMap(),
)
@Composable
fun NewsComposableS0(data: NewsInfo, emitEvent: (ComposableAction) -> Unit = {}) {
...
Text(
text = if (data.isSubscribed) "已订阅" else "订阅",
modifier = Modifier.clickable {
data.actions[0]?.let{
emitEvent(it)
}
}
)
}
}
这样数据就可以像声明组件外观和内容一样的方式,来声明事件了。组件的事件触发后上升到一个通用的地方,例如产生组件的容器 Activity 或者 Fragment ,然后匹配事件集里的相应事件并执行。
MainActivity.kt
fun invokeFunc(event: ComposableAction) {
logI("invokeFunc event >>>>> $event")
when (event.action) {
Action.NAV_TO -> // 跳转页面事件
logI("nav to ${event.param["destination"] as String}")
else -> {
logI("并没有发生任何事情~")
}
}
}
到这样,一个组件就做好了。接下来简单介绍一下一条页面数据到页面展示的具体实现。
展示页面
后端或者是本地缓存的数据中,页面相关标识数据并不是重点,所以在这里就忽略了。我们来关注一下组件集合 uiConfigs 。
"uiConfigs": [
{
"name": "搜索框",
"type": 5,
"style": 0,
"content": {
...
}
},
...
]
页面效果大致如下,为了更清晰,我标记出了每个组件的边界。
我的示例是比较简单的,组件在页面中的布局是纵向线性的,所以使用的是 LazyColumn 。LazyColumn 可以更高性能地展示我们组件列表,代码大致如下:
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(items = uiConfigs) {
createComposable(it).invoke(this)
}
}
items 是 LazyColumn 提供的一个内置函数,可以接受一个列表,以及展示每个列表元素的 @Composable lambda 函数。而我们根据元素配合到对应的组件,就在这个函数中实现,既 createComposable(it).invoke(this) 。
data class ComposableBean(
val name: String,
val type: Int,
val style: Int,
val content: ComposableInfo
)
/* 组件信息密封类 */
sealed class ComposableInfo
fun createComposable(data: ComposableBean): (@Composable LazyItemScope.() -> Unit) =
{
data.content.let { info ->
when (info) {
EmptyInfo -> PlaceholderComposable {}
is SearchInfoS0 -> SearchComposableS0(info)
...
}
}
}
通过每个组件信息中的 type 和 style 找到 content 对应的数据类,并将 content 内容反序列化到对应的对象中。(这部分的反序列化将在后面独立介绍)
这样再用密封类的特性,编译器会帮你列出所有的子类,进行类匹配以及强转,代码变得清晰了不少。当然用 SparseArray 去做匹配也可,代码会变得抽象一点。
最后,将找到的@Compoable 函数交给 LazyColumn 就可以了。
Moshi 自定义解析Adapter
上节中有个解析不同组件数据的技术细节需要展开说说。我这里用的反序列化工具是 Moshi ,以 Moshi 为例,解析有着不同结构的 Json 需要自定义 JsonAdapter。Moshi 反序列化 Json 的方式主要是用对应数据类的 JsonAdapter 来实现的,而 Moshi 的 @JsonClass(generateAdapter = true) 注解会为我们生成对应的 JsonAdapter。你编译之后,将数据类后加上 JsonAdapter 应该就能搜到。一个 JsonAdapter 的结构大致如下。
class ComposableTempAdapter : JsonAdapter<ComposableBean>() {
@FromJson
override fun fromJson(reader: JsonReader): ComposableBean {}
@ToJson
override fun toJson(writer: JsonWriter, value: ComposableBean) {}
}
然后在运行时Moshi 就会通过反射获取到对应数据类的 Adapter 进行序列化或者反序列化。这里需要用的是反序列化函数 fromJson。这个函数会提供一个 Json 的读取流,在本文场景中,就是后端返回的JSON 数据中的 uiConfig 的每一个元素。具体实现如下:
private val names = JsonReader.Options.of("name", "type", "style", "content")
private val moshi: Moshi = Moshi.Builder().build()
@FromJson
override fun fromJson(reader: JsonReader): ComposableBean {
var type: Int = -1
var style: Int = -1
var name: String = ""
var content: ComposableInfo = EmptyInfo
reader.beginObject()
val peek = reader.peekJson()
// [1] 获取组件数据的 type 和 style
while (peek.hasNext()) {
when (peek.selectName(names)) {
0 -> name = peek.nextString()
1 -> type = peek.nextInt()
2 -> style = peek.nextInt()
else -> {
peek.skipValue()
}
}
}
// [2] 根据 type+style 找组件对应的 JsonAdapter,进行反序列化
val data = if (map.containsKey(keyOf(type, style))) {
logI("map has [$type,$style]")
map[keyOf(type, style)].fromJson(reader) ?: EmptyInfo
} else {
EmptyInfo
}
while (reader.hasNext()) {
when (reader.selectName(names)) {
3 -> content = data
else -> reader.skipValue()
}
}
reader.endObject()
return ComposableBean(name, type, style, content)
}
正常来说,fromJson 中只需要对 json 流读一次,就可以完成反序列化。但对于数组中的对象是多种类型的情况,需要对流读俩次。代码中的标记1是用来找到数据中的组件标识,标记2是用来找到对应的 JsonAdapter 的。这样在返回的 ComposableBean 对象中的 content 就有了组件需要的数据了,再强转一下就可以了。
总结
至此,整个方案的简单实现已经介绍完成了,从页面数据的格式,到数据解析,再到匹配到组件展示在页面上,这全流程在 Compose 的帮助下,变得简单清晰了起来。其可行性的理论基础源自于声明式编程,对于相关信息感兴趣的具体可以看看《从实现原理看低代码》。
实际上,对于【定制化页面】这个功能需求而言,客户端仅仅只做了一半的事。对于需要定制化页面的产品,用户维度少的情况可以请前端和后端同学为产品同学开发一个操作页面,用来配置多个维度用户的页面,最终导出能驱动客户端页面状态的数据就好;用户维度庞大的定制化页面的产品,就应该考虑接入公司内部更专业的数据生产渠道了,或者其他的方案。至少在客户端这里,使用现代的声明式 UI 是个很有优势的方案。
你在使用该方案之前可能会考虑到:需要声明的组件过多而导致本地组件库膨胀怎么办?定制化页面一般运用在首页的各个tab中,在这种场景下的组件类型一般不会膨胀的一个不可控的程度。
最后我希望能通过这个简单的例子,为 Compose 抛砖引玉,有什么说得不好的地方,望斧正。
[代码仓库:gitee.com/stanza/conf…]
引用: