แนวทางปฏิบัติแนะนำสำหรับโครูทีนใน Android

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

Inject Dispatchers

อย่าฮาร์ดโค้ด Dispatchers เมื่อสร้างโครูทีนใหม่หรือเรียกใช้ withContext

// DO inject Dispatchers
class NewsRepository(
    private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default
) {
    suspend fun loadNews() = withContext(defaultDispatcher) { /* ... */ }
}

// DO NOT hardcode Dispatchers
class NewsRepository {
    // DO NOT use Dispatchers.Default directly, inject it instead
    suspend fun loadNews() = withContext(Dispatchers.Default) { /* ... */ }
}

รูปแบบการแทรกการอ้างอิงนี้ช่วยให้การทดสอบง่ายขึ้น เนื่องจากคุณสามารถแทนที่ Dispatcher เหล่านั้นในการทดสอบหน่วยและการทดสอบเครื่องมือด้วย Test Dispatcher เพื่อให้การทดสอบมีความแน่นอนมากขึ้น

ฟังก์ชันระงับควรเรียกใช้จากเทรดหลักได้อย่างปลอดภัย

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

class NewsRepository(private val ioDispatcher: CoroutineDispatcher) {

    // As this operation is manually retrieving the news from the server
    // using a blocking HttpURLConnection, it needs to move the execution
    // to an IO dispatcher to make it main-safe
    suspend fun fetchLatestNews(): List<Article> {
        withContext(ioDispatcher) { /* ... implementation ... */ }
    }
}

// This use case fetches the latest news and the associated author.
class GetLatestNewsWithAuthorsUseCase(
    private val newsRepository: NewsRepository,
    private val authorsRepository: AuthorsRepository
) {
    // This method doesn't need to worry about moving the execution of the
    // coroutine to a different thread as newsRepository is main-safe.
    // The work done in the coroutine is lightweight as it only creates
    // a list and add elements to it
    suspend operator fun invoke(): Result<List<ArticleWithAuthor>> {
        val news = newsRepository.fetchLatestNews()

        val response = mutableListOf<ArticleWithAuthor>()
        for (article in news) {
            val author = authorsRepository.getAuthor(article.author)
            response.add(ArticleWithAuthor(article, author))
        }
        return Result.Success(response)
    }
}

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

ViewModel ควรสร้างโครูทีน

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

// DO create coroutines in the ViewModel
class LatestNewsViewModel(
    private val getLatestNewsWithAuthors: GetLatestNewsWithAuthorsUseCase
) : ViewModel() {

    private val _uiState = MutableStateFlow<LatestNewsUiState>(LatestNewsUiState.Loading)
    val uiState: StateFlow<LatestNewsUiState> = _uiState

    fun loadNews() {
        viewModelScope.launch {
            val latestNewsWithAuthors = getLatestNewsWithAuthors()
            _uiState.value = LatestNewsUiState.Success(latestNewsWithAuthors)
        }
    }
}

// Prefer observable state rather than suspend functions from the ViewModel
class LatestNewsViewModel(
    private val getLatestNewsWithAuthors: GetLatestNewsWithAuthorsUseCase
) : ViewModel() {
    // DO NOT do this. News would probably need to be refreshed as well.
    // Instead of exposing a single value with a suspend function, news should
    // be exposed using a stream of data as in the code snippet above.
    suspend fun loadNews() = getLatestNewsWithAuthors()
}

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

นอกจากนี้ โครูทีนจะยังคงอยู่แม้จะมีการเปลี่ยนแปลงการกำหนดค่า โดยอัตโนมัติหากเริ่มงานใน viewModelScope หากสร้างโครูทีนโดยใช้ lifecycleScope แทน คุณจะต้องจัดการด้วยตนเอง หากโครูทีนต้องมีอายุการใช้งานนานกว่าขอบเขตของ ViewModel ให้ดูส่วนการสร้างโครูทีนในเลเยอร์ธุรกิจและเลเยอร์ข้อมูล

อย่าแสดงประเภทที่เปลี่ยนแปลงได้

ควรแสดงประเภทที่เปลี่ยนแปลงไม่ได้ต่อคลาสอื่นๆ ด้วยวิธีนี้ การเปลี่ยนแปลงทั้งหมดในประเภทที่เปลี่ยนแปลงได้จะรวมอยู่ในคลาสเดียว ซึ่งทำให้แก้ไขข้อบกพร่องได้ง่ายขึ้นเมื่อมีข้อผิดพลาดเกิดขึ้น

