Joe's Blog

iOS 開發筆記

MQTT 實作聊天 Demo

發佈於 2025-03-04
2026-06-13 更新

MQTT 是一個輕量的 Pub/Sub 協定,拿來做聊天有幾個優點:

Facebook Messenger 早期也是用 MQTT 做即時訊息的傳輸層。


架構概覽

採用 MVVMC,兩個頁面:

MQTT 的操作集中在 MQTTManager,ViewModel 不直接碰 MQTT。


MQTTManager

1
2
3
4
5
6
actor MQTTManager {
    static let shared = MQTTManager()
    private let topic = "JoeChatDemo/chat"
    private var client: MQTTClient?
    private let eventLoopGroup = NIOTSEventLoopGroup()
}

actor 封裝,確保 client 的狀態在 concurrent 環境下安全存取。

連線

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func connect(userId: String) async throws {
    try? await disconnect()

    self.client = MQTTClient(
        host: "broker.emqx.io",
        port: 1883,
        identifier: userId,
        eventLoopGroupProvider: .shared(eventLoopGroup)
    )

    client?.addPublishListener(named: topic) { result in
        switch result {
        case let .success(value):
            if let data = String(buffer: value.payload).data(using: .utf8) {
                NotificationCenter.default.post(name: .MQTTReceivedMessage, object: data)
            }
        case .failure:
            break
        }
    }

    try await client?.connect(cleanSession: true)
    let subscribeInfo = MQTTSubscribeInfo(topicFilter: topic, qos: .exactlyOnce)
    _ = try await client?.subscribe(to: [subscribeInfo])
}

使用公開的 broker broker.emqx.ioidentifier 帶入 userId 區分不同使用者。

收到訊息後透過 NotificationCenter 發出去,讓 ViewModel 可以在 @MainActor 環境接收。

發送與斷線

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func send(message: RoomViewModel.Message) async throws {
    let data = try JSONEncoder().encode(message)
    try await client?.publish(to: topic, payload: .init(data: data), qos: .exactlyOnce)
}

func disconnect() async throws {
    client?.removePublishListener(named: topic)
    try? await client?.unsubscribe(from: [topic])
    try? await client?.disconnect()
    try? await client?.shutdown()
    self.client = nil
}

訊息資料結構

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 傳輸用(JSON 編解碼)
struct Message: Codable, Sendable {
    let id: String
    let userId: String
    let userName: String
    let content: String
}

// 顯示用(區分自己和他人)
struct DisplayMessage: Identifiable, Sendable {
    let id: String
    let name: String
    let content: String
    let fromMe: Bool

    init(message: Message, myId: String) {
        self.id = message.id
        self.fromMe = message.userId == myId
        self.name = fromMe ? "我" : message.userName
        self.content = message.content
    }
}

Message 是傳輸的 DTO,DisplayMessage 是 View 用的 Domain Model,在 init 裡把「是不是自己發的」算好,View 不需要做任何判斷。


RoomViewModel 接收訊息

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
private func setupCancellable() {
    cancellable = NotificationCenter.default
        .publisher(for: .MQTTReceivedMessage)
        .receive(on: DispatchQueue.main)
        .sink { [weak self] notify in
            self?.handleReceiveMessage(notify: notify)
        }
}

private func handleReceiveMessage(notify: Notification) {
    guard let data = notify.object as? Data,
          let message = try? JSONDecoder().decode(Message.self, from: data) else { return }
    state.messages.append(DisplayMessage(message: message, myId: state.user.id))
}

MQTTManager(actor)收到訊息後,透過 NotificationCenter 把 Data 傳出來,RoomViewModel@MainActor)在 main thread 解碼並更新 state。


使用的套件


Demo


本文使用 Claude 共同完成