こんにちは!iOSエンジニアの @chuymaster です!私たちのチームが2年近くかけて開発した 「スタディサプリ 中学講座」 のiOSアプリがようやくリリースされました!🎉🎉 いやぁ〜みんなで頑張りましたよ!中学生のお子様がいらっしゃる方はぜひお試しください!
ところで、皆さんはメモリリークに気をつけていますか?「スタディサプリ 中学講座」はMVVMアーキテクチャで、SwiftUIとCombineを使って開発をしています。SwiftUIのViewはとても軽いのでメモリ管理についてはあまり意識しなくてもいいですね。しかし、ViewModel の方は気をつけなければ循環参照が発生して、メモリリークが起きてしまうことがあります。それを防ぐ3つのTipsを、この記事で紹介します。ぜひ試してみてください!
前提
1. [weak self]
を忘れない
よくある循環参照の問題です。Swiftでコードを書いていくと、おそらく誰もが遭遇する strong self
の強参照によるメモリリークです。Combineの世界でも変わらず存在します。
下記の再現コードは、「Managing self and cancellable references when using Combine」を参考に書きました。
struct LeakView: View { @StateObject private var viewModel = LeakViewModel() var body: some View { Text("Time: \(viewModel.time)") } } final class LeakViewModel: ObservableObject { @Published private(set) var time = Date().timeIntervalSince1970 private var cancellables = Set<AnyCancellable>() init() { print("\(self): \(#function)") // Combineでタイマーの監視をする Timer.publish(every: 1, on: .main, in: .default) .autoconnect() .sink { date in // 強参照している self.time = date.timeIntervalSince1970 } .store(in: &cancellables) } deinit { print("\(self): \(#function)") } }
画像から分かるように、 ViewModel.deinit
が呼ばれていません。self.time = date.timeIntervalSince1970
が原因で LeakViewModel
が開放されないからです。
修正方法は簡単で、 [weak self]
をつけて、クロージャー内の LeakViewModel
の参照を弱参照にすれば解決です。
// LeakViewModel init() { Timer.publish(every: 1, on: .main, in: .default) .autoconnect() .sink { [weak self] date in // 弱参照に変える self?.time = date.timeIntervalSince1970 } .store(in: &cancellables) }
これで、画面を閉じると deinit
も呼ばれました。
2. .map
と .assign(to:)
を活用する
毎回 [weak self]
を書くのが煩わしいと感じた方もいると思います。前述のコードのように、 @Published
のプロパティに対して、値をバインディングするだけのであれば、 map
と .assign(to:)
を使うと良いでしょう。 self
とは無縁のコードが書けます。
// Before init() { Timer.publish(every: 1, on: .main, in: .default) .autoconnect() .sink { [weak self] date in self?.time = date.timeIntervalSince1970 } .store(in: &cancellables) } // After init() { Timer.publish(every: 1, on: .main, in: .default) .autoconnect() .map { $0.timeIntervalSince1970 } .assign(to: &$time) }
これでコードがスッキリ書けて、メモリリークも起こらなくなります。しかも、Combineが購読のライフサイクルを管理してくれるので、 cancellables
も不要です。
3. StateObject(wrappedValue:)
を使わない
私たちのMVVM設計のアプリでは、 View
が ViewModel
を持ってます。値の変化がある場合は @StateObject
属性を付与しています。
@StateObject
の作成方法について、init(wrappedValue:) | Apple Developer Documentation では、このようにViewで作成するように書いています。
struct MyView: View { @StateObject var model = DataModel() ... }
しかし、 View
で ViewModel
を作成すると、初期値を渡すことができません。例えば、一覧画面から詳細画面に遷移する際、詳細画面に何らかのIDを渡すケースがほとんどですが、これでは使えません。
では、どうしたら良いかというと、下記のように、プロパティを定義して ViewModel
を View
から初期化して渡せば良いです。
// 遷移元画面 struct RootView: View { var body: some View { ... .sheet(isPresented: $isPresented) { // 遷移先の定義。ここでViewModelを作成する DetailView(viewModel: DetailViewModel(id: "hoge")) } } } // 遷移先画面 struct DetailView: View { @StateObject var viewModel: DetailViewModel ... }
DetailView
を開いて閉じると、問題なく ViewModel
が作られて、開放されます。
正常なメモリの状態
重要なポイントは、 View
の init
関数を用意しないことです。 View
で init
関数を用意すると、 @StateObject
を作るには StateObject(wrappedValue:)
のイニシャライザーを使う必要があります。しかし、 init(wrappedValue:) | Apple Developer Documentation では You don’t call this initializer directly.
と書かれており、使わないように注意しています。
その理由は明らかではないですが、私たちが確認した現象として、メモリ管理が最適化されなくなるのが理由だと思います。試しに下記のコードを用意しました。
// 遷移先画面 struct DetailView: View { @StateObject var viewModel: DetailViewModel init(viewModel: DetailViewModel) { // StateObjectを自分で作る self._viewModel = StateObject(wrappedValue: viewModel) } ... }
すると、メモリリークが発生します。
メモリリークの様子
DetailView
を開くと、 DetailViewModel
が2回初期化されたことが分かります。画面を閉じるとどちらも開放されるので、メモリリークとしては軽微ですが、気持ちいいものではないですね。
ViewModel
のプロパティを private
にしようとしたり、 View
の init
関数で他の引数を受けたいケースがあると思いますが、StateObject(wrappedValue:)
を使うとメモリが最適化されないことが分かったので、私たちは極力使わないようにしています。値をViewに渡したいときは、初期化しないプロパティを宣言して init
を避けましょう。
NavigationLink
で使う場合はさらに注意
補足として、 NavigationLink
の遷移先の View
で、StateObject(wrappedValue:)
を使うと、リンクをタップしなくても、すべての ViewModel
が作られてしまいます。パフォーマンスに大打撃なので、絶対にやめましょう!
// NavigationLink遷移元View struct ContentView: View { let ids = 1...100 var body: some View { NavigationView { VStack { ForEach(ids, id: \.self) { id in NavigationLink { DetailView(viewModel: DetailViewModel(id: "\(id)")) } label: { Text("\(id)") } } } } } } // NavigationLink遷移先View struct DetailView: View { @StateObject var viewModel: DetailViewModel init(viewModel: DetailViewModel) { // StateObjectを自分で作る self._viewModel = StateObject(wrappedValue: viewModel) } ... }
initでStateObjectを自分で作る場合、NavigationLinkの数だけViewModelができる
StateObject(wrappedValue:)を使わない場合、画面遷移して初めてViewModelが作られる
まとめ
今回は SwiftUI x Combineでのメモリリークを防ぐ3つのTipsをご紹介しました。
[weak self]
を忘れない.map
と.assign(to:)
を活用するStateObject(wrappedValue:)
を使わない
皆さんが開発するアプリの品質向上に役に立てば嬉しいです!
We’re hiring!
スタディサプリでは、世界の果てまで最高の学びを共に届ける仲間を募集しています。 少しでも気になった方はカジュアル面談もやっていますのでお気軽にお問い合わせください!