Reputation: 153
I'm writing an app with Jetpack Compose that lets the user input text in some TextFields
and check a few radio buttons.
This data is then stored in a Room database.
Currently, I have a "save" button at the bottom of the screen, with a "Leave without saving?" popup.
However, I'd like to get rid of the save button entirely and have it autosave data as it's being typed.
Would the repeated DB queries from typing cause any performance issues? And are there any established best practices for this sort of thing?
Upvotes: 8
Views: 1674
Reputation: 897
I group all the fields in a data class
instead of having separate fields.
@Entity
data class Counter(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
val title: String = "",
val count: Double = 0.0,
) // for Room
Then, I use a debounce of 300ms.
@OptIn(FlowPreview::class)
@HiltViewModel
class AddEditViewModel @Inject constructor(
private val useCases: CounterUseCases,
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
private val _counter = MutableStateFlow(Counter())
val counter = _counter.asStateFlow()
private val _uiEventFlow = MutableSharedFlow<UiEvent>()
val uiEventFlow = _uiEventFlow.asSharedFlow()
init {
viewModelScope.launch {
// find counter
val id = savedStateHandle.get<Int>("id")
if (id != null && id != -1) {
useCases.getCounter(id)?.let {
// found
_counter.value = it
}
}
autoSave()
}
}
private suspend fun autoSave() {
counter
.drop(1) // avoid saving initial value
.debounce(300) // wait until nothing changed for 300ms
.collectLatest {// cancels previous block if it took too long to process the new/latest value
val id = useCases.insertCounter(it)
Log.d("SAVE", "$it $id")
// for new counters, the id is 0, so each save will create a new counter in the database
if (counter.value.id != id){
_counter.value = counter.value.copy(id = id) // will trigger another save, not sure how to avoid it
// change navigation argument from the default -1 to an actual id
savedStateHandle["id"] = id
}
}
}
fun handleEvent(event: AddEditEvent) {
when (event) {
is AddEditEvent.TitleChange -> {
_counter.value = counter.value.copy(title = event.value)
}
}
}
sealed class UiEvent {
data class ShowSnackbar(val message: String) : UiEvent()
object GoBack : UiEvent()
}
}
It is a little trickier to automatically save a new item. You might need to change the navigation arguments so that the id of the newly created item is used. With Room, to get the auto generated id, return Long
:
@Dao
interface CounterDao {
@Query("SELECT * FROM counter")
fun getCounters(): Flow<List<Counter>>
@Query("SELECT * FROM counter WHERE id = :id")
suspend fun getCounterById(id: Int): Counter?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertCounter(counter: Counter): Long
}
If you have auto generated fields such as createdAt
and updatedAt
, getCounterById
would need to return a Flow
.
Edit:
If the user navigates back, the coroutine has an opportunity to be cancelled due to debounce
. In onCleared
, viewModelScope
doesn't work so I use GlobalScope
.
override fun onCleared() {
super.onCleared()
GlobalScope.launch {
useCases.insertCounter(counter.value)
}
}
Upvotes: 0
Reputation: 88202
With kotlin flow you can use debounce
, which is designed specifically for such cases. That way, as long as the user enters text, saveToDatabase
will not be called, and when he does not enter a character for some time (in my example it is one second) - the flow will be emitted.
Also during Compose Navigation the view model may be destroyed (and the coroutine will be cancelled) if the screen is closed, in that case I also save the data inside onCleared
to make sure that nothing is missing.
class ScreenViewModel: ViewModel() {
private val _text = MutableStateFlow("")
val text: StateFlow<String> = _text
init {
viewModelScope.launch {
@OptIn(FlowPreview::class)
_text.debounce(1000)
.collect(::saveToDatabase)
}
}
fun updateText(text: String) {
_text.value = text
}
override fun onCleared() {
super.onCleared()
saveToDatabase(_text.value)
}
private fun saveToDatabase(text: String) {
}
}
@Composable
fun ScreenView(
viewModel: ScreenViewModel = viewModel()
) {
val text by viewModel.text.collectAsState()
TextField(value = text, onValueChange = viewModel::updateText)
}
@OptIn(FlowPreview::class)
means that the API may be changed in the future. If you don't want to use it now, see the replacement here.
Upvotes: 13