SwiftUI で EnvironmentValues にクロージャを入れると再描画が毎回起こる問題

こんにちは!亀山です。今回は、SwiftUIを使用して開発を進めている際に、パフォーマンスに影響を与える問題について解説します。この問題に気づいたのは、Mastodon用クライアントアプリケーション「feather」の開発中でした。問題の原因は、EnvironmentValues にクロージャを格納していることでした。この記事では、この問題の根本原因と適切な対処法について詳しく説明します。

クロージャを格納する例とパフォーマンス問題

まず、パフォーマンス問題を引き起こす EnvironmentValues にクロージャを格納する例を、具体的なサンプルコードとともに解説します。

import SwiftUI

struct ExampleEnvironmentKey: EnvironmentKey {
    static var defaultValue: () -> Void = {}
}

extension EnvironmentValues {
    var example: () -> Void {
        get { self[ExampleEnvironmentKey.self] }
        set { self[ExampleEnvironmentKey.self] = newValue }
    }
}

struct ChildView: View {
    @Environment(\.example) var example

    var body: some View {
        Button("Push me") {
            example()
        }
    }
}

struct ContentView: View {
    @State var count = 0

    var body: some View {
        VStack {
            Text("count: \(count)")

            ChildView()
                .environment(\.example) {
                    count += 1
                }
        }
        .padding()
    }
}

このサンプルコードでは、ExampleEnvironmentKey という EnvironmentKey を定義しています。EnvironmentValues の拡張により、クロージャ型のプロパティ example を追加しています。次に、ChildView という子ビューがあり、このビューではボタンをクリックすると example クロージャが実行されます。

問題は、ContentView で子ビュー ChildView の environment にクロージャを設定する部分です。この方法では、ContentView が再描画されるたびにクロージャが新しく生成されるため、パフォーマンスに影響が出ます。

Binding を用いた対処法とその限界

この問題に対処するためには、environment にクロージャを直接設定するのではなく、外部から渡されるデータやアクションをバインドする方法が推奨されます。例えば、ChildView に @Bindingプロパティラッパーを使用して count を渡す方法です。以下の修正済みコードでは、ChildView が count を @Binding で受け取り、ContentView からバインディングを介して count を更新することができます。

struct ChildView: View {
    @Binding var count: Int

    var body: some View {
        Button("Push me") {
            count += 1
        }
    }
}

struct ContentView: View {
    @State var count = 0

    var body: some View {
        VStack {
            Text("count: \(count)")

            ChildView(count: $count)
        }
        .padding()
    }
}

この方法では、ChildView が再描画されるたびに新しいクロージャが生成されることはなくなりました。しかし、Binding は値が変化したときに再描画を起こすため、この方法でも ChildView の再描画を防ぐことはできません。

また、@Binding を使った方法は API アクセスなどの非同期処理を含むケースでは、そのままでは適用できません。

非同期処理を含むケースへの対応

以下の例では、ContentViewModel を ContentView に追加し、ContentViewModel 内に @Published var count = 0 と非同期でカウントアップを行う requestCountUp() メソッドを実装しています。requestCountUp は非同期処理のダミーとしてスリープを入れています。

import SwiftUI

struct CountUpEnvironmentKey: EnvironmentKey {
    static var defaultValue: () -> Void = {}
}

extension EnvironmentValues {
    var requestCountUp: () -> Void {
        get { self[CountUpEnvironmentKey.self] }
        set { self[CountUpEnvironmentKey.self] = newValue }
    }
}

@MainActor
class ContentViewModel: ObservableObject {
    @Published var count = 0

    func requestCountUp() async {
        try? await Task.sleep(nanoseconds: 500_000_000)
        count += 1
    }
}

struct ChildView: View {
    @Environment(\.requestCountUp) var requestCountUp

    var body: some View {
        Button("Push me") {
            requestCountUp()
        }
    }
}

struct ContentView: View {
    @StateObject var viewModel = ContentViewModel()

    var body: some View {
        VStack {
            Text("count: \(viewModel.count)")

            ChildView()
                .environment(\.requestCountUp) {
                    Task {
                        await viewModel.requestCountUp()
                    }
                }
        }
        .padding()
    }
}

前述のコードでは、ChildView に environment を渡す際にクロージャを使用しています。これにより、ContentView が再描画されるたびに新しいクロージャが生成され、パフォーマンスの問題が発生する可能性があります。

この問題を回避しようとして、ContentViewModel に同期メソッド countUp() を追加し、それを環境プロパティの第2引数に直接指定する方法を試すかもしれません。

class ContentViewModel: ObservableObject {
    // ...
    func countUp() {
        Task {
            await requestCountUp()
        }
    }
}

// ...

ChildView()
    .environment(\.requestCountUp, viewModel.countUp)

