Transform   هي جزء من Android Jetpack.

‫Transform هي مكتبة مبنية على وقت تشغيل Compose، وتُشغِّل كتلة @Composable بدون واجهة مستخدم لتحويل المدخلات إلى Compose State. عندما يتغيّر أي State تتم قراءته داخل الكتلة، تتم إعادة إنشاء الكتلة تلقائيًا وتحديث State الذي تم إرجاعه، ما يتيح لك استخدام نموذج تتبُّع التبعية وإعادة الإنشاء في Compose في ViewModels أو المستودعات أو الخدمات التي تُشغَّل في الخلفية.

حالات الاستخدام

  • تحويل البيانات التفاعلية: يمكنك تحويل مصادر البيانات الأولية (مثل استجابات الشبكة أو طلبات البحث في قاعدة البيانات) إلى حالة جاهزة لواجهة المستخدم.
  • دمج عدة مدخلات: يمكنك دمج عدة مصادر مستقلة للحالة (مثل إدخالات المستخدم والإعدادات وحالة الشبكة) في حالة واحدة متماسكة بدون عوامل تشغيل Flow معقّدة.
  • التحكّم في معدّل تكرار الأحداث والحدّ من معدّل نقل البيانات: يمكنك تنفيذ سلوك مستند إلى الوقت (مثل التحكّم في معدّل تكرار طلبات البحث) باستخدام التأثيرات الجانبية العادية في Compose مثل LaunchedEffect.
  • العمليات الشرطية: يمكنك تشغيل منطق مختلف لجمع البيانات استنادًا إلى الشروط الحالية باستخدام تعبيرات if-else أو when العادية.

نظرة عامة على واجهة برمجة التطبيقات

توفّر حزمة androidx.appstate.transform الدوال التالية:

package androidx.appstate.transform

import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers

public fun <R> transform(
    initialValue: R,
    scope: CoroutineScope,
    dispatcher: CoroutineDispatcher = Dispatchers.Default,
    onUpdate: @Composable (R) -> R,
): State<R>

@Composable
public fun <R> transform(
    initialValue: R,
    dispatcher: CoroutineDispatcher = Dispatchers.Default,
    onUpdate: @Composable (R) -> R,
): State<R>

public suspend fun listener(
    dispatcher: CoroutineDispatcher = Dispatchers.Default,
    onUpdate: @Composable () -> Unit,
)

إنشاء عملية تحويل

يمكنك إنشاء عمليات تحويل إما بمفردها (باستخدام CoroutineScope المقدَّم) أو ضمن تركيبة حالية. المَعلمات الرئيسية لأي تحويل هي initialValue وonUpdate. تتلقّى دالة lambda onUpdate قيمة الناتج السابقة R كمَعلمة (يتم ضبطها على initialValue في التركيب الأول). يمكنك عرض هذه المَعلمة للاحتفاظ بآخر حالة تم إرسالها بدلاً من إعادة الضبط على حالة التحميل، مثل { previous -> flow.collectAsState(initial = null).value ?: previous }.

على سبيل المثال، يمكنك استخدام transform لمراقبة التغييرات في سلسلة طلبات البحث وتقليل العمليات الحسابية المكلفة مثل طلبات الشبكة:

var query: String by mutableStateOf("")
val searchResults: List<String> by transform<List<String>>(
    initialValue = emptyList(),
    scope = coroutineScope,
) {
    var results by remember { mutableStateOf(emptyList<String>()) }
    LaunchedEffect(query) {
        if (query.isBlank()) {
            results = emptyList()
            return@LaunchedEffect
        }
        delay(300.milliseconds)
        results = api.search(query)
    }
    results
}

