[iOS] シングルトンでRxSwiftのSingle使ってメモリリークはどうなるのか調べた | Singleton

2020/05/3

こんにちは。きんくまです。

RxSwiftのメモリリークがどうなるのか気になったので調べてみました。
RxSwiftのバージョンは5.0.1

まずは実際のメモリリークをみてみます

こんな感じにメモリリークのマークが出ていることがわかります。ただし、これはSingleではなくてObservableを使っておこしています。理由はあとで説明。

前提

どういうときにおきるのか?いろいろと調べてみたのですが、通常は2点あります。

1. .disposed(by: disposeBag) を呼んでいない
2. クロージャーの中で weak self を使わずに self を使って循環参照をおこしてしまっている

逆にどういうときにObservableのメモリが開放されるかというと以下のとき

1. .onCompleted()を呼んだとき
2. .onError(error)を呼んだとき

参考)【RxSwift】Singleton で DisposeBag を使うことの考察

Singleはメモリリークを起こすのか?

結論からいうと、普通に使うと起こさないと思います。いろいろと試してみたのですが、SingleはObservableのメモリが開放される、onCompleted(Singleだとsuccess)とonErrorを必ず呼ぶので、何もしなくても自動開放されました。

サンプルコード1

import UIKit
import RxSwift

enum SampleError: Error {
    case unknown
}

class DetailViewController: UIViewController {
    
    let disposeBag = DisposeBag()
    var count: Int = 0
    
    @IBAction func didTapStartButton() {
        print("start \(count)")

        createSingleLoad(count: count).subscribe(onSuccess: { result in
            print("onSuccess: message \(result)")
        }, onError: { error in
            print("onError \(error)")
        }).disposed(by: disposeBag)
        
        count += 1
    }
    
    func createSingleLoad(count: Int) -> Single<String?> {
        return Single.create { event -> Disposable in
            
            DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
                event(.success("Success \(count)"))
            }
            
            return Disposables.create {
                print("disposed \(count)")
            }
        }
    }
}

わざわざ dispossed(by: disposeBag) を使っていますが、使わなくても必ずdisposedされました。

サンプルコード2

    @IBAction func didTapStartButton() {
        print("start \(count)")

        // これでもdipsosedされた
        _ = createSingleLoad(count: count).subscribe(onSuccess: { result in
            print("onSuccess: message \(result)")
        }, onError: { error in
            print("onError \(error)")
        })
        
        count += 1
    }

Singleでdispossed(by:)使うのは途中で処理をキャンセルしたいとき

呼んでしまったAPIのコール自体をキャンセルすることはできませんが、結果をみないようにするという意味ではキャンセル可能です。

サンプルコード1では、画面をpopすると処理が途中だったとしても、disposedすることができました。
これは以下の理由から。

– DetailViewControllerのインスタンスプロパティとして、disposeBagが定義されている
– DetailViewControllerがpopされたときにdisposeBagが開放
– ひもづいているsubscribeがキャンセル

あとは、例えばこのようにdisposeBagをOptionalにすれば処理中でもキャンセルできます。
キャンセルボタンを押すdidTapCancelButtonを呼んだときに、disposeBagをnilにして開放しています。

ただし、この場合はもしstartButtonを連打すると、処理途中のものもキャンセルされてしまいます。
反対にサンプルコード1の場合は連続でスタートボタンを押しても同時にSingleを実行することができました。

サンプルコード3

class DetailViewController: UIViewController {
    
    var disposeBag: DisposeBag?
    var count: Int = 0
    
    @IBAction func didTapStartButton() {
        print("start \(count)")

        let bag = DisposeBag()
        disposeBag = bag
        
        createSingleLoad(count: count).subscribe(onSuccess: { result in
            print("onSuccess: message \(result)")
        }, onError: { error in
            print("onError \(error)")
        }).disposed(by: bag)
        
        count += 1
    }
    
    @IBAction func didTapCancelButton() {
        disposeBag = nil
    }
}

Observableでメモリリークをおこしてみよう。その1

次のコードはObservableを使っています。ただし、Observableの中でonCompletedやonErrorを呼んでいません。
また、disposed(by:)を使っていません。

