Kotlin สำหรับ Jetpack Compose

Jetpack Compose สร้างขึ้นโดยใช้ Kotlin ในบางกรณี Kotlin มีสำนวนพิเศษที่ช่วยให้เขียนโค้ด Compose ที่ดีได้ง่ายขึ้น หากคุณคิดเป็นภาษาโปรแกรมอื่นและแปลภาษานั้นเป็น Kotlin ในใจ คุณอาจพลาดจุดแข็งบางอย่างของ Compose และอาจพบว่าการทำความเข้าใจโค้ด Kotlin ที่เขียนตามสำนวนเป็นเรื่องยาก การทำความคุ้นเคยกับรูปแบบของ Kotlin มากขึ้นจะช่วยให้คุณหลีกเลี่ยงข้อผิดพลาดเหล่านั้นได้

อาร์กิวเมนต์เริ่มต้น

เมื่อเขียนฟังก์ชัน Kotlin คุณสามารถระบุค่าเริ่มต้นสำหรับอาร์กิวเมนต์ของฟังก์ชัน ซึ่งจะใช้หากผู้เรียกไม่ได้ส่งค่าเหล่านั้นอย่างชัดแจ้ง ฟีเจอร์นี้ช่วยลดความจำเป็นในการใช้ฟังก์ชันที่โอเวอร์โหลด

เช่น สมมติว่าคุณต้องการเขียนฟังก์ชันที่วาดสี่เหลี่ยมจัตุรัส ฟังก์ชันนั้นอาจมีพารามิเตอร์ที่จำเป็นเพียงรายการเดียวคือ sideLength ซึ่งระบุความยาวของแต่ละด้าน โดยอาจมีพารามิเตอร์ที่ไม่บังคับหลายรายการ เช่น thickness, edgeColor และอื่นๆ หากผู้เรียกไม่ได้ระบุพารามิเตอร์เหล่านั้น ฟังก์ชันจะใช้ค่าเริ่มต้น ในภาษาอื่นๆ คุณอาจต้องเขียนฟังก์ชันหลายรายการ ดังนี้

// 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 ส่วนใหญ่ใช้อาร์กิวเมนต์เริ่มต้น และแนวทางปฏิบัติแนะนำคือให้ทำเช่นเดียวกันกับฟังก์ชันที่ประกอบกันได้ที่คุณเขียน แนวทางปฏิบัตินี้ช่วยให้คุณปรับแต่ง Composable ได้ แต่ยังคงทำให้ลักษณะการทำงานเริ่มต้นเรียกใช้ได้ง่าย ตัวอย่างเช่น คุณอาจสร้างองค์ประกอบของข้อความง่ายๆ ดังนี้

Text(text = "Hello, Android!")

โค้ดดังกล่าวมีผลเหมือนกับโค้ดต่อไปนี้ซึ่งมีรายละเอียดมากกว่า โดยมีการตั้งค่าพารามิเตอร์ Text อย่างชัดเจนมากขึ้น

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

ข้อมูลโค้ดแรกไม่เพียงแต่จะง่ายกว่าและอ่านง่ายกว่ามากเท่านั้น แต่ยังเป็นเอกสารที่อธิบายตัวเองด้วย การระบุเฉพาะพารามิเตอร์ text หมายความว่าคุณต้องการใช้ค่าเริ่มต้นสำหรับพารามิเตอร์อื่นๆ ทั้งหมด ในทางตรงกันข้าม ข้อมูลโค้ดที่ 2 บ่งบอกว่าคุณต้องการตั้งค่าพารามิเตอร์อื่นๆ เหล่านั้นอย่างชัดเจน แม้ว่าค่าที่คุณตั้งไว้จะเป็นค่าเริ่มต้นของฟังก์ชันก็ตาม

ฟังก์ชันลำดับสูงและนิพจน์ Lambda

Kotlin รองรับฟังก์ชันลำดับสูง ซึ่งเป็นฟังก์ชันที่รับฟังก์ชันอื่นๆ เป็นพารามิเตอร์ Compose สร้างขึ้นจากแนวทางนี้ ตัวอย่างเช่น ฟังก์ชันที่ประกอบกันได้ Button มีพารามิเตอร์ lambda onClick ค่าของพารามิเตอร์นั้นคือฟังก์ชัน ซึ่งปุ่มจะเรียกใช้เมื่อผู้ใช้คลิกปุ่ม

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

