ทำความเข้าใจว่า การไหลของข้อมูลทางเดียว (Unidirectional Data Flow) คืออะไร — สตรีมข้อมูลทิศทางเดียว รูปแบบสถาปัตยกรรมที่ข้อมูลเคลื่อนที่ในลูปปิด State → View → Intent → Reducer → State โดยไม่มีลูปป้อนกลับ แตกต่างจากการผูกมัดสองทาง UDF รับประกันว่าการเปลี่ยนแปลงสถานะเกิดขึ้นผ่านการกระทำที่ชัดเจน (Intent/Event) เท่านั้น ทำให้การไหลของข้อมูลคาดเดาได้และติดตามได้ ตามข้อมูลของ Google I/O 2024 UDF เป็นสถาปัตยกรรมที่แนะนำสำหรับแอปพลิเคชัน Jetpack Compose และ SwiftUI ที่มีตรรกะทางธุรกิจระดับความซับซ้อนปานกลางและสูง
ประเด็นสำคัญ
การไหลของข้อมูลทางเดียว (UDF) เป็นรูปแบบสถาปัตยกรรมที่ข้อมูลเคลื่อนที่ในทิศทางเดียวในลูปปิด กำจัดลูปป้อนกลับระหว่าง View และ Model แตกต่างจากการผูกมัดสองทาง ที่การเปลี่ยนแปลงใน UI อัปเดตโมเดลทันที UDF ต้องการการกระทำที่ชัดเจน (Intent, Event, Action) สำหรับทุกการเปลี่ยนแปลงสถานะ ทำให้การไหลของข้อมูลคาดเดาได้อย่างสมบูรณ์: ในเวลาใดก็ได้ สามารถระบุได้ว่าการกระทำใดนำไปสู่สถานะปัจจุบัน
แนวคิดของ UDF มาจากเฟรมเวิร์กเว็บ — Redux (JavaScript, 2015) และ Elm (2012) — และถูกปรับใช้สำหรับการพัฒนามือถือ ตามข้อมูลของ Google I/O 2024 UDF ได้กลายเป็นสถาปัตยกรรมที่แนะนำสำหรับ Jetpack Compose แทนที่ MVVM แบบคลาสสิกด้วย LiveData บน iOS มีการใช้แนวทางที่คล้ายกันใน The Composable Architecture (TCA) โดย Point-Free ซึ่งนักพัฒนา iOS กว่า 15% ใช้ตามข้อมูลของ Swift Community Survey (2024)
ข้อได้เปรียบหลักของ UDF คือ แหล่งความจริงเดียว (SSOT): สถานะทั้งหมดของแอปพลิเคชันถูกจัดเก็บในที่เดียวและแก้ไขผ่านการดำเนินการที่กำหนดไว้อย่างเคร่งครัด ซึ่งทำให้การดีบัก การทดสอบ และการทำซ้ำข้อบกพร่องง่ายขึ้น เนื่องจากการเปลี่ยนแปลงสถานะทุกครั้งถูกบันทึกและสามารถทำซ้ำได้โดยการส่ง Intent เดิมอีกครั้ง
วงจร UDF พื้นฐานประกอบด้วยสี่ขั้นตอน: State (สถานะปัจจุบัน) ถูกเรนเดอร์ใน View; ผู้ใช้ดำเนินการที่กลายเป็น Intent; Intent ถูกประมวลผลใน Reducer (ฟังก์ชันบริสุทธิ์) ซึ่งสร้าง State ใหม่; สถานะใหม่ถูกส่งไปยัง View เพื่อเรนเดอร์ใหม่ วงจรนี้ทำซ้ำในทุกเหตุการณ์ของผู้ใช้หรือระบบ
แต่ละองค์ประกอบของวงจรมีความรับผิดชอบที่เคร่งครัด: State — วัตถุที่ไม่สามารถเปลี่ยนแปลงได้ซึ่งอธิบายสถานะหน้าจอในขณะที่เฉพาะเจาะจง; View — ฟังก์ชันที่เรนเดอร์ State; Intent — ค่าที่อธิบายความตั้งใจของผู้ใช้ (เช่น LoginIntent.Submit); Reducer — ฟังก์ชันบริสุทธิ์ที่ไม่มีผลข้างเคียง รับ State ปัจจุบันและ Intent และส่งคืน State ใหม่ ผลข้างเคียง (คำขอเครือข่าย, การดำเนินการฐานข้อมูล) ถูกย้ายไปยังเลเยอร์ Middleware หรือ Effect ที่แยกต่างหาก
ตามบทความ Google Android Architecture (2024) ความบริสุทธิ์ของ Reducer เป็นข้อกำหนดสำคัญ: หาก Reducer มีการเรียกเครือข่ายหรือการเขียนฐานข้อมูล การทดสอบและดีบักการไหลของข้อมูลจะเป็นไปไม่ได้ ผลข้างเคียงทั้งหมดต้องดำเนินการใน coroutine ของ ViewModel หรือ Swift Task ก่อนเรียก Reducer และผลลัพธ์ต้องถูกส่งเป็น Intent ใหม่
บน Android การนำ UDF ไปใช้สร้างขึ้นบนองค์ประกอบ Jetpack สามส่วน: ViewModel จัดการวงจรชีวิต, StateFlow ให้สตรีมสถานะแบบรีแอกทีฟ, Intent (sealed class) อธิบายการกระทำที่เป็นไปได้ทั้งหมดของผู้ใช้ View สมัครรับ StateFlow ผ่าน collectAsState() ใน Compose หรือ observe() ในระบบ View
sealed class LoginIntent {
data object Submit : LoginIntent()
data class UpdateEmail(val value: String) : LoginIntent()
data class UpdatePassword(val value: String) : LoginIntent()
}
data class LoginState(
val email: String = "",
val password: String = "",
val isLoading: Boolean = false,
val error: String? = null
)
class LoginViewModel : ViewModel() {
private val _state = MutableStateFlow(LoginState())
val state: StateFlow<LoginState> = _state.asStateFlow()
fun onIntent(intent: LoginIntent) {
when (intent) {
is LoginIntent.UpdateEmail -> {
_state.update { it.copy(email = intent.value) }
}
is LoginIntent.UpdatePassword -> {
_state.update { it.copy(password = intent.value) }
}
is LoginIntent.Submit -> {
_state.update { it.copy(isLoading = true, error = null) }
loginUseCase(_state.value.email, _state.value.password)
.onSuccess {
_state.update { it.copy(isLoading = false) }
}
.onFailure { e ->
_state.update { it.copy(isLoading = false, error = e.message) }
}
}
}
}
}
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val state by viewModel.state.collectAsState()
LoginForm(
email = state.email,
onEmailChange = { viewModel.onIntent(LoginIntent.UpdateEmail(it)) },
password = state.password,
onPasswordChange = { viewModel.onIntent(LoginIntent.UpdatePassword(it)) },
isLoading = state.isLoading,
onSubmit = { viewModel.onIntent(LoginIntent.Submit) }
)
}ตัวอย่างแสดงวงจร UDF ที่สมบูรณ์ใน Android: LoginIntent อธิบายการกระทำที่เป็นไปได้ทั้งหมด (การเปลี่ยนแปลงอีเมล, การเปลี่ยนแปลงรหัสผ่าน, การส่งแบบฟอร์ม), LoginState เป็นสถานะที่ไม่สามารถเปลี่ยนแปลงได้, LoginViewModel ประมวลผล Intents และอัปเดต StateFlow, และหน้าจอ Compose สมัครรับสถานะผ่าน collectAsState() ทุกการเปลี่ยนแปลงสถานะเป็นผลลัพธ์ของการประมวลผล Intent ที่เฉพาะเจาะจง ทำให้การไหลของข้อมูลโปร่งใสอย่างสมบูรณ์
บน iOS UDF ถูกนำไปใช้ผ่าน The Composable Architecture (TCA) โดย Point-Free หรือรูปแบบ Observable ดั้งเดิมกับ iOS 17+ TCA มีวงจรพร้อมใช้ของ State + Action + Reducer + Store โดย Store เป็นแหล่งความจริงเดียว และ View สมัครรับการเปลี่ยนแปลงผ่าน @Observable หรือ ObservableObject
struct LoginState: Equatable {
var email = ""
var password = ""
var isLoading = false
var error: String?
}
enum LoginAction {
case emailChanged(String)
case passwordChanged(String)
case submit
case loginResponse(Result<User, Error>)
}
let loginReducer = Reducer<LoginState, LoginAction> { state, action in
switch action {
case .emailChanged(let email):
state.email = email
return .none
case .passwordChanged(let password):
state.password = password
return .none
case .submit:
state.isLoading = true
state.error = nil
return .run { send in
let result = await loginUseCase(state.email, state.password)
await send(.loginResponse(result))
}
case .loginResponse(.success):
state.isLoading = false
return .none
case .loginResponse(.failure(let error)):
state.isLoading = false
state.error = error.localizedDescription
return .none
}
}
struct LoginView: View {
let store: StoreOf<LoginReducer>
var body: some View {
WithViewStore(store, observe: { $0 }) { viewStore in
Form {
TextField("Email", text: viewStore.binding(get: \.email, send: { .emailChanged($0) }))
SecureField("Password", text: viewStore.binding(get: \.password, send: { .passwordChanged($0) }))
Button("เข้าสู่ระบบ") { viewStore.send(.submit) }
}
}
}
}loginReducer เป็นฟังก์ชันบริสุทธิ์: มันไม่ดำเนินการขอเครือข่ายโดยตรงแต่ส่งคืน Effect ที่จะถูกดำเนินการโดยรันไทม์ TCA ซึ่งช่วยให้ทดสอบรีดิวเซอร์อย่างแยกส่วนโดยการแทนที่เอฟเฟกต์ในการทดสอบ View สมัครรับการเปลี่ยนแปลงของ Store ผ่าน WithViewStore และส่ง Actions ผ่าน send() TCA จัดการการยกเลิกเอฟเฟกต์โดยอัตโนมัติเมื่อ Store ถูกทำลาย ป้องกันการรั่วไหลของหน่วยความจำ
MVVM และ UDF มักสับสน แต่มีความแตกต่างพื้นฐานระหว่างพวกเขา MVVM เป็นรูปแบบโครงสร้างที่แยกโค้ดออกเป็นสามเลเยอร์ (Model, View, ViewModel) แต่ไม่ได้กำหนดทิศทางการไหลของข้อมูล UDF เป็นรูปแบบพฤติกรรมที่อธิบายว่าข้อมูลเคลื่อนที่ภายในโครงสร้างนั้นอย่างไร ใน MVVM กับ LiveData ทั้งการผูกมัดสองทางและการไหลทางเดียวเป็นไปได้ — UDF เพิ่มกฎการประมวลผล Intent ที่เคร่งครัดให้กับ MVVM
ตามเอกสารประกอบ Android Developers (2024) สถาปัตยกรรมที่แนะนำสำหรับ Compose คือ UDF ภายใน MVVM: ViewModel จัดเก็บ State และประมวลผล Intents, View สมัครรับ State และส่ง Intents Google แนะนำ MVVM แบบคลาสสิกกับการผูกมัดสองทางผ่าน DataBinding เฉพาะสำหรับหน้าจอที่ไม่มีตรรกะทางธุรกิจเท่านั้น สำหรับ Jetpack Compose สถานการณ์หลักคือ UDF ที่มีการจัดการเหตุการณ์อย่างชัดเจน
ตารางเปรียบเทียบ:
| ลักษณะ | MVVM (คลาสสิก) | MVVM + UDF |
|---|---|---|
| การไหลของข้อมูล | ไม่ได้กำหนด | ทางเดียวอย่างเคร่งครัด |
| การเปลี่ยนแปลงสถานะ | โดยตรงผ่าน setText() | ผ่าน Intent → Reducer เท่านั้น |
| แหล่งความจริงเดียว | ไม่ | ใช่ |
| ความสามารถในการทดสอบ Reducer | ต่ำ | สูง (ฟังก์ชันบริสุทธิ์) |
| คำแนะนำของ Google | แนวทางเดิม | หลักสำหรับ Compose |
ข้อผิดพลาดที่พบบ่อยที่สุดคือ ผลข้างเคียงภายใน Reducer นักพัฒนาที่คุ้นเคยกับ MVVM วางคำขอเครือข่ายโดยตรงในตัวจัดการ Intent ทำให้ Reducer ไม่บริสุทธิ์และทำลายความสามารถในการทดสอบ เอฟเฟกต์ทั้งหมดต้องถูกส่งคืนเป็นค่า (Effect / SideEffect) และดำเนินการโดยโครงสร้างพื้นฐานของเฟรมเวิร์ก บน Android ใช้ coroutine ใน ViewModel สำหรับสิ่งนี้; ใน TCA — Effect.run
ข้อผิดพลาดที่สองคือ Intents ที่ละเอียดเกินไป การกดแป้นพิมพ์ทุกครั้ง การเคลื่อนไหวของแถบเลื่อน และการเปลี่ยนแปลงข้อความสร้าง Intent แยกต่างหาก สำหรับฟิลด์ป้อนข้อมูลนี่มากเกินไป — ในกรณีเช่นนี้ การใช้ Binding กับการไหลทางเดียวภายในแบบฟอร์ม (สถานะท้องถิ่น) เป็นที่ยอมรับ และส่ง Intent ทั่วโลกเฉพาะสำหรับการกระทำที่สำคัญ (ส่ง, นำทาง) เท่านั้น
ข้อผิดพลาดที่สามคือ การขาดการจัดการยกเลิกเอฟเฟกต์ หากผู้ใช้ออกจากหน้าจอในขณะที่ coroutine หรือ Task ยังคงทำงานอยู่ ผลลัพธ์อาจถูกนำไปใช้กับ View ที่ถูกทำลายไปแล้ว บน Android ใช้ viewModelScope.cancel() หรือ takeWhileActive(); ใน TCA เอฟเฟกต์จะถูกยกเลิกโดยอัตโนมัติเมื่อ Store ถูกทำลาย ตาม Google Issue Tracker (2024) การรั่วไหลจาก coroutine ที่ไม่สมบูรณ์อยู่ใน 5 อันดับแรกของสาเหตุการหยุดทำงานในแอปพลิเคชัน Compose
คำถามที่พบบ่อย
MVI (Model-View-Intent) เป็นกรณีเฉพาะของ UDF ที่มีองค์ประกอบบังคับสามอย่าง: Intent (ความตั้งใจ), Model (สถานะ), View (การแสดงผล) ความแตกต่างหลักคือใน MVI แต่ละสถานะหน้าจอถูกอธิบายด้วยโครงสร้างที่ไม่สามารถเปลี่ยนแปลงได้เดียว (Sealed class) และ View เป็นฟังก์ชันบริสุทธิ์จาก Model ไปยัง UI UDF เป็นคำที่กว้างกว่าซึ่งอธิบายการไหลทางเดียวใด ๆ รวมถึง Redux และ Elm ในเอกสารประกอบของ Google คำว่า UDF ถูกใช้เป็นชื่อทั่วไป ในขณะที่ MVI เป็นการนำไปใช้เฉพาะ
UDF มากเกินไปสำหรับหน้าจอที่มีฟิลด์ป้อนข้อมูลเดียวที่ไม่มีการตรวจสอบ หน้าคงที่ และหน้าจอตัวยึดตำแหน่ง หากหน้าจอไม่มีตรรกะทางธุรกิจและสถานะของมันไม่ขึ้นอยู่กับการกระทำของผู้ใช้ UDF จะเพิ่มโค้ดที่ไม่จำเป็นโดยไม่มีประโยชน์ สำหรับสถานการณ์เช่นนี้ การผูกมัดทางเดียวหรือ @State อย่างง่ายใน SwiftUI ก็เพียงพอ UDF จะสมเหตุสมผลเมื่อจำนวนสถานะหน้าจอที่เป็นไปได้เกิน 3–4 และ/หรือมีผลข้างเคียง
เนื่องจาก Reducer เป็นฟังก์ชันบริสุทธิ์ การทดสอบจึงลดลงเป็นการเรียกด้วยชุดค่าผสมต่าง ๆ ของ State และ Intent และตรวจสอบ State และ Effect ที่ได้ บน Android ใช้ Turbine เพื่อทดสอบ StateFlow: ส่ง Intent ตรวจสอบการส่งสถานะครั้งถัดไป ใน TCA มี TestStore ในตัวที่ตรวจสอบโดยอัตโนมัติว่าหลังจาก Action เฉพาะฟิลด์ State ที่คาดหวังเท่านั้นที่เปลี่ยนแปลงและเฉพาะ Effects ที่คาดหวังเท่านั้นที่ถูกดำเนินการ
ได้ การรวมกันนั้นยอมรับได้และมักจะเหมาะสมที่สุด สำหรับฟิลด์ป้อนข้อมูลภายในแบบฟอร์ม ให้ใช้การผูกมัดสองทางท้องถิ่น (หรือ Binding ใน SwiftUI) เพื่อหลีกเลี่ยงการสร้าง Intent ทุกครั้งที่กดแป้นพิมพ์ เมื่อส่งแบบฟอร์ม ให้ส่ง Intent เดียวพร้อมข้อมูลที่รวบรวม ซึ่งประมวลผลโดย Reducer แนวทางแบบผสมนี้ — UDF ทั่วโลกกับการผูกมัดสองทางท้องถิ่น — ใช้ใน 70% ของแอปพลิเคชัน SwiftIO เชิงพาณิชย์ (ข้อมูล Swift Community Survey 2024)
ทั้งสามรูปแบบใช้การไหลของข้อมูลทางเดียวกับแหล่งความจริงเดียว Elm (2012) — ภาษาฟังก์ชันนัล — นำเสนอวงจรบริสุทธิ์ Model → View → Update เป็นครั้งแรก Redux (2015) ปรับ Elm สำหรับ JavaScript ด้วยแนวคิดของ Store, Reducer และ Action UDF เป็นการสรุปแนวคิดเหล่านี้สำหรับการพัฒนามือถือ ทั้งสามแนวทางรับประกันความสามารถในการคาดเดาของการเปลี่ยนแปลงผ่านการอัปเดตสถานะแบบอะตอมมิก
สรุป
เราจะพัฒนาแอปพลิเคชันบนมือถือแบบครบวงจร
IT Sectr สร้างแอปพลิเคชัน iOS และ Android สำหรับสตาร์ทอัพและธุรกิจตั้งแต่ปี 2017 เราจะให้คำแนะนำและเสนอวิธีแก้ปัญหาที่ดีที่สุดแก่คุณ