Apps foundation

ใช้สถานะ Compose นอกเหนือจาก UI ด้วย Jetpack AppState

ลำโพง

  • Ben Sagmoe

    วิศวกรนักพัฒนาซอฟต์แวร์สัมพันธ์

  • Elif Bilgin Morris

    วิศวกรซอฟต์แวร์

เกี่ยวกับ

เกี่ยวกับการเสวนา

รันไทม์สแนปชอตของ Compose, การติดตามทรัพยากร Dependency อัตโนมัติ และการอัปเดตแบบอะตอมไม่จำเป็นต้องหยุดที่เลเยอร์ UI Jetpack AppState ช่วยให้คุณย้ายสถานะ Compose ไปยังอัปสตรีมได้ ซึ่งจะช่วยปรับปรุงสิ่งที่เข้าถึงหน้าจอของคุณโดยการยกและดึงสถานะด้วยระบบสแนปชอตก่อนที่จะเข้าถึง UI

ในเซสชันนี้ คุณจะได้เรียนรู้เกี่ยวกับไลบรารีพื้นฐาน 2 รายการของ AppState เราจะเจาะลึก StateStore สำหรับการยกสถานะ และ Transform สำหรับการใช้ Composition เพื่อรับสถานะด้วยโฟลว์ควบคุม Kotlin แบบธรรมดา ซึ่งจะช่วยลดเชนการรวม Flow ที่ซับซ้อนและทำให้ UI ซิงค์กันได้อย่างง่ายดาย

สรุป

  • รันไทม์ของ Compose ทำงานเป็นเครื่องมือจัดการสถานะอเนกประสงค์โดยไม่มี Compose UI ระบบสแนปชอตจะติดตามการอ่านแบบไดนามิกและทำให้การอัปเดตพร้อมกันสอดคล้องกันในพร็อพเพอร์ตี้ต่างๆ
  • Jetpack AppState Transform จะคำนวณและแสดงสถานะ Compose จากบล็อก Composable แบบไม่มีส่วนหัว ซึ่งจะแทนที่เชนโอเปอเรเตอร์ Flow ที่ซับซ้อน เช่น combine ขณะเดียวกันก็รองรับการแสดงผลก่อนกำหนดและการดึงข้อมูลสถานะที่ซ้อนกัน
  • Transform ใช้นาฬิกาเฟรมแบบ Headless ที่ทำงานอย่างรวดเร็วเพื่อเรียกใช้การเปลี่ยนรูปแบบนอกเธรด UI นาฬิกาจะหยุดทำงานเมื่อสถานะการดูไม่มีการเปลี่ยนแปลง เพื่อหลีกเลี่ยงการทำงานที่สูญเปล่า
  • StateStore ของ Jetpack AppState จะแชร์สถานะ Compose ในคอมโพเนนต์ต่างๆ เช่น กิจกรรมและบริการที่ทำงานอยู่เบื้องหลัง การผสานรวมกับ DataStore ช่วยให้คีย์แต่ละคีย์ยังคงอยู่แม้จะมีการสิ้นสุดการประมวลผลโดยไม่ต้องใช้ต้นแบบที่กำหนดเอง

แหล่งข้อมูล

Gemini Notebook

หัวข้อ

  • jetpack-compose
  • jetpack-appstate
  • state-management
  • android-architecture
  • compose-runtime

ข้อความถอดเสียง

บทนำ

  • สวัสดีและยินดีต้อนรับสู่ "ยกระดับสถานะ Compose ให้เหนือกว่า UI ด้วย Jetpack AppState"

  • ผมชื่อ Ben เป็นวิศวกรฝ่ายลูกค้าสัมพันธ์ของนักพัฒนาซอฟต์แวร์ที่ทำงานเกี่ยวกับไลบรารีสถาปัตยกรรม Jetpack

  • และฉัน Elif เป็นหนึ่งในวิศวกรที่ทำงานเกี่ยวกับไลบรารีสถาปัตยกรรมแอป

  • และวันนี้เราจะมาพูดคุยกับคุณเกี่ยวกับอนาคตที่ Compose จะก้าวข้าม UI ไปสู่เลเยอร์ข้อมูลและโดเมนของแอปพลิเคชัน

  • อย่างที่ทุกคนทราบกันดีอยู่แล้วว่า Compose เป็นชุดเครื่องมือที่แนะนำสำหรับการพัฒนา UI บน Android

  • ไม่ว่าจะเป็นโทรศัพท์และแท็บเล็ต ไปจนถึงวิดเจ็ต นาฬิกา ทีวี และอุปกรณ์ XR ทั้งหมดนี้ล้วนใช้ Compose

  • แต่เมื่อการเติบโตของแอปของคุณครอบคลุมแพลตฟอร์ม UI และคอมโพเนนต์เบื้องหลังเหล่านี้ การจัดการสถานะเบื้องหลังจะยากขึ้น โดยเฉพาะปัญหาที่พบบ่อย 2 ประการ

  • วิธีแรกคือการรับสถานะที่ซับซ้อน

  • การผสานสตรีมข้อมูลหลายรายการ, อินพุต UI ในเครื่อง และสถานะแวดล้อม เช่น การเชื่อมต่อเครือข่าย เข้าด้วยกันมักจะกลายเป็นโอเปอเรเตอร์ที่ซับซ้อน เช่น combine, flatMapLatest และ distinctUntilChanged

  • และอย่างที่ 2 คือการแชร์สถานะระหว่างคอมโพเนนต์ต่างๆ เช่น การซิงค์กิจกรรม บริการ วิดเจ็ตแอป และอื่นๆ รวมถึงตรวจสอบว่าสถานะดังกล่าวจะยังคงอยู่เมื่อเกิดการสิ้นสุดการประมวลผลเมื่อจำเป็น

  • เมื่อเราย้อนกลับไปดูความท้าทายเหล่านี้ เราก็มาดูวิธีสร้าง Compose เอง

  • คนส่วนใหญ่คิดว่า Compose เป็นชุดเครื่องมือ UI แต่จริงๆ แล้วมี 2 เลเยอร์ที่แตกต่างกัน ได้แก่ Compose UI ที่ด้านบนและ Compose Runtime ที่ด้านล่าง

  • แม้ว่า Compose UI จะจัดการเลย์เอาต์ การวาด และอินพุต แต่ Compose Runtime เป็นเครื่องมืออเนกประสงค์สำหรับการจัดการสถานะและการจัดองค์ประกอบใหม่

  • โดยไม่ขึ้นอยู่กับ UI

  • และนั่นคือสิ่งที่ทำให้เราสร้าง Jetpack AppState ขึ้นมา

  • ซึ่งเป็นกลุ่มไลบรารีที่มุ่งเน้นการใช้ประโยชน์สูงสุดจากสถานะ Compose นอกเหนือจาก UI

