Biến đổi   Một phần của Android Jetpack.

Biến đổi là một thư viện được xây dựng trên thời gian chạy Compose, chạy một khối @Composable không có giao diện người dùng để biến đổi dữ liệu đầu vào thành State của Compose. Bất cứ khi nào có thay đổi về State được đọc bên trong khối, khối đó sẽ tự động tái cấu trúc và cập nhật State được trả về, cho phép bạn sử dụng mô hình theo dõi phần phụ thuộc và tái cấu trúc của Compose trong ViewModel, kho lưu trữ hoặc dịch vụ nền.

Trường hợp sử dụng

  • Chuyển đổi dữ liệu phản ứng: Chuyển đổi luồng dữ liệu thô (chẳng hạn như phản hồi mạng hoặc truy vấn cơ sở dữ liệu) thành trạng thái sẵn sàng cho giao diện người dùng.
  • Kết hợp nhiều dữ liệu đầu vào: Hợp nhất nhiều nguồn trạng thái độc lập (chẳng hạn như dữ liệu đầu vào của người dùng, chế độ cài đặt và trạng thái mạng) thành một trạng thái gắn kết duy nhất mà không cần các toán tử Flow phức tạp.
  • Chống dội phím và điều tiết: Triển khai hành vi dựa trên thời gian (chẳng hạn như chống dội phím cho cụm từ tìm kiếm) bằng cách sử dụng hiệu ứng phụ chuẩn của Compose như LaunchedEffect.
  • Toán tử điều kiện: Chạy logic thu thập trạng thái khác nhau dựa trên các điều kiện hiện tại bằng cách sử dụng biểu thức if-else hoặc when chuẩn.

Tổng quan về API

Gói androidx.appstate.transform cung cấp các hàm sau:

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

Tạo phép biến đổi

Bạn có thể tạo phép biến đổi riêng (bằng cách sử dụng CoroutineScope được cung cấp) hoặc trong một Thành phần hiện có. Các tham số chính cho mọi phép biến đổi là initialValue và onUpdate. Hàm lambda onUpdate nhận giá trị đầu ra trước đó R làm tham số (được khởi tạo thành initialValue trong thành phần đầu tiên). Bạn có thể trả về tham số này để giữ trạng thái phát ra gần đây nhất thay vì đặt lại về trạng thái đang tải, chẳng hạn như { previous -> flow.collectAsState(initial = null).value ?: previous }.

Ví dụ: bạn có thể sử dụng transform để quan sát các thay đổi đối với chuỗi cụm từ tìm kiếm và gỡ bỏ các phép tính tốn kém như lệnh gọi mạng:

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
}

Trong đoạn mã này:

  • query được hỗ trợ bởi MutableState, một thành phần kết hợp giao diện người dùng thanh tìm kiếm có thể đọc và cập nhật.
  • transform lấy initialValue của một danh sách trống, CoroutineScope và onUpdate làm hàm lambda theo sau.
  • Bên trong khối onUpdate, remember giữ trạng thái results trên các lần kết hợp lại trong khi LaunchedEffect chạy.
  • LaunchedEffect lấy query làm khoá. Vì query được hỗ trợ bởi MutableState, nên hiệu ứng này sẽ bị huỷ và chạy lại mỗi khi query thay đổi.
  • Để tránh gọi API trên mỗi lần nhấn phím, phần nội dung hiệu ứng sẽ đợi 300 mili giây. Nếu truy vấn thay đổi trước khi độ trễ đó hoàn tất, hiệu ứng sẽ bị huỷ và không có lệnh gọi mạng nào được thực hiện.

Bên trong hàm @Composable, bạn cũng có thể sử dụng phương thức nạp chồng @Composable transform. Phương thức này ghi nhớ CoroutineScope trong nội bộ và chạy onUpdate trên Dispatchers.Default theo mặc định. Điều này rất hữu ích khi bạn muốn giảm tải một phép lấy trạng thái tốn kém (chẳng hạn như lọc hoặc tìm kiếm một danh sách lớn) khỏi luồng giao diện người dùng chính:

@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...
}

Quan sát trạng thái bằng listener

Khi bạn muốn phản ứng với các thay đổi về State của Compose trong một thành phần không phải giao diện người dùng (chẳng hạn như trình quản lý trạng thái, kho lưu trữ hoặc dịch vụ nền) mà không tạo ra State<R> phái sinh, hãy sử dụng listener.

