[새싹 iOS] 16주차_RxSwift dispose

임승섭·2023년 11월 3일
1

새싹 iOS

목록 보기
30/45
post-thumbnail
  • Subscribe 중인 Stream을 메모리에서 해제(리소스 정리)하기 위해서는 해당 Stream을 dispose하는 과정이 필요하다.

  • 어느 상황에서 dispose가 실행될 수 있는지 정리해 보았다


1. 반환값 타입 : Disposable


  • 일반적으로 사용하는 subscribe 함수는 반환값이 존재한다

    let textArray = BehaviorSubject(value: ["Hue", "Jack", "Koko", "Bran"])
    
    // 구독하기 -> 반환값의 타입은? : Disposable
    textArray.subscribe(with: self) { owner , value in
        print("onNext - \(value)")
    } onError: { owner , error in
        print("onError - \(error)")
    } onCompleted: { owner in
        print("onCompleted")
    } onDisposed: { owner in
        print("onDisposed")
    }
  • 그래서 이렇게만 쓰면 반환값을 사용하지 않았다는 노란색 warning이 뜬다
  • 반환값의 타입은 Disposable 이라는 프로토콜인 걸 확인할 수 있다
  • 어쨌든 이 반환값을 사용해야 하고, 그래서 일반적으로 disposeBag에 담는 코드를 뒤에 붙여준다

    let textArray = BehaviorSubject(value: ["Hue", "Jack", "Koko", "Bran"])
    
    // 구독하기 -> 반환값의 타입은? : Disposable
    textArray.subscribe(with: self) { owner , value in
        print("onNext - \(value)")
    } onError: { owner , error in
        print("onError - \(error)")
    } onCompleted: { owner in
        print("onCompleted")
    } onDisposed: { owner in
        print("onDisposed")
    }
    .disposed(by: disposeBag)

2. Observable과 Subject 차이 (dispose 관점)


  • Subject와 Observable의 정의를 배울 때
    Subject는 Observable과 Observer의 역할을 모두 수행한다 라고 배웠다
  • 그럼 dispose되는 시점에 어느 차이가 있는지 확인해보자

    
    let textArray = BehaviorSubject(value: ["Hue", "Jack", "Koko", "Bran"])
    let textArray = Observable.from(["Hue", "Jack", "Koko", "Bran"])
    
    /* subscribe 코드는 위와 동일하다 */

  • Observable로 선언한 경우, onNext로 방출이 끝나면 그 즉시 onCompleted가 실행되고, onDisposed까지 실행되면서 dispose가 완료되었음을 확인할 수 있다
  • 하지만 Subject로 선언한 경우, onNext 실행 이후 별다른 코드가 보이지 않는다
  • 단순하게 정의에서 차이점을 생각하면,
    Observable은 데이터를 방출한 순간, 역할을 다 했다. 하지만 SubjectObserver의 역할, 즉 데이터를 전달받을 수도 있기 때문에 아직 역할이 남았다고 표현할 수 있다.
  • 따라서 SubjectonCompleted가 실행되지 않고, dispose도 역시 실행되지 않는다

3. dispose 실행 (onError, onCompletd)


  • dispose 는 리소스가 정리됨을 의미하고, 메모리가 해제됨을 의미한다.
    즉, 더 이상 할 일이 없을 때 dispose가 실행된다
  • 2번에서 유추할 수 있는 점은, 아마 onError 또는onCompleted가 실행된다면 그 즉시 dispose가 실행될 것이다
  • onCompleted 에 대해서는 2번에서 확인했으므로, onError를 실행시켜보자

  • onError 실행시키는 것도 생각보다 간단하진 않다

    enum JackError: Error {	// Error 프로토콜의 열거형
        case invalid
    }
    
    textArray.onNext(["hihi"])
    textArray.onNext(["ho"])
    
    textArray.onError(JackError.invalid)	// Error 이벤트 전달
    
    textArray.onNext(["a", "b", "c"])		// 여긴 전달이 되지 않을 것이다
    textArray.onNext(["d", "e", "f"])

  • onError 이벤트가 실행된 순간, dispose가 실행되는 것을 확인할 수 있다

  • 당연히 dispose 된 이후에 onNext로 전달한 이벤트는 받을 수 없다


