「Kotlin Multiplatform (KMP) に興味はあるけど、まだ使ったことがない」
…という Android 開発者の方に向けて、この記事では、WebRTCとWebSocketを使ったリアルタイム通信アプリを、意外と簡単に開発できた経験を共有します。
はじめに
普段の私 @isseikz は、主にAndroid アプリ開発をしています。
Android だけでなく、iOS や Web に開発範囲を広げたいと考えていますが、プラットフォーム別の技術習得コストを課題に感じていました。その解決策として、Kotlin Multiplatform (KMP) と Compose Multiplatform (CMP) に興味がありました。
このたび、新たに作りたいものが浮かんできたので、ここらで KMP/CMP を使ったシステムを構築し、その使用感をレビューしたいと思います (← セリフが YouTuber くさい)。
それではさっそくやっていきましょう。
お題 - Android TV リモートリモコン
Android TV のミラーリングとリモコン操作を、PCやスマホから遠隔で行える iOS・Android・Web アプリを作りました。
ポイントは、キャプチャ映像とリモコン操作が一つの画面に統合されていることです。こういうアプリがあまりないので困っていました。
(左から iOS, Android, Google Chrome)
リポジトリはこちら (Pull Request 歓迎です!) : isseikz/ATV-Remote
よかったところ
① プロジェクトのセットアップがとにかく簡単
JetBrains が提供する公式ウィザード を利用すれば、 Hello World アプリをビルドでき、特につまづくこともありませんでした。
開発を進める中で、iOS のライブラリ導入など、後述するハマりどことはありましたが、まずは Android と共通部分の開発に集中できたので、開発モチベを維持しやすかったです。
② Android開発のスキルが想像以上に活かせる
Androidx の API は使えないだろうな…。と想像していましたが、ViewModel や Lifecycle など、普段使っているAndroidx 系のライブラリがそのまま使えました。MVVM パターンに慣れている人なら、すぐに安定動作するアプリが作れるはずです。
③ Ktor と Kotlin RPC の統合が直感的
この二つを組み合わせることで、サーバー側とクライアント側の API 仕様の実装を、共通モジュールの interface で統一できました。
通信していることをほぼ意識せずに、型安全な RPC を実装できることが、とても面白かったです。
ハマったところと解決策
iOS ビルドのライブラリ導入
前置きとして、iOS をはじめとした XCode 開発では、CocoaPods 等を用いてライブラリ管理するそうです。
JetBrains が提供する CocoaPodsと Gradleの連携プラグイン は、Kotlin 2.0 から仕様が変わった様で、情報検索に苦労しました。
※最終的な記述方法は補足欄に記載しました。
サーバー側 WebRTC ライブラリの組み込み
Apple Silicon 環境では、webrtc-java ライブラリが実行時エラーになり、WebRTC API にアクセスできない問題がありました。
build.gradle.kts でプラットフォームを明示する必要がありました。
dependencies {
runtimeOnly(libs.webrtc.java) {
artifact {
classifier = "macos-aarch64"
}
}
(略)
WebRTC の振る舞いの一貫性
WebRTC の動作が、Web ブラウザによって動作がまちまちの様です。
Android の Chrome では問題なく接続できましたが、iOS の Safari/Chrome ではうまく動作していません。ICE Candidate の送信まわりに問題だと推測しますが、未解決問題の課題です。
総評
Android 開発者にとって、とにかく簡単に iOS/Web アプリを作ることができました。開発初期のハードルが劇的に下がったことは率直に感動的でした。
もちろん、プラットフォーム固有の実装が少なからず残り、解決にはそれなりの知識/LLM が必要になりますが、共通化によって生まれた時間とパワーを、各プラットフォームの差分の理解に充てられることは、大きなメリットだと思いました。
個人的には、今後も使い続けたいと思います。
補足
システムの全体像
設計コンセプト
- 制御関係: WebSocket で通信することで、信頼性の高いデータ授受を実現する
- 映像配信機能: WebRTC で低遅延な配信を実現する
- 映像取得: Android デバイスの映像を HDMI キャプチャで取得することで、OS 標準機能で映像を取り込む
技術スタック
| カテゴリ | 技術 | バージョン | 役割 |
|---|---|---|---|
| 言語 | Kotlin | 2.2.0 | プロジェクト全体の開発言語 |
| プラットフォーム | Kotlin Multiplatform (KMP) | 2.2.0 | マルチプラットフォーム開発基盤 |
| UIフレームワーク | Compose Multiplatform | 1.8.2 | 宣言的クロスプラットフォームUI |
| サーバー | Ktor | 3.2.1 | WebSocketサーバー、HTTP API |
| RPC | kotlinx-rpc | 0.9.1 | 型安全なRPC通信フレームワーク |
| WebRTC (共通) | webrtc-kmp | 0.125.11 | マルチプラットフォームWebRTC実装 |
| WebRTC (Server) | webrtc-java | 0.14.0 | サーバーサイドWebRTC、MediaDevices映像キャプチャ機能含む |
| WebRTC (iOS) | WebRTC-SDK | 125.6422.07 | iOS向けネイティブWebRTC実装 |
| シリアライゼーション | kotlinx-serialization | 1.8.0 | JSON シリアライゼーション |
| コルーチン | kotlinx-coroutines | 1.10.2 | 非同期処理・並行処理 |
Cloudflare Realtime TURN サーバー
NAT越えのために Cloudflare が提供する TURN サーバー を利用しました。 個人用途としては意外と安価で、利用方法も簡単だったことが選定理由です。
参考に価格設定を掲載します。
| Freeプラン | 有料プラン | |
|---|---|---|
| クライアントへのデータ転送 | 1,000GB/月 | 1,000GB/月まで無料、以降従量制$0.05/GB |
| クライアントからのデータ転送 | 無料 | 無料 |
引用元: SFUおよびTURNサーバーの価格設定 - Cloudflare Realtime
Ktor と Kotlin RPC によるシグナリング機能の実装
Ktor のプラグインとして WebSocket と Kotlin RPC をインストールすることで、WebSocket の通信実装が自動生成されます。エンドポイントの実装も、共有モジュールで定義した interface を登録するだけでした。
fun Application.configureRouting(
signaling: ISignalingService,
sessionManager: SessionManager
) {
routing {
// RPC WebSocket endpoint for kotlinx-rpc communication
rpc(PATH_RPC) {
rpcConfig {
serialization {
json()
}
}
// ここに RPC 仕様を interface と具象クラスで定義する
registerService<IAtvControlService> { AtvControlServiceImpl(this@configureRouting, sessionManager) } // TODO セッション別に別の adb 接続を管理する
registerService<ISignalingService> { signaling }
registerService<ISessionService> { SessionServiceImpl(sessionManager) }
}
}
}
fun Application.configureRPC() {
install(WebSockets) {
pingPeriod = 15.seconds
timeout = 5.seconds
maxFrameSize = Long.MAX_VALUE
masking = false
}
install(Krpc)
}
@Rpc
interface ISignalingService {
/**
* クライアントA が ウェイティングリストに自身を登録して、Offer を待機する
*
* @param request セッションの詳細情報
* @return SDP Offer
*/
fun waitForOffer(request: SessionRequest): Flow<SignalingOffer>
/**
* クライアントB が待機中のクライアントA に Offer を送信し、Answer を待ち受ける
* @param offer クライアントから送信されたSDP Offer
* @return 対応するSDP AnswerとICE Candidate
*/
fun offer(offer: SignalingOffer): Flow<SignalingAnswer>
/**
* クライアントA がクライアントB に Answer を送信し、Candidate を待ち受ける
*
* @param answer SDP Answer
* @return リモートデバイスの ICE Candidate. これを setRemoteDescription 後に addIceCandidate で追加する
*/
fun answer(answer: SignalingAnswer): Flow<SignalingCandidate>
/**
* 自身の通信経路が変わったときに、相手に新しい Candidate を送信する
* @param candidate クライアントから送信されたICE Candidate情報
*/
fun putIceCandidates(candidate: SignalingCandidate): Flow<Unit>
}
Kotlin CocoaPods プラグインの宣言方法
iOSビルドが成功した設定ファイルを共有します。ios.deploymentTargetとpodfileのパス指定がポイントでした。
※ Kotlin 2.2.0 でのみ動作確認したため、他のバージョンではうまく動作しない可能性があります。
kotlin {
androidTarget {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_18)
}
}
cocoapods {
version = "1.0"
summary = "Some description for a Kotlin/Native module"
homepage = "Not yet"
ios.deploymentTarget = "18.0" // ポイント1 これを XCode のプロジェクト設定と合わせておかないと、CocoaPods の target version 不一致でビルドが失敗する
framework {
baseName = "ComposeApp"
isStatic = false
}
pod("WebRTC-SDK") {
version = libs.versions.webrtc.ios.get()
moduleName = "WebRTC"
packageName = "WebRTC"
linkOnly = true
}
podfile = project.file("../iosApp/Podfile") // ポイント2 composeApp のパスを手動登録しないと、iOS ビルド時に Compose のモジュールを解決できない
xcodeConfigurationToNativeBuildType["CUSTOM_DEBUG"] = NativeBuildType.DEBUG
xcodeConfigurationToNativeBuildType["CUSTOM_RELEASE"] = NativeBuildType.RELEASE
}
iosArm64()
iosSimulatorArm64()
(略)
# iosApp/Podfile
# Uncomment the next line to define a global platform for your project
platform :ios, '13.0'
target 'iosApp' do
# Comment the next line if you don't want to use dynamic frameworks
use_frameworks!
# Pods for iosApp
pod 'composeApp', :path => '../composeApp'
end
最後まで読んでいただき、ありがとうございました!
