Jetpack Compose 的 Kotlin

Jetpack Compose 基于 Kotlin 构建。在某些情况下,Kotlin 提供了一些特殊的习惯用法,使编写良好的 Compose 代码变得更容易。如果您使用其他编程语言进行思考,并将该语言在脑海中转换为 Kotlin,那么您可能会错过 Compose 的一些优势,并且可能难以理解习惯性编写的 Kotlin 代码。更熟悉 Kotlin 的风格可以帮助您避免这些陷阱。

默认参数

编写 Kotlin 函数时,您可以为函数参数指定默认值,如果调用方没有显式传递这些值,则使用这些值。此功能减少了对重载函数的需求。

例如,假设您想编写一个绘制正方形的函数。该函数可能只有一个必需的参数 sideLength,指定每条边的长度。它可能还有几个可选参数,如 thicknessedgeColor 等;如果调用方未指定这些参数,则函数使用默认值。在其他语言中,您可能希望编写多个函数

// We don't need to do this in Kotlin!
void drawSquare(int sideLength) { }

void drawSquare(int sideLength, int thickness) { }

void drawSquare(int sideLength, int thickness, Color edgeColor) { }

在 Kotlin 中,您可以编写一个函数并为参数指定默认值

fun drawSquare(
    sideLength: Int,
    thickness: Int = 2,
    edgeColor: Color = Color.Black
) {
}

除了让您不必编写多个冗余函数之外,此功能还可以使您的代码更易于阅读。如果调用方未为参数指定值,则表示他们愿意使用默认值。此外,命名参数使更容易查看正在发生的事情。如果您查看代码并看到如下函数调用,则可能无法在不检查 drawSquare() 代码的情况下了解参数的含义

drawSquare(30, 5, Color.Red);

相比之下,此代码具有自文档功能

drawSquare(sideLength = 30, thickness = 5, edgeColor = Color.Red)

大多数 Compose 库使用默认参数,并且在您编写的可组合函数中也建议这样做。此做法使您的可组合函数可自定义,但仍然使默认行为易于调用。因此,例如,您可以创建如下所示的简单文本元素

Text(text = "Hello, Android!")

该代码与以下更冗长的代码具有相同的效果,在该代码中,更多地显式设置了Text 参数

Text(
    text = "Hello, Android!",
    color = Color.Unspecified,
    fontSize = TextUnit.Unspecified,
    letterSpacing = TextUnit.Unspecified,
    overflow = TextOverflow.Clip
)

第一个代码段不仅更简单、更易于阅读,而且还具有自文档功能。通过仅指定 text 参数,您记录了对于所有其他参数,您希望使用默认值。相比之下,第二个代码段暗示您希望显式设置这些其他参数的值,尽管您设置的值恰好是函数的默认值。

高阶函数和 lambda 表达式

Kotlin 支持高阶函数,即接收其他函数作为参数的函数。Compose 基于此方法构建。例如,Button 可组合函数提供了一个 onClick lambda 参数。该参数的值是一个函数,当用户单击按钮时,按钮会调用该函数

Button(
    // ...
    onClick = myClickFunction
)
// ...

高阶函数与lambda 表达式自然地配对,lambda 表达式求值为函数。如果您只需要使用一次函数,则无需在其他地方定义它以将其传递给高阶函数。相反,您只需使用 lambda 表达式在该处定义函数即可。前面的示例假设 myClickFunction() 在其他地方定义。但是,如果您仅在此处使用该函数,则使用 lambda 表达式内联定义函数会更简单

Button(
    // ...
    onClick = {
        // do something
        // do something else
    }
) { /* ... */ }

尾随 lambda

Kotlin 提供了一种特殊的语法来调用其最后一个参数为 lambda 的高阶函数。如果要将 lambda 表达式作为该参数传递,可以使用尾随 lambda 语法。您无需将 lambda 表达式放在括号内,而是将其放在后面。这在 Compose 中很常见,因此您需要熟悉代码的外观。

例如,所有布局的最后一个参数(例如 Column() 可组合函数)是 content,一个发出子 UI 元素的函数。假设您想创建一个包含三个文本元素的列,并且需要应用一些格式。此代码可以工作,但它非常繁琐

Column(
    modifier = Modifier.padding(16.dp),
    content = {
        Text("Some text")
        Text("Some more text")
        Text("Last text")
    }
)