ฟังก์ชันลำดับสูงจะจับคู่กับนิพจน์ Lambda โดยอัตโนมัติ ซึ่งเป็นนิพจน์ที่ประเมินเป็นฟังก์ชัน หากต้องการใช้ฟังก์ชันเพียงครั้งเดียว คุณไม่จำเป็นต้องกำหนดฟังก์ชันไว้ที่อื่นเพื่อส่งไปยังฟังก์ชันลำดับสูง แต่คุณสามารถกำหนดฟังก์ชันได้ทันทีด้วยนิพจน์ Lambda ตัวอย่างก่อนหน้านี้ถือว่ามีการกำหนด myClickFunction() ไว้ที่อื่น แต่หากคุณใช้ฟังก์ชันนั้นที่นี่เท่านั้น การกำหนดฟังก์ชันในบรรทัดด้วยนิพจน์ Lambda จะง่ายกว่า ดังนี้

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

แลมบ์ดาต่อท้าย

Kotlin มีไวยากรณ์พิเศษสำหรับการเรียกใช้ฟังก์ชันลำดับสูงที่มีพารามิเตอร์สุดท้ายเป็น Lambda หากต้องการส่งนิพจน์ Lambda เป็นพารามิเตอร์ดังกล่าว คุณสามารถใช้ไวยากรณ์ Lambda ต่อท้ายได้ แทนที่จะใส่นิพจน์ Lambda ไว้ในวงเล็บ คุณจะใส่ไว้หลังวงเล็บแทน สถานการณ์นี้พบได้บ่อยใน Compose คุณจึงต้องคุ้นเคยกับลักษณะของโค้ด

ตัวอย่างเช่น พารามิเตอร์สุดท้ายของเลย์เอาต์ทั้งหมด เช่น ฟังก์ชันที่ประกอบกันได้ Column() คือ content ซึ่งเป็นฟังก์ชันที่แสดงองค์ประกอบ UI ย่อย สมมติว่าคุณต้องการสร้างคอลัมน์ที่มีองค์ประกอบของข้อความ 3 รายการ และคุณต้องใช้การจัดรูปแบบบางอย่าง โค้ดนี้จะทำงานได้ แต่ก็ยุ่งยากมาก

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

ทั้ง 2 ตัวอย่างมีความหมายเหมือนกันทุกประการ วงเล็บปีกกาจะกำหนดนิพจน์ Lambda ที่ส่งไปยังพารามิเตอร์ content

อันที่จริงแล้ว หากพารามิเตอร์เดียวที่คุณส่งคือ Lambda ต่อท้าย นั่นคือหากพารามิเตอร์สุดท้ายเป็น Lambda และคุณไม่ได้ส่งพารามิเตอร์อื่นๆ คุณสามารถละเว้นวงเล็บทั้งหมดได้ ตัวอย่างเช่น สมมติว่าคุณไม่จำเป็นต้องส่งตัวปรับแต่งไปยัง Column คุณอาจเขียนโค้ดดังนี้

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

ไวยากรณ์นี้พบได้บ่อยใน Compose โดยเฉพาะสำหรับองค์ประกอบเลย์เอาต์ เช่น Column พารามิเตอร์สุดท้ายคือนิพจน์ Lambda ที่กำหนดองค์ประกอบย่อยขององค์ประกอบ และองค์ประกอบย่อยเหล่านั้นจะระบุไว้ในวงเล็บปีกกาหลังการเรียกใช้ฟังก์ชัน

ขอบเขตและตัวรับ

เมธอดและพร็อพเพอร์ตี้บางรายการใช้ได้เฉพาะในขอบเขตที่กำหนดเท่านั้น ขอบเขตที่จำกัดช่วยให้คุณนำเสนอฟังก์ชันการทำงานในจุดที่จำเป็น และหลีกเลี่ยงการใช้ฟังก์ชันการทำงานนั้นโดยไม่ตั้งใจในจุดที่ไม่เหมาะสม

