Transform   เป็นส่วนหนึ่งของ Android Jetpack

Transform เป็นไลบรารีที่สร้างขึ้นในรันไทม์ Compose ซึ่งเรียกใช้บล็อก @Composable โดยไม่มี UI เพื่อเปลี่ยนอินพุตเป็น State ของ Compose เมื่อใดก็ตามที่ State ที่อ่านภายในบล็อกมีการเปลี่ยนแปลง บล็อกจะจัดองค์ประกอบใหม่และอัปเดต State ที่แสดงผลโดยอัตโนมัติ ซึ่งช่วยให้คุณใช้โมเดลการติดตามทรัพยากร Dependency และการจัดองค์ประกอบใหม่ของ Compose ใน ViewModel, ที่เก็บ หรือบริการที่ทำงานอยู่เบื้องหลังได้

กรณีการใช้งาน

  • การเปลี่ยนรูปแบบข้อมูลแบบรีแอ็กทีฟ: แปลงสตรีมข้อมูลดิบ (เช่น การตอบสนองของเครือข่ายหรือการค้นหาฐานข้อมูล) เป็นสถานะที่พร้อมสำหรับ 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 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 ของแถบค้นหาสามารถอ่านและอัปเดตได้
  • transform รับ initialValue ของรายการว่างเปล่า, CoroutineScope และ onUpdate เป็น Lambda ต่อท้าย
  • ภายในบล็อก onUpdate remember จะเก็บสถานะ results ไว้ตลอดการจัดองค์ประกอบใหม่ขณะที่ LaunchedEffect ทำงาน
  • LaunchedEffect ใช้ query เป็นคีย์ เนื่องจาก query ได้รับการสนับสนุนจาก MutableState ระบบจึงยกเลิกและเปิดใช้เอฟเฟกต์อีกครั้งทุกครั้งที่ query มีการเปลี่ยนแปลง
  • เพื่อหลีกเลี่ยงการเรียก API ทุกครั้งที่กดแป้นพิมพ์ เนื้อหาของเอฟเฟกต์จะรอ 300 มิลลิวินาที หากการค้นหามีการเปลี่ยนแปลงก่อนที่ความล่าช้านั้นจะเสร็จสมบูรณ์ ระบบจะยกเลิกเอฟเฟกต์และไม่มีการเรียกใช้เครือข่าย

ภายในฟังก์ชัน @Composable คุณยังใช้โอเวอร์โหลด @Composable transform ซึ่งจะจดจำ CoroutineScope ภายในและเรียกใช้ onUpdate ใน Dispatchers.Default โดยค่าเริ่มต้นได้ด้วย ซึ่งจะมีประโยชน์เมื่อคุณต้องการลดภาระการรับค่าสถานะที่มีค่าใช้จ่ายสูง (เช่น การกรองหรือการค้นหารายการขนาดใหญ่) ออกจากเธรด 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

เมื่อต้องการตอบสนองต่อการเปลี่ยนแปลง State ของ Compose ในคอมโพเนนต์ที่ไม่ใช่ UI (เช่น เครื่องมือจัดการสถานะ, ที่เก็บ หรือบริการที่ทำงานอยู่เบื้องหลัง) โดยไม่สร้าง 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 จะออกจาก Composition และยกเลิกตัวจับเวลาโดยอัตโนมัติ เมื่อยกเลิก scope แล้ว listener จะหยุดสังเกตการณ์

รูปแบบทั่วไป

รูปแบบต่อไปนี้แสดงวิธีใช้ transform สำหรับตรรกะแบบมีเงื่อนไข การรวมสตรีมหลายรายการ และการสร้างสถานะ UI ใน ViewModel

การผลิตตามสถานะแบบมีเงื่อนไข

