为何需要了解有关架构组件库的内容?

架构组件是一组 Android 内容库,用于以稳健、可测试并且可维护的方式结构化您的应用。除了这些内容库,还有应用架构指南,其中介绍了利用架构组件库设计 Android 应用的方法。

通过学习使用架构组件,您可以在编写应用时减少样板文件代码的数量,还将了解涉及生命周期和持久存储的棘手问题的应对策略。

您将构建的应用

在此 Codelab 中,您将利用不同组件构建一款名为 Sunshine 的天气应用,该应用从远程来源获取数据,将其存储在本地,然后显示给用户。

您构建的应用将:

您需要具备的条件

获取代码

在此步骤中,您需要下载整个 Codelab 的代码。

下载代码

  1. 从以上链接下载初始代码。
  2. 解压缩代码。
  3. 导入Android Studio 3.0。这可能需要几分钟时间。

如果您想了解构建这款应用所需的所有步骤,可以查看GitHub 历史记录

Sunshine:前世今生

Sunshine 是 Google 的Android 应用开发 Udacity 课程中使用的一款天气应用。在此 Codelab 中,您将在这款应用框架代码的基础上添加应用架构指南中介绍的架构组件库和架构模式。

您构建的应用应满足下列条件:

我们生成的应用将包含两个屏幕,一个屏幕列出十四天天气预报,一个详情屏幕提供某一天天气预报的更多详情:

MainActivity

DetailActivity

特色应用架构指南

您将遵循应用架构指南建议的应用架构。以下是所涉及的不同类的示意图。如果您并非熟悉所有组件,也不必担心,因为您将在此 Codelab 中了解它们的相关信息。

以下按从上到下顺序概要说明了示意图中的不同类:

界面控制器 - 界面控制器是 Activity 或 Fragment。界面控制器的唯一职责是知晓如何显示数据和传递界面 Event(例如用户按下按钮)。界面控制器既不包含界面数据,也不直接操纵数据。

ViewModel 和 LiveData - 这些类代表显示界面所需的全部数据。您将在此 Codelab 中确切了解这两个类如何协同工作。

存储区 - 此类是应用所有数据的单一可信来源,充当与界面通信的洁净 API。ViewModel 只管从存储区请求数据。它们不必操心存储区应该从数据库还是网络加载,或者如何或何时持久存储数据。所有这些都由存储区管理。充当不同数据源之间的媒介是存储区的职责之一。当您在此 Codelab 中建立存储区时,可以了解到更多有关存储区的信息。

远程网络数据源 - 管理来自远程数据源(例如互联网)的数据。

模型 - 管理存储在数据库中的本地数据。

Sunshine 初始代码

初始代码包含MainActivityDetailActivity这两个 Activity。

MainActivity

DetailActivity

DetailActivity是您将处理的第一个 Activity。初始代码已包含正确显示列表项和所有图像资源所需的全部界面代码,只不过应用尚未连接数据源。

您可以通过查看README了解有关应用类的更多信息。

Sunshine:架构

让我们快速了解一下这款应用的最终架构。

将有两个 Activity(MainActivityDetailActivity),它们各有自己的ViewModelMainActivityViewModelDetailActivityViewModel)和关联的LiveData。它们将使用一个存储区类 (SunshineRepository),后者将管理 SQLite 数据库与网络数据源之间的通信。WeatherNetworkDataSource使用两个 Service(SunshineSyncIntentServiceSunshineFirebaseJobService)从mock 天气服务器请求天气数据。mock 天气服务器返回随机 JSON 数据。

绿色轮廓的类是您需要从头开始构建的类。让我们先从应用的数据库部分着手,了解有关Room的信息,后者是 SQLite 面向 Android 平台的对象映射库。

为何缓存天气数据

大多数(即便不是全部)应用都会对数据进行一定程度的处理。以 Sunshine 为例,您有代表天气预报的WeatherEntry数据对象。您可以决定,每次在 Sunshine 中创建 Activity 时都从服务器下载最新天气数据。此策略可确保用户看到最新的天气信息,但效率极低。您每次切换屏幕或旋转手机时都会重新下载天气数据,而大多数情况下这些数据没有发生任何变化!此外,如果用户离线,就无法使用您的应用。

正因如此,大多数应用会在手机的本地数据缓存中保存一些数据。Android 全面支持本地 SQLite 数据库,因此 SQLite 是为此缓存选择的常见数据库格式。

Room 简介

Room 的优势

在 Android 上使用 SQLite 数据库意味着使用SQLiteOpenHelperSQLiteDatabaseSQLiteQueryBuilder之类的 API。尽管功能强大,但它们也带来了诸多开发挑战,其中包括大量样板文件代码,以及在编译时不能方便地验证您的 SQLite 语句。

对于 Sunshine,您将使用新的 SQLite 对象映射库RoomRoom具备内置 API 所缺乏的诸多优势,其中包括:

Room 的组件

Room 利用注解来定义数据库结构。它有三个主要组件:

轮到您了:为 Sunshine 添加 Room

为您的项目添加 Room 内容库:

  1. 打开您的项目(而非您的应用或模块)的build.gradle文件。您应该会看到其中已包含以下内容:
allprojects {
    repositories {
        jcenter()
        maven { url 'https://maven.google.com' }
    }
}
  1. 打开您的应用或模块的build.gradle 文件,并添加下列依赖项:
compile "android.arch.persistence.room:runtime:1.0.0-alpha9"
annotationProcessor "android.arch.persistence.room:compiler:1.0.0-alpha9"
  1. 同步 Gradle。

这些依赖项提供 Room 及其稳健的注解处理器,现在您将使用它来创建Entity

可在此处查看此步骤的代码差异。

如何创建实体

为了解实体是如何创建的,下面我们来看一个来自Room 文档的示例。然后您将运用所学内容来构建 Sunshine。

Room 使用注解为 Room 给您生成的表格定义表格结构和列约束。假定您想为 user 对象建立与以下类似的表格:

users 表格

id(表格的主键)

firstName

lastName

1

Florina

Muntenescu

2

Lyla

Fujiwara

3

Yigit

Boyar

创建该表格的代码如下:

// Creates a table named users. 
// tableName is the property name, users is the value
@Entity(tableName = "users") 
class User {
    @PrimaryKey // Denotes id as the primary key
    public int id;
    public String firstName;
    public String lastName;

    @Ignore // Tells Room to ignore this field
    Bitmap picture;
}

您可以在示例中看到,实体必须包含以下几项内容:

默认情况下,这会创建以类名称命名的表格,表格的列以字段名称命名。Room 还有其他注解,并且这些注解也有相应的属性。在本例中,您可以看到@Entity注解具有属性tableName,这意味着不使用"user"(类名)作为该表格的名称,而是将其命名为"users"

轮到您了:为单一天气预报创建一个实体

我们的数据库将使用单一表格来存储天气值:

weather表格

id(主键)

weatherIconId

date

min

max

humidity

pressure

wind

degrees

1

500

1502668800000

13.32

18.27

96

996.68

1.2

0

2

501

1502755200000

12.66

17.34

97

996.12

4.8

45

3

800

1502841600000

12.07

16.48

90

995.7

8.2

90

如果是从头开始构建,您需要创建一个对象,以对保存在此表格中的值建模。初始应用已包含模型类WeatherEntry。现在看一下它的情况。

通过执行以下操作,将您的data.database.WeatherEntry模型转换为 Room 实体:

  1. WeatherEntry设置为@Entity并将表格名称更改为"weather":在 WeatherEntry 类正上方添加@Entity注解。添加tableName属性并将值设置为"weather"。如果不进行此操作,表格名称将是"weatherentry"。
@Entity(tableName = "weather")
  1. 将 id 定义为自动生成的主键:@PrimaryKey annotation 于id段上方。Sunshine 代码不包含每个WeatherEntry的唯一数据库 id,因为天气服务器未返回该 id。要让 Room 为您执行此操作,请将autoGenerate属性添加到@PrimaryKey注解中,并将其值设置为true
@PrimaryKey(autoGenerate = true)
  1. date字段应唯一:date 字段唯一是因为我们只存储一个位置的天气数据,因此给定日期应该永远不存在两个不同的天气预报。将indices属性添加到@Entity注解中(位于该类的上方),作为对tableName属性的补充。其值应为date列,并且unique应设置为true
@Entity(tableName = "weather", indices = {@Index(value = {"date"}, unique = true)})
  1. 为 Room 提供对字段的访问权:在您的情况下,您希望WeatherEntry是只读类:Sunshine 将下载并显示天气数据,但永远不会修改这些天气数据。

    要实现此目的,将字段保持私有状态,保留提供的 getter 函数并额外创建一个构造函数,允许 Room 设置WeatherEntry的每一个字段。这样一来,Room 便可为我们创建WeatherEntity对象,却又可以防止这些对象在构建完成后被他人编辑。
    public WeatherEntry(int id, int weatherIconId, Date date, double min, double max, double humidity, double pressure, double wind, double degrees) {
        this.id = id;
        this.weatherIconId = weatherIconId;
        this.date = date;
        this.min = min;
        this.max = max;
        this.humidity = humidity;
        this.pressure = pressure;
        this.wind = wind;
        this.degrees = degrees;
    }
  1. 只应向 Room 公开一个构造函数:Room 编译的实体不能包含两个构造函数,因为它不知道该使用哪一个。由于 Room 不需要不带int id的构造函数,您可以使用@Ignore注解将其隐藏起来,让 Room 看不到它。

可在此处查看此步骤的代码差异。

如何创建 DAO(数据库访问对象)

接下来,您将为WeatherEntry实体创建一个@Dao(数据库访问对象的缩写)。DAO 是定义您可以对数据库数据应用的读写操作的抽象类或接口。让我们看一个文档中的简单示例 DAO,它对应的是之前的示例User.java实体:

@Dao // Required annotation for Dao to be recognized by Room
public interface UserDao {
    // Returns a list of all users in the database
    @Query("SELECT * FROM user")
    List<User> getAll();

    // Inserts multiple users
    @Insert
    void insertAll(User... users);

    // Deletes a single user
    @Delete
    void delete(User user);
}

DAO 唯一需要的是@Dao注解。要让您的 DAO 具有实用性,需要定义函数签名,并为它们添加@Insert@Delete@Update@Query注解。@Insert@Delete@Update便利注解,它们创建的函数执行其顾名思义的功能。

示例void insertAll(User... users);展示了您如何指定自己可以插入可变数量的Users -- 此函数将接受任意数量的User对象或User对象数组,并将它们插入数据库。

正如您在示例中所见,您可以传入User实体对象作为参数,或从 Dao 函数返回User实体对象。您可以对任何实体类执行此操作。

带参数的 @Query

如果便利注解不能涵盖您想执行的操作,请使用@Query@Query允许您编写 SQLite 来创建数据库读/写操作。值得注意的是,您可以通过为查询字符串中的参数追加冒号 (:) 提供参数,这些参数传入的函数带有@Query注解。例如,如果您要定义按名称查找用户的函数,其内容可能类似于如下:

