Contents
Chào bạn đến với Fx Studio!
Series Combine quả thật còn rất nhiều thứ thú vị để cho bạn khám phá. Bài viết này sẽ tiếp tục với Phần 3 của toàn series. Chủ đề hôm nay là Binding. Bài viết sẽ tập trung vào giải quyết ân oán tình thù của 2 thế thực View & ViewModel.
Dành cho các bạn chưa đọc bài viết đầu tiên về MVVM với Combine, thì có thể truy cập vào link sau:
Còn nếu bạn đã đọc qua rồi thì …
Bắt đầu thôi!
Chuẩn bị
- Xcode 11.0
- Swift 5.1
- iOS 13.0
Project sử dụng trong bài này, vẫn là Project ở bài trước. Chúng ta sẽ phát triển tiếp ứng dụng với 2 màn hình mới:
- Login
- Home
Bạn có thể tự do thiết kế giao diện cho 2 màn hình này. Bạn hãy yên tâm vì mọi thứ không quá phức tạp, chúng ta sẽ giải quyết bài toán kinh điển trong vũ trụ Reactive Programming.
1. Tổng quát về Binding
Trong bài đầu tiên về mô hình MVVM mới, chúng ta có 2 khái niệm mới là State & Action ,được đề cập trong thực thể ViewModel. Ta xem qua hình ảnh mô tả cho ý nghĩa & chức năng của 2 khái niệm mới trên.
Trong hình đó, thì phần binding
được vẽ với mũi tên 2 đầu. Thể hiện cho việc binding 2-way (2 chiều) đối với 2 thực thể này. Phần user action
thì vẫn chưa có gì thay đổi.
Ý nghĩa của việc binding
này:
- Chúng ta tiến hành setup một lần cho việc ràng buộc dữ liệu của các đối tượng
- Tập trung việc binding dữ liệu tại 1 chỗ
- Tuỳ theo các dữ liệu nhận được, mà các đối tượng tự động có các phản ứng theo
- Đơn giản và đồng nhất dữ liệu giữa các đối tượng
Quan trọng nhất chính là:
Tiết kiệm bộ nhớ.
Nghe qua, thì có thể bạn suy nghĩ mình hơi ngáo đá chút. Nhưng bạn có thể hình dung ra một điều rất đơn giản về bản chất của việc gán dữ liệu lẫn nhau.
Bản thân View & ViewModel là 2 thực thể tồn tại sẵn trong mô hình MVVM. Bạn muốn đưa dữ liệu từ View tới ViewModel hay ngược lại. Thì đều qua một thực thể trung gian (có thể là thèn Model).
Nếu như dữ liệu của bạn không cần qua thực thể trung gian. Thì giá trị của thuộc tính View liên kết với giá trị của một thuộc tính ViewModel và người lại. Do đó, bạn đã giảm đi rất nhiều đối tượng trung gian để truyền dữ liệu.
Tất nhiên, muốn thúc đẩy việc liên kết này, thì bạn phải dùng tới Combine. Để biến 1 bên thành phát và 1 bên thành nhận. Ngoài ra, vẫn phải đảm bảo việc:
- Hoạt động tốt trong project với Non-Combine Code
- Lưu trữ dữ liệu cơ bản cho các thuộc tính
… Chúng ta sẽ tiếp tục tìm hiểu thêm với việc sử dụng code làm demo. Chắc sẽ giúp bạn nhìn thấu hiểu chúng nó một cách nhanh hơn.
Hình này sẽ minh hoạ các vấn đề chúng ta cần giải quyết trong mối quan hệ đầy oan trái của View & ViewModel.
1. Base ViewController
Bắt đầu, chúng ta cần phải tiến hành cài đặt thêm 1 base class
cho các sub-class
của UIViewController. Mục đích đơn giản là tiết kiệm thời gian mà thôi.
Tạo một file mới có tên là BaseViewController
, kế thừa từ class UIViewController. Chúng ta tham khảo code cho base class
này như sau:
import UIKit import Combine class BaseViewController: UIViewController { //MARK: - Properties var subscriptions = [AnyCancellable]() override func viewDidLoad() { super.viewDidLoad() setupData() setupUI() bindingToView() bindingToViewModel() router() } //MARK: - Configuration func setupData() { } func setupUI() { } func bindingToView() { } func bindingToViewModel() { } //MARK: - Navigation func router() { } //MARK: - Publish functions func alert(title: String, text: String?) -> AnyPublisher<Void, Never> { let alertVC = UIAlertController(title: title, message: text, preferredStyle: .alert) return Future { resolve in alertVC.addAction(UIAlertAction(title: "Close", style: .default, handler: { _ in resolve(.success(())) })) self.present(alertVC, animated: true, completion: nil) }.handleEvents(receiveCancel: { self.dismiss(animated: true) }).eraseToAnyPublisher() } }
Trong đó:
- Về mặt thiết lập và cài đặt, thì vẫn 2 function quen thuộc
setupData
dành cho mặt chuẩn bị về dữ liệusetupUI
dành cho việc cấu hình và cài đặt các view
- Về mặt Combine chúng ta cần
import Combine
để sử dụngsubscriptions
để lưu trữ các subscription phát sinh- Các function với kiểu trả về là một
AnyPublisher
- Về mặt MVVM mới, có 3 đại diện như sau:
bindingToView
để cấu hình cho việc đưa dữ liệu từ các thuộc tính của ViewModel tới ViewbindingToViewModel
để cấu hình cho việc đưa dữ liệu từ UI Control của View tới các thuộc tính của ViewModelrouter
dành cho ViewController điều kiển việc điều hướng
Nếu bạn có muốn thêm gì cho riêng bạn, thì hãy tự do sáng tạo nha. Còn bây giờ, thì chúng ta tạo thêm một ViewController với tên là LoginViewController
, kế thừa trực tiếp từ BaseViewController.
Về giao diện, chúng ta cần:
- 2 UITextField dành cho
username
&password
- 1 UIButton để thực hiện hành động
login
- 1 Indicator cho việc hiển thị trạng thái đang thực hiện request bất đồng bộ và tốn thời gian để chờ.
Ngoài ra, bạn thêm 1 màn hình HomeViewController
, chưa cần làm gì hết. Bạn tạo nó ra và để im như vậy. Và giờ, chúng ta sang các phần chính của bài.
2. Binding To View
2.1. Store Property
Setup lại class User, sao cho phù hợp với dữ liệu của màn hình Login. Mở file User.swift
và tiếp hành code như sau:
import Foundation final class User { var username: String var password: String var isLogin = false var about = "n/a" init(username: String, password: String) { self.username = username self.password = password } }
Cũng không quá khó. Bạn chịu khó edit lại màn hình WelcomeViewController nha. Tiếp theo, chúng ta cần setup tiếp LoginViewController.
import UIKit import Combine class LoginViewController: BaseViewController { //MARK: - Properties // Outlet @IBOutlet weak var usernameTextField: UITextField! @IBOutlet weak var passwordTextField: UITextField! @IBOutlet weak var loginButton: UIButton! @IBOutlet weak var indicatorView: UIActivityIndicatorView! //MARK: - Lifecycle override func viewDidLoad() { super.viewDidLoad() } //MARK: - Config View //MARK: Setup override func setupData() { super.setupData() } override func setupUI() { super.setupUI() // Title self.title = "Login" // Navigation Bar let clearBarButton = UIBarButtonItem(title: "Clear", style: .plain, target: self, action: #selector(clear)) self.navigationItem.leftBarButtonItem = clearBarButton } //MARK: Binding override func bindingToView() { } override func bindingToViewModel() { } //MARK: Router override func router() { } //MARK: - Actions @IBAction func loginButtonTouchUpInside(_ sender: Any) { } @objc func clear() { } }
Cũng không quá rối não. LoginViewController kế thừa lại BaseViewController. Sau đó, bạn tiến hành override
các function cần thiết.
Bạn chú ý, chúng ta có 4 IBOutlet
để binding dữ liệu từ ViewModel lên View. Việc tiếp theo, bạn tạo một file mới với tên là LoginViewModel
. Tiến hành code như sau:
final class LoginViewModel { //MARK: - Properties // Publisher & store @Published var username: String? @Published var password: String? @Published var isLoading: Bool = false // Model var user: User? init(username: String, password: String) { self.username = username self.password = password self.user = .init(username: username, password: password) } }
Trong đó:
- Có các thuộc tính
- 1 đối tượng Model
- 1 function khởi tạo
Bạn sẽ thấy, trong class ViewModel này mình sử dụng @Published
thay cho các loại Subject
. Tại sao lại như vậy?
Vì mình thích thôi!
Đùa thôi và theo quan điểm cá nhân, thì mình thích việc sử dụng trực tiếp biến cho các việc gán hay edit dữ liệu của nó. Còn với subscject
, bạn phải trỏ tới thuộc tính value
của đối tượng Subject
mới thay đổi được giá trị.
Về
@Published
mà bạn lỡ quên nó rồi, bạn quay lại Phần 2 của Combine vs. UIKit để đọc lại lý thuyết về nó.
Giờ chuyển sang file LoginViewController, thêm thuộc tính viewmodel
cho nó. Tạm thời sử dụng dữ liệu giả như sau.
var viewModel = LoginViewModel(username: "fxstudio", password: "123456")
Phần việc tiếp theo là đưa dữ liệu từ 2 biến username
& password
của đối tượng ViewModel tới 2 IBOutlet của View, bằng cách subscribe tới chính 2 thuộc tính của ViewModel. Vì chúng hiện tại là 1 Publisher với khai báo @Published
.
Tại function bindingToView
, bạn tiến hành thêm đoạn code sau:
override func bindingToView() { // username viewModel.$username .assign(to: \.text, on: usernameTextField) .store(in: &subscriptions) // password viewModel.$password .assign(to: \.text, on: passwordTextField) .store(in: &subscriptions) }
Do mọi thứ đều tương đồng nhau về kiểu dữ liệu. Nên ta sử dụng assign
để tiến hành đăng ký tới các Publisher. Bạn build project và kiểm tra kết quả.
Chúng ta đã thấy được dữ liệu hiển thị lên View rồi. OKE, bạn đã hoàn thành một nữa chặng đường gian nan này rồi đó. Và chúng ta có bài học đầu tiên là:
Sử dụng
@Published
cho các Store Property của ViewModel.
2.2. State
Công việc phần này vẫn chưa xong, bạn thấy cái tròn tròn xoay xoay chính giữa màn hình. Nó rất là chướng mắt và khi nào thực hiện login thì nó với được phép hiện ra….
Để giải quyết nó thì bạn hãy nhớ lại bài trước với State & Action. Vì hiển thị view tròn tròn xoay xoay đó cũng được xem là do một trạng thái của ViewModel quyết định. Chúng ta quay lại file LoginViewModel và cài đặt các phần State & Action như bài trước.
- Define cho State & Action
- State có 3 trạng thái
initial
là trạng thái khởi tạologined
cho đăng nhập thành côngerror
có lỗi cần phải thông báo cho người dùng biết
- Action có 2 hành động chính
login
dành cho loginclear
dành cho việc xoá nội dung ở 2 field
- State có 3 trạng thái
// State enum State { case initial case logined case error(message: String) } // Action enum Action { case login case clear }
- Khai báo 2 Publisher cho State & Action. Kèm theo đó là biến
subscriptions
để lưu trữ các subscription lại.
// Actions let action = PassthroughSubject<Action, Never>() // State let state = CurrentValueSubject<State, Never>(.initial) // Subscriptions var subscriptions = [AnyCancellable]()
- Các function xử lí cho State & Action. Tạm thời bạn đừng quan tâm tới phần Action, mình sẽ dành ra một bài cho nó. Còn State thì xử lý đơn giản như vậy, chỉ là việc gán giá trị cho các thuộc tính.
// process Action private func processAction(_ action: Action) { switch action { case .login: print("ViewModel -> Login") case .clear: username = "" password = "" } } // process State private func processState(_ state: State) { switch state { case .initial: if let user = user { username = user.username password = user.password isLoading = false } else { username = "" password = "" isLoading = false } case .logined: print("LOGINED") case .error(let message): print("Error: \(message)") } }
- Update lại
init
- Thêm việc subscribe cho State & Action
- Sau đó trỏ tới function xử lý State & Action
init(username: String, password: String) { self.user = .init(username: username, password: password) // state state .sink { [weak self] state in self?.processState(state) }.store(in: &subscriptions) // action action .sink { [weak self] action in self?.processAction(action) }.store(in: &subscriptions) }
Để cho chắc chắn, bạn thử build lại project và xem mọi thử vẫn ổn không. Nếu ổn thì chúng ta tiếp tục nào.
Bạn để ý với case .initial
, với việc giá trị của biến isLogin = false
. Và với các case khác bạn có thể cho nó giá trị là true
, nhưng bạn hãy để sau.
Tiếp tục với file LoginViewController, tại function bindingToView
, ta tiến hành subscribe thêm thuộc tính khác của ViewModel cho View. Bạn thêm đoạn code sau vào function đó.
viewModel.$isLoading .sink(receiveValue: { isLoading in if isLoading { self.indicatorView.startAnimating() } else { self.indicatorView.stopAnimating() } }) .store(in: &subscriptions)
Vì chúng ta không thể đưa giá trị lên thẳng indicatorView
(vì nó là read-only), nên sử dụng sink
và gọi các function liên quan. Bạn tiến hành build, sau đó hãy thay đổi từ false
thành true
ở case .initial
. Và xem kết quả như thế nào nha.
Hoặc bạn có thể thử cách khác như sau:
- update
processState
case .logined: print("LOGINED") isLoading = true
send state
tại LoginViewController
@IBAction func loginButtonTouchUpInside(_ sender: Any) { viewModel.state.send(.logined) }
Build và cảm nhận kết quả. Chuyển sang phần tiếp theo nào!
3. Binding To ViewModel
Với trường hợp binding
dữ liệu theo chiều ngược lại (từ View sang ViewModel) thì UIKit khá là bất tiện. Do bạn phải tiến hành custom
hoặc viết thêm các extension
cho các UI Control. Nhằm biến chúng thành nguồn phát (Publisher).
Với RxSwift thì công việc này đơn giản hơn nhiều. Do đã được cài đặt sẵn RxCocoa cho các UI Control.
Tất nhiên, cái khó sẽ ló cái khôn. Trong bài, chúng ta có sử dụng 2 UI Control là UITextField. Mà chúng nó lại có các Notification
thông báo cho việc update dữ liệu của nó. Ta có thể lợi dụng điểm này.
Bạn tạo mới 1 file có tên là UITextField.Publisher.swift
. Thêm đoạn code này vào
import Foundation import UIKit import Combine extension UITextField { var publisher: AnyPublisher<String?, Never> { NotificationCenter.default .publisher(for: UITextField.textDidChangeNotification, object: self) .compactMap { $0.object as? UITextField? } .map { $0?.text } .eraseToAnyPublisher() } }
Trong đó:
- Chúng ta đang tạo thêm 1 property của UITextField có tên là
publisher
, bằng cách tạo thêm 1 extension cho UITextField. - Thuộc tính mới này là kiểu
AnyPublisher
, với:- Output là
String?
- Failure là
Never
- Output là
- Tiến hành
subscribe
tới NotificationCenter.default , vớipublisher
của nó chotextDidChangeNotification
- Sau đó tiến hành 1 loạt các
operator
nhằm đưa đối tượngNotification
thànhString?
Sang phần sử dụng nó nào. Bạn mở file LoginViewController, tại function bindingToViewModel
. Thêm đoạn code sau vào:
override func bindingToViewModel() { // usernameTextField usernameTextField.publisher .assign(to: \.username, on: viewModel) .store(in: &subscriptions) // passwordTextField passwordTextField.publisher .assign(to: \.password, on: viewModel) .store(in: &subscriptions) }
Đây là một pha đá bóng chuyền ngược lại. Bạn chỉ cần thay đổi vai trò giữa View & ViewModel. Xong, bạn tiến hành build và xem kết quả.
Nếu bạn không tin thì có thể test lại với đoạn code sau. Bạn đặt nó ở đâu cũng được trong ViewModel.
//test $username .sink { print($0)} .store(in: &subscriptions)
Đây là kết quả cho việc nhập dữ liệu từ View và binding
sang ViewModel.
Chúng ta đã xong 2 phần chính của việc binding
. Giờ sang 2 phần phụ tiếp theo.
4. Trigger
Đây là lợi điểm mà biết bao thế hệ dev khi làm việc với Reactive Programming đều lợi dụng. Nôm na, chúng ta sẽ có 1 Publisher lắng nghe tới các Publisher khác, được gọi là trigger
. Khi đạt đủ điều kiện thì Publisher trigger
sẽ phát tín hiệu đi. Các hành động & giao diện sẽ dựa vào đó mà phản ứng theo.
Đây cũng là bài toán kinh điển trong RxSwift. Ta có 2 TextField là username
& password
. Chỉ khi nào cả 2 field đó đều có dữ liệu (không rỗng) thì Button Login mới được kích hoạt. Việc này cũng giảm đi 1 cơ số bug cho dev mình bất cẩn trong quá trình code.
Lý thuyết là vậy, giờ chúng ta tiến hành cài đặt. Đầu tiên, chúng ta tạo Publisher trigger
. Mở file LoginViewModel, khai báo 1 biến với kiểm AnyPublisher
.
- Vì chỉ cần biết trạng thái đúng hay sai để cập nhật Button Login. Nên Output là
Bool
.
var validatedText: AnyPublisher<Bool, Never>
Theo yêu cầu là 2 textfield phải có dữ liệu. Tới đây, chúng ta sẽ lựa chọn toán tử CombineLatest để thực hiện.
// Trigger TextField var validatedText: AnyPublisher<Bool, Never> { return Publishers.CombineLatest($username, $password) .map { !($0!.isEmpty || $1!.isEmpty) } .eraseToAnyPublisher() }
Có chút khác biệt, chúng ta không dùng toán tử từ 1 Publisher. Mà dùng nó để tạo ra 1 Publisher mới từ 2 upstream
. Cuối cùng, tiến hành binding
lên View. Tại function bindingToView
của LoginViewController, ta tiến hành subscribe
thuộc tính vừa mới tạo ra.
// button viewModel.validatedText .assign(to: \.isEnabled, on: loginButton) .store(in: &subscriptions)
Build lại project của bạn và tiến hành kiểm tra xem nó hoạt động chưa. Nếu đã ổn thì chúc mừng bạn đã xong phần trigger
này. Và bài học được rút ra như sau
Dùng
AnyPublisher
để tạo ra các Computed Property ở ViewModel.
5. Router
Giờ tới phần điều hướng của ViewController. Việc điều hướng này sẽ tuỳ thuộc theo State
của ViewModel phát ra. Điều hướng dành cho các View/Custom View/ ViewController khác …
Quay lại project demo, ta có yêu cầu là khi nhấn vào Button Login, thì sẽ chuyển sang màn hình HomeViewController. Nó sẽ tương ứng với trạng thái .logined
của State
.
Bạn mở file LoginViewController, tại function router
. Tiến hành cài đặt như sau:
override func router() { // viewmodel State viewModel.state .sink { [weak self] state in if case .error(let message) = state { // show alert _ = self?.alert(title: "Demo MVVM", text: message) } else if case .logined = state { self!.viewModel.isLoading = false let vc = HomeViewController() self?.navigationController?.pushViewController(vc, animated: true) } }.store(in: &subscriptions) }
Ta chỉ cần subscribe
tới thuộc tính state
của ViewModel bằng sink
. Sau đó, tuỳ thuộc vào giá trị nhận được để quyết định điều hướng các View nào. Trong code ví dụ, mình có bổ sung thêm trạng thái error
. Nếu như có quá nhiều trạng thái cần điều hướng thì bạn có thể sử dụng switch ... case
.
Ta tiếp tục edit thêm một chút nữa:
@IBAction func loginButtonTouchUpInside(_ sender: Any) { viewModel.action.send(.login) }
Thay vì phát đi 1 state
thì chúng ta quay về đúng ý nghĩa của Action là phát đi 1 action
. Và khi nhấn nút Login, chúng ta sử dụng viewmodel
phát đi sự kiện login
.
Bây giờ, chuyển sang file LoginViewModel, tại hàm xử lý action
, update theo đoạn code sau:
private func processAction(_ action: Action) { switch action { case .login: print("ViewModel -> Login") self.state.value = .logined case .clear: username = "" password = "" } }
Build project và test lại sự kiện nhấn nút Login. Qua trên, bạn sẽ thấy chúng ta hoàn toàn tách biệt việc điều hướng với các sự kiện người dùng. Chỉ cần quan tâm tới trạng thái nào của ViewModel thì sẽ có việc điều hướng tương tứng mà thôi.
OKAY! Mọi thứ đã ổn và mình xin hết thúc bài viết này ở đây. Bạn đã giải quyết cơ bản được mối quan hệ giữa View & ViewModel rồi. Phần hay vẫn còn ở sau. Bạn hãy đón chờ xem nó.
Bạn có thể checkout project demo tại đây:
Tạm kết
- Sử dụng
@Published
vàAnyPublisher
cho các loại thuộc tính của ViewModel - Cách
binding
dữ liệu hai chiều giữa View & ViewModel - Cách điều hướng cơ bản dựa theo trạng thái của ViewModel
- Tạo các
trigger
để điều khiển hoặc thực hiện các function, dựa theo dữ liệu nhận được
Nếu bạn thấy bài viết này hay và hưu ích, thì hãy share cho nhiều người cùng đọc. Nếu bạn muốn đóng góp hoặc góp ý cho mình, thì hãy để lại comment hoặc email hoặc theo contact của website.
Cảm ơn bạn đã đọc bài viết này!
Written by chuotfx
Hãy ngồi xuống, uống miếng bánh và ăn miếng trà. Chúng ta cùng nhau đàm đạo về đời, về code nhóe!
Leave a Reply Cancel reply
Fan page
Tags
Recent Posts
- CO-STAR – Công thức vàng để viết Prompt hiệu quả cho LLM
- Prompt Engineering trong 10 phút
- Một số ví dụ sử dụng Prompt cơ bản khi làm việc với AI
- Prompt trong 10 phút
- Charles Proxy – Phần 1 : Giới thiệu, cài đặt và cấu hình
- Complete Concurrency với Swift 6
- 300 Bài code thiếu nhi bằng Python – Ebook
- Builder Pattern trong 10 phút
- Observer Pattern trong 10 phút
- Memento Pattern trong 10 phút
Archives
- December 2024 (4)
- September 2024 (1)
- July 2024 (1)
- June 2024 (1)
- May 2024 (4)
- April 2024 (2)
- March 2024 (5)
- January 2024 (4)
- February 2023 (1)
- January 2023 (2)
- November 2022 (2)
- October 2022 (1)
- September 2022 (5)
- August 2022 (6)
- July 2022 (7)
- June 2022 (8)
- May 2022 (5)
- April 2022 (1)
- March 2022 (3)
- February 2022 (5)
- January 2022 (4)
- December 2021 (6)
- November 2021 (8)
- October 2021 (8)
- September 2021 (8)
- August 2021 (8)
- July 2021 (9)
- June 2021 (8)
- May 2021 (7)
- April 2021 (11)
- March 2021 (12)
- February 2021 (3)
- January 2021 (3)
- December 2020 (3)
- November 2020 (9)
- October 2020 (7)
- September 2020 (17)
- August 2020 (1)
- July 2020 (3)
- June 2020 (1)
- May 2020 (2)
- April 2020 (3)
- March 2020 (20)
- February 2020 (5)
- January 2020 (2)
- December 2019 (12)
- November 2019 (12)
- October 2019 (19)
- September 2019 (17)
- August 2019 (10)