4. 직접 dispose (Disposable 프로토콜)


  • 여태까지 정리한 내용은
    1. subscribe 이후에는 disposeBag 이라는 곳에 Stream을 담아둔다
    2. onError 또는 onCompleted가 실행되면 알아서 dispose가 실행된다
  • 근데 onErroronCompleted가 실행되지 않았더라도 내 맘대로 dispose시키고 싶을 수도 있다. 즉, 내가 원하는 시점dispose를 실행시키고 싶다
  • 코드를 살짝 변형시켜서, subscribe 한 Stream 자체를 변수에 담는다
    let textArrayValue = textArray
                            .subscribe(with: self) { owner , value in
                                print("next - \(value)")
                            } onError: { owner , error in
                                print("error - \(error)")
                            } onCompleted: { owner in
                                print("completed")
                            } onDisposed: { owner in
                                print("disposed")
                            }
  • 요렇게 코드를 작성하면, 맨 처음 1번에서 목격한 노란색 워닝이 뜨지 않는다
    (반환값을 상수에 받았으니까)
  • 1번에서 확인했듯이, 상수의 타입은 Disposable 이다
    • Disposable 프로토콜의 정의를 타고 들어가면 dispose 메서드가 있고, 이게 내가 해야 실행시켜야 하는 메서드이다.
  • 즉, 이제 원하는 시점에 메서드를 실행시키기만 하면 된다
    DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
        textArrayValue.dispose()    // "dispose" : 직접적으로 리소스를 정리함
    }

5. disposeBag의 메커니즘


  • 여태까지 정리한 내용은, Stream을 dispose 하기 위해서
    1. onError 또는 onCompleted 가 실행되거나
    2. Stream을 상수에 저장해서 직접 메서드를 실행해야 한다
  • 그럼 여태까지 Rx를 배우면서 항상 당연하게 적었던 .disposed(by: disposeBag) 은 뭐냐
  • 위의 두 케이스에 있지 않기 때문에 여태까지 작성한 Stream들은 모두 메모리에 남아있고, 꾸준히 메모리 누수를 발생시키고 있었던 것일까?
  • 는 당연히 아니고, disposBag을 통해 dispose 메서드가 실행되고 있었다

  • DisposeBag 클래스의 정의를 타고 들어가보자
    (설명에 필요하지 않은 내용은 지웠다)

    public final class DisposeBag: DisposeBase {
    
        // state
        private var disposables = [Disposable]()
    
        /// This is internal on purpose, take a look at `CompositeDisposable` instead.
            private func dispose() {
            let oldDisposables = self._dispose()
    
            for disposable in oldDisposables {
                disposable.dispose()
            }
        }
    
        private func _dispose() -> [Disposable] {
            self.lock.performLocked {
                let disposables = self.disposables
    
                self.disposables.removeAll(keepingCapacity: false)
                self.isDisposed = true
    
                return disposables
        }
    
        deinit {
            self.dispose()
        }
    } 
  • 간단하게 코드를 훑어보면,
    1. 내가 disposeBag에 담아둔 Stream들이 disposables 라는 배열에 저장된다
    2. dispose 메서드가 실행되면, 그 배열을 반복문으로 돌면서 각 Stream에 대해 dispose() 를 실행한다. 위에서 내가 직접 dispose() 를 실행시킨 부분과 동일하다
      • dispose가 다르다는 점을 주의하자
    3. dispose 메서드가 실행되는 시점은, 인스턴스가 deinit될 때이다

  • 정리하면, VC 또는 VM 클래스에서 계속 생성하고 다녔던
    let disposeBag = DisposeBag() 인스턴스가 deinit될 때,
    해당 disposeBag이 물고 있던(?) 모든 Stream에 대해 dispose가 실행된다.
  • 따라서, 해당 VC 또는 VM 클래스가 deinit되는 순간 메모리 정리가 싹 되기 때문에 메모리 누수가 발생하지 않는다

6. rootVC의 dispose


  • 5번을 정리하면, 어차피 VC가 정상적으로 deinit 되면, Stream들의 메모리 누수 걱정을 할 필요가 없다
  • 하지만 rootVCdeinit이 실행되지 않을 것이다. 만약 rootVC의 Stream에 대해 dispose를 실행해야 한다면 어떻게 해야 할까

1. 직접 dispose

  • 첫 번째 방법은 4번에서 했던 것처럼 모든 Stream에 대해 직접 dispose 메서드를 실행하는 것이다

  • 4번과 동일하게, disposeBag에 담지 않고 모든 Stream을 상수에 담아주었다

    let increment = Observable<Int>.interval(.seconds(1), scheduler: MainScheduler.instance)
    
    let incrementValue = increment
        .subscribe(with: self) { owner , value in
            print("next - \(value)")
        } onError: { owner , error in
            print("error - \(error)")
        } onCompleted: { owner in
            print("completed")
        } onDisposed: { owner in
            print("disposed")
        }
    
    let incrementValue2 = increment
        .subscribe(with: self) { owner , value in
            print("next - \(value)")
        } onError: { owner , error in
            print("error - \(error)")
        } onCompleted: { owner in
            print("completed")
        } onDisposed: { owner in
            print("disposed")
        }
    
    let incrementValue3 =  increment
        .subscribe(with: self) { owner , value in
            print("next - \(value)")
        } onError: { owner , error in
            print("error - \(error)")
        } onCompleted: { owner in
            print("completed")
        } onDisposed: { owner in
            print("disposed")
        }
        
        
    // 필요한 시점에, 내가 직접 dispose 한다!
    DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
                incrementValue.dispose()
                incrementValue2.dispose()
                incrementValue3.dispose()
    }

