google / dagger

A fast dependency injector for Android and Java.
https://dagger.dev
Apache License 2.0
17.44k stars 2.01k forks source link

[Hilt] Question: How to create a subcomponent of a custom component that has a Builder #2551

Closed philips77 closed 3 years ago

philips77 commented 3 years ago

We're using Hilt in version 2.34.1-beta.

We have 2 components:

@LoggedUserScope
@DefineComponent(parent = SingletonComponent::class)
interface UserComponent {

    @DefineComponent.Builder
    interface Builder {
        fun setUser(@BindsInstance user: User): Builder
        fun setToken(@BindsInstance string: String): Builder
        fun build(): UserComponent
    }
}

@ProjectScope
@DefineComponent(parent = UserComponent::class)
interface ProjectComponent {

    @DefineComponent.Builder
    interface Builder {
        fun setProject(@BindsInstance project: Project): Builder
        fun build(): ProjectComponent 
    }
}

The UserComponent, without ProjectComponent works fine. We are able to get the instance using EntryPoint. Also, when the ProjectComponent has a parent set to SingletonComponent, everything is OK. But we'd like to have them nested. A logged user can choose a project, but after logging out projects are not available.

With the above code the compiler screams that we need @Provide-annotated method for the ProjectComponent.Builder. As I understand it correctly, the ProjectComponent needs to know the instance of UserComponent to be initialized. How can it be provided? All samples we found are using a parent component without any parameters, so the compiler can instantiate it automagically.

CC: @roshanrajaratnam

Chang-Eric commented 3 years ago

It is hard to say without the error message, but I think what is happening is you are injecting the ProjectComponent.Builder outside of the UserComponent.

the ProjectComponent needs to know the instance of UserComponent to be initialized. How can it be provided?

All DefineComponent types use subcomponents, so the way the ProjectComponent gets the UserComponent instance is you have to get the ProjectComponent.Builder from an instance of UserComponent, either by injecting it in an object that came from the UserComponent (most common) or using an entry point with an instance of the UserComponent (less common).

philips77 commented 3 years ago

Thank you for the response. Is there any example demonstrating this? So far we were trying to get ProjectComponent.Builder injected, like we do with UserComponent, but of course this does not work.

@Singleton
class UserManager @Inject constructor(
    // Since UserManager will be in charge of managing the UserComponent's
    // lifecycle, it needs to know how to create instances of it. We use the
    // provider (i.e. factory) Dagger generates for us to create instances of UserComponent.
    private val userComponentProvider: Provider<UserComponent.Builder>,
) {
    /**
     *  UserComponent is specific to a logged in user. Holds an instance of
     *  UserComponent. This determines if the user is logged in or not, when the
     *  user logs in, a new Component will be created.
     *  When the user logs out, this will be null.
     */
    var userComponent: UserComponent? = null
        private set

    fun userLoggedIn(user: User, token: String) {
        // When the user logs in, we create a new instance of UserComponent
        userComponent = userComponentProvider.get().setUser(user).setToken(token).build()
    }

    fun logout() {
        // When the user logs out, we remove the instance of UserComponent from memory
        userComponent = null
    }
}
bcorso commented 3 years ago

So far we were trying to get ProjectComponent.Builder injected, like we do with UserComponent, but of course this does not work.

Can you post the entire error message? The error messages will contain a trace from the component entry point to the missing binding and other information that may help us identify what your issue is.

Chang-Eric commented 3 years ago

Can you also show an example of the code used to get the ProjectComponent.Builder? It should look a lot like what you posted with the UserComponent, but there are some easy mistakes to make like if you copied @Singleton over instead of making your manager class @LoggedUserScope then you'll get an error. Similarly, even if your manager class is unscoped, if you inject it from an @Singleton type it will cause issues because the parent is still the SingletonComponent and not the UserComponent.

roshanrajaratnam commented 3 years ago

@bcorso Thanks for getting back

