6 Episodes · 2h13min
Swift Talk # 109
iOS Remote Debugger: Connecting with Bonjour
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
and call it 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. First we 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 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.
Resources
-
Sample Project
Written in Swift 4.1
-
Episode Video
Become a subscriber to download episode videos.
In Collection
Episode Details
-
- Released
- July 13, 2018
-
- Host
- Chris Eidhof
-
- Host
- Florian Kugler
-
- Transcript
- Juul Spee
-
- Copy Editing
- Natalye Childress
Recent Episodes
See AllAttribute Graph (Part 1)
Episode 429 · Nov 15
Swift 6 Concurrency (Part 5)
Episode 428 · Nov 08
Swift 6 Concurrency (Part 4)
Episode 427 · Nov 01
Swift 6 Concurrency (Part 3)
Episode 426 · Oct 25
Swift 6 Concurrency (Part 2)
Episode 425 · Oct 18
Swift 6 Concurrency (Part 1)
Episode 424 · Oct 11
Particle Effects (Part 6)
Episode 423 · Oct 04
Particle Effects (Part 5)
Episode 422 · Sep 27
Unlock Full Access
Subscribe to Swift Talk
-
Watch All Episodes
A new episode every week
-
Download Episodes
Take Swift Talk with you when you're offline
-
Support Us
With your help we can keep producing new episodes