I'm dabbling with Android development for fun. I have a very simple app that allows you to create and edit users. I'm storing these users in a Room database. The issue I'm having is that when I click on a user from my list of users it uses the UUID to fetch the user from the database, but it seems to always be out of sync in that it always shows me my previous selection, not my current one.
For example:
Here's the relevant parts of my code:
// Room code
@Entity(
tableName = "Users"
)
data class UserEntity(
@PrimaryKey
val userId: String,
@ColumnInfo
var userName: String,
)
@Dao
interface UserDataAccessObject {
@Query("SELECT * FROM Users")
fun getAll(): Flow<List<UserEntity>>
@Query("SELECT * FROM Users WHERE userId = :userId")
suspend fun get(userId: String): UserEntity
@Insert
suspend fun insert(user: UserEntity)
}
class UserRepository @Inject constructor(private val userDao: UserDataAccessObject) {
val allUsers: Flow<List<UserEntity>> = userDao.getAll()
@WorkerThread
suspend fun get(userId: String): UserEntity {
return userDao.get(userId)
}
@WorkerThread
suspend fun insert(user: UserEntity) {
userDao.insert(user)
}
}
@HiltViewModel
class UserViewModel @Inject constructor(private val repository: UserRepository): ViewModel() {
val allUsers: LiveData<List<UserEntity>> = repository.allUsers.asLiveData()
val user = MutableLiveData<UserEntity>()
fun get(userId: String) = viewModelScope.launch {
user.value = repository.get(userId)
}
fun insert(user: UserEntity) = viewModelScope.launch {
repository.insert(user)
}
}
// Compose code
@AndroidEntryPoint
class MainActivity: ComponentActivity() {
@ExperimentalMaterial3Api
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val navController = rememberNavController()
val userViewModel = hiltViewModel<userViewModel>()
AppTheme {
NavHost(navController = navController, startDestination = AppNavigation.Home.route) {
... // Omitted code
composable(
AppNavigation.User.route + "/{userId}",
arguments = listOf(navArgument("userId") {
type = NavType.StringType
})
) { backstackEntry ->
val userId = backstackEntry.arguments?.getString("userId")
UserSettingViewModelComponent(
navController = navController,
userViewModel = userViewModel,
userId = userId ?: UUID.randomUUID().toString()
)
}
}
}
}
}
}
@ExperimentalMaterial3Api
@Composable
fun UserSettingViewModelComponent(navController: NavHostController, userViewModel: UserViewModel, userId: String) {
userViewModel.get(userId) // The userId is correct and what is expected
var entity = userViewModel.user.observeAsState().value // This is always the previous selection
var isNew = false;
if (entity == null) {
isNew = true;
entity = UserEntity(userId, "Default User")
}
UserSettingViewModelComponent(navController = navController, userViewModel = userViewModel, userEntity = entity, isNew = isNew)
}
I think the issues is that the userViewModel.get(userId)
runs asynchronously, but I'd expect the composable to update witht he correct data when it retrieves it.
Any help would be greatly appreciated. There's cleary something I'm not understanding with how it should all work.
I think you are not using the observeAsState()
function correctly. Please try to update your code as follows:
var entity by userViewModel.user.observeAsState()
Besides that, your code seems to have some flaws and you probably should refactor it as a whole. The major problem I see is that entity
is also null
while it is still being loaded from the database. So your code will immediately assign a Default User
even though the ViewModel is not even finished loading.
Also, userViewModel.get()
will be called at each single recomposition.
I would suggest that you pass null
for the userId
if the user does not exist yet:
composable(
AppNavigation.User.route + "/{userId}",
arguments = listOf(navArgument("userId") {
type = NavType.StringType
})
) { backstackEntry ->
val userId = backstackEntry.arguments?.getString("userId")
UserSettingViewModelComponent(
navController = navController,
userViewModel = userViewModel,
userId = userId // pass null if a new user should be created
)
}
)
Then use it in your Composable like this and only get()
the user exactly once using a LaunchedEffect
:
@ExperimentalMaterial3Api
@Composable
fun UserSettingViewModelComponent(
navController: NavHostController,
userViewModel: UserViewModel,
userId: String?
) {
if (userId == null) {
UserSettingViewModelComponent(
navController = navController,
userViewModel = userViewModel,
userEntity = UserEntity(userId, "Default User"),
isNew = true
)
return
}
LaunchedEffect(Unit) { // passing Unit means it will be executed only once
userViewModel.get(userId) // call suspend function here
}
val entity = userViewModel.user
if (entity == null) {
CircularLoadingIndicator()
} else {
UserSettingViewModelComponent(
navController = navController,
userViewModel = userViewModel,
userEntity = entity,
isNew = false
)
}
}
You can update your ViewModel as follows:
@HiltViewModel
class UserViewModel @Inject constructor(private val repository: UserRepository): ViewModel() {
val allUsers: LiveData<List<UserEntity>> = repository.allUsers.asLiveData()
var user by mutableStateOf<UserEntity?>(null)
// We can make this a suspend function as we will be calling
// it from a LaunchedEffect block
suspend fun get(userId: String) {
user = null
try {
user = repository.get(userId)
} catch(EmptyResultSetException e) {
Log.d("User with ID $userId was not found")
}
}
fun insert(user: UserEntity) = viewModelScope.launch {
repository.insert(user)
}
}