对于Jetpack Compose的一次尝试

本篇文章来自薛定猫的谔的投稿,讲解了Google今年推出的全新技术Jetpack Compose的相关内容,相信会对大家有所帮助!同时也感谢作者贡献的精彩文章。


薛定猫的谔的博客地址:

https://juejin.im/user/5a96922b5188257a865da3f2

Android中的布局文件是借助XML实现的,描述的很直观,也很容易复用,但是XML毕竟只是简单的标记语言,只能用来描述页面结构,而数据和页面元素的关系以及其他复杂的业务逻辑还需要通过其他程序代码主动处理。在Activity中,通过显式编程的方式解析XML文件找到你的控件,然后通过同步或者异步的方式获取控件相关的数据,最后将数据显示到控件上。这是一个很传统、很简单、很有效的流程,但是随着需求的不断变化,越来越多的弊端暴露了出来。

文件数剧增

由于布局文件是作为资源而非源码存在的,所以只能存放在res/layout这个唯一目录下,随着产品的不断迭代,该目录下的文件急剧增加,命名、查找、维护的难度变得越来越大。

关系不直观

一个页面(Activity)中可能包含几个甚至十几个布局文件,没有办法直观精确地查看和定位一个页面涉及到的布局文件,在源码和这些布局文件之间来回切换阅读和修改是异常艰难的,而对于res/layout目录下的布局文件也没有办法直观地了解哪些地方正在使用这些布局文件。

繁琐

在布局文件中我们给每个控件起一个名字(ID),而在程序中我们需要根据这个 ID 查找我们需要的具体控件,如TextView nameTextView = (TextView) rootView.findViewById(R.id.tv_name);页面中的每个控件都需要这样的操作获取,还要重新再起一个符合源码规范的新名字,对于几十甚至几百个控件的页面来说,完成这样的操作是相当繁琐相当痛苦的。

耦合严重

随着应用迭代,XML布局文件和源码都要同步地进行更改,也就是说每加一个控件,XML文件和源码都要相应地进行更改,如果XML中移除了某个控件而源码没有更改就会出现运行时异常,所以它们实际上是强耦合的。

方式不统一

一个页面布局既可以通过XML文件的方式静态指定,也可以通过编写源码的方式动态创建,这两种截然不同的方式虽然都可以实现页面布局,但毕竟是不同的语言,不同的系统,很难统一管理和维护。

架构缺陷

数据驱动视图思想的实现需要视图可以方便的与数据进行单向或者双向的绑定,只有Data Binding技术可以实现XML里插入代码,完成和数据的绑定,但是这样的操作就像JSP在HTML文件中插入Java代码一样,虽然简单直接,但是代码逻辑的连贯性、一致性以及可维护性会面临前所未有的挑战。

那什么样的UI构建方式才能避免上述的问题呢?什么样的构建方式才是简单有效的呢?我相信大多数人的回答都是 “声明式(declarative)”,在源码中声明式的构建UI既直观又不会损失源码的能力。但是这个愿景在实现上却又困难重重,怎么让Java或Kotlin拥有声明式语法的能力,怎么让排版布局更加的简洁直观,怎么避免 UI 逻辑和业务逻辑的耦合等等都是需要重点解决的问题,而我觉得Jetpack Compose是个很不错的尝试。

关注点分离

关注点分离(separation of concerns)是最常见最出名的软件设计原则,也是每个开发者都应该了解并遵循的,其实关注点分离最初是对另外两个词的概括:耦合(coupling)和内聚(cohesion)。理论上,当我们写代码时,我们会把应用看成多个模块,而且还可能把每个模块看成多个单元,这些模块或单元之间的依赖关系就是耦合,也就是说,如果我在某处对一些代码进行了更改,那么我还必须对其他文件进行多少更改?

所以我们一般的想法就是尽可能的减少耦合。有时耦合是隐式的,那些我们依赖的依赖或者其他我们依赖的东西实际上是不确定的,但是还是会因为我们的更改而被破坏。另一方面,内聚指的是模块中的单元如何相互归属,它们彼此相关,高内聚通常被视为一件好事。因此关注点分离就是将尽可能多的相关代码组织在一起,以便我们的代码可以随着时间推移而更好地维护,随着应用的成长而真正地扩展 。

在Android中一般的做法是用XML布局显示东西,用ViewModel给这个布局提供数据,事实上这里隐含了很多依赖,ViewModel和布局之间存在很多耦合,如果XML中新增了控件,ViewModel中也要新增对应的数据,这个关系是隐式的,但又是真实存在的。如果我们用相同的语言如Kotlin构建UI,那么这个关系就可能会变成显式的了,甚至我们接下来开始重构一些代码,将一些东西移到它们所属的地方,实际上减少了某些耦合,增加了一些内聚。

