MVIとSwiftUI-1つの状態





画面の動作を少し調整する必要があるとしましょう。多くのプロセスが同時に進行しているため、画面は毎秒変化します。原則として、すべての画面状態を解決するには、それぞれに独自の寿命がある変数を参照する必要があります。それらを念頭に置くことは非常に難しいか、完全に不可能です。問題の原因を見つけるには、画面の変数と状態を理解する必要があります。また、修正によって他の場所で問題が発生しないことを確認する必要があります。多くの時間を費やし、それでも必要な編集を行ったとしましょう。この問題をより簡単かつ迅速に解決することは可能でしたか?それを理解しましょう。



MVI



このパターンは、JavaScript開発者のAndreStalzによって最初に説明されました。一般的な原則は、リンクにあります。







インテント:ユーザーからのイベントを待機して処理します

モデル処理されたイベントが状態を変更するのを待機し

ますビュー:状態の変更を待機して表示します

カスタム要素:ビューのサブセクションであり、それ自体がUI要素です。 MVIまたはWebコンポーネントとして実装できます。ビューではオプション。



反応的なアプローチに直面して。各モジュール(関数)はイベントを予期し、それを受信して​​処理した後、このイベントを次のモジュールに渡します。一方向の流れになります。ビューの単一の状態はモデルに存在し、これにより、追跡が困難な多くの状態の問題が解決されます。



これをモバイルアプリケーションにどのように適用できますか?



MartinFowlerとRiceDavidは、著書「Patterns of Enterprise Applications」で、パターンは問題を解決するためのパターンであり、1対1でコピーするのではなく、現在の現実に適応させる方がよいと書いています。モバイルアプリケーションには、考慮しなければならない独自の制限と機能があります。ビューはユーザーからイベントを受信し、それをインテントにプロキシできます。スキームは少し変更されていますが、パターンの原理は同じです。







実装





以下にたくさんのコードがあります。

最終的なコードは、以下のスポイラーの下で表示できます。



MVIの実装
見る



import SwiftUI

struct RootView: View {

    // Or @StateObject for iOS 14
    @ObservedObject private var intent: RootIntent

    var body: some View {
        ZStack {
            imageView()
                .onTapGesture(perform: intent.onTapImage)
            errorView()
            loadView()
        }
        .overlay(RootRouter(screen: intent.model.routerSubject))
        .onAppear(perform: intent.onAppear)
    }

    static func build() -> some View {
        let model = RootModel()
        let intent = RootIntent(model: model)
        let view = RootView(intent: intent)
        return view
    }
}

// MARK: - Private - Views
private extension RootView {

    private func imageView() -> some View {
        Group { () -> AnyView  in
            if let image = intent.model.image {
                return Image(uiImage: image)
                    .resizable()
                    .toAnyView()
            } else {
                return Color.gray.toAnyView()
            }
        }
        .cornerRadius(6)
        .shadow(radius: 2)
        .frame(width: 100, height: 100)
    }

    private func loadView() -> some View {
        guard intent.model.isLoading else {
            return EmptyView().toAnyView()
        }
        return ZStack {
            Color.white
            Text("Loading")
        }.toAnyView()
    }

    private func errorView() -> some View {
        guard intent.model.error != nil else {
            return EmptyView().toAnyView()
        }
        return ZStack {
            Color.white
            Text("Fail")
        }.toAnyView()
    }
}




モデル



import SwiftUI
import Combine

protocol RootModeling {
    var image: UIImage? { get }
    var isLoading: Bool { get }
    var error: Error? { get }
    var routerSubject: PassthroughSubject<RootRouter.ScreenType, Never> { get }
}

class RootModel: ObservableObject, RootModeling {

    enum StateType {
        case loading, show(image: UIImage), failLoad(error: Error)
    }

    @Published private(set) var image: UIImage?
    @Published private(set) var isLoading: Bool = true
    @Published private(set) var error: Error?

    let routerSubject = PassthroughSubject<RootRouter.ScreenType, Never>()

    func update(state: StateType) {
        switch state {
        case .loading:
            isLoading = true
            error = nil
            image = nil

        case .show(let image):
            self.image = image
            isLoading = false

        case .failLoad(let error):
            self.error = error
            isLoading = false
        }
    }
}




意図



import SwiftUI
import Combine

class RootIntent: ObservableObject {

    let model: RootModeling

    private var rootModel: RootModel! { model as? RootModel }
    private var cancellable: Set<AnyCancellable> = []