Here's the entire error as requested.


  public abstract static class SingletonC implements HiltWrapper_ActivityRetainedComponentManager_ActivityRetainedComponentBuilderEntryPoint,
                         ^
  A binding with matching key exists in component: xx.xxx.android.ei.di.MainApplication_HiltComponents.UserC
      javax.inject.Provider<xx.xxx.android.ei.di.ProjectComponent.Builder> is injected at
          xx.xxx.android.ei.di.ProjectManager(component)
      xx.xxx.android.ei.di.ProjectManager is injected at
          xx.xxx.android.ei.viewmodels.DashboardViewModel(�, projectManager, �)
      xx.xxx.android.ei.viewmodels.DashboardViewModel is injected at
          xx.xxx.android.ei.viewmodels.DashboardViewModel_HiltModules.BindsModule.binds(vm)
      @dagger.hilt.android.internal.lifecycle.HiltViewModelMap java.util.Map<java.lang.String,javax.inject.Provider<androidx.lifecycle.ViewModel>> is requested at
          dagger.hilt.android.internal.lifecycle.HiltViewModelFactory.ViewModelFactoriesEntryPoint.getHiltViewModelMap() [xx.xxx.android.ei.di.MainApplication_HiltComponents.SingletonC ? xx.xxx.android.ei.di.MainApplication_HiltComponents.ActivityRetainedC ? xx.xxx.android.ei.di.MainApplication_HiltComponents.ViewModelC]``
roshanrajaratnam commented 3 years ago

@Chang-Eric Here's the manager class for the ProjectComponent and it's basically a duplicate of the UserManager class without being marked as Singleton. As @philips77 has stated above, the idea is that once a user is logged in, they can select a project etc.

class ProjectManager @Inject constructor(
    private val component: Provider<ProjectComponent.Builder>
) {

    /**
     *  ProjectComponent is specific to a logged in user's projects. Holds an instance of
     *  ProjectComponent.
     */
    var projectComponent: ProjectComponent? = null
        private set

    fun projectSelected(project: Project, keys: DevelopmentKeys) {
        // we can call this to select a project when the user is logged in
        projectComponent = component.get().setProject(project).setKeys(keys).build()
    }
}

Edit: Added the two component class as well to make it easier for you.

@ProjectScope
@DefineComponent(parent = UserComponent::class)
interface ProjectComponent {

    @DefineComponent.Builder
    interface Builder {
        fun setKeys(@BindsInstance keys: DevelopmentKeys): Builder
        fun setProject(@BindsInstance project: Project): Builder
        fun build(): ProjectComponent
    }
}
@LoggedUserScope
@DefineComponent(parent = SingletonComponent::class)
interface UserComponent {

    @DefineComponent.Builder
    interface Builder {
        fun setUser(@BindsInstance user: User): Builder
        fun setToken(@BindsInstance string: String): Builder
        fun build(): UserComponent
    }
}
Chang-Eric commented 3 years ago

Thanks, this is helpful. So what is going on is that you are trying to inject the ProjectComponent from a ViewModel, but the standard HiltViewModels don't come from your UserComponent (since it is a custom component). (You can see the component path of the ViewModel at the very end of that error: [xx.xxx.android.ei.di.MainApplication_HiltComponents.SingletonC ? xx.xxx.android.ei.di.MainApplication_HiltComponents.ActivityRetainedC ? xx.xxx.android.ei.di.MainApplication_HiltComponents.ViewModelC])

You'll need to inject the ProjectComponent.Builder from a type that was injected from the UserComponent.

roshanrajaratnam commented 3 years ago

Does this also mean that a manager class is not required for the ProjectComponent.Builder at all in this case? We thought ProjectComponent.Builder can be injected the same way as the UserComponent is injected from a ViewModel because it's also the parent of the ProjectComponent. We'd be really grateful if you could share an example explaining this.

Chang-Eric commented 3 years ago

because it's also the parent of the ProjectComponent

Not sure I followed that, but the Builder isn't the parent of the ProjectComponent? Or do you mean that because UserComponent is the parent, the child ProjectComponent should be able to be injected the same as the parent?

In any case, you would need something like the following:

// This doesn't really have to be scoped, but it probably is best for it to be, especially if you
// presumably will be storing the ProjectComponent instances in this class.
@LoggedUserScope
public final class UserFoo {
  @Inject UserFoo(ProjectComponent.Builder builder) { ... }
}

// Somewhere where you have your UserManager
UserFoo userFoo = userManager.userComponent().getUserFoo();
userFoo.buildProjectComponent();

