
Olá Habr!
Meu nome é Valera e, há dois anos, desenvolvo um aplicativo iOS como parte da equipe do Badoo. Uma de nossas prioridades é fácil de manter o código. Devido ao grande número de novos recursos que caem em nossas mãos semanalmente, precisamos primeiro pensar na arquitetura do aplicativo; caso contrário, será extremamente difícil adicionar um novo recurso ao produto sem interromper os existentes. Obviamente, isso também se aplica à implementação da interface do usuário (UI), independentemente de isso ser feito usando código, Xcode (XIB) ou uma abordagem mista. Neste artigo, descreverei algumas técnicas de implementação da interface do usuário que nos permitem simplificar o desenvolvimento da interface do usuário, tornando-a flexível e conveniente para o teste. Há também uma versão em
inglês deste artigo.
Antes de começar ...
Considerarei as técnicas de implementação da interface do usuário usando um aplicativo de exemplo escrito em Swift. O aplicativo com o clique de um botão mostra uma lista de amigos.
Consiste em três partes:
- Componentes são componentes de interface do usuário personalizados, ou seja, código relacionado apenas à interface do usuário.
- Aplicativo de demonstração - modelos de exibição de demonstração e outras entidades da interface do usuário que possuem apenas dependências da interface do usuário.
- O aplicativo real é exibir modelos e outras entidades que podem conter dependências e lógicas específicas.
Por que existe essa separação? Responderei a essa pergunta abaixo, mas, por enquanto, confira a interface do usuário do nosso aplicativo:
Esta é uma visualização pop-up com conteúdo sobre outra visualização em tela cheia. Tudo é simples.
O código fonte completo do projeto está disponível no
GitHub .
Antes de me aprofundar no código da interface do usuário, quero apresentar a classe auxiliar Observable usada aqui. Sua interface é assim:
var value: T func observe(_ closure: @escaping (_ old: T, _ new: T) -> Void) -> ObserverProtocol func observeNewAndCall(_ closure: @escaping (_ new: T) -> Void) -> ObserverProtocol
Ele simplesmente notifica todos os observadores assinados anteriormente sobre as alterações, portanto, esse é um tipo de alternativa ao KVO (observação de valor-chave) ou, se você preferir, programação reativa. Aqui está um exemplo de uso:
self.observers.append(self.viewModel.items.observe { [weak self] (_, newItems) in self?.state = newItems.isEmpty ? .zeroCase(type: .empty) : .normal self?.collectionView.reloadSections(IndexSet(integer: 0)) })
O controlador assina alterações na propriedade
self.viewModel.items
e, quando a alteração ocorre, o manipulador executa a lógica de negócios. Por exemplo, ele atualiza o estado da exibição e recarrega a exibição da coleção com novos itens.
Você verá mais exemplos de uso abaixo.
Metodologias
Nesta seção, falarei sobre quatro técnicas de desenvolvimento de UI usadas no Badoo:
1. Implementação da interface do usuário no código.
2. Usando âncoras de layout.
3. Componentes - dividir e conquistar.
4. Separação da interface do usuário e lógica.
# 1: Implementando a interface do usuário no código
No Badoo, a maioria dos interesses dos usuários é implementada no código. Por que não usamos XIBs ou storyboards? Questão justa. O principal motivo é a conveniência de manter o código para uma equipe de tamanho médio, a saber:
- as alterações no código são claramente visíveis, o que significa que não há necessidade de analisar o arquivo XML storyboard / XIB para encontrar as alterações feitas por um colega;
- sistemas de controle de versão (por exemplo, Git) são muito mais fáceis de trabalhar com código do que com arquivos XLM "pesados", especialmente durante conflitos moderados; também é levado em consideração que o conteúdo dos arquivos XIB / storyboard muda sempre que são salvos, mesmo que a interface não seja alterada (embora eu tenha ouvido falar que no Xcode 9 esse problema já foi corrigido);
- pode ser difícil modificar e manter algumas propriedades no Interface Builder (IB), por exemplo, propriedades do CALayer durante o processo de relançamento de visualizações filho (sub-visualizações de layout), o que pode levar a várias fontes de verdade para o estado de exibição;
- O Interface Builder não é a ferramenta mais rápida e, às vezes, é muito mais rápido trabalhar diretamente com o código.
Dê uma olhada no seguinte controlador (FriendsListViewController):
final class FriendsListViewController: UIViewController { struct ViewConfig { let backgroundColor: UIColor let cornerRadius: CGFloat } private var infoView: FriendsListView! private let viewModel: FriendsListViewModelProtocol private let viewConfig: ViewConfig init(viewModel: FriendsListViewModelProtocol, viewConfig: ViewConfig) { self.viewModel = viewModel self.viewConfig = viewConfig super.init(nibName: nil, bundle: nil) } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() self.setupContainerView() } private func setupContainerView() { self.view.backgroundColor = self.viewConfig.backgroundColor let infoView = FriendsListView( frame: .zero, viewModel: self.viewModel, viewConfig: .defaultConfig) infoView.backgroundColor = self.viewConfig.backgroundColor self.view.addSubview(infoView) self.infoView = infoView infoView.translatesAutoresizingMaskIntoConstraints = false infoView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor).isActive = true infoView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor).isActive = true infoView.topAnchor.constraint(equalTo: self.view.topAnchor).isActive = true infoView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor).isActive = true }
Este exemplo mostra que você pode criar um controlador de visualização apenas fornecendo um modelo de visualização e uma configuração de visualização. Você pode ler mais sobre modelos de apresentação, ou seja, o modelo de design do MVVM (Model-View-ViewModel)
aqui . Como a configuração da visualização é uma entidade estrutural simples que define o layout e o estilo da visualização, ou seja, recuo, tamanho, cor, fonte etc., acho apropriado fornecer uma configuração padrão como esta:
extension FriendsListViewController.ViewConfig { static var defaultConfig: FriendsListViewController.ViewConfig { return FriendsListViewController.ViewConfig(backgroundColor: .white, cornerRadius: 16) } }
Toda a inicialização da visualização ocorre no método
setupContainerView
, que é chamado apenas uma vez de viewDidLoad quando a visualização já está criada e carregada, mas ainda não desenhada na tela, ou seja, todos os elementos necessários (sub-visualizações) são simplesmente adicionados à hierarquia da visualização e, em seguida, a marcação é aplicada (layout) e estilos.
Aqui está a aparência do controlador de exibição agora:
final class FriendsListPresenter: FriendsListPresenterProtocol { // … func presentFriendsList(from presentingViewController: UIViewController) { let controller = Class.createFriendsListViewController( presentingViewController: presentingViewController, headerViewModel: self.headerViewModel, contentViewModel: self.contentViewModel) controller.modalPresentationStyle = .overCurrentContext controller.modalTransitionStyle = .crossDissolve presentingViewController.present(controller, animated: true, completion: nil) } private class func createFriendsListViewController( presentingViewController: UIViewController, headerViewModel: FriendsListHeaderViewModelProtocol, contentViewModel: FriendsListContentViewModelProtocol) -> FriendsListContainerViewController { let dismissViewControllerBlock: VoidBlock = { [weak presentingViewController] in presentingViewController?.dismiss(animated: true, completion: nil) } let infoViewModel = FriendsListViewModel( headerViewModel: headerViewModel, contentViewModel: contentViewModel) let containerViewModel = FriendsListContainerViewModel(onOutsideContentTapAction: dismissViewControllerBlock) let friendsListViewController = FriendsListViewController( viewModel: infoViewModel, viewConfig: .defaultConfig) let controller = FriendsListContainerViewController( contentViewController: friendsListViewController, viewModel: containerViewModel, viewConfig: .defaultConfig) return controller } }
Você pode ver uma clara
separação de responsabilidades , e esse conceito não é muito mais complicado do que chamar segue em um storyboard.
Criar um controlador de visão é bastante simples, já que temos seu modelo e você pode simplesmente usar a configuração de visão padrão:
let friendsListViewController = FriendsListViewController( viewModel: infoViewModel, viewConfig: .defaultConfig)
# 2: Usando âncoras de layout
Aqui está o código do layout:
self.view.addSubview(infoView) self.infoView = infoView infoView.translatesAutoresizingMaskIntoConstraints = false infoView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor).isActive = true infoView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor).isActive = true infoView.topAnchor.constraint(equalTo: self.view.topAnchor).isActive = true infoView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor).isActive = true
Simplificando, esse código coloca o
infoView
dentro da visualização pai (superview), nas coordenadas (0, 0) em relação ao tamanho original da superview.
Por que usamos âncoras de layout? É rápido e fácil. Obviamente, você pode definir o UIView.frame manualmente e calcular todas as posições e tamanhos em tempo real, mas às vezes pode ser um código muito confuso e / ou volumoso.
Você também pode usar um formato de texto para marcação, conforme descrito
aqui , mas muitas vezes isso gera erros, porque você precisa seguir rigorosamente o formato, e o Xcode não verifica o texto da descrição da marcação no estágio de escrever / compilar o código e não pode usar o Guia de Layout de Área Segura:
NSLayoutConstraint.constraints( withVisualFormat: "V:|-(\(topSpace))-[headerView(headerHeight@200)]-[collectionView(collectionViewHeight@990)]|", options: [], metrics: metrics, views: views)
É muito fácil cometer um erro ou erro de digitação na sequência de texto que define a marcação, certo?
# 3: Componentes - Dividir e conquistar
Nosso exemplo de interface de usuário é dividido em componentes, cada um dos quais desempenha uma função específica, não mais.
Por exemplo:
FriendsListHeaderView
- Exibe informações sobre amigos e o botão Fechar.FriendsListContentView
- exibe uma lista de amigos com células clicáveis, o conteúdo é carregado dinamicamente quando chega ao final da lista.FriendsListView
- um contêiner para as duas visualizações anteriores.
Como mencionado anteriormente, no Badoo amamos o
princípio de responsabilidade exclusiva quando cada componente é responsável por uma função separada. Isso ajuda não apenas no processo de correção de bugs (que talvez não seja a parte mais interessante do trabalho do desenvolvedor do iOS), mas também durante o desenvolvimento de novas funcionalidades, porque essa abordagem expande significativamente as possibilidades de reutilização de código no futuro.
# 4: Separando interface do usuário e lógica
E o último, mas não menos importante, ponto é a separação entre interface do usuário e lógica. Uma técnica que pode economizar tempo e nervos para sua equipe. No sentido literal: um projeto separado para a interface do usuário e outro para a lógica de negócios.
Vamos voltar ao nosso exemplo. Como você se lembra, a essência da apresentação (apresentador) fica assim:
func presentFriendsList(from presentingViewController: UIViewController) { let controller = Class.createFriendsListViewController( presentingViewController: presentingViewController, headerViewModel: self.headerViewModel, contentViewModel: self.contentViewModel) controller.modalPresentationStyle = .overCurrentContext controller.modalTransitionStyle = .crossDissolve presentingViewController.present(controller, animated: true, completion: nil) }
Você só precisa fornecer modelos de exibição para o título e o conteúdo. O restante está oculto na implementação acima dos componentes da interface do usuário.
O protocolo do modelo de visualização de cabeçalho é semelhante a este:
protocol FriendsListHeaderViewModelProtocol { var friendsCountIcon: UIImage? { get } var closeButtonIcon: UIImage? { get } var friendsCount: Observable<String> { get } var onCloseAction: VoidBlock? { get set } }
Agora imagine que você está adicionando testes visuais para a interface do usuário - é tão simples quanto passar modelos de stub para os componentes da interface do usuário.
final class FriendsListHeaderDemoViewModel: FriendsListHeaderViewModelProtocol { var friendsCountIcon: UIImage? = UIImage(named: "ic_friends_count") var closeButtonIcon: UIImage? = UIImage(named: "ic_close_cross") var friendsCount: Observable<String> var onCloseAction: VoidBlock? init() { let friendsCountString = "\(Int.random(min: 1, max: 5000))" self.friendsCount = Observable(friendsCountString) } }
Parece simples, certo? Agora, queremos adicionar lógica de negócios aos componentes de nosso aplicativo, o que pode exigir provedores de dados, modelos de dados etc.:
final class FriendsListHeaderViewModel: FriendsListHeaderViewModelProtocol { let friendsCountIcon: UIImage? let closeButtonIcon: UIImage? let friendsCount: Observable<String> = Observable("0") var onCloseAction: VoidBlock? private let dataProvider: FriendsListDataProviderProtocol private var observers: [ObserverProtocol] = [] init(dataProvider: FriendsListDataProviderProtocol, friendsCountIcon: UIImage?, closeButtonIcon: UIImage?) { self.dataProvider = dataProvider self.friendsCountIcon = friendsCountIcon self.closeButtonIcon = closeButtonIcon self.setupDataObservers() } private func setupDataObservers() { self.observers.append(self.dataProvider.totalItemsCount.observeNewAndCall { [weak self] (newCount) in self?.friendsCount.value = "\(newCount)" }) } }
O que poderia ser mais fácil? Basta implementar o provedor de dados - e pronto!
A implementação do modelo de conteúdo parece um pouco mais complicada, mas a separação de responsabilidades ainda simplifica bastante a vida. Aqui está um exemplo de como instanciar e exibir uma lista de amigos com o clique de um botão:
private func presentRealFriendsList(sender: Any) { let avatarPlaceholderImage = UIImage(named: "avatar-placeholder") let itemFactory = FriendsListItemFactory(avatarPlaceholderImage: avatarPlaceholderImage) let dataProvider = FriendsListDataProvider(itemFactory: itemFactory) let viewModelFactory = FriendsListViewModelFactory(dataProvider: dataProvider) var headerViewModel = viewModelFactory.makeHeaderViewModel() headerViewModel.onCloseAction = { [weak self] in self?.dismiss(animated: true, completion: nil) } let contentViewModel = viewModelFactory.makeContentViewModel() let presenter = FriendsListPresenter( headerViewModel: headerViewModel, contentViewModel: contentViewModel) presenter.presentFriendsList(from: self) }
Essa técnica ajuda a isolar a interface do usuário da lógica de negócios. Além disso, isso permite cobrir toda a interface do usuário com testes visuais, passando dados de teste para os componentes! Portanto, a separação da interface do usuário e da lógica de negócios relacionada é fundamental para o sucesso do projeto, seja uma inicialização ou um produto já finalizado.
Conclusão
Obviamente, essas são apenas algumas das técnicas usadas no Badoo e não são uma solução universal para todos os casos possíveis. Portanto, use-os após avaliar se eles são adequados para você e seus projetos.
Existem outros métodos, por exemplo, componentes de interface do usuário configuráveis por XIB usando o Interface Builder (eles são descritos em outro
artigo ), mas por vários motivos, eles não são usados no Badoo. Lembre-se de que todos têm sua própria opinião e visão geral, portanto, para desenvolver um projeto bem-sucedido, você deve chegar a um consenso na equipe e escolher a abordagem mais adequada para a maioria dos cenários.
Que Swift esteja com você!
Fontes