Usando WebSockets, ViewModel e Jetpack Compose

Tempo de leitura: 6 minutes

Fui pesquisar como utilizar WebSockets com Android e fiquei muito contente em descobrir como é fácil. 😌 Então resolvi compartilhar aqui por meio de um exemplo bem simples.

Servidor WebSocket

Para testar esse exemplo, não é preciso criar um servidor WebSocket do zero. Nesse post será utilizado o site PieSocket, onde é possível criar uma conta gratuita e “levantar” um servidor WebSocket.

Após criar a conta e fazer o login, na opção “Dashboard” (do lado esquerdo) é preciso criar um “Channel Cluster” clicando no botão “Create Channels Cluster“.

No campo “Cluster Name” é possível definir um nome para o cluster, mas o importante aqui é escolher o tipo do plano. O plano gratuito permite 200 conexões simultâneas e 200 mil mensagens por dia. Isso é mais do que o suficiente para o teste que será feito aqui.

Mais abaixo, ainda é possível escolher a região onde esse cluster será criado. No momento da escrita desse artigo, o cluster mais próximo do Brasil é em Nova Iorque. Selecione essa opção e clique em “Create Cluster“.

Após criar o cluster, selecione-o na listagem e será exibida diversas informações sobre ele.

Na implementação do aplicativo que será apresentada a seguir, será preciso o “Cluster ID” e da “API Key”, pois a URL para estabelecer a conexão com o servidor segue o seguinte padrão:
wss://<cluster_id>.piesocket.com/v3/1?api_key=<API_Key>

Perceba que existe um botão “Test online” que será utilizado ao final deste artigo para testar o envio de mensagens do servidor para o cliente.

Criando a aplicação Android

Dependências

Crie um novo projeto Compose no Android Studio (neste post estou usando a versão Hedgehog | 2023.1.1 Patch 2).

Essas são as dependências necessárias para o projeto.

dependencies {
    // Automaticamente adicionadas ao projeto
    implementation 'androidx.core:core-ktx:1.10.1'
    implementation platform('org.jetbrains.kotlin:kotlin-bom:1.8.0')
    implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.1'
    implementation 'androidx.activity:activity-compose:1.7.2'
    implementation platform('androidx.compose:compose-bom:2022.10.00')
    implementation 'androidx.compose.ui:ui'
    implementation 'androidx.compose.ui:ui-graphics'
    implementation 'androidx.compose.ui:ui-tooling-preview'
    implementation 'androidx.compose.material3:material3'
    // Dependências que precisaremos neste exemplo
    implementation 'com.squareup.okhttp3:okhttp:4.11.0'
    implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1'

Além das dependências adicionadas automaticamente, as duas últimas foram incluídas:

  • OkHttp para realizar a conexão com o WebSocket;
  • View Model Compose, para poder instanciar o view model mais facilmente (usando a função viewModel()). Caso esteja usando Hilt ou Koin, essa última dependência não é necessária.

 

Message Service

A classe MessageService listada a seguir contém os métodos para conectar, desconectar, enviar e receber mensagens do WebSocket.

import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener

class MessageService {
    private val _isConnected = MutableStateFlow(false)
    val isConnected = _isConnected.asStateFlow()

    private val _messages = MutableStateFlow(emptyList<Pair<Boolean, String>>())
    val messages = _messages.asStateFlow()

    private val okHttpClient = OkHttpClient()
    private var webSocket: WebSocket? = null

    private val webSocketListener = object : WebSocketListener() {

        override fun onOpen(webSocket: WebSocket, response: Response) {
            super.onOpen(webSocket, response)
            _isConnected.value = true
            webSocket.send("Android Client Connected")
        }

        override fun onMessage(webSocket: WebSocket, text: String) {
            super.onMessage(webSocket, text)
            _messages.update {
                val list = it.toMutableList()
                list.add(false to text)
                list
            }
        }

        override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
            super.onClosing(webSocket, code, reason)
        }

        override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
            super.onClosed(webSocket, code, reason)
            _isConnected.value = false
        }

        override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
            super.onFailure(webSocket, t, response)
        }
    }

    fun connect() {
        val cluseterId = "SEU_CLUSTER_ID" // e.g: s9999.nyc1
        val apiKey = "SUA_API_KEY"
        val webSocketUrl =
            "wss://$cluseterId.piesocket.com/v3/1?api_key=$apiKey"

        val request = Request.Builder()
            .url(webSocketUrl)
            .build()
        
        webSocket = okHttpClient.newWebSocket(request, webSocketListener)
    }

    fun disconnect() {
        webSocket?.close(1000, "Disconnected by client")
    }

    fun shutdown() {
        okHttpClient.dispatcher.executorService.shutdown()
    }

    fun sendMessage(text: String) {
        if (_isConnected.value) {
            webSocket?.send(text)
            _messages.update {
                val list = it.toMutableList()
                list.add(Pair(true, text))
                list
            }
        }
    }
}

