Android 和 Dagger 2 中的依赖注入

原文:Dependency Injection in Android with Dagger 2

作者:Joe Howard

译者:kmyhy

在现代开发团队中到处充斥着“你一定要用依赖注入”的叫嚣。依赖注入(简称 DI)变成了一个威风十足的名字,足以让每个开发者都胆战心惊。

无处不在的依赖注入和它的名字一样复杂,它是一个重要的、可维护和可测试的软件构建工具。此外,通过依赖注入你可以极大地简化你的代码并允许用一种更简单的方式编写可测试的代码。

在这篇教程中,我们会将一个现成的 app “DeezFoods” 升级为依赖注入。这个 app 显示一张食品列表(来自 USDA 食品成分数据库)。用户可以点击一种食品查看它的糖分,然后对这个糖分含量点赞或或者拍砖。

这个 app 使用常见的 Android 库和设计模式,比如 Retrofi 和 MVP。我们会用流行的 Java/Android 依赖注入框架 Dragger 2 来进行依赖注入。

下图是 DeezFoodz 的详情页:

Android 和 Dagger 2 中的依赖注入

在此之前,请到 USDA 网站(前面的链接)获得访问权限。你需要获得它的 API key,否则无法在 app 中使用它。

介绍

到底什么是依赖?任何重要的软件程序都会包含多个组件,这些组件之间会来回调用并传递数据。

例如,在使用面向对象语言(比如 Android 中的 Java)时,对象会调用其引用的其它对象的方法。简单的依赖就是一个对象依赖于其它对象的具体实现。

具体到 Java 代码中,我们可以识别出自己代码中的依赖,比如你在一个对象中使用 new 关键字创建一个新对象。这时,你唯一责任就是创建这个对象,并在创建时正确配置这个对象。例如,有一个类 A:

public class A {
  private B b;
  public A() {
    b = new B();
  }
}

A 对象在构造函数中创建了变量 b。A 对象完全依赖了 B 的具体实现,为了能够正确调用到 b 的属性,我们配置了 b 变量。

这表明了 A 类对 B 类是耦合的或者是依赖的。如果 B 对象的创建比较复杂,则 A 类中也会得到体现。任何对 B 对象的配置的改变则必然对 A 类造成影响。

如果 B 类依赖于 C,C 依赖于 D,所有的复杂性都会在代码库中传递,并导致 app 中组件的紧耦合。

依赖注入是一个术语,用于描述了这种松散耦合的描述。在这个例子中,我们只需要做一小点修改:

public class A {
  private B b;
  public A(B b) {
    this.b = b;
  }
}

好了——依赖注入出现了。比起在 A 的构造器中创建一个 b 对象,现在 b 对象是以注入的方式传递到 A 的构造器中。配置 b 的任务可以在任何地方进行,A 类只是简单地对 B 类进行消费。

依赖反转原则

依赖注入经常和面向对象设计中的 SOLID 五原则之一相提并论:即依赖反转原则。关于 SOLID 的介绍,尤其是针对 Android 系统,请参考“依赖反转的王国”。

简单来说,依赖反转原则是说,依赖于抽象而不要依赖于具体实现。

还是之前的例子,将 B 用 Java 的接口来替换 Java 类。这样,就可以将不同的实现了 B 接口的具体的 B 的类型传递给 A 的构造函数了。这会带来几个好处:

  • 可以用各种 B 对象来测试 A 类。
  • 在某种测试场景中,根据需要可以使用假的 B 对象。
  • 测试 A 并不会依赖于 B 的具体实现。

虽然经常二者并提,但依赖注入和依赖反转原则是不一样的。基本上,依赖注入是一种遵循了依赖反转原则的技术。

Dagger 2

在 Java 的世界,有许多用于简化依赖注入的使用的框架。这些框架减少了大量重复的代码,提供了在软件系统中使用依赖注入的机制。

早期有一个 Java 依赖注入框架,叫做 Guice,由 Google 所创建。后来 Square 团队开发了 Dagger 框架,主要针对于 Android 系统。

虽然 Dagger 总的来说很棒,但第一版的 Dagger 框架也有几个缺点。例如,由于运行时反射导致的性能问题,以及 ProGuard 的难用。

因此,Dagger 2 框架问世了,它生成的代码更小,通过在编译时进行注入解决了性能问题。

Dagger (短剑,匕首)这个名字来自于开发中依赖的本质。依赖发生在对象之间,比如 A、B、C …,导致一种所谓的“有向非循环图”的结构。Dagger 和 Dagger 2 用于降低 Java 和 Android 项目中此类图的出现。

在本文后面,Dagger 一次专指 Dagger 2.

理论课上完了!开始编写代码吧!

开始

这里下载开始项目。

用 Android Studio 打开开始项目。你需要使用 Android Studio 2.2.2 以上版本。低于此版本,或者提示你升级 gradle 版本,请照提示进行。

修改 app 包下面的 Constants.java 文件,将 API_KEY 替换成你自己的值。

在模拟器或设备上运行 app,确认你可以编译通过。

Android 和 Dagger 2 中的依赖注入

这个 app 有两个页面。第一个是一个来自 USDA 食品成分数据库的食品列表,点击任何单元格,显示详情页,它会列出该食品的糖分数据。

查看项目结构和类文件。你会看到 app 使用了 MVP 模型来搭建代码结构。还使用了 Square 的 Retrofit 2 库来作为连接 USDA API 的网络层。

main 包下面的子包分别是 app、model、network、ui。如果你查看 ui 包,你会看到这两个页面的子包。每个页面都有自己的 View 类和 present 类。

Android 和 Dagger 2 中的依赖注入

打开 app 的 build.gradle 文件,你会看到 app 用 Butterknife 进行视图绑定,和一个类似 Java 8 streams 的lightweight stream 库

MVP

如果你不熟悉 MVP 模式,你可以先阅读一些在线资源。

MVP 和其它结构模式类似,实现了关注分离,就像 MVC 和 MVVM。对于 Android 的 MVP 来说,你的 activity 和 fragment 就是典型的 view 对象,实现了 View 的界面,处理 app 于用户之间的交互。

view 将用户动作传递给 presenter,后者负责业务逻辑,和数据库存储交互,比如服务端 API 或者数据库。model 层则用于表示构成 app 内容的对象。

以 DeezFoodz 来说,FoodActivity 类是详情页,实现了 FoodView 接口。FoodActivity 有一个对 FoodPresenter 接口的引用,这个接口负责访问 Food 类型的模型对象。

对 Retrofit 2 的使用放在了 network 包下面的 UsdaApi 类中。这个类定义了 app 所要调用的 USDA API 接口。有两个 GET 类型的调用,一个获取一个 FoodzList 对象,另一个通过查询参数获取指定 Food 的详情。

注意:Retrofit 是一个强大的类型安全的 HTTP 客户端。它将 USDA REST API 重新暴露成 Java 接口。Retrofit 简化了进行同步异步 web 请求的工作,将响应数据转换成 POJO。更多内容,请看 Android 网络教程:开始

DeezFoodz 中的依赖

打开 ui.food 下面的 FoodActivity 类。在 onCreate() 方法, 这几句创建和配置了 FoodPresenter:

presenter = new FoodPresenterImpl();
presenter.setView(this);
presenter.getFood(foodId);

这里,我们创建了一个 FoodPresenter 的具体实现类。打开 FoodPresenterImpl.java 看一下 getFood()。Retrofit 对象和 UsdaApi 对象都在这个方法中以工厂方法的方式创建和配置。

这导致了对模型、视图和 presenter 层的紧耦合。针对这个 view,换一个假的 presenter 就必须修改 View 的代码。创建 Retrofit 对象和 UsdaApi 对象的代码在两个 presenter 中重复了:FoodPresenterImpl 和 FoodzPresenterImpl。

接下来,我们用 Dagger 实习对 DeezFoodz 进行依赖注入,移除不同层之间的耦合和重复代码。

最后,是写代码的时候了!

项目中 Dagger 2 的相关配置

打开 app 的 build.gradle 添加 Dagger 依赖:

depencies {
  ...
  // Dependency Injection
  apt "com.google.dagger:dagger-compiler:2.2"
  compile "com.google.dagger:dagger:2.2"
  provided 'javax.annotation:jsr250-api:1.0'
  ...
}

省略号代表项目中的已有依赖。Android Studio 会提示你的 gradle 文件已改变,需要同步,因此请根据提示进行,确保你的 Dagger 能够正确引入。注意你还导入了 javax 的注解库,因为 Dagger 的许多功能都是通过 Java 注解来实现的。

模块

你要用到的第一个注解就是@Module。在 app 的 main 包下面新建一个 dagger 包,右键点击 main 包,选择 New/Package:

Android 和 Dagger 2 中的依赖注入

然后,在 dagger 包下面新建文件。右键点击 dagger,选择 New/Java Class。类名命名为 AppModule。

Android 和 Dagger 2 中的依赖注入

Android 和 Dagger 2 中的依赖注入

在新文件中添加类声明:

@Module
public class AppModule {
}

这里,我们创建了一个名为 AppModule 的类,并用 @Module 进行注解。Android Studio 会自动创建必须的 import 语句,如果没有,请用 Alt-Enter 创建。

@Module 注解告诉 Dagger,AppModule 类为 app 提供依赖。在一个项目中使用多个 Dagger 模块是比较常见的,通常用对于 app 级别的依赖会专门用一个模块来提供。

在类体中添加下列代码:

private Application application;

public AppModule(Application application) {
  this.application = application;
}

@Provides
@Singleton
public Context provideContext() {
  return application;
}

我们添加了一个私有成员保存对 app 的引用,用一个构造函数来配置 application 成员,以及一个 provideContext() 方法返回这个 application。注意这个方法有两个 Dagger 注解: @Provides and @Singleton。

@Provides 注解告诉 Dagger 这个方法提供了某种类型的依赖,在我们的例子中,就是 Context 对象。当 app 请求 Dagger 注入一个 Context 时,@Provides 注解告诉 Dagger 去哪里找到这个对象。

注意:这个 provider 的方法名,叫做 provideContext() 或者别的什么一点也不重要。Dagger 只关心返回类型。

@Singleton 注解告诉 Dagger 这个依赖只有一个单例对象,并且为你减少了许多重复的代码。

组件

现在你有了一个 Dagger 模块,它包含了一个可注入的依赖,我们要怎么使用它呢?

这需要用到另外了一个 Dagger 注解 @Component。新建一个 Java 文件在 dagger 包下,命名为 AppComponent>

在这个文件中,编写代码:

@Singleton
@Component(modules = {AppModule.class})
public interface AppComponent {
}

我们告诉 Dagger 这个 AppComponent 是一个单例组件。@Component 注解用一个模块数组作为参数,对于这个参数,我们在数组中添加了一个 AppModule。

这个组件用于将对象和它们的依赖进行绑定,通常需要覆盖 injecdt() 方法。在第一版的 Dagger 中,这个过程在反射对象图的时候进行。为了使用组件,应该让它在 app 中所有需要注入的地方被访问。一般,就是 app 的 Applicatoin 子类。

首先,添加下列变量声明以及 Applicationd 的 get 方法:

private AppComponent appComponent;

public AppComponent getAppComponent() {
  return appComponent;
}

然后来初始化 AppCompoent.z DeezFoodzApplication 中添加如下方法:

protected AppComponent initDagger(DeezFoodzApplication application) {
  return DaggerAppComponent.builder()
      .appModule(new AppModule(application))
      .build();
}

AndroidStudio 会在 DaggerAppComponnet 上报错。这个类还没有被生成。通过 Android Studio 的 Build 菜单,选择 Make Module ‘app’ 。这会产生编译错误,但会清除掉代码中的报错。

再次执行 Make Module ‘app’,清除编译错误。然后,你会在 appModule()方法上出现一个警告,这个很快就能搞定。

最终,在 DeezFoodzApplication 修改 onCreate() 方法:

@Override
public void onCreate() {
  super.onCreate();
  appComponent = initDagger(this);
}

当 app 第一次启动,初始化了 appComponent 变量。

用 Dagger 2 进行依赖注入

在 AppComponent 接口中添加这个方法:

void inject(FoodzActivity target);

这里,我们指定 FoodzActivity 类需要依靠 AppComponent 进行注入。接下来我们将对应的对象注入到 FoodzActivity。

在 dagger 包中新建类 PresenterModule。在这个类中声明:

@Module
public class PresenterModule {
  @Provides
  @Singleton
  FoodzPresenter provideFoodzPresenter() {
    return new FoodzPresenterImpl();
  }
}

这个类会提供一个 FoodzPresenter ,这个方法会返回一个由 FoodzPresenterImpl 来具体实现的 presenter 对象。

然后,在 AppComponent 中的 @Component 注解中,添加 PresenterModule,和 AppComponent 放在一起。

@Component(modules = {AppModule.class, PresenterModule.class})

最后,打开 FoodzActivity ,它位于 ui.foodz 包下面。首先,在 presenter 字段前添加一个 @Inject 注解:

@Inject
FoodzPresenter presenter;

然后,在 onCreate() 方法中,将下列创建 presenter 的语句删除:

presenter = new FoodzPresenterImpl();

@Inject 注解告诉 Dagger,我们对 presenter 字段进行依赖注入。

运行 app,app 崩溃,报 NPE(空指针异常)错误。我们还有一个步骤忘了做。

修改 onCreate() 方法,在其中加入对 AppComponent.inject() 的调用:

@Override
protected void onCreate(Bundle savedInstanceState) {
  super.onCreate(savedInstanceState);
  setContentView(R.layout.activity_foodz);

  ((DeezFoodzApplication)getApplication()).getAppComponent().inject(this);

  ButterKnife.bind(this);
  ...

我们从 DeezFoodzApplication 获得了 AppComponent 的引用,然后用它将所有依赖注入到 FoodzActivity。因为 presenter 被声明为 @Inject,Dagger 会将一个 FoodzPresenter 实现注入给 FoodzActivity。

Dagger 知道在 PresenterModule 类中有一个 ProvideFoodzPresenter() 方法,并用它来创建用于注入的FoodzPresenter 对象。

运行 app。结果和之前一样,同时我们也解决了 NPE 问题。现在我们已经用 Dagger 2 实现了我们的第一个依赖注入!

根据前面我们对代码的修改,我们可以总结出一些规律。以 Dagger 向 FoodzActivity 中进行的依赖注入为例:

  1. 在 AppComponent 新建一个 inject() 方法,使用一个 FoodzActivity 对象做参数。
  2. 在 PresenterModule 中新增方法 provideFoodzPresenter()。
  3. 在 FoodzActivity 的 presenter 之前添加注解 @Inject。
  4. 在 FoodzActivity 的 onCreate() 方法中添加 DeezFoodzApplication.getAppComponent().inject(this)。

假设将 FoodzActivity 称作 Target 类,FoodzPresenter 用将被注入的接口 Source,那么上述步骤可以抽象为向任何目标类注入源接口的步骤:

  1. 在 AppCompoent 中增加 inject() 方法,用 Target 类作为参数。
  2. 在 PresenterModule 中,针对每个注入 Source 对象的方法用 @Provides 进行修饰。
  3. 在 Target 类中,对每个 Source 成员变量,用 @Inject 进行修饰。
  4. 在 Target 的 OnCreate() 方法中,添加 DeezFoodzApplication.getAppComponent().inject(this)。

给你出道题,将 FoodPresenter 详情页面注入到 FoodActivity 中(在 ui.food 包下面),文件中的这行代码要删除:

presenter = new FoodPresenterImpl();

这和我们在 FoodzActivity 中所做的步骤是一样的。使用上面的模板,如果做不出来,可以参考最终项目中的源代码。

注入 Network Graph

在读到这里的时候,列表页面和详情页面的 presenter 仍然在各自创建自己的网络依赖。在一个正常的使用了 Dagger 2 和 Retrofit 的 app 中,Retrofit 应该是以依赖注入的方式使用的。

这里列出了一些使用依赖注入和 Dagger 2 的好处,包括:

  • 解决代码复用问题
  • 解决依赖配置问题
  • 自动生成依赖图谱

在 dagger 包中新建文件 NetworkModule,文件内容为:

@Module
public class NetworkModule {
}

这里我们需要注入一个 UsdaApi 对象给 app 的 presenter 实现,以便 presenter 能够使用这些 API。

例如,打开当前的 FoodPresenterImpl 文件,你会发现 UsdaApi 依赖了一个 Retrofit 对象,而创建一个 Retrofit 对象需要一个字符串形式的 API base URL 和一个 Conver.factory 对象。

Android 和 Dagger 2 中的依赖注入

首先,在 NetworkModule 中新增两个方法和一个字符串常量:

private static final String NAME_BASE_URL = "NAME_BASE_URL";

@Provides
@Named(NAME_BASE_URL)
String provideBaseUrlString() {
  return Constants.BASE_URL;
}

@Provides
@Singleton
Converter.Factory provideGsonConverter() {
  return GsonConverterFactory.create();
}

这里有一个新注解出现了。@Named。你要注入一个字符串对象,在 Android app 中字符串是一种普通类型,我们可以通过 @Named 注解指定某个字符串应当是被提供的(类似 @Provides)。如果我们有很多变量需要注入时,也可以在自定义类型上使用同样的方法。

现在,你拥有了一个 String 和 一个 GsonConverterFactory, 在 NetworkModule 底部添加:

@Provides
@Singleton
Retrofit provideRetrofit(Converter.Factory converter, @Named(NAME_BASE_URL) String baseUrl) {
  return new Retrofit.Builder()
    .baseUrl(baseUrl)
    .addConverterFactory(converter)
    .build();
}

@Provides
@Singleton
UsdaApi provideUsdaApi(Retrofit retrofit) {
  return retrofit.create(UsdaApi.class);
}

我们添加了两个 provide 方法,一个返回 Retrofit 对象,一个返回 UsdaApi 对象。这就让 Dagger 能够构建出一个依赖图谱,当一个对象请求一个 UsdaApi 对象注入时,Dagger 首先会向 provideUsdaApi(Retrofit retrofit) 方法提供一个 Retrofit 对象。

然后 Dagger 在图谱中查找 converter 和 baseUrl 以便提供给 provideRetrofit(Converter.Factory converter, @Named(NAME_BASE_URL) String baseUrl) 方法。

通过 @Singleton 注解, UsdaApi 和 Retrofit 对象都只会创建一次,然后在不同的 activity *用。

将 NetworkModule 添加到 AppComponent 的 @Component 注解:

@Component(modules = {AppModule.class, PresenterModule.class, NetworkModule.class})

然后,修改 PresenterModule 的 provide 方法,以便 presenter 的构造函数能够增加一个 Context 参数:

@Provides
@Singleton
FoodzPresenter provideFoodzPresenter(Context context) {
  return new FoodzPresenterImpl(context);
}

@Provides
@Singleton
FoodPresenter provideFoodPresenter(Context context) {
  return new FoodPresenterImpl(context);
}

想 AppComponent 中添加两个注入方法:

void inject(FoodzPresenterImpl target);
void inject(FoodPresenterImpl target);

然后,修改 FoodzPresenterImpl 和 FoodPresenterImpl 增加两个构造函数:

public FoodzPresenterImpl(Context context) {
  ((DeezFoodzApplication)context).getAppComponent().inject(this);
}
public FoodPresenterImpl(Context context) {
  ((DeezFoodzApplication)context).getAppComponent().inject(this);
}

在两个类中增加以下注入式字段:

@Inject
UsdaApi usdaApi;

现在,我们已经向 UsdaApi 注入两个 presenter 实现了,可以将它们中的这些重复的语句删除了:

Converter.Factory converter = GsonConverterFactory.create();

Retrofit retrofit = new Retrofit.Builder()
  .baseUrl(Constants.BASE_URL)
  .addConverterFactory(converter)
  .build();

UsdaApi usdaApi = retrofit.create(UsdaApi.class);

最后一次运行 app。这回,你会发现 app 行为和之前没有区别,但我们成功地使用了依赖注入,你的 app 维护性更好,更容易测试了。

结束

这里下载最终项目。

对 DeezFoodz 的改造花费了我们大量的工作。但在现实生活中使用依赖注入和 Dagger 2 这样的框架的 app 变得非常普遍,因为这些 app 的依赖图谱是非常复杂的。

Dagger 2 和依赖注入在测试 app 时尤其有用,它允许在测试中模拟后台 API 和数据存储。

更多关于 Dagger 2 的内容用法,还有:

  • Scopes
  • Subcomponents
  • Testing with Mockito

在网上有许多优秀的资源介绍了这些主题。终于结束了,祝你在注入时开心!

有任何问题和评论,请在下面留言。

上一篇:[Android游戏开发学习笔记]View和SurfaceView


下一篇:Andriod中的依赖注入