WebSockets, ViewModel e Jetpack Compose

Nelson Glauber
8 min readJun 29, 2023

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".

Criando um Channel Cluster no site PieSocket

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".

Criando um Channel Cluster no site PieSocket

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 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.

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.

Página de teste do Web Socket da PieSocket e o aplicativo Android desenvolvido nesse artigo

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

--

--

Nelson Glauber

Android Developer. Google Developer Expert for Android/Kotlin. Author of “Dominando o Android”.