ปรับปรุงประสิทธิภาพของแอปด้วยโครูทีน Kotlin

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

หัวข้อนี้จะอธิบายรายละเอียดเกี่ยวกับโครูทีนใน Android หากคุณไม่คุ้นเคยกับโครูทีน โปรดอ่าน โครูทีนของ Kotlin ใน Android ก่อนอ่านหัวข้อนี้

จัดการงานที่ใช้เวลานาน

โครูทีนสร้างขึ้นจากฟังก์ชันปกติโดยเพิ่มการดำเนินการ 2 อย่างเพื่อจัดการงานที่ใช้เวลานาน นอกเหนือจาก invoke (หรือ call) และ return แล้ว โครูทีนยังเพิ่ม suspend และ resume ดังนี้

  • suspend จะหยุดการทำงานของโครูทีนปัจจุบันชั่วคราวและบันทึกตัวแปรภายในทั้งหมด
  • resume จะดำเนินการต่อจากจุดที่หยุดโครูทีนชั่วคราว

คุณเรียกใช้ฟังก์ชัน suspend ได้จากฟังก์ชัน suspend อื่นๆ เท่านั้น หรือใช้ตัวสร้างโครูทีน เช่น launch เพื่อเริ่มโครูทีนใหม่

ตัวอย่างต่อไปนี้แสดงการติดตั้งใช้งานโครูทีนอย่างง่ายสำหรับงานที่ใช้เวลานานสมมติ

suspend fun fetchDocs() { // Dispatchers.Main
    val result = get("https://developer.android.com") // Dispatchers.IO for `get`
    show(result) // Dispatchers.Main
}

suspend fun get(url: String) = withContext(Dispatchers.IO) {
}

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

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

ใช้โครูทีนเพื่อความปลอดภัยในเทรดหลัก

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

Kotlin มีตัวส่ง 3 ตัวที่คุณใช้เพื่อระบุตำแหน่งที่โครูทีนควรทำงานได้ ดังนี้

  • Dispatchers.Main - ใช้ตัวส่งนี้เพื่อเรียกใช้โครูทีนในเทรดหลักของ Android ควรใช้ตัวส่งนี้เพื่อโต้ตอบกับ UI และทำงานที่รวดเร็วเท่านั้น ตัวอย่างเช่น การเรียกใช้ฟังก์ชัน suspend การดำเนินการเฟรมเวิร์ก UI ของ Android และการอัปเดตออบเจ็กต์ LiveData
  • Dispatchers.IO - ตัวส่งนี้ได้รับการเพิ่มประสิทธิภาพเพื่อดำเนินการ I/O ของดิสก์หรือเครือข่ายนอกเทรดหลัก ตัวอย่างเช่น การใช้ คอมโพเนนต์ Room, การอ่านจากหรือเขียนลงในไฟล์ และการดำเนินการเครือข่าย
  • Dispatchers.Default - ตัวส่งนี้ได้รับการเพิ่มประสิทธิภาพเพื่อดำเนินการที่ใช้ CPU มากนอกเทรดหลัก ตัวอย่างกรณีการใช้งาน ได้แก่ การจัดเรียงรายการและการแยกวิเคราะห์ JSON

จากตัวอย่างก่อนหน้า คุณสามารถใช้ตัวส่งเพื่อกำหนดฟังก์ชัน get ใหม่ได้ ภายในเนื้อหาของ get ให้เรียกใช้ withContext(Dispatchers.IO) เพื่อสร้างบล็อกที่ทำงานในพูลเทรด IO โค้ดใดก็ตามที่คุณใส่ไว้ในบล็อกนั้นจะทำงานผ่านตัวส่ง IO เสมอ เนื่องจาก withContext เป็นฟังก์ชันหยุดชั่วคราว ฟังก์ชัน get จึงเป็นฟังก์ชันหยุดชั่วคราวด้วย

suspend fun fetchDocs() { // Dispatchers.Main
    val result = get("developer.android.com") // Dispatchers.Main
    show(result) // Dispatchers.Main
}

suspend fun get(url: String) = // Dispatchers.Main
    withContext(Dispatchers.IO) { // Dispatchers.IO (main-safety block)
        /* perform network IO here */ // Dispatchers.IO (main-safety block)
    } // Dispatchers.Main

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

ในตัวอย่างก่อนหน้า fetchDocs() จะทำงานในเทรดหลัก แต่สามารถเรียกใช้ get ได้อย่างปลอดภัย ซึ่งจะดำเนินการคำขอเครือข่ายในเบื้องหลัง เนื่องจากโครูทีนรองรับ suspend และ resume โครูทีนในเทรดหลักจึงดำเนินการต่อด้วยผลลัพธ์ get ทันทีที่บล็อก withContext เสร็จสมบูรณ์

ประสิทธิภาพของ withContext()

