安卓编程初学者手册第三版(七)
原文:
zh.annas-archive.org/md5/ceefdd89e585c59c20db6a7760dc11f1译者:飞龙
第二十二章:粒子系统和处理屏幕触摸
我们已经在上一章中使用线程实现了实时系统。在本章中,我们将创建将存在并在这个实时系统中发展的实体,就好像它们有自己的思想一样;它们将形成用户可以实现的绘图的外观。
我们还将看到用户如何通过学习如何响应与屏幕的交互来实现这些实体。这与在 UI 布局中与小部件交互是不同的。
以下是本章即将涉及的内容:
-
向屏幕添加自定义按钮
-
编写
Particle类 -
编写
ParticleSystem类 -
处理屏幕触摸
-
Android Studio Profiler 工具
我们将首先为我们的应用程序添加自定义 UI。
警告
这个应用程序产生明亮的闪烁颜色。这可能会引起光敏性癫痫的人不适或癫痫发作。请谨慎阅读。您可能只想阅读这个项目的理论,而不运行完成的项目。
技术要求
您可以在 GitHub 上找到本章中的代码文件,网址为github.com/PacktPublishing/Android-Programming-for-Beginners-Third-Edition/tree/main/chapter%2022。
向屏幕添加自定义按钮
我们需要让用户控制何时开始另一次绘制,并清除他们以前的作品。我们需要让用户能够决定何时以及何时将绘图带到生活中。为了实现这一点,我们将在屏幕上添加两个按钮,每个任务一个按钮。
在LiveDrawingView类中添加下面突出显示的成员:
// These will be used to make simple buttons
private RectF mResetButton;
private RectF mTogglePauseButton;
我们现在有两个RectF实例。这些对象每个都包含四个浮点坐标,每个按钮的每个角落一个坐标。
在LiveDrawingView的构造函数中初始化位置:
// Initialize the two buttons
mResetButton = new RectF(0, 0, 100, 100);
mTogglePauseButton = new RectF(0, 150, 100, 250);
添加RectF类的import:
import android.graphics.RectF;
现在我们已经为按钮添加了实际的坐标。如果您在屏幕上可视化坐标,您会看到它们位于左上角,暂停按钮就在重置/清除按钮的下方。
现在我们可以绘制按钮。在LiveDrawingView类的draw方法中添加以下两行代码:
// Draw the buttons
mCanvas.drawRect(mResetButton, mPaint);
mCanvas.drawRect(mTogglePauseButton, mPaint);
新代码使用了drawRect方法的重写版本,我们只需将两个RectF实例与通常的Paint实例一起传递进去。我们的按钮现在将被绘制到屏幕上。
我们将在本章后面看到如何与这些略显粗糙的按钮进行交互。
实现粒子系统效果
粒子系统是控制粒子的系统。在我们的情况下,ParticleSystem是一个我们将编写的类,它将生成Particle类的实例(许多实例),从而创建一个简单的爆炸效果。
这是一张由粒子系统控制的一些粒子的图像:
图 22.1 - 粒子系统效果
为了澄清,每个彩色方块都是Particle类的一个实例,所有Particle实例都由ParticleSystem类控制和持有。此外,用户将通过用手指绘制来创建多个(数百个)ParticleSystem实例。粒子将显示为点或块,直到用户点击暂停按钮时才会活跃起来。我们将仔细检查代码,以便您能够在代码中设置Particle和ParticleSystem实例的大小、颜色、速度和数量。
注意
读者可以在屏幕上添加额外的按钮,以允许用户更改这些属性作为应用程序的一个特性。
我们将从编写Particle类开始。
编写Particle类
按照以下代码中所示的import语句、成员变量和构造方法添加:
import android.graphics.PointF;
class Particle {
PointF mVelocity;
PointF mPosition;
Particle(PointF direction)
{
mVelocity = new PointF();
mPosition = new PointF();
// Determine the direction
mVelocity.x = direction.x;
mVelocity.y = direction.y;
}
}
我们有两个成员变量:一个用于速度,一个用于位置。它们都是PointF对象。PointF包含两个浮点值。位置很简单;它只是一个水平和垂直值。速度值值得更详细解释。PointF中的两个值将是速度,一个是水平的,另一个是垂直的。这两个速度的组合将意味着一个方向。
注意
在构造函数中,两个新的PointF对象被实例化,并且mVeleocity的x和y值被初始化为由PointF direction参数传入的值。注意值是如何从direction复制到mVelocity的。现在,PointF mVelocity不是作为参数传入的PointF的引用。每个Particle实例将从direction复制值(对于每个实例它们将是不同的),但mVelocity与direction没有持久的连接。
接下来,添加以下三种方法,然后我们可以讨论它们:
void update(float fps)
{
// Move the particle
mPosition.x += mVelocity.x;
mPosition.y += mVelocity.y;
}
void setPosition(PointF position)
{
mPosition.x = position.x;
mPosition.y = position.y;
}
PointF getPosition()
{
return mPosition;
}
也许并不奇怪,有一个update方法。Particle实例的update方法将由ParticleSystem类的update方法在应用程序的每一帧调用,而ParticleSystem类的update方法将由LiveDrawingView类(在update方法中)调用,我们将在本章后面编写。
在update方法中,使用mVelocity的相应值更新mPosition的水平和垂直值。
注意
请注意,我们在更新中没有使用当前帧速率。如果您想确保粒子以完全正确的速度飞行,可以修改这一点。但是所有速度都将是随机的。增加这个额外的计算并没有太多好处(对于每个粒子)。然而,正如我们很快将看到的,ParticleSystem类将需要考虑当前每秒帧数,以测量它应该运行多长时间。
接下来,我们编写了setPosition方法。请注意,该方法接收PointF,用于设置初始位置。ParticleSystem类将在触发效果时传递此位置。
最后,我们有getPosition方法。我们需要这个方法,以便ParticleSystem类可以在正确的位置绘制所有粒子。我们本可以在Particle类中添加一个draw方法,而不是getPosition方法,并让Particle类自己绘制。在这个实现中,两种选项都没有特别的好处。
现在我们可以继续ParticleSysytem类。
编写ParticleSystem类。
ParticleSystem类比Particle类有更多的细节,但仍然相当简单。记住我们需要用这个类实现的目标:保存、生成、更新和绘制一堆(相当大的一堆)Particle实例。
添加以下成员和import语句:
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.PointF;
import java.util.ArrayList;
import java.util.Random;
class ParticleSystem {
private float mDuration;
private ArrayList<Particle> mParticles;
private Random random = new Random();
boolean mIsRunning = false;
}
我们有四个成员变量:首先,一个名为mDuration的float变量,它将被初始化为我们希望效果运行的秒数。名为mParticles的ArrayList实例保存Particle实例,并将保存我们实例化的所有Particle对象。
称为random的Random实例被创建为成员,因为我们需要生成如此多的随机值,每次创建一个新对象都会减慢速度。
最后,mIsRunning布尔值将跟踪粒子系统当前是否正在显示(更新和绘制)。
现在我们可以编写init方法。每当我们想要一个新的ParticleSystem时,将调用此方法。请注意,唯一的参数是一个名为numParticles的int参数。
当我们调用init时,我们可以有一些乐趣初始化疯狂数量的粒子。添加init方法,然后我们将更仔细地查看代码:
void init(int numParticles){
mParticles = new ArrayList<>();
// Create the particles
for (int i = 0; i < numParticles; i++){
float angle = (random.nextInt(360)) ;
angle = angle * 3.14f / 180.f;
// Option 1 - Slow particles
//float speed = (random.nextFloat()/10);
// Option 2 - Fast particles
float speed = (random.nextInt(10)+1);
PointF direction;
direction = new PointF((float)Math.cos(angle) *
speed, (float)Math.sin(angle) *
speed);
mParticles.add(new Particle(direction));
}
}
init方法只包括一个for循环,完成所有工作。for循环从零到numParticles-1运行。
首先,生成一个介于零和 359 之间的随机数,并存储在名为angle的float变量中。接下来,进行一些数学运算,将angle乘以3.14/180。这将角度从度转换为弧度测量,这是Math类在稍后将要使用的。
然后我们生成另一个 1 到 10 之间的随机数,并将结果赋给一个名为speed的float变量。
注意
我已经添加了注释,建议在代码的这部分中使用不同的值。我在ParticleSystem类的几个地方都这样做了,当我们到达章节的末尾时,我们将乐趣地改变这些值,看看对绘图应用有什么影响。
现在我们有了一个随机角度和速度,我们可以将它们转换并组合成一个矢量,该矢量可以在Particle类的update方法中使用,以更新其每帧的位置。
注意
矢量是一个确定方向和速度的值。我们的矢量存储在direction对象中,直到传递到Particle构造函数中。矢量可以是多维的。我们的矢量是二维的,因此定义了 0 到 359 度之间的航向和 1 到 10 之间的速度。您可以在我的网站上阅读更多关于矢量、航向、正弦和余弦的内容:gamecodeschool.com/essentials/calculating-heading-in-2d-games-using-trigonometric-functions-part-1/。
使用Math.sin和Math.cos创建矢量的单行代码,我决定不完全解释,因为其中的魔法部分在以下公式中发生:
-
角度的余弦 *
speed -
角度的正弦 *
speed
这也在Math类提供的余弦和正弦函数的隐藏计算中部分发生。如果您想了解它们的全部细节,请参阅前面的提示框。
最后,创建一个新的Particle,然后将其添加到mParticles ArrayList实例中。
接下来,我们将编写update方法。请注意,update方法确实需要当前帧速率作为参数。编写如下所示的update方法:
void update(long fps){
mDuration -= (1f/fps);
for(Particle p : mParticles){
p.update(fps);
}
if (mDuration < 0)
{
mIsRunning = false;
}
}
update方法内部发生的第一件事是减去mDuration的经过时间。请记住,fps参数是每秒帧数,所以1/fps会给出一个作为秒的分数值。
接下来是一个增强的for循环,调用mParticles ArrayList实例中每个Particle实例的update方法。
最后,代码检查粒子效果是否已经完成,使用if(mDuration < 0),如果是,则将mIsRunning设置为false。
现在我们可以编写emitParticles方法,它将使每个Particle实例运行。这不应与init混淆,后者创建所有新的粒子并赋予它们速度。init方法将在用户开始交互之前调用一次,而emitParticles方法将在每次需要启动效果时调用,用户在屏幕上绘制时。
添加emitParticles方法:
void emitParticles(PointF startPosition){
mIsRunning = true;
// Option 1 - System lasts for half a minute
//mDuration = 30f;
// Option 2 - System lasts for 2 seconds
mDuration = 3f;
for(Particle p : mParticles){
p.setPosition(startPosition);
}
}
首先,请注意将所有粒子的起始位置作为参数传递给PointF引用。所有粒子将从完全相同的位置开始,然后根据它们各自的速度每帧扩散。
mIsRunning布尔值设置为true,mDuration设置为1f,所以效果将持续一秒,增强的for循环调用setPosition来移动每个粒子到起始坐标。
我们ParticleSysytem类的最后一个方法是draw方法,它将展示效果的全部荣耀。该方法接收一个Canvas实例和一个Paint实例的引用,因此它可以在LiveDrawingView类的draw方法中锁定的相同画布上绘制。
添加draw方法:
void draw(Canvas canvas, Paint paint){
for (Particle p : mParticles) {
// Option 1 - Coloured particles
//paint.setARGB(255, random.nextInt(256),
//random.nextInt(256),
//random.nextInt(256));
// Option 2 - White particles
paint.setColor(
Color.argb(255,255,255,255));
// How big is each particle?
float sizeX = 0;
float sizeY = 0;
// Option 1 - Big particles
//sizeX = 25;
//sizeY = 25;
// Option 2 - Medium particles
sizeX = 10;
sizeY = 10;
// Option 3 - Tiny particles
//sizeX = 1;
//sizeY = 1;
// Draw the particle
// Option 1 - Square particles
//canvas.drawRect(p.getPosition().x,
//p.getPosition().y,
//p.getPosition().x + sizeX,
//p.getPosition().y + sizeY,
//paint);
// Option 2 - Circle particles
canvas.drawCircle(p.getPosition().x,
p.getPosition().y,
sizeX, paint);
}
}
增强的for循环遍历mParticles ArrayList实例中的每个Particle实例。依次使用drawRect方法和getPosition方法绘制每个Particle。请注意调用paint.setARGB方法。您将看到我们随机生成每个颜色通道。
注意
请注意评论中我建议了不同的代码更改选项,这样在完成编码后我们就可以玩得更开心。
我们现在可以开始让粒子系统工作了。
在LiveDrawingView类中生成粒子系统
添加一个充满系统的ArrayList实例和一些其他成员来跟踪事物。在现有注释所指示的位置添加突出显示的代码:
// The particle systems will be declared here later
private ArrayList<ParticleSystem>
mParticleSystems = new ArrayList<>();
private int mNextSystem = 0;
private final int MAX_SYSTEMS = 1000;
private int mParticlesPerSystem = 100;
将ArrayList类导入如下:
import java.util.ArrayList;
现在我们可以跟踪多达 1000 个每个系统中有 100 个粒子的粒子系统。随意尝试调整这些数字。
注意
在现代设备上,您可以运行数百万个粒子而不会遇到任何问题,但在模拟器上,处理数十万个粒子就会有些吃力。
通过添加以下突出显示的代码在构造函数中初始化系统:
// Initialize the particles and their systems
for (int i = 0; i < MAX_SYSTEMS; i++) {
mParticleSystems.add(new ParticleSystem());
mParticleSystems.get(i).init(mParticlesPerSystem);
}
该代码循环遍历ArrayList实例,对每个ParticleSystem实例调用构造函数,然后调用init方法。
通过在update方法中添加以下突出显示的代码,为循环的每一帧更新系统:
private void update() {
// Update the particles
for (int i = 0; i < mParticleSystems.size(); i++) {
if (mParticleSystems.get(i).mIsRunning) {
mParticleSystems.get(i).update(mFPS);
}
}
}
前面的代码循环遍历每个ParticleSystem实例,首先检查它们是否活动,然后调用update方法并传入当前的每秒帧数。
通过将此突出显示的代码添加到draw方法中,为循环的每一帧绘制系统:
// Choose a color to paint with
mPaint.setColor(Color.argb(255, 255, 255, 255));
// Choose the font size
mPaint.setTextSize(mFontSize);
// Draw the particle systems
for (int i = 0; i < mNextSystem; i++) {
mParticleSystems.get(i).draw(mCanvas, mPaint);
}
// Draw the buttons
mCanvas.drawRect(mResetButton, mPaint);
mCanvas.drawRect(mTogglePauseButton, mPaint);
前面的代码循环遍历mParticleSystems,对每个调用draw方法。当然,我们实际上还没有生成任何实例。为此,我们需要学习如何响应屏幕交互。
处理触摸
要开始,请将OnTouchEvent方法添加到LiveDrawingView类中:
@Override
public boolean onTouchEvent(MotionEvent motionEvent) {
return true;
}
这是一个重写的方法,每当用户与屏幕交互时,Android 都会调用它。查看OnTouchEvent方法的唯一参数。
使用以下代码行导入MotionEvent类:
import android.view.MotionEvent;
原来motionEvent中隐藏了大量数据,这些数据包含了刚刚发生的触摸的详细信息。操作系统将其发送给我们,因为它知道我们可能需要其中的一些数据。
请注意,我说的是一些。MotionEvent类非常庞大。它包含了数十种方法和变量。
注意
在这个项目中,我们将揭示MotionEvent类的一些细节。您可以在这里完整地探索MotionEvent类:stuff.mit.edu/afs/sipb/project/android/docs/reference/android/view/MotionEvent.html。请注意,完成此项目并不需要进行进一步的研究。
目前,我们只需要知道在玩家的手指在屏幕上移动、触摸屏幕或从屏幕上移开时的精确时刻的屏幕坐标。
我们将使用motionEvent中包含的一些变量和方法,包括以下内容。
-
getAction方法,意料之中地“获取”执行的动作。不幸的是,它以稍微编码的格式提供这些信息,这解释了其他一些变量的必要性。 -
ACTION_MASK变量提供一个称为掩码的值,借助一些更多的 Java 技巧,可以用来过滤getAction的数据。 -
ACTION_UP变量,我们可以使用它来比较并查看执行的动作是否是我们想要响应的动作(从屏幕上移开手指)。 -
ACTION_DOWN变量,我们可以使用它来比较并查看执行的动作是否是我们想要响应的动作。 -
ACTION_MOVE变量,我们可以用它来比较并查看执行的动作是否是移动/拖动。 -
getX方法告诉我们事件发生的水平浮点坐标。 -
getY方法告诉我们事件发生的垂直浮点坐标。
举个具体的例子,假设我们需要使用ACTION_MASK过滤getAction方法返回的数据,并查看结果是否与ACTION_UP相同。如果是,那么我们知道用户刚刚从屏幕上移开手指,也许是因为他们刚刚点击了一个按钮。一旦我们确定事件是正确类型的,我们就需要使用getX和getY方法找出事件发生的位置。
最后一个复杂之处在于,“Java 诡计”我所指的是&位运算符,不要与我们一直与if关键字一起使用的逻辑&&运算符混淆。
&位运算符检查两个值中的每个对应部分是否为真。这是在使用ACTION_MASK与getAction时所需的过滤器。
注意
理智检查。我不愿详细讨论MotionEvent和位运算符。完全可以完成整本书甚至一个专业质量的交互式应用程序,而不需要完全理解它们。如果你知道我们在下一节中写的代码行确定了玩家刚刚触发的事件类型,那就足够了。我只是猜想像你这样挑剔的读者可能想要了解其中的细节。总之,如果你理解位运算符,很好,你可以继续。如果你不理解,没关系,你仍然可以继续。如果你对位运算符感兴趣(有很多种),你可以在这里阅读更多关于它们的信息:en.wikipedia.org/wiki/Bitwise_operation。
现在我们可以编写onTouchEvent方法并查看所有MotionEvent的操作。
编写onTouchEvent方法
通过在onTouchEvent方法中添加以下突出显示的代码来处理用户在屏幕上移动手指:
// User moved a finger while touching screen
if ((motionEvent.getAction() &
MotionEvent.ACTION_MASK)
== MotionEvent.ACTION_MOVE) {
mParticleSystems.get(mNextSystem).emitParticles(
new PointF(motionEvent.getX(),
motionEvent.getY()));
mNextSystem++;
if (mNextSystem == MAX_SYSTEMS) {
mNextSystem = 0;
}
}
return true;
添加以下代码行以导入PointF类:
import android.graphics.PointF;
if条件检查事件类型是否是用户移动手指。如果是,那么mParticleSystems中的下一个粒子系统将调用其emitParticles方法。之后,mNextSystem变量递增,并进行测试以查看是否是最后一个粒子系统。如果是,那么mNextSystem将被设置为零,准备在下次需要时重新使用现有的粒子系统。
通过在我们刚讨论过的代码之后并在我们已经编写的return语句之前添加以下突出显示的代码来处理用户按下按钮之一:
// Did the user touch the screen
if ((motionEvent.getAction() &
MotionEvent.ACTION_MASK)
== MotionEvent.ACTION_DOWN) {
// User pressed the screen see if it was in a
button
if (mResetButton.contains(motionEvent.getX(),
motionEvent.getY())) {
// Clear the screen of all particles
mNextSystem = 0;
}
// User pressed the screen see if it was in a
button
if (mTogglePauseButton.contains
(motionEvent.getX(), motionEvent.getY())) {
mPaused = !mPaused;
}
}
return true;
if语句的条件检查用户是否点击了屏幕。如果是,那么RectF类的contains方法与getX和getY方法一起被用来检查这次按压是否在我们自定义按钮的范围内。如果按下重置按钮,所有的粒子都会消失,因为mNextSystem被设置为零。如果按下暂停按钮,那么mPaused的值将被切换,导致线程中的update方法停止/开始被调用。
完成 HUD
将以下突出显示的代码添加到printDebuggingText方法中:
// We will add more code here in the next chapter
mCanvas.drawText("Systems: " + mNextSystem,
10, mFontMargin + debugStart + debugSize * 2,
mPaint);
mCanvas.drawText("Particles: " + mNextSystem * mParticlesPerSystem,
10, mFontMargin + debugStart + debugSize * 3,
mPaint);
这段代码将在屏幕上打印一些有趣的统计数据,告诉我们当前绘制了多少粒子和系统。
警告
这个应用程序会产生明亮的闪烁颜色。这可能会引起光敏性癫痫的人感到不适或发作。请谨慎阅读。您可能只想阅读这个项目的理论,而不运行已完成的项目。
运行应用程序
现在我们可以看到实时绘图应用程序的运行并尝试一些我们在代码中注释掉的不同选项。
使用小型、圆形、彩色、快速粒子运行应用程序。只需在屏幕上轻点几下:
图 22.2 – 点击屏幕
然后恢复绘图:
图 22.3 – 点击结果
使用小型、白色、方形、缓慢、长时间的粒子进行儿童风格的绘图:
图 22.4 – 儿童风格的绘图
然后取消绘图暂停,等待 20 秒,直到绘图活跃起来并发生变化:
图 22.5 – 儿童风格的绘图结果
在我们进行下一个项目之前,Live Drawing 应用程序为我们提供了一个很好的机会,可以探索 Android Studio 的另一个功能。
Android Studio Profiler 工具
Android Studio Profiler 工具非常复杂和深入。但是,使用它来进行一些真正重要的测量非常简单。我们可以看到我们的应用程序使用了设备资源的多少,因此可以尝试提高应用程序的效率,使其运行更高效,并且使用更少的资源。资源包括 CPU 和内存使用率。
代码优化超出了本书的范围,但是我们开始监视应用程序性能的方式是一个很好的介绍。从主 Android Studio 菜单中选择View,然后选择Tool Windows | Profiler。
您将在 Android Studio 的下部区域看到以下窗口:
图 22.6 – Android Studio 窗口
要开始使用 Profiler 工具,请运行 Live Drawing 应用程序。Profiler 工具应该开始显示图表和数据,如下图所示。
根据您的 PC 防火墙软件的配置,您可能需要允许 Profiler 工具运行。此外,您可能需要在Profiler窗口左上角的**+**图标上单击,然后选择您的 AVD,以便 Profiler 工具连接到:
图 22.7 – 实时图表数据
在上图中,我们可以看到 CPU 使用率、内存使用率、网络使用率和能量/电池使用率的实时图表数据。我们将重点关注 CPU 和内存使用率。
将鼠标悬停在CPU行,然后悬停在MEMORY行上,以查看每个指标的弹出详细信息。下图显示了我 PC 上这两个指标的详细信息,经过了 Photoshop 处理:
图 22.8 – 每个指标的弹出详细信息
您可能会看到与我不同的值。前面的图表显示大约四分之一的 CPU 正在使用,大约使用了 121MB 的 RAM。
接下来,让我们稍微修改我们的代码并观察效果。在LiveDrawingView类中,编辑mParticlesPerSystem成员变量的初始化:
private int mParticlesPerSystem = 100;
将其更改为:
private int mParticlesPerSystem = 1000;
我们现在将每个系统的粒子数量增加了 10 倍。我们这样做是为了在分析器数据中获得一个峰值,因为我们现在将使用应用程序来绘制一些粒子系统。
当您再次运行应用程序时,通过在屏幕上移动手指/指针来绘制大量的粒子系统。请注意,当您在屏幕上绘制一些粒子系统时,CPU 使用率会急剧上升,尽管可能没有您预期的那么多。当粒子移动时,我的 CPU 使用率急剧上升到接近 40%,然后回落到 25%以上。如果您以前从未使用过类似分析器的工具,更令人惊讶的是内存使用几乎没有变化。
我们得到这样的结果的原因是,成千上万个粒子的计算占用了相当大量的 CPU。然而,在屏幕上绘制粒子并不需要增加内存。原因在于应用程序的内存都是在执行开始时分配的。无论粒子当前是否显示给用户都不重要。
这一小节并不打算深入探讨如何优化我们的图形或 CPU 密集型应用程序;它只是想介绍一下,您可能希望将优化添加到您进一步调查的事项列表中。
总结
在本章中,我们看到了如何向我们的实时系统添加成千上万个独立的实体。这些实体由ParticleSystem类控制,而ParticleSystem类又与游戏循环进行交互和控制。由于游戏循环在一个线程中运行,我们看到用户仍然可以无缝地与屏幕进行交互,操作系统通过onTouchEvent方法向我们发送这些交互的详细信息。
在下一章中,当我们探讨如何播放音效时,我们的应用程序最终会变得有些喧闹;我们还将学习如何检测不同版本的安卓系统。
第二十三章:支持不同版本的 Android,声音效果和 Spinner 小部件
在本章中,我们将学习如何检测和处理不同版本的 Android。然后,我们将能够研究SoundPool类以及根据应用程序运行的 Android 设备的 Android 版本使用它的不同方式。在这一点上,我们可以将我们学到的一切都投入到制作一个很酷的声音演示应用程序中,这也将向我们介绍一个新的 UI 小部件Spinner。
总之,在本章中,我们将涵盖以下内容:
-
学习如何处理不同版本的 Android
-
学习如何使用 Android 的
SoundPool类 -
编写一个基于声音的应用程序,同时使用
SpinnerView小部件
让我们开始吧。
处理不同版本的 Android
在本书的大部分时间里,我们没有注意支持旧的 Android 设备,主要原因是我们一直在使用的 API 的最新部分在如此高比例的设备上运行(超过 99%),以至于似乎不值得。除非你打算在古老的 Android 遗物应用中开辟一个利基,否则这似乎是一个明智的做法。然而,关于播放声音,Android API 最近进行了一些相对较新的修改。
实际上,这并不是一个立即的大问题,因为比这更新的设备仍然可以使用 API 的旧部分。但是,特别处理这些兼容性差异是一个很好的做法,因为最终,有一天,旧的部分可能在新版本的 Android 上不起作用。
在此时此地讨论这个的主要原因是,Android Lollipop 之前和之后的声音处理中的轻微差异给了我们一个很好的借口,让我们看看我们如何在我们的代码中处理这样的事情。
我们将看看如何使我们的应用与最新的设备和早期的 Lollipop 设备兼容。
我们将使用的类来发出一些声音是SoundPool类。首先,让我们看一些检测当前 Android 版本的简单代码。
检测当前的 Android 版本
我们可以使用Build.Version类的静态变量来确定当前的 Android 版本,并且我们可以通过将其与该版本的适当Build.VERSION_CODES变量进行比较来确定它是否比特定版本更新。如果这个解释有点啰嗦,那就看看我们如何确定当前版本是否等于或更新(更大)于 Lollipop:
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
// Lollipop or newer code goes here
} else {
// Code for devices older than lollipop here
}
现在让我们看看如何在比 Lollipop 更新的 Android 设备和比 Lollipop 更老的 Android 设备上发出一些声音。
SoundPool 类
SoundPool类允许我们保存和操作一系列音效 - 简而言之,一组声音。该类处理从解压声音文件(如.wav或.ogg)到通过整数 ID 保持标识引用,当然还包括播放声音的一切。当播放声音时,它是以非阻塞的方式进行的(在幕后使用线程),不会干扰我们的应用程序的流畅运行或用户与之的交互。
我们需要做的第一件事是将音效添加到游戏项目的main文件夹中名为assets的文件夹中。我们很快就会真正做到这一点。
接下来,在我们的 Java 代码中,声明一个SoundPool类型的对象和一个用作我们打算使用的每个音效的 ID 的int。我们还声明另一个名为nowPlaying的int,我们可以用它来跟踪当前正在播放的声音,我们很快就会看到我们是如何做到这一点的:
// create an ID
SoundPool sp;
int nowPlaying =-1;
int repeats = 2;
int idFX1 = -1;
float volume = 1;// Volumes rage from 0 through 1
现在我们将看一下我们初始化SoundPool的两种不同方式,这取决于设备使用的 Android 版本。这是使用我们编写不同版本的 Android 代码的绝佳机会。
以新方式初始化 SoundPool
新的方法涉及我们使用AudioAttributes对象来设置我们想要的声音池的属性。
在第一个代码块中,我们使用链接并在一个对象上调用四个单独的方法来初始化我们的AudioAttributes对象(audioAttributes):
// Instantiate a SoundPool dependent on Android version
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
// The new way
// Build an AudioAttributes object
AudioAttributes audioAttributes =
// First method call
new AudioAttributes.Builder()
// Second method call
.setUsage
(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION)
// Third method call
.setContentType
(AudioAttributes.CONTENT_TYPE_SONIFICATION)
// Fourth method call
.build();// Yay! A semicolon
// Initialize the SoundPool
sp = new SoundPool.Builder()
.setMaxStreams(5)
.setAudioAttributes(audioAttributes)
.build();
}
在代码中,我们使用链接和这个类的Builder方法来初始化一个AudioAttributes对象,让它知道它将用于与USAGE_ASSISTANCE_SONIFICATION进行 UI 交互。
我们还使用CONTENT_TYPE_SONIFICATION值,让该类知道它是用于响应声音,例如用户按钮点击,碰撞或类似情况。
现在我们可以通过传入AudioAttributes对象(audioAttributes)和我们可能想要播放的最大同时声音数量来初始化SoundPool(sp)本身。
第二个代码块将另外四个方法链接到初始化sp,包括调用setAudioAttributes,该方法使用我们在之前链接方法块中初始化的audioAttributes对象。
现在我们可以编写一个else代码块,其中当然包含了旧方法的代码。
以旧方式初始化 SoundPool
不需要AudioAttributes对象;只需通过传入同时播放的声音数量来初始化SoundPool(sp)。最后一个参数是声音质量,传入0就足够了。这比新方法简单得多,但在我们可以做出的选择方面也不够灵活:
else {
// The old way
sp = new SoundPool(5, AudioManager.STREAM_MUSIC, 0);
}
注意
我们可以使用旧方法,较新版本的 Android 也可以处理它。但是,我们会收到有关使用已弃用方法的警告。这就是官方文档对此的说法。
此外,新方法提供了更多功能,正如我们所看到的。无论如何,这是一个很好的借口来查看处理不同版本 Android 的一些简单代码。
现在我们可以加载(解压缩)声音文件到我们的SoundPool中。
加载声音文件到内存
与我们的线程控制一样,我们需要将我们的代码包装在try-catch块中。这是有道理的,因为读取文件可能因我们无法控制的原因而失败,而且我们被迫这样做是因为我们使用的方法会抛出异常,否则我们编写的代码将无法编译。
在try块中,我们声明并初始化了AssetManager和AssetFileDescriptor类型的对象。
通过使用AssetManager对象的openFd方法初始化AssetFileDescriptor实例,该方法实际上解压缩声音文件。然后我们初始化我们的 ID(idFX1),同时将AssetFileDescriptor的内容加载到我们的SoundPool中。
catch块只是向控制台输出一条消息,让我们知道是否出了什么问题。请注意,无论 Android 版本如何,此代码都是相同的:
try{
// Create objects of the 2 required classes
AssetManager assetManager = this.getAssets();
AssetFileDescriptor descriptor;
// Load our fx in memory ready for use
descriptor = assetManager.openFd("fx1.ogg");
idFX1 = sp.load(descriptor, 0);
}catch(IOException e){
// Print an error message to the console
Log.d("error", "failed to load sound files");
}
我们准备制造一些噪音。
播放声音
在这一点上,我们的SoundPool实例中有一个音效,并且我们有一个 ID 可以用来引用它。
无论我们如何构建SoundPool对象,这段代码都是相同的,这就是我们播放声音的方式。请注意,在下一行代码中,我们使用相同方法的返回值来初始化nowPlaying变量。
因此,以下代码同时播放声音并将正在播放的 ID 的值加载到nowPlaying中:
nowPlaying = sp.play(idFX1, volume, volume, 0, repeats, 1);
注意
不需要将 ID 存储在nowPlaying中来播放声音,但正如我们将要看到的那样,它也有其用途。
play方法的参数如下:
-
音效的 ID
-
左扬声器音量和右扬声器音量
-
相对于其他声音的优先级
-
重复播放声音的次数
-
它播放的速率/速度(1 是正常速率)
在我们制作声音演示应用之前,还有一件事。
停止声音
使用stop方法在声音仍在播放时停止声音也非常简单。请注意,可能在任何给定时间有多个音效正在播放,因此stop方法需要声音效果的 ID 来停止:
sp.stop(nowPlaying);
当您调用play时,如果要跟踪当前播放的声音,只需存储其 ID,以便以后可以与其交互。很快我们将看到,在 Pong 游戏中播放声音的代码看起来更像这样:
sp.play(mBeepID, 1, 1, 0, 0, 1);
上一行代码将简单地以最大音量播放所选的声音(mBeepID),具有最低的优先级,直到以正常速度结束且不重复。
现在我们可以制作声音演示应用程序。
声音演示应用程序介绍 Spinner 小部件
当然,谈到声音效果,我们需要一些实际的声音文件。您可以使用 Bfxr 制作自己的声音效果(下面解释),也可以使用提供的声音效果。该应用程序的声音效果位于第二十三章/Sound Demo文件夹中的下载包的assets文件夹中。但您可能想制作自己的声音效果。
制作声音效果
有一个名为 Bfxr 的开源应用程序,可以让我们制作自己的声音效果。以下是使用 Bfxr 制作自己的声音效果的快速指南。从www.bfxr.net免费获取一份副本。
注意
请注意,声音演示应用程序的声音效果已经在第二十三章/assets文件夹中提供给您。除非您愿意,否则您无需创建自己的声音效果。学习如何使用它仍然是值得的。
按照网站上的简单说明进行设置。尝试其中一些内容以制作酷炫的声音效果:
注意
这是一个非常简化的教程。您可以使用 Bfxr 做更多事情。要了解更多,请阅读上一个 URL 网站上的提示。如果您在下载 Bfxr 时遇到问题,可以使用该网站创建所有您的声音效果,或者只使用提供的示例。
- 运行 Bfxr:
图 23.1 – Bfxr 页面
- 尝试所有生成该类型随机声音的预设类型。当您有接近所需声音时,继续下一步:
图 23.2 – 不同效果
- 使用滑块微调音调、持续时间和其他方面的新声音:
图 23.3 – 调整您的声音
- 通过单击
.wav按钮保存您的声音:
图 23.4 – 导出 Wav 按钮
-
Android 与 OGG 格式的声音非常兼容,因此在要求命名文件时,请在文件名末尾使用
.ogg扩展名。 -
重复步骤 2至5,创建三个酷炫的音效。将它们命名为
fx1.ogg,fx2.ogg和fx3.ogg。我们使用.ogg文件格式,因为它比 WAV 等格式更压缩。
一旦您的声音文件准备好,我们就可以继续进行应用程序。
布置声音演示
我将更简洁地描述我们正在适应的项目的部分,比以前的项目更简洁。但是,每当有新概念时,我一定会详细解释。我想现在你应该可以轻松地将一些小部件拖放到ConstraintLayout上并更改它们的text属性。
完成以下步骤。如果您遇到任何问题,可以在下载包的第二十三章文件夹中复制或查看代码:
-
创建一个新项目,命名为
Sound Demo,从activity_main.xml文件中选择TextView。 -
现在我们将在
activity_main.xml文件中构建布局,所以请确保处于设计视图中。按照从上到下,然后从左到右的顺序,拖放text属性,如下一张截图所示:
图 23.5 – 在 activity_main.xml 文件中构建布局
-
单击推断约束按钮。
-
使用以下表格设置它们的属性:
-
接下来,在 values 文件夹中的 strings.xml 文件中添加以下突出显示的代码。我们在上一步中使用了名为 spinner_options 的字符串资源数组作为上一步中 options 属性的选项。它将表示可以从我们的 Spinner 小部件中选择的选项:
<resources>
<string name="app_name">Sound Demo</string>
<string name="hello_world">Hello world!</string>
<string-array name="spinner_options">
<item>0</item>
<item>1</item>
<item>3</item>
<item>5</item>
<item>10</item>
</string-array>
</resources>
现在运行应用程序,最初您将看不到任何我们以前没有看到的东西。但是,如果您点击下拉列表,您将看到我们称为 spinner_options 的字符串数组中的选项。我们可以使用下拉列表来控制播放音效时音效重复的次数:
图 23.6 - 下拉列表选项
让我们编写 Java 代码使这个应用程序工作,包括我们如何与我们的下拉列表交互。
使用操作系统的文件浏览器,转到项目的 app\src\main 文件夹,并添加一个名为 assets 的新文件夹。
在下载包的/assets 文件夹中有三个预先准备好的音频文件第二十三章。将这三个文件放入您刚创建的 assets 目录中,或者使用您自己创建的文件。重要的是它们的文件名是 fx1.ogg,fx2.ogg 和 fx3.ogg。
编写声音演示
首先,我们将更改类声明,以便我们可以有效地处理所有小部件的交互。编辑声明以实现 View.OnClickListener,如下所示:
public class MainActivity extends AppCompatActivity
implements View.OnClickListener {
我们将很快添加所需的 onClick 方法。
现在,我们为我们的 SoundPool、音效 ID、nowPlaying int(如前所述),还将添加一个 float 来保存音量值,介于 0(静音)和 1(相对于设备当前音量的最大音量)之间。我们还将添加一个 int 叫做 repeats,它不出所料地保存我们将重复播放给定音效的次数的值:
SoundPool sp;
int idFX1 = -1;
int idFX2 = -1;
int idFX3 = -1;
int nowPlaying = -1;
float volume = .1f;
int repeats = 2;
现在,在 onCreate 方法中,我们可以以通常的方式获取引用并为我们的按钮设置点击侦听器。将以下代码添加到项目中:
Button buttonFX1 = findViewById(R.id.btnFX1);
buttonFX1.setOnClickListener(this);
Button buttonFX2 = findViewById(R.id.btnFX2);
buttonFX2.setOnClickListener(this);
Button buttonFX3 = findViewById(R.id.btnFX3);
buttonFX3.setOnClickListener(this);
Button buttonStop = findViewById(R.id.btnStop);
buttonStop.setOnClickListener(this);
仍然在 onCreate 中,我们可以根据设备使用的 Android 版本初始化我们的 SoundPool(sp)。将以下代码添加到项目中:
// Instantiate our SoundPool based on the version of Android
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
AudioAttributes audioAttributes =
new AudioAttributes.Builder()
.setUsage(AudioAttributes.
USAGE_ASSISTANCE_SONIFICATION)
.setContentType(
AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build();
sp = new SoundPool.Builder()
.setMaxStreams(5)
.setAudioAttributes(audioAttributes)
.build();
} else {
sp = new SoundPool(5, AudioManager.STREAM_MUSIC, 0);
}
注意
使用您喜欢的方法为前面的代码添加以下 import 语句:
导入 android.media.AudioAttributes;
导入 android.media.AudioManager;
导入 android.media.SoundPool;
导入 android.os.Build;
导入 android.view.View;
导入 android.widget.Button;
接下来,依次加载我们的每个音效,并使用一个值来初始化我们的 ID,该值指向我们加载到 SoundPool 中的相关音效。整个过程都包裹在 try-catch 块中。将以下代码添加到项目中:
try{
// Create objects of the 2 required classes
AssetManager assetManager = this.getAssets();
AssetFileDescriptor descriptor;
// Load our fx in memory ready for use
descriptor = assetManager.openFd("fx1.ogg");
idFX1 = sp.load(descriptor, 0);
descriptor = assetManager.openFd("fx2.ogg");
idFX2 = sp.load(descriptor, 0);
descriptor = assetManager.openFd("fx3.ogg");
idFX3 = sp.load(descriptor, 0);
}catch(IOException e){
// Print an error message to the console
Log.e("error", "failed to load sound files");
}
注意
使用您喜欢的方法为前面的代码添加以下 import 语句:
导入 android.content.res.AssetFileDescriptor;
导入 android.content.res.AssetManager;
导入 android.util.Log;
导入 java.io.IOException;
然后我们看看我们将如何处理 SeekBar 小部件。正如您可能已经期待的那样,我们将使用一个匿名类。我们使用 OnSeekBarChangeListener 类并重写 onProgressChanged,onStartTrackingTouch 和 onStopTrackingTouch 方法。
我们只需要在 onProgressChanged 方法中添加代码。在这个方法中,我们只是改变我们的音量变量的值,然后在我们的 SoundPool 对象上使用 setVolume 方法,传入当前播放的音效和左右声道的音量。将以下代码添加到项目中:
// Now setup the seekbar
SeekBar seekBar = findViewById(R.id.seekBar);
seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
@Override
public void onProgressChanged(SeekBar seekBar,
int value, boolean fromUser) {
volume = value / 10f;
sp.setVolume(nowPlaying, volume, volume);
}
@Override
public void onStartTrackingTouch(SeekBar seekBar) {
}
@Override
public void onStopTrackingTouch(SeekBar seekBar) {
}
});
注意
使用您喜欢的方法为前面的代码添加以下 import 语句:
导入 android.widget.SeekBar;
在 SeekBar 代码之后是 Spinner 代码和另一个匿名类来处理用户交互。我们使用 AdapterView.OnItemSelectedListener 来重写 onItemSelected 和 onNothingSelected 方法。
我们所有的代码都放在onItemSelected方法中,该方法创建一个名为temp的临时字符串,然后使用Integer.ValueOf方法将字符串转换为int,我们可以用它来初始化repeats变量。将以下代码添加到项目中:
// Now for the spinner
final Spinner spinner = (Spinner) findViewById(R.id.spinner);
spinner.setOnItemSelectedListener(
new AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(AdapterView<?>
parentView, View selectedItemView,
int position, long id) {
String temp = String.valueOf(
spinner.getSelectedItem());
repeats = Integer.valueOf(temp);
}
@Override
public void onNothingSelected(AdapterView<?> parentView) {
}
});
注意
使用您喜欢的方法为以前的代码添加以下import语句:
导入 android.widget.AdapterView;
导入 android.widget.Spinner;
这就是onCreate的全部内容。
现在实现onClick方法,这是必需的,因为这个类实现了View.OnClickListener接口。简单地说,每个按钮都有一个case语句。有一个case语句来播放我们的三种声音效果,设置音量和设置重复次数。请注意,对play的每次调用的返回值都存储在nowPlaying中。当用户按下stop时,nowPlaying的当前值会导致最近启动的声音效果停止。将以下代码添加到项目中:
@Override
public void onClick(View v) {
switch (v.getId()){
case R.id.btnFX1:
sp.stop(nowPlaying);
nowPlaying = sp.play(idFX1, volume,
volume, 0, repeats, 1);
break;
case R.id.btnFX2:
sp.stop(nowPlaying);
nowPlaying = sp.play(idFX2,
volume, volume, 0, repeats, 1);
break;
case R.id.btnFX3:
sp.stop(nowPlaying);
nowPlaying = sp.play(idFX3,
volume, volume, 0, repeats, 1);
break;
case R.id.btnStop:
sp.stop(nowPlaying);
break;
}
}
现在我们可以运行应用程序。如果听不到任何声音,请确保设备的音量已调高。
点击适当的按钮以播放所需的声音效果。更改音量和重复次数,当然,尝试使用STOP按钮停止它。
还要注意,当一个声音效果正在播放时,您可以重复点击多个播放按钮,声音将同时播放,直到我们设置的最大流数(5)。
总结
在本章中,我们仔细研究了SoundPool类,包括如何检测用户使用的 Android 版本并相应地改变我们的代码。然后,我们利用所有这些知识完成了声音演示应用程序。
在下一章中,我们将学习如何使我们的应用程序与多种不同的布局配合工作。
第二十四章:设计模式、多种布局和片段
从我们刚开始设置 Android Studio 的时候,我们已经走了很长的路。那时,我们一步一步地进行了所有操作,但随着我们的进展,我们试图展示的不仅仅是如何将 x 添加到 y 或将功能 a 添加到应用程序 b,而是让你能够以自己的方式使用所学到的知识来实现自己的想法。
乍一看,这一章可能看起来枯燥而技术性,但这一章更多地关乎你未来的应用程序,而不是迄今为止书中的任何内容。我们将看一下 Java 和 Android 的一些方面,你可以将其用作框架或模板,以制作更加令人兴奋和复杂的应用程序,同时保持代码的可管理性。这是成功现代应用程序的关键。此外,我将建议进一步学习的领域,这本书中根本没有足够的空间来涉及。
在这一章中,我们将学习以下内容:
-
模式和模型-视图-控制器
-
Android 设计指南
-
开始使用真实世界的设计和处理多种不同的设备
-
片段简介
让我们开始吧。
技术要求
你可以在 GitHub 上找到本章的代码文件github.com/PacktPublishing/Android-Programming-for-Beginners-Third-Edition/tree/main/chapter%2024。
介绍模型-视图-控制器模式
模型-视图-控制器模式涉及将我们应用程序的不同方面分离成称为层的明确部分。Android 应用程序通常使用模型-视图-控制器模式。模式只是一种公认的结构代码和其他应用资源(如布局文件、图像、数据库等)的方式。
模式对我们很有用,因为通过遵循模式,我们可以更有信心地做正确的事情,也不太可能因为将自己编码到尴尬的境地而不得不撤销大量的辛苦工作。
在计算机科学中有许多模式,但了解模型-视图-控制器(MVC)就足以创建一些专业构建的 Android 应用程序。
我们已经部分地使用了 MVC,所以让我们依次看看这三个层:
-
Note类以及它的 getter、setter 和 JSON 代码是数据和逻辑。 -
Android API 的
View类层次结构。 -
控制器:控制器是视图和模型之间的部分。它与两者进行交互并保持它们分开。它包含了所谓的应用逻辑。如果用户点击按钮,应用层决定如何处理它。当用户点击“确定”以添加新的注释时,应用层监听视图层上的交互。它捕获视图中包含的数据并将其传递给模型层。
注
设计模式是一个庞大的主题。有许多不同的设计模式,如果你想对这个主题有一个适合初学者的介绍,我会推荐《Head First Design Patterns》。如果你想深入了解设计模式的世界,那么你可以尝试《Design Patterns: Elements of Reusable Object-Oriented Software》,这本书被认为是一种设计模式的权威,但阅读起来要困难得多。
随着书籍的进展,我们还将开始更多地利用我们已经讨论过但尚未充分利用的面向对象编程方面。我们将一步一步地做到这一点。
Android 设计指南
应用程序设计是一个广阔的主题。这是一个只能在一本专门的书中开始教授的主题。而且,就像编程一样,只有通过不断的练习、审查和改进,你才能开始擅长应用程序设计。
那么,我所说的设计到底是什么意思呢?我指的是您将小部件放在屏幕上的位置,使用哪些小部件,它们应该是什么颜色,大小应该是多少,如何在屏幕之间进行过渡,滚动页面的最佳方式,何时以及使用哪些动画插值器,您的应用程序应该分成哪些屏幕,以及还有更多其他内容。
希望本书能让您有能力实施您对上述问题的所有选择。不幸的是,本书没有足够的空间,作者可能也没有足够的技能来教您如何做出这些选择。
注意
您可能会想,“我应该怎么办?”继续制作应用程序,不要让缺乏设计经验和知识阻止您!甚至将您的应用程序发布到应用商店。但请记住,还有这样一个完全不同的话题 - 设计 - 如果您的应用程序真的要成为世界一流的应用程序,那就需要一些关注。
即使在中等规模的开发公司中,设计师很少也是程序员,即使是非常小的公司也经常会外包他们的应用程序的设计(或设计师可能会外包编码)。
设计既是艺术又是科学,Google 已经证明他们认识到这一点,为现有设计师和新设计师提供了高质量的支持。
注意
我强烈建议您访问并收藏这个网页:developer.android.com/design/。它非常详细和全面,完全专注于 Android,并提供了大量的资源,包括图像、调色板和指南。
使理解设计原则成为短期目标。使提高您的实际设计技能成为一项持续的任务。访问并阅读以设计为重点的网站,并尝试实施您发现令人兴奋的想法。
然而,最重要的是,不要等到您成为设计专家才开始制作应用程序。继续将您的想法付诸实践并发布它们。要求每个应用程序的设计都比上一个稍微好一点。
我们将在接下来的章节中看到,并且已经看到,Android API 为我们提供了一整套超时尚的 UI,我们可以利用这些 UI,只需很少的代码或设计技能。这些 UI 在很大程度上使您的应用程序看起来就像是由专业人员设计的。
现实世界的应用程序
到目前为止,我们已经构建了十几个或更多不同复杂度的应用程序。其中大部分我们都是在手机上设计和测试的。
当然,在现实世界中,我们的应用程序需要在任何设备上都能良好运行,并且必须能够处理在纵向或横向视图(在所有设备上)发生的情况。
此外,我们的应用程序通常不能只是在不同设备上“正常工作”和“看起来还行”就足够了。通常,我们的应用程序需要根据设备是手机还是平板电脑,以及是横向还是纵向方向,而表现出明显不同的 UI 和行为。
注意
Android 支持大屏幕电视、通过 Wear API 支持智能手表、虚拟现实和增强现实,以及物联网中的“物品”。本书不涵盖后两种情况,但在本书结束时,作者猜测您将准备好涉足这些话题,如果您选择的话。
看看 BBC 天气应用程序在 Android 手机上以纵向方向运行的截图。看看基本布局,也研究所显示的信息,因为我们将很快将其与平板应用程序进行比较:
图 24.1 - BBC 天气应用程序在 Android 手机上以纵向方向运行
目前,上一张截图的目的并不是为了向您展示具体的 UI 功能,而是为了让您能够将其与下一张截图进行比较。看看在平板电脑上以横向方向运行的完全相同的应用程序:
图 24.2 – BBC 天气应用在 Android 手机上横向方向运行
请注意,与手机应用程序相比,平板电脑 UI 有一个额外的信息面板。这个额外的面板在前面的截图中被突出显示。
这个截图的重点再次不是特定的 UI,甚至我们如何实现类似的 UI,而是 UI 是如此不同,它们很容易被认为是完全不同的应用程序。然而,如果您下载这个应用程序,平板电脑和手机是相同的下载。
Android 允许我们设计这样的真实应用程序,其中不仅布局针对不同的设备类型/方向/大小是不同的,而且(这一点很重要)行为也是不同的。使这一切成为可能的 Android 秘密武器是片段。
谷歌说:
“片段代表活动中的行为或用户界面的一部分。您可以在单个活动中组合多个片段,以构建多窗格 UI,并在多个活动中重用片段。
您可以将片段视为活动的模块化部分,它具有自己的生命周期,接收自己的输入事件,并且您可以在活动运行时添加或删除它(有点像可以在不同活动中重复使用的“子活动”)。
片段必须始终嵌入在活动中,片段的生命周期受主机活动的生命周期直接影响。
我们可以在不同的 XML 文件中设计多个不同的布局,我们很快就会这样做。我们还可以在我们的 Java 代码中检测设备方向和屏幕分辨率,因此我们可以动态地做出布局方面的决策。
让我们尝试使用设备检测,然后我们将首次查看片段。
设备检测迷你应用
了解检测和响应设备及其不同属性(屏幕、方向等)的最佳方法是制作一个简单的应用程序:
-
创建一个新的
设备检测。将所有其他设置保留为默认设置。 -
在设计选项卡中打开
activity_main.xml文件,并删除默认的TextView。 -
拖动
detectDevice。我们将在一分钟内编写这个方法。 -
拖动两个
txtOrientation和txtResolution。 -
检查您是否有一个看起来像下一个截图的布局:
注意
我拉伸了我的小部件(主要是水平方向),并增加了textSize属性到24sp,以使它们在屏幕上更清晰,但这对于应用程序的正确工作并不是必需的。
图 24.3– 布局检查
- 单击推断约束按钮以固定 UI 元素的位置。
现在我们将做一些新的事情。我们将专门为横向方向构建一个布局。
在 Android Studio 中,确保在编辑器中选择了activity_main.xml文件,并找到如下所示的预览方向按钮:
图 24.4 – 创建横向变化
单击它,然后选择创建横向变化。
现在,您有一个新的布局 XML 文件,名称相同,但是是横向模式。布局在编辑器中看起来是空白的,但正如我们将看到的那样,情况并非如此。查看项目资源管理器中的layout文件夹,并注意确实有两个名为activity_main的文件,其中一个(我们刚刚创建的新文件)带有land后缀。这在下一个截图中显示:
图 24.5 – activity_main 文件夹
选择这个新文件(带有land后缀的文件),现在查看组件树。它在下一个截图中显示:
图 24.6 – 组件树
看起来布局已经包含了我们所有的小部件 - 我们只是无法在设计视图中看到它们。这种异常的原因是,当我们创建横向布局时,Android Studio 复制了纵向布局,包括所有的约束。纵向约束很少与横向约束匹配。
要解决这个问题,点击删除所有约束按钮;它是推断约束按钮左侧的按钮。现在 UI 没有约束了。所有的 UI 小部件将会混乱地出现在左上角。一次一个,重新排列它们,使其看起来像下一个截图。我不得不手动添加约束来使这个设计工作,所以我在下一个截图中展示了约束:
图 24.7 - 添加约束
点击/Device Detection/layout-land文件夹。
注意
外观并不重要,只要您能看到两个TextView小部件的内容并点击按钮即可。
现在我们有了两种不同方向的基本布局,我们可以把注意力转向编写 Java 代码。
编写 MainActivity 类
在MainActivity类声明之后,添加以下成员变量,以保存对我们两个TextView小部件的引用:
private TextView txtOrientation;
private TextView txtResolution;
注意
此时导入TextView类:
import android.widget.TextView;
现在,在MainActivity类的onCreate方法中,在调用setContentView之后,添加以下代码:
// Get a reference to our TextView widgets
txtOrientation = findViewById(R.id.txtOrientation);
txtResolution = findViewById(R.id.txtResolution);
在onCreate之后,添加处理我们按钮点击并运行检测代码的方法:
public void detectDevice(View v){
// What is the orientation?
Display display =
getWindowManager().getDefaultDisplay();
txtOrientation.setText("" + display.getRotation());
// What is the resolution?
Point xy = new Point();
display.getSize(xy);
txtResolution.setText("x = " + xy.x + " y = " + xy.y);
}
注意
导入以下三个类:
import android.graphics.Point;
import android.view.Display;
import android.view.View;
这段代码通过声明和初始化一个名为display的Display类型的对象来工作。这个对象(display)现在保存了关于设备特定显示属性的大量数据。
getRotation方法的结果输出到顶部的TextView小部件中。
然后,代码初始化了一个名为xy的Point类型的对象。getSize方法然后将屏幕分辨率加载到xy中。然后使用setText方法将水平(xy.x)和垂直(xy.y)分辨率输出到TextView小部件中。
注意
您可能还记得我们在 Kids 绘画应用中使用了Display和Point类。
每次点击按钮,两个TextView小部件都将被更新。
解锁屏幕方向
在我们运行应用程序之前,我们要确保设备没有锁定在纵向模式(大多数新手机默认情况下是这样)。从模拟器的应用抽屉(或您将要使用的设备)中,点击设置应用程序,选择显示,并使用开关将自动旋转屏幕设置为开启。我在下一个截图中展示了这个设置:
图 24.8 - 自动旋转屏幕
运行应用程序
现在您可以运行应用程序并点击按钮:
图 24.9 - 点击按钮
使用模拟器控制面板上的旋转按钮之一,将设备旋转到横向:
图 24.10 - 旋转设备
注意
您还可以在 PC 上使用Ctrl + F11,在 Mac 上使用Ctrl + FN + F11。
现在再次点击按钮,您将看到横向布局的效果:
图 24.11 - 横向布局
您可能会注意到的第一件事是,当您旋转屏幕时,屏幕会短暂地变空白。这是 Activity 重新启动并再次调用onCreate方法 - 这正是我们需要的。它在横向布局上调用setContentView方法,而MainActivity中的代码引用具有相同 ID 的小部件,所以完全相同的代码可以工作。
注意
不要花太多时间思考这个问题,因为我们将在本章后面讨论它。
如果 0 和 1 的结果对设备方向不够明显,它们指的是Surface类的public static final变量 - Surface.ROTATION_0等于 0,Surface.ROTATION_180等于 1。
注意
请注意,如果您将屏幕向左旋转,那么您的值将是 1 - 与我的相同,但如果您将其向右旋转,您将看到值 3。如果您将设备旋转到倒置的纵向模式,您将获得值 4。
然后我们可以根据这些检测测试的结果编写一个switch块,并加载不同的布局。
但正如我们刚才看到的,Android 通过允许我们将特定布局添加到带有配置限定符的文件夹中(例如land,代表横向)来简化这一过程。
配置限定符
我们已经在第三章中遇到了配置限定符,例如layout-large和layout-xhdpi,探索 Android Studio 和项目结构。在这里,我们将刷新并扩展对它们的理解。
我们可以通过使用配置限定符开始减少对控制器层的影响来影响应用程序布局。有关大小、方向和像素密度的配置限定符。要利用配置限定符,我们只需按照通常的方式设计布局,针对我们首选的配置进行优化,然后将该布局放入 Android 识别为特定配置的文件夹中。
例如,在前一个应用程序中,将布局放入land文件夹会告诉 Android 在设备处于横向方向时使用布局。
上述声明可能会显得有些模糊。这是因为 Android Studio 的layout和layout-land文件夹如下所示:
图 24.12 - 布局和布局-横向文件夹
切换回Android视图或保持在项目文件视图上 - 任何您喜欢的。
因此,如果我们想要为横向和纵向创建不同的布局,我们将在res文件夹中创建一个名为layout-land的文件夹(或者使用我们在前一个应用程序中使用的快捷方式),并在其中放置我们专门设计的布局。
当设备处于纵向位置时,将使用layout文件夹中的常规布局,当设备处于横向位置时,将使用layout-land文件夹中的布局。
如果我们要为不同尺寸的屏幕设计,我们将布局放入以下名称的文件夹中:
-
layout-small -
layout-normal -
layout-large -
layout-xlarge
如果我们要为不同像素密度的屏幕设计,我们可以将 XML 布局放入文件夹中,文件夹的名称如下:
-
layout-ldpi用于低 DPI 设备 -
layout-mdpi用于中等 DPI 设备 -
layout-hdpi用于高 DPI 设备 -
layout-xhdpi用于超高 DPI 设备 -
layout-xxhdpi用于超超高 DPI 设备 -
layout-xxxhdpi用于超高 DPI 设备 -
layout-nodpi用于未另外适配 DPI 的设备 -
layout-tvdpi用于电视
低、高或超高 DPI 等的确切资格可以在下一个信息框中的链接中进行研究。这里的重点是原则。
值得指出的是,我们刚才讨论的远远不是关于配置限定符的全部故事,与设计一样,值得将其列入进一步研究的事项清单。
注意
通常,Android 开发者网站上有大量关于处理不同设备布局的详细信息。请尝试此链接以获取更多信息:developer.android.com/guide/practices/screens_support。
配置限定符的限制
前一个应用程序和我们对配置限定符的讨论在许多情况下确实非常有用。然而,不幸的是,配置限定符和在代码中检测属性只解决了我们 MVC 模式的视图层中的问题。
正如讨论的那样,我们的应用程序有时需要具有不同的行为以及布局。这可能意味着我们的 Java 代码在控制器层(在我们以前的大多数应用程序中为MainActivity)中有多个分支,并且可能召唤出对于每种不同情况都有不同代码的巨大的if或switch块的可怕愿景。
幸运的是,这不是这样做的方式。对于这种情况,实际上对于大多数应用程序,Android 都有片段。
片段
片段很可能会成为您制作的几乎每个应用程序的主打。它们非常有用,有很多使用它们的理由,一旦您习惯了它们,它们就变得非常简单,几乎没有理由不使用它们。
片段是应用程序的可重用元素,就像任何类一样,但正如之前提到的,它们具有特殊功能,例如能够加载它们自己的视图/布局以及它们自己的生命周期方法,这使它们非常适合实现我们在真实世界应用程序部分讨论的目标,并为不同的设备(如我们查看的天气应用程序)拥有不同的布局和代码。
让我们深入了解片段,一次一个特性。
片段也有生命周期
我们可以设置和控制片段,就像我们对活动所做的那样,覆盖适当的生命周期方法:
-
onCreate:在onCreate方法中,我们可以初始化变量并几乎可以做所有我们通常在Activity onCreate方法中做的事情。这个方法的一个重要例外是初始化我们的 UI。 -
onCreateView:在这个方法中,我们将像其名称所示一样,获取对我们的任何 UI 小部件的引用,设置匿名类以监听点击,以及更多其他内容,我们很快就会看到。 -
onAttach和onDetach:这些方法在将Fragment投入使用/停止使用之前调用。 -
onResume,onStart,onPause和onStop:在这些方法中,我们可以执行某些操作,例如创建或删除对象或保存数据,就像我们在基于它们的Activity中所做的那样。
注意
如果您想详细了解Fragment的生命周期,请访问 Android 开发者网站上的此链接:developer.android.com/guide/components/fragments。
这一切都很好,但我们需要一种方法来首先创建我们的片段,并能够在正确的时间调用这些方法。
使用 FragmentManager 管理片段
FragmentManager类是Activity的一部分。我们使用它来初始化Fragment,将片段添加到活动的布局中,并结束Fragment。我们之前在初始化FragmentDialog时简要看到了FragmentManager。
注意
在 Android 中学习很难避免碰到Fragment类,就像在学习 Java 时不断碰到 OOP/类一样困难,等等。
以下突出显示的代码显示了我们如何使用FragmentManager(它已经是Activity的一部分)作为参数来创建弹出对话框:
button.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
// Create a new DialogShowNote called dialog
DialogShowNote dialog = new DialogShowNote();
// Send the note via the sendNoteSelected method
dialog.sendNoteSelected(mTempNote);
// Create the dialog
dialog.show(getFragmentManager(), "123");
}
});
当时,我要求您不要关心方法调用的参数。方法调用的第二个参数是Fragment的 ID。我们将看到如何更广泛地使用FragmentManager,以及如何使用Fragment ID。
FragmentManager确实做到了其名称所暗示的。这里重要的是Activity只有一个FragmentManager,但它可以处理许多片段。这正是我们需要在单个应用程序中拥有多个行为和布局的情况。
FragmentManager 还调用其负责的片段的各种生命周期方法。这与 Android 调用的 Activity 生命周期方法是不同的,但又密切相关,因为 FragmentManager 调用许多 Fragment 生命周期方法是作为对 Activity 生命周期方法的响应。通常情况下,如果我们在每种情况下做出适当的响应,我们就不需要太担心何时以及如何做出响应。
注意
片段将成为我们未来许多,如果不是所有应用程序的基本部分。然而,就像我们对命名约定、字符串资源和封装性所做的那样,出于简单学习目的或在应用程序很小且使用片段会过度的情况下,我们将不使用片段。当然,学习片段时将是一个例外。
我们的第一个 Fragment 应用程序
让我们以尽可能简单的形式构建 Fragment,以便在后面的章节中,在我们开始在各个地方生成真正有用的 Fragment 之前,我们可以理解正在发生的事情。
注意
我敦促所有读者浏览并构建此项目。从一个文件跳到另一个文件,仅仅阅读可能会使它看起来比实际复杂得多。当然,你可以从下载包中复制粘贴代码,但也请按照步骤创建自己的项目和类。片段并不太难,但它们的实现,正如它们的名字所暗示的那样,有点分散。
使用空活动模板创建一个名为Simple Fragment的新项目,并将其余设置保持默认。
切换到activity_main.xml并删除默认的TextView小部件。
现在确保通过在 fragmentHolder 中左键单击它来选择根 ConstraintLayout。现在我们将能够在我们的 Java 代码中获取对此布局的引用,并且正如 id 属性所暗示的那样,我们将向其添加一个 Fragment。
现在我们将创建一个布局,该布局将定义我们片段的外观。右键单击 LinearLayout 中的 fragment_layout,然后左键单击 LinearLayout。
添加一个单独的按钮。
现在我们有一个简单的布局供我们的 Fragment 使用,让我们写一些 Java 代码来创建实际的片段。
注意
请注意,您可以通过从调色板中简单拖放来创建一个片段,但以这种方式做事情会更不灵活和可控,而灵活性和控制性是片段的重要优势,正如我们将在接下来的章节中看到的那样。通过创建一个扩展 Fragment 的类,我们可以从中制作任意多的片段。
在项目资源管理器中,右键单击包含MainActivity文件的文件夹。从上下文菜单中,选择SimpleFragment。
注意
请注意,有各种预编码状态的选项可快速实现 Fragment 类的创建,但现在它们可能会稍微模糊此应用程序的学习目标。
在我们的新SimpleFragment类中,更改代码以扩展 Fragment。在输入代码时,将要求您选择要导入的特定 Fragment 类,如下一张屏幕截图所示:
图 24.13 - 选择特定的 Fragment 类
选择顶部选项,即androidx.fragment.app。
注意
在这个类中,我们将需要以下所有的导入语句。之前的步骤已经添加了 androidx.fragment.app.Fragment 类:
导入 androidx.fragment.app.Fragment;
导入 android.os.Bundle;
导入 android.view.LayoutInflater;
导入 android.view.View;
导入 android.view.ViewGroup;
导入 android.widget.Button;
导入 android.widget.Toast;
现在添加一个名为myString的单个字符串变量和一个名为myButton的按钮变量作为成员。
现在重写onCreate方法。在onCreate方法内,将myString初始化为Hello from SimpleFragment。到目前为止我们的代码(不包括包声明和import语句)将会像下面的代码一样:
public class SimpleFragment extends Fragment {
// member variables accessible from anywhere in this
fragment
String myString;
Button myButton;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
myString = "Hello from SimpleFragment";
}
}
在前面的代码中,我们创建了一个名为myString的成员变量,然后在onCreate方法中初始化它。这非常像我们在之前的应用程序中只使用Activity时所做的事情。
然而,不同之处在于我们没有设置视图或尝试获取对我们的Button成员变量myButton的引用。
在使用Fragment时,我们需要在onCreateView方法中执行此操作。现在让我们重写一下,看看我们如何设置视图并获取对我们的Button小部件的引用。
在onCreate方法之后将此代码添加到SimpleFragment类中:
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup
container, Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.fragment_layout,
container, false);
myButton = view.findViewById(R.id.button);
return view;
}
要理解上一段代码,我们首先必须查看onCreateView方法的签名。首先,注意方法的开始声明必须返回一个View类型的对象:
public View onCreateView...
接下来,我们有三个参数。让我们先看前两个:
(LayoutInflater inflater, ViewGroup container,...
我们需要一个LayoutInflater引用,因为我们不能调用setContentView方法,因为Fragment类没有提供这样的方法。在onCreateView的主体中,我们使用inflater的inflate方法来充气我们包含在fragment_layout.xml中的布局,并用结果初始化view(一个View类型的对象)。
我们在inflate方法中也使用了传入onCreateView的container。container变量是对activity_main.xml中的布局的引用。
可能会觉得activity_main.xml是包含的布局,但正如我们将在本章后面看到的那样,ViewGroup container参数允许任何具有任何布局的Activity成为我们片段的容器。这是非常灵活的,使我们的Fragment代码在很大程度上可重用。
我们传递给inflate的第三个参数是false,这意味着我们不希望我们的布局立即添加到包含的布局中。我们很快将从代码的另一个部分自己做这个。
onCreateView的第三个参数是Bundle savedInstanceState,它可以帮助我们维护片段保存的数据。
现在我们有了包含在view中的充气布局,我们可以使用它来获取对我们的Button小部件的引用,就像这样:
myButton = view.findViewById(R.id.button);
并将view实例用作调用代码的返回值,如有需要:
return view;
现在我们可以添加一个匿名类来监听我们按钮上的点击,就像通常一样。在onClick方法中,我们显示一个弹出的Toast消息,以演示一切都按预期工作。
在onCreateView方法的return语句之前添加此代码,如下面的代码所示:
@Override
public View onCreateView(LayoutInflater inflater,
ViewGroup container,
Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.fragment_layout,
container, false);
myButton = view.findViewById(R.id.button);
myButton.setOnClickListener(
new View.OnClickListener() {
@Override
public void onClick(View v) {
Toast.makeText(getActivity(),
myString ,
Toast.LENGTH_SHORT).
show();
}
});
return view;
}
注意
作为提醒,getActivity()方法调用作为makeText中的参数,获取了包含Fragment的Activity的引用。这是显示Toast消息所需的。我们在 Note to Self 应用程序中的FragmentDialog类中也使用了getActivity方法。
我们现在还不能运行我们的应用程序;它不会工作,因为还需要一步。我们需要创建一个SimpleFragment类的实例并适当地初始化它。这就是FragmentManager类将被介绍的地方。
这段代码通过调用getSupportFragmentManager创建了一个新的FragmentManager。然后,代码根据我们的SimpleFragment类创建了一个新的Fragment,并使用FragmentManager传入了将容纳它的布局(在Activity内部)的 ID。
在MainActivity.java的onCreate方法中添加此代码,就在调用setContentView方法之后:
// Get a fragment manager
FragmentManager fManager = getSupportFragmentManager();
// Create a new fragment using the manager
// Passing in the id of the layout to hold it
Fragment frag = fManager.findFragmentById(R.id.fragmentHolder);
// Check the fragment has not already been initialized
if(frag == null){
// Initialize the fragment based on our SimpleFragment
frag = new SimpleFragment();
fManager.beginTransaction()
.add(R.id.fragmentHolder, frag)
.commit();
}
注意
您需要将以下import语句添加到MainActivity类中:
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import android.os.Bundle;
现在运行应用程序,惊叹于我们的可点击按钮,它使用Toast类显示消息,并且需要两个布局和两个完整的类来创建:
图 24.14 - 使用 Toast 类显示消息
如果你记得在*第二章**中实现了更多的功能,而且代码更少,那么很明显我们需要对 Fragment 进行现实检查,以充分理解为什么我们要这样做的答案!
Fragment 现实检查
那么,这个Fragment到底对我们有什么作用呢?我们的第一个Fragment迷你应用程序如果没有使用Fragment,外观和功能将是一样的。
事实上,使用Fragment类使整个事情变得更加复杂!我们为什么要这样做呢?
我们知道Fragment实例或片段可以添加到Activity的布局中。
我们知道Fragment不仅包含自己的布局(视图),还包含自己的代码(控制器),虽然由Activity托管,但Fragment实例几乎是独立的。
我们的快速应用程序只显示了一个Fragment实例在运行中,但我们可以有一个Activity来托管两个或更多的片段。然后,我们在单个屏幕上有效地有两个几乎独立的控制器。
然而,最有用的是,当Activity启动时,我们可以检测我们的应用程序运行的设备的属性 - 可能是手机或平板电脑;纵向或横向。然后,我们可以使用这些信息来决定同时显示一个或两个片段。
这不仅帮助我们实现本章开头讨论的真实应用程序部分中讨论的功能,而且还允许我们使用完全相同的Fragment代码来实现两种可能的情况!
这确实是片段的本质。我们通过将功能(控制器)和外观(视图)配对成一堆片段来创建一个完整的应用程序,我们可以以几乎不费吹灰之力的方式以不同的方式重复使用它们。
当然,可以预见到一些障碍,所以看看这个常见问题解答。
经常问的问题
- 缺失的环节是,如果所有这些片段都是完全独立的控制器,那么我们需要更多地了解如何实现我们的模型层。如果我们只是有一个
ArrayList,就像“Note to Self”应用程序一样,ArrayList实例将会放在哪里?我们如何在片段之间共享它(假设所有片段都需要访问相同的数据)?
我们可以使用一种更加优雅的解决方案来创建一个模型层(数据本身和维护数据的代码)。当我们探索第二十六章**中的 NavigationView 布局,以及第二十七章**中的 Android 数据库*时,我们将看到这一点。
总结
现在我们对 Fragment 的用途有了广泛的了解,以及我们如何开始使用它们,我们可以开始深入了解它们的使用方式。在下一章中,我们将完成几个以不同方式使用多个 Fragment 的应用程序。
第二十五章:构建一个简单的图片库应用
RecyclerView小部件,我们可以有选择地加载当前页面所需的数据,也许是前一页和下一页的数据。
正如您所期望的,Android API 提供了一些解决方案来以相当简单的方式实现分页。
在本章中,我们将学习如何做到以下几点:
- 实现像照片库应用中可能找到的图片一样的分页和滑动。
首先,让我们看一个滑动的例子。
技术要求
您可以在 GitHub 上找到本章的代码文件,网址为github.com/PacktPublishing/Android-Programming-for-Beginners-Third-Edition/tree/main/chapter%2025。
愤怒的小鸟经典滑动菜单
在这里,我们可以看到著名的愤怒的小鸟关卡选择菜单展示了滑动/分页的效果:
图 25.1 – 愤怒的小鸟关卡选择菜单
让我们构建一个分页应用。
构建一个图片库/幻灯片应用
在 Android Studio 中创建一个名为Image Pager的新项目。使用空活动模板,并将其余设置保持默认。
这些图片位于第二十五章/Image Pager/drawable文件夹中的下载包中。下一张截图显示了它们在 Windows 文件资源管理器中的情况:
图 25.2 – 添加图片
将图片添加到 Android Studio 中的 Project Explorer 中的drawable文件夹中,或者您可以添加更有趣的图片,也许是您拍摄的一些照片。
实现布局
对于一个简单的图片分页应用,我们使用PagerAdapter类。我们可以将其视为像RecyclerAdapter一样,但用于图片,因为它将处理ViewPager小部件中的图像数组的显示。这很像RecyclerAdapter如何处理ListView中ArrayList的内容的显示。我们只需要重写适当的方法。
要使用PagerAdapter实现图片库,我们首先需要在主布局中添加一个ViewPager小部件。为了确切地了解需要什么,这是activity_main.xml的实际 XML 代码。编辑activity_main.xml使其看起来完全像这样:
<RelativeLayout xmlns:android=
"http://schemas.android.com/apk/res/android"
android:layout_width="fill_parent"
android:layout_height="fill_parent" >
<androidx.viewpager.widget.ViewPager
android:id="@+id/pager"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</RelativeLayout>
名为androidx.ViewPager.widget.ViewPager的类是在ViewPager之前发布的 Android 版本中提供此功能的类。
接下来,就像我们需要一个布局来表示列表项一样,我们需要一个布局来表示一个项目,这里是一个图片,在ViewPager中。以通常的方式创建一个新的布局文件,命名为pager_item.xml。添加一个带有 ID 为imageView的ImageView。
使用可视化设计工具来实现这一点,或者将以下 XML 复制到pager_item.xml中:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="fill_parent"
android:layout_height="fill_parent" >
<ImageView
android:id="@+id/imageView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</RelativeLayout>
现在我们可以开始编写我们的PagerAdapter类了。
编写 PagerAdapter 类
接下来,我们需要扩展PagerAdapter来处理图片。创建一个名为ImagePagerAdapter的新类,并使其扩展PagerAdapter。
将以下导入添加到ImagePagerAdapter类的顶部。我们经常依赖使用Alt + Enter快捷键来添加导入。这次我们做的有些不同,因为有一些非常相似的类,它们不适合我们的目标。
将以下导入添加到ImagePagerAdapter类中:
import android.content.Context;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.RelativeLayout;
import androidx.viewpager.widget.PagerAdapter;
import androidx.viewpager.widget.ViewPager;
这是类声明,添加了extends...代码以及一些成员变量。这些变量是我们不久将要使用的Context对象和一个名为images的int数组。之所以使用int数组来存储图像,是因为我们将为每个图像存储int标识符。我们将在几个代码块后看到这是如何工作的。最后一个成员变量是一个LayoutInflater实例,您可能猜到它将用于填充pager_item.xml的每个实例。
扩展PagerAdapter类并添加我们刚刚讨论过的成员变量:
public class ImagePagerAdapter extends PagerAdapter {
Context context;
int[] images;
LayoutInflater inflater;
}
现在我们需要一个构造函数,通过从MainActivity类接收Context以及图像的int数组并用它们初始化成员变量来设置ImagerPagerAdapter。
在ImagePagerAdapter类中添加构造方法:
public ImagePagerAdapter(
Context context, int[] images) {
this.context = context;
this.images = images;
}
现在我们必须重写PagerAdapter的必需方法。在上一个代码之后,添加重写的getCount方法,它简单地返回数组中图像 ID 的数量。这个方法是类内部使用的:
@Override
public int getCount() {
return images.length;
}
现在我们必须重写isViewFromObject方法,根据当前View实例是否与传入的参数作为当前Object相关联,返回一个布尔值。同样,这是一个类内部使用的方法。在上一个代码之后,添加这个Override方法:
@Override
public boolean isViewFromObject(View view, Object object) {
return view == object;
}
现在我们必须重写instantiateItem方法;这是我们要做的大部分工作。首先,我们声明一个新的ImageView对象,然后初始化我们的LayoutInflater成员。接下来,我们使用LayoutInflater实例从我们的pager_item.xml布局文件中声明和初始化一个新的View实例。
在这之后,我们获取pager_item.xml布局中ImageView小部件的引用。现在,根据instantiateItem方法的position参数和images数组中的适当 ID 整数,我们可以将适当的图像添加为ImageView小部件的内容。
最后,我们使用addView方法将布局添加到PagerAdapter实例,并从该方法返回。
添加我们刚刚讨论过的方法:
@Override
public Object instantiateItem(
ViewGroup container, int position) {
ImageView image;
inflater = (LayoutInflater)
context.getSystemService(
Context.LAYOUT_INFLATER_SERVICE);
View itemView = inflater.inflate(
R.layout.pager_item, container, false);
// get reference to imageView in pager_item layout
image =
itemView.findViewById(R.id.imageView);
// Set an image to the ImageView
image.setImageResource(images[position]);
// Add pager_item layout as the current page to the
ViewPager
((ViewPager) container).addView(itemView);
return itemView;
}
我们必须重写的最后一个方法是destroyItem方法,当类需要根据position参数的值移除适当的项时,可以调用该方法。
在上一个代码之后,在ImagePagerAdapter类的闭合大括号之前添加destroyItem方法:
@Override
public void destroyItem(ViewGroup container,
int position,
Object object) {
// Remove pager_item layout from ViewPager
container.removeView((RelativeLayout) object);
}
正如我们在编写ImagePagerAdapter类时所看到的,它几乎没有什么。只是正确实现ImagePagerAdapter类使用的重写方法,以帮助在幕后顺利运行。
现在我们可以编写MainActivity类,它将使用我们的ImagePagerAdapter实现。
编写 MainActivity 类
最后,我们可以编写我们的MainActivity类。与ImagePagerAdapter类一样,为了清晰起见,手动将以下import语句添加到MainActivity.java类中,然后再类声明之前,如下所示:
import androidx.appcompat.app.AppCompatActivity;
import androidx.viewpager.widget.PagerAdapter;
import androidx.viewpager.widget.ViewPager;
我们需要一些成员变量。毫不奇怪,我们需要一个ViewPager实例,它将用于保存布局中ViewPager的引用。此外,我们还需要一个刚刚编码的类的ImagePagerAdapter引用。我们还需要一个int数组来保存图像 ID 的数组。
调整MainActivity类如下:
public class MainActivity extends AppCompatActivity {
ViewPager viewPager;
PagerAdapter adapter;
int[] images;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
...
所有其他的代码都放在onCreate方法中。我们使用每个添加到drawable-xhdpi文件夹中的图像来初始化我们的int数组。
我们通常使用findViewById方法来初始化ViewPager。我们还通过传入MainActivity类的引用和images数组来初始化我们的ImagePagerAdapter实例,这是我们之前编写的构造函数所需的。最后,我们使用setAdapter方法将适配器绑定到 pager。
编写onCreate方法,使其看起来像下面的代码:
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
// Grab all the images and stuff them in our array
images = new int[] { R.drawable.image1,
R.drawable.image2,
R.drawable.image3,
R.drawable.image4,
R.drawable.image5,
R.drawable.image6 };
// get a reference to the ViewPager in the layout
viewPager = findViewById(R.id.pager);
// Initialize our adapter
adapter = new
ImagePagerAdapter(MainActivity.this, images);
// Binds the Adapter to the ViewPager
viewPager.setAdapter(adapter);
}
现在我们准备运行应用程序。
运行图库应用程序
在这里,我们可以看到我们int数组中的第一张图片:
图 25.3 – 第一张图片
向右和向左轻轻滑动,看到图像平稳过渡的愉悦方式:
图 25.4 – 滑动查看图片
这就是本章的全部内容。让我们回顾一下我们所做的事情。
总结
在本章中,我们看到了如何使用分页器来创建简单的图库,只需几行代码和一些非常简单的布局。我们之所以能够如此轻松地实现这一点,是因为有了ImagePagerAdapter类。
在下一章中,我们将看到另一个非常酷的 UI 元素,它在许多最新的 Android 应用程序中使用,可能是因为它看起来很棒,而且非常实用,使用起来非常愉快。NavigationView布局将使我们能够设计和实现具有完全不同行为(代码)的不同布局。让我们来看看NavigationView布局。