深刻详解 Jetpack Compose | 优化 UI 构建

人们对于 UI 开发的预期已经不一样往昔。现现在,为了知足用户的需求,咱们构建的应用必须包含完善的用户界面,其中必然包括动画 (animation) 和动效 (motion),这些诉求在 UI 工具包建立之初时并不存在。为了解决如何快速而高效地建立完善的 UI 这一技术难题,咱们引入了 Jetpack Compose —— 这是一个现代的 UI 工具包,可以帮助开发者们在新的趋势下取得成功。编程

在本系列的两篇文章中,咱们将阐述 Compose 的优点,并探讨它背后的工做原理。做为开篇,在本文中,我会分享 Compose 所解决的问题、一些设计决策背后的缘由,以及这些决策如何帮助开发者。此外,我还会分享 Compose 的思惟模型,您应如何考虑在 Compose 中编写代码,以及如何建立您本身的 API。架构

Compose 所解决的问题

关注点分离 (Separation of concerns, SOC) 是一个众所周知的软件设计原则,这是咱们做为开发者所要学习的基础知识之一。然而,尽管其广为人知,但在实践中却经常难以把握是否应当遵循该原则。面对这样的问题,从 "耦合" 和 "内聚" 的角度去考虑这一原则可能会有所帮助。app

编写代码时,咱们会建立包含多个单元的模块。"耦合" 即是不一样模块中单元之间的依赖关系,它反映了一个模块中的各部分是如何影响另外一个模块的各个部分的。"内聚" 则表示的是一个模块中各个单元之间的关系,它指示了模块中各个单元相互组合的合理程度。框架

在编写可维护的软件时,咱们的目标是最大程度地减小耦合增长内聚编程语言

当咱们处理紧耦合的模块时,对一个地方的代码改动,便意味对其余的模块做出许多其余的改动。更糟的是,耦合经常是隐式的,以致于看起来毫无关联的修改,却会形成了意料以外的错误发生。函数

关注点分离是尽量的将相关的代码组织在一块儿,以便咱们能够轻松地维护它们,并方便咱们随着应用规模的增加而扩展咱们的代码。工具

让咱们在当前 Android 开发的上下文中进行更为实际的操做,并以视图模型 (view model) 和 XML 布局为例:布局

视图模型会向布局提供数据。事实证实,这里隐藏了不少依赖关系: 视图模型与布局间存在许多耦合。一个更为熟悉的可让您查看这一清单的方式是经过一些 API,例如 findViewByID。使用这些 API 须要对 XML 布局的形式和内容有必定了解。学习

使用这些 API 须要了解 XML 布局是如何定义并与视图模型产生耦合的。因为应用规模会随着时间增加,咱们还必须保证这些依赖不会过期。动画

大多数现代应用会动态展现 UI,而且会在执行过程当中不断演变。结果致使应用不只要验证布局 XML 是否静态地知足了这些依赖关系,并且还须要保证在应用的生命周期内知足这些依赖。若是一个元素在运行时离开了视图层级,一些依赖关系可能会被破坏,并致使诸如 NullReferenceExceptions 一类的问题。

一般,视图模型会使用像 Kotlin 这样的编程语言进行定义,而布局则使用 XML。因为这两种语言的差别,使得它们之间存在一条强制的分隔线。然而即便存在这种状况,视图模型与布局 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 的尾随 lambda 语法,因此 Body() 是一个含有 Composable lambda 参数的 Composable 函数。这种关系意味着层级或结构,因此这里 Body() 能够包含多个元素组成的多个元素组成的集合。

声明式 UI

"声明式" 是一个流行词,但也是一个很重要的字眼。当咱们谈论声明式编程时,咱们谈论的是与命令式相反的编程方式。让咱们来看一个例子:

假设有一个带有未读消息图标的电子邮件应用。若是没有消息,应用会绘制一个空信封;若是有一些消息,咱们会在信封中绘制一些纸张;而若是有 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 继承

