[iOS/UIKit] Reactive Programming - Custom Observable In UIkit

전성훈·2023년 11월 2일

iOS/UIKit

목록 보기
2/3
post-thumbnail

주제: Observable과 Subscribe 만들기


시작하기 앞서

  • Observable패턴은 주로 리액티브 프로그래밍에서 사용되며, 이는 데이터의 변화를 관찰하고 이에 반응하는 프로그래밍 방식입니다. 저는 RxSwift, Combine, MVVM 패턴을 공부하면서 Observable과 Subscribe를 직접 만들어보고자 했으며, 해당 내용에 대한 요약 정리입니다.
  • 리엑티브 프로그래밍은 애플리케이션의 상태가 변경될 때 자동으로 이를 감지하고 반응하는 프로그래밍 방식입니다. 이를 통해 코드가 더 선언적이고 이해하기 쉬워집니다.
  • Observable 패턴을 활용하면 데이터의 변화를 관찰하고 이에 따라 UI를 업데이트하거나 다른 로직을 수행해야 할 때 유용하며, 코드가 더 깔끔해지고 유지보수가 쉬워집니다.

구현 방법

Observable 패턴의 기본 구조 - Observable Class

ObservableEvent enum

  • Observable 객체가 관찰자에게 전송할 수 있는 이벤트의 종류를 나타냅니다.
  1. next(Value)
  2. error(Error)
  3. completed
enum ObservableEvent<Value> {
    case next(Value)
    case error(Error)
    case completed
}

Observer struct

  1. 이벤트 리스닝
    • Observer는 block을 통해 특정 이벤트가 발생했을 때 실행할 코드를 가지고 있습니다.
  2. 약한 참조
    • observer 프로퍼티는 weak로 선언되어 있어서 순환 참조를 방지합니다.
class Observable<Value> {
    /// - observer : 실제 관찰자 객체
    /// - block: 값이 변경될 때 실행될 클로저를 저장
    struct Observer<Value> {
        weak var observer: AnyObject?
        let block: (ObservableEvent<Value>) -> Void
    }
}

Observable class

  1. 상태 관리
    • 내부에 value 라는 프로퍼티를 가지고 있으며, 이 값의 변화를 추적합니다.
  2. 옵저버 관리
    • 내부에 observers 라는 배열을 통해 모든 관찰자(Observer)를 관리합니다.
  3. 이벤트 알림
    • 값이 변경되거나 특정 이벤트가 발생하면 모든 관찰자에게 이를 알립니다.
  • Observable Class의 메서드
  1. observe(on: observerBlock:)
    • 객체는 Observable 인스턴스의 observe(on: observerBlock:) 메서드를 호출하여 자신을 Observer로 등록합니다.
  2. notifyObservers(event:)
    • 모든 등록된 관찰자에게 특정 이벤트를 알립니다. 이 메서드는 내부 상태의 변화나 외부에서 발생한 이벤트에 반응하여 호출됩니다.
  3. remove(observers:)
    • 특정 관찰자를 제거합니다. 이는 관찰자가 더 이상 상태 변화에 반응하지 않아도 되는 경우에 사용됩니다.
  4. subscribe(on:disposeBag:onNext:onError:onCompleted:)
    • subscribe 메서드는 Observable 인스턴스에 관찰자를 추가하고, 이 관찰자가 어떻게 상태 변화에 반응할지를 정의하는 역할을 합니다.
class Observable<Value> {
    /// - observer : 실제 관찰자 객체
    /// - block: 값이 변경될 때 실행될 클로저를 저장
    struct Observer<Value> {
        weak var observer: AnyObject?
        let block: (ObservableEvent<Value>) -> Void
    }
    
    /// - 모든 observers를 저장하는 배열
    private var observers = [Observer<Value>]()
    
    /// - 실제 관찰되는 값
    /// - 값이 설정될 때마다 'didSet'에서 'notifyObservers' 메서드를 호출하여 모든 observer에게 알린다.
    var value: Value {
        didSet { notifyObservers(event: .next(value)) }
    }
    
    init(_ value: Value) {
		print("Observable Init")
		  
        self.value = value
    }
    
    deinit {
	    print("Observable Deinit")
	    
        observers.removeAll()
    }
    
