🦋

SwiftUIでMVVMやるシンプルなパターン3つ

2022/03/24に公開
2

1. ViewModelをViewでバケツリレー

メリット 簡単
デメリット 小孫Viewの使い回しができない(ViewがViewModelに依存してしまう)

項目 特記事項
Model 特になし
ViewModel - ObservableObjectプロトコルに準拠する
- modelに@Publishedアノテーションをつける
View - 親ViewのviewModelに@StateObjectアノテーションをつける
- 子孫ViewのviewModelには@ObservedObjectアノテーションをつける
App
@main
struct SampleApp: App {
    var body: some Scene {
        WindowGroup {
            SampleView(viewModel: SampleViewModel(model: SampleModel()))
        }
    }
}
Model
struct SampleModel {
    var count: Int = 0
    var stars: String = "★"
    
    mutating func changeStarsLength(_ len: Int) {
        self.stars = [String](repeating: "★", count: len).joined()
    }
}
ViewModel
@MainActor
class SampleViewModel: ObservableObject {
    @Published var model: SampleModel
    
    init(model: SampleModel) {
        self.model = model
    }
    
    var count: Int {
        get {
            return model.count
        }
        set {
            model.count = newValue
        }
    }
    
    var stars: String {
        return model.stars
    }
    
    func changeStarsLength(_ len: Int) {
        model.changeStarsLength(len)
    }
}
View
struct SampleView: View {
    @StateObject var viewModel: SampleViewModel
    
    var body: some View {
        VStack(spacing: 8) {
            Text("\(viewModel.count)")
            Button("Count Up") {
                viewModel.count += 1
            }
            SampleSubView(viewModel: viewModel)
        }
    }
}

struct SampleSubView: View {
    @ObservedObject var viewModel: SampleViewModel
    
    init(viewModel: SampleViewModel) {
        self.viewModel = viewModel
    }
    
    var body: some View {
        VStack(spacing: 8) {
            Text(viewModel.stars)
            Button("Change Stars Length") {
                viewModel.changeStarsLength(Int.random(in: 1 ..< 10))
            }
        }
    }
}

2. プロパティはBinding、メソッドはクロージャーで保持

メリット 小孫Viewが使い回し可能になる
デメリット とくになし?

項目 特記事項
Model 特になし
ViewModel - ObservableObjectプロトコルに準拠する
- modelに@Publishedアノテーションをつける
- 子ViewでBindingしたいComputed Propertyにはsetterを定義する
View - 親ViewのviewModelに@StateObjectアノテーションをつける
- 小孫ViewでViewModelのメソッドを叩く時はクロージャーを保持するようにする

パターン1からModelには変化なし

ViewModel
@MainActor
class SampleViewModel: ObservableObject {
    @Published var model: SampleModel
    
    init(model: SampleModel) {
        self.model = model
    }
    
    var count: Int {
        get {
            return model.count
        }
        set {
            model.count = newValue
        }
    }
    
    // 子ViewでBindingするためにはsetterが必要
    var stars: String {
        get {
            return model.stars
        }
        set {
            model.stars = newValue
        }
    }
    
    func changeStarsLength(_ len: Int) {
        model.changeStarsLength(len)
    }
}
View
struct SampleView: View {
    @StateObject var viewModel: SampleViewModel
    
    var body: some View {
        VStack(spacing: 8) {
            Text("\(viewModel.count)")
            Button("Count Up") {
                viewModel.count += 1
            }
	    // $をつけて渡す
            SampleSubView(stars: $viewModel.stars) { len in
	        // クロージャーで処理を渡す
                viewModel.changeStarsLength(len)
            }
        }
    }
}

struct SampleSubView: View {
    @Binding private var stars: String
    private var handler: (Int) -> Void
    
    init(stars: Binding<String>, action handler: @escaping (Int) -> Void) {
        self._stars = stars
        self.handler = handler  // クロージャーを保持
    }
    
    var body: some View {
        VStack(spacing: 8) {
            Text(stars)
            Button("Change Stars Length") {
                handler(Int.random(in: 1 ..< 10))
            }
        }
    }
}

3. EnvironmentObjectでシングルトンなViewModelにアクセス

メリット バケツリレーしなくていい
デメリット 小孫Viewの使い回しができない(ViewがViewModelに依存してしまう)

項目 特記事項
App - .environmentObject()でViewにViewModelを渡しておく
Model 特になし
ViewModel - ObservableObjectプロトコルに準拠する
- modelに@Publishedアノテーションをつける
View - ViewModelが必要な各Viewで@EnvironmentObjectアノテーションをつけてviewModelを定義
App
@main
struct Sample3App: App {
    var body: some Scene {
        WindowGroup {
            SampleView()
                .environmentObject(SampleViewModel(model: SampleModel()))
        }
    }
}

パターン1からModel、ViewModelには変化なし

View
struct SampleView: View {
    @EnvironmentObject var viewModel: SampleViewModel
    
    var body: some View {
        VStack(spacing: 8) {
            Text("\(viewModel.count)")
            Button("Count Up") {
                viewModel.count += 1
            }
            SampleSubView()
        }
    }
}

struct SampleSubView: View {
    @EnvironmentObject var viewModel: SampleViewModel
    
    var body: some View {
        VStack(spacing: 8) {
            Text(viewModel.stars)
            Button("Change Stars Length") {
                viewModel.changeStarsLength(Int.random(in: 1 ..< 10))
            }
        }
    }
}

番外:ViewModelとModelのやりとりをCombineでやる

Computed PropertyではなくCombineのPublisherを使ってViewModelとModelのやりとりをやる。

Model
// class にしてプロパティに@Publishedアノテーションをつける
final class SampleModel {
    @Published var count: Int = 0
    @Published var stars: String = "★"
    
    func changeStarsLength(_ len: Int) {
        self.stars = [String](repeating: "★", count: len).joined()
    }
}
ViewModelの例1
@MainActor
class SampleViewModel: ObservableObject {
    @Published var count: Int
    @Published var stars: String
    
    private var model: SampleModel

    init(model: SampleModel) {
        self.model = model	
	// 初期値を渡す
        self.count = model.count
        self.stars = model.stars

        // modelの値が変わったらviewModelに反映させる
        self.model.$count.assign(to: &$count)
        self.model.$stars.assign(to: &$stars)
    }
    
    func changeStarsLength(_ len: Int) {
        model.changeStarsLength(len)
    }
}

assignではなくsinkを使う手もある。

ViewModelの例2
@MainActor
class SampleViewModel: ObservableObject {
    @Published var count: Int
    @Published var stars: String
    
    private var model: SampleModel
    private var cancellables = Set<AnyCancellable>()

    init(model: SampleModel) {
        self.model = model
        self.count = model.count
        self.stars = model.stars
                
        self.model.$count
            .sink(receiveValue: { count in
                self.count = count
            })
            .store(in: &cancellables)

        self.model.$stars
            .sink(receiveValue: { stars in
                self.stars = stars
            }).store(in: &cancellables)
    }
    
    func changeStarsLength(_ len: Int) {
        model.changeStarsLength(len)
    }
}

Discussion