    init(model: RootModeling) {
        self.model = model
        cancellable.insert(rootModel.objectWillChange.sink { self.objectWillChange.send() })
    }
}

// MARK: - API
extension RootIntent {

    func onAppear() {
        rootModel?.update(state: .loading)

        let url: URL! = URL(string: "https://upload.wikimedia.org/wikipedia/commons/f/f4/Honeycrisp.jpg")
        let task = URLSession.shared.dataTask(with: url) { [weak self] (data, _, error) in
            guard let data = data, let image = UIImage(data: data) else {
                DispatchQueue.main.async {
                    self?.rootModel?.update(state: .failLoad(error: error ?? NSError()))
                    self?.rootModel?.routerSubject.send(.alert(title: "Error",
                                                               message: "It was not possible to upload a image"))
                }
                return
            }
            DispatchQueue.main.async {
                self?.rootModel?.update(state: .show(image: image))
            }
        }
        task.resume()
    }

    func onTapImage() {
        guard let image = rootModel?.image else {
            rootModel?.routerSubject.send(.alert(title: "Error", message: "Failed to open the screen"))
            return
        }
        rootModel?.routerSubject.send(.descriptionImage(image: image))
    }
}




ルーター



import SwiftUI
import Combine

struct RootRouter: View {

    enum ScreenType {
        case alert(title: String, message: String)
        case descriptionImage(image: UIImage)
    }

    let screen: PassthroughSubject<ScreenType, Never>

    @State private var screenType: ScreenType? = nil
    @State private var isFullImageVisible = false
    @State private var isAlertVisible = false

    var body: some View {
        Group {
            alertView()
            descriptionImageView()
        }.onReceive(screen, perform: { type in
            self.screenType = type
            switch type {
            case .alert:
                self.isAlertVisible = true

            case .descriptionImage:
                self.isFullImageVisible = true
            }
        })
    }
}

private extension RootRouter {

    private func alertView() -> some View {
        guard let type = screenType, case .alert(let title, let message) = type else {
            return EmptyView().toAnyView()
        }
        return Spacer().alert(isPresented: $isAlertVisible, content: {
            Alert(title: Text(title), message: Text(message))
        }).toAnyView()
    }

    private func descriptionImageView() -> some View {
        guard let type = screenType, case .descriptionImage(let image) = type else {
            return EmptyView().toAnyView()
        }
        return Spacer().sheet(isPresented: $isFullImageVisible, onDismiss: {
            self.screenType = nil
        }, content: {
            DescriptionImageView.build(image: image, action: { _ in
                // code
            })
        }).toAnyView()
    }
}






それでは、各モジュールを個別に調べてみましょう。



実装を進める前に、ビューの拡張機能が必要です。これにより、コードの記述が簡素化され、読みやすくなります。



extension View {
    func toAnyView() -> AnyView {
        AnyView(self)
    }
}




見る



表示-ユーザーからのイベントを受け入れ、それらをインテントに渡し、モデルからの状態変更を待ちます



import SwiftUI

struct RootView: View {

    // 1
    @ObservedObject private var intent: RootIntent

    var body: some View {
        ZStack {
   	       // 4
            imageView()
            errorView()
            loadView()
        }
        // 3
        .onAppear(perform: intent.onAppear)
    }

    // 2
    static func build() -> some View {
        let intent = RootIntent()
        let view = RootView(intent: intent)
        return view
    }

    private func imageView() -> some View {
        Group { () -> AnyView  in
		 // 5
            if let image = intent.model.image {
                return Image(uiImage: image)
                    .resizable()
                    .toAnyView()
            } else {
                return Color.gray.toAnyView()
            }
        }
        .cornerRadius(6)
        .shadow(radius: 2)
        .frame(width: 100, height: 100)
    }

    private func loadView() -> some View {
	   // 5
        guard intent.model.isLoading else {
            return EmptyView().toAnyView()
        }
        return ZStack {
            Color.white
            Text("Loading")
        }.toAnyView()
    }

