Tutoriales de IoT

Cliente MQTT para iOS en Swift con CocoaMQTT y SwiftUI

Tutorial paso a paso de iOS MQTT en Swift: añade CocoaMQTT con Swift Package Manager, envuélvelo en un ObservableObject, muestra datos de sensores en vivo en SwiftUI y publica comandos por TLS.

App iOS MQTT en Swift en un iPhone mostrando lecturas de sensores en vivo desde un broker en la nube con un botón de comando en SwiftUI
Pruébalo en tu propio broker MQTTConsigue un broker MQTT privado y gestionado con el plan Free y empieza con ejemplos de código listos para usar.
Pruébalo gratis Ejemplos de código Sin tarjeta

Este tutorial de iOS MQTT en Swift construye una pequeña app SwiftUI que se suscribe a topics de sensores en vivo y publica comandos hacia un dispositivo. Añadirás CocoaMQTT con Swift Package Manager, lo envolverás en un modelo observable, te conectarás por TLS a broker.connectmq.com y gestionarás las reconexiones que necesitan los iPhone reales.

Qué necesitas

  • Xcode 15 o posterior y un simulador o dispositivo con iOS 16+.
  • El paquete CocoaMQTT (compatible con MQTT 3.1.1 y MQTT 5, de código abierto y mantenido por EMQX).
  • Un broker. Para aprender, el servidor MQTT público gratuito en broker.connectmq.com acepta clientes anónimos con cualquier client ID no vacío. Usa un prefijo de topic aleatorio como connectmq/demo/<random-id>/..., porque el área pública es legible por todo el mundo; úsalo solo para pruebas y prototipos, nunca para datos de producción.
  • Opcionalmente, las herramientas CLI de Mosquitto para simular un dispositivo.

La app usa dos topics:

Topic Dirección Payload
<prefix>/sensors/+/temperature dispositivo a app 21.7
<prefix>/fan/set app a dispositivo ON / OFF

El comodín + significa que una sola suscripción cubre todos los sensores (kitchen, office, ...), y la app construye su lista de forma dinámica. Si tu equipo también publica una versión para Android o multiplataforma, los mismos topics funcionan sin cambios con el cliente MQTT para Android en Kotlin o con una app Flutter MQTT.

Añadir CocoaMQTT al proyecto

En Xcode elige File, Add Package Dependencies, e introduce:

https://github.com/emqx/CocoaMQTT.git

Selecciona "Up to Next Major Version" desde la 2.1.0 y añade la biblioteca CocoaMQTT al target de tu app. CocoaMQTT incorpora automáticamente su dependencia de sockets. El README del proyecto en GitHub es la documentación oficial de CocoaMQTT y recoge la API completa de delegados.

No hace falta ninguna excepción de App Transport Security: te conectas por TLS a un host con un certificado de Let's Encrypt de confianza pública, así que iOS lo valida contra el almacén de confianza del sistema.

Construir el modelo iOS MQTT en Swift

Mantén la red fuera de las vistas SwiftUI. Un ObservableObject es dueño del cliente, publica el estado de la conexión y las lecturas, y expone un método setFan.

MQTTModel.swift:

import Foundation
import CocoaMQTT

@MainActor
final class MQTTModel: ObservableObject {
    @Published var isConnected = false
    @Published var readings: [String: Double] = [:]
    @Published var lastError: String?

    let topicPrefix: String
    private let mqtt: CocoaMQTT

