This episode is freely available thanks to the support of our subscribers

Subscribers get exclusive access to new and all previous subscriber-only episodes, video downloads, and 30% discount for team members. Become a Subscriber

We're building a remote view state debugger, starting with the networking code on the client.

00:06 We're using a lightweight view state debugger — a Mac app that other apps can connect to over Bonjour. An iOS app can send its view state over to the debugger, and from the debugger, a history of received states can be inspected or sent back to the app.

Demonstration

00:43 Let's look at an example. We run both the debugger and our Recordings app from our book App Architecture — specifically, the Elm implementation of Recordings. The entire app state of Recordings is described by a single struct, and whenever it changes, we encode this struct and send it to the debugger, along with both the action that caused the change and a screenshot.

01:28 We create a new folder in the app, and we see a list of states appearing in the debugger. We can inspect the states and see their corresponding screenshots. The first item on the list is the initial state, in which only the root folder exists. The second state has two folders: the root folder and the new subfolder. The cool thing is that we can go back in time by selecting the initial state in the debugger and sending it back to the app.

02:43 Any app can work with this debugger if it can connect to the debugger over TCP, encode and send its view state, and observe and apply received view states. A while ago, we worked on the Laufpark app, which uses the Incremental library. Because Laufpark's app state is encoded in an incremental value, it's easy to send these states to the debugger as well. But it takes much longer: each state is a couple of megabytes in size because it includes all the data for running routes.

Communication with the Debugger

04:52 We were able to quickly make the Recordings app support the debugger. In the app delegate, we instantiate a RemoteDebugger:

class AppDelegate: UIResponder, UIApplicationDelegate {
    // ...
    let driver = Driver<AppState, AppState.Message>(/*...*/)
    let debugger = RemoteDebugger()
    // ...
}

06:08 Then we listen for incoming states from the debugger, and we try to apply them:

class AppDelegate: UIResponder, UIApplicationDelegate {
    // ...
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
        // ...
        let decoder = JSONDecoder()
        debugger.onData = { [driver] data in
            guard let d = data else { return }
            guard let result = try? decoder.decode(AppState.self, from: d) else {
                fatalError("Cannot decode!: \(d)")
            }
            DispatchQueue.main.async {
                driver.changeState(result)
            }
        }
        // ...
    }
    // ...
}

06:16 Next, we observe the app state and send it to the debugger when it changes:

class AppDelegate: UIResponder, UIApplicationDelegate {
    // ...
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
        // ...
        driver.onChange = { [unowned debugger, window] (state, message) in
            var action = ""
            dump(message, to: &action)
            try! debugger.write(action: action, state: state, snapshot: window!)
        }
        // ...
    }
    // ...
}

Writing RemoteDebugger

06:28 We're going to create the RemoteDebugger class from scratch, so in the app delegate, we comment out all the code that we can't yet support, like the debugger.onData closure. But we keep the driver's onChange callback, in which we call a write method on RemoteDebugger with three arguments: the Elm message converted to a string, the state, and the app's window that should be used to create a snapshot.

07:34 We don't want to make the debugger specific to this app, so we use a generic state parameter, allowing any encodable type to be sent. We also mark the write method as throwing so that the signature matches with how we call the method in the app delegate:

final class RemoteDebugger {
    func write<S: Encodable>(action: String, state: S, snapshot: UIView) throws {
    }
}

08:26 The debugger application doesn't care about the contents of the state; it just expects a string, some JSON data, and a screenshot image. This makes it easy for any application, written in any language, to work with the debugger.

Connecting to a NetService

08:56 Before we can send anything, we need to connect to the debugger application running on the Mac. The debugger advertises itself as a network service, and our app can discover this service with a NetServiceBrowser:

final class RemoteDebugger: NetServiceBrowserDelegate {
    let browser = NetServiceBrowser()

    init() {
        browser.searchForServices(ofType: "_debug._tcp", inDomain: "local")
    }

    // ...
}

11:01 The browser calls its delegate if it finds a network service of the requested type. In order to set RemoteDebugger as the delegate, we conform to NetServiceBrowserDelegate, which forces us to inherit from NSObject:

final class RemoteDebugger: NSObject, NetServiceBrowserDelegate {
    let browser = NetServiceBrowser()

    override init() {
        super.init()
        browser.delegate = self
        browser.searchForServices(ofType: "_debug._tcp", inDomain: "local")
    }

    func netServiceBrowser(_ browser: NetServiceBrowser, didFind service: NetService, moreComing: Bool) {

    }

    // ...
}

11:12 The delegate method might be called multiple times if the browser finds multiple debuggers on the local network. To keep things simple, we ignore this fact and naively connect to any debugger we find.

11:45 Once we find a network service, we need a way to communicate with it. Apple announced the upcoming Network framework at WWDC, which should make this part very easy. However, we'll set up the communication the old way for now.

We first get the input and output streams from the service:

final class RemoteDebugger: NSObject, NetServiceBrowserDelegate {
    // ...

    func netServiceBrowser(_ browser: NetServiceBrowser, didFind service: NetService, moreComing: Bool) {
        var input: InputStream?
        var output: OutputStream?
        service.getInputStream(&input, outputStream: &output)
    }

    // ...
}

12:47 We have to make sure the networking isn't done on the main thread. We can assign queues to either stream with these two global functions:

final class RemoteDebugger: NSObject, NetServiceBrowserDelegate {
    let browser = NetServiceBrowser()
    let queue = DispatchQueue(label: "remoteDebugger")

    // ...

    func netServiceBrowser(_ browser: NetServiceBrowser, didFind service: NetService, moreComing: Bool) {
        var input: InputStream?
        var output: OutputStream?
        service.getInputStream(&input, outputStream: &output)
        CFReadStreamSetDispatchQueue(input, queue)
        CFWriteStreamSetDispatchQueue(output, queue)
    }

    // ...
}

13:54 Now that we have our input and output streams, we can use them for reading and writing. We first focus on writing to the output stream, and in order to access it from the write method, we store output as a property:

final class RemoteDebugger: NSObject, NetServiceBrowserDelegate {
    let browser = NetServiceBrowser()
    let queue = DispatchQueue(label: "remoteDebugger")
    var output: OutputStream?

    // ...

    func netServiceBrowser(_ browser: NetServiceBrowser, didFind service: NetService, moreComing: Bool) {
        var input: InputStream?
        service.getInputStream(&input, outputStream: &output)
        CFReadStreamSetDispatchQueue(input, queue)
        CFWriteStreamSetDispatchQueue(output, queue)
    }

    func write<S: Encodable>(action: String, state: S, snapshot: UIView) throws {
        guard let o = output else { return }
        // ...
    }
}

Constructing the Payload

14:30 The debugger expects to receive a JSON object containing a state object, an action string, and image data. We create a struct for this payload, called DebugData. By constraining its generic state type to be Encodable, the compiler is able to generate the encoding capability of DebugData as well:

struct DebugData<S: Encodable>: Encodable {
    var state: S
    var action: String
    var imageData: Data
}

16:25 In the write method, we want to create a DebugData payload from the passed-in arguments and encode it to JSON data. For the image data, we use a helper function that creates a UIImage from the passed-in view, and we then turn that image into PNG data:

final class RemoteDebugger: NSObject, NetServiceBrowserDelegate {
    // ...

   func write<S: Encodable>(action: String, state: S, snapshot: UIView) throws {
        guard let o = output else { return }

        let image = snapshot.capture()!
        let imageData = UIImagePNGRepresentation(image)!
        let data = DebugData(state: state, action: action, imageData: imageData)
        let encoder = JSONEncoder()
        let json = try! encoder.encode(data)
        // ...
    }
}

Sending over TCP

18:29 TCP has no notion of structured messages. Instead, we can just send some bytes over the stream. In order to properly communicate with the debugger over TCP, we follow a protocol called JSON over TCP: we first send the number 206 as a single byte to signal the start. Then we send four bytes containing the length of the JSON message as an Int32. Finally, we send the JSON data.

20:04 Let's implement the protocol in steps. We send the 206-signature byte by passing in an array with the number 206, which is automatically converted to the unsafe pointer the parameter expects:

