본문 바로가기
iOS/iOS

Data Binding in MVVM on iOS

by 헤콩 2021. 1. 7.
반응형
본 게시물은 Data Binding in MVVM on iOS 라는 Medium 게시물을 기반으로 작성하였습니다. 다소 흥미로웠던 내용이라 제 블로그에 메모 및 정리해놓으면 좋을 것 같아 작성을 하게되었습니다. 영어로 된 본문을 제가 읽히는대로 번역을 해서 의역이 많고, 최대한 적절한 단어선택을 하긴 했으나 그래도 이해가 가지 않거나 잘못된 내용이 있다면 댓글로 알려주세요! 바로 수정하겠습니다 :)

> Technique1. Observables

> Technique2. Event Bus / Notification Center

> Technique3. FRP Technique (RxCocoa / RxSwift)

> Technique4. Combine

 

 

 

 

우리는 새로운 프로젝트를 할수록 View Controller의 역할이 점점 거대해지고 있는 것을 느낄 수 있습니다. 그리고 정말 많이 복잡하고 얽혀있는 코드들을 볼 수 있습니다.

이것이 바로 MVC(Model-View-Controller)가 Messive-View-Controller로 불리우는 이유입니다. 이런 코드를 재설계하고 리팩토링하는 데에 있어서, 우리는 VIPER, MVVM, VIP 등의 디자인 패턴을 배우게 됩니다.

 

그리고 우리는 MVVM이 지금 우리에게 필요한 디자인 패턴이라고 생각하고 배우게 될 것입니다. 그리고 MVVM 패턴을 배우는 것이 재미있을 것입니다. 하지만 아래와 같은 MVVM의 기본 규칙을 발견하게 되면 생각이 달라지죠 😳

 

1. View는 View Model을 가지고, View Model은 Model을 가집니다.

2. View Model은 입출력을 처리하고 UI가 요구하는 로직을 처리하는 역할만 가집니다.

3. View Model은 UI를 수정할 수 없습니다.

 

즉, View Model과 View Controller는 서로에게 데이터의 변경을 알려줄 수 있는 방법이 필요합니다.

그리고 그것이 바로 Data Binding입니다. 

 

Data Binding은 단순히 앱의 UI (View Controller)와 UI가 표시하는 데이터 (Model 자체가 아니라 View Model의 데이터) 사이를 연결을 설정하는 프로세스입니다. Data Binding을 하는 다양한 방법이 있는데, 우리는 그걸 함께 살펴볼 것입니다.

하지만 기억해주세요. Data Binding은 MVVM 뿐만 아니라 다른 패턴에서도 사용될 수 있습니다.

 

 

Technique 1: Observables

지금 보는 방법이 가장 쉽고 가장 널리 사용되는 방법입니다. Bond같은 라이브러리를 사용하면 쉽게 바인딩 할 수 있지만, Observable이라는 자체 Helper Class를 만들 것입니다. 이 클래스는 우리가 observe하길 원하는 값으로 초기화되고, 우리에게 binding 역할과 값을 얻어오는 역할을 하는 bind라는 함수를 제공할 것입니다.

아래 코드에서 listener는 값이 변할 때마다 호출되는 클로저입니다.

 

class Observable<T> {
    private var listener: ((T) -> Void)?
    
    var value: T {
        didSet {
            listener?(value)
        }
    }
    
    init(_ value: T) {
        self.value = value
    }
    
    func bind(_ closure: @escaping (T) -> Void) {
        closure(value)
        listener = closure
    }
}

 

 

 

그럼 이제 ViewModel을 살펴보겠습니다. 아래 코드는 이전에 우리가 번호로 나열했던 MVVM의 기본 규칙을 중점적으로 다뤄보기 전에, ViewModel이 채택할 프로토콜을 먼저 구현한 다음 APIManager 클래스에서 데이터(Employee)를 가져오는 코드입니다.

 

import Foundation
import Alamofire

protocol ObservableViewModelProtocol {
    func fetchEmployees()
    func setError(_ message: String)
    var employees: Observable<[Employee]> { get set } // (1)
    var errorMessage: Observable<String?> { get set }
    var error: Observable<Bool> { get set }
}

class ObservableViewModel: ObservableViewModelProtocol {
    var errorMessage: Observable<String?> = Observable(nil)
    var error: Observable<Bool> = Observable(false)
    
    var apiManager: APIManager?
    var employees: Observable<[Employee]> = Observable([]) // (2)
    
    init(manager: APIManager = APIManager()) {
        self.apiManager = manager
    }
    
    func setAPIManager(manager: APIManager) {
        self.apiManager = manager
    }
    
    func fetchEmployees() {
        self.apiManager!.getEmployees { (result: DataResponse<EmployeesResponse, AFError>) in
            switch result.result {
            case .success(let response):
                if response.status == "success" {
                    self.employees = Observable(response.data) // (3)
                    return
                }
                self.setError("에러 메시지")
            }
            case .failure:
                self.setError("에러 메시지")
        }
    }
    
