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

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

จ่ายงานฉีด

อย่าฮาร์ดโค้ด 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) { /* ... */ }
}

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

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

ฟังก์ชันการระงับควรปลอดภัยเป็นหลัก ซึ่งหมายความว่าจะเรียกใช้จาก เทรดหลัก หากชั้นเรียนดำเนินการบล็อกเป็นเวลานานใน coroutine มีหน้าที่ย้ายการดำเนินการออกจากเทรดหลักโดยใช้ 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(): List<ArticleWithAuthor> {
        val news = newsRepository.fetchLatestNews()

        val response: List<ArticleWithAuthor> = mutableEmptyList()
        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)

    /* ... */
}

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

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

// 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: เรียกใช้โครูทีนใหม่อย่างตั้งใจในการบล็อก ซึ่งมักจะทำให้การเขียน ทดสอบได้ง่ายขึ้น แต่ทำให้คุณควบคุมลักษณะของโครูทีนได้น้อยลง ดำเนินการระหว่างการทดสอบ

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

ในการทดสอบโครูทีน ให้ใช้ runTest เครื่องมือสร้าง Coroutine 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,
            testDispatcher
        )
        val article = Article()
        repository.bookmarkArticle(article)
        assertThat(articlesDataSource.isBookmarked(article)).isTrue()
    }
}

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

ทำให้โครูทีนไม่สามารถเก็บได้

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

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

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

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

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

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

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

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

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

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

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

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