    /// observer를 추가한다. 추가될 때 현재 값에 대한 알림도 바로 전달한다.
    @discardableResult
    fileprivate func observe(
        on observer: AnyObject,
        observerBlock: @escaping (ObservableEvent<Value>) -> Void
    ) -> Observable<Value> {
        observers.append(Observer(observer: observer, block: observerBlock))
        observerBlock(.next(self.value))
        
        return self
    }
    
    /// 모든 observers에게 값을 알린다
    private func notifyObservers(event: ObservableEvent<Value>) {
        for observer in observers {
            observer.block(event)
        }
    }
    
    fileprivate func removeDisposable(for observer: AnyObject) -> () -> Void {
        return { [weak self] in
            self?.remove(observer: observer)
        }
    }
    
    /// 특정 observer를 제거한다
    private func remove(observer: AnyObject) {
        observers = observers.filter { $0.observer !== observer }
    }
	
	@discardableResult
    func subscribe(
        on observer: AnyObject,
        disposeBag: DisposeBag,
        onNext: ((Value) -> Void)? = nil,
        onError: ((Error) -> Void)? = nil,
        onCompleted: (() -> Void)? = nil
    ) -> Subscription<Value> {
        return Subscription(
            observable: self,
            observer: observer,
            disposeBag: disposeBag,
            onNext: onNext,
            onError: onError,
            onCompleted: onCompleted
        )
    }
}
  1. Observer 등록
    • 객체는 Observable 인스턴스의 observe(on:observerBlock:) 메서드를 호출하여 자신을 Observer로 등록합니다.
  2. 상태 변화 감지
    • Observable 인스턴스의 value 프로퍼티가 변경되면, didSet을 통해 notifyObservers(event:) 메서드가 호출됩니다.
  3. 이벤트 알림
    • notifyObservers(event:) 메서드는 모든 등록된 Observer에게 변경된 값을 알립니다.
  4. Observer의 반응
    • 각 Observer는 block을 통해 정의된 방식으로 변경된 값에 반응합니다.

설계시 고려사항

  1. 메모리 관리
    • Observer 패턴을 사용할 때 순환 참조가 발생하지 않도록 weak 키워드를 사용해야 합니다.
  2. 동시성 관리
    • 여러 스레드에서 Observable 객체에 접근할 경우, 동시성 문제가 발생할 수 있으므로 이에 대해 주의해서 사용해야 합니다.
  3. 오류 처리
    • ObservableEvent enum error(Error) 케이스를 통해 오류를 처리해야 합니다.
  4. 리소스 해제
    • Observer가 더 이상 필요하지 않을 때, 적절히 리소스를 해제해야 합니다. DisposeBag 클래스를 사용하여 이를 관리할 수 있습니다.

Subscription

Subscription class

  • Subscription class는 Observable에 관찰자(observer)를 등록하고, 이벤트가 발생했을 때 적절한 액션을 취하기 위한 메서드를 제공합니다.
  • Subscription class의 주요 요소 및 메서드
  1. observable
    • Subscription이 관찰하는 Observable 객체입니다.
  2. observer
    • 관찰자 객체로, 이 객체는 Observable의 변화를 관찰하고 반응합니다.
  3. disposeBag
    • 생성된 모든 Subscription 객체를 저장하고, 필요할 경우 일괄적으로 모두 해제 (dispose)할 수 있습니다.
  4. onNext
    • Observable에서 .next 이벤트가 발생했을 때 실행할 클로저를 등록합니다.
  5. onError
    • Observable 에서 .error 이벤트가 발생했을 때 실행할 클로저를 등록합니다.
  6. onCompleted
    • Observable 에서 .completed 이벤트가 발생했을 때 실행할 클로저를 등록합니다.
  • Subscription 객체는 Observablesubscribe 메서드를 통해 생성됩니다. 생성된 Subscription 객체에서 ObserverObservable에 저장하고, disposeBagdispose시 발동 할 클로저 함수를 저장하고 deinit 됩니다.
/* Observable class와 같은 swift 파일*/
final class Subscription<Value> {
    
    private let observable: Observable<Value>
    private weak var observer: AnyObject?
    private let disposeBag: DisposeBag

    init(
           observable: Observable<Value>,
           observer: AnyObject,
           disposeBag: DisposeBag,
           onNext: ((Value) -> Void)? = nil,
           onError: ((Error) -> Void)? = nil,
           onCompleted: (() -> Void)? = nil
    ) {
        self.observable = observable
        self.observer = observer
        self.disposeBag = disposeBag
        
        if let onNext = onNext {
            self.onNext(onNext)
        }
        
        if let onError = onError {
            self.onError(onError)
        }
        
        if let onCompleted = onCompleted {
            self.onCompleted(onCompleted)
        }
        
        print("Subscription Init")
    }
    
