21
18

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2019-07-24

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

実際に起きていた場所は、下記のような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] していて安全な書き方に見えますが、
この≈はいつまでも破棄されず、メモリリークします。

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

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

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

.swift
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の利用法等、時間が経って発覚する場合があるので、なるべく事前に防ぐ工夫が必要です。

21
18
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
21
18

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?