SharedFlow คือสตรีมแบบ hot flow จากไลบรารี Kotlin Coroutines ที่ถูกปรับให้เหมาะสมสำหรับอีเวนต์แบบครั้งเดียว (one-shot events) ที่ไม่ควรเกิดขึ้นซ้ำเมื่อหมุนหน้าจอหรือเมื่อมีการสร้าง subscriber ใหม่ เราจะแสดงให้เห็นว่า SharedFlow แตกต่างจาก StateFlow อย่างไร: ไม่เหมือน StateFlow ตรงที่ SharedFlow ไม่เก็บค่าล่าสุดสำหรับ subscriber ใหม่และรองรับการกำหนดค่า replay, extraBufferCapacity และ onBufferOverflow ตามข้อมูลของ Google (Android Developers, 2025) SharedFlow เป็นโซลูชันที่แนะนำสำหรับคำสั่งนำทาง ข้อความ Snackbar และอีเวนต์อื่น ๆ ที่ควรถูกประมวลผลเพียงครั้งเดียว
สาระสำคัญ
SharedFlow คือ hot flow จากไลบรารี kotlinx.coroutines.flow ซึ่งแตกต่างจาก StateFlow ตรงที่ไม่ผูกติดกับสถานะเดียวและสามารถ emit อีเวนต์จำนวนเท่าใดก็ได้ให้กับ subscriber จำนวนเท่าใดก็ได้ SharedFlow เป็นชนิดพื้นฐานของ StateFlow — StateFlow ถูก implement ผ่าน SharedFlow โดยมี replay = 1
คุณลักษณะสำคัญของ SharedFlow คือมันไม่จำเป็นต้องเก็บค่าล่าสุด โดยค่าเริ่มต้น (replay = 0) subscriber ใหม่จะไม่ได้รับอะไรเลยจนกว่าจะมีการส่งอีเวนต์ใหม่ ซึ่งทำให้ SharedFlow เหมาะอย่างยิ่งสำหรับสถานการณ์ที่อีเวนต์ควรถูกประมวลผลเพียงครั้งเดียว: การนำทาง Snackbar การแจ้งเตือนระบบ ผลการสแกน QR โค้ด
SharedFlow ถูกทำให้เสถียรใน kotlinx.coroutines 1.4.0 (พฤศจิกายน 2020) พร้อมกับ StateFlow ตามเอกสารประกอบ Kotlin Coroutines (2025) SharedFlow ใช้การล็อกแบบละเอียดเพื่อซิงโครไนซ์ subscriber และให้ ความสามารถในการปรับขนาดเชิงเส้น สูงถึง subscriber พร้อมกัน 1000+ รายโดยไม่ลดประสิทธิภาพ ซึ่งได้รับการยืนยันโดยการทดสอบของ JetBrains
การเลือกระหว่าง SharedFlow และ StateFlow ขึ้นอยู่กับความหมายของข้อมูลที่ส่ง: สถานะ (StateFlow) หรืออีเวนต์ (SharedFlow) ด้านล่างคือเกณฑ์ที่ชัดเจนพร้อมตัวอย่าง
| เกณฑ์ | SharedFlow | StateFlow |
|---|---|---|
| ความหมาย | อีเวนต์แบบครั้งเดียว (การนำทาง, toast, alert) | สถานะ UI (รายการ, การโหลด, ข้อผิดพลาด) |
| ค่าเริ่มต้น | ไม่จำเป็น | จำเป็น |
| การทำซ้ำเมื่อสมัครสมาชิก | เฉพาะเมื่อ replay > 0 | ค่าล่าสุดเสมอ |
| การรวมค่า | ไม่ — อีเวนต์จะไม่สูญหาย (ถ้าบัฟเฟอร์ไม่ล้น) | ใช่ — เก็บเฉพาะค่าล่าสุด |
| การบัฟเฟอร์ | ปรับแต่งได้ผ่าน replay + extraBufferCapacity | เพียง 1 (replay=1 คงที่) |
| การใช้งาน | navigationEvent, showSnackbar, openDialog | items, isLoading, uiState |
กฎที่ง่ายที่สุด: ถ้าข้อมูลควรแสดงเมื่อหมุนหน้าจอ — นี่คือสถานะ (StateFlow) ถ้าเมื่อหมุนหน้าจออีเวนต์ไม่ควรเกิดขึ้นซ้ำ — นี่คืออีเวนต์แบบครั้งเดียว (SharedFlow) ตัวอย่างเช่น «toast พร้อมข้อความแสดงข้อผิดพลาด» — SharedFlow: เมื่อหมุนหน้าจอ toast ไม่ควรแสดงอีกครั้ง «รายการสินค้า» — StateFlow: เมื่อหมุนหน้าจอรายการควรยังคงอยู่บนหน้าจอ
ใน IT Sectr เราใช้ SharedFlow สำหรับ: คำสั่งนำทาง (การเปลี่ยนหน้าจอ, การเปิด deep link), อีเวนต์ UI (Snackbar, AlertDialog), การแจ้งเตือนระบบ (การอัปเดตข้อมูลในพื้นหลัง, ผลลัพธ์การชำระเงิน), อีเวนต์การวิเคราะห์ (การบันทึก, การติดตาม)
MutableSharedFlow คือเวอร์ชันที่แก้ไขได้ของ SharedFlow พร้อมเมธอด emit() (suspend) และ tryEmit() (non-suspend) สำหรับส่งอีเวนต์ emit() จะถูกระงับถ้าบัฟเฟอร์เต็มและ onBufferOverflow = SUSPEND tryEmit() คืนค่า Boolean — ว่าอีเวนต์ถูกเพิ่มลงในบัฟเฟอร์สำเร็จหรือไม่
class EventBus {
private val _events = MutableSharedFlow<UiEvent>(
replay = 0,
extraBufferCapacity = 10,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val events: SharedFlow<UiEvent> get() = _events
suspend fun sendEvent(event: UiEvent) {
_events.emit(event)
}
fun trySendEvent(event: UiEvent): Boolean {
return _events.tryEmit(event)
}
}
sealed interface UiEvent {
data class ShowSnackbar(val message: String) : UiEvent
data class NavigateTo(val route: String) : UiEvent
data class ShowDialog(val title: String, val message: String) : UiEvent
}
พารามิเตอร์ของคอนสตรัคเตอร์มีความสำคัญ: replay = 0 รับประกันว่าอีเวนต์จะไม่เกิดขึ้นซ้ำสำหรับ subscriber ใหม่ extraBufferCapacity = 10 — บัฟเฟอร์ในกรณีที่มีการส่งอีเวนต์อย่างรวดเร็วก่อนที่ UI จะสมัครสมาชิก DROP_OLDEST — กลยุทธ์เมื่อบัฟเฟอร์ล้น: อีเวนต์เก่าจะถูกทิ้ง อีเวนต์ใหม่จะถูกเก็บไว้ ตามข้อมูลประสิทธิภาพ Kotlin Coroutines (JetBrains, 2024) SharedFlow ที่มี extraBufferCapacity = 64 สามารถประมวลผลมากกว่า 100,000 อีเวนต์ต่อวินาที โดยไม่สูญเสีย
รูปแบบ Event (หรือ UiEvent) — วิธีที่ Google แนะนำสำหรับการส่งอีเวนต์แบบครั้งเดียวจาก ViewModel ไปยัง View แตกต่างจากสถานะ (StateFlow) ตรงที่อีเวนต์ควรถูกประมวลผลเพียงครั้งเดียว และเมื่อหมุนหน้าจออีเวนต์ไม่ควรเกิดขึ้นซ้ำ SharedFlow ที่มี replay = 0 เหมาะอย่างยิ่งสำหรับงานนี้
class CheckoutViewModel : ViewModel() {
private val _uiState = MutableStateFlow<CheckoutState>(CheckoutState.Idle)
val uiState: StateFlow<CheckoutState> get() = _uiState
private val _event = MutableSharedFlow<CheckoutEvent>()
val event: SharedFlow<CheckoutEvent> get() = _event
fun placeOrder() {
viewModelScope.launch {
_uiState.value = CheckoutState.Loading
try {
val orderId = orderRepository.createOrder(cart)
_uiState.value = CheckoutState.Success(orderId)
_event.emit(CheckoutEvent.NavigateToOrderTracking(orderId))
} catch (e: Exception) {
_uiState.value = CheckoutState.Error(e.message)
_event.emit(CheckoutEvent.ShowErrorSnackbar(e.message ?: "ข้อผิดพลาดในการสั่งซื้อ"))
}
}
}
}
sealed interface CheckoutEvent {
data class NavigateToOrderTracking(val orderId: String) : CheckoutEvent
data class ShowErrorSnackbar(val message: String) : CheckoutEvent
}
ใน View (Activity/Fragment): การสมัครสมาชิกอีเวนต์ควรทำใน lifecycleScope ด้วย repeatOnLifecycle(STATE.STARTED) ทุกครั้งที่เข้าสู่ STARTED การสมัครสมาชิกจะถูกสร้างขึ้นใหม่ แต่อีเวนต์จะไม่เกิดขึ้นซ้ำเพราะ SharedFlow ที่มี replay=0 ได้ปล่อยมันไปแล้ว ซึ่งรับประกันว่าการนำทางไปยังหน้าจอติดตามคำสั่งซื้อจะเกิดขึ้นเพียงครั้งเดียว ไม่ใช่ทุกครั้งที่หมุนหน้าจอ
คอนสตรัคเตอร์ของ MutableSharedFlow รับพารามิเตอร์สามตัวที่กำหนดพฤติกรรมของบัฟเฟอร์ การตั้งค่าที่ไม่ถูกต้องอาจทำให้สูญเสียอีเวนต์หรือการบล็อกของ emit()
| พารามิเตอร์ | ชนิด | ค่าเริ่มต้น | คำอธิบาย |
|---|---|---|---|
| replay | Int | 0 | จำนวนอีเวนต์ล่าสุดที่จะเล่นซ้ำให้ subscriber ใหม่ 0 = ไม่เล่นซ้ำ, 1 = เหมือน StateFlow |
| extraBufferCapacity | Int | 0 | บัฟเฟอร์เพิ่มเติมนอกเหนือจาก replay อีเวนต์ถูกเก็บในบัฟเฟอร์แบบวงกลม 64 — ขีดจำกัดที่แนะนำสำหรับสถานการณ์ส่วนใหญ่ |
| onBufferOverflow | BufferOverflow | SUSPEND | กลยุทธ์เมื่อบัฟเฟอร์เต็ม: SUSPEND, DROP_OLDEST, DROP_LATEST |
// การกำหนดค่าสำหรับสถานการณ์ต่าง ๆ:
// 1. อีเวนต์ UI แบบครั้งเดียว (การนำทาง, toast)
val uiEvents = MutableSharedFlow<UiEvent>(
replay = 0,
extraBufferCapacity = 5,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
// 2. สตรีม Replay สำหรับการซิงค์สถานะ (เหมือน StateFlow)
val stateLike = MutableSharedFlow<AppState>(
replay = 1,
extraBufferCapacity = 0
)
// 3. การส่งอีเวนต์ความถี่สูง (การวิเคราะห์, บันทึก)
val analytics = MutableSharedFlow<AnalyticsEvent>(
replay = 0,
extraBufferCapacity = 100,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
สำคัญ: extraBufferCapacity + replay = ขนาดบัฟเฟอร์ทั้งหมด ถ้า emit() ถูกเรียกเร็วกว่าที่ subscriber จะประมวลผลอีเวนต์ บัฟเฟอร์จะเต็มและ onBufferOverflow จะทำงาน สำหรับอีเวนต์ UI DROP_OLDEST เป็นกลยุทธ์ที่ปลอดภัย: อีเวนต์เก่า (การนำทางที่ไม่เกี่ยวข้องอีกต่อไป) จะถูกทิ้งเพื่อให้อีเวนต์ใหม่ สำหรับธุรกรรมทางการเงิน ให้ใช้ SUSPEND — ซึ่งรับประกันว่าจะไม่มีอีเวนต์สูญหายแม้ต้องแลกกับการบล็อกผู้ส่ง
คำสั่งนำทางเป็นกรณีการใช้งานคลาสสิกสำหรับ SharedFlow Fragment สมัครสมาชิกอีเวนต์และดำเนินการนำทาง เมื่อหมุนหน้าจอ คำสั่งจะไม่เกิดขึ้นซ้ำ
// ViewModel
class AuthViewModel : ViewModel() {
private val _navEvent = MutableSharedFlow<NavEvent>()
val navEvent: SharedFlow<NavEvent> get() = _navEvent
fun onLoginSuccess() {
viewModelScope.launch {
_navEvent.emit(NavEvent.NavigateTo(NavRoutes.HOME))
}
}
fun onLogout() {
viewModelScope.launch {
_navEvent.emit(NavEvent.NavigateTo(NavRoutes.LOGIN))
}
}
}
sealed interface NavEvent {
data class NavigateTo(val route: String) : NavEvent
data class NavigateBack(val popUpTo: String? = null) : NavEvent
}
// ใน Fragment:
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.navEvent.collect { navEvent ->
when (navEvent) {
is NavEvent.NavigateTo -> findNavController().navigate(navEvent.route)
is NavEvent.NavigateBack -> findNavController().popBackStack()
}
}
}
}
สถานการณ์ที่ซับซ้อน: SharedFlow สำหรับการแจ้งเตือนอีเวนต์พื้นหลังรวมกับ StateFlow สำหรับ UI
class NotificationViewModel : ViewModel() {
private val _toastMessage = MutableSharedFlow<String>()
val toastMessage: SharedFlow<String> get() = _toastMessage
private val _notifications = MutableStateFlow<List<Notification>>(emptyList())
val notifications: StateFlow<List<Notification>> get() = _notifications
init {
viewModelScope.launch {
notificationChannel
.consumeAsFlow()
.collect { notification ->
_notifications.value = _notifications.value + notification
_toastMessage.emit("การแจ้งเตือนใหม่: ${notification.title}")
}
}
}
fun dismissNotification(id: String) {
_notifications.value = _notifications.value.filter { it.id != id }
}
fun markAllRead() {
viewModelScope.launch {
_notifications.value = _notifications.value.map { it.copy(isRead = true) }
_toastMessage.emit("การแจ้งเตือนทั้งหมดถูกทำเครื่องหมายว่าอ่านแล้ว")
}
}
}
ในตัวอย่างนี้: StateFlow เก็บรายการการแจ้งเตือน (สถานะ — ยังคงอยู่เมื่อหมุนหน้าจอ), SharedFlow ส่งข้อความ toast (อีเวนต์แบบครั้งเดียว — ไม่เกิดขึ้นซ้ำเมื่อหมุนหน้าจอ) การรวม Flow ทั้งสองชนิดเป็นรูปแบบที่ Google แนะนำสำหรับ ViewModel ตั้งแต่ปี 2022
คำถามที่พบบ่อย
ได้ ถ้า บัฟเฟอร์ล้น และ onBufferOverflow = DROP_OLDEST หรือ DROP_LATEST SharedFlow ไม่รับประกันการส่งทุกอีเวนต์ — มันไม่ใช่คิวข้อความ (เหมือน Channel) ถ้าต้องการการส่งทุกอีเวนต์อย่างแน่นอน ให้ใช้ Channel ที่มีบัฟเฟอร์ไม่จำกัด (UNLIMITED) หรือ BroadcastChannel (เลิกใช้แล้ว) สำหรับอีเวนต์ UI การสูญเสียอีเวนต์ที่ล้าสมัย (เช่น การนำทางเก่า) เป็นพฤติกรรมที่คาดหวัง ไม่ใช่บั๊ก
Channel คือคิว FIFO ที่แต่ละอีเวนต์ถูกส่งไปยัง subscriber เพียงรายเดียว (จุดต่อจุด) SharedFlow คือการแพร่ภาพ: แต่ละอีเวนต์ถูกส่งไปยัง subscriber ที่ทำงานอยู่ทั้งหมด SharedFlow ใกล้เคียงกับ BroadcastChannel (ซึ่งเลิกใช้แล้ว) และเหมาะสำหรับสถานการณ์ «หนึ่งต่อหลาย» Channel สำหรับ «หนึ่งต่อหนึ่ง» (พูลเธรด, pipeline) ตามคำแนะนำของ JetBrains SharedFlow คือสิ่งทดแทน BroadcastChannel สำหรับโปรเจกต์ใหม่ทั้งหมด
SharedFlow ปลอดภัยต่อเธรดอยู่แล้ว — emit() และ collect() ถูกซิงโครไนซ์อย่างถูกต้อง หลายเธรดสามารถเรียก emit() โดยไม่ต้องล็อก และ subscriber ที่ทำงานอยู่ทั้งหมดจะได้รับอีเวนต์ตามลำดับที่ถูกต้อง tryEmit() ไม่บล็อก — คืนค่า false ถ้าบัฟเฟอร์เต็ม สำหรับระบบที่มีโหลดสูง ให้ใช้ tryEmit() กับ DROP_OLDEST — ซึ่งป้องกันการบล็อกของเธรด
SharedFlow ที่ไม่มี replay=1 จะไม่เก็บค่าล่าสุด — เมื่อหมุนหน้าจอ subscriber ใหม่จะไม่ได้รับสถานะปัจจุบัน UI จะว่างเปล่า เมื่อมี replay=1 SharedFlow จะทำงานเหมือน StateFlow แต่สูญเสียการเพิ่มประสิทธิภาพการเปรียบเทียบผ่าน equals() ซึ่งทำให้เกิดการแจ้งเตือนที่ไม่จำเป็นเมื่อส่งค่าเดียวกันซ้ำ StateFlow เป็นตัวเลือกที่ถูกต้องสำหรับสถานะ; SharedFlow สำหรับอีเวนต์
สำหรับการทดสอบ SharedFlow ให้ใช้ Turbine — ไลบรารี Kotlin สำหรับทดสอบ Flow Turbine ช่วยให้คุณตรวจสอบแต่ละ emission แยกกันพร้อมการตั้งเวลาและการตรวจสอบความสมบูรณ์ ตัวอย่าง: viewModel.event.test { assertEquals(UiEvent.ShowSnackbar("OK"), awaitItem()) } นอกจากนี้ยังสามารถใช้ .toList() ใน runTest โดยระบุจำนวนอีเวนต์ที่คาดหวัง
สรุป
เราจะพัฒนาแอปพลิเคชันบนมือถือแบบครบวงจร
IT Sectr สร้างแอปพลิเคชัน iOS และ Android สำหรับสตาร์ทอัพและธุรกิจตั้งแต่ปี 2017 เราจะให้คำแนะนำและเสนอวิธีแก้ปัญหาที่ดีที่สุดแก่คุณ
อ่านเพิ่มเติม