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 รองรับฟังก์ชันระดับสูง ซึ่งเป็นฟังก์ชันที่รับฟังก์ชันอื่นๆ เป็นพารามิเตอร์ คอมโพเซ่นจะต่อยอดจากแนวทางนี้ ตัวอย่างเช่น ฟังก์ชันคอมโพสิเบิล 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

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

Coroutine ช่วยให้รวม 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