o.write([206], maxLength: 1)

20:31 Next, we have to write the length of the JSON data as a 4-byte array. The easiest way to do this is by creating a data object with the correct size — this allocates a 4-byte region of memory — and assigning the JSON's length to this data:

var encodedLength = Data(count: 4)
encodedLength.withUnsafeMutableBytes { bytes in
    bytes.pointee = Int32(json.count)
}

22:11 Then we want to write this length to the output stream. But we can't directly pass in encodedLength because Data isn't converted into the required UnsafePointer<UInt8>.

22:25 One way to write the data's bytes to the stream is by combining the data with the [206] array from the first write call. The inferred type of [206] + encodedLength is [Int8]. This is almost what we need, but not exactly, so we have to help the compiler and explicitly ask for the type [UInt8]. This type automatically converts to an UnsafePointer<UInt8> when we pass it in:

o.write(([206] + encodedLength) as [UInt8], maxLength: 5)

23:27 Next, we send the actual JSON data. A different way to pass Data to the write method is to call withUnsafeBytes, which takes a closure that receives the bytes. In that closure, we write the bytes to the output stream:

json.withUnsafeBytes { bytes in
    o.write(bytes, maxLength: json.count)
}

This is the entire method up to this point:

final class RemoteDebugger: NSObject, NetServiceBrowserDelegate {
    // ...

   func write<S: Encodable>(action: String, state: S, snapshot: UIView) throws {
        guard let o = output else { return }

        let image = snapshot.capture()!
        let imageData = UIImagePNGRepresentation(image)!
        let data = DebugData(state: state, action: action, imageData: imageData)
        let encoder = JSONEncoder()
        let json = try! encoder.encode(data)
        var encodedLength = Data(count: 4)
        encodedLength.withUnsafeMutableBytes { bytes in
            bytes.pointee = Int32(json.count)
        }
        o.write(([206] + encodedLength) as [UInt8], maxLength: 5)
        json.withUnsafeBytes { bytes in
            o.write(bytes, maxLength: json.count)
        }
    }
}

24:19 We run the app and see that nothing happens. That's because we still have to open the stream:

final class RemoteDebugger: NSObject, NetServiceBrowserDelegate {
    // ...

   func netServiceBrowser(_ browser: NetServiceBrowser, didFind service: NetService, moreComing: Bool) {
        var input: InputStream?
        service.getInputStream(&input, outputStream: &output)
        CFReadStreamSetDispatchQueue(input, queue)
        CFWriteStreamSetDispatchQueue(output, queue)
        output?.open()
    }

    // ...
}

24:57 Now when we run the app and we trigger a state change, we see that our data is received by the debugger. Each new state appears in the table, and when we select a state, we see its screenshot. This means we've successfully connected to the network service and we've structured the data correctly.

The Problem with Sending Large Data

25:20 We've managed to send over some data, but our code isn't very robust yet. Ultimately, we have to take care of many things that can go wrong. For example, we might encounter a situation where we can't write everything we want to. The JSON data could be too large to send all at once, or the connection might be closed unexpectedly.

The output stream's write method returns the number of bytes written, so we can assert that this number matches with the length of the JSON data:

let bytesWritten = json.withUnsafeBytes { bytes in
    o.write(bytes, maxLength: json.count)
}
assert(bytesWritten == json.count)

26:40 We can force a problem to occur by making the payload too large to send at once. We add a megabyte of padding to the DebugData struct, which will be included in the encoded data:

struct DebugData<S: Encodable>: Encodable {
    var state: S
    var action: String
    var imageData: Data
    let padding = Data(repeating: 0, count: 1_000_000)
}

27:28 If we run the app now and we create a new folder to trigger a state change, the app halts on the assertion. We can see that bytesWritten is around 260 kilobytes, while the data we wanted to send is larger than a megabyte.

Clearly, we have to work with a buffer and write the data in chunks. Doing this correctly can be tricky, but it's certainly doable. In the near future, the Network framework can handle things like buffering for us, but it's still a fun exercise to implement it ourselves. We'll continue with this next time.