LoginSignup
36
24

More than 1 year has passed since last update.

クロージャでメモリリークする原因と解決策(Swift)

Last updated at Posted at 2021-12-03

はじめに

本記事は Swift/Kotlin愛好会 Advent Calendar 2021 の4日目の記事です。
昨日は @n_takehata さんで 【Kotlin初心者向け】KotlinでvarとMutableListを使わなくする方法 でした。

Swiftのクロージャでメモリリークする原因と解決策を紹介します。

環境

  • OS:macOS Big Sur 11.6
  • Swift:5.5
  • Xcode:13.0 (13A233)

現象

どのような場合にクロージャでメモリリークが発生するか紹介します。

URLから画像を取得し、キャッシュした上でcompletion handlerに画像を渡す ImageCacheManager#cacheImage(imageUrl:completion:) というメソッドがあるとします。

ImageCacheManager.swift
final class ImageCacheManager {
    func cacheImage(imageUrl: URL, completion: @escaping (Result<UIImage, Error>) -> Void) { ... }
}

上記のメソッドをViewControllerから呼び出す例です。

MonsterDetailViewController.swift
final class MonsterDetailViewController: UIViewController {
    var imageCacheManager = ImageCacheManager()
    var monster: Monster!

    @IBOutlet private weak var iconImageView: UIImageView!

    override func viewDidLoad() {
        super.viewDidLoad()

        configureIconImageView()
    }

    private func configureIconImageView() {
        imageCacheManager.cacheImage(imageUrl: monster.iconUrl) { result in
            switch result {
            case let .success(icon):
                DispatchQueue.main.async {
                    self.iconImageView.image = icon
                }
            case let .failure(error):
                print(error)
            }
        }
    }

一見問題ないコードに見えますが、実際に問題ありませんw
ただ他のクラスとの組み合わせによっては、メモリリークする可能性があると思います。

実務でありそう、かつ必ずメモリリークする例が作れませんでした…。

以下のサンプルコードはクロージャをプロパティに保持していないですが、必ずメモリリークします。
実行しても deinitprint(_:) メソッドが呼ばれないので、解放されていないことがわかります。

実際にメモリリークしているかは、XcodeのDebug Memory Graphを使うとわかりやすいです。

結論

クロージャでメモリリークする原因と解決策を一言で紹介します。

  • 原因: クロージャと self が循環参照しているため
  • 解決策: @escaping なクロージャで self を使う場合は常に弱参照する

原因

原因を詳細に説明します。

「escaping」属性とは?

まず @escaping 属性について説明します。
関数の引数として渡すクロージャに @escaping を付けると、そのクロージャが関数のスコープ外で保持できるようになります。
関数からエスケープするので「escaping」と命名されたのだと思います。

@escaping を付けると「エスケープできる」ようになるのであり、必ずしもエスケープされるとは限らないので「escapable」のほうが直感的だと思いました。
しかし @escaping が付いているのにエスケープされないことは考えにくいので「escaping」でよさそうです。

以下に簡単な例を示します。

ImageCacheManager.swift
final class ImageCacheManager {
    private var completionHandler: ((Result<UIImage, Error>) -> Void)? = nil

    func cacheImage(imageUrl: URL, completion: @escaping (Result<UIImage, Error>) -> Void) {
        completionHandler = completion // `@escaping` が付いているので関数のスコープ外で保持できる
    }
}

上記の例で @escaping を付けないと以下のエラーが発生します。

Assigning non-escaping parameter 'completion' to an @escaping closure
Parameter 'completion' is implicitly non-escaping

スクリーンショット 2021-11-26 0.43.21.png

ただクロージャを直接プロパティに代入することは少なく、実際はもう少し複雑なケースで保持することが多いです。
私が少なかっただけで、RxSwiftやCombineなどのライブラリを使っていたり、ViewControllerにクロージャを渡すアーキテクチャを採用していたりすると、クロージャをプロパティに保持するケースは少なくありませんでした。

関数内の非同期で実行される別のクロージャで引数のクロージャを実行するとき、エスケープしないと関数を抜けたときに別のクロージャ内で引数のクロージャを実行できません(多分)。

以下の例だと URLSession.shared.dataTaskDispatchQueue.main.async のクロージャ内で引数のクロージャを実行するのに @escaping が必要です。

ImageCacheManager.swift
final class ImageCacheManager {
    func cacheImage(imageUrl: URL, completion: @escaping (Result<UIImage, Error>) -> Void) {
        var imageToCache = UIImage()

        URLSession.shared.dataTask(with: imageUrl) { data, _, error in
            guard let data = data, let image = UIImage(data: data) else {
                completion(.failure(ImageCacheError.loadingFailure)) // `@escaping` が付いているので関数のスコープ外で実行できる
                return
            }

            imageToCache = image
            ImageCacheManager.imageCache.setObject(imageToCache, forKey: imageUrl as AnyObject)
            DispatchQueue.main.async {
                completion(.success(imageToCache)) // `@escaping` が付いているので関数のスコープ外で実行できる
            }
        }.resume()
    }
}

クロージャを不要に保持するとメモリリークに繋がるので、 必要でない限りは @escaping を付けるべきではありません
Xcodeに怒られたら付けるのがいいと思います。

「クロージャに @escaping が付いている → このクロージャは関数のスコープ外で保持されているんだな」とわかるのが望ましいです。
関数のスコープ外で保持されていないクロージャに @escaping が付いていると、読み手に混乱を招きます。

escapingなクロージャでインスタンス自身のプロパティやメソッドを呼ぶ

@escaping なクロージャでインスタンス自身のプロパティやメソッドを呼ぶ場合、明示的に self. を付けないとビルドエラーになります。

MonsterDetailViewController.swift
final class MonsterDetailViewController: UIViewController {