สถานะการเขียนคืออะไร

  • แต่ก่อนที่จะเจาะลึกเรื่อง AppState เรามาทำความเข้าใจให้ตรงกันก่อนว่าเราหมายถึงอะไรเมื่อพูดถึงสถานะ Compose

  • และคำตอบคือ androidx.compose.runtime.State

    /**
     * A value holder where reads to the [value] property during the execution of a [Composable]
     * function, the current [RecomposeScope] will be subscribed to changes of that value.
     */
    @Stable
    public interface State<out T> {
        public val value: T
    }
  • และอินเทอร์เฟซที่คุณอาจคุ้นเคยมากกว่าอย่าง MutableState

    /**
     * A mutable value holder where reads to the [value] property during the execution of a [Composable]
     * function, the current [RecomposeScope] will be subscribed to changes of that value. When the
     * [value] property is written to and changed, a recomposition of any subscribed [RecomposeScope]s
     * will be scheduled. If [value] is written to with the same value, no recompositions will be
     * scheduled.
     */
    @Stable
    public interface MutableState<T> : State<T> {
        override var value: T
        public operator fun component1(): T
        public operator fun component2(): (T) -> Unit
    }
  • โดยพื้นฐานแล้ว คอนเทนเนอร์เหล่านี้เป็นเพียงคอนเทนเนอร์เปล่าๆ

  • สิ่งที่ทำให้ฟังก์ชันเหล่านี้ทำงานได้จริงคือระบบสแนปชอตของ Compose

  • ดังนั้นเมื่อเราพูดถึงสถานะ Compose สิ่งที่เราหมายถึงจริงๆ มักจะเป็นสถานะ Snapshot

  • ดังนั้นเมื่อเรียกใช้ mutableStateOf คุณกำลังสร้างคอนเทนเนอร์ที่กำหนดเส้นทางการอ่านและการเขียนผ่านระบบสแนปชอต

  • และเมื่อใดก็ตามที่โค้ดของคุณทำงานภายใน Snapshot Observer เช่น Composition คุณจะได้รับพร็อพเพอร์ตี้ 2 รายการที่สตรีมอย่าง Flow ไม่ได้ให้คุณ

    @Composable
    fun Counter() {
        val count = remember { mutableStateOf(0) }
    }
  • วิธีแรกคือการติดตามการอ่านแบบไดนามิกและอัตโนมัติ

    @Composable
    fun Counter() {
        val count = remember { mutableStateOf(0) }
        Column {
            Text("Count: ${count.value}")
            AddButton(onClick = { count.value++ })
        }
    }
  • ดังนั้น "อัตโนมัติ" หมายความว่าไม่มีการสมัครใช้บริการหรือการรวบรวมข้อมูล

  • การอ่าน .value เพียงอย่างเดียวคือการสมัครใช้บริการ

  • ดังนั้น หากเราดูที่ Counter การอ่าน count.value โดยตรงภายใน Composable จะรายงานไปยังการอ่านไปยัง Composition และจะลงทะเบียน Counter ในรายการผู้อ่าน

  • และตอนนี้ให้ดูที่ AddButton

    @Composable
    fun Counter() {
        val count = remember { mutableStateOf(0) }
        Column {
            Text("Count: ${count.value}")
            AddButton(onClick = { count.value++ })
        }
    }
    
    @Composable
    fun AddButton(onClick: () -> Unit) {
        Button(onClick = onClick) {
            Text("Add")
        }
    }
  • โดยจะเขียนเพื่อบันทึกการคลิกเท่านั้น

  • ดังนั้นจึงไม่ใช่เครื่องอ่าน

  • เมื่อผู้ใช้คลิก AddButton และแก้ไขจำนวน ระบบสแนปชอตจะติดตามการเขียนนั้น ซึ่งช่วยให้ Composition ทราบว่าต้องทำให้ Counter ไม่ถูกต้อง แต่ไม่ใช่ AddButton

  • และ "แบบไดนามิก" หมายความว่าการติดตามการอ่านจะเกิดขึ้นที่รันไทม์โดยอิงตามเส้นทางโค้ดที่ทำงานจริง

    @Composable
    fun Counter() {
        val isVisible = remember { mutableStateOf(true) }
        val count = remember { mutableStateOf(0) }
        Column {
            if (isVisible.value) {
                Text("Count: ${count.value}")
            } else {
                Text("Hidden")
            }
            AddButton(onClick = { count.value++ })
        }
    }
  • ดังนั้นในที่นี้ เราจะป้องกันข้อความตัวนับไว้หลังแฟล็ก isVisible

  • เมื่อ isVisible เป็นจริง ระบบจะอ่านทั้ง isVisible และจำนวน

  • ดังนั้น Counter จึงได้รับการลงทะเบียนเป็นผู้อ่านของทั้ง 2 รายการ

  • และเมื่อ isVisible เป็นเท็จ หากสาขา if ไม่ทำงาน ระบบจะไม่อ่าน count ดังนั้น Counter จะได้รับการลงทะเบียนเป็นผู้อ่าน isVisible เท่านั้น ไม่ใช่ count

    @Composable
    fun Counter() {
        val isVisible = remember { mutableStateOf(false) }
        val count = remember { mutableStateOf(0) }
        Column {
            if (isVisible.value) {
                Text("Count: ${count.value}")
            } else {
                Text("Hidden")
            }
            AddButton(onClick = { count.value++ })
        }
    }
  • ดังนั้น เมื่อ AddButton เขียนเพื่อทำการนับ จึงไม่มีอะไรเหลือให้ทำให้เป็นโมฆะ

  • และตอนนี้ คุณสมบัติที่ 2 คือคุณไม่จำเป็นต้องเลือกระหว่างความสอดคล้องกับรายละเอียด

  • ความสอดคล้องกันหมายความว่าเมื่อคุณอัปเดตค่าหลายค่าในสแนปชอต การเปลี่ยนแปลงเหล่านั้นจะยังคงแยกกันขณะที่โค้ดทำงาน แม้ว่าเทรดอื่นจะเขียนไปยังค่าเหล่านั้นพร้อมกันก็ตาม

  • และการอัปเดตเหล่านั้นจะปรากฏพร้อมกันทั้งหมดเมื่อสแนปชอตเสร็จสมบูรณ์

  • ความละเอียดหมายความว่าคุณสามารถประกาศและติดตามพร็อพเพอร์ตี้แยกกันได้ โดยเฉพาะอย่างยิ่งเมื่อพร็อพเพอร์ตี้เหล่านั้นไม่เกี่ยวข้องกัน

  • ดังนั้น การอัปเดตพร็อพเพอร์ตี้เดียวจะทำให้ Reader ที่ขึ้นอยู่กับพร็อพเพอร์ตี้นั้นไม่ถูกต้องเท่านั้น

  • มาดูตัวอย่างโค้ดจริงกัน

  • ตัวอย่างเช่น เราจะดูเกมที่มีสถานะ 3 ส่วน ได้แก่ ชื่อระดับ เวลาที่รอดชีวิต และคะแนน

    val levelName: String = "Level 1 - Stay Alive!"
    val timeSurvived: Int = 0
    val score: Double = 0.0
  • ก่อนอื่น เรามาดูการใช้งาน Flow ที่เลือกความสอดคล้องโดยแลกกับรายละเอียดด้วยการรวมทุกอย่างไว้ในคลาส GameUiState

    data class GameUiState(
        val levelName: String = "Level 1 - Stay Alive!",
        val timeSurvived: Int = 0,
        val score: Double = 0.0
    )
  • ซึ่งหมายความว่าทุกครั้งที่ updateScore ทำงาน คุณจะคัดลอก GameUiState ทั้งหมดเพื่อให้ทั้ง timeSurvived และคะแนนสามารถอัปเดตพร้อมกันได้

    data class GameUiState(
        val levelName: String = "Level 1 - Stay Alive!",
        val timeSurvived: Int = 0,
        val score: Double = 0.0
    )
    
    class GameViewModel : ViewModel() {
        val uiState = MutableStateFlow(GameUiState())
        fun updateScore() = uiState.update {
            it.copy(
                timeSurvived = it.timeSurvived + 1,
                score = it.score + 10 + .01 * it.timeSurvived
            )
        }
    }
  • ใน UI GameScreen จะรวบรวมสถานะ UI และอ่านจากสถานะนั้นเพื่อส่งพร็อพเพอร์ตี้ที่เกี่ยวข้องไปยัง Header และ ScoreBadge

    data class GameUiState(
        val levelName: String = "Level 1 - Stay Alive!",
        val timeSurvived: Int = 0,
        val score: Double = 0.0
    )
    
    class GameViewModel : ViewModel() {
        val uiState = MutableStateFlow(GameUiState())
        fun updateScore() = uiState.update {
            it.copy(
                timeSurvived = it.timeSurvived + 1,
                score = it.score + 10 + .01 * it.timeSurvived
            )
        }
    }
    
    @Composable
    fun GameScreen(viewModel: GameViewModel = viewModel()) {
        val state by viewModel.uiState.collectAsStateWithLifecycle()
        Column {
            Header(levelName = state.levelName)
            ScoreBadge(
                timeSurvived = state.timeSurvived,
                score = state.score
            )
        }
    }
  • เนื่องจาก GameScreen อ่านทุกสถานะ การเรียก updateScore ทุกครั้งจึงทำให้ GameScreen ไม่ถูกต้องแม้ว่าข้อมูลของส่วนหัวจะไม่เคยเปลี่ยนแปลงก็ตาม

  • ตอนนี้คุณสามารถแยกพร็อพเพอร์ตี้ทุกรายการออกเป็น Flow ของตัวเองเพื่อให้ได้รายละเอียด แต่คุณจะสูญเสียความสอดคล้องเนื่องจากชุดข้อความอื่นอาจอัปเดตค่าในขณะที่บล็อกการอัปเดตอื่นกำลังทำงานอยู่

  • คราวนี้เรามาดูการติดตั้งใช้งานตามสถานะแบบอื่นกัน

    @Stable
    class GameUiState {
        var levelName by mutableStateOf("Level 1 - Stay Alive!")
        var timeSurvived by mutableStateOf(0)
        var score by mutableStateOf(0.0)
    }
  • ในตัวอย่างนี้ เราจะใช้ GameUiState อีกครั้ง

  • แต่ในกรณีนี้จะแตกต่างออกไปเล็กน้อยเนื่องจากพร็อพเพอร์ตี้แต่ละรายการจะมอบสิทธิ์ให้ mutableStateOf

  • ในกรณีนี้ updateScore จะรวมการเขียนไว้ใน withMutableSnapshot ซึ่งทำให้เรามีความสอดคล้องกันในการเขียน

    @Stable
    class GameUiState {
        var levelName by mutableStateOf("Level 1 - Stay Alive!")
        var timeSurvived by mutableStateOf(0)
        var score by mutableStateOf(0.0)
    }
    
    class GameViewModel : ViewModel() {
        val uiState = GameUiState()
        fun updateScore() = Snapshot.withMutableSnapshot {
            uiState.timeSurvived += 1
            uiState.score += 10 + .01 * uiState.timeSurvived
        }
    }
  • จากนั้นใน UI GameScreen จะส่ง State ไปยัง Header และ ScoreBadge โดยตรง

    @Stable
    class GameUiState {
        var levelName by mutableStateOf("Level 1 - Stay Alive!")
        var timeSurvived by mutableStateOf(0)
        var score by mutableStateOf(0.0)
    }
    
    class GameViewModel : ViewModel() {
        val uiState = GameUiState()
        fun updateScore() = Snapshot.withMutableSnapshot {
            uiState.timeSurvived += 1
            uiState.score += 10 + .01 * uiState.timeSurvived
        }
    }
    
    @Composable
    fun GameScreen(viewModel: GameViewModel = viewModel()) {
        val state = viewModel.uiState
        Column {
            Header(state = state)
            ScoreBadge(state = state)
        }
    }
  • แต่จะไม่อ่านพร็อพเพอร์ตี้ใดๆ ดังนั้นเมื่อ updateScore ทำงาน GameScreen จะไม่ใช่ผู้อ่าน

  • เฉพาะคอมโพเนนต์จริงเท่านั้นที่ใช้งานไม่ได้ ซึ่งในกรณีนี้คือ ScoreBadge

  • และสุดท้าย โปรดทราบว่าไม่มีการดำเนินการใดที่เชื่อมโยงกับ UI

  • UI เป็นเพียงหนึ่งในตำแหน่งที่พร็อพเพอร์ตี้เหล่านี้โดดเด่นอย่างมาก

  • สุดท้ายนี้ ขอขอบคุณที่มาร่วมฟังการบรรยายของเรา

  • เอาล่ะ เลิกเล่นมุกแล้วมาฟัง Elif พูดถึงไลบรารีแรกกันดีกว่า ซึ่งก็คือ Transform

