Mavericks/MvRx, Compose, Hilt: Passing Nav Route Arguments to MavericksViewModel
If you're using the Jetpack Compose Navigation component with routes set up with arguments, you may be looking to use data passed in Compose route arguments in your MavericksState
.
Note that if you're looking to consume Fragment arguments, then you should be looking at this.
Before we proceed, let's ensure we're on the same page w.r.t. the stack of libraries at play: Compose foundation
for UI, navigation-compose
for Navigation, hilt
for DI, Mavericks for ViewModels and state (mavericks-compose
and mavericks-hilt
). In our case, we don't use Fragments in the project.
In this set up, you create your routes with NavGraphBuilder.composable
under a NavHost
, and your Compose Navigation route arguments will be available in the backStackEntry.arguments
Bundle.
Set the argsFactory
parameter for your mavericksViewModel
call. The result from this function will be passed to your state constructor as a parameter when the ViewModel is first initialized. That return value needs to be a Parcelable
.
val argsBundle = backStackEntry.arguments ?: return // remove the Elvis return if arguments aren't mandatory
val viewModel: InvoiceViewModel = mavericksViewModel(argsFactory = { argsBundle })
Next, add a constructor to your MavericksState
to accept a Bundle
and build the initial state object from data in the Bundle:
In the above case, argsBundle
of type Parcelable
supplied in the argsFactory
parameter of the mavericksViewModel
call will be passed to InvoiceViewState
's secondary constructor accepting the arguments: Bundle
parameter.
Hilt, Mavericks: Writing Unit Tests for MavericksViewModel, Repository, DAO
Typically, a MavericksViewModel
accepts the MavericksState
along with Repositories which in turn contain DAOs. When using Hilt for DI, the most common challenge is how to create an instance of a repository to supply to the constructor of a MavericksViewModel
. The solution is to use Hilt DI in tests.
Hilt also lets you easily swap the database implementation in tests and use a throwaway in-memory database to test persistence operations. You can also preload data in it.
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [DatabaseModule::class],
)
@Module
object FakeDatabaseModule {
@Provides
fun provideEntityDao(database: Database) = database.entityDao()
@Provides
@Singleton
fun provideDatabase(): Database {
val context: Context = ApplicationProvider.getApplicationContext()
return Room.inMemoryDatabaseBuilder(context, Database::class.java)
.allowMainThreadQueries()
.build()
}
}
Testing MavericksViewModel
Create a base class for your tests. This registers two rules - one for Hilt and another for Mavericks. The Hilt rule allows you to use Hilt DI in test cases. The Mavericks test rule makes ViewModel state access synchronous, among many other things.
import com.airbnb.mvrx.test.MvRxTestRule
import dagger.hilt.android.testing.HiltAndroidRule
import org.junit.Before
import org.junit.Rule
abstract class BaseTest {
@get:Rule(order = 0)
val hiltRule by lazy { HiltAndroidRule(this) }
@get:Rule(order = 1)
val mvrxRule = MvRxTestRule()
@Before
fun init() {
hiltRule.inject()
}
}
Your test class for MavericksViewModel
can be created as follows:
@HiltAndroidTest
class HomeViewModelTest : BaseTest() {
@Inject
lateinit var repository1: OneRepository
@Test
fun testHomeViewModel() = runBlocking {
val viewModel = HomeViewModel(
HomeViewState(),
repository1,
)
/* actions, asserts code here */
}
}
Testing DAOs
Similarly, DAO's can be tested as follows:
@HiltAndroidTest
class EntityDaoTest : BaseTest() {
@Inject
lateinit var entityDao: EntityDao
@Test
@Throws(Exception::class)
fun insertAndGetEntity() = runBlocking {
val entityId = 0
val entity = Entity(
0,
"test://$entityId",
)
entityDao.insert(entity)
val savedEntity = entityDao.entities().first()
assertEquals(savedEntity[0].uri, entity.uri)
}
}
Notes on Common Errors
Getting Mavericks to work with Hilt is easy. But you have to be mindful of the steps involved in the integration. For instance, not registering a MavericksViewModel
with Hilt
gives you the following cryptic error.
at android.view.Choreographer$FrameDisplayEventReceiver.run(Choreographer.java:957)
at android.os.Handler.handleCallback(Handler.java:938)
at android.os.Handler.dispatchMessage(Handler.java:99)
at android.os.Looper.loop(Looper.java:223)
at android.app.ActivityThread.main(ActivityThread.java:7656)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:592)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:947)
Caused by: java.lang.NullPointerException: null cannot be cast to non-null type VM of com.airbnb.mvrx.hilt.HiltMavericksViewModelFactory
at com.airbnb.mvrx.hilt.HiltMavericksViewModelFactory.create(HiltMavericksViewModelFactory.kt:49)
at [].ui.screen.invoice.InvoiceViewModel$Companion.create(Unknown Source:15)
at [].ui.screen.invoice.InvoiceViewModel$Companion.create(InvoiceViewModel.kt:35)
The Fix
Background
In mobile apps, most P1 bugs exist in the state management space. Mavericks provides a good foundation to manage state in Android apps, especially for those familiar with the React way. With Jetpack Compose set up to do the rendering, it's helpful to think of a "screen" as a "function of the state". This makes the rendering aspect simple, while increasing the complexity of generating states based on user, service, or environment effects.
That complexity, however, can be managed by good practices, writing tests. You can now have two types of tests: one set that tests whether x state produces y UI effect, and another set testing if x condition produces y state. Those tests can be easily run with mocks and can be run in parallel to save time.
Another advantage of cleanly having mocks supply state to render a working UI is that you can build a demo app very quickly, without implementing any details and even without a backend service. That way, UX engineers can take early calls on what needs to change without involving too much engineering effort.
Now, most of what's possible in Mavericks should be possible with Hilt, and you'll definitely find it easier to get help with Hilt issues. But in my opinion, MavericksViewModel
provides a simple and intuitive DX compared to HiltViewModel
. I'd much rather use Hilt for DI alone. I'll mark this down to personal preference for now, but there's much more to it.