Implementando debounce no Kotlin com Coroutines
Tags: kotlin, android, jetpackcompose
Published: 2023-06-28 11:00:00

Se você não tem ideia do que seja o [debounce](https://www.techtarget.com/whatis/definition/debouncing#:~:text=Debouncing%20is%20removing%20unwanted%20input,hardware%20switches%2C%20programs%20and%20websites.), 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/93zfw92l904pfe03o7e0.gif)

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:

```kotlin
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:

```kotlin
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:

```kotlin
@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](https://kotlinlang.org/docs/coroutines-overview.html) 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.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/x2fgguu8b38j82a8lbsd.gif)

Para você chegar no mesmo resultado, segue o código de implementação:

- Código da tela:

```kotlin
@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:

```kotlin
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:

```kotlin
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:

```kotlin
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:

```kotlin
...
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:

![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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ni3ew5gzn9u5fjmkjtys.gif)

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`](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-job/), logo, podemos criar essa referência como uma property do ViewModel e podemos aplicar algumas configurações:

```kotlin
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 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/93zfw92l904pfe03o7e0.gif)

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](https://user-images.githubusercontent.com/8989346/232496380-398a1032-5300-4913-8b87-6d6927556c25.gif)

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](https://www.alura.com.br/curso-online-jetpack-compose-comunicacao-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](https://www.alura.com.br/promocao/alexfelipe) 😉

O que achou do debounce com coroutine? Você utiliza uma outra abordagem? Aproveite para compartilhar nos comentários.