因为 content 参数是函数签名中的最后一个参数,并且我们将其值作为 lambda 表达式传递,所以我们可以将其从括号中提取出来

Column(modifier = Modifier.padding(16.dp)) {
    Text("Some text")
    Text("Some more text")
    Text("Last text")
}

这两个示例具有完全相同的含义。花括号定义传递给 content 参数的 lambda 表达式。

实际上,如果您传递的唯一参数是该尾随 lambda(即,如果最后一个参数是 lambda,并且您没有传递任何其他参数),则可以完全省略括号。因此,例如,假设您不需要向 Column 传递修饰符。您可以像这样编写代码

Column {
    Text("Some text")
    Text("Some more text")
    Text("Last text")
}

此语法在 Compose 中非常常见,尤其是在 Column 等布局元素中。最后一个参数是一个 lambda 表达式,用于定义元素的子元素,这些子元素在函数调用后的花括号中指定。

作用域和接收者

某些方法和属性仅在特定作用域中可用。有限的作用域允许您在需要的地方提供功能,并避免意外地在不合适的地方使用该功能。

考虑 Compose 中使用的示例。当您调用 Row 布局可组合函数时,您的内容 lambda 会自动在 RowScope 中调用。这使 Row 能够公开仅在 Row 内有效的函数。以下示例演示了 Row 如何为 align 修饰符公开了特定于行的值

Row {
    Text(
        text = "Hello world",
        // This Text is inside a RowScope so it has access to
        // Alignment.CenterVertically but not to
        // Alignment.CenterHorizontally, which would be available
        // in a ColumnScope.
        modifier = Modifier.align(Alignment.CenterVertically)
    )
}

某些 API 接受在接收者作用域中调用的 lambda。这些 lambda 可以访问在其他地方定义的属性和函数,具体取决于参数声明