2. disposeBag 교체

  • 근데 위 코드처럼 저러고 있으면, Stream의 양이 많아질수록 부담스럽다. 점점 편리한 disposeBag 이 생각나게 된다.
  • disposeBag 이 해줬던 것처럼, 물고 있는 모든 Stream에 대해 한 번에 dispose를 실행시킬 수는 없을까?
  • 편리한 disposeBag의 기능을 사용하기 위해, 실행되어야 하는 것은 disposeBag 인스턴스의 deinit 메서드이다.
  • 5번에서는 VC가 deinit될 때, 당연히 disposeBagdeinit된다고 소개했다.
  • 하지만, 이 경우가 아니더라도 충분히 disposeBagdeinit을 실행시킬 수 있다

  • 인스턴스를 교체한다

    let increment = Observable<Int>.interval(.seconds(1), scheduler: MainScheduler.instance)
    
    increment
        .subscribe(with: self) { owner , value in
            print("next - \(value)")
        } onError: { owner , error in
            print("error - \(error)")
        } onCompleted: { owner in
            print("completed")
        } onDisposed: { owner in
            print("disposed")
        }
        .disposed(by: disposeBag)
    
    increment
        .subscribe(with: self) { owner , value in
            print("next - \(value)")
        } onError: { owner , error in
            print("error - \(error)")
        } onCompleted: { owner in
            print("completed")
        } onDisposed: { owner in
            print("disposed")
        }
        .disposed(by: disposeBag)
    
    increment
        .subscribe(with: self) { owner , value in
            print("next - \(value)")
        } onError: { owner , error in
            print("error - \(error)")
        } onCompleted: { owner in
            print("completed")
        } onDisposed: { owner in
            print("disposed")
        }
        .disposed(by: disposeBag)
        
        
    // 필요한 시점에, 내가 직접 dispose 한다!
    DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
        self.disposeBag = DisposeBag()
    }
    

Summary


  • dispose : 메모리에서 해제. 리소스 정리
  • 실행 시점
    1. onError, onCompleted 실행
    2. Disposable 프로토콜의 dispose 메서드 직접 실행
      1. 직접 인스턴스에 접근해서 실행
      2. DisposeBag 클래스 이용
        • 일반적으로 VC 또는 VM이 deinit되면
          disposeBag 인스턴스도 deinit되고
          물고 있던 Stream들 dispose
        • rootVC라서 deinit 될 일이 없다면
          원하는 시점에 disposeBag 인스턴스 교체
          -> 기존 disposeBagdeinit 실행

추가 : TableViewCell의 재사용


  • 테이블 뷰 셀 위에 버튼이 있고, 해당 버튼을 누르면 화면 전환이 일어나는 코드를 작성한다
  • 셀 위의 버튼.rx.tap 과 화면 전환 코드(navigation push)를
    subscribe로 연결한다. (VC의 cellForRowAt 부분에서 작성한다)
/* SearchTableViewCell 의 인스턴스 */
let appNameLabel: UILabel
let appIconImageView: UIImageView
let downloadButton: UIButton    // 화면 전환을 연결할 버튼
var disposeBag: DisposeBag
/* SearchTableViewController */
var data = ["a", "b", "ab", "abcde", "de", "db", "abcd"]  // 데이터 변경 시 편의를 위해 따로 배열을 관리한다
lazy var items = BehaviorSubject(value: data)    
let disposeBag = DisposeBag()

func bind() {
    // cellForRowAt
    items.bind(to: tableView.rx.items(
                    cellIdentifier: SearchTableViewCell.identifier,
                    cellType: SearchTableViewCell.self
    )) { (row, element, cell) in
        cell.appNameLabel.text = element
        cell.appIconImageView.backgroundColor = .green
        
		// 버튼과 화면 전환 구독
        cell.downloadButton.rx.tap
            .subscribe(with: self) { owner , value in
                owner.navigationController?.pushViewController(SampleViewController(), animated: true)
            }
            .disposed(by: cell.disposeBag)    // cell 인스턴스의 disposeBag을 사용한다
    }
    .disposed(by: disposeBag)
}
  • 요기서 문제는, tableView의 cell은 재사용된다
    즉, 재사용되는 동일한 cell에 중복해서 subscribe를 해주고 있는 꼴이 된다
  • subscribe로 연결한 액션은 화면 전환이기 때문에, 버튼을 한 번 누르면 연속해서 화면 전환이 일어난다
  • 셀의 재사용 때문에 발생한 문제는 대부분 prepareForReuse 에서 해결할 수 있는 것 같다
  • 위 코드도 마찬가지로 prepareForReuse에 코드를 작성해주면 되는데,
    문제 원인이 중복된 subscribe이기 때문에,
    매번 셀을 만들 때, subscribe를 끊어주면 된다
  • disposeBag을 교체해준다

    override func prepareForReuse() {
        super.prepareForReuse()
    
        disposeBag = DisposeBag()
    }

0개의 댓글