Google Samples 学习之 todo-mvp(三)

本文着重对 data 文件夹进行分析,其内容为 Model 层主要代码。 前置文章: Google Samples 学习之 todo-mvp(一) Google Samples 学习之 todo-mvp(二)

结构分析

data 目录的结构如下图所示:

data.PNG

各个文件或子目录的职责和功能为:

  • Task 类:数据模型类
  • TasksDataSource 接口:数据操作接口,方便 Presenter 层使用
  • TasksRepository 类:数据操作类,实现 TasksDataSource
  • remote 目录:获取远程数据
  • local 目录:获取本地数据

代码分析

任务的主要属性是标题和内容,分别用字段 mTitle 和 mDescription 表示,mCompleted 表示任务是否完成,mId 表示任务的存储 id。

Task 的代码相对简单,主要注意其中的不同构造器的作用,当然代码中的构造器都附有注释,理解难度不大。

任务状态的表示为“已完成”和“进行中”两种,查询方法为:

1
2
3
4
5
6
7
    public boolean isCompleted() {
        return mCompleted;
    }

    public boolean isActive() {
        return !mCompleted;
    }

另外,特别要注意的是任务构造器中只有 mId 被标记为 @NonNull,也就是说标题和内容都可以为空。这样在任务列表显示页面显示任务信息的时候,如果标题为空则要显示任务内容,如果内容也为空则返回内容的值,相关代码为:

1
2
3
4
5
6
7
8
    @Nullable
    public String getTitleForList() {
        if (!Strings.isNullOrEmpty(mTitle)) {
            return mTitle;
        } else {
            return mDescription;
        }
    }

内部还包含两个接口 LoadTasksCallback 和 GetTaskCallback:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
    interface LoadTasksCallback {

        void onTasksLoaded(List<Task> tasks);

        void onDataNotAvailable();
    }

    interface GetTaskCallback {

        void onTaskLoaded(Task task);

        void onDataNotAvailable();
    }

它们定义了获取所有任务和单个任务之后的回调接口,以及获取操作失败的处理方法接口。根据其中的代码注释,只定义两个接口是为了保证代码的简单,实际项目中应根据需要制定更多的接口。

接口中方法的名称很明确地解释了它自身的作用,不再赘述。 要注意的是 completeTask 和 activateTask 方法需要提供两个重载的方法,处理不同参数类型:Task 和 taskId(实际是 String)。因为任务实例有可能没有定义 id。

从不同数据来源获取数据,并将它们载入内存缓存中。而且要注意数据的同步操作比较简单:仅仅当本地数据不存在或为空的时候才向服务器上查询数据。

1
2
3
4
5
6
7
8
9
    private static TasksRepository INSTANCE = null;

    private final TasksDataSource mTasksRemoteDataSource;

    private final TasksDataSource mTasksLocalDataSource;

    Map<String, Task> mCachedTasks;

    boolean mCacheIsDirty = false;

可以看出:

  • 其中 INSTANCE 为本类单例的实例引用,初始化为 null 即表明要使用延迟加载方式构建实例
  • 保存本地和远程服务器的数据操作类实例引用 mTasksRemoteDataSource 和 mTasksLocalDataSource,方便获取和同步数据
  • 缓存 mCachedTasks 使用 Map 实现
  • mCacheIsDirty 表示本地数据是否为 dirty,如果为 dirty 则需要从远程服务器获取数据,应用初始化时默认不需要更新
  • 一般来说字段应该定义为 private,但是为了方便会将测试用的字段定义为 package private

本类为“懒汉式”单例,构造器私有,需要传入依赖初始化 mTasksRemoteDataSource 和 mTasksLocalDataSource。 特别要注意的是,本类提供了手动的实例销毁方法 destroyInstance。

工作的流程是:

  1. 如果内存缓存非空,而且不需要更新,则直接从缓存获取数据并结束方法,否则继续下面的流程
  2. 如果本地数据为 dirty,则从远程获取(调用方法 getTasksFromRemoteDataSource),否则继续下面的流程
  3. 从本地获取数据,成功后更新缓存数据,如果失败则从远程获取数据

其中,要注意对于 LoadTasksCallback.onTasksLoaded 方法,其参数必须是缓存的 Map 实例的 values 生成的 List 引用,这样才能保证回调方法的数据操作正确的应用到缓存中,即:new ArrayList<>(mCachedTasks.values())

工作的流程是:

  1. 如果内存缓存非空,则直接从缓存获取数据并结束方法,否则继续下面的流程
  2. 从本地数据库获取任务,成功后更新缓存数据,获取失败则继续下面的流程
  3. 从远程服务器获取数据,成功后更新缓存数据

大体的操作步骤类似,都是:

  • 调用远程数据操作类实例的相应方法
  • 调用本地数据操作类实例的相应方法
  • 操作缓存数据实例

注意:缓存数据仍然可能为空,所以每个方法都要进行空值判断,如果非空则新建 LinkedHashMap 实例。而且,所有更新缓存的操作都要使用 Map 的 put 方法,而不能提取对应 key 的 value 来进行赋值操作,防止抛出空指针异常。