    deinit {
        print("Subscription Deinit")
    }
    
    @discardableResult
    func onNext(_ onNext: @escaping (Value) -> Void) -> Self {
        guard let observer = observer else { return self }
        
        let disposable = observable.observe(on: observer) { event in
            if case .next(let value) = event {
                onNext(value)
            }
        }.removeDisposable(for: observer)
        
        disposeBag.add(disposable)
        return self
    }
    
    @discardableResult
     func onError(_ onError: @escaping (Error) -> Void) -> Self {
         guard let observer = observer else { return self }

         let disposable = observable.observe(on: observer) { event in
             if case .error(let error) = event {
                 onError(error)
             }
         }.removeDisposable(for: observer)
         
         disposeBag.add(disposable)
         return self
     }
     
     @discardableResult
     func onCompleted(_ onCompleted: @escaping () -> Void) -> Self {
         guard let observer = observer else { return self }

         let disposable = observable.observe(on: observer) { event in
             if case .completed = event {
                 onCompleted()
             }
         }.removeDisposable(for: observer)

         disposeBag.add(disposable)
         return self
     }
}

DisposeBag

Disposeables Class

  • Disposebles 클래스는 단일 또는 여러 개의 Disposable을 관리하는 역할을 합니다. Disposable은 클로저를 통해 정의되며, 이 클로저는 특정 리소스 또는 작업을 폐기하는 데 사용됩니다.
  • 해당 클래스를 private해서 동일 위치에있는 DisposeBag 클래스를 제외하곤 다른 곳에서 활용할 수 없게 해줍니다.
  1. disposables
    • [() -> Void] 타입의 배열로, 폐기할 수 있는 리소스의 목록을 나타냅니다. 폐기 작업을 수행하는 클로저를 포함하고 있습니다.
  2. add()
    • 새로운 폐기 가능한 리소스를 목록에 추가하는 메서드입니다. 인자로 클로저를 받아 disposables 배열에 추가합니다.
  3. dispose()
    • 목록에 있는 모든 폐기 가능한 리소스를 폐기하고, disposables 배열을 비웁니다. 각 클로저를 호출하여 리소스를 해제합니다.
    • 즉, disposables.forEach를 통해 ObservableremoveDisposable() 함수를 호출 후 Observable 내부 Observer를 제거 후 disposables 배열을 비웁니다.
private final class Disposables {
    private var disposables: [() -> Void] = [] {
        didSet {
            print(disposables)
        }
    }
    
    func add(_ disposable: @escaping () -> Void) {
        disposables.append(disposable)
    }
    
    func dispose() {
        disposables.forEach { $0() }
        disposables.removeAll()
    }
}

DisposeBag Class

  • DisposeBag 클래스는 Observer의 클로저 함수들을 관리하고, 이들을 일괄적으로 해제할 수 있는 기능을 제공합니다 .
  1. disposables
    • Disposables 타입의 인스턴스로, 폐기 가능한 리소스를 실제로 관리합니다.
  2. add()
    • 새로운 폐기 가능한 리소스를 Disposables 인스턴스에 추가하는 메서드입니다.
  3. clear()
    • Disposeables 인스턴스의 dispose() 메서드를 호출하여 모든 리소스를 폐기하고 비웁니다. 즉, 수동으로 비우게 될 때 활용하는 메서드 입니다.
  • DisposeBagdeinit 메서드를 통해 자동으로 dispose 메서드를 호출하므로, DisposeBag이 해제되면 관련된 모든 Observer를 자동으로 해제됩니다. 이를 통해 메모리 누수를 방지하고, 코드의 안정성을 높일 수 있습니다.
final class DisposeBag {
    private weak var disposables: Disposables?
    
    func add(_ disposable: @escaping () -> Void) {
        disposables?.add(disposable)
    }
    
    func clear() {
        disposables?.dispose()
    }
    
    deinit {
        disposables?.dispose()
    }
}

사용 예시

  • 간단한 MVVM 패턴을 활용하여, Observable, Subscribe를 사용해보도록 하겠습니다.
  • 해당 코드는 ViewModel에서 만든 Observable를 구독하여 textField에 text를 입력할 때 마다 해당 label에 값을 반영하도록 하였습니다.
  • 또 disposeBag 버튼을 만들어서 dispose를 수동으로 해줬을 때 정상적으로 구독이 해제되는지도 확인할 수 있게 하였습니다.