ขอแนะนำ Transform

  • ตกลง

  • ขอบคุณ Ben

  • ดังนั้น อย่างที่ Ben เพิ่งบอกไป สถานะสแนปชอตของ Compose ช่วยให้เราติดตามการอ่าน ความสอดคล้อง และรายละเอียดแบบอัตโนมัติและไดนามิกได้ ซึ่งทำให้ฉันเกิดคำถามว่า จะเป็นอย่างไรหากเราสามารถให้โฟลว์ข้อมูลขับเคลื่อนโดยสแนปชอตสถานะของ Compose หรือสถานะสแนปชอตได้ด้วย

  • และหากสถานะเป็นโมเดลข้อมูลของเรา เราจะคำนวณและเปลี่ยนรูปแบบสถานะสำหรับผู้ใช้ทั้งหมดได้อย่างไร

  • ซึ่งเป็นจุดที่ไลบรารี Jetpack AppState Transform ใหม่เข้ามามีบทบาท

  • ก่อนที่จะเจาะลึกเรื่องห้องสมุด เราต้องพูดถึงที่มาของห้องสมุดก่อน

  • ดังนั้นแนวคิดในการใช้ประโยชน์จากเครื่องมือ Compose ซึ่งเป็นรันไทม์และคอมไพเลอร์ที่นี่เพื่อสร้างค่าสถานะในองค์ประกอบแบบ Headless จึงไม่ใช่แนวคิดใหม่

  • หลายคนอาจใช้หรือคุ้นเคยกับไลบรารี Molecule อยู่แล้ว

  • ซึ่ง Headless ในที่นี้หมายความว่าไม่มี UI

  • Molecule จึงเป็นผู้บุกเบิกในการแสดงให้เห็นถึงประสิทธิภาพของการใช้ Compose Runtime โดยแสดงให้เห็นว่าฟังก์ชันที่ประกอบกันได้ไม่จำเป็นต้องปล่อยโหนด UI และสามารถใช้เขียนฟังก์ชันที่ประกอบกันได้ซึ่งแสดงผลโมเดลข้อมูลล้วนๆ

  • ในกรณีของ Molecule จะเป็น Kotlin Flow หรือ StateFlow

  • ซึ่งหมายความว่าคุณสามารถเขียน Kotlin แบบธรรมดาได้ แทนที่จะต้องเชื่อมโยงโอเปอเรเตอร์แบบรีแอ็กทีฟ เช่น combine

  • แต่แทนที่จะจัดการกับ Flow จะเป็นอย่างไรหากโมเดลข้อมูลของคุณเป็น State เนื่องจาก UI ของคุณอยู่ใน Compose อยู่แล้ว

  • เมื่อใช้ Transform โมเดลข้อมูลของคุณจะเป็น State

  • มาดู API จริงกัน

    public fun <R> transform(
        initialValue: R,
        scope: CoroutineScope,
        onUpdate: @Composable (R) -> R,
    ): State<R>
  • นี่คือ API ทั้งหมด

  • ซึ่งเป็นฟังก์ชันเดียวที่เรียบง่ายมาก

  • โดยจะแสดงผลเป็น State ที่สังเกตได้ธรรมดา ไม่ใช่ Flow และไม่ใช่ StateFlow

  • และใครก็ตามที่ใช้ข้อมูลนี้จะไม่ทราบหรือไม่สนใจว่า Transform เป็นผู้สร้างข้อมูลนี้ขึ้นมา

  • สิ่งที่น่าสนใจมากตรงนี้ก็คือบล็อกนี้

  • เน้นสร้างส่วนประกอบขนาดเล็ก

  • ซึ่งหมายความว่าทุกสิ่งที่คุณทราบอยู่แล้วจะทำงานภายในนั้น

  • โปรดทราบว่า LaunchedEffect, collectAsState, การแสดงผลก่อนกำหนด, if แบบธรรมดา และทุกอย่างที่คุณอ่านได้จากภายใน Composable คุณสามารถรับค่าได้ใน Transform

  • เราควรทราบว่า API ทั้งหมดที่เราเห็นในวันนี้ในงานนำเสนอนี้เป็นเวอร์ชันอัลฟ่าทั้งหมด และอาจมีการเปลี่ยนแปลง แต่ไม่ว่าอย่างไรก็ตาม เรามาดูตัวอย่างกัน

  • ลองดูตัวอย่างแอปจับเวลาเพื่อการจดจ่อที่เรียบง่ายและมีขนาดเล็ก

  • คุณเลือกงาน ตั้งตัวจับเวลา 25 นาที แล้วนาฬิกาก็จะนับถอยหลัง

  • ทุกครั้งที่คุณสร้างหน้าจอเดียว แม้ว่าจะเป็นหน้าจอเดียวที่เรียบง่ายเช่นนี้ คุณอาจต้องรวมและจัดการสตรีมข้อมูลอิสระหลายรายการเพื่อประกอบสถานะของคุณเบื้องหลัง

  • สำหรับหน้าจอโฟกัส สมมติว่าเรามี 2 หน้าจอ ได้แก่ เซสชันโฟกัส ซึ่งจะเป็นระยะที่เราอยู่ ดังนั้นจะหยุดชั่วคราวหรือไม่ เหลือเวลาอีกกี่วินาที และทั้งหมดนี้จะมาจากเครื่องมือจับเวลาเป็น StateFlow ที่จะเดินทุกๆ 1 วินาที

  • สตรีมข้อมูลอีกรายการจะเป็นงานปัจจุบันที่ผู้ใช้กำลังทำอยู่ ซึ่งจะมาจากฐานข้อมูล Room เป็น Flow

  • ดังนั้นจะมีสตรีมข้อมูล 2 รายการและสถานะหน้าจอ 1 รายการในตอนท้าย

  • ViewModel จะเป็นที่ที่นำข้อมูลมารวมกัน และเอาต์พุตซึ่งเราจะเรียกว่าสรุปจะเป็นโค้ดที่เราจะเขียนต่อไป

  • โดยปกติแล้ว คุณจะต้องรวมโฟลว์เหล่านี้เข้าด้วยกันโดยใช้โอเปอเรเตอร์แบบเชื่อมโยงใน ViewModel

    class FocusScreenViewModel(
        focusManager: FocusSessionManager,
        taskRepository: TaskRepository
    ) : ViewModel() {
        val summary: StateFlow<FocusSummary> =
            combine(focusManager.sessionFlow, taskRepository.tasksFlow) { session, tasks ->
                if (session.phase == FocusPhase.Idle) {
                    FocusSummary.Idle
                } else {
                    val task = tasks.find { it.id == session.activeTaskId }
                    FocusSummary.Active(...)
                }
            }.stateIn(
                    scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = FocusSummary.Idle,
    )
    }
  • ดังนั้นให้รวมทั้ง 2 โฟลว์เข้าด้วยกันเป็นสตรีม การแยกสาขาภายใน Lambda ดังนั้นหากมีอย่างอื่นอยู่ตรงนั้น และสถานะที่ส่วนท้าย

  • เมื่อใช้ Transform เราจะตัดโค้ด Boilerplate ทั้งหมดนี้ออกได้

    class FocusScreenViewModel(
        focusManager: FocusSessionManager,
        taskRepository: TaskRepository
    ) : ViewModel() {
        val summary: State<FocusSummary> =
            transform<FocusSummary>(
    initialValue = FocusSummary.Idle,
    scope = viewModelScope,
    container = handle.getOrCreateContainer("transform")
    ) {
                val session by focusManager.sessionFlow.collectAsState()
                if (session.phase == FocusPhase.Idle) return@transform FocusSummary.Idle
                val tasks by taskRepository.tasksFlow.collectAsState(emptyList())
                val task = tasks.find { it.id == session.activeTaskId }
                FocusSummary.Active(...)
  • การเรียกนี้ดูคุ้นเคยเนื่องจากเรามีค่าเริ่มต้นในขอบเขตที่ส่งเข้ามา แต่ไม่มีอะไรที่ต้นทางให้เชื่อมโยง การดึงข้อมูลคือบล็อกนั้นเอง

  • และไม่เหมือนกับ stateIn ที่คุณอาจต้องกำหนดค่าWhileSubscribedระยะหมดเวลาของ เพื่อหยุดชั่วคราวและกลับมาทำงานต่อในสตรีมต้นทาง แต่ Transform จะทำงานอย่างต่อเนื่องภายในอายุการใช้งานของขอบเขตนั้น

  • ขอย้ำอีกครั้งว่าแหล่งที่มาของเราคือโฟลว์ และเรายินดีรับโฟลว์

    class FocusScreenViewModel(
        focusManager: FocusSessionManager,
        taskRepository: TaskRepository
    ) : ViewModel() {
        val summary: State<FocusSummary> =
            transform<FocusSummary>(
                initialValue = FocusSummary.Idle,
                scope = viewModelScope,
                       container = handle.getOrCreateContainer("transform")
            ) {
                val session by focusManager.sessionFlow.collectAsState()
                if (session.phase == FocusPhase.Idle)
                    return@transform FocusSummary.Idle
                val tasks by taskRepository.tasksFlow.collectAsState(emptyList())
                val task = tasks.find {
                    it.id == session.activeTaskId
                }
                         FocusSummary.Active(...)
            }
    }
  • เราจะเชื่อมต่อแต่ละรายการด้วย collectAsState และเซสชันเป็น StateFlow ดังนั้นเราจึงไม่จำเป็นต้องมีค่าเริ่มต้น แต่ Room Flow เป็นแบบ Cold เราจึงกำหนดค่าให้

  • และนี่คือเรื่องราวทั้งหมดเกี่ยวกับการสมัครใช้บริการ

  • การอ่านคือสิ่งที่ทำให้เราสมัครรับข้อมูล

  • ซึ่งก็คือการติดตามการอ่านอัตโนมัติที่ Ben พูดถึงก่อนหน้านี้ในตัวอย่างที่เราเห็น แต่เป็นใน ViewModel

  • เรายังสามารถใช้การกลับคืนก่อนเวลาแบบนี้ได้เมื่ออยู่ในสถานะไม่มีการใช้งาน

  • สมมติว่าเราไม่มีงานใหม่ เราจะไม่ประมวลผลงานที่เหลือด้วยซ้ำ

  • และนี่ก็เป็นอีกครั้งที่การติดตามการอ่านแบบไดนามิกให้ผลลัพธ์ที่ดี

  • ขณะที่เราไม่ได้ใช้งาน ระบบจะไม่เก็บรวบรวมข้อมูลการเคลื่อนไหวในห้องเลย

  • จากนั้นเราจะแสดงสรุปในที่สุด

  • ดังนั้น หากผู้ใช้หยุดงานชั่วคราวหรือตัวจับเวลาเดิน ทุกอย่างจะอัปเดตโดยอัตโนมัติ

การเปลี่ยนรูปแบบที่ซ้อนกัน

  • ดังนั้น สิ่งต่างๆ จะซับซ้อนขึ้นเล็กน้อยเมื่อเราก้าวออกจากตัวอย่าง ViewModel รายการเดียว

  • สมมติว่าเซสชันเดียวกันนี้ต้องแสดงบนแพลตฟอร์มเพิ่มเติมอีก 2 รายการ ได้แก่ แถบตัวจับเวลาขนาดเล็กที่อยู่ด้านล่างของหน้าจอแอปในทุกหน้าจอ และวิดเจ็ตบนหน้าจอหลัก

  • ทั้ง 2 อย่างนี้จะต้องใช้ข้อมูลที่ได้มาเหมือนกันเกี่ยวกับสรุปเซสชัน ระยะเวลาที่หยุดชั่วคราวหรือไม่ และเวลาที่เหลืออยู่ในตัวจับเวลา

  • ซึ่งหมายความว่าหากทุกแพลตฟอร์มต้องรวบรวมสำเนาของตัวเอง ก็จะเกิดการดึงข้อมูลเดียวกัน 3 ครั้งใน 3 ตำแหน่งที่แตกต่างกันซึ่งทั้งหมดต้องสอดคล้องกัน

  • ดังนั้น สิ่งที่เราต้องทำแทนคือรับข้อมูลสรุปหนึ่งครั้งในขอบเขตแอปพลิเคชัน จากนั้นทุกอย่างที่อยู่ปลายน้ำจะใช้ข้อมูลสรุปนั้น

  • แล้วเราจะนำแนวคิดแบบนี้ไปใช้จริงได้อย่างไร

  • ซึ่งการติดตั้งใช้งานของเราอาจต้องใช้ 3 ส่วน

    class FocusSessionManager(taskRepository: TaskRepository, scope: CoroutineScope) {
        ...
    }
    
    class MiniTimerViewModel(focusManager: FocusSessionManager) : ViewModel() {
        ...
    }
    
    class FocusWidget : GlanceAppWidget() {
        override suspend fun provideGlance(context: Context, id: GlanceId) {
            provideContent {
                ...
            }
        }
    }
  • ดังนั้น ตัวจัดการเซสชันในขอบเขตแอปพลิเคชันจะเป็นที่ที่สรุปข้อมูล, ViewModel ของตัวจับเวลาขนาดเล็ก และวิดเจ็ต Glance ซึ่งทั้ง 2 อย่างนี้จะใช้สรุปที่ได้ในตัวจัดการเซสชัน Focus นี้

  • มาสร้างแต่ละรายการแบบเดิม แล้วค่อยใช้ Transform กัน

  • ดังนั้น โดยปกติแล้ว เราจะเริ่มด้วยการดึงข้อมูลสรุป 1 ครั้งในขอบเขตแอปพลิเคชันเป็น StateFlow

    class FocusSessionManager(
        taskRepository: TaskRepository,
        scope: CoroutineScope
    ) {
        val sessionFlow: StateFlow<FocusSession> = ...
        val summary: StateFlow<FocusSummary> =
            combine(
                sessionFlow,
                taskRepository.tasksFlow
            ) { session, tasks ->
                if (session.phase == FocusPhase.Idle) {
                    FocusSummary.Idle
                } else {
                    val task = tasks.find {
                        it.id == session.activeTaskId
                    }
                    FocusSummary.Active(...)
                }
            }.stateIn(
    scope = appScope,
    started = SharingStarted.WhileSubscribed(5_000),
    initialValue = FocusSummary.Idle
    )
    }
  • ซึ่งเป็นการเรียกใช้ combine และ stateIn แบบเดียวกับที่เราเห็นก่อนหน้านี้ในตัวอย่าง ViewModel แต่ย้ายขึ้นมาอีกระดับหนึ่ง

  • เมื่อใช้ Transform เรายังสามารถใช้การประกาศเดียวกันจาก ViewModel ที่เราเห็นก่อนหน้านี้ซ้ำได้ด้วย

    class FocusSessionManager(
        taskRepository: TaskRepository,
        scope: CoroutineScope
    ) {
        val sessionFlow: StateFlow<FocusSession> = ...
        val summary: State<FocusSummary> =
            transform<FocusSummary>(
               initialValue = FocusSummary.Idle,
    scope = scope,
    container = handle.getOrCreateContainer("transform")
            ) {
                val session by sessionFlow.collectAsState()
                if (session.phase == FocusPhase.Idle)
                    return@transform FocusSummary.Idle
                val tasks by taskRepository.tasksFlow
                    .collectAsState(emptyList())
                val task = tasks.find {
                    it.id == session.activeTaskId
                }
                FocusSummary.Active(...)
            }
    }
  • สิ่งเดียวที่เปลี่ยนแปลงคือตอนนี้อยู่ในขอบเขตของแอปพลิเคชันแล้ว

  • ถัดไปคือViewModelของตัวจับเวลาขนาดเล็ก

    class MiniTimerViewModel(
        focusManager: FocusSessionManager
    ) : ViewModel() {
        val isExpanded = MutableStateFlow(false)
    
        val uiState: StateFlow<MiniTimerUiState> =
            combine(
                focusManager.summary,
                isExpanded
            ) { summary, expanded ->
                MiniTimerUiState(
        summary = summary,
        showControls = expanded && summary is FocusSummary.Active
    )
            }.stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = MiniTimerUiState()
    )
    
        fun toggleExpanded() { isExpanded.update { !it } }
    }
  • สมมติว่าเราต้องการท้าทายตัวเองเล็กน้อยด้วย

  • สมมติว่าในมินิตัวจับเวลา นอกจากสตรีมข้อมูล 2 รายการที่เรามีอยู่แล้ว ได้แก่ StateFlow และ Room Flow เรายังต้องการติดตามด้วยว่าแถบตัวจับเวลาขยายอยู่หรือไม่

  • และเราใช้ MutableStateFlow เพื่อดำเนินการดังกล่าว

  • ในการดำเนินการนี้ เราจะต้องใช้ Combine อีกรายการเพื่อติดตามขั้นตอนสรุปและ isExpanded StateFlow

  • และเราก็มีรัฐที่ 2 เข้ามา

  • เมื่อใช้ Transform เราไม่จำเป็นต้องใช้โอเปอเรเตอร์ที่เชื่อมโยงกันเหล่านี้อีกเพื่อซิงค์ระหว่างสตรีมข้อมูล

    class MiniTimerViewModel(
        focusManager: FocusSessionManager
    ) : ViewModel() {
        val isExpanded = MutableStateFlow(false)
    
        val uiState: State<MiniTimerUiState> =
            transform(
                initialValue = MiniTimerUiState(),
                scope = viewModelScope,
                       container = handle.getOrCreateContainer("transform")
            ) {
                val summary by focusManager.summary
                       val expanded by isExpanded.collectAsState()
                MiniTimerUiState(
        summary = summary,
        showControls = expanded && summary is FocusSummary.Active
    )
            }
    
        fun toggleExpanded() { isExpanded.update { !it } }
    }
  • สิ่งที่เราทำได้และเจ๋งมากก็คือเราสามารถทำ Transform ที่ซ้อนกันได้

  • ดังนั้น ViewModel จะอ่านสรุปซึ่งเป็นเอาต์พุตของ Transform ต้นทางเช่นเดียวกับสถานะอื่นๆ จากนั้นจะรวบรวม isExpanded ด้วย collectAsState

  • ดังนั้น เราจึงสามารถผสมสถานะอัปสตรีมและโฟลว์ดาวน์สตรีมใน StateFlow ไว้ในบล็อกเดียวกันได้

  • และอย่าลืมเรื่องระดับรายละเอียด

  • ดังนั้น หากคุณสลับ isExpanded ระบบจะเรียกใช้บล็อกนี้ซ้ำเท่านั้น

  • การบล็อกระดับแอปและวิดเจ็ตไม่จำเป็นต้องสังเกตเห็นด้วยซ้ำ

  • สุดท้ายคือวิดเจ็ต

    class FocusWidget : GlanceAppWidget() {
        override suspend fun provideGlance(
            context: Context,
            id: GlanceId
        ) {
            val focusManager = ...
            provideContent {
                val summary by focusManager.summary
                    .collectAsState()
                GlanceTheme { FocusWidgetContent(summary) }
            }
        }
    }
  • เนื่องจาก Glance คือ Compose เราจึงสามารถรวบรวม StateFlow ด้วย collectAsState ภายใน provideContent ได้เหมือนกับ Composable อื่นๆ

  • แต่ Transform จะช่วยให้การดำเนินการนี้ง่ายขึ้นไปอีก

    class FocusWidget : GlanceAppWidget() {
        override suspend fun provideGlance(
            context: Context,
            id: GlanceId
        ) {
            val focusManager = ...
            provideContent {
                val summary by focusManager.summary
                GlanceTheme { FocusWidgetContent(summary) }
            }
        }
    }
  • เครื่องมือจัดการโฟกัสจะให้สถานะ Compose แก่เรา ดังนั้นเราจึงไม่ต้องแปลงอะไรที่นี่

  • และท้ายที่สุด แถบตัวจับเวลาขนาดเล็กและวิดเจ็ตบนหน้าจอหลักจะไม่มีทางขัดแย้งกันเนื่องจากอ่านสถานะเดียวกัน

  • และไม่เพียงเท่านั้น การเปลี่ยนแปลงใดๆ ที่เกิดขึ้นกับสถานะนั้นจะแสดงต่อผู้ใช้ทั้งหมดของสถานะนั้นด้วย

  • หมายความว่าอย่างไร

  • ดังนั้น หากคุณไปกดหยุดชั่วคราวในวิดเจ็ตที่นี่ ระบบจะส่งข้อมูลผ่านตัวจัดการไปยังสตรีมเซสชัน แล้วส่งกลับผ่านข้อมูลสรุป และหน้าจอทั้ง 3 หน้าจอนี้จะได้รับการอัปเดตพร้อมกัน

  • ตอนนี้เราได้ดูวิธีใช้ Transform ไปบ้างแล้ว มาดูข้อมูลเจาะลึกกันดีกว่า

  • Transform API ที่เราเห็นก่อนหน้านี้เป็นฟังก์ชันตัวช่วยที่ใช้สร้าง Transform ที่มีการจัดการขั้นพื้นฐาน

    public fun <R> transform(
        initialValue: R,
        scope: CoroutineScope,
        onUpdate: @Composable (R) -> R,
    ): State<R> {
            val transformScope = TransformScope()
        val transform = transformScope
    .launchTransform(initialValue, onUpdate)
        scope.launch(
    dispatcher,
    start = CoroutineStart.UNDISPATCHED
     ) {
            try { transformScope.run() } finally {
                transform.dispose()
            }
        }
        return transform
    }
  • ในความเป็นจริงแล้ว ฟังก์ชันนี้ใช้ฟังก์ชันส่วนขยาย TransformScope และ launchTransform ซึ่งจะเปิดใช้งานการเปลี่ยนรูปแบบ Composable ใน TransformScope นี้และแสดงผลสถานะที่ใช้แล้วทิ้ง

  • เมื่ออ่านจากบนลงล่าง คุณจะเห็นว่าระบบทำทุกอย่างให้คุณ

  • โดยจะสร้าง TransformScope และเปิดใช้ Transform ในนั้น ซึ่งจะเป็นสถานะที่คุณได้รับกลับมา

  • จากนั้นจะเปิดใช้การเรียกใช้ใน TransformScope บน Dispatcher ที่คุณระบุ เพื่อให้วงจรของขอบเขตและชุดข้อความนั้นเป็นของคุณ

  • และสุดท้ายจะกำจัดการเปลี่ยนรูปแบบเมื่อขอบเขตสิ้นสุดลง

  • นี่คือเรื่องราววงจรชีวิตทั้งหมดของ Transform

  • เรามาดูอินเทอร์เฟซของ TransformScope กันแบบเร็วๆ เลยดีกว่า

    public fun TransformScope(context: CoroutineContext = Dispatchers.Default): TransformScope
    
    public abstract class TransformScope {
            public fun <T> createTransform(initialState: T, onUpdate: @Composable (T) -> T): DisposableState<T>
    public abstract suspend fun run()
    }
    
    public fun <T> TransformScope.launchTransform(
    initialState: T,
    onUpdate: @Composable (T) -> T,
    ): DisposableState<T>
    
    public interface DisposableState<T> : State<T> {
    public fun dispose()
    }
  • เราจะเห็นว่ามีฟังก์ชัน createTransform ซึ่งจะสร้างการเปลี่ยนรูปแบบที่แนบมากับ TransformScope นี้

  • และยังมีฟังก์ชันการเรียกใช้ที่ระงับได้ ซึ่งจะเริ่ม TransformScope และเริ่มกระบวนการอัปเดตการเปลี่ยนรูปแบบสถานะปัจจุบัน

  • หมายความว่าอย่างไร

  • ดังนั้น run จะตั้งค่า Recomposer และ Composition จริงๆ

  • และ Transform ทุกรายการในขอบเขตจะอยู่ภายใน Composition นั้นและมีขอบเขตการจัดองค์ประกอบใหม่ของตัวเอง

  • ด้วยเหตุนี้ เราจึงต้องการแชร์ขอบเขตและเหตุผลที่เรามี TransformScope

  • เพื่อให้เมื่อใดก็ตามที่สถานะเปลี่ยนแปลง ระบบจะเรียกใช้เฉพาะ Transform ที่อ่านสถานะนั้นอีกครั้ง

  • ดังที่ได้กล่าวไว้ก่อนหน้านี้ run เป็นฟังก์ชันระงับ

    override suspend fun run() {
            val context = currentCoroutineContext()
            val clock = context[MonotonicFrameClock] ?: HeadlessTransformClock()
            ...
            val recomposer = Recomposer(context)
            val composition = Composition(UnitApplier, recomposer)
            try {
                withContext(clock) {
                    if (clock is HeadlessTransformClock) {
                        launch(start = CoroutineStart.UNDISPATCHED) {
                            clock.runClock()
                        }
                    }
                    ...
                }
            } finally {
                // dispose of composition, cancel the clock, close the recomposer
            }
        }
  • ดังนั้นจึงทำงานใน Coroutine ใดก็ตามที่คุณเรียกใช้จนกว่า Coroutine นั้นจะถูกยกเลิก

  • ตอนนี้คุณอาจสงสัยว่า Recomposer ต้องใช้นาฬิกาเฟรมเพื่อบอกว่าต้องสร้างเฟรมเมื่อใด

  • ใน Compose UI นั่นคือการแสดงผล แต่ดังที่กล่าวไว้ก่อนหน้านี้ เราอยู่ในสภาพแวดล้อมแบบ Headless จึงไม่มี UI

  • ดังนั้น หากคุณไม่ระบุนาฬิกาให้เรา เราจะสร้างนาฬิกาของเราเอง

  • การแยกการจัดองค์ประกอบใหม่จากนาฬิกาเฟรมการแสดงผลช่วยให้ Transform เรียกใช้การเปลี่ยนรูปแบบและการดึงข้อมูลสถานะทั้งหมดนอกเธรด UI ได้

  • เราจึงสร้างHeadlessTransformClockขึ้นมาโดยเฉพาะเพื่อรองรับการดำเนินการนี้

  • นาฬิกาแบบกรอบรูปที่เปลี่ยนรูปแบบได้

  • เนื่องจากวิดเจ็ต บริการ หรือ Transform อื่นๆ ที่อยู่ปลายน้ำอาจกำลังรอค่าจาก Transform อยู่ นาฬิกาของเราจึงทำงานอย่างรวดเร็ว

  • ซึ่งหมายความว่าเมื่อสถานะที่ Transform ใช้อยู่มีการเปลี่ยนแปลง ระบบจะสร้างเฟรมใหม่ทันที

  • ซึ่งเป็นเครื่องจักรสถานะขนาดเล็กที่ไม่มีการล็อก ดังนั้นการขอเฟรมจึงเป็นการดำเนินการเปรียบเทียบและตั้งค่าที่ง่ายดาย

  • และเมื่อไม่มีอะไรเปลี่ยนแปลงในสถานะ นาฬิกาจะหยุดทำงาน

  • ดังนั้น Transform ที่ใช้สถานะที่ไม่มีการเปลี่ยนแปลงเลยจะไม่มีค่าใช้จ่าย

  • ก่อนที่จะส่งต่อให้ Ben เราควรทราบด้วยว่าตอนนี้ Transform รองรับ rememberSaveable เพื่อให้มั่นใจว่าระบบจะบันทึกข้อมูลของคุณไว้เมื่อมีการเปลี่ยนแปลงการกำหนดค่า

  • โดยจะดำเนินการผ่าน SavedStateContainer API ใหม่ ซึ่งตอนนี้เป็นองค์ประกอบพื้นฐานที่ใช้สร้าง SaveableStateRegistry, SavedStateHandle และ SavedStateRegistry ของ Compose

  • คุณสามารถเปลี่ยนออบเจ็กต์เหล่านี้เป็น SavedStateContainer หรือสร้างคอนเทนเนอร์ย่อยและส่งไปยัง Transform เพื่อให้มั่นใจว่าระบบจะบันทึกข้อมูลของคุณ

  • สุดท้ายนี้ เราขอแจ้งเตือนอีกครั้งว่า API ทั้งหมดที่เราพูดถึงในวันนี้ยังอยู่ในเวอร์ชันอัลฟ่า จึงอาจมีการเปลี่ยนแปลงได้

  • หลังจากนี้ ฉันขอส่งต่อให้ Ben

สถานะ Compose นอก UI

  • โอเค

  • โอเค ขอบคุณ Elif

  • ตอนนี้เราได้เห็นแล้วว่าสถานะ Compose มีประสิทธิภาพเพียงใด และ Transform ช่วยให้เราดึงสถานะดังกล่าวออกมานอก UI ได้อย่างไร สิ่งหนึ่งที่คุณอาจสังเกตเห็นในตัวอย่างจนถึงตอนนี้คืออินพุตของเราเป็น Flow ที่เราเชื่อมโยงโดยใช้ collectAsState

  • แต่จำเป็นต้องทำเช่นนั้นเสมอไปไหม

  • ทำไมไม่ใช้สถานะ Compose โดยตรงในตัวเก็บสถานะ

  • ดังนั้น เพื่อตอบคำถามนั้น ผมคิดว่าการพิจารณาสถานะภายใน 3 หมวดหมู่กว้างๆ จะช่วยได้

  • สถานะแรกคือสถานะที่ใช้งานอยู่ซึ่งจะจัดเก็บไว้ในหน่วยความจำเท่านั้น

  • เช่น สถานะการเชื่อมต่อเครือข่ายหรือบลูทูธ

  • คุณไม่ควรใช้ค่าที่เก็บถาวร

  • และสถานะที่ 2 คือสถานะชั่วคราวที่ควรคงอยู่หลังจากการสิ้นสุดการประมวลผล แต่ไม่จำเป็นต้องจัดเก็บไว้อย่างไม่มีกำหนด

  • ลองนึกถึงสิ่งต่างๆ เช่น สถานะ UI เช่น การ์ดขยายหรือไม่ และสถานะเซสชัน เช่น Back Stack การนำทางหรือแบบร่างของแบบฟอร์ม

  • และสถานะที่ 3 คือสถานะถาวร ซึ่งจะจัดเก็บไว้โดยไม่มีกำหนด

  • เช่น ค่ากำหนดของผู้ใช้หรือคลังสื่อที่อยู่ในฐานข้อมูล

  • เรามาเริ่มกันที่วิธีใช้สถานะ Compose สำหรับทั้งสถานะแบบเรียลไทม์และสถานะชั่วคราว

  • จนถึงตอนนี้ คำแนะนำด้านสถาปัตยกรรมของเรายังคงเก็บสถานะ Compose ไว้ในเลเยอร์ UI หรือบางครั้งก็เก็บไว้ใน ViewModel และเราได้บอกให้คุณใช้ Flow ในที่อื่นๆ ทั้งหมด

  • แต่ทำไมจึงเป็นเช่นนั้น

  • และนั่นเป็นเพราะทันทีที่คุณต้องการรวมพร็อพเพอร์ตี้สถานะกับ Flow คุณต้องแปลงกลับโดยใช้ snapshotFlow

  • แต่เมื่อใช้ Transform คุณไม่จำเป็นต้องแปลงไฟล์

  • มาดูกันว่าสิ่งนี้หมายถึงอะไร โดยเริ่มจากViewModel

    @HiltViewModel
    class TaskListViewModel @Inject constructor(
        private val tasksRepository: TasksRepository,
        private val networkMonitor: NetworkMonitor,
        savedStateHandle: SavedStateHandle,
    ) : ViewModel() {
        ...
    }
  • สำหรับอินพุตหน้าจอในเครื่อง เช่น selectedTaskIds ที่นี่ คุณสามารถประกาศพร็อพเพอร์ตี้ mutableStateOf ธรรมดาแทน MutableStateFlow ได้

    @HiltViewModel
    class TaskListViewModel @Inject constructor(...) : ViewModel() {
        var selectedTaskIds by mutableStateOf(emptySet<String>())
            private set
    }
  • และเนื่องจากนี่เป็นสิ่งที่คุณไม่จำเป็นต้องคงไว้ตลอดการสิ้นสุดการประมวลผล จึงไม่เป็นไรหากไม่ได้ใช้ mutableStateOf

  • สำหรับสิ่งที่ต้องการให้คงอยู่หลังจากการสิ้นสุดการประมวลผล เช่น คำค้นหา คุณสามารถสำรองสถานะที่เปลี่ยนแปลงได้โดยตรงด้วย savedStateHandle.saveable

    @HiltViewModel
    class TaskListViewModel @Inject constructor(...) : ViewModel() {
        var selectedTaskIds by mutableStateOf(emptySet<String>())
            private set
    
        var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
            private set
    }
  • และเช่นเดียวกับ updateScore ในตัวอย่างเกมก่อนหน้านี้ เมื่อเมธอดอัปเดตค่าสถานะหลายค่าพร้อมกัน การรวมการเขียนไว้ใน withMutableSnapshot จะช่วยให้มั่นใจได้ถึงความสอดคล้อง

    @HiltViewModel
    class TaskListViewModel @Inject constructor(...) : ViewModel() {
        var selectedTaskIds by mutableStateOf(emptySet<String>())
            private set
    
        var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
            private set
    
        fun updateSearchQuery(query: String) {
            Snapshot.withMutableSnapshot {
                searchQuery = query
                selectedTaskIds = emptySet()
            }
        }
    }
  • จากนั้น หากต้องการรวม searchQuery เข้ากับ Room Flow คุณก็แค่อ่านข้อมูลจากภายใน Transform โดยตรงขณะรวบรวม Flow

    val tasksState: State<TaskListState> = transform(
        initialValue = TaskListState.Loading,
        scope = viewModelScope,
    ) {
        val tasks = remember(searchQuery) {
            tasksRepository.getTasks(searchQuery)
        }.collectAsState(initial = null).value
            ?: return@transform TaskListState.Loading
    
        TaskListState.Success(tasks = tasks)
    }
  • แล้วสถานะแบบเรียลไทม์ เช่น การเชื่อมต่อเครือข่ายล่ะ

    @Singleton
    class NetworkMonitor @Inject constructor(
        @ApplicationContext context: Context,
    ) {
        private val connectivityManager =
            context.getSystemService(ConnectivityManager::class.java)
        ...
    }
  • ซึ่งมักจะอยู่ใน Singleton เนื่องจากมีการอ่านในหลายหน้าจอหรือแม้แต่โดยคอมโพเนนต์แอปหลายรายการ เช่น บริการ

  • แต่ไม่ได้หมายความว่าคุณจะใช้ Compose State เพื่อจัดเก็บไม่ได้

  • อันที่จริงแล้ว วิธีนี้มักจะง่ายกว่าเวอร์ชันที่อิงตามโฟลว์

  • คุณไม่จำเป็นต้องมีโฟลว์ Callback หรืออะไรก็ตามเพื่อทำการปรับเปลี่ยน เพียงแค่อัปเดตค่าจุดใน Callback โดยตรง

    @Singleton
    class NetworkMonitor @Inject constructor(...) {
        ...
        private val _isOnline = mutableStateOf(checkCurrentConnectivity())
        val isOnline: State<Boolean> = _isOnline
    }
  • และหากผู้บริโภคยังต้องการโฟลว์ คุณสามารถใช้ snapshotFlow เพื่อเชื่อมต่อกลับได้

    @Singleton
    class NetworkMonitor @Inject constructor(...) {
        ...
        private val _isOnline = mutableStateOf(checkCurrentConnectivity())
        val isOnline: State<Boolean> = _isOnline
    
        private val callback = object : ConnectivityManager.NetworkCallback() {
            override fun onAvailable(network: Network) { _isOnline.value = true }
            override fun onLost(network: Network) { _isOnline.value = false }
        }
    
        init {
            connectivityManager.registerDefaultNetworkCallback(callback)
        }
    }
  • มาดูกันว่าหน้าตาของฟีเจอร์นี้ใน ViewModel เป็นอย่างไร

  • คุณสามารถอ่าน isOnline ได้โดยตรงใน Transform และระบบจะจัดองค์ประกอบใหม่เมื่อค่ามีการเปลี่ยนแปลง

    val tasksState: State<TaskListState> = transform(
        initialValue = TaskListState.Loading,
        scope = viewModelScope,
    ) {
        if (!networkMonitor.isOnline.value) {
            return@transform TaskListState.Offline
        }
    
        val tasks = remember(searchQuery) {
            tasksRepository.getTasks(searchQuery)
        }.collectAsState(initial = null).value
            ?: return@transform TaskListState.Loading
    
        TaskListState.Success(tasks = tasks)
    }
  • และทั้งหมดนี้เกิดขึ้นโดยตรง ไม่จำเป็นต้องระบุขอบเขตแอปพลิเคชันหรือขอบเขตโครูทีนเพื่ออัปเดตสถานะ เป็นเพียงการอัปเดตแบบซิงโครนัสโดยตรง

  • ดังนั้นเมื่อใช้ Transform ViewModel ของคุณจะสามารถรวม State จาก Singleton Manager, State ใน ViewModel และ Flow จากที่เก็บข้อมูลไว้ในบล็อก Transform เดียวได้

  • ตอนนี้เราจะกลับไปที่สถานะชั่วคราว

  • ก่อนหน้านี้ ฉันได้พูดถึงสถานะ 2 ประเภท ได้แก่ สถานะ UI และสถานะเซสชัน

  • สำหรับสถานะ UI และแม้แต่สถานะเซสชันที่อยู่ภายในกิจกรรมเดียว ViewModel และ SavedState จะทำงานได้ดีมาก

  • นั่นคือสิ่งที่ออกแบบมาเพื่อจุดประสงค์นี้โดยเฉพาะ

  • แต่ปัญหาจะเริ่มเกิดขึ้นเมื่อต้องแชร์สถานะเซสชันนั้นใน UI และคอมโพเนนต์ต่างๆ ของแอป

  • เมื่อสถานะนั้นอยู่นอก ViewModel API และคำแนะนำที่มีอยู่ของเราจะทำให้เกิดช่องว่างเล็กน้อย

  • ดังนั้น อันดับแรก SavedState API จะบันทึกลงใน Bundle ของกิจกรรม แต่จะใช้ไม่ได้ในคอมโพเนนต์อื่นๆ ของแอป เช่น บริการ

  • ประการที่ 2 mutableStateOf ใน Singleton ซึ่งอยู่ในหน่วยความจำเท่านั้น จึงไม่คงอยู่หลังจากการสิ้นสุดการประมวลผล

ขอแนะนำ StateStore

  • และนั่นคือช่องว่างที่ Jetpack AppState StateStore เติมเต็ม

  • ซึ่งเป็นแผนที่ที่ปลอดภัยในการกำหนดประเภทของสถานะ Compose ที่สามารถแชร์ในคอมโพเนนต์ต่างๆ ของแอปและได้รับการสนับสนุนโดย DataStore เพื่อให้รอดพ้นจากการสิ้นสุดการประมวลผล

  • คุณสามารถเชื่อมต่อ DataStore เข้ากับ Singleton ทุกรายการด้วยตนเองได้ แต่นั่นหมายความว่าคุณต้องเขียนโค้ดสำเร็จรูปสำหรับบันทึกและกู้คืนพร็อพเพอร์ตี้ทุกรายการ

  • การผสานรวม StateStore DataStore ช่วยให้คุณเชื่อมต่อ DataStore เพียงครั้งเดียว จากนั้นเลือกใช้สถานะเล็กๆ น้อยๆ แต่ละสถานะที่ต้องการคงไว้

  • งั้นเรามาสร้างฟีเจอร์นั้นในแอปตัวจับเวลาการโฟกัสกัน

  • เราจะเริ่มในหน่วยความจำก่อน แล้วจึงเพิ่มความคงอยู่ของ DataStore ในภายหลัง

  • เพื่อเป็นการปูพื้นฐาน เราจะจำลอง FocusSession เป็นอินเทอร์เฟซที่ปิดผนึกซึ่งมีสถานะไม่ได้ใช้งาน กำลังทำงาน และหยุดชั่วคราว

    sealed interface FocusSession {
        data object Idle : FocusSession
        data class Running(val taskId: String, val endTimeMillis: Long) : FocusSession
        data class Paused(val taskId: String, val remainingMillis: Long) : FocusSession
    }
  • จากนั้น หากต้องการจัดเก็บสถานะนี้ใน StateStore เราได้กำหนด StateStoreKey ด้วยประเภท FocusSession และค่าเริ่มต้นเป็น "ไม่มีการใช้งาน"

    sealed interface FocusSession { ... }
    
    object FocusSessionKey :
        StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle)
  • จากนั้นเราจะจัดเตรียม Singleton StateStore ใน Hilt ที่เราสามารถแทรกลงในคอมโพเนนต์ต่างๆ ที่ต้องการได้ เช่น Focus Session Manager

    @Module
    @InstallIn(SingletonComponent::class)
    object AppModule {
        @Provides
        @Singleton
        fun provideStateStore(): StateStore = StateStore()
    }
    
    @Singleton
    class FocusSessionManager @Inject constructor(
        private val stateStore: StateStore,
    ) {
        ...
    }
  • ซึ่งคล้ายกับวิธีที่ ViewModel แทรก SavedStateHandle

  • ตกลง

  • ตอนนี้เรามี StateStore แล้ว แต่จะใช้ยังไงดี

  • แต่ข้อดีคือ API นี้มีขนาดเล็ก

  • ซึ่งมีเพียง 3 วิธีเท่านั้น เราจะอธิบายทีละวิธี

  • วิธีแรกคือ setState ซึ่งจะเขียนค่าใหม่สำหรับคีย์

    fun startFocus(taskId: String, durationMinutes: Int = 25) {
        val endTime = System.currentTimeMillis() + durationMinutes * 60_000L
        stateStore.setState(
            FocusSessionKey,
            FocusSession.Running(taskId = taskId, endTimeMillis = endTime),
        )
    }
    
    fun endFocus() {
        stateStore.setState(FocusSessionKey, FocusSession.Idle)
    }
  • เมื่อเซสชันเริ่มต้น เราจะตั้งค่าเป็น "กำลังทำงาน" และเมื่อเซสชันสิ้นสุดลง เราจะตั้งค่าเป็น "ไม่มีการใช้งาน"

  • เท่านี้เอง

  • อย่างที่ 2 เรายังมี updateState ซึ่งช่วยให้คุณคำนวณค่าใหม่โดยอิงตามค่าปัจจุบัน เช่น การคำนวณเวลาที่เหลือเมื่อสลับระหว่างการทำงานและการหยุดชั่วคราว

    fun pauseFocus(nowMillis: Long = System.currentTimeMillis()) {
        stateStore.updateState(FocusSessionKey) { session ->
            if (session !is FocusSession.Running) return@updateState session
            FocusSession.Paused(session.taskId, session.endTimeMillis - nowMillis)
        }
    }
    
    fun resumeFocus(nowMillis: Long = System.currentTimeMillis()) {
        stateStore.updateState(FocusSessionKey) { session ->
            if (session !is FocusSession.Paused) return@updateState session
            FocusSession.Running(session.taskId, nowMillis + session.remainingMillis)
        }
    }
  • และสุดท้ายคือ getState ซึ่งจะแสดงผล State แบบอ่านอย่างเดียวสำหรับคีย์ และมีตัวเลือกในการลบล้างค่าเริ่มต้น หากคุณต้องการเปลี่ยนค่าดังกล่าวที่ Call Site ที่เฉพาะเจาะจง

    @Singleton
    class FocusSessionManager @Inject constructor(
        private val stateStore: StateStore,
    ) {
        val session: State<FocusSession> = stateStore.getState(FocusSessionKey)
        ...
    }
  • นอกจากนี้ การแสดงสถานะ FocusSession ในเครื่องมือจัดการนี้หมายความว่าผู้บริโภคไม่จำเป็นต้องเข้าถึง StateStore หรือคีย์ของ โดยตรง

  • คุณสามารถรักษาการห่อหุ้มการเขียนและการอ่านได้

  • จากนั้นแทนที่จะเป็นผู้บริโภค พวกเขาเพียงแค่แทรก Focus Session Manager ซึ่งพวกเขาน่าจะมีอยู่แล้วเพราะต้องควบคุมเซสชัน จากนั้นจึงจะอ่านสถานะได้

    @HiltViewModel
    class MiniTimerViewModel @Inject constructor(
        focusSessionManager: FocusSessionManager,
    ) : ViewModel() { ... }
    
    @EntryPoint
    @InstallIn(SingletonComponent::class)
    interface WidgetEntryPoint {
        fun focusSessionManager(): FocusSessionManager
    }
  • จากนั้น Reader เหล่านั้นจะแสดง State ซ้ำโดยตรง หรือจะใช้ Transform เพื่อสร้าง State ใหม่ที่อัปเดตแล้วก็ได้ เหมือนกับที่เราเห็นในตัวอย่าง Mini-Timer ViewModel ก่อนหน้านี้

    @HiltViewModel
    class MiniTimerViewModel @Inject constructor(
        focusSessionManager: FocusSessionManager,
    ) : ViewModel() {
        val session: State<FocusSession> = focusSessionManager.session
    }
  • จากนั้น UI Composable จะอ่านสถานะนั้นได้เช่นเดียวกับ Compose State อื่นๆ และจัดองค์ประกอบใหม่เมื่อจำเป็น

    @HiltViewModel
    class MiniTimerViewModel @Inject constructor(
        focusSessionManager: FocusSessionManager,
    ) : ViewModel() {
        val session: State<FocusSession> = focusSessionManager.session
    }
    
    @Composable
    fun MiniTimerBar(viewModel: MiniTimerViewModel = hiltViewModel()) {
        val session by viewModel.session
        if (session !is FocusSession.Idle) {
            TimerReadout(session = session)
        }
    }
  • แต่จนถึงตอนนี้ FocusSession ก็ยังคงเป็นเพียงความทรงจำ

  • มาดูกันว่าต้องทำอย่างไรจึงจะรอดพ้นจากการสิ้นสุดการประมวลผล

  • ดังนั้น ก่อนอื่นเราต้องตั้งค่าแบบครั้งเดียวโดยเปิด syncToDataStore เมื่อสร้างร้านค้า

    @Module
    @InstallIn(SingletonComponent::class)
    object AppModule {
        @Provides
        @Singleton
        fun provideStateStore(
            @ApplicationContext context: Context,
            @ApplicationScope scope: CoroutineScope,
        ): StateStore = StateStore().apply {
            val path = context.dataStoreFile("app_state.json").absolutePath
            scope.launch { syncToDataStore(path = path, coroutineScope = scope) }
        }
    }
  • จากนั้นเราจะเลือกใช้คีย์แต่ละรายการที่ต้องการคงไว้

    @Serializable
    sealed interface FocusSession {
        @Serializable data object Idle : FocusSession
        @Serializable data class Running(val taskId: String, val endTimeMillis: Long) : FocusSession
        @Serializable data class Paused(val taskId: String, val remainingMillis: Long) : FocusSession
    }
    
    object FocusSessionKey :
        StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle),
        PersistToDataStore
  • สิ่งที่เราต้องทำคือตรวจสอบว่าประเภทนั้นเป็นแบบอนุกรมได้ และคีย์ใช้อินเทอร์เฟซ PersistToDataStore

  • คุณอาจสงสัยว่าดูเหมือนจะง่าย

  • แล้วฟีเจอร์นี้ทำอะไรเพื่อคงคีย์ไว้

  • ดังนั้น syncToDataStore จะใช้ Listener API ของ Transform ซึ่งโดยพื้นฐานแล้วเหมือนกับ Transform ทุกประการ ยกเว้นว่าจะไม่แสดงผลใดๆ แต่ใช้เพื่อการสังเกตการณ์และผลข้างเคียงเท่านั้น

    suspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
        listener { … }
    }
  • จากนั้นภายใน Listener นั้น ระบบจะอ่านคีย์ทุกคีย์ภายใน Store และตรวจสอบว่าคีย์ใดเป็นคีย์ PersistToDataStore

    suspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
        listener {
            for (key in keys) {
                if (key is PersistToDataStore) {
                    val value = getState(key).value
    
                    LaunchedEffect(key) {
                        // Restore saved value from dataStore on first access
                    }
    
                    LaunchedEffect(key, value) {
                        // Flush updated value to dataStore on change
                    }
                }
            }
        }
    }
  • เมื่อเปิดใช้ ระบบจะใช้ LaunchedEffect เพื่อซิงค์ค่าเหล่านั้นกลับไปยัง DataStore เมื่อใดก็ตามที่มีการเปลี่ยนแปลง

  • และสิ่งที่เราต้องการชี้ให้เห็นคือการมีคอนเทนเนอร์มาตรฐานอย่าง StateStore ช่วยให้เราสามารถผสานรวมแบบดรอปอินได้

  • DataStore เป็นเพียงตัวอย่างแรกที่เราจะพิจารณา

สรุปหมวดหมู่สถานะการเขียน

  • เมื่อย้อนกลับไปที่หมวดหมู่สถานะ ตอนนี้เรามีภาพที่ค่อนข้างชัดเจนเกี่ยวกับวิธีใช้สถานะ Compose ในแอปพลิเคชันทั้งหมด

  • ตั้งแต่ mutableStateOf สำหรับสถานะแบบเรียลไทม์ ไปจนถึง SavedState API สำหรับสถานะการนำเสนอในหน้าจอ และ StateStore สำหรับสถานะเซสชันข้ามคอมโพเนนต์ และสุดท้าย เรามี collectAsState สำหรับการทำงานร่วมกับ Flow

  • แต่จริงๆ แล้วความสามารถในการทำงานร่วมกันไม่ได้จำกัดอยู่แค่หมวดหมู่เดียว คุณสามารถใช้ตัวช่วยเหล่านั้น เช่น collectAsState ได้ทุกเมื่อที่ต้องการเชื่อมโยงประเภทที่สังเกตได้เข้ากับ Compose State

  • แต่สำหรับสถานะถาวร อีกสิ่งหนึ่งที่เรากำลังพิจารณาคือวิธีทำงานร่วมกับ Compose หรือให้ DataStore และ Room แสดง Compose State โดยตรง เพื่อให้คุณไม่ต้องเขียนไวยากรณ์ที่สวยงามขึ้นเองเพื่อทำสิ่งนั้น

  • และเรายินดีที่จะประกาศว่า Transform, StateStore และการผสานรวม DataStore พร้อมให้ใช้งานในเวอร์ชันอัลฟ่าแล้ววันนี้ นอกจากนี้ เรายังได้เผยแพร่เอกสารประกอบทั้งหมดใน developer.android.com และมีที่เก็บ AppState Recipes ใน GitHub ที่แสดงตัวอย่างการใช้ทั้ง StateStore และ Transform ด้วย

    [versions]
    appstate = "1.0.0-alpha01"
    ...
    
    [libraries]
    ...
    transform = { module = "androidx.appstate.transform:transform", version.ref = "appstate" }
    transform-testing = { module = "androidx.appstate.transform:transform-testing", version.ref = "appstate" }
    statestore = { module = "androidx.appstate.statestore:statestore", version.ref = "appstate" }
    statestore-datastore = { module = "androidx.appstate.statestore:statestore-datastore", version.ref = "appstate" }
  • และโปรดลองใช้ฟีเจอร์เหล่านี้ในแอปของคุณ

  • แสดงความคิดเห็น

  • ตอนนี้เราอยู่ในเวอร์ชันอัลฟ่า ดังนั้นเราจึงสามารถปรับปรุงและทำการเปลี่ยนแปลงเพื่อให้แน่ใจว่าฟีเจอร์นี้จะทำงานได้ตาม Use Case ของคุณ

รหัส

ข้อมูลโค้ด

ข้อมูลโค้ด Kotlin ที่นาที 01:56 ใน "What is Compose State?"
/**
 * A value holder where reads to the [value] property during the execution of a [Composable]
 * function, the current [RecomposeScope] will be subscribed to changes of that value.
 */
@Stable
public interface State<out T> {
    public val value: T
}
ข้อมูลโค้ด Kotlin ที่นาที 02:00 ใน "What is Compose State?"
/**
 * A mutable value holder where reads to the [value] property during the execution of a [Composable]
 * function, the current [RecomposeScope] will be subscribed to changes of that value. When the
 * [value] property is written to and changed, a recomposition of any subscribed [RecomposeScope]s
 * will be scheduled. If [value] is written to with the same value, no recompositions will be
 * scheduled.
 */
@Stable
public interface MutableState<T> : State<T> {
    override var value: T
    public operator fun component1(): T
    public operator fun component2(): (T) -> Unit
}
ข้อมูลโค้ด Kotlin ที่นาที 02:23 ใน "What is Compose State?"
@Composable
fun Counter() {
    val count = remember { mutableStateOf(0) }
}
ข้อมูลโค้ด Kotlin ที่นาที 02:32 ใน "สถานะ Compose คืออะไร"
@Composable
fun Counter() {
    val count = remember { mutableStateOf(0) }
    Column {
        Text("Count: ${count.value}")
        AddButton(onClick = { count.value++ })
    }
}
ข้อมูลโค้ด Kotlin ที่นาที 02:52 ใน "สถานะ Compose คืออะไร"
@Composable
fun Counter() {
    val count = remember { mutableStateOf(0) }
    Column {
        Text("Count: ${count.value}")
        AddButton(onClick = { count.value++ })
    }
}