    private func errorView() -> some View {
	   // 5
        guard intent.model.error != nil else {
            return EmptyView().toAnyView()
        }
        return ZStack {
            Color.white
            Text("Fail")
        }.toAnyView()
    }
}


  1. ビューが受信するすべてのイベントは、インテントに渡されます。状態を変更するのは彼であるため、インテントはビュー自体の実際の状態へのリンクを保持します。モデルで発生するすべての変更をビューに転送するには、@ ObservedObjectラッパーが必要です(詳細は以下を参照)。
  2. ビューの作成が簡単になるため、別の画面からデータを受け入れるのが簡単になります(例:RootView.build(またはHomeView.build(記事:42)
  3. ライフサイクルイベント送信ビューテント
  4. カスタム要素を作成する関数
  5. ユーザーはさまざまな画面の状態を確認できます。これはすべて、モデル内のデータによって異なります。intent.model.isLoading属性のブール値がtrueの場合、ユーザーには読み込みが表示されます。falseの場合、読み込み済みのコンテンツまたはエラーが表示されます。状態に応じて、ユーザーにはさまざまなカスタム要素が表示されます。


モデル



モデル-画面の実際の状態を保持します



 import SwiftUI

// 1
protocol RootModeling {
    var image: UIImage? { get }
    var isLoading: Bool { get }
    var error: Error? { get }
}

class RootModel: ObservableObject, RootModeling {
    // 2
    @Published var image: UIImage?
    @Published var isLoading: Bool = true
    @Published var error: Error?
} 


  1. UIを表示するために必要なものだけをビューに表示するには、プロトコルが必要です
  2. ビューでのリアクティブデータ転送には@Publishedが必要です


意図



Inent-ビューからのイベントを待機して、さらにアクションを実行します。ビジネスロジックやデータベースと連携し、サーバーにリクエストを送信します。



import SwiftUI
import Combine

class RootIntent: ObservableObject {

    // 1
    let model: RootModeling

    // 2
    private var rootModel: RootModel! { model as? RootModel }

    // 3
    private var cancellable: Set<AnyCancellable> = []

    init() {
        self.model = RootModel()

	  // 3
        let modelCancellable = rootModel.objectWillChange.sink { self.objectWillChange.send() }
        cancellable.insert(modelCancellable)
    }
}

// MARK: - API
extension RootIntent {

    // 4
    func onAppear() {
	  rootModel.isLoading = true
	  rootModel.error = nil


        let url: URL! = URL(string: "https://upload.wikimedia.org/wikipedia/commons/f/f4/Honeycrisp.jpg")
        let task = URLSession.shared.dataTask(with: url) { [weak self] (data, _, error) in
            guard let data = data, let image = UIImage(data: data) else {
                DispatchQueue.main.async {
		       // 5
                    self?.rootModel.error = error ?? NSError()
                    self?.rootModel.isLoading = false
                }
                return
            }
            DispatchQueue.main.async {
		   // 5
                self?.model.image = image
                self?.model.isLoading = false
            }
        }

        task.resume()
    }
} 


  1. インテントにはモデルへのリンクが含まれており、必要に応じてモデルのデータを変更します。RootModelIngは、モデルの属性を表示し、それらが変更されないようにするプロトコルです。
  2. インテントの属性を変更するために、RootModelPropertiesをRootModelに変換します
  3. インテントは、モデルの属性が変更されるのを常に待機し、それらをビューに渡します。AnyCancellableを使用すると、モデルからの変更を待機するための参照をメモリに保持しないことができます。この簡単な方法で、ビューは最新の状態を取得します。
  4. この関数は、ユーザーからイベントを受け取り、画像をダウンロードします
  5. これが画面の状態を変更する方法です


このアプローチ(順番に状態を変更する)には欠点があります。モデルに多くの属性がある場合、属性を変更するときに、何かを変更するのを忘れることができます。



1つの可能な解決策
protocol RootModeling {
    var image: UIImage? { get }
    var isLoading: Bool { get }
    var error: Error? { get }
}

class RootModel: ObservableObject, RootModeling {

    enum StateType {
        case loading, show(image: UIImage), failLoad(error: Error)
    }

    @Published private(set) var image: UIImage?
    @Published private(set) var isLoading: Bool = true
    @Published private(set) var error: Error?

    func update(state: StateType) {
        switch state {
        case .loading:
            isLoading = true
            error = nil
            image = nil

        case .show(let image):
            self.image = image
            isLoading = false

        case .failLoad(let error):
            self.error = error
            isLoading = false
        }
    }
}

// MARK: - API
extension RootIntent {

    func onAppear() {
	   rootModel?.update(state: .loading)
... 




これが唯一の解決策ではなく、他の方法で問題を解決できると私は信じています。



もう1つの欠点があります。Intentクラスは、多くのビジネスロジックで大きく成長する可能性があります。この問題は、ビジネスロジックをサービスに分割することで解決されます。



ナビゲーションはどうですか?MVI + R



Viewですべてを実行できれば、おそらく問題はありません。しかし、ロジックがより複雑になると、いくつかの問題が発生します。結局のところ、次の画面にデータを転送して、この画面を呼び出したビューにデータを戻すルーターを作成するのはそれほど簡単ではありません。データ転送は@EnvironmentObjectを介して実行できますが、階層の下のすべてのビューがこのデータにアクセスできるため、適切ではありません。私たちはこの考えを拒否します。画面の状態はモデルによって変化するため、このエンティティを介してルーターを参照します。



protocol RootModeling {
    var image: UIImage? { get }
    var isLoading: Bool { get }
    var error: Error? { get }

    // 1
    var routerSubject: PassthroughSubject<RootRouter.ScreenType, Never> { get }
}

class RootModel: ObservableObject, RootModeling {

    // 1
    let routerSubject = PassthroughSubject<RootRouter.ScreenType, Never>() 


  1. エントリーポイント。この属性を通じて、ルーターを参照します


メインビューを詰まらせないために、他の画面への遷移に関連するすべてが別のビューに取り出されます



 struct RootView: View {

    @ObservedObject private var intent: RootIntent

    var body: some View {
        ZStack {
            imageView()
		   // 2
                .onTapGesture(perform: intent.onTapImage)
            errorView()
            loadView()
        }
	  // 1
        .overlay(RootRouter(screen: intent.model.routerSubject))
        .onAppear(perform: intent.onAppear)
    }
} 


  1. ナビゲーションに関連するすべてのロジック要素とカスタム要素を含む個別のビュー
  2. ビューのライフサイクルイベントをインテントに送信します


インテントは、移行に必要なすべてのデータを収集します



// MARK: - API
extension RootIntent {

    func onTapImage() {
        guard let image = rootModel?.image else {
	      // 1
            rootModel?.routerSubject.send(.alert(title: "Error", message: "Failed to open the screen"))
            return
        }
        // 2
        model.routerSubject.send(.descriptionImage(image: image))
    }
} 


  1. 何らかの理由で画像がない場合は、必要なすべてのデータをモデルに転送してエラーを表示します
  2. 必要なデータをモデルに送信して、画像の詳細な説明を含む画面を開きます




import SwiftUI
import Combine

struct RootRouter: View {

    // 1
    enum ScreenType {
        case alert(title: String, message: String)
        case descriptionImage(image: UIImage)
    }

    // 2
    let screen: PassthroughSubject<ScreenType, Never>


    // 3
    @State private var screenType: ScreenType? = nil


    // 4
    @State private var isFullImageVisible = false
    @State private var isAlertVisible = false

    var body: some View {
	  Group {
            alertView()
            descriptionImageView()
        }
	  // 2
        .onReceive(screen, perform: { type in
            self.screenType = type
            switch type {
            case .alert:
                self.isAlertVisible = true

            case .descriptionImage:
                self.isFullImageVisible = true
            }
        }).overlay(screens())
    }

    private func alertView() -> some View {
	  // 3
        guard let type = screenType, case .alert(let title, let message) = type else {
            return EmptyView().toAnyView()
        }
	  
        // 4
        return Spacer().alert(isPresented: $isAlertVisible, content: {
            Alert(title: Text(title), message: Text(message))
        }).toAnyView()
    }

    private func descriptionImageView() -> some View {
	  // 3
        guard let type = screenType, case .descriptionImage(let image) = type else {
            return EmptyView().toAnyView()
        }

        // 4
        return Spacer().sheet(isPresented: $isFullImageVisible, onDismiss: {
            self.screenType = nil
        }, content: {
            DescriptionImageView.build(image: image)
        }).toAnyView()
    }
}


  1. 画面に必要なデータを含む列挙
  2. イベントはこの属性を介して送信されます。イベントごとに、どの画面を表示するかがわかります
  3. この属性は、画面を開くためのデータを格納するために必要です。
  4. falseからtrueに変更すると、必要な画面が開きます


結論



SwiftUIは、MVIと同様に、反応性を中心に構築されているため、うまく適合します。ナビゲーションには問題があり、複雑なロジックでは大きなインテントがありますが、すべてを解決できます。MVIを使用すると、複雑な画面を実装し、最小限の労力で、画面の状態を非常に動的に変更できます。もちろん、この実装だけが正しい実装ではありません。常に代替手段があります。ただし、このパターンはAppleの新しいUIアプローチにうまく適合します。すべての画面状態に対して1つのクラスを使用すると、画面の操作がはるかに簡単になります。



記事からコード、ならびにXcodeのためのテンプレートは、 GitHubの上で見ることができます。



All Articles