ViewModel

import Foundation

protocol MainViewModelInput {
    func viewDidLoad()
    func textDidChange(text: String?)
}

protocol MainViewModelOutput {
    var textFieldText: Observable<String?> { get }
}

typealias MainViewModelProtocol = MainViewModelInput & MainViewModelOutput

final class MainViewModel: MainViewModelProtocol {
    
    // MARK: Output
    
    let textFieldText: Observable<String?> = Observable(nil)
}

// MARK: Input
extension MainViewModel {
    func viewDidLoad() {
        
    }
    
    func textDidChange(text: String?) {
        textFieldText.value = text
    }
}

ViewController

import UIKit

final class MainViewController: UIViewController, Alertable {
    
    private var viewModel: MainViewModelProtocol? = MainViewModel()
    
    private let textField = UITextField()
    private let label = UILabel()
    private let button = UIButton()
    
    private var disposeBag = DisposeBag()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        setupUI()
        bindViewModel()
    }
    
    private func setupUI() {
        self.view.backgroundColor = .white
        textField.borderStyle = .roundedRect
        textField.addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged)
        label.numberOfLines = 0
        
        let stackView = UIStackView(arrangedSubviews: [textField, label])
        stackView.axis = .vertical
        stackView.spacing = 20
        stackView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(stackView)
        
        NSLayoutConstraint.activate([
            stackView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
            stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20)
        ])
        
        view.addSubview(button)
        button.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            button.centerXAnchor.constraint(equalTo: self.view.centerXAnchor),
            button.bottomAnchor.constraint(equalTo: self.view.bottomAnchor, constant: -16)
        ])
        button.setTitle("disposeBag", for: .normal)
        button.setTitleColor(.black, for: .normal)
        button.addTarget(self, action: #selector(cancelSubscribe), for: .touchUpInside)
    }
    
    // MARK: Input
    @objc private func textFieldDidChange() {
        viewModel?.textDidChange(text: textField.text)
    }
    
    @objc private func cancelSubscribe() {
        button.isSelected.toggle()
        if button.isSelected {
            disposeBag.clear()
//            viewModel = nil
        } else {
//            viewModel = MainViewModel()
            bindViewModel()
            
        }

    }
    
    // MARK: Output
    private func bindViewModel() {
        viewModel?.textFieldText
            .subscribe(on: self, disposeBag: disposeBag)
            .onNext { [weak self] text in
                if let text = text {
                    self?.label.text = text + "\n" + text
                }
            }
        
        viewModel?.textFieldText
            .subscribe(on: self, disposeBag: disposeBag)
            .onNext { text in
                if let text = text {
                    print(text)
                }
            }
    }
}

처음 앱을 실행 했을 때

  • 결과를 보면, Observableinit되고 Subsciptioninit되면서 dispoableobserver가 추가가 되고 Subscription이 참조된게 없으므로 바로 deinit되는 것을 확인 할 수 있다.
  • 또한 하나의 Observable의 두 개의 Subscription을 반영했기 때문에 disposables 배열도 두 개가 되는 것을 확인 할 수 있다.

disposeBag 버튼 클릭 시 (viewmodel 초기화 주석 처리 했을 때)

  • 결과를 확인해 보면disposables 내부 배열은 모두 지워지며, Observable 내부의 있는 Observer도 지워져서 기능이 정상작동 하지 않는것을 확인할 수 있다.

disposeBag 버튼 클릭 시 (viewmodel 초기화 주석 처리 취소했을 때)

  • 결과를 확인해 보면 disposables 내부 배열도 지워지면서, ViewModelnil 처리 하였기 때문에 Observable 또한 Deinit 처리 되는 것을 확인할 수 있다.

출처(참고문헌)

원본 코드

제가 학습한 내용을 요약하여 정리한 것입니다. 내용에 오류가 있을 수 있으며, 어떠한 피드백도 감사히 받겠습니다.

감사합니다.

1개의 댓글

comment-user-thumbnail
2023년 11월 2일

포스팅 후 코드를 확인해 보니깐 onNext를 제외한 나머지 onCompleted, onError는 기능을 제대로 할 수 없는 코드네요 ㅎㅎ..감안해주세요~

답글 달기