Search code examples
androidandroid-jetpack-composeandroid-room

Sync issue between entity retrieved from room database and entity used in composable


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:

  1. I open the app and I have 2 users listed.
  2. I click on user1 and it shows me the UserView with default values
  3. I go back, I click user2 and it shows me the UserView with user1 values
  4. I go back, I click user1 again and it shows me the UserView with user2 values.
  5. I go back, I click user2 again and it shows me the UserView with user1 values.

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.


Solution

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