Android5 高级教程(七)
二十二、触摸屏
许多 Android 设备都集成了触摸屏。当设备没有物理键盘时,大部分用户输入必须通过触摸屏。因此,您的应用通常需要能够处理来自用户的触摸输入。您很可能已经见过当用户需要输入文本时,屏幕上显示的虚拟键盘。我们在第十九章中使用了 touch 和地图应用。到目前为止,触摸屏界面的实现对您是隐藏的,但是现在我们将向您展示如何利用触摸屏。
本章由三个主要部分组成。第一部分将处理 MotionEvent 对象,这是 Android 如何告诉应用用户正在触摸触摸屏。我们还将介绍速度跟踪器。第二部分将处理多点触摸 ,用户可以在触摸屏上一次使用多个手指。最后,我们将包括一个关于手势的部分,这是一种特殊类型的功能,触摸序列可以被解释为命令。
理解运动事件
在这一节,我们将介绍 Android 如何告诉应用用户的触摸事件。现在,我们将只关注一次一个手指触摸屏幕(我们将在后面的章节中讨论多点触摸)。
在硬件层面,触摸屏由特殊材料制成,可以拾取压力并将其转换为屏幕坐标。关于触摸的信息被转换成数据,这些数据被传递给软件进行处理。
运动事件对象
当用户触摸 Android 设备的触摸屏时,会创建一个 MotionEvent 对象。运动事件包含关于触摸发生的时间和地点的信息,以及触摸事件的其他细节。运动事件对象被传递给应用中的一个适当的方法。这可能是一个视图对象的 onTouchEvent() 方法。记住视图类是 Android 中相当多类的父类,包括布局 s、按钮 s、列表 s、时钟 s 等等。这意味着我们可以使用触摸事件与所有这些不同类型的视图对象进行交互。当该方法被调用时,它可以检查 MotionEvent 对象来决定做什么。例如, GoogleMap 可以使用触摸事件来横向移动地图,以允许用户将地图平移到其他感兴趣的点。虚拟键盘对象可以接收触摸事件来激活虚拟键,以向用户界面(UI)的某个其他部分提供文本输入。
接收 MotionEvent 对象
运动事件对象是与用户触摸相关的一系列事件之一。该序列从用户第一次触摸触摸屏时开始,通过手指在触摸屏表面上的任何移动继续,并在手指从触摸屏抬起时结束。手指的初始触摸(一个动作 _ 下动作)、侧向移动(动作 _ 移动动作)、上事件(一个动作 _ 上动作)都创建运动事件对象。在您接收到最终的 ACTION_UP 事件 之前,随着手指在表面上移动,您可能会接收到相当多的 ACTION_MOVE 事件。每个 MotionEvent 对象包含关于正在执行什么动作、触摸发生在哪里、施加了多少压力、触摸有多大、动作何时发生以及初始 ACTION_DOWN 何时发生的信息。还有第四种可能的动作,就是动作 _ 取消。该动作用于指示触摸序列在没有实际做任何事情的情况下结束。最后,还有 ACTION_OUTSIDE ,它设置在一个特殊的情况下,触摸发生在我们的窗口之外,但我们仍然可以发现它。
还有另一种方法来接收触摸事件,那就是为一个视图对象上的触摸事件注册一个回调处理器。接收事件的类必须实现视图。OnTouchListener 接口,必须调用视图对象的 setOnTouchListener() 方法 来设置该视图的处理器。视图的实现类。OnTouchListener 必须实现 onTouch() 方法。而 onTouchEvent() 方法 只取一个运动事件对象作为参数, onTouch() 同时取一个视图和一个运动事件对象作为参数。这是因为 OnTouchListener 可以接收多个视图的 MotionEvent 对象。这将在我们的下一个示例应用中变得更加清晰。
如果一个 MotionEvent 处理器(通过 onTouchEvent() 或 onTouch() 方法)使用了该事件,而其他人不需要知道它,该方法应该返回 true 。这告诉 Android 事件不需要传递给任何其他视图。如果视图对象对该事件或任何与该触摸序列相关的未来事件不感兴趣,则返回假。基类视图的 onTouchEvent() 方法不做任何事情,返回 false 。视图的子类可能会也可能不会做同样的事情。例如,按钮对象会消耗一个触摸事件,因为触摸相当于点击,因此从 onTouchEvent() 方法返回 true 。当接收到一个 ACTION_DOWN 事件 时,按钮会改变颜色,表示正在被点击。按钮也希望接收 ACTION_UP 事件,以了解用户何时放开,从而可以启动单击按钮的逻辑。如果一个按钮对象从 onTouchEvent() 返回了 false ,那么当用户从触摸屏上抬起手指时,它将不再接收任何 MotionEvent 对象来告诉它。
当我们希望触摸事件对特定的视图对象做一些新的事情时,我们可以扩展该类,覆盖 onTouchEvent() 方法,并将我们的逻辑放在那里。我们也可以贯彻的观点。OnTouchListener 接口,并在视图对象上设置一个回调处理器。通过用 onTouch() 设置一个回调处理器, MotionEvent s 将在它们转到视图的 onTouchEvent() 方法之前首先被传递到那里。只有当 onTouch() 方法返回 false 时,我们的视图的 onTouchEvent() 方法才会被调用。让我们来看看我们的示例应用,在这里应该更容易看到这一点。
注意我们会在本章末尾给你一个 URL,你可以用它来下载本章的项目。这将允许您将这些项目直接导入到 IDE 中。
设置示例应用
清单 22-1 显示了一个布局文件的 XML。从这个布局开始创建一个新的 Android 项目。
清单 22-1 。TouchDemo1 的 XML 布局文件 ??
<?xml version="1.0" encoding="utf-8"?>
<!-- This file is res/layout/main.xml -->
<LinearLayout xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical" >
<RelativeLayout android:id="@+id/layout1"
android:tag="trueLayoutTop" android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1" >
<com.androidbook.touch.demo1.TrueButton android:text="Returns True"
android:id="@+id/trueBtn1" android:tag="trueBtnTop"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<com.androidbook.touch.demo1.FalseButton android:text="Returns False"
android:id="@+id/falseBtn1" android:tag="falseBtnTop"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/trueBtn1" />
</RelativeLayout>
<RelativeLayout android:id="@+id/layout2"
android:tag="falseLayoutBottom" android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1" android:background="#FF00FF" >
<com.androidbook.touch.demo1.TrueButton android:text="Returns True"
android:id="@+id/trueBtn2" android:tag="trueBtnBottom"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<com.androidbook.touch.demo1.FalseButton android:text="Returns False"
android:id="@+id/falseBtn2" android:tag="falseBtnBottom"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/trueBtn2" />
</RelativeLayout>
</LinearLayout>
关于这种布局,有几点需要指出。我们在 UI 对象上加入了标签,当事件发生时,我们将能够在代码中引用这些标签。我们使用了定制对象(真按钮和假按钮)。你会在 Java 代码中看到这些是从按钮类扩展而来的类。因为这些是按钮,所以我们可以使用我们在其他按钮上使用的所有 XML 属性。图 22-1 显示了这个布局,清单 22-2 显示了我们的按钮 Java 代码。
图 22-1 。我们的 TouchDemo1 应用的 UI
清单 22-2 。TouchDemo1 的按钮类 的 Java 代码
// This file is BooleanButton.java
public abstract class BooleanButton extends Button {
protected boolean myValue() {
return false;
}
public BooleanButton(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
String myTag = this.getTag().toString();
Log.v(myTag, "-----------------------------------");
Log.v(myTag, MainActivity.describeEvent(this, event));
Log.v(myTag, "super onTouchEvent() returns " +
super.onTouchEvent(event));
Log.v(myTag, "and I'm returning " + myValue());
return(myValue());
}
}
// This file is TrueButton.java
public class TrueButton extends BooleanButton {
protected boolean myValue() {
return true;
}
public TrueButton(Context context, AttributeSet attrs) {
super(context, attrs);
}
}
// This file is FalseButton.java
public class FalseButton extends BooleanButton {
public FalseButton(Context context, AttributeSet attrs) {
super(context, attrs);
}
}
构建了 BooleanButton 类,以便我们可以重用 onTouchEvent() 方法,我们已经通过添加日志记录对其进行了定制。然后,我们创建了 TrueButton 和 FalseButton ,它们将对传递给它们的 MotionEvent s 做出不同的响应。当您查看主活动代码时,这将变得更加清晰,如清单 22-3 所示。
清单 22-3 。我们主要活动的 Java 代码
// This file is MainActivity.java
import android.view.MotionEvent;
import android.view.View.OnTouchListener;
public class MainActivity extends Activity implements OnTouchListener {
/** Called when the activity is first created. */
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
RelativeLayout layout1 =
(RelativeLayout) findViewById(R.id.layout1);
layout1.setOnTouchListener(this);
Button trueBtn1 = (Button)findViewById(R.id.trueBtn1);
trueBtn1.setOnTouchListener(this);
Button falseBtn1 = (Button)findViewById(R.id.falseBtn1);
falseBtn1.setOnTouchListener(this);
RelativeLayout layout2 =
(RelativeLayout) findViewById(R.id.layout2);
layout2.setOnTouchListener(this);
Button trueBtn2 = (Button)findViewById(R.id.trueBtn2);
trueBtn2.setOnTouchListener(this);
Button falseBtn2 = (Button)findViewById(R.id.falseBtn2);
falseBtn2.setOnTouchListener(this);
}
@Override
public boolean onTouch(View v, MotionEvent event) {
String myTag = v.getTag().toString();
Log.v(myTag, "-----------------------------");
Log.v(myTag, "Got view " + myTag + " in onTouch");
Log.v(myTag, describeEvent(v, event));
if( "true".equals(myTag.substring(0, 4))) {
/* Log.v(myTag, "*** calling my onTouchEvent() method ***");
v.onTouchEvent(event);
Log.v(myTag, "*** back from onTouchEvent() method ***"); */
Log.v(myTag, "and I'm returning true");
return true;
}
else {
Log.v(myTag, "and I'm returning false");
return false;
}
}
protected static String describeEvent(View view, MotionEvent event) {
StringBuilder result = new StringBuilder(300);
result.append("Action: ").append(event.getAction()).append("\n");
result.append("Location: ").append(event.getX()).append(" x ")
.append(event.getY()).append("\n");
if( event.getX() < 0 || event.getX() > view.getWidth() ||
event.getY() < 0 || event.getY() > view.getHeight()) {
result.append(">>> Touch has left the view <<<\n");
}
result.append("Edge flags: ").append(event.getEdgeFlags());
result.append("\n");
result.append("Pressure: ").append(event.getPressure());
result.append(" ").append("Size: ").append(event.getSize());
result.append("\n").append("Down time: ");
result.append(event.getDownTime()).append("ms\n");
result.append("Event time: ").append(event.getEventTime());
result.append("ms").append(" Elapsed: ");
result.append(event.getEventTime()-event.getDownTime());
result.append(" ms\n");
return result.toString();
}
}
我们的主活动代码在按钮和布局上设置了回调,这样我们就可以处理 UI 中所有的触摸事件( MotionEvent 对象)。我们增加了许多日志记录,所以你将能够准确地告诉触摸事件发生时发生了什么。另一个好主意是将以下标记添加到您的清单文件中,这样谷歌 Play 商店将知道您的应用需要触摸屏才能工作:。例如,谷歌电视没有触摸屏,所以试图在那里运行这个应用没有意义。当您编译并运行这个应用时,您应该会看到一个类似于图 22-1 的屏幕。
运行示例应用
为了充分利用这个应用,你需要在你的 IDE (Eclipse 或 Android Studio)中打开 LogCat,在你触摸触摸屏的时候观察信息的飞逝。这在模拟器和真实设备上都有效。我们还建议您最大化 LogCat 窗口,这样您可以更容易地上下滚动来查看该应用生成的所有事件。要最大化窗口,只需双击 LogCat 选项卡。现在,转到应用 UI,触摸并释放最上面标有 Returns True 的按钮(如果您正在使用模拟器,请使用鼠标单击并释放按钮)。您应该看到 LogCat 中至少记录了两个事件。这些消息被标记为来自 trueBtnTop ,并从主活动中的 onTouch() 方法记录下来。关于 onTouch() 方法的代码,请参见 MainActivity.java。在查看 LogCat 输出时,可以看到哪些方法调用产生了这些值。例如,Action 之后显示的值来自于 getAction() 方法。清单 22-4 显示了您可能在示例应用的 LogCat 中看到的示例。
清单 22-4 。来自 TouchDemo1 的示例 LogCat 消息
trueBtnTop -----------------------------
trueBtnTop Got view trueBtnTop in onTouch
trueBtnTop Action: 0
trueBtnTop Location: 42.8374 x 25.293747
trueBtnTop Edge flags: 0
trueBtnTop Pressure: 0.05490196 Size: 0.2
trueBtnTop Down time: 24959412ms
trueBtnTop Event time: 24959412ms Elapsed: 0 ms
trueBtnTop and I'm returning true
trueBtnTop -----------------------------
trueBtnTop Got view trueBtnTop in onTouch
trueBtnTop Action: 2
trueBtnTop Location: 42.8374 x 25.293747
trueBtnTop Edge flags: 0
trueBtnTop Pressure: 0.05490196 Size: 0.2
trueBtnTop Down time: 24959412ms
trueBtnTop Event time: 24959530ms Elapsed: 118 ms
trueBtnTop and I'm returning true
trueBtnTop -----------------------------
trueBtnTop Got view trueBtnTop in onTouch
trueBtnTop Action: 1
trueBtnTop Location: 42.8374 x 25.293747
trueBtnTop Edge flags: 0
trueBtnTop Pressure: 0.05490196 Size: 0.2
trueBtnTop Down time: 24959412ms
trueBtnTop Event time: 24959567ms Elapsed: 155 ms
trueBtnTop and I'm returning true
了解运动事件内容
第一个事件的动作为 0,即动作 _ 下降。最后一个事件的 action 为 1,即 ACTION_ UP 。如果您使用真实设备,您可能会看到两个以上的事件。在 ACTION_DOWN 和 ACTION_UP 之间的任何事件最有可能有一个 ACTION 2,即 ACTION _MOVE。另一种可能是 3 的一个动作,也就是动作 _ 取消 ,或者 4,也就是动作 _ 在 之外。当在真实的触摸屏上使用真实的手指时,你不能总是在表面没有轻微移动的情况下触摸和释放,所以期待一些 ACTION_MOVE 事件。
请注意位置值。一个运动事件的位置有一个 x 和 y 分量,其中 x 代表从视图对象的左侧到被触摸点的距离,y 代表从视图对象的顶部到被触摸点的距离。
在模拟器中,压力可能是 1.0,大小可能是 0.0。对于真实的设备,压力代表手指按下的力度,大小代表触摸的力度。如果用小指尖轻轻触摸,压力和大小的值会很小。如果用拇指使劲按,压力和大小都会大一些。用拇指轻轻按压会产生一个较小的压力值,但会产生一个较大的尺寸值。文档说压力和大小的值将在 0 和 1 之间。然而,由于硬件的差异,在您的应用中很难使用任何绝对数字来决定压力和尺寸。当运动事件在您的应用中发生时,比较它们之间的压力和大小是很好的,但是如果您决定压力必须超过诸如 0.8 之类的值才能被认为是硬按压,那么您可能会遇到麻烦。在那个特定的设备上,你可能永远得不到高于 0.8 的值。你可能得不到高于 0.2 的值。
停机时间和事件时间值在模拟器和真实设备之间以相同的方式运行,唯一的区别是真实设备具有大得多的值。经过的时间是一样的。
边缘标志用于检测触摸何时到达物理屏幕的边缘。Android SDK 文档称,标志被设置为指示触摸已经与显示器的边缘相交(顶部、底部、左侧或右侧)。然而, getEdgeFlags() 方法可能总是返回零,这取决于它在什么设备或仿真器上使用。对于一些硬件来说,实际检测显示器边缘的触摸太困难了,所以 Android 应该将位置固定到边缘,并为您设置适当的边缘标志。这种情况并不总是发生,所以您不应该依赖于正确设置的边缘标志。 MotionEvent 类提供了一个 setEdgeFlags() 方法,这样你就可以自己设置标志了。
最后要注意的是,我们的 onTouch() 方法返回 true ,因为我们的 TrueButton 被编码为返回 true 。返回 true 告诉 Androidmotion event 对象已经被消费,没有理由再给别人。它还告诉 Android 继续从这个触摸序列向这个方法发送触摸事件。这就是为什么我们得到了 ACTION_UP 事件,以及真实设备情况下的 ACTION_MOVE 事件。
现在触摸屏幕顶部附近的退货错误按钮 。清单 22-5 显示了退货误触的示例 LogCat 输出。
清单 22-5 。触摸顶部返回假按钮的示例 LogCat
falseBtnTop -----------------------------
falseBtnTop Got view falseBtnTop in onTouch
falseBtnTop Action: 0
falseBtnTop Location: 61.309372 x 44.281494
falseBtnTop Edge flags: 0
falseBtnTop Pressure: 0.0627451 Size: 0.26666668
falseBtnTop Down time: 28612178ms
falseBtnTop Event time: 28612178ms Elapsed: 0 ms
falseBtnTop and I'm returning false
falseBtnTop -----------------------------------
falseBtnTop Action: 0
falseBtnTop Location: 61.309372 x 44.281494
falseBtnTop Edge flags: 0
falseBtnTop Pressure: 0.0627451 Size: 0.26666668
falseBtnTop Down time: 28612178ms
falseBtnTop Event time: 28612178ms Elapsed: 0 ms
falseBtnTop super onTouchEvent() returns true
falseBtnTop and I'm returning false
trueLayoutTop -----------------------------
trueLayoutTop Got view trueLayoutTop in onTouch
trueLayoutTop Action: 0
trueLayoutTop Location: 61.309372 x 116.281494
trueLayoutTop Edge flags: 0
trueLayoutTop Pressure: 0.0627451 Size: 0.26666668
trueLayoutTop Down time: 28612178ms
trueLayoutTop Event time: 28612178ms Elapsed: 0 ms
trueLayoutTop and I'm returning true
trueLayoutTop -----------------------------
trueLayoutTop Got view trueLayoutTop in onTouch
trueLayoutTop Action: 2
trueLayoutTop Location: 61.309372 x 111.90039
trueLayoutTop Edge flags: 0
trueLayoutTop Pressure: 0.0627451 Size: 0.26666668
trueLayoutTop Down time: 28612178ms
trueLayoutTop Event time: 28612217ms Elapsed: 39 ms
trueLayoutTop and I'm returning true
trueLayoutTop -----------------------------
trueLayoutTop Got view trueLayoutTop in onTouch
trueLayoutTop Action: 1
trueLayoutTop Location: 55.08958 x 115.30792
trueLayoutTop Edge flags: 0
trueLayoutTop Pressure: 0.0627451 Size: 0.26666668
trueLayoutTop Down time: 28612178ms
trueLayoutTop Event time: 28612361ms Elapsed: 183 ms
trueLayoutTop and I'm returning true
现在你看到了非常不同的行为,所以我们将解释发生了什么。Android 在一个 MotionEvent 对象中接收 ACTION_DOWN 事件,并将其传递给我们在 MainActivity 类中的 onTouch() 方法。我们的 onTouch() 方法在 LogCat 中记录信息,并返回 false 。这告诉 Android 我们的 onTouch() 方法没有使用事件,所以 Android 期待调用下一个方法,在我们的例子中是我们的 FalseButton 类的被覆盖的 onTouchEvent() 方法。因为 FalseButton 是 BooleanButton 类的扩展,请参考 BooleanButton.java 中的 onTouchEvent() 方法查看代码。在 onTouchEvent() 方法中,我们再次向 LogCat 写入信息,我们调用父类的 onTouchEvent() 方法,然后我们也返回 false 。请注意,LogCat 中的位置信息与之前完全相同。这应该是意料之中的,因为我们仍然在同一个视图对象中,即假按钮。我们看到我们的父类想要从 onTouchEvent() 返回 true ,我们可以看出为什么。如果您在 UI 中查看按钮,它应该与 Returns True 按钮的颜色不同。我们的退货错误按钮现在看起来像是被按了一半。也就是说,它看起来就像一个按钮被按下但没有释放时的样子。我们的自定义方法返回了假而不是真。因为我们再次告诉 Android 我们没有消费这个事件,通过返回 false ,Android 从不发送 ACTION_UP 事件给我们的按钮,所以我们的按钮不知道手指曾经离开触摸屏。因此,我们的按钮仍然处于按下状态。如果我们像父母希望的那样返回了 true ,我们最终会收到 ACTION_UP 事件,因此我们可以将颜色改回正常的按钮颜色。概括一下,每次我们从一个 UI 对象为接收到的 MotionEvent 对象返回 false 时,Android 停止向那个 UI 对象发送 MotionEvent 对象,Android 继续寻找另一个 UI 对象来消费我们的 MotionEvent 对象。
您可能已经意识到,当我们触摸 Returns True 按钮时,按钮的颜色没有变化。为什么会这样?嗯,我们的 onTouch() 方法是在任何实际的按钮方法被调用之前被调用的,并且 onTouch() 返回 true ,所以 Android 从不费心调用 Returns True 按钮的 onTouchEvent() 方法。如果添加一个 v.onTouchEvent(事件);行到 onTouch() 方法就在返回 true 之前,你会看到按钮改变颜色。您还会在 LogCat 中看到更多的日志行,因为我们的 onTouchEvent() 方法也在向 LogCat 写入信息。
让我们继续检查 LogCat 的输出。现在 Android 已经两次尝试为 ACTION_DOWN 事件寻找消费者,但都失败了,它转到应用中可能接收事件的下一个视图,在我们的例子中是按钮下面的布局。我们调用了我们的顶部布局 trueLayoutTop ,我们可以看到它接收了 ACTION_DOWN 事件。
注意,我们的 onTouch() 方法再次被调用,尽管现在是在布局视图中而不是在按钮视图中。传递给 onTouch() 用于 trueLayoutTop 的 MotionEvent 对象的所有内容都与之前相同,包括时间,除了位置的 y 坐标。按钮的 y 坐标从 44.281494 更改为布局的 116.281494。这是有意义的,因为 Returns False 按钮不在布局的左上角,而是在 Returns True 按钮的下面。因此,触摸相对于布局的 y 坐标大于相同触摸相对于按钮的 y 坐标;触摸距离布局的上边缘比距离按钮的上边缘更远。因为 trueLayoutTop 的 onTouch() 返回 true ,Android 将其余的触摸事件发送到布局,我们看到的日志记录对应于 ACTION _MOVE 和 ACTION_UP 事件。继续操作,再次触摸顶部的 Returns False 按钮,您会注意到出现了一组相同的日志记录。即 false bttop 调用 onTouch() ,其余事件 trueLayoutTop 调用 false bttop 调用 onTouchEvent() 。Android 每次只停止向按钮发送一个触摸序列的事件。对于一个新的触摸事件序列,Android 将发送到按钮,除非它从被调用的方法获得另一个返回 false ,在我们的示例应用中仍然是这样。
现在用手指触摸顶部布局,但不要触摸任何按钮,然后拖动手指一点,并将其抬离触摸屏(如果您使用的是仿真器,只需使用鼠标做出类似的动作)。请注意 LogCat 中的日志消息流,其中第一条记录有一个动作 ACTION_DOWN ,然后许多 ACTION_MOVE 事件之后是一个 ACTION_UP 事件。
现在,触摸顶部的 Returns True 按钮,在从按钮上抬起手指之前,在屏幕上拖动手指,然后抬起。清单 22-6 显示了 LogCat 中的一些新信息。
清单 22-6 。LogCat 记录 显示我们视野之外的触摸
[ ... log messages of an ACTION_DOWN event followed by some ACTION_MOVE events ... ]
trueBtnTop Got view trueBtnTop in onTouch
trueBtnTop Action: 2
trueBtnTop Location: 150.41768 x 22.628128
trueBtnTop >>> Touch has left the view <<<
trueBtnTop Edge flags: 0
trueBtnTop Pressure: 0.047058824 Size: 0.13333334
trueBtnTop Down time: 31690859ms
trueBtnTop Event time: 31691344ms Elapsed: 485 ms
trueBtnTop and I'm returning true
[ ... more ACTION_MOVE events logged ... ]
trueBtnTop Got view trueBtnTop in onTouch
trueBtnTop Action: 1
trueBtnTop Location: 291.5864 x 223.43854
trueBtnTop >>> Touch has left the view <<<
trueBtnTop Edge flags: 0
trueBtnTop Pressure: 0.047058824 Size: 0.13333334
trueBtnTop Down time: 31690859ms
trueBtnTop Event time: 31692493ms Elapsed: 1634 ms
trueBtnTop and I'm returning true
即使在您的手指离开按钮后,我们仍会收到与按钮相关的触摸事件通知。清单 22-6 中的第一条记录显示了一条我们不再点击按钮的事件记录。在这种情况下,触摸事件的 x 坐标位于按钮对象边缘的右侧。然而,我们一直被 MotionEvent 对象调用,直到我们得到一个 ACTION_UP 事件,因为我们继续从 onTouch() 方法返回 true 。即使当你最终将手指从触摸屏上移开,即使你的手指没有放在按钮上,我们的 onTouch() 方法仍然被调用来给我们 ACTION_UP 事件,因为我们一直返回 true 。这是在处理 MotionEvent s 时要记住的事情。当手指离开视图时,我们可以决定取消可能已经执行的任何操作,并从 onTouch() 方法返回 false ,这样我们就不会得到进一步事件的通知。或者我们可以选择继续接收事件(通过从 onTouch() 方法返回 true ,并且只有当手指在抬起之前返回到我们的视图时才执行逻辑。
当我们从 onTouch() 返回 true 时,事件的触摸序列与顶部的返回 True 按钮相关联。这告诉 Android,它可以停止寻找一个对象来接收运动事件对象,而只是将这个触摸序列的所有未来运动事件对象发送给我们。即使我们在拖动手指时遇到了另一个视图,我们仍然被绑定到该序列的原始视图。
练习示例应用的下半部分
让我们看看应用的下半部分会发生什么。继续,触摸下半部分的返回真按钮。我们看到顶部的 Returns True 按钮发生了同样的事情。因为 onTouch() 返回 true ,Android 向我们发送触摸序列中的其余事件,直到手指从触摸屏上抬起。现在,触摸底部返回错误按钮。再次, onTouch() 方法和 onTouchEvent() 方法返回 false (都与 falseBtnBottom 视图对象相关联)。但这次,下一个接收 MotionEvent 对象的视图是 falseLayoutBottom 对象,它也返回 false 。现在,我们结束了。
因为 onTouchEvent() 方法调用了 super 的 onTouchEvent() 方法,所以按钮已经改变了颜色,表示它已经被按下了一半。同样,按钮将保持这种状态,因为我们从未在这个触摸序列中获得 ACTION_UP 事件,因为我们的方法总是返回 false 。不像以前,连版面都对这个活动不感兴趣。如果您触摸底部的 Returns False 按钮并按住它,然后在显示屏上拖动手指,您将不会在 LogCat 中看到任何额外的记录,因为不再有 MotionEvent 对象发送给我们。我们返回了 false ,所以 Android 不会再用这个触摸序列的任何事件来打扰我们。同样,如果我们开始一个新的触摸序列,我们可以看到新的 LogCat 记录出现。如果您在底部布局中而不是在按钮上启动触摸序列,您将在 LogCat 中看到一个针对 falseLayoutBottom 的事件,该事件返回 false ,然后再无其他事件(直到您启动新的触摸序列)。
到目前为止,我们已经使用按钮从触摸屏上向您展示了 MotionEvent 事件的效果。值得指出的是,通常情况下,您会使用 onClick() 方法在按钮上实现逻辑。我们在这个示例应用中使用了按钮,因为它们易于创建,并且是视图的子类,因此可以像任何其他视图一样接收触摸事件。记住,这些技术适用于应用中的任何视图对象,无论是标准的还是定制的视图类。
回收运动事件
你可能已经注意到了 Android 参考文档中的 MotionEvent 类的 recycle() 方法 。你很想回收在 onTouch() 或 onTouchEvent() 中收到的 MotionEvent s,但是不要这样做。如果你的回调方法没有使用 MotionEvent 对象,并且你返回了 false ,那么 MotionEvent 对象可能会被传递给其他方法或视图或我们的活动,所以你还不想让 Android 回收它。即使你消费了事件并返回了 true ,事件对象也不属于你,所以你不应该回收它。
如果你看一下 MotionEvent 文档,你会看到一个叫做获取() 的方法的一些变体。这要么是创建一个运动事件的副本,要么是一个全新的运动事件。你的副本,或者说你全新的事件对象,是你用完后应该回收的事件对象。比如你要挂一个通过回调传递给你的事件对象,你要用获取()做一个副本,因为一旦你从回调返回,那个事件对象就会被 Android 回收,继续使用可能会得到奇怪的结果。当您使用完您的副本时,您对它调用 recycle() 。
使用速度跟踪器
Android 提供了一个类来帮助处理触摸屏序列,这个类就是 velocity tracker。当手指在触摸屏上移动时,知道它在表面上移动的速度可能会很好。例如,如果用户在屏幕上拖动一个对象,然后放开,应用可能希望相应地显示该对象在屏幕上飞行。Android 提供了 VelocityTracker 来帮助解决相关的数学问题。
要使用 VelocityTracker ,首先要通过调用静态方法 VelocityTracker.obtain() 获得一个 VelocityTracker 的实例。然后,您可以使用 add movement(motion event ev)方法向其添加 MotionEvent 对象。您可以在接收 MotionEvent 对象的处理器中调用该方法,从一个处理器方法如 onTouch() 或从一个视图的 onTouchEvent() 调用该方法。 VelocityTracker 使用 MotionEvent 对象来判断用户的触摸顺序。一旦 VelocityTracker 中至少有两个 MotionEvent 对象,我们可以使用其他方法来找出发生了什么。
两个 VelocityTracker 方法——getx velocity()和 getYVelocity()——分别返回手指在 x 和 y 方向的相应速度。这两种方法返回的值将表示每个时间段的像素。这可以是每毫秒或每秒的像素,或者任何你想要的东西。为了告诉 VelocityTracker 使用什么时间段,在调用这两个 getter 方法之前,需要调用 VelocityTracker 的 computeCurrentVelocity(int units)方法。单位的值代表测量速度的时间周期为多少毫秒。如果你想要每毫秒像素,使用一个单位值为 1;如果你想要每秒像素,使用单位值 1000。如果速度向右(对于 x)或向下(对于 y),那么由 getXVelocity() 和 getYVelocity() 方法返回的值将是正的。如果速度向左(对于 x)或向上(对于 y),返回值将是负的。
当你完成了用获取()方法得到的 VelocityTracker 对象后,调用 VelocityTracker 对象的 recycle() 方法。清单 22-7 显示了一个活动的示例 onTouchEvent() 处理器。原来,一个活动有一个 onTouchEvent() 回调,每当没有视图处理触摸事件时就会调用这个回调。因为我们使用的是普通的空布局,所以没有视图消耗我们的触摸事件。
清单 22-7 。使用 VelocityTracker 的示例活动
import android.view.MotionEvent;
import android.view.VelocityTracker;
public class MainActivity extends Activity {
private static final String TAG = "VelocityTracker";
/** Called when the activity is first created. */
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
}
private VelocityTracker vTracker = null;
public boolean onTouchEvent(MotionEvent event) {
int action = event.getAction();
switch(action) {
case MotionEvent.ACTION_DOWN:
if(vTracker == null) {
vTracker = VelocityTracker.obtain();
}
else {
vTracker.clear();
}
vTracker.addMovement(event);
break;
case MotionEvent.ACTION_MOVE:
vTracker.addMovement(event);
vTracker.computeCurrentVelocity(1000);
Log.v(TAG, "X velocity is " + vTracker.getXVelocity() +
" pixels per second");
Log.v(TAG, "Y velocity is " + vTracker.getYVelocity() +
" pixels per second");
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
Log.v(TAG, "Final X velocity is " + vTracker.getXVelocity() +
" pixels per second");
Log.v(TAG, "Final Y velocity is " + vTracker.getYVelocity() +
" pixels per second");
vTracker.recycle();
vTracker = null;
break;
}
return true;
}
}
显然,当你只给一个速度跟踪器(ACTION _ DOWN 事件)添加了一个运动事件时,速度不能被计算为零以外的任何值。但是我们需要添加起点,以便随后的 ACTION_MOVE 事件可以计算速度。
就性能而言,VelocityTracker 有点昂贵,所以要谨慎使用。此外,确保你用完后尽快回收。安卓系统中可以有多个 VelocityTracker 在使用,但它们会占用大量内存,所以如果你不打算继续使用它,就把你的还给我。在清单 22-7 中,如果我们开始一个新的触摸序列(也就是说,如果我们得到一个 ACTION_DOWN 事件并且我们的 VelocityTracker 对象已经存在),我们也使用 clear() 方法,而不是回收这个对象并获得一个新的。
多点触摸
现在,您已经看到了单点触摸的实际操作,让我们继续多点触摸。自从 2006 年的 TED 大会上 Jeff Han 演示了用于计算机用户界面的多点触摸表面以来,多点触摸就引起了人们的极大兴趣。在屏幕上使用多个手指为操作屏幕上的内容提供了很多可能性。例如,将两个手指放在一幅图像上并分开,可以放大图像。通过将多个手指放在图像上并顺时针旋转,可以旋转屏幕上的图像。例如,这些是谷歌地图中的标准触摸操作。
但是,如果你仔细想想,这并没有什么魔力。如果屏幕硬件可以检测到在屏幕上开始的多点触摸,在这些触摸在屏幕表面上移动时通知您的应用,并在这些触摸离开屏幕时通知您,您的应用就可以知道用户试图对这些触摸做什么。虽然不是魔术,但也不容易。在这一部分,我们将帮助您理解多点触控。
多点触摸的基础
多点触摸的基本原理与单点触摸完全相同。 MotionEvent 对象被创建用于触摸,这些 MotionEvent 对象像以前一样被传递给你的方法。您的代码可以读取关于触摸的数据,并决定做什么。基本上,运动事件的方法是相同的;也就是我们调用 getAction() 、get down()、 getX() 等等。然而,当不止一个手指触摸屏幕时, MotionEvent 对象必须包括来自所有手指的信息,但有一些注意事项。来自 getAction() 的动作值是针对一个手指,而不是所有手指。“按下时间”值是指第一个手指按下时的时间,只要至少有一个手指按下,它就会测量时间。位置值 getX() 和 getY() ,以及 getPressure() 和 getSize() ,可以为手指取一个实参;因此,您需要使用指针索引值来请求您感兴趣的手指的信息。我们之前使用的一些方法调用没有使用任何参数来指定一个指针(例如, getX() , getY() ),那么如果我们使用这些方法,这些值将用于哪个指针呢?你可以弄清楚,但这需要一些工作。因此,如果你不总是考虑多个手指,你可能会得到一些奇怪的结果。让我们深入研究一下,看看该怎么办。
多点触控需要了解的第一个 MotionEvent 方法是 getPointerCount() 。这告诉你有多少手指出现在 MotionEvent 对象中,但不一定告诉你有多少手指实际上在触摸屏幕;这取决于硬件和 Android 在硬件上的实现。您可能会发现,在某些设备上, getPointerCount() 不会报告所有正在触摸的手指,只是一些手指。但是让我们继续努力。一旦在 MotionEvent 对象中报告了多个手指,就需要开始处理指针索引和指针 id。
MotionEvent 对象包含从索引 0 开始直到该对象中报告的手指数量的指针信息。指针索引总是从 0 开始;如果报告三个指针,指针索引将为 0、1 和 2。对诸如 getX() 之类的方法的调用必须包含您想要了解的手指的指针索引。指针 id 是表示哪个手指正在被跟踪的整数值。第一个手指按下时,指针 id 从 0 开始,但一旦手指在屏幕上出现和消失,指针 id 并不总是从 0 开始。把一个指针 ID 想象成被 Android 追踪的手指的名字。例如,想象两个手指的一对触摸序列,从手指 1 向下开始,然后是手指 2 向下、手指 1 向上和手指 2 向上。第一个手指按下将获得指针 ID 0。第二个手指向下将得到指针 ID 1。一旦第一个手指向上,第二个手指仍将是指针 ID 1。此时,第二手指的指针索引变为 0,因为指针索引总是从 0 开始。在这个例子中,第二个手指(指针 ID 1)在第一次触下时开始为指针索引 1,然后在第一个手指离开屏幕时转变为指针索引 0。即使第二手指是屏幕上唯一的手指,它仍然是指针 ID 1。您的应用将使用指针 id 将与特定手指相关的事件链接在一起,即使涉及到其他手指。让我们看一个例子。
清单 22-8 展示了我们新的 XML 布局和多点触摸应用的 Java 代码。这就是名为 MultiTouchDemo1 的应用。图 22-2 显示了它应该是什么样子。
清单 22-8 。多点触摸演示的 XML 布局 和 Java
<?xml version="1.0" encoding="utf-8"?>
<!-- This file is /res/layout/main.xml -->
<RelativeLayout xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
android:id="@+id/layout1"
android:tag="trueLayout" android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
>
<TextView android:text="Touch fingers on the screen and look at LogCat"
android:id="@+id/message"
android:tag="trueText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true" />
</RelativeLayout>
// This file is MainActivity.java
import android.view.MotionEvent;
import android.view.View.OnTouchListener;
public class MainActivity extends Activity implements OnTouchListener {
/** Called when the activity is first created. */
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
RelativeLayout layout1 =
(RelativeLayout) findViewById(R.id.layout1);
layout1.setOnTouchListener(this);
}
public boolean onTouch(View v, MotionEvent event) {
String myTag = v.getTag().toString();
Log.v(myTag, "-----------------------------");
Log.v(myTag, "Got view " + myTag + " in onTouch");
Log.v(myTag, describeEvent(event));
logAction(event);
if( "true".equals(myTag.substring(0, 4))) {
return true;
}
else {
return false;
}
}
protected static String describeEvent(MotionEvent event) {
StringBuilder result = new StringBuilder(500);
result.append("Action: ").append(event.getAction()).append("\n");
int numPointers = event.getPointerCount();
result.append("Number of pointers: ");
result.append(numPointers).append("\n");
int ptrIdx = 0;
while (ptrIdx < numPointers) {
int ptrId = event.getPointerId(ptrIdx);
result.append("Pointer Index: ").append(ptrIdx);
result.append(", Pointer Id: ").append(ptrId).append("\n");
result.append(" Location: ").append(event.getX(ptrIdx));
result.append(" x ").append(event.getY(ptrIdx)).append("\n");
result.append(" Pressure: ");
result.append(event.getPressure(ptrIdx));
result.append(" Size: ").append(event.getSize(ptrIdx));
result.append("\n");
ptrIdx++;
}
result.append("Down time: ").append(event.getDownTime());
result.append("ms\n").append("Event time: ");
result.append(event.getEventTime()).append("ms");
result.append(" Elapsed: ");
result.append(event.getEventTime()-event.getDownTime());
result.append(" ms\n");
return result.toString();
}
private void logAction(MotionEvent event) {
int action = event.getActionMasked();
int ptrIndex = event.getActionIndex();
int ptrId = event.getPointerId(ptrIndex);
if(action == 5 || action == 6)
action = action - 5;
Log.v("Action", "Pointer index: " + ptrIndex);
Log.v("Action", "Pointer Id: " + ptrId);
Log.v("Action", "True action value: " + action);
}
}
图 22-2 。我们的多点触摸演示应用
如果你只有模拟器,这个应用仍然可以工作,但是你不能在屏幕上同时显示多个手指。您将看到类似于我们在前面的应用中看到的输出。清单 22-9 显示了我们之前描述的触摸序列的示例 LogCat 消息 。也就是第一个手指按在屏幕上,然后第二个手指按,第一个手指离开屏幕,第二个手指离开屏幕。
清单 22-9 。多点触摸应用的示例 LogCat 输出
trueLayout -----------------------------
trueLayout Got view trueLayout in onTouch
trueLayout Action: 0
trueLayout Number of pointers: 1
trueLayout Pointer Index: 0, Pointer Id: 0
trueLayout Location: 114.88211 x 499.77502
trueLayout Pressure: 0.047058824 Size: 0.13333334
trueLayout Down time: 33733650ms
trueLayout Event time: 33733650ms Elapsed: 0 ms
Action Pointer index: 0
Action Pointer Id: 0
Action True Action value: 0
trueLayout -----------------------------
trueLayout Got view trueLayout in onTouch
trueLayout Action: 2
trueLayout Number of pointers: 1
trueLayout Pointer Index: 0, Pointer Id: 0
trueLayout Location: 114.88211 x 499.77502
trueLayout Pressure: 0.05882353 Size: 0.13333334
trueLayout Down time: 33733650ms
trueLayout Event time: 33733740ms Elapsed: 90 ms
Action Pointer index: 0
Action Pointer Id: 0
Action True Action value: 2
trueLayout -----------------------------
trueLayout Got view trueLayout in onTouch
trueLayout Action: 261
trueLayout Number of pointers: 2
trueLayout Pointer Index: 0, Pointer Id: 0
trueLayout Location: 114.88211 x 499.77502
trueLayout Pressure: 0.05882353 Size: 0.13333334
trueLayout Pointer Index: 1, Pointer Id: 1
trueLayout Location: 320.30692 x 189.67395
trueLayout Pressure: 0.050980393 Size: 0.13333334
trueLayout Down time: 33733650ms
trueLayout Event time: 33733962ms Elapsed: 312 ms
Action Pointer index: 1
Action Pointer Id: 1
Action True Action value: 0
trueLayout -----------------------------
trueLayout Got view trueLayout in onTouch
trueLayout Action: 2
trueLayout Number of pointers: 2
trueLayout Pointer Index: 0, Pointer Id: 0
trueLayout Location: 111.474594 x 499.77502
trueLayout Pressure: 0.05882353 Size: 0.13333334
trueLayout Pointer Index: 1, Pointer Id: 1
trueLayout Location: 320.30692 x 189.67395
trueLayout Pressure: 0.050980393 Size: 0.13333334
trueLayout Down time: 33733650ms
trueLayout Event time: 33734189ms Elapsed: 539 ms
Action Pointer index: 0
Action Pointer Id: 0
Action True Action value: 2
trueLayout -----------------------------
trueLayout Got view trueLayout in onTouch
trueLayout Action: 6
trueLayout Number of pointers: 2
trueLayout Pointer Index: 0, Pointer Id: 0
trueLayout Location: 111.474594 x 499.77502
trueLayout Pressure: 0.05882353 Size: 0.13333334
trueLayout Pointer Index: 1, Pointer Id: 1
trueLayout Location: 320.30692 x 189.67395
trueLayout Pressure: 0.050980393 Size: 0.13333334
trueLayout Down time: 33733650ms
trueLayout Event time: 33734228ms Elapsed: 578 ms
Action Pointer index: 0
Action Pointer Id: 0
Action True Action value: 1
trueLayout -----------------------------
trueLayout Got view trueLayout in onTouch
trueLayout Action: 2
trueLayout Number of pointers: 1
trueLayout Pointer Index: 0, Pointer Id: 1
trueLayout Location: 318.84656 x 191.45105
trueLayout Pressure: 0.050980393 Size: 0.13333334
trueLayout Down time: 33733650ms
trueLayout Event time: 33734240ms Elapsed: 590 ms
Action Pointer index: 0
Action Pointer Id: 1
Action True Action value: 2
trueLayout -----------------------------
trueLayout Got view trueLayout in onTouch
trueLayout Action: 1
trueLayout Number of pointers: 1
trueLayout Pointer Index: 0, Pointer Id: 1
trueLayout Location: 314.95224 x 190.5625
trueLayout Pressure: 0.050980393 Size: 0.13333334
trueLayout Down time: 33733650ms
trueLayout Event time: 33734549ms Elapsed: 899 ms
Action Pointer index: 0
Action Pointer Id: 1
Action True Action value: 1
了解多点触摸内容
我们现在将讨论这个应用是怎么回事。我们看到的第一个事件是第一个手指的 ACTION_DOWN (动作值为 0)。我们使用 getAction() 方法 来了解这一点。请参考 MainActivity.java 中的 describeEvent() 方法来了解哪些方法产生了哪些输出。我们得到一个索引为 0、指针 ID 为 0 的指针。之后,你可能会看到这个第一个手指的几个动作 _ 移动事件(动作值为 2),尽管我们在清单 22-9 中只显示了其中一个。我们仍然只有一个指针,索引和 ID 仍然都是 0。
过了一会儿,我们用第二手指触摸屏幕。该动作现在是十进制值 261。这是什么意思?动作值实际上由两部分组成:一个指示动作是针对哪个指针的,以及这个指针正在做什么动作。将十进制 261 转换为十六进制,我们得到 0x00000105。动作是最小的字节(本例中为 5),指针索引是下一个字节(本例中为 1)。注意,这告诉我们的是指针索引,而不是指针 ID。如果您在屏幕上按下第三个手指,动作将是 0x00000205(或十进制 517)。第四个指针是 0x00000305(或十进制 773),依此类推。你还没有看到一个动作值为 5,但它被称为 ACTION_POINTER_DOWN 。它就像 ACTION_DOWN 一样,只是它用在多点触摸的情况下。
现在,看看清单 22-9 中来自 LogCat 的下一对记录。第一条记录是针对一个动作 _ 移动事件 ??(动作值为 2)。请记住,很难阻止手指在真实屏幕上移动。我们只显示了一个 ACTION_MOVE 事件,但是当您亲自尝试时,您可能会看到几个。当第一个手指离开屏幕时,我们得到一个动作值 0x00000006(或十进制 6)。像以前一样,我们有指针索引 0 和动作值 ACTION_POINTER_UP (类似于 ACTION_UP ,但用于多点触摸情况)。如果在多点触摸的情况下抬起第二个手指,我们将获得 0x00000106(或十进制 262)的动作值。请注意,当我们为其中一个手指获取 ACTION_UP 时,我们仍然有两个手指的信息。
清单 22-9 中的最后一对记录显示了第二手指的另一个动作 _ 移动事件,随后是第二手指的动作 _ 上升。这一次,我们看到一个动作值为 1 ( ACTION_UP )。我们没有得到 262 的动作值,但是我们将在下面解释。另外,请注意,一旦第一个手指离开屏幕,第二个手指的指针索引已经从 1 变为 0,但是指针 ID 仍然是 1。
ACTION_MOVE 事件不会告诉你哪个手指动了。无论有多少根手指向下或哪根手指在移动,你的动作值总是为 2。所有向下手指的位置在运动事件对象中都是可用的,所以你需要读取这些位置,然后把事情弄清楚。如果屏幕上只剩下一个手指,指针 ID 会告诉你哪个手指还在动,因为它是唯一剩下的手指。在清单 22-9 中,当屏幕上只剩下第二手指时, ACTION_MOVE 事件的指针索引为 0,指针 ID 为 1,所以我们知道是第二手指在移动。
一个 MotionEvent 对象不仅可以包含多个手指的移动事件,还可以包含每个手指的多个移动事件。它使用对象中包含的历史值来实现这一点。Android 应该报告自最后一个运动事件对象以来的所有历史。参见 getHistoricalSize() 和另一个 getHistorical...()方法。
回到清单 22-9 的开头,第一个按下的手指是指针索引 0,指针 ID 0,那么为什么在第一个手指先于其他任何手指按到屏幕上时,我们得不到动作值 0x00000005(或者十进制 5)?很遗憾,这个问题没有一个满意的答案。我们可以在下面的场景中得到一个动作值 5:将第一个手指按向屏幕,然后按第二个手指,得到动作值 0 和 261(暂时忽略 ACTION_MOVE 事件)。现在,抬起第一个手指(动作值为 6),并在屏幕上向下按压。第二手指的指针 ID 保持为 1。当第一个手指在空中时,我们的应用只知道指针 ID 1。一旦第一个手指再次触摸屏幕,Android 会将指针 ID 0 重新分配给第一个手指,并赋予其指针索引 0。因为现在我们知道涉及到多个手指,所以我们得到的动作值为 5(指针索引为 0,动作值为 5)。因此,问题的答案是向后兼容,但这不是一个令人满意的答案。动作值 0 和 1 是预多点触摸。
当只有一个手指留在屏幕上时,Android 将其视为单点触控外壳。所以我们得到旧的 ACTION_UP 值 1,而不是多点触摸 ACTION_UP 值 6。我们的代码需要仔细考虑这些情况。指针索引为 0 可能导致 ACTION_DOWN 值为 0 或 5,这取决于哪个指针在起作用。最后一个向上的指针将获得为 1 的 ACTION_UP 值 ,而不管它具有哪个指针 ID。
还有一个动作我们至今没有提到: ACTION_SCROLL (值为 8),在 Android 3.1 中引入。这来自鼠标之类的输入设备,而不是触摸屏。事实上,从 MotionEvent 中的方法可以看出,这些对象可以用于触摸屏触摸之外的许多事情。我们不会在本书中讨论这些其他的输入设备。
手势
手势是一种特殊类型的触摸屏事件。术语手势在 Android 中用于各种各样的事情,从简单的触摸序列,如扔或捏,到正式的手势类。投掷、挤压、长按和滚动都有带有预期触发的预期行为。也就是说,对大多数人来说很清楚,一个手指触摸屏幕,向一个方向快速拖动,然后抬起的手势。例如,当有人在 Gallery 应用(以从左到右的链显示图像的应用)中使用 fling 时,图像将向旁边移动,以向用户显示新的图像。
在接下来的几节中,您将学习如何实现挤压手势,从中您可以轻松地实现其他常见的手势。正式手势类是指用户在触摸屏上绘制的手势,以便应用可以对这些手势做出反应。典型的例子包括绘制应用可以理解为字母的字母表中的字母。正式的手势课程不在本书的讨论范围之内。让我们学会掐!
捏手势
多点触摸的一个很酷的应用是挤压手势,用于缩放。这个想法是,如果你把两个手指放在屏幕上并分开,应用应该通过放大来响应。如果你的手指并拢,应用应该会缩小。该应用通常显示图像,可能是地图。
在我们开始挤压手势的 本地支持之前,我们首先需要介绍一个从一开始就存在的类——手势检测器。
手势检测器和 OnGestureListeners
第一个帮助我们做手势的类是 GestureDetector ,它从 Android 最开始就存在了。它在生活中的目的是接收运动事件对象,并告诉我们一系列事件何时看起来像一个普通的手势。我们从回调中将所有的事件对象传递给手势检测器,当它识别出一个手势时,就会调用其他回调函数,比如一个轻击或长按。我们需要为来自 GestureDetector 的回调注册一个监听器,这是我们放置逻辑的地方,它告诉我们如果用户执行了这些常见的手势中的一个,该做什么。不幸的是,这个类没有告诉我们一个捏手势是否正在发生;为此,我们需要使用一个新的类,我们很快就会用到它。
有几种方法可以构建侦听器端。您的第一个选择是编写一个新的类来实现适当的手势监听器接口:例如,手势检测器。OnGestureListener 接口。对于每个可能的回调,都必须实现几个抽象方法。
第二种选择是选择一个简单的侦听器实现,并覆盖您所关心的适当的回调方法。比如手势检测器。SimpleOnGestureListener 类已经实现了所有的抽象方法,不做任何事情并返回 false 。您所要做的就是扩展该类,并覆盖您需要对您所关心的几个手势进行操作的几个方法。其他方法有它们默认的实现。即使您决定覆盖所有回调方法,选择第二个选项也更符合未来,因为如果 Android 的未来版本向接口添加另一个抽象回调方法,简单的实现将提供一个默认回调方法,因此您可以放心。
我们将探索 ScaleGestureDetector ,加上相应的 listener 类,看看如何使用收缩手势来调整图像的大小。在这个例子中,我们扩展了简单的实现( ScaleGestureDetector)。SimpleOnScaleGestureListener)为我们的监听器。清单 22-10 给出了我们的主活动的 XML 布局和 Java 代码。
清单 22-10 。使用 ScaleGestureDetector 的挤压手势的布局和 Java 代码
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
android:id="@+id/layout" android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent" >
<TextView android:text=
"Use the pinch gesture to change the image size"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<ImageView android:id="@+id/image" android:src="@drawable/icon"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="matrix" />
</LinearLayout>
// This file is MainActivity.java
public class MainActivity extends Activity {
private static final String TAG = "ScaleDetector";
private ImageView image;
private ScaleGestureDetector mScaleDetector;
private float mScaleFactor = 1f;
private Matrix mMatrix = new Matrix();
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
image = (ImageView)findViewById(R.id.image);
mScaleDetector = new ScaleGestureDetector(this,
new ScaleListener());
}
@Override
public boolean onTouchEvent(MotionEvent ev) {
Log.v(TAG, "in onTouchEvent");
// Give all events to ScaleGestureDetector
mScaleDetector.onTouchEvent(ev);
return true;
}
private class ScaleListener extends
ScaleGestureDetector.SimpleOnScaleGestureListener {
@Override
public boolean onScale(ScaleGestureDetector detector) {
mScaleFactor *= detector.getScaleFactor();
// Make sure we don't get too small or too big
mScaleFactor = Math.max(0.1f, Math.min(mScaleFactor, 5.0f));
Log.v(TAG, "in onScale, scale factor = " + mScaleFactor);
mMatrix.setScale(mScaleFactor, mScaleFactor);
image.setImageMatrix(mMatrix);
image.invalidate();
return true;
}
}
}
我们的布局很简单。我们有一个简单的文本视图和我们的消息来使用捏手势,我们有我们的图像视图和标准的 Android 图标。我们将使用收缩手势来调整图标图像的大小。当然,你可以随意用你自己的图像文件来代替图标。只需将您的图像文件复制到一个 drawable 文件夹中,并确保更改布局文件中的 android:src 属性。注意我们图像的 XML 布局中的 android:scaleType 属性。这告诉 Android 我们将使用图形矩阵对图像进行缩放操作。虽然图形矩阵也可以在布局中移动我们的图像,但我们现在只关注缩放。还要注意,我们将 ImageView 的尺寸设置得尽可能大。当我们缩放图像时,我们不希望它被 ImageView 的边界所剪切。
代码也很简单。在 onCreate() 中,我们获取对我们图像的引用,并创建我们的 ScaleGestureDetector 。在我们的 onTouchEvent() 回调中,我们所做的就是将我们获得的每个事件对象传递给 ScaleGestureDetector 的 onTouchEvent() 方法,并返回 true ,这样我们就能不断获得新的事件。这允许 ScaleGestureDetector 看到所有事件,并决定何时通知我们手势。
ScaleListener 是缩放发生的地方。监听器类中实际上有三个回调: onScaleBegin() 、 onScale() 和 onScaleEnd() 。我们不需要对 begin 和 end 方法做任何特殊的处理,所以我们没有在这里实现它们。
在 onScale() 中,传入的检测器可以用来找出大量关于缩放操作的信息。比例因子是一个徘徊在 1 左右的值。也就是说,随着手指捏得越来越近,该值略低于 1;当手指分开时,该值略大于 1。我们的 mScaleFactor 成员从 1 开始,所以随着手指的靠拢或分开,它逐渐变得小于或大于 1。如果 mScaleFactor 等于 1,我们的图像将是正常大小。否则,当 mScaleFactor 移动到 1 以下或以上时,我们的图像将会比正常图像小或大。我们用优雅的最小/最大函数组合为 mScaleFactor 设置了一些界限。这可以防止我们的图像变得太小或太大。然后,我们使用 mScaleFactor 缩放图形矩阵,并将新缩放的矩阵应用于我们的图像。调用 invalidate() 强制在屏幕上重新绘制图像。
为了使用 OnGestureListener 接口,你要做的事情与我们在这里使用 ScaleListener 所做的事情非常相似,除了回调将针对不同的常见手势,如单击、双击、长按和投掷。
参考
这里有一些对您可能希望进一步探索的主题有帮助的参考。
- :与本书相关的可下载项目。对于这一章,寻找一个名为 pro Android 5 _ Ch22 _ touch screens . zip 的 zip 文件。这个 zip 文件包含本章中的所有项目,列在单独的根目录中。还有一个自述。TXT 文件,它准确地描述了如何将项目从这些 zip 文件之一导入到您的 IDE 中。
- www . ted . com/talks/Jeff _ Han _ demos _ his _ breakthrough _ touchscreen . html:Jeff Han 在 2006 年的 TED 大会上展示了他的多点触控电脑用户界面——非常酷。
android-developers . blogspot . com/2010/06/making-sense-of-multi touch . html:一篇关于多点触摸的 Android 博客文章提供了另一种在视图扩展中实现手势检测器的方法。
摘要
让我们通过快速列举到目前为止您已经了解的触摸屏知识来结束本章:
- 作为触摸处理基础的运动事件
- 不同的回调处理一个视图对象上的触摸事件,并通过一个 OnTouchListener
- 触摸序列中发生的不同类型的事件
- 触摸事件如何在整个视图层次中传播,除非沿途处理
- 一个运动事件对象包含的关于触摸的信息,包括多个手指
- 何时回收一个运动事件对象,何时不回收
- 确定手指在屏幕上拖动的速度
- 多点触控的奇妙世界及其工作原理的内部细节
- 实现挤压手势以及其他常见手势
二十三、实现拖放
在上一章中,我们介绍了触摸屏、 MotionEvent 类和手势。您学习了如何使用触摸在您的应用中实现一些事情。我们没有涉及的一个领域是拖放。从表面上看,拖放似乎相当简单:触摸屏幕上的一个对象,在屏幕上拖动它(通常在其他对象上),然后放开,应用应该采取适当的动作。在许多计算机操作系统中,这是从桌面上删除文件的常用方法;你只要把文件的图标拖到垃圾桶图标上,文件就被删除了。在 Android 中,你可能已经看到如何通过将图标拖到新位置或垃圾桶来重新排列主屏幕上的图标。
这一章将深入探讨拖放。在 Android 3.0 之前,当涉及到拖放时,开发人员只能靠自己。但是因为仍然有相当多的手机运行 Android 2.3,我们将向您展示如何在这些手机上进行拖放。我们将在本章的第一节向您展示旧的方法,然后在第二部分向您展示新的方法。
探索拖放
在下一个示例应用中,我们将选取一个白点,并将其拖动到用户界面中的新位置。我们还将在用户界面中放置三个计数器,如果用户将白点拖到其中一个计数器上,该计数器将递增,该点将返回到其起始位置。如果这个点被拖到屏幕上的其他地方,我们就把它留在那里。
注意参见本章末尾的“参考”一节,获取可以将这些项目直接导入 IDE 的 URL。我们将只在文本中显示代码来解释概念。您需要下载代码来创建一个工作示例应用。
本章的第一个示例应用叫做 TouchDragDemo 。在这一部分中,我们要讨论两个关键文件:
- /res/layout/main.xml
- /src/com/Android book/touch/drag demo/dot . Java
main.xml 文件包含我们的拖放演示的布局。如清单 23-1 所示。我们希望您注意的一些关键概念是使用一个 FrameLayout 作为顶层布局,其中有一个 LinearLayout ,包含 TextView s 和一个名为 Dot 的自定义 View 类。因为 LinearLayout 和 Dot 共存于 FrameLayout 中,它们的位置和大小实际上不会相互影响,但是它们将共享屏幕空间,一个在另一个之上。该应用的用户界面如图 23-1 所示。
清单 23-1 。我们的拖动示例的示例布局 XML
<?xml version="1.0" encoding="utf-8"?>
<!-- This file is res/layout/main.xml -->
<FrameLayout xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#0000ff" >
<LinearLayout android:id="@+id/counters"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent" >
<TextView android:id="@+id/top" android:text="0"
android:background="#111111"
android:layout_height="wrap_content"
android:layout_width="60dp"
android:layout_gravity="right"
android:layout_marginTop="30dp"
android:layout_marginBottom="30dp"
android:padding="10dp" />
<TextView android:id="@+id/middle" android:text="0"
android:background="#111111"
android:layout_height="wrap_content"
android:layout_width="60dp"
android:layout_gravity="right"
android:layout_marginBottom="30dp"
android:padding="10dp" />
<TextView android:id="@+id/bottom" android:text="0"
android:background="#111111"
android:layout_height="wrap_content"
android:layout_width="60dp"
android:layout_gravity="right"
android:padding="10dp" />
</LinearLayout>
<com.androidbook.touch.dragdemo.Dot android:id="@+id/dot"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>
图 23-1 。触摸屏演示的用户界面
注意,布局 XML 文件中的包名必须与应用中使用的包名相匹配。如上所述,点的布局与线状布局是分开的。这是因为我们想要在屏幕上自由移动圆点,这也是我们选择【match _ parent】的 layout_width 和 layout_height 的原因。当我们在屏幕上画点时,我们希望它是可见的,如果我们将我们的点的视图的大小压缩到点的直径,当我们将它从我们的起始位置拖开时,我们将看不到它。
注意从技术上来说,我们可以在 FrameLayout 标签中将 android:clipChildren 设置为 true ,并将点的布局宽度和高度设置为 wrap_content ,但是这样感觉不太干净。
对于每个计数器,我们简单地用背景、填充、边距和重力来布置它们,使它们显示在屏幕的右侧。我们从零开始,但是你很快就会看到,当点被拖到它们上面时,我们会增加这些值。虽然在这个例子中我们选择使用文本视图的,但是你可以使用任何视图对象作为拖放目标。现在我们来看看清单 23-2 中点类的 Java 代码。
清单 23-2 。我们的点类的 Java 代码
public class Dot extends View {
private static final String TAG = "TouchDrag";
private float left = 0;
private float top = 0;
private float radius = 20;
private float offsetX;
private float offsetY;
private Paint myPaint;
private Context myContext;
public Dot(Context context, AttributeSet attrs) {
super(context, attrs);
// Save the context (the activity)
myContext = context;
myPaint = new Paint();
myPaint.setColor(Color.WHITE);
myPaint.setAntiAlias(true);
}
public boolean onTouchEvent(MotionEvent event) {
int action = event.getAction();
float eventX = event.getX();
float eventY = event.getY();
switch(action) {
case MotionEvent.ACTION_DOWN:
// First make sure the touch is on our dot,
// since the size of the dot's view is
// technically the whole layout. If the
// touch is *not* within, then return false
// indicating we don't want any more events.
if( !(left-20 < eventX && eventX < left+radius*2+20 &&
top-20 < eventY && eventY < top+radius*2+20))
return false;
// Remember the offset of the touch as compared
// to our left and top edges.
offsetX = eventX - left;
offsetY = eventY - top;
break;
case MotionEvent.ACTION_MOVE:
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
left = eventX - offsetX;
top = eventY - offsetY;
if(action == MotionEvent.ACTION_UP) {
checkDrop(eventX, eventY);
}
break;
}
invalidate();
return true;
}
private void checkDrop(float x, float y) {
// See if the x,y of our drop location is near to
// one of our counters. If so, increment it, and
// reset the dot back to its starting position
Log.v(TAG, "checking drop target for " + x + ", " + y);
int viewCount = ((MainActivity)myContext).counterLayout
.getChildCount();
for(int i = 0; i<viewCount; i++) {
View view = ((MainActivity)myContext).counterLayout
.getChildAt(i);
if(view.getClass() == TextView.class){
Log.v(TAG, "Is the drop to the right of " +
(view.getLeft()-20));
Log.v(TAG, " and vertically between " +
(view.getTop()-20) +
" and " + (view.getBottom()+20) + "?");
if(x > view.getLeft()-20 &&
view.getTop()-20 < y &&
y < view.getBottom()+20) {
Log.v(TAG, " Yes. Yes it is.");
// Increase the count value in the TextView by one
int count =
Integer.parseInt(
((TextView)view).getText().toString());
((TextView)view).setText(String.valueOf( ++count ));
// Reset the dot back to starting position
left = top = 0;
break;
}
}
}
}
public void draw(Canvas canvas) {
canvas.drawCircle(left + radius, top + radius, radius, myPaint);
}
}
当你运行这个应用时,你会看到一个蓝底白点。你可以触摸这个点并在屏幕上拖动它。当您抬起手指时,圆点会停留在原来的位置,直到您再次触摸它并将它拖到其他地方。 draw() 方法 将圆点放在其当前的左上方位置,并根据圆点的半径进行调整。通过在 onTouchEvent() 方法中接收 MotionEvent 对象,我们可以通过触摸的移动来修改左边和上边的值。
因为用户不会总是触摸对象的精确中心,所以触摸坐标不会与对象的位置坐标相同。这就是偏移值的目的:让我们从触摸的位置回到点的左边和上边。但是,即使在我们开始拖动操作之前,我们也希望确保用户的触摸被认为离点足够近才有效。如果用户触摸屏幕远离点,这在技术上是在点的视图布局内,我们不希望这开始一个拖动序列。这就是为什么我们要观察触摸是否在白点本身之内;如果不是,我们简单地返回假,这阻止在该触摸序列中接收任何更多的触摸事件。
当你的手指开始在屏幕上移动时,我们根据得到的运动事件来调整对象在 x 和 y 方向的位置。当你停止移动( ACTION_UP ,我们使用你触摸的最后坐标来确定我们的位置。在这个例子中,我们不必担心滚动条,滚动条会使我们的对象在屏幕上的位置计算变得复杂。但是基本原理还是一样的。通过知道要移动的对象的起始位置并跟踪从动作 _ 向下到动作 _ 向上的触摸的增量值,我们可以调整对象在屏幕上的位置。
将一个物体放到屏幕上的另一个物体上,与触摸的关系要小得多,与了解物体在屏幕上的位置关系更大。当我们在屏幕上拖动一个物体时,我们知道它相对于一个或多个参考点的位置。我们还可以询问屏幕上物体的位置和大小。然后我们可以确定我们拖动的对象是否在另一个对象的“上方”。确定被拖动对象的拖放目标的典型过程是遍历可以拖放的可用对象,并确定我们的当前位置是否与该对象重叠。每个物体的大小和位置(有时是形状)可以用来做这个决定。如果我们得到一个 ACTION_UP 事件,这意味着用户已经放开了我们拖动的对象,并且该对象位于我们可以拖放的对象之上,我们可以触发逻辑来处理拖放操作。
我们在示例应用中使用了这种方法。当检测到 ACTION_UP 动作时,我们接着查看 LinearLayout 的子视图,对于找到的每个 TextView ,我们将触摸的位置与 TextView 的边缘进行比较(加上一点额外的)。如果触摸在那个文本视图内,我们获取文本视图的当前数值,递增 1,并写回。如果发生这种情况,点的位置将被重置回其开始位置(left = 0,top = 0 ),以便进行下一次拖动。
我们的例子向您展示了在 3.0 之前的 Android 中进行拖放的基本方法。这样你就可以在你的应用中实现拖放功能。这可能是将某个对象拖到垃圾桶的动作,在垃圾桶中被拖动的对象应该被删除,也可能是将文件拖到文件夹中以便移动或复制它。为了美化您的应用,您可以预先确定哪些视图是潜在的拖放目标,并在拖动开始时使它们在视觉上发生变化。如果你希望被拖动的对象在被放下时从屏幕上消失,你总是可以通过编程从布局中移除它(参见视图组中的各种 removeView 方法)。
既然你已经看到了拖放的艰难过程,我们将向你展示 Android 3.0 中添加的拖放支持。
3.0+中拖放的基础知识
在 Android 3.0 之前,没有直接支持拖拽。在本章的第一节中,你学习了如何在屏幕上拖动一个视图;您还了解了可以使用被拖动对象的当前位置来确定下面是否有拖放目标。当接收到竖起手指事件的 MotionEvent 时,您的代码可以判断这是否意味着发生了跌落。尽管这是可行的,但肯定不如在 Android 中直接支持拖放操作那么容易。你现在得到了直接支持。
在最基本的情况下,拖放操作从一个声明拖动已经开始的视图开始;然后,所有相关方都会看到拖动的发生,直到 drop 事件被触发。如果一个视图捕捉到了拖放事件并想要接收它,那么拖放就发生了。如果没有视图接收放置,或者接收放置的视图不想要放置,那么就不会发生放置。拖动是通过使用一个 DragEvent 对象来传递的,该对象被传递给所有可用的拖动监听器。
在 DragEvent 对象中有大量信息的描述符,这取决于拖动序列的发起者。例如, DragEvent 可以包含对发起者本身的对象引用、状态信息、文本数据、URIs,或者任何您想通过拖动序列传递的东西。
可以传递导致视图到视图动态通信的信息;然而,当 DragEvent 被创建时, DragEvent 对象中的发起者数据被设置,并且此后保持不变。除了这些数据之外,拖拽事件还有一个动作值,指示拖拽序列正在进行什么,以及位置信息,指示拖拽在屏幕上的位置。
一个拖拽事件有六种可能的动作:
- ACTION _ DRAG _ STARTED表示一个新的拖动序列已经开始。
- ACTION _ DRAG _ enter表示被拖动的对象已经被拖动到特定视图的边界内。
- ACTION _ DRAG _ LOCATION表示被拖动的对象已经在屏幕上被拖动到一个新的位置。
- ACTION _ DRAG _ EXITED 表示被拖动的对象已经被拖动到特定视图的边界之外。
- ACTION_DROP 表示用户已经放开了被拖动的对象。由该事件的接收者来确定这是否真正意味着发生了丢弃。
- ACTION _ DRAG _ ENDED 告诉所有的拖拽监听器,之前的拖拽序列已经结束。 DragEvent.getResult() 方法表示成功丢弃或失败。
您可能认为您需要在系统中参与拖动序列的每个视图上设置一个拖动监听器;但是,事实上,您可以在应用中的任何内容上定义一个拖动监听器,它将接收系统中所有视图的所有拖动事件。这可能会使事情变得有点混乱,因为拖动侦听器不需要与被拖动的对象或放置目标相关联。监听器可以管理所有的拖放协调。
事实上,如果您检查 Android SDK 附带的拖放示例项目,您会看到它在一个与实际拖放无关的 TextView 上设置了一个侦听器。接下来的示例项目使用绑定到特定视图的拖动侦听器。这些拖动监听器每个都接收一个拖动事件对象,用于拖动序列中发生的拖动事件。这意味着一个视图可能会接收到一个可以被忽略的 DragEvent 对象,因为它实际上是关于一个不同的视图。这也意味着拖动监听器必须在代码中做出决定,并且在 DragEvent 对象中必须有足够的信息让拖动监听器知道该做什么。
如果一个拖动监听器得到一个 DragEvent 对象,它只是说有一个未知的对象正在被拖动,并且它在坐标(15,57)处,那么拖动监听器对它没有什么作用。获得一个 DragEvent 对象会更有帮助,它表示一个特定的对象正在被拖动,它位于坐标(15,57),这是一个复制操作,数据是一个特定的 URI。当这个值下降时,就有足够的信息来启动拷贝操作。
我们实际上看到了两种不同的拖拽方式。在我们的第一个示例应用中,我们将一个视图拖过一个框架布局,我们可以放开它,视图将停留在原来的位置。当我们把视图放到其他东西上面时,我们只有拖放行为。支持的拖放形式与此不同。现在,当您将视图作为拖放序列的一部分进行拖动时,被拖动的视图根本不会移动。我们得到了拖动视图的阴影图像,它在屏幕上移动,但是如果我们放开它,阴影视图就消失了。这意味着,在 Android 3.0+应用中,你可能仍然有机会使用本章开头的技术,在屏幕上移动图像,而不需要拖放。
拖放示例应用
对于您的下一个示例应用,您将使用 3.0 版本的 staple,即片段。这将证明拖拽可以跨越片段边界。您将在左侧创建一个点调色板,在右侧创建一个方形目标。当长时间点击一个点时,你将改变调色板中该点的颜色,Android 将在你拖动时显示该点的阴影。当拖动的点到达正方形目标时,目标将开始发光。如果您将圆点放在方形目标上,将会出现一条信息,提示您刚刚向滴数中添加了一滴,发光将会停止,原来的圆点将恢复到原来的颜色。
文件列表
这个应用建立在我们在本书中讨论的概念之上。我们将只在文本中包含有趣的文件。至于其他的,你可以在空闲的时候在你的 IDE 里看看。以下是我们包含在文本中的内容:
- palette.xml 是左侧圆点的片段布局(见清单 23-3 )。
- dropzone.xml 是右边正方形目标的片段布局,加上 drop-count 消息(见清单 23-4 )。
- DropZone.java 膨胀 dropzone.xml 片段布局文件,然后实现拖放目标的拖动监听器(见清单 23-5 )。
- 是你要拖动的对象的自定义视图类。它处理拖动序列的开始,观察拖动事件,并画出点(见清单 23-6 )。
布置示例拖放应用
在我们进入代码之前,图 23-2 展示了应用的样子。
图 23-2 。拖放片段示例应用用户界面
主布局文件有一个简单的水平线性布局和两个片段规范。第一个片段将用于点调色板,第二个片段将用于 dropzone。
调色板片段布局文件(清单 23-3 )变得更有趣了。虽然这个布局表示一个片段,但是您不需要在这个布局中包含一个片段标记。这个布局将被放大,成为您的面板片段的视图层次结构。这些点被指定为自定义点,其中有两个垂直排列。注意,在 dots 的定义中有几个定制的 XML 属性( dot:color 和 dot:radius) 。如您所见,这些属性指定了点的颜色和半径。您可能还注意到,布局的宽度和高度是 wrap_content ,而不是本章前面的示例应用中的 match_parent 。新的拖放支持使事情变得更加容易。
清单 23-3 。点 s 的 palette.xml 布局文件
<?xml version="1.0" encoding="utf-8"?>
<!-- This file is res/layout/palette.xml -->
<LinearLayout
xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
xmlns:dot=
"[`schemas.android.com/apk/res/com.androidbook.drag.drop.demo`](http://schemas.android.com/apk/res/com.androidbook.drag.drop.demo)"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.androidbook.drag.drop.demo.Dot android:id="@+id/dot1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="30dp"
android:tag="Blue dot"
dot:color="#ff1111ff"
dot:radius="20dp" />
<com.androidbook.drag.drop.demo.Dot android:id="@+id/dot2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="10dp"
android:tag="White dot"
dot:color="#ffffffff"
dot:radius="40dp" />
</LinearLayout>
清单 23-4 中的 dropzone 片段布局文件也很容易理解。有一个绿色方块和一条横向排列的短信。这将是您将要拖动的点的拖放区。文本消息将用于显示液滴的运行计数。
清单 23-4 。 dropzone.xml 布局文件
<?xml version="1.0" encoding="utf-8"?>
<!-- This file is res/layout/dropzone.xml -->
<LinearLayout
xmlns:android="[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal" >
<View android:id="@+id/droptarget"
android:layout_width="75dp"
android:layout_height="75dp"
android:layout_gravity="center_vertical"
android:background="#00ff00" />
<TextView android:id="@+id/dropmessage"
android:text="0 drops"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:paddingLeft="50dp"
android:textSize="17sp" />
</LinearLayout>
在空投区回应翁德拉格
现在您已经有了主应用布局,让我们通过检查清单 23-5 来看看需要如何组织放置目标。
清单 23-5 。DropZone.java 文件
public class DropZone extends Fragment {
private View dropTarget;
private TextView dropMessage;
@Override
public View onCreateView(LayoutInflater inflater,
ViewGroup container, Bundle icicle)
{
View v = inflater.inflate(R.layout.dropzone, container, false);
dropMessage = (TextView)v.findViewById(R.id.dropmessage);
dropTarget = (View)v.findViewById(R.id.droptarget);
dropTarget.setOnDragListener(new View.OnDragListener() {
private static final String DROPTAG = "DropTarget";
private int dropCount = 0;
private ObjectAnimator anim;
public boolean onDrag(View v, DragEvent event) {
int action = event.getAction();
boolean result = true;
switch(action) {
case DragEvent.ACTION_DRAG_STARTED:
Log.v(DROPTAG, "drag started in dropTarget");
break;
case DragEvent.ACTION_DRAG_ENTERED:
Log.v(DROPTAG, "drag entered dropTarget");
anim = ObjectAnimator.ofFloat(
(Object)v, "alpha", 1f, 0.5f);
anim.setInterpolator(new CycleInterpolator(40));
anim.setDuration(30*1000); // 30 seconds
anim.start();
break;
case DragEvent.ACTION_DRAG_EXITED:
Log.v(DROPTAG, "drag exited dropTarget");
if(anim != null) {
anim.end();
anim = null;
}
break;
case DragEvent.ACTION_DRAG_LOCATION:
Log.v(DROPTAG, "drag proceeding in dropTarget: " +
event.getX() + ", " + event.getY());
break;
case DragEvent.ACTION_DROP:
Log.v(DROPTAG, "drag drop in dropTarget");
if(anim != null) {
anim.end();
anim = null;
}
ClipData data = event.getClipData();
Log.v(DROPTAG, "Item data is " +
data.getItemAt(0).getText());
dropCount++;
String message = dropCount + " drop";
if(dropCount > 1)
message += "s";
dropMessage.setText(message);
break;
case DragEvent.ACTION_DRAG_ENDED:
Log.v(DROPTAG, "drag ended in dropTarget");
if(anim != null) {
anim.end();
anim = null;
}
break;
default:
Log.v(DROPTAG, "other action in dropzone: " +
action);
result = false;
}
return result;
}
});
return v;
}
}
现在你开始进入有趣的代码。对于 dropzone,您需要创建要在其上拖动点的目标。正如您前面看到的,布局在屏幕上指定了一个绿色方块,旁边有一条文本消息。因为 dropzone 也是一个片段,所以您覆盖了 DropZone 的 onCreateView() 方法 。首先要做的是膨胀 dropzone 布局,然后提取方形目标( dropTarget )和文本消息( dropMessage )的视图引用。然后,您需要在目标上设置一个拖动监听器,这样它就会知道拖动何时开始。
拖放目标拖动监听器中有一个回调方法: onDrag() 。这个回调将接收一个视图引用以及一个 DragEvent 对象。视图引用与拖拽事件相关的视图相关。如前所述,拖动侦听器不一定连接到将与拖动事件交互的视图,因此这个回调必须标识发生拖动事件的视图。
在任何 onDrag() 回调中,您可能要做的第一件事就是从 DragEvent 对象中读取动作。这会告诉你发生了什么。在大多数情况下,您在这个回调中唯一想做的事情就是记录一个拖动事件正在发生的事实。例如,你不需要为 ACTION_DRAG_LOCATION 做任何事情。但是,当对象被拖动到您的边界内时,您确实希望有一些特殊的逻辑(ACTION _ DRAG _ enter),当对象被拖动到您的边界外时( ACTION_DRAG_EXITED )或者当对象被放下时( ACTION_DROP ),这些逻辑将被关闭。
你正在使用在第十八章中介绍的 ObjectAnimator 类 ,只是在这里你在代码中使用它来指定一个修改目标 alpha 的循环插值器。这将具有使绿色目标方块的透明度脉动的效果,这将是目标愿意接受物体掉落到其上的视觉指示。因为您打开了动画,所以您必须确保在对象离开或被放下,或者拖放结束时也关闭动画。理论上,你不需要在 ACTION_DRAG_ENDED 上停止动画,但无论如何这样做是明智的。
对于这个特定的拖动监听器,只有当被拖动的对象与您关联的视图交互时,您才会得到 ACTION _ DRAG _ enter 和 ACTION_DRAG_EXITED 。正如您将看到的, ACTION_DRAG_LOCATION 事件只有在被拖动的对象在您的目标视图中时才会发生。
另一个有趣的条件是 ACTION_DROP 本身(注意 DRAG_ 不是这个动作名称的一部分)。如果你的视图上出现了一个点,这意味着用户已经放开了绿色方块上的点。因为您希望这个对象被放到绿色方块上,所以您可以直接从第一个项目中读取数据,然后将其记录到 LogCat 中。在生产应用中,您可能会更加关注包含在拖动事件本身中的 ClipData 对象。通过检查它的属性,您可以决定是否接受这个拖放操作。
这是在这个 onDrag() 回调方法中指出结果布尔值的好时机。根据事情的进展,你想让 Android 知道你处理了拖动事件(通过返回真)或者你没有处理(通过返回假)。如果您在拖动事件对象中没有看到您想要看到的内容,您当然可以从这个回调中返回 false,这将告诉 Android 这个拖放操作没有被处理。
一旦在 LogCat 中记录了来自拖动事件的信息,就会增加接收到的 drops 的计数;这是在用户界面中更新的,关于 DropZone 也就这些了。
如果你看一下这个类,它真的很简单。这里实际上没有任何处理 MotionEvents 的代码,也不需要自己判断是否有拖拽在进行。随着拖动序列的展开,您只需获得适当的回调调用。
设置拖动源视图
现在让我们考虑一下对应于一个拖动源的视图是如何组织的,从查看清单 23-6 开始。
清单 23-6 。自定义视图的 Java:点
public class Dot extends View
implements View.OnDragListener
{
private static final int DEFAULT_RADIUS = 20;
private static final int DEFAULT_COLOR = Color.WHITE;
private static final int SELECTED_COLOR = Color.MAGENTA;
protected static final String DOTTAG = "DragDot";
private Paint mNormalPaint;
private Paint mDraggingPaint;
private int mColor = DEFAULT_COLOR;
private int mRadius = DEFAULT_RADIUS;
private boolean inDrag;
public Dot(Context context, AttributeSet attrs) {
super(context, attrs);
// Apply attribute settings from the layout file.
// Note: these could change on a reconfiguration
// such as a screen rotation.
TypedArray myAttrs = context.obtainStyledAttributes(attrs,
R.styleable.Dot);
final int numAttrs = myAttrs.getIndexCount();
for (int i = 0; i < numAttrs; i++) {
int attr = myAttrs.getIndex(i);
switch (attr) {
case R.styleable.Dot_radius:
mRadius = myAttrs.getDimensionPixelSize(attr,
DEFAULT_RADIUS);
break;
case R.styleable.Dot_color:
mColor = myAttrs.getColor(attr, DEFAULT_COLOR);
break;
}
}
myAttrs.recycle();
// Setup paint colors
mNormalPaint = new Paint();
mNormalPaint.setColor(mColor);
mNormalPaint.setAntiAlias(true);
mDraggingPaint = new Paint();
mDraggingPaint.setColor(SELECTED_COLOR);
mDraggingPaint.setAntiAlias(true);
// Start a drag on a long click on the dot
setOnLongClickListener(lcListener);
setOnDragListener(this);
}
private static View.OnLongClickListener lcListener =
new View.OnLongClickListener() {
private boolean mDragInProgress;
public boolean onLongClick(View v) {
ClipData data =
ClipData.newPlainText("DragData", (String)v.getTag());
mDragInProgress =
v.startDrag(data, new View.DragShadowBuilder(v),
(Object)v, 0);
Log.v((String) v.getTag(),
"starting drag? " + mDragInProgress);
return true;
}
};
@Override
protected void onMeasure(int widthSpec, int heightSpec) {
int size = 2*mRadius + getPaddingLeft() + getPaddingRight();
setMeasuredDimension(size, size);
}
// The dragging functionality
public boolean onDrag(View v, DragEvent event) {
String dotTAG = (String) getTag();
// Only worry about drag events if this is us being dragged
if(event.getLocalState() != this) {
Log.v(dotTAG, "This drag event is not for us");
return false;
}
boolean result = true;
// get event values to work with
int action = event.getAction();
float x = event.getX();
float y = event.getY();
switch(action) {
case DragEvent.ACTION_DRAG_STARTED:
Log.v(dotTAG, "drag started. X: " + x + ", Y: " + y);
inDrag = true; // used in draw() below to change color
break;
case DragEvent.ACTION_DRAG_LOCATION:
Log.v(dotTAG, "drag proceeding… At: " + x + ", " + y);
break;
case DragEvent.ACTION_DRAG_ENTERED:
Log.v(dotTAG, "drag entered. At: " + x + ", " + y);
break;
case DragEvent.ACTION_DRAG_EXITED:
Log.v(dotTAG, "drag exited. At: " + x + ", " + y);
break;
case DragEvent.ACTION_DROP:
Log.v(dotTAG, "drag dropped. At: " + x + ", " + y);
// Return false because we don't accept the drop in Dot.
result = false;
break;
case DragEvent.ACTION_DRAG_ENDED:
Log.v(dotTAG, "drag ended. Success? " + event.getResult());
inDrag = false; // change color of original dot back
break;
default:
Log.v(dotTAG, "some other drag action: " + action);
result = false;
break;
}
return result;
}
// Here is where you draw our dot, and where you change the color if
// you're in the process of being dragged. Note: the color change
// affects the original dot only, not the shadow.
public void draw(Canvas canvas) {
float cx = this.getWidth()/2 + getLeftPaddingOffset();
float cy = this.getHeight()/2 + getTopPaddingOffset();
Paint paint = mNormalPaint;
if(inDrag)
paint = mDraggingPaint;
canvas.drawCircle(cx, cy, mRadius, paint);
invalidate();
}
}
点代码看起来有点类似于 DropZone 的代码。这在一定程度上是因为在这个类中也接收到了拖动事件。一个点的构造器计算出属性以设置正确的半径和颜色,然后它设置两个监听器:一个用于长点击,另一个用于拖动事件。
这两种颜料将被用来画你的圆圈。当圆点就在那里的时候,你使用普通的颜料。但是,当拖动点时,您希望通过将原件的颜色更改为洋红色来表明这一点。
长点击监听器是您启动拖动序列的地方。让用户开始拖动点的唯一方式是用户点击并按住点。当长点击监听器触发时,您使用一个字符串和点的标签创建一个新的 ClipData 对象。您碰巧知道标记是 XML 布局文件中指定的点的名称。还有其他几种方法可以将数据指定到一个 ClipData 对象中,所以请随意阅读参考文档中关于在一个 ClipData 对象中存储数据的其他方法。
接下来的语句是关键的一个: startDrag() 。这是 Android 将接管并开始拖动过程的地方。注意,第一个参数是之前的 ClipData 对象;然后是拖动阴影对象,然后是本地状态对象,最后是数字零。
拖动阴影对象是拖动过程中显示的图像。在您的情况下,这不会替换屏幕上的原始点图像,但在拖动时,除了屏幕上的原始点之外,还会显示一个点的阴影。默认的 dragshaodbuilder 行为是创建一个看起来非常像原始的阴影,所以对于你的目的,你仅仅调用它并在你的视图中传递。在这里,你可以随心所欲地创建任何你想要的阴影视图,但是如果你要覆盖这个类,你需要实现一些方法来使它工作。
这里的 onMeasure() 方法 为 Android 提供您在这里使用的自定义视图的维度信息。你必须告诉 Android 你的视图有多大,这样它才知道如何把它和其他东西放在一起。这是自定义视图的标准做法。
最后,还有一个 onDrag() 回调。如上所述,每个拖动监听器都可以接收拖动事件。比如他们都得到动作 _ 拖动 _ 开始和动作 _ 拖动 _ 结束。因此,当事件发生时,你必须小心处理这些信息。因为在这个示例应用中有两个点,所以无论何时使用这些点做什么,都必须小心不要影响到正确的点。
当两个点都接收到 ACTION_DRAG_STARTED 动作时,只有一个点应该将自己的颜色设置为洋红色。要弄清楚哪一个是正确的,请将传入的本地状态对象与您自己进行比较。如果您回顾设置本地状态对象的位置,您将当前视图传递给了。现在,当您接收到本地状态对象时,您将它与您自己进行比较,看看您是否是启动拖动序列的视图。
如果您不同意这种观点,您可以向 LogCat 写一条日志消息,说明这不适合您,然后返回 false 说明您没有处理这条消息。
如果您是应该接收这个拖动事件的视图,那么您从拖动事件中收集一些值,然后您只需将该事件记录到 LogCat 中。第一个例外是 ACTION_DRAG_STARTED 。如果你得到了这个动作,并且是给你的,那么你就知道你的点已经开始了一个拖动序列。因此,您设置了 inDrag boolean,以便稍后的 draw() 方法会做正确的事情并显示不同颜色的点。这种不同的颜色只持续到收到 ACTION_DRAG_ENDED 为止,这时你就恢复了点的原始颜色。
如果一个点得到了 ACTION_DROP 动作,这意味着用户试图将一个点放到一个点上——甚至可能是原来的点。这不应该做任何事情,所以你只要从这个回调函数中返回 false 就可以了。
最后,您的自定义视图的 draw() 方法计算出您的圆(点)的中心点的位置,然后用适当的颜料画出来。 invalidate() 方法 告诉 Android 你已经修改了视图,Android 应该重新绘制用户界面。通过调用 invalidate() ,您可以确保用户界面会很快被新内容更新。
现在,您已经拥有了编译和部署这个示例拖放应用所需的所有文件和背景。
测试示例拖放应用
下面是我们运行这个示例应用时 LogCat 的一些示例输出。注意日志消息如何使用蓝点 来表示来自蓝点的消息,白点 表示来自白点的消息, DropTarget 表示允许放置的视图。
White dot: starting drag? true
Blue dot: This drag event is not for us
White dot: drag started. X: 53.0, Y: 206.0
DropTarget: drag started in dropTarget
DropTarget: drag entered dropTarget
DropTarget: drag proceeding in dropTarget: 29.0, 36.0
DropTarget: drag proceeding in dropTarget: 48.0, 39.0
DropTarget: drag proceeding in dropTarget: 45.0, 39.0
DropTarget: drag proceeding in dropTarget: 41.0, 39.0
DropTarget: drag proceeding in dropTarget: 40.0, 39.0
DropTarget: drag drop in dropTarget
DropTarget: Item data is White dot
ViewRoot: Reporting drop result: true
White dot: drag ended. Success? true
Blue dot: This drag event is not for us
DropTarget: drag ended in dropTarget
在这个特殊的例子中,拖动是从白点开始的。一旦长点击触发了拖动序列的开始,我们就会得到开始拖动的消息。
请注意,接下来的三行都表示在三个不同的视图中收到了一个 ACTION_DRAG_STARTED 动作。蓝点确定回调不适合它。这也不是为了的 DropTarget 。
接下来,注意拖动进行消息如何显示通过 DropTarget 发生的拖动,从 ACTION _ DRAG _ enter 动作开始。这意味着圆点被拖到了绿色方块的顶部。拖动事件对象中报告的 x 和 y 坐标是拖动点相对于视图左上角的坐标。因此,在示例应用中,拖放目标中的第一条拖动记录位于(x,y) = (29,36),拖放发生在(40,39)。看看 drop target 是如何从事件的 ClipData 中提取白点的标签名并将其写入 LogCat 的。
同样,看看所有的拖动监听器是如何接收到 ACTION_DRAG_ENDED 动作的。只有白点确定可以使用 getResult() 显示结果。
请随意试验这个示例应用。将一个点拖到另一个点,甚至拖到它本身。继续添加另一个点到 palette.xml 。请注意,当拖动的圆点离开绿色方块时,会有一条消息提示拖动已退出。还要注意的是,如果你把一个点放到绿色方块以外的地方,那么这个点就被认为是失败的。
参考
以下是一些对您可能希望进一步探索的主题有帮助的参考:
- :与本书相关的可下载项目列表。对于这一章,寻找一个名为 pro Android 5 _ Ch23 _ dragndrop . zip 的 zip 文件。这个 zip 文件包含本章中的所有项目,列在单独的根目录中。还有一个自述。TXT 文件,它准确地描述了如何将项目从这些 zip 文件之一导入到您的 IDE 中。
- 【http://developer.android.com/guide/topics/ui/drag-drop.html】:Android 开发者拖拽指南。
摘要
让我们总结一下本章涉及的主题:
- Android 3.0 中的拖放支持,并在 3.0 之前使用其他方法实现它
- 遍历可能的放置目标,查看是否发生了放置(即手指在拖动后离开屏幕)
- 计算跟踪拖动对象的位置以及它是否在放置目标上的难度
- Android 3.0+中的拖放支持,这要好得多,因为它消除了许多猜测
- 拖动侦听器,它可以是任何对象,不需要是可拖动对象或拖放目标视图
- 拖动可以发生在片段之间的事实
- DragEvent 对象,它可以包含大量关于什么被拖动以及为什么被拖动的信息
- Android 如何利用数学来确定视图顶部是否发生了拖放
二十四、使用传感器
Android 设备通常带有内置的硬件传感器,Android 提供了一个与这些传感器一起工作的框架。传感器很有趣。测量外部世界并在设备的软件中使用它是非常酷的。这种编程体验是你在桌子上或服务器机房里的普通电脑上无法获得的。使用传感器的新应用的可能性是巨大的,我们希望你受到启发来实现它们。
在这一章中,我们将探索 Android 传感器框架。我们将解释什么是传感器以及我们如何获得传感器数据,然后讨论我们可以从传感器获得的数据类型的一些细节以及我们可以用这些数据做什么。虽然 Android 已经定义了几种传感器类型,但毫无疑问,Android 的未来会有更多的传感器,我们预计未来的传感器将被纳入传感器框架。
什么是传感器?
在 Android 中, 传感器是来自物理世界的数据事件的来源。这通常是一个已经连接到设备中的硬件,但 Android 也提供了一些逻辑传感器,可以将来自多个物理传感器的数据结合起来。反过来,应用使用传感器数据来通知用户关于物理世界的信息,控制游戏,进行增强现实,或者为在现实世界中工作提供有用的工具。传感器只在一个方向上工作;它们是只读的。这使得使用它们相当简单。您设置了一个侦听器来接收传感器数据,然后在数据到达时对其进行处理。GPS 硬件就像我们在本章中讨论的传感器。在第十九章的中,我们为 GPS 位置更新设置了监听器,当这些位置更新进来时,我们就进行处理。虽然 GPS 类似于传感器,但它不是 Android 提供的传感器框架的一部分。
Android 设备中可能出现的一些传感器类型包括
- 光敏感元件
- 近程传感器
- 温度传感器
- 压力传感器
- 陀螺仪传感器
- 加速计
- 磁场传感器
- 重力传感器
- 线性加速度传感器
- 旋转矢量传感器
- 相对湿度传感器
检测传感器
请不要假设,然而,所有的 Android 设备都有所有这些传感器。事实上,许多设备只有其中一些传感器。例如,Android 模拟器只有一个加速度计。那么,如何知道设备上有哪些传感器可用呢?有两种方式,一种直接,一种间接。
第一种方法是向 SensorManager 请求可用传感器的列表。它将响应一个传感器对象列表,然后您可以为其设置监听器并从中获取数据。我们将在本章的稍后部分向您展示如何操作。这种方法假设用户已经将您的应用安装到一个设备上,但是如果这个设备没有您的应用需要的传感器呢?
这就是第二种方法出现的原因。在 AndroidManifest.xml 文件中,您可以指定设备必须具备的特性,以便正确支持您的应用。如果您的应用需要一个近程传感器,您可以在清单文件中用如下所示的行来指定:
<uses-feature android:name="android.hardware.sensor.proximity" />
谷歌 Play 商店只会将您的应用安装在具有接近传感器的设备上,因此当您的应用运行时,您会知道它就在那里。对于所有其他 Android 应用商店来说,情况就不一样了。也就是说,一些 Android 应用商店不会进行这种检查,以确保你的应用只能安装在支持你指定的传感器的设备上。
关于传感器我们能知道什么?
虽然在清单文件中使用 uses-feature 标记可以让您知道您的应用需要的传感器存在于设备上,但它并不能告诉您您可能想知道的关于实际传感器的所有信息。让我们构建一个简单的应用,向设备查询传感器信息。清单 24-1 显示了我们的主活动的 Java 代码。
注意
你可以下载本章的项目。我们会在本章末尾给你网址。这将允许您将这些项目直接导入到 IDE 中。
清单 24-1 。??【传感器列表 App】Java
public class MainActivity extends Activity {
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
TextView text = (TextView)findViewById(R.id.text);
SensorManager mgr =
(SensorManager) this.getSystemService(SENSOR_SERVICE);
List<Sensor> sensors = mgr.getSensorList(Sensor.TYPE_ALL);
StringBuilder message = new StringBuilder(2048);
message.append("The sensors on this device are:\n");
for(Sensor sensor : sensors) {
message.append(sensor.getName() + "\n");
message.append(" Type: " +
sensorTypes.get(sensor.getType()) + "\n");
message.append(" Vendor: " +
sensor.getVendor() + "\n");
message.append(" Version: " +
sensor.getVersion() + "\n");
try {
message.append(" Min Delay: " +
sensor.getMinDelay() + "\n");
} catch(NoSuchMethodError e) {} // ignore if not found
try {
message.append(" FIFO Max Event Count: " +
sensor.getFifoMaxEventCount() + "\n");
} catch(NoSuchMethodError e) {} // ignore if not found
message.append(" Resolution: " +
sensor.getResolution() + "\n");
message.append(" Max Range: " +
sensor.getMaximumRange() + "\n");
message.append(" Power: " +
sensor.getPower() + " mA\n");
}
text.setText(message);
}
private HashMap<Integer, String> sensorTypes =
new HashMap<Integer, String>();
{
sensorTypes.put(Sensor.TYPE_ACCELEROMETER, "TYPE_ACCELEROMETER");
sensorTypes.put(Sensor.TYPE_AMBIENT_TEMPERATURE,
"TYPE_AMBIENT_TEMPERATURE");
/* ... the rest is omitted to save space ... */
}
}
在我们的 onCreate() 方法中,我们首先获取对 SensorManager 的引用。其中只能有一个,所以我们将其作为系统服务进行检索。然后我们调用它的 getSensorList() 方法来获取传感器列表。对于每一个传感器,我们写下它的信息。输出将类似于图 24-1 。
图 24-1 。从我们的传感器列表应用输出
关于此传感器信息,需要了解一些事情。类型值告诉您传感器的基本类型,但没有具体说明。光传感器就是光传感器,但不同设备的光传感器可能会有所不同。例如,一个设备上的光传感器的分辨率可能与另一个设备上的不同。当你在 < uses-feature > 标签中指定你的应用需要一个光传感器时,你并不知道你将会得到什么类型的光传感器。如果这对您的应用有影响,您将需要查询设备来找出并相应地调整您的代码。
您获得的分辨率和最大范围值将采用该传感器的相应单位。功率测量以毫安(mA)为单位,代表传感器从设备电池中汲取的电流;越小越好。
现在我们知道了我们可以使用哪些传感器,我们如何从它们那里获取数据呢?正如我们前面所解释的,我们设置了一个监听器来获取发送给我们的传感器数据。现在让我们来探索一下。
获取传感器事件
一旦我们注册了一个侦听器来接收数据,传感器就会向我们的应用提供数据。当我们的听众不听时,传感器可以关闭,节省电池寿命,所以请确保您只在真正需要的时候听。设置传感器监听器很容易做到。假设我们想要测量光传感器的亮度。清单 24-2 显示了实现这一功能的示例应用的 Java 代码。
清单 24-2 。 一款光传感器监控 App 的 Java 代码
public class MainActivity extends Activity implements SensorEventListener {
private SensorManager mgr;
private Sensor light;
private TextView text;
private StringBuilder msg = new StringBuilder(2048);
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
mgr = (SensorManager) this.getSystemService(SENSOR_SERVICE);
light = mgr.getDefaultSensor(Sensor.TYPE_LIGHT);
text = (TextView) findViewById(R.id.text);
}
@Override
protected void onResume() {
mgr.registerListener(this, light,
SensorManager.SENSOR_DELAY_NORMAL);
super.onResume();
}
@Override
protected void onPause() {
mgr.unregisterListener(this, light);
super.onPause();
}
public void onAccuracyChanged(Sensor sensor, int accuracy) {
msg.insert(0, sensor.getName() + " accuracy changed: " +
accuracy + (accuracy==1?" (LOW)":(accuracy==2?" (MED)":
" (HIGH)")) + "\n");
text.setText(msg);
text.invalidate();
}
public void onSensorChanged(SensorEvent event) {
msg.insert(0, "Got a sensor event: " + event.values[0] +
" SI lux units\n");
text.setText(msg);
text.invalidate();
}
}
在这个示例应用中,我们再次获得对 SensorManager 的引用,但是我们没有获得传感器列表,而是专门查询光传感器。然后,我们在活动的 onResume() 方法中设置了一个监听器,并在 onPause() 方法中注销了这个监听器。当我们的应用不在前台时,我们不想担心光线水平。
对于 registerListener() 方法,我们传入一个值,表示我们希望多久收到一次传感器值变化的通知。该参数可以是
- 传感器 _ 延迟 _ 正常(代表 200,000 微秒延迟)
- SENSOR_DELAY_UI(代表 60000 微秒延迟)
- 传感器 _ 延迟 _ 游戏(代表 20000 微秒延迟)
- 传感器 _ 延迟 _ 最快(代表尽可能快)
您还可以使用其他 registerListener 方法之一指定特定的微秒延迟,只要它大于 3 微秒;然而,任何低于 20,000 英镑的都不太可能兑现。为该参数选择合适的值很重要。有些传感器非常敏感,会在短时间内产生大量事件。如果您选择 SENSOR _ DELAY _ fast,您甚至可能会超出应用的处理能力。根据您的应用对每个传感器事件的处理,您可能会在内存中创建和销毁太多对象,以至于垃圾收集会导致设备明显变慢和中断。另一方面,某些传感器非常需要尽可能频繁地被读取;这尤其适用于旋转矢量传感器。另外,不要依赖这个参数来生成精确定时的事件。事件可能会来得快一点或慢一点。
因为我们的活动实现了 SensorEventListener 接口,所以我们有两个针对传感器事件的回调: onAccuracyChanged() 和 onSensorChanged() 。第一种方法将让我们知道我们的传感器(或多个传感器,因为它可以被调用多个传感器)的精度是否改变。精度参数的值将为传感器状态不可靠、传感器状态精度低、传感器状态精度中或传感器状态精度高。精度不可靠不代表设备坏了;这通常意味着传感器需要校准。第二个回调方法告诉我们光线水平何时改变,我们得到一个 SensorEvent 对象来告诉我们来自传感器的新值的细节。
一个 SensorEvent 对象有几个成员,其中一个是一个由 float 值组成的数组。对于光传感器事件,只有第一个 float 值有意义,它是传感器检测到的光的 SI lux 值。对于我们的示例应用,我们通过在旧消息的顶部插入新消息来构建消息字符串,然后我们在一个 TextView 中显示这批消息。我们最新的传感器值将始终显示在屏幕顶部。
当您运行这个应用时(当然是在真实的设备上,因为模拟器没有光传感器),您可能会注意到最初什么也没有显示。只需更改设备左上角的指示灯。这很可能是你的光传感器所在的位置。如果你仔细看,你可能会看到屏幕后面的那个点,那就是光传感器。如果你用手指盖住这个点,光级很可能会变成一个很小的值(虽然可能不会达到零)。信息应该显示在屏幕上,告诉你光线水平的变化。
注意
您可能还会注意到,当光传感器被盖住时,您的按钮会亮起(如果您的设备带有发光按钮)。这是因为 Android 已经检测到了黑暗,并点亮了按钮,使设备在“黑暗中”更容易使用。
获取传感器数据的问题
Android 传感器框架有一些你需要注意的问题。这是不好玩的部分。在某些情况下,我们有办法解决问题;在其他地方我们没有,或者很难。
无法直接访问传感器值
您可能已经注意到,没有直接的方法来查询传感器的当前值。从传感器获取数据的唯一方法是通过监听器。有两种传感器:流式传感器和非流式传感器。流式传感器 s 将定期发送数值,例如加速度计。方法调用 getMinDelay() 将为流式传感器返回一个非零值,告诉您传感器用来感知环境的最小微秒数。对于非流式传感器来说,返回值是零,所以即使你设置了监听器,也不能保证你会在设定的时间内得到新的数据。至少回调是异步的,所以不会阻塞 UI 线程等待来自传感器的数据。然而,您的应用必须适应这样一个事实,即传感器数据可能在您需要的时候不可用。重温图 24-1 ,你会注意到光传感器是不流动的。因此,只有当光线水平发生变化时,您的应用才会获得一个事件。对于所示的其他传感器,事件之间的延迟最小为 20 毫秒,但也可能更长。
可以使用本机代码和 Android 的 JNI 功能直接访问传感器。您需要了解感兴趣的传感器驱动程序的底层本地 API 调用,并且能够设置回 Android 的接口。所以可以做到,但是不容易。
传感器值发送不够快
即使在 SENSOR _ DELAY _ fast 下,您可能也不会每隔 20 ms 获得一次新值(这取决于设备和传感器)。如果您需要比使用 SENSOR _ DELAY _ fast 的速率设置所能获得的更快的传感器数据,可以使用本地代码和 JNI 来更快地获得传感器数据,但与前面的情况类似,这并不容易。
传感器随屏幕关闭
Android 2.x 中出现了传感器更新在屏幕关闭时关闭的问题。显然有人认为,如果屏幕关闭,不发送传感器更新是一个好主意,即使你的应用(很可能使用服务)有一个唤醒锁。基本上,当屏幕关闭时,你的监听器会被注销。
这个问题有几种解决方法。有关此问题以及可能的解决方案和变通办法的更多信息,请参考 Android 问题 11028:
[`code.google.com/p/android/issues/detail?id=11028`](http://code.google.com/p/android/issues/detail?id=11028)
现在你知道了如何从传感器获取数据,你能用这些数据做什么?如前所述,根据从哪个传感器获取数据,values 数组中返回的值有不同的含义。下一节将探讨每种传感器类型及其值的含义。
解释传感器数据
现在,您已经了解了如何从传感器获取数据,您将希望对这些数据做一些有意义的事情。然而,您获得的数据将取决于您从哪个传感器获得数据。有些传感器比其他传感器简单。在接下来的部分中,我们将描述您将从我们目前了解的传感器中获得的数据。随着新设备的出现,新的传感器无疑也会被引入。传感器框架很可能保持不变,因此我们在这里展示的技术应该同样适用于新的传感器。
光传感器
光传感器 是设备上最简单的传感器之一,也是本章第一个示例应用中使用的传感器。传感器给出由设备的光传感器检测到的光水平的读数。随着光线水平的变化,传感器读数也会变化。数据的单位是 SI lux 单位。要了解这意味着什么,请参阅本章末尾的“参考资料”部分,获取更多信息的链接。
对于 SensorEvent 对象中的值数组,光线传感器只使用第一个元素 values[0] 。该值是一个浮动值,从技术上讲,范围从 0 到特定传感器的最大值。我们说在技术上是,因为传感器在没有光线的时候可能只发送非常小的值,而从来不会实际发送值 0 。
还要记住,传感器可以告诉我们它可以返回的最大值,不同的传感器可以有不同的最大值。出于这个原因,在 SensorManager 类中考虑与光线相关的常数可能没有用。比如 SensorManager 有一个常量叫做 LIGHT_SUNLIGHT_MAX ,是一个 float 值 12 万;然而,当我们之前查询我们的设备时,返回的最大值是 10,240,显然比这个常量值小得多。还有一个叫 LIGHT_SHADE 在 20000,也在我们测试的设备最大值以上。因此,在编写使用光传感器数据的代码时,请记住这一点。
接近传感器
接近传感器 要么测量某个物体距离设备的距离(以厘米为单位),要么代表一个标志来表示物体是近还是远。一些接近传感器将给出范围从 0.0 到最大值的增量值,而其他传感器返回 0.0 或仅返回最大值。如果接近传感器的最大范围等于传感器的分辨率,那么你知道它是只返回 0.0 或最大值的传感器之一。有最大值为 1.0 的设备,也有最大值为 6.0 的设备。不幸的是,在应用安装和运行之前,没有办法知道你将得到哪个接近传感器。即使您在接近传感器的 AndroidManifest.xml 文件中放置了一个 < uses-feature > 标签,您也可以获得任何一种标签。除非您绝对需要更细粒度的接近传感器,否则您的应用应该很好地适应这两种类型。
关于接近传感器有一个有趣的事实:接近传感器有时和光传感器是同一个硬件。然而,Android 仍然将它们视为逻辑上独立的传感器,所以如果您需要来自两者的数据,您将需要为每一个设置一个监听器。这里有另一个有趣的事实:接近传感器通常用于手机应用,以检测设备旁边是否有人的头部。如果头部离触摸屏如此之近,触摸屏将被禁用,因此当人在打电话时,耳朵或脸颊不会意外按下任何按键。
本章的源代码项目包括一个简单的近程传感器监视器应用,它基本上是光传感器监视器应用,修改后使用近程传感器而不是光传感器。我们不会在本章中包含代码,但是您可以自己尝试。
温度传感器
旧的不推荐使用的温度传感器 (TYPE_TEMPERATURE)提供温度读数,并且在值【0】中只返回一个值。该传感器通常读取内部温度,例如电池的温度。有一种新型温度传感器叫做 TYPE _ AMBIENT _ TEMPERATURE。新值表示设备外部的温度,单位为摄氏度。
温度传感器的位置取决于器件,温度读数可能会受到器件本身产生的热量的影响。本章的项目包括一个名为温度传感器的温度传感器。它负责根据运行的 Android 版本调用正确的温度传感器。
压力传感器 s
该传感器测量大气压力,例如可以检测海拔高度或用于天气预测。该传感器不应与触摸屏产生具有压力值(触摸压力)的运动事件的能力相混淆。我们在第二十二章中讨论过这种触摸类型的压力感应。触摸屏压力感测不使用 Android 传感器框架。
压力传感器的测量单位为大气压力,单位为 hPa(毫巴),该测量值以值[0] 表示。
陀螺仪传感器
陀螺仪 是非常酷的组件,可以测量设备相对于参考系的扭曲度。换句话说,陀螺仪测量绕轴旋转的速率。当设备不旋转时,传感器值将为零。当有任何方向的旋转时,你会从陀螺仪得到非零值。陀螺仪常用于导航。但是陀螺仪本身并不能告诉你导航所需要知道的一切。不幸的是,随着时间的推移,错误会慢慢出现。但是再加上加速度计,就可以确定设备的运动路径。
卡尔曼滤波器可用于将来自两个传感器的数据联系在一起。加速度计在短期内不是非常精确,陀螺仪在长期内也不是非常精确,所以结合起来,它们可以一直相当精确。虽然卡尔曼滤波器非常复杂,但有一种替代方法叫做互补滤波器 ,它更容易在代码中实现,并且产生非常好的结果。这些概念超出了本书的范围。
陀螺仪传感器返回 x、y 和 z 轴的值数组中的三个值。单位是弧度/秒,数值代表绕每个轴的旋转速率。处理这些值的一种方法是对它们进行时间积分,以计算角度变化。这种计算类似于将线速度与时间相结合来计算距离。
加速计
加速度计可能是设备上使用最多的传感器。使用这些传感器,您的应用可以确定设备在空间中相对于重力直接下拉的物理方向,并了解作用在设备上的力。提供这些信息允许应用做各种有趣的事情,从玩游戏到增强现实。当然,加速度计会告诉 Android 何时将用户界面的方向从纵向切换到横向,然后再切换回来。
加速度计坐标系是这样工作的:加速度计的 x 轴从设备的左下角开始,穿过底部到右边。y 轴也从左下角开始,沿着显示屏的左侧向上。z 轴从左下角开始,在远离设备的空间中向上延伸。图 24-2 显示了这意味着什么。
图 24-2 。加速度计坐标系
这个坐标系不同于布局和 2D 图形中使用的坐标系。在这个坐标系中,原点(0,0)在左上角,y 在屏幕下方是正的。在处理不同参照系的坐标系时很容易混淆,所以要小心。
我们还没有说加速度计的值意味着什么,那么它们意味着什么呢?加速度的单位是米每秒平方(米/秒 2 )。正常的地球重力是 9.81 米/秒 2 ,向地心拉下。从加速度计的角度来看,重力测量值为–9.81。如果您的设备完全静止(不动)并且位于一个非常平坦的表面上,x 和 y 读数将为 0,z 读数将为+9.81。实际上,由于加速度计的灵敏度和精度,这些值不会完全相同,但它们会很接近。当设备静止时,重力是唯一作用在设备上的力,因为重力直接向下拉动,如果我们的设备完全平坦,它对 x 轴和 y 轴的影响为零。在 z 轴上,加速度计测量设备上的力减去重力。因此,0 减–9.81 等于+9.81,这就是 z 值(在 SensorEvent 对象中又称为 values【2】)。
加速度计发送给应用的值始终代表设备上的力减去重力的总和。如果你拿起一个非常平的设备,把它垂直向上举起,z 值一开始会增加,因为你增加了向上(z)方向的力。一旦你的提升力停止,总的力就会回到重力状态。如果该设备被摔落(假设——请不要这样做),它将朝着地面加速,这将使重力为零,因此加速度计将读取 0 力。
让我们从图 24-2 中取出设备,并向上旋转,使其处于纵向模式和垂直状态。x 轴也是一样,从左指向右。我们的 y 轴现在是直上直下的,z 轴指向屏幕外面直对着我们。y 值将为+9.81,x 和 z 都将为 0。
当你将设备旋转到横向模式并继续垂直拿着它,屏幕就在你的面前时会发生什么?如果你猜 y 和 z 现在是 0,x 是+9.81,那你就对了。图 24-3 显示了它可能的样子。
图 24-3 。横向垂直加速计数值
当设备不移动或以恒定速度移动时,加速度计仅测量重力。在每个轴上,加速度计的值是重力在那个轴上的分量。因此,使用一些三角学,你可以计算出角度,并知道设备相对于重力的方向。也就是说,你可以知道设备是处于纵向模式还是横向模式,或者处于某种倾斜模式。事实上,这正是 Android 所做的,以确定使用哪种显示模式(纵向或横向)。然而,请注意,加速度计并没有说明设备如何相对于磁北定向。因此,虽然你可以知道设备是在横向模式下垂直放置的,但你不知道你是面向东还是面向西或者两者之间的任何地方。这就是磁场传感器的用武之地,我们将在后面的章节中讨论。
加速度计和显示方向
设备中的加速度计是硬件,它们牢固地连接在一起,因此相对于设备具有特定的方向,不会随着设备的转动而改变。加速度计发送到 Android 的值当然会随着设备的移动而改变,但加速度计的坐标系相对于物理设备保持不变。然而,当用户从纵向到横向再返回时,显示器的坐标系会改变。事实上,根据屏幕转动的方式,纵向可以是正面朝上,也可以是 180 度倒置。类似地,风景可以是相隔 180 度的两个不同旋转之一。
当您的应用正在读取加速度计数据并希望正确影响用户界面时,您的应用必须知道显示器发生了多大的旋转,才能进行适当的补偿。当您的屏幕从纵向重新定向为横向时,屏幕的坐标系相对于加速度计的坐标系发生了旋转。要处理这一点,您的应用必须使用方法 display . get rotation()。返回值是一个简单的整数,但不是旋转的实际度数。该值将是面中的一个。、面旋转 _0。旋转 _90 ,面。ROTATION_180 或面。旋转 _270 。这些常量的值分别为 0 、 1 、 2 和 3 。这个返回值告诉你显示器从设备的“正常”方向旋转了多少。因为不是所有的 Android 设备都正常处于人像模式,所以你不能假设人像处于 ROTATION_0 。
加速度计和重力
到目前为止,我们只是简单地谈到了当设备移动时加速度计值会发生什么变化。让我们进一步探讨这个问题。加速度计将检测到作用在设备上的所有力。如果抬起设备,初始升力在 z 方向为正,您会得到一个大于+9.81 的 z 值。如果你把设备推到左边,你会在 x 方向得到一个初始的负读数。
你想要做的是,把重力和作用在设备上的其他力分开。有一种相当简单的方法可以做到这一点,它被称为低通滤波器 。除了重力之外,作用在该装置上的力通常不是渐进的。换句话说,如果用户正在摇动设备,则摇动力会迅速反映在加速度计值中。低通滤波器将有效地去除振动力,只留下稳定的力,即重力。让我们用一个示例应用来说明这个概念。它叫做重力演示。清单 24-3 显示了 Java 代码。
清单 24-3 。 用加速度计测量重力
// This file is MainActivity.java
public class MainActivity extends Activity implements SensorEventListener {
private SensorManager mgr;
private Sensor accelerometer;
private TextView text;
private float[] gravity = new float[3];
private float[] motion = new float[3];
private double ratio;
private double mAngle;
private int counter = 0;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
mgr = (SensorManager) this.getSystemService(SENSOR_SERVICE);
accelerometer = mgr.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
text = (TextView) findViewById(R.id.text);
}
@Override
protected void onResume() {
mgr.registerListener(this, accelerometer,
SensorManager.SENSOR_DELAY_UI);
super.onResume();
}
@Override
protected void onPause() {
mgr.unregisterListener(this, accelerometer);
super.onPause();
}
public void onAccuracyChanged(Sensor sensor, int accuracy) {
// ignore
}
public void onSensorChanged(SensorEvent event) {
// Use a low-pass filter to get gravity.
// Motion is what's left over
for(int i=0; i<3; i++) {
gravity [i] = (float) (0.1 * event.values[i] +
0.9 * gravity[i]);
motion[i] = event.values[i] - gravity[i];
}
// ratio is gravity on the Y axis compared to full gravity
// should be no more than 1, no less than -1
ratio = gravity[1]/SensorManager.GRAVITY_EARTH;
if(ratio > 1.0) ratio = 1.0;
if(ratio < -1.0) ratio = -1.0;
// convert radians to degrees, make negative if facing up
mAngle = Math.toDegrees(Math.acos(ratio));
if(gravity[2] < 0) {
mAngle = -mAngle;
}
// Display every 10th value
if(counter++ % 10 == 0) {
String msg = String.format(
"Raw values\nX: %8.4f\nY: %8.4f\nZ: %8.4f\n" +
"Gravity\nX: %8.4f\nY: %8.4f\nZ: %8.4f\n" +
"Motion\nX: %8.4f\nY: %8.4f\nZ: %8.4f\nAngle: %8.1f",
event.values[0], event.values[1], event.values[2],
gravity[0], gravity[1], gravity[2],
motion[0], motion[1], motion[2],
mAngle);
text.setText(msg);
text.invalidate();
counter=1;
}
}
}
运行该应用的结果是一个类似于图 24-4 的显示。这张截图是在设备平放在桌子上时拍摄的。
图 24-4 。重力、运动和角度值
这个示例应用的大部分与之前的 Accel 传感器应用相同。不同之处在于 onSensorChanged() 方法。我们试图跟踪重力和运动,而不是简单地显示事件数组中的值。通过只使用事件数组中的一小部分新值和重力数组中以前值的大部分来获得重力。所用的两部分之和必须是 1.0。我们用了 0.9 和 0.1。您也可以尝试其他值,例如 0.8 和 0.2。我们的重力阵列不可能像实际传感器值那样快速变化。但这更接近现实。这就是低通滤波器的作用。只有当力导致设备移动时,事件数组值才会改变,并且您不希望将这些力作为重力的一部分来测量。你只想在你的重力数组中记录重力本身。这里的数学并不意味着您神奇地只记录重力,但是您计算的值将会比事件数组中的原始值更接近。
还要注意代码中的运动数组。通过跟踪原始事件数组值和计算出的重力值之间的差异,您基本上是在测量运动数组中设备上的主动力、非重力。如果运动数组中的值为零或非常接近零,这意味着设备可能没有移动。这是有用的信息。从技术上讲,以恒定速度移动的设备在运动数组中也具有接近于零的值,但是实际情况是,如果用户正在移动设备,运动值将稍微大于零。用户不可能以完美的恒定速度移动设备。
最后,请注意这个例子没有产生需要垃圾收集的新对象。在处理传感器事件时,不创建新对象是非常重要的;否则,您的应用将花费太多时间暂停垃圾收集周期。
使用加速度计测量设备的角度
在我们继续之前,我们想再向您展示一个关于加速度计的东西。如果我们回顾三角学的课程,我们会记得角的余弦是近边和斜边的比值。如果我们考虑 y 轴和重力本身之间的角度,我们可以测量重力在 y 轴上的力,然后取反余弦来确定角度。我们在这段代码中也做到了这一点,尽管这里我们不得不再次处理 Android 中一些混乱的传感器。在 SensorManager 中有不同重力常数的常数,包括地球的。但是您的实际测量值可能会超过定义的常数。接下来我们会解释这是什么意思。
理论上,你的设备在静止状态下会测得一个与恒定值相等的重力值,但这种情况很少发生。静止时,加速度计传感器很可能会给我们一个大于或小于常数的重力值。因此,我们的比率最终可能会大于 1,或者小于负 1。这会让 acos() 方法抱怨,所以我们将比率值固定为不大于 1 且不小于-1。相应的角度范围从 0 度到 180 度。这很好,除了这样我们不会得到从 0 到–180 的负角度。为了获得负角度,我们使用重力数组中的另一个值,即 z 值。如果重力的 z 值为负,则意味着设备的正面朝下。对于所有那些设备面朝下的值,我们也使我们的角度为负,结果是我们的角度从–180 到+180,正如我们所预期的。
继续尝试这个示例应用。请注意,当设备平放时,角度值为 90 °,当设备在我们面前上下垂直放置时,角度值为零(或接近于 90°)。如果我们继续向下旋转越过 flat,我们会看到角度值超过 90°。如果我们将设备从 0°位置向上倾斜更多,角度值将变为负值,直到我们将设备举过头顶,角度值为–90°。最后,您可能已经注意到了我们的计数器,它控制显示更新的频率。因为传感器事件可能会非常频繁地出现,所以我们决定每十次获取值时才显示一次。
磁场传感器
磁场传感器测量 x、y 和 z 轴上的环境磁场。该坐标系就像加速度计一样对齐,因此 x、y 和 z 如图 24-2 中的所示。磁场传感器的单位是微特斯拉(uT)。这个传感器可以探测地球磁场,从而告诉我们北极在哪里。这个传感器也被称为指南针 ,实际上 < uses-feature > 标签使用 Android . hardware . sensor . compass 作为这个传感器的名称。由于这种传感器非常微小和敏感,它会受到设备附近物体产生的磁场的影响,甚至在某种程度上影响设备内的组件。因此,磁场传感器的准确性有时会受到怀疑。
我们在网站的下载部分包含了一个简单的 CompassSensor 应用,所以可以随意导入并使用它。如果您在此应用运行时将金属物体放在设备附近,您可能会注意到相应的数值变化。当然,如果你把一块磁铁靠近设备,你会看到数值发生变化。事实上,谷歌 Cardboard“设备”在一个物理按钮下使用了一块磁铁,当按钮被按下时,手机会检测到磁场的变化。
你可能会问,我可以用指南针传感器作为指南针来检测北在哪里吗?答案是:它本身不会。虽然指南针传感器可以检测设备周围的磁场,但如果设备没有完全平放在地球表面,您将无法正确解读指南针传感器的值。但是你有加速度计可以告诉你设备相对于地球表面的方向!因此,您可以从指南针传感器创建一个指南针,但您也需要加速度计的帮助。让我们来看看如何做到这一点。
一起使用加速度计和磁场传感器
SensorManager 提供了一些方法,允许我们结合指南针传感器和加速度计来计算方向。正如我们刚刚讨论的,您不能只使用指南针传感器来完成这项工作。因此 SensorManager 提供了一个名为 getRotationMatrix() 的方法,该方法从加速度计和指南针获取值,并返回一个可用于确定方向的矩阵。
另一个 SensorManager 方法, getOrientation() ,采用前一步的旋转矩阵,给出一个方向矩阵。方向矩阵中的值告诉您设备相对于地球磁北极的旋转,以及设备相对于地面的俯仰和滚动。
磁偏角和地磁场
关于方向和设备,我们还想讨论另一个话题。指南针传感器会告诉你磁北在哪里,但不会告诉你真北在哪里(也叫地理北)。想象你正站在磁北极和地理北极的中点。他们会相差 180 度。离两个北极越远,这个角度差就越小。磁北与真北的角度差称为磁偏角 。而且这个值只能相对于行星表面上的一个点来计算。也就是说,你必须知道你站在哪里,才能知道地理北极相对于磁北极的位置。幸运的是,Android 有一种方法可以帮助你,那就是地磁领域 ?? 类。
为了实例化一个 GeomagneticField 类的对象,您需要传入一个纬度和经度。因此,为了得到磁偏角,你需要知道参考点在哪里。您还需要知道您想要该值的时间。磁北极随时间漂移。一旦实例化,您只需调用此方法来获取偏角(以度为单位):
float declinationAngle = geoMagField.getDeclination();
如果磁北在地理北的东面,则下倾角的值将为正。
重力传感器
这个传感器不是一个独立的硬件。这是一个基于加速度计的虚拟传感器。事实上,这种传感器使用的逻辑类似于我们之前描述的加速度计,以产生作用于设备的力的重力分量。然而,我们不能访问这个逻辑,所以无论在重力传感器类中使用什么因素和逻辑,我们都必须接受。不过,虚拟传感器可能会利用陀螺仪等其他硬件来帮助它更准确地计算重力。该传感器的值数组报告重力,就像加速计传感器报告其值一样。
线性加速度传感器
与重力传感器类似,线性加速度传感器 是一种虚拟传感器,代表加速度计力减去重力。同样,我们之前对加速度计传感器值进行了自己的计算,以去除重力,从而获得这些线性加速度力值。这个传感器让你更方便。它可以利用陀螺仪等其他硬件来帮助它更精确地计算线性加速度。值数组报告线性加速度,就像加速度计传感器报告其值一样。
旋转矢量传感器
旋转矢量传感器 代表设备在空间中的方位,相对于硬件加速度计的参考框架有一定角度(见图 24-2 )。该传感器返回一组表示单位四元数的最后三个分量的值。四元数是一个可以写满一本书的主题,所以我们不会在这里深入讨论。
谢天谢地,谷歌在 SensorManager 中提供了一些方法来帮助这个传感器。getquaternionfromvvector()方法将旋转矢量传感器输出转换为归一化四元数。getrotationmatrix from vector()方法将旋转矢量传感器输出转换为旋转矩阵,可与 getOrientation() 一起使用。但是,当将旋转矢量传感器输出转换为方向矢量时,您需要意识到它的范围是从–180 度到+180 度。
本章示例应用的 ZIP 文件包括一个版本的 VirtualJax ,它显示了正在使用的旋转矢量。
参考
以下是一些对您可能希望进一步探索的主题有帮助的参考:
- :与本书相关的可下载项目列表。对于这一章,寻找一个名为 pro Android 5 _ Ch24 _ sensors . ZIP 的 ZIP 文件。该文件包含本章中的所有项目,列在单独的根目录中。还有一个自述。TXT 文件,它准确地描述了如何将项目从这些 ZIP 文件之一导入到 IDE 中。
- :维基百科词条勒克司,光的度量单位。
- :来自 NOAA 的地磁信息。
- :大卫·萨克斯关于加速度计、陀螺仪、指南针和 Android 开发的谷歌技术演讲。
stack overflow . com/questions/1586658/combine-gyroscope-and-accelerometer-data:stackoverflow.com 上一篇不错的帖子,谈到了组合陀螺仪和加速度计传感器数据以供应用。en . Wikipedia . org/wiki/Quaternions _ and _ spatial _ rotation:关于四元数及其如何用于表示空间旋转的维基百科页面,例如 Android 设备。
摘要
在本章中,我们讨论了以下主题:
- 安卓有哪些传感器。
- 找出设备上的传感器。
- 指定应用加载到 Android 设备之前所需的传感器。
- 确定设备上传感器的属性。
- 如何获取传感器事件?
- 事实上,只要传感器值发生变化,事件就会发生,因此了解在获得第一个值之前可能会有一个滞后是很重要的。
- 传感器更新的不同速度以及何时使用每种速度。
- SensorEvent 的详细信息以及这些信息如何用于各种传感器类型。
- 虚拟传感器,由来自其他传感器的数据组成。旋转矢量传感器就是其中之一。
- 使用传感器确定设备的角度,并告知设备面向哪个方向。
二十五、探索 Android 持久性和内容供应器
在 Android SDK 中有许多保存状态的方法。其中包括 1)共享首选项,2)内部文件,3)外部文件,4) SQLite,5)内容供应器,6) O/R 映射工具,以及 7)云中的网络存储。我们将首先简要介绍每个状态保存选项,然后详细介绍使用 SQLite 和内容提供者管理应用状态。
使用共享偏好设置保存状态
我们已经在第十一章中介绍了共享偏好。共享首选项是应用拥有的基于键/值的 XML 文件。Android 在这个通用的持久化机制之上有一个框架来显示/更新/检索偏好,而无需编写大量代码。后一个方面是第十一章的主要话题。
第十一章还简要介绍了应用如何使用 XML 文件中的共享偏好 API 存储任何类型的数据。在这种方法中,数据首先被转换为字符串表示,然后存储在首选项键/值存储中。这种方法可以用来存储应用的任意状态,只要它是小到中等大小。
共享首选项 XML 文件是设备上应用的内部文件。其他应用不能直接使用这些数据。最终用户不能通过安装到 USB 端口上来直接操作这些数据。当应用被删除时,该数据被自动删除。
从简单到中等程度的应用持久性需求,您可以通过将各种 Java 对象树直接存储在共享首选项文件中来利用共享首选项。在给定的首选项文件中,可以有一个指向序列化 Java 对象树的关键点。您还可以为多个 Java 对象树使用多个首选项文件。我们已经使用 google 的 JSON/GSON 库非常有效地完成了从 Java 对象到它们的等效 JSON 字符串值的转换。在这种方法中,使用 google GSON 库将 Java 对象树作为 JSON 字符串进行流式传输。然后,该树作为一个值存储在首选项文件的键/值对中。请记住,Java 对象的 GSON 和 JSON 转换可能有一些限制。阅读 GSON/JSON 文档,了解 Java 对象可以变得多么复杂,才能使这种方法发挥作用。我们相当确信,对于大多数基于数据的 Java 对象来说,这是可行的。
清单 25-1 给出了一些使用 GSON/JSON 和共享参数保存 Java 树的示例代码。
清单 25-1 。使用 JSON 在共享首选项 XML 文件中保存 Java 对象树
//Implementation of storeJSON for storing any object
public void storeJSON(Context context, Object anyObject) {
//Get a GSON instance
Gson gson = new Gson();
//Convert Java object to a JSON string
String jsonString = gson.toJson(anyObject);
//See Chapter 11 for more details on how to get a shared preferences reference
String filename = "somefilename.xml";
int mode = Context.MODE_PRIVATE;
SharedPreferences sp = context.getSharedPreferences(filename,mode);
//Save the JSON string in the shared preferences
SharedPreferences.Editor spe = sp.edit();
spe.putString("json", jsonString);
spe.commit();
}
//This code can then be used by a client like this:
//Create any data object with reasonable complexity
//Ex: MainObject mo = MainObject.createTestMainObject();
//You can then call storeJSON(some-activity, mo) below
清单 25-2 展示了一些使用 GSON/JSON 和共享参数检索 Java 树的示例代码。
清单 25-2 。使用 JSON 从共享首选项 XML 文件中读取 Java 对象树
public Object retrieveJSON(Context context, String filename, Class classRef) {
int mode = Context.MODE_PRIVATE;
SharedPreferences sp = context.getSharedPreferences(filename,mode);
String jsonString = sp.getString("json", null);
if (jsonString == null) {
throw new RuntimeException("Not able to read the preference");
}
Gson gson = new Gson();
return gson.fromJson(jsonString, classRef);
}
//You can then do this in the client code
MainObject mo = (MainObject)retrieveJSON(context,"somefilename.xml", MainObject.class);
String compareResult = MainObject.checkTestMainObject(mo);
if (compareResult != null) {
throw new RuntimeException("Something is wrong. Objects don't match");
}
这段代码要求您将 GSON Java 库添加到项目中。这种基于 GSON 的方法在我们的姊妹书《来自 Apress 的专家 Android》中有详细介绍。这个在网上也有简要记载在。
使用内部文件保存状态
在 Android 中,你也可以使用内部文件来存储你的应用的状态。这些内部文件是设备上应用的内部文件。其他应用不能直接使用这些数据。最终用户不能通过安装到 USB 端口上来直接操作这些数据。当应用被删除时,该数据被自动删除。
清单 25-3 显示了如何使用 GSON/JSON 和内部文件保存 Java 树的示例代码。
清单 25-3 。从/向 Android 内部文件读取/写入 JSON 字符串
private Object readFromInternalFile(Context appContext, String filename, Class classRef)
throws Exception
{
FileInputStream fis = null;
try {
fis = appContext.openFileInput(filename);
//Read the following string from the filestream fis
String jsonString;
Gson gson = new Gson();
return gson.fromJson(jsonString, classRef);
}
finally {
// write code to closeStreamSilently(fis);
}
}
private void saveToInternalFile(Context appContext, String filename, Object anyObject){
Gson gson = new Gson();
String jsonString = gson.toJson(anyObject);
FileOutputStream fos = null;
try {
fos = appContext.openFileOutput(filename
,Context.MODE_PRIVATE);
fos.write(jsonString.getBytes());
}
finally {
// closeStreamSilently(fos);
}
}
这种基于内部文件和 GSON 的方法在我们的姊妹书《来自 Apress 的专家 Android(【www.apress.com/97814302495…](androidbook.com/item/4439)也…
使用外部文件保存状态
在 Android 中,外部文件要么存储在 SD 卡上,要么存储在设备上。这些成为公共文件,其他应用(包括用户)可以在应用环境之外看到和读取。对于许多想要管理内部状态的应用来说,这些外部文件会不必要地污染公共空间。
因为您表示为 JSON 的数据通常是特定于您的应用的,所以将其作为外部存储是没有意义的,外部存储通常用于音乐文件、视频文件或其他应用可以理解的格式的文件。
因为诸如 SD 卡的外部存储器可以处于各种状态(可用、未安装、已满等。),当数据足够小时,为简单的应用编写这样的程序会更困难。因此,我们现在还不能很好地将应用状态保存在外部存储上。
如果应用需要音乐和照片,而这些可以放在外部存储中,同时将核心状态数据保存在 JSON 和 internal 中,那么混合方法可能是有意义的。
android.os.Environment 类和 android.content.Context 类有许多读写外部文件和目录的方法。我们没有包括代码示例,因为一旦您通过和 id.content.Context 访问这些文件,这种方法与内部文件非常相似。
使用 SQLite 保存状态
Android 应用可以使用 SQLite 数据库来存储它们的状态。SQLite 很好地集成到了 Android 的结构中。如果你想存储应用的内部状态,那么这可能是最好的方法。然而,使用包括 SQLite 在内的任何关系数据库都有许多细微差别。我们将在本章稍后介绍在 Android 上使用 SQLite 的要点和细微差别。
使用 O/R 映射库保存状态
O/R 映射代表对象到关系的映射。用 Java 这样的编程语言在关系数据库中存储状态的一个关键困难是 Java 对象结构和数据库的关系结构之间的不匹配。我们需要在名称、类型和字段关系之间进行映射,因为它们在 Java 空间和数据库空间中是等价的。这种映射容易出错。当我们稍后详细介绍 SQLite 时,您会看到这一点。
需要简化 Java 和 SQL 之间的数据映射。这个空间在业内被称为 O/R 映射。现在有一些工具可以在 Android 中解决这个问题。涵盖这些 O/R 映射工具的要点超出了本书的范围。但是我们现在将命名其中的几个工具并给出它们的在线参考。
这方面的两个关键工具是绿岛( 、http://greendao-orm.com/()和奥姆利特( 、http://ormlite.com/)。每年都有更多的出现。所以经常检查看看新的是更快还是更容易。GreenDAO 使用基于模式定义的代码生成方法。据说比 OrmLite 快三到四倍。OrmLite 通过注释将模式定义与 Java 类融合在一起。后一种方法在编程上更容易。OrmLite 在任何 Java 平台上也是一样的。然而,可能由于在运行时使用反射,它可能会慢一些,但我怀疑对于大多数应用来说已经足够快了。
我们预测,使用这些 O/R 映射库之一是将您的应用更快推向市场的关键需求。我们建议您隔离持久性服务,从 OrmLite 开始,然后如果您的应用获得足够的牵引力或从您的原型转移到生产,则转移到 GreenDAO。
使用内容提供者 保存状态
Android 在基于 URIs 的数据存储之上提供了更高层次的抽象。使用这种方法,任何应用都可以像 URIs 一样使用 REST 读取或存储数据。这种抽象还允许应用通过基于 URI 字符串的 API 共享数据。在这种方法中,提交 URI 将返回数据库游标中的行和列的集合。如果被授予权限,URI 还可以接受一组键/值对,并将它们保存在目标数据库中。这是 Android 应用之间数据互操作性的通用机制。我们将在本章后面更详细地讨论这一点。如果您的应用拥有可供其他应用共享、创建或操作的宝贵数据,这是一种首选机制。例如,许多处理笔记、文档、音频或视频的应用将它们的数据实现为内容提供者。Android 的大部分核心数据相关服务也是如此。
使用网络存储保存状态
当一个应用创建或使用的数据需要由同一平台或不同平台(如协作应用)上的其他用户通过网络共享时,网络存储就发挥了作用。移动应用使用的这种后端服务设施被称为 MBaaS(移动后端即服务)。Parse.com 是 MBAAS 的一个例子,它提供后端服务,如用户管理、用户登录、安全、社交、公共网络存储、服务器端业务逻辑和通知。
Android 本身也使用一种叫做同步适配器的概念来在设备和网络服务器之间传输数据。你可以在developer . Android . com/training/sync-adapters/index . html了解更多关于同步适配器的信息。这是一个使用异步回调的框架,通过在最合适的时机调度和执行它来有效地优化任意数量数据的传输。框架苦于细节,开发人员只需提供转移代码。
以上总结了为 Android 移动应用保存状态的各种方法。我们现在将详细讨论其中的两种方法:SQLite 和内容提供者。我们将从 Android SQLite API 开始。
使用 SQLite 直接存储数据
在本节中,我们将详细探讨如何有效地使用 SQLite 来管理 Android 应用状态。你会明白 Android 对 SQLite 的支持程度。我们将向您展示重要的代码片段。我们将向您展示在 Android 上使用 SQLite 的最佳实践。我们将向您展示如何最好地加载 DDL 来创建您的数据库。我们将向您展示抽象持久性服务的更清晰的架构模式。我们将展示如何通过动态代理应用事务。这一节是对在 Android 上使用 SQLite 的一个健壮的处理。我们还有一个示例程序,您可以下载来查看完整的工作实现。让我们先快速概述一下 Android 中的 SQLite 包和类。
总结关键的 SQLite 包和类
Android 通过其 Java 包 android.database.sqlite 支持 SQLite。为了有效地使用 Android SQLite API,你需要理解的一些关键类在清单 25-4 中列出。注意,有些类在 android.database.sqlite 包之外。
清单 25-4 。Android SDK 中的关键 SQLite Java 类
android.database.sqlite.SQLiteDatabase
android.database.sqlite.SQLiteCursor
android.database.sqlite.SQLiteQueryBuilder
android.content.ContentValues
android.database.Cursor
android.database.SQLException
android.database.sqlite.SQLiteOpenHelper
让我们简单地讨论一下这些包和类。
SQLiteDatabase:SQLiteDatabase 是一个 Java 类,表示数据库通常指的是一个。文件系统上的 db 文件。使用该对象,您可以查询、插入、更新或删除数据库中的给定表。您还可以执行一条任意的 SQL 语句。您可以应用事务。您还可以使用该对象通过 DDLs(数据定义语言)来定义表。DDL 是允许您创建数据库实体(如表、视图、索引等)的语句。通常,在您的应用中有一个表示数据库的该对象的实例。
SQLiteCursor :这个 Java 类表示从 SQLiteDatabase 返回的行的集合。它还实现了 android.database.Cursor 接口。这个对象有一些方法,像向前的数据库游标一样一次导航一行,并只在需要时检索行。如果需要,这个对象还可以像随机光标一样通过实现窗口特性向前或向后跳转。这也是您将用来读取任何当前行的列值的对象。
SQLiteQueryBuilder :这是一个 helper Java 类,通过递增地指定表名、列名、where 子句等来构造 SQLite 查询字符串。,作为单独的字段。这个类有许多 set 方法来逐步构建查询,而不是将整个 SQL 查询指定为一个字符串。如果您的查询很简单,您也可以直接在 SQLiteDatabase 类上使用查询方法。
ContentValues :这个类的 Java 对象包含一组键/值对,许多 SQLite 类使用它们来插入或更新一行数据。
SQLException :大部分 Android SQLite 数据库 API 在有错误时抛出这个异常。
SQLiteOpenHelper :这个助手 Java 对象通过检查一些事情来提供对一个 SQLiteDatabase 的访问:给定一个数据库的文件名,这个对象检查该数据库是否已经安装并且可用。如果可用,它会检查版本是否相同。如果版本也相同,它提供对代表该数据库的 SQLiteDatabase 的引用。如果版本不同,它会在提供对数据库的有效引用之前提供一个回调来迁移数据库。如果数据库文件不存在,那么它提供一个回调来创建和填充数据库。您将扩展这个基类,并为这些不同的回调提供实现。您将很快在提供的代码片段中看到这一点。
这是您用来在 SQLite 数据库中保存应用状态的关键类的快速总结。现在让我们转向使用 SQLite 管理应用状态的关键概念。让我们从创建数据库开始。
创建 SQLite 数据库
Android 中数据库的创建是通过 SQLiteOpenHelper 类来控制的。对于应用中的每个数据库,您将拥有一个 Java 数据库对象,它是该类的一个实例。这个 SQLiteOpenHelper 对象有一对 get 方法来获取对读优化(配置为)或写优化(配置为) SQLiteDatabase 对象的引用。创建或获取对 SQLite 数据库对象的访问包括以下内容:
- 扩展 SQLiteOpenHelper 并将数据库名称和版本提供给这个派生类的构造函数,以便这些值可以传递给基类
- 从 SQLiteOpenHelper 中重写 onCreate() 、 onUpgrade() 和 onDowngrade() 方法。如果这个数据库不存在,您将调用 onCreate() 。如果数据库版本较新,则调用 onUpgrade() ,如果数据库版本比设备上的版本旧,则调用 onDowngrade()。您将在这些方法中使用 execute DDL 语句来创建或调整数据库。如果您的数据库不是新的或者版本相同,那么这两个回调都不会被调用。
- 对此派生对象有一个静态引用。对此对象调用 get 方法以获取对可读或可写数据库副本的引用。使用这些数据库引用来执行 CRUD 操作和事务。
清单 25-5 是一个代码片段,展示了如何实现这些步骤来创建一个名为“booksqlite.db”的数据库,这个数据库保存了一个图书及其详细信息的表格。
清单 25-5 。使用 SQLiteOpenHelper
// File reference in project: DirectAccessBookDBHelper.Java
/**
* A complete example of SQLiteOpenHelper demonstrating
* 1\. How to create a databases
* 2\. How to migrate a database
* 3\. How to hold a static reference
* 4\. How to give out read and write database references
*
* This class also can act as a DatabaseContext.IFactory to produce read and write
* database references. This aspect is not critical to understanding but included
* for advanced readers and for some material later in the chapter.
*/
public class DirectAccessBookDBHelper extends SQLiteOpenHelper
implements DatabaseContext.IFactory
{
//there is one and only one of these database helpers
//for this database for this entire application
public static DirectAccessBookDBHelper m_self =
new DirectAccessBookDBHelper(MyApplication.m_appContext);
//Name of the database on the device
private static final String DATABASE_NAME = "bookSQLite.db";
//Name of the DDL file you want to load while creating a database
private static final String CREATE_DATABASE_FILENAME = "create-book-db.sql";
//Current version number of the database for the App to work
private static final int DATABASE_VERSION = 1;
//Just a logging tag
private static final String TAG = "DirectAccessBookDBHelper";
//Pass the database name and version to the base class
//This is a non public constructor
//Clients can just use m_self and not construct this object at all directly
DirectAccessBookDBHelper(Context context) {
super(context,DATABASE_NAME,null,DATABASE_VERSION);
//Initialize anything else in your system that may need a
//reference to this object.
//Example: DatabaseContext.initialize(this);
}
@Override
public void onCreate(SQLiteDatabase db) {
try {
//No database exists. Load DDL from a file in the assets directory
loadSQLFrom(this.CREATE_DATABASE_FILENAME,db);
}
catch(Throwable t) {
//Problem creating database
throw new RuntimeException(t);
}
}
//A function to load one SQL statement at a time using execSQL method
private void loadSQLFrom(String assetFilename, SQLiteDatabase db) {
List<String> statements = getDDLStatementsFrom(assetFilename);
for(String stmt: statements){
Log.d(TAG,"Executing Statement:" + stmt);
db.execSQL(stmt);
}
}
//Optimize this function for robustness.
//For now it assumes there are no comments in the file
//the statements are separated by a semicolon
private List<String> getDDLStatementsFrom(String assetFilename) {
ArrayList<String> l = new ArrayList<String>();
String s = getStringFromAssetFile(assetFilename);
for (String stmt: s.split(";")) {
//Add the stmt if it is a valid statement
if (isValid(stmt)) {
l.add(stmt);
}
}
return l;
}
private boolean isValid(String s) {
//write logic here to see if it is null, empty etc.
return true; //for now
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
//Use old and new version numbers to run DDL statements
//to upgrade the database
}
//Using your specific application object to remember the application context
//Then using that application context to read assets
private 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);
}
}
//Optimize later. 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();
}
//Here are some examples of how to get access to readable and
//writable databases. These methods will make sense once we get through the
//the transactions applied through dynamic proxies
/*
public ReadDatabaseContext createReadableDatabase() {
return new ReadDatabaseContext(this.getReadableDatabase());
}
public WriteDatabaseContext createWritableDatabase() {
return new WriteDatabaseContext(this.getWritableDatabase());
}
*/
}//eof-class DatabaseHelper
//Here is the code for MyApplication to remember the context
public class MyApplication extends Application {
public final static String tag="MyApplication";
public static volatile Context m_appContext = null;
@Override
public void onCreate() {
super.onCreate();
MyApplication.m_appContext = this.getApplicationContext();
}
}
//assets/create-book-db.sql
CREATE TABLE t_books (id INTEGER PRIMARY KEY,
name TEXT, isbn TEXT, author TEXT,
created_on INTEGER, created_by TEXT,
last_updated_on INTEGER, last_updated_by TEXT
);
通过 DDLs 定义数据库
在清单 25-5 中,DirectAccessBookDBHelper 是 SQLiteOpenHelper 的一个派生类,它允许我们检查一个现有的数据库,看看它是否需要被创建或者仅仅是基于它的版本被迁移。
只有当设备上不存在该数据库时,才会调用方法 onCreate() 。如果没有 SQLiteOpenHelper ,我们将不得不检查这个文件的物理位置,看看它是否存在。换句话说, SQLiteOpenHelper 实际上是一个瘦包装器,它为我们节省了许多“if-else”子句来检查数据库并进行必要的初始化:无论是创建数据库还是迁移数据库。
许多在互联网上创建 Android 数据库的例子使用 Java 代码中嵌入的 DDL 字符串来创建所需的表。作为 DDL 语句,Java 代码中的字符串难以阅读并且容易出错。更好的方法是将这些数据库创建脚本放在 assets 目录中的一个文本文件中。清单 25-5 中的示例代码演示了如何从应用的资产目录中读取一个文本文件,并使用 SQLiteDatabase 上可用的 execSQL() 函数来初始化数据库。
execSQL() 的一个限制是它一次只能执行一条 SQL 语句。这就是为什么清单 25-5 中的代码读取脚本文件并使用简单的语法将其解析成一系列语句。您可能希望搜索互联网,查看更好的解析工具,以获得更好的脚本文件支持。如果适合您的情况,另一种替代方法是拥有一个 schema 类,它的唯一目的是包含 DDL 的静态公共字符串,因为它减少了解析文件的需要。在本章末尾提供的在线参考资料中,我们有这些基于 Java 的库的链接。特别是,使用 ANTLR 的基于 Java 的工具对于复杂的数据库设置有很大的前景。
onCreate() 函数也将其执行包装在一个事务中,以便执行的数据库是一致的。
如果您有许多脚本,也可以创建整个数据库,并将其保存在 assets 文件夹中。在部署过程中,如果数据库不存在,您可以将文件复制到目标位置。
迁移数据库
如上所述, SQLiteOpenHelper 识别版本号,并适当地调用 onUpgrade() 方法来升级数据库。在这里,您可能还想在 assets 文件夹中保存一系列脚本,这些脚本可以根据版本号的不同适当地改变数据库。请记住,设备上的版本号可能小于或大于您的目标版本。因此,您可能需要一组对于每个转换序列都是唯一的脚本:从 V1 到 V3,或者从 V2 到 V3,或者从 V3 到 V1。后退可能需要警告或动态下载服务器端转换到旧版本,因为旧版本应用的源代码不太可能具有从未来版本退出所需的工具。
插入行
其核心是,将一行及其列值插入到 SQLiteDatabase 中仅仅是调用 SQLiteDatabase 对象上与插入相关的方法。解释这一点的伪代码如清单 25-6 所示。
清单 25-6 。使用 SQLiteDatabase 插入行的基础知识
//Get a reference to the database object
//Depending on the framework you have there could be many ways of doing this
SQLiteDatatabase db = DirectAccessBookDBHelper.m_self.getReadableDatabase();
String tablename; //which table you want to insert the row into
//populate a structure with the needed columns and their values
ContentValues cv = new ContentValues();
cv.put(columnName1, column1Value); //etc.
//A column that could be null if 'cv' is empty if an empty row is needed
//Provide null if that behavior is not needed
String nullColumnName = null;
//Insert the row
long rowId = db.insertOrThrow(tablename, nullColumnName, cv);
这段代码非常简单。使用这段代码插入任何 Java 对象仅仅是读取其属性,并将这些值放入 ContentValues 数据集中,然后插入。就 Android 的 SQLite insert 功能而言,你只需要知道这些。
如何最好地构造 Java 对象以及如何将这些值转换成内容值取决于您的框架。这是一个冗长乏味的过程。但是,这个细节对于 insert 的基本理解来说并不重要。大多数应用都需要这种严格程度。如果你认为这很复杂,你可以跳过,但是我们把它放在这里,因为我们觉得你的大多数应用都需要这种严格程度。
因此,为插入行获取正确的列名和值需要一些工作,您通常需要以下内容(不管您使用什么框架):
- 一个 Java 对象,通常代表数据库中的行,例如一个 Book 对象
- 保存一组帐册的表名
- Books 表中可用列的字符串名称
- 最后,调用 insert 方法将 Book 对象持久化为 Books 表中的一行
我们将给出这些需求的代码片段(有些是伪代码)。对于实际的代码,你可以下载本章的项目。下面是清单 25-7 中的几个类,它们用 Java 代码表示一个图书对象。
清单 25-7 。确保域对象和持久性之间的最小依赖
// File reference in project: BaseEntity.Java
public class BaseEntity {
private int id; //database identifier
private String ownedAccount = null; //Multi-tenant if needed
private String createdBy;
private Date createdOn;
private String lastUpdatedBy;
private Date lastUpdatedOn;
public BaseEntity(String ownedAccount, String createdBy, Date createdOn,
String lastUpdatedBy, Date lastUpdatedOn, int id) {
super();
this.ownedAccount = ownedAccount;
this.createdBy = createdBy;
this.createdOn = createdOn;
this.lastUpdatedBy = lastUpdatedBy;
this.lastUpdatedOn = lastUpdatedOn;
this.id = id;
}
//For persistence
public BaseEntity(){}
//Usual generated get/set methods
//eliminated here for space. See the downloads
}
// File reference in project: Book.Java
public class Book extends BaseEntity
{
//Key data fields
//*************************************
private String name;
private String author;
private String isbn;
//*************************************
public Book(String ownedAccount, String createdBy, Date createdOn,
String lastUpdatedBy, Date lastUpdatedOn, String name,
String author, String isbn) {
super(ownedAccount, createdBy, createdOn, lastUpdatedBy, lastUpdatedOn,-1);
this.name = name;
this.author = author;
this.isbn = isbn;
}
//To help with persistence
public Book() {}
//Generated methods get and set methods...
//....
//The following method is here for testing purposes
//and also to see how a book object is typically created
public static Book createAMockBook() {
String ownedAccount = "Account1";
String createdBy = "satya";
Date createdOn = Calendar.getInstance().getTime();
String lastUpdatedBy = "satya";
Date lastUpdatedOn = Calendar.getInstance().getTime();
//See how many books I have and increment it by one
//The following method returns a collection of books in the database
//This is not essential for your understanding here
//You will see this clarified when you read the section of transactions
List<Book> books = Services.PersistenceServices.bookps.getAllBooks();
int i = books.size();
String name = String.format("Book %s",i);
String author = "satya";
String isbn = "isbn-12344-" + i;
return new Book(ownedAccount,createdBy,createdOn,
lastUpdatedBy,lastUpdatedOn,
name,author,isbn);
}
}
这个清单有两个 Java 类:一个 BaseEntity 和一个扩展了 BaseEntity 的 Book 。看起来像清单 25-7 中的书的对象被称为域对象。这些是纯 Java 对象,它们可以在程序的 Java 空间中移动,而不会受到与持久性相关的行为的影响。然而,谁创建了这些对象,它们是何时创建的,这些属性被封装在 BaseEntity 中,这样所有的域对象都有这些基本信息。
因为 SQLite 数据库方法需要这些对象的显式列名,所以该方面是在一组单独的类中定义的,这些类描述了这些对象的元数据。这些支持类在清单 25-8 中给出。
清单 25-8 。为域对象定义元数据
// File reference in project: BaseEntitySQLiteSQLiteMetaData.Java
public class BaseEntitySQLiteSQLiteMetaData {
static public final String OWNED_ACCOUNT_COLNAME = "owned_account";
static public final String CREATED_BY_COLNAME = "created_by";
static public final String CREATED_ON_COLNAME = "created_on";
static public final String LAST_UPDATED_ON = "last_updated_on";
static public final String LAST_UPDATED_BY = "last_updated_by";
static public final String ID_COLNAME = "id";
}
// File reference in project: BookSQLiteSQLiteMetaData.Java
public class BookSQLiteSQLiteMetaData extends BaseEntitySQLiteSQLiteMetaData {
static public final String TABLE_NAME = "t_books";
static public final String NAME = "name";
static public final String AUTHOR = "author";
static public final String ISBN = "isbn";
}
这两个类平行于它们各自的 BaseEntity 和 Book 对象类。您必须注意列名与数据库中的列名相匹配。所以这种需求基本上是容易出错的。除非您使用 O/R 映射库并创建自己的库,否则这个问题会一直存在,您必须做好测试。在我们前面讨论的 O/R 映射工具中,由程序员明确定义这些类是不必要的。
现在我们有了一个 Java 类来表示一本书及其元数据定义,它告诉我们表名和字段,我们可以继续编写 Java 代码来将一个 book 对象保存在数据库中,如清单 25-9 所示(注意这仍然是伪代码,使用下载查看任何遗漏的细节)。
清单 25-9 。使用 Android SQLite APIs 插入一行
// File reference in project: BookPSSQLite.Java
private long createBook(Book book) {
//Get access to a read database
SQLiteDatabase db = DirectAccessBookDBHelper.m_self.getWritableDatabase();
//Fill fields from the book object into the content values
ContentValues bcv = new ContentValues();
//.... fill other fields example
bcv.put(BookSQLiteSQLiteMetaData.NAME, book.getName());
bcv.put(BookSQLiteSQLiteMetaData.ISBN, book.getIsbn());
bcv.put(BookSQLiteSQLiteMetaData.AUTHOR, book.getAuthor());
//.... fill other fields
//if bcv is an empty set, then an empty row can possibly be inserted.
//It is not the case for our book table. If it were though, the empty bcv
//will result in an insert statement with no column names in it.
//At least one column name is needed by SQL insert syntax.
//It is one of these column names that goes below. For us this is not case so a null
String nullColumnName = null;
long rowId = db.insertOrThrow(BookSQLiteSQLiteMetaData.TABLE_NAME,
nullColumnName,
bcv);
return rowId;
}
清单 25-9 中的逻辑非常简单。获取对我们想要保存的书对象的引用。将书中的字段值复制到一个 ContentValues 键/值对对象中。使用元数据类来正确定义字段名称。使用填充的 ContentValues 对象并调用 insert 方法。如果我们什么都不做,插入会被封装在自动提交中。我们将很快讨论如何进行交易,因为它的理论有点复杂,尽管代码写起来很简单。insert 方法返回该表新插入的主键 ID。这种返回表主键的约定来自底层 SQLite 产品文档,并不特定于 Android。
nullColumnName 与 SQL insert 语句的语法有关。如果该行有十列,但只有两列,并且指示了它们的非空值,则插入带有这两列的新行,并且预计剩余的八列将允许空值。如果您想要一个每列都为 null 的行,可以发出一个完全没有列名的 insert 语句,匹配空的内容值集。但是,不允许使用没有列名的 insert 语句。因此,这个参数 nullColumnName 可以包含一个可以为 null 的列名,以便满足 insert 语句的语法要求。当插入该行时,数据库会在内部将其余的列设置为 null。通常这个列名作为 null 传入,因为我们很少想要插入一个每一列都为空或 null 的行。
更新行
清单 25-10 是一个示例伪代码片段(完整代码见下载项目),展示了如何更新数据库中的一行。注意 Book object 和 BookSQLiteMetaData 类是如何被用来最小化指定表名和列名的错误的。该方法类似于插入方法。
清单 25-10 。更新记录的 Android SQLite API
// File reference in project: BookPSSQLite.Java
public void updateBook(Book book) {
if (book.getId() < 0) {
throw new SQLException("Book id is less than 0");
}
//Get access to a read database
SQLiteDatabase db = DirectAccessBookDBHelper.m_self.getWritableDatabase();
//Fill fields from the book object into the content values
ContentValues bcv = new ContentValues();
//.... fill other fields
bcv.put(BookSQLiteSQLiteMetaData.NAME, book.getName());
bcv.put(BookSQLiteSQLiteMetaData.ISBN, book.getIsbn());
bcv.put(BookSQLiteSQLiteMetaData.AUTHOR, book.getAuthor());
//.... fill other fields
//You can do this
String whereClause = String.format("%s = %s",BookSQLiteSQLiteMetaData.ID_COLNAME,book.getId());
String whereClauseArgs = null;
//Or the next 4 lines (this is preferred)
String whereClause2 = BookSQLiteSQLiteMetaData.ID_COLNAME + " = ?";
String[] whereClause2Args = new String[1];
whereClause2Args[1] = Integer.toString(book.getId());
int count = db.update(BookSQLiteSQLiteMetaData.TABLE_NAME, bcv, whereClause2, whereClause2Args);
if (count == 0) {
throw new SQLException(
String.format("Failed to update book for book id:%s",book.getId()));
}
}
删除行
清单 25-11 是如何从数据库中删除一行的例子。
清单 25-11 。Android SQLite API 删除记录
// File reference in project: BookPSSQLite.Java
public void deleteBook(int bookid){
//Get access to a writable database
SQLiteDatabase db = DirectAccessBookDBHelper.m_self.getWritableDatabase();
String tname = BookSQLiteSQLiteMetaData.TABLE_NAME;
String whereClause =
String.format("%s = %s;",
BookSQLiteSQLiteMetaData.ID_COLNAME,
bookid);
String[] whereClauseargs = null;
int i = db.delete(tname,whereClause, whereClauseargs);
if (i != 1) {
throw new RuntimeException("The number of deleted books is not 1 but:" + i);
}
}
读取行
清单 25-12 显示了使用 SQLiteDatabase.query() 方法从 SQLite 中读取的伪代码片段(完整代码参见下载项目)。这个方法返回一个光标对象,您可以用它来检索每一行。
清单 25-12 。Android SQLite API 读取记录
// File reference in project: BookPSSQLite.Java
public List<Book> getAllBooks() {
//Get access to a read database
SQLiteDatabase db = DirectAccessBookDBHelper.m_self.getReadableDatabase();
String tname = BookSQLiteSQLiteMetaData.TABLE_NAME;
//Get column name array from the metadata class
//(See the download how the column names are gathered)
//(at the end of the day it is just a set of column names
String[] colnames = BookSQLiteSQLiteMetaData.s_self.getColumnNames();
//Selection
String selection = null; //all rows. Usually a where clause. exclude where part
String[] selectionArgs = null; //use ?s if you need it
String groupBy = null; //sql group by clause: exclude group by part
String having = null; //similar
String orderby = null;
String limitClause = null; //max number of rows
//db.query(tname, colnames)
Cursor c = null;
try {
c = db.query(tname,colnames,selection,selectionArgs,groupBy,having,orderby,limitClause);
//This may not be the optimal way to read data through a list
//Directly pass the cursor back if your intent is to read these one row at a time
List<Book> bookList = new ArrayList<Book>();
for(c.moveToFirst();!c.isAfterLast();c.moveToNext()) {
Log.d(tag,"There are books");
Book b = new Book();
//..fill base fields the same way
b.setName(c.getString(c.getColumnIndex(BookSQLiteMetaData.NAME)));
b.setAuthor(c.getString(c.getColumnIndex(BookSQLiteMetaData.AUTHOR)));
b.setIsbn(c.getString(c.getColumnIndex(BookSQLiteMetaData.ISBN)));
//..fill other fields
//Or you could delegate this work to the BookSQLiteMetaData object
//as we have done in the sample downloadable project
//Ex: BookSQLiteSQLiteMetaData.s_self.fillFields(c,b);
bookList.add(b);
}
return bookList;
}
finally {
if (c!= null) c.close();
}
}
以下是一些关于 Android 光标对象的事实:
- 游标是行的集合。
- 在读取任何数据之前,您需要使用 moveToFirst() ,因为光标从第一行之前开始定位。
- 您需要知道列名。
- 您需要知道列的类型。
- 所有的字段访问方法都是基于列号的,所以必须先将列名转换成列号。注意,这个查找可以被优化。如果您希望获取值,然后在游标上使用显式常量索引,那么按顺序填充列名数组会更有效。
- 光标是随机的(可以前后移动,可以跳跃)。
- 因为游标是随机的,所以可以向它询问行数。
应用交易
Android 上的 SQLite 库支持事务。事务方法在 SQLiteDatabase 类中可用。这些方法如清单 25-13 中的伪代码片段所示(完整代码参见下载项目)。
清单 25-13 。用于事务的 SQLite API
// File reference in project: DBServicesProxyHandler.Java
public void doSomeUpdates() {
SQLiteDatabase db; //Get a reference to this database through helper
db.beginTransaction();
try {
//...call a number of database methods
db.setTransactionSuccessful();
}
finally {
db.endTransaction();
}
}
总结 SQLite
如果你是一个有几年经验的 Java 程序员,我们到目前为止所介绍的内容足以理解 Android 中的 SQLite API。到目前为止,已经学习了这些内容,您知道了如何检查数据库、通过 DDL 创建数据库、插入行、更新行、删除行或使用数据库游标进行读取。我们还向您展示了事务的基本 API。然而,如果您不是 Java 方面的老手,数据库事务很难正确有效地实现。下一节将告诉您一个使用 Java 动态代理的基于 API 的模式。
通过动态代理进行交易
您可以将您的移动应用想象成两块砖的集合:一个 API 砖和一个 UI 砖。API 块将有一系列的无状态方法,为 UI 块提供逻辑和数据。在这种情况下,清单 25-13dosome updates()中的方法被 UI 的许多部分或其他 API 认为是可重用的 API。因为它是一个可重用的 API,所以客户端决定在该事务中是否应该提交某些东西。这意味着 API 大部分时间不应该处理事务。它非常像关系数据库中的存储过程。存储过程很少直接处理事务。存储过程的容器决定在存储过程外部提交或不提交。逻辑是这样的:如果存储过程被自己调用,那么它的输出将在存储过程级别提交。如果存储过程被另一个存储过程调用,提交将一直等到主调用存储过程完成。
最好在应用中对这些 API 使用相同的策略,以降低实现 API 的复杂性。这是通过拦截对所有 API 的调用来完成的,以确定这是一个直接调用还是由另一个已经被事务监视的 API 调用。有许多方法可以拦截需要拦截的 API 调用。这有时也被称为面向方面编程或 AOP。AOP 需要复杂的工具来完成。Java 通过动态代理提供了一种不太复杂但很简单的方法。动态代理是 Java 中的一种工具,基于 Java 反射,允许您在对象不知道的情况下拦截对底层对象的调用。当客户机通过这个代理调用对象时,客户机认为它是在直接与对象对话。然而,代理可以选择应用其他方面(如安全性、日志、事务等。)在发送对真实对象的调用之前。本章包含的项目提供了一个动态代理的完整实现,它可以自动应用事务方面。
我们将首先向您展示一旦动态代理就位,您的 API 实现是什么样子的。这将首先让您了解这种事务处理方法的简单性。然后你可以看看你是否想走这条路,并使用动态代理。当我们展示下面的代码时,请注意我们将只包括代码片段或示例,而不是整个代码。使用可下载的项目了解全部细节。我们对下载项目做了很多注释,以帮助您理解。考虑到这一点,请考虑使用 API 来处理基于 Book 的对象。
。基于 API 的接口,用于处理 Book 域对象
// File reference in project: IBookPS.Java
public interface IBookPS {
public int saveBook(Book book);
public Book getBook(int bookid);
public void updateBook(Book book);
public void deleteBook(int bookid);
public List<Book> getAllBooks();
}
该接口使用基于 Java 的对象定义操作。 IBookPS 服务末尾的字母“PS”表示这是一本书的持久性服务 API。清单 25-15 显示了 IBookPS 的 SQLite 实现
清单 25-15 。使用 SQLite 实现图书 API
// File reference in project: BookPSSQLite.Java
// The missing classes in this code are in the download and not essential for
// exploring the idea.
// ASQLitePS is a class that contains reusable common methods like getting access
// to the read and write databases using the singleton database helper.
public class BookPSSQLite extends ASQLitePS implements IBookPS {
private static String tag = "BookPSSQLite";
@Override public int saveBook(Book book) {
//get the database
//case: id does not exist in the book object
if (book.getId() == -1) {
//id of the book doesn't exist so create it
return (int)createBook(book);
}
//case: id exists in book object
updateBook(book);
return book.getId();
}
@Override public void deleteBook(int bookid){
SQLiteDatabase db = getWriteDb();
String tname = BookSQLiteSQLiteMetaData.TABLE_NAME;
String whereClause =
String.format("%s = %s;",
BookSQLiteSQLiteMetaData.ID_COLNAME,
bookid);
String[] whereClauseargs = null;
int i = db.delete(tname,whereClause, whereClauseargs);
if (i != 1) {
throw new RuntimeException("The number of deleted books is not 1 but:" + i);
}
}
private long createBook(Book book) {
//book doesn't exist
//create it
SQLiteDatabase db = getWriteDb();
ContentValues bcv = this.getBookAsContentValuesForCreate(book);
//I don't need to insert an empty row
//usually any nullable column name goes here if I want to insert an empty row.
String nullColumnNameHack = null;
//Construct values from the Book object. SQLException is a runtime exception
long rowId = db.insertOrThrow(BookSQLiteMetaData.TABLE_NAME, nullColumnNameHack, bcv);
return rowId;
}
@Override public void updateBook(Book book) {
if (book.getId() < 0) {
throw new SQLException("Book id is less than 0");
}
SQLiteDatabase db = getWriteDb();
ContentValues bcv = this.getBookAsContentValuesForUpdate(book);
String whereClause = String.format("%s = %s",BookSQLiteMetaData.ID_COLNAME,book.getId());
whereArgs[0] = BookSQLiteMetaData.ID_COLNAME;
whereArgs[1] = Integer.toString(book.getId());
int count = db.update(BookSQLiteMetaData.TABLE_NAME, bcv, whereClause, null);
if (count == 0) {
throw new SQLException(
String.format("Failed to update book for book id:%s",book.getId()));
}
}
private ContentValues getBookAsContentValuesForUpdate(Book book) {
ContentValues cv = new ContentValues();
//Following code loads column values from book object to the cv
//See the downloadable project for the mechanics of it
BookSQLiteMetaData.s_self.fillUpdatableColumnValues(cv, book);
return cv;
}
private ContentValues getBookAsContentValuesForCreate(Book book) {
ContentValues cv = new ContentValues();
BookSQLiteMetaData.s_self.fillAllColumnValues(cv, book);
return cv;
}
@Override public List<Book> getAllBooks() {
SQLiteDatabase db = getReadDb();
String tname = BookSQLiteMetaData.TABLE_NAME;
String[] colnames = BookSQLiteMetaData.s_self.getColumnNames();
//Selection
String selection = null; //all rows. Usually a where clause. exclude where part
String[] selectionArgs = null; //use ?s if you need it
String groupBy = null; //sql group by clause: exclude group by part
String having = null; //similar
String orderby = null;
String limitClause = null; //max number of rows
//db.query(tname, colnames)
Cursor c = null;
try {
c = db.query(tname,colnames,selection,selectionArgs,groupBy,having,orderby,limitClause);
//This may not be the optimal way to read data through a list
//Directly pass the cursor back if your intent is to read these one row at a time
List<Book> bookList = new ArrayList<Book>();
for(c.moveToFirst();!c.isAfterLast();c.moveToNext())
{
Log.d(tag,"There are books");
Book b = new Book();
BookSQLiteMetaData.s_self.fillFields(c,b);
bookList.add(b);
}
return bookList;
}
finally {
if (c!= null) c.close();
}
}
@Override public Book getBook(int bookid) {
SQLiteDatabase db = getReadDb();
String tname = BookSQLiteMetaData.TABLE_NAME;
String[] colnames = BookSQLiteMetaData.s_self.getColumnNames();
//Selection
String selection =
String.format("%s = %s",
BookSQLiteMetaData.ID_COLNAME,
bookid);
//all rows. Usually a where clause. exclude where part
String[] selectionArgs = null; //use ?s if you need it
String groupBy = null; //sql group by clause: exclude group by part
String having = null; //similar
String orderby = null;
String limitClause = null; //max number of rows
//db.query(tname, colnames)
Cursor c = db.query(tname,colnames,selection,
selectionArgs,groupBy,having,orderby,limitClause);
try {
if (c.isAfterLast()) {
Log.d(tag,"No rows for id" + bookid);
return null;
}
Book b = new Book();
BookSQLiteMetaData.s_self.fillFields(c, b);
return b;
}
finally {
c.close();
}
}
}//eof-class
注意 Book persistence API 的实现并不直接处理这些方法的事务方面。相反,事务是由 Java 动态代理处理的,我们将很快展示这一点。清单 25-16 展示了客户端如何看到这些 API 并间接调用这些持久性 API(同样,请参考下载项目中的类,这些类在这段代码中被引用,但没有在这里列出,因为它们对于理解并不重要)。
清单 25-16 。客户端对基于 API 的服务的访问
// File reference in project: SQLitePersistenceTester.Java
// BaseTester is just a helper class to provider common functionality
// it implements some logging and report back methods to the UI activity
public class SQLitePersistenceTester extends BaseTester {
private static String tag = "SQLitePersistenceTester";
//Services is a static class that provides access to persistence services
//Services class provides visibility to the implementer of the IBookPS
//It demonstrates how a client gets access to the namespace of services
//You will shortly see what this class is. Understand the intent first.
private IBookPS bookPersistenceService = Services.PersistenceServices.bookps;
//IReportBack is a logging interface to report loggable events back to the UI
//UI will then choose to log those events and also show on the activity screen.
SQLitePersistenceTester(Context ctx, IReportBack target) {
super(ctx, target,tag);
}
//Add a book whose id is one larger than the books
//in the database
public void addBook() {
Book book = Book.createAMockBook();
int bookid = bookPersistenceService.saveBook(book);
reportString(String.format("Inserted a book %s whose generated id now is %s"
,book.getName()
,bookid));
}
//Delete the last book
public void removeBook() {
List<Book> bookList = bookPersistenceService.getAllBooks();
if( bookList.size() <= 0)
{
reportString("There are no books that can be deleted");
return;
}
reportString(String.format("There are %s books. First one will be deleted", bookList.size()));
Book b = bookList.get(0);
bookPersistenceService.deleteBook(b.getId());
reportString(String.format("Book with id:%s successfully deleted", b.getId()));
}
//write the list of books so far to the screen
public void showBooks() {
List<Book> bookList = bookPersistenceService.getAllBooks();
reportString(String.format("Number of books:%s", bookList.size()));
for(Book b: bookList) {
reportString(String.format("id:%s name:%s author:%s isbn:%s"
,b.getId()
,b.getName()
,b.getAuthor()
,b.getIsbn()));
}
}
//Count the number of books in the database
private int getCount() {
List<Book> bookList = bookPersistenceService.getAllBooks();
return bookList.size();
}
}
在清单 25-16 中,注意通过静态类服务访问 API 是多么简单。当然,我们还没有向您展示服务的实现,以及静态类服务持有的动态代理。清单 25-17 显示了静态服务类的源代码,以便让您了解这个方案是如何工作的。本章中许多(如果不是全部)列表的目的是帮助您理解。对于完整的可编译源代码,我们恳请您参考本章的可下载项目。
清单 25-17 。通过服务名称空间向客户端公开 API
// File reference in project: Services.Java
/**
* Allow a namespace for clients to discover various services
* Usage: Services.persistenceServices.bookps.addBook(); etc.
* Dynamic proxy will take care of transactions.
* Dynamic proxy will take care of mock data.
* Dynamic Proxy will allow more than one interface
* to apply the above aspects.
*/
public class Services {
public static String tag = "Services";
public static class PersistenceServices {
////se this pointer during initialization
public static IBookPS bookps = null;
static {
Services.init();
}
}
//Although this method is empty, calling it
//will trigger all static initialization code for this class
public static void init() {}
private static Object mainProxy;
static {
//A utility class to compile all database-related initializations so far
//Gets the database helper going.
//See the download project how it uses the concepts presented so far to do this
Database.initialize();
//set up bookps
ClassLoader cl = IBookPS.class.getClassLoader();
//Add more interfaces as available
Class[] variousServiceInterfaces = new Class[] { IBookPS.class };
//Create a big object that can proxy all the related interfaces
//for which similar common aspects are applied
//In this cases it is android SQLite transactions
mainProxy = Proxy.newProxyInstance(cl,
variousServiceInterfaces, new DBServicesProxyHandler());
//Preset the namespace for easy discovery
PersistenceServices.bookps = (IBookPS)mainProxy;
}
}
注意 DBServicesProxyHandler 是如何代理实现 IBookPS 的。当被客户端调用时,DBServicesProxyHandler 然后调用 IBookPS 的实际实现。 IBookPS 的实际实现如清单 25-15 所示。让我们转向清单 25-18 中的动态代理的实现。清单 25-18 中引用的一些代码和类只在可下载的项目中可用。然而,这不应该妨碍对动态代理体系结构的一般理解。
清单 25-18 。Java 动态代理来包装 SQLite API 实现
// File reference in project: DBServicesProxyHandler.Java
/**
* DBServicesProxyHandler: A class to externalize SQLite Transactions.
* It is a dynamic proxy. See Services.Java to see how a reference to this is used.
*
* This proxy is capable of hosting multiple persistence interfaces.
* Each interface may represent persistence aspects of a particular entity or a domain object
* like a Book. Or the interface can be a composite interface dealing with multiple entities.
*
* It also uses ThreadLocals to pass the DatabaseContext
* DatabaseContext holds a reference to the database that is on this thread
* It also knows how to apply transactions to that database
* It also knows if the current thread also has a running transaction
* @See DatabaseContext
*
* DatabaseContext provides the SQLiteDatabase reference to
* the implementation classes.
*
* Related classes
* ****************
* Services.Java : Client access to interfaces
* IBookPS: Client interface to deal with persisting a Book
* BookPSSQLite: SQLite Implementation of IBookPS
*
* DBServicesProxyHandler: This class that is a dynamic proxy
* DatabaseContext: Holds a db reference for BookPSSQLite implementation
* DirectAccessBookDBHelper: Android DBHelper to construct the database
*
*/
public class DBServicesProxyHandler implements InvocationHandler {
private BookPSSQLite bookServiceImpl = new BookPSSQLite();
private static String tag = "DBServicesProxyHandler";
DBServicesProxyHandler(){}
public Object invoke(Object proxy, Method method, Object[] args)
throws Throwable {
logMethodSignature(method);
String mname = method.getName();
if (mname.startsWith("get")){
return this.invokeForReads(method, args);
}
else {
return this.invokeForWrites(method, args);
}
}
private void logMethodSignature(Method method){
String interfaceName = method.getDeclaringClass().getName();
String mname = method.getName();
Log.d(tag,String.format("%s : %s", interfaceName, mname));
}
private Object callDelegatedMethod(Method method, Object[] args)
throws Throwable{
return method.invoke(bookServiceImpl, args);
}
private Object invokeForReads(Method method, Object[] args) throws Throwable {
//See comments above about DatabaseContext
if (DatabaseContext.isItAlreadyInsideATransaction() == true){
//It is already bound
return invokeForReadsWithoutATransactionalWrap(method, args);
}
else {
//A new transaction
return invokeForReadsWithATransactionalWrap(method, args);
}
}
private Object invokeForReadsWithATransactionalWrap(Method method, Object[] args)
throws Throwable {
try {
DatabaseContext.setReadableDatabaseContext();
return callDelegatedMethod(method, args);
}
finally {
DatabaseContext.reset();
}
}
private Object invokeForReadsWithoutATransactionalWrap(Method method, Object[] args)
throws Throwable {
return callDelegatedMethod(method, args);
}
private Object invokeForWrites(Method method, Object[] args) throws Throwable {
if (DatabaseContext.isItAlreadyInsideATransaction() == true) {
//It is already bound
return invokeForWritesWithoutATransactionalWrap(method, args);
}
else {
//A new transaction
return invokeForWritesWithATransactionalWrap(method, args);
}
}
private Object invokeForWritesWithATransactionalWrap(Method method, Object[] args)
throws Throwable {
try {
DatabaseContext.setWritableDatabaseContext();
DatabaseContext.beginTransaction();
Object rtnObject = callDelegatedMethod(method, args);
DatabaseContext.setTransactionSuccessful();
return rtnObject;
}
finally {
try {
DatabaseContext.endTransaction();
}
finally {
DatabaseContext.reset();
}
}
}
private Object invokeForWritesWithoutATransactionalWrap(Method method, Object[] args)
throws Throwable {
return callDelegatedMethod(method, args);
}
}//eof-class
清单 25-18 中的代码是动态代理实现。我们没有包括所有的细节,但是这里有足够的细节来理解这个动态代理如何以自动化的面向方面的方式执行事务。它通过反射检查被调用的方法名,看方法名是否以“get”开头,如果是,那么它假设方法不需要事务上下文。否则,它将当前线程标记为事务上下文。在方法返回时,它成功完成事务。如果在它们之间调用了其他方法,动态代理从线程中知道有一个事务在适当的位置,因此从事务的角度来看忽略了那个方法。
现在,根据你的需要,你可能想根据注解或者接口的其他方面来改变这个协议,但是你明白了。这种将 API 从 UI 中分离出来的方法是很好的设计,您可以使用任意数量的持久性存储,而无需更改您的客户端 UI 代码。我们强烈建议您采用这种方法,而不管您使用的持久性机制,包括 O/R 映射工具。
浏览模拟器和可用设备上的数据库
当您直接或通过内容提供者(下一节)使用 SQLite 作为持久性机制时,您可能希望在设备上检查生成的数据库文件,以便进行调试。
SQLite API 创建的数据库文件保存在以下目录中:
/data/data//数据库
您可以使用 Eclipse Android file explorer 来定位目录并将文件复制到您的本地驱动器,并直接使用 SQLite 提供的本地 SQLite 工具来查看和操作该数据库。
您还可以使用 Android 和 SQLite 提供的工具来检查这些数据库。这些工具中有许多位于<Android-SDK-install-directory>\ tools 子目录中;其他的在 \ <安卓-SDK-安装-目录>\平台-工具。
这些目录中一些有用的命令是
android list avd: To see a list of AVDs or emulators
emulator.exe @avdname: To start an emulator with a given name
adb.exe devices: To see the devices or emulators
adb shell: To open a shell on the emulator or device
您可以从“adb shell”中使用以下命令这些可以在模拟器上运行,但是在真实设备上你需要 root 访问权限。
ls /system/bin : To see available commands
ls -l /: Root level directories
ls /data/data/com.android.providers.contacts/databases: an example
ls -R /data/data/*/databases: To see all databases on the device or emulator
如果在附带的 Android Unix shell 中有一个 find 命令,你可以查看所有的 *。db 文件。但是仅仅用 ls 是没有好办法做到这一点的。你能做的最接近的事情是:
ls -R /data/data/*/databases
使用这个命令,您会注意到 Android 发行版具有清单 25-19 中所示的数据库(根据您的版本,这个列表可能会有所不同):
清单 25-19 。几个示例数据库
alarms.db
contacts.db
downloads.db
internal.db
settings.db
mmssms.db
telephony.db
您可以在 adb shell 中调用这些数据库中的一个数据库上的 sqlite3 ,方法是键入以下命令:
sqlite3 /data/data/com.android.providers.contacts/databases/contacts.db
您可以通过键入以下命令退出 sqlite3 :
sqlite>.exit
注意 adb 的提示是 # , sqlite3 的提示是 sqlite > 。这些提示可能因设备而异。你可以通过访问www.sqlite.org/sqlite.html来了解各种 sqlite3 命令。但是,我们将在这里列出一些重要的命令,这样您就不必去访问 Web 了。您可以通过键入以下命令来查看表格列表
sqlite> .tables
该命令是查询 sqlite_master 表的快捷方式,如清单 25-20 所示(结果输出的格式和结构可能有所不同)。
清单 25-20 。使用 SQLite sqlite_master 表
SELECT name FROM sqlite_master
WHERE type IN ('table','view') AND name NOT LIKE 'sqlite_%'
UNION ALL
SELECT name FROM sqlite_temp_master
WHERE type IN ('table','view')
ORDER BY 1
表 sqlite_master 是一个主表,它跟踪 sqlite 数据库中的表和视图。下面的命令行为 contacts.db 中的一个名为 people 的表显示一个 create 语句(假设这个数据库存在于您的设备上):
.schema people
这是在 SQLite 中获取表的列名的一种方法。这也将显示列数据类型。使用内容供应器时,您应该注意这些列类型,因为访问方法依赖于它们。还要注意,这可能不是查看这些数据库的实用方法,因为您可能无法在真实设备上访问它们。在这种情况下,您必须依赖内容供应器提供的文档。
您可以在操作系统命令提示符下发出以下命令,将 contacts.db 文件下载到本地文件系统:
adb pull /data/data/com.android.providers.contacts/databases/contacts.db É
c:/somelocaldir/contacts.db
清单 25-21 中的示例 SQL 语句可以帮助您快速浏览 SQLite 数据库(或者您可以使用任何第三方 SQLite 浏览器工具):
清单 25-21 。SQLite 的示例 SQL 代码
--Set the column headers to show in the tool
sqlite>.headers on
--select all rows from a table
select * from table1;
--count the number of rows in a table
select count(*) from table1;
--select a specific set of columns
select col1, col2 from table1;
--Select distinct values in a column
select distinct col1 from table1;
--counting the distinct values
select count(col1) from (select distinct col1 from table1);
--group by
select count(*), col1 from table1 group by col1;
--regular inner join
select * from table1 t1, table2 t2
where t1.col1 = t2.col1;
--left outer join
--Give me everything in t1 even though there are no rows in t2
select * from table t1 left outer join table2 t2
on t1.col1 = t2.col1
where ....
探索内容供应器
在本章的前面,我们谈到了内容提供者在应用之间共享数据。如上所述,内容提供者是数据存储的包装者。数据存储可以是本地的或远程的。数据存储通常是本地设备上的 SQLite 数据库。
要从内容提供者检索数据或将数据保存到内容提供者,您将使用一组类似 REST 的 URIs。例如,如果您要从一个内容供应器那里检索一套图书,该内容供应器是图书数据库的封装,您可能需要使用如下的 URI:
content://com.android.book.BookProvider/books
要从图书数据库中检索特定的图书(比如说图书 23),您可以使用如下的 URI:
content://com.android.book.BookProvider/books/23
在本章中,您将看到这些 URIs 如何转化为底层的数据库访问机制。在设备上具有适当访问权限的任何应用都可以利用这些 URIs 来访问和操作数据。
探索 Android 的内置供应器
Android 带有许多内置的内容提供者,这些内容提供者记录在 SDK 的 android.provider Java 包中。您可以在此处查看这些供应器的列表:
[`developer.android.com/reference/android/provider/package-summary.html`](http://developer.android.com/reference/android/provider/package-summary.html)
供应器包括例如联系人和媒体商店。这些 SQLite 数据库通常有一个扩展名。db 和只能从实现包中访问。该包之外的任何访问都必须通过内容提供者接口。您可以使用上一节“浏览模拟器和可用设备上的数据库”来浏览模拟器上内置提供程序创建的数据库文件。在真实的设备上,这是不可行的,除非您在设备上有 root 访问权限。
了解内容供应器 URIs 的结构
设备上的每个内容提供者都在 Android manifest 文件中注册,就像一个网站,带有一个名为 authority(类似于域名)的字符串标识符。清单 25-22 有两个注册的例子:
清单 25-22 。注册供应器的示例
<!-- File reference in project: AndroidManifest.xml -->
<provider android:name="SomeProviderJavaClass"
android:authorities="com.your-company.SomeProvider" />
<provider android:name="BookProvider"
android:authorities="com.androidbook.provider.BookProvider"
/>
唯一的授权字符串形成了该内容供应器提供的一组 URIs 的基础。Android 内容 URI 具有以下结构:
content://<authority-name>/<path-segment1>/<path-segment2>/etc...
下面是一个 URI 的例子,它在图书数据库中找到一本编号为 23 的书:
content:// com.androidbook.provider.BookProvider/books/23
在内容:之后,URI 包含授权,用于在提供者注册中心定位提供者。在前面的例子中,com . Android book . provider . book provider 是 URI 的权威部分。
/books/23 是特定于每个提供者的 URI 的路径部分。书和 23 部分的路段称为路段。记录和解释 URIs 的路段和路段是供应器的责任。因此,内容供应器提供这些类似 REST 的 URL 来检索或操作数据。对于前面的注册,在图书数据库中标识目录或图书集合的 URI 是
content:// com.androidbook.provider.BookProvider/books
URI 确定的一个具体注意事项是
content:// com.androidbook.provider.BookProvider/books/#
其中 # 是特定音符的 id 。清单 25-23 显示了一些 Android 上的数据供应器接受的 URIs 的其他例子:
清单 25-23 。几个示例 Android 内容 URL
content://media/internal/images
content://media/external/images
content://contacts/people/
content://contacts/people/23
请注意,这些提供者的媒体(内容://媒体)和联系人(内容://联系人)没有完全限定的授权名称。这是因为 Android 提供的提供者可能没有完全合格的授权名称。
给定这些内容 URIs,提供者需要检索 URIs 表示的行。还期望提供者使用任何状态改变方法在这个 URI 改变内容:插入、更新或删除。
实施内容供应器
让我们通过实现和使用一个来充分理解内容提供者。要编写一个内容提供者,你得扩展 Android . content . content provider 并实现以下关键方法: query() 、 insert() 、 update() 、 delete() 、 getType() 。
您需要设置一些东西来实现这些方法。实现内容提供者需要以下步骤:
- 规划数据库、URIs、列名等,并创建一个元数据类,为所有这些元数据元素定义常量。
- 扩展抽象类 ContentProvider。
- 实现这些方法:查询、插入、更新、删除和获取类型。
- 在清单文件中注册提供程序。
- 使用内容供应器。
规划数据库
为了探究这个主题,我们将创建一个数据库,它类似于我们在图书收藏中使用的数据库,用于直接演示在 SQLite 中存储数据。请注意,为了防止数据库相互冲突,一些名称可能会不同。
图书数据库只包含一个名为图书的表,它的列是名称、 isbn 和作者。这些列名属于元数据。您将在 Java 类中定义这种相关的元数据。这个承载元数据的 Java 类 BookProviderMetaData 如清单 25-24 所示。
清单 25-24 。为数据库定义元数据
// File reference in project: BookProviderMetaData.Java
public class BookProviderMetaData {
public static final String AUTHORITY = "com.androidbook.provider.BookProvider";
public static final String DATABASE_NAME = "book.db";
public static final int DATABASE_VERSION = 1;
public static final String BOOKS_TABLE_NAME = "books";
private BookProviderMetaData() {}
//inner class describing BookTable
public static final class BookTableMetaData implements BaseColumns {
private BookTableMetaData() {}
public static final String TABLE_NAME = "books";
//uri and MIME type definitions
public static final Uri CONTENT_URI =
Uri.parse("content://" + AUTHORITY + "/books");
public static final String CONTENT_TYPE =
"vnd.android.cursor.dir/vnd.androidbook.book";
public static final String CONTENT_ITEM_TYPE =
"vnd.android.cursor.item/vnd.androidbook.book";
public static final String DEFAULT_SORT_ORDER = "modified DESC";
//Additional Columns start here.
//string type
public static final String BOOK_NAME = "name";
//string type
public static final String BOOK_ISBN = "isbn";
//string type
public static final String BOOK_AUTHOR = "author";
//Integer from System.currentTimeMillis()
public static final String CREATED_DATE = "created";
//Integer from System.currentTimeMillis()
public static final String MODIFIED_DATE = "modified";
}
}
这个 BookProviderMetaData 类首先将其权限定义为 com . androidbook . provider . book provider。
然后,这个类继续将其一个表( books )定义为内部的 BookTableMetaData 类。然后, BookTableMetaData 类定义了一个 URI 来标识图书集合。给定上一段中的权限,URI 的藏书将是这样的:
content://com.androidbook.provider.BookProvider/books
这个 URI 由常数表示
BookProviderMetaData.BookTableMetaData.CONTENT_URI
然后, BookTableMetaData 类继续为一组书籍和一本书籍定义 MIME 类型。提供者实现将使用这些常量来返回传入 URIs 的 MIME 类型。MIME 类型类似于 HTTP 定义的 MIME 类型。作为指南,通过 Android 光标返回的项目集合的主要 MIME 类型应该始终是 vnd.android.cursor.dir ,通过 Android 光标检索的单个项目的主要 MIME 类型应该是 vnd.android.cursor.item 。当涉及到子类型时,您有更多的回旋余地,如清单 25-24 中的 vnd.androidbook.book 。
BookTableMetaData 然后定义图书表的列集合:名称, isbn ,作者,创建(创建日期),以及修改(最后更新日期)。
元数据类 BookTableMetaData 也继承了提供标准 _id 字段的 BaseColumns 类,该字段表示行 id。有了这些元数据定义,我们就可以开始处理提供者实现了。
扩展 ContentProvider
实现 BookProvider 包括扩展 ContentProvider 类并覆盖 onCreate() 来创建数据库,然后实现查询、插入、更新、删除和获取类型方法。
查询方法需要它需要返回的一组列。这类似于一个 select 子句,该子句需要列名及其对应的作为(有时称为同义词)。按照惯例,Android SDK 使用一个称为投影映射的映射对象来表示这些列名及其同义词。我们将需要设置这个映射,以便稍后在查询方法实现中使用它。在提供者实现的代码中(见清单 25-26 ,你会看到这是作为投影贴图设置的一部分提前完成的。
我们将为内容供应器契约实现的大多数方法都将 URI 作为输入。清单 25-25 显示了图书供应商 URI 的例子:
清单 25-25 。图书供应器内容 URIs 示例
Uri1: content://com.androidbook.provider.BookProvider/books
Uri2: content://com.androidbook.provider.BookProvider/books/12
图书供应商需要区分这些 URIs 中的每一个。 BookProvider 是一个简单的案例。如果我们的图书供应商除了图书之外还存放了更多的对象,那么就会有更多的 URIs 来识别这些额外的对象。
提供者实现需要一种机制来区分 fromAndroid 为此使用了一个名为 UriMatcher 的类。所以我们需要用所有的 URI 变体来设置这个对象。在我们定义了投影图之后,你会在清单 25-26 中看到这段代码。我们将在“使用 UriMatcher 计算 URIs”一节中进一步解释 UriMatcher 类
清单 25-26 中的代码覆盖了 onCreate() 方法以方便数据库的创建。数据库的创建与我们在直接使用 SQLite 来满足内部持久性需求的过程中介绍的数据库创建是相同的。
清单 25-26 中的源代码实现了 insert() 、 query() 、 update() 、 getType() 和 delete() 方法。所有这些的代码都在清单 25-26 中给出,但是我们将在单独的小节中解释每个方面。
清单 25-26 。实现 BookProvider 内容提供者
// File reference in project: BookProvider.Java
public class BookProvider extends ContentProvider
{
//Logging helper tag. No significance to providers.
private static final String TAG = "BookProvider";
//Setup projection Map
//Projection maps are similar to "as" (column alias) construct
//in an sql statement where by you can rename the
//columns.
private static HashMap<String, String> sBooksProjectionMap;
static
{
sBooksProjectionMap = new HashMap<String, String>();
sBooksProjectionMap.put(BookTableMetaData._ID,
BookTableMetaData._ID);
//name, isbn, author
sBooksProjectionMap.put(BookTableMetaData.BOOK_NAME,
BookTableMetaData.BOOK_NAME);
sBooksProjectionMap.put(BookTableMetaData.BOOK_ISBN,
BookTableMetaData.BOOK_ISBN);
sBooksProjectionMap.put(BookTableMetaData.BOOK_AUTHOR,
BookTableMetaData.BOOK_AUTHOR);
//created date, modified date
sBooksProjectionMap.put(BookTableMetaData.CREATED_DATE,
BookTableMetaData.CREATED_DATE);
sBooksProjectionMap.put(BookTableMetaData.MODIFIED_DATE,
BookTableMetaData.MODIFIED_DATE);
}
//Provide a mechanism to identify all the incoming uri patterns.
private static final UriMatcher sUriMatcher;
private static final int INCOMING_BOOK_COLLECTION_URI_INDICATOR = 1;
private static final int INCOMING_SINGLE_BOOK_URI_INDICATOR = 2;
static {
sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
sUriMatcher.addURI(BookProviderMetaData.AUTHORITY, "books",
INCOMING_BOOK_COLLECTION_URI_INDICATOR);
sUriMatcher.addURI(BookProviderMetaData.AUTHORITY, "books/#",
INCOMING_SINGLE_BOOK_URI_INDICATOR);
}
// Setup/Create Database to use for the implementation
private static class DatabaseHelper extends SQLiteOpenHelper {
DatabaseHelper(Context context) {
super(context,
BookProviderMetaData.DATABASE_NAME,
null,
BookProviderMetaData.DATABASE_VERSION);
}
@Override
public void onCreate(SQLiteDatabase db) {
Log.d(TAG,"inner oncreate called");
db.execSQL("CREATE TABLE " + BookTableMetaData.TABLE_NAME + " ("
+ BookTableMetaData._ID + " INTEGER PRIMARY KEY,"
+ BookTableMetaData.BOOK_NAME + " TEXT,"
+ BookTableMetaData.BOOK_ISBN + " TEXT,"
+ BookTableMetaData.BOOK_AUTHOR + " TEXT,"
+ BookTableMetaData.CREATED_DATE + " INTEGER,"
+ BookTableMetaData.MODIFIED_DATE + " INTEGER"
+ ");");
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
Log.d(TAG,"inner onupgrade called");
Log.w(TAG, "Upgrading database from version "
+ oldVersion + " to "
+ newVersion + ", which will destroy all old data");
db.execSQL("DROP TABLE IF EXISTS " +
BookTableMetaData.TABLE_NAME);
onCreate(db);
}
}//eof-inner DatabaseHelper class
//This is initialized in the onCreate() method
private DatabaseHelper mOpenHelper;
//Component creation callback
@Override
public boolean onCreate() {
Log.d(TAG,"main onCreate called");
mOpenHelper = new DatabaseHelper(getContext());
return true;
}
@Override
public Cursor query(Uri uri, String[] projection, String selection,
String[] selectionArgs, String sortOrder) {
SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
switch (sUriMatcher.match(uri)) {
case INCOMING_BOOK_COLLECTION_URI_INDICATOR:
qb.setTables(BookTableMetaData.TABLE_NAME);
qb.setProjectionMap(sBooksProjectionMap);
break;
case INCOMING_SINGLE_BOOK_URI_INDICATOR:
qb.setTables(BookTableMetaData.TABLE_NAME);
qb.setProjectionMap(sBooksProjectionMap);
qb.appendWhere(BookTableMetaData._ID + "="
+ uri.getPathSegments().get(1));
break;
default:
throw new IllegalArgumentException("Unknown URI " + uri);
}
// If no sort order is specified use the default
String orderBy;
if (TextUtils.isEmpty(sortOrder)) {
orderBy = BookTableMetaData.DEFAULT_SORT_ORDER;
} else {
orderBy = sortOrder;
}
// Get the database and run the query
SQLiteDatabase db = mOpenHelper.getReadableDatabase();
Cursor c = qb.query(db, projection, selection,
selectionArgs, null, null, orderBy);
//example of getting a count
int i = c.getCount();
// Tell the cursor what uri to watch,
// so it knows when its source data changes
c.setNotificationUri(getContext().getContentResolver(), uri);
return c;
}
@Override
public String getType(Uri uri) {
switch (sUriMatcher.match(uri)) {
case INCOMING_BOOK_COLLECTION_URI_INDICATOR:
return BookTableMetaData.CONTENT_TYPE;
case INCOMING_SINGLE_BOOK_URI_INDICATOR:
return BookTableMetaData.CONTENT_ITEM_TYPE;
default:
throw new IllegalArgumentException("Unknown URI " + uri);
}
}
@Override
public Uri insert(Uri uri, ContentValues initialValues) {
// Validate the requested uri
if (sUriMatcher.match(uri)
!= INCOMING_BOOK_COLLECTION_URI_INDICATOR) {
throw new IllegalArgumentException("Unknown URI " + uri);
}
ContentValues values;
if (initialValues != null) {
values = new ContentValues(initialValues);
} else {
values = new ContentValues();
}
Long now = Long.valueOf(System.currentTimeMillis());
// Make sure that the fields are all set
if (values.containsKey(BookTableMetaData.CREATED_DATE) == false){
values.put(BookTableMetaData.CREATED_DATE, now);
}
if (values.containsKey(BookTableMetaData.MODIFIED_DATE) == false) {
values.put(BookTableMetaData.MODIFIED_DATE, now);
}
if (values.containsKey(BookTableMetaData.BOOK_NAME) == false) {
throw new SQLException(
"Failed to insert row because Book Name is needed " + uri);
}
if (values.containsKey(BookTableMetaData.BOOK_ISBN) == false) {
values.put(BookTableMetaData.BOOK_ISBN, "Unknown ISBN");
}
if (values.containsKey(BookTableMetaData.BOOK_AUTHOR) == false) {
values.put(BookTableMetaData.BOOK_ISBN, "Unknown Author");
}
SQLiteDatabase db = mOpenHelper.getWritableDatabase();
long rowId = db.insert(BookTableMetaData.TABLE_NAME,
BookTableMetaData.BOOK_NAME, values);
if (rowId > 0) {
Uri insertedBookUri =
ContentUris.withAppendedId(
BookTableMetaData.CONTENT_URI, rowId);
getContext()
.getContentResolver()
.notifyChange(insertedBookUri, null);
return insertedBookUri;
}
throw new SQLException("Failed to insert row into " + uri);
}
@Override
public int delete(Uri uri, String where, String[] whereArgs) {
SQLiteDatabase db = mOpenHelper.getWritableDatabase();
int count;
switch (sUriMatcher.match(uri)) {
case INCOMING_BOOK_COLLECTION_URI_INDICATOR:
count = db.delete(BookTableMetaData.TABLE_NAME,
where, whereArgs);
break;
case INCOMING_SINGLE_BOOK_URI_INDICATOR:
String rowId = uri.getPathSegments().get(1);
count = db.delete(BookTableMetaData.TABLE_NAME,
BookTableMetaData._ID + "=" + rowId
+ (!TextUtils.isEmpty(where) ? " AND (" + where + ')' : ""),
whereArgs);
break;
default:
throw new IllegalArgumentException("Unknown URI " + uri);
}
getContext().getContentResolver().notifyChange(uri, null);
return count;
}
@Override
public int update(Uri uri, ContentValues values,
String where, String[] whereArgs) {
SQLiteDatabase db = mOpenHelper.getWritableDatabase();
int count;
switch (sUriMatcher.match(uri)) {
case INCOMING_BOOK_COLLECTION_URI_INDICATOR:
count = db.update(BookTableMetaData.TABLE_NAME,
values, where, whereArgs);
break;
case INCOMING_SINGLE_BOOK_URI_INDICATOR:
String rowId = uri.getPathSegments().get(1);
count = db.update(BookTableMetaData.TABLE_NAME,
values, BookTableMetaData._ID + "=" + rowId
+ (!TextUtils.isEmpty(where) ? " AND (" + where + ')' : ""),
whereArgs);
break;
default:
throw new IllegalArgumentException("Unknown URI " + uri);
}
getContext().getContentResolver().notifyChange(uri, null);
return count;
}
}
现在,让我们一段一段地分析这段代码。
使用 UriMatcher 计算出 URIs
我们已经多次提到了 UriMatcher 类;让我们调查一下。内容提供者中的几乎所有方法相对于 URI 都是重载的。例如,无论您想要检索单本书还是一系列书,都会调用相同的 query() 方法。由方法决定是否知道正在请求哪种类型的 URI。Android 的 UriMatcher 工具类帮助你识别 URI 类型。
它是这样工作的。您告诉一个 UriMatcher 的实例,在它的初始化过程中会出现什么样的 URI 模式。您还将为每个模式关联一个唯一的编号。一旦这些模式被注册,您就可以询问 UriMatcher 传入的 URI 是否匹配某个模式。
正如我们提到的,我们的 book providercontent provider 有两种 URI 模式:一种针对一系列书籍,另一种针对单本书籍。清单 25-26 中的代码使用 UriMatcher 注册了这两种模式。它为图书集分配 1 ,为单本书分配 2(URI 模式本身在图书表的元数据中定义)。您可以在清单 25-26 中的变量 sUriMatcher 的静态初始化中看到这一点。然后,您可以看到 UriMatcher 如何在 query() 方法实现中发挥作用,使用常数区分每种类型 URI 的 URIs。
使用投影地图
内容提供者就像数据库中抽象列集和真实列集之间的中介,但是这些列集可能不同。在构造查询时,您必须在客户端指定的 where 子句列和实际的数据库列之间进行映射。您在 SQLiteQueryBuilder 类的帮助下建立了这个投影地图。您可以看到这个投影映射变量 sBooksProjectionMap 是如何为清单 25-26 中的 BookProvider 设置的。您还可以在清单中看到这个变量 sBooksProjectionMap 如何被 SQLite QueryBuilder 对象使用。
履行模拟合同
让我们从清单 25-26 中的 getType () 方法开始。此方法返回给定 URI 的 MIME 类型。像内容供应器的许多其他方法一样,这种方法对输入的 URI 很敏感。因此, getType() 方法的首要职责就是区分 URI 的类型。是藏书还是单本?代码使用了 UriMatcher 来破译这种 URI 类型。根据这个 URI, BookTableMetaData 类定义了为每个 URI 返回的 MIME 类型常量。
实现查询方法
像其他方法一样,查询方法使用 UriMatcher 来识别 URI 类型。如果 URI 类型是单项类型,该方法通过查看由 getPathSegments() 返回的第一个段,从传入的 URI 中检索图书 ID。
然后,查询方法使用我们在清单 25-26 中预先创建的投影来标识返回列。最后,查询将光标返回给调用者。在整个过程中,查询方法使用 SQLiteQueryBuilder 对象来制定和执行查询。
读取数据时,可以使用 URI 或通过作为输入传递给查询方法的显式 where 子句参数来约束返回的行。在清单 25-26 的 BookProvider 实现中,我们使用了使用 URI 段检索图书 ID 的方法来返回该书的值。
相反,您可以使用 query() 方法的 selection 参数和 selectionArgs 参数来显式传递 where 子句参数。这些参数的工作方式就像清单 25-12 中的 SQLiteDatabase.query() 参数,其中“?”用作在 selectionArgs 数组中传递的值的占位符。
实现插入方法
内容提供者中的 insert 方法负责将记录插入底层数据库,然后返回指向新创建记录的 URI。
像其他方法一样, insert 使用 UriMatcher 来识别 URI 类型。代码首先检查 URI 是否指示正确的集合类型 URI。否则,代码会抛出异常。
然后,代码验证可选和强制的列参数。如果某些列缺少默认值,该代码可以替换它们。
接下来,代码使用一个 SQLiteDatabase 对象来插入新记录,并返回新插入的 ID。最后,代码使用数据库返回的 ID 构造新的 URI。
实施更新方法
内容提供者中的 update 方法负责根据传入的列值以及传入的 where 子句更新记录。然后, update 方法返回流程中更新的行数。
像其他方法一样, update 使用 UriMatcher 来识别 URI 类型。如果 URI 类型是一个集合,那么 where 子句被传递,因此它可以影响尽可能多的记录。如果 URI 类型是单记录类型,那么从 URI 中提取图书 ID,并指定为附加的 where 子句。最后,代码返回更新的记录数。还要注意这个 notifyChange 方法如何让您向全世界宣布 URI 的数据已经更改。潜在地,你可以在 insert 方法中做同样的事情,比如说 URI 的图书数据集合".../books 在插入记录时已经改变。
实现删除方法
内容提供者中的 delete 方法负责根据传入的 where 子句删除一个(或多个)记录。然后, delete 方法返回流程中删除的行数。
像其他方法一样, delete 使用 UriMatcher 来识别 URI 类型。如果 URI 类型是集合类型,那么 where 子句将被传递,这样您就可以删除尽可能多的记录。如果 where 子句为 null ,所有记录将被删除。如果 URI 类型是单记录类型,则从 URI 中提取图书 ID,并指定为附加的 where 子句。最后,代码返回删除的记录数。
注册供应器
最后,您必须在 Android 中注册内容供应器。使用清单 25-27 中的标签结构的 Manifest.xml 文件。提供者是一个组件,因此是其他组件(如活动和接收者)的兄弟。所以它是 Android 清单文件中其他活动的兄弟节点。
清单 25-27 。注册供应器
<provider android:name=".BookProvider"
android:authorities="com.androidbook.provider.BookProvider"/>
行使图书供应商
现在我们有了一个图书提供者,我们将向您展示使用该提供者的示例代码。示例代码包括添加一本书、删除一本书、计算书的数量,最后显示所有的书。
请记住,这些是从示例项目中提取的代码,不会被编译,因为它们需要额外的依赖文件。但是,我们认为这个示例代码足以展示我们所探索的概念。
在本章的最后,我们包含了一个到可下载的示例项目的链接,您可以在您的 Eclipse 环境中使用它来编译和测试。
添加图书
清单 25-28 中的代码将一本新书插入图书数据库。
清单 25-28 。练习提供者插入
// File reference in project:ProviderTester.Java
public void addBook(Context context) {
String tag = "Exercise BookProvider";
Log.d(tag,"Adding a book");
ContentValues cv = new ContentValues();
cv.put(BookProviderMetaData.BookTableMetaData.BOOK_NAME, "book1");
cv.put(BookProviderMetaData.BookTableMetaData.BOOK_ISBN, "isbn-1");
cv.put(BookProviderMetaData.BookTableMetaData.BOOK_AUTHOR, "author-1");
ContentResolver cr = context.getContentResolver();
Uri uri = BookProviderMetaData.BookTableMetaData.CONTENT_URI;
Log.d(tag,"book insert uri:" + uri);
Uri insertedUri = cr.insert(uri, cv);
Log.d(tag,"inserted uri:" + insertedUri);
}
移除一本书
清单 25-29 中的代码从图书数据库中删除最后一条记录。
清单 25-29 。执行提供者删除
// File reference in project:ProviderTester.Java
public void removeBook() {
int firstBookId = this.getFirstBookId();
if (firstBookId == -1) throw new SQLException("Book id is less than 0");
ContentResolver cr = this.mContext.getContentResolver();
Uri uri = BookProviderMetaData.BookTableMetaData.CONTENT_URI;
Uri delUri = Uri.withAppendedPath(uri, Integer.toString(firstBookId));
reportString("Del Uri:" + delUri);
cr.delete(delUri, null, null);
this.reportString("Number of Books after the delete:" + getCount());
}
private int getFirstBookId() {
Uri uri = BookProviderMetaData.BookTableMetaData.CONTENT_URI;
Activity a = (Activity)this.mContext;
Cursor c = null;
try {
c = a.getContentResolver().query(uri,
null, //projection
null, //selection string
null, //selection args array of strings
null); //sort order
int numberOfRecords = c.getCount();
if (numberOfRecords == 0) {
return -1;
}
c.moveToFirst();
int id = c.getInt(1); //id column
return id;
}
finally {
if (c!= null) c.close();
}
}
显示图书列表
清单 25-30 中的代码检索图书数据库中的所有记录。
清单 25-30 。显示图书列表
// File reference in project:ProviderTester.Java
public void showBooks() {
Uri uri = BookProviderMetaData.BookTableMetaData.CONTENT_URI;
Activity a = (Activity)this.mContext;
Cursor c = null;
try {
c = a.getContentResolver().query(uri,
null, //projection
null, //selection string
null, //selection args array of strings
null); //sort order
int iid = c.getColumnIndex(BookProviderMetaData.BookTableMetaData._ID);
int iname = c.getColumnIndex(BookProviderMetaData.BookTableMetaData.BOOK_NAME);
int iisbn = c.getColumnIndex(BookProviderMetaData.BookTableMetaData.BOOK_ISBN);
int iauthor = c.getColumnIndex(BookProviderMetaData.BookTableMetaData.BOOK_AUTHOR);
//Report your indexes
Log.d(tag, "name,isbn,author:" + iname + iisbn + iauthor);
//walk through the rows based on indexes
for(c.moveToFirst();!c.isAfterLast();c.moveToNext()) {
//Gather values
String id = c.getString(iid);
String name = c.getString(iname);
String isbn = c.getString(iisbn);
String author = c.getString(iauthor);
//Report or log the row
StringBuffer cbuf = new StringBuffer(id);
cbuf.append(",").append(name);
cbuf.append(",").append(isbn);
cbuf.append(",").append(author);
Log.d(tag,cbuf.toString());
}
//Report how many rows have been read
int numberOfRecords = c.getCount();
Log.d(tag,"Num of Records:" + numberOfRecords);
}
finally {
if (c!= null) c.close();
}
}
请注意,从内容供应器处检索图书的方法与从 SQLite 数据库中检索数据非常相似。在清单 25-30 中,我们使用了来自 ContentResolver 对象的 query() 方法。使用光标对象后,我们关闭了光标。
相反,如果您将这个光标对象传递给一个位于活动中的 UI 组件,那么这个光标对象需要被管理,因为活动遵循它的生命周期。在 Honeycomb 之前,在活动上有一个名为 managedQuery() 的方法来自动完成这项工作,但该方法已经被弃用,取而代之的是 CursorLoader。
当通过 managedQuery() 管理一个查询时,活动可以调用游标上的方法,将其置于适当的状态。例如,活动在停止时会调用光标上的 deactivate() ,稍后在开始时会调用 requery() 。当活动被销毁时,光标将被关闭。如果您想自己控制光标的行为,可以选择对该光标调用 stopManagingCursor() 。因为活动关闭了游标,所以不要关闭托管游标。如果您打算一次读取所有行并关闭游标,则使用 ContentResolver 的 query() 方法,而不是 activity . managed query()方法并显式关闭游标。
自 Honeycomb 以来,游标读取被打包成一种更通用的方法,称为“加载器”,它允许您通过暴露给片段或活动的回调在异步线程中读取数据。这是推荐和首选的方法。我们将在下一章关于加载器的第二十六章中介绍这种方法。
您已经看到了我们如何在内容供应器上使用更新 API。如果通过内容供应器一个接一个地进行,这些更新操作可能是低效的。在第二十七章中,我们将介绍如何将这些单独的更新操作批量发送给内容供应器,以提高效率。
资源
以下是一些额外的 Android 资源,可以帮助您了解本章涵盖的主题:
developer . Android . com/guide/topics/data/data-storage . html:Android 文档中的各种数据存储选项。- :Android 上持久化的选项总结。
- 【http://www.androidbook.com/item/4876】:探索 Android 上直接 SQL 存储的工具和技术。这也包括对 O/R 映射工具的研究。
- 【http://www.androidbook.com/item/4877】:通过为 Android 解析将数据存储在云端。
- 【http://www.androidbook.com/item/4440】:使用 GSON/JSON 进行手机 app 存储。
- :使用共享偏好进行应用状态管理。
developer . android . com/guide/topics/providers/content-providers . html:关于内容供应器的 Android 文档。developer . Android . com/reference/Android/content/content provider . html:一个 ContentProvider 的 API 描述,在这里可以了解到 ContentProvider 合约。developer . Android . com/reference/Android/content/uri matcher . html:对理解 UriMatcher 有用的信息。developer . Android . com/reference/Android/database/cursor . html:帮助你直接从内容供应器或数据库读取数据的信息。developer.android.com/guide/components/loaders.html:加载器开发者指南。developer . Android . com/reference/Android/app/activity . html # startManagingCursor(Android . database . cursor:什么是托管光标的 API 文档。- :SQLite 的主页,在这里您可以了解更多关于 SQLite 的信息,并下载可以用来处理 SQLite 数据库的工具。
- :本章的可下载测试项目可以从这个 URL 获得。zip 文件的名称是 proandroid 5 _ Ch25 _ test provider . zip。
摘要
本章涵盖了应用的一个重要需求的许多方面:持久性。我们已经为您提供了 Android 中过多的持久性选项,以及如何选择合适的选项。我们已经非常详细地介绍了如何使用 SQLite 来满足内部持久性需求。我们向您展示了使用 SQLite 实现持久性的工业级 API 模式,该模式可以扩展到任何持久性实现。重要的是,该模式向您展示了如何将事务外部化,以保持您的持久性代码简单。然后,我们讨论了什么是内容提供者,内容 URIs 的本质,MIME 类型,如何使用 SQLite 来构造响应 URIs 的提供者,如何编写新的内容提供者,以及如何访问现有的内容提供者。