@Composable
fun AddButton(onClick: () -> Unit) {
    Button(onClick = onClick) {
        Text("Add")
    }
}
ข้อมูลโค้ด Kotlin ที่ 03:08 ใน "สถานะ Compose คืออะไร"
@Composable
fun Counter() {
    val isVisible = remember { mutableStateOf(true) }
    val count = remember { mutableStateOf(0) }
    Column {
        if (isVisible.value) {
            Text("Count: ${count.value}")
        } else {
            Text("Hidden")
        }
        AddButton(onClick = { count.value++ })
    }
}
ข้อมูลโค้ด Kotlin ที่นาที 03:26 ใน "สถานะ Compose คืออะไร"
@Composable
fun Counter() {
    val isVisible = remember { mutableStateOf(false) }
    val count = remember { mutableStateOf(0) }
    Column {
        if (isVisible.value) {
            Text("Count: ${count.value}")
        } else {
            Text("Hidden")
        }
        AddButton(onClick = { count.value++ })
    }
}
ข้อมูลโค้ด Kotlin ที่นาที 04:15 ในวิดีโอ "What is Compose State?"
val levelName: String = "Level 1 - Stay Alive!"
val timeSurvived: Int = 0
val score: Double = 0.0
ข้อมูลโค้ด Kotlin ที่นาที 04:23 ใน "What is Compose State?"
data class GameUiState(
    val levelName: String = "Level 1 - Stay Alive!",
    val timeSurvived: Int = 0,
    val score: Double = 0.0
)
ข้อมูลโค้ด Kotlin ที่นาที 04:33 ใน "สถานะ Compose คืออะไร"
data class GameUiState(
    val levelName: String = "Level 1 - Stay Alive!",
    val timeSurvived: Int = 0,
    val score: Double = 0.0
)