    private func configureIconImageView() {
        imageCacheManager.cacheImage(imageUrl: monster.iconUrl) { result in
            switch result {
            case let .success(icon):
                DispatchQueue.main.async {
                    iconImageView.image = icon // FIXME: `self.` を付けていないのでビルドエラー
                }
            case let .failure(error):
                print(error)
            }
        }
    }

}
Reference to property 'iconImageView' in closure requires explicit use of 'self' to make capture semantics explicit

Reference 'self.' explicitly

Capture 'self' explicitly to enable implicit 'self' in this closure

スクリーンショット 2021-12-03 22.33.19.png

エラーを解消するため self. を戻します。

MonsterDetailViewController.swift
final class MonsterDetailViewController: UIViewController {

    private func configureIconImageView() {
        imageCacheManager.cacheImage(imageUrl: monster.iconUrl) { result in
            switch result {
            case let .success(icon):
                DispatchQueue.main.async {
-                   iconImageView.image = icon
+                   self.iconImageView.image = icon
                }
            case let .failure(error):
                print(error)
            }
        }
    }

}

これでクロージャが self を強参照します。
回り回って self がクロージャを保持すると循環参照するため、メモリリークするというわけです。

ちなみにどう回り回るのかはまだ理解していません…。

escapingなクロージャでselfが省略できない理由

self キーワードが省略できない理由は、クロージャと self が循環参照するのに気づきやすくするためです。

self を付けてプロパティやメソッドを呼び出している → @escaping なクロージャなんだな → このクロージャは関数のスコープ外で保持されているんだな」とわかるのが望ましいです。
そのため 必要でない限りは self を省略する のがオススメです。

解決策

解決策を詳細に説明します。

結論で述べた通り @escaping なクロージャ内で self を強参照するのではなく、弱参照すれば循環参照しなくなり、メモリリークが発生しません。

クロージャ内の先頭で [weak self] を付ける(「キャプチャ」と呼ぶ)ことで、 self を弱参照できます。
? 型になり、アンラップする必要があるのに注意です。

MonsterDetailViewController.swift
final class MonsterDetailViewController: UIViewController {
    var imageCacheManager = ImageCacheManager()
    var monster: Monster!

    @IBOutlet private weak var iconImageView: UIImageView!

    override func viewDidLoad() {
        super.viewDidLoad()

        configureIconImageView()
    }

    private func configureIconImageView() {
-       imageCacheManager.cacheImage(imageUrl: monster.iconUrl) { result in
+       imageCacheManager.cacheImage(imageUrl: monster.iconUrl) { [weak self] result in
            switch result {
            case let .success(icon):
                DispatchQueue.main.async {
-                   self.iconImageView.image = icon
+                   self?.iconImageView.image = icon
                }
            case let .failure(error):
                print(error)
            }
        }
    }

上記は self の参照が少ないのでオプショナルチェイニングで楽してアンラップしていますが、 selfnil になるのは異常系なので guard let でエラーハンドリングするのが望ましいです。

あと一度 self を弱参照したら、下位のクロージャにも伝播します。
上記の例で DispatchQueue.main.async { ... }[weak self] を省略していますが、付いているのと同じです。
ただ外側で guard let self = self していると必要になるので、常に [weak self] を省略しないのもありだと思います。

MonsterDetailViewController.swift
final class MonsterDetailViewController: UIViewController {
    var imageCacheManager = ImageCacheManager()
    var monster: Monster!

    @IBOutlet private weak var iconImageView: UIImageView!

    override func viewDidLoad() {
        super.viewDidLoad()

        configureIconImageView()
    }

    private func configureIconImageView() {
        imageCacheManager.cacheImage(imageUrl: monster.iconUrl) { [weak self] result in
+           guard let self = self else {
+               // TODO: エラーハンドリング
+           }
            switch result {
            case let .success(icon):
-               DispatchQueue.main.async {
+               DispatchQueue.main.async { [weak self] // !!!: `self` をアンラップしたので再度 `[weak self]` を付けないとメモリリークに繋がる
                    self?.iconImageView.image = icon
                }
            case let .failure(error):
                print(error)
            }
        }
    }

ちなみに [unowned self] でキャプチャすると ! 型になります。
selfnil のときにクラッシュするので使いどころは難しいですが、手動でアンラップする手間が省けるので、このあたりのライフサイクルを理解していれば使っていいと思います。

weakunowned の使い分けは、以下のスライドが参考になります。

[weak self] を付けなくてもメモリリークしないパターンもあります。
しかし判断が難しいので、常に付けるのがベターです。

最終的には「 self を付けてプロパティやメソッドを呼び出している → @escaping なクロージャなんだな → このクロージャは関数のスコープ外で保持されているんだな → 循環参照を避けるために [weak self] を付けて弱参照しよう」に辿り着きます。

おまけ: Swift Concurrency時代のweak self

私はまだSwift Concurrencyをあまり理解していないのですが、さらに考えることが増えるようです。

おわりに

これでもうクロージャでメモリリークすることがなくなるはずです :relaxed:

以上 Swift/Kotlin愛好会 Advent Calendar 2021 の4日目の記事でした。
明日は @jollyjoester さんで「何か書く」です。

参考リンク

36
24
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
36
24