listener là một hàm suspend chạy khối @Composable () -> Unit cho đến khi coroutine gọi bị huỷ, tự động kết hợp lại bất cứ khi nào trạng thái đọc bên trong onUpdate thay đổi:

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...
}

Trong ví dụ này, listener quan sát session.value. Khi phiên là FocusSession.Running, LaunchedEffect sẽ đợi cho đến endTimeMillis trước khi đặt lại phiên thành FocusSession.Idle và đăng thông báo hoàn tất. Nếu người dùng tạm dừng hoặc kết thúc phiên sớm, nhánh Running sẽ rời khỏi thành phần và tự động huỷ bộ hẹn giờ. Khi scope bị huỷ, listener sẽ ngừng quan sát.

Mẫu kiểm thử phổ biến

Các mẫu sau đây cho thấy cách sử dụng transform cho logic có điều kiện, kết hợp nhiều luồng và tạo trạng thái giao diện người dùng trong ViewModel.

Tạo trạng thái có điều kiện

Sử dụng biểu thức if-else hoặc when chuẩn của Kotlin bên trong transform để chuyển đổi giữa các nguồn trạng thái ngược dòng dựa trên điều kiện hiện tại. Vì tính năng theo dõi lượt đọc là động, nên transform chỉ quan sát nhánh chạy trên quá trình kết hợp lại hiện tại:

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

Kết hợp nhiều nguồn trạng thái

Vì Compose tự động theo dõi các lượt đọc trạng thái, nên bạn có thể kết hợp số lượng thuộc tính State<T> và luồng Flow bất kỳ trong một khối transform. Không giống như Flow.combine (chỉ cung cấp các phương thức nạp chồng được nhập cho tối đa 5 luồng trước khi quay lại Array<Any?> không được nhập), việc thêm một dữ liệu đầu vào khác trong transform luôn là một lượt đọc an toàn về kiểu:

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

Sử dụng transform trong ViewModel

Quá tải transform không kết hợp chạy logic phản ứng trong các lớp không phải giao diện người dùng (chẳng hạn như ViewModel hoặc kho lưu trữ) trong khi sử dụng mô hình trạng thái của Compose. Hàm này chạy miễn là CoroutineScope được cung cấp đang hoạt động, vì vậy, bạn có thể liên kết hàm này với vòng đời của một thành phần bằng cách truyền viewModelScope vào bên trong 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,
        )
    }
}

Trong ViewModel này, uiState đọc networkMonitor.isOnline và chuyển đổi linh hoạt giữa việc thu thập mediaRepository.downloadedTracksFlow khi không có mạng và mediaRepository.allTracksFlow khi có mạng, hiển thị một State<HomeUiState> mà các thành phần kết hợp giao diện người dùng có thể đọc trực tiếp. Để biết thêm thông tin chi tiết về cách kết hợp transform với MutableState và SavedStateHandle.saveable, hãy xem bài viết Sử dụng Trạng thái Compose trong ViewModel.

Chọn giữa transform, derivedStateOf và Flow

Hãy sử dụng các nguyên tắc sau để chọn giữa transform, derivedStateOf và Flow của Kotlin:

derivedStateOf đấu với transform

Cả derivedStateOf và transform đều tạo ra một State<T> dẫn xuất tự động theo dõi các trạng thái Compose được đọc bên trong khối của nó. Chọn giữa các lớp này dựa trên việc bạn đang tính toán giá trị trực tiếp từ các đối tượng State<T> hiện có hay cần các tính năng @Composable như collectAsState, produceState và LaunchedEffect:

Chức năng derivedStateOf transform
Kiểu lambda Biểu thức lambda Kotlin chuẩn (() -> T) @Composable lambda (@Composable (R) -> R)
Thông tin đầu vào được hỗ trợ Chỉ đọc State<T> đồng bộ State<T>, Kotlin Flow (sử dụng collectAsState), hàm suspend và Callback API (sử dụng produceState)
Thực thi và tính nhất quán Lười và đồng bộ khi .value được đọc; cập nhật tự động trong cùng một ảnh chụp nhanh mà không có initialValue hoặc CoroutineScope Chạy một composition đang hoạt động trong CoroutineScope và recomposition không đồng bộ sau khi snapshot được xác nhận; yêu cầu initialValue
Trạng thái và hiệu ứng phụ Không thể gọi remember hoặc API hiệu ứng phụ Hỗ trợ các hàm remember, LaunchedEffect, produceState và @Composable có thể sử dụng lại

