関数の引数の強参照で引き起こされるMemoryleakを防ぐ


先日メモリリークの調査をしたのですが、かなり見つけづらいパターンだなと思ったので、共有します。

実際に起きていた場所は、下記のようなUIView内でした。

Swiftにおけるメモリリーク、参照カウント、強参照については別記事を参考にしていただけると幸いです。

実際の発生コード

クラス名等は変更しています。

ButtonView.swift
...

//常にViewControllerの下部に浮かせて表示したいボタン
class ButtonView: UIView {
    private let disposeBag = DisposeBag()
    ...

    func putBottom(to viewController: UIViewController) {
        viewController.view.addSubview(self)
        self.translatesAutoresizingMaskIntoConstraints = false
        self.leadingAnchor.constraint(equalTo: viewController.view.leadingAnchor).isActive = true
        self.trailingAnchor.constraint(equalTo: viewController.view.trailingAnchor).isActive = true
        self.heightAnchor.constraint(equalToConstant: 70).isActive = true

        //今回はRxSwiftを利用した例ですが、破棄されないクロージャであればなんでも良いです。
        viewController.view.rx.methodInvoked(#selector(viewController.view.safeAreaInsetsDidChange))
            .take(1)
            .subscribe(onNext: { [weak self] _ in
                guard let self = self else { return }
                viewController.view.safeAreaLayoutGuide.bottomAnchor.constraint(equalTo: self.bottomAnchor).isActive  = true
            })
            .disposed(by: disposeBag)
    }

    ...

}
MyViewController.swift
class MyViewController: UIViewController {
    private var buttonView = ButtonView.instantiate()

    ...

    override func viewDidLoad() {
        super.viewDidLoad()
        buttonView.putBottom(to: self)
    }

    ...
}

一見ButtonView側でも [weak self] していて安全な書き方に見えますが、
この≈はいつまでも破棄されず、メモリリークします。

原因:引数をクロージャ内でキャプチャしていることによる循環参照

循環参照によるメモリリークの例

よくある循環参照によるメモリリークの例として、

let classA = ClassA()
let classB = ClassB()

classA.child = classB
classB.child = classA

みたいなコードを見ることがあると思いますが、これは、互いに参照をもってしまっているために
ARCの参照カウンターがゼロにならずにメモリが解放されない為に起こります。

今回の例

今回の例はそれがさらに複雑になっています。
MyViewController -> ButtonView の参照は明示的に書かれています。
では、ButtonView -> MyViewController の参照はどうでしょう?

原因箇所はここです

ButtonView.swift
   ...
   //今回はRxSwiftを利用した例ですが、破棄されないクロージャであればなんでも良いです。
   viewController.view.rx.methodInvoked(#selector(viewController.view.safeAreaInsetsDidChange))
            .take(1)
            .subscribe(onNext: { [weak self] _ in
                guard let self = self else { return }
                //クロージャ内で関数の引数であるviewControllerをキャプチャしている
                viewController.view.safeAreaLayoutGuide.bottomAnchor.constraint(equalTo: self.bottomAnchor).isActive  = true
            })
            .disposed(by: disposeBag)
    }
    ...

}

このクロージャはdisposeBagが破棄されたタイミング、つまりButtonViewの破棄されるタイミングで参照がリセットされますが、今回の例ではクロージャ内で引数であるviewControllerがクロージャ内でキャプチャされています。
これにより、暗黙的に ButtonView -> MyViewController の参照が生まれていて、循環参照となっていました。

修正と対策

viewControllerを引数に渡す関数設計をやめる

今回の例では、実はそもそもviewControllerを引数に取る必要がありませんでした笑
なので、下記のような修正でメモリリークは解消されます。

ButtonView.swift
...

//常にViewControllerの下部に浮かせて表示したいボタン
class ButtonView: UIView {
    private let disposeBag = DisposeBag()
    ...

    func putBottom(to view: UIView) {
        view.addSubview(self)
        self.translatesAutoresizingMaskIntoConstraints = false
        self.leadingAnchor.constraint(equalTo:view.leadingAnchor).isActive = true
        self.trailingAnchor.constraint(equalTo:view.trailingAnchor).isActive = true
        self.heightAnchor.constraint(equalToConstant: 70).isActive = true

        //今回はRxSwiftを利用した例ですが、破棄されないクロージャであればなんでも良いです。
        view.rx.methodInvoked(#selector(view.safeAreaInsetsDidChange))
            .take(1)
            .subscribe(onNext: { [weak self] _ in
                guard let self = self else { return }
                view.safeAreaLayoutGuide.bottomAnchor.constraint(equalTo: self.bottomAnchor).isActive  = true
            })
            .disposed(by: disposeBag)
    }

    ...

}

MyViewController.view-> ButtonView への参照はないため、どちらもMyViewControllerのdeallocateのタイミングでメモリが解放されます。

関数内クロージャでの引数の参照を弱参照にする

ですが、UIView利用の仕方によってメモリリークするButtonViewはあまりいい設計とは言えません。
この場合、さらに関数内クロージャでの引数の参照を弱参照にすることでより安全になります。

ButtonView.swift
   ...
   viewController.view.rx.methodInvoked(#selector(viewController.view.safeAreaInsetsDidChange))
            .take(1)
            .subscribe(onNext: { [weak self, weak view] _ in
                guard let self = self, let view = view else { return }
                //クロージャ内で関数の引数であるviewControllerをキャプチャしている
                viewController.view.safeAreaLayoutGuide.bottomAnchor.constraint(equalTo: self.bottomAnchor).isActive  = true
            })
            .disposed(by: disposeBag)
    }
    ...

}

あまり見慣れない書き方ですが、クロージャ内で複数の引数を弱参照でキャプチャしたい場合は [weak self, weak view] とかけます。
この場合、仮に 引数のView -> ButtonViewの参照があった場合でも、メモリリークを起こすことなく利用できます

利用するViewController側での対策

利用するViewController側でも対策することができます。

MyViewController.swift
class MyViewController: UIViewController {
    weak var buttonView: ButtonView?

    ...

    override func viewDidLoad() {
        super.viewDidLoad()
        buttonView.putBottom(to: self)
    }

    ...
}

このように、自身を参照する可能性のあるプロパティに関して、弱参照で定義することができます。
しかし、どこからも参照がなくなってしまったViewに関しては、すぐにdeallocateしてしまうのでこの対策には注意が必要です.

まとめ

今回のようなメモリリークを起こさないために、書き手としては、
- プロパティに自身を渡すような関数には気をつける
- 引数の強参照をふせぐため、保持されるクロージャ内では引数の弱参照を心がける

といった必要を感じました。実際起きてしまうとかなり気づきにくい上、ButtonViewの利用法等、時間が経って発覚する場合があるので、なるべく事前に防ぐ工夫が必要です。