في هذا المقتطف:

  • يتم دعم query من خلال MutableState، والذي يمكن لدالة مركّبة لواجهة مستخدم شريط البحث قراءته وتحديثه.
  • تستخدم دالة transform initialValue لقائمة فارغة وCoroutineScope وonUpdate كدالة lambda الأخيرة.
  • داخل كتلة onUpdate، يحافظ remember على حالة results عبر عمليات إعادة الإنشاء أثناء تشغيل LaunchedEffect.
  • تتطلّب الدالة LaunchedEffect استخدام query كمفتاح. بما أنّ query يستند إلى MutableState، يتم إلغاء التأثير وإعادة تشغيله في كل مرة يتغيّر فيها query.
  • لتجنب استدعاء واجهة برمجة التطبيقات في كل ضغطة مفتاح، ينتظر نص التأثير 300 مللي ثانية. إذا تغير الاستعلام قبل اكتمال هذا التأخير، يتم إلغاء التأثير ولا يتم إجراء أي مكالمة شبكة.

داخل دالة @Composable، يمكنك أيضًا استخدام التحميل الزائد @Composable transform الذي يتذكر CoroutineScope داخليًا ويشغّل onUpdate على Dispatchers.Default تلقائيًا. يكون ذلك مفيدًا عندما تريد تفريغ عملية اشتقاق حالة مكلفة (مثل فلترة قائمة كبيرة أو البحث فيها) من سلسلة واجهة المستخدم الرئيسية:

@Composable
fun LogViewerScreen(
    entriesState: State<List<LogEntry>>,
    filterQueryState: State<String>,
) {
    // Calls rememberCoroutineScope() internally and runs onUpdate on
    // Dispatchers.Default so main-thread UI recomposition isn't blocked
    val matchingEntries by transform<List<LogEntry>>(
        initialValue = emptyList(),
        dispatcher = Dispatchers.Default,
    ) {
        val entries by entriesState
        val filterQuery by filterQueryState
        if (filterQuery.isBlank()) {
            entries
        } else {
            entries.filter { entry ->
                entry.matchesQuery(filterQuery)
            }
        }
    }
    // Render matchingEntries...
}

مراقبة الحالة باستخدام listener

عندما تريد التفاعل مع تغييرات State في مكوّن غير واجهة المستخدم (مثل مدير الحالة أو المستودع أو خدمة تُشغَّل في الخلفية) بدون إنشاء State<R> مشتق، استخدِم listener.

‫listener هي دالة suspend تُشغِّل مجموعة بيانات @Composable () -> Unit إلى أن يتم إلغاء الكوروتين الذي يستدعيها، وتعيد إنشاء العناصر تلقائيًا كلما تغيّرت أي حالة تتم قراءتها داخل onUpdate:

class FocusSessionManager(
    private val stateStore: StateStore,
    private val notificationManager: NotificationManager,
    scope: CoroutineScope,
) {
    val session: State<FocusSession> = stateStore.getState(FocusSessionKey)

    init {
        scope.launch {
            listener {
                when (val current = session.value) {
                    is FocusSession.Running -> {
                        LaunchedEffect(current.endTimeMillis) {
                            val remainingMillis =
                                current.endTimeMillis - System.currentTimeMillis()
                            if (remainingMillis > 0) {
                                delay(remainingMillis)
                            }
                            stateStore.setState(FocusSessionKey, FocusSession.Idle)
                            notificationManager.notify(
                                NOTIFICATION_ID,
                                buildSessionCompleteNotification(current.taskId),
                            )
                        }
                    }
                    FocusSession.Idle,
                    is FocusSession.Paused -> Unit
                }
            }
        }
    }
    // startFocus, pauseFocus, resumeFocus, endFocus...
}

في هذا المثال، يراقب "listener" "session.value". عندما تكون الجلسة FocusSession.Running، ينتظر LaunchedEffect حتى endTimeMillis قبل إعادة ضبط الجلسة على FocusSession.Idle ونشر إشعار بالاكتمال. إذا أوقف المستخدم الجلسة مؤقتًا أو أنهاها مبكرًا، يغادر فرع Running التركيب ويلغي الموقّت تلقائيًا. عند إلغاء scope، يتوقف listener عن المراقبة.

الأنماط الشائعة

توضّح الأنماط التالية كيفية استخدام transform للمنطق الشرطي، ودمج مصادر بيانات متعددة، وإنشاء حالة واجهة المستخدم في ViewModel.