Sử dụng derivedStateOf khi tất cả dữ liệu đầu vào đều là đối tượng State<T> và bạn không cần API @Composable:

  • Các dẫn xuất đồng bộ, cùng ảnh chụp nhanh: Tính toán một giá trị hoặc danh sách đã lọc từ các thuộc tính State<T> hiện có mà không cần quản lý CoroutineScope hoặc cung cấp phần giữ chỗ initialValue. Vì derivedStateOf đánh giá đồng bộ khi .value được đọc, nên phương thức gọi đọc .value ngay sau khi cập nhật trạng thái đầu vào luôn quan sát kết quả đã cập nhật trong cùng một ảnh chụp nhanh.
  • Giảm tần suất cập nhật của trình quan sát: Tính toán một giá trị thay đổi ít thường xuyên hơn nguồn State<T> (chẳng hạn như kiểm tra xem độ lệch cuộn có vượt quá ngưỡng hay không). Để biết thêm thông tin, hãy xem tài liệu về derivedStateOf.

Sử dụng transform khi việc lấy dữ liệu yêu cầu thời gian chạy Compose:

  • Kết hợp State<T> với luồng không đồng bộ: Thu thập Flow Kotlin từ một kho lưu trữ hoặc cơ sở dữ liệu bằng cách sử dụng collectAsState cùng với dữ liệu đầu vào State<T>.
  • Khởi động lại công việc ngược dòng khi dữ liệu đầu vào thay đổi: Sử dụng remember(key) bên trong khối để tạo lại Flow có tham số chỉ khi một dữ liệu đầu vào State<T> cụ thể cập nhật.
  • Chạy hiệu ứng phụ hoặc ghi nhớ trạng thái trung gian: Sử dụng LaunchedEffect (ví dụ: để gỡ lỗi đầu vào hoặc chạy bộ hẹn giờ), produceState (để tải dữ liệu từ hàm suspend hoặc gói lệnh gọi lại bằng awaitDispose) hoặc remember để giữ trạng thái trong các lần kết hợp lại.

Ví dụ sau đây so sánh derivedStateOf với một khối transform kết hợp State<T> và một kho lưu trữ 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)
    }
}

Trong ví dụ này, hasSearchQuery chỉ kiểm tra trạng thái searchQuery, vì vậy, derivedStateOf sẽ tính toán trạng thái đó một cách lười biếng mà không cần CoroutineScope. Ngược lại, uiState kết hợp preferencesManager.showCompleted và searchQuery với một Flow không đồng bộ từ tasksRepository và sử dụng remember(showCompleted) để tránh tạo lại Flow khi chỉ có searchQuery thay đổi.

Trường hợp sử dụng Flow thay vì transform

transform được thiết kế để biểu thị giá trị hiện tại của trạng thái, chứ không phải là một luồng sự kiện. Nếu một trạng thái thay đổi nhiều lần trước lần kết hợp lại tiếp theo, thì Compose chỉ sử dụng giá trị mới nhất và bỏ qua các bản cập nhật khi giá trị mới bằng (==) giá trị trước đó. Thay vào đó, hãy sử dụng Flow của Kotlin khi logic của bạn phải xử lý mọi giá trị theo thứ tự:

  • Tổng và trung bình cộng luỹ tiến: Các phép tính như trung bình động, tổng tích luỹ hoặc nhật ký huỷ (runningFold hoặc scan) cần tính từng giá trị một lần. Trong khối transform, các bản cập nhật nhanh trước khi kết hợp lại sẽ bị bỏ qua, hai lần đọc giống hệt nhau liên tiếp (chẳng hạn như cảm biến phát ra 10.0 hai lần) không kích hoạt quá trình kết hợp lại và các quá trình kết hợp lại do các trạng thái khác kích hoạt trong cùng một khối có thể tính giá trị hiện tại hai lần. Trước tiên, hãy tính giá trị đang chạy trong Flow, sau đó thu thập kết quả trong transform nếu bạn cần kết hợp kết quả đó với trạng thái khác.
  • Ghi nhật ký sự kiện và hàng đợi: Quy trình ghi nhật ký hoạt động tương tác của người dùng, xử lý các yêu cầu mạng trong hàng đợi hoặc ghi lại các giao dịch mà mọi thay đổi trung gian (A -> B -> C hoặc A -> B -> A) đều phải được ghi lại.
  • Sự kiện một lần: Các hành động như phát âm thanh hoặc gửi lệnh phần cứng, trong đó việc gửi cùng một lệnh hai lần liên tiếp phải chạy hai lần. transform yêu cầu initialValue và bỏ qua các kết quả đầu ra bằng nhau liên tiếp.
  • Luồng nhạy cảm với áp suất ngược: Luồng (chẳng hạn như đọc tệp hoặc ổ cắm) trong đó trình tạo nhanh phải tạm ngưng khi trình tiêu thụ bị chậm lại. Các hoạt động ghi trạng thái của Compose không tạm ngưng mà sẽ ghi đè các giá trị trước đó.

