Aprende a usar el patrón delegación en Swift y evita retain cycles en Swift
Aprende a usar el patrón delegación en Swift y evita retain cycles en Swift

Delegation Pattern y Retain Cycles en Swift

Eel patrón delegation es un patrón que nos permite delegar de una clase A a una clase B para realizar una tarea. Al acabar en nuestra clase B volvemos al flujo de nuestra app (clase A). También veremos lo fácil que es crear retain cycles en nuestro código y por lo tanto generar memory leaks.

SwiftBeta

Tabla de contenido


👇 SÍGUEME PARA APRENDER SWIFTUI, SWIFT, XCODE, etc 👇
🤩 ¡Sígueme en Twitter!
▶️ ¡Suscríbete al canal!
🎁 ¡Apoya el canal!

Aprende sobre el patrón de diseño delegación en Swift y UIKit

Hoy en SwiftBeta vamos a ver el patrón Delegación, este patrón es muy usado en aplicaciones iOS y vas a ver por qué lo solemos utilizar, por ejemplo lo hemos ido viendo durante el curso de UIKit, cuando usábamos UITableViewDelegate, UITableViewDataSource, UICollectionViewDelegate, UICollectionViewDataSource, etc. Este patrón puede implementarse tanto en UIKit y SwiftUI. No depende del framework de UI que estemos utilizando, y incluso en otros lenguajes, es un patrón que cualquier lenguaje orientado a objetos puede utilizar (esto lo comento para que quede muy claro), se puede utilizar con otros lenguajes de programación. Y el delegation pattern quería explicarlo antes de llegar a los próximos videos donde hablaremos de arquitecturas, así será mucho más simple entender por ejemplo, la arquitectura Model-View-Presenter.
Pero ¿qué hace este Patrón? Este patrón sirve por ejemplo, cuando estamos en una clase A y queremos realizar alguna acción que está en otra clase, por ejemplo en una clase B. Cuando la clase B finaliza de hacer la operación que le ha solicitado la clase A, utilizamos este patrón para volver a la clase inicial y seguir con el flujo de nuestra lógica. Cuando volvemos a la clase inicial, en nuestro caso la clase A, puede recibir información que le ha pasado la clase B, de esta manera la clase A puede manipular esos datos.

Vamos a ver un caso real, pero si quieres que siga creando este tipo de contenido, completamente gratis, puedes suscribirte en el canal, de esta manera seguiré creando contenido. Ahora sí, seguimos con el ejemplo, imagina que has cargado la vista de tu ViewController, y al pulsar un UIButton, quieres hacer una petición HTTP para obtener un modelo JSON que necesitas para dibujar en la vista, y justo tienes una clase que se llama APIClient que tiene esta responsabilidad (hemos creado esta clase, para no añadir código al ViewController relacionado con peticiones HTTP, ya que no es su responsabilidad). Es decir, desde nuestra clase A (que es nuestro ViewController) llamamos a nuestra clase B (que es nuestro APIClient).
Cuando el APIClient finaliza de obtener la información, se la pasa al ViewController y muestra la información en la vista. Para hacerlo vamos a utilizar el Delegation Pattern con este mismo ejemplo, cuando pulsemos un UIButton vamos a realizar una petición HTTP  para obtener un listado de pokemons, y vamos a mostrar el nombre de uno de ellos en un UILabel. Vas a ver que es muy sencillo.

Creamos el proyecto en Xcode

Lo primero de todo que vamos hacer es crear el proyecto en Xcode. Acuérdate que estamos usando UIKit, es por eso que debes seleccionar Storyboard en Interface.

Creamos la vista del ViewController

Una vez hemos creado el proyecto, nos vamos al ViewController y allí vamos a crear y añadir a la vista un UILabel y un UIButton:

import UIKit

class ViewController: UIViewController {
    private let nameLabel: UILabel = {
        let label = UILabel()
        label.numberOfLines = 2
        label.textAlignment = .center
        label.text = "placeholder"
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()
    
    private lazy var acceptButton: UIButton = {
        var configuration = UIButton.Configuration.bordered()
        configuration.title = "¡Suscríbete a SwiftBeta!"
        
        let button = UIButton(type: .system, primaryAction: UIAction(handler: { _ in
            self.didTapOnAcceptButton()
        }))
        
        button.configuration = configuration
        button.translatesAutoresizingMaskIntoConstraints = false
        return button
    }()
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.addSubview(acceptButton)
        view.addSubview(nameLabel)
        
        NSLayoutConstraint.activate([
            nameLabel.bottomAnchor.constraint(equalTo: acceptButton.topAnchor, constant: -32),
            nameLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            nameLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            acceptButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            acceptButton.centerYAnchor.constraint(equalTo: view.centerYAnchor),
        ])
    }

