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