
iOSチームの福山です。 Mirrativ iOSでは Flipper (Meta社のOSS) を使っていました。しかし Xcode 16.3 から Flipper が動かなくなるという事象が発生し、リポジトリもアーカイブされていることから、Vapor を利用して代替となる機能を作成しようと考えました。今回の記事では、その基礎となるサンプルの実装をご紹介します。
VaporはSwift言語で書かれたオープンソースのWebフレームワークです。
サンプル作成のフローは次のようになります。
- VaporでMac上のWebブラウザにWebページを表示するローカルサーバーをたてる
- VaporでWebSocket(双方向通信)を行うローカルサーバーをたてる
- VaporとiPhone上のアプリがWebSocketで双方向通信
- VaporとWebページがWebSocketで双方向通信
Vaporの導入
HomebrewでVaporをインストールします。 Vaporアプリの名前はMyVaporAppとしておきます。
brew install vapor vapor --version # ブログ記事の時点では19.2.0 vapor new MyVaporApp
以下のように、いくつか質問されます。今回はORMもLeafも使わないので、2回とも "n" を入力し Enter (or Return) キーを押しセットアップを完了させます。
Cloning template... Would you like to use Fluent (ORM)? (--fluent/--no-fluent) y/n> n fluent: No Would you like to use Leaf (templating)? (--leaf/--no-leaf) y/n> n leaf: No Generating project files Creating git repository Adding first commit Project MyVaporApp has been created! Use cd 'MyVaporApp' to enter the project directory Then open your project, for example if using Xcode type open Package.swift or code . if using VSCode
Vaporの実装
まずWebページとなる MyVaporApp/Public/index.html を追加します。
<html> <head> <title>My Vapor App</title> <style> #send-button { margin: 20px; } #content { width: 500px; min-height:50px; border: 1px solid blue; } </style> </head> <body> <button id="send-button">iPhoneアプリへ何か送信</button> <hr> <p>iPhoneアプリから来たメッセージ</p> <div id="content"></div> <script src="script.js"></script> </body> </html>
次にWebページとVaporをWebSocketで接続するため MyVaporApp/Public/script.js を追加します。
// WebSocket (() => { const socket = new WebSocket('ws://localhost:8080/ws-interface') // メッセージ受信時の処理 const content = document.getElementById('content') socket.onmessage = function (event) { const data = JSON.parse(event.data) let html = `<p>${data.message}</p>` content.insertAdjacentHTML('beforeend', html) } // エラー処理 socket.onerror = function (error) { console.error('WebSocket Error:', error) } // 接続が閉じられたときの処理 socket.onclose = function (event) { console.log('WebSocket connection closed') } // アプリへメッセージを送る const sendButton = document.getElementById('send-button') sendButton.addEventListener('click', function () { const randomNumberString = Math.random().toString() const json = { message: randomNumberString } let jsonString = JSON.stringify(json) socket.send(jsonString) }) })();
最後にMyVaporApp/Sources/MyVaporApp/routes.swift を開き以下のコードに置き換えます。
import Vapor func routes(_ app: Application) throws { // iPhone など同一LANから到達できるよう全I/Fで待受け app.http.server.configuration.hostname = "0.0.0.0" app.http.server.configuration.port = 8080 // Publicフォルダを使用可能にする (index.html をデフォルトに) let fileMiddleware = FileMiddleware( publicDirectory: app.directory.publicDirectory, defaultFile: "index.html" ) app.middleware.use(fileMiddleware) let webSocketStore = WebSocketStore() // WebページのJSから接続 // ws://localhost:8080/ws-interface app.webSocket("ws-interface") { req, ws in webSocketStore.interface = ws ws.onText { ws, text in webSocketStore.clients.forEach({ $0.object?.send(text) }) } } // iPhoneアプリから接続 // ws://192.168.x.x:8080/ws-client app.webSocket("ws-client") { req, ws in webSocketStore.appendClient(ws) ws.onText { ws, text in webSocketStore.interface?.send(text) } ws.onClose.whenComplete { result in print("Client WebSocket closed: \(result)") webSocketStore.removeClient(ws) } } } final class WebSocketStore: @unchecked Sendable { var interface: WebSocket? var clients: [WeakObjectWrapper] = [] func appendClient(_ client: WebSocket) { clients.append(WeakObjectWrapper(object: client)) } func removeClient(_ client: WebSocket) { clients.removeAll(where: { $0.object === client }) } } final class WeakObjectWrapper { weak var object: WebSocket? init(object: WebSocket) { self.object = object } }
以上でVapor側は完了です。
iOSアプリの実装
XcodeでSwiftUIのプロジェクトを新規作成するとContentView.swift が生成されています。 このファイルを以下に書き換えます。
import Observation import SwiftUI struct ContentView: View { @State private var state = ContentViewState() var body: some View { VStack { Button { state.sendSomeMessage() } label: { Text("Webページへ何か送信") .foregroundStyle(Color.white) } .padding(.vertical, 10) .padding(.horizontal, 20) .background( RoundedRectangle(cornerRadius: 10, style: .continuous) .foregroundStyle(Color.cyan) ) Divider() .padding(.bottom, 10) Text("Webページから来たメッセージ") Text(state.message) .foregroundStyle(Color.gray) } .padding() } } @Observable final class ContentViewState { protocol Delegate: AnyObject { func didReceiveMessage(_ message: Message) } var message = "" init() { VaporService.shared.contentViewStateDelegate = self } func sendSomeMessage() { let message = Int.random(in: 1...10000).description VaporService.shared.sendMessage(Message(message: message)) } } extension ContentViewState: ContentViewState.Delegate { func didReceiveMessage(_ message: Message) { let text = message.message self.message += self.message.isEmpty ? text : "\n\(text)" } } #Preview { ContentView() } struct Message: Codable { let message: String } final class VaporService: NSObject { static let shared = VaporService() weak var contentViewStateDelegate: ContentViewState.Delegate? private lazy var urlSession = URLSession(configuration: .default, delegate: self, delegateQueue: nil) /// 192.168.10.5部分はMacのローカルIPで環境や人によって異なる。 /// MacとiPhoneが同一WiFiに繋がっていることが前提で /// `ipconfig getifaddr en0` コマンドやMacのWiFi詳細設定から確認できる。 private var url: URL? = URL(string: "ws://192.168.10.5:8080/ws-client") private(set) var isOpen = false private var webSocketTask: URLSessionWebSocketTask? { didSet { oldValue?.cancel() } } private var receiveMessageTask: Task<Void, Never>? { didSet { oldValue?.cancel() } } override private init() { super.init() start() } func start() { guard let url else { return } webSocketTask = urlSession.webSocketTask(with: url) webSocketTask?.delegate = self webSocketTask?.resume() } func receive() async { guard let webSocketTask else { return } while isOpen { do { let message = try await webSocketTask.receive() switch message { case let .string(text): guard let data = text.data(using: .utf8) else { return } let decodedMessage: Message = try JSONDecoder().decode(Message.self, from: data) contentViewStateDelegate?.didReceiveMessage(decodedMessage) default: break } } catch { closeWebSocket() isOpen = false } } } func closeWebSocket() { webSocketTask?.cancel(with: .goingAway, reason: nil) } /// Webページへメッセージを送る func sendMessage(_ message: Message) { guard isOpen else { return } Task { let encoded = try JSONEncoder().encode(message) if let jsonString = String(data: encoded, encoding: .utf8) { try? await webSocketTask?.send(.string(jsonString)) } } } } extension VaporService: URLSessionWebSocketDelegate { public func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) { isOpen = true receiveMessageTask = Task { await receive() } } public func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { isOpen = false } }
iPhoneとMacが同じWiFi下にあることを前提として、iPhoneからMacに接続するためにMacのローカルIPアドレスが必要になります。
通常はipconfig getifaddr en0コマンド等で取得可能で、MacのWiFi詳細設定からも確認できます。
ここでは触れませんが、自動化をするとしたらXcodeでビルド時にBuild PhaseでIPアドレスを取得しinfo.plistに保存しランタイムに取り出して使うという方法があります。

接続
まずはVaporを起動します。
# 作成したVaporフォルダへ移動 cd path/to/MyVaporApp # 起動 swift run # 以下出力 [1/1] Planning build Building for debugging... [1/1] Write swift-version--XXXXXXXXXXXXXXXX.txt Build of product 'MyVaporApp' complete! (4.22s) [ NOTICE ] Server started on http://0.0.0.0:8080
Vapor起動時にネットワーク受信接続の許諾ダイアログが表示されるので許可してください。

次にWebブラウザで http://localhost:8080 を開くとターミナルにはこのように出力されます。
[ INFO ] GET / [request-id: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX] [ INFO ] GET /script.js [request-id: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX] [ INFO ] GET /favicon.ico [request-id: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX] [ INFO ] GET /ws-interface [request-id: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX]
次にiOSアプリをXcodeでビルドします。 ローカルネットワーク接続の許諾ダイアログが表示されるので許可します。

再度ビルドするかアプリをキルして開きなおすとターミナルには以下のように出力されます。
[ INFO ] GET /ws-client [request-id: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX]
ここまでの操作で接続は完了しました。 WebページとiOSアプリの一方でボタンを押すともう一方にランダムな数字が表示されるようになり、開発環境で双方向通信が実現できました。

おわりに
今回は最低限のサンプルをご紹介しました。以下の画像のようにMirrativ iOSではUnityやPub/Sub通信等のログ取得と操作で活用しています。アイデア次第で様々な使い方ができるのではないでしょうか。

We are hiring!
ミラティブでは一緒に開発してくれる iOS エンジニアを募集しています! 少しでも興味を持っていただいた方はお話を聞いていただくだけでも結構ですので、お気軽にご連絡ください。
インターンも募集中です!