วิศวกรนักพัฒนาซอฟต์แวร์สัมพันธ์
รันไทม์สแนปชอตของ Compose, การติดตามทรัพยากร Dependency อัตโนมัติ และการอัปเดตแบบอะตอมไม่จำเป็นต้องหยุดที่เลเยอร์ UI Jetpack AppState ช่วยให้คุณย้ายสถานะ Compose ไปยังอัปสตรีมได้ ซึ่งจะช่วยปรับปรุงสิ่งที่เข้าถึงหน้าจอของคุณโดยการยกและดึงสถานะด้วยระบบสแนปชอตก่อนที่จะเข้าถึง UI
ในเซสชันนี้ คุณจะได้เรียนรู้เกี่ยวกับไลบรารีพื้นฐาน 2 รายการของ AppState เราจะเจาะลึก StateStore สำหรับการยกสถานะ และ Transform สำหรับการใช้ Composition เพื่อรับสถานะด้วยโฟลว์ควบคุม Kotlin แบบธรรมดา ซึ่งจะช่วยลดเชนการรวม Flow ที่ซับซ้อนและทำให้ UI ซิงค์กันได้อย่างง่ายดาย
combine ขณะเดียวกันก็รองรับการแสดงผลก่อนกำหนดและการดึงข้อมูลสถานะที่ซ้อนกันข้อความถอดเสียง
สวัสดีและยินดีต้อนรับสู่ "ยกระดับสถานะ 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
ตกลง
ขอบคุณ 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
โอเค
โอเค ขอบคุณ 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 ซึ่งอยู่ในหน่วยความจำเท่านั้น จึงไม่คงอยู่หลังจากการสิ้นสุดการประมวลผล
และนั่นคือช่องว่างที่ 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 ของคุณ
/**
* 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
}/**
* 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
}@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++ })
}
}@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")
}
}@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++ })
}
}@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++ })
}
}val levelName: String = "Level 1 - Stay Alive!"
val timeSurvived: Int = 0
val score: Double = 0.0data class GameUiState(
val levelName: String = "Level 1 - Stay Alive!",
val timeSurvived: Int = 0,
val score: Double = 0.0
)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
)
}
}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
)
}
}@Stable
class GameUiState {
var levelName by mutableStateOf("Level 1 - Stay Alive!")
var timeSurvived by mutableStateOf(0)
var score by mutableStateOf(0.0)
}@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
}
}@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)
}
}public fun <R> transform(
initialValue: R,
scope: CoroutineScope,
onUpdate: @Composable (R) -> R,
): State<R>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,
)
}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(...)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(...)
}
}class FocusSessionManager(taskRepository: TaskRepository, scope: CoroutineScope) {
...
}
class MiniTimerViewModel(focusManager: FocusSessionManager) : ViewModel() {
...
}
class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
...
}
}
}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
)
}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(...)
}
}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 } }
}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 } }
}class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(
context: Context,
id: GlanceId
) {
val focusManager = ...
provideContent {
val summary by focusManager.summary
.collectAsState()
GlanceTheme { FocusWidgetContent(summary) }
}
}
}class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(
context: Context,
id: GlanceId
) {
val focusManager = ...
provideContent {
val summary by focusManager.summary
GlanceTheme { FocusWidgetContent(summary) }
}
}
}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
}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()
}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
}
}@HiltViewModel
class TaskListViewModel @Inject constructor(
private val tasksRepository: TasksRepository,
private val networkMonitor: NetworkMonitor,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
...
}@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
var selectedTaskIds by mutableStateOf(emptySet<String>())
private set
}@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
var selectedTaskIds by mutableStateOf(emptySet<String>())
private set
var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
private set
}@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()
}
}
}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
class NetworkMonitor @Inject constructor(...) {
...
private val _isOnline = mutableStateOf(checkCurrentConnectivity())
val isOnline: State<Boolean> = _isOnline
}@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)
}
}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)
}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
}sealed interface FocusSession { ... }
object FocusSessionKey :
StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle)@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideStateStore(): StateStore = StateStore()
}
@Singleton
class FocusSessionManager @Inject constructor(
private val stateStore: 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)
}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)
}
}@Singleton
class FocusSessionManager @Inject constructor(
private val stateStore: StateStore,
) {
val session: State<FocusSession> = stateStore.getState(FocusSessionKey)
...
}@HiltViewModel
class MiniTimerViewModel @Inject constructor(
focusSessionManager: FocusSessionManager,
) : ViewModel() { ... }
@EntryPoint
@InstallIn(SingletonComponent::class)
interface WidgetEntryPoint {
fun focusSessionManager(): FocusSessionManager
}@HiltViewModel
class MiniTimerViewModel @Inject constructor(
focusSessionManager: FocusSessionManager,
) : ViewModel() {
val session: State<FocusSession> = focusSessionManager.session
}@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)
}
}@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),
PersistToDataStoresuspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
listener { … }
}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
}
}
}
}
}[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" }