    func didTapOnAcceptButton() {
       // TODO:
    }
}
Creamos nuestro ViewController con UIKit

Como hemos dicho, nuestro ViewController va a ser la clase A, la clase que va a solicitar una tarea a otra clase B. Y la llamaremos cuando se pulse el UIButton de la clase A, de nuestro ViewController. Nuestro APIClient va a ser muy sencillo.

Creamos nuestro APIClient

Dentro de nuestra clase, creamos el siguiente método:

class APIClient {
    func getPokemons() {
        let url = URL(string: "https://pokeapi.co/api/v2/pokemon/?offset=0&limit=151")!
        let task = URLSession.shared.dataTask(with: url) {
            data, response, error in
            let dataModel = try! JSONDecoder().decode(PokemonResponseDataModel.self, from: data!)
            print("DataModel \(dataModel)")
        }
        task.resume()
    }
}
APIClient sencillo en Swift

Como es normal, el compilador se queja, ya que no hemos creado PokemonResponseDataModel, así que ahora vamos a crear dos structs necesarías para parsear el JSON a un objeto de nuestro dominio:

struct PokemonDataModel: Decodable {
    let name: String
    let url: String
}

struct PokemonResponseDataModel: Decodable {
    let pokemons: [PokemonDataModel]
    
    enum CodingKeys: String, CodingKey {
        case results
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.pokemons = try container.decode([PokemonDataModel].self, forKey: .results)
    }
}
Decoder del JSON que recibiremos en nuestra petición HTTP

Perfecto, ya tenemos nuestra clase A (nuestro ViewController) y nuestra clase B (nuestro APIClient). Ahora lo que vamos hacer es crear una instancia de APIClient en nuestro ViewController, de esta manera cuando se pulse el UIButton podemos llamar al método getPokemons().

Conectamos APIClient con ViewController

Para hacerlo creamos una propiedad de apiClient en ViewController, y llamamos al método getPokemons al pulsar el UIButton:

class ViewController: UIViewController {
    let apiClient = APIClient()
    
    // aquí va el código que hemos añadido al principio del post
    
    func didTapOnAcceptButton() {
        apiClient.getTracks()
    }
}
Al pulsar el UIButton realizamos la petición HTTP

Si compilamos la app y damos al UIButton, vemos como se realiza la petición HTTP correctamente. Pero hemos hecho el camino de IDA y no de VUELTA, queremos que la información obtenida al realizar la petición, se le pase al ViewController para poder mostrar en el UILabel el nombre de un pokemon de forma aleatoria. Ahora es el momento de utilizar el Delegation Pattern, nos vamos a nuestro APIClient, y allí vamos a crear un protocolo:

protocol APIClientDelegate {
    func update(pokemons: [PokemonResponseDataModel])
}
Creamos nuestro protocolo (Interfaz)

Este protocolo significa muchas cosas que vamos hacer a continuación:

  • Vamos a crear una propiedad llamada delegate en el APIClient que será de tipo APIClientDelegate
  • Cuando obtengamos el resultado de la petición HTTP, llamaremos a la propiedad y el método update(pokemons:) pasándole nuestro modelo de dominio.
  • En ViewController conformaremos este protocolo, así de esta manera obtendremos el resultado obtenido de nuestro APIClient. Y asignaremos un nombre de un pokemon random al UILabel.
  • Para que todo esto funcione, al crear la instancia de APIClient, debemos asignarle la propiedad delegate. Y en esta propiedad delegate le vamos a asignar la instancia de nuestro ViewController.

Nuestra clase APIClient quedaría de la siguiente manera:

class APIClient {
    var delegate: APIClientDelegate?
    
    func getTracks() {
        let url = URL(string: "https://pokeapi.co/api/v2/pokemon/?offset=0&limit=151")!
        let task = URLSession.shared.dataTask(with: url) { [weak self]
            data, response, error in
            let dataModel = try! JSONDecoder().decode(PokemonResponseDataModel.self, from: data!)
            print("DataModel \(dataModel)")
            self?.delegate?.update(pokemons: dataModel.pokemons)
        }
        task.resume()
    }
}
Código de nuestro APIClient

