Inicialmente, todo o projeto foi escrito em Objective-C e utilizado o ReactiveCocoa versão 2.0
A interação entre o View e o ViewModel foi realizada por meio de ligações das propriedades do modelo de exibição, e tudo ficaria bem, exceto que a depuração desse código era muito difícil. Tudo devido à falta de digitação e mingau no rastreamento da pilha :(
E agora é hora de usar o Swift. No início, decidimos tentar sem reatividade. Exibir métodos chamados explicitamente no ViewModel, e o ViewModel relatou suas alterações usando um delegado:
protocol ViewModelDelegate { func didUpdateTitle(newTitle: String) } class View: UIView, ViewModelDelegate { var viewModel: ViewModel func didUpdateTitle(newTitle: String) { //handle viewModel updates } } class ViewModel { weak var delegate: ViewModelDelegate? func handleTouch() { //respond to some user action } }
Parece bom. Mas conforme o ViewModel cresceu, começamos a obter vários métodos no delegado para lidar com todos os espirros produzidos pelo ViewModel:
protocol ViewModelDelegate { func didUpdate(title: String) func didUpdate(subtitle: String) func didReceive(items: [SomeItem]) func didReceive(error: Error) func didChangeLoading(isLoafing: Bool) //... }
Cada método precisa ser implementado e, como resultado, obtemos uma enorme pegada dos métodos na exibição. Não parece muito legal. Não é nada legal. Se você pensar bem, se usar o RxSwift, obterá uma situação semelhante, mas, em vez de implementar os métodos delegados, haveria um monte de ligantes para diferentes propriedades do ViewModel.
A saída se sugere: você precisa combinar todos os métodos em um e as propriedades de enumeração da seguinte forma:
enum ViewModelEvent { case updateTitle(String) case updateSubtitle(String) case items([SomeItem]) case error(Error) case loading(Bool) //... }
À primeira vista, a essência não muda. Mas, em vez de seis métodos, obtemos um com um switch:
func handle(event: ViewModelEvent) { switch event { case .updateTitle(let newTitle): //... case .updateSubtitle(let newSubtitle): //... case .items(let newItems): //... case .error(let error): //... case .loading(let isLoading): //... } }
Por simetria, você pode criar outra enumeração e seu manipulador no ViewModel:
enum ViewEvent { case touchButton case swipeLeft } class ViewModel { func handle(event: ViewEvent) { switch event { case .touchButton: //... case .swipeLeft: //... } } }
Tudo parece muito mais conciso, além de fornecer um único ponto de interação entre o View e o ViewModel, o que afeta muito bem a legibilidade do código. Acontece vantajoso para as duas partes - e a revisão da solicitação de recebimento é acelerada e os recém-chegados entram rapidamente no projeto.
Mas não uma panacéia. Os problemas começam a surgir quando um modelo de visualização deseja relatar seus eventos para várias visualizações, por exemplo, ContainerView e ContentView (um está incorporado no outro). A solução, novamente, surge por si só, escrevemos uma nova classe em vez do delegado:
class Output<Event> { var handlers = [(Event) -> Void]() func send(_ event: Event) { for handler in handlers { handler(event) } } }
Na propriedade handlers
, armazenamos marcadores com chamadas para os métodos handle(event:)
e, quando chamamos o método send(_ event:)
, chamamos todos os manipuladores com esse evento. E, novamente, o problema parece estar resolvido, mas toda vez que você liga o View - ViewModel, é necessário escrever o seguinte:
vm.output.handlers.append({ [weak view] event in DispatchQueue.main.async { view?.handle(event: event) } }) view.output.handlers.append({ [weak vm] event in vm?.handle(event: event) })
Não é muito legal.
Fechamos o View e o ViewModel com os protocolos:
protocol ViewModel { associatedtype ViewEvent associatedtype ViewModelEvent var output: Output<ViewModelEvent> { get } func handle(event: ViewEvent) func start() } protocol View: ViewModelContainer { associatedtype ViewModelEvent associatedtype ViewEvent var output: Output<ViewEvent> { get } func setupBindings() func handle(event: ViewModelEvent) }
Por que os métodos start()
e setupBindings()
são necessários - descreveremos mais adiante. Estamos escrevendo extensões para o protocolo:
extension View where Self: NSObject { func bind<ViewModelType: ViewModel>(with vm: ViewModelType?) where ViewModelType.ViewModelEvent == ViewModelEvent, ViewModelType.ViewEvent == ViewEvent { guard let vm = vm else { return } vm.output.handlers.append({ [weak self] event in DispatchQueue.main.async { self?.handle(event: event) } }) output.handlers.append({ [weak vm] event in vm?.handle(event: event) }) setupBindings() vm.start() } }
E temos um método pronto para vincular qualquer View - ViewModel, cujos eventos correspondem. O método start()
garante que, quando executado, a exibição já receberá todos os eventos que serão enviados do ViewModel, e o método setupBindings()
será necessário se você precisar colocar o ViewModel em suas próprias subvisões, para que esse método possa ser implementado por padrão na extensão ' e
Acontece que, para o relacionamento entre o View e o ViewModel, suas implementações específicas não são absolutamente importantes, o principal é que o View possa manipular eventos do ViewModel e vice-versa. E para armazenar na visualização não um link específico para o ViewModel, mas sua versão generalizada, você pode escrever um wrapper TypeErasure adicional (já que é impossível usar propriedades do tipo de protocolo com o tipo associatedtype
):
class AnyViewModel<ViewModelEvent, ViewEvent>: ViewModel { var output: Output<ViewModelEvent> let startClosure: EmptyClosure let handleClosure: (ViewEvent) -> Void let vm: Any? private var isStarted = false init?<ViewModelType: ViewModel>(with vm: ViewModelType?) where ViewModelType.ViewModelEvent == ViewModelEvent, ViewModelType.ViewEvent == ViewEvent { guard let vm = vm else { return nil } self.output = vm.output self.vm = vm self.startClosure = { [weak vm] in vm?.start() } self.handleClosure = { [weak vm] in vm?.handle(event: $0) }//vm.handle } func start() { if !isStarted { isStarted = true startClosure() } } func handle(event: ViewEvent) { handleClosure(event) } }
Ainda mais
Decidimos ir além e, obviamente, não armazenar a propriedade na exibição, mas defini-la durante o tempo de execução, no total, a extensão do protocolo View
foi assim:
extension View where Self: NSObject { func bind<ViewModelType: ViewModel>(with vm: ViewModelType?) where ViewModelType.ViewModelEvent == ViewModelEvent, ViewModelType.ViewEvent == ViewEvent { guard let vm = AnyViewModel(with: vm) else { return } vm.output.handlers.append({ [weak self] event in if #available(iOS 10.0, *) { RunLoop.main.perform(inModes: [.default], block: { self?.handle(event: event) }) } else { DispatchQueue.main.async { self?.handle(event: event) } } }) output.handlers.append({ [weak vm] event in vm?.handle(event: event) }) p_viewModelSaving = vm setupBindings() vm.start() } private var p_viewModelSaving: Any? { get { return objc_getAssociatedObject(self, &ViewModelSavingHandle) } set { objc_setAssociatedObject(self, &ViewModelSavingHandle, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } } var viewModel: AnyViewModel<ViewModelEvent, ViewEvent>? { return p_viewModelSaving as? AnyViewModel<ViewModelEvent, ViewEvent> } }
É um momento polêmico, mas decidimos que seria mais conveniente não declarar essa propriedade todas as vezes.
Padrões
Essa abordagem se encaixa perfeitamente nos modelos do Xcode e permite gerar módulos muito rapidamente em apenas alguns cliques. Exemplo de modelo para o View:
final class ___VARIABLE_moduleName___ViewController: UIView, View { var output = Output<___VARIABLE_moduleName___ViewModel.ViewEvent>() override func viewDidLoad() { super.viewDidLoad() setupViews() } private func setupViews() { //Do layout and more } func handle(event: ___VARIABLE_moduleName___ViewModel.ViewModelEvent) { } }
E para o ViewModel:
final class ___VARIABLE_moduleName___ViewModel: ViewModel { var output = Output<ViewModelEvent>() func start() { } func handle(event: ViewEvent) { } } extension ___VARIABLE_moduleName___ViewModel { enum ViewEvent { } enum ViewModelEvent { } }
E a criação da inicialização do módulo no código leva apenas três linhas:
let viewModel = SomeViewModel() let view = SomeView() view.bind(with: viewModel)
Conclusão
Como resultado, conseguimos uma maneira flexível de trocar mensagens entre o View e o ViewModel, que possui um único ponto de entrada e é bem baseado na geração de código do Xcode. Essa abordagem tornou possível acelerar o desenvolvimento de recursos e as revisões de solicitação de recebimento, além de aumentar a legibilidade e a simplicidade do código e simplificar a gravação de testes (devido ao fato de que, conhecendo a sequência desejada de recebimento de eventos do modelo de exibição, é fácil escrever testes de unidade com os quais essa sequência pode ser garantido). Embora essa abordagem tenha começado a ser usada conosco recentemente, esperamos que ela se justifique totalmente e simplifique bastante o desenvolvimento.
PS
E um pequeno anúncio para os amantes do desenvolvimento para iOS - já nesta quinta-feira, 25 de julho, realizaremos uma simulação do iOS no ART-SPACE , a entrada é gratuita, venha.