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 表达式是计算结果为函数的表达式。如果您只需要该函数一次,则无需在其他地方定义它以将其传递给高阶函数。相反,您只需使用 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 使用 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)
        }
    }
}

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

Kotlin 协程

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

Jetpack Compose 提供了 API,这些 API 使在 UI 层内安全使用协程变得更加容易。 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)
        )
    }

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