Box(
    modifier = Modifier.drawBehind {
        // This method accepts a lambda of type DrawScope.() -> Unit
        // therefore in this lambda we can access properties and functions
        // available from DrawScope, such as the `drawRectangle` function.
        drawRect(
            /*...*/
            /* ...
        )
    }
)

有关更多信息,请参阅 Kotlin 文档中的带有接收者的函数文字

委托属性

Kotlin 支持委托属性。这些属性的调用方式与字段相同,但其值由动态计算表达式决定。您可以通过 by 语法的使用来识别这些属性

class DelegatingClass {
    var name: String by nameGetterFunction()

    // ...
}

其他代码可以使用如下代码访问该属性

val myDC = DelegatingClass()
println("The name property is: " + myDC.name)

println() 执行时,将调用 nameGetterFunction() 返回字符串的值。

这些委托属性在处理状态支持的属性时特别有用

var showDialog by remember { mutableStateOf(false) }

// Updating the var automatically triggers a state change
showDialog = true

解构数据类

如果您定义了一个数据类,则可以使用解构声明轻松访问数据。例如,假设您定义了一个 Person

data class Person(val name: String, val age: Int)

如果您有该类型的对象,则可以使用如下代码访问其值

val mary = Person(name = "Mary", age = 35)

// ...

val (name, age) = mary

您经常会在 Compose 函数中看到这种代码

Row {

    val (image, title, subtitle) = createRefs()

    // The `createRefs` function returns a data object;
    // the first three components are extracted into the
    // image, title, and subtitle variables.

    // ...
}

数据类提供了许多其他有用的功能。例如,当您定义数据类时,编译器会自动定义有用的函数,如 equals()copy()。您可以在数据类文档中找到更多信息。

单例对象

Kotlin 使声明单例变得容易,单例始终只有一个实例。这些单例使用object 关键字声明。Compose 经常使用此类对象。例如,MaterialTheme 被定义为一个单例对象;MaterialTheme.colorsshapestypography 属性都包含当前主题的值。

类型安全的构建器和 DSL

Kotlin 允许使用类型安全的构建器创建特定领域语言 (DSL)。DSL 允许以更易于维护和阅读的方式构建复杂的分层数据结构。

Jetpack Compose 在一些 API 中使用了 DSL,例如 LazyRowLazyColumn

@Composable
fun MessageList(messages: List<Message>) {
    LazyColumn {
        // Add a single item as a header
        item {
            Text("Message List")
        }

        // Add list of messages
        items(messages) { message ->
            Message(message)
        }
    }
}

Kotlin 使用 带接收者的函数字面量 来保证类型安全的构建器。如果我们以 Canvas 可组合项为例,它接收一个函数作为参数,该函数以 DrawScope 作为接收者,onDraw: DrawScope.() -> Unit,允许代码块调用在 DrawScope 中定义的成员函数。

Canvas(Modifier.size(120.dp)) {
    // Draw grey background, drawRect function is provided by the receiver
    drawRect(color = Color.Gray)

    // Inset content by 10 pixels on the left/right sides
    // and 12 by the top/bottom
    inset(10.0f, 12.0f) {
        val quadrantSize = size / 2.0f

        // Draw a rectangle within the inset bounds
        drawRect(
            size = quadrantSize,
            color = Color.Red
        )

        rotate(45.0f) {
            drawRect(size = quadrantSize, color = Color.Blue)
        }
    }
}

Kotlin 文档 中了解更多关于类型安全构建器和 DSL 的信息。

Kotlin 协程

协程在 Kotlin 中提供了语言级别的异步编程支持。协程可以在不阻塞线程的情况下挂起执行。响应式 UI 本质上是异步的,Jetpack Compose 通过在 API 层级拥抱协程而不是使用回调来解决这个问题。

Jetpack Compose 提供了可在 UI 层安全使用协程的 API。 rememberCoroutineScope 函数返回一个 CoroutineScope,您可以使用它在事件处理程序中创建协程并调用 Compose 挂起 API。请参见下面使用 ScrollStateanimateScrollTo API 的示例。

// Create a CoroutineScope that follows this composable's lifecycle
val composableScope = rememberCoroutineScope()
Button(
    // ...
    onClick = {
        // Create a new coroutine that scrolls to the top of the list
        // and call the ViewModel to load data
        composableScope.launch {
            scrollState.animateScrollTo(0) // This is a suspend function
            viewModel.loadData()
        }
    }
) { /* ... */ }

默认情况下,协程按顺序执行代码块。调用挂起函数的正在运行的协程会挂起其执行,直到挂起函数返回。即使挂起函数将执行转移到不同的 CoroutineDispatcher,情况也是如此。在前面的示例中,loadData 只有在挂起函数 animateScrollTo 返回后才会执行。

要并发执行代码,需要创建新的协程。在上面的示例中,要将滚动到屏幕顶部和从 viewModel 加载数据并行化,需要两个协程。

// Create a CoroutineScope that follows this composable's lifecycle
val composableScope = rememberCoroutineScope()
Button( // ...
    onClick = {
        // Scroll to the top and load data in parallel by creating a new
        // coroutine per independent work to do
        composableScope.launch {
            scrollState.animateScrollTo(0)
        }
        composableScope.launch {
            viewModel.loadData()
        }
    }
) { /* ... */ }

协程使组合异步 API 变得更容易。在以下示例中,我们将 pointerInput 修饰符与动画 API 结合使用,以便在用户点击屏幕时为元素的位置设置动画。

@Composable
fun MoveBoxWhereTapped() {
    // Creates an `Animatable` to animate Offset and `remember` it.
    val animatedOffset = remember {
        Animatable(Offset(0f, 0f), Offset.VectorConverter)
    }

    Box(
        // The pointerInput modifier takes a suspend block of code
        Modifier
            .fillMaxSize()
            .pointerInput(Unit) {
                // Create a new CoroutineScope to be able to create new
                // coroutines inside a suspend function
                coroutineScope {
                    while (true) {
                        // Wait for the user to tap on the screen
                        val offset = awaitPointerEventScope {
                            awaitFirstDown().position
                        }
                        // Launch a new coroutine to asynchronously animate to
                        // where the user tapped on the screen
                        launch {
                            // Animate to the pressed position
                            animatedOffset.animateTo(offset)
                        }
                    }
                }
            }
    ) {
        Text("Tap anywhere", Modifier.align(Alignment.Center))
        Box(
            Modifier
                .offset {
                    // Use the animated offset as the offset of this Box
                    IntOffset(
                        animatedOffset.value.x.roundToInt(),
                        animatedOffset.value.y.roundToInt()
                    )
                }
                .size(40.dp)
                .background(Color(0xff3c1361), CircleShape)
        )
    }

要了解有关协程的更多信息,请查看 Android 上的 Kotlin 协程 指南。