【SwiftUI】@EnvironmentObjectはシングルトンにした方が良い話

おはこんばんにちは。
気づいたらアプリエンジニアに転身していた和尚です。

今回は私がSwiftUIアプリを作ってきたなかで、ViewのProperty Wrapperである「[EnvironmentObject](https://developer.apple.com/documentation/swiftui/environmentobject)」はシングルトンにした方がいいなと思ったので、記事を書いていきたいと思います。

## 概要
SwiftUIのViewModelには、「StateObject」や「ObservedObject」といったProperty Wrapperを用いてViewと1対1の構造をとるViewModelと、「EnvironmentObject」という複数のView間で値を共有することのできるグローバルなViewModelの大きく2種類があります。（__解説のため、前者を「ViewModel」、後者を「GlobalViewModel」と呼びます__）

EnvironmentObjectについては詳しくは説明はしませんが、MVVMアーキテクチャを採用する場合はビジネスロジックはこのEnvironmentObjectを含めた全てのViewModelに記載していきます。

基本的にはSwiftUIでアプリを作成する場合、あるViewに対してのビジネスロジックはそのあるViewに紐づいたViewModelに書いていくことになります。ただ複数のViewを跨いで値を共有したい場合はGlobalViewModelを用いることになります。そういった作りをしていく中でも、ViewModelからGlobalViewModelのプロパティを弄ったり、関数を呼び出したい時があります。本件はその課題を解決するための記事となります。

## 解決したい課題
- ViewModelからGlobalViewModelのプロパティを変更したい
- ViewModelからGlobalViewModelの関数を呼び出したい

### これまでの自分の解決法
このような場合、自分はGlobalViewModelのインスタンスをViewからViewModelに逐一渡していました。

```swift:GlobalViewModel.swift
final class GlobalViewModel: ObservableObject {
    @Published var count: Int = .zero
    
    func countUp() {
        count += 1
    }
}
```

```swift:MainApp.swift
@main
struct MainAppApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView().environmentObject(GlobalViewModel())
        }
    }
}
```

```swift:ContentViewModel.swift
final class ContentViewModel: ObservableObject {
    func onTapButton(_ globalViewModel: GlobalViewModel) {
        globalViewModel.countUp()
    }
}
```

```swift:ContentView.swift
struct ContentView: View {
    @EnvironmentObject private var globalViewModel: GlobalViewModel
    @StateObject private var viewModel = ContentViewModel()
    
    var body: some View {
        VStack {
            Text("\(globalViewModel.count)")
            Button(action: {
                viewModel.onTapButton(globalViewModel)
            }) {
                Text("BUTTON")
            }
        }
        
    }
}
```
この実装方法の場合引数が多くなってしまい大変なのと、そもそもこのGlobalViewModelという存在自体が擬似シングルトンのような動きをしているため、だったら最初からシングルトンにしてしまえば良いのではないかと考えた結果以下のような実装に落ち着きました。

### GlobalViewModelをシングルトンにした書き方
```swift:GlobalViewModel.swift
final class GlobalViewModel: ObservableObject {
    static let shared: GlobalViewModel = .init() // シングルトンクラスへ
    private init() {}
    
    @Published var count: Int = .zero
    
    func countUp() {
        count += 1
    }
}
```

```swift:MainApp.swift
@main
struct SampleAppApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView().environmentObject(GlobalViewModel.shared)
        }
    }
}
```

```swift:ContentViewModel.swift
final class ContentViewModel: ObservableObject {
    func onTapButton() {
        GlobalViewModel.shared.countUp()
    }
}
```

```swift:ContentView.swift
struct ContentView: View {
    @EnvironmentObject private var globalViewModel: GlobalViewModel
    @StateObject private var viewModel = ContentViewModel()
    
    var body: some View {
        VStack {
            Text("\(globalViewModel.count)")
            Button(action: viewModel.onTapButton) {
                Text("BUTTON")
            }
        }
    }
}
```
GlobalViewModelのインスタンスをViewから渡す必要がなくなったので、かなりContentViewModelがスッキリしましたね。

## 注意点
GlobalViewModelをシングルトン化したことで、これまで`.sheet`や`.fullScreenCover`のViewに対しては`.environtmentObject()`でGlobalViewModelを設定しなければなりませんでしたが、設定しなくてもプロパティが共有されるようになりました。

またこれまでGlobalViewModelは`.environmentObject()`で設定したViewより下の階層のViewでのみ適用されていましたが、それも全部で共有されるようになるので扱いには注意が必要です。

## 後書き
いかがだったでしょうか。
既存のシステムにこの修正を加える場合は注意書きに書いたものを考慮する必要がありますが、ViewModel内からGlobalViewModelに直接アクセスできるためViewおよびViewModelがとてもスッキリするのでオススメです。

ではでは！