بهترین روش‌ها برای کوروتین‌ها در اندروید

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

تزریق کننده ها

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

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

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

توابع Suspend باید main-safe باشند، به این معنی که فراخوانی آنها از thread اصلی ایمن باشد. اگر یک کلاس عملیات مسدودسازی طولانی مدت را در یک coroutine انجام می‌دهد، وظیفه دارد با استفاده withContext اجرا را از thread اصلی خارج کند. این امر در مورد همه کلاس‌های برنامه شما، صرف نظر از بخشی از معماری که کلاس در آن قرار دارد، صدق می‌کند.

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)
    }
}

این الگو برنامه شما را مقیاس‌پذیرتر می‌کند، زیرا کلاس‌هایی که توابع suspend را فراخوانی می‌کنند، لازم نیست نگران این باشند که از چه Dispatcher برای چه نوع کاری استفاده کنند. این مسئولیت بر عهده کلاسی است که کار را انجام می‌دهد.

ViewModel باید کوروتین ایجاد کند.

کلاس‌های ViewModel باید ترجیح دهند به جای افشای توابع suspend برای انجام منطق کسب‌وکار، coroutine ایجاد کنند. توابع suspend در 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()
}

نماها (Views) نباید مستقیماً هیچ کوروتینی را برای انجام منطق کاری فعال کنند. در عوض، این مسئولیت را به 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)

    /* ... */
}

لایه داده و کسب و کار باید توابع و جریان‌های معلق را در معرض نمایش قرار دهد.

کلاس‌های موجود در لایه‌های داده و کسب‌وکار عموماً توابعی را برای انجام فراخوانی‌های یک‌باره یا اطلاع‌رسانی از تغییرات داده‌ها در طول زمان در معرض نمایش قرار می‌دهند. کلاس‌های موجود در این لایه‌ها باید توابع suspend را برای فراخوانی‌های یک‌باره و 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 توضیح داده شده است، برای کاری که نباید لغو شود، استفاده شود.

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 که به یک گراف ناوبری محدود شده است، مدیریت شود.

تزریق TestDispatcherها در تست‌ها

یک نمونه از TestDispatcher باید در کلاس‌های شما در تست‌ها تزریق شود. دو پیاده‌سازی در کتابخانه kotlinx-coroutines-test وجود دارد:

  • StandardTestDispatcher : کوروتین‌های شروع‌شده روی آن را با یک زمان‌بند در صف قرار می‌دهد و وقتی نخ آزمایشی مشغول نیست، آنها را اجرا می‌کند. می‌توانید نخ آزمایشی را به حالت تعلیق درآورید تا سایر کوروتین‌های صف‌بندی‌شده با استفاده از روش‌هایی مانند advanceUntilIdle اجرا شوند.

  • UnconfinedTestDispatcher : کوروتین‌های جدید را مشتاقانه و به صورت مسدودکننده اجرا می‌کند. این کار عموماً نوشتن تست‌ها را آسان‌تر می‌کند، اما کنترل کمتری بر نحوه اجرای کوروتین‌ها در طول تست به شما می‌دهد.

برای جزئیات بیشتر، به مستندات مربوط به هر پیاده‌سازی 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 باید یک زمان‌بند (scheduler) مشترک داشته باشند. این به شما امکان می‌دهد تمام کد کوروتین خود را روی یک نخ تست واحد اجرا کنید تا تست‌های شما قطعی شوند. runTest قبل از بازگشت، منتظر می‌ماند تا تمام کوروتین‌هایی که در یک زمان‌بند هستند یا فرزندان کوروتین تست هستند، تکمیل شوند.

از GlobalScope اجتناب کنید

این شبیه به بهترین روش Inject Dispatchers است. با استفاده از GlobalScope ، شما CoroutineScope را که یک کلاس استفاده می‌کند، hardcode می‌کنید و برخی از جنبه‌های منفی را نیز به همراه دارد:

  • مقادیر hard-coding را ترویج می‌دهد. اگر GlobalScope را hard-coding می‌کنید، ممکن است Dispatchers نیز hard-coding کنید.

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

  • شما نمی‌توانید یک CoroutineContext مشترک برای اجرا روی همه Coroutineهای ساخته شده در خود scope داشته باشید.

در عوض، تزریق یک 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 should not canceled مراجعه کنید .

کوروتین خود را قابل لغو کنید

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

برای مثال، اگر چندین فایل را از روی دیسک می‌خوانید، قبل از شروع خواندن هر فایل، بررسی کنید که آیا کوروتین لغو شده است یا خیر. یک راه برای بررسی لغو، فراخوانی تابع ensureActive است.

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

همه توابع suspend از 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
            }
        }
    }
}

برای اطلاعات بیشتر، به پست وبلاگ «استثنائات در کوروتین‌ها» یا «مدیریت استثنائات کوروتین» در مستندات کاتلین مراجعه کنید.

درباره کوروتین‌ها بیشتر بدانید

برای منابع بیشتر در مورد کوروتین‌ها، به صفحه منابع اضافی برای کوروتین‌ها و جریان‌های کاتلین مراجعه کنید.