How to Use Phoenix Websockets with Swift on iOS

How to use Websockets to drive interactive, two-way, real-time communication between your iOS app and a server running the Phoenix Framework.

How to Use Phoenix Websockets with Swift on iOS

If you’re developing an iOS app which communicates with a server, you’re likely using an API based on HTTP, the protocol which powers the web. In a HTTP-based API, your client makes specific requests to the server when it needs data to display.

While HTTP is great for requests initiated by the app, such as fetching a newsfeed or a list of products, it’s not so good for real-time back-and-forth communication like chat, games, and other interactive applications. If you’re building an app with these kinds of features, you might want to consider using Websockets: persistent two-way connections between client and server.

Phoenix is a web framework for the Elixir language, which makes Websocket communication super simple and delivers truly incredible performance and scalability. In this post, we’ll learn how to use Websockets to drive interactive, two-way, real-time communication between your iOS app and a server running the Phoenix Framework.

What are Websockets?

Websockets are persistent two-way connections between client and server.

With a traditional HTTP API, clients initiate communication with requests to the server. When data becomes available on the server, it’s not visible to the client until the client makes a new request. If the data changes frequently, clients need to implement polling to periodically check for new data.

Websockets, on the other hand, use a persistent connection between client and server.  When new data is available to the server, it can be immediately pushed out to the client. Some applications that demand real-time interaction are games, chat clients, and collaborative editors, but really, almost any kind of application can  give a better user experience with real-time communication. Even the newsfeed and product list described above as good candidates for an HTTP API could be enhanced with real-time updates enabled through Websockets.

Basic Concepts: Sockets, Phoenix Channels and Messages

When using Websockets with Phoenix, communication is organized into distinct channels. A channel represents a single stream of data, identified by a distinct string, that a client can subscribe to. A channel might be used to deliver the data for a chat room, for a particular match in a game, or notifications for the current user.

It’s not uncommon for a single client to be connected to multiple channels simultaneously. For example, a client might be subscribed to several different chatrooms, as well as a channel for the current user’s notifications.

Channels are typically named as topic:subtopic — where topic is the type of channel and subtopic is a specific identifier. Often this corresponds to a model and identifier in your system like chat:lobby, match:114 or user:10049.

Once subscribed to a channel, we can begin to receive messages, which are comprised of an event name and a payload. The event name is used to identify the type of event. In a chat room example, we might have events like “chat_message”, or “user_join” and “user_leave”. The payload is used to provide additional data. For example, with a “chat_message” event, we might have a payload of:

{  
   sender: {id: 129, name: "Bob"},  
   message: "Good morning, everybody!"
 }

With that background in place, let’s dive in to using Phoenix Channels with an iOS app.

Phoenix Backend: Setup a Channel

In this section, we’ll assume you’ve already got a Phoenix server up and running. If you don’t, you can get started with the Phoenix documentation.

First, we’ll create a channel in our Phoenix app. We can create the basic bolierplate with the following mix command:

mix phx.gen.channel Room

This will create a couple of files, and gives some instructions on configuring your new channel. The file we're most interested in is our channel module: lib/myapp_web/channels/room_channel.ex.  The file contains placeholder code for callbacks that are called at various stages of the channel lifecycle.

Connect and Authenticate

The first callback we’ll look at is for the join event, called when a user attempts to join a channel. In the default implementation, we simply check whether the client is authorized, and accept or deny the connection by returning the appropriate tuple:

def join("room:lobby", payload, socket) do
  if authorized?(payload) do    
    {:ok, socket}  
  else
    {:error, %{reason: "unauthorized"}}  
  end
end

In this example, we have the logic for handling a user connection to our room:lobby channel — to support more than one channel, we can pattern match as needed on the first parameter to handle connection logic.  For example:

def join("room:" <> room_id, payload, socket) do
  ...
end

Further down in the file, we’ll find the authorized? function which contains the actual authorization logic.  In this placeholder code, it simply returns true, meaning any client can join the channel. That’s fine for development purposes, but may or may not be appropriate for a real-world use case.

For security purposes, the Websocket connection does not have access to client cookies.  If your application needs to perform authorization to join a channel, a typical approach is to pass a token issued from a previous authentication request.

