To give some background to this question, I have a ViewModel that waits for some data, posts it to a MutableLiveData, and then exposes all the values through some properties. Here's a short gist of what that looks like:
class QuestionViewModel {
private val state = MutableLiveData<QuestionState>()
private val currentQuestion: Question?
get() = (state.value as? QuestionState.Loaded)?.question
val questionTitle: String
get() = currentQuestion?.title.orEmpty()
...
}
Then, in my test, I mock the data and just run an assertEquals check:
assertEquals("TestTitle", viewModel.questionTitle)
All of this works fine so far, but I actually want my fragment to observe for when the current question changes. So, I tried changing it around to use Transformations.map:
class QuestionViewModel {
private val state = MutableLiveData<QuestionState>()
private val currentQuestion: LiveData<Question> = Transformations.map(state) {
(it as? QuestionState.Loaded)?.question
}
val questionTitle: String
get() = currentQuestion.value?.title.orEmpty()
...
}
Suddenly, all of my assertions in the test class have failed. I made currentQuestion public and verified that it's value is null in my unit test. I've determined this is the issue because:
I can mock the data and still get the right value from my state LiveData
I can run my app and see the expected data on the screen, so this issue is specific to my unit test.
I have already added the InstantTaskExecutorRule to my unit test, but maybe that doesn't handle the Transformations methods?
I recently had the same problem, I've solved it by adding a mocked observer to the LiveData:
#Mock
private lateinit var observer: Observer<Question>
init {
initMocks(this)
}
fun `test using mocked observer`() {
viewModel.currentQuestion.observeForever(observer)
// ***************** Access currentQuestion.value here *****************
viewModel.questionTitle.removeObserver(observer)
}
fun `test using empty observer`() {
viewModel.currentQuestion.observeForever {}
// ***************** Access currentQuestion.value here *****************
}
Not sure how it works exactly or the consequences of not removing the empty observer the after test.
Also, make sure to import the right Observer class. If you're using AndroidX:
import androidx.lifecycle.Observer
Luciano is correct, it's because the LiveData is not being observed. Here is a Kotlin utility class to help with this.
class LiveDataObserver<T>(private val liveData: LiveData<T>): Closeable {
private val observer: Observer<T> = mock()
init {
liveData.observeForever(observer)
}
override fun close() {
liveData.removeObserver(observer)
}
}
// to use:
LiveDataObserver(unit.someLiveData).use {
assertFalse(unit.someLiveData.value!!)
}
Looks like you're missing the .value on the it variable.
private val currentQuestion: LiveData<Question> = Transformations.map(state) {
(it.value as? QuestionState.Loaded)?.question
}
Related
I started building my app using Room, Flow, LiveData and Coroutines, and have come across something odd: what I'm expecting to be a value flow actually has one null item in it.
My setup is as follows:
#Dao
interface BookDao {
#Query("SELECT * FROM books WHERE id = :id")
fun getBook(id: Long): Flow<Book>
}
#Singleton
class BookRepository #Inject constructor(
private val bookDao: BookDao
) {
fun getBook(id: Long) = bookDao.getBook(id).filterNotNull()
}
#HiltViewModel
class BookDetailViewModel #Inject internal constructor(
savedStateHandle: SavedStateHandle,
private val bookRepository: BookRepository,
private val chapterRepository: ChapterRepository,
) : ViewModel() {
val bookID: Long = savedStateHandle.get<Long>(BOOK_ID_SAVED_STATE_KEY)!!
val book = bookRepository.getBook(bookID).asLiveData()
fun getChapters(): LiveData<PagingData<Chapter>> {
val lastChapterID = book.value.let { book ->
book?.lastChapterID ?: 0L
}
val chapters = chapterRepository.getChapters(bookID, lastChapterID)
return chapters.asLiveData()
}
companion object {
private const val BOOK_ID_SAVED_STATE_KEY = "bookID"
}
}
#AndroidEntryPoint
class BookDetailFragment : Fragment() {
private var queryJob: Job? = null
private val viewModel: BookDetailViewModel by viewModels()
override fun onResume() {
super.onResume()
load()
}
private fun load() {
queryJob?.cancel()
queryJob = lifecycleScope.launch() {
val bookName = viewModel.book.value.let { book ->
book?.name
}
binding.toolbar.title = bookName
Log.i(TAG, "value: $bookName")
}
viewModel.book.observe(viewLifecycleOwner) { book ->
binding.toolbar.title = book.name
Log.i(TAG, "observe: ${book.name}")
}
}
}
Then I get a null value in lifecycleScope.launch while observe(viewLifecycleOwner) gets a normal value.
I think it might be because of sync and async issues, but I don't know the exact reason, and how can I use LiveData<T>.value to get the value?
Because I want to use it in BookDetailViewModel.getChapters method.
APPEND: In the best practice example of Android Jetpack (Sunflower), LiveData.value (createShareIntent method of PlantDetailFragment) works fine.
APPEND 2: The getChapters method returns a paged data (Flow<PagingData<Chapter>>). If the book triggers an update, it will cause the page to be refreshed again, confusing the UI logic.
APPEND 3: I found that when I bind BookDetailViewModel with DataBinding, BookDetailViewModel.book works fine and can get book.value.
LiveData.value has extremely limited usefulness because you might be reading it when no value is available yet.
You’re checking the value of your LiveData before it’s source Flow can emit its first value, and the initial value of a LiveData before it emits anything is null.
If you want getChapters to be based on the book LiveData, you should do a transformation on the book LiveData. This creates a LiveData that under the hood observes the other LiveData and uses that to determine what it publishes. In this case, since the return value is another LiveData, switchMap is appropriate. Then if the source book Flow emits another version of the book, the LiveData previously retrieved from getChapters will continue to emit, but it will be emitting values that are up to date with the current book.
fun getChapters(): LiveData<PagingData<Chapter>> =
Transformations.switchMap(book) { book ->
val lastChapterID = book.lastChapterID
val chapters = chapterRepository.getChapters(bookID, lastChapterID)
chapters.asLiveData()
}
Based on your comment, you can call take(1) on the Flow so it will not change the LiveData book value when the repo changes.
val book = bookRepository.getBook(bookID).take(1).asLiveData()
But maybe you want the Book in that LiveData to be able to be changed when the repo changes, and what you want is that the Chapters LiveData retrieved previously does not change? So you need to manually get it again if you want it to be based on the latest Book? If that's the case, you don't want to be using take(1) there which would prevent the book from appearing updated in the book LiveData.
I would personally in that case use a SharedFlow instead of LiveData, so you could avoid retrieving the values twice, but since you're currently working with LiveData, here's a possible solution that doesn't require you to learn those yet. You could use a temporary Flow of your LiveData to easily get its current or first value, and then use that in a liveData builder function in the getChapters() function.
fun getChapters(): LiveData<PagingData<Chapter>> = liveData {
val singleBook = book.asFlow().first()
val lastChapterID = singleBook.lastChapterID
val chapters = chapterRepository.getChapters(bookID, lastChapterID)
emitSource(chapters)
}
I have problem working with MutableStateFlow, I cannot understand how it is working or I am mistaken somewhere. For example purpose I created simpler classes to get the idea what I am doing.
First I have data class which holds the values and controller which update values in the data class
data class ExampleUiState(
val dataFlag: Boolean = false
)
class ExampleController {
private val _exampleUiState = MutableStateFlow(ExampleUiState())
val exampleUiState = _exampleUiState.asStateFlow()
fun onChangeFlag(flag: Boolean) {
_exampleUiState.update { it.copy(dataFlag = flag) }
}
}
I am using koin, and I created Example controller singleton.
Second I am injection it in my ViewModel where I have two functions there
class ExampleViewModel(
private val exampleController: ExampleController
) : ViewModel() {
val exampleUiState = exampleController.exampleUiState.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5000),
ExampleUiState()
)
//called second
private fun useFlagInViewModelFun() {
//here the value is not updated
exampleUiState.value.dataFlag
}
//called first from UI
fun changeValueFromUi(flag: Boolean) {
//change it from default false to true
exampleController.onChangeFlag(flag)
useFlagInViewModelFun()
}
}
The idea is when I call changeValueFromUi from some compose function, I update the value with my controller function, and after it I call other function where I want to use already updated state of data class, but I don't get the correct value.
Where I am mistaken?
Is there any time needed for onChangeFlag() to react and update the value?
Am I mistaken the way that I am trying to get the value after exampleUiState.value.dataFlag ?
Am learning android kotlin follow this:
https://developer.android.com/topic/libraries/architecture/viewmodel#kotlin
class MyViewModel : ViewModel() {
private val users: MutableLiveData<List<User>> by lazy {
MutableLiveData<List<User>>().also {
loadUsers(it)
}
}
fun getUsers(): LiveData<List<User>> {
return users
}
private fun loadUsers() {
// Do an asynchronous operation to fetch users.
}
}
Dont know how to write the fun loadUsers()
Here is my User:
class User {
constructor(name: String?) {
this.name = name
}
var name:String? = null
}
If dont use the keyword 'also' , i know how to do it.
But if use 'also' , it seems not work.
Here is how i try to write the fun loadUsers:
private fun loadUsers( it: MutableLiveData<List<User>>){
val users: MutableList<User> = ArrayList()
for (i in 0..9) {
users.add(User("name$i"))
}
it = MutableLiveData<List<User>>(users)
}
Error tips near it : Val cant be ressigned
Part 1: According to the Kotlin documentation, also provides the object in question to the function block as a this parameter. So, every function call and property object you access is implied to refer to your MutableLiveData<List<User>>() object. also returns this from the function block when you are done.
Thus, another way of writing your MutableLiveData<> would be like this:
val users = MutableLiveData<List<User>>()
users.loadUsers()
Part 2: As far as how to implement loadUsers(), that is a separate issue (your question is not clear). You can use Retrofit + RxJava to load the data asynchronously, and that operation is totally outside of the realm of ViewModel or also.
Part 3: With your approach, you have conflicting things going on. Instead of doing a loadUsers() from your lazy {} operation, I would remove your lazy {} operation and create a MutableLiveData<> directly. Then, you can load users later on and update the users property any time new data is loaded. Here is a similar example I worked on a while ago. It uses state flows, but the idea is similar. Also use a data class to model the User instead of a regular class. Another example.
It is solved change to code:
private fun loadUsers( it: MutableLiveData<List<User>>){
val users: MutableList<User> = ArrayList()
for (i in 0..9) {
users.add(User("name$i"))
}
it.value = users
}
it can't be reassigned , but it.value could .
I have a huge understanding problem here, I have a ecommerce app and I cannot properly calculate value of users cart.
The problem is, my solution works well to the point but I have an issue when there are no products in the cart. Obviously LiveData observer or switchMap will not get executed when it's value is empty.
It seems like something trivial, only thing I want to do here is handle the situation when user have no products in the cart. Is the livedata and switchMap a wrong approach here?
I get userCart from the repo -> I calculate its value in the viewModel and expose it to the view with dataBinding.
#HiltViewModel
class CartFragmentViewModel
#Inject
constructor(
private val repository: ProductRepository,
private val userRepository: UserRepository,
private val priceFormatter: PriceFormatter
) : ViewModel() {
private val user = userRepository.currentUser
val userCart = user.switchMap {
repository.getProductsFromCart(it.cart)
}
val cartValue = userCart.switchMap {
calculateCartValue(it)
}
private fun calculateCartValue(list: List<Product>?): LiveData<String> {
val cartVal = MutableLiveData<String>()
var cartValue = 0L
list?.let { prods ->
prods.forEach {
cartValue += it.price
}
cartVal.postValue(priceFormatter.formatPrice(cartValue))
} ?: cartVal.postValue(priceFormatter.formatPrice(0))
return cartVal
}
fun removeFromCart(product: Product) {
userRepository.removeFromCart(product)
getUserData()
}
private fun getUserData() {
userRepository.getUserData()
}
init {
getUserData()
}
}
Default value is to solve the "initial" empty cart.
Now if you need to trigger it when there's no data... (aka: after you remove items and the list is now empty), I'd use a sealed class to wrap the actual value.
(names and code are pseudo-code, so please don't copy-paste)
Something like this:
Your Repository should expose the cart, user, etc. wrapped in a sealed class:
sealed class UserCartState {
object Empty : UserCartState()
data class HasItems(items: List<things>)
object Error(t: Throwable) :UserCartState() //hypotetical state to signal problems
}
In your CartFragmentViewModel, you observe and use when (for example), to determine what did the repo responded with.
repo.cartState.observe(...) {
when (state) {
is Empty -> //deal with it
is HasItems -> // do what it takes to convert it, calculate it, etc.
is Error -> // handle it
}
}
When the user removes the last item in the cart, your repo should emit Empty.
The VM doesn't care how that happened, it simply reacts to the new state.
The UI cares even less. :)
You get the idea (I hope).
That's how I would look into it.
You can even use a flow of cart items, or the new "FlowState" thingy (see the latest Google I/O 21) to conserve resources when the lifecycle owner is not ready.
I suppose that this part of code creates the problem
list?.let { prods ->
prods.forEach {
cartValue += it.price
}
cartVal.postValue(priceFormatter.formatPrice(cartValue))
} ?: cartVal.postValue(priceFormatter.formatPrice(0))
Probably, list is not null but is empty. Please try this:
if (list.isNullOrEmpty) {
list.forEach {
cartValue += it.price
}
cartVal.postValue(priceFormatter.formatPrice(cartValue))
} else {
cartVal.postValue(priceFormatter.formatPrice(0))
}
I'm trying to combine three different flows in my ViewModel to make a list of items that will then be displayed on a RecyclerView in a fragment. I found out that when navigating to the screen, when there is no data in the table yet, the flow for testData1 doesn't emit the data in the table. Happens probably 1/5 of the time. I assume it's a timing issue because it only happens so often, but I don't quite understand why it happens. Also, this only happens when I'm combining flows so maybe I can only have so many flows in one ViewModel?
I added some code to check to see if the data was in the table during setListData() and it's definitely there. I can also see the emit happening but, there is no data coming from room. Any guidance would be greatly appreciated!
Versions I'm using:
Kotlin: 1.4.20-RC
Room: 2.3.0-alpha03
Here is my ViewModel
class DemoViewModel #Inject constructor(
demoService: DemoService,
private val demoRepository: DemoRepository
) : ViewModel() {
private val _testData1 = demoRepository.getData1AsFlow()
private val _testData2 = demoRepository.getData2AsFlow()
private val _testData3 = demoRepository.getData3AsFlow()
override val mainList = combine(_testData1, _testData2, _testData3) { testData1, testData2, testData3 ->
setListData(testData1, testData2, testData3)
}.flowOn(Dispatchers.Default)
.asLiveData()
init {
viewModelScope.launch(Dispatchers.IO) {
demoService.getData()
}
}
private suspend fun setListData(testData1: List<DemoData1>, testData2: List<DemoData2>, testData3: List<DemoData3>): List<CombinedData> {
// package the three data elements up to one list of rows
...
}
}
And here is my Repository/DAO layer (repeats for each type of data)
#Query("SELECT * FROM demo_data_1_table")
abstract fun getData1AsFlow() : Flow<List<DemoData1>>
I was able to get around this issue by removing flowOn in the combine function. After removing that call, I no longer had the issue.
I still wanted to run the setListData function on the default dispatcher, so I just changed the context in the setListData instead.
class DemoViewModel #Inject constructor(
demoService: DemoService,
private val demoRepository: DemoRepository
) : ViewModel() {
private val _testData1 = demoRepository.getData1AsFlow()
private val _testData2 = demoRepository.getData2AsFlow()
private val _testData3 = demoRepository.getData3AsFlow()
override val mainList = combine(_testData1, _testData2, _testData3) { testData1, testData2, testData3 ->
setListData(testData1, testData2, testData3)
}.asLiveData()
init {
viewModelScope.launch(Dispatchers.IO) {
demoService.getData()
}
}
private suspend fun setListData(testData1: List<DemoData1>, testData2: List<DemoData2>, testData3: List<DemoData3>): List<CombinedData> = withContext(Dispatchers.Default) {
// package the three data elements up to one list of rows
...
}
}