Kotlin 对 Jetpack Compose 的支持

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 表达式在该位置定义该函数。前面的示例假设在其他位置定义了 myClickFunction()。但是,如果您只在此处使用该函数,则使用 lambda 表达式以内嵌方式定义该函数会更简单:

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

尾随 lambda

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

例如,所有布局的最后一个参数(如 Column() 可组合函数)均为 content,它是一个发出子界面元素的函数。假设您想要创建一个包含三个文本元素的列,并且需要应用某种格式设置。以下代码行得通,但它非常繁琐:

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 布局可组合项时,系统会自动在 RowScope 中调用内容 lambda。这样一来,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()
}

其他代码可以访问此类属性,所用代码如下:

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 函数中看到这样的代码:

ConstraintLayout {

    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 使用 DSL 来实现某些 API(例如 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)
        }
    }
}

如需详细了解类型安全的构建器和 DSL,请参阅 Kotlin 文档

Kotlin 协程

在 Kotlin 中,协程在语言级别提供了异步编程支持。协程可以挂起执行,而不会阻塞线程。自适应界面本质上是异步的,而 Jetpack Compose 会在 API 级别引入协程而非使用回调来解决此问题。

Jetpack Compose 提供了可在界面层中安全使用协程的 API。rememberCoroutineScope 函数会返回一个 CoroutineScope,您可以用它在事件处理脚本中创建协程并调用 Compose Suspend 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,也是如此。在上述示例中,在挂起函数 animateScrollTo 返回之前,系统不会执行 loadData

若要同时执行代码,则需要创建新的协程。在上述示例中,如需在滚动到屏幕顶部的同时从 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 independant 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 协程指南。