withContext() ไม่เพิ่มค่าใช้จ่ายเพิ่มเติมเมื่อเทียบกับการติดตั้งใช้งานที่เทียบเท่าซึ่งอิงตามการเรียกกลับ นอกจากนี้ คุณยังสามารถเพิ่มประสิทธิภาพการเรียกใช้ withContext() ให้ดีกว่าการติดตั้งใช้งานที่เทียบเท่าซึ่งอิงตามการเรียกกลับในบางสถานการณ์ได้ ตัวอย่างเช่น หากฟังก์ชันเรียกใช้เครือข่าย 10 ครั้ง คุณสามารถบอกให้ Kotlin เปลี่ยนเทรดเพียงครั้งเดียวได้โดยใช้ withContext() ภายนอก จากนั้นแม้ว่าไลบรารีเครือข่ายจะใช้ withContext() หลายครั้ง แต่ไลบรารีจะยังคงอยู่ในตัวส่งเดียวกันและหลีกเลี่ยงการเปลี่ยนเทรด นอกจากนี้ Kotlin ยังเพิ่มประสิทธิภาพการเปลี่ยนระหว่าง Dispatchers.Default และ Dispatchers.IO เพื่อหลีกเลี่ยงการเปลี่ยนเทรดทุกครั้งที่เป็นไปได้

เริ่มโครูทีน

คุณเริ่มโครูทีนได้ 2 วิธี ดังนี้

  • launch จะเริ่มโครูทีนใหม่และไม่แสดงผลลัพธ์ไปยังผู้เรียก คุณสามารถเริ่มงานใดก็ตามที่ถือว่าเป็น "ยิงแล้วลืม" ได้โดยใช้ launch
  • async จะเริ่มโครูทีนใหม่และให้คุณแสดงผลลัพธ์ด้วยฟังก์ชันหยุดชั่วคราว ที่เรียกว่า await

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

การแยกย่อยแบบขนาน

โครูทีนทั้งหมดที่เริ่มภายในฟังก์ชัน suspend จะต้องหยุดเมื่อฟังก์ชันนั้นแสดงผลลัพธ์ ดังนั้นคุณจึงต้องรับประกันว่าโครูทีนเหล่านั้นจะเสร็จสมบูรณ์ก่อนที่จะแสดงผลลัพธ์ การทำงานพร้อมกันแบบมีโครงสร้าง ใน Kotlin ช่วยให้คุณกำหนด coroutineScope ที่เริ่มโครูทีนอย่างน้อย 1 รายการได้ จากนั้นใช้ await() (สำหรับโครูทีนเดียว) หรือ awaitAll() (สำหรับโครูทีนหลายรายการ) เพื่อรับประกันว่าโครูทีนเหล่านี้จะเสร็จสมบูรณ์ก่อนที่จะแสดงผลลัพธ์จากฟังก์ชัน

ตัวอย่างเช่น ลองกำหนด coroutineScope ที่ดึงข้อมูลเอกสาร 2 ฉบับแบบอะซิงโครนัส การเรียกใช้ await() ในการอ้างอิงที่เลื่อนออกไปแต่ละรายการจะรับประกันว่าการดำเนินการ async ทั้ง 2 รายการจะเสร็จสมบูรณ์ก่อนที่จะแสดงค่า

suspend fun fetchTwoDocs() =
    coroutineScope {
        val deferredOne = async { fetchDoc(1) }
        val deferredTwo = async { fetchDoc(2) }
        deferredOne.await()
        deferredTwo.await()
    }

นอกจากนี้ คุณยังใช้ awaitAll() กับคอลเล็กชันได้ด้วย ดังที่แสดงในตัวอย่างต่อไปนี้

suspend fun fetchTwoDocs() = // called on any Dispatcher (any thread, possibly Main)
    coroutineScope {
        val deferreds = listOf( // fetch two docs at the same time
            async { fetchDoc(1) }, // async returns a result for the first doc
            async { fetchDoc(2) } // async returns a result for the second doc
        )
        deferreds.awaitAll() // use awaitAll to wait for both network requests
    }

แม้ว่า fetchTwoDocs() จะเปิดใช้โครูทีนใหม่ด้วย async แต่ฟังก์ชันนี้จะใช้ awaitAll() เพื่อรอให้โครูทีนที่เปิดใช้เสร็จสมบูรณ์ก่อนที่จะแสดงผลลัพธ์ อย่างไรก็ตาม โปรดทราบว่าแม้ว่าเราจะไม่ได้เรียกใช้ awaitAll() ตัวสร้าง coroutineScope จะไม่ดำเนินการต่อจากโครูทีนที่เรียกใช้ fetchTwoDocs จนกว่าโครูทีนใหม่ทั้งหมดจะเสร็จสมบูรณ์

