Kotlin dành cho Jetpack Compose

Jetpack Compose được xây dựng dựa trên Kotlin. Trong một số trường hợp, Kotlin cung cấp các thành ngữ đặc biệt giúp bạn viết mã Compose phù hợp dễ dàng hơn. Nếu bạn nghĩ về một ngôn ngữ lập trình khác và chuyển ý ngôn ngữ đó về mặt tinh thần sang Kotlin, thì bạn có thể bỏ lỡ một số điểm mạnh của công cụ Compose. Bạn cũng có thể thấy khó hiểu được cách viết thành ngữ mã Kotlin. Việc càng trở nên quen thuộc hơn với phong cách của Kotlin càng có thể giúp bạn tránh được những sai lầm đó.

Đối số mặc định

Khi viết hàm Kotlin, bạn có thể chỉ định các giá trị mặc định cho các đối số của hàm. Các đối số này được dùng nếu người viết lệnh không chuyển các giá trị đó một cách rõ ràng. Tính năng này làm giảm nhu cầu cần các hàm nạp chồng.

Ví dụ: giả sử bạn muốn viết một hàm vẽ hình vuông. Hàm đó có thể có một thông số bắt buộc duy nhất là sideLength, chỉ định độ dài của mỗi cạnh. Hàm có thể có một vài thông số không bắt buộc, chẳng hạn như thickness, edgeColor, v.v. nếu người viết lệnh không chỉ định các giá trị đó, hàm sẽ sử dụng giá trị mặc định. Ở các ngôn ngữ khác, bạn có thể viết một số hàm:

// 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 ) {...}
// …

Trong Kotlin, bạn có thể viết một hàm duy nhất và chỉ định các giá trị mặc định cho các đối số:

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

Ngoài việc giúp bạn không phải viết nhiều hàm thừa, tính năng này giúp mã của bạn dễ đọc hơn. Nếu người viết lệnh không chỉ định giá trị cho đối số, điều đó cho biết rằng họ sẵn sàng sử dụng giá trị mặc định. Ngoài ra, các thông số được đặt tên giúp bạn dễ dàng biết điều gì đang diễn ra. Nếu nhìn vào mã và thấy một lệnh gọi hàm như thế này, thì bạn có thể không biết ý nghĩa của các thông số nếu không kiểm tra mã drawSquare():

drawSquare(30, 5, Color.Red);

Ngược lại, mã này là tự ghi lại là:

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

Hầu hết các thư viện của Compose đều sử dụng đối số mặc định. Do đó, bạn nên triển khai tương tự cho các hàm có thể kết hợp mà bạn viết. Phương pháp này giúp bạn có thể tuỳ chỉnh các thành phần kết hợp, nhưng vẫn có thể gọi hoạt động mặc định một cách đơn giản. Ví dụ: bạn có thể tạo một thành phần văn bản đơn giản như sau:

Text(text = "Hello, Android!")

Mã đó có tác dụng giống như mã sau, chi tiết hơn nhiều, trong đó có nhiều thông số Text được đặt rõ ràng hơn:

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

Đoạn mã đầu tiên không chỉ đơn giản và dễ đọc hơn nhiều mà còn tự ghi lại. Bằng cách chỉ định chỉ thông số text, bạn ghi nhận lại điều đó cho tất cả các thông số khác mà bạn muốn sử dụng các giá trị mặc định. Ngược lại, đoạn mã thứ hai ngụ ý rằng bạn muốn đặt các giá trị cho các thông số khác đó một cách rõ ràng, mặc dù các giá trị bạn đặt là các giá trị mặc định cho hàm.

Hàm có thứ tự cao hơn và biểu thức lambda

Kotlin hỗ trợ các hàm có thứ tự cao hơn, các hàm nhận các hàm khác làm thông số. Compose xây dựng dựa trên cách tiếp cận này. Ví dụ: hàm có thể kết hợp Button cung cấp tham số lambda onClick. Giá trị của thông số đó là một hàm. Nút gọi hàm này khi người dùng nhấp vào hàm:

Button(
    // ...
    onClick = myClickFunction
)

Các hàm có thứ tự cao hơn ghép nối tự nhiên với các biểu thức lambda, biểu thức đánh giá một hàm. Nếu chỉ cần hàm một lần, bạn không cần phải xác định hàm đó ở nơi khác để chuyển hàm đó đến hàm có thứ tự cao hơn. Thay vào đó, bạn chỉ cần xác định hàm ngay tại đó bằng một biểu thức lambda. Ví dụ trước giả định rằng myClickFunction() được xác định ở nơi khác. Nhưng nếu bạn chỉ sử dụng hàm đó tại đây, bạn chỉ cần đơn giản là chỉ xác định hàm cùng dòng với biểu thức lambda:

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