ใช้นิพจน์ if-else หรือ when ของ Kotlin มาตรฐานภายใน 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 ซึ่งให้เฉพาะโอเวอร์โหลดที่พิมพ์สำหรับโฟลว์สูงสุด 5 รายการก่อนที่จะกลับไปใช้ 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 ที่ไม่สามารถคอมโพสได้จะเรียกใช้ตรรกะเชิงรับในเลเยอร์ที่ไม่ใช่ UI (เช่น ViewModel หรือที่เก็บข้อมูล) ขณะใช้โมเดลสถานะของ 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 Composable สามารถอ่านได้โดยตรง ดูรายละเอียดเพิ่มเติมเกี่ยวกับการรวม transform เข้ากับ MutableState และ SavedStateHandle.saveable ได้ที่ใช้ Compose State ใน ViewModel

เลือกระหว่าง transform, derivedStateOf และ Flow

ใช้หลักเกณฑ์ต่อไปนี้เพื่อเลือกระหว่าง transform, derivedStateOf และ Flow ของ Kotlin

derivedStateOf พบกับ transform

ทั้ง derivedStateOf และ transform จะสร้าง State<T> ที่ได้มาซึ่งจะติดตามสถานะการเขียนที่อ่านภายในบล็อกโดยอัตโนมัติ เลือกระหว่าง 2 ตัวเลือกนี้โดยพิจารณาว่าคุณกำลังคำนวณค่าจากออบเจ็กต์ State<T> ที่มีอยู่โดยตรง หรือต้องการฟีเจอร์ @Composable เช่น collectAsState, produceState และ LaunchedEffect

ความสามารถ derivedStateOf transform
ประเภทแลมบ์ดา Kotlin Lambda มาตรฐาน (() -> T) @Composable lambda (@Composable (R) -> R)
อินพุตที่รองรับ การอ่านแบบซิงโครนัส State<T> เท่านั้น State<T>, Kotlin Flow (ใช้ collectAsState), ฟังก์ชัน suspend และ Callback API (ใช้ produceState)
การดำเนินการและความสม่ำเสมอ Lazy และ Synchronous เมื่ออ่าน .value โดยจะอัปเดตแบบอะตอมภายในสแนปชอตเดียวกันโดยไม่มี initialValue หรือ CoroutineScope เรียกใช้ Composition ที่ใช้งานอยู่ใน CoroutineScope และจัดองค์ประกอบใหม่แบบอะซิงโครนัสหลังจากคอมมิตสแนปชอต ต้องใช้ initialValue
สถานะและผลข้างเคียง เรียก remember หรือ API ที่มีผลข้างเคียงไม่ได้ รองรับฟังก์ชัน remember, LaunchedEffect, produceState และ @Composable ที่นำกลับมาใช้ใหม่ได้

ใช้ derivedStateOf เมื่ออินพุตทั้งหมดเป็นออบเจ็กต์ State<T> อยู่แล้ว และคุณไม่จำเป็นต้องใช้ API ของ @Composable

  • การดึงข้อมูลแบบซิงโครนัสและสแนปชอตเดียวกัน: การคำนวณค่าหรือรายการที่กรองจากพร็อพเพอร์ตี้ State<T> ที่มีอยู่โดยไม่ต้องจัดการ CoroutineScope หรือระบุตัวยึดตำแหน่ง initialValue เนื่องจาก derivedStateOf จะประเมินพร้อมกันเมื่อมีการอ่าน .value ผู้เรียกที่อ่าน .value ทันทีหลังจากอัปเดตสถานะอินพุตจะสังเกตเห็นผลลัพธ์ที่อัปเดตในสแนปชอตเดียวกันเสมอ
  • ลดความถี่ในการอัปเดตออบเซิร์ฟเวอร์: การคำนวณค่าที่เปลี่ยนแปลงน้อยกว่าแหล่งที่มา State<T> (เช่น การตรวจสอบว่าออฟเซ็ตการเลื่อนเกินเกณฑ์หรือไม่) ดูรายละเอียดเพิ่มเติมได้ที่เอกสารประกอบderivedStateOf

ใช้ transform เมื่อการดึงข้อมูลต้องใช้รันไทม์ Compose

  • การรวม State<T> เข้ากับสตรีมแบบอะซิงโครนัส: การรวบรวม Flow ของ Kotlin จากที่เก็บหรือฐานข้อมูลโดยใช้ collectAsState ควบคู่ไปกับอินพุต State<T>
  • การรีสตาร์ทงานอัปสตรีมเมื่ออินพุตมีการเปลี่ยนแปลง: การใช้ remember(key) ภายในบล็อกเพื่อสร้าง Flow ที่กำหนดพารามิเตอร์ขึ้นมาใหม่เฉพาะเมื่ออินพุต State<T> ที่เฉพาะเจาะจงอัปเดต
  • การเรียกใช้ผลข้างเคียงหรือการจดจำสถานะระดับกลาง: การใช้ LaunchedEffect (เช่น เพื่อลดการตีกลับอินพุตหรือเรียกใช้ตัวจับเวลา) produceState (เพื่อโหลดข้อมูลจากฟังก์ชัน suspend หรือรวม Callback ด้วย 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 จะคำนวณแบบ Lazy โดยไม่มี CoroutineScope ในทางตรงกันข้าม uiState จะรวม preferencesManager.showCompleted และ searchQuery เข้ากับ Flow แบบอะซิงโครนัสจาก tasksRepository และใช้ remember(showCompleted) เพื่อหลีกเลี่ยงการสร้าง Flow ขึ้นมาใหม่เมื่อมีการเปลี่ยนแปลงเฉพาะ searchQuery เท่านั้น

กรณีที่ควรใช้ Flow แทน transform

transform ออกแบบมาเพื่อแสดงค่าปัจจุบันของสถานะ ไม่ใช่สตรีมของเหตุการณ์ หากสถานะมีการเปลี่ยนแปลงหลายครั้งก่อนการจัดองค์ประกอบใหม่ครั้งถัดไป Compose จะใช้เฉพาะค่าล่าสุด และจะข้ามการอัปเดตเมื่อค่าใหม่เท่ากับ (==) ค่าก่อนหน้า ใช้ Flow ของ Kotlin แทนเมื่อตรรกะของคุณต้องประมวลผลทุกค่าตามลำดับ

  • ผลรวมและค่าเฉลี่ยสะสม: การคำนวณ เช่น ค่าเฉลี่ยเคลื่อนที่ ผลรวมสะสม หรือประวัติการเลิกทำ (runningFold หรือ scan) ต้องนับทุกค่าเพียงครั้งเดียว ในบล็อก transform ระบบจะข้ามการอัปเดตอย่างรวดเร็วก่อนการจัดองค์ประกอบใหม่ การอ่านค่าที่เหมือนกัน 2 ครั้งติดต่อกัน (เช่น เซ็นเซอร์ส่งค่า 10.0 2 ครั้ง) จะไม่ทริกเกอร์การจัดองค์ประกอบใหม่ และการจัดองค์ประกอบใหม่ที่ทริกเกอร์โดยสถานะอื่นๆ ในบล็อกเดียวกันอาจนับค่าปัจจุบัน 2 ครั้ง คำนวณค่าที่รันอยู่ใน Flow ก่อน จากนั้นรวบรวมผลลัพธ์ใน transform หากต้องการรวมกับสถานะอื่นๆ
  • การบันทึกเหตุการณ์และคิว: ไปป์ไลน์ที่บันทึกการโต้ตอบของผู้ใช้ ประมวลผลคำขอเครือข่ายที่อยู่ในคิว หรือบันทึกธุรกรรมที่ต้องบันทึกการเปลี่ยนแปลงระดับกลางทุกรายการ (A -> B -> C หรือ A -> B -> A)
  • เหตุการณ์ที่เกิดขึ้นครั้งเดียว: การดำเนินการต่างๆ เช่น การเล่นเสียงหรือการส่งคำสั่งฮาร์ดแวร์ ซึ่งการส่งคำสั่งเดียวกัน 2 ครั้งติดต่อกันจะต้องทำงาน 2 ครั้ง transform ต้องใช้ initialValue และข้ามเอาต์พุตที่เท่ากันที่ต่อเนื่องกัน
  • สตรีมที่ไวต่อแรงดันย้อนกลับ: สตรีม (เช่น การอ่านไฟล์หรือซ็อกเก็ต) ที่ผู้ผลิตที่รวดเร็วต้องระงับเมื่อผู้บริโภคตามไม่ทัน การเขียนสถานะ Compose จะไม่ระงับและเขียนทับค่าก่อนหน้าแทน

ทดสอบการเปลี่ยนรูปแบบ

เนื่องจากการเปลี่ยนรูปแบบอาศัยรันไทม์ Compose และ Kotlin Coroutine การทดสอบจึงต้องประสานงานการดำเนินการแบบอะซิงโครนัสและการจัดองค์ประกอบสถานะใหม่ ใช้อาร์ติแฟกต์ androidx.appstate.transform:transform-testing เพื่อควบคุมเครื่องจัดตารางเวลา Coroutine และทริกเกอร์การจัดองค์ประกอบใหม่ใน Unit Test

แพ็กเกจ 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: TestDispatcher ที่ลิงก์กับ testCoroutineScheduler ส่งค่านี้ไปยังพารามิเตอร์ dispatcher ของ transform เพื่อให้การจัดองค์ประกอบใหม่ทำงานในตัวจัดตารางเวลาทดสอบแทนที่จะเป็นค่าเริ่มต้น Dispatchers.Default
  • advance: เรียกใช้ Coroutine ที่รอดำเนินการในเครื่องจัดตารางเวลาการทดสอบ ส่งการแจ้งเตือนการใช้สแนปชอตของ Compose (Snapshot.sendApplyNotifications) และจัดองค์ประกอบ transform ใหม่เพื่อให้เอาต์พุต State อัปเดต
  • advanceTimeBy(duration) และ advanceUntilIdle: เลื่อนนาฬิกาเสมือนใน testCoroutineScheduler สำหรับการดำเนินการที่ขึ้นอยู่กับเวลา

ทดสอบการอัปเดตสถานะ

หากต้องการทดสอบ transform ให้ส่ง backgroundScope และ testDispatcher จาก TransformTestScope แล้วเรียกใช้ advance หลังจากอัปเดตสถานะอินพุตหรือ Flow:

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

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

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

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

ทดสอบการดำเนินการตามเวลา

เมื่อ transform ใช้ผลข้างเคียงตามเวลา เช่น LaunchedEffect และ delay (เช่น การดีบาวน์คำค้นหา) ให้รวม advanceTimeBy(duration) กับ advance:

  1. หลังจากแก้ไขสถานะอินพุตแล้ว ให้เรียกใช้ advance เพื่อให้บล็อก transform จัดองค์ประกอบใหม่และเริ่ม LaunchedEffect
  2. เรียกใช้ advanceTimeBy(duration) เพื่อเลื่อนนาฬิกาเสมือนไปข้างหน้า แล้วเรียกใช้ advance เพื่อเรียกใช้งานที่กำหนดเวลาไว้ที่การประทับเวลานั้นและจัดองค์ประกอบ transform ใหม่

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

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

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

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

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

ทดสอบ ViewModel หรือตัวเก็บสถานะ

เนื่องจาก transform เรียกใช้ Composition ใน scope ที่ระบุและกำหนดค่าเริ่มต้นของพารามิเตอร์ dispatcher เป็น Dispatchers.Default คลาสที่สร้าง transform ควรยอมรับ CoroutineScope และ CoroutineDispatcher เป็นพารามิเตอร์คอนสตรัคเตอร์ ใน ViewModel ให้ส่ง CoroutineScope ที่แทรกไปยังเครื่องมือสร้าง ViewModel(coroutineScope) และส่งต่อ CoroutineDispatcher ไปยัง transform

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

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

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

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

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

ในการทดสอบนี้ การส่ง backgroundScope และ testDispatcher ไปยัง HomeViewModel จะเชื่อมโยง Composition viewModelScope และ transform กับตัวจัดตารางเวลาการทดสอบ ดังนั้นการเรียกใช้ advance แต่ละครั้งจะประมวลผลการปล่อยจาก FakeMediaRepository และ FakeNetworkMonitor ในเชิงกำหนด ดูรูปแบบเพิ่มเติมเกี่ยวกับการใช้ SavedStateHandle.saveable และ Snapshot.withMutableSnapshot ใน ViewModel ได้ที่ใช้สถานะ Compose ใน ViewModel