你可能会问了,这不是把业务逻辑和UI混在一起了吗?好吧,我们换个角度看一下,一些业务逻辑难道不是UI的一部分吗?

其实任何框架都不能完美地帮你分离你的关注点,也不能阻止你将逻辑和UI混在一起,但是Jetpack Compose提供了工具可以让你很容易进行分离,这个工具就是组合式函数(composable functions),一个加了@Composable注解的函数,所以你之前写函数时重构,写可靠、可维护性、整洁代码的技巧同样适用于组合式函数。

声明式 vs 命令式

声明式编程(declarative)和命令式编程(imperative)是不同的编程思想,比如有个需求是这样的,未读消息数是0的时候显示一个空信封的图标,有几个消息的时候在信封图标上加个信件图标和消息数badge,消息数超过100时再加个火苗并且badge不再是具体数字而是99+。如果是命令式编程,我们肯定要写一个根据数量进行更新的函数:

 
   

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 BadgeEnvelope(count: Int) {
    Envelope(fire = count > 99, paper = count > 0) {
        if (count > 0) {
            Badge(text = if (count > 99"99+" else "$count")
        }
    }
}

你会发现至少在UI操作上来说声明式编程要更加直观,更加简洁。

而UI开发者最关心的是什么呢?对于给定的数据UI该怎么显示?怎么响应事件让UI进行交互?UI随着时间应该怎样变化?

有了声明式编程,有了Jetpack Compose,我们不再需要考虑UI随时间的变化,这是最重要最关键的点,因为在我们拿到数据后我们就定义了它在各个状态下应该怎么展示,之后框架会控制如何从一个进入另一个状态,即 “根据提供的参数来描述UI”。组合式函数,是个函数定义,但是它在一个地方描述了UI所有可能的状态,而且是本地定义的,这就是组合(composition),因此有了Compose和@Composable这两个名字。

组合 vs 继承


组合(composition)和继承(Inheritance)是面向对象编程中最常见的关联关系,继承是扩展类功能最简单直接的方式,但是多继承弊端太大导致除了C++的大部分语言都是只允许单继承的,如果我们把View系统通过继承实现,那么就会出现类似这样的问题,如果我想要个Input,那么我继承View,如果我想要个ValidatedInput那么我继承Input,如果我想要个DateInput那么我继承ValidatedInput,如果我想要个DateRangeInput怎么办呢?

我不能继承DateInput因为我有两个Date,但我又想拥有DateInput的能力,所以,我们最终还是遇到了单继承的限制。而在Jetpack Compose中这个问题就很简单了,我们无非多组合一个DateInput而已。

封装

Jetpack Compose另一个做得比较好的地方就是封装,一个composable就是 给定参数,一个composable可以 管理状态,这是你开放你的 API 时唯一需要考虑的。另一方面,composable可以管理和创建状态,然后它可以将状态以及接收到的数据作为参数传递给其他composable,子composable也可以通过回调的方式通知你状态的更改。

重组

重组(Recomposition)最基本的就是任何组合式函数都有 随时被再次调用 的能力,这也就意味着,如果你有一个很大的层级结构,当一部分层级改变后,你不需要重建整个层级。你可以利用这个特性做一些大事,比如对于之前这样的操作:

 
   

fun bind(liveMsgs: LiveData<MessageData>) {
    liveMsgs.observe(this) { msgs ->
        updateBody(msgs)
    }
}

我们观察这个LiveData,每次LiveData更新的时候都会调用我们传入的lambda,然后更新UI。但是这毕竟是异步回调的形式,不符合我们的习惯,而在Jetpack Compose中我们就可以把这个关系转换过来:


 
   

@Composable
fun Messages(liveMsgs: LiveData<MessageData>) {
    val msgs = +observe(liveMsgs)
    for (msg in msgs) {
        Message(msg)
    }
}

在这里我们调用了observe()函数,它做了两件事,首先是解封装LiveData来返回它的当前值,这也就意味着你可以在函数体中直接使用这个值。其次,它还隐式地将LiveData订阅到这个它会被解封装的组合式函数作用域中。这也就意味着,我们不再需要传递lambda表达式了,我们只需要知道这个组合式函数每次在LiveData变化时都会重组就行了。让我们再次比较上面两段代码,虽然在代码量上没有什么差异,但是在思想上后者要更加符合我们的思维习惯,更加直观。

数据驱动视图

数据驱动视图的思想既能简化UI操作又能保证数据展示的一致性,而Data Binding对于数据驱动视图的尝试虽然有效,但是并不是十分优雅,一个Model可以插入到XML中,可以进行一些简单的处理,而如果让视图跟随Model变化还需要将Model转化成Observable,这个转化是需要手动完成的。而Jetpack Compose对于数据驱动视图的尝试要更优雅一些,如这里的一个计数器功能:

 
   

@Composable
fun Counter() {
    val count = +state { 0 }
    Button(
        text = "Count: ${count.value}",
        onClick = { count.value += 1 }
    )
}

state函数可以直接返回包裹了给定值的State状态类实例,State类用了@Model注解,而@Model注解就意味着这个类的所有属性的读写操作都是observable的,Jetpack Compose做得就是当你执行你的组合式函数时,如果你读取了一些Model实例,那么Jetpack Compose将自动订阅所在的作用域以便进行Model的读写。因此这个例子中的Counter是独立自给的,每次Model的值发生更改时Counter都会重组。

组合式函数

Jetpack Compose是建立在组合式函数(composable functions)的基础上的,这些函数可以让你以编程的方式定义UI(通过描述它的形状和数据依赖),而不是关注UI的构建过程 一个组合式函数只能被另一个组合式函数调用,所以组合式函数需要添加@Composable注解。

 
   

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            Greeting("Android")
        }
    }
}