@Query("SELECT * FROM user WHERE first_name LIKE :first AND "
           + "last_name LIKE :last LIMIT 1")
User findByName(String first, String last);

第一个和最后一个参数以:first:last形式包括在查询字符串中。因此如果您调用函数findByName("Jane", "Doe"),系统将调用查询SELECT * FROM user WHERE first_name LIKE Jane AND last_name LIKE Doe LIMIT 1 ,并返回一行字符串,该字符串将自动转换成User对象。

轮到您了:为 WeatherEntry 创建 DAO

为名为WeatherDaoWeatherEntry创建 DAO。步骤如下:

  1. data.database软件包(与WeatherEntry相同的软件包)中,建立一个名为WeatherDao.java的新接口
  2. 为接口WeatherDao添加@Dao注解。
  3. 定义 void bulkInsert函数,该函数用于插入任意数量的WeatherEntry对象。当应用从服务器收到天气条目列表时,将会使用bulkInsert将它们放入数据库。
@Insert
void bulkInsert(WeatherEntry... weather);
  1. 此外,对于bulkInsert,您还想使用OnConflictStrategy.REPLACE,以便在 Sunshine 重新下载预报时,用新的天气预报替换旧的天气预报。您可以使用注解属性来实现此目的,例如:
@Insert(onConflict = OnConflictStrategy.REPLACE)
  1. 定义getWeatherByDate函数,该函数接受Java.util.Date对象并返回该日期的天气。Date对象不会转换成String值来满足查询目的。您将在下一步中了解有关Date对象 TypeConverter 的信息。目前只需要知道 Room 有办法将Date对象自动转换成long,并假定您可以将Date参数当作long使用。
@Query("SELECT * FROM weather WHERE date = :date")
WeatherEntry getWeatherByDate(Date date);

可在此处查看此步骤的代码差异。

如何创建数据库

您已获得@Entity及其@Dao,现在该创建实际的@Database类了。以下是一个简单示例,它使用了文档User实体和 DAO:

@Database(entities = {User.class}, version = 1) //Entities listed here
public abstract class AppDatabase extends RoomDatabase {
    public abstract UserDao userDao(); //Getters for Dao
}

要创建Database类,您需要执行以下操作:

构建数据库的代码如下所示:

AppDatabase database = Room.databaseBuilder(getApplicationContext(),
        AppDatabase.class, "database-name").build();

同时运行多个数据库实例会引发一致性问题,举例来说,如果您试图通过一个实例读取数据库,同时通过另一个实例写入数据库,就会引发这种问题。为确保您只创建一个RoomDatabase实例,您的数据库类应为单一实例

要执行数据库查询,您需要通过调用在RoomDatabase子类中提供的函数访问 DAO:

List<User> allUsers = database.userDao().getAll();

轮到您了:创建 Sunshine 数据库

完成下列步骤来创建SunshineDatabase

  1. data.database软件包中,新建一个名为SunshineDatabase.java的类。
  2. SunshineDatabase设置为扩展RoomDatabase的抽象类。
  3. 使用@Database为该类添加注解
  4. @Database 添加 version 和 entities 属性:为数据库添加entities属性并使用WeatherEntry.class作为值。还要添加version属性并将其值设置为 1:
@Database(entities = {WeatherEntry.class}, version = 1)
  1. 为您的 WeatherDao 添加抽象函数:添加一个名为weatherDao的抽象函数,该函数返回WeatherDao的实例:
public abstract WeatherDao weatherDao();
  1. SunshineDatabase设置成单一实例:您可以查看一个示例,了解如何在WeatherNetworkDataSource类中执行此操作,该类是另一个只需要运行一个实例的类。您需要创建一个名为sInstanceSunshineDatabase类型静态变量和一个锁对象来确保线程安全。然后创建一个名为getInstance()的静态函数,该函数在sInstance存在时返回它,不存在时创建SunshineDatabasegetInstance函数可从以下代码中复制:
private static final String DATABASE_NAME = "weather";

// For Singleton instantiation
private static final Object LOCK = new Object();
private static volatile SunshineDatabase sInstance;

public static SunshineDatabase getInstance(Context context) {
    if (sInstance == null) {
        synchronized (LOCK) {
           if (sInstance == null) {
                sInstance = Room.databaseBuilder(context.getApplicationContext(),
                    SunshineDatabase.class, SunshineDatabase.DATABASE_NAME).build();
            }
        }
    }
    return sInstance;
}

您的数据库是一个扩展RoomDatabase抽象类。它带有@Database注解,其中定义了它的实体。它随后还为您的每个DAO建立了抽象getter函数。它同时也是单一实例

TypeConverter

WeatherEntry类有一个java.util.Date对象,但您无法将其原样存储在数据库中,因为SQLite 没有 Date 数据类型。要将其转换成可以存储在数据库中的类型,您需要TypeConverter

要在 Java 类型与 SQLite 支持的数据类型之间进行转换,您需要定义转换函数,并通过注解告知 Room 有关这些函数的信息。具体而言,您需要执行以下操作:

轮到您了:实现 TypeConverter

  1. data.database.DateConverter.java取消注释。类中已为您编写了代码。它包含两个带@TypeConverter注解的函数,这两个函数用于实现从Datelong,然后从longDate的类型转换。
  1. 将 TypeConverter 添加到SunshineDatabase:获得这些带注解的转换器函数后,您需要让SunshineDatabase察觉到转换器类。您可以通过为数据库添加@TypeConverters注解并添加包含@TypeConverter函数的类来实现此目的:
@Database(entities = {WeatherEntry.class}, version = 1)
@TypeConverters(DateConverter.class)
public abstract class SunshineDatabase extends RoomDatabase { ...
  1. 运行您的代码以确保未犯下明显的错误。

轮到您了:Room 编译时验证

现在您已将 DAO 添加到数据库,可以见证 Room 的其中一项强大功能了:编译时验证 SQLite 代码。

  1. 更改您的WeatherDAO中的getWeatherByDate函数,使其出现拼写错误——将原来的"date = :date"改为"date = :data"
@Query("SELECT * FROM weather WHERE data = :date")
WeatherEntry getWeatherByDate(Date date);
  1. 运行您的代码。应用将不会运行。如果数据库其余部分设置正确,您应该会看到一则有帮助的错误消息:

可在此处查看此步骤的代码差异。

后续步骤

您为 Sunshine 建立了数据库,并建立了用于访问该数据库的 DAO。您现在要做的是创建DetailActivityViewModelLiveData

ViewModel

ViewModel类设计为以注重生命周期的方式容纳和管理界面相关数据。这可以让数据在屏幕旋转等配置变更后继续存在。您可以通过将界面数据与界面控制器分开实现职责分离:ViewModel 负责处理界面状态的提供、操纵和存储,界面控制器负责显示界面状态。

ViewModel 通常与作为其数据提供对象的界面控制器关联。它们利用LifecycleOwnerLifecycle类执行此操作:

当您获得 ViewModel 时,需要为组件提供LifecycleOwner。这通常是 Activity 或 Fragment。通过提供 LifecycleOwner,您可以在 ViewModel 与 LifecycleOwner 之间建立联系。

ViewModel 生命周期

ViewModel生命周期作用域不同于其关联的界面控制器。这是因为界面控制器会在配置变更时销毁并重建。ViewModel 则不然。

以下示意图显示的是创建、旋转并完成 Activity 时,ViewModel 生命周期Activity 生命周期的对比。

ViewModel继续存在,直至其关联的界面控制器(在本例中是一个 Activity)完全销毁为止。如需查看ViewModel的进一步阐述和简单示例,您可以阅读此博文

ViewModel通常包含LiveData对象;我们稍后将通过示例对这一关系进行阐述。

轮到您了:DetailActivityViewModel

在此部分,您将通过对代码执行取消注释和复制操作,了解ViewModel、Activity 以及LiveData之间联系的工作方式。然后您需要在处理MainActivity时自行编写完整的代码。

DetailActivity显示一天的天气预报。它关联有一条界面状态数据:一个WeatherEntry

  1. 打开您的应用或模块的build.gradle 文件,并为Lifecycle 内容库添加下列依赖项:
compile "android.arch.lifecycle:runtime:1.0.0-alpha9"
compile "android.arch.lifecycle:extensions:1.0.0-alpha9"
annotationProcessor "android.arch.lifecycle:compiler:1.0.0-alpha9"
  1. 同步 Gradle。
  2. 打开ui.detail.DetailActivityViewModel并对整个文件取消注释。其内容如下所示:
public class DetailActivityViewModel extends ViewModel {

   // Weather forecast the user is looking at
   private WeatherEntry mWeather;

   public DetailActivityViewModel() {

   }

   public WeatherEntry getWeather() {
       return mWeather;
   }

   public void setWeather(WeatherEntry weatherEntry) {
       mWeather = weatherEntry;
   }
}

此类扩展ViewModel,为其赋予ViewModel的生命周期作用域。单个WeatherEntry对象就包含应用显示DetailActivity所需的全部数据。

  1. 打开ui.detail.DetailActivity.DetailActivity 扩展 LifecycleActivity而非AppCompatActivity
  2. DetailActivity中,添加一个名为mViewModelDetailActivityViewModel变量
  3. 将以下行添加到DetailActivityonCreate中:
mViewModel = ViewModelProviders.of(this).get(DetailActivityViewModel.class);

首次创建DetailActivity时,ViewModelProviders.of函数在onCreate中调用。它会新建一个DetailActivityViewModel实例。

之后,如果发生配置变更并重建了 Activity,ViewModelProviders.of函数将再次在onCreate 中调用。这次它返回的将是预先存在并与DetailActivity关联的ViewModel实例。

您随后可以调用mViewModel.getWeather()以获得对数据的访问权,无论是否发生配置变更,这些数据都会得到保留。

将 ViewModel 与 LiveData 结合使用好处更多。

LiveData

LiveData是一个具有生命周期感知能力的数据容器类。它保留某个值并允许对该值进行观察

它是数据容器,因为它确实容纳了一些数据。例如:

MutableLiveData<String> name = new MutableLiveData<String>();
name.setValue("Lyla");

这是一个LiveData 实例,它容纳了一个String对象,其当前值为"Lyla"。

观察是指观察者模式,系指如下情形:被称为主体的对象具有一系列被称为观察者的关联对象。当主体的状态发生变化时,它会通知所有观察者,通常是通过调用它们的其中一个函数进行通知。

LiveData为例,主体是LiveData,观察者是作为Observer类的子类的对象。每当调用setValue并且主体的状态发生变化时,都会触发活动观察者。

LiveData 保留有关联观察者和LifecycleOwner列表。LifecycleOwner的通常是 Activity 或 Fragment.。一般而言,Observers仅在其关联的LifecycleOwner在屏幕上时才视为活动观察者。这意味着它处于STARTEDRESUMED 状态。之所以称LiveData具有生命周期感知能力,是因为LiveData会追踪LifecycleOwners这一事实。

一般通过以下方式为LiveData对象创建观察者:

name.observe(<LIFECYCLE OWNER>, newName -> {
   // Do something when the observer is triggered; usually updating the UI
});

LifecycleOwner传入 observe 函数。这是与Observer关联的LifecycleOwner

轮到您了:添加 LiveData

DetailActivity将观察MutableLiveData。此MutableLiveData 将容纳一个WeatherEntry。通过postValue()更新 MutableLiveData 时,DetailActivityObserver将收到通知。DetailActivity将随即更新界面。

执行以下操作以创建并观察您的第一个LiveData

