LaunchedEffect vs StateFlow in Compose
โอเคเรื่องมีอยู่ว่าได้ผ่านไปเจอโพสของคุณ Somkiat ได้แชร์รู้สึกว่าน่าสนใจเช่นกันเลยเกิดเป็นบทความนี้ให้อ่านเล่นๆ กัน
เอ… แล้วเราจะทำ Cold Flow ยังไงดีละ ….. เดี๋ยวก่อน ก่อนที่จะไปกันถึง Cold Flow
ก่อนอื่นเลยต้องเล่าก่อนว่า ใน Jetpack Compose มีวิธีการจัดการสถานะ (State management) ที่หลากหลาย ซึ่งหนึ่งในวิธีที่พบบ่อยคือการใช้ side-effects ของ Jetpack Compose อย่างเช่น LaunchedEffect
และในขณะเดียวกัน StateFlow
ก็มีบทบาทสำคัญในการจัดการสถานะเช่นกัน อย่างไรก็ตาม ทั้ง LaunchedEffect
และ StateFlow
มีบทบาทและวิธีการทำงานที่แตกต่างกัน โดย
LaunchedEffect มักถูกใช้ในการจัดการ side-effects ที่เกี่ยวข้องกับการดำเนินงานบางอย่างเมื่อ composable ถูกสร้างขึ้นหรือเปลี่ยนแปลง ส่วนอีกอันก็คือ
ส่วนใหญ่เรามักจะใช้ตอนที่เราอยากจะให้มันทำอะไรสักอย่างแค่ครั้งเดียวต้องเปิดหน้าขึ้นมา ส่วน Side Effects อื่นๆ ใน Compose เราอาจจะยังไม่ได้กล่าวถึงในรอบนี้นะเพราะมีหลายตัวมากๆ แต่ถ้าใครสนใจก็จะมี remember, rememberUpdatedState, SideEffect, DisposableEffect, produceState แต่ละตัวก็จะมีจุดประสงค์ใช้งานแตกต่างกันออกไปอีก
ตัวอย่างโค้ด
@Composable
fun MyScreen(viewModel: MyViewModel) {
LaunchedEffect(Unit) {
// Do some things 1 time
}
Text(text = data)
}
StateFlow เป็นเครื่องมือที่ช่วยให้เราสามารถจัดการและแชร์สถานะที่สามารถติดตามได้อย่างมีประสิทธิภาพ ซึ่งทำให้ composable ของเราสามารถตอบสนองต่อการเปลี่ยนแปลงของข้อมูลได้อย่างต่อเนื่องและสอดคล้องกับ lifecycle ของหน้าจอ
ก่อนจะไปเรื่อง Anti-patterns (แนวทางที่ไม่ดี) ตามในโพส ขอเสริมอีกเรื่องที่ติดค้างไว้ตอนต้นนั่นก็คือ Cold Flow จริงๆ แล้ว Cold Flow ก็คือเป็นหนึ่งในคำที่เราใช้เรียก Kotlin Flow ตามลักษณะการทำงานที่เกิดขึ้น
Kotlin Flow จะมี 2 คำหลักๆ ที่เราจะได้ยินกันบ่อยๆ นั่นก็คือ Cold Flow และ Hot Flow
Cold Flow
ลักษณะ : ข้อมูลที่ถูกสร้างขึ้นมาชั่วคราว หรือเอามาส่งอะไรสักอย่างแปปๆ
การทำงาน: ของที่จะได้ออกไปจะได้ อันใหม่เสมอไม่ได้มีการ Reuse เช่นFlow
, suspend fun
ตัวอย่างโค้ด การดึงข้อมูลอะไรบางอย่าง
val coldFlow = flow {
emit(fetchData()) // fetchData() will be called every time this flow is collected
}
จุดประสงค์การใช้งานเพื่ออยากได้อะไรบางอย่างที่เป็นข้อมูลใหม่ๆ ทุกครั้งที่เรียกใช้งาน หรืออะไรที่แบบเรียกครั้งเดียวจบ
Hot Flow
ลักษณะ: ข้อมูลที่ถูกสร้างจะอยู่ต่อไปเรื่อยๆ ใครก็ตามสามารถเข้ามาดึงข้อมูลที่เก็บไว้นี้ออกไปใช้ได้เรื่อยๆ
การทำงาน: ของที่ได้ออกไปนี้จะคงอยู่แม้ว่าจะมีการเข้าถึงข้อมูลในเวลาที่แตกต่างกัน
เช่น StateFlow
หรือ SharedFlow
- StateFlow จะมีการคงที่ของสถานะที่ตัวมันเองเก็บไว้ได้ เช่น กำลังโหลดอยู่นะ โหลดเสร็จแล้วนะ เวลาใครก็ตามเข้ามา access จะได้ข้อมูลล่าสุดกลับไป
ตัวอย่างการใช้ StateFlow
class MyViewModel : ViewModel() {
// ใช้ MutableStateFlow เพื่อเก็บสถานะของ loading และ data
private val _isLoading = MutableStateFlow(true) // เริ่มต้นเป็น true แสดงว่า loading อยู่
val isLoading: StateFlow<Boolean> = _isLoading
private val _data = MutableStateFlow<String>("") // เก็บข้อมูลที่โหลดจาก API
val data: StateFlow<String> = _data
// ฟังก์ชันสำหรับ fetch ข้อมูลจาก API
fun fetchData() {
viewModelScope.launch {
_isLoading.value = true // เริ่มต้นแสดงสถานะการโหลด
delay(2000L) // จำลองการโหลดข้อมูล เช่น จาก API
_data.value = "Data fetched successfully!" // อัพเดตข้อมูลเมื่อโหลดเสร็จ
_isLoading.value = false // ปิดสถานะการโหลด
}
}
}
//Compose
@Composable
fun MyScreen(viewModel: MyViewModel = viewModel()) {
// เก็บสถานะ loading และ data จาก StateFlow
val isLoading by viewModel.isLoading.collectAsState()
val data by viewModel.data.collectAsState()
// UI
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
// แสดง ProgressBar ขณะกำลังโหลดข้อมูล
if (isLoading) {
CircularProgressIndicator()
} else {
// แสดงข้อมูลเมื่อโหลดสำเร็จ
Text(text = data)
}
Spacer(modifier = Modifier.height(16.dp))
// ปุ่มกดเพื่อโหลดข้อมูลอีกครั้ง
Button(onClick = { viewModel.fetchData() }) {
Text("Fetch Data")
}
}
}
- SharedFlow ข้อมูลที่เก็บไว้หากมีการเปลี่ยนแปลงใครก็ตามที่ Observe ค่าเอาไว้เช่น แจ้งเตือนค่าอะไรบางอย่าง ไม่ได้ต้องการเก็บค่าสถานะ เหมาะสำหรับไว้ส่งค่าอะไรบางอย่างแบบ Realtime
ตัวอย่างการใช้ SharedFlow
// สร้าง SharedFlow ที่มี replay buffer ขนาด 1
val sharedFlow = MutableSharedFlow<Int>(replay = 1)
// ปล่อยข้อมูลออกจาก flow
viewModelScope.launch {
sharedFlow.emit(1)
sharedFlow.emit(2)
sharedFlow.emit(3)
}
// รับข้อมูลจาก flow
viewModelScope.launch {
sharedFlow.collect { value ->
println("Collector 1 received: $value")
}
}
viewModelScope.launch {
delay(1000L)
sharedFlow.collect { value ->
println("Collector 2 received: $value")
}
}
มาถึงตรงนี้น่าจะพอเข้าใจ ขึ้นมาอีกเล็กน้อยแล้ว ฮ่าๆ
ต่อมากลับมาหัวข้อตอนต้นแล้วเราจะใช้ LaunchedEffect ร่วมกันกับ Flow ยังไงดี
ตัวอย่างโค้ดที่เป็น Anti-patterns
@Composable
fun MyScreen(viewModel: MyViewModel) {
val state = remember { mutableStateOf(MyState()) }
LaunchedEffect(Unit) {
// Side-effect: Network request triggered on the first composition
state.value = viewModel.fetchData()
}
// UI rendering based on state
Text(text = state.value.data)
}
ตัวอย่างโค้ดที่แนะนำ
@Composable
fun MyScreen(viewModel: MyViewModel) {
// Use a cold flow that only starts collecting when it is observed
val state by viewModel.myFlow.collectAsStateWithLifecycle()
// UI rendering based on state
Text(text = state.data)
}
// ViewModel Example
class MyViewModel : ViewModel() {
val myFlow = flow {
// Cold flow that performs a network request when collected
emit(fetchData())
}
}
ปัญหา
การใช้ Cold Flow ในลักษณะนี้จะทำให้ API ถูกเรียกซ้ำทุกครั้งเมื่อมีการ resume ซึ่งไม่ใช่สิ่งที่ต้องการหากเราต้องการเรียก API แค่ครั้งเดียวในช่วงที่ composable ถูกสร้างขึ้นครั้งแรก
สุดท้ายแล้ว
แนวทางที่แนะนำ:
การใช้ LaunchedEffect
จะช่วยให้การจัดการการเรียก API ควบคุมได้ดีขึ้น และป้องกันการเรียก API ซ้ำในกรณีที่ไม่จำเป็น เช่น การ resume ของ composable
ตัวอย่างโค้ด
class MyViewModel : ViewModel() {
private val _stateFlow = MutableStateFlow("Initial data")
val stateFlow: StateFlow<String> = _stateFlow
// ฟังก์ชันสำหรับ fetch ข้อมูล
fun fetchData() {
viewModelScope.launch {
// สมมุติการเรียก API
val newData = "Fetched data from API"
_stateFlow.value = newData
}
}
}
@Composable
fun MyScreen(viewModel: MyViewModel) {
// เก็บสถานะจาก StateFlow
val state by viewModel.stateFlow.collectAsState()
// ใช้ LaunchedEffect เพื่อเรียก fetchData เพียงครั้งเดียว
LaunchedEffect(Unit) {
viewModel.fetchData()
}
// UI แสดงผลตามสถานะที่ได้รับ
Text(text = state)
}
สรุป การใช้ Cold Flow เหมาะกับกรณีที่ต้องการให้ข้อมูลถูกดึงใหม่ทุกครั้งที่มีการเก็บข้อมูล แต่ไม่เหมาะในกรณีที่ต้องการควบคุมให้เรียก API ครั้งเดียวเท่านั้น
แถมก่อนจะจากกันแบบไหนถึงเป็น Anti-patterns
@Composable
fun MyScreen() {
var count by remember { mutableStateOf(0) }
// การใช้ LaunchedEffect เพื่อเปลี่ยนแปลงสถานะโดยตรง (ซึ่งไม่ควรทำ)
LaunchedEffect(Unit) {
// การใช้ delay เพื่ออัปเดต state เป็นการทำงานที่ควรจะจัดการนอก composable
delay(1000L)
count += 1 // การเปลี่ยนแปลง state ใน LaunchedEffect เป็น anti-pattern
}
// แสดงผลของ count
Text(text = "Count: $count")
}
ทำไมเป็น anti-pattern:
- การเปลี่ยนแปลง state (
count
) ภายในLaunchedEffect
ไม่เหมาะสม เพราะการจัดการสถานะและการเปลี่ยนแปลงควรถูกจัดการใน ViewModel หรือภายนอกComposable
- การเปลี่ยนแปลงสถานะใน
LaunchedEffect
อาจทำให้เกิดการ recompose ซ้ำๆ โดยไม่จำเป็น และทำให้โค้ดซับซ้อนเกินไป - โค้ดนี้ไม่มีการใช้ ViewModel หรือจัดการสถานะภายนอกอย่างเหมาะสม
แล้วจะแก้ยังไงสมมุติต้องการจะนับเวลาแบบนี้
class MyViewModel : ViewModel() {
private val _count = MutableStateFlow(0)
val count: StateFlow<Int> = _count
init {
viewModelScope.launch {
delay(1000L)
_count.value += 1
}
}
}
@Composable
fun MyScreen(viewModel: MyViewModel = viewModel()) {
// เก็บสถานะจาก StateFlow
val count by viewModel.count.collectAsState()
// UI แสดงข้อมูลตามสถานะที่ได้รับ
Text(text = "Count: $count")
}
ทำไมถึงไม่เป็นแล้วละ ? นั่นก็เพราะว่าการจัดการ State count
ถูกย้ายไปที่ ViewModel
ซึ่งเป็นที่ที่เหมาะสมสำหรับการจัดการ Stateใน Compose
ไว้พบกันใหม่ครัช