MVI和SwiftUI-一种状态





假设我们需要对屏幕的工作方式进行一些小的调整。屏幕每秒更改一次,因为同时有许多进程在进行。通常,为了确定所有屏幕状态,必须引用变量,每个变量都有自己的生命。牢记它们要么非常困难,要么完全不可能。要找到问题的根源,您将必须了解屏幕的变量和状态,甚至还要确保我们的修复程序不会破坏其他地方的内容。假设我们花了很多时间,但仍然进行了必要的编辑。是否可以更轻松,更快地解决此问题?让我们弄清楚。



MVI



这种模式最初由JavaScript开发人员Andre Stalz描述。一般原理可在链接中找到:







意图:等待用户处理事件并对其进行处理

模型:等待处理后的事件改变状态

视图:等待状态发生变化并向其显示

自定义元素:视图的子部分,它本身就是UI元素。可以实现为MVI或Web组件。在视图中是可选的。



面对被动的方法。每个模块(功能)都期待某个事件,并且在接收并处理该事件后,会将事件传递给下一个模块。事实证明是单向流。视图的单个状态驻留在模型中,这解决了许多难以跟踪的状态的问题。



如何将其应用于移动应用程序?



Martin Fowler和Rice Rice在他们的《企业应用程序模式》一书中写道,模式是解决问题的模式,与其一一对应地复制,不如将它们适应当前的现实。移动应用程序具有其自身的局限性和功能,必须加以考虑。View接收到来自用户的事件,然后可以将其代理到Intent。略微修改了该方案,但是模式的原理保持不变。







实作





下面将有很多代码。

最终代码可以在下面的扰流板下查看。



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






现在让我们开始分别检查每个模块。



在继续执行之前,我们需要对View进行扩展,这将简化代码的编写并使其更具可读性。



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




视图



视图-接受来自用户的事件,将其传递给Intent并等待模型的状态更改



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. View收到的所有事件都传递给Intent。意图本身会链接到视图的实际状态,因为改变状态的是他。@ObservedObject包装器是必需的,以便将在模型中发生的所有更改传送到“视图”(下面有更多详细信息)
  2. 简化了View的创建,因此更容易接受来自另一个屏幕的数据(例如RootView.build()HomeView.build(文章:42)
  3. 视图的生命周期事件发送Intent
  4. 创建自定义元素的函数
  5. 用户可以看到不同的屏幕状态,这完全取决于模型中的数据。如果intent.model.isLoading属性的boolean值为true,则用户看到加载,如果为false,则他看到加载的内容或错误。根据状态,用户将看到不同的Custom元素。


模型



模型-保持屏幕的实际状态



 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-等待View中的事件以采取进一步的措施。使用业务逻辑和数据库,向服务器发出请求等。



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. 为了更改Intent中的属性,我们将RootModelProperties转换为RootModel
  3. 意图一直在等待模型的属性更改并将其传递给视图。AnyCancellable允许您不要在内存中保留引用来等待对Model的更改。通过这种简单的方法,视图将获得最新状态。
  4. 此功能接收用户的事件并下载图片
  5. 这就是我们改变屏幕状态的方式


这种方法(依次更改状态)有一个缺点:如果模型具有很多属性,那么在更改属性时,您可能会忘记更改某些内容。



一种可能的解决方案
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)
... 




我相信这不是唯一的解决方案,您可以通过其他方式解决该问题。



还有一个缺点-Intent类可以随着许多业务逻辑的发展而增长。通过将业务逻辑拆分为服务来解决此问题。



导航呢?MVI + R



如果您设法在View中进行所有操作,则很可能不会出现问题。但是,如果逻辑变得更加复杂,则会出现许多困难。事实证明,要使路由器将数据传输到下一个屏幕并将数据返回到称为该屏幕的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. 将视图的生命周期事件发送到Intent


Intent收集过渡所需的所有数据



// 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并打开所需的屏幕


结论



像MVI一样,SwiftUI围绕反应性构建,因此它们很好地契合在一起。导航困难,逻辑复杂,意图大,但可以解决所有问题。MVI使您可以实现复杂的屏幕,并以最小的努力非常动态地更改屏幕的状态。当然,这种实现不是唯一正确的实现,总会有其他选择。但是,这种模式非常适合Apple的新UI方法。一类适用于所有屏幕状态的屏幕使操作屏幕变得更加容易。可以在GitHub上查看



本文中的代码以及Xcode模板



All Articles