Mientras que UIKit ha sido el marco de referencia para los ingenieros de iOS para construir interfaces de usuario en sus aplicaciones a lo largo de los años, SwiftUI ha ido ganando terreno como un marco alternativo que rectifica muchas desventajas de UIKit. Por ejemplo, SwiftUI requiere mucho menos código para construir la misma interfaz de usuario, y siempre produce un diseño válido. Los desarrolladores ya no tienen que pasar horas depurando problemas de diseño automático. En este artículo, primero compararemos el enfoque basado en eventos de UIKit con el enfoque basado en datos de SwiftUI, luego profundizaremos en el ciclo de vista de SwiftUI, la identidad y el proceso de renderizado para entender mejor cómo escribir código de alto rendimiento en SwiftUI.
Funcionamiento de un marco basado en eventos
UIKit proporciona una interfaz de usuario dirigida por eventos por naturaleza, donde las vistas se crean a través de una secuencia de eventos que realizan operaciones y finalmente se unen para formar lo que se ve en la pantalla. En un framework dirigido por eventos, es necesario que haya un controlador que una la vista y los eventos. Este pegamento se llama controlador de vista.
Funcionamiento del controlador de vista
El controlador de vista es esencialmente un centro de control que decide lo que ocurre ante determinados eventos. Por ejemplo, si se necesita mostrar algún contenido en la pantalla cuando se carga una página, el controlador de vista escucha el evento de carga de la página y realiza la lógica de negocio necesaria para cargar y mostrar el contenido. Veamos un ejemplo más específico:
Digamos que hay un botón que, al pulsarlo, muestra una imagen de un tipo de fruta aleatorio en la pantalla. Después de cada nuevo clic en el botón, se muestra un nuevo tipo de fruta. Veamos una representación del flujo si esto se construyera con UIKit en la Figura 1 a continuación.
En este flujo, el controlador de vista mantiene una referencia al botón y a la vista. Cuando un usuario hace clic en el botón, el controlador de vista toma esto como la señal para calcular un nuevo tipo de fruta. Una vez que se devuelve una nueva fruta, el controlador de vista le dice a la vista que actualice la interfaz de usuario con ella. En este caso, el evento de clic en el botón dirige la lógica que cambia la interfaz de usuario.
Manténgase informado con las actualizaciones semanales
Suscríbase a nuestro blog de ingeniería para estar al día de los proyectos más interesantes en los que trabaja nuestro equipo.
Please enter a valid email address.
Gracias por suscribirse.
Los retos de utilizar UIKit y controladores de vistas
Aunque este es un ejemplo muy simple, podemos ver que el controlador de vista tiene varias responsabilidades. Con vistas más complejas en una aplicación de producción, estas responsabilidades significan que el controlador de vista puede llegar a ser masivo y difícil de manejar. Tenemos que escribir el código y dictar la lógica para la interacción entre el controlador de vista, la vista y cada evento, lo que puede ser propenso a errores y difícil de leer.
Por supuesto, gran parte del dolor de tratar con el controlador de vista puede ser aliviado por tener una buena arquitectura de código y la separación de preocupaciones. La arquitectura VIP que utiliza nuestra aplicación para consumidores de DoorDash iOS puede extraer la lógica de negocio y de presentación, de tal forma que el controlador de vista no necesita conocer nada de esa lógica, y solo puede centrarse en mostrar la vista en la pantalla dados los datos.
Pero cualquier arquitectura no puede evitar el controlador de vista, ya que su propósito de servir como el pegamento entre los eventos y la vista es insustituible en un marco de trabajo dirigido por eventos.
Cómo funciona un marco basado en datos
Mientras que UIKit utiliza un marco impulsado por eventos, SwiftUI se basa en un marco impulsado por datos. En SwiftUI, las vistas son una función del estado, no una secuencia de eventos(WWDC 2019). Una vista está vinculada a algunos datos (o estado) como fuente de verdad, y se actualiza automáticamente cada vez que cambia el estado. Esto se consigue definiendo las vistas como funciones que toman como argumento la vinculación de datos.
Este marco de trabajo basado en datos elimina por completo el controlador de vista como intermediario. Lo que el usuario ve en la pantalla está directamente controlado por un estado, que puede ser de cualquier tipo de datos. Usando el mismo ejemplo de aplicación de fruta que usamos anteriormente con UIKit, podemos ver una ilustración de este concepto a continuación en la Figura 2.
El tipo de fruta es un estado que está ligado a la vista, lo que significa que cada vez que se actualice la fruta, se reflejará automáticamente en la vista. Esto significa que cuando un usuario hace clic en el botón, sólo tenemos que actualizar el estado, y la vista se actualizará para mostrar la nueva fruta, sin necesidad de un controlador para decirle que lo haga. De ahí el término "data-driven": la interfaz de usuario es una representación directa de los datos.
Ventajas de un marco basado en datos
Trabajar con un framework basado en datos significa que ya no hay controladores de vista masivos, ni necesidad de definir la lógica de los eventos para realizar actualizaciones de la vista. La interfaz se acopla a los datos, lo que resulta en menos líneas de código y una mejor legibilidad. Podemos entender fácilmente que la fruta que muestra la vista está controlada por el estado de la fruta, a diferencia de UIKit, donde tendríamos que escarbar en el código para ver cómo se controla la fruta.
Los retos de utilizar SwiftUI
Cualquier nuevo marco o tecnología tiene sus ventajas y desventajas. Basándonos únicamente en las comparaciones de marcos basados en eventos y datos de arriba, SwiftUI siempre puede parecer la opción superior, pero esa no es la historia completa.
Los inconvenientes de SwiftUI se asocian principalmente con el hecho de que fue lanzado hace sólo tres años. SwiftUI es un nuevo marco, por lo que va a tomar tiempo para que más desarrolladores lo adopten y aprendan. Dada la adopción en curso, hay menos arquitecturas de código establecidas basadas en SwiftUI. También hemos experimentado problemas de retrocompatibilidad, donde el mismo código SwiftUI funciona de forma diferente en iOS 14 y 15, lo que hace que sea muy difícil de depurar.
Ahora que tenemos una comprensión básica de los pros y los contras de los dos tipos de frameworks, vamos a sumergirnos en algunos desafíos específicos que experimentamos al tratar con SwiftUI y su proceso de renderizado de vistas, y cómo escribir código eficiente para preservar la identidad de una vista con el fin de crear una interfaz de usuario fluida y óptima.
Ver en SwiftUI
Hay algunos conceptos principales que vale la pena mencionar cuando se trabaja con SwiftUI:
- Vista en función del estado
- Identidad de la vista
- Vida útil de la vista
En primer lugar, los datos son la fuente de verdad de la vista. Cuando los datos cambian, recibimos las actualizaciones en una vista. Así que ya sabemos que las vistas en SwiftUI son una función de un estado (Figura 3). Pero, ¿qué es este estado en el mundo SwiftUI?
Cuando se está cambiando la mentalidad de la arquitectura basada en eventos a un marco declarativo, puede haber algunas preguntas. No es difícil obtener la comprensión básica de SwiftUI, pero es un poco confuso lo que sucede bajo el capó. Sabemos que cuando el estado de la vista cambia, la vista se actualiza, pero algunas preguntas surgen de forma natural:
- ¿Cómo se actualizan exactamente los datos?
- ¿Cómo entiende la opinión lo que hay que cambiar exactamente?
- ¿Crea una nueva vista cada vez que cambia un pequeño dato?
- ¿Hasta qué punto es eficaz y costosa la actualización de datos?
Es esencial entender cómo funciona internamente el framework. Obtener las respuestas a estas y otras preguntas puede ayudar a resolver algunos comportamientos no deseados en nuestras aplicaciones, como un rendimiento deficiente, errores aleatorios o animaciones inesperadas. Ayudará a desarrollar aplicaciones bien optimizadas y libres de errores.
Sobre la jerarquía de vistas de SwiftUI.
El principal elemento de interfaz de usuario en SwiftUI es una vista. El rendimiento y la calidad de la parte visual de la aplicación dependen de la eficiencia de su definición y manipulaciones de estado. Echemos un vistazo a la vista por defecto que se ha creado para una plantilla SwiftUI en Xcode:
struct ContentView: View {
var body: some View {
Text("Hello, world!")
.padding()
}
}
Existe una estructura ContentView que se ajusta al protocolo View:
public protocol View {
associatedtype Body : View
@ViewBuilder var body: Self.Body { get }
}
Una propiedad computed body define el contenido de la vista. La composición de vistas SwiftUI forma una jerarquía de vistas. El protocolo View tiene un tipo asociado, que también es un View. En algún momento, SwiftUI intentará renderizar la ContentView, y simplemente preguntará por el cuerpo de la ContentView. Sin embargo, si la vista de contenido no contiene una vista de Texto primitiva, sino otra vista personalizada, SwiftUI necesitará preguntar a todas las vistas personalizadas anidadas por sus cuerpos para poder mostrarlas. Echemos un vistazo a este ejemplo:
struct FruitsView: View {
var body: some View {
BananaView()
}
}
struct BananaView: View {
var body: some View {
Text("I am banana!")
.padding()
}
}
En este caso, FruitsView preguntará a BananaView por su cuerpo, ya que necesita saber qué mostrar. BananaView pregunta a Text por su cuerpo. Este es un conjunto de llamadas recursivas, como se ve en la Figura 4, porque cada vista tiene un cuerpo, y el cuerpo devuelve alguna Vista.
SwiftUI, para tener un buen rendimiento, necesita acortarlo y romper de alguna manera la recursión. En nuestro caso la recursión terminará cuando SwiftUI intente preguntar al Texto por su cuerpo, porque el Texto, así como otros componentes de SwiftUI, es un tipo primitivo. Puede ser dibujado sin preguntar por el cuerpo. Esto se consigue con un tipo Never:
extension Text : View {
public typealias Body = Never
}
extension Never : View {
public typealias Body = Never
public var body: Never { get }
}
Además, Never se ajusta al protocolo View. Así, nuestra recursión terminará cuando alcancemos el tipo primitivo, como se muestra en la Figura 5, porque SwiftUI manejará los tipos primitivos de una manera especial.
Los tipos primitivos se convierten en la base de cualquier jerarquía de vistas. El texto es uno de los tipos de vista primitivos, pero también hay otros:
- Texto
- Imagen
- Espaciador
- ZStack
- VStack
- HStack
- Lista
- Etc.
Sistema de gestión estatal
Cada vista tiene un estado, que puede cambiar durante la ejecución de nuestra aplicación. El estado es una única fuente de verdad para esta vista. La vista y su estado tienen algunos mecanismos que impulsan las actualizaciones del cuerpo, por lo que cada vez que el estado de la vista cambia, el cuerpo es solicitado. En SwiftUI el estado puede ser creado de varias maneras, por ejemplo:
- @Estado
- @ObjetoEstado
- @Encuadernación
- @ObjetoObservado
- @ObjetoEntorno.
@Estado
El estado es una fuente de verdad para la vista y se utiliza cuando el alcance de los cambios se limita únicamente a la vista. Al envolver los tipos de valor como propiedades de Estado transitorias, el framework asigna un almacenamiento persistente para este tipo de valor y lo convierte en una dependencia, por lo que los cambios en el estado se reflejarán automáticamente en la vista. Es una buena práctica utilizar una palabra clave privada al declarar el Estado, ya que está diseñado para ser utilizado por la vista internamente.
@ObjetoEstado
Esta envoltura de propiedad debe ser aplicada al tipo que se ajusta al protocolo ObservedObject y permite la monitorización de los cambios en este objeto y lo trata como un estado. SwiftUI crea una nueva instancia del objeto sólo una vez por cada instancia de la estructura que declara el objeto. Cuando las propiedades publicadas del objeto observable cambian, SwiftUI actualiza las partes de cualquier vista que dependa de esas propiedades.
@ObjetoObservado
Este es un tipo de envoltura de propiedad que se suscribe a un objeto observable e invalida una vista cada vez que el objeto observable cambia. Esta envoltura de propiedad es muy similar a @StateObject; la principal diferencia es que @StateObject se utiliza para crear inicialmente el valor y luego podemos pasarlo como una dependencia a las otras vistas utilizando @ObservedObject.
@ObservedObject se utiliza para realizar un seguimiento de un objeto que ya ha sido creado.
@Encuadernación
Esta envoltura de propiedad es útil en casi todas las vistas de SwiftUI. Binding es una envoltura de propiedad que puede leer y escribir un valor propiedad de una fuente de verdad, por ejemplo, un @State o una de las propiedades de @StateObject. El signo de dólar ($) se utiliza como prefijo de la variable de propiedad @State para obtener el valor proyectado, y este valor proyectado es un binding. A continuación, puede pasar un enlace más abajo en una jerarquía de vistas y cambiarlo. Los cambios se reflejarán en cualquier vista que lo utilice como fuente de verdad.
struct BananaView: View {
@State private var isPeeled: Bool = false
var body: some View {
Text(isPeeled ? "Peeled banana!" : "Banana!")
.background(.yellow)
PeelBananaButton(isPeeled: $isPeeled)
}
}
struct PeelBananaButton: View {
@Binding var isPeeled: Bool
var body: some View {
Button("Peel Banana") {
isPeeled = true
}
}
}
@ObjetoEntorno
Esta envoltura de propiedades tampoco crea ni asigna el objeto en sí. En su lugar, proporciona un mecanismo para controlar el entorno de la jerarquía de vistas. Por ejemplo, la vista padre, que tiene la fuente de verdad (por ejemplo, StateObject) tiene unas cuantas capas de subvistas (Figura 6).
Las vistas C y D dependen de los datos. Pasar los datos puede lograrse inyectando continuamente el objeto observado varias veces, hasta que estas vistas tengan una referencia a él. Las vistas A y B no necesitan realmente conocer este objeto, ya que sólo las vistas C y D necesitan los datos. Este enfoque puede crear algo de código repetitivo y traer dependencias adicionales a las vistas que no las necesitan.
Un objeto de entorno es realmente útil en este caso. Se define en una vista de nivel superior y cualquier vista hija en una jerarquía de vistas puede acceder al objeto y obtener las actualizaciones de datos correctas, como se ve en la Figura 7 a continuación. Se puede acceder al objeto observado en una vista antepasada siempre que uno de sus antepasados lo añada a la jerarquía utilizando el modificador environmentObject(_:):
Estos son los instrumentos que podemos utilizar para actualizar los datos y hacer que la vista refleje las actualizaciones. Cada pequeño cambio en el flujo de datos puede causar múltiples cálculos en el cuerpo de la vista. Estos cálculos pueden afectar potencialmente al rendimiento, por ejemplo en caso de utilizar variables calculadas no optimizadas. SwiftUI es lo suficientemente inteligente como para detectar los cambios y sólo puede redibujar las partes de la vista que han sido realmente afectadas por una actualización de datos. Este redibujado se realiza con la ayuda de AttributeGraph - un componente interno utilizado por SwiftUI para construir y analizar el gráfico de dependencia de los datos y sus vistas relacionadas.
Identidad de una vista
En UIKit, las vistas son clases y las clases tienen punteros que identifican sus vistas. En SwiftUI, sin embargo, las vistas son structs, y no tienen punteros. Para ser eficiente y optimizado, SwiftUI necesita entender si las vistas son las mismas o distintas. También es importante para el framework identificar las vistas con el fin de hacer una transición correcta y renderizar la vista correctamente una vez que algunos de los valores de la vista han cambiado.
La identidad de la vista es un concepto que aporta algo de luz a la magia de renderizado de SwiftUI. Puede haber miles de actualizaciones a través de su aplicación, y algunas propiedades del cuerpo se vuelven a calcular una y otra vez. Sin embargo, no siempre conduce a la re-presentación completa de la vista afectada. Y la identidad de la vista es clave para entender esto. Hay dos formas de identificar la vista en SwiftUI, a través de la identidad explícita o la identidad estructural. Vamos a profundizar en ambas.
Identidad explícita
Las vistas pueden identificarse utilizando identificadores personalizados o basados en datos. La identidad de puntero que se utiliza en UIKit es un ejemplo de la identidad explícita, ya que los punteros se están utilizando para identificar la vista. Probablemente hayas visto ejemplos de ello mientras iteras sobre tus vistas en un bucle for each. La identidad explícita se puede proporcionar utilizando el identificador directamente: .id(...) . Vincula la identidad de una vista al valor dado, que debe ser hashable:
extension View {
@inlinable public func id<ID>(_ id: ID) -> some View where ID : Hashable
}
Supongamos que tenemos un conjunto de frutas. Cada fruta tiene un nombre único y un color:
struct Fruit {
let name: String
let color: Color
}
Para mostrar una lista desplazable de frutas, se puede utilizar la estructura ForEach:
struct FruitListView: View {
let fruits = [Fruit(name: "Banana", color: .yellow),
Fruit(name: "Cherry", color: .red)]
var body: some View {
ScrollView {
ForEach(fruits) { fruit in
FruitView(fruit: fruit)
}
}
}
}
struct FruitView: View {
let fruit: Fruit
var body: some View {
Text("\(fruit.name)!")
.foregroundColor(fruit.color)
.padding()
}
}
Sin embargo, esto no compilará y habrá un error: La referencia al inicializador 'init(_:content:)' en 'ForEach' requiere que 'Fruit' sea 'Identifiable'.
Este problema se puede solucionar implementando el protocolo Identifiable en la estructura Fruit, o proporcionando un keypath. De cualquier manera permitirá a SwiftUI saber que identidad explícita debe tener la FruitView:
struct FruitListView: View {
let fruits = [Fruit(name: "Banana", color: .yellow),
Fruit(name: "Cherry", color: .red)]
var body: some View {
ScrollView {
ForEach(fruits, id: \.name) { fruit in
FruitView(fruit: fruit)
}
}
}
}
Este nuevo código compilará y FruitView se identificará por el nombre, ya que el nombre de la fruta está diseñado para ser único.
Otro caso de uso en el que se utiliza habitualmente la identidad explícita es la posibilidad de realizar un desplazamiento manual a una de las secciones de la vista de desplazamiento.
struct ContentView: View {
let headerID = "header"
let fruits = [Fruit(name: "Banana", color: .yellow),
Fruit(name: "Cherry", color: .red)]
var body: some View {
ScrollView {
ScrollViewReader { proxy in
Text("Fruits")
.id(headerID)
ForEach(fruits, id: \.name) { fruit in
FruitView(fruit: fruit)
}
Button("Scroll to top") {
proxy.scrollTo(headerID)
}
}
}
}
}
En este ejemplo, al pulsar sobre un botón, la vista se desplazará a la parte superior. La extensión .id() se utiliza para proporcionar identificadores personalizados a nuestras vistas, dándoles la identidad explícita.
Identidad estructural
Cada vista SwiftUI debe tener una identidad. Si la vista no tiene una identidad explícita, tiene una identidad estructural. Una identidad estructural es cuando la vista es identificada usando su tipo y su posición en una jerarquía de vistas. SwiftUI utiliza la jerarquía de vistas para generar la identidad implícita para las vistas.
Considere el siguiente ejemplo:
struct ContentView: View {
@State var isRounded: Bool = false
var body: some View {
if isRounded {
PizzaView()
.cornerRadius(25)
} else {
PizzaView()
.cornerRadius(0)
}
PizzaView()
.cornerRadius(isRounded ? 25 : 0)
Toggle("Round", isOn: $isRounded.animation())
.fixedSize()
}
}
Como se ve en el ejemplo anterior, hay dos enfoques diferentes para implementar el cambio animado del radio de la esquina para la PizzaView.
El primer enfoque crea dos vistas completamente distintas, dependiendo del estado booleano. En realidad SwiftUI creará una instancia de la vista ConditionalContent entre bastidores. Esta vista ConditionalContent es responsable de presentar una u otra vista basada en la condición. Y estas vistas de pizza tienen diferentes identidades de vista, debido a la condición utilizada. En este caso SwiftUI redibujará la vista una vez que el toggle haya cambiado, y aplicará la transición fade in/out para el cambio, como se ve en la Figura 8 de abajo. Es importante entender que no es la misma PizzaView, son dos vistas diferentes y tienen sus propias identidades estructurales. También se puede implementar utilizando el modificador de vista:
PizzaView()
.cornerRadius(isRounded ? 25 : 0)
Esto mantendrá la identidad estructural de la vista igual, y SwiftUI no aplicará la transición fade in/out. Se animará el cambio de radio de la esquina, como se muestra en la Figura 8 a continuación, porque para el marco es la misma vista, sólo con diferentes valores de propiedad.
En este caso, la identidad estructural de la vista no cambia. Apple recomienda preservar la identidad de la vista poniendo condicionales dentro del modificador de la vista en lugar de usar sentencias if/else.
La identidad estructural y su comprensión es clave para una aplicación mejor optimizada y con menos errores. También explica por qué utilizar un modificador de vista condicional puede ser una mala idea.
Hay que tener en cuenta algunas cosas para conseguir un mejor rendimiento:
- Mantenga la identidad de la vista. Si puedes, no utilices sentencias condicionales para preservar la identidad.
- Utilice identificadores estables para su vista si se proporcionan explícitamente.
- Evite utilizar AnyView si es posible
Un ejemplo real de identidad visual en DoorDash
Veamos un ejemplo dentro de la aplicación iOS de DoorDash. La vista de contactos muestra la lista de contactos y permite al usuario elegir uno o varios contactos, como se ve en la Figura 9 a continuación. El componente de la lista de contactos se utiliza hoy en DoorDash cuando se envía un regalo.
Esta vista utiliza el marco de Contactos para obtener los contactos en el dispositivo y transformar esos contactos en secciones con títulos que se mostrarán en el componente `List` de SwiftUI.
Para ello, iteramos sobre nuestra lista de secciones mediante un `ForEach` y las mostramos en la lista con la clave del identificador único de la sección.
```
List {
ForEach(listSections, id: \.id) { contactsSection in
// Display the contact section header & rows
}
}
```
La `ContactSection` es responsable de encapsular las propiedades necesarias para mostrar la lista de contactos en la vista. Contiene 3 propiedades:
- Un identificador único para la sección
- El título de la sección
- La lista de contactos de la sección
```
struct ContactSection {
let id: String = UUID().uuidString
let title: String
let contacts: [Contacts]
init(title: String, contacts: [Contacts]) {
self.title = title
self.contacts = contacts
}
}
Nuestros contactos se muestran ahora en la lista, pero tenemos un problema: cuando se selecciona un número de teléfono no válido de la lista, aparece un mensaje animado en la vista para alertar al cliente. Cuando aparece este mensaje, toda la lista cambia de nombre (Figura 10), como si hubiera nuevos datos que presentar: no es la experiencia ideal para el usuario.
A medida que la vista se anima, Swift redibuja la vista y, por tanto, nuestra lista. Cada vez que accedemos a la variable computada que genera las secciones, la estructura `ContactSection` se inicializa con un nuevo identificador diferente para la misma sección.
En este caso, el título de nuestras secciones es la primera inicial del nombre del contacto, lo que hace que cada título sea único. Así que podemos eliminar la propiedad `id` de nuestra estructura `ContactSection` y ordenar la lista por el título en lugar de por el identificador inconsistente.
List {
ForEach(listSections, id: \.title) { contactsSection in
// Display the contact section header & rows
}
}
Ahora, como se ve en la Figura 11, ¡la animación tiene un aspecto estupendo!
Cuando usamos el componente `List` en SwiftUI, queremos recordar usar un identificador persistente para la clave de la lista; mejora nuestras animaciones y rendimiento.
Conclusión
A partir de lo anterior, podemos ver claramente las ventajas en términos de experiencia de usuario y rendimiento cuando preservamos la identidad de una vista y gestionamos las dependencias de forma correcta y eficiente. Estos conceptos son esenciales para escribir aplicaciones iOS mejor optimizadas, fluidas y eficaces con SwiftUI. El marco de trabajo utiliza un algoritmo de diferenciación basado en tipos para determinar qué vistas redibujar para cada cambio de estado, y hace todo lo posible para garantizar que nuestra interfaz de usuario siga siendo eficiente y optimizada. Todavía tenemos que escribir código eficiente, y entender cómo funcionan las invocaciones del cuerpo, cómo se gestionan las dependencias, y cómo preservar la identidad de la vista.