Using WebSockets to Build a Realtime iOS Model Inspector
Recently I have been working on a new feature for my app, FoodNoms, called "recipes". Recipes let you combine multiple food items into a single, composite food item. It's useful for things like protein shakes, baked goods, or homemade dishes. It's actually a pretty common feature for food tracking apps like FoodNoms, and it's naturally been one of the top requested features.
Recipes is actually a pretty straightforward feature. Other than the necessary UI changes, it's just math! Effectively, all I need to define is the sum operation for food items.
Though it's not as simple as summing each nutrient; I want to preserve as much portion data as possible, e.g. calories per 100g. When I sat down to figure out how to implement this correctly, I grabbed pen and paper and did some quick algebra.
When I tested the logic on my device, I realized that my initial implementation wasn't correct for some cases (yay non-minimal state). These moments are typically when I would go into the debugger and inspect the state of my models in order to get an idea of what might be going wrong.
The workflow of debugging models and state in Xcode leaves a lot to be desired. My typical workflow is:
- Think of a view controller that references those models.
- Open the view controller's source and put a breakpoint in a method like
viewDidLoad
. - Run the app on a connected device.
- Use lldb to print out the state variables.
- Change code, rebuild, and repeat.
When lldb and Xcode are sluggish on my 2018 MacBook Pro, this workflow makes it quite slow and painful to iterate.
Perhaps I should be leveraging unit tests for situations like this. Still, I maintain that there's nothing quite as reassuring as seeing values from the actual app as it's running.
For the past five plus years I've been used to an entirely different workflow for debugging state. Tools like Chrome DevTools and the React Developer Tools have legit spoiled me. I can just point and click on any part of the UI and observe the state change in realtime. Also, Chrome's console UI is leagues ahead of lldb's interface, with actual log levels, color coding, interactive collapsible nodes, and powerful filter features.
After another frustrating debugging session last Thursday, I was motivated to try a little experiment. How much work would it take to recreate a tool that delivers a similar developer experience of inspecting web apps? Well, after a few hours I had this little demo and I shared it on on Twitter:
I missed the Chrome DevTools so much I built a live remote inspector for inspecting Codable model values in realtime. Supports collapsing nested fields and observing multiple models simultaneously. It also lets you forward logs to Chrome's console. Anything else like this? pic.twitter.com/i3Es0wDrKU
— Ryan Ashcraft (@ryanashcraft) January 17, 2020
This tweet received a surprising amount of attention! It's clear that there is some sort of demand for more tools like this that make it easier to debug apps in realtime.
It'd be wonderful if Apple were to invest more in their developer tooling to make these workflows less painful. In the meantime, I figure I'd share some of my work here and try to explain how it works.
How It Works
There are three main components to this setup: the Swift client code, a Node.js WebSockets server, and a React frontend.
The Node Server
All communication between the three components is done via WebSockets. There are three WebSocket message types: "subscribe", "update", and "log".
The Node server acts as a central router for these messages. It listens to "subscribe" messages from the frontend to create a pool of clients. Then when it receives "update" and "log" messages, it passes those through to all of the subscribed clients.
The iOS App
The Swift app sends "update" and "log" messages via a URLSessionWebSocketTask
task. The "update" message contains an identifier and a JSON-encoded copy of an Encodable struct. In my app, I'll typically place a call to send an update message inside a property's didSet
callback or inside a constructor.
FoodNoms already uses Willow for logging, so adding a new LogWriter to push logs to the server in debug mode was trivial. Willow supports log levels, and most of these map to browser console log levels. When the frontend receives log messages, it attempts to map those to an appropriate console method with a fallback to console.log.
In order for the iOS app to communicate with the Node server, the app needs to know the WebSocket server's local IP address and port number. I also wanted a toggle to control the connection state and an option to connect automatically on app launch. To support all of these settings, I implemented a little settings screen in SwiftUI that can only be launched when building for development:
The React Frontend
When an "update" message arrives at the React frontend, the message is merged into a larger, long-lived JSON object. Newer values replace older values with the same identifier, without modifying other observed values.
On the client, the merged object is rendered using a wonderful library called react-inspector, which emulates the expandable object UI in Chrome DevTools.
I decided to use a zero-configuration bundler, Parcel, for compiling and serving the React app.
Prior Art
I asked on Twitter if there are similar things to what I built. I was surprised that there isn't more tools like this already out there. The only tool that anyone brought up was PonyDebugger by Square, which hasn't had a release in 5+ years. I haven't tried PonyDebugger out yet, but it does look quite interesting!
I'd love to hear from the PonyDebugger maintainers what they learned from working on the project, and what they think about the state of realtime debugging iOS apps in 2020.
Potential Future Experiments
- Leveraging SwiftUI environment objects to build a context tree for values inside a SwiftUI hierarchy. This should give us something more similar to React's developer tools. It would also let us null-out expired values when views disappear.
- A declarative property wrapper interface for observing Encodable values.
- Supporting rendering hierarchical objects in logs as expandable objects instead of formatted strings.
- Adding more features like a network inspector, an interface to SQLite files or CoreData.
Source Code
I am not interested in sharing this code on GitHub, let alone building a reusable library for all of this. But I'm totally fine sharing some code snippets here. Note that this code is not super-duper production ready™ and it will need to be modified to work for your needs.
All code here is licensed under the MIT License (Copyright (c) 2020 Ryan Ashcraft). See MIT license below at end of post.
Swift Client Singleton
// Copyright (c) 2020 Ryan Ashcraft.
// This source code is licensed under the MIT license found in the
// license section at the end of this post.
import Foundation
public class RemoteInspector {
private struct UpdateMessage<T: Encodable>: Encodable {
let type = "update"
var id: String
var data: T
}
private struct LogMessage: Encodable {
let type = "log"
var message: String
var level: String
}
public static let shared = RemoteInspector()
static func connect(url: URL) -> URLSessionWebSocketTask {
let urlSession = URLSession(configuration: .default)
return urlSession.webSocketTask(with: url)
}
var webSocketTask: URLSessionWebSocketTask?
var url: URL?
public var isConnected: Bool {
return webSocketTask?.state == .running
}
public func connect(url: URL) -> Bool {
#if !DEBUG
// Don't want WebSocket activity happening in prod!
return false
#else
self.url = url
webSocketTask = Self.connect(url: url)
if let webSocketTask = webSocketTask {
logger.infoMessage("Starting remote inspector session at \(url.absoluteString)")
webSocketTask.resume()
return true
}
return false
#endif
}
public func disconnect() {
guard let webSocketTask = webSocketTask else {
return
}
webSocketTask.cancel(with: .goingAway, reason: nil)
self.webSocketTask = nil
}
public func sendUpdate(_ id: String, _ value: Encodable) {
guard let webSocketTask = webSocketTask else {
return
}
do {
let encoder = JSONEncoder()
let jsonData = try encoder.encode(UpdateMessage(id: id, data: AnyEncodable(value: value)))
let message = URLSessionWebSocketTask.Message.data(jsonData)
webSocketTask.send(message) { error in
if let error = error {
print(error)
}
}
} catch {
// Maybe do something here but this shouldn't affect the app's behavior so no big deal
}
}
func log(_ message: String, logLevel: String) {
guard let webSocketTask = webSocketTask else {
return
}
let encoder = JSONEncoder()
guard let jsonData: Data = try? encoder.encode(
LogMessage(
message: message,
level: logLevel.description,
attributesJSON: nil
)
) else {
return
}
let message = URLSessionWebSocketTask.Message.data(jsonData)
webSocketTask.send(message) { error in
if let error = error {
print(error)
}
}
}
}
Example Usage
// Copyright (c) 2020 Ryan Ashcraft.
// This source code is licensed under the MIT license found in the
// license section at the end of this post.
class EditRecipeViewController: UITableViewController {
private var recipe: Recipe {
didSet {
RemoteInspector.shared.sendUpdate("recipe", recipe)
RemoteInspector.shared.sendUpdate("recipe food entry", recipe.foodEntry)
}
}
…
}
SwiftUI Settings Screen
// Copyright (c) 2020 Ryan Ashcraft.
// This source code is licensed under the MIT license found in the
// license section at the end of this post.
struct RemoteInspectorSettingsView: View {
private class Model: ObservableObject {
var urlAddress: String = AppUserDefaults.group.url(forKey: AppUserDefaults.remoteInspectorURL)?.absoluteString ?? "ws://" {
didSet {
if let url = URL(string: urlAddress) {
AppUserDefaults.group.set(url, forKey: AppUserDefaults.remoteInspectorURL)
}
}
}
var connectOnLaunch: Bool = AppUserDefaults.group.bool(forKey: AppUserDefaults.remoteInspectorConnectOnLaunch) {
didSet {
AppUserDefaults.group.set(connectOnLaunch, forKey: AppUserDefaults.remoteInspectorConnectOnLaunch)
}
}
var isConnected: Bool = RemoteInspector.shared.isConnected {
didSet {
if isConnected {
if let url = URL(string: urlAddress) {
let didConnect = RemoteInspector.shared.connect(url: url)
isConnected = didConnect
} else {
isConnected = false
}
} else {
RemoteInspector.shared.disconnect()
}
}
}
}
@ObservedObject private var model = Model()
var body: some View {
Form {
Section {
TextField("ws://", text: $model.urlAddress)
}
Section {
HStack {
Toggle(isOn: $model.isConnected, label: {
Text("Connected")
})
}
HStack {
Toggle(isOn: $model.connectOnLaunch, label: {
Text("Connect on Launch")
})
}
}
}
}
}
Node.js Server
// Copyright (c) 2020 Ryan Ashcraft.
// This source code is licensed under the MIT license found in the
// license section at the end of this post.
require('dotenv').config();
const WebSocket = require('ws');
const port = process.env.PORT || 8080;
const wss = new WebSocket.Server({ port: port });
console.log(`WebSocket server started on port ${port}`);
const clients = new Set();
wss.on('connection', ws => {
ws.on('close', () => {
clients.delete(ws);
});
ws.on('message', message => {
let parsed = JSON.parse(message);
switch (parsed.type) {
case 'subscribe':
clients.add(ws);
break;
case 'update':
[...clients].forEach(client => {
client.send(JSON.stringify(parsed));
});
break;
case 'log':
[...clients].forEach(client => {
client.send(JSON.stringify(parsed));
});
break;
}
});
});
React Client Frontend
// Copyright (c) 2020 Ryan Ashcraft.
// This source code is licensed under the MIT license found in the
// license section at the end of this post.
// Needed this polyfill to workaround an issue with react-inspector
import 'regenerator-runtime/runtime';
import React from 'react';
import ReactDOM from 'react-dom';
import { ObjectInspector } from 'react-inspector';
const listen = callback => {
var ws = new WebSocket(`ws://localhost:${process.env.PORT}/ws`);
ws.onopen = () => {
ws.send(JSON.stringify({ type: 'subscribe' }));
};
ws.onmessage = message => {
try {
let parsed = JSON.parse(message.data);
switch (parsed.type) {
case 'update':
callback(parsed.id, parsed.data);
break;
case 'log':
try {
console[parsed.level.toLowerCase()](parsed.message);
} catch {
console.log(parsed.message);
}
break;
}
} catch (e) {
console.error(e);
}
};
};
const App = () => {
const [values, setValues] = React.useState(null);
React.useEffect(() => {
listen((id, message) => {
setValues(values => ({
...values,
[id]: message,
}));
});
}, []);
React.useEffect(
() => {
// Set a global variable on window for easy repl access
window.$i = values;
},
[values],
);
return (
<ObjectInspector expandLevel={2} sortObjectKeys={true} data={values} />
);
};
ReactDOM.render(<App />, document.getElementById('root'));
License
The MIT License (MIT)
Copyright (c) 2020 Ryan Ashcraft
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.