Trabalhando com padrões estáticos: um tutorial do Swift MVVM
Publicados: 2022-03-11Hoje vamos ver como as novas possibilidades técnicas e expectativas de nossos usuários para aplicativos baseados em dados em tempo real criam novos desafios na forma como estruturamos nossos programas, especialmente nossos aplicativos móveis. Embora este artigo seja sobre iOS e Swift, muitos dos padrões e conclusões são igualmente aplicáveis a aplicativos Android e web.
Houve uma evolução importante na forma como os aplicativos móveis modernos funcionam nos últimos anos. Graças ao acesso à Internet mais difundido e tecnologias como notificações push e WebSockets, o usuário geralmente não é mais a única fonte de eventos de tempo de execução – e não necessariamente a mais importante – em muitos dos aplicativos móveis atuais.
Vamos dar uma olhada em quão bem dois padrões de design Swift funcionam com um aplicativo de bate-papo moderno: o padrão clássico model-view-controller (MVC) e um padrão imutável modelo-view-viewmodel simplificado (MVVM, às vezes estilizado “o padrão ViewModel "). Os aplicativos de bate-papo são um bom exemplo porque têm muitas fontes de dados e precisam atualizar suas interfaces de usuário de muitas maneiras diferentes sempre que os dados são recebidos.
Nosso aplicativo de bate-papo
O aplicativo que usaremos como guia neste tutorial do Swift MVVM terá a maioria dos recursos básicos que conhecemos de aplicativos de bate-papo como o WhatsApp. Vamos rever os recursos que vamos implementar e comparar MVVM vs MVC. A aplicação:
- Carregará os bate-papos recebidos anteriormente do disco
- Sincronizará os chats existentes por meio de uma solicitação
GET
com o servidor - Receberá notificações push quando uma nova mensagem for enviada ao usuário
- Será conectado a um WebSocket quando estivermos em uma tela de bate-papo
- Pode
POST
uma nova mensagem em um chat - Mostrará uma notificação no aplicativo quando uma nova mensagem for recebida de um bate-papo em que não estamos no momento
- Mostrará uma nova mensagem imediatamente quando recebermos uma nova mensagem para o chat atual
- Enviará uma mensagem lida quando lermos uma mensagem não lida
- Receberá uma mensagem lida quando alguém ler nossa mensagem
- Atualiza o emblema do contador de mensagens não lidas no ícone do aplicativo
- Sincroniza todas as mensagens recebidas ou alteradas de volta para Core Data
Neste aplicativo de demonstração, não haverá implementação real de API, WebSocket ou Core Data para manter a implementação do modelo um pouco mais simples. Em vez disso, adicionei um chatbot que começará a responder a você assim que você iniciar uma conversa. No entanto, todos os outros roteamentos e chamadas são implementados como seriam se o armazenamento e as conexões fossem reais, incluindo pequenas pausas assíncronas antes de retornar.
As três telas a seguir foram construídas:
MVC clássico
Em primeiro lugar, há o padrão MVC padrão para construir um aplicativo iOS. É assim que a Apple estrutura todo o seu código de documentação e como as APIs e os elementos da interface do usuário esperam funcionar. É o que a maioria das pessoas aprende quando faz um curso de iOS.
Muitas vezes, o MVC é culpado por levar a UIViewController
s inchados de alguns milhares de linhas de código. Mas se for bem aplicado, com uma boa separação entre cada camada, podemos ter ViewController
bem finos que atuam apenas como gerenciadores intermediários entre View
, Model
e outros Controller
.
Aqui está o fluxograma para a implementação do MVC do aplicativo (deixando de fora o CreateViewController
para maior clareza):
Vamos examinar as camadas em detalhes.
Modelo
A camada de modelo geralmente é a camada menos problemática no MVC. Nesse caso, optei por usar ChatWebSocket
, ChatModel
e PushNotificationController
para mediar entre os objetos Chat
e Message
, as fontes de dados externas e o restante do aplicativo. ChatModel
é a fonte da verdade dentro do aplicativo e só funciona na memória neste aplicativo de demonstração. Em um aplicativo da vida real, provavelmente seria apoiado por Core Data. Por fim, ChatEndpoint
lida com todas as chamadas HTTP.
Visualizar
As visualizações são bastante grandes, pois precisam lidar com muitas responsabilidades, pois separei cuidadosamente todo o código de visualização dos UIViewController
s. Eu fiz o seguinte:
- Usou o padrão de
enum
de estado (muito recomendável) para definir em que estado a visualização está atualmente. - Adicionadas as funções que são conectadas aos botões e outros itens de interface que acionam ações (como tocar em Retornar ao inserir um nome de contato.)
- Configure as restrições e ligue de volta para o delegado sempre.
Depois de lançar um UITableView
na mistura, as visualizações agora são muito maiores do que os UIViewController
s, levando a mais de 300 linhas de código preocupantes e muitas tarefas mistas no ChatView
.
Controlador
Como toda a lógica de manipulação de modelo foi movida para ChatModel
. Todo o código de exibição - que pode estar escondido aqui em projetos separados e menos otimizados - agora vive na exibição, então os UIViewController
s são bem pequenos. O controlador de exibição é completamente alheio à aparência dos dados do modelo, como eles são buscados ou como devem ser exibidos - apenas coordena. No projeto de exemplo, nenhum dos UIViewController
s ultrapassa 150 linhas de código.
No entanto, o ViewController ainda faz o seguinte:
- Ser um delegado para a exibição e outros controladores de exibição
- Instanciando e empurrando (ou popping) controladores de visão, se necessário
- Enviando e recebendo chamadas de e para o
ChatModel
- Iniciando e parando o WebSocket dependendo do estágio do ciclo do controlador de visualização
- Tomar decisões lógicas como não enviar uma mensagem se estiver vazia
- Atualizando a visualização
Isso ainda é muito, mas é principalmente coordenação, processamento de blocos de retorno de chamada e encaminhamento.
Benefícios
- Esse padrão é entendido por todos e promovido pela Apple
- Funciona com toda a documentação
- Não são necessários frameworks extras
Desvantagens
- Os controladores de exibição têm muitas tarefas; muitos deles estão basicamente passando dados entre a visão e a camada do modelo
- Não é muito adequado para lidar com várias fontes de eventos
- As turmas tendem a saber muito sobre outras turmas
Definição de problema
Isso funciona muito bem desde que o aplicativo siga as ações do usuário e responda a elas, como você imaginaria que um aplicativo como Adobe Photoshop ou Microsoft Word funcionaria. O usuário executa uma ação, as atualizações da interface do usuário, repetem.
Mas os aplicativos modernos estão conectados, geralmente de mais de uma maneira. Por exemplo, você interage por meio de uma API REST, recebe notificações push e, em alguns casos, também se conecta a um WebSocket.
Com isso, de repente o view controller precisa lidar com mais fontes de informação, e sempre que uma mensagem externa é recebida sem que o usuário a acione – como receber uma mensagem através do WebSocket – as fontes de informação precisam encontrar o caminho de volta para a direita ver controladores. Isso precisa de muito código apenas para colar todas as partes para executar o que é basicamente a mesma tarefa.
Fontes de dados externas
Vamos dar uma olhada no que acontece quando recebemos uma mensagem push:
class PushNotificationController { class func received(notification: UNNotification, whenProcessed result: (_ shouldShow: Bool) -> Void) { let shouldShowNotification: Bool defer { result(shouldShowNotification) } let content = notification.request.content let date = DateParser.date(from: content.subtitle) ?? Date() let sender: Message.Sender = .other(name: content.title) let pushedMessage = Message(with: sender, message: content.body, state: .sent, sendDate: date) ChatModel.received(message: pushedMessage, by: content.title) if let chatViewController = chatViewController(handling: content.title) { chatViewController.received(message: pushedMessage) shouldShowNotification = false } else { shouldShowNotification = true } updateChats(for: content.title) } private static func updateChats(for contact: String) { guard let chat = ChatModel.loadedChats.first(where: { (chat) -> Bool in chat.contact == contact }) else { return assertionFailure("Chat for received message should always exist") } BaseNavigationViewController.navigationController?.viewControllers.forEach({ (viewController) in switch viewController { case let chatsViewController as UpdatedChatDelegate: chatsViewController.updated(chat: chat) default: break } }) } private static func chatViewController(handling contact: String) -> ChatViewController? { guard let lastViewController = BaseNavigationViewController.navigationController?.viewControllers.last as? ChatViewController, lastViewController.chat.contact == contact else { return nil } return lastViewController } }
Temos que vasculhar a pilha de controladores de exibição manualmente para descobrir se há um controlador de exibição que precisa se atualizar após recebermos uma notificação por push. Neste caso, também queremos atualizar as telas que implementam o UpdatedChatDelegate
, que, neste caso, é apenas o ChatsViewController
. Também fazemos isso para saber se devemos suprimir a notificação porque já estamos vendo o bate- Chat
para o qual ela se destina. Nesse caso, finalmente entregamos a mensagem ao controlador de exibição. Está bem claro que o PushNotificationController
precisa saber muito sobre o aplicativo para poder fazer seu trabalho.
Se o ChatWebSocket
também estivesse entregando mensagens para outras partes do aplicativo, em vez de ter uma relação de um para um com o ChatViewController
, enfrentaríamos o mesmo problema lá.
É claro que temos que escrever um código bastante invasivo toda vez que adicionamos outra fonte externa. Esse código também é bastante frágil, pois depende muito da estrutura do aplicativo e delega a passagem de dados de volta para a hierarquia para funcionar.
Delegados
O padrão MVC também adiciona complexidade extra à mistura quando adicionamos outros controladores de exibição. Isso ocorre porque os controladores de exibição tendem a conhecer uns aos outros por meio de delegados, inicializadores e - no caso de storyboards prepareForSegue
ao passar dados e referências. Cada controlador de exibição lida com suas próprias conexões com o modelo ou controladores de mediação, e ambos estão enviando e recebendo atualizações.
Além disso, as visualizações se comunicam com os controladores de visualização por meio de delegados. Embora isso funcione, significa que há muitas etapas que precisamos seguir para passar os dados, e sempre me pego refatorando muito em torno de retornos de chamada e verificando se os delegados estão realmente definidos.
É possível quebrar um controlador de exibição alterando o código em outro, como dados obsoletos no ChatsListViewController
porque o ChatViewController
não está mais chamando updated(chat: Chat)
mais. Especialmente em cenários mais complexos, é difícil manter tudo em sincronia.
Separação entre Vista e Modelo
Ao remover todo o código relacionado à exibição do controlador de exibição para customView
s e mover todo o código relacionado ao modelo para controladores especializados, o controlador de exibição é bastante enxuto e separado. No entanto, ainda resta um problema: há uma lacuna entre o que a exibição deseja exibir e os dados que residem no modelo. Um bom exemplo é o ChatListView
. O que queremos exibir é uma lista de células que nos informam com quem estamos falando, qual foi a última mensagem, a data da última mensagem e quantas mensagens não lidas restam no Chat
:
No entanto, estamos passando um modelo que não sabe sobre o que queremos ver. Em vez disso, é apenas um Chat
com um contato, contendo mensagens:
class Chat { let contact: String var messages: [Message] init(with contact: String, messages: [Message] = []) { self.contact = contact self.messages = messages } }
Agora é possível adicionar rapidamente algum código extra que nos dará a última mensagem e a contagem de mensagens, mas formatar datas em strings é uma tarefa que pertence firmemente à camada de visualização:
var unreadMessages: Int { return messages.filter { switch ($0.sender, $0.state) { case (.user, _), (.other, .read): return false case (.other, .sending), (.other, .sent): return true } }.count } var lastMessage: Date? { return messages.last?.sendDate }
Então, finalmente, formatamos a data no ChatItemTableViewCell
quando a exibimos:
func configure(with chat: Chat) { participant.text = chat.contact lastMessage.text = chat.messages.last?.message ?? "" lastMessageDate.text = chat.lastMessage.map { lastMessageDate in DateRenderer.string(from: lastMessageDate) } ?? "" show(unreadMessageCount: chat.unreadMessages) }
Mesmo em um exemplo bastante simples, fica bem claro que há uma tensão entre o que a visão precisa e o que o modelo fornece.
Static Event-driven MVVM, também conhecido como Static Event-driven Take on “the ViewModel Pattern”
O MVVM estático funciona com modelos de exibição, mas em vez de criar tráfego bidirecional por meio deles - como costumávamos fazer por meio de nosso controlador de exibição com MVC - criamos modelos de exibição imutáveis que atualizam a interface do usuário toda vez que a interface do usuário precisa ser alterada em resposta a um evento .
Um evento pode ser acionado por quase qualquer parte do código, desde que seja capaz de fornecer os dados associados exigidos pelo evento enum
. Por exemplo, receber o evento received(new: Message)
pode ser acionado por uma notificação por push, o WebSocket ou uma chamada de rede normal.
Vejamos em um diagrama:
À primeira vista, parece ser um pouco mais complexo do que o exemplo clássico do MVC, pois há muito mais classes envolvidas para realizar exatamente a mesma coisa. Mas, olhando mais de perto, nenhuma das relações é mais bidirecional.
Ainda mais importante é que cada atualização da interface do usuário é acionada por um evento, portanto, há apenas uma rota pelo aplicativo para tudo o que acontece. Fica imediatamente claro quais eventos você pode esperar. Também está claro onde você deve adicionar um novo, se necessário, ou adicionar um novo comportamento ao responder a eventos existentes.
Após a refatoração, acabei com muitas novas classes, como mostrei acima. Você pode encontrar minha implementação da versão estática do MVVM no GitHub. No entanto, quando comparo as alterações com a ferramenta cloc
, fica claro que, na verdade, não há muito código extra:
Padrão | arquivos | Em branco | Comente | Código |
---|---|---|---|---|
MVC | 30 | 386 | 217 | 1807 |
MVVM | 51 | 442 | 359 | 1981 |
Há apenas um aumento de 9% nas linhas de código. Mais importante, o tamanho médio desses arquivos caiu de 60 linhas de código para apenas 39.
Também crucialmente, as maiores quedas podem ser encontradas nos arquivos que normalmente são os maiores no MVC: as visualizações e os controladores de visualização. As visualizações têm apenas 74% de seus tamanhos originais e os controladores de visualização agora têm apenas 53% de seu tamanho original.
Deve-se notar também que muito do código extra é código de biblioteca que ajuda a anexar blocos a botões e outros objetos na árvore visual, sem exigir os padrões @IBAction
ou delegados clássicos do MVC.
Vamos explorar as diferentes camadas deste design, uma por uma.
Evento
O evento é sempre um enum
, geralmente com valores associados. Muitas vezes, eles se sobrepõem a uma das entidades em seu modelo, mas não necessariamente. Nesse caso, o aplicativo é dividido em duas enum
de eventos principais: ChatEvent
e MessageEvent
. ChatEvent
é para todas as atualizações nos próprios objetos de chat:
enum ChatEvent { case started case loaded(chats: [Chat]) case creating(chat: Chat) case created(chat: Chat) case createChatFailed(reason: String) }
O outro trata de todos os eventos relacionados a mensagens:
enum MessageEvent { case sending(message: Message, contact: String, previousMessages: [Message]) case sent(message: Message, contact: String) case failedSending(message: Message, contact: String, reason: String) case received(message: Message, contact: String) case userReads(messagesSentBy: String) case userRead(othersMessages: [Message], sentBy: String) case otherRead(yourMessage: Message, reader: String) }
É importante limitar seus *Event
enum
s a um tamanho razoável. Se você precisar de 10 ou mais casos, isso geralmente é um sinal de que você está tentando cobrir mais de um assunto.
Nota: O conceito enum
é extremamente poderoso no Swift. Eu costumo usar muito enum
s com valores associados, pois eles podem eliminar muita ambiguidade que você teria com valores opcionais.
Tutorial Swift MVVM: Roteador de Eventos
O roteador de eventos é o ponto de entrada para cada evento que acontece no aplicativo. Qualquer classe que possa fornecer o valor associado pode criar um evento e enviá-lo ao roteador de eventos. Portanto, eles podem ser acionados por qualquer tipo de fonte, por exemplo:
- O usuário seguindo para um controlador de exibição específico
- O usuário tocando em um determinado botão
- O aplicativo iniciando
- Eventos externos como:
- Uma solicitação de rede retornando com uma falha ou novos dados
- Notificações via push
- Mensagens do WebSocket
O roteador de eventos deve saber o mínimo possível sobre a origem do evento e, de preferência, nada. Nenhum dos eventos neste aplicativo de exemplo tem qualquer indicador de onde eles vêm, então é muito fácil misturar qualquer tipo de fonte de mensagem. Por exemplo, o WebSocket aciona o mesmo evento — received(message: Message, contact: String)
— como uma nova notificação por push.

Os eventos são (você já adivinhou) roteados para as classes que precisam processar esses eventos. Normalmente, as únicas classes que são chamadas são a camada de modelo (se os dados precisarem ser adicionados, alterados ou removidos) e o manipulador de eventos. Discutirei ambos um pouco mais à frente, mas o principal recurso do roteador de eventos é fornecer um ponto de acesso fácil a todos os eventos e encaminhar o trabalho para outras classes. Aqui está o ChatEventRouter
como exemplo:
class ChatEventRouter { static func route(event: ChatEvent) { switch event { case .loaded(let chats): ChatEventHandler.loaded(chats: chats) case .creatingChat(let contact): let chat = ChatModel.create(chatWith: contact) ChatEndpoint.create(chat: chat) ChatEventHandler.creatingChat() case .created(let chat): ChatEventHandler.created(chat: chat) case .createChatFailed(let reason): ChatEventHandler.failedCreatingChat(reason: reason) } } }
Há muito pouco acontecendo aqui: a única coisa que estamos fazendo é atualizar o modelo e encaminhar o evento para o ChatEventHandler
para que a interface do usuário seja atualizada.
Tutorial Swift MVVM: Controlador de modelo
Esta é exatamente a mesma classe que usamos no MVC, pois já estava funcionando muito bem. Ele representa o estado do aplicativo e normalmente seria apoiado por Core Data ou uma biblioteca de armazenamento local.
Camadas de modelo – se implementadas corretamente no MVC – raramente precisam de refatoração para se adequar a diferentes padrões. A maior mudança é que a mudança do modelo acontece a partir de menos classes, deixando um pouco mais claro onde as mudanças acontecem.
Em uma alternativa a esse padrão, você pode observar as alterações no modelo e garantir que elas sejam tratadas. Nesse caso, optei por deixar apenas as *EventRouter
e *Endpoint
alterarem o modelo, para que haja uma responsabilidade clara de onde e quando o modelo é atualizado. Por outro lado, se estivéssemos observando mudanças, teríamos que escrever código adicional para propagar eventos que não alteram o modelo, como erros, por meio do ChatEventHandler
, o que tornaria menos óbvio como os eventos fluem pelo aplicativo.
Tutorial Swift MVVM: manipulador de eventos
O manipulador de eventos é o local onde as exibições ou controladores de exibição podem se registrar (e cancelar o registro) como ouvintes para receber modelos de exibição atualizados, que são criados sempre que o ChatEventRouter
chama uma função no ChatEventHandler
.
Você pode ver que ele reflete aproximadamente todos os estados de exibição que usamos no MVC antes. Se você quiser outros tipos de atualizações de interface do usuário - como som ou acionamento do mecanismo Taptic - eles também podem ser feitos aqui.
protocol ChatListListening: class { func updated(list: ChatListViewModel) } protocol CreateChatListening: class { func updated(create: CreateChatViewModel) } class ChatEventHandler { private static var chatListListening: [ChatListListening?] = [] private static var createChatListening: [CreateChatListening?] = [] class func add(listener: ChatListListening) { weak var weakListener = listener chatListListening.append(weakListener) } class func remove(listener: ChatListListening) { chatListListening = chatListListening.filter { $0 !== listener } } class func add(listener: CreateChatListening) { weak var weakListener = listener createChatListening.append(weakListener) listener.updated(create: CreateChatViewModelBuilder.build(isSending: false, error: nil)) } class func remove(listener: CreateChatListening) { createChatListening = createChatListening.filter { $0 !== listener } } class func started() { ChatEndpoint.fetchChats() let loadingViewModel = ChatListViewModelBuilder.buildLoading() chatListListening.forEach { $0?.updated(list: loadingViewModel) } } class func loaded(chats: [Chat]) { let chatList = ChatListViewModelBuilder.build(for: chats) chatListListening.forEach { $0?.updated(list: chatList) } } class func creatingChat() { let createChat = CreateChatViewModelBuilder.build(isSending: true, error: nil) createChatListening.forEach { $0?.updated(create: createChat) } } class func failedCreatingChat(reason: String) { let createChat = CreateChatViewModelBuilder.build(isSending: false, error: reason) createChatListening.forEach { $0?.updated(create: createChat) } } class func created(chat: Chat) { let createChat = CreateChatViewModelBuilder.build(isSending: false, error: nil) createChatListening.forEach { $0?.updated(create: createChat) } updateAllChatLists() let chatViewController = ChatViewController(for: chat) BaseNavigationViewController.pushViewController(chatViewController, animated: true, removePreviousFromStack: true) } class func updateAllChatLists() { let chatListViewModel = ChatListViewModelBuilder.build(for: ChatModel.allChats()) chatListListening.forEach { $0?.updated(list: chatListViewModel) } } }
Essa classe não faz nada além de garantir que o ouvinte correto possa obter o modelo de exibição correto sempre que um determinado evento ocorrer. Novos ouvintes podem obter um modelo de exibição imediatamente quando são adicionados, se isso for necessário para configurar seu estado inicial. Certifique-se sempre de adicionar uma referência weak
à lista para evitar ciclos de retenção.
Tutorial Swift MVVM: Ver modelo
Aqui está uma das maiores diferenças entre o que muitos padrões MVVM fazem e o que a variante estática faz. Nesse caso, o modelo de exibição é imutável em vez de se configurar como um intermediário permanente de duas vias entre o modelo e a exibição. Por que faríamos isso? Vamos fazer uma pausa para explicá-lo um momento.
Um dos aspectos mais importantes da criação de um aplicativo que funcione bem em todos os casos possíveis é garantir que o estado do aplicativo esteja correto. Se a interface do usuário não corresponder ao modelo ou tiver dados desatualizados, tudo o que fizermos poderá fazer com que dados incorretos sejam salvos ou o aplicativo falhe ou se comporte de maneira inesperada.
Um dos objetivos de aplicar esse padrão é que não temos estado no aplicativo, a menos que seja absolutamente necessário. O que é estado, exatamente? Estado é basicamente todo lugar onde armazenamos uma representação de um determinado tipo de dados. Um tipo especial de estado é o estado em que sua interface do usuário está atualmente, o que obviamente não podemos evitar com um aplicativo orientado por interface do usuário. Os outros tipos de estado são todos relacionados a dados. Se tivermos uma cópia de um array de Chat
s fazendo backup de nosso UITableView
na tela Chat List, esse é um exemplo de estado duplicado. Um modelo de visualização tradicional de duas vias seria outro exemplo de uma duplicata dos Chat
s do nosso usuário.
Ao passar um modelo de exibição imutável que é atualizado a cada alteração de modelo, eliminamos esse tipo de estado duplicado, porque depois que ele se aplica à interface do usuário, ele não é mais usado. Então, temos apenas os dois únicos tipos de estado que não podemos evitar – UI e model – e eles estão perfeitamente sincronizados um com o outro.
Portanto, o modelo de exibição aqui é bem diferente de alguns aplicativos MVVM. Ele serve apenas como um armazenamento de dados imutável para todos os sinalizadores, valores, blocos e outros valores que a visualização requer para refletir o estado do modelo, mas não pode ser atualizado de forma alguma pela visualização.
Portanto, pode ser uma struct
imutável simples. Para manter essa struct
o mais simples possível, vamos instanciá-la com um construtor de modelo de exibição. Uma das coisas interessantes sobre um modelo de exibição é que ele obtém sinalizadores comportamentais como shouldShowBusy
e shouldShowError
que substituem o mecanismo de enum
de estado encontrado anteriormente na exibição. Aqui estão os dados do ChatItemTableViewCell
que analisamos antes:
struct ChatListItemViewModel { let contact: String let message: String let lastMessageDate: String let unreadMessageCount: Int let itemTapped: () -> Void }
Como o construtor de modelo de exibição já cuida dos valores e ações exatos que a exibição precisa, todos os dados são pré-formatados. Outra novidade é um bloco que será acionado assim que um item for tocado. Vamos ver como isso é feito pelo construtor de modelos de exibição.
Ver Construtor de Modelos
O construtor de modelos de visualização pode construir instâncias de modelos de visualização, transformando entradas como Chat
s ou Message
s em modelos de visualização perfeitamente adaptados para uma determinada visualização. Uma das coisas mais importantes que acontecem no construtor de modelos de visualização é determinar o que realmente acontece dentro dos blocos no modelo de visualização. Os blocos anexados pelo construtor de modelos de visualização devem ser extremamente curtos, chamando funções de outras partes da arquitetura o mais rápido possível. Esses blocos não devem ter lógica de negócios.
class ChatListItemViewModelBuilder { class func build(for chat: Chat) -> ChatListItemViewModel { let lastMessageText = chat.messages.last?.message ?? "" let lastMessageDate = (chat.messages.last?.sendDate).map { DateRenderer.string(from: $0) } ?? "" let unreadMessageCount = ChatModel.unreadMessages(for: chat.contact).count return ChatListItemViewModel(contact: chat.contact, message: lastMessageText, lastMessageDate: lastMessageDate, unreadMessageCount: unreadMessageCount, itemTapped: { show(chat: chat) }) } private class func show(chat: Chat) { let chatViewController = ChatViewController(for: chat) BaseNavigationViewController.pushViewController(chatViewController, animated: true) } }
Agora toda a pré-formatação acontece no mesmo lugar e o comportamento é decidido aqui também. É uma classe bastante importante nessa hierarquia e pode ser interessante ver como os diferentes construtores no aplicativo de demonstração foram implementados e lidam com cenários mais complicados.
Tutorial do Swift MVVM: View Controller
O controlador de exibição nesta arquitetura faz muito pouco. Ele irá configurar e derrubar tudo relacionado à sua visão. É mais adequado fazer isso porque obtém todos os retornos de chamada do ciclo de vida necessários para adicionar e remover ouvintes no momento certo.
Às vezes, ele precisa atualizar um elemento da interface do usuário que não é coberto pela visualização raiz, como o título ou um botão na barra de navegação. É por isso que geralmente ainda registro o controlador de exibição como um ouvinte do roteador de eventos se tiver um modelo de exibição que cubra toda a exibição para o controlador de exibição fornecido; Eu encaminho o modelo de exibição para a exibição depois. Mas também não há problema em registrar qualquer UIView
como o ouvinte diretamente se houver uma parte da tela que tenha uma taxa de atualização diferente, por exemplo, um ticker de ações ao vivo no topo de uma página sobre uma determinada empresa.
O código para o ChatsViewController
agora é tão curto que leva menos de uma página. O que resta é substituir a visualização base, adicionar e remover o botão adicionar da barra de navegação, definir o título, adicionar a si mesmo como ouvinte e implementar o protocolo ChatListListening
:
class ChatsViewController: UIViewController { private lazy var customView: ChatsView = { let customView = ChatsView() return customView }() private var addButton: UIBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: nil, action: nil) override func loadView() { view = customView } override func viewDidLoad() { super.viewDidLoad() ChatEventHandler.add(listener: self) ChatEventRouter.route(event: .started) title = "Chats" } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) navigationItem.rightBarButtonItem = addButton } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) navigationItem.rightBarButtonItem = nil } } extension ChatsViewController: ChatListListening { func updated(list: ChatListViewModel) { addButton.action(block: { _ in list.addChat() }) customView.display(viewModel: list) } }
Não há mais nada que possa ser feito em outro lugar, pois o ChatsViewController
é reduzido ao mínimo.
Tutorial Swift MVVM: Visualizar
A visão na arquitetura MVVM imutável ainda pode ser bastante pesada, pois ainda possui uma lista de tarefas, mas consegui tirar dela as seguintes responsabilidades em relação à arquitetura MVC:
- Determinando o que precisa mudar em resposta a um novo estado
- Implementando delegados e funções para ações
- Lidar com gatilhos de visualização para visualização, como gestos e animações acionadas
- Transformando dados de forma que possam ser mostrados (como
Date
s paraString
s)
Especialmente o último ponto tem uma vantagem bastante grande. No MVC, quando o view ou view controller é responsável por transformar os dados para exibição, ele sempre fará isso no thread principal, pois é muito difícil separar as mudanças verdadeiras na interface do usuário que precisam acontecer nesse thread das coisas que são não é obrigado a rodar nele. E ter um código sem alteração da interface do usuário em execução no thread principal pode levar a um aplicativo menos responsivo.
Em vez disso, com esse padrão MVVM, tudo desde o bloco que é acionado por um toque até o momento em que o modelo de visualização é construído e será passado para o ouvinte - podemos executar tudo isso em um thread separado e apenas mergulhar no thread principal no end para fazer atualizações de interface do usuário. Se nosso aplicativo gastar menos tempo no encadeamento principal, ele funcionará de forma mais suave.
Depois que o modelo de exibição aplica o novo estado à exibição, ele pode evaporar em vez de permanecer como outra camada de estado. Tudo o que pode acionar um evento é anexado a um item na exibição e não nos comunicaremos com o modelo de exibição.
Uma coisa é importante lembrar: você não é forçado a mapear um modelo de exibição por meio de um controlador de exibição para uma exibição. Conforme mencionado anteriormente, partes da visualização podem ser gerenciadas por outros modelos de visualização, especialmente quando as taxas de atualização variam. Considere uma planilha do Google sendo editada por pessoas diferentes, mantendo um painel de bate-papo aberto para os colaboradores. Não é muito útil atualizar o documento sempre que uma mensagem de bate-papo chegar.
Um exemplo bem conhecido é uma implementação de tipo para localizar em que a caixa de pesquisa é atualizada com resultados mais precisos à medida que inserimos mais texto. É assim que eu implementaria o preenchimento automático na classe CreateAutocompleteView
: A tela inteira é atendida pelo CreateViewModel
, mas a caixa de texto está ouvindo o AutocompleteContactViewModel
.
Outro exemplo é usar um validador de formulário, que pode ser construído como um “loop local” (anexando ou removendo estados de erro a campos e declarando um formulário como válido) ou feito através do acionamento de um evento.
Modelos de visualização estática imutável fornecem melhor separação
Usando uma implementação MVVM estática, conseguimos finalmente separar completamente todas as camadas porque o modelo de exibição agora faz a ponte entre o modelo e a exibição. Também facilitamos o gerenciamento de eventos que não eram causados pela ação do usuário e removemos muitas das dependências entre as diferentes partes do nosso aplicativo. A única coisa que um controlador de exibição faz é registrar-se (e cancelar o registro) nos manipuladores de eventos como ouvinte dos eventos que deseja receber.
Benefícios:
- As implementações de view e view controller tendem a ser muito mais leves
- As aulas são mais especializadas e separadas
- Os eventos podem ser acionados facilmente de qualquer lugar
- Os eventos seguem um caminho previsível através do sistema
- O estado só é atualizado de um lugar
- App can be more performant as it's easier to do work off the main thread
- Views receive tailor-made view models and are perfectly separated from the models
Downsides:
- A full view model is created and sent every time the UI needs to update, often overwriting the same button text with the same button text, and replacing blocks with blocks that do exactly the same
- Requires some helper extensions to make button taps and other UI events work well with the blocks in the view model
- Event
enum
s can easily grow pretty large in complex scenarios and might be hard to split up
The great thing is that this is a pure Swift pattern: It does not require a third-party Swift MVVM framework, nor does it exclude the use of classic MVC, so you can easily add new features or refactor problematic parts of your application today without being forced to rewrite your whole application.
There are other approaches to combat large view controllers that provide better separation as well. I couldn't include them all in full detail to compare them, but let's take a brief look at some of the alternatives:
- Some form of the MVVM pattern
- Some form of Reactive (using RxSwift, sometimes combined with MVVM)
- The model-view-presenter pattern (MVP)
- The view-interactor-presenter-entity-router pattern (VIPER)
Traditional MVVM replaces most of the view controller code with a view model that is just a regular class and can be tested more easily in isolation. Since it needs to be a bi-directional bridge between the view and the model it often implements some form of Observables. That's why you often see it used together with a framework like RxSwift.
MVP and VIPER deal with extra abstraction layers between the model and the view in a more traditional way, while Reactive really remodels the way data and events flow through your application.
The Reactive style of programming is gaining a lot of popularity lately and actually is pretty close to the static MVVM approach with events, as explained in this article. The major difference is that it usually requires a framework, and a lot of your code is specifically geared towards that framework.
MVP is a pattern where both the view controller and the view are considered to be the view layer. The presenter transforms the model and passes it to the view layer, while I transform the data into a view model first. Since the view can be abstracted to a protocol, it's much easier to test.
VIPER takes the presenter from MVP, adds a separate “interactor” for business logic, calls the model layer “entity,” and has a router for navigation purposes (and to complete the acronym). It can be considered a more detailed and decoupled form of MVP.
So there you have it: static event-driven MVVM explained. I look forward to hearing from you in the comments below!