    func setError(_ message: String) {
        self.errorMessage = Observable(message)
        self.error = Observable(true)
    }
}

 

 

(1) 은 Protocol에서 어떻게 우리가 Employee 배열을 담는 Observable을 선언하는지를 보여줍니다.

(2) 는 어떻게 우리가 ViewModel에서 (1) 을 구현하는지 보여줍니다.

마지막으로 (3) 은 Observable에 데이터를 세팅하거나 추가하는 것을 보여줍니다.

 

그럼 우리는 이제 View Controller의 viewDidLoad에서 bind를 수행하면 됩니다!

 

/* viewDidLoad in View Controller */
viewModel.employees.bind { _ in
    self.showTableView()
}

 

짜잔~❗️

이제 언제든지 employees 가 변경될 때마다 self.showTableView( )는 View Controller에서 호출되게 됩니다.

 

 

Technique 2: Event Bus / Notification Center

Event Bus는 사실 안드로이드에서 더 많이 사용됩니다. iOS에서는 NotificationCenter로 잘 구성되어 있습니다.

 

(1) 모든 subscriber에게 EventBus로 보낼 이벤트를 생성합니다. 이 이벤트는 일반적으로 우리가 전달하고자 하는 내용을 포함하고 있습니다. 그렇기 때문에 EmployeesEvent는 employees와 Boolean 타입인 error 값, 그리고 String 타입인 errorMessage 값을 가집니다.

 

import Foundation

class EmployeesEvent: NSObject {
    var error: Bool
    var errorMessage: String?
    var employees: [Employee]?
    
    init(error: Bool, errorMessage: String? = nil, employees: [Employee]? = nil) {
        self.error = error
        self.errorMessage = errorMessage
        self.employees = employees
    }
}

 

 

(2) EventBus를 이용해서 ViewModel 로부터 이벤트를 발생시킵니다.

 

func callEvent() {
    EventBus.post(
        "fetchEmployees",
        sender: EmployeesEvent(error: error, errorMessage: errorMessage, employees: employees)
    )
}

 

 

(3) View Controller에서 이벤트를 구독합니다. 따라서 setupEventBusSubscriber는 viewDidLoad에서 호출됩니다.

 

func setupEventBusSubscriber() {
    _ = EventBus.onMainThread(self, name: "fetchEmployees") { result in
        if let event = result!.object as? EmployeesEvent {
            if event.employees != nil {
                self.showTableView()
            } else if let message = event.errorMessage {
                self.showAlert(title: "Error", message: message)
            }
        }
    }
}

 

 

그럼 이제 위 코드에 나오는 EventBus 의 onMainThread 구현체는 ViewModel에 있는 callEvent가 호출될 때마다 실행되게 됩니다.

 

 

Technique 3: FRP Technique (RxCocoa / RxSwift)

이번에는 Functional / Reactive Programming 방식입니다. 우리는 RxCocoa나 RxSwift를 사용해서 구현할 수 있습니다. 두 방식 모두 확인해보고 싶다면 RayWenderlich가 분석해놓은 것을 보면 좋을 것 같습니다. 하지만 지금 우리는 그 두 가지 중에서 RxSwift를 사용해보겠습니다.

 

아래에 ViewModel 코드가 있습니다. 그리고 이것은 위에서 다뤘던 Technique 1: Observables과 비슷하게 보입니다.

 

import Foundation
import Alamofire
import RxSwift
import RxCocoa

class RxSwiftViewModel {
    private let disposeBag = DisposeBag()
    private let _employees = BehaviorRelay<[Employee]>(value: [])
    private let _error = BehaviorRelay<Bool>(value: false)
    private let _errorMessage = BehaviorRelay<String?>(value: nil)
    
    var employees: Driver<[Employee]> {
        return _employees.asDriver()
    }
    
    var hasError: Bool {
        return _error.value
    }
    
    var errorMessage: Driver<String?> {
        return _errorMessage.asDriver()
    }
    
    var numberOfEmployees: Int {
        return _employees.value.count
    }
    
    var apiManager: APIManager?
    
    init(manager: APIManager = APIManager()) {
        self.apiManager = manager
    }
    
    func setAPIManager(manager: APIManager) {
        self.apiManager = manager
    }
    
    func fetchEmployees() {
        self.apiManager!getEmployees { (result: DataResponse<EmployeesResponse, AFError>) in
            switch result.result {
            case .success(let response):
                if response.status == "success" {
                    self._error.accept(false)
                    self._errorMessage.accept(nil)
                    self._employees.accept(response.data)
                    return
                }
                self.setError("에러 메시지")
            case .failure:
                self.setError("에러 메시지")
            }
        }
    }
    
    func setError(_ message: String) {
        self._error.accept(true)
        self._errorMessage.accept(message)
    }
    
    func modelForIndex(at index: Int) -> Employee? {
        guard index < _employees.value.count else {
            return nil
        }
        return _employees.value[index]
    }
}

 

 

