Transform   Android Jetpack-এর অংশ।

Transform হল Compose রানটাইমে তৈরি একটি লাইব্রেরি যা UI ছাড়াই একটি @Composable ব্লক চালায় যাতে ইনপুটগুলিকে Compose State-এ রূপান্তর করা যায়। ব্লকের মধ্যে পড়া যেকোনও State পরিবর্তন হলে, ব্লকটি অটোমেটিক রি-কম্পোজ করে এবং রিটার্ন করা State আপডেট করে—আপনাকে ViewModels, রিপোজিটরি বা ব্যাকগ্রাউন্ড সার্ভিসে Compose-এর ডিপেন্ডেন্সি ট্র্যাকিং এবং রি-কম্পোজিশন মডেল ব্যবহার করতে দেয়।

উদাহরণ

  • রিঅ্যাক্টিভ ডেটা ট্রান্সফর্মেশন: র ডেটা স্ট্রিম (যেমন নেটওয়ার্ক রেসপন্স বা ডেটাবেস কোয়েরি) UI-রেডি স্টেটে কনভার্ট করুন।
  • একাধিক ইনপুট একত্রিত করা: জটিল Flow অপারেটর ছাড়াই একাধিক স্বাধীন স্টেট সোর্স (যেমন ব্যবহারকারীর ইনপুট, সেটিংস এবং নেটওয়ার্ক স্ট্যাটাস) একটি একক সুসংহত স্টেটে মার্জ করুন।
  • ডিবাউন্সিং ও থ্রটলিং: স্ট্যান্ডার্ড Compose সাইড-ইফেক্ট যেমন LaunchedEffect ব্যবহার করে সময়-ভিত্তিক আচরণ (যেমন সার্চ কোয়েরি ডিবাউন্স করা) প্রয়োগ করুন।
  • কন্ডিশনাল অপারেশন: স্ট্যান্ডার্ড if-else বা when এক্সপ্রেশন ব্যবহার করে বর্তমান পরিস্থিতির উপর ভিত্তি করে বিভিন্ন স্টেট-গ্যাদারিং লজিক রান করুন।

API ওভারভিউ

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 ল্যাম্বডা তার প্যারামিটার হিসেবে পূর্ববর্তী আউটপুট ভ্যালু 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 কম্পোজেবল পড়তে ও আপডেট করতে পারে।
  • transform একটি খালি তালিকার initialValue, একটি CoroutineScope এবং onUpdate-কে ট্রেলিং ল্যাম্বডা হিসেবে নেয়।
  • onUpdate ব্লকের মধ্যে, LaunchedEffect রান করার সময় remember রিকম্পোজিশন জুড়ে results স্টেট বজায় রাখে।
  • LaunchedEffect, query-কে কী হিসেবে নেয়। query-এর ব্যাক-আপ MutableState-এর মাধ্যমে নেওয়া হয় বলে, query পরিবর্তন করা হলে প্রতিবার এফেক্ট বাতিল করে আবার চালু করা হয়।
  • প্রতিটি কিস্ট্রোকে API কল করা এড়াতে, এফেক্ট বডি ৩০০ মিলি সেকেন্ড অপেক্ষা করে। যদি সেই বিলম্ব সম্পূর্ণ হওয়ার আগে কোয়েরি পরিবর্তিত হয়, তাহলে এফেক্ট বাতিল করা হয় এবং কোনও নেটওয়ার্ক কল করা হয় না।

@Composable ফাংশনের মধ্যে, আপনি @Composable transform ওভারলোডও ব্যবহার করতে পারবেন, যা ইন্টার্নালি CoroutineScope মনে রাখে এবং ডিফল্ট হিসেবে Dispatchers.Default-এ onUpdate রান করে। এটি তখন উপযোগী হয় যখন আপনি মূল UI থ্রেড থেকে একটি ব্যয়বহুল স্টেট ডেরিভেশন (যেমন একটি বড় তালিকা ফিল্টার করা বা সার্চ করা) অফলোড করতে চান:

@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-এর সাথে স্ট্যাটাস দেখুন

আপনি যখন কোনও নন-UI কম্পোনেন্টে (যেমন, স্টেট ম্যানেজার, রিপোজিটরি বা ব্যাকগ্রাউন্ড পরিষেবা) 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 সেশন FocusSession.Idle-এ রিসেট করা এবং সম্পূর্ণ হওয়ার বিজ্ঞপ্তি পোস্ট করার আগে endTimeMillis পর্যন্ত অপেক্ষা করে। ব্যবহারকারী সেশন পজ করলে বা আগে শেষ করলে, Running ব্রাঞ্চ কম্পোজিশন থেকে বেরিয়ে আসে এবং অটোমেটিক টাইমার বাতিল করে দেয়। scope বাতিল করা হলে, listener পর্যবেক্ষণ করা বন্ধ করে দেয়।

সাধারণ প্যাটার্ন