class GameViewModel : ViewModel() {
    val uiState = MutableStateFlow(GameUiState())
    fun updateScore() = uiState.update {
        it.copy(
            timeSurvived = it.timeSurvived + 1,
            score = it.score + 10 + .01 * it.timeSurvived
        )
    }
}
ข้อมูลโค้ด Kotlin ที่นาที 04:42 ใน "What is Compose State?"
data class GameUiState(
    val levelName: String = "Level 1 - Stay Alive!",
    val timeSurvived: Int = 0,
    val score: Double = 0.0
)

class GameViewModel : ViewModel() {
    val uiState = MutableStateFlow(GameUiState())
    fun updateScore() = uiState.update {
        it.copy(
            timeSurvived = it.timeSurvived + 1,
            score = it.score + 10 + .01 * it.timeSurvived
        )
    }
}

@Composable
fun GameScreen(viewModel: GameViewModel = viewModel()) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()
    Column {
        Header(levelName = state.levelName)
        ScoreBadge(
            timeSurvived = state.timeSurvived,
            score = state.score
        )
    }
}
ข้อมูลโค้ด Kotlin ที่นาที 05:09 ใน "สถานะ Compose คืออะไร"
@Stable
class GameUiState {
    var levelName by mutableStateOf("Level 1 - Stay Alive!")
    var timeSurvived by mutableStateOf(0)
    var score by mutableStateOf(0.0)
}
ข้อมูลโค้ด Kotlin ที่นาที 05:21 ใน "สถานะ Compose คืออะไร"
@Stable
class GameUiState {
    var levelName by mutableStateOf("Level 1 - Stay Alive!")
    var timeSurvived by mutableStateOf(0)
    var score by mutableStateOf(0.0)
}

class GameViewModel : ViewModel() {
    val uiState = GameUiState()
    fun updateScore() = Snapshot.withMutableSnapshot {
        uiState.timeSurvived += 1
        uiState.score += 10 + .01 * uiState.timeSurvived
    }
}
ข้อมูลโค้ด Kotlin ที่นาที 05:30 ใน "What is Compose State?"
@Stable
class GameUiState {
    var levelName by mutableStateOf("Level 1 - Stay Alive!")
    var timeSurvived by mutableStateOf(0)
    var score by mutableStateOf(0.0)
}

class GameViewModel : ViewModel() {
    val uiState = GameUiState()
    fun updateScore() = Snapshot.withMutableSnapshot {
        uiState.timeSurvived += 1
        uiState.score += 10 + .01 * uiState.timeSurvived
    }
}