Sending and Receiving Messages on a Channel

Next, let’s look at how to handle messages in the channel. We do so with the handle_in function. This function gets called with the event name, a data payload, and the current socket. In the generated file, we’ll see two implementations of handle_in which show two different common patterns of message handling.

In the first implementation, we receive a message and send a reply back to the same client by returning a tuple starting with :reply. This pattern somewhat resembles a simple HTTP API call, as it’s initiated by the client and sends a response:

def handle_in("ping", payload, socket) do  
  {:reply, {:ok, payload}, socket}
end

The second implementation, however, shows another common pattern: it doesn't send a response back to the client directly, but instead, broadcasts a message to all connected client on the channel using the Phoenix.Channel.broadcast/3 function:

def handle_in("shout", payload, socket) do  
  broadcast(socket, "shout", payload)  
  {:noreply, socket}
end

This is the type of pattern we would follow for a chat or game client: an event initiated by one client is broadcast out to everybody on the channel (including the client that initiated the event).  In this way, we can synchronize state across all clients.

The example above sends a broadcast from within the channel, using the socket for context. However, it’s also possible to broadcast messages to a channel from elsewhere in the application by broadcasting the message via the endpoint itself:

MyApp.Endpoint.broadcast("room:topic", "event", message)

This is useful for sending messages to a channel that originate from some unrelated process within the application.  

iOS Client: Connect with SwiftPhoenixClient

Now that we have a basic server channel setup, we can integrate our iOS app.  In order to use Phoenix channels with Swift, we’ll need a Websocket client. Although Swift Foundation’s URLSession does include basic Websocket functionality, Phoenix channels do involve a bit of logic on top of the raw socket (for channels, presence, etc), so we’ll use a 3rd party library: SwiftPhoenixClient.

If you are using Xcode and the Swift Package Manager for your dependencies, you can go to Project Settings -> Swift Packages and then add the package: https://github.com/davidstump/SwiftPhoenixClient.git

If you are using Cocoapods, add the requirement to your Podfile and run pod install:

pod "SwiftPhoenixClient", '~> 5.1'

Connecting to the Server and Subscribing to a Channel

After you’ve successfully added SwiftPhoenixClient to your project, you can use it to create a connection to your backend. We’ll use the same socket path that was specified in the endpoint in the previous step.  Here's how we connect the socket and install some basic callbacks to handle lifecycle events:

socket = Socket("http://localhost:4000/socket/websocket", params: ["param": "value"])

socket.delegateOnOpen(to: self) { (self) in }
socket.delegateOnClose(to: self) { (self) in }
socket.delegateOnError(to: self) { (self, error) in }

socket.connect()

Once our socket is connected, we can begin to join channels.  Here, we join the room:lobby channel with some connect parameters, and install handlers for some of the channel's lifecycle events:

let topic = "room:lobby"

channel = socket.channel(topic, params: ["param": "value"])
channel  
  .join()  
  .delegateReceive("ok", to: self, callback: { (self, _) in })
  .delegateReceive("error", to: self, callback: { (self, msg) in })

Sending and Receiving Messages on a Channel

Once our channel is connected, we can begin to listen to messages broadcast over the channel, with a separate handler for each message type. For example, if we were building a chat client, we might expect a couple of different message types to build out chat room experience:

channel.on("chat_message", callback: { [self] (message) in ... })

channel.on("sound_played", callback: { [self] (message) in ... })

channel.on("user_join", callback: { [self] (message) in ... })

In these handlers, we can use the provided message data to take actions or update local client state.  For example, when a chat_message event is received, we can take the message from the payload and put it into a local array of messages that are they displayed via SwiftUI.

And finally, the last piece of the puzzle is sending messages to our channel using channel.push:

channel.push("chat_message", payload: ["message": "Hello!"])

Next Steps — Putting it all Together

Putting it all together, here are the steps we went through to enable Websocket communication in our iOS app:

  • Create a Phoenix channel on the backend
  • Handle connection and incoming events from clients
  • Connect to the channel from Swift using SwiftPhoenixClient
  • Handle events pushed out from the server and update client state
  • Push events from our clients which can broadcast out to others on the channel

With these pieces in place, you can start to build out real-time, multi-client, interactive experiences in your iOS apps.