นอกจากนี้ coroutineScope ยังดักจับข้อยกเว้นที่โครูทีนส่งกลับและส่งข้อยกเว้นเหล่านั้นกลับไปยังผู้เรียก

ดูข้อมูลเพิ่มเติมเกี่ยวกับการแยกย่อยแบบขนานได้ที่ การสร้างฟังก์ชันหยุดชั่วคราว

แนวคิดเกี่ยวกับโครูทีน

CoroutineScope

A CoroutineScope จะติดตามโครูทีนใดก็ตามที่สร้างขึ้นโดยใช้ launch หรือ async คุณสามารถยกเลิกงานที่กำลังดำเนินการอยู่ (เช่น โครูทีนที่กำลังทำงาน) ได้โดยเรียกใช้ scope.cancel() ทุกเมื่อ ใน Android ไลบรารี KTX บางรายการมี CoroutineScope ของตัวเองสำหรับคลาสวงจรการทำงานบางคลาส ตัวอย่างเช่น ViewModel มี viewModelScope, และ Lifecycle มี lifecycleScope. อย่างไรก็ตาม CoroutineScope จะไม่เรียกใช้โครูทีน ซึ่งแตกต่างจากตัวส่ง

viewModelScope ยังใช้ในตัวอย่างที่พบใน เทรดเบื้องหลังใน Android ด้วยโครูทีน อย่างไรก็ตาม หากคุณต้องการสร้าง CoroutineScope ของตัวเองเพื่อควบคุมวงจรการทำงานของโครูทีนในเลเยอร์หนึ่งๆ ของแอป คุณสามารถสร้างได้ดังนี้

class ExampleClass {

    // Job and Dispatcher are combined into a CoroutineContext which
    // will be discussed shortly
    val scope = CoroutineScope(Job() + Dispatchers.Main)

    fun exampleMethod() {
        // Starts a new coroutine within the scope
        scope.launch {
            // New coroutine that can call suspend functions
            fetchDocs()
        }
    }

    fun cleanUp() {
        // Cancel the scope to cancel ongoing coroutines work
        scope.cancel()
    }
}

ขอบเขตที่ยกเลิกแล้วจะสร้างโครูทีนเพิ่มเติมไม่ได้ ดังนั้นคุณควรเรียกใช้ scope.cancel() เฉพาะเมื่อคลาสที่ควบคุมวงจรการทำงานของขอบเขตนั้นถูกทำลาย เมื่อใช้ viewModelScope คลาส ViewModel จะยกเลิก ขอบเขตให้คุณโดยอัตโนมัติในเมธอด onCleared() ของ ViewModel

งาน

A Job คือตัวจัดการโครูทีน โครูทีนแต่ละรายการที่คุณสร้างด้วย launch หรือ async จะแสดงผลอินสแตนซ์ Job ที่ระบุโครูทีนอย่างไม่ซ้ำกันและจัดการวงจรการทำงานของโครูทีน นอกจากนี้ คุณยังส่ง Job ไปยัง CoroutineScope เพื่อจัดการวงจรการทำงานของโครูทีนเพิ่มเติมได้ ดังที่แสดงในตัวอย่างต่อไปนี้

class ExampleClass {
    // ...
    fun exampleMethod() {
        // Handle to the coroutine, you can control its lifecycle
        val job = scope.launch {
            // New coroutine
        }

        if (condition) {
            // Cancel the coroutine started above, this doesn't affect the scope
            // this coroutine was launched in
            job.cancel()
        }
    }
}

CoroutineContext

A CoroutineContext กำหนดลักษณะการทำงานของโครูทีนโดยใช้ชุดองค์ประกอบต่อไปนี้

  • Job: ควบคุมวงจรการทำงานของโครูทีน
  • CoroutineDispatcher: ส่งงานไปยังเทรดที่เหมาะสม
  • CoroutineName: ชื่อของโครูทีน ซึ่งมีประโยชน์สำหรับการแก้ไขข้อบกพร่อง
  • CoroutineExceptionHandler: จัดการข้อยกเว้นที่ไม่ได้ดักจับ

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

class ExampleClass {
    val scope = CoroutineScope(Job() + Dispatchers.Main)

    fun exampleMethod() {
        // Starts a new coroutine on Dispatchers.Main as it's the scope's default
        val job1 = scope.launch {
            // New coroutine with CoroutineName = "coroutine" (default)
        }

        // Starts a new coroutine on Dispatchers.Default
        val job2 = scope.launch(Dispatchers.Default + CoroutineName("BackgroundCoroutine")) {
            // New coroutine with CoroutineName = "BackgroundCoroutine" (overridden)
        }
    }
}

CoroutineExceptionHandler

แหล่งข้อมูลเพิ่มเติมเกี่ยวกับโครูทีน

ดูแหล่งข้อมูลเพิ่มเติมเกี่ยวกับโครูทีนได้จากลิงก์ต่อไปนี้