Implementando debounce no Kotlin com Coroutines

alexfelipe

Alex Felipe

Posted on June 28, 2023

Implementando debounce no Kotlin com Coroutines

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:

App em execução, ao tentar criar várias tarefas com múltiplos cliques, só é criada apenas uma depois de 3 segundos

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()
                        })
                }
            }
        }
    }

}
Enter fullscreen mode Exit fullscreen mode

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)
                )
            }
        }

    }
}
Enter fullscreen mode Exit fullscreen mode

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)
                    }
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

App em execução apresentando um campo de texto com um placeholder escrito 'New task...' um botão com o texto 'Create task' em coluna. Ao clicar no botão, é adicionada uma nova tarefa abaixo em uma lista 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)
                    }
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
  • 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)
            )
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
  • 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()
                        })
                }
            }
        }
    }

}
Enter fullscreen mode Exit fullscreen mode

É 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)
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
        }
    })
Enter fullscreen mode Exit fullscreen mode

Então, podemos testar o App e clicar mais de uma vez ao tentar criar uma tarefa:

App em execução, adicionando um título para a tarefa e clicando múltiplas vezes no botão para criar tarefas. Após alguns segundos, vai aparecendo cada tarefa aos poucos

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)
                )
            }
        }
    }

}
Enter fullscreen mode Exit fullscreen mode

Esta implementação pode parecer complexa, mas é mais simples do que parece! Então vamos entender cada etapa:

  • Criamos um Job mutável para o ViewModel
  • 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 de launch
  • 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!

App em execução, ao tentar criar várias tarefas com múltiplos cliques, só é criada apenas uma depois de 3 segundos

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:

App em execução exibindo o formulário com os campos para endereço, ao preencher o campo CEP, após 2 segundos, automaticamente o campo de logradouro, bairro, cidade e estado são preenchidos automaticamente

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.

💖 💪 🙅 🚩
alexfelipe
Alex Felipe

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