@Composable
fun GameScreen(viewModel: GameViewModel = viewModel()) {
    val state = viewModel.uiState
    Column {
        Header(state = state)
        ScoreBadge(state = state)
    }
}
ข้อมูลโค้ด Kotlin ที่ 07:35 ใน "Introducing Transform"
public fun <R> transform(
    initialValue: R,
    scope: CoroutineScope,
    onUpdate: @Composable (R) -> R,
): State<R>
ข้อมูลโค้ด Kotlin ที่ 09:20 ใน "Introducing Transform"
class FocusScreenViewModel(
    focusManager: FocusSessionManager,
    taskRepository: TaskRepository
) : ViewModel() {
    val summary: StateFlow<FocusSummary> =
        combine(focusManager.sessionFlow, taskRepository.tasksFlow) { session, tasks ->
            if (session.phase == FocusPhase.Idle) {
                FocusSummary.Idle
            } else {
                val task = tasks.find { it.id == session.activeTaskId }
                FocusSummary.Active(...)
            }
        }.stateIn(
                scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = FocusSummary.Idle,
)
}
ข้อมูลโค้ด Kotlin ที่ 09:37 ใน "Introducing Transform"
class FocusScreenViewModel(
    focusManager: FocusSessionManager,
    taskRepository: TaskRepository
) : ViewModel() {
    val summary: State<FocusSummary> =
        transform<FocusSummary>(
initialValue = FocusSummary.Idle,
scope = viewModelScope,
container = handle.getOrCreateContainer("transform")
) {
            val session by focusManager.sessionFlow.collectAsState()
            if (session.phase == FocusPhase.Idle) return@transform FocusSummary.Idle
            val tasks by taskRepository.tasksFlow.collectAsState(emptyList())
            val task = tasks.find { it.id == session.activeTaskId }
            FocusSummary.Active(...)
ข้อมูลโค้ด Kotlin ที่นาที 10:01 ใน "Introducing Transform"
class FocusScreenViewModel(
    focusManager: FocusSessionManager,
    taskRepository: TaskRepository
) : ViewModel() {
    val summary: State<FocusSummary> =
        transform<FocusSummary>(
            initialValue = FocusSummary.Idle,
            scope = viewModelScope,
                   container = handle.getOrCreateContainer("transform")
        ) {
            val session by focusManager.sessionFlow.collectAsState()
            if (session.phase == FocusPhase.Idle)
                return@transform FocusSummary.Idle
            val tasks by taskRepository.tasksFlow.collectAsState(emptyList())
            val task = tasks.find {
                it.id == session.activeTaskId
            }
                     FocusSummary.Active(...)
        }
}
ข้อมูลโค้ด Kotlin ที่ 11:39 ใน "Nested transforms"
class FocusSessionManager(taskRepository: TaskRepository, scope: CoroutineScope) {
    ...
}

class MiniTimerViewModel(focusManager: FocusSessionManager) : ViewModel() {
    ...
}

class FocusWidget : GlanceAppWidget() {
    override suspend fun provideGlance(context: Context, id: GlanceId) {
        provideContent {
            ...
        }
    }
}
ข้อมูลโค้ด Kotlin ที่นาที 12:03 ใน "Nested transforms"
class FocusSessionManager(
    taskRepository: TaskRepository,
    scope: CoroutineScope
) {
    val sessionFlow: StateFlow<FocusSession> = ...
    val summary: StateFlow<FocusSummary> =
        combine(
            sessionFlow,
            taskRepository.tasksFlow
        ) { session, tasks ->
            if (session.phase == FocusPhase.Idle) {
                FocusSummary.Idle
            } else {
                val task = tasks.find {
                    it.id == session.activeTaskId
                }
                FocusSummary.Active(...)
            }
        }.stateIn(
scope = appScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = FocusSummary.Idle
)
}
ข้อมูลโค้ด Kotlin ที่ 12:17 ใน "Nested transforms"
class FocusSessionManager(
    taskRepository: TaskRepository,
    scope: CoroutineScope
) {
    val sessionFlow: StateFlow<FocusSession> = ...
    val summary: State<FocusSummary> =
        transform<FocusSummary>(
           initialValue = FocusSummary.Idle,
scope = scope,
container = handle.getOrCreateContainer("transform")
        ) {
            val session by sessionFlow.collectAsState()
            if (session.phase == FocusPhase.Idle)
                return@transform FocusSummary.Idle
            val tasks by taskRepository.tasksFlow
                .collectAsState(emptyList())
            val task = tasks.find {
                it.id == session.activeTaskId
            }
            FocusSummary.Active(...)
        }
}
ข้อมูลโค้ด Kotlin ที่ 12:28 ใน "Nested transforms"
class MiniTimerViewModel(
    focusManager: FocusSessionManager
) : ViewModel() {
    val isExpanded = MutableStateFlow(false)

    val uiState: StateFlow<MiniTimerUiState> =
        combine(
            focusManager.summary,
            isExpanded
        ) { summary, expanded ->
            MiniTimerUiState(
    summary = summary,
    showControls = expanded && summary is FocusSummary.Active
)
        }.stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = MiniTimerUiState()
)

    fun toggleExpanded() { isExpanded.update { !it } }
}
ข้อมูลโค้ด Kotlin ที่นาที 12:56 ใน "Nested transforms"
class MiniTimerViewModel(
    focusManager: FocusSessionManager
) : ViewModel() {
    val isExpanded = MutableStateFlow(false)

    val uiState: State<MiniTimerUiState> =
        transform(
            initialValue = MiniTimerUiState(),
            scope = viewModelScope,
                   container = handle.getOrCreateContainer("transform")
        ) {
            val summary by focusManager.summary
                   val expanded by isExpanded.collectAsState()
            MiniTimerUiState(
    summary = summary,
    showControls = expanded && summary is FocusSummary.Active
)
        }

    fun toggleExpanded() { isExpanded.update { !it } }
}
ข้อมูลโค้ด Kotlin ที่นาที 13:37 ใน "Nested transforms"
class FocusWidget : GlanceAppWidget() {
    override suspend fun provideGlance(
        context: Context,
        id: GlanceId
    ) {
        val focusManager = ...
        provideContent {
            val summary by focusManager.summary
                .collectAsState()
            GlanceTheme { FocusWidgetContent(summary) }
        }
    }
}
ข้อมูลโค้ด Kotlin ที่ 13:47 ใน "Nested transforms"
class FocusWidget : GlanceAppWidget() {
    override suspend fun provideGlance(
        context: Context,
        id: GlanceId
    ) {
        val focusManager = ...
        provideContent {
            val summary by focusManager.summary
            GlanceTheme { FocusWidgetContent(summary) }
        }
    }
}
ข้อมูลโค้ด Kotlin ที่นาที 14:33 ใน "Nested transforms"
public fun <R> transform(
    initialValue: R,
    scope: CoroutineScope,
    onUpdate: @Composable (R) -> R,
): State<R> {
        val transformScope = TransformScope()
    val transform = transformScope
.launchTransform(initialValue, onUpdate)
    scope.launch(
dispatcher,
start = CoroutineStart.UNDISPATCHED
 ) {
        try { transformScope.run() } finally {
            transform.dispose()
        }
    }
    return transform
}
ข้อมูลโค้ด Kotlin ที่ 15:18 ใน "Nested transforms"
public fun TransformScope(context: CoroutineContext = Dispatchers.Default): TransformScope

public abstract class TransformScope {
        public fun <T> createTransform(initialState: T, onUpdate: @Composable (T) -> T): DisposableState<T>
public abstract suspend fun run()
}

public fun <T> TransformScope.launchTransform(
initialState: T,
onUpdate: @Composable (T) -> T,
): DisposableState<T>

public interface DisposableState<T> : State<T> {
public fun dispose()
}
ข้อมูลโค้ด Kotlin ที่นาที 16:02 ใน "Nested transforms"
override suspend fun run() {
        val context = currentCoroutineContext()
        val clock = context[MonotonicFrameClock] ?: HeadlessTransformClock()
        ...
        val recomposer = Recomposer(context)
        val composition = Composition(UnitApplier, recomposer)
        try {
            withContext(clock) {
                if (clock is HeadlessTransformClock) {
                    launch(start = CoroutineStart.UNDISPATCHED) {
                        clock.runClock()
                    }
                }
                ...
            }
        } finally {
            // dispose of composition, cancel the clock, close the recomposer
        }
    }
ข้อมูลโค้ด Kotlin ที่ 19:12 ใน "Compose State outside of the UI"
@HiltViewModel
class TaskListViewModel @Inject constructor(
    private val tasksRepository: TasksRepository,
    private val networkMonitor: NetworkMonitor,
    savedStateHandle: SavedStateHandle,
) : ViewModel() {
    ...
}
ข้อมูลโค้ด Kotlin ที่ 19:17 ใน "Compose State outside of the UI"
@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
    var selectedTaskIds by mutableStateOf(emptySet<String>())
        private set
}
ข้อมูลโค้ด Kotlin ที่เวลา 19:33 ใน "Compose State นอก UI"
@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
    var selectedTaskIds by mutableStateOf(emptySet<String>())
        private set

    var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
        private set
}
ข้อมูลโค้ด Kotlin ที่นาที 19:41 ใน "Compose State outside of the UI"
@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
    var selectedTaskIds by mutableStateOf(emptySet<String>())
        private set

    var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
        private set

    fun updateSearchQuery(query: String) {
        Snapshot.withMutableSnapshot {
            searchQuery = query
            selectedTaskIds = emptySet()
        }
    }
}
ข้อมูลโค้ด Kotlin ที่ 19:52 ใน "สถานะการเขียนนอก UI"
val tasksState: State<TaskListState> = transform(
    initialValue = TaskListState.Loading,
    scope = viewModelScope,
) {
    val tasks = remember(searchQuery) {
        tasksRepository.getTasks(searchQuery)
    }.collectAsState(initial = null).value
        ?: return@transform TaskListState.Loading

    TaskListState.Success(tasks = tasks)
}
ข้อมูลโค้ด Kotlin ที่นาที 20:00 ใน "Compose State outside of the UI"
@Singleton
class NetworkMonitor @Inject constructor(
    @ApplicationContext context: Context,
) {
    private val connectivityManager =
        context.getSystemService(ConnectivityManager::class.java)
    ...
}
ข้อมูลโค้ด Kotlin ที่นาที 20:17 ใน "Compose State outside of the UI"
@Singleton
class NetworkMonitor @Inject constructor(...) {
    ...
    private val _isOnline = mutableStateOf(checkCurrentConnectivity())
    val isOnline: State<Boolean> = _isOnline
}
ข้อมูลโค้ด Kotlin ที่นาที 20:23 ใน "Compose State outside of the UI"
@Singleton
class NetworkMonitor @Inject constructor(...) {
    ...
    private val _isOnline = mutableStateOf(checkCurrentConnectivity())
    val isOnline: State<Boolean> = _isOnline

    private val callback = object : ConnectivityManager.NetworkCallback() {
        override fun onAvailable(network: Network) { _isOnline.value = true }
        override fun onLost(network: Network) { _isOnline.value = false }
    }

    init {
        connectivityManager.registerDefaultNetworkCallback(callback)
    }
}
ข้อมูลโค้ด Kotlin ที่ 20:32 ใน "สถานะการเขียนนอก UI"
val tasksState: State<TaskListState> = transform(
    initialValue = TaskListState.Loading,
    scope = viewModelScope,
) {
    if (!networkMonitor.isOnline.value) {
        return@transform TaskListState.Offline
    }

    val tasks = remember(searchQuery) {
        tasksRepository.getTasks(searchQuery)
    }.collectAsState(initial = null).value
        ?: return@transform TaskListState.Loading

    TaskListState.Success(tasks = tasks)
}
ข้อมูลโค้ด Kotlin ที่นาที 22:07 ใน "Introducing StateStore"
sealed interface FocusSession {
    data object Idle : FocusSession
    data class Running(val taskId: String, val endTimeMillis: Long) : FocusSession
    data class Paused(val taskId: String, val remainingMillis: Long) : FocusSession
}
ข้อมูลโค้ด Kotlin ที่ 22:14 ใน "Introducing StateStore"
sealed interface FocusSession { ... }

object FocusSessionKey :
    StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle)
ข้อมูลโค้ด Kotlin ที่ 22:23 ใน "Introducing StateStore"
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
    @Provides
    @Singleton
    fun provideStateStore(): StateStore = StateStore()
}

@Singleton
class FocusSessionManager @Inject constructor(
    private val stateStore: StateStore,
) {
    ...
}
ข้อมูลโค้ด Kotlin ที่ 22:42 ใน "Introducing StateStore"
fun startFocus(taskId: String, durationMinutes: Int = 25) {
    val endTime = System.currentTimeMillis() + durationMinutes * 60_000L
    stateStore.setState(
        FocusSessionKey,
        FocusSession.Running(taskId = taskId, endTimeMillis = endTime),
    )
}

fun endFocus() {
    stateStore.setState(FocusSessionKey, FocusSession.Idle)
}
ข้อมูลโค้ด Kotlin ที่นาที 22:52 ใน "Introducing StateStore"
fun pauseFocus(nowMillis: Long = System.currentTimeMillis()) {
    stateStore.updateState(FocusSessionKey) { session ->
        if (session !is FocusSession.Running) return@updateState session
        FocusSession.Paused(session.taskId, session.endTimeMillis - nowMillis)
    }
}

fun resumeFocus(nowMillis: Long = System.currentTimeMillis()) {
    stateStore.updateState(FocusSessionKey) { session ->
        if (session !is FocusSession.Paused) return@updateState session
        FocusSession.Running(session.taskId, nowMillis + session.remainingMillis)
    }
}
ข้อมูลโค้ด Kotlin ที่นาที 23:03 ใน "Introducing StateStore"
@Singleton
class FocusSessionManager @Inject constructor(
    private val stateStore: StateStore,
) {
    val session: State<FocusSession> = stateStore.getState(FocusSessionKey)
    ...
}
ข้อมูลโค้ด Kotlin ที่นาที 23:22 ใน "Introducing StateStore"
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
    focusSessionManager: FocusSessionManager,
) : ViewModel() { ... }

@EntryPoint
@InstallIn(SingletonComponent::class)
interface WidgetEntryPoint {
    fun focusSessionManager(): FocusSessionManager
}
ข้อมูลโค้ด Kotlin ที่ 23:33 ใน "Introducing StateStore"
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
    focusSessionManager: FocusSessionManager,
) : ViewModel() {
    val session: State<FocusSession> = focusSessionManager.session
}
ข้อมูลโค้ด Kotlin ที่ 23:45 ใน "Introducing StateStore"
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
    focusSessionManager: FocusSessionManager,
) : ViewModel() {
    val session: State<FocusSession> = focusSessionManager.session
}

@Composable
fun MiniTimerBar(viewModel: MiniTimerViewModel = hiltViewModel()) {
    val session by viewModel.session
    if (session !is FocusSession.Idle) {
        TimerReadout(session = session)
    }
}
ข้อมูลโค้ด Kotlin ที่นาที 23:58 ใน "Introducing StateStore"
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
    @Provides
    @Singleton
    fun provideStateStore(
        @ApplicationContext context: Context,
        @ApplicationScope scope: CoroutineScope,
    ): StateStore = StateStore().apply {
        val path = context.dataStoreFile("app_state.json").absolutePath
        scope.launch { syncToDataStore(path = path, coroutineScope = scope) }
    }
}
ข้อมูลโค้ด Kotlin ที่นาที 24:04 ใน "Introducing StateStore"
@Serializable
sealed interface FocusSession {
    @Serializable data object Idle : FocusSession
    @Serializable data class Running(val taskId: String, val endTimeMillis: Long) : FocusSession
    @Serializable data class Paused(val taskId: String, val remainingMillis: Long) : FocusSession
}

object FocusSessionKey :
    StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle),
    PersistToDataStore
ข้อมูลโค้ด Kotlin ที่นาที 24:22 ใน "Introducing StateStore"
suspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
    listener { … }
}
ข้อมูลโค้ด Kotlin ที่นาที 24:33 ใน "Introducing StateStore"
suspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
    listener {
        for (key in keys) {
            if (key is PersistToDataStore) {
                val value = getState(key).value

                LaunchedEffect(key) {
                    // Restore saved value from dataStore on first access
                }

                LaunchedEffect(key, value) {
                    // Flush updated value to dataStore on change
                }
            }
        }
    }
}
ข้อมูลโค้ด TOML ที่ 25:39 ใน "สรุปหมวดหมู่สถานะของ Compose"
[versions]
appstate = "1.0.0-alpha01"
...

[libraries]
...
transform = { module = "androidx.appstate.transform:transform", version.ref = "appstate" }
transform-testing = { module = "androidx.appstate.transform:transform-testing", version.ref = "appstate" }
statestore = { module = "androidx.appstate.statestore:statestore", version.ref = "appstate" }
statestore-datastore = { module = "androidx.appstate.statestore:statestore-datastore", version.ref = "appstate" }