إنتاج الحالة الشرطية

استخدِم تعبيرات Kotlin العادية if-else أو when داخل transform للتبديل بين مصادر الحالة الأولية استنادًا إلى الظروف الحالية. بما أنّ تتبُّع القراءة ديناميكي، لا يراقب transform سوى الفرع الذي يتم تشغيله في إعادة التركيب الحالية:

var searchQuery by mutableStateOf("")

val displayedTasks: State<List<Task>> = transform(
    initialValue = emptyList(),
    scope = coroutineScope,
) {
    if (searchQuery.isBlank()) {
        val recentTasks by tasksRepository.recentTasks.collectAsState(
            initial = emptyList(),
        )
        recentTasks
    } else {
        val matchingTasks by remember(searchQuery) {
            tasksRepository.searchTasks(searchQuery)
        }.collectAsState(initial = emptyList())
        matchingTasks
    }
}

دمج مصادر متعددة للحالة

بما أنّ Compose يتتبّع تلقائيًا عمليات قراءة الحالة، يمكنك دمج أي عدد من خصائص State<T> وتدفقات Flow في مجموعة transform واحدة. على عكس Flow.combine، التي توفّر فقط عمليات تحميل زائدة مكتوبة لما يصل إلى خمسة تدفقات قبل الرجوع إلى Array<Any?> غير مكتوب، فإن إضافة إدخال آخر في transform تكون دائمًا قراءة واحدة آمنة من النوع:

var showCompleted by mutableStateOf(false)
var selectedCategory by mutableStateOf<Category?>(null)

val dashboardState: State<DashboardUiState> = transform(
    initialValue = DashboardUiState(),
    scope = coroutineScope,
) {
    val tasks by tasksRepository.tasksFlow.collectAsState(initial = emptyList())
    val sortOrder by userPrefsRepository.sortOrderFlow.collectAsState(
        initial = SortOrder.BY_DEADLINE,
    )
    val focusSession by focusSessionManager.session
    val isOnline by networkMonitor.isOnline

    val visibleTasks = tasks
        .filter { task ->
            (showCompleted || !task.isCompleted) &&
                (selectedCategory == null || task.category == selectedCategory)
        }
        .sortedWith(sortOrder.comparator)

    DashboardUiState(
        tasks = visibleTasks,
        selectedCategory = selectedCategory,
        focusSession = focusSession,
        isOnline = isOnline,
    )
}

استخدام transform في ViewModel

تنفّذ دالة التحميل الزائد transform غير المركّبة منطقًا تفاعليًا في طبقات غير واجهة المستخدم (مثل ViewModels أو المستودعات) أثناء استخدام نموذج حالة Compose. ويتم تشغيلها طالما أنّ CoroutineScope المقدَّمة نشطة، لذا يمكنك ربطها بدورة حياة المكوّن من خلال تمرير viewModelScope داخل ViewModel:

class HomeViewModel(
    private val mediaRepository: MediaRepository,
    private val networkMonitor: NetworkMonitor,
) : ViewModel() {
    val uiState: State<HomeUiState> = transform<HomeUiState>(
        initialValue = HomeUiState.Loading,
        scope = viewModelScope,
    ) {
        val isOnline by networkMonitor.isOnline
        val tracks = if (isOnline) {
            mediaRepository.allTracksFlow.collectAsState(initial = null).value
        } else {
            mediaRepository.downloadedTracksFlow
                .collectAsState(initial = null).value
        } ?: return@transform HomeUiState.Loading

        HomeUiState.Success(
            tracks = tracks,
            isOnline = isOnline,
        )
    }
}

في ViewModel، يقرأ uiState networkMonitor.isOnline ويبدّل ديناميكيًا بين جمع mediaRepository.downloadedTracksFlow في وضع عدم الاتصال وmediaRepository.allTracksFlow في وضع الاتصال، ما يعرض State<HomeUiState> واحدًا يمكن للدوال المركّبة لواجهة المستخدم قراءته مباشرةً. لمزيد من التفاصيل حول الجمع بين transform وMutableState وSavedStateHandle.saveable، يُرجى الاطّلاع على استخدام حالة Compose في ViewModel.