নিম্নলিখিত প্যাটার্নগুলি দেখায় যে কীভাবে কন্ডিশনাল লজিকের জন্য transform ব্যবহার করতে হয়, একাধিক স্ট্রিম একত্রিত করা এবং ViewModel-এ UI স্টেট তৈরি করা।

শর্তসাপেক্ষ স্টেট প্রোডাকশন

বর্তমান অবস্থার উপর ভিত্তি করে আপস্ট্রিম স্টেট সোর্সের মধ্যে পরিবর্তন করতে 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 অটোমেটিক স্টেট রিড ট্র্যাক করে বলে, আপনি যেকোনও সংখ্যক 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,
    )
}

ViewModel-এ transform ব্যবহার করা

নন-কম্পোজেবল transform ওভারলোড নন-UI লেয়ারে প্রতিক্রিয়াশীল লজিক চালায় (যেমন 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> প্রকাশ করে যা UI কম্পোজেবল সরাসরি পড়তে পারে। MutableState ও SavedStateHandle.saveable-এর সাথে transform কম্বাইন করার ব্যাপারে আরও জানতে, ViewModel-এ Compose স্টেট ব্যবহার করুন দেখুন।

transform, derivedStateOf ও Flow-এর মধ্যে থেকে বেছে নিন

transform, derivedStateOf, এবং Kotlin Flow-এর মধ্যে বেছে নিতে নিম্নলিখিত নির্দেশিকা ব্যবহার করুন:

derivedStateOf বনাম transform

derivedStateOf এবং transform উভয়ই একটি ডেরাইভড State<T> তৈরি করে যা এর ব্লকের মধ্যে পড়া কম্পোজ স্টেট অটোমেটিক ট্র্যাক করে। আপনি আগে থেকে থাকা State<T> অবজেক্ট থেকে সরাসরি কোনও ভ্যালু গণনা করছেন নাকি @Composable ফিচার যেমন collectAsState, produceState এবং LaunchedEffect প্রয়োজন তার উপর ভিত্তি করে এগুলির মধ্যে থেকে বেছে নিন:

ক্ষমতা derivedStateOf transform
ল্যাম্বডা টাইপ স্ট্যান্ডার্ড Kotlin ল্যাম্বডা (() -> T) @Composable ল্যাম্বডা (@Composable (R) -> R)
যেসব ইনপুট কাজ করে সিঙ্ক্রোনাস State<T> শুধুমাত্র রিড করে State<T>, Kotlin Flow (collectAsState ব্যবহার করে), suspend ফাংশন এবং কলব্যাক API (produceState ব্যবহার করে)
এক্সিকিউশন ও ধারাবাহিকতা .value রিড করার সময় লেজি ও সিঙ্ক্রোনাস; initialValue বা CoroutineScope ছাড়াই একই স্ন্যাপশটের মধ্যে অ্যাটমিকভাবে আপডেট হয় CoroutineScope-এ একটি অ্যাক্টিভ কম্পোজিশন রান করে এবং স্ন্যাপশট কমিট করার পরে অ্যাসিঙ্ক্রোনাসভাবে রিকম্পোজ করে; initialValue প্রয়োজন
State ও Side-effects remember বা সাইড-এফেক্ট API কল করা যাবে না remember, LaunchedEffect, produceState ও আবার ব্যবহার করা যায় এমন @Composable ফাংশন কাজ করে

সব ইনপুট আগে থেকেই State<T> অবজেক্ট হলে এবং আপনার @Composable API-এর প্রয়োজন না হলে derivedStateOf ব্যবহার করুন:

  • সিঙ্ক্রোনাস, একই-স্ন্যাপশট ডেরিভেশন: CoroutineScope ম্যানেজ না করে বা প্লেসহোল্ডার initialValue প্রদান না করে, আগে থেকে থাকা State<T> প্রপার্টি থেকে ভ্যালু বা ফিল্টার করা তালিকা গণনা করা। কারণ derivedStateOf .value পড়ার সময় সিঙ্ক্রোনাসভাবে মূল্যায়ন করে, ইনপুট স্টেট আপডেট করার সাথে সাথেই .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 প্যাকেজ নিম্নলিখিত API প্রদান করে:

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,
        )
    )
}

এই টেস্টে, HomeViewModel-এ backgroundScope ও testDispatcher পাস করলে, এটি টেস্ট শিডিউলারের সাথে viewModelScope ও transform কম্পোজিশন বাইন্ড করে, যাতে প্রতিটি advance কল, FakeMediaRepository ও FakeNetworkMonitor থেকে নির্গমনকে নির্ধারকভাবে প্রসেস করে। ViewModel-এ SavedStateHandle.saveable ও Snapshot.withMutableSnapshot ব্যবহার করার আরও প্যাটার্ন সম্পর্কে জানতে, ViewModel-এ Compose State ব্যবহার করা দেখুন।