Skip to content

Latest commit

 

History

History
324 lines (243 loc) · 10.7 KB

README.md

File metadata and controls

324 lines (243 loc) · 10.7 KB

Lich ViewModel

Maven Central

Lightweight framework for managing ViewModels in the same way as Lich Component.

This library is very similar to AndroidX ViewModel, but it has the following advantages:

  • The code for initializing a ViewModel class can be written in the ViewModel itself.
  • It is easy to write unit tests for Activity / Fragment with mocking ViewModels.

Set up

Add the following entries to your build.gradle file.

dependencies {
    implementation 'com.linecorp.lich:viewmodel:x.x.x'
    // For Jetpack Compose
    implementation 'com.linecorp.lich:viewmodel-compose:x.x.x'

    testImplementation 'com.linecorp.lich:viewmodel-test-mockk:x.x.x'
    testImplementation 'androidx.test:runner:x.x.x'
    testImplementation 'androidx.test.ext:junit:x.x.x'
    testImplementation 'io.mockk:mockk:x.x.x'
    testImplementation 'org.robolectric:robolectric:x.x'

    androidTestImplementation 'com.linecorp.lich:viewmodel-test-mockk:x.x.x'
    androidTestImplementation 'androidx.test:runner:x.x.x'
    androidTestImplementation 'androidx.test.ext:junit:x.x.x'
    androidTestImplementation 'io.mockk:mockk-android:x.x.x'
}

The above code uses MockK as a mocking library. If you prefer Mockito-Kotlin over MockK, you can specify the following instead.

dependencies {
    implementation 'com.linecorp.lich:viewmodel:x.x.x'
    // For Jetpack Compose
    implementation 'com.linecorp.lich:viewmodel-compose:x.x.x'

    testImplementation 'com.linecorp.lich:viewmodel-test-mockitokotlin:x.x.x'
    testImplementation 'androidx.test:runner:x.x.x'
    testImplementation 'androidx.test.ext:junit:x.x.x'
    testImplementation 'org.mockito:mockito-inline:x.x.x'
    testImplementation 'org.mockito.kotlin:mockito-kotlin:x.x.x'
    testImplementation 'org.robolectric:robolectric:x.x'

    androidTestImplementation 'com.linecorp.lich:viewmodel-test-mockitokotlin:x.x.x'
    androidTestImplementation 'androidx.test:runner:x.x.x'
    androidTestImplementation 'androidx.test.ext:junit:x.x.x'
    androidTestImplementation 'org.mockito:mockito-android:x.x.x'
    androidTestImplementation 'org.mockito.kotlin:mockito-kotlin:x.x.x'
}

How to use

In this library, your ViewModel classes need to extend AbstractViewModel and have a companion object inheriting ViewModelFactory.

This is a sample code:

class FooViewModel(context: Context, savedStateHandle: SavedStateHandle) : AbstractViewModel() {

    // snip...

    companion object : ViewModelFactory<FooViewModel>() {
        override fun createViewModel(context: Context, savedStateHandle: SavedStateHandle): FooViewModel =
            FooViewModel(context, savedStateHandle)
    }
}

You can obtain an instance of the ViewModel using ComponentActivity.viewModel or Fragment.viewModel like this:

class FooActivity : AppCompatActivity() {

    // An instance of FooViewModel associated with FooActivity.
    private val fooViewModel by viewModel(FooViewModel)

    // snip...
}
class FooFragment : Fragment() {

    // An instance of FooViewModel associated with FooFragment.
    private val fooViewModel by viewModel(FooViewModel)

    // snip...
}

You can also use Fragment.activityViewModel to obtain a ViewModel associated with the Activity hosting the current Fragment. This ViewModel can be used to share data between Fragments and their host Activity.

class FooFragment : Fragment() {

    // A shared instance of FooViewModel associated with the Activity hosting this FooFragment.
    private val fooActivityViewModel by activityViewModel(FooViewModel)

    // snip...
}

If you're using AndroidX Navigation library, you can use Fragment.navGraphViewModel to obtain a ViewModel scoped to the entry point's navigation back stack.

class FooFragment : Fragment() {

    // A shared instance of FooViewModel scoped to the `foo_nav_graph` navigation graph.
    private val fooNavGraphViewModel by navGraphViewModel(FooViewModel, R.id.foo_nav_graph)

    // snip...
}

You can also use Lich ViewModels in Jetpack Compose via lichViewModel.

@Composable
fun FooScreen(fooViewModel: FooViewModel = lichViewModel(FooViewModel)) {
    // Use fooViewModel here.
}

Coroutines support

