Note: As a non-native speaker, I’m using AI for grammar and spelling checks, but the source code and the “unpolished” version of this post were generated by my own mushy human brain. It’s a bit sad that a disclaimer like this even needs to exist.
Introduction
In iOS and macOS development, we frequently deal with resource-intensive data sources. It’s best to observe them only when necessary, but managing their lifecycle and state is often a challenge. Additionally, they are notoriously difficult to mock for tests and UI previews. This post explores a Lazy/Dynamic Publisher pattern and provides a reusable base class to simplify creating your own dynamic publishers.
The Core Concept
The Lazy/Dynamic Publisher pattern reacts to subscriber availability by automatically starting and stopping its internal processes. It handles both subscriber reference counting and the management of internal data source lifecycles.
Why This Pattern?
- Resource Efficiency: Internal data mechanisms remain idle when not in use. All subscribers share the same data source and receive identical events.
- Cleaner ViewModels: Models are only responsible for subscribing and unsubscribing, keeping the business logic decoupled.
- Ease of Mocking, Testing, and Previews: By wrapping Dynamic Publishers as
AnyPublisher, you can easily swap them for mock data during testing or provide dynamic providers for SwiftUI previews.
Code
Note: This pattern is specifically useful for data streams, which is my primary goal; I’m intentionally ignoring the Demand mechanism of Swift Combine Publishers. Additionally, since I plan to use these publishers as the foundation for my UI data providers, I have chosen to make them @MainActor isolated.
“
@MainActor
open class DynamicPublisher<Output, Failure: Error>: NSObject, @MainActor Publisher {
private let logTag = "DynamicPublisher<\(Output.self), \(Failure.self)>"
public typealias Output = Output
public typealias Failure = Failure
private var subscriptions: [UUID: DynamicSubscription] = [:]
public var subscriptionCount: Int { subscriptions.count }
private(set) var isInitialized: Bool = false
open func start() {}
open func stop() {}
public func receive<S>(subscriber: S) where S : Subscriber, Failure == S.Failure, Output == S.Input {
let subscription = DynamicSubscription(publisher: self, subscriber: subscriber)
addSubscription(subscription: subscription)
}
public func send(_ value: Output) {
guard subscriptionCount > 0 else {
return
}
subscriptions.forEach {(id, subscription) in
_ = subscription.subscriber.receive(value)
}
}
public func fail(with error: Failure) {
Swift.print(logTag, "Failed with:", error)
completion(with: .failure(error))
}
public func finish() {
Swift.print(logTag, "Finish")
completion(with: .finished)
}
public func completion(with completion: Subscribers.Completion<Failure>) {
guard subscriptionCount > 0 else {
return
}
subscriptions.forEach {(id, subscription) in
subscription.subscriber.receive(completion: completion)
}
subscriptions.removeAll()
invokeStop()
}
private func invokeStop() {
guard isInitialized else { return }
isInitialized = false
Swift.print(logTag, "call stop")
stop()
}
private func invokeStart() {
guard !isInitialized else { return }
isInitialized = true
Swift.print(logTag, "call start")
start()
}
public func clear() {
Swift.print(logTag, "Clear")
guard subscriptions.count > 0 else {
return
}
subscriptions.forEach {(id, subscription) in
cancelSubscription(subscription: subscription)
}
}
fileprivate func addSubscription(subscription: DynamicSubscription) {
Swift.print(logTag, "addSubscription")
guard subscriptions[subscription.id] == nil else {
return
}
subscriptions[subscription.id] = subscription
if !isInitialized {
invokeStart()
}
subscription.subscriber.receive(subscription: subscription)
}
fileprivate func cancelSubscription(subscription: DynamicSubscription) {
Swift.print(logTag, "cancelSubscription")
guard subscriptions[subscription.id] != nil else {
return
}
subscriptions.removeValue(forKey: subscription.id)
subscription.subscriber.receive(completion: .finished)
if subscriptions.count == 0 {
invokeStop()
}
}
@MainActor
fileprivate class DynamicSubscription: @MainActor Subscription, Identifiable {
let id: UUID = .init()
let publisher: DynamicPublisher<Output, Failure>
let subscriber: any Subscriber<Output, Failure>
init(publisher: DynamicPublisher<Output, Failure>, subscriber: any Subscriber<Output, Failure>) {
self.publisher = publisher
self.subscriber = subscriber
}
func request(_ demand: Subscribers.Demand) {}
func cancel() {
publisher.cancelSubscription(subscription: self)
}
}
}
Practical Example
Let’s see how this approach simplifies a common task: streaming location data.
Note: In this example, the publisher is not responsible for requesting location permissions; however, it will monitor them, react to changes, and fail “gracefully” if permission is not granted.
Publisher:
enum LocationDataProviderError: Error {
case authorizedDenied
case locationManagerError(error: any Error)
}
class LocationDataProvider: DynamicPublisher<CLLocation, LocationDataProviderError>, CLLocationManagerDelegate {
static let shared: LocationDataProvider = .init()
private let locationManager = CLLocationManager()
override init() {
super.init()
locationManager.delegate = self
}
override func start() {
if checkAuthorization() {
locationManager.startUpdatingLocation()
} else {
fail(with: LocationDataProviderError.authorizedDenied)
}
}
override func stop() {
locationManager.stopUpdatingLocation()
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
if let location = locations.first {
send(location)
}
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: any Error) {
fail(with: LocationDataProviderError.locationManagerError(error: error))
}
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
checkAuthorization()
}
@discardableResult
private func checkAuthorization() -> Bool {
return [.authorizedAlways, .authorizedWhenInUse].contains(locationManager.authorizationStatus)
}
}
Usage:
// ... Model
init(locationDataProvider: AnyPublisher<CLLocation, LocationDataProviderError>) {
self.locationDataProvider = locationDataProvider
super.init()
initialize()
}
subscription = locationDataProvider.sink(
receiveCompletion: {completion in
debugPrint(self.logTag, "receiveCompletion: \(completion)")
if case .failure(let error) = completion {
self.error = error
self.state = .failed
} else {
self.state = .notSubscribed
}
},
receiveValue: { location in
self.location = location
})
Mock:
class LocationDataProviderMock: DynamicPublisher<CLLocation, LocationDataProviderError> {
static let shared = LocationDataProviderMock()
private var task: Task<Void, Never>?
override func start() {
task = Task {
while true {
try? await Task.sleep(for: .seconds(1))
if Task.isCancelled { return }
send(CLLocation(latitude: CLLocationDegrees.random(in: -90...90), longitude: CLLocationDegrees.random(in: -180...180)))
}
}
}
override func stop() {
task?.cancel()
}
}
Preview:
#Preview {
NavigationStack {
LocationDataProviderTestView(
model: LocationDataProviderTestViewModel(locationDataProvider: LocationDataProviderMock.shared.eraseToAnyPublisher())
)
}
}
Conclusion
Joining Swift Combine with the Dynamic/Lazy Pattern provides a clean decoupled abstraction layer between data sources and the UI. It significantly improves testability and previewability, and helps build more battery-efficient and resource-efficient solutions.
P.S. The full source code is available here: https://github.com/AndreiMaksimovich/SwiftDeveloperBlog/