Transform   Android Jetpack का हिस्सा.

Transform, Compose रनटाइम पर बनी एक लाइब्रेरी है. यह बिना यूज़र इंटरफ़ेस (यूआई) के @Composable ब्लॉक को चलाती है, ताकि इनपुट को Compose State में बदला जा सके. जब भी ब्लॉक के अंदर पढ़ा गया कोई State बदलता है, तो ब्लॉक अपने-आप फिर से कंपोज़ हो जाता है और दिखाए गए State को अपडेट कर देता है. इससे आपको ViewModels, रिपॉज़िटरी या बैकग्राउंड में चलने वाली सेवाओं में Compose के डिपेंडेंसी ट्रैकिंग और फिर से कंपोज़ करने वाले मॉडल का इस्तेमाल करने की सुविधा मिलती है.

इस्तेमाल के उदाहरण

  • रिएक्टिव डेटा ट्रांसफ़ॉर्मेशन: रॉ डेटा स्ट्रीम (जैसे, नेटवर्क रिस्पॉन्स या डेटाबेस क्वेरी) को यूज़र इंटरफ़ेस (यूआई) के लिए तैयार स्टेट में बदलें.
  • एक से ज़्यादा इनपुट को जोड़ना: अलग-अलग स्टेट सोर्स (जैसे, उपयोगकर्ता का इनपुट, सेटिंग, और नेटवर्क की स्थिति) को एक साथ जोड़कर, एक ही स्टेट में दिखाया जा सकता है. इसके लिए, मुश्किल 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 से बैकअप किया जाता है. इसे खोज बार यूआई कंपोज़ेबल, पढ़ और अपडेट कर सकता है.
  • transform, खाली सूची का initialValue, CoroutineScope, और ट्रेलिंग लैम्डा के तौर पर onUpdate लेता है.
  • onUpdate ब्लॉक के अंदर, remember, रीकंपोज़िशन के दौरान results की स्थिति को बनाए रखता है. इस दौरान, LaunchedEffect चलता है.
  • LaunchedEffect, query को कुंजी के तौर पर लेता है. query को MutableState से बैकअप किया जाता है. इसलिए, जब भी query में बदलाव होता है, तब इफ़ेक्ट रद्द हो जाता है और फिर से लॉन्च हो जाता है.
  • हर कीस्ट्रोक पर एपीआई को कॉल करने से बचने के लिए, इफ़ेक्ट बॉडी 300 मि॰से॰ तक इंतज़ार करती है. अगर देरी पूरी होने से पहले क्वेरी बदल जाती है, तो इफ़ेक्ट रद्द कर दिया जाता है और कोई नेटवर्क कॉल नहीं किया जाता.

@Composable फ़ंक्शन में, @Composable transform ओवरलोड का भी इस्तेमाल किया जा सकता है. यह CoroutineScope को इंटरनल तौर पर याद रखता है और डिफ़ॉल्ट रूप से Dispatchers.Default पर onUpdate को चलाता है. यह तब काम आता है, जब आपको मुख्य यूज़र इंटरफ़ेस (यूआई) थ्रेड से, ज़्यादा रिसॉर्स खर्च करने वाले स्टेट डेरिवेशन (जैसे, किसी बड़ी सूची को फ़िल्टर करना या खोजना) को ऑफ़लोड करना हो:

@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 के बदलावों पर प्रतिक्रिया देनी हो, तो listener का इस्तेमाल करें. इससे, कोई डिराइव्ड State<R> नहीं बनता.

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 में यूज़र इंटरफ़ेस (यूआई) की स्थिति जनरेट करने का तरीका बताया गया है.

शर्तों के मुताबिक स्टेट प्रोडक्शन

मौजूदा स्थितियों के आधार पर अपस्ट्रीम स्टेट सोर्स के बीच स्विच करने के लिए, transform में स्टैंडर्ड Kotlin if-else या when एक्सप्रेशन का इस्तेमाल करें. रीड ट्रैकिंग डाइनैमिक होती है. इसलिए, 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, स्टेट रीड को अपने-आप ट्रैक करता है. इसलिए, एक ही transform ब्लॉक में, State<T> प्रॉपर्टी और Flow स्ट्रीम को किसी भी संख्या में जोड़ा जा सकता है. 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,
    )
}

ViewModel में transform का इस्तेमाल करना

नॉन-कंपोज़ेबल transform ओवरलोड, Compose के स्टेट मॉडल का इस्तेमाल करते समय, नॉन-यूआई लेयर (जैसे, ViewModel या रिपॉज़िटरी) में रिएक्टिव लॉजिक चलाता है. यह तब तक चलता है, जब तक दिया गया CoroutineScope चालू रहता है. इसलिए, इसे किसी कॉम्पोनेंट की लाइफ़साइकल से जोड़ा जा सकता है. इसके लिए, ViewModel में viewModelScope को पास करें:

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 के साथ जोड़ने के बारे में ज़्यादा जानने के लिए, ViewModel में Compose स्टेट का इस्तेमाल करना लेख पढ़ें.

