Usando WebSockets, ViewModel e Jetpack Compose
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.
Conteudo
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 propriedadeisConnected
é atualizado e a mensagem “Android Client Connected” é enviada para o servidor.onMessage
é chamado quando uma mensagem do servidor é recebida. Essa mensagem é do tipoString
(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 valorfalse
é 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 atributoisConnected
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
¬ifySelf=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.
O 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.