أفضل الممارسات الخاصة بالكوروتينات في Android

تعرض هذه الصفحة عدة أفضل ممارسات لها تأثير إيجابي من خلال جعل تطبيقك أكثر قابلية للتوسّع والاختبار عند استخدام الكوروتينات.

إدخال أدوات إرسال

لا تستخدم 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) { /* ... */ }
}

يسهّل نمط إدخال التبعية هذا عملية الاختبار، إذ يمكنك استبدال أدوات الإرسال هذه في اختبارات الوحدات واختبارات الأجهزة بأداة إرسال اختبار لجعل اختباراتك أكثر تحديدًا.

يجب أن يكون من الآمن استدعاء الدوال المعلقة من سلسلة التعليمات الرئيسية

يجب أن تكون الدوال المعلقة آمنة للاستخدام في سلسلة التعليمات الرئيسية، ما يعني أنّه من الآمن استدعاؤها من سلسلة التعليمات الرئيسية. إذا كانت فئة معيّنة تجري عمليات حظر طويلة الأمد في كوروتين، تكون مسؤولة عن نقل التنفيذ خارج سلسلة التعليمات الرئيسية باستخدام 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 خارجي كما هو موضّح في منشور المدونة الكوروتينات والأنماط للعمل الذي لا يجب إلغاؤه.

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 coroutine builder. 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.

مزيد من المعلومات عن الكوروتينات

لمزيد من موارد الكوروتينات، يمكنك الاطّلاع على صفحة موارد إضافية للكوروتينات وFlow في Kotlin.