原文:
zh.annas-archive.org/md5/0ffc7f04a3e132a02fea5cc6b989228c译者:飞龙
第五章:将数据绑定到小部件
到目前为止,你一直是手动将数据从数据模型复制到你的表示层,然后再将其复制回来。这种在具有状态的控件之间来回移动数据的行为,在某种程度上是你始终需要做的。数据复制的位置和方式可能会改变,但为了使应用程序工作,这必须完成。在本章中,我们将探讨 Android 提供的一个名为数据绑定的系统。数据绑定为数据的来回复制提供了一种替代方案,同时也为代码的更多重用打开了几个其他设计机会。
数据绑定为你提供了一种方法,可以显著减少应用程序中的样板代码量,同时保持类型安全并提供出色的性能。数据绑定引擎允许你提供用户界面逻辑,该逻辑与布局资源明显分离,并且可以很容易地由应用程序中的许多屏幕重用,同时简化应用程序代码和布局资源文件的复杂性。
在本章中,我们将探讨以下主题:
-
数据绑定存在的理由
-
如何编写数据绑定布局
-
如何在 MVP 设计中使用数据绑定
-
响应式编程和你的数据模型
-
如何在 Activity、Fragment 和小部件中使用数据绑定
探索数据模型和小部件
理论上,小部件可以直接通过持有数据指针来引用它们正在操作的记忆,而不是来回复制数据,但大多数情况下,使用相同的数据格式来存储和编辑是没有意义的。
以文本字符串为例;存储字符串的最佳方式是作为字符数组;每次需要将文本发送到任何地方,无论是通过网络还是显示,都可以简单地从第一个字符读取到最后的字符,每个字符都可以原样传输。例如,“Hello World” 可以存储为字符串长度,后跟每个字符:
这不是存储正在编辑的字符串的好方法;然而,对于编辑来说,最好在光标周围留有缓冲空间,以避免在用户输入和更正时需要来回复制大量数据。例如,如果用户将光标放在单词 “Hello” 之后,相同的数组可能看起来像这样:
这种在数据只读时应如何存储,以及编辑时应如何存储之间的张力,是现代用户界面小部件往往成为复杂机械的重要原因之一。它们不仅需要看起来漂亮,还需要快速,为此,它们需要在内部以最适合其实现的方式表示数据。因此,我们无法让EditText小部件仅仅操作一个字符数组,我们被迫像你迄今为止手动做的那样,在内部结构中复制和粘贴字符串。
Android 中的数据绑定系统允许你直接从布局文件中引用你的对象模型,然后生成连接对象模型到小部件所需的全部 Java 代码。这个系统被称为数据绑定,其核心类可以在android.databinding包中找到。数据绑定系统还支持响应式编程;当数据模型发生变化时,它可以直接反映在用户界面小部件上,使得应用无需显式更新小部件就能保持屏幕上的内容更新。数据绑定系统也是完全类型安全的,因为它在应用编译时生成所有代码,所以任何类型错误都会立即产生,而不是可能在运行时产生,那时用户可能会看到它们。
观察者模式
Android 中的数据绑定框架利用观察者模式来实现响应式编程。任何由实现Observable接口的布局文件引用的对象都会被监视,当它发出已更改的信号时,用户界面会相应地更新。由于数据绑定系统可以用在任何小部件的任何属性或设置器上,这意味着你可以控制的不仅仅是用户界面内容或状态。你可以控制小部件是可见还是不可见,还可以控制用于小部件背景的图像。在核心上,观察者模式看起来是这样的:
在 Android 观察者模式中,数据模型类通过实现android.databinding.Observable接口并通知一系列事件监听器(观察者)其状态的变化来暴露自己。Android 提供了几个便利类,使得实现这个模式变得容易得多。你可以通过以下三种方式在 Android 中实现这个模式:
-
在你的对象模型中实现可观察模型
-
在你的对象模型之上实现一个可观察模型
-
在表示层实现可观察模型
让我们详细看看这三种方法:
-
在你的对象模型中直接实现可观察是常见的,但副作用是使用可观察模式和 Android 类污染你的对象模型,这实际上会阻止你在系统的其他部分(例如服务器端)使用相同的代码库。当你的对象模型代码仅由你的 Android 应用程序使用时,这是一个好的方法。
-
在对象模型之上实现一个可观察层有时是一个更好的选择,但也可能导致复杂化;通过可观察层引用的每个对象也需要被包裹在一个可观察对象中。这会导致模型实现变得更加复杂,并且无法覆盖在可观察层之外做出的更改。当你使用工具生成对象模型的代码,或者需要在 Android 应用程序代码中添加额外的应用特定层时,这种方法是有用的。
-
在表示层实现观察者模式意味着数据绑定层持有的根引用本身是可观察的,但对象模型不是。这从技术上允许你拥有一个不可变的数据模型。数据绑定引擎将不会看到数据模型中各个字段的更改,而是被通知整个模型已更改。这也可能是一个非常昂贵的模型,因为数据绑定层将重新评估数据模型的每个部分,以应对对其所做的每个更改。然而,当你的应用程序倾向于同时更新模型中的多个字段,或者高度多线程时,这是一个很好的方法。
这些选项中没有一个是始终优于其他选项的;相反,在确保你的用户界面与应用程序的整体状态保持同步时,值得考虑每个选项。在某些屏幕上,这种反应性行为甚至可能是不希望的,因为它可能会轻易地打扰用户。在这些情况下,仅为了填充屏幕,使用数据绑定就值得了。
数据绑定系统不是双向的;模型中的更改反映在用户界面上,但用户界面小部件中的输入不会自动推送到模型中。这意味着你的应用程序仍然需要处理事件并捕获用户界面中的更改,如前面所示。
启用数据绑定
在 Android 项目中,默认情况下数据绑定功能是关闭的。你需要在项目的build.gradle文件中手动启用它们。按照以下快速步骤启用数据绑定系统:
- 首先,在 Android Studio 的 Android 面板中找到你的应用程序模块的
build.gradle文件:
- 打开此文件并定位到
android块:
android {
compileSdkVersion 26
// ...
}
- 在
android块的末尾,添加以下片段以启用数据绑定:
android {
compileSdkVersion 26
// ...
dataBinding {
enabled = true
}
}
- 保存此文件后,Android Studio 将在文件顶部打开一个横幅,告诉你需要同步项目。点击横幅右侧的“立即同步”链接,等待同步完成。
恭喜!你已经在你的项目中启用了数据绑定框架。现在你可以开始了,利用你的布局文件中的数据绑定系统,这将简化应用程序并打开通往重用代码库的新方法。
数据绑定布局文件
数据绑定主要通过代码生成来实现,运行时开销非常小。它允许你在布局 XML 文件中使用特殊的表达式语言,这些表达式在应用程序编译之前被转换为 Java 代码。这些表达式可以调用方法、访问属性,甚至对于触发事件也很有用。然而,它们也有一些限制:它们不能直接引用用户界面中的小部件,也不能创建任何新对象(它们没有new运算符)。因此,你需要为你的布局文件提供一些实用方法以保持简单,并且在使用表达式时有一些指南需要遵循:
-
保持表达式简单:不要在表达式中写入应用程序逻辑;相反,创建一个可重用的实用方法。
-
避免直接操作数据:尽管这样做可能很有吸引力,但请确保在将数据提供给布局绑定之前,数据总是准备好用于展示。在你的
Activity或Fragment类模型中保留默认值,而不是在布局 XML 文件中。 -
使用展示者对象:当你需要对数据进行一些简单的转换(例如格式化日期或数字)时,将这些转换放入对象中。表达式语言可以引用静态方法,但展示者对象要强大得多,也更加灵活。
-
传递事件:在编写事件时,避免使用表达式语言进行除方法调用之外的操作,并尝试将事件作为对象传递到布局中,无论是作为展示者还是作为命令对象。这保持了事件的灵活性和可重用性。
通过坚持这些指南,你会发现使用数据绑定系统不仅能让你摆脱一些最常见的用户界面模板代码,还能提高你布局和整体应用程序的质量。通过在你的布局文件中使用对象而不是静态方法,你最终会得到模块化的类,这些类可以在整个应用程序中轻松重用。
现在您的应用可以捕获人们的费用作为索赔,是时候开始考虑如何显示这些信息了。这有两个主要组成部分:用户创建的索赔项列表,以及他们应该保持的整体旅行津贴。到目前为止,您有捕获屏幕,虽然在许多方面它是应用中最重要的屏幕,但并不是用户首先看到的屏幕——那将是概览屏幕。
概览屏幕的主要任务是按顺序显示索赔项,从最新到最旧。然而,为了保持用户的简单生活,我们还会在屏幕顶部显示一个摘要卡片,这有助于他们跟踪他们的消费。在这个例子中,我们假设津贴是按每天旅行金额指定的。
创建一个 Observable 模型
为了开始这个项目部分的工作,你需要一个新的模型类来封装用户的津贴和消费。我们将把这个新类命名为 Allowance,并内置一些实用方法来获取有用的信息(例如用户在两个日期之间的消费金额)。最重要的是,这个新模型需要告诉我们何时发生变化。这可以从技术上通过事件总线或专门的监听器来完成,但在这个例子中,我们将采用观察者模式。为了使这可行,Allowance 类将扩展自 BaseObservable,这是一个数据绑定 API 的一部分,用于方便的类。每当 Allowance 类发生变化时,它将发出事件,通知其观察者变化。让我们开始构建 Allowance 类:
-
右键单击
model包,然后选择“新建| Java 类”。 -
将新类命名为
Allowance。 -
将父类更改为
android.databinding.BaseObservable。 -
将
android.os.Parcelable添加到接口字段。 -
点击“确定”以创建新类。
-
在类顶部,声明以下字段和构造函数,以及一个获取
amountPerDay的 getter 方法,它代表用户希望获得的津贴:
private int amountPerDay;
private final List<ClaimItem> items = new ArrayList<>();
public Allowance(final int amountPerDay) {
this.amountPerDay = amountPerDay;
}
protected Allowance(final Parcel in) {
amountPerDay = in.readInt();
in.readTypedList(items, ClaimItem.CREATOR);
}
public int getAmountPerDay() { return amountPerDay; }
- 现在是 Observable 实现的第一部分;当我们更改
amountPerDay字段时,我们需要通知任何观察者Allowance对象已更改:
public void setAmountPerDay(final int amountPerDay) {
this.amountPerDay = amountPerDay;
notifyChange();
}
Allowance类将始终确保所有ClaimItem对象按从新到旧的顺序排序;了解这一点后,我们可以添加一些便利方法来找到Allowance对象的 起始 和 结束 日期:
public Date getStartDate() {
return items.get(items.size() - 1).getTimestamp();
}
public Date getEndDate() {
return items.get(0).getTimestamp();
}
- 现在,创建一个简单的计算方法来确定这个
Allowance的总消费金额。这个方法简单地将所有ClaimItem对象中的金额加起来:
public double getTotalSpent() {
double total = 0;
for (final ClaimItem item : items)
total += item.getAmount();
return total;
}
- 然后,添加另一个计算方法来计算两个日期之间的消费金额。这可以用来找出特定日期、周、月等的消费金额:
public double getAmountSpent(final Date from, final Date to) {
double spent = 0;
for (int i = 0; i < items.size(); i++) {
final ClaimItem item = items.get(i);
if (item.getTimestamp().compareTo(from) >= 0
&& item.getTimestamp().compareTo(to) <= 0) {
spent += item.getAmount();
}
}
return spent;
}
- 现在,您需要一个方法来向
Allowance添加ClaimItem。Allowance始终维护从最新到最旧的ClaimItem对象列表,因此每次添加项目时,此方法只需对列表进行排序,然后通知观察者Allowance已更改:
public void addClaimItem(final ClaimItem item) {
items.add(item);
Collections.sort(
items,
Collections.reverseOrder(new Comparator<ClaimItem>() {
@Override
public int compare(final ClaimItem o1, final ClaimItem o2) {
return o1.getTimestamp().compareTo(o2.getTimestamp());
}
})
);
notifyChange();
}
对列表进行此类排序是一个非常糟糕的实现,但非常简单易写。在实际应用中,您应该使用二分查找来确定添加ClaimItem的正确位置。Android 提供了帮助进行此操作的类,我们将在本书的后面部分探讨。
- 我们还需要能够从
Allowance中删除ClaimItem对象。这也是一个可变操作,因此在完成后通知任何观察者:
public void removeClaimItem(final ClaimItem item) {
items.remove(item);
notifyChange()
}
- 添加
ClaimItem对象的访问器方法:
public int getClaimItemCount() {
return items.size();
}
public ClaimItem getClaimItem(final int index) {
return items.get(index);
}
public boolean isEmpty() {
return items.isEmpty();
}
- 通过编写其
Parcelable实现来完成Allowance类的编写:
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeInt(amountPerDay);
dest.writeTypedList(items);
}
@Override
public int describeContents() { return 0; }
public static final Creator<Allowance> CREATOR = new Creator<Allowance>() {
@Override
public Allowance createFromParcel(Parcel in) {
return new Allowance(in);
}
@Override
public Allowance[] newArray(int size) {
return new Allowance[size];
}
};
如您所见,Allowance类是您对象模型中需要观察的第一个(也是目前唯一的)部分;构建一个Observable模型并不困难,能够观察模型状态的变化会打开一些惊人的机会,例如自动网络同步或统计聚合。
如果您的应用程序中有事件总线,通过它而不是直接观察来推送对象模型更改通常是一个更好的选择,因为它将提供更好的解耦。有许多与 Android 兼容的事件总线 API,值得检查它们。一个具有事件总线实现的知名 API 是 Google 的 Guava API (github.com/google/guava)。
建立 AllowanceOverviewFragment
允许概述将以卡片的形式显示在概述屏幕的顶部。概述卡片将由一个新的Fragment类填充,该类将封装数据绑定的第一部分。AllowanceOverviewFragment将依赖于数据绑定系统来完成大部分繁重的工作,并将提供一个特殊的AllowanceOverviewPresenter对象,该对象可以查询统计数据和数据。AllowanceOverviewPresenter将反过来引用Allowance对象,并监听其上的任何更改,以便更新和缓存统计数据。这些实体之间的关系可以用以下图表最好地解释:
在Fragment中封装统计数据意味着它更容易包含在其他布局中,这些布局可能包含与概述屏幕不同的信息。按照以下快速步骤创建AllowanceOverviewFragment和AllowanceOverviewPresenter骨架:
-
右键单击
ui包,然后选择“新建”|“Fragment”|“Fragment(空白)”。 -
将 Fragment 命名为
AllowanceOverviewFragment。 -
关闭“包含 Fragment 工厂方法”和“包含接口回调”选项:
-
点击“完成”以创建新的
Fragment及其默认布局文件。 -
再次右键单击
ui包,并选择“新建| Java 类”。 -
将新类命名为
presenters.AllowanceOverviewPresenter。 -
点击“确定”以创建新的包和类。
-
AllowanceOverviewPresenter需要的第一件事是一个内部类,用于存储将显示给用户的缓存支出统计信息。这将是一个不可变结构;当统计信息发生变化时,我们将同时刷新所有这些信息:
public static class SpendingStats {
public final int total;
public final int today;
public final int thisWeek;
SpendingStats(
final int total,
final int today,
final int thisWeek) {
this.total = total;
this.today = today;
this.thisWeek = thisWeek;
}
}
你会注意到 SpendingStats 类的字段是 public final,并且没有 getter 方法。在处理数据绑定时,耦合通常非常紧密,因此引入 getter 方法实际上可能会增加复杂性。最好在需要之前避免使用 getter 方法。
- 我们需要以某种方式将
SpendingStats暴露在类外部,以便数据绑定可以监视其变化。Android 数据绑定再次有一个辅助类;当你有一个需要观察的字段时,你可以使用ObservableField类。当数据绑定布局文件中的表达式引用这些之一时,它将自动监听变化,并在字段更改时重新评估:
public final ObservableField<SpendingStats> spendingStats = new ObservableField<>();
当使用 ObservableField(及其表亲:ObservableString、ObservableInt 等)时,最好将它们声明为 final 并初始化。数据绑定系统无法监视字段本身的变化,而是将监听器附加到 ObservableField 对象上。
AllowanceOverviewPresenter还需要一个Allowance对象,它将封装它,以及一个构造函数:
public final Allowance allowance;
public AllowanceOverviewPresenter(final Allowance allowance) {
this.allowance = allowance;
}
- 最后,
AllowanceOverviewPresenter需要一个方法,允许用户更新他们每天被允许花费的金额。在这种情况下,演示者充当助手,将一些逻辑从布局文件中排除;EditText小部件将提供一个数字作为CharSequence,因此AllowanceOverviewPresenter需要解析它并处理任何错误,如果它在某些方面无效:
public void updateAllowance(final CharSequence newAllowance) {
try {
allowance.setAmountPerDay(
Integer.parseInt(newAllowance.toString()));
} catch (final RuntimeException ex) {
//ignore
allowance.setAmountPerDay(0);
}
}
AllowanceOverviewPresenter 类将作为原始数据绑定布局文件和原始对象模型之间的中介系统。这允许你将任何渲染逻辑从对象模型中排除,同时也将数据模型需求从布局 XML 文件中排除。
创建 AllowanceOverview 布局
现在,是时候创建布局文件并将其绑定到 AllowanceOverviewPresenter 类了。数据绑定布局文件与正常的 Android 布局文件略有不同。由于每个布局 XML 文件都会生成自己的绑定类,因此它们有一个 layout 的根元素,后面跟着一个 data 部分,该部分声明了它们将要绑定的变量。每个变量都以其 Java 类命名和类型化,因为在编译期间,这些都会转换为生成绑定类中的 Java 变量。最终,你希望创建的布局在概述屏幕顶部看起来像这样:
每日配额字段将允许用户直接编辑他们每天分配的金额,而右侧的标签将显示他们今天的支出、本周的支出以及总支出。按照以下步骤构建前面的布局;与之前的示例不同,这些步骤不使用设计视图进行编辑,布局是从右到左构建的:
-
打开
fragment_allowance_overview.xml布局文件。 -
将编辑器更改为文本模式。
-
将根元素从
FrameLayout更改为布局,并删除内容:
<layout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.packtpub.claim.ui.AllowanceOverviewFragment">
</layout>
- 现在,在
layout中声明一个数据部分,并为AllowanceOverviewPresenter类声明一个表示变量:
<layout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.packtpub.claim.ui.AllowanceOverviewFragment">
<data>
<variable
name="presenter"
type="com.packtpub.claim.ui.
presenters.AllowanceOverviewPresenter" />
</data>
</layout>
- 与
data部分不同,小部件元素没有特殊的根,因此在data部分之后(并且仍然嵌套在layout元素中),声明此布局的根元素,它将是一个ConstraintLayout:
<android.support.constraint.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
</android.support.constraint.ConstraintLayout>
- 在
ConstraintLayout中创建一个TextView,它将作为包含单词Total的标签:
<TextView
android:id="@+id/totalLabel"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/grid_spacer1"
android:gravity="center"
android:text="@string/label_total"
android:minWidth="@dimen/allowance_overview_label_min_width"
android:textAppearance="@style/TextAppearance.AppCompat.Caption"
app:layout_constraintEnd_toEndOf="@+id/total"
app:layout_constraintStart_toStartOf="@+id/total"
app:layout_constraintTop_toTopOf="parent" />
-
在指定
android:text属性的行上,Android Studio 会抱怨@string/label_total资源不存在。使用代码辅助功能(通常是Alt + Enter),并选择创建字符串值资源label_total。 -
将会打开一个对话框,提示您输入资源值;输入
Total并点击确定按钮。 -
使用相同的代码辅助功能在下一行创建一个尺寸资源,指定最小宽度。将新的
allowance_overview_label_min_width资源设置为50dp并点击确定。 -
在总标签小部件下方创建一个
TextView,它将包含用户在Allowance中实际花费的金额:
<TextView
android:id="@+id/total"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/grid_spacer1"
android:layout_marginTop="@dimen/grid_spacer1"
android:gravity="center"
android:minWidth="@dimen/allowance_overview_label_min_width"
android:textAppearance="@style/TextAppearance.AppCompat.Display1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/totalLabel" />
- 注意,在这里,您没有指定
android:text属性。这将是布局文件中的第一个数据绑定属性,我们希望显示表示中的SpendingStats对象的总额字段。将此android:text属性写入上面的TextView,在app:layout_constraintEnd_toEndOf属性之前:
android:text='@{Integer.toString(presenter.spendingStats.total) ?? "0"}'
数据绑定表达式都包裹在@{..}中,以表示它们与普通属性的不同。代码看起来像 Java,但实际上不是。注意??运算符;它是一个非常有用的“空安全”运算符。如果左侧的任何部分为 null,则将使用右侧的值(在这种情况下,是"0"字符串)代替(就像一个非常具体的三元运算符)。此外,注意android:text属性周围的单引号;数据绑定布局仍然必须是一个有效的 XML 文件,并且前面的代码需要指定一个使用双引号的 Java 字符串。与其将 Java 字符串转义为"0",不如使用单引号来清理 XML 属性。
另一个重要因素是您需要使用Integer.toString来确保在TextView上调用正确的方法。将其保留为int将导致调用TextView.setText(int),它期望一个字符串资源标识符。
- 接下来,你需要为每周标签和金额显示声明非常相似的
TextView元素。这些元素几乎与总TextView元素完全相同,只是它们的标签、ID 和约束不同。你还需要创建一个值为Week的label_week字符串资源:
<TextView
android:id="@+id/weekLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="0dp"
android:layout_marginTop="@dimen/grid_spacer1"
android:gravity="center"
android:minWidth="@dimen/allowance_overview_label_min_width"
android:text="@string/label_week"
android:textAppearance="@style/TextAppearance.AppCompat.Caption"
app:layout_constraintEnd_toEndOf="@+id/week"
app:layout_constraintStart_toStartOf="@+id/week"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/week"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/grid_spacer1"
android:layout_marginTop="@dimen/grid_spacer1"
android:gravity="center"
android:minWidth="@dimen/allowance_overview_label_min_width"
android:text='@{Integer.toString(presenter.spendingStats.thisWeek) ?? "0"}'
android:textAppearance="@style/TextAppearance.AppCompat.Display1"
app:layout_constraintEnd_toStartOf="@+id/total"
app:layout_constraintTop_toBottomOf="@+id/weekLabel" />
- 你需要为今天的数字重复相同的操作。同样,你需要更改标签、ID 和约束,并创建一个值为
Today的label_today字符串资源:
<TextView
android:id="@+id/todayLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="0dp"
android:layout_marginTop="@dimen/grid_spacer1"
android:gravity="center"
android:minWidth="@dimen/allowance_overview_label_min_width"
android:text="@string/label_today"
android:textAppearance="@style/TextAppearance.AppCompat.Caption"
app:layout_constraintEnd_toEndOf="@+id/today"
app:layout_constraintStart_toStartOf="@+id/today"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/today"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/grid_spacer1"
android:layout_marginTop="@dimen/grid_spacer1"
android:gravity="center"
android:minWidth="@dimen/allowance_overview_label_min_width"
android:text='@{Integer.toString(presenter.spendingStats.today) ?? "0"}'
android:textAppearance="@style/TextAppearance.AppCompat.Display1"
app:layout_constraintEnd_toStartOf="@+id/week"
app:layout_constraintTop_toBottomOf="@+id/todayLabel" />
- 此卡片中的最后一个元素是每日限额输入区域,用户可以输入他们每天可以花费的金额。它由一个
TextInputLayout和一个绑定到每天金额的TextInputEditText小部件组成。在这个元素中,你还将TextInputEditText小部件绑定到一个事件处理器,这看起来很像 Java lambda 表达式,但像所有绑定表达式一样,它并不是。然而,它被翻译成了 Java:
<android.support.design.widget.TextInputLayout
android:id="@+id/textInputLayout"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginEnd="@dimen/grid_spacer1"
android:layout_marginStart="@dimen/grid_spacer1"
android:layout_marginTop="@dimen/grid_spacer1"
app:layout_constraintBottom_toBottomOf="@+id/today"
app:layout_constraintEnd_toStartOf="@+id/today"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<android.support.design.widget.TextInputEditText
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/label_daily_allowance"
android:inputType="number"
android:onTextChanged=
"@{(text, start, before, end)
-> presenter.updateAllowance(text)}"
android:text='@{presenter.allowance.amountPerDay > 0 ?
Integer.toString(presenter.allowance.amountPerDay) : ""}' />
</android.support.design.widget.TextInputLayout>
</android.support.constraint.ConstraintLayout>
- 使用 Android Studio 代码助手创建一个值为
Daily Allowance的label_daily_allowance字符串资源。
现在,如果你回到设计模式,你将能够看到你的新片段在用户设备屏幕上的样子。事件处理器已经连接,并且每次用户在每日限额输入框中更改任何文本时都会被触发。事件触发器将调用presenter.updateAllowance方法,该方法反过来会尝试解析该值并将其设置在Allowance对象上(假设它可以解析为整数)。
更新SpendingStats类
你已经创建了SpendingStats类并将其绑定到你的布局中,但它永远不会包含任何数据,因为它从未真正创建过,AllowanceOverviewPresenter中的ObservableField<SpendingStats>字段也从未被填充。这有一个很好的原因——统计需要时间来计算。即使我们有数据库来做繁重的工作,在将这三个数字显示在屏幕上之前,计算这些数字可能存在相当大的开销。而你可以直接在布局 XML 中调用Allowance.getTotalSpent()方法,这将阻塞主线程直到计算完那个数字。这不是一个好主意,因为这种延迟会迅速累积,并可能导致用户体验下降或甚至出现应用程序无响应错误。
解决方案是监听Allowance对象的更改,并在更新AllowanceOverviewPresenter中的SpendingStats字段之前在一个工作线程上重新计算值。数据绑定系统将负责其余部分,并在屏幕上填充值。本例的这一部分需要两个结构:一个观察者来监视Allowance对象上的任何更改,以及一个ActionCommand来计算并更新AllowanceOverviewPresenter中的SpendingStats。让我们创建它们:
-
在 Android Studio 中打开
AllowanceOverviewPresenter源文件。 -
在
AllowanceOverviewPresenter类的底部,开始一个新的ActionCommand内部类来更新SpendingStats,命名为UpdateSpendingStatsCommand:
private class UpdateSpendingStatsCommand
extends ActionCommand<Allowance, SpendingStats> {
UpdateSpendingStatsCommand需要两个实用方法来计算本周和今天的日期范围。不幸的是,Android 不支持新的 Java 8 时间 API;你需要使用Calendar类。另一方面,Android 提供了一个非常有用的实用类Pair,非常适合定义日期范围:
Pair<Date, Date> getThisWeek() {
final GregorianCalendar today = new GregorianCalendar();
today.set(
Calendar.HOUR_OF_DAY,
today.getActualMaximum(Calendar.HOUR_OF_DAY));
today.set(
Calendar.MINUTE,
today.getActualMaximum(Calendar.MINUTE));
today.set(
Calendar.SECOND,
today.getActualMaximum(Calendar.SECOND));
today.set(
Calendar.MILLISECOND,
today.getActualMaximum(Calendar.MILLISECOND));
final Date end = today.getTime();
today.add(
Calendar.DATE,
-(today.get(Calendar.DAY_OF_WEEK) - Calendar.SUNDAY));
today.set(Calendar.HOUR_OF_DAY, 0);
today.set(Calendar.MINUTE, 0);
today.set(Calendar.SECOND, 0);
today.set(Calendar.MILLISECOND, 0);
return new Pair<>(today.getTime(), end);
}
Pair<Date, Date> getToday() {
final GregorianCalendar today = new GregorianCalendar();
today.set(
Calendar.HOUR_OF_DAY,
today.getActualMaximum(Calendar.HOUR_OF_DAY));
today.set(
Calendar.MINUTE,
today.getActualMaximum(Calendar.MINUTE));
today.set(
Calendar.SECOND,
today.getActualMaximum(Calendar.SECOND));
today.set(
Calendar.MILLISECOND,
today.getActualMaximum(Calendar.MILLISECOND));
final Date end = today.getTime();
today.add(Calendar.DATE, -1);
today.set(Calendar.HOUR_OF_DAY, 0);
today.set(Calendar.MINUTE, 0);
today.set(Calendar.SECOND, 0);
today.set(Calendar.MILLISECOND, 0);
return new Pair<>(today.getTime(), end);
}
你会发现你的应用程序中有两种不同的Pair实现可用。一个是 Android 核心平台的一部分(android.util.Pair),另一个是由支持包提供的(android.support.v4.util.Pair)。支持实现旨在针对 API 版本 4 及以下的应用程序,而你的应用程序针对的是 API 版本 16 及以上;因此,你应该使用平台实现(android.util.Pair)。
- 然后,你需要实现
onBackground方法,将Allowance对象中的数据处理到SpendingStats中:
public SpendingStats onBackground(final Allowance allowance)
throws Exception {
final Pair<Date, Date> today = getToday();
final Pair<Date, Date> thisWeek = getThisWeek();
// for stats we round everything to integers
return new SpendingStats(
(int) allowance.getTotalSpent(),
(int) allowance.getAmountSpent(today.first, today.second),
(int) allowance.getAmountSpent(thisWeek.first, thisWeek.second)
);
}
- 然后,
UpdateSpendingStatsCommand需要其onForeground设置AllowanceOverviewPresenter上的SpendingStats字段,这将导致用户界面使用新数据更新:
public void onForeground(final SpendingStats newStats) {
spendingStats.set(newStats);
}
- 这完成了
UpdateSpendingStatsCommand;现在,在AllowanceOverviewPresenter类中,你需要一个UpdateSpendingStatsCommand的实例,当Allowance对象发生变化时可以调用:
private final UpdateSpendingStatsCommand updateSpendStatsCommand
= new UpdateSpendingStatsCommand();
- 然后,你需要
AllowanceOverviewPresenter能够监视Allowance对象的变化。这将涉及一个观察者,Android 的数据绑定 API 调用OnPropertyChangedCallback。问题是OnPropertyChangedCallback是一个类而不是接口,所以对于AllowanceOverviewPresenter,使用匿名内部类作为OnPropertyChangedCallback:
private final Observable.OnPropertyChangedCallback
allowanceObserver = new Observable.OnPropertyChangedCallback() {
public void onPropertyChanged(
final Observable observable,
final int propertyId) {
updateSpendStatsCommand.exec(allowance);
}
};
AllowanceOverviewPresenter需要在构造函数中将其观察者连接到Allowance对象:
public AllowanceOverviewPresenter(final Allowance allowance) {
this.allowance = allowance;
this.allowance.addOnPropertyChangedCallback(allowanceObserver);
}
Observable对象持有的对其观察者的引用是强引用,所以如果不注意,你可能会发现自己有内存泄漏。为了避免这种情况,当AllowanceOverviewPresenter不再需要时,一个好的做法是断开监听器;然而,这需要从外部完成:
public void detach() {
allowance.removeOnPropertyChangedCallback(allowanceObserver);
}
UpdateSpendingStatsCommand的大部分代码被日期范围计算占据;否则它是一个非常简单的类。重要的是它既封装了计算,又在后台工作线程上运行,以保持用户界面在计算数字时平滑运行。
数据绑定和片段
在使用数据绑定框架工作时,重要的是要考虑将用户界面的各个部分封装在哪里。由于你可以直接将逻辑钩入布局文件,因此通常更好的做法是使用类似于你在第三章,“采取行动”,中编写的DatePickerWrapper的类,使用<include>和<merge>标签,而不是将组件组包裹在类中。包含在其他布局中的数据绑定布局仍然有变量,并且外部布局有责任将这些变量向下传递到包含的布局文件中。例如,包含日期选择器的布局可能看起来像这样:
<include layout="@layout/merge_date_picker"
app:date="@{user.dateOfBirth}"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
只要user或其dateOfBirth是Observable,布局将自动反映对其的任何更改。这种模式不仅允许你模块化你的布局,还可以确保它们只接收它们实际需要以工作的数据。另一个优点是,使用<merge>元素与ConstraintLayout配合使用时,可以非常顺畅,允许你构建复杂且可重用的布局元素,这些元素在代码中嵌套,但在组件层次结构中是平的(不是嵌套的)。使用ConstraintLayout的平面布局通常更容易构建,渲染速度通常更快,并且比深层嵌套布局提供更多灵活的动画。它们可能更难模块化以供重用;数据绑定使这一点变得容易得多。
如果你还在犹豫是否仍然引入片段和视图类,看看逻辑边界,你将不得不嵌套你的组件。一个很好的边界示例是CardView。CardView需要一个嵌套布局,因此其内容是完美适合作为视图或片段的候选,这可以进一步帮助你封装布局和逻辑。
在构建它们时,也要考虑你的“展示者”类和对象。单个布局可以有任意数量的变量,展示者类不必是浅层结构。按继承级别构建展示者类很常见,你可能构建一个应用级别的展示者,具有全局规则(如何格式化日期和数字),以及用于显示对话框等子类;记住,一些逻辑可能不是直接由布局使用,而是由事件处理方法使用。以这种方式拆分展示者类可以进一步将逻辑限制在需要的地方,并提高代码的可重用性。
测试你的知识
-
Android 的数据绑定框架遵循哪种绑定?
-
模型-视图-视图模型(双向)绑定
-
模型-视图-展示者模式
-
模型-视图(单向)绑定
-
-
数据绑定布局必须具有以下哪种变量?
-
任何 Java 对象
-
可由数据绑定框架观察
-
展示对象
-
模型对象
-
-
以下哪个功能属于数据绑定表达式?
-
它们必须用单引号编写
-
它们是 Java 表达式
-
它们是一种特殊的表达式语言
-
它们仅在运行时评估
-
-
要触发数据绑定用户界面的更新,你必须做以下哪一项?
-
使用事件总线监听对象模型的变化
-
扩展
PropertyChangeCallback类 -
在生成的
Binding对象上调用刷新 -
对
Binding对象进行一个可以观察到的更改
-
摘要
数据绑定不仅可以大量减少编写用户界面所需的样板代码量,还可以积极改进你的代码库并增加可重用代码的数量。通过避免复杂的绑定表达式并在你的表示类中封装显示逻辑,你可以构建高度模块化的布局,这些布局快速、类型安全且可重用。
有时将数据绑定布局文件视为它们自己的 Java 类是有用的;毕竟,它们每个都会生成一个 Binding 类。记住,Binding 类本身也是可观察的,所以通过它们生成的设置方法对它们的任何更改都会自动触发用户界面的更新。此外,记住当你将数据绑定布局包含在另一个布局中时,你需要向下传递所有其变量,这就像在构造函数中指定参数一样,而这些变量不需要直接包含在父布局中。
到目前为止,你一直在构建内存中的数据模型,但这也意味着当你的应用程序终止时,所有数据都会丢失。在下一章中,我们将探讨 Android 上的长期数据存储,并了解如何在不降低用户体验和感知性能的情况下将其与用户界面集成。
第六章:存储和检索数据
初看起来,数据存储似乎与用户界面毫不相关,但在大多数应用程序中,用户界面存在是为了在设备和网络上操纵持久数据。这意味着虽然它不会直接影响应用程序的外观,但它确实会影响用户体验。用户期望应用程序始终反映他们可用的最新数据,正如我们在第五章中探讨的,“将数据绑定到小部件”。使用响应式模式编写的应用程序确保用户界面始终与应用程序可用的最新数据保持同步,Android 数据绑定系统有助于简化编写响应式应用程序的过程。即使没有数据绑定框架,Android 本身也始终从底层向上构建为响应式应用程序,但直到最近,这种行为需要大量的样板代码。
当你开发任何类型的应用程序时,在应用程序内建立数据容器或权限是非常重要的。在大多数 Web 系统中,这将是一个数据库。系统可能还有许多其他数据存储层,例如缓存和内存中的对象模型,但在这个情况下,“权限”将是数据库。Android 应用程序可能一开始看起来更复杂;你通常有一个包含一些数据的服务器,你通常有一个本地数据库,然后还有屏幕上和内存中的内容。保持所有这些状态同步可能看起来像是一场噩梦,但实际上已经得到了妥善处理。
Android 团队构建了一个名为架构组件的 API 集合。这些组件共同简化了编写响应式应用程序的工作,因为它们处理了编写应用程序时最常见的常见问题。它们包括用于存储和检索数据的 API,以及用于响应应用程序状态变化的 API。
在本章中,我们将探讨以下主题:
-
数据和存储如何影响用户体验
-
Android 提供的用于存储和检索结构化数据的工具
-
保持用户界面与数据存储同步的最佳方式
-
使用 Room 持久化 API 构建 SQLite 数据库存储
Android 中的数据存储
几乎每个应用程序在某个时候都需要持久化存储数据。任何需要在应用程序停止时保持完整的数据都必须放置在某种数据存储系统中,以便以后可以再次检索它。您可以将所有数据存储在服务器上,但这样您的应用程序只有在用户有活跃的互联网连接时才能工作,并且速度将仅限于他们的可用连接速度。您还可以将数据作为文件存储在设备的本地文件系统中,但这意味着您需要每次更改时都将所有数据加载到内存中并保存整个应用程序状态,或者您需要编写复杂的逻辑来维护应用程序将写入的各种文件之间的完整性。
Android 生态系统提供了大量的数据库系统,其中最流行的大概是 SQLite。数据可以保存在 SQLite 表中,并通过结构化查询检索。这为在设备上存储所有应用程序数据提供了一个理想的方式,同时只检索应用程序所需的数据。可以指示 SQLite 数据库精确检索哪些记录上的哪些字段,您可以使用索引来使此过程非常快速。
持久性数据存储和对象映射确实会带来显著的成本——数据库查找可能很快,但在主线程上它需要的时间明显长于可接受的范围,这会在图形渲染和事件分发中造成延迟。因此,您再次希望数据从后台线程加载。这可能会带来一些额外的挑战:如何确保数据始终是最新的,并且在涉及Activity生命周期、持久化和从多个存储系统加载时不会停滞?这可能会迅速失控,但再次强调,Android 有一个完整的生态系统,旨在保持一切井然有序。
在创建 Android 应用程序时,最好设计它,以便用户在当前Activity中编辑的任何内容都保持在可变的内存模型中,如图所示:
这种设计模式将为您的应用程序提供良好的性能,同时让您能够通过简单地丢弃用户正在更改的内存模型来轻松地取消更改。当用户正在查看而不是编辑数据时,需要不同的方法。当用户查看如他们的电子邮件收件箱或聊天对话的屏幕时,他们期望它在没有他们的交互下更新。在这种情况下,最好遵循单向数据流设计,如图中所示:
图中的传入更改可以来自任何地方。它可以来自应用程序的另一个部分,也可以来自网络,甚至可以来自用户正在查看的屏幕的另一部分。重要的是数据库(DB)总是首先更新,然后触发模型重新加载或更新,进而触发用户界面更新。这与模型相反,其中用户界面接收传入的事件并获取新数据。在这里,用户界面将始终直接接收最新数据。
使用 SQLite 数据库
SQLite 是一个嵌入到核心 Android 系统中的优秀小型 SQL 兼容数据库。这允许您利用完整的 SQL 数据库,而无需将数据库与您的应用程序一起分发(这将大大增加您的代码大小)。这使得它成为 Android 上存储结构化数据最常用的工具,但绝不是唯一的选择。
对于许多需要与服务器实时同步的应用程序,人们使用 Firebase 数据库。Firebase是谷歌云产品,包括一个功能强大的文档数据库,它实时同步其数据,直到客户端。这意味着当其数据从外部被修改时,客户端会触发一个事件,这使得它非常适合聊天和消息应用。然而,像 Firebase 这样的工具需要大量的额外客户端 API,将您的应用程序绑定到某个服务,并且很难将应用程序迁移到其他平台。使用它们构建的应用程序也可能会违反某些国家的隐私法律,如果应用程序在客户端未加密的情况下存储私人信息。在这些情况下,您可能需要设置自己的同步系统,或者使用具有过滤实时同步功能的数据库,例如 Apache 的 CouchDB 项目。
通常情况下,SQLite 作为客户端存储结构化数据的优秀选择。它灵活、非常强大且非常快速,并且因为它已经集成到 Android 平台,所以不会为您的应用程序增加任何直接的大小开销。大多数 Java 开发者在访问 SQL 数据库时都会使用 JDBC,尽管 Android 也提供了 JDBC 支持,但 android.database 和 android.database.sqlite 包是访问数据库的首选方法,而且速度更快。Android 还提供了一层额外的抽象,用于直接使用 SQLite,我们将在下一节中探讨这一点。
如需了解有关 SQLite 的更多信息以及如何充分利用它,建议浏览该项目的优秀文档,网址为 sqlite.org/。
介绍 Room
直接使用 SQLite 需要大量的代码来将 SQLite 结构化数据转换为 Java 对象,然后准备 SQL 语句将这些对象存储回数据库。将 SQL 记录映射到 Java 对象的通常形式如下:
public Attachment selectById(final long id) {
final Cursor cursor = db.query(
"attachments",
new String[]{"file", "type"},
"_id=?",
new String[]{Long.toString(id)},
null, null, null);
try {
if (cursor.moveToFirst()) {
return new Attachment(
new File(cursor.getString(0)),
Attachment.Type.valueOf(cursor.getString(1))
);
}
} finally {
cursor.close();
}
return null;
}
如您立即所见,那里有很多代码,您将需要为每个数据模型对象重复使用。
幸运的是,Google 作为其架构组件的一部分提供了解决这个模板问题的方案,它被称为Room。Room 是一个 API 和代码生成器,允许您定义您的对象模型和您想要执行的 SQL 查询,同时它会为您编写模板数据访问对象(DAO)类。Room 是一个极佳的选择,因为所有繁重的工作都是在编译时通过为您的应用程序生成源代码来完成的。这也意味着它需要包含在您的应用程序中的额外代码要少得多,这有助于保持您的应用程序在最终用户设备上的体积更小。
Room 不是一个传统的对象/关系(O/R)映射层,而是允许您定义SELECT语句,并将它们返回的数据复制到您指定的对象模型中。因此,它不直接处理对象之间的关系(例如ClaimItem包含一个Attachment对象的数组)。虽然这看起来像是一个问题,但它是一个非常重要的特性!这类关系在对象模型中很常见,但在对象/关系层中实现起来成本很高,因为每次调用ClaimItem.getAttachments都需要另一个数据库查询,而在 Android 中,这些调用很可能会泄漏到主线程。
相反,Room 被设计成您可以创建适合数据绑定的对象模型,并构建可以直接返回它们的 SQL 查询。这把复杂性推回到数据库中,并有助于鼓励使用单个查询来显示编程行为。
向项目中添加房间
Room 是架构组件的一部分,默认情况下不会导入到项目中。相反,您需要按照以下简单步骤将它们作为依赖项添加到您的项目中:
- 在 Android 面板中,打开 Gradle 脚本子部分,然后打开应用模块的
build.gradle文件:
- 在文件底部,您会找到一个依赖项块;在块的底部,添加以下两行代码:
implementation 'android.arch.persistence.room:runtime:+'
annotationProcessor 'android.arch.persistence.room:compiler:+'
-
使用编辑器顶部的“立即同步”链接将项目与其 Gradle 文件同步。Android Studio 将自动下载您项目的新 Room 依赖项。
-
您的项目现在已集成 Room API 及其代码生成器,您可以开始创建持久对象模型和数据库模式。
创建实体模型
Room,就像一个 SQL 数据库一样,是可选的非对称的;你写入它的内容可能与从它读取的内容格式不完全相同。当你向 Room 数据库写入时,你保存Entity对象,但在读取时,你可以读取几乎任何 Java 对象。这允许你定义最适合用户界面的对象模型,并通过JOIN查询加载它们,而不是为每个要向用户展示的对象进行一个或多个额外的查询。虽然JOIN查询在服务器上可能过于昂贵,但在移动设备上,它们通常比多查询替代方案要快得多。因此,在定义实体模型时,值得考虑你需要在数据库中保存什么,以及你需要在用户界面上使用哪些特定字段。你需要写入存储的数据成为你的实体,而用户界面的字段成为可以通过 Room 查询的 Java 对象中的字段。
Room 中的Entity类被注解为@Entity,并预期遵循某些规则:
-
字段必须是
public的或者有 Java Beans 风格的 getter 和 setter -
至少有一个字段必须使用
@PrimaryKey注解标记为主键 -
Room 期望一个单独的
public构造函数,因此你可能需要使用@Ignore注解标记其他构造函数,以便你的代码可以编译。通常最好只为 Room 留下一个默认(无参数)的构造函数
为了使用 Room 开始存储索赔数据,我们需要修改现有的ClaimItem和Attachment类,使它们成为有效的实体。这涉及到使它们作为关系结构可用;ClaimItem和Attachment都需要一个 ID 主键,并且附件需要为其所属的ClaimItem的外键标识符。执行以下步骤以修改这两个数据模型类,以便它们可以使用 Room 作为实体存储:
-
首先在 Android Studio 中打开
ClaimItem源文件。 -
使用
@Entity注解类声明:
@Entity
public class ClaimItem implements Parcelable {
- 添加一个 ID 字段,使用
@PrimaryKey注解它,并告诉 Room 你希望它由数据库生成,而不是手动创建 ID(如果你喜欢,也可以为这个字段添加 getter 和 setter):
@PrimaryKey(autoGenerate = true)
public long id;
将字段保留为public意味着 Room 将直接访问字段,而不是使用 getter 和 setter。字段访问可能比调用 getter 和 setter 的方法调用要快得多。
- 告诉 Room 忽略
Attachment的List。Room 无法直接持久化这类关系,当它尝试为这个字段生成映射代码时,你的应用程序将无法编译:
@Ignore List<Attachment> attachments = new ArrayList<>();
- 修改
ClaimItem的Parcelable实现以保存和恢复 ID 字段:
protected ClaimItem(final Parcel in) {
id = in.readLong();
description = in.readString();
amount = in.readDouble();
// …
}
public void writeToParcel(final Parcel dest, final int flags) {
dest.writeLong(id);
dest.writeString(description);
dest.writeDouble(amount);
dest.writeLong(timestamp != null ? timestamp.getTime() : -1);
dest.writeInt(category != null ? category.ordinal() : -1);
dest.writeTypedList(attachments);
}
-
打开
附件源文件。 -
将
Entity注解添加到Attachment类中;这次你还需要包括一个@Index注解,以告诉 Room 在即将添加的新字段claimItemId上生成数据库索引。索引将确保查询特定ClaimItem记录的附件时非常快速:
@Entity(indices = @Index("claimItemId"))
public class Attachment implements Parcelable {
- 为
Attachment添加数据库主键字段,以及新的claimItemId字段,该字段将用于指示当Attachment存储在数据库中时它属于哪个ClaimItem:
@PrimaryKey(autoGenerate = true)
public long id;
public long claimItemId;
- 确保存在一个
public默认构造函数,并且任何其他public构造函数都标记为@Ignore:
public Attachment() {}
@Ignore public Attachment(final File file, final Type type) {
this.file = file;
this.type = type;
}
- 更新
Attachment类的Parcelable实现,以包括新字段:
protected Attachment(final Parcel in) {
id = in.readLong();
claimItemId = in.readLong();
file = new File(in.readString());
type = Type.values()[in.readInt()];
}
public void writeToParcel(final Parcel dest, final int flags) {
dest.writeLong(id);
dest.writeLong(claimItemId);
dest.writeString(file.getAbsolutePath());
dest.writeInt(type.ordinal());
}
如你所见,将现有的对象模型修改为存储在Room数据库中非常简单。Room 现在能够生成代码来从其数据库的表中加载和保存这些对象;它还能从这些类中生成数据库模式。
创建数据访问层
现在你已经有了一些要写入数据库的内容,你需要一种实际写入的方法,以及一种再次检索它的方法。最常见的方式是为每个类创建一个专门处理此类操作的类——数据访问对象(Data Access Object,简称 DAO)。然而,在 Room 中,你只需要使用接口声明它们应该是什么样子;Room 会为你生成实现代码。你可以通过在方法上使用@Query注解来定义你的查询,如下所示:
@Query(“SELECT * FROM users WHERE _id = :id”)
public User selectById(long id);
这与传统 O/R 映射层相比具有巨大优势,因为你仍然可以编写任何形式的 SQL 查询,让 Room 来决定如何将其转换为所需的对象模型。如果它无法生成代码,你将在编译时得到错误,而不是应用程序可能因为用户而崩溃。这还有一个额外的优势:Room 可以将你的 SQL 查询绑定到非实体类,让你能够充分利用 SQLite 数据库的全部功能,而无需手动进行所有列/字段/对象映射。例如,你可以定义一个特殊的DisplayContact类来显示联系人列表中的摘要数据,然后直接使用join查询它们:
@Query(“SELECT contacts.firstname, contacts.lastname, emails.address FROM contacts, emails WHERE emails._id = contacts.primaryEmailId ORDER BY contacts.lastname”)
public List<DisplayContact> selectDisplayContacts()
前面的查询不会返回可以直接保存到数据库中的对象;它是查看两个不同的表并收集它们字段的结果。尽管如此,Room 处理这种情况非常得心应手,并且不需要对返回的类进行任何类型的注解。
LiveData 类
Room 执行的不仅仅是将数据库结构绑定到对象并再次绑定;它还为您提供了编写更简单反应性程序的能力。如前所述,Room 是 Android 架构组件库之一。架构组件共同提供了一般基础设施,可用于快速构建反应性应用程序,同时保持出色的性能和安全性。架构组件中最重要的类之一是 LiveData。LiveData 是对外部更改敏感的数据的通用封装。LiveData 可以被观察,就像用于数据绑定布局的类一样。主要区别在于 LiveData 将始终在新的观察者上触发一个 首次 事件,并提供当前的数据状态。
Room 内置了对 LiveData 的支持,这意味着您可以通过返回任何包装在 LiveData 中的对象来接收对该对象发生的任何更改。在撰写本文时,Room 通过监视每个表的变化来实现这一点。这意味着即使对象实际上没有发生变化,您也可能收到对象的更新。对于大多数应用程序来说,这不应该是一个问题,因为查询仍在工作线程上运行,而通知仅在主线程上发生。这使得 LiveData 在大多数情况下成为查询数据库的首选方法,因为它负责在工作线程上运行和处理查询,从而释放主线程来处理事件并保持应用程序平稳运行。
LiveData 不是 Room 的直接部分,因此您需要按照以下步骤将 LiveData 和其他架构组件添加到您的项目中:
- 在 Android 面板中,打开 Gradle Scripts 子部分,然后打开应用模块的 build.gradle 文件:
- 在文件底部,您会找到一个依赖项块;在块的底部,添加以下两行代码:
implementation 'android.arch.lifecycle:runtime:+'
implementation 'android.arch.lifecycle:extensions:+'
annotationProcessor 'android.arch.lifecycle:compiler:+'
- 使用编辑器顶部的 Sync Now 链接将您的项目与其 Gradle 文件同步,并下载新的依赖项。
在 Room 中实现数据访问对象
您需要为 Claim 应用程序实现两个不同的数据访问对象类,一个用于每个 Entity 对象。从技术上讲,Room 不强制要求每个实体有一个 DAO,您可以为整个应用程序或每个屏幕使用一个单一的 DAO 接口。然而,最常见的设计模式是每个实体类型有一个 DAO 类,即使其中一些查询方法返回统计数据或其他数据视图。当处理更复杂的数据集时,考虑引入额外的 DAO 接口来覆盖特定于屏幕的查询或数据重叠在多个实体上的查询。
下面是如何逐步实现 Claim 示例应用程序的数据访问对象接口:
-
在 Android Studio 中,右键单击
model包,然后选择 New | Java Class。 -
将新类命名为
db.ClaimItemDao。 -
将 Kind 字段更改为“接口”。Room DAO 类型通常是接口,尽管这不是严格的要求,它们也可以是抽象类。
-
点击“确定”以创建新的包和类。
-
使用
@Dao注解接口以将其标记为数据访问对象:
@Dao
public interface ClaimItemDao {
- 声明一个查询方法以按最近的时间顺序获取所有
ClaimItem对象;确保它返回LiveData以反映更改:
@Query("SELECT * FROM claimitem ORDER BY timestamp DESC")
LiveData<List<ClaimItem>> selectAll();
- 接下来,您需要方法来在数据库中插入、更新和删除
ClaimItem对象;这些方法仅接受Entity对象,而不是查询,而是用它们的操作进行注释。在插入方法的情况下,返回新记录生成的 ID 是有用的:
@Insert long insert(ClaimItem item);
@Update void update(ClaimItem item);
@Delete void delete(ClaimItem item);
-
现在,再次在
db包上右键单击,并选择“新建”|“Java 类”。 -
将新类命名为
AttachmentDao,并将其 Kind 设置为“接口”。 -
点击“确定”以创建
AttachmentDao类。 -
声明新的接口为
Dao:
@Dao
public interface AttachmentDao {
- 编写一个查询方法以获取单个
ClaimItem的Attachment对象。这是您在Attachment上声明的索引变得重要的地方:
@Query("SELECT * FROM attachment WHERE claimItemId = :claimItemId")
LiveData<List<Attachment>> selectForClaimItemId(final long claimItemId);
- 声明
Attachment类的插入、更新和删除方法,就像您对ClaimItem方法所做的那样:
@Insert long insert(Attachment attachment);
@Update void update(Attachment attachment);
@Delete void delete(Attachment attachment);
创建数据库
当使用 Room 编写应用程序时,您需要定义至少一个 数据库 类。每个此类都对应于一个特定的数据库模式–一组实体类及其保存和从存储中加载的各种方式。它还可以作为编写应用程序中其他数据库相关逻辑的方便位置。例如,ClaimItem 和 Attachment 类需要保存和加载 Room 无法理解的各种类型;例如,Date、File、Category 枚举和 Attachment Type 枚举。每个此类都需要一个 TypeConverter 方法,该方法可用于将其转换为 Room 能够理解的原始类型,并从原始类型转换回来。
Room 数据库类是抽象的。这是因为 Room 注解处理器会扩展它们以生成您在运行时使用的实现。这允许您在数据库类中定义任何数量的具体方法实现,这些实现可能对您的应用程序有用。按照以下步骤声明您的新 Room 兼容数据库类:
-
在 Android Studio 中右键单击
db包,然后选择“新建”|“Java 类”。 -
将新类命名为
ClaimDatabase,并将其 Superclass 设置为RoomDatabase。 -
选择“抽象”修饰符。
-
点击“确定”以创建新的类。
-
注释该类以表明它是一个数据库,并声明它将存储
ClaimItem和Attachment实体。您还需要指定模式版本,对于第一个版本将是1:
@Database(
entities = {ClaimItem.class, Attachment.class},
version = 1,
exportSchema = false)
public abstract class ClaimDatabase extends RoomDatabase {
- 如前所述,您需要为
ClaimItem和Attachment使用的所有非原始字段声明TypeConverter方法。您需要告诉数据库这些方法的位置,在这种情况下,它将是ClaimDatabase类本身:
@Database(
entities = {ClaimItem.class, Attachment.class},
version = 1,
exportSchema = false)
@TypeConverters(ClaimDatabase.class)
public abstract class ClaimDatabase extends RoomDatabase {
- 现在,定义用于检索您之前创建的数据访问对象实现的
abstract方法;这些方法将由 Room 生成的子类实现:
public abstract ClaimItemDao claimItemDao();
public abstract AttachmentDao attachmentDao();
- 现在,您需要告诉 Room 如何将各种字段转换为数据库支持的原始类型,并将其转换回原始类型。首先,实现将
Date对象转换为可以存储在数据库中的时间戳长整型的方法(SQLite 没有DATE或DATETIME类型):
@TypeConverter
public static Long fromDate(final Date date) {
return date == null ? null : date.getTime();
}
@TypeConverter
public static Date toDate(final Long value) {
return value == null ? null : new Date(value);
}
- 现在继续使用这种模式来处理
ClaimItem和Attachment需要的其他类型:
@TypeConverter
public static String fromFile(final File value) {
return value == null ? null : value.getAbsolutePath();
}
@TypeConverter
public static File toFile(final String path) {
return path == null ? null : new File(path);
}
@TypeConverter
public static String fromCategory(final Category value) {
return value == null ? null : value.name();
}
@TypeConverter
public static Category toCategory(final String name) {
return name == null ? null : Category.valueOf(name);
}
@TypeConverter
public static String fromAttachmentType(final Attachment.Type value) {
return value == null ? null : value.name();
}
@TypeConverter
public static Attachment.Type toAttachmentType(final String name) {
return name == null ? null : Attachment.Type.valueOf(name);
}
TypeConverter方法将由 Room 注解处理器找到并使用。它们直接从生成的代码中调用,基于存储或检索的 Java 类中使用的类型。这意味着它们几乎没有额外的运行时开销。
访问您的 Room 数据库
到目前为止,您已经为 Room 管理的 SQLite 数据库构建了所有组件,但您实际上仍然无法访问它。由于它是抽象的,您不能直接实例化ClaimDatabase类,您在 DAO 接口上也有同样的问题,那么访问数据库的最佳方法是什么?Room 为您提供了一个条目类,该类将正确实例化生成的ClaimDatabase实现,但这并不是全部故事;您的整个应用程序都依赖于这个数据库,它应该在应用程序启动时设置,并且应该对整个应用程序可访问。
您可以使用一个单例ClaimDatabase对象,但那么 SQLite 数据库文件将放在哪里呢?为了使其存储在应用程序的私有空间中,您需要一个 Context 对象。进入Application类,当使用时,它将持有将在您的应用程序中调用的第一个onCreate方法。按照以下快速步骤构建一个简单的Application类,该类将实例化并保留对您的ClaimDatabase的引用:
-
右键单击您的根包(即
com.packtpub.claim),然后选择“新建”|“Java 类”。 -
将新类命名为
ClaimApplication。 -
将其超类设置为
android.app.Application。 -
点击“确定”以创建应用程序类。
-
声明一个静态的
ClaimDatabase以供应用程序使用:
private static ClaimDatabase DATABASE;
- 重写
onCreate方法,并使用它通过 Room 实例化ClaimDatabase对象;这将在您的应用程序中的任何其他操作之前发生:
@Override
public void onCreate() {
super.onCreate();
DATABASE = Room.databaseBuilder(
this, /* Context */
ClaimDatabase.class, /* Abstract Database Class */
"Claims" /* Filename */
).build();
}
- 提供一个
publicstatic方法,供应用程序的其他部分使用,以访问单例数据库实例:
public static ClaimDatabase getClaimDatabase() {
return DATABASE;
}
-
您需要将
ClaimApplication注册到 Android 平台,以便它在应用程序启动时初始化它。您可以通过打开 manifests 目录并打开AndroidManifest.xml文件来完成此操作。 -
在
<application>元素中,你需要添加一个android:name属性来告诉 Android 平台代表应用程序根的类的名称:
<application
android:name=".ClaimApplication"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
现在,每当你的应用程序的任何部分需要数据库时,它都可以简单地调用 ClaimApplication.getClaimDatabase() 来检索一个全局实例,并且因为它不再与特定的上下文实例相关联,所以它可以从任何地方调用(甚至是一个演示者)。
测试你的知识
-
Android 的 Room API 提供了以下哪些?
-
一个完整的数据库解决方案
-
在 SQLite 之上的轻量级 API
-
一个对象存储引擎
-
-
从 Room DAO 返回
LiveData需要做哪些?-
你观察它以获取数据的变化
-
你在主线程上运行查询
-
当你被
LiveData对象通知时,再次调用查询方法
-
-
不返回
LiveData的数据库查询应该做什么?-
应该避免
-
在工作线程上运行
-
返回
Cursor对象
-
-
为 Room 编写更新方法需要哪些列表中的?
-
在 DAO 接口上的
@Query("UPDATE")方法 -
一个接口上的
@Update方法,接受一个Entity对象 -
将添加到你的
Entity实现中
-
摘要
你在 Android 应用程序中存储和检索结构化数据的方式将直接影响你的用户如何体验你的应用程序。当你选择使用 Room、CouchDB 或 Firebase 这样的系统时,数据更改作为更新推送到应用程序,用户将自然拥有一个反应式应用程序。更重要的是,应用程序通常将是响应式的,因为这些模式自然地将缓慢运行的查询和更新从应用程序主线程上移除。
Room 为标准的 Android 数据存储生态系统提供了一个出色的补充,不仅大大减少了编写样板数据访问代码的需求,而且还提供了一个定义良好且编写出色的接口来运行数据反应式查询。当然,你的应用程序不需要全部都是反应式的;一旦通过 LiveData 对象传递了一个对象,它就只是一个对象,可以用作内存快照,甚至如果它是可变的,还可以进行编辑。
当使用 Room 时,重要的是要记住你应该避免对象之间的复杂关系,因为 Room 将无法为你保存和解析这些关系。这通常是一个迹象,表明你可能需要重新思考你的数据结构;复杂的关系将大大减慢查询速度,因此任何依赖于它们的用户界面。通常,这些关系应该通过创建特定于演示的对象模型来处理,然后在查询中使用连接来检索所有所需的数据。有关 SQL 和如何在 SQLite 中使用它的更多信息,请参阅 SQLite 文档和 SQLite 项目网站上的教程,网址为 sqlite.org/。
在下一章中,我们将探讨构建概览屏幕的方法。这些屏幕在应用程序中极为常见,通常是应用程序的中心屏幕,用户在导航过程中会反复返回到这个屏幕。Android 为这些屏幕提供了一个极其灵活的组件——RecyclerView。此外,我们还将探讨如何使用 RecyclerView,通过将其与 LiveData 结合并使用数据绑定来确保它与应用程序的其他部分保持同步更新。
第七章:创建概览屏幕
概览屏幕,或仪表盘屏幕,是允许用户快速查看应用程序中数据的布局。因此,它们也是用户会反复返回的屏幕。通常情况下,它们被定位为用户打开应用程序时通常会看到的第一个屏幕,例如电子邮件应用程序中的收件箱,或 Google Drive 中的文件列表。在应用程序中,导航通常是目标导向的;用户从一个概览开始,然后导航以执行特定操作。一旦他们完成操作(例如,撰写并发送电子邮件),他们就会被重定向到概览屏幕。
概览屏幕可能是一个复杂的构建系统,因为它们应该是响应式的,并且通常依赖于大量的应用程序数据。由于这是用户在您的应用程序中最常看到的屏幕,因此在设计过程中概览屏幕需要特别注意。向用户提供最重要的数据,而不会让他们感到不知所措是很重要的。在屏幕上放置过多的信息会让用户更难找到他们想要的信息。
在本章中,我们将探讨如何设计概览屏幕。我们将详细探讨以下内容:
-
RecyclerView类,这是概览列表中最常用的组件 -
数据绑定如何使
RecyclerView的使用更加容易 -
设计概览屏幕时可以使用的技巧
-
如何从 Room 数据库获取数据到
RecyclerView
设计概览屏幕
概览屏幕和仪表盘屏幕不仅是用户通常会看到的第一个界面,而且也是与用户接触最频繁的点。它们需要具备功能性、美观性,并且非常快速。如果一个应用程序加载第一个屏幕需要太长时间,只会让用户感到沮丧。如果应用程序让用户感到沮丧,他们就会避免使用它。因此,考虑用户需要的信息以及他们在概览屏幕上最可能采取的重要操作非常重要。
Material Design 指南提供了极好的建议,可以帮助您决定应用程序的这些方面,从而帮助您制作出更好的应用程序。记住,虽然发挥创意(并且很重要)是件有趣的事情,但坚持规则也非常重要。设计中的常见模式有助于用户理解您要求他们做什么,以及如何使用您的应用程序。您和用户之间的这种理解是为什么Material Design 是一种设计语言,而不仅仅是外观和感觉。这是一种您可以与用户交谈的语言,他们可以轻松理解。例如,当您在屏幕的右下角有一个浮动操作按钮时,用户知道它通常用于启动或创建新事物,例如创建一个空文档或拍摄一张新照片(取决于应用程序)。
概览屏幕需要允许用户到达应用程序的每个部分,但与网站或桌面应用程序不同,这可能需要一些中间步骤(尽管尽可能少)。这意味着虽然你可能向他们展示数据,但这绝不应该只是为了查看。放置在概览屏幕上的每个元素都必须有存在的理由。它们都应该履行两个角色:向用户提供信息,并允许他们使用这些信息采取某些行动(即使只是了解更多)。一个角色是通过简单地出现在屏幕上完成的,另一个角色是通过允许用户点击小部件来完成的。当然,你可以添加更多:滑动以取消,滚动等。在这些情况下,交互必须与 Material Design 中的交互保持一致(即,滑动以取消应该始终应用于列表项,而不是按钮)。
应用程序中一个示例流程应类似于以下图表。你会注意到所有流程最终都会将用户带回到概览屏幕。这就是所谓的深度导航;它是一个以目标为导向的结构,旨在引导用户完成他们试图完成的任务:
概览屏幕的元素
概览屏幕有一些常见的元素,让用户知道他们正在看什么,以及他们应该如何使用该屏幕。了解人们在第一次看到屏幕时是如何看待屏幕的很有帮助。尼尔森等团体进行的研究表明,大多数西方人在第一次看屏幕时遵循一种类似F形的模式。从左上角开始,他们的眼睛向右下方移动,如图所示:
这意味着在设计概览屏幕时,最重要的信息应该位于屏幕顶部,其次是位于其右侧的第二重要信息,随着你在屏幕上向下工作,信息的重要性逐渐降低。前面提到的图表在其屏幕顶部使用了一个图表;这也是一个重要的元素:在适用的情况下,优先使用图形和指标而不是原始数字。用户可以从图表中获得比从数字表更快的概览,尽管后者更强大。概览屏幕应该是用户可以在几秒钟内使用的;它不是一个他们想要花时间理解细节的地方。因此,概览屏幕不需要滚动就可以有用。避免滚动概览屏幕的重要性不如在表单/输入屏幕上那么重要,但任何滚动只应适用于访问详细信息。
概览屏幕通常以图表或用户数据的摘要开始,然后是适用细节的列表。以旅行报销应用为例,概览应该有屏幕顶部的概览片段,然后是他们的旅行报销列表,最近的报销在最上面:
概览片段允许他们看到他们花了多少钱,而列表则立即显示他们花在什么上。另一种可能性是显示他们在每个类别中花费的细分情况的图表。然而,这通常在日常基础上不太有用,而在商务旅行结束时以报告的形式更有用。
概览屏幕上最常见的元素之一是某种类型的列表。即使概览中不包含图表和信息图表,用户最新/最有用的项目列表也是非常常见的结构,Android 提供了RecyclerView作为构建此类列表的完美系统。与ViewPager或ListView不同,RecyclerView是一个用于显示大量滚动数据的通用系统。其子小部件不需要以严格的方式布局;它们可以是列表,可以是网格,可以是错落有致的,或者任何你想要用自定义布局管理器想到的东西。然而,它们都共享一组公共结构–每个RecyclerView都需要以下组件:
-
一个
Adapter来提供子View对象,并将它们绑定到数据模型 -
包装子
View对象的ViewHolder类 -
一个
LayoutManager来决定如何放置子View对象相对于彼此的位置
让我们更详细地探讨如何构建和使用RecyclerView的组件,以及如何构建旅行报销应用的概览屏幕。
为 ViewHolder 创建布局
RecyclerView正是其名称所暗示的–它回收或重用其子元素来向用户展示不同的数据。这意味着虽然它看起来有一个长长的子小部件列表(如卡片或图片),但实际上它只有用户可以看到的那些。当一个小部件被滚动出屏幕时,RecyclerView会更改其数据,然后将其滚动回视图。RecyclerView不会直接将数据绑定到子视图中;然而,它通过ViewHolder来完成。ViewHolder的职责是帮助加快数据绑定过程。再次以旅行报销应用为例;如果我们想在RecyclerView中显示每个报销项目,每个项目将看起来像以下这样:
前述每一项都需要不同的 Android 小部件,并且每次您想要填充它们时,都需要查找并将它们绑定到新的数据。ViewHolder实现是一个方便的地方,可以查找、保留和绑定特定数据模型类型和显示组件的数据。让我们继续为前面的图创建一个布局资源,然后我们可以创建一个ViewHolder来使用它与RecyclerView:
-
在 Android Studio 中,在应用程序资源(res)目录下,右键单击布局目录并选择“新建|布局资源文件”:
-
将新的布局资源命名为
card_claim_item。 -
将根元素更改为
CardView:
-
点击“确定”以创建新的布局文件:
-
在调色板中,打开布局部分,并将一个
ConstraintLayout拖动到设计画布中: -
在调色板中,打开图像部分,并将一个
ImageView拖动到设计画布中: -
从自动打开的可绘制资源选择器中选择
ic_other_black图标: -
使用右侧的约束编辑器添加到新
ImageView顶部、左侧和底部的约束,并将所有这些设置为 8,如下所示:
-
将
ImageView的 ID 更改为item_category: -
在调色板中,打开文本部分,并将一个新的
TextView拖动到设计画布中,位于类别图标ImageView的右侧: -
使用约束编辑器为新
TextView添加顶部、右侧和底部的8dp约束,以便它居中并放置在 Design canvas 的右侧(直接对应类别图标ImageView): -
将
TextView的 ID 更改为item_amount: -
删除文本属性的值,并将下面的文本属性(带有扳手图标的属性)更改为
250。此值仅用于设计画布,并允许您预览设置值后的布局外观(尽管实际值在运行时填充):
-
将
textAppearance属性更改为@style/TextAppearance.AppCompat.Medium,它将在下拉菜单中显示为AppCompat.Medium: -
从调色板中拖动另一个
TextView到设计视图中,大致位于图标ImageView和金额TextView之间: -
从
TextView的左侧拖动一个约束到ImageView的右侧手柄:
- 从新
TextView的右侧拖动另一个约束到金额TextView的左侧:
-
使用约束编辑器为新
TextView添加顶部约束: -
将顶部约束设置为
8:
-
使用属性面板(位于约束编辑器下方)将新
TextView的layout_width属性更改为match_constraint: -
将新
TextView的 ID 更改为item_description: -
清除文本属性,并将设计文本属性设置为
Airport Shuttle,这样你仍然在设计画布上有所可见。 -
将文本外观属性更改为
@style/TextAppearance.AppCompat.Medium,它将在下拉菜单中显示为AppCompat.Medium。 -
从调色板中拖动第三个
TextView到设计画布,并将其放在类别图标ImageView和金额TextView之间。 -
就像描述
TextView一样,将新的TextView约束到类别图标右侧和金额TextView左侧。 -
使用约束编辑器,在新的
TextView底部添加一个约束,并将其底部边距设置为8:
-
将新
TextView的 ID 设置为item_timestamp。 -
将新
TextView的layout_width更改为match_constraint。 -
从新
TextView的顶部拖动一个约束到描述TextView的底部;这将确保它们之间至少有 8dp 的空间。 -
清除文本属性,并将设计工具文本属性设置为日期,例如
27-December-2017。 -
在组件树面板中,选择布局根部的
CardView。 -
切换到查看所有属性面板。
-
打开布局边距组。
-
将顶部边距设置为
@dimen/grid_spacer1。 -
将
CardView的layout_height设置为wrap_content;布局将卷起,看起来像这样:
创建一个简单的 ViewHolder 类
创建一个 ViewHolder 非常简单,并且这是一个封装 RecyclerView 中渲染项目特定逻辑的好地方。对于前面的布局,按照以下步骤构建一个 ViewHolder:
-
在 Android Studio 中的 ui 包上右键单击,并选择 New| Java Class。
-
将新类命名为
ClaimItemViewHolder。 -
将新类的父类设置为
android.support.v7.widget.RecyclerView.ViewHolder。 -
点击确定以创建新的类。
-
ViewHolder的主要任务是加快数据模型与用户界面小部件之间的绑定,为此,ViewHolder需要引用它将要填充的每个View对象:
private final ImageView categoryIcon;
private final TextView description;
private final TextView amount;
private final TextView timestamp;
- 这个
ViewHolder还需要一种格式化时间戳的方法,而最好的方法就是使用java.text.DateFormat,这也是需要保留引用的东西,因为它们构建起来相当昂贵:
private final DateFormat dateFormat;
ViewHolder通常使用它预期绑定到的View对象来构建。你可以在ViewHolder构造函数中填充View对象,但为了保持灵活性并避免在构造函数中产生参数混乱,这个ViewHolder实现将只接受它将要包装的View对象:
public ClaimItemViewHolder(final View claimItemCard) {
super(claimItemCard);
this.categoryIcon = claimItemCard.findViewById(R.id.item_category);
this.description = claimItemCard.findViewById(R.id.item_description);
this.amount = claimItemCard.findViewById(R.id.item_amount);
this.timestamp = claimItemCard.findViewById(R.id.item_timestamp);
- 你还需要创建一个
DateFormat对象,并且你希望使用用户当前区域的长时间日期格式:
this.dateFormat = DateFormat.getDateInstance(DateFormat.LONG);
- 这个类需要一个工具方法来确定应该渲染哪个图标用于
Category,这将涉及手动引用应用程序的Resources来检索类别图标的黑色版本:
public Drawable getCategoryIcon(final Category category) {
final Resources resources = itemView.getResources();
switch (category) {
case ACCOMMODATION:
return resources.getDrawable(R.drawable.ic_hotel_black);
case FOOD:
return resources.getDrawable(R.drawable.ic_food_black);
case TRANSPORT:
return resources.getDrawable(R.drawable.ic_transport_black);
case ENTERTAINMENT:
return resources.getDrawable(R.drawable.ic_entertainment_black);
case BUSINESS:
return resources.getDrawable(R.drawable.ic_business_black);
case OTHER:
default:
return resources.getDrawable(R.drawable.ic_other_black);
}
}
- 你还需要一个工具方法来格式化金额,使得整数金额没有小数部分,而非整数只显示两位小数:
public String formatAmount(final double amount) {
return amount == 0
? ""
: amount == (int) amount
? Integer.toString((int) amount)
: String.format("%.2f", amount);
}
- 最后,你需要一种方式让适配器用数据填充所有
View元素,并且因为这个类是针对ClaimItem数据对象的,你可以通过提供一个类似设置器的方法来简化这个过程:
public void setClaimItem(final ClaimItem item) {
categoryIcon.setImageDrawable(getCategoryIcon(item.getCategory()));
description.setText(item.getDescription());
amount.setText(formatAmount(item.getAmount()));
timestamp.setText(dateFormat.format(item.getTimestamp()));
}
使用数据绑定创建 ViewHolder
如您从构建传统的 ViewHolder 实现中看到的那样,仅仅为了将单个项目中的数据放置在布局中,就需要做很多工作,并且有很多样板代码。此外,它本身实际上相当昂贵,因为每个 ViewHolder 实例都会创建并持有 DateFormatter 的一个实例,它们可以很容易地在 RecyclerView 的所有 ClaimItemViewHolder 实例之间共享:
在这种情况下,数据绑定可以带来巨大的差异。通过使用一些技巧,你实际上可以创建一个完全通用的 ViewHolder 实现,它将适用于你应用程序中的任何数据对象(假设你可以将其绑定到布局文件)。首先,你需要创建一个漂亮的通用 ItemPresenter,然后修改布局,然后你就可以创建一个通用的数据绑定 ViewHolder 实现了。按照这些说明操作,你将只需要一个 ViewHolder 实现:
-
在 Android Studio 中,右键单击
presenters包,然后选择 New| Java Class。 -
将类命名为
ItemPresenter。 -
点击“确定”以创建新类。
-
ItemPresenter需要一个Context来引用应用程序的Resources和文件:
private final Context context;
public ItemPresenter(final Context context) {
this.context = context;
}
- 创建一个与简单
ViewHolder类中相同方式的formatAmount工具方法:
public String formatAmount(final double amount) {
return amount == 0
? ""
: amount == (int) amount
? Integer.toString((int) amount)
: String.format("%.2f", amount);
}
- 在新的
ItemPresenter中编写一个getCategoryIcon工具方法(这几乎与ClaimItemViewHolder中的方法完全相同,只是在访问Resources对象的方式上有所不同):
public Drawable getCategoryIcon(final Category category) {
final Resources resources = context.getResources();
switch (category) {
case ACCOMMODATION:
return resources.getDrawable(R.drawable.ic_hotel_black);
case FOOD:
return resources.getDrawable(R.drawable.ic_food_black);
case TRANSPORT:
return resources.getDrawable(R.drawable.ic_transport_black);
case ENTERTAINMENT:
return resources.getDrawable(R.drawable.ic_entertainment_black);
case BUSINESS:
return resources.getDrawable(R.drawable.ic_business_black);
case OTHER:
default:
return resources.getDrawable(R.drawable.ic_other_black);
}
}
- 编写一个
formatDate工具方法,将Date对象转换为适合在屏幕上显示的文本。转换是通过一个DateFormat对象完成的,它仅在第一次调用formatDate时创建(它是延迟初始化的)。延迟初始化很重要,因为这个类预期将在应用程序中所有可能的项表示器中通用,因此,将会有一些情况下它不会被使用:
private DateFormat dateFormat;
public String formatDate(final Date date) {
if (dateFormat == null) {
dateFormat = DateFormat.getDateInstance(DateFormat.LONG);
}
return dateFormat.format(date);
}
-
现在,打开
card_claim_item.xml布局资源。 -
在编辑器中切换到文本视图。
-
在
CardView上方创建一个新的布局根元素,并确保从CardView中移除命名空间声明,并在文件末尾关闭布局元素:
<layout
>
- 在
CardView上方声明一个包含两个变量的数据块。保持这些名称的泛型是很重要的。一个将是ItemPresenter的实例,另一个将是布局要绑定的ClaimItem:
<data>
<variable name="presenter" type="com.packtpub.claim.ui.presenters.ItemPresenter" />
<variable name="item" type="com.packtpub.claim.model.ClaimItem" />
</data>
- 找到
item_category的ImageView声明,并添加一个新的数据绑定属性,使用ItemPresenter找到正确的图标:
<ImageView
android:id="@+id/category_icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
app:imageDrawable="@{presenter.getCategoryIcon(item.category)}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
- 找到
TextView的声明并绑定其文本属性,使用Presenter将ClaimItem中的金额格式化:
<TextView
android:id="@+id/item_amount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:layout_marginEnd="8dp"
android:layout_marginTop="8dp"
android:text="@{presenter.formatAmount(item.amount)}"
android:textAppearance="@style/TextAppearance.AppCompat.Medium"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="150" />
- 将
ClaimItem中的描述数据绑定到item_descriptionTextView:
<TextView
android:id="@+id/item_description"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:text="@{item.description}"
android:textAppearance="@style/TextAppearance.AppCompat.Medium"
app:layout_constraintEnd_toStartOf="@+id/item_amount"
app:layout_constraintStart_toEndOf="@+id/category_icon"
app:layout_constraintTop_toTopOf="parent"
tools:text="Airport Shuttle" />
- 使用
Presenter将ClaimItem中的时间戳数据绑定到时间戳TextView:
<TextView
android:id="@+id/item_timestamp"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:layout_marginEnd="8dp"
android:text="@{presenter.formatDate(item.timestamp)}"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/item_amount"
app:layout_constraintStart_toStartOf="@+id/item_description"
app:layout_constraintTop_toBottomOf="@+id/item_description"
tools:text="16-December-2017" />
-
现在,是时候开始创建一个通用的
ViewHolder类,它可以与任何数据绑定布局一起重用。在ui包上右键单击,然后选择“新建 | Java 类”。 -
将新类命名为
DataBoundViewHolder。 -
将超类改为
android.support.v7.widget.RecyclerView.ViewHolder。 -
点击“确定”以创建新类。
-
在类中添加一个泛型声明,以便为
Presenter和 Item(P,I)变量提供泛型类型:
public class DataBoundViewHolder<P, I> extends RecyclerView.ViewHolder {
- 数据绑定系统生成的每个绑定类都扩展了
ViewDataBinding;DataBoundViewHolder实际上将包装这些之一,以便任何数据绑定布局都可以被包装:
private final ViewDataBinding binding;
- 现在,编写一个构造函数,它接受一个
ViewDataBinding对象和一个用于数据绑定布局的Presenter对象。由于ViewDataBinding是一个泛型抽象类,我们无法直接调用在CardClaimItemBinding类中由数据绑定系统生成的setPresenter方法。相反,我们可以使用一个特殊的泛型数据绑定方法,它允许你根据生成的 ID 号分配未知变量;这有点像使用 Java 反射,但实际的实现是在编译时生成的,并且非常快:
public DataBoundViewHolder(final ViewDataBinding binding, final P presenter) {
super(binding.getRoot());
this.binding = binding;
this.binding.setVariable(BR.presenter, presenter);
}
如果你面临多个BR类的选择,请使用你自己的项目(com.packtpub.claim)的类。与正常的 Android 资源(R)类似,数据绑定系统为每个项目生成一个查找类。
- 然后,编写两个 setter 方法,以便可以从外部统一更改
Presenter和item变量:
public void setItem(final I item) {
binding.setVariable(BR.item, item);
}
public void setPresenter(final P presenter) {
binding.setVariable(BR.presenter, presenter);
}
setVariable方法在编译时生成,就像 getter 和 setter 方法一样,由一系列if语句组成。这使得它比实际的 setter 方法慢一点,但比使用反射调用 setter 方法要快得多。这不是需要优化的区域,特别是当这些数据绑定布局只有两个可能的变量时。如果你的布局在RecyclerView中需要超过这两个变量,你应该考虑将这些逻辑和数据组合或继承到更具体的类中。
本节中定义的card_claim_item布局生成的setVariable实现将类似于以下内容:
public boolean setVariable(int variableId, @Nullable Object variable) {
boolean variableSet = true;
if (BR.item == variableId) {
setItem((com.packtpub.claim.model.ClaimItem) variable);
}
else if (BR.presenter == variableId) {
setPresenter((ItemPresenter) variable);
}
else {
variableSet = false;
}
return variableSet;
}
如您所见,此代码将非常快速地执行,如果给出了未知变量 ID,则不会抛出异常。然而,如果您尝试为数据绑定变量传递错误类型,它将抛出ClassCastException。
创建 RecyclerView 适配器
为了将数据放入RecyclerView中,你需要一个Adapter类,这类似于你为显示CaptureClaimActivity的附件预览而编写的PagerAdapter。然而,RecyclerView比ViewPager做了更多繁重的工作,因此,在适配器内部可以和不可以做的事情比PagerAdapter要受到更多的限制。此外,与PagerAdapter不同,RecyclerView适配器涉及两个与显示每个元素相关的操作:创建和绑定。当RecyclerView需要为元素创建一个新的子视图小部件时,它将调用onCreateViewHolder,这个方法应该返回一个未填充的ViewHolder,然后这个ViewHolder将被传递到onBindViewHolder,在那里应该将数据映射到从适配器使用的任何数据源中。
首先,RecyclerView完全维护其子视图的列表,因此适配器绝不能直接添加或删除它们。其次,RecyclerView期望适配器是稳定的,也就是说,适配器内部的数据必须在通知RecyclerView的情况下才能改变。
与像ListView和GridView这样的旧回收小部件类不同,RecyclerView并不假设它一次又一次地展示相同的对象模型。相反,从Adapter返回的每个对象可以可选地有一个视图类型指示器;当这些不同时,RecyclerView为每个视图类型维护一个单独的池,并分别回收它们。
当使用不同的视图类型时,适配器通常使用布局资源 ID 作为视图类型;这些在应用程序中是唯一的,避免了在内部视图类型 ID 和实际资源之间进行switch语句或类似映射的需要。
对于旅行索赔示例,您需要一个适配器来在概览屏幕上显示所有的ClaimItems。幸运的是,Room 为您提供了预构建的LiveData,可以直接观察,这使得构建适配器变得简单得多。按照以下简单步骤构建一个绑定到LiveData对象的RecyclerView适配器,并使用DataBoundViewHolder将数据展示给用户:
-
右键点击 ui 包,选择 New| Java Class。
-
将新类命名为
ClaimItemAdapter。 -
点击“确定”以创建新类。
-
将类声明修改为继承自
RecyclerView.Adapter,并描述你将使用的DataBoundViewHolder泛型:
public class ClaimItemAdapter
extends RecyclerView.Adapter<DataBoundViewHolder<ItemPresenter, ClaimItem>> {
- 此适配器类将作为资源填充数据绑定的布局文件,因此它需要一个
LayoutInflator来完成这项工作:
private final LayoutInflater layoutInflater;
ItemPresenter实例也可以在屏幕上所有显示的索赔项布局之间共享,因此ClaimItemAdapter应该持有它的引用:
private final ItemPresenter itemPresenter;
- 最重要的是,
ClaimItemAdapter需要数据来展示。确保你实例化这个引用,这样你就不需要在其他方法中进行空检查:
private List<ClaimItem> items = Collections.emptyList();
- 现在,声明一个
ClaimItemAdapter的构造函数;由于ClaimItemAdapter将观察一个LiveData对象,它需要一个LifecycleOwner。LifecycleOwner告诉LiveData何时通知你变化,何时不通知,以及何时注销任何监听器。典型的LifecycleOwners是Activity或Fragment实例,但你几乎可以将任何类变成LifecycleOwner:
public ClaimItemAdapter(
final Context context,
final LifecycleOwner owner,
final LiveData<List<ClaimItem>> liveItems) {
this.layoutInflater = LayoutInflater.from(context);
this.itemPresenter = new ItemPresenter(context);
为了获得更大的灵活性,你可以允许将ItemPresenter传递给构造函数。这将允许在ClaimItemAdapter对象外部扩展或配置ItemPresenter,并且每个实例都可以有不同的展示规则。
- 注意,
ClaimItemAdapter还没有保留对LiveData实例的引用,实际上,它根本不会直接持有任何引用。相反,你将使用匿名内部类(如果可用的话,可以使用 lambda 表达式)来观察LiveData。重要的是要知道,当你开始观察一个LiveData实例时,如果你的LifecycleOwner处于正确的状态,你将自动接收到一个初始事件,其中包含数据的当前状态。这意味着你永远不需要尝试直接获取数据:
liveItems.observe(owner, new Observer<List<ClaimItem>>() {
public void onChanged(final List<ClaimItem> claimItems) {
ClaimItemAdapter.this.items = (claimItems != null)
? claimItems
: Collections.<ClaimItem>emptyList();
ClaimItemAdapter.this.notifyDataSetChanged();
}
});
- 现在构造函数已经完成,是时候实现与绑定相关的功能了。第一步是实现
onCreateViewHolder,这将使用DataBindingUtil来创建布局和ViewDataBinding,后者将被DataBoundViewHolder包装:
public DataBoundViewHolder<ItemPresenter, ClaimItem> onCreateViewHolder(
final ViewGroup parent,
final int viewType) {
return new DataBoundViewHolder<>(
DataBindingUtil.inflate(
layoutInflater,
R.layout.card_claim_item,
parent,
false
),
itemPresenter
);
}
- 由于
DataBoundViewHolder的实现,onBindViewHolder方法非常容易实现:
public void onBindViewHolder(
final DataBoundViewHolder<ItemPresenter, ClaimItem> holder,
final int position) {
holder.setItem(items.get(position));
}
RecyclerView还需要知道数据模型中有多少项:
public int getItemCount() {
return items.size();
}
此适配器可以非常容易地进一步适应,就像DataBoundViewHolder一样,允许你使用任意数据绑定的布局文件展示从 Room 数据库返回的任何LiveData列表。数据绑定和LiveData的结合是一个非常强大的组合,极大地简化了你的用户界面代码,并避免了为每种视图和模型组合编写大量样板结构的需要。
数据绑定适配器
如果你想在包含RecyclerView的布局上使用数据绑定,你甚至可以将适配器对象数据绑定到RecyclerView。你所需做的只是在一个表示类中公开一个方法来访问所需的适配器对象:
private RecyclerView.Adapter<?> claimItemsAdapter;
public RecyclerView.Adapter<?> getClaimItemsAdapter() {
if (claimItemsAdapter == null) {
claimItemsAdapter = new ClaimItemAdapter(
this, this,
database.claimItemDao().selectAll()
);
}
return claimItemsAdapter;
}
重要的是你预先构建或缓存你创建的实例,以避免不必要地重新创建适配器对象。同时,也要记住不要将适配器设置为ObservableField或类似类型,因为适配器的内容应该是变化的,而不是适配器本身。要绑定RecyclerView到其适配器,请使用数据绑定系统的自动属性系统:
<android.support.v7.widget.RecyclerView
app:adapter="@{presenter.claimItemsAdapter}"
app:layoutManager="android.support.v7.widget.LinearLayoutManager"
android:id="@+id/claim_items"
android:clipChildren="false"
android:layout_marginTop="@dimen/grid_spacer1"
android:layout_width="match_parent"
android:layout_height="match_parent">
在使用数据绑定和适配器视图一起时,记住它们都会更新用户界面是非常重要的。因此,确保你保持适配器引用在表示者中稳定,并且在没有确定的情况下不要更改它。更改适配器引用将导致AdapterView(例如RecyclerView)完全重建其内容,而不是仅仅刷新其内容。使用适配器来通知AdapterView变化,比使适配器可观察要好得多。
创建概览活动
旅行报销示例应用需要一个很好的概览活动来整合津贴概览、报销项列表以及用户创建新报销项的方式。由于我们有 Room 数据库,事情可以变得显著地更加解耦,这真的是一件好事。拥有一个中央的响应式数据源允许应用程序的不同部分始终反映应用程序的实际状态,而无需相互协调。
构建OverviewActivity的第一部分是创建Activity类本身,并用用户输入的报销项填充它。按照以下步骤创建一个骨架OverviewActivity并将其注册为应用程序的主Activity:
-
首先右键单击你的主包(即 com.packtpub.claim),然后从菜单中选择 New | Activity | Empty Activity。
-
将新类命名为
OverviewActivity。 -
将所有其他字段保留为默认值,并选择 Finish 以创建新的
Activity及其布局文件。 -
打开新的
activity_overview.xml布局文件并切换到文本编辑器。 -
Android Studio 已经将
ConstraintLayout作为根元素放置好了;将其更改为FrameLayout,因为这个布局非常简单,而且由于逻辑将是自绑定,使用数据绑定布局就没有意义了:
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.packtpub.claim.OverviewActivity">
</FrameLayout>
FrameLayout是一个非常简单的布局,其中其子元素是相互叠加渲染的。第一个子元素先被绘制,然后第二个子元素在第一个子元素之上被绘制。这使得它非常适合构建分层场景,即使某些层可能不会总是可见。
FrameLayout的第一个子元素将是一个简单的LinearLayout,以便你可以在报销项滚动列表上方放置津贴概览。在这里使用LinearLayout是理想的,因为它是一个非常简单且非常快的布局,我们不需要ConstraintLayout的复杂性:
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:orientation="vertical"
android:paddingTop="@dimen/grid_spacer1"
android:paddingBottom="@dimen/grid_spacer1">
</LinearLayout>
LinearLayout的第一个子元素是AllowanceOverviewFragment,它将允许用户编辑他们的每日津贴并查看他们花费了多少:
<fragment
class="com.packtpub.claim.ui.AllowanceOverviewFragment"
android:id="@+id/allowance_overview"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
- 接下来是
RecyclerView,它将显示用户输入的报销项的滚动列表。注意这里的裁剪和填充属性;它们确保报销项卡片有内边距,但它们的完整边框和阴影将可见:
<android.support.v7.widget.RecyclerView
android:id="@+id/claim_items"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="@dimen/grid_spacer1"
android:clipToPadding="false"
android:paddingLeft="@dimen/grid_spacer1"
android:paddingRight="@dimen/grid_spacer1"
app:layoutManager="android.support.v7.widget.LinearLayoutManager" />
-
现在,打开 Android Studio 生成的
OverviewActivity类;是时候用声明项填充布局了。 -
我们将使用
ClaimItemAdapter渲染ClaimItem对象的列表,并且它需要使用数据库产生的LiveData对象来监视变化。这要求Activity报告其生命周期,这通过扩展支持包提供的Activity实现之一(在这种情况下,AppCompatActivity)来完成:
public class OverviewActivity
extends AppCompatActivity {
- 由于此
Activity的所有行为实际上都是由其片段和由ClaimDatabase触发的LiveData变化处理的,因此onCreate实现只需要设置RecyclerView的适配器。OverviewActivity的所有其他逻辑和行为将由片段和适配器处理:
protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_overview);
final RecyclerView claimItems = findViewById(R.id.claim_items);
claimItems.setAdapter(new ClaimItemAdapter(
// both the Context, and LifecycleOwner are the OverviewActivity
this, this,
ClaimApplication.getClaimDatabase().claimItemDao().selectAll()
));
}
-
最后,你需要更改
AndroidManifest.xml文件,告诉系统应用程序的主入口点是OverviewActivity,而不是CaptureClaimActivity;打开项目文件树顶部附近的manifests文件夹,并打开AndroidManifest.xml文件。 -
修改活动元素声明,使 MAIN / LAUNCHER intent-filter 在
OverviewActivity元素中而不是CaptureClaimActivity元素中。还值得更改windowSoftInputMode属性,以便在启动OverviewActivity时软件键盘不会自动打开。键盘默认打开,因为屏幕上的第一个小部件是EditText字段,用户可以在其中输入他们的每日津贴:
<activity
android:name=".CaptureClaimActivity"
android:label="@string/title_activity_capture_claim"
android:theme="@style/AppTheme.NoActionBar" />
<activity
android:name=".OverviewActivity"
android:windowSoftInputMode="stateHidden">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
如果你现在运行你的应用程序,你会看到虽然屏幕在技术上已经完成,但没有声明项,也没有添加它们的方法。因此,RecyclerView 中没有内容可供查看或滚动:
你需要提供一个方法让用户添加新的声明项。最好的方法是在屏幕右下角使用一个浮动操作按钮,我们将使用一个新的 Fragment 来实现这一点。通过使用 Fragment 来完成这项任务,你可以在应用程序的任何屏幕上放置一个“新建项”浮动操作按钮,而无需在 Activity 类中实现任何特殊代码。
使用片段创建新的声明项
使用 Room 数据库中的 LiveData 的一个不寻常的特性是,现在应用程序的各个部分可以相互交互,而无需相互直接了解。在你的 OverviewActivity 的情况下,这将允许你在不向 ClaimItemAdapter 发送任何“新项目”或“项目已添加”事件的情况下,用新的 ClaimItem 实体填充数据库。然而,Room 数据库抽象层阻止你在主线程上运行任何查询,除非它返回 LiveData。虽然检索 ClaimItem 实体的查询返回了 LiveData,但插入新的 LiveData 实体需要在后台运行。按照以下步骤构建一个允许用户捕捉和记录新的旅行索赔项的 Fragment:
-
你需要一项任务来插入一个
ClaimItem实体以及与其相关的任何Attachment实体。这项任务需要在后台工作线程上运行,因此打开 Android Studio 中的ClaimDatabase类。 -
在返回抽象方法的后面,
ClaimItemDao和AttachmentDao声明了一个新的方法,该方法返回一个插入新ClaimItem的Runnable任务:
public Runnable createClaimItemTask(final ClaimItem claimItem) {
return new Runnable() {
@Override
public void run() {
}
};
}
- 在新的
Runnable任务中,你希望使用事务将ClaimItem对象的内容保存到数据库中;如果此方法的任何部分失败,事务将被回滚,并且该方法将没有任何效果:
beginTransaction();
try {
final long claimId = claimItemDao().insert(claimItem);
claimItem.id = claimId;
for (final Attachment attachment : claimItem.getAttachments()) {
attachment.claimItemId = claimId;
attachment.id = attachmentDao().insert(attachment);
}
setTransactionSuccessful();
} finally {
endTransaction();
}
-
你还需要在
ClaimItem中有一个方法来确保它有内容并且被认为是有效的,因此打开ClaimItem类。 -
在
ClaimItem类的末尾创建一个新的isValid方法;这将用于在CaptureClaimActivity返回ClaimItem时检查我们是否应该将新的ClaimItem存储到数据库中:
public boolean isValid() {
return !TextUtils.isEmpty(description)
&& amount > 0
&& timestamp != null
&& category != null;
}
-
你需要一个用于添加索赔项的新图标;在可绘制资源目录上右键单击,然后选择新建|矢量资产。
-
使用图标选择器找到并选择名为
add的图标。 -
将新的图标资源命名为
ic_add_white_24dp。 -
点击下一步然后点击完成以创建新的资源。
-
在 Android Studio 文本编辑器中打开新的图标资源。
-
将路径元素的
fillColor属性更改为白色:
<path
android:fillColor="#FFFFFFFF"
android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
-
现在,在 ui 包上右键单击,然后选择新建|片段|片段(空白)。
-
将新的
Fragment类命名为NewClaimItemFloatingActionButtonFragment。 -
关闭包含片段工厂方法和包含接口回调选项。
-
点击完成按钮以创建新的
Fragment类。 -
打开新的布局文件,该文件应命名为
fragment_new_claim_item_floating_action_button.xml。 -
用一个
FloatingActionButton替换此文件的内容:
<android.support.design.widget.FloatingActionButton
tools:context="com.packtpub.claim.ui.NewClaimItemFloatingActionButtonFragment"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:fabSize="normal"
app:srcCompat="@drawable/ic_add_white_24dp" />
-
现在,打开新的
NewClaimItemFloatingActionButtonFragment类。 -
将类声明更改为实现
View.OnClickListener接口:
public class NewClaimItemFloatingActionButtonFragment
extends Fragment
implements View.OnClickListener {
- 声明一个请求码,用于将用户发送到
CaptureClaimActivity:
private static final int REQUEST_CODE_CREATE_CLAIM_ITEM = 100;
- 将
onCreateView方法更改为同时设置FloatingActionButton的OnClickListener:
@Override
public View onCreateView(
final LayoutInflater inflater,
final ViewGroup container,
final Bundle savedInstanceState) {
final View button = inflater.inflate(
R.layout.fragment_new_claim_item_floating_action_button,
container,
false
);
button.setOnClickListener(this);
return button;
}
- 重写
onClick方法从View.OnClickListener并启动CaptureClaimActivity以获取结果:
@Override public void onClick(final View view) {
startActivityForResult(
new Intent(getContext(), CaptureClaimActivity.class),
REQUEST_CODE_CREATE_CLAIM_ITEM);
}
- 重写
onActivityResult方法以处理传入的ClaimItem,如果它是有效的,则使用AsyncTask的SERIAL_EXECUTOR将其保存到数据库中:
public void onActivityResult(
final int requestCode,
final int resultCode,
final Intent data) {
if (requestCode != REQUEST_CODE_CREATE_CLAIM_ITEM
|| resultCode != Activity.RESULT_OK
|| data == null) {
return;
}
final ClaimItem claimItem = data.getParcelableExtra(
CaptureClaimActivity.EXTRA_CLAIM_ITEM
);
if (claimItem.isValid()) {
final ClaimDatabase database = ClaimApplication.getClaimDatabase();
AsyncTask.SERIAL_EXECUTOR.execute(
database.createClaimItemTask(claimItem)
);
}
}
-
现在,你需要将新片段添加到
OverviewActivity中。打开activity_overview布局文件并切换到文本模式。 -
在
FrameLayout根元素的底部,包含一个引用NewClaimItemFloatingActionButtonFragment的片段标签,并将其定位在屏幕的右下角:
<fragment
class="com.packtpub.claim.ui.NewClaimItemFloatingActionButtonFragment"
android:id="@+id/new_claim_item"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="@dimen/fab_margin" />
现在,你应该能够再次运行应用程序了;不仅你现在的概览屏幕底部应该有一个浮动操作按钮,而且它将正常工作!如果你点击该按钮并在CaptureClaimActivity上捕获一些细节,然后选择导航回OverviewActivity,新的索赔项目将出现在列表中,按日期排序。
与直接使用SQLiteDatabase相比,Room 将只允许在工作线程上运行查询。这使得将更新封装在可以在后台线程上运行的Runnable中(就像你在ClaimDatabase类中的createClaimItemTask所做的那样)变得很有吸引力。在ClaimDatabase上提供这些方法使得它们易于重用,并保持应用程序中逻辑的一致性。它还允许你将它们放入队列中或与其他任务并行运行,如果你选择使用线程池而不是AsyncTask的SERIAL_EXECUTOR(它一次只能运行一个任务)。
使用 Room 数据库的津贴概览
如果你运行概览屏幕并添加一些索赔到其中,你会注意到代码中的一段没有对新添加到数据库中的新项目做出反应:屏幕顶部的津贴概览。这是因为尽管其他所有内容都与 Room 数据库连接,但它仍在监视Allowance数据模型。当数据仅存在于内存中时,使用此类数据模型是一个好主意,但现在你已经有了数据库,事情可以改变并简化。例如,Allowance类实际上只保留用户计划每天花费的金额;索赔项实际上可以被视为数据库模型中的一个完全独立的结构。
因此,你可以将每日津贴移动到不同类型的数据存储中–SharedPreferences。SharedPreferences是 Android 中的键值存储,具有共享的内存表示和原子更新。如果你不期望它们存储太多数据,这使得它们非常适合跟踪那些实际上不会进入 SQLite 数据库的数据。让我们将Allowance概览的模型更改为使用ClaimDatabase和SharedPreferences:
-
首先,打开
AllowanceOverviewPresenter类。 -
将其从使用
Allowance类更改为公开每日津贴作为ObservableInt,并移除OnPropertyChangeCallback,以便现在字段看起来像这样:
public final ObservableField<SpendingStats> spendingStats = new ObservableField<>();
public final ObservableInt allowance = new ObservableInt();
private final UpdateSpendingStatsCommand updateSpendStatsCommand =
new UpdateSpendingStatsCommand();
- 现在,将
UpdateSpendingStatsCommand内部类更改为接受ClaimItem对象List而不是Allowance作为其参数:
private class UpdateSpendingStatsCommand extends ActionCommand<List<ClaimItem>, SpendingStats> {
- 现在将
onBackground实现更改为通过给定的ClaimItem对象List进行单次扫描,并一次性计算所有支出统计:
public SpendingStats onBackground(final List<ClaimItem> items) throws Exception {
final Pair<Date, Date> today = getToday();
final Pair<Date, Date> thisWeek = getThisWeek();
double spentTotal = 0;
double spentToday = 0;
double spentThisWeek = 0;
for (int i = 0; i < items.size(); i++) {
final ClaimItem item = items.get(i);
spentTotal += item.getAmount();
if (item.getTimestamp().compareTo(thisWeek.first) >= 0
&& item.getTimestamp().compareTo(thisWeek.second) <= 0) {
spentThisWeek += item.getAmount();
}
if (item.getTimestamp().compareTo(today.first) >= 0
&& item.getTimestamp().compareTo(today.second) <= 0) {
spentToday += item.getAmount();
}
}
// for stats we round everything to integers
return new SpendingStats(
(int) spentTotal,
(int) spentToday,
(int) spentThisWeek
);
}
- 现在,更改构造函数,使其接受
LifecycleOwner和要显示给用户的起始津贴。然后,使用ClaimDatabase在添加新的ClaimItem对象时更新支出统计:
public AllowanceOverviewPresenter(
final LifecycleOwner lifecycleOwner,
final int allowance) {
ClaimApplication.getClaimDatabase()
.claimItemDao()
.selectAll()
.observe(lifecycleOwner, new Observer<List<ClaimItem>>() {
@Override
public void onChanged(final List<ClaimItem> claimItems) {
updateSpendStatsCommand.exec(claimItems);
}
});
this.allowance.set(allowance);
}
- 你还需要将
updateAllowance方法更改为使用ObservableInt而不是Allowance对象:
public void updateAllowance(final CharSequence newAllowance) {
try {
allowance.set(Integer.parseInt(newAllowance.toString()));
} catch (final RuntimeException ex) {
//ignore
allowance.set(0);
}
}
-
现在,打开
AllowanceOverviewFragment类。 -
在
AllowanceOverviewFragment中添加一个SharedPreferences字段;我们将在本类中多次使用它们:
private FragmentAllowanceOverviewBinding binding;
private SharedPreferences preferences;
- 重写
Fragment的onCreate方法,并检索你将存储每日津贴的私有SharedPreferences实例。第一个参数指定要检索的SharedPreferences的名称,而第二个参数指定范围为private,意味着只有你的应用程序能够看到或使用此SharedPreferences实例:
@Override
public void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
this.preferences = getContext().getSharedPreferences(
"Allowance",
Context.MODE_PRIVATE
);
}
- 创建一个
onCreateView方法来创建AllowanceOverviewPresenter,并将Fragment实例作为LifecycleOwner传递,以及从SharedPreferences检索当前的allowancePerDay。传递给SharedPreferences.getInt方法的第二个参数是默认值,如果没有存储现有值,则返回该值:
@Override
public View onCreateView(
final LayoutInflater inflater,
final ViewGroup container,
final Bundle savedInstanceState) {
this.binding = DataBindingUtil.inflate(
inflater,
R.layout.fragment_allowance_overview,
container,
false
);
this.binding.setPresenter(new AllowanceOverviewPresenter(
this,
preferences.getInt("allowancePerDay", 150)
));
return this.binding.getRoot();
}
- 最后,创建一个
onDestroy方法,将每日津贴存储回SharedPreferences对象。你这样做是通过首先从SharedPreferences请求一个Editor,然后应用更改。Editor中的所有更改都是原子性地同时应用的(原子性地):
@Override
public void onDestroy() {
super.onDestroy();
preferences.edit()
.putInt("allowancePerDay", this.binding.getPresenter().allowance.get())
.apply();
}
现在,如果你构建并运行应用程序,你会注意到津贴概览将正确显示你今天、“本周”以及总支出。使用CaptureClaimActivity中的日期选择器添加几个不同日期的报销项,并查看用户界面如何响应并重新计算你已支出的金额。
测试你的知识
-
RecyclerView的一个实例将为以下哪项创建一个View实例?-
每项数据
-
屏幕上可见的每一项数据
-
每种也可见于屏幕上的数据元素
-
-
当将观察者附加到
LiveData时,你需要执行以下哪项操作?-
当其
LifecycleOwner被销毁时将其分离 -
在主线程上附加它
-
提供一个有效的
LifecycleOwner
-
-
概览/仪表盘屏幕应该具备哪些功能?
-
它们应该只使用图表来显示统计信息
-
如果可以避免,它们不应该滚动
-
它们应该首先显示最重要的信息概览
-
-
ViewHolder类被RecyclerView用来做什么?-
提高数据绑定性能
-
引用将被垃圾回收的视图
-
将
View对象存储在Bundle中
-
-
当使用
LiveData对象引用多个Fragment对象使用的数据时,以下哪个是正确的?-
Fragment实例必须共享相同的LiveData引用以查看更改 -
LiveData只会更新一个Fragment实例 -
Fragment类必须都扩展android.support.v4.app.Fragment
-
摘要
概览屏幕是用户在应用程序中首先看到并与之交互的东西,也将是他们将在应用程序中花费大部分时间的地方。保持屏幕专注于显示给用户的数据,以及如何显示数据,这一点很重要。始终考虑用户需要查看你的屏幕多长时间,以及他们需要轻松访问哪些信息。利用RecyclerView和LiveData类为用户提供以最重要的信息为先的详细视图,并允许他们快速滚动查看他们最重要的最近事件。
同样重要的是要考虑你应用程序的导航,用户将如何从概览屏幕离开的各种方式,以及他们将如何返回。尽可能保持概览类只负责在屏幕上排列数据。任何将用户从屏幕上移开(无论出于何种原因)的逻辑都应该封装在Fragment类中,这些类还包含处理他们最终返回概览屏幕的逻辑。
在本章中,我们探讨了构建概览屏幕的一种非常简单的方法。通过在用户滚动和拖动用户界面各种元素时重新设计屏幕布局,这些类型的屏幕可以通过多种方式变得更加有用和强大。
在下一章中,我们将探讨如何利用 Material Design API 提供的某些布局系统,允许用户界面动态地改变其形状和重点。
第八章:设计材料布局
在设计和创建屏幕布局时,关于如何进行有许多不同的观点。现代布局通常是复杂的系统,它们会根据用户的交互动态地改变形状。在过去,布局往往是相当刚性的结构,只有像窗口或狭缝面板这样的特定区域可以被用户调整。然而,移动应用程序必须更好地利用它们可用的空间,因为它们通常用于物理尺寸较小的设备上。触摸界面的直接交互也改变了用户对应用程序行为的期望;你需要不仅对用户的操作做出反应,还要注意他们的手和手指可能在哪里,因为它们可能会遮挡屏幕的一部分,当他们拖动以滚动应用程序时。
要了解布局如何改变和调整,最简单的方法是使用巨型折叠工具栏。当屏幕打开时,工具栏是全尺寸的,占据足够的空间来容纳各种附加的小部件和信息。当屏幕滚动时,操作按钮消失,工具栏缩小。然后,工具栏将自己固定在屏幕顶部,并仅以标题和可能的一些操作按钮的形式保持可见,如下所示:
这种折叠行为在材料应用程序中很常见——用户界面的各个部分在用户滚动或更改操作时显示或隐藏。这些布局通常同时协调许多不同小部件的移动、调整大小、显示和隐藏,为此有一个特殊的类——CoordinatorLayout。
在本章中,我们将探讨CoordinatorLayout和其他一些专业的 Android 布局类,以便完成以下任务:
-
创建基于用户操作的动态布局
-
在灵活的网格上创建布局
-
允许用户通过手势执行操作
-
使用高度突出显示一些小部件,使其高于其他小部件
查看材料结构
材料布局有一系列模式,应用程序应该遵循每个它们构建的屏幕。这种类型的模板通常被称为框架,对于移动设备来说,它看起来是这样的:
框架的重要性在于,尽管它定义了几乎所有屏幕的基本布局,但它并没有定义你应该如何实现这种设计,甚至在 Android 上,你会发现有几种不同的方式来创建具有前述布局结构的屏幕。一些元素也是可选的:底部栏和浮动操作按钮通常被省略,因为它们对屏幕没有帮助。应用栏几乎出现在所有屏幕上,但它可以更大,也可以折叠起来,为用户提供更多阅读空间的内容区域。
还很重要的一点是,默认情况下,平台主题会将 App Bar(由ActionBar类呈现)放入一个Activity中为你处理;使用Toolbar类和NoActionBar主题在Activity中创建自己的 App Bar 也是常见的。实际上,在第二章,设计表单屏幕中,当你创建CaptureClaimActivity时,Android Studio 模板正是这样做的:
<activity
android:name=".CaptureClaimActivity"
android:label="@string/title_activity_capture_claim"
android:theme="@style/AppTheme.NoActionBar" />
在CaptureClaimActivity类中,在onCreate方法顶部附近,你可以找到以下代码片段:
Toolbar toolbar = findViewById(R.id.toolbar);
setSupportActionBar(toolbar);
这段代码允许你的应用程序完全控制Toolbar的外观和包含的小部件。将其设置为SupportActionBar会告诉AppCompatActivity将任何对Activity.setTitle和类似方法的调用委托给Toolbar,但不会以任何方式改变Toolbar与布局系统的交互方式。现在,这仍然完全在你的控制之下。
介绍CoordinatorLayout
Android 有一系列布局,旨在协同工作以在用户滚动时实现动态移动效果。这个系列的核心是CoordinatorLayout类,它允许将复杂的行为附加到任意数量的浮动兄弟小部件上,这些小部件可以相互依赖并对其位置和大小做出反应。为了说明CoordinatorLayout的实际工作原理,请看以下这张图:
尽管看起来FloatingActionButton似乎浮在其它小部件之上,但实际上它是CoordinatorLayout的直接子元素。它之所以保持在原位,是因为它被锚定在工具栏的底部。如果工具栏改变其大小或位置,CoordinatorLayout将会移动FloatingActionButton,使其看起来像是附着在工具栏的底部。这些移动都是作为布局过程的一部分一起完成的,从而确保每一帧都是像素完美的,并且所有元素看起来像是一起移动和调整大小。
CoordinatorLayout定义了两种主要的操纵子小部件的方式——锚点和行为:
-
锚点是这两种方式中较为简单的一种;它只是将一个小部件附着到另一个小部件上。锚点响应
layout_gravity属性和特殊的layout_anchorGravity属性,以确定锚定小部件相对于它所附着的小部件应该出现在哪个位置。 -
行为更复杂;它们是完整的类,可以根据其他小部件(称为其依赖项)以任何方式操作小部件。几个类定义了自己的行为类,当它们在
CoordinatorLayout内声明时应该使用。例如,FloatingActionButton声明了一个FloatingActionButton.Behavior类,当其锚点接近屏幕末端时,将隐藏按钮,并在有足够空间时再次出现。这种显示和隐藏行为甚至伴随着动画。
协调概览屏幕
你在第七章,创建概览屏幕中构建的概览屏幕是CoordinatorLayout的完美候选者。首先,允许概览栏可以折叠,并在用户滚动时展开。这为屏幕上的索赔项提供了更多空间,当他们向上滚动时再次展开概览,用户不需要滚动到顶部以获取概览。
这种行为不仅会使用CoordinatorLayout,还需要AppBarLayout和CollapsingToolbarLayout类的帮助,因为你需要控制 Material Design 脚手架以使其工作。按照以下步骤将允许概览移动到标题栏并使其折叠:
-
首先,从项目树中的
manifests文件夹打开AndroidManifest文件(使用 Android 视角)。 -
找到
OverviewActivity条目并添加一个主题属性,告诉系统不要提供系统ActionBar,因为你会添加自己的:
<activity
android:name=".OverviewActivity"
android:theme="@style/AppTheme.NoActionBar"
android:windowSoftInputMode="stateHidden">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
-
现在,打开
activity_overview布局文件,切换到文本模式。删除FrameLayout及其所有内容;你需要完全重写这个文件。 -
使用所有标准命名空间和上下文创建
CoordinatorLayout根元素。请注意,这次你需要告诉系统这个小部件将适应根窗口,而不是作为内容:
<android.support.design.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
android:id="@+id/scaffolding"
tools:context="com.packtpub.claim.OverviewActivity">
</android.support.design.widget.CoordinatorLayout>
- 现在,在
CoordinatorLayout中创建AppBarLayout元素;再次提醒系统,AppBarLayout应适应系统窗口,不应被视为普通内容小部件:
<android.support.design.widget.AppBarLayout
android:id="@+id/app_bar"
android:layout_width="match_parent"
android:layout_height="@dimen/app_bar_height"
android:fitsSystemWindows="true"
android:theme="@style/AppTheme.AppBarOverlay">
</android.support.design.widget.AppBarLayout>
- 使用
layout_height的代码辅助功能创建一个名为app_bar_height的新维度资源,并分配一个值为180dp:
<dimen name="app_bar_height">180dp</dimen>
- 在
AppBarLayout内部,你需要声明CollapsingToolbarLayout。这将处理工具栏和其他小部件的折叠和展开,当用户滚动时。你使用layout_scrollFlags来告诉它如何折叠和展开,但重要的是要注意,实际上是AppBarLayout负责这些操作,所以AppBarLayout的任何子项都可以使用这些标志。在这种情况下,我们将告诉它,当用户滚动查看项目列表时进行折叠,但不要完全退出(消失),当用户开始向上滚动列表时立即重新进入:
<android.support.design.widget.CollapsingToolbarLayout
android:id="@+id/toolbar_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
app:contentScrim="?attr/colorPrimary"
app:expandedTitleGravity="top"
app:layout_scrollFlags="scroll|enterAlwaysCollapsed|snap|exitUntilCollapsed"
app:toolbarId="@+id/toolbar">
</android.support.design.widget.CollapsingToolbarLayout>
在前面的代码中,CollapsingToolbarLayout将其contentScrim声明为?attr/colorPrimary。属性?语法是与主题一起使用的一种查找类型。它告诉资源系统在主题中查找该属性,而不是直接引用属性。
- 在
CollapsingToolbarLayout内部,你需要声明一个Toolbar小部件。这个小部件将取代系统ActionBar的位置。我们使用layout_collapseMode来告诉CollapsingToolbarLayout,一旦折叠,就将Toolbar固定在屏幕顶部(而不是让它完全消失):
<android.support.v7.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:layout_collapseMode="pin"
app:popupTheme="@style/AppTheme.PopupOverlay" />
- 在
Toolbar小部件之后,你可以声明AllowanceOverviewFragment;它将使用parallax折叠模式,并在用户滚动查看索赔项目列表时消失:
<fragment
android:id="@+id/overview"
class="com.packtpub.claim.ui.AllowanceOverviewFragment"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:layout_marginBottom="@dimen/grid_spacer1"
app:layout_collapseMode="parallax"
app:layout_collapseParallaxMultiplier="0.65" />
- 这就完成了你的新
AppBarLayout结构;现在你需要在AppBarLayout之后添加RecyclerView,并告诉CoordinatorLayout使用layout_behaviour属性,它正在滚动内容。这将告诉CoordinatorLayout,当RecyclerView滚动时,AppBarLayout应该对滚动做出反应:
<android.support.v7.widget.RecyclerView
android:id="@+id/claim_items"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="@dimen/grid_spacer1"
android:clipToPadding="false"
android:clipChildren="false"
android:paddingLeft="@dimen/grid_spacer1"
android:paddingRight="@dimen/grid_spacer1"
app:layoutManager="android.support.v7.widget.LinearLayoutManager"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
声明的RecyclerView其行为引用了一个名为appbar_scrolling_view_behavior的字符串资源,但你没有在strings.xml文件中声明这样的资源,所以为什么代码助手没有抱怨?这是一个由CoordinatorLayout支持库声明的字符串资源,它在构建过程中合并到你的应用程序资源中。其内容是滚动视图Behaviour实现的完整类名(即:)。
- 在你的
CoordinatorLayout中的最后一个元素应该是NewClaimItemFloatingActionButtonFragment,由于FloatingActionButton类的编写方式,它将在CoordinatorLayout中自动获得特殊行为:
<fragment
android:id="@+id/new_claim_item"
class="com.packtpub.claim.ui.NewClaimItemFloatingActionButtonFragment"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="@dimen/fab_margin" />
FloatingActionButton类声明了一个默认的Behaviour类,当任何子项被添加到CoordinatorLayout中时,CoordinatorLayout会查找这个类。这定义了FloatingActionButton在屏幕上的位置,以及它应该在何时消失、重新出现,甚至相对于可能出现在屏幕底部的面板(如 snackbars)移动。声明是通过一个公开可访问的注解来完成的:
@CoordinatorLayout.DefaultBehavior(FloatingActionButton.Behavior.class)
public class FloatingActionButton extends VisibilityAwareImageButton {
由于您的应用程序的结构,OverviewActivity 类不需要修改即可使新布局工作。它仍然会自动用 ClaimItem 对象填充 RecyclerView,并且片段将通过数据库进行通信。然而,使新的 Toolbar 小部件充当 OverviewActivity 的 ActionBar 是有用的;您可以通过将 onCreate 方法更改为调用 setSupportActionBar 来实现这一点:
protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_overview);
setSupportActionBar(findViewById(R.id.toolbar));
// …
}
向右滑动删除
虽然您的用户有创建索赔项的方法,但他们没有删除自己创建的索赔项的方法。在移动应用列表中,一个常见的模式是允许用户向右滑动来删除或删除项目。RecyclerView 提供了一些优秀且易于使用的结构来启用这种行为;然而,始终确保用户不会意外删除项目是非常重要的。
在过去,大多数用户界面在执行破坏性操作时都会使用确认对话框。然而,这些“你确定吗”对话框对大多数用户来说都是一种糟糕的干扰,因为这些消息违反了一个关键原则——应用程序假设用户可能不想执行他们刚刚采取的操作。实际上,用户可能确实打算删除该项目,但应用程序会打断他们,询问他们是否确定自己的选择。更好的行为是假设用户确实想要采取行动,但如果他们犯了错误,则提供一种撤销操作的方法。Material Design 有一个专门针对此类任务的设计模式和控件——Snackbar。
在 Material Design 术语中,Snackbar 是一个可以出现在屏幕底部的小栏,向用户提供信息以及基于所提供信息的可能操作。最常见的用法是在删除某物时,用户有机会撤销删除。撤销操作可能看起来很复杂,但如果正确地封装在 Command 类中,实际上执行起来非常简单。按照以下步骤向旅行索赔应用程序添加向右滑动删除操作和撤销选项:
-
打开 ui 包中的
DataBoundViewHolder类。 -
您的新类将需要一个简单的方法来访问
DataBoundViewHolder中的项目,但ViewDataBinding并没有提供getVariable方法,因此您需要将其保存在类字段中并提供一个获取方法:
private I item;
public I getItem() { return item; }
- 您还需要修改
setItem方法以捕获此字段:
public void setItem(final I item) {
this.item = item;
binding.setVariable(BR.item, item);
}
-
在 Android Studio 中打开
OverviewActivity的源文件。 -
在
OverviewActivity类的底部,您需要声明一个新的ActionCommand类,该类将封装删除操作和撤销操作。与大多数其他ActionCommand类不同,这个类是不可重用的,并且不接受任何参数:
class DeleteClaimItemCommand
extends ActionCommand<Void, Void>
implements View.OnClickListener {
}
- 新的
DeleteClaimItemCommand类需要一个对ClaimDatabase的引用,并且还将有一个ClaimItem字段,它将删除并可选地恢复:
private final ClaimDatabase database = ClaimApplication.getClaimDatabase();
private final ClaimItem item;
public DeleteClaimItemCommand(final ClaimItem item) {
this.item = item;
}
onBackground的实现将从数据库中删除ClaimItem对象,但DeleteClaimItemCommand会保留对内存中实现的引用,如果用户决定恢复它:
public Void onBackground(final Void noArgs) {
database.claimItemDao().delete(item);
return null;
}
此代码不会删除与 ClaimItem 相关的 Attachments,这会导致应用程序泄漏附件文件和数据库行。在实际应用中,你还希望确保附件也被清理,就像对 ClaimItem 使用的行为一样,但这超出了本例的范围。
onForeground的实现需要显示一个Snackbar通知,告诉用户项目已被删除;为此,你需要一个可本地化的消息。Context类提供了一个方便的getString方法,它将从应用程序资源生成格式化的字符串:
final String message = getString(
R.string.msg_claim_item_deleted,
item.getDescription());
- 使用代码辅助功能创建一个名为
msg_claim_item_deleted的新字符串资源:
<string name="msg_claim_item_deleted">%s Deleted</string>
这些字符串遵循 java.util.Formatter 或 String.format 中定义的格式化规则,允许你创建相对复杂的格式化规则。通过为不同语言和格式提供不同的 strings.xml 文件,你可以非常容易地本地化应用程序中的大多数字符串。
- 在
onForeground方法中,你需要获取CoordinatorLayout的引用作为Snackbar的基础:
final View scaffolding = findViewById(R.id.scaffolding);
- 然后,创建
Snackbar对象,指定其撤销动作文本,并使用DeleteClaimItemCommand作为动作处理程序(OnClickListener):
Snackbar.make(scaffolding, message, Snackbar.LENGTH_LONG)
.setAction(R.string.undo, this)
.show();
- 使用代码辅助功能在
R.string.undo引用上创建一个新的字符串资源,用于undo动作的文本:
<string name="undo">Undo</string>
- 如果用户点击撤销动作,将调用
DeleteClaimItemCommands和onClick方法。然后,它可以使用其缓存的已删除ClaimItem引用将其恢复到数据库中:
public void onClick(final View view) {
AsyncTask.SERIAL_EXECUTOR.execute(database.createClaimItemTask(item));
}
- 作为
OverviewActivity的另一个内部类,你需要一个类来提供对 滑动删除 行为的动作定义和处理。这个新类将扩展ItemTouchHelper类中的SimpleCallback类,该类提供了对移动手势识别的处理:
private class SwipeToDeleteCallback extends ItemTouchHelper.SimpleCallback {
}
SimpleCallback构造函数接受两套以int值形式表示的“标志”。这些标志实际上是一系列可以二进制“或”操作(使用|操作符)的数字。这些定义了允许和管理不同手势。其中第一个是用于不同类型的“移动”手势的标志,这些手势可以用来重新排列RecyclerView中的项目(将此设置为零表示不应识别任何移动手势)。第二个标志的参数是用于“滑动”手势的,这是我们在这里感兴趣的内容:
SwipeToDeleteCallback() {
super(0, ItemTouchHelper.RIGHT);
}
SimpleCallback类要求你声明用于移动和滑动的处理方法,即使该类不会处理移动手势。你需要声明onMove,但该类可以简单地返回false作为其实现:
public boolean onMove(
final RecyclerView recyclerView,
final RecyclerView.ViewHolder viewHolder,
final RecyclerView.ViewHolder target) {
return false;
}
- 接下来,你可以定义
onSwipe方法的实现,这将创建一个DeleteClaimItemCommand并执行它:
public void onSwiped(
final RecyclerView.ViewHolder viewHolder,
final int direction) {
final DataBoundViewHolder<?, ClaimItem> holder
= (DataBoundViewHolder<?, ClaimItem>) viewHolder;
new DeleteClaimItemCommand(holder.getItem()).exec(null);
}
- 现在,要将
SwipeToDeleteCallback附加到RecyclerView上,你需要使用ItemTouchHelper类将其包装,并在onCreate方法的底部将其附加到你的RecyclerView实例上:
final RecyclerView claimItems = findViewById(R.id.claim_items);
claimItems.setAdapter(new ClaimItemAdapter(
this, this,
ClaimApplication.getClaimDatabase().claimItemDao().selectAll()
));
new ItemTouchHelper(new SwipeToDeleteCallback())
.attachToRecyclerView(claimItems);
}
提升小部件
在屏幕上突出显示一个小部件而不是其他小部件的一个很好的方法是让它出现在其他小部件之上,不是二维的,而是像在三维空间中一样浮在它们之上。如果你查看FloatingActionButton类,这已经是一个清晰的模式;它们不仅仅重叠其他小部件,而且它们有阴影,看起来像在空间中浮动(因此类名为FloatingActionButton)。
Android 小部件库中的伟大功能之一是View类定义了海拔的概念,这使得它可以通过工具包中的每个小部件使用。小部件的海拔不会影响其二维位置或大小,但它会导致它产生一个阴影,该阴影将正确着色,就像小部件在三维空间中浮动一样。这可以在你需要吸引对消息的注意,或者当用户在屏幕上重新定位小部件时(例如,重新组织提醒列表)时创建惊人的效果。鉴于大多数 Material Design 用户界面都是平面的,添加三维海拔可以立即让小部件在用户面前脱颖而出。
与CardView小部件的边框和阴影类似,当你使用海拔时,你需要确保阴影不会被父小部件或填充属性裁剪。使用clipChildren和clipToPadding属性来控制这一点。
按照以下步骤向滑动删除行为回调添加海拔效果:
-
打开
OverviewActivity并找到SwipeToDeleteCallback内部类。 -
如果用户在拾起项目后“放下”项目以删除它,则该类需要能够重置海拔。为此,
SwipeToDeleteCallback类需要一个具有默认卡片海拔的字段:
final float defaultElevation =
getResources().getDimensionPixelSize(R.dimen.cardview_default_elevation);
- 每次在
RecyclerView的子项被拾起后绘制,ItemTouchHelper允许你覆盖绘制行为。在你的情况下,你想要根据用户拖动的距离调整卡片相对于右侧的海拔。为了在旧版本的 Android 上工作,此代码使用ViewCompat类来更改海拔:
public void onChildDraw(
final Canvas c,
final RecyclerView recyclerView,
final RecyclerView.ViewHolder viewHolder,
final float dX, final float dY,
final int actionState,
final boolean isCurrentlyActive) {
if (isCurrentlyActive) {
ViewCompat.setElevation(
viewHolder.itemView,
Math.min(
Math.max(dX / 4f, defaultElevation),
defaultElevation * 16f
)
);
}
super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive);
}
- 一旦用户释放卡片,我们需要通过将其重置为默认值来清除海拔值;当用户放下一个项目时,
ItemTouchHelper将调用clearView回调:
public void clearView(
final RecyclerView recyclerView,
final RecyclerView.ViewHolder viewHolder) {
ViewCompat.setElevation(viewHolder.itemView, defaultElevation);
super.clearView(recyclerView, viewHolder);
}
一旦实现了这种行为,用户在滑动删除手势时将收到二级视觉反馈,因为当他们将卡片向右拖动时,卡片看起来会升到其他卡片之上。如果他们再次将卡片向左拖动,它也会反向动作,看起来会下降回到正常的高度。这种高度反馈在用户可以改变列表中卡片位置的用户界面(例如,待办事项列表)上会更加有用。注意,随着卡片高度的增加,它会在其下方和上方的卡片上投下阴影:
使用网格构建布局
当构建屏幕时,通常希望特定的部件与其它部件具有相同的大小和形状。这通常是通过使用灵活的网格模型来实现布局的。通过将屏幕划分为若干个单元格,并让每个部件占据一个或多个单元格,你可以创建非常复杂的布局,这些布局可以扩展到任何屏幕大小。然而,当面对ConstraintLayout时,这种传统模型就显得完全过时了,因为ConstraintLayout能够在不使用网格的情况下维护部件之间的复杂关系。
在大多数情况下,ConstraintLayout应该能够管理你选择的任何复杂布局,并且会比网格/表格布局管理器更加灵活。与基于网格的布局引擎不同,ConstraintLayout在处理基于字体大小或图像大小的部件时更加灵活,这些图像的大小取决于物理屏幕大小和像素密度。虽然GridLayout会调整单元格的大小以适应这些部件,但它们仍然受限于网格线。
然而,时不时地,你可能需要基于网格单元格构建布局。对于这种情况,你将希望使用GridLayout类。GridLayout允许你基于一个不可见的网格定义布局,其中每个部件可以占据一个或多个单元格,每一行和每一列的大小都是灵活的;也就是说,每一列可以有不同的宽度,每一行可以有不同的高度。重要的是要记住,GridLayout并不适用于显示大量数据的大表格,而是用于布局那些偏好网格结构的屏幕。如果你需要向用户展示一个可滚动的网格(例如,图标图像的网格),那么更好的模型是使用带有GridLayoutManager的RecyclerView,因为它可以扩展到几乎任何数量的子组件。
在 Android 中,GridLayout有两种不同的实现:一种是在平台核心 API 中,另一种是在支持 v7 API 中。出于兼容性的原因,通常最好使用支持包中的类,因为它包括了最近添加的许多可能不在平台实现中出现的特性。
为了探索GridLayout,让我们看看您将如何使用GridLayout而不是ConstraintLayout来实现捕获索赔详情卡片:
-
首先,您需要将
GridLayout实现添加到您的项目中。在项目树中打开 Gradle 脚本,并打开 app 模块的 build.gradle 文件(使用 Android 视角)。 -
在依赖项列表中,添加对 grid-layout 模块的依赖项:
implementation 'com.android.support:appcompat-v7:26.0.0'
implementation 'com.android.support:gridlayout-v7:26.0.0'
末尾的版本号(在本例中为26.0.0)必须与您的应用程序引用的appcompat模块的版本号完全匹配。如果这些版本不匹配,可能会导致不稳定,在某些情况下,甚至无法编译应用程序。在继续下一步之前,将版本号更改为与您的build.gradle中声明的appcompat引用上的版本号相匹配。
-
保存文件,并使用编辑器顶部的 Sync Now 链接同步项目。
-
在项目文件树中,右键单击 res | layout 目录,然后选择 New | Layout resource file。
-
将新文件命名为
fragment_capture_claim_grid。 -
将根元素更改为
android.support.v7.widget.GridLayout:
-
切换到文本模式编辑器。
-
由于您正在使用
GridLayout的支持库实现,XML 属性中的许多将位于app命名空间而不是平台(android)命名空间中。您需要将app命名空间添加到GridLayout声明中:
<android.support.v7.widget.GridLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
</android.support.v7.widget.GridLayout>
9. GridLayout默认设置了许多布局属性,并默认假设每个子元素都在单元格中,跟随其前面的单元格(从左上角的单元格开始)。它允许您指定columnWeight和rowWeight属性来定义每个单元格应占用多少可用空间。声明一个TextInputLayout来占用 70%的可用空间:
<android.support.design.widget.TextInputLayout app:layout_columnWeight="0.7">
<android.support.design.widget.TextInputEditText
android:id="@+id/description"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/label_description" />
</android.support.design.widget.TextInputLayout>
前面的TextInputLayout小部件仅占用GridLayout中的一个单元格,但该单元格已被告知在渲染时占用 70%的可用水平空间。
- 接下来,声明
TextInputLayout的数量;这将仅占用单个单元格,但我们希望它占用剩余的 30%水平空间:
<android.support.design.widget.TextInputLayout app:layout_columnWeight="0.3">
<android.support.design.widget.TextInputEditText
android:id="@+id/amount"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/label_amount" />
</android.support.design.widget.TextInputLayout>
- 现在,我们想要声明一个
DatePickerLayout供用户选择日期,但我们需要告诉GridLayout将其放在下一行。您可以使用row和column属性来完成此操作。此小部件还需要占用GridLayout的整个宽度,这意味着它需要占用两个列,这是通过使用columnSpan属性来完成的:
<com.packtput.claim.widget.DatePickerLayout
android:id="@+id/date"
app:layout_row="1"
app:layout_column="0"
app:layout_columnSpan="2"
app:layout_gravity="fill_horizontal" />
如果你查看设计视图,你会注意到这个布局几乎与你写在第二章,“设计表单屏幕”,中捕获索赔的那个布局一模一样。最大的区别是,ConstraintLayout为金额使用了一个固定的最小尺寸,而此布局通过操作网格单元的权重使用相对尺寸。生成的布局应该看起来像这样:
栈视图
有时候,能够只显示一次一个项目的同时显示长列表的项目是有用的,例如,ClaimItem的附件列表。在这种情况下,你可以使用你之前已经使用过的侧向ViewPager,但还有一个选项——StackView。StackView类将其内容呈现为三维卡片堆叠,其中“顶部”卡片完全可见,而一些卡片“在其后面”,如下所示:
这通常是一个非常有用的模式,因为它为用户提供足够的屏幕空间来查看顶部项目,同时也能看到还有其他可以查看的项目。这使得它非常适合显示照片或大型数据卡片。这与你在设备上点击“最近应用”按钮时 Android 显示正在运行的应用程序列表的方式非常相似。
StackView是一个经典的Adapter视图,它使用与ListView或GridView相同的Adapter实现。如果做得正确,你可以编写可以在这些类中使用的代码;按照以下步骤构建一个简单的StackView和Adapter实现,以便以不同的方式预览附件:
-
在项目树中右键单击
ui.attachments包,然后选择“新建| Java 类”。 -
将新类命名为
AttachmentListAdapter。 -
将超类改为
android.widget.BaseAdapter。 -
点击“确定”以创建新类。
-
在新的
AttachmentListAdapter类中,声明一个用于向用户呈现的Attachment对象List:
private List<Attachment> attachments = Collections.emptyList();
- 创建一个构造函数来观察
LiveData并分配附件的List,并在事情发生变化时通知StackView:
public AttachmentListAdapter(
final LifecycleOwner lifecycleOwner,
final LiveData<List<Attachment>> attachments) {
attachments.observe(lifecycleOwner, new Observer<List<Attachment>>() {
@Override
public void onChanged(final List<Attachment> attachments) {
AttachmentListAdapter.this.attachments =
attachments != null
? attachments
: Collections.<Attachment>emptyList();
notifyDataSetChanged();
}
});
}
- 与
RecyclerView.Adapter实现类似,BaseAdapter需要一个方法来访问它预期呈现的项目数量:
public int getCount() { return attachments.size(); }
- 然而,与
RecyclerView.Adapter实现不同,BaseAdapter预期直接暴露底层数据。它还必须暴露每个数据元素的唯一 ID:
public Object getItem(final int i) { return attachments.get(i); }
public long getItemId(final int i) { return attachments.get(i).id; }
- 此外,与
RecyclerView.Adapter不同,创建和重用现有视图项以及将数据绑定到它们的方法只有一个。在此方法中,第二个参数可以是null,也可以是一个期望被回收的现有视图:
public View getView(
final int i,
final View view,
final ViewGroup viewGroup) {
AttachmentPreview preview = (AttachmentPreview) view;
if (preview == null) {
preview = new AttachmentPreview(viewGroup.getContext());
}
preview.setAttachment(attachments.get(i));
return preview;
}
- 要从布局 XML 文件中使用
StackView,你只需像声明RecyclerView或ViewPager一样声明StackView:
<StackView
android:id="@+id/attachments"
android:layout_width="match_parent"
android:layout_height="match_parent" />
- 然后,从封装的
Activity或Fragment中,您需要设置其Adapter。与RecyclerView类似,Adapter也可以从数据绑定布局中指定。以下是在Activity中的代码示例:
final StackView attachments = findViewById(R.id.attachments);
attachments.setAdapter(new AttachmentListAdapter(
this,
database.attachmentDao().selectForClaimItemId(claimItem.id)
));
StackView 类是向用户展示大量更大、更视觉化的项目的一种极好方式。它非常适合浏览照片或预览图形,并提供易于使用的三维变换。在使用 StackView 之前,您应该始终考虑用户是否需要同时查看多个项目中的数据。有时,最好将 RecyclerView 作为“概览”与 StackView 结合使用,以查看单个项目。
测试你的知识
-
应该使用提升(Elevation)来做什么?
-
当用户在列表中选择一个项目时
-
为了选择性地突出显示平铺布局上方的单个项目
-
当用户滑动删除项目时
-
-
CoordinatorLayout可以用来协调以下哪些之间的移动和大小?-
嵌套在
AppBarLayout中的组件 -
任何其直接子小部件
-
在不同活动中的
Fragment
-
-
要以向后兼容的方式更改小部件的提升,您需要执行以下提到的哪些操作?
-
将小部件嵌套在
CardView中 -
使用
ViewCompat类 -
使用 Java 反射来调用
setElevation
-
-
在以下哪种情况下应使用
GridLayout类?-
当
ConstraintLayout不可用时 -
显示大量数据表格
-
沿着网格线排列屏幕
-
摘要
掌握通常属于系统装饰的应用程序提供了巨大的额外灵活性和功能。通过使用 CoordinatorLayout 来托管屏幕的框架和内容,您通过允许小部件在动画过程中动态交互进一步扩展了您的灵活性。这为您提供了以最少的额外工作制作像素完美屏幕的方法。
使用不仅可以动态改变形状,还可以使用如滑动删除等手势来改变内容的布局,以进一步增强触摸屏用户界面的直接操作方面。同时,始终考虑用户的交互和何时中断它们,尤其是在破坏性行动周围非常重要。虽然有时您可能仍然想使用确认对话框,但通常更好的方法是给用户提供一种撤销操作的方法。通常,将已删除的实体对象保留在内存中,直到 Snackbar 消失并从内存中释放,这是一个非常简单的事情。实际上,从 Room 中插入您已删除的实体将保持它们的 ID,这意味着它们的行将恢复到删除之前的状态。
在下一章中,我们将探讨 Android 应用程序中的导航,并了解为用户导航应用程序提供的各种用户界面功能。我们还将研究一些允许您更好地控制应用程序导航流程的技术。提供一致且高质量的导航对用户体验有着巨大的影响。**
981

被折叠的 条评论
为什么被折叠?



