假设我们需要对屏幕的工作方式进行一些小的调整。屏幕每秒更改一次,因为同时有许多进程在进行。通常,为了确定所有屏幕状态,必须引用变量,每个变量都有自己的生命。牢记它们要么非常困难,要么完全不可能。要找到问题的根源,您将必须了解屏幕的变量和状态,甚至还要确保我们的修复程序不会破坏其他地方的内容。假设我们花了很多时间,但仍然进行了必要的编辑。是否可以更轻松,更快地解决此问题?让我们弄清楚。
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()
}
}
- View收到的所有事件都传递给Intent。意图本身会链接到视图的实际状态,因为改变状态的是他。@ObservedObject包装器是必需的,以便将在模型中发生的所有更改传送到“视图”(下面有更多详细信息)
- 简化了View的创建,因此更容易接受来自另一个屏幕的数据(例如RootView.build()或HomeView.build(文章:42))
- 将视图的生命周期事件发送到Intent
- 创建自定义元素的函数
- 用户可以看到不同的屏幕状态,这完全取决于模型中的数据。如果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?
}
- 为了仅显示视图,需要使用协议来显示UI
- 在视图中进行响应数据传输需要@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()
}
}
- 该意图包含指向模型的链接,并在必要时更改模型的数据。RootModelIng是一种协议,它显示模型的属性并防止更改它们
- 为了更改Intent中的属性,我们将RootModelProperties转换为RootModel
- 意图一直在等待模型的属性更改并将其传递给视图。AnyCancellable允许您不要在内存中保留引用来等待对Model的更改。通过这种简单的方法,视图将获得最新状态。
- 此功能接收用户的事件并下载图片
- 这就是我们改变屏幕状态的方式
这种方法(依次更改状态)有一个缺点:如果模型具有很多属性,那么在更改属性时,您可能会忘记更改某些内容。
一种可能的解决方案
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>()
- 入口点。通过该属性,我们将引用路由器
为了不阻塞主视图,与过渡到其他屏幕有关的所有内容都在单独的视图中删除
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)
}
}
- 一个单独的视图,其中包含与导航相关的所有逻辑和自定义元素
- 将视图的生命周期事件发送到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))
}
}
- 如果由于某种原因没有图片,则它将所有必要的数据传输到模型以显示错误
- 将必要的数据发送到模型以打开带有图片详细说明的屏幕
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()
}
}
- 枚举屏幕所需的数据
- 事件将通过此属性发送。通过事件,我们将了解应显示哪个屏幕
- 需要此属性来存储用于打开屏幕的数据。
- 从false更改为true并打开所需的屏幕
结论
像MVI一样,SwiftUI围绕反应性构建,因此它们很好地契合在一起。导航困难,逻辑复杂,意图大,但可以解决所有问题。MVI使您可以实现复杂的屏幕,并以最小的努力非常动态地更改屏幕的状态。当然,这种实现不是唯一正确的实现,总会有其他选择。但是,这种模式非常适合Apple的新UI方法。一类适用于所有屏幕状态的屏幕使操作屏幕变得更加容易。可以在GitHub上查看
本文中的代码以及Xcode模板。