0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Android】WebSocketをMVVM + Hiltで実装する:Android推奨アーキテクチャによるリアルタイム通信

Posted at

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 を使って、送信・受信・接続・切断をシンプルに実装できる構造を提示する

この記事を読んでできるようになること

  1. Data Layer に WebSocket をどう配置するか設計できる
  2. Hilt を使った依存注入で通信クラスをクリーンに設計できる
  3. 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 による接続、送信、受信の実装。SharedFlowMutableSharedFlow によるメッセージ配信、切断・再接続ロジックもここで担います。
(外部) ネットワーク/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を導入することで、各レイヤーの依存関係をシンプルに自動解決できます:

  • OkHttpClientWebSocketListenerImpl@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実装を提供できました。

8. 参考記事

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?