ユニファ開発者ブログ

ユニファ株式会社システム開発部メンバーによるブログです。

Network.framework を使った TCP Socket 通信してみる

こんにちは、iOSエンジニアのしだです。
今回は iOSエンジニアらしく、iOS12 から追加された Network.framework を使ってTCP Socket でチャットアプリを作ってみます。
この前、iOSアプリで TCP Socket を経由して画像を取ってくる必要が出てできて、めんどくさいなーどうしようかなーと考えてたら同僚に iOS12 から Network.framework が追加されたよと教えてもらったので実際に使ってみました。

準備

  • Xcode 10.1
  • Swift 4.2.1
  • NIOChatServer

Chatサーバー(NIOChatServer の実行)

今回、Chatのクライアントを iOSで書きますが、サーバー側は SwiftNIO のサンプルコードに含まれている NIOChatServer をそのまま使います。 SwiftNIO は Netty の Swift 実装なのですが、try! Swift 2018 で聞いてから使ってみようと思っていましたがなんだかんだで忘れていました 😅

mkdir ChatServer && cd ChatServer

// swift のプロジェクト作成
❯ swift package init

Package.swift の dependencies に swift-nio のリポジトリを追加します。

import PackageDescription

let package = Package(
    name: "ChatServer",

    ...    

    dependencies: [
        .package(url: "https://github.com/apple/swift-nio.git", from: "1.0.0")
    ],
    
    ...
)

ビルドしてChatサーバーを起動します。

❯ swift build

// 起動するとデフォルトポートは 9999 になります
❯ swift run NIOChatServer

Chatクライアント

こちらが本題で TCP Socket から Chatサーバーに接続してメッセージのやりとりができるクライアントを作成します。

ChatClient

接続の確立

はじめに、Network.framework をインポートして、 NWConnectionを初期化します。 引数にはホスト名やポート、ネットワーク接続する際のプロトコルを指定します。 NWConnection はコネクションの確立やデータの読み書きに必要なオブジェクトになります。

import Network

/// ...

class ChatClient {
    let connection: NWConnection

    /// 送受信したメッセージ
    var messages = BehaviorRelay<[Message]>(value: [])

    init(ip: String, port: UInt16) {
        let host = NWEndpoint.Host(ip)
        let port = NWEndpoint.Port(integerLiteral: port)

        /// NWConnection にホスト名とポート番号を指定して初期化
        self.connection = NWConnection(host: host, port: port, using: .tcp)
        
        /// コネクションのステータス監視のハンドラを設定
        self.connection.stateUpdateHandler = { (newState) in
            switch newState {
            case .ready:
                NSLog("Ready to send")
            case .waiting(let error):
                NSLog("\(#function), \(error)")
            case .failed(let error):
                NSLog("\(#function), \(error)")
            case .setup: break
            case .cancelled: break
            case .preparing: break
            }
        }
        
        /// コネクションの開始
        self.connection.start(queue: queue)
        self.receive(on: connection)
    }

    /// ...
}

stateUpdateHandler はコネクションのステータスを返すハンドラで、コネクションのライフサイクルを監視できます。 .setup.preparing.ready などのライフサイクルは WWDC 2018 の動画で詳しく説明されています。
.ready の状態はハンドシェイクも全て終わったあと返ってくるので、送受信を行う際は .ready のあとに行います。 また、WiFiとモバイル通信の切り替わりの中などのDNS名前解決を行っているときは .waiting になるようです。

メッセージの受信

class ChatClient {

    /// ...

    func receive(on connection: NWConnection) {
        /// コネクションからデータを受信
        connection.receive(minimumIncompleteLength: 0, maximumLength: Int(UInt32.max)) { [weak self] (data, _, _, error) in
            if let data = data {
                let text = String(data: data, encoding: .utf8)!
                let message = Message(text: text, isReceived: true)
                self?.messages.acceptAppending(message)
                self?.receive(on: connection)
            } else {
                NSLog("\(#function), Received data is nil")
            }
        }
    }

    /// ...
}

receive(minimumIncompleteLength:maximumLength:completion:) を使ってデータの受信処理を記述します。
このメソッドは実際にデータを受け取ると completion のコールバックが呼ばれますが、一度の受信に付き一回だけ呼ばれるので常に待受状態にしたい場合や大きいデータを受け取る場合などは毎回コールする必要があります。

APIのドキュメントの方には書かれていないようなのですが、 Netwotk.framework のコメントの方に記載がありました。

The completion handler will be invoked exactly once for each call, so the client must call this function multiple times to receive multiple chunks of data.

メッセージの送信

class ChatClient {
    
    /// ...

    func send(text: String) {
        let message = "\(text)\n"
        let data = message.data(using: .utf8)!

        /// メッセージの送信
        connection.send(content: data, completion: .contentProcessed { [unowned self] (error) in
            if let error = error {
                NSLog("\(#function), \(error)")
            } else {
                let message = Message(text: text, isReceived: false)
                self.messages.acceptAppending(message)
            }
        })
    }
}

最後に送信処理を記述します。NIOChatServer が改行(\n)を受け取るまでバッファリングされていたので、一回の送信ごとに改行(\n)を明示的に追加しています。

ChatViewController

チャットを行うViewControllerです。簡単にメッセージの送信とメッセージの表示を行います。

import UIKit

import RxSwift
import RxCocoa

class ChatViewController: UIViewController {
    let dispose = DisposeBag()

    @IBOutlet var tableView: UITableView!
    @IBOutlet var textField: UITextField!

    var ip: String = ""
    var port: UInt16 = 0
    lazy var chatClient = ChatClient(ip: ip, port: port)

    override func viewDidLoad() {
        super.viewDidLoad()

        /// メッセージが送受信されたらセルを更新する
        chatClient.messages.asObservable()
            .bind(to: tableView.rx.items(cellIdentifier: "ChatCell")) { (_, item: Message, cell) in
                cell.textLabel?.text = item.text
                cell.textLabel?.textAlignment = item.isReceived ? .right : .left
            }
            .disposed(by: dispose)
    }

    @IBAction func sendMessage(_ sender: UIButton) {
        if let message = textField.text {

            /// UITextField内のテキストを送信する
            chatClient.send(text: message)
            textField.text = ""
            view.endEditing(true)
        }
    }
}

実行

NIOChatServer を起動させたまま、クライアントを2つ起動します。今回はローカルで動かしているので iOSシミュレータを2つ起動させてデモしています。

f:id:unifa_tech:20181118003114g:plain

さいごに

Java でいう Socket つないで InputStream と OutputStream を開いて読み書きするインターフェイスとは異なりますが、 思ったよりも簡単に TCP Socket の実装ができるのでよかったです。WWDC 2018 の動画のほうには UDP でビデオストリーミングするデモもあるので参考になりました。

すべてのソースコードは以下に置いてありますので参考にしてください。

bitbucket.org

参考