Understanding scopes in Dagger 2

2019-02-13 17:35发布

I have an scope-related error in Dagger 2 and I'm trying to understand how I can solve it.

I have a CompaniesActivity that shows companies. When the user selects an item, selected company's employees are shown in EmployeesActivity. When the user selects an employee, her detail is shown in EmployeeDetailActivity.

class Company {
    List<Employee> employees;
}

Class CompaniesViewModel contains the companies and the selected one (or null):

class CompaniesViewModel {
    List<Company> companies;
    Company selected;
}

CompaniesActivity has a reference to CompaniesViewModel:

class CompaniesActivity extends Activity {

    @Inject
    CompaniesViewModel viewModel;

    @Override
    protected void onCreate(Bundle b) {
        //more stuff
        getComponent().inject(this);
        showCompanies(viewModel.companies);
    }

    //more stuff

    private onCompanySelected(Company company) {
        viewModel.selected = company;
        startActivity(new Intent(this, EmployeesActivity.class));
    }

}

Class EmployeesViewModel contains the employees and the selected one (or null):

class EmployeesViewModel {
    List<Employee> employees;
    Employee selected;
}

EmployeesActivity has a reference to EmployeesViewModel:

  class EmployeesActivity extends Activity {

        @Inject
        EmployeesViewModel viewModel;

        @Override
        protected void onCreate(Bundle b) {
            //more stuff
            getComponent().inject(this);
            showEmployees(viewModel.employees);
        }

        //more stuff

        private onEmployeeSelected(Employee emp) {
            viewModel.selected = emp;
            startActivity(new Intent(this, EmployeeDetailActivity.class));
        }

    }

Finally, in EmployeeDetailActivity, I get selected Employee from view model and show her detail:

  class EmployeeDetailActivity extends Activity {

        @Inject
        EmployeesViewModel viewModel;

        @Override
        protected void onCreate(Bundle b) {
            //more stuff
            getComponent().inject(this);
            showEmployeeDetail(viewModel.selected); // NullPointerException
        }
    }

I get NullPointerException because EmployeesViewModel instance in EmployeesActivity is not the same as the EmployeeDetailActivity and, in the second one, viewModel.selected is null.

This is my dagger module:

@Module
class MainModule {

    @Provides
    @Singleton
    public CompaniesViewModel providesCompaniesViewModel() {
        CompaniesViewModel cvm = new CompaniesViewModel();
        cvm.companies = getCompanies();
        return cvm;
    }

    @Provides
    public EmployeesViewModel providesEmployeesViewModel(CompaniesViewModel cvm) {
        EmployeesViewModel evm = new EmployeesViewModel();    
        evm.employees = cvm.selected.employees;
        return evm;
    }

}

Note that CompaniesViewModel is singleton (@Singleton) but EmployeesViewModel is not, because it has to be recreated each time user selects a company (employees list will contain other items).

I could set the company's employees to EmployeesViewModel each time user selects a company, instead of create a new instance. But I would like CompaniesViewModel to be immutable.

How can I solve this? Any advise will be appreciated.

3条回答
聊天终结者
2楼-- · 2019-02-13 17:50

According to this article about Custom Scopes:

http://frogermcs.github.io/dependency-injection-with-dagger-2-custom-scopes/

In short - scopes give us “local singletons” which live as long as scope itself.

Just to be clear - there are no @ActivityScope or @ApplicationScope annotations provided by default in Dagger 2. It’s just most common usage of custom scopes. Only @Singleton scope is available by default (provided by Java itself), and the point is using a scope is not enough(!) and you have to take care of component that contains that scope. This mean keeping a reference to it inside Application class and reuse it when Activity changes.

public class GithubClientApplication extends Application {

    private AppComponent appComponent;
    private UserComponent userComponent;

    //...

    public UserComponent createUserComponent(User user) {
        userComponent = appComponent.plus(new UserModule(user));
        return userComponent;
    }

    public void releaseUserComponent() {
        userComponent = null;
    }

    //...
}

You can take a look at this sample project:

http://github.com/mmirhoseini/marvel

and this article:

https://hackernoon.com/yet-another-mvp-article-part-1-lets-get-to-know-the-project-d3fd553b3e21

to get more familiar with MVP and learn how dagger scope works.

查看更多
Lonely孤独者°
3楼-- · 2019-02-13 18:08

Unfortunately, I think that you abuse DI framework in this case, and the issues that you encounter are "code smells" - these issues hint that you're doing something wrong.

DI frameworks should be used in order to inject critical dependencies (collaborator objects) into top level components, and the logic that performs these injections should be totally independent of the business logic of your application.

From the first sight everything looks fine - you use Dagger in order to inject CompaniesViewModel and EmployeesViewModel into Activity. This could have been fine (though I wouldn't do it this way) if these were real "Objects". However, in your case, these are "Data Structures" (therefore you want them to be immutable).

This distinction between Objects and Data Structures is not trivial, but very important. This blog post summarizes it pretty well.

Now, if you try to inject Data Structures using DI framework, you ultimately turn the framework into "data provider" of the application, thus delegating part of the business functionality into it. For example: it looks like EmployeesViewModel is independent of CompaniesViewModel, but it is a "lie" - the code in @Provides method ties them together logically, thus "hiding" the dependency. Good "rule of thumb" in this context is that if DI code depends on implementation details of the injected objects (e.g. calls methods, accesses fields, etc.) - it is usually an indication of insufficient separation of concerns.

Two specific recommendations:

  1. Don't mix business logic with DI logic. In your case - don't inject data structures, but inject objects that either provide access to the data (bad), or expose the required functionality while abstracting the data (better).
  2. I think that your attempt of sharing a View-Model between multiple screens is not a very robust design. It would be better to have a separate instance of View-Model for each screen. If you need to "share" state between screens, then, depending on the specific requirements, you could do this with 1) Intent extras 2) Global object 3) Shared prefs 4) SQLite
查看更多
\"骚年 ilove
4楼-- · 2019-02-13 18:13

There are a couple of issues here and that are only obliquely related to Dagger 2 scopes.

Firstly, the fact that you have used the term "ViewModel" suggests you are trying to use MVVM architecture. One of the salient features of MVVM is separation of layers. However, your code has not achieved any separation between model and view-model.

Let's take a look at this definition of model from Eric Evans:

A domain model is a system of abstractions that describes selected aspects of a sphere of knowledge, influence, or activity (a domain).2

Here your sphere of knowledge is a company and its employees. Looking at your EmployeesViewModel, it contains at least one field that is probably better isolated in the model layer.

class EmployeesViewModel {
    List<Employee> employees; //model layer
    Employee selected;        
}

Perhaps it is merely an unfortunate choice of name, but I think your intention is to create proper view-models so any answer to this question should address that. While selection is associated with the view, the class doesn't really qualify as an abstraction of the view. A real view-model would probably somehow match the way the Employee is displayed on the screen. Let's say you have "name" and "date of birth" TextViews. Then a view-model would expose methods that provide the text, visibility, color etc. for those TextViews.

Secondly, what you are proposing is using (singleton) Dagger 2 scopes to communicate between Activities. You want the company selected in the CompaniesActivity to be communicated to the EmployeesActivity and the employee selected in EmployeesActivity to be communicated to the EmployeeDetailActivity. You are asking how to achieve this by making them all use the same shared global object.

While this may be technically possible using Dagger 2, the correct approach in Android to communicate between Activities is to use intents, rather than shared objects. The answers to this question are a really good explanation of this point.

Here is a proposed solution: It's not clear what you are doing to actually get the List<Company>. Maybe you are getting from a db, maybe you are getting from a cached web request. Whatever it is, encapsulate this in an object CompaniesRepository. Likewise for EmployeesRepository.

So you will have something like:

public abstract class EmployeesRepository {

     List<Employee> getAll();

     Employee get(int id);

     int getId(Employee employee);

}

Do something similar for a CompaniesRepository class. These two retrieval classes can be singletons and be initialised in your module.

@Module
class MainModule {

    @Provides
    @Singleton
    public CompaniesRepository(Dependency1 dependency1) {
        //TODO: code you need to generate the companies retrieval object
    }

    @Provides
    @Singleton
    public EmployeesRepository(Dependency2 dependency2) {
       //TODO: code you need to generate the employees retrieval object
    }
}

Your EmployeesActivity now looks something like this:

class EmployeesActivity extends Activity {

    @Inject CompaniesRepository companiesRepository;
    @Inject EmployeesRepository employeesRepository;       

    @Override
    protected void onCreate(Bundle b) {
        //more stuff
        getComponent().inject(this);
        //retrieve the id of the company selected in the previous activity
        //and use that to get the company model
        int selectedCompanyId = b.getIntExtra(BUNDLE_COMPANY_ID, -1);
        //TODO: handle case where no company id has been passed into the activity
        Company selectedCompany = companiesRepository.get(selectedCompanyId);
        showEmployees(selectedCompany.getEmployees);
    }

    //more stuff

    private onEmployeeSelected(Employee emp) {
        int selectedEmployeeId = employeesRepository.getId(emp);
        Intent employeeDetail = new Intent();
        employeeDetail.putExtra(BUNDLE_EMPLOYEE_ID, selectedEmployeeId);
        startActivity(employeeDetail));
    }
}

Extend this example to your other two activities and you will be approaching the standard architecture for an Android app and you will be using Dagger 2 without mixing your model layer, view layer etc.

查看更多
登录 后发表回答