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.
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 Flamingo | 2022.2.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.
Esse artigo foi totalmente baseado no post "WebSockets in Android with OkHttp and ViewModel" escrito por Burak, a quem dou os devidos créditos.
4br4ç05,
nglauber