Otávio’s blog

Reachability using NWPathMonitor and Combine

About a month ago I decided to build a new app to attend a personal need, a desktop client for Pinboard, a service I've been using for over a decade to store and manage my bookmarks.

As any application which requires internet to fetch and update data, my client needed a way to detect network accessibility. For a long time the strategy adopted by engineers in this situation was to rely on Apple's Reachability class, a sample code the company used to provide for download on their developer portal. Well, either that or one of the many CocoaPods frameworks the community built to replace Apple's drag-and-drop solution. But since iOS 12.0 and macOS 10.14 there's a powerfull first-party alternative.

Introduced a couple of years ago, NWPathMonitor is the easiest way to detect network changes (and retrieve connection properties). Its interface is extremely simple to use, providing a callback to get notified of updates.

let monitor = NWPathMonitor()
let queue = DispatchQueue.global(qos: .background)

monitor.pathUpdateHandler = { path in
    print(path.status) // .unsatisfied, .satisfied, .requiresConnection
}

monitor.start(
    queue: queue
)

// ...

monitor.stop()

My application in built in SwiftUI (2.0) and Combine and the solution above wouldn't feel right in my View Model. So I started with a simple wrapper, hiding the monitor and exposing a publisher which notifies path changes.

let wrapper = NWPathMonitorWrapper()

let cancellable = wrapper
    .pathUpdatePublisher()
    .receive(on: RunLoop.main)
    .sink { path in
        print(path.status)
    }

wrapper.start()

But the implementation (below) still requires NWPathMonitorWrapper.start() to be called, hurting the beauty of the publisher-subscriber relationship.

public final class NWPathMonitorWrapper {

    // MARK: - Properties

    private let monitor: NWPathMonitor
    private let queue: DispatchQueue = .global(qos: .background)
    private let pathUpdateSubject = PassthroughSubject<NWPath, Never>()

    // MARK: - Life cycle

    public init(
        monitor: NWPathMonitor = NWPathMonitor()
    ) {
        self.monitor = monitor
        self.monitor.pathUpdateHandler = {
            self.pathUpdateSubject.send($0)
        }
    }

    // MARK: - Public

    public func start() {
        monitor.start(
            queue: queue
        )
    }

    public func stop() {
        monitor.cancel()
    }

    public func pathUpdatePublisher() -> AnyPublisher<NWPath, Never> {
        pathUpdateSubject
            .eraseToAnyPublisher()
    }
}

So I looked for a similar example in Apple's frameworks, one which requires a method to be called to trigger the action. Turns out URLSession, familiar to every iOS/macOS engineer, is the perfect example since it requires URLSession.resume() to be called to fire the network requrest. Recently URLSession got a new method, a publisher which starts the request at the moment there's demand (a subscriber):

let session = URLSession.shared

let cancellable = session
    .dataTaskPublisher(for: request)
    .receive(on: RunLoop.main)
    .tryMap { result in
        // ...
    }
    .sink(
        receiveCompletion: { _ in },
        receiveValue: { _ in }    
    )

And that's the interface I wanted for my monitor. A simple publisher which starts emitting values as soon as there's demand.

let monitor = NWPathMonitor()

let cancellable = monitor
    .pathUpdatePublisher()
    .receive(on: RunLoop.main)
    .sink { path in
        print(path.status)
    } 

SwiftUI and Combine are new to me, I spiked and re-implemented some UI components I have up in my sleeve, but nothing too complex. Building this client is helping me to explore and learn new APIs.

The protocols Publisher, Subscriber, and Subscription are examples of these APIs. To implement a custom publisher it's necessary to implement types conforming to two of these protocols.

First, a quick recap:

The first step was to define my publisher interface. Since the monitor requires a queue to run, I decided to pass the queue as argument. In order to simplify its interface, a background queue is passed by default to the implementation.

extension NWPathMonitor {
  
    public func pathUpdatePublisher(
        on queue: DispatchQueue = .global(qos: .background)
    ) -> NWPathMonitor.PathMonitorPublisher {
      // ...
    }
}

The interface provides a hint of the first type to be implemented, NWPathMonitor.PathMonitorPublisher. Most of the code is boilerplate, and for this publisher, the Output expected is NWPath, without a Failure.

When the publisher receives a subscriber, the link between them is established.

extension NWPathMonitor {
   
  public struct PathMonitorPublisher: Publisher {

        // MARK: - Nested types
    
        public typealias Output = NWPath
        public typealias Failure = Never
    
        // MARK: - Properties
    
        private let monitor: NWPathMonitor
        private let queue: DispatchQueue
    
        // MARK: - Life cycle
    
        fileprivate init(
            monitor: NWPathMonitor,
            queue: DispatchQueue
        ) {
            self.monitor = monitor
            self.queue = queue
        }
    
        // MARK: - Public
    
        public func receive<S>(
            subscriber: S
        ) where S: Subscriber, S.Failure == Failure, S.Input == Output {
            let subscription = PathMonitorSubscription(
                subscriber: subscriber,
                monitor: monitor,
                queue: queue
            )
    
            subscriber.receive(
                subscription: subscription
            )
        }
    }
}

The Subscription fulfills the demand from the subscriber. As soon as there's demand, it starts monitoring changes using the monitor's callback, passing changes to the subscriber. And since Subscription conforms to Cancellable, cancel() can be used to stop the monitor.

extension NWPathMonitor {

    private final class PathMonitorSubscription<
        S: Subscriber
    >: Subscription where S.Input == NWPath {

        // MARK: - Nested types
  
        private let subscriber: S
        private let monitor: NWPathMonitor
        private let queue: DispatchQueue
  
        // MARK: - Life cycle
  
        init(
            subscriber: S,
            monitor: NWPathMonitor,
            queue: DispatchQueue
        ) {
            self.subscriber = subscriber
            self.monitor = monitor
            self.queue = queue
        }
  
        // MARK: - Public
  
        func request(
            _ demand: Subscribers.Demand
        ) {
            guard
                demand == .unlimited,
                monitor.pathUpdateHandler == nil
            else {
                return
            }
  
            monitor.pathUpdateHandler = { path in
                _ = self.subscriber.receive(path)
            }
  
            monitor.start(
                queue: queue
            )
        }
  
        func cancel() {
            monitor.cancel()
        }
    }
}

With all in place, the View Model becomes extremely simple and elegant. Below a View Model which publishes a boolean indicating the connectivity status to the View.

final class ViewModel: ObservableObject {

    // MARK: - Properties

    @Published var isConnected: Bool = false
    private var monitorCancellable: Cancellable?

    // MARK: - Life cycle

    init(
        pathMonitorPublisher: NWPathMonitor.PathMonitorPublisher
    ) {
        monitorCancellable = pathMonitorPublisher
            .receive(on: RunLoop.main)
            .map { $0.status == .satisfied }
            .assign(to: \.isConnected, on: self)
    }
}

Overall, I'm happy with the result, and am looking forward to using Combine (and SwiftUI) in production.

Resources: