أفضل الممارسات الخاصة بالكوروتينات في 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(): List<ArticleWithAuthor> {
        val news = newsRepository.fetchLatestNews()

        val response: List<ArticleWithAuthor> = mutableEmptyList()
        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)

    /* ... */
}

ينبغي أن تعرض طبقة البيانات والأنشطة التجارية دوال التعليق والتدفقات

تُعرّض الفئات في طبقات البيانات والعمل عمومًا الوظائف لإجراء استدعاءات بنقرة واحدة أو لتلقّي إشعارات بتغييرات البيانات بمرور الوقت. ومن المفترض أن تكشف الفئات في تلك الطبقات وظائف التعليق لاستدعاءات بنقرة واحدة و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 على نطاق رسم بياني للتنقّل.

إدخال مُرسلي الاختبار في الاختبارات

يجب إدخال مثال 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,
            testDispatcher
        )
        val article = Article()
        repository.bookmarkArticle(article)
        assertThat(articlesDataSource.isBookmarked(article)).isTrue()
    }
}

يجب أن تشترك جميع TestDispatchers في أداة جدولة المهام نفسها. يتيح لك ذلك تشغيل كل رموز الكوروتين على سلسلة الاختبار الفردية من أجل تقديم اختبارات حتمية. سينتظر runTest حتى يتم إكمال كل الكوروتينات الموجودة على نفس الجدولة أو الأطفال من كوروتين الاختبار قبل العودة.

تجنب النطاق العالمي

يُعد هذا الإجراء مشابهًا لأفضل ممارسات إدخال المُرسِلين. من خلال استخدام 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)
                // Notify view user logged in successfully
            } catch (exception: IOException) {
                // Notify view login attempt failed
            }
        }
    }
}

لمزيد من المعلومات، يمكنك الاطّلاع على مشاركة المدوّنة الاستثناءات في الكوروتينات أو التعامل مع استثناءات الكوروتين في مستندات Kotlin.

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

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