// DO expose immutable types
class LatestNewsViewModel : ViewModel() {

    private val _uiState = MutableStateFlow(LatestNewsUiState.Loading)
    val uiState: StateFlow<LatestNewsUiState> = _uiState

    /* ... */
}

class LatestNewsViewModel : ViewModel() {

    // DO NOT expose mutable types
    val uiState = MutableStateFlow(LatestNewsUiState.Loading)

    /* ... */
}

เลเยอร์ข้อมูลและเลเยอร์ธุรกิจควรแสดงฟังก์ชันระงับและโฟลว์

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

// Classes in the data and business layer expose
// either suspend functions or Flows
class ExampleRepository {
    suspend fun makeNetworkRequest() { /* ... */ }

    fun getExamples(): Flow<Example> {
        /* ... */
    }
}

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

การสร้างโครูทีนในเลเยอร์ธุรกิจและเลเยอร์ข้อมูล

สำหรับคลาสในเลเยอร์ข้อมูลหรือเลเยอร์ธุรกิจที่ต้องสร้างโครูทีนด้วย เหตุผลที่แตกต่างกัน จะมีตัวเลือกที่แตกต่างกัน

หากงานที่จะทำในโครูทีนเหล่านั้นเกี่ยวข้องเฉพาะเมื่อผู้ใช้ อยู่บนหน้าจอปัจจุบัน โครูทีนควรทำตามวงจรของโครูทีนที่เรียกใช้ ในกรณีส่วนใหญ่ ผู้เรียกใช้จะเป็น ViewModel และระบบจะยกเลิกการเรียกใช้เมื่อ ผู้ใช้ออกจากหน้าจอและล้าง ViewModel ในกรณีนี้ ควรใช้ coroutineScope หรือ supervisorScope

class GetAllBooksAndAuthorsUseCase(
    private val booksRepository: BooksRepository,
    private val authorsRepository: AuthorsRepository,
) {
    suspend fun getBookAndAuthors(): BookAndAuthors {
        // In parallel, fetch books and authors and return when both requests
        // complete and the data is ready
        return coroutineScope {
            val books = async { booksRepository.getAllBooks() }
            val authors = async { authorsRepository.getAllAuthors() }
            BookAndAuthors(books.await(), authors.await())
        }
    }
}

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

class ArticlesRepository(
    private val articlesDataSource: ArticlesDataSource,
    private val externalScope: CoroutineScope,
) {
    // As we want to complete bookmarking the article even if the user moves
    // away from the screen, the work is done creating a new coroutine
    // from an external scope
    suspend fun bookmarkArticle(article: Article) {
        externalScope.launch { articlesDataSource.bookmarkArticle(article) }
            .join() // Wait for the coroutine to complete
    }
}

externalScope ควรสร้างและจัดการโดยคลาสที่มีอายุการใช้งานนานกว่า หน้าจอปัจจุบัน โดยอาจจัดการโดยคลาส Application หรือ ViewModel ที่กำหนดขอบเขตไว้ในกราฟการนำทาง

แทรก TestDispatchers ในการทดสอบ

ควรแทรกอินสแตนซ์ของ TestDispatcher ลงในคลาสในการทดสอบ โดยมี 2 การติดตั้งใช้งานในไลบรารี kotlinx-coroutines-test ดังนี้

  • StandardTestDispatcher: จัดคิวโครูทีนที่เริ่มต้นในนั้นด้วยตัวจัดกำหนดการ และเรียกใช้ เมื่อเธรดการทดสอบไม่ว่าง คุณสามารถระงับเธรดการทดสอบเพื่อให้ โครูทีนอื่นๆ ที่อยู่ในคิวทำงานได้โดยใช้วิธีการต่างๆ เช่น advanceUntilIdle

  • UnconfinedTestDispatcher: เรียกใช้โครูทีนใหม่ทันทีในลักษณะการบล็อก โดยทั่วไปแล้ว วิธีนี้จะช่วยให้เขียน การทดสอบได้ง่ายขึ้น แต่จะควบคุมได้น้อยลงว่าโครูทีนจะ ทำงานอย่างไรในระหว่างการทดสอบ

ดูรายละเอียดเพิ่มเติมได้ในเอกสารประกอบของการติดตั้งใช้งาน Dispatcher แต่ละรายการ

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

class ArticlesRepositoryTest {

    @Test
    fun testBookmarkArticle() = runTest {
        // Pass the testScheduler provided by runTest's coroutine scope to
        // the test dispatcher
        val testDispatcher = UnconfinedTestDispatcher(testScheduler)

        val articlesDataSource = FakeArticlesDataSource()
        val repository = ArticlesRepository(
            articlesDataSource,
            defaultDispatcher = testDispatcher
        )
        val article = Article()
        repository.bookmarkArticle(article)
        assertThat(articlesDataSource.isBookmarked(article)).isTrue()
    }
}

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

หลีกเลี่ยง GlobalScope

ซึ่งคล้ายกับแนวทางปฏิบัติแนะนำของ Inject Dispatchers การใช้ GlobalScope จะทำให้คุณฮาร์ดโค้ด CoroutineScope ที่คลาสใช้ ซึ่งอาจมีข้อเสียบางประการ ดังนี้

  • ส่งเสริมการฮาร์ดโค้ดค่า หากคุณทำฮาร์ดโค้ด GlobalScope คุณอาจทำฮาร์ดโค้ด Dispatchers ด้วย

  • ทำให้การทดสอบเป็นเรื่องยากมากเนื่องจากโค้ดของคุณจะดำเนินการในขอบเขตที่ควบคุมไม่ได้ คุณจึงควบคุมการดำเนินการไม่ได้

  • คุณไม่สามารถมี CoroutineContext ทั่วไปเพื่อเรียกใช้สำหรับโครูทีนทั้งหมด ที่สร้างขึ้นในขอบเขตเอง

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

// DO inject an external scope instead of using GlobalScope.
// GlobalScope can be used indirectly. Here as a default parameter makes sense.
class ArticlesRepository(
    private val articlesDataSource: ArticlesDataSource,
    private val externalScope: CoroutineScope = GlobalScope,
    private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default
) {
    // As we want to complete bookmarking the article even if the user moves
    // away from the screen, the work is done creating a new coroutine
    // from an external scope
    suspend fun bookmarkArticle(article: Article) {
        externalScope.launch(defaultDispatcher) {
            articlesDataSource.bookmarkArticle(article)
        }
            .join() // Wait for the coroutine to complete
    }
}

// DO NOT use GlobalScope directly
class ArticlesRepository(
    private val articlesDataSource: ArticlesDataSource,
) {
    // As we want to complete bookmarking the article even if the user moves away
    // from the screen, the work is done creating a new coroutine with GlobalScope
    suspend fun bookmarkArticle(article: Article) {
        GlobalScope.launch {
            articlesDataSource.bookmarkArticle(article)
        }
            .join() // Wait for the coroutine to complete
    }
}

ดูข้อมูลเพิ่มเติมเกี่ยวกับ GlobalScope และทางเลือกอื่นๆ ได้ใน บล็อกโพสต์ Coroutines & Patterns for work that shouldn’t be cancelled

ทำให้ Coroutine ยกเลิกได้

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

เช่น หากคุณอ่านไฟล์หลายไฟล์จากดิสก์ ก่อนที่จะเริ่มอ่านแต่ละไฟล์ ให้ตรวจสอบว่ามีการยกเลิกโครูทีนหรือไม่ วิธีหนึ่งในการ ตรวจสอบการยกเลิกคือการเรียกใช้ฟังก์ชัน ensureActive

someScope.launch {
    for (file in files) {
        ensureActive() // Check for cancellation
        readFile(file)
    }
}

คุณยกเลิกฟังก์ชันระงับทั้งหมดจาก kotlinx.coroutines เช่น withContext และ delay ได้ หากโครูทีนเรียกใช้ฟังก์ชันเหล่านี้ คุณก็ไม่จำเป็นต้องดำเนินการใดๆ เพิ่มเติม

ดูข้อมูลเพิ่มเติมเกี่ยวกับการยกเลิกในโครูทีนได้ที่บล็อกโพสต์เกี่ยวกับการยกเลิกในโครูทีน

ระวังข้อยกเว้น

ข้อยกเว้นที่ไม่ได้จัดการซึ่งเกิดขึ้นในโครูทีนอาจทำให้แอปขัดข้อง หากมีแนวโน้มที่จะเกิดข้อยกเว้น ให้ดักจับข้อยกเว้นเหล่านั้นในเนื้อหาของโครูทีนที่สร้างด้วย viewModelScope หรือ lifecycleScope

class LoginViewModel(
    private val loginRepository: LoginRepository
) : ViewModel() {

    fun login(username: String, token: String) {
        viewModelScope.launch {
            try {
                loginRepository.login(username, token)
                // Update UI, user logged in successfully
            } catch (exception: IOException) {
                // Update UI, login attempt failed
            }
        }
    }
}

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

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

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