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

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

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

ขอบเขตและผู้รับ

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

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

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

คุณมักจะเห็นโค้ดประเภทนี้ในฟังก์ชันการเขียน

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 ได้ง่าย ซึ่งเป็นคลาสที่มีอินสแตนซ์เพียงรายการเดียวเสมอ ประกาศ Singleton เหล่านี้ด้วยคีย์เวิร์ด object คอมโพสิชันมักใช้ออบเจ็กต์ดังกล่าว ตัวอย่างเช่น MaterialTheme ได้รับการกําหนดให้เป็นออบเจ็กต์แบบ Singleton โดยพร็อพเพอร์ตี้ 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 รับประกันว่าบิลเดอร์จะเป็นประเภทที่ปลอดภัยโดยใช้นิพจน์ฟังก์ชันที่มีตัวรับ หากใช้ 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

Coroutine ของ Kotlin

Coroutines รองรับการเขียนโปรแกรมแบบแอซิงโครนัสที่ระดับภาษาใน Kotlin Coroutine สามารถหยุดชั่วคราวการดําเนินการโดยไม่บล็อกเธรด UI แบบตอบสนองจะทำงานแบบไม่พร้อมกันโดยพื้นฐาน และ Jetpack Compose แก้ปัญหานี้ด้วยการรองรับ coroutine ที่ระดับ API แทนการใช้การเรียกกลับ

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

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

หากต้องการเรียกใช้โค้ดพร้อมกัน คุณต้องสร้างโคโรทีนใหม่ ในตัวอย่างข้างต้น หากต้องการเลื่อนขึ้นด้านบนของหน้าจอและโหลดข้อมูลจาก 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()
        }
    }
) { /* ... */ }

Coroutines ช่วยให้คุณรวม 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)
        )
    }

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