Swift Concurrencyは Task
が作られたときや await
したタイミングで暗黙的に self
をキャプチャするようになっています。
このおかげで例えば非同期処理中に画面を閉じたとしても、先にリソースが解放されてしまうような事態が起きないようになっています。
しかし [unowned self]
や [weak self]
でキャプチャしているクロージャ内では、この暗黙的キャプチャがされないケースがありました。
- Xcode 13.4.1 (Swift 5.6)
- Xcode 14.0.1 (Swift 5.7)
でこの現象が確認できましたが、今後のバージョンでは変わるかもしれません。
問題を再現するコード
モーダル画面が表示されてから約3秒後、アラートが表示されるコードを用意します。
final class ModalViewController: UIViewController {
private let viewModel = ModalViewModel()
private var cancellables = Set<AnyCancellable>()
deinit {
print("deinit")
}
override func viewDidLoad() {
super.viewDidLoad()
bind()
Task {
// echo()は3秒後、viewModel.messageにエコーバックする
await viewModel.echo(message: "ALERT!!")
}
}
private func bind() {
// メッセージが返ってきたらアラート表示
viewModel.$message
.filter { !$0.isEmpty }
.sink { [unowned self] message in
Task {
await showMessage(message)
}
}
.store(in: &cancellables)
}
// アラートでメッセージを表示する
private func showMessage(_ message: String) async {
return await withCheckedContinuation { continuation in
let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert)
alert.addAction(.init(title: "OK", style: .default, handler: { _ in continuation.resume() }))
present(alert, animated: true)
}
}
}
このコードを実行してモーダル画面を表示した後すぐ、 アラートが表示される前に画面を閉じると、アプリがクラッシュ します!
このとき Fatal error: Attempted to read an unowned reference but the object was already deallocated
というエラーログが出力されているので、どうも解放済の self
参照が発生していそうです。
もう少し詳細を知るために参照カウントを出力してみます。
private func bind() {
viewModel.$message
.filter { !$0.isEmpty }
.sink { [unowned self] message in
print("ref count(begin): \(CFGetRetainCount(self))")
Task {
// 🧨すぐにモーダル画面を閉じると、ここでクラッシュ
print("ref count(task): \(CFGetRetainCount(self))")
await showMessage(message)
}
print("ref count(end): \(CFGetRetainCount(self))")
}
.store(in: &cancellables)
}
この状態で手順を踏むと、
ref count(begin): 2
ref count(end): 2
deinit
Fatal error: Attempted to read an unowned reference...
というログが出力されており、
-
ref count(end)
が2
のままで、Task
が作られた後に参照カウントが増えていない - 先に
deinit
された後、Task
内のクロージャを実行している
という挙動になっているので、意図しないクラッシュが起きてしまいそうです。
問題の原因と解決方法
先に述べたとおり [unowned self]
でキャプチャしているクロージャ内 Task
を生成すると、この問題が発生するようです。試しに以下のようなコード変更してみるとクラッシュが発生しません。
private func bind() {
viewModel.$message
.filter { !$0.isEmpty }
.sink { [unowned self] message in
// メソッドを通じてTaskを実行する
showMessage(message)
}
.store(in: &cancellables)
}
private func showMessage(_ message: String) {
print("ref count(begin): \(CFGetRetainCount(self))")
Task {
print("ref count(task): \(CFGetRetainCount(self))")
await showMessage(message)
}
print("ref count(end): \(CFGetRetainCount(self))")
}
このときのログ出力は
ref count(begin): 3
ref count(end): 4
ref count(task): 2
deinit
となり、 Task
が作られた後に参照カウントが増え、Task
内の非同期処理が実行されてから deinit
されています。
一見すると同じ挙動になると思われるコードですが、書き方次第で最悪クラッシュしてしまうのはちょっと怖いですね...
この参照カウントが増えない問題はコンパイラのバグなのか仕様なのか分からないので、何かご存知の方いましたら教えていただけると🙇♂️
おまけ
アラートが表示される前にモーダル画面を閉じても、 Task
はキャンセルせずインスタンスも生きているため、アラートを表示するロジックは実行されます。
しかし presentingViewController
が存在しない状態になっているため、内部的に Attempt to present ... whose view is not in the window hierarchy.
エラーが起き continuation.resume()
がされず、メモリリークが発生してしまいます。
なので presentingViewController
が nil
でないことをチェックしておいたほうが良さそうです。
private func showMessage(_ message: String) async {
// これ何気に大事
guard presentingViewController != nil else { return }
return await withCheckedContinuation { continuation in
let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert)
alert.addAction(.init(title: "OK", style: .default, handler: { _ in continuation.resume() }))
present(alert, animated: true)
}
}