اختَر بين transform وderivedStateOf وFlow

اتّبِع الإرشادات التالية للاختيار بين transform وderivedStateOf وFlow في Kotlin:

derivedStateOf ضد transform

derivedStateOf وtransform ينتجان State<T> مشتقًا يتتبّع تلقائيًا حالات Compose التي تتم قراءتها داخل الكتلة. يمكنك الاختيار بينهما استنادًا إلى ما إذا كنت تحتسب قيمة مباشرةً من عناصر State<T> الحالية أو تحتاج إلى ميزات @Composable، مثل collectAsState وproduceState وLaunchedEffect:

إمكانية derivedStateOf transform
نوع Lambda دالة lambda عادية في Kotlin ‏ (() -> T) ‫@Composable lambda (@Composable (R) -> R)
المدخلات المتوافقة قراءات State<T> بإذن القراءة فقط المتزامنة State<T>، وKotlin Flow (باستخدام collectAsState)، ودوال suspend، وواجهات برمجة التطبيقات لعمليات ردّ الاتصال (باستخدام produceState)
التنفيذ والاتساق يتم التحميل الكسول والمتزامن عند قراءة .value، ويتم التحديث بشكل ذري ضمن اللقطة نفسها بدون initialValue أو CoroutineScope تشغّل هذه الدالة تركيبة نشطة في CoroutineScope وتعيد تركيبها بشكل غير متزامن بعد تنفيذ لقطة، وتتطلّب initialValue
الحالة والتأثيرات الجانبية لا يمكن استدعاء remember أو واجهات برمجة التطبيقات ذات التأثير الجانبي يتيح استخدام دوال remember وLaunchedEffect وproduceState و@Composable القابلة لإعادة الاستخدام

استخدِم derivedStateOf عندما تكون جميع المدخلات عبارة عن عناصر State<T> ولا تحتاج إلى واجهات برمجة تطبيقات @Composable:

  • الاشتقاقات المتزامنة من اللقطة نفسها: احتساب قيمة أو قائمة مفلترة من سمات State<T> الحالية بدون إدارة CoroutineScope أو تقديم عنصر نائب initialValue. بما أنّ derivedStateOf يقيّم بشكل متزامن عند قراءة .value، فإنّ المتصلين الذين يقرأون .value فورًا بعد تعديل حالة الإدخال يلاحظون دائمًا النتيجة المعدَّلة في اللقطة نفسها.
  • تقليل معدّل تعديل المراقبين: احتساب قيمة تتغيّر بمعدّل أقل من مصدرها State<T> (مثل التحقّق مما إذا كانت إزاحة الانتقال تتجاوز الحدّ الأدنى) لمزيد من التفاصيل، يُرجى الاطّلاع على مستندات derivedStateOf.

استخدِم transform عندما يتطلّب الاشتقاق وقت تشغيل Compose:

  • الجمع بين State<T> والتدفقات غير المتزامنة: جمع Kotlin Flow من مستودع أو قاعدة بيانات باستخدام collectAsState إلى جانب مدخلات State<T> .
  • إعادة تشغيل العمل في المراحل السابقة عند تغيير المدخلات: استخدام remember(key) داخل الكتلة لإعادة إنشاء Flow معلَمي فقط عند تحديث إدخال State<T> محدّد.
  • تنفيذ التأثيرات الجانبية أو تذكُّر الحالة المتوسطة: استخدام LaunchedEffect (على سبيل المثال، لإلغاء ارتداد الإدخالات أو تشغيل الموقّتات)، أو produceState (لتحميل البيانات من دالة suspend أو التفاف رد اتصال باستخدام awaitDispose)، أو remember للاحتفاظ بالحالة في جميع عمليات إعادة الإنشاء.

يقارن المثال التالي بين derivedStateOf وكتلة transform التي تجمع بين State<T> ومستودع Flow:

class TaskListViewModel(
    private val tasksRepository: TasksRepository,
    private val preferencesManager: TaskPreferencesManager,
) : ViewModel() {
    var searchQuery by mutableStateOf("")
        private set

    // Computing a value from existing State<T>: use derivedStateOf
    val hasSearchQuery: State<Boolean> = derivedStateOf {
        searchQuery.isNotBlank()
    }

    // Combining State<T> with a Flow and remember(key): use transform
    val uiState: State<TaskListUiState> = transform<TaskListUiState>(
        initialValue = TaskListUiState.Loading,
        scope = viewModelScope,
    ) {
        val showCompleted by preferencesManager.showCompleted
        val tasks by remember(showCompleted) {
            tasksRepository.observeTasks(showCompleted)
        }.collectAsState(initial = null)

        val currentTasks = tasks ?: return@transform TaskListUiState.Loading
        val filteredTasks = currentTasks.filter { task ->
            task.title.contains(searchQuery, ignoreCase = true)
        }
        TaskListUiState.Success(filteredTasks)
    }
}

في هذا المثال، يفحص hasSearchQuery حالة searchQuery فقط، لذا يحتسبها derivedStateOf بشكل كسول بدون CoroutineScope. في المقابل، يجمع uiState بين preferencesManager.showCompleted وsearchQuery مع Flow غير متزامن من tasksRepository ويستخدم remember(showCompleted) لتجنب إعادة إنشاء Flow عندما يتغير searchQuery فقط.

حالات استخدام Flow بدلاً من transform

تم تصميم transform لتمثيل القيمة الحالية للحالة، وليس سلسلة من الأحداث. إذا تغيّرت حالة عدة مرات قبل إعادة الإنشاء التالية، يستخدم Compose القيمة الأخيرة فقط، ويتخطّى التحديثات عندما تكون القيمة الجديدة مساوية (==) للقيمة السابقة. استخدِم Kotlin Flow بدلاً من ذلك عندما يجب أن يعالج المنطق كل قيمة بالترتيب:

  • المجاميع والمتوسطات التراكمية: تتطلب العمليات الحسابية مثل المتوسطات المتحركة أو المجاميع التراكمية أو سجلّ التراجع (runningFold أو scan) احتساب كل قيمة مرة واحدة. في مجموعة transform، يتم تخطّي التعديلات السريعة قبل إعادة الإنشاء، ولا تؤدي القراءتان المتطابقتان على التوالي (مثل أداة استشعار تُصدر 10.0 مرتين) إلى إعادة الإنشاء، ويمكن أن تحتسب عمليات إعادة الإنشاء التي يتم تشغيلها بواسطة حالات أخرى في المجموعة نفسها القيمة الحالية مرتين. احسب القيمة الجارية في Flow أولاً، ثم اجمع النتيجة في transform إذا كنت بحاجة إلى دمجها مع حالة أخرى.
  • تسجيل الأحداث وقوائم الانتظار: مسارات البيانات التي تسجّل تفاعلات المستخدمين أو تعالج طلبات الشبكة في قائمة الانتظار أو تسجّل المعاملات التي يجب فيها تسجيل كل تغيير متوسط (A -> B -> C أو A -> B -> A).
  • الأحداث التي تحدث لمرة واحدة: إجراءات مثل تشغيل صوت أو إرسال أمر إلى الجهاز، حيث يجب تنفيذ الأمر نفسه مرتين متتاليتين. تتطلّب الدالة transform استخدام initialValue وتتخطّى النتائج المتساوية المتتالية.
  • عمليات البث الحساسة للضغط العكسي: عمليات البث (مثل قراءة الملفات أو المقابس) التي يجب أن يتوقف فيها المنتج السريع عندما يتأخر المستهلك. لا يتم تعليق عمليات كتابة حالة Compose، بل يتم استبدال القيم السابقة بدلاً من ذلك.

اختبار عمليات التحويل

