安卓专家级编程-全-

安卓专家级编程(全)

原文:Expert Android

协议:CC BY-NC-SA 4.0

一、探索自定义视图

Abstract

直到你掌握了 Android 的视图架构,你对 Android SDK 的理解才算轰轰烈烈。因此,我们通过探索 Android 自定义视图的能力来开始专家 Android 是合适的。在这一章和接下来的两章中,我们的目标是通过定制来解开 Android 视图的架构。在 Android 中,你可以通过三种方式定制视图:

直到你掌握了 Android 的视图架构,你对 Android SDK 的理解才算轰轰烈烈。因此,我们通过探索 Android 自定义视图的能力来开始专家 Android 是合适的。在这一章和接下来的两章中,我们的目标是通过定制来解开 Android 视图的架构。在 Android 中,你可以通过三种方式定制视图:

  • 自定义视图(通过扩展View类)
  • 复合视图/控件(通过扩展一个现有的Layout类来组合其他现有的控件)(注意,在本章和接下来的几章中,我们将自定义视图和自定义组件作为同义词使用)
  • 自定义布局(通过扩展ViewGroup类)

在研究这些话题的过程中,我们学到了很多。我们渴望与您分享在本章和接下来的两章中介绍的关于定制组件的信息。我们相信定制组件是释放 Android SDK 全部潜力的关键。

我们从介绍自定义视图开始这一章。这一章也是后面两章的基础:复合视图/控件和自定义布局。

为了演示自定义视图,在本章中,我们:

  • 创建一个名为CircleView的定制视图,并解释定制一个View的理论和机制。
  • 呈现CircleView的全部源代码,以便指导您编写自己的自定义视图。
  • 展示如何在任何 Android 布局中嵌入CircleView
  • 通过改变圆圈的大小展示CircleView如何响应触摸事件。(请注意,在本书的大部分内容中,我们将“点击”和“触摸”作为同义词使用!)
  • 展示CircleView在旋转设备时如何记住状态(如圆圈的大小)。
  • 展示如何在布局文件中使用自定义属性来初始化CircleView

规划自定义视图

在我们解释像CircleView这样的定制视图的实现之前,让我们向您展示一下CircleView的预期外观、感觉和行为。我们相信,这将使你更容易理解随后的解释和代码。

让我们从检查图 1-1 中的CircleView开始。在图 1-1 中,CircleView在线性布局的两个文本视图之间。视图的宽度设置为match_parentCircleView的高度设置为wrap_content

A978-1-4302-4951-1_1_Fig1_HTML.jpg

图 1-1。

Custom CircleView with wrap_content

当我们设计这个CircleView时,我们使用自定义属性使圆笔画颜色和宽度在布局文件中可配置。为了测试对事件的响应,我们使用 click 事件来扩展圆圈并重新绘制。图 1-2 显示了点击几次后CircleView的样子。每点击一次,圆圈就会扩大 20%。

A978-1-4302-4951-1_1_Fig2_HTML.jpg

图 1-2。

Custom CircleView expanded with clicks

然后,我们对CircleView实现状态管理,这样当我们将设备翻转到横向时,视图保持其放大倍数。图 1-3 为旋转后的装置CircleView保持膨胀。

A978-1-4302-4951-1_1_Fig3_HTML.jpg

图 1-3。

Custom CircleView retaining state after rotation

让我们开始并涵盖关于自定义视图的所有基本内容(有很多),这样您就可以设计并编码图 1-1 、 1-2 和 1-3 中所示的CircleView

Android 中绘画的本质

要了解如何在 Android 中绘图,您必须了解以下类的架构:

View

ViewParent (interface)

ViewGroup (extends View and implements ViewParent)

ViewRoot (implements ViewParent)

是 Android 中所有可见组件的基础类。它定义了许多回调来定制它的行为,比如定义大小、绘制和保存状态的能力。

一个ViewParent为任何想要扮演其他视图的父角色的对象(包括另一个视图)定义协议。父母有两个重要的观点。其中,ViewGroup是关键的一个。除了作为一个ViewParent,一个ViewGroup也定义了子视图集合的协议。像 Android SDK 中的FrameLayoutLinearLayout这样的布局都扩展了这个类ViewGroupViewGroup在定义 XML 文件的布局和将控件(视图)放在正确的位置上起着核心作用。一个ViewGroup也控制它的子视图的背景和动画。

另一个键ViewParent,ViewRoot是以实现为中心的,不是公共 API。在一些版本中,它被称为ViewRoot,在一些实现中,它被称为ViewRootImplementation—,甚至可能在未来被改为其他名称。然而,这个类对于理解在 Android 中如何画图是很重要的。

我们建议您记下这三个类(View、ViewGroup、ViewParent)的源代码,以备不时之需。例如,如果你想查找View.java的源代码,用谷歌搜索这个名字,你会在网上看到很多有这个源代码的地方。源代码可能与最新版本不匹配,但是对于理解这个类的作用来说,这已经足够了。我倾向于下载最新的android.jar源代码并保存在 eclipse 中,然后使用 CTRL-SHIFT-R (R 代表“资源”)在源代码中快速定位一个文件。

作为活动中所有视图的根父级,ViewRoot调度所有视图的遍历,以便首先以正确的大小将它们布置在正确的位置;这被称为布局阶段。然后ViewRoot遍历视图层次来绘制它们;这个阶段被称为绘图阶段。我们现在将讨论其中的每一个阶段。

布局阶段:测量和布局

布局阶段的目标是了解父视图(如ViewRoot)所拥有的视图层次结构中每个视图的位置和大小。为了计算每个视图的位置和大小,ViewRoot启动一个布局阶段。然而,在布局阶段,视图根只遍历那些报告或请求布局变化的视图。这种有条件的测量是为了节省资源和提高响应时间。

启动布局阶段的触发可能来自多个事件。一次触发可能是所有东西第一次被抽取。或者其中一个视图在对点击或触摸等事件做出反应时,可以报告其大小已经改变。在这种情况下,被点击的视图调用方法requestLayout()。这个调用沿着链向上,到达根视图(ViewRoot)。然后根视图在主线程的队列上调度一个布局遍历消息。

布局阶段有两个阶段:测量阶段和布局阶段。测量过程由View类的measure()函数实现。这个函数的特征是

public final void measure(int widthMeasureSpec, int heightMeasureSpec)

记下此方法的签名。这个签名将帮助您在大型View.java源文件的源代码中轻松定位这个方法measure()。这个方法,measure(),做一些内务处理,并调用派生视图的onMeasure()。派生的视图需要通过调用setMeasuredDimension()来设置它们的尺寸。在每个视图上设置的这些测量尺寸随后将用于布局通道。在这种情况下,您的主要优先选项是View.onMeasure()。请记住,onMeasure()有一个默认的实现。onMeasure()的默认实现根据布局文件的建议决定视图的大小,包括传入的精确大小。我们将在本章的后面讨论这一点。

虽然当你创建一个像CircleView一样的自定义视图时,你关心的是onMeasure()但是有时候measure()也很重要。如果继承的定制视图是其他视图的集合,比如在ViewGroup中,那么您需要在您的onMeasure()方法中调用子视图上的measure()。前面的measure()的签名清楚地支持了这样一个观点,即你不能通过成为 final 来覆盖它,但是你应该通过成为 public 来调用它。我们将在本章后面使用onMeasure()时讨论measure()方法参数widthMeasureSpecheightMeasureSpec

度量通过后,每个视图都知道它的维度。然后,控制传递到布局阶段。这个布局传递在layout()方法中实现,其在基类View中的签名是:

public void layout(int left, int top, int right, int bottom)

同样,我们给出了layout()方法的完整签名,因为这个签名将帮助您在基本View类源文件中定位这个方法。与measure()方法非常相似,layout()方法为基础View执行一个内部协议,并导致调用清单 1-1 中被覆盖的方法。

清单 1-1。视图的 Layout()方法调用的重写方法

protected void onSizeChanged(int w, int h, int oldw, int oldh);

protected void onLayout(boolean changed, int left, int top, int right, int bottom)

layout()中实现的布局通道将考虑测量通道测量的尺寸,并给出每个视图的起始位置和每个视图需要使用的尺寸。基本的layout()方法实际上是在调用它的视图上设置这些维度。如果大小或位置确实发生了变化,它就会调用onSizeChanged()。onSizeChanged()的默认实现存在于View类中,但它是无操作的

在调用了onSizeChanged()方法之后,layout()方法调用onLayout()来允许类似视图组的东西调用其子对象上的layout()方法。对于onLayout()的默认实现是存在的,但它是不可操作的。要将它应用到我们的CircleView,我们不需要在onLayout()中做任何事情,因为我们的位置和维度已经固定,并且我们没有子元素通过调用它们的layout()方法来建议它们的布局。

一旦完成了布局阶段的两个阶段,由视图根发起的遍历将移动到绘图阶段。

绘图阶段:奥德劳力学

绘制遍历是在View's draw()方法中实现的。通过这种方法实现的协议是:

Draw the background

Draw view's content by delegating to onDraw()

Draw children by delegating to dispatchDraw()

Draw decorations such as scroll bars

因为绘制遍历发生在布局遍历之后,所以您已经知道了视图的位置和大小。如果你的视图像CircleView一样没有子视图,你就不太关心dispatchDraw()。基类View中该方法的默认实现存在,但为空。

您可能会问:如果我的自定义视图有子视图,为什么我不选择在onDraw中绘制它们?或许是因为,在一个框架中,draw()的基类View's固定协议可能会在你的onDraw()和你的孩子的onDraw()之间选择做一些事情。因此,ViewdispatchDraw()提示程序员,View的绘图已经完成,派生的实现可以选择任何需要的。

从某种意义上说,程序员甚至可以把dispatchDraw()当做 post onDraw()。我们建议您检查一下View类的draw()方法的源代码。您可以使用下面的方法签名在View类的源代码中搜索它。

public void draw(...)

虽然draw()是一个你可以覆盖的公共方法,但是你不应该这样做。它实现了由 base View定义的协议和契约。您的选择是覆盖其建议的方法:

public void onDraw(...)

public void dispatchDraw(...)

这种dispatch …模式的想法在 Android 中经常被用来在你为自己做了事情之后为孩子们做事情。

由于布局阶段的触发器是requestLayout(),绘制阶段的触发器是invalidate()。当您使一个视图无效时,它沿着链向上,导致从视图根开始的遍历的调度。如果一个视图没有请求无效,或者如果它的位置和大小没有改变,那么onDraw()可能不会被那个View调用。

但是,如果视图的大小或位置发生了变化,基本视图将对该视图调用 invalidate。所以大概没必要把你onSizeChanged()里的东西作废。当您有疑问时,在一个requestlayout()之后调用invalidate()因为对性能的影响是最小的,因为所有这些调用在当前主线程周期结束时聚集起来进行遍历。

让我们回顾一下,看看定制视图的可用方法:

onMeasure

onSizeChanged

onLayout

onDraw

dispatchDraw

这些都是回调。特别有意思的是onMeasure()onLayout()onDraw()。这三种方法都有对应的“协议”或“模板”方法:measure()layout()draw()

这种模式通常被称为模板/钩子。例如,draw()是模板方法,它以某种方式固定行为,同时依靠钩子onDraw()来专门化自己。从另一个角度来看,模板方法就像是一个“带有替换填充物的 HTML 模板”,一个或多个钩子是数据,它们在那里被替换以完成整个网页。

您甚至可以进一步将这种模式称为模板/钩子/子对象。这个想法是:

measure()

onMeasure()

for(child in children) child.measure()

或者

template()

hook()

for(child in children) child.hook()

所以,draw()稍微打破了这个模式,但是measure()layout()还是跟着这个模式走。这是一个很好的经验法则,可以避免在定制组件时迷失在回调名称的海洋中。

当您自定义没有任何子视图的视图时,通常被覆盖的方法有:

onMeasure(...)

onDraw(...)

偶尔也可能被覆盖的方法是onSizeChanged()。

这就结束了关于如何在 Android 中画图的报道。为了快速总结以便引导您进入下一部分,请注意我们已经建立了以下内容:

  • 有一个布局阶段,在这个阶段会进行一次度量,我们需要覆盖onMeasure()。
  • 有一个绘图阶段,我们需要实现onDraw()

现在让我们向您展示实现onMeasure()的机制。

实施措施通过

在测量过程中,自定义视图需要返回它在后续绘制过程中绘制时想要(或指定)的大小。视图需要在覆盖的onMeasure()中设置其尺寸。设置视图的大小并不简单。您的视图大小取决于您的视图与其他视图的匹配程度。这不是说你的视图是 400 像素乘 200 像素那么简单。Android 将一个名为mode的位传递给onMeasure()以给出计算视图大小的上下文。

该模式位可以是以下三位之一:AT_MOST, UNSPECIFIEDEXACT。例如,如果模式位是EXACT,你的视图应该使用传入的大小,不需要计算。本节结束时,您将对这些模式位有一个全面的了解。

onMeasure()的主要职责是识别它是如何被调用的(模式),然后计算视图大小,如果这是一个选项(基于模式),然后使用setMeasuredDimension()设置该大小。onMeasure()的另一个问题是,它可能被多次调用,这取决于父布局如何为所有子布局协调空间。在这个方法中需要实现一个简短的协商协议。在本节结束时,您也将了解该协议。

有时,您可以使用基类View的默认实现onMeasure()。但是首先我们将解释我们在这个方法中做了什么,然后进入为什么我们没有为CircleView使用默认的实现。清单 1-2 展示了我们如何实现onMeasure()。

清单 1-2。如何覆盖 Views onMeasure()方法

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec)

{

logSpec(MeasureSpec.getMode(widthMeasureSpec));

Log.d(tag, "size:" + MeasureSpec.getSize(widthMeasureSpec));

setMeasuredDimension(getImprovedDefaultWidth(widthMeasureSpec)

getImprovedDefaultHeight(heightMeasureSpec));

}

让我们逐点详细说明清单 1-2 中的onMeasure()方法的实现。注意,清单 1-2 中的实现依赖于我们已经专门化的另外两个方法,即:getImprovedDefaultWidth()和getImprovedDefaultHeight()。我们将很快介绍它们。

让我们从清单 1-2 中的参数开始:宽度和高度测量规范。我们知道我们的CircleView可能是布局的一部分。这意味着开发人员可以在一个布局文件中以三种不同的方式指定像高度这样的尺寸。清单 1-3 提供了一个布局文件的例子。

清单 1-3。在布局文件中提供可能影响测量的布局尺寸

<com.androidbook.custom.CircleView

android:id="@+id/circle_view_id"

android:layout_width="match_parent"

android:layout_height="wrap_content"

circleViewPkg:strokeWidth="5"

circleViewPkg:strokeColor="@android:color/holo_red_dark"

/>

参数android:layout_height可以是以下之一:

wrap_content

match_parent

or exact width in pixels like: 30dp

在每种情况下,onMeasure()被不同地调用。widthMeasureSpec实际上是两个参数组合成一个整数。封装这种行为的类是View.MeasureSpec

清单 1-4 显示了如何找到它们各自的部分。

清单 1-4。通过测量解密

int inputMeasureSpec;

int specMode = MeasureSpec.getMode(inputMeasureSpec);

int specSize= MeasureSpec.getSize(inputMeasureSpec);

清单 1-5 显示了如何打印一个度量规范的各种模式。

清单 1-5。了解 MeasureSpec 模式

private void logSpec(int specMode)

{

if (specMode == MeasureSpec.UNSPECIFIED) {

Log.d(tag,"mode: unspecified");

return;

}

if (specMode == MeasureSpec.AT_MOST) {

Log.d(tag,"mode: at most");

return;

}

if (specMode == MeasureSpec.EXACTLY) {

Log.d(tag,"mode: exact");

return;

}

}

如果布局规范说match_parent,那么onMeasure()将被调用,规范为EXACT。大小将等于父对象的大小。然后,onMeasure()将需要通过调用setMeasuredDimension来获取那个精确的大小并将其设置在同一个视图上(如清单 1-2 所示)

如果布局规范说的是精确的像素,那么将使用规范EXACT调用onMeasure()。大小将等于指定像素的大小。然后onMeasure()会用setMeasuredDimension设置这个大小。

现在是更难的模式。如果将尺寸设置为wrap_content,则模式将为AT_MOST。传递的大小可能会大得多,会占用剩余的空间。所以它可能会说,“我有 411 像素。告诉我你不超过 411 像素的尺寸。”程序员的问题是:我应该返回什么?

在你的圆里,你可以把所有给你的尺寸都拿出来,画一个足够大的圆。但是如果这样做,其余的视图将没有任何空间。(我们不确定 Android 为什么要这么做,但事情就是这样。)所以,你要给一个“合理”的尺寸。在我们的例子中,我们选择返回最小尺寸,就像一个发送现金的善意保守者。

为了了解我们如何处理这些测量模式,让我们回到清单 1-5 中引用的getImprovedDefaultHeight()和getImprovedDefaultWidth()中。清单 1-6 展示了这些方法的实现,展示了它们如何处理onMeasure()模式。

清单 1-6。正确实现 onMeasure()

private int getImprovedDefaultHeight(int measureSpec) {

int specMode = MeasureSpec.getMode(measureSpec);

int specSize =  MeasureSpec.getSize(measureSpec);

switch (specMode) {

case MeasureSpec.UNSPECIFIED:

return hGetMaximumHeight();

case MeasureSpec.EXACTLY:

return specSize;

case MeasureSpec.AT_MOST:

return hGetMinimumHeight();

}

//you shouldn't come here

Log.e(tag,"unknown specmode");

return specSize;

}

private int getImprovedDefaultWidth(int measureSpec) {

.... identical to getImprovedDefaultHeight

.... but of course uses the width as opposed to height

}

//Override these methods to provide a maximum size

//"h" stands for hook pattern

abstract protected int hGetMaximumHeight();

abstract protected int hGetMaximumWidth();

protected int hGetMinimumHeight() {

return this.getSuggestedMinimumHeight();

}

protected int hGetMinimumWidth() {

return this.getSuggestedMinimumWidth();

}

注意我们是如何从基类View调用getSuggestedMinimumHeight()来获得这个视图的最小尺寸的。这意味着派生的视图必须调用setMinimumHeight()和setMinimumWidth()。如果像CircleView这样的派生视图在其构造函数中调用这些 set 方法,那么wrap_content的小部件的大小将使用最小维度。如果您的目的是返回平均宽度,而不是最小宽度,请相应地更改此代码。

从清单 1-6 中,你还可以看到我们对UNSPECIFIED模式使用了最大尺寸。那么这个什么时候被调用呢?文档中说,当布局想要知道真正的大小是多少时,会传递这个模式。真实的大小可以有多大就有多大;布局可能会滚动它。带着这个想法,我们返回了我们的圆的最大尺寸。当我们在本章后面向您展示CircleView的完整源代码时,您将会看到这一点。

还要注意,为了满足onMeasure()(在清单 1-2 和 1-6 中),我们使用了两个内置函数:

setMeasuredDimension()     //from view class

getSuggestedMinimumWidth() //from view class

现在让我们看看onMeasure()的默认实现是做什么的(清单 1-7)以及为什么我们没有选择它。

清单 1-7。View 类对 onMeasure()的默认实现

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec)

getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));

}

public static int getDefaultSize(int size, int measureSpec) {

int result = size;

int specMode = MeasureSpec.getMode(measureSpec);

int specSize = MeasureSpec.getSize(measureSpec);

switch (specMode) {

case MeasureSpec.UNSPECIFIED:

result = size;

break;

case MeasureSpec.AT_MOST:

case MeasureSpec.EXACTLY:

result = specSize;

break;

}

return result;

}

请注意,当模式为wrap_content时,该实现将导致占用整个剩余空间!这就是我们重写该类的原因。如果您没有在您的小部件上添加wrap_content(意味着它没有自然大小),那么您可以使用默认实现,并且不允许在布局文件中使用wrap_content

理解定制视图的大部分工作是在度量过程中,以及如何实现onMeasure()。现在已经过去了,让我们转向onDraw()。

通过 onDraw()实现绘图

不像onMeasure(),没有关于onDraw()的困惑。首先,默认实现什么都不做。画画是你的工作。下面是我们如何在清单 1-8 中实现它的:

清单 1-8。覆盖 onDraw()

...

private int defRadius;

private int strokeWidth;

private int strokeColor;

...

//Called by the constructor

public void initCircleView()

{

//Set the minimum width and height

this.setMinimumHeight(defRadius * 2);

this.setMinimumWidth(defRadius * 2);

//Say we respond to clicks

this.setOnClickListener(this);

this.setClickable(true);

//allow for statmanagement

this.setSaveEnabled(true);

}

//we don't use the defRadius variable here

//we just use the dimensions that are passed

//defRadius is used to set the minimum dimension

@Override

public void onDraw(Canvas canvas) {

super.onDraw(canvas);

Log.d(tag,"onDraw called");

int w = this.getWidth();

int h = this.getHeight();

int t = this.getTop();

int l = this.getLeft();

int ox = w/2;

int oy = h/2;

int rad = Math.min(ox,oy)/2;

canvas.drawCircle(ox, oy, rad, getBrush());

}

private Paint getBrush()

{

Paint p = new Paint();

p.setAntiAlias(true);

p.setStrokeWidth(strokeWidth);

p.setColor(strokeColor);

p.setStyle(Paint.Style.STROKE);

return p;

}

很简单。你去拿画布。您向视图询问宽度、高度、左侧和顶部。左侧和顶部相对于父视图,从 0 开始。宽度和高度也包括填充。使用getPadding …()系列方法获得填充坐标,如果您选择使用它们的话。

在清单 1-8 中,没有任何惊喜。当然,当你开始创造性地使用画布时,你就进入了 2D 图形的奇妙世界。但是本章的重点是定制视图的管道,而不是不顾重力的 2D 图形编程。

为了启动和运行基本的定制视图,只需要重写两个方法onMeasure()和onDraw()。只要稍加思考,您就可以继续对整个定制组件类使用相同的onMeasure()实现。一旦理解了这些方法的基础,编写一个绘制到画布上的自定义视图就变得轻而易举了。

响应事件

作为自定义视图的下一步,我们想要练习requestLayout()和invalidate()方法。为了在清单 1-9 中演示这两种方法,我们让我们的圆对触摸做出响应。

清单 1-9。响应事件的自定义视图

public class CircleView

extends implements OnClickListener {

....other stuff

public void initCircleView() {

...other stuff

this.setOnClickListener(this);

this.setClickable(true);

...other stuff

}

....other stuff

public void onClick(View v) {

//increase the radius

defRadius *= 1.2;

adjustMinimumHeight();

requestLayout();

invalidate();

}

private void adjustMinimumHeight() {

this.setMinimumHeight(defRadius * 2);

this.setMinimumWidth(defRadius * 2);

}

....other stuff

}

为了响应点击,我们的自定义控件实现了点击监听器并覆盖了onClick方法。它还告诉基类View这是点击监听器,并且为这个视图启用了点击。在onClick()方法中,我们增加默认半径,并使用该半径来改变最小高度和宽度。

因为onClick事件导致了维度的改变,我们的视图需要变得更大,占据更多的空间。我们如何向 Android 表达这种需求?嗯,我们requestLayout()。该方法沿着链向上,标记需要重新测量的每个视图父视图。当最终的父节点收到这个请求(视图根节点)时,父节点安排一次布局遍历。一个布局遍历可能会也可能不会导致onDraw,尽管在这种情况下应该会这样。作为一个良好的编程实践,我们也调用invalidate()来确保绘制阶段。

有可能特定的事件将检测不到尺寸的变化,而只是检测到圆的颜色;在这种情况下,我们只需要做invalidate()而不调用requestLayout()。

如果您处于布局阶段,您不应该调用可能导致requestLayout()的方法。比方说,你在onSizeChanged()里加了一张背景图。你不应该从同一个相位再次呼叫requestLayout。它不会生效,因为视图根在当前周期结束时重置这些标志。但是你可以在绘画阶段这样做。或者,您可以向调用requestLayout()的队列发送一个事件。

视图上还有另一个方法叫做forceLayout()。这与requestLayout()的区别在于,后者沿着链向上,导致布局通道的调度。没有什么比查看View类中这两个方法的源代码(取自 API 14)更清楚的了(如清单 1-10 所示):

清单 1-10。forceLayout()和 requestLayout()之间的区别

public void forceLayout() {

mPrivateFlags |= FORCE_LAYOUT;

mPrivateFlags |= INVALIDATED;

}

public void requestLayout() {

if (ViewDebug.TRACE_HIERARCHY) {

ViewDebug.trace(this, ViewDebug.HierarchyTraceType.REQUEST_LAYOUT);

}

mPrivateFlags |= FORCE_LAYOUT;

mPrivateFlags |= INVALIDATED;

if (mParent != null) {

if (mLayoutParams != null) {

mLayoutParams.resolveWithDirection(getResolvedLayoutDirection());

}

if (!mParent.isLayoutRequested()) {

mParent.requestLayout();

}

}

}

请注意,清单 1-10 中的方法是内部方法,不是公共 API 的一部分,所以它们可能会随着新版本的发布而改变。然而,基础协议将保持不变。

在清单 1-10 中,首先说明什么是forceLayout()更容易。它就像构建环境中的触摸命令。通常当一个文件没有改变时,构建依赖项会忽略它。因此,您通过“触摸”来强制编译该文件,从而更新其时间戳。就像 touch 一样,forceLayout()本身不会调用任何构建命令(除非您的构建环境过于复杂,无法立即启动)。touch 的效果是,当请求构建时,您不会忽略这个文件。

所以,当你forceLayout()一个视图时,你是在标记那个视图(仅仅是那个)作为测量的候选。如果一个视图没有被标记,那么它的onMeasure()将不会被调用。您可以在视图的measure()方法中看到这一点。measure()方法检查这个视图是否被标记为布局。

requestLayout()的行为(只是)略有不同。一个requestlayoutforceLayout()一样接触当前视图,但是它也沿着链向上移动,接触这个视图的每个父视图,直到到达ViewRootViewRoot覆盖此方法并安排一次布局。因为它只是一个要运行的时间表,所以它不会立即开始布局传递。它等待主线程完成它的杂务并加入消息队列。

你可能想知道,当我使用forceLayout ()的时候,请解释给我听!我理解requestLayout(),因为我最终安排了一个通行证。我拿着forceLayout()在做什么?显然,如果你在一个视图上调用requestLayout,在那个视图上调用forceLayout是没有意义的。“力”到底是什么?

回想一下,“力”就像是构建的“接触”!所以,你是在“强迫”文件编译。虽然它看起来像是要马上运行布局通道,但它并没有。

当一个视图的requestLayout被调用时,它的兄弟和子视图都不会被触动。他们没有升起这面旗帜。因此,如果被触摸的视图是一个视图组(当您删除一个视图或添加一个视图时),视图组不需要计算其子视图的大小,因为它们的大小没有改变。称他们为onMeasure毫无意义。但是如果由于某种原因,视图组决定需要测量这些孩子,它将对他们每个人调用forceLayout,然后是measure(),现在正确地调用onmeasure()。它不会在孩子身上调用requestLayout,因为当你在当前关中间时,不需要触发另一个关。(这是一种“在我打断你的时候不要打断我”的交易。)

这就引出了第一次会发生什么的问题。是谁从一开始就触及了所有的观点,使它们得到了衡量?当一个视图被添加到一个视图组时,该视图组确保该视图被标记为测量,并为该视图调用一个请求布局。更重要的是,什么时候调用这些方法?

开发人员通常有更多的机会调用requestLayout。例如,当你点击一个视图来增加它的尺寸时,你这样做,然后在那个视图上说requestLayout。不管出于什么原因,如果这不起任何作用,你会倾向于称之为forceLayout,因为这个名字很容易让人误解。根据我们所知,这相当于喊了两次。如果第一声大喊不行,第二声也会得到同样的回应。

如果一个目标视图正在改变大小,并且如果你认为你的兄弟视图的大小将会受到影响,那么对你自己调用requestLayout,对你的兄弟调用forceLayout。当然,你也可以调用兄弟姐妹requestLayout,但是这增加了 CPU 的周期;如果你知道自己在做什么,一个更简单的forceLayout就能搞定。这在衍生自视图组的复杂布局中也很常见,其中对单个同级的更改可能需要对其同级进行测量,因此您希望明确决定它们是否需要再次测量。

保存视图状态

到目前为止,你已经知道在 Android 上工作的感受了:当你翻转手机或设备时,你会从纵向切换到横向,或者反过来。总的来说,这被称为对设备的配置改变。配置更改将停止并删除该活动,并使用新配置重新创建一个新实例。因此,活动持有的所有内存变量都消失了,并被重新创建。

如果您有一个包含局部变量的视图,它们也会消失并被重新初始化。如果您有自视图初始化以来创建的临时数据,并且没有写入永久存储,那么这些数据也将消失。

为了保持瞬时状态,可以使用活动或片段来保存和恢复实例数据。“实例数据”是指由ActivityFragmentView等类维护的局部变量。ActivityFragment有一个预定义的协议来管理这种方法。我们不打算在本章中详细讨论这个问题;我们的重点是如何管理视图的实例状态。

管理视图状态有三种方式:

  • Activity使用保存和恢复实例方法显式调用视图来保存和恢复其状态。
  • 使用View的内置功能来保存和恢复其状态。
  • 使用View的内置功能来保存和恢复其状态,如上面的项目,但使用BaseSavedState协议。

我们将讨论每种方法的优缺点,并推荐第三种方法最适合工业级组件。

依靠活动方法

清单 1-11 中的伪代码展示了Activity如何定位并调用View来保存和恢复瞬态。

清单 1-11。通过活动查看状态管理

YourActivity

{

@Override

protected void onSaveInstanceState(Bundle savedInstanceState) {

super.onRestoreInstanceState(savedInstanceState);

//locate your view component

CircleView cv = findViewById(R.id.circle_view_id);

//call a custom method and get a bundle

Bundle b = cv.saveState();

//Put the bundle to be saved

savedInstanceState.putBundle("circle_view_bundle",b);

}

@Override

protected void onRestoreInstanceState(Bundle savedInstanceState) {

super.onRestoreInstanceState(savedInstanceState);

//locate your view component

CircleView cv = findViewById(R.id.circle_view_id);

//call a custom method

cv.restoreState(savedInstanceState.getBundle("circle_view_bundle"));

}

}

这是一些 Android SDK API 示例中使用的方法,比如SnakeView。清单 1-12 显示了来自SnakeView示例的源代码片段:

清单 1-12。保存和恢复状态的示例

public Bundle saveState() {

Bundle map = new Bundle();

map.putIntArray("mAppleList", coordArrayListToArray(mAppleList));

map.putInt("mDirection", Integer.valueOf(mDirection));

....more

return map;

}

public void restoreState(Bundle icicle) {

mAppleList = coordArrayToArrayList(icicle.getIntArray("mAppleList"));

mDirection = icicle.getInt("mDirection");

....more

mSnakeTrail = coordArrayToArrayList(icicle.getIntArray("mSnakeTrail"));

}

这种做法真的很简单,这就是它的魅力所在。然而,如果活动包含许多视图,那么我们必须保存和恢复清单 1-12 中每个视图的状态。我们还必须为每个视图定义字符串常量,并确保它们不会冲突。那将会有很多代码,更不用说还有点容易出错。尽管如此,清单 1-12 所示的方法对于简单的场景还是有用的。

启用自我状态管理视图

如果您可以让视图进行自己的状态管理,那么您就不需要在更高级别的组件中记账,比如片段和活动。您可以通过调用以下命令告诉 Android 视图自己进行状态管理:

view.setSaveEnabled();

这将在视图上调用下面的方法,如清单 1-13 所示(只要布局文件中的视图定义定义了惟一的 ID;这是对视图管理其自身状态的限制和要求)。

清单 1-13。覆盖视图的保存和恢复状态方法

@Override

protected void onRestoreInstanceState(Parcelable p)

{

//Code for these two methods are presented a little later

this.onRestoreInstanceStateSimple(p);

this.initCircleView();

}

@Override

protected Parcelable onSaveInstanceState()

{

//Code for this method is presented a little later

return this.onSaveInstanceStateSimple(p);

}

这种方法的警告是视图必须有一个惟一的 ID 来触发这两种方法。当像CircleView这样的视图独立存在并且独立地与布局挂钩时,这不是问题。但是如果CircleView成为一个复合组件的一部分,并且如果该复合组件在一个布局中被多次指定,那么 id 将会冲突。在下一章,当我们告诉你如何编写复合控件时,我们将更详细地讨论这个主题。

在清单 1-13 中,我们只展示了视图调用什么方法来保存和恢复状态。我们还没有展示如何真正拯救国家。有一个简单的方法,也有一个标准的方法。我们将首先介绍简单的方法,如清单 1-14 所示。

清单 1-14。管理视图状态的简单方法

private Parcelable onSaveInstanceStateSimple()

{

Parcelable p = super.onSaveInstanceState();

Bundle b = new Bundle();

b.putInt("defRadius",defRadius);

b.putParcelable("super",p);

return b;

}

private void onRestoreInstanceStateSimple(Parcelable p)

{

if (!(p instanceof Bundle))

{

throw new RuntimeException("unexpected bundle");

}

Bundle b = (Bundle)p;

defRadius = b.getInt("defRadius");

Parcelable sp = b.getParcelable("super");

super.onRestoreInstanceState(sp);

}

在清单 1-14 所示的方法中,超类ViewonSave期间传递一个对象,并希望在onRestore期间取回它。如果对象不匹配,超类View将抛出一个异常。为了解决这个问题,我们在保存期间将传入的对象放入我们自己的包中,然后在恢复期间将其解包并发送回。

这是一个折中的解决方案,也很简单,适合于演示目的。主要的缺点是,如果您希望您的视图被继承,那么这些包可能会有冲突的名称,必须进行管理。我们现在将讨论正确的方法。

BaseSavedState 模式

为了保存内置 UI 控件的状态,Android 使用了一种基于ViewBaseSavedState类的模式。这有点绕弯,需要大量代码。不过,好消息是,您可以复制这段代码,并对每个派生视图做一些修改,这样您就有了一个坚如磐石的框架,可以很好地与视图的核心状态管理一起工作。

在这种方法中,在您最衍生的定制视图中,比如CircleView,您需要创建一个内部静态类,如清单 1-15 所示。

清单 1-15。实现特定于视图的 SavedState 类以管理视图状态

public class CircelView extends View

{

....other stuff

public static class SavedState extends BaseSavedState {

int defRadius;

SavedState(Parcelable superState) {

super(superState);

}

@Override

public void writeToParcel(Parcel out, int flags) {

super.writeToParcel(out, flags);

out.writeInt(defRadius);

}

//Read back the values

private SavedState(Parcel in) {

super(in);

defRadius = in.readInt();

}

@Override

public String toString() {

return "CircleView defRadius:" + defRadius;

}

@SuppressWarnings("hiding")

public static final Parcelable.Creator<SavedState> CREATOR

= new Parcelable.Creator<SavedState>() {

public SavedState createFromParcel(Parcel in) {

return new SavedState(in);

}

public SavedState[] newArray(int size) {

return new SavedState[size];

}

};

}//eof-state-class

....other stuff

}//eof-custom-view-class

这个内部SavedState类对于每个派生视图的唯一不同之处是您保存的内部变量。这种情况下就是defRadius。这个SavedState类最终衍生自Parcelable。因此,根据那个类Parcelable的契约,SavedState需要一个静态创建者对象来从包流中创建这些SavedState可打包对象。清单 1-15 中显示的这段代码是每一个想要管理其状态的派生视图的标准模板。

SavedState中,还需要覆盖writeToParcel() method将局部变量写到包中。您可以在您的SavedState构造函数中从传入的包中读回它们。

一旦有了这个内部SavedState类,清单 1-16 显示了如何使用这个内部类SavedState来保存和恢复CircleView的状态。参见清单 1-13,了解如何从View的保存和恢复回调中调用清单 1-16 中的方法。

清单 1-16。使用特定于视图的 SavedState 对象维护视图状态

private Parcelable onSaveInstanceStateStandard()

{

Parcelable superState = super.onSaveInstanceState();

SavedState ss = new SavedState(superState);

ss.defRadius = this.defRadius;

return ss;

}

private void onRestoreInstanceStateStandard(Parcelable state)

{

//If "state" object is not yours doesn't mean it is BaseSavedState

//You may have a parent in your hierarchy that has their own

//state derived from BaseSavedState.

//It is like peeling an onion or opening a Russian doll

if (!(state instanceof SavedState)) {

super.onRestoreInstanceState(state);

return;

}

//it is our state

SavedState ss = (SavedState)state;

//Peel it and give the child to the super class

super.onRestoreInstanceState(ss.getSuperState());

defRadius = ss.defRadius;

}

这种采用SavedState模式的方法消除了为保存和恢复本地变量而发明字符串名称的需要。这个模式还有一个协议,可以从属于继承层次结构中超级视图的包中识别出视图的包。

自定义属性

这就把我们带到了自定义视图实现的最后一个细节。比方说,您的自定义视图有您想要读取的特殊属性。清单 1-17 显示了一个线性布局,在这里你可以指定我们正在画的圆的自定义属性:strokeWidthstrokeColor

清单 1-17。指定自定义属性

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android

xmlns:circleViewPkg="http://schemas.android.com/apk/res/com.androidbook.custom

....

<com.androidbook.custom.CircleView

android:id="@+id/circle_view_id"

android:layout_width="wrap_content"

android:layout_height="wrap_content"

circleViewPkg:strokeWidth="5"

circleViewPkg:strokeColor="@android:color/holo_red_dark"

/>

</LinearLayout>

注意清单 1-17 中的两个自定义属性:

strokeWidth

strokeColor

为了能够在布局中放置这些自定义变量(如清单 1-17 所示),您需要在/res/values/attrs.xml文件中向 Android 声明这些自定义属性,如清单 1-18 所示。

清单 1-18。在 attrs.xml 中定义自定义属性

<resources>

<declare-styleable name="CircleView">

<attr name="strokeWidth" format="integer"/>

<attr name="strokeColor" format="color|reference" />

</declare-styleable>

</resources>

文件名attrs.xml可以是任何东西,但是惯例是使用那个名字。关于清单 1-18 中的属性,有几点值得注意。首先,对于您的整个包,您的属性必须是唯一的。如果你有另一个名为CircleView1的组件,你不能这样做,如清单 1-19 所示。

清单 1-19。在包级别显示定制属性的唯一性

<resources>

<declare-styleable name="``CircleView

<attr name="``strokeWidth

<attr name="strokeColor" format="color|reference" />

</declare-styleable>

<declare-styleable name="``CircleView1

<attr name="``strokeWidth

<attr name="strokeColor" format="color|reference" />

</declare-styleable>

</resources>

您将得到错误消息,指出这些属性名称已经被使用。所以,属性的名称空间就是你的整个包!attrs.xml中的样式名CircleView仅仅是一个约定;你可以用任何名字。你也可以在一个可样式化的组之外定义属性,比如CircleView。(参见清单 1-20。)

清单 1-20。显示自定义属性可以在 Styleable 标记之外定义

<resources>

<declare-styleable name="CircleView">

<attr name="strokeWidth" format="integer"/>

<attr name="strokeColor"/>

</declare-styleable>

<attr name="strokeColor" format="color|reference" />

</resources>

其次,清单 1-20 展示了属性可以被定义为独立的,并且可以独立于declare-styleable分组;declare-styleable仅仅是一组属性。只要不重新定义属性的格式,也可以将属性分成多个组。你可以在清单 1-20 中看到,我们重用了strokeColor

第三,如果属性可以独立定义,那么我们为什么要把它们分组到declare-styleable中,并给它起名叫CircleView?因为这种分组便于CircleView读取这些自定义属性。分组完成后,CircleView类会说“从布局文件中读取该组中的所有变量!”

我们留下了一个关于自定义属性使用的细节,如清单 1-17 所示。在该清单中,我们已经声明了strokeWidth:的名称空间

xmlns: circleViewPkg =" http://schemas.android.com/apk/res/com.androidbook.custom "

虽然名称空间值是任意的,但是 Android 中的工具希望您的结尾部分/apk/res/com.androidbook.custom匹配您的包名。这就是它为您的属性定位和分配 id 的方式。

给定清单 1-18 中的attrs.xml, Android 生成以下 id:

R.attr.strokeWidth (int)

R.attr.srokeColor (int)

R.styleable.CircelView (an array of ints)

R.styleable.CircleView_strokeWidth (offset into the array)

R.styleable.CircelView_strokeColor (offset into the array)

我们使用这些常量,如清单 1-21 所示,从布局 XML 文件中读取自定义属性值。

清单 1-21。使用 TypedArrays 读取自定义属性

public CircleView(Context context, AttributeSet attrs, int defStyle) {

super(context, attrs, defStyle);

//Use the array constant to read the bag once

TypedArray t = context.obtainStyledAttributes(attrs

R.styleable.CircleView

defStyle, //if any values are in the theme

0); //Do you have your own style group

//Use the offset in the bag to get your value

strokeColor = t.getColor(R.styleable.CircleView_strokeColor, strokeColor);

strokeWidth = t.getInt(R.styleable.CircleView_strokeWidth, strokeWidth);

//Recycle the typed array

t.recycle();

//Go ahead and initialize your class.

initCircleView();

}

在 Android 中,属性、样式、风格和主题是联系在一起的。要完全理解如何读取布局和初始化自定义属性,您必须理解这种联系。虽然您可以机械地重复这种读取定制属性的模式,但是最好知道您为什么以这种方式读取这些定制属性。关于这种联系的快速入门是必要的。

正如您在这里看到的,属性是绑定到一个包的一组唯一的名称。比如说,TextView这样的对象会挑选一些属性供其使用。Styleable(正如您在这里看到的)是一个给定定制组件的分组,用于选择它所关心的属性。一个style是一组属性名的命名集合(包)。您可以将style连接到ActivityView。当您这样做时,调用obtainStyledAttributes将沿着链向上走,并提取该组件关心的所有属性。(我们在本章末尾的参考资料中包含了作者关于自定义属性、样式和主题的注释的 URL。)

考虑到所有这些,清单 1-22 显示了一个定制视图类是如何构造的。

清单 1-22。为自定义视图设计构造函数

public class CircleView extends View implements OnClickListener

{

//Local variables

public static String tag="CircleView";

private int defRadius = 20;

private int strokeColor = 0xFFFF8C00;

private int strokeWidth = 10;

//for using it from java cdoe

public CircleView(Context context) {

super(context);

initCircleView();

}

//for using it from java cdoe

public CircleView(Context context, int inStrokeWidth, int inStrokeColor) {

super(context);

strokeColor = inStrokeColor;

strokeWidth = inStrokeWidth;

initCircleView();

}

//Invoked by layout inflater

public CircleView(Context context, AttributeSet attrs) {

//Delegate this to a more general method.

//we don't have any default style we care about

//so set it to 0.

this(context, attrs,0);

}

//Meant for derived classes to call if they care about defStyle

//Not called by the layout inflater

public CircleView(Context context, AttributeSet attrs, int defStyle) {

super(context, attrs, defStyle);

TypedArray t = context.obtainStyledAttributes(attrs,R.styleable.CircleView, defStyle,0);

strokeColor = t.getColor(R.styleable.CircleView_strokeColor, strokeColor);

strokeWidth = t.getInt(R.styleable.CircleView_strokeWidth, strokeWidth);

t.recycle();

initCircleView();

}

//See how all constructors swoop in on this one initialization

public void initCircleView()

{

this.setMinimumHeight(defRadius * 2);

this.setMinimumWidth(defRadius * 2);

this.setOnClickListener(this);

this.setClickable(true);

this.setSaveEnabled(true);

}

...You will see other methods when we present the full source code

...very soon after this section.

}//eof-class

阅读清单 1-22 中的注释,了解构造函数方法如何结合在一起,以及如何用于从布局文件中读取定制属性。

自定义视图的完整源代码

我们已经讨论了编写生产就绪的定制视图所需的所有理论(和代码片段)。在这一节中,我们将向您展示CircleView的完整源代码。这解决了我们认为不太重要,但是您可能希望在完整实现的上下文中看到的任何遗留问题。

CircleView的实现在这里被分成了两个类。第一个是一个基础抽象类,您可以在其他定制视图中重用它。第二个是CircleView本身,专门化基础抽象View类来完成实现。我们还将展示任何依赖于实现的文件,比如attrs.xml

实现基本抽象视图类

我们不想直接编码CircleView,而是首先想要创建一个基类,它概述了视图中哪些方法是可重写的。我们列出了一个View的每一个方法,并评论了重写它的意义。如果有一个有意义的默认行为,我们也在这个基类中实现。例如,我们知道我们可以更好地完成onMeasure()的工作,并从派生类中减轻这种责任,比如CircleView

这个抽象的基本视图类如清单 1-23 所示。到目前为止,我们已经介绍了所有内容,尽管是分段介绍,但您应该能够通读评论,而无需进一步解释。现在,所有代码都在一个地方了。

清单 1-23。实现 AbstractBaseView

public abstract class AbstractBaseView

extends View

{

public static String tag="AbstractBaseView";

public AbstractBaseView(Context context) {

super(context);

}

public AbstractBaseView(Context context, AttributeSet attrs) {

super(context, attrs);

}

public AbstractBaseView(Context context, AttributeSet attrs, int defStyle) {

super(context, attrs, defStyle);

}

@Override

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec)

{

logSpec(MeasureSpec.getMode(widthMeasureSpec));

Log.d(tag, "size:" + MeasureSpec.getSize(widthMeasureSpec));

setMeasuredDimension(getImprovedDefaultWidth(widthMeasureSpec)

getImprovedDefaultHeight(heightMeasureSpec));

}

private void logSpec(int specMode) {

if (specMode == MeasureSpec.UNSPECIFIED) {

Log.d(tag,"mode: unspecified");

return;

}

if (specMode == MeasureSpec.AT_MOST) {

Log.d(tag,"mode: at most");

return;

}

if (specMode == MeasureSpec.EXACTLY) {

Log.d(tag,"mode: exact");

return;

}

}

@Override

protected void onSizeChanged(int w, int h, int oldw, int oldh) {

super.onSizeChanged(w,h,oldw,oldh);

}

@Override

protected void onLayout (boolean changed, int left

int top, int right, int bottom)

{

Log.d(tag,"onLayout");

super.onLayout(changed, left, top, right, bottom);

}

@Override

public void onDraw(Canvas canvas) {

super.onDraw(canvas);

Log.d(tag,"onDraw called");

}

@Override

protected void onRestoreInstanceState(Parcelable p) {

Log.d(tag,"onRestoreInstanceState");

super.onRestoreInstanceState(p);

}

@Override

protected Parcelable onSaveInstanceState() {

Log.d(tag,"onSaveInstanceState");

Parcelable p = super.onSaveInstanceState();

return p;

}

private int getImprovedDefaultHeight(int measureSpec) {

//int result = size;

int specMode = MeasureSpec.getMode(measureSpec);

int specSize =  MeasureSpec.getSize(measureSpec);

switch (specMode) {

case MeasureSpec.UNSPECIFIED:

return hGetMaximumHeight();

case MeasureSpec.EXACTLY:

return specSize;

case MeasureSpec.AT_MOST:

return hGetMinimumHeight();

}

//you shouldn't come here

Log.e(tag,"unknown specmode");

return specSize;

}

private int getImprovedDefaultWidth(int measureSpec) {

//int result = size;

int specMode = MeasureSpec.getMode(measureSpec);

int specSize =  MeasureSpec.getSize(measureSpec);

switch (specMode) {

case MeasureSpec.UNSPECIFIED:

return hGetMaximumWidth();

case MeasureSpec.EXACTLY:

return specSize;

case MeasureSpec.AT_MOST:

return hGetMinimumWidth();

}

//you shouldn't come here

Log.e(tag,"unknown specmode");

return specSize;

}

//Override these methods to provide a maximum size

//"h" stands for hook pattern

abstract protected int hGetMaximumHeight();

abstract protected int hGetMaximumWidth();

//For minimum height use the View's methods

protected int hGetMinimumHeight() {

return this.getSuggestedMinimumHeight();

}

protected int hGetMinimumWidth() {

return this.getSuggestedMinimumWidth();

}

}

CircleView 实现

清单 1-24 显示了扩展了AbastractBaseViewCircleView的完整实现(见前面的清单 1-23)。

清单 1-24。自定义 CircleView 实现的源代码

public class CircleView

extends AbstractBaseView

implements OnClickListener

{

public static String tag="CircleView";

private int defRadius = 20;

private int strokeColor = 0xFFFF8C00;

private int strokeWidth = 10;

public CircleView(Context context) {

super(context);

initCircleView();

}

public CircleView(Context context, int inStrokeWidth, int inStrokeColor) {

super(context);

strokeColor = inStrokeColor;

strokeWidth = inStrokeWidth;

initCircleView();

}

public CircleView(Context context, AttributeSet attrs) {

this(context, attrs,0);

}

//Meant for derived classes to call

public CircleView(Context context, AttributeSet attrs, int defStyle) {

super(context, attrs, defStyle);

TypedArray t = context.obtainStyledAttributes(attrs,R.styleable.CircleView, defStyle,0);

strokeColor = t.getColor(R.styleable.CircleView_strokeColor, strokeColor);

strokeWidth = t.getInt(R.styleable.CircleView_strokeWidth, strokeWidth);

t.recycle();

initCircleView();

}

public void initCircleView()    {

this.setMinimumHeight(defRadius * 2);

this.setMinimumWidth(defRadius * 2);

this.setOnClickListener(this);

this.setClickable(true);

this.setSaveEnabled(true);

}

@Override

public void onDraw(Canvas canvas) {

super.onDraw(canvas);

Log.d(tag,"onDraw called");

int w = this.getWidth();

int h = this.getHeight();

int t = this.getTop();

int l = this.getLeft();

int ox = w/2;

int oy = h/2;

int rad = Math.min(ox,oy)/2;

canvas.drawCircle(ox, oy, rad, getBrush());

}

private Paint getBrush() {

Paint p = new Paint();

p.setAntiAlias(true);

p.setStrokeWidth(strokeWidth);

p.setColor(strokeColor);

p.setStyle(Paint.Style.STROKE);

return p;

}

@Override

protected int hGetMaximumHeight() {

return defRadius * 2;

}

@Override

protected int hGetMaximumWidth() {

return defRadius * 2;

}

public void onClick(View v) {

//increase the radius

defRadius *= 1.2;

adjustMinimumHeight();

requestLayout();

invalidate();

}

private void adjustMinimumHeight() {

this.setMinimumHeight(defRadius * 2);

this.setMinimumWidth(defRadius * 2);

}

/*

* ***************************************************************

* Save and restore work

* ***************************************************************

*/

@Override

protected void onRestoreInstanceState(Parcelable p) {

this.onRestoreInstanceStateStandard(p);

this.initCircleView();

}

@Override

protected Parcelable onSaveInstanceState() {

return this.onSaveInstanceStateStandard();

}

private void onRestoreInstanceStateStandard(Parcelable state) {

//If it is not yours doesn't mean it is BaseSavedState

//You may have a parent in your hierarchy that has their own

//state derived from BaseSavedState

//It is like peeling an onion or a Russian doll

if (!(state instanceof SavedState)) {

super.onRestoreInstanceState(state);

return;

}

//it is our state

SavedState ss = (SavedState)state;

//Peel it and give the child to the super class

super.onRestoreInstanceState(ss.getSuperState());

defRadius = ss.defRadius;

}

private Parcelable onSaveInstanceStateStandard() {

Parcelable superState = super.onSaveInstanceState();

SavedState ss = new SavedState(superState);

ss.defRadius = this.defRadius;

return ss;

}

/*

* ***************************************************************

* Saved State inner static class

* ***************************************************************

*/

public static class SavedState extends BaseSavedState {

int defRadius;

SavedState(Parcelable superState) {

super(superState);

}

@Override

public void writeToParcel(Parcel out, int flags) {

super.writeToParcel(out, flags);

out.writeInt(defRadius);

}

//Read back the values

private SavedState(Parcel in) {

super(in);

defRadius = in.readInt();

}

@Override

public String toString() {

return "CircleView defRadius:" + defRadius;

}

@SuppressWarnings("hiding")

public static final Parcelable.Creator<SavedState> CREATOR

= new Parcelable.Creator<SavedState>() {

public SavedState createFromParcel(Parcel in) {

return new SavedState(in);

}

public SavedState[] newArray(int size) {

return new SavedState[size];

}

};

}//eof-state-class

}//eof-main-view class

清单 1-24 中的所有代码在本章前面已经介绍过了。现在把它们都放在一个地方,应该会给你一个实现的环境。

为 CircleView 定义自定义属性

清单 1-25 显示了这段代码的attrs.xml

清单 1-25。自定义 CircleView 的 Attrs.xml

<resources>

<declare-styleable name="CircleView">

<attr name="strokeWidth" format="integer"/>

<attr name="strokeColor" format="color|reference" />

</declare-styleable>

</resources>

在布局中使用圆形视图

清单 1-26 展示了如何在线性布局中使用CircleView自定义组件来生成图 1-1 中的图像。

清单 1-26。在线性布局中使用 CircleView

<?xml version="1.0" encoding="utf-8"?>

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android

xmlns:circleViewPkg="http://schemas.android.com/apk/res/com.androidbook.custom

android:orientation="vertical"

android:layout_width="fill_parent"

android:layout_height="match_parent">

<TextView

android:id="@+id/text1"

android:layout_width="fill_parent"

android:layout_height="wrap_content"

android:text="Custom Hello" />

<com.androidbook.custom.CircleView

android:id="@+id/circle_view_id"

android:layout_width="wrap_content"

android:layout_height="wrap_content"

circleViewPkg:strokeWidth="5"

circleViewPkg:strokeColor="@android:color/holo_red_dark"/>

<TextView

android:id="@+id/text1"

android:layout_width="fill_parent"

android:layout_height="wrap_content"

android:text="Debut Text Appears here" />

</LinearLayout>

参考

我们有很多很好的参考资料来补充本章提供的信息。你会发现下面的这些资源非常有用。

摘要

了解定制组件拓宽了您使用 Android SDK 的范围。这让你更有信心从别人那里借用和下载定制组件。我们已经向您展示了如何测量组件。我们还向您展示了如何正确管理视图状态。我们已经解释了requestLayout()、forceLayout()、invalidate()之间的区别。我们已经全面介绍了定制属性及其与样式和主题的关系。这一章不仅为接下来的两章打下了坚实的基础,也为你进一步学习 Android 打下了坚实的基础。

复习问题

以下问题应进一步作为确定您在本章中学到了什么的里程碑:

What are the differences between requestLayout(  ), invalidate(  ), and forceLayout(  )?   Why is ViewRoot important?   What is meant by scheduling of a traversal?   What is a template/hook/child pattern?   What do you do in onMeasure(  )?   What methods do you use to override for a custom view?   How do you correctly manage view state?   What are the limitations of view state management in Android?   What constructor method is called from the layout inflater?   What is the name space for attributes?   How are attributes, styles, and themes connected?   What are measure spec modes and how do they correlate to layout sizes?

二、探索复合控件

Abstract

在第一章中,我们说过在 Android 中定制视图的方法之一是将现有控件组合(或放在一起)为一个新的复合控件。在本章中,我们将讨论如何创建这些自定义复合控件。

在第一章中,我们说过在 Android 中定制视图的方法之一是将现有控件组合(或放在一起)为一个新的复合控件。在本章中,我们将讨论如何创建这些自定义复合控件。

编写定制的复合控件(本章的主题)和直接定制一个独立的视图(正如我们在第一章中所做的)有许多相似之处。在这两种类型的自定义中,管理自定义属性是相同的。但是,与自定义视图相比,在管理自定义复合控件的视图状态方面存在细微但重要的差异。此外,与您自己绘制的自定义视图不同,在复合控件中,您不需要处理测量、布局或绘制。这是因为您正在使用现有的控件和那些控件(如文本视图、按钮等。)知道如何测量和绘制自己。

除了自定义视图和复合控件之间的这些高级异同,您将了解到创建行为良好的复合控件包括以下步骤。

Derive the custom compound control from an existing layout like LinearLayout, RelativeLayout, etc.   Place the child controls you want to compose in a layout XML file. Then load that layout XML file in the constructor of the custom compound control as its layout.   Use merge as the root node of your custom layout XML file so that the composed child components in the layout XML file become the direct children of the custom control.   If you intend to invoke fragment dialogs from your child controls (like clicking or touching a button), you might need to assume your context for the fragment dialog as an activity.   From step 4, you will be able to derive a fragment manager and use fragment dialogs.   If you are going to use a fragment dialog, you need to create a fragment class to work with your fragment dialog.   When using fragment dialogs, to allow for device rotation, you need to pass the view ID of the parent compound control in the argument bundle of the fragment dialog. This ID is needed so that your fragment dialog can communicate with the parent compound control.   On device rotation, you need to restore view pointers to the parent compound control in your dialog fragments in the fragment method onActivityCreated(  ).   To overcome the “ID” dependence of the views for view state management, the compound control needs to take over view state management for child views.   Of course, as in Chapter 1, you can use custom attributes.

我们将用带注释的代码片段来解释每一个步骤。首先,我们展示我们用来说明所有这些步骤的自定义复合控件。

计划工期复合控制

对于我们的自定义复合控件,我们将使用两个日期,并查看这两个日期之间的天数或周数。图 2-1 显示了这个控件嵌入到活动布局中时的样子。

A978-1-4302-4951-1_2_Fig1_HTML.jpg

图 2-1。

A compound control: DurationControl

我们的自定义持续时间组件嵌入在两个文本控件之间,一个以“欢迎…”开始,另一个在底部以“调试开始…”开始

我们在这个控件上有两个日期:一个“从”日期和一个“到”日期。当我们针对 from 日期按 GO 时,我们调用一个日期选择器对话框(片段对话框),并用“From”日期替换文本“Enter From Date”。当我们按 GO 到 to 日期时,我们调用同一个日期选择器对话框,并用“To”日期替换文本“Enter To Date”。然后,compound DurationControl可以计算天数或周数(基于自定义属性)。

图 2-2 显示了当你点击 GO 时,日期选择器片段对话框在纵向模式下的样子。

A978-1-4302-4951-1_2_Fig2_HTML.jpg

图 2-2。

Invoking a fragment dialog from a compound control

一旦你从图 2-2 中选择了一个日期,该日期将被填充到日期文本框中,如图 2-3 所示。

A978-1-4302-4951-1_2_Fig3_HTML.jpg

图 2-3。

Saving the date from a date picker fragment dialog

在图 2-3 中,注意“从”日期文本被替换为选择的日期。此时,您需要确保翻转设备时数据保持完整。比方说,你从纵向模式的日期对话框开始。你把手机翻到横向。然后,图 2-4 显示了这个对话框应该是什么样子。如果设备翻转,您不必重新单击“转到”来查看此对话框。

A978-1-4302-4951-1_2_Fig4_HTML.jpg

图 2-4。

Demonstrating device rotation with fragment dialogs

在设备翻转时保留对话框并不是那么简单。我们将在本章后面讨论如何做好这一点。图 2-5 显示了当您从图 2-4 中选择日期并设置“至”日期时,大院DurationControl的景观。

A978-1-4302-4951-1_2_Fig5_HTML.jpg

图 2-5。

DurationControl state in landscape mode

现在,您希望翻转设备以确保复合控件可以保持其状态(两个选定的日期及其值)。图 2-6 显示了翻转回纵向后的DurationControl视图。

A978-1-4302-4951-1_2_Fig6_HTML.jpg

图 2-6。

Duration control state in portrait mode

现在,您已经完全理解了定制化合物DurationControl,让我们开始探索实现本章开头列出的全套功能所需的每个步骤。

从现有布局驱动

清单 2-1 显示了创建自定义复合控件的第一步。在这个清单中,我们将让我们的DurationControl扩展LinearLayout以产生图 2-1 中的布局。

清单 2-1。duration 控制扩展现有布局

public class DurationControl

extends LinearLayout

implements android.view.View.OnClickListener

{

...

除了扩展LinearLayout,控件还实现了一个onclick监听器。这个listener在那里监听两个按钮并启动片段对话框来收集日期。

为复合控件创建布局文件

清单 2-2 显示了产生如图 2-1 所示的DurationControl布局所需的布局文件。如图 2-1 所示,该列表具有(a)两个显示所选日期值的文本视图,以及(b)两个调用日期选择器对话框的按钮。我们使用了两个内部LinearLayouts来完成DurationControl视图,如图 2-1 所示。或许你可以发挥创意,使用一个RelativeLayout来完成或达到图 2-1 中的布局。(RelativeLayout是与生产代码的嵌套LinearLayouts相比的首选机制。)

清单 2-2。DurationControl 自定义布局文件

<?xml version="1.0" encoding="utf-8"?>

<!--/res/layout/duration_view_layout.xml -->

<merge xmlns:android="http://schemas.android.com/apk/res/android

<LinearLayout

android:orientation="horizontal"

android:layout_width="fill_parent"

android:layout_height="wrap_content"

>

<TextView

android:id="@+id/fromDate"

android:layout_width="0dp"

android:layout_height="wrap_content"

android:text="Enter From Date"

android:layout_weight="70"

/>

<Button

android:id="@+id/fromButton"

android:layout_width="0dp"

android:layout_height="wrap_content"

android:text="Go"

android:layout_weight="30"

/>

</LinearLayout>

<LinearLayout

android:orientation="horizontal"

android:layout_width="fill_parent"

android:layout_height="wrap_content"

>

<TextView

android:id="@+id/toDate"

android:layout_width="0dp"

android:layout_height="wrap_content"

android:text="Enter To Date"

android:layout_weight="70"

/>

<Button

android:id="@+id/toButton"

android:layout_width="0dp"

android:layout_height="wrap_content"

android:text="Go"

android:layout_weight="30"

/>

</LinearLayout>

</merge>

您需要将这个布局放在布局资源目录下的 XML 文件中。这将生成布局 ID,您将在DurationControl的构造函数中使用它来读取这个自定义布局。我们将很快向您展示这是如何完成的。

注意,这个布局文件的根不是<LinearLayout>,而是<merge>。这一点很重要,因为定制控件已经是一个LinearLayout(见清单 2-1),你想让清单 2-2 中的子控件直接附加到DurationControl,?? 本身是一个LinearLayout。如果你不这样做,而是在清单 2-2 中放置一个LinearLayout作为根节点,你的DurationControl将会有一个额外的不必要的LinearLayout作为它的子节点。当你看到清单 2-3 中的DurationControl的构造函数,将清单 2-2 的这些节点作为父视图附加到它自身时,这就变得很明显了。

清单 2-3。在 DurationControl 构造函数中加载自定义布局

//Constructor for Java

public DurationControl(Context context) {

super(context);

initialize(context);

}

...other constructors that are there to read custom attributes

...Which also call initialize(context)

private void initialize(Context context)  {

//Get the layout inflater

LayoutInflater lif = (LayoutInflater)

context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);

//inflate the custom layout of listing 2-2

//Use the second argument to attach the layout

//as a child of this layout

lif.inflate(R.layout.duration_view_layout, this);

//Initialize the buttons

Button b = (Button)this.findViewById(R.id.fromButton);

b.setOnClickListener(this);

b = (Button)this.findViewById(R.id.toButton);

b.setOnClickListener(this);

//Allow view state management

this.setSaveEnabled(true);

}

清单 2-3 是复合控件加载自定义布局的典型例子。

所有继承视图,包括从布局继承的视图,都有多个构造函数。你已经在第一章中看到了。有一个构造函数用于直接从 Java 实例化该类。Android 使用另外两个构造函数来实例化视图,作为展开布局的一部分。在清单 2-3 中,我们只展示了一个构造函数来演示DurationControl如何加载它的自定义布局。在本章的后面,当我们讨论这个类的自定义属性时,我们将展示这个类中其余的构造函数。

不管怎样,DurationControl的所有这些构造函数最终都会调用清单 2-3 中的initialize()方法。这个初始化方法从 activity 中获取一个布局生成器,并使用它来加载清单 2-2 中的布局,使用为这个自定义布局文件生成的 ID。假设清单 2-2 中的布局在文件/res/layout/duration_view_layout中,我们将使用的 ID 是R.layout.duration_view_layout

清单 2-3 的初始化例程在清单 2-2 的自定义布局中定位按钮,并将DurationControl设置为它们的onClicks的目标。

您现在有了一个自定义布局(清单 2-2)。您已经在您的DurationControl构造函数中加载了自定义布局(清单 2-3)。现在让我们看看自定义DurationControl是如何在活动的布局中使用的。这是充分体会merge在自定义布局文件中的效果的重要一点。

在活动布局中指定持续时间控制

注意清单 2-2 中的merge除了xmlns规范之外没有其他属性。你说布局清单 2-2(或图 1-1 )中的控件需要垂直布局在哪里?这不是在merge节点完成的,而是在活动的布局中指定DurationControl时完成的,如清单 2-4 所示。清单 2-4 是您在图 1-1 所示的活动中看到的布局。

清单 2-4 活动布局中的持续时间控件

<?xml version="1.0" encoding="utf-8"?>

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android

xmlns:cc="http://schemas.android.com/apk/res/com.androidbook.compoundControls

android:orientation="vertical"

android:layout_width="fill_parent"  android:layout_height="match_parent">

<TextView

android:id="@+id/text2"

android:layout_width="fill_parent" android:layout_height="wrap_content"

android:text="Welcome to the Compound Controls"/>

/>

<com.androidbook.compoundControls.DurationControl

android:id="@+id/durationControlId" android:orientation="vertical"

android:layout_width="fill_parent" android:layout_height="wrap_content"

cc:durationUnits="weeks"

/>

<TextView

android:id="@+id/text1"

android:layout_width="fill_parent" android:layout_height="wrap_content"

android:text="Scratch for debug text"

/>

</LinearLayout>

注意这个布局中的第二个节点是您的定制控件DurationControl。看看DurationControl节点是如何被指定的,就像线性布局一样。

到目前为止,您已经获得了我们的自定义控件的外观和感觉,使其与图 1-1 中的内容相匹配。我们现在将关注它的行为。这就引出了对片段对话框的讨论,因为我们需要它们来捕获“从”和“到”的日期。

使用片段对话框

碰巧我们的复合控件使用类似于date picker的片段对话框来计算“从”和“到”的日期。如果您想在一个视图中隔离这个功能,以便在复合定制视图之外隐藏这个行为,那么使用片段管理器并不是那么简单。在这一节中,我们将一步一步地展示如何从一个自包含的复合控件(如DurationControl)中使用片段对话框。

访问片段管理器

我们知道我们想要调用一个date picker对话框。Android 中调用对话框的新方法是使用片段。要使用这些对话片段,您需要访问片段管理器。但是因为视图不能直接访问片段管理器,所以您需要一个活动来获取与该活动相关联的片段管理器。即使这样做——从视图中获取活动——也没有直接的方法。

视图只能访问其上下文,而不能访问活动。虽然活动是一种上下文,但是视图可能在不是活动的上下文中操作。所以,如果你想使用片段对话框,你需要做一些假设。这个特殊的控件严重依赖于片段对话框。如果你得不到一个片段管理器,那根本不行。所以你假设你只在一个活动的上下文中工作。一旦你使用了这个参数,清单 2-5 显示了如何从视图中获得一个片段管理器。

清单 2-5。访问片段管理器

Public class DurationControl...{

private FragmentManager getFragmentManager() {

Context c = getContext();

if (c instanceof Activity) {

return ((Activity)c).getFragmentManager();

}

throw new RuntimeException("Activity context expected instead");

}

...} //end-of-class DurationControl

在设计自定义复合控件时,可能需要也可能不需要使用片段对话框。这取决于复合控制的需要和性质。在这一章中,我们将讨论使用片段对话框的更困难的情况。因为这个组件使用片段对话框,所以我们处于这样的困境:假设在View类中可用的上下文是一个活动。你可以有一个不调用片段对话框的复合控件,你不需要做这样的假设;那样的话,就不需要做这种不必要的假设了。

调用片段对话框

既然您已经有了获取片段管理器的方法,那么您可以使用清单 2-6 中的代码来调用片段对话框。当触摸/按下图 1-1 中的任何 GO 按钮时,清单 2-6 中的onclick将被调用。根据按下的按钮,调用date picker片段对话框,并传递按钮 ID。该按钮 ID 允许date picker对话框将按钮 ID 发送回DurationControl以知道要设置哪个日期(“从”或“到”)。

清单 2-6。调用片段对话框

public class DurationControl {

...

public void onClick(View v)

{

Button b = (Button)v;

if (b.getId() == R.id.fromButton)

{

DialogFragment newFragment = new DatePickerFragment(this,R.id.fromButton);

newFragment.show(getFragmentManager(), "com.androidbook.tags.datePicker");

return;

}

//Otherwise – to button

DialogFragment newFragment = new DatePickerFragment(this,R.id.toButton);

newFragment.show(getFragmentManager(), "com.androidbook.tags.datePicker");

return;

}//eof-onclick

...

}//eof-class DurationControl

如果你在看清单 2-6,你会注意到我们没有向你展示片段对话框DatePickerFragment的代码。然而,你会看到DatePickerFragment需要一个片段管理器来工作(参见清单 2-6 中对show()方法的调用)。在上一节中,我们展示了如何通过合理的假设获得片段管理器。我们现在展示DatePickerFragment类。

正在创建 DatePickerFragment 类

清单 2-7 显示了DatePickerFragment的代码。这个定制类封装了 SDK 提供的DatePickerDialog。它是在新的 Android SDK 中显示对话框的标准机制(从 Android release 3 开始)。

清单 2-7。DatePickerFragment 的源代码

public class DatePickerFragment extends DialogFragment

implements DatePickerDialog.OnDateSetListener

{

public static String tag = "DatePickerFragment";

private DurationControl parent;

private int buttonId;

public DatePickerFragment(DurationControl inParent, int inButtonId)

{

parent = inParent;

buttonId = inButtonId;

Bundle argsBundle = this.getArguments();

if (argsBundle == null)

{

argsBundle = new Bundle();

}

argsBundle.putInt("parentid", inParent.getId());

argsBundle.putInt("buttonid", buttonId);

this.setArguments(argsBundle);

}

//Default constructor for device rotation

public DatePickerFragment(){}

@Override

public Dialog onCreateDialog(Bundle savedInstanceState)

{

//this.establishParent();

// Use the current date as the default date in the picker

final Calendar c = Calendar.getInstance();

int year = c.get(Calendar.YEAR);

int month = c.get(Calendar.MONTH);

int day = c.get(Calendar.DAY_OF_MONTH);

// Create a new instance of DatePickerDialog and return it

return new DatePickerDialog(getActivity(), this, year, month, day);

}

public void onDateSet(DatePicker view, int year, int month, int day) {

// Do something with the date chosen by the user

parent.onDateSet(buttonId, year, month, day);

}

@Override

public void onActivityCreated(Bundle savedInstanceState) {

super.onActivityCreated(savedInstanceState);

Log.d(tag,"DatePickerFragment onActivity created called");

this.establishParent();

}

private void establishParent()   {

if (parent != null) return;

Log.d(tag, "establishing parent");

int parentid = this.getArguments().getInt("parentid");

buttonId =  this.getArguments().getInt("buttonid");

View x = this.getActivity().findViewById(parentid);

if (x == null)    {

throw new RuntimeException("Sorry not able to establish parent on restart");

}

parent = (DurationControl)x;

}

} //eof-class-DatePickerFragment

虽然这是显示对话框的标准方法,但是我们已经做了一些事情来使这些片段对话框很好地为像我们这样的自定义复合控件工作。正如我们在本章开始时指出的,当这个片段对话框显示时,设备可以旋转。我们需要设计DatePickerFragment类,以便它考虑到设备旋转。

构造日期选择器片段

参考清单 2-8,让我们重点看看DatePickerFragment的构造函数。我们有几件事要指出来。

清单 2-8。在片段构造函数中使用包

public DatePickerFragment(DurationControl inParent, int inButtonId)

{

parent = inParent;

buttonId = inButtonId;

Bundle argsBundle = this.getArguments();

if (argsBundle == null)  {

argsBundle = new Bundle();

}

argsBundle.putInt("parentid", inParent.getId());

argsBundle.putInt("buttonid", buttonId);

this.setArguments(argsBundle);

}

//Default constructor for device rotation

public DatePickerFragment(){}

因为DatePickerFragment是一个对话框片段,它需要告诉调用者(我们的自定义复合视图,DurationControl)这个对话框已经完成了日期的选择。为了便于与DurationControl的交流,我们在DatePickerFragment的构建过程中通过parent参数传递了对DurationControl的引用。

然后,DatePickerFragment将这个引用保存为一个局部变量。这个parent引用稍后用于在对话结束时回调对话片段。我们还将按钮 ID 作为DatePickerFragment构造函数的输入。我们将把按钮 ID 传递给回调函数,以便DurationControl知道要设置哪个日期字段。

一旦我们将DurationControl和按钮 ID 作为本地变量存储在DatePickerFragment中,我们就做了一些有趣的事情,至少是不典型的(尤其是如果你是 Android 和 fragments 的新手!).我们获取DurationControl的视图 ID 和传入的按钮 ID,并将它们填充到DatePickerFragment的参数包中。

Note

参考关于碎片的文献,包括我们自己的 Pro Android 4,当设备旋转时,参数包如何管理它们的状态。

您还会注意到清单 2-8 中片段对话框的默认构造函数。什么时候调用默认构造函数?为什么这个默认构造函数是空的?调用默认构造函数时,所有重要的局部变量parentbuttonid会发生什么?谁设定的?我们将在下一节回答这些问题。

还原对话片段状态

当设备旋转时,调用清单 2-8 中对话框片段的默认构造函数。Android 删除活动及其所有片段,并重新实例化它们。Android 在这个重新实例化阶段调用默认的构造函数。它依靠大量与状态相关的回调来重新初始化这些对象。

当调用这个默认构造函数时,有一个不完整的对话框片段类:对DurationControl的父引用和按钮 ID 还没有填充。要设置这些变量,您需要在 dialog fragment 类上寻找一个回调,它会告诉您活动中的所有视图何时被重新创建。你要找的片段上的回调是onActivityCreated()。尽管一个片段在重新初始化阶段有许多回调,但是正是这个onActivityCreated()方法保证了所有的视图都已经被创建。

当一个片段被重新创建时,它会记住您最初在这个片段上设置的参数包。在onActivityCreated回调中,您将使用来自参数包的 ID,通过使用其视图 ID 来定位父DurationControl。清单 2-9 显示了这是如何做到的。

清单 2-9。在片段再生期间重新建立指针

@Override

public void onActivityCreated(Bundle savedInstanceState) {

super.onActivityCreated(savedInstanceState);

this.establishParent();

}

private void establishParent()

{

if (parent != null) return;

//get parent view id from the arguments bundle

int parentid = this.getArguments().getInt("parentid");

buttonId =  this.getArguments().getInt("buttonid");

//Look for the parent view in the activity

View x = this.getActivity().findViewById(parentid);

if (x == null) {

throw new RuntimeException("Sorry not able to establish parent on restart");

}

//Get back our DurationControl parent

parent = (DurationControl)x;

}

给父母回电

一旦日期选择器对话框片段重新建立了父级,如清单 2-9 所示,该对话框片段就处于良好的构造状态。它可以访问按钮 ID 和父视图DurationControl。现在如果从日期选择器对话框中选择一个日期(如图 2-4 ),那么DatePickerFragment将调用父节点的onDateSet方法。下面是清单 2-10 所示的DurationControl的回调方法。

清单 2-10。在复合控件中实现片段对话框回调

public class DurationControl...{

...

public void onDateSet(int buttonId, int year, int month, int day)   {

Calendar c = getDate(year,month,day);

if (buttonId == R.id.fromButton) {

setFromDate(c);

return;

}

setToDate(c);

}

private void setFromDate(Calendar c)   {

if (c == null) return;

this.fromDate = c;

TextView tc = (TextView)findViewById(R.id.fromDate);

tc.setText(getDateString(c));

}

private void setToDate(Calendar c)   {

if (c == null) return;

this.toDate = c;

TextView tc = (TextView)findViewById(R.id.toDate);

tc.setText(getDateString(c));

}

private Calendar getDate(int year, int month, int day)   {

Calendar c = Calendar.getInstance();

c.set(year,month,day);

return c;

}

public static String getDateString(Calendar c)   {

if(c == null) return "null";

SimpleDateFormat df = new SimpleDateFormat("MM/dd/yyyy");

df.setLenient(false);

String s = df.format(c.getTime());

return s;

}

...more

}//eof-class-DurationControl

清单 2-10 突出显示了DurationControl的这个键回调方法onDateSet。现在,您可以看到为什么我们努力将按钮 ID 传递给对话框片段。在这个onDateSet回调方法中,我们询问调用片段对话框的按钮是“从”日期按钮还是“到”日期按钮。在清单 2-10 中,还可以看到我们如何定位属于相应日期控件的文本视图并设置它的文本。

这就总结了在复合控件中使用基于片段的对话框的复杂性。总而言之,(a)你必须得到一个片段管理器,(b)你必须将回调对象传递给片段对话框,(c)你必须使用参数束来保存状态,以及(d)你必须使用onActivityCreated来重新建立回调指针。至此,我们转向复合控件的下一个主题:管理视图状态。

管理复合控件的视图状态

当我们谈到自定义视图时,我们在第一章中详细讨论了视图状态管理。管理复合控件的视图状态是相似的,但也有一些不同。

如果在一个活动中多次使用类似于DurationControl的复合控件,那么文本视图和按钮视图的 id 就会重复。这对于基于 Android 的视图类所设计的协议来管理他们自己的视图状态是不可行的,Android 的视图类在给定活动的上下文中需要一个唯一的视图 id。

为了克服这个问题,您可以使用一个复合控件来阻止其子控件管理它们的视图状态,而代之以为它们管理它们的状态。为了理解这种视图状态管理的方法,您需要理解来自ViewGroup类的四个基类方法。这些如清单 2-11 所示。

清单 2-11。视图组视图状态管理的相关 API

dispatchSaveInstanceState

dispatchFreezeSelfOnly

dispatchRestoreInstanceState

dispatchThawSelfOnly

一个ViewGroup使用dispatchSaveInstanceState首先通过调用超级(视图的)dispatchSaveInstanceState来保存它自己的状态,这又在它自己身上触发onSaveInstanceState,然后为它的每个孩子调用dispatchSaveInstanceState。如果孩子是普通的views而不是ViewGroups,这将导致调用他们的onSaveInstanceState。清单 2-12 展示了这些关键方法是如何结合在一起的伪代码。

清单 2-12。关于分派存储实例如何工作的伪代码

ViewGroup.dispatchSaveInstanceState() {

View.dispatchSaveInstanceState()

...ends up calling its own ViewGroup.onSaveInstanceState()

Children.dispatchSaveInstanceState()

...ends up calling children's onSaveInstanceState()

}

View.dispatchSaveInstanceState() {

onSaveInstanceState()

}

ViewGroup.dispatchFreezeSelfOnly() {

View.dispatchSaveInstanceState()

...ends up calling ViewGroup.onSaveInstanceState()

}

注意这里dispatchFreezeSelfOnly的褶皱。ViewGroup上的这个方法只是调用为自己保存状态的语义,因为它最终调用了当前视图组的onSaveInstanceState。在使用等效方法的恢复阶段也会发生同样的事情。

知道了这个复杂的协议,你可以覆盖清单 2-13 中所示的ViewGroup的适当方法来抑制子视图的状态保存,并在视图组本身中实现状态管理。

清单 2-13。重写复合控件的 dispatchSaveInstanceState

@Override

protected void dispatchSaveInstanceState(SparseArray<Parcelable> container)

{

//Don't call this so that children won't be explicitly saved

//super.dispatchSaveInstanceState(container);

//Call your self onsavedinstancestate

super.dispatchFreezeSelfOnly(container);

}

当你这样做时,你是在召唤自己,而不是你的孩子。您对dispatchRestoreInstanceState做同样的事情,如清单 2-14 所示。

清单 2-14。为复合控件重写 dispatchRestorInstanceState

@Override

protected void dispatchRestoreInstanceState(

SparseArray<Parcelable> container)

{

//Don't call this so that children won't be explicitly saved

//super.dispatchRestoreInstanceState(container);

super.dispatchThawSelfOnly(container);

}

通过覆盖这两个方法(清单 2-13 和 2-14),您已经支持了子状态管理。我们现在向您展示我们的DurationControl上的onSaveInstanceStateonRestoreInstanceState的代码,它负责管理它的四个子节点的状态:两个文本视图和两个按钮。当然,按钮没有状态,但是两个文本视图有。然而,在向您展示onSaveInstanceStateonRestoreInstanceState的代码之前,我们向您展示这个DurationControl如何实现它自己的SavedState类,这是这两个方法所需要的。(参见第一章了解这种类型的BaseSavedState保存状态模式。)

为 DurationControl 实现 SavedState 类

清单 2-15 显示了保存DurationControl及其子节点状态的SavedState类。它遵循第一章的“BaseSavedState 模式”一节中的相同模式。我们感兴趣保存的两个变量是两个日期:“从”日期和“到”日期。

清单 2-15。自定义 DurationControl 的 SavedState 类实现

public static class SavedState extends BaseSavedState {

//null values are allowed

private Calendar fromDate;

private Calendar toDate;

SavedState(Parcelable superState) {

super(superState);

}

SavedState(Parcelable superState, Calendar inFromDate, Calendar inToDate) {

super(superState);

fromDate = inFromDate;

toDate = inToDate;

}

@Override

public void writeToParcel(Parcel out, int flags) {

super.writeToParcel(out, flags);

if (fromDate != null) {

out.writeLong(fromDate.getTimeInMillis());

}

else {

out.writeLong(-1L);

}

if (fromDate != null) {

out.writeLong(toDate.getTimeInMillis());

}

else {

out.writeLong(-1L);

}

}

@Override

public String toString() {

StringBuffer sb  = new StringBuffer("fromDate:"

+ DurationControl.getDateString(fromDate));

sb.append("fromDate:" + DurationControl.getDateString(toDate));

return sb.toString();

}

@SuppressWarnings("hiding")

public static final Parcelable.Creator<SavedState> CREATOR

= new Parcelable.Creator<SavedState>() {

public SavedState createFromParcel(Parcel in) {

return new SavedState(in);

}

public SavedState[] newArray(int size) {

return new SavedState[size];

}

};

//Read back the values

private SavedState(Parcel in) {

super(in);

//Read the from date

long lFromDate = in.readLong();

if (lFromDate == -1) {

fromDate = null;

}

else {

fromDate = Calendar.getInstance();

fromDate.setTimeInMillis(lFromDate);

}

//Read the from date

long lToDate = in.readLong();

if (lFromDate == -1) {

toDate = null;

}

else {

toDate = Calendar.getInstance();

toDate.setTimeInMillis(lToDate);

}

}

}//eof-state-class

我们将需要保存和恢复的两个日期表示为 Java 日历对象。当我们将它们存储在parcelable中时,我们以longs(毫秒)的形式存储它们,以longs的形式检索它们,并将它们转换回日历对象。除此之外,清单 2-15 的代码与第一章中给出的非常相似。现在让我们看看如何使用这个SavedState类来恢复子文本视图。

代表子视图实现保存和恢复状态

清单 2-16 显示了我们的DurationControlsaverestore方法的实现,使用了清单 2-15 中设计的SavedState类。您从SavedState parcelable 中检索日期,并使用 set 方法设置文本视图的值。日期在SavedState类(清单 2-15)中作为公共变量提供。在DurationControl上设置日期的方法在DurationControl类中可用。我们还没有向您展示这两个setdate方法,但是我们在本章末尾展示的DurationControl的整个源代码中包含了它们(清单 2-19)。清单 2-16 中的方法onRestoreInstanceStateonSaveInstatanceState也在DurationControl类中。这两个状态方法最初是在基类ViewGroup上定义的,在清单 2-16 中,你在DurationControl类中覆盖了这些方法。您还可以在清单 2-19 中看到这些方法的完整上下文。

清单 2-16。使用 SavedState 管理子视图状态

@Override

protected void onRestoreInstanceState(Parcelable state) {

if (!(state instanceof SavedState)) {

super.onRestoreInstanceState(state);

return;

}

//it is our state

SavedState ss = (SavedState)state;

//Peel it and give the child to the super class

super.onRestoreInstanceState(ss.getSuperState());

this.setFromDate(ss.fromDate);

this.setToDate(ss.toDate);

}

@Override

protected Parcelable onSaveInstanceState() {

Parcelable superState = super.onSaveInstanceState();

SavedState ss = new SavedState(superState);

ss.fromDate = this.fromDate;

ss.toDate = this.toDate;

//Or you can do this

//SavedState ss = new SavedState(superState,fromDate,toDate);

return ss;

}

这将我们带到创建自定义复合控件中的最后一个主题:定义和使用自定义属性。我们现在讨论这个问题。

为 DurationControl 创建自定义属性

定义和使用自定义属性与我们在第一章中介绍的自定义视图相同。清单 2-17 显示了我们为这个类定义的单个自定义属性。

清单 2-17。attrs.xml for DurationControl

<resources>

<declare-styleable name="DurationComponent">

<attr name="durationUnits">

<enum name="days" value="1"/>

<enum name="weeks" value="2"/>

</attr>

</declare-styleable>

</resources>

自定义属性durationUnits表示您希望自定义控件返回天数还是周数。(我们同意;这是一个蹩脚的自定义属性,但我们只是想向您展示这是如何做到的,并且您有一个编写自己的自定义属性的示例可以遵循。)

一旦定义了自定义属性,清单 2-18 显示了如何在DurationControl的构造函数中读取该属性。

清单 2-18。用自定义属性初始化 DurationControl

public DurationControl(Context context) {

super(context);

initialize(context);

}

public DurationControl(Context context, AttributeSet attrs, int defStyle) {

super(context, attrs, defStyle);

TypedArray t = context.obtainStyledAttributes(attrs

R.styleable.DurationComponent,0,0);

durationUnits = t.getInt(

R.styleable.DurationComponent_durationUnits, durationUnits);

t.recycle();

initialize(context);

}

public DurationControl(Context context, AttributeSet attrs) {

this(context, attrs,0);

}

private void initialize(Context context)  {

LayoutInflater lif = (LayoutInflater)

context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);

lif.inflate(R.layout.duration_view_layout, this);

Button b = (Button)this.findViewById(R.id.fromButton);

b.setOnClickListener(this);

b = (Button)this.findViewById(R.id.toButton);

b.setOnClickListener(this);

this.setSaveEnabled(true);

}

我们对创建定制复合控件的细节的介绍到此结束。参见本章后面的“DurationControl 的实现细节”一节,了解在一个地方实现DurationControl所需的所有源代码。

扩展现有视图

我们到目前为止所介绍的内容以及我们在第一章中所介绍的内容为创建自定义视图或扩展现有视图奠定了良好的基础。此外,在本章中,我们展示了如何扩展布局视图。然而,我们还没有展示如何扩展像TextView这样更简单的已有视图。知道你现在做什么,这应该是在公园散步。扩展现有视图如TextView的步骤如下:

Extend from TextView. TextView will take care of measuring, drawing, etc.   In the constructor, read any custom attributes you may have defined in attrs.xml.   Implement any view state that you want to manage yourself in addition to TextView or just delegate that to the TextView.

我们将把这项工作留给你,作为测试你对主题掌握程度的练习。

持续控制的实施细节

如果你对我们在这一章中讨论的所有内容都很熟悉,你可以自己动手整理,你不需要阅读这一节,在这一节中,我们将向你展示所有相关的代码文件。其中一些档案已经完整呈现;对于这些文件,我们只是简单地引用它们以前的清单。对于某些文件,我们在下面复制了它们,这样这些文件的完整源代码就在一个地方。我们从初级类DurationControl.java的源代码开始。

DurationControl.java

清单 2-19 中的类DurationControl承担了大部分的实现负担。

清单 2-19。DurationControl.java

public class DurationControl extends LinearLayout

implements    android.view.View.OnClickListener

{

private static final String tag = "DurationControl";

private Calendar fromDate = null;

private Calendar toDate = null;

// 1: days, 2: weeks

private static int ENUM_DAYS = 1;

private static int ENUM_WEEKS = 1;

private int durationUnits = 1;

// public interface

public long getDuration() {

if (validate() == false)

return -1;

long fromMillis = fromDate.getTimeInMillis();

long toMillis = toDate.getTimeInMillis();

long diff = toMillis - fromMillis;

long day = 24 * 60 * 60 * 1000;

long diffInDays = diff / day;

long diffInWeeks = diff / (day * 7);

if (durationUnits == ENUM_WEEKS) {

return diffInDays;

}

return diffInWeeks;

}

public boolean validate() {

if (fromDate == null || toDate == null) {

return false;

}

if (toDate.after(fromDate)) {

return true;

}

return false;

}

public DurationControl(Context context) {

super(context);

initialize(context);

}

public DurationControl(Context context, AttributeSet attrs, int defStyle) {

super(context, attrs, defStyle);

TypedArray t = context.obtainStyledAttributes(attrs

R.styleable.DurationComponent, 0, 0);

durationUnits = t.getInt(R.styleable.DurationComponent_durationUnits

durationUnits);

t.recycle();

initialize(context);

}

public DurationControl(Context context, AttributeSet attrs) {

this(context, attrs, 0);

}

private void initialize(Context context) {

LayoutInflater lif = (LayoutInflater) context

.getSystemService(Context.LAYOUT_INFLATER_SERVICE);

lif.inflate(R.layout.duration_view_layout, this);

Button b = (Button) this.findViewById(R.id.fromButton);

b.setOnClickListener(this);

b = (Button) this.findViewById(R.id.toButton);

b.setOnClickListener(this);

this.setSaveEnabled(true);

}

private FragmentManager getFragmentManager() {

Context c = getContext();

if (c instanceof Activity) {

return ((Activity) c).getFragmentManager();

}

throw new RuntimeException("Activity context expected instead");

}

public void onClick(View v) {

Button b = (Button) v;

if (b.getId() == R.id.fromButton) {

DialogFragment newFragment = new DatePickerFragment(this

R.id.fromButton);

newFragment.show(getFragmentManager()

"com.androidbook.tags.datePicker");

return;

}

// Otherwise

DialogFragment newFragment = new DatePickerFragment(this, R.id.toButton);

newFragment.show(getFragmentManager()

"com.androidbook.tags.datePicker");

return;

}// eof-onclick

public void onDateSet(int buttonId, int year, int month, int day) {

Calendar c = getDate(year, month, day);

if (buttonId == R.id.fromButton) {

setFromDate(c);

return;

}

setToDate(c);

}

private void setFromDate(Calendar c) {

if (c == null)

return;

this.fromDate = c;

TextView tc = (TextView) findViewById(R.id.fromDate);

tc.setText(getDateString(c));

}

private void setToDate(Calendar c) {

if (c == null)

return;

this.toDate = c;

TextView tc = (TextView) findViewById(R.id.toDate);

tc.setText(getDateString(c));

}

private Calendar getDate(int year, int month, int day) {

Calendar c = Calendar.getInstance();

c.set(year, month, day);

return c;

}

public static String getDateString(Calendar c) {

if (c == null)

return "null";

SimpleDateFormat df = new SimpleDateFormat("MM/dd/yyyy");

df.setLenient(false);

String s = df.format(c.getTime());

return s;

}

@Override

protected void dispatchSaveInstanceState(SparseArray<Parcelable> container) {

// Don't call this so that children won't be explicitly saved

// super.dispatchSaveInstanceState(container);

// Call your self onsavedinstancestate

super.dispatchFreezeSelfOnly(container);

Log.d(tag, "in dispatchSaveInstanceState");

}

@Override

protected void dispatchRestoreInstanceState(

SparseArray<Parcelable> container) {

// Don't call this so that children won't be explicitly saved

// .super.dispatchRestoreInstanceState(container);

super.dispatchThawSelfOnly(container);

Log.d(tag, "in dispatchRestoreInstanceState");

}

@Override

protected void onRestoreInstanceState(Parcelable state) {

Log.d(tag, "in onRestoreInstanceState");

if (!(state instanceof SavedState)) {

super.onRestoreInstanceState(state);

return;

}

// it is our state

SavedState ss = (SavedState) state;

// Peel it and give the child to the super class

super.onRestoreInstanceState(ss.getSuperState());

// this.fromDate = ss.fromDate;

// this.toDate= ss.toDate;

this.setFromDate(ss.fromDate);

this.setToDate(ss.toDate);

}

@Override

protected Parcelable onSaveInstanceState() {

Log.d(tag, "in onSaveInstanceState");

Parcelable superState = super.onSaveInstanceState();

SavedState ss = new SavedState(superState);

ss.fromDate = this.fromDate;

ss.toDate = this.toDate;

return ss;

}

/*

* ***************************************************************

* Saved State inner static class

* ***************************************************************

*/

public static class SavedState extends BaseSavedState {

//null values are allowed

private Calendar fromDate;

private Calendar toDate;

SavedState(Parcelable superState) {

super(superState);

}

@Override

public void writeToParcel(Parcel out, int flags) {

super.writeToParcel(out, flags);

if (fromDate != null) {

out.writeLong(fromDate.getTimeInMillis());

} else {

out.writeLong(-1L);

}

if (fromDate != null) {

out.writeLong(toDate.getTimeInMillis());

} else {

out.writeLong(-1L);

}

}

@Override

public String toString() {

StringBuffer sb = new StringBuffer("fromDate:"

+ DurationControl.getDateString(fromDate));

sb.append("fromDate:" + DurationControl.getDateString(toDate));

return sb.toString();

}

@SuppressWarnings("hiding")

public static final Parcelable.Creator<SavedState> CREATOR

= new Parcelable.Creator<SavedState>() {

public SavedState createFromParcel(Parcel in) {

return new SavedState(in);

}

public SavedState[] newArray(int size) {

return new SavedState[size];

}

};

// Read back the values

private SavedState(Parcel in) {

super(in);

// Read the from date

long lFromDate = in.readLong();

if (lFromDate == -1) {

fromDate = null;

} else {

fromDate = Calendar.getInstance();

fromDate.setTimeInMillis(lFromDate);

}

// Read the from date

long lToDate = in.readLong();

if (lFromDate == -1) {

toDate = null;

} else {

toDate = Calendar.getInstance();

toDate.setTimeInMillis(lToDate);

}

}

}// eof-state-class

}// eof-class

我们已经在本章前面讨论和辩论了这个代码的所有方面。然而,我们给这段代码添加了一个新方法,叫做validate()。这允许活动查看该活动中的所有视图是否都处于良好状态。这是你可以自己设计的,但是在这里只是作为一个建议。

/layout/duration _ view _ layout . XML

这是清单 2-19 中控件DurationControl的自定义布局文件。一个定制的布局文件,它被加载到清单 2-19 的构造函数中。清单 2-2 中已经给出了完整的布局 XML 文件。

datepicker 片段. java

这是我们用来显示日期选择器对话框的对话框片段类。这个类的源代码如清单 2-7 所示。

主活动 XML 文件

这是我们在线性活动布局中放置DurationControl的文件。清单 2-4 给出了这个文件。

/values/attrs.xml

这是我们定义自定义属性的地方。清单 2-17 给出了这个文件。

我们还包含了一个链接,可以让您下载整个项目,您可以使用它在参考资料中构建 eclipse。

参考

第一章中引用的大部分资料也适用于本章。因此,我们在此仅列出本章特有的参考文献。

摘要

在本章中,我们介绍了如何通过聚合现有组件来创建复合组件。我们展示了如何扩展一个现有的视图组,如 linear layout,以拥有自定义的子视图,以及如何将它专用于特定的行为。我们展示了如何将这种方法与片段对话框集成。重要的是,我们展示了如何恢复片段对话框的回调指针。我们还展示了复合控件的视图状态管理的细微差异。

复习问题

以下问题有助于巩固你在本章中所学的知识。

What classes do you extend to create compound controls?   What is merge node in XML layouts?   How do you load layout XML files in the constructor of a compound control?   How do you use fragment dialogs from custom compound controls?   How do you reestablish callback views for fragment dialogs?   How do you manage state for a custom compound control?   What is dispatchFreezeSelfOnly and why do you care?   How do you define and use custom attributes for a compound control?

三、自定义布局的原则和实践

Abstract

在前两章中,我们介绍了如何创建和使用自定义视图和复合控件。我们现在处于视图定制之旅的第三站:创建和使用定制布局。前三章的内容显然是相关的。该材料依赖于与第一章中所述相同的视图基础架构。然而,每一章都表达了视图架构的不同方面。

在前两章中,我们介绍了如何创建和使用自定义视图和复合控件。我们现在处于视图定制之旅的第三站:创建和使用定制布局。前三章的内容显然是相关的。该材料依赖于与第一章中所述相同的视图基础架构。然而,每一章都表达了视图架构的不同方面。

对比自定义布局

让我们花点时间来比较和对比 Android UI 框架中定制控件的三种方法:定制视图、复合控件和定制布局。这种比较有助于我们关注定制布局的特殊材料:从ViewGroup开始扩展,测量子视图,布置子视图。

自定义视图

当我们设计定制视图时,主要关注的是onMeasure()和onDraw()。自定义视图没有子视图。所以像onLayout()这样的方法并不适用,因为onLayout()是给孩子用的。视图的基础layout()方法负责这一点。另外,onMeasure()非常简单,因为您只处理一个视图。你的注意力主要转移到onDraw()以及如何使用画布上。

当然,在这三种方法中,定义和读取定制属性的方式非常相似。因为自定义视图没有子视图,所以布局参数在自定义视图中不起作用,但在自定义布局中起重要作用。在定制视图中,我们还担心使用BaseSavedState模式保存视图的状态。总之,在定制视图时,您必须考虑以下事项:

  • 扩展View
  • 覆盖onMeasure()
  • 覆盖onDraw()
  • 使用BaseSavedState模式保存状态
  • 使用自定义属性
  • 理解和应用 requestLayout 和 invalidate

相反,在创建自定义视图时,您可以忽略以下细节:

  • 覆盖onLayout()
  • 实施和使用LayoutParams

复合控件

对于复合控件,主要关注的是如何使用现有的布局,如线性布局和相对布局,来组成具有特定行为的合并组件。

因为我们使用的是现有的布局,所以不需要测量、布局或绘图。我们在复合控件中的主要焦点是使用BaseSavedState模式为复合控件本身及其子控件保存视图状态。当然,我们也可以使用自定义属性。所以对于复合控件,你需要担心:

  • 扩展现有布局
  • 使用BaseSavedState模式保存状态
  • 控制其子视图的保存状态
  • 使用自定义属性

虽然你可以忽略:

  • 覆盖onMeasure()
  • 覆盖onDraw()
  • 覆盖onLayout()
  • 担心requestLayout()invalidate()
  • 实施和使用LayoutParams

自定义布局

现在,您可以将上述两种方法与实现自定义布局所需的以下步骤进行对比(第三种方法):

Inherit from ViewGroup   Override onMeasure()   Override onLayout()   Implement custom LayoutParams with any additional layout attributes   Override layout parameters construction methods in the custom layout class

我们解释了每一个步骤,并提供了一个带注释的示例代码。首先,我们展示了我们计划编写的自定义布局,作为一个示例来说明所有指示的步骤。

规划简单的流程布局

为了记录像线性布局这样的自定义布局的创建,我们使用流布局作为示例。为了演示流布局,我们将一组按钮水平放置,当水平空间用完时,将它们绕到下一行。图 3-1 显示了我们将要设计的流程布局,布置在一个活动中。

A978-1-4302-4951-1_3_Fig1_HTML.jpg

图 3-1。

A set of buttons encapsulated in a custom flow layout

在图 3-1 中,整个活动呈线性布局。流布局位于中间,在显示“欢迎使用流布局”的文本视图和底部显示“调试文本暂存”的文本视图之间您可以看到流布局采用了许多大小不同的按钮,并将它们包裹起来。

到本章结束时,你会对如何做好这一点有一个清晰的想法,也将有基础来编写你自己的自定义布局。自定义布局有点棘手,但是对于简化 UI 设计来说很实用。我们在这里的目的是让你拥有自信地使用定制布局所需的一切。

现在你已经有了一个FlowLayout的心理图像,我们开始探索实现它所需的每个步骤。

从 ViewGroup 类派生

正如在内置的LinearLayout, FlowLayout中扩展了ViewGroup。清单 3-1 显示了FlowLayout如何扩展一个ViewGroup。该清单还显示了 a)?? 如何使用构造函数读取其特定的定制属性,以及 b)适当地初始化超类 ??。

清单 3-1。从视图组扩展的 FlowLayout

public class FlowLayout

extends ViewGroup

{

private int hspace=10;

private int vspace=10;

public FlowLayout(Context context) {

super(context);

initialize(context);

}

public FlowLayout(Context context, AttributeSet attrs, int defStyle) {

super(context, attrs, defStyle);

TypedArray t = context.obtainStyledAttributes(attrs

R.styleable.FlowLayout, 0, 0);

hspace = t.getDimensionPixelSize(R.styleable.FlowLayout_hspace, hspace);

vspace = t.getDimensionPixelSize(R.styleable.FlowLayout_vspace, vspace);

t.recycle();

initialize(context);

}

public FlowLayout(Context context, AttributeSet attrs) {

this(context, attrs, 0);

}

private void initialize(Context context) {

//Do any common initializations you may have

//It is empty in our implementation

}

清单 3-1 中的代码很像第一章和第二章中讨论的自定义视图的任何其他扩展:扩展一个基类(View,或者一个现有的布局,比如LinearLayout,或者这里的ViewGroup)。在所有情况下,您都可以拥有自定义属性;我们在attrs.xml中定义自定义属性,并在构造函数中读取它们。在FlowLayout的情况下,自定义属性是“hspace”(水平间距)和“vspace”(垂直间距),用于子视图和换行时的新行。现在让我们看看清单 3-1 中构造函数代码的attrs.xml

在 Attrs.xml 中为 FlowLayout 定义自定义属性

清单 3-2 显示了我们自定义的FlowLayoutattrs.xml。这个文件需要在“/res/values”子目录中。

清单 3-2。为 FlowLayout 定义自定义属性

<resources>

<declare-styleable name="``FlowLayout

<attr name="hspace" format="dimension"/>

<attr name="vspace" format="dimension" />

</declare-styleable>

<declare-styleable name="``FlowLayout_Layout

<attr name="``layout_space

</declare-styleable>

</resources>

清单 3-2 中的 styleable FlowLayout定义了两个定制属性:hspacevspace。可样式化的FlowLayout_Layout自定义属性是为需要由FlowLayout定义为内部类的LayoutParams对象定义的。(我们很快会谈到这一点。)这些后来的定制布局参数代表父布局类存储在每个子视图中,如FlowLayout,以允许子视图在必要时覆盖任何父属性。

按照惯例,我们通过布局的名称来调用属于主布局的样式——在本例中是“FlowLayout.”同样按照惯例,我们用布局名称后跟“layout.来调用属于LayoutParams的样式。在清单 3-2 中,这个名称是“FlowLayout_layout.

在本章的后面你会看到这两种类型的属性是如何在布局文件中被赋值的(未来的清单 3-8)。你已经在清单 3-1 中看到了如何读取样式。稍后您将看到如何读取FlowLayout_layout styleables(未来的清单 3-5)。

使用 onMeasure()

以下是当你使用像FlowLayout这样的自定义布局时onMeasure()的关键方面。

当您扩展一个ViewGroup时,您需要首先度量子元素,并添加这些度量以作为ViewGroup的度量返回。在第一章的中,我们说过一个视图有一个名为“measure()”的方法,它将被视图的父视图调用,作为父视图的onMeasure()的一部分。在FlowLayout的情况下,FlowLayout是父代。图 3-1 中的按钮是子视图。所以FlowLayout需要在它的onMeasure()中调用每个子Button控件的measure()方法。

然而,有一个问题。你会渴望使用child.measure()。但是不要。相反,您需要使用ViewGroup.measureChild(),然后将这些度量值相加,得到您的总尺寸。这是因为measureChild()是一种聪明的方法,它考虑到了ViewGroup ( AT_MOSTUNSPECIFIEDEXACT)的测量规格,并在需要时用不同的测量规格适当地询问孩子。如果你调用child.measure(),那么你就要自己做这个逻辑。我们在本章末尾包含了源代码ViewGroup.measureChild()来帮助你理解这一点。(我们认为在这里包含冗长的代码会分散您对本节主要内容的注意力。)

一旦有了FlowLayout的总大小,也就是它所有子元素的总和,就调用预制方法resolveSize()来计算出流布局的最终测量大小。让我们进一步解释一下resolveSize()吧。假设你有很多孩子。子级的总测量大小可能超过流布局的父级为流布局建议的大小。当流布局的测量规格为UNSPECIFIED时,可以返回较大的尺寸。但是如果测量规格是AT_MOST,那么您需要返回建议的尺寸,并且不能超过它。测量规格也可以说EXACT。在这种情况下,也不能超过建议的精确大小。所有这些逻辑都由内置方法resolveSize()处理。该resolveSize()考虑了测量规格的变化,并将可能较大的测量尺寸裁剪为与onMeasure()的测量规格中指定的尺寸一致的适当尺寸。

一旦这个度量过程完成,您需要在需要定位子视图的地方覆盖onLayout()。此练习与测量相同,因为您需要知道每个视图的大小,以便可以一个接一个地放置视图,并在每个视图之间留出足够的垂直和水平空间。那么为什么要测量两次呢?如果您还能在测量过程中记住每个子视图的原点,那么您可以在onLayout()中使用该原点和每个视图的维度。要存储每个视图的原点,可以使用 layout parameters 对象,它代表父视图FlowLayout.与每个视图一起持有

这就结束了应该如何为FLowLayout实现onMeasure()的理论。在我们进入onMeasure()的实现之前,我们想要呈现一个建议尺寸、流布局的测量尺寸、子尺寸的测量尺寸以及它们如何相互关联的图形表示(图 3-2 )。我们还使用图 3-2 解释测量算法。

A978-1-4302-4951-1_3_Fig2_HTML.jpg

图 3-2。

Measuring a compound control: DurationControl

让我们快速浏览一下图 3-2 。值RWRH代表“实际宽度”和“实际高度”这是从FlowLayout的父节点建议的宽度和高度。我们从传递给onMeasure()方法的measurespec中接收这些值。当然,RHRW只对AT_MOSTEXACT有效。在UNSPECIFIED的情况下,像FlowLayout这样的子节点可以返回它的最大值,而不用考虑传入的大小。

在下一级,whFlowLayout的测量宽度和高度,通过结合子视图的测量宽度(vw =视图宽度)和测量高度(vh =视图高度)来确定。当我们浏览FlowLayout的每个子视图时,我们将相应地增加hw

在逻辑上,我们依赖于子视图的来源。在图 3-2 中,我们使用点(h1,w1)作为当前视图的原点。然后我们通过计算(h2,w2)来计算下一个视图的原点。当我们计算这个(h2,w2))时,我们考虑了水平和垂直方向上有多少可用空间。一旦我们测量了手头的当前视图并记录了它的原点,我们就移动到下一个视图并将(h2,w2)设置回(h1,w1)作为新的原点,并且重复这个过程直到我们穷尽所有的视图。(清单 3-3 给出了执行这个逻辑的实际源代码。)

在图 3-2 中,当我们浏览每个视图时,不难看出何时增加w以及何时增加h(当前需要的总宽度和高度)。宽度随着我们向右移动而增加;当我们检测到一个新的行时,高度增加。当前原点宽度(w1)与当前子宽度(VW)相加,并且加在一起超过了可用的实际宽度(RW,这就是一个新行。

给定这个逻辑的图示(图 3-2 ,清单 3-3 展示了我们是如何为FlowLayout实现onMeasure()的。

清单 3-3。实施 onMeasure()

//This is very basic

//doesn't take into account padding

//You can easily modify it to account for padding

@Override

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec)

{

//********************

//Initialize

//********************

int rw = MeasureSpec.getSize(widthMeasureSpec);

int rh = MeasureSpec.getSize(heightMeasureSpec);

int h = 0; //current height

int w = 0; //current width

int h1 = 0, w1=0; //Current point to hook the child to

//********************

//Loop through children

//********************

int numOfChildren = this.getChildCount();

for (int i=0; i < numOfChildren; i++ )

{

//********************

//Front of the loop

//********************

View child = this.getChildAt(i);

this.measureChild(child,widthMeasureSpec, heightMeasureSpec);

int vw = child.getMeasuredWidth();

int vh = child.getMeasuredHeight();

if (w1 + vw > rw)

{

//new line: max of current width and current width position

//when multiple lines are in play w could be maxed out

//or in uneven sizes is the max of the right side lines

//all lines don't have to have the same width

//some may be larger than others

w = Math.max(w,w1);

//reposition the point on the next line

w1 = 0; //start of the line

h1 = h1 + vh; //add view height to the current heigh

}

//********************

//Middle of the loop

//********************

int w2 = 0, h2 = 0; //new point for the next view

w2 = w1 + vw;

h2 = h1;

//latest height: current point + height of the view

//however if the previous height is larger use that one

h = Math.max(h,h1 + vh);

//********************

//Save the current origin coords for the view

//in its layout

//********************

LayoutParams lp = (LayoutParams)child.getLayoutParams();

lp.x = w1;

lp.y = h1;

//********************

//Restart the loop

//********************

w1=w2;

h1=h2;

}

//********************

//End of for

//********************

w = Math.max(w1,w);

//h = h;

setMeasuredDimension(

resolveSize(w, widthMeasureSpec)

resolveSize(h,heightMeasureSpec));

};

正如我们在onMeasure()的解释中指出的,点(h1,w1)代表每个子视图的原点。为了帮助布局阶段,清单 3-3 中的onMeasure()代码将这个原点存放在子视图持有的布局参数对象中。一个ViewGroup的每个子视图保证有一个LayoutParams对象。在这种情况下,像FlowLayout这样的自定义布局可以专门化这个基础LayoutParms对象,使其具有像原点这样的附加参数。在清单 3-3 中,我们检索这个布局参数对象LayoutParams并为该视图设置原点。我们突出显示了清单 3-3 中的代码,以显示视图原点的设置。

同样,为了让清单 3-3 中的逻辑清晰明了,我们没有使用我们在清单 3-2 的attrs.xml中定义的间距布局参数。但是您可以使用下面的代码读取这个间距参数,方法是将它放在清单 3-2 的onMeasure()方法中的任何地方。参见清单 3-5 中的FlowLayout.LayoutParams内部类定义。

LayoutParams lp = (LayoutParams)child.getLayoutParams();

int spacing = lp.spacing;

//Adjust your widths based on this spacing.

在我们继续描述onLayout()之前,让我们总结一下关于onMeasure()的几个事实:

Each child is measured independently of its siblings in a view group.   You have to alter the measurespec passed to the FlowLayout before measuring the children. This is evident when you consider EXACT coming into FlowLayout becomes AT_MOST when applied to children; otherwise, each child will take all the space given to the FlowLayout. This alteration is done by the ViewGroup.measureChild(). So unless you have a reason not to, you should call this method to measure children.   It is perfectly okay to add all the children’s measurements. No need to worry about the total size being too large (exceeding the suggested height or width) because the resolveSize(  ) will clip the final reported size for the FlowLayout.   FlowLayout is not playing favorites and distributing the available space by itself. It relies on well-behaved children to claim space on a first come, first served basis. It is possible that a misbehaving view could take most of the space!   A layout like FlowLayout first forms a full picture of all its children in a best-case scenario, and could be larger than what is suggested. It is the resolveSize(  ) that cuts its size down as appropriate. So when you are envisioning your layout, imagine its full picture and not a constrained one by the input size.

现在来说说onLayout

实现 onLayout()

在第一章的中,我们介绍了测量通道和布局通道。测量通道用于测量儿童。它不关心每个孩子的安置。相反,这是在布局过程中完成的。布局路径在onLayout()方法中定义。

在我们的FlowLayout's onMeasure()中,我们已经存储了每个视图的布局原点,onLayout()的实现变得非常简单。遍历每个子对象并检索其LayoutParams对象。从该对象中,获得xy位置。利用这些xy的位置和测量出的孩子的宽度和高度,在孩子身上调用layout()。这就是你在清单 3-4 中看到的。

清单 3-4。实现 onLayout()

@Override

protected void onLayout(boolean arg0, int arg1, int arg2, int arg3, int arg4)

{

//Call layout() on children

int numOfChildren = this.getChildCount();

for (int i=0; i < numOfChildren; i++ )

{

View child = this.getChildAt(i);

LayoutParams lp = (LayoutParams)child.getLayoutParams();

child.layout(lp.x

lp.y

lp.x + child.getMeasuredWidth()

lp.y + child.getMeasuredHeight());

}

}

定义自定义 LayoutParams

正如我们已经指出的,LayoutParams是一个对象,它保存了一些特定于像FlowLayout这样的布局的参数。它们与FlowLayout的其他局部变量分开的原因是这些LayoutParams是由子视图创建和保存的。然而,是FlowLayout定义了这些是什么,如何阅读它们,以及如何解释它们。

清单 3-5 显示了我们如何为类FlowLayout定义布局参数。LayoutParams类定义了一个名为spacing的变量来表示一个视图相对于它的兄弟视图需要多少间距。这类似于清单 3-1 中的“hspace”。不同之处在于:hspace定义了所有视图的间距,而LayoutParams中的间距特定于每个视图。所以清单 3-5 中的spacing有机会覆盖hspace的布局级别设置。

清单 3-5。定义自定义 FlowLayout LayoutParams 类

//*********************************************************

//Custom Layout Definition

//*********************************************************

public static class LayoutParams extends ViewGroup.MarginLayoutParams {

public int spacing = -1;

public int x =0;

public int y =0;

public LayoutParams(Context c, AttributeSet attrs) {

super(c, attrs);

TypedArray a =

c.obtainStyledAttributes(attrs, R.styleable.FlowLayout_Layout);

spacing = a.getDimensionPixelSize(R.styleable.FlowLayout_Layout_layout_space, 0);

a.recycle();

}

public LayoutParams(int width, int height) {

super(width, height);

spacing = 0;

}

public LayoutParams(ViewGroup.LayoutParams p) {

super(p);

}

public LayoutParams(MarginLayoutParams source) {

super(source);

}

}//eof-layout-param

清单 3-5 展示了如何从 XML 布局文件中读取间距变量。这种方法与我们在第一章和第二章中使用定制属性的方法非常相似:在attrs.xml中定义变量名(见清单 3-2),然后通过构造函数中的类型化属性读取它。

除了布局参数,我们还为FlowLayoutLayoutParams类分配了一个额外的职责。参见布局参数类上的xy公共成员。我们使用这些公共变量在测量过程中存储子视图的原点,并在布局过程中重用它。

重写自定义 LayoutParams 构造

子视图,如TextviewButtonview,可以嵌入到任何父布局中,如LinearLayoutFlowLayout。子视图如何知道如何构造和持有与父布局相关的LayoutParams对象?这不能硬编码到视图中,因为视图在编译时不知道它的父视图是什么。

为了解决这一困境,Android SDK 在ViewGroup(所有布局的父级)上提供了许多需要被覆盖的标准方法。当一个视图被放入一个视图组时,这些标准方法被调用来构造派生的LayoutParams并将其关联到视图。清单 3-6 显示了这些构建派生的FlowLayout.LayoutParamsViewGroup标准方法。

清单 3-6。覆盖 LayoutParams 创建

//*********************************************************

//Layout Param Support

//*********************************************************

@Override

public LayoutParams generateLayoutParams(AttributeSet attrs) {

return new FlowLayout.LayoutParams(getContext(), attrs);

}

@Override

protected LayoutParams generateDefaultLayoutParams() {

return new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);

}

@Override

protected LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) {

return new LayoutParams(p);

}

// Override to allow type-checking of LayoutParams.

@Override

protected boolean checkLayoutParams(ViewGroup.LayoutParams p) {

return p instanceof FlowLayout.LayoutParams;

}

调用第一个方法来实例化派生布局的布局参数类。如果我们不覆盖它,那么基本视图组将实例化它的实现MarginLayoutParams。这个人不会有 x 和 y,也不会读取自定义的"spacing"变量。

其余的方法在不同的时间被适当地调用。清单 3-6 是一组标准的方法,可以复制并粘贴到您的自定义 latouts 中,就像我们在这里对FlowLayout所做的一样。

FlowLayout 的源代码

清单 3-7 显示了FlowLayout;的全部源代码,在这里你可以看到我们到目前为止在一个地方为定制布局覆盖的所有步骤:(a)扩展视图组,(b)读取定制属性,(c) onMeasure(),(d) onLayout,(e)实现FlowLayout.LayoutParams,以及(f)实现定制布局参数的支持方法。为了能够编译和运行这个文件,你还需要清单 3-2 所示的attrs.xml文件,放在/res/values子目录中。

清单 3-7。FlowLayout 的完整源代码

public class FlowLayout

extends ViewGroup

{

private int hspace=10;

private int vspace=10;

public FlowLayout(Context context) {

super(context);

initialize(context);

}

public FlowLayout(Context context, AttributeSet attrs, int defStyle) {

super(context, attrs, defStyle);

TypedArray t = context.obtainStyledAttributes(attrs

R.styleable.FlowLayout, 0, 0);

hspace = t.getDimensionPixelSize(R.styleable.FlowLayout_hspace

hspace);

vspace = t.getDimensionPixelSize(R.styleable.FlowLayout_vspace

vspace);

t.recycle();

initialize(context);

}

public FlowLayout(Context context, AttributeSet attrs) {

this(context, attrs, 0);

}

private void initialize(Context context) {

}

//This is very basic

//doesn't take into account padding

//You can easily modify it to account for padding

@Override

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec)

{

//********************

//Initialize

//********************

int rw = MeasureSpec.getSize(widthMeasureSpec);

int rh = MeasureSpec.getSize(heightMeasureSpec);

int h = 0; //current height

int w = 0; //current width

int h1 = 0, w1=0; //Current point to hook the child to

//********************

//Loop through children

//********************

int numOfChildren = this.getChildCount();

for (int i=0; i < numOfChildren; i++ )

{

//********************

//Front of the loop

//********************

View child = this.getChildAt(i);

this.measureChild(child,widthMeasureSpec, heightMeasureSpec);

int vw = child.getMeasuredWidth();

int vh = child.getMeasuredHeight();

if (w1 + vw > rw)

{

//new line: max of current width and current width position

//when multiple lines are in play w could be maxed out

//or in uneven sizes is the max of the right side lines

//all lines don't have to have the same width

//some may be larger than others

w = Math.max(w,w1);

//reposition the point on the next line

w1 = 0; //start of the line

h1 = h1 + vh; //add view height to the current height

}

//********************

//Middle of the loop

//********************

int w2 = 0, h2 = 0; //new point for the next view

w2 = w1 + vw;

h2 = h1;

//latest height: current point + height of the view

//however if the previous height is larger use that one

h = Math.max(h,h1 + vh);

//********************

//Save the current coords for the view

//in its layout

//********************

LayoutParams lp = (LayoutParams)child.getLayoutParams();

lp.x = w1;

lp.y = h1;

//********************

//Restart the loop

//********************

w1=w2;

h1=h2;

}

//********************

//End of for

//********************

w = Math.max(w1,w);

//h = h;

setMeasuredDimension(

resolveSize(w, widthMeasureSpec)

resolveSize(h,heightMeasureSpec));

};

@Override

protected void onLayout(boolean arg0, int arg1, int arg2, int arg3, int arg4)

{

//Call layout() on children

int numOfChildren = this.getChildCount();

for (int i=0; i < numOfChildren; i++ )

{

View child = this.getChildAt(i);

LayoutParams lp = (LayoutParams)child.getLayoutParams();

child.layout(lp.x

lp.y

lp.x + child.getMeasuredWidth()

lp.y + child.getMeasuredHeight());

}

}

//*********************************************************

//Layout Param Support

//*********************************************************

@Override

public LayoutParams generateLayoutParams(AttributeSet attrs) {

return new FlowLayout.LayoutParams(getContext(), attrs);

}

@Override

protected LayoutParams generateDefaultLayoutParams() {

return new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);

}

@Override

protected LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) {

return new LayoutParams(p);

}

// Override to allow type-checking of LayoutParams.

@Override

protected boolean checkLayoutParams(ViewGroup.LayoutParams p) {

return p instanceof FlowLayout.LayoutParams;

}

//*********************************************************

//Custom Layout Definition

//*********************************************************

public static class LayoutParams extends ViewGroup.MarginLayoutParams {

public int spacing = -1;

public int x =0;

public int y =0;

public LayoutParams(Context c, AttributeSet attrs) {

super(c, attrs);

TypedArray a =

c.obtainStyledAttributes(attrs, R.styleable.FlowLayout_Layout);

spacing = a.getDimensionPixelSize(R.styleable.FlowLayout_Layout_layout_space, 0);

a.recycle();

}

public LayoutParams(int width, int height) {

super(width, height);

spacing = 0;

}

public LayoutParams(ViewGroup.LayoutParams p) {

super(p);

}

public LayoutParams(MarginLayoutParams source) {

super(source);

}

}//eof-layout-params

}// eof-class

运行中的流程布局

你现在可能急于学习如何在活动布局文件中使用FlowLayout来查看如图 1-1 所示的视图。下面是清单 3-8 中产生图 1-1 的布局文件:

清单 3-8。为活动使用流程布局

<?xml version="1.0" encoding="utf-8"?>

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android

xmlns:cl="http://schemas.android.com/apk/res/com.androidbook.customLayouts

android:orientation="vertical"

android:layout_width="fill_parent"

android:layout_height="match_parent"

>

<TextView

android:id="@+id/text2"

android:layout_width="fill_parent"

android:layout_height="wrap_content"

android:text="Welcome to the Compound Controls"

/>

<com.androidbook.customLayouts.FlowLayout

android:id="@+id/durationControlId"

android:layout_width="fill_parent"

android:layout_height="wrap_content"

cl:hspace="10dp"

cl:vspace="10dp">

<Button android:text="Button1"

android:layout_width="wrap_content"

android:layout_height="wrap_content"

cl:layout_space="20dp"

/>

<Button android:text="Button2"

android:layout_width="wrap_content"

android:layout_height="wrap_content"

/>

<Button android:text="Button3"

android:layout_width="wrap_content"

android:layout_height="wrap_content"

/>

<Button android:text="Button4"

android:layout_width="wrap_content"

android:layout_height="wrap_content"

/>

<Button android:text="Button5"

android:layout_width="wrap_content"

android:layout_height="wrap_content"

/>

<Button android:text="B1"

android:layout_width="wrap_content"

android:layout_height="wrap_content"

/>

<Button android:text="B2"

android:layout_width="wrap_content"

android:layout_height="wrap_content"

/>

<Button android:text="B3"

android:layout_width="wrap_content"

android:layout_height="wrap_content"

/>

<Button android:text="B4"

android:layout_width="wrap_content"

android:layout_height="wrap_content"

/>

<Button android:text="B5"

android:layout_width="wrap_content"

android:layout_height="wrap_content"

/>

</com.androidbook.customLayouts.FlowLayout>

<TextView

android:id="@+id/text1"

android:layout_width="fill_parent"

android:layout_height="wrap_content"

android:text="Scratch for debug text"

/>

</LinearLayout>

请特别注意我们是如何使用我们自己的定制名称空间“cl”(定制布局)来指示定制属性的。

了解 ViewGroup.getChildMeasureSpec

在覆盖onMeasure()时,我们已经表示使用ViewGroup.measureChild(),不直接使用child.measure()。当您看到清单 3-9 中的ViewGroup.getChildMeasureSpec()(由ViewGroup.measureChild()触发)的实现时,这变得非常清楚。清单 3-9 中的代码直接取自 Android 源代码中的ViewGroup类。

清单 3-9 中需要注意的关键是如何更仔细地查看FlowLayout' s(或ViewGroup' s)度量规格,并且如果必要的话,将不同的度量规格传递给孩子以测量孩子的尺寸。

清单 3-9。找出正确的子度量规格

public static int getChildMeasureSpec(int spec, int padding, int childDimension)

{

int specMode = MeasureSpec.getMode(spec);

int specSize = MeasureSpec.getSize(spec);

int size = Math.max(0, specSize - padding);

int resultSize = 0;

int resultMode = 0;

switch (specMode) {

// Parent has imposed an exact size on us

case MeasureSpec.EXACTLY:

if (childDimension >= 0) {

resultSize = childDimension;

resultMode = MeasureSpec.EXACTLY;

} else if (childDimension == LayoutParams.MATCH_PARENT) {

// Child wants to be our size. So be it.

resultSize = size;

resultMode = MeasureSpec.EXACTLY;

} else if (childDimension == LayoutParams.WRAP_CONTENT) {

// Child wants to determine its own size. It can't be

// bigger than us.

resultSize = size;

resultMode = MeasureSpec.AT_MOST;

}

break;

// Parent has imposed a maximum size on us

case MeasureSpec.AT_MOST:

if (childDimension >= 0) {

// Child wants a specific size... so be it

resultSize = childDimension;

resultMode = MeasureSpec.EXACTLY;

} else if (childDimension == LayoutParams.MATCH_PARENT) {

// Child wants to be our size, but our size is not fixed.

// Constrain child to not be bigger than us.

resultSize = size;

resultMode = MeasureSpec.AT_MOST;

} else if (childDimension == LayoutParams.WRAP_CONTENT) {

// Child wants to determine its own size. It can't be

// bigger than us.

resultSize = size;

resultMode = MeasureSpec.AT_MOST;

}

break;

// Parent asked to see how big we want to be

case MeasureSpec.UNSPECIFIED:

if (childDimension >= 0) {

// Child wants a specific size... let him have it

resultSize = childDimension;

resultMode = MeasureSpec.EXACTLY;

} else if (childDimension == LayoutParams.MATCH_PARENT) {

// Child wants to be our size... find out how big it should

// be

resultSize = 0;

resultMode = MeasureSpec.UNSPECIFIED;

} else if (childDimension == LayoutParams.WRAP_CONTENT) {

// Child wants to determine its own size.... find out how

// big it should be

resultSize = 0;

resultMode = MeasureSpec.UNSPECIFIED;

}

break;

}

return MeasureSpec.makeMeasureSpec(resultSize, resultMode);

}

参考

第一章和第二章中列出的大部分参考资料对本章也是有效的。因此,我们在这里只添加了几个本章特有的参考文献。

摘要

在第一章中,我们展示了视图的基本架构,以及如何定制一个开放式视图对象和如何直接使用画布。在《??》第二章中,我们谈到了通过扩展现有布局来构建用户级控件。在本章中,我们介绍了如何通过直接扩展视图组来创建自定义布局。在这三种方法中,我们认为您最有机会使用自定义布局。例如,流布局的需求是必不可少的,但它不在原生 SDK 中。举个例子,我们正在做一个文字游戏,我们需要把单词的字母排列成一个流动的布局。在同一个游戏中,我们希望将一组菜单图标呈现为一个旋转的表格。你可以在 Google Play 中看到这个名为解读专家的游戏。或者,您可能想要创建一个图像滚轴平台,如相册。这些都是自定义布局的例子。考虑到这一点,我们在这里详细介绍了onMeasure()和onLayout()以便帮助您进行自定义布局。

复习问题

以下一组详细的问题应该可以巩固您在本章中学到的内容:

What classes do you extend to create custom layouts?   Can you use custom attributes for the custom layout?   What are LayoutParams?   Who implements the LayoutParams class?   Who has methods to construct a LayoutParams class?   Why do views hold LayoutParams on behalf of a parent layout?   Why should you use resolveSize(  )?   Why should you use ViewGroup.measureChild() and not child.measure()?   What would be wrong if a layout passes its measure spec as is to each of its children?   What happens if the total measured size of the children exceeds the suggested size of the parent layout?   How do you define and read layout parameters from attrs.xml?

四、用于设备上持久性的 JSON

Abstract

移动应用或游戏的一个关键方面是需要设备上的持久性。Android 为持久性提供了许多非常好的选项,包括关系存储。然而,所有提供的使用关系存储的机制都需要相当长的开发时间。本章为许多简单到中等程度的持久性需求提供了一条捷径。该方法使用 GSON/JSON 技术将对象树直接存储在非 SQL 存储中,例如设备上的共享首选项和内部文件存储方式。

移动应用或游戏的一个关键方面是需要设备上的持久性。Android 为持久性提供了许多非常好的选项,包括关系存储。然而,所有提供的使用关系存储的机制都需要相当长的开发时间。本章为许多简单到中等程度的持久性需求提供了一条捷径。该方法使用 GSON/JSON 技术将对象树直接存储在非 SQL 存储中,比如共享首选项和内部文件存储。

JSON 代表 JavaScript 对象符号。GSON 是 Google 项目中的一个 Java 库,它使用 JSON 结构将 Java 对象序列化和反序列化为字符串。

你会惊讶地发现 GSON 是如何提高你的移动应用的工作效率的。尤其是那些需要中等持久性的应用可以从 GSON 中受益。在这种方法中,您将应用的持久状态表示为一个 JSON 字符串。然后,您将使用神奇的 GSON Java 库将您的应用对象转换为 JSON,并将生成的 JSON 字符串持久化,或者保存在共享的首选项中,或者保存在设备的内部文件中。

这种方法适合快速编写中等大小的应用。使用这种方法,每隔几周发布一个新的应用是合理的,而开发这样的应用可能需要一两个月。事实上,在某些应用上,你可能会获得两到三倍的优势。即使当您考虑复杂的应用时,您也可以从这种方法中获得显著的优势,因为您可以将想法原型化,并在有限的版本中进行测试。

为了帮助您理解这种基于 JSON 的应用存储方法,我们在本章中介绍了以下内容:

Using GSON to convert Java objects to JSON strings and back   Storing and retrieving JSON strings using shared preferences   Storing and retrieving JSON strings from Android internal file storage

在涵盖这些主题的过程中,我们回答了以下关键问题:(1)使用共享偏好作为简单应用和游戏的数据存储有哪些限制?(2)除了简单的键/值对之外,有没有一个官方的词不使用共享首选项?(3)使用此方案可以存储的数据量是否有最大限制?(4)我是否应该探索其他数据存储选项?(Android 内部存储和外部存储是什么意思?(6)我应该使用共享首选项还是内部文件?(7)我应该使用外部存储卡上的文件吗?(8)我何时需要使用 SQL 存储?以及(8)我能否以这样一种方式编写我的应用,以便为以后的版本迁移到 SQL 存储?

我们首先快速回顾一下 Android 中用于管理持久状态的数据存储选项。

Android 中的数据存储选项

Android 中存储数据的方式有五种:(1)共享首选项,(2)内部文件,(3)外部文件,(4) SQLite,(5)云端网络存储。让我们逐一回顾一下。

共享偏好设置是应用和设备内部的。该数据不可用于其他应用。用户不能通过安装到 USB 端口上来直接操作这些数据。删除应用时,数据会自动删除。共享首选项是结构化的键/值对数据,并遵循 Android 为使用存储的数据作为首选项而强加的一些其他语义。共享首选项以 XML 文件的形式维护。

内部文件是非结构化的私有文件,应用可以在其中创建和存储数据。像共享首选项一样,内部文件遵循应用的生命周期。卸载应用时,由应用创建的内部文件也会被删除。

外部文件存储在 SD 卡上。这些成为公共文件,其他应用包括用户可以在你的应用环境之外看到。外部文件通常用于主要由用户创建并且在创建它的应用之外有意义的数据,例如音频文件、视频文件、word 文档等。当数据量很大(比如 10MB 或更大)时,外部文件也是合适的。

SQLite 是一个关系数据库。对于结构化数据,比如您计划使用 JSON 的数据,SQLite 是首选。然而,在 Java 对象和关系数据库之间要做大量的工作。即使在使用精心制作的 o/r 映射工具或库的最简单的情况下,仍然有大量的工作要做。然而,SQLite 是为后续版本调整和重构应用的绝佳选择。这种重构将使您的应用响应更快,使用更少的能量。数据库也是应用私有的,外部应用无法使用,除非您将数据库包装在内容提供者框架中。(有关内容供应器的详细报道,请参考我们的配套书籍 Pro Android 4。)

网络存储允许您的应用选择将持久数据保存在云中。然而,网络存储并不是很多应用的完整选项,因为你可能需要应用在断开互联网连接时工作。可能有更多机会使用 parse.com 或类似的 BaaS(后端即服务)平台来实现混合方法,从而将一些数据存储在云中,并在需要时与设备同步。

我们的研究使我们建议在使用 GSON 将对象转换成 JSON 后,使用共享参数或内部文件作为对象的存储机制。这些存储选项有两个优点。第一,它们是私有的;第二,当应用被删除时,它们也被删除。它们的缺点是尺寸有限;如果文件变得太大(几十兆字节或者有图像或视频),一旦你的应用启动,你可以在后续版本中迁移到 SQLite。

使用 JSON 实现持久性的一般方法

JSON 是一种字符串格式,将 JavaScript 对象表示为字符串。您可以将一组 JavaScript 对象转换成 JSON 字符串。然后,这些字符串可以通过网络传输,并作为 JavaScript 对象读回。由于 Java 对象类似于 JavaScript 对象,所以可以使用相同的 JSON 字符串格式将 Java 对象转换为字符串。产生的 JSON 字符串可以转换回 Java 对象。

因为 JSON 已经成为跨网络传输对象的主要格式,所以围绕 JSON 出现了许多工具来简化这个过程。在我们的例子中,我们使用 JSON 字符串,不是为了传输而是为了将它们持久化到磁盘上并读回它们。在这种方法中,我们使用 Google 工具 GSON 将 Java 对象转换成 JSON 字符串,然后再转换回来。如上所述,JSON 字符串可以存储在内部文件或共享的首选项中。

与 GSON 合作

GSON 是一个 Java 库(一个单独的 jar 文件),可以用来将 Java 对象转换成 JSON 或者相反。(您可以在本章末尾的参考资料中看到主页、用户指南和 API 的链接。)让我们在将 Java 对象序列化和反序列化为 JSON 的同时,快速看一下 GSON 的特性和局限性。

GSON 的特点

大多数类型的 Java 对象都可以使用 GSON 转换成 JSON 字符串。使用 GSON,您可以在嵌套结构中的对象中包含对象。如果您镜像 Java 对象并关注它们的存储结构,那么您可以将大多数(如果不是全部)对象序列化为 JSON 字符串。因此,您希望您的 Java 对象主要表示数据,而不是可能干扰用 GSON 序列化它们的成熟行为。

只要您的成员集合使用 Java 泛型,GSON 就可以成功地序列化您的集合。您将很快看到一个这样的例子。使用 Java 泛型时,需要指定集合的类型。这有助于 GSON 在反序列化时实例化正确类型的对象。(您可以参考 GSON 用户指南了解更多功能和限制。)

GSON 还将转义引号字符和任何其他对 JSON 特殊的字符。默认情况下,GSON 对 HTML 和 XML 字符进行转义。在我们的研究中,我们发现这种行为非常令人满意,因为它允许您序列化任何 Java 对象——即使它的成员包含具有任何类型字符的任意字符串。

将 GSON Jar 添加到应用中

要开始在 Android 代码中使用 GSON,您需要将 GSON jar 文件添加到您的 Eclipse/ ADT 项目中。下面是添加 GSON jar 文件所需的步骤:

Go to GSON home page: http://code.google.com/p/google-gson/ .   download the GSON jar file.   Create a subdirectory under the root of your Eclipse project called "libs” (a sibling of src).   Copy the GSON jar to that lib directory.   Go go “project properties.”   Go to the “Java Build Path” option in project properties.   Go to “Library” tab.   Add the GSON jar as an external jar.   Go to the “order/export” tab.   Choose the GSON jar to be exported as well.

这些步骤将使 GSON jar 可用于编译您的代码,也可用于将它与 GSON jar 一起部署到仿真器或设备上。添加外部 jar 所需的步骤可能会随着您所使用的 Eclipse/ADT 版本的不同而略有不同。

您也许能够链接外部 GSON jar,而不需要复制到您的 APK 项目,如这里所示。在这种情况下,您将能够成功地编译和链接。但是,当您在设备或模拟器上运行应用时,将会出现“未找到运行时类”异常。因此,按照上述步骤将 GSON jar 添加到您的 Eclipse/ADT 项目中。

为 GSON 规划 Java 对象

您可以将您的持久性结构建模为一组相互连接的 Java 对象。然后,您可以使用 GSON 将根 Java 对象转换成 JSON。我们向您展示了几个具有代表性的 Java 对象;使用这些例子,您可以用类似的方式对您的存储对象建模。

我们从根对象开始,我们称它为MainObject。清单 4-1 是这个根MainObject.的源代码

清单 4-1。用于存储应用状态的根对象的示例结构

public class MainObject

{

public int intValue = 5;

public String stringValue = "st<ri>\"ng\"Value<node1>test</node2>";

public String[] stringArray;

public ArrayList<ChildObject> childList = new ArrayList<ChildObject>();

...

}

注意,我们已经在清单 4-1 的MainObject中模拟了许多存储类型。我们有普通的整数、字符串、数组和嵌入的子对象集合。在字符串值中,我们甚至存储了嵌套的 XML 节点。这些嵌套的 XML 节点将允许我们测试 GSON 和 Android 共享偏好的转义特性。

在清单 4-1 中,还要注意我们如何使用一个通用的ArrayList集合来保存子对象的集合。每个子对象都有自己的内部结构。子对象的类定义如清单 4-2 所示。

清单 4-2。存储为 JSON 的子对象的示例结构

public class ChildObject {

public String name;

public int age;

public boolean likesVeggies = false;

public ChildObject(String inName, int inAge)   {

name = inName;

age = inAge;

}

}

尽管我们已经将ChildObject的成员变量表示为公共变量,但 GSON 确实允许它们作为私有成员,只要您提供与它们的名称匹配的 get/set 方法。

尽管清单 4-1 和 4-2 中的这些对象主要是为了建模存储需求,但是您可以向这些类添加基本行为,并提供以结构化方式初始化和存储数据的好方法。你可以在清单 4-3 中看到这一点,我们扩展了MainObject来添加一些行为。

清单 4-3。显示行为的对象

public class MainObject

{

public int intValue = 5;

public String strinValue = "st<ri>\"ng\"Value<node1>test</node2>";

public String[] stringArray;

public ArrayList<ChildObject> childList = new ArrayList<ChildObject>();

public void addChild(ChildObject co)   {

childList.add(co);

}

public void populateStringArray()   {

stringArray = new String[2];

stringArray[0] = "first";

stringArray[1] = "second";

}

//This method is used to create a sample MainObject

public static MainObject createTestMainObject()   {

MainObject mo = new MainObject();

mo.populateStringArray();

mo.addChild(new ChildObject("Eve",30));

mo.addChild(new ChildObject("Adam",28));

return mo;

}

//this method is used to verify two MainObject

//instances are the same.

public static String checkTestMainObject(MainObject mo)   {

MainObject moCopy = createTestMainObject();

if (!(mo.strinValue.equals(moCopy.strinValue)))

{

return "String values don't match:" + mo.strinValue;

}

if (mo.childList.size() != moCopy.childList.size())

{

return "array list size doesn't match";

}

//get first child

ChildObject firstChild = mo.childList.get(0);

ChildObject firstChildCopy = moCopy.childList.get(0);

if (!firstChild.name.equals(firstChildCopy.name))

{

return "first child name doesnt match";

}

return "everything matches";

}

}

将 Java 对象转换成 JSON

既然我们已经为我们的持久性需求定义了对象结构,让我们看看如何获取一个MainObject的实例并将其转换成 JSON。我们还将获取生成的 JSON 字符串,并将其转换回实例MainObject。然后我们将比较这个生成的MainObject实例,看它是否与原型MainObject匹配。清单 4-4 显示了这样做的代码。

清单 4-4。使用 GSON 序列化和反序列化 Java 对象

public void testJSON()

{

MainObject mo = MainObject.createTestMainObject();

Gson gson = new Gson();

//Convert to string

String jsonString = gson.toJson(mo);

//Convert it back to object

MainObject mo1 = gson.fromJson(jsonString, MainObject.class);

String compareResult = MainObject.checkTestMainObject(mo1);

Log.i(“sometag”,compareResult);

}

非常简单。

现在剩下的就是如何在一个持久化的地方存储和检索这个结果 JSON 字符串。我们有两个选项:共享首选项和内部文件存储。我们首先讨论共享偏好。

使用 JSON 持久性的共享首选项

在 Android 中,共享偏好用于满足两个主要需求。Android SDK 使用共享首选项机制来创建为应用自动创建首选项屏幕所必需的 UI。Android SDK 还直接公开了共享的首选项,没有 UI 组件。在后一种模式中,共享首选项只是键/值对的存储/检索机制。Android SDK 提供了许多类和方法来直接处理这些键/值对。(你可以在我们的同伴 Pro Android 系列书籍中阅读更多关于共享偏好的内容。)

共享首选项的核心是一个 XML 文件,它以持久的方式保存键/值对。XML 文件是实现细节;Android 将来可能会选择不同的表现形式,如果它决定这样做的话。您可以拥有任意数量的共享首选项(XML 文件)。每个共享首选项可以有任意多的键/值对。

这些共享的首选项 XML 文件是您的应用专用的。卸载应用时,它们会被移除。要获取对共享首选项的引用,您需要一个上下文对象。Activity是 Android 中上下文对象的一个例子。一旦有了上下文对象,就可以用文件名调用方法getSharedPreferences()来访问共享的首选项对象。然后,您可以使用该对象来保存和检索键/值对。

如果您可以访问一个Activity,您可以只调用getPreferences()来代替。这个方法只是调用前面的getShaerdPreferences(),用活动名作为 XML 文件的名称。

获取对应用上下文的访问

应用中的每个活动都可以有自己的共享首选项文件。但是我们感兴趣的是整个应用的持久性需求,而不仅仅是特定的活动。所以我们不能在Activity类上使用getPreferences()方法。据您所知,您甚至可能在您的应用中没有活动。

因此,我们需要一个适用于整个应用级别的上下文。为此,您需要覆盖Application类并创建应用的一个实例。清单 4-5 显示了这一点。

清单 4-5。收集应用上下文

public class MyApplication extends Application

{

public final static String tag="MyApplication";

public static Context s_applicationContext = null;

@Override

public void onConfigurationChanged(Configuration newConfig) {

super.onConfigurationChanged(newConfig);

Log.d(tag,"configuration changed");

}

@Override

public void onCreate() {

super.onCreate();

s_applicationContext = getApplicationContext();

Log.d(tag,"oncreate");

}

@Override

public void onLowMemory() {

super.onLowMemory();

Log.d(tag,"onLowMemory");

}

@Override

public void onTerminate() {

super.onTerminate();

Log.d(tag,"onTerminate");

}

}

清单 4-5 中的关键行用粗体突出显示。在这个清单中,我们获取应用上下文并将其存储在一个公共静态变量中,以便应用上下文在应用中全局可用。

一旦你有了你的应用MyApplication,如清单 4-5 所示,你需要调整你的清单文件来注册MyApplication,如清单 4-6 所示。

清单 4-6。在清单文件中注册应用对象

<application android:name="com.androidbook.testjson.MyApplication"

...

</application>

清单 4-6 中的声明将导致调用MyApplicationonCreate()方法,如清单 4-5 所示

使用共享偏好设置存储和恢复字符串

有了MyApplication中的静态全局应用上下文,您可以使用清单 4-7 中的代码来保存和恢复共享首选项中的字符串。

清单 4-7。使用共享偏好设置存储和恢复字符串

//Use an XML file called myprefs.xml to represent shared preferences

//for this example.

private SharedPreferences getSharedPreferences()

{

SharedPreferences sp

= MyApplication

.s_applicationContext

.getSharedPreferences("myprefs", Context.MODE_PRIVATE);

return sp;

}

public void testEscapeCharactersInPreferences()

{

//Use a string that is a bit more complicated

//to see how escape characters work

String testString = "<node1>blabhhh</node1>";

//get shared preferences

SharedPreferences sp = getSharedPreferences();

//Prepare the shared preferences for save

SharedPreferences.Editor spe = sp.edit();

//add a key/value pair

spe.putString("test", testString);

//Commit the changes to persistence

spe.commit();

//Retrieve what is stored

String savedString = sp.getString("test", null);

if (savedString == null)

{

Log.d(tag,"no saved string");

return;

}

//Compare the two strings

Log.d(tag,savedString);

if (testString.equals(savedString))

{

Log.d(tag,"Saved the string properly. Match");

return;

}

//they dont match

Log.d(tag,"They don't match");

return;

}

让我们回顾一下清单 4-7 中的代码。我们首先使用静态全局上下文来使用myprefs.xml作为共享首选项文件来测试字符串的存储。我们还指出了在私有模式下创建和维护myprefs.xml。其他可能的模式如清单 4-8 所示。

清单 4-8。共享偏好设置的模式位

MODE_PRIVATE (value of 0 and default)

MODE_WORLD_READABLE

MODE_WORLD_WRITEABLE

MODE_MULTI_PROCESS

在清单 4-7 所示的测试方法中,我们使用了一个包含 XML 字符的字符串,知道共享的首选项存储在 XML 节点中。我们想看看共享偏好机制是否足够聪明来避开这些字符。

此时,您可能想知道共享首选项文件myprefs.xml是何时创建的,因为我们没有明确要求创建任何文件。文档表明,当我们试图使用清单 4-9 中所示的代码段保存第一个键/值对时,创建了共享首选项的底层 XML 文件。(这个清单摘自前面的清单 4-7。)

清单 4-9。使用编辑器保存和提交首选项

SharedPreferences.Editor spe = esp.edit();

spe.put...();

spe.commit();

从清单 4-9 中的代码,您可以看到SharedPreferences有点奇怪。我们不直接使用SharedPreferences对象来保存值。相反,我们使用它的内部类SharedPreferences.Editor来完成保存。这只是一种你必须习惯的模式。在项目的生命周期中,您可以选择向一个共享的首选项添加多个键/值对。最后,一个commit()将把它写到持久性存储中,这将保存对文件的多次写入。

此时一个奇怪的问题是,这个myprefs.xml存储在设备的什么地方?这个文件存储在设备的路径中,如清单 4-10 所示。

清单 4-10。共享首选项文件路径

/data/data/YOUR_PACKAGE_NAME/shared_prefs/myprefs.xml

在 eclipse/ADT 中,您使用文件管理器工具来查看模拟器或设备上的文件,并将myprefs.xml文件拖到本地驱动器并查看它。清单 4-11 显示了清单 4-7 中的代码创建的myprefs.xml文件。看看 Android 首选项如何对字符串值中的 XML 字符进行转义。

清单 4-11。共享首选项 XML 文件的内容

<?xml version='1.0' encoding='utf-8' standalone='yes' ?>

<map>

<string name="test">&lt;node1&gt;blabhhh&lt;/ndoe1&gt;</string>

</map>

在共享偏好设置中使用 GSON 保存/恢复对象

现在,您已经拥有了从共享首选项中保存和恢复 Java 对象所需的所有信息。清单 4-12 显示了这是如何做到的。在这个例子中,我们创建了MainObject并使用 GSON 将其转换成 JSON。然后,我们使用共享首选项来存储它。我们像前面一样使用文件名myprefs.xml,我们使用MainObject JSON 字符串的键作为json。一旦我们成功存储了对象,我们就检索 JSON 字符串并将其转换回MainObject。然后,我们将它与原始对象进行比较,看它们是否匹配。

清单 4-12 .从共享首选项中存储/检索对象

public void storeJSON()

{

MainObject mo = MainObject.createTestMainObject();

//

Gson gson = new Gson();

String jsonString = gson.toJson(mo);

Log.i(tag, jsonString);

MainObject mo1 = gson.fromJson(jsonString, MainObject.class);

Log.i(tag, jsonString);

SharedPreferences sp = getSharedPreferences();

SharedPreferences.Editor spe = sp.edit();

spe.putString("json", jsonString);

spe.commit();

}

public void retrieveJSON()

{

SharedPreferences sp = getSharedPreferences();

String jsonString = sp.getString("json", null);

if (jsonString == null)

{

Log.i(tag,"Not able to read the preference");

return;

}

Gson gson = new Gson();

MainObject mo = gson.fromJson(jsonString, MainObject.class);

Log.i(tag,"Object successfully retrieved");

String compareResult = MainObject.checkTestMainObject(mo);

if (compareResult != null)

{

//there is an error

Log.i(tag,compareResult);

return;

}

//compareReesult is null

Log.i(tag,"Retrieved object matches");

return;

}

您可能想看看在执行清单 4-12 中的代码后,myprefs.xml是什么样子,因为这将让您有机会看到 GSON 是如何对字符串进行转义的。下面是清单 4-13 中的文件。

清单 4-13。演示共享首选项中的转义字符

<?xml version='1.0' encoding='utf-8' standalone='yes' ?>

<map>

<string name="test">&lt;node1&gt;blabhhh&lt;/ndoe1&gt;</string>

<string name="json">{&quot;childList&quot;:[{&quot;name&quot;:&quot;Adam&quot;,&quot;likesVeggies&quot;:false

&quot;age&quot;:30},{&quot;name&quot;:&quot;Eve&quot;,&quot;likesVeggies&quot;:false

&quot;age&quot;:28}],&quot;stringArray&quot;:[&quot;first&quot;,&quot;second&quot;]

&quot;strinValue&quot;:&quot;st\u003cri\u003e\&quot;ng\&quot;Value\u003cnode1\u003etest\u003c/node2\u003e

&quot;,&quot;intValue&quot;:5}</string>

</map>

请注意清单 4-13 中 GSON 和 Android 首选项之间的转义是如何工作的。两者都有转义序列。所以不用担心包含特殊字符的对象中的字符串值。它们被 GSON 和 Android 偏好自动处理得很好。

作为讨论共享首选项的最后一点,我们提供了一个问题的答案,共享首选项中可以存储的数据量有限制吗?答案是它相当大,至少理论上是如此。实质上,设备上可用的内部存储量提供了上限。重要的是,Android 没有对共享偏好文件的大小施加任何任意限制。但是,由于所有应用共享同一个存储设备,所以您需要谨慎使用多少存储设备。可能任何小于 10MB 的都是合理的。

为 JSON 使用内部存储

最终,共享的首选项作为键/值对存储在 XML 文件中。Android 以一种特殊的方式处理共享偏好,正如前面共享偏好一节所展示的。例如,共享首选项是文件这一事实不会直接暴露给程序员。此外,键/值对作为节点/值对存储在 XML 文档中。相反,您可能希望完全控制存储 JSON 的文件。在这种情况下,您可以使用 Android 提供的内部存储选项。这些内部文件与共享首选项 XML 文件非常相似,也是专门为您的应用存储在设备上的,默认情况下是私有的。

从内部存储器存储和检索

在内部文件中存储和检索 JSON 非常简单。Android SDK 有一个打开和关闭内部文件的 API。Android SDK 还决定这些文件驻留在哪里。作为一名程序员,你可以控制文件名。您还可以控制何时读取/写入它们。您可以使用标准的 Java I/O 库从文件流中读取和写入文件流。

清单 4-14 和 4-15 中的代码段演示了如何完成以下工作:

Open an internal file for writing   Convert objects to JSON and write to the file   Open the same internal file for reading   Read the JSON string from the file   Convert the JSON string to objects   Compare the source and target objects to ensure they are same

清单 4-14 .从内部文件存储中存储/检索对象

public void saveJSONToPrivateStorage()

{

String json = createJSON();

saveToInternalFile(json);

String retrievedString = this.readFromInternalFile();

//Create the object from retrievedString

Gson gson = new Gson();

MainObject mo = gson.fromJson(retrievedString, MainObject.class);

//makesure it is the same object

MainObject srcObject = MainObject.createTestMainObject();

String compareResult = mo.checkTestMainObject(srcObject);

Log.i(tag,compareResult);

}

private String createJSON()

{

MainObject mo = MainObject.createTestMainObject();

Gson gson = new Gson();

String jsonString = gson.toJson(mo);

return jsonString;

}

private String readFromInternalFile()

{

FileInputStream fis = null;

try {

Context appContext = MyApplication.s_applicationContext;

fis = appContext.openFileInput("``datastore-json.txt

String jsonString = readStreamAsString(fis);

return jsonString;

}

catch(IOException x)

{

Log.d(tag,"Cannot create or write to file");

return null;

}

finally

{

closeStreamSilently(fis);

}

}

private void saveToInternalFile(String ins)

{

FileOutputStream fos = null;

try {

Context appContext = MyApplication.s_applicationContext;

fos = appContext.openFileOutput("datastore-json.txt"

,Context.MODE_PRIVATE);

fos.write(ins.getBytes());

}

catch(IOException x)

{

Log.d(tag,"Cannot create or write to file");

}

finally

{

closeStreamSilently(fos);

}

}

一点都不复杂。清单 4-14 中的代码使用了几个文件工具方法。你可以根据自己的喜好来设计这些方法。但是如果你想看看它们的快速实现,清单 4-15 中有这些方法。

清单 4-15。支持文件实用程序方法

private void copy(InputStream reader, OutputStream writer)

throws IOException

{

byte byteArray[] = new byte[4092];

while(true)   {

int numOfBytesRead = reader.read(byteArray,0,4092);

if (numOfBytesRead == -1)      {

break;

}

// else

writer.write(byteArray,0,numOfBytesRead);

}

return;

}

private String readStreamAsString(InputStream is)

throws FileNotFoundException, IOException

{

ByteArrayOutputStream baos = null;

try    {

baos = new ByteArrayOutputStream();

copy(is,baos);

return baos.toString();

}

finally    {

if (baos != null)

closeStreamSilently(baos);

}

}

private void closeStreamSilently(OutputStream os)

{

if (os == null) return;

//os is not null

try {os.close();} catch(IOException x)    {

throw new RuntimeException(

"This shouldn't happen. exception closing a file",x);

}

}

private void closeStreamSilently(InputStream os)

{

if (os == null) return;

//os is not null

try {os.close();} catch(IOException x)    {

throw new RuntimeException(

"This shouldn't happen. exception closing a file",x);

}

}

在清单 4-14 中,我们使用了文件名datastore-json.txt。您可以在您的设备上找到这个文件,位置如清单 4-16 所示。

清单 4-16。Android 内部文件的文件位置

data/data/<your-pkg-name>/files/datastore-json.txt

一旦执行了清单 4-14 中的代码,就可以使用 Eclipse/ ADT 中的文件管理器来查看这个文件的内容。这个文件的内容将类似于清单 4-17 所示的文本。

清单 4-17。内部存储文件中的 JSON 字符串

{"childList":[{"name":"Adam","likesVeggies":false,"age":30}

{"name":"Eve","likesVeggies":false,"age":28}],"stringArray":["first","second"]

"strinValue":"st\u003cri\u003e\"ng\"Value\u003cnode1\u003etest\u003c/node2\u003e"

"intValue":5}

您可以使用多个文件来存储多个 Java 根对象,以细分存储的粒度。这将有助于某种程度的优化。

在外部存储器上存储 JSON

Android SDK 提供 API 来控制外部 SD 卡上的目录和文件。正如我们在前面的“数据存储选项”一节中指出的,这些外部文件是公共的。如果您选择将 JSON 字符串存储在这些外部文件中,那么您可以遵循与上一节中描述的内部文件相似的模式。然而,在大多数情况下,外部存储选项有很好的理由。

因为通常您表示为 JSON 的数据是特定于您的应用的,所以将其作为外部存储是没有意义的,外部存储通常用于音乐文件、视频文件或其他应用可以理解的通用格式的文件。因为诸如 SD 卡的外部存储器可以处于各种状态(可用、未安装、已满等。),当数据足够小,可以通过更简单的内部文件管理时,很难为简单的应用编写这种卡。

因此,我们不认为应用状态是在外部存储上维护的。如果应用需要音乐和照片,那么混合方法可能是有意义的,这些可以放在外部存储上,而您将核心状态数据保存在 JSON 和内部文件中。如果您选择使用外部存储,请参考 Android SDK 文档,了解一些正确管理外部存储的 API。

将 SQLite 用于结构化存储

没有什么像 GSON 一样简单和甜蜜的事情可以不存在。如果每次发生变化时都要编写文件,那么这种 JSON 方法的成本会很高。当然,存在一个平衡点,在这个平衡点上,您希望将代码迁移到 SQLite 并使用粒度更新。

如果是这样的话,您可以构建您的代码,这样您就可以用 SQLite 之类的替代品来交换持久层,而不会对您的代码进行重大修改。要做到这一点,把你的应用分成一个持久性服务层和其余部分。您将需要足够的训练来使用服务层作为一组无状态的服务,您可以在以后使用不同的实现。记住接口和服务接口防火墙。在参考资料中,我们包含了一篇文章的链接,这篇文章是作者之一在 2006 年写的,内容是关于极限原型的。您可以借用相同的原则来构建一个可以交换的服务层。

参考

我们发现以下链接对本章的研究很有帮助。

下载本章专用的测试项目:www.androidbook.com/expertandroid/projects.ZIP 文件的名称是ExpertAndroid_ch04_TestGSON.zip

摘要

GSON 有助于将您的应用快速部署到市场。这是测试市场实力的好方法。它也有助于快速发布大量简单的应用,只需做最少的持久性工作。为了实现这个目标,我们在本章中介绍了如何使用 GSON 将对象存储在共享的首选项或内部文件中。在这个过程中,我们还讨论了 Android 的其他数据存储选项,看它们是否适合存储 JSON。

复习问题

以下一组问题应该可以巩固您在本章中学到的内容:

What are the five different storage options for Android applications?   How would you use JSON for persisting Android application state?   What are the pros and cons of using JSON for persistence?   What is GSON?   Can you save nested objects using GSON?   Can you save nested collections of objects using GSON?   How are characters in strings escaped in GSON?   How do you add external jar files like the GSON jar to the Android APK file?   What are shared preferences?   What is the difference between getSharedPreferences and getPreferences?   How do you get a context independent of an activity?   Where are shared preference files stored?   How do you save JSON to a shared preference file?   What does the saved preference file look like?   How do you read JSON from shared preferences back as Java objects?   How do escape characters work in Android preferences?   What is internal storage?   How do you save to internal storage?   How do you restore objects from internal storage?   Should you use external storage like the SD card?   What are the useful references while working with this approach?   How do you code in such a way that you can migrate in the future to SQLite?

五、多设备编程

Abstract

Android 的一个奇妙之处是有如此多的设备运行它。从手机到平板电脑,从电视到相机到跑步机,Android 作为各种电子设备的软件平台非常受欢迎。这对应用开发人员来说也是一个巨大的挑战。设备之间的差异可能很多,从不同尺寸的屏幕到不同的屏幕密度,从不同的硬件功能到不同版本的 Android。优雅地处理所有这些差异是可能的,而且可能比你想象的更容易。

Android 的一个奇妙之处是有如此多的设备运行它。从手机到平板电脑,从电视到相机到跑步机,Android 作为各种电子设备的软件平台非常受欢迎。这对应用开发人员来说也是一个巨大的挑战。设备之间的差异可能很多,从不同尺寸的屏幕到不同的屏幕密度,从不同的硬件功能到不同版本的 Android。优雅地处理所有这些差异是可能的,而且可能比你想象的更容易。

随着 Android 的发展,开发人员也有了更多的选择。本章将探讨设备差异以及如何处理它们,这样一个应用就可以在你想要的任意多的设备上运行。

不同的屏幕密度

你需要完全理解的第一个概念是屏幕密度。在这个领域有很多术语,也有一些常见的误解。首先,表 5-1 列出了一些术语及其真实含义。

表 5-1。

Density Terms and Their Meanings

| 学期 | 定义 | 意义 | | --- | --- | --- | | 像素 | 图像中的一个颜色点 | 像素可以是屏幕上的实体,也可以是构成数字图像的点(在文件中或内存中) | | 像素 | Android 屏幕像素 | 视频屏幕上显示颜色的微小物理点。你也可以称之为绝对像素。术语 px 在 Android 中与维度一起使用。 | | 像素 | 每英寸像素 | 每英寸显示器的实际物理显示像素数 | | (灰)点/英寸 (扫描仪的清晰度参数) | 每英寸点数 | DPI 最初是一个打印概念,但有时也用于描述视频显示。然而,视频像素通常由多种颜色的点组成,因此 DPI 在视频显示方面会令人困惑。避免使用这个术语。 | | 数据处理 | 密度无关像素 | 最好称之为虚拟像素,因为它不存在于现实世界中。但 Android 使用这些作为应用中图形对象的测量单位。一个 dp 大约相当于一个 160 PPI 的像素。 | | Sp | 与比例无关的像素 | 非常类似于 dp,但仅用于字体。 |

关于像素的一切

屏幕是物理对象,其表面由物理像素组成。像素的产生方式会因屏幕技术的不同而不同;例如,一些使用不同颜色的多个点来产生一个像素。当一个屏幕的 1 英寸表面上有很多像素时,我们说像素密度高。确切的数值是屏幕的 PPI。例如,谷歌 Nexus 10 平板电脑的 PPI 为 300。这意味着 1 英寸是 300 像素宽。换句话说,谷歌 Nexus 10 像素是 1/300 英寸宽。最初的摩托罗拉 Xoom 平板电脑的 PPI 为 149,即每英寸 149 个物理像素。然而,这两款平板电脑的整体物理尺寸大致相同。Nexus 10 的像素密度(PPI)高于 Xoom。这意味着 Nexus 10 可以在 1 英寸见方的屏幕上显示比 Xoom 更多的细节。

Note

一个常见的误解是大屏幕具有更高的屏幕密度(PPI)。这是完全不正确的,谷歌 Nexus 10 和摩托罗拉 Xoom 平板电脑的比较就说明了这一点。

位图、图标、字形等图形图像也是由像素组成的,但是是按颜色的行和列排列的。行数代表图像的高度,列数代表宽度。但是图像的尺寸不是用英寸来表示的;它们的尺寸只是宽度和高度。

如果您决定将 96x96 位图图形 1 图像像素对 1 屏幕像素映射到 Nexus 10 平板电脑的屏幕上,它将占用 96 屏幕像素的宽度和 96 屏幕像素的高度,或者大约 0.32 英寸乘 0.32 英寸的屏幕。同样的位图以同样的方式显示在 Xoom 平板电脑上,每个维度也将占用 96 个像素,但那将是 0.64 英寸乘 0.64 英寸。这似乎不是一个很大的区别(每边只多 0.32 英寸),但它是。Xoom 上的位图看起来要大四倍!这是一个问题,因为您希望在总体大小相同的设备上保持应用外观的一致性。

将视图映射到密度

无论平板电脑的制造商是谁,平板电脑应用中的按钮看起来都应该差不多。如果您为 Xoom 创建的图形文件版本的大小是 96x96 图像的四分之一(即 48x48),那么它在 Xoom 上显示时的大小将与在 Nexus 10 上显示的 96x96 相同。或者,如果有人动态地将图形缩放到 Xoom 的四分之一大小,那也是可行的。坚持这样的想法,因为你稍后会回到这个问题上。

然而事情变得更加复杂,因为真实的屏幕有许多不同的 PPI 值。Android 团队不希望开发人员不得不处理将图形转换成每一种密度大小的数学问题,以便它在不同的屏幕密度上看起来都正确。为所有可用设备上的每个 PPI 创建唯一的图形文件也没有意义。因此,他们决定了四个密度大小的桶,称为 ldpi、mdpi、hdpi 和 xhdpi(代表低、中、高和超高),它们分别对应于 120、160、240 和 320 的 PPI。设备制造商选择他们的设备属于哪个类别,然后为该设备配置 Android,告诉应用它是这些 dpi 值之一。

设备的真实 PPI 可能与规定的密度大小(即桶大小)不同,但这没关系。在上面提到的两个片剂中,没有一个与这些值中的一个完全相同。Xoom 被归类为 mdpi 设备,因为 149 最接近 160。谷歌 Nexus 10 平板电脑被归类为 xhdpi 设备,因为 300 最接近 320。作为开发者,你不需要知道或者关心设备的实际 PPI 您将只处理设备的屏幕密度-大小桶 PPI 值。

上面提到的可能解决方案——缩放图形以适应设备的实际 PPI,并为不同的屏幕密度创建多个版本的图形——都适用于 Android。制造商处理规定的屏幕密度和设备的真实 PPI 之间的调整,开发人员负责创建每个图形文件的版本,每个版本对应应用支持的每个密度大小的存储桶。这意味着在为应用创建图形文件时,您只需担心四个 PPI 值。事实上,如果你愿意,Android 甚至可以处理桶之间的缩放。

密度比例因子

事实证明,桶大小之间的比例因子是很好的简单值。如果我们说 mdpi 是 1.0,那么 ldpi 是那个的 0.75,hdpi 是那个的 1.5,xhdpi 是那个的 2.0。如果您希望 200x200 的图形在不同的设备上以相同的大小呈现给用户,那么您可以为 ldpi 设备制作 75x75 的版本,为 mdpi 制作 100x100 的版本,为 hdpi 制作 150x150 的版本,为 xhdpi 制作 200x200 的版本。最佳实践是以尽可能高的密度创建您的原创作品,或者更好的是,使用矢量图形包,然后从那里为 Android 密度桶生成图形。这样做的原因是,你想要尽可能多的细节。从较小的尺寸开始并将图形放大到较大的尺寸会导致像素化或块化,即在图像中看到块而不是平滑曲线和渐变的效果。

另一种方法是只为 mdpi 提供一个 100x100 的文件,Android 会相应地为其他密度进行缩放。但是,如果您选择这种方法,从 mdpi 自动缩放到 xhdpi 的图形可能看起来不像您希望的那样清晰。

从技术上讲,还有另一个非常高端的密度:xxhdpi。这对应于大约 480 PPI 的密度和 3.0 的比例因子。谷歌表示,不要担心提供这种密度的图形文件,但随着市场上出现这种密度的设备,如果这会影响到你,不要感到惊讶。您可能需要提供额外的图形文件,这将增加应用的整体大小,并可能对性能和应用限制产生其他影响。

Android 团队定义了另一个屏幕密度-尺寸桶,那就是 tvdpi。它的可比 PPI 约为 213,位于 mdpi 和 hdpi 之间,旨在用于谷歌电视应用。建议不要担心智能手机或平板电脑应用的密度。但是,如果您确实想创建这种密度的图形,您可以为 mdpi 创建 100x100 的图形,并为 tvdpi 创建 133x133 的版本。

资源目录

在 Android 应用的应用文件中,ldpi、mdpi、hdpi、xhdpi 和 xxhdpi 的图形文件(也称为 drawables)有单独的目录。例如,ldpi 可绘制文件位于 res 目录下的 drawable-ldpi 目录中。基于运行应用的设备的屏幕密度设置(即 ldpi、mdpi、hdpi、xhdpi 或 xxhdpi),Android 将从适当的目录中选择适当的图形文件。如果 Android 无法在设备的屏幕密度大小桶的相应资源目录中找到命名的 drawable,它将在默认的 drawable 目录(/res/drawable)中查找 drawable 文件,或者从另一个 drawable 目录中选择一个并适当地缩放它。Android 通过选择在缩放时产生最佳结果的目录来决定其他可绘制目录的最佳选择。

如果你创建一个新的 Android 项目并查看初始的 drawable 目录,你应该会看到名为 ic_launcher.png 的默认图标文件(或者类似的文件)。请注意,每个目录中的文件名都是相同的。这很重要;您将使用文件名在布局文件中指定图像,因此不同密度的文件名必须相同——否则 Android 将无法找到它。如前所述,您实际上不需要为每个目录提供每个图形文件。Android 将选择一个,并根据运行该应用的设备的需要进行缩放。

使用 dp 指定尺寸

您已经了解到,不同的屏幕密度被简化为五个密度大小的桶,开发人员应该为属于这些桶的设备提供不同版本的图形文件。但是,Android 应用的 UI 中的图形元素的规范是怎样的呢?Android 如何知道使用哪个图形?你如何简单地设计一个用户界面?

这就是密度无关像素发挥作用的地方。使用缩写“dp”,您应该使用与密度无关的像素大小来指定图形元素的大小。与密度无关的像素大约相当于 160 PPI 屏幕上物理像素的大小。通过使用“dp”指定可绘制的大小,Android 将确定用于在运行应用的设备上绘制可绘制内容的实际物理像素数量。如果该设备被分类为 mdpi 设备,所使用的物理像素的数量将与 dps 的数量大致相同。如果设备是 xhdpi,那么使用的物理像素数将是 dps 的两倍。这意味着图形在任一设备上的尺寸将大致相同,并且布局指令将使用相同数量的 DP。例如,如果您在 UI 中用以下命令指定一个ImageView:

android:layout_width="96dp"   android:layout_height="96dp"

那么当在 mdpi 屏幕上并使用 mdpi 可绘制文件时,图像将是大约 96×96 像素。它在 ldpi 屏幕上将是 72x72 像素并使用 ldpi 可绘制文件,在 hdpi 屏幕上将是 144x144 像素并使用 hdpi 可绘制文件,在 xhdpi 屏幕上将是 192x192 像素并使用 xhdpi 可绘制文件。即 72 是 96 的 0.75 倍;144 是 96 的 1.5 倍;192 是 96 的 2 倍。

一个真正是 216 PPI 的设备,如谷歌 Nexus 7 平板电脑,很可能在 hdpi (240 PPI)桶中,Nexus 7 也是如此。在幕后,调整正在发生。如果你的应用为一个正方形图形指定了 480x480dp,那将计算出每边大约 3 英寸的距离。(请记住,dp 大约相当于 160 PPI 像素,每英寸 480 除以 160 像素等于 3。)在谷歌 Nexus 7 的情况下,图形不会是每边 2 英寸(480 除以 240),也不会是每边 2.222 英寸(480 除以 216)。Android 正在进行调整,以便图形每边 3 英寸,或每边约 648 个谷歌 Nexus 7 像素。然而,由于这里正在进行调整,图形也可能不是每边 3 英寸。如果你认为一个图形会以一个绝对尺寸出现在屏幕上,实际上可能会有一点不同。

关于 Android 的另一个误解是,你可以确定屏幕的实际 PPI。不幸的是,(到目前为止)还没有可靠的 API 调用返回这些信息。文档中说要使用DisplayMetrics(详见本章末尾的参考文献)和属性xdpiydpi。然而,在某些情况下,检索到的数据甚至与事实不符。不要试图在 Android 屏幕上绘制东西,因为你需要绘制的图像具有精确的尺寸——除非你将你的应用绑定到一个设备上,在这个设备上你确切地知道你在屏幕上处理什么,以及如何进行缩放和调整。但这严重限制了你的应用。

不同的屏幕尺寸

您的应用很可能会根据显示它的屏幕的整体大小进行不同的布局,而不考虑屏幕的实际物理像素分辨率。如果你的目标是 10 英寸的平板电脑,你几乎肯定会有相同的按钮、标签、输入栏、图像等排列。,平板电脑是低密度(ldpi)还是超高密度(xhdpi)并不重要。密度将影响哪个图形图像文件用于屏幕上的ImageView,但不影响它对用户显示的大小或它在屏幕上的位置。

筛网尺寸桶

值得重申的是,设备的屏幕尺寸与其屏幕密度并不对应。屏幕密度与要在屏幕上显示的图形文件的外观以及显示细节的能力密切相关,但是很有可能大屏幕密度低,小屏幕密度高。表 5-2 显示了截至 2013 年 5 月 1 日的已知设备组合,包括其尺寸和密度。

表 5-2。

Known Device Sizes and Densities (as of May 1, 2013)

|   | ldpi(消歧义) | mdpi(mdpi) | tvdpi | hdpi | xhdpi | xxhdpi | | --- | --- | --- | --- | --- | --- | --- | | 小的 | 9.8% |   |   |   |   |   | | 标准 | 0.1% | 16.1% |   | 37.3% | 24.9% | 1.3% | | 大的 | 0.6% | 2.9% | 1.0% | 0.4% | 0.7% |   | | 品牌介绍 | 0.2% | 4.5% |   | 0.1% | 0.1% |   |

正如你在表 5-2 中看到的,一些设备似乎确实证明了尺寸和密度是相辅相成的,一些小屏幕是 ldpi,一些超大屏幕是 xhdpi。但是绝大多数平板(大屏和超大屏)是 mdpi 密度,绝大多数手机(普通屏)是 hdpi 或 xhdpi 密度。这再次证明了屏幕尺寸与屏幕密度无关。

屏幕尺寸与你一次能向用户显示多少内容有关。屏幕越大,可以显示的内容就越多。这里,制造商又制造了许多屏幕尺寸可供选择,对于一个应用来说,单独处理每一个可能的精确屏幕尺寸几乎是不可能的。因此,Android 团队选择了四个屏幕大小的桶进行选择:小、普通、大和 xlarge。前两种一般是手机,后两种一般是平板。大尺寸最初是为 7 英寸平板电脑设计的,而 xlarge 是为 10 英寸平板电脑设计的。

事实证明,电视属于大型类别,尽管它们明显大于 10 英寸。其原因是电视是从远处观看的,因此屏幕上的对象必须占据更多的物理像素才能被正确地看到。如果电视被视为超大,则项目可能太小,观众无法阅读。当然,有些电视很大,所以这些桶不一定是找出如何布置屏幕的最佳方式。你很快就会发现还有另一种方法。

重访布局

布局通常是你在应用的用户界面中指定一切的方式。它们表示为 XML 文件,包含按钮、标签、输入字段、图像、片段和其他用户界面对象的标签。这些视图对象排列在布局标记内。

如果显示器是平板电脑,常见的布局模式是主/细节,其中屏幕一侧的列表允许用户进行选择,并且关于该选择的详细信息可以同时出现在屏幕另一侧。许多电子邮件应用在平板电脑上就是这样工作的。但是在较小的屏幕上,比如手机,没有足够的空间来同时显示列表和细节。因此,列表是单独显示的,如果用户单击列表中的某个项目,就会在列表活动的顶部显示一个详细活动。然后,用户按 Back 键返回到项目列表。见图 5-1 。

A978-1-4302-4951-1_5_Fig1_HTML.jpg

图 5-1。

Tablet vs. phone screen layout

在图 5-1 中,左边和中间的布局代表了上面有碎片的平板电脑。右边的布局代表智能手机屏幕,从本质上来说,它更小。智能手机使用片段,但一次只能看到一个片段。想象一下,如果你愿意,这是所有三个设备上的相同应用。平板电脑横向模式的布局不同于平板电脑纵向模式的布局,智能手机纵向模式的布局也不同。从功能的角度来看,Frag 中的列表会发生什么。1 对三者都是一样的。也就是说,适配器会将数据拉进一个列表,并在右边显示一个滚动条。当用户点击列表中的一个条目时,列表条目的详细信息将显示在 Frag 中。2(可能与片段中的列表同时可见,也可能不可见。1).你想尽可能多地重复使用碎片。1,以及背后的代码,同时因为设备不同而容纳不同的布局。现在让我们来看看如何管理不同设备的不同布局。

使用布局处理屏幕尺寸

应用活动的布局将根据屏幕大小而有所不同。根据设备是处于横向模式还是纵向模式,它们也会有所不同。当你的应用显示用户界面时,Android 会根据设备的屏幕大小和创建活动时设备的方向找到合适的布局。布局文件中包含布局和控件定义。

和以前/res 下不同的目录用于不同密度的图形文件一样,Android 在/res 下有不同的目录用于不同的布局。在这种情况下,可以有很多不同的目录。不仅可以有不同的大小和方向布局(最常见的),还可以根据国家、语言、夜间模式和许多其他条件有不同的布局。当 Android 需要定位一个命名的布局资源文件时,它会根据可用的同名布局资源文件选择最佳的一个,然后使用布局目录名称上的其他限定符来与设备的属性和当前配置进行比较。

指定布局目录的最简单方法如下:

/res/layout-normal-land

这代表正常尺寸屏幕上的横向布局。您可以为小、大、超大屏幕以及端口(纵向)模式创建替代布局目录。您将在您的应用项目中找到一个默认的/res/layout目录;如果 Android 找不到更好的东西可以使用,它就会去那里寻找。您也可以将布局文件存储在默认目录中,以包含在其他目录中的其他布局文件中。这允许您创建部分布局,并在应用中的任何地方使用它们,而只需维护它们一次。

你不应该根据设备的屏幕密度(PPI)创建布局目录。对于布局,你真的不关心密度是多少。你确实关心你要处理多少屏幕空间。在这里,Android 更喜欢使用密度独立像素(dp)规格的屏幕尺寸。屏幕尺寸(通常以英寸表示)不如 dps 中的屏幕尺寸重要。表 5-3 显示了不同筛网尺寸的铲斗与 dp 尺寸的关系。

表 5-3。

Screen-size bucket specs in dps

| 屏幕大小的存储桶名称 | 至少应该是这样(以 dp 表示) | | --- | --- | | 小的 | 426 x 320 | | 标准 | 470 x 320 | | 大的 | 640 x 480 | | 品牌介绍 | 960 x 720 |

屏幕尺寸用高度和宽度表示,其中高度大于宽度。这与设备的正常使用方式无关(即,横向模式还是纵向模式)。现在我们可以介绍指定布局目录的另一种方法。

Android 团队发现屏幕大小的桶是不够的。从 Android 3.2 开始,他们根据 dp 宽度和 dp 高度为布局资源目录添加了一些额外的限定符。由于大多数用户界面会垂直滚动,对用户来说很自然,所以很少看到布局资源目录使用高度限定符。所以,宽度是你会最感兴趣的限定词。这里,Android 团队有两个选项:最小宽度和当前宽度。最小宽度是一个系统值,表示屏幕尺寸的两个维度中较短的一个,与设备的方向无关。当前宽度是器件在当前方向上的宽度。布局资源目录名中的限定符将分别是–sw<N>dp–w<N>dp,其中<N>是您想要指定的宽度的最小尺寸。例如,使用/res/layout-sw600dp/res/layout–w720dp指定 600 个或更多密度无关像素的最短宽度,或 720 dp 或更多的当前宽度。

您的应用中可能有多个带有–w<N>dp限定符的布局目录,根据设备的方向选择最大的一个。例如,如果你的应用运行在一个 10 英寸的平板电脑上,并且你有一个–w600dp和一个–w1000dp布局资源目录,当 10 英寸的平板电脑处于纵向模式时,将选择–w600dp布局;当平板电脑处于横向模式时,将选择–w1000dp布局。

如果使用–sw<N>dp限定符,情况就不一样了。该器件的最小宽度为或更大,或者没有。布局文件选择不会因方向而改变。因此,如果您正在使用 –sw<N>dp限定符,并且您想要不同的纵向和横向布局,您需要添加–land和/或–port作为另一个限定符,以便为您的应用选择预期的布局文件。

根据你运行的 ADT 版本的不同,当你创建一个新的 Android 项目时,Google 可能会默认提供/res/values–sw600dp/res/values–sw720dp-land资源目录。但是,对于布局,默认只是默认的布局资源目录。查看默认的布局 XML 文件,您会发现一些边距规格,这些规格引用存储在这些默认值资源目录下的维度。尺寸文件名为 dimens.xml。在这些文件中,你会发现<dimen>标签覆盖了布局 xml 文件中的尺寸。这是另一种针对不同设备和方向定制布局的技术,无需创建大量不同的布局 XML 文件。在运行时,布局的值将基于设备的当前配置来自不同的值资源文件。

屏幕之外的不同限定符

我们已经讨论了与可绘制性和布局有关的限定符,但是 Android 还有许多其他限定符,您可以利用它们在您的应用中提供最佳的用户体验。虽然有许多限定词与键和键盘、夜晚与白天等等有关,但让我们简要地讨论一下语言。您可能不认为语言会对您的布局产生影响,但是您可能还没有见过按钮和标签被翻译成另一种语言的用户界面。填满按钮、占据标签空间的翻译单词可以极大地改变用户界面的外观。按钮可以更宽或更窄;标签也是如此。这可能会迫使您为其他语言创建替代布局。

不同的硬件特性

Android 设备可以有许多不同的硬件功能。一些设备具有陀螺仪、GPS、光传感器、磁力计、相机、蓝牙等,而其他设备几乎没有附加的硬件功能。因为把你的应用安装到一个没有你的应用正常运行所需硬件的设备上是没有意义的,所以 Android 提供了一种机制,让你可以声明你的应用需要什么硬件。通过这种方式,Google Play 将从没有该硬件的设备上的用户那里过滤掉您的应用。例如,如果您的应用需要前置摄像头,则使用没有前置摄像头的设备的用户将无法在 Google Play 中看到您的应用。

使用标签

应用的AndroidManifest.xml文件应该包含应用使用的每个硬件特性的<uses-feature>标签。标签的语法如下:

<uses-feature android:name="name.of.feature" android:required="true" android:glEsVersion="#" />

其中name.of.feature将被替换为特性的适当字符串名称,例如android.hardware.camera.front。这个标签应该在<manifest>里面,在<application>之前。必需的属性可以是 true 或 false,因此可以指定您的应用可以使用一个硬件特性(如果它存在的话),但这不是必需的。如果android:required被设置为false,你的应用实际上将在 Google Play 中对不支持该硬件功能的用户可见。这也意味着当硬件特性不存在时,您的应用需要是优雅的。在您的应用中,您可以执行以下操作:

boolean hasFrontCamera =

getPackageManager()

.hasSystemFeature(PackageManager.FEATURE_CAMERA_FRONT);

然后,当hasFrontCamerafalse时,采取适当的步骤以正常运行。

当您的应用需要高级版本的 OpenGL 库时,可以选择使用最后一个属性(android:glEsVersion)。所有的机器人都支持 1.1 版,所以如果你只需要这个属性或者你的应用不使用 OpenGL,你就不需要指定这个属性。如果需要 2.0 版本,那么将值设置为“0x 00020000”;对于 2.1 版,将该值设置为“0x00020001”高 16 位代表主版本号,低 16 位代表次版本号。

我们不要忘记,访问设备上的摄像头也需要权限(android.permission.CAMERA),并且用户必须在安装应用时授予权限。那么<uses-permission>如何与<uses-feature>合作呢?清单文件中必须有<uses-permission>标签,用于您的应用使用的任何需要权限的内容。<uses-permission>标记没有必需的属性;没有办法使许可成为可选的。对于与硬件特性相关的<uses-permission>标签,Android 将隐含地假设一个相应的<uses-feature>标签,required 设置为 true。这个规则的例外是蓝牙;更多详情请参见<uses-feature>的在线参考文档。

最佳实践是始终指定应用需要的所有<uses-feature>标签。但是如果您的应用不需要这个特性,您必须指定一个<uses-feature>标签(将android:required设置为false)。否则,<uses-permission>将导致您的应用对没有该硬件特性的设备隐藏。

如果您的应用需要某个硬件功能的权限,但该功能不是必需的,则在安装时,即使该功能在该设备上不存在,用户仍会看到正在请求权限。你可能想在 Google Play 上的应用描述中加入一些东西,向用户解释这一点,这样他们就不会混淆。

使用标签

值得指出的是,屏幕尺寸是一种硬件特性,但是使用的标签不是<compatible-screens>就是<supports-screens>。使用这些标记类型之一,您可以指定应用可以在哪些类型的设备屏幕上运行。在第一种情况下,在中,您将提供单独的标签,列出适用于您的应用的筛桶尺寸和筛桶密度的组合。Google Play 将使用这些组合对不具备所列组合之一的设备隐藏您的应用。如果你不列出一个具体的组合,Google Play 就不会用那样的设备向用户展示你的应用。例如:

<compatible-screens>

<screen android:screenSize="normal" android:screenDensity="mdpi" />

<screen android:screenSize="normal" android:screenDensity="hdpi" />

</compatible-screens>

将导致您的应用仅对具有正常屏幕尺寸和中高像素密度的用户可见。这也意味着当新的 xxhdpi 屏幕出现时,它们将看不到您的应用。这可能是一件好事,也可能是一件坏事,取决于你的观点。

Note

<compatible-screens>仅由 Google Play 和相关服务使用。它从不在设备上使用或被设备使用。

相比之下,下面的例子:

<supports-screens android:smallScreens="false"

android:normalScreens="false"

android:largeScreens="true"

android:xlargeScreens="true"

android:requiresSmallestWidthDp="600" />

说明您的应用可以在最小边长至少为 600dp 的大型或超大屏幕设备上运行。然而,这个标签实际上是告诉 Google Play 支持的最小屏幕尺寸。任何大于第一个且属性值为 true 的屏幕尺寸也将在 Google Play 中看到该应用。如果您将 normalScreens 属性值设置为 true,并将 largeScreens 和 xlarge screens 属性值设置为 false,您的应用在 Google Play 中对于大屏幕和 xlargeScreens 设备仍然可见。Android 认为可以在更大的屏幕上调整应用的大小;如果不希望调整大小,请将android:resizeable属性指定为 false。如果你真的想精确地控制哪些设备可以接收你的应用,那就坚持使用<compatible-screens>

不同版本的 Android

在这最后一节,我们将介绍如何处理“在野外”存在的所有不同版本的 Android。成为这么多不同设备上的流行操作系统是福也是祸。当谷歌发布新的操作系统时,大多数制造商和运营商不会迅速为他们的所有设备升级 Android 操作系统。有些从未升级。其他人在几个月后获得一次升级,然后再也没有获得另一次升级。其结果是许多设备在野生运行弗罗育(2.2),姜饼(2.3),冰淇淋三明治(4.0),和果冻豆(4.1,4.2 和 4.3)。还有其他设备运行 Donut (1.6)、éclair(2.1)和 Honeycomb (3.x),但它们现在很少。

Android 的每一个新版本都有新的特性,一些类和/或方法被弃用,所以它们不应该再被使用。一些特性甚至在不同版本之间改变它们的含义(例如,Froyo 之后的 MotionEvent 中的指针 id 和指针索引)。在某些情况下,Android 编程的主要概念会被移植回兼容性库中的旧版本。开发者该怎么做?

标签

首先,清单文件中的标签可以而且应该用来指定与 Android 版本的兼容性。它的语法是:

<uses-sdk android:minSdkVersion="integer"

android:targetSdkVersion="integer"

android:maxSdkVersion="integer" />

其中 minSdkVersion 告诉 Google Play 将运行该应用的最老版本是什么,maxSdkVersion 告诉 Google Play 将运行该应用的最新版本是什么。整数值是对应于更常见的 Android OS 版本的 API 级别。例如,Android Froyo 版的 SdkVersion 编号为 8。maxSdkVersion 值与设备本身没有关系,因此,如果应用安装在设备上,并且设备随后接收到超过 maxSdkVersion 的操作系统更新,则应用仍将存在于设备上,并且用户仍将被允许运行它。Android 团队表示,Android 的未来版本是向后兼容的,只要开发者遵循最佳实践,新的操作系统将负责让应用工作。

如果操作系统的版本比 targetSdkVersion 新,Android 的内置兼容性行为就会发挥作用。如果设备的操作系统版本与 targetSdkVersion 匹配,则认为应用已经过全面测试,不需要操作系统进行特殊处理。

因此,理论上,您可以为您的应用创建单独的 apk,每个版本的 Android 一个,并相应地设置<uses-sdk>标签。你甚至可以为 Google Play 中的单个应用列表上传多个 apk,这样用户会觉得这是一个应用,而实际上可能有很多。但是,不要这样。您已经看到,设备上的操作系统更新会导致应用 APK 与操作系统不匹配。如果你开始针对下一个更高版本的操作系统测试你的应用 apk,你会发疯的。你还会发现,无论如何,你的大部分应用在不同版本的 Android 之间是相同的。所以你不妨换一种方式。

您仍然需要使用标签来指定 SDK 的最低版本和目标版本。设置最大值并不重要,事实上谷歌也不鼓励这样做。基于您的应用所需的特性和 Android APIs,您希望选择适合您和您的用户的最低版本的 Android。这就是您将用于 minSdkVersion 属性值的内容。然后,您希望为您认为最受欢迎的版本指定 targetSdkVersion,或者使您的生活最轻松的版本。这同样可以基于该版本 Android 中可用的 API。选择一个缺少您的应用可以使用的东西的 minSdkVersion 是很好的,只要您的应用在没有该东西的情况下仍然可以运行。您很快就会看到,您将在代码中容纳缺失的项目。

在代码中处理 Android 版本的一个最简单的方法是询问设备它运行的是什么版本,并据此采取行动。Build.VERSION.SDK_INT static int 以整数值的形式保存设备的 API 级别。所以使用起来非常简单:

if (Build.VERSION.SDK_INT >= 14) { ...

然后你就可以做以前只能在 Android 4.0 及以上版本上做的事情了。要查看最终的值列表,请查看Build.VERSION_CODES上的文档。

您还可以使用 Java 反射来查看某个类或类方法是否存在,如果存在,您可以使用它,如果不存在,则不使用它。如果您的应用依赖于某些特定于制造商的 API,而这些 API 不会存在于某个版本的 Android 的所有设备中,那么您也可以使用这种方法。关于以这种方式使用反射的例子,请参见第八章(高级调试)中的“旧 Android 版本的严格模式”一节。

兼容性库

当 Android 在 Honeycomb 中引入片段时,它代表了如何构建 Android 应用的一个重要新概念。Android 团队没有创建以前版本的 Android 操作系统的新版本,而是创建了一个兼容性库,其中包含用于旧操作系统的片段、加载器和其他几个代码。现在有几个版本的库,并在此过程中添加了额外的功能。通过在您的应用中包含 Android 支持库,并以 11 或更高的 Android API 级别为目标,您可以相当容易地使用片段和其他现代功能,同时支持 Android 的旧版本。

Note

如果您在使用高 targetSDK 构建应用时遇到问题,Google 建议您尝试将 targetSDK 设置为与 minSDK 相同的版本。

库 jar 文件位于 Android SDK 中的extras/support/android下。然而,包含库的最简单方法是在 Eclipse 中右键单击您的项目名称,选择 Android Tools 菜单,然后选择菜单项 Add Support Library。这为你设置好了一切。现在是警告。。。

为了让兼容性库既能支持不支持新功能的旧版本 Android,也能支持新版本 Android,你必须在代码中做一些改动。第一个主要的是用一个FragmentActivity代替一个Activity;,用FragmentActivity.getSupportFragmentManager()代替FragmentActivity.getFragmentManager();,用FragmentActivity.getSupportLoaderManager()代替FragmentActivity.getLoaderManager()。与以前不同的是,您检查 Android 的版本,为一个或另一个版本的 Android 执行不同的代码,您只需为所有版本的 Android 使用兼容性库类,在幕后,一切都适用于您的应用运行的任何版本的 Android。

为 Honeycomb 或更高版本编写应用和用兼容性库编写应用的另一个很大的区别是兼容性库中不支持ActionBar。关于蜂巢的想法发生了转变,从在应用上使用菜单到使用ActionBar。当你的应用运行在一个有ActionBar的 Android 版本上时,你希望它能被正确使用。当您的应用运行在旧版本上时,您会看到一个选项菜单。您需要做的是为 Options 菜单编写代码,但是为您在ActionBar上想要的每个菜单项调用一个特殊的兼容性库实用程序方法。在运行时,会出现正确的行为。清单 5-1 显示了菜单 XML 文件的样子,后面是正确实例化ActionBar或选项菜单的 Java 代码。

清单 5-1。兼容菜单和动作栏的 XML 和 Java

<!-- This file is /res/menu/options.xml -->

<menu xmlns:android="http://schemas.android.com/apk/res/android

<item android:id="@+id/menu_item1"

android:title="Item1"

android:icon="@android:drawable/ic_media_previous"

android:orderInCategory="0" />

<item android:id="@+id/menu_item2"

android:title="Item2"

android:icon="@android:drawable/ic_media_next"

android:orderInCategory="1" />

<item android:id="@+id/menu_item3"

android:title="Item3"

android:icon="@android:drawable/ic_menu_compass"

android:orderInCategory="1" />

</menu>

@Override

public boolean onCreateOptionsMenu(Menu menu) {

MenuInflater inflater = getMenuInflater();

inflater.inflate(R.menu.options, menu);

MenuItemCompat.setShowAsAction(menu.findItem(R.id.menu_item1)

MenuItemCompat.SHOW_AS_ACTION_ALWAYS);

MenuItemCompat.setShowAsAction(menu.findItem(R.id.menu_item2)

MenuItemCompat.SHOW_AS_ACTION_ALWAYS);

MenuItemCompat.setShowAsAction(menu.findItem(R.id.menu_item3)

MenuItemCompat.SHOW_AS_ACTION_NEVER);

return true;

}

这个清单采用了一些快捷方式,比如在 XML 文件中硬编码菜单项的文本,这是您通常不会做的。我们借用安卓图标,而不是创造自己的图标。但是这样更容易演示。菜单看起来很标准。变化出现在Activity中的onCreateOptionsMenu()代码中。一旦创建了菜单,不管你是在蜂窝系统之前的 Android 还是更新的 Android 上,你都可以使用MenuItemCompat.setShowAsAction()方法并指定该项目应该如何出现在ActionBar上。兼容性库将这种方法视为没有ActionBar的旧版本 Android 的禁用方法,因此没有必要做任何特殊处理。对于支持ActionBar的 Android 版本,这个方法调用将设置ActionBar而不是选项菜单。

Note

清单 5-1 中的代码包含在我们网站的可下载 zip 文件中。有关更多信息,请参阅本章末尾的参考资料。

兼容性库中有很多类和方法,我们无法在本章中介绍。要查看所有可用的内容,请访问在线 Android 参考文档,并开始搜索 android.support。当您在搜索字段中键入该内容时,您将看到兼容性库中的软件包选择。如果你很好奇,你也可以通过 Android SDK 管理器把源代码下载到兼容库,它会把它和 jar 文件一起安装在 Android SDK 目录的extras/android/support下。大部分来源在extras/android/support/v4/src/java/android/support/v4/app下。

参考

以下是一些对您可能希望进一步探索的主题有帮助的参考:

摘要

本章介绍了以下内容:

  • 屏幕密度以及物理像素和与密度无关的像素之间的差异
  • 屏幕密度与图像在尺寸和质量方面的关系
  • 屏幕尺寸和四个桶的描述
  • 解释屏幕尺寸与布局的关系
  • Android 3.2 新增布局资源限定符(-sw<N>dp–w<N>dp)
  • 语言限定符及其对布局的影响
  • 如何指定应用可能需要或使用的硬件特性,包括屏幕
  • 处理不同版本 Android 的几种方法,包括兼容性库

复习问题

以下是一些你可以问自己的问题,以巩固你对这个话题的理解:

True or false? The more pixels on a device’s display, the bigger the display is.   True or false? An image file that is 160x160 pixels will always appear 1-inch square on a display.   What density bucket would a device with a PPI of 206 be in?   To make an image that is 120x240 on an hdpi display look the same size on an ldpi display, what should its dimensions be?   What kinds of files would you find in a drawables resource directory?   What are the differences between physical pixels and density-independent pixels?   Why are there four screen sizes: small, normal, large, and xlarge? Why use these and not actual screen dimensions?   What are some of the qualifiers that can be used for a layout resource directory? Describe their relevance to layouts.   When should you use the tag in the AndroidManifest.xml file? Why?   How would you ensure that your application could be seen in Google Play for devices that use a normal screen, but not on devices that use a large or xlarge screen?   What are three attributes of the tag?   True or false? All Android applications for Gingerbread devices must now be built using the compatibility library.   Under what circumstances would you not need to use the compatibility library?   Does the compatibility library provide an ActionBar for pre-Honeycomb releases of Android?

六、高级表单处理

Abstract

表单处理是编写计算机应用(包括移动应用)的常见需求。在 Android 中,您可以通过设计活动、对话框或片段来满足这一需求,这些活动、对话框或片段的行为类似于具有大量数据输入字段的表单。

表单处理是编写计算机应用(包括移动应用)的常见需求。在 Android 中,您可以通过设计活动、对话框或片段来满足这一需求,这些活动、对话框或片段的行为类似于具有大量数据输入字段的表单。

开箱即用,Android SDK 具有基于类型控制每个字段行为的基本机制。Android SDK 还允许您在检测到字段出错时,以编程方式在每个字段上设置错误图标和消息。当表单上有一个或两个字段时,这些基本机制非常有用。然而,根据我们的经验,即使只有三四个字段,这种逐个字段的验证也会变得重复和笨拙。

所以我们问了这个问题,“有没有适用于 Android 的表单处理库?”在我们的研究中,我们在开源 Android 社区中发现了一些库。我们会在本章末尾给你一些解决方案的参考。虽然你可以下载并使用其中的一个库,但我们认为有必要在本章解释高级表单处理的一般原则。此外,我们还向您展示了如何开发一个简单而有效的表单处理框架。

解决这个表单处理问题的一般步骤非常简单。了解这些步骤将允许您为您可能遇到的开源库无法解决的许多情况定制解决方案。考虑到这一点,我们将在本章中讲述以下内容:

A simple application that requires form processing in order to address challenges of field-by-field validation   A general-purpose design to deal with form and field validations   Annotated source code that you can use to further customize the presented form processing framework

当您使用我们在本章中记录的方法时,表单处理变得简单。然后,您可以专注于应用的主要行为,而不会迷失在粒度字段级验证的细节中。

基于规划表单的应用

为了演示字段验证的概念和代码,我们创建了一个简单的表单,允许用户注册服务。这个注册表单有常见的字段,如用户 ID、电子邮件地址和密码,如图 6-1 所示。密码字段是重复的,以确保准确性。

A978-1-4302-4951-1_6_Fig1_HTML.jpg

图 6-1。

A sample signup form

完成注册需要图 6-1 中的所有字段。如果用户单击注册按钮而没有填写某些字段,您需要突出显示这些字段,并告诉用户在提交表单之前填写这些字段。

图 6-2 显示了如果用户点击注册按钮而不填写字段,屏幕会是什么样子。

A978-1-4302-4951-1_6_Fig2_HTML.jpg

图 6-2。

Required field validation on form submit

在图 6-2 中,注意所有必填但未填写的字段都被高亮显示。此外,对于第一个必填字段,如果留空,会出现一条错误消息,指示该字段是必填的。图 6-3 显示了当一些字段被填写并且用户点击注册按钮提交表单时的表单。

A978-1-4302-4951-1_6_Fig3_HTML.jpg

图 6-3。

Partially filled-in form fields

在图 6-3 中,请注意,一旦用户开始在其中一个字段中输入信息,该字段的错误指示器就会消失。图 6-4 显示了当密码字段不匹配时该表单的另一种变体。

A978-1-4302-4951-1_6_Fig4_HTML.jpg

图 6-4。

Multi-field validation

在图 6-4 中,观察错误信息是如何明确错误性质的。当用户成功填写所有字段并点击注册按钮时,应用将向前移动,并可能显示如图 6-5 所示的成功活动。

A978-1-4302-4951-1_6_Fig5_HTML.jpg

图 6-5。

Successfully submitted form

到目前为止,我们已经说明了行为合理的基于表单的应用的要求。我们现在开始设计一个优雅的字段验证框架,它可以满足这些表单处理需求。我们将从一般原则开始。

表单验证的一般步骤

Android 中没有内置的框架来完全实现我们的代表性应用中所指示的行为。然而,Android 确实具有构建一个非常好的表单验证框架所需的基本特性。

构建良好的表单处理框架的一般步骤如下:

When you create the layout file for your form activity, use inputType attribute on the controls (or fields) that make up the form. There are a number of default input types, such as plain text, email, or a decimal number. Specifying the right input type automatically controls the user input that can be entered into that field on every key stroke. This feature is out of the box in the Android SDK.   Then you write validators that can check the behavior that is specific to that field when the form is submitted. These specific validators are attached to each field. They are not in the core framework, so you need to design them. We will show you an implementation for this.   Once you have the validators for each field, you attach the validators through code (as we do in this chapter) to the corresponding fields. You can also attach the validators to their fields through metadata-driven Java annotations. We have provided a URL to a framework that does precisely this; see the References at the end of this chapter.   Once the validators are attached to all the fields, you gather the fields into an abstraction called a Form or a FieldSet, so that you can validate the entire field set just once when the form is submitted. Again, this step is not core Android, but it will be part of the form-validation framework.

你在 Android 社区中遇到的大多数框架都使用了类似的方法,或者略有不同。在本章的其余部分,我们将向您展示如何调整这些通用过程来实现您的表单验证框架。

设计基础活动

我们的样本表单验证框架使用了一个基于继承的解决方案,包括三个活动级别。

BaseActivity extends Activity

FormActivity extends BaseActivity

SignupFormTestActivity extends FormActivity

在这个活动层次中,SignupFormActivity如图 6-1 所示。FormActivity抽象了字段集并允许字段验证的方法。BaseActivity封装了非常常见的方法。让我们先看看代码,分析一下BaseActivity的设计。该活动的代码如清单 6-1 所示。

清单 6-1。封装常用函数的 BaseActivity

/*

* Provides many utility methods that are used by inherited classes.

* The utility methods include such things as toasts, alerts, log messages

* and navigating to other activities.

* Also includes the ability to turn on/off progress dialogs.

*/

public abstract class BaseActivity extends Activity

{

//Uses the tag from derived classes

private static String tag=null;

//To turn/off progress dialogs

private ProgressDialog pd = null;

//Transfer the tag from derived classes

public BaseActivity(String inTag)    {

tag = inTag;

}

//Just a way to log a message

public void reportBack(String message)    {

reportBack(tag,message);

}

public void reportBack(String tag, String message)    {

Log.d(tag,message);

}

//report a transient message and log it

public void reportTransient(String message)    {

reportTransient(tag,message);

}

//Report it using a toast

public void reportTransient(String tag, String message)

{

String s = tag + ":" + message;

Toast mToast = Toast.makeText(this, s, Toast.LENGTH_SHORT);

mToast.show();

reportBack(tag,message);

Log.d(tag,message);

}

//we often need to do string validations

public boolean invalidString(String s)   {

return StringUtils.invalidString(s);

}

public boolean validString(String s)    {

return StringUtils.validString(s);

}

//we often need to transfer to other activities

public void gotoActivity(Class activityClassReference)

{

Intent i = new Intent(this,activityClassReference);

startActivity(i);

}

//On callbacks turn on/off progress bars

public void turnOnProgressDialog(String title, String message){

pd = ProgressDialog.show(this,title,message);

}

public void turnOffProgressDialog()    {

pd.cancel();

}

//Sometimes you need an explicit alert

public void alert(String title, String message)

{

AlertDialog alertDialog = new AlertDialog.Builder(this).create();

alertDialog.setTitle(title);

alertDialog.setMessage(message);

alertDialog.setButton(DialogInterface.BUTTON_POSITIVE

"OK"

new DialogInterface.OnClickListener() {

public void onClick(DialogInterface dialog, int which) {

}

});

alertDialog.show();

}

}//eof-class

清单 6-1 中的BaseActivity很大程度上是通过代码中的行内注释自我记录的。我们发现这种将最常见的函数封装在基类中的方法对于继承的类非常有用。事实上,如果你注意到了,在同一个清单 6-1 中,我们甚至煞费苦心地从一个名为StringUtils的静态类中重新定义了一些方法,使得最常见的方法更容易调用。为了保证BaseActivity的完整性,下面是StringUtils的代码,如清单 6-2 所示。

清单 6-2。常用的基于字符串的方法

public class StringUtils {

public static boolean invalidString(String s)    {

return !validString(s);

}

public static boolean validString(String s)    {

if (s == null)        {

return false;

}

if (s.trim().equalsIgnoreCase(""))  {

return false;

}

return true;

}

}

Note

请注意,您可以从我们的网站下载整个项目。本章末尾给出了该 URL。

现在我们有了一个BaseActivity,让我们进入FormActivity类,它是表单验证框架的入口。

表单活动的设计与实现

BaseActivity类扩展而来的一个FormActivity提供了它的子类,其他基于表单的活动,一个精简的和不易出错的方法来收集和验证字段。这个基类(FromActivity)的职责是:

Provide an ability for derived form-based classes to add fields that can be validated   Provide a method to run validation on all of the fields when the form is submitted   As part of the validation, make the fields responsible for setting themselves up with appropriate errors

在我们看了清单 6-3 中的FormActivity, shown的源代码后,我们将讨论每一个责任。

清单 6-3。FormActivity 封装字段验证

public abstract class FormActivity

extends BaseActivity

{

public FormActivity(String inTag) {

super(inTag);

}

//Provide an opportunity to add fields

//to this form. This is called a hook method

protected abstract void initializeFormFields();

//See how the above hook method is called

//whenever the content view is set on this activity

//containing the layout fields.

@Override

public void setContentView(int viewid) {

super.setContentView(viewid);

initializeFormFields();

}

//A set of fields or validators to call validation on

private ArrayList<IValidator> ruleSet = new ArrayList<IValidator>();

//Add a field which is also a validator

public void addValidator(IValidator v)    {

ruleSet.add(v);

}

//Validate the every field in the form

//Call this method when a form is submitted.

public boolean validateForm()

{

boolean finalResult = true;

for(IValidator v: ruleSet)

{

boolean result = v.validate();

if (result == false)

{

finalResult = false;

}

//if true go around

//if all true it should stay true

}

return finalResult;

}

}//eof-class

清单 6-3 的关键部分被突出显示。让我们首先考虑这个FormActivity类如何允许派生类添加字段。FormActivity有一个名为initializeFormFields()的抽象方法。该方法需要由派生类实现,以初始化和添加需要验证的字段。

为了确保方法initializeFormFields()被调用,FormActivity恢复了覆盖Activity类的setContentView()的技巧。方法setContentView()通常由派生类调用来为活动设置布局或主屏幕。因此,这是一个收集视图中需要验证的字段的好地方。认识到这一点,FormActivity自动调用initializeFormFields()作为被覆盖的setContentView()的一部分。

Note

如果出于某种原因,这种覆盖setContentView()的方法不适合您,那么您可以直接从活动创建回调中调用initializeFormFields()函数。这样,这个呼叫就没有魔力了;只需要在活动创建开始时调用它。

强制派生类遵守规定协议的方法被称为template/hook模式。这里的hook方法就是initializeFormField()。在特定时间触发钩子的template方法是setContentView()。在这种模式中,派生类仅仅实现隔离的动作单元(比如initializeFormFields)。template方法将定义协议何时以何种顺序调用这些动作,以及何时调用多少次,等等。

您将很快看到派生类是如何实现initializeFormField()的,在这里它们将调用addValidator()方法。addValidator()方法依赖于下面的类来工作:

IValueValidator //Represents how to validate any string

IValidator //Represents a validatable entity that can self report errors

Field //extends a Validator and also allows

清单 6-4 显示了IValueValidator接口的定义。

清单 6-4。ivalue validator:value validating 类的协定

/*

* An interface for such value validators as RegExValidator

*/

public interface IValueValidator

{

//Given a string to see if it is valid

boolean validateValue(String value);

//what should be the error message when the field is wrong

String getErrorMessage();

}

因此,值验证器负责验证一个字符串值,如果它无效,它会确定它有什么问题。这个接口的目的是让一个像Field这样的对象可以附加许多值验证器。一个验证器可能正在检查该值是否必须是 10 个字符。另一个验证器可以检查所有的字符都是数字。然后,Field可以通过传递它的值并根据一组验证器对它进行评估来检查每个值验证器。

Field实现了一个稍微不同的接口,叫做IValidator。在我们看一下Field的实现之前,让我们先看看这个,如清单 6-5 所示。

清单 6-5。IValidator:自报告实体(如字段)的合同

public interface IValidator {

public boolean validate();

}

一个IValidator类似于一个IValueValidator。然而,与IvalueValidator不同的是,IValidator不仅要验证,还要反映验证实体的含义,比如改变被验证实体的状态。例如,当一个Field被验证并且如果Field是错误的,则Field将显示一条错误信息并且还显示一个图标(参见图 6-1 )。当你检查Field类的实现时,你会看到这种关系,没有延迟,现在如清单 6-6 所示。

清单 6-6。字段:表示控件验证行为的具体类

public class Field

implements IValidator

{

//The underlying control this field is representing

private TextView control;

//Because whether required or not is so essential

//give it a special status.

private boolean required = true;

//A list of value validators to be attached

private ArrayList<IValueValidator> valueValidatorList

= new ArrayList<IValueValidator>();

public Field(TextView tv) {

this(tv, true);

}

public Field(TextView tv, boolean inRequired) {

control = tv;

required = inRequired;

}

//Validate if it is a required field first.

//Also run through all the value validators.

//Stop on the first validator that fails.

//Show the error message from the failed validator.

//Use the android setError to show the errors.

@Override

public boolean validate()

{

String value = getValue();

if (StringUtils.invalidString(value))

{

//invalid string

if (required)

{

warnRequiredField();

return false;

}

}

for(IValueValidator validator: valueValidatorList)

{

boolean result = validator.validateValue(getValue());

if (result == true) continue;

if (result == false)

{

//this validator failed

String errorMessage = validator.getErrorMessage();

setErrorMessage(errorMessage);

return false;

}

}//eof-for

//All validators passed

return true;

}//eof-validate

private void warnRequiredField() {

setErrorMessage("This is a required field");

}

public void setErrorMessage(String message)    {

control.setError(message);

}

public String getValue() {

return this.control.getText().toString();

}

}//eof-class

现在,我们可以讨论表单验证框架的关键组件的实现细节了。Field本身既是一个验证器,也有一组值验证器。一个Field实现了IValidator的契约,因为它不仅想验证自己,还想显示更正该字段所需的任何线索或提示。

一个给定的字段在一个表单中是否是必需的是如此的基本和重要,以至于我们已经将该功能直接硬编码到Field定义中。一个字段上的其余验证可以封装到许多值验证器中。

所以,这里是一个Field如何工作。派生类用它的底层编辑字段初始化一个Field。然后,派生类附加一系列值验证器来进一步验证该字段。然后将Field添加到FormAcvity中,成为验证表单时得到验证的字段集的一部分。下面是如何使用Field对象的伪代码,如清单 6-7 所示。

清单 6-7。创建和注册字段对象的伪代码

//Say emailEditText is a required form field

EditText emailEditText;

//Create a Field object that wraps the emailEditText

//By default the field becomes a required field

Field emailField = new Field(emailEditText);

//Add further validators. Here are some sample validators

emailField.addValidator(new StrictEmailValidator());

emailField.addValidator(new MaxLenghtValidator());

//Add this field to the form field set

addValidator(emailField);

...add other fields similarly if you have them

addValidator(field2);

..etc

清单 6-7 中的伪代码显示了如何处理单个字段。有时候你也要做跨领域的验证。例如,在图 6-1 中,如果两个密码字段必须匹配才能通过表单验证,那么任何一个字段验证都不能满足这个要求。清单 6-8 展示了如何创建一个复合字段来完成这种多字段验证。

清单 6-8。PasswordRule:多字段验证的一个例子

/*

* A class simulating multi-field validation

*/

public class PasswordFieldRule implements IValidator

{

private TextView password1;

private TextView password2;

public PasswordFieldRule(TextView p1, TextView p2)

{

password1 = p1;

password2 = p2;

}

@Override

public boolean validate()

{

String p1 = password1.getText().toString();

String p2 = password2.getText().toString();

if (p1.equals(p2))

{

return true;

}

//They are not the same

password2.setError("Sorry, password values don't match!");

return false;

}

}//eof-class

可以将类PasswordFieldRule添加到表单中,就像它是另一个字段一样。清单 6-9 是一个基于通用正则表达式的值赋值函数的例子。

清单 6-9。正则表达式值验证器

/*

* A general purpose regular expression value validator

*/

public class RegExValueValidator

implements IValueValidator

{

private String regExPattern;

private String error;

private String hint;

RegExValueValidator(String inRegExPattern

String errorMessage, String inHint)

{

regExPattern = inRegExPattern;

error = errorMessage;

hint = inHint;

}

@Override

public boolean validateValue(String value) {

if (value.matches(regExPattern) == true)

{

return true;

}

return false;

}

@Override

public String getErrorMessage() {

return error + ". " + hint;

}

}

到目前为止,我们已经解释了最终实现SignupTestFormActivity所需的所有类,这些类将实现示例应用所需的行为。

实现 SignupActivityTestForm

清单 6-10 显示了源代码,它汇集了到目前为止本章涉及的所有细节,并展示了现在做字段验证是多么简单。

清单 6-10。SignupActivityTestForm:将所有内容放在一起

/*

* A test form to demonstrate field validation

*/

public class SignupActivityTestForm

extends FormActivity

{

private static String tag = "SignupActivity";

//Form Fields

EditText userid;

EditText password1;

EditText password2;

EditText email;

public SignupActivityTestForm()    {

super(tag);

}

/** Called when the activity is first created. */

public void onCreate(Bundle savedInstanceState) {

super.onCreate(savedInstanceState);

setContentView(R.layout.signup);

}

//from FormActivity

@Override

protected void initializeFormFields()

{

this.reportBack("form initialized");

//Keep local variables

userid = (EditText)findViewById(R.id.userid);

password1 = (EditText)findViewById(R.id.password1);

password2 = (EditText)findViewById(R.id.password2);

email = (EditText)findViewById(R.id.email);

//Setup the validators

addValidator(new Field(userid));

addValidator(new Field(password1));

addValidator(new Field(password2));

addValidator(new Field(email));

addValidator(new PasswordFieldRule(password1,password2));

}

public void signupButtonClick(View v)

{

if (validateForm() == false)

{

reportTransient("Make sure all fields have valid values");

return;

}

//everything is good

String userid = getUserId();

String password = getPassword1();

String email = getUserEmail();

reportTransient("Going to sign up now");

signup(userid, email, password);

}

private void signup(String userid, String email, String password)

{

gotoActivity(WelcomeActivity.class);

}

//Utility methods

private String getUserId()    {

return getStringValue(R.id.userid);

}

private String getUserEmail()    {

return getStringValue(R.id.email);

}

private String getPassword1()    {

return getStringValue(R.id.password1);

}

private String getStringValue(int controlId)

{

TextView tv = (TextView)findViewById(controlId);

if (tv == null)

{

throw new RuntimeException("Sorry Can't find the control id");

}

//view available

return tv.getText().toString();

}

}//eof-class

除了注册表单活动的源之外,让我们看看相应的布局文件,如清单 6-11 所示,这样您就可以识别您试图验证的字段。

清单 6-11。支持 SignupActivityTestForm 所需字段的布局文件

<?xml version="1.0" encoding="utf-8"?>

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android

android:layout_width="fill_parent"

android:layout_height="fill_parent"

android:orientation="vertical" >

<!-- Userid -->

<TextView android:layout_width="fill_parent"

android:layout_height="wrap_content" android:text="Userid" />

<EditText android:id="@+id/userid"

android:layout_width="fill_parent" android:layout_height="wrap_content"/>

<!-- email -->

<TextView android:layout_width="fill_parent"

android:layout_height="wrap_content" android:text="email" />

<EditText android:id="@+id/email" android:layout_width="fill_parent"

android:layout_height="wrap_content"``android:inputType="textEmailAddress"

<!-- password1 -->

<TextView android:layout_width="fill_parent" android:layout_height="wrap_content"

android:text="password" />

<EditText android:id="@+id/password1" android:layout_width="fill_parent"

android:layout_height="wrap_content"``android:inputType="textPassword"

<!-- password2 -->

<TextView android:layout_width="fill_parent" android:layout_height="wrap_content"

android:text="Reenter Password" />

<EditText android:id="@+id/password2" android:layout_width="fill_parent"

android:layout_height="wrap_content" android:inputType="textPassword"

/>

<!-- form submit button -->

<Button android:id="@+id/SignupButton" android:layout_width="fill_parent"

android:layout_height="wrap_content" android:onClick="signupButtonClick"

android:text="SignUp" />

</LinearLayout>

现在让我们一节一节地分析清单 6-10 中SignupActivityTestForm的行为。我们从initializeFormFields()方法开始。在这里,我们首先收集代表我们的字段的所有控件。然后,我们将所有字段作为必填字段注册到表单中。此外,我们创建一个PasswordRule,它将password1password2字段作为输入。

当注册按钮(见图 6-1 和清单 6-11)被点击时,它调用函数signupButtonClick()。这个方法又调用基类FormActivity定义的validateForm()方法。(关于这个方法的实现,请参见清单 6-3。)如果字段无效,它们会自动设置错误指示器和消息。用户焦点被带到第一个出错的字段。

如果整个表单都是有效的,那么控制转到signupSuccessful()方法。该方法只是调用BaseActivity定义的gotoActivity()来调用WelcomeActivity,如图 6-5 所示。

Note

注意,我们没有给出 WelcomeActivity 的源代码。如果你想了解 Android 可以完成的最简单的活动,你可以下载这一章的内容。

在现实世界中,注册可能涉及到服务器端调用,要求您提供进度对话框。当你需要的时候,这些条款都在BaseActivity里。(参见清单 6-1 中的BaseActivity。)

还要注意,在用于SignupActivityTestForm的布局文件(清单 6-11)中,我们使用了android:inputType属性来描述输入字段的性质。这是 Android 中的一个关键条款,它限制了可以在文本字段中输入的内容。我们在本章的参考资料中提供了一个 URL,在那里你可以找到所有可能的inputTypes。这些输入类型包括:

text

textCapCharacters

textCapWords

textMultiLine

textUri

textEmailSubject

textEmailAddress

textPersonName

textPostalAddress

textWebEditText

number

numberSigned

numberDecimal

numberPassword

phone

datetime

date

time

最后,请注意,您可以从本章末尾指定的 URL 下载示例程序,亲自测试该行为。

对创建基于表单的活动的改进

创建基于表单的活动是乏味且重复的。我们在这里概述了如何简化这项工作。您还可以根据自己的需要进一步定制和优化这个框架。例如,您可能希望使用 Java 元数据注释来注册字段。在这一章中,我们使用 Java 代码来使框架对你是透明的。或者您可能希望两种方法都允许。在这一章中,我们还使用了继承方法,将各自的活动规定为相互扩展。这可能会带来一些限制,因为除了接口之外,multiple inheritance在 Java 中不可用。您可能希望将这种方法转换为基于委托的方法,以便缓解这种约束。也有可能你的表单在 Android Fragment中,而不在活动中。在这种情况下,您需要定制您的框架来适应片段,而不是活动。

如果您有许多基于表单的活动,您可能希望使用一个简单的代码生成框架来创建活动类和 xml 布局文件,并注册字段以进行验证。然后,您可以使用生成的代码作为起点来修改代码。这里有一个这种意图的简单例子。比方说,你想参加一个像我们在本章中所展示的活动。你只要说:

<form>

<email>

<userid>

<password1>

<password2>

<signup type="button">

</form>

现在代码生成器可以创建所有的工件:activity 类、布局 xml 文件、创建字段的必要方法等等。事实上,我们鼓励您根据自己的具体需求来完善这个解决方案。

参考

我们发现以下链接对本章的研究很有帮助。

摘要

在编写移动应用时,基于表单的活动也很常见。本章介绍了一个用于验证表单域的灵活框架。开发人员可以进一步增强这个框架,以满足他们的特定需求。

复习问题

以下问题有助于巩固您在本章中学到的知识:

What is a good way to write form-based activities in Android?   What base Android SDK features are available to aid field validations in the Android SDK?   How do you use regular expressions to validate form fields?   What is an android:inputType attribute and how many input types are available?   How can you abstract progress dialogs in base classes?   How can you abstract alerts in base classes?

七、使用电话 API

Abstract

许多 Android 设备都是智能手机,但到目前为止,我们还没有谈到如何编写使用手机功能的应用。在本章中,我们将向您展示如何发送和接收短信息服务(SMS)信息。我们还将涉及 Android 中电话 API 的其他几个有趣的方面,包括会话发起协议(SIP)功能。SIP 是用于实现互联网协议语音(VoIP)的 IETF 标准,通过该标准,用户可以在互联网上进行类似电话的呼叫。SIP 也可以处理视频。

许多 Android 设备都是智能手机,但到目前为止,我们还没有谈到如何编写使用手机功能的应用。在本章中,我们将向您展示如何发送和接收短信息服务(SMS)信息。我们还将涉及 Android 中电话 API 的其他几个有趣的方面,包括会话发起协议(SIP)功能。SIP 是用于实现互联网协议语音(VoIP)的 IETF 标准,通过该标准,用户可以在互联网上进行类似电话的呼叫。SIP 也可以处理视频。

使用 SMS

SMS 代表短消息服务,但它通常被称为文本消息。Android SDK 支持发送和接收文本消息。通过使用 SMS 管理器,您可以发送和接收任意长度的消息。如果消息超过了单个消息的字符限制,SMS 管理器提供了一种以块为单位发送较长消息的方法。SMS 管理器还提供发送消息成功或失败的状态更新。我们将从讨论如何用 SDK 发送 SMS 消息开始。

发送短信

要从您的应用发送文本消息,您需要将android.permission.SEND_SMS权限添加到您的清单文件中,然后使用android.telephony.SmsManager类。这个例子的第一段 Java 代码见清单 7-1,它完成了消息发送。

清单 7-1。发送 SMS(文本)消息

public class TelephonyDemo extends Activity

{

protected static final String TAG = "TelephonyDemo";

protected static final String SENT_ACTION =

"com.androidbook.telephony.SMS_SENT_ACTION";

protected static final String DELIVERED_ACTION =

"com.androidbook.telephony.SMS_DELIVERED_ACTION";

@Override

protected void onCreate(Bundle savedInstanceState) {

super.onCreate(savedInstanceState);

setContentView(R.layout.main);

}

public void doSend(View view) {

EditText addrTxt =

(EditText)findViewById(R.id.addrEditText);

EditText msgTxt =

(EditText)findViewById(R.id.msgEditText);

try {

sendSmsMessage(

addrTxt.getText().toString()

msgTxt.getText().toString());

} catch (Exception e) {

Toast.makeText(this, "Failed to send SMS"

Toast.LENGTH_LONG).show();

e.printStackTrace();

}

}

private void sendSmsMessage(String address, String message)

throws Exception

{

SmsManager smsMgr = SmsManager.getDefault();

// Split the message up into manageable chunks if needed

ArrayList<String> messages = smsMgr.divideMessage(message);

if(messages.size() > 1) {

int count = messages.size();

// Will need to send with multipart

// so prepare the pending intents

ArrayList<PendingIntent> sentPIs =

new ArrayList<PendingIntent>(count);

ArrayList<PendingIntent> deliveredPIs =

new ArrayList<PendingIntent>(count);

for(int i = 0; i<count; i++) {

sentPIs.add(PendingIntent.getBroadcast(

TelephonyDemo.this, 0

new Intent(SENT_ACTION), 0));

deliveredPIs.add(PendingIntent.getBroadcast(

TelephonyDemo.this, 0

new Intent(DELIVERED_ACTION), 0));

}

smsMgr.sendMultipartTextMessage(address, null

messages, sentPIs, deliveredPIs);

Toast.makeText(this, "Multipart SMS Sent"

Toast.LENGTH_LONG).show();

}

else {

smsMgr.sendTextMessage(address, null, message

PendingIntent.getBroadcast(

TelephonyDemo.this, 0

new Intent(SENT_ACTION), 0)

PendingIntent.getBroadcast(

TelephonyDemo.this, 0

new Intent(DELIVERED_ACTION), 0)

);

Toast.makeText(this, "SMS Sent"

Toast.LENGTH_LONG).show();

}

}

}

Note

我们会在本章末尾给你一个 URL,你可以用它来下载本章中的项目。这将允许您将这些项目直接导入到 Eclipse 中。

发送 SMS 消息的代码是通过在 UI 中单击按钮来调用的。地址和文本消息被传递给sendSmsMessage()。您可能想在您的实际应用中做一些编辑检查。SmsManager有一个名为divideMessage()的方法,将消息字符串分割成符合 SMS 规范的块。如果有一个以上的块,你要用sendMultipartTextMessage()的方法;否则,你要用sendTextMessage()。无论哪种情况,您都想知道发送是否成功。这就是PendingIntent的用武之地。

通过包含每个块的发送状态和交付状态的PendingIntent(或者整个消息,如果它适合一条 SMS 消息的话),您的应用可以被通知失败,在某些情况下,成功。SmsManager可以将意图广播回您的应用,让它知道您发送的 SMS 消息发生了什么。这些意图由一个BroadcastReceiver处理,其源代码如清单 7-2 所示。

清单 7-2。接收 SMS 状态意向

public class MyBReceiver extends BroadcastReceiver {

@Override

public void onReceive(Context context, Intent intent) {

String action = intent.getAction();

Log.d(TelephonyDemo.TAG, "Got action of " + action);

if(TelephonyDemo.SENT_ACTION.compareTo(action) == 0) {

Log.d(TelephonyDemo.TAG, "SMS sent intent received.");

switch(getResultCode()) {

case Activity.RESULT_OK:

Log.d(TelephonyDemo.TAG, "SMS sent OK.");

break;

case SmsManager.RESULT_ERROR_RADIO_OFF:

Log.d(TelephonyDemo.TAG

"*** SMS not sent. Radio is off.");

break;

case SmsManager.RESULT_ERROR_NO_SERVICE:

Log.d(TelephonyDemo.TAG

"*** SMS not sent. No SMS service.");

break;

case SmsManager.RESULT_ERROR_NULL_PDU:

Log.d(TelephonyDemo.TAG

"*** SMS not sent. PDU was null.");

break;

case SmsManager.RESULT_ERROR_GENERIC_FAILURE:

Log.d(TelephonyDemo.TAG

"*** SMS not sent. Unknown failure.");

break;

default:

Log.d(TelephonyDemo.TAG, "*** Unknown sent code: "

+ getResultCode());

break;

}

}

if(TelephonyDemo.DELIVERED_ACTION.compareTo(action) == 0) {

Log.d(TelephonyDemo.TAG, "SMS delivered intent received.");

switch(getResultCode()) {

case Activity.RESULT_OK:

Log.d(TelephonyDemo.TAG, "SMS delivered.");

break;

case Activity.RESULT_CANCELED:

Log.d(TelephonyDemo.TAG, "*** SMS not delivered.");

break;

default:

Log.d(TelephonyDemo.TAG, "*** Unknown delivery code: "

+ getResultCode());

break;

}

}

}

}

当试图向运营商的 SMS 服务器发送消息(或消息的一部分)时,将触发BroadcastReceiver。您会注意到有两个动作可以返回给这个应用:发送状态和交付状态。重要的是要认识到发送状态比交付状态更可靠。根据我们的经验,也根据 Android 的例子来判断,不能保证收到已交付的状态。也就是说,您可能会收到也可能不会收到短信发送状态的任何指示。但是,如果您的应用被通知交付失败,包含清单 7-2 中的代码来指示交付状态也无妨。但是,不要依赖于收到一个肯定的已发送状态来确定 SMS 消息已发送,否则您可能会不必要地重新发送。

在许多演示应用中,BroadcastReceiveronResume()注册,在onPause()取消注册。然而,由于您可能希望接收广播,即使您的活动在后台进行,您将希望使用自己注册的BroadcastReceiver来处理广播。清单 7-3 显示了注册您的BroadcastReceiverAndroidManifest.xml部分。

清单 7-3。MyBReceiver 的 AndroidManifest.xml

<receiver android:name="MyBReceiver">

<intent-filter>

<action

android:name="com.androidbook.telephony.SMS_SENT_ACTION" />

</intent-filter>

<intent-filter>

<action

android:name="com.androidbook.telephony.SMS_DELIVERED_ACTION" />

</intent-filter>

</receiver>

清单 7-1 中的例子演示了使用 Android SDK 发送 SMS 文本消息。用户界面有两个EditText字段:一个用于捕获 SMS 接收者的目的地址(电话号码),另一个用于保存文本消息。用户界面还有一个发送短信的按钮,如图 7-1 所示。

A978-1-4302-4951-1_7_Fig1_HTML.jpg

图 7-1。

The UI for the SMS example

测试该应用时,您可以向同一台设备发送文本消息。观察 LogCat 中指示应用正在做什么的消息。示例中有趣的部分是sendSmsMessage()方法。该方法使用SmsManager类的sendTextMessage()方法来发送 SMS 消息。下面是SmsManager.sendTextMessage()的签名:

sendTextMessage(String destinationAddress, String smscAddress

String textMsg, PendingIntent sentIntent

PendingIntent deliveryIntent);

在本例中,您只填充了目的地址和文本消息参数。但是,您可以自定义该方法,使其不使用默认的 SMS 中心(蜂窝网络上发送 SMS 消息的服务器的地址)。如上图所示,还有一种发送多部分消息的方法,那就是sendMultipartTextMessage()。SmsManager 还有一种发送数据消息的方法,使用字节数组代替字符串消息。此方法还允许在 SMS 服务器上指定备用端口号。

发送 SMS 消息有两个主要步骤:发送和递送。当每个步骤完成时,如果您的应用提供了一个未决的意图,它就会被广播。您可以将您想要的任何内容放入挂起的 intent 中,比如 action,但是传递到您的BroadcastReceiver的结果代码将特定于 SMS 发送或交付。此外,根据 SMS 系统的实施情况,您可能会获得与无线电错误或状态报告相关的额外数据。

如果没有挂起的意图,您的代码就无法判断文本消息是否发送成功。不过,在测试时,你可以分辨出。如果您在一个仿真器中启动这个示例应用,并启动仿真器的另一个实例(从命令行或从 Eclipse 窗口➤ Android SDK 和 AVD 管理器屏幕),您可以使用另一个仿真器的端口号作为目的地址。端口号是出现在模拟器窗口标题栏中的数字,通常类似于 5554。单击 Send Text Message 按钮后,您应该会在另一个模拟器中看到一个通知,表明您的文本消息已在另一端收到。

发送短信只是故事的一半。现在,我们将向您展示如何监控收到的短信。

监控收到的短信

您使用刚刚创建的相同应用来发送 SMS 消息,并且您添加了一个BroadcastReceiver来监听动作android.provider.Telephony.SMS_RECEIVED .当设备接收到 SMS 消息时,这个动作由 Android 广播。

当您注册您的接收器时,您的应用将在收到 SMS 消息时得到通知。监控收到的 SMS 消息的第一步是请求接收它们的许可。为此,您需要向清单文件添加android.permission.RECEIVE_SMS权限。要实现接收者,您需要编写一个扩展android.content.BroadcastReceiver的类,然后在清单文件中注册接收者。清单 7-4 包括了AndroidManifest.xml文件和你的 receiver 类。请注意,这两种权限都出现在清单文件中,因为您仍然需要为上面创建的活动发送权限。

清单 7-4。监控短信

<!-- This file is AndroidManifest.xml -->

<manifest xmlns:android="http://schemas.android.com/apk/res/android

package="com.androidbook.telephony" android:versionCode="1"

android:versionName="1.0">

<uses-permission android:name="android.permission.RECEIVE_SMS"/>

<uses-permission android:name="android.permission.SEND_SMS"/>

[ ... ]

<receiver android:name="MySMSMonitor">

<intent-filter>

<action

android:name="android.provider.Telephony.SMS_RECEIVED"/>

</intent-filter>

</receiver>

[ ... ]

</manifest>

// This file is MySMSMonitor.java

import android.content.BroadcastReceiver;

import android.content.Context;

import android.content.Intent;

import android.telephony.SmsMessage;

import android.util.Log;

public class MySMSMonitor extends BroadcastReceiver

{

private static final String ACTION =

"android.provider.Telephony.SMS_RECEIVED";

@Override

public void onReceive(Context context, Intent intent)

{

if(intent!=null && intent.getAction()!=null &&

ACTION.compareToIgnoreCase(intent.getAction())==0)

{

Object[] pduArray= (Object[]) intent.getExtras().get("pdus");

SmsMessage[] messages = new SmsMessage[pduArray.length];

for (int i = 0; i<pduArray.length; i++) {

messages[i] = SmsMessage.createFromPdu(

(byte[])pduArray [i]);

Log.d("MySMSMonitor", "From: " +

messages[i].getOriginatingAddress());

Log.d("MySMSMonitor", "Msg: " +

messages[i].getMessageBody());

}

Log.d("MySMSMonitor","SMS Message Received.");

}

}

}

清单 7-4 的顶部是BroadcastReceiver拦截 SMS 消息的清单定义。短信监控类是MySMSMonitor。该类实现了抽象的onReceive()方法,当 SMS 消息到达时,系统会调用该方法。测试应用的一种方法是使用 Eclipse 中的模拟器控件视图。运行模拟器中的应用,并前往窗口➤显示查看➤其他➤安卓➤模拟器控制。用户界面允许您向模拟器发送数据,以模拟接收 SMS 消息或电话呼叫。如图 7-2 所示,您可以通过填充“来电号码”字段并选择 SMS 单选按钮向模拟器发送 SMS 消息。接下来,在消息字段中键入一些文本,然后单击发送按钮。这样做向模拟器发送一条 SMS 消息,并调用您的BroadcastReceiveronReceive()方法。

A978-1-4302-4951-1_7_Fig2_HTML.jpg

图 7-2。

Using the Emulator Control UI to send SMS messages to the emulator

onReceive()方法将具有广播意图,它将包含bundle属性中的SmsMessage。你可以通过调用intent.getExtras().get("pdus")来提取SmsMessage。该调用返回以协议描述单元(PDU)模式定义的对象数组,这是一种表示 SMS 消息的行业标准方式。然后您可以将 PDU 转换成 Android SmsMessage对象,如清单 7-4 所示。如您所见,您从 intent 获得了作为对象数组的 PDU。然后构建一个与 PDU 数组大小相等的SmsMessage对象数组。最后,迭代 PDU 数组,并通过调用SmsMessage.createFromPdu()从 PDU 创建SmsMessage对象。

你读完收到的信息后要做的事情一定要快。广播接收机在系统中获得高优先级,但是它的任务必须快速完成,并且它不会被放在前台供用户查看。因此,你的选择是有限的。你不应该做任何直接的 UI 工作。发布一个通知是好的,启动一个服务来继续工作也是好的。一旦onReceive()方法完成,onReceive()方法的托管进程随时可能被终止。启动一个服务是可以的,但是绑定到一个服务就不行了,因为这需要您的进程存在一段时间,而这可能不会发生。

现在,让我们通过查看如何使用各种 SMS 文件夹来继续我们关于 SMS 的讨论。

使用 SMS 文件夹

访问 SMS 收件箱是另一个常见的需求。首先,向清单文件添加 read SMS 权限(android.permission.READ_SMS)。添加此权限使您能够阅读 SMS 收件箱。

要阅读 SMS 消息,您可以在 SMS 收件箱上执行一个查询,如清单 7-5 所示。

清单 7-5。显示来自 SMS 收件箱的消息

<?xml version="1.0" encoding="utf-8"?>

<!-- This file is /res/layout/sms_inbox.xml -->

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android

android:orientation="vertical"

android:layout_width="fill_parent"

android:layout_height="fill_parent" >

<TextView android:id="@+id/row"

android:layout_width="fill_parent"

android:layout_height="fill_parent"/>

</LinearLayout>

// This file is SMSInboxDemo.java

import android.app.ListActivity;

import android.database.Cursor;

import android.net.Uri;

import android.os.Bundle;

import android.widget.ListAdapter;

import android.widget.SimpleCursorAdapter;

public class SMSInboxDemo extends ListActivity {

private ListAdapter adapter;

private static final Uri SMS_INBOX =

Uri.parse("content://sms/inbox");

@Override

public void onCreate(Bundle bundle) {

super.onCreate(bundle);

Cursor c = getContentResolver()

.query(SMS_INBOX, null, null, null, null);

startManagingCursor(c);

String[] columns = new String[] { "body" };

int[]      names = new int[]    { R.id.row };

adapter = new SimpleCursorAdapter(this, R.layout.sms_inbox

c, columns, names);

setListAdapter(adapter);

}

}

清单 7-5 中的代码打开 SMS 收件箱并创建一个列表,列表中的每一项都包含 SMS 消息的正文部分。清单 7-5 的布局部分包含一个简单的TextView,它将在一个列表项中保存每条消息的正文。要获得 SMS 消息列表,您需要创建一个指向 SMS 收件箱(content://sms/inbox)的 URI,然后执行一个简单的查询。然后过滤短信正文,并设置ListActivity的列表适配器。执行清单 7-5 中的代码后,您会在收件箱中看到一个 SMS 消息列表。确保在模拟器上运行代码之前,使用模拟器控件生成一些 SMS 消息。

因为您可以访问 SMS 收件箱,所以您希望能够访问其他与 SMS 相关的文件夹,如“已发送”或“草稿”文件夹。访问收件箱和访问其他文件夹的唯一区别是您指定的 URI。例如,您可以通过对content://sms/sent执行查询来访问已发送文件夹。以下是 SMS 文件夹的完整列表以及每个文件夹的 URI:

  • 全部:content://sms/all
  • 收件箱:content://sms/inbox
  • 已发送:content://sms/sent
  • 草稿:content://sms/draft
  • 发件箱:content://sms/outbox
  • 失败:content://sms/failed
  • 排队:content://sms/queued
  • 未送达:content://sms/undelivered
  • 对话:content://sms/conversations

Android 结合了 MMS 和 SMS,并允许您使用mms-sms权限同时访问两者的内容供应器。因此,您可以访问这样的 URI:

content://mms-sms/conversations

发送电子邮件

既然您已经看到了如何在 Android 中发送 SMS 消息,您可能会认为您可以访问类似的 API 来发送电子邮件。不幸的是,Android 并没有为你提供发送电子邮件的 API。普遍的共识是,用户不希望应用在他们不知情的情况下代表他们发送电子邮件。相反,要发送电子邮件,你必须通过注册的电子邮件应用。例如,您可以使用ACTION_SEND来启动电子邮件应用,如清单 7-6 所示。

清单 7-6。通过意向启动电子邮件应用

Intent emailIntent=new Intent(Intent.ACTION_SEND);

String subject = "Hi!";

String body = "hello from android....";

String[] recipients = new String[]{"``aaa@bbb.com

emailIntent.putExtra(Intent.EXTRA_EMAIL, recipients);

emailIntent.putExtra(Intent.EXTRA_SUBJECT, subject);

emailIntent.putExtra(Intent.EXTRA_TEXT, body);

emailIntent.setType("message/rfc822");

startActivity(emailIntent);

这段代码启动默认的电子邮件应用,并允许用户决定是否发送电子邮件。您可以添加到电子邮件意向中的其他“额外内容”包括EXTRA_CCEXTRA_BCC

让我们假设您想要在邮件中发送一个电子邮件附件。要做到这一点,您可以使用类似下面这样的代码,其中Uri是对您想要作为附件的文件的引用:

emailIntent.putExtra(Intent.EXTRA_STREAM

Uri.fromFile(new File(myFileName)));

接下来,我们来谈谈电话管理器。

使用电话管理器

电话 API 还包括电话管理器(android.telephony.TelephonyManager),您可以使用它来获取有关设备上电话服务的信息、获取订户信息以及注册电话状态更改。一个常见的电话用例要求应用对来电执行业务逻辑。例如,音乐播放器可能会因有来电而暂停播放,并在通话结束后继续播放。

监听电话状态变化的最简单方法是在android.intent.action.PHONE_STATE上实现一个广播接收器。你可以用同样的方法监听收到的短信,如上所述。另一种方法是使用电话管理器。在本节中,我们将向您展示如何注册电话状态更改以及如何检测来电。清单 7-7 显示了细节。

清单 7-7。使用电话管理器

<?xml version="1.0" encoding="utf-8"?>

<!-- This file is res/layout/main.xml -->

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android

android:orientation="vertical"

android:layout_width="fill_parent"

android:layout_height="fill_parent"

>

<Button

android:id="@+id/callBtn"

android:layout_width="wrap_content"

android:layout_height="wrap_content"

android:text="Place Call"

android:onClick="doClick"

/>

<Button

android:id="@+id/quitBtn"

android:layout_width="wrap_content"

android:layout_height="wrap_content"

android:text="Quit"

android:onClick="doClick"

/>

</LinearLayout>

// This file is PhoneCallActivity.java

package com.androidbook.phonecall.demo;

import android.app.Activity;

import android.content.Context;

import android.content.Intent;

import android.net.Uri;

import android.os.Bundle;

import android.telephony.PhoneStateListener;

import android.telephony.TelephonyManager;

import android.util.Log;

import android.view.View;

public class PhoneCallActivity extends Activity {

private static final String TAG = "PhoneCallDemo";

private TelephonyManager teleMgr = null;

private MyPhoneStateListener myListener = null;

@Override

protected void onCreate(Bundle savedInstanceState)

{

super.onCreate(savedInstanceState);

setContentView(R.layout.main);

teleMgr =

(TelephonyManager)getSystemService(Context.TELEPHONY_SERVICE);

myListener = new MyPhoneStateListener();

teleMgr.listen(myListener, PhoneStateListener.LISTEN_CALL_STATE);

String myIMEI = teleMgr.getDeviceId();

Log.d(TAG, "device IMEI is " + myIMEI);

if (teleMgr.getSimState() == TelephonyManager.SIM_STATE_READY) {

String country = teleMgr.getSimCountryIso();

Log.d(TAG, "SIM country ISO is " + country);

}

}

// Only unregister the listener if this app is going away.

// Otherwise, when the user tries to make or receive a phone

// call, this app will get paused and we don't want to stop

// listening when we're put into the background.

@Override

public void onDestroy() {

super.onDestroy();

Log.d(TAG, "In onDestroy");

teleMgr.listen(myListener, PhoneStateListener.LISTEN_NONE);

}

public void doClick(View target) {

switch(target.getId()) {

case R.id.callBtn:

Intent intent = new Intent(Intent.ACTION_VIEW

Uri.parse("tel:5551212"));

startActivity(intent);

break;

case R.id.quitBtn:

finish();

break;

default:

break;

}

}

public class MyPhoneStateListener extends PhoneStateListener

{

@Override

public void onCallStateChanged(int state, String incomingNumber){

super.onCallStateChanged(state, incomingNumber);

switch(state)

{

case TelephonyManager.CALL_STATE_IDLE:

Log.d(TAG, "call state idle...incoming number ["+

incomingNumber+"]");

break;

case TelephonyManager.CALL_STATE_RINGING:

Log.d(TAG, "call state ringing...incoming number ["+

incomingNumber+"]");

break;

case TelephonyManager.CALL_STATE_OFFHOOK:

Log.d(TAG, "call state offhook...incoming number ["+

incomingNumber+"]");

break;

default:

Log.d(TAG, "call state ["+state+"]");

break;

}

}

}

}

使用电话管理器时,请确保将android.permission.READ_PHONE_STATE权限添加到您的清单文件中,以便您可以访问电话状态信息。如清单 7-7 所示,通过实现一个PhoneStateListener并调用TelephonyManagerlisten()方法,您可以得到关于电话状态变化的通知。当一个电话来了,或者电话状态改变了,系统会用新的状态调用你的PhoneStateListeneronCallStateChanged()方法。当您尝试这样做时,您将会看到,来电号码仅在状态为CALL_STATE_RINGING时可用。在本例中,您向 LogCat 写入了一条消息,但是您的应用可以在它的位置实现定制的业务逻辑,比如暂停音频或视频的回放。

Note

如果你的应用由于电话(或通知或警报)需要改变音量,你应该研究 Android 的音频聚焦功能集。本书不涉及音频聚焦。

为了模拟来电,您可以使用 Eclipse 的 Emulator Control UI——与您用来发送 SMS 消息的 UI 相同(见图 7-2)——但是选择语音而不是 SMS。

请注意,我们在onDestroy()中告诉TelephonyManager停止向我们发送更新。当你的活动结束时,关闭信息总是很重要的。否则,TelephonyManager可能会保留对您的对象的引用,并阻止它在以后被清除。然而,当活动进入后台时,您仍然希望接收更新。

此示例仅处理可供监听的一种电话状态。查看其他人的PhoneStateListener文档,包括例如LISTEN_MESSAGE_WAITING_INDICATOR。当处理电话状态变化时,您可能还需要获取订户(用户)的电话号码。TelephonyManager.getLine1Number()会把那个还给你的。

你可能想知道是否有可能通过代码接听电话。不幸的是,目前 Android SDK 没有提供这样做的方法,尽管文档暗示您可以通过一个动作ACTION_ANSWER来激发一个意图。在实践中,这种方法还不可行,尽管您可能想检查一下自撰写本文以来是否已经解决了这个问题。似乎有一些黑客在一些设备上工作,但不是所有的。在网上搜索一下ACTION_ANSWER应该能找到它们。

类似地,您可能希望通过代码拨出电话。在这里,你会发现事情更容易。进行出站呼叫的最简单方法是通过如下代码调用拨号器应用:

Intent intent = new Intent(Intent.ACTION_CALL, Uri.parse("tel:5551212"));

startActivity(intent);

注意,为了实际拨号,您的应用将需要android.permission.CALL_PHONE权限。否则,当您的应用试图调用拨号器应用时,您将得到一个SecurityException。要在没有此权限的情况下进行拨号,请将意向操作更改为Intent.ACTION_VIEW,这将导致拨号器应用显示您要拨打的号码,但用户需要按下发送按钮来发起呼叫。

检测手机状态变化的另一个方法是注册一个广播接收器来检测手机状态变化(android.intent.action.PHONE_STATE),类似于本章开头示例中接收 SMS 消息的方式。这可以在代码中完成,或者您可以在清单文件中指定一个标记。

会话发起协议(SIP)

Android 2.3 (Gingerbread)在 android.net.sip 包中引入了支持 SIP 的新特性。SIP 是互联网工程任务组(IETF)标准,用于协调通过网络连接发送语音和视频,以便在通话中将人们联系在一起。这项技术有时被称为 IP 语音(VoIP),但请注意,实现 VoIP 的方式不止一种。

例如,Skype 使用一种专有协议来实现 VoIP,它与 SIP 不兼容。SIP 也不等同于谷歌语音。Google Voice(在撰写本文时)并不直接支持 SIP,尽管有一些方法可以将 Google Voice 与 SIP 供应器集成在一起,以便将事情联系在一起。Google Voice 为您设置了一个新的电话号码,您可以用它来连接其他电话,如您的家庭电话、工作电话或手机。一些 SIP 供应器将生成一个可用于 Google Voice 的电话号码,但在这种情况下,Google Voice 并不真正知道该号码是用于 SIP 帐户的。搜索互联网会发现相当多的 SIP 供应器,许多有合理的通话费率,有些是免费的。

值得注意的是,SIP 标准没有解决通过网络传递音频和视频数据的问题。SIP 只涉及建立和拆除设备之间的直接连接,以允许音频和视频数据流动。客户端计算机程序使用 SIP 以及音频和视频编解码器和其他库来建立用户之间的呼叫。SIP 呼叫经常涉及的其他标准包括实时传输协议(RTP)、实时流协议(RTSP)和会话描述协议(SDP)。Android 3.1 带来了 Android 对 RTP 的直接支持,在 android.net.rtp 包中。RTSP 支持 MediaPlayer 已经有一段时间了,尽管不是所有的 RTSP 服务器都与 Android 的 MediaPlayer 兼容。SDP 是用于描述多媒体会话的应用级协议,因此您将看到 SDP 格式的消息内容。

用户可以在台式电脑上拨打 SIP 电话,而不会产生长途电话费。计算机程序可以很容易地在移动设备上运行,如 Android 智能手机或平板电脑。SIP 计算机程序通常被称为“软电话”移动设备上的软电话的真正优势是当设备使用 Wi-Fi 连接到互联网时,用户没有使用任何无线分钟,但仍然能够拨打或接听电话。在接收端,软电话必须已经向 SIP 供应器注册了它的位置和功能,以便供应器的 SIP 服务器可以响应 invite 请求来建立直接连接。例如,如果接收者的软电话不可用,SIP 服务器可以将呼入请求定向到语音邮件账户。

Google 为 SIP 提供了一个演示应用,叫做 SipDemo。现在让我们来研究一下这个应用,这样您就可以理解它是如何工作的了。如果你是 SIP 的新手,某些方面并不明显。如果你想尝试 SipDemo,你可能需要一个支持 SIP 的 Android 物理设备。这是因为在撰写本文时,Android 模拟器不支持 SIP(或 Wi-Fi)。互联网上有一些让 SIP 在模拟器中工作的尝试,当你读到这篇文章时,其中一些可能很容易实现并且很健壮。

要使用 SipDemo,您需要从 SIP 供应器处获得一个 SIP 帐户。您需要有您的 SIP ID、SIP 域名(或代理)和您的 SIP 密码。然后将它们插入 SipDemo 应用的首选项屏幕,供应用使用。最后,你需要一个从你的设备到互联网的 Wi-Fi 连接。然而,如果您不想在设备上实际试验 SipDemo,您应该仍然能够理解本节的其余部分。SipDemo 如图 7-3 所示。

A978-1-4302-4951-1_7_Fig3_HTML.jpg

图 7-3。

The SipDemo application with the menu showing

要将 SipDemo 作为新项目加载到 Eclipse 中,请使用 New Android Project 向导,单击 Android Sample 项目选项,在 Build Target 部分选择 Android 2.3 或更高版本,然后选择 SipDemo。单击 Finish,Eclipse 将为您创建新项目。您可以不做任何更改就运行这个项目,但如前所述,除非设备支持 SIP,启用 Wi-Fi,您在某个地方有一个 SIP 帐户,您使用菜单按钮编辑您的 SIP 信息,并且您使用菜单按钮发起呼叫,否则它不会做任何事情。您将需要一些其他 SIP 帐户来调用,以便测试应用。按下屏幕上的大麦克风图像,您就可以与对方通话。这个演示应用也可以接收来电。现在我们来谈谈 android.net.sip 包的内部工作原理。

android.net.sip 包有四个基本类:SipManager、SipProfile、SipSession 和 SipAudioCall。SipManager 是这个包的核心;它提供对 SIP 其余功能的访问。您调用 SipManager 的静态 newInstance()方法来获取 SipManager 对象。使用 SipManager 对象,您可以为大多数 SIP 活动获取 SipSession,或者为纯音频呼叫获取 SipAudioCall。这意味着谷歌在 android.net.sip 包中提供了超出标准 sip 所提供的功能——即建立音频通话的能力。

SipProfile 用于定义将相互通话的 SIP 帐户。这并不直接指向终端用户的设备,而是指向 SIP 供应器的 SIP 账户。服务器帮助完成建立实际连接的其余细节。

SipSession 是奇迹发生的地方。建立一个会话包括您的 SipProfile,以便您的应用可以让您的 SIP 供应器的服务器知道自己。您还通过了 SipSession。事件发生时将被通知的侦听器实例。一旦设置了 SipSession 对象,您的应用就可以调用另一个 SipProfile 或接收来电。侦听器有一系列回调,因此您的应用可以正确处理会话状态的变化。

截至蜂巢,最简单的就是使用 SipAudioCall。逻辑就是把麦克风和扬声器连接到数据流上,这样你就可以和对方进行对话。SipAudioCall 上有许多管理静音、保持等的方法。所有的音频片段也为你处理。

除此之外,你还有工作要做。SipSession 类具有用于发出出站调用的makeCall()方法。主要参数是会话描述(字符串形式)。这是需要做更多工作的地方。构建会话描述需要根据前面提到的会话描述协议(SDP)进行格式化。理解接收到的会话描述意味着根据 SDP 对其进行解析。SDP 的标准文档位于: http: / / tools。ietf。org/ html/ rfc4566 。不幸的是,Android SDK 不提供对 SDP 的任何支持。然而,感谢一些非常善良的人,有几个免费的 Android SIP 应用已经建立了这个功能。分别是 sipdroid ( http://code.google.com/p/sipdroid/ )和 csipsimple ( http://code.google.com/p/csipsimple/ )。

我们甚至还没有开始考虑管理 SIP 客户端之间的视频流的编解码器,尽管 sipdroid 有这个功能。SIP 的另一个非常吸引人的方面是能够在两个以上的人之间建立电话会议。这些主题超出了本书的范围,但是我们希望您能够理解 SIP 能为您做些什么。

请注意,SIP 应用至少需要android.permission.USE_SIPandroid.permission.INTERNET权限才能正常运行。如果您使用 SipAudioCall,您还需要android.permission.RECORD_AUDIO权限。假设你用的是 Wi-Fi,你应该加上android.permission.ACCESS_WIFI_STATEandroid.permission.WAKE_LOCK。将下面的标签作为<manifest>的子标签添加到您的AndroidManifest.xml文件中也是一个好主意,这样您的应用将只能安装在支持 SIP 的硬件设备上:

<uses-feature android:name="android.hardware.sip.voip" />

参考

这里有一些对您可能希望进一步探索的主题有帮助的参考。

摘要

本章讨论了 Android 电话 API。特别是:

You learned how to send and receive an SMS message.   You learned about SMS folders and reading SMS messages.   We covered the sending of e-mail from an application.   You learned about the TelephonyManager and how to detect an incoming call.   You saw how SIP can be used to create a VoIP client program.

复习问题

你可以用下面的问题来巩固你对本章的理解。

Can an SMS message contain more than 140 characters?   True or false? You get an SmsManager instance by calling Context.getSystemService(MESSAGE_SERVICE).   Where is the ADT feature that allows you to send a test SMS message to an emulator?   Can an application send an e-mail without the user’s knowing?   Can an application send an SMS message without the user’s knowing?   Can an application make a phone call without the user’s knowing?   Is SIP the same as Skype?   What are the four main classes of the android.net.sip package?   Which SIP class defines the SIP accounts that will be talking to each other?   What tag do you put into the AndroidManifest.xml file to ensure a SIP app will be seen only by devices that support SIP?   What permissions are needed in order to make SIP work properly?

八、高级调试和分析

Abstract

在您学习 Android 的这个阶段,您可能已经掌握了一些应用,并且您可能已经遇到了一些来自它们的意外行为。这一章花了一些时间来探索调试应用的高级方法,这样你就可以深入了解应用的内部并发现发生了什么。本章还介绍了如何分析您的应用,以确保它尽可能好地执行,并帮助您确保它没有做它不应该做的事情。

在您学习 Android 的这个阶段,您可能已经掌握了一些应用,并且您可能已经遇到了一些来自它们的意外行为。这一章花了一些时间来探索调试应用的高级方法,这样你就可以深入了解应用的内部并发现发生了什么。本章还介绍了如何分析您的应用,以确保它尽可能好地执行,并帮助您确保它没有做它不应该做的事情。

您将在这里探索各种工具和功能,许多都在 Eclipse 中,并带有用于 Eclipse 的 Android Developer Tools (ADT)插件。您将了解到在哪里可以找到这些工具以及它们是如何工作的。在这个过程中,您将使用一些故意写得很差的示例应用来演示这些工具如何发现问题。

Eclipse Debug 透视图是 Eclipse 附带的标准透视图,它并不特定于 Android 编程。然而,你很快就会知道用它能做什么。Android dal vik Debug Monitor Server(DDMS)透视图有很多非常有用的功能来帮助您调试应用。其中包括设备视图(用于查看您连接到的设备)、仿真器控制(用于发送电话呼叫、SMS 消息和 GPS 坐标)、文件浏览器(用于查看/传输设备上的文件)、网络统计(用于查看进出设备的流量)、线程、堆、分配跟踪器(用于查看您的应用内部)、systrace(用于查看您的 Android 设备内部)和 OpenGL tracer。您还将了解 Traceview,它使分析来自应用的转储文件变得更加容易。

本章还深入到了层次视图的视角,因此您可以遍历正在运行的应用的实际视图结构。最后,您覆盖了StrictMode类,该类可用于捕获违反策略的行为,以捕捉可能导致糟糕用户体验的设计错误。

启用高级调试

当您在模拟器中测试时,Eclipse Android Developer Tools(ADT)插件会负责为您设置一切,以便您可以使用即将看到的所有工具。

关于在真实设备上调试应用,您需要知道两件事。首先,应用必须设置为可调试的。这包括将android:debuggable="true"添加到AndroidManifest.xml文件中的<application>标签中。幸运的是,ADT 正确地设置了这一点,所以您不必这样做。当您为模拟器创建调试版本或者直接从 Eclipse 部署到设备时,这个属性被 ADT 设置为true。当您导出应用来创建它的生产版本时,ADT 会自动将debuggable设置为false。请注意,如果您在AndroidManifest.xml中自行设置,无论如何它都会保持设置。

第二件要知道的事情是,设备必须进入 USB 调试模式。要找到 Gingerbread 之前的 Android 版本的设置,请转到设备的设置屏幕,选择应用,然后选择开发。确保选择了“启用 USB 调试”。

在较新版本的 Android(冰激凌三明治和更高版本)上,进入设置,选择开发者选项,并启用 USB 调试。如果你没有看到开发者选项,你必须做一个简单的技巧来取消隐藏它们。从设置列表中,选取“关于电话”,然后向下滚动,直到看到内部版本号。快速重复点击这个按钮——七次应该就可以了——你会得到一条消息,告诉你现在是开发者了,菜单选项会出现在设置中。

Easter Egg Note

如果你运行的是软糖豆,要获得更多乐趣,在“关于手机”下找到 Android 版本条目,快速点击多次,直到你看到一个大大的微笑软糖豆。按住果冻豆,直到你看到一片漂浮的豆子。触摸并投掷这些豆子。按“后退”按钮退出。

调试视角

尽管 LogCat 对于查看日志消息非常有用,但是您肯定希望在应用运行时获得更多控制和更多信息。在 Eclipse 中调试相当容易,在互联网上的很多地方都有详细的描述。因此,这一章不会详细介绍 Eclipse,但是这些是您可以获得的一些有用的特性:

  • 在代码中设置断点,以便在应用运行时执行在断点处停止
  • 检查变量
  • 单步执行并进入代码行
  • 将调试器附加到已经运行的应用
  • 断开与您连接的应用的连接
  • 查看堆栈跟踪
  • 查看线程列表
  • 查看日志目录

图 8-1 显示了一个示例屏幕布局,展示了您可以使用 Debug 透视图做些什么。

A978-1-4302-4951-1_8_Fig1_HTML.jpg

图 8-1。

The Debug perspective

您可以从 Java 透视图(您编写代码的地方)开始调试应用,方法是右键单击项目并选择 debug as➤Android application;这将启动应用。您也可以从“运行”菜单中选择“调试”,从工具栏中选择“调试”,或者使用键盘快捷键 F11。您可能需要切换到 Debug 透视图来进行调试。

DDMS 的视角

DDMS 代表达尔维克调试监控服务器。这个透视图使您能够深入了解仿真器或设备上运行的应用,允许您观察线程和内存,并在应用运行时收集统计信息。图 8-2 显示了它在您的工作站上的外观。请注意,当本节使用术语“设备”时,它指的是设备或仿真器。

或者,您可以导航到 Android SDK 目录,在工具下找到 monitor 程序。启动它会产生与您在 Eclipse 中看到的相同的 DDMS 窗口。

A978-1-4302-4951-1_8_Fig2_HTML.jpg

图 8-2。

The DDMS perspective

在图 8-2 的左上角,注意设备视图。这将向您显示连接到工作站的所有设备(您可以同时连接多个设备或仿真器),如果您展开视图,还会显示可用于调试的所有应用。在图 8-2 中,您正在查看一个仿真器,因此股票应用看起来可供调试。在真实设备上,您可能只看到少数应用(如果有的话)。不要忘记,如果你正在一个真实的设备上调试一个生产应用,你可能需要调整AndroidManifest.xml文件来设置android:debuggabletrue

Devices 视图中的按钮用于开始调试应用、更新堆、转储堆和 CPU 性能分析代理(HPROF)文件、进行垃圾收集(GC)、更新线程列表、启动方法性能分析、停止进程、拍摄设备屏幕、分析视图层次结构以及生成系统跟踪或捕获 OpenGL 跟踪。按钮如图 8-3 所示。让我们从左到右更详细地了解每一项。除了摄像头和 systrace 按钮之外,所有这些按钮都适用于在设备视图列表中选择的任何应用。如果您没有看到任何列出的应用,您可能需要点按设备名称旁边的+号。或者您可能需要将应用设置为可调试的,如上所述。

A978-1-4302-4951-1_8_Fig3_HTML.jpg

图 8-3。

The DDMS advanced debugging buttons

调试按钮

绿色的小 bug 按钮开始调试所选的应用。单击它会将您带到刚才描述的 Debug 透视图。这个选项的好处是您可以将调试器附加到正在运行的应用上。您可以让应用进入您希望开始调试的状态,选择它,然后单击此按钮。然后,当您继续运行应用时,断点将导致执行停止,您可以检查变量并单步执行代码。

调试 Android 应用与调试 Eclipse 中的任何其他应用没有什么不同。如前所述,您可以设置断点、检查变量以及单步执行代码。本章不会深入讨论这些细节,但是互联网上有很多资源可以学习更多关于 Eclipse 中调试的知识。

堆按钮

接下来的三个按钮用于分析正在运行的进程的内存堆。您希望您的应用使用尽可能少的内存,并且不要过于频繁地分配内存。与使用 Debug 按钮类似,您选择想要检查的应用,然后单击 Update Heap 按钮(这三个按钮中的第一个)。您应该只选择您正在积极调试的应用。在图 8-2 右侧的堆视图选项卡中,您可以点击原因 GC 按钮来收集关于堆中内存的信息。将显示摘要结果,详细结果如下。然后,对于每种类型和大小的已分配内存,您可以看到有关内存使用情况的更多详细信息。

您将在此视图中寻找的一些内容包括:

  • 大对象的高计数:您可能会有几个大小在几 KB 范围内的对象;这很正常。如果你看到许多大对象,这可能意味着你的代码正在一遍又一遍地重新创建一些大对象;这可能是个问题。选择统计列表中的每一行,并查看下图。如果你在图的右边看到一个高的长条,那表示有很多大的物体。
  • 一些非常大的对象:一般来说,移动应用应该避免创建这些对象,因为内存是一种宝贵的资源。标有“最大”的一栏会告诉你最大的物体是什么,接下来你会看到如何找到它的来源。
  • 任何大小的对象都有很高的计数:避免应用垃圾收集暂停的最好方法是首先不要创建大量垃圾。如果您的应用在几秒钟内创建数 MB 的对象,您的用户将会遇到 GC 暂停,这会影响用户体验。使用“计数”列或图表来标识出现频率非常高的任何类型的对象。

“转储 HPROF 文件”按钮就是这样做的:它给你一个 HPROF 文件。如果您已经安装了 Eclipse Memory Analyzer (MAT)插件,那么这个文件将被处理并显示结果。这可能是查找内存泄漏的一种强有力的方法。默认情况下,HPROF 文件是在 Eclipse 中打开的,但是如果没有 MAT 插件,它不会有很大帮助。有关该插件的更多信息,请参见本章末尾的参考资料。根据您工作站的电源,此操作可能需要一分钟或更长时间,因此如果看起来什么也没发生,请耐心等待。在 Android ➤ DDMS 下有一个偏好设置,你可以选择保存到一个文件。

MAT 将报告应用中对象的内存使用情况。对于您在上面的堆中可能已经看到的任何问题,该工具可以更深入地挖掘,以确定问题对象来自哪里。例如,从 MAT 视图的 Overview 选项卡中,如果您单击 Top Consumers 链接,您将看到占用大部分堆的对象。相反,如果您单击泄漏可疑点,您将看到可能泄漏内存的对象。这个报告甚至可以显示对象引用名,以帮助定位分配内存的代码。

线程按钮

“更新线程”按钮用所选应用的当前线程集填充右侧的“线程”选项卡。这是观察线程创建和销毁的好方法,也是了解应用中线程级别发生了什么的好方法。在线程列表下面,您可以通过跟踪看起来像堆栈跟踪的内容(对象、源代码文件引用和行号)来查看线程的位置。

例如,谷歌地图应用使用了大量线程,你可以通过在设备视图中选择应用,然后点击更新线程按钮来观察它们的来去。在右侧的线程视图中,当 Maps 与各种 Google Maps 服务对话时,您会看到许多线程。双击其中一个线程条目会在下面的列表中显示详细信息。

下一个按钮 Start Method Profiling 允许您收集应用中方法的信息,包括调用次数和计时信息。单击该按钮,与应用进行交互,然后再次单击该按钮(它在开始和停止方法分析之间切换)。当您单击 Stop Method Profiling 时,Eclipse 将切换到 Traceview 视图,这将在本章的下一节中介绍。与 HPROF 转储一样,打开 Traceview 视图可能需要一分钟或更长时间,这取决于您的工作站的能力,因此如果 Eclipse 似乎没有做任何事情,请耐心等待。

停止按钮

“停止”按钮(看起来像停止标志)允许您停止选定的进程。这是一个硬应用停止—它不像单击后退按钮,后者只影响一个活动。在这种情况下,整个应用都会消失。

相机按钮

无论在设备视图中选择了哪个应用,看起来像照相机的按钮都会捕捉设备屏幕的当前状态。然后,您可以刷新图像、旋转图像、保存图像或复制图像。“保存”选项仅使用 PNG 格式,但如果您单击“复制”按钮,则可以粘贴到其他工具(例如,“绘画”)中,并以该工具使用的任何格式保存。

“转储视图层次结构”按钮

此按钮(官方称为 UI Automator 的转储视图层次)作用于所选的应用,以捕捉当前屏幕和屏幕上的所有视图,无论是否可见。一旦捕获,将显示视图层次视图,类似于图 8-4 。

A978-1-4302-4951-1_8_Fig4_HTML.jpg

图 8-4。

The View Hierarchy view

这个特性的目的是提供使用 UI Automator 创建自动化 UI 测试所需的信息。右上角的窗口显示了左侧截图中所有视图的层次结构。记住,屏幕上的所有东西都是视图对象,包括文本和按钮。对于选定的布局或视图,详细信息显示在右下窗口中。

该工具与本章后面讨论的视图层次透视图类似,但有所不同。这个视图简单地向您展示了视图及其层次结构。稍后描述的工具为您提供了更多关于视图呈现的信息。

系统跟踪按钮

系统跟踪按钮启动一个对话框,您可以设置从仪器捕获系统跟踪(系统跟踪)的参数。系统跟踪许多系统进程以及您的程序的方法调用。它用于在非常低的级别识别可能导致用户体验问题的问题,例如响应延迟。它允许您选择问题中可能涉及的流程,并轻松地比较方法调用时间,以查看什么影响什么,甚至跨流程边界—例如,当使用服务时。systrace 的输出是一个 HTML 文件,您可以将其加载到浏览器(Chrome 首选)中进行分析。

在这种特定情况下,模拟器不能用于获取系统跟踪,您必须有一个物理设备。该设备必须运行 Android 4.1 或更高版本,并且必须能够捕获系统跟踪。谷歌 Nexus 设备已启用,三星 Galaxy S3 也是如此。要查看您的设备是否能够运行,请检查其文件系统,查看/sys/kernel/debug 目录是否存在。如果它确实存在,那么你就可以走了。systrace 的某些功能需要设备的 root 访问权限,而设备制造商可能不支持其他功能。你会得到错误信息,告诉你什么是不支持的,所以你可能会在某种程度上受到限制,你可以做什么与您的特定设备。

设置跟踪

在开始跟踪之前,您必须在设备上做一些准备工作。首先,设备必须连接到您的工作站,并启用 USB 调试,如上所述。在“设置”的“开发人员选项”部分,您还会发现一个名为“启用跟踪”的条目。在这里选择你想追踪的东西。单击 OK,准备运行应用进行捕获。

现在,将你的注意力转向你的工作站和当你点击 Systrace 按钮时弹出的对话框,如图 8-5 所示。在捕获之前或之后(或两者都有),您可能希望在设备上运行 ps 命令并捕获输出。以下内容在您的工作站上运行良好:

adb shell ps > c:\temp\ps.txt

使用任何适合您平台的输出文件名。这将捕获在分析跟踪信息时有用的活动进程列表。

A978-1-4302-4951-1_8_Fig5_HTML.jpg

图 8-5。

The Systrace dialog

前两个字段不言自明。为了捕获所有数据,跟踪缓冲区大小(kb)字段需要相当大。在这个例子中,缓冲区被设置为 10MB,这似乎工作得很好。如果您发现您的采集被截断,您可以选取更大的尺寸。

前四个跟踪事件选项显然与 CPU 活动有关,这是您希望看到的。并非所有设备都支持所有这些事件。如果您收到与这些事件类型之一相关的错误消息,只需在此对话框中取消选择它,然后重试。对于需要 root 的跟踪事件,您需要使用命令adb root重启 adbd。当然,这只在你的设备安装了不安全的boot.img后才有效。

跟踪标记与设备上设置➤开发人员选项➤启用跟踪下的选项相匹配。除了 am(活动管理器)和 wm(窗口管理器)之外,大多数标签都是不言自明的。您不需要在对话框中选择您在设备上启用的所有标签,但是对于您在对话框中选择的任何标签,您必须已经在设备上选择了相应的启用跟踪设置选项。您不需要按照说明执行 adb shell 命令,除非出于某种原因,您没有得到有效的 trace.html 文件作为输出。当您单击“确定”时,跟踪将开始,一个对话框将出现在您的工作站上,您应该开始在设备上运行您的应用,以便您可以捕获所有事件的详细信息。一旦捕获完成,您将有一个大的 HTML 文件加载到 Chrome 浏览器中。当你读到这篇文章的时候,其他浏览器如 IE、Firefox 或 Safari 可能还能用,但你最好的选择是 Chrome。

如果你冒险进入 Android 开发者网站,阅读了那里关于下载 python 来让 systrace 工作的说明,那就算了。从版本 21 开始,ADT 中的代码使得 systrace 不需要 python。事实上,让基于 python 的 systrace 工作存在很多问题。您最好使用 ADT 功能。

分析痕迹

一旦将捕获的跟踪文件加载到 Chrome 中,您将看到一个类似图 8-6 的屏幕。在左侧,您将看到已经捕获的事件类型、活动等。使用右侧的滚动条查看捕获的所有内容。在图形窗口的顶部,您会看到一个时间刻度。使用 w 和 s 键可以扩大或缩小时间范围。z 键将时间刻度恢复到原始状态。图形窗口显示左侧所示线程每次执行的行。a 和 d 键分别向左和向右移动线条。

A978-1-4302-4951-1_8_Fig6_HTML.jpg

图 8-6。

A systrace capture file in Chrome

毫无疑问,你已经发现,有太多的东西要同时看。你需要一些路标。幸运的是,您可以单击时间刻度来添加垂直线,向下延伸到所有行。这允许您关注特定的时间片并比较线程。如果你愿意,你可以有两条以上的垂直线;您可以再次单击某一行来删除它;你可以点击并拖动一条线来移动它。您也可以单击最左侧的 x 来删除您认为不重要的行。数据仍然保存在 systrace 文件中,所以如果需要的话,可以随时重新加载。

放大图形窗口中的线,直到看到一些宽度。现在点击一条线。您将在窗口的下部看到有关该事件时间的一些详细信息。点击另一行,你会看到它的细节。现在,单击并拖动一个方框,将一串线包围起来。当您拖动时,您将看到该框的时间片持续时间,当您放开时,该框中的事件将在窗口的下部汇总。这是一种非常强大的方式来查看设备上发生的一切。

要看的几个关键行是 SurfaceFlinger 和它的第二线程 surfaceflinger。如果设备上一切顺利,SurfaceFlinger 将看起来非常规则,非常短的线条均匀分布。这确保了用户体验的快速响应。事实上,所有的应用都应该表现出这种行为——也就是说,应用中的处理应该简短而有规律。任何时候你的应用处理一个事件(一个用户点击,接收一个广播),它应该快速地做它能做的任何事情,并确保主线程没有被阻塞。如果你看到 SurfaceFlinger 线路中断,这意味着有其他东西占用了设备的资源,并可能导致用户体验不稳定。有了这个 systrace 工具,您可以准确地跟踪坏事情发生的地方,并可能采取一些措施来解决问题。

对 Android 底层架构的完整解释超出了本章的范围,但是您可能有兴趣了解事件源列表中的一些项目。VSYNC 是垂直同步,Android 使用它来管理显示缓冲区。绑定器是数据在进程间传递的方式,包括传递到图形服务器。

OpenGL ES 的启动 OpenGL 跟踪按钮/跟踪器

此按钮启动一个对话框,允许您捕捉 OpenGL 帧进行分析。或者,至少应该是这样。使用这种方法获得 OpenGL 跟踪功能存在问题。使用 Tracer for OpenGL ES 透视图可能会更成功。使用窗口菜单查找并启动该透视图。然后使用这里的开始跟踪按钮(看起来和 DDMS 屏幕上的开始 OpenGL 跟踪按钮一样)来得到如图 8-7 所示的对话框。

A978-1-4302-4951-1_8_Fig7_HTML.jpg

图 8-7。

An OpenGL trace dialog

该设备可能会默认为你。对于应用包,输入要跟踪的活动包的名称。对于要启动的活动,请输入活动的名称。如果在活动名称上出现错误,请尝试在活动名称前面加上一个句点(。).选择收集选项并提供文件名—通常以. gltrace 结尾。准备好后按 trace 按钮。将出现一个新的对话窗口,显示帧捕获的进度。当你收集到你想要的东西时,按停止追踪按钮。

然后,透视图会发生变化,以显示帧捕获的结果。您可以使用这个屏幕来查看 OpenGL 的工作情况。如果需要,请单击“打开保存的 OpenGL 跟踪文件”按钮,以加载您在上一个对话框中指定的文件。使用帧滑块选择一个帧,然后查看 OpenGL ES 调用。绘图命令将以蓝色突出显示。

设备视图菜单

最后,菜单包括所有的按钮功能;此外,还有一个复位 adb 菜单项。Adb 是 Android Debug Bridge,一个在您的工作站上运行的服务器进程,用于与您工作站上的仿真器或连接到它的设备进行对话。重置 adb 选项重新启动 adb 服务器,以防出现不同步,您无法再看到设备或仿真器。这将(实际上)刷新视图中的设备列表。重置 adb 服务器的另一种方法是在工具窗口中使用以下一对命令:

adb kill-server

adb start-server

分配跟踪器

图 8-8 显示了分配跟踪器选项卡。这使您可以开始跟踪单个内存分配。单击“开始跟踪”后,练习您的应用,然后单击“获取分配”。将显示该时间段内的内存分配列表,您可以单击特定的分配来查看其来源(类、方法、源代码文件引用和行号)。停止跟踪按钮在那里,所以你可以重置和重新开始。

A978-1-4302-4951-1_8_Fig8_HTML.jpg

图 8-8。

The Allocation Tracker view

可以单击分配列表的列,按该列进行排序。例如,这使得寻找大的分配变得容易。还有一个过滤字段,可以很容易地将显示的行限制为包含该字段中输入的文本的行。通过在应用的包名的开头键入,列表将会缩小,以显示从您的代码中进行的分配。然后,您可以按“分配于”列进行排序,并查找重复分配的同一类对象的多次出现。在所示的例子中,Paint 类的重复实例化可能是一个问题。我们稍后将对此进行更详细的探讨。

特蕾西

您已经看到了如何收集应用中方法执行的统计信息。使用 DDMS,您可以执行方法分析,之后 Traceview 窗口会显示结果。图 8-9 显示了这种情况。

A978-1-4302-4951-1_8_Fig9_HTML.jpg

图 8-9。

Traceview

使用前面展示的技术来启动这个视图,您将获得应用中在 DDMS 捕获方法调用信息时执行的所有方法的结果。您在捕获方法时对应用的练习越多,您在这个视图中获得的信息就越多。一种方法是捕获特定应用操作的方法,这样您就可以专注于这段时间内发生的事情。如果您有一个具有大量功能的大型应用,这种方法可能需要很长时间。在这种情况下,您可能希望测试更长的时间,尽管这样您将有大量的数据要处理,这可能会隐藏一些不太严重的问题。

从图 8-9 中可以看出,应用中每个线程的活动都以图形方式显示,让您知道哪些线程正在工作以及何时工作。如果您将鼠标放在线程行上的每个条上,您将会看到上面关于进行了哪个方法调用的信息,以及 CPU 计时。

请注意,分析的结果显示了调用的内容、频率以及每个方法花费的时间。细分是按线程,用颜色编码。可以单击此处的列,根据该值对结果进行排序。

查找问题的一个好方法是按降序排列包含 Cpu 时间百分比(即最大值在顶部)。这将表明时间在你的应用中的花费,从多到少。但是每个值都包括从每个方法内部调用方法所花费的时间。通过查看具有这种排序顺序的前几十行,您通常可以发现一些意外的方法是否占用了太多的总时间。当您捕获方法调用时,您应该对应用应该在做什么有所了解,如果时间花在了不应该花的地方,那么您可以去调查一下。

使用这种排序顺序要注意的另一件事是靠近顶部的 Excl Cpu Time %中的一个大值。该值表示仅在方法代码中花费的时间,不包括在此方法的方法调用中花费的时间。这是在这种特定方法中花费时间的更真实的度量,因此高值表明该方法正在做大量的工作。您需要判断该方法是否应该做大量的工作。如果用这种方法花费的时间看起来很长,去看看并找出原因。当然,您可以直接按该列进行排序,并查看使用最多 CPU 时间的方法是否是您所期望的方法。

您可以单击一个方法调用的行,它将显示对此方法的父调用、对此方法的方法的子调用,以及每个调用的计时。您可以单击父方法或子方法,将视图切换到该方法。当然,单击一个子方法将会在子方法的父方法列表中显示当前的方法。或者点击一个父方法将会在父方法的子方法列表中显示当前的方法。通过这种方式,您可以上下遍历应用方法调用树,以查看时间都花在了哪里。

您还可以通过使用android.os.Debug类获得 Android 应用的更具体的跟踪信息,该类提供了一个开始跟踪方法(Debug.startMethodTracing("basename"))和一个停止跟踪方法(Debug.stopMethodTracing())。Android 在设备的 SD 卡上创建一个跟踪文件,文件名为basename.trace,尽管你可以指定一个完整的路径名,该文件将转到那里。您将开始和停止代码放在想要跟踪的内容周围,从而限制了收集到跟踪文件中的数据量。然后,您可以将跟踪文件复制到您的工作站,并使用 Android SDK 工具目录中包含的traceview工具查看跟踪输出,跟踪文件名作为traceview的唯一参数。

Note

当将方法捕获到跟踪文件中时,会有一些额外的开销影响应用方法的计时。时间不应该被认为是绝对准确的,而是相对的。没有追踪,一切都会进行得更快;但是无论是否开启追踪,最快的部分很可能是最快的。在跟踪开启的情况下,最慢的部分相对于其他部分仍然是最慢的。

测试您的调试技能

现在您已经了解了调试工具,下面是一个测试,看看您是否可以调试一个存在已知问题的应用。从本书的网站( www.androidbook.com/expertandroid/projects )下载第八章的示例程序 NoteBad,使用名为 expert Android _ Ch08 _ debugging . zip 的文件。这是 Android 示例应用记事本,但在投入生产之前需要进行一些调整。将这个项目导入 Eclipse,然后在真实设备或模拟器上运行它。使用菜单按钮和添加笔记菜单选项在应用中创建一些笔记。“后退”按钮保存当前便笺并使用户返回便笺列表。您可能会注意到,也可能没有注意到这个应用有些迟缓;没关系,您将使用工具来查找性能问题的原因。

单击 Start Method Profiling 按钮开始捕获方法调用。现在,通过编辑您创建的笔记来练习应用。单击 Stop Method Profiling 按钮,等一会儿,查看出现的 Traceview 视图。双击。跟踪标签,如果需要的话,让它充满窗口。现在看看列表中的方法调用。看到什么异常了吗?比如从 note editor linededittext.ondrawTextView.onDrawnoteeditor linededittext . ondraw 的 Excl Cpu 时间%明显大于它周围的任何东西?这个方法绝对需要研究。

如果单击列表中的 note editor $ linededittext . ondraw 行,它将展开以显示父母和子女。孩子之下是自我,占据了大部分时间。这意味着 onDraw 方法中的代码做了大量的处理,而不是在 onDraw 调用的方法中。打开 NoteEditor.java 文件并导航到 onDraw 方法。您是否看到了导致严重延迟的内部 for 循环?如果你去掉这个没有意义的小循环,再试一次,你会发现在这个方法上花费的时间大大减少了。

现在,在编辑已经创建的另一个注释时,使用 Allocation Tracker 视图来捕获应用的一些分配事件。一旦您点击 Get Allocations 按钮,您将看到列表中显示了相当多的行。要将列表限制为仅来自代码的分配,请在筛选器字段中键入 com.androidbook。当你输入的时候,你会看到列表缩小到只有包含 com.androidbook 的行。太奇怪了!为什么要一次又一次地分配一个绘制对象?

单击其中一个分配行,您会看到下面显示该分配来自何处的跟踪。在 NoteEditor.java 双击 onDraw 方法的第一行,Eclipse 会直接带您找到代码。onDraw 方法每秒被调用多次,每次都分配一个新的 Paint 对象是没有意义的。如果你在 NoteEditor.java 向上滚动一点,你会看到真正的绘制对象分配应该在哪里。在 onDraw 方法中,您应该引用现有的对象,而不是每次都创建一个新的对象。继续重构代码以引用 mPaint,而不是在 onDraw 中创建新的 MP aint。现在,当您重新运行应用时,您甚至可能会注意到应用性能的显著提高。

层次视图视角

在 Hierarchy View 透视图中,您在模拟器中(而不是在真实设备上)连接到应用的运行实例。然后,您可以探索应用的视图、它们的结构以及它们的属性。首先,选择您想要的应用。选中并读取后,视图层次以多种方式显示,如图 8-10 所示。

A978-1-4302-4951-1_8_Fig10_HTML.jpg

图 8-10。

The Hierarchy View perspective

您可以在结构中导航,检查属性并确保您没有多余的视图。例如,如果你有许多嵌套的布局,你可以用一个RelativeLayout来代替它们。

当您自己尝试时,您可能会注意到中间窗口视图中的三个彩球。这些(从左到右)对应于查看者在测量、布局和绘制视图(包括封闭视图)方面的表现等级。颜色是相对的,所以红色的球不一定意味着有问题,但它肯定意味着你应该调查。

另请注意所选视图及其上方的信息。它不仅包括该视图图像的捕获,还显示了测量、布局和绘制该视图的绝对时间。这些都是有价值的数据,可以帮助你确定是否真的需要深入了解这个观点并做出改进。除了如前所述的折叠布局,您还可以改变视图的初始化方式和绘制工作量。如果您的代码创建了大量的对象,您可以重用对象来避免这种开销。使用后台线程、加载程序或其他技术来完成可能需要很长时间的工作。

像素完美视图

与设备视图中的层次视图和相机按钮类似,您可以获取当前屏幕图像并将其显示在像素完美视图中。这个 Eclipse 插件为您提供了一个放大的图像查看器,允许您查看单个像素及其相关的颜色。这个特性的有趣之处在于,你可以覆盖另一个图形(比如屏幕模型)并将其与当前屏幕进行比较。如果你需要复制一个特定的外观,这是一个很好的方法来看看你做得如何。

亚行司令部

您可以从命令行(或工具窗口)使用其他几个调试工具。Android Debug Bridge ( adb)命令允许您安装、更新和删除应用。它位于 Android SDK 目录中的 platform-tools 下。您可以在模拟器或设备上启动一个 shell,并从那里运行 Android 提供的 Linux 命令子集。例如,您可以浏览文件系统、列出进程、读取日志,甚至连接到 SQLite 数据库并执行 SQL 命令。例如,以下命令(在工具窗口中)在模拟器上创建一个外壳:

adb –e shell

注意-e来指定一个仿真器。如果您正在连接设备,请使用-d。在仿真器 shell 中,您拥有提升的 Linux 特权,而在真实设备上则没有(除非您已经对其进行了根操作)。这意味着您可以在模拟器中浏览 SQLite 数据库,但是您不能在真实的设备上这样做,即使它是您的应用!

键入不带参数的adb显示了adb命令的所有可用功能。

当您以这种方式连接时,通过 adb 的普通 shell 的限制是由于默认的 userid。在幕后,Android 使用一个 Linux 变种,所以最终的用户 id 是 root。有很多在 Android 设备上获得 root 的技术,如果你能在自己的设备上获得,你会发现有很多可能性。例如,使用 Android 设备的 root 访问权限,您将能够检查设备上的任何和所有 SQLite 数据库。因为获取 root 的技术对于每种设备都是不同的,而且事实上由于制造商试图消除获取 root 的能力而不断变化,所以本书不会详细介绍如何为您的设备设置 root。请注意,试图获取 root 可能会损坏您的设备,使其无法使用。

模拟器控制台

另一种强大的调试技术是运行模拟器控制台,它显然只适用于模拟器。要在模拟器启动并运行后开始,请在工具窗口中键入以下内容:

telnet localhost port#

其中port#是仿真器监听的位置。端口号通常显示在模拟器窗口标题中,通常是一个值,如 5554。模拟器控制台启动后,您可以键入命令来模拟 GPS 事件、SMS 消息,甚至电池和网络状态的变化。有关模拟器控制台命令及其用法的链接,请参见本章末尾的参考资料。

严格模式

Android 2.3 引入了一个名为StrictMode的新调试功能,据谷歌称,该功能用于对 Android 可用的谷歌应用进行数百次改进。那么它是做什么的呢?它报告违反与线程和虚拟机相关的策略的情况。如果检测到违反策略的情况,您会收到一个警报,其中包括一个堆栈跟踪,显示发生违规时您的应用所在的位置。您可以使用警报强制崩溃,也可以记录警报并让您的应用继续运行。

严格模式策略

StrictMode目前提供两种类型的策略。第一个策略与线程相关,主要针对主线程(也称为 UI 线程)运行。从主线程读取和写入磁盘不是好的做法,从主线程执行网络访问也不是好的做法。Google 在磁盘和网络代码中加入了StrictMode钩子;如果您为您的一个线程启用了StrictMode,并且该线程执行磁盘或网络访问,那么您会得到警告。您可以选择您想要警告ThreadPolicy的哪些方面,并且您可以选择警告方法。

您可以查找的一些违规包括自定义慢速调用、磁盘读取、磁盘写入和网络访问。对于警报,您可以选择写入 LogCat、显示对话框、闪烁屏幕、写入 DropBox 日志文件或使应用崩溃。最常见的选择是写入 LogCat 并使应用崩溃。清单 8-1 展示了一个为线程策略设置StrictMode的例子。

清单 8-1。设置StrictModeThreadPolicy

StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder()

.detectDiskReads()

.detectDiskWrites()

.detectNetwork()

.penaltyLog()

.build());

注意,Builder类使得设置StrictMode变得很容易。定义策略的Builder方法都返回一个对Builder对象的引用,所以这些方法可以链接在一起,如清单 8-1 所示。最后一个方法调用build(),返回一个ThreadPolicy对象,它是StrictModesetThreadPolicy()方法所期望的参数。注意setThreadPolicy()是一个静态方法,所以不需要实例化一个StrictMode对象。

在内部,setThreadPolicy()使用策略的当前线程,因此根据ThreadPolicy评估后续线程操作,并在必要时发出警报。在这个示例代码中,策略被定义为通过向 LogCat 发送消息来警告磁盘读取、磁盘写入和网络访问。您可以使用detectAll()方法来代替特定的检测方法。您还可以使用不同的或附加的惩罚方法。例如,您可以使用penaltyDeath()让应用在将StrictMode警告消息写入 LogCat(作为penaltyLog()方法调用的结果)后崩溃。

因为您在一个线程上启用了StrictMode,一旦您启用了它,您就不需要一直启用它。因此,您可以在主活动的onCreate()方法开始时启用StrictMode,该方法在主线程上运行,然后它将为该主线程上发生的所有事情启用。根据您想要查找的违规类型,第一个活动可能会很快发生,足以启用StrictMode。您也可以通过扩展Application类并向应用的onCreate()方法添加StrictMode设置来在您的应用中启用它。可以想象,任何在线程上运行的东西都可以设置StrictMode,但是你当然不需要从任何地方调用设置代码;一次就够了。

ThreadPolicy类似,StrictMode也有一个VmPolicyVmPolicy可以检查几种不同类型的内存泄漏。一个VmPolicy通过一个类似的Builder类创建,如清单 8-2 所示。VmPolicyThreadPolicy的一个区别是VmPolicy不能通过对话框报警。

清单 8-2。设置StrictModeVmPolicy

StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder()

.detectActivityLeaks()

.detectLeakedClosableObjects()

.detectLeakedRegistrationObjects()

.detectLeakedSqlLiteObjects()

.penaltyLog()

.penaltyDeath()

.build());

关闭 StrictMode

因为设置发生在一个线程上,StrictMode即使在控制从一个对象到另一个对象流动时也能发现违规。当违规发生时,您可能会惊讶地意识到代码正在主线程上运行,但是堆栈跟踪可以帮助您了解违规是如何发生的。然后,您可以通过将该代码移动到它自己的后台线程来采取措施解决这个问题。或者你可能决定让事情保持原样。这取决于你。当然,当您的应用投入生产时,您可能希望关闭StrictMode;您不希望您的代码因为一个警告而对用户崩溃。

有几种方法可以关闭生产应用的StrictMode。最直接的方法是删除调用,但是这使得继续开发应用变得更加困难。在调用StrictMode代码之前,你可以定义一个应用级的布尔值并测试它。在向外界发布应用之前将布尔值设置为 false 实际上禁用了StrictMode

一种更优雅的方法是利用应用的调试模式,如AndroidManifest.xml中所定义的。这个文件中<application>标签的属性之一是android:debuggable。如前所述,当您想要调试应用时,将该值设置为true;这样做的结果是ApplicationInfo对象获得一个标志集,然后您可以在代码中读取它。清单 8-3 展示了如何利用这一点,当应用处于调试模式时,StrictMode是活动的(当应用不处于调试模式时,StrictMode是不活动的)。

清单 8-3。设置StrictMode仅用于调试

// Return if this application is not in debug mode

ApplicationInfo appInfo = context.getApplicationInfo();

int appFlags = appInfo.flags;

if ((appFlags & ApplicationInfo.FLAG_DEBUGGABLE) != 0) {

// Do StrictMode setup here

}

请记住,当在模拟器或设备上启动应用的开发版本时,ADT 会将该属性设置为true,因此会启用前面代码中的StrictMode。当您导出应用来创建生产版本时,ADT 将属性设置为false

严格模式练习

作为一个练习,进入 Eclipse,复制一个您到目前为止开发的应用。您必须选择 2.3 或更高版本的构建目标,这样它才能找到StrictMode类。在首先启动的活动的onCreate()方法中,添加类似清单 8-1、8-2 和 8-3 中的代码;在模拟器中运行 Android 2.3 或更高版本的程序。在使用应用时,您可能会在 LogCat 中偶尔看到违规消息。

参考

以下是一些对您可能希望进一步探索的主题有帮助的参考:

  • http://developer.android.com/guide/developing/tools/index.html :这里描述的 Android 调试工具的开发者文档。
  • http://developer.android.com/guide/developing/devices/emulator.html#console :仿真器控制台命令的语法和用法。这允许您使用命令行界面来模拟在模拟器中运行的应用的事件。
  • www.eclipse.org/mat/:Eclipse 项目名为内存分析器(MAT)。您可以使用这个插件来读取由 DDMS 功能收集的 HPROF 文件。在 MAT 主页上,查找下载链接。MAT 可以作为独立工具下载,与保存的 HPROF 文件一起使用。或者您将看到一个更新站点链接,您可以使用 Eclipse 的安装新软件对话框来获取插件。安装好插件后,将 Android ➤ DDMS 首选项更改为在 Eclipse 中打开 HPROFs。

摘要

本章介绍了以下内容:

  • 如何设置 Eclipse 和您的设备进行调试?
  • Debug 透视图,它允许您停止应用来检查变量值,并逐句通过代码。
  • DDMS 透视图,它有相当多的工具用于调查线程、内存和方法调用,以及拍摄屏幕快照和生成事件发送到模拟器。
  • 从 DDMS 和命令行重置 adb 服务器。
  • Traceview,它显示应用运行时调用的方法,以及帮助您识别需要注意的问题方法以获得更好的用户体验的统计信息。
  • Hierarchy 视图,显示正在运行的应用的视图结构,并包括帮助您调整和排除应用故障的指标。
  • adb命令,可用于登录设备并查看周围。
  • 模拟器控制台,这是从命令行与模拟器对话的好方法。想象一下脚本的可能性。
  • 一个特殊的类,用于验证你的应用没有在主线程中做不推荐的事情,比如磁盘或网络 I/O。

复习问题

以下是一些你可以问自己的问题,以巩固你对这个话题的理解:

True or false? If you want to debug an application, you must explicitly set the android:debuggable attribute to true in the <application> tag of the AndroidManifest.xml file.   Name four things you can do with your application while using the Eclipse Debug perspective.   Is it possible to connect more than one device and/or emulator to Eclipse at the same time? If so, where do you select which application you want to work with?   Which DDMS feature do you use to get statistics for an application’s current memory allocations?   How do you determine how many threads are running in your application?   How do you find out the number of times a particular method is called in your application, and what the time of execution is within that method?   Where do you go to capture a picture of a device’s screen?   What Eclipse perspective is used to analyze the structure of an application’s views?   What do the three colored balls mean in this perspective? Does yellow mean you have a big problem? Does red?   If you see a yellow or red ball and want to know how bad the situation is, what should you do to see the actual numeric metric values?   If you want to look at method profiles, but you don’t want to see all the methods for the entire application, what do you do?   How do you create a Linux shell inside a running emulator?   Can you also do this on a real device? If so, are there any limitations to what you can do on a real device?   How do you figure out the port number of an emulator so you can connect to it using the Emulator Console?   What two main things does StrictMode check for?

九、使用 OpenGL 编程三维图形

Abstract

在本章中,您将了解 Android 平台上的 OpenGL ES 3D 图形 API。OpenGL ES 是 OpenGL 的一个版本,针对嵌入式系统(ES)和其他低功耗设备(如手机)进行了优化。

在本章中,您将了解 Android 平台上的 OpenGL ES 3D 图形 API。OpenGL ES 是 OpenGL 的一个版本,针对嵌入式系统(ES)和其他低功耗设备(如手机)进行了优化。

Android 平台支持 OpenGL ES 1.0 和 OpenGL ES 2.0。OpenGL ES 2.0 从 API level 8 开始提供,对应 Android SDK release 2.2。ES 2.0 与 ES 1.0 有很大的不同,并且不是向后兼容的。

ES 1.0 不依赖于 GPU(图形卡或图形处理单元)的可编程性。在这个模型中,绘图的机制是相当固定的(在 GPU 上)。您的客户端代码设置顶点和必要的变换矩阵,并调用 GPU 来完成其余工作。GPU 遵循预先部署的协议来绘制几何图形(顶点)和给表面着色(片段)。

ES 2.0 是一个模型,其中用 GLSL (OpenGL 着色语言)编写的类似“C”的程序在 GPU 上编译、链接和运行,以影响 OpenGL 绘图。这些写在 GPU 上的 GLSL 程序允许你在如何定位顶点和如何给片段着色上有更多的灵活性。

我们将在这一章中讨论 ES 1.0 和 2.0。未来属于可编程 GPU,期待越来越多支持 ES 2.0 的移动设备。请记住,很难找到关于如何在模拟器上运行 ES 2.0 OpenGL 程序的文档。在本章中,我们使用了一个真实的设备来测试 ES 2.0 示例。

您可以从以下网址了解 OpenGL ES 的历史、范围和定义

http: / / www .希罗尼乌斯。org/open rules/

OpenGL 基础

本节将帮助您理解 OpenGL 和 OpenGL ES API 背后的概念。我们解释了所有的关键概念和 API,但是首先我们在固定功能 OpenGL ES 1.0 的上下文中介绍这些 API。您在这里获得的知识也适用于 ES 2.0。

OpenGL 遵循客户端服务器模型。客户端是请求绘制的一方,服务器是接收、解释和绘制数据的一方。通常,GPU 的作用类似于服务器。然而,与典型的“C”API 不同,OpenGL APIs 是“有状态的”这意味着一个 API 会影响后续 API 的行为。例如,第一个 API 可以设置顶点。后续的 API 可以设置颜色。并且第三 API 可以基于由第一和第二 API 提供的信息发出绘制命令。然后 GPU 使用目前收集的所有信息进行绘制。

OpenGL 中的关键概念

当你阅读本章中的 OpenGL APIs 或其他 OpenGL 文献时,以下 OpenGL 关键概念将被证明是非常宝贵的。所以找把椅子,坐好,随便浏览一下这些 OpenGL 概念和术语。提前了解这些想法是理解任何 OpenGL 材料的最快方法,包括我们在本章中的详细介绍。

顶点

在 OpenGL 中,复杂的图形是通过连接三维空间中定义的点来绘制的。这些点也称为顶点。每个顶点都在三维空间中定义:x、y 和 z,通常用浮点数表示。然后指示 OpenGL 如何连接这些点,是使用直线(两点)还是三角形(三点),等等。这些基本形状有时被称为“基本几何”

碎片

当两个点连接在一起时,连接这两个点的线需要绘制为一组不连续的片段。每个片段可以是一个像素宽。类似地,当三个点定义一个三角形表面时,该表面可以被视为片段的集合,其中每个片段可以是一个像素或更宽。这样的片段(一组,包括一个像素)然后可以具有诸如颜色、深度(离视角多远或多近)等特征。当一个顶点存在于 3D 空间中时,一个片段存在于一个扁平的虚拟空间中,如你的屏幕(2D 空间)。然而,在片段变成像素之前,该片段经过一系列测试来评估它是否应该变成像素。例如,根据片段的深度以及是否有另一个邻近的片段位于其上,您可以选择忽略较远的片段。这就是所谓的“深度测试”请参考 OpenGL 文献,了解许多其他测试,如剪刀测试、alpha 测试、模板测试、抖动等。

像素

尽管片段直到图形管道的后期才变成像素,但很多时候像素与片段是同义的。例如,为片段着色的逻辑有时被称为“像素着色器”或“片段着色器”稍后我们将讨论“着色器”的含义。

供过于求

OpenGL 是一组核心 API,主要处理三维点集并将它们渲染到虚拟屏幕表面。无论那个屏幕表面是在内存中,大屏幕,小屏幕,还是打印机,都不是核心 OpenGL。这些细节留给操作系统和各自的窗口系统。

OpenGL 与窗口系统的交互方式取决于操作系统。OpenGL 不提供与屏幕、鼠标、菜单或按钮交互的 API。每个操作系统都以自己的方式做到这一点。GLUT 是一个常见的“C”库,用来隐藏这些细节。每个操作系统都提供了 GLUT API 的实现来支持这种交互。

因此,GLUT 是一个 API,它允许您编写可执行的(当然,您需要编译和链接)“C”程序,这些程序可以在多个平台上运行,而无需为每个操作系统或环境重新编码。如果你正在使用 Android Java SDK,你不需要处理大量的数据,但是你需要使用提供这种交互的 Android 特定的 Java 类。

格勒夫

OpenGL 允许额外的 API 作为其核心的扩展。GLEW 是一个通用的可移植 API,用于发现和管理来自 GPU 供应商的这些扩展 API。在本章中。我们没有讨论扩展 API,所以没有机会讨论这个,另一个管理这些扩展的可移植“C”API。

欧洲珠宝实验室

OpenGL APIs 需要两样东西来工作:渲染上下文和表面缓冲区。API 需要一个像工作内存一样管理 OpenGL 状态的地方;这是渲染上下文。OpenGL 也需要一个表面来绘制。各种 OpenGL 缓冲区附加到此绘图图面;这些被称为表面缓冲。示例缓冲区有颜色缓冲区、模板缓冲区和深度缓冲区。(我们将在这个关键概念部分稍后解释模板和深度。)

设置和管理这两个方面是底层操作系统的责任。在 OpenGL ES 中,有一个叫做 EGL 的标准来处理这些集成 API。不同于 GLUT 和 GLEW(它们是便利的 API),EGL 是 OpenGL ES 实现的基本 API。因此,Android 确实公开了 EGL API 来控制 OpenGL、渲染上下文和帧缓冲区,并绑定到可用的显示器。同时,Android 还封装了 EGL 行为,并通过一个名为 GLSurfaceView 的类提供了一个简单得多的与 OpenGL 交互的机制。我们将在本章的后面介绍和使用这个类。

OpenGL ES 2.0 编程指南很好地涵盖了 EGL。但是,如果您不在 Android 上进行系统级编程,您就不需要这种级别的细节。

彩色缓冲器

屏幕上的每个像素都有一个颜色值。根据硬件的不同,保存该值所需的空间可能会有所不同。例如,它可以是 24 位值,每个颜色通道(R、G、B)8 位。比如我的笔记本电脑,屏幕是 1280 × 1024。这意味着有 1280 x 1024 个像素。然后,颜色缓冲器需要在缓冲器中为这些像素中的每一个保存 24 位;这被称为“颜色缓冲区”因此,颜色缓冲区保存所有像素的颜色值。给定像素没有自己的颜色缓冲区,但整个显示帧有一个颜色缓冲区。如果你正在显示立体图像,那么你可能有两个颜色缓冲区:一个左边的和一个右边的。如果你使用双缓冲,你可能会有另外两个,前端和后端。

深度缓冲器

在结构上,深度缓冲区非常像颜色缓冲区。深度缓冲器保存场景中所有像素的每个像素的深度值。深度值是像素(更准确地说,是片段)在 z 轴上的远近程度。您可以使用这个缓冲区来执行深度测试,并告诉 OpenGL 不要绘制隐藏的表面。例如,要隐藏一个像立方体一样的物体的隐藏表面,你可以使用清单 9-1 所示的 API 序列。

清单 9-1。如何使用深度隐藏隐藏表面

// Enable depth test

glEnable(GL_DEPTH_TEST);

// Accept fragment Or Draw a pixel if it closer to the camera than the former one

glDepthFunc(GL_LESS);

模板缓冲区

与颜色和深度缓冲区非常相似,模板缓冲区与像素相关。模板缓冲区可以为场景中的所有像素保存任意一组位。OpenGL 中有几个 API 允许你根据这些信息位(模板位)来控制一个片段(像素)是否应该被绘制。然后,您可以使用此工具来模拟我们在现实世界中使用模板的方式,即在一个剪切的形状上绘画,以便您的绘图或绘画受到约束。如果模板缓冲区被指定并处于活动状态,它会影响您在屏幕上看到的内容。一个经常给出的例子是汽车的仪表板,它只允许你透过挡风玻璃看东西。仪表板由一个模具表示,该模具在视图周围框出一个边框。

帧缓冲器

帧缓冲区是所有像素缓冲区的集合,如颜色、深度和模板缓冲区。

渲染脚本

Renderscript 是一种“C”或类似“C”的编程语言,自 Android 3.0 以来就可用于高性能计算。renderscript 的目标是跨各种物理处理器移植。Renderscript 还提供了多个处理器之间的并行性。Android 通过 Java 为 renderscript 编写的程序提供了一个接口 API,以便在物理处理器上编译和运行这些脚本。Renderscript 预计将用于图像处理、数学建模和计算。虽然在 Android 3.0 中它被绑定到图形处理,但从 Android 4.0 开始,这个工具已经成为一个通用的计算工具。就学习 OpenGL 而言,你可以忽略 Renderscript,直到你需要高要求的转换或计算。

Renderscript 有很多内置的数学函数,包括很多矩阵操作。如前所述,Renderscript 为客户端提供了一个类似 EJB(但更简单、更直接)的 Java 接口。Renderscript 的动机是在提供可移植的高性能计算的同时易于使用。OpenCL 是 Khronos 为 GPU 带来并行高性能计算的类似努力。CUDA(计算统一设备架构)是这个领域的另一个术语,由 Nvidia 推出,旨在利用其高度并行的 GPU 来执行计算机密集型任务,无论这些任务是否与图形相关。

GLSL 和着色器

在 OpenGL ES 2.0 中,您会看到对顶点着色器和片段着色器的引用。在这两种情况下,着色器是用类似“C”的着色语言 GLSL 编写的程序。顶点着色器是一个被调用来将每个顶点转换到它在屏幕上的最终位置的程序;为每个顶点调用一次顶点着色器。片段着色器是在渲染每个像素(更准确地说,是每个片段)时调用的程序。(见上面片段。)

OpenGL 版引入了 GLSL。基于 OpenGL 1.3 的 OpenGL ES 1.x 是一个固定功能的流水线,没有可编程性,因此不需要任何像 GLSL 这样的语言。另一方面,OpenGL ES 2.0 基于 OpenGL 2.0 的可编程管道,因此它需要自己的 GLSL 版本。用于 OpenGL ES 2.0 的 GLSL ES 基于 GLSL 的 1.2 版本(为 OpenGL 2.0 设计)。

与此同时,OpenGL 已经发展到 4.0 版本,它的 GLSL 版本也已经升级。因此,如果你正在阅读 GLSL 的文献,请注意你所使用的 GLSL 规范的版本号。

用 GLSL ES 1.x 编写的程序通常在源文件的第一行标明它们的版本号。下面举个例子:#版本 100。虽然在撰写本文时并不常见,但如果您使用的是 OpenGL ES 3.0,那么 GLSL ES 的相应版本是 3.0,表示为:#version 300。如果您阅读了 ES 2.0 附带的 GLSL ES 规范(URL 在本文档的参考资料一节中指定),您将会看到该规范将版本号指定为 1.00,将修订号指定为 17。因此,看起来你会忽略修订,只使用主版本号,没有小数。如果不指定版本号,则认为是 1.00。

物体坐标

在 3D 绘图中,您创建对象并将它们放置在三维场景中的某个点。对象坐标通过指定该对象的顶点来指定该对象的大小和位置。您可以通过相对于(0,0,0)原点的方式来建模该对象。然后,您可以对该对象应用一些变换来缩放或旋转它,甚至可以相对于您已经绘制或计划绘制的另一个对象来放置它。

对给定对象应用的这些变换称为模型变换。当您绘制多个对象时,您所绘制的每个对象可能会经历自己的模型转换;否则,一次性定义所有对象的顶点会变得很乏味。

物体坐标的比例和位置可以像你想象的那样适合你的建模。这类似于在一张图纸上画各种图表。选择比例,并相应地缩放坐标。(选择比例将在本章稍后解释。)所以当你在设计物体坐标时,你选择了一个适合那个物体的相对比例。例如,如果您正在从真实世界中绘制一个对象,如一辆汽车,您可以选择它的真实尺寸,然后选择足够大的场景。不过,最终,所有这些尺寸都将被适当地缩小或裁剪到您要使用的窗口的大小。

世界坐标

当您按照前面的定义绘制多个对象时,您是将这些对象放在一个有自己的中心和尺寸的概念世界中。换句话说,有一个场景是你在想象的。这些场景坐标称为世界坐标。很多时候程序员可能会选择使用相同的维度和中心作为物体和世界坐标。这就是我们在本章中所做的。

查看对象和世界坐标的另一种方式如下:当您对对象应用模型变换时,您会将对象放置在世界坐标中。

模型坐标

您正在绘制的对象可以被视为模型。或者,有时你可以看到你试图作为一个模型描绘的整个世界。因此,一些作者将对象坐标称为模型坐标;他们还将世界坐标称为模型坐标。

有一种想法认为 OpenGL 并不真正识别世界坐标,因为除了发生在每个单独对象上的“模型”转换之外,没有任何特定的转换。例如,在 OpenGL 中有一个模型矩阵、一个视图矩阵和一个投影矩阵。因此,模型矩阵解决了对象、模型和世界坐标的需要。

相机和眼睛坐标

你的概念世界(比如说,一个大立方体)可以和你想象的一样大或一样小,它有自己的中心位置。为了可视化这个世界,你需要一个观察这个世界的观察者。如果这个观察者离想象中的中心太远,那么世界看起来很小,如果观察者离得很近,那么世界看起来更大。“眼睛”的位置和方向(上、下、倾斜)会影响模型坐标在成为最终屏幕坐标时的转换方式。眼睛看到的世界的变换坐标称为眼睛坐标。

当然,你不会开始在眼睛坐标中指定某个东西。相反,您从对象坐标开始,并应用模型变换来获得世界坐标。然后应用“视图”变换(由眼睛的位置和方向指示)来获得设备/屏幕坐标。

平截头体和设备坐标

当你想象你的世界时,你必须通过定义一个盒子来定义你的世界的边界。这个盒子可以是一个长矩形管(矩形棱柱)或一个截头棱锥(有一个像眼睛一样的投影;见图 9-1 )称为平截头体。这个有限的封闭区域决定了什么是可见的,什么是不可见的。

因此,我们采用眼睛坐标,并应用投影矩阵将顶点投影到平截头体的前面。这些投影坐标需要根据窗口的大小进行缩放或调整,然后才能在屏幕上获得(x,y)坐标。

查看端口和窗口/屏幕坐标

平截头体定义了将 3D 空间投影到 2D 空间的机制。然后,平截头体的概念框需要绑定到显示场景的固定窗口大小。这个窗口大小定义被恰当地称为视口。投影坐标由 OpenGL 内部转换为屏幕坐标,不需要显式矩阵。

MVP 或模型视图投影矩阵

如果你从一个顶点的原始坐标开始,那么这个顶点在到达屏幕坐标之前要通过三个不同的矩阵进行转换。这三个矩阵分别是:

Model Matrix       //Get to the world coordinates

View Matrix       //Apply the eye

Projection Matrix //Apply the frustum

通过应用清单 9-2 中的公式,你可以得到一个乘法矩阵(称为模型视图投影矩阵,或 MVP ),而不是将顶点坐标分别乘以这些矩阵:

清单 9-2。计算模型视图矩阵

MVP matrix = Projection Matrix * View Matrix  * Model Matrix

在这个等式中(清单 9-2),模型矩阵本身可能是多个单独的模型矩阵相乘的结果,如缩放、旋转和平移。如果你拿一个物体,通过旋转矩阵旋转它,通过平移矩阵平移它,然后通过缩放矩阵缩放它,这是很常见的。

一旦有了这个 MVP 矩阵,就可以通过矩阵乘法将任意顶点转换到它的最终位置:

Final vertex = MVP Matrix *  Initial Vertex

顶点属性

顶点是一个位置。我们也可以把颜色和顶点联系起来。顶点的这些特征称为该顶点的属性。所以,位置是一种属性。颜色是一种属性。在一个给定的纹理图像上,一个顶点被固定在那里,这可以是另一个顶点属性。顶点属性的概念并不局限于这些广为接受的 OpenGL 属性。在 ES 2.0 中,可以为一个顶点表示任意数量的属性。对于每个属性,可以为属性缓冲区中的所有顶点发送该属性的值数组。例如,可以在颜色属性数组中指定所有顶点的颜色值。所有顶点的位置可以在 position 属性数组中指定。在本章中,当我们浏览几个 ES 2.0 示例程序时,你会看到这样的例子。

顶点的缠绕

如果你在 OpenGL 中用三个顶点画一个三角形,这个三角形被认为有一个正面和一个背面。你怎么知道哪个是正面哪个是背面?这是如何确定的:想象一个右手螺旋在三角形的中心。如果你以顶点顺序的相同方向转动那个螺丝,你将会看到 RHS 螺丝是离开表面还是进入表面。螺丝钉离开的表面是前面,而螺丝钉前进的表面(即使后退)是后面。因此逆时针缠绕将指示正面。请记住,顶点的顺序是有区别的。然而,从哪个顶点开始并不重要,只要这些顶点的遍历是你想要的:逆时针或顺时针。如果螺钉从对象中出来,那么您就正确地指定了顶点,并且顶点一致地形成了外部曲面。

抗锯齿

计算机屏幕上的像素排列成网格。给定的像素不能部分着色:对于该像素,要么全部着色,要么什么也不着色。你可以画出没有任何锯齿边缘的横线或竖线,但你画出对角线的那一刻,你会看到一条锯齿对角线,因为你无法划分和绘制半个像素。当你沿着对角线往下走时,你会留下一个像素打开,一个像素关闭,就像楼梯一样。因此,抗锯齿是一种用合适的阴影颜色绘制额外像素的算法,以便您的眼睛将混合这些颜色以获得平滑线条的印象。这些抗锯齿算法需要计算有多少相邻像素,以及使用什么样的阴影。

混合

片段处理器或着色器的输出之一是颜色。如果没有混合,颜色将变成(假设没有剔除或其他效果来忽略用于绘制的像素)帧缓冲区的颜色,您将在屏幕上看到它们。但是如果混合有效,颜色缓冲区中可能已经有需要与这组新颜色混合的颜色。混合的一个常见用途是提供一定程度的透明度。OpenGL 允许许多混合功能来控制混合行为。

抖动

数字图像上的污点是由图像和显示图像的显示器的色深差异引起的。颜色深度是为每个像素分配的用于表示其颜色的位数。如果是 24 位颜色,则 8 位代表 R、G 和 b 中每种颜色的 256 种色调。当这些色调由于缺乏深度而近似时,您可能会看到一种颜色替换了许多近似的颜色,从而产生带有斑点的图像。抖动是一种混合可用色调(调色板)的技术,通过使用提供混合的多个相邻像素来近似原始色调。例如,如果你把 500 个红色像素和 500 个黄色像素混合起来,从足够远的地方你会看到一个既不红也不黄的新颜色。

OpenGL 中的雾化效果是根据像素与观察者的距离来调整像素颜色的过程。

颜色总和

在 OpenGL 管道中完成纹理化后,可以指定一种辅助颜色添加到纹理化的颜色中。这种二次色可能是由适当的或明确指定的照明效果产生的(只要照明关闭)。在颜色总和之后应用雾。

片段测试

片段处理的输出是计算出目标像素及其颜色特征。然而,在这些像素被写入帧缓冲区成为屏幕的一部分之前,它们要经过多次测试。帧缓冲区中的像素位置当前是否属于这个 OpenGL 上下文?还是由其他应用控制?。如果深度测试打开,这个像素是否在另一个像素后面?是否有模板活动,可能会阻止此像素显示?这些被称为片段测试。如果像素没有通过测试,它们将不会被写入帧缓冲区。

虽然在阅读 OpenGL 文献时你会看到更多的术语,但是了解这些术语将会极大地帮助你理解本章的其余部分。

用 OpenGL ES 1.0 绘制基本图形

本章的主要焦点是 OpenGL ES 2.0。但是,了解 OpenGL ES 1.0 的基础知识对于很好地掌握 OpenGL ES 2.0 是有用的。因此,首先我们介绍使用 ES 1.0 绘制 OpenGL 的基础知识。

在 OpenGL 中,你在 3D 空间中绘图。从指定一系列顶点开始。一个顶点有三个值:x、y 和 z。然后将顶点连接在一起形成一个形状。您可以将这些顶点连接成各种形状,称为基本形状(也称为基本几何体),可以是点、线和三角形。注意,在 OpenGL 中,基本形状还包括矩形和多边形。当您使用 OpenGL 和 OpenGL ES 时,您将继续看到两者的差异,即后者的功能比前者少。

OpenGL ES 1.0 提供了两种主要的绘图方法:

  • glVertexPointer
  • glDrawElements

您使用glVertexPointer来指定一系列顶点,并使用glDrawElements使用前面提到的原始形状之一来绘制它们。

glVertexPointer 和指定绘图顶点

glVertexPointer方法负责指定要绘制的点的数组。每个点都是在三维空间中指定的,所以每个点都有三个值:x,y 和 z。清单 9-3 显示了如何在一个数组中指定这三个点。

清单 9-3。OpenGL 三角形的顶点坐标示例

float[] coords = {

-0.5f, -0.5f, 0,     //p1: (x1,y1,z1)

0.5f, -0.5f, 0,     //p2: (x1,y1,z1)

0.0f,  0.5f, 0      //p3: (x1,y1,z1)

};

清单 9-3 中的结构是一组连续的浮点数,保存在一个基于 Java 的浮点数组中。你可能想知道点p1p2p3的坐标使用什么单位。简单的回答是:当你建模你的 3D 空间时,这些坐标单位可以是你想要的任何东西。随后,您将需要指定一个称为包围体(或包围盒)的东西,它以相对的方式量化这些坐标。

例如,可以将边界框指定为边长为 5 英寸的立方体或边长为 2 英寸的立方体。这些坐标是世界坐标,因为你是独立于物理设备的限制概念化你的世界。(我们将在“理解 OpenGL 相机和坐标”小节中详细解释这些坐标。)现在,假设您正在使用一个立方体,它的所有边长为 2 个单位,中心位于(x = 0,y = 0,z = 0)。换句话说,中心在立方体的中心,立方体的边与中心相距一个单位。

Note

术语包围体、包围盒、观察体、观察框和平截头体都指的是同一个概念:决定屏幕上可见内容的金字塔形 3D 体。您将在“了解 OpenGL 相机和坐标”下的“GL 截锥和观察体”小节中了解更多信息

你也可以假设原点在视觉显示的中心。z 轴在进入显示器时为负(远离你),在离开显示器时为正(朝向你);当你向右移动时,x 为正,向左移动时,x 为负。然而,这些坐标也取决于你观察场景的方向。

要绘制清单 9-3 中的点,你需要通过glVertexPointer方法将它们传递给 OpenGL ES。然而,出于效率的原因,glVertexPointer采用了一个与语言无关的本机缓冲区,而不是一个 Java 浮点数组。为此,您需要将基于 Java 的浮点数组转换为可接受的类似“C”的本机缓冲区。您需要使用java.nio类将浮点数组转换成本机缓冲区。清单 9-4 显示了一个使用nio缓冲区的例子。

清单 9-4。创建 NIO 浮点缓冲区

jva.nio.ByteBuffer vbb = java.nio.ByteBuffer.allocateDirect(3 * 3 * 4);

vbb.order(ByteOrder.nativeOrder());

java.nio.FloatBuffer mFVertexBuffer = vbb.asFloatBuffer();

在清单 9-4 中,字节缓冲区是按字节排序的内存缓冲区。每个点因为有三个轴,所以有三个浮点,每个浮点是 4 个字节。所以每个点总共有3 x 4个字节。另外,三角形有三个点。所以你需要3 x 3 x 4字节来保存一个三角形的所有三个浮点。

一旦你把这些点收集到一个本地缓冲区,你就可以调用glVertexPointer,如清单 9-5 所示。

清单 9-5。glVertexPointer API 定义

glVertexPointer(

3,                // Are we using (x,y) or (x,y,z) in each point

GL10.GL_FLOAT,    // each value is a float value in the buffer

0,                // Between two points in the buffer there is no space

mFVertexBuffer);  // pointer to the start of the buffer

第一个参数告诉 OpenGL ES 一个点或一个顶点有多少个维度。在这种情况下,我们为 x、y 和 z 指定了3。您也可以仅为 x 和 y 指定2。在这种情况下,z 将为零。请注意,第一个参数不是缓冲区中的点数,而是使用的维数。所以如果你通过 20 分画出若干个三角形,你不会通过20作为第一个参数;你会通过23,这取决于使用的维度数量。

第二个参数表明坐标需要被解释为浮点数。第三个参数称为stride,指向分隔每个点的字节数。在这种情况下,它是零,因为一个点紧跟着另一个点。有时,您可以在每个点后添加颜色属性作为缓冲区的一部分。如果你想这样做,你可以使用一个stride来跳过这些作为顶点规范的一部分。最后一个参数是指向包含这些点的缓冲区的指针。

现在你知道了如何设置要绘制的点的数组,让我们看看如何使用glDrawElements方法绘制这个点的数组。

glDrawElements

一旦通过glVertexPointer指定了一系列点,就可以使用glDrawElements方法用 OpenGL ES 允许的一种基本形状来绘制这些点。注意 OpenGL 是一个状态机;当它以累积的方式调用下一个方法时,它会记住由一个方法设置的值。所以不需要明确的把glVertexPointer设置的点数传递给glDrawElementsglDrawElements会含蓄地使用那些要点。清单 9-6 显示了这种方法的一个例子,带有可能的参数。

清单 9-6。glDrawElements 示例

glDrawElements(

GL10.GL_TRIANGLES,      // type of shape

3,                      // Number of indices

GL10.GL_UNSIGNED_SHORT, // How big each index is

mIndexBuffer);          // buffer containing the 3 indices

第一个参数表示要绘制的几何形状的类型:GL_TRIANGLE_STRIP表示一个三角形带。该参数的其他可能选项有仅点(GL_POINTS)、线条(GL_LINE_STRIP)、仅线条(GL_LINES)、线条循环(GL_LINE_LOOP)、仅三角形(GL_TRIANGLES)和三角形扇形(GL_TRIANGLE_FAN)。

GL_LINE_STRIPGL_TRIANGLE_STRIP中 a STRIP的概念是在利用旧点的同时增加新点。通过这样做,您可以避免为每个新对象指定所有点。例如,如果在一个数组中指定四个点,可以使用条带从(1,2,3)中绘制第一个三角形,从(2,3,4)中绘制第二个三角形。每个新的点将增加一个新的三角形。(详见 OpenGL 红皮书。)您还可以改变这些参数,以便在添加新点时查看三角形是如何绘制的。

GL_TRIANGLE_FANFAN的概念适用于第一个点被用作所有后续三角形起点的三角形。所以你实际上是在做一个扇形或圆形的物体,第一个顶点在中间。假设你的数组中有六个点:(1,2,3,4,5,6)。使用FAN,三角形将被绘制在(1,2,3)、(1,3,4)、(1,4,5)和(1,5,6)处。每一个新的点都会增加一个额外的三角形,类似于展开一把扇子或展开一副牌的过程。

glDrawElements的其余参数涉及该方法让您重用点规范的能力。例如,一个正方形包含四个点。每个正方形都可以由两个三角形组合而成。如果要画两个三角形组成正方形,是否必须指定六个点?不可以。您只能指定四个点,并参考它们六次来绘制两个三角形。这个过程被称为索引到点缓冲区。

这里有一个例子:

Points: (p1, p2, p3, p4)

Draw indices (p1, p2, p3,    p2,p3,p4)

注意第一个三角形是如何组成p1, p2, p3的,第二个三角形是如何组成p2, p3, p4的。有了这个知识,glDrawElements的第二个参数确定了索引缓冲区中有多少个索引。

glDrawElements(见清单 9-6)的第三个参数指向了索引数组中值的类型,无论它们是无符号短整型(GL_UNSIGNED_SHORT)还是无符号字节(GL_UNSIGNED_BYTE)。

glDrawElements的最后一个参数指向索引缓冲区。要填满索引缓冲区,您需要做一些与顶点缓冲区类似的事情。从一个 Java 数组开始,使用java.nio包将该数组转换成一个本地缓冲区。

清单 9-7 显示了一些将一个短数组{0,1,2}转换成适合传递给glDrawElements的本地缓冲区的示例代码。

清单 9-7。将 Java 数组转换为 NIO 缓冲区

//Figure out how you want to arrange your points

short[] myIndecesArray = {0,1,2};

//get a short buffer

java.nio.ShortBuffer mIndexBuffer;

//Allocate 2 bytes each for each index value

ByteBuffer ibb = ByteBuffer.allocateDirect(3 * 2);

ibb.order(ByteOrder.nativeOrder());

mIndexBuffer = ibb.asShortBuffer();

//stuff the index array into the nio buffer

for (int i=0;i<3;i++) {

mIndexBuffer.put(myIndecesArray[i]);

}

现在你已经看到了清单 9-7 中的mIndexBuffer,你可以重温一下清单 9-6,看看这个清单中的索引缓冲指针是如何被创建和操作的。

Note

索引缓冲区并不创建任何新点,而是仅仅索引到已经通过glVertexPointer指示的点的数组中。

接下来我们来看两个常用的 OpenGL ES 方法:glClearglColor

格莱丽

您使用glClear方法擦除绘图表面。使用这种方法,您可以重置所用模具的颜色、深度和类型。(参考前面的关键概念部分,了解这三种缓冲。)您可以通过传入的常量来指定要重置哪个缓冲区:GL_COLOR_BUFFER_BITGL_DEPTH_BUFFER_BITGL_STENCIL_BUFFER_BIT

例如,您可以使用清单 9-8 中的代码来清除颜色缓冲区:

清单 9-8。清除颜色缓冲区

//Clear the surface of any color

gl.glClear(gl.GL_COLOR_BUFFER_BIT);

glColor(彩色)

您可以使用glColor来设置后续绘图的默认颜色。在清单 9-9 的代码段中,方法glColor4f将颜色设置为红色:

清单 9-9。设置颜色

//Set the current color

glColor4f(1.0f, 0, 0, 0.5f);

基本方法名是glColor4f指的是该方法接受的四个参数,每个参数都是一个浮点数。这四个参数是红色、绿色、蓝色和 alpha(颜色渐变/不透明度)的组成部分。每个的起始值是(1,1,1,1):“1”表示全彩;“0”表示没有颜色。在这种情况下,颜色被设置为带有半个渐变的红色(由最后一个 alpha 参数指定)。

虽然我们已经介绍了基本的绘图 API(指定顶点、指定顶点、绘制几何图元、清除和分配颜色),但我们仍然需要解决一些关于您在 3D 空间中指定的点的坐标的问题。下一小节解释了 OpenGL 是如何通过一个观察者透过摄像机观察的视角来模拟真实世界场景的。下一小节还应该阐明前面的关键概念部分中提到的关于各种坐标的内容。

了解 OpenGL 相机和坐标

到目前为止,我们已经有了一个三角形对象(清单 9-3 ),我们已经把它建模为在我们自己的虚拟坐标空间中绘制。当您在 3D 空间中绘图时,您最终必须将 3D 视图投影到 2D 屏幕上,就像在现实世界中使用相机捕捉 3D 场景一样。

绘图中可见的部分取决于相机的位置、相机镜头的方向、相机的方向(例如上下颠倒或倾斜)、缩放级别以及捕捉“胶片”的大小

将 3D 图像投影到 2D 屏幕上的这些方面由 OpenGL 中的三种方法控制:

  • gluLookAt控制摄像机的方向。
  • glFrustum控制您关心的查看音量、缩放或距离(从和到)。
  • glViewport控制屏幕尺寸或相机胶卷的尺寸。

gluLookAt 和相机象征

想象你正在拍摄一幅风景照片。你带着相机来到一片草地;你面前的场景相当于 OpenGL 中的绘图场景。你可以把这些画放大,像山一样,或者缩小,像花一样——只要它们彼此成比例。正如我们前面暗示的,你在这些绘图中使用的坐标叫做世界坐标。例如,在这些坐标下,通过将直线的端点设置为(0,0,0)到(4,0,0),可以在 x 轴上建立一条四个单位长的直线。

当你准备拍照时,你找到一个地方放置你的三脚架。然后你把相机放在三脚架上。你相机的位置——不是三脚架,而是相机本身——成为你相机在世界上的原点。所以你需要拿一张纸,记下这个位置,这个位置叫做眼点。

如果不指定眼点,相机位于(0,0,0),这是屏幕的精确中心。通常你想离开原点,这样你就能看到位于 z = 0 原点的(x,y)平面。为了便于讨论,假设您将摄像机定位在(0,0,5)。这将使摄像机从屏幕上向你移动五个单位。

您可以参考图 9-1 来直观显示摄像机的放置方式。

A978-1-4302-4951-1_9_Fig1_HTML.jpg

图 9-1。

OpenGL viewing concepts using a camera analogy

看着图 9-1 ,你可能会想为什么图中的轴是 y 和 z,而不是 x 和 y。这是因为我们使用的惯例是 OpenGL 相机看向深度——z 轴,如果你的场景法线平面是 xy 平面。这个约定很好,因为我们通常将 z 轴作为深度轴。

一旦你放置好相机,你就开始向前看,看你想捕捉场景的哪个部分。你把相机对准你要看的方向。你正在看的这个远点被称为视点或注视点。点规格实际上是方向的规格。如果将视点指定为(0,0,0),则假设相机位于(0,0,5),相机将沿着 z 轴从五个单位的距离看向原点。你可以在图 9-1 中看到这一点,这里摄像机正沿着 z 轴向下看。

进一步想象一下,原点有一个长方形的建筑。你想看它,不是以肖像的方式,而是以风景的方式。你必须做什么显然,您可以将相机留在同一位置,并仍然将其指向原点,但现在您需要将相机旋转 90 度(类似于侧过头来看)。这是摄像机的方向,因为摄像机固定在一个给定的视点上,并观察一个特定的观察点或方向。这个方向称为上矢量。

上方向向量仅标识相机的方向(上、下、左、右或某个角度)。摄像机的这个方向也是使用观察点来指定的。想象一条从原点(不是相机原点,而是世界坐标原点)到这个观察点的直线。这条线在三维空间中与原点所成的任何角度都是相机的方向。

例如,相机的上方向向量可能看起来像(0,1,0)或者甚至像(0,15,0),这两种向量具有相同的效果。点(0,1,0)是沿 y 轴向上远离原点的点。这意味着您要将摄像机竖直放置。如果使用(0,1,0),您将颠倒放置相机。在这两种情况下,相机仍然在同一点(0,0,5)并看着同一个原点(0,0,0)。

我们现在可以总结三个与摄像机相关的坐标:

  • (0,0,5):视点(相机的位置)
  • (0,0,0):注视点(相机指向的方向)
  • (0,1,0):向上向量(相机是向上、向下还是倾斜)

你将使用gluLookAt方法来指定这三个点——视点、观察点和上矢量——如清单 9-10 所示:

清单 9-10。设置摄像机

gluLookAt(gl, 0,0,5,    0,0,0,   0,1,0);

这些参数是:(1)第一组坐标属于眼点,(2)第二组坐标属于注视点,以及(3)第三组坐标属于相对于原点的上矢量。

现在来讨论一下观看量。

GL 平截头体和观察体积

你可能已经注意到,使用gluLookAt描述相机位置的点都不涉及尺寸。它们都只涉及定位、方向和方向。你如何告诉照相机聚焦在哪里?你要捕捉的对象离你有多远?主题区域有多宽多高?您使用 OpenGL 方法glFrustum来指定您感兴趣的场景区域。

如果你想象自己坐在一个礼堂里看戏,那么舞台就是你的观看空间。你真的不需要知道这个阶段之外会发生什么。然而,你确实关心这个阶段的维度,因为你想观察这个阶段上/内发生的一切。

将场景区域想象成由一个盒子包围,也称为视锥体或视见体积(这是由图 9-1 中间的粗体边框标记的区域)。框内的任何内容都会被捕获,框外的任何内容都会被剪切和忽略。那么如何指定这个查看框呢?你首先决定近点,或者相机和盒子的起点之间的距离。然后你可以选择一个远点,这个远点就是相机到盒子末端的距离。沿 z 轴的近点和远点之间的距离是盒子的深度。如果您指定近点为 50,远点为 200,那么您将捕捉这些点之间的所有内容,并且您的长方体深度将为 150。您还需要指定盒子的左侧、右侧、顶部和底部。此处使用的尺寸是相对于建模的对象而言的。例如,清单 9-3 中的三角形尺寸将与这个观察体积相关。

在 OpenGL 中,你可以用两种方式想象这个盒子。一种叫做透视投影,它涉及到我们一直在谈论的平截头体。这个视图模拟了一个类似照相机的功能,包括一个金字塔结构,其中远平面作为底部,照相机作为顶点。近平面切掉金字塔的顶部,在近平面和远平面之间形成平截头体。

想象盒子的另一种方式是把它想象成一个直角棱镜(一维较长的立方体)。第二种情况称为正交投影,适用于需要保留尺寸的几何图形,而不管它们与相机的距离。清单 9-11 显示了如何为我们的例子指定平截头体。

清单 9-11。通过 glFrustum 指定平截头体

//calculate aspect ratio first

//w – width of the window/view

//h – height of the window/view

float ratio = (float) w / h;

//indicate that we want a perspective projection

glMatrixMode(GL10.GL_PROJECTION);

//Specify the frustum: the viewing volume

gl.glFrustumf(

-ratio,    // Left side of the viewing box

ratio,     // right side of the viewing box

1,         // top of the viewing box

-1,        // bottom of the viewing box

3,         // how far is the front of the box from the camera

7);        // how far is the back of the box from the camera

因为我们在前面的代码(清单 9-11)中将顶部设置为1,底部设置为-1,所以我们将盒子的前部高度设置为2单位。考虑到窗口的长宽比,我们使用比例数字来指定平截头体左侧和右侧的大小。这就是为什么这段代码使用窗口的高度和宽度来计算比例。该代码还假设动作区域位于 z 轴上的37单位之间。相对于相机,画在这些坐标之外的任何东西都是不可见的。

因为我们将相机设置为(0,0,5)并指向(0,0,0),所以从相机到原点的三个单位将是(0,0,2),从相机到原点的七个单位将是(0,0,2)。这会将原点平面留在 3D 长方体的正中间。

现在我们已经确定了可视体积的大小。还有一个更重要的 API,它将这些尺寸映射到屏幕上:glViewport

GL 视窗和屏幕尺寸

glViewport负责指定屏幕上的矩形区域,观察体积将被投影到该区域上。该方法使用四个参数来指定矩形框:左下角的 x 和 y 坐标,后跟宽度和高度。清单 9-12 是指定一个视图作为投影目标的例子。

清单 9-12。通过 glViewPort 定义视口

glViewport(0,        // lower left "x" of the rectangle on the screen

0,        // lower left "y" of the rectangle on the screen

width,    // width of the rectangle on the screen

height);  // height of the rectangle on the screen

如果您的窗口或视图大小为 100 像素高,平截头体高度为 10 个单位,那么世界坐标中的每个逻辑单位 1 都转换为屏幕坐标中的 10 个像素。

到目前为止,我们已经介绍了 OpenGL 中的关键概念,而不管 OpenGL 是在什么平台上实现的。理解这些 OpenGL 基础知识对于学习如何在 Android 平台上编写 OpenGL 代码也是必不可少的。有了这些先决条件之后,我们现在讨论在 Android 中需要什么来调用我们到目前为止已经讨论过的 OpenGL ES APIs,并尝试绘制清单 9-3 中所设想的三角形。

OpenGL ES 与 Android 的接口

如上所述,OpenGL ES 是一种被许多平台支持的标准。如你所见,它的核心是一个类似“C”的 API,处理所有的 OpenGL 绘图杂务。然而,每个平台和操作系统在实现 OpenGL 上下文、显示、屏幕缓冲等方面是不同的。这些特定于操作系统的方面留给每个操作系统来实现。一个叫做 EGL 的标准被用来实现这些细节。虽然 Android 在必要时直接公开了 EGL API,但它也定义了面向对象的抽象来隐藏 EGL 的重复节奏。

使用 GLSurfaceView 和相关类

从 SDK 的 1.5 版本开始,使用 OpenGL 的常见使用模式被大大简化了。(参考本书第一版,看 Android 1.0 的做法。)实现这个交互的关键类和接口是GLSurfaceViewRenderer。(所以,除非你想追踪某个特定的行为,否则你不会直接与 EGL 合作。)

以下是使用这些类进行绘制的典型步骤:

Implement the GLSurfaceView.Renderer interface.   Set up key OpenGL parameters that control the general behavior of OpenGL, and provide the camera settings needed for your drawing in the implementation of this renderer. (You do this in onCreateSurface and onSurfaceChanged callbacks of the renderer interface)   Provide drawing code in the onDrawFrame method of the renderer implementation.   Construct a GLSurfaceView, to which you can pass your implemented renderer.   Set the renderer implemented in steps 1 to 3 in the GLSurfaceView.   Indicate whether you want animation or not to the GLSurfaceView.   Set the GLSurfaceView in an activity as the content view. You can also use this view wherever you can use a regular view. But keep in mind that all drawing in a GLSurfaceView takes place in a dedicated secondary thread. This is in contrast to regular views, which are painted by the main thread of your application.

让我们从如何实现渲染器接口开始。

实现渲染器

清单 9-13 显示了渲染器接口的签名。

清单 9-13。渲染器接口

public static interface GLSurfaceView.Renderer

{

void onDrawFrame(GL10 gl);

void onSurfaceChanged(GL10 gl, int width, int height);

void onSurfaceCreated(GL10 gl, EGLConfig config);

}

主绘图发生在onDrawFrame()方法中。每当为这个视图创建一个新的表面时,就会调用onSurfaceCreated()方法。我们可以在这个方法中调用许多 OpenGL APIs,这些 API 被认为是设置 OpenGL 上下文和其他全局级别的 OpenGL 参数。参见清单 9-14(这里我们定义了一个抽象呈现器),了解从这个方法调用的方法类型。

类似地,当一个表面改变时,比如窗口的宽度和高度,调用onSurfaceChanged()方法。我们可以在这里设置我们的观看音量。

onDrawFrame()方法中,我们设置摄像机并绘制所需的顶点。如果我们在一个场景中绘制多个对象,或者计划使用相同的设置来绘制多个示例,那么拥有一个抽象类会有所帮助,该类在各自的方法中执行以下所有操作:

Set up global OpenGL behavior (onSurfaceCreated)   Set up view port (onSurfaceChanged)   Set up camera (onDrawFrame)

然后我们可以让onDrawFrame通过一个类似于“draw()”的重写方法将实际的绘图(指定顶点、模型转换和绘图)委托给一个派生类这就是你在清单 9-14 中看到的,我们已经给出了抽象渲染器类的源代码。

清单 9-14。抽象渲染器//文件名:AbstractRenderer.java

//...Not all imports listed: See the full file in the download

//Or you can autogenerate the rest

import javax.microedition.khronos.egl.EGLConfig;

import javax.microedition.khronos.opengles.GL10;

import android.opengl.GLU;

public abstract class AbstractRenderer

implements android.opengl.GLSurfaceView.Renderer

{

public void onSurfaceCreated(GL10 gl, EGLConfig eglConfig) {

gl.glDisable(GL10.GL_DITHER);

gl.glHint(GL10.GL_PERSPECTIVE_CORRECTION_HINT

GL10.GL_FASTEST);

gl.glClearColor(.5f, .5f, .5f, 1);

gl.glShadeModel(GL10.GL_SMOOTH);

gl.glEnable(GL10.GL_DEPTH_TEST);

}

public void onSurfaceChanged(GL10 gl, int w, int h) {

gl.glViewport(0, 0, w, h);

float ratio = (float) w / h;

//The following three methods used in succession

//sets up the projection matrix.

//Indicate we are going to target the projection matrix

gl.glMatrixMode(GL10.GL_PROJECTION);

// Set that matrix to the identity matrix

gl.glLoadIdentity();

//This multiplies the previous matrix with the projection matrix

gl.glFrustumf(-ratio, ratio, -1, 1, 3, 7);

}

public void onDrawFrame(GL10 gl)

{

gl.glDisable(GL10.GL_DITHER);

gl.glClear(GL10.GL_COLOR_BUFFER_BIT | GL10.GL_DEPTH_BUFFER_BIT);

//See how similarly we are working with and setting the model/view matrix

gl.glMatrixMode(GL10.GL_MODELVIEW);

gl.glLoadIdentity();

GLU.gluLookAt(gl, 0, 0, -5, 0f, 0f, 0f, 0f, 1.0f, 0.0f);

gl.glEnableClientState(GL10.GL_VERTEX_ARRAY);

draw(gl);

}

protected abstract void draw(GL10 gl);

}

拥有这个抽象类非常有用,因为它允许我们只关注绘图方法。我们使用这个类来创建一个SimpleTriangleRenderer类;清单 9-15 显示了源代码。

清单 9-15。简单三角形渲染器

//filename: SimpleTriangleRenderer.java

public class SimpleTriangleRenderer extends AbstractRenderer

{

//Number of points or vertices we want to use

private final static int VERTS = 3;

//A raw native buffer to hold the point coordinates

private FloatBuffer mFVertexBuffer;

//A raw native buffer to hold indices

//allowing a reuse of points.

private ShortBuffer mIndexBuffer;

public SimpleTriangleRenderer(Context context)

{

ByteBuffer vbb = ByteBuffer.allocateDirect(VERTS * 3 * 4);

vbb.order(ByteOrder.nativeOrder());

mFVertexBuffer = vbb.asFloatBuffer();

ByteBuffer ibb = ByteBuffer.allocateDirect(VERTS * 2);

ibb.order(ByteOrder.nativeOrder());

mIndexBuffer = ibb.asShortBuffer();

float[] coords = {

-0.5f, -0.5f, 0, // (x1,y1,z1)

0.5f, -0.5f, 0

0.0f,  0.5f, 0

};

for (int i = 0; i < VERTS; i++) {

for(int j = 0; j < 3; j++) {

mFVertexBuffer.put(coords[i*3+j]);

}

}

short[] myIndecesArray = {0,1,2};

for (int i=0;i<3;i++)

{

mIndexBuffer.put(myIndecesArray[i]);

}

mFVertexBuffer.position(0);

mIndexBuffer.position(0);

}

//overridden method: more accurately implemented method

protected void draw(GL10 gl)

{

gl.glColor4f(1.0f, 0, 0, 0.5f);

gl.glVertexPointer(3, GL10.GL_FLOAT, 0, mFVertexBuffer);

gl.glDrawElements(GL10.GL_TRIANGLES, VERTS

GL10.GL_UNSIGNED_SHORT, mIndexBuffer);

}

}

虽然这里看起来有很多代码,但大部分都是用来定义顶点,然后将它们从 Java 缓冲区翻译到 NIO 缓冲区。否则,draw方法只是三行:设置颜色、设置顶点和绘制。

Note

尽管我们正在为nio缓冲区分配内存,但是我们从来没有在代码中释放它们。那么谁来释放这些缓冲呢?这个内存对 OpenGL 有什么影响?

根据我们的研究,java.nio包在 Java 堆之外分配内存空间,这些空间可以被 OpenGL、文件 I/O 等系统直接使用。nio缓冲区实际上是最终指向本机缓冲区的 Java 对象。这些nio物品是垃圾收集。当它们被垃圾收集时,它们会删除本机内存。Java 程序不需要做任何特殊的事情来释放内存。

然而,gc不会被触发,除非 Java 堆中需要内存。这意味着您可能会耗尽本机内存,而gc可能不会意识到这一点。互联网上提供了许多关于这个主题的例子,其中内存不足异常将触发一个gc,然后可以查询由于gc被调用,内存现在是否可用。

在一般情况下,您可以分配本机缓冲区,而不必担心显式释放已分配的内存,因为这是由gc完成的。

现在您已经有了一个示例渲染器,让我们看看如何将这个渲染器提供给一个GLSurfaceView并让它出现在活动中。

从活动中使用 GLSurfaceView

清单 9-16 显示了一个典型的活动,它使用了一个GLSurfaceView和一个合适的渲染器。

清单 9-16。一个简单的 OpenGLTestHarness 活动

public class OpenGLTestHarnessActivity extends Activity {

private GLSurfaceView mTestHarness;

@Override

protected void onCreate(Bundle savedInstanceState) {

super.onCreate(savedInstanceState);

mTestHarness = new GLSurfaceView(this);

mTestHarness.setEGLConfigChooser(false);

mTestHarness.setRenderer(new SimpleTriangleRenderer(this));

mTestHarness.setRenderMode(GLSurfaceView.RENDERMODE_WHEN_DIRTY);

//mTestHarness.setRenderMode(GLSurfaceView.RENDERMODE_CONTINUOUSLY);

setContentView(mTestHarness);

}

@Override

protected void onResume()    {

super.onResume();

mTestHarness.onResume(); //Important

}

@Override

protected void onPause() {

super.onPause();

mTestHarness.onPause(); //Important

}

}

让我们检查一下这个源代码的关键元素。下面是实例化GLSurfaceView的代码:

mTestHarness = new GLSurfaceView(this);

然后我们告诉视图,我们不需要一个特殊的 EGL 配置选择器,默认的配置选择器将通过以下方式工作:

mTestHarness.setEGLConfigChooser(false);

然后我们设置我们的渲染器如下:

mTestHarness.setRenderer(new SimpleTriangleRenderer(this));

接下来,我们使用这两种方法之一来决定是否允许动画:

mTestHarness.setRenderMode(GLSurfaceView.RENDERMODE_WHEN_DIRTY);

//mTestHarness.setRenderMode(GLSurfaceView.RENDERMODE_CONTINUOUSLY);

如果我们选择第一行,绘图将只被调用一次,或者更准确地说,是在需要绘制的任何时候。如果我们选择第二个选项,我们的绘图代码将被重复调用,这样我们就可以动画我们的绘图。

Note

根据 Android SDK 文档,当活动暂停和恢复时,您的活动应该告诉GLSurfaceView暂停和恢复。这是为了通知总帐绘图线程暂停和恢复。无论哪种方式,每次在活动中使用GLSurfaceView时,您都必须这样做。

这就是在 Android 上与 OpenGL 接口的全部内容。

在本章的这一点上,本书以前的版本已经给出了调用该活动的驱动程序的代码。这对于理解 OpenGL 并不是一个非常有用的补充。所以我们删除了所有的代码,留给读者做练习,因为读者应该能够创建一个简单的测试程序来调用清单 9-16 中的活动。我们有可供下载的示例程序,其中包括完整的源代码。下载、编译并运行程序后,选择菜单选项“简单三角形”,你会看到如图 9-2 所示的三角形。

A978-1-4302-4951-1_9_Fig2_HTML.jpg

图 9-2。

A simple OpenGL triangle

更改相机设置

为了更好地理解 OpenGL 坐标,让我们试验一下与相机相关的方法,看看它们如何影响我们在图 9-2 中绘制的三角形。记住这些是我们三角形的点:(-0.5,-0.5,0     0.5,-0.5,0    0,0.5,0)。有了这些点,下面三个在AbstractRenderer(清单 9-14)中使用的与摄像机相关的方法产生了如图 9-2 所示的三角形:

//Look at the screen (origin) from 5 units away from the front of the screen

GLU.gluLookAt(gl, 0,0,-5,    0,0,0,   0,1,0);

//Set the height to 2 units and depth to 4 units

gl.glFrustumf(-ratio, ratio, -1, 1, 3, 7);

//normal window stuff

gl.glViewport(0, 0, w, h);

现在,假设您将相机的上方向向量更改为负 y 方向,如下所示:

GLU.gluLookAt(gl, 0,0,5,    0,0,0,   0,-1,0);

如果你这样做,你会看到一个倒置的三角形(图 9-3 )。如果你想做这个改变,你可以在AbstractRenderer.java文件中找到改变的方法(清单 9-14)。

A978-1-4302-4951-1_9_Fig3_HTML.jpg

图 9-3。

A triangle with the camera upside down

现在让我们看看如果我们改变平截头体(也称为观察体或观察框)会发生什么。以下代码将查看框的高度和宽度增加了 4 倍(参见图 9-1 了解这些尺寸)。如果您还记得,glFrustum的前四个参数指向查看框的前矩形。通过将每个值乘以 4,我们将查看框缩放了四倍,如下所示:

gl.glFrustumf(-ratio * 4, ratio * 4, -1 * 4, 1 *4, 3, 7);

有了这段代码,我们看到的三角形缩小了,因为三角形保持不变,而我们的观察框却变大了(图 9-4 )。这个方法调用出现在AbstractRenderer.java类中(见清单 9-14)。

A978-1-4302-4951-1_9_Fig4_HTML.jpg

图 9-4。

A triangle with a viewing box that is four times bigger

使用索引添加另一个三角形

我们通过继承AbstractRenderer类来总结这些简单的三角形示例,并通过添加一个额外的点和使用索引来创建另一个三角形。概念上,我们将这四个点定义为(-1,-1,    1,-1,    0,1,    1,1)。我们要求 OpenGL 把这些画成(0,1,2    0,2,3)。清单 9-17 显示了这样做的代码(注意我们改变了三角形的尺寸)。

清单 9-17。SimpleTriangleRenderer2 类

//filename: SimpleTriangleRenderer2.java

public class SimpleTriangleRenderer2 extends AbstractRenderer

{

float[] coords = {

-1.0f, -1.0f, 0, // (x1,y1,z1)

1.0f, -1.0f, 0

0.0f,  1.0f, 0

1.0f,  1.0f, 0

};

......other code modifications to match coords variable

..use listing 9-15 as an example

short[] myIndecesArray = {0,1,2,    0,2,3};

}

添加完这段代码后,我们可以再次运行程序,选择菜单选项“两个三角形”来查看绘制出来的两个三角形(见图 9-5 )。

A978-1-4302-4951-1_9_Fig5_HTML.jpg

图 9-5。

Two triangles with four points

制作简单的 OpenGL 三角形动画

我们可以通过改变GLSurfaceView对象的渲染模式来轻松适应 OpenGL 动画。清单 9-18 显示了示例代码。

清单 9-18。指定连续渲染模式

//get a GLSurfaceView

GLSurfaceView openGLView;

//Set the mode to continuous draw mode

openGLView.setRenderMode(GLSurfaceView.RENDERMODE_CONTINUOUSLY);

注意,我们在这里展示了如何改变渲染模式,因为我们在前面的部分已经指定了RENDERMODE_WHEN_DIRTY(见清单 9-16)。如前所述,RENDERMODE_CONTINUOUSLY是默认设置,所以默认启用动画。

一旦渲染模式是连续的,就由渲染器的onDraw方法来做影响动画所必需的事情。为了演示这一点,让我们使用上一个例子中画的三角形(见清单 9-15 和图 9-2 )并以圆形的方式旋转它。

动画 SimpleTriangleRenderer

除了在onDraw方法中发生的事情之外,AnimatedSimpleTriangleRenderer类与SimpleTriangleRenderer非常相似(见清单 9-15)。在这种方法中,我们每四秒钟设置一个新的旋转角度。随着图像被反复绘制,我们看到三角形在缓慢旋转。清单 9-19 包含了AnimatedSimpleTriangleRenderer类的完整实现。

清单 9-19。animated example triangle renderer 源码

//filename: AnimatedSimpleTriangleRenderer.java

public class AnimatedSimpleTriangleRenderer extends AbstractRenderer

{

.....Same code here as in the SimpleTriangleRenderer method

//overridden method

protected void draw(GL10 gl)

{

long time = SystemClock.uptimeMillis() % 4000L;

float angle = 0.090f * ((int) time);

gl.glRotatef(angle, 0, 0, 1.0f);

gl.glColor4f(1.0f, 0, 0, 0.5f);

gl.glVertexPointer(3, GL10.GL_FLOAT, 0, mFVertexBuffer);

gl.glDrawElements(GL10.GL_TRIANGLES, VERTS

GL10.GL_UNSIGNED_SHORT, mIndexBuffer);

}

}

在清单 9-19 中,方法glRotate()看似简单,并没有引起潜在的问题。然而,我们问:我们知道旋转是一种变换,因此涉及到一个矩阵,但是是哪个矩阵呢?如前所述,有三种矩阵:模型视图、投影和纹理。文档显示旋转调用更新了当前的模型视图矩阵。我们在哪里设置了当前的模型视图矩阵?它之前的值是多少?好吧,事实证明,我们有点幸运,这个方法成功了。如果你看到清单 9-14 的抽象呈现器,你会意识到我们已经通过调用glMatrixMode()将目标矩阵设置为模型视图。在相同的抽象渲染器代码中,我们对模型视图矩阵应用了视点坐标转换。这意味着旋转应用于整个场景,而不仅仅是三角形对象。

因此,其含义是:如果您的意图是多次放置对象,并在旋转场景之前首先旋转对象,那么应该在模型变换之后应用眼睛坐标变换。我们现在把它作为一个练习,来重塑你的抽象类以适应这个目标。

要查看清单 9-19 中实现的动画三角形,你可以使用本章提供的示例程序。您可以在参考资料部分看到这个程序的 zip 文件的参考资料。在示例程序中,选择菜单选项“动画三角形”来查看由清单 9-19 实现的三角形,最初显示在图 9-2 旋转中。

勇敢面对 OpenGL:形状和纹理

在本节中,我们将介绍三件事情:使用形状来简化顶点指定,使用纹理(将图像附加到表面),以及在场景中绘制多个图形。这些基础知识应该会让你更接近开始创建可行的 3D 人物和场景。尽管下面的小节也关注 ES 1.0,但是这些信息同样适用于 ES 2.0。事实上,首先在 1.0 的上下文中解释这些概念,然后指出 ES 2.0 中采取的灵活方法更容易。

使用形状

显式指定要绘制的顶点的方法可能很繁琐。例如,如果您想要绘制一个有 20 条边的多边形,那么您需要指定 20 个顶点,每个顶点最多需要三个值。总共有 60 个值。太乏味了。

作为形状的正多边形

绘制三角形或正方形等图形的更好方法是通过定义抽象多边形的某些方面(如原点和半径)来定义它,然后让该多边形为您提供顶点数组和索引数组。比方说,你把这个类叫做RegularPolygon。然后你可以使用这个类,如清单 9-20 所示,来绘制各种规则多边形。

清单 9-20。使用规则多边形对象

//A polygon with 4 sides and a radious of 0.5

//and located at (x,y,z) of (0,0,0)

RegularPolygon``square``= new``RegularPolygon

//Let the polygon return the vertices

mFVertexBuffer = square.getVertexBuffer();

//Let the polygon return the triangles

mIndexBuffer = square.getIndexBuffer();

//you will need this for glDrawElements

numOfIndices = square.getNumberOfIndices();

//set the buffers to the start

this.mFVertexBuffer.position(0);

this.mIndexBuffer.position(0);

//set the vertex pointer

gl.glVertexPointer(3, GL10.GL_FLOAT, 0, mFVertexBuffer);

//draw it with the given number of Indices

gl.glDrawElements(GL10.GL_TRIANGLES, numOfIndices

GL10.GL_UNSIGNED_SHORT, mIndexBuffer);

注意你是如何从形状square中获得必要的顶点和索引的。更有可能的是,RegularPolygon可以从定义“形状”契约的接口的基本形状中导出。清单 9-21 显示了一个形状界面的例子。

清单 9-21。形状界面

public interface Shape

{

FloatBuffer    getVertexBuffer();

ShortBuffer    getIndexBuffer();

int            getNumberofIndices();

//You could even go further with

//getTextureBuffer(), getTextureImage() etc.

}

实现规则多边形形状

如前所述,这个RegularPolygon负责返回使用 OpenGL 顶点绘制所需的内容。对于正多边形,有许多方法可以做到这一点。由于篇幅限制,我们不打算在这里展示其中一些类的完整源代码。您可以在本章的下载示例中看到本章中提到但没有包括的那些类的源代码(参见参考资料)。比代码更重要的是,这里有到达这些顶点的方法和逻辑。

在我们的方法中,我们使用边的数量和从正多边形的中心到它的一个顶点的距离来定义正多边形。我们称这个距离为半径,因为正多边形的顶点落在一个圆的周长上,这个圆的中心也是正多边形的中心。所以这样一个圆的半径和边数会告诉我们想要的多边形。通过指定中心的坐标,我们也知道在几何图形中的何处绘制多边形。

我们首先假设半径是一个单位。我们算出连接多边形中心和每个顶点的每条线的角度。我们把这些角度放在一个数组里。对于每个角度,我们计算 x 轴投影,并将其称为“x 乘数阵列”。(我们使用“乘数阵列”,因为我们从半径单位开始。)当我们知道真实半径时,我们将这些值与真实半径相乘,得到真实的 x 坐标。这些真实的 x 坐标被存储在一个名为“x 数组”的数组中我们对 y 轴投影做同样的处理。虽然这里所说的逻辑非常简单,但是代码很长而且非常详细,读起来像数学。相反,我们建议下载 zip 文件并查看代码。

制作规则多边形动画

既然我们已经展示了通过RegularPoygon绘制形状的基本思想,让我们变得复杂一点。让我们看看是否可以使用一个动画,通过使用一个多边形,我们从一个三角形开始,以一个圆形结束,这个多边形的边大约每四秒钟增加一次。要在 draw 方法中简单地做到这一点,实例化一个增加大小计数的多边形,并告诉渲染器连续绘制。这个类如清单 9-22 所示。

清单 9-22。多边租赁公司

public class PolygonRenderer extends AbstractRenderer

{

//Number of points or vertices we want to use

private final static int VERTS = 4;

//A raw native buffer to hold the point coordinates

private FloatBuffer mFVertexBuffer;

//A raw native buffer to hold indices

//allowing a reuse of points.

private ShortBuffer mIndexBuffer;

private int numOfIndices = 0;

private long prevtime = SystemClock.uptimeMillis();

private int sides = 3;

public PolygonRenderer(Context context)     {

prepareBuffers(sides);

}

//Parameterized polygon whose sides vary

//with each animation time.

private void prepareBuffers(int sides)    {

RegularPolygon t = new RegularPolygon(0,0,0,1,sides);

this.mFVertexBuffer = t.getVertexBuffer();

this.mIndexBuffer = t.getIndexBuffer();

this.numOfIndices = t.getNumberOfIndices();

this.mFVertexBuffer.position(0);

this.mIndexBuffer.position(0);

}

//overridden method

protected void draw(GL10 gl)    {

long curtime = SystemClock.uptimeMillis();

if ((curtime - prevtime) > 2000)        {

prevtime = curtime;

sides += 1;

if (sides > 20)        {

sides = 3;

}

this.prepareBuffers(sides);

}

gl.glColor4f(1.0f, 0, 0, 0.5f);

gl.glVertexPointer(3, GL10.GL_FLOAT, 0, mFVertexBuffer);

gl.glDrawElements(GL10.GL_TRIANGLES, this.numOfIndices

GL10.GL_UNSIGNED_SHORT, mIndexBuffer);

}

}

如果运行示例程序并选择菜单项“多边形”,您将看到一组边数不断增加的变换多边形。观察多边形随时间的进展是有启发性的。图 9-6 显示了循环开始时的六边形。

A978-1-4302-4951-1_9_Fig6_HTML.jpg

图 9-6。

Hexagon at the beginning of the polygon drawing cycle

图 9-7 显示了循环即将结束时的情况。

A978-1-4302-4951-1_9_Fig7_HTML.jpg

图 9-7。

A circle being drawn as a regular polygon

您可以将这种抽象形状的思想扩展到更复杂的形状,甚至扩展到一个场景图,其中包含许多通过某种类型的 XML 定义的其他对象,然后使用这些实例化的对象在 OpenGL 中呈现它们。

现在让我们转到纹理,看看如何将粘贴壁纸的想法整合到我们迄今为止绘制的表面上,如正方形和多边形。

使用纹理

纹理是 OpenGL 的另一个核心主题。OpenGL 纹理有许多细微差别。我们在这一章中讲述了纹理的基础知识,这样你就可以开始使用 OpenGL 纹理了。在 ES 1.x 和 2.0 中,纹理的基本原理是相似的,尽管它们在实现上略有不同。我们在这一节中介绍 ES 1.0 纹理,然后在 ES 2.0 一节中介绍 ES 2.0 纹理。

理解纹理

OpenGL 纹理是在 OpenGL 中粘贴到曲面上的位图。(在这一章,我们只涉及 2D 表面纹理。)例如,您可以将邮票的图像粘贴到一个正方形上,使正方形看起来像邮票。或者你可以把一块砖的位图粘贴到一个矩形上,重复砖的图像,使矩形看起来像一堵砖墙。

将纹理位图附加到 OpenGL 表面的过程类似于将一张壁纸(形状为正方形)粘贴到形状规则或不规则的对象的侧面。只要你选择一张足够大的纸来覆盖它,表面的形状并不重要。然而,要放置纸张以使图像正确对齐,您必须将 OpenGL 形状的每个顶点(每个表面)准确地标记在壁纸上,以便壁纸和对象的形状保持一致。如果 OpenGL 表面形状看起来很奇怪,并且有许多顶点,那么每个顶点也需要在你的纸上(纹理)标记出来。

另一种方式是想象你把平面的 OpenGL 对象面朝上放在地上,把壁纸放在上面,然后旋转纸张直到图像对准正确的方向。现在你在纸的每个顶点上戳一些洞。你拿走纸,看看孔在哪里,并在纸上记下它们的坐标,假设纸是校准过的。这些坐标被称为纹理坐标。

归一化纹理坐标

这里没有说明的细节是物体和纸张的尺寸。OpenGL 使用规范化的方法来解决这个问题。OpenGL 假设纸张始终是一个 1 × 1 的正方形,原点在(0,0),右上角在(1,1)。然后 OpenGL 要你缩小你的物体表面,让它适合这些 1 × 1 的边界。因此,程序员的任务是计算出 1 × 1 正方形中物体表面的顶点。当您希望纹理图像占据对象曲面的整个空间时,请确保对象顶点都位于纹理坐标的(0,0)到(1,1)范围内。但是,您可以指定特定顶点的纹理坐标在 x 方向或 y 方向大于 1。在这种情况下,您需要告诉 OpenGL 如何映射(0,0)和(1,1)之外的空间。这称为包装模式。

在一种包装模式中,您可以告诉 OpenGL 在纹理图像每次越过 0 到 1 的边界时重复纹理图像。因此,如果你说 1.4 是你的顶点的纹理坐标,那么你的纹理元素(纹理图像中相应的像素)将是从开始的第 40 个百分点。如果你的顶点在 2.4,那么你在这个顶点绘制的纹理元素将会是第 40 百分位的纹理元素(从 0 开始)。所以你已经在给定的方向上绘制了两次纹理图像,然后当你到达 2.4 的顶点时,绘制了它的 1/4。这是一个非常迂回的说法,你重复图像,直到你用尽空间。当我们在特定 API 的上下文中讨论包装模式时,会进一步解释这一点。

抽象常见纹理处理

一旦您理解了纹理坐标和顶点坐标之间的映射,并且能够计算出纹理贴图的坐标,剩下的就足够简单了。(OpenGL 中没有什么可以大胆地说成“相当简单!”)后续工作包括将纹理位图加载到内存中,并给它一个纹理 ID,以便您可以在后续 API 中引用该纹理。这个纹理 ID 有时也称为“纹理名称”然后,为了允许同时加载多个纹理,可以通过指定 ID 来设置当前纹理。

一旦纹理加载并可用,在绘制管道期间,您可以指定纹理坐标和顶点坐标。然后你画。因为加载纹理的过程相当常见,我们通过一个继承自AbstractRenderer的抽象类SingleAbstractTextureRenderer抽象了这个过程(见清单 9-14)。这意味着我们通过AbstractRenderer的基类继续使用之前的抽象,并通过SingleAbstractTextureRenderer提供额外的纹理抽象。

清单 9-23 显示了源代码,它抽象了一个纹理的所有设置代码。根据源代码,我们逐行解释了几乎所有的函数。

清单 9-23。抽象单一纹理支持

public abstract class AbstractSingleTexturedRenderer

extends AbstractRenderer

{

int mTextureID;        // var to hold ID/Name of the single texture

int mImageResourceId;  // Image that becomes the texture

Context mContext;     // In case we need the activity context

public AbstractSingleTexturedRenderer(Context ctx

int imageResourceId) {

mImageResourceId = imageResourceId;

mContext = ctx;

}

public void onSurfaceCreated(GL10 gl, EGLConfig eglConfig) {

super.onSurfaceCreated(gl, eglConfig);

gl.glEnable(GL10.GL_TEXTURE_2D);

prepareTexture(gl);

}

private void prepareTexture(GL10 gl)

{

int[] textures = new int[1];

gl.glGenTextures(1, textures, 0);

mTextureID = textures[0];

gl.glBindTexture(GL10.GL_TEXTURE_2D, mTextureID);

gl.glTexParameterf(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_MIN_FILTER

GL10.GL_NEAREST);

gl.glTexParameterf(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_MAG_FILTER

GL10.GL_LINEAR);

gl.glTexParameterf(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_WRAP_S

GL10.GL_CLAMP_TO_EDGE);

gl.glTexParameterf(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_WRAP_T

GL10.GL_CLAMP_TO_EDGE);

gl.glTexEnvf(GL10.GL_TEXTURE_ENV, GL10.GL_TEXTURE_ENV_MODE

GL10.GL_REPLACE);

InputStream is = mContext.getResources()

.openRawResource(this.mImageResourceId);

Bitmap bitmap;

try {

bitmap = BitmapFactory.decodeStream(is);

} finally {

try {

is.close();

} catch(IOException e) {

// Ignore.

}

}

GLUtils.texImage2D(GL10.GL_TEXTURE_2D, 0, bitmap, 0);

bitmap.recycle();

}

public void onDrawFrame(GL10 gl)

{

gl.glDisable(GL10.GL_DITHER);

gl.glClear(GL10.GL_COLOR_BUFFER_BIT | GL10.GL_DEPTH_BUFFER_BIT);

gl.glMatrixMode(GL10.GL_MODELVIEW);

gl.glLoadIdentity();

GLU.gluLookAt(gl, 0, 0, -5, 0f, 0f, 0f, 0f, 1.0f, 0.0f);

gl.glEnableClientState(GL10.GL_VERTEX_ARRAY);

gl.glEnableClientState(GL10.GL_TEXTURE_COORD_ARRAY);

gl.glActiveTexture(GL10.GL_TEXTURE0);

gl.glBindTexture(GL10.GL_TEXTURE_2D, mTextureID);

draw(gl);

}

}

在这段代码中,单个纹理(一个位图)在onSurfaceCreated方法中被加载和准备。与AbstractRenderer一样,onDrawFrame的代码设置绘图空间的尺寸,以便坐标有意义。根据您的情况,您可能希望更改此代码来计算出您自己的最佳观看音量。请注意构造函数如何获取纹理位图,为以后使用做准备。根据你有多少纹理,你可以相应地创建你的抽象类。

我们现在解释清单 9-23 中的关键部分和 API。

生成纹理名称:glGenTextures

OpenGL 方法glGenTextures负责为纹理生成(更像是保留)唯一的 id,以便以后可以引用这些纹理。注意清单 9-24 中的代码。

清单 9-24。生成纹理名称

int[] textures = new int[1];

gl.glGenTextures(1, textures, 0);

mTextureID = textures[0];

对于glGenTextures,第一个参数是我们想要的纹理数量,第二个参数是 API 写入返回的纹理整数 id(名称)的数组。第三个参数是 API 要写入的数组中的偏移量。

在此 ID 或名称分配期间,没有加载位图。我们需要这个 ID,因为我们需要指导 OpenGL 设置一些参数来控制这个纹理的行为,包括位图的后续加载。在某种意义上,这就像创建一个纹理对象,以便开始定义它的属性。

设置纹理目标:glBindTexture

OpenGl 可以应用多种类型的纹理。它们是:GL_TEXTURE_1D,GL_TEXTURE_2D,GL_TEXTURE_3D 或 GL_TEXTURE_CUBE_MAP。当你调用纹理 API 时,每个 API 的第一个参数是你所指的纹理类型。因此,这些被称为纹理目标。现在考虑清单 9-25 中的代码。

清单 9-25。将纹理目标绑定到纹理名称

glBindTexture(GL10.GL_TEXTURE_2D, mTextureID);

这个方法的第一个参数是纹理目标。第二个参数是纹理的名称或 ID。从代码清单 9-23 中可以知道,mTextureID是我们在调用了glGenTextures.之后通过分配它得到的名字

那么这个绑定是什么意思呢?OpenGL 令人讨厌地有状态。OpenGL 中有许多 API 操作纹理,这些 API 仅仅表示它们想要操作当前的 2D 纹理。因此,当后续 API 指定对目标GL_TEXTURE_2D的操作时,该绑定表示让该 API 影响由纹理 ID: mTextureID指示的纹理对象。换句话说,你是说你当前的 2D 纹理指的是绑定到mTextureID的纹理。如果您要生成/分配五个纹理名称,并且如果您要将名为“3”的纹理绑定到 2D 纹理,那么所有后续的 API 都会引用纹理对象“3”的 2D 纹理点这是一个相当大的循环,但你去那里。

控制纹理行为:glTexParameter

一旦你感兴趣的纹理(通过它的名字)被绑定到 2D 纹理,你可以通过设置一些纹理参数来控制它的行为。参见清单 9-26 中的代码。

清单 9-26。设置纹理行为

gl.glTexParameterf(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_MIN_FILTER

GL10.GL_NEAREST);

gl.glTexParameterf(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_MAG_FILTER

GL10.GL_LINEAR);

gl.glTexParameterf(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_WRAP_S

GL10.GL_CLAMP_TO_EDGE);

gl.glTexParameterf(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_WRAP_T

GL10.GL_CLAMP_TO_EDGE);

对于当前的 2D 纹理(基于绑定),这些方法指示纹理在应用时的行为。

过滤参数:你可能只有一个像小位图一样的图标作为你的纹理,OpenGL 对象的表面可能是你的平板电脑窗口的大小。那么单个纹理元素可以映射到多个像素,从而导致放大。这实质上是放大或放大纹理图像。我们已经指定线性外推这些值。在这种情况下,我们在这个像素周围取四个纹理元素,并得到它们的平均值。当纹理较大时,我们需要缩小纹理以适应表面。在我们的例子中,我们已经指出使用最接近像素坐标的纹理元素。

包裹参数:GL_REPEAT 是纹理图像在曲面上以任一方向(s 或 t–与 x,y 相同)重复的选项。使用 GL_REPEAT,当纹理坐标大于 1(比如 1.3)时,只需使用 0.3 作为新坐标,忽略整数值。结果是,你最终在那个方向上重画图形。您可以在两个方向上应用它,从而在多个方向上复制纹理。相比之下,当您使用选项 GL_CLAMP 进行钳制时,当纹理坐标为 1.3 时,它会将其设置为 1。本质上,你在图片的剩余部分重复坐标 1 处的最后一个纹理元素。因此 GL_CLAMP 的结果是只画一次图片,并根据图片的边缘给其余区域着色。

控制纹理混合行为:glTexEnv

上一节讨论的纹理参数根据像素和纹理坐标控制每个像素的目标颜色。所以你有一个你想搭配的目标颜色。使用API glTexEnv,您可以指示如何对已经存在的像素的原始颜色属性做出反应。例如,看一下清单 9-27 中的代码。

清单 9-27。如何融合纹理

gl.glTexEnvf(GL10.GL_TEXTURE_ENV, GL10.GL_TEXTURE_ENV_MODE

GL10.GL_REPLACE);

对于 OpenGL ES,第一个参数始终是 GL_TEXTURE_ENV。在主 OpenGL 中似乎有更多可能的值(过滤器控制,点精灵)。当目标是控制混合行为时,第二个参数被设置为环境模式。对于纹理环境模式,可能的选项在第三个参数中指定:GL_REPLACE、GL_DECAL、GL_MODULATE 和 GL_BLEND。

GL_REPLACE: Use the texel color as the pixel color

GL_DECAL: Blend the texel color with the pixel color controlled by the alpha value of the texel

GL_MODULATE: Multiply the texel color with the pixel color (especially to retain lighting effects)

GL_BLEND:无论有没有纹理,混合都是有效的。混合采用前景色,并根据您指定的函数将它们与背景色(使用深度)混合,得到的颜色将是最终的颜色。现在,在纹理的上下文中,使用纹理元素颜色作为前景色,使用像素作为背景色,并使用正在使用的混合功能混合它们。用好 GL_BLEND 是个高级话题。

将位图作为纹理加载:LUtils.texImage2D

让我们看看加载位图图像的代码,如清单 9-28 所示。

清单 9-28。加载纹理图像

InputStream is = mContext.getResources()

.openRawResource(this.mImageResourceId);

Bitmap bitmap;

try { bitmap = BitmapFactory.decodeStream(is);} finally {

try { is.close();}catch(IOException e) {// Ignore.}}

GLUtils.texImage2D(GL10.GL_TEXTURE_2D, 0, bitmap, 0);

bitmap.recycle();

首先使用BitmapFactory和一个原始位图资源流加载位图。然后,使用该位图对象作为纹理位图,用于当前的 2D 纹理(它已经绑定到您生成的纹理名称)。没错。按照位图类的协议要求调用bitmap.recycle()是很重要的。

在内部GLUtils.texImage2D调用 OpenGL ES 方法glTexImage2D。这个方法对应的参数是(1)纹理目标,(2)级别,(3)位图指针,和(4)边界。“目标”是指我们到目前为止一直使用的 2D 目标。级别与另一个称为 mip/mapping 的纹理概念有关。当需要缩小或放大时,Mip/mapping 允许多个图像用于纹理。在我们的例子中,我们只有一个级别图像,表示为基准级别 0。位图指针参数是指存储在客户端内存中的位图图像。border 指定了边框的宽度,但是对于 es 文档来说,这个值应该是 0。

设置纹理单位:glActiveTexture

当一个表面被纹理化时,OpenGL 允许多个纹理单元生效。到目前为止,我们使用的所有 API 都在活动纹理单元上工作。默认情况下,纹理单元是基本结构单元。当我们调用glEnable (GL_TEXTURE_2D)时,我们启用了以纹理 2D 为目标的基本纹理单元(该 API 及其对应部分glDisable作用于当前活动的纹理单元)。当启用多个纹理单元时,像素的最终颜色是所有纹理的组合。

让我们检查一下我们用来设置活动纹理的代码,如清单 9-29 所示。

清单 9-29。激活纹理单元

gl.glActiveTexture(GL10.GL_TEXTURE0);

gl.glBindTexture(GL10.GL_TEXTURE_2D, mTextureID);

基础纹理始终是 GL_TEXTURE0。正如你所看到的,纹理有很多绑定。你可以找出硬件支持多少纹理单元。这个关联的层次是(a)纹理单元,(b)纹理目标(1D 或 2D 等。)、(c)纹理名称和(d)实际纹理位图。难怪纹理是令人生畏的。(你可以在 www.khronos.org/opengles/documentation/opengles1_0/html/index.html 的 OpenGL ES 参考中找到这些 API 中的大部分。)

使用纹理绘图

一旦位图被加载并设置为纹理,你所需要的就是一组顶点坐标和一组纹理坐标来绘制纹理图形。我们再次用形状来证明这个观点。我们使用一个能够生成顶点和纹理坐标的RegularPolygon来绘制一个正多边形和纹理。清单 9-30 显示了绘制纹理正方形的类。

清单 9-30.TexturedSquareRenderer

public class TexturedSquareRenderer extends AbstractSingleTexturedRenderer

{

//Number of points or vertices we want to use

private final static int VERTS = 4;

//A raw native buffer to hold the point coordinates

private FloatBuffer mFVertexBuffer;

//A raw native buffer to hold the point coordinates

private FloatBuffer mFTextureBuffer;

//A raw native buffer to hold indices

//allowing a reuse of points.

private ShortBuffer mIndexBuffer;

private int numOfIndices = 0;

private int sides = 4;

public TexturedSquareRenderer(Context context)

{

super(context,com.androidbook.OpenGL.R.drawable.robot);

prepareBuffers(sides);

}

private void prepareBuffers(int sides)

{

RegularPolygon t = new RegularPolygon(0,0,0,0.5f,sides);

this.mFVertexBuffer = t.getVertexBuffer();

this.mFTextureBuffer = t.getTextureBuffer();

//These textured coordinates for a square will be:

//0,0,    0,1,    1,1,    0,0

this.mIndexBuffer = t.getIndexBuffer();

this.numOfIndices = t.getNumberOfIndices();

this.mFVertexBuffer.position(0);

this.mIndexBuffer.position(0);

this.mFTextureBuffer.position(0);

}

//overriden method

protected void draw(GL10 gl)

{

prepareBuffers(sides);

gl.glEnable(GL10.GL_TEXTURE_2D);

gl.glVertexPointer(3, GL10.GL_FLOAT, 0, mFVertexBuffer);

gl.glTexCoordPointer(2, GL10.GL_FLOAT, 0, mFTextureBuffer);

gl.glDrawElements(GL10.GL_TRIANGLES, this.numOfIndices

GL10.GL_UNSIGNED_SHORT, mIndexBuffer);

}

}

在清单 9-30 中,当RegularPolygon计算纹理映射顶点时,纹理的大部分繁重工作由抽象纹理渲染器类完成。清单 9-30 中唯一一个新的纹理相关的方法是:

gl.glTexCoordPointer(2, GL10.GL_FLOAT, 0, mFTextureBuffer);

该方法仅仅指向包含在缓冲区mTextureBuffer.中的一组浮点纹理坐标值。第一个参数值“2”指的是每个纹理坐标的浮点数量。没有RegularPolygon的代码,你可能想知道正方形的纹理坐标是什么。

当我们讨论 ES 2.0 纹理时,我们将实际纹理一个立方体,你将能够清楚地看到顶点坐标和相应的纹理坐标。然而,并没有失去一切。即使在这里,我们也应该能够清楚地指出矩形的纹理坐标数组是什么。

我们知道矩形有四个点。我们用这些指数从构成正方形的四个顶点中画出了两个三角形。每个顶点都有一个等价的纹理点。所以我们知道会有四个纹理点。因为纹理坐标总是落在(0,0)到(1,1)的范围内(假设我们不想重复图像),四个顶点坐标对应的纹理坐标是:

(0,0)//左下方

(0,1)//左上

(1,1)//右上

(1,0)//右下方

不过,有一个警告!在位图中,y 坐标向下增长,在 OpenGL 中,y 向上为正。因此,您可能希望反转 y 坐标,以正确的方向查看图片。现在这些纹理坐标保持不变,即使你的顶点坐标形成一个 10 x 10 的正方形。唯一希望纹理坐标落在(1,1)之外的情况是,如果您想要重复 pictcure(例如,拿一块砖,在一面大墙上重复它)。

现在,如果我们运行示例程序并选择菜单项“纹理正方形”,我们将看到纹理正方形被绘制成如图 9-8 所示。

A978-1-4302-4951-1_9_Fig8_HTML.jpg

图 9-8。

A textured square

绘制多个图形

到目前为止,本章中的每个例子都涉及到按照标准模式绘制一个简单的图形。该模式是设置顶点,加载纹理,设置纹理坐标,并绘制单个图形。如果你想画两个图形会发生什么?如果你想用传统的指定顶点的方法画一个三角形,然后用像RegularPolygon这样的形状画一个多边形,该怎么办?如何指定组合顶点?是否必须为两个对象指定一次顶点,然后调用 draw 方法?

事实证明,在 Android OpenGL 渲染器接口的两次draw()调用之间,OpenGL 允许你发出多个glDraw方法。在这些多种glDraw方法之间,你可以设置新的顶点和纹理。一旦draw()方法完成,所有这些绘图方法就会出现在屏幕上。

还有另一个技巧,你可以用它来用 OpenGL 绘制多个图形。考虑到目前为止我们已经创建的多边形。这些多边形能够通过将原点作为输入,在任意原点进行自我渲染。事实证明,OpenGL 可以自然地做到这一点,它允许您指定一个总是在(0,0,0)的RegularPolygon,并让 OpenGL 的“translate”机制将它从原点移动到所需的位置。您可以对另一个多边形进行同样的操作,并将其平移到不同的位置,从而在屏幕上的两个不同位置绘制两个多边形。

清单 9-31 通过多次绘制纹理多边形展示了这些想法。

清单 9-31。纹理多边形渲染器

public class TexturedPolygonRenderer extends AbstractSingleTexturedRenderer

{

//Number of points or vertices we want to use

private final static int VERTS = 4;

//A raw native buffer to hold the point coordinates

private FloatBuffer mFVertexBuffer;

//A raw native buffer to hold the point coordinates

private FloatBuffer mFTextureBuffer;

//A raw native buffer to hold indices

//allowing a reuse of points.

private ShortBuffer mIndexBuffer;

private int numOfIndices = 0;

private long prevtime = SystemClock.uptimeMillis();

private int sides = 3;

public TexturedPolygonRenderer(Context context)

{

super(context,com.androidbook.OpenGL.R.drawable.robot);

prepareBuffers(sides);

}

private void prepareBuffers(int sides)

{

RegularPolygon t = new RegularPolygon(0,0,0,0.5f,sides);

this.mFVertexBuffer = t.getVertexBuffer();

this.mFTextureBuffer = t.getTextureBuffer();

this.mIndexBuffer = t.getIndexBuffer();

this.numOfIndices = t.getNumberOfIndices();

this.mFVertexBuffer.position(0);

this.mIndexBuffer.position(0);

this.mFTextureBuffer.position(0);

}

//overridden method

protected void draw(GL10 gl)

{

long curtime = SystemClock.uptimeMillis();

if ((curtime - prevtime) > 2000)

{

prevtime = curtime;

sides += 1;

if (sides > 20)

{

sides = 3;

}

this.prepareBuffers(sides);

}

gl.glEnable(GL10.GL_TEXTURE_2D);

//Draw once to the left

gl.glVertexPointer(3, GL10.GL_FLOAT, 0, mFVertexBuffer);

gl.glTexCoordPointer(2, GL10.GL_FLOAT, 0, mFTextureBuffer);

gl.glPushMatrix();

gl.glScalef(0.5f, 0.5f, 1.0f);

gl.glTranslatef(0.5f,0, 0);

gl.glDrawElements(GL10.GL_TRIANGLES, this.numOfIndices

GL10.GL_UNSIGNED_SHORT, mIndexBuffer);

gl.glPopMatrix();

//Draw again to the right

gl.glPushMatrix();

gl.glScalef(0.5f, 0.5f, 1.0f);

gl.glTranslatef(-0.5f,0, 0);

gl.glDrawElements(GL10.GL_TRIANGLES, this.numOfIndices

GL10.GL_UNSIGNED_SHORT, mIndexBuffer);

gl.glPopMatrix();

}

}

此示例演示了以下概念:

  • 使用形状绘图。
  • 使用变换矩阵绘制多个形状。
  • 提供纹理。
  • 提供动画。

清单 9-31 中负责多次绘制的主要代码在方法draw()中。我们在该方法中突出显示了相应的行。注意,在一个draw()调用中,我们调用了glDrawElements两次。每一次我们都独立于其他时间设置绘图图元。

需要澄清的另一点是转换矩阵的使用。每次调用glDrawElements()都会用到一个特定的变换矩阵。如果要更改此选项以改变图形的位置(或图形的任何其他方面),则需要将其设置回原始位置,以便下一个图形可以正确绘制。这是通过 OpenGL 矩阵上提供的推-放操作来完成的。push 和 pop 是必需的,因为矩阵操作是累积的,所以当你想重新开始时,你想回到矩阵的第一个状态。因此,“推送”操作允许您在给定点保存/记忆矩阵,并允许您稍后通过“弹出”返回到该点

如果我们运行示例程序并选择菜单项“Multiple Figures”,我们将看到在动画开始时绘制了两组变化的多边形(如图 9-9 所示)。(注意,我们已经将渲染模式设置为连续。)

A978-1-4302-4951-1_9_Fig9_HTML.jpg

图 9-9。

A pair of textured polygons

图 9-10 显示了动画中间的相同练习。

A978-1-4302-4951-1_9_Fig10_HTML.jpg

图 9-10。

A pair of textured circles

这总结了 OpenGL 中的另一个重要概念。总之,本节向您展示了如何累积大量不同的图形或场景,并一前一后地绘制它们,以便最终结果形成一个相当复杂的 OpenGL 场景。

接下来,我们考虑 Android 对 OpenGL ES 2.0 的支持。

OpenGL 是 2.0

现在市场上的大多数设备都有支持 OpenGL ES 2.0 的 GPU。Android 从 API level 8 开始增加了对 OpenGL ES 2.0 的支持。然而,你需要一个真实的设备来测试 ES 2.0 程序。尽管在互联网上有一些参考资料表明 Google 内部的一些人已经演示了如何在模拟器上运行 ES 2.0 程序,但是我们找不到任何信息来告诉你如何去做。

OpenGL ES 2.0 和 OpenGL ES 1.x 是不同的动物,它不是向后兼容的。对于初学者来说更复杂的是,它在初始化和学习如何画最简单的图画方面有很大的不同。如果你擅长 OpenGL,这种转变并不难。不要害怕。到目前为止,我们已经介绍了足够多的 OpenGL 基础知识,我们打算在本章结束时让你对 ES 2.0 非常熟悉。

对于可编程 GPU,OpenGL ES 2.0 引入了一种称为 GLSL(着色语言)的语言,该语言在 GPU 上执行,以绘制由顶点指示的几何图形,并用颜色填充它们的表面片段。这些程序被称为着色器。控制顶点的着色器称为顶点着色器。控制片段(像素)着色的程序称为片段着色器。

专家和 OpenGL 文献主要将两个版本的 OpenGL ES 区分如下:ES 1.0 是固定功能的 OpenGL 管道,而 2.0 是可编程管道。另一种说法是,ES 1.0 的行为是硬连线的,而 ES 2.0 允许在管道中进行编程。我们想通过检查 ES 2.0 管道来进一步澄清这一区别,如清单 9-32 所示。

清单 9-32。ES 2.0 管道

Client Programming stage

Setup OpenGL Environment

Declare/Define vertices, textures etc in client memory

Setup variables such as frustum, window etc that affect the graphics pipeline

Vertex Processing stage – Vertex Shader Program

Apply Model transformations: scaling, positioning, rotation etc

Apply View transformations: fixing the eye coordinates

Apply Projection transformations: viewing volume or frustum

Texture coordinates calculated/transformed

Lighting impact on colors

Also called T&L (Transformation and lighting stage)

Graphics Primitive assembly

Gather enough vertices for a primitive: lines or triangles etc

Pass them to the next stage where the following happens

Figure out front/back faces

Cull if needed

(done not by a shader but like a fixed functionality pipeline)

Rasterization

Convert geometric primitives to fragments (or pixels)

Apply Anti-aliasing

Fragment processing – Fragment Shader Program

Interpolate fragment attributes: color, texture etc

Texture mapping

Apply textures programmatically if needed

Fog

Color sum

Per fragment operations

Fragment testing: ownership, depth, etc

Blending

Dithering

Framebuffer transfer

Pixels sent to the frame buffer for display

ES 2.0 中的两个可编程阶段是顶点处理阶段和片段处理阶段。清单 9-32 中的第一个阶段,客户端编程阶段,并不是真正的 OpenGL 图形管道阶段。我们把它放在这里是为了告诉你在第一个 OpenGL 阶段,顶点处理阶段之前会发生什么。

当您绘制图形时,除了两个可编程的阶段,其余的工作自动发生(基于客户端程序中指定的内容),就像在固定的管道中一样。相比之下,在 ES 1.0 中,所有阶段都是固定的,并由客户端编程阶段严格控制。

在本章的其余部分,你将看到着色器程序是什么样子的,这样你就对什么是可编程的有了一个概念!虽然清单 9-32 中的阶段是不言自明的,但是请参考本章开头的 OpenGL 关键概念部分来回顾每个阶段下面列出的操作的含义。

因为我们已经涵盖了 OpenGL 的大部分介绍性理论,所以我们主要通过两个工作示例来探索 ES 2.0。我们将绘制一个立方体,对其进行纹理处理,然后通过一个按钮控制其旋转。

OpenGL ES 2.0 的 Java 绑定

Android 上 ES 2.0 API 的 Java 绑定在包android.opengl.GLES20中提供。这个类的所有函数都是静态的,并且对应于 Khronos 规范中相应的“C”API。(Khronos 的 URL 可以在参考资料中找到。)GLSurfaceView以及这里为 OpenGL ES 1.0 介绍的相应渲染器抽象同样适用于 OpenGL ES 2.0。

首先,让我们看看如何使用清单 9-33 中的代码来判断设备或仿真器是否支持这个版本的 OpenGL ES 2.0。

清单 9-33。检测 OpenGL ES 2.0 的可用性

private boolean detectOpenGLES20() {

ActivityManager am =

(ActivityManager) getSystemService(Context.ACTIVITY_SERVICE);

ConfigurationInfo info = am.getDeviceConfigurationInfo();

return (info.reqGlEsVersion >= 0x20000);

}

一旦你有了这个函数(detectOpenGLES20),你就可以在你的活动中开始使用 GLSurfaceView,如清单 9-34 所示。

清单 9-34。将 GLSurfaceView 用于 OpenGL ES 2.0

if (detectOpenGLES20())

{

GLSurfaceView glview = new GLSurfaceView(this);

glview.setEGLContextClientVersion(2);

glview.setRenderer(new YourGLES20Renderer(this));

glview.setRenderMode(GLSurfaceView.RENDERMODE_WHEN_DIRTY);

setContentView(glview);

}

请注意GLSurfaceView是如何通过将客户端版本设置为 2 来配置使用 OpenGL ES 2.0 的。然后,类YourGLESRenderer将类似于本章前面介绍的renderer类。然而,在 renderer 类的主体中,您将使用GLES20API,而不是GL10API。

为了能够在你的活动中使用 OpenGL ES 2.0 的特性,比如清单 9-34 中的特性,你需要包含下面的<uses-feature>作为应用节点的子节点(见清单 9-35)。

清单 9-35。使用 OpenGL ES 2.0 功能

<application...>

......other nodes

<uses-feature android:glEsVersion="0x00020000" />

</application>

渲染步骤

在 OpenGL ES 2.0 中渲染图形需要以下步骤:

Write shader programs that run on the GPU to extract such things as drawing coordinates and model/view/projection matrices from the client memory, and impact the final positions for vertices and pixel colors. (See the pipeline where this happens.) There is no counterpart to this in OpenGL ES 1.0, as this functionality behaves in a fixed manner.   Compile the source code of shaders from step 1on the GPU.   Link the compiled units in step 2 into a program object that can be used at drawing time.   Retrieve address handlers from the program in step 3 so that data can be set into those pointers.   Define your vertex buffers.   Define your model view matrices (done through such things as setting the frustum, camera position, etc.; it’s very similar to how it’s done in OpenGL ES 1.1).   Pass the items from step 5 and 6 to the program through the handlers obtained in step 4.   Finally, draw. This will kick off the pipeline. Vertex shaders and fragment shaders are called. Note that the vertex shader program is called once for each vertex in your client memory. The fragment shader is called once for every pixel (or fragment). The outputs of the vertex shader become one of the inputs to the fragment shader.

我们通过代码片段检查每个步骤,然后呈现一个与作为 OpenGL ES 1.0 的一部分呈现的SimpleTriangleRenderer并行的工作渲染器。让我们先从了解着色器开始。

了解着色器

我们已经简要介绍了(在本章开始的关键概念部分)GLSL,它的版本,以及顶点和片段着色器的性质。顶点着色器的输入主要是原始顶点属性的数组和必要的变换矩阵。顶点着色器的输出是经过变换或任何其他您认为重要的计算后每个顶点的最终位置。对每个顶点调用一次顶点着色器程序。

顶点着色器的输出被馈送到片段着色器以给像素着色。例如,对于三角形的三个顶点,顶点着色器只被调用三次。然而,三角形的表面可能有数百个碎片(像素)。片段着色器被多次调用,每个片段调用一次。传递给片段的值(如颜色值)是插值的,以便对该表面内片段的特定(x,y)位置有意义。对于顶点显示不同颜色的三角形曲面,为给定像素传递的颜色是插值颜色,它是基于像素与每个顶点的相对距离的三种不同顶点颜色的混合。

当您看到每个着色器程序的示例时,这些概念会变得更加清晰。让我们从顶点着色器开始。

顶点明暗器

清单 9-36 是一个顶点着色器程序段的例子。

清单 9-36。一个简单的顶点着色器

uniform mat4 uMVPMatrix;

attribute vec4 aPosition;

void main() {

gl_Position = uMVPMatrix * aPosition;

}

这个程序是用着色语言 GLSL 编写的。第一行表示变量uMVPMatrix是程序的输入变量,它的类型是mat4(一个 4 x 4 的矩阵)。它也被称为统一变量,因为这个矩阵变量适用于所有顶点,而不是任何特定的顶点。这样的变量称为统一变量。

与统一变量相反,变量aPosition被声明为顶点属性,处理顶点的位置(坐标)。它被识别为顶点的属性,并且是顶点特有的。顶点的其他属性包括颜色、纹理等。这个aPosition变量也是一个四点向量。

事实上,你可以为一个顶点发明你自己的属性。然后,您的顶点着色器程序可以以一种有意义的方式访问该属性,以改变顶点的最终位置,或者根据您发明的特殊属性向下游传递某些内容(如颜色)。由于这种通用的功能,在 ES 2.0 中,您不再需要单独的 API 来传递顶点位置、顶点颜色和其他顶点级别的属性。您只有一个 API,它将属性作为一个变量,来传递所有与顶点相关的数据!

清单 9-36 中的程序获取顶点的坐标位置,并使用模型视图投影(MVP)矩阵(必须由调用程序设置)进行转换。程序通过将顶点的坐标位置与 MVP 矩阵相乘来实现这一点。结果是由顶点着色器的保留变量gl_Position标识的最终顶点位置。

一旦以这种方式确定了所有顶点的位置(顶点着色器被调用多次,对顶点缓冲区中的每个顶点调用一次),片段就由固定流水线来计算,并且对每个片段(或像素)多次调用片段着色器。

虽然gl_position是一个保留变量,它是片段着色器的输入,但是您可以显式地指定可以传递给片段着色器的任意数量的变量。稍后,当我们传递颜色值和纹理坐标时,我们将看到一个这样的例子。还有一些为顶点着色器保留的变量,可供高级用户使用;参考 GLSL 规范或一本 GLSL 书籍来了解这些变量的更多信息。

总之,顶点着色器程序负责定位顶点。您可能渴望看到客户端程序如何使用属性变量aPosition来传递顶点数据!清单 9-37 显示了如何操作。

清单 9-37 .从客户端设置/传输顶点数据到顶点着色器

GLES20.glVertexAttribPointer(positionHandle, 3, GLES20.GL_FLOAT, false

TRIANGLE_VERTICES_DATA_STRIDE_BYTES, mFVertexBuffer);

顶点缓冲区是这个 GLES 20 方法的最后一个参数。这看起来非常像 OpenGL 1.0 中的glVertexPointer,除了第一个参数,它被标识为positionHandle。这个参数指向清单 9-36 中顶点着色器程序的aPosition输入属性变量。您可以使用类似于下面清单 9-38 中的代码来获得这个句柄:

清单 9-38。绑定到属性变量

positionHandle = GLES20.glGetAttribLocation(shaderProgram, "aPosition");

本质上,在清单 9-38 中,你要求着色器程序给一个名为“位置”的输入变量一个句柄然后,这个句柄可以在后续调用中使用(清单 9-53 draw 方法),将数据从客户空间传输到着色器程序空间。当我说传输数据时,我只是指出这是一种绑定,允许在调用顶点着色器时一次将一个顶点传递给顶点着色器。

现在在清单 9-38 中,变量shaderProgram需要通过将着色器源代码传递给 GPU 进行编译和链接来构建。在我们解释这是如何发生的之前,让我们先来看一个样本片段着色器。

片段着色器

清单 9-39 是一个片段着色器的例子。

清单 9-39。片段着色器的示例

void main() {

gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);

}

在这个程序中,gl_FragColor是一个保留变量,定义一个像素或片段的输出颜色。我们将这个保留变量gl_FragColor硬编码为红色,以保持简单。然而,我们可以将这些颜色值从用户程序通过顶点着色器一直传递到片段着色器,而不是像清单 9-39 那样将其硬编码为红色。与定义顶点位置属性的方式类似,您可以在顶点着色器中定义顶点颜色的属性。然后,您可以传递每个顶点的颜色数组,以顶点着色器中的颜色属性为目标。然后,顶点着色器需要声明一个“varying”类型的输出变量,以便它可以在片段着色器中使用。清单 9-40 中有一个这样的顶点着色器的例子。

清单 9-40。带有颜色输入的顶点着色器

uniform mat4 uMVPMatrix;

attribute vec4 aPosition;

attribute vec4 aColor;

varying vec4 aFragmentColor;

void main() {

gl_Position = uMVPMatrix * aPosition;

aFragmentColor = aColor;

}

类型“变化”意味着通过基于调用片段着色器的片段的(x,y)坐标进行插值,在变量流向片段着色器时改变该变量的值。下面是清单 9-41 中的片段着色器代码,它可以使用这个颜色输入变量。

清单 9-41。具有颜色输入的片段着色器

varying vec4 aFragmentColor;

void main() {

gl_FragColor = aFragmentColor;

}

注意可变变量aFragmentColor是如何在顶点着色器和片段着色器之间传递的。为此,名称必须匹配;否则,当您尝试编译着色器程序时,将会看到一个编译器错误。此外,请记住,片段着色器是为每个预计会受到影响的片段或像素调用的。

这些着色器程序是在 OpenGL ES 2.0 中开始绘制所必需的。现在,您已经看到了一个简单的顶点着色器和一个简单的片段着色器的源代码,让我们看看这个源代码是如何被 GPU 编译并链接到程序中的。

将着色器编译到程序中

一旦你有了着色器程序段,如清单 9-40 和 9-41 所示,你可以使用清单 9-42 中的代码来编译和加载着色器程序。

清单 9-42。编译和加载着色器程序

//Call this function to load and compile a shader

private int loadShader(int shaderType, String source) {

int shader = GLES20.glCreateShader(shaderType);

if (shader == 0) {

//throw an exception or return 0

return 0;

}

//associate shader source with the shader id

GLES20.glShaderSource(shader, source);

//Compile it

GLES20.glCompileShader(shader);

//Check compiled status

int[] compiled = new int[1];

GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compiled, 0);

if (compiled[0] == 0) {

//if it is an error throw an exception or return 0

return 0;

}

return shader;

}

在这段代码中,shadertypeGLES20.GL_VERTEX_SHADERGLES20.GL_FRAGMENT_SHADER中的一个。变量source需要指向包含源代码的字符串,如清单 9-40 和 9-41 所示。我们将很快介绍错误检查功能glGetShaderiv

清单 9-43 显示了函数loadShader(来自清单 9-42)是如何被用于构建程序对象的。

清单 9-43。创建程序并获得变量句柄

private int createProgram(String vertexSource, String fragmentSource) {

int vertexShader = loadShader(GLES20.GL_VERTEX_SHADER, vertexSource);

if (vertexShader == 0) {

return 0; //or throw an exception

}

Log.d(TAG,"vertex shader created");

int pixelShader = loadShader(GLES20.GL_FRAGMENT_SHADER, fragmentSource);

if (pixelShader == 0) {

return 0; //or throw an exception

}

Log.d(TAG,"fragment shader created");

int program = GLES20.glCreateProgram();

if (program == 0) { /* throw an exception or */ return 0;}

GLES20.glAttachShader(program, vertexShader);

checkGlError("glAttachShader");

GLES20.glAttachShader(program, pixelShader);

checkGlError("glAttachShader");

GLES20.glLinkProgram(program);

int[] linkStatus = new int[1];

GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, linkStatus, 0);

if (linkStatus[0] == GLES20.GL_TRUE) {

return program;

}

//Report error

Log.e(TAG, "Could not link program: ");

Log.e(TAG, GLES20.glGetProgramInfoLog(program));

GLES20.glDeleteProgram(program);

//throw an exception or

return 0

}

关键步骤相当清楚。我们加载顶点和着色器,然后创建一个程序对象,将着色器附加到程序对象并链接程序对象。如果有任何错误,就会报告这些错误。

我们有几个函数来检查状态和报告错误。其中之一是checkGLError(),一个你可以在清单 9-43 中看到的实用函数。这个函数是如何实现的,如清单 9-44 所示。

清单 9-44。如何检查一般的 OpenGL 错误

protected void checkGlError(String op) {

int error;

while ((error = GLES20.glGetError()) != GLES20.GL_NO_ERROR) {

Log.e(TAG, op + ": glError " + error);

throw new RuntimeException(op + ": glError " + error);

}

}

注意这个实用函数如何在循环中使用GLES20.glGetError(),直到不再有错误需要检索。根据 OpenGL 文档,调用一个glGetError会将错误标志重置为 GL_NO_ERROR。但是,SDK 建议可以有多个错误标志,每次调用该方法返回并重置时,都会选择其中一个标志。因此,SDK 的建议是调用此方法,直到用完所有标志。

除了这个函数,我们还使用了如清单 9-45 所示的调试方法。

清单 9-45。有助于编译和链接着色器的调试方法

glGetShaderiv

glGetShaderInfoLog

glGetProgramiv

glGetProgramInfoLog

glGetShaderivglGetShaderInfoLog用于查看着色器源代码的编译操作是否成功。参见清单 9-43,了解这两种方法是如何使用的。比如我们先用glGetShaderiv看看 GL_COMPILE_STATUS 是不是 GL_TRUE。如果没有,我们使用glShaderInfoLog来检索导致 commpile 状态变为 false 的确切错误消息。你也可以使用glGetShaderiv来获得一个着色器的信息,如清单 9-46 所示。

清单 9-46。关于着色器源的可用信息

GL_SHADER_TYPE

GL_COMPILE_STATUS

GL_DELETE_STATUS

GL_INFO_LOG_LENGTH

GL_SHADER_SOURCE_LENGTH

glGetProgamivglGetProgramInfoLog用于查看着色器程序上的链接操作是否成功;关于如何使用这些方法,见清单 9-43。我们首先使用glGetProgramiv来看看 GL_LINK_STATUS 是否为 GL_TRUE。如果没有,我们使用glProgramInfoLog来检索导致链接状态变为假的确切错误消息。你也可以使用glGetProgramiv来获得如清单 9-47 所示的关于着色器程序的信息。

清单 9-47。关于链接着色器程序的可用信息

GL_DELETE_STATUS

GL_LINK_STATUS

GL_VALIDATE_STATUS

GL_INFO_LOG_LENGTH

GL_ATTACHED_SHADERS

GL_ACTIVE_ATTRIBUTES

GL_ACTIVE_ATTRIBUTE_MAX_LENGTH

GL_ACTIVE_UNIFORMS

GL_ACTIVE_UNIFORM_MAX_LENGTH

访问着色器程序变量

一旦程序被设置(编译和链接),程序的句柄(整数标识符)可用于获取着色器中定义的输入变量的句柄。清单 9-48 显示了如何操作。

清单 9-48。获取顶点和统一句柄

int maPositionHandle =

GLES20.glGetAttribLocation(mProgram, "aPosition");

int muMVPMatrixHandle =

GLES20.glGetUniformLocation(mProgram, "uMVPMatrix");

一个简单的 ES 2.0 三角形

现在,我们已经介绍了构建一个类似于我们为 OpenGL 1.0 创建的框架所需的基础知识。我们现在将抽象渲染器放在一起,它将封装 ES 2.0 初始化工作(如创建着色器、程序等)。).这种抽象将允许我们专注于定义图形并在派生类中绘制它们。清单 9-49 显示了 ES20 抽象渲染器的代码。

清单 9-49。es20 抽象渲染器

//filename: ES20AbstractRenderer.java

/*

* Responsibilities

* *****************

* 1\. Load vertex and shader programs

* 2\. Provide model transformations

* 3\. provide default vertex/shader programs

* 4\. Provide default frustum and camera settings

*

* Abstract features

* ******************

* 1\. allow derived shader programs

* 2\. allow derived draw

* 3\. allow derived figures

* 4\. allow derived frustum/viewing volume settings

* 5\. Act as a base class, if needed, for abstracting textures

*/

public abstract class ES20AbstractRenderer implements Renderer

{

public static String TAG = "ES20AbstractRenderer";

//Class level or global variables are usually

//unhealthy. Try to minimize them!!

//The target matrix that holds the end result

//of all model transformations

private float[] mCurrentModelMatrix = new float[16];

//A matrix that is a result of setting the camera/eye

private float[] mVMatrix = new float[16];

//A matrix that is the result of setting the frustum

private float[] mProjMatrix = new float[16];

//A matrix that is a multiple of current model, view

//and projection matrices.

private float[] mMVPMatrix = new float[16];

//GLSL program object with both the shaders compiled

//linked, and attached.

private int mProgram;

//A handle for the uniform variable identifying the MVP matrix

private int muMVPMatrixHandle;

//An attribute handle in the vertex shader

//for passing the vertex arrays.

private int maPositionHandle;

//Name of the default vertex shader

//source code file in the asset directory.

private static final String DEF_VERTEX_SHADER_FILENAME

= "def_vertex_shader.txt";

//Name of the default fragment shader

//source code file in the asset directory.

private static final String DEF_FRAGMENT_SHADER_FILENAME

= "def_fragment_shader.txt";

/*

* This class relies on virtual methods to specialize.

* Doesn't use construction arguments for specialization.

*/

public ES20AbstractRenderer() {

initializeMatrices();

}

public void initializeMatrices()

{

//Set the model matrix to identity

//Subsequent scaling, rotation, etc will update this

//in a stateful manner. So starting state matters.

Matrix.setIdentityM(this.mCurrentModelMatrix, 0);

//Although we use this matrix only once

//it is good to start with a known state.

Matrix.setIdentityM(mMVPMatrix, 0);

}

//@Override the interface method of Renderer

//JDK 1.5 doesn't allow this override tag on ab

public void onSurfaceCreated(GL10 gl, EGLConfig eglConfig) {

prepareSurface(gl,eglConfig);

}

/**

* 1\. Create the GLSL program object by passing vertex

* and shader code. Derived classes can supply their own shader programs.

* 2\. Get vertex position hanndle

* 3\. get the uniform mvp matrix handle

*/

public void prepareSurface(GL10 gl, EGLConfig eglConfig)

{

Log.d(TAG,"preparing surface");

mProgram = createProgram(

this.getVertexShaderCodeString()

this.getFragmentShaderCodeString());

if (mProgram == 0) {

return;

}

Log.d(TAG,"Getting position handle:aPosition");

maPositionHandle = getAttributeHandle("aPosition", "Getting Position Handle");

Log.d(TAG,"Getting matrix handle:uMVPMatrix");

muMVPMatrixHandle = getUniformHandle("uMVPMatrix"

"Getting MVP uniform matrix handle");

}

//Override this method to specify your

//your own frustum or the dimensions of a viewing volume.

protected FrustumDimensions getFrustumDimensions()    {

//Get default dimensions in this base class

return FrustumDimensions.getDefault();

}

//@Override the interface method of Renderer

//JDK 1.5 doesn't allow this override tag on absolute methods

//Based on width and height of the window set the

//viewport and the frustum.

public void onSurfaceChanged(GL10 gl, int w, int h)

{

Log.d(TAG,"surface changed. Setting matrix frustum: projection matrix");

GLES20.glViewport(0, 0, w, h);

float ratio = (float) w / h;

FrustumDimensions fd = this.getFrustumDimensions();

Matrix.frustumM(mProjMatrix, 0, ratio * fd.bottom, ratio * fd.top

fd.bottom, fd.top, fd.near, fd.far);

}

//@Override the interface method of Renderer

//JDK 1.5 doesn't allow this override tag on absolute methods

//1\. Set your camera. You can place this method while creating

//the surface or changing the surface. Or you can choose to

//vary it during the draw method.

//2\. Do basic drawing methods like clear the pallet

//3\. Use the shader program that is setup before

public void onDrawFrame(GL10 gl)

{

Log.d(TAG,"set look at matrix: view matrix");

Matrix.setLookAtM(mVMatrix, 0, 0, 0, -5, 0f, 0f, 0f, 0f, 1.0f, 0.0f);

GLES20.glClearColor(0.0f, 0.0f, 1.0f, 1.0f);

GLES20.glClear( GLES20.GL_DEPTH_BUFFER_BIT

| GLES20.GL_COLOR_BUFFER_BIT);

GLES20.glUseProgram(mProgram);

checkGlError("glUseProgram");

//Allow a derived class to set up drawing

//for further down the chain.

//the default doesn't do anything.

preDraw(gl,this.maPositionHandle);

//Real abstract method

draw(gl,this.maPositionHandle);

}

/*

* 1\. Load vertex shader

* 2\. load fragment shader

* 3\. create program

* 4\. attach shaders

* 5\. link program and return it

* 6\. returns 0 if the program cannot be created

*/

private int createProgram(String vertexSource, String fragmentSource)

{

int vertexShader = loadShader(GLES20.GL_VERTEX_SHADER, vertexSource);

if (vertexShader == 0) {   return 0;        }

Log.d(TAG,"vertex shader created");

int pixelShader = loadShader(GLES20.GL_FRAGMENT_SHADER, fragmentSource);

if (pixelShader == 0) {       return 0;        }

Log.d(TAG,"fragment shader created");

int program = GLES20.glCreateProgram();

if (program == 0) {

checkGlError("Error Creating the program");

return 0;

}

Log.d(TAG,"program created");

GLES20.glAttachShader(program, vertexShader);

checkGlError("glAttachShader");

GLES20.glAttachShader(program, pixelShader);

checkGlError("glAttachShader");

GLES20.glLinkProgram(program);

int[] linkStatus = new int[1];

GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, linkStatus, 0);

if (linkStatus[0] == GLES20.GL_TRUE) {

Log.d(TAG,"Program successfully linked");

return program;

}

Log.e(TAG, "Could not link program: ");

Log.e(TAG, GLES20.glGetProgramInfoLog(program));

GLES20.glDeleteProgram(program);

return 0;

}

// Load a given type of shader and check for any errors

private int loadShader(int shaderType, String source)

{

int shader = GLES20.glCreateShader(shaderType);

if (shader == 0){

checkGlError("Cannot create shader:"

+ getShaderTypeAsString(shaderType));

return 0;

}

//Associaate shader id to source

GLES20.glShaderSource(shader, source);

//Compile source

GLES20.glCompileShader(shader);

//Check if there is an error

int[] compiled = new int[1];

GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compiled, 0);

//Return if there is no error

if (compiled[0] == GLES20.GL_TRUE) {

Log.d(TAG, getShaderTypeAsString(shaderType)

+ " successfully compiled");

return shader;

}

//report error if there is one and return 0

Log.e(TAG, "Could not compile shader "

+ getShaderTypeAsString(shaderType));

Log.e(TAG, GLES20.glGetShaderInfoLog(shader));

GLES20.glDeleteShader(shader);

return 0;

}

//Purely used for debugging purposes

public String getShaderTypeAsString(int shaderType)    {

if (shaderType == GLES20.GL_VERTEX_SHADER){

return "Vertex Shader";

}

else if (shaderType == GLES20.GL_FRAGMENT_SHADER) {

return "Fragment Shader";

}

else { return new String("Unknown Shader Type Value");}

}

//Use this method to check and log GL errors

protected void checkGlError(String op) {

int error;

while ((error = GLES20.glGetError()) != GLES20.GL_NO_ERROR) {

Log.e(TAG, op + ": glError " + error);

throw new RuntimeException(op + ": glError " + error);

}

}

/*

* The following three methods update the mCurrentModelMatrix

* with the given model transformation.

* These are stateful accumulative methods.

*/

public void translate(float x, float y, float z)

{

float[] tempModelMatrix = new float[16];

Matrix.setIdentityM(tempModelMatrix, 0);

Matrix.translateM(tempModelMatrix,0,x,y,z);

Matrix.multiplyMM(this.mCurrentModelMatrix, 0

tempModelMatrix, 0, this.mCurrentModelMatrix, 0);

}

public void rotate(float angle, float x, float y, float z)

{

float[] tempModelMatrix = new float[16];

Matrix.setIdentityM(tempModelMatrix, 0);

Matrix.rotateM(tempModelMatrix,0,angle,x,y,z);

Matrix.multiplyMM(this.mCurrentModelMatrix, 0

tempModelMatrix, 0, this.mCurrentModelMatrix, 0);

}

public void scale(float xFactor, float yFactor, float zFactor)

{

float[] tempModelMatrix = new float[16];

Matrix.setIdentityM(tempModelMatrix, 0);

Matrix.scaleM(tempModelMatrix,0,xFactor,yFactor,zFactor);

Matrix.multiplyMM(this.mCurrentModelMatrix, 0

tempModelMatrix, 0, this.mCurrentModelMatrix, 0);

}

/*

* Calculaute the final model view matrix

* 1\. Order of matrix multiplication is important

* 2\. MVPmatrix = proj * view * model;

* 3\. Setup the MVP matrix in the vertex shader memory

*/

protected void setupMatrices()

{

float[] tempModelMatrix = new float[16];

Matrix.setIdentityM(tempModelMatrix, 0);

//translate the model combo next

Matrix.multiplyMM(mMVPMatrix, 0, //matrix and offset

mCurrentModelMatrix, 0

tempModelMatrix, 0);

//translate eye coordinates first

Matrix.multiplyMM(mMVPMatrix, 0

this.mVMatrix, 0

mMVPMatrix, 0);

//Project it: screen coordinates

Matrix.multiplyMM(mMVPMatrix, 0

mProjMatrix, 0

mMVPMatrix, 0);

//Set the vertex uniform handler representing the MVP matrix

GLES20.glUniformMatrix4fv(muMVPMatrixHandle, //uniform handle

1, //number of uniforms. 1 if it is not an array

false, //transpose: must be false

mMVPMatrix, //client matrix memory pointer

0); //offset

}

//Override this method to continue the onDrawframe callback

//from the renderer.

protected abstract void draw(GL10 gl, int positionHandle);

//Override this to implement preDraw

//useful for derived classes to specialize pre-draws

protected void preDraw(GL10 gl, int positionHandle) {

//nothing to do for this class: No op

}

//Use this method if your intent is to return

//a default vertex shader.

public String getDefaultVertexShaderCodeString() {

return this.getStringFromAssetFile(DEF_VERTEX_SHADER_FILENAME);

}

//Use this method if your intent is to return

//a default fragment shader.

public String getDefaultFragmentShaderCodeString() {

return this.getStringFromAssetFile(DEF_FRAGMENT_SHADER_FILENAME);

}

//Override this method if you want to provide

//a different vertex shader program.

protected String getVertexShaderCodeString()

{

String vertexShader =

"uniform mat4 uMVPMatrix;\n" +

"attribute vec4 aPosition;\n" +

"void main() {\n" +

"  gl_Position = uMVPMatrix * aPosition;\n" +

"}\n";

return vertexShader;

}

//Override this method if you want to provide

//a different vertex shader program.

//In a derived method call getStringFromAssetFile(filename)

//to read as string to be returned from here.

protected String getFragmentShaderCodeString()

{

String fragmentShader =

"void main() {\n" +

"  gl_FragColor = vec4(0.5, 0.25, 0.5, 1.0);\n" +

"}\n";

return fragmentShader;

}

//How to to read a text file from an asset

//directory. In this approach you will need to

//create your application object and provide a static

//variable to create the context.

//See MyApplication implementation to see how this works.

//Or seehttp://androidbook.com/item/4224

public String getStringFromAssetFile(String filename)

{

Context ctx = MyApplication.m_appContext;

if ( ctx == null) {

throw new RuntimeException("Sorry your app context is null");

}

try         {

AssetManager am = ctx.getAssets();

InputStream is = am.open(filename);

String s = convertStreamToString(is);

is.close();

return s;

}

catch (IOException x)        {

throw new RuntimeException("Sorry not able to read filename:" + filename,x);

}

}

//Converting a file stream to a string

//Optimize as you see fit. This may not be an efficient read

private String convertStreamToString(InputStream is)

throws IOException

{

ByteArrayOutputStream baos = new ByteArrayOutputStream();

int i = is.read();

while (i != -1)         {

baos.write(i);

i = is.read();

}

return baos.toString();

}

//Use this if you need to use a program object

//directly. Make sure you call this after the

//surface is created and prepared in this base class.

//otherwise it will be null.

public int getGLSProgramObjectReference()     {

return this.mProgram;

}

//Use this method to get a handle to any

//named attribute. It is a utility method.

//It uses the program object that is in effect.

//Ensure program object is valid before calling this.

public int getAttributeHandle(String GLSLAttributeName, String comment)

{

String logComment = comment + ":" + GLSLAttributeName;

Log.d(TAG,comment);

int attributeHandle =

GLES20.glGetAttribLocation(mProgram, GLSLAttributeName);

checkGlError(logComment);

if (attributeHandle == -1) {

throw new RuntimeException(logComment);

}

return attributeHandle;

}

public int getUniformHandle(String GLSLUniformName, String comment)

{

String logComment = comment + ":" + GLSLUniformName;

Log.d(TAG,comment);

int uniformHandle =

GLES20.glGetUniformLocation(mProgram, GLSLUniformName);

checkGlError(logComment);

if (uniformHandle == -1) {

throw new RuntimeException(logComment);

}

return uniformHandle;

}//eof-method

}//eof-class

这段代码的大部分是前面介绍的思想的集合。OpenGL 本质上是程序性的,非常以“C”为中心。在像 Java 这样的面向对象语言中,你可以更好地把它塑造成一个好的面向对象框架。事实上,这个抽象类是这种愿望的早期表现。

清单 9-49 中的抽象类也允许你从项目的资源目录中加载顶点着色器和片段着色器文件。默认情况下,这两个文件如下所示:

Project-root/assets/def_vertex_shader.txt

Project-root/assets/def_fragment_shader.txt

在这个抽象类中,我们进一步依赖于一个实用程序类来定义截锥。在清单 9-49 的方法onSurfaceChanged中,我们使用了这个类截锥来提供截锥的尺寸并得到投影矩阵。清单 9-50 显示了FrustumDimensions的类定义。

清单 9-50。平截头体尺寸实用程序类

public class FrustumDimensions

{

//These are usually set by figuring out the window ratio.

public float left;

public float right;

public float bottom;

public float top;

public float near;

public float far;

public FrustumDimensions(float left, float right

float bottom, float top

float near, float far)     {

this.left = left;

this.right = right;

this.bottom = bottom;

this.top = top;

this.near = near;

this.far = far;

}

static public FrustumDimensions getDefault()    {

return new FrustumDimensions(-1, 1, -1, 1, 3, 7);

}

static public FrustumDimensions getMedium()    {

return new FrustumDimensions(-1, 1, -2, 2, 3, 15);

}

}

清单 9-51 显示了默认顶点着色器文件的内容。

清单 9-51。默认顶点着色器文件

//assets/def_vertex_shader.txt

uniform mat4 uMVPMatrix;

attribute vec4 aPosition;

void main()

{

gl_Position = uMVPMatrix * aPosition;

}

清单 9-52 显示了默认片段着色器文件的内容。

清单 9-52。默认片段着色器文件

//assets/def_fragment_shader.txt

void main()

{

gl_FragColor = vec4(0.5, 0.25, 0.5, 1.0);

}

这些非常简单的着色器可以让你用单一颜色绘制任何几何体。你可以用这个来测试你的基础。随着您变得越来越复杂,派生类可以传递它们自己的着色器。然而,根据您需要映射的变量,您可能希望比这个抽象设计中呈现的更灵活地构造抽象类。

这个基本抽象类还提供了您需要绑定到任何变量的实用方法,无论它是属性变量还是统一变量。

清单 9-49 中用于ES20AbstractRenderer的函数setupMatrices可能需要一些解释。This function演示了如何使用 m atrix类从一个单位矩阵开始,通过与其他矩阵相乘,将多个矩阵组合成一个名为mMVPMatrix的矩阵。

变量mCurrentModelMatrix从一个单位矩阵开始。每一个模型转换都会在这个矩阵上累积它的效果。当客户端调用设置矩阵时,该矩阵将具有所有模型变换(平移、旋转、缩放等)的最终乘法。)在正在绘制的对象上。

变量mVMatrix通过使用摄像机的视点或观察点获得。投影矩阵mProjMatrix是通过使用 matrix 类上的截锥规范获得的。你可以在清单 9-42 中看到这两个变量的设置。两个概念——眼点和平截头体——与 OpenGL ES 1.0 中的概念相同。MVP 矩阵(代码中的mMVPMatrix)就是这些矩阵按正确的顺序相乘。顺序很重要。你把模型矩阵(mCurrentModelMatrix)和视图矩阵(mVMatrix)相乘。然后取结果矩阵乘以投影(mProjMatrix)得到最终的 MVP 矩阵(mMVPMatrix)。

最后,调用glUniformMatrix4fv在顶点着色器中设置这个(mMVPMatrix)模型视图投影变量(清单 9-49),这样顶点着色器可以用这个矩阵乘以每个顶点的位置来得到最终的位置(见清单 9-36)。

清单 9-53 显示了ES20SimpleTriangleRenderer的代码,它扩展了抽象渲染器以及定义点和绘制三角形所需的最小量。

清单 9-53。ES20SimpleTriangleRenderer

public class ES20SimpleTriangleRenderer extends ES20AbstractRenderer

{

//A raw native buffer to hold the point coordinates

private FloatBuffer mFVertexBuffer;

private static final int FLOAT_SIZE_BYTES = 4;

private final float[] mTriangleVerticesData = {

// X, Y, Z

-1.0f, -0.5f, 0

1.0f, -0.5f, 0

0.0f,  1.11803399f, 0 };

public ES20SimpleTriangleRenderer()

{

ByteBuffer vbb = ByteBuffer.allocateDirect(mTriangleVerticesData.length

* FLOAT_SIZE_BYTES);

vbb.order(ByteOrder.nativeOrder());

mFVertexBuffer = vbb.asFloatBuffer();

mFVertexBuffer.put(mTriangleVerticesData);

mFVertexBuffer.position(0);

}

protected void draw(GL10 gl, int positionHandle)

{

GLES20.glVertexAttribPointer(positionHandle, 3, GLES20.GL_FLOAT, false

0, mFVertexBuffer);

checkGlError("glVertexAttribPointer maPosition");

GLES20.glEnableVertexAttribArray(positionHandle);

checkGlError("glEnableVertexAttribArray maPositionHandle");

this.setupMatrices();

GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, 3);

checkGlError("glDrawArrays");

}

}

这是一个具体的渲染器类。你可以使用这个渲染器并使用清单 9-34 中的代码(这里我们使用了一个GLSurfaceView来绘制一个带有渲染器的活动)来绘制你选择的活动。如果您想看看实际效果,请使用本章的测试程序并调用菜单项“ES20 三角形”

旋转纹理立方体

在本章的最后一节,让我们加大赌注,使用 ES 2.0 执行以下操作:

  • 用一个立方体进入 3D 世界
  • 纹理立方体
  • 从中心向下平移立方体
  • 旋转立方体
  • 在活动中混合总帐视图和常规视图
  • 使用按钮来控制立方体的旋转

为了做到这一切,我们利用了清单 9-49 中的 ES20 抽象渲染器,并对其进行了扩展,以添加纹理相关的行为。然后,我们可以使用支持纹理的抽象渲染器来导出最终的立方体渲染器。

单一纹理抽象渲染器

因为我们在本章前面已经广泛地讨论了纹理,我们可以很容易地用纹理行为扩展清单 9-49 中的抽象渲染器。在这个渲染器中,我们将“draw”方法的实现留给了派生类。纹理渲染器的职责是加载和设置纹理,以便派生类专注于顶点坐标和纹理坐标。大多数与纹理相关的工作都将在这个抽象的纹理渲染器中进行。

因为我们假设有一个单一的纹理应用于所有的表面,我们称之为纹理抽象渲染器ES20SingleTextureAbstractRenderer。清单 9-54 是这个类的注释源代码。

清单 9-54。es20 单纹理抽象渲染器

//filename: ES20SingleTextureAbstractRenderer.java

public abstract class ES20SingleTextureAbstractRenderer

extends ES20AbstractRenderer

{

public static String TAG = "ES20SingleTextureAbstractRenderer";

//Handle to the texture attribute in the vertex shader

private int maTextureHandle;

//handle to the texture Sampler Uniform variable in the fragment shader

private int mu2DSamplerTexture;

//Client assigned name of the texture

int mTextureID;

//default texture ImageResourceId

int mDefTextureImageResourceId = R.raw.robot;

public ES20SingleTextureAbstractRenderer()   {

super();

}

//give out the texture attribute handle

//if needed.

protected int getTextureHandle()   {

return maTextureHandle;

}

//You can prepare and load your texture here

//so that you don't have to do this every time

//a surface just changed changing the height and width.

public void onSurfaceCreated(GL10 gl, EGLConfig eglConfig)

{

//Give a chance to have the parent prepare the surface

super.prepareSurface(gl,eglConfig);

prepareSurfaceForTexture(gl,eglConfig);

}

public void prepareSurfaceForTexture(GL10 gl, EGLConfig eglConfig)

{

//Get texture attribute handle

Log.d(TAG,"Getting texture handle:aTextureCoordinate");

maTextureHandle = getAttributeHandle("aTextureCoordinate"

"Getting Texture handle");

//Get uniform texture handle

Log.d(TAG,"Getting texture 2D sampler handle");

mu2DSamplerTexture = getUniformHandle("s_2DtextureSampler"

"Getting 2D sampler for texture");

this.prepareTexture();

}

@Override

protected void preDraw(GL10 gl, int positionHandle)

{

//Call the parent's method first

super.preDraw(gl, positionHandle);

//texture support

//Make texture unit 0 as the active texture unit

//This is the default as well

GLES20.glActiveTexture(GLES20.GL_TEXTURE0);

//Make texture target 2D and texture name mTextureId

//as the target texture for the active texture unit

GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, mTextureID);

//Tell the texture sampler in GLSL that the texture to

//sample belongs to the texture unit 0.

//This is also the default

GLES20.glUniform1i(mu2DSamplerTexture, 0);

}

//Get the texture name that is initialized

//Make sure it is initialized before calling this

//method.

public int getTextureID()    {

return mTextureID;

}

//Ultimately this code prepares the

//texture ID mTextureID

//creates it, and binds it to the texture target 2D.

public void prepareTexture()

{

//GLES20.glEnable(GLES20.GL_TEXTURE_2D);

int[] textures = new int[1];

GLES20.glGenTextures(1, textures, 0);

mTextureID = textures[0];

GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, mTextureID);

GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D

GLES20.GL_TEXTURE_MIN_FILTER

GLES20.GL_NEAREST);

GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D

GLES20.GL_TEXTURE_MAG_FILTER

GLES20.GL_LINEAR);

GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D

GLES20.GL_TEXTURE_WRAP_S

GLES20.GL_REPEAT);

GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D

GLES20.GL_TEXTURE_WRAP_T

GLES20.GL_REPEAT);

final BitmapFactory.Options options = new BitmapFactory.Options();

options.inScaled = false;   // No pre-scaling

int texturImageReourceId = getTextureImageResourceId();

// Read in the resource

final Bitmap bitmap = BitmapFactory.decodeResource(

MyApplication.m_appContext.getResources()

texturImageReourceId, options);

GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0);

bitmap.recycle();

}

//override this to give your own texture image resource id

protected int getTextureImageResourceId()    {

return this.mDefTextureImageResourceId;

}

}//eof-main-class

清单 9-54 中的源代码附有注释。本章前面的纹理覆盖也应该有助于澄清这个类中发生了什么。

清单 9-54 中的抽象假设了一些来自派生类的契约。例如,它假设有一个单一的 2D 纹理。它还假设顶点处理器有一个名为aTextureCoordinate的属性,将纹理坐标传递给顶点着色器。此代码进一步假设在片段着色器中有一个名为s_2DTextureSampler的纹理统一变量,用于从纹理单元中采样纹理图像。

纹理采样器是片段着色器中的一种结构,允许您查看纹理坐标和纹理单元,并返回要应用于像素的颜色。ES 2.0 中引入了纹理采样器;它们不适用于 1.0。这是 1.0 和 2.0 在纹理方面的一个关键区别。

为了理解这些变量之间的联系,这里有一个可以处理这个抽象类的顶点着色器和片段着色器。

具有纹理坐标的顶点着色器

清单 9-55 显示了一个顶点着色器的源代码,它假设了两件事。首先,它假设顶点通过一个名为aPosition的属性变量传递。其次,它假设纹理坐标是通过一个名为aTextureCoordinate的属性变量传递的。两个抽象类——es 20 abstract renderer(清单 9-49)和 es 20 singletextureabstractrenderer(清单 9-54)—都假定这些变量的名称与这里定义的一样。如果您想通过派生类参数化这些变量名,我们把它留在这里作为一个练习。

清单 9-55。具有纹理坐标的顶点着色器

//input: model view matrix

uniform mat4 uMVPMatrix;

//input: vertex points

attribute vec4   aPosition;

//input: vertex texture coordinates

attribute vec2   aTextureCoordinate;

//output: vertex texture coordinate

//will act as input to the fragment shader

varying vec2   v_TextureCoordinate;

void main()

{

gl_Position = uMVPMatrix * aPosition;

//Pass the texture coordinate as is to the fragment shader.

v_TextureCoordinate = aTextureCoordinate;

}

从纹理的角度来看,这个顶点着色器做得最少。它只是通过一个名为a_TextureCoordinate的输出变量来传递纹理坐标。因为这是一个可变变量,所以当它被传递给片段着色器时,可以预期它会被插值。请注意,变化并传递给片段着色器的不是纹理值(颜色值),而是给定片段处的纹理坐标。

具有 2D 纹理采样的片段编码器

清单 9-56 显示了片段着色器的代码。变量名s_2DtextureSampler是我们在抽象类ES20SingleTextureAbstractRenderer中使用的(清单 9-54)。每个片段调用一次这个片段着色器代码。当它被调用时,会被传递一个与该片段相关的纹理坐标。预建的 GLSL 函数texture2D用于在从顶点着色器传来的纹理像素坐标处对纹理单元(指示并绑定到s_2DtextureSampler)进行采样。

清单 9-56。使用纹理坐标的片段着色器

precision mediump float;

//Appears precision is mandatory for this version of GLSL

//if you are using samplers.

//Input from the vertex shader

//names have to match.

varying vec2 v_TextureCoordinate;

//A structure to sample the texture

//from the texture unit.

//A client needs to bind a texture unit to this

//variable.

uniform sampler2D s_2DtextureSampler;

void main()

{

//texture2D is a prebuilt function in GLSL

gl_FragColor = texture2D(s_2DtextureSampler, v_TextureCoordinate);

}

这两个着色器(清单 9-55 和清单 9-56)足够通用,只要你打算使用一个单一的纹理,就可以在任何复杂的图形中重复使用。对于派生类,就好像纹理细节已经消失,您可以专注于绘制和纹理图像,而不必关注纹理如何应用到模型表面的机制。

我们将在下一小节中看到如何用很少的工作量来纹理化一个立方体的六个表面。如果你愿意,你可以对三角形、正方形或多边形做同样的事情,而不用担心着色器。这就是抽象的美。

立方体渲染器

清单 9-57 是绘制一个立方体,给它纹理,并根据命令旋转它的代码。注意这段代码有多简单。事实上,这个类中的大部分空间被 36 个顶点(六个表面中的每一个都有两个三角形)和 24 个纹理点的定义所占据。

先看一下代码。按照代码,我们解释代码中的关键步骤。

清单 9-57。可控旋转立方体

//filename: ES20ControlledAnimatedTexturedCubeRenderer.java

public class ES20ControlledAnimatedTexturedCubeRenderer

extends ES20SingleTextureAbstractRenderer

{

//A raw native buffer to hold the point coordinates for the cube.

private FloatBuffer mFVertexBuffer;

//A raw native buffer to hold the texture coordinates for the cube.

private FloatBuffer mFTextureBuffer;

private static final int FLOAT_SIZE_BYTES = 4;

//variables to control rotation

//if stopFlag = true stop the rotation

private boolean stopFlag = false;

//what is the current angle of rotation

private float curAngle = 0;

//At the last stop what was the angle

//to restart.

private float stoppedAtAngle = 0;

//What are the vertex and fragment shader source code files

private static final String VERTEX_SHADER_FILENAME

= "tex_vertex_shader_1.txt";

private static final String FRAGMENT_SHADER_FILENAME

= "tex_fragment_shader_1.txt";

//front-face: f1,f2,f3,f4

//starting top-right-anti-clockwise

//f1(1,1) f2(-1,1) f3(-1,-1) f4(1,-1): z plane 0

//back-face: b1,b2,b3,b4

//starting bottom-right-anti-clockwise

//b1(1,-1) b2(1,1) b3(-1,1) b4(-1,-1) : z plane 2

private float z = 2.0f;

private final float[] mTriangleVerticesData = {

//1\. front-triangles

//f1,f2,f3

1,1,0,          -1,1,0,            -1,-1,0

//f3,f4,f1

-1,-1,0,         1,-1,0,            1,1,0

//2\. back-triangles

//b1,b2,b3

1,-1,z,            1,1,z,            -1,1,z

//b3,b4,b1

-1,1,z,            -1,-1,z,        1,-1,z

//3\. right-triangles

//b2,f1,f4

1,1,z,            1,1,0,            1,-1,0

//b2,f4,b1

1,1,z,            1,-1,0,            1,-1,z

//4\. left-triangles

//b3, f2, b4

-1,1,z,            -1,1,0,        -1,-1,z

//b4 f2 f3

-1,-1,z,        -1,1,0,        -1,-1,0

//5\. top-triangles

//b2, b3, f2

1,1,z,            -1,1,z,        -1,1,0

//b2, f2, f1

1,1,z,            -1,1,0,        1,1,0

//6\. bottom-triangles

//b1, b4, f3

1,-1,z,        -1,-1,z,    -1,-1,0

//b1, f3, f4

1,-1,z,        -1,-1,0,    1,-1,0

};

//Follow each face and the triangle set.

//Takes into account the upside down nature of bitmaps and

//texture coordinates.

private final float[] mTextureData = {

//1\. front face: f1(10) f2(00) f3(01) f4(11)

//front-triangles

//f1,f2,f3

1,0,    0,0,    0,1

//f3,f4,f1

0,1,     1,1,    1,0

//2\. back face: b2(10) b3(00) b4(01) b1(11)

//back-triangles:

//b1,b2,b3

1,1,    1,0,    0,0

//b3,b4,b1

0,0,    0,1,    1,1

//3\. Right face: b2(10) f1(00) f4(01) b1(11)

//right-triangles

//b2,f1,f4

1,0,     0,0,    0,1

//b2,f4,b1

1,0,     0,1,    1,1

//4\. Left face: f2(10) b3(00) b4(01) f3(11)

//left-triangles

//b3, f2, b4

0,0,    1,0,    0,1

//b4 f2 f3

0,1,    1,0,    1,1

//5\. Top face: b2(10) b3(00) f2(01) f1(11)

//top-triangles

//b2, b3, f2

1,0,    0,0,    0,1

//b2, f2, f1

1,0,    0,1,    1,1

//6\. Bottom face: b1(10) b4(00) f3(01) f4(11)

//bottom-triangles

//b1, b4, f3

1,0,    0,0,    0,1

//b1, f3, f4

1,0,    0,1,    1,1

};

public ES20ControlledAnimatedTexturedCubeRenderer()

{

//Turn java points to native buffer points

setupVertexBuffer();

//Turn java texture points to native buffer texture points.

setupTextureBuffer();

}

//Convert to a native buffer

private void setupTextureBuffer()

{

//Allocate and handle texture buffer

ByteBuffer vbb1 = ByteBuffer.allocateDirect(mTextureData.length

* FLOAT_SIZE_BYTES);

vbb1.order(ByteOrder.nativeOrder());

mFTextureBuffer = vbb1.asFloatBuffer();

mFTextureBuffer.put(mTextureData);

mFTextureBuffer.position(0);

}

//Convert to a native buffer

private void setupVertexBuffer()

{

//Allocate and handle vertex buffer

ByteBuffer vbb = ByteBuffer.allocateDirect(mTriangleVerticesData.length

* FLOAT_SIZE_BYTES);

vbb.order(ByteOrder.nativeOrder());

mFVertexBuffer = vbb.asFloatBuffer();

mFVertexBuffer.put(mTriangleVerticesData);

mFVertexBuffer.position(0);

}

//Transfer the vertices from the vertex buffer

//to the shader.

private void transferVertexPoints(int vertexPositionHandle)

{

GLES20.glVertexAttribPointer(

vertexPositionHandle, //bound address in the vertex shader

3, //how may floats for this attribute: x, y, z

GLES20.GL_FLOAT, //what is type of each attribute?

false, // not normalized

0, //stride

mFVertexBuffer); //local client pointer to data

//Check to see if this caused any errors

checkGlError("glVertexAttribPointer maPosition");

//You have to call this to enable the arrays to be

//used by glDrawArrays or glDrawElements.

GLES20.glEnableVertexAttribArray(vertexPositionHandle);

checkGlError("glEnableVertexAttribArray maPositionHandle");

}

//Same as above but for transferring texture attributes

//Notice how textures and vertices use the same concept

//of attributes and the same APIs.

private void transferTexturePoints(int texturePositionHandle)

{

GLES20.glVertexAttribPointer(texturePositionHandle, 2

GLES20.GL_FLOAT, false

0, mFTextureBuffer);

checkGlError("glVertexAttribPointer texture array");

GLES20.glEnableVertexAttribArray(texturePositionHandle);

checkGlError("glEnableVertexAttribArray textures");

}

//Drawing operation

@Override

protected void draw(GL10 gl, int positionHandle)

{

//Hide the hidden surfaces using these APIs

GLES20.glEnable(GLES20.GL_DEPTH_TEST);

GLES20.glDepthFunc(GLES20.GL_LESS);

//Transfer vertices to the shader

transferVertexPoints(positionHandle);

//Transfer texture points to the shader

transferTexturePoints(getTextureHandle());

//Implement rotation from 0 to 360 degrees

//Stop when asked and restart when the stopFlag

//is set to false.

//Decide what the current angle to apply

//for rotation is.

if (stopFlag == true)        {

//stop rotation

curAngle = stoppedAtAngle;

}

else        {

curAngle += 1.0f;

}

if (curAngle > 360)        {

curAngle = 0;

}

//Tell the base class to start their

//matrices to unit matrices.

this.initializeMatrices();

//The order of these model transformations matter

//Each model transformation is specified with

//respect to the last one, and not the very first.

//Center the cube

this.translate(0,0,-1);

//Rotate it around y axis

this.rotate(curAngle, 0,-1,0);

//Decenter it to where ever you want

this.trnslate(0,-2,2);

//Go ahead calculate the ModelViewMatrix as

//we are done with ALL of our model transformations

this.setupMatrices();

//Call glDrawArrays to use the vertices and draw

int vertexCount = mTriangleVerticesData.length/3;

GLES20.glDrawArrays(GLES20.GL_TRIANGLES, //what primitives to use

0, //at what point to start

vertexCount); //Starting there how many points to use

//Check if there are errors

checkGlError("glDrawArrays");

}

//Indicate how big of a viewing volume we desire

//This is just a simple homegrown class

//@see FrustumDimensions

@Override

protected FrustumDimensions getFrustumDimensions() {

return FrustumDimensions.getMedium();

}

//Indicate the fragment shader source code

@Override

protected String getFragmentShaderCodeString()    {

return this.getStringFromAssetFile(FRAGMENT_SHADER_FILENAME);

}

//Give out the vertex shader source code

@Override

protected String getVertexShaderCodeString()    {

return this.getStringFromAssetFile(VERTEX_SHADER_FILENAME);

}

//Stop the rotation. Called by a client

//on a button click or other user actions.

public void stop()    {

this.stopFlag = true;

this.stoppedAtAngle = curAngle;

}

//Restart the rotation

public void start()    {

this.stopFlag = false;

this.curAngle = this.stoppedAtAngle;

}//eof-function

}//eof-class

我们已经在清单 9-57 所示的源代码中做了大量的注释。这段代码的关键部分是定义立方体的所有顶点。每个面需要六个顶点,所以我们最终重复顶点,因为它参与形成构成每个面的多个三角形。

定义顶点时,请注意每个面上形成的三角形。缠绕对于弄清楚哪个是面向外的表面是很重要的。正如我们在本章开始的关键概念部分所解释的,你这样做的方法是通过想象你正沿着三角形的顶点(沿着它们的定义顺序的路径)转动一个右旋螺丝(RHS)。RHS 应伸出水面,而不是进入水面。

这个类还通过覆盖getFrustumDimensions()方法表明它想要一个中等大小的平截头体。请参见清单 9-50 中的 frustum 类,了解中等大小的截锥的尺寸。这个类还指示顶点着色器和片段着色器的文件名,以匹配清单 9-55 和 9-56 中的文件名

这个类使用glVertexAttribArray()将顶点和纹理点转移到着色器。这个类还展示了如何正确使用旋转。该类首先将立方体定位在中心并旋转它,然后将立方体移动到最终位置。为什么这很重要?因为当您进行模型转换时,您必须记住三件事情:

Each vertex goes through all of the model transformations alone. The cumulative effect of all the vertices going through this process will be a transformed model. But it is good to think of one vertex when you are transforming OpenGL figures.   Rotation as indicated in Listing 9-57 is around the axis extending from the origin to the specified point.   Each model transformation uses the output coordinates from the previous model transformation, not the first. So if your point is at 5 and it ends up at 7 after rotation, then moving it down by 1 will result in 6 (with respect to the last point) and not in 4 (with respect to its original point). Transformations have no historical memory. They only act on the last position, so the order of model transformations can give you different results.

如果您运行示例程序并调用菜单“受控旋转立方体”,您将看到如图 9-11 所示的活动。我们已经通过使用来自 eclipse APT 的 DDMS 连接到一个真实的设备上拍摄了这张照片。在参考资料中,我们有一个 URL,在那里我们记录了如何使用 DDMS 来拍摄设备图片。

A978-1-4302-4951-1_9_Fig11_HTML.jpg

图 9-11。

Controlling the rotation of a textured cube with mixed views

图 9-11 中显示的活动是一个复合视图,其中我们混合了一个 OpenGL 视图和一个常规的 Android 视图。常规的 Android 视图可以有旋转立方体的控件。但是请记住,当您混合视图时,GL 表面视图上的绘制是由背景线程完成的。这样做的副作用是,如果你使用的是由主线程绘制的背景可绘制图形,它们可能不会显示出来,因为 GL 看起来优先,可能会覆盖表面。

你可以在本章的可下载项目中看到活动的代码和图 9-11 的布局(参见参考文献)。或者你可以访问这个网址来一窥源代码: http://androidbook.com/item/4254

参考

您会发现以下 OpenGL 资源非常有用:

摘要

这是对本书之前版本的 OpenGL 在 Android 上的一个很大的改进。这一章是关于向你介绍 Android 上的 OpenGL 的精彩研究的高潮。我们已经介绍了 OpenGL、ES 1.0 和 ES 2.0 的基础知识。我们已经解释了理解 OpenGL 文献所必需的 OpenGL 基本词汇。我们已经讨论了纹理,绘制多个图形,模型转换和 GLSL。我们已经给了你一套基本的着色器程序,以及如何使用它们进行大量简单的 OpenGL 练习。我们已经使用 GLSL 和 ES 2.0 在包含 GL 视图和普通视图的混合活动中绘制了一个旋转的立方体。我们已经向您展示了如何从简单的 Android 控件(如按钮)控制 OpenGL 绘图和动画。

复习问题

下面的问题可以作为你在本章所学内容的里程碑

What is the difference between OpenGL and OpenGL ES?   What is the difference between ES 1.0 and ES 2.0?   Why is ES 2.0 not backward compatible with ES 1.0?   What is a fixed-function graphics pipeline?   What are GLUT, GLEW, and EGL?   What are color, depth, and stencil buffers?   What buffers make up a frame buffer?   What is Android Renderscript?   What are the three key characteristics of Android Renderscript?   What is OpenCL?   What is CUDA?   What are object, model, world, eye, and device coordinates?   What are model, view, and projection matrices?   How many transforamtion matrices are there in ES 1.0?   How do methods related to matrix operations in ES 1.0 target a specific matrix?   What is the need for a push-and-pop operations for a matrix in OpenGL?   What is a frustum?   What is an OpenGL view port?   How do you explain position and color as ES 2.0 vertex attributes?   Can you pass an arbitrary set of values as vertex attributes in ES 2.0?   How do you know which side of an OpenGL surface is outside and which is inside?   Does it matter in what order you specify vertices?   Does it matter where you start the vertices as long as they satisfy the winding?   Provide a rudimentary understanding of blending, dithering, and anti-aliasing.   How do you use indices to draw geometry in OpenGL?   What is orthographic and perspective projections in OpenGL?   Explain how GLSurfaceView and Renderer are used to do a basic drawing?   How do you do animation with GLSurfaceView?   What is glMatrixMode() and how does it affect model transformations like glRotate?   Why do you need shapes for defining vertices for complex objects?   How do you specify texture coordinates for an OpenGL surface?   How are texture coordinates related to vertex coordinates?   What are texels?   What is the difference between a texture ID and a texture name?   What is GLSL?   What are shaders?   How do you compile and link shaders?   How often does a vertex shader get called?   How often does a fragment shader get called?   Why is glVertexAttribPointer() a generic method?   What APIs are available to debug ES 2.0 issues?   What is a varying variable?   What are reserved variables in GLSL?   How do you bind to the attribute and uniform variables in a shader?   What is the right order to transform using model, view, and projection matrices?   How do you load shader source code from assets?   What are attributes in GLSL?   What are uniform variables in GLSL?   What is the role of texture samplers in GLSL?   Why do you need to turn a texture coordinates upside down when attaching to a surface geometry?   How do you associate a texture sampler with a texture unit?   How do you debug compiler errors for the shading programs?   How do you debug linking errors from linking the shaders in a program?   What would you do if your texture appears upside down?   Can you have a texture coordinate larger than 1?   What is the difference between GL_REPEAT and GL_CLAMP?

十、Android 搜索简介

Abstract

Android 中的搜索功能扩展了人们熟悉的基于网络的谷歌搜索栏,可以搜索基于设备的本地内容和基于互联网的外部内容。您可以进一步使用这种搜索机制,直接从主页上的搜索结果中发现和调用应用。Android 通过提供一个允许所有应用参与搜索的搜索框架,使这些功能成为可能。

Android 中的搜索功能扩展了人们熟悉的基于网络的谷歌搜索栏,可以搜索基于设备的本地内容和基于互联网的外部内容。您可以进一步使用这种搜索机制,直接从主页上的搜索结果中发现和调用应用。Android 通过提供一个允许所有应用参与搜索的搜索框架,使这些功能成为可能。

Android 搜索包含一个搜索框,让用户输入搜索数据。无论您是使用主页上的全局搜索框还是通过自己的应用进行搜索,都是如此:您使用相同的搜索框。

这个搜索框可以采用三种形式之一:搜索小部件、搜索对话框或搜索视图。搜索小部件是一个 Android 小部件,可以拖放到主屏幕上。应用可以调用搜索对话框来帮助用户输入搜索文本。这个搜索对话框可以由主页上或应用内部的搜索小部件调用。搜索视图是一种特殊的搜索对话框,它嵌入在视图中,特别是应用的操作栏中,用于搜索特定于该应用的数据。

当用户在搜索视图或搜索对话框中输入文本时,Android 获取文本并将其传递给各种已注册响应搜索的应用。应用将通过返回一组响应进行响应。Android 汇总了来自多个应用的这些响应,并将其作为可能的建议列表呈现。当用户点击其中一个响应时,Android 会调用给出建议的应用,按照给出建议的应用的设计做出正确的响应。从这个意义上说,Android 搜索是一组参与应用之间的联合搜索。

在这一章中,你将学到三件事:(1)在 Android 平台上搜索的最终用户体验是什么,(2)活动如何与搜索框架交互以调用或响应搜索,以及(3)在编写可搜索的应用时如何解决设备差异。这也是一个关于 Android 搜索的介绍性章节,它构成了编写你自己的搜索提供者的基础,这将在接下来的两个章节中讨论。

探索 Android 全球搜索

你不能错过 Android 设备上的搜索框;通常显示在首页,如图 10-1 所示。此搜索框也称为快速搜索框(QSB)或搜索小部件。在 Android 的某些版本中,或者取决于设备制造商或运营商,默认情况下,您可能不会在主屏幕上看到这个搜索小部件。本章中的所有图形都是使用 Android 4.0 中的模拟器捕获的。

如果您在主窗格上没有看到搜索小部件,或者如果您之前已经删除了它,就像任何其他 Android 小部件一样,您可以通过转到小部件目录并选择将搜索小部件放置在您可能有的任何主屏幕上来找到它。您也可以通过将搜索小部件拖到垃圾桶来将其从主页中移除。当然,您可以再次从小部件选项卡/屏幕中重新绘制它。

A978-1-4302-4951-1_10_Fig1_HTML.jpg

图 10-1。

Android home page with a search widget

QSB 作为一个窗口小部件的一个副作用是,将焦点转移到主页上的搜索窗口小部件以便输入数据,这基本上会将你带入一个全局搜索对话框(见图 10-2 ),从而你离开主页上下文,进入搜索对话框的上下文。

A978-1-4302-4951-1_10_Fig2_HTML.jpg

图 10-2。

Global search dialog spawned from the home search widget

您也可以通过点击物理搜索键来调用图 10-2 的搜索对话框(如果您的设备上有)。当搜索键可用时,就像 Home 键一样,您可以随时点按搜索键,而不考虑可见的应用。

趋势是没有物理搜索键。搜索关键字可能是虚拟的,或者它甚至可能不是一个全局图标。在这种情况下,您将不得不依赖于搜索小部件。作为一个好的模式,建议应用使用自己的搜索菜单项,或者在应用的操作栏中放置一个搜索视图。简而言之,您可以可靠地假设搜索小部件将始终可用,并且您的应用应该通过提供菜单项(如果有操作栏,它可以变成搜索视图)来显式地提供搜索体验。

棘手的是,虚拟或物理的搜索关键字可能存在,但如果你想利用它,你必须为它编程。当这个键存在时,并且当一个应用处于焦点时,应用就有机会专门化搜索(我们将在后面讨论)。这种定制的搜索称为本地搜索。更一般、普通和非定制的搜索称为全局搜索。

Note

当应用处于焦点时按下搜索键,由应用决定是允许还是不允许本地和全局搜索。在 2.0 之前的版本中,默认操作是允许全局搜索。在 2.2 版和更高版本中,默认行为是禁用全局搜索。这意味着当一个活动成为焦点时,用户必须首先单击 Home 键,然后单击 search 键或 Search 小部件,如果他或她想要进行全局搜索的话。

在 2.2 版本之前,Android 全局搜索框不提供仅在单个应用中搜索数据的选择。所有启用搜索的应用都被视为全局搜索的上下文。

从 2.2 开始,Android 搜索允许用户选择特定的搜索上下文(或应用)。他们可以通过点击全局搜索活动的左侧图标来实现这一点,这将打开提供搜索的单个搜索应用的选择(参见图 10-3 )。

A978-1-4302-4951-1_10_Fig3_HTML.jpg

图 10-3。

Global search dialog with various applications’ search contexts

你在图 10-3 中看到的是 4.0 中默认的搜索应用。该列表可能会随后续版本而变化。搜索上下文“All”的行为很像以前版本的全局搜索。您还可以通过将您的应用注册为可能的可搜索应用之一来创建自己的搜索上下文。我们将在接下来的两章中更详细地讨论这一方面,这里主要关注用户的搜索体验。

让我们暂时回头参考图 10-2 。根据您过去对设备的使用情况,图 10-2 中显示的图像可能会有所不同,因为 Android 会根据您过去的操作来猜测您在搜索什么。当在 QSB 中没有输入文本时,这种搜索模式被称为零建议模式。这是因为可搜索的应用没有被给予任何输入以提供建议。

根据输入的搜索文本,Android 会向用户提供一些建议,如图 10-4 所示。这些建议以列表形式显示在 QSB 下方,称为搜索建议。当你在搜索框中输入每个字母时,Android 会动态替换搜索建议以反映新信息。当没有搜索文本时,Android 会显示所谓的零建议。在图 10-2 中,Android 已经确定没有一个搜索应用主动提出任何零建议。但是,当您开始使用该设备时,您可能会看到一些建议,因为默认的建议提供程序可能会调出您上次的搜索字符串作为可能的建议,供您再次搜索。图 10-4 显示了当你在 QSB 中输入一些文本时出现的搜索建议。

A978-1-4302-4951-1_10_Fig4_HTML.jpg

图 10-4。

Search suggestions

图 10-4 中显示了六个重点区域。这些是:

The left-side search context/application icon   The search box   The search arrow on the right   The list of suggestions   The pencil icon next to each suggestion   The Go button on the keyboard

左侧的搜索上下文/应用图标表示您的搜索上下文。对于“全部”,它通常是设备制造商提供的图标。例如,它可以是谷歌,也可以是必应,或者只是一个标准的搜索图标。

搜索框或 QSB 是一个编辑控件,您可以在其中输入搜索文本。如果搜索上下文是“All”,并且存在一些搜索文本,您可以单击右侧的搜索箭头,它将启动由设备制造商控制的默认搜索,这通常是浏览器搜索。此搜索的输入将是您在搜索框中输入的搜索文本。如果搜索的上下文是一个特定的应用,那么该应用中的一个活动将使用搜索框提供的输入来调用。

在图 10-4 中,建议列表中的每一项也是一组文字。然而,情况并不总是如此。这些建议可以是可以直接调用的应用的名称(稍后如图 10-5 所示)。如图 10-4 所示,当一个建议是一个可在网上搜索的文本时,你可以点击右边的铅笔图标,将建议文本移动到搜索编辑框中,从而对其进行修改。

在图 10-4 中还可以看到,键盘上的 Enter 按钮已经变成了 Go 按钮。在“全部”上下文的全局对话框中,此行为取决于制造商。当搜索上下文是您的应用时,您可以使用相关图标。

让我们再看一遍建议清单。Android 获取到目前为止已经输入到搜索框中的搜索文本,并寻找由可搜索应用提供的所谓的建议提供者。Android 异步并并行地调用每个建议提供者,以一组行的形式检索一组匹配的建议。Android 期望这些行(称为搜索建议)符合一组预定义的列(建议列)。通过探索这些众所周知的专栏,Android 绘制了建议列表。

当搜索文本改变时,Android 重复这个过程。为搜索建议调用所有建议提供者和接收建议的这种交互对于“所有”搜索上下文是真实的。然而,如果您要选择一个特定的搜索应用上下文(如图 10-3 所示),则只有为该应用定义的建议提供程序将被调用来检索搜索建议。

Note

搜索建议集也称为建议光标。这是因为代表建议提供者的内容提供者返回了一个cursor对象。

图 10-5 显示了一个建议列表的例子,其中一些建议指向已经安装在设备上的应用。对于“se”的输入文本,有两个应用与此名称匹配:搜索和设置。各个应用的图标也显示在建议列表中,如左侧所示。如果您选择其中一个应用(搜索或设置),则会调用相应的应用。

A978-1-4302-4951-1_10_Fig5_HTML.jpg

图 10-5。

Search suggestions for applications

为全球搜索启用可搜索的应用

正如我们已经说过的,您可以编写指定搜索特定数据集的应用。这些应用然后需要在它们的清单文件中注册它们将被考虑用于搜索。即使在此之后,最终用户也必须选择这些应用的一个子集或全部,使其成为可搜索上下文的一部分。

要选择或取消选择这些搜索应用,您必须访问搜索设置。要访问搜索设置,请单击主页上的搜索小部件。这将把您带到全局搜索对话框,正如我们到目前为止所展示的那样。现在,单击菜单(虚拟或物理)图标。迄今为止,大多数手机似乎都带有硬件菜单按钮;在平板电脑上,您可能会在屏幕的右上角看到软件菜单图标。该屏幕将显示可用于全局搜索活动的单个菜单项,如图 10-6 所示。

A978-1-4302-4951-1_10_Fig6_HTML.jpg

图 10-6。

Global search activity/dialog menu

如果你点击搜索设置菜单,如图 10-6 所示,你会看到搜索设置选项可用;见图 10-7 。

A978-1-4302-4951-1_10_Fig7_HTML.jpg

图 10-7。

Global search settings

“清除快捷方式”选项会删除搜索历史。相反,选择“可搜索项目”来查看可用的搜索应用集,如图 10-8 所示。

A978-1-4302-4951-1_10_Fig8_HTML.jpg

图 10-8。

Available searchable applications

如图 10-8 所示,一组可搜索的应用可用或安装在设备上。您可以根据需要选中/取消选中这些应用。

这就结束了对 Android 中搜索功能的高级使用的讨论。您还可以在网上搜索每种设备的专用用户指南。这些具体的指南将进一步展示不同设备之间的搜索体验可能会有所不同。我们提供了一个 Android 4.0 Nexus 用户指南的 URL 参考。

我们现在研究活动如何与搜索框架交互。

活动和搜索关键字交互

当用户在一个活动处于焦点时点击搜索键会发生什么?答案取决于很多因素。假设有一个搜索关键字,我们可以探究它对以下类型活动的影响:

  • 不知道当前搜索的常规活动
  • 明确禁止搜索的活动
  • 明确调用全局搜索的活动
  • 使用本地搜索的活动

常规活动中搜索关键字的行为

如果有一个完全不知道正在进行搜索的常规活动,单击搜索键将调用回调函数onSearchRequested() callback。默认情况下,这将调用本地搜索,除非另有定义。因为这是一个没有定义本地搜索的活动,所以单击搜索关键字不会产生任何影响。

Note

在 4.0 模拟器中,默认情况下不启用搜索键。如果您想测试这种行为,请转到 AVD 定义并选择启用物理键盘和 DPAD。

这种行为是有含义的。如果您的意图是在按下搜索键时调用全局搜索,那么您需要覆盖onSearchRequested()并显式调用全局搜索。或者,如果没有物理搜索关键字,您需要在应用中提供一个搜索菜单项。

禁用搜索的活动的行为

通过从 activity 类的onSearchRequested()回调方法返回 false,activity 可以选择完全禁用搜索(全局和局部)。清单 10-1 显示了这种禁用的一个例子。

清单 10-1。以编程方式禁用搜索

//filename: NoSearchActivity.java

public class NoSearchActivity extends Activity

{

.....other code

@Override

public boolean onSearchRequested()

{

return false;

}

}

通过菜单显式调用搜索

除了能够响应搜索关键字,活动还可以选择通过搜索菜单项显式调用搜索。当不再有物理搜索关键字时,这很有用。相反,您需要提供一个显式的菜单项。如果有足够的空间,这个菜单项可以嵌入到动作栏中。当搜索是一个菜单项时,您需要像处理任何其他菜单项一样处理它,并通过自己调用onSearchRequested()显式地调用搜索。

清单 10-2 显示了当一个菜单项被按下时调用搜索的一个活动(SearchInvokerActivity)的源代码。

清单 10-2。通过菜单调用搜索

public class SearchInvokerActivity extends Activity

{

.....other stuff

@Override

public boolean onOptionsItemSelected(MenuItem item)

{

if (item.getItemId() == R.id.mid_si_search)

{

this.onSearchRequested();

return true;

}

return super.onOptionsItemSelected(item);

}

@Override

public boolean onSearchRequested()

{

this.startSearch("test",true,null,true);

return true;

}

}

源代码的关键部分以粗体突出显示。注意菜单 ID ( R.id.mid_si_search)是如何调用函数onSearchRequested()的。这个方法,onSearchRequested(),调用搜索。

基础方法"startSearch"具有以下参数:

  • initialQuery:要搜索的文本。
  • selectInitialQuery:一个布尔值,指示是否突出显示搜索文本。在这种情况下,我们使用“true”来突出显示文本,以便在需要时可以删除它以支持新的文本。
  • appSearchData:要传递给搜索活动的 bundle 对象。在这种情况下,我们不针对任何特定的搜索活动。在清单 10-2 中,我们为这个参数传递了 null。
  • globalSearch:如果为真,则调用全局搜索。如果为假,则调用本地搜索(如果可用);否则,调用全局搜索。

SDK 文档建议调用基类onSearchRequested(),不像我们在清单 10-2 中显示的那样。然而,默认的onSearchRequested()使用“false”作为startSearch()的最后一个参数。根据文档,如果没有可用的本地搜索,这将调用全局搜索。但是,在最近的版本中(从 2.2 开始),不调用全局搜索。这可能是一个 bug,也可能是设计出来的,需要更新文档。

在本例中,我们通过向最后一个参数startSearch()传递“true”来强制进行全局搜索。

了解本地搜索

现在,让我们看看在什么情况下,应用中的搜索关键字或专门的搜索图标不会调用全局搜索,而是调用本地搜索。但是首先,我们必须进一步解释本地搜索。

本地搜索有四个组成部分:

A search dialog (with a QSB in it) or a search view   A search results activity   A searchable info XML file (search configuration)   An invoker activity that starts the search

第一个组件是搜索对话框或搜索视图,其中包含一个搜索框,与全球搜索 QSB 非常相似(如果不是相同的话)。这个 QSB,无论是本地的还是全局的,都提供了一个用于输入文本的编辑文本控件和一个用于单击的搜索图标。当活动在清单文件中声明它需要本地搜索时,将调用本地 QSB 而不是全局。您可以通过查看图 10-10 中的图标和 QSB 中的提示(搜索框内的文本)来区分调用的本地 QSB 和全局。正如您将看到的,这两个值来自一个搜索配置元数据 XML 文件。

本地搜索的第二个组成部分是一个活动,它可以从 QSB(本地或全局)接收搜索字符串,并显示一组结果或与搜索文本相关的任何其他输出。这种活动通常被称为搜索活动或搜索结果活动。

第三个组件是一个名为SearchableInfo的 XML 搜索元数据文件,它定义了 QSB 应该如何表现,以及是否有任何建议提供者与该搜索相关联。

本地搜索的第四个组件是允许调用刚才描述的搜索结果活动的活动(第二个组件)。这个调用活动通常被称为搜索调用者或搜索调用活动。这个搜索调用程序活动是可选的,因为可以通过建议让全局搜索直接调用本地搜索活动(第二个组件)。

在图 10-9 中,你可以看到这四个组件以及它们在上下文中是如何相互作用的。

A978-1-4302-4951-1_10_Fig9_HTML.jpg

图 10-9。

Local search activity interaction

在图 10-9 中,重要的交互显示为带注释的箭头(带圆圈的数字)。以下是更详细的解释:

  • SearchActivity(或搜索结果活动)是安卓搜索中的中枢。搜索元数据 XML 文件和建议提供者都挂起了这个活动。需要在清单文件中将SearchActivity定义为能够接收搜索请求的活动。SearchActivity还使用一个强制性的 XML 文件来声明本地 QSB 应该如何呈现(比如标题、提示等等),以及是否有相关的建议提供者(参见清单 10-6)。在图 10-9 中,你可以看到这是在SearchActivity和两个 XML 文件(清单文件和搜索元数据文件)之间的几个“定义”行。
  • 一旦在清单文件中定义了SearchActivity(参见清单 10-5),清单文件中的Search InvokingActivity通过清单 10-8 )中的元数据定义android.app.default_searchable(表明它与SearchActivity相关联。
  • 有了这两个活动的定义,当SearchInvokingActivity成为焦点时,按下搜索键将调用本地 QSB。你可以在图 10-9 中看到这一点——编号为 1 和 2 的圆圈。您可以通过查看 QSB 的标题和提示来判断所调用的 QSB 是本地 QSB。这两个值是在强制搜索元数据 XML 定义中设置的。一旦通过搜索关键字调用 QSB,您将能够在 QSB 中键入查询文本。这个本地的 QSB,类似于全球的 QSB,能够提出建议。
  • 一旦输入了查询文本并点击了搜索图标,本地 QSB 就会将搜索转移给负责处理它的SearchActivity,比如显示一组结果。

我们通过查看每个相关文件的代码片段来进一步研究这些交互。我们从清单 10-3 开始,SearchActivity的源代码(它也负责接收查询并显示搜索结果)。

清单 10-3。简单的搜索结果活动

//filename: SearchActivity.java

public class SearchActivity extends Activity

{

@Override

protected void onCreate(Bundle savedInstanceState) {

super.onCreate(savedInstanceState);

setContentView(R.layout.search_activity);

.....

//Use the invoking intent to receive the search text

//and do present what is needed

......

return;

}

}

这是一个非常简单的带有文本控件的活动。清单 10-4 显示了它的布局。

清单 10-4。简单搜索结果活动的布局文件

<?xml version="1.0" encoding="utf-8"?>

<LinearLayout

xmlns:android="http://schemas.android.com/apk/res/android

android:orientation="vertical"

android:layout_width="fill_parent" android:layout_height="fill_parent"

>

<TextView

android:id="@+id/text1"

android:layout_width="fill_parent" android:layout_height="wrap_content"

android:text="@string/search_activity_prompt"

/>

</LinearLayout>

我们使用了最简单的搜索活动。在下一章中,您将看到该活动如何检索搜索文本或搜索查询。现在,我们展示 QSB 是如何调用这个活动的。清单 10-5 显示了如何将SearchActivity定义为负责清单文件中搜索结果的搜索活动。

清单 10-5。用搜索元数据在清单文件中定义搜索结果活动

<activity android:name=".SearchActivity"

android:label="Activity/QSB Interaction::Search Results">

<intent-filter>

<action android:name="android.intent.action.SEARCH"/>

<category android:name="android.intent.category.DEFAULT"/>

</intent-filter>

<meta-data android:name="android.app.searchable"

android:resource="@xml/searchable"/>

</activity>

Note

对于搜索活动,需要指定两件事情。活动需要表明它可以响应搜索操作。它还需要指定一个 XML 文件,描述与该搜索活动交互所需的元数据。

清单 10-6 显示了这个SearchActivity的搜索元数据 XML 文件。

清单 10-6。搜索结果活动的 SearchableInfo

<!-- /res/xml/searchable.xml -->

<searchable xmlns:android="http://schemas.android.com/apk/res/android

android:label="@string/search_label"

android:hint="@string/search_hint"

android:searchMode="showSearchLabelAsBadge"

/>

Tip

该 XML 中可用的各种选项记录在位于 http://developer.android.com/guide/topics/search/searchable-config.html 的 SDK 中。

我们将在接下来的两章中讨论更多这些可搜索的 info XML 属性。现在,属性android:label用于标记搜索框。属性android:hint用于在搜索框中放置初始文本(见图 10-10 )。清单 10-6 中的android:searchMode属性表示使用android:label属性来标记搜索框。这个选项在手机上看起来很好,标签在一行,搜索框在下面。但是,在 4.0 中,这个标签与搜索框一致;这看起来很糟糕,因为它占用了搜索框的空间。最好用showSearchIconAsBadge来代替。

现在让我们看看活动如何将这个SearchActivity指定为它的搜索。我们称之为调用激活LocalSearchEnabledActivity。清单 10-7 显示了这个LocalSearchEnbaledActivity的源代码。

清单 10-7。LocalSearchEnabledActivity 源代码

public class LocalSearchEnabledActivity extends Activity

{

@Override

protected void onCreate(Bundle savedInstanceState) {

super.onCreate(savedInstanceState);

setContentView(R.layout.local_search_enabled_activity);

return;

}

@Override

public boolean onCreateOptionsMenu(Menu menu)     {

super.onCreateOptionsMenu(menu);

MenuInflater inflater = getMenuInflater();

inflater.inflate(R.menu.search_invoker_menu, menu);

return true;

}

@Override

public boolean onOptionsItemSelected(MenuItem item)     {

if (item.getItemId() == R.id.mid_si_search)

{

onSearchRequested();

return true;

}

return super.onOptionsItemSelected(item);

}

}

注意,在清单 10-7 中,我们为搜索定义了一个名为R.id.mid_si_search的菜单项。这个菜单项是菜单文件的一部分,在清单 10-7 中用R.menu.search_invoker_menu表示。我们将让您创建一个满足R.id.mid_si_searchR.menu.search_invoker_menu的菜单文件。如果选择了菜单项R.id.mid_si_search,那么我们称之为onSearchRequested()。Android 此时如何知道调用本地搜索?清单文件中的LocalSearchEnabledActivity的定义阐明了 Android 与SearchActivity的关系,如清单 10-8 所示。

清单 10-8。通过元数据绑定搜索结果活动

<activity android:name=".LocalSearchEnabledActivity"

android:label="Activity/QSB Interaction::Local Search">

<meta-data android:name="android.app.default_searchable"

android:value=".SearchActivity" />

</activity>

注意这个LocalSearchEnabledActivity是如何指向中枢销SearchActivity的。SearchActivity反过来告诉本地 QSB 应该如何呈现,搜索文本被传递给那个SearchActivity

Note

您也可以在应用级别使用这个元数据定义,这样所有活动都将继承这个搜索活动。如果需要,单个活动可以进一步覆盖应用级别的搜索活动。以前的版本在这里接受“”来表示全局搜索;该“”规范现在已被弃用。

LocalSearchEnabledActivity处于焦点时,如果你点击设备搜索,或者使用清单 10-7 中的菜单项,两者都将调用本地搜索框(本地 QSB),如图 10-10 所示。

A978-1-4302-4951-1_10_Fig10_HTML.jpg

图 10-10。

Local search QSB

注意这个搜索框上的图标和这个搜索框的提示。看看它们与全局搜索有何不同(见图 10-2 )。该图标来自应用包的图标,如应用包的清单文件中所定义。该提示来自为SearchActivity指定的搜索元数据(searchable.xml,清单 10-6)。现在,如果您在 QSB 中输入文本并点击搜索图标,您将最终调用SearchActivity(参见清单 10-3)。图 10-11 显示了这个SearchActivity的样子。

A978-1-4302-4951-1_10_Fig11_HTML.jpg

图 10-11。

Search results in response to the local search QSB

虽然这个活动不使用任何查询搜索文本来获取结果,但是它演示了如何定义和调用搜索活动。在接下来的两章中,我们将展示这个SearchActivity如何利用搜索查询以及它需要响应的各种与搜索相关的动作。

启用键入搜索

当你在查看如图 10-10 所示的LocalSearchInvokerActivity活动时,有一种方法可以通过键入一个随机的字母(如“t”)来调用搜索。这种模式称为“键入搜索”,因为您键入的任何不受活动处理的键都将调用搜索。

键入搜索的意图是这样的:在任何 Android 活动上,你可以告诉 Android 任何按键都可以调用搜索——除了活动明确处理的按键。例如,如果一个活动处理“x”和“y”,但不关心任何其他键,该活动可以选择调用对任何其他键的搜索,如“z”或“a”。这种模式对于已经显示搜索结果的活动很有用;它可以将按键解释为再次开始搜索的提示。

下面是几行代码,您可以在活动的onCreate()方法中使用它们来实现这种行为(第一行用于调用全局搜索,第二行用于调用局部搜索):

this.setDefaultKeyMode(Activity.DEFAULT_KEYS_SEARCH_GLOBAL);

或者

this.setDefaultKeyMode(Activity.DEFAULT_KEYS_SEARCH_LOCAL);

在操作栏中使用 SearchView

到目前为止,我们已经在主页上看到了一个搜索小部件、一个全局搜索对话框和一个本地搜索对话框。还有另一种方法来利用搜索功能。这是通过一个通过动作栏暴露出来的SearchView来实现的,这是现在手机和平板电脑都推荐的模式。

图 10-12 显示了手机上的搜索视图,以及顶部标题/操作栏中的应用图标。

A978-1-4302-4951-1_10_Fig12_HTML.jpg

图 10-12。

Search view in the action bar of a phone

图 10-13 显示了带有搜索视图的操作栏在平板电脑上的外观。

A978-1-4302-4951-1_10_Fig13_HTML.jpg

图 10-13。

Search view in the action bar of a tablet

将搜索视图小部件定义为菜单项

要定义一个显示在活动操作栏中的搜索视图,您需要在一个菜单 XML 文件中定义一个菜单项,如清单 10-9 所示。

清单 10-9。搜索视图菜单项定义

<item android:id="@+id/menu_search"

android:title="Search"

android:showAsAction="ifRoom"

android:actionViewClass="android.widget.SearchView"

/>

清单 10-9 中的关键元素是指向android.widget.SearchViewactionViewClass属性。其他属性仅仅是通常的菜单项属性。

标识搜索视图小部件的搜索目标

到目前为止,您的操作栏中已经有了搜索视图,并且您已经有了可以响应搜索的活动(SearchActivity)。我们需要将这两部分结合在一起,这是用 Java 代码完成的。作为设置菜单的一部分,您需要在搜索调用活动的onCreateOptions()回调中这样做。清单 10-10 中的函数可以从onCreateOptions()中调用,以便将搜索视图小部件和搜索结果活动联系起来。

清单 10-10。将搜索视图小部件绑定到搜索结果活动

private void setupSearchView(Menu menu)

{

//Locate the search view widget

//as indicated by the menu item of listing 10-9

SearchView searchView =

(SearchView) menu.findItem(R.id.menu_search).getActionView();

if (searchView == null)

{

Log.d(tag, "Failed to get search view");

return;

}

//setup searchview

SearchManager searchManager =

(SearchManager) getSystemService(Context.SEARCH_SERVICE);

ComponentName cn =

new ComponentName(this,SearchActivity.class);

SearchableInfo info =

searchManager.getSearchableInfo(cn);

if (info == null)

{

Log.d(tag, "Failed to get search info");

return;

}

searchView.setSearchableInfo(info);

// Do not iconify the widget; expand it by default

searchView.setIconifiedByDefault(false);

}

为了练习这段代码,我们在本章的示例项目中包含了一个名为ActionBarSearchActivity的活动。这就是我们在图 10-12 中展示的活动。

清单 10-9 中的菜单项可能在活动的动作栏中没有空间。在这种情况下,它仅仅是一个菜单项,你必须显式地调用onSearchRequested()来调用搜索对话框,而不是搜索视图。你可以看到这个例子的代码清单 10-7。

由于篇幅限制,我们没有包括测试本章中介绍的概念所需的每个文件。你可以下载本章的专用项目;我们在参考资料中给出了该项目的 URL。

参考

以下是我们在撰写本章时发现的有价值的资源列表。

摘要

这是关于 Android 搜索的介绍性章节,涵盖了用户体验。我们已经展示了如何使用搜索关键字来调用搜索。我们已经展示了创建能够响应搜索的搜索结果活动的基础。我们解释了如何将应用或搜索调用活动与搜索结果活动联系起来。我们已经展示了搜索结果活动如何通过可搜索的 info XML 文件定义其搜索需求。我们已经演示了如何在动作栏中使用 searchview。我们还列出了在设备上实现搜索功能的各种方式,以及如何编写代码来涵盖所有这些情况。

接下来的两章进一步探讨了检索搜索文本和产生结果的搜索结果活动。我们还将介绍如何编写简单和定制的建议提供程序,在搜索对话框中提供搜索建议。

复习问题

以下问题是你在本章所学内容的里程碑。

What is QSB?   What is a search widget, a search dialog, and a search view?   What is searchable info?   What is a global search and a local search?   What is a search results activity?   How do you attach a search results activity to an application or an activity?   What is search metadata?   What are search suggestions? What are suggestion columns? What is a suggestion cursor?   What is a suggestions provider? What is zero suggestions mode?   How do you craft a search so that it works well when the phyical search key is present and when it is not?   How do you craft a search so that it works well on both tablets and phones?   How does a search interact with the action bar?   How do you attach searchable info to a search view in the action bar?   How do you define the search icon for an action bar?   What is the class name you would use for a search view in the menu.xml?   How do you enable the Search key in the emulator if it is disabled by default?

十一、简单搜索建议供应器

Abstract

我们在第十章中探讨了 Android 搜索的用户体验。在那里,我们解释了搜索应用如何响应用户输入的搜索文本提供建议。搜索应用中响应建议的组件称为建议提供者。本章介绍了建议提供者,并探索了一个名为SearchRecentSuggestionsProvider.的预建建议提供者。在下一章中,我们将向您展示如何从头开始编写一个定制的搜索建议提供者。

我们在第十章中探讨了 Android 搜索的用户体验。在那里,我们解释了搜索应用如何响应用户输入的搜索文本提供建议。搜索应用中响应建议的组件称为建议提供者。本章介绍了建议提供者,并探索了一个名为SearchRecentSuggestionsProvider的预构建建议提供者。在下一章,我们将向你展示如何从头开始编写一个定制的搜索建议提供者。

当你写一个建议提供者的时候,有三个主要部分需要关注。第一个是建议提供者 Java 类,负责将这些建议返回给 Android 搜索。第二个是搜索结果活动(类似于第十章中讨论的活动),它接受一个查询或建议并将其转化为搜索结果。在本章中,我们交替使用搜索活动和搜索结果活动这两个术语。

编写建议提供者涉及的第三部分是元数据 XML 文件(也在第十章的中介绍),它是在搜索活动的上下文中定义的。这个搜索元数据 XML 文件也被称为searchableinfo XML 文件,因为在 Java 源代码中,这个文件通常被称为“在SearchActivity object上获取和设置searchableinfo

我们描述了每个搜索应用的职责,并通过源代码片段展示了如何实现它们以形成一个简单的建议提供者。在本章末尾的参考资料中,我们还提供了一个下载完整工作应用的链接。

规划简单的建议提供者

所有搜索建议供应器的目标都是一样的:从 Android 搜索工具(快速搜索框,也称为 QSB,在第十章中介绍)接收部分或全部搜索文本,并以建议光标的形式回复一组建议(一组搜索建议行)。建议提供者是内容提供者。因此,实现一个建议提供者本质上与实现一个内容提供者是一样的,尽管有一组固定的输入和输出。

要演示一个建议提供者,最简单的方法是扩展预构建的SearchRecentSuggestionsProviderSearchRecentSuggestionsProvider允许您重放(或建议)之前从 QSB 提交给搜索结果活动的查询。一旦这些查询被搜索结果活动保存,当用户开始在 QSB 中键入文本时,它们通过建议提供者被提示回 QSB。

SearchRecentSuggestionsProvider从一组空泛的建议开始。用户在搜索框中键入一些文本。该文本提供给查找结果的搜索活动。搜索活动还保存搜索文本作为下次可能的建议。这个保存操作本质上是保存到SearchRecentSuggestionsProvider;中,它允许建议提供者将那些先前的搜索表示为建议。但是,这是专门针对SearchRecentSuggestionsProvider的协议。在下一章中,当你学习编写一个定制的建议提供者时,你可以自由地定义你将如何用建议来响应。

虽然这个协议已经由SearchRecentSuggestionsProvider实现了,但是您需要初始化和专门化SearchRecentSuggestionsProvider来满足您特定的搜索需求。在派生的建议提供程序中,您通过指示搜索文本的哪些部分需要重放来初始化基本的SearchRecentSuggestionsProvider

在我们简单的演示应用中,我们使用了一个最小的搜索结果活动,它只是一个文本视图,以显示搜索结果活动已经被调用。在搜索结果活动中,我们还将向您展示检索和保存搜索查询的方法,以便下次搜索建议提供者可以使用它们。

一旦应用完成,我们应该在本地和全局 QSB 中看到作为建议提示的前面的查询。在本章的最后一节,我们用图表展示了这种用户体验。

实现简单建议提供程序

因为简单的建议提供者是从SearchRecentSuggestionsProvider派生出来的,所以大部分职责都由这个基类来处理。因为建议提供者是内容提供者,所以您需要一个唯一的字符串来标识该内容提供者的权限。仔细阅读内容供应器,因为我们在本章中假设你已经知道内容供应器。内容供应器的权限是它的 web 类调用 URL。

base SearchRecentSuggestionsProvider需要用这个应用唯一的授权字符串进行初始化。Android search 然后基于唯一的内容供应器 URL 调用这个建议供应器。一旦使用这个对基本SearchRecentSuggestionsProvider类的简单调用实现了派生的建议提供者,就需要在清单文件中将其配置为具有权限的常规内容提供者。它(间接地通过可搜索的元数据 XML 文件)与搜索活动联系在一起。搜索活动定义引用可搜索的 XML 文件,该文件又指向建议提供者。

现在,我们将通过带注释的代码片段详细展示所有这些步骤。完整的源代码和工作应用可以从本章末尾参考资料中的 URL 下载。因为我们从SearchRecentSuggestionsProvider继承,简单建议提供者的源代码将会非常简单,如清单 11-1 所示。

清单 11-1 . SimpleSuggestionProvider.java

//SimpleSuggestionProvider.java

public class SimpleSuggestionProvider

extends SearchRecentSuggestionsProvider {

final static String AUTHORITY =

"com.androidbook.search.simplesp.SimpleSuggestionProvider";

final static int MODE =

DATABASE_MODE_QUERIES | DATABASE_MODE_2LINES;

public SimpleSuggestionProvider() {

super();

setupSuggestions(AUTHORITY, MODE);

}

}

清单 11-1 中有几件值得注意的事情:

The parent class is initialized.   The base provider is set up with an authority and mode, indicating what portions of a search text need to be remembered.

建议内容供应器授权字符串必须是唯一的。清单 11-1 中的惟一授权字符串需要与清单文件中的内容提供者定义相匹配。这个SimpleSuggestionProvider(清单 11-1)和任何其他内容提供者一样在清单文件中注册。清单 11-2 显示了这个定义。

清单 11-2。清单文件中的 SimpleSuggestionProvider

<?xml version="1.0" encoding="utf-8"?>

<manifest xmlns:android=http://schemas.android.com/apk/res/android

<application...>

<provider android:name=".SimpleSuggestionProvider"

android:authorities

="com.androidbook.search.simplesp.SimpleSuggestionProvider" />

</application>

</manifest>

注意简单建议提供者的权限在源代码(清单 11-1)和清单文件(清单 11-2)中是如何匹配的。在这两种情况下,这种权威的价值是

com.androidbook.search.simplesp.SimpleSuggestionProvider

Android 提供的SearchRecentSuggestionsProvider工具的一个关键功能是存储/重放来自数据库的查询,以便它们可以作为未来的建议。一个建议有两个文本字符串(参见本章后面的图 11-6 )。一个字符串是查询,另一个字符串是显示在建议显示项中的描述行。只有第一个字符串是必需的。当您使用SearchRecentSuggestionsProvider来重放这些字符串时,您需要告诉它您是想要使用一个字符串还是两个字符串。

为了适应这种情况,基本建议提供程序支持两种模式(模式位)。

  • DATABASE_MODE_QUERIES(二进制值 1)
  • DATABASE_MODE_2LINES(二进制值 2)

当搜索活动被调用来响应查询时,它负责保存这两个字符串值。搜索活动将调用清单 11-3 所示的方法来存储查询字符串。(我们将在讨论搜索活动时更详细地介绍这一点。).

清单 11-3。如何保存最近的查询

pulbic class SearchRecentSuggestions

{

...

public void saveRecentQuery (String queryString, String line2);

...

}

Note

SearchRecentSuggestions是一个 SDK 类,当我们讨论搜索活动代码(清单 11-4)时,我们会涉及更多。

在清单 11-3 中,queryString是用户输入的字符串。该字符串显示为建议,如果用户单击建议,该字符串将被发送到搜索活动(作为新的搜索查询)。

以下是 Android 文档对line2论点的评论:

如果您已经用 DATABASE _ MODE _ 行配置了最近建议提供程序,则可以在此处传递第二行文本。它将以较小的字体显示在主要建议的下方。键入时,任何一行文本中的匹配项都将显示在列表中。如果您没有配置两行模式,或者如果给定的建议没有任何附加文本要显示,您可以在此处传递 null。

您可以在以下网址查看 Android 开发人员参考

http://developer.android.com/reference/android/provider/SearchRecentSuggestions.html

在我们的示例中,我们希望保存查询和在建议中与查询一起显示的有用文本。或者至少,我们希望在建议的底部显示有用的文本,如 SSSP(搜索简单建议供应器),以便当来自该供应器的建议出现在全局搜索中时,我们可以看到是什么应用负责搜索建议中的文本。

指定该模式以便保存建议和有用文本的方法是设置两个模式位,如SimpleSuggestionProvider的源代码所示(见清单 11-1)。如果您只是将模式位设置为保存两行,您将得到一个无效参数异常。模式位必须至少包括DATABASE_MODE_QUERIES位。本质上,你需要做一个按位或运算,这样模式在本质上是互补的,而不是排他的。

Tip

您可以通过 http://developer.android.com/reference/android/provider/SearchRecentSuggestions.html 了解更多关于这个预制建议供应器的信息。

搜索结果活动

Android search (QSB)通过查询字符串调用搜索结果活动。反过来,搜索活动需要从意图中读取这个查询字符串,执行必要的操作,并可能显示结果。因为搜索结果活动是一个活动,所以它可能被其他意图和其他动作调用。因此,检查调用它的意图动作是一个好的做法。在我们的例子中,当 Android search 调用搜索结果活动时,与调用意图一起传递的action属性是 ACTION_SEARCH。

在某些情况下,搜索结果活动可以调用自身。当这有可能发生时,您应该将搜索活动启动模式定义为一个singleTop。该活动还需要处理onNewIntent()的触发。(我们将在下一节“理解 onCreate()和 onNewIntent()”中介绍这一点。)对于查询字符串,我们只记录它。一旦查询被记录,我们需要将它保存在SearchRecentSuggestionsProvider中,这样它就可以作为未来搜索的建议。

现在让我们看看执行这些职责的搜索结果或搜索活动类的源代码。清单 11-4 显示了这个SearchActivity类的源代码。

清单 11-4。搜索活动的 Java 代码

public class SearchActivity extends Activity

{

private final static String tag ="SearchActivity";

@Override

protected void onCreate(Bundle savedInstanceState) {

super.onCreate(savedInstanceState);

setContentView(R.layout.layout_search_activity);

//this.setDefaultKeyMode(Activity.DEFAULT_KEYS_SEARCH_GLOBAL);

this.setDefaultKeyMode(Activity.DEFAULT_KEYS_SEARCH_LOCAL);

// get and process search query here

final Intent queryIntent = getIntent();

final String queryAction = queryIntent.getAction();

if (Intent.ACTION_SEARCH.equals(queryAction))

{

Log.d(tag,"new intent for search");

this.doSearchQuery(queryIntent);

}

else {

Log.d(tag,"new intent NOT for search");

}

return;

}

@Override

public void onNewIntent(final Intent newIntent)

{

super.onNewIntent(newIntent);

Log.d(tag,"new intent calling me");

// get and process search query here

// Notice we are using the newIntent and not the one

// from the activity.

final Intent queryIntent = newIntent;

final String queryAction = queryIntent.getAction();

if (Intent.ACTION_SEARCH.equals(queryAction))

{

this.doSearchQuery(queryIntent);

Log.d(tag,"new intent for search");

}

else {

Log.d(tag,"new intent NOT for search");

}

}

private void doSearchQuery(final Intent queryIntent)

{

final String queryString =

queryIntent.getStringExtra(SearchManager.QUERY);

// Record the query string in the recent

// queries suggestions provider.

SearchRecentSuggestions suggestions =

new SearchRecentSuggestions(this

SimpleSuggestionProvider.AUTHORITY

SimpleSuggestionProvider.MODE);

String helpfullHint = "SSSP";

suggestions.saveRecentQuery(queryString, helpfullHint);

}

}

清单 11-5 快速记录了清单 11-4 中搜索结果活动的布局文件。

清单 11-5。搜索活动布局文件

<?xml version="1.0" encoding="utf-8"?>

<!-- /res/layout/layout_search_activity.xml -->

<LinearLayout

xmlns:android="http://schemas.android.com/apk/res/android

android:orientation="vertical"

android:layout_width="fill_parent"    android:layout_height="fill_parent"

>

<TextView

android:id="@+id/text1"

android:layout_width="fill_parent"  android:layout_height="wrap_content"

android:text="Test Search Activity view"

/>

</LinearLayout>

清单 11-6 显示了如何在清单文件中定义SearchActivity活动,并将其绑定到它的searchableinfo xml 文件。

清单 11-6。定义 SearchActivity 及其 SearchableInfo

<activity android:name=".SearchActivity"

android:label="SSSP: Search Activity"

android:launchMode="singleTop">

<intent-filter>

<action android:name="android.intent.action.SEARCH" />

<category android:name="android.intent.category.DEFAULT" />

</intent-filter>

<meta-data android:name="android.app.searchable"

android:resource="@xml/searchable" />

</activity>

<meta-data android:name="android.app.default_searchable"

android:value=".SearchActivity" />

在第十章中,我们介绍了如何在清单文件中定义搜索活动。所有这些方面都存在于搜索活动清单文件中,如清单 11-6 所示。重申一下,我们将搜索活动定义为能够响应搜索动作。然后我们附加了一个可搜索的 XML 文件来定义搜索的性质。(我们将在本章后面的“搜索元数据”一节中详细介绍这个 XML 文件。)我们还指出,在应用级别,这(清单 11-4 的SearchActivity)是指定的搜索活动。这意味着在此应用的任何活动中进行搜索都会以此搜索活动为目标。

让我们回到清单 11-4 中搜索活动的实现源代码,看看搜索活动是如何检查动作和检索查询字符串的。

使用搜索查询字符串

搜索活动代码通过查看调用意图并将其与constant intent.ACTION_SEARCH进行比较来检查调用动作。如果动作匹配,那么它调用doSearchQuery()函数。

doSearchQuery()函数中,搜索活动使用 intent extra 检索查询字符串。清单 11-7 显示了这段代码:

清单 11-7。如何从搜索意图中读取查询字符串

final String queryString =

queryIntent.getStringExtra(SearchManager.QUERY);

注意,额外意图被定义为SearchManager.QUERY。当您阅读本章时,您将会看到在SearchManager API 参考中定义的一些额外功能。(它的 URL 包含在本章末尾的参考资料中。)

了解 onCreate()和 onNewIntent()

当用户在搜索框中输入文本并点击建议或前进箭头时,搜索活动由 Android 启动。这导致创建搜索活动并调用其onCreate()方法。传递给这个onCreate()的意图将把动作设置为ACTION_SEARCH

有时候,活动并没有被创建,而是通过onNewIntent()方法传递了新的搜索标准。这是怎么发生的?回调onNewIntent()与一个活动的发起方式密切相关。

当一个活动被设置为singleTop(见清单 11-6 中SearchActivity的清单文件定义)时,它指示 Android 不要创建一个新的活动,当那个活动已经在栈顶的时候。在这种情况下,Android 调用onNewIntent()而不是onCreate()。这就是为什么在清单 11-4 的SearchActivity源代码中,我们有两个地方来检查意图。注意,在onNewIntent()方法中,您应该使用新的意图来检索查询,而不是开始活动的意图。

测试 onNewIntent()

一旦你实现了onNewIntent(),你开始注意到它不会在正常的流程中被调用。这就提出了一个问题,什么时候搜索活动会在栈顶?这种情况一般不会发生。

原因如下:假设一个搜索调用活动 A 调用了一个搜索,这导致搜索活动 B 出现。然后,活动 B 显示结果,用户点击 Back 按钮返回,此时,作为用户搜索活动的活动 B 不再位于栈顶,活动 A 位于栈顶。或者用户可以点击主页键并使用主页屏幕上的全局搜索,在这种情况下,主页活动是顶部的活动。

搜索活动可以在上面的一种方式是这样的:假设由于搜索,活动 A 导致活动 B。如果活动 B 定义了搜索类型,那么当您关注活动 B 时,搜索将使用新的标准再次调用活动 B。清单 11-4 显示了我们是如何设置搜索类型来演示的。下面是代码:

this.setDefaultKeyMode(Activity.DEFAULT_KEYS_SEARCH_LOCAL);

保存搜索查询

我们已经讨论了搜索活动如何需要保存它遇到的查询,以便它们可以通过建议提供者作为建议被回放。清单 11-8 是保存这些查询的代码段:

清单 11-8。为将来的建议保存最近的搜索查询

final String queryString =

queryIntent.getStringExtra(SearchManager.QUERY);

// Record the query string in the

// recent queries suggestions provider.

SearchRecentSuggestions suggestions =

new SearchRecentSuggestions(this

SimpleSuggestionProvider.AUTHORITY

SimpleSuggestionProvider.MODE);

String helpfullHint = "SSSP";

suggestions.saveRecentQuery(queryString, helpfullHint);

从这段代码中,您可以看到 Android 将查询信息作为额外的(SearchManager。查询)通过意向。

一旦有了可用的查询,就可以使用 SDK 实用程序类SearchRecentSuggestions来保存查询和提示(“SSSP”),方法是实例化一个新的 suggestions 对象并要求它保存。因为我们已经选择使用两行模式和查询模式,所以saveRecentQuery的第二个参数是 SSSP(同样,这代表简单搜索建议提供者)。您将看到此帮助文本出现在该供应器的每个建议的底部。(参见本章后面的图 11-6 )。

现在,我们来看看搜索元数据定义(也称为searchableinfo),其中我们将搜索活动与搜索建议提供者联系起来。(另一方面,请参见清单文件清单 11-6,其中我们将搜索元数据与搜索活动联系在一起。)

探索搜索元数据

Android 中搜索的定义是从一个搜索活动开始的。首先在清单文件中定义一个搜索活动。作为这个定义的一部分,您告诉 Android 在哪里可以找到搜索元数据 XML 文件。参见清单 11-6,其中定义了我们的搜索活动以及搜索元数据 XML 文件的路径(searchable.xml)。清单 11-9 显示了相应的搜索元数据 XML 文件。

清单 11-9。SimpleSuggestionProvider 搜索元数据

<!-- filename: /res/xml/searchable.xml -->

<searchable

xmlns:android="http://schemas.android.com/apk/res/android

android:label="@string/search_label"

android:hint="@string/search_hint"

android:searchMode="showSearchLabelAsBadge"

android:queryAfterZeroResults="true"

android:includeInGlobalSearch="true"

android:searchSuggestAuthority=

"com.androidbook.search.simplesp.SimpleSuggestionProvider"

android:searchSuggestSelection=" ? "

/>

让我们研究一下清单 11–9 中的一些关键属性。

首先,在清单 11-9 中,属性searchSuggestAuthority指向建议提供者的权限,如该建议提供者的清单文件中所定义的那样(参见清单 11-2)。属性includeInGlobalSearch告诉 Android 使用这个建议提供者作为全局 QSB 的来源之一。

属性queryAfterZeroResults指示如果当前字母集没有返回任何结果,QSB 是否应该向建议提供者发送更多的字母。因为我们正在进行测试,所以我们不想遗漏任何细节,所以我们将该属性设置为 True,这样我们就给了提供者做出响应的所有机会。

当您从最近的搜索建议提供者派生时,属性searchSuggestSelection,总是由?表示的字符串,该字符串作为内容提供者query方法的selection字符串(where 子句)传递给建议提供者。通常,这表示任何内容提供者的 select 语句中的“where”子句。

具体到建议提供者,当有为searchSuggestSelection(作为一个协议)指定的值时,Android 将搜索查询字符串(在 QSB 中输入)作为内容提供者查询方法的 select arguments 数组中的第一个条目进行传递。

响应这些细微差别(提供者如何在内部使用这些字符串)的代码隐藏在最近的搜索建议提供者中;我们无法向您展示这些参数是如何在内容提供者的query方法中使用的。(我们将在下一章更详细地讨论这一点,在这一章中,你将看到该字符串的全貌。”?.")事实上,这个字符串不太可能用于缩小SearchRecentSuggestionsProvider的结果范围,因为它没有限定任何要查询的字段,比如“someid ==?”很可能它的存在促使 Android 将 QSB 字符串作为第一个参数传递给提供者。并且 SDK 搜索建议提供者仅仅依靠这个协议来接收由内容提供者query()方法的选择参数列表提供的便利数组中的 QSB 字符串。

现在让我们来讨论一个搜索调用者活动,我们将使用它作为这个应用的主要入口点。这个主活动允许我们测试本地搜索。

搜索调用者活动

到目前为止,我们已经开发了两个关键素材:一个搜索建议供应器和一个搜索活动。然后我们通过清单 11-6 中的searchableinfo.xml.将它们联系在一起,我们还指出SearchActivity是这个应用中搜索的目标活动。

Note

当您在检查SearchActivity上的结果时,您可以通过单击搜索关键字来利用应用级的搜索活动来调用onNewIntent()。如果您只为单个活动而不是整个应用定义默认搜索,情况就不一样了。

现在有了这些(搜索活动、建议提供者、searchableinfo xml和清单文件中的指定条目),您可以通过使用任何简单的活动作为搜索调用程序活动来测试搜索功能。在下一节中,我们将使用为本章开发的示例程序概述这种搜索体验。

简单建议供应器用户体验

示例应用包含一个非常简单的主活动,如图 11-1 所示。这个活动非常简单,不知道任何搜索上下文。

A978-1-4302-4951-1_11_Fig1_HTML.jpg

图 11-1。

A simple main activity (enabled for local search)

然而,因为我们已经将我们的搜索活动(见清单 11-6)定义为应用的全局活动,如果我们在图 11-1 中简单的主活动处于焦点时点击搜索键,我们将看到本地搜索被调用,如图 11-2 所示。

A978-1-4302-4951-1_11_Fig2_HTML.jpg

图 11-2。

Local search QSB shown on top of the main activity Note

本章中的所有数字都是使用 Android 2.3 捕获的。我们也在 4.0 中测试了该应用,我们没有发现明显的差异。我们还没有更新 4.0 的数据,但是本章的应用下载使用的是 Android 4.0。

如你所见,图 11-2 中没有任何建议,因为到目前为止我们还没有搜索任何建议。您还可以看到这是一个本地搜索—搜索的标签和提示与我们在搜索元数据 XML 文件中指定的一样。

让我们继续搜索字符串test1。这将带您进入搜索活动屏幕,如图 11-3 所示。

A978-1-4302-4951-1_11_Fig3_HTML.jpg

图 11-3。

Local search results activity

正如您在清单 11-4 的SearchActivity源代码中看到的,SearchActivity在屏幕上并没有做什么引人注目的事情,但是在幕后,它将查询字符串保存在数据库中。现在,如果您导航回主屏幕(通过按下返回按钮)并再次调用搜索,您将看到如图 11-4 所示的屏幕,其中搜索建议由之前的查询文本填充。你也可以在图 11-4 中看到建议“SSSP”的底部这在这里似乎无关紧要,因为这是一个本地搜索,并且清楚地表明它来自我们的应用。然而,当这个字符串“SSSP”作为全局搜索建议的一部分显示时,它将区分test1搜索字符串。

A978-1-4302-4951-1_11_Fig4_HTML.jpg

图 11-4。

Retrieved local suggestion

这是了解如何调用onNewIntent()的好时机。当你在进行搜索活动时(图 11-3 ,你可以键入一个类似 t 的字母,它会使用 type-to-search 再次调用搜索,你会在调试日志中看到onNewIntent()被调用。

让我们看看我们需要做些什么来让这些建议出现在全球 QSB 中。因为我们在searchable.xml中启用了includeInGlobalSearch,所以您应该也能在全球 QSB 中看到这些建议。但是,在此之前,您需要为全球 QSB 建议启用该应用,如图 11-5 所示。

A978-1-4302-4951-1_11_Fig5_HTML.jpg

图 11-5。

Enabling a Searchable application

在第十章中,我们向你展示了如何到达这个屏幕。我们编写的简单定制建议提供程序现在可以在可搜索应用列表中作为“SSSP:搜索活动”使用这个文本字符串来自于SearchActivity的活动名称(参见清单 11-6)。选择完成后,您可以看到如图 11-6 所示的全局搜索,与我们的建议供应器合作。

A978-1-4302-4951-1_11_Fig6_HTML.jpg

图 11-6。

Global suggestions from simple suggestion provider

在图 11-6 的全局搜索中,如果你键入一个类似 t 的文本,它会调出本节建议提供者的建议。但是,您可能希望用一个长词来测试它,比如 testsuperb,这样通用工具就不会有其他以 t 开头的建议,或者您必须一直向下滚动。或者,您可以筛选并明确选择该供应器进行搜索。在图 11-6 中,当您通过全局搜索导航到特定项目时,您将看到如图 11-3 所示的本地搜索活动。

参考

在您学习本章时,以下资源将会非常有用。

摘要

您已经了解了如何使用内置的RecentSearchSuggestionProvider来记住特定于您的应用的搜索。使用这种方法,用最少的代码,您应该能够进行本地搜索,并使它们作为建议甚至在全局上下文中可用。我们还解释了如何利用onNewIntent()回调来利用singleTop活动。

然而,这个简单的练习并没有向您展示如何从头开始编写建议提供者。更重要的是,对于建议提供者如何返回一组建议以及这个建议集中有哪些列可用,我们没有给你任何提示。为了理解这一点以及更多,我们需要从头实现一个定制的建议提供者。

那要等到下一章了。

复习问题

以下问题可以作为你在本章所学内容的里程碑:

What are suggestion providers?   How do you code a very simple suggestion provider?   What is SearchRecentSuggestionProvider?   How do you save recent suggestions so that the SearchRecentSuggestionProvider could use them?   What are the basic attributes of searchableinfo xml that are relevent to a SearchRecentSuggestionProvider?   How do you test singleTop and onNewIntent()?   How do you enable suggestion providers to show up in a global search?

十二、自定义搜索建议供应器

Abstract

安卓搜索太灵活,不能不定制。因为我们在上一章中使用了一个预构建的建议提供者,所以建议提供者的许多可定制特性都隐藏在SearchRecentSuggestionsProvider中,没有讨论。在本章中,我们将通过实现一个名为SuggestUrlProvider.的定制建议提供者来探究这些细节

安卓搜索太灵活,不能不定制。因为我们在上一章中使用了一个预构建的建议提供者,所以建议提供者的许多可定制特性都隐藏在SearchRecentSuggestionsProvider中,没有讨论。在本章中,我们将通过实现一个名为SuggestUrlProvider的定制建议提供者来探究这些细节。

我们从解释SuggestUrlProvider如何工作开始。我们将为您提供实现提供程序所需的所有代码片段,这些代码片段将提供如何构建定制建议提供程序的详细思路。我们还将通过在一个示例应用中展示它,向您展示如何使用这个定制建议提供程序。

规划定制建议提供者

我们将致电我们的定制建议供应器SuggestURLProvider。该提供程序的目标是监视输入到 QSB 中的内容。如果搜索查询具有类似“great . m”(后缀)的文本。m”被选择来表示含义),我们的定制建议提供者将把查询的第一部分解释为一个单词,并建议一个基于互联网的 URL,可以调用该 URL 来查找该单词的含义。

对于使用这种模式识别的每个单词,SuggestURLProvider提供了两个 URL。第一个 URL 允许用户使用 http://www.thefreedictionary.com 搜索单词,第二个 URL 使用 http://www.google.com 。选择这些建议中的一个会将用户直接带到这些站点中的一个。

实现自定义 SuggestUrlProvider

你已经在第十一章中看到,从本质上来说,建议提供者就是内容提供者。因此,实现定制建议提供者与实现内容提供者是一样的。我们将介绍实现内容提供者的步骤,并向您展示如何以不同的方式使其成为定制的建议提供者。如果你对内容供应器有一个大致的了解,这肯定会有所帮助,这样你就可以很快找到相似之处。但是,即使你没有这方面的知识,你也应该能够理解这里提出的论点和代码——在这个过程中,你也可以了解内容供应器!

实现定制建议提供者包括实现由内容提供者定义的虚拟方法,尤其是query()方法,因为我们扩展了基本提供者类。当您扩展基本的ContentProvider类方法时,以下细节很重要:

Understand the URIs honored by your suggestion provider.   Understand how to implement the getType() method that is expected to return MIME types for the search results. This involves recognizing the incoming URIs from item 1 above through the URIMatcher class. Then based on the URI type use the SearchManager defined MIME types for identifying the MIME type of the search results.   Understand how to implement the query() method that is expected to return the search results. This involves (a) recognizing the incoming URIs through the URIMatcher class, (b) understanding how to retrieve and use the query search text that was entered, and (c) returning a collection of rows with the required columns as defined by the SearchManager.   Understand how to construct the necessary columns required by the SearchManager into a cursor as defined by the suggestion provider’s suggestion cursor contract.

我们现在按照与上面大致相同的顺序介绍这些细节,并在需要的地方提供源代码片段。最后,我们展示了计划中的SuggestUrlProvider的完整源代码。

了解建议提供者 URIs

内容供应器的核心是它的一组 URIs,用于读取和更新内容供应器。从这个意义上说,内容供应器是拥有内容或数据的网站。URI 是读取或更新内容的一种方式。

Android 搜索使用两种 URIs 来调用建议提供者。第一个叫做搜索 URI。这个 URI 被建议提供者用来提供建议集。对此搜索 URI 的响应需要一个或多个搜索建议行,每行包含一组包含搜索建议数据的众所周知的列。这个搜索 URI 可以采用清单 12-1 所示的两种形式中的任何一种。

清单 12-1。建议供应器搜索 URIs 的结构

content://com.androidbook.search.suggesturlprovider/search_suggest_query

content://com.androidbook.search.suggesturlprovider/search_suggest_query/<your-query>

在这个搜索 URI 中,第一部分,如清单 12-2 所示,是内容提供者权威,这是我们的建议提供者所独有的。

清单 12-2。我们客户建议提供者的基本权威

com.androidbook.search.custom.suggesturlprovider

第二个 URI 被称为建议捷径 URI。该 URI 用于更新先前缓存的建议。这些缓存的建议被称为 Android 建议快捷方式。对这种类型的 URI 的响应需要是包含相同的一组众所周知的搜索建议列的单行。这个建议的快捷方式 URI 可以采用清单 12-3 所示的两种形式中的任何一种。

清单 12-3。建议快捷方式 URIs 的结构

content://com.androidbook.search.suggesturlprovider/search_suggest_shortcut

content://com.androidbook.search.suggesturlprovider/search_suggest_shortcut/<shortcut-id>

这个建议快捷方式 URI 是由 Android 发出的,当它试图确定它缓存的快捷方式是否仍然有效时。如果提供程序返回单行,它将用新的快捷方式替换当前快捷方式。如果供应器发送一个 null,那么 Android 认为这个建议不再有效。

Android 中的SearchManager类定义了两个常数来表示这些区分它们的 URI 段(search_suggest_searchsearch_suggest_shortcut)。这些常量如清单 12-4 所示。

清单 12-4。SearchManager 建议 URI 定义

SearchManager.SUGGEST_URI_PATH_QUERY

SearchManager.SUGGEST_URI_PATH_SHORTCUT

提供者有责任在其query()方法中识别这些传入的 URIs。为了将传入的 URI 字符串与上面的常量之一进行比较,Android 提供了一个名为UriMatcher的实用程序类。清单 12-5 显示了如何初始化UriMatcher,以便它可以在以后用于识别输入的搜索 URI 结构。

清单 12-5。用 SearchManager 构建一个 UriMatcher 建议 URI 定义

private static UriMatcher buildUriMatcher(String AUTHORITY)

{

UriMatcher matcher =

new UriMatcher(UriMatcher.NO_MATCH);

matcher.addURI(AUTHORITY

SearchManager.SUGGEST_URI_PATH_QUERY, SEARCH_SUGGEST);

matcher.addURI(AUTHORITY

SearchManager.SUGGEST_URI_PATH_QUERY + "/*", SEARCH_SUGGEST);

matcher.addURI(AUTHORITY, SearchManager.SUGGEST_URI_PATH_SHORTCUT

SHORTCUT_REFRESH);

matcher.addURI(AUTHORITY

SearchManager.SUGGEST_URI_PATH_SHORTCUT + "/*"

SHORTCUT_REFRESH);

return matcher;

}

在清单 12-5 中,我们创建了一个UriMatcher对象,它知道如何识别一个输入的字符串,如果它看起来像我们的建议提供者的搜索 URIs。要做到这一点,UriMatcher需要知道建议提供者的权限和遵循该权限的 URI 路径字符串模式,就像 web URI 和相应的路径段一样。一旦我们有了一个用权限和预期的 URIs 结构初始化的UriMatcher对象,我们就可以使用它,如清单 12-6 所示,根据权限来区分内容供应器 URIs。

清单 12-6。使用 UriMatcher 的 query()方法概述

@Override

public Cursor query(Uri uri, String[] projection, String selection

String[] selectionArgs, String sortOrder)

{

...other stuff

switch (sURIMatcher.match(uri))

{

case SEARCH_SUGGEST:

//Return a series of suggestions

case SHORTCUT_REFRESH:

//Return the updated suggestion

}//eof-switch

....other stuff

}

在清单 12-6 中,query()方法是所有内容提供者都需要的签名。当针对该内容提供者发布任何 URI 时,将调用此方法。(稍后我们将介绍这个query()方法是如何完全实现的。)

实现 getType()来指定 MIME 类型

因为建议提供者最终是内容提供者,所以它有责任实现内容提供者契约,这包括为getType()方法定义一个实现,以返回相应 URIs 的 MIME 类型。清单 12-7 展示了getType()的实现。

清单 12-7。为建议的 UrlProvider 实现 getType()

....other stuff

//Initialize the declared object below first using code in listing 12-5

private static URIMatcher sURIMatcher;

....other stuff

public String getType(Uri uri) {

switch (sURIMatcher.match(uri)) {

case SEARCH_SUGGEST:

return SearchManager.SUGGEST_MIME_TYPE;

case SHORTCUT_REFRESH:

return SearchManager.SHORTCUT_MIME_TYPE;

default:

throw new IllegalArgumentException("Unknown URL " + uri);

}

}

注意在清单 12-7 中我们是如何使用UriMatcher类的。通过它的SearchManager类,Android 搜索框架提供了一些常量来帮助这些 MIME 类型。MIME 类型常量如清单 12-8 所示。

清单 12-8。SearchManager MIME 类型常量

SearchManager.SUGGEST_MIME_TYPE

SearchManager.SHORTCUT_MIME_TYPE

清单 12-8 中的常量翻译成清单 12-9 中的字符串。

清单 12-9。SearchManager MIME 类型常数值

vnd.android.cursor.dir/vnd.android.search.suggest

vnd.android.cursor.item/vnd.android.search.suggest

MIME 类型对内容供应器很重要,就像它们对 web URLs 一样重要。它们指示 URI 返回的数据类型,以便调用者知道返回的是什么类型的数据。虽然我们没有直接使用清单 12-9 中给出的字符串值,但是它们有助于理解 MIME 类型在 Android 中是如何映射的,也有助于在查看日志文件时进行调试。

实现内容供应器查询方法

当它使用一个搜索 URIs 调用建议提供者时,Android 最终调用建议提供者(作为内容提供者)的query()方法来接收建议光标。清单 12-10 显示了我们的SuggestUrlProviderquery()方法的概要实现。

清单 12-10。为 SuggestUrlProvider 实现 query()方法

public Cursor query(Uri uri, String[] projection

String selection

String[] selectionArgs, String sortOrder)

{

Log.d(tag,"query called with uri:" + uri);

Log.d(tag,"selection:" + selection);

String query = selectionArgs[0];

Log.d(tag,"query:" + query);

switch (sURIMatcher.match(uri)) {

case SEARCH_SUGGEST:

Log.d(tag,"search suggest called");

return getSuggestions(query);

case SHORTCUT_REFRESH:

Log.d(tag,"shortcut refresh called");

return null;

default:

throw new IllegalArgumentException("Unknown URL " + uri);

}

}

让我们看看清单 12-10 所示的query()方法中的参数。“uri”参数是我们已经讨论过的搜索 URIs 之一:它有一个类型Uri。类Uri只是 URI 字符串值的一层薄薄的包装。“projection”参数携带调用者有兴趣从内容提供者(比如我们的建议提供者)检索的列的列表。对于建议提供者,我们可以忽略这个参数,因为建议提供者所期望的列列表是固定的和已知的。

“selection”参数是一个字符串,表示一个带问号(?)在里面。问号应该被替换为selectionArgs数组中的值。在建议提供者的情况下,选择参数通常是“?”而selectionArgs数组包含一个携带输入到 QSB 中的查询字符串的元素。

尽管选择论的论点是?您可以修改它,因为searchableinfo XML 文件为它做了准备。清单 12-11 显示了如何通过searchableinfo XML 文件配置我们的定制建议提供者。

清单 12-11。CustomSuggestionProvider searchable info 元数据 XML 文件

//xml/searchable.xml

<searchable

xmlns:android="http://schemas.android.com/apk/res/android

android:label="@string/search_label"

android:hint="@string/search_hint"

android:searchMode="showSearchLabelAsBadge"

android:searchSettingsDescription="suggests urls"

android:includeInGlobalSearch="true"

android:queryAfterZeroResults="true"

android:searchSuggestAuthority=

"com.androidbook.search.custom.suggesturlprovider"

android:searchSuggestIntentAction=

"android.intent.action.VIEW"

android:searchSuggestSelection=" ? "

/>

Note

请注意searchSuggestAuthority字符串值。它应该与 Android 清单文件中相应的内容提供者 URL 定义相匹配。

注意清单 12-11 所示的搜索元数据定义文件中的searchSuggestSelection属性。它直接对应于清单 12-10 中内容提供者的query()方法的选择参数。

当您在searchableinfo XML 文件中指定searchSuggestSelection时,Android 假设您不想通过 URI 接收搜索文本,而是希望通过query()方法的选择参数接收文本。在这种情况下,Android search 将发送“?”(注意问号前后的空格)作为选择参数的值,并将查询文本作为选择参数(selectionArgs,在清单 12-10 中)数组的第一个元素。如果不指定searchSuggestSelection,那么它将把搜索文本作为 URI 的最后一个路径段传递。你可以选择其中之一。在我们的例子中,我们选择了选择方法,而不是 URI 方法。

现在,如果你注意到清单 12-10 中的query()方法的主体,你会看到我们首先决定哪种 URI 调用了query()方法。和以前一样,我们使用UriMatcher类来了解这一点。如果 URI 是一个建议 URI,那么我们调用getSuggestions()来返回一个光标。如果是快捷方式 URI,我们只需返回 null 来表示建议已经过期。当然,如果您想根据需要返回什么来专门化这个行为,您可以改变这个逻辑。

浏览建议光标列

在实现清单 12-10 中的query()方法时,我们使用了一个方法getSuggestions()来返回一个游标值的列。所以这个getSuggestions()方法需要返回一组具有明确定义名称的列。由getSuggestions()方法返回的光标称为建议光标。

建议光标毕竟是一个光标。它与 Android 定义的数据库游标没有什么不同。建议光标充当 Android 搜索工具和建议提供者之间的契约。这意味着游标返回的列的名称和类型是固定的,双方都知道。为了提供搜索的灵活性,Android 搜索在这个光标中提供了大量的列。这些列中的许多(如果不是大多数的话)是可选的。建议提供者不需要返回所有这些列;它可以忽略发送与建议提供者不相关的列。让我们看看建议提供者可以返回的列,每一列的含义,以及它如何影响搜索。

像所有游标一样,建议游标必须有一个_id列。这是强制性的。每隔一列以前缀SUGGEST_COLUMN_开始。这些常量被定义为SearchManager API 参考的一部分。这里,我们回顾一下最常用的色谱柱。(要获得完整的列表,请使用本章末尾参考资料中的 API 源代码。)在下面的列描述中,搜索活动一词指的是被调用来显示搜索文本的结果的活动。

  • text_1:显示的建议中的第一行文本(参见本章后面的图 12-1 中显示的基于键入内容的搜索建议列表)。
  • text_2:提出的建议中的第二行文本(见本章后面的图 12-1 )。
  • icon_1:建议左侧的图标,通常是资源 ID。
  • icon_2:建议右侧的图标,通常是资源 ID。
  • intent_action:当SearchActivity作为意图动作被调用时,传递给它什么。这将覆盖搜索元数据中可用的相应意图动作(参见清单 12-11)。
  • intent_data:当SearchActivity作为意图数据被调用时,传递给它的是什么。这将覆盖搜索元数据中可用的相应意图动作(参见清单 12-11)。这是一个数据 URI。
  • intent_data_id:追加到数据 URI。如果您想在元数据中一次性提到数据的根部分,然后对每个建议进行更改,这将非常有用。那样会更有效率一点。
  • query:发送给搜索活动的查询字符串。
  • shortcut_id:如前所述,Android search 缓存由建议供应器提供的建议。这些缓存的建议被称为快捷方式。如果这个栏目不存在,Android 会缓存建议,永远不会要求更新。如果这包含一个相当于SUGGEST_NEVER_MAKE_SHORTCUT的值,那么 Android 不会缓存这个建议。如果它包含任何其他值,此 ID 将作为快捷方式 URI 的最后一个路径段传递。(参见“了解建议提供者 URIs”一节)
  • 这个布尔值告诉 Android 当它在更新快捷键的过程中是否应该使用一个微调器。

有一组可变的附加列用于响应动作键。我们将在后面关于动作键的章节中讨论这个问题。现在,让我们看看我们的定制建议提供者如何选择返回哪些列,以及它们是如何返回的。

填充并返回建议光标

不要求每个定制建议提供者返回所有这些列。对于我们的建议提供者,我们将根据本章开始的“规划定制建议提供者”一节中指出的功能,只返回列的子集。

方法getSuggestions()依赖于我们定义的列名数组,如清单 12-12 所示。

清单 12-12。为 SuggestUrlProvider 定义建议光标列

private static final String[] COLUMNS = {

"_id",  // must include this column

SearchManager.SUGGEST_COLUMN_TEXT_1

SearchManager.SUGGEST_COLUMN_TEXT_2

SearchManager.SUGGEST_COLUMN_INTENT_DATA

SearchManager.SUGGEST_COLUMN_INTENT_ACTION

SearchManager.SUGGEST_COLUMN_SHORTCUT_ID

};

如您所见,列名不是硬编码的,而是取自于SearchManager API 中可用的列名定义。选择这些列是为了满足以下功能:

首先,用户输入一个带有提示的单词,比如 QSB 中的“great.m”。我们的建议提供者将不会回应,直到有一个。搜索文本中的“m”。一旦识别出来,建议提供者就从中提取单词(在本例中是“great”),然后提供两个建议。

接下来,第一个建议是用这个单词调用 thefreewebdictionary.com ,第二个建议是用模式define:great搜索 Google。为此,提供者加载列intent_action作为intent.action.view(由常量intent.ACTION_VIEW定义)和包含整个 URI 的意图数据。希望 Android 在看到以http://开头的数据 URI 时会启动浏览器。

然后,我们用search some-website with:填充 text 1 列,用单词本身填充 text 2 列(同样,在本例中是“棒极了”)。为了简化,我们还将快捷方式 ID 设置为SUGGEST_NEVER_MAKE_SHORTCUT。此设置禁用缓存,并防止触发建议快捷方式 URI。

一旦我们在类似清单 12-12 中的COLUMNS的数组中确定了这些列,我们就可以定义一个游标,如清单 12-13 所示。

清单 12-13。使用矩阵光标

MatrixCursor cursor = new MatrixCursor(COLUMNS);

String[] rowData;

//insert values for each column in rowData

cursor.addRow(rowData);

MatrixCursor来自 Android API。一旦我们有了这个光标对象,我们就可以通过调用MatrixCursor对象上的addRow()来添加每个建议行。

SuggestUrlProvider 的全部源代码

我们已经介绍了所有必要的背景知识,现在向您展示我们定制的SuggestUrlProvider的完整代码。清单 12-14 显示了SuggestUrlProvider class.的完整源代码

清单 12-14。自定义建议提供程序完整源代码

public class SuggestUrlProvider extends ContentProvider

{

private static final String tag = "SuggestUrlProvider";

public static String AUTHORITY =

"com.androidbook.search.custom.suggesturlprovider";

private static final int SEARCH_SUGGEST = 0;

private static final int SHORTCUT_REFRESH = 1;

private static final UriMatcher sURIMatcher = buildUriMatcher();

private static final String[] COLUMNS = {

"_id",  // must include this column

SearchManager.SUGGEST_COLUMN_TEXT_1

SearchManager.SUGGEST_COLUMN_TEXT_2

SearchManager.SUGGEST_COLUMN_INTENT_DATA

SearchManager.SUGGEST_COLUMN_INTENT_ACTION

SearchManager.SUGGEST_COLUMN_SHORTCUT_ID

};

private static UriMatcher buildUriMatcher()

{

UriMatcher matcher =

new UriMatcher(UriMatcher.NO_MATCH);

matcher.addURI(AUTHORITY

SearchManager.SUGGEST_URI_PATH_QUERY

SEARCH_SUGGEST);

matcher.addURI(AUTHORITY

SearchManager.SUGGEST_URI_PATH_QUERY +

"/*"

SEARCH_SUGGEST);

matcher.addURI(AUTHORITY

SearchManager.SUGGEST_URI_PATH_SHORTCUT

SHORTCUT_REFRESH);

matcher.addURI(AUTHORITY

SearchManager.SUGGEST_URI_PATH_SHORTCUT +

"/*"

SHORTCUT_REFRESH);

return matcher;

}

@Override

public boolean onCreate() {

//lets not do anything in particular

Log.d(tag,"onCreate called");

return true;

}

@Override

public Cursor query(Uri uri, String[] projection

String selection, String[] selectionArgs

String sortOrder)

{

Log.d(tag,"query called with uri:" + uri);

Log.d(tag,"selection:" + selection);

String query = selectionArgs[0];

Log.d(tag,"query:" + query);

switch (sURIMatcher.match(uri)) {

case SEARCH_SUGGEST:

Log.d(tag,"search suggest called");

return getSuggestions(query);

case SHORTCUT_REFRESH:

Log.d(tag,"shortcut refresh called");

return null;

default:

throw new IllegalArgumentException("Unknown URL " + uri);

}

}

private Cursor getSuggestions(String query)

{

if (query == null) return null;

String word = getWord(query);

if (word == null)

return null;

Log.d(tag,"query is longer than 3 letters");

MatrixCursor cursor = new MatrixCursor(COLUMNS);

cursor.addRow(createRow1(word));

cursor.addRow(createRow2(word));

return cursor;

}

private Object[] createRow1(String query)

{

return columnValuesOfQuery(query

"android.intent.action.VIEW"

" http://www.thefreedictionary.com/ " + query

"Look up in freedictionary.com for"

query);

}

private Object[] createRow2(String query)

{

return columnValuesOfQuery(query

"android.intent.action.VIEW"

"http://www.google.com/search?hl=en&source=hp&q=define%3A/

+ query

"Look up in google.com for"

query);

}

private Object[] columnValuesOfQuery(String query

String intentAction

String url

String text1

String text2)

{

return new String[] {

query,        // _id

text1,        // text1

text2,        // text2

url

// intent_data (included when clicking on item)

intentAction, //action

SearchManager.SUGGEST_NEVER_MAKE_SHORTCUT

};

}

private Cursor refreshShortcut(String shortcutId

String[] projection) {

return null;

}

public String getType(Uri uri) {

switch (sURIMatcher.match(uri)) {

case SEARCH_SUGGEST:

return SearchManager.SUGGEST_MIME_TYPE;

case SHORTCUT_REFRESH:

return SearchManager.SHORTCUT_MIME_TYPE;

default:

throw

new IllegalArgumentException("Unknown URL " + uri);

}

}

public Uri insert(Uri uri, ContentValues values) {

throw new UnsupportedOperationException();

}

public int delete(Uri uri, String selection

String[] selectionArgs) {

throw new UnsupportedOperationException();

}

public int update(Uri uri, ContentValues values

String selection

String[] selectionArgs) {

throw new UnsupportedOperationException();

}

private String getWord(String query)

{

int dotIndex = query.indexOf('.');

if (dotIndex < 0)

return null;

return query.substring(0,dotIndex);

}

}

总之,清单 12-14 汇集了所有的代码片段。在这里,您将看到我们是如何满足基类ContentProvider强加的完整契约的。

探索搜索元数据

在关于 Android 搜索的前两章中,我们已经介绍了搜索元数据 XML 文件的一些细节。我们还在清单 12-11 中介绍了这个搜索元数据 XML 文件的内容,在清单 12-11 中,我们展示了搜索查询是如何发送给我们的定制建议提供者的。我们现在可以介绍这个searchableinfo XML 文件的一些常用属性。有关该文件属性的完整列表,请参考 Google 的搜索配置文档,可从以下网址获得:

http://developer.android.com/guide/topics/search/searchable-config.html

为了帮助讨论这些额外的属性,让我们在这里复制清单 12-11,如清单 12-15,以便有一个快速的参考。

清单 12-15。Searchableinfo XML 文件

//xml/searchable.xml

<searchable

xmlns:android="http://schemas.android.com/apk/res/android

android:label="@string/search_label"

android:hint="@string/search_hint"

android:searchMode="showSearchLabelAsBadge"

android:searchSettingsDescription="suggests urls"

android:includeInGlobalSearch="true"

android:queryAfterZeroResults="true"

android:searchSuggestAuthority=

"com.androidbook.search.custom.suggesturlprovider"

android:searchSuggestIntentAction=

"android.intent.action.VIEW"

android:searchSuggestSelection=" ? "

/>

了解 SearchSuggestAction 属性

在清单 12-15 中,searchSuggestIntentAction属性用于在调用SearchActivity时传递或指定意图动作。这允许SearchActivity做一些默认搜索之外的事情。清单 12-16 显示了如何在响应搜索活动的onCreate()方法中使用意图动作:

清单 12-16。响应操作查看和操作搜索

//Body of onCreate

// get and process search query here

final Intent queryIntent = getIntent();

//query action

final String queryAction = queryIntent.getAction();

if (Intent.ACTION_SEARCH.equals(queryAction))

{

this.doSearchQuery(queryIntent);

}

else if (Intent.ACTION_VIEW.equals(queryAction))

{

this.doView(queryIntent);

}

else {

Log.d(tag,"Create intent NOT from search");

}

您将看到清单 12-16 中的代码在上下文中使用,因为清单 12-17 显示了SearchActivity如何通过检查意图的动作值来寻找查看动作或搜索动作。

了解 searchSuggestIntentData 属性

就像意图动作一样,您可以使用searchSuggestIntentData属性指定意图数据。这是一个数据 URI,在被调用时,它可以作为意图的一部分,沿着操作传递给搜索活动。

了解 searchSuggestPath 属性

我们在这里没有使用的另一个属性,但是建议提供者可以使用,叫做searchSuggestPath。如果指定的话,这个字符串值被附加到搜索建议 URI(调用建议提供者的那个)。它允许单个定制建议提供者针对两个不同的搜索活动提供建议。每个搜索活动使用相同的建议提供者权限,但是使用不同的searchSuggestPath。建议提供者可以使用这个路径后缀为每个目标搜索活动返回一组不同的建议。

了解 searchSuggestThreshold 属性

名为searchSuggestThreshold的属性表示在调用这个建议提供者之前必须输入到 QSB 中的字符数。默认阈值为零。

了解 queryAfterZeroResults 属性

属性queryAfterZeroResults(真或假)指示随着更多字符被键入,如果当前字符集返回零个结果集,是否应该联系提供者。在我们特定的SuggestUrlProvider中,打开这个标志很重要,这样我们每次都能看到整个查询文本。

实施搜索活动

既然我们已经对SuggestUrlProvider进行了完全编码,我们需要一个搜索活动来响应这个提供者提出的建议。在第十一章中讨论的简单建议提供者实现中,我们只讨论了搜索活动的部分职责。现在让我们看看我们忽略的方面。

Android search 调用一个搜索活动,以响应以两种方式之一出现的搜索动作。当从 QSB 点击一个搜索图标或者当用户直接点击一个建议时,就会发生这种情况。当被调用时,搜索活动需要检查它为什么被调用。此信息在意图操作中可用。也就是说,搜索活动检查意图动作以便做正确的事情。很多情况下,这个动作就是ACTION_SEARCH。但是,建议提供者可以选择通过搜索元数据 XML 文件或建议游标列指定操作来覆盖它。这种类型的行动可以是任何事情。在我们的例子中,我们也将使用一个视图动作。

正如我们在第十一章的中对简单建议提供者的讨论中所指出的,也可以将搜索活动的启动模式设置为singleTop。在这种情况下,搜索活动增加了响应onNewIntent()onCreate()的责任。让我们看看这两个案例,看看它们有多么相似。

我们使用onNewIntent()onCreate()来检查ACTION_SEARCHACTION_VIEW。在搜索动作ACTION_SEARCH的情况下,我们简单地向用户显示查询文本。(请看图 12-2 看看这段文字是什么样子的。).在查看动作的情况下,我们将控制转移到浏览器,并在当前活动上调用finish()方法,这样用户就有了通过直接点击建议来调用浏览器的印象。

Note

这个search activity不需要成为 Android 主应用菜单中的可启动活动。确保您不会像设置需要从设备的主应用屏幕调用的其他活动一样,无意中为此活动设置意图过滤器。

说完,我们来检查一下SearchActivity.java的源代码。

SearchActivity 源代码

既然我们已经概述了搜索活动的职责,我们可以看看这个搜索活动的源代码,如清单 12-17 所示。

清单 12-17。SearchActivity 的完整源代码

//file: SearchActivity.java

public class SearchActivity extends Activity

{

private final static String tag ="SearchActivity";

@Override

protected void onCreate(Bundle savedInstanceState) {

super.onCreate(savedInstanceState);

Log.d(tag,"I am being created");

//See the downloadable project if you want the following layout file

//Or copy it from Listing 12-19.

setContentView(R.layout.layout_test_search_activity);

// get and process search query here

final Intent queryIntent = getIntent();

//query action

final String queryAction = queryIntent.getAction();

Log.d(tag,"Create Intent action:"+queryAction);

final String queryString =

queryIntent.getStringExtra(SearchManager.QUERY);

Log.d(tag,"Create Intent query:"+queryString);

if (Intent.ACTION_SEARCH.equals(queryAction))

{

this.doSearchQuery(queryIntent);

}

else if (Intent.ACTION_VIEW.equals(queryAction))

{

this.doView(queryIntent);

}

else {

Log.d(tag,"Create intent NOT from search");

}

return;

}

@Override

public void onNewIntent(final Intent newIntent)

{

super.onNewIntent(newIntent);

Log.d(tag,"new intent calling me");

// get and process search query here

final Intent queryIntent = newIntent;

//query action

final String queryAction = queryIntent.getAction();

Log.d(tag,"New Intent action:"+queryAction);

final String queryString =

queryIntent.getStringExtra(SearchManager.QUERY);

Log.d(tag,"New Intent query:"+queryString);

if (Intent.ACTION_SEARCH.equals(queryAction))

{

this.doSearchQuery(queryIntent);

}

else if (Intent.ACTION_VIEW.equals(queryAction))

{

this.doView(queryIntent);

}

else {

Log.d(tag,"New intent NOT from search");

}

return;

}

private void doSearchQuery(final Intent queryIntent)

{

final String queryString =

queryIntent.getStringExtra(SearchManager.QUERY);

appendText("You are searching for:" + queryString);

}

private void appendText(String msg)

{

TextView tv = (TextView)this.findViewById(R.id.text1);

tv.setText(tv.getText() + "\n" + msg);

}

private void doView(final Intent queryIntent)

{

Uri uri = queryIntent.getData();

String action = queryIntent.getAction();

Intent i = new Intent(action);

i.setData(uri);

startActivity(i);

this.finish();

}

}

我们从分析这个源代码(清单 12-17)开始,首先检查这个搜索活动是如何被调用的。

搜索活动调用的详细信息

像所有活动一样,我们知道搜索活动必须是通过意图调用的。然而,假设总是意图的行动对此负责是错误的。事实证明,搜索活动是通过其组件名称规范显式调用的。

你可能会问为什么这很重要。我们知道,在我们的建议提供者中,我们在建议行中明确地指定了一个意图动作。如果这个意图动作是 view,而意图数据是一个http URL,那么一个不知情的程序员会认为将会启动一个浏览器作为响应,而不是搜索活动。这当然是可取的。但是因为除了意图动作和数据之外,最终意图还加载了组件名SearchActivity,所以组件名将优先。

我们不知道为什么会有这种限制,也不知道如何克服这种限制。但事实是,不管您的建议提供者在建议中指定了什么意图动作,SearchActivity都是将要被调用的那个。在我们的例子中,我们只需从搜索活动启动浏览器并关闭搜索活动。

为了证明这一点,下面是当我们点击一个建议时,Android 启动调用我们的搜索活动的意图:

launching Intent {

act=android.intent.action.VIEW

dat=http://www.google.com

flg=0x10000000

cmp=com.androidbook.search.custom/.SearchActivity (has extras)

}

请注意意图的组件规格。它直接指向搜索活动。所以无论你指示什么意图动作,Android 都会一直调用SearchActivity。因此,调用浏览器就成了搜索活动的责任。现在,让我们看看在搜索活动中我们是如何处理这些意图的。

响应操作 _ 搜索和操作 _ 查看

我们知道 Android search 通过名称显式调用搜索活动。然而,调用意图还带有指定的动作。当 QSB 通过搜索图标调用这个活动时,这个动作就是ACTION_SEARCH。如果搜索建议调用该操作,则该操作可能会有所不同。这取决于建议提供者如何设置建议。在我们的例子中,建议提供者将其设置为ACTION_VIEW

因此,搜索活动需要检查动作的类型。清单 12-18 显示了我们如何检查这种类型的动作,看看是调用搜索查询方法还是视图方法。(这段代码摘自清单 12-17。)

清单 12-18。响应操作 _ 搜索和操作 _ 查看

if (Intent.ACTION_SEARCH.equals(queryAction))

{

this.doSearchQuery(queryIntent);

}

else if (Intent.ACTION_VIEW.equals(queryAction))

{

this.doView(queryIntent);

}

从清单 12-18 中的代码,你可以看到我们调用了查看动作的doView()和搜索动作的doSearchQuery()

doView()函数中,我们检索动作和数据 URI,并用它们填充新的意图,然后调用活动。这将调用浏览器。我们还在活动上调用了方法finish(),这样 Back 按钮就可以带您回到搜索调用它的地方。

doSearchQuery()中,我们只是将搜索查询文本记录到视图中。让我们来看看用来支持doSearchQuery()的布局。

搜索活动布局

清单 12-19 是一个简单的布局,在doSearchQuery()的情况下被一个搜索活动使用。

清单 12-19。搜索活动布局 XML

<?xml version="1.0" encoding="utf-8"?>

<!-- file: layout/layout_test_search_activity.xml -->

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android

android:orientation="vertical"

android:layout_width="fill_parent"

android:layout_height="fill_parent"

>

<TextView

android:id="@+id/text1"

android:layout_width="fill_parent"

android:layout_height="wrap_content"

android:text="@string/search_activity_main_text"

/>

</LinearLayout>

在这一点上,提到负责这个应用的一些文本需求的strings.xml是合适的,如清单 12-19 所示。(参见android:text属性。)你会在图 12-2 ( SearchActivity视图)中看到我们用于清单 12-9 的字符串。您还可以在参考资料部分找到的相应的可下载应用中看到本章使用的所有文件。在这里列出长字符串值并没有什么好处。

正在响应 onCreate()和 onNewIntent()

在清单 12-17 中,你会看到onCreate()onNewIntent()中的代码几乎相同。这种模式并不少见。调用搜索活动时,根据搜索活动的启动模式,调用onCreate()onNewIntent()。这些方法在第十一章中有详细讨论。

完成搜索活动的注意事项

在本次讨论的早些时候,我们简要提到了如何回应doView()。清单 12-20 显示了这个doView()函数的代码摘录(摘自清单 12-17)。

清单 12-20。完成搜索活动

private void doView(final Intent queryIntent)

{

Uri uri = queryIntent.getData();

String action = queryIntent.getAction();

Intent i = new Intent(action);

i.setData(uri);

startActivity(i);

this.finish();

}

这个函数的目标是调用浏览器。如果我们没有在最后做finish()操作,用户在点击 back 按钮后会从浏览器返回到搜索活动,而不是像预期的那样回到他们来的搜索屏幕。理想情况下,为了提供最佳的用户体验,控件不应该通过搜索活动。完成这个活动就解决了这个问题。清单 12-20 还提供了一个机会来检查我们如何从原始意图(由建议提供者设置)中转移意图动作和意图数据,然后将它们传递给新的浏览器意图。

到目前为止,我们已经有了一个建议提供者、一个搜索活动和一个searchableinfo XML 文件。现在,我们将介绍这个应用的清单文件。

自定义建议提供程序清单文件

清单文件是您将应用的许多组件放在一起的地方。对于我们的定制建议提供者应用,与其他示例一样,这是您声明其组件的地方,比如搜索活动和建议提供者。您还可以使用 manifest 文件通过将Search Activity声明为默认搜索来声明该应用支持本地搜索。另外,请注意为搜索活动定义的意图过滤器。清单文件代码中用粗体突出显示了这些细节,如清单 12-21 所示。

清单 12-21。自定义建议提供程序清单文件

//file:AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>

<manifest xmlns:android="http://schemas.android.com/apk/res/android

package="com.androidbook.search.custom"

android:versionCode="1"

android:versionName="1.0.0">

<application android:icon="@drawable/icon"

android:label="Custom Suggestions Provider">

<!--

****************************************************************

* Search related code: search activity

****************************************************************

-->

<activity android:name=".SearchActivity"

android:label="Search Activity Label"

android:launchMode="singleTop">

<intent-filter>

<action

android:name="android.intent.action.SEARCH" />

<category

android:name="android.intent.category.DEFAULT" />

</intent-filter>

<meta-data android:name="android.app.searchable"

android:resource="@xml/searchable" />

</activity>

<!--``Declare default search

<meta-data android:name="android.app.default_searchable"

android:value=".SearchActivity" />

<!--``Declare Suggestion Provider

<provider android:name="SuggestUrlProvider"

android:authorities=

"com.androidbook.search.custom.suggesturlprovider" />

</application>

<uses-sdk android:minSdkVersion="4" />

</manifest>

如你所见,我们在清单 12-21 中强调了三件事:

  • 定义搜索活动及其搜索元数据 XML 文件
  • SearchActivity定义为应用的默认搜索
  • 定义建议提供者及其权限

有了所有的源代码之后,是时候浏览一下应用,看看它在模拟器中是什么样子了。

自定义建议供应器用户体验

在这一节中,我们将向您展示如何使用我们开发的定制建议提供程序。如果您想在您的模拟器或设备上看到这个,您可以从本章末尾的参考资料中的 URL 下载本章的 Android Eclipse 项目。本节将快速介绍这一体验。

一旦您通过 ADT 构建并部署了下载的应用,您将不会看到任何弹出的活动,因为在这个应用中没有要启动的活动。自定义建议提供程序只是一个幕后引擎。因此,您将看到应用成功安装在 Eclipse 控制台中,而不是由安装程序启动任何活动。当然,该应用包含在模拟器或设备上的应用列表中。

成功安装意味着建议提供者准备好响应全局 QSB。但是在这之前,您需要使这个建议提供者能够参与全局搜索。我们已经在第十章和第十一章中展示了如何启用自定义搜索应用。

要开始此自定义搜索供应器的用户体验之旅,请打开全球 QSB,然后在 QSB 中输入“chiaroscur.m”。

A978-1-4302-4951-1_12_Fig1_HTML.jpg

图 12-1。

Suggestions from the custom SuggestUrlProvider

请注意图 12-1 中自定义建议提供者的搜索建议是如何呈现的。如果有太多来自其他搜索应用的建议,我们的建议可能在它们下面,在视图之外,你可能需要滚动。或者,您可以单击左上角的搜索图标,将搜索应用更改为“custom suggestion provider”应用。这将把搜索建议过滤到我们的建议列表中。

现在,如图 12-1 所示,导航到我们的定制建议供应器提供的建议之一,并单击 QSB 搜索图标。Android 会直接带你到搜索活动,不需要调用任何浏览器,如图 12-2 所示。

A978-1-4302-4951-1_12_Fig2_HTML.jpg

图 12-2。

SearchActivity responding to ACTION_SEARCH

这个例子演示了与ACTION_VIEW相对的ACTION_SEARCH。现在相反,如果你触摸图 12-1 中的免费字典建议,我们的搜索活动得到ACTION_VIEW,搜索活动调用浏览器,如图 12-3 所示。这演示了所讨论的两种意图动作:搜索活动和视图。

A978-1-4302-4951-1_12_Fig3_HTML.jpg

图 12-3。

SearchActivity transferring to free dictionary

同样,在图 12-1 中,如果你触摸谷歌建议项,你会看到如图 12-4 所示的浏览器变化。

A978-1-4302-4951-1_12_Fig4_HTML.jpg

图 12-4。

SearchActivity transferring to Google for the word definition

图 12-5 显示了如果不输入后缀会发生什么。m”进入全局搜索。

A978-1-4302-4951-1_12_Fig5_HTML.jpg

图 12-5。

Custom provider with no contributed suggestions

注意建议提供者没有提供任何反馈。

从头开始构建功能性定制建议提供程序的讨论到此结束。虽然我们已经讨论了搜索的许多方面,但是还有一些主题没有讨论。这些是动作键和特定于应用的搜索数据。

使用操作键和特定于应用的搜索数据

动作键和特定于应用的搜索数据增加了 Android 搜索的灵活性。操作键允许您使用专门的设备键来实现搜索相关的功能。特定于应用的搜索数据允许活动将附加数据传递给搜索活动。让我们从动作键开始。

在 Android 搜索中使用操作键

到目前为止,我们已经展示了许多调用搜索的方法:

  • QSB 中可用的搜索图标
  • 作为一组操作键的一部分的搜索键
  • 由活动显示的显式图标或按钮
  • 基于键入搜索声明的任何按键

在这一节中,我们将通过使用操作键来调用搜索。动作键是设备上与特定动作相关联的一组可用键。清单 12-22 显示了一些动作键的例子。

清单 12-22。动作键码列表

keycode_dpad_up

keycode_dpad_down

keycode_dpad_left

keycode_dpad_right

keycode_dpad_center

keycode_back

keycode_call

keycode_camera

keycode_clear

kecode_endcall

keycode_home

keycode_menu

keycode_mute

keycode_power

keycode_search

keycode_volume_up

keycode_volume_down

你可以看到这些动作键是在KeyEvent的 API 中定义的,在 http://developer.android.com/reference/android/view/KeyEvent.html 有。

Note

并非所有这些操作键都可以用于搜索,但有些可以,比如 keycode_call。你将不得不尝试每一个,看看哪一个适合你的需要。

一旦你知道你想要使用哪个动作键,你可以告诉 Android 你对这个键感兴趣,通过使用 XML 段把它放到元数据中,如清单 12-23 所示。

清单 12-23。动作键定义示例

<searchable xmlns:android="http://schemas.android.com/apk/res/android

android:label="@string/search_label"

android:hint="@string/search_hint"

android:searchMode="showSearchLabelAsBadge"

android:includeInGlobalSearch="true"

android:searchSuggestAuthority=

"com.androidbook.search.simplesp.SimpleSuggestionProvider"

android:searchSuggestSelection=" ? "

>

<actionkey

android:keycode="KEYCODE_CALL"

android:queryActionMsg="call"

android:suggestActionMsg="call"

android:suggestActionMsgColumn="call_column" />

<actionkey

android:keycode="KEYCODE_DPAD_CENTER"

android:queryActionMsg="doquery"

android:suggestActionMsg="dosuggest"

android:suggestActionMsgColumn="my_column" />

.....

</searchable>

同一搜索上下文也可以有多个操作键。下面是actionKey元素的每个属性代表什么,以及如何用它来响应动作键的按下。

  • keycode:这是在KeyEvent API 类中定义的 keycode,应该用于调用搜索活动。由键码识别的该键可以被按下两次。第一种情况是,用户在 QSB 中输入查询文本,但没有导航到任何建议。通常,在没有动作键实现的情况下,用户会按下 QSB 的搜索图标。通过在搜索的元数据中指定一个操作键,Android 允许用户点击操作键,而不是 QSB 搜索 Go 图标。第二种是当用户导航到一个特定的建议,然后单击 action 键。在这两种情况下,搜索活动都是通过一个动作ACTION_SEARCH调用的。要知道这个动作是通过一个动作键调用的,可以查找一个名为SearchManager.ACTION_KEY的额外字符串。如果您在这里看到一个值,您知道您正在响应一个动作键的按下而被调用。
  • queryActionMsg:您在这个元素中输入的任何文本都被传递给搜索活动,调用 intent 作为一个名为SearchManager.ACTION_MSG的额外字符串。这是在用户将文本输入到 QSB 中并按下动作键时完成的。
  • suggestActionMsg:您在这个元素中输入的任何文本都被传递给搜索活动,调用 intent 作为一个名为SearchManager.ACTION_MSG的额外字符串。当一个建议被聚焦并且用户已经按下动作键时,这被完成。如你所见,在意图中,“额外”键和queryActionMsg键是一样的。如果您为这两个字段赋予相同的值,比如call,那么您将不会知道用户以何种方式调用了操作键。在许多情况下,这是不相关的,所以你可以给两者相同的值。但是如果你需要区分两者,你必须指定一个不同于queryActionMsg的值。
  • suggestActionMsgColumn:值queryActionMsgsuggestActionMsg全局应用于这个搜索活动和建议提供者。没有办法根据建议改变动作的含义。如果您想这样做,那么您将需要告诉元数据,在每个建议的建议光标中有一个额外的列来携带这个消息。这将允许 Android 从额外的列中提取文本,并将其作为调用ACTION_SEARCH意图的一部分发送给活动。有趣的是,来自光标的这个额外列的值是通过 intent 中的同一个额外键发送的,即SearchManager.ACTION_MSG

在这些属性中,keycode 是必需的。此外,要激发动作键,至少需要另外三个属性中的一个。

如果您要使用suggestActionMsgColumn,您需要在建议提供者类中填充这个列。在清单 12-23 的searchableinfo XML 文件中,如果你要使用所示的两个动作键,那么你需要在建议光标中定义指定的两个附加字符串列(见清单 12-12),即call_columnmy_column。在这种情况下,清单 12-12 中的光标列数组将如清单 23-24 所示。

清单 12-24。建议光标中操作键列的示例

private static final String[] COLUMNS = {

"_id",  // must include this column

SearchManager.SUGGEST_COLUMN_TEXT_1

SearchManager.SUGGEST_COLUMN_TEXT_2

SearchManager.SUGGEST_COLUMN_INTENT_DATA

SearchManager.SUGGEST_COLUMN_INTENT_ACTION

SearchManager.SUGGEST_COLUMN_SHORTCUT_ID

"call_column"

"my_column"

};

使用特定于应用的搜索上下文

Android search 允许一个活动在被调用时向搜索活动传递额外的搜索数据。让我们浏览一下细节。

正如我们在第十一章中所展示的,应用中的活动可以通过返回 false 来覆盖onSearchRequested()方法以禁用搜索。有趣的是,可以使用相同的方法将额外的特定于应用的数据传递给搜索活动。清单 12-25 给出了一个例子。

清单 12-25。将附加应用数据传递给搜索活动

public boolean onSearchRequested()

{

Bundle applicationData = new Bundle();

applicationData.putString("string_key","some string value");

applicationData.putLong("long_key",290904);

applicationData.putFloat("float_key",2.0f);

startSearch(null,          // Initial Search search query string

false,                  // don't "select initial query"

applicationData,        // extra data

false                   // don't force a global search

);

return true;

}

Note

可以使用下面的 Bundle API 参考来查看 Bundle 对象上可用的各种函数: http://developer.android.com/reference/android/os/Bundle.html

一旦搜索以这种方式开始,活动就可以使用名为SearchManager.APP_DATAextra来检索应用数据包。清单 12-26 显示了如何从搜索意图中检索上述每个应用数据字段(在清单 12-27 中设置),在负责搜索结果的搜索活动的onCreate()onNewIntent()方法中。

清单 12-26。检索附加应用数据上下文

Bundle applicationData =

queryIntent.getBundleExtra(SearchManager.APP_DATA);

if (applicationData != null)

{

String s = applicationData.getString("string_key");

long   l = applicationData.getLong("long_key");

float  f = applicationData.getFloat("float_key");

}

让我们回到清单 12-25 所示的startSearch()方法。我们在第十一章中介绍了这个startSearch()方法,你可以在下面的活动 API 中找到更多关于它的信息:

http://developer.android.com/reference/android/app/Activity.html

快速提供该方法的概述可能是有益的。它有以下四个参数:

  • initialQuery          // a string argument
  • selectInitialQuery    // boolean
  • applicationDataBundle //Bundle
  • globalSearchOnly      //boolean

第一个参数(如果可用)将填充 QSB 中的查询文本。如果为真,第二个布尔参数将突出显示文本。这样做将使用户能够用键入的内容替换所有选定的查询文本。如果为 false,则光标将位于查询文本的末尾。第三个理由当然是我们正在准备的捆绑包。第四个参数,如果为真,将总是调用全局搜索。如果为假,则首先调用本地搜索(如果可用);否则,它将使用全局搜索。

参考

以下是我们在撰写本章时发现的有价值的资源列表:

摘要

在这一章中,我们通过编写一个来自 scrach 的自定义建议提供程序,详细介绍了 Android 搜索的内部工作原理。在这个过程中,我们详细演示了建议光标及其列,探索了负责从建议提供者那里获取数据的 URIs,并给出了许多示例代码,这些代码可以使设计和实现您自己的创造性搜索策略变得容易。

复习问题

以下问题应作为你在本章中所学内容的里程碑:

How do you specialize a content provider to become a suggestion provider?   How many types of search URIs are there?   Why and how do you differentiate various search URIs?   How do you know the names of the suggestion cursor columns?   What Java class do you use to construct a cursor from the names of the cursor columns?   How do you add rows to a MatrixCursor?   How do you co-opt action keys to be search keys?   How do you pass application-specific data to a search activity?

十三、Parse 云存储简介

Abstract

一些移动应用可能需要在服务器上存储数据的能力。这些数据可能与用户简档一样简单。或者,如果您的应用是一个游戏,您可能希望存储用户分数。或者,您的应用可能是协作性的,在这种情况下,一个用户可能需要查看另一个用户创建的数据。或者,您可能希望为您的移动应用提供跨设备同步功能。

一些移动应用可能需要在服务器上存储数据的能力。这些数据可能与用户简档一样简单。或者,如果您的应用是一个游戏,您可能希望存储用户分数。或者,您的应用可能是协作性的,在这种情况下,一个用户可能需要查看另一个用户创建的数据。或者,您可能希望为您的移动应用提供跨设备同步功能。

这些类型的数据使用需要一个可供您的移动应用作为服务使用的服务器。这个服务器端技术空间,尤其是针对移动需求的,现在有了一个名字。它被称为 BaaS:后端即服务。现在在 BaaS 领域有大量的公司。这种 BaaS 平台提供的典型功能有:

  • 用户注册
  • 允许知名网站如脸书或 Twitter 的用户直接登录的能力
  • 能够在云中存储任意对象
  • 能够处理无模式对象(如 NoSQL)
  • 立即保存或在一段时间后保存的能力
  • 查询对象的能力
  • 能够在本地设备上缓存查询
  • 使用 Web 控制台来管理用户及其数据
  • 能够发送推送通知
  • 能够对用户及其行为进行分类,并相应地发送推送通知
  • 除了存储数据之外,还能够在云中编写可执行的服务,从而为移动应用提供三层架构

在这一章和接下来的几章中,我们将以一个流行的 BaaS 平台 Parse.com 为例,介绍其中的一些特性。BaaS 领域的其他新兴参与者包括:

  • ACS Appcelerator 云服务(以前为 Cocoafish)
  • 应用 a
  • 堆栈移动
  • 微软 Azure 移动服务
  • 金维
  • 脂肪分形

在这一章中,我们将通过编写一个简单的 Android 应用来探索 Parse cloud API,这个应用将介绍 Parse 的基本特性。这应该为您提供了足够的关于 Parse API 的信息。下一章将集中在关于从属对象的关键细微差别,以及如何通过地块传递解析对象。在接下来的章节中,我们将向您的用户推送通知,以持续吸引他们。

现在让我们转到我们计划使用 Parse 的示例应用。

规划一个示例解析应用

我们在这里创建的示例应用是一个多用户移动应用。也就是说,每个用户都能够创建一个单词,并看到其他用户创建的单词。除了使用 word,这款应用还需要具备基本的用户管理功能,比如注册、登录、重置密码和注销。

图 13-1 显示了当用户第一次遇到应用时,用户将看到的主屏幕。

A978-1-4302-4951-1_13_Fig1_HTML.jpg

图 13-1。

Parse sample application signup/login activity

因此,当用户第一次看到应用时,他或她可以选择注册或登录(如果该用户已经注册),如图 13-1 所示。

Note

Parse 还允许从脸书和 Twitter 等其他网站登录。然而,我们不在本章中讨论这些集成。

设计注册活动

我们先来规划一下注册页面。图 13-2 显示了注册页面的样子。

A978-1-4302-4951-1_13_Fig2_HTML.jpg

图 13-2。

Parse sample application signup activity

作为注册活动的一部分,您将收集用户 ID、电子邮件地址和密码,如图 13-2 所示。然后,您可以调用 Parse API 向用户注册这些详细信息。

设计登录屏幕

注册完成后,用户可以使用如图 13-3 所示的以下登录活动进行登录。(但是,请注意,当用户注册成功时,用户会自动登录!用户需要注销才能看到和使用这个登录屏幕。)

A978-1-4302-4951-1_13_Fig3_HTML.jpg

图 13-3。

Parse sample application login activity

对于图 13-3 所示的登录屏幕,没有什么值得惊讶的。您正在收集用户 ID 和密码,以便您可以调用解析 API 来登录。因为当联系服务器上的 Parse 进行登录时可能会有延迟,所以最好放置一个进度对话框。这种相互作用如图 13-4 所示。

A978-1-4302-4951-1_13_Fig4_HTML.jpg

图 13-4。

Waiting for login to complete

大多数解析 API 都有它们的异步变体。这些 API 提供了在调用返回时调用的回调。在这个回调中,我们可以通过编程关闭进度对话框。

设计密码重置活动

用户可能不记得以前使用过的密码。使用 Parse,很容易实现重置密码功能。重置密码工具的屏幕可能如图 13-5 所示。

A978-1-4302-4951-1_13_Fig5_HTML.jpg

图 13-5。

An activity to reset password

用户只需输入电子邮件地址即可重置密码。图 13-5 中的表单收集邮件地址,一旦用户点击发送密码重置按钮,你就可以调用 Parse API 通过传递邮件来重置密码。Parse 随后会向该电子邮件地址发送一个 web 链接,以便重置密码。

规划应用的主页

一旦用户登录,您将为该应用显示的屏幕是一个欢迎屏幕,如图 13-6 所示。

A978-1-4302-4951-1_13_Fig6_HTML.jpg

图 13-6。

Welcome activity after a successful login

使用这个欢迎屏幕,我们将演示三件事情。第一个是显示到目前为止已经注册的用户列表。这将探索可供用户查询的解析 API。

然后,我们将探索如何创建一个数据对象并将其存储在解析云中。为此,我们将创建一个可以存储在解析云中的“word”对象。

使用图 13-6 所示的创建单词按钮,我们将创建一些单词,然后使用显示单词列表按钮查看到目前为止创建的所有单词。

向用户展示

图 13-7 显示了列出所有注册用户的活动。

A978-1-4302-4951-1_13_Fig7_HTML.jpg

图 13-7。

An activity that shows a list of users

在图 13-7 中,只显示了一个用户,因为目前只有一个用户注册。如果有更多的注册用户,查询将显示所有的用户。如果用户太多,也可以翻页查看。然而,对于这一章,我们不打算显示分页。有关分页查询的详细信息,请参考解析文档。

创建和存储数据对象

图 13-8 显示了允许您创建一个单词并将其存储在解析云中的活动。

A978-1-4302-4951-1_13_Fig8_HTML.jpg

图 13-8。

Creating a word Parse object

一旦收集了单词及其含义,就可以调用解析 API 将其存储在解析云中。这个应用的目标是开发一个简单的基于社区的字典,其中的含义由其他用户提供。

查询单词

一旦用户创建了一个单词,该用户就可以浏览所有现有的单词。图 13-9 显示了显示可用单词集的列表活动。

A978-1-4302-4951-1_13_Fig9_HTML.jpg

图 13-9。

Querying for the list of registered users

图 13-9 中的活动列出了创建的单词、哪个用户创建了该单词以及创建时间。该屏幕还提供了删除单词的功能。“含义”按钮允许多个用户为一个单词提供各自的含义。在这一章中,我们不会进入用于创建和浏览含义的附加屏幕。这些概念类似于创建单词和浏览单词,因为单词和含义都是解析对象。

这就完成了我们计划用 Parse 实现的应用的快速概述。我们现在将介绍 Parse 中的一些基本概念,然后开始为 Parse 设置您的移动应用。

探索语法分析基础

在 Parse 中,对象被存储为一组键值对。对象不必像关系表中的列或类定义中的属性那样坚持预定义的模式。一旦有了 Parse 对象,就可以添加任意多的列;所有这些列的值都根据这些列名存储在该对象中。但是,每次在解析对象中存储它们的值时,都必须指定列名(属性名)。

了解基本 ParseObject

一个解析对象有一个与之关联的类型名。它就像关系数据库中的表名。但是,属于该类型或表名的许多对象可以有一组不同的列和相应的值。这与类型语言中的类型和关系数据库中的表形成了对比。

Parse 中的每个对象都保证有一个对象 ID。解析对象还保留字段(a)对象创建的时间和(b)对象最后更新的时间。您可以将大多数类型的对象作为特定键的值放置。这些对象(那些被指定为值的对象)通常被转换成某种流表示,并根据它们各自的键名进行存储。当这些对象值被检索时,它们作为它们的原始对象类型被检索。因此,一个解析对象可以存储其他解析对象作为给定键的目标值。这意味着一个解析对象可以与其他解析对象有关系,尤其是它的父对象。

比方说,您的应用有 30 种类型的 Java 对象。当存储在 Parse 中时,它们都被表示为无类型的基于键值对的解析对象。需要时,您可以将它们映射到各自的 Java 对象,以保证类型安全。

虽然在很多时候,将无类型对象转换为有类型对象是正确的,但是请记住,在某些情况下,无类型集合允许更多的动态性和灵活性,尤其是当您正在创建一个框架,其中这些对象必须通过各种防火墙时。

了解 ParseUser 对象

一个ParseUser也是一个ParseObject,它提供了一些额外的类型特征,比如用户名、电子邮件等。,而不恢复到基础键名。一个ParseUser还提供了登录、注销等必要的方法。

因为一个ParseUser是一个ParseObject,如果你愿意,你可以把额外的属性放到一个ParseUser对象中。

了解 ParseQuery 对象

与关系数据库不同,没有用于查询解析对象的 SQL。提供了一个 Java API,您可以使用它来检索解析对象。

使用解析查询 API,一次只能检索一种类型的对象。这意味着您不能有选择地连接多个解析对象类型并同时检索它们。

然而,某些类型的连接可以通过其他方式完成,同时遵守这样的约束,即通过 Parse 只能检索一种类型的根级对象。例如,如果我们有一个Word,并且如果这个单词有许多含义,那么我们将这个单词和单词含义表示为两种类型的对象。那么WordMeaning将与它的Word有一个关系作为附加属性。现在,我们可以说“得到所有的WordMeanings,它们的Word是如此如此。”

当您检索一个主对象时,如果需要,您可以让查询包含相关的解析对象。您还可以在解析查询上设置缓存策略,以减少延迟。您可以在保存对象或通过查询检索对象时执行此操作。

这些基础知识应该足以实现我们在本章开始时建议的应用。

设置支持解析的 Android 应用

到目前为止,我们描述的示例应用是使用 Parse API 进行开发的一个很好的选择。我们将实现陈述的用例中指出的所有功能。这将为您提供关于 Parse 平台及其 API 的精彩介绍。

在我们实现示例应用之前,让我们首先了解如何设置 Parse 并开始使用它。

创建解析帐户和应用

parse.com 开始很简单。访问 parse.com 并创建账户。在该帐户中,创建一个应用。一旦创建了应用,您将成为该应用的所有者。您可以创建任意多的应用。

Note

Parse.com 网站上,创建帐户和创建应用所需的链接可能会发生变化。你应该能够导航和完成这些基本任务。

一旦创建了一个应用,Parse 就会创建一组键,您需要用这些键来初始化您的移动应用。Parse 为您的应用提供了一个仪表板,可以方便地复制和粘贴这些键。图 13-10 ,显示了我们创建的第一个示例应用的仪表板。

A978-1-4302-4951-1_13_Fig10_HTML.jpg

图 13-10。

Parse dashboard to locate application keys

出于安全目的,我们截断了密钥,如图 13-10 所示。在这个面板中,您可以将这些键复制并粘贴到您的移动应用中。

我们将很快向您展示如何使用这些键,以便您的应用能够在云端与 Parse 进行通信。但是首先您应该下载必要的 jar 文件并创建 Android 移动应用。为了使用 Parse 引导应用开发,Parse 提供了一个示例 Android 应用,名为 Parse Starter(或 Android Blank Project ),您可以下载并作为起点使用。

Note

随着 Parse 经历多个版本,它可能会更改这个 starter 应用的名称,或者提供一个完全不同的机制来帮助您入门。然而,我们在这里讨论的内容应该适用。

您可以从以下 URL 下载这个样例初学者模板应用: https://www.parse.com/apps/quickstart

到达这个 URL 后,选择您的平台(例如,Android ),下载并在 eclipse ADT 中设置 Parse starter 应用。根据 Parse download 的发行版本,我们在这一章中介绍的内容可能与您下载的内容略有不同,但是一般的原则和方向应该适用。

一旦您在首选 IDE 中下载并设置了 Parse starter 应用,它很可能会失败,并在一个名为ParseApplication.java的文件中显示错误。这是因为 Parse 有意留下了占位符,用于放置我们前面展示的键。一旦你放置了这些键,这个文件将看起来如清单 13-1 所示(考虑到 Parse 的后续版本可能会有额外的代码段!).

清单 13-1。用解析键初始化应用

public class ParseApplication extends Application {

private static String tag = "ParseApplication";

private static String PARSE_APPLICATION_ID

= "vykek4ps.....";

private static String PARSE_CLIENT_KEY

= "w52SGUXv....";

@Override

public void onCreate() {

super.onCreate();

Log.d(tag,"initializing with keys");

// Add your initialization code here

Parse.initialize(this, PARSE_APPLICATION_ID, PARSE_CLIENT_KEY);

// This allows read access to all objects

ParseACL defaultACL = new ParseACL();

defaultACL.setPublicReadAccess(true);

ParseACL.setDefaultACL(defaultACL, true);

Log.d(tag,"initializing app complete");

}

}

在如上所示的代码中插入应用 ID 和客户机密钥后,Parse starter 应用就可以在服务器上使用 Parse 了。代码中的 ACL 代表“访问控制列表”对于本书,我们将其设置为用户创建的所有对象的默认读取权限。除此之外,这本书没有涉及 Parse 的安全方面,建议读者查阅 Parse 文档。

我们几乎准备好开始实现我们所描述的应用了。不过,在此之前,让我们先了解一下 Parse 的一些基本概念。

实现示例应用

在本章的这一部分,我们将使用到目前为止出现的每个屏幕,并展示演示如何使用 parse API 来完成该用例的关键代码片段。如果您对整个应用源代码感兴趣,可以从本章末尾的参考资料部分给出的项目下载 URL 下载。

实施注册

让我们从注册活动开始(见图 13-2 )。在注册活动中,我们将收集用户名、密码和电子邮件地址。使用这些值,清单 13-2 中的代码演示了我们如何使用 Parse API 来注册用户。

清单 13-2。解析用户注册的 API

private void signup(String userid, String email, String password)

{

ParseUser user = new ParseUser();

user.setUsername(userid);

user.setPassword(password);

user.setEmail(email);

//Show the progress dialog

turnOnProgressDialog("Signup", "Please wait while we sign you up");

//Go for signup with a callback

user.signUpInBackground(new SignUpCallback() {

public void done(ParseException e) {

turnOffProgressDialog();

if (e == null) {

// Hooray! Let them use the app now.

signupSuccessful();

} else {

// Sign up didn't succeed. Look at the ParseException

// to figure out what went wrong

signupFailed(e);

}

}

});

return;

}//signup-method

private void signupSuccessful()

{

//Go to signup successful page

//finish

gotoActivity(SignupSuccessActivity.class);

finish();

}

private void signupFailed(ParseException x)

{

//stay on the page

//Put an error message for the exception

String message = x.getMessage();

alert("Signup", "Failed:" + message);

}

请注意,这段代码(以及本章中的其他代码片段)取自一个更大的代码库的中间部分。它将帮助您理解如何使用解析 API。

不要试图编译,因为代码引用了一些本章没有列出的方法。然而,这些方法的意图从它们的命名中应该是清楚的。例如,alert()方法用于放置一个警告对话框。gotoActivity()方法用于将控制转移给另一个活动。turnOnProgressDialog()方法显示进度对话框,直到解析方法通过其回调返回。

现在,将注意力转回到清单 13-2 中解析注册代码的主要流程。我们首先创建一个名为ParseUser的新解析对象,并用我们通过用户注册表单收集的用户相关信息填充它。然后我们调用进度对话框,期待对解析 API signUpInBackground的调用。这个注册 API 是异步的。该方法接受一个回调对象,该对象提供了一个done()回调方法。在done()方法中,我们首先关闭进度对话框。然后我们判断注册方法是成功还是失败。如果注册成功,Parse 会自动将用户登录到设备上。

通过这一步,用户注册。现在,用户可以进行登录、注销等操作。

检测用户是否登录

要知道用户是否登录,您可以使用清单 13-3 中的代码片段:

清单 13-3。显示用户是否登录

private void setViewsProperly()

{

ParseUser pu = ParseUser.getCurrentUser();

if (pu == null)

{

//User is not logged in

showLoggedOutView();

return;

}

//User is logged in

showLoggedInView();

}

我们调用了ParseUsergetCurrentUser()上的静态方法。如果此方法返回有效用户,则该用户已登录。否则,用户不会登录。

注销

您可以使用清单 13-4 中的代码片段从当前会话中注销用户。

清单 13-4。从解析中注销

private void logoutFromParse()

{

ParseUser.logOut();

}

一旦调用这个 logout 方法,ParseUser.getCurrentUser()将返回一个 null,表示当前会话中没有用户。

实现登录

为了理解登录,请注意本章开头介绍的登录活动,并参见图 13-3 。在登录活动中,我们收集用户 ID 和密码。一旦我们有了这些字段,登录的代码就很简单了,如清单 13-5 所示。

清单 13-5。解析 API 以登录

public void login(View v)

{

if (validateForm() == false){

return;

}

//form is valid

String sUserid = getUserid();

String sPassword = getPassword();

turnOnProgressDialog("Login","Wait while we log you in");

ParseUser.logInInBackground(sUserid, sPassword, new LogInCallback() {

public void done(ParseUser user, ParseException e) {

turnOffProgressDialog();

if (user != null) {

reportSuccessfulLogin();

} else {

reportParseException(e);

}

}

});

}//eof-login

private void reportParseException(ParseException e)

{

String error = e.getMessage();

reportTransient("Login failed with:" + error);

}

private void reportSuccessfulLogin()

{

gotoActivity(ParseStarterProjectActivity.class);

finish();

}

同样,login 调用可以作为静态方法在ParseUser对象上使用。我们还通过提供回调在后台使用这种方法,这是 Android 与服务器端内容对话的常用模式。这种模式非常类似于使用 signup API 和几乎所有其他解析 API 的模式,因为它们都有回调,所以它们不会停止主线程。

实施重置密码

如果忘记了密码怎么办?当然,您提供了重置它的方法。参见本章开头介绍的复位活动,如图 13-5 所示。我们使用这个活动来收集帐户的电子邮件地址,并调用ParseUser类上的requestPasswordResetInBackground()静态方法。清单 13-6 中的代码展示了如何使用这个解析重置密码 API。

清单 13-6。通过解析 API 重置密码

public void resetPassword(View v)

{

if (validateForm() == false){

return;

}

String sEmail = email.getText().toString();

turnOnProgressDialog("Reset Password","Wait while we send you email with password reset");

//userid is there

ParseUser.requestPasswordResetInBackground(sEmail

new RequestPasswordResetCallback() {

public void done(ParseException e) {

turnOffProgressDialog();

if (e == null) {

reportSuccessfulReset();

} else {

reportResetError(e);

}

}

});

}//eof-reset

private void reportSuccessfulReset(){

gotoActivity(PasswordResetSuccessActivity.class);

finish();

}

private void reportResetError(ParseException e)

{

//stay on the page

//Put an error message for the exception

String message = e.getMessage();

alert("Reset Password", "Failed:" + message);

}

重置密码解析 API 的模式类似于其他解析 API。您调用方法,提供回调,并处理成功和失败。如果重置密码调用成功,Parse 将向用户发送一封电子邮件,其中包含输入新密码的 web URL。

查询用户

清单 13-7 显示了可以用来查看目前已经注册的用户的代码。参见本章开头的用户活动列表,如图 13-7 所示。

清单 13-7。查询用户

private void populateUserNameList()

{

ParseQuery query = ParseUser.getQuery();

this.turnOnProgressDialog("Going to get users", "Patience. Be Right back");

query.findInBackground(new FindCallback() {

public void done(List<ParseObject> objects, ParseException e) {

turnOffProgressDialog();

if (e == null) {

// The query was successful.

successfulQuery(objects);

} else {

// Something went wrong.

queryFailure(e);

}

}

});

}

private void successfulQuery(List<ParseObject> objects)

{

ArrayList<ParseUserWrapper> userList = new ArrayList<ParseUserWrapper>();

for(ParseObject po: objects)

{

ParseUser pu = (ParseUser)po;

ParseUserWrapper puw = new ParseUserWrapper(pu);

userList.add(puw);

}

ArrayAdapter<ParseUserWrapper> listItemAdapter =

new ArrayAdapter<ParseUserWrapper>(this

,android.R.layout.simple_list_item_1

,userList);

this.setListAdapter(listItemAdapter);

}

private void queryFailure(ParseException x)

{

this.setErrorView(x.getMessage());

}

首先使用ParseUser对象获得一个ParseQuery对象。然后在查询对象上使用 find 方法来查找用户。一旦该方法通过回调返回,您就可以检索到ParseUser对象的集合,并填充如图 13-7 所示的用户列表。当我们讨论查询Word对象时,我们将再次讨论查询对象。

在 Parse 中存储数据对象:创建一个单词

您现在知道了如何注册、登录、注销以及在需要时重置密码。这里显示的下两件事是如何在 Parse 中创建其他对象以及如何查询它们。

让我们从创建一个对象并将其存储在解析云中开始。为此,回想一下开始时出现的创建单词的屏幕(图 13-8 )。在创建单词活动中,您收集单词及其含义,并将其存储在解析云中。要使用 Parse API 做到这一点,你所要做的就是显示伪代码,如清单 13-8 所示。

清单 13-8。用于在云中保存对象的伪代码

ParseObject po = new ParseObject("word_table");

po.put("word", "prow");

po.put("word","I think it means something to do with boats and ships!");

po.saveInTheBackground(...withsomecallback-method...)

这是 Parse 提供的 API 级别。在清单 13-8 给出的 Java 伪代码中,你可以看到我们使用ParseObject作为主要的键值对的无类型集合。创建一个包含两个字段的类型化类Word可能是值得的。下面是一些伪代码(清单 13-9 ),展示了如何做到这一点。

清单 13-9。将有类型的对象作为无类型解析对象的替身的想法

public class Word extends ParseObjectWrapper

{

public Word(String word, String meaning);

public String getWord();

public String setWord(String word);

public String getMeaning();

public String setMeaning();

}

这种类型的正式类定义允许我们将单词视为 Java 对象,而不仅仅是字符串的集合。基类ParseObjectWrapper可以保存底层解析对象,并将该解析对象中的字段值存储为键值对。

下面是从我们可下载的示例项目中提取的ParseObjectWrapper的实际代码,如清单 13-10 所示。

清单 13-10。ParseObjectWrapper 类的源代码

public class ParseObjectWrapper

{

public static String f_createdAt = "createdAt";

public static String f_createdBy = "createdBy";

public static String f_updatedAt = "updatedAt";

public static String f_updatedBy = "updatedBy";

public ParseObject po;

public ParseObjectWrapper(String tablename)

{

po = new ParseObject(tablename);

po.put(f_createdBy, ParseUser.getCurrentUser());

}

public ParseObjectWrapper(ParseObject in)

{

po = in;

}

//Accessors

public ParseObject getParseObject() { return po; }

String getTablename()

{

return po.getClassName();

}

public ParseUser getCreatedBy()

{

return po.getParseUser(f_createdBy);

}

public void setCreatedBy(ParseUser in)

{

po.put(f_createdBy, in);

}

public void setUpdatedBy()

{

po.put(f_updatedBy, ParseUser.getCurrentUser());

}

public ParseUser getLastUpdatedBy()

{

return (ParseUser)po.getParseObject(f_updatedBy);

}

}//eof-class

清单 13-10 显示了我们如何设计这个解析对象包装类作为第一次尝试。它将保存一个对实际ParseObject的引用。解析对象包装器可以通过两种方式获取ParseObject。当您第一次创建一个解析对象时,您可以简单地说出该解析对象的表名是什么。或者,如果您碰巧从云中检索到了一个解析对象,并且想要修改它,那么您可以直接传入该解析对象。

这里是我们在这个解析对象包装器中构建的另一个特性:本机ParseObject并不携带哪个用户创建了它或者最后更新了它。因此,解析对象包装器为这两个附加属性提供了字段名称,以“f_...”开头。这些额外的字段允许我们存储这两种类型的用户(created bylast updated by)和每个ParseObject。给定这个解析对象包装器,清单 13-11 显示了我们如何创建Word类。

清单 13-11。使用 ParseObjectWrapper 表示类型化的对象词

public class Word

extends ParseObjectWrapper

{

//Name of the table or class for this type of object

public static String t_tablename = "WordObject";

//Only two fileds

public static String f_word = "word";

public static String f_meaning = "meaning";

public Word(String word, String meaning)

{

super(t_tablename);

setWord(word);

setMeaning(meaning);

}

public Word(ParseObject po)

{

super(po);

}

public String getWord()

{

return po.getString(f_word);

}

public void setWord(String in)

{

po.put(f_word,in);

}

public String getMeaning()

{

return po.getString(f_meaning);

}

public void setMeaning(String in)

{

po.put(f_meaning,in);

}

public String toString()

{

String word = getWord();

String user = getCreatedBy().getUsername();

return word + "/" + user;

}

}//eof-class

清单 13-11 中的Word类的代码很简单。我们使用了“t_”作为表名和“f_”作为字段名的惯例。这些静态常量与使用它们的方法一样重要。这是因为当您为该对象提供查询时,您将对查询所依据的字段使用这些字符串名称。

有了正确的Word类定义,现在就可以创建一个Word并将其存储在解析云中,如清单 13-12 所示。

清单 13-12。在云中创建和保存一个单词解析对象

public void createWord(View v){

if (validateForm() == false) {

return;

}

//form is valid

String sWord = getWord();

String sMeaning = getMeaning();

Word w = new Word(sWord, sMeaning);

turnOnProgressDialog("Saving Word", "We will be right back");

w.po.saveInBackground(new SaveCallback() {

@Override

public void done(ParseException e) {

turnOffProgressDialog();

if (e == null)    {

//no exception

wordSavedSuccessfully();

}

else    {

wordSaveFailed(e);

}

}

});

}//eof-login

private void wordSaveFailed(ParseException e)

{

String error = e.getMessage();

alert("Saving word failed", error);

}

private void wordSavedSuccessfully()

{

gotoActivity(WordListActivity.class);

//Don't finish it as back button is valid

//finish();

}

请注意,在清单 13-12 所示的代码中,您能够通过其构造函数创建一个Word对象,而不必担心在解析对象上显式设置字段名称。如果需要的话,这也可以确保您不会在每次需要字段名时都输入错误。因为 Parse 对象允许任何字段名,所以如果输入错误,最终会创建不需要的新属性。当然,可能有些地方你有理由这样做,但至少对于大多数常见的情况,这是一个很好的防火墙。

还要注意,在清单 13-12 中,您使用了来自底层解析对象包装器(w.po)的真正的 ParseObject 来启动诸如 save 之类的解析 API。

Note

这里给出的ParseObjectWrapperWord的代码被剥离出来,以向您展示我们当前需求所需的最少代码。如果您查找可下载的项目,您会看到这些类有更多的代码。当你开始通过 Android 包裹传递像“Word”这样的对象时,额外的代码是必要的。(那是下一章的主题。)所以,当您在下载的项目中看到原始代码时,请记住这一点。

现在,让我们将注意力转向如何查询这些word对象,并通过使用 Android 列表适配器将它们绘制在列表中。

查询和填充解析对象

清单 13-13 显示了如何查询类型为Word的对象。这段代码与查询 Parse 用户略有不同。

清单 13-13。查询解析对象列表

private void populateWordList()

{

ParseQuery query = new ParseQuery(Word.t_tablename);

query.orderByDescending(Word.f_createdAt);

query.include(Word.f_createdBy);

query.setCachePolicy(ParseQuery.CachePolicy.CACHE_ELSE_NETWORK);

//Milliseconds

query.setMaxCacheAge(100000L);

this.turnOnProgressDialog("Going to get words", "Patience. Be Right back");

query.findInBackground(new FindCallback() {

public void done(List<ParseObject> objects, ParseException e) {

turnOffProgressDialog();

if (e == null) {

// The query was successful.

successfulQuery(objects);

} else {

// Something went wrong.

queryFailure(e);

}

}

});

}

private void successfulQuery(List<ParseObject> objects)

{

ArrayList<Word> wordList = new ArrayList<Word>();

for(ParseObject po: objects)

{

Word puw = new Word(po);

wordList.add(puw);

}

WordListAdapter listItemAdapter =

new WordListAdapter(this

,wordList

,this);

this.setListAdapter(listItemAdapter);

}

private void queryFailure(ParseException x)

{

this.setErrorView(x.getMessage());

}

如前所述,Parse 使用一个名为ParseQuery的对象来执行查询。Parse query 对象是用您正在查询的对象类型初始化的,并且您使用了Word类来指示这是什么类型。在我们的例子中,变量Word.t_tablename指向表的名称。在 Parse 中,这个表名被称为与对象数据库一致的“类”,而不是关系数据库。

然后,使用作为排序依据的字段名称在查询中设置 order 子句。Word.f_createdBy。再次注意这里如何使用WordParseObjectWrapper上的静态字段定义来避免直接输入字符串名称。

然后以毫秒为单位设置缓存策略,这样就不会过于频繁地访问服务器。然后对查询对象调用findInBackground()方法。另外,请注意,当您被回调时,您获取返回的解析对象,并通过其构造函数将它们填充到一个Word中。这允许您在Word对象上调用常规的 Java 方法,而不用担心基于字符串的字段名。

Note

本章中没有列出但在代码中引用的所有实用函数和类都可以在参考资料部分提到的可下载项目中找到。

这基本上涵盖了使用 Parse 的一般模式,并记录了我们作为用例提出的应用是如何实现的。

下一步是什么

这一章只是对 Parse 的一点皮毛。它可能代表了 Parse 所能提供的四分之一或五分之一。尽管我们在第十五章中使用 Parse 讨论了推送通知,但我们没有空间或时间来讨论本书中的所有 Parse 主题,所以我们建议您参考 Parse 文档,这是非常好的。此外,Parse 论坛是我们见过的最好的论坛之一,包括响应有多快。

此外,在完成本章之前,我们想指出几件值得考虑的事情。首先,我们采用的将解析对象转换成 Java 对象的方法是基本的方法。您可能需要考虑增强它以满足您的需求。有一些努力,像 ParseFacade 和 BaaSFacade,正在尝试更聪明地做到这一点,使用更少的代码和更多的类型安全。考虑这些选项,看看它们如何满足您的需求。例如,BaaSFacade 不仅为 Parse backend 提供了一个 Facade,还为其他源/接收器提供了 facade,比如 StackMob,甚至是本地 SQLLite。

其次,直接解析对象的一个主要缺点是,它们不提供将对象序列化为字符串的方法。当你试图将它们通过额外的意图传递给其他活动时,这是一个问题。如果没有这种能力,您只有两个选择:要么将它们完全转换成普通的 Java 对象,然后通过 intent 防火墙传递它们,要么传递对象的 ID,然后根据它的 ID 重新查询真正的对象。我们希望,通过适当地调整缓存,后一种选择没有那么糟糕。但是手里有一个物体却不能使用它是不自然的,因为你不能通过一个意图把它传递给另一个活动。

第三,请记住,您可以使用 JSON 非常有效地序列化普通 Java 对象,并将它们作为额外的意图进行传输。但是,注意ParseObjects不能转换成 JSON 字符串。因此,您可能必须首先手动将 Parse 对象转换成普通的 Java 对象,然后使用 JSON 将它们作为 parcelables 进行传输。关于 JSON 本地存储的第四章同样适用于实现 JSON parcelables。是的,JSON 比纯手工编码的 parcelables 要冗长一些;然而,如果您的对象很小,这种方法非常有效,可以让您避免编写大量容易出错的代码。

在第十四章的中,我们将提供一个合理的解决方案,向您展示直接使用ParseObjects进行打包的中间方法。如果您迫不及待,可以看看本章的可下载项目,看看 Parse object 包装器是如何被增强以实现 parcelable 的,以及它是如何做出必要的规定以使这种方法起作用的。

因为我们已经实现了本章的示例应用,所以我们使用了第六章中提到的表单处理框架。这个高级表单处理框架为我们节省了大量时间,因为这个应用有许多表单,可以用于登录、注册、创建单词等等。

参考

我们发现以下链接对本章的研究很有帮助。

摘要

协作移动应用无处不在。在云中存储数据是这些类型的应用的基本需求。本章对流行的云存储平台之一 Parse 进行了精彩的介绍。这一章还列出了注册用户和与用户合作的标准模式。它为用户管理提供了必要的解析 API,还解释了如何存储和检索解析对象。最后,本章简要对比了关系连接和非 SQL 方法。

复习问题

以下问题应该有助于巩固您在本章中学到的知识:

What is a Parse application key and a client key?   How do you get the keys in question 1?   How do you initialize an application that needs to talk with Parse with the Parse generated keys?   What Parse APIs do you use to sign up a user?   What Parse APIs do you use to log in a user?   How do you reset a user password with Parse?   How do you log out using Parse?   Are you logged in when the signup is successful?   How is a Parse object different from a relational table?   Does a Parse object maintain the user that has either created the object or last updated it?   Why do you need to use progress dialogs when you are issuing Parse methods?   How do you cache Parse objects?   How do you tell a Parse query to include dependent objects?   Can you change an Android package name while keeping the Parse keys intact?

十四、使用 Parcelables 增强解析

Abstract

在第十三章中,我们记录了 Parse 的基本特性。我们向您展示了如何使用 Parse 创建一个帐户,以及如何使用该帐户注册用户并代表这些用户在云中存储对象。我们还向您展示了查询解析对象的基本机制。

在第十三章中,我们记录了 Parse 的基本特性。我们向您展示了如何使用 Parse 创建一个帐户,以及如何使用该帐户注册用户并代表这些用户在云中存储对象。我们还向您展示了查询解析对象的基本机制。

在这一章中,我们将讨论另外两个与解析相关的主题。首先是需要通过 intent extras 将解析对象作为 parcelables 传递。第二个是基于解析对象与其他解析对象的关系来查询解析对象的能力。

让我们更详细地讨论一下第一个话题。如果您还记得我们为解释 Parse 而介绍的类似字典的应用,有两种类型的对象是该应用特有的:单词及其含义。每个单词可以有不同用户提供的多种含义。我们现在想要创建一个屏幕,在这里我们显示一个给定单词的意思。事实证明,由于 Android 和 Parse 的原因,这有点棘手。

让我们来了解一下显示给定单词含义的屏幕有什么技巧。理想情况下,我们会选择一个Word解析对象,并将其传递给列出该单词含义的活动。一旦我们在 receiving meanings 活动中有了这个Word对象,我们就可以运行一个单词含义查询,它的父单词是传入的单词。

然而,Android SDK 有一个规定,即您不能轻易地将内存中对象的引用传递给另一个活动。只有某些礼仪对象可以这样做(比如实现IBinder的那些)。WordWordMeaning的对象一点礼仪性都没有!它们只是包含一些属性的普通 Java 对象。因此,在 Android 中,您通过 intent extras 将数据(而不是对象引用)传递给其他活动。您可以通过执行以下操作来尝试这一点:

ParseObject wordObject;

Intent i = new Intent(this,MeaningsListActivity.classname);

i.putExtra("word-parse-object", wordObject);

this.startActivity(i);

在这段代码中,变量this指向当前活动。如果成功的话,那么接收活动可以通过这种方式检索 word 对象:

Intent i = this.getIntent();

ParseObject parceledWord = (ParseObject)i.getParcelableExtra();

然而,为了让这个方法工作,类型ParseObject需要实现 Android SDK 接口Parcelable,而ParseObject不做这个!因此,为了克服这个限制,我们将向您展示:

  • 如何通过意图传递一个ParseObject
  • 如何在查询中使用 parceled ParseObject 来检索其子对象

然而,在我们开始探索这两个主题之前,让我们看一下我们将用来实现和说明这些概念的屏幕。

示例应用的用户体验

我们从第十三章中显示的单词列表开始,现在在下面的图 14-1 中给出。

A978-1-4302-4951-1_14_Fig1_HTML.jpg

图 14-1。

List of words activity

你在图 14-1 中看到的是一个单词列表。轻触单词的Meanings按钮,您将进入显示该单词含义的活动(如果有)。图 14-2 就是那个屏幕。

A978-1-4302-4951-1_14_Fig2_HTML.jpg

图 14-2。

List of meanings activity

你在图 14-2 中看到的是从上一个屏幕(图 14-1 )传入的一个单词的可用含义列表。显然,这个活动需要一个 word 对象来绘制自己。因此,当我们稍后实现该活动时,我们将展示 parcelables 在这里是如何发挥作用的。该屏幕还显示了一个为当前单词创建新含义的按钮。图 14-3 显示了为单词创建新含义的屏幕。

A978-1-4302-4951-1_14_Fig3_HTML.jpg

图 14-3。

Create a word meaning activity

虽然图 14-3 屏幕很简单,但是我们需要解决将父 word 对象作为 parcelable 传递的问题,以便我们创建的含义属于正确的 Parse word 对象。此外,因为该屏幕是从单词含义列表屏幕(图 14-2 调用的,我们需要打包一个已经从图 14-1 打包传递的单词解析对象。所以这里的任何解决方案都应该多次传递一个解析对象作为额外的意图。

现在让我们更详细地研究一下这个问题。我们还将在 Android SDK 中讨论包裹和 parcelables 背后的理论。然后,我们将展示如何实现将ParseObjects作为 parcelables 传递的解决方案。

将 ParseObjects 作为 Parcelables 传递

通过一个意图发送一个ParseObject就是让它成为一个Parcelable。但是ParseObject是一个已经被 Parse SDK 定义和冻结的类型。更复杂的是,ParseObject不仅不可打包,而且不可序列化。此外,它不能被转换成一个JSON字符串。如果它是可序列化的或者被转换成一个JSON字符串,我们就可以以那种形式将它传递给 parcelable。

这是一个可能的选择。从一个 Parse 对象中读取所有的值,并把它们放入一个 hashmap 中,然后将这个 hashmap 序列化或转换成一个字符串,然后可以在 parcelable 中传递。另一方面,您获取这个打包的散列表并构造一个解析对象——在某种程度上,您可以将它构造为原始的解析对象。但是,原始解析对象可能有一个您不知道的内部状态,因此它不能被原样复制。

然而,我们还没有使用这种将解析对象映射到 hashmaps 的方法来进行传输。一个原因是,在第十三章中,我们已经有了一个包装原始解析对象的对象。(它被称为ParseObjectWrapper。)所以,我们想用这个包装器作为一个可打包的东西,即使有一些限制。一个限制是,当这个ParseWrapperObject在另一边被重新创建时,它的核心会有一个重新创建的ParseObject,而不是原来的ParseObject。我们可以对此进行一点补偿,因为ParseObjectWrapper上的方法可以检测到嵌入的ParseObject是克隆的,并根据需要进行调整,以给人一种尽可能接近处理真实解析对象的印象。此外,终端用户对象如WordWordMeaning已经扩展了ParseObjectWrapper,所以从ParseObjectWrapper重新创建它们更容易。

所以,我们从ParseObjectWrapper开始,在那个水平上看起来是可行的。因此,我们将坚持使用 parcelable 支持来扩展ParseObjectWrapper。关于 hashmap 方法在其他方面是否更好的研究还没有完成。这些优势目前还不太明显,也不可能变得更好,所以我们坚持使用ParseObjectWrapper作为打包产品的载体。此外,正如第十三章中指出的,如果你要使用基于反射和基于接口的方法,比如 ParseFacade 或 BaaSFacade,你可能会有更好的选择。我们会把这项研究交给你。

让我们看看 parcelables 背后的理论,然后深入研究代码,看看如何使ParseObjectWrapper parcelable。

重温 Parcelables

在 Android 中,Parcels用于在进程间传递消息。它是 Android 中的 IPC(进程间通信)机制。因此,Parcel是一个消息数据流,可以通过进程间的存储转发机制进行实时通信和存储。

一个Parcel可以包含来自对象的数据,该数据在一侧被展平,然后在另一侧被展平,回到对象中。它还可以用来携带服务或文件流的对象引用或代理。一个Parcel可能包含以下内容:

  • 基元
  • 数组(4 字节长度+数据)
  • 实现 parcelable 接口的对象
  • 捆绑包(键值对,其中的值反映了上述任何内容)
  • 使用 IBinder 接口的代理对象
  • 代理的文件描述符对象

可以在 Android SDk 链接: http://developer.android.com/reference/android/os/Parcel.html 了解更多关于ParcelsParcelables的内容。

当一个对象实现Parcelable接口时,该对象向 Android SDK 承诺,它知道如何一个字段一个字段地将自己写入Parcel。这样的一个Parcelable对象也知道如何从Parcel中一个字段一个字段地创建和读取自己。

实现 Parcelable 的简单示例

让我们考虑一个简单的对象,看看它如何实现一个 parcelable,如果它想被传输的话。这个简单的对象,一个User,在清单 14-1 中给出。

清单 14-1。一个简单的可打包实现示例:用户类

public class User

implements Parcelable

{

//Add more field types later

public String userid;

public String username;

public User(String inuserid, String inusername)

{

userid = inuserid;

username = inusername;

}

public static final Parcelable.Creator<User> CREATOR

= new Parcelable.Creator<User>() {

public User createFromParcel(Parcel in) {

return new User(in);

}

public User[] newArray(int size) {

return new User[size];

}

}; //end of creator

//

@Override

public int describeContents() {

return 0;

}

public User(Parcel in)

{

userid = in.readString();

username = in.readString();

}

@Override

public void writeToParcel(Parcel dest, int flags)

{

dest.writeString(userid);

dest.writeString(username);

}

}//eof-class

这个类User只有两个字段:usernameuserid。当这个类实现Parcelable接口时,它需要能够做以下事情:

Understand to see if it needs to handle describeContents(  )   Know how to read and write its constituent attributes   Understand flags to alter the behavior of what and how to write if needed

描述内容和文件描述符

显然对于 Android SDK 来说,知道一个Parceled对象是否包含文件描述符是很重要的。该信息用于实现方法Bundle.hasFileDescriptors()。该方法又用于防止带有描述符的对象被提供给系统进程。例如,像这样的代码可以在核心 Android SDK 源代码中看到:

//Taken from ActivityManagerService.java

if (intent.hasFileDescriptors()) {

throw new IllegalArgumentException("File descriptors passed in Intent");

}

在我们的例子中,User对象甚至不远程处理文件描述符,所以我们可以安全地返回一个零,表示我们不需要操作系统进行任何特殊处理。(如果你想了解如何处理包含文件描述符的对象,请参考 Android SDK 类ParcelFileDescriptor的源代码。)

向包中读写成员

parcelable 类的第二个职责是从 parcel 对象中读取其成员并将其写入 parcel 对象。如果您看到清单 14-1 中的writeToParcel()方法,您会注意到我们正在将字符串对象写入包流。类似地,要阅读User类的成员,看一看构造函数User(Parcel p)。这个构造函数只是将值读回到它的局部变量中。

writeParcel()方法不同,User类中没有对等的readParcel()方法。相反,Android SDK 要求实现parcelable接口的类提供对知道如何实例化特定类型对象的CREATOR对象的静态引用,比如我们例子中的User。这个CREATOR对象具有createFromParcel()方法,该方法负责通过调用适当的构造函数来实例化User对象,该构造函数将 parcel 对象作为输入。

写时间可打包标志

现在让我们考虑一下可包装旗帜的细节。当您使用parcel.writeParcelable(Parcelable p, int flags)将一个对象写入一个包时,您可以传递标志,以便正在写入的包可以改变要写入的内容。Android SDK 定义和识别的唯一标志是:

PARCELABLE_WRITE_RETURN_VALUE

要知道当这个标志被传递时,Parcelable是否做了什么不同的事情,您需要检查它的文档。例如,ParcelableFileDescriptor使用了这个标志,如果被传入,它将关闭文件描述符并仅仅通过包传递它的值。在我们的例子中,User类根本不使用这个标志。

API 的另一个建议是,如果您的 parcelable 表示一个有状态的对象——比方说,一个文件描述符或一个服务的引用——您可能希望回收资源,只将代理或值传递给那些底层资源。在这种情况下,Android API 推荐这种识别标志的好方法。因此,在打包核心 Android 对象时,请注意这些对象的文档,看看它们是否支持该标志,以及行为是否会受到该标志的影响。在User示例的情况下,确实没有理由识别该标志或对其做出反应。在大多数情况下,当您写入 parcelables 时,您可以始终为describeContents()返回 0,并在写入 parcelables 时忽略这些标志。

现在您已经了解了什么是 parcelable 以及它们是如何工作的,让我们看看如何将ParseObjectWrapper实现为一个 parcelable。

实现 Parcelable ParseObjectWrapper

清单 14-2 展示了ParseObjectWrapper的源代码。当清单中的代码都在一个地方时,最好理解,但是我们将在后面讨论相关的部分。清单很长;快速浏览,浏览各个部分,看看它们是如何组合在一起的。我们解释清单后面的所有关键部分。一旦你通读了这些解释,你就可以重新阅读清单来巩固这个类的结构组成。

清单 14-2。ParseObjectWrapper 的源代码

public class ParseObjectWrapper

implements Parcelable

{

public static String f_createdAt = "createdAt";

public static String f_createdBy = "createdBy";

public static String f_updatedAt = "updatedAt";

public static String f_updatedBy = "updatedBy";

//The parse object that is being wrapped

public ParseObject po;

//Constructors

//Use this when you are creating a new one from scratch

public ParseObjectWrapper(String tablename)    {

po = new ParseObject(tablename);

po.put(f_createdBy, ParseUser.getCurrentUser());

}

//Use this to create proper shell

//For example you can do this in parcelable

public ParseObjectWrapper(String tablename, String objectId)  {

po = ParseObject.createWithoutData(tablename, objectId);

}

//Use this when you are creating from an exsiting parse obejct

public ParseObjectWrapper(ParseObject in)  {

po = in;

}

//To create derived objects like Word using the

//ParseObjectWrapper that is unparceled

public ParseObjectWrapper(ParseObjectWrapper inPow)   {

//Parseobject underneath

po = inPow.po;

//parseobject essentials if it has it

poe = inPow.poe;

}

//Accessors

public ParseObject getParseObject() { return po; }

String getTablename(){return po.getClassName();    }

public ParseUser getCreatedBy(){return po.getParseUser(f_createdBy);}

public void setCreatedBy(ParseUser in){po.put(f_createdBy, in);}

public void setUpdatedBy(){po.put(f_updatedBy, ParseUser.getCurrentUser());}

public ParseUser getLastUpdatedBy(){return

(ParseUser)po.getParseObject(f_updatedBy);    }

//Parcelable stuff

@Override

public int describeContents() {

return 0;

}

public static final Parcelable.Creator<ParseObjectWrapper> CREATOR

= new Parcelable.Creator<ParseObjectWrapper>() {

public ParseObjectWrapper createFromParcel(Parcel in) {

return create(in);

}

public ParseObjectWrapper[] newArray(int size) {

return new ParseObjectWrapper[size];

}

};  //end of creator

@Override

public void writeToParcel(Parcel parcel, int flags)

{

//Order: tablename, objectId, fieldlist, field values, essentials

//write the tablename

parcel.writeString(this.getTablename());

//write the object id

parcel.writeString(this.po.getObjectId());

//write the field list and write the field names

List<ValueField> fieldList = getFieldList();

//See how many

int i = fieldList.size();

parcel.writeInt(i);

//write each of the field types

for(ValueField vf: fieldList)      {

parcel.writeParcelable(vf, flags);

}

//You need to write the field values now

FieldTransporter ft =

new FieldTransporter(this.po

parcel,FieldTransporter.DIRECTION_FORWARD);

for(ValueField vf: fieldList)      {

//This will write the field from parse object to the parcel

ft.transfer(vf);

}

//get the essentials and write to the parcel

ParseObjectEssentials lpoe = this.getEssentials();

parcel.writeParcelable(lpoe, flags);

}

//

private static ParseObjectWrapper create(Parcel parcel)

{

//Order: tablename, objectid, fieldlist, field values, essentials

String tablename = parcel.readString();

String objectId = parcel.readString();

ParseObjectWrapper parseObject =

new ParseObjectWrapper(tablename, objectId);

//Read the valuefiled list from parcel

List<ValueField> fieldList = new ArrayList<ValueField>();

int size = parcel.readInt();

for(int i=0;i<size;i++)

{

ValueField vf = (ValueField)

parcel.readParcelable(

ValueField.class.getClassLoader());

fieldList.add(vf);

}

//add the field values

FieldTransporter ft =

new FieldTransporter(

parseObject.po, parcel

IFieldTransport.DIRECTION_BACKWARD);

for(ValueField vf: fieldList)

{

ft.transfer(vf);

}

//read essentials

ParseObjectEssentials poe =

(ParseObjectEssentials)parcel.readParcelable(

ParseObjectEssentials.class.getClassLoader());

parseObject.setParseObjectEssentials(poe);

return parseObject;

}

//have the derived classes override this

public List<ValueField> getFieldList()

{

return new ArrayList<ValueField>();

}

//To represent createdby and lastupdatedby user objects

//when parceled. We don't recreate them as ParseObjects but save their

//essential attributes in separate objects.

private ParseObjectEssentials poe;

public void setParseObjectEssentials(ParseObjectEssentials inpoe)   {

poe = inpoe;

}

public ParseObjectEssentials getEssentials()

{

if (poe != null) return poe;

Date cat = po.getCreatedAt();

Date luat = po.getUpdatedAt();

ParseUser cby = getCreatedBy();

ParseUser luby = getLastUpdatedBy();

return new ParseObjectEssentials(

cat, User.fromParseUser(cby)

luat, User.fromParseUser(luby));

}

public boolean isParcelled()

{

if (poe != null) return true;

return false;

}

//Utility methods that take into account if this

//object is parceled or not

public User getCreatedByUser()    {

if (!isParcelled())

{

//it is not parcelled so it is original

return User.fromParseUser(getCreatedBy());

}

//it is parcelled

return poe.createdBy;

}

public Date getCreatedAt()    {

if (!isParcelled())

{

//it is not parcelled so it is original

return po.getCreatedAt();

}

//it is parcelled

return poe.createdAt;

}

}//eof-class

在前一章你已经看到了这个类的基础知识。这个类现在在清单 14-2 中被扩展,以实现通过包发送ParseObjectWrapper所需的 parcelable 方法。同样,清单 14-2 中的代码假设清单 14-1 中所示的User类有以下两个额外的静态方法:

public static User getAnnonymousUser() {

return new User("0","Annonynous");

}

public static User fromParseUser(ParseUser pu) {

if (pu == null) return getAnnonymousUser();

//pu is available

String userid = pu.getObjectId();

String username = pu.getUsername();

return new User(userid,username);

}

现在,让我们剖析清单 14-2 中 parcelable ParseObjectWrapper的代码。正如在 parcelable 的讨论中所指出的,这个类的describeContents()方法返回 0,并且这个类也忽略写时间 parcelable 标志。

这个类ParseObjectWrapper中的大部分代码来自于做下面清单 14-3 中伪代码所示的事情的愿望。

清单 14-3。可打包 Parcelable ParseObjectWrapper 的伪代码

public class Word extends ParseObjectWrapper {}

public class WordMeaning extends ParseObjectWrapper {}

//On the sending side

Word wordObject;

Intent i;

i.putExtra(Word.t_tablename, wordObject);

startActivity(i,...);

//In the receiving activity

Intent i = getIntent();

Word parceledWordObject = (Word)i.getExtra(Word.t_tablename);

//Use the parceledWordObject

现在,由清单 14-2 中的 parcelable ParseObjectWrapper代码提供的解决方案并不像它应该的那样准确、精确或纯粹,但是正如你将看到的,它已经非常接近了。如清单 14-3 所示,这种高层次的理解是快速掌握ParseObjectWrapper(清单 14-2)代码的关键。

实现 writeToParcel()

清单 14-2 的关键方法是writeToParcel()方法和静态对应方法createFromParcel()。我们从writeToParcel()开始讨论。在此方法中,您将按顺序将以下元素写入地块:

tablename, objectId, fieldlist, field values, essentials

在另一端重新创建ParseObject需要表名和解析对象 ID。如上所述,没有办法克隆或序列化一个ParseObject。因此,您最终只使用表名及其解析对象 ID 创建了一个新的ParseObject。然后将 Parse 对象拥有的每个属性放入包中。

要将这些字段值从解析对象传输到宗地,您需要两个帐户的帮助。首先,我们的 parcelable 实现背后的理念是,我们不需要像WordWordMeaning这样的派生类来实现Parceleble并流式传输它们自己的字段。我们希望基类ParseObjectWrappper为我们做这项工作。这使得派生类的负担最小。

为了允许基类打包属性,我们希望派生类使用名为getFieldList()的方法来声明它们的字段。该方法返回字段名及其类型的列表。然后,我们可以将这些字段名和类型存储在包中,并在另一端检索它们,以便在新创建的目标解析对象上设置它们。这些字段定义封装在一个名为ValueField的类中,该类有两个属性:字段名及其类型。清单 14-4 是ValueField的代码。

清单 14-4。ValueField:表示字段名和类型的类

public class ValueField

implements Parcelable

{

public static String FT_int = "Integer";

public static String FT_string = "String";

public static String FT_Object = "Object";

public static String FT_unknown = "Unknown";

//Add more field types later

public String name;

public String type;

public ValueField(String inName, String inFieldType)

{

name = inName;

type = inFieldType;

}

public static final Parcelable.Creator<ValueField> CREATOR

= new Parcelable.Creator<ValueField>() {

public ValueField createFromParcel(Parcel in) {

return new ValueField(in);

}

public ValueField[] newArray(int size) {

return new ValueField[size];

}

}; //end of creator

//

@Override

public int describeContents() {

return 0;

}

public ValueField(Parcel in)   {

name = in.readString();

type = in.readString();

}

@Override

public void writeToParcel(Parcel dest, int flags)    {

dest.writeString(name);

dest.writeString(type);

}

public String toString()   {

return name + "/" + type;

}

public static ValueField getStringField(String fieldName)  {

return new ValueField(fieldName, ValueField.FT_string);

}

}//eof-class

因为需要将一个ValueField存储在包中,所以我们通过实现Parcelable各自的方法,将ValueField变成了一个Parcelable。这个ValueField类还为必需的字段类型定义了常量。现在我们只定义了几个类型;您可以通过添加其他允许的基本类型来扩展它。

因此,回头参考一下ParseObjectWrapperwriteParcel()方法,您可以看到将字段名及其类型名写到包中非常简单。

野外运输工具

下一个任务是将ParseObject的每个属性或字段的值写入包中。ParcelParseObject都提供了获取和设置值的类型化方法。所以你需要一个媒人把值从一个传递到另一个。要完成这种转换,您需要使用一个接口和几个类。这些如清单 14-5 所示。

清单 14-5。支持在 ParseObject 和 Parcel 之间传输字段的类

//Transfer value from one source to another

public interface IFieldTransport

{

public static int DIRECTION_FORWARD = 1;

public static int DIRECTION_BACKWARD= 2;

//Transfer from one mode to another

public void transfer(ValueField f);

}

//A class to transport an integer between a

//ParseObject and a Parcel

//ParseObject is source and Parcel is target

//Direction indicates how this value should be transported

public class IntegerFieldTransport

implements IFieldTransport

{

ParseObject po;

Parcel p;

int d = IFieldTransport.DIRECTION_FORWARD;

public IntegerFieldTransport(ParseObject inpo, Parcel inp){

this(inpo,inp,DIRECTION_FORWARD);

}

public IntegerFieldTransport(ParseObject inpo, Parcel inp, int direction)

{

po = inpo;

p = inp;

d = direction;

}

@Override

public void transfer(ValueField f)

{

//1

if (d == DIRECTION_BACKWARD) {

//parcel to parseobject

int i = p.readInt();

po.put(f.name, i);

}

else {

//forward

//parseobject to parcel

int i = po.getInt(f.name);

p.writeInt(i);

}

}

}

public class StringFieldTransport

implements IFieldTransport

{

ParseObject po;

Parcel p;

int d = IFieldTransport.DIRECTION_FORWARD;

public StringFieldTransport(ParseObject inpo, Parcel inp){

this(inpo,inp,DIRECTION_FORWARD);

}

public StringFieldTransport(ParseObject inpo, Parcel inp, int direction)

{

po = inpo;

p = inp;

d = direction;

}

@Override

public void transfer(ValueField f) {

if (d == DIRECTION_BACKWARD)

{

//parcel to parseobject

String s = p.readString();

po.put(f.name, s);

}

else

{

//forward

//parseobject to parcel

String s = po.getString(f.name);

p.writeString(s);

}

}

}

给定这个接口和类型转换器,您可以将它们收集在注册表中,并让注册表处理所有类型的转换。清单 14-6 是一个FieldTransporter的代码,它可以传递所有已知的字段类型。

清单 14-6。FieldTransporter:单个字段传输的注册表

public class FieldTransporter

implements IFieldTransport

{

ParseObject po;

Parcel p;

int d = IFieldTransport.DIRECTION_FORWARD;

Map<String,IFieldTransport> transporterMap;

public FieldTransporter(ParseObject inpo, Parcel inp, int direction){

po = inpo;

p = inp;

d = direction;

//Register the all the translators/tranporters

register();

}

private void register()

{

transporterMap = new HashMap<String,IFieldTransport>();

//register integers

transporterMap.put(

ValueField.FT_int

new IntegerFieldTransport(po,p,d));

//register string transporter

transporterMap.put(

ValueField.FT_string

new StringFieldTransport(po,p,d));

//Other missing transporters

}

private IFieldTransport getTransportFor(String fieldType)

{

IFieldTransport ift = transporterMap.get(fieldType);

if (ift == null)

{

throw new RuntimeException("Problem with locating the type");

}

return ift;

}

@Override

public void transfer(ValueField f)

{

IFieldTransport ift = getTransportFor(f.type);

ift.transfer(f);

}

}//eof-class

使用这个FieldTransporter,您现在可以很容易地看到writeParcel()方法是如何神奇地为从parseobjectparcel的所有字段写入值的。清单 14-7 中重复了这段代码,以便快速查看。

清单 14-7。如何使用野外运输车

//add the field values

FieldTransporter ft =

new FieldTransporter(

parseObject.po, parcel, IFieldTransport.DIRECTION_BACKWARD);

for(ValueField vf: fieldList)

{

ft.transfer(vf);

}

看看清单 14-7 中的FieldTransporter是如何用源、目标和方向实例化的;然后,对于由ParseObjectWrapper给出的每个ValueField,字段值被传输。这种方法保持了ParseObjectparcel之间写操作的类型安全。但是请记住,由于您可能有更多的字段类型,您将需要为这些类型创建 transporters,并将它们添加到上面的FieldTransporter注册中。

什么是 ParseObjectEssentials?

到目前为止,我们已经收集了源ParseObject的原始属性,并且已经转移。然而,源解析对象有一些指向其他解析对象的属性。这尤其包括两个用户对象:创建解析对象的用户和最后更新它的用户。

潜在地,您也可以将 parcelables 概念扩展到这些子对象。然而,为了我们的目的,我们采用一种更简单的方法。我们剥离这两个用户对象的本质,并将它们封装到一个自己开发的User对象中,如本章开头所述。然后,我们在一个名为ParseObjectEssentials的合并对象中捕获两个用户。一旦我们从当前被打包的ParseObject中提取出这个ParseObjectEssentials,我们就可以打包ParseObjectEssentials来代替子对象或相关的解析对象。清单 14-8 给出了ParseObjectEssentials的定义。

清单 14-8。ParseObjectEssentials 的概念

public class ParseObjectEssentials

implements Parcelable

{

//Add more fields if desired from their respective ParseObjects

public Date createdAt;

public User createdBy;

public Date lastUpdatedAt;

public User lastUpdatedBy;

public ParseObjectEssentials(Date createdAt, User createdBy

Date lastUpdatedAt, User lastUpdatedBy) {

super();

this.createdAt = createdAt;

this.createdBy = createdBy;

this.lastUpdatedAt = lastUpdatedAt;

this.lastUpdatedBy = lastUpdatedBy;

}

public static final Parcelable.Creator<ParseObjectEssentials> CREATOR

= new Parcelable.Creator<ParseObjectEssentials>() {

public ParseObjectEssentials createFromParcel(Parcel in) {

return new ParseObjectEssentials(in);

}

public ParseObjectEssentials[] newArray(int size) {

return new ParseObjectEssentials[size];

}

}; //end of creator

@Override

public int describeContents() {

return 0;

}

public ParseObjectEssentials(Parcel in)

{

createdAt = new Date(in.readLong());

createdBy = (User)in.readParcelable(User.class.getClassLoader());

lastUpdatedAt = new Date(in.readLong());

lastUpdatedBy = (User)in.readParcelable(User.class.getClassLoader());

}

@Override

public void writeToParcel(Parcel dest, int flags)

{

dest.writeLong(this.createdAt.getTime());

dest.writeParcelable(createdBy, flags);

dest.writeLong(lastUpdatedAt.getTime());

dest.writeParcelable(lastUpdatedBy, flags);

}

public static ParseObjectEssentials getDefault()

{

Date cat = new Date(0);

User auser = User.getAnnonymousUser();

Date luat = new Date(0);

return new ParseObjectEssentials(cat,auser,luat,auser);

}

}//eof-class

为了完成ParseObjectWrapperwriteToParcel(),你在包裹中保存这些ParseObjectEssentials中的一个。

接下来,我们来看看ParseObjectWrapper代码,看看如何从ParseObjectWrapper的内核嵌入式ParseObject中获得这个ParseObjectEssentials

另一方面:重新创建 ParseObjectWrapper

作为一个可打包的包,ParseObjectWrapper需要能够从包中重新创建自己。参见静态的create()函数,该函数从给定的包裹中返回ParseObjectWrapper。在这种方法中,你逆向阅读,如下所示。

首先,读取属于被打包的旧解析对象的表名和对象 ID。根据这两个参数,可以创建一个解析 shell 对象。接下来,读取字段定义,查看有多少属性可用于这个解析对象并被打包。然后使用字段传输器将每个字段及其值传输到新创建的ParseObject。你用这个ParseObject创造一个新的ParseObjectWrapper。此时,您已经有了准备返回的ParseObjectWrapper。但是,你也需要读取ParseObjectEssentials并将其设置在ParseObjectWrapper上。

ParseObjectWrapper变得非常上下文敏感,因为它可能处于多个状态。当它最初被创建时,它只是持有一个仅仅用它的表名(甚至没有 ID)创建的ParseObject,因为ParseObject甚至没有被保存在解析云中。在下一个状态,ParseObjectWrapper可能持有完全保存在解析云中的ParseObject。然后,在第三种状态下,ParseObjectWrapper可能已经被打包并在另一端重新创建。在这最后一个状态中,它保存的ParseObject只是一个替身,并不与服务器绑定。ParseObjectWrapper也持有一个ParseObjectEssentials,所以可以询问ParseObjectWrapper是否打包。

我在ParseObjectWrapper上创建了一些方法,比如 last created user 和 last updated by,以便它们考虑到ParseObjectWrapper所处的状态,并相应地返回正确的值。

把这个词分成两半

我们来看看如何把一个Word物体打包成包裹带回去。清单 14-9 显示了基于我们新的ParseObjectWrapperWord的定义。

清单 14-9。扩展 Parcelable ParseObjectWrapper

public class Word

extends ParseObjectWrapper

{

public static String t_tablename = "WordObject";

public static String PARCELABLE_WORD_ID = "WordObjectId";

//Only two fileds

public static String f_word = "word";

public static String f_meaning = "meaning";

//Constructors: A new word from scratch

public Word(String word, String meaning){

super(t_tablename);

setWord(word);

setMeaning(meaning);

}

//Wrapping from a ParseObject gotten from the cloud

public Word(ParseObject po)    {

super(po);

}

//Recreated using a previously Parceled word

public Word(ParseObjectWrapper inPow)    {

super(inPow);

}

//Accessors

public String getWord()    {

return po.getString(f_word);

}

public void setWord(String in)    {

po.put(f_word,in);

}

public String getMeaning()    {

return po.getString(f_meaning);

}

public void setMeaning(String in)    {

po.put(f_meaning,in);

}

public String toString()

{

String word = getWord();

String user = getCreatedBy().getUsername();

return word + "/" + user;

}

//have the children override this

@Override

public List<ValueField> getFieldList()

{

ArrayList<ValueField> fields = new ArrayList<ValueField>();

fields.add(ValueField.getStringField(Word.f_word));

fields.add(ValueField.getStringField(Word.f_meaning));

return fields;

}

}//eof-class

如果你回头看第十三章,你会发现这个版本的Word非常相似。主要的增加是Word类现在通过覆盖getFieldList()来提供它的字段列表。它还有一个接受ParseObjectWrapper作为输入的构造函数。这个构造函数在重新创建穿过包裹的Word时非常有用。

我们现在准备实施本章开始时介绍的WordMeanings活动(见图 14-2 )。

实施单词含义列表活动

实施WordMeaningsListActivity的主体包括以下内容:

How to invoke this activity by passing a Word through an intent   How to retrieve that word from the intent   How to access the word so that you can change the title of the activity based on the input word   How you use the word to query for its word meanings

传递单词作为额外的意图

图 14-1 中的单词列表活动屏幕显示每一行代表一个由列表 14-9 中给出的单词类定义的Word对象。如果点击图中的Meanings按钮,将需要调用图 14-2 所示的WordMeaningsListActivity。清单 14-10 显示了如何调用这个活动。

清单 14-10。传递单词作为额外的意图

private void respondToClick(WordListActivity activity, Word wordRef)

{

Intent i = new Intent(activity,WordMeaningsListActivity.class);

i.putExtra(Word.t_tablename,wordRef);

activity.startActivity(i);

}

从额外的意图中重新创造这个词

注意单词 object 是如何作为 parcelable 传递给 intent extra 的。让我们看看如何让Word对象回到另一边。清单 14-11 显示了从WordMeaningsListActivity中再次获取Word对象的代码片段。

清单 14-11。从 Intent Extra 中检索键入的单词

private Word getParceledWordFromIntent()

{

Intent i = this.getIntent();

ParseObjectWrapper pow =

(ParseObjectWrapper)i.getParcelableExtra(Word.t_tablename);

Word parceledWord = new Word(pow);

return parceledWord;

}

注意如何首先从 intent extra 中检索到一个ParseObjectWrapper,然后用它来包装Word对象。

在目标活动中使用检索到的 Word 对象

您可以使用 Word 对象来访问它的所有属性并使用它的方法。清单 14-12 显示了如何设置活动的标题。

清单 14-12。使用 Word 访问器方法的伪代码

Word parceledWord;

activity.setTitle(parceledWord.getWord());

请注意,您可以通过 intent 传递单词并使用它,而无需求助于底层的 Parse 对象。如果您不能通过 intent 传递单词,那么您必须只传递单词的解析对象 ID,并再次查询解析后端以检索单词对象,从而获得其单词字符串值并确定是谁在何时创建的。正是为了避免对服务器的第二次查询,我们才不厌其烦地编写了这么多打包代码。

使用检索到的单词来搜索其含义

让我们看看如何使用这个打包的Word对象来检索WordMeanings。首先,让我们看看一个Word和一个WordMeaning是如何连接的,如清单 14-13 所示。

清单 14-13。词义的源代码

public class WordMeaning extends ParseObjectWrapper

{

//Design the table first

public static String t_tablename = "WordMeaningObject";

public static String f_word = "word";

public static String f_meaning = "meaning";

public WordMeaning(String wordMeaning, Word inParentWord)

{

super(t_tablename);

setMeaning(wordMeaning);

setWord(inParentWord);

}

//Make sure there is a way to construct with a straight

//Parse object

public WordMeaning(ParseObject po)

{

//Create a check in the future if it is not of the same type

super(po);

}

public void setMeaning(String meaning)   {

po.put(f_meaning, meaning);

}

public void setWord(Word word)   {

po.put(f_word, word.po);

}

public String getMeaning()   {

return po.getString(f_meaning);

}

public Word getWord()   {

return new Word(po.getParseObject(f_word));

}

}

一个WordMeaning带有一个指向其父词的属性。您可以使用这个属性来查询一个给定单词的所有词义,如清单 14-14 所示。

清单 14-14。使用打包的单词查询其含义

private void populateWordMeaningsList(Word word)

{

ParseQuery query = new ParseQuery(WordMeaning.t_tablename);

query.whereEqualTo(WordMeaning.f_word, word.po);

query.orderByDescending(WordMeaning.f_createdAt);

//Include who created me

query.include(WordMeaning.f_createdBy);

//Include who the parent word is

query.include(WordMeaning.f_word);

//How can We include the owner of the word

query.include(WordMeaning.f_word + "." + Word.f_createdBy);

this.turnOnProgressDialog("Going to get word meanings for:" + word.getWord()

"Patience. Be Right back");

query.findInBackground(new FindCallback() {

public void done(List<ParseObject> objects, ParseException e) {

turnOffProgressDialog();

if (e == null) {

// The query was successful.

successfulQuery(objects);

} else {

// Something went wrong.

queryFailure(e);

}

}

});

}

private void successfulQuery(List<ParseObject> objects)

{

this.setEmptyViewToNoRows();

ArrayList<WordMeaning> wordMeaningList = new ArrayList<WordMeaning>();

for(ParseObject po: objects)

{

WordMeaning wordMeaning = new WordMeaning(po);

wordMeaningList.add(wordMeaning);

}

WordMeaningListAdapter listItemAdapter =

new WordMeaningListAdapter(this

,wordMeaningList

,this);

this.setListAdapter(listItemAdapter);

}

private void queryFailure(ParseException x)

{

this.setErrorView(x.getMessage());

}

这段代码与上一章给出的查询单词的代码非常相似。这里的区别在于如何指定涉及父单词解析对象的“where”子句。下面是刚刚给出代码中的一行:

query.whereEqualTo(WordMeaning.f_word, word.po);

请注意,您可以像在任何其他场合一样使用打包的 word 对象。其余代码与第十三章中给出的代码非常相似。

创造一个词的意义

让我们把注意力转向图 14-2 中的创建含义按钮,它调用了创建单词活动。在这里,你也可以直接从打包的单词中受益。清单 14-15 显示了如何将已经打包的单词再一次通过 intent 传递给 create word meaning 活动。

清单 14-15。将已打包的单词转移到另一个活动

public void createWordMeaning(View v)

{

Intent i = new Intent(this,CreateAMeaningActivity.class);

i.putExtra(Word.t_tablename,parceledWord);

startActivity(i);

}

注意被打包的单词是如何通过 intent extra 再次打包的。这意味着ParseObjectWrapper需要成功地知道它的状态和包裹,不管它以前没有被包裹还是已经被包裹。您可以在writeToParcel()方法中看到这一点,也可以在ParseObjectWrappercreate()方法中从包创建ParseObjectWrapper时看到这一点。

清单 14-16 显示了如何为CreateWordMeaning活动检索打包的单词。请注意,这段代码与第一次检索打包单词的代码相同,如清单 14-11 所示。

清单 14-16。检索被打包两次的单词

private Word getParceledWordFromIntent()

{

Intent i = this.getIntent();

ParseObjectWrapper pow =

(ParseObjectWrapper)i.getParcelableExtra(Word.t_tablename);

Word parceledWord = new Word(pow);

return parceledWord;

}

清单 14-17 显示了如何使用检索到的单词来填充显示在CreateWordMeaning活动中所需的单词细节。

清单 14-17。使用 ParseObjectEssentials 访问器方法

private String getWordDetail(Word pword)

{

String by = pword.getCreatedByUser().username;

Date d = pword.getCreatedAt();

DateFormat df = SimpleDateFormat.getDateInstance(DateFormat.SHORT);

String datestring =  df.format(d);

return by + "/" + datestring;

}

最后,清单 14-18 展示了如何使用传入的Word在解析云中创建一个WordMeaning

清单 14-18。打包的 ParseObjects 的进一步使用

public void createMeaning(View v)

{

if (validateForm() == false)   {

return;

}

//get meaning from the text box

String meaning = getUserEnteredMeaning();

WordMeaning wm = new WordMeaning(meaning, parceledWord);

turnOnProgressDialog("Saving Word Meaning", "We will be right back");

wm.po.saveInBackground(new SaveCallback() {

@Override

public void done(ParseException e) {

turnOffProgressDialog();

if (e == null)    {

wordMeaningSavedSuccessfully();

}

else {

wordMeaningSaveFailed(e);

}

}

});

}

private void wordMeaningSaveFailed(ParseException e) {

String error = e.getMessage();

alert("Saving word failed", error);

}

private void wordMeaningSavedSuccessfully(){

alert("word meaning saved", "Success");

}

请注意,在这段代码中,经过两次打包的单词被用作保存单词含义的父单词属性的直接目标。

参考

第十三章中给出的解析参考适用于此处。以下附加链接进一步支持本章中的内容。

摘要

本章涵盖了如何使用 parcelables 有效开发 Parse 的关键主题。我们已经展示了 parcelables 如何在 Android 中工作的详细架构。我们已经解释了为什么 parcelables 在用 Android 和 Parse 编码时很重要。我们已经展示了一个可行的框架,你可以直接使用它,也可以修改它来创建一个全新的框架,以满足既定的指导方针。在第十五章中,我们将介绍解析推送通知。

复习问题

以下问题有助于巩固您在本章中学到的知识:

Why are parcelables important while working with Parse in Android?   How do you implement a parcelable?   What is Parcelable.describeContents()?   What are parcelable flags?   What is the creator static method in a parcelable?   Are ParseObjects parcelable?   Are ParseObjects serializable?   Can ParseObjects be converted to JSON strings?   How can you query for Parse objects where an attribute points to another Parse object?   Can you create a Parse object using its Parse object ID?

十五、使用 Parse 探索推送通知

Abstract

当您发布移动应用时,您需要一种与移动应用用户沟通的方式。对于某些应用,您甚至可能希望促进用户之间的交流。移动应用的这些特征通常通过使用推送通知来实现。

当您发布移动应用时,您需要一种与移动应用用户沟通的方式。对于某些应用,您甚至可能希望促进用户之间的交流。移动应用的这些特征通常通过推送通知来实现。

在第十三章和第十四章中,我们介绍了如何使用解析云来管理用户,以及如何代表他们存储对象。在本章中,我们将介绍 Parse 的推送通知 API。我们涵盖的功能有:

How to intialize a mobile appplication so that it is enabled for Parse push notifications   How to send notifications using the Parse dashboard from the server   How to send notifications using the Parse dashboard to specific channels   How to allow mobile client applications, to send notifications to specific channels subscribed by other users, instead of using the server-side dashboard   How to capture push notifications through Android broadcast receivers

为了演示这些概念,我们将通过添加一个既能响应推送通知又能发送推送通知的活动来扩展为上两章开发的应用。我们从应用的用户体验开始,然后在本章中实现。

演示解析推送通知

如果你运行我们在本章中作为例子使用的应用,你会看到如图 15-1 所示的屏幕。该屏幕列出了可供登录用户使用的选项。

A978-1-4302-4951-1_15_Fig1_HTML.jpg

图 15-1。

Welcome screen to invoke the test push activity

请注意,图 15-1 中的欢迎屏幕与第十三章 ( 图 13-6 )中显示的屏幕相同,除了有一个额外的按钮来调用将用于接收和发送推送通知的活动。

响应推送通知

图 15-2 中的活动显示了演示如何响应和发送推送通知的用户控件。在 Java 代码中(稍后显示),这个活动被称为RespondToPushActivity .

A978-1-4302-4951-1_15_Fig2_HTML.jpg

图 15-2。

Activity to respond to and send push notifications

让我们看看关于这个RespondToPushActivity的一些事情,因为它是一个多用途的活动。

在顶部,有一个文本视图,显示作为推送通知的结果而调用此活动的意图的内容。当您从解析云推送通知消息时,通知消息会显示在 Android 设备顶部的通知栏中(或通知栏所在的任何位置,具体取决于 Android 设备和型号)。当您下拉通知消息并点击它时,图 15-2 中所示活动RespondToPushActivity将被调用。

这个活动做的第一件事是在“只读”文本视图的顶部显示调用它的意图及其细节。查看这个意图文本视图,您可以看到 Parse 是如何将消息传递给移动应用中的活动的。当我们从服务器推送这样的消息时,这是一个服务器端推送,是通过解析仪表板完成的。相比之下,图 15-2 中的活动有几个其他的按钮来触发用户端的推送。我们接下来将讨论这些发送按钮。

从客户端程序推送通知

如图 15-2 所示,RespondToPushActivity画面中还有三个附加按钮。第一个按钮使用更简单的消息类型 API Parse 将消息从客户端程序发送到其他客户端程序。第二个按钮使用某种无类型的 JSON 数据对象从客户端程序向其他客户端程序发送消息。两者相似,但你很快就会看到细微的差别。此外,这两个选项都利用了称为推送通道的东西,每个通道都可以与一个活动相关联。在示例应用中,我们使用了RespondToPushActivity(图 15-2 )作为来自通道的推送通知的目标。因为RespondToPushActivity也调查调用它的意图,这允许您查看当客户端程序推送通知时接收到的是什么类型的消息。

第三个按钮使用通道将类似的消息从客户端程序发送到其他客户端程序,但是该消息的目标不是活动,而是广播接收器。然后,广播接收器可以选择通过 Android 通知管理器进行通知。如果需要,这种基于广播接收机的方法在通知中如何显示和显示什么方面提供了更大的灵活性。

支撑屏幕

有几个支持屏幕。图 15-3 是服务器端解析仪表板的屏幕截图,您可以从这里向所有用户(或客户端)发送消息。

A978-1-4302-4951-1_15_Fig3_HTML.jpg

图 15-3。

Parse push notification dashboard

图 15-4 是解析推送通知仪表板页面的屏幕截图,该仪表板负责构建消息并将其推送到该应用的所有 Android 客户端。

A978-1-4302-4951-1_15_Fig4_HTML.jpg

图 15-4。

Constructing a push message

图 15-5 是消息如何在 Android 设备上显示的屏幕截图。更具体地说,图 15-5 是用户拖动通知面板查看通知栏中的通知时的视图。

A978-1-4302-4951-1_15_Fig5_HTML.jpg

图 15-5。

Push notification displayed on an Android device

我们现在将继续讨论如何使用 Parse push API 及其服务器端工具来实现这个应用。

实现解析推送

用 Parse 实现推送通知非常简单。Parse 维护着一个与 Parse cloud 保持联系的 Android 服务。这使得 Parse 可以直接从服务器向客户机推送消息,当然也可以通过服务器从其他客户机向客户机推送消息。

安装在设备上的每个应用都由一个“安装”解析对象表示。(关于什么是 Parse 对象以及如何安装 Parse SDK,请参考第十三章和第十四章。)安装对象需要在应用启动时初始化。它存储在每个应用的解析云中,并告诉解析云它是什么类型的设备,是否准备好接收消息,等等。

清单 15-1 显示了如何用一个安装对象初始化一个移动应用。这段代码与前两章中使用的初始化代码非常相似,只是最后一节包含了与推送相关的初始化。

清单 15-1。初始化应用以解析推送通知

public class ParseApplication extends Application {

private static String tag = "ParseApplication";

private static String PARSE_APPLICATION_ID

= "....wqHaD2m";

private static String PARSE_CLIENT_KEY

= "....WGd2p";

@Override

public void onCreate() {

super.onCreate();

// Add your initialization code here

Parse.initialize(this, PARSE_APPLICATION_ID, PARSE_CLIENT_KEY);

//Security of data

ParseACL defaultACL = new ParseACL();

// If you would like objects to be private by default, remove this line.

defaultACL.setPublicReadAccess(true);

ParseACL.setDefaultACL(defaultACL, true);

//Enable to receive push

PushService.setDefaultPushCallback(this, RespondToPushActivity.class);

ParseInstallation pi = ParseInstallation.getCurrentInstallation();

//Register a channel to test push channels

Context ctx = this.getApplicationContext();

PushService.subscribe(ctx, "ch1", RespondToPushActivity.class);

pi.saveEventually();

}

}

我们已经使用了PushService类来指示当用户点击接收到的消息时应该调用的默认活动。(我们将很快向您展示RespondToPushActivity代码的样子。)我们还使用了PushService来订阅所有针对用户定义的频道(称为ch1)的消息。

通道仅仅是对消息进行分类的一种方式,并允许发布/订阅类型的功能。值得注意的是,解析仪表板没有明确的位置来创建或维护这些通道字符串(它们的名称)。由开发人员根据需要设计这些通道字符串名称。事实上,没有正式的 API 来创建这些通道;仅仅通过订阅一个字符串,字符串就变成了一个通道!

例如,您可以设计三个活动,希望在接收消息时以它们为目标。然后,您创建(或订阅)三个频道,并将其命名为“ch1”、“ch2”和“ch3”当您将消息发送到这些通道时,就会调用相应的活动。

使用通道的另一种方式是,应用的用户可以通过应用创建通道,其他用户可以订阅这些通道。在这种情况下,作为开发人员,您有责任跟踪这个频道元数据(频道名称和它们是如何订阅的),就像您跟踪任何其他解析对象一样。

现在回到安装对象。在清单 15-1 中代码段的末尾,您通过调用saveEventually()saveInTheBackground()获得当前的安装对象并将其保存到解析云中。Parse 的早期版本在另一个服务器端调用可能正在进行时对saveInTheBackground()有一些问题,比如在同一线程上还没有完成的Parse.initialize()或更早的saveInTheBackground()。当您编写的方法调用多个直接或间接在后台操作中开始保存的 Parse SDK 方法时,这很容易发生。这个问题在最近的版本中得到了纠正,比如我们使用的版本:Parse 1.2.3。

为了完成初始化,除了清单 15-1 中的代码之外,您还需要在您的应用的清单文件中包含以下条目(清单 15-2)。

清单 15-2。解析推送通知所需的权限

<uses-permission android:name="android.permission.INTERNET" />

<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />

<uses-permission android:name="android.permission.VIBRATE" />

这些权限标签通常位于应用标签的正上方,是应用标签的兄弟。

在应用标签中,你需要清单 15-3 中的代码行。

清单 15-3。注册解析推送服务和解析广播接收器

<service android:name="com.parse.PushService" />

<receiver android:name="com.parse.ParseBroadcastReceiver">

<intent-filter>

<action android:name="android.intent.action.BOOT_COMPLETED" />

<action android:name="android.intent.action.USER_PRESENT" />

</intent-filter>

</receiver>

这段代码中的PushServiceParseBroadcastReceiver都是 Parse SDK 的一部分。清单 15-1、15-2 和 15-3 完成了初始化过程。我们现在将描述如何发送和接收解析推送消息。

从解析仪表板发送推送通知

在图 15-2 、 15-3 和 15-4 中,在描述该应用的用户体验时,我们展示了如何构建一个推送消息并将其发送到所有 Android 客户端。这个推送的消息将由RespondToPushActivity类接收。现在让我们看看示例应用的推送通知所针对的RespondToPushActivity类的代码。清单 15-4 给出了这段代码。

清单 15-4。RespondToPushActivity:响应和发送推送通知

public class RespondToPushActivity

extends BaseActivity

{

public RespondToPushActivity() {

super("RespondToPushActivity");

}

public void onCreate(Bundle savedInstanceState) {

super.onCreate(savedInstanceState);

setContentView(R.layout.rtp_respond_to_push);

examineIntent(getIntent());

}

//This activity is setup as a singletop

@Override

protected void onNewIntent(Intent intent) {

examineIntent(intent);

}

//Populate the textview with the intent that has the message

private void examineIntent(Intent i)

{

String u = i.toURI(0);

TextView tv = (TextView)findViewById(R.id.rtp_welcomeMessage);

tv.setText(u);

}

}//eof-class

用于此RespondToPushActity的布局如图 15-2 所示。请注意传入意图文本的显示和发送消息的三个按钮。

onCreate()方法的清单 15-4 采用调用意图并填充顶部的意图文本视图。你可以在方法examineIntent()中看到这一点。我们在 intent 上使用了toUri(0)方法来获得 intent 的字符串表示。这告诉我们 Parse 是如何构造要交付给活动的消息的。

此外,由于您可能会收到针对同一活动的其他消息,我们在清单文件中将该活动标记为singletop。清单 15-5 显示了这段代码。

清单 15-5。将 RespondToPushActivity 注册为 Singletop

<activity android:name="com.androidbook.parse.RespondToPushActivity"

android:launchMode="singleTop"

android:label="Respond"/>

成为一个singletop意味着如果这个活动在栈顶,那么onCreate()不会被调用。相反,调用了onNewIntent()方法。这就是清单 15-4 中的代码考虑onNewIntent()的原因。如果我们不这样做,每次有新消息到达并且用户点击它时,就会在堆栈上创建一个新的活动。但是通过将其指定为singletop,可以避免堆栈上的多个活动。

有了这个RespondToPushActivity,你就可以从服务器发送一条消息,这条消息将首先作为警告出现在你的 Android 设备的顶部。然后你向下拖动通知面板(图 15-5 )并点击后选择通知,你被带到这个RespondToPushActivity(图 15-2 ),意图显示在顶部。您将能够看到您在仪表板中键入的消息,在意向中可用。您可能希望使用此消息文本采取一些措施。

使用仪表板向频道发送消息

因为您在初始化过程中已经订阅了一个名为“ch1”的通道(清单 15-1),所以 Parse 知道有一个通道“ch1”可以作为目标。在解析推送通知仪表板中,您可以选择此通道作为目标。然后,该消息将被发送给订阅了该通道的所有用户,并最终调用同一个RespondToPushActivity,因为该活动被注册为该通道的目标活动。

通过客户端程序发送消息

看看清单 15-6 中的sendMessage()方法。您可以将此代码添加到RespondToPushActivity中,以响应图 15-2 中的发送消息按钮。

清单 15-6。从用户(客户端)发送推送通知

//A useful counter to distinguish messages

private int i = 0;

//Use Java methods to push a message

public void sendMessage(View v)

{

ParsePush push = new ParsePush();

String message = "Client message" + Integer.toString(i++);

push.setChannel("ch1");

push.setMessage(message);

push.sendInBackground();

}

在这个代码片段中,您正在使用一个名为ParsePush的对象来设置频道和消息,然后在后台发送它。该消息将会出现,并最终导致调用同一个RespondToPushActivity(图 15-2 )。但是,在解析仪表板上,您需要首先启用客户端推送。您进入 parse.com 并访问您的应用的仪表板。然后,您将看到推送的设置,在这里您会找到这个客户端推送设置。

从客户端程序发送数据形式的消息

当您使用ParsePush对象时,您可能想知道是否有可能发送除消息之外的其他数据。答案是肯定的。这是通过对ParsePush对象使用setData()方法来完成的。这个方法接受一个 JSON 对象作为它的输入。作为一个 JSON 对象,数据对象允许任意数量的键值对发送给接收者。

即使当您使用ParsePush.setMessage()方法时,它也会被转换成带有一组预定义键的 JSON 对象。作为setMessage()的一部分的消息作为密钥alert。(我们将在本章后面介绍另一个名为action的保留键。有关这些键的完整列表,请参见联机解析文档。)

现在让我们发送一个名为customdata的额外数据元素,看看这个字段是否可以在RespondToPushActivity中访问。

Note

你可能想知道你能给客户传递多大的信息。IOS 有一个大约 199 个字符的硬限制。对于 Android 设备,这可能是很大的,目前还不确定。所以如果你知道你的接收器是安卓的,你可以试着推更大的有效载荷。然而,一个更好的选择是发送一个有限的消息,然后让接收者从 Parse 中把它拉回来,以避免这种限制!

清单 15-7 中的代码展示了如何创建一个 JSON 数据对象,然后把它作为一个解析推送消息发送出去。

清单 15-7。使用 JSON 数据发送推送通知

//Use a JSON object to send a message

public void sendMessageAsData(View v)

{

JSONObject data = getJSONDataMessage();

ParsePush push = new ParsePush();

push.setChannel("ch1");

push.setData(data);

push.sendInBackground();

}

private JSONObject getJSONDataMessage()

{

try

{

JSONObject data = new JSONObject();

data.put("alert", "Main Message");

data.put("customdata", "custom data value");

return data;

}

catch(JSONException x)

{

throw new RuntimeException("Something wrong with JSON", x);

}

}

当您使用 JSON 数据对象时,使用什么键或属性来发送数据是很重要的。参考解析文档来获得可用键的句柄。

似乎没有办法从客户端向所有其他客户端发送推送消息。用户端推送消息似乎需要一个发布渠道。但是,如果你所有的移动应用都订阅一个单一的、众所周知的频道,那么在这个频道上发布的任何内容都会被所有的客户端获取。

使用客户端推送,您还可以将一组Installation对象提供给ParsePush。我们在这一章中没有涉及到,但是这很容易做到。您在安装类上获得一个查询对象,然后指定您想要作为目标的所有类型的安装对象的“where”子句。

使用广播接收器作为推送通知的目标

通过在通过 push 消息发送的 JSON 数据对象上设置不同的属性(action 属性),可以让 Parse 调用一个广播接收器,而不是触发通知栏中显示的通知。使用这种方法时,Parse 不会向通知管理器发送通知。相反,Parse 调用由 action 属性指示的广播接收器。

为了促使 Parse 这样做,需要构造一个 JSON 数据对象,它带有一个名为action的键,指向一个 Android 意图,其动作调用一个广播接收器。清单 15-8 中的代码展示了如何创建这样一个 JSON 数据对象,并将其作为推送通知发送。

清单 15-8。发送推送通知以调用广播接收器

//Use JSON data to send a message to a broadcast receiver

public void sendMessageAsIntent(View v)

{

JSONObject data = getJSONDataMessageForIntent();

ParsePush push = new ParsePush();

push.setChannel("ch1");

push.setData(data);

push.sendInBackground();

}

//Notice how the 'action' attribute enables the

//broadcast receiver behavior.

private JSONObject getJSONDataMessageForIntent()

{

try

{

JSONObject data = new JSONObject();

//Notice alert is not required

//data.put("alert", "Message from Intent");

//instead action is used

data.put("action", TestBroadcastReceiver.ACTION);

data.put("customdata", "custom data value");

return data;

}

catch(JSONException x)

{

throw new RuntimeException("Something wrong with JSON", x);

}

}

当你使用动作属性时,确保不要同时使用alert。如果这样做,Parse 似乎会两者都做——调用广播接收器并发送通知。

在清单 15-8 中,JSON 对象的 action 属性指向常量TestBroadcastReceiver.ACTION。这是在代码中为TestBroadcastReceiver定义的字符串。清单 15-9 显示了TestBroadcastReceiver的代码。注意,动作的值是com.androidbook.parse.TestPushAction

清单 15-9。TestBroadcastReceiver 的源代码

public class TestBroadcastReceiver

extends BroadcastReceiver

{

public static final String ACTION="com.androidbook.parse.TestPushAction";

public static final String PARSE_EXTRA_DATA_KEY="com.parse.Data";

public static final String PARSE_JSON_ALERT_KEY="alert";

public static final String PARSE_JSON_CHANNELS_KEY="com.parse.Channel";

private static final String TAG = "TestBroadcastReceiver";

@Override

public void onReceive(Context context, Intent intent)

{

try

{

String action = intent.getAction();

//"com.parse.Channel"

String channel =

intent.getExtras()

.getString(PARSE_JSON_CHANNELS_KEY);

JSONObject json =

new JSONObject(

intent.getExtras()

.getString(PARSE_EXTRA_DATA_KEY));

Log.d(TAG, "got action " + action + " on channel " + channel + " with:");

Iterator itr = json.keys();

while (itr.hasNext())

{

String key = (String) itr.next();

Log.d(TAG, "…" + key + " => " + json.getString(key));

}

notify(context,intent,json);

}

catch (JSONException e)

{

Log.d(TAG, "JSONException: " + e.getMessage());

}

}

private void notify(Context ctx, Intent i, JSONObject dataObject)

throws JSONException

{

NotificationManager nm = (NotificationManager)

ctx.getSystemService(Context.NOTIFICATION_SERVICE);

int icon = R.drawable.robot;

String tickerText =

dataObject.getString("customdata");

long when = System.currentTimeMillis();

Notification n = new Notification(icon, tickerText, when);

//Let the intent invoke the respond activity

Intent intent = new Intent(ctx, RespondToPushActivity.class);

//Load it with parse data

intent.putExtra("com.parse.Data"

i.getExtras().getString("com.parse.Data"));

PendingIntent pi = PendingIntent.getActivity(ctx, 0, intent, 0);

n.setLatestEventInfo(ctx, "Parse Alert", tickerText, pi);

n.flags |= Notification.FLAG_AUTO_CANCEL;

nm.notify(1, n);

}

}//eof-class

按照广播接收器的规则,这个接收器需要在 Android manifest 文件中注册,如清单 15-10 所示。

清单 15-10。注册 TestBroadcastReceiver

<receiver

android:name=".TestBroadcastReceiver"

android:exported="false"

>

<intent-filter>

<action android:name="com.androidbook.parse.TestPushAction" />

</intent-filter>

</receiver>

注意,JSON 对象中的动作名称与测试广播接收器的动作过滤器相匹配。当这个消息被推送到 Android 设备时,TestBroadcastReceiver就是这样被调用的。

使用这个广播接收器你做了两件事(清单 15-9)。首先,您正在检索解析通过 intent 发送的 JSON 数据对象。从这个 JSON 对象中,您可以提取标准字段以及您已经准备好的任何定制字段。接下来,您将构建自己的通知并将其发送给通知管理器。(您可能想从广播接收器中弹出一个活动,但这不是一个好的做法,因为无论用户正在做什么,用户都会受到上下文的干扰。此外,想象一下,如果多个广播接收机开始这样做,会发生什么。从广播接收器提醒用户的良好做法是向通知管理器发送通知。)您还在通知上设置了一个新的意图,以便当用户点击通知时调用相同的RespondToPushActivity

参考

第十三章和第十四章中给出的 Parse 参考在这里是适用的。以下附加链接进一步支持本章中的内容。

摘要

Parse.com 可以把你的移动应用带到他们以前没去过的地方。使用 Parse,您将能够更好地与您的用户群进行交流。有可能编写协作应用,将群体和社区的力量推向前台。

Parse.com 通过一个非常好的 API 和一个基于 web 的仪表板提供存储、用户管理和推送通知来实现这种通信。随着脸书收购 Parse,我们希望看到这个平台迅速成熟,甚至可能在明年凭借新的想法超越其他人。

这一章很好地介绍了你可以用解析推送通知做的一些事情。要了解更多特性,请访问 Parse 网站,了解我们顺便提及或根本没有涉及的其他内容。

复习问题

以下问题应巩固您在本章中学到的知识:

How does Parse accomplish push notifications?   How do you set up your application to work with Parse push notifications?   What kind of dashboard support is there to work with push notifications in Parse?   What are installation objects?   What are the Parse services and receivers that need to be registered in your application?   What are channels?   How do you manage channels? Do they need to be predefined?   How do you send messages only to certain devices based on a query?   How do you write broadcast receivers that can be triggered by push messages?   What is the difference between mobile clients sending push messages and a server using a dashboard to send push messages?   Is there a message size limitation for Push notifications?   What is the role of JSON data objects in sending and receiving push notifications?   How do you retrieve and construct a JSON data object using intent extras?

posted @   绝不原创的飞龙  阅读(18)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· [翻译] 为什么 Tracebit 用 C# 开发
· 腾讯ima接入deepseek-r1,借用别人脑子用用成真了~
· Deepseek官网太卡,教你白嫖阿里云的Deepseek-R1满血版
· DeepSeek崛起:程序员“饭碗”被抢,还是职业进化新起点?
· RFID实践——.NET IoT程序读取高频RFID卡/标签
点击右上角即可分享
微信分享提示