O atributo isConnected é um StateFlow para informar se o socket está conectado ou não. Em messages (que também é um StateFlow) ficará armazenada a lista de mensagens recebidas pelo servidor. Perceba que é uma lista de Pair<Boolean,String> onde: o Boolean indica se a mensagem foi enviada pelo próprio device, ou veio do servidor; e a String é a mensagem em si. Fiz dessa forma para simplificar o exemplo, mas na prática, teríamos uma estrutura como um JSON, onde faríamos o decode, converteríamos para um objeto e teríamos algo mais elaborado.

A parte mais importante desse código é o objeto webSocketListener que implementa a interface WebSocketListener. Os métodos dessa interface são quase auto-explicativos:

  • onOpen é chamado quando a conexão com o web socket é estabelecida. Neste momento, o valor da propriedade isConnected é atualizado e a mensagem “Android Client Connected” é enviada para o servidor.
  • onMessage é chamado quando uma mensagem do servidor é recebida. Essa mensagem é do tipo String (e não um array de bytes) o que deixa a manipulação muito mais simples. Nesse momento, a lista de mensagens recebidas é atualizada, como está vindo do servidor, o valor false é atribuído seguido da mensagem.
  • onClosing será invocado quando o servidor ou o cliente iniciar o processo de desconexão indicando que nenhuma outra mensagem será transmitida.
  • onClose é invocado quando cliente e servidor indicaram que nenhuma outra mensagem será transmitida e a conexão foi liberada com sucesso. Nenhuma outra chamada para este listener será feita. Aqui é onde o atributo isConnected deve ser atualizado.
  • onFailure é chamado quando o WebSocket foi fechado devido a um erro de leitura ou gravação na rede. As mensagens enviadas e recebidas podem ter sido perdidas. Nenhuma outra chamada para este listener será feita.

No método connect, é onde a conexão é estabelecida. O objeto Request é criado passando a URL do servidor como parâmetro. Perceba que na URL é preciso o ID do cluster e a API Key. O ID do cluster possui também a região onde encontra-se o servidor (por exemplo, s9999.nyc1 — New York City 1).

Caso queira que o servidor notifique automaticamente o cliente, enviando de volta uma cópia da mensagem recebida, basta adicionar &notifySelf=1 ao final da URL de conexão.

Para criar a instância do webSocket é preciso do okHttpClient. A conexão é estabelecida ao invocar o método newWebSocket passando o objeto Request e o WebSocketListener.

O método disconnect encerra a conexão. O primeiro parâmetro é o código de status de fechamento da conexão conforme definido na RFC-6455. O valor 1000 indica um encerramento normal. O segundo parâmetro é a razão da desconexão (até 123 bytes). Aqui foi usada uma mensagem simples.

shutdown serve para encerrar o Executor atrelado ao objeto OkHttpClient.

Finalmente, em sendMessage, caso o socket esteja conectado, a mensagem é enviada pelo WebSocket e a lista de mensagens é atualizada.

View Model

O trabalho do MainViewModel listado a seguir é simplesmente intermediar a comunicação entre a Composable function (que será mostrada na próxima seção) e o MessageService que foi criada na seção anterior.

import androidx.lifecycle.ViewModel

class MainViewModel : ViewModel() {

    private val messageService = MessageService()

    val socketStatus = messageService.isConnected
    val messages = messageService.messages

    override fun onCleared() {
        super.onCleared()
        messageService.shutdown()
    }

    fun send(text: String) {
        messageService.sendMessage(text)
    }

    fun connect() {
        messageService.connect()
    }

    fun disconnect() {
        messageService.disconnect()
    }
}

Nada de especial aqui. Estamos apenas delegando as chamadas para o MessageService. Caso você esteja usando uma biblioteca de injeção de dependência (como Hilt ou Koin), você deve injetar esse serviço no construtor.

 