  1. DetailActivityViewModel中,将mWeatherWeatherEntry更改为MutableLiveData<WeatherEntry>。"可变"LiveData 可能发生变化。
  2. DetailActivityViewModel中,在构造函数内初始化您的新MutableLiveData<WeatherEntry>对象。
public DetailActivityViewModel() {
    mWeather = new MutableLiveData<>();
}
  1. DetailActivityViewModel中,更新mWeather的 getter 函数,以返回新的MutableLiveData对象。
  2. DetailActivityViewModel中,将setWeather()更改为mWeather.postValue(weatherEntry);
  3. DetailActivityonCreate中,观察LiveData
mViewModel.getWeather().observe(this, weatherEntry -> {
   // Update the UI
});
  1. 在新的观察者中,更新不同的界面元素。有一个bindWeatherToUI()函数,完全出于此目的接受WeatherEntry
if (weatherEntry != null) bindWeatherToUI(weatherEntry);

最后一步意味着,每当调用mViewModel.setWeather()时,都将调用MutableLiveDatapostValue()函数。postValue()会触发所有观察LiveData的观察者。在此情况下,有一个观察者在观察LiveData,它就是您刚创建的、用于更新DetailActivity界面的那个观察者。

因此,简言之,调用DetailActivityViewModelsetWeather函数时将会触发界面更新。

了解 LiveData 和 ViewModel 的实用效果

要了解更新DetailActivityViewModel.mWeather会对界面产生怎样立竿见影的影响,请将以下代码添加到DetailActivitiyonCreate()

AppExecutors.getInstance().diskIO().execute(()-> {
   try {

       // Pretend this is the network loading data
       Thread.sleep(4000);
       Date today = SunshineDateUtils.getNormalizedUtcDateForToday();
       WeatherEntry pretendWeatherFromDatabase = new WeatherEntry(1, 210, today,88.0,99.0,71,1030, 74, 5);
       mViewModel.setWeather(pretendWeatherFromDatabase);

       Thread.sleep(2000);
       pretendWeatherFromDatabase = new WeatherEntry(1, 952, today,50.0,60.0,46,1044, 70, 100);
       mViewModel.setWeather(pretendWeatherFromDatabase);

   } catch (InterruptedException e) {
       e.printStackTrace();
   }
});

这种快速却又粗糙的方法能够模拟网络请求或数据库调用之类以异步方式更改天气数据的操作。代码启动另一个线程,后者等待四秒,调用包含更新后WeatherEntry,postValue(),再等待两秒,然后再次调用包含不同WeatherEntrypostValue()DetailActivityLiveData之间的观察者关系会在LiveData发生变化时更新DetailActivity的界面。

如果您旋转手机,就会注意到显示的是同一WeatherEntry。这是因为DetailActivityViewModel会恢复与同一LiveData对象的连接,该对象会在配置变更后继续存在,因为它存储在ViewModel中。

可在此处查看此步骤的代码差异。

到目前为止,您已使用了 Room、ViewModelLiveData,其中囊括了您打算了解的所有内容库。但您尚未了解它们的协作方式。界面与网络以及您刚创建的数据库是完全分离的。您现在需要做的是,向界面公开网络和数据库数据。这是存储区类的职责。

存储区类

存储区类负责处理数据操作。它们向应用的其余部分提供一个干净的 API 以获取应用数据。更新数据时,它们知道从何处获取数据以及进行哪些 API 调用。它们是不同数据源(持久模型、网络 Service、缓存等)之间的媒介。

有别于 Room、LiveDataViewModel,您将创建的存储区类不会扩展或实现架构组件库。它就是应用架构指南中介绍的一种应用数据组织方式。

在您的情况下,存储区类将管理您新创建的WeatherDao与您的WeatherNetworkDataSource之间的数据通信,前者赋予您对数据库中所有内容的访问权,后者控制从我们的 mock 服务器获取数据的Service类。

只有存储区类直接与数据库或网络软件包通信,数据和网络软件包不会与位于各自软件包之外的类通信。因此,存储区将是界面用来获取在屏幕上显示的数据的 API。

总体思路

下载天气数据并将其保存在数据库中涉及几个类的协作,每个类都执行不同的功能:

SunshineRepository精心安排所有数据相关命令,将它们委托给WeatherNetworkDataSourceWeatherDao。观察WeatherNetworkDataSource以确定其完成数据获取的时间,从而知晓何时更新数据库。

WeatherNetworkDataSource执行所有网络操作。提供可信的最近下载网络数据来源。通过容纳LiveData对象来执行此操作,该对象存储最近下载的数据,每当成功执行获取时,就会更新该数据。

SunshineSyncIntentService您将使用IntentService来执行实际同步,这样在应用关闭时,该 Service 将有更多时间完成下载和数据库保存。

WeatherDao用于对天气表格执行所有数据库操作。

下面分四个部分说明网络同步是如何触发的:

观察