AbstractViewModel is implementing CoroutineScope interface. This scope is bound to Dispatchers.Main, and will be cancelled just before AbstractViewModel.onCleared() is called.

class BarViewModel(private val barRepository: BarRepository) : AbstractViewModel() {

    val barText: MutableLiveData<String> = MutableLiveData("")

    fun loadBarText() {
        // The launched job will be automatically cancelled when this ViewModel is destroyed.
        launch {
            val barData = barRepository.loadBarData()
            barText.value = barData.text
        }
    }

    // snip...
}

This feature is almost equivalent to Android Architecture Components' viewModelScope, but you can simply write launch { ... } instead of viewModelScope.launch { ... }.

Testing

In unit tests, you can use mockViewModel(factory) { ... } to mock ViewModels. The following code is an example of mockViewModel to mock the above BarViewModel class using MockK:

@RunWith(AndroidJUnit4::class)
class BarActivityTest {

    @After
    fun tearDown() {
        // You can omit this in Robolectric tests.
        // All ViewModel mocks are automatically cleared for every Robolectric test.
        clearAllMockViewModels()
    }

    @Test
    fun testViewBinding() {
        val mockBarText = MutableLiveData("Mocked.")
        // Set mock ViewModel for `BarViewModel`.
        val mockViewModelHandle = mockViewModel(BarViewModel) {
            every { barText } returns mockBarText
        }

        ActivityScenario.launch(BarActivity::class.java).use { scenario ->

            scenario.onActivity {
                assertTrue(mockViewModelHandle.isCreated)
            }

            onView(withId(R.id.bar_text)).check(matches(withText("Mocked.")))

            onView(withId(R.id.load_bar_button)).perform(click())

            scenario.onActivity {
                verify(exactly = 1) { mockViewModelHandle.mock.loadBarText() }
            }
        }
    }
}

See also MvvmSampleActivityTest in the :sample-app:ui module.

The mockViewModel function is also available for Mockito-Kotlin. Here is an example using Mockito-Kotlin.

@RunWith(AndroidJUnit4::class)
class BarActivityTest {

    @After
    fun tearDown() {
        // You can omit this in Robolectric tests.
        // All ViewModel mocks are automatically cleared for every Robolectric test.
        clearAllMockViewModels()
    }

    @Test
    fun testViewBinding() {
        val mockBarText = MutableLiveData("Mocked.")
        // Set mock ViewModel for `BarViewModel`.
        val mockViewModelHandle = mockViewModel(BarViewModel) {
            on { barText } doReturn mockBarText
        }

        ActivityScenario.launch(BarActivity::class.java).use { scenario ->

            scenario.onActivity {
                assertTrue(mockViewModelHandle.isCreated)
            }

            onView(withId(R.id.bar_text)).check(matches(withText("Mocked.")))

            onView(withId(R.id.load_bar_button)).perform(click())

            scenario.onActivity {
                verify(mockViewModelHandle.mock, times(1)).loadBarText()
            }
        }
    }
}

Working with Lich SavedState library

This library can be used in conjunction with Lich SavedState library. The delegated properties and auto-generated ViewModelArgs classes provided by the Lich SavedState library can be used in Lich ViewModel library as well.

@GenerateArgs
class FooViewModel(savedStateHandle: SavedStateHandle) : AbstractViewModel() {

    @Argument
    private val userName: String by savedStateHandle.required()

    @Argument(isOptional = true)
    private val tags: Array<String> by savedStateHandle.initial(arrayOf("normal"))

    @Argument
    private var attachment: Parcelable? by savedStateHandle

    @Argument
    private val message: MutableLiveData<CharSequence> by savedStateHandle.liveData()

    // snip...

    companion object : ViewModelFactory<FooViewModel>() {
        override fun createViewModel(context: Context, savedStateHandle: SavedStateHandle): FooViewModel =
            FooViewModel(savedStateHandle)
    }
}
class FooFragment : Fragment() {

    private val fooViewModel by viewModel(FooViewModel)

    // snip...
}

val fooFragment = FooFragment().also {
    // This `FooViewModelArgs` is used to initialize the `savedStateHandle` of `FooFragment.fooViewModel`.
    it.setViewModelArgs(FooViewModelArgs(userName = "John", attachment = null, message = "Hello."))
}

The generated ViewModelArgs class can be used in the argument of the viewModel extension as follows.

class FooFragment : Fragment() {

    private val fooViewModel by viewModel(FooViewModel) {
        // This `FooViewModelArgs` is used to initialize the `savedStateHandle` of `fooViewModel`.
        FooViewModelArgs(userName = "John", attachment = null, message = "Hello.").toBundle()
    }

    // snip...
}

Example

SampleViewModel in the :sample-app:ui module.