スタディサプリ Product Team Blog

株式会社リクルートが開発するスタディサプリのプロダクトチームのブログです

SwiftUI x Combineのメモリリークを防ぐ3つのTips

こんにちは!iOSエンジニアの @chuymaster です!私たちのチームが2年近くかけて開発した 「スタディサプリ 中学講座」iOSアプリがようやくリリースされました!🎉🎉 いやぁ〜みんなで頑張りましたよ!中学生のお子様がいらっしゃる方はぜひお試しください!

ところで、皆さんはメモリリークに気をつけていますか?スタディサプリ 中学講座」はMVVMアーキテクチャで、SwiftUIとCombineを使って開発をしています。SwiftUIのViewはとても軽いのでメモリ管理についてはあまり意識しなくてもいいですね。しかし、ViewModel の方は気をつけなければ循環参照が発生して、メモリリークが起きてしまうことがあります。それを防ぐ3つのTipsを、この記事で紹介します。ぜひ試してみてください!

前提

  • 本記事で紹介したコードと現象は、Xcode 13.2.1 / iOS 15.2のシミュレーターで再現しています。
  • SwiftUIとCombineを知っている方を読者と想定しています。

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)")
    }
}

vdo1.gif

画像から分かるように、 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)
}

vdo2.gif

これで、画面を閉じると 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設計のアプリでは、 ViewViewModel を持ってます。値の変化がある場合は @StateObject 属性を付与しています。

@StateObject の作成方法について、init(wrappedValue:) | Apple Developer Documentation では、このようにViewで作成するように書いています。

struct MyView: View {
    @StateObject var model = DataModel()
    ...
}

しかし、 ViewViewModel を作成すると、初期値を渡すことができません。例えば、一覧画面から詳細画面に遷移する際、詳細画面に何らかのIDを渡すケースがほとんどですが、これでは使えません。

では、どうしたら良いかというと、下記のように、プロパティを定義して ViewModelView から初期化して渡せば良いです。

// 遷移元画面
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 が作られて、開放されます。

vdo4.gif

正常なメモリの状態

重要なポイントは、 Viewinit 関数を用意しないことです。 Viewinit 関数を用意すると、 @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)
    }
    ...
}

すると、メモリリークが発生します。

vdo3.gif

メモリリークの様子

DetailView を開くと、 DetailViewModel が2回初期化されたことが分かります。画面を閉じるとどちらも開放されるので、メモリリークとしては軽微ですが、気持ちいいものではないですね。

ViewModel のプロパティを private にしようとしたり、 Viewinit 関数で他の引数を受けたいケースがあると思いますが、StateObject(wrappedValue:) を使うとメモリが最適化されないことが分かったので、私たちは極力使わないようにしています。値をViewに渡したいときは、初期化しないプロパティを宣言して init を避けましょう。

補足として、 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)
    }
    ...
}

Untitled

initでStateObjectを自分で作る場合、NavigationLinkの数だけViewModelができる

Untitled

StateObject(wrappedValue:)を使わない場合、画面遷移して初めてViewModelが作られる

まとめ

今回は SwiftUI x Combineでのメモリリークを防ぐ3つのTipsをご紹介しました。

  1. [weak self] を忘れない
  2. .map.assign(to:)を活用する
  3. StateObject(wrappedValue:) を使わない

皆さんが開発するアプリの品質向上に役に立てば嬉しいです!

We’re hiring!

スタディサプリでは、世界の果てまで最高の学びを共に届ける仲間を募集しています。 少しでも気になった方はカジュアル面談もやっていますのでお気軽にお問い合わせください!