A Simple Implementation of Remote Configuration For SwiftUI
Implementing the Remote Configuration feature and integrating it with the latest Swift development environment; the pros and cons of adding dependencies.
Join the DZone community and get the full member experience.
Join For FreeFirst of all, a quick definition of Remote Configuration: It is a way to customize the behaviour of a desired system based on certain parameters that are stored on a remote location.
Many well-known libraries will give you this feature, and many of us are tempted to just integrate this big, complex, and unknown dependency without evaluating the real cost of it.
In this article on behalf of Apiumhub, I will guide you through what I find a simple way to achieve Remote Configuration natively and apply it to a SwiftUI App flow.
For this example, I will need to have a configuration file stored somewhere remotely. This could be a web service, CMS, or whichever service you use to store data remotely. I will just upload it to Firebase Storage and download it via its media URL for the sake of simplicity.
Here we have the JSON file we are going to use to configure our application.
{
"minVersion": "1.0.0"
}
Now we are going to create a model struct to store this configuration in our environment as follows:
struct AppConfig {
let minVersion: String
}
We will then create a class that will be responsible for providing us with this configuration; we will call it ConfigProvider
.
class ConfigProvider {
private(set) var config: AppConfig
}
Now we need a way to populate this configuration. As we want this app to always have a proper configuration to work, we will implement a local configuration loader to provide us a default or cached configuration. Let’s define a protocol with the features that we need from it:
protocol LocalConfigLoading {
func fetch() -> AppConfig
func persist(_ config: AppConfig)
}
I will not get too deep on the explanation of the class that will implement this protocol because it is not related to our objective and could be done in other ways. We will code a class called LocalConfigLoader
which will get from the bundle our default configuration, or a cached version of it, if available. It will also be capable of persisting a configuration in our Documents directory, the mentioned cache.
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)
}
}
}
At this point, we should be able to integrate this class into our ConfigProvider, and it will be capable of giving a default configuration. So let’s add the dependency:
class ConfigProvider {
private(set) var config: AppConfig
private let localConfigLoader: LocalConfigLoading
init(localConfigLoader: LocalConfigLoading) {
self.localConfigLoader = localConfigLoader
config = localConfigLoader.fetch()
}
}
So instantiating this class will fetch the local configuration right away. Now we need a way to get this configuration from our application flow. For this task, we will use Combine and make our ConfigProvider class conform ObservableObject protocol and expose the configuration variable with the @Published wrapper. This way, we will be able to respond to a change on this variable from where it’s needed in the application, without needing to pass any values of it.
This is the ConfigProvider class ready to be consumed by our SwiftUI application:
Import Combine
class ConfigProvider: ObservableObject {
@Published private(set) var config: AppConfig
private let localConfigLoader: LocalConfigLoading
init(localConfigLoader: LocalConfigLoading) {
self.localConfigLoader = localConfigLoader
config = localConfigLoader.fetch()
}
}
Now let’s go to the main entry point of our app, add our config provider as a property, and set it as an environment object for our ContentView.
import SwiftUI
@main
struct RemoteConfigExampleApp: App {
let configProvider = ConfigProvider(localConfigLoader: LocalConfigLoader())
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(configProvider)
}
}
}
And in our ContentView, we consume this environment object as follows:
struct ContentView: View {
@EnvironmentObject var configProvider: ConfigProvider
var body: some View {
Text(configProvider.config.minVersion)
.padding()
}
}
Don’t forget to add a config.json file to your bundle! And we are ready to build and launch our app. You should see the default configuration file version on the screen:
Finally, we will get to implement the real remote configuration loader, which will only need to fetch the configuration from wherever you stored your remote configuration JSON file.
The protocol adopted by this class could be then like this:
protocol RemoteConfigLoading {
func fetch() -> AnyPublisher<AppConfig, Error>
}
The only thing to note on the implementation is the use of Combine Publishers to map, decode, and return the information.
Here is the class:
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()
}
}
Now we need to integrate it with ConfigProvider and implement a method to update our configuration with the RemoteConfigLoader. Since we need to subscribe to a Combine Publisher, and we only want to load one configuration at a time, we will store a cancellable and clean it after successfully fetching the configuration.
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)
})
}
}
}
The last thing to do is add the dependency to the ConfigProvider initialization, fetch the last remote configuration as soon as we launch the app, and implement some kind of feature based on the version. I will tell the user to update the config if it is outdated.
Now our main entry point of the app will look like this:
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()
})
}
}
}
And now, we can modify the behaviour of the ContentView based on our app configuration. I will keep it really simple here and just display the version, saying if its has been updated from the default configuration file:
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")
}
}
}
If you update your remote config file on the remote resource you used and run the app, you will see the values changing as soon as the information is fetched:
To conclude, I would like to say that apart from the Remote Configuration feature itself and the way of integrating it with the latest features of the Swift development environment, this article aims to make us think of the pros and cons of adding dependencies before doing it. Because lots of times we skip this step, going the fast way, which can give you a variety of problems in the future and you are missing the opportunity to learn how to achieve yourself what the library you just integrated is doing. In the case of this article’s feature, you saw it was really fast and simple to achieve without external dependencies.
Published at DZone with permission of Felipe Ferrari. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments