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

ไว้พบกันใหม่ครัช

--

--