Y nuestro ViewController quedaría de la siguiente manera:

import UIKit

class ViewController: UIViewController, APIClientDelegate {
    let apiClient = APIClient()
    
    private let nameLabel: UILabel = {
        let label = UILabel()
        label.numberOfLines = 2
        label.textAlignment = .center
        label.text = "placeholder"
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()
    
    private lazy var acceptButton: UIButton = {
        var configuration = UIButton.Configuration.bordered()
        configuration.title = "¡Suscríbete a SwiftBeta!"
        
        let button = UIButton(type: .system, primaryAction: UIAction(handler: { [weak self] _ in
            self?.didTapOnAcceptButton()
        }))
        
        button.configuration = configuration
        button.translatesAutoresizingMaskIntoConstraints = false
        return button
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        apiClient.delegate = self
        
        view.addSubview(acceptButton)
        view.addSubview(nameLabel)
        
        NSLayoutConstraint.activate([
            nameLabel.bottomAnchor.constraint(equalTo: acceptButton.topAnchor, constant: -32),
            nameLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            nameLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            acceptButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            acceptButton.centerYAnchor.constraint(equalTo: view.centerYAnchor),
        ])
    }

    func didTapOnAcceptButton() {
        apiClient.getTracks()
    }
    
    func update(pokemons: [PokemonDataModel]) {
        DispatchQueue.main.async {
            self.nameLabel.text = pokemons.randomElement()?.name
        }
    }
}
Código de nuestro VIewController en Swift y UIKit

Vamos a compilar y vamos a ver qué ocurre. Ahora cada vez que pulsemos nuestro UIButton se hace una petición HTTP y el APIClient nos retorna la lista de pokemons. Nosotros cogemos 1 de forma random y mostramos el nombre en el UILabel de nuestra vista del ViewController.

Retain Cycles

Al utilizar este patrón en iOS hay que prestar atención a no crear un retain cycle. ¿Qué es un retain cycle? Nuestra app tiene una memoria limitada, y hay casos en que si no manejamos bien nuestras instancias de clases en nuestro código, podemos hacer que nuestra app no pare de consumir memoria y por lo tanto nunca sea liberada, acabando en un crash. Un retain cycle ocurre cuando dos instancias tienen referencias fuertes entre ellas.

Recurso extraido de Apple. Muestra dos referencias fuertes entre dos instancias
Recurso extraido de Apple. Muestra dos referencias fuertes entre dos instancias

En nuestro código tenemos un retain cycle que impide que se libere memoria de nuestra app, vamos a ver qué ocurre y cómo lo podemos arreglar.

Nuestra app tiene dos referencias fuertes, de ViewController a APIClient y de APIClient a ViewController. Vamos a copiar y pegar el código de ViewController en un nuevo ViewController2 y vamos a presentarlo desde ViewController, de esta manera verás que cada vez que lo presentamos y dismisseamos los recursos (instancias) que hemos utilizado no desaparecen de la memoria de nuestra app.

Creamos ViewController2

Dentro de este nuevo ViewController vamos a añadir un método que se llama deinit justo al inicio de nuestra clase:

class ViewController2: UIViewController, APIClientDelegate {
    deinit {
        print("Deinit ViewController 2")
    }
    // Todo lo demás es exactamente igual
}
Usamos deinit para saber cuándo se deinicializa un ViewController

Este método se llamará cuando se libere nuestro ViewController2 de la memoria de nuestra app (también creamos el deinit en nuestro APIClient), y acabará printando un mensaje por consola.
También aprovechamos y cambiamos el backgroundColor de nuestro ViewController2:

override func viewDidLoad() {
    super.viewDidLoad()
        
    apiClient.delegate = self
        
    view.backgroundColor = .orange
    // Todo lo demás es exactamente igual
}
Cambiamos el backgroundColor de nuestra View del ViewController

Ahora volvemos a nuestro ViewController y vamos a añadir un UIButton para cada vez que se pulse se navegue al ViewController2. Y así es como quedaría nuestro ViewController:

import UIKit

class ViewController: UIViewController, APIClientDelegate {
    let apiClient = APIClient()
    
    private let nameLabel: UILabel = {
        let label = UILabel()
        label.numberOfLines = 2
        label.textAlignment = .center
        label.text = "placeholder"
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()
    
    private lazy var acceptButton: UIButton = {
        var configuration = UIButton.Configuration.bordered()
        configuration.title = "¡Suscríbete a SwiftBeta!"
        
        let button = UIButton(type: .system, primaryAction: UIAction(handler: { [weak self] _ in
            self?.didTapOnAcceptButton()
        }))
        
        button.configuration = configuration
        button.translatesAutoresizingMaskIntoConstraints = false
        return button
    }()
    
    
    private lazy var presentViewController2Button: UIButton = {
        var configuration = UIButton.Configuration.bordered()
        configuration.title = "Present ViewController 2"
        
        let button = UIButton(type: .system, primaryAction: UIAction(handler: { [weak self] _ in
            self?.didTapOnPresentViewController2Button()
        }))
        
        button.configuration = configuration
        button.translatesAutoresizingMaskIntoConstraints = false
        return button
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        apiClient.delegate = self
        
        view.addSubview(acceptButton)
        view.addSubview(nameLabel)
        view.addSubview(presentViewController2Button)
        
        NSLayoutConstraint.activate([
            nameLabel.bottomAnchor.constraint(equalTo: acceptButton.topAnchor, constant: -32),
            nameLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            nameLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            acceptButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            acceptButton.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            presentViewController2Button.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            presentViewController2Button.centerYAnchor.constraint(equalTo: acceptButton.bottomAnchor, constant: 32),
        ])
    }

    func didTapOnAcceptButton() {
        apiClient.getTracks()
    }
    
    func update(pokemons: [PokemonDataModel]) {
        DispatchQueue.main.async {
            self.nameLabel.text = pokemons.randomElement()?.name
        }
    }
    
    func didTapOnPresentViewController2Button() {
        present(ViewController2(),
                animated: true)
    }
}
Código final en Swift de todo el post de hoy

Si compilamos nuestra app, vamos a presentar y dismissear 5 veces nuestro ViewController2:

Simulador mostrando los dos UIButtons
Simulador mostrando los dos UIButtons

Una vez lo hemos hecho, ¿cómo podemos saber si hay retain cycles? Vamos a pulsar el siguiente button de Xcode

Xcode Debug Memory Graph button
Xcode Debug Memory Graph button

Al hacerlo, nos aparecerá el grafo de memoria de nuestra app, y podremos ver si tenemos problemas.

Memory Leak, múltiples instancias de nuestro ViewController2
Memory Leak, múltiples instancias de nuestro ViewController2 

Y en nuestro caso, podemos ver que tenemos 5 instancias de nuestro ViewController aún "vivas" en la memoria de nuestra app. Esto es debido a que tenemos 1 o más retain cycles, y que hemos introducido nosotros al programar nuestra app.

Grafo completo al pulsar en Debug Memory Graph (herramienta de Xcode)
Grafo completo al pulsar en Debug Memory Graph (herramienta de Xcode)

Debuggando el grafo de memoria de nuestra app veo que es posible que tengamos dos retain cycles. Vamos a por el más evidente. Al tener una referencia fuerte de ViewController al APIClient y otra referencia fuerte de APIClient a ViewController creamos un retain cycle y nunca se liberaran estas dos instancias. Lo que vamos hacer es crear un referencia débil, y para hacerlo nos vamos a nuestro APIClient y en la property delegate vamos a añadir weak:

class APIClient {
    deinit {
        print("Deinit APIClient")
    }
    
    weak var delegate: APIClientDelegate?
    // Todo lo demás es exactamente igual
}
Añadimos deinit en nuestro APIClient

Ahora vamos a compilar y vamos a ver si hemos arreglado el retain cycle de nuestra app. Para ello volvemos hacer la misma prueba, de presentar y dimissear ViewController2. Y a medida que lo hacemos vemos por consola:

Deinit ViewController 2
Deinit APIClient
Deinit ViewController 2
Deinit APIClient
Deinit ViewController 2
Deinit APIClient
Deinit ViewController 2
Deinit APIClient
Deinit ViewController 2
Deinit APIClient
Prints que se muestran por consola, demostrando que se deinicializa correctamente nuestro ViewController2

Esto significa que al romper las strong references que habían, ahora los recursos se liberan de memoria. Quería mostrarte que es muy importante utilizando el Delegation Pattern que no crees referencias fuertes entre instancias de clases y por lo tanto crees retain cycles.

Conclusión

Hoy hemos aprendido a utilizar el delegation pattern con un ejemplo práctico. Dando especial atención a que no creemos retain cycles.