コネヒト開発者ブログ

コネヒト開発者ブログ

【iOS】URLSessionWebSocketTaskを用いたリアルタイムチャット機能の実装パターン

本記事はコネヒト Advent Calendar 2025の17日目のエントリーになります。

adventar.org

こんにちは、iOSエンジニアのyoshitakaです!

この記事ではAdvent Calendar 2025の13日目のエントリー「API Gateway WebSocket API と Lambda で作る、ママリのリアルタイムチャット機能(サークル機能)を支えるサーバーレスなインフラ設計」で紹介された、ママリのリアルタイムチャット機能について、iOSアプリ開発の視点で知見を共有したいと思います。

tech.connehito.com

はじめに

ママリの「サークル機能」とは、出産育児に関するさまざまなテーマのサークルに参加し、ユーザー同士がチャット形式でコミュニケーションを取れるというものです。

ママリではQ&A形式でユーザー同士がやり取りできる機能を提供していますが、このサークル機能ではよりリアルタイム性を重視しました。

「API Gateway WebSocket API と Lambda で作る、ママリのリアルタイムチャット機能(サークル機能)を支えるサーバーレスなインフラ設計」で詳しく紹介されていますが、リアルタイムチャット機能にはWebSocketを使っています。

WebSocketを使ったリアルタイム通信をSwiftでどのように実装しているのか、具体的なコードとともに紹介します。

WebSocketをiOSアプリで使うには

iOS13以降ではURLSessionWebSocketTaskを実装することで実現できます。

developer.apple.com

WebSocketの接続と送受信周りの処理を紹介します。 なお、今回のサークル機能では常に1つのコネクションのみを使用する仕様のため、紹介するコードも複数接続は考慮していません。

ハンドシェイク(接続)部分

URLSessionwebSocketTaskを生成しresumeすることで接続が開始できます。

webSocketTask(with:) | Apple Developer Documentation

func webSocketTask(with request: URLRequest) -> URLSessionWebSocketTask

今回の設計では接続はサークルごとに確立しています。サークルIDをリクエストに含めてwebSocketTaskを作成します。

func setup() {
        let request = WebSocketRequestProvider.createRequest(circleId: circleId)
        let session = URLSession(
            configuration: .default,
            delegate: self,
            delegateQueue: nil
        )
        socket = session.webSocketTask(with: request)
        socket?.resume()
    }

接続の結果はURLSessionWebSocketDelegateで受け取ります。

URLSessionWebSocketDelegate | Apple Developer Documentation

ハンドシェイクが成功した場合urlSession(_:webSocketTask:didOpenWithProtocol:)が呼ばれるので、接続状態のステートを更新します。

※ 異常系は接続管理部分で書きました

extension CircleChatWebSocketService: URLSessionWebSocketDelegate {
    nonisolated public func urlSession(
        _ session: URLSession,
        webSocketTask: URLSessionWebSocketTask,
        didOpenWithProtocol protocol: String?
    ) {

        // 接続成功
        Task { @MainActor in
            connectionState = .connected
            resetReconnectAttempts()
        }
    }

受信部分

無事接続が確立できると、次はWebSocketから値が流れてくるようになります。

値の受け取りはwebSocketTaskreceive()を使用します。

注意点として、このメソッドは再帰的に呼ぶ必要があります。

func receive() async throws -> URLSessionWebSocketTask.Message

https:// https://developer.apple.com/documentation/foundation/urlsessionwebsockettask/receive()

completionHandler版の方には解説があります。

receive(completionHandler:) | Apple Developer Documentation

WebSocketから受け取る値は、String or Dataの形になります。

startReceivingは初回接続時以外にも接続が切れた際の再接続時には常に呼び出す形になります。

    private func startReceiving() {
        stopReceiving()

        receiveTask = Task { [weak self] in
            while !Task.isCancelled {
                do {
                    guard let webSocket = self?.socket else { break }

                    let message = try await webSocket.receive()

                    switch message {
                    case .string(let string):
                        self?.handleTextMessage(string)

                    case .data(let data):
                        self?.handleBinaryMessage(data)

                    @unknown default:
                        break
                    }
                } catch {

                    // エラーハンドリング
                    if !Task.isCancelled, self?.connectionState == .connected {
                        try? await Task.sleep(for: .seconds(1))
                        continue
                    }
                    break
                }
            }
        }
    }