    init(host: String = "broker.connectmq.com",
         port: UInt16 = 8883,
         username: String? = nil,
         password: String? = nil,
         clientID: String? = nil,
         topicPrefix: String? = nil) {
        let suffix = String(UUID().uuidString.prefix(8)).lowercased()
        self.topicPrefix = topicPrefix ?? "connectmq/demo/\(suffix)"
        let id = clientID ?? "ios-\(suffix)"

        mqtt = CocoaMQTT(clientID: id, host: host, port: port)
        mqtt.username = username
        mqtt.password = password
        mqtt.enableSSL = true
        mqtt.keepAlive = 30
        mqtt.cleanSession = true
        mqtt.autoReconnect = true
        mqtt.autoReconnectTimeInterval = 2
        mqtt.maxAutoReconnectTimeInterval = 60

        mqtt.didConnectAck = { [weak self] client, ack in
            Task { @MainActor in
                guard let self else { return }
                self.isConnected = (ack == .accept)
                if ack == .accept {
                    client.subscribe("\(self.topicPrefix)/sensors/+/temperature", qos: .qos1)
                } else {
                    self.lastError = "Connection rejected: \(ack)"
                }
            }
        }

        mqtt.didReceiveMessage = { [weak self] _, message, _ in
            Task { @MainActor in
                guard let self,
                      let text = message.string,
                      let value = Double(text.trimmingCharacters(in: .whitespaces)) else { return }
                // topic: <prefix>/sensors/<name>/temperature
                let parts = message.topic.split(separator: "/")
                guard parts.count >= 2 else { return }
                let name = String(parts[parts.count - 2])
                self.readings[name] = value
            }
        }

        mqtt.didDisconnect = { [weak self] _, error in
            Task { @MainActor in
                self?.isConnected = false
                if let error { self?.lastError = error.localizedDescription }
            }
        }
    }

    func connect() {
        lastError = nil
        if !mqtt.connect() {
            lastError = "Could not start connection"
        }
    }

    func disconnect() {
        mqtt.disconnect()
    }

    func setFan(_ on: Bool) {
        mqtt.publish("\(topicPrefix)/fan/set", withString: on ? "ON" : "OFF", qos: .qos1)
    }
}

Merece la pena entender tres decisiones:

  1. Los callbacks saltan al main actor. CocoaMQTT ejecuta sus closures en su propia cola. Actualizar propiedades @Published desde un hilo en segundo plano provoca advertencias de SwiftUI y fallos ocasionales, así que cada callback envuelve su trabajo en Task { @MainActor in ... }.
  2. Suscribirse en didConnectAck. Con cleanSession = true, el broker olvida las suscripciones al desconectarse. Suscribirse dentro de la confirmación de conexión hace que cada reconexión automática las restaure.
  3. Análisis defensivo. Cualquiera puede publicar en un topic público, así que un payload no numérico se ignora en lugar de bloquear la app.

La pantalla SwiftUI

ContentView.swift:

import SwiftUI

struct ContentView: View {
    @StateObject private var model = MQTTModel()
    @State private var fanOn = false
    @Environment(\.scenePhase) private var scenePhase

    var body: some View {
        NavigationStack {
            List {
                Section("Connection") {
                    Label(model.isConnected ? "Connected" : "Disconnected",
                          systemImage: model.isConnected ? "checkmark.circle" : "xmark.circle")
                    if let err = model.lastError {
                        Text(err).foregroundStyle(.red).font(.footnote)
                    }
                    Text(model.topicPrefix).font(.caption).textSelection(.enabled)
                }
                Section("Sensors") {
                    if model.readings.isEmpty {
                        Text("Waiting for data...").foregroundStyle(.secondary)
                    }
                    ForEach(model.readings.keys.sorted(), id: \.self) { name in
                        HStack {
                            Text(name.capitalized)
                            Spacer()
                            Text(String(format: "%.1f °C", model.readings[name] ?? 0))
                                .monospacedDigit()
                        }
                    }
                }
                Section("Commands") {
                    Toggle("Fan", isOn: $fanOn)
                        .onChange(of: fanOn) { _, newValue in model.setFan(newValue) }
                        .disabled(!model.isConnected)
                }
            }
            .navigationTitle("iOS MQTT Demo")
        }
        .onAppear { model.connect() }
        .onChange(of: scenePhase) { _, phase in
            switch phase {
            case .active: model.connect()
            case .background: model.disconnect()
            default: break
            }
        }
    }
}

