בדף הזה מפורטות כמה שיטות מומלצות שיכולות לשפר את יכולת ההרחבה של האפליקציה ואת יכולת הבדיקה שלה כשמשתמשים ב-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 הראשי. אם מחלקה מבצעת פעולות חסימה ארוכות ב-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(): 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) /* ... */ }
שכבת הנתונים והשכבה העסקית צריכות לחשוף פונקציות השעיה ו-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 חיצוני, כמו שמוסבר בפוסט בבלוג Coroutines & Patterns for work that shouldn’t be cancelled.
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: מוסיף לתור קורוטינות שהופעלו בו באמצעות מתזמן, ומבצע אותן כשהשרשור של הבדיקה לא עמוס. אפשר להשהות את השרשור של הבדיקה כדי לאפשר לקורוטינות אחרות בתור לפעול באמצעות methods כמו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 צריך להיות מתזמן משותף. כך תוכלו להריץ את כל קוד הקורוטינה בשרשור הבדיקה היחיד כדי שהבדיקות יהיו דטרמיניסטיות. runTest ימתין לסיום של כל הקורוטינות שנמצאות באותו מתזמן או שהן צאצאות של קורוטינת הבדיקה לפני שיחזיר ערך.
הימנעות משימוש ב-GlobalScope
זה דומה לשיטה המומלצת הוספת 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.
הגדרת קורוטינה שניתנת לביטול
ביטול בקורוטינות הוא שיתופי, כלומר כשקורוטינה Job מבוטלת, היא לא מבוטלת עד שהיא מושהית או עד שהיא בודקת אם היא בוטלה. אם מבצעים פעולות חסימה בקורוטינה, צריך לוודא שהקורוטינה ניתנת לביטול.
לדוגמה, אם קוראים כמה קבצים מהדיסק, לפני שמתחילים לקרוא כל קובץ, צריך לבדוק אם הקורוטינה בוטלה. אחת הדרכים לבדוק אם יש ביטול היא להפעיל את הפונקציה 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.
מידע נוסף על קורוטינות
במאמר משאבים נוספים בנושא קורוטינות ו-Flow ב-Kotlin אפשר למצוא מידע נוסף על קורוטינות.