בדף הזה מפורטות כמה שיטות מומלצות שיכולות לשפר את יכולת ההרחבה של האפליקציה ואת יכולת הבדיקה שלה כשמשתמשים ב-coroutines.
החדרת רכיבי Dispatcher
אל תקודדו קוד קשיח של 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 כדי להפוך את הבדיקות לדטרמיניסטיות יותר.
אפשר להפעיל פונקציות השהיה מה-thread הראשי
פונקציות השהיה צריכות להיות בטוחות לשימוש ב-thread הראשי. אם מחלקה מבצעת פעולות חסימה ממושכות בקורוטינה, היא אחראית להעברת הביצוע מחוץ לשרשור הראשי באמצעות 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, במקום להשתמש בבדיקות מכשור שנדרשות לבדיקת תצוגות.
בנוסף, שגרות ההמשך (coroutine) ימשיכו להתקיים אוטומטית אחרי שינויים בהגדרות אם הן יופעלו ב-viewModelScope. אם יוצרים קורוטינות באמצעות lifecycleScope, צריך לטפל בזה באופן ידני.
אם שגרת המשך (coroutine) צריכה להיות פעילה גם אחרי סיום ההיקף של ViewModel, כדאי לעיין בקטע יצירת שגרות המשך (coroutine) בשכבת העסק ושכבת הנתונים.
אל תחשפו סוגים שניתנים לשינוי
עדיף לחשוף סוגים בלתי ניתנים לשינוי לכיתות אחרות. כך, כל השינויים בסוג הניתן לשינוי מרוכזים במחלקה אחת, מה שמקל על איתור באגים אם משהו משתבש.
// 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) /* ... */ }
שכבת הנתונים והשכבה העסקית צריכות לחשוף פונקציות השעיה ו-Flows
בדרך כלל, מחלקות בשכבות הנתונים והעסקים חושפות פונקציות לביצוע קריאות חד-פעמיות או לקבלת התראות על שינויים בנתונים לאורך זמן. במקרים כאלה, המחלקות בשכבות צריכות לחשוף פונקציות השהיה לקריאות חד-פעמיות ו-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 חיצוני, כמו שמוסבר בפוסט בבלוג בנושא קורוטינות ותבניות לעבודה שלא אמורה להתבטל.
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 לכיתות בבדיקות. יש שתי הטמעות זמינות בספרייה kotlinx-coroutines-test:
StandardTestDispatcher: מכניס לתור קורוטינות שהופעלו בו באמצעות מתזמן, ומבצע אותן כשהשרשור של הבדיקה לא עמוס. אפשר להשהות את השרשור של הבדיקה כדי לאפשר לקורוטינות אחרות בתור לפעול באמצעות שיטות כמוadvanceUntilIdle.
UnconfinedTestDispatcher: מפעיל קורוטינות חדשות באופן מיידי, בצורה חוסמת. בדרך כלל קל יותר לכתוב בדיקות, אבל יש לכם פחות שליטה על האופן שבו קורוטינות מופעלות במהלך הבדיקה.
פרטים נוספים זמינים במסמכי התיעוד של כל הטמעה של רכיב השליחה.
כדי לבדוק קורוטינות, משתמשים בבונה הקורוטינות 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 צריכים לחלוק את אותו מתזמן. כך אפשר להריץ את כל קוד שגרות ההמשך (coroutine) בשרשור הבדיקה היחיד כדי שהבדיקות יהיו דטרמיניסטיות. runTest ימתין לסיום של כל הקורוטינות שנמצאות באותו מתזמן או שהן צאצאים של קורוטינת הבדיקה לפני שיחזיר ערך.
הימנעות מ-GlobalScope
זה דומה לשיטה המומלצת הוספת רכיבי Dispatcher. כשמשתמשים ב-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) הוא שיתופי, כלומר כשמבטלים שגרת המשך (coroutine) Job, שגרת המשך (coroutine) לא מבוטלת עד שהיא מושהית או עד שהיא בודקת אם יש ביטול. אם מבצעים פעולות חסימה בשגרת המשך (coroutine), צריך לוודא ששגרת המשך (coroutine) ניתנת לביטול.
לדוגמה, אם קוראים כמה קבצים מהדיסק, לפני שמתחילים לקרוא כל קובץ, צריך לבדוק אם הקורוטינה בוטלה. אחת הדרכים לבדוק אם יש ביטול היא להפעיל את הפונקציה ensureActive.
someScope.launch { for (file in files) { ensureActive() // Check for cancellation readFile(file) } }
אפשר לבטל את כל פונקציות ההשעיה מ-kotlinx.coroutines, כמו withContext ו-delay. אם שגרת ההמשך קוראת להן, לא צריך לבצע פעולות נוספות.
מידע נוסף על ביטול בקורוטינות זמין בפוסט בבלוג בנושא ביטול בקורוטינות.
שימו לב לחריגים
חריגים שלא טופלו שנוצרו ב-coroutines עלולים לגרום לקריסת האפליקציה. אם צפויים חריגים, כדאי לטפל בהם בגוף של כל קורוטינה שנוצרה באמצעות 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 } } } }
מידע נוסף זמין בפוסט בבלוג בנושא Exceptions in coroutines או במאמר Coroutine exceptions handling במסמכי התיעוד של Kotlin.
מידע נוסף על קורוטינות
מידע נוסף על קורוטינות זמין במדריך לקורוטינות במסמכי התיעוד של Kotlin.