So I use kotlin for android, and when inflating views, I tend to do the following:
private val recyclerView by lazy { find<RecyclerView>(R.id.recyclerView) }
This method will work. However, there is a case in which it will bug the app. If this is a fragment, and the fragment goes to the backstack, onCreateView will be called again, and the view hierarchy of the fragment will recreated. Which means, the lazy initiated recyclerView will point out to an old view no longer existent.
A solution is like this:
private lateinit var recyclerView: RecyclerView
And initialise all the properties inside onCreateView.
My question is, is there any way to reset lazy properties so they can be initialised again? I like the fact initialisations are all done at the top of a class, helps to keep the code organised. The specific problem is found in this question: kotlin android fragment empty recycler view after back
Here is a quick version of a resettable lazy, it could be more elegant and needs double checked for thread safety, but this is basically the idea. You need something to manage (keep track) of the lazy delegates so you can call for reset, and then things that can be managed and reset. This wraps lazy() in these management classes.
Here is what your final class will look like, as an example:
class Something {
val lazyMgr = resettableManager()
val prop1: String by resettableLazy(lazyMgr) { ... }
val prop2: String by resettableLazy(lazyMgr) { ... }
val prop3: String by resettableLazy(lazyMgr) { ... }
}
Then to make the lazy's all go back to new values on next time they are accessed:
lazyMgr.reset() // prop1, prop2, and prop3 all will do new lazy values on next access
The implementation of the resettable lazy:
class ResettableLazyManager {
// we synchronize to make sure the timing of a reset() call and new inits do not collide
val managedDelegates = LinkedList<Resettable>()
fun register(managed: Resettable) {
synchronized (managedDelegates) {
managedDelegates.add(managed)
}
}
fun reset() {
synchronized (managedDelegates) {
managedDelegates.forEach { it.reset() }
managedDelegates.clear()
}
}
}
interface Resettable {
fun reset()
}
class ResettableLazy<PROPTYPE>(val manager: ResettableLazyManager, val init: ()->PROPTYPE): Resettable {
#Volatile var lazyHolder = makeInitBlock()
operator fun getValue(thisRef: Any?, property: KProperty<*>): PROPTYPE {
return lazyHolder.value
}
override fun reset() {
lazyHolder = makeInitBlock()
}
fun makeInitBlock(): Lazy<PROPTYPE> {
return lazy {
manager.register(this)
init()
}
}
}
fun <PROPTYPE> resettableLazy(manager: ResettableLazyManager, init: ()->PROPTYPE): ResettableLazy<PROPTYPE> {
return ResettableLazy(manager, init)
}
fun resettableManager(): ResettableLazyManager = ResettableLazyManager()
And some unit tests to be sure:
class Tester {
#Test fun testResetableLazy() {
class Something {
var seed = 1
val lazyMgr = resettableManager()
val x: String by resettableLazy(lazyMgr) { "x ${seed}" }
val y: String by resettableLazy(lazyMgr) { "y ${seed}" }
val z: String by resettableLazy(lazyMgr) { "z $x $y"}
}
val s = Something()
val x1 = s.x
val y1 = s.y
val z1 = s.z
assertEquals(x1, s.x)
assertEquals(y1, s.y)
assertEquals(z1, s.z)
s.seed++ // without reset nothing should change
assertTrue(x1 === s.x)
assertTrue(y1 === s.y)
assertTrue(z1 === s.z)
s.lazyMgr.reset()
s.seed++ // because of reset the values should change
val x2 = s.x
val y2 = s.y
val z2 = s.z
assertEquals(x2, s.x)
assertEquals(y2, s.y)
assertEquals(z2, s.z)
assertNotEquals(x1, x2)
assertNotEquals(y1, y2)
assertNotEquals(z1, z2)
s.seed++ // but without reset, nothing should change
assertTrue(x2 === s.x)
assertTrue(y2 === s.y)
assertTrue(z2 === s.z)
}
}
I find a convenient method:
import java.util.concurrent.atomic.AtomicReference
import kotlin.reflect.KProperty
fun <T> resetableLazy(initializer: () -> T) = ResetableDelegate(initializer)
class ResetableDelegate<T>(private val initializer: () -> T) {
private val lazyRef: AtomicReference<Lazy<T>> = AtomicReference(
lazy(
initializer
)
)
operator fun getValue(thisRef: Any?, property: KProperty<*>): T {
return lazyRef.get().getValue(thisRef, property)
}
fun reset() {
lazyRef.set(lazy(initializer))
}
}
test:
import org.junit.Assert
import org.junit.Test
class ResetableLazyData {
var changedData = 0
val delegate = resetableLazy { changedData }
val readOnlyData by delegate
}
class ResetableLazyTest {
#Test
fun testResetableLazy() {
val data = ResetableLazyData()
data.changedData = 1
Assert.assertEquals(data.changedData, data.readOnlyData)
data.changedData = 2
Assert.assertNotEquals(data.changedData, data.readOnlyData)
data.delegate.reset()
Assert.assertEquals(data.changedData, data.readOnlyData)
data.changedData = 3
Assert.assertNotEquals(data.changedData, data.readOnlyData)
}
}
I had the same task, and this is what I used:
import kotlin.properties.ReadOnlyProperty
import kotlin.reflect.KProperty
class SingletonLazy<T : Any>(val initBlock: () -> T, val clazz: Class<T>) {
operator fun <R> provideDelegate(ref: R, prop: KProperty<*>): ReadOnlyProperty<R, T> = delegate()
#Suppress("UNCHECKED_CAST")
private fun <R> delegate(): ReadOnlyProperty<R, T> = object : ReadOnlyProperty<R, T> {
override fun getValue(thisRef: R, property: KProperty<*>): T {
val hash = clazz.hashCode()
val cached = singletonsCache[hash]
if (cached != null && cached.javaClass == clazz) return cached as T
return initBlock().apply { singletonsCache[hash] = this }
}
}
}
private val singletonsCache = HashMap<Int, Any>()
fun <T> clearSingleton(clazz: Class<T>) : Boolean {
val hash = clazz.hashCode()
val result = singletonsCache[hash]
if (result?.javaClass != clazz) return false
singletonsCache.remove(hash)
return true
}
inline fun <reified T : Any> singletonLazy(noinline block: () -> T): SingletonLazy<T>
= SingletonLazy(block, T::class.java)
usage:
val cat: Cat by singletonLazy { Cat() }
fun main(args: Array<String>) {
cat
println(clearSingleton(Cat::class.java))
cat // cat will be created one more time
println(singletonsCache.size)
}
class Cat {
init { println("creating cat") }
}
Of course, you may have you own caching strategies.
If you want something very simple, extends Lazy<T> and yet efficient in few lines of code, you could use this
class MutableLazy<T>(private val initializer: () -> T) : Lazy<T> {
private var cached: T? = null
override val value: T
get() {
if (cached.isNull()) {
cached = initializer()
}
#Suppress("UNCHECKED_CAST")
return cached as T
}
fun reset() {
cached = null
}
override fun isInitialized(): Boolean = cached != null
companion object {
fun <T> resettableLazy(value: () -> T) = MutableLazy(value)
}
}
Use it like this:
class MainActivity() {
val recyclerViewLazy = MutableLazy.resettable {
findViewById<RecyclerView>(R.id.recyclerView)
}
val recyclerView by recyclerViewLazy
// And later on
override onCreate(savedInstanceState: Bundle?) {
recyclerViewLazy.reset() /** On next get of the recyclerView, it would be updated*/
}
}
Borrowed partly from
lazy(LazyThreadSafetyMode.NONE) { }
provided in the stlib
you can try this
fun <P, T> renewableLazy(initializer: (P) -> T): ReadWriteProperty<P, T> =
RenewableSynchronizedLazyWithThisImpl({ t, _ ->
initializer.invoke(t)
})
fun <P, T> renewableLazy(initializer: (P, KProperty<*>) -> T): ReadWriteProperty<P, T> =
RenewableSynchronizedLazyWithThisImpl(initializer)
class RenewableSynchronizedLazyWithThisImpl<in T, V>(
val initializer: (T, KProperty<*>) -> V,
private val lock: Any = {}
) : ReadWriteProperty<T, V> {
#Volatile
private var _value: Any? = null
override fun getValue(thisRef: T, property: KProperty<*>): V {
val _v1 = _value
if (_v1 !== null) {
#Suppress("UNCHECKED_CAST")
return _v1 as V
}
return synchronized(lock) {
val _v2 = _value
if (_v2 !== null) {
#Suppress("UNCHECKED_CAST") (_v2 as V)
} else {
val typedValue = initializer(thisRef, property)
_value = typedValue
typedValue
}
}
}
override fun setValue(thisRef: T, property: KProperty<*>, value: V) {
// 不论设置何值,都会被重置为空
synchronized(lock) {
_value = null
}
}
}
Related
I am using MutableSharedFlow in project. My main project concept is very big, so I cannot add in here, instead I made a very small sample to reproduce my problem. I know this example is very wrong, but I have same scenario in my main project. I am using MutableSharedFlow as a Queue implementation with single Thread execution with the help of Mutex.
ExampleViewModel
class ExampleViewModel : ViewModel() {
val serviceNumber = ServiceNumber()
val serviceNumberEventFlow = serviceNumber.eventFlow
val mutex = Mutex()
var delayCounter = 0
suspend fun addItem(itemOne: Int = 2, itemTwo: Int = 2): Add {
return mutex.queueWithTimeout("add") {
serviceNumberEventFlow.onSubscription {
serviceNumber.add(itemOne, itemTwo)
delayCounter++
if (delayCounter == 1) {
delay(1000)
Log.w("Delay ", "Delay Started")
serviceNumber.add(8, 8)
}
}.firstOrNull {
it is Add
} as Add? ?: Add("No value")
}
}
suspend fun subItem(itemOne: Int = 2, itemTwo: Int = 2): Sub {
return mutex.queueWithTimeout("sub") {
serviceNumberEventFlow.onSubscription {
serviceNumber.sub(itemOne, itemTwo)
}.firstOrNull {
it is Sub
} as Sub? ?: Sub("No value")
}
}
private suspend fun <T> Mutex.queueWithTimeout(
action: String, timeout: Long = 5000L, block: suspend CoroutineScope.() -> T
): T {
return try {
withLock {
return#withLock withTimeout<T>(timeMillis = timeout, block = block)
}
} catch (e: Exception) {
Log.e("Wrong", " $e Timeout on BLE call: $action")
throw e
}
}
}
class ServiceNumber : Number {
val eventFlow = MutableSharedFlow<Event>(extraBufferCapacity = 50)
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun add(itemOne: Int, itemTwo: Int) {
Log.i("ServiceNumber", " Add event trigger with $itemOne -- $itemTwo")
eventFlow.emitEvent(Add("Item added ${itemOne + itemTwo}"))
}
override fun sub(itemOne: Int, itemTwo: Int) {
eventFlow.emitEvent(Sub("Item subtract ${itemOne - itemTwo}"))
}
private fun <T> MutableSharedFlow<T>.emitEvent(event: T) {
scope.launch { emit(event) }
}
}
interface Number {
fun add(itemOne: Int, itemTwo: Int)
fun sub(itemOne: Int, itemTwo: Int)
}
sealed class Event
data class Add(val item: String) : Event()
data class Sub(val item: String) : Event()
MainActivity.kt
class MainActivity : AppCompatActivity() {
private val viewModel: ExampleViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
Theme {
Column {
Button(onClick = {
lifecycleScope.launchWhenCreated {
withContext(Dispatchers.IO) {
val result = viewModel.addItem()
Log.e("Result", "$result")
}
}
}) {
Text("Add")
}
Button(onClick = {
lifecycleScope.launchWhenCreated {
withContext(Dispatchers.IO) {
val result = viewModel.subItem()
Log.e("Result", "$result")
}
}
}) {
Text("Sub")
}
}
}
}
}
}
#Composable
fun Theme(content: #Composable () -> Unit) {
MaterialTheme(content = content)
}
Problem
This example is simple Add and subtract of two number. When I am click on Add Button first time, viewmodel.addItem(...) -> ... ->ServiceNumber.add() will trigger and emit the value and we can see log in console. Inside the Add Button function, I was also added a delay to trigger ServiceNumber.add() again to see that onSubscription will be also retrigger or not. MutableSharedFlow emit the value as I can see in log but onSubscription method not called. I don't understand what is the problem in here.
onSubscription is an operator so it creates a new copy of your shared flow. The lambda code will only be run when there are new collectors on this new flow. The only time you collect this new flow is when you call firstOrNull() on it, a terminal operator that collects a single value.
I would like to use Mapbox to get the road speed limit value but returned the value from LocationObserver always is null while speed limit value in the official map box app has value In the same direction. how can i fix this problem?
setup mapBoxNavigation:
if (!MapboxNavigationApp.isSetup()) {
MapboxNavigationApp.setup {
NavigationOptions.Builder(this)
.accessToken(MAPBOX_ACCESS_TOKEN)
.build()
}
}
lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onResume(owner: LifecycleOwner) {
MapboxNavigationApp.attach(owner)
}
override fun onPause(owner: LifecycleOwner) {
MapboxNavigationApp.detach(owner)
}
})
MapboxNavigationApp.current()?.startTripSession()
This is observer dataSource:
class MapBoxLocationObserverDataSource #Inject constructor(context: Context) :
MapboxNavigationObserver, MapBoxLocationObserver {
private val speedLimitCallback = MutableStateFlow<NavigationInfo?>(null)
override val speedLimit: Flow<NavigationInfo?>
get() = speedLimitCallback
private val locationObserver = object : LocationObserver {
override fun onNewLocationMatcherResult(locationMatcherResult: LocationMatcherResult) {
MapboxSpeedInfoApi().updatePostedAndCurrentSpeed(
locationMatcherResult,
DistanceFormatterOptions.Builder(context).build(),
).apply {
speedLimitCallback.value =
NavigationInfo(speedInfo = this, locationMatcherResult = locationMatcherResult)
}
}
override fun onNewRawLocation(rawLocation: Location) = Unit
}
override fun onAttached(mapboxNavigation: MapboxNavigation) {
mapboxNavigation.registerLocationObserver(locationObserver)
}
override fun onDetached(mapboxNavigation: MapboxNavigation) {
mapboxNavigation.unregisterLocationObserver(locationObserver)
}
data class NavigationInfo(
val speedInfo: SpeedInfoValue,
val locationMatcherResult: LocationMatcherResult
)
}
The speed limit always is null in the LocationMatcherResult but some value are not, like current speed or enhancedLocation
I am learning Android development, and as I saw in many topics, people were talking about that LiveData is not recommended to use anymore. I mean it's not up-to-date, and we should use Flows instead.
I am trying to get data from ROOM database with Flows and then convert them to StateFlow because as I know they are observables, and I also want to add UI states to them. Like when I get data successfully, state would change to Success or if it fails, it changes to Error.
I have a simple app for practicing. It stores subscribers with name and email, and show them in a recyclerview.
I've checked a lot of sites, how to use stateIn method, how to use StateFlows and Flows but didn't succeed. What's the most optimal way to do this?
And also what's the proper way of updating recyclerview adapter? Is it okay to change it all the time in MainActivity to a new adapter?
Here is the project (SubscriberViewModel.kt - line 30):
Project link
If I am doing other stuff wrong, please tell me, I want to learn. I appreciate any kind of help.
DAO:
import androidx.room.*
import kotlinx.coroutines.flow.Flow
#Dao
interface SubscriberDAO {
#Insert
suspend fun insertSubscriber(subscriber : Subscriber) : Long
#Update
suspend fun updateSubscriber(subscriber: Subscriber) : Int
#Delete
suspend fun deleteSubscriber(subscriber: Subscriber) : Int
#Query("DELETE FROM subscriber_data_table")
suspend fun deleteAll() : Int
#Query("SELECT * FROM subscriber_data_table")
fun getAllSubscribers() : Flow<List<Subscriber>>
#Query("SELECT * FROM subscriber_data_table WHERE :id=subscriber_id")
fun getSubscriberById(id : Int) : Flow<Subscriber>
}
ViewModel:
class SubscriberViewModel(private val repository: SubscriberRepository) : ViewModel() {
private var isUpdateOrDelete = false
private lateinit var subscriberToUpdateOrDelete: Subscriber
val inputName = MutableStateFlow("")
val inputEmail = MutableStateFlow("")
private val _isDataAvailable = MutableStateFlow(false)
val isDataAvailable : StateFlow<Boolean>
get() = _isDataAvailable
val saveOrUpdateButtonText = MutableStateFlow("Save")
val deleteOrDeleteAllButtonText = MutableStateFlow("Delete all")
/*
//TODO - How to implement this as StateFlow<SubscriberListUiState> ??
//private val _subscribers : MutableStateFlow<SubscriberListUiState>
//val subscribers : StateFlow<SubscriberListUiState>
get() = _subscribers
*/
private fun clearInput() {
inputName.value = ""
inputEmail.value = ""
isUpdateOrDelete = false
saveOrUpdateButtonText.value = "Save"
deleteOrDeleteAllButtonText.value = "Delete all"
}
fun initUpdateAndDelete(subscriber: Subscriber) {
inputName.value = subscriber.name
inputEmail.value = subscriber.email
isUpdateOrDelete = true
subscriberToUpdateOrDelete = subscriber
saveOrUpdateButtonText.value = "Update"
deleteOrDeleteAllButtonText.value = "Delete"
}
fun saveOrUpdate() {
if (isUpdateOrDelete) {
subscriberToUpdateOrDelete.name = inputName.value
subscriberToUpdateOrDelete.email = inputEmail.value
update(subscriberToUpdateOrDelete)
} else {
val name = inputName.value
val email = inputEmail.value
if (name.isNotBlank() && email.isNotBlank()) {
insert(Subscriber(0, name, email))
}
inputName.value = ""
inputEmail.value = ""
}
}
fun deleteOrDeleteAll() {
if (isUpdateOrDelete) {
delete(subscriberToUpdateOrDelete)
} else {
deleteAll()
}
}
private fun insert(subscriber: Subscriber) = viewModelScope.launch(Dispatchers.IO) {
repository.insert(subscriber)
_isDataAvailable.value = true
}
private fun update(subscriber: Subscriber) = viewModelScope.launch(Dispatchers.IO) {
repository.update(subscriber)
clearInput()
}
private fun delete(subscriber: Subscriber) = viewModelScope.launch(Dispatchers.IO) {
repository.delete(subscriber)
clearInput()
}
private fun deleteAll() = viewModelScope.launch(Dispatchers.IO) {
repository.deleteAll()
//_subscribers.value = SubscriberListUiState.Success(emptyList())
_isDataAvailable.value = false
}
sealed class SubscriberListUiState {
data class Success(val list : List<Subscriber>) : SubscriberListUiState()
data class Error(val msg : String) : SubscriberListUiState()
}
}
MainActivity:
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private lateinit var viewModel: SubscriberViewModel
private lateinit var viewModelFactory: SubscriberViewModelFactory
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
val dao = SubscriberDatabase.getInstance(application).subscriberDAO
viewModelFactory = SubscriberViewModelFactory(SubscriberRepository(dao))
viewModel = ViewModelProvider(this, viewModelFactory)[SubscriberViewModel::class.java]
binding.viewModel = viewModel
binding.lifecycleOwner = this
initRecycleView()
}
private fun initRecycleView() {
binding.recyclerViewSubscribers.layoutManager = LinearLayoutManager(
this#MainActivity,
LinearLayoutManager.VERTICAL, false
)
displaySubscribersList()
}
private fun displaySubscribersList() {
/*
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.subscribers.collect { uiState ->
when (uiState) {
is SubscriberViewModel.SubscriberListUiState.Success -> {
binding.recyclerViewSubscribers.adapter = SubscriberRecyclerViewAdapter(uiState.list) {
subscriber: Subscriber -> listItemClicked(subscriber)
}
}
is SubscriberViewModel.SubscriberListUiState.Error -> {
Toast.makeText(applicationContext,uiState.msg, Toast.LENGTH_LONG).show()
}
}
}
}
}*/
}
private fun listItemClicked(subscriber: Subscriber) {
Toast.makeText(this, "${subscriber.name} is selected", Toast.LENGTH_SHORT).show()
viewModel.initUpdateAndDelete(subscriber)
}
}
You can convert a Flow type into a StateFlow by using stateIn method.
private val coroutineScope = CoroutineScope(Job())
private val flow: Flow<CustomType>
val stateFlow = flow.stateIn(scope = coroutineScope)
In order to transform the CustomType into UIState, you can use the transformLatest method on Flow. It will be something like below:
stateFlow.transformLatest { customType ->
customType.toUiState()
}
Where you can create an extension function to convert CustomType to UiState like this:
fun CustomType.toUiState() = UiState(
x = x,
y = y... and so on.
)
I have been trying, without success, to do some UI tests on Android.
My app follows the MVVM architecture and uses Koin for DI.
I followed this tutorial to properly set up a UI test for a Fragment with Koin, MockK and Kakao.
I created the custom rule for injecting mocks, setup the ViewModel, and on the #Before call, run the expected answers and returns with MockK. The problem is that, even when the fragment's viewmodel's LiveData object is the same as the testing class's LiveData object, the Observer's onChange is never triggered on the Fragment.
I run the test with the debugger and it seems the LiveData functions and MockK's answers are properly called. The logs show that the value hold by the LiveData objects is the same. The lifecycle of the Fragment when the test is running is Lifecycle.RESUMED. So why is the Observer's onChange(T) not being triggered?
The custom rule:
#VisibleForTesting(otherwise = VisibleForTesting.NONE)
abstract class FragmentTestRule<F : Fragment> :
ActivityTestRule<FragmentActivity>(FragmentActivity::class.java, true, true) {
override fun afterActivityLaunched() {
super.afterActivityLaunched()
activity.runOnUiThread {
val fm = activity.supportFragmentManager
val transaction = fm.beginTransaction()
transaction.replace(
android.R.id.content,
createFragment()
).commit()
}
}
override fun beforeActivityLaunched() {
super.beforeActivityLaunched()
val app = InstrumentationRegistry.getInstrumentation()
.targetContext.applicationContext as VideoWorldTestApp
app.injectModules(getModules())
}
protected abstract fun createFragment(): F
protected abstract fun getModules(): List<Module>
fun launch() {
launchActivity(Intent())
}
}
#VisibleForTesting(otherwise = VisibleForTesting.NONE)
fun <F : Fragment> createRule(fragment: F, vararg module: Module): FragmentTestRule<F> =
object : FragmentTestRule<F>() {
override fun createFragment(): F = fragment
override fun getModules(): List<Module> = module.toList()
}
My test App:
#VisibleForTesting(otherwise = VisibleForTesting.NONE)
class VideoWorldTestApp: Application(){
companion object {
lateinit var instance: VideoWorldTestApp
}
override fun onCreate() {
super.onCreate()
instance = this
startKoin {
if (BuildConfig.DEBUG) androidLogger(Level.DEBUG) else EmptyLogger()
androidContext(this#VideoWorldTestApp)
modules(emptyList())
}
Timber.plant(Timber.DebugTree())
}
internal fun injectModules(modules: List<Module>) {
loadKoinModules(modules)
}
}
The custom test runner:
class CustomTestRunner: AndroidJUnitRunner() {
override fun newApplication(
cl: ClassLoader?,
className: String?,
context: Context?
): Application {
return super.newApplication(cl, VideoWorldTestApp::class.java.name, context)
}
}
The test:
#RunWith(AndroidJUnit4ClassRunner::class)
class HomeFragmentTest {
private val twitchViewModel: TwitchViewModel = mockk(relaxed = true)
private val userData = MutableLiveData<UserDataResponse>()
private val fragment = HomeFragment()
#get:Rule
var fragmentRule = createRule(fragment, module {
single(override = true) {
twitchViewModel
}
})
#get:Rule
var countingTaskExecutorRule = CountingTaskExecutorRule()
#Before
fun setup() {
val userResponse: UserResponse = mockk()
every { userResponse.displayName } returns "Rubius"
every { userResponse.profileImageUrl } returns ""
every { userResponse.description } returns "Soy streamer"
every { userResponse.viewCount } returns 5000
every { twitchViewModel.userData } returns userData as LiveData<UserDataResponse>
every { twitchViewModel.getUserByInput(any()) }.answers {
userData.value = UserDataResponse(listOf(userResponse))
}
}
#Test //This one is passing
fun testInitialViewState() {
onScreen<HomeScreen> {
streamerNameTv.containsText("")
streamerCardContainer.isVisible()
nameInput.hasEmptyText()
progressBar.isGone()
}
}
#Test //This one is failing
fun whenWritingAName_AndPressingTheImeAction_AssertTextChanges() {
onScreen<HomeScreen> {
nameInput.typeText("Rubius")
//nameInput.pressImeAction()
searchBtn.click()
verify { twitchViewModel.getUserByInput(any()) } //This passes
countingTaskExecutorRule.drainTasks(5, TimeUnit.SECONDS)
streamerNameTv.hasText("Rubius") //Throws exception
streamerDescp.hasText("Soy streamer")
streamerCount.hasText("Views: ${5000.formatInt()}}")
}
}
}
The fragment being tested:
class HomeFragment : BaseFragment<FragmentHomeBinding>(R.layout.fragment_home) {
override val bindingFunction: (view: View) -> FragmentHomeBinding
get() = FragmentHomeBinding::bind
val twitchViewModel: TwitchViewModel by sharedViewModel()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
twitchViewModel.getUserClips("")
binding.nameInput.setOnEditorActionListener { _, actionId, _ ->
if(actionId == EditorInfo.IME_ACTION_SEARCH) {
twitchViewModel.getUserByInput(binding.nameInput.text.toString())
hideKeyboard()
return#setOnEditorActionListener true
}
return#setOnEditorActionListener false
}
binding.searchBtn.setOnClickListener {
twitchViewModel.getUserByInput(binding.nameInput.text.toString() ?: "")
hideKeyboard()
}
twitchViewModel.userData.observe(viewLifecycleOwner, Observer { data ->
if (data != null && data.dataList.isNotEmpty()){
binding.streamerCard.setOnClickListener {
findNavController().navigate(R.id.action_homeFragment_to_clipsFragment)
}
val streamer = data.dataList[0]
Picasso.get()
.load(streamer.profileImageUrl)
.into(binding.profileIv)
binding.streamerLoginTv.text = streamer.displayName
binding.streamerDescpTv.text = streamer.description
binding.streamerViewCountTv.text = "Views: ${streamer.viewCount.formatInt()}"
}
else {
binding.streamerCard.setOnClickListener { }
}
})
twitchViewModel.errorMessage.observe(viewLifecycleOwner, Observer { msg ->
showSnackbar(msg)
})
twitchViewModel.progressVisibility.observe(viewLifecycleOwner, Observer { visibility ->
binding.progressBar.visibility = visibility
binding.cardContent.visibility =
if(visibility == View.VISIBLE)
View.GONE
else
View.VISIBLE
})
}
}
The ViewModel:
class TwitchViewModel(private val repository: TwitchRepository): BaseViewModel() {
private val _userData = MutableLiveData<UserDataResponse>()
val userData = _userData as LiveData<UserDataResponse>
private val _userClips = MutableLiveData<UserClipsResponse?>()
val userClips = _userClips as LiveData<UserClipsResponse?>
init {
viewModelScope.launch {
repository.authUser(this#TwitchViewModel)
}
}
fun currentUserId() = userData.value?.dataList?.get(0)?.id ?: ""
fun clipsListExists() = userClips.value != null
fun getUserByInput(input: String){
viewModelScope.launch {
_progressVisibility.value = View.VISIBLE
_userData.value = repository.getUserByName(input, this#TwitchViewModel)
_progressVisibility.value = View.GONE
}
}
/**
* #param userId The ID of the Streamer whose clips are gonna fetch. If null, resets
* If empty, sets the [userClips] value to null.
*/
fun getUserClips(userId: String){
if(userId.isEmpty()) {
_userClips.postValue(null)
return
}
if(userId == currentUserId() && _userClips.value != null) {
_userClips.postValue(_userClips.value)
return
}
viewModelScope.launch {
_userClips.value = repository.getUserClips(userId, this#TwitchViewModel)
}
}
}
When running the test with the normal ActivityRule and launching the Activity as it were a normal launch, the observers are triggering successfully.
I'm using a relaxed mock to avoid having to mock all functions and variables.
Finally found the problem and the solution with the debugger. Apparently, the #Before function call runs after the ViewModel is injected into the fragment, so even if the variables pointed to the same reference, mocked answer where executing only in the test context, not in the android context.
I changed the ViewModel initialization to the module scope like this:
#get:Rule
val fragmentRule = createRule(fragment, module {
single(override = true) {
makeMocks()
val twitchViewModel = mockViewModel()
twitchViewModel
}
})
private fun makeMocks() {
mockkStatic(Picasso::class)
}
private fun mockViewModel(): TwitchViewModel {
val userData = MutableLiveData<UserDataResponse>()
val twitchViewModel = mockk<TwitchViewModel>(relaxed = true)
every { twitchViewModel.userData } returns userData
every { twitchViewModel.getUserByInput("Rubius") }.answers {
updateUserDataLiveData(userData)
}
return twitchViewModel
}
And the Observer inside the Fragment got called!
Maybe it's not related, but I could not rebuild the gradle project if I have mockk(v1.10.0) as a testImplementation and as a debugImplementation.
I am uning MVVM pattern when i call api in activity from ViewModel its always throwing me with Error
Smart cast to 'MainActivityViewModel' is impossible, because 'binding.mainModel' is a complex expression
Following is the my ViewMode:
class MainActivityViewModel(private val api: SearchAPI) : BaseViewModel() {
private var query: String = ""
get() = if (field.isEmpty()) "MVVM" else field
private val _refreshing: NotNullMutableLiveData<Boolean> = NotNullMutableLiveData(false)
val refreshing: NotNullMutableLiveData<Boolean>
get() = _refreshing
lateinit var _items: NotNullMutableLiveData<RetrofitWrapper>
val items: NotNullMutableLiveData<RetrofitWrapper>
get() = _items
fun getMainPageData() {
val params = mutableMapOf<String, String>().apply {
this["version"] = "v1"
this["locale"] = "en"
this["platform"] = "android"
}
addToDisposable(api.getHomePageDetail(params).with()
.doOnSubscribe { _refreshing.value = true }
.doOnSuccess { _refreshing.value = false }
.doOnError { _refreshing.value = false }
.subscribe({
_items.value = it
}, {
// handle errors
})
)
}
}
And following is my MainActivity:
class MainActivity : BindingActivity<ActivityMain2Binding>() {
override fun getLayoutResId(): Int {
return R.layout.activity_main2
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding.mainModel = getViewModel()
binding.setLifecycleOwner(this)
binding.mainModel.getMainPageData()
}
}
Your Help on this matter highly appreciated, do let me know why this is happening to me.
binding.mainModel is a mutable, nullable variable.
Generated binding setter method code will look something like this :
public void setViewModel(#Nullable MainActivityViewModel viewModel) { ... }
When you call binding.mainModel.getMainPageData() it cannot infer that the variable is not null.
You can either do :
binding.mainModel!!.getMainPageData()
or more safely :
binding.mainModel?.getMainPageData()