Hoy hablaremos sobre enfoques arquitectónicos en el desarrollo de iOS, sobre algunos de los matices y desarrollos en la implementación de ciertas cosas. Te diré qué enfoques seguimos y profundizaré un poco más en los detalles.
Revela de inmediato todas las cartas. Usamos MVVM-R (MVVM + Router).
De hecho, este es un MVVM normal, en el que la navegación entre las pantallas se coloca en una capa separada (enrutador y la lógica para recibir datos) en los servicios. A continuación, consideraremos nuestros logros en la implementación de cada capa.
¿Por qué MVVM, no VIPER o MVC?
A diferencia de MVC, MVVM tiene una responsabilidad bastante dividida entre las capas. No tiene tantos códigos de "servicio" como en VIPER, aunque ViewModel para pantallas también está cerrado por protocolos. Esta arquitectura es algo similar a VIPER, solo Presenter e Interactor se combinan en ViewModel, y las conexiones entre las capas se simplifican mediante el uso de programación reactiva y aglutinantes (utilizamos ReactiveSwift).
Entidad
Utilizamos dos capas de modelos de datos: el primero está vinculado a una base de datos (en lo sucesivo denominados objetos gestionados ), el segundo son los llamados objetos simples , que no tienen nada que ver con la base de datos.
Cada entidad simple implementa el protocolo traducible, que se puede inicializar desde un objeto administrado y desde el cual se puede crear un objeto administrado. Usamos Realm como base de datos, en nuestro caso ManagedObject
es RealmSwift.Object
. La asignación se realiza a través de Codable
: se asignan como objetos simples y se guardan como objetos administrados. Otros servicios y ViewModel solo funcionan con objetos simples.
protocol Translatable { associatedtype ManagedObject: Object init(object: ManagedObject) func toManagedObject() -> ManagedObject }
Para guardar, recuperar y eliminar objetos de la base de datos, se utiliza una entidad separada: almacenamiento. Dado que el protocolo cierra el almacenamiento, no dependemos de la implementación de una base de datos específica y, si es necesario, podemos reemplazar Realm con CoreData.
protocol StorageProtocol { func cachedObjects<T: Translatable>() -> [T] func object<T: Translatable>(byPrimaryKey key: AnyHashable) -> T? func save<T: Translatable>(objects: [T]) throws func save<T: Translatable>(object: T) throws func delete<T: Translatable>(objects: [T]) throws func delete<T: Translatable>(object: T) throws func deleteAll<T: Translatable>(ofType type: T.Type) throws }
¿Cuáles son los pros y los contras de este enfoque?
Cada base de datos tiene sus propias características. Por ejemplo, un objeto Realm ya almacenado en la base de datos solo se puede usar dentro del marco de la secuencia en la que se creó. Esto es inconveniente.
Además, un objeto se puede eliminar de la base de datos, mientras está en la RAM, y el acceso se bloqueará. Core Data tiene las mismas características. Por lo tanto, obtenemos objetos de la base de datos, los convertimos en objetos simples y luego trabajamos con ellos.
Con este enfoque, el código se hace más grande y debe ser compatible. Independientemente de las características de la base de datos, perdemos la capacidad de usar chips geniales. En el caso de CoreData, este es un FetchedResultsController, donde podemos controlar todas las inserciones, eliminaciones y cambios dentro de una matriz de entidades. Sobre el mismo mecanismo en Realm.
Componentes principales
Los componentes principales son entidades que realizan una de sus tareas. Por ejemplo, mapeo, interacción con la base de datos, envío y procesamiento de solicitudes de red. El almacenamiento del párrafo anterior es solo uno de los componentes principales.
Protocolos
Utilizamos activamente protocolos. Todos los componentes principales están cerrados por protocolos, y es posible hacer una implementación simulada o de prueba para pruebas unitarias. Por lo tanto, obtenemos una cierta flexibilidad de implementación. Todas las dependencias se pasan a init. Al inicializar cada objeto, entendemos qué tipo de dependencias hay, qué usa dentro de sí mismo.
Cliente HTTP
El protocolo NetworkRequestParams
describe una solicitud de red.
protocol NetworkRequestParams { var path: String { get } var method: HTTPMethod { get } var parameters: Parameters { get } var encoding: ParameterEncoding { get } var headers: [String: String]? { get } var defaultHeaders: [String: String]? { get } }
Usamos enum
para describir las solicitudes de red. Se ve así:
enum UserNetworkRouter: URLRequestConvertible { case info case update(userJson:[String : Any]) } extension UserNetworkRouter: NetworkRequestParams { var path: String { switch self { case .info: return "/users/profile" case .update: return "/users/update_profile" } } var method: HTTPMethod { switch self { case .info: return .get case .update: return .post } } var encoding: ParameterEncoding { switch self { case .info: return URLEncoding() case .update: return JSONEncoding() } } var parameters: Parameters { switch self { case .info: return [:] case .update(let userJson): return userJson } } }
Cada NetworkRouter
implementa el protocolo URLRequestConvertible
. Se lo entregamos al cliente de la red, que lo convierte a URLRequest
y lo utiliza para el fin previsto.
El cliente de red es el siguiente:
protocol HTTPClientProtocol { func load(request: NetworkRequestParams & URLRequestConvertible) -> SignalProducer<Data, Error> }
Mapper
Usamos Codable
para mapeo de datos.
protocol MapperProtocol { func map<MappingResult: Codable>(data: Data, dateDecodingStrategy: JSONDecoder.DateDecodingStrategy) -> SignalProducer<MappingResult, Error> }
Notificaciones push
Cada notificación push tiene un tipo y cada tipo tiene su propio controlador. El controlador recibe un diccionario con información de la notificación. La entidad de agregación tiene los controladores; es ella quien recibirá el impulso y lo dirigirá al controlador deseado. Este es un enfoque bastante escalable con el que es conveniente trabajar si necesita manejar varios tipos de notificaciones push de manera diferente.
Servicios
En términos generales, un servicio es responsable de una entidad. Considere esto con un ejemplo de una aplicación de red social. Hay un servidor del usuario que recibe al usuario, él mismo, y da las entidades modificadas si lo editamos. Hay un servicio de correos que recibe una lista de publicaciones, una publicación detallada, un servicio de pago, etc. etc.
Todos los servicios contienen componentes centrales. Cuando llamamos a un método en un servicio, comienza a extraer varios métodos de componentes principales y finalmente da el resultado.
Un servicio, por regla general, funciona para una pantalla específica, o más bien para un modelo de vista de pantalla (más sobre esto a continuación). Si, al salir de la pantalla, el servicio no se destruye, pero continúa cumpliendo una solicitud de red ya innecesaria y ralentizará otras solicitudes. Esto se puede controlar manualmente, pero será más difícil mantener dicho sistema. Sin embargo, este enfoque tiene una desventaja: si el resultado del servicio es necesario incluso después de que salgamos de la pantalla, tendrá que buscar otras soluciones, tal vez hacer que algunos servicios sean únicos.
Los servicios son apátridas. Como los servicios no son singletones, podemos tener varias instancias del mismo servicio, en las que los estados pueden diferir entre sí. Esto puede conducir a un comportamiento incorrecto.
Un ejemplo de un método de uno de los servicios:
func currentUser() -> SignalProducer<User, Error> { let request = UserNetworkRouter.info return httpClient.load(request: request) .flatMap(.latest, mapUser) .flatMap(.latest, save) }
ViewModel
Dividiremos ViewModel en 2 tipos:
- ViewModel para la pantalla (ViewController)
- ViewModel para UIView (incluidas celdas de tabla o UICollectionView)
ViewModel para ViewController es responsable de la lógica de la pantalla. Como regla, esto es enviar solicitudes de red, preparar datos, responder a eventos de UI.
ViewModel prepara todos los datos para la vista que vino del servicio. Si llega una lista de entidades, ViewModel la transforma en una lista de ViewModel y las une para verlas. Si hay estados (hay una marca de verificación / sin marca de verificación), esto también se administra y pasa al ViewModel.
ViewModel también controla la lógica de navegación. Hay una capa de enrutador separada para la navegación, pero ViewModel proporciona los comandos.
Funciones típicas del modelo de vista: obtener el usuario, ponerse en contacto con el servicio del usuario, crear el modelo de vista a partir del valor recibido. Cuando todo se carga, Ver toma el Modelo de vista y dibuja la celda de vista.
ViewModel para la pantalla está cerrado por el protocolo por los mismos motivos que los servicios. Sin embargo, hay otro caso interesante: por ejemplo, una aplicación bancaria, donde cada acción (transferir fondos, abrir una cuenta, bloquear una cuenta) se confirma por SMS. En la pantalla de confirmación hay un campo de entrada de código y un botón "enviar de nuevo".
ViewModel está cerrado por este protocolo:
protocol CodeInputViewModelProtocol {
En ViewController, se almacena de la siguiente forma:
var viewModel: CodeInputViewModelProtocol?
Dependiendo de lo que estamos tratando de confirmar por SMS, el envío de un código y el envío de SMS pueden representarse mediante solicitudes completamente diferentes, y después de la confirmación, se necesitan transiciones a diferentes pantallas, etc. Dado que ViewController no le importa qué tipo de tiempo tiene realmente ViewModel, podemos tener varias implementaciones de ViewModel para diferentes casos, y la interfaz de usuario será común.
ViewModel for View y las celdas generalmente se ocupan del formato de datos y el procesamiento de entrada del usuario. Por ejemplo, almacenar el estado seleccionado / no seleccionado.
final class FeedCellViewModel { let url: URL? let title: String let subtitle: String init(feed: FeedItem) { url = URL(string: feed.imageUrl) title = feed.title subtitle = DateFormatter.feed.string(from feed.publishDate) } }
La navegación
Las transiciones entre pantallas las realiza el enrutador.
class BaseRouter { init(sourceViewController: UIViewController) { self.sourceViewController = sourceViewController } weak var sourceViewController: UIViewController? }
Cada pantalla tiene su propio enrutador, que se hereda de la base. Tiene métodos de transición para pantallas específicas.
final class FeedRouter : BaseRouter { func showDetail(viewModel: FeedDetailViewModelProtocol) { let vc = FeedDetailViewController() vc.viewModel = viewModel sourceViewController?.navigationController?.pushViewController(vc, animated: true) } }
Como puede ver en el ejemplo anterior, el ensamblaje del "módulo" ocurre en el enrutador. Esto contradice formalmente la letra S de SOLID, pero en la práctica resulta bastante conveniente y no causa problemas.
Hay momentos en que se necesita el mismo método en diferentes enrutadores. Para no escribirlo varias veces, creamos un protocolo en el que habrá métodos generales e implementaremos una extension
. Ahora es suficiente con firmar el enrutador deseado para este protocolo, y tendrá los métodos necesarios.
protocol FeedRouterProtocol { func showDetail(viewModel: FeedDetailViewModelProtocol) } extension FeedRouterProtocol where Self: BaseRouter { func showDetail(viewModel: FeedDetailViewModelProtocol) { let vc = FeedDetailViewController() vc.viewModel = viewModel sourceViewController?.navigationController?.pushViewController(vc, animated: true) } }
Vista
View es tradicionalmente responsable de mostrar información al usuario y procesar las acciones del usuario. En MVVM, creemos que ViewController es una vista. Es importante que no haya una lógica compleja que tenga un lugar en ViewModel. En cualquier caso, incluso en MVC, no debe cargar demasiado el ViewController, aunque es difícil hacerlo.
Ver ordena el ViewModel. Si se carga el ViewController, le damos el comando ViewModel: cargar datos desde la red o desde la caché. View también acepta señales del ViewModel. Si ViewModel dice que algo ha cambiado (por ejemplo, los mismos datos se han cargado), entonces View reacciona y vuelve a dibujar.
No usamos guiones gráficos. La navegación está fuertemente ligada al ViewController, y es difícil adaptarse a la arquitectura. Los conflictos a menudo surgen en los guiones gráficos, que son un "placer" separado para editar.
¿Qué hacer a continuación?
Puede usar la generación de código para modelos (traducibles), ya que toda la inicialización desde el objeto de la base de datos al objeto del plan y viceversa ahora se registra manualmente.
También puede usar un esquema de consulta más universal, ya que muchos métodos de servicios se ven así: vaya a la red, aplique la asignación, guarde en la base de datos. Esto también se puede universalizar, para establecer un esqueleto común.
Hemos considerado enfoques arquitectónicos, pero no olvidemos que una aplicación de alta calidad no es solo arquitectura, sino también una interfaz fluida, receptiva y conveniente. Ama a tus usuarios y escribe aplicaciones de calidad.