Digamos que precisamos fazer uma pequena mudança no funcionamento da tela. A tela muda a cada segundo porque há muitos processos acontecendo ao mesmo tempo. Como regra, para resolver todos os estados da tela, é necessário fazer referência a variáveis, cada uma com sua própria vida. Mantê-los em mente é muito difícil ou completamente impossível. Para encontrar a origem do problema, você terá que entender as variáveis e os estados da tela e até mesmo ter certeza de que nossa correção não danifique algo em outro lugar. Digamos que passamos muito tempo e ainda fizemos as edições necessárias. Foi possível resolver esse problema de forma mais fácil e rápida? Vamos descobrir.
MVI
Esse padrão foi descrito pela primeira vez pelo desenvolvedor de JavaScript Andre Stalz. Os princípios gerais podem ser encontrados no link
Intent : espera por eventos do usuário e os processa
Model : espera por eventos processados para mudar o estado
View : espera por mudanças de estado e mostra-os
Elemento personalizado : uma subseção da View, que é um elemento de UI. Pode ser implementado como MVI ou como um componente da web. Opcional na visualização.
Em face de uma abordagem reativa. Cada módulo (função) espera algum evento e, após recebê-lo e processá-lo, passa esse evento para o próximo módulo. Acontece um fluxo unidirecional. O único estado da View reside no Model e, portanto, resolve o problema de muitos estados difíceis de rastrear.
Como isso pode ser aplicado em um aplicativo móvel?
Martin Fowler e Rice David escreveram em seu livro "Patterns of Enterprise Applications" que os padrões são padrões para resolver problemas e, em vez de copiar um para um, é melhor adaptá-los às realidades atuais. O aplicativo móvel tem suas próprias limitações e recursos que devem ser levados em consideração. View recebe um evento do usuário e, em seguida, pode ser enviado por proxy para o Intent. O esquema é ligeiramente modificado, mas o princípio do padrão permanece o mesmo.
Implementação
Haverá muito código abaixo.
O código final pode ser visto no spoiler abaixo.
Implementação MVI
Visão
Modelo
Intenção
Roteador
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()
}
}
Modelo
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
}
}
}
Intenção
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))
}
}
Roteador
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()
}
}
Agora vamos examinar cada módulo separadamente.
Antes de prosseguir com a implementação, precisamos de uma extensão para a View, que irá simplificar a escrita do código e torná-lo mais legível.
extension View {
func toAnyView() -> AnyView {
AnyView(self)
}
}
Visão
View - aceita eventos do usuário, passa-os para o Intent e aguarda uma mudança de estado do modelo
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()
}
}
- Todos os eventos que a View recebe são passados para o Intent. Intent mantém um link para o estado real da View em si, uma vez que é ele quem muda os estados. O wrapper @ObservedObject é necessário para transferir para o View todas as mudanças que ocorrem no modelo (mais detalhes abaixo)
- Simplifica a criação de uma Visualização, portanto é mais fácil aceitar dados de outra tela (exemplo RootView.build () ou HomeView.build (articul: 42) )
- Envia o evento do ciclo de vida da Visualização para o Intent
- Funções que criam elementos personalizados
- O usuário pode ver diferentes estados de tela, tudo depende de quais dados estão no modelo. Se o valor booleano do atributo intent.model.isLoading for verdadeiro , o usuário verá o carregamento; se for falso, ele verá o conteúdo carregado ou um erro. Dependendo do estado, o usuário verá diferentes elementos personalizados.
Modelo
Modelo - mantém o estado real da tela
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?
}
- O protocolo é necessário para mostrar à Visualização apenas o que é necessário para exibir a IU
- @Published é necessário para transferência de dados reativos na Visualização
Intenção
Inent - aguarda eventos da Visualização para ações futuras. Trabalha com lógica de negócios e bancos de dados, faz solicitações ao servidor, etc.
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()
}
}
- O intent contém um link para o modelo e, quando necessário, altera os dados do modelo. RootModelIng é um protocolo que mostra os atributos do modelo e não permite que sejam alterados
- Para alterar os atributos no Intent, convertemos RootModelProperties em RootModel
- O Intent está constantemente esperando que os atributos do Model mudem e os passa para a View. AnyCancellable permite que você não mantenha na memória uma referência para esperar por mudanças do Model. Desta forma simples, a View obtém o estado mais atual.
- Esta função recebe um evento do usuário e baixa uma imagem
- É assim que mudamos o estado da tela
Essa abordagem (mudar os estados por sua vez) tem uma desvantagem: se o modelo tiver muitos atributos, então, ao mudar os atributos, você pode esquecer de mudar alguma coisa.
Uma solução possível
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)
...
Acredito que essa não seja a única solução e que você pode resolver o problema de outras maneiras.
Há outra desvantagem - a classe Intent pode crescer muito com muita lógica de negócios. Esse problema é resolvido dividindo a lógica de negócios em serviços.
E a navegação? MVI + R
Se você conseguir fazer tudo no modo de exibição, provavelmente não haverá problemas. Mas se a lógica se tornar mais complicada, surgirão várias dificuldades. Como se viu, fazer um Roteador com transferência de dados para a próxima tela e retornar os dados de volta para a View que chamava essa tela não é tão fácil. A transferência de dados pode ser feita por meio de @EnvironmentObject, mas todas as visualizações abaixo da hierarquia terão acesso a esses dados, o que não é bom. Recusamos essa ideia. Como os estados da tela mudam por meio do modelo, nos referimos ao roteador por meio dessa entidade.
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>()
- Ponto de entrada. Por meio deste atributo, nos referiremos a Roteador
Para não obstruir a Visualização principal, tudo relacionado às transições para outras telas é retirado em uma Visualização separada
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)
}
}
- Uma visão separada que contém toda a lógica e elementos personalizados relacionados à navegação
- Envia o evento do ciclo de vida da Visualização para o Intent
O Intent coleta todos os dados necessários para a transição
// 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))
}
}
- Se por algum motivo não houver imagem, ele transfere todos os dados necessários ao modelo para mostrar o erro
- Envia os dados necessários para a Modelo abrir uma tela com a descrição detalhada da imagem
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()
}
}
- Enum com dados necessários para telas
- Os eventos serão enviados por meio deste atributo. Por eventos, vamos entender qual tela deve ser mostrada
- Este atributo é necessário para armazenar dados para abrir a tela.
- Mude de falso para verdadeiro e a tela necessária é aberta
Conclusão
O SwiftUI, como o MVI, é construído em torno da reatividade, então eles se encaixam bem. Existem dificuldades com navegação e grande Intent com lógica complexa, mas tudo pode ser resolvido. O MVI permite implementar telas complexas e, com esforço mínimo, alterar o estado da tela de maneira muito dinâmica. Essa implementação, claro, não é a única correta, sempre há alternativas. No entanto, o padrão se encaixa perfeitamente com a nova abordagem de IU da Apple. Uma classe para todos os estados da tela torna muito mais fácil trabalhar com a tela.
O código do artigo , bem como os modelos para Xcode, podem ser visualizados no GitHub.