原文:
zh.annas-archive.org/md5/ceefdd89e585c59c20db6a7760dc11f1
译者:飞龙
第十八章:本地化
本章内容简洁明了,我们将学习的内容可以使您的应用程序对数百万潜在用户更加可访问。我们将看到如何添加额外的语言。我们还将看到通过字符串资源以正确的方式添加文本在添加多种语言时对我们的好处。
在本章中,我们将涵盖以下内容:
-
通过添加西班牙语和德语语言使“Note to Self”应用程序多语言化
-
更全面地学习如何使用字符串资源
让我们开始吧。
技术要求
您可以在 GitHub 上找到本章中的代码文件github.com/PacktPublishing/Android-Programming-for-Beginners-Third-Edition/tree/main/chapter%2018
。
使“Note to Self”应用程序对西班牙语和德语使用者可访问
首先,我们需要向我们的项目添加一些文件夹 - 每种新语言一个文件夹。这些文本被归类为res
文件夹。按照以下步骤向项目添加西班牙语和德语支持。
重要提示
虽然此项目的源文件位于第十八章文件夹中,但它们仅供参考。您需要按照下面描述的流程来实现多语言功能。
添加西班牙语支持
按照以下步骤添加西班牙语支持:
-
右键单击
values-es
。 -
左键单击确定。
-
现在我们需要添加一个文件,我们可以把所有的西班牙语翻译放在里面。在目录名称字段中,右键单击
values-es
中的strings.xml
。 -
左键单击确定。
在这个阶段,我们有一个新的文件夹,用于西班牙语翻译,里面有一个strings.xml
文件,用于字符串资源。让我们也为德语做同样的事情。
添加德语语言支持
按照以下步骤添加德语语言支持:
-
右键单击
values-de
。 -
左键单击确定。
-
现在我们需要添加一个文件,我们可以把所有的德语翻译放在里面。在目录名称字段中,右键单击
values-de
中的strings.xml
。 -
左键单击确定。
这就是strings.xml
文件夹的样子。您可能想知道strings.xml
文件夹是从哪里来的,因为它与我们似乎在之前的步骤中创建的结构不符。Android Studio 正在帮助我们(显然)组织我们的文件和文件夹,以符合 Android 操作系统的要求。但是,您可以看到西班牙语和德语文件,分别由它们的特定于国家的扩展es和de指示:
图 18.1 - strings.xml 文件夹
现在我们可以向文件添加翻译。
添加字符串资源
正如我们所知,strings.xml
文件包含应用程序将显示的单词 - 诸如“重要”,“待办事项”,“想法”等。通过为我们想要支持的每种语言都有一个strings.xml
文件,我们可以让 Android 根据用户的语言设置选择适当的文本。
在您进行以下操作时,请注意,尽管我们将要翻译的单词的翻译放在值中,但name
属性保持不变。如果您仔细想想,这是合乎逻辑的,因为我们在布局文件中引用的是name
属性。
让我们提供翻译,看看我们取得了什么成就,然后回来讨论我们将如何处理 Java 代码中的文本。实现此代码的最简单方法是从原始的strings.xml
文件中复制并粘贴代码,然后编辑每个name
属性的值:
- 双击打开
strings.xml
文件。确保选择带有**(es)**后缀的文件。编辑文件使其看起来像这样:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Nota a sí mismo</string>
<string name="action_settings">Configuración</string>
<string name="action_add">add</string>
<string name="title_hint">Título</string>
<string name="description_hint">Descripción</string>
<string name="idea_text">Idea</string>
<string name="important_text">Importante</string>
<string name="todo_text">Que hacer</string>
<string name="cancel_button">Cancelar</string>
<string name="ok_button">Vale</string>
<string name="settings_title">Configuración</string>
</resources>
- 双击打开
strings.xml
文件。确保选择与strings.xml
文件相邻的文件,然后缺少的资源将从默认文件中获取。
我们所做的是提供了两种翻译。Android 知道哪种翻译是为哪种语言,因为它们所放置的文件夹。此外,我们使用了一个name
属性来引用翻译。回顾一下以前的代码,您会看到相同的标识符用于两种翻译以及原始的strings.xml
文件。
您甚至可以将本地化到不同版本的语言,例如美国或英国英语。完整的代码列表可以在这里找到:stackoverflow.com/questions/7973023/what-is-the-list-of-supported-languages-locales-on-android
。您甚至可以本地化资源,如图像和声音。在这里了解更多信息:developer.android.com/guide/topics/resources/localization.html
。
这些翻译是从谷歌翻译复制并粘贴而来的,因此很可能有些翻译与正确的相去甚远。像这样廉价地进行翻译可能是将具有基本字符串资源集的应用程序放入使用不同语言的用户设备的有效方式。一旦您开始需要任何深度的翻译,也许在叙事驱动的游戏或社交媒体应用的情况下,您肯定会受益于由人类专业人员进行的翻译。
这个练习的目的是展示 Android 的工作原理,而不是如何翻译。
注意
对于可能看到这里提供的翻译的局限性的任何西班牙语或德语使用者,我表示诚挚的歉意。
现在我们已经有了翻译,我们可以看到它们在一定程度上起作用。
在德语或西班牙语中运行 Note to Self
运行应用程序以查看它是否正常工作。现在我们可以更改本地化设置以在西班牙语中查看它。不同的设备在如何执行此操作上略有不同,但 Pixel 3 模拟器的选项如下:
-
选择设置 | 系统 | 语言和输入 | 添加语言。接下来,选择Español,然后您将能够在列表中在西班牙语和英语之间切换。
-
左键单击并拖动Español (Estados Unidos),使其位于列表顶部。
恭喜,您的模拟器现在默认为西班牙语。完成本章后,您可以将首选语言拖回到列表顶部。
现在您可以以通常的方式运行应用程序。以下是应用程序以西班牙语运行的一些屏幕截图。我用 Photoshop 将一些屏幕截图并排放置,以展示 Note to Self 应用程序的不同屏幕:
图 18.2 - 应用程序以西班牙语运行
在屏幕截图中,您可以清楚地看到我们的应用程序主要是用西班牙语翻译的。显然,用户输入的文本将是他们所说的任何语言;这不是我们应用程序的缺陷。然而,仔细看屏幕截图,注意我指出了一些文本仍然是英文的地方。我们的每个对话框窗口中仍然有一些未翻译的文本。
这是因为文本直接包含在我们的 Java 代码中。正如我们所见,使用多种语言的字符串资源然后在我们的布局中引用它们是很容易的,但是我们如何从我们的 Java 代码中引用字符串资源呢?
使翻译在 Java 代码中起作用
首先要做的是在三个strings.xml
文件中创建资源。以下是需要添加到三个不同文件中的两个资源。
在strings.xml
(没有任何国家后缀),在<resources></resources>
标签中添加这两个资源:
<string name="add_new_note">Add a new note</string>
<string name="your_note">Your note</string>
在strings.xml
中使用<resources></resources>
标签:
<string name="add_new_note">Agregar una nueva nota</string>
<string name="your_note">Su nota</string>
在strings.xml
中使用<resources></resources>
标签:
<string name="add_new_note">Eine neue Note hinzufügen</string>
<string name="your_note">Ihre Notiz</string>
接下来,我们需要编辑一些 Java 代码,以引用资源而不是硬编码的字符串。
打开DialogNewNote.java
文件并找到这行代码:
builder.setView(dialogView).setMessage("Add a new note");
编辑如下所示,使用我们刚刚添加的字符串资源而不是硬编码的文本:
builder.setView(dialogView).setMessage(getResources().
getString(R.string.add_new_note));
新代码使用了链式getResources.getString
方法来替换先前硬编码的"Add a new note"
文本。仔细看,你会发现发送给getString
方法的参数是R.string.add_new_note
字符串标识符。
R.string
代码指的是res
文件夹中的字符串资源,add_new_note
是我们的标识符。Android 将能够根据应用所在设备的语言环境决定使用哪个版本(默认、西班牙语或德语)。
我们还有一个硬编码的字符串需要更改。
打开DialogShowNote.java
文件并找到这行代码:
builder.setView(dialogView).setMessage("Your Note");
编辑如下所示,使用我们刚刚添加的字符串资源而不是硬编码的文本:
builder.setView(dialogView).setMessage(getResources().
getString(R.string.your_note));
新代码再次使用了链式getResources.getString
方法来替换先前硬编码的"Your note"
文本。同样,发送给getString
的参数是字符串标识符,这次是R.string.your_note
。
Android 现在可以根据应用所在设备的语言环境决定使用哪个版本(默认、西班牙语或德语)。下一张截图显示了新建笔记界面现在以适当的语言显示开头文本:
图 18.3 – 新建笔记界面
您可以添加任意多个字符串资源。作为第三章中的提醒,探索 Android Studio 和项目结构,请注意,使用字符串资源是向所有项目添加任何文本的推荐方式。本书中的教程(除了《自言自语》)将倾向于硬编码它们,以便制作更紧凑的教程。
总结
我们已经看到了如何满足世界上说不同语言的地区。我们现在可以让我们的应用全球化,同时添加更灵活的字符串资源,而不是硬编码所有文本。
在下一章中,我们将看到如何使用动画和插值器为我们的应用添加酷炫的动画效果。
第十九章:动画和插值
在这里,我们将看到如何使用Animation
类使我们的 UI 变得不那么静态,更有趣一些。正如我们所期望的那样,Android API 将允许我们用相对简单的代码做一些相当先进的事情,Animation
类也不例外。
这一章大致可以分为以下几个部分:
-
介绍了 Android 中动画的工作原理和实现方式
-
介绍了一个我们尚未探索的 UI 小部件,
SeekBar
-
创建一个工作动画应用程序
首先,让我们探索一下 Android 中的动画是如何工作的。
技术要求
您可以在 GitHub 上找到本章中的代码文件github.com/PacktPublishing/Android-Programming-for-Beginners-Third-Edition/tree/main/chapter%2019
。
Android 中的动画
在 Android 中创建动画的正常方式是通过 XML。我们可以编写 XML 动画,然后在 Java 中加载和播放它们,放在指定的 UI 小部件上。因此,例如,我们可以编写一个动画,在 3 秒内淡入淡出五次,然后在ImageView
或任何其他小部件上播放该动画。我们可以将这些 XML 动画看作脚本,因为它们定义了类型、顺序和时间。
让我们探索一下我们可以分配给动画的一些不同属性,然后如何在我们的 Java 代码中使用它们,最后,我们可以制作一个漂亮的动画应用程序来尝试一切。
在 XML 中设计酷炫的动画
我们已经学会了 XML 不仅可以用来描述 UI 布局,还可以用来描述动画,但让我们来看看具体是如何做到的。我们可以陈述动画的属性,描述小部件的起始和结束外观。然后我们的 Java 代码可以通过引用包含它的 XML 文件的名称并将其转换为可用的 Java 对象来加载 XML,这与 UI 布局非常相似。
这里我们快速浏览一下一些动画属性对,我们可以陈述以创建动画。在我们查看了一些 XML 之后,我们将看到如何在 Java 中使用它。
淡入淡出
Alpha 是透明度的度量。因此,通过陈述起始的fromAlpha
和结束的toAlpha
值,我们可以淡入淡出物品。值为0.0
是不可见的,而1.0
是对象的正常外观。在两者之间稳定移动会产生淡入效果:
<alpha
android:fromAlpha="0.0"
android:toAlpha="1.0" />
移动
我们可以通过类似的技术在我们的 UI 中移动一个对象。fromXDelta
和toXDelta
的值可以设置为被动画化对象大小的百分比。
以下代码将使一个对象从左到右移动,距离等于对象本身的宽度:
<translate
android:fromXDelta="-100%"
android:toXDelta="0%"/>
此外,还有用于上下动画的fromYDelta
和toYDelta
属性。
缩放或拉伸
fromXScale
和toXScale
将增加或减少对象的比例。例如,以下代码将改变对象,使动画从正常大小到不可见:
<scale
android:fromXScale="1.0"
android:fromYScale="0.0"/>
举个例子,我们可以使用android:fromYScale="0.1"
将对象缩小到其通常大小的十分之一,或者使用android:fromYScale="10.0"
将其放大十倍。
控制持续时间
当然,如果这些动画只是瞬间到达它们的结论,那么它们中的任何一个都不会特别有趣。因此,为了使我们的动画更有趣,我们可以设置它们的持续时间(以毫秒为单位)。毫秒是一秒的千分之一。我们还可以通过设置startOffset
(也以毫秒为单位)来使时间更容易,特别是与其他动画相关联。
下面的代码将在我们开始动画的 1/3 秒后开始动画,并且需要 2/3 秒才能完成:
android:duration="666"
android:startOffset="333"
旋转动画
如果要使某物旋转,只需使用fromDegrees
和toDegrees
。下面的代码可能会让小部件在一个完整的圆圈中旋转,因为当然,一个圆圈有 360 度:
<rotate android:fromDegrees="360"
android:toDegrees="0"
/>
重复动画
在一些动画中,重复可能很重要,也许是一种摇摆或抖动效果,所以我们可以添加一个 repeatCount
属性。此外,我们可以通过设置 repeatMode
来指定动画的重复方式。
以下代码将重复一个动画 10 次,每次都会改变动画的方向。repeatMode
属性是相对于动画的当前状态的。这意味着,如果你,例如,将一个按钮从 0 度旋转到 360 度,动画的第二部分(第一次重复)将从 360 度反向旋转回到 0。动画的第三部分(第二次重复)将再次反向旋转,从 0 度到 360 度:
android:repeatMode="reverse"
android:repeatCount="10"
使用 360 度旋转的例子,前面的代码将使一个小部件向右旋转 360 度,然后再向左旋转 360 度,重复五次。这是 10 次重复,每次都会反向旋转。
将动画的属性与 set 结合
要结合这些效果的组,我们需要使用 set
。这段代码展示了我们如何将刚刚看到的所有先前代码片段组合成一个实际的 XML 动画,它将编译:
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
...All our animations go here
</set>
然而,我们还没有看到任何 Java 代码来使这些动画生动起来。让我们现在来解决这个问题。
实例化动画并用 Java 代码控制它们
下面的 Java 代码片段展示了我们如何声明一个 Animation
类型的对象,用一个名为 fade_in.xml
的 XML 文件中的动画对其进行初始化,并在一个 ImageView
小部件上启动动画。很快我们将在一个项目中这样做,并看看在哪里放置 XML 动画:
// Declare an Animation object
Animation animFadeIn;
// Initialize it
animFadeIn = AnimationUtils.loadAnimation(
getApplicationContext(), R.anim.fade_in);
// Get an ImageView from the UI in the usual way
ImageView findViewById(R.id.imageView);
// Start the animation on the ImageView
imageView.startAnimation(animFadeIn);
我们已经有了相当强大的动画和控制功能,比如时间控制。但是 Android API 还给了我们更多。
更多动画特性
我们可以监听动画的状态,就像我们可以监听按钮的点击一样。我们还可以使用 插值器 来使我们的动画更加生动和愉悦。让我们先看看监听器。
监听器
如果我们实现 AnimationListener
接口,我们确实可以通过覆盖告诉我们发生了什么的三种方法来监听动画的状态。然后我们可以根据这些事件来采取行动。
OnAnimationEnd
宣布动画结束,onAnimationRepeat
每次动画开始重复时调用,也许可以预料到,onAnimationStart
在动画开始动画时调用。如果在动画的 XML 中设置了 startOffset
,这可能不是 startAnimation
被调用的时间:
@Override
public void onAnimationEnd(Animation animation) {
// Take some action here
}
@Override
public void onAnimationRepeat(Animation animation) {
// Take some action here
}
@Override
public void onAnimationStart(Animation animation) {
// Take some action here
}
我们将在动画演示应用中看到 AnimationListener
是如何工作的,以及如何将另一个小部件 SeekBar
付诸实践。
动画插值器
如果你能回想起高中时的一些激动人心的关于计算加速度的课程,你可能会记得。如果我们以恒定的速度对某物进行动画,乍一看,事情可能看起来还不错。如果我们将动画与另一个使用渐进加速的动画进行比较,那么后者几乎肯定会更令人愉悦。
有可能,如果我们没有被告知两个动画之间唯一的区别是一个使用了加速度,另一个没有,我们可能无法说出为什么我们更喜欢它。我们的大脑更容易接受符合我们周围世界规范的事物。这就是为什么添加一点真实世界的物理学,比如加速度和减速度,会改善我们的动画。
然而,我们最不想做的事情就是开始做一堆数学计算,只是为了将一个按钮滑动到屏幕上或者让一些文本在圆圈中旋转。
这就是 插值器 的用武之地。它们是我们可以在 XML 中的一行代码中设置的动画修改器。
一些插值器的例子是 accelerate_interpolator
和 cycle_interpolator
:
android:interpolator="@android:anim/accelerate_interpolator"android:interpolator="@android:anim/cycle_interpolator"/>
接下来我们将把一些插值器,以及一些 XML 动画和相关的 Java 代码,付诸实践。
注意
您可以在开发者网站上了解更多关于插值器和 Android Animation
类的信息:developer.android.com/guide/topics/resources/animation-resource.html
。
动画演示应用程序 - 介绍 SeekBar
这就够了理论,尤其是对于应该是视觉的东西。让我们构建一个动画演示应用程序,探索我们刚刚讨论过的一切以及更多内容。
这个应用程序涉及大量不同文件中的少量代码。因此,我已经尽量明确哪些代码在哪个文件中,这样你就可以跟踪发生了什么。这也将使我们为这个应用程序编写的 Java 更容易理解。
该应用程序将演示旋转、淡入淡出、平移、动画事件、插值器和使用SeekBar
小部件控制持续时间。解释SeekBar
小部件的最佳方法是构建它,然后观察它的运行。
布局动画演示
使用空活动模板创建一个名为Animation Demo
的新项目,将所有其他设置保持默认。如果您希望通过复制和粘贴布局、Java 代码或动画 XML 来加快速度,可以在第十九章文件夹中找到所有内容。
使用完成布局的下一个参考截图来帮助你完成接下来的步骤:
图 19.1 - 完成的布局
以下是为此应用程序布局 UI 的方法:
-
在编辑器窗口的设计视图中打开
activity_main.xml
。 -
删除默认的
TextView
。 -
在布局的顶部中心添加一个
ImageView
小部件。使用之前的参考截图来指导你。在弹出的资源窗口中选择项目 | ic_launcher,使用@mipmap/ic_launcher
在提示时在ImageView
小部件中显示 Android 机器人。 -
将
ImageView
小部件的id
属性设置为imageView
。
在ImageView
小部件的正下方,添加一个TextView
小部件。将id
属性设置为textStatus
。我通过拖动其边缘使我的TextView
小部件变大,并将其textSize
属性更改为40sp
。
- 现在我们将添加大量
id
属性值,稍后在教程中将它们添加到它们。按照下一个截图来布局 12 个按钮。修改每个按钮的text
属性,使其与下一个截图中的文本相同。如果截图不够清晰,下一步中具体详细说明了text
属性:
图 19.2 - 文本属性
注意
为了加快按钮布局的过程,首先只是大致布局它们,然后从下一步中添加text
属性,然后微调按钮位置以获得整洁的布局。
-
按照截图中的文本值添加文本。以下是从左到右,从上到下的所有值:
淡入
,淡出
,淡入淡出
,放大
,缩小
,左右
,右左
,上下
,弹跳
,闪烁
,向左旋转
和向右旋转
。 -
从
id
属性到seekBarSpeed
添加一个SeekBar
小部件,并将max
属性设置为5000
。这意味着当用户从左向右拖动时,滑块将保存一个在0
和5000
之间的值。我们将看到如何读取和使用这些数据。 -
我们想要使
SeekBar
小部件更宽。为了实现这一点,您可以使用与任何小部件相同的技术;只需拖动小部件的边缘。然而,由于滑块很小,很难增加其大小而不小心选择约束手柄。为了解决这个问题,您可以通过按住Ctrl键并向前滚动鼠标中键来放大设计。然后,您可以抓住滑块的边缘,而不触摸约束手柄。我在下一个截图中展示了这一操作:
图 19.3 - 抓住滑块的边缘
-
现在在
SeekBar
小部件的右侧添加一个TextView
小部件,并将其id
属性设置为textSeekerSpeed
。 -
调整位置,使其看起来像这些步骤开始时的参考图像,然后单击推断约束按钮以锁定位置。当然,如果您想要练习,也可以手动完成这一步骤。
-
接下来,将以下
id
属性添加到按钮中,这些按钮由您已经设置的文本属性标识。如果在输入这些值时被问及是否要更新用法…,请选择是:
当我们在几节时间内编写MainActivity
类时,我们将看到如何使用这个新加入的 UI(SeekBar
)。
编写 XML 动画
右键单击目录名称字段中的anim
,然后左键单击确定。
现在,右键单击新的fade_in
,然后左键单击确定。删除整个内容并添加以下代码以创建动画:
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:fillAfter="true" >
<alpha
android:fromAlpha="0.0"
android:interpolator="
@android:anim/accelerate_interpolator"
android:toAlpha="1.0" />
</set>
右键单击fade_out
,然后左键单击确定。删除整个内容并添加以下代码以创建动画:
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:fillAfter="true" >
<alpha
android:fromAlpha="1.0"
android:interpolator="
@android:anim/accelerate_interpolator"
android:toAlpha="0.0" />
</set>
右键单击fade_in_out
,然后左键单击确定。删除整个内容并添加以下代码以创建动画:
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:fillAfter="true" >
<alpha
android:fromAlpha="0.0"
android:interpolator="
@android:anim/accelerate_interpolator"
android:toAlpha="1.0" />
<alpha
android:fromAlpha="1.0"
android:interpolator="
@android:anim/accelerate_interpolator"
android:toAlpha="0.0" />
</set>
右键单击zoom_in
,然后左键单击确定。删除整个内容并添加以下代码以创建动画:
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:fillAfter="true" >
<scale
android:fromXScale="1"
android:fromYScale="1"
android:pivotX="50%"
android:pivotY="50%"
android:toXScale="6"
android:toYScale="6" >
</scale>
</set>
右键单击zoom_out
,然后左键单击确定。删除整个内容并添加以下代码以创建动画:
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<scale
android:fromXScale="6"
android:fromYScale="6"
android:pivotX="50%"
android:pivotY="50%"
android:toXScale="1"
android:toYScale="1" >
</scale>
</set>
右键单击left_right
,然后左键单击确定。删除整个内容并添加以下代码以创建动画:
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate
android:fromXDelta="-500%"
android:toXDelta="0%"/>
</set>
右键单击right_left
,然后左键单击确定。删除整个内容并添加以下代码以创建动画:
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate
android:fillAfter="false"
android:fromXDelta="500%"
android:toXDelta="0%"/>
</set>
右键单击top_bot
,然后左键单击确定。删除整个内容并添加以下代码以创建动画:
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate
android:fillAfter="false"
android:fromYDelta="-100%"
android:toYDelta="0%"/>
</set>
右键单击flash
,然后左键单击确定。删除整个内容并添加以下代码以创建动画:
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<alpha android:fromAlpha="0.0"
android:toAlpha="1.0"
android:interpolator="
@android:anim/accelerate_interpolator"
android:repeatMode="reverse"
android:repeatCount="10"/>
</set>
右键单击bounce
,然后左键单击确定。删除整个内容并添加以下代码以创建动画:
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:fillAfter="true"
android:interpolator="
@android:anim/bounce_interpolator">
<scale
android:fromXScale="1.0"
android:fromYScale="0.0"
android:toXScale="1.0"
android:toYScale="1.0" />
</set>
右键单击rotate_left
,然后左键单击pivotX="50%"
和pivotY="50%"
。这使得旋转动画在将要被动画化的小部件上居中。我们可以将其视为设置动画的中心点:
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<rotate android:fromDegrees="360"
android:toDegrees="0"
android:pivotX="50%"
android:pivotY="50%"
android:interpolator="
@android:anim/cycle_interpolator"/>
</set>
右键单击rotate_right
,然后左键单击确定。删除整个内容并添加以下代码以创建动画:
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<rotate android:fromDegrees="0"
android:toDegrees="360"
android:pivotX="50%"
android:pivotY="50%"
android:interpolator="
@android:anim/cycle_interpolator"/>
</set>
现在我们可以编写 Java 代码,将动画添加到我们的 UI 中。
将动画演示应用程序与 Java 连接起来
打开MainActivity.java
文件。现在,在类声明下面,我们可以声明以下用于动画的成员变量:
Animation animFadeIn;
Animation animFadeOut;
Animation animFadeInOut;
Animation animZoomIn;
Animation animZoomOut;
Animation animLeftRight;
Animation animRightLeft;
Animation animTopBottom;
Animation animBounce;
Animation animFlash;
Animation animRotateLeft;
Animation animRotateRight;
现在在上述代码之后为 UI 小部件添加这些成员变量:
ImageView imageView;
TextView textStatus;
Button btnFadeIn;
Button btnFadeOut;
Button btnFadeInOut;
Button zoomIn;
Button zoomOut;
Button leftRight;
Button rightLeft;
Button topBottom;
Button bounce;
Button flash;
Button rotateLeft;
Button rotateRight;
SeekBar seekBarSpeed;
TextView textSeekerSpeed;
注意
此时,您需要添加以下import
语句:
import android.view.animation.Animation;
import android.widget.Button;
import android.widget.ImageView;
import android.widget.SeekBar;
import android.widget.TextView;
接下来,我们添加一个int
成员变量,用于跟踪滑块的当前值/位置:
int seekSpeedProgress;
现在,在setContentView
方法调用之后,让我们从onCreate
方法中调用两个新方法:
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
loadAnimations();
loadUI();
}
在这一点上,两行新代码将有错误,直到我们实现这两个新方法。
现在我们将实现 loadAnimations
方法。虽然这个方法中的代码相当庞大,但也非常简单。我们所做的就是使用 AnimationUtils
类的静态 loadAnimation
方法来初始化我们每个 Animation
引用中的一个 XML 动画。还要注意,对于 animFadeIn
的 Animation
引用,我们还在其上调用了 setAnimationListener
。我们将很快编写监听事件的方法。
添加 loadAnimations
方法:
private void loadAnimations(){
animFadeIn = AnimationUtils.loadAnimation(
this, R.anim.fade_in);
animFadeIn.setAnimationListener(this);
animFadeOut = AnimationUtils.loadAnimation(
this, R.anim.fade_out);
animFadeInOut = AnimationUtils.loadAnimation(
this, R.anim.fade_in_out);
animZoomIn = AnimationUtils.loadAnimation(
this, R.anim.zoom_in);
animZoomOut = AnimationUtils.loadAnimation(
this, R.anim.zoom_out);
animLeftRight = AnimationUtils.loadAnimation(
this, R.anim.left_right);
animRightLeft = AnimationUtils.loadAnimation(
this, R.anim.right_left);
animTopBottom = AnimationUtils.loadAnimation(
this, R.anim.top_bot);
animBounce = AnimationUtils.loadAnimation(
this, R.anim.bounce);
animFlash = AnimationUtils.loadAnimation(
this, R.anim.flash);
animRotateLeft = AnimationUtils.loadAnimation(
this, R.anim.rotate_left);
animRotateRight = AnimationUtils.loadAnimation(
this, R.anim.rotate_right);
}
注意
在这一点上,您需要导入一个新的类:
import android.view.animation.AnimationUtils;
在三个部分中实现 loadUI
方法。首先,让我们以通常的方式引用 XML 布局的部分:
private void loadUI(){
imageView = findViewById(R.id.imageView);
textStatus = findViewById(R.id.textStatus);
btnFadeIn = findViewById(R.id.btnFadeIn);
btnFadeOut = findViewById(R.id.btnFadeOut);
btnFadeInOut = findViewById(R.id.btnFadeInOut);
zoomIn = findViewById(R.id.btnZoomIn);
zoomOut = findViewById(R.id.btnZoomOut);
leftRight = findViewById(R.id.btnLeftRight);
rightLeft = findViewById(R.id.btnRightLeft);
topBottom = findViewById(R.id.btnTopBottom);
bounce = findViewById(R.id.btnBounce);
flash = findViewById(R.id.btnFlash);
rotateLeft = findViewById(R.id.btnRotateLeft);
rotateRight = findViewById(R.id.btnRotateRight);
现在我们将为每个按钮添加一个点击监听器。在 loadUI
方法的最后一个块之后立即添加以下代码:
btnFadeIn.setOnClickListener(this);
btnFadeOut.setOnClickListener(this);
btnFadeInOut.setOnClickListener(this);
zoomIn.setOnClickListener(this);
zoomOut.setOnClickListener(this);
leftRight.setOnClickListener(this);
rightLeft.setOnClickListener(this);
topBottom.setOnClickListener(this);
bounce.setOnClickListener(this);
flash.setOnClickListener(this);
rotateLeft.setOnClickListener(this);
rotateRight.setOnClickListener(this);
注意
我们刚刚添加的代码在所有代码行中都创建了错误。我们现在可以忽略它们,因为我们很快就会修复它们并讨论发生了什么。
loadUI
方法的第三部分和最后一部分设置了一个匿名类来处理 SeekBar
小部件。我们本可以像监听按钮点击和动画事件一样将其添加为 MainActivity
类的一个接口,但是对于像这样的单个 SeekBar
小部件,直接处理它是有意义的。
我们将重写三个方法,这是实现 OnSeekBarChangeListener
接口时所需的:
-
一个检测 seek bar 位置变化的方法叫做
onProgressChanged
-
一个检测用户开始改变位置的方法叫做
onStartTrackingTouch
-
一个检测用户完成使用 seek bar 的方法叫做
onStopTrackingTouch
为了实现我们的目标,我们只需要向 onProgressChanged
方法添加代码,但是我们仍然必须重写它们全部。
在 onProgressChanged
方法中,我们所做的就是将 seek bar 的当前值分配给 seekSpeedProgress
成员变量,以便可以从其他地方访问它。然后我们使用这个值以及通过调用 seekBarSpeed.getMax()
获得的 SeekBar
小部件的最大可能值,并将消息输出到 textSeekerSpeed
TextView
小部件。
将我们刚刚讨论过的代码添加到 loadUI
方法中:
seekBarSpeed = findViewById(R.id.seekBarSpeed);
textSeekerSpeed = findViewById(R.id.textSeekerSpeed);
seekBarSpeed.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
@Override
public void onProgressChanged(SeekBar seekBar,
int value, boolean fromUser) {
seekSpeedProgress = value;
textSeekerSpeed.setText(""
+ seekSpeedProgress
+ " of "
+ seekBarSpeed.getMax());
}
@Override
public void onStartTrackingTouch(SeekBar seekBar) {
}
@Override
public void onStopTrackingTouch(SeekBar seekBar) {
}
});
}
现在我们需要修改 MainActivity
类的声明以实现两个接口。在这个应用程序中,我们将监听点击和动画事件,所以我们将使用的两个接口是 View.OnClickListener
和 Animation.AnimationListener
。请注意,要实现多个接口,我们只需用逗号分隔接口。
通过添加我们刚刚讨论过的以下突出显示的代码来修改 MainActivity
类的声明:
public class MainActivity extends AppCompatActivity
implements View.OnClickListener,
Animation.AnimationListener {
在这个阶段,我们可以添加并实现这些接口所需的方法 - 首先是 AnimationListener
的以下方法:onAnimationEnd
,onAnimationRepeat
和 onaAnimationStart
。我们只需要向这两个方法中添加一点代码。在 onAnimationEnd
中,我们将 textStatus
的 text
属性设置为 STOPPED
,在 onAnimationStart
方法中,我们将 textStatus
的 text
属性设置为 RUNNING
。这将演示我们的动画监听器确实在监听和工作:
@Override
public void onAnimationEnd(Animation animation) {
textStatus.setText("STOPPED");
}
@Override
public void onAnimationRepeat(Animation animation) {
}
@Override
public void onAnimationStart(Animation animation) {
textStatus.setText("RUNNING");
}
onClick
方法非常长,但并不复杂。UI 中处理每个按钮的每个 case
简单地根据 seek bar 的当前位置设置动画的持续时间,设置动画以便监听事件,然后启动动画。
注意
您需要使用您喜欢的技术导入 View
类:
import android.view.View;
添加我们刚刚讨论过的 onClick
方法,然后我们就完成了这个小应用程序:
@Override
public void onClick(View v) {
switch(v.getId()){
case R.id.btnFadeIn:
animFadeIn.setDuration(seekSpeedProgress);
animFadeIn.setAnimationListener(this);
imageView.startAnimation(animFadeIn);
break;
case R.id.btnFadeOut:
animFadeOut.setDuration(seekSpeedProgress);
animFadeOut.setAnimationListener(this);
imageView.startAnimation(animFadeOut);
break;
case R.id.btnFadeInOut:
animFadeInOut.setDuration(seekSpeedProgress);
animFadeInOut.setAnimationListener(this);
imageView.startAnimation(animFadeInOut);
break;
case R.id.btnZoomIn:
animZoomIn.setDuration(seekSpeedProgress);
animZoomIn.setAnimationListener(this);
imageView.startAnimation(animZoomIn);
break;
case R.id.btnZoomOut:
animZoomOut.setDuration(seekSpeedProgress);
animZoomOut.setAnimationListener(this);
imageView.startAnimation(animZoomOut);
break;
case R.id.btnLeftRight:
animLeftRight.setDuration(seekSpeedProgress);
animLeftRight.setAnimationListener(this);
imageView.startAnimation(animLeftRight);
break;
case R.id.btnRightLeft:
animRightLeft.setDuration(seekSpeedProgress);
animRightLeft.setAnimationListener(this);
imageView.startAnimation(animRightLeft);
break;
case R.id.btnTopBottom:
animTopBottom.setDuration(seekSpeedProgress);
animTopBottom.setAnimationListener(this);
imageView.startAnimation(animTopBottom);
break;
case R.id.btnBounce:
/*
Divide seekSpeedProgress by 10 because with
the seekbar having a max value of 5000 it
will make the animations range between
almost instant and half a second
5000 / 10 = 500 milliseconds
*/
animBounce.setDuration(seekSpeedProgress / 10);
animBounce.setAnimationListener(this);
imageView.startAnimation(animBounce);
break;
case R.id.btnFlash:
animFlash.setDuration(seekSpeedProgress / 10);
animFlash.setAnimationListener(this);
imageView.startAnimation(animFlash);
break;
case R.id.btnRotateLeft:
animRotateLeft.setDuration(seekSpeedProgress);
animRotateLeft.setAnimationListener(this);
imageView.startAnimation(animRotateLeft);
break;
case R.id.btnRotateRight:
animRotateRight.setDuration(seekSpeedProgress);
animRotateRight.setAnimationListener(this);
imageView.startAnimation(animRotateRight);
break;
}
}
现在运行应用程序。将 seek bar 移动到大致中心,以便动画运行一段合理的时间,如下一张截图所示:
图 19.4 – 将寻找栏移动到大致中心
点击放大按钮以查看效果,如下一个截图所示:
图 19.5 – 放大效果
注意 Android 机器人上的文本如何在适当的时间从RUNNING更改为STOPPED。现在点击其中一个旋转按钮,以查看下一个显示的效果:
图 19.6 – 旋转按钮
大多数其他动画在截图中无法展现出自己的价值,所以一定要自己尝试它们。
常见问题
- 我现在知道如何对小部件进行动画处理,但是我自己创建的形状或图像呢?
ImageView
可以容纳任何您喜欢的图像。只需将图像添加到drawable
文件夹,然后在ImageView
小部件上设置适当的src
属性。然后可以对ImageView
中显示的任何图像进行动画处理。
- 但如果我想要比这更灵活的功能,比如绘图应用程序甚至游戏呢?
要实现这种功能,我们需要了解另一个通用的计算概念(Paint
,Canvas
和SurfaceView
)。我们将学习如何从单个像素到形状绘制任何东西,然后在屏幕上移动它们,从下一章开始,第二十章,绘图图形。
总结
现在我们掌握了另一个增强应用程序的技巧,并且知道 Android 中的动画非常简单。我们可以在 XML 中设计动画并将文件添加到anim
文件夹中。之后,我们可以在 Java 代码中使用Animation
对象获取对 XML 中动画的引用。
然后,我们可以使用 UI 中小部件的引用,并使用setAnimation
为其设置动画,并传入Animation
对象。通过在小部件的引用上调用startAnimation
来开始动画。
我们还看到可以控制动画的时序,以及监听动画事件。
在下一章中,我们将学习如何在 Android 中绘制图形。这将是关于图形的几章中的开始,我们将构建一个儿童风格的绘图应用程序。
现有文本属性 | 要设置的 id 属性值 |
---|---|
淡入 | btnFadeIn |
淡出 | btnFadeOut |
淡入淡出 | btnFadeInOut |
放大 | btnZoomIn |
缩小 | btnZoomOut |
左右 | btnLeftRight |
右左 | btnRightLeft |
上下 | btnTopBottom |
弹跳 | btnBounce |
闪烁 | btnFlash |
向左旋转 | btnRotateLeft |
向右旋转 | btnRotateRight |
第二十章:绘制图形
整个章节将涉及 Android 的Canvas
类和一些相关类,包括Paint
、Color
和Bitmap
。这些类结合起来在绘制屏幕时具有强大的功能。有时,Android API 提供的默认 UI 并不是我们需要的。如果我们想要制作一个绘图应用、绘制图表,或者创建一个游戏,我们需要控制 Android 设备提供的每个像素。
在这一章中,我们将涵盖以下内容:
-
理解
Canvas
和相关类 -
编写基于
Canvas
的演示应用 -
查看 Android 坐标系统,以便知道在哪里进行绘图
-
学习绘制和操作位图
-
编写基于位图的演示应用
让我们开始绘图!
技术要求
您可以在 GitHub 上找到本章的代码文件,网址为github.com/PacktPublishing/Android-Programming-for-Beginners-Third-Edition/tree/main/chapter%2020
。
理解 Canvas 类
Canvas
类是android.graphics
包的一部分。在接下来的两章中,我们将使用android.graphics
包中的所有以下import
语句,以及来自现在熟悉的View
包的一个额外的import
语句。它们为我们提供了从 Android API 中获取一些强大绘图方法的权限:
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.widget.ImageView;
首先,让我们讨论Bitmap
、Canvas
和ImageView
,正如前面的代码所强调的那样。
使用 Bitmap、Canvas 和 ImageView 开始绘图
由于 Android 设计用于运行各种类型的移动应用程序,我们不能立即开始输入绘图代码并期望它能够工作。我们需要做一些准备(编码)来考虑我们的应用正在运行的特定设备。有些准备可能有点反直觉,但我们将一步一步地进行。
Canvas 和 Bitmap
根据您如何使用Canvas
类,这个术语可能会有点误导。虽然Canvas
类是您绘制图形的类,就像绘画画布一样,但您仍然需要一个表面来转置画布。
在这种情况下(以及我们的前两个演示应用中),表面将来自Bitmap
类。我们可以这样想:我们得到一个Canvas
对象和一个Bitmap
对象,然后将Bitmap
对象设置为Canvas
对象的一部分来进行绘制。
如果您按照字面意义上的画布这个词,这可能有点反直觉,但一旦设置好了,我们就可以忘记它,专注于我们想要绘制的图形。
注意
Canvas
类提供了绘制的能力。它具有绘制形状、文本、线条和图像文件(包括其他位图),甚至绘制单个像素的所有方法。
Bitmap
类被Canvas
类使用,并且是被绘制的表面。你可以把Bitmap
实例想象成在Canvas
实例上的图片框内。
Paint
除了Canvas
和Bitmap
类之外,我们还将使用Paint
类。这更容易理解。Paint
是用来配置特定属性的类,比如我们将在Bitmap
(在Canvas
内)上绘制的颜色。
在我们开始绘图之前,还有一个谜题需要解决。
ImageView 和 Activity
ImageView
类是Activity
类用于向用户显示输出的类。有第三层抽象的原因是,正如我们在整本书中所看到的,Activity
类需要将一个View
传递给setContentView
方法,以向用户显示内容。到目前为止,这一直是我们在可视化设计器或 XML 代码中创建的布局。
这一次,我们不需要常规的 UI;我们需要绘制线条、像素和形状。
有多种类型的类扩展了View
类,使得可以制作许多不同类型的应用程序,并且它们都与Activity
类兼容,这是所有常规 Android 应用程序(包括绘图应用程序和游戏)的基础。
因此,一旦绘制完成,就需要将Bitmap
类(通过与Canvas
的关联)与ImageView
关联起来。最后一步是通过将其传递给setContentView
方法,告诉Activity
我们的ImageView
类代表用户要看到的内容。
Canvas、Bitmap、Paint 和 ImageView 的简要总结
如果我们需要设置的代码结构的理论看起来并不简单,当你看到相对简单的代码时,你会松一口气。
迄今为止我们所知道的内容的一个简要总结:
-
每个应用程序都需要一个
Activity
类来与用户和底层操作系统交互。因此,如果我们想成功,我们必须遵循所需的层次结构。 -
我们将使用
ImageView
类,它是View
类的一种类型。View
类是Activity
需要显示我们的应用程序给用户的内容。 -
Canvas
类还提供了绘制线条、像素和其他图形的能力。它具有绘制形状、文本、线条和图像文件,甚至绘制单个像素的所有方法。 -
Bitmap
类将与Canvas
类关联,它是实际绘制的表面。 -
Canvas
类使用Paint
类来配置颜色等细节。
最后,一旦位图被绘制,我们必须将其与ImageView
实例关联起来,然后通过setContentView
方法将其设置为Activity
的视图。
结果将是我们在Canvas
实例中绘制的Bitmap
实例,通过调用setContentView
方法显示给用户的ImageView
实例。哦!
注意
如果这并不是 100%清楚也没关系。并不是你没有清晰地看到事情 - 它只是没有一个清晰的关系。反复编写代码并使用这些技术将使事情变得更清晰。看看代码,做一下本章和下一章的演示应用程序,然后重新阅读本节。
让我们看看如何在代码中建立这种关系。不要担心输入代码,先学习它。
使用 Canvas 类
让我们看看代码和获取绘图所需的不同阶段,然后我们可以快速转移到使用Canvas
演示应用程序真正绘制一些东西。
准备所需类的实例
第一步是声明我们需要的类的实例:
// Here are all the objects(instances)
// of classes that we need to do some drawing
ImageView myImageView;
Bitmap myBlankBitmap;
Canvas myCanvas;
Paint myPaint;
上面的代码声明了ImageView
、Bitmap
、Canvas
和Paint
类型的引用。它们分别被命名为myImageView
、myBlankBitmap
、myCanvas
和myPaint
。
初始化对象
接下来,我们需要在使用之前初始化我们的新对象:
// Initialize all the objects ready for drawing
// We will do this inside the onCreate method
int widthInPixels = 800;
int heightInPixels = 800;
myBlankBitmap = Bitmap.createBitmap(widthInPixels,
heightInPixels,
Bitmap.Config.ARGB_8888);
myCanvas = new Canvas(myBlankBitmap);
myImageView = new ImageView(this);
myPaint = new Paint();
// Do drawing here
注意上面代码中的这条注释:
// Do drawing here
这是我们配置颜色和绘制内容的地方。还要注意代码顶部我们声明和初始化了两个名为widthInPixels
和heightInPixels
的int
变量。当我们编写Canvas
演示应用程序时,我将更详细地介绍其中的一些代码行。
我们现在准备好绘制了。我们所需要做的就是将ImageView
实例分配给Activity
。
设置 Activity 内容
最后,在我们看到我们的绘图之前,我们告诉 Android 使用我们称为myImageView
的ImageView
实例作为要显示给用户的内容:
// Associate the drawn upon Bitmap with the ImageView
myImageView.setImageBitmap(myBlankBitmap);
// Tell Android to set our drawing
// as the view for this app
// via the ImageView
setContentView(myImageView);
正如我们迄今为止在每个应用程序中看到的那样,setContentView
方法是Activity
类的一部分,我们传入myImageView
作为参数,而不是像我们在整本书中一直做的那样传入 XML 布局。就是这样。我们现在所需要学习的就是如何在Bitmap
实例上实际绘制。
在我们进行一些绘图之前,我认为开始一个真正的项目,逐步复制和粘贴我们刚刚讨论过的代码到正确的位置,然后实际看到一些东西被绘制到屏幕上会很有用。
让我们进行一些绘制。
画布演示应用程序
我们将创建一个新项目,只是为了探索使用Canvas
进行绘制的主题。我们将重用我们刚刚学到的知识,这次还将绘制到Bitmap
实例上。
创建一个新项目
创建一个新项目,命名为Canvas Demo
。选择空活动模板。
此外,我们将使用Activity
类的原始版本,因此MainActivity
类将扩展Activity
,而不是之前使用的AppCompatActivity
。这仅仅是因为我们不再需要AppCompatActivity
类提供的额外功能。
注意
此应用程序的完整代码可以在第二十章/Canvas Demo
文件夹中的下载包中找到。
编写 Canvas 演示应用程序
要开始,请编辑MainActivity.java
中的代码,包括添加import
指令和更改MainActivity
类继承的Activity
类的版本。还要注意下一个代码中setContentView
方法的调用也已被删除。我们很快会替换它:
import android.app.Activity;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.os.Bundle;
import android.widget.ImageView;
public class MainActivity extends Activity {
// Here are all the objects(instances)
// of classes that we need to do some drawing
ImageView myImageView;
Bitmap myBlankBitmap;
Canvas myCanvas;
Paint myPaint;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
}
}
现在我们已经声明了所需类的实例,我们可以初始化它们。在onCreate
方法中调用super.onCreate…
之后,添加以下代码,如下一个代码所示:
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// Initialize all the objects ready for drawing
// We will do this inside the onCreate method
int widthInPixels = 800;
int heightInPixels = 600;
// Create a new Bitmap
myBlankBitmap = Bitmap.createBitmap(widthInPixels,
heightInPixels,
Bitmap.Config.ARGB_8888);
// Initialize the Canvas and associate it
// with the Bitmap to draw on
myCanvas = new Canvas(myBlankBitmap);
// Initialize the ImageView and the Paint
myImageView = new ImageView(this);
myPaint = new Paint();
}
这段代码与我们之前讨论Canvas
时看到的一样。值得探索Bitmap
类的初始化,因为它并不简单。
探索位图初始化
位图,在基于图形的应用程序和游戏中更典型地用于表示不同的画笔、玩家、背景、游戏对象等。在这里,我们只是用它来绘制。在下一个项目中,我们将使用位图来表示我们绘制的主题,而不仅仅是绘制的表面。
需要解释的方法是createBitmap
方法。从左到右的参数如下:
-
宽度(以像素为单位)
-
高度(以像素为单位)
-
位图配置
位图可以以几种不同的方式配置。ARGB_8888
配置意味着每个像素由 4 个字节的内存表示。
Android 可以使用几种位图格式。这个对于一系列的颜色来说是完美的,并且将确保我们使用的位图和我们请求的颜色将按预期绘制。有更高和更低的配置,但ARGB_8888
对于本章来说是完美的。
现在我们可以进行实际的绘制了。
在屏幕上绘制
在myPaint
初始化之后,在onCreate
方法的闭合大括号内添加下面突出显示的代码:
myPaint = new Paint();
// Draw on the Bitmap
// Wipe the Bitmap with a blue color
myCanvas.drawColor(Color.argb(255, 0, 0, 255));
// Re-size the text
myPaint.setTextSize(100);
// Change the paint to white
myPaint.setColor(Color.argb(255, 255, 255, 255));
// Draw some text
myCanvas.drawText("Hello World!",100, 100, myPaint);
// Change the paint to yellow
myPaint.setColor(Color.argb(255, 212, 207, 62));
// Draw a circle
myCanvas.drawCircle(400,250, 100, myPaint);
}
前面的代码使用myCanvas.drawColor
方法来填充屏幕颜色。
myPaint.setTextSize
方法定义了接下来将绘制的文本的大小。myPaint.setColor
方法确定了未来绘制的颜色。myCanvas.drawText
方法实际将文本绘制到屏幕上。
分析传递给drawText
方法的参数,我们可以看到文本将会显示“Hello World!”,并且将在我们的位图(myBitmap
)的左侧 100 像素和顶部 100 像素处绘制。
接下来,我们再次使用setColor
方法来改变将用于绘制的颜色。最后,我们使用drawCircle
方法来绘制一个距左侧 400 像素,顶部 100 像素的圆。圆的半径为 100 像素。
我保留了解释Color.argb
方法直到现在。
解释 Color.argb
Color
类,不出所料,帮助我们操纵和表示颜色。先前使用的argb
方法返回使用alpha(不透明度/透明度),红色,绿色,蓝色(argb)模型构建的颜色。该模型使用从 0(无颜色)到 255(全颜色)的值。重要的是要注意,尽管在反思时可能似乎显而易见,但混合的颜色是光的强度,与我们混合颜色时发生的情况完全不同,例如混合油漆。
注意
要设计 argb 值并进一步探索这个模型,请查看这个方便的网站:www.rapidtables.com/web/color/RGB_Color.html
。该网站可以帮助您选择 RGB 值;然后您可以尝试 alpha 值。
用于清除绘图表面的值是255, 0, 0, 255
。这些值意味着完全不透明(纯色),没有红色,没有绿色,完全蓝色。这制作了一个蓝色。
对argb
方法的下一个调用是在第一个调用setColor
时,我们正在设置文本所需的颜色。值255, 255, 255, 255
表示完全不透明,完全红色,完全绿色和完全蓝色。当您将光与这些值混合时,您会得到白色。
对argb
方法的最终调用是在最终调用setColor
方法时,我们正在设置绘制圆的颜色。255, 21, 207, 62
制作了太阳黄色。
在我们运行代码之前的最后一步是添加对setContentView
方法的调用,该方法将我们的ImageView
(myImageView
)放置为要设置为此应用程序内容的View
实例。以下是要在onCreate
方法的结束大括号之前添加的最终代码行:
// Associate the drawn upon Bitmap with the ImageView
myImageView.setImageBitmap(myBlankBitmap);
// Tell Android to set our drawing
// as the view for this app
// via the ImageView
setContentView(myImageView);
最后,我们通过调用setContentView
方法告诉Activity
类使用myImageView
。
当您运行时,这就是Canvas
演示的样子。我们可以看到一个 800 x 800 像素的图纸。在下一章中,我们将使用更高级的技术来利用整个屏幕,并且我们还将学习有关线程以使图形实时移动:
图 20.1 - 画布演示
](https://github.com/OpenDocCN/freelearn-android-zh/raw/master/docs/andr-prog-bg-3e/img/Figure_20.1_B16773.jpg)
图 20.1 - 画布演示
如果我们更好地了解 Android 坐标系统,将有助于理解我们在Canvas
类绘图方法中使用的坐标的结果。
Android 坐标系统
正如我们所看到的,绘制位图是微不足道的。但是,我们用来绘制图形的坐标系统需要简要解释。
绘图和绘制
当我们将Bitmap
对象绘制到屏幕上时,我们传入要绘制对象的坐标。给定 Android 设备的可用坐标取决于其屏幕的分辨率。
例如,Google Pixel 手机在横向视图中的屏幕分辨率为 1,920 像素(横向)x 1,080 像素(纵向)。
这些坐标的编号系统从左上角的0, 0
开始,然后向下和向右移动,直到右下角,即像素1919, 1079
。1920
和1919
之间以及1080
和1079
之间明显的 1 像素差异是因为编号从 0 开始。
因此,当我们将位图或其他任何东西绘制到屏幕上(例如Canvas
圆和矩形),我们必须指定一个x,y
坐标。
此外,位图(或Canvas
形状)当然包括许多像素。那么,给定位图的哪个像素会在我们指定的x,y
屏幕坐标处绘制?
答案是Bitmap
对象的左上角像素。看看下一个图,它应该使用 Google Pixel 手机作为示例来澄清屏幕坐标。作为解释 Android 坐标绘图系统的图形手段,我将使用一个可爱的太空飞船图形:
图 20.2 - 屏幕坐标
此外,坐标是相对于您所绘制的内容的。因此,在我们刚刚编写的Canvas
演示应用程序以及下一个演示应用程序中,坐标是相对于位图(myBitmap
)的。在下一章中,我们将使用整个屏幕,前面的图将准确地表示发生的情况。
让我们再做一些绘图,这次使用来自图形文件的位图。我们将使用与此应用程序中看到的相同的起始代码。
创建位图
在我们深入代码之前,让我们先做一点理论,考虑一下我们将如何在屏幕上呈现图像。要绘制位图,我们将使用Canvas
类的drawBitmap
方法。
首先,我们需要在res/drawable
文件夹中的项目中添加一个位图;我们将在接下来的Bitmap
演示应用程序中真正做到这一点。现在,假设图形文件/位图的名称为myImage.png
。
接下来,我们声明一个Bitmap
类型的对象,就像我们在上一个演示中用于背景的Bitmap
对象一样:
Bitmap mBitmap;
接下来,我们需要使用我们之前添加到项目的drawable
文件夹中的首选图像来初始化mBitmap
对象:
mBitmap = BitmapFactory.decodeResource
(getResources(), R.drawable.myImage);
BitmapFactory
方法的静态decodeResource
方法用于初始化mBitmap
。它接受两个参数。第一个是对getResources
的调用,这是由Activity
类提供的。这个方法,顾名思义,可以访问项目资源,第二个参数R.drawable.myImage
指向drawable
文件夹中的myImage.png
文件。位图(mBitmap
)现在已经准备好由Canvas
类绘制。
然后,您可以使用以下代码绘制位图:
// Draw the bitmap at coordinates 100, 100
mCanvas.drawBitmap(mBitmap,
100, 100, mPaint);
下面是前一节中的飞船图形在屏幕上绘制时的样子,作为我们讨论旋转位图时的参考:
图 20.3 - 飞船图形
操作位图
然而,通常情况下,我们需要以旋转或其他方式改变的状态绘制位图。很容易使用 Photoshop 或您喜欢的任何图像编辑软件从原始位图创建更多位图以面向其他方向。然后,当我们来绘制我们的位图时,我们可以简单地决定面向哪个方向,并绘制适当的预加载位图。
然而,我认为如果我们只使用一个单一的源图像,并学习 Android 提供的用于在我们的 Java 代码中操作图像的类,将会更有趣和有教育意义。然后,您将能够将旋转和反转的图形添加到您的应用程序开发工具包中。
位图到底是什么?
位图之所以被称为位图,是因为它确实就是这样:一张位的地图。虽然有许多使用不同范围和值来表示颜色和透明度的位图格式,但它们都归结为同一件事。它们是一组/地图值,每个值代表一个像素的颜色。
因此,要旋转、缩放或反转位图,我们必须对位图的每个像素/位进行适当的数学计算。这些计算并不是非常复杂,但也不是特别简单。如果您完成了高中的数学课程,您可能不会太费力地理解这些数学知识。
不幸的是,仅仅理解数学是不够的。我们还需要设计高效的代码,以及了解位图格式,然后为每种格式修改我们的代码。这并不是微不足道的。幸运的是,Android API 已经为我们做好了一切。见识一下Matrix
类。
Matrix 类
这个类被命名为Matrix
,因为它使用数学概念和规则来对一系列值(称为矩阵的复数)进行计算。
注意
Android 的Matrix
类与同名电影系列无关。然而,作者建议所有有抱负的应用程序开发者服用红色药丸。
你可能对矩阵很熟悉,但如果你不熟悉也不用担心,因为Matrix
类隐藏了所有的复杂性。此外,Matrix
类不仅允许我们对一系列值进行计算,还具有一些预先准备的计算,使我们能够做一些事情,比如围绕另一个点旋转特定角度的点。所有这些都不需要了解三角学。
注意
如果你对数学运算的工作方式感到好奇,并且想要一个绝对初学者指南来了解旋转游戏对象的数学知识,那么请看看我网站上以可飞行和可旋转的飞船为结尾的一系列 Android 教程:
gamecodeschool.com/essentials/calculating-heading-in-2d-games-using-trigonometric-functions-part-1/
gamecodeschool.com/essentials/rotating-graphics-in-2d-games-using-trigonometric-functions-part-2/
gamecodeschool.com/android/2d-rotation-and-heading-demo/
本书将继续使用 Android 的Matrix
类,但在下一章创建粒子系统时,我们将进行稍微更高级的数学运算。
将位图反转以面对相反方向
首先,我们需要创建Matrix
类的一个实例。下一行代码以熟悉的方式调用new
来调用默认构造函数:
Matrix matrix = new Matrix();
注意
请注意,您现在不需要将任何此代码添加到项目中;稍后将再次显示所有与Matrix
相关的代码,其中将提供更多的上下文。我只是认为在此之前单独看到所有与Matrix
相关的代码会更容易一些。
现在我们可以使用Matrix
类的许多巧妙方法之一。preScale
方法需要两个参数:一个用于水平变化,一个用于垂直变化。看看下面的代码:
matrix.preScale(-1, 1);
preScale
方法将循环遍历每个像素位置,并将所有水平坐标乘以-1
,所有垂直坐标乘以1
。
这些计算的效果是所有垂直坐标将保持不变,因为如果乘以 1,那么数字不会改变。然而,当你乘以-1 时,像素的水平位置将被倒置。看下面的例子:
水平位置 0、1、2、3 和 4 将变为 0、-1、-2、-3 和-4。
在这个阶段,我们已经创建了一个可以对位图执行必要计算的矩阵。我们实际上还没有对位图做任何事情。要使用这个矩阵,我们调用Bitmap
类的createBitmap
方法,如下面所示的代码行:
mBitmapLeft = Bitmap
.createBitmap(mBitmap,
0, 0, 25, 25, matrix, true);
先前的代码假设mBitmapLeft
已经初始化,以及mBitmap
。createBitmap
方法的参数解释如下:
-
mBitmapHeadRight
是一个Bitmap
对象,已经被创建和缩放,并且已经加载了一个飞船(面向右侧)的图像。这个图像将被用作创建新位图的源。源位图实际上不会被改变。 -
0, 0
是我们希望将新位图映射到的水平和垂直起始位置。 -
25, 25
参数是设置位图缩放大小的值。 -
下一个参数是我们预先准备的
Matrix
实例matrix
。 -
最后一个参数
true
指示createBitmap
方法需要过滤以正确处理位图的创建。
当绘制到屏幕上时,mBitmapLeft
将如下所示:
图 20.4 - mBitmapLeft
我们还可以使用旋转矩阵创建面向上或面向下的位图。
旋转位图以面向上或向下
让我们看看如何旋转位图,然后我们可以构建演示应用程序。我们已经有了Matrix
类的一个实例,所以我们所要做的就是调用preRotate
方法,创建一个能够按照preRotate
的单个参数指定的角度旋转每个像素的矩阵。看看这行代码:
// A matrix for rotating
matrix.preRotate(-90);
这有多简单?matrix
实例现在已准备好逆时针(-
)旋转我们传递给它的任何一系列数字,旋转90
度。
下一行代码与我们之前解析的对createBitmap
的调用具有完全相同的参数,只是新的Bitmap
实例分配给了mBitmapUp
,matrix
的效果是执行旋转而不是preScale
:
mBitmapUp = Bitmap
.createBitmap(mBitmap,
0, 0, ss, ss, matrix, true);
这是绘制mBitmapUp
时的样子:
图 20.5 - mBitmapUp
您还可以使用相同的技术,但在preRotate
的参数中使用不同的值来将位图向下旋转。让我们继续进行演示应用程序,看看所有这些内容是如何运作的。
位图操作演示应用程序
既然我们已经学习了理论,让我们来绘制和旋转一些位图。使用操作位图
创建一个新项目。
将图形添加到项目中
右键单击并从第二十章/操作位图/可绘制
文件夹中选择bob.png
图形文件。
在 Android Studio 中,定位项目资源管理器窗口中的app/res/drawable
文件夹。下一张截图清楚地显示了这个文件夹的位置以及其中带有bob.png
图像的样子:
图 20.6 - app/res/drawable 文件夹中的 bob.png
右键单击项目中的bob.png
文件。单击两次确定,以确认将文件导入项目的默认选项。
编辑MainActivity
类的代码,包括所有必需的import
指令、Activity
类的基本版本和一些成员变量,以便我们可以开始。此阶段MainActivity
类的状态如下所示:
import android.app.Activity;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.os.Bundle;
import android.widget.ImageView;
import android.graphics.BitmapFactory;
import android.graphics.Color;
import android.graphics.Matrix;
public class MainActivity extends Activity {
// Here are all the objects(instances)
// of classes that we need to do some drawing
ImageView myImageView;
Bitmap myBlankBitmap;
Bitmap bobBitmap;
Canvas myCanvas;
Paint myPaint;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
}
}
现在我们可以在onCreate
中初始化所有成员:
// Initialize all the objects ready for drawing
int widthInPixels = 2000;
int heightInPixels = 1000;
// Create a new Bitmap
myBlankBitmap = Bitmap.createBitmap(widthInPixels,
heightInPixels,
Bitmap.Config.ARGB_8888);
// Initialize Bob
bobBitmap = BitmapFactory.decodeResource
(getResources(), R.drawable.bob);
// Initialize the Canvas and associate it
// with the Bitmap to draw on
myCanvas = new Canvas(myBlankBitmap);
// Initialize the ImageView and the Paint
myImageView = new ImageView(this);
myPaint = new Paint();
// Draw on the Bitmap
// Wipe the Bitmap with a blue color
myCanvas.drawColor(Color.argb(255, 0, 0, 255));
接下来,我们添加对三种方法的调用,我们很快将编写这些方法,并将我们的新绘图设置为应用程序的视图:
// Draw some bitmaps
drawRotatedBitmaps();
drawEnlargedBitmap();
drawShrunkenBitmap();
// Associate the drawn upon Bitmap with the ImageView
myImageView.setImageBitmap(myBlankBitmap);
// Tell Android to set our drawing
// as the view for this app
// via the ImageView
setContentView(myImageView);
现在添加执行位图操作的drawRotatedBitmap
方法:
void drawRotatedBitmaps(){
float rotation = 0f;
int horizontalPosition =350;
int verticalPosition = 25;
Matrix matrix = new Matrix();
Bitmap rotatedBitmap = Bitmap.createBitmap(100,
200,
Bitmap.Config.ARGB_8888);
for(rotation = 0; rotation < 360; rotation += 30){
matrix.reset();
matrix.preRotate(rotation);
rotatedBitmap = Bitmap
.createBitmap(bobBitmap,
0, 0, bobBitmap
.getWidth()-1,
bobBitmap.getHeight()-1,
matrix, true);
myCanvas.drawBitmap(rotatedBitmap,
horizontalPosition,
verticalPosition,
myPaint);
horizontalPosition += 120;
verticalPosition += 70;
}
}
先前的代码使用for
循环来循环遍历 360 度,每次 30 度。循环中的每个值都用于Matrix
实例中旋转 Bob 的图像,然后使用drawBitmap
方法将其绘制到屏幕上。
按照下面显示的方式添加最后两种方法:
void drawEnlargedBitmap(){
bobBitmap = Bitmap
.createScaledBitmap(bobBitmap,
300, 400, false);
myCanvas.drawBitmap(bobBitmap, 25,25, myPaint);
}
void drawShrunkenBitmap(){
bobBitmap = Bitmap
.createScaledBitmap(bobBitmap,
50, 75, false);
myCanvas.drawBitmap(bobBitmap, 250,25, myPaint);
}
drawEnlargedBitmap
方法使用createScaledBitmap
方法,大小为 300x400 像素。然后,drawBitmap
方法将其绘制到屏幕上。
drawShrunkenBitmap
使用完全相同的技术,只是缩放然后绘制一个 50x75 像素的图像。
运行应用程序,看看 Bob 是如何长大、缩小,然后在 30 度间隔下 360 度旋转的,如下一张截图所示:
![图 20.7 - 应用程序运行方式
第 20.7 图 - B16773.jpg
图 20.7 - 应用程序运行方式
我们绘图库中唯一缺少的是观看所有这些活动发生的能力。我们将在下一章中填补这一知识空白。
常见问题
- 我知道如何进行所有这些绘图,但我看不到任何移动。为什么?
要看到物体移动,您需要能够调节绘图的每个部分发生的时间。您需要使用动画技术。这并不是微不足道的,但对于一个有决心的初学者来说也不是难以掌握的。我们将在下一章中学习所需的主题。
总结
在本章中,我们看到了如何绘制自定义形状、文本和位图。现在我们知道如何绘制和操作基本形状、文本和位图,我们可以把事情提升到一个新水平。
在下一章中,我们将开始我们的下一个重要应用程序,这是一个儿童绘画应用程序,实际上在按下按钮时会活跃起来。
第二十一章:线程和启动 Live Drawing 应用程序
在本章中,我们将开始我们的下一个应用程序。这个应用程序将是一个儿童绘画应用程序,用户可以用手指在屏幕上绘画。但是,这个绘画应用程序将略有不同。用户绘制的线条将包括成千上万个粒子的粒子系统。我们将称这个项目为 Live Drawing。
为了实现这一点,我们将执行以下操作:
-
开始使用 Live Drawing 应用程序
-
了解实时交互,有时被称为游戏循环
-
了解线程
-
编写一个准备在下一章绘制的实时系统
让我们开始吧。
技术要求
您可以在 GitHub 上找到本章中的代码文件,网址为github.com/PacktPublishing/Android-Programming-for-Beginners-Third-Edition/tree/main/chapter%2021
。
创建 Live Drawing 项目
要开始,请在 Android Studio 中创建一个名为Live Drawing
的新项目。使用空活动项目。
现在我们将考虑文件的名称和屏幕房地产。在这个项目中,我们将学习一些新东西。对于我们的 Activity 类,使用默认名称并不总是合适的。在这个项目中,Activity 类不会是最重要的类,MainActivity
似乎不是一个合适的名称。让我们重命名它。
将 MainActivity 重构为 LiveDrawingActivity
对于我们的代码的所有不同部分使用有意义的名称是一个很好的做法。对于这个项目,我认为MainActivity
有点模糊和不确定。我们可以将其使用,但让我们将其重命名为更有意义的名称。这也将让我们看到如何将MainActivity
文件名更改为LiveDrawingActivity
,Android Studio 将更改AndroidManifest.xml
文件和MainActivity.java
(即将更改为LiveDrawingActivity.java
)文件中的一些代码。
在项目面板中,右键单击MainActivity
文件,然后选择重构 | 重命名。在弹出窗口中,将MainActivity更改为LiveDrawingActivity。将所有其他选项保持为默认值,然后单击重构按钮。
请注意,项目面板中的文件名已如预期更改,但AndroidManifest.xml
文件中的多个实例以及LiveDrawingActivity.java
文件中的多个实例也已更改为LiveDrawingActivity
。如果您感兴趣,现在可以扫描这些文件以查看这一点,但无论如何,我们将在即将到来的章节中更详细地讨论这两个文件。
注意
重构是一个重要的工具,了解幕后发生的更多事情对于避免混淆至关重要。
将游戏锁定到全屏和横向方向
我们希望使用用户 Android 设备提供的每个像素,因此我们将对AndroidManifest.xml
文件进行更改,这允许我们为应用程序使用一个样式,隐藏用户界面中的所有默认菜单。
从manifests
文件夹中打开AndroidManifest.xml
文件。在AndroidManifest.xml
文件中,找到以下代码行:android:name=".LiveDrawingActivity">。
将光标放在先前显示的关闭>
之前。按Enter键几次,将>
移动到先前显示的其余行下方几行。
在".LiveDrawingActivity"
下方,但在新定位的>
之前,键入或复制并粘贴下一行代码,以使游戏在没有默认用户界面的情况下运行。
请注意,代码行显示在两行上,因为它太长而无法适应打印页面,但在 Android Studio 中,您应该将其输入为一行:
android:theme=
"@android:style/Theme.Holo.Light.NoActionBar.Fullscreen"
这是一组繁琐的步骤,所以在这里我向您展示了这个文件的更大范围,其中我们刚刚添加的代码也被突出显示,以提供额外的上下文。如前所述,我不得不将一些代码行显示为两行:
…
<activity android:name=".LiveDrawingActivity"
android:theme=
"@android:style/
Theme.Holo.Light.NoActionBar.Fullscreen"
>
<intent-filter>
<action android:name="android.intent.action.MAIN"
/>
<category android:name= "android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
…
现在我们的应用程序将使用设备提供的所有屏幕空间,而不需要任何额外的菜单。我们还将看到一些新的 Java 代码,使我们的应用程序占据屏幕的每一个像素。
创建一些占位符类
这个应用程序只包含 Java 文件。到本章结束时的所有代码都可以在下载包的第二十一章文件夹中找到。
接下来,我们将创建一些空类,我们将在接下来的两章项目中编写。创建一个名为LiveDrawingView
的新类,一个名为ParticleSystem
的新类,以及一个名为Particle
的新类。
让我们稍微展望一下。
展望 Live Drawing 应用程序
由于这个应用程序更加深入,并且需要实时响应,因此需要使用稍微更深入的结构。起初,这似乎是一个复杂,但从长远来看,它甚至可以使我们的代码更简单、更容易理解。
在 Live Drawing 应用程序中,我们将有四个类:
-
LiveDrawingActivity
:Android API 提供的Activity
类是与操作系统交互的类。我们已经看到了当用户点击应用程序图标启动应用程序时,操作系统是如何与onCreate
交互的。我们不再使用一个叫做MainActivity
的类来处理所有事情,而是使用一个基于 Activity 的类来处理应用程序的启动和关闭,以及通过获取屏幕分辨率来帮助初始化。这个类将是Activity
类型是有意义的。然而,很快你会看到,我们将委托触摸交互给另一个类,这个类也将处理几乎每个方面的应用程序。这将为我们介绍一些新的有趣的概念。 -
LiveDrawingView
:这是负责绘图和创建实时环境的类,允许用户在他们的创作移动和发展的同时进行交互。 -
ParticleSystem
:这个类将管理Particle
类的成千上万个实例。 -
Particle
:这个类将是最简单的。它将在屏幕上有一个位置和一个方向。当LiveDrawingView
类提示时,它将每秒更新大约 60 次。
现在我们可以开始编码了。
编写 LiveDrawingActivity 类
让我们开始编写基于Activity
的类。当我们重构MainActivity
时,我们将这个类称为LiveDrawingActivity
。
用以下代码替换LiveDrawingActivity
类的内容(不包括包声明):
import android.app.Activity;
import android.graphics.Point;
import android.os.Bundle;
import android.view.Display;
import android.view.Window;
public class LiveDrawingActivity extends Activity {
private LiveDrawingView mLiveDrawingView;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
requestWindowFeature(Window.FEATURE_NO_TITLE);
Display display = getWindowManager()
.getDefaultDisplay();
Point size = new Point();
display.getSize(size);
mLiveDrawingView = new LiveDrawingView(
this, size.x, size.y);
setContentView(mLiveDrawingView);
}
}
代码显示了一些错误,我们很快会讨论它们。
第一行新代码是这样的:
requestWindowFeature(Window.FEATURE_NO_TITLE);
这行代码从用户界面中移除了标题。当我们运行这个应用程序时,屏幕将完全为空。
代码以以下方式获取设备的像素数(宽和高)。再看一下下一行新代码:
Display display = getWindowManager().getDefaultDisplay();
我们创建了一个名为display
的Display
类型的对象,并用调用getWindowManager
和getDefaultDisplay
方法的结果依次初始化它,这些方法都是Activity
类的一部分。
然后我们创建了一个名为size
的Point
类型的新对象。我们将size
作为参数发送给display.getSize
方法。Point
类型有一个x
和y
成员变量,因此size
对象也有,经过第三行代码后,它现在保存了显示器的宽度和高度(以像素为单位)。
现在我们将屏幕分辨率隐藏在size
对象中的x
和y
变量中。
接下来的新事物是,我们声明了我们的LiveDrawingView
类的一个实例。目前,这是一个空类:
private LiveDrawingView mLiveDrawingView;
接下来,在onCreate
方法中,我们像这样初始化mLiveDrawingView
:
mLiveDrawingView = new LiveDrawingView(this, size.x, size.y);
我们正在向LiveDrawingView
构造函数传递三个参数。显然,我们还没有编写构造函数,我们知道默认构造函数不带参数。因此,在我们修复它之前,这行代码将导致错误。
传递的参数很有趣。首先是this
,它是对LiveDrawingActivity
类的引用。LiveDrawingView
类将需要使用它需要这个引用的方法。
第二个和第三个参数是水平和垂直屏幕分辨率。我们的应用程序需要这些来执行诸如检测屏幕边缘和将绘图对象缩放到适当大小等任务是有意义的。当我们开始编写LiveDrawingView
构造函数时,我们将进一步讨论这些参数。
接下来,看一下接下来的一行:
setContentView(mLiveDrawingView);
这是在 Canvas Demo 应用程序中,我们将ImageView
设置为应用程序的内容。请记住,Activity
类的setContentView
方法必须接受一个View
对象,而ImageView
是一个View
。前一行代码似乎在暗示我们将使用我们的LiveDrawingView
类作为应用程序的可见内容。但是LiveDrawingView
,尽管名字是这样,但它还不是View
。至少目前还不是。
在我们向LiveDrawingActivity
添加几行代码之后,我们将修复构造函数和不是View
问题。
读者挑战
你能猜到解决方案可能是哪种 OOP 概念吗?
添加这两个重写的方法,然后我们将讨论它们。将它们添加到onCreate
方法的右大括号下面,但在LiveDrawingActivity
类的右大括号之前添加:
@Override
protected void onResume() {
super.onResume();
// More code here later in the chapter
}
@Override
protected void onPause() {
super.onPause();
// More code here later in the chapter
}
我们所做的是重写Activity
类的另外两个方法。我们将看到为什么我们需要这样做以及我们将在这些方法内部做什么。这里要注意的一点是,通过添加这些重写的方法,我们给了操作系统在另外两种情况下通知我们用户意图的机会。就像我们在 Note to Self 应用程序中保存和加载数据时所做的那样。
在这一点上,转到LiveDrawingView
类是有意义的,这是该应用程序的主要类。我们将在本章末回到LiveDrawingActivity
类。
编写 LiveDrawingView 类
我们要做的第一件事是解决LiveDrawingView
类不是View
类型的问题。更新类声明如下所示:
class LiveDrawingView extends SurfaceView {
注意
您需要导入android.view.SurfaceView
类。
SurfaceView
是View
的后代,现在LiveDrawingView
也是View
的一种类型,通过继承。再次看看已添加的import
语句。这种关系如下所示:
import android..SurfaceView
注意
请记住,正是由于多态性,我们可以将View
的后代发送到LiveDrawingActivity
类中的setContentView
方法,而正是由于继承,LiveDrawingView
类现在是SurfaceView
的一种类型。
有很多View
的后代可以扩展以解决这个初始问题,但随着我们继续,我们将看到SurfaceView
类具有一些非常特定的功能,非常适合实时交互应用程序,这使得这个选择对我们来说是正确的。
我们在这个类和LiveDrawingActivity
类中仍然有错误。这两个错误都是由于缺少合适的构造方法。
这里有一张屏幕截图显示了LiveDrawingView
类中的错误,因为我们扩展了SurfaceView
:
图 21.1 - LiveDrawingView 类中的错误
LiveDrawingActivity
中的错误更明显;我们调用了一个不存在的方法。然而,前面截图中显示的错误不太容易理解。现在让我们讨论LiveDrawingView
类声明中的错误。
LiveDrawingView
类,现在是一个SurfaceView
,必须提供一个构造函数,因为如 OOP 章节中所述,一旦你提供了自己的构造函数,默认(无参数)构造函数就不复存在了。由于SurfaceView
类实现了几种不同的构造函数,我们必须明确实现其中的一个或编写我们自己的。因此出现了之前的错误。
由于没有提供的SurfaceView
构造函数正是我们所需要的,我们将提供我们自己的构造函数。
注意
如果你想知道如何知道提供了哪些构造函数和关于 Android 类的其他细节,只需谷歌一下。输入类名,后跟API
。谷歌几乎总会提供一个指向 Android 开发者网站相关页面的链接作为最顶部的结果。这是SurfaceView
页面的直接链接:developer.android.com/reference/android/view/SurfaceView.html
。查看Public constructors标题下,你会看到一些可选的构造函数。
LiveDrawingActivity
还要求我们创建一个构造函数,与我们尝试在LiveDrawingActivity
类中初始化的方式匹配:
mLiveDrawingView = new LiveDrawingView(this, size.x, size.y);
让我们添加一个构造函数,与从LiveDrawingActivity
传入this
和屏幕分辨率的调用匹配,并一次解决两个问题。
编写LiveDrawingView
类
记住,LiveDrawingView
类无法看到LiveDrawingActivity
类中的变量。通过使用构造函数,LiveDrawingActivity
提供了对自身(this
)以及包含在size.x
和size.y
中的像素屏幕大小的引用。将此构造函数添加到LiveDrawingView.java
文件中。代码必须放在类的开头和结尾的大括号内。这是一种约定,但不是必须的,将构造函数放在其他方法之上,但在成员变量声明之后:
// The LiveDrawingView constructor
// Called when this line:
// mLiveDrawingView = new LiveDrawingView(this, size.x, size.y);
// is executed from LiveDrawingActivity
public LiveDrawingView(Context context, int x, int y) {
// Super... calls the parent class
// constructor of SurfaceView
// provided by the Android API
super(context);
}
注意
使用以下代码导入Context
类:
import android.content.Context;
现在我们的LiveDrawingView
类或初始化它的LiveDrawingActivity
类中没有错误。
在这个阶段,我们可以运行应用程序,看看使用LiveDrawingView
作为setContentView
中的View
是否有效,并且我们有一个美丽的空白屏幕,准备在上面绘制我们的粒子系统。如果你愿意,可以尝试一下,但我们将编写LiveDrawingView
类,使其做一些事情,包括在构造函数中添加代码,接下来。
在这个项目的过程中,我们将不断回到这个类。我们现在要做的是准备好基本知识,以便在下一章中编写ParticleSystem
实例后添加它们。
为了实现这一点,首先我们将添加一堆成员变量,然后我们将在构造函数内部添加一些代码,以便在LiveDrawingActivity
实例化/创建时设置类。
接下来,我们将编写draw
方法,它将揭示我们需要采取的新步骤,以便每秒在屏幕上绘制 60 次,并且我们还将看到一些熟悉的代码,使用了我们上一章的老朋友Canvas
、Paint
和drawText
。
在这一点上,我们需要讨论更多的理论 - 诸如我们将如何计时粒子的动画以及如何锁定这些时间而不干扰 Android 的平稳运行等事情。这最后两个主题,游戏循环和线程,将允许我们添加本章的最终代码,并见证我们的粒子系统绘画应用程序的运行 - 尽管只有一点点文本。
注意
游戏循环是一个概念,描述了允许虚拟系统在允许用户改变/交互的同时更新和绘制自己的能力。
添加成员变量
按照下面所示的变量添加到LiveDrawingView
声明之后但在构造函数之前,然后导入必要的额外类:
// Are we debugging?
private final boolean DEBUGGING = true;
// These objects are needed to do the drawing
private SurfaceHolder mOurHolder;
private Canvas mCanvas;
private Paint mPaint;
// How many frames per second did we get?
private long mFPS;
// The number of milliseconds in a second
private final int MILLIS_IN_SECOND = 1000;
// Holds the resolution of the screen
private int mScreenX;
private int mScreenY;
// How big will the text be?
private int mFontSize;
private int mFontMargin;
// The particle systems will be declared here later
// These will be used to make simple buttons
添加以下import
代码:
import android.graphics.Canvas;
import android.graphics.Paint;
import android.view.SurfaceHolder;
确保学习代码,然后我们可以讨论它。
我们使用在成员变量名之前添加m
的命名约定。当我们在方法中添加局部变量时,这将有助于区分它们。
另外,请注意所有变量都声明为private
。你可以愉快地删除所有private
访问修饰符,代码仍然可以工作,但由于我们没有必要从这个类的外部访问任何这些变量,因此通过声明它们为private
来保证它永远不会发生是明智的。
第一个成员变量是DEBUGGING
。我们将其声明为final
,因为我们不希望在应用程序执行期间更改其值。请注意,将其声明为final
并不妨碍我们在希望在调试和非调试之间切换时手动更改其值。
我们声明的接下来的三个类的实例将处理屏幕上的绘图。请注意我突出显示的新类:
// These objects are needed to do the drawing
private SurfaceHolder mOurHolder;
private Canvas mCanvas;
private Paint mPaint;
SurfaceHolder
类是必需的,以便进行绘图。它实际上是持有绘图表面的对象。当我们编写draw
方法时,我们将看到它允许我们使用的方法来在屏幕上绘制。
接下来的两个变量让我们对实现平滑和一致的动画有了一些了解。再次列出如下:
// How many frames per second did we get?
private long mFPS;
// The number of milliseconds in a second
private final int MILLIS_IN_SECOND = 1000;
mFPS
变量的类型是long
,因为它将保存一个巨大的数字。计算机从 1970 年以来以毫秒为单位来测量时间 - 关于这一点,我们在谈论游戏循环时会详细讨论。但现在,我们需要知道,监控和测量每一帧动画的速度是确保粒子移动正常的关键。
第一个mFPS
将在每一帧动画中重新初始化,大约每秒 60 次。它将被传递到每个粒子系统(每一帧动画)中,以便它知道经过了多少时间。
MILLIS_IN_SECOND
变量初始化为1000
。一秒钟确实有1000
毫秒。我们将在计算中使用这个变量,因为它会使我们的代码比使用字面值1000
更清晰。它声明为final
,因为一秒钟的毫秒数显然永远不会改变。
我们刚刚添加的代码的下一部分为了方便起见再次显示如下:
// Holds the resolution of the screen
private int mScreenX;
private int mScreenY;
// How big will the text be?
private int mFontSize;
private int mFontMargin;
变量mScreenX
和mScreenY
将保存屏幕的水平和垂直分辨率。请记住,它们是从LiveDrawingActivity
传递到构造函数中的。
接下来的两个变量mFontSize
和mMarginSize
将根据屏幕大小(以像素为单位)进行初始化,以保存像素值,使我们的文本格式整齐,并且比为每个文本位进行不断的计算更简洁。
现在我们可以开始在构造函数中初始化一些这些变量。
编写 LiveDrawingView 构造函数
将突出显示的代码添加到构造函数中。确保也学习代码,然后我们可以讨论它:
public LiveDrawingView(Context context, int x, int y) {
// Super... calls the parent class
// constructor of SurfaceView
// provided by Android
super(context);
// Initialize these two members/fields
// With the values passed in as parameters
mScreenX = x;
mScreenY = y;
// Font is 5% (1/20th) of screen width
mFontSize = mScreenX / 20;
// Margin is 1.3% (1/75th) of screen width
mFontMargin = mScreenX / 75;
// getHolder is a method of SurfaceView
mOurHolder = getHolder();
mPaint = new Paint();
// Initialize the two buttons
// Initialize the particles and their systems
}
我们刚刚添加到构造函数的代码首先使用传递的参数值(x
和y
)来初始化mScreenX
和mScreenY
。我们的整个LiveDrawingView
类现在可以在需要时访问屏幕分辨率。以下是这两行代码:
// Initialize these two members/fields
// With the values passed in as parameters
mScreenX = x;
mScreenY = y;
接下来,我们将mFontSize
和mFontMargin
初始化为屏幕宽度的像素分数。这些值有点随意,但它们有效,并且我们将使用这些变量的各种倍数来使文本在屏幕上整齐对齐。以下是我所指的两行代码:
// Font is 5% (1/20th) of screen width
mFontSize = mScreenX / 20;
// Margin is 1.3% (1/75th) of screen width
mFontMargin = mScreenX / 75;
接下来,我们初始化了我们的Paint
和SurfaceHolder
对象。Paint
使用了默认构造函数,就像我们之前做过的那样,但mHolder
使用了getHolder
方法,这是SurfaceView
类的一个方法。getHolder
方法返回一个初始化为mHolder
的引用,所以mHolder
现在就是那个引用。简而言之,mHolder
现在已经准备好使用了。我们可以访问这个方便的方法,因为LiveDrawingView
是一个SurfaceView
:
// getHolder is a method of SurfaceView
mOurHolder = getHolder();
mPaint = new Paint();
在我们像以前一样使用Paint
和Canvas
类之前,我们需要在draw
方法中做更多的准备工作。我们很快就会看到具体是什么。请注意注释,指示我们最终将初始化粒子系统以及两个控制按钮的位置。
让我们准备好开始绘制。
编写draw
方法
在构造方法之后立即添加下面显示的draw
方法。代码中会有一些错误。我们将处理它们,然后我们将详细介绍draw
方法在SurfaceView
类中的工作原理,因为其中有一些看起来完全陌生的代码,以及一些熟悉的代码。这是要添加的代码:
// Draw the particle systems and the HUD
private void draw() {
if (mOurHolder.getSurface().isValid()) {
// Lock the canvas (graphics memory) ready to draw
mCanvas = mOurHolder.lockCanvas();
// Fill the screen with a solid color
mCanvas.drawColor(Color.argb(255, 0, 0, 0));
// 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
// Draw the buttons
// Draw the HUD
if(DEBUGGING){
printDebuggingText();
}
// Display the drawing on screen
// unlockCanvasAndPost is a method of
SurfaceHolder
mOurHolder.unlockCanvasAndPost(mCanvas);
}
}
我们有两个错误。一个是Color
类需要导入。您可以按照通常的方式修复这个问题,或者手动添加下一行代码。无论您选择哪种方法,以下额外的行需要添加到文件顶部的代码中:
import android.graphics.Color;
让我们处理另一个错误。
添加printDebuggingText
方法
第二个错误是调用printDebuggingText
。这个方法还不存在。让我们现在添加它。
在draw
方法之后添加以下代码:
private void printDebuggingText(){
int debugSize = mFontSize / 2;
int debugStart = 150;
mPaint.setTextSize(debugSize);
mCanvas.drawText("FPS: " + mFPS ,
10, debugStart + debugSize, mPaint);
// We will add more code here in the next chapter
}
先前的代码使用局部变量debugSize
来保存成员变量mFontSize
的一半。这意味着mFontSize
(用于 HUD)是根据屏幕分辨率动态初始化的,debugSize
始终是它的一半。然后在开始绘制文本之前,使用debugSize
变量设置字体的大小。debugStart
变量只是一个垂直开始打印调试文本的好位置的猜测。
然后使用这两个值来定位屏幕上显示当前每秒帧数的一行文本。由于这个方法是从draw
中调用的,而draw
又将从游戏循环中调用,所以这行文本将每秒刷新 60 次。
注意
在非常高或非常低分辨率的屏幕上,您可能需要尝试不同的文本大小,以找到更适合您屏幕的大小。
让我们来探索draw
方法中的这些新代码,以及我们如何可以使用SurfaceView
来处理所有的绘图需求,LiveDrawingView
类是从SurfaceView
派生出来的。
理解draw
方法和SurfaceView
类
从方法的中间开始,逐渐向外工作,我们有一些熟悉的东西,比如调用drawColor
、setTextSize
和drawText
方法。我们还可以看到注释,指示我们最终将添加代码来绘制粒子系统和 HUD:
-
drawColor
代码用纯色清除屏幕。 -
setTextSize
方法设置了绘制 HUD 的文本大小。 -
一旦我们更深入地探索了粒子系统,我们将编写绘制 HUD 的代码。我们将让玩家知道他们的绘图包括多少个粒子和系统。
然而,完全新的是draw
方法的开头的代码。这里是它:
if (mOurHolder.getSurface().isValid()) {
// Lock the canvas (graphics memory) ready to draw
mCanvas = mOurHolder.lockCanvas();
…
…
if
语句包含对getSurface
的调用,并将其与isValid
的调用链接在一起。如果这行返回true
,则确认我们要操作的内存区域以表示我们的绘图帧是可用的,代码将继续在if
语句内部执行。
这些方法内部发生了什么(特别是第一个方法)是非常复杂的。它们是必需的,因为我们所有的绘制和其他处理(比如移动对象)都将与检测用户输入的代码和监听操作系统的消息异步进行。这在之前的项目中并不是问题,因为我们的代码只是绘制了一个帧然后等待。
现在我们希望每秒执行 60 次代码,我们需要确认我们可以访问内存 - 在访问之前。
这引发了更多关于这段代码如何异步运行的问题。这将在我们不久后讨论线程时得到解答。现在,只需知道这行代码检查我们的代码的某个其他部分或 Android 本身是否正在使用所需的内存部分。如果空闲,那么 if
语句内的代码将执行。
此外,在 if
语句内执行的第一行代码调用了 lockCanvas
方法,以便如果另一个应用程序或 Android 尝试在我们的代码访问内存时访问它,它将无法访问。然后我们进行所有的绘制。
最后,在 draw
方法中,最后有这样一行代码(以及注释):
// Display the drawing on screen
// unlockCanvasAndPost is a method of SurfaceHolder
mOurHolder.unlockCanvasAndPost(mCanvas);
unlockCanvasAndPost
方法将我们新装饰的 Canvas
对象 (mCanvas
) 发送到屏幕上进行绘制,并释放锁定,以便代码的其他部分可以再次使用它 - 尽管在整个过程重新开始之前只是非常短暂的时间。这个过程在每一帧动画中都会发生。
我们现在理解了 draw
方法中的代码;然而,我们仍然没有调用 draw
方法的机制。事实上,我们甚至没有调用一次 draw
方法。我们需要讨论游戏循环和线程。
游戏循环
游戏循环到底是什么?几乎每个实时绘图/图形游戏都有一个游戏循环。即使你可能怀疑没有游戏循环的游戏,比如回合制游戏,仍然需要将玩家输入与绘图和人工智能同步,同时遵循底层操作系统的规则。
需要不断更新应用中的对象,也许是通过移动它们,在同时响应用户输入的同时绘制所有对象的当前位置。一个图表可能会有所帮助:
图 21.2 – 游戏循环
我们的游戏循环包括三个主要阶段:
-
通过移动它们、检测碰撞和处理粒子移动和状态变化等方式更新所有游戏/绘图对象。
-
基于刚刚更新的数据,绘制动画帧的最新状态。
-
响应用户的屏幕触摸。
我们已经有一个用于处理循环的 draw
方法。这表明我们也将有一个方法来进行所有的更新。我们很快将编写一个 update
方法的大纲。此外,我们知道我们可以响应屏幕触摸,尽管我们需要稍微调整之前所有项目的代码,因为我们不再在 Activity 中工作,也不再在布局中使用传统的 UI 小部件。
还有一个问题,就是(我简要提到过的)所有的更新和绘制都是异步进行的,与屏幕触摸的检测和操作系统的监听是分开的。
注意
只是为了明确,异步意味着它不会同时发生。我们的游戏代码将通过与 Android 和用户界面共享执行时间来运行。CPU 将在我们的代码和 Android/用户输入之间非常快速地来回切换。
但是这三个阶段究竟如何循环执行?我们将如何编写这个异步系统,其中可以调用 update
和 draw
方法,以及如何使循环以正确的速度(帧率)运行?
正如我们可能猜到的那样,编写一个高效的游戏循环并不像一个 while
循环那样简单。
注意
然而,我们的游戏循环也将包含一个 while
循环。
我们需要考虑时机,开始和结束循环,以及不要让操作系统变得无响应,因为我们正在垄断整个 CPU 在我们的循环中。
但是我们何时以及如何调用我们的draw
方法?我们如何测量和跟踪帧速率?考虑到这些问题,我们完成的游戏循环可能可以更好地用下一个图表表示。注意引入了线程的概念。
图 21.3 - 完成的游戏循环
现在我们知道我们想要实现什么,让我们学习一下线程。
线程
那么,什么是线程?在编程中,你可以把线程想象成故事中的线索一样。在故事的一个线索中,我们可能有主要角色在前线与敌人作战,而在另一个线索中,士兵的家人正在日复一日地生活。当然,一个故事不一定只有两个线索;我们可以引入第三个线索。也许故事还讲述了政治家和军事指挥官做出决策。这些决策会微妙地或者不那么微妙地影响其他线索中发生的事情。
编程线程就像这样。我们在程序中创建部分/线程来控制不同的方面。在 Android 中,当我们需要确保一个任务不会干扰应用程序的主(UI)线程,或者当我们有一个需要很长时间才能完成并且不能中断主线程执行的后台任务时,线程尤其有用。我们引入线程来代表这些不同的方面,因为有以下原因:
-
从组织的角度来看,它们是有意义的。
-
它们是一种被证明有效的程序结构方式。
-
我们所工作的系统的性质迫使我们使用它们。
在 Android 中,我们同时出于这三个原因使用线程。这是有意义的,它有效,而且我们必须这样做,因为 Android 系统的设计要求如此。
通常,我们在不知情的情况下使用线程。这是因为我们使用的类会代表我们使用线程。我们在第十九章中编写的所有动画,动画和插值,都在线程中运行。在 Android 中的另一个例子是SoundPool
类,它在一个线程中加载声音。我们将在第二十三章**,支持不同版本的 Android,声音效果和 Spinner 小部件中看到或听到SoundPool
的实际效果,我们已经看到并将再次看到,我们的代码不必处理我们即将学习的线程方面,因为这一切都由类内部处理。然而,在这个项目中,我们需要更深入地参与其中。
在实时系统中,考虑一个线程同时接收玩家的按钮点击以左右移动,同时监听来自操作系统的消息,比如调用onCreate
(以及我们即将看到的其他方法)作为一个线程,另一个线程负责绘制所有图形并计算所有移动。
线程问题
具有多个线程的程序可能会出现问题。就像故事中的线索一样,如果没有适当的同步,事情可能会出错。如果我们的士兵在战斗甚至战争开始之前就进入了战斗,那会怎么样?很奇怪。
假设我们有一个变量int x
,它代表程序中三个线程使用的关键数据。如果一个线程稍微超前于自己,并使数据对其他两个线程来说是“错误”的,会发生什么。这个问题是由多个线程竞争完成而引起的正确性问题,因为它们毕竟只是愚蠢的代码。
正确性问题可以通过对线程和锁定的密切监督来解决。锁定意味着暂时阻止一个线程的执行,以确保事情以同步的方式工作 - 就像冻结士兵登上战舰直到船靠岸并放下跳板,避免尴尬的溅水。
多线程程序的另一个问题是int x
的问题,但那一刻从未到来,最终整个程序都停滞了。
您可能已经注意到,第一个问题(正确性)的解决方案是第二个问题(死锁)的原因。
幸运的是,这个问题已经为我们解决了。就像我们使用Activity
类并重写onCreate
来知道何时需要创建我们的应用程序一样,我们也可以使用其他类来创建和管理我们的线程。就像Activity
一样,我们只需要知道如何使用它们 - 而不需要知道它们的工作原理。
那么,当你不需要知道时,我为什么告诉你所有这些关于线程的东西,你是正确的。只是因为我们将编写看起来不同并且以不熟悉的方式结构化的代码。如果我们能做到以下几点,那么我们将毫不费力地编写我们的 Java 代码来创建和在我们的线程中工作:
-
理解线程的一般概念,这只是一个故事线程的同义词,几乎同时发生
-
学习使用线程的几个规则
有一些不同的 Android 类处理线程。不同的线程类在不同情况下效果最佳。
我们需要记住的是,我们将编写几乎同时运行的程序的部分。
注意
你说的“几乎”是什么意思?发生的情况是 CPU 在线程之间切换,但这几乎是同时/异步发生的。然而,这发生得如此之快,以至于我们除了同时性/同步性之外无法感知任何东西。当然,在故事线程的类比中,人们确实是完全同步行动的。
让我们来看看我们的线程代码将是什么样子。现在先不要向项目添加任何代码。我们可以这样声明一个Thread
类型的对象:
Thread ourThread;
初始化并启动它:
ourThread = new Thread(this);
ourThread.start();
这个线程的问题还有一个谜团。看看初始化线程的构造函数。这是代码的一行,以方便您再次查看:
ourThread = new Thread(this);
看一下传递给构造函数的突出参数。我们传入this
。请记住,代码将进入LiveDrawingView
类,而不是LiveDrawingActivity
。因此,我们可以推断this
是对LiveDrawingView
类的引用(它扩展了SurfaceView
)。
在 Android 总部的书呆子编写Thread
类时,他们似乎很难想象有一天我们会编写我们的LiveDrawingView
类。那么这怎么可能呢?
Thread
类需要完全不同的类型传递到它的构造函数中。Thread
构造函数需要一个Runnable
类型的对象。
注意
您可以通过查看 Android 开发者网站上的Thread
类来确认这一事实:developer.android.com/reference/java/lang/Thread.html#Thread(java.lang.Runnable
。
您还记得我们在第十一章中谈到的接口吗,更多面向对象的编程?作为提醒,我们可以使用implements
关键字和类声明后面的接口名称来实现接口,就像在这段代码中一样:
class someClass extends someotherClass implements Runnable{
然后我们必须实现接口的抽象方法。Runnable
只有一个方法。就是run
方法。
注意
您可以通过查看 Android 开发者网站上的Runnable
接口来确认这一事实:developer.android.com/reference/java/lang/Runnable.html
。
然后我们可以使用 Java 的@override
关键字来改变操作系统允许我们的线程对象运行其代码时发生的情况:
class someClass extends someotherClass implements Runnable{
@override
run(){
// Anything in here executes in a thread
// No skill needed on our part
// It is all handled by Android, the Thread class
// and the Runnable interface
}
}
在重写的run
方法中,我们将调用两个方法:一个是我们已经启动的draw
,另一个是update
。update
方法是我们所有的计算和人工智能的地方。代码看起来会有点像这样——暂时不要添加:
@override
public void run() {
// Update the drawing based on
// user input, physics,
// collision detection and artificial intelligence
update();
// Draw all the particle systems in their updated
locations
draw();
}
在适当的时候,我们也可以像这样停止我们的线程:
ourThread.join();
现在run
方法中的所有内容都在一个单独的线程中执行,使默认或 UI 线程监听触摸和系统事件。我们很快将看到这两个线程如何相互通信在绘图项目中。
请注意,所有这些代码的确切位置将进入我们的应用程序尚未解释,因为在真实项目中向您展示会更容易得多。
使用线程实现游戏循环
现在我们已经学习了游戏循环和线程,我们可以把它们全部放在一起,在 Living Drawing 项目中实现我们的游戏循环。
我们将添加整个游戏循环的代码,包括在LiveDrawingActivity
类中编写两个方法来启动和停止控制循环的线程。
注意
你能猜到基于 Activity 的类将如何在LiveDrawingView
类中启动和停止线程吗?
实现 Runnable 并提供 run 方法
通过实现Runnable
来更新类声明,就像我们之前讨论过的那样,并且如下面的下一个突出显示的代码所示:
class LiveDrawingView extends SurfaceView implements Runnable{
注意到我们的代码中出现了一个新错误。将鼠标指针悬停在Runnable
一词上,您将看到一条消息,告诉您我们需要再次实现run
方法,就像我们在讨论接口时讨论过的那样。按照一会儿的示例添加空的run
方法,包括@override
标签。
如果它在LiveDrawingView
类的大括号内而不是在另一个方法内,那么添加的位置并不重要。我把我的添加在构造方法之后,因为它靠近顶部,很容易找到。在本章中我们将对其进行相当多的编辑。按照下面的示例添加空的run
方法:
// When we start the thread with:
// mThread.start();
// the run method is continuously called by Android
// because we implemented the Runnable interface
// Calling mThread.join();
// will stop the thread
@Override
public void run() {
}
错误已经消失,现在我们可以声明和初始化一个Thread
对象。
编写线程
在LiveDrawingView
类中的所有其他成员下面,声明一些更多的成员变量和实例,如下所示:
// Here is the Thread and two control variables
private Thread mThread = null;
// This volatile variable can be accessed
// from inside and outside the thread
private volatile boolean mDrawing;
private boolean mPaused = true;
现在我们可以启动和停止线程。想一想我们可能在哪里做到这一点。记住应用程序需要响应操作系统启动和停止应用程序。
启动和停止线程
现在我们需要启动和停止线程。我们已经看到了我们需要的代码,但是什么时候和在哪里应该这样做呢?让我们编写两个方法,一个用于启动,一个用于停止,然后我们可以进一步考虑何时以及从何处调用这些方法。在LiveDrawingView
类中添加这两个方法。如果它们的名称听起来很熟悉,那并非偶然:
// This method is called by LiveDrawingActivity
// when the user quits the app
public void pause() {
// Set mDrawing to false
// Stopping the thread isn't
// always instant
mDrawing = false;
try {
// Stop the thread
mThread.join();
} catch (InterruptedException e) {
Log.e("Error:", "joining thread");
}
}
// This method is called by LiveDrawingActivity
// when the player starts the app
public void resume() {
mDrawing = true;
// Initialize the instance of Thread
mThread = new Thread(this);
// Start the thread
mThread.start();
}
正在发生的事情在评论中稍微透露了一些——你读了评论吗?我们现在有了pause
和resume
方法,它们使用我们之前讨论过的相同代码来停止和启动Thread
对象。
注意新方法是public
的,因此可以从类外部访问到任何具有LiveDrawingView
实例的其他类。记住LiveDrawingActivity
有完全声明和初始化的LiveDrawingView
实例吗?
让我们使用 Android Activity 生命周期来调用这两个新方法。
使用 Activity 生命周期来启动和停止线程
更新LiveDrawingActivity
中重写的onResume
和onPause
方法,如下所示,带有突出显示的代码行:
@Override
protected void onResume() {
super.onResume();
// More code here later in the chapter
mLiveDrawingView.resume();
}
@Override
protected void onPause() {
super.onPause();
// More code here later in the chapter
mLiveDrawingView.pause();
}
现在我们的线程将在操作系统恢复和暂停我们的应用程序时启动和停止。请记住,onResume
在应用程序创建后第一次调用onCreate
之后被调用,而不仅仅是在从暂停中恢复后调用。onResume
和onPause
中的代码使用mLiveDrawingView
对象来调用其resume
和pause
方法,这反过来又有代码来启动和停止线程。然后,这段代码触发线程的run
方法执行。就是在这个run
方法(在LiveDrawingView
中)中,我们将编写我们的游戏循环。现在让我们来做这个。
编写 run 方法
虽然我们的线程已经设置好并准备就绪,但因为run
方法是空的,所以什么也不会发生。按照下面所示的方式编写run
方法:
@Override
public void run() {
// mDrawing gives us finer control
// rather than just relying on the calls to run
// mDrawing must be true AND
// the thread running for the main
// loop to execute
while (mDrawing) {
// What time is it now at the start of the loop?
long frameStartTime = System.currentTimeMillis();
// Provided the app isn't paused
// call the update method
if(!mPaused){
update();
// Now the particles are in
// their new positions
}
// The movement has been handled and now
// we can draw the scene.
draw();
// How long did this frame/loop take?
// Store the answer in timeThisFrame
long timeThisFrame =
System.currentTimeMillis() -
frameStartTime;
// Make sure timeThisFrame is at least 1
// millisecond because accidentally dividing
// by zero crashes the app
if (timeThisFrame > 0) {
// Store the current frame rate in mFPS
// ready to pass to the update methods of
// of our particles in the next frame/loop
mFPS = MILLIS_IN_SECOND / timeThisFrame;
}
}
}
请注意,Android Studio 中有两个错误。这是因为我们还没有编写update
方法。让我们快速添加一个空方法(带有注释)。我在run
方法之后添加了我的:
private void update() {
// Update the particles
}
现在让我们详细讨论run
方法中的代码如何通过逐步查看整个内容来实现我们游戏循环的目标。
这个第一部分初始化了一个while
循环,条件是mDrawing
,它包裹在run
中的其余代码中,所以线程需要被启动(才能调用run
),并且mDrawing
需要为true
才能执行while
循环:
@Override
public void run() {
// mPlaying gives us finer control
// rather than just relying on the calls to run
// mPlaying must be true AND
// the thread running for the main
// loop to execute
while (mPlaying) {
while
循环内的第一行代码声明并初始化一个名为frameStartTime
的局部变量,其值为当前时间。System
类的静态方法currentTimeMillis
返回这个值。如果以后我们想要测量一帧花费了多长时间,那么我们需要知道它是什么时候开始的:
// What time is it now at the start of the loop?
long frameStartTime = System.currentTimeMillis();
接下来,在while
循环中,我们检查应用程序是否暂停,只有当应用程序没有暂停时,才会执行下面的代码。如果逻辑允许在这个块中执行,那么就会调用update
:
// Provided the app isn't paused
// call the update method
if(!mPaused){
update();
// Now the particles are in
// their new positions
}
在前面的if
语句之外,调用draw
方法来绘制所有对象的刚更新的位置。此时,另一个局部变量被声明并初始化为完成整个帧(更新和绘制)所花费的时间长度。这个值是通过再次使用currentTimeMillis
获取当前时间,并从中减去frameStartTime
来计算的:
// The movement has been handled and collisions
// detected now we can draw the scene.
draw();
// How long did this frame/loop take?
// Store the answer in timeThisFrame
long timeThisFrame =
System.currentTimeMillis() -
frameStartTime;
下一个if
语句检测timeThisFrame
是否大于零。如果线程在对象初始化之前运行,该值可能为零。如果你看一下if
语句中的代码,它通过将经过的时间除以MILLIS_IN_SECOND
来计算帧速率。如果除以零,应用程序将崩溃,这就是我们进行检查的原因。
一旦mFPS
得到分配给它的值,我们就可以在下一帧中使用它传递给所有粒子的update
方法,我们将在下一章中编写。它们将使用这个值来确保它们根据其目标速度和帧所花费的时间长度精确地移动:
// Make sure timeThisFrame is at least 1
// millisecond because accidentally dividing
// by zero crashes the app
if (timeThisFrame > 0) {
// Store the current frame rate in mFPS
// ready to pass to the update methods of
// the particles in the next frame/loop
mFPS = MILLIS_IN_SECOND / timeThisFrame;
}
}
}
在每一帧中初始化mFPS
的计算结果是,mFPS
将保存 1 的分数。因此,当我们在每个粒子对象中使用这个值时,我们将能够使用这个计算:
mSpeed / mFPS
为了确定任何给定帧的经过时间,由于帧速率波动,mFPS
将保存不同的值,并为游戏对象提供适当的数字来计算每次移动。
运行应用程序
在 Android Studio 中点击播放按钮,最后两章的辛勤工作和理论将会生动地展现出来。这是我们的应用程序在平板模拟器上运行的开端:
图 21.4 - 运行应用程序
你可以看到我们现在已经创建了一个实时系统,具有我们的游戏循环和一个线程。如果你在真实设备上运行这个应用程序,你将很容易地在这个阶段实现每秒 60 帧。
总结
这可能是迄今为止最技术性的一章。线程、游戏循环、计时、使用接口以及 Activity 生命周期等等……这是一个非常长的主题清单。
如果这些事物之间的确切相互关系并不完全清楚,那也没关系。你只需要知道,当用户启动和停止应用程序时,LiveDrawingActivity
类将通过调用LiveDrawingView
类的pause
和resume
方法来处理启动和停止线程。它通过重写的onPause
和onResume
方法来实现,这些方法由操作系统调用。
一旦线程运行起来,run
方法内的代码将与监听用户输入的 UI 线程一起执行。当我们同时从run
方法调用update
和draw
方法,并跟踪每帧所需的时间时,我们的应用程序就准备好了。
我们只需要允许用户向他们的艺术品添加一些粒子,然后我们可以在每次调用update
方法时更新它们,并在每次调用draw
方法时绘制它们。
在下一章中,我们将编写、更新和绘制Particle
和ParticleSystem
类。此外,我们将编写用户与应用程序进行交互(进行一些绘图)的代码。