Rx+MVVM 예제를 살펴보면 주로 검색에 대한 테이블 뷰 화면, 또는 로그인 화면이 많은 것을 알 수 있는데,
실시간으로 반응해서 대응하는 화면이 Rx의 가장 큰 장점(반응형)을 돋보이게 할 수 있어서 그런 것 같다.
그러니까 한번 가보자고 ~
먼저 화면부터 살펴보면 아래와 같다.
이렇게 위와 같이 이메일을 입력할 수 있는 텍스트필드와, 비밀번호를 입력할 수 있는 텍스트 필드 2개, 그리고 로그인 버튼으로 구성되어 있다.
🌱 MVVM
먼저 프로젝트 구성을 살펴보면 다음과 같다.
MVVM 패턴이므로 Model-View-ViewModel의 구조로 구성되어 있다.
#Model
Model에는 위와 같이 로그인 화면에서 필요한 데이터 모델을 만들어준다.
이 때 우리는 크게 두가지 모델이 필요하다.
✅ 바로 로그인을 하기 위해서 서버로 보낼 id와 password를 담은 구조체가 필요하고,
✅ 로그인이 성공적으로 이뤄졌을 때 서버로부터 다시 전달 받을, (더 많은 정보가 있을 수 있겠지만) 예를 들면 사용자의 이름과 같은 정보가 필요하다.
각 모델을 살펴보면 아래와 같다.
먼저 로그인할 때 필요한 아이디, 비밀번호를 담은 구조체이다.
import Foundation
struct LoginRequest {
let email: String
let password: String
}
로그인 이후 응답값을 담을 구조체이다.
import Foundation
struct LoginResponse: Codable {
let name: String
init(name:String) {
self.name = name
}
}
실제로 서비스에서 로그인 서버를 연결하게 되면 유저의 이름 뿐 아니라, 유저의 소셜 로그인 토큰 등의 정보도 같이 담아서 온다.
#View
그리고 이제 화면을 만들어보자.
email을 입력할 수 있는 텍스트 필드, password를 입력할 수 있는 텍스트 필드, 둘 다 잘 작성되었다면 활성화를 할 로그인 버튼 .. 으로 화면이 구성되므로 아래와 같이 코드를 작성할 수 있다.
protocol BaseViewControllerAttribute {
func configureHierarchy()
func setAttribute()
func bind()
}
final class LoginViewController: UIViewController {
// MARK: - UI Property
private var idTextField = UITextField().then {
$0.borderStyle = .roundedRect
}
private var passwordTextField = UITextField().then {
$0.borderStyle = .roundedRect
}
private var loginButton = UIButton().then {
$0.backgroundColor = .systemPink
$0.setTitle("로그인", for: .normal)
$0.setTitleColor(.white, for: .normal)
}
// MARK: - Property
private let viewModel = LoginViewModel()
private let disposeBag = DisposeBag()
// MARK: - Life Cycle
override func viewDidLoad() {
super.viewDidLoad()
configureHierarchy()
setAttribute()
bind()
}
}
extension LoginViewController: BaseViewControllerAttribute {
func configureHierarchy() {
view.addSubview(idTextField)
view.addSubview(passwordTextField)
view.addSubview(loginButton)
idTextField.snp.makeConstraints { make in
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide).inset(10)
make.height.equalTo(50)
}
passwordTextField.snp.makeConstraints { make in
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide).inset(10)
make.height.equalTo(50)
}
loginButton.snp.makeConstraints { make in
make.top.leading.trailing.equalTo(passwordTextField.snp.bottom).inset(10)
make.height.equalTo(50)
}
}
func setAttribute() {
view.backgroundColor = .white
}
func bind() {
}
}
위의 코드에서 protocol을 통해 view controller에서 필요한, 작성될 내용을 한번 제시한 것이라고 볼 수 있다.
protocol BaseViewControllerAttribute {
func configureHierarchy()
func setAttribute()
func bind()
}
이렇게 기본적으로 사용할 함수들을 프로토콜로 선언한 것이다.
(이 프로토콜을 채택한 View Controller는 위의 세가지 함수 중 하나라도 작성하지 않으면 에러가 나기 때문에 휴먼 에러등을 줄일 수 있고 정의를 하여 추상화를 할 수 있다.)
사용자의 인터렉션, 화면 전환 등이 아닌, 비즈니스 로직은 모두 View Model에서 처리하므로 (일단) ViewController은 위의 코드까지만 적고 ViewModel로 넘어가보자.
#ViewModel
로그인 화면의 뷰모델에서 필요한 비즈니스 로직은
✅ email 텍스트 필드의 값이 변경되었는가? 어떤 값이 들어왔는가? 에 대한 판단
✅ password 텍스트 필드의 값이 변경되었는가? 어떤 값이 들어왔는가? 에 대한 판단
✅ 이메일과 비밀번호를 바탕으로 제대로 값이 들어왔고, 조건을 모두 만족하는가? 에 대한 판단
✅ 이메일, 비밀번호를 바탕으로 서버에 요청 후 값을 다시 뷰에 전달
이것들을 코드로 나타내면 아래와 같다.
import Foundation
import Alamofire
import RxCocoa
import RxSwift
enum LoginError: Error {
case badRequest
case serverError
}
final class LoginViewModel {
let emailRelay = BehaviorRelay<String>(value: "이메일을 입력해주세요")
let passwordRelay = BehaviorRelay<String>(value: "비밀번호를 입력해주세요")
var isLoginSucceed = PublishSubject<LoginResponse>()
var isValid: Observable<Bool> {
return Observable
.combineLatest(emailRelay, passwordRelay)
.map { email, password in
print("Email : \(email), Password : \(password)")
return !email.isEmpty && email.contains("@") && email.contains(".") && password.count > 0 && !password.isEmpty
}
}
func requestLogin(_ loginRequest: LoginRequest) {
let url = ""
let headers : HTTPHeaders = ["Content-Type" : "application/json"]
let params: Parameters = ["email": loginRequest.email, "password" : loginRequest.password]
let request = AF.request(url,
method: .post,
parameters: params,
encoding: JSONEncoding.default,
headers: headers)
request.responseDecodable(of: LoginResponse.self) { [weak self] response in
guard let self = self else { return }
switch response.result {
case .success(let value):
guard let statusCode = response.response?.statusCode else { return }
if statusCode == 200 {
self.isLoginSucceed.onNext(value)
} else if statusCode == 400 {
self.isLoginSucceed.onError(LoginError.badRequest)
} else if statusCode == 500 {
self.isLoginSucceed.onError(LoginError.serverError)
}
case .failure(let error):
self.isLoginSucceed.onError(LoginError.badRequest)
print(error)
}
}
}
}
-> 위의 코드에서 서버통신 부분은 APIManager 파일로 따로 관리할 수 있다.
여기서 유효성 판단 이후로, (isVaild)
일단 정규식이 모두 true로 반환이 되었을 때, 그 값을 위에서 만든 LoginRequest에 넣어서 서버에 보내고
응답값을 LoginResponse에 담아 이를 바탕으로 처리할 수 있다.
일단은 이렇게 파일 분리를 했다면,
뷰에서의 인터랙션과 그에 따른 비즈니스 로직 처리를 Rx를 이용해서 반응형으로 만들어보자 !!
🌱 RxSwift (+ RxCocoa)
위의 코드에서 View(=View Controller)에 따로 bind 코드를 작성하지 않았는데,
이제 사용자가 인터랙션을 해서 어떻게 데이터의 흐름이 이어지는지 따라가면서 코드를 작성해보자.
자 내가 사용자라고 가정해보자.
- 먼저, 앱을 시작하고 들어와서 이메일 텍스트 필드를 탭해서 이메일을 작성할 것이고,
- 그 아래에 있는 비밀번호 텍스트 필드를 탭해서 비밀 번호를 작성할 것이다.
- 그리고 조건에 맞게 이메일과 비밀번호를 작성했다면, 로그인 버튼이 활성화 될 것이고,
- 로그인 버튼을 탭해서 입력한 이메일과 비밀번호를 서버로 보내서 통신 이후
- 통신이 성공한 경우와, 실패한 경우로 나눠서 작업이 이뤄질 것이다.
이 흐름을 생각하면서 bind 로직을 짜면 된다. 그리고 MVVM 패턴을 적용했기 때문에
✅ UI와 관련된 작업 (UI 변화 또는 인터랙션 등)
✅ 로직과 관련된 작업 (이메일, 비밀번호 유효성 검사 및 이메일, 비밀번호를 기반으로 서버 통신 등)
을 나눠서 View와 ViewModel로 데이터를 전달하고-받으면 된다.
일단 뷰에서 받은 인터렉션에 대해서 뷰모델이 처리할 비즈니스 로직은 다음과 같다.
#이메일, 비밀번호 텍스트필드에서 받은 값에 대한 처리
이 때, 텍스트 필드의 값이므로 일종의 placeholder와 같은 초기값이 필요하다.
그러므로 초기값이 있는, UI의 이벤트를 전달할 수도, 받을 수도 있으므로 BehaviorRelay 형태가 적합하다.
let emailRelay = BehaviorRelay<String>(value: "이메일을 입력해주세요")
let passwordRelay = BehaviorRelay<String>(value: "비밀번호를 입력해주세요")
#이메일, 비밀번호가 유효한가? 에 대한 처리
각 텍스트필드의 값을 받고,
이메일의 경우 @와 .을 모두 입력했는지,
비밀번호의 경우의 값을 공란으로 두지 않고 입력했는지,
에 대한 판단이 필요하다.
이는 초기값은 필요없고, 이벤트를 방출하기만 하면 되므로 Observable의 형태로 선언할 수 있다.
var isValid: Observable<Bool> {
return Observable
.combineLatest(emailRelay, passwordRelay)
.map { email, password in
print("Email : \(email), Password : \(password)")
return !email.isEmpty && email.contains("@") && email.contains(".") && password.count > 0 && !password.isEmpty
}
}
#로그인이 성공했는가?
입력받은, 전달받은 이메일/비밀번호로 서버통신을 한 다음, 그 결과에 대한 처리를 하기 위해 로그인 성공 유무에 대한 이벤트를 받고, 다시 방출할 변수가 필요하다.
이는 초기값은 필요 없는 형태이므로 PublishSubject로 선언할 수 있다.
var isLoginSucceed = PublishSubject<LoginResponse>()
그리고 전달 받은 이메일, 비밀번호로 서버통신을 하는 코드를 작성하면 아래와 같이 뷰모델을 만들 수 있다.
실제로 서버 통신 코드는 APIManager로 따로 관리할 수 있다.
import Alamofire
import RxCocoa
import RxSwift
enum LoginError: Error {
case badRequest
case serverError
}
final class LoginViewModel {
let emailRelay = BehaviorRelay<String>(value: "이메일을 입력해주세요")
let passwordRelay = BehaviorRelay<String>(value: "비밀번호를 입력해주세요")
var isLoginSucceed = PublishSubject<LoginResponse>()
var isValid: Observable<Bool> {
return Observable
.combineLatest(emailRelay, passwordRelay)
.map { email, password in
print("Email : \(email), Password : \(password)")
return !email.isEmpty && email.contains("@") && email.contains(".") && password.count > 0 && !password.isEmpty
}
}
func requestLogin(_ loginRequest: LoginRequest) {
let url = ""
let headers : HTTPHeaders = ["Content-Type" : "application/json"]
let params: Parameters = ["email": loginRequest.email, "password" : loginRequest.password]
let request = AF.request(url,
method: .post,
parameters: params,
encoding: JSONEncoding.default,
headers: headers)
request.responseDecodable(of: LoginResponse.self) { [weak self] response in
guard let self = self else { return }
switch response.result {
case .success(let value):
guard let statusCode = response.response?.statusCode else { return }
if statusCode == 200 {
self.isLoginSucceed.onNext(value)
} else if statusCode == 400 {
self.isLoginSucceed.onError(LoginError.badRequest)
} else if statusCode == 500 {
self.isLoginSucceed.onError(LoginError.serverError)
}
case .failure(let error):
self.isLoginSucceed.onError(LoginError.badRequest)
print(error)
}
}
}
}
그리고 이제 뷰(= 컨트롤러)로 가서 코드를 작성하자.
여기서는 각 텍스트 필드에서 값을 받아서 뷰모델로 보내고
뷰모델의 유효성 검사에 대한 값에 따라서 UI를 업데이트 하고(= 이메일과 비밀번호를 조건에 맞게 작성했다면, 로그인 버튼이 활성화 될 것이다.)
서버 통신 이후의 값에 따라서 알림창을 띄워주면 된다.(= 로그인 버튼을 눌렀을 때, 서버 통신을 하고, 이의 결과에 따라서 다른 알림창을 띄워주면 된다.)
viewModel.emailRelay
.bind(to: emailTextField.rx.text)
.disposed(by: disposeBag)
viewModel.passwordRelay
.bind(to: passwordTextField.rx.text)
.disposed(by: disposeBag)
먼저, 사용자의 입력이 있기 전까지 초기값을 각 텍스트 필드의 text로 보내 일종의 placeholder 역할을 하도록 한다.
emailTextField.rx.text.orEmpty
.bind(to: viewModel.emailRelay)
.disposed(by: disposeBag)
passwordTextField.rx.text.orEmpty
.bind(to: viewModel.passwordRelay)
.disposed(by: disposeBag)
사용자가 값을 입력했다면, 이를 뷰모델의 relay로 보내서 유효성 검사를 할 수 있도록 한다.
let isValid = viewModel.isValid
.share()
isValid
.bind(to: loginButton.rx.isEnabled)
.disposed(by: disposeBag)
isValid
.map { $0 == true ? UIColor.systemPink : UIColor.systemGray4 }
.bind(to: loginButton.rx.backgroundColor)
.disposed(by: disposeBag)
유효성 검사를 한 뒤,
이메일과 비밀번호를 조건에 맞게 작성했다면,
1. 로그인 버튼이 활성화 되도록 한다.
2. 그리고 배경색도 변화하여 UI 업데이트를 한다.
-> 이 때, isValid는 Observable의 형태로 Stream 공유가 되지 않는다. 그렇기 때문에 share을 통해서 하나의 메모리에서 관리할 수 있도록 한다.
loginButton.rx.tap
.withUnretained(self)
.bind { vc, _ in
let request = LoginRequest(email: vc.viewModel.emailRelay.value, password: vc.viewModel.passwordRelay.value)
vc.viewModel.requestLogin(request)
}
.disposed(by: disposeBag)
버튼이 활성화 되어 탭하면
작성한 이메일과 비밀번호로 서버 통신을 할 수 있도록, 뷰모델의 통신 함수를 호출한다.
viewModel.isLoginSucceed
.withUnretained(self)
.subscribe { vc, response in
vc.presentAlert("로그인 성공", "\(response.name)님 환영합니다.")
} onError: { [weak self] error in
guard let self = self else { return }
self.presentAlert("로그인 실패", "\(error)")
} onCompleted: {
print("완료")
} onDisposed: {
print("버려")
}
.disposed(by: disposeBag)
그리고 서버 통신 이후의 결과에 대한 이벤트를 구독하여
각 경우에 따라서 다른 처리를 한다.
이렇게 하면 .. !
위와 같이 동작하는 것을 볼 수 있다.
전체 코드를 보고 싶다면 .. 아래의 주소에서 Login 폴더를 확인하면 된다.
https://github.com/pcsoyeon/SSAC-SAEBBING/tree/main/WhatsNew/WhatsNew/Presentation/Login
'Swift > RxSwift' 카테고리의 다른 글
[🔥Rx뿌셔] Subject - Publish Subject (0) | 2022.10.31 |
---|---|
[🔥Rx뿌셔] Hot Observable VS Cold Observable (0) | 2022.10.31 |
[🌱SeSAC] Observable VS Subject, Drive & Relay (6) | 2022.10.28 |
[🌱SeSAC] Observable/Observer, Subject, Relay (0) | 2022.10.26 |
[🔥Rx뿌셔] Subject VS Observable (0) | 2022.10.26 |