  1. SunshineRepository观察由WeatherNetworkDataSource.提供的LiveData

启动 Service

  1. SunshineRepository检查是否有足够的数据,否则...
  2. WeatherNetworkDataSource创建并立即启动SunshineSyncIntentService.

执行获取操作

  1. SunshineSyncIntentService获取WeatherNetworkDataSource的实例并利用它来启动获取。
  2. WeatherNetworkDataSource执行实际的天气数据获取,将该操作委托给OpenWeatherJsonParserNetworkUtils。完成后,将更新后的值发布到存储最近下载数据的LiveData

保存在数据库中

  1. 最后,由于SunshineRepository在观察LiveDataSunshineRepository将更新数据库

现在您将编写用于触发和完成同步的代码。完成这项工作后,您需要添加该代码以检查是否确实需要进行同步。

轮到您了:建立存储区

  1. 为让您不必进行复制和粘贴,已经为您准备了SunshineRepository.java框架类。对此代码取消注释。它包含:

    构造函数和getInstance()函数。与 SunshineDatabase 一样,SunshineRepository也是单一实例。

    initializeData(), deleteOldData(), isFetchNeeded()startFetchWeatherService()的空函数

轮到您了:建立 LiveData

您将使用LiveData变量来存储最近从网络下载的数据。

  1. WeatherNetworkDataSource,中,创建一个名为mDownloadedWeatherForecastsMutableLiveData成员变量。它应该是私有变量,存储WeatherEntry对象数组,因为这是数据同步操作返回的内容。
// LiveData storing the latest downloaded weather forecasts
private final MutableLiveData<WeatherEntry[]> mDownloadedWeatherForecasts;
  1. WeatherNetworkDataSource的构造函数中,将mDownloadedWeatherForecasts.实例化
mDownloadedWeatherForecasts = new MutableLiveData<WeatherEntry[]>();
  1. WeatherNetworkDataSource 中,为mDownloadedWeatherForecasts创建一个名为getCurrentWeatherForecasts的 getter 函数。
public LiveData<WeatherEntry[]> getCurrentWeatherForecasts() {
    return mDownloadedWeatherForecasts;
}

轮到您了:启动 Service

现在该编写启动IntentService的代码了。

  1. SunshineRepository中,完成startFetchWeatherService()函数。让它从WeatherNetworkDataSource调用startFetchWeatherService(),以创建并启动IntentService
private void startFetchWeatherService() {
    mWeatherNetworkDataSource.startFetchWeatherService();
}
  1. SunshineRepository中,添加到initalizeData()函数。ViewModel请求数据时,系统将调用initializeData()。目前,暂时调用startFetchWeatherService()。未来您将添加一项检查以确认 Sunshine 是否需要启动同步。
public synchronized void initializeData() {

    // Only perform initialization once per app lifetime. If initialization has already been
    // performed, we have nothing to do in this method.
    if (mInitialized) return;
    mInitialized = true;

    startFetchWeatherService();
}

轮到您了:完成获取数据的逻辑

现在 Service 已经运行,让我们指示该 Service 获取数据并将其保存在mDownloadedWeatherForecastsLiveData中。

  1. InjectorUtils,,对provideRepository()provideNetworkDataSource()取消注释InjectorUtils的用途是提供依赖注入的静态函数。

    依赖注入的思路是,您应该将必备组件提供给类使用,而不是在类自身内部创建这些组件。举例来说,Sunshine 代码对此的处理方法是,它不会在SunshineRepository内部构建WeatherNetworkDatasource,而是通过InjectorUtilis创建WeatherNetworkDatasource并将其传入SunshineRepository构造函数。这样做的其中一个优点是,可以在测试时更方便地替换组件。您可以在此处了解有关依赖的详情。目前只需要知道InjectorUtils中的函数创建您需要的类,以便将它们传入构造函数。
  2. SunshineSyncIntentService中的onHandleIntent()内,调用InjectorUtils .provideNetworkDataSource以获取对WeatherNetworkDataSource.的引用
@Override
protected void onHandleIntent(Intent intent) {
    Log.d(LOG_TAG, "Intent service started");
    WeatherNetworkDataSource networkDataSource = InjectorUtils.provideNetworkDataSource(this.getApplicationContext());

}
  1. SunshineSyncIntertService中的onHandleIntent()内,调用WeatherNetworkDataSourcefetchWeather()函数。
networkDataSource.fetchWeather();
  1. WeatherNetworkDataSourcefetchWeather()函数的末尾,使用新的预报更新mDownloadedWeatherForecasts中保留的值。您应该使用postValue()执行此操作,因为调用将在主线程以外的其他线程中完成。
mDownloadedWeatherForecasts.postValue(response.getWeatherForecast());

轮到您了:观察 LiveData

调用initalizeData时,它会启动一系列 Event,这会派生一个SunshineSyncIntentService,后者会启动同步并将产生的数据保存至mDownloadedWeatherForecasts。最后一步是让SunshineRepository观察mDownloadedWeatherForecasts并更新数据库。

