Una simple implementación de Remote Configuration para SwiftUI

Compartir esta publicación

En primer lugar, vamos a por una rápida definición de la Configuración Remota: es una forma de personalizar el comportamiento de un sistema deseado basado en ciertos parámetros que se almacenan en una ubicación remota.

Muchas bibliotecas bien conocidas te darán esta funcionalidad, y muchos de nosotros estamos tentados a integrar simplemente esta dependencia grande, compleja y desconocida sin evaluar el coste real de la misma.

En este artículo, te guiaré a través de lo que encuentro una forma sencilla de lograr la Configuración Remota de forma nativa y aplicarla a un flujo de aplicaciones SwiftUI.

Para este ejemplo necesitaré tener un archivo de configuración almacenado en algún lugar remoto. Este podría ser un servicio web, CMS o cualquier servicio que utilice para almacenar datos de forma remota. Simplemente lo cargaré en el sistema de almacenamiento Firebase Storage y lo descargaré a través de su URL multimedia, para simplificar.

Aquí tenemos el archivo JSON que vamos a utilizar para configurar nuestra aplicación.

{
  "minVersion": "1.0.0"
}

Ahora vamos a crear una estructura modelo para almacenar esta configuración en nuestro entorno de la siguiente manera:

struct AppConfig {
    let minVersion: String
}

Y luego crearemos una clase que se encargará de proporcionarnos esta configuración, la llamaremos ConfigProvider. 

class ConfigProvider {
  private(set) var config: AppConfig
}

Ahora necesitamos una forma de poblar esta configuración, y como queremos que esta aplicación siempre tenga una configuración adecuada para funcionar, implementaremos un cargador de configuración local que nos proporcione una configuración por defecto o en caché. Vamos a definir un protocolo con las características que necesitamos del mismo:

protocol LocalConfigLoading {
  func fetch() -> AppConfig
  func persist(_ config: AppConfig)
}

No profundizaré demasiado en la explicación de la clase que implementará este protocolo porque no está relacionado con nuestro objetivo y podría hacerse de otras maneras. Codificaremos una clase llamada LocalConfigLoader que obtendrá del paquete nuestra configuración por defecto o una versión en caché de la misma si está disponible. También será capaz de persistir una configuración en nuestro directorio de Documentos, el mencionado caché.

class LocalConfigLoader: LocalConfigLoading {
  private var cachedConfigUrl: URL? {
    guard let documentsUrl = FileManager.default
      .urls(for: .documentDirectory, in: .userDomainMask).first else {
      return nil
    }
    return documentsUrl.appendingPathComponent("config.json")
  }
  private var cachedConfig: AppConfig? {
    let jsonDecoder = JSONDecoder()
    guard let configUrl = cachedConfigUrl,
          let data = try? Data(contentsOf: configUrl),
          let config = try? jsonDecoder.decode(AppConfig.self, from: data)
    else {
      return nil
    }
    return config
  }
  private var defaultConfig: AppConfig {
    let jsonDecoder = JSONDecoder()
    guard let url = Bundle.main.url(forResource: "config",
                                    withExtension: "json"),
          let data = try? Data(contentsOf: url),
          let config = try? jsonDecoder.decode(AppConfig.self, from: data)
    else {
      fatalError("Bundle must include default config.")
    }
    return config
  }
  func fetch() -> AppConfig {
    if let cachedConfig = self.cachedConfig {
      return cachedConfig
    } else {
      let config = self.defaultConfig
      persist(config)
      return config
    }
  }
  func persist(_ config: AppConfig) {
    guard let configUrl = cachedConfigUrl else {
      return
    }
    do {
      let encoder = JSONEncoder()
      let data = try encoder.encode(config)
      try data.write(to: configUrl)
    } catch {
      print(error)
    }
  }
}

En este punto deberíamos ser capaces de integrar esta clase a nuestro ConfigProvider que nos dará una configuración por defecto. Así que vamos a añadir la dependencia:

class ConfigProvider {
  private(set) var config: AppConfig
  private let localConfigLoader: LocalConfigLoading
  init(localConfigLoader: LocalConfigLoading) {
    self.localConfigLoader = localConfigLoader
    config = localConfigLoader.fetch()
  }
}

Instanciar esta clase, extraerá la configuración local de inmediato. Ahora necesitamos una forma de obtener esta configuración de nuestro flujo de aplicación. Para realizar esta tarea usaremos Combine y haremos que nuestra clase ConfigProvider se ajuste al protocolo ObservableObject y expondremos la variable de configuración con el wrapper @Published. De esta forma podremos responder a un cambio en esta variable desde donde se necesite en la aplicación, sin necesidad de pasar ningún valor de la misma.

  Migración a Swift 6: La estricta concurrencia que debes adoptar

Esta es la clase ConfigProvider lista para ser consumida por nuestra aplicación SwiftUI:

Import Combine
class ConfigProvider: ObservableObject {
  @Published private(set) var config: AppConfig
  private let localConfigLoader: LocalConfigLoading
  init(localConfigLoader: LocalConfigLoading) {
    self.localConfigLoader = localConfigLoader
    config = localConfigLoader.fetch()
  }
}

Vamos ahora al punto de entrada principal de nuestra aplicación, agreguemos nuestro proveedor de configuración como una propiedad y pongámoslo como un objeto de entorno para nuestro ContentView.

import SwiftUI
@main
struct RemoteConfigExampleApp: App {
    let configProvider = ConfigProvider(localConfigLoader: LocalConfigLoader())
    var body: some Scene {
      WindowGroup {
        ContentView()
          .environmentObject(configProvider)
      }
    }
}

Y en nuestro ContentView consumimos este objeto de entorno de la siguiente manera:

struct ContentView: View {
  @EnvironmentObject var configProvider: ConfigProvider
  var body: some View {
    Text(configProvider.config.minVersion)
      .padding()
  }
}

¡Recuerda añadir un archivo config.json a tu paquete! Ya estamos listos para construir y lanzar nuestra aplicación. Deberías ver la versión del archivo de configuración por defecto en la pantalla:

SwiftUI

Finalmente conseguiremos implementar el verdadero cargador de configuración remota, que sólo tendrá que recuperar la configuración desde donde haya almacenado su archivo JSON de configuración remota.

El protocolo adoptado por esta clase podría ser así:

protocol RemoteConfigLoading {
  func fetch() -> AnyPublisher<AppConfig, Error>
}

Lo único que hay que señalar sobre la implementación es el uso de Combine Publishers para mapear, decodificar y devolver la información.

Aquí está la clase:

import Combine
import Foundation
class RemoteConfigLoader: RemoteConfigLoading {
  func fetch() -> AnyPublisher<AppConfig, Error> {
    let configUrl = URL(string: "https://firebasestorage.googleapis.com/apiumhub/config.json")!
    return URLSession.shared.dataTaskPublisher(for: configUrl)
      .map(\.data)
      .decode(type: AppConfig.self, decoder: JSONDecoder())
      .eraseToAnyPublisher()
  }
}

Ahora necesitamos integrarlo con ConfigProvider e implementar un método para actualizar nuestra configuración con el RemoteConfigLoader. Como necesitamos suscribirnos a un Publisher de Combine, y sólo queremos cargar una configuración a la vez, almacenaremos un cancellable y la limpiaremos después de haber obtenido la configuración.

Import Combine
class ConfigProvider: ObservableObject {
  @Published private(set) var config: AppConfig
  private let localConfigLoader: LocalConfigLoading
  private let remoteConfigLoader: RemoteConfigLoading
  init(localConfigLoader: LocalConfigLoading,
       remoteConfigLoader: RemoteConfigLoading
  ) {
    self.localConfigLoader = localConfigLoader
    self.remoteConfigLoader = remoteConfigLoader
    config = localConfigLoader.fetch()
  }
  private var cancellable: AnyCancellable?
  private var syncQueue = DispatchQueue(label: "config_queue_\(UUID().uuidString)")
  func updateConfig() {
    syncQueue.sync {
      guard self.cancellable == nil else {
        return
      }
      self.cancellable = self.remoteConfigLoader.fetch()
        .sink(receiveCompletion: { completion in
          // clear cancellable so we could start a new load
          self.cancellable = nil
        }, receiveValue: { [weak self] newConfig in
          DispatchQueue.main.async {
              self?.config = newConfig
          }
          self?.localConfigLoader.persist(newConfig)
        })
    }
  }
}

Lo último que hay que hacer es añadir la dependencia a la inicialización de ConfigProvider, buscar la última configuración remota tan pronto como lancemos la aplicación e implementar algún tipo de funcionalidad basada en la versión. Le diremos al usuario que actualice la configuración si está desactualizada.

  Tendencias en aplicaciones móviles

Ahora nuestro principal punto de entrada de la aplicación se verá así:

import SwiftUI
@main
struct RemoteConfigExampleApp: App {
    let configProvider = ConfigProvider(localConfigLoader: LocalConfigLoader(),
                                        remoteConfigLoader: RemoteConfigLoader())
    var body: some Scene {
      WindowGroup {
        ContentView()
          .environmentObject(configProvider)
          .onAppear(perform: {
            self.configProvider.updateConfig()
          })
      }
    }
}

Y ahora podemos modificar el comportamiento del ContentView en base a la configuración de nuestra aplicación. Lo mantendré muy simple aquí y sólo mostraré la versión, diciendo si ha sido actualizada desde el archivo de configuración por defecto:

import SwiftUI
struct ContentView: View {
  @EnvironmentObject var configProvider: ConfigProvider
  var body: some View {
    Text(configProvider.config.minVersion)
      .padding()
    if configProvider.config.minVersion == "1.0.0" {
        Text("Out Of Date")
    } else {
        Text("Updated")
    }
  }
}

Si actualizas tu archivo de configuración remota en el recurso remoto que utilizaste y ejecutas la aplicación, verás que los valores cambian tan pronto como se obtiene la información:

Img2

Para concluir, me gustaría decir que, aparte de la característica de Configuración Remota en sí y la forma de integrarla con las últimas características del entorno de desarrollo Swift, el objetivo de este artículo es hacernos pensar en los pros y los contras de la inyección de dependencias antes de hacerlo. Porque muchas veces nos saltamos este paso, vamos directos por el camino más rápido, lo que no sólo te puede dar muchos problemas en el futuro, sino que también estás perdiendo la oportunidad de aprender cómo lograr por tí mismo lo que la biblioteca que acabas de integrar está haciendo. En el caso de uso de este artículo, ya has visto que es realmente rápido y simple de lograr sin dependencias externas.

Author

  • Felipe Ferrari

    iOS Developer working in the software development industry with agile methodologies. Skilled in Swift, objective-C, Python, PostgreSQL, SQL, PHP, and C++. More than 8 years of experience working as iOs Developer, following the best practices.

    Ver todas las entradas
  Behavior Driven Development: La metodología que conecta a los tres amigos

Leave a Reply

Your email address will not be published. Required fields are marked *

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>

Suscríbete a nuestro boletín de noticias

Recibe actualizaciones de los últimos descubrimientos tecnológicos

¿Tienes un proyecto desafiante?

Podemos trabajar juntos

apiumhub software development projects barcelona
Secured By miniOrange