Các biểu thức lambda tạo vệt

Kotlin cung cấp một cú pháp đặc biệt để gọi các hàm có thứ tự cao hơn có tham số cuối cùng là một biểu thức lambda. Nếu muốn chuyển một biểu thức lambda làm thông số đó, bạn có thể sử dụng cú pháp lambda tạo vệt. Thay vì đặt biểu thức lambda trong dấu ngoặc đơn, bạn đặt biểu thức đó sau đó. Đây là trường hợp phổ biến trong công cụ Compose, vì vậy, bạn cần phải quen thuộc với cách mã trông như thế nào.

Ví dụ: thông số cuối cùng cho tất cả các bố cục, chẳng hạn như Column() hàm có thể kết hợp, là content, một hàm phát ra các thành phần giao diện người dùng con cháu. Giả sử bạn muốn tạo một cột chứa ba thành phần văn bản và bạn cần áp dụng một số định dạng. Mã này sẽ hoạt động, nhưng rất cồng kềnh:

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

Vì tham số content là tham số cuối cùng trong chữ ký hàm và chúng ta đang chuyển giá trị của tham số đó làm một biểu thức lambda, nên chúng ta có thể rút tham số này khỏi các dấu ngoặc đơn:

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

Hai ví dụ này có cùng ý nghĩa. Dấu ngoặc nhọn xác định biểu thức lambda được chuyển đến thông số content.

Trên thực tế, nếu thông số only mà bạn đang chuyển là lambda tạo vệt—nghĩa là, nếu thông số cuối cùng là lambda và bạn không chuyển bất kỳ thông số nào khác—bạn hoàn toàn có thể bỏ qua các dấu ngoặc đơn. Ví dụ: giả sử bạn không cần chuyển công cụ sửa đổi đến Column. Bạn có thể viết mã như sau:

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

Cú pháp này khá phổ biến trong công cụ Compose, đặc biệt là đối với các thành phần bố cục như Column. Thông số cuối cùng là một biểu thức lambda xác định các thành phần con cháu của thành phần và những thành phần con cháu đó được chỉ định trong dấu ngoặc nhọn sau lệnh gọi hàm.

Phạm vi và trình nhận

Một số phương thức và thuộc tính chỉ có trong một phạm vi nhất định. Phạm vi giới hạn cho phép bạn cung cấp chức năng ở những nơi cần thiết và tránh vô tình sử dụng chức năng đó ở những nơi không phù hợp.

Hãy xem xét một ví dụ được sử dụng trong công cụ Compose. Khi bạn gọi thành phần kết hợp bố cục Row biểu thức lambda nội dung của bạn tự động được gọi trong RowScope. Việc này sẽ kích hoạt Row để hiển thị chức năng chỉ có hiệu lực trong Row. Ví dụ bên dưới minh họa cách Row đã hiển thị một giá trị cụ thể theo hàng cho công cụ sửa đổi 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)
    )
}

Một số API chấp nhận các biểu thức lambda được gọi trong phạm vi trình nhận. Những lambda đó có quyền truy cập vào các thuộc tính và hàm được xác định ở nơi khác, dựa trên nội dung khai báo thông số:

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( /*...*/ )
    }
)

Để biết thêm thông tin, hãy xem các giá trị cố định của hàm có trình nhận trong tài liệu về Kotlin.

Thuộc tính ủy quyền

Kotlin hỗ trợ các thuộc tính ủy quyền. Các thuộc tính này được gọi như thể chúng là các trường, nhưng giá trị của các thuộc tính này được xác định một cách linh động bằng cách đánh giá một biểu thức. Bạn có thể nhận ra các thuộc tính này bằng việc sử dụng cú pháp của chúngby:

class DelegatingClass {
    var name: String by nameGetterFunction()
}

Mã khác có thể truy cập vào thuộc tính có mã như sau:

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

Khi println() thực thi, nameGetterFunction() được gọi để trả về giá trị của chuỗi.

Các thuộc tính ủy quyền này đặc biệt hữu ích khi bạn làm việc với các thuộc tính có trạng thái hỗ trợ:

var showDialog by remember { mutableStateOf(false) }

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

Huỷ cấu trúc lớp dữ liệu

Nếu xác định được một lớp dữ liệu, bạn có thể dễ dàng truy cập dữ liệu đó bằng cách khai báo huỷ cấu trúc. Ví dụ: giả sử bạn xác định được một lớp Person:

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

Nếu bạn có một đối tượng thuộc loại đó, bạn có thể truy cập các giá trị của đối tượng bằng mã như sau:

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

// ...

val (name, age) = mary

Bạn thường sẽ thấy loại mã đó trong các hàm của 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.

    // ...
}

Lớp dữ liệu cung cấp rất nhiều chức năng hữu ích khác. Ví dụ: khi bạn xác định một lớp dữ liệu, trình biên dịch sẽ tự động xác định các hàm hữu ích như equals()copy(). Bạn có thể tìm thêm thông tin trong tài liệu về lớp dữ liệu.

Đối tượng Singleton

Kotlin giúp bạn dễ dàng khai báo singleton, các lớp luôn có một và chỉ một phiên bản. Các singleton này được khai báo bằng từ khoá object. Compose thường sử dụng những đối tượng như vậy. Ví dụ: MaterialTheme được xác định là một đối tượng singleton; tất cả các thuộc tính MaterialTheme.colors, shapestypography đều chứa các giá trị cho giao diện hiện tại.

Trình tạo loại an toàn và các DSL

Kotlin cho phép tạo các ngôn ngữ dành riêng cho miền (DSL) với các trình tạo loại an toàn. DSL cho phép xây dựng các cấu trúc dữ liệu phân cấp phức tạp theo cách có thể duy trì và dễ đọc hơn.

Jetpack Compose dùng các DSL cho một số API như 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 đảm bảo các trình tạo loại an toàn sử dụng các giá trị của hàm có trình nhận. Nếu chúng ta lấy thành phần kết hợp Canvas là ví dụ, thì thành phần này được xem là một tham số mà một hàm có DrawScope làm trình nhận, onDraw: DrawScope.() -> Unit, cho phép khối mã gọi các hàm thành viên được xác định trong 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)
        }
    }
}

Hãy tìm hiểu thêm về các trình tạo loại an toàn và các DSL trong tài liệu của Kotlin.

Kiến thức cơ bản về coroutine của Kotlin

Coroutine cung cấp dịch vụ hỗ trợ lập trình không đồng bộ ở cấp độ ngôn ngữ trong Kotlin. Coroutine có thể tạm ngưng việc thực thi mà không chặn chuỗi. Một giao diện người dùng thích ứng vốn không đồng bộ và Jetpack Compose sẽ giải quyết vấn đề này bằng cách phối hợp các coroutine ở cấp API thay vì dùng các lệnh gọi lại.

Jetpack Compose cung cấp các API giúp sử dụng coroutine an toàn trong lớp giao diện người dùng. Hàm rememberCoroutineScope trả về CoroutineScope mà bạn có thể tạo cáccoroutine trong trình xử lý sự kiện và gọi Compose tạm ngưng các API. Hãy xem ví dụ bên dưới về việc sử dụng API animateScrollTo của ScrollState.

// 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()
        }
    }
) { /* ... */ }

Theo mặc định, coroutine thực thi khối mã tuần tự. Một coroutine đang chạy gọi hàm tạm ngưng tạm ngưng việc thực thi cho đến khi hàm tạm ngưng trả về. Điều này vẫn áp dụng ngay cả khi hàm tạm ngưng di chuyển tệp thực thi sang một CoroutineDispatcher khác. Trong ví dụ trước, loadData sẽ không được thực thi cho đến khi hàm tạm ngưng animateScrollTo trả về.

Để thực thi mã đồng thời, bạn cần tạo các coroutine mới. Trong ví dụ ở trên, để cuộn lên đầu màn hình và tải dữ liệu đồng thời từ viewModel, bạn cần sử dụng hai coroutine.

// 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()
        }
    }
) { /* ... */ }

Coroutine giúp kết hợp API không đồng bộ dễ dàng hơn. Trong ví dụ sau, chúng tôi kết hợp công cụ sửa đổi pointerInput với API ảnh động để tạo hiệu ứng động vị trí một thành phần khi người dùng nhấn vào màn hình.

@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)
        )
    }
}

Để tìm hiểu thêm về Coroutine, hãy xem hướng dẫn về Coroutine của Kotlin trên Android.