بما أنّ عمليات التحويل تعتمد على وقت تشغيل Compose وKotlin Coroutines، يتطلّب اختبارها تنسيق التنفيذ غير المتزامن وإعادة تركيب الحالة. استخدِم العنصر androidx.appstate.transform:transform-testing للتحكّم في أداة جدولة الكوروتين وتشغيل عمليات إعادة الإنشاء في اختبارات الوحدات.

توفّر حزمة androidx.appstate.transform.testing واجهات برمجة التطبيقات التالية:

package androidx.appstate.transform.testing

import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
import kotlin.time.Duration
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.TestCoroutineScheduler
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.TestResult

public fun transformTest(
    context: CoroutineContext = EmptyCoroutineContext,
    block: suspend TransformTestScope.() -> Unit,
): TestResult

public sealed interface TransformTestScope : CoroutineScope {
    public val testCoroutineScheduler: TestCoroutineScheduler
    public val testDispatcher: TestDispatcher
    public val backgroundScope: CoroutineScope
    public fun advance()
    public fun advanceTimeBy(duration: Duration)
    public fun advanceUntilIdle()
}

transformTest wraps runTest and executes your test block with a TransformTestScope receiver:

  • ‫backgroundScope: CoroutineScope لتشغيل عمليات التحويل التي لا يجب أن تمنع إكمال الاختبار. ويتم إلغاؤه تلقائيًا عند انتهاء الاختبار، ما يؤدي إلى إيقاف عملية التحويل.
  • ‫testDispatcher: TestDispatcher مرتبط بـ testCoroutineScheduler. مرِّر هذه القيمة إلى مَعلمة dispatcher في transform ليتم تشغيل إعادة الإنشاء على أداة جدولة الاختبار بدلاً من استخدام Dispatchers.Default تلقائيًا.
  • advance: لتشغيل الكوروتينات في انتظار المراجعة على أداة جدولة الاختبار، وإرسال إشعارات تطبيق لقطة Compose‏ (Snapshot.sendApplyNotifications)، وإعادة إنشاء transform لتحديث الناتج State.
  • ‫advanceTimeBy(duration) وadvanceUntilIdle: لتقديم الساعة الافتراضية على testCoroutineScheduler للعمليات التي تعتمد على الوقت.

تعديلات حالة الاختبار

لاختبار transform، يجب تمرير backgroundScope وtestDispatcher من TransformTestScope، ثم استدعاء advance بعد تعديل حالة الإدخال أو Flow:

@Test
fun testStateUpdate() = transformTest {
    var sourceState by mutableStateOf(0)

    val state = transform(
        initialValue = 0,
        scope = backgroundScope,
        dispatcher = testDispatcher,
    ) {
        sourceState
    }
    assertThat(state.value).isEqualTo(0)

    // Modify the source state and advance to trigger recomposition.
    sourceState = 1
    advance()

    assertThat(state.value).isEqualTo(1)
}

اختبار العمليات المستندة إلى الوقت

عندما يستخدم transform تأثيرات جانبية مستندة إلى الوقت، مثل LaunchedEffect وdelay (مثل طلبات البحث التي يتم إلغاء ارتدادها)، ادمج advanceTimeBy(duration) مع advance:

  1. بعد تعديل حالة الإدخال، يمكنك استدعاء advance لكي يعيد المكوّن transform إنشاء نفسه ويبدأ LaunchedEffect.
  2. يمكنك استدعاء advanceTimeBy(duration) لتقديم الساعة الافتراضية، ثم استدعاء advance لتشغيل المهام المجدولة في ذلك الطابع الزمني وإعادة إنشاء transform.