    private func stopReceiving() {
        // 受信待機停止
        receiveTask?.cancel()
        receiveTask = nil
    }

送信部分

値の送信はwebSocketTasksend(_:)を使用します。

func send(_ message: URLSessionWebSocketTask.Message) async throws

send(_:) | Apple Developer Documentation

completionHandler版の方には解説があります

send(_:completionHandler:) | Apple Developer Documentation

送信前には接続を確認しておき、接続が切れている場合は再接続をするように処理を入れました。

    public func send(request: CircleWebSocketRequest) async throws {
        guard connectionState == .connected else {
            switch connectionState {
            case .disconnected:
                // リクエスト送信前に接続を確認、切断されている場合は再接続トライ
                connect()
                throw WebSocketError.notConnected
            case .connecting:
                throw WebSocketError.connecting
            default: return
            }
        }

        let dataString = try request.createDataString()

        try await socket?.send(.string(dataString))
    }

この送信処理では、REST APIとは異なりレスポンスを受け取りません。 つまり、送信したデータをサーバー側が受信したかどうかは別途確認応答を送ってもらう必要があります。

サークル機能ではテキストや写真を投稿できるため、投稿の成功・失敗をユーザーにフィードバックする必要があります。

そこで、すべての送信データにクライアント側でリクエストIDを発行し、そのIDを確認応答で受け取ることで成功・失敗を判定するようにしました。

接続管理

接続が確立してから一定期間送受信がないと接続がタイムアウトしてしまいます。 タイムアウトの時間はサーバー側の設定次第ですが、意図しないタイムアウトを避けるため、pingを一定間隔で送るようにします。

sendPing(pongReceiveHandler:) | Apple Developer Documentation

    func setupPingTimer() {
        pingTimerCancellables.removeAll()

        Timer.publish(every: pingInterval, on: .main, in: .common)
            .autoconnect()
            .sink { [weak self] _ in
                self?.ping()
            }
            .store(in: &pingTimerCancellables)
    }

    func ping() {
        // ping送信前に接続状況を確認、切断されている場合は再接続をトライ
        if connectionState == .disconnected {
            connect()
        }

        socket?
            .sendPing(pongReceiveHandler: { error in
                // 必要に応じてエラーハンドリング
            })
    }

また、意図しない切断が発生した場合は、再接続を行います。

接続が切断される場合は、接続成功時と同じくURLSessionWebSocketDelegateにて切断理由とともに値を受け取ることができます。

urlSession(_:webSocketTask:didCloseWith:reason:) | Apple Developer Documentation

また、電波遮断等が原因の切断を検知できるようにURLSessionTaskDelegatedidCompleteWithErrorの実装も追加しておくと安心です。

urlSession(_:task:didCompleteWithError:) | Apple Developer Documentation

意図した切断以外は再接続をトライ(遅延を入れて数回行う)するようにしました。

    // サーバーからの切断
    nonisolated public func urlSession(
        _ session: URLSession,
        webSocketTask: URLSessionWebSocketTask,
        didCloseWith closeCode: URLSessionWebSocketTask.CloseCode,
        reason: Data?
    ) {
        let reasonString = reason.flatMap { String(data: $0, encoding: .utf8) } ?? "No reason"

        Task { @MainActor in
            let wasConnected = connectionState == .connected
            connectionState = .disconnected
            socket = nil

            if wasConnected && closeCode != .normalClosure {
                // 接続状態からの異常切断、再接続を試みる
                attemptReconnect()
            }
        }
    }

    // 電波断絶やタイムアウトなど
    nonisolated public func urlSession(
        _ session: URLSession,
        task: URLSessionTask,
        didCompleteWithError error: Error?
    ) {
        guard let error else { return }

        // 接続失敗時の再接続
        Task { @MainActor in
            let wasConnecting = connectionState == .connecting
            connectionState = .disconnected
            socket = nil

            if wasConnecting {
                attemptReconnect()
            }
        }
    }

おわりに

URLSessionWebSocketTaskを使った具体的なリアルタイムチャット機能の実装方法について紹介しました。

WebSocketを使ったデータのやり取りにおけるリクエスト成功/失敗の管理は想定よりもクライアント側でやることが多く大変でした。

リアルタイムチャット機能の開発ではUI側の実装で苦労した部分も多かったので、別の記事で紹介できればと思います!