Supporting updatable assets

🚀
You're about to read an article that was previously reserved only for our premium supporters. Want to get access to valuable insights, cutting-edge tutorials, and time-saving code snippets immediately when they are published?

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.
0:00
/
Remote Asset Manager in action

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
    }
}

💡
I'm using force unwrapping / try for the convenience of the tutorial, but I recommend you don't do that in a real app and instead handle errors gracefully.

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:

  1. Copy bundled asset if it's required
    • If it didn't exist before
    • If the version changed
  2. Materialize the asset into the expected type
  3. 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
public extension Materializer {
  static var image: Materializer<UIImage> {
    Materializer<UIImage> { data -> UIImage in
      guard let img = UIImage(data: data) else {
        throw NotMaterializable()
      }
      return img
    }
  }
}

public extension Materializer where Type == UIImage {
  var swiftUI: Materializer<Image> {
    .init { data in
      Image(uiImage: try self.closure(data))
    }
  }
}
Adding Image Materializer
public extension DataProvider {
  /// Provides data using `dataTaskPublisher` on the provided `session`
  static func dataTask(_ session: URLSession) -> Self {
    DataProvider { url in
      session.dataTaskPublisher(for: url)
        .map { Data?.some($0.data) }
        .catch { _ in
          Just(Data?.none)
        }
        .compactMap { $0 }
        .receive(on: DispatchQueue.main)
        .eraseToAnyPublisher()
    }
  }

  func autoRefresh(interval _: TimeInterval) -> DataProvider {
    DataProvider { url in
      Timer.publish(every: 1 / 60, on: .main, in: .common)
        .autoconnect()
        .flatMap { _ in
          self.closure(url)
        }
        .eraseToAnyPublisher()
    }
  }
}
Adding URLSession driven Provider integration

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!

You've successfully subscribed to Krzysztof Zabłocki
Great! Next, complete checkout to get full access to all premium content.
Error! Could not sign up. invalid link.
Welcome back! You've successfully signed in.
Error! Could not sign in. Please try again.
Success! Your account is fully activated, you now have access to all content.
Error! Stripe checkout failed.
Success! Your billing info is updated.
Error! Billing info update failed.