First 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 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
}
And then we will 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, and 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 with 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 be also 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 to 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 make use of 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)
})
}
}
}
Last things to do, 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, the aim of this article is 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 not only can give you a variety of problems in the future but also 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.
Author
-
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.
View all posts
2 Comments
dal
nice example, do you have repo for it?
Amit Saini
Nice way of doing it.