Composable UI

A implementação da UI será bem simples. Na AppBar será exibido o status da conexão (Conectado ou Desconectado) e um botão para Conectar e Desconectar do servidor. Na parte inferior teremos um painel para o usuário digitar e enviar a mensagem. E ocupando o restante da tela teremos a listagem de mensagens.

Começaremos pela função WebSocketChatScreen.

@Composable
fun WebSocketChatScreen(
    viewModel: MainViewModel = viewModel()
) {
    val status by viewModel.socketStatus.collectAsState(false)
    val messages by viewModel.messages.collectAsState(emptyList())

    Scaffold(
        topBar = {
            TopAppBar(
                isConnected = status,
                onConnect = viewModel::connect,
                onDisconnect = viewModel::disconnect
            )
        }
    ) {
        Column(Modifier.padding(it).fillMaxSize()) {
            LazyColumn(Modifier.weight(1f).fillMaxWidth()) {
                items(messages) { item ->
                    MessageItem(item = item)
                }
            }
            BottomPanel(onSend = viewModel::send)
        }
    }
}

Essa função instancia o MainViewModel e observa seus dois StateFlow (status e messages). No Scaffold é definida a topAppBar que está utilizando a função TopAppBar declarada a seguir:

@Composable
private fun TopAppBar(
    isConnected: Boolean,
    onConnect: () -> Unit,
    onDisconnect: () -> Unit,
) {
    val statusText = if (isConnected) "Connected" else "Disconnected"
    val contentDesc = if (!isConnected) "Connect" else "Disconnect"
    val buttonIcon = if (isConnected) Icons.Outlined.Close else Icons.Outlined.Check
    TopAppBar(
        title = { Text(statusText) },
        actions = {
            IconButton(onClick = {
                if (!isConnected) {
                    onConnect()
                } else {
                    onDisconnect()
                }
            }) {
                Icon(imageVector = buttonIcon, contentDescription = contentDesc)
            }
        },
    )
}

Na parte superior é exibido se o socket está conectado ou não. Além disso, o botão de conectar/desconectar fica nessa barra.

Voltando ao Scaffold , temos uma LazyColumn que exibe a listagem das mensagens. Cada mensagem é exibida pela função MessageItem. Essa função é bem simples: caso a mensagem tenha sido enviada pelo usuário, aparecerá “You: <mensagem>” , caso a mensagem tenha vindo do servidor, será exibido “Other: <mensagem>”.

@Composable
private fun MessageItem(item: Pair<Boolean, String>) {
    val (iAmTheSender, message) = item
    Text(
        text = "${if (iAmTheSender) "You: " else "Other: "} $message",
        modifier = Modifier.padding(8.dp)
    )
}

Por fim, o BottomPanel é a parte da interface onde o usuário digitará a mensagem a ser enviada.

@Composable
private fun BottomPanel(
    onSend: (String) -> Unit
) {
    var text by remember {
        mutableStateOf("")
    }
    Row(Modifier.padding(8.dp)) {
        OutlinedTextField(
            value = text,
            onValueChange = { s -> text = s },
            modifier = Modifier.weight(1f),
        )
        TextButton(
            onClick = {
                onSend(text)
                text = ""
            },
            enabled = text.isNotBlank(),
        ) {
            Text("Send")
        }
    }
}

Agora é preciso fazer dois ajustes no AndroidManifest.xml. Primeiro, adicionar a permissão de internet.

<uses-permission android:name="android.permission.INTERNET" />

Em seguida, redimensionar a activity quando o teclado for exibido.

<activity
    android:name=".MainActivity"
    android:windowSoftInputMode="adjustResize" <-- adicionar

Pronto! Basta executar a aplicação, conectar-se ao servidor e enviar mensagens. Para enviar mensagens a partir do servidor, basta clicar no botão “Test online”. A imagem a seguir mostra a aplicação sendo executada no emulador ao lado da página de teste do servidor web socket da PieSocket.

 

Conclusão

Realmente é muito simples utilizar Web Sockets no Android. Obviamente uma aplicação profissional requer uma complexidade muito maior, com um tratamento mais elaborado dos dados e um melhor gerenciamento de conexão. Mas o processo de conexão, envio e recebimento das mensagens é realmente muito simples.