본문 바로가기
iOS/Swift

[iOS] Data Binding in MVVM

by 안녕주 2022. 2. 19.

MVVM에서 가장 중요한 개념은 Data Binding입니다. 이번에는 Data Binding에 대해 알아보겠습니다! 참고로 Data Binding은 MVVM 패턴 뿐만 아니라 다른패턴에서도 사용될 수 있습니다...

 

Data Binding in SwiftUI

MVVM에서 View는 ViewModel의 데이터 변경을 알아채야하는데, 그 때 필요한 것이 Data Binding이라고 했습니다. MVVM 관련된 포스팅은 여기 를 참고하세요

Data Binding은 View와 ViewModel 사이를 연결하는 프로세스입니다. 데이터를 제공하는 자와 그 데이터를 사용하는 자를 연결시켜 동기화되도록 하는 방식이다.

 

SwiftUI에서는 View가 VM을 소유하고 있고, VM은 View에 의해 Observed되고 있다. VM의 프로퍼티에 변화가 생기면, 그 데이터의 변화를 유저들에게 보여주기 위해 View가 다시 그려집니다.

 

[Model]

Struct Person {
	let name: String
	var age: Int
}

[ViewModel]

class PersonViewModel: ObservableObject {
  @Published var person: Person
  
  init() {
    self.person = Person(name: "Lee", age: 23)
  }
  
  func addAge() {
    self.person.age += 1
  }
}

여기서 ViewModel은 데이터 바인딩을 위해 ObservableObject를 채택한게 끝이다. person은 Published로 선언되었기에, View에서 이 ViewModel의 person 프로퍼티가 변경되면 변경을 감지해서 새로운 View가 그려진다.

또한 init()을 보면 VM은 Model을 소유하고 있고, 데이터를 만들고 그 데이터를 변경시키는 앱의 핵심 로직을 V이 가지고 있다는것을 알 수 있다.

VM은 View에 대한 어떤 정보고 가지고 있지 않으며, 비즈니스 로직만 잘 분리했다.

 

[View]

struct ContentView: View {
  @ObservedObject var viewModel = PersonViewModel()
  
   var body: some View {
     VStack {
       Text("\\(viewModel.person.name)'s Age is \\(viewModel.person.age)")
       Button(action: {viewModel.addAge()}) {
         Text("Add an year")
       }
     }
   }
}

우선 View는 화면구성과 관련된 계층이다.

그리고 View는 viewModel에 ObservedObject를 달아줌으로서, ViewModel을 소유하고 있음을 알 수 있다. 해당 View는 PersonViewModel과 바인딩 되어있음을 알 수 있다.

 


Data Binding in UIKit

1. Observables

가장 많이 사용되는 방법입니다. Bond와 같은 라이브러리를 사용하면 쉽게 바인딩할 수있지만 Observable이라는 이름의 Helper class를 생성하겠습니다. 이 클래스는 우리가 observe하기 원하는 값으로 초기화되고, binding 역할과 값을 얻어오는 역할을 하는 bind함수를 가지고 있습니다. (listener는 값이 변경될 때 마다 호출되는 클로저 입니다.)

 

[Observable Class → Model?]

class Observable<T> {

    var value: T {
        didSet {
            listener?(value)
        }
    }

    private var listener: ((T) -> Void)?

    init(_ value: T) {
        self.value = value
    }

    func bind(_ closure: @escaping (T) -> Void) {
        closure(value)
        listener = closure
    }
}

 

 

[ViewModel]

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(BaseNetworkManager().getErrorMessage(response: result))
            case .failure:
                self.setError(BaseNetworkManager().getErrorMessage(response: result))
            }
        }
    }

    func setError(_ message: String) {
        self.errorMessage = Observable(message)
        self.error = Observable(true)
    }

}

(1)은 프로토콜에서 어떻게 우리가 employee 배열을 담는 Observable을 선언하는지 보여줍니다.

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

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

 

[View Controller → View]

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

ViewController로 와서 viewDidLoad에 bind를 수행하면 끝!

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

 

 

2. Event Bus / Notification Center

Event Bus는 안드로이드에서 많이 사용되고, iOS에서는 Notification Center를 사용합니다.

 

[1] Model : 모든 subscriber에게 EventBus로 보낼 이벤트를 생성합니다. 해당 이벤트는 우리가 전달하고자 하는 것들을 담고 있습니다. EmployeesEvent를 보면, Bool값의 error, String타입의 errorMessage, employees를 담고 있습니다.

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] ViewModel : EventBus를 이용해서 ViewModel로부터 이벤트를 발생시킵니다.

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

 

[3] View : VC에서 이벤트를 구독합니다. 아래의 함수는 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가 호출될 때 마다 실행되게 됩니다.

 

 

3. FRP Technique ( RxCocoa / RxSwift )

[ViewModel]

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(BaseNetworkManager().getErrorMessage(response: result))
            case .failure:
                self.setError(BaseNetworkManager().getErrorMessage(response: result))
            }
        }
    }

    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]
    }
}

 

[ViewController → View]

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)
    	}

    //... other delegate methods go here
}

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

 

 

4. Combine

Swift5.1부터 생긴 Combine 프레임워크는 비동기 시그널을 채널링하고 처리할 수 있는 통합된 publish-and-subscribe API를 제공합니다.

 

[1] ViewModel

publisher를 만듭니다. ViewMdoel에 Combine import를 하고 ObservableObject를 상속받습니다. employees 배열은 @Published로 감싸줍니다. 이제 그 publisher(published로 감싸진 프로퍼티)는 프로퍼티가 변결될 때마다 현재 값을 방출하게 됩니다.

import Foundation
import Alamofire
import Combine

class CombineViewModel: ObservableObject {

    var apiManager: APIManager?
    @Published var employees: [Employee] = [] //1
    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 = response.data
                }
            case .failure:
                print("Failure")
            }
        }
    }

}

 

[2] ViewController → View

publisher에 subscriber를 붙입니다. bindViewModel에서 sink라는 Combine키워드를 사용해서 $employees를 subscribe합니다. 그러면 published property가 변경될 때마다 view가 업데이트 될 것입니다. 그리고 우리는 그 subscriber를 인스턴스 프로퍼티에 저장하여 유지 및 자동해제가 되도록 하거나 자체적으로 취소되도록 합니다.

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() {
        	viewModel.$employees.sink { [weak self] _ in
            		self?.showTableView()
        	}.store(in: &cancellables)
    	}
    
  	//... Other delegate methods
  
}

https://medium.com/hcleedev/ios-swiftui의-mvvm-패턴과-mvc와의-비교-8662c96353cc

https://beenii.tistory.com/124

https://fitzafful.medium.com/data-binding-in-mvvm-on-ios-714eb15e3913

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

[iOS] SOLID 원칙 in Swift  (0) 2022.02.19
[iOS] 의존성 주입 DI  (2) 2022.02.19
[iOS] MVVM 패턴  (0) 2022.02.18
[iOS] Swift의 5가지 접근 타입  (0) 2022.02.17
[iOS] MVC 패턴  (0) 2022.02.16

댓글