Implementando debounce no Kotlin com Coroutines
Alex Felipe
Posted on June 28, 2023
Se você não tem ideia do que seja o debounce, trata-se de um design pattern com o objetivo de evitar múltiplas execuções em um curto período.
Os exemplos mais notáveis são em formulários, que pode realizar comportamentos inesperados em múltiplas ações, como por exemplo, clicar várias vezes no botão para fazer um cadastro, autenticação etc e resultar em um bug!
Com o uso do debounce, podemos evitar esses comportamentos com facilidade! E agora que tivemos a introdução, vamos partir para a implementação.
TL;DR
O objetivo deste artigo é chegar no seguinte resultado em um App de tarefas:
Criar apenas uma tarefa mesmo que ocorra múltiplos cliques. Se o seu objetivo é ver apenas a amostra de código, então vamos começar pela Activity:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
PlaygroundTheme {
Surface(
Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
val viewModel by viewModels<TasksListViewModel>()
val uiState by viewModel.uiState.collectAsState()
TasksListScreen(
uiState = uiState,
onCreateClick = {
viewModel.create()
})
}
}
}
}
}
Então o código do modelo, UI State e ViewModel:
data class Task(
val title: "String"
)
data class TasksListUiState(
val title: "String = \"\","
val onTitleChange: (String) -> Unit = {},
val tasks: List<Task> = emptyList()
)
class TasksListViewModel : ViewModel() {
private val _uiState = MutableStateFlow(TasksListUiState())
val uiState = _uiState.asStateFlow()
private var job: Job = Job()
init {
_uiState.update { currentState ->
currentState.copy(
onTitleChange = {
_uiState.value = _uiState.value.copy(
title = it
)
}
)
}
}
fun create() {
job.cancel()
job = viewModelScope.launch {
delay(3000)
_uiState.update { currentState ->
currentState.copy(
tasks = _uiState.value.tasks +
Task(title = _uiState.value.title)
)
}
}
}
}
E o código de tela:
@Composable
fun TasksListScreen(
uiState: TasksListUiState,
onCreateClick: () -> Unit = {}
) {
val tasks = uiState.tasks
Column {
TextField(
value = uiState.title,
onValueChange = uiState.onTitleChange,
Modifier
.padding(8.dp)
.fillMaxWidth(),
placeholder = {
Text("New task...")
}
)
Button(
onClick = onCreateClick,
Modifier
.padding(8.dp)
.fillMaxWidth()
) {
Text(text = "Create task")
}
if (tasks.isEmpty()) {
Box(modifier = Modifier.fillMaxSize()) {
Text(
text = "No tasks...",
Modifier.align(Alignment.Center),
fontSize = 18.sp,
style = TextStyle.Default.copy(
color = Color.Gray.copy(alpha = 0.5f)
)
)
}
} else {
LazyColumn(
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(tasks) { task ->
Box(
Modifier
.clip(RoundedCornerShape(20.dp))
.border(
1.dp,
color = Color.Gray.copy(
alpha = 0.5f
),
RoundedCornerShape(20.dp)
)
.padding(8.dp)
.fillMaxWidth()
) {
Text(text = task.title)
}
}
}
}
}
}
Agora, se a sua intenção é entender a motivação do código, continue lendo o artigo 😉
Coroutines do Kotlin para implementar o Debounce
Primeiro, é importante saber que vou considerar a biblioteca de Coroutine que dá suporte para códigos assíncronos no Kotlin.
Essa é uma ferramenta bastante comum em diversos códigos Kotlin, e também, oferece recursos que facilita a implementação do debounce.
App de exemplo do artigo
Como exemplo, vou usar um App com o Jetpack Compose para simular uma interação com o usuário, um criador de tarefas:
Para você chegar no mesmo resultado, segue o código de implementação:
- Código da tela:
@Composable
fun TasksListScreen(
uiState: TasksListUiState,
onCreateClick: () -> Unit = {}
) {
val tasks = uiState.tasks
Column {
TextField(
value = uiState.title,
onValueChange = uiState.onTitleChange,
Modifier
.padding(8.dp)
.fillMaxWidth(),
placeholder = {
Text("New task...")
}
)
Button(
onClick = onCreateClick,
Modifier
.padding(8.dp)
.fillMaxWidth()
) {
Text(text = "Create task")
}
if (tasks.isEmpty()) {
Box(modifier = Modifier.fillMaxSize()) {
Text(
text = "No tasks...",
Modifier.align(Alignment.Center),
fontSize = 18.sp,
style = TextStyle.Default.copy(
color = Color.Gray.copy(alpha = 0.5f)
)
)
}
} else {
LazyColumn(
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(tasks) { task ->
Box(
Modifier
.clip(RoundedCornerShape(20.dp))
.border(
1.dp,
color = Color.Gray.copy(
alpha = 0.5f
),
RoundedCornerShape(20.dp)
)
.padding(8.dp)
.fillMaxWidth()
) {
Text(text = task.title)
}
}
}
}
}
}
- Implementação do modelo, UI State e ViewModel:
data class Task(
val title: String
)
data class TasksListUiState(
val title: String = "",
val onTitleChange: (String) -> Unit = {},
val tasks: List<Task> = emptyList()
)
class TasksListViewModel : ViewModel() {
private val _uiState = MutableStateFlow(TasksListUiState())
val uiState = _uiState.asStateFlow()
init {
_uiState.update { currentState ->
currentState.copy(
onTitleChange = {
_uiState.value = _uiState.value.copy(
title = it
)
}
)
}
}
fun create() {
_uiState.update { currentState ->
currentState.copy(
tasks = _uiState.value.tasks +
Task(title = _uiState.value.title)
)
}
}
}
- Código da Activity que cria o ViewModel, usa o UI State e chama a tela:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
PlaygroundTheme {
Surface(
Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
val viewModel by viewModels<TasksListViewModel>()
val uiState by viewModel.uiState.collectAsState()
TasksListScreen(
uiState = uiState,
onCreateClick = {
viewModel.create()
})
}
}
}
}
}
É uma implementação de gerenciamento de estado com ViewModel e UI State. Agora que conhecemos o código, podemos seguir com o próximo passo que é introduzir cenários comuns que tornam essa solução um problema.
Situações problemáticas em Apps com delay
A situação mais comum é quando a criação da tarefa tende a demorar, como por exemplo, ao tentar enviar para uma API. Podemos fazer essa simulação a partir de um delay usando coroutines:
suspend fun create() {
delay(3000)
_uiState.update { currentState ->
currentState.copy(
tasks = _uiState.value.tasks +
Task(title = _uiState.value.title)
)
}
}
Observe que é um delay de 3 segundos! No código da tela, precisamos usar uma coroutine para chamar a suspend function:
...
val scope = rememberCoroutineScope()
TasksListScreen(
uiState = uiState,
onCreateClick = {
scope.launch {
viewModel.create()
}
})
Então, podemos testar o App e clicar mais de uma vez ao tentar criar uma tarefa:
Veja que após alguns segundos as tarefas vão aparecendo! E para evitar isso, podemos aplicar o debounce 😎
Implementando o debounce com o Job de Coroutine
A implementação do debounce baseada em coroutines é controlada pela referência Job
, logo, podemos criar essa referência como uma property do ViewModel e podemos aplicar algumas configurações:
class TasksListViewModel : ViewModel() {
private var job: Job = Job()
...
fun create() {
job.cancel()
job = viewModelScope.launch {
delay(3000)
_uiState.update { currentState ->
currentState.copy(
tasks = _uiState.value.tasks +
Task(title = _uiState.value.title)
)
}
}
}
}
Esta implementação pode parecer complexa, mas é mais simples do que parece! Então vamos entender cada etapa:
- Criamos um
Job
mutável para oViewModel
- Ao tentar criar uma tarefa, cancelamos o
job
que cancela todas as coroutines vinculadas a ele. - Ao rodar a coroutine, reatribuímos o
Job
a partir do retorno delaunch
- o
Job
do launch é vinculado a coroutine que cria a nova tarefa - Ao rodar novamente e cancelar o
job
com o valor novo, a coroutine anterior é cancelada e é criada uma nova seguindo o mesmo looping.
Veja que com esse looping o código só vai rodar caso não for cancelado durante o tempo de 3 segundos! Outro detalhe interessante desta implementação, é que quem chama o métode de criação do ViewModel, não precisa mais usar uma coroutine!
Agora é criada apenas uma tarefa! Mesmo que sejam vários cliques 😎
Para saber mais
Debounce para botões é um caso de uso, mas é possível aplicar em outras situações também! Como por exemplo, ao tentar preencher automaticamente campos de endereço a partir de um CEP:
Veja que ele só carrega após um tempo depois que para de escrever o CEP. Essa técnica é interessante, pois evita o consumo desnecessário de recursos do dispositivo, como processamento, internet etc.
Essa amostra foi uma implementação de um desafio sugerido no curso de Jetpack Compose: comunicação com REST API, que tal tentar fazê-lo?
Caso você não seja assinante da Alura e tenha interesse, eu posso te ajudar com esse cupom de desconto 😉
O que achou do debounce com coroutine? Você utiliza uma outra abordagem? Aproveite para compartilhar nos comentários.
Posted on June 28, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
January 26, 2024