Search code examples
androidkotlinrepositoryandroid-roomandroid-viewmodel

ViewModel getSingle(id) returns null and causes Logcat to return error


I have an App using latest dependencies and Gradle 8.3 with Kotlin DSL. The ViewModel is working fine to retrieve all data from the Room local SQLite Database. But doesn't work for retrieving single Recipe by id. The App compiles without errors. Issue found using Logcat.

Here is the code of different Kotlin files associated with this project.

RecipeDatabase.kt

@Database(
entities = [RecipeModel::class],
version = Constants.version,
exportSchema = false
)
abstract class RecipeDatabase: RoomDatabase() {

    abstract fun recipeDao(): RecipeDAO

    companion object{

        @Volatile
        private var INSTANCE: RecipeDatabase? = null

        fun getDatabase(
            context: Context
        ): RecipeDatabase{
            val tempInstance = INSTANCE
            if(tempInstance != null){
                return tempInstance
            }
            synchronized(this) {
                val instance = Room.databaseBuilder(context, RecipeDatabase::class.java, Constants.database)
                    .createFromAsset(Constants.databaseLocation)
                    .fallbackToDestructiveMigration()
                    .build()
                INSTANCE = instance
                return instance
            }
        }
    }
}

RecipeDAO.kt

@Dao
interface RecipeDAO {

    @Query("SELECT * FROM recipes ORDER BY id ASC")
    fun readAllData(): LiveData<List<RecipeModel>> // THIS WORKS

    @Query("SELECT * FROM recipes WHERE id=:id")
    fun loadSingle(id: String): LiveData<RecipeModel> // THIS DOESNT

}

RecipeRepository.kt

class RecipeRepository(private val recipeDAO: RecipeDAO) {

val readAllData: LiveData<List<RecipeModel>> = recipeDAO.readAllData() // THIS WORKS

fun loadSingle(recipeId: String): LiveData<RecipeModel> {
    return recipeDAO.loadSingle(recipeId)
}

}

RecipeViewModel

 class RecipeViewModel(application: Application): AndroidViewModel(application) {

    val readAllData: LiveData<List<RecipeModel>>
    private val repository: RecipeRepository
    lateinit var eventSingle: LiveData<RecipeModel>

    init {
        val recipeDAO = RecipeDatabase.getDatabase(application).recipeDao()
        repository = RecipeRepository(recipeDAO)
        readAllData = repository.readAllData // THIS WORKS
    }

    fun getSingle(recipeId: String) { // THIS DOESNT
        eventSingle = repository.loadSingle(recipeId)
    }

}

Composable RecipeScreen.kt

@Composable
fun RecipeScreen(navController: NavHostController, recipeViewModel: RecipeViewModel, recipeID: Int) {
    println("RECIPE ID" + recipeID.toString()) // THIS WORKS
    val recipeAvailable = recipeViewModel.getSingle(recipeID.toString()) // THIS DOESNT
    val available by recipeViewModel.eventSingle.observeAsState() // THIS DOESNT
    println(available) // <-- null
} 

NavHost composable

composable(route = Recipe.route) {
            backStackEntry ->
        val recipeID = backStackEntry.arguments?.getString("recipeID")
        if (recipeID != "") {
            RecipeScreen(navController, recipeViewModel, recipeID!!.toInt())
        }
    }

The problem was caused by this code (how can I replace the Image composable so it waits even if it has drawable set to null at first?) :

@Composable
fun Hero(recipePreview: RecipeModel) {
val context = LocalContext.current
val imageName = recipePreview.image.replace(".jpg", "")
val drawableId = remember(imageName) {
    context.resources.getIdentifier(
        imageName,
        "drawable",
        context.packageName
    )
}
Box(Modifier.height(280.dp)) {
    Image(
        painter = painterResource(id = drawableId),
        contentDescription = recipePreview.title,
        modifier = Modifier
            .fillMaxWidth()
            .fillMaxHeight(),
        contentScale = ContentScale.FillBounds,
    )
}
}

When I remove Image the rest of the Recipe appears. So only the Image is the problem, because at first I have null.

Please help. Thanks in advance.


Solution

  • This might not be the only issue, but combining var with LiveData is going to cause problems. The UI layer begins observing the first LiveData. When you change the property to point at a different LiveData, the UI layer doesn't know and just keeps observing the first LiveData.

    To solve this, you can create a backing LiveData of the requested IDs, and switchMap to produce the final map of single database rows that you want.

    Updated ViewModel code:

    class RecipeViewModel(application: Application): AndroidViewModel(application) {
    
        val readAllData: LiveData<List<RecipeModel>>
        private val repository: RecipeRepository
        private val eventSingleId = MutableLiveData<String>()
        val eventSingle: LiveData<RecipeModel> =
            eventSingleId.switchMap { repository.loadSingle(it) }
    
        init {
            val recipeDAO = RecipeDatabase.getDatabase(application).recipeDao()
            repository = RecipeRepository(recipeDAO)
            readAllData = repository.readAllData
        }
    
        fun getSingle(recipeId: String) {
            eventSingleId.value = recipeId
        }
    
    }
    

    By the way, your singleton access of the database is vulnerable to instantiating two databases. It needs to be a double-checked lock. Update your code to do a second check for it having been instantiated inside the synchronized block:

    fun getDatabase(
        context: Context
    ): RecipeDatabase{
        var instance = INSTANCE
        if(instance != null){
            return tempInstance
        }
        synchronized(this) {
            instance = INSTANCE
            if(instance != null){
                return instance
            }
            instance = Room.databaseBuilder(context, RecipeDatabase::class.java, Constants.database)
                .createFromAsset(Constants.databaseLocation)
                .fallbackToDestructiveMigration()
                .build()
            INSTANCE = instance
            return instance
        }
    }
    

    Or to simplify the code using Elvis operators and also:

    fun getDatabase(
        context: Context
    ): RecipeDatabase =
        INSTANCE ?: synchronized(this) {
            INSTANCE ?: Room.databaseBuilder(context, RecipeDatabase::class.java, Constants.database)
                .createFromAsset(Constants.databaseLocation)
                .fallbackToDestructiveMigration()
                .build()
                .also { INSTANCE = it }
        }