@Composable
fun Greeting(name: String) {
    Text("Hello $name!")
}

没有参数的组合式函数是可以直接预览的,只需要添加@Preview注解即可


640?wx_fmt=other


Jetpack Compose团队认为 “创建未被应用调用的单独的预览函数是最佳的做法,专门的预览函数不但可以提高性能,还可以方便地提供多个预览”,不过我觉得有点鸡肋,单个组合式函数应该可以自动或者手动预览,不应该书写额外的预览函数,而对于多个预览函数,应该属于UI测试的范畴,不需要也不应该出现在源码中。

640?wx_fmt=other

布局

使用 Column() 函数可以竖向堆叠元素,可以通过它的 crossAxisSize 参数指定列的大小,通过它的 modifier 参数指定修饰样式。

 
   

Column(
    crossAxisSize = LayoutSize.Expand,
    modifier = Spacing(16.dp)
) {
    Text("A day in Shark Fin Cove")
    Text("Davenport, California")
    Text("December 2018")
}

Container() 可以作为通用容器包裹和限制里面的元素。HeightSpacer()可以用作留白。Clip()可以裁剪,参数是用来裁剪的Shape,Shape是不可见的。MaterialTheme()可以给组件应用主题,然后就可以给文本应用样式了,如 Text("A day in Shark Fin Cove", style = +themeTextStyle { h6 }) 。

 
   

@Composable
private fun TopicItem(topicKey: String, itemTitle: String) {
    val image = +imageResource(R.drawable.placeholder_1_1)
    Padding(left = 16.dp, right = 16.dp) {
        FlexRow(
            crossAxisAlignment = CrossAxisAlignment.Center
        ) {
            inflexible {
                Container(width = 56.dp, height = 56.dp) {
                    Clip(RoundedCornerShape(4.dp)) {
                        DrawImage(image)
                    }
                }
            }
            expanded(1f) {
                Text(
                    text = itemTitle,
                    modifier = Spacing(16.dp),
                    style = +themeTextStyle { subtitle1 })
            }
            inflexible {
                val selected = isTopicSelected(topicKey)
                SelectTopicButton(
                    onSelected = {
                        selectTopic(topicKey, !selected)
                    },
                    selected = selected
                )
            }
        }
    }
}

这是一个包含三个元素的列表项,看起来还算直观,但是还是感觉哪里有点别扭。由于借助了Kotlin的trailing lambda表达式的语法,代码最终看起来还是能够表达出某种层次或结构的。

Jetpack Compose是个很有趣的尝试,让我看到了Android新的构建UI方式的可能,从语法上来看还是有一些HTML和Flutter的影子的,对于页面复杂嵌套层级过深情况下的处理应该还有很长一段路要走,Flex布局能否解决这个问题,Flex布局是否真的适合Android,Jetpack Compose的性能如何保证,调试是否方便,Gap Buffer的算法是否比Diff算法有优势等等都是需要面对,需要思考,需要时间去解决的问题。

总之,Jetpack Compose目前只是一个尝试,还缺少足够的控件支持,还缺少足够的工具支持,还缺少足够的稳定性,不过我很乐意看到这种新的尝试出现,刀耕火种的时代总是要过去的。