この状態で画面をpopすると素敵にメモリリークします!

サンプルコード4

class DetailViewController: UIViewController {
    
    let disposeBag = DisposeBag()
    var count: Int = 0
    
    @IBAction func didTapStartButton() {
        print("start \(count)")

        createObservableLoad(count: count).subscribe { [weak self] event in
            guard let self = self else { return }
            switch event {
            case .next(let text):
                print("text desu \(text)")
            case .error(let error):
                print("error \(error)")
            case .completed:
                print("completed!")
            }
        }
        
        count += 1
    }
    
    func createObservableLoad(count: Int) -> Observable<String?> {
        return Observable.create { event -> Disposable in
            DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
                event.onNext("next dayo")
                event.onNext("next dayo2 count \(count)")
            }
            
            return Disposables.create {
                print("disposed \(count)")
            }
        }
    }
}

最後にdisposed(by:)を使えばメモリリークしません

サンプルコード5

    @IBAction func didTapStartButton() {
        print("start \(count)")

        createObservableLoad(count: count).subscribe { [weak self] event in
            guard let self = self else { return }
            switch event {
            case .next(let text):
                print("text desu \(text)")
            case .error(let error):
                print("error \(error)")
            case .completed:
                print("completed!")
            }
        }.disposed(by: disposeBag)
        
        count += 1
    }

Observableでメモリリークをおこしてみよう。その2

循環参照を使ってメモリリークをおこしてみましょう!
クロージャーの中でそのままselfを使っているので、disposed(by:)を使っていてもメモリリークがおきました!

サンプルコード6


class DetailViewController: UIViewController {

    let disposeBag = DisposeBag()
    var count: Int = 0
    var name: String = "Sample Name"
    @IBOutlet weak var startButton: UIButton!

    @IBAction func didTapStartButton() {
        print("start \(count)")

        createObservableLoad(count: count).subscribe { event in
            switch event {
            case .next(let text):
                print("text desu \(text)")

                //ここ
                self.startButton.setTitle("start2", for: .normal)
            case .error(let error):
                print("error \(error)")
            case .completed:
                print("completed!")
            }
        }.disposed(by: disposeBag)
        
        count += 1
    }
}

weak selfで、循環参照を解消しましょう!

サンプルコード7

    @IBAction func didTapStartButton() {
        print("start \(count)")

        createObservableLoad(count: count).subscribe { [weak self] event in
            guard let self = self else { return }
            switch event {
            case .next(let text):
                print("text desu \(text)")
                self.startButton.setTitle("start2", for: .normal)
            case .error(let error):
                print("error \(error)")
            case .completed:
                print("completed!")
            }
        }.disposed(by: disposeBag)

        
        count += 1
    }

ちなみに、Singleで循環参照を起こした場合、なんとメモリリークはおきませんでした。
これは、Singleが必ずonCompletedで破棄されるから(循環参照してても!)なのではないか?と思いますが、詳しいことはわかりませんです、、。

シングルトンでためす

こんなクラスを作ってみました

サンプルコード8

import Foundation
import RxSwift

class SampleService {
    static private(set) var shared: SampleService = SampleService()
    
    let disposeBag = DisposeBag()
    
    func startSingle(count: Int) {
        createSingleLoad(count: count).subscribe(onSuccess: { result in
            print("onSuccess \(result)")
        }, onError: { error in
            print("onError \(error)")
        }).disposed(by: disposeBag)
    }
    
    func startObservable(count: Int) {
        createObservableLoad(count: count).subscribe { event in
            switch event {
            case .next(let text):
                print("text desu \(text)")
            case .error(let error):
                print("error \(error)")
            case .completed:
                print("completed!")
            }
        }.disposed(by: disposeBag)
    }
    
    func createSingleLoad(count: Int) -> Single<String?> {
        return Single.create {  event -> Disposable in
            
            DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
                event(.success("Success \(count)"))
            }
            
            return Disposables.create {
                print("disposed \(count)")
            }
        }
    }
    
    func createObservableLoad(count: Int) -> Observable<String?> {
        return Observable.create { event -> Disposable in
            DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
                event.onNext("next dayo")
                event.onNext("next dayo2 count \(count)")
                // onCompleteさえ呼べば開放。呼ばないといつまでたっても開放されない
                event.onCompleted()
            }
            
            return Disposables.create {
                print("disposed \(count)")
            }
        }
    }
}

