By becoming a paid supporter, you'll not only receive the latest content straight to your inbox, but you'll also contribute to the growth and maintenance of over 20 open-source projects used by 80,000+ teams—including industry giants like Apple, Disney, Airbnb, and more.
Join here
How often have you wanted to change some small setting in your application? In most apps, there are things you want to be able to tweak without generating a new build and getting it through the Apple approval process:
- Configurations for experimental features, e.g., we use this at The Browser Company a lot.
- Policy and reference links, e.g., need to update because you are changing a domain?
- Small Javascript snippets to apply some logic, e.g., we use this at The New York Times to filter out some analytics events before they hit the server.
Behaviors
I always start my work with requirements and expected behaviors:
- The app will always start with the bundled resource
- If valid, we'll replace the bundled resource with a remote one
- If the app version changes, we replace the cached resource with a new bundled one
- In development mode, we want real-time updates
- We want to build an expressive API
- Easy testability
The basic creation of the manager will look like this:
RemoteAssetConfiguration(
bundle: Bundle.main.url(forResource: "Icon", withExtension: "png"),
remote: URL(fileURLWithPath: "/Users/merowing/Downloads/Icon.png"),
materialize: .image.swiftUI
dataProvider: .dataTask(.shared).autoRefresh(interval: 60.fps)
)
But we'll make it even nicer with a shared repository model:
struct ContentView: View {
@ObservedObject
private var remoteImage = RemoteAsset.icon.manager
var body: some View {
remoteImage.asset
}
}
Configuration
Let's look first at what kind of configuration an asset manager would need:
- Bundled Asset URL
- Remote URL
- Cache Directory <- Unless you want to hardcode it
- Materialization function <-
f(Data) throws -> Type
- Needed to verify if the remote asset is valid since it could be in the wrong format
- Data Provider <-
f(URL) -> Data
- We want to be able to provide custom data providing mechanisms e.g., for tests
We could use closures for Materialization and Data Provider, but closures are poor API and don't allow for lovely expressiveness, so instead of them, we'll leverage Callable Types.
Callable type
Callable type is a typed wrapper around a closure function, let's use create a namespace and chaining, and we can create an API like this:
fetch(dataProvider: .dataTask(session: .shared))
and add auto refresh into it by simply chaining it:
fetch(dataProvider: .dataTask(session: .shared).autoRefresh(60.fps)
Creating a callable type is straightforward:
public struct DataProvider {
let closure: (URL) -> AnyPublisher<Data, Never>
func callAsFunction(_ url: URL) -> AnyPublisher<Data, Never> {
closure(url)
}
public init(closure: @escaping (URL) -> AnyPublisher<Data, Never>) {
self.closure = closure
}
}
Using callAsFunction
also allows us to call the instance of the type as a function:
let dataProvider = DataProvider { ... }
let publisher = dataProvider(url)
Implementing the class
Our class needs to start by performing three required steps:
- Copy bundled asset if it's required
- If it didn't exist before
- If the version changed
- Materialize the asset into the expected type
- Subscribe for updates
Let's look at each of those steps
Copying bundled asset
- We check if the file existed. If it didn't, then we could just copy it
- If the file existed, but app version changed, we want to replace the asset with a new bundled one
- We always want to save the app version the file had. We'll leverage extended file attributes for this part to avoid creating a global state
private static func copyFromBundleIfNeeded(_ basePath: URL, cachedPath: URL, appVersion: String) throws {
var fileExists = FileManager.default.fileExists(atPath: cachedPath.path)
if fileExists, String(data: try cachedPath.extendedAttribute(forName: appVersionAttribute), encoding: .utf8) != appVersion {
// version changed, remove old file
try FileManager.default.removeItem(at: cachedPath)
fileExists = false
}
if !fileExists {
try FileManager.default.copyItem(at: basePath, to: cachedPath)
}
if let data = appVersion.data(using: .utf8) {
try cachedPath.setExtendedAttribute(data: data, forName: appVersionAttribute)
}
}
If any of the steps fail, we should abandon the creation of the asset manager since having at least one working asset is critical to app functionality.
Materialising
For materialization, we provide a Callable type that can turn Data into Type:
private static func materializeExisting(_ cachedPath: URL, materialize: Materializer<Type>) throws -> Type {
let data = try Data(contentsOf: cachedPath)
return try materialize(data)
}
Remote updates
For updates, we want to perform similar steps as on the initial setup:
- We fetch the data
- Try to materialize it
- If materialization succeeds, we persist the data in the cache path
- If it fails, we don't proceed, so we keep the previous version
- We set the successful result as the new value for our manager
private func subscribeForUpdates() {
dataProvider(remoteAsset)
.tryMap { [unowned self] data in
let materialized = try materialize(data)
try data.write(to: cachedPath, options: .atomic)
if let data = appVersion.data(using: .utf8) {
try cachedPath.setExtendedAttribute(data: data, forName: appVersionAttribute)
}
return materialized
}
.catch { _ in // Important to not finish publisher, you'd probably want to add error handling here
Just(Type?.none)
}
.compactMap { $0 }
.sink { [unowned self] value in
self.objectWillChange.send()
self.current.send(value)
}
.store(in: &cancellables)
}
Adding convenience helpers
Let's add both Materializer and DataProvider helpers for everyday use cases.
- Image materialization
- URLSession-driven data provider
You can see above how we can leverage chaining to build more complex providers, e.g.
RemoteAssetManager(
dataProvider: .dataTask(.shared).autoRefresh(interval: 1/60)
)
Adding an asset repository model
Often we want to configure our assets in one place for convenience and to allow additional functionality.
Let's define asset configuration first, meaning the things that differ between each asset:
struct RemoteAssetConfiguration<Type> {
var bundle: URL
var remote: URL
var materializer: Materializer<Type>
}
Add a RemoteAsset
namespace and define individual assets there + some configuration:
enum RemoteAsset {
enum Configuration {
#if DEBUG
static var dataProvider: DataProvider = .dataTask(.shared).autoRefresh(interval: 1 / 60)
#else
static var dataProvider: DataProvider = .dataTask(.shared).autoRefresh(interval: 3600)
#endif
static var cacheDirectory: URL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
}
static var icon = RemoteAssetConfiguration(
bundle: Bundle.main.url(forResource: "Icon", withExtension: "png")!,
remote: URL(fileURLWithPath: "/Users/merowing/Downloads/Icon.png"),
materializer: .image.swiftUI
)
}
Then extend RemoteAssetConfiguration
extension RemoteAssetConfiguration {
var manager: RemoteAssetManager<Type> {
try! .init(
baseAsset: bundle,
cacheDirectory: RemoteAsset.cacheDirectory,
remoteAsset: remote,
materialize: materializer,
dataProvider: RemoteAsset.dataProvider
)
}
}
And we can end up with the originally promised API:
struct ContentView: View {
@ObservedObject
private var remoteImage = RemoteAsset.icon.manager
var body: some View {
remoteImage.asset
}
}
Conclusion
I found this simple class useful in multiple client projects, usually for small scripts and configurations.
Doing an app release is not always convenient, and this approach allows me to have configurations independent of the app binary.
Like always, let me know if anything is unclear or if you'd like help with more advanced tooling!