不需要处理刷新操作,只需要把 dirty 标记设为 true,在下次获取任务的时候就会进行刷新操作。

注意对于方法 refreshCache,因为刚刚同步完成,缓存 dirty 标记应为 false

在获取数据成功后,更新缓存和本地数据库数据,即调用方法 refreshCache 和 refreshLocalDataSource。 refreshLocalDataSource 方法的实现简单粗暴,删除所有数据库数据,然后存入新的缓存数据即可。

类的实现要点有:

  • 持有 TasksDbHelper 的实例引用,用来访问数据库
  • “懒汉式”单例实现
  • 直接调用 TasksDbHelper 的 insert、query、update、delete 方法完成数据的 CRUD
  • 注意查询任务要使用任务的 entryid 而不是数据库自动生成的 id

各个方法对应的 CRUD 为:

  • getTasks 和 getTask:查询
  • saveTask:插入
  • completeTask:修改对应任务 completed 值为 1
  • activateTask:修改对应任务 completed 值为 0
  • deleteTask 和 deleteAllTasks:删除
  • clearCompeletedTask:删除 completed 值为 1 的数据

数据库契约类,定义数据库要用到的常量,包括:

  • 数据表名:TABLE_NAME
  • 数据表列名:所有 COLUMN_NAME_ 开头的常量

要注意的是常量使用了静态内部类包裹,并且实现了内容提供器 api 提供的 BaseColumns 接口,这个接口的内容为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
package android.provider;

public interface BaseColumns
{
    /**
     * The unique ID for a row.
     * <P>Type: INTEGER (long)</P>
     */
    public static final String _ID = "_id";

    /**
     * The count of rows in a directory.
     * <P>Type: INTEGER</P>
     */
    public static final String _COUNT = "_count";
}

其中 _ID 对应 SQLite 数据库的主键 id,也可以用于内容提供器。使用这样统一的接口减少了使用数据库时候重复的代码,而且也方便其他应用使用内容提供器同默认的方式查询。 这里要明白的是 _IDCOLUMN_NAME_ENTRY_ID 是不同的:

  • _ID 是用于本地的数据库存储使用,一般由数据库自动生成,和远程服务器无关
  • COLUMN_NAME_ENTRY_ID 是任务的自有属性,由业务代码生成,是同步任务的凭据,必须在远程服务器中存储相同的数据 使用两个 id 列的主要是为了保证数据正确同步:因为本地的数据库不一定会保存所有任务数据,也不一定会只在这一个客户端生成任务。而且,数据库自动生成的 id 一般都是自然数序列,如果只用它作为同步依据,可能不同客户端会对不同任务生成同样的 id,会造成严重的同步错误。

_COUNT 则是表示表中数据的条目数,所有记录的这个项的值都必须是相同的。此列的数据是为了方便内容提供器调用所创建的。

继承 SQLiteOpenHelper,注意创建数据表所需要的字符串尽量使用字符串常量储存,即使只是表示数据列的类型,比如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
    public static final String DATABASE_NAME = "Tasks.db";

    private static final String TEXT_TYPE = " TEXT";

    private static final String BOOLEAN_TYPE = " INTEGER";

    private static final String COMMA_SEP = ",";

    private static final String SQL_CREATE_ENTRIES =
            "CREATE TABLE " + TasksPersistenceContract.TaskEntry.TABLE_NAME + " (" +
                    TasksPersistenceContract.TaskEntry._ID + TEXT_TYPE + " PRIMARY KEY," +
                    TasksPersistenceContract.TaskEntry.COLUMN_NAME_ENTRY_ID + TEXT_TYPE + COMMA_SEP +
                    TasksPersistenceContract.TaskEntry.COLUMN_NAME_TITLE + TEXT_TYPE + COMMA_SEP +
                    TasksPersistenceContract.TaskEntry.COLUMN_NAME_DESCRIPTION + TEXT_TYPE + COMMA_SEP +
                    TasksPersistenceContract.TaskEntry.COLUMN_NAME_COMPLETED + BOOLEAN_TYPE +
            " )";

这个目录中只有 TasksRemoteDataSource 类一个文件。而且实际上并没有真正地从远程服务器获取数据,而是使用了本地的模拟数据来代替。 模拟服务器处理的方式也很简单:使用 Handler 的 postDelayed 方法延迟处理任务回调方法 onTasksLoaded 和 onTaskLoaded,造成服务器正在传输数据的假象。(延迟处理的时间间隔在本类中用 SERVICE_LATENCY_IN_MILLIS 定义)

注意

  • 真实的情景下,远程访问可能出现各种错误,比如:服务器无响应、返回数据错误等,需要增加错误判断和处理的代码
  • 不必实现部分方法的根据任务 id 操作的重载版本,即 completeTask 和 activateTask,因为 TasksRepository 可以转换参数类型
  • 不必实现 refreshTasks,因为 TasksRepository 会在所有数据来源刷新数据

参考文章

What is the use of BaseColumns in Android

Why/Should we implement BaseColumns when using a Content Provider in Android?