El manejador de scenePhase es la parte importante específica de iOS. Cuando tu app pasa a segundo plano, iOS la suspende en cuestión de segundos y el socket muere en silencio. Desconectar limpiamente en .background y reconectar en .active da a los usuarios una conexión nueva y suscrita en el momento en que vuelven a abrir la app. La forma de onChange con dos parámetros que se muestra aquí requiere iOS 17; en iOS 16 usa el closure de un solo parámetro.

Probar la app con un sensor simulado

Ejecuta la app, copia el prefijo de topic de la pantalla y publica lecturas desde una terminal:

PREFIX="connectmq/demo/1a2b3c4d"   # pega el prefijo que muestra la app

mosquitto_pub -h broker.connectmq.com -p 1883 -i ios-sim-kitchen \
  -t "$PREFIX/sensors/kitchen/temperature" -m "22.8"

mosquitto_pub -h broker.connectmq.com -p 1883 -i ios-sim-office \
  -t "$PREFIX/sensors/office/temperature" -m "20.1"

# Observar los comandos del ventilador que envía la app
mosquitto_sub -h broker.connectmq.com -p 1883 -i ios-sim-fan -t "$PREFIX/fan/set" -v

Ambos sensores deberían aparecer en la lista, y al accionar el ventilador se imprime ON u OFF en el suscriptor. Si prefieres hacer clic en lugar de escribir, el explorador MQTT ConnectMQ Desktop y el cliente en el navegador del portal hacen el mismo trabajo.

Mostrar el estado en línea de los dispositivos

Una lista de temperaturas solo es fiable si el usuario sabe que los sensores siguen vivos. MQTT tiene una respuesta integrada. Cada dispositivo se conecta con un mensaje de last will en un topic de estado, por ejemplo <prefix>/sensors/kitchen/status con el payload offline y el flag retain activado, y publica un online retenido justo después de conectarse. Si el dispositivo pierde la alimentación o la red, el broker publica el will en su nombre. Los detalles están en last will and testament en MQTT.

En el lado de la app, añade una segunda suscripción en didConnectAck para "\(topicPrefix)/sensors/+/status", guarda los valores en un diccionario [String: Bool] y atenúa las filas cuyo sensor esté desconectado. Como ambos mensajes son retenidos, la app conoce el estado actual de cada sensor en cuanto se conecta, sin esperar a la siguiente lectura.

La misma idea se aplica a la propia app. Si otros sistemas necesitan saber si el teléfono de un operario está conectado, define un will en el inicializador del modelo, antes de llamar a connect():

mqtt.willMessage = CocoaMQTTMessage(topic: "\(self.topicPrefix)/apps/\(id)/status",
                                    string: "offline", qos: .qos1, retained: true)

En la mayoría de las apps de consumo esto no es necesario, pero en las apps para operarios y servicio técnico de campo ofrece a los supervisores una vista en vivo de quién está conectado.

Usar WebSocket en lugar de TLS

Algunas redes Wi-Fi corporativas y de hoteles bloquean el puerto 8883. CocoaMQTT puede tunelizar MQTT a través de WebSocket seguro en el puerto 8084, que se parece al tráfico HTTPS normal:

let websocket = CocoaMQTTWebSocket(uri: "/mqtt")
let client = CocoaMQTT(clientID: id, host: "broker.connectmq.com", port: 8084, socket: websocket)
client.enableSSL = true

Todo lo demás en el modelo se mantiene igual. Las ventajas y desventajas se explican en MQTT sobre WebSocket.

Pasar a producción con un broker privado

Cuando el prototipo funcione, deja el servidor público y pasa a tu propio broker MQTT gestionado:

  1. Crea un broker en el portal de ConnectMQ y copia el usuario, la contraseña y el client ID generados.
  2. Inicializa el modelo con ellos: MQTTModel(username: "YOUR_USERNAME", password: "YOUR_PASSWORD", clientID: "YOUR_CLIENT_ID", topicPrefix: "acme/home1").
  3. Guarda la contraseña en el Keychain, no en UserDefaults ni en el código fuente.
  4. Mantén enableSSL = true en el puerto 8883.