  1. SunshineRepository的构造函数中,获取mDownloadedWeatherForecasts。使用您编写的getCurrentWeatherForecasts函数获取mDownloadedWeatherForecasts。这与 Activity 利用 get 方法从ViewModel获取并观察LiveData很相似。
LiveData<WeatherEntry[]> networkData = mWeatherNetworkDataSource.getCurrentWeatherForecasts();
  1. SunshineRepository ,观察mDownloadedWeatherForecasts。在SunshineRepository的构造函数中,使用observeForever函数观察mDownloadedWeatherForecasts
networkData.observeForever(newForecastsFromNetwork -> {

});
  1. mDownloadedWeatherForecasts发生变化时,触发数据库保存。SunshineRepository中的观察者中,调用WeatherDaobulkInsert()函数。请注意,数据库操作必须在主线程以外的其他线程中完成。利用您的AppExecutor的磁盘 I/O 执行器来提供合适的线程:
networkData.observeForever(newForecastsFromNetwork -> {
        mExecutors.diskIO().execute(() -> {
            // Insert our new weather data into Sunshine's database
            mWeatherDao.bulkInsert(newForecastsFromNetwork);
            Log.d(LOG_TAG, "New values inserted");
        });
});

如果您想运行代码(绝对是个好主意),请从DetailActivityonCreate调用以下代码:

// THIS IS JUST TO RUN THE CODE; REPOSITORY SHOULD NEVER BE CREATED IN
// DETAILACTIVITY
InjectorUtils.provideRepository(this).initializeData();

首次运行代码时,您看到的日志输出应如下所示:

  1. DetailActivity中调用InjectorUtils.provideRepository时,所有命令都会运行。请注意,并不适合在此处调用initializeData(),这完全是为了说明执行同步时会发生的情况。
  2. 何时调用SunshineData.intializeData()
  3. onHandleIntent(),包括何时SunshineSyncIntentService调用InjectorUtils.provideWeatherNetworkDataSource()。注意它为何没有新建网络数据源,而是获取静态单一实例。
  4. 表示WeatherNetworkDataSource.fetchWeather()正在运行。
  5. 表示触发SunshineRepositoryobserver时,它会将数据存储到数据库中。

请注意,DetailActivity适合用来与SunshineRepository进行交互;Activity 和其他界面控制器绝不应直接与存储区进行交互。那是 ViewModel 的职责;接下来您将处理这部分工作。

可在此处查看此步骤的代码差异。

此时,您已执行与服务器的同步。您的存储区会借助观察的强大功能自动更新您的数据库。现在您需要让 ViewModel 从存储区获取天气数据。

轮到您了:按日期公开 WeatherEntry

您的DetailActivityViewModel需要来自数据库的数据,也就是说,它需要一天的天气信息作为LiveData对象。在WeatherDao中,您有一个getWeatherByDate()函数,它返回WeatherEntry。这已接近完美。

Room 具有一个极其方便的功能,适合在您希望LiveData对象能够与数据库中的任何内容保持同步时使用 - Room 可以返回 LiveData 包装的对象。只要数据库数据发生变化,此LiveData就会触发其观察者。它甚至会为您从主线程以外的其他线程加载这些数据库数据。

按下列步骤从 Room 获取LiveData

  1. WeatherDao中,更新getWeatherbyDate()以返回LiveData<WeatherEntry>

WeatherDao返回您需要的数据,您可以让DetailActivityViewModel直接与WeahterDao通信。但这样做会颠覆存储区类的全部意义;存储区应是所有数据操作的唯一可信来源。因此,DetailActivityViewModel将从SunshineRepository获取这些数据。而SunshineRepository则会向WeatherDao请求LiveData

  1. SunshineRepository中,添加getWeatherbyDate()函数。该函数应接受Date对象并返回LiveData<WeatherEntry>。它应该使用存储在SunshineRepository中的WeatherDao对象来获取LiveData对象。
  2. 如果您尚未从DetailActivityonCreate中移除以下内容,请现在执行这项操作:
// THIS IS JUST TO RUN THE CODE; REPOSITORY SHOULD NEVER BE CREATED IN
// DETAILACTIVITY
InjectorUtils.provideRepository(this).initializeData();
  1. SunshineRepository.getWeatherByDate中,调用initializeData()您将执行数据的"延缓"实例化——收到请求时再从网络加载。这展现了存储区有用的一面:由于所有数据请求都是通过此 API 发起的,您需要确保每次执行getWeatherByDate()时都会触发数据初始化。这在您直接访问WeatherDao的情况下是无法实现的。

    您可以将 initalizeData()上的访问修饰符更改为private,因为它现在只在存储区内使用。

ViewModelProvider 工厂

接下来,您需要使用存储区来获取数据。但有一个问题,ViewModel没有对SunshineRepository的引用。

最便于测试的代码设计方式是将SunshineRepository的一个实例传入DetailActivityViewModel——这样您就可以在测试视图模型时方便地模拟存储区。

ViewModelProvider自动调用的构造函数是默认构造函数——它不带任何参数。如果您想为视图模型创建其他构造函数,需要建立视图模型提供程序工厂。对DetailViewModelFactory取消注释时可以看到初始代码:

public class DetailViewModelFactory extends ViewModelProvider.NewInstanceFactory {

   private final SunshineRepository mRepository;

   public DetailViewModelFactory(SunshineRepository repository) {
       this.mRepository = repository;
   }

   @Override
   public <T extends ViewModel> T create(Class<T> modelClass) {
       //noinspection unchecked
       return (T) new DetailActivityViewModel(mRepository);
   }
}

要创建视图模型提供程序工厂,您必须:

  1. 扩展ViewModelProvider.NewInstanceFactory
  2. 将您需要的任何构造函数参数放入DetailViewModelFactory。在本例中,您放入的是存储区。
  3. 替换调用您的自定义视图模型构造函数的create()函数

要在随后使用工厂,您需要调用

// Get the ViewModel from the factory
DetailViewModelFactory factory = new DetailViewModelFactory(repository);

mViewModel = ViewModelProviders.of(this, factory).get(DetailActivityViewModel.class);

轮到您了:创建 DetailViewModelFactory

您将完成DetailViewModelFactory的编写并使用InjectorUtils类来创建它。框架代码已经就绪,但您还需要随存储区一起传入Date

