تبدیل کردن   بخشی از 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 است. onUpdate lambda مقدار برونداد قبلی 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 پشتیبانی می‌شود که یک نوار جستجوی UI composable می‌تواند آن را بخواند و به‌روز کند.
  • ‫transform یک initialValue از فهرست خالی، یک CoroutineScope، و onUpdate را به‌عنوان لامبدا دنباله‌دار می‌گیرد.
  • درون بلوک onUpdate، remember حالت results را در سراسر بازترکیب‌ها حفظ می‌کند درحالی‌که LaunchedEffect اجرا می‌شود.
  • LaunchedEffect query را به‌عنوان کلید می‌گیرد. چون query توسط MutableState پشتیبانی می‌شود، هربار که query تغییر می‌کند، جلوه لغو و دوباره راه‌اندازی می‌شود.
  • برای جلوگیری از فراخوانی میانای برنامه‌سازی کاربردی در هر ضربه کلید، بدنه اثر ۳۰۰ میلی‌ثانیه صبر می‌کند. اگر پرسمان قبل‌از تکمیل آن تأخیر تغییر کند، اثر لغو می‌شود و هیچ فراخوانی شبکه‌ای انجام نمی‌شود.

درون تابع @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

وقتی می‌خواهید به تغییرات Compose 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,
        )
    }
}

In this ViewModel, uiState reads networkMonitor.isOnline and dynamically switches between collecting mediaRepository.downloadedTracksFlow when offline and mediaRepository.allTracksFlow when online, exposing a single State<HomeUiState> that UI composables can read directly. برای جزئیات بیشتر درباره ترکیب کردن transform با MutableState و SavedStateHandle.saveable، به استفاده از «وضعیت Compose» در ViewModel مراجعه کنید.

از بین transform، derivedStateOf، و Flow انتخاب کنید

از دستورالعمل‌های زیر برای انتخاب بین transform،‏ derivedStateOf، و Kotlin Flow استفاده کنید:

‫derivedStateOf مقابل transform

هر دو derivedStateOf و transform یک State<T> مشتق‌شده تولید می‌کنند که به‌طور خودکار وضعیت‌های Compose خوانده‌شده در بلوک خود را ردیابی می‌کند. براساس اینکه مقدار را مستقیماً از اشیاء State<T> موجود محاسبه می‌کنید یا به ویژگی‌های @Composable مثل collectAsState،‏ produceState، و LaunchedEffect نیاز دارید، یکی از آن‌ها را انتخاب کنید:

قابلیت derivedStateOf transform
نوع لامبدا لامبدا Kotlin استاندارد (() -> T) ‫@Composable لامبدا (@Composable (R) -> R)
ورودی‌های پشتیبانی‌شده فقط خواندن همگام State<T> State<T>،‏ Kotlin Flow (بااستفاده از collectAsState)، توابع suspend، و میاناهای برنامه‌سازی کاربردی callback (بااستفاده از produceState)
اجرا و ثبات Lazy and synchronous when .value is read; updates atomically within the same snapshot without an initialValue or CoroutineScope «ترکیب‌بندی» فعالی را در CoroutineScope اجرا می‌کند و پس‌از ثبت عکس آنی، به‌صورت ناهمگام بازترکیب می‌کند؛ به initialValue نیاز دارد
حالت و عوارض جانبی نمی‌توان remember یا میاناهای برنامه‌سازی کاربردی عوارض جانبی را فراخوانی کرد از توابع remember،‏ LaunchedEffect،‏ produceState، و توابع قابل‌استفاده مجدد @Composable پشتیبانی می‌کند

وقتی همه ورودی‌ها ازقبل شیء State<T> هستند و به میاناهای برنامه‌سازی کاربردی @Composable نیاز ندارید، از derivedStateOf استفاده کنید:

  • مشتقات هم‌زمان و هم‌عکس: محاسبه مقدار یا فهرست فیلترشده از ویژگی‌های State<T> موجود بدون مدیریت CoroutineScope یا ارائه جای‌بان initialValue. چون derivedStateOf به‌طور هم‌زمان هنگام خوانده شدن .value ارزیابی می‌کند، تماس‌گیرندگانی که .value را بلافاصله پس‌از به‌روزرسانی وضعیت ورودی می‌خوانند، همیشه نتیجه به‌روزرسانی‌شده را در همان عکس آنی مشاهده می‌کنند.
  • کاهش دفعات به‌روزرسانی ناظران: محاسبه مقداری که کمتر از منبع خود State<T> تغییر می‌کند (مانند بررسی اینکه آیا جابه‌جایی پیمایش از آستانه فراتر می‌رود یا نه). برای جزئیات بیشتر، به derivedStateOf مستندات مراجعه کنید.

وقتی اشتقاق شما به زمان اجرای Compose نیاز دارد، از transform استفاده کنید:

  • ترکیب کردن State<T> با جریان‌های ناهمگام: جمع‌آوری کردن Flow Kotlin از مخزن یا پایگاه داده بااستفاده از 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 runTest را می‌پیچد و بلوک آزمایشی شما را با گیرنده TransformTestScope اجرا می‌کند:

  • 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 «ترکیب‌بندی» خود را در 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 آن را به زمان‌بند آزمایش مقید می‌کند، بنابراین هر فراخوانی advance به‌طور قطعی انتشارات FakeMediaRepository و FakeNetworkMonitor را پردازش می‌کند. برای الگوهای بیشتر درباره استفاده از SavedStateHandle.saveable و Snapshot.withMutableSnapshot در ViewModel، استفاده از «وضعیت Compose» در ViewModel را ببینید.