在软件开发领域,Composition (组合) 指的是多个简单的代码单元如何结合到一块儿,从而构成更为复杂的代码单元。在面向对象编程模型中,最多见的组合形式之一即是基于类的继承。在 Jetpack Compose 的世界中,因为咱们使用函数替代了类型,所以实现组合的方法颇为不一样,但相比于继承也拥有许多优势,让咱们来看一个例子:

假设咱们有一个视图,而且咱们想要添加一个输入。在继承模型中,咱们的代码可能会像下面这样:

class Input : View() { /* ... */ }
class ValidatedInput : Input() { /* ... */ }
class DateInput : ValidatedInput() { /* ... */ }
class DateRangeInput : ??? { /* ... */ }

View 是基类,ValidatedInput 使用了 Input 的子类。为了验证日期,DateInput 使用了 ValidatedInput 的子类。可是接下来挑战来了: 咱们要建立一个日期范围的输入,这意味着须要验证两个日期——开始和结束日期。您能够继承 DateInput,可是您没法执行两次,这即是继承的限制: 咱们只能继承自一个父类。 

在 Compose 中,这个问题变得很简单。假设咱们从一个基础的 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 的组合模型中,咱们再也不有单个父类的限制,这样一来便解决了咱们在继承模型中所遭遇的问题。

另外一种类型的组合问题是对装饰类型的抽象。为了可以说明这一状况,请您考虑接下来的继承示例:

class FancyBox : View() { /* ... */ }
class Story : View() { /* ... */ }
class EditForm : FormView() { /* ... */ }
class FancyStory : ??? { /* ... */ }
class FancyEditForm : ??? { /* ... */ }

FancyBox 是一个用于装饰其余视图的视图,本例中将用来装饰 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 作的很好的另外一个方面是 "封装"。这是您在建立公共 Composable 函数 API 时须要考虑的问题: 公共的 Composable API 只是一组其接收的参数而已,因此 Compose 没法控制它们。另外一方面,Composable 函数能够管理和建立状态,而后将该状态及它接收到的任何数据做为参数传递给其余的 Composable 函数。

如今,因为它正管理该状态,若是您想要改变状态,您能够启用您的子级 Composable 函数经过回调告知当前改变已备份。

重组

"重组" 指的是任何 Composable 函数在任什么时候候均可以被从新调用。若是您有一个庞大的 Composable 层级结构,当您的层级中的某一部分发生改变时,您不会但愿从新计算整个层级结构。因此 Composable 函数是可重启动 (restartable) 的,您能够利用这一特性来实现一些强大的功能。

举个例子,这里有一个 Bind 函数,里面是一些 Android 开发的常见代码:

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

咱们有一个 LiveData,而且但愿视图能够订阅它。为此,咱们调用 observe 方法并传入一个 LifecycleOwner,并在接下来传入 lambda。lambda 会在每次 LiveData 更新被调用,而且发生这种状况时,咱们会想要更新视图。

使用 Compose,咱们能够反转这种关系。

@Composable
fun Messages(liveMsgs: LiveData<MessageData>) {
 val msgs by liveMsgs.observeAsState()
 for (msg in msgs) {
   Message(msg)
 }
}

这里有一个类似的 Composable 函数—— Messages。它接收了 LiveData 做为参数并调用了 Compose 的 observeAsState 方法。observeAsState 方法会把 LiveData<T> 映射为 State<T>,这意味着您能够在函数体的范围使用其值。State 实例订阅了 LiveData 实例,这意味着 State 会在 LiveData 发生改变的任何地方更新,也意味着,不管在何处读取 State 实例,包裹它的、已被读取的 Composable 函数将会自动订阅这些改变。结果就是,这里再也不须要指定 LifecycleOwner 或者更新回调,Composable 能够隐式地实现这二者的功能。

总结

Compose 提供了一种现代的方法来定义您的 UI,这使您能够有效地实现关注点分离。因为 Composable 函数与普通 Kotlin 函数很类似,所以您使用 Compose 编写和重构 UI 所使用的工具与您进行 Android 开发的知识储备和所使用的工具将会无缝衔接。