使ってみる

サンプルコード9

SampleService.shared.startObservable(count: count)
もしくは
SampleService.shared.startSingle(count: count)

結論からいいますと、シングルトンの中にletのdisposeBagを使って、disposed(by:)するとメモリリークがおきました。
SingleやObservable自体は開放されているのですが、いつまでもsubscribeされているのではないか?と思います。
なので、disposed(by:)をしないようにすると、メモリが開放されました。

サンプルコード10

    func startSingle(count: Int) {
        _ = createSingleLoad(count: count).subscribe(onSuccess: { result in
            print("onSuccess \(result)")
        }, onError: { error in
            print("onError \(error)")
        })
    }
    
    func startObservable(count: Int) {
        _ = createObservableLoad(count: count).subscribe { event in
            switch event {
            case .next(let text):
                print("text desu \(text)")
            case .error(let error):
                print("error \(error)")
            case .completed:
                print("completed!")
            }
        }
    }

シングルトンでRxSwift使うときのまとめ

– SingleでないObservableなどの場合は、必ずonCompletedかonErrorをObservableの中から呼ぶこと(シングルトンのときだけ気をつける)
– クロージャーの中で循環参照をおこさないようにする
– disposed(by:)を呼ばない(シングルトンのときだけ気をつける)
– もしdisposed(by:)を使いたい場合は、disposeBagをOptionalにして、毎回開放すること
– メモリリークをおこす原因は、Observable側と、Subscribeする方のObserve側がある

ソースコード(コメントアウトばっかりで汚い!)
RxSample2

余談: 結局disposeBagって何なの?

ここに書いてありました。

Memory management in RxSwift – DisposeBag

– ObservableをsubscribeするとDisposableが返る
– そのときDisposable -> Observableと、Observable -> Disposableへの循環参照の状態になる(メモリリーク!)
– その参照を断ち切るのがObservableのdispose()
– dispose()はObservable自身がcompletedとerrorを発行したときに自動で呼ぶ
– もしObservableがcompletedとerrorを発行しなかったら、本来はsubscribeしているObserveがdeinitのタイミングでdisposeを手動で呼ばないといけない
– そこでdisposeBagの登場
– disposeBagにDisposableを全部登録しておけば、deinitのタイミングで自動で中に入っている複数のDisposableをdisposeしてくれる

このことからシングルトンでSingleを使っているときに、disposed(by:)を呼ばなくてよい理由がわかりました。
つまり、Observable自身がcompletedとerrorを発行したときにすでにdisposeを呼んでいるからです。
何らかの理由でSingleを任意のタイミングでキャンセルしたい場合はdisposed(by:)で管理する必要がありますが、そうでない場合はほっておいてもdisposeされるので何もしなくて良いということになります。

また、disposed(by:)したときのdisposeBagが残っている場合は、subscribe自体は残り続けます。(メモリリークというか、保持し続けるというか)

シングルトンでない、通常の場合

1. disposeBagが開放
2. このとき、もしObservableがcompleteとerrorを呼んでなくて、Observable自身でObservableを開放していなければ、disposeBagからdisposeする
3. subscribeもキャンセル。その分のメモリ開放
4. disposeBagを開放するのは、letのインスタンスプロパティとして保持している場合deinitのタイミング。またdisposeBagをoptionalにしておけば(DisposeBag?型)、任意のタイミングで開放できる

シングルトンでdisposed(by:)した場合

– deinitが実行されないためdisposeBagはoptionalにしない限り開放されない
– そのためsubscribeされ続ける(Observableが開放されていても)

LINEで送る
Pocket

自作iPhoneアプリ 好評発売中!
フォルメモ - シンプルなフォルダつきメモ帳
ジッピー電卓 - 消費税や割引もサクサク計算!

LINEスタンプ作りました!
毎日使える。とぼけたウサギ。LINEスタンプ販売中! 毎日使える。とぼけたウサギ

ページトップへ戻る