Skip to content

Commit

Permalink
Refactor viewModelScoped to hiltViewModelScoped to follow Hilt conven…
Browse files Browse the repository at this point in the history
…tions
  • Loading branch information
sebaslogen committed Jun 24, 2022
1 parent dfe5095 commit 2d7898a
Show file tree
Hide file tree
Showing 5 changed files with 84 additions and 45 deletions.
55 changes: 39 additions & 16 deletions resacahilt/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,54 +3,72 @@
[![GitHub license](https://img.shields.io/github/license/sebaslogen/resaca)](https://github.com/sebaslogen/resaca/blob/main/LICENSE)

# Resaca Hilt 🍹🗡️
Short lived View Models provided by [**HILT**](https://dagger.dev/hilt/quick-start) with the right scope in Android [Compose](https://developer.android.com/jetpack/compose).

Short lived View Models provided by [**HILT**](https://dagger.dev/hilt/quick-start) with the right scope in
Android [Compose](https://developer.android.com/jetpack/compose).

# Why
Compose allows the creation of fine-grained UI components that can be easily reused like Lego blocks 🧱. Well architected Android apps isolate functionality in small business logic components (like use cases, interactors, repositories, etc.) that are also reusable like Lego blocks 🧱.

Screens are built using Compose components together with business logic components, and the standard tool to connect these two types of components is a [Jetpack ViewModel](https://developer.android.com/topic/libraries/architecture/viewmodel). Unfortunately, ViewModels can only be scoped to a whole screen (or larger scope), but not to smaller Compose components on the screen.
Compose allows the creation of fine-grained UI components that can be easily reused like Lego blocks 🧱. Well architected Android apps isolate functionality in
small business logic components (like use cases, interactors, repositories, etc.) that are also reusable like Lego blocks 🧱.

Screens are built using Compose components together with business logic components, and the standard tool to connect these two types of components is
a [Jetpack ViewModel](https://developer.android.com/topic/libraries/architecture/viewmodel). Unfortunately, ViewModels can only be scoped to a whole screen (or
larger scope), but not to smaller Compose components on the screen.

In practice, this means that we are gluing UI Lego blocks with business logic Lego blocks using a big glue class for the whole screen, the ViewModel 🗜.

Until now...


# Usage
Inside your `@Composable` function create and retrieve a ViewModel using `viewModelScoped` to remember any `@HiltViewModel` annotated ViewModel.
That plus the standard Hilt configuration is all that's needed 🪄✨

Inside your `@Composable` function create and retrieve a ViewModel using `hiltViewModelScoped` to remember any `@HiltViewModel` annotated ViewModel. This
together with the standard Hilt configuration is all that's needed 🪄✨

Example

```kotlin
@Composable
fun DemoInjectedViewModelScoped() {
val myInjectedViewModel: MyViewModel = viewModelScoped()
val myInjectedViewModel: MyViewModel = hiltViewModelScoped()
DemoComposable(viewModel = myInjectedViewModel)
}
```

Once you use the `viewModelScoped` function, the same object will be restored as long as the Composable is part of the composition, even if it _temporarily_ leaves composition on configuration change (e.g. screen rotation, change to dark mode, etc.) or while being in the backstack.
Once you use the `hiltViewModelScoped` function, the same object will be restored as long as the Composable is part of the composition, even if it _temporarily_
leaves composition on configuration change (e.g. screen rotation, change to dark mode, etc.) or while being in the backstack.

⚠️ ViewModels provided with `viewModelScoped` **should not be created** using any of the Hilt `hiltViewModel()` or Compose `viewModel()` nor `ViewModelProviders` factories, otherwise they will be retained in the scope of the screen regardless of `viewModelScoped`.
⚠️ ViewModels provided with `hiltViewModelScoped` **should not be created** using any of the Hilt `hiltViewModel()` or Compose `viewModel()`
nor `ViewModelProviders` factories, otherwise they will be retained in the scope of the screen regardless of `hiltViewModelScoped`.

❕ Due to the keyed factory limitations mentioned below, any call to `viewModelScoped` from a new location in the Compose code (_inside the same Activity_) will return the same instance of the ViewModel instead of a new instance per call location.
❕ Due to the keyed factory limitations mentioned below, any call to `hiltViewModelScoped` from a new location in the Compose code (_inside the same Activity_)
will return the same instance of the ViewModel instead of a new instance per call location.

# Basic Hilt setup
To use the `viewModelScoped` function you need to follow these 3 Hilt configuration steps:

To use the `hiltViewModelScoped` function you need to follow these 3 Hilt configuration steps:

- Annotate your application class with `@HiltAndroidApp`.
- Annotate with `@AndroidEntryPoint` the Activity class that will contain the Composables with the ViewModel.
- Annotate your ViewModel class with `@HiltViewModel` and the constructor with `@Inject`. [See example here](https://github.com/sebaslogen/resaca/blob/main/sample/src/main/java/com/sebaslogen/resacaapp/ui/main/data/FakeInjectedViewModel.kt).
- Annotate your ViewModel class with `@HiltViewModel` and the constructor with `@Inject`
. [See example here](https://github.com/sebaslogen/resaca/blob/main/sample/src/main/java/com/sebaslogen/resacaapp/ui/main/data/FakeInjectedViewModel.kt).

**Optionally: Annotate with `@Inject` any other class that is part of your ViewModel's constructor.*

For a complete guide to Hilt check the official documentation. Here are the [quick-start](https://dagger.dev/hilt/quick-start) and the [Hilt ViewModel](https://dagger.dev/hilt/view-model) docs.
Gradle setup for Hilt [can be found here](https://dagger.dev/hilt/gradle-setup.html)

For a complete guide to Hilt check the official documentation. Here are the [quick-start](https://dagger.dev/hilt/quick-start) and
the [Hilt ViewModel](https://dagger.dev/hilt/view-model) docs.

# Sample use cases

Here are some sample use cases reported by the users of this library:
- ❤️ Isolated and stateful UI components like a **favorite button** that are widely used across the screens. This `FavoriteViewModel` can be very small, focused and only require an id to work without affecting the rest of the screen's UI and state.

- ❤️ Isolated and stateful UI components like a **favorite button** that are widely used across the screens. This `FavoriteViewModel` can be very small, focused
and only require an id to work without affecting the rest of the screen's UI and state.

# Installation

Add the Jitpack repo and include the library:

```gradle
Expand All @@ -67,6 +85,11 @@ Add the Jitpack repo and include the library:
```

# Hilt keyed factory limitations
Unfortunately, Hilt does not yet support multiple instances of the same ViewModel using **keys**. This feature is currently a work in progress in the Dagger/Hilt project and its status can be tracked here: [\[Hilt\] Multiple viewmodel instances with different keys crash · Issue #2328 · google/dagger](https://github.com/google/dagger/issues/2328).

The consequence of this limitation is that we can't create multiple instances of the same ViewModel in the same scope (i.e. Activity, Fragment or Compose Navigation Destination). This is required for example to implement view-pager use cases like [this one](https://github.com/sebaslogen/resaca/blob/main/README.md#sample-use-cases) from the vanilla Resaca library.
Unfortunately, Hilt does not yet support multiple instances of the same ViewModel using **keys**. This feature is currently a work in progress in the
Dagger/Hilt project and its status can be tracked
here: [\[Hilt\] Multiple viewmodel instances with different keys crash · Issue #2328 · google/dagger](https://github.com/google/dagger/issues/2328).

The consequence of this limitation is that we can't create multiple instances of the same ViewModel in the same scope (i.e. Activity, Fragment or Compose
Navigation Destination). This is required for example to implement view-pager use cases
like [this one](https://github.com/sebaslogen/resaca/blob/main/README.md#sample-use-cases) from the vanilla Resaca library.
56 changes: 36 additions & 20 deletions sample/README.md
Original file line number Diff line number Diff line change
@@ -1,43 +1,59 @@
# Resaca Demo App 🍹

The purpose of this app is to show the usage of the `rememberScoped` function in the context of different lifecycle events.

The demo will instantiate different fake business logic objects ([FakeRepo](https://github.com/sebaslogen/resaca/blob/main/sample/src/main/java/com/sebaslogen/resacaapp/ui/main/data/FakeRepo.kt), [FakeScopedViewModel](https://github.com/sebaslogen/resaca/blob/main/sample/src/main/java/com/sebaslogen/resacaapp/ui/main/data/FakeScopedViewModel.kt) or [FakeInjectedViewModel](https://github.com/sebaslogen/resaca/blob/main/sample/src/main/java/com/sebaslogen/resacaapp/ui/main/data/FakeInjectedViewModel.kt)) and either scope them with `rememberScoped`, `viewModelScoped` or with the vanilla `remember` from Compose, to illustrate the differences in memory retention across different lifecycle events.
The demo will instantiate different fake business logic
objects ([FakeRepo](https://github.com/sebaslogen/resaca/blob/main/sample/src/main/java/com/sebaslogen/resacaapp/ui/main/data/FakeRepo.kt)
, [FakeScopedViewModel](https://github.com/sebaslogen/resaca/blob/main/sample/src/main/java/com/sebaslogen/resacaapp/ui/main/data/FakeScopedViewModel.kt)
or [FakeInjectedViewModel](https://github.com/sebaslogen/resaca/blob/main/sample/src/main/java/com/sebaslogen/resacaapp/ui/main/data/FakeInjectedViewModel.kt))
and either scope them with `rememberScoped`, `hiltViewModelScoped` or with the vanilla `remember` from Compose, to illustrate the differences in memory
retention across different lifecycle events.

The remembered objects will be represented on the screen with their **unique memory location** by rendering:

- the object's toString representation in a `Text` Composable
- a unique color for the object's instance using `objectToColorInt` as background
- a semi-unique emoji for the object's instance (limited to list of emojis available in [emojis](https://github.com/sebaslogen/resaca/blob/main/sample/src/main/java/com/sebaslogen/resacaapp/ui/main/ui/theme/Emojis.kt))
- a semi-unique emoji for the object's instance (limited to list of emojis available
in [emojis](https://github.com/sebaslogen/resaca/blob/main/sample/src/main/java/com/sebaslogen/resacaapp/ui/main/ui/theme/Emojis.kt))

# Screens structure of the app

The app contains the following screens:

- **Main Activity**. Purpose: show Fragment navigation/lifecycle events and Activity configuration changes (Activity _recreation_)
+ Composable content. Purpose: show Activity configuration changes and entry point for ComposeActivity
* 2 Scoped objects
* Button to Navigate to ComposeActivity
+ **MainFragment**. Purpose: show Fragment navigation/lifecycle events when MainFragment goes into the backstack, and then comes back and its View is _recreated_
* 1 Not scoped object
* 2 Scoped objects
* Button to Navigate to **FragmentTwo**
+ **FragmentTwo**. Purpose: push **MainFragment** into the backstack to destroy its View
+ Composable content. Purpose: show Activity configuration changes and entry point for ComposeActivity
* 2 Scoped objects
* Button to Navigate to ComposeActivity
+ **MainFragment**. Purpose: show Fragment navigation/lifecycle events when MainFragment goes into the backstack, and then comes back and its View is
_recreated_
* 1 Not scoped object
* 2 Scoped objects
* Button to Navigate to **FragmentTwo**
+ **FragmentTwo**. Purpose: push **MainFragment** into the backstack to destroy its View
- **ComposeActivity**. Purpose: show Compose navigation/lifecycle events with Compose Navigation destinations
+ **first rememberScoped** Compose destination. Purpose: show Compose navigation/lifecycle events when destination goes into the backstack, comes back and its Composables are _recreated_
* 1 Not scoped object
* 2 Scoped objects
* Button to Navigate to first
* Button to Navigate to second
* Button to Navigate back
+ **second Hilt viewModelScoped** Compose destination. Purpose: show Activity configuration changes (Activity _recreation_) and Compose conditional UI (in dark mode for this example) when using Hilt injected ViewModels
* 1 Not scoped object
* 1 Scoped object (FakeRepo with `rememberScoped`)
* 1 Hilt Injected Scoped object (FakeInjectedViewModel with `viewModelScoped`)
+ **first rememberScoped** Compose destination. Purpose: show Compose navigation/lifecycle events when destination goes into the backstack, comes back and
its Composables are _recreated_
* 1 Not scoped object
* 2 Scoped objects
* Button to Navigate to first
* Button to Navigate to second
* Button to Navigate back
+ **second Hilt hiltViewModelScoped** Compose destination. Purpose: show Activity configuration changes (Activity _recreation_) and Compose conditional UI (
in dark mode for this example) when using Hilt injected ViewModels
* 1 Not scoped object
* 1 Scoped object (FakeRepo with `rememberScoped`)
* 1 Hilt Injected Scoped ViewModel (FakeInjectedViewModel with `hiltViewModelScoped`)

# Lifecycle events

Here is a list of lifecycle events where `rememberScoped` will retain objects:

- Android Configuration change. Examples: App size changes (like in split screen), light/dark mode switches, rotation, language, etc.
- Fragment goes into the backstack
- Composable destination goes into the backstack

# Demo app

<p align="center">
<img src="https://user-images.githubusercontent.com/1936647/144597718-db7e8901-a726-4871-abf8-7fc53333a90e.gif" alt="Resaca-demo" width="340" height="802" />
</p>
Expand Down
4 changes: 2 additions & 2 deletions sample/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ plugins {
id 'com.android.application'
id 'kotlin-android'
id 'kotlin-kapt'
id 'dagger.hilt.android.plugin' // TODO: Add to resaca-hilt usage documentation
id 'dagger.hilt.android.plugin'
}

android {
Expand Down Expand Up @@ -82,7 +82,7 @@ dependencies {
// Material Design
implementation "androidx.compose.material:material:$versions.composeMaterial"

// Hilt dependencies // TODO: Add to resaca-hilt usage documentation
// Hilt dependencies
implementation "com.google.dagger:hilt-android:$versions.hiltAndroid"
kapt "com.google.dagger:hilt-compiler:$versions.hiltCompiler"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import com.sebaslogen.resacaapp.ui.main.ui.theme.ResacaAppTheme
import dagger.hilt.android.AndroidEntryPoint

const val rememberScopedDestination = "rememberScopedDestination"
const val viewModelScopedDestination = "viewModelScopedDestination"
const val hiltViewModelScopedDestination = "hiltViewModelScopedDestination"


@AndroidEntryPoint // This annotation is required for Hilt to work anywhere inside this Activity
Expand Down Expand Up @@ -59,7 +59,7 @@ fun ScreensWithNavigation(navController: NavHostController = rememberNavControll
composable(rememberScopedDestination) {
ComposeScreenWithRememberScoped(navController)
}
composable(viewModelScopedDestination) {
composable(hiltViewModelScopedDestination) {
ComposeScreenWithViewModelScoped(navController)
}
}
Expand Down Expand Up @@ -99,8 +99,8 @@ fun NavigationButtons(navController: NavHostController) {
Text(text = "Push rememberScoped destination")
}
Button(modifier = Modifier.padding(vertical = 4.dp),
onClick = { navController.navigate(viewModelScopedDestination) }) {
Text(text = "Push Hilt viewModelScoped destination")
onClick = { navController.navigate(hiltViewModelScopedDestination) }) {
Text(text = "Push Hilt hiltViewModelScoped destination")
}
val activity = (LocalContext.current as? Activity)
Button(modifier = Modifier.padding(vertical = 4.dp),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.sebaslogen.resacaapp.ui.main.ComposeActivity
import com.sebaslogen.resacaapp.ui.main.ComposeActivity.Companion.START_DESTINATION
import com.sebaslogen.resacaapp.ui.main.viewModelScopedDestination
import com.sebaslogen.resacaapp.ui.main.hiltViewModelScopedDestination
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
Expand All @@ -26,7 +26,7 @@ class HiltViewModelTests : ComposeTestUtils {

// Given the starting screen with Hilt injected ViewModel scoped that is ONLY shown in light mode
val launchIntent = Intent(ApplicationProvider.getApplicationContext(), ComposeActivity::class.java).apply {
putExtra(START_DESTINATION, viewModelScopedDestination)
putExtra(START_DESTINATION, hiltViewModelScopedDestination)
}
ActivityScenario.launch<ComposeActivity>(launchIntent).use { scenario ->
scenario.onActivity { activity: ComposeActivity ->
Expand All @@ -53,7 +53,7 @@ class HiltViewModelTests : ComposeTestUtils {

// Given the starting screen with Hilt injected ViewModel scoped
val launchIntent = Intent(ApplicationProvider.getApplicationContext(), ComposeActivity::class.java).apply {
putExtra(START_DESTINATION, viewModelScopedDestination)
putExtra(START_DESTINATION, hiltViewModelScopedDestination)
}
ActivityScenario.launch<ComposeActivity>(launchIntent).use { scenario ->
scenario.onActivity { activity: ComposeActivity ->
Expand Down

0 comments on commit 2d7898a

Please sign in to comment.