Como ConnectMQ vincula los client ID a las credenciales emitidas, evita codificar un único inicio de sesión compartido en cada copia instalada de una app de la App Store. Un backend que emita credenciales por usuario o por dispositivo es el diseño más seguro: la app inicia la sesión del usuario, recibe por HTTPS su propio usuario, contraseña y client ID de MQTT, los guarda en el Keychain, y tu backend puede revocar el acceso de un solo usuario sin tener que publicar una actualización de la app para todos los demás. La guía de buenas prácticas de seguridad en MQTT profundiza en la gestión de credenciales.

Lista de comprobación para solucionar problemas

Problema Qué comprobar
didConnectAck nunca se dispara ¿Está enableSSL activado para el puerto 8883 y desactivado para el 1883? Una discrepancia TLS/plano bloquea el handshake.
El ack es .identifierRejected El client ID está vacío o ya lo usa otra sesión.
El ack es .badUsernameOrPassword Credenciales copiadas con espacios al final, o broker equivocado.
Funciona en el simulador, pero no en el dispositivo El dispositivo está en una red que bloquea el 8883; prueba WebSocket en el 8084.
Los valores se detienen tras desbloquear el teléfono Falta la reconexión con scenePhase, o las suscripciones no se restauran en didConnectAck.
Advertencias moradas de SwiftUI en tiempo de ejecución Una propiedad @Published se actualiza fuera del main actor.

Empieza con ConnectMQ

Tu app SwiftUI está a un cambio de credenciales de la producción. Crea un broker Serverless gratuito en app.connectmq.com sin tarjeta de crédito, pega el usuario, la contraseña y el client ID en MQTTModel y usa el explorador de topics en vivo del portal para ver cómo tu iPhone y tus dispositivos hablan entre sí. ConnectMQ gestiona el broker para que tú te centres en la app.

Preguntas frecuentes

¿Qué biblioteca MQTT para Swift debería usar en iOS?

CocoaMQTT es la opción más utilizada, con soporte para Swift Package Manager, TLS, WebSocket y clientes tanto MQTT 3.1.1 como MQTT 5. Para una comparación más amplia entre plataformas, consulta la panorámica de bibliotecas cliente MQTT.

¿Puede una app iOS recibir mensajes MQTT en segundo plano?

No de forma fiable. iOS suspende las apps en segundo plano y sus sockets, así que vuelve a conectar cuando la app pase a estar activa y usa notificaciones push para las alertas que deban llegar a un teléfono bloqueado.

¿Necesito una excepción de App Transport Security para MQTT?

No, si te conectas con TLS a un broker que usa un certificado de confianza pública, como hace broker.connectmq.com en los puertos 8883 y 8084. Las reglas de ATS se aplican a la carga de URL, y de todos modos conviene evitar MQTT sobre TCP plano en el 1883 en las apps publicadas.

¿Cómo uso MQTT 5 con CocoaMQTT?

Usa la clase CocoaMQTT5 en lugar de CocoaMQTT. Su API es similar y añade las propiedades de MQTT 5; ConnectMQ es compatible con ambas versiones del protocolo.

Artículos relacionados

Las mejores herramientas CLI de MQTT para pruebas, scripts y depuración

Una comparativa práctica de mosquitto_pub/sub, MQTTX CLI, HiveMQ MQTT CLI, la CLI de MQTT.js, curl y mqttui, con la misma prueba ejecutada contra broker.connectmq.com para que elijas la herramienta adecuada para scripts y depuración.

9 min de lectura

Todos los artículos →

Tu broker MQTT privado, a unos pocos clics

Crea una cuenta, obtén las credenciales de tu broker y conecta tu primer dispositivo, sin servidores que instalar, parchear ni escalar.