しかし、この方法でも効果がありません。Swift ではクロージャの比較ができません。つまり、新しいクロージャが生成されるたびに、Swift は前のクロージャと新しいクロージャが同じかどうかを判断できないため、再描画が発生します。

ViewModel を直接渡す方法

この問題を解決するために、環境プロパティを介さずに ChildView に直接 viewModel を渡す方法があります。

struct ChildView: View {
    @ObservedObject var viewModel: ContentViewModel

    var body: some View {
        Button("Push me") {
            Task {
                await viewModel.requestCountUp()
            }
        }
    }
}

struct ContentView: View {
    @StateObject var viewModel = ContentViewModel()

    var body: some View {
        VStack {
            Text("count: \(viewModel.count)")

            ChildView(viewModel: viewModel)
        }
        .padding()
    }
}

この方法では、ChildView が再描画されるたびに新しいクロージャが生成されることはなく、パフォーマンスの問題が解消されます。しかし子ビューが直接 ViewModel を参照することは再利用性を下げるためあまりオススメできません。

Combine の Subject を渡す方法

Combine の Subject を渡す方法も考えられます。以下に PassthroughSubject を environment で渡す例を示します。

struct CountUpEnvironmentKey: EnvironmentKey {
    static var defaultValue = ObservableBox(value: PassthroughSubject<Void, Never>())
}

extension EnvironmentValues {
    var requestCountUp: ObservableBox<PassthroughSubject<Void, Never>> {
        get { self[CountUpEnvironmentKey.self] }
        set { self[CountUpEnvironmentKey.self] = newValue }
    }
}

struct CountUpAction {
    let action: () -> Void
}

struct ChildView: View {
    @Environment(\.requestCountUp) var requestCountUp

    var body: some View {
        Button("Push me") {
            requestCountUp.value.send(())
        }
    }
}

class ObservableBox<T>: ObservableObject {
    let value: T

    init(value: T) {
        self.value = value
    }
}

struct ContentView: View {
    @StateObject var viewModel = ContentViewModel()
    @StateObject var countUpSubject = ObservableBox(value: PassthroughSubject<Void, Never>())

    var body: some View {
        VStack {
            Text("count: \(viewModel.count)")

            ChildView()
                .environment(\.requestCountUp, countUpSubject)
        }
        .padding()
        .onReceive(countUpSubject.value) { _ in
            viewModel.countUp()
        }
    }
}

このコードでは、直接 viewModel を ChildView には渡さず、countUpSubject を ChildView に渡します。再描画の問題を回避するために countUpSubject は @StateObject で保持します。また、PassthroughSubject は ObservableObject を実装していないので、@StateObject で保持できるように ObservableBox を追加しています。

feather ではこのパターンを簡単に書けるように、以下のようなクラスを実装しました。

import Combine
import SwiftUI

class EnvironmentAction<Parameter> {
    typealias ActionType = EnvironmentSubject<Parameter>
}

extension EnvironmentAction: EnvironmentKey {
    static var defaultValue: EnvironmentSubject<Parameter> {
        .init()
    }
}

final class EnvironmentSubject<Parameter>: ObservableObject {
    private let subject = PassthroughSubject<Parameter, Never>()

    @MainActor
    func callAsFunction(_ parameter: Parameter) {
        subject.send(parameter)
    }
}

extension EnvironmentSubject: Publisher {
    typealias Output = Parameter
    typealias Failure = Never

    func receive<S>(subscriber: S) where S: Subscriber, Never == S.Failure, Parameter == S.Input {
        subject.receive(subscriber: subscriber)
    }
}

extension EnvironmentSubject where Parameter == Void {
    @MainActor
    func callAsFunction() {
        subject.send(())
    }
}

使用例

struct ParentView: View {
    @StateObject var viewModel: ViewModel
    @StateObject var fooActionHandler: EnvironmentSubject<FooAction>

    var body: some View {
        Subview()
            .environment(\.fooActionHandler, fooActionHandler)
            .onReceive(fooActionHandler) {
               viewModel.handleFoo($0)
            }
    }
}

EnvironmentKey を実装しているので下記のように簡単に EnvironmentValues を定義できます。

 class FooActionHandler: EnvironmentSubject<FooAction> {}

 extension EnvironmentValues {
     var fooActionHandler: FooActionHandler.ActionType {
         get { self[FooActionHandler.self] }
         set { self[FooActionHandler.self] = newValue }
     }
 }

まとめ

SwiftUIの開発では、適切なデータフローを意識することが重要です。今回の例のようなパフォーマンス問題を回避するため、データやアクションのバインディングを適切に行い、効率的なコードを書くことが重要です。対処法として、直接 viewModel を渡す方法や Combine の Subject を渡す方法を検討することができます。これらの方法を適切に利用することで、パフォーマンスに影響を与える問題を克服することができます。