1. はじめに
リポジトリはこちら👇️
1.1 問題提起:リアルタイム通信の実装は難しい?
リアルタイム通信(WebSocket)をAndroidアプリに組み込もうとすると、通信ロジックが ViewModel や UI に分散してしまいがちです。
その結果、可読性の低下・テスト困難・バグ発生リスクといった問題が起こります。
// NGな例:UIから直接WebSocketを送信
@Composable
fun ChatScreen() {
val webSocket = OkHttpClient().newWebSocket(...)
Button(onClick = { webSocket.send("Hello") }) {
Text("Send")
}
}
1.2 本記事の目的と対象読者
本記事の目的
- WebSocket 通信ロジックを Data Layer(Repository)に集約し、View/ViewModelとの分離を徹底する
- MVVM + Hilt による構成で、依存注入やテストまで考慮した設計を実現する
- OkHttp を使って、送信・受信・接続・切断をシンプルに実装できる構造を提示する
この記事を読んでできるようになること
- Data Layer に WebSocket をどう配置するか設計できる
- Hilt を使った依存注入で通信クラスをクリーンに設計できる
- OkHttp によるリアルタイムメッセージ送受信を実装できる
対象読者・前提条件
項目 | 内容 |
---|---|
対象読者 | MVVM/Compose を使った Android 開発経験がある方 |
前提知識 | Kotlin Coroutine の基本が分かる方 |
2. Androidアーキテクチャの基本とレイヤー構成
Androidアプリ開発では、レイヤーごとに役割を明確に分離することが重要です。ここでは、MVVM + Android Architecture に基づいた典型的な3層構成をご紹介します。
層名 | 役割 | 詳細(関心事) |
---|---|---|
UI層(View) | ユーザー操作の受け取り・表示 | Composable によるビュー構築、ユーザー入力の収集(TextField , Button )、アニメーション、画面遷移など。UIは純粋に描画とイベント取得に専念し、ロジックは ViewModel に委譲します。 |
UI層(ViewModel) | UI と Data の仲介、状態管理 |
StateFlow /LiveData による UI 状態提供、ユーザーイベントを Repository に委託、viewModelScope を使ったコルーチン実行、ライフサイクルに応じた接続やクローズ処理(例:onCleared() )。 |
Data層 | 外部データソース(WebSocketなど)との具体的なやり取り | OkHttp を用いた WebSocketListenerImpl による接続、送信、受信の実装。SharedFlow や MutableSharedFlow によるメッセージ配信、切断・再接続ロジックもここで担います。 |
(外部) | ネットワーク/DB/ストレージなどの実際のデータソース | WebSocket サーバー、REST API、ローカルデータベース(Room)など、アプリがやり取りする外部環境です。 |
2-1 UI層(View)とUI層(ViewModel)の分離
ComposableなUIは、ボタンやテキストなどの描画とユーザー操作の受け取りに専念し、ロジックはViewModelへ委譲します。
例えば、「メッセージ送信」ボタンのクリック時にViewModelの send()
を呼ぶ構造です。
Button(onClick = { viewModel.send(input) }) {
Text("Send")
}
この分離により、UIのテスト・差し替えが容易になり、Composeプレビューも活用しやすくなります。
2-2 UI層(ViewModel)とData層の責務
UI層(ViewModel)は以下の役割を担います:
- UIが必要とする状態を
StateFlow/LiveData
として提供 - ユーザー操作イベントを受け、Data層(Repository)へ委託
- ライフサイクルに応じたリクエストやクリーンアップ処理(例:onCleared() で接続切断)
Data層には WebSocketRepository
クラスを配置し、ViewModelはこれに依存します。
実装の詳細をData層に隠蔽することで、ロジックの差し替えやテストが容易になります。
2-3 Hiltによる依存注入とレイヤー間の関係
Hiltを導入することで、各レイヤーの依存関係をシンプルに自動解決できます:
-
OkHttpClient
やWebSocketListenerImpl
は@Module
で提供 - ViewModelが依存するのは
WebSocketRepository
のみ - 実際の実装
WebSocketListenerImpl
は Data層から注入されます
@Provides @Singleton fun provideOkHttpClient(): OkHttpClient = ...
@Provides @Singleton fun provideWebSocketListener(): WebSocketListenerImpl = ...
この設計により、疎結合かつテストしやすい構造が実現できます。
2-4 なぜこうすべきか?メリットまとめ
- 可読性の向上:各レイヤーが担う責務が明確で、コードを追いやすい
- テスト容易性:WebSocket接続をモックに差し替えたり、UIテストにも強くなる
- メンテナンス性:接続先(HTTP⇄WebSocketなど)の変更や機能拡張も局所対応で済む
- バグの局所化:不具合が発生した場合の原因特定・修正がスムーズ
3. 本アプリのアーキテクチャ図
4. 技術スタックと各レイヤーの役割
レイヤー | 使用技術 | 役割 |
---|---|---|
UI層 | Compose + Navigation | ユーザー操作と表示 |
ViewModel層 | lifecycle-viewmodel-ktx + Hilt | UI状態管理とData依存性 |
Data層 | OkHttp + Hilt | WebSocket接続とメッセージの流れ |
5. Data Layer 実装の詳細
5-1. AppModule.kt
Hiltで依存注入を行い、以下を提供します:
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides @Singleton
fun provideOkHttpClient(): OkHttpClient = OkHttpClient.Builder().build()
@Provides @Singleton
fun provideWebSocketListener(): WebSocketListenerImpl = WebSocketListenerImpl()
}
Singletonにすることで、複数画面で同一接続を共有しやすくなります。
5-2. Models.kt
data class ChatMessage(
val text: String,
val isUser: Boolean,
val timestamp: Long = System.currentTimeMillis()
)
5-3. WebSocketListenerImpl.kt
OkHttpのWebSocketListener
を実装し、MutableSharedFlow
でイベントを配信します。
@Singleton
class WebSocketListenerImpl @Inject constructor(
private val client: OkHttpClient,
) : WebSocketListener() {
private val TAG = "WebSocketListenerImpl"
private val _events = MutableSharedFlow<String>(extraBufferCapacity = 64)
val events: SharedFlow<String> = _events
private lateinit var webSocket: WebSocket
private var url: String = "wss://echo.websocket.org"
/* ---------- Public API ---------- */
fun connect(targetUrl: String = url) {
url = targetUrl
if (::webSocket.isInitialized) {
Log.w(TAG, "connect() called but already connected")
return
}
Log.i(TAG, "Connecting → $url")
webSocket = client.newWebSocket(Request.Builder().url(url).build(), this)
}
fun send(text: String): Boolean {
val ok = ::webSocket.isInitialized && webSocket.send(text)
Log.d(TAG, "Send \"$text\" | success=$ok")
return ok
}
fun close(code: Int = 1000, reason: String? = null) {
Log.i(TAG, "Closing ($code) ${reason.orEmpty()}")
if (::webSocket.isInitialized) webSocket.close(code, reason)
}
/* ---------- WebSocketListener ---------- */
override fun onOpen(webSocket: WebSocket, response: Response) {
Log.i(
TAG,
"onOpen • protocol=${response.header("Sec-WebSocket-Protocol")} • headers=${response.headers}"
)
}
override fun onMessage(webSocket: WebSocket, text: String) {
Log.v(TAG, "onMessage (text): $text")
if (text.isBlank()) {
Log.w(TAG, "Received empty message, ignoring")
return
}
_events.tryEmit(text)
}
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
Log.v(TAG, "onMessage (binary): size=${bytes.size}")
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
Log.i(TAG, "onClosing ($code) $reason")
webSocket.close(code, reason)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
Log.i(TAG, "onClosed ($code) $reason")
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
Log.e(TAG, "onFailure • response=$response", t)
reconnect()
}
/* ---------- Reconnect helper ---------- */
private fun reconnect(delayMillis: Long = 3000) {
Log.w(TAG, "Reconnecting in $delayMillis ms…")
CoroutineScope(Dispatchers.IO).launch {
delay(delayMillis)
if (::webSocket.isInitialized) {
webSocket.cancel()
Log.d(TAG, "Old WebSocket cancelled")
}
connect(url)
}
}
}
5-4. WebSocketRepository.kt
ViewModelに提供するAPIクラス:
@Singleton
class WebSocketRepository @Inject constructor(
private val webSocket: WebSocketListenerImpl
) {
fun connect() {
webSocket.connect()
}
fun send(text: String): Boolean {
return webSocket.send(text)
}
fun observeMessages(): Flow<String> = webSocket.events
fun close(code: Int = 1000, reason: String? = null) {
webSocket.close(code, reason)
}
}
close()
の引数code
はWebSocket接続が閉じられた理由を示します。詳細はMDNの記事をご覧ください。
6. ViewModel~UIの連携
6-1. ChatViewModel
@HiltViewModel
class ChatViewModel @Inject constructor(
private val webSocketRepository: WebSocketRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<List<ChatMessage>>(emptyList())
val uiState: StateFlow<List<ChatMessage>> = _uiState.asStateFlow()
init {
viewModelScope.launch {
webSocketRepository.connect()
webSocketRepository.observeMessages().collect { text ->
_uiState.update {
it + ChatMessage(text = text, isUser = false)
}
}
}
}
override fun onCleared() {
super.onCleared()
webSocketRepository.close()
}
fun send(text: String) {
_uiState.update {
it + ChatMessage(text = text, isUser = true)
}
webSocketRepository.send(text)
}
}
6-2. ChatView
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatView(
viewModel: ChatViewModel = hiltViewModel()
) {
val messages by viewModel.uiState.collectAsState()
var input by remember { mutableStateOf("") }
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = { Text("WebSocket Sample") }
)
},
bottomBar = {
Row(
Modifier
.fillMaxWidth()
.padding(WindowInsets.navigationBars.asPaddingValues())
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
TextField(
modifier = Modifier.weight(1f),
value = input,
onValueChange = { input = it },
placeholder = { Text("Type a message…") },
singleLine = true
)
Spacer(Modifier.width(8.dp))
Button(
onClick = {
viewModel.send(input)
input = ""
},
enabled = input.isNotBlank()
) {
Text("Send")
}
}
}
) { innerPadding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
reverseLayout = true
) {
items(messages.reversed()) { message ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = if (message.isUser) Arrangement.End else Arrangement.Start
) {
Surface(
modifier = Modifier.widthIn(max = 280.dp),
tonalElevation = 2.dp,
shape = RoundedCornerShape(12.dp),
color = if (message.isUser) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant
) {
Text(
text = message.text,
modifier = Modifier.padding(12.dp),
style = MaterialTheme.typography.bodyLarge,
color = if (message.isUser) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
}
7. まとめ
この実装により、以下のメリットを実現できます:
- 責務の明確化:UI層はUIのみ、Data層は通信のみに集中
- テストの容易性:各レイヤーを独立してテスト可能
- 保守性の向上:変更の影響範囲を局所化
- 依存性の管理:Hiltによる自動注入で疎結合を実現
3層構成によるシンプルな設計で、拡張性と保守性を両立したWebSocket実装を提供できました。