여기서 error 변수와 errorMessage 변수와 같이 employees 프로퍼티는 각각의 private 프로퍼티의 Driver를 반환합니다.

(RxSwift와 RxCocoa를 import해야 합니다.)

 

그리고 아래 코드는 View Controller입니다.

 

import RxSwift
import RxCocoa

class RxSwiftController: UIViewController {
    @IBOutlet weak var tableView: UITableView!
    @IBOutlet weak var emptyView: UIView!
    @IBOutlet weak var activityIndicator: UIActivityIndicatorView!
    let disposeBag = DisposeBag()
    
    lazy var viewModel: RxSwiftViewModel = {
        let viewModel = RxSwiftViewModel()
        return viewModel
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        showLoader()
        setupTableView()
        setupBindings()
    }
    
    func setupBindings() {
        viewModel.employees.drive(
            onNext: { [unowned self] _ in
                self.showTableView()
            }
        ).disposed(by: disposeBag)
        
        viewModel.errorMessage.drive(
            onNext: { _message in
                if let message = _message {
                    self.showAlert(title: "Error", message: message)
                }
            }
        ).disposed(by: disposeBag)
    }
}

 

 

여기 View Controller에서는 RxSwift 객체인 DisposeBag를 통해서 Observable들에 대한 참조를 해제합니다. 그리고 setupBindings 메서드는 View Model에 있는 employees 프로퍼티를 observe하게 합니다. 그리고 나면 employees 가 변경될 때마다 showTableView 메서드가 호출되어 리스트를 reload시킵니다.

 

 

Technique 4: Combine

Swift5.1 부터 생긴 Combine 프레임워크는 비동기 시그널을 캐치하고 처리하기 위한 통합된 publish-and-subscribe API를 제공합니다. 그럼 Combine을 사용한 데이터바인딩 처리를 한 번 살펴보겠습니다.

 

A. We make a publisher (in our View Model)

ViewModel 파일에 Combine을 import하고 ObservableObject를 상속받습니다. 그리고 우리가 observe할 emplyees 배열은 @Published로 감싸줍니다. 그럼 그 publisher(published로 감싸진 프로퍼티)는 프로퍼티가 변경될 때마다 현재 값을 방출하게 됩니다.

 

import Foundation
import Alamofire
import Combine

class CombineViewModel: ObservableObject {
    var apiManager: APIManager?
    @Published var employees: [Employee] = [] // A.
    
    //.. apiManager 설정
    
    func fetchEmployees() {
        self.apiManager!.getEmployees { response in
            switch response.result {
            case .success(let resp):
                if resp.status == "success" {
                    self.employees = response.data
                }
            case .failure:
                print("Failure")
            }
        }
    }
}

 

 

B. Then, attach a subscriber to the publisher (in our View Controller)

아래 코드의 bindViewModel( )에서 우리는 sink라는 Combine 키워드를 사용해서 $employees를 subscribe합니다. 그리고 나면 그 published property가 변경될 때마다 view가 업데이트될 것입니다.

 

import UIKit
import Combine

class CombineController: UIViewController {
    @IBOutlet weak var tableView: UITableView!
    @IBOutlet weak var emptyView: UIView!
    @IBOutlet weak var activityIndicator: UIActivityIndicatorView!
    
    lazy var viewModel: CombineViewModel = {
        let viewModel = CombineViewModel()
        return viewModel
    }()
    
    private var cancellables: Set<AnyCancellable> = []
    
    override func viewDidLoad() {
        super.viewDidLoad()
        showLoader()
        setupTableView()
        bindViewModel()
    }
    
    private func bindViewModel { // B.
        viewModel.$employees.sink { [weak self] _ in
            self?.showTableView()
        }.store(in: &cancellables)
    }
    
    //.. other delegate methods
}

 

그리고 우리는 그 subscriber를 인스턴스 프로퍼티에 저장하여 유지 및 자동해제가 되도록 하거나 자체적으로 취소되도록 합니다.

 

 

 

 


여기서 Cancellableactivityaction의 취소를 지원하는 프로토콜로, cancel( )을 호출하면 할당된 모든 리소스가 해제되도록 합니다. 그리고 이걸 활용해서 타이머, 네트워크 액세스, 디스크 IO와 같은 side effects를 막는다고 합니다...❓

뭐.. 이렇게 보니 RxSwift에서 DisposeBag를 활용하는 거랑 비슷한 것 같은데 나중에 Combine을 공부하면서 알아봐야겠어요!

 

반응형

'iOS > iOS' 카테고리의 다른 글

키체인 (Key Chain)  (0) 2021.04.13
GCD (Grand Central Dispatch)  (0) 2021.03.23
XCTest tips and tricks that can level up your Swift testing  (0) 2021.03.03
Frame과 Bounds  (0) 2021.02.26
ARC (Automatic Reference Counting)  (0) 2020.12.22

댓글