transform, derivedStateOf, और Flow में से चुनें

transform, derivedStateOf, और Kotlin Flow में से किसी एक को चुनने के लिए, इन दिशा-निर्देशों का इस्तेमाल करें:

derivedStateOf बनाम transform

derivedStateOf और transform, दोनों ही एक ऐसा State<T> बनाते हैं जो अपने ब्लॉक में पढ़े गए Compose के स्टेटस को अपने-आप ट्रैक करता है. इनमें से किसी एक को चुनें. यह इस बात पर निर्भर करता है कि आपको सीधे तौर पर मौजूदा State<T> ऑब्जेक्ट से वैल्यू की गणना करनी है या आपको @Composable की सुविधाएं चाहिए, जैसे कि collectAsState, produceState, और LaunchedEffect:

क्षमता derivedStateOf transform
लैम्ब्डा टाइप स्टैंडर्ड Kotlin lambda (() -> T) @Composable लैम्ब्डा (@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 एपीआई की ज़रूरत न हो:

  • सिंक्रोनस, सेम-स्नैपशॉट डेरिवेशन: CoroutineScope को मैनेज किए बिना या प्लेसहोल्डर initialValue दिए बिना, मौजूदा State<T> प्रॉपर्टी से वैल्यू या फ़िल्टर की गई सूची का हिसाब लगाना. .value को पढ़े जाने पर, derivedStateOf एक साथ आकलन करता है. इसलिए, इनपुट की स्थिति को अपडेट करने के तुरंत बाद .value को पढ़ने वाले कॉलर, हमेशा एक ही स्नैपशॉट में अपडेट किया गया नतीजा देखते हैं.
  • ऑब्ज़र्वर के अपडेट होने की फ़्रीक्वेंसी कम करना: ऐसी वैल्यू को कंप्यूट करना जो अपने सोर्स State<T> की तुलना में कम बार बदलती है. जैसे, यह देखना कि स्क्रोल ऑफ़सेट किसी थ्रेशोल्ड से ज़्यादा है या नहीं. ज़्यादा जानकारी के लिए, derivedStateOf दस्तावेज़ देखें.

जब आपके डेरिवेशन के लिए Compose रनटाइम की ज़रूरत हो, तब transform का इस्तेमाल करें:

  • State<T> को एसिंक्रोनस स्ट्रीम के साथ जोड़ना: State<T> इनपुट के साथ collectAsState का इस्तेमाल करके, किसी डेटाबेस या रिपॉज़िटरी से Kotlin Flow को इकट्ठा करना.
  • इनपुट बदलने पर अपस्ट्रीम वर्क को रीस्टार्ट करना: ब्लॉक के अंदर 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 को tasksRepository से एसिंक्रोनस Flow के साथ जोड़ता है. साथ ही, सिर्फ़ searchQuery में बदलाव होने पर, Flow को फिर से बनाने से बचने के लिए remember(showCompleted) का इस्तेमाल करता है.

transform के बजाय Flow का इस्तेमाल कब करना चाहिए

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: testCoroutineScheduler से लिंक किया गया TestDispatcher. इसे transform के dispatcher पैरामीटर को पास करें, ताकि रीकंपोज़िशन, Dispatchers.Default पर डिफ़ॉल्ट रूप से सेट होने के बजाय, टेस्ट शेड्यूलर पर चले.
  • advance: यह टेस्ट शेड्यूलर पर, बाकी बचे कोरूटीन चलाता है. साथ ही, Compose स्नैपशॉट की सूचनाएं (Snapshot.sendApplyNotifications) भेजता है और transform को फिर से कंपोज़ करता है, ताकि आउटपुट State अपडेट हो सके.
  • advanceTimeBy(duration) और advanceUntilIdle: समय के हिसाब से होने वाली कार्रवाइयों के लिए, testCoroutineScheduler पर वर्चुअल घड़ी को आगे बढ़ाएं.

टेस्ट की स्थिति से जुड़े अपडेट

transform की जांच करने के लिए, backgroundScope और testDispatcher को TransformTestScope से पास करें. इसके बाद, इनपुट की स्थिति या Flow को अपडेट करने के बाद, advance को कॉल करें:

@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) को कॉल करें. इसके बाद, उस टाइमस्टैंप पर शेड्यूल किए गए टास्क चलाने और transform को फिर से कंपोज़ करने के लिए, advance को कॉल करें.

@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 से होने वाले उत्सर्जन को डिटरमिनिस्टिक तरीके से प्रोसेस करता है. ViewModel में SavedStateHandle.saveable और Snapshot.withMutableSnapshot का इस्तेमाल करने के ज़्यादा पैटर्न के लिए, ViewModel में Compose स्टेट का इस्तेमाल करना लेख पढ़ें.