ลองดูตัวอย่างที่ใช้ใน Compose เมื่อคุณเรียกใช้คอมโพสิเบิลเลย์เอาต์ Row ระบบจะเรียกใช้แลมบ์ดาเนื้อหาภายใน 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 ที่เรียกใช้ในขอบเขตผู้รับ แลมบ์ดาเหล่านั้นมีสิทธิ์เข้าถึงพร็อพเพอร์ตี้และฟังก์ชันที่กำหนดไว้ที่อื่น โดยอิงตามการประกาศพารามิเตอร์ ดังนี้

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() ดูข้อมูลเพิ่มเติมได้ในเอกสารประกอบเกี่ยวกับคลาสข้อมูล

ออบเจ็กต์ Singleton

Kotlin ช่วยให้คุณประกาศ Singleton ซึ่งเป็นคลาสที่มีอินสแตนซ์เพียงรายการเดียวเสมอ ซิงเกิลตันเหล่านี้จะประกาศด้วยคีย์เวิร์ด object Compose มักใช้ออบเจ็กต์ดังกล่าว เช่น MaterialTheme จะกำหนดเป็นออบเจ็กต์ซิงเกิลตัน โดยพร็อพเพอร์ตี้ MaterialTheme.colors, shapes และ typography ทั้งหมดจะมีค่าสำหรับธีมปัจจุบัน

เครื่องมือสร้างที่ปลอดภัยในการกำหนดประเภทและ DSL

Kotlin ช่วยให้สร้างภาษาเฉพาะโดเมน (DSL) ด้วยเครื่องมือสร้างที่ปลอดภัยในการกำหนดประเภทได้ DSL ช่วยให้สร้างโครงสร้างข้อมูลแบบลำดับชั้นที่ซับซ้อนได้ในลักษณะที่ดูแลรักษาและอ่านได้ง่ายขึ้น

Jetpack Compose ใช้ DSL สำหรับ API บางรายการ เช่น LazyRow และ LazyColumn

@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 ช่วยให้สามารถใช้เครื่องมือสร้างที่ปลอดภัยต่อประเภทโดยใช้ฟังก์ชันลิเทอรัลที่มีรีซีฟเวอร์ หากเรายกตัวอย่าง Composable 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 โครูทีนสามารถระงับการดำเนินการได้โดยไม่บล็อกชุดข้อความ UI ที่ปรับเปลี่ยนตามอุปกรณ์เป็นแบบอะซิงโครนัสโดยเนื้อแท้ และ Jetpack Compose แก้ปัญหานี้ด้วยการใช้โครูทีนที่ระดับ API แทนการใช้ Callback

Jetpack Compose มี API ที่ช่วยให้ใช้โครูทีนได้อย่างปลอดภัยภายในเลเยอร์ UI ฟังก์ชัน rememberCoroutineScope จะแสดงผล CoroutineScope ซึ่งคุณสามารถใช้สร้างโครูทีนในเครื่องจัดการเหตุการณ์และเรียกใช้ API การระงับของ Compose ได้ ดูตัวอย่างต่อไปนี้โดยใช้ API animateScrollTo ของ 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()
        }
    }
) { /* ... */ }

โครูทีนจะเรียกใช้บล็อกโค้ดตามลำดับโดยค่าเริ่มต้น โครูทีนที่ทำงานอยู่ซึ่งเรียกใช้ฟังก์ชันระงับจะระงับการดำเนินการจนกว่าฟังก์ชันระงับจะแสดงผล ซึ่งจะเป็นเช่นนี้แม้ว่าฟังก์ชันระงับจะย้ายการดำเนินการไปยัง CoroutineDispatcher อื่นก็ตาม ในตัวอย่างก่อนหน้านี้ loadData จะไม่ทำงานจนกว่าฟังก์ชันระงับ animateScrollTo จะแสดงผล

หากต้องการเรียกใช้โค้ดพร้อมกัน คุณต้องสร้าง Coroutine ใหม่ ในตัวอย่างด้านบน หากต้องการเลื่อนไปยังด้านบนของหน้าจอและโหลดข้อมูลจาก viewModel ไปพร้อมกัน คุณต้องใช้ Coroutine 2 รายการ

// 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 and animate
                        // in the same block
                        awaitPointerEventScope {
                            val offset = 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)
        )
    }

ดูข้อมูลเพิ่มเติมเกี่ยวกับ Coroutine ได้ที่คู่มือ Kotlin Coroutine ใน Android