diff --git a/build.gradle b/build.gradle index 778045728..ddb8a9eb5 100644 --- a/build.gradle +++ b/build.gradle @@ -7,6 +7,7 @@ buildscript { } dependencies { classpath libs.androidGradle + classpath libs.anvilGradle classpath libs.kotlinGradle classpath libs.hiltGradle diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fbcef8f04..8e58e39a1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,6 +10,7 @@ ksp = { id = "com.google.devtools.ksp", version = "1.7.10-1.0.6" } [libraries] androidGradle = "com.android.tools.build:gradle:_" +anvilGradle = "com.squareup.anvil:gradle-plugin:_" autoValue = "com.google.auto.value:auto-value:_" autoValueAnnotations = "com.google.auto.value:auto-value-annotations:_" dagger = "com.google.dagger:dagger:_" diff --git a/sample-anvil/.gitignore b/sample-anvil/.gitignore new file mode 100644 index 000000000..796b96d1c --- /dev/null +++ b/sample-anvil/.gitignore @@ -0,0 +1 @@ +/build diff --git a/sample-anvil/README.md b/sample-anvil/README.md new file mode 100644 index 000000000..7f31483fb --- /dev/null +++ b/sample-anvil/README.md @@ -0,0 +1,13 @@ +# Anvil Sample + +The Anvil sample consists of 3 modules: +* :sample-anvil: The actual application that demonstrates an example features. +* :sample-anvilcodegen: The [anvil-compiler code generator](https://github.com/square/anvil/blob/main/compiler-api/README.md) that allows `@ContributesViewModel` to setup all of the wiring necessary for constructor dependency injection. +* :sample-anvilannotations: Shared annotations between the sample and codegen. + +The Anvil sample demonstrates how to setup a `@ContributesViewModel` annotation that allows you to do constructor injection from Dagger without any additional wiring such as the creation of a Dagger module. The setup requires a bit of infrastructure which is all included in these three modules. However, once it is set up, it dramatically simplifies the usage of ViewModels and Dagger. + +A good starting point is ExampleFeatureFragment. ExampleFeatureFragment and the other classes in that folder demonstrate: +* How to use `@ContributesViewModel` to do constructor injection. +* How to create feature/Fragment scoped Dagger Components that are supported by `@ContributesViewModel`. +* How to set up App/User/Feature Dagger component hierarchies that are commonly used in real world apps. diff --git a/sample-anvil/build.gradle b/sample-anvil/build.gradle new file mode 100644 index 000000000..11bc5a871 --- /dev/null +++ b/sample-anvil/build.gradle @@ -0,0 +1,53 @@ +apply plugin: "com.android.application" +apply plugin: "com.squareup.anvil" +apply plugin: "kotlin-android" +apply plugin: "kotlin-kapt" + +android { + + defaultConfig { + applicationId "com.airbnb.mvrx.sample.anvil" + versionCode 1 + versionName "0.0.1" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + multiDexEnabled true + } + + buildTypes { + release { + minifyEnabled true + signingConfig signingConfigs.debug + } + } + + signingConfigs { + debug { + storeFile file("debug.keystore") + storePassword "testing" + keyAlias "helloDagger" + keyPassword "testing" + } + } + + buildFeatures { + viewBinding true + } +} + +dependencies { + implementation project(":mvrx") + implementation project(":utils-view-binding") + implementation project(":sample-anvilannotations") + + anvil project(':sample-anvilcodegen') + + kapt libs.daggerCompiler + + implementation libs.appcompat + implementation libs.constraintlayout + implementation libs.coreKtx + implementation libs.dagger + implementation libs.fragmentKtx + implementation libs.viewModelKtx + implementation libs.multidex +} diff --git a/sample-anvil/src/main/AndroidManifest.xml b/sample-anvil/src/main/AndroidManifest.xml new file mode 100644 index 000000000..8554bdb5d --- /dev/null +++ b/sample-anvil/src/main/AndroidManifest.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sample-anvil/src/main/java/com/airbnb/mvrx/sample/anvil/AnvilSampleApplication.kt b/sample-anvil/src/main/java/com/airbnb/mvrx/sample/anvil/AnvilSampleApplication.kt new file mode 100644 index 000000000..476dd415f --- /dev/null +++ b/sample-anvil/src/main/java/com/airbnb/mvrx/sample/anvil/AnvilSampleApplication.kt @@ -0,0 +1,23 @@ +package com.airbnb.mvrx.sample.anvil + +import android.app.Application +import com.airbnb.mvrx.Mavericks +import com.airbnb.mvrx.sample.anvil.di.DaggerComponentOwner +import com.airbnb.mvrx.sample.anvil.di.bindings + +class AnvilSampleApplication : Application(), DaggerComponentOwner { + + lateinit var appComponent: AppComponent + // This can be set or unset as users log in and out. + var userComponent: UserComponent? = null + + override val daggerComponent get() = listOfNotNull(appComponent, userComponent) + + override fun onCreate() { + super.onCreate() + appComponent = DaggerAppComponent.create() + // Simulate a logged in user + userComponent = bindings().userComponentBuilder().user(User("Gabriel Peal")).build() + Mavericks.initialize(this) + } +} diff --git a/sample-anvil/src/main/java/com/airbnb/mvrx/sample/anvil/AppComponent.kt b/sample-anvil/src/main/java/com/airbnb/mvrx/sample/anvil/AppComponent.kt new file mode 100644 index 000000000..de79ba966 --- /dev/null +++ b/sample-anvil/src/main/java/com/airbnb/mvrx/sample/anvil/AppComponent.kt @@ -0,0 +1,10 @@ +package com.airbnb.mvrx.sample.anvil + +import com.airbnb.mvrx.sample.anvil.di.SingleIn +import com.squareup.anvil.annotations.MergeComponent + +interface AppScope + +@SingleIn(AppScope::class) +@MergeComponent(AppScope::class) +interface AppComponent diff --git a/sample-anvil/src/main/java/com/airbnb/mvrx/sample/anvil/MainActivity.kt b/sample-anvil/src/main/java/com/airbnb/mvrx/sample/anvil/MainActivity.kt new file mode 100644 index 000000000..795743c23 --- /dev/null +++ b/sample-anvil/src/main/java/com/airbnb/mvrx/sample/anvil/MainActivity.kt @@ -0,0 +1,5 @@ +package com.airbnb.mvrx.sample.anvil + +import androidx.appcompat.app.AppCompatActivity + +class MainActivity : AppCompatActivity(R.layout.activity_main) diff --git a/sample-anvil/src/main/java/com/airbnb/mvrx/sample/anvil/UserComponent.kt b/sample-anvil/src/main/java/com/airbnb/mvrx/sample/anvil/UserComponent.kt new file mode 100644 index 000000000..2e6ed2fab --- /dev/null +++ b/sample-anvil/src/main/java/com/airbnb/mvrx/sample/anvil/UserComponent.kt @@ -0,0 +1,31 @@ +package com.airbnb.mvrx.sample.anvil + +import com.airbnb.mvrx.sample.anvil.di.SingleIn +import com.squareup.anvil.annotations.ContributesTo +import com.squareup.anvil.annotations.MergeSubcomponent +import dagger.BindsInstance +import dagger.Subcomponent + +data class User(val name: String) + +interface UserScope + +@SingleIn(UserScope::class) +@MergeSubcomponent(UserScope::class) +interface UserComponent { + @Subcomponent.Builder + interface Builder { + @BindsInstance + fun user(user: User): Builder + fun build(): UserComponent + } + + /** + * This is a subcomponent of [AppComponent]. This tells [AppComponent] that it needs to be able to + * provide the builder for [UserComponent]. + */ + @ContributesTo(AppScope::class) + interface ParentBindings { + fun userComponentBuilder(): Builder + } +} diff --git a/sample-anvil/src/main/java/com/airbnb/mvrx/sample/anvil/UserScopedRepository.kt b/sample-anvil/src/main/java/com/airbnb/mvrx/sample/anvil/UserScopedRepository.kt new file mode 100644 index 000000000..438c675a2 --- /dev/null +++ b/sample-anvil/src/main/java/com/airbnb/mvrx/sample/anvil/UserScopedRepository.kt @@ -0,0 +1,20 @@ +package com.airbnb.mvrx.sample.anvil + +import com.airbnb.mvrx.sample.anvil.di.SingleIn +import kotlinx.coroutines.delay +import javax.inject.Inject + +/** + * This doesn't need to be a singleton in the user component but is done just to demonstrate + * how to create singletons with [SingleIn]. + */ +@SingleIn(UserScope::class) +class UserScopedRepository @Inject constructor( + private val user: User, +) { + + suspend operator fun invoke(): String { + delay(2000) + return "Hello World, ${user.name}!" + } +} diff --git a/sample-anvil/src/main/java/com/airbnb/mvrx/sample/anvil/di/AssistedViewModelFactory.kt b/sample-anvil/src/main/java/com/airbnb/mvrx/sample/anvil/di/AssistedViewModelFactory.kt new file mode 100644 index 000000000..748d51d85 --- /dev/null +++ b/sample-anvil/src/main/java/com/airbnb/mvrx/sample/anvil/di/AssistedViewModelFactory.kt @@ -0,0 +1,55 @@ +package com.airbnb.mvrx.sample.anvil.di + +import com.airbnb.mvrx.MavericksState +import com.airbnb.mvrx.MavericksViewModel + +/** + * You do not need to use this type directly. It is used by DaggerMavericksViewModelFactory. + * + * Serves as a supertype for AssistedInject factories in ViewModels. + * + * We use this interface as a marker in a Multibinding Dagger setup to populate a Map + * of ViewModel classes with their AssistedInject factories. + * + * First we define our ViewModel with an @AssistedInject annotated constructor, and a Factory interface + * implementing AssistedViewModelFactory. + * + * class MyViewModel @AssistedInject constructor( + * @Assisted initialState: MyState, + * … + * ): MavericksViewModel(...) { + * @AssistedFactory + * interface Factory : AssistedViewModelFactory { + * override fun create(state: MyState): MyViewModel + * } + * } + * + * Then we need to create a Dagger Module, which contains methods that @Binds @IntoMap all of our + * AssistedViewModelFactories using a [ViewModelKey]. Notice that the input to these methods is + * the exact type of our AssistedInject factory, but the return type is an AssistedViewModelFactory. + * + * @Module + * interface MyAppModule { + * @Binds + * @IntoMap + * @ViewModelKey(MyViewModel::class) + * fun myViewModelFactory(factory: MyViewModel.Factory): AssistedViewModelFactory<*, *> + * } + * + * This Module tells Dagger to include MyViewModel.Factory class in the Multibinding map using + * MyViewModel::class as the key. Such a method should be added for **every ViewModel Factory** + * so that they can be identified by Dagger and used for populating the Map. + * + * The generated map can then be injected wherever it is required. + * + * interface AppComponent { + * fun viewModelFactories(): Map>, AssistedViewModelFactory<*, *>> + * } + * + * class SomeClass @Inject constructor( + * val viewModelFactories: Map>, AssistedViewModelFactory<*, *>> + * ) + */ +interface AssistedViewModelFactory, S : MavericksState> { + fun create(state: S): VM +} diff --git a/sample-anvil/src/main/java/com/airbnb/mvrx/sample/anvil/di/Bindings.kt b/sample-anvil/src/main/java/com/airbnb/mvrx/sample/anvil/di/Bindings.kt new file mode 100644 index 000000000..f6c7374fd --- /dev/null +++ b/sample-anvil/src/main/java/com/airbnb/mvrx/sample/anvil/di/Bindings.kt @@ -0,0 +1,53 @@ +package com.airbnb.mvrx.sample.anvil.di + +import android.content.Context +import android.content.ContextWrapper +import androidx.fragment.app.Fragment + +/** + * Use this to get the Dagger "Bindings" for your module. Bindings are used if you need to directly interact with a dagger component such as: + * * an inject function: `inject(MyFragment frag)` + * * an explicit getter: `fun myClass(): MyClass` + * + * Anvil will make your Dagger component implement these bindings so that you can call any of these functions on an instance of your component. + * + * [bindings] will walk up the Fragment/Activity hierarchy and check for [DaggerComponentOwner] to see if any of its components implement the + * specified bindings. Most of the time this will "just work" and you don't have to think about it. + * + * For example, if your class has @Inject properties: + * 1) Create an bindings interface such as `YourModuleBindings` + * 1) Add an inject function like `fun inject(yourClass: YourClass)` + * 2) Contribute your interface to the correct component via `@ContributesTo(AppScope::class)`. + * 3) Call bindings().inject(this). + */ +inline fun Context.bindings() = bindings(T::class.java) + +/** + * @see bindings + */ +inline fun Fragment.bindings() = bindings(T::class.java) + +/** Use no-arg extension function instead: [Context.bindings] */ +fun Context.bindings(klass: Class): T { + // search dagger components in the context hierarchy + return generateSequence(this) { (it as? ContextWrapper)?.baseContext } + .plus(applicationContext) + .filterIsInstance() + .map { it.daggerComponent } + .flatMap { if (it is Collection<*>) it else listOf(it) } + .filterIsInstance(klass) + .firstOrNull() + ?: error("Unable to find bindings for ${klass.name}") +} + +/** Use no-arg extension function instead: [Fragment.bindings] */ +fun Fragment.bindings(klass: Class): T { + // search dagger components in fragment hierarchy, then fallback to activity and application + return generateSequence(this, Fragment::getParentFragment) + .filterIsInstance() + .map { it.daggerComponent } + .flatMap { if (it is Collection<*>) it else listOf(it) } + .filterIsInstance(klass) + .firstOrNull() + ?: requireActivity().bindings(klass) +} diff --git a/sample-anvil/src/main/java/com/airbnb/mvrx/sample/anvil/di/DaggerComponentOwner.kt b/sample-anvil/src/main/java/com/airbnb/mvrx/sample/anvil/di/DaggerComponentOwner.kt new file mode 100644 index 000000000..95dfa3a39 --- /dev/null +++ b/sample-anvil/src/main/java/com/airbnb/mvrx/sample/anvil/di/DaggerComponentOwner.kt @@ -0,0 +1,19 @@ +package com.airbnb.mvrx.sample.anvil.di + +/** + * A [DaggerComponentOwner] is anything that "owns" a Dagger Component. The owner can be a Application, Activity, or Fragment. + * + * When using [bindings] or when creating a ViewModel, the infrastructure provided will walk up the Fragment tree, then check + * the Activity, then the Application for any [DaggerComponentOwner] that can provide ViewModels. + * + * In this sample: + * * [com.airbnb.mvrx.sample.anvil.AppComponent] is owned by the Application class. + * * [com.airbnb.mvrx.sample.anvil.UserComponent] is subcomponent of AppComponent and injected with the current logged in user. + * It is set/cleared in the Application class. + * * [com.airbnb.mvrx.sample.anvil.ExampleFeatureComponent] is a subcomponent of UserComponent and + * owned by [com.airbnb.mvrx.sample.anvil.ExampleFeatureFragment]. + */ +interface DaggerComponentOwner { + /** This is either a component, or a list of components. */ + val daggerComponent: Any +} diff --git a/sample-anvil/src/main/java/com/airbnb/mvrx/sample/anvil/di/DaggerMavericksViewModelFactory.kt b/sample-anvil/src/main/java/com/airbnb/mvrx/sample/anvil/di/DaggerMavericksViewModelFactory.kt new file mode 100644 index 000000000..229f89066 --- /dev/null +++ b/sample-anvil/src/main/java/com/airbnb/mvrx/sample/anvil/di/DaggerMavericksViewModelFactory.kt @@ -0,0 +1,68 @@ +package com.airbnb.mvrx.sample.anvil.di + +import com.airbnb.mvrx.FragmentViewModelContext +import com.airbnb.mvrx.MavericksState +import com.airbnb.mvrx.MavericksViewModel +import com.airbnb.mvrx.MavericksViewModelFactory +import com.airbnb.mvrx.ViewModelContext + +/** + * To connect Mavericks ViewModel creation with Anvil's dependency injection, add the following to your MavericksViewModel. + * + * Example: + * + * @ContributesViewModel(YourScope::class) + * class MyViewModel @AssistedInject constructor( + * @Assisted initialState: MyState, + * …, + * ): MavericksViewModel(...) { + * … + * + * companion object : MavericksViewModelFactory by daggerMavericksViewModelFactory() + * } + */ + +inline fun , S : MavericksState> daggerMavericksViewModelFactory() = DaggerMavericksViewModelFactory(VM::class.java) + +/** + * A [MavericksViewModelFactory] makes it easy to create instances of a ViewModel + * using its AssistedInject Factory. This class should be implemented by the companion object + * of every ViewModel which uses AssistedInject via [daggerMavericksViewModelFactory]. + * + * @param viewModelClass The [Class] of the ViewModel being requested for creation + * + * This class accesses the map of ViewModel class to [AssistedViewModelFactory]s from the nearest [DaggerComponentOwner] and + * uses it to retrieve the requested ViewModel's factory class. It then creates an instance of this ViewModel + * using the retrieved factory and returns it. + * @see daggerMavericksViewModelFactory + */ +class DaggerMavericksViewModelFactory, S : MavericksState>( + private val viewModelClass: Class +) : MavericksViewModelFactory { + + override fun create(viewModelContext: ViewModelContext, state: S): VM { + val bindings: DaggerMavericksBindings = when (viewModelContext) { + is FragmentViewModelContext -> viewModelContext.fragment.bindings() + else -> viewModelContext.activity.bindings() + } + val viewModelFactoryMap = bindings.viewModelFactories() + val viewModelFactory = viewModelFactoryMap[viewModelClass] ?: error("Cannot find ViewModelFactory for ${viewModelClass.name}.") + + @Suppress("UNCHECKED_CAST") + val castedViewModelFactory = viewModelFactory as? AssistedViewModelFactory + val viewModel = castedViewModelFactory?.create(state) + return viewModel as VM + } +} + +/** + * These Anvil/Dagger bindings are used by [DaggerMavericksViewModelFactory]. The factory will find the nearest [DaggerComponentOwner] + * that implements these bindings. It will then attempt to retrieve the [AssistedViewModelFactory] for the given ViewModel class. + * + * In this example, this bindings class is implemented by [com.airbnb.mvrx.sample.anvil.feature.ExampleFeatureComponent] because + * it provides the [com.airbnb.mvrx.sample.anvil.feature.ExampleFeatureViewModel]. Any component that will generate ViewModels should + * either implement this directly or have this added via `@ContributesTo(YourScope::class)`. + */ +interface DaggerMavericksBindings { + fun viewModelFactories(): Map>, AssistedViewModelFactory<*, *>> +} diff --git a/sample-anvil/src/main/java/com/airbnb/mvrx/sample/anvil/di/FragmentComponent.kt b/sample-anvil/src/main/java/com/airbnb/mvrx/sample/anvil/di/FragmentComponent.kt new file mode 100644 index 000000000..05275afae --- /dev/null +++ b/sample-anvil/src/main/java/com/airbnb/mvrx/sample/anvil/di/FragmentComponent.kt @@ -0,0 +1,43 @@ +package com.airbnb.mvrx.sample.anvil.di + +import android.app.Application +import androidx.fragment.app.Fragment +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.airbnb.mvrx.sample.anvil.AnvilSampleApplication +import kotlinx.coroutines.CoroutineScope +import java.util.concurrent.ConcurrentHashMap + +/** + * Use this property delegate to create a DaggerComponent scoped to a Fragment. + * + * The factory lambda will be given Application as well as a CoroutineScope that will have the same lifecycle as this component. + * + * In the factory, return the instance of the Dagger component. Most likely, it will look something like: + * ``` + * app.bindings().createMyComponent() + * ``` + * + * The returned component will be stored inside of a backing Jetpack ViewModel and will have the equivalent lifecycle as it. + * That means that during configuration changes or while on the back stack, your Dagger component will continue to operate. + * When the Fragment is destroyed for the last time (equivalent to ViewModel.onCleared()), the provided CoroutineScope will be canceled. + * + * It may be convenient to bind the CoroutineScope as an instance in your Dagger component so it can be injected into singleton objects. + */ +inline fun Fragment.fragmentComponent( + crossinline factory: (CoroutineScope, AnvilSampleApplication) -> T +) = lazy { + ViewModelProvider(this)[DaggerComponentHolderViewModel::class.java].get(factory) +} + +/** + * @see fragmentComponent + */ +class DaggerComponentHolderViewModel(app: Application) : AndroidViewModel(app) { + val map = ConcurrentHashMap, Any>() + + inline fun get(factory: (CoroutineScope, AnvilSampleApplication) -> T): T { + return map.getOrPut(T::class.java) { factory(viewModelScope, getApplication()) } as T + } +} diff --git a/sample-anvil/src/main/java/com/airbnb/mvrx/sample/anvil/di/SingleIn.kt b/sample-anvil/src/main/java/com/airbnb/mvrx/sample/anvil/di/SingleIn.kt new file mode 100644 index 000000000..cc99b9a52 --- /dev/null +++ b/sample-anvil/src/main/java/com/airbnb/mvrx/sample/anvil/di/SingleIn.kt @@ -0,0 +1,34 @@ +package com.airbnb.mvrx.sample.anvil.di + +import javax.inject.Scope +import kotlin.reflect.KClass + +/** + * This annotation lets you use the same annotation to represent which scope you want to contribute an Anvil object to + * and also as `@SingleIn(YourScope::class)`. + * + * Without `@SingleIn`, an AppComponent contribution might look like this: + * ``` + * @Singleton + * @ContributesBinding(AppScope::class) + * class YourClassImpl : YourClass + * ``` + * Singleton is a well defined pattern for AppScope but the scope naming becomes more confusing once you start defining your + * own components. + * `@SingleIn` prevents you from memorizing two names per component. The above example becomes: + * ``` + * @SingleIn(AppScope::class) + * @ContributesBinding(AppScope::class) + * class YourClassImpl : YourClass + * ``` + * + * And custom components would look like: + * ``` + * @SingleIn(YourScope::class) + * @ContributesBinding(YourScope::class) + * class YourClassImpl : YourClass + * ``` + */ +@Scope +@Retention(AnnotationRetention.RUNTIME) +annotation class SingleIn(val clazz: KClass<*>) diff --git a/sample-anvil/src/main/java/com/airbnb/mvrx/sample/anvil/di/ViewModelKey.kt b/sample-anvil/src/main/java/com/airbnb/mvrx/sample/anvil/di/ViewModelKey.kt new file mode 100644 index 000000000..8eff73db4 --- /dev/null +++ b/sample-anvil/src/main/java/com/airbnb/mvrx/sample/anvil/di/ViewModelKey.kt @@ -0,0 +1,13 @@ +package com.airbnb.mvrx.sample.anvil.di + +import com.airbnb.mvrx.MavericksViewModel +import dagger.MapKey +import kotlin.reflect.KClass + +/** + * A [MapKey] for populating a map of ViewModels and their factories. + */ +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.FUNCTION) +@MapKey +annotation class ViewModelKey(val value: KClass>) diff --git a/sample-anvil/src/main/java/com/airbnb/mvrx/sample/anvil/feature/ExampleFeatureComponent.kt b/sample-anvil/src/main/java/com/airbnb/mvrx/sample/anvil/feature/ExampleFeatureComponent.kt new file mode 100644 index 000000000..c110fcde2 --- /dev/null +++ b/sample-anvil/src/main/java/com/airbnb/mvrx/sample/anvil/feature/ExampleFeatureComponent.kt @@ -0,0 +1,51 @@ +package com.airbnb.mvrx.sample.anvil.feature + +import com.airbnb.mvrx.sample.anvil.UserComponent +import com.airbnb.mvrx.sample.anvil.UserScope +import com.airbnb.mvrx.sample.anvil.di.DaggerMavericksBindings +import com.airbnb.mvrx.sample.anvil.di.SingleIn +import com.squareup.anvil.annotations.ContributesTo +import com.squareup.anvil.annotations.MergeSubcomponent +import dagger.BindsInstance +import dagger.Subcomponent +import kotlinx.coroutines.CoroutineScope + +/** + * This should be used as the scope for any `@SingleIn(ExampleFeatureScope::class)` objects. + * + * The reason this is a named class rather than just binding `CoroutineScope` directly is that there will likely + * be a CoroutineScope associated with each Dagger component in the hierarchy (AppComponent, UserComponent, ExampleFeatureComponent). + * Using a named class is the best way to be explicit and ensure that there aren't duplicate bindings and that the correct + * scope is always used. + */ +class ExampleFeatureCoroutineScope(private val parentScope: CoroutineScope) : CoroutineScope by parentScope + +interface ExampleFeatureScope + +/** + * Any component that provides ViewModels via [com.airbnb.mvrx.sample.anvil.annotation.ContributesViewModel] should + * implement [DaggerMavericksBindings]. + */ +@SingleIn(ExampleFeatureScope::class) +@MergeSubcomponent(ExampleFeatureScope::class) +interface ExampleFeatureComponent : DaggerMavericksBindings { + @Subcomponent.Builder + interface Builder { + /** + * This CoroutineScope will have the same lifecycle as this component. Any objects annotated with + * `@SingleIn(ExampleFeatureScope::class)` that need a CoroutineScope should use this. + */ + @BindsInstance + fun coroutineScope(coroutineScope: ExampleFeatureCoroutineScope): Builder + fun build(): ExampleFeatureComponent + } + + /** + * This is a subcomponent of [UserComponent]. This tells [UserComponent] that it needs to be able to + * provide the builder for [ExampleFeatureCoroutineScope]. + */ + @ContributesTo(UserScope::class) + interface ParentBindings { + fun exampleFeatureComponentBuilder(): Builder + } +} diff --git a/sample-anvil/src/main/java/com/airbnb/mvrx/sample/anvil/feature/ExampleFeatureFragment.kt b/sample-anvil/src/main/java/com/airbnb/mvrx/sample/anvil/feature/ExampleFeatureFragment.kt new file mode 100644 index 000000000..8d11341cb --- /dev/null +++ b/sample-anvil/src/main/java/com/airbnb/mvrx/sample/anvil/feature/ExampleFeatureFragment.kt @@ -0,0 +1,90 @@ +package com.airbnb.mvrx.sample.anvil.feature + +import androidx.fragment.app.Fragment +import com.airbnb.mvrx.Async +import com.airbnb.mvrx.Fail +import com.airbnb.mvrx.Loading +import com.airbnb.mvrx.MavericksState +import com.airbnb.mvrx.MavericksView +import com.airbnb.mvrx.MavericksViewModel +import com.airbnb.mvrx.MavericksViewModelFactory +import com.airbnb.mvrx.Success +import com.airbnb.mvrx.Uninitialized +import com.airbnb.mvrx.fragmentViewModel +import com.airbnb.mvrx.sample.anvil.R +import com.airbnb.mvrx.sample.anvil.UserScopedRepository +import com.airbnb.mvrx.sample.anvil.annotation.ContributesViewModel +import com.airbnb.mvrx.sample.anvil.databinding.HelloFragmentBinding +import com.airbnb.mvrx.sample.anvil.di.DaggerComponentOwner +import com.airbnb.mvrx.sample.anvil.di.bindings +import com.airbnb.mvrx.sample.anvil.di.daggerMavericksViewModelFactory +import com.airbnb.mvrx.sample.anvil.di.fragmentComponent +import com.airbnb.mvrx.viewbinding.viewBinding +import com.airbnb.mvrx.withState +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject + +data class ExampleFeatureState( + val title: Async = Uninitialized, + val description: Async = Uninitialized, +) : MavericksState + +/** + * The following code and the companion object contain everything needed to wire up constructor injection with Anvil. + * + * The `sample-anvilcodegen` module contains the code generation that happens for [ContributesViewModel]. + * + * Note that this ViewModel is created in [ExampleFeatureFragment] which is a [DaggerComponentOwner] for [ExampleFeatureComponent] which means + * that this can inject anything from [ExampleFeatureComponent] as well. And because [ExampleFeatureComponent] is a subcomponent of + * [com.airbnb.mvrx.sample.anvil.UserComponent] and [com.airbnb.mvrx.sample.anvil.AppComponent], it can inject anything from those components as well. + */ +@ContributesViewModel(ExampleFeatureScope::class) +class ExampleFeatureViewModel @AssistedInject constructor( + @Assisted initialState: ExampleFeatureState, + userScopedRepo: UserScopedRepository, + featureScopedRepo: ExampleFeatureScopedRepository, +) : MavericksViewModel(initialState) { + + init { + suspend { + userScopedRepo() + }.execute { copy(title = it) } + suspend { + featureScopedRepo() + }.execute { copy(description = it) } + } + + companion object : MavericksViewModelFactory by daggerMavericksViewModelFactory() +} + +class ExampleFeatureFragment : Fragment(R.layout.hello_fragment), MavericksView, DaggerComponentOwner { + private val binding: HelloFragmentBinding by viewBinding() + private val viewModel: ExampleFeatureViewModel by fragmentViewModel() + + /** + * We are using this Fragment as the owner of a Dagger Component. In a real world example, this Fragment + * could have child fragments and/or be a container for an entire flow or large feature. + * With the [bindings] methods, any ViewModels for this fragment or any child fragments can inject objects + * from this component. + * + * If you don't need a custom dagger component for your Fragment, you can omit this and the [DaggerComponentOwner] interface entirely. + * In that case, you could contribute the ViewModel to the user or app component depending on what is available from + * parent fragments/Activity/Application. + */ + override val daggerComponent by fragmentComponent { scope, _ -> + // Note: use `requireActivity().bindings` not `bindings` here or else you will wind up with a StackOverflow in which this + // Fragment which is a DaggerComponentOwner will keep searching itself over and over. + requireActivity().bindings().exampleFeatureComponentBuilder() + .coroutineScope(ExampleFeatureCoroutineScope(scope)) + .build() + } + + override fun invalidate() = withState(viewModel) { state -> + requireActivity().title = state.description() + binding.title.text = when (state.title) { + is Uninitialized, is Loading -> getString(R.string.hello_fragment_loading_text) + is Success -> state.title() + is Fail -> getString(R.string.hello_fragment_failure_text) + } + } +} diff --git a/sample-anvil/src/main/java/com/airbnb/mvrx/sample/anvil/feature/ExampleFeatureScopedRepository.kt b/sample-anvil/src/main/java/com/airbnb/mvrx/sample/anvil/feature/ExampleFeatureScopedRepository.kt new file mode 100644 index 000000000..602f807a3 --- /dev/null +++ b/sample-anvil/src/main/java/com/airbnb/mvrx/sample/anvil/feature/ExampleFeatureScopedRepository.kt @@ -0,0 +1,10 @@ +package com.airbnb.mvrx.sample.anvil.feature + +import com.airbnb.mvrx.sample.anvil.di.SingleIn +import javax.inject.Inject + +@SingleIn(ExampleFeatureScope::class) +class ExampleFeatureScopedRepository @Inject constructor() { + @Suppress("FunctionOnlyReturningConstant") + operator fun invoke() = "Example Feature" +} diff --git a/sample-anvil/src/main/res/drawable-v24/ic_launcher_foreground.xml b/sample-anvil/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 000000000..1f6bb2906 --- /dev/null +++ b/sample-anvil/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + diff --git a/sample-anvil/src/main/res/drawable/ic_launcher_background.xml b/sample-anvil/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 000000000..0d025f9bf --- /dev/null +++ b/sample-anvil/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sample-anvil/src/main/res/layout/activity_main.xml b/sample-anvil/src/main/res/layout/activity_main.xml new file mode 100644 index 000000000..8b461458f --- /dev/null +++ b/sample-anvil/src/main/res/layout/activity_main.xml @@ -0,0 +1,6 @@ + + diff --git a/sample-anvil/src/main/res/layout/hello_fragment.xml b/sample-anvil/src/main/res/layout/hello_fragment.xml new file mode 100644 index 000000000..9b7a15f90 --- /dev/null +++ b/sample-anvil/src/main/res/layout/hello_fragment.xml @@ -0,0 +1,21 @@ + + + + + + diff --git a/sample-anvil/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/sample-anvil/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..eca70cfe5 --- /dev/null +++ b/sample-anvil/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/sample-anvil/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/sample-anvil/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 000000000..eca70cfe5 --- /dev/null +++ b/sample-anvil/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/sample-anvil/src/main/res/mipmap-hdpi/ic_launcher.png b/sample-anvil/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..898f3ed59 Binary files /dev/null and b/sample-anvil/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/sample-anvil/src/main/res/mipmap-hdpi/ic_launcher_round.png b/sample-anvil/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 000000000..dffca3601 Binary files /dev/null and b/sample-anvil/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/sample-anvil/src/main/res/mipmap-mdpi/ic_launcher.png b/sample-anvil/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..64ba76f75 Binary files /dev/null and b/sample-anvil/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/sample-anvil/src/main/res/mipmap-mdpi/ic_launcher_round.png b/sample-anvil/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 000000000..dae5e0823 Binary files /dev/null and b/sample-anvil/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/sample-anvil/src/main/res/mipmap-xhdpi/ic_launcher.png b/sample-anvil/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..e5ed46597 Binary files /dev/null and b/sample-anvil/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/sample-anvil/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/sample-anvil/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 000000000..14ed0af35 Binary files /dev/null and b/sample-anvil/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/sample-anvil/src/main/res/mipmap-xxhdpi/ic_launcher.png b/sample-anvil/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..b0907cac3 Binary files /dev/null and b/sample-anvil/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/sample-anvil/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/sample-anvil/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 000000000..d8ae03154 Binary files /dev/null and b/sample-anvil/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/sample-anvil/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/sample-anvil/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..2c18de9e6 Binary files /dev/null and b/sample-anvil/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/sample-anvil/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/sample-anvil/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 000000000..beed3cdd2 Binary files /dev/null and b/sample-anvil/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/sample-anvil/src/main/res/values/colors.xml b/sample-anvil/src/main/res/values/colors.xml new file mode 100644 index 000000000..69b22338c --- /dev/null +++ b/sample-anvil/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + + #008577 + #00574B + #D81B60 + diff --git a/sample-anvil/src/main/res/values/strings.xml b/sample-anvil/src/main/res/values/strings.xml new file mode 100644 index 000000000..e230fa2c4 --- /dev/null +++ b/sample-anvil/src/main/res/values/strings.xml @@ -0,0 +1,6 @@ + + Hello Anvil + Say Hello + Loading… + Loading failed + diff --git a/sample-anvil/src/main/res/values/styles.xml b/sample-anvil/src/main/res/values/styles.xml new file mode 100644 index 000000000..5885930df --- /dev/null +++ b/sample-anvil/src/main/res/values/styles.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/sample-anvilannotations/build.gradle b/sample-anvilannotations/build.gradle new file mode 100644 index 000000000..2bb0a22ba --- /dev/null +++ b/sample-anvilannotations/build.gradle @@ -0,0 +1,5 @@ +apply plugin: 'org.jetbrains.kotlin.jvm' + +dependencies { + api 'javax.inject:javax.inject:1' +} \ No newline at end of file diff --git a/sample-anvilannotations/src/main/java/com/airbnb/mvrx/sample/anvil/annotation/ContributesViewModel.kt b/sample-anvilannotations/src/main/java/com/airbnb/mvrx/sample/anvil/annotation/ContributesViewModel.kt new file mode 100644 index 000000000..0880004c1 --- /dev/null +++ b/sample-anvilannotations/src/main/java/com/airbnb/mvrx/sample/anvil/annotation/ContributesViewModel.kt @@ -0,0 +1,17 @@ +package com.airbnb.mvrx.sample.anvil.annotation + +import kotlin.reflect.KClass + +/** + * Adds view model to the specified component graph. + * Equivalent to the following declaration in a dagger module: + * + * @Binds + * @IntoMap + * @ViewModelKey(YourViewModel::class) + * public abstract fun bindYourViewModelFactory(factory: YourViewModel.Factory): AssistedViewModelFactory<*, *> + */ +@Target(AnnotationTarget.CLASS) +annotation class ContributesViewModel( + val scope: KClass<*>, +) diff --git a/sample-anvilcodegen/build.gradle b/sample-anvilcodegen/build.gradle new file mode 100644 index 000000000..88373336c --- /dev/null +++ b/sample-anvilcodegen/build.gradle @@ -0,0 +1,20 @@ +apply plugin: 'org.jetbrains.kotlin.jvm' +apply plugin: 'org.jetbrains.kotlin.kapt' + +tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { + kotlinOptions { + freeCompilerArgs += [ + "-opt-in=com.squareup.anvil.annotations.ExperimentalAnvilApi"] + } +} + +dependencies { + api "com.squareup.anvil:compiler-api:2.4.0" + implementation project(':sample-anvilannotations') + implementation "com.squareup.anvil:compiler-utils:2.4.0" + implementation "com.squareup:kotlinpoet:1.10.2" + implementation 'com.google.dagger:dagger:2.42' + + compileOnly "com.google.auto.service:auto-service-annotations:1.0.1" + kapt "com.google.auto.service:auto-service:1.0.1" +} diff --git a/sample-anvilcodegen/src/main/java/com/airbnb/mvrx/sample/anvil/codegen/ContributesViewModelCodeGenerator.kt b/sample-anvilcodegen/src/main/java/com/airbnb/mvrx/sample/anvil/codegen/ContributesViewModelCodeGenerator.kt new file mode 100644 index 000000000..f7978b308 --- /dev/null +++ b/sample-anvilcodegen/src/main/java/com/airbnb/mvrx/sample/anvil/codegen/ContributesViewModelCodeGenerator.kt @@ -0,0 +1,119 @@ +package com.airbnb.mvrx.sample.anvil.codegen + +import com.airbnb.mvrx.sample.anvil.annotation.ContributesViewModel +import com.google.auto.service.AutoService +import com.squareup.anvil.annotations.ContributesTo +import com.squareup.anvil.compiler.api.AnvilCompilationException +import com.squareup.anvil.compiler.api.AnvilContext +import com.squareup.anvil.compiler.api.CodeGenerator +import com.squareup.anvil.compiler.api.GeneratedFile +import com.squareup.anvil.compiler.api.createGeneratedFile +import com.squareup.anvil.compiler.internal.asClassName +import com.squareup.anvil.compiler.internal.buildFile +import com.squareup.anvil.compiler.internal.fqName +import com.squareup.anvil.compiler.internal.reference.ClassReference +import com.squareup.anvil.compiler.internal.reference.asClassName +import com.squareup.anvil.compiler.internal.reference.classAndInnerClassReferences +import com.squareup.kotlinpoet.AnnotationSpec +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import com.squareup.kotlinpoet.STAR +import com.squareup.kotlinpoet.TypeSpec +import dagger.Binds +import dagger.Module +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.multibindings.IntoMap +import org.jetbrains.kotlin.descriptors.ModuleDescriptor +import org.jetbrains.kotlin.name.FqName +import org.jetbrains.kotlin.psi.KtFile +import java.io.File + +/** + * This is an anvil plugin that allows ViewModels to use [ContributesViewModel] alone and let this plugin automatically + * handle the rest of the Dagger wiring required for constructor injection. + */ +@AutoService(CodeGenerator::class) +class ContributesViewModelCodeGenerator : CodeGenerator { + + override fun isApplicable(context: AnvilContext): Boolean = true + + override fun generateCode(codeGenDir: File, module: ModuleDescriptor, projectFiles: Collection): Collection { + return projectFiles.classAndInnerClassReferences(module) + .filter { it.isAnnotatedWith(ContributesViewModel::class.fqName) } + .flatMap { listOf(generateModule(it, codeGenDir, module), generateAssistedFactory(it, codeGenDir, module)) } + .toList() + } + + private fun generateModule(vmClass: ClassReference.Psi, codeGenDir: File, module: ModuleDescriptor): GeneratedFile { + val generatedPackage = vmClass.packageFqName.toString() + val moduleClassName = "${vmClass.shortName}_Module" + val scope = vmClass.annotations.single { it.fqName == ContributesViewModel::class.fqName }.scope() + val content = FileSpec.buildFile(generatedPackage, moduleClassName) { + addType( + TypeSpec.classBuilder(moduleClassName) + .addModifiers(KModifier.ABSTRACT) + .addAnnotation(Module::class) + .addAnnotation(AnnotationSpec.builder(ContributesTo::class).addMember("%T::class", scope.asClassName()).build()) + .addFunction( + FunSpec.builder("bind${vmClass.shortName}Factory") + .addModifiers(KModifier.ABSTRACT) + .addParameter("factory", ClassName(generatedPackage, "${vmClass.shortName}_AssistedFactory")) + .returns(assistedViewModelFactoryFqName.asClassName(module).parameterizedBy(STAR, STAR)) + .addAnnotation(Binds::class) + .addAnnotation(IntoMap::class) + .addAnnotation(AnnotationSpec.Companion.builder(viewModelKeyFqName.asClassName(module)).addMember("%T::class", vmClass.asClassName()).build()) + .build(), + ) + .build(), + ) + } + return createGeneratedFile(codeGenDir, generatedPackage, moduleClassName, content) + } + + private fun generateAssistedFactory(vmClass: ClassReference.Psi, codeGenDir: File, module: ModuleDescriptor): GeneratedFile { + val generatedPackage = vmClass.packageFqName.toString() + val assistedFactoryClassName = "${vmClass.shortName}_AssistedFactory" + val constructor = vmClass.constructors.singleOrNull { it.isAnnotatedWith(AssistedInject::class.fqName) } + val assistedParameter = constructor?.parameters?.singleOrNull { it.isAnnotatedWith(Assisted::class.fqName) } + if (constructor == null || assistedParameter == null) { + throw AnvilCompilationException( + "${vmClass.fqName} must have an @AssistedInject constructor with @Assisted initialState: S parameter", + element = vmClass.clazz, + ) + } + if (assistedParameter.name != "initialState") { + throw AnvilCompilationException( + "${vmClass.fqName} @Assisted parameter must be named initialState", + element = assistedParameter.parameter, + ) + } + val vmClassName = vmClass.asClassName() + val stateClassName = assistedParameter.type().asTypeName() + val content = FileSpec.buildFile(generatedPackage, assistedFactoryClassName) { + addType( + TypeSpec.interfaceBuilder(assistedFactoryClassName) + .addSuperinterface(assistedViewModelFactoryFqName.asClassName(module).parameterizedBy(vmClassName, stateClassName)) + .addAnnotation(AssistedFactory::class) + .addFunction( + FunSpec.builder("create") + .addModifiers(KModifier.OVERRIDE, KModifier.ABSTRACT) + .addParameter("initialState", stateClassName) + .returns(vmClassName) + .build(), + ) + .build(), + ) + } + return createGeneratedFile(codeGenDir, generatedPackage, assistedFactoryClassName, content) + } + + companion object { + private val assistedViewModelFactoryFqName = FqName("com.airbnb.mvrx.sample.anvil.di.AssistedViewModelFactory") + private val viewModelKeyFqName = FqName("com.airbnb.mvrx.sample.anvil.di.ViewModelKey") + } +} diff --git a/settings.gradle b/settings.gradle index 120e0e672..0a1c3d903 100644 --- a/settings.gradle +++ b/settings.gradle @@ -20,6 +20,9 @@ include ':utils-view-binding' include ':sample-counter' include ':sample-dogs' include ':sample-dagger' +include ':sample-anvil' +include ':sample-anvilcodegen' +include ':sample-anvilannotations' include ':sample-hilt' include ':sample' include ':sample-compose' diff --git a/versions.properties b/versions.properties index 943225431..8febd5c52 100644 --- a/versions.properties +++ b/versions.properties @@ -184,6 +184,8 @@ version.androidx.activity=1.5.1 plugin.io.gitlab.arturbosch.detekt=1.21.0 +version.anvil=2.4.2 + plugin.android=7.2.2 ## # available=7.3.0-alpha01 ## # available=7.3.0-alpha02