روال‌های مطلوب برای coroutines در Android

این صفحه چندین روال مطلوب را ارائه می‌دهد که با مقیاس‌پذیرتر و آزمایش‌پذیرتر کردن برنامه شما هنگام استفاده از روتین‌های همکار، تأثیر مثبتی دارند.

تزریق توزیع‌کننده‌ها

هنگام ایجاد روتین‌های هم‌زمان جدید یا فراخوانی withContext، Dispatchers را کدبندی سخت نکنید.

// 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) { /* ... */ }
}

این الگوی تزریق وابستگی آزمایش را آسان‌تر می‌کند زیرا می‌توانید آن توزیع‌کننده‌ها را در آزمون‌های واحد و ابزار دقیق با توزیع‌کننده آزمون جایگزین کنید تا آزمون‌هایتان قطعی‌تر شوند.

فراخوانی توابع تعلیق باید از رشته اصلی ایمن باشد

توابع تعلیق باید main-safe باشند، یعنی فراخوانی آن‌ها از رشته اصلی ایمن باشد. اگر کلاسی در روتین همکار عملیات مسدودکننده طولانی‌مدت انجام می‌دهد، مسئولیت دارد بااستفاده از 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)

    /* ... */
}

لایه داده و کسب‌وکار باید توابع تعلیق و «جریان‌ها» را آشکار کند

کلاس‌های موجود در لایه‌های داده و کسب‌وکار معمولاً توابعی را برای انجام تماس‌های یک‌باره یا برای مطلع شدن از تغییرات داده در طول زمان آشکار می‌کنند. کلاس‌های موجود در آن لایه‌ها باید توابع تعلیق برای تماس‌های یک‌باره و جریان برای اعلام تغییرات داده‌ها را آشکار کنند.

// 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 باید زمان‌بند یکسانی داشته باشند. این کار به شما امکان می‌دهد همه کد روتین همکار خود را در رشته آزمایشی واحدی اجرا کنید تا آزمون‌هایتان قطعی شوند. ‫runTest قبل‌از بازگشت منتظر می‌ماند تا همه روتین‌های همکار که در زمان‌بند یکسان هستند یا فرزندان روتین همکار آزمایشی هستند تکمیل شوند.

پرهیز از GlobalScope

این شبیه به روال مطلوب تزریق توزیع‌کننده‌ها است. بااستفاده از 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 و جایگزین‌های آن بیشتر بدانید.

امکان لغو کردن روتین همکار را فراهم کنید

لغو در روتین‌های همکارانه مشارکتی است، یعنی وقتی 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 مراجعه کنید.