The requirement is that the code that injects the ProjectComponent.Builder has to be injected from the parent UserComponent. If it is injected from somewhere else, like the Hilt ViewModelComponent, then it won't work. The reason injecting the UserComponent.Builder works is because the parent is the Hilt SingletonComponent, so the parent is accessible from the ViewModelComponent since ViewModelComponent is a child of SingletonComponent. ViewModelComponent is not a child of UserComponent however so that is why it does not work for ProjectComponent.Builder.

roshanrajaratnam commented 3 years ago

Not sure I followed that, but the Builder isn't the parent of the ProjectComponent? Or do you mean that because UserComponent >is the parent, the child ProjectComponent should be able to be injected the same as the parent?`

What I meant was UserComponent being the parent of the ProjectComponent, but your sample is very helpful understanding the whole picture.

I still have one more thing to clarifiy here, you also did mention about using an EntryPoint with an instance of a UserComponent (the less common version). How does that look like?

Really appreciate your support on this!

roshanrajaratnam commented 3 years ago

Thanks for your help, so we managed to solve our issue by injecting the ProjectManager using the UserComponentEntryPoint. Refer the source below, we decided to share it here as somebody else might benefit from our discussion.

@LoggedUserScope
@DefineComponent(parent = SingletonComponent::class)
interface UserComponent {

    @DefineComponent.Builder
    interface Builder {
        fun setUser(@BindsInstance user: User): Builder
        fun setToken(@BindsInstance string: String): Builder
        fun build(): UserComponent
    }
}
@ProjectScope
@DefineComponent(parent = UserComponent::class)
interface ProjectComponent {

    @DefineComponent.Builder
    interface Builder {
        //fun setKeys(@BindsInstance keys: DevelopmentKeys): Builder
        fun setProject(@BindsInstance project: Project): Builder
        fun build(): ProjectComponent
    }
}
@LoggedUserScope
class ProjectManager @Inject constructor(private val builder: ProjectComponent.Builder) {

    /**
     *  ProjectComponent is specific to a logged in user's projects. Holds an instance of
     *  ProjectComponent.
     */
    var projectComponent: ProjectComponent? = null
        private set

    fun projectSelected(project: Project) {
        // a logged in user can call this to select a project.
        //TODO include development keys in the builder
        projectComponent = builder.setProject(project).build()
    }
}
@EntryPoint
@InstallIn(UserComponent::class)
interface UserComponentEntryPoint {
    fun userDataRepository(): UserDataRepository

    fun getProjectManager(): ProjectManager
}
@ProjectScope
class ProjectDataRepository @Inject constructor(
    val project: Project
)

There is one last thing I noticed that, ProjectDataRepository did not needed to be scoped with @ProjectScope (but still scoped it and seems like it had no effect of not being scoped?). Does this mean that @ProjectScope is not a child scope of @LoggedUserScope as I expected this to be automagically!

Chang-Eric commented 3 years ago

There is one last thing I noticed that, ProjectDataRepository did not needed to be scoped with @ProjectScope (but still scoped it and seems like it had no effect of not being scoped?).

A scope annotation just tells you whether or not the object should be cached. If you don't scope it, every time you ask for a ProjectDataRepository you'll get a new instance. This doesn't affect the parent/child relationship of UserComponent and ProjectComponent. (Note that it is really the components that have the relationship. I think it gets confusing because colloquially people will often use the scope and component names interchangeably to mean the same thing, but most of the time it is technically the components you should be referring to as the scope annotation really just controls caching within that component).

Anyway, closing this now since it seems everything has been solved.

stavfx commented 1 year ago

Hey @roshanrajaratnam, I'm curious how you used the instance of ProjectComponent to get an instance of ProjectDataRepository? Did you create an @EntryPoint with @InstallIn(ProjectComponent::class) ?

roshanrajaratnam commented 1 year ago

@stavfx here's the link to the project where I used this. We ended up creating an entry point to the ProjectComponent class.

https://github.com/NordicSemiconductor/Android-nRF-Edge-Impulse/blob/fbd2dcbc81e875bc526d9db6345166eae0f7776f/app/src/main/java/no/nordicsemi/android/ei/di/ProjectComponentEntryPoint.kt#L7-L11