はじめに
本記事は 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:)
というメソッドがあるとします。
final class ImageCacheManager {
func cacheImage(imageUrl: URL, completion: @escaping (Result<UIImage, Error>) -> Void) { ... }
}
上記のメソッドをViewControllerから呼び出す例です。
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
ただ他のクラスとの組み合わせによっては、メモリリークする可能性があると思います。
実務でありそう、かつ必ずメモリリークする例が作れませんでした…。
以下のサンプルコードはクロージャをプロパティに保持していないですが、必ずメモリリークします。
実行しても deinit
の print(_:)
メソッドが呼ばれないので、解放されていないことがわかります。
実際にメモリリークしているかは、XcodeのDebug Memory Graphを使うとわかりやすいです。
結論
クロージャでメモリリークする原因と解決策を一言で紹介します。
- 原因: クロージャと
self
が循環参照しているため - 解決策:
@escaping
なクロージャでself
を使う場合は常に弱参照する
原因
原因を詳細に説明します。
「escaping」属性とは?
まず @escaping
属性について説明します。
関数の引数として渡すクロージャに @escaping
を付けると、そのクロージャが関数のスコープ外で保持できるようになります。
関数からエスケープするので「escaping」と命名されたのだと思います。
@escaping
を付けると「エスケープできる」ようになるのであり、必ずしもエスケープされるとは限らないので「escapable」のほうが直感的だと思いました。
しかし @escaping
が付いているのにエスケープされないことは考えにくいので「escaping」でよさそうです。
以下に簡単な例を示します。
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
ただクロージャを直接プロパティに代入することは少なく、実際はもう少し複雑なケースで保持することが多いです。
私が少なかっただけで、RxSwiftやCombineなどのライブラリを使っていたり、ViewControllerにクロージャを渡すアーキテクチャを採用していたりすると、クロージャをプロパティに保持するケースは少なくありませんでした。
関数内の非同期で実行される別のクロージャで引数のクロージャを実行するとき、エスケープしないと関数を抜けたときに別のクロージャ内で引数のクロージャを実行できません(多分)。
以下の例だと URLSession.shared.dataTask
と DispatchQueue.main.async
のクロージャ内で引数のクロージャを実行するのに @escaping
が必要です。
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.
を付けないとビルドエラーになります。
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
エラーを解消するため self.
を戻します。
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
を弱参照できます。
?
型になり、アンラップする必要があるのに注意です。
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
の参照が少ないのでオプショナルチェイニングで楽してアンラップしていますが、 self
が nil
になるのは異常系なので guard let
でエラーハンドリングするのが望ましいです。
あと一度 self
を弱参照したら、下位のクロージャにも伝播します。
上記の例で DispatchQueue.main.async { ... }
は [weak self]
を省略していますが、付いているのと同じです。
ただ外側で guard let self = self
していると必要になるので、常に [weak self]
を省略しないのもありだと思います。
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]
でキャプチャすると !
型になります。
self
が nil
のときにクラッシュするので使いどころは難しいですが、手動でアンラップする手間が省けるので、このあたりのライフサイクルを理解していれば使っていいと思います。
weak
と unowned
の使い分けは、以下のスライドが参考になります。
[weak self]
を付けなくてもメモリリークしないパターンもあります。
しかし判断が難しいので、常に付けるのがベターです。
最終的には「 self
を付けてプロパティやメソッドを呼び出している → @escaping
なクロージャなんだな → このクロージャは関数のスコープ外で保持されているんだな → 循環参照を避けるために [weak self]
を付けて弱参照しよう」に辿り着きます。
おまけ: Swift Concurrency時代のweak self
私はまだSwift Concurrencyをあまり理解していないのですが、さらに考えることが増えるようです。
おわりに
これでもうクロージャでメモリリークすることがなくなるはずです
以上 Swift/Kotlin愛好会 Advent Calendar 2021 の4日目の記事でした。
明日は @jollyjoester さんで「何か書く」です。
参考リンク
- Swift実践入門 第3版 p.142-143, 301-302
- Automatic Reference Counting — The Swift Programming Language (Swift 5.5)
- Closures — The Swift Programming Language (Swift 5.5)
- iOSアプリのメモリリークを発見、改善する技術 - クックパッド開発者ブログ
- Add [weak self] by uhooi · Pull Request #258 · uhooi/UhooiPicBook
- Remove unnecessary [weak self] by uhooi · Pull Request #259 · uhooi/UhooiPicBook
- https://twitter.com/the_uhooi/status/1463354028604080128?s=20
- https://twitter.com/___freddi___/status/1463336122713444353?s=20
- https://twitter.com/omochimetaru/status/1463445649727180802?s=20
- https://twitter.com/_take_hito_/status/1463468355919040512?s=20
- https://twitter.com/marty_suzuki/status/1463479022369718272?s=20
- https://twitter.com/the_uhooi/status/1463511587235401736?s=20
- https://twitter.com/the_uhooi/status/1463785352632565762?s=20
- https://twitter.com/the_uhooi/status/1463798572499931138?s=20
- https://twitter.com/_take_hito_/status/1463822403511848975?s=20
- https://twitter.com/hicka04/status/1463640655561846785?s=20
- https://twitter.com/inamiy/status/1464063488855199751?s=20
- https://twitter.com/the_uhooi/status/1466784442731872261?s=20