Hiểu Luồng Dữ Liệu Một Chiều là gì — một luồng dữ liệu đơn hướng, một mẫu kiến trúc nơi dữ liệu di chuyển theo vòng lặp khép kín State → View → Intent → Reducer → State mà không có vòng phản hồi. Khác với ràng buộc hai chiều, UDF đảm bảo rằng các thay đổi trạng thái chỉ xảy ra thông qua các hành động rõ ràng (Intent/Event), làm cho luồng dữ liệu có thể dự đoán và theo dõi được. Theo Google I/O 2024, UDF là kiến trúc được khuyến nghị cho các ứng dụng Jetpack Compose và SwiftUI với logic nghiệp vụ có độ phức tạp trung bình và cao.
Những Điểm Chính
Luồng Dữ Liệu Một Chiều (UDF) là một mẫu kiến trúc nơi dữ liệu di chuyển theo một hướng trong một vòng lặp khép kín, loại bỏ các vòng phản hồi giữa View và Model. Khác với Ràng buộc Hai chiều, nơi một thay đổi trong UI ngay lập tức cập nhật model, UDF yêu cầu một hành động rõ ràng (Intent, Event, Action) cho mỗi thay đổi trạng thái. Điều này làm cho luồng dữ liệu hoàn toàn có thể dự đoán: tại bất kỳ thời điểm nào, có thể xác định hành động nào đã dẫn đến trạng thái hiện tại.
Khái niệm UDF đến từ các framework web — Redux (JavaScript, 2015) và Elm (2012) — và đã được điều chỉnh cho phát triển di động. Theo Google I/O 2024, UDF đã trở thành kiến trúc được khuyến nghị cho Jetpack Compose, thay thế MVVM cổ điển với LiveData. Trên iOS, một cách tiếp cận tương tự được triển khai trong The Composable Architecture (TCA) bởi Point-Free, được hơn 15% nhà phát triển iOS sử dụng theo Khảo sát Cộng đồng Swift (2024).
Ưu điểm chính của UDF là Nguồn Sự Thật Duy Nhất (SSOT): toàn bộ trạng thái ứng dụng được lưu trữ tại một nơi và được sửa đổi thông qua các thao tác được xác định nghiêm ngặt. Điều này đơn giản hóa việc gỡ lỗi, kiểm thử và tái tạo lỗi, vì mọi thay đổi trạng thái đều được ghi lại và có thể tái tạo bằng cách gửi lại cùng một Intents.
Chu trình UDF cơ bản bao gồm bốn bước: State (trạng thái hiện tại) được kết xuất trong View; người dùng thực hiện một hành động trở thành Intent; Intent được xử lý trong một Reducer (hàm thuần túy), tạo ra một State mới; trạng thái mới được chuyển đến View để kết xuất lại. Chu trình này lặp lại ở mỗi sự kiện người dùng hoặc hệ thống.
Mỗi phần tử của chu trình có trách nhiệm nghiêm ngặt: State — một đối tượng bất biến mô tả trạng thái màn hình tại một thời điểm cụ thể; View — một hàm kết xuất State; Intent — một giá trị mô tả ý định của người dùng (ví dụ: LoginIntent.Submit); Reducer — một hàm thuần túy không có tác dụng phụ, nhận State hiện tại và Intent và trả về một State mới. Các tác dụng phụ (yêu cầu mạng, thao tác cơ sở dữ liệu) được chuyển đến một lớp Middleware hoặc Effect riêng biệt.
Theo bài viết Google Android Architecture (2024), tính thuần túy của Reducer là một yêu cầu chính: nếu Reducer chứa một lệnh gọi mạng hoặc ghi cơ sở dữ liệu, việc kiểm thử và gỡ lỗi luồng dữ liệu trở nên bất khả thi. Tất cả các tác dụng phụ phải được thực thi trong một coroutine ViewModel hoặc Swift Task trước khi gọi Reducer, và kết quả phải được gửi dưới dạng một Intent mới.
Trên Android, việc triển khai UDF được xây dựng trên ba thành phần Jetpack: ViewModel quản lý vòng đời, StateFlow cung cấp một luồng trạng thái phản ứng, Intent (sealed class) mô tả tất cả các hành động có thể có của người dùng. View đăng ký StateFlow qua collectAsState() trong Compose hoặc observe() trong hệ thống 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) }
)
}Ví dụ minh họa chu trình UDF hoàn chỉnh trong Android: LoginIntent mô tả tất cả các hành động có thể (thay đổi email, thay đổi mật khẩu, gửi biểu mẫu), LoginState là trạng thái bất biến, LoginViewModel xử lý Intents và cập nhật StateFlow, và màn hình Compose đăng ký trạng thái qua collectAsState(). Mọi thay đổi trạng thái đều là kết quả của việc xử lý một Intent cụ thể, làm cho luồng dữ liệu hoàn toàn minh bạch.
Trên iOS, UDF được triển khai thông qua The Composable Architecture (TCA) bởi Point-Free hoặc mẫu Observable gốc với iOS 17+. TCA cung cấp một chu trình có sẵn State + Action + Reducer + Store, nơi Store là nguồn sự thật duy nhất, và View đăng ký các thay đổi qua @Observable hoặc 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("Đăng nhập") { viewStore.send(.submit) }
}
}
}
}loginReducer là một hàm thuần túy: nó không thực hiện yêu cầu mạng trực tiếp mà trả về một Effect sẽ được thực thi bởi môi trường TCA. Điều này cho phép kiểm thử bộ rút gọn một cách cô lập bằng cách thay thế các hiệu ứng trong bài kiểm thử. View đăng ký các thay đổi của Store qua WithViewStore và gửi Actions qua send(). TCA tự động xử lý việc hủy bỏ các hiệu ứng khi Store bị phá hủy, ngăn chặn rò rỉ bộ nhớ.
MVVM và UDF thường bị nhầm lẫn, nhưng có một sự khác biệt cơ bản giữa chúng. MVVM là một mẫu cấu trúc phân tách mã thành ba lớp (Model, View, ViewModel) nhưng không xác định hướng của luồng dữ liệu. UDF là một mẫu hành vi mô tả cách dữ liệu di chuyển trong cấu trúc đó. Trong MVVM với LiveData, cả ràng buộc hai chiều và luồng một chiều đều có thể — UDF thêm các quy tắc xử lý Intent nghiêm ngặt vào MVVM.
Theo tài liệu Android Developers (2024), kiến trúc được khuyến nghị cho Compose là UDF bên trong MVVM: ViewModel lưu trữ State và xử lý Intents, View đăng ký State và gửi Intents. Google khuyến nghị MVVM cổ điển với Ràng buộc Hai chiều qua DataBinding chỉ cho các màn hình đơn giản không có logic nghiệp vụ. Đối với Jetpack Compose, kịch bản chính là UDF với xử lý sự kiện rõ ràng.
Bảng so sánh:
| Đặc điểm | MVVM (cổ điển) | MVVM + UDF |
|---|---|---|
| Luồng dữ liệu | Không xác định | Nghiêm ngặt một chiều |
| Thay đổi trạng thái | Trực tiếp qua setText() | Chỉ qua Intent → Reducer |
| Nguồn Sự Thật Duy Nhất | Không | Có |
| Khả năng kiểm thử Reducer | Thấp | Cao (hàm thuần túy) |
| Khuyến nghị của Google | Cách tiếp cận cũ | Chính cho Compose |
Lỗi phổ biến nhất là tác dụng phụ bên trong Reducer. Các nhà phát triển quen với MVVM đặt các yêu cầu mạng trực tiếp trong trình xử lý Intent, làm cho Reducer không thuần túy và phá vỡ khả năng kiểm thử. Tất cả các hiệu ứng phải được trả về dưới dạng một giá trị (Effect / SideEffect) và được thực thi bởi cơ sở hạ tầng framework. Trên Android, coroutine trong ViewModel được sử dụng cho việc này; trong TCA — Effect.run.
Lỗi thứ hai là Intents quá chi tiết. Mỗi lần gõ phím, di chuyển thanh trượt và thay đổi văn bản tạo ra một Intent riêng biệt. Đối với các trường nhập liệu, điều này là quá mức — trong những trường hợp như vậy, có thể chấp nhận sử dụng Binding với luồng một chiều trong biểu mẫu (trạng thái cục bộ) và chỉ gửi một Intent toàn cục cho các hành động quan trọng (gửi, điều hướng).
Lỗi thứ ba là thiếu xử lý hủy bỏ hiệu ứng. Nếu người dùng rời khỏi màn hình trong khi một coroutine hoặc Task vẫn đang thực thi, kết quả có thể được áp dụng cho một View đã bị phá hủy. Trên Android, sử dụng viewModelScope.cancel() hoặc takeWhileActive(); trong TCA, các hiệu ứng tự động bị hủy khi Store bị phá hủy. Theo Google Issue Tracker (2024), rò rỉ từ các coroutine chưa hoàn thành nằm trong top 5 nguyên nhân gây sập ứng dụng Compose.
Câu hỏi thường gặp
MVI (Model-View-Intent) là một trường hợp cụ thể của UDF với ba yếu tố bắt buộc: Intent (ý định), Model (trạng thái), View (hiển thị). Sự khác biệt chính là trong MVI, mỗi trạng thái màn hình được mô tả bằng một cấu trúc bất biến duy nhất (Sealed class), và View là một hàm thuần túy từ Model đến UI. UDF là một thuật ngữ rộng hơn mô tả bất kỳ luồng một chiều nào, bao gồm Redux và Elm. Trong tài liệu của Google, thuật ngữ UDF được sử dụng như tên gọi chung, còn MVI là một triển khai cụ thể.
UDF là quá mức đối với các màn hình có một trường nhập liệu duy nhất không có xác thực, các trang tĩnh và màn hình giữ chỗ. Nếu một màn hình không có logic nghiệp vụ và trạng thái của nó không phụ thuộc vào hành động của người dùng, UDF thêm mã không cần thiết mà không có lợi ích. Đối với những kịch bản như vậy, ràng buộc một chiều hoặc @State đơn giản trong SwiftUI là đủ. UDF được chứng minh khi số lượng trạng thái màn hình có thể vượt quá 3–4 và/hoặc có các tác dụng phụ.
Vì Reducer là một hàm thuần túy, việc kiểm thử nó chỉ đơn giản là gọi nó với các kết hợp khác nhau của State và Intent và kiểm tra State và Effect kết quả. Trên Android, sử dụng Turbine để kiểm thử StateFlow: gửi một Intent, kiểm tra lần phát State tiếp theo. Trong TCA, có một TestStore tích hợp tự động xác minh rằng sau một Action, chỉ các trường State dự kiến thay đổi và chỉ các Effects dự kiến được thực thi.
Có, kết hợp chúng là chấp nhận được và thường là tối ưu. Đối với các trường nhập liệu trong biểu mẫu, hãy sử dụng Ràng buộc Hai chiều cục bộ (hoặc Binding trong SwiftUI) để tránh tạo Intent cho mỗi lần gõ phím. Khi gửi biểu mẫu, hãy gửi một Intent duy nhất với dữ liệu đã thu thập, được xử lý bởi Reducer. Cách tiếp cận kết hợp này — UDF toàn cục với Ràng buộc Hai chiều cục bộ — được sử dụng trong 70% ứng dụng SwiftUI thương mại (dữ liệu Khảo sát Cộng đồng Swift 2024).
Cả ba mẫu đều triển khai luồng dữ liệu một chiều với một nguồn sự thật duy nhất. Elm (2012) — một ngôn ngữ hàm — lần đầu tiên giới thiệu chu trình thuần túy Model → View → Update. Redux (2015) đã điều chỉnh Elm cho JavaScript với các khái niệm Store, Reducer và Action. UDF là sự tổng quát hóa những ý tưởng này cho phát triển di động. Cả ba cách tiếp cận đều đảm bảo khả năng dự đoán của các thay đổi thông qua các cập nhật trạng thái nguyên tử.
Tổng kết
Chúng tôi sẽ phát triển ứng dụng di động chìa khóa trao tay
IT Sectr tạo các ứng dụng iOS và Android cho các công ty khởi nghiệp và doanh nghiệp từ năm 2017. Chúng tôi sẽ tư vấn và đề xuất giải pháp tốt nhất cho bạn.
Đọc thêm