安卓 Processing 教程(三)
八、利用传感器数据驱动图形和声音
上一章已经介绍了读取传感器数据的基础知识,现在我们将了解如何在处理草图中使用来自加速度计、磁力计和陀螺仪的数据来生成交互式图形和声音。
使用科泰读取传感器数据
Android 设备中可用的传感器为我们提供了设备周围的大量数据。我们看到了如何通过使用 Android API 或 Ketai 库来检索这些数据,后者使传感器处理更容易。一旦数据以数值的形式出现在我们的草图中,我们就可以用任何我们想要的方式来驱动代码中的动画和交互。
在这一章中,我们将重点讨论三种特定的传感器,它们可以对我们设备的移动和位置状态提供即时反馈:加速度计(以及衍生的计步器)、磁场传感器和陀螺仪。有了这些传感器的数据,我们的机器人草图将能够对设备检测到的各种运动做出反应:突然的摇动、行走、空间旋转以及相对于地球磁场的方向。
我们将使用 Ketai 来读取传感器数据,因为它消除了定义事件监听器和传感器管理器的需要,从而简化了代码。然而,本章中的所有例子都可以很容易地使用 Android API。
测量加速度
加速度是速度相对于时间的变化率,但 Android 返回的加速度值还包括由于重力产生的加速度,该加速度指向地面,大小为 9.8 m/s 2 。如果我们的手机完全静止在桌子上,屏幕朝上,它的加速度应该是 a = (0,0,-9.8),因为它包括了 z 轴负方向的重力(记得图 7-1 )。但是,如果我们旋转手机,重力加速度将沿着三个轴投影,这取决于手机相对于垂直方向的方向。
图 8-1。
Acceleration pattern during the walking stages (left), and acceleration data corresponding to a series of steps (right). Reproduced with permission from Neil Zhao
震动检测
当我们摇动手机时,我们使它的速度在很短的时间内从零快速变化到一个高值。因此,在此期间加速度会很高。我们可以通过计算加速度向量的大小来检测这种情况,如果它足够大,就触发“震动事件”。但是,我们还需要考虑到重力已经在加速度中了,所以它的大小需要至少大于重力的大小,9.8 m/s 2 。我们可以通过比较从 Ketai 获得的加速度向量的大小和重力常数来做到这一点,如果前者比预定义的阈值大,则确定发生了震动。这就是我们在清单 8-1 中要做的事情。
import ketai.sensors.*;
import android.hardware.SensorManager;
KetaiSensor sensor;
PVector accel = new PVector();
int shakeTime;
color bColor = color(78, 93, 75);
void setup() {
fullScreen();
sensor = new KetaiSensor(this);
sensor.start();
textAlign(CENTER, CENTER);
textSize(displayDensity * 36);
}
void draw() {
background(bColor);
text("Accelerometer: \n" +
"x: " + nfp(accel.x, 1, 3) + "\n" +
"y: " + nfp(accel.y, 1, 3) + "\n" +
"z: " + nfp(accel.z, 1, 3), 0, 0, width, height);
}
void onAccelerometerEvent(float x, float y, float z) {
accel.set(x, y, z);
int now = millis();
if (now - shakeTime > 250) {
if (1.2 * SensorManager.GRAVITY_EARTH < accel.mag()) {
bColor = color(216, 100, 46);
shakeTime = now;
} else {
bColor = color(78, 93, 75);
}
}
}
Listing 8-1.Simple Shake-Detection Code
检查抖动的条件是1.2 * SensorManager.GRAVITY_EARTH < accel.mag())。这里,我们使用 1.2 作为抖动检测的阈值,我们可以使用更小或更大的值来检测更弱或更强的抖动。Android 中传感器 API 的SensorManager类变得很方便,因为它包含一个常数GRAVITY_EARTH,代表地球的重力加速度(太阳系中所有行星都有类似的常数,加上月球、太阳和虚构的死星)。时间条件已经到位,所以我们的应用不能每 250 毫秒触发一次以上的震动。
步进计数器
在检测震动的情况下,我们只需识别由加速度大小表征的单个事件。在行走或跑步时检测步数的情况下,问题更加困难:当我们迈出一步时,仅仅识别加速度的单一变化是不够的,而是我们需要记录一段时间内的规律,如图 8-1 所示。
然而,这种模式并不遵循完美的曲线,因为它受到信号噪声和行走速度不规则性的影响。再者就是因人而异,看自己的步态。虽然找出一种能够从原始加速度计数据中检测步数的算法并不太难,但 Android 通过在 4.4 版本中提供一种新型传感器(KitKat)来解决这个问题:步数计数器。该传感器为我们分析加速度计输入。它每走一步都会触发一个新的事件,所以我们可以在任何我们希望的时间间隔内计算步数。清单 8-2 中描述了加工中一个非常简单的步骤检测草图。
import ketai.sensors.*;
KetaiSensor sensor;
color bColor = color(78, 93, 75);
int stepTime = 0;
int stepCount = 0;
void setup() {
fullScreen();
orientation(PORTRAIT);
sensor = new KetaiSensor(this);
sensor.start();
textAlign(CENTER, CENTER);
textSize(displayDensity * 24);
}
void draw() {
if (millis() - stepTime > 500) {
bColor = color(78, 93, 75);
}
background(bColor);
text("Number of steps = " + stepCount, 0, 0, width, height);
}
void onStepDetectorEvent() {
bColor = color(216, 100, 46);
stepTime = millis();
stepCount++;
}
Listing 8-2.Using Android’s Step Counter
Ketai 有另一个函数onStepCounterEvent(float s),我们在变量s中接收设备重启后的总步数。如果我们需要在应用未运行时跟踪一天的总步数,而又不错过活动,这可能会很有用。
步骤数据的视听映射
正如我们刚刚看到的,使用 Ketai 中的步数检测器事件来计算单个步数非常容易。如何在我们的加工草图中使用这些步数数据是一个我们只有在考虑我们的最终目标是什么之后才能回答的问题;例如,显示身体活动的“实用”可视化,创建该活动的更抽象的表示,驱动一些我们可以用作动态壁纸的背景图形(和/或音频),等等。
由我们来决定如何将传感器数据映射到视觉或声音元素中。为了说明如何进行这种映射,我们将绘制一个草图,其中每一个新的步骤都会触发一个简单的动画,一个彩色的圆圈出现在屏幕上,然后淡出到背景中,因此最终结果将是一个响应我们行走的几何图案。
在进入 Android 模式之前,我们可以开始用 Java 模式写一些草图来完善视觉概念。一种可能的方法是使用一个矩形网格,我们在网格上随机放置彩色点。定义一个类来保存点的动画逻辑,以及在草图运行时使用数组列表来跟踪可变数量的点,这可能是有用的。所有这些想法都在清单 8-3 中实现了。
float minSize = 50;
float maxSize = 100;
ArrayList<ColorDot> dots;
void setup() {
size(800, 480);
colorMode(HSB, 360, 100, 100, 100);
noStroke();
dots = new ArrayList<ColorDot>();
}
void draw() {
background(0, 0, 0);
if (random(1) < 0.1) {
dots.add(new ColorDot());
}
for (int i = dots.size() - 1; i >= 0 ; i--) {
ColorDot d = dots.get(i);
d.update();
d.display();
if (d.colorAlpha < 1) {
dots.remove(i);
}
}
}
class ColorDot {
float posX, posY;
float rad, maxRad;
float colorHue, colorAlpha;
ColorDot() {
posX = int(random(1, width/maxSize)) * maxSize;
posY = int(random(1, height/maxSize)) * maxSize;
rad = 0.1;
maxRad = random(minSize, maxSize);
colorHue = random(0, 360);
colorAlpha = 70;
}
void update() {
if (rad < maxRad) {
rad *= 1.5;
} else {
colorAlpha -= 0.3;
}
}
void display() {
fill(colorHue, 100, 100, colorAlpha);
ellipse(posX, posY, rad, rad);
}
}
Listing 8-3.
Random Colored Dots
这里,我们使用 HSB 空间从整个光谱中随机选取一种颜色,同时保持饱和度和亮度不变。这些点通过快速增大尺寸(随着半径的rad *= 1.5更新)来制作动画,然后通过降低 alpha 随着colorAlpha -= 0.3逐渐消失,直到它们变得完全透明,这时它们被移除。在每一帧中以 0.1 的概率添加新点。调整这些值后,我们应该得到类似于图 8-2 的输出。
图 8-2。
Output of the initial sketch that generates random dots, running from Java mode
下一步是连接点动画与步骤检测。实现这一点的一个简单方法是在每次触发步进检测器事件时创建一个新的点。因此,我们需要将 Ketai 库添加到前面的代码中,然后在onStepDetectorEvent()事件中创建点,如清单 8-4 所示。
import ketai.sensors.*;
KetaiSensor sensor;
float minSize = 150 * displayDensity;
float maxSize = 300 * displayDensity;
ArrayList<ColorDot> dots;
void setup() {
fullScreen();
orientation(LANDSCAPE);
colorMode(HSB, 360, 100, 100, 100);
noStroke();
dots = new ArrayList<ColorDot>();
sensor = new KetaiSensor(this);
sensor.start();
}
void draw() {
background(0, 0, 0);
for (int i = dots.size() - 1; i >= 0 ; i--) {
ColorDot d = dots.get(i);
d.update();
d.display();
if (d.colorAlpha < 1) {
dots.remove(i);
}
}
}
class ColorDot {
float posX, posY;
float rad, maxRad;
float colorHue, colorAlpha;
ColorDot() {
posX = int(random(1, width/maxSize)) * maxSize;
posY = int(random(1, height/maxSize)) * maxSize;
rad = 0.1;
maxRad = random(minSize, maxSize);
colorHue = random(0, 360);
colorAlpha = 70;
}
void update() {
if (rad < maxRad) {
rad *= 1.5;
} else {
colorAlpha -= 0.1;
}
}
void display() {
fill(colorHue, 100, 100, colorAlpha);
ellipse(posX, posY, rad, rad);
}
}
void onStepDetectorEvent() {
dots.add(new ColorDot());
}
Listing 8-4.Using Steps to Animate the Dots
注意点的最小和最大尺寸现在是如何被displayDensity缩放的,所以我们草图的输出保持了它的比例,而不管屏幕设备的 DPI。我们可以运行这个草图作为一个普通的应用或动态壁纸,以防我们想让它一直运行,并在我们的主屏幕上驱动背景图像。
可以用不同的方法来完善这个草图。例如,我们可以通过将这些参数与时间和行走速度联系起来,来减少点的大小和颜色的随机性。为了计算后者,我们可以在某个固定的时间间隔(比如说每五秒钟)将计数值重置为零,并除以自上次重置以来经过的时间(因为速度=数值差/时间差)。清单 8-5 包括我们需要存储当前行走速度、上次更新时间和步数的额外变量,以及计算点半径和色调的差异(其余与清单 8-4 相同)。
import ketai.sensors.*;
KetaiSensor sensor;
float minSize = 150 * displayDensity;
float maxSize = 300 * displayDensity;
ArrayList<ColorDot> dots;
int stepCount = 0;
int updateTime = 0;
float walkSpeed = 0;
...
class ColorDot {
float posX, posY;
float rad, maxRad;
float colorHue, colorAlpha;
ColorDot() {
posX = int(random(1, width/maxSize)) * maxSize;
posY = int(random(1, height/maxSize)) * maxSize;
rad = 0.1;
float speedf = constrain(walkSpeed, 0, 2)/2.0;
maxRad = map(speedf, 1, 0, minSize, maxSize);
colorHue = map(second(), 0, 60, 0, 360);
colorAlpha = 70;
}
...
}
void onStepDetectorEvent() {
int now = millis();
stepCount++;
if (5000 < now - updateTime) {
walkSpeed = stepCount/5.0;
stepCount = 0;
updateTime = now;
}
dots.add(new ColorDot());
}
Listing 8-5.Using Time and Walking Speed to Control Animation
如果我们检查ColorDot类的构造函数中半径的计算,我们可以看到walkSpeed中的值并没有被直接使用,而是首先用函数constrain()限制在 0-2 步/秒的范围内,然后进行归一化,这样我们就有了一个介于 0 和 1 之间的值,我们可以一致地映射到半径范围maxSize - minSize。这意味着我们走得越快,圆点应该越小。点的色调也是映射的结果,在这种情况下,使用second()功能获得的当前秒到 0-360°色调范围。
播放音频
到目前为止,本书中的所有例子都是纯视觉的,没有音频成分。然而,点草图可以利用声音来补充步行驱动的动画。一种选择是每次检测到一个音步时播放随机的音频片段,但是也许我们可以通过播放音阶的音符来做一些更有趣的事情。
为了简单起见,让我们考虑一个五声音阶( http://www.musictheoryis.com/pentatonic-scale/ ),它有音符 A、G、E、D 和 c。如果我们总是按照它们的自然顺序演奏这些音符,我们会一遍又一遍地听到原始音阶,结果会相当重复。在另一个极端,随机选择一个音符会太混乱。所以,我们可以尝试一个中间的解决方案,它有足够的可变性,同时保持尺度的和谐;例如,播放当前音符的上一个或下一个音符,给每个选项一个预定义的概率。我们如何着手实施这个想法呢?
首先,与传感器一样,处理不包括任何用于音频播放的内置功能。然而,我们可以使用 Android API 创建一个最小的AudioPlayer类来扩展 Android 的MediaPlayer。然后,我们需要获得五个音符的音频剪辑,并将它们复制到草图的数据文件夹中。
Note
Android 支持多种音频格式,包括 MP3、WAVE、MIDI 和 Vorbis。有关媒体格式和编解码器的完整列表,请参考开发网站上的媒体格式页面: https://developer.android.com/guide/topics/media/media-formats.html 。
清单 8-6 结合了我们之前的彩色点草图和一个AudioPlayer类以及我们之前讨论过的简单逻辑来挑选要演奏的音符(只显示了与清单 8-5 不同的代码部分)。
import ketai.sensors.*;
import android.media.MediaPlayer;
import android.content.res.AssetFileDescriptor;
import android.media.AudioManager;
KetaiSensor sensor;
...
int numNotes = 5;
AudioPlayer [] notes = new AudioPlayer[numNotes];
int lastNote = int(random(1) * 4);
void setup() {
fullScreen();
orientation(LANDSCAPE);
colorMode(HSB, 360, 100, 100, 100);
noStroke();
for (int i = 0; i < numNotes; i++) notes[i] = new AudioPlayer();
notes[0].loadFile(this, "5A.wav");
notes[1].loadFile(this, "4G.wav");
notes[2].loadFile(this, "4E.wav");
notes[3].loadFile(this, "4D.wav");
notes[4].loadFile(this, "4C.wav");
dots = new ArrayList<ColorDot>();
sensor = new KetaiSensor(this);
sensor.start();
}
...
class ColorDot {
float posX, posY;
float rad, maxRad;
float colorHue, colorAlpha;
int note;
ColorDot() {
posX = int(random(1, width/maxSize)) * maxSize;
posY = int(random(1, height/maxSize)) * maxSize;
rad = 0.1;
float speedf = constrain(walkSpeed, 0, 2)/2.0;
maxRad = map(speedf, 1, 0, minSize, maxSize);
selectNote();
colorHue = map(note, 0, 4, 0, 360);
colorAlpha = 70;
}
void selectNote() {
float r = random(1);
note = lastNote;
if (r < 0.4) note--;
else if (r > 0.6) note++;
if (note < 0) note = 1;
if (4 < note) note = 3;
notes[note].play();
lastNote = note;
}
...
}
...
class AudioPlayer extends MediaPlayer {
boolean loadFile(PApplet app, String fileName) {
AssetFileDescriptor desc;
try {
desc = app.getActivity().getAssets().openFd(fileName);
} catch (IOException e) {
println("Error loading " + fileName);
println(e.getMessage());
return false;
}
if (desc == null) {
println("Cannot find " + fileName);
return false;
}
try {
setDataSource(desc.getFileDescriptor(), desc.getStartOffset(),
desc.getLength());
setAudioStreamType(AudioManager.STREAM_MUSIC);
prepare();
return true;
} catch (IOException e) {
println(e.getMessage());
return false;
}
}
void play() {
if (isPlaying()) seekTo(0);
start();
}
}
Listing 8-6.Playing a Pentatonic Scale by Walking
我们将每个音符加载到一个单独的AudioPlayer类实例中,并将五个AudioPlayer对象存储在notes数组中。我们在setup()中初始化这个数组,然后在ColorDot类的新selectNote()方法中实现选择逻辑,使用 0.4 作为选择音阶中前一个音符的概率,使用 0.6 作为选择下一个音符的概率。图 8-3 显示了该草图的输出,但是,当然,我们需要在实际设备上运行它,以便在走动时欣赏它的音频部分。
图 8-3。
Dots sketch running on the device
使用磁传感器
磁性传感器(或磁力计)是我们在 Android 设备中发现的另一种非常常见的传感器,它对几个应用都很有用。例如,清单 8-7 显示了我们如何使用它来检测金属物体的接近程度,方法是将磁场的测量值与我们当前位置的地球磁场的预期值进行比较。如果我们在手机上运行这个草图,我们将有效地把它变成一个金属探测器!
import ketai.sensors.*;
import android.hardware.GeomagneticField;
KetaiSensor sensor;
float expMag, obsMag;
void setup() {
fullScreen();
sensor = new KetaiSensor(this);
sensor.start();
GeomagneticField geoField = new GeomagneticField(14.0093, 120.996147, 300,
System.currentTimeMillis());
expMag = geoField.getFieldStrength()/1000;
}
void draw() {
println(obsMag, expMag);
if (obsMag < 0.7 * expMag || 1.3 * expMag < obsMag) {
background(255);
} else {
background(0);
}
}
void onMagneticFieldEvent(float x, float y, float z) {
obsMag = sqrt(sq(x) + sq(y) + sq(z));
}
Listing 8-7.Detecting the Strength of the Magnetic Field
请注意,我们必须向GeomagneticField()构造函数提供我们当前位置的地理坐标(以度表示的纬度和经度,以米表示的高度),以及所谓的纪元时间(自 1970 年 1 月 1 日以来以毫秒表示的当前时间),以便获得仅由地球磁场引起的场。然后,我们可以与设备测量的实际磁场进行比较。
创建指南针应用
除了用于实现方便的金属探测器之外,将磁场数据与加速度相结合可以用于确定设备相对于地球磁北极的方向。换句话说,一个指南针。
重力和地磁矢量编码了确定设备相对于地球表面的方向所需的所有信息。使用 Ketai,我们可以获得加速度和磁场向量的分量,并利用这些分量获得旋转矩阵,该矩阵将坐标从设备系统(图 7-1 )转换到世界坐标系,我们可以想象该坐标系与我们在地球表面的位置相关,如图 8-4 所示。
图 8-4。
World coordinate system, with x pointing east, y pointing north, and z away from Earth’s center
最后一步是从旋转矩阵中导出相对于这些 xyz 轴的方向角:方位角(围绕-z 的角度)、俯仰角(围绕 x 的角度)和滚动角(围绕 y 的角度)。为了实现指南针,我们只需要方位角,因为它给出了我们所在位置相对于指向北方的 y 轴的偏差。Android API 的SensorManager类包含了几个方便的方法来执行所有这些计算,我们在清单 8-8 中执行了这些计算。
import ketai.sensors.*;
import android.hardware.SensorManager;
KetaiSensor sensor;
float[] gravity = new float[3];
float[] geomagnetic = new float[3];
float[] I = new float[16];
float[] R = new float[16];
float orientation[] = new float[3];
float easing = 0.05;
float azimuth;
void setup() {
fullScreen(P2D);
orientation(PORTRAIT);
sensor = new KetaiSensor(this);
sensor.start();
}
void draw() {
background(255);
float cx = width * 0.5;
float cy = height * 0.4;
float radius = 0.8 * cx;
translate(cx, cy);
noFill();
stroke(0);
strokeWeight(2);
ellipse(0, 0, radius*2, radius*2);
line(0, -cy, 0, -radius);
fill(192, 0, 0);
noStroke();
rotate(-azimuth);
beginShape();
vertex(-30, 40);
vertex(0, 0);
vertex(30, 40);
vertex(0, -radius);
endShape();
}
void onAccelerometerEvent(float x, float y, float z) {
gravity[0] = x; gravity[1] = y; gravity[2] = z;
calculateOrientation();
}
void onMagneticFieldEvent(float x, float y, float z) {
geomagnetic[0] = x; geomagnetic[1] = y; geomagnetic[2] = z;
calculateOrientation();
}
void calculateOrientation() {
if (SensorManager.getRotationMatrix(R, I, gravity, geomagnetic)) {
SensorManager.getOrientation(R, orientation);
azimuth += easing * (orientation[0] - azimuth);
}
}
Listing 8-8.A Compass Sketch
通过向SensorManager中的getRotationMatrix()和getOrientation()方法提供加速度和磁场矢量,我们将获得一个包含方位角、俯仰角和滚动角的方向矢量。在这个例子中,我们只使用方位角来绘制指南针,我们可以将它作为动态壁纸安装,这样它在后台总是可用的(如图 8-5 所示)。
图 8-5。
Compass sketch running as a live wallpaper
加速度计和磁力计的值都有噪声,所以我们用线azimuth += easing * (orientation[0] - azimuth)对值进行了一些“缓和”。使用这个公式,我们用新值的一部分来更新当前的方位角值,因此变化更柔和,噪声被平滑掉。缓动常数越接近 0,平滑度越强,指南针指针的移动越受抑制。另一方面,缓动值 1 将导致完全没有平滑,因为它等同于指定新的传感器值azimuth = orientation[0]。
或者,我们可以直接从科泰获得方向向量,而不必依赖来自 Android 的SensorManager类。为此,我们首先必须在setup()中显式启用加速度计和磁场传感器(因为我们不会使用 Ketai 的事件函数),然后我们可以从draw()中的KetaiSensor对象调用getOrientation(),如清单 8-9 所示。草图的这个修改版本的输出应该和以前的一样。
import ketai.sensors.*;
float orientation[] = new float[3];
float easing = 0.05;
float azimuth;
KetaiSensor sensor;
void setup() {
fullScreen(P2D);
orientation(PORTRAIT);
sensor = new KetaiSensor(this);
sensor.enableAccelerometer();
sensor.enableMagenticField();
sensor.start();
}
void draw() {
...
ellipse(0, 0, radius*2, radius*2);
line(0, -cy, 0, -radius);
sensor.getOrientation(orientation);
azimuth += easing * (orientation[0] - azimuth);
fill(192, 0, 0);
noStroke();
...
}
Listing 8-9.Using Ketai’s getOrientation() Function
陀螺仪
陀螺仪可以补充加速度计和磁力计,但也可以应用于这些传感器无法处理的情况。加速度计和磁力计为我们提供设备在空间中的运动和方向的数据;然而,它们有局限性。一方面,加速度计不能检测恒速运动,因为在这种情况下加速度为零,而另一方面,磁传感器仅给出与位置(即,相对于地球磁场的方向)相关的非常粗略的变量。此外,两个传感器都返回带有大量噪声的值。
相比之下,陀螺仪可以精确读取设备在空间旋转的角速度。有了这个速度,就有可能推断出设备相对于任意初始状态的方位。也就是说,它不能给我们一个相对于一个系统的方向的绝对描述,比如我们之前讨论过的世界坐标。然而,该信息可以在加速度计和磁力计的帮助下推断出来。
让我们通过几个简单的例子来了解陀螺仪的工作原理。因为它提供了我们在加工草图中用来控制 3D 运动的值,所以写一个非常简单的 3D 草图是有意义的。使用 Ketai,很容易从陀螺仪获得旋转角度,就像我们之前对其他传感器所做的那样。我们将使用清单 8-10 中的 P3D 渲染器来绘制一个简单的 3D 场景,其中一个立方体根据陀螺仪测量的角速度绕其中心旋转。
import ketai.sensors.*;
KetaiSensor sensor;
float rotationX, rotationY, rotationZ;
void setup() {
fullScreen(P3D);
orientation(LANDSCAPE);
sensor = new KetaiSensor(this);
sensor.start();
rectMode(CENTER);
fill(180);
}
void draw() {
background(255);
translate(width/2, height/2);
rotateZ(rotationZ);
rotateY(rotationX);
rotateX(rotationY);
box(height * 0.3);
}
void onGyroscopeEvent(float x, float y, float z) {
rotationX += 0.1 * x;
rotationY += 0.1 * y;
rotationZ += 0.1 * z;
}
Listing 8-10.Rotating a Box with the Gyroscope
由于 x、y 和 z 值是(角速度),我们不能直接使用它们作为场景中对象的旋转角度,而是应该将它们添加到旋转变量中(由一个常数缩放,在本例中为 0.1,但可以调整以使移动更慢或更快)。此外,我们用rotateY()函数应用rotationX角度(意味着我们绕 y 轴旋转立方体),用rotateX()应用rotationY。此开关的原因是设备的方向被锁定在LANDSCAPE,这意味着加工屏幕中的 x 轴对应于设备的水平方向,该方向沿着设备坐标系的 y 轴,我们在图 7-1 中看到。
图 8-6。
Sketch using the gyroscope to control rotation of a cube
使用陀螺仪的另一个重要方面是,涉及设备的任何旋转都将被传感器测量;例如,它将检测手持电话的人在行走时何时转身(即使电话相对于用户的相对方向没有改变)。在这种情况下,我们可以通过减去一个偏移值来保持初始方向,任何时候我们都可以在手机上“重新定位”场景。例如,我们可以存储当前的旋转角度作为我们触摸屏幕时的偏移,如清单 8-11 中所做的那样(仅显示与清单 8-10 不同的部分)。
...
void draw() {
background(255);
translate(width/2, height/2);
rotateZ(rotationZ - offsetZ);
rotateY(rotationX - offsetX);
rotateX(rotationY - offsetY);
box(height * 0.3);
}
...
void mousePressed() {
offsetX = rotationX;
offsetY = rotationY;
offsetZ = rotationZ;
}
Listing 8-11.
Recentering
the Gyroscope Data
使用陀螺仪时,我们不仅限于处理 3D 几何图形。如果我们在 2D 绘图,我们需要做的就是跟踪 z 旋转,如清单 8-12 所示。
import ketai.sensors.*;
KetaiSensor sensor;
float rotationZ, offsetZ;
void setup() {
fullScreen(P2D);
orientation(LANDSCAPE);
sensor = new KetaiSensor(this);
sensor.start();
rectMode(CENTER);
fill(180);
}
void draw() {
background(255);
translate(width/2, height/2);
rotate(rotationZ - offsetZ);
rect(0, 0, height * 0.3, height * 0.3);
}
void onGyroscopeEvent(float x, float y, float z) {
rotationZ += 0.1 * z;
}
void mousePressed() {
offsetZ = rotationZ;
}
Listing 8-12.Gyroscope Rotation in 2D
陀螺仪可用于在游戏应用中实现输入。我们将在本章的最后一节看到如何做到这一点。
用陀螺仪控制导航
在前面的例子中,我们使用旋转角度来控制 2D 和 3D 形状,它们保持固定在屏幕的中心。如果我们只需要控制形状的旋转,这就足够了,但是如果我们还想确定它们的平移,我们需要想出其他方法。
事实上,一种方法是不平移我们想要用陀螺仪控制的形状,而是以相反的方向平移场景的其余部分。图 8-7 中的图表有助于形象化这个想法。在这里,我们将写一个草图来导航一艘“宇宙飞船”(只是一个三角形),通过一个无尽的小行星(椭圆)领域。关键是正确编码所有椭圆的平移,以传达飞船相对于小行星的相对运动。
图 8-7。
Diagram on relative translations of moving objects
实现这种效果的数学并不困难:如果我们的形状最初朝着屏幕的顶部边缘移动,描述这种移动的前向向量将是v = new PVector(0, -1),因为处理中的 y 轴指向下。我们可以计算一个矩阵来表示应该应用于这个向量的旋转。产生的向量可用于平移场景中的所有其他形状,以创建相对运动。
处理 API 包括一个封装这些计算的PMatrix2D类。例如,如果我们的旋转角度是QUARTER_PI,我们可以通过做mat.rotate(QUARTER_PI)来生成一个对应于这个旋转的旋转矩阵,其中mat是一个PMatrix3D类型的对象。一旦我们完成了这些,我们就可以将矩阵应用到代表翻译的PVector对象上;例如mat.mult(v, rv),其中v是原始的PVector,rv是旋转后的结果PVector。让我们看看清单 8-13 中的这个 API。
import ketai.sensors.*;
KetaiSensor sensor;
float rotationZ, offsetZ;
PMatrix2D rotMatrix = new PMatrix2D();
PVector forward = new PVector(0, -1);
PVector forwardRot = new PVector();
ArrayList<Asteroid> field;
float speed = 2;
void setup() {
fullScreen(P2D);
orientation(LANDSCAPE);
sensor = new KetaiSensor(this);
sensor.start();
ellipseMode(CENTER);
noStroke();
field = new ArrayList<Asteroid>();
for (int i = 0; i < 100; i++) {
field.add(new Asteroid());
}
}
void draw() {
background(0);
boolean hit = false;
float angle = rotationZ - offsetZ;
rotMatrix.reset();
rotMatrix.rotate(angle);
rotMatrix.mult(forward, forwardRot);
forwardRot.mult(speed);
for (Asteroid a: field) {
a.update(forwardRot);
a.display();
if (a.hit(width/2, height/2)) hit = true;
}
pushMatrix();
translate(width/2, height/2);
rotate(angle);
if (hit) {
fill(252, 103, 43);
} else {
fill(67, 125, 222);
}
float h = height * 0.2;
triangle(0, -h/2, h/3, +h/2, -h/3, +h/2);
popMatrix();
}
void onGyroscopeEvent(float x, float y, float z) {
rotationZ += 0.1 * z;
}
void mousePressed() {
offsetZ = rotationZ;
}
class Asteroid {
float x, y, r;
color c;
Asteroid() {
c = color(random(255), random(255), random(255));
r = height * random(0.05, 0.1);
x = random(-2 * width, +2 * width);
y = random(-2 * height, +2 * height);
}
void update(PVector v) {
x -= v.x;
y -= v.y;
if (x < -2 * width || 2 * width < x ||
y < -2 * height || 2 * height < y) {
x = random(-2 * width, +2 * width);
y = random(-2 * height, +2 * height);
}
}
void display() {
fill(c);
ellipse(x, y, r, r);
}
boolean hit(float sx, float sy) {
return dist(x, y, sx, sy) < r;
}
}
Listing 8-13.Controlling a Spaceship with the Gyroscope
正如我们在这段代码中看到的,宇宙飞船总是被绘制在屏幕的中心,并且它是由旋转的正向向量平移的小行星,正如我们前面讨论的那样。Asteroid类包含所有处理将每个小行星放置在随机位置的逻辑,使用旋转的正向向量更新其位置,在当前位置显示它,并通过检查它是否足够靠近屏幕的中心来确定它是否正在撞击飞船。
每颗小行星都被放置在一个尺寸为[-2 * width, +2 * width] × [-2 * height, +2 * height]的矩形区域中,一旦它移出这个区域(由update()函数中的边界检查决定),它就会被再次放回内部。此外,请注意 x 和 y、–v.x和–v.y平移中的负号,这确保了正确的相对运动。我们可以把正向矢量想象成我们飞船的速度,事实上通过速度因子(在这个草图中设置为 2)缩放它,我们可以让飞船移动得更快或更慢。
最后,我们实现了一个简单的碰撞检测元素,这样当一颗小行星接近它在屏幕中心的位置时,飞船就会改变颜色。我们可以设想多种方法,通过添加交互来控制速度、更好的图像图形和 SVG 形状等等,将这个早期原型变成一个更有吸引力的游戏。在其初始形式中,输出应该类似于图 8-8 。
图 8-8。
Controlling the navigation through a field of obstacles with the gyroscope
摘要
基于在处理过程中读取传感器数据的基本技术,我们现在已经了解了三种常见硬件传感器的一些高级应用:加速度计、磁力计和陀螺仪。这些传感器特别重要,因为它们可以提供关于设备移动和位置的即时反馈,从而使我们能够基于物理手势和动作创建交互式应用。我们会发现这些交互在很多项目中非常有用,从可视化身体活动到用图形和声音编码我们自己的游戏体验。
九、地理定位
我们的 Android 设备中的地理位置传感器让我们能够高精度地知道我们在哪里,我们可以在位置感知应用中使用这些信息。在这一章中,我们将看到如何在处理中创建这种类型的应用,我们将开发一个结合位置和谷歌街景图像的最终项目。
Android 中的位置数据
我们每天都在智能手机中使用位置感知应用来寻找我们周围的名胜,提前计划旅行方向,或玩基于位置的游戏,如 Turf 和 Pokémon GO。所有这些用途都是通过相同的基础地理定位技术实现的,主要是全球定位系统(GPS),但也包括蜂窝塔三角测量、蓝牙邻近检测和 Wi-Fi 接入点。GPS 是大多数人立即与地理定位联系在一起的技术:它基于由美国拥有并由美国空军运营的卫星网络,这些卫星将地理定位信息发送到地球表面的 GPS 接收器,包括移动电话中的接收器。
Note
其他国家也开发了类似的系统,如 GLONASS(俄国)、北斗(中国)、NAVIC(印度)和伽利略(欧洲)。默认情况下,Android 系统只使用 GPS 卫星,但一些制造商引入了一些变化,以便从这些其他系统中获取地理位置数据,这可以提供更好的覆盖范围和准确性。Play Store 中提供的 GPS 测试应用可以显示手机正在使用的系统。
使用 GPS 或类似的导航卫星系统来获取位置数据的一个缺点是,它需要大量电池来为 GPS 天线供电。还有,手机需要对天空有一个通畅的视线。为了解决这些问题,我们可以利用其他定位源,如 Cell-ID、蓝牙和 Wi-Fi,它们精度较低,但能耗较低。作为参考,GPS 定位的精度在 16 英尺(4.9 米)左右,而 Wi-Fi 的精度在 130 英尺(40 米)以内。Cell-ID 的可变性更高,具体取决于小区大小,范围从几英尺到几英里。
然而,我们不需要担心何时以及如何选择特定的位置系统,因为 Android 会在手机设置中给定一个通用配置的最佳位置供应器之间自动切换,如图 9-1 所示。在 Android 7 及更高版本中,我们所要做的就是设置我们是想要结合所有可能来源的高精度定位,还是不需要 GPS 的电池节省,还是只需要 GPS。
图 9-1。
Android settings to choose the location mode
在处理中使用位置 API
Android 提供了全面的 API 来访问系统中可用的位置服务( https://developer.android.com/guide/topics/location/index.html )。我们还可以使用 Ketai 库来获取位置值,而不必担心这个 API,就像我们对运动传感器所做的那样。然而,在本章中,我们将直接从我们的处理草图中使用位置 API,因为在使用位置服务时有许多重要的方面需要考虑,特别是权限处理和并发性,即使我们稍后使用 Ketai,熟悉它们也是一个好主意。
Note
Google Play 服务位置 API(https://developer.android.com/training/location/index.html)是我们将在本章学习的标准 Android 位置 API 的更新和功能更丰富的替代品。然而,当从 PDE 编码时,处理仅支持后者。我们可以将我们的草图导出为 Android 项目,然后从 Android Studio 导入以使用 Google Play 服务(详见附录 A)。
位置权限
在我们的 Android 应用中使用特定功能,例如访问互联网,需要使用 Android 权限选择器向我们的草图添加适当的权限。然而,我们在第六章中看到,这对于危险权限来说是不够的,危险权限需要在运行 Android 6 或更新版本的设备上运行时进行额外的显式请求。访问位置数据的权限属于这一类,它们是ACCESS_COARSE_LOCATION和ACCESS_FINE_LOCATION。第一个允许访问从手机信号塔和 Wi-Fi 获得的大致位置,而第二个允许从 GPS 获得位置。为了在我们的草图中使用这些权限,我们需要在权限选择器中检查它们(图 9-2 ),然后在我们的草图代码中使用requestPermission()函数。
图 9-2。
Selecting coarse and fine location permissions
清单 9-1 展示了启用位置的草图的基本设置,其中我们定义了一个位置管理器和相关的监听器,其方式与我们之前对其他传感器所做的类似,包括设置草图所需的权限。
import android.content.Context;
import android.location.Location;
import android.location.LocationListener;
import android.location.LocationManager;
import android.os.Bundle;
LocationManager manager;
SimpleListener listener;
String provider;
double currentLatitude;
double currentLongitude;
double currentAltitude;
void setup () {
fullScreen();
textFont(createFont("SansSerif", displayDensity * 24));
textAlign(CENTER, CENTER);
requestPermission("android.permission.ACCESS_FINE_LOCATION", "initLocation");
}
void draw() {
background(0);
if (hasPermission("android.permission.ACCESS_FINE_LOCATION")) {
text("Latitude: " + currentLatitude + "\n" +
"Longitude: " + currentLongitude + "\n" +
"Altitude: " + currentAltitude, width, height);
} else {
text("No permissions to access location", 0, 0, width, height);
}
}
void initLocation(boolean granted) {
if (granted) {
Context context = getContext();
listener = new SimpleListener();
manager = (LocationManager)
context.getSystemService(Context.LOCATION_SERVICE);
provider = LocationManager.NETWORK_PROVIDER;
if (manager.isProviderEnabled(LocationManager.GPS_PROVIDER)) {
provider = LocationManager.GPS_PROVIDER;
}
manager.requestLocationUpdates(provider, 1000, 1, listener);
}
}
public void resume() {
if (manager != null) {
manager.requestLocationUpdates(provider, 1000, 1, listener);
}
}
public void pause() {
if (manager != null) {
manager.removeUpdates(listener);
}
}
class SimpleListener implements LocationListener {
public void onLocationChanged(Location loc) {
currentLatitude = loc.getLatitude();
currentLongitude = loc.getLongitude();
currentAltitude = loc.getAltitude();
}
public void onProviderDisabled(String provider) { }
public void onProviderEnabled(String provider) { }
public void onStatusChanged(String provider, int status, Bundle extras) { }
}
Listing 9-1.Getting Location Data
onLocationChanged()中接收到的位置对象包含几条信息,其中最重要的是经纬度值,表示手机在地球表面的位置,沿着与赤道平行的线和连接地理极点的子午线。
Note
Android 以双精度数字的形式提供纬度和经度,有效数字反映了位置读数的精度:米精度需要五个有效数字,而亚米细节需要六个或更多。
还有一些重要的事情需要注意。首先,我们请求了精确定位权限,这将为我们提供可用的最高分辨率以及对不太精确的源的访问,因此不需要为粗略定位请求单独的权限。其次,我们通过指明我们的首选供应器(网络或 GPS)用requestLocationUpdates()配置了位置管理器。Android 将通过考虑所请求的权限、设备的位置模式以及每个时刻位置数据的可用来源的组合来确定实际的供应器。在代码中,我们将网络供应器设置为默认供应器,它使用来自手机信号发射塔和 Wi-Fi 接入点的数据来确定位置,如果启用了相应的供应器,则切换到更精确的 GPS。我们还设置了位置更新之间的最小时间间隔(单位为毫秒)(这里 1000 表示更新不能超过每秒一次)和触发位置更新的最小距离(单位为米)。
最后,我们像对其他传感器一样实现了恢复和暂停事件,因此应用暂停时不会生成位置更新,应用在恢复后会再次请求它们。当应用在后台运行时,这对于节省电池非常重要。
事件线程和并发
在我们前面看到的所有传感器示例中,我们在相应的侦听器中读取传感器数据没有太大困难。只要我们只是将最后收到的数据存储在 float 变量中,就应该没问题。然而,一旦我们将传感器信息保存在数据结构(如数组或数组列表)中以跟踪以前的值,问题就开始了。问题源于这样一个事实:处理中的draw()函数是从动画线程中调用的(看看第六章中的“使用线程”一节),而事件处理方法,像位置情况下的onLocationChanged(),是从另一个线程,即应用的主线程中调用的。由于这些线程是并行运行的,当它们试图同时访问相同的数据时,可能会发生冲突;也就是同时。这可能会导致我们的应用出现意外行为,甚至崩溃。
正如我们在第六章中讨论的,解决并发问题需要一些额外的工作。一种解决方案是将每次调用onLocationChanged()获得的位置数据存储在一个“队列”中,然后在绘制过程中从队列中检索事件。队列是“同步的”,因此当在一个线程中添加新数据或删除现有数据时,任何其他线程都必须等待,直到操作结束。这种特殊的技术不能解决所有的并发问题,但是对于我们的情况来说已经足够了。清单 9-2 展示了我们如何实现纬度/经度位置的队列。
import android.content.Context;
import android.location.Location;
import android.location.LocationListener;
import android.location.LocationManager;
import android.os.Bundle;
LocationManager manager;
SimpleListener listener;
String provider;
LocationQueue queue = new LocationQueue();
ArrayList<LocationValue> path = new ArrayList<LocationValue>();
void setup () {
fullScreen();
textFont(createFont("SansSerif", displayDensity * 24));
textAlign(CENTER, CENTER);
requestPermission("android.permission.ACCESS_FINE_LOCATION", "initLocation");
}
void draw() {
background(0);
while (queue.available()) {
LocationValue loc = queue.remove();
path.add(0, loc);
}
String info = "";
for (LocationValue loc: path) {
info += loc.latitude + ", " + loc.longitude + "\n";
}
text(info, 0, 0, width, height);
}
void initLocation(boolean granted) {
if (granted) {
Context context = getContext();
listener = new SimpleListener();
manager = (LocationManager)
context.getSystemService(Context.LOCATION_SERVICE);
provider = LocationManager.NETWORK_PROVIDER;
if (manager.isProviderEnabled(LocationManager.GPS_PROVIDER)) {
provider = LocationManager.GPS_PROVIDER;
}
manager.requestLocationUpdates(provider, 1000, 1, listener);
}
}
class SimpleListener implements LocationListener {
public void onLocationChanged(Location loc) {
queue.add(new LocationValue(loc.getLatitude(), loc.getLongitude()));
}
public void onProviderDisabled(String provider) { }
public void onProviderEnabled(String provider) { }
public void onStatusChanged(String provider, int status, Bundle extras) { }
}
public void resume() {
if (manager != null) {
manager.requestLocationUpdates(provider, 1000, 1, listener);
}
}
public void pause() {
if (manager != null) {
manager.removeUpdates(listener);
}
}
class LocationValue {
double latitude;
double longitude;
LocationValue(double lat, double lon) {
latitude = lat;
longitude = lon;
}
}
class LocationQueue {
LocationValue[] values = new LocationValue[10];
int offset, count;
synchronized void add(LocationValue val) {
if (count == values.length) {
values = (LocationValue[]) expand(values);
}
values[count++] = val;
}
synchronized LocationValue remove() {
if (offset == count) {
return null;
}
LocationValue outgoing = values[offset++];
if (offset == count) {
offset = 0;
count = 0;
}
return outgoing;
}
synchronized boolean available() {
return 0 < count;
}
}
Listing 9-2.Storing Locations in a Queue
权限和监听器设置代码与清单 9-1 中的相同,但是现在我们有了两个新的类,LocationValue和LocationQueue. LocationValue非常简单,它只是以双精度存储了一对纬度/经度值。让我们更仔细地看看LocationQueue。它有三个方法:add()、remove()和available(),所有这些方法都是同步的,因此不能从不同的线程中同时调用。当在onLocationChanged()中接收到一个新的位置时,我们创建一个新的LocationValue并将其添加到队列中。随着新的位置不断从事件线程进入,它们被存储在队列内部的values数组中,如果需要的话,这个数组的大小可以增加一倍。在动画线程中,我们从队列中移除位置,并将它们添加到path数组列表中,这样我们就可以在每一帧中打印到目前为止收到的所有纬度/经度值,从最后到第一的顺序相反。请注意,我们从队列中删除位置并不使用剩余多少,因为如果在我们仍在绘制新的帧时新的位置到达,这个数字可能会改变,而只是通过检查队列是否有可用的元素。
Note
在应用开始接收位置值之前,我们可能会经历一段延迟。这种延迟是由设备搜索来自 GPS 卫星、本地信号发射塔或 Wi-Fi 接入点的信号造成的。
现在,我们可以将路径绘制为连接连续位置的线条。清单 9-3 展示了新的draw()函数,草图的其余部分是相同的。因为我们将纬度和经度值映射到屏幕上的位置,所以我们必须确定最小值和最大值来定义映射。此外,我们将双精度值转换成单精度浮点数,可以在min()、max()和map()函数中用作参数。图 9-3 显示了一个人走动时该草图的典型输出。
图 9-3。
Path-tracking sketch running
float minLat = 90;
float maxLat = -90;
float minLon = 180;
float maxLon = -180;
...
void draw() {
background(255);
while (queue.available()) {
LocationValue loc = queue.remove();
minLat = min(minLat, (float)loc.latitude);
maxLat = max(maxLat, (float)loc.latitude);
minLon = min(minLon, (float)loc.longitude);
maxLon = max(maxLon, (float)loc.longitude);
path.add(0, loc);
}
stroke(70, 200);
strokeWeight(displayDensity * 4);
beginShape(LINE_STRIP);
for (LocationValue loc: path) {
float x = map((float)loc.longitude, minLon, maxLon,
0.1 * width, 0.9 * width);
float y = map((float)loc.latitude, minLat, maxLat,
0.1 * height, 0.9 * height);
vertex(x, y);
}
endShape();
}
Listing 9-3.Drawing the Locations Along a Line Strip
与科泰的位置
使用 Ketai 的优点是,我们在上一节中讨论的所有细节(权限、并发性)都被自动处理,因此我们可以专注于使用位置值。使用 Ketai 重写的前一个例子要短得多,正如我们在清单 9-4 中看到的。
import ketai.sensors.*;
KetaiLocation location;
ArrayList<LocationValue> path = new ArrayList<LocationValue>();
float minLat = 90;
float maxLat = -90;
float minLon = 180;
float maxLon = -180;
void setup () {
fullScreen();
location = new KetaiLocation(this);
}
void draw() {
background(255);
stroke(70, 200);
strokeWeight(displayDensity * 4);
beginShape(LINE_STRIP);
for (LocationValue loc: path) {
float x = map((float)loc.longitude, minLon, maxLon,
0.1 * width, 0.9 * width);
float y = map((float)loc.latitude, minLat, maxLat,
0.1 * height, 0.9 * height);
vertex(x, y);
}
endShape();
}
void onLocationEvent(double lat, double lon) {
path.add(new LocationValue(lat, lon));
minLat = Math.min(minLat, (float)lat);
maxLat = Math.max(maxLat, (float)lat);
minLon = Math.min(minLon, (float)lon);
maxLon = Math.max(maxLon, (float)lon);
}
class LocationValue {
double latitude;
double longitude;
LocationValue(double lat, double lon) {
latitude = lat;
longitude = lon;
}
}
Listing 9-4.Getting Location Data with Ketai
onLocationEvent()由 Ketai 在与draw()函数相同的线程中触发,因此不存在遇到并发问题的风险。
使用附加位置数据
当我们的位置监听器在onLocationChanged()处理程序方法中接收到一个新位置时,我们不仅可以获得该位置的纬度和经度,还可以获得其他相关信息,比如海拔、精确度和方位( https://developer.android.com/reference/android/location/Location.html )。精度值很重要,因为它反映了当前供应器的位置精度。特别是,如果连续位置值之间的差异小于当前精度,则存储连续位置值没有意义。我们可以修改我们之前的位置队列示例(清单 9-3 )来合并这个检查。清单 9-5 中显示了这些变化。
void draw() {
background(255);
while (queue.available()) {
LocationValue loc = queue.remove();
minLat = min(minLat, (float)loc.latitude);
maxLat = max(maxLat, (float)loc.latitude);
minLon = min(minLon, (float)loc.longitude);
maxLon = max(maxLon, (float)loc.longitude);
if (0 < path.size()) {
LocationValue last = path.get(path.size() - 1);
if (last.distanceTo(loc) < loc.accuracy + last.accuracy) continue;
}
path.add(0, loc);
}
stroke(70, 200);
strokeWeight(displayDensity * 4);
beginShape(LINE_STRIP);
for (LocationValue loc: path) {
float x = map((float)loc.longitude, minLon, maxLon,
0.1 * width, 0.9 * width);
float y = map((float)loc.latitude, minLat, maxLat,
0.1 * height, 0.9 * height);
vertex(x, y);
}
endShape();
}
...
class SimpleListener implements LocationListener {
public void onLocationChanged(Location loc) {
queue.add(new LocationValue(loc.getLatitude(), loc.getLongitude(),
loc.getAccuracy()));
}
...
}
...
class LocationValue {
double latitude;
double longitude;
double accuracy;
LocationValue(double lat, double lon, double acc) {
latitude = lat;
longitude = lon;
accuracy = acc;
}
double distanceTo(LocationValue dest) {
double a1 = radians((float)latitude);
double a2 = radians((float)longitude);
double b1 = radians((float)dest.latitude);
double b2 = radians((float)dest.longitude);
double t1 = Math.cos(a1) * Math.cos(a2) * Math.cos(b1) * Math.cos(b2);
double t2 = Math.cos(a1) * Math.sin(a2) * Math.cos(b1) * Math.sin(b2);
double t3 = Math.sin(a1) * Math.sin(b1);
double tt = Math.acos(t1 + t2 + t3);
return 6366000 * tt;
}
}
Listing 9-5.Using Location Accuracy
我们从onLocationChanged()事件中的Location参数获得位置精度,并将其存储在LocationValue对象中,与它的纬度和经度放在一起。然后,我们使用最新位置和先前位置的精确度来确定最新位置是否足够不同以添加到路径中。这包括计算两个位置之间的距离,也就是地球表面连接它们的弧的长度。有几个公式可以近似这个距离(https://en.wikipedia.org/wiki/Great-circle_distance#Computational_formulas);在代码中,我们使用了所谓的余弦定律,该定律假设地球是一个完美的球形(并不完全准确,因为我们的星球沿其南北轴略微变平,但对于这个简单的应用来说已经足够了)。
街景学院
在这一点上,我们有几种技术可以用来创建一个更复杂的地理定位项目。正如开头提到的,我们每天都在使用位置感知应用,一天可能会有几十次甚至上百次。谷歌街景等工具如此受欢迎,以至于我们第一次看到一个新地方时,往往不是亲自去,而是在谷歌地图上查看。我们在城市中的体验受到这些应用的影响,因此值得尝试使用街景图像结合我们白天参观的地点来创建某种非功能性的视觉拼贴。事实上,这种想法并不新鲜,许多艺术家以前就一直在使用它,将日常的城市景观转化为既熟悉又陌生又令人迷失方向的构图。图 9-4 再现了艺术家 Masumi Hayashi ( http://masumimuseum.com/ )的全景照片拼贴画,他使用定制的系统捕捉了 360 条风景,后来合并成拼贴画。最近,安娜丽莎·卡西尼( http://www.annalisacasini.com/project-withtools-google-maps/ )一直在用街景图像创作超现实的城市景观拼贴画。
图 9-4。
Top: OSERF Building Broad Street View, Columbus, Ohio, by Masumi Hayashi (2001). Reproduced with permission from the Estate of Masumi Hayashi.
由于智能手机的永远在线特性和实时位置更新的可能性,我们可以在走动时使用从互联网上下载的街景图像来构建一个动态拼贴。从视觉输出的角度来看,要解决的最重要的问题是如何将城市意象组合成引人入胜的作品。当然也有很多(无限!)我们可以这样做的方法,像 Hayashi 和 Casini 这样的艺术家的作品给了我们一些参考来启发我们自己。
我们将首先通过我们的处理草图解决检索街景图像的问题,因为这一步是实现我们概念的任何进一步工作的先决条件。一旦我们能够解决这个技术问题,我们将考虑自动创建拼贴的方法。
使用谷歌街景图像 API
谷歌街景是谷歌地图和谷歌地球的一个受欢迎的功能,它提供了世界上许多地方的全景,主要是城市和城镇的街道,但现在也包括建筑内部,珊瑚礁,甚至太空等网站!街景可以从使用不同 API 的 Android 应用中访问,其中一个 API 允许您在专门的视图组件中创建交互式全景。然而,这个 API 不适合在我们的处理草图中使用,因为全景视图不能与处理的绘图表面集成。
幸运的是,谷歌还提供了一个图像 API,通过这个 API,人们可以使用 HTTP 请求( https://developers.google.com/maps/documentation/streetview/intro )下载与经纬度坐标相对应的街景静态图像。要使用这个 API,我们首先要在 Google Maps APIs 下启用 Google Street View Image API,并在开发者控制台( https://console.developers.google.com/apis/dashboard )中创建一个 Google API 项目。然后,我们必须获得一个 API 键( https://developers.google.com/maps/documentation/android-api/signup )来添加到项目中。这些步骤非常重要,否则我们的应用将无法请求街景图像。我们可以通过在 web 浏览器中创建一个请求来测试一切是否如预期的那样工作。Google 街景图像 API 请求具有以下格式:
请求中的大多数参数都是可选的,除了位置、大小,当然还有 API 键。上一段链接的谷歌街景图片 API 页面详细描述了所有这些 URL 参数。该请求应该返回一个图像,然后我们可以保存在我们的计算机上。
我们可以在处理草图中使用完全相同的请求语法来请求一个PImage。这是通过清单 9-6 中的简单代码实现的,在这里你必须提供你自己的 API 密匙,还需要给草图添加互联网权限。
PImage street;
String apiKey = "<your API key>";
void setup() {
size(512, 512);
street = requestImage("http://maps.googleapis.com/maps/api/streetview?" +
"location=42.383401,-71.116110&size=512x512&" +
"fov=90&pitch=-10&key=" + apiKey);
}
void draw() {
if (0 < street.width && 0 < street.height) {
image(street, 0, 0, width, height);
}
}
Listing 9-6.Requesting a Street View Image
requestImage()函数返回一个PImage对象,该对象将在一个单独的线程中下载,以避免在传输图像数据时挂起我们的草图。当图像的宽度和高度大于零时,我们就知道图像准备好了。如果由于某种原因请求失败,宽度和高度都将被设置为-1。这也是为什么 draw()中有 if(0<street . width&&0<street . height)条件的原因。
通过将从 Google Street View Image API 请求图像的能力与我们之前从清单 9-5 中获得的路径跟踪草图相结合,我们将能够显示从定位服务接收到的最新位置的街道图像。让我们看看如何在清单 9-7 中做到这一点。
...
ArrayList<LocationValue> path = new ArrayList<LocationValue>();
ArrayList<PImage> street = new ArrayList<PImage>();
String apiKey = "<your API key>";
...
void draw() {
background(255);
while (queue.available()) {
LocationValue loc = queue.remove();
minLat = min(minLat, (float)loc.latitude);
maxLat = max(maxLat, (float)loc.latitude);
minLon = min(minLon, (float)loc.longitude);
maxLon = max(maxLon, (float)loc.longitude);
if (0 < path.size()) {
LocationValue last = path.get(path.size() - 1);
if (last.distanceTo(loc) < loc.accuracy + last.accuracy) continue;
}
path.add(0, loc);
String url = "http://maps.googleapis.com/maps/api/streetview?location=" +
loc.latitude + "," + loc.longitude +
"&size=512x512&fov=90&pitch=-10&sensor=false&key=" + apiKey;
street.add(requestImage(url));
}
if (0 < street.size()) {
PImage img = street.get(street.size() - 1);
if (0 < img.width && 0 < img.height) {
image(img, 0, 0, width, height);
}
}
}
...
Listing 9-7.Showing Street View of Our Last Location
我们将每个新位置请求的图像添加到street数组列表中,然后在下载完成后选择列表中的最后一个,就像我们在清单 9-6 中所做的那样。
Note
如果您使用版本控制服务(如 GitHub)来存储代码项目,请注意不要将 API 密钥上传到任何人都可以访问的公共存储库中。如果您将敏感数据提交到一个公共 Git 存储库中,本文将解释如何完全删除它: https://help.github.com/articles/removing-sensitive-data-from-a-repository/ 。
Voronoi 镶嵌
我们现在面临的挑战是通过代码创建一个视觉上有趣的拼贴画。思考这个问题的一种方法是考虑如何将屏幕分成不重叠的区域,每个区域对应一个位置及其相关图像。这种分割在技术上被称为镶嵌。使用矩形的镶嵌很容易实现,但是可能看起来太简单了。然而,有一个众所周知的分割称为 Voronoi 镶嵌。为了创建 Voronoi 镶嵌,我们从一组任意的 2D 点开始。接下来,如果我们在 2D 平面中的位置(x,y ),我们找到集合中最接近(x,y)的点 p。我们说(x,y)属于由 p 确定的区域。然后我们用相同的颜色画出与 p 相关的所有这样的位置(x,y)。按照这个简单的算法,我们将把飞机分割成看起来或多或少像图 9-5 左面的区域。
图 9-5。
Left: Voronoi tessellation with 20 regions. Right: Voronoi portraits by Mark Kleback and Sheiva Rezvani
Voronoi 镶嵌在生物学、地理学、数学、气象学和机器人学等需要进行空间数据分析的领域都有应用。Voronoi 镶嵌能够将空间划分为多个区域,从而更容易操作数据。艺术家们还将其作为基本技术来创作一些作品,其中一些底层数据集(如图 9-5 右侧面板中的肖像)经过处理,以生成数据的“自然”分区,同时还具有视觉吸引力。
给定一个点集,有许多算法可以生成 Voronoi 镶嵌图,有些算法非常有效,但实现起来也很复杂。最简单的算法,但也是效率最低的算法,是将我们对 Voronoi 镶嵌的文字描述翻译成代码的算法。这是非常低效的,因为我们需要将屏幕中的每个像素(x,y)与集合中的所有点进行比较,因此它的执行时间随着屏幕中像素数量的平方而增长,而像素数量的平方又是宽度和高度的乘积。这意味着随着屏幕分辨率的增加,算法的速度会非常快。
但是,如果屏幕分辨率不太高,这个简单的算法仍然足够快,可以交互运行。在清单 9-8 中,每当我们按下鼠标(或触摸屏幕)时,我们都会向 Voronoi 集中添加一个新点。我们将“Voronoi 点”的最大数量设置为 10,分辨率设置为 512 × 512。这个草图可以在 Java 或 Android 模式下运行,无需任何修改。使该算法在处理中容易实现的关键元素是pixels数组,我们在第六章中讨论过。该数组包含屏幕中每个像素的颜色,以连续值的形式排列,因此,如果屏幕的分辨率为 W × H,则pixels数组中的前 W 个元素对应于屏幕中的第一行,接下来的 W 个元素对应于第二行,依此类推。我们也可以使用pixels数组来设置屏幕中任何像素的颜色。我们在清单 9-8 中这样做。
int lastPoint = 0;
int maxPoints = 10;
VoronoiPoint[] points = new VoronoiPoint[maxPoints];
boolean updated = false;
void setup () {
size(512, 512);
}
void draw() {
if (updated) {
updateColors();
drawPoints();
updated = false;
}
}
void mousePressed() {
points[lastPoint] = new VoronoiPoint(mouseX, mouseY, color(random(255), random(255), random(255)));
lastPoint = (lastPoint + 1) % maxPoints;
updated = true;
}
void updateColors() {
int idx = 0;
loadPixels();
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
int closest = findClosestPoint(x, y);
if (-1 < closest) pixels[idx] = points[closest].getColor();
idx++;
}
}
updatePixels();
}
void drawPoints() {
strokeWeight(10);
stroke(0, 50);
for (int i = 0; i < points.length; i++) {
VoronoiPoint p = points[i];
if (p == null) break;
point(p.x, p.y);
}
}
int findClosestPoint(float x, float y) {
int minIdx = -1;
float minDist = 1000;
for (int i = 0; i < points.length; i++) {
VoronoiPoint p = points[i];
if (p == null) break;
float d = dist(x, y, p.x, p.y);
if (d < minDist) {
minIdx = i;
minDist = d;
}
}
return minIdx;
}
class VoronoiPoint {
float x, y;
color c;
VoronoiPoint(float x, float y, color c){
this.x = x;
this.y = y;
this.c = c;
}
color getColor() {
return c;
}
}
Listing 9-8.Generating a Voronoi Tessellation
我们通过定位 Voronoi 列表中最接近的点,在updateColors()函数中设置每个屏幕像素的颜色。为此,我们需要首先调用loadPixels()来确保像素数组被初始化,然后更新 pixels(),以便像素被绘制到屏幕上。findClosestPoint()功能找到离屏幕位置最近的点(x,y);它的工作方式是设置一个大的初始距离(1000),然后遍历所有点,将到(x,y)的距离小于前一个的点的索引保存在变量minIdx中。
如果我们在电脑或 Android 设备上运行这个草图,我们将创建新的 Voronoi 区域,直到最多十个,每个区域被分配一个随机的颜色。一旦我们超过 10 个,草图就在点阵列上循环,新的鼠标/触摸位置将取代原来的位置。图 9-6 显示了典型的输出。
图 9-6。
A typical Voronoi tessellation
在这个交互式镶嵌中,我们为每个点指定了一种纯色。但是,我们可以使用图像来代替,这样相应区域中的像素就可以用图像像素的颜色来绘制。这样,每个区域将显示不同图像的一部分。如果所有图像的大小都与屏幕分辨率相同,那么修改前面的清单以使用图像就很容易了。这就是我们在清单 9-9 中所做的。这里,我们使用街景以 512 × 512 的分辨率生成的一组十张图像,streetview0.jpg到streetview9.jpg,并存储在草图的data文件夹中。
...
void mousePressed() {
points[lastPoint] = new VoronoiPoint(mouseX, mouseY, lastPoint);
lastPoint = (lastPoint + 1) % maxPoints;
updated = true;
}
void updateColors() {
int idx = 0;
loadPixels();
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
int closest = findClosestPoint(x, y);
if (-1 < closest) pixels[idx] = points[closest].getColor(idx);
idx++;
}
}
updatePixels();
}
...
class VoronoiPoint {
float x, y;
PImage img;
VoronoiPoint(float x, float y, int i) {
this.x = x;
this.y = y;
img = loadImage("streetview" + i + ".jpg");
img.loadPixels();
}
color getColor(int idx) {
return img.pixels[idx];
}
}
Listing 9-9.Painting a Voronoi Tessellation with Images
这个清单只显示了需要修改的几部分代码。现在,我们使用点索引来加载存储在每个VoronoiPoint类中的图像对象。PImage类也有一个pixels数组,我们可以从中检索图像每个像素的颜色。使用来自谷歌街景的十张图片,我们的新例子应该生成一个类似于图 9-7 所示的拼贴画。
图 9-7。
Painting the regions in a Voronoi tessellation using Street View images
使用屏幕外绘图图面
我们的 Voronoi 镶嵌代码的一个限制是,它只能在低分辨率下使用,这使得它不适合全屏应用。我们可以应用屏幕外绘图来解决这个问题。想法是将镶嵌画到一个较小的绘图表面上,然后在屏幕上以全分辨率呈现该表面的内容。处理允许我们用createGraphics(width, height)函数创建一个屏幕外绘图表面,该函数返回一个PGraphics对象( https://processing.org/reference/PGraphics.html )封装一个具有所请求分辨率的表面。我们可以用目前为止学过的所有 API 绘制成一个PGraphics对象。要记住的一件重要的事情是在beginDraw()和endDraw()之间封闭所有的PGraphics绘图调用。清单 9-10 显示了使用屏幕外渲染将图像镶嵌草图制作成全屏应用所需的更改。
...
PGraphics canvas;
void setup () {
fullScreen();
canvas = createGraphics(512, 512);
}
void draw() {
...
image(canvas, 0, 0, width, height);
}
void mousePressed() {
float x = map(mouseX, 0, width, 0, canvas.width);
float y = map(mouseY, 0, height, 0, canvas.height);
points[lastPoint] = new VoronoiPoint(x, y, lastPoint);
lastPoint = (lastPoint + 1) % maxPoints;
updated = true;
}
void updateColors() {
int idx = 0;
canvas.beginDraw();
canvas.loadPixels();
for (int y = 0; y < canvas.height; y++) {
for (int x = 0; x < canvas.width; x++) {
int closest = findClosestPoint(x, y);
if (-1 < closest) canvas.pixels[idx] = points[closest].getColor(idx);
idx++;
}
}
canvas.updatePixels();
canvas.endDraw();
}
void drawPoints() {
canvas.beginDraw();
canvas.strokeWeight(10);
canvas.stroke(0, 50);
for (int i = 0; i < points.length; i++) {
VoronoiPoint p = points[i];
if (p == null) break;
canvas.point(p.x, p.y);
}
canvas.endDraw();
}
Listing 9-10.Drawing into an Offscreen Pgraphics Object
此外,请记住,虽然主屏幕表面中的(x,y)坐标范围是从(0,0)到(宽度,高度),但PGraphics分辨率是(PGraphics.width, PGraphics.height),这意味着我们可能需要将坐标从一个表面映射到另一个表面。例如,(mouseX, mouseY)坐标总是指向屏幕,所以我们应该将它们映射到PGraphics对象的宽度和高度,这样交互就能在屏幕外正常工作。
把所有东西放在一起
到目前为止,我们已经成功地用一组预定义的街景图像生成了拼贴画。因此,是时候将这些代码与我们早期的草图结合起来了,这些草图从可用的定位服务中检索设备的位置。我们将使用纬度和经度值来组合 HTTPS 请求,以获得新的街景图像,就像我们在清单 9-7 中所做的那样,并将它分配给一个随机的 Voronoi 点。
然而,我们应该考虑几个重要的技术方面。首先,我们希望以全屏分辨率绘制拼贴画,为此我们可以使用一个分辨率较低的屏幕外PGraphics对象,例如 512 × 512,然后放大它以覆盖整个屏幕,就像我们在清单 9-10 中所做的那样,尽管保留了图像的原始平方比。
第二个方面涉及位置更新的频率。非常频繁地请求更新,尤其是在高精度模式(即 GPS)下,会更快地耗尽电池,但我们实际上不需要非常频繁的更新,因为应用应该等待足够长的时间,以确保周围环境有明显的差异。位置更新的粒度是在对requestLocationUpdates()的调用中设置的,这里我们使用 1 秒和 1 米作为更新的最小时间和距离。这些值对于我们的拼贴目的来说太小了,我们可以将它们增加很多—例如,分别增加到 30 秒和 20 米。考虑到这些因素,让我们看看清单 9-11 中的完整代码。
import android.content.Context;
import android.location.Location;
import android.location.LocationListener;
import android.location.LocationManager;
import android.os.Bundle;
LocationManager manager;
SimpleListener listener;
String provider;
LocationQueue queue = new LocationQueue();
ArrayList<LocationValue> path = new ArrayList<LocationValue>();
int lastPoint = 0;
int maxPoints = 10;
VoronoiPoint[] points = new VoronoiPoint[maxPoints];
PGraphics canvas;
String apiKey = "<your API key>";
void setup () {
fullScreen();
orientation(PORTRAIT);
canvas = createGraphics(512, 512);
imageMode(CENTER);
requestPermission("android.permission.ACCESS_FINE_LOCATION", "initLocation");
}
void draw() {
background(0);
updatePositions();
float h = max(width, height);
image(canvas, width/2, height/2, h, h);
}
void updatePositions() {
while (queue.available()) {
LocationValue loc = queue.remove();
if (0 < path.size()) {
LocationValue last = path.get(path.size() - 1);
if (last.distanceTo(loc) < 20) continue;
}
String url = "http://maps.googleapis.com/maps/api/streetview?location=" +
loc.latitude + "," + loc.longitude +
"&size=512x512&fov=90&pitch=-10&sensor=false&key=" + apiKey;
loc.setStreetView(requestImage(url));
path.add(loc);
}
boolean newImage = false;
for (int i = path.size() - 1; i >= 0; i--) {
LocationValue loc = path.get(i);
PImage img = loc.getStreetView();
if (img.width == -1 || img.height == -1) {
path.remove(i);
} else if (img.width == 512 && img.height == 512) {
float x = random(0, canvas.width);
float y = random(0, canvas.height);
points[lastPoint] = new VoronoiPoint(x, y, img);
lastPoint = (lastPoint + 1) % maxPoints;
newImage = true;
path.remove(i);
}
}
if (newImage) updateColors();
}
void updateColors() {
int idx = 0;
canvas.beginDraw();
canvas.loadPixels();
for (int y = 0; y < canvas.height; y++) {
for (int x = 0; x < canvas.width; x++) {
int closest = findClosestPoint(x, y);
if (-1 < closest) canvas.pixels[idx] = points[closest].getColor(idx);
idx++;
}
}
canvas.updatePixels();
canvas.endDraw();
}
void initLocation(boolean granted) {
if (granted) {
Context context = getContext();
listener = new SimpleListener();
manager = (LocationManager)
context.getSystemService(Context.LOCATION_SERVICE);
provider = LocationManager.NETWORK_PROVIDER;
if (manager.isProviderEnabled(LocationManager.GPS_PROVIDER)) {
provider = LocationManager.GPS_PROVIDER;
}
manager.requestLocationUpdates(provider, 30000, 20, listener);
}
}
class SimpleListener implements LocationListener {
public void onLocationChanged(Location loc) {
queue.add(new LocationValue(loc.getLatitude(), loc.getLongitude()));
}
public void onProviderDisabled(String provider) { }
public void onProviderEnabled(String provider) { }
public void onStatusChanged(String provider, int status, Bundle extras) { }
}
public void resume() {
if (manager != null) {
manager.requestLocationUpdates(provider, 30000, 20, listener);
}
}
public void pause() {
if (manager != null) {
manager.removeUpdates(listener);
}
}
class LocationValue {
double latitude;
double longitude;
PImage streetView;
LocationValue(double lat, double lon) {
latitude = lat;
longitude = lon;
}
void setStreetView(PImage img) {
streetView = img;
}
PImage getStreetView() {
return streetView;
}
double distanceTo(LocationValue dest) {
double a1 = radians((float)latitude);
double a2 = radians((float)longitude);
double b1 = radians((float)dest.latitude);
double b2 = radians((float)dest.longitude);
double t1 = Math.cos(a1) * Math.cos(a2) * Math.cos(b1) * Math.cos(b2);
double t2 = Math.cos(a1) * Math.sin(a2) * Math.cos(b1) * Math.sin(b2);
double t3 = Math.sin(a1) * Math.sin(b1);
double tt = Math.acos(t1 + t2 + t3);
return 6366000 * tt;
}
}
class LocationQueue {
LocationValue[] values = new LocationValue[10];
int offset, count;
synchronized void add(LocationValue val) {
if (count == values.length) {
values = (LocationValue[]) expand(values);
}
values[count++] = val;
}
synchronized LocationValue remove() {
if (offset == count) {
return null;
}
LocationValue outgoing = values[offset++];
if (offset == count) {
offset = 0;
count = 0;
}
return outgoing;
}
synchronized boolean available() {
return 0 < count;
}
}
class VoronoiPoint {
float x, y;
PImage img;
VoronoiPoint(float x, float y, PImage img){
this.x = x;
this.y = y;
this.img = img;
img.loadPixels();
}
color getColor(int idx) {
return img.pixels[idx];
}
}
int findClosestPoint(float x, float y) {
int minIdx = -1;
float minDist = 1000;
for (int i = 0; i < points.length; i++) {
VoronoiPoint p = points[i];
if (p == null) break;
float d = dist(x, y, p.x, p.y);
if (d < minDist) {
minIdx = i;
minDist = d;
}
}
return minIdx;
}
Listing 9-11.
Street View Collage
让我们来讨论这个草图中的新代码,特别是updatePositions()函数。第一部分与我们之前所做的非常相似:从队列中删除新位置,如果它们太靠近前一个位置(这里的阈值是 20 米),则跳过它们,否则将它们添加到路径中。我们还生成相应的街景请求,并将PImage对象存储在新的LocationValue对象中。然后,它向后遍历到目前为止接收到的所有位置,并删除那些没有接收到街景图像(通过检查所请求图像的宽度或高度是否为-1)或完成了图像下载的位置。后面的位置被删除,因为它们的图像被分配给新的 Voronoi 点,所以不再需要它们。通过这种方式,我们确保应用的内存使用量不会持续增加,直到它耗尽内存并被迫退出;我们最多只存储十幅同步图像(分辨率为 512 × 512),因为这是我们的 Voronoi 点阵列的长度。
一个较小的细节,但仍然是重要的,以确保拼贴看起来很好,是确保它覆盖整个屏幕,同时保持其原始的纵横比。如果我们看看draw()函数中的代码,我们会看到如何做:我们用线float h = max(width, height)取最大的屏幕尺寸,然后用image()函数以这个尺寸h画出屏幕外的PGraphics画布,把它放在屏幕的中心。由于我们在setup()中将图像模式设置为CENTER,画布将正确地显示在屏幕中央。
我们可以将该草图作为常规应用或动态壁纸运行(在第二种情况下,我们可能希望使用wallpaperPreview()功能,以便仅在选择壁纸后请求位置许可)。输出的一些例子如图 9-8 所示。如果我们计划通过 Play Store 发布它,我们还必须创建一整套图标,在它的清单文件中写下完整的包和应用的名称以及版本,并导出一个发布包,就像我们在第 3 和 6 章中为最终项目所做的那样。
图 9-8。
Different Street View collages generated with the final sketch, running as a live wallpaper
摘要
在这一章中,我们详细探讨了 Android location API 提供的可能性,以及我们如何在 Android 处理中使用它来结合其他技术(如 Google Street View)创建非常规的地理定位应用。正如我们刚刚看到的,处理给了我们很大的自由度来访问不同的数据源(GPS、图像等)。),而且还提供了一个坚实的框架来成功整合这些资源,以便我们可以将我们的想法从概念草图转化为最终的应用。
十、可穿戴设备
在本章中,我们将使用处理来为 Android 智能手表创建手表面部。我们将讨论在编写智能手表应用时应该考虑的可穿戴设备的具体功能和限制。
从活动追踪器到智能手表
尽管我们在考虑移动开发时可能会首先想到手机和平板电脑,但自 2009 年推出健身追踪器(如 Fitbit)以来,可穿戴设备已经出现在许多人的生活中,最近这种设备的列表已经扩展到包括苹果和几家安卓制造商的数字智能手表。随着传感器技术的快速发展和电子元件尺寸的减小,这些设备能够执行广泛的功能,而不仅仅是计数步数和心跳。事实上,2017 款智能手表拥有许多与智能手机相同的功能(2D 和 3D 图形、触摸屏、位置和移动传感器、Wi-Fi 连接)。
Android 平台通过 Android 操作系统的 Wear 版本为所有这些设备提供支持。运行 Android Wear 1.x 的设备需要与运行 Android 4.3 或更高版本的 Android 手机配对,才能启用可穿戴设备中的所有功能(例如,显示来自手机的电子邮件和消息通知),而运行 Wear 2.x 的手表可以运行独立的应用,不需要将手表与手机配对。Android 平台上的穿戴应用( https://developer.android.com/training/wearables/apps/index.html )可以访问手表的传感器和图形。手表脸是一种特殊的佩戴应用,作为手表的背景运行,与手机和平板电脑上的动态壁纸没有什么不同。它们旨在显示时间和其他相关信息,如身体活动。
Android 的处理目前允许我们在 Android 智能手表上运行草图作为手表表面,但不能作为一般的穿戴应用。前几章讨论的所有绘图、交互和传感 API 都适用于手表表面,只需增加一些功能来处理智能手表的独特功能。
Note
Android mode 4.0 版本可用于为运行 Android Wear 2.0 或更高版本的智能手表创建手表面部。它不支持 Wear 1.x 设备。
智能手表
一些制造商提供 Android 智能手表,因此有各种不同规格和风格的型号。图 10-1 展示了 Android 手表的一小部分选择。
图 10-1。
A selection of Android smartwatches, from left to right: Sony Smartwatch 3, Moto 360, LG Watch Urbane, Polar M600
尽管手表种类繁多,但所有的手表都必须符合技术规格的最低标准。为 Android Wear 1 . x 版本发布的手表具有(圆形或方形)显示器,密度在 200 到 300 dpi 之间(因此,属于 hdpi 范围),Wi-Fi 和蓝牙连接,加速度计,陀螺仪,通常还有心率传感器,4 GB 的内部存储,以及长达两天的混合使用电池寿命(完全活动模式与节省电池的“环境”模式)。正如本章介绍中提到的,鉴于 Wear 2.x 设备的自主性增加,Wear 2.0 将鼓励手表配备更多传感器、更高的显示密度和更长的电池寿命。
Note
Wear 1.x 和 2.x 之间的一个重要区别是,对于前者,手表总是需要与智能手机配对才能发挥全部功能(即显示信息,提供位置),但对于后者,它们可以完全自主工作,并运行与为手机设计的应用一样强大的应用。
运行手表表面草图
正如我们可以在实际设备或模拟器上运行常规应用的处理草图一样,我们也可以在手表或模拟器上运行我们的手表表面草图。在物理设备上调试的过程通常更方便,因为仿真器通常更慢,并且无法模拟我们可能需要的所有传感器数据,以便调试我们的手表表面,但仿真器允许我们测试各种显示配置,并且如果我们还没有 Android 手表,可以运行我们的手表表面。
使用手表
要在 Android 手表上运行处理草图,我们首先需要在手表上启用“开发者选项”,如下所示:
- 打开手表上的设置菜单。
- 滚动到菜单底部,选择“系统|关于”
- 轻按内部版本号七次。
- 从“设置”菜单中,选择“开发人员选项”
- 确认“ADB 调试”已启用。
一旦我们启用了开发者选项,我们必须在两个选项之间进行选择,以便在 Wear 2.x 手表上运行和调试我们的手表表面草图:Wi-Fi 和蓝牙。谷歌关于调试 Wear 应用的开发者指南详细介绍了所有细节( https://developer.android.com/training/wearables/apps/debugging.html ),我们现在来回顾一下最重要的步骤。
使用蓝牙时,手表必须与手机配对。首先,我们需要在两台设备上启用蓝牙调试。在手表上,我们通过打开“设置|开发者选项”并启用“蓝牙调试”来实现这一点在手机上,我们打开 Android Wear 伴侣应用,点击它的设置图标,然后启用“通过蓝牙调试”一旦我们完成了所有这些,处理器就应该能够通过与手机的蓝牙配对连接到手表。
Note
如果手表通过蓝牙与手机配对,并且该手机是唯一通过 USB 连接到电脑的设备,则处理将能够自动连接到手表。但如果有多部手机,就需要用 adb 命令```java `./adb -s ID forward tcp:4444 localabstract:/adb-hub手动连接手表,提供手表配对的手机 ID,然后使用命令 adb connect 127.0.0.1:4444``。
就 Wi-Fi 而言,我们运行处理的电脑和手表必须连接到同一个网络。然后,我们需要在手表上启用 Wi-Fi 调试,方法是进入“设置|开发者选项”并启用“通过 Wi-Fi 调试”稍后,手表将显示其 IP 地址(如 192.168.1.100)。一旦我们获得了手表的 IP 地址,我们将打开一个终端。从那里,我们将切换到 Android SDK 内的platform-tools文件夹,并运行命令``` `adb connect 192.168.1.100``,如图 10-2 所示。
图 10-2。
Connecting to a watch over Wi-Fi from the command line
一旦我们通过 Wi-Fi 或蓝牙连接了手表,我们应该会在 Android 菜单下的设备列表中看到它。此外,我们必须确保选择“手表表面”选项,因为加工不允许我们在手表上运行其他草图类型(图 10-3 )。
图 10-3。
Enabling running sketches as watch faces and listing a connected watch
通过蓝牙或 Wi-Fi 连接到我们的手表后,我们可以按照清单 10-1 运行一个动画手表表面。
void setup() {
fullScreen();
strokeCap(ROUND);
stroke(255);
noFill();
}
void draw() {
background(0);
if (wearAmbient()) strokeWeight(1);
else strokeWeight(10);
float angle = map(millis() % 60000, 0, 60000, 0, TWO_PI);
arc(width/2, height/2, width/2, width/2, 0, angle);
}
Listing 10-1.Simple Animated Watch Face
在 Processing 将草图作为观察面安装到设备上之后,我们必须选择它作为活动观察面。为此,向左滑动屏幕以访问最喜爱的手表面孔列表。如果我们的没有出现在这个列表中,点击列表最右端的“添加更多的手表面孔”,你应该会在那里找到草图,可能在其他可用的手表面孔中。首先在那里选择它,一旦它被添加到收藏列表,你可以点击它设置为当前背景。输出将如图 10-4 所示。
图 10-4。
Output of the animated watch face example
请注意,表盘不会一直看起来像这个图中的样子。几秒钟后,手表将进入环境模式,显示每分钟更新一次。此模式的目的是在我们不看手表时节省电池电量。一旦手表检测到(使用其加速度计)转动手腕看时间的典型手势,它将返回到交互模式。谷歌的开发者指南建议在环境模式下将大部分屏幕设置为黑色背景,并用细白线绘制剩余的图形元素。正如我们在代码中看到的,处理过程给了我们wearAmbient()函数来检测手表是否处于环境模式,并相应地更新图形。
使用模拟器
我们在第一章中看到,为了在模拟器中运行手机 Android 虚拟设备(AVD ),我们应该安装一个系统映像。我们还看到,我们必须决定是否要使用 ARM 或 x86 映像。要使用带有 watch faces 的模拟器,我们需要安装一个单独的 watch AVD 供模拟器使用。我们第一次在仿真器中运行手表表面草图时,会看到一个对话框要求我们下载手表系统映像(图 10-5 ),然后是 ARM/x86 选择。一旦镜像(以及 x86 镜像的 HAXM 软件,正如我们在第一章中所讨论的)被下载并安装,处理会将草图复制到仿真器中,并在草图作为表盘成功安装后通知我们。
图 10-5。
Downloading the watch system image
就像实际设备一样,我们需要选择我们的手表表面,以便将其设置为当前背景,这可以通过我们之前看到的相同系列步骤来完成,如图 10-6 所示:将手表表面添加到收藏夹列表,然后从该列表中选择它。
图 10-6。
Selecting a watch face in the emulator
默认处理创建一个分辨率为 280 × 280 的方形手表 AVD。然而,在第三章中,我们了解到我们可以用 avdmanager 命令行工具创建其他 avd。只要我们在正确的端口上使用仿真器工具启动这些 avd,处理就会在这些 avd 上运行我们的草图。例如,让我们用"wear_round_360_300dpi"设备定义创建一个圆形手表 AVD,并在端口 5576 上启动它,这样我们就可以在处理中使用它。这样做的命令如图 10-7 所示(创建 AVD 后,记得将skin参数添加到其config.ini文件中,正如我们在第三章中看到的)。图 10-8 显示了在 round watch AVD 中运行我们的草图所得到的仿真器。
图 10-8。
Running our watch face sketch in the custom AVD
图 10-7。
Creating and launching a custom watch AVD
显示时间
显示时间是手表的基本功能之一,通过处理,我们能够创建任何我们可以想象的时间的可视化表示。Processing 提供了许多函数来获取当前的时间和日期— year()、month()、day()、hour()、minute()和second()—这将允许我们生成自己的时间可视化。作为一个基本的例子,在清单 10-2 中,我们用文本显示时间。
void setup() {
fullScreen();
frameRate(1);
textFont(createFont("Serif-Bold", 48));
textAlign(CENTER, CENTER);
fill(255);
}
void draw() {
background(0);
if (wearInteractive()) {
String str = hour() + ":" + nfs(minute(), 2) + ":" + nfs(second(), 2);
text(str, width/2, height/2);
}
}
Listing 10-2.Displaying the Time as Text
注意我们使用了frameRate(1)。因为我们显示的时间精确到秒,所以不需要以更高的帧速率运行草图,这也有助于节省电池。nfs()函数方便地在数字的右边加零,所以得到的字符串总是有两位数。最后,wearInteractive()简单地返回与wearAmbient()函数相反的函数,我们在第一个 watch face 中使用了这个函数。
计算步数
我们可以通过 Android API 或 Ketai 库,使用与前几章相同的技术访问手表中的传感器。我们将在第十二章研究身体感应的可能性,但在这里的清单 10-3 中,我们展示了一个使用 Android sensor API 的简单计步器示例。
import android.content.Context;
import android.hardware.Sensor;
import android.hardware.SensorManager;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
Context context;
SensorManager manager;
Sensor sensor;
SensorListener listener;
int offset = -1;
int steps;
void setup() {
fullScreen();
frameRate(1);
Context context = (Context) surface.getComponent();
manager = (SensorManager)context.getSystemService(Context.SENSOR_SERVICE);
sensor = manager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER);
listener = new SensorListener();
manager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_NORMAL);
textFont(createFont("SansSerif", 40 * displayDensity));
textAlign(CENTER, CENTER);
fill(255);
}
void draw() {
background(0);
if (wearInteractive()) {
String str = steps + " steps";
float w = textWidth(str);
text(str, width/2, height/2);
}
}
void resume() {
if (manager != null)
manager.registerListener(listener, sensor,
SensorManager.SENSOR_DELAY_NORMAL);
}
void pause() {
if (manager != null) manager.unregisterListener(listener);
}
class SensorListener implements SensorEventListener {
public void onSensorChanged(SensorEvent event) {
if (offset == -1) offset = (int)event.values[0];
steps = (int)event.values[0] - offset;
}
public void onAccuracyChanged(Sensor sensor, int accuracy) { }
}
Listing 10-3.Simple Step Counter
由于计步传感器(它不是实际的硬件传感器,而是一个“衍生”传感器,使用来自加速度计的信息来计算步数)返回的值是从手表启动时开始累积的,因此我们存储第一个值,从特定表盘打开的那一刻开始计数。
智能手表设计
使用 Processing 的绘图 API 来创建手表表面,结合上下文和传感器数据,为时间的表示开辟了无数方向。智能手表显示屏的有限尺寸在视觉设计和信息密度方面带来了挑战。我们将在接下来的两章中更深入地探讨这些挑战。
谷歌官方开发者网站包括一个专门关于表盘设计的部分( https://developer.android.com/design/wear/watchfaces.html ),它提供了一些关于设计概念和语言的有用指导,以及如何处理智能手表特有的方面。
屏幕形状和插图
要考虑的第一个重要方面是使表盘的图形适应圆形和方形显示屏,以便我们的视觉设计在两种情况下都能有效工作。
Note
即使显示器是圆形的,宽度和高度值也是指显示器沿水平和垂直方向的最大范围。
您可以通过调用wearRound()或wearSquare()函数来确定屏幕的形状,对于圆形/方形屏幕,这些函数将返回 true,否则返回 false,如清单 10-4 所示。
void setup() {
fullScreen();
if (wearSquare()) rectMode(CENTER);
}
void draw() {
background(0);
if (wearAmbient()) {
stroke(255);
noFill();
} else {
noStroke();
fill(255);
}
float scale = map(second(), 0, 59, 0, 1);
if (wearRound()) {
ellipse(width/2, height/2, scale * width, scale * width);
} else {
rect(width/2, height/2, scale * width, scale * height);
}
}
Listing 10-4.Adjusting Graphics to the Screen Shape
然而,如果我们在屏幕底部有插图(或“下巴”)的设备(如 Moto 360)上运行清单 10-4 中的草图,我们会注意到圆圈相对于手表的边框是偏心的,即使它相对于显示屏本身是居中的(图 10-9 )。
图 10-9。
“Chin” in a Moto 360 smartwatch. Graphics centered at the screen center (left), and translated by half of the bottom inset to properly center them with respect to the bezels
在这些情况下,我们可以使用 wearInsets()函数正确地将草图居中。该函数返回一个对象,该对象包含显示器周围的嵌入边框:top、bottom、left和right。对于下巴较低的设备,我们只需要添加调用translate(0, wearInsets().bottom/2)来使图形相对于边框中心居中,尽管代价是修剪图形的下部(清单 10-5 )。
void setup() {
fullScreen();
if (wearSquare()) rectMode(CENTER);
}
void draw() {
background(0);
if (wearAmbient()) {
stroke(255);
noFill();
} else {
noStroke();
fill(255);
}
translate(0, wearInsets().bottom/2);
float scale = map(second(), 0, 59, 0, 1);
if (wearRound()) {
ellipse(width/2, height/2, scale * width, scale * width);
} else {
rect(width/2, height/2, scale * width, scale * height);
}
}
Listing 10-5.Watch Face Insets
观看面孔预览图标
除了我们在第三章讨论的常规应用图标,Android 还需要一组预览图标来显示选择列表中的手表表面。常规图标将用于 UI 的其他部分,如应用信息和卸载菜单。
由于手表可以是圆形或方形,我们只需要提供两个预览图标:一个用于圆形表壳(分辨率为 320 × 320),另一个用于方形表壳(分辨率为 280 × 280)。这两个图标都需要复制到草图的文件夹中,并且它们必须有名称preview_circular.png和preview_rectangular.png。
图 10-10 显示了预览图标,注意圆形预览中的红色部分将不可见。将草图导出为已签名的包时,在所有八个图标(六个常规的 ldpi、mdpi、hdpi、xhdpi、xxhdpi 和 xxxhdpi 分辨率图标和两个预览图标)都包含在草图文件夹中之前,处理不会让我们完成导出。
图 10-10。
Preview images for round (left) and square (right) devices. The red portion in the round preview will be ignored.
摘要
在关于 Android 智能手表的第一章中,我们学习了可穿戴设备和应用的基础知识,以及如何为这些新颖的设备创建手表外观。我们还详细研究了将处理连接到实际手表或仿真器所需的设置,以测试我们在不同显示器配置下的表盘草图。