@Test
fun testDebouncedSearch() = transformTest {
    var query by mutableStateOf("")

    val searchResults = transform<List<String>>(
        initialValue = emptyList(),
        scope = backgroundScope,
        dispatcher = testDispatcher,
    ) {
        var results by remember { mutableStateOf(emptyList<String>()) }
        LaunchedEffect(query) {
            if (query.isBlank()) {
                results = emptyList()
                return@LaunchedEffect
            }
            delay(300.milliseconds)
            results = api.search(query)
        }
        results
    }

    query = "compose"
    // Recompose to start the LaunchedEffect for the new query.
    advance()

    // Results don't update before the 300ms debounce delay completes.
    advanceTimeBy(200.milliseconds)
    advance()
    assertThat(searchResults.value).isEmpty()

    // Advance past the remaining 100ms and trigger recomposition.
    advanceTimeBy(100.milliseconds)
    advance()
    assertThat(searchResults.value).containsExactly("Compose State", "Compose UI")
}

اختبار ViewModel أو عنصر الاحتفاظ بالحالة

بما أنّ transform يشغّل Composition في scope المقدَّم ويضبط مَعلمة dispatcher تلقائيًا على Dispatchers.Default، يجب أن تقبل الفئات التي تنشئ transform مَعلمتَي CoroutineScope وCoroutineDispatcher كمَعلمتَي دالة إنشائية. في ViewModel، مرِّر CoroutineScope الذي تم حقنه إلى دالة الإنشاء ViewModel(coroutineScope) وأعِد توجيه CoroutineDispatcher إلى transform:

class HomeViewModel(
    private val mediaRepository: MediaRepository,
    private val networkMonitor: NetworkMonitor,
    coroutineScope: CoroutineScope =
        CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate),
    dispatcher: CoroutineDispatcher = Dispatchers.Default,
) : ViewModel(coroutineScope) {
    val uiState: State<HomeUiState> = transform<HomeUiState>(
        initialValue = HomeUiState.Loading,
        scope = viewModelScope,
        dispatcher = dispatcher,
    ) {
        val isOnline by networkMonitor.isOnline
        val tracks = if (isOnline) {
            mediaRepository.allTracksFlow.collectAsState(initial = null).value
        } else {
            mediaRepository.downloadedTracksFlow
                .collectAsState(initial = null).value
        } ?: return@transform HomeUiState.Loading

        HomeUiState.Success(
            tracks = tracks,
            isOnline = isOnline,
        )
    }
}

@Test
fun testHomeViewModelOfflineState() = transformTest {
    val mediaRepository = FakeMediaRepository()
    val networkMonitor = FakeNetworkMonitor()
    val viewModel = HomeViewModel(
        mediaRepository = mediaRepository,
        networkMonitor = networkMonitor,
        coroutineScope = backgroundScope,
        dispatcher = testDispatcher,
    )

    val downloadedTrack = Track(id = "1", isDownloaded = true)
    val streamingTrack = Track(id = "2", isDownloaded = false)
    mediaRepository.emitAllTracks(listOf(downloadedTrack, streamingTrack))
    mediaRepository.emitDownloadedTracks(listOf(downloadedTrack))
    advance()
    assertThat(viewModel.uiState.value).isEqualTo(
        HomeUiState.Success(
            tracks = listOf(downloadedTrack, streamingTrack),
            isOnline = true,
        )
    )

    // Switch offline and verify it switches to downloadedTracksFlow.
    networkMonitor.setOnline(false)
    // First advance() recomposes into the offline branch (where collectAsState
    // initially returns null -> Loading) and starts collecting downloadedTracksFlow.
    advance()
    // Second advance() applies the collected downloadedTracksFlow value and
    // recomposes uiState with the offline Success state.
    advance()
    assertThat(viewModel.uiState.value).isEqualTo(
        HomeUiState.Success(
            tracks = listOf(downloadedTrack),
            isOnline = false,
        )
    )
}

في هذا الاختبار، يؤدي تمرير backgroundScope وtestDispatcher إلى HomeViewModel إلى ربط viewModelScope وtransform Composition بأداة جدولة الاختبار، وبالتالي يعالج كل استدعاء advance بشكل حتمي الانبعاثات من FakeMediaRepository وFakeNetworkMonitor. لمزيد من الأنماط حول استخدام SavedStateHandle.saveable وSnapshot.withMutableSnapshot في ViewModel، يُرجى الاطّلاع على استخدام حالة Compose في ViewModel.