Skip to content
Go back

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:

Remote Asset Manager in action

Behaviors

I always start my work with requirements and expected behaviors:

The basic creation of the manager will look like this:

RemoteAssetConfiguration(
  bundle: Bundle.main.url(forResource: "Icon", withExtension: "png"),
  remote: URL(fileURLWithPath: "/Users/merowing/work/private/blog/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:

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
  1. Materialize the asset into the expected type
  2. Subscribe for updates

Let’s look at each of those steps

Copying bundled asset

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:

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.

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

💡

Full Source Code

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!


Share this post on:

Previous Post
Building captivating content reveal animation.
Next Post
Hot Reloading in Swift