  1. DetailActivityViewModel中,更改构造函数,使其同时接受SunshineRepositoryjava.util.Date
public DetailActivityViewModel(SunshineRepository repository, Date date)
  1. 如果您尚未对DetailViewModelFactory框架代码取消注释,请现在执行这项操作。
  2. DetailViewModelFactory, 中,添加将Date传入构造函数的功能。可以参考存储区的传入方式。
  3. InjectorUtils中,对provideDetailViewModelFactory取消注释。它使用的构造函数签名应匹配您添加的新参数。
  4. DetailActivityonCreate()中,使用以下代码建立今天的Date
Date date = SunshineDateUtils.getNormalizedUtcDateForToday();
  1. DetailActivity中,使用InjectorUtils.provideDetailViewModelFactory()获取对DetailViewModelFactory的引用。
  2. DetailActivity中,使用DetailViewModelFactory获取对视图模型的访问权:
ViewModelProviders.of(this, factory).get(DetailActivityViewModel.class);

轮到您了:按日期获取 WeatherEntry

现在您已获得对DateSunshineRepository的访问权:

  1. DetailActivityViewModel中,使用日期从存储区获取天气条目LiveData
  2. 清理代码。您需要完成以下这几项清理:
  1. 首先,视图模型中的LiveData不再由您的应用进行修改。将其从MutableLiveData更改为LiveData
  2. 同样,移除setWeather()函数,因为您无法再使用它。
  3. DetailActivity中,使用thread.sleep()移除模拟网络请求的代码。
  4. 如果 Activity 中仍存在对initalizeData的调用或任何与SunshineRepository的直接通信,务必将其移除!

运行代码,您应该会看到随机产生的天气数据。

可在此处查看此步骤的代码差异。

Sunshine 现在能够从网络执行基本数据加载,将其保存在数据库中,并显示出来。您应该改进以下这两个有问题的低效环节,然后再继续加入任何新功能:

  1. 每次创建DetailActivityViewModel时它都会重新查询网络。您应该在启动SunshineSyncIntentService之前检查本地缓存已包含的内容。毕竟,本地缓存的意义就是避免不必要地重新下载数据。这同样也展示了SunshineRepository如何调节和精心安排应用内的数据流——完整的同步将包括检查 DAO 以确认是否存在数据,如果不存在则执行网络同步,然后是最后一步更新 DAO。
  2. 应用并非是为了显示历史天气数据而打造,按照设计它只能显示未来天气数据。没错,根本就没有删除旧数据的流程!如果您的用户喜爱 Sunshine 并使用了一整年,用户的手机上就存储了 365 天无用的历史天气数据。

轮到您了:必要时获取

您可以通过许多不同方式来决定是否下载数据。对 Sunshine 而言,您需要完成以下工作:

  1. 计算数据库中晚于当前日期的天数
  2. 如果天数少于两周(14 天),则下载更多数据

我们之所以使用两周,是因为这是您想要在MainActivity中显示的数据量,接下来您将处理这部分工作。要实现以上工作,请执行以下操作:

  1. WeatherDao中,为countAllFutureWeather创建函数签名。这应该是一个查询函数,该函数使用SQL COUNT命令来获取未来天气日期数量的列表。
  2. SunshineRepository中,完成isFetchNeeded()函数。这应该检查您是否有至少 14 天的天气数据,如果天气数据少于 14 天,则返回 true。
  3. SunshineRepository's initalizeData()函数中,使用isFetchNeeded()函数来确定是否启动SunshineSyncIntentService您需要使用磁盘 I/O 线程来执行以下操作:
mExecutors.diskIO().execute(() -> {//CODE ON DISK I/O THREAD HERE});

您现在应该注意到了,当您首次运行应用后再次将其打开时,它不会抓取新的随机天气数据。

轮到您了:删除旧数据

现在让我们删除过时的旧数据:

  1. WeatherDao中,为deleteOldData()创建函数签名。此函数应该会删除给定日期前的所有日期。尽管名称中包含"delete",但您需要使用的是@Query注解而非@Delete注解。这是因为,您需要编写一些 SQL 代码来定义WHERE子句。
  2. SunshineRepository中,完成deleteOldData()函数。您需要获取deleteOldData()的当前日期,您可以使用在isFetchNeeded()中使用的相同代码:
Date today = SunshineDateUtils.getNormalizedUtcDateForToday();
  1. SunshineRepository的网络数据观察者中,删除旧的天气预报,然后再插入新数据。调用deleteOldData()

每当您保存数据到数据库时,此操作都会删除所有旧数据。由于应用采用OnConflictReplace策略并通过日期确保了唯一性,因此如果它获取了新的天气信息,同样会更新数据库中的已有内容。

可在此处查看此步骤的代码差异。

与 DetailActivity 一样,Sunshine 的所有界面都是为了名为 MainActivity、能够正常工作的 RecyclerView 屏幕而打造。在此代码步骤中,您将运用所学知识为 MainActivity 创建 ViewModel、ViewModel 提供程序工厂以及正确的 LiveData。

由于其中的许多步骤您已完成过一次,因此可以借此机会运用所学知识:

可在此处查看此步骤的代码差异。

可在此处查看此步骤的代码差异。

可在此处查看此步骤的代码差异。