
Hola Habr!
Mi nombre es Valera, y durante dos años he estado desarrollando una aplicación para iOS como parte del equipo de Badoo. Una de nuestras prioridades es el código fácil de mantener. Debido a la gran cantidad de nuevas características que caen en nuestras manos semanalmente, primero debemos pensar en la arquitectura de la aplicación, de lo contrario será extremadamente difícil agregar una nueva característica al producto sin romper las existentes. Obviamente, esto también se aplica a la implementación de la interfaz de usuario (UI) independientemente de si esto se hace usando código, Xcode (XIB) o un enfoque mixto. En este artículo describiré algunas técnicas de implementación de UI que nos permiten simplificar el desarrollo de la interfaz de usuario, haciéndola flexible y conveniente para las pruebas. También hay una versión en
inglés de este artículo.
Antes de comenzar ...
Consideraré las técnicas de implementación de la interfaz de usuario utilizando una aplicación de ejemplo escrita en Swift. La aplicación con solo hacer clic en un botón muestra una lista de amigos.
Consta de tres partes:
- Los componentes son componentes de interfaz de usuario personalizados, es decir, código relacionado solo con la interfaz de usuario.
- Aplicación de demostración: modelos de vista de demostración y otras entidades de interfaz de usuario que solo tienen dependencias de la interfaz de usuario.
- La aplicación real es ver modelos y otras entidades que pueden contener dependencias específicas y lógica.
¿Por qué hay tal separación? Contestaré esta pregunta a continuación, pero por ahora, consulte la interfaz de usuario de nuestra aplicación:
Esta es una vista emergente con contenido encima de otra vista de pantalla completa. Todo es simple
El código fuente completo del proyecto está disponible en
GitHub .
Antes de profundizar en el código de la interfaz de usuario, quiero presentarles la clase auxiliar Observable utilizada aquí. Su interfaz se ve así:
var value: T func observe(_ closure: @escaping (_ old: T, _ new: T) -> Void) -> ObserverProtocol func observeNewAndCall(_ closure: @escaping (_ new: T) -> Void) -> ObserverProtocol
Simplemente notifica a todos los observadores firmados previamente de los cambios, por lo que esta es una especie de alternativa a KVO (observación de valor clave) o, si lo desea, programación reactiva. Aquí hay un ejemplo 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)) })
El controlador se suscribe a los cambios en la propiedad
self.viewModel.items
y, cuando se produce el cambio, el controlador ejecuta la lógica empresarial. Por ejemplo, actualiza el estado de la vista y vuelve a cargar la vista de la colección con nuevos elementos.
Verá más ejemplos de uso a continuación.
Metodologías
En esta sección hablaré sobre cuatro técnicas de desarrollo de UI que se usan en Badoo:
1. Implementación de la interfaz de usuario en código.
2. Uso de anclajes de diseño.
3. Componentes: divide y vencerás.
4. Separación de la interfaz de usuario y la lógica.
# 1: Implementando la interfaz de usuario en código
En Badoo, la mayoría del interés del usuario se implementa en el código. ¿Por qué no usamos XIB o guiones gráficos? Pregunta justa La razón principal es la conveniencia de mantener el código para un equipo de tamaño mediano, a saber:
- los cambios en el código son claramente visibles, lo que significa que no hay necesidad de analizar el archivo XML storyboard / XIB para encontrar los cambios realizados por un colega;
- Los sistemas de control de versiones (por ejemplo, Git) son mucho más fáciles de trabajar con código que con archivos XLM "pesados", especialmente durante conflictos moderados; también se tiene en cuenta que el contenido de los archivos XIB / storyboard cambia cada vez que se guardan, incluso si la interfaz no ha cambiado (aunque escuché que en Xcode 9 este problema ya se ha solucionado);
- puede ser difícil cambiar y mantener algunas propiedades en Interface Builder (IB), por ejemplo, propiedades CALayer durante el proceso de retransmisión de vistas secundarias (subvistas de diseño), lo que puede conducir a varias fuentes de verdad para el estado de vista;
- Interface Builder no es la herramienta más rápida, y a veces es mucho más rápido trabajar directamente con el código.
Eche un vistazo al siguiente 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 ejemplo muestra que puede crear un controlador de vista solo proporcionando un modelo de vista y una configuración de vista. Puede leer más sobre los modelos de presentación, es decir, el modelo de diseño MVVM (Model-View-ViewModel)
aquí . Dado que la configuración de la vista es una entidad estructural simple (entidad de estructura) que define el diseño y el estilo de la vista, es decir, sangrías, tamaños, colores, fuentes, etc., considero apropiado proporcionar una configuración estándar como esta:
extension FriendsListViewController.ViewConfig { static var defaultConfig: FriendsListViewController.ViewConfig { return FriendsListViewController.ViewConfig(backgroundColor: .white, cornerRadius: 16) } }
Toda la inicialización de la vista ocurre en el método
setupContainerView
, que se llama solo una vez desde viewDidLoad cuando la vista ya está creada y cargada, pero aún no se dibuja en la pantalla, es decir, todos los elementos necesarios (subvistas) simplemente se agregan a la jerarquía de la vista, y luego se aplica el marcado (diseño) y estilos.
Así es como se ve el controlador de vista ahora:
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 } }
Puede ver una clara
separación de responsabilidades , y este concepto no es mucho más complicado que llamar a segue en un guión gráfico.
Crear un controlador de vista es bastante simple, dado que tenemos su modelo y simplemente puede usar la configuración de vista estándar:
let friendsListViewController = FriendsListViewController( viewModel: infoViewModel, viewConfig: .defaultConfig)
# 2: uso de anclas de diseño
Aquí está el código de diseño:
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
En pocas palabras, este código coloca
infoView
dentro de la vista principal (supervista), en las coordenadas (0, 0) relativas al tamaño original de la supervista.
¿Por qué usamos anclas de diseño? Es rapido y facil. Por supuesto, puede configurar UIView.frame manualmente y calcular todas las posiciones y tamaños sobre la marcha, pero a veces puede resultar un código demasiado confuso y / o voluminoso.
También puede usar un formato de texto para el marcado, como se describe
aquí , pero a menudo esto conduce a errores, porque necesita seguir estrictamente el formato, y Xcode no verifica el texto de descripción del marcado en la etapa de escribir / compilar el código, y no puede usar la Guía de diseño de área segura:
NSLayoutConstraint.constraints( withVisualFormat: "V:|-(\(topSpace))-[headerView(headerHeight@200)]-[collectionView(collectionViewHeight@990)]|", options: [], metrics: metrics, views: views)
Es bastante fácil cometer un error o un error tipográfico en la cadena de texto que define el marcado, ¿verdad?
# 3: Componentes - Divide y vencerás
Nuestra interfaz de usuario de ejemplo se divide en componentes, cada uno de los cuales realiza una función específica, no más.
Por ejemplo:
FriendsListHeaderView
: muestra información sobre amigos y el botón Cerrar.FriendsListContentView
: muestra una lista de amigos con celdas en las que se puede hacer clic, el contenido se carga dinámicamente cuando llega al final de la lista.FriendsListView
: un contenedor para las dos vistas anteriores.
Como se mencionó anteriormente, en Badoo amamos el
principio de responsabilidad exclusiva cuando cada componente es responsable de una función separada. Esto ayuda no solo en el proceso de corrección de errores (que, quizás, no es la parte más interesante del trabajo del desarrollador de iOS), sino también durante el desarrollo de una nueva funcionalidad, porque este enfoque amplía significativamente las posibilidades de reutilizar el código en el futuro.
# 4: Separar la interfaz de usuario y la lógica
Y el último, pero no menos importante punto, es la separación de la interfaz de usuario y la lógica. Una técnica que puede ahorrarle tiempo y nervios a su equipo. En el sentido literal: un proyecto separado para la interfaz de usuario y otro para la lógica de negocios.
Volvamos a nuestro ejemplo. Como recordarán, la esencia de la presentación (presentador) se ve así:
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) }
Solo necesita proporcionar modelos de vista para el título y el contenido. El resto está oculto dentro de la implementación anterior de los componentes de la interfaz de usuario.
El protocolo del modelo de vista de encabezado se ve así:
protocol FriendsListHeaderViewModelProtocol { var friendsCountIcon: UIImage? { get } var closeButtonIcon: UIImage? { get } var friendsCount: Observable<String> { get } var onCloseAction: VoidBlock? { get set } }
Ahora imagine que está agregando pruebas visuales para la interfaz de usuario: es tan simple como pasar modelos de código auxiliar para los componentes de la interfaz de usuario.
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) } }
Se ve simple, ¿verdad? Ahora queremos agregar lógica empresarial a los componentes de nuestra aplicación, lo que puede requerir proveedores de datos, modelos de datos, 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)" }) } }
¿Qué podría ser más fácil? Simplemente implemente el proveedor de datos, ¡y listo!
La implementación del modelo de contenido parece un poco más complicada, pero la separación de responsabilidades aún simplifica enormemente la vida. Aquí hay un ejemplo de cómo crear instancias y mostrar una lista de amigos con solo hacer clic en un botón:
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) }
Esta técnica ayuda a aislar la interfaz de usuario de la lógica empresarial. Además, esto le permite cubrir toda la interfaz de usuario con pruebas visuales, ¡pasando datos de prueba a los componentes! Por lo tanto, la separación de la interfaz de usuario y la lógica de negocios relacionada es crítica para el éxito del proyecto, ya sea un inicio o un producto ya terminado.
Conclusión
Por supuesto, estas son solo algunas de las técnicas utilizadas en Badoo, y no son una solución universal para todos los casos posibles. Por lo tanto, úselos después de evaluar si son adecuados para usted y sus proyectos.
Existen otros métodos, por ejemplo, componentes de interfaz de usuario configurables por XIB que usan Interface Builder (se describen en
nuestro otro
artículo ), pero por diversas razones no se usan en Badoo. Recuerde que todos tienen su propia opinión y visión del panorama general, por lo tanto, para desarrollar un proyecto exitoso, debe llegar a un consenso en el equipo y elegir el enfoque más adecuado para la mayoría de los escenarios.
¡Que Swift te acompañe!
Fuentes