Kiểm thử các phép biến đổi

Vì các phép biến đổi dựa vào thời gian chạy Compose và Kotlin Coroutine, nên việc kiểm thử các phép biến đổi này đòi hỏi phải điều phối quá trình thực thi không đồng bộ và kết hợp lại trạng thái. Sử dụng cấu phần phần mềm androidx.appstate.transform:transform-testing để kiểm soát trình lập lịch biểu coroutine và kích hoạt quá trình kết hợp lại trong kiểm thử đơn vị.

Gói androidx.appstate.transform.testing cung cấp các API sau:

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 gói runTest và thực thi khối kiểm thử bằng trình nhận TransformTestScope:

  • backgroundScope: Một CoroutineScope để chạy các phép biến đổi không được chặn quá trình hoàn tất kiểm thử. Phạm vi này sẽ tự động bị huỷ khi quá trình kiểm thử kết thúc, từ đó dừng quá trình biến đổi.
  • testDispatcher: Một TestDispatcher được liên kết với testCoroutineScheduler. Truyền giá trị này đến tham số dispatcher của transform để quá trình kết hợp lại chạy trên trình lập lịch biểu kiểm thử thay vì mặc định là Dispatchers.Default.
  • advance: Chạy coroutine đang chờ xử lý trên trình lập lịch kiểm thử, gửi thông báo áp dụng ảnh chụp nhanh Compose (Snapshot.sendApplyNotifications) và kết hợp lại transform để cập nhật kết quả State.
  • advanceTimeBy(duration) và advanceUntilIdle: Chuyển đồng hồ ảo trên testCoroutineScheduler cho các thao tác phụ thuộc vào thời gian.

Kiểm thử thông tin cập nhật trạng thái

Để kiểm thử transform, hãy truyền backgroundScope và testDispatcher từ TransformTestScope, sau đó gọi advance sau khi cập nhật trạng thái đầu vào hoặc 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)
}

Kiểm thử các thao tác dựa trên thời gian

Khi transform sử dụng các hiệu ứng phụ dựa trên thời gian như LaunchedEffect và delay (chẳng hạn như gỡ lỗi cụm từ tìm kiếm), hãy kết hợp advanceTimeBy(duration) với advance:

  1. Sau khi sửa đổi trạng thái đầu vào, hãy gọi advance để khối transform kết hợp lại và bắt đầu LaunchedEffect.
  2. Gọi advanceTimeBy(duration) để chuyển đồng hồ ảo về phía trước, sau đó gọi advance để chạy các tác vụ được lên lịch tại dấu thời gian đó và kết hợp lại 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")
}

Kiểm thử ViewModel hoặc phần tử giữ trạng thái

Vì transform chạy Composition trong scope được cung cấp và đặt tham số dispatcher mặc định thành Dispatchers.Default, nên các lớp tạo transform phải chấp nhận CoroutineScope và CoroutineDispatcher làm tham số hàm khởi tạo. Trong ViewModel, hãy truyền CoroutineScope được chèn vào hàm khởi tạo ViewModel(coroutineScope) và chuyển tiếp CoroutineDispatcher đến 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,
        )
    )
}

Trong kiểm thử này, việc truyền backgroundScope và testDispatcher vào HomeViewModel sẽ liên kết composition viewModelScope và transform với trình lập lịch kiểm thử, vì vậy, mỗi lệnh gọi advance sẽ xử lý một cách tất định các lượt phát từ FakeMediaRepository và FakeNetworkMonitor. Để biết thêm các mẫu về cách sử dụng SavedStateHandle.saveable và Snapshot.withMutableSnapshot trong ViewModel, hãy xem bài viết Sử dụng Trạng thái Compose trong ViewModel.