构建安卓-UI-自定义视图-全-

构建安卓 UI 自定义视图(全)

原文:zh.annas-archive.org/md5/DB7176CF30C0E45521FC275B41E28E14

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

多年前,在安卓和 iPhone 推出之前,一个主要的担忧是有没有一个集中的地方来购买和下载移动应用程序。如今,我们通过广泛可用的集中应用商店如谷歌应用商店解决了这个问题,但代价是应用程序的可发现性降低。

谷歌应用商店(Google Play)和其他移动应用商店一样,市场竞争已经高度饱和。除非一个应用有独特之处或者有特别之处,否则在众多功能相近甚至不相关的应用中脱颖而出是非常困难的。

增加市场营销投入可能会暂时缓解这个问题,但从长远来看,应用程序仍然需要找到那项独特的功能或那个使其与众不同的细节。

一个让应用与众不同的方法是从安卓标准小部件和 UI 组件中稍微偏离,加入特定的自定义视图或自定义菜单,或者,在最后,任何让应用变得卓越的东西。我们应该知道,这并不意味着我们应该完全忽视安卓标准小部件,重写整个应用程序的 UI。与几乎所有事情一样,进行用户测试,发现对他们来说什么有效,什么无效。探索新选项,解决他们遇到的问题,但不要过度。有时,在应用程序顶部创建一个特定的菜单可能解决了导航问题,或者一个定义良好的动画可能向用户正确传达了过渡。

在这本书中,我们将学习如何开始为安卓构建自定义视图并将其集成到我们的应用程序中。我们会详细探讨如何与这些视图互动,添加动画,并给出 2D 和 3D 渲染能力的综合示例。最后,我们还将学习如何共享我们的自定义视图,以便在企业环境中复用,以及如何开源它们,让安卓开发社区也能使用。

本书内容涵盖

第一章,入门,解释了自定义视图是什么,我们何时需要它们,并展示如何构建你的第一个自定义视图。

第二章,实现你的第一个自定义视图,更详细地介绍了测量、实例化、参数化以及一些基本的渲染,从而开始感受自定义视图能做什么。

第三章,处理事件,向读者展示如何让自定义视图具有交互性,以及如何响应用户的交互。

第四章,高级 2D 渲染,添加了额外的渲染原语和操作,并展示如何将它们组合起来构建更复杂的自定义视图。

第五章,引入 3D 自定义视图,因为我们的渲染不仅限于 2D,本章介绍了如何使用 OpenGL ES 渲染 3D 的自定义视图。

第六章,动画,讲述了如何为自定义视图添加动画,既可以使用标准的 Android 组件,也可以自己实现。

第七章,性能考虑,提出了一些建议和最佳实践,在构建自定义视图时应当遵循,以及不遵循可能产生的影响。

第八章,分享我们的自定义视图,讲述了如何打包和分享我们的自定义视图,使其公开可用。

第九章,实现自己的电子节目指南,展示了如何通过结合我们在书中看到的内容,构建一个更复杂自定义视图的例子。

第十章,构建图表组件,详细介绍了如何逐步构建一个可定制的图表自定义视图。

第十一章,创建 3D 旋转菜单,介绍了如何构建一个更复杂的 3D 自定义视图,用作选择菜单。

阅读本书所需

为了跟随本书中的示例,你需要安装 Android Studio。我们将在第一章简要介绍如何安装和设置设备模拟器。强烈建议至少安装 Android Studio 3.0。在撰写本书时,Android Studio 3.0 仍然是测试版,但足够稳定,可以开发、运行和测试所有示例。此外,建议使用 Android 设备以更好地体验我们将创建的自定义视图中的用户交互,但它们也可以在 Android 模拟器中工作。

本书适合的读者

本书适用于希望提高 Android 应用开发技能并使用自定义视图构建 Android 应用的开发者。

约定

在本书中,你会发现多种文本样式,用于区分不同类型的信息。以下是一些样式示例及其含义的解释。

文本中的代码字、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 处理程序如下所示:

"我们可以使用getWidth()getHeight()方法分别获取视图的宽度和高度。"

代码块设置如下:

<com.packt.rrafols.customview.OwnTextView 
        android:layout_width="wrap_content" 
        android:layout_height="wrap_content" 
        android:text="Hello World!" /> 

当我们希望引起你对代码块中某个特定部分的注意时,相关的行或项目会以粗体设置:

float maxLabelWidth = 0.f; 
if (regenerate) { 
    for (int i = 0; i<= 10; i++) { 
        float step; 
 if (!invertVerticalAxis) {
 step = ((float) i / 10.f);
 } else {
 step = ((float) (10 - i)) / 10.f;
}

新术语和重要词汇以粗体显示,例如,它们在文本中这样出现:"布局通常被称为ViewGroup。"

警告或重要说明会以这样的方框显示。

技巧和诀窍会以这样的形式出现。

读者反馈

我们始终欢迎读者的反馈。让我们知道您对这本书的看法——您喜欢或不喜欢什么。读者的反馈对我们很重要,因为它帮助我们开发出您真正能从中获得最大收益的标题。

如果要给我们发送一般反馈,只需发送电子邮件至feedback@packtpub.com,并在邮件主题中提及本书的标题。

如果你对某个主题有专业知识,并且有兴趣撰写或参与书籍编写,请查看我们的作者指南www.packtpub.com/authors

客户支持

既然您已经拥有了 Packt 的一本书,我们有许多方法可以帮助您充分利用您的购买。

下载示例代码

您可以从www.packtpub.com的账户下载本书的示例代码文件。如果您在别处购买了这本书,可以访问www.packtpub.com/support注册,我们会直接将文件通过电子邮件发送给您。

按照以下步骤,您可以下载代码文件:

  1. 使用您的电子邮件地址和密码登录或注册我们的网站。

  2. 将鼠标悬停在顶部的“支持”标签上。

  3. 点击“代码下载与勘误”。

  4. 在搜索框中输入书名。

  5. 选择您要下载代码文件的书。

  6. 从下拉菜单中选择您购买本书的地方。

  7. 点击“代码下载”。

文件下载后,请确保使用最新版本的以下软件解压或提取文件夹:

  • 对于 Windows 系统,请使用 WinRAR / 7-Zip。

  • 对于 Mac 系统,请使用 Zipeg / iZip / UnRarX。

  • 对于 Linux 系统,请使用 7-Zip / PeaZip。

本书附带的代码包也托管在 GitHub 上,地址为github.com/PacktPublishing/Building-Android-UIs-with-Custom-Views。我们还有其他丰富的书籍和视频代码包,可以在github.com/PacktPublishing/找到。请查看!

勘误

尽管我们已经竭尽全力确保内容的准确性,但错误仍然可能发生。如果您在我们的书中发现了一个错误——可能是文本或代码中的错误——如果您能报告给我们,我们将不胜感激。这样做,您可以避免其他读者感到沮丧,并帮助我们在后续版本中改进这本书。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击“勘误提交表单”链接,并输入您的勘误详情。一旦您的勘误被验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题下的现有勘误列表中。

要查看之前提交的勘误信息,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书名。所需信息将显示在勘误部分下。

盗版问题

互联网上对版权材料进行盗版是一个所有媒体都面临的持续问题。在 Packt,我们非常重视保护我们的版权和许可。如果您在互联网上以任何形式遇到我们作品的非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

如果您发现疑似盗版材料,请通过 copyright@packtpub.com 联系我们,并提供相关链接。

我们感谢您帮助保护我们的作者以及我们为您提供有价值内容的能力。

问题咨询

如果您对本书的任何方面有问题,可以通过 questions@packtpub.com 联系我们,我们将尽力解决问题。

第一章:开始

你可能想知道自定义视图是什么;没问题,我们将在本书中介绍这个以及其他更多内容。如果你已经开发了一段时间的Android应用程序,你很可能已经多次使用过标准的 Android 视图或小部件。例如:TextViewImageViewButtonListView等等。自定义视图略有不同。简单来说,自定义视图是一个我们自行实现其行为的视图或小部件。在本章中,我们将介绍开始构建 Android 自定义视图所需的基本步骤,以及我们应该使用自定义视图的场景和应该依赖 Android 标准小部件的场景。更具体地说,我们将讨论以下主题:

  • 自定义视图是什么,为什么我们需要它们?

  • 如何设置和配置我们的开发环境以开发自定义视图

  • 创建我们自己的第一个自定义视图

自定义视图是什么

正如我们刚刚提到的,自定义视图是我们自行实现其行为的视图。这有点过于简化了,但这是一个不错的起点。我们实际上并不需要自行实现其全部行为。有时,它可能只是一个简单的细节,或者是一个更复杂的功能,甚至是整个功能和行为,如交互、绘图、调整大小等等。例如,将按钮的背景颜色作为一个自定义视图的实现进行微调,这是一个简单的改变,但创建一个基于位图的 3D 旋转菜单在开发时间和复杂性上则完全不同。我们将在本书中展示如何构建这两种视图,但本章将仅关注一个非常简单的示例,在接下来的章节中,我们将添加更多功能。

在整本书中,我们将同时提到自定义视图和自定义布局。关于自定义视图的定义同样适用于布局,但主要区别在于,自定义布局可以帮助我们用我们创建的逻辑布置其包含的项目,并以我们希望的方式精确定位它们。稍后我们会学习如何做到这一点,敬请期待!

布局通常被称为ViewGroup。最典型的例子,也是你可能听说过的,在你的应用中很可能使用过的有:LinearLayoutRelativeLayoutConstraintLayout

如果想要了解更多关于 Android 视图和布局的信息,我们可以随时查阅官方的 Android 开发者文档:

Android 开发者官网

为什么需要自定义视图

Google Play 和其他市场上有很多可爱的 Android 应用程序:仅使用标准Android UI 组件和布局的亚马逊。还有许多其他应用程序拥有让我们的互动更容易或仅仅更愉悦的小功能。虽然没有神奇的公式,但也许只是添加一些不同的东西,让用户觉得“这不仅仅是另一个用于...的应用程序”可能会提高我们的用户留存率。它可能不是决定性的因素,但有时确实可以产生差异。

一些自定义视图的影响力如此之大,以至于其他应用程序也希望效仿或构建类似的东西。这种效果为应用程序带来了病毒式营销,也吸引了开发者社区,因为可能会有许多类似的组件以教程或开源库的形式出现。显然,这种效果只会持续一段时间,但如果发生了,对你的应用程序来说绝对是值得的,因为它会在开发者中变得更加流行和知名,因为它不仅仅是另一个 Android 应用程序,而是有特色的东西。

我们为移动应用程序创建自定义视图的一个主要原因,正是为了拥有一些特别的东西。它可能是一个菜单、一个组件、一个屏幕,或者是我们应用程序真正需要的主要功能,或者只是一个附加功能。

此外,通过创建我们自己的自定义视图,我们实际上可以优化应用程序的性能。我们可以创建一种特定的布局方式,否则仅使用标准 Android 布局或自定义视图将需要许多层次结构,从而简化渲染或用户交互。

另一方面,我们很容易犯试图自定义构建一切的错误。Android 提供了一个出色的组件和布局列表,为我们处理了很多事情。如果我们忽略基本的 Android 框架,试图自己构建一切,那将是非常多的工作。我们可能会遇到许多 Android 操作系统开发者已经面对过的问题,至少也是非常相似的问题。一句话,我们就是在重新发明轮子。

市场上的例子

我们可能都使用过仅使用标准 Android UI 组件和布局构建的优秀应用程序,但也有许多其他应用程序有一些我们不知道或没有真正注意到的自定义视图。自定义视图或布局有时可能非常微妙,难以察觉。

我们不一定是第一个在应用程序中拥有自定义视图或布局的人。实际上,许多受欢迎的应用程序都有一些自定义元素。让我们来看一些例子:

第一个例子将是Etsy应用程序。Etsy应用程序有一个名为StaggeredGridView的自定义布局。它甚至在 GitHub 上作为开源发布。自 2015 年以来,它已被废弃,取而代之的是与RecyclerView一起使用的谷歌自己的StaggeredGridLayoutManager

你可以通过从 Google Play 下载Etsy应用程序来亲自查看,但为了快速预览,以下截图实际上展示了Etsy应用程序中的 StaggeredGrid 布局:

还有许多其他潜在的例子,但第二个好的例子可能是荷兰最大的有线电视运营商之一Ziggo的电子编程指南。电子编程指南是一个自定义视图,为电视节目呈现不同的盒子,并改变当前时间前后内容的颜色。

该应用只能在荷兰的 Google Play 下载,不过,以下截图展示了应用程序如何呈现电子编程指南:

最后,第三个例子,也是最近发布的应用程序是来自 Airbnb 的LottieLottie是一个示例应用程序,它实时呈现Adobe After Effects动画。

Lottie可以直接从 Google Play 下载,但以下截图展示了应用程序的快速预览:

渲染视图和自定义字体是自定义渲染的例子。有关Lottie的更多信息,请参考:

airbnb.design/introducing-lottie/

我们刚刚看到了一些例子,但还有更多可用。一个发现它们或查看可用内容的好网站是 Android Arsenal:

android-arsenal.com/

设置环境

既然我们已经对自定义视图、为什么需要它们以及市场上的一些例子有了简要介绍,那么让我们开始构建自己的视图吧。如果我们还没有这样做,那么我们的第一步自然就是安装 Android 开发工具。如果你已经安装了 Android Studio,可以跳过这一部分,直接进入正题。本书中的大多数例子都可以完美地与 Android Studio 2.3.3 配合使用,但后面的章节将需要 Android Studio 3.0。在撰写本文时,Android Studio 3.0 仍处于测试阶段,但强烈建议使用它来测试提供的所有示例。

安装开发工具

要开始创建自己的自定义视图,你只需要正常开发 Android 移动应用程序所需的工具。在本书中,我们将使用 Android Studio,因为这是谷歌推荐的工具。

我们可以从 Android Studio 的官方网站获取最新版本:

developer.android.com/studio/index.html

一旦我们为电脑下载了软件包,就可以开始安装了:

现在,我们可以创建一个新项目,这个项目将用于我们自定义视图的初步尝试。

选择应用程序名称、公司域名(这将反转成应用程序包名)和项目位置后,Android Studio 会询问我们想要创建哪种类型的项目:

在这个例子中,我们不需要太花哨的东西,只要有手机和平板支持,API 21 的支持就足够了。完成这些设置后,我们可以添加一个空的活动(Empty Activity):

如果你需要安装 Android Studio 的帮助,可以在《Learning Android Application Development, Packt Publishing》中找到一份分步指南,或者在 Android 开发者文档网站上总有很多信息。更多信息,请参考:

学习 Android 应用开发

现在,我们可以在设备模拟器或真实设备上运行这个应用程序了。

如何设置模拟器

要设置模拟器,我们需要运行Android 虚拟设备管理器AVD Manager)。我们可以在顶部栏找到它的图标,就在播放/停止应用程序图标旁边。

一旦我们执行了Android 设备管理器,就可以从那里添加或管理我们的虚拟设备,如下面的截图所示:

点击“创建虚拟设备”将给我们一个使用 Android 设备定义之一的机会,甚至可以创建我们自己的硬件配置文件,如下面的截图所示:

选择硬件后,我们需要选择在其上运行的软件,或者说是系统镜像。稍后,我们可以添加所有需要的测试组合:多种不同的设备,或者带有不同 Android 版本镜像的同一设备,甚至是两者的组合。

最后一步是给我们的 AVD 命名,检查我们的硬件和软件选择,然后就可以开始了!

如何为开发设置真实设备

使用模拟器进行测试和调试是可以的,但有时我们确实想要在真实设备上测试或安装应用程序。为了在我们的设备上启用开发,我们需要执行几个步骤。首先,我们需要为开发启用我们的设备。我们可以轻松地通过在设置中点击七次“关于”菜单 -> “构建号”(自 Android 4.2 起)。完成这一步后,将出现一个新的菜单选项,称为“开发者选项”。那里有多种选项供我们探索,但现在我们需要的是启用 USB 调试。

如果启用了 USB 调试,我们将在设备选择中看到我们的设备和正在运行的模拟器:

创建我们自己的第一个自定义视图

现在我们已经设置好了开发环境,可以在模拟器和真实设备上运行和调试 Android 应用程序,我们可以开始创建我们自己的第一个自定义视图了。为了简化,我们首先会轻松地修改一个现有的视图,稍后我们将从头开始创建我们自己的视图。

扩展一个视图

使用上一节的示例,或者如果你跳过了它,只需创建一个带有空活动的新项目,我们将用我们自己的实现来替换 TextView

如果我们查看默认的布局 XML 文件,通常称为 activity_main.xml(除非在项目创建期间你更改了它),我们可以看到 RelativeLayout 中有一个 TextView

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

    android:id="@+id/activity_main" 
    android:layout_width="match_parent" 
    android:layout_height="match_parent" 
    android:paddingBottom="@dimen/activity_vertical_margin" 
    android:paddingLeft="@dimen/activity_horizontal_margin" 
    android:paddingRight="@dimen/activity_horizontal_margin" 
    android:paddingTop="@dimen/activity_vertical_margin" 
    tools:context="com.packt.rrafols.customview.MainActivity"> 

    <TextView 
        android:layout_width="wrap_content" 
        android:layout_height="wrap_content" 
        android:text="Hello World!" /> 
</RelativeLayout> 

让我们修改那个 TextView,将其变为我们接下来将实现的定制类。

<com.packt.rrafols.customview.OwnTextView 
        android:layout_width="wrap_content" 
        android:layout_height="wrap_content" 
        android:text="Hello World!" /> 

我们使用了 com.packt.rrafols.customview 包,但请根据你的应用程序的包名相应地更改它。

要实现这个类,我们首先会创建一个继承自 TextView 的类:

package com.packt.rrafols.customview; 

import android.content.Context; 
import android.util.AttributeSet; 
import android.widget.TextView; 

public class OwnTextView extends TextView { 

    public OwnTextView(Context context, AttributeSet attributeSet) { 
        super(context, attributeSet); 
    } 
} 

这个类或自定义视图将表现得像一个标准的 TextView。考虑到我们使用的构造函数。还有其他的构造函数,但现在我们只关注这一个。创建它是很重要的,因为它将接收上下文和我们定义在 XML 布局文件中的参数。

在这一点上,我们只是传递参数,并没有对它们进行任何花哨的操作,但让我们通过重写 onDraw() 方法来准备我们的自定义视图以处理新功能:

@Override 
protected void onDraw(Canvas canvas) { 
    super.onDraw(canvas); 
} 

通过重写 onDraw() 方法,我们现在可以控制自定义视图的绘制周期。如果我们运行应用程序,由于还没有添加任何新的行为或功能,我们不会注意到与原始示例有任何区别。为了解决这个问题,让我们做一个非常简单的更改,这将证明它实际上是在工作的。

onDraw() 方法中,我们将绘制一个红色矩形,覆盖视图的全部区域,如下所示:

@Override 
    protected void onDraw(Canvas canvas) { 
        canvas.drawRect(0, 0, getWidth(), getHeight(), backgroundPaint); 
        super.onDraw(canvas); 
    } 

我们可以使用getWidth()getHeight()方法分别获取视图的宽度和高度。为了定义颜色和样式,我们将初始化一个新的Paint对象,但我们要在构造函数中执行这一操作,因为在onDraw()方法中执行是错误的做法。我们将在本书后面更多地讨论性能问题:

private Paint backgroundPaint; 

    public OwnTextView(Context context, AttributeSet attributeSet) { 
        super(context, attributeSet); 

        backgroundPaint= new Paint(); 
        backgroundPaint.setColor(0xffff0000); 
        backgroundPaint.setStyle(Paint.Style.FILL); 
    } 

在这里,我们使用整数十六进制编码将Paint对象初始化为红色,并将样式设置为Style.FILL,以便填充整个区域。默认情况下,Paint样式设置为FILL,但明确设置可以增加清晰度。

如果我们现在运行应用程序,我们将看到TextView,这是我们现在的类,背景为红色,如下所示:

下面的代码片段是OwnTextView类的整个实现。更多详情,请查看 GitHub 仓库中Example01文件夹的完整项目:

package com.packt.rrafols.customview; 

import android.content.Context; 
import android.graphics.Canvas; 
import android.graphics.Paint; 
import android.util.AttributeSet; 
import android.widget.TextView; 

public class OwnTextView extends TextView { 

    private Paint backgroundPaint; 

    public OwnTextView(Context context, AttributeSet attributeSet) { 
        super(context, attributeSet); 

        backgroundPaint = new Paint(); 
        backgroundPaint.setColor(0xffff0000); 
        backgroundPaint.setStyle(Paint.Style.FILL); 
    } 

    @Override 
    protected void onDraw(Canvas canvas) { 
        canvas.drawRect(0, 0, getWidth(), getHeight(),
        backgroundPaint); 
        super.onDraw(canvas); 
    } 
} 

这个示例只是为了展示我们如何扩展标准视图并实现我们自己的行为;在 Android 中还有多种其他方法可以为小部件设置背景颜色或绘制背景颜色。

从零开始创建一个简单的视图

现在我们已经看到了如何修改已经存在的View,我们将看到一个更复杂的示例:如何从零开始创建我们自己的自定义视图!

让我们从创建一个继承自View的空类开始:

package com.packt.rrafols.customview; 

import android.content.Context; 
import android.util.AttributeSet; 
import android.view.View; 

public class OwnCustomView extends View { 

    public OwnCustomView(Context context, AttributeSet attributeSet) { 
        super(context, attributeSet); 
    } 

} 

我们现在将添加与上一个示例相同的代码以绘制红色背景:

package com.packt.rrafols.customview; 

import android.content.Context; 
import android.graphics.Canvas; 
import android.graphics.Paint; 
import android.util.AttributeSet; 
import android.view.View; 

public class OwnCustomView extends View { 

    private Paint backgroundPaint; 

    public OwnCustomView(Context context, AttributeSet attributeSet) { 
        super(context, attributeSet); 

        backgroundPaint= new Paint(); 
        backgroundPaint.setColor(0xffff0000); 
        backgroundPaint.setStyle(Paint.Style.FILL); 

    } 

    @Override 
    protected void onDraw(Canvas canvas) { 
        canvas.drawRect(0, 0, getWidth(), getHeight(),
        backgroundPaint); 
        super.onDraw(canvas); 
    } 
} 

如果我们运行应用程序,从下面的截图中可以看出,我们将得到与上一个示例略有不同的结果。这是因为在上一个示例中,TextView小部件调整大小以适应文本的大小。如果我们记得正确,我们在布局 XML 文件中有android:layout_width="wrap_content"android:layout_height="wrap_content"。我们刚才创建的这个新的自定义视图不知道如何计算其大小。

在 GitHub 仓库的Example02文件夹中查看这个简单例子的完整实现。

总结

在本章中,我们已经了解了为什么要构建自定义视图和布局的原因,同时也必须应用常识。Android 提供了一个用于创建 UI 的优秀基本框架,不使用它将是一个错误。并非每个组件、按钮或小部件都必须完全自定义开发,但通过在正确的位置执行此操作,我们可以添加一个可能会让我们的应用程序被记住的额外功能。此外,我们已经展示了一些已经在市场上使用自定义视图的应用程序示例,所以我们知道我们并不孤单!最后,我们已经看到了如何设置环境以开始工作,并且我们已经开始了自定义视图的初步尝试。

在下一章中,我们将继续添加功能;我们将了解如何计算自定义视图的正确大小并学习更多关于自定义渲染的内容。

第二章:实现你的第一个自定义视图

在前一章中,我们已经看到了如何创建自定义视图的基础,但除非我们添加更多功能和自定义,否则它将相当无用。在本章中,我们将在这些基础上继续构建,了解如何参数化我们的自定义视图,以便我们或其他开发人员可以自定义它们,并在最后,涵盖一些渲染内容,这将使我们能够构建更复杂的自定义视图。

此外,正如我们在前一章提到的,我们还可以创建自定义布局。在本章中,我们将了解如何创建一个简单的自定义布局。

更详细地说,我们将涵盖以下主题:

  • 测量和参数化我们的自定义视图

  • 实例化自定义视图

  • 创建自定义布局

  • 基本渲染

测量和参数化我们的自定义视图

为了有一个好的可重用的自定义视图,它需要能够适应不同的尺寸和设备分辨率,为了进一步提高其可重用性,它应该支持参数化。

测量我们的自定义视图

在前一章中我们快速构建的示例中,我们将所有尺寸和测量都委托给了父视图本身。坦白说,我们甚至没有委托它;我们只是没有特别做任何事情来处理这个问题。能够控制我们自定义视图的尺寸和维度是我们绝对需要关注的事情。首先,我们将从视图重写onMeasure()方法,如下所示:

@Override 
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 
    super.onMeasure(widthMeasureSpec, heightMeasureSpec); 
} 

阅读关于onMeasure()方法的 Android 文档,我们应该看到我们必须调用 setMeasuredDimension(int, int)或者父类的onMeasure(int, int)。如果我们忘记这样做,我们将得到一个IllegalStateException

com.packt.rrafols.customview E/AndroidRuntime: FATAL EXCEPTION: main Process: com.packt.rrafols.customview, PID: 13601 java.lang.IllegalStateException: View with id -1: com.packt.rrafols.customview.OwnCustomView#onMeasure() did not set the measured dimension by calling setMeasuredDimension() at android.view.View.measure(View.java:18871)

有三种不同的模式,我们的视图的父视图可以通过这些模式指示我们的视图如何计算其大小。我们可以通过使用MeasureSpec.getMode(int)方法与每个尺寸规范widthMeasureSpecheightMeasureSpec来获取模式。

这些模式如下:

  • MeasureSpec.EXACTLY

  • MeasureSpec.AT_MOST

  • MeasureSpec.UNSPECIFIED

当父视图计算或决定了尺寸时,我们将得到MeasureSpec.EXACTLY。即使我们的视图需要或返回不同的尺寸,它也将具有这个大小。如果我们得到MeasureSpec.AT_MOST,我们则有更大的灵活性:我们可以根据需要变得更大,但最大不超过给定的大小。最后,如果我们收到MeasureSpec.UNSPECIFIED,我们可以将视图的大小设置为任意我们想要的或视图需要的尺寸。

使用MeasureSpec.getSize(int),我们还可以从尺寸规范中获取一个尺寸值。

既然有了这些,我们如何知道哪些值对应于我们 XML 布局文件中的宽度和高度参数?很容易看出,让我们检查一下。例如,如果我们像 GitHub 仓库中的activity_main.xml文件那样指定精确值,我们将得到以下代码:

<com.packt.rrafols.customview.OwnCustomView 
   android:layout_width="150dp" 
   android:layout_height="150dp"/> 

在我们的自定义视图中,使用MeasureSpec.toString(int)获取测量规范和尺寸的字符串描述的代码如下:

@Override 
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 
    Log.d(TAG, "width spec: " +
    MeasureSpec.toString(widthMeasureSpec)); 
    Log.d(TAG, "height spec: " +
    MeasureSpec.toString(heightMeasureSpec)); 
    super.onMeasure(widthMeasureSpec, heightMeasureSpec); 
} 

在 Android 日志上的结果如下:

D/com.packt.rrafols.customview.OwnCustomView: width : MeasureSpec: EXACTLY 394 D/com.packt.rrafols.customview.OwnCustomView: height: MeasureSpec: EXACTLY 394

我们的视图将是精确的394394像素。这个394像素来自于将150dp转换为我用于测试的移动设备上的像素。

由于有许多具有不同分辨率和屏幕密度的 Android 设备,我们应始终使用密度独立像素dp)或(dip)而不是像素。

要了解更多关于 dp 的信息,请参考谷歌在 YouTube 上发布的一个视频:DesignBytes:密度独立像素。

如果你想在特定设备上将 dp 转换为实际像素,你可以使用以下方法:

public final int dpToPixels(int dp) { 
    return (int) (dp * getResources().getDisplayMetrics().density +
    0.5); 
} 

我们可以看到转换是如何使用屏幕密度的,因此在不同的设备上转换可能会有所不同。前面代码中的+ 0.5只是为了在从浮点数转换为int时将值四舍五入。

要从像素转换到密度独立点,我们必须进行相反的操作,如下面的代码所示:

public final int pixelsToDp(int dp) { 
    return (int) (dp / getResources().getDisplayMetrics().density +
    0.5); 
} 

现在我们来看看,如果我们使用不同的测量参数,比如match_parentwrap_content,如 GitHub 仓库中的activity_main.xml文件所示,我们会得到什么结果:

<com.packt.rrafols.customview.OwnCustomView 
   android:layout_width="match_parent" 
   android:layout_height="match_parent"/> 

运行与之前相同的代码,我们在 Android 日志中得到以下信息:

D/com.packt.rrafols.customview.OwnCustomView: width : MeasureSpec: EXACTLY 996 D/com.packt.rrafols.customview.OwnCustomView: height: MeasureSpec: EXACTLY 1500

因此,我们仍然得到了一个MeasureSpec.EXACTLY,但这次是父RelativeLayout的大小;让我们尝试在activity_main.xml中将一个match_parents改为wrap_content

<com.packt.rrafols.customview.OwnCustomView 
    android:layout_width="match_parent" 
    android:layout_height="wrap_content"/> 

结果如下:

D/com.packt.rrafols.customview.OwnCustomView: width : MeasureSpec: EXACTLY 996 D/com.packt.rrafols.customview.OwnCustomView: height: MeasureSpec: AT_MOST 1500

我们可以轻松地识别出MeasureSpec.EXACTLYMeasureSpec.AT_MOST的模式,但MeasureSpec.UNSPECIFIED呢?

如果我们的父视图没有边界,我们将得到一个MeasureSpec.UNSPECIFIED;例如,如果我们有一个垂直的LinearLayoutScrollView内部,如 GitHub 仓库中的scrollview_layout.xml文件所示:

<?xml version="1.0" encoding="utf-8"?> 
<ScrollView  
    android:orientation="vertical" 
    android:layout_width="match_parent" 
    android:layout_height="match_parent"> 

    <LinearLayout 
        android:layout_width="match_parent" 
        android:layout_height="wrap_content" 
        android:orientation="vertical" 
        android:padding="@dimen/activity_vertical_margin"> 
        <com.packt.rrafols.customview.OwnCustomView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"/>
    </LinearLayout> 
</ScrollView> 

然后我们在 Android 日志中得到以下信息:

D/com.packt.rrafols.customview.OwnCustomView: width : MeasureSpec: EXACTLY 996 D/com.packt.rrafols.customview.OwnCustomView: height: MeasureSpec: UNSPECIFIED 1500

这看起来没问题,但如果我们现在运行这个代码会怎样呢?我们会得到一个空白屏幕;我们之前实现的红色背景不见了:

这是因为我们没有管理自定义视图的大小。让我们按照下面的代码所示进行修复:

private static int getMeasurementSize(int measureSpec, int defaultSize) { 
        int mode = MeasureSpec.getMode(measureSpec); 
        int size = MeasureSpec.getSize(measureSpec); 
        switch(mode) { 
            case MeasureSpec.EXACTLY: 
                return size; 

            case MeasureSpec.AT_MOST: 
                return Math.min(defaultSize, size); 

            case MeasureSpec.UNSPECIFIED: 
            default: 
                return defaultSize; 
        } 
    } 

    @Override 
    protected void onMeasure(int widthMeasureSpec, int
        heightMeasureSpec) { 
        int width = getMeasurementSize(widthMeasureSpec, DEFAULT_SIZE); 
        int height = getMeasurementSize(heightMeasureSpec,
        DEFAULT_SIZE); 
        setMeasuredDimension(width, height); 
    } 

现在,根据测量规格,我们将通过调用setMeasuredDimension(int, int)方法来设置视图的大小。

要查看完整示例,请检查 GitHub 仓库中Example03-Measurement文件夹中的源代码。

参数化我们的自定义视图

我们现在有一个能适应多种尺寸的自定义视图;这是好事,但如果我们需要另一个自定义视图,将背景色改为蓝色而不是红色呢?还有黄色?我们不应该为了每个定制而复制自定义视图类。幸运的是,我们可以在 XML 布局中设置参数,并从我们的自定义视图中读取它们:

  1. 首先,我们需要定义我们将在自定义视图中使用的参数类型。我们必须在 res 文件夹中创建一个名为 attrs.xml 的文件:
<?xml version="1.0" encoding="utf-8"?> 
<resources> 
    <declare-styleable name="OwnCustomView"> 
        <attr name="fillColor" format="color"/> 
    </declare-styleable> 
</resources> 
  1. 然后,在我们想要使用我们刚刚创建的这个新参数的布局文件中,我们添加了一个不同的命名空间:
<?xml version="1.0" encoding="utf-8"?> 
<ScrollView  

    android:orientation="vertical" 
    android:layout_width="match_parent" 
    android:layout_height="match_parent"> 

    <LinearLayout 
        android:layout_width="match_parent" 
        android:layout_height="wrap_content" 
        android:orientation="vertical" 
        android:padding="@dimen/activity_vertical_margin"> 

        <com.packt.rrafols.customview.OwnCustomView 
            android:layout_width="match_parent" 
            android:layout_height="wrap_content"
            app:fillColor="@android:color/holo_blue_dark"/>          
    </LinearLayout> 
</ScrollView> 
  1. 现在我们已经定义了它,让我们看看如何从我们的自定义视图类中读取它:
int fillColor;
TypedArray ta =
    context.getTheme().obtainStyledAttributes(attributeSet,
        R.styleable.OwnCustomView, 0, 0);
try {
    fillColor =
        ta.getColor(R.styleable.OwnCustomView_ocv_fillColor,
            DEFAULT_FILL_COLOR);
} finally {
    ta.recycle();
}

通过使用我们在保存 attrs.xml 文件后,Android 工具为我们创建的样式属性 ID 来获取 TypedArray,我们将能够查询在 XML 布局文件上设置的这些参数的值。

在此示例中,我们创建了一个名为 fillColor 的属性,它将被格式化为颜色。这种格式,或者说基本上,属性的类别非常重要,因为它决定了我们可以设置哪种类型的值,以及之后如何从我们的自定义视图中检索这些值。

同时,对于我们定义的每个参数,我们将在 TypedArray 中获得一个 R.styleable.<name>_<parameter_name> 索引。在上述代码中,我们正在使用 R.styleable.OwnCustomView_fillColor 索引来查询 fillColor

使用完 TypedArray 后,我们不应该忘记回收它,以便稍后可以重新使用,但一旦回收,我们就不能再使用它了。

让我们看看这个小小的自定义的结果:

在这个特定情况下我们使用了颜色,但我们也可以使用许多其他类型的参数;例如:

  • 布尔值

  • 整数

  • 浮点数

  • 颜色

  • 尺寸

  • 图像

  • 字符串

  • 资源

每个都有自己的获取方法:getBoolean(int index, boolean defValue)getFloat(int index, float defValue)

此外,为了知道是否设置了参数,我们可以在查询之前使用 hasValue(int) 方法,或者我们可以简单地使用获取器的默认值。如果在那个索引处没有设置属性,获取器将返回默认值。

有关完整示例,请查看 GitHub 存储库中的 Example04-Parameters 文件夹。

实例化自定义视图

现在我们已经看到了如何在 XML 布局上设置参数并在我们的自定义视图类中解析它们,接下来我们将看到如何从代码中实例化自定义视图,并尽可能多地重用这两种实例化机制。

从代码中实例化自定义视图

在我们的自定义视图中,我们创建了一个带有两个参数的单个构造函数,一个 Context 和一个 AttributeSet。现在,如果我们是编程式地创建我们的 UI,或者由于任何其他原因我们需要通过代码实例化我们的自定义视图,我们需要创建一个额外的构造函数。

因为我们想要在 XML 布局中继续使用我们的自定义视图,所以我们必须保留这两个构造函数。为了避免代码重复,我们将创建一些辅助方法来初始化它,并从两个构造函数中使用它们:

   public OwnCustomView(Context context) { 
        super(context); 

        init(DEFAULT_FILL_COLOR); 
    } 

    public OwnCustomView(Context context, AttributeSet attributeSet) { 
        super(context, attributeSet); 

        int fillColor; 

        TypedArray ta =
        context.getTheme().obtainStyledAttributes(attributeSet,
        R.styleable.OwnCustomView, 0, 0); 
        try { 
           fillColor = ta.getColor(R.styleable.OwnCustomView_fillColor,
           DEFAULT_FILL_COLOR); 
        } finally { 
            ta.recycle(); 
        } 

        init(fillColor); 
    } 

    private void init(int fillColor) { 
        backgroundPaint = new Paint(); 
        backgroundPaint.setStyle(Paint.Style.FILL); 

        setFillColor(fillColor); 
    } 

    public void setFillColor(int fillColor) { 
        backgroundPaint.setColor(fillColor); 
    } 

我们还创建了一个公共方法 setFillColor(int),这样我们也可以通过代码设置填充颜色。例如,让我们修改我们的 Activity,以编程方式创建视图层次结构,而不是从 XML 布局文件中膨胀它:

public class MainActivity extends AppCompatActivity { 
    private static final int BRIGHT_GREEN = 0xff00ff00; 

    @Override 
    protected void onCreate(Bundle savedInstanceState) { 
        super.onCreate(savedInstanceState); 

        LinearLayout linearLayout = new LinearLayout(this); 
        linearLayout.setLayoutParams( 
                new LinearLayout.LayoutParams(ViewGroup.
                    LayoutParams.MATCH_PARENT,
                    ViewGroup.LayoutParams.MATCH_PARENT)); 

        OwnCustomView customView = new OwnCustomView(this); 
        customView.setFillColor(BRIGHT_GREEN); 
        linearLayout.addView(customView); 

        setContentView(linearLayout); 
    } 
} 

这里,我们只是创建了一个垂直方向的LinearLayout,并添加了一个自定义视图作为子视图。然后我们将LinearLayout设置为Activity的内容视图。此外,我们还直接使用了十六进制颜色。如果我们不习惯用十六进制格式指定颜色,可以使用Color.argb()Color.rgb()将颜色组件转换为整数值。

完整的源代码可以在 GitHub 仓库中的Example05-Code文件夹中找到。

构建器模式

在上一个示例中,我们使用了setFillColor()方法来设置自定义视图的填充颜色,但是假设我们还有许多其他参数,代码可能会因为所有的设置器而变得有些混乱。

让我们创建一个简单的示例:不是使用单一背景色,我们将使用四种不同的颜色,并在我们的视图上绘制渐变:

让我们首先定义四种不同的颜色及其设置方法,如下所示:

private int topLeftColor = DEFAULT_FILL_COLOR; 
private int bottomLeftColor = DEFAULT_FILL_COLOR; 
private int topRightColor = DEFAULT_FILL_COLOR; 
private int bottomRightColor = DEFAULT_FILL_COLOR; 
private boolean needsUpdate = false;

public void setTopLeftColor(int topLeftColor) { 
    this.topLeftColor = topLeftColor; 
    needsUpdate = true; 
} 

public void setBottomLeftColor(int bottomLeftColor) { 
    this.bottomLeftColor = bottomLeftColor; 
    needsUpdate = true; 
} 

public void setTopRightColor(int topRightColor) { 
    this.topRightColor = topRightColor; 
    needsUpdate = true; 
} 

public void setBottomRightColor(int bottomRightColor) { 
    this.bottomRightColor = bottomRightColor; 
    needsUpdate = true; 
} 

我们还添加了一个布尔值以检查是否需要更新渐变。这里我们忽略线程同步,因为这不是此示例的主要目的。

然后,我们在onDraw()方法中为这个boolean添加了一个检查,如果需要的话,它会重新生成渐变:

@Override
protected void onDraw(Canvas canvas) {
    if (needsUpdate) {
        int[] colors = new int[] {topLeftColor, topRightColor,
        bottomRightColor, bottomLeftColor};

        LinearGradient lg = new LinearGradient(0, 0, getWidth(),
            getHeight(), colors, null, Shader.TileMode.CLAMP);

        backgroundPaint.setShader(lg);
        needsUpdate = false;
    }

    canvas.drawRect(0, 0, getWidth(), getHeight(), backgroundPaint);
    super.onDraw(canvas);
}

onDraw()方法中创建新的对象实例是一个不好的实践。这里只做一次,或者每次更改颜色时都会执行。如果我们不断更改颜色,这将是一个不好的例子,因为它会不断创建新对象,污染内存,并触发垃圾回收器GC)。关于性能和内存的内容将在第七章,性能考量中进行更详细的介绍。

我们必须更新我们的Activity的代码以设置这些新颜色:

public class MainActivity extends AppCompatActivity { 
    private static final int BRIGHT_GREEN = 0xff00ff00; 
    private static final int BRIGHT_RED = 0xffff0000; 
    private static final int BRIGHT_YELLOW = 0xffffff00; 
    private static final int BRIGHT_BLUE = 0xff0000ff; 

    @Override 
    protected void onCreate(Bundle savedInstanceState) { 
        super.onCreate(savedInstanceState); 

        LinearLayout linearLayout = new LinearLayout(this); 
        linearLayout.setLayoutParams( 
                new LinearLayout.LayoutParams(ViewGroup.
                LayoutParams.MATCH_PARENT,
                ViewGroup.LayoutParams.MATCH_PARENT)); 

        OwnCustomView customView = new OwnCustomView(this); 
        customView.setTopLeftColor(BRIGHT_RED); 
        customView.setTopRightColor(BRIGHT_GREEN); 
        customView.setBottomLeftColor(BRIGHT_YELLOW); 
        customView.setBottomRightColor(BRIGHT_BLUE); 
        linearLayout.addView(customView); 
        setContentView(linearLayout); 
    } 
} 

如我们所见,我们使用了四个设置器来设置颜色。如果我们有更多参数,可以使用更多设置器,但这种方法的其中一个问题是,我们必须处理线程同步,并且对象可能在所有调用完成之前都处于不稳定状态。

另一个选择是将所有参数添加到构造函数中,但这也不是一个好的解决方案。它会使得我们的工作更加复杂,因为记住参数的顺序可能会很困难,或者在有可选参数的情况下,创建许多不同的构造函数或传递 null 引用,这会使我们的代码更难以阅读和维护。

在 GitHub 仓库的Example06-BuilderPattern-NoBuilder文件夹中查看此示例的完整源代码。

既然我们已经介绍了这个问题,让我们通过在自定义视图上实现Builder模式来解决它。我们从在自定义视图中创建一个public static class开始,它会按照以下方式构建视图:

public static class Builder { 
    private Context context; 
    private int topLeftColor = DEFAULT_FILL_COLOR; 
    private int topRightColor = DEFAULT_FILL_COLOR; 
    private int bottomLeftColor = DEFAULT_FILL_COLOR; 
    private int bottomRightColor = DEFAULT_FILL_COLOR; 

    public Builder(Context context) { 
        this.context = context; 
    } 

    public Builder topLeftColor(int topLeftColor) { 
        this.topLeftColor = topLeftColor; 
        return this; 
    } 

    public Builder topRightColor(int topRightColor) { 
        this.topRightColor = topRightColor; 
        return this; 
    } 

    public Builder bottomLeftColor(int bottomLeftColor) { 
        this.bottomLeftColor = bottomLeftColor; 
        return this; 
    } 

    public Builder bottomRightColor(int bottomRightColor) { 
        this.bottomRightColor = bottomRightColor; 
        return this; 
    } 

    public OwnCustomView build() { 
        return new OwnCustomView(this); 
    } 
} 

我们还创建了一个新的私有构造函数,它只接受一个OwnCustomView.Builder对象:

private OwnCustomView(Builder builder) { 
    super(builder.context); 

    backgroundPaint = new Paint(); 
    backgroundPaint.setStyle(Paint.Style.FILL); 

    colorArray = new int[] { 
            builder.topLeftColor, 
            builder.topRightColor, 
            builder.bottomRightColor, 
            builder.bottomLeftColor 
    }; 

    firstDraw = true; 
 } 

为了清晰起见,我们删除了其他构造函数。在这个阶段,我们还基于builder对象具有的颜色创建颜色数组,以及一个boolean来判断是否是第一次绘制。

这将有助于只实例化一次LinearGradient对象,避免创建许多实例:

@Override 
    protected void onDraw(Canvas canvas) { 
        if (firstDraw) { 
            LinearGradient lg = new LinearGradient(0, 0, getWidth(),
            getHeight(), 
                    colorArray, null, Shader.TileMode.CLAMP); 

            backgroundPaint.setShader(lg); 
            firstDraw = false; 
        } 

        canvas.drawRect(0, 0, getWidth(), getHeight(),
        backgroundPaint); 
        super.onDraw(canvas); 
    } 

现在,一旦创建了对象,我们就不能更改其颜色,但我们不需要担心线程同步和对象的状态。

为了使其工作,让我们也更新我们的Activity上的代码:

public class MainActivity extends AppCompatActivity { 
    private static final int BRIGHT_GREEN = 0xff00ff00; 
    private static final int BRIGHT_RED = 0xffff0000; 
    private static final int BRIGHT_YELLOW = 0xffffff00; 
    private static final int BRIGHT_BLUE = 0xff0000ff; 

    @Override 
    protected void onCreate(Bundle savedInstanceState) { 
        super.onCreate(savedInstanceState); 

        LinearLayout linearLayout = new LinearLayout(this); 
        linearLayout.setLayoutParams( 
                new LinearLayout.LayoutParams(ViewGroup.
                LayoutParams.MATCH_PARENT,
                ViewGroup.LayoutParams.MATCH_PARENT)); 

        OwnCustomView customView = new OwnCustomView.Builder(this) 
                .topLeftColor(BRIGHT_RED) 
                .topRightColor(BRIGHT_GREEN) 
                .bottomLeftColor(BRIGHT_YELLOW) 
                .bottomRightColor(BRIGHT_BLUE) 
                .build(); 

        linearLayout.addView(customView); 

        setContentView(linearLayout); 
    } 
} 

使用Builder模式,我们的代码更清晰,当设置所有属性时构建或创建对象,如果自定义视图有更多参数,这将变得更加方便。

完整的示例源代码可以在 GitHub 仓库中的Example07-BuilderPattern文件夹中找到。

创建自定义布局

Android 提供了多种布局来以多种不同的方式定位我们的视图,但如果这些标准布局不适用于我们的特定用例,我们可以创建自己的布局。

扩展 ViewGroup

创建自定义布局的过程与创建自定义视图类似。我们需要创建一个从ViewGroup而不是视图继承的类,创建适当的构造函数,实现onMeasure()方法,并覆盖onLayout()方法,而不是onDraw()方法。

让我们创建一个非常简单的自定义布局;它会将元素添加到前一个元素的右侧,直到不适合屏幕,然后开始新的一行,使用较高的元素来计算新行的起始位置,并避免视图之间的任何重叠。

添加随机大小的视图,每个视图具有红色背景,将如下所示:

首先,让我们创建一个从ViewGroup继承的类:

public class CustomLayout extends ViewGroup { 

    public CustomLayout(Context context, AttributeSet attrs) { 
        super(context, attrs); 
    } 

    @Override 
   protected void onLayout(boolean changed, int l, int t, int r, int b) { 

   } 
} 

我们创建了构造函数,并实现了onLayout()方法,因为这是一个抽象方法,我们必须实现它。让我们添加一些逻辑:

@Override 
   protected void onLayout(boolean changed, int l, int t, int r, int b){ 
        int count = getChildCount(); 
        int left = l + getPaddingLeft(); 
        int top = t + getPaddingTop(); 

        // keeps track of maximum row height 
        int rowHeight = 0; 

        for (int i = 0; i < count; i++) { 
            View child = getChildAt(i); 

            int childWidth = child.getMeasuredWidth(); 
            int childHeight = child.getMeasuredHeight(); 

            // if child fits in this row put it there 
            if (left + childWidth < r - getPaddingRight()) { 
                child.layout(left, top, left + childWidth, top +
                childHeight); 
                left += childWidth; 
        } else { 
            // otherwise put it on next row 
                left = l + getPaddingLeft(); 
                top += rowHeight; 
                rowHeight = 0; 
            } 

            // update maximum row height 
            if (childHeight > rowHeight) rowHeight = childHeight; 
        } 
    } 

这个逻辑实现了我们之前描述的内容;它试图将子项添加到前一个子项的右侧,如果不适合布局宽度,检查当前的left位置加上测量的子项宽度,它就会开始新的一行。rowHeight变量测量那一行上的较高视图。

让我们也实现onMeasure()方法:

@Override 
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 

    int count = getChildCount(); 

    int rowHeight = 0; 
    int maxWidth = 0; 
    int maxHeight = 0; 
    int left = 0; 
    int top = 0; 

    for (int i = 0; i < count; i++) { 
        View child = getChildAt(i); 
        measureChild(child, widthMeasureSpec, heightMeasureSpec); 

        int childWidth = child.getMeasuredWidth(); 
        int childHeight = child.getMeasuredHeight(); 

        // if child fits in this row put it there 
        if (left + childWidth < getWidth()) { 
            left += childWidth; 
        } else { 
            // otherwise put it on next row 
            if(left > maxWidth) maxWidth = left; 
            left = 0; 
            top += rowHeight; 
            rowHeight = 0; 
        } 

        // update maximum row height 
        if (childHeight > rowHeight) rowHeight = childHeight; 
    } 

    if(left > maxWidth) maxWidth = left; 
    maxHeight = top + rowHeight; 

    setMeasuredDimension(getMeasure(widthMeasureSpec, maxWidth),
    getMeasure(heightMeasureSpec, maxHeight)); 

} 

逻辑与之前相同,但它没有布置其子项。它计算将需要的最大宽度和高度,然后在一个帮助方法的帮助下,根据宽度和高度测量规范设置此自定义布局的尺寸:

private int getMeasure(int spec, int desired) { 
        switch(MeasureSpec.getMode(spec)) { 
            case MeasureSpec.EXACTLY: 
                return MeasureSpec.getSize(spec); 

            case MeasureSpec.AT_MOST: 
                return Math.min(MeasureSpec.getSize(spec), desired); 

            case MeasureSpec.UNSPECIFIED: 
            default: 
                return desired; 
        } 
    } 

现在我们有了自定义布局,让我们将其添加到我们的activity_main布局中:

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

    android:id="@+id/activity_main" 
    android:layout_width="match_parent" 
    android:layout_height="match_parent" 
    android:padding="@dimen/activity_vertical_margin" 
    tools:context="com.packt.rrafols.customview.MainActivity"> 

    <com.packt.rrafols.customview.CustomLayout 
        android:id="@+id/custom_layout" 
        android:layout_width="match_parent" 
        android:layout_height="match_parent"> 

    </com.packt.rrafols.customview.CustomLayout> 
</RelativeLayout> 

在最后一步中,让我们添加一些随机大小的视图:

public class MainActivity extends AppCompatActivity { 
    @Override 
    protected void onCreate(Bundle savedInstanceState) { 
        super.onCreate(savedInstanceState); 
        setContentView(R.layout.activity_main); 

        CustomLayout customLayout = (CustomLayout)
        findViewById(R.id.custom_layout); 

        Random rnd = new Random(); 
        for(int i = 0; i < 50; i++) { 
            OwnCustomView view = new OwnCustomView(this); 

            int width = rnd.nextInt(200) + 50; 
            int height = rnd.nextInt(100) + 100; 
            view.setLayoutParams(new ViewGroup.LayoutParams(width,
            height)); 
            view.setPadding(2, 2, 2, 2); 

            customLayout.addView(view); 
        } 
    } 
} 

在 GitHub 的Example08-CustomLayout文件夹中查看此示例的完整源代码。

在此页面上,我们还可以找到一个功能齐全的自定义布局的相当复杂的示例。

基本渲染

到目前为止,我们只绘制了纯色背景或线性渐变。这既不令人兴奋也没有实际用途。让我们看看如何绘制更有趣的形状和图元。我们将通过创建一个圆形活动指示器的示例来实现,在接下来的章节中,我们将在其中添加越来越多的功能。

创建基本的圆形活动指示器

Canvas类为我们提供了许多绘图函数;例如:

  • drawArc()

  • drawBitmap()

  • drawOval()

  • drawPath()

要绘制圆形活动指示器,我们可以使用drawArc()方法。让我们创建基本的类并绘制一个弧线:

public class CircularActivityIndicator extends View { 
    private static final int DEFAULT_FG_COLOR = 0xffff0000; 
    private static final int DEFAULT_BG_COLOR = 0xffa0a0a0; 
    private Paint foregroundPaint; 
    private int selectedAngle; 

    public CircularActivityIndicator(Context context, AttributeSet
    attributeSet) { 
        super(context, attributeSet); 

        foregroundPaint = new Paint(); 
        foregroundPaint.setColor(DEFAULT_FG_COLOR); 
        foregroundPaint.setStyle(Paint.Style.FILL); 

        selectedAngle = 280; 
    } 

    @Override 
    protected void onDraw(Canvas canvas) { 
        canvas.drawArc( 
                0, 
                0, 
                getWidth(), 
                getHeight(), 
                0, selectedAngle, true, foregroundPaint); 
    } 
} 

结果如下截图所示:

让我们调整比例,使弧线的宽度与高度相同:

@Override 
protected void onDraw(Canvas canvas) { 
    int circleSize = getWidth(); 
    if (getHeight() < circleSize) circleSize = getHeight(); 

    int horMargin = (getWidth() - circleSize) / 2; 
    int verMargin = (getHeight() - circleSize) / 2; 

    canvas.drawArc( 
            horMargin, 
            verMargin, 
            horMargin + circleSize, 
            verMargin + circleSize, 
            0, selectedAngle, true, foregroundPaint); 
} 

我们将使用较小的尺寸,无论是宽度还是高度,并以正方形比例(宽度与高度相同)居中绘制弧线。

这看起来不像一个活动指示器;让我们改变它,只绘制弧线的一细带。我们可以通过使用canvas提供的剪裁功能来实现这一点。我们可以使用canvas.clipRectcanvas.clipPath,例如。使用剪裁方法时,我们还可以指定一个剪裁操作。如果我们不指定,默认情况下,它将与当前的剪裁相交。

为了只绘制一个细带,我们将在路径中创建一个较小的弧线,大小约为我们想要绘制的弧线的75%。然后,我们将它从整个视图的剪裁矩形中减去:

private Path clipPath; 

@Override 
protected void onDraw(Canvas canvas) { 
    int circleSize = getWidth(); 
    if (getHeight() < circleSize) circleSize = getHeight(); 

    int horMargin = (getWidth() - circleSize) / 2; 
    int verMargin = (getHeight() - circleSize) / 2; 

    // create a clipPath the first time 
    if(clipPath == null) { 
        int clipWidth = (int) (circleSize * 0.75); 

        int clipX = (getWidth() - clipWidth) / 2; 
        int clipY = (getHeight() - clipWidth) / 2; 
        clipPath = new Path(); 
        clipPath.addArc( 
                clipX, 
                clipY, 
                clipX + clipWidth, 
                clipY + clipWidth, 
                0, 360); 
    } 

    canvas.clipRect(0, 0, getWidth(), getHeight()); 
    canvas.clipPath(clipPath, Region.Op.DIFFERENCE); 

    canvas.drawArc( 
            horMargin, 
            verMargin, 
            horMargin + circleSize, 
            verMargin + circleSize, 
            0, selectedAngle, true, foregroundPaint); 
} 

在以下截图中,我们可以看到差异:

作为最后的润色,让我们给弧线添加一个背景颜色,并将起始位置改为视图的顶部。

为了绘制背景,我们将在构造函数中添加以下代码来创建一个背景Paint

backgroundPaint = new Paint(); 
backgroundPaint.setColor(DEFAULT_BG_COLOR); 
backgroundPaint.setStyle(Paint.Style.FILL); 

然后修改onDraw()方法,在实际绘制另一个弧线之前绘制它:

canvas.drawArc( 
        horMargin, 
        verMargin, 
        horMargin + circleSize, 
        verMargin + circleSize, 
        0, 360, true, backgroundPaint); 

作为一个小差异,我们绘制了整个360度,这样它将覆盖整个圆。

要改变弧线的起始位置,我们将旋转绘图操作。Canvas支持旋转、平移和矩阵变换。在这种情况下,我们只需逆时针旋转90度,就能使我们的起始点位于弧线的顶部:

@Override 
protected void onDraw(Canvas canvas) { 
    int circleSize = getWidth(); 
    if (getHeight() < circleSize) circleSize = getHeight(); 

    int horMargin = (getWidth() - circleSize) / 2; 
    int verMargin = (getHeight() - circleSize) / 2; 

    // create a clipPath the first time 
    if(clipPath == null) { 
        int clipWidth = (int) (circleSize * 0.75); 

        int clipX = (getWidth() - clipWidth) / 2; 
        int clipY = (getHeight() - clipWidth) / 2; 
        clipPath = new Path(); 
        clipPath.addArc( 
                clipX, 
                clipY, 
                clipX + clipWidth, 
                clipY + clipWidth, 
                0, 360); 
    } 

    canvas.clipRect(0, 0, getWidth(), getHeight()); 
    canvas.clipPath(clipPath, Region.Op.DIFFERENCE); 

    canvas.save(); 
    canvas.rotate(-90, getWidth() / 2, getHeight() / 2); 

    canvas.drawArc( 
            horMargin, 
            verMargin, 
            horMargin + circleSize, 
            verMargin + circleSize, 
            0, 360, true, backgroundPaint); 

    canvas.drawArc( 
            horMargin, 
            verMargin, 
            horMargin + circleSize, 
            verMargin + circleSize, 
            0, selectedAngle, true, foregroundPaint); 

    canvas.restore(); 
} 

我们还使用了canvas.save()canvas.restore()来保存我们的canvas的状态;否则,每次绘制时它都会旋转-90度。当调用canvas.rotate()方法时,我们还指定了旋转的中心点,该中心点与屏幕的中心点以及弧线的中心点相匹配。

每当我们使用如rotatescaletranslatecanvas函数时,实际上我们是在对所有后续的canvas绘图操作应用变换。

最终结果如下截图所示:

我们需要意识到的一件事是,并非所有的canvas操作在所有 Android 版本上都得到硬件支持。请检查您需要执行的操作是否受支持,或者为它们提供运行时解决方案。在以下链接中了解更多关于哪些操作是硬件加速的信息:

developer.android.com/guide/topics/graphics/hardware-accel.html

这是类的最终实现代码:

public class CircularActivityIndicator extends View { 
    private static final int DEFAULT_FG_COLOR = 0xffff0000; 
    private static final int DEFAULT_BG_COLOR = 0xffa0a0a0; 
    private Paint backgroundPaint; 
    private Paint foregroundPaint; 
    private int selectedAngle; 
    private Path clipPath; 

    public CircularActivityIndicator(Context context, AttributeSet
        attributeSet) { 
        super(context, attributeSet); 

        backgroundPaint = new Paint(); 
        backgroundPaint.setColor(DEFAULT_BG_COLOR); 
        backgroundPaint.setStyle(Paint.Style.FILL); 

        foregroundPaint = new Paint(); 
        foregroundPaint.setColor(DEFAULT_FG_COLOR); 
        foregroundPaint.setStyle(Paint.Style.FILL); 

        selectedAngle = 280; 
    } 

    @Override 
    protected void onDraw(Canvas canvas) { 
        int circleSize = getWidth(); 
        if (getHeight() < circleSize) circleSize = getHeight(); 

        int horMargin = (getWidth() - circleSize) / 2; 
        int verMargin = (getHeight() - circleSize) / 2; 

        // create a clipPath the first time 
        if(clipPath == null) { 
            int clipWidth = (int) (circleSize * 0.75); 

            int clipX = (getWidth() - clipWidth) / 2; 
            int clipY = (getHeight() - clipWidth) / 2; 
            clipPath = new Path(); 
            clipPath.addArc( 
                    clipX, 
                    clipY, 
                    clipX + clipWidth, 
                    clipY + clipWidth, 
                    0, 360); 
        } 

        canvas.clipPath(clipPath, Region.Op.DIFFERENCE); 

        canvas.save(); 
        canvas.rotate(-90, getWidth() / 2, getHeight() / 2); 

        canvas.drawArc( 
                horMargin, 
                verMargin, 
                horMargin + circleSize, 
                verMargin + circleSize, 
                0, 360, true, backgroundPaint); 

        canvas.drawArc( 
                horMargin, 
                verMargin, 
                horMargin + circleSize, 
                verMargin + circleSize, 
                0, selectedAngle, true, foregroundPaint); 

        canvas.restore(); 
    } 
} 

整个示例源代码可以在 GitHub 仓库中的Example09-BasicRendering文件夹中找到。

此外,我在 2015 年 1 月在克拉科夫的 Android 开发者后台关于这个话题进行了演讲;以下是演讲的链接:

www.slideshare.net/RaimonRls/android-custom-views-72600098

总结

在本章中,我们学习了如何测量以及如何为自定义视图添加参数。我们还了解了如何从代码中实例化自定义视图,并使用Builder模式来简化所有参数,使代码保持整洁。此外,我们还快速通过一个自定义布局的示例,并开始构建圆形活动指示器。在下一章中,我们将学习如何处理事件并为刚刚开始构建的圆形活动指示器添加一些交互。

第三章:事件处理

现在我们已经了解了画布绘图的基础知识,并且我们的自定义视图已经适应了其大小,是时候与它进行交互了。许多自定义视图只需要以特定方式绘制某些内容;这就是我们创建它们为自定义视图的原因,但还有许多其他视图需要响应用户事件。例如,当用户在我们的自定义视图上点击或拖动时,它将如何表现?

为了回答这些问题,我们将在本章中详细介绍以下内容:

  • 基本事件处理

  • 高级事件处理

基本事件处理

让我们从为我们的自定义视图添加一些基本事件处理开始。我们将介绍基础知识,稍后我们将添加更复杂的事件。

响应触摸事件

为了使我们的自定义视图具有交互性,我们首先要实现的是处理并响应用户的触摸事件,或者基本上,当用户在我们的自定义视图上触摸或拖动时。

安卓提供了onTouchEvent()方法,我们可以在自定义视图中重写它。通过重写这个方法,我们将获取到发生在其上的任何触摸事件。为了了解它是如何工作的,让我们将它添加到上一章构建的自定义视图中:

@Override 
public boolean onTouchEvent(MotionEvent event) { 
    Log.d(TAG, "touch: " + event); 
    return super.onTouchEvent(event); 
} 

同时让我们添加一个日志调用,以查看我们接收的事件。如果我们运行此代码并在视图上触摸,我们将得到以下结果:

D/com.packt.rrafols.customview.CircularActivityIndicator: touch: MotionEvent { action=ACTION_DOWN, actionButton=0, id[0]=0, x[0]=644.3645, y[0]=596.55804, toolType[0]=TOOL_TYPE_FINGER, buttonState=0, metaState=0, flags=0x0, edgeFlags=0x0, pointerCount=1, historySize=0, eventTime=30656461, downTime=30656461, deviceId=9, source=0x1002 }

如我们所见,事件上有许多信息,如坐标、动作类型和时间,但即使我们对它执行更多操作,我们也只会收到ACTION_DOWN事件。这是因为视图的默认实现不是可点击的。默认情况下,如果我们不在视图上启用可点击标志,onTouchEvent()的默认实现将返回 false 并忽略进一步的事件。

onTouchEvent()方法必须返回true如果事件已经被处理,或者返回false如果还没有。如果我们在自定义视图中接收到一个事件,而我们不知道该如何处理或者对此类事件不感兴趣,我们应该返回false,这样它就可以由我们视图的父视图或其他组件或系统来处理。

为了接收更多类型的事件,我们可以做两件事:

  • 使用setClickable(true)将视图设置为可点击

  • 在我们自己的类中实现逻辑并处理事件

稍后,我们将实现更复杂的事件;我们将选择第二个选项。

让我们进行一个快速测试,将方法更改为只返回 true,而不是调用父方法:

@Override 
public boolean onTouchEvent(MotionEvent event) { 
    Log.d(TAG, "touch: " + event); 
    return true; 
} 

现在,我们应该能够接收许多其他类型的事件,如下所示:

...CircularActivityIndicator: touch: MotionEvent { action=ACTION_DOWN, ...CircularActivityIndicator: touch: MotionEvent { action=ACTION_UP, ...CircularActivityIndicator: touch: MotionEvent { action=ACTION_DOWN, ...CircularActivityIndicator: touch: MotionEvent { action=ACTION_MOVE, ...CircularActivityIndicator: touch: MotionEvent { action=ACTION_MOVE, ...CircularActivityIndicator: touch: MotionEvent { action=ACTION_MOVE, ...CircularActivityIndicator: touch: MotionEvent { action=ACTION_UP, ...CircularActivityIndicator: touch: MotionEvent { action=ACTION_DOWN,

如前一个示例所示,我们可以看到在之前的日志中,我们不仅拥有ACTION_DOWNACTION_UP,还有ACTION_MOVE来表示我们在视图上执行了拖动操作。

我们首先关注处理ACTION_UPACTION_DOWN事件。让我们添加一个名为boolean的变量,该变量将跟踪我们当前是否正在按或触摸我们的视图:

private boolean pressed; 

public CircularActivityIndicator(Context context, AttributeSet attributeSet) { 
    ... 
    ... 
    pressed = false; 
} 

我们添加了变量,并将其默认状态设置为false,因为视图在创建时不会被按压。现在,让我们在我们的onTouchEvent()实现中添加代码来处理这个问题:

@Override 
public boolean onTouchEvent(MotionEvent event) { 
    Log.d(TAG, "touch: " + event); 
    switch(event.getAction()) { 
        case MotionEvent.ACTION_DOWN: 
            pressed = true; 
            return true; 

        case MotionEvent.ACTION_UP: 
            pressed = false; 
            return true; 

        default: 
            return false; 
    } 
} 

我们处理了MotionEventACTION_DOWNMotionEvent.ACTION_UP事件;我们在这里收到的任何其他动作,我们都会忽略并返回false,因为我们没有处理它。

好的,现在我们有一个变量来跟踪我们是否正在按压视图,但我们还应该做其他事情,否则这个变量不会很有用。让我们修改onDraw()方法,当视图被按压时,以不同的颜色绘制圆形:

private static final int DEFAULT_FG_COLOR = 0xffff0000; 
private static final int PRESSED_FG_COLOR = 0xff0000ff; 

@Override 
protected void onDraw(Canvas canvas) { 
    if (pressed) { 
        foregroundPaint.setColor(PRESSED_FG_COLOR); 
    } else { 
        foregroundPaint.setColor(DEFAULT_FG_COLOR); 
    } 

如果我们运行这个例子并触摸我们的视图,我们会发现什么都没有发生!问题是什么?我们没有触发任何重绘事件,视图也没有再次被绘制。如果我们设法持续按压视图,并将应用放到后台然后再返回前台,我们就能看到这段代码是有效的。然而,为了正确地处理,当我们更改需要重新绘制视图的内容时,我们应该触发一个重绘事件,如下所示:

@Override 
public boolean onTouchEvent(MotionEvent event) { 
    Log.d(TAG, "touch: " + event); 
    switch(event.getAction()) { 
        case MotionEvent.ACTION_DOWN: 
            pressed = true; 
            invalidate(); 
            return true; 

        case MotionEvent.ACTION_UP: 
            pressed = false; 
            invalidate(); 
            return true; 

        default: 
            pressed = false; 
            invalidate(); 
            return false; 
    } 
} 

好的,这应该能解决问题!调用 invalidate 方法将在未来触发一个onDraw()方法的调用:

developer.android.com/reference/android/view/View.html#invalidate()

我们现在可以重构这段代码,并将其移动到一个方法中:

private void changePressedState(boolean pressed) { 
    this.pressed = pressed; 
    invalidate(); 
} 

@Override 
public boolean onTouchEvent(MotionEvent event) { 
    Log.d(TAG, "touch: " + event); 
    switch(event.getAction()) { 
        case MotionEvent.ACTION_DOWN: 
            changePressedState(true); 
            return true; 

        case MotionEvent.ACTION_UP: 
            changePressedState(false); 
            return true; 

        default: 
            changePressedState(false); 
            return false; 
    } 
} 

我们需要知道 invalidate 必须在 UI 线程中调用,如果从其他线程调用将会抛出异常。如果我们需要从另一个线程调用它,例如,在从网络服务接收到一些数据后更新视图,我们应该调用postInvalidate()

这是结果:

拖动事件

既然我们已经对ACTION_DOWNACTION_UP事件做出了反应,我们将通过也对ACTION_MOVE事件做出反应来增加一点复杂性。

让我们根据在两个方向上拖动的距离来更新角度。为此,我们需要存储用户最初按压的位置,因此我们将用ACTION_DOWN事件中的XY坐标来存储变量lastXlastY

当我们收到一个ACTION_MOVE事件时,我们计算lastXlastY坐标与事件中收到的当前值之间的差。我们用XY差值的平均值来更新selectedAngle,并最终更新lastXlastY坐标。我们必须记得调用 invalidate,否则我们的视图将不会被重绘:

private float lastX, lastY; 

@Override 
public boolean onTouchEvent(MotionEvent event) { 
    switch(event.getAction()) { 
        case MotionEvent.ACTION_DOWN: 
            changePressedState(true); 

            lastX = event.getX(); 
            lastY = event.getY(); 
            return true; 

        case MotionEvent.ACTION_UP: 
            changePressedState(false); 
            return true; 

        case MotionEvent.ACTION_MOVE: 
            float dragX = event.getX(); 
            float dragY = event.getY(); 

            float dx = dragX - lastX; 
            float dy = dragY - lastY; 

            selectedAngle += (dx + dy) / 2; 

            lastX = dragX; 
            lastY = dragY; 

            invalidate(); 
            return true; 

        default: 
            return false; 
    } 
} 

这种移动可能感觉有点不自然,所以如果我们希望圆的角度跟随我们实际按压的位置,我们应该从笛卡尔坐标转换为极坐标:

en.wikipedia.org/wiki/List_of_common_coordinate_transformations

进行此更改后,无需跟踪先前坐标,因此我们可以用以下代码替换我们的代码:

private int computeAngle(float x, float y) { 
    x -= getWidth() / 2; 
    y -= getHeight() / 2; 

    int angle = (int) (180.0 * Math.atan2(y, x) / Math.PI) + 90; 
    return (angle > 0) ? angle : 360 + angle; 
} 

@Override 
public boolean onTouchEvent(MotionEvent event) { 
    switch(event.getAction()) { 
        case MotionEvent.ACTION_DOWN: 
            selectedAngle = computeAngle(event.getX(), event.getY()); 
            changePressedState(true); 
            return true; 

        case MotionEvent.ACTION_UP: 
            changePressedState(false); 
            return true; 

        case MotionEvent.ACTION_MOVE: 
            selectedAngle = computeAngle(event.getX(), event.getY()); 
            invalidate(); 
            return true; 

        default: 
            return false; 
    } 
} 

复杂布局

到目前为止,我们已经了解了如何在自定义视图上管理onTouchEvent()事件,但这仅适用于占据整个屏幕大小的视图,因此这是一个相对简单的处理方式。如果我们想在也处理触摸事件的ViewGroup中包含我们的视图,例如ScrollView,我们需要做哪些更改?

让我们更改这个布局:

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

    android:id="@+id/activity_main" 
    android:layout_width="match_parent" 
    android:layout_height="match_parent" 
    android:padding="@dimen/activity_vertical_margin" 
    tools:context="com.packt.rrafols.customview.MainActivity"> 

    <ScrollView 
        android:layout_width="match_parent" 
        android:layout_height="wrap_content" 
        android:layout_alignParentTop="true" 
        android:layout_alignParentStart="true" 
        android:layout_marginTop="13dp"> 

        <LinearLayout 
            android:layout_width="match_parent" 
            android:layout_height="wrap_content" 
            android:orientation="vertical"> 

            <TextView 
                android:layout_width="match_parent" 
                android:layout_height="wrap_content" 
                android:paddingTop="100dp" 
                android:paddingBottom="100dp" 
                android:text="Top" 
                android:background="@color/colorPrimaryDark" 
                android:textColor="@android:color/white" 
                android:gravity="center"/> 

            <com.packt.rrafols.customview.CircularActivityIndicator 
                android:layout_width="match_parent" 
                android:layout_height="300dp"/> 

            <TextView 
                android:layout_width="match_parent" 
                android:layout_height="wrap_content" 
                android:paddingTop="100dp" 
                android:paddingBottom="100dp" 
                android:text="Bottom" 
                android:background="@color/colorPrimaryDark" 
                android:textColor="@android:color/white" 
                android:gravity="center"/> 
        </LinearLayout> 
    </ScrollView> 
</RelativeLayout> 

基本上,我们把自定义视图放在了ScrollView中,这样两者都可以处理事件。我们应该选择哪些事件由我们的视图处理,哪些事件由ScrollView处理。

为了实现这一点,视图为我们提供了getParent()方法,以获取其父视图:

关于ViewParent的 Android 官方文档

一旦我们有了父视图,就可以调用requestDisallowInterceptTouchEvent来禁止父视图及其父视图拦截触摸事件。此外,为了只消耗我们感兴趣的事件,我们添加了一个检查,以查看用户触摸的位置是否在圆的半径内或外部。如果触摸在外部,我们将忽略该事件并不处理。

private boolean computeAndSetAngle(float x, float y) { 
    x -= getWidth() / 2; 
    y -= getHeight() / 2; 

    double radius = Math.sqrt(x * x + y * y); 
    if(radius > circleSize/2) return false; 

    int angle = (int) (180.0 * Math.atan2(y, x) / Math.PI) + 90; 
    selectedAngle = ((angle > 0) ? angle : 360 + angle); 
    return true; 
} 

@Override 
public boolean onTouchEvent(MotionEvent event) { 
    boolean processed; 

    switch(event.getAction()) { 
        case MotionEvent.ACTION_DOWN: 
            processed = computeAndSetAngle(event.getX(), event.getY()); 
            if(processed) { 
                getParent().requestDisallowInterceptTouchEvent(true); 
                changePressedState(true); 
            } 
            return processed; 

        case MotionEvent.ACTION_UP: 
            getParent().requestDisallowInterceptTouchEvent(false); 
            changePressedState(false); 
            return true; 

        case MotionEvent.ACTION_MOVE: 
            processed = computeAndSetAngle(event.getX(), event.getY()); 
            invalidate(); 
            return processed; 

        default: 
            return false; 
    } 
} 

我们通过应用之前使用的相同笛卡尔极坐标变换来计算半径。我们还更改了代码,所以如果触摸点在圆的半径内,我们会在ACTION_DOWN事件上调用getParent().requestDisallowInterceptTouchEvent(true),告诉ViewParent不要拦截触摸事件。我们需要在ACTION_UP事件上调用相反的getParent().requestDisallowInterceptTouchEvent(false)来撤销这个动作。

这是此更改的结果,我们可以看到自定义视图顶部和底部各有一个TextView

现在如果我们触摸圆圈,我们的自定义视图将只处理事件并改变圆圈的角度。另一方面,如果触摸圆圈外部,我们将让ScrollView处理这些事件。

变化并不多,但是当我们构建一个可能会在多个地方重复使用的自定义视图时,我们绝对应该在多种布局配置中测试它,以了解其表现如何。

在 GitHub 仓库的Example10-Events文件夹中找到此示例的完整源代码。

高级事件处理

我们已经了解了如何处理onTouchEvent(),但我们还可以检测一些手势或更复杂的交互。Android 为我们提供了GestureDetector来帮助检测一些手势。支持库中甚至还有一个GestureDetectorCompat,用于为旧版本的 Android 提供支持。

有关GestureDetector的更多信息,请查看 Android 文档。

检测手势

让我们改变我们一直在构建的代码,以使用GestureDetector。我们还将使用Scroller实现来在值之间平滑滚动。我们可以修改构造函数以创建Scroller对象和实现了GestureDetector.OnGestureListenerGestureDetector

private GestureDetector gestureListener; 
private Scroller angleScroller; 

public CircularActivityIndicator(Context context, AttributeSet attributeSet) { 
    super(context, attributeSet); 

    ... 

    selectedAngle = 280; 
    pressed = false; 

    angleScroller = new Scroller(context, null, true); 
    angleScroller.setFinalX(selectedAngle); 

    gestureListener = new GestureDetector(context, new
    GestureDetector.OnGestureListener() { 
       boolean processed; 

       @Override 
       public boolean onDown(MotionEvent event) { 
           processed = computeAndSetAngle(event.getX(), event.getY()); 
           if (processed) { 
               getParent().requestDisallowInterceptTouchEvent(true); 
               changePressedState(true); 
               postInvalidate(); 
           } 
           return processed; 
       } 

       @Override 
       public void onShowPress(MotionEvent e) { 

       } 

       @Override 
       public boolean onSingleTapUp(MotionEvent e) { 
           endGesture(); 
           return false; 
       } 

       @Override 
       public boolean onScroll(MotionEvent e1, MotionEvent e2, float
       distanceX, float distanceY) { 
           computeAndSetAngle(e2.getX(), e2.getY()); 
           postInvalidate(); 
           return true; 
       } 

       @Override 
       public void onLongPress(MotionEvent e) { 
           endGesture(); 
       } 

       @Override 
       public boolean onFling(MotionEvent e1, MotionEvent e2, float
       velocityX, float velocityY) { 
           return false; 
       } 
   }); 
} 

这个接口中有许多回调方法,但首先,为了处理手势,我们需要在onDown()回调中返回 true;否则,我们表明不会进一步处理事件链。

现在我们简化了onTouchEvent(),因为它只需将事件简单地转发给gestureListener

@Override 
public boolean onTouchEvent(MotionEvent event) { 
    return gestureListener.onTouchEvent(event); 
} 

因为我们可能有不同的手势,如长按、抛掷、滚动,所以我们创建了一个方法来结束手势并恢复状态:

private void endGesture() { 
    getParent().requestDisallowInterceptTouchEvent(false); 
    changePressedState(false); 
    postInvalidate(); 
} 

我们修改了computeAndSetAngle()方法以使用Scroller

private boolean computeAndSetAngle(float x, float y) { 
    x -= getWidth() / 2; 
    y -= getHeight() / 2; 

    double radius = Math.sqrt(x * x + y * y); 
    if(radius > circleSize/2) return false; 

    int angle = (int) (180.0 * Math.atan2(y, x) / Math.PI) + 90; 
    angle = ((angle > 0) ? angle : 360 + angle); 

    if(angleScroller.computeScrollOffset()) { 
        angleScroller.forceFinished(true); 
    } 

    angleScroller.startScroll(angleScroller.getCurrX(), 0, angle -
    angleScroller.getCurrX(), 0); 
    return true; 
} 

Scroller实例将动画化这些值;我们需要不断检查更新的值以执行动画。一种实现方法是,在onDraw()方法中检查动画是否完成,并在动画未完成时触发失效以重新绘制视图:

@Override 
protected void onDraw(Canvas canvas) { 
    boolean notFinished = angleScroller.computeScrollOffset(); 
    selectedAngle = angleScroller.getCurrX(); 

    ... 

    if (notFinished) invalidate(); 
} 

computeScrollOffset()方法会在Scroller还未到达终点时返回 true;在调用它之后,我们可以使用getCurrX()方法查询滚动值。在这个例子中,我们正在动画化圆的角度值,但我们使用ScrollerX坐标来驱动这个动画。

使用这个GestureDetector,我们还可以检测长按和抛掷等手势。由于抛掷涉及更多动画,我们将在本书的下一章中进行介绍。

有关如何使视图具有交互性的更多信息,请参考:

在 Android 开发者网站上了解如何使视图具有交互性

本例的源代码可以在 GitHub 仓库的Example11-Events文件夹中找到。

总结

在本章中,我们学习了如何与自定义视图进行交互。构建自定义视图的部分强大功能在于能够与它们互动并使它们具有交互性。我们也了解了如何简单地响应触摸和释放事件,如何拖动元素以及计算拖动事件之间的增量距离,最后学习了如何使用GestureDetector

由于到目前为止渲染保持相当简单,我们将在下一章重点介绍使我们的渲染更加复杂并使用更多的绘图原语。

第四章:高级 2D 渲染

能够绘制更复杂的原始图形或使用它们的组合对于使我们的自定义视图的用户体验变得出色、实用和特别至关重要。到目前为止,我们在自定义视图中使用了一些绘制和渲染操作,但如果我们仔细查看 Android 文档,这只是 Android 为开发者提供的一小部分功能。我们已经绘制了一些原始图形,保存和恢复了我们的canvas状态,并应用了一些剪辑操作,但这只是冰山一角。在本章中,我们将再次看到这些操作,但我们将看到一些新的绘制操作以及如何将它们一起使用。我们将更详细地介绍以下主题:

  • 绘图操作

  • 蒙版和剪辑

  • 渐变

  • 把它们放在一起

绘图操作

正如我们刚才提到的,我们已经看到并使用了一些绘图操作,但这只是冰山一角。我们将看到新的绘图操作以及如何将它们结合使用。

位图

让我们从绘制位图或图像开始。我们不是使用白色背景,而是将图像作为我们自定义视图的背景。使用我们之前示例的源代码,我们可以做一些非常简单的修改来绘制图像:

首先,定义一个Bitmap对象来保存对图像的引用:

private Bitmap backgroundBitmap; 

首先,让我们用已有的应用程序图标来初始化它:

public CircularActivityIndicator(Context context, AttributeSet attributeSet) { 
    super(context, attributeSet); 

    backgroundBitmap = BitmapFactory.decodeResource(getResources(),
    R.mipmap.ic_launcher); 

BitmapFactory为我们提供了多种加载和解码图像的方法。

当我们加载了图像之后,可以在onDraw()方法中通过调用drawBitmap(Bitmap bitmap, float left, float top, Paint paint)方法来绘制图像:

@Override 
protected void onDraw(Canvas canvas) { 
    if (backgroundBitmap != null) { 
        canvas.drawBitmap(backgroundBitmap, 0, 0, null); 
    } 

因为我们不需要从Paint对象中得到任何特别的东西,所以我们将其设置为null;我们将在本书稍后使用它,但现在,只需忽略它。

如果backgroundBitmapnull,这意味着它无法加载图像;因此,为了安全起见,我们应始终检查。这段代码只会在我们自定义视图的左上角绘制图标,尽管我们可以通过设置不同的坐标(这里我们使用了00)或对我们的canvas应用之前做过的变换来改变其位置。例如,我们可以根据用户选择的角度来旋转图像:

@Override 
protected void onDraw(Canvas canvas) { 
    // apply a rotation of the bitmap based on the selectedAngle 
    if (backgroundBitmap != null) { 
        canvas.save(); 
        canvas.rotate(selectedAngle, backgroundBitmap.getWidth() / 2,
        backgroundBitmap.getHeight() / 2); 
        canvas.drawBitmap(backgroundBitmap, 0, 0, null); 
        canvas.restore(); 
    } 

注意,我们已经将图像的中心作为轴心点,否则将以其左上角为中心旋转。

有其他方法可以绘制图像;Android 提供了另一种方法,可以从源Rect绘制到目标RectRect对象允许我们存储四个坐标并将其用作矩形。

drawBitmap(Bitmap bitmap, Rect source, Rect dest, Paint paint)方法非常适用于将图像的一部分绘制成我们想要的任何其他大小。这个方法会处理缩放选定部分的图像以填充目标矩形。例如,如果我们想绘制图像的右半部分并缩放到整个自定义视图的大小,我们可以使用以下代码。

首先,让我们定义背景 Bitmap 和两个 Rect;一个用于保存源尺寸,另一个用于目标尺寸:

private Bitmap backgroundBitmap; 
private Rect bitmapSource; 
private Rect bitmapDest; 

然后,让我们在类构造函数中实例化它们。在 onDraw() 方法中这样做不是一个好习惯,因为我们应该避免为每次帧调用或每次绘制自定义视图的方法分配内存。这样做会触发额外的垃圾收集周期,影响性能。

public CircularActivityIndicator(Context context, AttributeSet attributeSet) { 
    super(context, attributeSet); 

    backgroundBitmap = BitmapFactory.decodeResource(getResources(),
    R.mipmap.ic_launcher); 
    bitmapSource = new Rect(); 

    bitmapSource.top = 0; 
    bitmapSource.left = 0; 
    if(backgroundBitmap != null) { 
        bitmapSource.left = backgroundBitmap.getWidth() / 2; 
        bitmapSource.right = backgroundBitmap.getWidth(); 
        bitmapSource.botto 
        m = backgroundBitmap.getHeight(); 
    } 
    bitmapDest = new Rect(); 

默认情况下,Rect 会将四个坐标初始化为 0,但在这里,为了清晰起见,我们将顶部和左侧坐标设置为 0。如果图像加载成功,我们将右侧和底部分别设置为图像的宽度和高度。由于我们只想绘制图像的右半部分,因此我们将左侧边界更新为图像宽度的一半。

onDraw() 方法中,我们将目标 Rect 的右侧和底部坐标设置为自定义视图的宽度和高度,然后我们绘制图像:

@Override 
protected void onDraw(Canvas canvas) { 
    if (backgroundBitmap != null) { 
        bitmapDest.right = getWidth(); 
        bitmapDest.bottom = getHeight(); 

        canvas.drawBitmap(backgroundBitmap, bitmapSource, bitmapDest,
        null); 
    } 

让我们检查一下结果:

我们可以看到它并不遵循图像的宽高比,但我们可以通过计算较小维度(水平或垂直)的比例并以此比例进行缩放来解决它。然后,将这个比例应用到另一个维度上。计算图像比例后,我们将看到以下代码:

@Override 
protected void onDraw(Canvas canvas) { 
    if (backgroundBitmap != null) { 
        if ((bitmapSource.width() > bitmapSource.height() && getHeight() >
        getWidth()) || 
            (bitmapSource.width() <= bitmapSource.height() && getWidth() >=
            getHeight())) { 

            double ratio = ((double) getHeight()) / ((double)
            bitmapSource.height()); 
            int scaledWidth = (int) (bitmapSource.width() * ratio); 
            bitmapDest.top = 0; 
            bitmapDest.bottom = getHeight(); 
            bitmapDest.left = (getWidth() - scaledWidth) / 2; 
            bitmapDest.right = bitmapDest.left + scaledWidth; 
        } else { 
            double ratio = ((double) getWidth()) / ((double)
            bitmapSource.width()); 
            int scaledHeight = (int) (bitmapSource.height() * ratio); 
            bitmapDest.left = 0; 
            bitmapDest.right = getWidth(); 
            bitmapDest.top = 0; 
            bitmapDest.bottom = scaledHeight; 
        } 

        canvas.drawBitmap(backgroundBitmap, bitmapSource, bitmapDest,
        null); 
    } 

我们还可以使用变换 Matrix 绘制 Bitmap。为此,我们可以创建 Matrix 的新实例并应用变换:

private Matrix matrix; 

在构造函数中创建实例。不要在 onDraw() 实例中创建实例,因为这将污染内存并触发不必要的垃圾收集,如前所述:

matrix = new Matrix(); 
matrix.postScale(0.2f, 0.2f); 
matrix.postTranslate(0, 200); 

请注意矩阵操作顺序;也有后操作和前操作。更多信息请查看矩阵类文档。

onDraw() 方法中,只需使用 drawBitmap (Bitmap bitmap, Matrix matrix, Paint paint) 方法绘制 Bitmap,并使用我们在类构造函数中初始化的 matrix。在这个例子中,我们还使用了 null Paint 对象以简化,因为在这里我们不需要从 Paint 对象获取任何特定内容。

canvas.drawBitmap(backgroundBitmap, matrix, null); 

尽管这些是将 Bitmap 绘制到 Canvas 上最常见的方法,但还有更多方法。

此外,请查看 GitHub 存储库中的 Example12-Drawing 文件夹,以查看此示例的完整源代码。

使用 Paint 类

到现在为止我们一直在绘制一些基本图形,但 Canvas 为我们提供了更多基本渲染方法。我们将简要介绍其中一些,但首先,让我们正式介绍一下 Paint 类,因为我们还没有完全介绍它。

根据官方定义,Paint类保存了关于如何绘制基本图形、文本和位图的风格和颜色信息。如果我们检查我们一直在构建的示例,我们在类构造函数中或在onCreate方法中创建了一个Paint对象,并在后面的onDraw()方法中使用它来绘制基本图形。例如,如果我们把背景Paint实例的Style设置为Paint.Style.FILL,它会填充基本图形,但如果我们只想绘制边框或轮廓的笔触,我们可以将其更改为Paint.Style.STROKE。我们可以同时使用Paint.Style.FILL_AND_STROKE来绘制两者。

为了看到Paint.Style.STROKE的效果,我们将在自定义视图中的选定彩色栏上方绘制一个黑色边框。首先,在类构造函数中定义一个新的Paint对象,名为indicatorBorderPaint,并初始化它:

indicatorBorderPaint = new Paint(); 
indicatorBorderPaint.setAntiAlias(false); 
indicatorBorderPaint.setColor(BLACK_COLOR); 
indicatorBorderPaint.setStyle(Paint.Style.STROKE); 
indicatorBorderPaint.setStrokeWidth(BORDER_SIZE); 
indicatorBorderPaint.setStrokeCap(Paint.Cap.BUTT); 

我们还定义了一个常量来设置边框线的尺寸,并将笔触宽度设置为这个尺寸。如果我们把宽度设置为0,Android 保证会使用一个像素来绘制线条。由于我们现在想要绘制一条粗黑的边框,所以这不是我们的情况。此外,我们将笔触线帽设置为Paint.Cap.BUTT,以避免笔触溢出路径。还有两种线帽可以使用,Paint.Cap.SQUAREPaint.Cap.ROUND。最后这两种线帽会分别以圆形(使笔触变圆)或方形结束笔触。

让我们快速了解三种线帽之间的区别,并介绍drawLine这个基本图形绘制方法。

首先,我们创建一个包含所有三种线帽的数组,这样我们可以轻松地在它们之间迭代,并编写更紧凑的代码:

private static final Paint.Cap[] caps = new Paint.Cap[] { 
        Paint.Cap.BUTT, 
        Paint.Cap.ROUND, 
        Paint.Cap.SQUARE 
}; 

现在,在我们的onDraw()方法中,让我们使用drawLine(float startX, float startY, float stopX, float stopY, Paint paint)方法,用每种线帽绘制一条线:

int xPos = (getWidth() - 100) / 2; 
int yPos = getHeight() / 2 - BORDER_SIZE * CAPS.length / 2; 
for(int i = 0; i < CAPS.length; i++) { 
    indicatorBorderPaint.setStrokeCap(CAPS[i]); 
    canvas.drawLine(xPos, yPos, xPos + 100, yPos,
    indicatorBorderPaint); 
    yPos += BORDER_SIZE * 2; 
} 
indicatorBorderPaint.setStrokeCap(Paint.Cap.BUTT); 

我们将得到类似以下图像的结果。如我们所见,当使用Paint.Cap.BUTT作为笔触线帽时,线条会稍微短一些:

同样,正如我们之前所看到的,我们在Paint对象上设置了AntiAlias标志为 true。如果启用了这个标志,所有支持它的操作都会平滑它们正在绘制的图形的角。让我们比较一下启用和禁用这个标志时的差异:

在左边,我们启用了AntiAlias标志的三条线,在右边,我们禁用了AntiAlias标志的同样三条线。我们只能在圆角上看到差异,但结果更平滑、更美观。并非所有的操作和基本图形都支持这个标志,并且可能会影响性能,因此在使用这个标志时需要小心。

我们还可以使用另一个名为drawLine(float[] points, int offset, int count, Paint paint)的方法或其简化形式drawLine(float[] points, Paint paint)来绘制多条线。

这个方法将为数组中的每组四个条目绘制一条线;这就像调用drawLine(array[index], array[index + 1], array[index + 2], array[index +3], paint),将索引增加4,并重复此过程直到数组末尾。

在第一个方法中,我们还可以指定要绘制的线条数量以及从数组内部哪个偏移量开始。

现在,让我们来完成我们之前的任务并绘制边框:

canvas.drawArc( 
       horMargin + BORDER_SIZE / 4, 
       verMargin + BORDER_SIZE / 4, 
       horMargin + circleSize - BORDER_SIZE /2, 
       verMargin + circleSize - BORDER_SIZE /2, 
       0, selectedAngle, true, indicatorBorderPaint); 

它只是用这个新的Paint绘制相同的圆弧。一个小细节:由于边框宽度从绘制笔划的位置中心向外增长,我们需要将圆弧的大小减少BORDER_SIZE / 2。让我们看看结果:

我们缺少内部边框,但这很正常,因为如果我们从之前的章节中记得,这部分存在是因为我们将其裁剪掉了,而不是因为drawArc以这种方式绘制。我们可以用一个小技巧来绘制这个内部边框。我们将绘制一个与裁剪区域大小相同的圆弧,但只绘制边框:

canvas.drawArc( 
       clipX - BORDER_SIZE / 4, 
       clipY - BORDER_SIZE / 4, 
       clipX + clipWidth + BORDER_SIZE / 2, 
       clipY + clipWidth + BORDER_SIZE / 2, 
       0, selectedAngle, true, indicatorBorderPaint); 

在这里,我们对边框大小应用了相同的逻辑,但反过来:我们绘制稍微大一点的圆弧,而不是小一点的。

让我们看看结果:

我们在这本书的一开始提到过,但重要的是不要在onDraw()方法中或基本上在任何每次绘制帧时都会被调用的方法中创建新的Paint对象。在某些情况下,我们可能觉得这样做很方便;然而,抵制诱惑,在类构造函数中创建对象或仅仅复用对象。我们可以更改Paint类实例属性并复用它来绘制不同的颜色或样式。

在 GitHub 仓库的Example13-Paint文件夹中找到这个例子的完整源代码。

我们将更多地玩转Paint对象及其属性,但现在,让我们开始绘制更多的基础图形。

绘制更多的基础图形

让我们从最简单的绘图操作开始:drawColor(int color)drawARGB(int a, int r, int g, int b)drawRGB(int r, int g, int b),以及drawPaint(Paint paint)。这些将填充整个canvas,考虑到裁剪区域。

现在让我们来看看drawRect()drawRoundRect()。这两个方法也非常简单,drawRect()将绘制一个矩形,而drawRoundRect()将绘制具有圆角边框的矩形。

我们可以直接使用这两种方法,指定坐标或使用Rect。让我们创建一个简单的例子,它将在每次绘制视图或调用其onDraw()方法时绘制一个新的随机圆角矩形。

首先,定义两个ArrayLists;一个将保存矩形的坐标,另一个将保存矩形的颜色信息:

private Paint paint; 
private ArrayList<Float> rects; 
private ArrayList<Integer> colors; 

我们还声明了一个Paint对象,用于绘制所有圆角矩形。现在让我们来初始化它们:

public PrimitiveDrawer(Context context, AttributeSet attributeSet) { 
    super(context, attributeSet); 

    rects = new ArrayList<>(); 
    colors = new ArrayList<>(); 

    paint = new Paint(); 
    paint.setStyle(Paint.Style.FILL); 
    paint.setAntiAlias(true); 
} 

我们将 paint 对象的样式设置为 Paint.Style.FILL 并设置了 AntiAlias 标志,但我们还没有设置颜色。我们将在绘制每个矩形之前这样做。

现在让我们实现我们的 onDraw() 方法。首先,我们将添加四个新的随机坐标。由于 Math.random() 返回从 01 的值,我们将其乘以当前视图的宽度和高度以获得适当的视图坐标。我们还生成了一个具有完全不透明度的新随机颜色:

@Override 
protected void onDraw(Canvas canvas) { 
    canvas.drawColor(BACKGROUND_COLOR); 

    int width = getWidth(); 
    int height = getHeight(); 

    for (int i = 0; i < 2; i++) { 
        rects.add((float) Math.random() * width); 
        rects.add((float) Math.random() * height); 
    } 
    colors.add(0xff000000 | (int) (0xffffff * Math.random())); 

    for (int i = 0; i < rects.size() / 4; i++) { 
        paint.setColor(colors.get(i)); 
        canvas.drawRoundRect( 
                rects.get(i * 4    ), 
                rects.get(i * 4 + 1), 
                rects.get(i * 4 + 2), 
                rects.get(i * 4 + 3), 
                40, 40, paint); 
    } 

    if (rects.size() < 400) postInvalidateDelayed(20); 
} 

然后,我们将遍历我们添加的所有随机点,并一次取 4 个,假设前两个将是矩形的起始 X 和 Y,后两个将是矩形的结束 X 和 Y 坐标。我们将圆角的角度硬编码为 40。我们可以调整这个值来改变圆角的大小。

我们已经介绍了颜色上的位运算。我们知道可以将颜色存储在 32 位整数值中,通常是以 ARGB 格式。这样每个分量就有 8 位。通过位运算,我们可以轻松地操作颜色。关于位运算的更多信息,请参考:

位运算

最后,如果我们数组中的矩形少于 100 个或坐标少于 400 个,我们会发送一个延迟 20 毫秒的 Invalidate 事件。这只是为了演示目的,并显示它正在添加和绘制更多的矩形。通过仅移除两个硬编码的 40 作为圆角的角度,drawRoundRect() 方法可以很容易地更改为 drawRect()

让我们看看结果:

要查看完整源代码,请检查 GitHub 仓库中的 Example14-Primitives-Rect 文件夹。

让我们继续讨论其他原语,例如 drawPointsdrawPoints(float[] points, Paint paint) 方法将简单地绘制一系列点。它将使用 paint 对象的笔触宽度和笔触 Cap。例如,一个快速示例,绘制几条随机线,并在每条线的开始和结束处都绘制一个点:

@Override 
protected void onDraw(Canvas canvas) { 
    canvas.drawColor(BACKGROUND_COLOR); 

    if (points == null) { 
        points = new float[POINTS * 2]; 
        for(int i = 0; i < POINTS; i++) { 
            points[i * 2    ] = (float) Math.random() * getWidth(); 
            points[i * 2 + 1] = (float) Math.random() * getHeight(); 
        } 
    } 

    paint.setColor(0xffa0a0a0); 
    paint.setStrokeWidth(4.f); 
    paint.setStrokeCap(Paint.Cap.BUTT); 
    canvas.drawLines(points, paint); 

    paint.setColor(0xffffffff); 
    paint.setStrokeWidth(10.f); 
    paint.setStrokeCap(Paint.Cap.ROUND); 
    canvas.drawPoints(points, paint); 
} 

让我们看看结果:

我们在这里的 onDraw() 方法中创建 points 数组,但这只做一次。

在 GitHub 仓库的 Example15-Primitives-Points 文件夹中查看这个例子的完整源代码。

在上一个示例的基础上,我们可以轻松引入 drawCircle 原语。不过,让我们稍微改一下代码;不是只生成随机值对,而是生成三个随机值。前两个将是圆的 XY 坐标,第三个是圆的半径。此外,为了清晰起见,我们删除了线条:

@Override 
protected void onDraw(Canvas canvas) { 
    canvas.drawColor(BACKGROUND_COLOR); 

    if (points == null) { 
        points = new float[POINTS * 3]; 
        for(int i = 0; i < POINTS; i++) { 
            points[i * 3    ] = (float) Math.random() * getWidth(); 
            points[i * 3 + 1] = (float) Math.random() * getHeight(); 
            points[i * 3 + 2] = (float) Math.random() * (getWidth()/4); 
        } 
    } 

    for (int i = 0; i < points.length / 3; i++) { 
        canvas.drawCircle( 
                points[i * 3    ], 
                points[i * 3 + 1], 
                points[i * 3 + 2], 
                paint); 
    } 
} 

我们还在类构造函数中初始化了 paint 对象:

paint = new Paint(); 
paint.setStyle(Paint.Style.FILL); 
paint.setAntiAlias(true); 
paint.setColor(0xffffffff); 

让我们看看结果:

在 GitHub 仓库的 Example16-Primitives-Circles 文件夹中查看这个例子的完整源代码。

要了解有关在Canvas上绘制所有基本图形、模式和方法的详细信息,请查看 Android 文档。

可以将 Path 视为包含基本图形、线条、曲线以及其他几何形状的容器,正如我们已经看到的,它们可以用作裁剪区域、绘制或在其上绘制文本。

首先,让我们修改之前的示例,并将所有圆转换为Path

@Override 
protected void onDraw(Canvas canvas) { 
    if (path == null) { 
        float[] points = new float[POINTS * 3]; 
        for(int i = 0; i < POINTS; i++) { 
            points[i * 3    ] = (float) Math.random() * getWidth(); 
            points[i * 3 + 1] = (float) Math.random() * getHeight(); 
            points[i * 3 + 2] = (float) Math.random() * (getWidth()/4); 
        } 

        path = new Path(); 

        for (int i = 0; i < points.length / 3; i++) { 
            path.addCircle( 
                    points[i * 3    ], 
                    points[i * 3 + 1], 
                    points[i * 3 + 2], 
                    Path.Direction.CW); 
        } 

        path.close(); 
    } 

我们不需要存储点,因此将其声明为局部变量。我们创建了一个Path对象。现在我们有了这个包含所有圆的Path,可以通过调用drawPath(Path path, Paint paint)方法绘制它,或者用作裁剪遮罩。

我们向项目中添加了一张图片,并将其作为背景图像绘制,但我们将应用由我们的Path定义的裁剪遮罩以增加趣味:

    canvas.save(); 

    if (!touching) canvas.clipPath(path); 
    if(background != null) { 
        backgroundTranformation.reset(); 
        float scale = ((float) getWidth()) / background.getWidth(); 
        backgroundTranformation.postScale(scale, scale); 
        canvas.drawBitmap(background, backgroundTranformation, null); 
    } 
    canvas.restore(); 
} 

让我们看看结果:

要查看此示例的完整源代码,请检查 GitHub 仓库中的Example17-Paths文件夹。

查看有关 Paths 的 Android 文档,我们可以看到有很多方法可以向Path添加基本图形,例如:

  • addCircle()

  • addRect()

  • addRoundRect()

  • addPath()

然而,我们不仅限于这些方法,我们还可以使用lineTomoveTo方法添加线条或位移我们 path 的下一个元素的起始位置。如果我们想使用相对坐标,Path类为我们提供了rLineTorMoveTo方法,这些方法假设给定的坐标相对于Path的最后一个点。

有关Path及其方法的更多信息,请查看 Android 文档网站。我们可以使用cubicToquadTo方法来实现。贝塞尔曲线由控制点组成,这些控制点控制平滑曲线的形状。让我们构建一个快速示例,通过在用户每次点击屏幕时添加控制点。

首先,让我们定义两个Paint对象,一个用于贝塞尔线,另一个用于绘制控制点以供参考:

pathPaint = new Paint(); 
pathPaint.setStyle(Paint.Style.STROKE); 
pathPaint.setAntiAlias(true); 
pathPaint.setColor(0xffffffff); 
pathPaint.setStrokeWidth(5.f); 

pointsPaint = new Paint(); 
pointsPaint.setStyle(Paint.Style.STROKE); 
pointsPaint.setAntiAlias(true); 
pointsPaint.setColor(0xffff0000); 
pointsPaint.setStrokeCap(Paint.Cap.ROUND); 
pointsPaint.setStrokeWidth(40.f); 

控制点将以红色的圆点绘制,而贝塞尔线将以较细的白色线条绘制。在我们初始化对象时,也定义一个空的Path和浮点数数组来存储点:

points = new ArrayList<>(); 
path = new Path(); 

现在,让我们重写onTouchEvent(),以添加用户点击屏幕的位置,并通过调用 invalidate 方法触发我们自定义视图的重绘。

@Override 
public boolean onTouchEvent(MotionEvent event) { 
    if (event.getAction() == MotionEvent.ACTION_DOWN) { 
        points.add(event.getX()); 
        points.add(event.getY()); 

        invalidate(); 
    } 

    return super.onTouchEvent(event); 
} 

在我们的onDraw()方法中,首先检查是否已经有三个点。如果是这样,让我们向Path添加一个三次贝塞尔曲线:

while(points.size() - currentIndex >= 6) { 
    float x1 = points.get(currentIndex); 
    float y1 = points.get(currentIndex + 1); 

    float x2 = points.get(currentIndex + 2); 
    float y2 = points.get(currentIndex + 3); 

    float x3 = points.get(currentIndex + 4); 
    float y3 = points.get(currentIndex + 5); 

    if (currentIndex == 0) path.moveTo(x1, y1); 
    path.cubicTo(x1, y1, x2, y2, x3, y3); 
    currentIndex += 6; 
} 

currentIndex保持已插入到Path的点数组最后一个索引。

现在,让我们绘制Path和点:

canvas.drawColor(BACKGROUND_COLOR); 
canvas.drawPath(path, pathPaint); 

for (int i = 0; i < points.size() / 2; i++) { 
    float x = points.get(i * 2    ); 
    float y = points.get(i * 2 + 1); 
    canvas.drawPoint(x, y, pointsPaint); 
} 

让我们看看结果:

在 GitHub 仓库的Example18-Paths文件夹中查看此示例的完整源代码。

绘制文本

Canvas操作的角度来看,文本可以被认为是一个基本元素,但我们将它单独放在这里,因为它非常重要。我们没有从最简单的例子开始,因为我们刚刚介绍了路径,我们将继续上一个例子,在Path顶部绘制文本。要绘制文本,我们将重用贝塞尔曲线的Paint对象,但我们将添加一些文本参数:

pathPaint.setTextSize(50.f); 
pathPaint.setTextAlign(Paint.Align.CENTER); 

这设置了文本的大小,并将文本对齐到Path的中心,这样每次我们添加新点时,文本位置都会适应保持居中。要绘制文本,我们只需调用drawTextOnPath()方法:

canvas.drawTextOnPath("Building Android UIs with Custom Views", path, 0, 0, pathPaint); 

这是我们代码中一个非常快速的增加,但如果我们执行我们的应用程序,我们可以看到文本覆盖在Path线条上的结果:

请记住,我们正在绘制之前绘制过的相同内容,但我们可以自由地使用Path作为文本的指导。无需绘制它或绘制控制点。

在 GitHub 仓库的Example19-Text folder中查看这个例子的完整源代码。

我们已经开始在路径上绘制文本,因为我们的例子几乎已经构建完成。然而,还有更简单的方法来绘制文本。例如,我们可以通过调用canvas.drawText(String text, float x, float y, Paint paint)canvas.drawText(char[] text, float x, float y, Paint paint)在屏幕上的特定位置绘制文本。

这些方法只会完成它们的工作,但它们不会检查文本是否适合可用空间,而且绝对不会拆分和换行文本。要做到这一点,我们必须自己动手。Paint类为我们提供了测量文本和计算文本边界的方法。例如,我们创建了一个小助手方法,它返回String的宽度和高度:

private static final float[] getTextSize(String str, Paint paint) { 
    float[] out = new float[2]; 
    Rect boundaries = new Rect(); 
    paint.getTextBounds(str, 0, str.length(), boundaries); 

    out[0] = paint.measureText(str); 
    out[1] = boundaries.height(); 
    return out; 
} 

我们使用了文本边界来获取文本高度,但我们使用了measureText()方法来获取文本宽度。这两种方法在计算大小上有一些差异。尽管目前 Android 的官方文档网站上没有正确记录这一点,但在 Stack Overflow 上有一个关于这个问题的旧讨论:

stackoverflow.com/questions/7549182/android-paint-measuretext-vs-gettextbounds

然而,我们不应该实现自己的文本拆分方法。如果我们想要绘制大段文本,并且我们知道它可能需要拆分和换行,我们可以使用StaticLayout类。在这个例子中,我们将创建一个宽度为视图宽度一半的StaticLayout

我们可以在我们的onLayout()方法中实现它:

@Override 
protected void onLayout(boolean changed, int left, int top, int right, int bottom) { 
    super.onLayout(changed, left, top, right, bottom); 

    // create a layout of half the width of the View 
    if (layout == null) { 
        layout = new StaticLayout( 
                LONG_TEXT, 
                0, 
                LONG_TEXT.length(), 
                paint, 
                (right - left) / 2, 
                Layout.Alignment.ALIGN_NORMAL, 
                1.f, 
                1.f, 
                true); 
    } 
} 

在我们的onDraw()方法中,我们将它绘制在屏幕中心。我们知道,布局宽度是视图宽度的一半;我们知道我们需要将其位移到宽度的四分之一处。

@Override 
protected void onDraw(Canvas canvas) { 
    canvas.drawColor(BACKGROUND_COLOR); 

    canvas.save(); 
    // center the layout on the View 
    canvas.translate(canvas.getWidth()/4, 0); 
    layout.draw(canvas); 
    canvas.restore(); 
} 

这是结果:

在 GitHub 仓库的Example20-Text文件夹中查看这个示例的完整源代码。

变换和操作

在我们的自定义视图上,我们已经使用了一些canvas变换,但让我们重新审视我们可以使用的Canvas操作。首先,让我们看看如何连接这些变换。一旦我们使用了变换,我们使用的任何其他变换都会被连接或应用在我们之前的操作之上。为了避免这种行为,我们必须调用我们之前也使用过的save()restore()方法。为了了解变换是如何层层叠加的,让我们创建一个简单的示例。

首先,在我们构造函数中创建一个paint对象:

public PrimitiveDrawer(Context context, AttributeSet attributeSet) { 
    super(context, attributeSet); 

    paint = new Paint(); 
    paint.setStyle(Paint.Style.STROKE); 
    paint.setAntiAlias(true); 
    paint.setColor(0xffffffff); 
} 

现在,让我们在onLayout()方法中根据屏幕大小计算矩形尺寸:

@Override 
 protected void onLayout(boolean changed, int left, int top, int right,
 int bottom) { 
     super.onLayout(changed, left, top, right, bottom); 

     int smallerDimension = (right - left); 
     if (bottom - top < smallerDimension) smallerDimension = bottom -
     top; 

     rectSize = smallerDimension / 10; 
     timeStart = System.currentTimeMillis(); 
} 

我们还存储了开始时间,稍后我们将使用它进行快速简单的动画。现在,我们准备实现onDraw()方法:

@Override 
protected void onDraw(Canvas canvas) { 
    float angle = (System.currentTimeMillis() - timeStart) / 100.f; 

    canvas.drawColor(BACKGROUND_COLOR); 

    canvas.save(); 
    canvas.translate(canvas.getWidth() / 2, canvas.getHeight() / 2); 

    for (int i = 0; i < 15; i++) { 
        canvas.rotate(angle); 
        canvas.drawRect(-rectSize / 2, -rectSize / 2, rectSize / 2,
        rectSize / 2, paint); 
        canvas.scale(1.2f, 1.2f); 
    } 

    canvas.restore(); 
    invalidate(); 
} 

我们首先根据自开始以来经过的时间计算了angle。动画应该总是基于时间,而不是基于绘制的帧数。

然后,我们绘制背景,通过调用canvas.save()保存canvas状态,并进行平移到屏幕中心。我们将所有的变换和绘制都基于中心,而不是左上角。

在这个示例中,我们将绘制 15 个矩形,每个矩形都会逐渐旋转和缩放。由于变换是层层叠加的,因此在一个简单的for()循环中很容易实现。重要的是要从-rectSize / 2绘制到rectSize / 2,而不是从0rectSize;否则,它将从一个角度旋转。

修改我们绘制矩形的代码行,改为canvas.drawRect(0, 0, rectSize, rectSize, paint),看看会发生什么。

然而,这种方法有一个替代方案:我们可以在变换中使用枢轴点。rotate()scale()方法都支持两个额外的float参数,它们是枢轴点的坐标。如果我们查看scale(float sx, float sy, float px, float py)的源代码实现,我们可以看到它只是应用了一个平移,调用了简单的缩放方法,然后应用了相反的平移:

public final void scale(float sx, float sy, float px, float py) { 
    translate(px, py); 
    scale(sx, sy);
    translate(-px, -py); 
} 

使用这种方法,我们可以以另一种方式实现onDraw()方法:

@Override 
protected void onDraw(Canvas canvas) { 
    float angle = (System.currentTimeMillis() - timeStart) / 100.f; 

    canvas.drawColor(BACKGROUND_COLOR); 

    canvas.save(); 
    canvas.translate(canvas.getWidth() / 2, 
                     canvas.getHeight() / 2); 

    for (int i = 0; i < 15; i++) { 
        canvas.rotate(angle, rectSize / 2, rectSize / 2); 
        canvas.drawRect(0, 0, rectSize, rectSize, paint); 
        canvas.scale(1.2f, 1.2f, rectSize / 2, rectSize / 2); 
    } 

    canvas.restore(); 
    invalidate(); 
} 

查看以下截图,了解矩形的连接方式:

此外,这个完整示例的源代码可以在 GitHub 仓库的Example21-Transformations文件夹中找到。

我们已经了解了一些关于矩阵的基本操作,比如scale()rotate()translate(),但canvas为我们提供了更多附加方法:

  • skew:这应用一个斜切变换。

  • setMatrix:这让我们计算一个变换矩阵,并直接将其设置到我们的canvas中。

  • concat:这类似于前面的情况。我们可以将任何矩阵与当前矩阵进行拼接。

将它们全部组合在一起

到目前为止,我们已经看到了许多不同的绘图原语、剪辑操作和矩阵变换,但最有趣的部分是我们将它们全部组合在一起的时候。为了构建出色的自定义视图,我们必须使用许多不同类型的操作和变换。

然而,拥有如此多的操作是一个双刃剑。在向自定义视图添加这种复杂性时,我们必须小心,因为很容易损害性能。我们应该检查是否应用了过多的或不必要的剪辑操作,或者是否没有足够优化,或者没有最大化剪辑和变换操作的重用。在这种情况下,我们甚至可以使用canvas对象的quickReject()方法快速丢弃将落在剪辑区域外的区域。

同时,我们需要跟踪我们对canvas执行的所有save()restore()。执行额外的restore()方法,不仅意味着我们的代码存在问题,实际上它是一个错误。如果我们需要改变到不同的先前保存的状态,我们可以使用restoreToCount()方法,并结合保存状态编号的调用来保存状态。

正如我们之前提到的,并在后续章节中会再次提到,避免在onDraw()方法中分配内存或创建对象的新实例;特别是如果你认为需要在onDraw()内部创建一个新的paint对象实例时,请记住这一点。重用paint对象或在类构造函数中初始化它们。

总结

在本章中,我们了解了如何绘制更复杂的图形原语,变换它们,并在绘制自定义视图时使用剪辑操作。大多数情况下,这些原语本身并不能为我们提供太多价值,但我们还看到了许多快速示例,展示了如何将它们组合在一起创建有用的东西。我们没有涵盖所有可能的方法、操作或变换,因为这将包含大量信息并且可能不实用;它可能会像是阅读一本语言字典。要了解所有可能的方法和绘图原语,请持续查看开发者的 Android 文档,并关注每个新版本的 Android 的发行说明,以了解新增内容。

在下一章中,我们将了解如何使用 OpenGL ES 为自定义视图添加 3D 渲染。

第五章:引入 3D 自定义视图

在前面的章节中,我们已经了解了如何使用安卓 2D 图形库实现自定义视图。这是我们最常用的方法,但在某些情况下,由于额外的渲染特性或自定义视图的需求,我们可能需要更多的性能。在这些情况下,我们可能会使用嵌入式系统 OpenGLOpenGL ES),并在我们的视图中启用 3D 渲染操作。

在本章中,我们将了解如何在自定义视图中使用 OpenGL ES,并展示一个实际示例,说明我们如何构建一个。更详细地说,我们将涵盖以下主题:

  • OpenGL ES 简介

  • 绘制几何体

  • 加载外部几何体

OpenGL ES 简介

安卓支持 OpenGL ES 进行 3D 渲染。OpenGL ES 是桌面OpenGL API实现的一个子集。开放图形库OpenGL)本身是一个非常流行的跨平台 API,用于渲染 2D 和 3D 图形。

使用 OpenGL ES 来渲染我们的自定义视图比标准的安卓画布绘制原语要稍微复杂一些,正如我们将在本章中看到的,它需要与常识一起使用,并不总是最佳方法。

有关 OpenGL ES 的任何额外信息,请参考 Khronos 集团的官方文档:

Khronos 集团的 OpenGL ES 官方文档.

在安卓中开始使用 OpenGL ES

创建一个支持 3D 的自定义视图非常简单。我们可以通过简单地扩展GLSurfaceView而不是仅从View类扩展来实现。复杂性在于渲染部分,但让我们一步一步来。首先,我们将创建一个名为GLDrawer的类并将其添加到我们的项目中:

package com.packt.rrafols.draw; 

import android.content.Context; 
import android.opengl.GLSurfaceView; 
import android.util.AttributeSet; 

public class GLDrawer extends GLSurfaceView { 
    private GLRenderer glRenderer; 

    public GLDrawer(Context context, AttributeSet attributeSet) { 
        super(context, attributeSet); 
    } 
} 

与我们之前的示例一样,我们使用AttributeSet创建了构造函数,因此我们可以从 XML 布局文件中充气并设置参数(如果需要的话)。

我们可能会认为 OpenGL ES 只用于全屏游戏,但它也可以用于非全屏视图,甚至可以在ViewGroupsScrollView内部使用。

为了观察其行为,让我们将其添加到两个TextView之间的layout文件中:

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

    android:id="@+id/activity_main" 
    android:layout_width="match_parent" 
    android:layout_height="match_parent" 
    android:orientation="vertical" 
    android:padding="@dimen/activity_vertical_margin" 
    tools:context="com.packt.rrafols.draw.MainActivity"> 

<TextView 
        android:layout_width="match_parent" 
        android:layout_height="100dp" 
        android:background="@android:color/background_light" 
        android:gravity="center_vertical|center_horizontal" 
        android:text="@string/filler_text"/> 

<com.packt.rrafols.draw.GLDrawer 
        android:layout_width="match_parent" 
        android:layout_height="100dp"/> 

<TextView 
        android:layout_width="match_parent" 
        android:layout_height="100dp" 
        android:background="@android:color/background_light" 
        android:gravity="center_vertical|center_horizontal" 
        android:text="@string/filler_text"/> 
</LinearLayout> 

在我们的GLDrawer类可以工作之前,我们需要进行一个额外的步骤。我们必须创建一个GLSurfaceView.Renderer对象来处理所有的渲染工作,并通过使用setRenderer()方法将其设置到视图中。当我们设置这个渲染器时,GLSurfaceView将额外创建一个新线程来管理视图的绘制周期。让我们在GLDrawer类文件的末尾添加一个GLRenderer类:

class GLRenderer implements GLSurfaceView.Renderer { 
    @Override 
    public void onSurfaceCreated(GL10 gl, EGLConfig config) { 

    } 

    @Override 
    public void onSurfaceChanged(GL10 gl, int width, int height) { 

    } 

    @Override 
    public void onDrawFrame(GL10 gl) { 
        gl.glClearColor(1.f, 0.f, 0.f, 1.f); 
        gl.glClear(GL10.GL_COLOR_BUFFER_BIT); 
    } 
} 

glClearColor()方法告诉 OpenGL 我们希望从屏幕上清除哪种颜色。我们设置了四个分量:红色、绿色、蓝色和 alpha,以浮点格式表示,范围从01glClear()是实际清除屏幕的方法。由于 OpenGL 还可以清除其他几个缓冲区,如果我们设置了GL_COLOR_BUFFER_BIT标志,它才会清除屏幕。现在我们已经介绍了一些 OpenGL 函数,让我们创建一个GLRenderer实例变量,并在类构造函数中初始化它:

private GLRenderer glRenderer;
public GLDrawer(Context context, AttributeSet attributeSet) { 
    super(context, attributeSet); 
    glRenderer = new GLRenderer()
    setRenderer(glRenderer);
} 

实现一个GLSurfaceView.Renderer类时,我们必须重写以下三个方法或回调:

  • onSurfaceCreated(): 每当 Android 需要创建 OpenGL 上下文时,都会调用此方法——例如,在首次创建渲染线程时,或者每次 OpenGL 上下文丢失时。当应用程序进入后台时,上下文可能会丢失。这个回调是放置所有依赖于 OpenGL 上下文的初始化代码的理想方法。

  • onSurfaceChanged(): 当视图大小发生变化时,将调用此方法。在第一次创建表面时也会被调用。

  • onDrawFrame(): 此方法是负责实际绘制的内容,并且每次需要绘制视图时都会被调用。

在我们的示例中,我们留下了onSurfaceCreated()onSurfaceChanged()方法为空,因为此时我们只关注绘制实心背景以检查是否一切正常工作,而且我们暂时还不需要视图的大小。

如果我们运行这个示例,我们将看到两个TextView和带有红色背景的自定义视图:

如果我们在onDrawFrame()方法中设置断点或打印日志,我们将看到视图在不断地重绘。这种行为与普通视图不同,因为渲染线程会不断调用onDrawFrame()方法。通过调用设置渲染器对象后的setRender()方法,可以修改这种行为。如果我们在此之前调用它,应用程序将会崩溃。有两种渲染模式:

  • setRenderMode(RENDERMODE_CONTINUOUSLY): 这是默认行为。渲染器将不断被调用以渲染视图。

  • setRenderMode(RENDERMODE_WHEN_DIRTY): 可以设置此选项以避免视图的连续重绘。我们不需要调用 invalidate,而必须调用requestRender来请求视图的新渲染。

绘制基本几何图形

我们已经初始化了视图并绘制了一个实心的红色背景。接下来让我们绘制一些更有趣的内容。在以下示例中,我们将关注 OpenGL ES 2.0,因为它自 Android 2.2 或 API 级别 8 起就已经可用,而且解释如何在 OpenGL ES 1.1 中实现它并没有太大意义。然而,如果你想了解更多,GitHub 上有些将旧的 NeHe OpenGL ES 教程移植到 Android 的项目:

github.com/nea/nehe-android-ports

OpenGLES 1.1 和 OpenGL ES 2.0 的代码是不兼容的,因为 OpenGL ES 1.1 的代码基于固定功能管线,你需要指定几何体、灯光等,而 OpenGL ES 2.0 基于可编程管线,由顶点和片段着色器处理。

首先,由于我们需要 OpenGL ES 2.0,应该在清单文件中添加一个uses-feature配置行,这样 Google Play 就不会将应用程序展示给不兼容的设备:

<application> 
    .... 
<uses-feature android:glEsVersion="0x00020000" android:required="true" /> 
    ... 
</application> 

如果我们使用 OpenGL ES3.0 的特定 API,我们应该将要求更改为android:glEsVersion="0x00030000",以便 Google Play 相应地进行筛选。

完成这一步后,我们可以开始绘制更多形状和几何体。但在设置渲染器之前,我们应该将渲染器上下文设置为2,以便创建一个 OpenGL ES 2.0 上下文。我们可以通过修改GLDrawer类的构造函数轻松实现这一点:

public GLDrawer(Context context, AttributeSet attributeSet) { 
    super(context, attributeSet); 
    setEGLContextClientVersion(2);
    glRenderer = new GLRenderer(); 
    setRenderer(glRenderer); 
} 

现在我们一步一步来学习如何在屏幕上画一个矩形。如果你熟悉 OpenGL ES 1.1 但不熟悉 OpenGL ES 2.0,你会发现这里需要多做一点工作,但最终,我们将从 OpenGL ES 2.0 的额外灵活性和强大功能中受益。

我们将从定义一个以位置0, 0, 0为中心的矩形或四边形的坐标数组开始:

private float quadCoords[] = { 
    -1.f, -1.f, 0.0f, 
    -1.f,  1.f, 0.0f, 
     1.f,  1.f, 0.0f, 
     1.f, -1.f, 0.0f 
 }; 

我们要画三角形,因此需要定义它们的顶点索引:

private short[] index = { 
    0, 1, 2, 
    0, 2, 3 
}; 

要理解这些索引背后的逻辑,如何将它们映射到我们之前定义的顶点索引,以及如何使用两个三角形来绘制一个四边形,请看以下图表:

如果我们画一个顶点为012的三角形,再画一个顶点为023的三角形,最终我们会得到一个四边形。

在使用 OpenGL ES 时,我们需要使用Buffer或其子类来提供数据,因此让我们将这些数组转换为Buffer

ByteBuffer vbb = ByteBuffer.allocateDirect(quadCoords.length * (Float.SIZE / 8)); 
vbb.order(ByteOrder.nativeOrder()); 

vertexBuffer = vbb.asFloatBuffer(); 
vertexBuffer.put(quadCoords); 
vertexBuffer.position(0); 

首先,我们需要为Buffer分配所需的空间。由于我们知道数组的大小,这会非常简单:只需将其乘以浮点数的大小(以字节为单位)。一个浮点数正好是四个字节,但我们也可以通过获取位数(使用Float.SIZE)并除以8来计算。在 Java 8 中,有一个名为Float.BYTES的新常量,它正好返回以字节为单位的大小。

我们需要指出,我们放入数据的Buffer将具有平台的本地字节序。我们可以通过在Buffer上调用order()方法,并以ByteOrder.nativeOrder()作为参数来实现这一点。完成这一步后,我们可以通过调用Buffer.asFloatBuffer()将其转换为浮点缓冲区,并设置数据。最后,我们将Buffer的位置重置为开始位置,即设置为0

我们必须为顶点以及索引执行这个过程。由于索引作为短整数存储,我们在转换缓冲区以及计算大小时需要考虑这一点。

ByteBuffer ibb = ByteBuffer.allocateDirect(index.length * (Short.SIZE / 8)); 
ibb.order(ByteOrder.nativeOrder()); 

indexBuffer = ibb.asShortBuffer(); 
indexBuffer.put(index); 
indexBuffer.position(0); 

如前所述,OpenGL ES 2.0 渲染管线由顶点和片段shader处理。让我们创建一个辅助方法来加载和编译shader代码:

// Source: 
// https://developer.android.com/training/graphics/opengl/draw.html 
public static int loadShader(int type, String shaderCode){ 

    // create a vertex shader type (GLES20.GL_VERTEX_SHADER) 
    // or a fragment shader type (GLES20.GL_FRAGMENT_SHADER) 
    int shader = GLES20.glCreateShader(type); 

    // add the source code to the shader and compile it 
    GLES20.glShaderSource(shader, shaderCode); 
    GLES20.glCompileShader(shader); 

    return shader; 
} 

使用这个新方法,我们可以加载顶点和片段shaders

private void initShaders() { 
    int vertexShader = loadShader(GLES20.GL_VERTEX_SHADER, vertexShaderCode); 
    int fragmentShader = loadShader(GLES20.GL_FRAGMENT_SHADER, fragmentShaderCode); 

    shaderProgram = GLES20.glCreateProgram(); 
    GLES20.glAttachShader(shaderProgram, vertexShader); 
    GLES20.glAttachShader(shaderProgram, fragmentShader); 
    GLES20.glLinkProgram(shaderProgram); 
} 

目前,让我们使用来自 Android 开发者 OpenGL 培训网站的默认shaders

vertexShader如下所示:

// Source: 
// https://developer.android.com/training/graphics/opengl/draw.html 
private final String vertexShaderCode = 
        // This matrix member variable provides a hook to manipulate 
        // the coordinates of the objects that use this vertex shader 
"uniform mat4 uMVPMatrix;" + 
"attribute vec4 vPosition;" + 
"void main() {" + 
        // The matrix must be included as a modifier of gl_Position. 
        // Note that the uMVPMatrix factor *must be first* in order 
        // for the matrix multiplication product to be correct. 
"  gl_Position = uMVPMatrix * vPosition;" + 
"}"; 

fragmentShader如下所示:

private final String fragmentShaderCode = 
"precision mediump float;" + 
"uniform vec4 vColor;" + 
"void main() {" + 
"  gl_FragColor = vColor;" + 
"}"; 

在我们的vertexShader中添加了矩阵乘法,因此我们可以通过更新uMVPMatrix来修改顶点的位置。让我们添加一个投影和一些变换,以便实现基本的渲染。

我们不应该忘记onSurfaceChanged()回调;让我们使用它来设置我们的投影矩阵,并定义相机的裁剪平面,考虑到屏幕的宽度和高度以保持其长宽比:

@Override 
public void onSurfaceChanged(GL10 unused, int width, int height) { 
    GLES20.glViewport(0, 0, width, height); 

    float ratio = (float) width / height; 
    Matrix.frustumM(mProjectionMatrix, 0, -ratio * 2, ratio * 2, -2, 2,
    3, 7); 
} 

让我们通过使用Matrix.setLookAtM()计算视图矩阵,并将其与我们刚刚在mProjectionMatrix上计算出的投影矩阵相乘:

@Override 
public void onDrawFrame(GL10 unused) { 

    ... 

    Matrix.multiplyMM(mMVPMatrix, 0, mProjectionMatrix, 0, mViewMatrix,
    0); 

    int mMVPMatrixHandle = GLES20.glGetUniformLocation(shaderProgram,
    "uMVPMatrix"); 
    GLES20.glUniformMatrix4fv(mMVPMatrixHandle, 1, false, mMVPMatrix,
    0); 

    ... 

} 

在前面的代码中,我们还看到了如何更新一个可以从shader中读取的变量。为此,我们首先需要获取统一变量的句柄。通过使用GLES20.glGetUniformLocation(shaderProgram, "uMVPMatrix")我们可以得到uMVPMatrix统一变量的句柄,并在GLES20.glUniformMatrix4fv调用中使用这个句柄,我们可以将刚刚计算出的矩阵设置到它上面。如果我们检查shader的代码,可以看到我们定义了uMVPMatrix为统一变量:

uniform mat4 uMVPMatrix; 

既然我们知道如何设置一个统一变量,那么对于颜色我们也做同样的处理。在片段shader中,我们将vColor也设置为统一变量,因此我们可以使用同样的方法来设置它:

float color[] = { 0.2f, 0.2f, 0.9f, 1.0f }; 

... 

int colorHandle = GLES20.glGetUniformLocation(shaderProgram, "vColor"); 
GLES20.glUniform4fv(colorHandle, 1, color, 0); 

使用同样的机制,但将glGetUniformLocation更改为glGetAttribLocation,我们也可以设置顶点坐标:

int positionHandle = GLES20.glGetAttribLocation(shaderProgram, "vPosition"); 

GLES20.glVertexAttribPointer(positionHandle, 3, 
        GLES20.GL_FLOAT, false, 
        3 * 4, vertexBuffer); 

我们已经准备好将其绘制到屏幕上;我们只需要启用顶点属性数组,因为我们已经使用glVertexAttribPointer()调用和glDrawElements()只绘制启用的数组:

GLES20.glEnableVertexAttribArray(positionHandle); 

GLES20.glDrawElements( 
       GLES20.GL_TRIANGLES, index.length, 
       GLES20.GL_UNSIGNED_SHORT, indexBuffer); 

GLES20.glDisableVertexAttribArray(positionHandle); 

在 OpenGL 上绘制几何体的方法有很多,但我们使用了指向之前创建的面索引缓冲区的glDrawElements()调用。这里我们使用了GL_TRIANGLES图元,但还有许多其他的 OpenGL 图元可以使用。更多信息请查看 Khronos 官方文档关于glDrawElements()的部分:

www.khronos.org/registry/OpenGL-Refpages/gl4/html/glDrawElements.xhtml

同时,作为良好的实践,并在绘制后恢复 OpenGL 机器状态,我们禁用了顶点属性数组。

如果我们执行这段代码,我们将得到以下结果——虽然还不是很有用,但这是一个开始!

在 GitHub 仓库中查看Example23-GLSurfaceView以获取完整的示例源代码。

绘制几何体

到目前为止,我们已经了解了如何设置 OpenGL 渲染器并绘制一些非常基础的几何图形。但是,正如你所想象的,我们可以利用 OpenGL 做更多的事情。在本节中,我们将了解如何进行一些更复杂的操作以及如何加载使用外部工具定义的几何图形。有时,使用代码定义几何图形可能很有用,但大多数时候,尤其是如果几何图形非常复杂,它将通过 3D 建模工具设计和创建。知道如何导入这些几何图形对我们项目肯定非常有帮助。

添加体积

在上一个例子中,我们已经了解了如何用单一颜色绘制四边形,但如果是每个顶点都有完全不同的颜色呢?这个过程与我们已经做的不会有很大不同,但让我们看看如何实现它。

首先,让我们改变颜色数组,使其包含四个顶点的颜色:

float color[] = { 
        1.0f, 0.2f, 0.2f, 1.0f, 
        0.2f, 1.0f, 0.2f, 1.0f, 
        0.2f, 0.2f, 1.0f, 1.0f, 
        1.0f, 1.0f, 1.0f, 1.0f, 
}; 

现在,在我们的initBuffers()方法中,我们来初始化一个额外的Buffer来存储颜色:

private FloatBuffer colorBuffer; 

... 

ByteBuffer cbb = ByteBuffer.allocateDirect(color.length * (Float.SIZE / 8)); 
cbb.order(ByteOrder.nativeOrder()); 

colorBuffer = cbb.asFloatBuffer(); 
colorBuffer.put(color); 
colorBuffer.position(0); 

我们还必须更新我们的shaders以考虑颜色参数。首先,在我们的vertexShader中,我们必须创建一个新的属性,我们将其称为aColor,以保存每个顶点的颜色:

private final String vertexShaderCode = 
"uniform mat4 uMVPMatrix;" + 
"attribute vec4 vPosition;" + 
"attribute vec4 aColor;" + 
"varying vec4 vColor;" + 
"void main() {" + 
"  gl_Position = uMVPMatrix * vPosition;" + 
"  vColor = aColor;" + 
"}"; 

然后,我们定义一个可变的vColor变量,该变量将传递给fragmentShader,而fragmentShader将计算每个片段的值。让我们看看fragmentShader上的变化:

private final String fragmentShaderCode = 
"precision mediump float;" + 
"varying vec4 vColor;" + 
"void main() {" + 
"  gl_FragColor = vColor;" + 
"}"; 

我们唯一改变的是vColor的声明;它不再是统一变量,现在是一个varying变量。

就像我们对顶点和面索引所做的那样,我们必须将颜色数据设置到shader中:

int colorHandle = GLES20.glGetAttribLocation(shaderProgram, "aColor"); 
GLES20.glVertexAttribPointer(colorHandle, 4, 
        GLES20.GL_FLOAT, false, 
        4 * 4, colorBuffer); 

在绘制之前,我们必须启用和禁用顶点数组。如果颜色数组没有被启用,我们将得到一个黑色的正方形,因为glDrawElements()将无法获取颜色信息;

GLES20.glEnableVertexAttribArray(colorHandle); 
GLES20.glEnableVertexAttribArray(positionHandle); 
GLES20.glDrawElements( 
        GLES20.GL_TRIANGLES, index.length, 
        GLES20.GL_UNSIGNED_SHORT, indexBuffer); 

GLES20.glDisableVertexAttribArray(positionHandle); 
GLES20.glDisableVertexAttribArray(colorHandle); 

如果我们运行这个例子,我们会看到与上一个例子相似的效果,但我们可以看到颜色是如何在顶点之间插值的:

图片

既然我们知道如何插值颜色,让我们在几何体中增加一些深度。到目前为止,我们所绘制的所有内容都非常平坦,所以让我们将四边形转换为立方体。这非常简单。首先定义顶点和新的面索引:

private float quadCoords[] = { 
       -1.f, -1.f, -1.0f, 
       -1.f,  1.f, -1.0f, 
        1.f,  1.f, -1.0f, 
        1.f, -1.f, -1.0f, 

       -1.f, -1.f,  1.0f, 
       -1.f,  1.f,  1.0f, 
        1.f,  1.f,  1.0f, 
        1.f, -1.f,  1.0f 
}; 

我们复制了之前相同的四个顶点,但是位移了Z坐标,这将给立方体增加体积。

现在,我们必须创建新的面索引。立方体有六个面,或者说四边形,可以用十二个三角形来复制:

private short[] index = { 
        0, 1, 2,        // front 
        0, 2, 3,        // front 
        4, 5, 6,        // back 
        4, 6, 7,        // back 
        0, 4, 7,        // top 
        0, 3, 7,        // top 
        1, 5, 6,        // bottom 
        1, 2, 6,        // bottom 
        0, 4, 5,        // left 
        0, 1, 5,        // left 
        3, 7, 6,        // right 
        3, 2, 6         // right 
}; 

同时为新的四个顶点添加新颜色:

float color[] = { 
        1.0f, 0.2f, 0.2f, 1.0f, 
        0.2f, 1.0f, 0.2f, 1.0f, 
        0.2f, 0.2f, 1.0f, 1.0f, 
        1.0f, 1.0f, 1.0f, 1.0f, 

        1.0f, 1.0f, 0.2f, 1.0f, 
        0.2f, 1.0f, 1.0f, 1.0f, 
        1.0f, 0.2f, 1.0f, 1.0f, 
        0.2f, 0.2f, 0.2f, 1.0f 
}; 

如果我们按原样执行这个例子,我们会得到一个类似以下截图的奇怪结果:

图片

让我们给mMVPMatrix矩阵添加一个旋转变换,看看会发生什么。

我们必须定义一个私有变量来保存旋转角度,并将旋转应用到mMVPMatrix中:

private float angle = 0.f; 
... 
Matrix.setLookAtM(mViewMatrix, 0, 
        0, 0, -4, 
        0f, 0f, 0f, 
        0f, 1.0f, 0.0f); 

Matrix.multiplyMM(mMVPMatrix, 0, mProjectionMatrix, 0, mViewMatrix, 0); Matrix.rotateM(mMVPMatrix, 0, angle, 1.f, 1.f, 1.f);

在这个例子中,为了观察正在发生的事情,我们将旋转应用到三个轴:xyz。我们还稍微将相机从上一个示例中的位置移开,因为如果我们不这样做,现在可能会有一些剪辑。

为了定义我们必须旋转的角度,我们将使用一个 Android 定时器:

private long startTime; 
... 
@Override 
public void onSurfaceCreated(GL10 unused, EGLConfig config) { 
    initBuffers(); 
    initShaders(); 
    startTime = SystemClock.elapsedRealtime();
} 

我们在startTime变量上存储开始时间,在我们的onDrawFrame()方法中,我们根据自这一刻起经过的时间计算角度:

angle = ((float) SystemClock.elapsedRealtime() - startTime) * 0.02f; 

在这里,我们只是将其乘以0.02f以限制旋转速度,否则它会太快。这样做,动画速度将不受渲染帧率或 CPU 速度的影响,在所有设备上都是相同的。现在,如果我们运行这段代码,我们将看到我们遇到的问题的来源:

问题在于,OpenGL 在绘制所有三角形时没有检查像素的 z 坐标,因此可能会出现一些重叠和过度绘制,正如我们从前面的屏幕截图中轻易看到的那样。幸运的是,这个问题很容易解决。OpenGL 有一个状态,我们可以用它来启用和禁用深度(z)测试:

GLES20.glEnable(GLES20.GL_DEPTH_TEST);
GLES20.glEnableVertexAttribArray(colorHandle); 
GLES20.glEnableVertexAttribArray(positionHandle); 
GLES20.glDrawElements( 
        GLES20.GL_TRIANGLES, index.length, 
        GLES20.GL_UNSIGNED_SHORT, indexBuffer); 

GLES20.glDisableVertexAttribArray(positionHandle); 
GLES20.glDisableVertexAttribArray(colorHandle); GLES20.glDisable(GLES20.GL_DEPTH_TEST);

与上一个示例一样,在绘制之后,我们禁用我们启用的状态,以避免为任何其他绘图操作留下未知的 OpenGL 状态。如果我们运行这段代码,我们将看到差异:

在 GitHub 仓库中查看Example24-GLDrawing以获取完整的示例源代码。

添加纹理

让我们继续做更有趣的事情!我们已经看到了如何为每个顶点添加颜色,但现在让我们看看如果我们想为 3D 对象添加一些纹理,我们需要做哪些改变。

首先,让我们将颜色数组替换为纹理坐标数组。我们将纹理坐标0映射到纹理的起点,在两个轴上都是如此,将1映射到纹理的终点,在两个轴上也是如此。使用我们上一个示例中的几何图形,我们可以这样定义纹理坐标:

private float texCoords[] = { 
        1.f, 1.f, 
        1.f, 0.f, 
        0.f, 0.f, 
        0.f, 1.f, 

        1.f, 1.f, 
        1.f, 0.f, 
        0.f, 0.f, 
        0.f, 1.f, 
}; 

为了加载这些纹理坐标,我们使用的流程与之前完全相同:

ByteBuffer tbb = ByteBuffer.allocateDirect(texCoords.length * (Float.SIZE / 8)); 
tbb.order(ByteOrder.nativeOrder()); 

texBuffer = tbb.asFloatBuffer(); 
texBuffer.put(texCoords); 
texBuffer.position(0); 

让我们也创建一个辅助方法来将资源加载到纹理中:

private int loadTexture(int resId) { 
    final int[] textureIds = new int[1]; 
    GLES20.glGenTextures(1, textureIds, 0); 

    if (textureIds[0] == 0) return -1; 

    // do not scale the bitmap depending on screen density 
    final BitmapFactory.Options options = new BitmapFactory.Options(); 
    options.inScaled = false; 

    final Bitmap textureBitmap =
    BitmapFactory.decodeResource(getResources(), resId, options); 
    GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureIds[0]); 

    GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, 
            GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_NEAREST); 

    GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, 
            GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_NEAREST); 

    GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, 
            GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE); 

    GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, 
            GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE); 

    GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, textureBitmap, 0); 
    textureBitmap.recycle(); 

    return textureIds[0]; 
} 

我们必须考虑到纹理的两个维度都必须是 2 的幂。为了保持图像的原始大小并避免 Android 进行的任何缩放,我们必须将位图选项inScaled标志设置为false。在之前的代码中,我们生成了一个纹理 ID 来保存对我们纹理的引用,将其绑定为活动纹理,设置过滤和包裹的参数,并最终加载位图数据。完成这些操作后,我们可以回收临时位图,因为我们不再需要它。

如之前所做,我们也必须更新我们的shaders。在我们的vertexShader中,我们必须应用与之前几乎相同的更改,添加一个属性来设置顶点纹理坐标,以及一个varying变量传递给fragmentShader

private final String vertexShaderCode = 
"uniform mat4 uMVPMatrix;" + 
"attribute vec4 vPosition;" + 
"attribute vec2 aTex;" + 
"varying vec2 vTex;" + 
"void main() {" + 
"  gl_Position = uMVPMatrix * vPosition;" + 
"  vTex = aTex;" + 
"}"; 

请注意,顶点坐标是 vec2 而不是 vec4,因为我们只有两个坐标:U 和 V。我们新的 fragmentShader 比我们之前的要复杂一些:

private final String fragmentShaderCode = 
"precision mediump float;" + 
"uniform sampler2D sTex;" + 
"varying vec2 vTex;" + 
"void main() {" + 
"  gl_FragColor = texture2D(sTex, vTex);" + 
"}"; 

我们必须创建一个 varying 纹理坐标变量,以及一个统一的 sampler2D 变量,我们将在其中设置活动的纹理。为了获取颜色,我们必须使用 texture2D 查找函数从指定坐标的纹理中读取颜色数据。

现在,让我们在我们的 res 文件夹的 drawables 中添加一个名为 texture.png 的位图,并修改 onSurfaceCreated() 方法以将其作为纹理加载:

@Override 
public void onSurfaceCreated(GL10 unused, EGLConfig config) { 
    initBuffers(); 
    initShaders(); 

    textureId = loadTexture(R.drawable.texture); 

    startTime = SystemClock.elapsedRealtime(); 
} 

这是我们示例中使用的图像:

最后,让我们更新 onDrawFrame() 方法以设置纹理坐标:

int texCoordHandle = GLES20.glGetAttribLocation(shaderProgram, "aTex"); 
GLES20.glVertexAttribPointer(texCoordHandle, 2, 
        GLES20.GL_FLOAT, false, 
        0, texBuffer); 

这就是纹理本身:

int texHandle = GLES20.glGetUniformLocation(shaderProgram, "sTex"); 
GLES20.glActiveTexture(GLES20.GL_TEXTURE0); 
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId); 
GLES20.glUniform1i(texHandle, 0); 

同样,正如我们之前所做的,我们必须启用,稍后禁用,纹理坐标顶点数组。

如果我们运行这段代码,我们将得到以下结果:

在 GitHub 仓库中查看 Example25-GLDrawing 以获取完整的示例源代码。

加载外部几何图形

到目前为止,我们一直在绘制四边形和立方体,但如果我们想要绘制更复杂的几何图形,使用 3D 建模工具进行建模可能更为方便,而不是通过代码实现。我们可以用多个章节来涵盖这个主题,但让我们先看一个快速示例,了解如何实现,你可以根据需要扩展它。

我们使用了 Blender 来建模我们的示例数据。Blender 是一个免费且开源的 3D 建模工具集,可以在其网站上免费下载:

www.blender.org/

在这个例子中,我们没有建模一个极其复杂的例子;我们只是使用了 Blender 提供的一个基本形状:Suzanne:

为了简化我们的导入工具,我们将在右侧的“场景”|“Suzanne”下拉菜单下选择对象网格,当我们按下 Ctrl + T 时,Blender 将把所有面转换为三角形。否则,我们的导出文件中既有三角形也有四边形,从我们的 Android 应用程序代码中实现面导入器并不直接:

现在,我们将它导出为 Wavefront.obj)文件,这将创建一个 .obj 文件和一个 .mtl 文件。后者是材质信息,目前我们将忽略它。让我们将导出的文件放入我们项目的 assets 文件夹中。

现在,让我们自己创建一个简单的 Wavefront 文件对象解析器。由于我们将要处理文件加载和解析,因此我们需要异步执行:

public class WavefrontObjParser { 
    public static void parse(Context context, String name, ParserListener listener) { 
        WavefrontObjParserHelper helper = new WavefrontObjParserHelper(context, name, listener); 
        helper.start(); 
    } 

    public interface ParserListener { 
        void parsingSuccess(Scene scene); 
        void parsingError(String message); 
    } 
} 

如你所见,这里并没有实际完成工作。为了进行实际的加载和解析,我们创建了一个帮助类,它将在一个单独的线程上执行,并根据解析文件成功或出现错误来调用监听器:

class WavefrontObjParserHelper extends Thread { 
    private String name; 
    private WavefrontObjParser.ParserListener listener; 
    private Context context; 

    WavefrontObjParserHelper(Context context, String name,
    WavefrontObjParser.ParserListener listener) { 
        this.context = context; 
        this.name = name; 
        this.listener = listener; 
    } 

然后,当我们调用 helper.start() 时,它将创建实际的线程,并在其上执行 run() 方法:

public void run() { 
        try { 

            InputStream is = context.getAssets().open(name); 
            BufferedReader br = new BufferedReader(new
            InputStreamReader(is)); 

            Scene scene = new Scene(); 
            Object3D obj = null; 

            String str; 
            while ((str = br.readLine()) != null) { 
                if (!str.startsWith("#")) { 
                    String[] line = str.split(""); 

                    if("o".equals(line[0])) { 
                        if (obj != null) obj.prepare(); 
                        obj = new Object3D(); 
                        scene.addObject(obj); 

                    } else if("v".equals(line[0])) { 
                        float x = Float.parseFloat(line[1]); 
                        float y = Float.parseFloat(line[2]); 
                        float z = Float.parseFloat(line[3]); 
                        obj.addCoordinate(x, y, z); 
                    } else if("f".equals(line[0])) { 

                        int a = getFaceIndex(line[1]); 
                        int b = getFaceIndex(line[2]); 
                        int c = getFaceIndex(line[3]); 

                        if (line.length == 4) { 
                            obj.addFace(a, b, c); 
                        } else { 
                            int d = getFaceIndex(line[4]); 
                            obj.addFace(a, b, c, d); 
                        } 
                    } else { 
                        // skip 
                    } 
                } 
            } 
            if (obj != null) obj.prepare(); 
            br.close(); 

            if (listener != null) listener.parsingSuccess(scene); 
        } catch(Exception e) { 
            if (listener != null) listener.parsingError(e.getMessage()); 
            e.printStackTrace(); 
        } 
    } 

在之前的代码中,我们首先通过提供的名称打开文件来读取资源。为了获取应用程序资源,这里我们需要一个context

InputStream is = context.getAssets().open(name); 
BufferedReader br = new BufferedReader(new InputStreamReader(is)); 

然后,我们逐行读取文件,并根据开始的关键字采取不同的行动,除非行以#开始,这意味着它是一个注释。我们只考虑新对象、顶点坐标和面索引的命令;我们忽略了文件中可能存在的任何附加命令,比如使用的材质,或顶点和面法线。

由于我们可以获取面索引信息,如 f 330//278 336//278 338//278 332//278,我们创建了一个辅助方法来解析这些信息,并只提取面索引。斜杠后面的数字是面法线索引。参考官方文件格式以更详细地了解面索引数字的使用:

private static int getFaceIndex(String face) { 
    if(!face.contains("/")) { 
        return Integer.parseInt(face) - 1; 
    } else { 
        return Integer.parseInt(face.split("/")[0]) - 1; 
    } 
} 

同时,由于面索引从1开始,我们需要减去1以得到正确的结果。

为了存储我们从文件中读取的所有这些数据,我们还创建了一些数据类。Object3D类将存储所有相关信息——顶点、面索引,而Scene类将存储整个 3D 场景以及所有内部的Objects3D。为了简单起见,我们尽可能保持了这些实现的简短,但根据我们的需要,它们可以变得更加复杂:

public class Scene { 
    private ArrayList<Object3D> objects; 

    public Scene() { 
        objects = new ArrayList<>(); 
    } 

    public void addObject(Object3D obj) { 
        objects.add(obj); 
    } 

    public ArrayList<Object3D> getObjects() { 
        return objects; 
    } 

    public void render(int shaderProgram, String posAttributeName,
    String colAttributeName) { 
        GLES20.glEnable(GLES20.GL_DEPTH_TEST); 

        for (int i = 0; i < objects.size(); i++) { 
            objects.get(i).render(shaderProgram, posAttributeName,
            colAttributeName); 
        } 

        GLES20.glDisable(GLES20.GL_DEPTH_TEST); 
    } 
} 

我们可以看到Scene类上有一个render()方法。我们将渲染所有 3D 对象的责任移到了Scene本身,并且应用相同的原则,每个对象也负责渲染自身:

public void prepare() { 
    if (coordinateList.size() > 0 && coordinates == null) { 
        coordinates = new float[coordinateList.size()]; 
        for (int i = 0; i < coordinateList.size(); i++) { 
            coordinates[i] = coordinateList.get(i); 
        } 
    } 

    if (indexList.size() > 0 && indexes == null) { 
        indexes = new short[indexList.size()]; 
        for (int i = 0; i < indexList.size(); i++) { 
            indexes[i] = indexList.get(i); 
        } 
    } 

    colors = new float[(coordinates.length/3) * 4]; 
    for (int i = 0; i < colors.length/4; i++) { 
        float intensity = (float) (Math.random() * 0.5 + 0.4); 
        colors[i * 4    ] = intensity; 
        colors[i * 4 + 1] = intensity; 
        colors[i * 4 + 2] = intensity; 
        colors[i * 4 + 3] = 1.f; 
    } 

    ByteBuffer vbb = ByteBuffer.allocateDirect(coordinates.length *
   (Float.SIZE / 8)); 
    vbb.order(ByteOrder.nativeOrder()); 

    vertexBuffer = vbb.asFloatBuffer(); 
    vertexBuffer.put(coordinates); 
    vertexBuffer.position(0); 

    ByteBuffer ibb = ByteBuffer.allocateDirect(indexes.length *
   (Short.SIZE / 8)); 
    ibb.order(ByteOrder.nativeOrder()); 

    indexBuffer = ibb.asShortBuffer(); 
    indexBuffer.put(indexes); 
    indexBuffer.position(0); 

    ByteBuffer cbb = ByteBuffer.allocateDirect(colors.length * 
    (Float.SIZE / 8)); 
    cbb.order(ByteOrder.nativeOrder()); 

    colorBuffer = cbb.asFloatBuffer(); 
    colorBuffer.put(colors); 
    colorBuffer.position(0); 

    Log.i(TAG, "Loaded obj with " + coordinates.length + " vertices &"
    + (indexes.length/3) + " faces"); 
} 

一旦我们为3DObject设置好所有数据,我们可以通过调用其prepare()方法来准备渲染。这个方法将创建顶点和索引Buffer,并且由于在这种情况下数据文件中的网格没有任何颜色信息,它将为每个顶点生成一个随机颜色,或者更确切地说是一个强度。

在这里3DObject本身创建缓冲区允许我们渲染任何类型的对象。Scene容器不知道内部是什么类型的对象或几何图形。只要它处理自己的渲染,我们可以轻松地将这个类扩展为另一种类型的3DObject

最后,我们在3DObject中添加了一个render()方法:

public void render(int shaderProgram, String posAttributeName, String colAttributeName) { 
    int positionHandle = GLES20.glGetAttribLocation(shaderProgram,
    posAttributeName); 
    GLES20.glVertexAttribPointer(positionHandle, 3, 
            GLES20.GL_FLOAT, false, 
            3 * 4, vertexBuffer); 

    int colorHandle = GLES20.glGetAttribLocation(shaderProgram,
    colAttributeName); 
    GLES20.glVertexAttribPointer(colorHandle, 4, 
            GLES20.GL_FLOAT, false, 
            4 * 4, colorBuffer); 

    GLES20.glEnableVertexAttribArray(colorHandle); 
    GLES20.glEnableVertexAttribArray(positionHandle); 
    GLES20.glDrawElements( 
            GLES20.GL_TRIANGLES, indexes.length, 
            GLES20.GL_UNSIGNED_SHORT, indexBuffer); 

    GLES20.glDisableVertexAttribArray(positionHandle); 
    GLES20.glDisableVertexAttribArray(colorHandle); 
} 

这个方法负责启用和禁用正确的数组并渲染自身。我们从方法参数中获取shader属性。理想情况下,每个对象都可以有自己的shader,但我们不想在这个示例中增加太多复杂性。

在我们的GLDrawer类中,我们还添加了一个辅助方法来计算透视失真矩阵。OpenGL 中最常用的调用之一是gluPerspective,而许多出色的 OpenGL 教程的作者 NeHe 创建了一个函数将gluPerspective转换为glFrustrum调用:

// source: http://nehe.gamedev.net/article/replacement_for_gluperspective/21002/ 

private static void perspectiveFrustrum(float[] matrix, float fov, float aspect, float zNear, float zFar) { 
    float fH = (float) (Math.tan( fov / 360.0 * Math.PI ) * zNear); 
    float fW = fH * aspect; 

    Matrix.frustumM(matrix, 0, -fW, fW, -fH, fH, zNear, zFar); 
} 

因为我们不再需要它,我们从GLDrawer中移除了所有顶点和面索引信息,并简化了onDrawFrame()方法,现在将所有对象的渲染委托给Scene类,默认情况下,委托给每个单独的3DObject

@Override 
public void onDrawFrame(GL10 unused) { 
    angle = ((float) SystemClock.elapsedRealtime() - startTime) *
    0.02f; 
    GLES20.glClearColor(1.0f, 0.0f, 0.0f, 1.0f); 
    GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT | 
    GLES20.GL_DEPTH_BUFFER_BIT); 

    if (scene != null) { 
        Matrix.setLookAtM(mViewMatrix, 0, 
                0, 0, -4, 
                0f, 0f, 0f, 
                0f, 1.0f, 0.0f); 

        Matrix.multiplyMM(mMVPMatrix, 0, mProjectionMatrix, 0,
        mViewMatrix, 0); 
        Matrix.rotateM(mMVPMatrix, 0, angle, 0.8f, 2.f, 1.f); 

        GLES20.glUseProgram(shaderProgram); 

        int mMVPMatrixHandle = GLES20.glGetUniformLocation(shaderProgram, "uMVPMatrix"); 
        GLES20.glUniformMatrix4fv(mMVPMatrixHandle, 1, false,
        mMVPMatrix, 0); 

        scene.render(shaderProgram, "vPosition", "aColor"); 
    } 
} 

把所有内容放在一起,如果我们运行这个示例,我们将得到以下屏幕:

请在 GitHub 仓库中查看Example26-GLDrawing以获取完整的示例源代码。

总结

在本章中,我们学习了如何使用 OpenGL ES 创建非常基础的自定义视图。OpenGL ES 在创建自定义视图时提供了很多可能性,但如果我们没有太多与之工作的经验,它也会增加很多复杂性。我们本可以在这一主题上涵盖更多章节,但这并不是本书的主要目标。我们会有更多使用 3D 自定义视图的示例,但关于如何在 Android 设备上学习甚至掌握 OpenGL ES,已经有很多发布的材料了。

在下一章中,我们将学习如何为自定义视图添加更多动画和平滑的运动。由于我们可以动画化任何参数或变量,无论是 3D 自定义视图还是标准的 2D 自定义视图,这都不重要,但我们将看到如何在这两种情况下应用动画。

第六章:动画

到目前为止,我们已经了解了如何创建和渲染不同类型的自定义视图,从非常简单的 2D 画布绘图到更复杂的画布操作,以及最近如何使用 OpenGL ES 和顶点/片段着色器创建自定义视图。在一些用于演示如何使用这些渲染原语的示例中,我们已经使用了一些动画,正如你可以想象的,动画是自定义视图的关键元素之一。如果我们想使用自定义视图构建高度复杂的 UI,但完全不使用动画,那么使用静态图像可能更好。

在本章中,我们将介绍如何向自定义视图添加动画。有许多方法可以实现这一点,但我们会更详细地探讨以下主题:

  • 自定义动画

  • 固定时间步长技术

  • 使用 Android 属性动画

此外,我们还将探讨如果我们错误地实现一些动画,可能会出现哪些问题,因为这看起来可能更简单,也许仅仅是运气好,尽管这可能会对我们不利,但它们似乎在我们的设备上可以完美运行。

自定义动画

让我们从如何自己实现一些值的变化开始,而不是过分依赖 Android SDK 提供的方法和类。在本节中,我们将了解如何使用不同的机制对一个或多个属性进行动画处理。这样,我们就可以根据我们想要实现的动画类型或我们正在实现的观点的具体特点,在我们自定义的视图中应用更合适的方法。

定时帧动画

在我们上一章的 3D 示例中,我们已经使用了这种类型的动画。主要概念是在绘制新帧之前,根据经过的时间为所有可动画属性分配一个新值。我们可能会被诱惑根据已绘制的帧数递增或计算一个新值,但这是非常不建议的,因为动画的播放速度将取决于设备速度、计算或绘图复杂性以及其他在后台执行的过程。

为了正确实现,我们必须引入与渲染速度、每秒帧数或已绘制的帧数无关的东西,而基于时间的动画是一个完美的解决方案。

Android 为我们提供了几种机制来实现这一点。例如,我们可以使用 System.currentTimeMillis()System.nanoTime(),甚至是一些系统时钟中可用的方法,如 elapsedRealtime()

让我们构建一个简单的示例来比较不同的方法。首先,创建一个简单的自定义视图,绘制四个旋转不同角度的矩形,或者说是 Rect

private static final int BACKGROUND_COLOR = 0xff205020; 
private static final int FOREGROUND_COLOR = 0xffffffff; 
private static final int QUAD_SIZE = 50; 

private float[] angle; 
private Paint paint; 

public AnimationExampleView(Context context, AttributeSet attributeSet) { 
    super(context, attributeSet); 

    paint = new Paint(); 
    paint.setStyle(Paint.Style.FILL); 
    paint.setAntiAlias(true); 
    paint.setColor(FOREGROUND_COLOR); 
    paint.setTextSize(48.f); 

    angle = new float[4]; 
    for (int i = 0; i < 4; i++) { 
        angle[i] = 0.f; 
    } 
} 

在类的构造函数中,我们初始化 Paint 对象,并创建一个包含四个浮点数的数组来保存每个矩形的旋转角度。此时,这四个角度都将是 0。现在,让我们实现 onDraw() 方法。

onDraw()方法中,我们首先要做的是用纯色清除画布背景,以清除我们之前的帧。

完成这些后,我们计算将绘制四个矩形的坐标并开始绘制。为了简化旋转,在本例中,我们使用了canvas.translatecanvas.rotate,以矩形的中心点作为旋转轴点。同时,为了避免进行额外的计算并尽可能保持简单,我们在每个矩形绘制前后分别使用canvas.savecanvas.restore,以保持每次绘制操作之前的状态:

@Override 
protected void onDraw(Canvas canvas) { 
    canvas.drawColor(BACKGROUND_COLOR); 

    int width = getWidth(); 
    int height = getHeight(); 

    // draw 4 quads on the screen: 
    int wh = width / 2; 
    int hh = height / 2; 

    int qs = (wh * QUAD_SIZE) / 100; 

    // top left 
    canvas.save(); 
    canvas.translate( 
        wh / 2 - qs / 2, 
        hh / 2 - qs / 2); 

    canvas.rotate(angle[0], qs / 2.f, qs / 2.f); 
    canvas.drawRect(0, 0, qs, qs, paint); 
    canvas.restore(); 

    // top right 
    canvas.save(); 
    canvas.translate( 
        wh + wh / 2 - qs / 2, 
        hh / 2 - qs / 2); 

    canvas.rotate(angle[1], qs / 2.f, qs / 2.f); 
    canvas.drawRect(0, 0, qs, qs, paint); 
    canvas.restore(); 

    // bottom left 
    canvas.save(); 
    canvas.translate( 
        wh / 2 - qs / 2, 
        hh + hh / 2 - qs / 2); 

    canvas.rotate(angle[2], qs / 2.f, qs / 2.f); 
    canvas.drawRect(0, 0, qs, qs, paint); 
    canvas.restore(); 

    // bottom right 
    canvas.save(); 
    canvas.translate( 
        wh + wh / 2 - qs / 2, 
        hh + hh / 2 - qs / 2); 

    canvas.rotate(angle[3], qs / 2.f, qs / 2.f); 
    canvas.drawRect(0, 0, qs, qs, paint); 
    canvas.restore(); 

    canvas.drawText("a: " + angle[0], 16, hh - 16, paint); 
    canvas.drawText("a: " + angle[1], wh + 16, hh - 16, paint); 
    canvas.drawText("a: " + angle[2], 16, height - 16, paint); 
    canvas.drawText("a: " + angle[3], wh + 16, height - 16, paint); 

    postInvalidateDelayed(10); 
} 

为了更清晰地看到差异,我们绘制了一个文本,显示每个矩形旋转的角度。并且,为了实际触发视图的重绘,我们调用了延迟 10 毫秒的invalidate

第一个矩形将在每次绘制时简单地增加其角度,忽略时间方法,而其他三个将分别使用:System.currentTimeMillis()System.nanoTime()SystemClock.elapsedRealtime()。让我们初始化一些变量来保存定时器的初始值:

private long timeStartMillis; 
private long timeStartNanos; 
private long timeStartElapsed; 

onDraw()方法的开头添加一个小计算:

if (timeStartMillis == -1)  
    timeStartMillis = System.currentTimeMillis(); 

if (timeStartNanos == -1)  
    timeStartNanos = System.nanoTime(); 

if (timeStartElapsed == -1)  
    timeStartElapsed = SystemClock.elapsedRealtime(); 

angle[0] += 0.2f; 
angle[1] = (System.currentTimeMillis() - timeStartMillis) * 0.02f; 
angle[2] = (System.nanoTime() - timeStartNanos) * 0.02f * 0.000001f; 
angle[3] = (SystemClock.elapsedRealtime() - timeStartElapsed) * 0.02f; 

由于从初始类创建到调用onDraw()方法之间可能经过了一段时间,我们在这里计算定时器的初始值。例如,如果timeStartElapsed的值是-1,这意味着它尚未初始化。

首先,我们设定了初始时间,然后可以计算出已经过去了多少时间,并将其作为动画的基础值。我们可以乘以一个因子来控制速度。在本例中,我们使用了0.02作为示例,并考虑到纳秒和毫秒的量级不同。

如果我们运行这个示例,我们将得到类似于以下截图的结果:

这种方法的一个问题是,如果我们把应用放到后台,过一段时间再把它调到前台,我们会看到所有基于时间的值都会向前跳跃,因为当我们的应用在后台时时间并不会停止。为了控制这一点,我们可以重写onVisibilityChanged()回调,并检查我们的视图是可见还是不可见:

@Override 
protected void onVisibilityChanged(@NonNull View changedView, int visibility) { 
    super.onVisibilityChanged(changedView, visibility); 

    // avoid doing this check before View is even visible 
    if ((visibility == View.INVISIBLE || visibility == View.GONE) &&  
          previousVisibility == View.VISIBLE) { 

        invisibleTimeStart = SystemClock.elapsedRealtime(); 
    } 

    if ((previousVisibility == View.INVISIBLE || previousVisibility ==
        View.GONE) && 
        visibility == View.VISIBLE) { 

        timeStartElapsed += SystemClock.elapsedRealtime() -
        invisibleTimeStart; 
    } 
    previousVisibility = visibility; 
} 

在前面的代码中,我们计算了视图不可见的时间,并调整timeStartElapsed。我们必须避免在第一次执行此操作,因为该方法将在视图第一次可见时被调用。因此,我们检查timeStartElapsed是否不等于-1

由于我们有这个回调正好在视图变为可见之前,我们可以轻松地更改之前的代码来计算定时器的初始值,并将其放在这里,也简化我们的onDraw()方法:

@Override 
protected void onVisibilityChanged(@NonNull View changedView, int visibility) { 
    super.onVisibilityChanged(changedView, visibility); 

    // avoid doing this check before View is even visible 
    if (timeStartElapsed != -1) { 
        if ((visibility == View.INVISIBLE || visibility == View.GONE)
            && 
            previousVisibility == View.VISIBLE) { 

            invisibleTimeStart = SystemClock.elapsedRealtime(); 
        } 

        if ((previousVisibility == View.INVISIBLE || previousVisibility
            == View.GONE) && 
            visibility == View.VISIBLE) { 

            timeStartElapsed += SystemClock.elapsedRealtime() -
            invisibleTimeStart; 
        } 
    } else {
        timeStartMillis = System.currentTimeMillis();
        timeStartNanos = System.nanoTime();
        timeStartElapsed = SystemClock.elapsedRealtime();
    }
    previousVisibility = visibility;
}

通过这个微小的调整,只修改了timeStartElapsed,即使我们把应用放到后台,我们也会看到右下方的矩形保留了动画。

你可以在 GitHub 仓库的Example27-Animations文件夹中找到整个示例的源代码。

固定时间步长

在处理动画时,有时计算可能会非常复杂。一个明显的例子就是物理模拟和一般游戏中的情况,但在其他一些时候,即使是对于一个简单自定义视图,当使用基于时间的动画时,我们的计算也可能会有点棘手。固定时间步长将允许我们从时间变量中抽象出动画逻辑,但仍然使我们的动画与时间相关联。

设定固定时间步长的逻辑是假设我们的动画逻辑将始终以固定的速率执行。例如,我们可以假设无论实际渲染的每秒帧数是多少,它都将以60 fps 的速率执行。为了展示如何做到这一点,我们将创建一个新的自定义视图,该视图将在我们按或拖动屏幕的位置生成粒子,并应用一些非常基础简单的物理效果。

首先,我们按照之前的示例创建一个基本的自定义视图:

private static final int BACKGROUND_COLOR = 0xff404060; 
private static final int FOREGROUND_COLOR = 0xffffffff; 
private static final int N_PARTICLES = 800; 

private Paint paint; 
private Particle[] particles; 
private long timeStart; 
private long accTime; 
private int previousVisibility; 
private long invisibleTimeStart; 

public FixedTimestepExample(Context context, AttributeSet attributeSet) { 
    super(context, attributeSet); 

    paint = new Paint(); 
    paint.setStyle(Paint.Style.FILL); 
    paint.setAntiAlias(true); 
    paint.setColor(FOREGROUND_COLOR); 

    particles = new Particle[N_PARTICLES]; 
    for (int i = 0; i < N_PARTICLES; i++) { 
        particles[i] = new Particle(); 
    } 

    particleIndex = 0; 
    timeStart = -1; 
    accTime = 0; 
    previousVisibility = View.GONE; 
} 

我们初始化基本变量,并且创建一个particles数组。同样,由于我们在上一个示例中实现了onVisibilityChange回调,让我们利用它:

@Override 
protected void onVisibilityChanged(@NonNull View changedView, int visibility) { 
    super.onVisibilityChanged(changedView, visibility); 
    if (timeStartElapsed != -1) { 
        // avoid doing this check before View is even visible 
        if ((visibility == View.INVISIBLE ||  visibility == View.GONE)
            && 
            previousVisibility == View.VISIBLE) { 

            invisibleTimeStart = SystemClock.elapsedRealtime(); 
        } 

        if ((previousVisibility == View.INVISIBLE || previousVisibility 
            == View.GONE) && 
            visibility == View.VISIBLE) { 

            timeStart += SystemClock.elapsedRealtime() -
            invisibleTimeStart; 
        } 
    } else { 
        timeStart = SystemClock.elapsedRealtime(); 
    } 
    previousVisibility = visibility; 
} 

现在我们来定义一个Particle类,尽量保持其简单:

class Particle { 
    float x; 
    float y; 
    float vx; 
    float vy; 
    float ttl; 

    Particle() { 
        ttl = 0.f; 
    } 
} 

我们只定义了xy坐标,xy的速度分别为vxvy,以及粒子的生命周期。当粒子的生命周期达到0时,我们将不再更新或绘制它。

现在,我们来实现onDraw()方法:

@Override 
protected void onDraw(Canvas canvas) { 
    animateParticles(getWidth(), getHeight()); 

    canvas.drawColor(BACKGROUND_COLOR); 

    for(int i = 0; i < N_PARTICLES; i++) { 
        float px = particles[i].x; 
        float py = particles[i].y; 
        float ttl = particles[i].ttl; 

        if (ttl > 0) { 
            canvas.drawRect( 
                px - PARTICLE_SIZE, 
                py - PARTICLE_SIZE, 
                px + PARTICLE_SIZE, 
                py + PARTICLE_SIZE, paint); 
        } 
    } 
    postInvalidateDelayed(10); 
} 

我们将所有动画委托给animateParticles()方法,在这里我们只是遍历所有粒子,检查它们的生命周期是否为正,如果是,就绘制它们。

让我们看看如何使用固定时间步长来实现animateParticles()方法:

private static final int TIME_THRESHOLD = 16; 
private void animateParticles(int width, int height) { 
    long currentTime = SystemClock.elapsedRealtime(); 
    accTime += currentTime - timeStart; 
    timeStart = currentTime; 

    while(accTime > TIME_THRESHOLD) { 
        for (int i = 0; i < N_PARTICLES; i++) { 
            particles[i].logicTick(width, height); 
        } 

        accTime -= TIME_THRESHOLD; 
    } 
} 

我们计算自上次以来的时间差,或者说是时间增量,并将其累积在accTime变量中。然后,只要accTime高于我们定义的阈值,我们就执行一个逻辑步骤。可能会在两次渲染之间执行多个逻辑步骤,或者在有些情况下,可能在两帧之间没有执行。

最后,我们为每个执行的逻辑步骤从accTime中减去我们定义的时间阈值,并将新的timeStart设置为用于计算从上一次调用animateParticles()以来时间差的时间。

在这个例子中,我们将时间阈值定义为16,所以每16毫秒我们将执行一个逻辑步骤,无论我们是渲染10帧还是60帧每秒。

Particle类上的logicTick()方法完全忽略了计时器的当前值,因为它假设它将在固定的时间步长上执行:

void logicTick(int width, int height) { 
    ttl--; 

    if (ttl > 0) { 
        vx = vx * 0.95f; 
        vy = vy + 0.2f; 

        x += vx; 
        y += vy; 

        if (y < 0) { 
            y = 0; 
            vy = -vy * 0.8f; 
        } 

        if (x < 0) { 
            x = 0; 
            vx = -vx * 0.8f; 
        } 

        if (x >= width) { 
            x = width - 1; 
            vx = -vx * 0.8f; 
        } 
    } 
} 

这是对粒子物理模拟的极度简化。它基本上对粒子应用摩擦力并添加垂直加速度,计算它们是否需要从屏幕边缘反弹,并计算新的xy位置。

我们只是缺少在按或拖动TouchEvent时生成新粒子的代码:

@Override 
public boolean onTouchEvent(MotionEvent event) { 
    switch (event.getAction()) { 
        case MotionEvent.ACTION_DOWN: 
        case MotionEvent.ACTION_MOVE: 
            spawnParticle(event.getX(), event.getY()); 
            return true; 
    } 
    return super.onTouchEvent(event); 
} 

在这里,只要我们有按下的或移动的触摸事件,我们就会调用spawnParticle()spawnParticle()的实现也非常简单:

private static final int SPAWN_RATE = 8; 
private int particleIndex; 

private void spawnParticle(float x, float y) { 
    for (int i = 0; i < SPAWN_RATE; i++) { 
        particles[particleIndex].x = x; 
        particles[particleIndex].y = y; 
        particles[particleIndex].vx = (float) (Math.random() * 40.f) -
        20.f; 
        particles[particleIndex].vy = (float) (Math.random() * 20.f) -
        10.f; 
        particles[particleIndex].ttl = (float) (Math.random() * 100.f)
        + 150.f; 
        particleIndex++; 
        if (particleIndex == N_PARTICLES) particleIndex = 0; 
    } 
} 

我们使用particleIndex变量作为particles数组的循环索引。每当它到达数组末尾时,它将重新从数组开始处继续。这种方法设置触摸事件的xy坐标,并随机化每个生成粒子的速度和生存时间。我们创建了一个SPAWN_RATE常量,以在同一个触摸事件上生成多个粒子,从而改善视觉效果。

如果我们运行应用程序,我们可以看到它的实际效果,它将与以下截图非常相似,但在这种情况下,很难在截图中捕捉到动画的想法:

图片

但我们遗漏了一些东西。正如我们之前提到的,有时在两帧渲染之间,我们会执行两个或更多的逻辑步骤,但在其他时候,我们可能在连续的两帧之间不执行任何逻辑步骤。如果我们在这两帧之间不执行任何逻辑步骤,结果将是相同的,并且会浪费 CPU 和电池寿命。

即使我们处于逻辑步骤之间,这并不意味着在帧之间没有经过任何时间。实际上,我们处于上一个计算出的逻辑步骤和下一个步骤之间的某个位置。好消息是,我们实际上可以计算出这一点,从而提高动画的平滑度并同时解决此问题。

让我们把这个修改包括到animateParticles()方法中:

private void animateParticles(int width, int height) {
    long currentTime = SystemClock.elapsedRealtime();
    accTime += currentTime - timeStart;
    timeStart = currentTime;

     while(accTime > TIME_THRESHOLD) {
        for (int i = 0; i < N_PARTICLES; i++) {
            particles[i].logicTick(width, height);
        }

         accTime -= TIME_THRESHOLD;
    }

     float factor = ((float) accTime) / TIME_THRESHOLD;
     for (int i = 0; i < N_PARTICLES; i++) {
        particles[i].adjustLogicStep(factor);
    }
}

我们正在计算一个因子,该因子将告诉我们距离下一个逻辑步骤有多近或多远。如果因子是0,这意味着我们正好处于刚刚执行的逻辑步骤的确切时间。如果因子是0.5,这意味着我们处于当前步骤和下一个步骤之间的一半,而如果因子是0.8,我们几乎要到达下一个逻辑步骤,并且精确地80%的时间已经自上一个步骤过去了。在一步逻辑步骤和下一步之间平滑过渡的方法是使用这个因子进行插值,但要能够这样做,我们首先需要计算下一步的值。让我们改变logicTick()方法以实现这个变化:

float nextX; 
float nextY; 
float nextVX; 
float nextVY; 

void logicTick(int width, int height) { 
    ttl--; 

    if (ttl > 0) { 
        x = nextX; 
        y = nextY; 
        vx = nextVX; 
        vy = nextVY; 

        nextVX = nextVX * 0.95f; 
        nextVY = nextVY + 0.2f; 

        nextX += nextVX; 
        nextY += nextVY; 

        if (nextY < 0) { 
            nextY = 0; 
            nextVY = -nextVY * 0.8f; 
        } 

        if (nextX < 0) { 
            nextX = 0; 
            nextVX = -nextVX * 0.8f; 
        } 

        if (nextX >= width) { 
            nextX = width - 1; 
            nextVX = -nextVX * 0.8f; 
        } 
    } 
} 

现在,在每一个逻辑步骤中,我们都在将下一个逻辑步骤的值赋给当前变量以避免重新计算它们,并计算下一个逻辑步骤。这样,我们得到了这两个值;在执行下一个逻辑步骤之后的当前值和新值。

由于我们将使用xynextXnextY之间的中间值,我们也会在新变量上计算这些值。

float drawX; 
float drawY; 

void adjustLogicStep(float factor) { 
    drawX = x * (1.f - factor) + nextX * factor; 
    drawY = y * (1.f - factor) + nextY * factor; 
} 

正如我们所看到的,drawXdrawY将是当前逻辑步骤和下一个逻辑步骤之间的中间状态。如果我们将前一个示例的值应用到这个因子上,我们就会看到这种方法是如何工作的。

如果因子是0,则drawXdrawY正好是xy。相反,如果因子是1,则drawXdrawY正好是nextXnextY,尽管这实际上不会发生,因为另一个逻辑步骤将被触发。

在因子为0.8的情况下,drawXdrawY的值是对下一个逻辑步骤的值80%和当前步骤的值20%的线性插值,从而实现状态之间的平滑过渡。

你可以在 GitHub 仓库的Example28-FixedTimestep文件夹中找到整个示例源代码。固定时间步进在 Gaffer On Games 博客的“fix your timestep”文章中有更详细的介绍。

使用 Android SDK 类

到目前为止,我们已经了解了如何使用基于时间动画或固定时间步机制来创建我们自己的动画。但 Android 提供了多种使用其 SDK 和动画框架进行动画制作的方法。在大多数情况下,我们可以通过仅使用属性动画系统来简化我们的动画,而无需创建自己的系统,但这将取决于我们想要实现的内容的复杂性以及我们想要如何处理开发。

有关更多信息,请参考 Android 开发者文档网站上的属性动画框架。

值动画

作为属性动画系统的一部分,我们有ValueAnimator类。我们可以使用它来简单地动画化intfloatcolor变量或属性。它非常易于使用,例如,我们可以使用以下代码在1500毫秒内将浮点值从0动画化到360

ValueAnimator angleAnimator = ValueAnimator.ofFloat(0, 360.f); 
angleAnimator.setDuration(1500); 
angleAnimator.start(); 

这是正确的,但如果我们想要获取动画的更新并对其做出反应,我们必须设置一个AnimatorUpdateListener()

final ValueAnimator angleAnimator = ValueAnimator.ofFloat(0, 360.f); 
angleAnimator.setDuration(1500); 
angleAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { 
    @Override 
    public void onAnimationUpdate(ValueAnimator animation) { 
        angle = (float) angleAnimator.getAnimatedValue(); 
        invalidate(); 
    } 
}); 
angleAnimator.start(); 

同时,在这个例子中,我们可以看到我们在AnimatorUpdateListener()中调用了invalidate(),因此我们也在告诉 UI 重新绘制视图。

我们可以配置动画行为的许多方面:从动画重复模式、重复次数和插值器类型。让我们使用本章开始时使用的同一个示例来看一下它的实际应用。让我们在屏幕上绘制四个矩形,并使用ValueAnimator的不同设置来旋转它们:

//top left 
final ValueAnimator angleAnimatorTL = ValueAnimator.ofFloat(0, 360.f); 
angleAnimatorTL.setRepeatMode(ValueAnimator.REVERSE); 
angleAnimatorTL.setRepeatCount(ValueAnimator.INFINITE); 
angleAnimatorTL.setDuration(1500); 
angleAnimatorTL.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { 
    @Override 
    public void onAnimationUpdate(ValueAnimator animation) { 
        angle[0] = (float) angleAnimatorTL.getAnimatedValue(); 
        invalidate(); 
    } 
}); 

//top right 
final ValueAnimator angleAnimatorTR = ValueAnimator.ofFloat(0, 360.f); 
angleAnimatorTR.setInterpolator(new DecelerateInterpolator()); 
angleAnimatorTR.setRepeatMode(ValueAnimator.RESTART); 
angleAnimatorTR.setRepeatCount(ValueAnimator.INFINITE); 
angleAnimatorTR.setDuration(1500); 
angleAnimatorTR.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { 
    @Override 
    public void onAnimationUpdate(ValueAnimator animation) { 
        angle[1] = (float) angleAnimatorTR.getAnimatedValue(); 
        invalidate(); 
    } 
}); 

//bottom left 
final ValueAnimator angleAnimatorBL = ValueAnimator.ofFloat(0, 360.f); 
angleAnimatorBL.setInterpolator(new AccelerateDecelerateInterpolator()); 
angleAnimatorBL.setRepeatMode(ValueAnimator.RESTART); 
angleAnimatorBL.setRepeatCount(ValueAnimator.INFINITE); 
angleAnimatorBL.setDuration(1500); 
angleAnimatorBL.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { 
    @Override 
    public void onAnimationUpdate(ValueAnimator animation) { 
        angle[2] = (float) angleAnimatorBL.getAnimatedValue(); 
        invalidate(); 
    } 
}); 

//bottom right 
final ValueAnimator angleAnimatorBR = ValueAnimator.ofFloat(0, 360.f); 
angleAnimatorBR.setInterpolator(new OvershootInterpolator()); 
angleAnimatorBR.setRepeatMode(ValueAnimator.REVERSE); 
angleAnimatorBR.setRepeatCount(ValueAnimator.INFINITE); 
angleAnimatorBR.setDuration(1500); 
angleAnimatorBR.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { 
    @Override 
    public void onAnimationUpdate(ValueAnimator animation) { 
        angle[3] = (float) angleAnimatorBR.getAnimatedValue(); 
        invalidate(); 
    } 
}); 

angleAnimatorTL.start(); 
angleAnimatorTR.start(); 
angleAnimatorBL.start(); 
angleAnimatorBR.start(); 

我们现在配置了四个不同的 ValueAnimators,并通过它们的 onAnimationUpdate() 回调触发失效调用,而不是设置初始时间和计算时间差。在这些 ValueAnimator 上,我们使用了不同的插值器和不同的重复模式:ValueAnimator.RESTARTValueAnimator.REVERSE。在所有这些中,我们将重复次数设置为 ValueAnimator.INFINITE,这样我们就可以在没有压力的情况下观察和比较插值器的细节。

onDraw() 方法中,我们移除了 postInvalidate 调用,因为视图将被动画失效,但保留 drawText() 非常有趣,因为这样我们可以看到 OvershootInterpolator() 的行为以及它如何超出最大值。

如果我们运行这个示例,我们将看到四个矩形使用不同的插值机制进行动画处理。尝试使用不同的插值器,甚至可以通过扩展 TimeInterpolator 并实现 getInterpolation(float input) 方法来实现自己的插值器。

getInterpolation 方法的输入参数将在 01 之间,将 0 映射到动画的开始,将 1 映射到动画的结束。返回值应在 01 之间,但如果像 OvershootInterpolator 那样我们想要超出原始值,它可能更低或更高。然后 ValueAnimator 将根据这个因素计算初始值和最终值之间的正确值。

这个示例需要在模拟器或真实设备上查看,但为屏幕截图添加一点动态模糊可以稍微显示矩形以不同的速度和加速度进行动画处理。

ObjectAnimator

如果我们想直接对对象而不是属性进行动画处理,我们可以使用 ObjectAnimator 类。ObjectAnimatorValueAnimator 的一个子类,并使用相同的功能和特性,但增加了通过名称对对象属性进行动画处理的能力。

例如,为了展示其工作原理,我们可以以这种方式动画化我们 View 的一个属性。让我们为整个画布添加一个小的旋转,由 canvasAngle 变量控制:

float canvasAngle; 

@Override 
protected void onDraw(Canvas canvas) { 
    canvas.save(); 
    canvas.rotate(canvasAngle, getWidth() / 2, getHeight() / 2); 

    ... 

    canvas.restore(); 
} 

我们需要创建具有正确名称的设置器和获取器:以驼峰命名法命名的 set<变量名>get<变量名>,在我们的特定案例中:

public void setCanvasAngle(float canvasAngle) { 
    this.canvasAngle = canvasAngle; 
} 

public float getCanvasAngle() { 
    return canvasAngle; 
} 

由于这些方法将被 ObjectAnimator 调用,我们已经创建它们,现在可以设置 ObjectAnimator 本身了:

ObjectAnimator canvasAngleAnimator = ObjectAnimator.ofFloat(this, "canvasAngle", -10.f, 10.f); 
canvasAngleAnimator.setDuration(3000); 
canvasAngleAnimator.setRepeatCount(ValueAnimator.INFINITE); 
canvasAngleAnimator.setRepeatMode(ValueAnimator.REVERSE); 
canvasAngleAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { 
    @Override 
    public void onAnimationUpdate(ValueAnimator animation) { 
        invalidate(); 
    } 
}); 

这基本上与 ValueAnimator 的方法相同,但在这种情况下,我们指定要使用字符串和对象引用进行动画处理的属性。正如我们刚才提到的,ObjectAnimator 将使用 set<变量名>get<变量名> 的格式调用属性的获取器和设置器。此外,在 onAnimationUpdate 回调中只有一个 invalidate() 调用。我们移除了任何像前一个示例中的值赋值,因为它们将自动由 ObjectAnimator 更新。

你可以在 GitHub 仓库的Example29-PropertyAnimation文件夹中找到整个示例的源代码。

总结

在本章中,我们学习了如何为自定义视图添加不同类型的动画,从使用 Android 属性动画系统中的ValueAnimatorObjectAnimator类,到创建基于时间或使用固定时间步进机制的自定义动画。

Android 为我们提供了更多的动画类,比如AnimatorSet,我们可以组合多个动画,并指定哪个动画在另一个之前或之后播放。

作为建议,我们不应重复发明轮子,如果 Android 提供的功能足够用,尽量使用它,或者根据我们的特定需求进行扩展,但如果它不适合,不要强求,因为或许构建自己的动画可能会更简单且更容易维护。

与软件开发中的所有事物一样,应使用常识并选择最佳可用选项。

在下一章中,我们将学习如何提高自定义视图的性能。在自定义视图中,我们完全控制着绘制过程,因此优化绘制方法和资源分配至关重要,以避免使应用程序变得迟缓并节省用户的电量。

第七章:性能考虑

在前面的章节中,我们简要地讨论了性能问题,例如避免使用onDraw()方法进行某些操作。但我们还没有详细解释为什么你应该遵循这些建议,以及不遵循这些最佳实践对自定义视图和使用它的应用程序的真正影响。我们在这里解释的许多事情可能看起来是常识,实际上也应该是,但有时我们可能不会想到它们,或者我们可能不知道或不了解它们对应用程序可能产生的真实影响,无论是从性能角度还是关于电池消耗。

在本章中,我们将讨论这些问题,并更详细地了解以下主题:

  • 建议和最佳实践

  • 当不考虑性能时对应用的影响

  • 代码优化

性能影响和推荐

正如我们所说,除非我们经历过性能问题,或者我们在支持低端或非常旧的设备,否则我们可能甚至不知道不遵循性能建议或最佳实践的影响是什么。如果我们使用高端设备来测试当前开发的内容,我们可能无法看到它在低端设备上的表现,而且很可能会有更多用户在中低端设备上使用它。这几乎就像是我们用良好可靠的 Wi-Fi 连接开发网络连接软件,或者拥有无限的 4G 网络。对于网络受限或按量计费的用户,尤其是仍在使用 2G 网络的用户,他们的体验可能完全不同。

在这两种情况下,重要的是要考虑我们的所有目标用户,并在多种场景下进行测试,使用不同的设备和硬件。

不遵循最佳实践的影响

在最近几章中,我们一直在推荐避免在onDraw()方法中分配对象。但如果我们开始分配对象,会发生什么呢?

让我们创建一个简单的自定义视图,并故意分配一个对象,以便我们可以在运行应用时评估结果:

package com.packt.rrafols.draw; 

import android.content.Context; 
import android.graphics.Bitmap; 
import android.graphics.BitmapFactory; 
import android.graphics.Canvas; 
import android.graphics.Paint; 
import android.graphics.Path; 
import android.graphics.Rect; 
import android.graphics.Region; 
import android.util.AttributeSet; 
import android.view.GestureDetector; 
import android.view.MotionEvent; 
import android.view.View; 
import android.widget.Scroller; 

public class PerformanceExample extends View { 
    private static final String TAG =PerformanceExample.class.
                                     getName(); 

    private static final int BLACK_COLOR = 0xff000000; 
    private static final int WHITE_COLOR = 0xffffffff; 
    private float angle; 

    public PerformanceExample(Context context, AttributeSet attributeSet)
    { 
        super(context, attributeSet); 

        angle = 0.f; 
    } 

    /** 
     * This is precisely an example of what MUST be avoided. 
     * It is just to exemplify chapter 7\. 
     * 
     * DO NOT USE. 
     * 
     * @param canvas 
     */ 
    @Override 
    protected void onDraw(Canvas canvas) { 
        Bitmap bitmap = Bitmap.createBitmap(getWidth(), getHeight(), 
                        Bitmap.Config.ARGB_8888); 
           Rect rect = new Rect(0, 0, getWidth(), getHeight()); 
           Paint paint = new Paint(); 
           paint.setColor(BLACK_COLOR); 
           paint.setStyle(Paint.Style.FILL); 
           canvas.drawRect(rect, paint); 
           canvas.save(); 

           canvas.rotate(angle, getWidth() / 2, getHeight() / 2); 
           canvas.translate((getWidth() - getWidth()/4) / 2, 
                 (getHeight() - getHeight()/4) / 2); 

           rect = new Rect(0, 0, getWidth() / 4, getHeight() / 4); 
           paint = new Paint(); 
           paint.setColor(WHITE_COLOR); 
           paint.setStyle(Paint.Style.FILL); 
           canvas.drawBitmap(bitmap, 0, 0, paint); 
           canvas.drawRect(rect, paint); 
           canvas.restore(); 
           invalidate(); 
           bitmap.recycle(); 
           angle += 0.1f; 
       } 
    } 

在这个快速示例中,我们在onDraw()方法中分配了多件事情,从Paint对象到Rect对象,再到创建一个新的bitmap,这会分配内部内存。

如果我们运行这段代码,我们会在屏幕中央得到一个旋转的白色的矩形,如下面的截图所示:

图像

此外,我们不仅会得到一个类似的视图。如果我们在应用程序运行时检查 logcat 日志,我们可能会得到类似以下的行:

I art : Starting a blocking GC Explicit
I art : Explicit concurrent mark sweep GC freed 198893(13MB) AllocSpace objects, 30(656KB) LOS objects, 26% free, 43MB/59MB, paused 2.835ms total 313.353ms
I art : Background partial concurrent mark sweep GC freed 26718(2MB) AllocSpace objects, 1(20KB) LOS objects, 27% free, 43MB/59MB, paused 3.434ms total 291.430ms

应用程序执行期间,我们可能会多次获取它们。这是 Android 运行时(ART)的垃圾收集器介入,清理未使用的对象以释放内存。由于我们不断创建新对象,虚拟机将触发垃圾收集器来释放一些内存。

关于垃圾回收的更多信息可以在以下网址找到:

en.wikipedia.org/wiki/Garbage_collection_(computer_science)

幸运的是,Android Studio 已经非常明确地告诉我们,在我们的 onDraw() 方法内部我们正在做错误的事情:

它还告诉我们,如果不遵循这个建议,可能会造成什么后果。在这种情况下,如果在滚动或绘制过程中垃圾回收器启动,我们可能会遇到一些卡顿,或者一个平滑的动画可能看起来会跳跃或不那么流畅。

请在 GitHub 存储库的 Example30-Performance 文件夹中查看这个示例的完整源代码,不建议遵循它。请将其作为一个应该避免的示例。

代码优化

在考虑自定义视图中的性能时,分配对象不是我们应该考虑的唯一事情。我们应该考虑的计算量、计算类型、我们正在绘制的原始数量、过度绘制的数量以及我们应该检查的事情列表非常庞大。最终,大多数事情都是常识:只是不要重新计算我们已经拥有的值,并最大化如果不需要更改就可以跳过的代码部分,或者基本上,尽量重复使用尽可能多的之前帧已经计算过的内容。

让我们比较两种将 YUV 像素数据转换为 RGB 的方法。这并不是自定义视图中你必须做的最典型的事情,但它完美地展示了通过尽可能多地重复使用和不重新计算不需要的内容,性能会受到怎样的影响。

在 Android 中从摄像头取景器获取帧时,它们通常是 YUV 格式而不是 RGB。关于 YUV 的更多信息可以在以下网址找到:

YUV的相关信息可以在以下网址找到。

我们将从直接的代码开始,并逐步对其进行优化,以评估所有优化的影响:

private static void yuv2rgb(int width, int height, byte[] yuvData,
    int[] rgbData) { 
    int uvOffset = width * height; 
    for (int i = 0; i < height; i++) { 
         int u = 0; 
         int v = 0; 
         for (int j = 0; j < width; j++) { 
           int y = yuvData[i * width + j]; 
           if (y < 0) y += 256; 

           if (j % 2 == 0) { 
               u = yuvData[uvOffset++]; 
               v = yuvData[uvOffset++]; 
            } 

            if (u < 0) u += 256; 
            if (v < 0) v += 256; 

            int nY = y - 16; 
            int nU = u - 128; 
            int nV = v - 128; 

            if (nY< 0) nY = 0; 

            int nR = (int) (1.164 * nY + 2.018 * nU); 
            int nG = (int) (1.164 * nY - 0.813 * nV - 0.391 * nU); 
            int nB = (int) (1.164 * nY + 1.596 * nV); 

            nR = min(255, max(0, nR)); 
            nG = min(255, max(0, nG)); 
            nB = min(255, max(0, nB)); 

            nR&= 0xff; 
            nG&= 0xff; 
            nB&= 0xff; 

            int color = 0xff000000 | (nR<< 16) | (nG<< 8) | nB; 
            rgbData[i * width + j] = color; 
        } 
    } 
} 

这个版本基于以下网址找到的 YUV 到 RGB 转换器:

searchcode.com/codesearch/view/2393/

sourceforge.jp/projects/nyartoolkit-and/

我们在这里使用了浮点数版本,以便稍后我们可以看到与固定点版本的差异。

现在,让我们创建一个小的自定义视图,它将在每一帧中将 YUV 图像转换为 RGB,将其设置为 Bitmap,并在屏幕上绘制:

@Override 
protected void onDraw(Canvas canvas) { 
    yuv2rgb(imageWidth, imageHeight, yuvData, rgbData); 
    bitmap.setPixels(rgbData, 0, imageWidth, 0, 0, imageWidth,
    imageHeight); 

    canvas.drawBitmap(bitmap, 0.f, 0.f, null); 

    frames++; 
    invalidate(); 
} 

让我们也添加一段代码来检查我们的小代码能管理的每秒帧数。我们将使用这个测量来检查我们将要进行的优化对性能的提升:

if (timeStart == -1) { 
    timeStart = SystemClock.elapsedRealtime(); 
} else { 
    long tdiff = SystemClock.elapsedRealtime() - timeStart; 
    if (tdiff != 0) { 
        float fps = ((float) frames * 1000.f) / tdiff; 
        Log.d(TAG, "FPS: " + fps); 
    } 
} 

如果我们就这样在我的设备上运行这段代码,它测量到的每秒帧数是 1.20。使用的演示图片是1,000x1,500的图像。让我们看看我们能做些什么来改进它。

首先,我们可以移除一些不必要的计算:

private static void yuv2rgb(int width, int height, byte[] yuvData,
    int[] rgbData) { 
    int uvOffset = width * height; 
    int offset = 0; 
    for (int i = 0; i < height; i++) { 
        int u = 0; 
        int v = 0; 
        for (int j = 0; j < width; j++) { 
            int y = yuvData[offset]; 
            ... 
            rgbData[offset] = color; 

            offset++; 
        } 
    } 
} 

在这里,我们移除了两个像素位置的计算,而是通过每个像素的单个增量来完成。在之前的情况下,无论是读取yuvData还是写入rgbData,都会进行i * width + j的计算。如果我们检查这个更改后的每秒帧数计数器,我们会注意到它略微增加到了 1.22。虽然提升不大,但这是一个开始。

现在,我们可以看到在原始实现中,即 Android SDK 中使用的方法,浮点运算被注释掉了,取而代之的是定点运算。浮点运算通常比整数运算成本更高。尽管这些年随着新硬件的出现,浮点运算的性能有了很大的提升,但整数运算仍然更快。我们无法获得与浮点运算相同的精度,但通过使用定点运算,我们可以得到相当好的近似值。

关于定点运算的更多信息可以在以下 URL 找到:

定点运算的相关信息可以在以下链接找到。

使用定点运算时,我们必须定义一个整数值的位数,这将用作定点精度。剩余的位数将用于实际存储整数值。显然,我们用于存储的位数越多,精度就越高,但另一方面,用于存储整数值的位数就越少。想法是将所有常数和操作乘以 2 的幂次数,在完成所有操作后,将结果除以相同的数。由于它是 2 的幂,我们可以轻松地进行快速的位运算右移操作,而不是昂贵的除法。

例如,如果我们使用 10 位的定点精度,我们必须将所有值乘以1,024或左移 10 位,在所有计算结束时,执行 10 位的右移操作。

让我们把这些操作应用到这里:

int nR = (int) (1.164 * nY + 2.018 * nU); 
int nG = (int) (1.164 * nY - 0.813 * nV - 0.391 * nU); 
int nB = (int) (1.164 * nY + 1.596 * nV); 

我们将它们转换为以下形式:

int nR = (int) (1192 * nY + 2066 * nU); 
int nG = (int) (1192 * nY - 833 * nV - 400 * nU); 
int nB = (int) (1192 * nY + 1634 * nV); 

我们可以检查1.164 * 1,024 是向上取整的1192,其他所有常数也同样处理——我们四舍五入数字以获得最有效的近似值。

出于同样的原因,我们必须更改以下检查:

nR = min(255, max(0, nR)); 
nG = min(255, max(0, nG)); 
nB = min(255, max(0, nB)); 

我们必须将带有255255乘以1,024*的检查,左移10位:

nR = min(255 << 10, max(0, nR)); 
nG = min(255 << 10, max(0, nG)); 
nB = min(255 << 10, max(0, nB)); 

在输出颜色之前,先除以1,024或右移10位使用这些值:

nR>>= 10; 
nG>>= 10; 
nB>>= 10; 

实施这些更改后,即使与浮点版本相比我们增加了一些操作,但每秒帧数计数器提高到了1.55

另一个小优化是我们可以避免计算每个分量的亮度因子,因为在每种情况下它都是相同的。所以让我们替换这段代码:

int nR = (int) (1192 * nY + 2066 * nU); 
int nG = (int) (1192 * nY - 833 * nV - 400 * nU); 
int nB = (int) (1192 * nY + 1634 * nV); 

对于这个只计算一次亮度的版本:

int luminance = 1192 * nY; 
int nR = (int)(luminance + 2066 * nU); 
int nG = (int)(luminance - 833 * nV - 400 * nU); 
int nB = (int)(luminance + 1634 * nV); 

这应该会被大多数编译器优化;我不确定新的编译器 D8 和 R8 会做什么,但使用当前的 Java/Android 工具链,它并没有被优化。通过这个小小的改动,我们将每秒帧数计数器提升到了1.59

这种 YUV 文件格式的工作方式是,一对UV色度值被两个亮度值共享,所以让我们尝试利用这一点同时计算两个像素,避免额外的检查和代码开销:

for(int j = 0; j < width; j += 2) {
   int y0 = yuvData[offset]; 
   if (y0 < 0) y0 += 256; 

   int y1 = yuvData[offset + 1]; 
   if (y1 < 0) y1 += 256; 

   u = yuvData[uvOffset++]; 
   v = yuvData[uvOffset++]; 
   if (u < 0) u += 256; 
   if (v < 0) v += 256; 

   int nY0 = y0 - 16; 
   int nY1 = y1 - 16; 
   int nU = u - 128; 
   int nV = v - 128; 

   if (nY0 < 0) nY0 = 0; 
   if (nY1 < 0) nY1 = 0; 

   int chromaR = 2066 * nU; 
   int chromaG = -833 * nV - 400 * nU; 
   int chromaB = 1634 * nV; 

   int luminance = 1192 * nY0; 
   int nR = (int) (luminance + chromaR); 
   int nG = (int) (luminance + chromaG); 
   int nB = (int) (luminance + chromaB); 

   nR = min(255 << 10, max(0, nR)); 
   nG = min(255 << 10, max(0, nG)); 
   nB = min(255 << 10, max(0, nB)); 

   nR>>= 10; 
   nG>>= 10; 
   nB>>= 10; 

   nR&= 0xff; 
   nG&= 0xff; 
   nB&= 0xff; 

   rgbData[offset] = 0xff000000 | (nR<< 16) | (nG<< 8) | nB; 

   luminance = 1192 * nY1; 
   nR = (int) (luminance + chromaR); 
   nG = (int) (luminance + chromaG); 
   nB = (int) (luminance + chromaB); 

   nR = min(255 << 10, max(0, nR)); 
   nG = min(255 << 10, max(0, nG)); 
   nB = min(255 << 10, max(0, nB)); 

   nR>>= 10; 
   nG>>= 10; 
   nB>>= 10; 

   nR&= 0xff; 
   nG&= 0xff; 
   nB&= 0xff; 

   rgbData[offset + 1] = 0xff000000 | (nR<< 16) | (nG<< 8) | nB; 

   offset += 2; 
} 

现在我们只计算一次色度分量,并且移除了检查,只在每两个像素获取新的UV分量。进行这些更改后,我们的每秒帧数计数器提升到了1.77

由于 Java 字节范围从-128 到 127,我们添加了一些对负数的检查,但我们可以通过快速进行按位与操作(&)来代替这些检查:

for (int i = 0; i < height; i++) { 
    for (int j = 0; j < width; j += 2) { 
      int y0 = yuvData[offset    ] & 0xff; 
      int y1 = yuvData[offset + 1] & 0xff; 

      int u = yuvData[uvOffset++] & 0xff; 
      int v = yuvData[uvOffset++] & 0xff; 

        ... 
   } 
} 

这个小小的改动将我们的每秒帧数计数器略微提升到了1.83。但我们还可以进一步优化。我们使用了10位固定小数点精度的算术,但在这个特定情况下,我们可能使用8位精度就足够了。从10位精度改为仅8位将节省我们一个操作步骤:

for (int i = 0; i < height; i++) { 
  for (int j = 0; j < width; j += 2) { 
        ... 
    int chromaR = 517 * nU; 
    int chromaG = -208 * nV - 100 * nU; 
    int chromaB = 409 * nV; 

    int lum = 298 * nY0; 

    nR = min(65280, max(0, nR)); 
    nG = min(65280, max(0, nG)); 
    nB = min(65280, max(0, nB)); 

    nR<<= 8; 
    nB>>= 8; 

    nR&= 0x00ff0000; 
    nG&= 0x0000ff00; 
    nB&= 0x000000ff; 

    rgbData[offset] = 0xff000000 | nR | nG | nB; 

        ... 

    offset += 2; 
   } 
} 

我们将所有常量更新为乘以256而不是1,024,并更新了检查。代码中出现的常数65280255乘以256。在我们将值位移以获取实际颜色分量的代码部分,我们必须将红色分量右移8位,然后左移16位以调整到 ARGB 在颜色分量中的位置,这样我们只需进行一次8位左移的单一位移操作。在绿色坐标上甚至更好——我们需要将其右移8位然后左移8位,因此我们可以保持原样,不进行任何位移。我们仍然需要将蓝色分量右移8位。

我们还必须更新掩码,以确保每个分量保持在 0-255 的范围内,但现在掩码已经右移到了正确的位位置0x00ff00000x0000ff000x000000ff

这个改变将我们的每秒帧数计数器略微提升到了1.85,但我们还可以做得更好。让我们尝试去掉所有的位移、检查和掩码操作。我们可以通过使用一些预先计算的表格来实现,这些表格在我们自定义视图创建时计算一次。让我们创建这个函数来预先计算我们需要的一切:

private static int[] luminance; 
private static int[] chromaR; 
private static int[] chromaGU; 
private static int[] chromaGV; 
private static int[] chromaB; 

private static int[] clipValuesR; 
private static int[] clipValuesG; 
private static int[] clipValuesB; 

private static void precalcTables() {
    luminance = new int[256];
    for (int i = 0; i <luminance.length; i++) {
        luminance[i] = ((298 * (i - 16)) >> 8) + 300;
    }
    chromaR = new int[256]; 
    chromaGU = new int[256]; 
    chromaGV = new int[256]; 
    chromaB = new int[256]; 
    for (int i = 0; i < 256; i++) {
       chromaR[i] = (517 * (i - 128)) >> 8;
       chromaGU[i] = (-100 * (i - 128)) >> 8;
       chromaGV[i] = (-208 * (i - 128)) >> 8;
       chromaB[i] = (409 * (i - 128)) >> 8;
    }

    clipValuesR = new int[1024]; 
    clipValuesG = new int[1024]; 
    clipValuesB = new int[1024]; 
    for (int i = 0; i < 1024; i++) { 
       clipValuesR[i] = 0xFF000000 | (min(max(i - 300, 0), 255) << 16); 
       clipValuesG[i] = min(max(i - 300, 0), 255) << 8; 
       clipValuesB[i] = min(max(i - 300, 0), 255); 
    } 
} 

我们正在计算luminance(亮度)的所有色度分量以及最后所有内容的剪辑、移位和遮罩值。由于luminance和某些色度可能是负数,我们在luminance值中添加了+300,因为它将加到所有值上,然后调整clipValues表以考虑这个300的偏移量。否则,我们可能会尝试用负索引来索引数组,这将导致我们的应用程序崩溃。在访问数组之前检查索引是否为负将消除所有性能优化,因为我们尽可能想要摆脱所有操作和检查。

使用这些表格,我们的 YUV 到 RGB 转换器代码减少到以下内容:

private static void yuv2rgb(int width, int height, byte[] yuvData,
    int[] rgbData) { 
    int uvOffset = width * height; 
    int offset = 0; 

    for (int i = 0; i < height; i++) { 
        for (int j = 0; j < width; j += 2) { 
        int y0 = yuvData[offset ] & 0xff; 
        int y1 = yuvData[offset + 1] & 0xff; 

        int u = yuvData[uvOffset++] & 0xff; 
        int v = yuvData[uvOffset++] & 0xff; 

        int chR = chromaR[u]; 
        int chG = chromaGV[v] + chromaGU[u]; 
        int chB = chromaB[v]; 

        int lum = luminance[y0]; 
        int nR = clipValuesR[lum + chR]; 
        int nG = clipValuesG[lum + chG]; 
        int nB = clipValuesB[lum + chB]; 

        rgbData[offset] = nR | nG | nB; 

        lum = luminance[y1]; 
        nR = clipValuesR[lum + chR]; 
        nG = clipValuesG[lum + chG]; 
        nB = clipValuesB[lum + chB]; 

        rgbData[offset + 1] = nR | nG | nB; 

        offset += 2; 
       } 
    } 
} 

进行这些更改后,我们获得了每秒2.04帧的速度计数,或者与原始方法相比性能提升了70%。无论如何,这只是一个代码如何优化的示例;如果你真的想要实时将 YUV 图像转换为 RGB,我建议你检查一下本地 C 或 C++ 的实现,或者采用 GPU 或渲染脚本的方法。

最后,如果我们运行这个应用程序,我们将得到一个类似于以下截图的屏幕。我们没有对图像进行缩放或应用任何额外的转换,因为我们只想测量从 YUV 图像转换为 RGB 图像所需的时间。你的屏幕图像可能会因屏幕大小和设备的不同而有所不同:

在 GitHub 仓库的Example31-Performance文件夹中查看整个示例源代码。

在谈论性能时,还有很多其他事情需要考虑。如果你想了解更多关于 Java 代码如何转换为 dex 字节码并在 Android VM 中执行的信息,请查看以下演示:

字节码之谜

模拟预览窗口

当在 Android Studio 中预览我们的自定义视图时,有时计算可能会非常复杂,或者例如我们需要初始化一些数据,但我们不能在 Android Studio 的预览窗口中显示我们的自定义视图时这样做。通过检查 isInEditMode() 方法,我们将能够对此进行处理。

如果我们处于 IDE 或开发工具内部,这个方法将返回 true。知道了这个信息,我们可以轻松地模拟一些数据,或者简化渲染,只显示我们想要绘制的内容的预览。

例如,在 GitHub 仓库中的Example07-BuilderPattern文件夹里,我们在自定义视图创建时调用这个方法来改变渐变中使用的颜色值,尽管实际上我们也可以在onDraw()方法中调用它,来改变视图的渲染效果:

总结

在本章中,我们已经了解了不遵循性能建议的影响,以及在我们实现自定义视图时为何有一套最佳实践和应避免的事项。我们还学习了如何改进或优化代码以提高性能,以及如何调整或自定义视图以在 Android Studio IDE 预览窗口中渲染预览。

正如我们将在下一章看到的,无论我们的自定义视图是被其他人使用还是被我们自己使用,都不应该有任何区别。它不应该因为自身的问题导致使用它的应用程序崩溃或行为异常。就像包含第三方库一样,它绝不应该让我们的应用程序崩溃,否则,我们很可能会停止使用它并用另一个库来替代。

因此,在下一章中,我们不仅将学习如何应用这些建议,还将学习如何使我们的自定义视图在多个应用中可复用,以及如何分享或开源它,以便在 Android 社区内广泛使用。

第八章:分享我们的自定义视图

在前面的章节中,我们已经构建了我们的自定义视图,或者其中许多。我们已经了解了如何与它们互动,如何绘制 2D 和 3D 原始图形,现在我们希望其他人也能使用它。这是一个很好的想法!这可能是为了我们自己,我们可能会在未来的项目中重用,或者可能是我们同事的一个项目。如果我们目标更高,它可能是 Android 社区的一个项目。

让 Android 社区变得出色的一件事是有大量的开源库。开发者们的所有这些贡献帮助许多其他开发者开始了 Android 开发,深入理解某些概念,或者能够首先构建他们的应用程序。

首先,发布你的自定义视图,或者一个 Android 库,是贡献给这个惊人社区的方法之一。其次,这样做是宣传自己、展示雇主的开放性以及吸引公司人才的好方法。

在本章中,我们将了解如果想要分享我们的自定义视图应该考虑什么,以及如何做到这一点。我们还将实践一些在前面章节中给出的重要建议。更重要的是,我们希望其他开发者能使用我们的自定义视图。

更详细地说,我们将涵盖以下主题:

  • 建议和最佳实践

  • 发布你的自定义视图

几乎所有给出的建议不仅适用于自定义视图,也适用于我们想要分享或希望让同事或其他项目可重用的任何 Android 库。

分享自定义视图的最佳实践

尽管我们只是在为自己或一个小型应用构建自定义视图或组件,我们也应该始终追求尽可能高的质量。然而,如果我们想要分享我们的自定义视图,让其他人也能使用它,我们需要考虑一些额外的检查和最佳实践。如果我们目标是让尽可能多的开发者在他们的应用中使用它或为它贡献,那么如果我们忽视这些建议,将很难吸引他们参与。

考虑事项和建议

我们应该考虑的一件事是,一旦我们分享了自定义视图,它可能会被许多 Android 应用使用。如果我们的自定义视图有错误并且崩溃了,它将导致使用它的应用崩溃。应用的用户不会认为是自定义视图的问题,而是应用本身的问题。应用开发者可能会尝试提出问题,甚至提交一个 pull 请求来修复它,但如果自定义视图给他们带来太多麻烦,他们只会替换它。

这也适用于你自己的应用程序;你不想使用一个不稳定的组件或自定义视图,因为你可能最终要重写它或修补它。正如我们刚刚提到的,我们应始终追求最高质量。如果我们的自定义视图只在一个应用程序中使用,那么在生产阶段或应用程序发布到商店时发现一个关键问题的影响只影响一个应用程序。但是,如果它在多个应用程序中使用,维护的影响和成本就会增加。你可以想象,在开源组件中发现一个高度关键的问题,并不得不为所有使用它的应用程序发布新版本的影响。

此外,你应该尽量保持代码干净、组织有序、测试充分且文档合理。这对于你以及如果你在公司分享自定义视图的同事来说,将更容易维护自定义视图。如果它是开源的,这将鼓励贡献,并且实际上不会吓跑外部贡献者。与其他许多事情一样,常识适用。不要过度文档化你的自定义视图,因为基本上没人会去读它;尽量保持简单明了,直击要点。

在以下截图中,我们可以看到retrofit库的开放问题,这是一个在许多应用程序中广泛使用的开源 Android 库:

同时,我们可以看到有几位开发者提交了许多拉取请求,他们要么在修复问题,要么在添加功能或特性。以下截图是提交给retrofit库的一个拉取请求示例:

我们之前已经提到过,但自定义视图的行为正确也很重要。它不仅必须保证不崩溃,还必须在多种设备和分辨率下正常工作,并且具有良好的性能。

我们可以用以下要点总结建议列表:

  • 稳定

  • 在多种设备和分辨率下工作

  • 性能优良

  • 应用最佳代码实践和标准风格开发

  • 文档齐全且易于使用

可配置

在第二章,实现你的第一个自定义视图中,我们解释了如何参数化自定义视图。我们创建它是因为它可能服务于一个非常具体的目的,但一般来说,它配置得越灵活,就越有可能在其他地方被使用。

想象一下我们正在构建一个进度条。如果我们的自定义视图总是绘制一个水平红色条,它会有其用途,但不会太多,因为它太具体了。如果我们允许使用这个自定义视图的应用程序的开发者自定义条的颜色,我们就会为它增加几个其他用例。此外,如果我们还允许开发者配置背景颜色或者绘制水平条之外的哪种原始图形,我们的自定义视图将涵盖更多不同的场景。

我们也需要注意;添加太多选项也会增加代码和组件本身的复杂性。配置颜色是直接的,影响并不大,但例如能够更改绘图原语可能稍微有点复杂。增加复杂性可能会影响性能、稳定性,以及我们在发布或制作新版本时测试和验证所有场景是否正常工作的能力。

发布我们的自定义视图

一旦我们对自定义视图及其现状感到满意,我们就可以准备分享了。如果我们也遵循了最佳实践和推荐,我们可能会更有信心。即使没有,最好的学习方式就是尽快从社区获得反馈。不要害怕犯错误;你会在过程中学到东西的。

发布自定义视图的方法有很多:我们可以选择开源,例如,或者我们可以只发布编译后的二进制文件作为 SDK 或 Android 库。以上大多数建议针对的是开源方法或内部重用,无论是为了自己还是同事,但其中许多(并非全部)也适用于你的目标是发布一个封闭的 SDK 或只作为库发布编译后的二进制文件。

开源我们自定义的视图

开源一个自定义视图或者,作为替代,一个 Android 库,是相当简单和直接的。你需要确保你执行了一些额外的步骤,但整个过程非常简单。

我们一直在使用 GitHub 分享本书示例的源代码。这并非巧合。GitHub 是分享源代码、开源库和项目最广泛使用的工具之一。它也是我们将在本章推荐并使用的工具,来解释如何发布我们的自定义视图。

首要任务是,如果我们还没有 GitHub 账户,就需要注册并创建一个。只要我们只想托管公开的仓库或公开可访问的代码,创建账户是免费的。如果我们想要用它来存储私有代码仓库,就有付费选项。就本书的范围而言,免费选项已经足够了。

我们可以直接从主页注册:www.github.com 或者从以下链接:

加入 GitHub

创建账户后,我们创建一个代码仓库来存储代码。我们可以在以下位置进行操作:

新建 GitHub 仓库。如下截图所示:

(图片无需翻译,直接复制原文)

我们必须选择一个仓库名称。强烈建议添加描述,这样其他人更容易理解我们的组件或库的功能。我们还可以选择添加一个 .gitignore 文件和许可证。

.gitignore是一个非常有用的文件。这里提到的所有文件都不会上传到 GitHub。例如,没有必要上传所有临时文件、构建文件、中间构建文件或 Android Studio 的配置文件,这些文件包含有关项目的特定信息,仅保存在我们的本地计算机上。例如,知道我们将项目存储在\Users\raimon\development\AndroidCustomView没有任何用。

添加许可证对于确定我们授予使用源代码者的权利非常重要。开源项目中最常见的许可证有 Apache 2.0、MIT 和 GPLv3 许可证:

  • MIT 是最少限制和最宽容的许可证。只要其他方在使用源代码时包含许可证和版权声明,就可以以任何方式使用源代码。

  • Apache 2.0 许可证同样非常宽容。与 MIT 许可证一样,只要其他方在使用源代码时包含许可证和版权声明,并说明对原始文件的更改,就可以以任何方式使用源代码。

  • GPLv3 稍微严格一些,因为它要求任何使用你源代码的人必须按照相同的许可证发布使用该源代码的应用程序源代码。这对于一些希望保留源代码知识产权的公司来说可能是一种限制。

这三种许可证都限制了原始开发者的责任,并不提供任何担保。它们都是将软件或源代码“按现状”提供。

许多 Android 库使用 MIT 或 Apache 2.0 许可证,我们建议您的自定义视图也使用这两个许可证之一。

仓库创建并初始化后,我们可以上传代码。我们可以使用任何偏好的 Git 客户端,或者直接使用命令行界面。

首先,我们克隆刚才创建的仓库——仅作为参考,并非真实的仓库:

raimon$ git clone https://github.com/rrafols/androidcustomview.git 
Cloning into 'androidcustomview'... 
remote: Counting objects: 5, done. 
remote: Compressing objects: 100% (4/4), done. 
remote: Total 5 (delta 0), reused 0 (delta 0), pack-reused 0 
Unpacking objects: 100% (5/5), done. 

检查连接。完成。

如果我们已经有了一个包含源代码的目录,Git 会报错,无法创建目录:

raimon$ git clone https://github.com/rrafols/androidcustomview.git 

fatal: destination path androidcustomview already exists and is not an empty directory.

在这种情况下,我们必须使用不同的方法。首先,我们必须初始化本地仓库:

androidcustomview raimon$ gitinit 
Initialized empty Git repository in /Users/raimon/dev/androidcustomview/.git/ 

然后添加远程仓库:

androidcustomview raimon$ git remote add origin https://github.com/rrafols/androidcustomview.git 

最后,从主分支拉取内容:

androidcustomview raimon$ git pull origin master 
remote: Counting objects: 5, done. 
remote: Compressing objects: 100% (4/4), done. 
remote: Total 5 (delta 0), reused 0 (delta 0), pack-reused 0 
Unpacking objects: 100% (5/5), done. 
From https://github.com/rrafols/androidcustomview 
 * branch            master     -> FETCH_HEAD 
 * [new branch]      master     -> origin/master 

现在我们可以添加所有希望添加到 GitHub 仓库的文件。在这个例子中,我们将添加所有内容,Git 会自动忽略与.gitignore文件中模式匹配的文件:

androidcustomview raimon$ git add *

现在我们可以将改动提交到本地仓库。一定要使用有意义的提交信息或描述,因为这将有助于以后了解都更改了什么。

androidcustomview raimon$ git commit -m "Adding initial files" 
[master bc690c7] Adding initial files 
 6 files changed, 741 insertions(+) 

完成这些操作后,我们就可以将提交推送到远程仓库,本例中的远程仓库位于github.com/

androidcustomview raimon$ git push origin master 
Username for 'https://github.com': rrafols 
Password for 'https://rrafols@github.com':  
Counting objects: 9, done. 
Delta compression using up to 4 threads. 
Compressing objects: 100% (8/8), done. 
Writing objects: 100% (8/8), 6.06 KiB | 0 bytes/s, done. 
Total 8 (delta 3), reused 0 (delta 0) 
remote: Resolving deltas: 100% (3/3), done. 
To https://github.com/rrafols/androidcustomview.git 
343509f..bc690c7 master -> master

若要了解更多关于 Git 的信息,请访问:

en.wikipedia.org/wiki/Git.

创建仓库时,GitHub 会询问我们是否要创建一个README.md文件。这个README.md文件将显示在仓库页面上作为文档。它使用 markdown 格式,这就是扩展名为.md的原因,并且重要的是要将其与项目信息保持同步,包括如何使用、一个快速示例、以及关于许可和作者的信息。这里最重要的部分是,任何想要使用你的自定义视图的人都可以快速查看如何操作,许可是否合适,以及如何联系你寻求支持和帮助。这部分是可选的,因为他们总是可以在 GitHub 上提出问题,但这样更好。我们甚至可以直接从以下位置编辑和预览更改:

github.com/

不仅要保持文档更新,保持库的维护和更新也很重要。有一些需要解决的错误,需要添加的新功能,新的 Android 版本可能会破坏、弃用、改进或添加新的方法,以及其他开发者提出问题或询问。当寻找自定义视图或 Android 库时,如果没有最近的更新,或者至少在过去的几个月内没有,它看起来像是被遗弃了,这大大降低了其他人使用它的机会。

创建二进制工件

我们一直在谈论共享自定义视图和 Android 库,好像它们是同一回事。分享自定义视图最合适的方式是作为 Android 库。Android 应用程序和 Android 库之间的主要区别在于,后者不能在设备或模拟器上独立运行,并且只会生成一个.aar文件。这个.aar文件稍后可以作为依赖项添加到 Android 应用程序项目或其他库中。我们还可以在同一个项目内拥有子模块,并且它们之间可以有依赖关系。为了了解这是如何工作的,我们将把自定义视图项目转换成 Android 库,并且将添加一个测试应用程序项目以快速测试它。

首先,一旦我们有了 Android 应用程序,我们可以通过执行两个简单的步骤将其转换为库:

  1. 在 app 模块的build.gradle文件中删除提到applicationId的行。

  2. 将应用的插件从com.android.application更改为com.android.library

基本上更改以下内容:

apply plugin: 'com.android.application'

android {
   compileSdkVersion 25
   buildToolsVersion"25.0.2"

   defaultConfig {
       applicationId"com.rrafols.packt.customview"
       minSdkVersion 21
       targetSdkVersion 25
       versionCode 1
       versionName"1.0"

更改为以下内容:

apply plugin: 'com.android.library'

android {
   compileSdkVersion 25
   buildToolsVersion"25.0.2"

    defaultConfig {
       minSdkVersion 21
       targetSdkVersion 25
       versionCode 1
       versionName"1.0"

在我们的示例中,还将应用模块名称重构为 lib。

关于如何将 Android 应用程序转换为 Android 库的更多信息可以在开发者 Android 文档页面找到:

developer.android.com/studio/projects/android-library.html

如果我们正在开发或扩展这个库,我们建议在项目中添加一个新的模块作为测试应用程序。这将大大加快自定义视图的开发和测试速度。

我们可以使用 Android Studio 文件菜单添加一个新模块:文件 | 新建 | 新模块:

图片

添加测试应用模块后,我们向库添加一个依赖项。在新模块的build.gradle文件中,添加对本地库模块的依赖:


dependencies {
    compile project(":lib")
    compile fileTree(dir: 'libs', include: ['*.jar'])
    androidTestCompile('com.android.support.test.espresso:espresso-core:2.2.2',
    {
        exclude group: 'com.android.support', module: 'support-annotations'
    })

    compile 'com.android.support:appcompat-v7:25.3.1'
    compile 'com.android.support.constraint:constraint-layout:1.0.2'
    testCompile'junit:junit:4.12'
}

现在,你可以将自定义视图添加到这个新的测试应用布局中并测试它。此外,我们还可以生成一个库二进制文件以供分发。它只包含库或 lib 模块。我们可以通过在 gradle 上执行lib:assembleRelease任务来实现:

Example32-Library raimon$ ./gradlew lib:assembleRelease 

我们可以在项目的lib/build/outputs/aar/lib-release.aar文件夹中获取.aar文件。使用lib:assembleDebug任务,我们将生成调试库,或者简单地使用lib:assembleDebug来获取调试和发布版本。

你可以以任何你喜欢的方式发布二进制文件,但一个建议是上传到构件平台。许多公司都在使用内部构件或软件仓库来存储企业库和一般的构件,但如果你想要向更广泛的公众开放,你可以上传到例如JCenter。如果我们检查任何 Android 项目中的最顶层的build.gradle文件,我们会看到有一个依赖于JCenter来查找库的依赖项:

... 
repositories {
    jcenter()
}

我们可以通过 Bintray 轻松完成此操作,例如:bintray.com。注册后,我们可以创建项目,从 GitHub 导入它们,创建发布和版本,如果我们的项目被接受,甚至可以发布到JCenter

要获取有关 Bintray gradle 插件的更多信息,请访问:

关于 bintray 的 gradle 插件更多信息

为了简化我们的工作,有一些开源示例和代码可以使这个过程变得简单得多。但首先,让我们在 Bintray 上创建一个仓库。

我们将其命名为AndroidCustomView,将其设置为 Maven 仓库,并添加默认的 Apache 2.0 许可证:

图片

拥有了它之后,我们可以创建版本,或者直接从我们的 gradle 构建脚本中添加。为此,我们必须向最顶层的build.gradle添加一些依赖项:

buildscript {
    repositories {
        jcenter()
    }

    dependencies {
        classpath'com.android.tools.build:gradle:2.3.0'
        classpath'com.jfrog.bintray.gradle:gradle-bintrayplugin:1.4'classpath'com.github.dcendents:android-maven-gradleplugin:1.4.1'
    }
}

现在我们可以利用一些已经创建的开源 gradle 构建脚本。我们不需要复制粘贴或向我们的构建脚本中添加更多代码,可以直接从 GitHub 应用它。让我们在库build.gradle文件的最后添加这两行:

... 
apply from: 'https://raw.githubusercontent.com/nuuneoi/JCenter/master/installv1.gra
 dle' 
apply from: 'https://raw.githubusercontent.com/nuuneoi/JCenter/master/bintrayv1.gra
 dle' 

应用了这两个 gradle 构建脚本之后,我们最终会得到一个额外的任务:bintrayUpload。我们需要首先添加构件配置,所以在库模块build.gradle文件的 apply 库行后面最前面添加它:

apply plugin: 'com.android.library'

ext {
    bintrayRepo = 'AndroidCustomView'
    bintrayName = 'androidcustomview'
    publishedGroupId = 'com.rrafols.packt'
    libraryName = 'AndroidCustomView'
    artifact = 'androidcustomview'
    libraryDescription = 'Uploading libraries example.'
    siteUrl = 'https://github.com/rrafols/AndroidCustomView'
    gitUrl = 'https://github.com/rrafols/androidcustomview.git'
    libraryVersion = '1.0.0'
    developerId = 'rrafols'
    developerName = 'Raimon Ràfols'
    developerEmail = ''
    licenseName = 'The Apache Software License, Version 2.0'
    licenseUrl = 'http://www.apache.org/licenses/LICENSE-2.0.txt'
    allLicenses = ["Apache-2.0"]
}

我们需要将 Bintray 用户和 API 密钥信息添加到我们的local.properties文件中:

bintray.user=rrafols 
bintray.apikey=<key - can be retrieved from the edit profile option on bintray.com> 

bintrayRepo变量必须与我们要存储二进制文件的仓库相匹配,否则构建脚本将失败。

现在我们已经完成了所有配置,我们可以使用./gradlew install 构建库的新版本,并使用./gradlew bintrayUpload上传到 Bintray。

请记住,版本一旦被上传后就是只读的,因此我们将无法覆盖它们,除非我们更新版本号并上传不同的版本,否则在执行我们的 gradle 脚本时将会出现错误。

一旦我们上传了一个版本,我们将看到类似下面的屏幕:

我们还可以检查已上传版本中的文件,以了解已上传了哪些内容。如果我们进入某个版本,并点击文件菜单,我们会看到.aar的 Android 库文件以及脚本为我们上传的所有其他文件。

如我们所见,它还打包并上传了源代码、Javadoc并创建了一个.pom文件,因为它是作为 Maven 仓库托管的。

完成所有这些步骤后,我们可以直接从构件仓库页面通过点击添加到 JCenter 将库上传到JCenter。一旦获得批准,任何想要使用我们库的人只需在com.rrafols.packt.androidcustomview上定义一个依赖项,就可以直接从JCenter获取。

要了解关于这个过程以及这些 gradle 构建脚本的作者更多信息,请访问:

inthecheesefactory.com/blog/how-to-upload-library-to-jcenter-maven-central-as-dependency/en

我们还没有提到但同样重要的是,如何对我们的库进行版本控制。每当我们创建一个新的发布版本时,都会创建一个版本号。强烈建议在为自定义视图版本控制时使用语义版本MAJOR.MINOR.PATCH。这样我们可以轻松地指示一个版本中的更改是否引入了不兼容性。例如,使用语义版本控制,如果我们更改了主要版本号,就表示我们引入了与先前版本的不兼容性;或者通过更改次要版本号,表示我们添加了新功能但没有引入任何不兼容性。这对于使用我们库的第三方或其他开发人员来说非常重要,这样他们可以知道从一个版本到下一个版本应该期待什么。

若要了解更多关于语义版本控制的信息,请访问:

semver.org/

也请查看此示例的完整源代码,位于 GitHub 仓库中的Example32-Library文件夹。

摘要

在本章中,我们了解了分享我们的自定义视图的建议以及如何实际操作。开源我们的自定义视图或在公司内部分享它们有很多好处。我们不仅会更关注质量和细节,而且还将促进合作并丰富 Android 开发者社区。

在接下来的章节中,我们将学习如何把我们最近几章所涵盖的所有信息综合起来,构建一些更复杂的自定义视图,以便在我们的应用程序中直接使用和包含。

第九章:实现你自己的电子节目指南(EPG)

到目前为止,我们一直在构建一些非常基础的示例,以展示 Android 为我们提供的实现和绘制自定义视图的功能和方法。在本章中,我们将看到一个更复杂的自定义视图示例。我们将构建一个电子节目指南EPG)。

EPG 是一个相当复杂的组件,如果构建不当,可能会影响用户体验。例如,如果它性能不佳,使用起来会感觉迟缓和繁琐。

我们将使用我们在前面章节中已经介绍过的几件事情。所有这些可能会有些多,但我们会一步一步地构建它,并且会更详细地介绍:

  • 如何构建一个基本的 EPG 自定义视图

  • 如何添加基本的动画和交互

  • 如何允许缩放

  • 使其可配置

构建 EPG

如果我们想让我们的 EPG 更有用,它应该能同时显示多个频道,以及当前和未来的电视节目。同时,清晰地看到当前正在播放的内容,并有明确的指示其他电视节目的开始和结束时间会很好。

在这个特定的组件中,我们将选择一种涵盖这些点的渲染方法。你可以把它作为一个例子,但还有许多其他方式来渲染同类的信息。同时,它不会连接到一个提供 EPG 数据的后端服务。所有的 EPG 数据都将被模拟,但可以轻松连接到任何服务,尽管可能需要进行一些更改。

EPG 基础和动画设置

我们将从创建一个扩展视图的类开始。在其onDraw()方法中,我们将绘制以下部分:

  • 视图背景

  • 包含所有频道和电视节目的 EPG 主体

  • 一个顶部的时间条提示时间

  • 一条垂直线表示当前时间

如果我们有一些变量动画,我们还需要触发重绘周期。

所以,让我们开始实现这个onDraw()方法,并且一步一步地按照方法进行:

@Override 
protected void onDraw(Canvas canvas) { 
   animateLogic(); 

   long currentTime = System.currentTimeMillis(); 

   drawBackground(canvas); 
   drawEPGBody(canvas, currentTime, frScrollY); 
   drawTimeBar(canvas, currentTime); 
   drawCurrentTime(canvas, currentTime); 

   if (missingAnimations()) invalidate(); 
} 

最容易实现的方法将是drawBackground()

private static final int BACKGROUND_COLOR = 0xFF333333; 
private void drawBackground(Canvas canvas) { 
    canvas.drawARGB(BACKGROUND_COLOR >> 24,  
            (BACKGROUND_COLOR >> 16) & 0xff, 
            (BACKGROUND_COLOR >> 8) & 0xff,  
            BACKGROUND_COLOR & 0xff); 
} 

在这个例子中,我们定义了一个背景颜色为0xFF333333,这是一种深灰色,我们只是用drawARGB()调用填充整个屏幕,遮罩和移动颜色组件。

现在,让我们来看看drawTimeBar()方法:

private void drawTimeBar(Canvas canvas, long currentTime) { 
    calendar.setTimeInMillis(initialTimeValue - 120 * 60 * 1000); 
    calendar.set(Calendar.MINUTE, 0); 
    calendar.set(Calendar.SECOND, 0); 
    calendar.set(Calendar.MILLISECOND, 0); 

    long time = calendar.getTimeInMillis(); 
    float x = getTimeHorizontalPosition(time) - frScrollX + getWidth()
             / 4.f; 

    while (x < getWidth()) { 
        if (x > 0) { 
            canvas.drawLine(x, 0, x, timebarHeight, paintTimeBar); 
        } 

        if (x + timeBarTextBoundaries.width() > 0) { 
            SimpleDateFormat dateFormatter = 
                    new SimpleDateFormat("HH:mm", Locale.US); 

            String date = dateFormatter.format(new Date(time)); 
            canvas.drawText(date, 
                    x + programMargin, 
                    (timebarHeight - timeBarTextBoundaries.height()) /
                    2.f + timeBarTextBoundaries.height(),paintTimeBar); 
        } 

        time += 30 * 60 * 1000; 
        x = getTimeHorizontalPosition(time) - frScrollX + getWidth() /
            4.f; 
    } 

    canvas.drawLine(0, 
            timebarHeight, 
            getWidth(), 
            timebarHeight, 
            paintTimeBar); 
} 

让我们解释一下这个方法的作用:

  1. 首先,我们得到了我们想要开始绘制时间标记的初始时间:
calendar.setTimeInMillis(initialTimeValue - 120 * 60 * 1000); 
calendar.set(Calendar.MINUTE, 0); 
calendar.set(Calendar.SECOND, 0); 
calendar.set(Calendar.MILLISECOND, 0); 

long time = calendar.getTimeInMillis();  

我们在我们的类构造函数中定义了initialTimeValue,设置为当前时间后半小时。我们还移除了分钟、秒和毫秒,因为我们要指示每个小时的整点和半小时,例如:9.00, 9.30, 10.00, 10.30,等等。

然后,我们创建了一个辅助方法,根据时间戳获取屏幕位置,这将在代码中的许多其他地方使用:

private float getTimeHorizontalPosition(long ts) { 
    long timeDifference = (ts - initialTimeValue); 
    return timeDifference * timeScale; 
} 
  1. 此外,我们需要根据设备屏幕密度计算一个时间刻度。为了计算它,我们定义了一个默认的时间刻度:
private static final float DEFAULT_TIME_SCALE = 0.0001f;  
  1. 在类构造函数中,我们根据屏幕密度调整了时间刻度:
final float screenDensity = getResources().getDisplayMetrics().density; 
timeScale = DEFAULT_TIME_SCALE * screenDensity;  

我们知道有许多不同屏幕大小和密度的 Android 设备。这种方式,而不是硬编码像素尺寸,使得渲染在所有设备上尽可能接近。

在此方法的帮助下,我们可以轻松地循环处理半小时的块,直到达到屏幕末端。

float x = getTimeHorizontalPosition(time) - frScrollX + getWidth() / 4.f; 
while (x < getWidth()) { 

    ... 

    time += 30 * 60 * 1000; // 30 minutes 
    x = getTimeHorizontalPosition(time) - frScrollX + getWidth() / 4.f; 
} 

通过将 30 分钟(转换为毫秒)加到时间变量上,我们可以以 30 分钟的块来递增水平标记。

我们也考虑了 frScrollX 的位置。当我们添加允许滚动的交互时,这个变量将被更新,但我们在本章后面会看到这一点。

渲染非常直接:只要 x 坐标在屏幕内,我们就绘制一条垂直线:

if (x > 0) { 
    canvas.drawLine(x, 0, x, timebarHeight, paintTimeBar); 
} 

我们以 HH:mm 格式绘制时间,就在旁边:

SimpleDateFormat dateFormatter = new SimpleDateFormat("HH:mm", Locale.US); 
String date = dateFormatter.format(new Date(time)); 
canvas.drawText(date, 
        x + programMargin, 
        (timebarHeight - timeBarTextBoundaries.height()) / 2.f 
                + timeBarTextBoundaries.height(), paintTimeBar); 

我们可以做的性能改进之一是存储字符串,这样我们就无需一次又一次地调用格式化方法,避免昂贵的对象创建。我们可以通过创建一个以长整型变量作为键并返回字符串的 HashMap 来实现这一点:

String date = null; 
if (dateFormatted.containsKey(time)) { 
    date = dateFormatted.get(time); 
} else { 
    date = dateFormatter.format(new Date(time)); 
    dateFormatted.put(time, date); 
} 

如果我们已经有了格式化的日期,我们就使用它;如果这是第一次,我们先格式化并将其存储在 HashMap 中。

现在我们可以继续绘制当前时间指示器。这非常简单;它只是一个比单条线稍宽的垂直框,因此我们使用 drawRect() 而不是 drawLine()

private void drawCurrentTime(Canvas canvas, long currentTime) { 
    float currentTimePos = frChNameWidth +
    getTimeHorizontalPosition(currentTime) - frScrollX; 
    canvas.drawRect(currentTimePos - programMargin/2, 
            0, 
            currentTimePos + programMargin/2, 
            timebarHeight, 
            paintCurrentTime); 

    canvas.clipRect(frChNameWidth, 0, getWidth(), getHeight()); 
    canvas.drawRect(currentTimePos - programMargin/2, 
            timebarHeight, 
            currentTimePos + programMargin/2, 
            getHeight(), 
            paintCurrentTime); 
} 

由于我们已经有了 getTimeHorizontalPosition 方法,我们可以轻松地确定绘制当前时间指示器的位置。由于我们将滚动浏览电视节目,因此我们将绘制分为两部分:一部分在时间条上绘制线条,不进行任何剪辑;另一部分从时间条末端到屏幕底部绘制线条。在后者中,我们应用剪辑,使其只绘制在电视节目上方。

为了更清楚地理解这一点,让我们看一下结果的截图:

在左侧,我们有表示频道的图标,顶部是时间条,其余部分是包含不同电视节目的电子节目指南(EPG)主体。我们希望避免当前时间线(红色)覆盖频道图标,因此我们应用了刚才提到的剪辑。

最后,我们可以实现整个 EPG 主体绘制。这比其他方法要复杂一些,因此让我们一步一步来。首先,我们需要计算要绘制的频道数量,以避免进行不必要的计算和试图在屏幕外绘制:

int startChannel = (int) (frScrollY / channelHeight); 
verticalOffset -= startChannel * channelHeight; 
int endChannel = startChannel + (int) ((getHeight() -  timebarHeight) / channelHeight) + 1; 
if (endChannel >= channelList.length) endChannel = channelList.length - 1; 

与时间刻度一样,我们也定义了一个默认的频道高度,并根据屏幕密度来计算它:

private static final int CHANNEL_HEIGHT = 80; 
... 
channelHeight = CHANNEL_HEIGHT * screenDensity; 

现在我们知道了需要绘制的初始频道和结束频道,我们可以概述绘制循环:

canvas.save(); 
canvas.clipRect(0, timebarHeight, getWidth(), getHeight()); 

for (int i = startChannel; i <= endChannel; i++) { 
    float channelTop = (i - startChannel) * channelHeight -
    verticalOffset +
    timebarHeight; 
    float channelBottom = channelTop + channelHeight; 

    ... 

} 

canvas.drawLine(frChNameWidth, timebarHeight, frChNameWidth, getHeight(), paintChannelText); 
canvas.restore(); 

我们将多次修改canvas的剪辑区域,因此让我们在方法开始时保存它,在结束时恢复它。这样我们就不会影响在此之后完成的任何其他绘图方法。在循环内,对于每个频道,我们还需要计算channelTopchannelBottom值,因为稍后在绘制时会很有用。这些值表示我们正在绘制的频道的顶部和底部的垂直坐标。

现在让我们为每个频道绘制图标,如果我们没有图标,首先从互联网上请求。我们将使用Picasso来管理互联网请求,但我们也可以使用任何其他库:

if (channelList[i].getIcon() != null) { 
    float iconMargin = (channelHeight -
    channelList[i].getIcon().getHeight()) / 2;

    canvas.drawBitmap(channelList[i].getIcon(), iconMargin, channelTop
    + iconMargin, null); 

} else { 
    if (channelTargets[i] == null) { 
        channelTargets[i] = new ChannelIconTarget(channelList[i]); 
    } 

    Picasso.with(context) 
            .load(channelList[i] 
            .getIconUrl()) 
            .into(channelTargets[i]); 
} 

关于毕加索的信息可以在以下链接找到:

square.github.io/picasso/

同时,对于每个频道,我们需要绘制屏幕内的电视节目。再次,让我们使用之前创建的方法将时间戳转换为屏幕坐标:

for (int j = 0; j < programs.size(); j++) { 
    Program program = programs.get(j); 

    long st = program.getStartTime(); 
    long et = program.getEndTime(); 

    float programStartX = getTimeHorizontalPosition(st); 
    float programEndX = getTimeHorizontalPosition(et); 

    if (programStartX - frScrollX > getWidth()) break; 
    if (programEndX - frScrollX >= 0) { 

        ... 

    } 
} 

在这里,我们从程序的开始和结束时间获取程序的开始和结束位置。如果开始位置超出了屏幕宽度,我们可以停止检查更多的电视节目,因为它们都将位于屏幕外,假设电视节目是按时间升序排序的。同样,如果结束位置小于 0,我们可以跳过这个特定的电视节目,因为它也将被绘制在屏幕外。

实际的绘制相当简单;我们使用drawRoundRect来绘制电视节目的背景,并在其上居中绘制节目名称。我们还剪辑了该区域,以防名称比电视节目框长:

canvas.drawRoundRect(horizontalOffset + programMargin + programStartX, 
       channelTop + programMargin, 
       horizontalOffset - programMargin + programEndX, 
       channelBottom - programMargin, 
       programMargin, 
       programMargin, 
       paintProgram); 

canvas.save(); 
canvas.clipRect(horizontalOffset + programMargin * 2 + programStartX, 
       channelTop + programMargin, 
       horizontalOffset - programMargin * 2 + programEndX, 
       channelBottom - programMargin); 

paintProgramText.getTextBounds(program.getName(), 0, program.getName().length(), textBoundaries); 
float textPosition = channelTop + textBoundaries.height() + ((channelHeight - programMargin * 2) - textBoundaries.height()) / 2; 
canvas.drawText(program.getName(), 
           horizontalOffset + programMargin * 2 + programStartX, 
           textPosition, 
           paintProgramText); 
canvas.restore(); 

我们还增加了一个小检查,以确定电视节目是否正在播放。如果当前时间大于或等于节目开始时间,并且小于结束时间,我们可以得出结论,电视节目目前正在播放,并用高亮颜色渲染它。

if (st <= currentTime && et > currentTime) { 
    paintProgram.setColor(HIGHLIGHTED_PROGRAM_COLOR); 
    paintProgramText.setColor(Color.BLACK); 
} else { 
    paintProgram.setColor(PROGRAM_COLOR); 
    paintProgramText.setColor(Color.WHITE); 
} 

现在让我们添加动画周期。在这个例子中,我们选择了固定时间步长机制。我们只对滚动变量进行动画处理,包括水平和垂直的滚动以及屏幕中频道部分的运动:

private void animateLogic() { 
    long currentTime = SystemClock.elapsedRealtime(); 
    accTime += currentTime - timeStart; 
    timeStart = currentTime; 

    while (accTime > TIME_THRESHOLD) { 
        scrollX += (scrollXTarget - scrollX) / 4.f; 
        scrollY += (scrollYTarget - scrollY) / 4.f; 
        chNameWidth += (chNameWidthTarget - chNameWidth) / 4.f; 
        accTime -= TIME_THRESHOLD; 
    } 

    float factor = ((float) accTime) / TIME_THRESHOLD; 
    float nextScrollX = scrollX + (scrollXTarget - scrollX) / 4.f; 
    float nextScrollY = scrollY + (scrollYTarget - scrollY) / 4.f; 
    float nextChNameWidth = chNameWidth + (chNameWidthTarget -
                            chNameWidth) / 4.f; 

    frScrollX = scrollX * (1.f - factor) + nextScrollX * factor; 
    frScrollY = scrollY * (1.f - factor) + nextScrollY * factor; 
    frChNameWidth = chNameWidth * (1.f - factor) + nextChNameWidth *
    factor; 
} 

在我们后面的渲染和计算中,我们将使用frScrollXfrScrollYfrChNameWidth变量,它们包含了当前逻辑刻度与下一个逻辑刻度之间的分数部分。

我们将在下一节讨论向电子节目指南添加交互时看到如何滚动,但我们刚刚引入了频道部分的移动。现在,我们只是将每个频道渲染为一个图标,但是为了获取更多信息,我们添加了一个切换功能,使当前有图标的频道框变得更大,并在图标旁边绘制频道标题。

我们创建了一个布尔开关来跟踪我们正在渲染的状态,并在需要时绘制频道名称:

if (!shortChannelMode) { 
    paintChannelText.getTextBounds(channelList[i].getName(), 
            0, 
            channelList[i].getName().length(), 
            textBoundaries); 

    canvas.drawText(channelList[i].getName(), 
            channelHeight - programMargin * 2, 
            (channelHeight - textBoundaries.height()) / 2 +
             textBoundaries.height() + channelTop, 
            paintChannelText); 
} 

切换非常简单,因为它只是将频道框宽度目标更改为channelHeight,这样它就会有正方形的尺寸,或者在绘制文本时是channelHeight的两倍。动画周期将负责动画化这个变量:

if (shortChannelMode) { 
    chNameWidthTarget = channelHeight * 2; 
    shortChannelMode = false; 
} else { 
    chNameWidthTarget = channelHeight; 
    shortChannelMode = true; 
}  

交互

到目前为止,这并不是很有用,因为我们不能与它互动。要添加交互,我们需要从 View 中重写onTouchEvent()方法,正如我们在前面的章节中看到的。

在我们自己的onTouchEvent实现中,我们主要对ACTION_DOWNACTION_UPACTION_MOVE事件感兴趣。让我们看看我们已经完成的实现:

private float dragX; 
private float dragY; 
private boolean dragged; 

... 

@Override 
public boolean onTouchEvent(MotionEvent event) { 

    switch(event.getAction()) { 
        case MotionEvent.ACTION_DOWN: 
            dragX = event.getX(); 
            dragY = event.getY(); 

            getParent().requestDisallowInterceptTouchEvent(true); 
            dragged = false; 
            return true; 

        case MotionEvent.ACTION_UP: 
            if (!dragged) { 
                // touching inside the channel area, will toggle
                   large/short channels 
                if (event.getX() < frChNameWidth) { 
                    switchNameWidth = true; 
                    invalidate(); 
                } 
            } 

            getParent().requestDisallowInterceptTouchEvent(false); 
            return true; 

        case MotionEvent.ACTION_MOVE: 
            float newX = event.getX(); 
            float newY = event.getY(); 

            scrollScreen(dragX - newX, dragY - newY); 

            dragX = newX; 
            dragY = newY; 
            dragged = true; 
            return true; 
        default: 
            return false; 
    } 
} 

这个方法并没有太多逻辑;它只是在检查我们是否在屏幕上拖动,用上一次事件的拖动量差值来调用scrollScreen方法,并且,在只是点击频道框而没有拖动的情况下,触发切换以使频道框变大或变小。

scrollScreen方法简单地更新scrollXTargetscrollYTarget并检查其边界:

private void scrollScreen(float dx, float dy) { 
    scrollXTarget += dx; 
    scrollYTarget += dy; 

    if (scrollXTarget < -chNameWidth) scrollXTarget = -chNameWidth; 
    if (scrollYTarget < 0) scrollYTarget = 0; 

    float maxHeight = channelList.length * channelHeight - getHeight()
    + 1 + timebarHeight; 
    if (scrollYTarget > maxHeight) scrollYTarget = maxHeight; 

    invalidate(); 
} 

同时,调用invalidate以触发重绘事件非常重要。在onDraw()事件本身中,我们检查所有动画是否完成,如果需要,则触发更多的重绘事件:

if (missingAnimations()) invalidate(); 

missingAnimations的实际实现非常直接:

private static final float ANIM_THRESHOLD = 0.01f; 

private boolean missingAnimations() { 
    if (Math.abs(scrollXTarget - scrollX) > ANIM_THRESHOLD) 
    return true;

if (Math.abs(scrollYTarget - scrollY) > ANIM_THRESHOLD)
    return true;

if (Math.abs(chNameWidthTarget - chNameWidth) > ANIM_THRESHOLD)
    return true;

return false;
} 

我们只是检查所有可以动画的属性,如果它们与目标值的差小于预定义的阈值。如果只有一个大于这个阈值,我们需要触发更多的重绘事件和动画周期。

缩放

由于我们为每个电视节目渲染一个盒子,其大小直接由电视节目持续时间决定,可能会出现电视节目标题比其渲染的盒子大的情况。在这些情况下,我们可能想要阅读标题的更多部分,或者在其它时候,我们可能想要稍微压缩一下,以便我们可以了解那天稍后电视上会有什么节目。

为了解决这个问题,我们可以在我们的 EPG 小部件上通过在设备屏幕上捏合来实现缩放机制。我们可以将这种缩放直接应用到timeScale变量上,并且由于我们在所有计算中都使用了它,它将保持一切同步:

scaleDetector = new ScaleGestureDetector(context,  
    new ScaleGestureDetector.SimpleOnScaleGestureListener() {  

    ... 

    }); 

为了简化,我们使用SimpleOnScaleGestureListener,它允许我们只重写我们想要使用的方法。

现在,我们需要修改onTouchEvent,让scaleDetector实例也处理这个事件:

@Override 
public boolean onTouchEvent(MotionEvent event) { 
    scaleDetector.onTouchEvent(event); 

    if (zooming) { 
        zooming = false; 
        return true; 
    } 

    ... 

} 

我们还添加了一个检查,看看我们是否正在缩放。我们将在ScaleDetector实现中更新这个变量,但概念是避免在正在缩放时滚动视图或处理拖动事件。

现在让我们实现ScaleDetector

scaleDetector = new ScaleGestureDetector(context, new ScaleGestureDetector.SimpleOnScaleGestureListener() { 
    private long focusTime; 
    private float scrollCorrection = 0.f; 
    @Override 
    public boolean onScaleBegin(ScaleGestureDetector detector) { 
        zooming = true; 
        focusTime = getHorizontalPositionTime(scrollXTarget +
        detector.getFocusX() - frChNameWidth); 
        scrollCorrection = getTimeHorizontalPosition((focusTime)) -
        scrollXTarget; 
        return true; 
    } 

    public boolean onScale(ScaleGestureDetector detector) { 
        timeScale *= detector.getScaleFactor(); 
        timeScale = Math.max(DEFAULT_TIME_SCALE * screenDensity / 2,  
                        Math.min(timeScale, DEFAULT_TIME_SCALE *
                        screenDensity * 4)); 

        // correct scroll position otherwise will move too much when
           zooming 
        float current = getTimeHorizontalPosition((focusTime)) -
        scrollXTarget; 
        float scrollDifference = current - scrollCorrection; 
        scrollXTarget += scrollDifference; 
        zooming = true; 

        invalidate(); 
        return true; 
    } 

    @Override 
    public void onScaleEnd(ScaleGestureDetector detector) { 
        zooming = true; 
    } 
}); 

我们基本上在做两件事情。首先,我们将timeScale变量从默认值的一半调整到默认值的四倍:

timeScale *= detector.getScaleFactor(); 
timeScale = Math.max(DEFAULT_TIME_SCALE * screenDensity / 2,  
                Math.min(timeScale, DEFAULT_TIME_SCALE * screenDensity
                * 4)); 

同时,我们调整滚动位置以避免缩放时的不良效果。通过调整滚动位置,我们试图保持捏合焦点的位置不变,即使放大或缩小后也是如此。

float current = getTimeHorizontalPosition((focusTime)) - scrollXTarget; 
float scrollDifference = current - scrollCorrection; 
scrollXTarget += scrollDifference; 

有关ScaleDetector和手势的更多信息,请查看官方 Android 文档。

配置和扩展

如果你想创建一个可供多人使用的自定义视图,它需要是可定制的。电子节目指南(EPG)也不例外。在我们的初步实现中,我们硬编码了一些颜色和值,但让我们看看如何扩展这些功能,使我们的 EPG 可自定义。

使其可配置

在本书的初始章节中,我们介绍了如何添加参数,这样就可以轻松自定义我们的自定义视图。遵循同样的原则,我们创建了一个attrs.xml文件,其中包含了所有可自定义的参数:

<?xml version="1.0" encoding="utf-8"?> 
<resources> 
    <declare-styleable name="EPG"> 
        <attr name="backgroundColor" format="color"/> 
        <attr name="programColor" format="color"/> 
        <attr name="highlightedProgramColor" format="color"/> 
        <attr name="currentTimeColor" format="color"/> 
        <attr name="channelTextColor" format="color"/> 
        <attr name="programTextColor" format="color"/> 
        <attr name="highlightedProgramTextColor" format="color"/> 
        <attr name="timeBarColor" format="color"/> 

        <attr name="channelHeight" format="float"/> 
        <attr name="programMargin" format="float"/> 
        <attr name="timebarHeight" format="float"/> 
    </declare-styleable> 
</resources> 

可以添加许多其他变量作为参数,但从自定义视图的外观和感觉角度来看,这些是主要的自定义功能。

在我们的类构造函数中,我们还添加了读取和解析这些参数的代码。在它们不存在的情况下,我们会默认使用我们之前硬编码的值。

TypedArray ta = context.getTheme().obtainStyledAttributes(attrs, R.styleable.EPG, 0, 0); 
try { 
    backgroundColor = ta.getColor(R.styleable.EPG_backgroundColor,
    BACKGROUND_COLOR); 
    paintChannelText.setColor(ta.getColor(R.styleable.EPG_channelTextColor
                          Color.WHITE)); 
    paintCurrentTime.setColor(ta.getColor(R.styleable.EPG_currentTimeColor,
                          CURRENT_TIME_COLOR)); 
    paintTimeBar.setColor(ta.getColor(R.styleable.EPG_timeBarColor,
                          Color.WHITE)); 

    highlightedProgramColor =
    ta.getColor(R.styleable.EPG_highlightedProgramColor,
        HIGHLIGHTED_PROGRAM_COLOR);

    programColor = ta.getColor(R.styleable.EPG_programColor,
    PROGRAM_COLOR);

    channelHeight = ta.getFloat(R.styleable.EPG_channelHeight,
    CHANNEL_HEIGHT) * screenDensity;

    programMargin = ta.getFloat(R.styleable.EPG_programMargin,
    PROGRAM_MARGIN) * screenDensity;

    timebarHeight = ta.getFloat(R.styleable.EPG_timebarHeight,
    TIMEBAR_HEIGHT) * screenDensity;

    programTextColor = ta.getColor(R.styleable.EPG_programTextColor,
    Color.WHITE);

    highlightedProgramTextColor =
    ta.getColor(R.styleable.EPG_highlightedProgramTextColor,
        Color.BLACK);
} finally { 
    ta.recycle(); 
} 

为了让任何尝试自定义它的人更简单、更清晰,我们可以进行一个小改动。让我们将直接映射到像素大小的参数重新定义为尺寸,而不是浮点数:

<attr name="channelHeight" format="dimension"/> 
<attr name="programMargin" format="dimension"/> 
<attr name="timebarHeight" format="dimension"/> 

将解析代码更新为以下内容:

channelHeight = ta.getDimension(R.styleable.EPG_channelHeight, 
        CHANNEL_HEIGHT * screenDensity); 

programMargin = ta.getDimension(R.styleable.EPG_programMargin, 
        PROGRAM_MARGIN * screenDensity); 

timebarHeight = ta.getDimension(R.styleable.EPG_timebarHeight, 
        TIMEBAR_HEIGHT * screenDensity); 

使用getDimension而不是getFloat,它会自动将设置为密度像素的尺寸转换为实际像素。它不会对默认值进行这种转换,因此我们仍然需要自己乘以screenDensity

最后,我们需要在activity_main.xml布局文件中添加这些配置:

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

    android:layout_width="match_parent" 
    android:layout_height="match_parent" 
tools:context="com.rrafols.packt.epg.MainActivity"> 

    <com.rrafols.packt.epg.EPG 
        android:id="@+id/epg_view" 
        android:layout_width="match_parent" 
        android:layout_height="match_parent" 
        app:channelHeight="80dp"
        app:highlightedProgramColor="#ffffdd20"
        app:highlightedProgramTextColor="#ff000000"/>
</LinearLayout>  

我们可以在以下屏幕截图中看到这些更改的结果:

实现回调

我们还没有介绍 EPG 的另一个关键功能,即点击电视节目时实际执行某些操作的能力。如果我们想用我们的 EPG 做一些有用的事情,而不仅仅是展示即将到来的标题,我们必须实现这个功能。

这个实现相当直接,将处理逻辑传递给外部监听器或回调。修改源代码以在 EPG 本身上实现一些自定义行为也相对容易。

首先,我们在 EPG 类中创建一个新的接口,带有一个单一的方法:

interface EPGCallback { 
    void programClicked(Channel channel, Program program); 
} 

每当我们点击电视节目时,都会调用这个方法,实现这个回调的任何人都会同时获得Channel和电视Program

现在,让我们修改onTouchEvent()方法以处理这个新功能:

if (event.getX() < frChNameWidth) { 

    ... 

} else { 
    clickProgram(event.getX(), event.getY()); 
} 

在我们之前的代码中,我们只检查是否点击了屏幕的频道区域。现在我们可以使用另一个区域来检测我们是否点击了电视节目内部。

现在让我们实现clickProgram()方法:

private void clickProgram(float x, float y) { 
    long ts = getHorizontalPositionTime(scrollXTarget + x -
    frChNameWidth); 
    int channel = (int) ((y + frScrollY - timebarHeight) / 
    channelHeight); 

    ArrayList<Program> programs = channelList[channel].getPrograms(); 
    for (int i = 0; i < programs.size(); i++) { 
        Program pr = programs.get(i); 
        if (ts >= pr.getStartTime() && ts < pr.getEndTime()) { 
            if (callback != null) { 
                callback.programClicked(channelList[channel], pr); 
            } 
            break; 
        } 
    } 
}  

我们首先将用户点击的水平位置转换成时间戳,结合触摸事件的垂直位置,我们可以确定频道。有了频道和时间戳,我们就可以检查用户点击了哪个节目,并带着这些信息调用回调函数。

在 GitHub 示例中,我们添加了一个虚拟的监听器,它只记录被点击的频道和节目:

@Override 
protected void onCreate(Bundle savedInstanceState) { 
    super.onCreate(savedInstanceState); 
    setContentView(R.layout.activity_main); 

    EPG epg = (EPG) findViewById(R.id.epg_view); 
    epg.setCallback(new EPG.EPGCallback() { 
        @Override 
        public void programClicked(Channel channel, Program program) { 
            Log.d("EPG", "program clicked: " + program.getName() + "
            channel: " + channel.getName()); 
        } 
    }); 

    populateDummyChannelList(epg); 
} 

在这个 Activity 的onCreate中还有一个populateDummyChannelList()方法。这个方法只会填充随机的频道和电视节目数据,如果与真实的电子节目指南(EPG)数据提供者连接,应该移除这个方法。

整个示例可以在 GitHub 仓库的Example33-EPG文件夹中找到。

总结

在本章中,我们了解了如何构建一个具有许多功能的简单 EPG,但我们可能还留下许多其他功能没有实现。例如,我们的电视节目渲染相当简单,我们可以在电视节目框中添加更多信息,比如持续时间、开始时间和结束时间,甚至可以直接显示电视节目描述。

请随意使用 GitHub 仓库中的内容,进行操作、添加新的自定义或功能,并根据您的需要进行调整。

我们并没有特别讨论性能问题,但我们尽可能减少了onDraw方法及其调用方法中的内存分配数量,并尽可能减少了屏幕上的绘制内容,甚至不处理那些将落在屏幕边界之外的元素。

如果我们希望自定义视图(在这个案例中是 EPG)能够快速响应、伸缩以适应更多频道和电视节目,那么考虑这些细节是至关重要的。

在下一章中,我们将构建另一个复杂的自定义视图,可以用它在我们的 Android 应用程序上绘制图表。

第十章:构建图表组件

在上一章中,我们了解到如何构建一个复杂的自定义视图,它融合了本书所介绍的所有内容。它包括一些渲染代码,使用第三方库,具有触摸交互和动画效果,并且我们简要讨论了性能考量。这是一个相当完整自定义视图的例子,但它并非唯一。在本章中,我们将构建另一个复杂自定义视图。逐步地,我们将构建一个图表自定义视图,用以绘制可以嵌入到我们的 Android 应用程序中的图形。我们将从构建一个非常基础的实施开始,并在途中添加额外的功能和功能性。更详细地说,我们将了解以下内容:

  • 构建一个基础图表组件

  • 如何考虑边距和填充

  • 使用路径改善渲染

  • 更新和扩展我们的数据集

  • 增加额外的特性和自定义

构建一个基础的图表自定义视图

在 Android 应用程序中,我们可能需要在某个时刻绘制一些图表。它可以是静态图表,这并不那么有趣,因为它可以被简单地替换为图像,也可以是动态图表,允许用户交互和对数据变化的反应。最后一种情况是我们可以使用自定义视图来绘制实时图表,添加多个数据源,甚至为其添加动画。让我们从构建一个非常简单的自定义视图开始,稍后我们会添加更多功能。

边距和填充

与任何普通视图一样,我们的自定义视图将受到布局管理器的边距和视图填充的影响。我们不应该太担心边距值,因为布局管理器将直接处理它们,并且会透明地修改我们的自定义视图可用的尺寸。我们需要考虑的是填充值。正如在下图中所看到的,边距是布局管理器在我们自定义视图前后添加的空间,而填充则是视图边界与内容之间的内部空间:

我们的视图需要适当管理这个填充。为此,我们可以直接使用canvas中的不同getPadding方法,如getPaddingTop()getPaddingBottom()getPaddingStart()等。使用填充值,我们应该在onDraw()方法中相应地调整渲染区域:

protected void onDraw(Canvas canvas) {
    int startPadding = getPaddingStart();
    int topPadding = getPaddingTop();

    int width = canvas.getWidth() - startPadding - getPaddingEnd();
    int height = canvas.getHeight() - topPadding - getPaddingBottom();
}

在这段代码中,我们存储了 Canvas 的左侧和顶部点,分别是起始填充和顶部填充值。我们必须小心这句话,因为起始填充可能不是左侧填充。如果我们查看文档,我们会发现既有 getPaddingStart()getPaddingEnd(),也有 getPaddingLeft()getPaddingRight()。例如,如果我们的设备配置为从右到左RTL)模式,则起始填充可能是右侧填充。如果我们想要支持 LTR 和 RTL 设备,我们必须注意这些细节。在这个特定示例中,我们将通过使用视图上可用的 getLayoutDirection() 方法检测布局方向来构建支持 RTL 的版本。但首先,让我们专注于一个非常简单的实现。

基本实现

我们的基本实现将非常直接。首先创建类及其构造函数:

public class Chart extends View {
    private Paint linePaint;

    public Chart(Context context, AttributeSet attrs) {
        super(context, attrs);
        linePaint = new Paint();
        linePaint.setAntiAlias(true);
        linePaint.setColor(0xffffffff);
        linePaint.setStrokeWidth(8.f);
        linePaint.setStyle(Paint.Style.STROKE);
    }
}

在我们的构造函数中初始化了一个 Paint 对象,但这次我们将样式设置为 Paint.Style.STROKE,因为我们只关心绘制线条。现在让我们添加一个方法,这样无论谁使用自定义视图都可以设置要渲染的数据:

private float[] dataPoints;
private float minValue;
private float maxValue;
private float verticalDelta;

public void setDataPoints(float[] originalData) {
    dataPoints = new float[originalData.length];
    minValue = Float.MAX_VALUE;
    maxValue = Float.MIN_VALUE;
    for (int i = 0; i< dataPoints.length; i++) {
        dataPoints[i] = originalData[i];
        if (dataPoints[i] <minValue) minValue = dataPoints[i];
        if (dataPoints[i] >maxValue) maxValue = dataPoints[i];
    }

    verticalDelta = maxValue - minValue;
    postInvalidate();
}

我们正在复制原始数据数组,因为我们无法控制它,它可能会在没有任何预警的情况下发生变化。稍后,我们将看到如何改进这种行为并适应数据集的变化。

我们还在数组上计算最大值和最小值以及它们之间的差值。这将使我们能够得到这些数字的相对比例,并将它们缩小或按需放大到 0 到 1 的比例,这将非常方便调整渲染以适应我们的视图高度。

现在我们有了数据,可以实现我们的 onDraw() 方法:

@Override
protected void onDraw(Canvas canvas) {
    canvas.drawARGB(255,0 ,0 ,0);

    float leftPadding = getPaddingLeft();
    float topPadding = getPaddingTop();

    float width = canvas.getWidth() - leftPadding - getPaddingRight();
    float height = canvas.getHeight() - topPadding -
    getPaddingBottom();

    float lastX = getPaddingStart();
    float lastY = height * ((dataPoints[0] - minValue) / verticalDelta)
    + topPadding;

    for (int i = 1; i < dataPoints.length; i++) {
        float y = height * ((dataPoints[i] - minValue) / verticalDelta)
        + topPadding;
        float x = width * (((float) i + 1) / dataPoints.length) +
        leftPadding;

        canvas.drawLine(lastX, lastY, x, y, linePaint);
        lastX = x;
        lastY = y;
    }
}

为了尽可能简单,目前我们使用 canvas.drawARGB(255, 0, 0, 0) 绘制黑色背景,然后通过从总宽度和高度中减去填充来计算 Canvas 上的可用大小。

我们还将在所有点之间平均分配水平空间,并垂直缩放它们以使用所有可用空间。由于我们计算了数据集中最小值和最大值之间的差,我们可以通过减去数值的最小值然后除以差值(或这里我们使用的 verticalDelta 变量)来将这些数字缩放到 01 的范围。

通过这些计算,我们只需跟踪之前的值,以便能够从旧点画到新点。这里,我们将最后的 xy 坐标分别存储在 lastXlastY 变量中,并在每次循环结束时更新它们。

使用路径进行优化和改进

实际上,我们可以在onDraw()方法中预先计算这些操作,因为每次在屏幕上绘制图表时都没有必要这样做。我们可以在setDataPoints()中执行,这是我们自定义视图中唯一可以更改或替换数据集的点:

public void setDataPoints(float[] originalData) {
    dataPoints = new float[originalData.length];

    float minValue = Float.MAX_VALUE;
    float maxValue = Float.MIN_VALUE;
    for (int i = 0; i < dataPoints.length; i++) {
        dataPoints[i] = originalData[i];
        if (dataPoints[i] < minValue) minValue = dataPoints[i];
        if (dataPoints[i] > maxValue) maxValue = dataPoints[i];
    }

    float verticalDelta = maxValue - minValue;

    for (int i = 0; i < dataPoints.length; i++) {
        dataPoints[i] = (dataPoints[i] - minValue) / verticalDelta;
    }

    postInvalidate();
}

现在,我们可以简化onDraw()方法,因为我们完全可以假设我们的数据集将始终在01之间变化:

@Override
protected void onDraw(Canvas canvas) {
    canvas.drawARGB(255,0 ,0 ,0);

    float leftPadding = getPaddingLeft();
    float topPadding = getPaddingTop();

    float width = canvas.getWidth() - leftPadding - getPaddingRight();
    float height = canvas.getHeight() - topPadding -
    getPaddingBottom();

    float lastX = getPaddingStart();
    float lastY = height * dataPoints[0] + topPadding;
    for (int i = 1; i < dataPoints.length; i++) {
        float y = height * dataPoints[i] + topPadding;
        float x = width * (((float) i) / dataPoints.length) +
        leftPadding;

        canvas.drawLine(lastX, lastY, x, y, linePaint);

        lastX = x;
        lastY = y;
    }
}

但我们可以更进一步,将线条图转换成一条Path

private Path graphPath; 

@Override
protected void onDraw(Canvas canvas) {
    canvas.drawARGB(255,0 ,0 ,0);

    float leftPadding = getPaddingLeft();
    float topPadding = getPaddingTop();

    float width = canvas.getWidth() - leftPadding - getPaddingRight();
    float height = canvas.getHeight() - topPadding - 
    getPaddingBottom();

    if (graphPath == null) {
        graphPath = new Path();

        graphPath.moveTo(leftPadding, height * dataPoints[0] +
        topPadding);

        for (int i = 1; i < dataPoints.length; i++) {
            float y = height * dataPoints[i] + topPadding;
            float x = width * (((float) i + 1) / dataPoints.length) +
            leftPadding;

            graphPath.lineTo(x, y);
        }
    }

    canvas.drawPath(graphPath, linePaint);
}

它将在第一次调用onDraw()方法时生成一条从一点到另一点的Path。图表还将根据canvas的尺寸进行缩放。我们现在唯一的问题将是它不会自动调整以适应canvas大小的变化或我们的图表数据更新。让我们看看如何修复它。

首先,我们必须声明一个boolean类型的标志,以确定是否需要重新生成Path,以及两个变量来保存我们自定义视图的最后宽度和高度:

private boolean regenerate; 
private float lastWidth; 
private float lastHeight; 

在类的构造函数中,我们必须创建一个Path的实例。稍后,我们不是通过检查 null 来创建新实例,而是调用 reset 方法来生成新的Path,但重用这个对象实例:

graphPath = new Path(); 
lastWidth = -1; 
lastHeight = -1; 

setDataPoints()中,我们只需在调用postInvalidate之前将regenerate设置为 true。在我们的onDraw()方法中,我们必须添加额外的检查以检测canvas大小何时发生变化:

if (lastWidth != width || lastHeight != height) {
    regenerate = true;

    lastWidth = width;
    lastHeight = height;
}

正如我们刚才提到的,我们将检查boolean标志的值而不是检查 null,以重新生成Path

if (regenerate) {
    graphPath.reset();
    graphPath.moveTo(leftPadding, height * dataPoints[0] + topPadding);

    for (int i = 1; i < dataPoints.length; i++) {
        float y = height * dataPoints[i] + topPadding;
        float x = width * (((float) i + 1) / dataPoints.length) +
        leftPadding;

        graphPath.lineTo(x, y);
    }

    regenerate = false;
}

背景线条和细节

让我们将其添加到 Android 项目中以查看结果。首先创建一个非常简单的布局文件:

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

    android:layout_width="match_parent"
    android:layout_height="match_parent"

    tools:context="com.rrafols.packt.chart.MainActivity">

    <com.rrafols.packt.chart.Chart
        android:layout_margin="16dp"
        android:padding="10dp"
        android:id="@+id/chart_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

</LinearLayout>

让我们也创建一个空的活动,这个活动将仅将此布局文件设置为内容视图,并为我们的图表组件生成一些随机数据以进行渲染:


@Override
protected void onCreate(Bundle savedInstanceState) {
   super.onCreate(savedInstanceState);
   setContentView(R.layout.activity_main);

   Chart chart = (Chart) findViewById(R.id.chart_view);

   float[] data = new float[20];
   for (int i = 0; i < data.length; i++) {
       data[i] = (float) Math.random() * 10.f;
   }

   chart.setDataPoints(data);
}

如果我们运行这个例子,我们将得到以下屏幕:

好的,我们已经完成了一个简单的实现,但让我们添加一些细节。首先,在每个数据点上添加一个小点以提高清晰度。让我们在类构造函数中创建一个新的Paint对象:

circlePaint = new Paint(); 
circlePaint.setAntiAlias(true); 
circlePaint.setColor(0xffff2020); 
circlePaint.setStyle(Paint.Style.FILL); 

一种实现方法是在每个数据点上绘制小圆圈。我们将在类构造函数中创建一个circlePath实例,并在需要重新生成时重置它。由于我们正在计算线条的坐标,因此可以直接将它们用作圆圈的位置:

@Override
protected void onDraw(Canvas canvas) {
    canvas.drawARGB(255,0 ,0 ,0);

    float leftPadding = getPaddingLeft();
    float topPadding = getPaddingTop();

    float width = canvas.getWidth() - leftPadding - getPaddingRight();
    float height = canvas.getHeight() - topPadding -
    getPaddingBottom();

    if (lastWidth != width || lastHeight != height) {

        regenerate = true;

        lastWidth = width;
        lastHeight = height;
    }

    if (regenerate) {
        circlePath.reset();
        graphPath.reset();

        float x = leftPadding;
        float y = height * dataPoints[0] + topPadding;

        graphPath.moveTo(x, y);
        circlePath.addCircle(x, y, 10, Path.Direction.CW);

        for (int i = 1; i < dataPoints.length; i++) {
            y = height * dataPoints[i] + topPadding;
            x = width * (((float) i + 1) / dataPoints.length) +
            leftPadding;

            graphPath.lineTo(x, y);
            circlePath.addCircle(x, y, 10, Path.Direction.CW);
        }

        regenerate = false;
    }

    canvas.drawPath(graphPath, linePaint);
    canvas.drawPath(circlePath, circlePaint);
}

在这个例子中,我们将圆的半径硬编码为10,仅比线条的厚度8稍大一点,但稍后我们将在本章中讨论自定义选项。

如果我们现在运行这个例子,我们将看到与之前版本的区别:

为了添加更直观的参考,我们还可以添加一些背景线条。由于它将使用不同的设置来绘制,首先我们创建一个新的Paint对象:

backgroundPaint = new Paint(); 
backgroundPaint.setColor(0xffBBBB40); 
backgroundPaint.setStyle(Paint.Style.STROKE); 
backgroundPaint.setPathEffect(new DashPathEffect(new float[] {5, 5}, 0)); 

现在,让我们修改onDraw()方法,以生成带有背景线条的新Path

@Override
protected void onDraw(Canvas canvas) {
    canvas.drawARGB(255,0 ,0 ,0);

    float leftPadding = getPaddingLeft();
    float topPadding = getPaddingTop();

    float width = canvas.getWidth() - leftPadding - getPaddingRight();
    float height = canvas.getHeight() - topPadding -
    getPaddingBottom();

    if (lastWidth != width || lastHeight != height) {
        regenerate = true;

        lastWidth = width;
        lastHeight = height;
    }

    if (regenerate) {
        circlePath.reset();
        graphPath.reset();
        backgroundPath.reset();

 for (int i = 0; i <= dataPoints.length; i++) {
 float xl = width * (((float) i) / dataPoints.length) +
 leftPadding;
 backgroundPath.moveTo(xl, topPadding);
 backgroundPath.lineTo(xl, topPadding + height);
 }

 for (int i = 0; i <= 10; i++) {
 float yl = ((float) i / 10.f) * height + topPadding;
 backgroundPath.moveTo(leftPadding, yl);
 backgroundPath.lineTo(leftPadding + width, yl);
 }

        float x = leftPadding;
        float y = height * dataPoints[0] + topPadding;

        graphPath.moveTo(x, y);
        circlePath.addCircle(x, y, 10, Path.Direction.CW);

        for (int i = 1; i < dataPoints.length; i++) {
            x = width * (((float) i + 1) / dataPoints.length) + 
           leftPadding;
            y = height * dataPoints[i] + topPadding;

            graphPath.lineTo(x, y);
            circlePath.addCircle(x, y, 10, Path.Direction.CW);
        }

        regenerate = false;
    }

    canvas.drawPath(backgroundPath, backgroundPaint);
    canvas.drawPath(graphPath, linePaint);
    canvas.drawPath(circlePath, circlePaint);
}

在这里,我们创建水平和垂直的线条。水平线条将在有数据点的确切位置创建。对于垂直线条,我们不会遵循相同的原理,我们只需在Canvas的顶部和底部之间均匀绘制 10 条垂直线条。执行我们的示例,现在我们会得到类似于以下屏幕的内容:

这样可以,但我们仍然缺少一些参考点。让我们绘制一些水平和垂直的标签。

首先,让我们创建一个标签数组,并创建一个方法,让使用此自定义视图的任何人都可以设置它们:

private String[] labels; 

public void setLabels(String[] labels) {
    this.labels = labels;
}

如果它们没有被设置,我们可以选择不绘制任何内容,或者自己生成它们。在这个例子中,我们将自动使用数组索引生成它们:

if (labels == null) {
     labels = new String[dataPoints.length + 1];
     for (int i = 0; i < labels.length; i++) {
         labels[i] = "" + i;
     }
 }

为了测量文本,以便我们可以居中它,我们将复用Rect对象。让我们创建并实例化它:

private Rect textBoundaries = new Rect(); 

现在,我们可以将以下代码添加到onDraw()方法中,以绘制底部的标签,我们的数据集中的每个点都有一个:

for (int i = 0; i <= dataPoints.length; i++) {
    float xl = width * (((float) i) / dataPoints.length) + leftPadding;
    backgroundPaint.getTextBounds(labels[i], 0, labels[i].length(),
    textBoundaries);
    canvas.drawText(labels[i], 
        xl - (textBoundaries.width() / 2), 
        height + topPadding + backgroundPaint.getTextSize() * 1.5f, 
        backgroundPaint);
}

我们还调整了图表的总高度,以添加一些标签的空间:

float height = canvas.getHeight() - topPadding - getPaddingBottom() 
        - backgroundPaint.getTextSize() + 0.5f; 

让我们也绘制一个侧边图例,指示点的值和刻度。由于我们绘制的是预定义的一组垂直线条,我们只需计算这些值。我们需要将这些值从 0 到 1 的范围转换回它们的原始范围和特定值。

我们需要根据标签大小调整图表的宽度和初始左侧点。因此,让我们计算侧标签的最大宽度:

float maxLabelWidth = 0.f;

for (int i = 0; i <= 10; i++) {
    float step = ((float) i / 10.f);
    float value = step * verticalDelta + minValue;
    verticalLabels[i] = decimalFormat.format(value);
    backgroundPaint.getTextBounds(verticalLabels[i], 0,
    verticalLabels[i].length(), textBoundaries);
    if (textBoundaries.width() > maxLabelWidth) {
        maxLabelWidth = textBoundaries.width();
    }
}

我们还使用了一个DecimalFormat实例来格式化浮点数值。我们使用以下模式创建了此DecimalFormat

decimalFormat = new DecimalFormat("#.##"); 

此外,我们将标签存储在数组中,以避免每次绘制视图时都重新生成它们。在maxLabelWidth变量中存储最大标签宽度后,我们可以调整填充:

float labelLeftPadding = getPaddingLeft() + maxLabelWidth * 0.25f; 
float leftPadding = getPaddingLeft() + maxLabelWidth * 1.5f; 

我们仍然使用leftPadding来渲染所有对象,并使用labelLeftPadding来渲染标签。我们已经添加了最大标签的大小以及绘制标签前后分布的额外50%填充。因此,标签将具有额外的25%maxLabelWidth填充,这样标签末尾和图表开始之间将有另外25%的空间。

我们只需遍历数组并计算正确的垂直位置,就可以轻松绘制垂直标签:

for (int i = 0; i <= 10; i++) {
    float step = ((float) i / 10.f);
    float yl = step * height + topPadding- (backgroundPaint.ascent() +
    backgroundPaint.descent()) * 0.5f;
    canvas.drawText(verticalLabels[i],
        labelLeftPadding, 
        yl, 
        backgroundPaint);
}

为了在垂直坐标上居中文本,我们使用了当前字体上升和下降之间的平均值。

如果我们现在运行这个示例,我们将更详细地查看我们的图表:

我们在本章开头提到,我们将支持 RTL 和 LTR 设备。如果设备布局配置为 RTL,那么在图表视图中,图例在屏幕右侧会感觉更自然。让我们快速实现这个变化:

float labelLeftPadding = getPaddingLeft() + maxLabelWidth * 0.25f; 
float leftPadding = getPaddingLeft() + maxLabelWidth * 1.5f; 
float rightPadding = getPaddingRight(); 
float topPadding = getPaddingTop(); 

float width = canvas.getWidth() - leftPadding - rightPadding; 
float height = canvas.getHeight() - topPadding - getPaddingBottom() 
        - backgroundPaint.getTextSize() + 0.5f; 

if (getLayoutDirection() == LAYOUT_DIRECTION_RTL) { 
    leftPadding = getPaddingEnd(); 
    labelLeftPadding = leftPadding + width + maxLabelWidth * 0.25f; 
} 

我们唯一需要做的改变是检查布局方向是否为LAYOUT_DIRECTION_RTL,并更改leftPaddinglabelLeftPadding,以更新绘制图表和标签的位置。

自定义

在上一章我们已经看到了如何向自定义视图添加参数。在本章中我们构建的图表自定义视图,我们可以配置例如颜色、线条粗细、点的大小等等,但相反,我们将关注其他类型的自定义,例如,反转垂直轴,以及启用或禁用底部和侧标签或图表图例的渲染。与之前的配置相比,这些将需要一些额外的代码调整和特定实现。

我们先从允许反转垂直轴开始。我们的默认实现将在顶部渲染较小的值,在图表底部渲染较大的值。这可能不是预期的结果,所以让我们添加一种方法来反转轴:

private boolean invertVerticalAxis;

public void setInvertVerticalAxis(boolean invertVerticalAxis) {
    this.invertVerticalAxis = invertVerticalAxis;
    regenerate = true;
    postInvalidate();
}

然后,我们只需改变标签生成的步骤,并在适用的情况下反转数据点的值。要更改标签的生成,我们可以通过简单地更新步骤的顺序来实现。我们不是从01获取一个数字,而是反转这个过程,从10获取一个数字:

float maxLabelWidth = 0.f;
if (regenerate) {
    for (int i = 0; i <= 10; i++) {
        float step;

        if (!invertVerticalAxis) {
 step = ((float) i / 10.f);
 } else {
 step = ((float) (10 - i)) / 10.f;
 }

        float value = step * verticalDelta + minValue;
        verticalLabels[i] = decimalFormat.format(value);
        backgroundPaint.getTextBounds(verticalLabels[i], 0,
        verticalLabels[i].length(), textBoundaries);
        if (textBoundaries.width() > maxLabelWidth) {
            maxLabelWidth = textBoundaries.width();
        }
    }
}

如果需要,根据标志位的值获取数据点的反转值,让我们添加一个新方法来实现:

private float getDataPoint(int i) { 
    float data = dataPoints[i]; 
    return invertVerticalAxis ? 1.f - data : data; 
} 

现在,我们不是直接从数组获取数据点,而应该使用这个方法,因为它会在需要时透明地反转数字。

如我们之前提到的,我们还添加了一个setLabels()方法,因此标签也可以在外部进行自定义。

我们还可以添加一个boolean类型的标志,以允许或阻止绘制图例和背景线条:

private boolean drawLegend;

public void setDrawLegend(boolean drawLegend) {
    this.drawLegend = drawLegend;
    regenerate = true;
    postInvalidate();
}

在绘制背景线条和标签之前,只需检查此标志的状态。

在 GitHub 仓库的Example34-Charts文件夹中查看完整的示例。

添加高级功能

我们一直在构建一个简单的图表自定义视图实现。但是,我们的自定义视图可能需要一些更多的功能,否则可能会显得有些静态或不太有用。我们无法构建我们可能想到或可能需要的所有功能。同时,我们也应该注意不要构建一个瑞士军刀式的自定义视图,因为它可能难以维护,并且可能对自定义视图性能产生影响。

实时更新

在我们自定义视图的首次简单实现中,我们创建了一个设置数据点的方法,但无法修改或更新数据。让我们进行一些快速更改,以便能够动态添加点。在这个实现中,我们在setDataPoints()方法中直接将值调整到了 0 到 1 的刻度。由于我们将提供一个添加新数据值的方法,我们可能会得到超出原有最小值和最大值的值,这将使之前计算的刻度无效。

首先,让我们用集合而不是数组来存储数据,这样我们可以轻松添加新值:

private ArrayList<Float> dataPoints;

public void setDataPoints(float[] originalData) {
    ArrayList<Float> array = new ArrayList<>();
    for (float data : originalData) {
        array.add(data);
    }

    setDataPoints(array);
}

public void setDataPoints(ArrayList<Float> originalData) {
    dataPoints = new ArrayList<Float>();
    dataPoints.addAll(originalData);

    adjustDataRange();
}

我们将数据存储在ArrayList中,并修改了setDataPoints()方法以便能够这样做。同时,我们创建了adjustDataRange()方法来重新计算数据的范围,并触发数据重新生成和视图的重新绘制:

private void adjustDataRange() {
    minValue = Float.MAX_VALUE;
    maxValue = Float.MIN_VALUE;
    for (int i = 0; i < dataPoints.size(); i++) {
        if (dataPoints.get(i) < minValue) minValue = dataPoints.get(i);
        if (dataPoints.get(i) > maxValue) maxValue = dataPoints.get(i);
    }

    verticalDelta = maxValue - minValue;

    regenerate = true;
    postInvalidate();
}

addValue()方法的实现相当简单。我们将新数据添加到ArrayList中,如果它在当前范围内,我们只需触发图形的重新生成和视图的重新绘制。如果它超出了当前范围,我们调用adjustDataRange()方法来调整所有数据到新范围:

public void addValue(float data) {
    dataPoints.add(data);

    if (data < minValue || data > maxValue) {
        adjustDataRange();
    } else {
        regenerate = true;
        postInvalidate();
    }
}

我们只需修改getDataPoint()方法,将数据调整到01的范围:

private float getDataPoint(int i) { 
    float data = (dataPoints.get(i) - minValue) / verticalDelta; 
    return invertVerticalAxis ? 1.f - data : data; 
} 

如果我们运行示例,可以看到可以向图中添加新点,它会自动调整。要完全更改或更新数据,必须调用setDataPoints()方法。

多个数据集

有时,我们希望显示多个图表以进行比较,或者简单地同时显示多个数据集。让我们进行一些修改,以允许在我们的图表自定义视图中同时显示两个图表。它可以进一步扩展以支持更多的图表,但在这个示例中,我们将限制为两个以简化逻辑。

首先,我们需要为每个图表创建不同的 Paint 和 Path 对象。我们将创建数组来存储它们,这样稍后迭代和渲染它们会更容易。例如,我们可以为每个图表创建具有不同颜色的多个 Paint 对象:

linePaint = new Paint[2]; 
linePaint[0] = new Paint(); 
linePaint[0].setAntiAlias(true); 
linePaint[0].setColor(0xffffffff); 
linePaint[0].setStrokeWidth(8.f); 
linePaint[0].setStyle(Paint.Style.STROKE); 

linePaint[1] = new Paint(); 
linePaint[1].setAntiAlias(true); 
linePaint[1].setColor(0xff4040ff); 
linePaint[1].setStrokeWidth(8.f); 
linePaint[1].setStyle(Paint.Style.STROKE); 
circlePaint = new Paint[2]; 
circlePaint[0] = new Paint(); 
circlePaint[0].setAntiAlias(true); 
circlePaint[0].setColor(0xffff2020); 
circlePaint[0].setStyle(Paint.Style.FILL);  
circlePaint[1] = new Paint(); 
circlePaint[1].setAntiAlias(true); 
circlePaint[1].setColor(0xff20ff20); 
circlePaint[1].setStyle(Paint.Style.FILL); 

实际上,一次又一次地设置相同的参数是一项相当多的工作,因此我们可以使用Paint的另一个构造函数,它从一个已存在的Paint对象复制属性:

linePaint = new Paint[2]; 
linePaint[0] = new Paint(); 
linePaint[0].setAntiAlias(true); 
linePaint[0].setColor(0xffffffff); 
linePaint[0].setStrokeWidth(8.f); 
linePaint[0].setStyle(Paint.Style.STROKE);

linePaint[1] = new Paint(linePaint[0]); 
linePaint[1].setColor(0xff4040ff); 

circlePaint = new Paint[2]; 
circlePaint[0] = new Paint(); 
circlePaint[0].setAntiAlias(true); 
circlePaint[0].setColor(0xffff2020); 
circlePaint[0].setStyle(Paint.Style.FILL); 

circlePaint[1] = new Paint(circlePaint[0]); 
circlePaint[1].setColor(0xff20ff20); 

还有Path对象和数据存储:

graphPath = new Path[2]; 
graphPath[0] = new Path(); 
graphPath[1] = new Path(); 

circlePath = new Path[2]; 
circlePath[0] = new Path(); 
circlePath[1] = new Path(); 

dataPoints = (ArrayList<Float>[]) new ArrayList[2]; 

我们还需要一个机制来将数据添加到特定的数据集:

public void setDataPoints(ArrayList<Float> originalData, int index) {
    dataPoints[index] = new ArrayList<Float>();
    dataPoints[index].addAll(originalData);

    adjustDataRange();
}

由于我们将拥有不同的数据集,我们必须计算所有数据集的最小值和最大值。我们将每个图使用相同的刻度,这样比较起来更容易:

private void adjustDataRange() {
    minValue = Float.MAX_VALUE;
    maxValue = Float.MIN_VALUE;
    for (int j = 0; j < dataPoints.length; j++) {
        for (int i = 0; dataPoints[j] != null && i <
        dataPoints[j].size(); i++) {
            if (dataPoints[j].get(i) < minValue) minValue =
            dataPoints[j].get(i);
            if (dataPoints[j].get(i) > maxValue) maxValue =
            dataPoints[j].get(i);
        }
    }

    verticalDelta = maxValue - minValue;

    regenerate = true;
    postInvalidate();
}

最后,我们需要更新getDataPoint()方法,以允许我们从不同的数据集中获取数据:

private float getDataPoint(int i, int index) { 
    float data = (dataPoints[index].get(i) - minValue) / verticalDelta; 
    return invertVerticalAxis ? 1.f - data : data; 
} 

使用这些方法,我们可以更新路径生成代码以生成多个Path。如果该图的 数据集未定义,它将不会生成Path

for (int j = 0; j < 2; j++) {
    if (dataPoints[j] != null) {
        float x = leftPadding;
        float y = height * getDataPoint(0, j) + topPadding;

        graphPath[j].moveTo(x, y);
        circlePath[j].addCircle(x, y, 10, Path.Direction.CW);

        for (int i = 1; i < dataPoints[j].size(); i++) {
            x = width * (((float) i + 1) / dataPoints[j].size()) + 
            leftPadding;
            y = height * getDataPoint(i, j) + topPadding;

            graphPath[j].lineTo(x, y);
            circlePath[j].addCircle(x, y, 10, Path.Direction.CW);
        }
    }
}

渲染代码,只是遍历所有生成的Path并使用相应的Paint对象进行绘制:

for (int j = 0; j < graphPath.length; j++) {
    canvas.drawPath(graphPath[j], linePaint[j]);
    canvas.drawPath(circlePath[j], circlePaint[j]);
}

如果我们用两组随机数据运行这个示例,我们将看到类似于以下屏幕的内容:

放大和滚动

我们可以实现的另一个有趣功能是自定义视图的放大和滚动能力。就像我们在上一章中所做的那样,我们将使用 Android 的ScaleDetector类来检测捏合手势并在自定义视图中更新放大。

实现将与上一章有很大不同。在这种情况下,我们会以更简单的方式来做。由于我们希望放大所有内容,我们将应用canvas转换,而不是再次重新生成缩放的Path对象,但首先,让我们实现手势检测器并添加滚动和动画属性的能力。

我们几乎可以复制之前在自定义 EPG 视图中使用的相同方法,用于动画变量逻辑的检查,以及我们是否还有未完成的动画:

private boolean missingAnimations() {
    if (Math.abs(scrollXTarget - scrollX) > ANIM_THRESHOLD) 
        return true;

    if (Math.abs(scrollYTarget - scrollY) > ANIM_THRESHOLD)
        return true;

    return false;
}

private void animateLogic() {
    long currentTime = SystemClock.elapsedRealtime();
    accTime += currentTime - timeStart;
    timeStart = currentTime;

    while (accTime > TIME_THRESHOLD) {
        scrollX += (scrollXTarget - scrollX) / 4.f;
        scrollY += (scrollYTarget - scrollY) / 4.f;
        accTime -= TIME_THRESHOLD;
    }

    float factor = ((float) accTime) / TIME_THRESHOLD;
    float nextScrollX = scrollX + (scrollXTarget - scrollX) / 4.f;
    float nextScrollY = scrollY + (scrollYTarget - scrollY) / 4.f;

    frScrollX = scrollX * (1.f - factor) + nextScrollX * factor;
    frScrollY = scrollY * (1.f - factor) + nextScrollY * factor;
}

我们还可以几乎原封不动地添加检查拖动事件、将触摸事件发送到缩放检测器并根据拖动量滚动屏幕的代码:

@Override
public boolean onTouchEvent(MotionEvent event) {
    scaleDetector.onTouchEvent(event);

    if (zooming) {
        invalidate();
        zooming = false;
        return true;
    }

    switch(event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            dragX = event.getX();
            dragY = event.getY();

            getParent().requestDisallowInterceptTouchEvent(true);
            dragged = false;
            return true;

        case MotionEvent.ACTION_UP:
            getParent().requestDisallowInterceptTouchEvent(false);
            return true;

        case MotionEvent.ACTION_MOVE:
            float newX = event.getX();
            float newY = event.getY();

            scrollScreen(dragX - newX, dragY - newY);

            dragX = newX;
            dragY = newY;
            dragged = true;
            return true;
        default:
            return false;
    }
}

private void scrollScreen(float dx, float dy) {
    scrollXTarget += dx;
    scrollYTarget += dy;

    if (scrollXTarget < 0) scrollXTarget = 0;
    if (scrollYTarget < 0) scrollYTarget = 0;

    if (scrollXTarget > getWidth() * scale - getWidth()) {
        scrollXTarget = getWidth() * scale - getWidth();
    }

    if (scrollYTarget > getHeight() * scale - getHeight()) {
        scrollYTarget = getHeight() * scale - getHeight();
    }

    invalidate();
}

我们定义了一个名为 scale 的变量,它将控制我们对图表自定义视图的放大(或缩放)量。现在,让我们编写scaleDetector的实现:

scaleDetector = new ScaleGestureDetector(context, new ScaleGestureDetector.SimpleOnScaleGestureListener() {
    private float focusX;
    private float focusY;
    private float scrollCorrectionX = 0.f;
    private float scrollCorrectionY = 0.f;

    @Override
    public boolean onScaleBegin(ScaleGestureDetector detector) {
        zooming = true;
        focusX = detector.getFocusX();
        focusY = detector.getFocusY();
        scrollCorrectionX = focusX * scale - scrollXTarget;
        scrollCorrectionY = focusY * scale - scrollYTarget;
        return true;
    }

    public boolean onScale(ScaleGestureDetector detector) {
        scale *= detector.getScaleFactor();
        scale = Math.max(1.f, Math.min(scale, 2.f));

        float currentX = focusX * scale - scrollXTarget;
        float currentY = focusY * scale - scrollYTarget;

        scrollXTarget += currentX - scrollCorrectionX;
        scrollYTarget += currentY - scrollCorrectionY;

        invalidate();
        return true;
    }

    @Override
    public void onScaleEnd(ScaleGestureDetector detector) {
        zooming = true;
    }
});

我们还实现了一个滚动校正机制,以尽可能保持放大时的居中。在这种情况下,我们必须在水平和垂直轴上实现它。算法背后的主要思想是计算手势关注点的水平和垂直位置,并在改变缩放时调整滚动位置,以保持其位置不变。

现在,我们的onDraw()方法将简单地从以下内容开始:

animateLogic(); 

canvas.save(); 

canvas.translate(-frScrollX, -frScrollY); 
canvas.scale(scale, scale); 

我们需要通过调用animateLogic()来检查和处理动画周期,然后正确地表现并保存我们的canvas状态,应用由滚动值frScrollXfrScrollY确定的平移,以及通过scale变量缩放整个canvas

我们要渲染的所有内容都将被滚动位置偏移并由 scale 变量的值进行缩放。在结束方法之前,我们必须恢复我们的canvas,并在不是所有的属性动画都完成时触发新的重绘周期:

canvas.restore(); 
if (missingAnimations()) invalidate(); 

在 GitHub 仓库的Example35-Charts文件夹中查看完整的示例源代码。

总结

在本章中,我们了解了如何在 Android 应用程序中构建图表的自定义视图。我们还快速介绍了如何管理内边距、RTL / LTR 支持,最后通过支持多个数据集或添加放大和滚动的功能,为我们的自定义视图增加了复杂性。

我们实现这个自定义视图的方式;使用独立的数据范围并动态适应屏幕,意味着它将自动调整以适应任何屏幕分辨率,或者例如,适应屏幕方向的改变。这通常是一个好习惯,可以防止在多种设备上测试自定义视图时出现许多问题。此外,像我们在上一个示例中所做的那样,使屏幕上绘制的一切大小依赖于屏幕密度,将使可移植性更加容易。

在下一章中,我们将展示如何利用前几章中介绍的三维渲染功能来构建自定义视图。

第十一章:创建一个 3D 旋转轮菜单

除了第五章,介绍 3D 自定义视图,我们解释了如何使用 OpenGL ES 构建自定义视图之外,本书中的所有其他示例都使用了Canvas类提供的 2D 绘图方法。在最后两章中,我们看到了如何构建稍微复杂的自定义视图,但它们都没有使用任何 3D 渲染技术。因此,在本章中,我们将展示如何构建和自定义一个完整的 3D 视图以及如何与之交互。

更详细地说,我们将在本章介绍以下内容:

  • 向 3D 自定义视图添加交互

  • 添加GestureDetector以管理复杂的手势

  • 使用scroller管理滚动和抛动手势

  • 将文本渲染成纹理并在 OpenGL ES 上绘制它们

  • 程序化生成几何图形

创建一个交互式的 3D 自定义视图

在第五章,介绍 3D 自定义视图中,我们看到了如何使用 OpenGL ES 创建一个非常简单的旋转立方体。从这个示例开始,只需添加一种对用户交互做出反应的方式,我们就可以创建更复杂、更具交互性的自定义视图的基础。

添加交互

让我们从使用Example25-GLDrawing的代码开始。处理用户交互非常简单,正如我们在之前的示例中已经看到的。我们不需要做与之前不同的任何事情,只需在我们扩展GLSurfaceView的类中重写onTouchEvent()方法,并正确地对我们将接收到的不同 MotionEvents 做出反应。例如,如果我们接收到MotionEvent.ACTION_DOWN时不返回true,我们将不会收到任何进一步的事件,因为基本上我们是在说我们不处理这个事件。

一旦我们有示例的源代码,让我们添加一个简单的onTouchEvent()实现,用于跟踪拖动事件:

private float dragX; 
private float dragY; 

@Override 
public boolean onTouchEvent(MotionEvent event) { 
   switch(event.getAction()) { 
       case MotionEvent.ACTION_DOWN: 
           dragX = event.getX(); 
           dragY = event.getY(); 

           getParent().requestDisallowInterceptTouchEvent(true); 
           return true; 

       case MotionEvent.ACTION_UP: 
           getParent().requestDisallowInterceptTouchEvent(false); 
           return true; 

       case MotionEvent.ACTION_MOVE: 
           float newX = event.getX(); 
           float newY = event.getY(); 

           angleTarget -= (dragX - newX) / 3.f; 

           dragX = newX; 
           dragY = newY; 
           return true; 
       default: 
           return false; 
   } 
} 

我们将使用拖动量来改变立方体的旋转角度,我们将在以下代码片段中看到这一点。此外,在本章稍后,我们将看到如何使用scroller类进行此动画,但目前,让我们使用固定时间步进机制:

private float angle = 0.f; 
private float angleTarget = 0.f; 
private float angleFr = 0.f; 

private void animateLogic() { 
    long currentTime = SystemClock.elapsedRealtime(); 
    accTime += currentTime - timeStart; 
    timeStart = currentTime; 

    while (accTime > TIME_THRESHOLD) { 
        angle += (angleTarget - angle) / 4.f; 
        accTime -= TIME_THRESHOLD; 
    } 

    float factor = ((float) accTime) / TIME_THRESHOLD; 
    float nextAngle = angle + (angleTarget - angle) / 4.f; 

    angleFr = angle * (1.f - factor) + nextAngle * factor; 
} 

它使用了我们之前示例中一直遵循的相同原则,每隔TIME_THRESHOLD毫秒执行一次逻辑滴答。立方体角度值将在当前状态和下一个状态之间根据剩余执行时间进行插值。这个插值值将存储在angleFr变量中。

我们还对onSurfaceChanged进行了一些修改,以使用透视投影模式,而不是使用Matrix.frustrumM。后者定义了六个裁剪平面:近、远、上、下、左和右。然而,使用Matrix.perspective允许我们以摄像机视场角和两个裁剪平面:近和远来定义投影矩阵。在某些情况下可能更方便,但最终,这两种方法实现了相同的目标:

@Override 
public void onSurfaceChanged(GL10 unused, int width, int height) { 
    GLES20.glViewport(0, 0, width, height); 

    float ratio = (float) width / height; 
    Matrix.perspectiveM(mProjectionMatrix, 0, 90, ratio, 0.1f, 7.f); 
} 

最后,我们需要对onDrawFrame()方法进行一些修改:

@Override 
public void onDrawFrame(GL10 unused) { 
animateLogic();
    GLES20.glClearColor(1.0f, 0.0f, 0.0f, 1.0f); 
    GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT | GLES20.GL_DEPTH_BUFFER_BIT); 

    Matrix.setLookAtM(mViewMatrix, 0, 
            0, 0, -3, 
            0f, 0f, 0f, 
            0f, 1.0f, 0.0f); 

    Matrix.multiplyMM(mMVPMatrix, 0, mProjectionMatrix, 0, mViewMatrix, 0);
    Matrix.rotateM(mMVPMatrix, 0, angleFr, 0.f, 1.f, 0.f);
    Matrix.rotateM(mMVPMatrix, 0, 5.f, 1.f, 0.f, 0.f);
    GLES20.glUseProgram(shaderProgram); 
    int positionHandle = GLES20.glGetAttribLocation(shaderProgram, "vPosition"); 
    GLES20.glVertexAttribPointer(positionHandle, 3, 
            GLES20.GL_FLOAT, false, 
            0, vertexBuffer); 

    int texCoordHandle = GLES20.glGetAttribLocation(shaderProgram, "aTex"); 
    GLES20.glVertexAttribPointer(texCoordHandle, 2, GLES20.GL_FLOAT, false, 
                                 0, texBuffer); 
    int mMVPMatrixHandle = GLES20.glGetUniformLocation(shaderProgram,
                           "uMVPMatrix"); 

    GLES20.glUniformMatrix4fv(mMVPMatrixHandle, 1, false, mMVPMatrix, 0); 

    int texHandle = GLES20.glGetUniformLocation(shaderProgram, "sTex"); 
    GLES20.glActiveTexture(GLES20.GL_TEXTURE0); 
    GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId); 
    GLES20.glUniform1i(texHandle, 0); 

    GLES20.glEnable(GLES20.GL_DEPTH_TEST); 
    GLES20.glEnableVertexAttribArray(texHandle); 
    GLES20.glEnableVertexAttribArray(positionHandle); 
    GLES20.glDrawElements( 
            GLES20.GL_TRIANGLES, index.length, 
            GLES20.GL_UNSIGNED_SHORT, indexBuffer); 

    GLES20.glDisableVertexAttribArray(positionHandle); 
    GLES20.glDisableVertexAttribArray(texHandle); 
    GLES20.glDisable(GLES20.GL_DEPTH_TEST); 
} 

基本上,我们需要做的更改是调用animateLogic()方法来执行任何挂起的逻辑滴答,并使用插值的angleFr变量作为旋转角度。如果我们运行这个例子,我们将得到与Example25中相同的立方体,但在这个情况下,我们可以通过在屏幕上水平拖动来控制动画。我们还必须记住,从GLSurfaceView扩展我们的类时,无需调用invalidatepostInvalidate,除非特别指出,屏幕将不断重绘。

提高交互和动画效果。

我们一直在使用固定时间步机制来管理动画,但让我们看看使用 Android 提供的scroller类来处理动画,而不是自己处理所有动画,会给我们带来哪些优势。

首先,让我们创建一个GestureDetector实例来处理触摸事件:

private GestureDetectorCompat gestureDetector =  
        new GestureDetectorCompat(context, new MenuGestureListener()); 

我们使用了支持库中的GestureDetectorCompat,以确保在旧版本的 Android 上具有相同的行为。

正如我们在第三章《处理事件》中所述,通过引入GestureDetector,我们可以大大简化onTouchEvent(),因为所有逻辑将由MenuGestureListener回调处理,而不是在onTouchEvent()中处理:

@Override 
public boolean onTouchEvent(MotionEvent event) { 
    return gestureDetector.onTouchEvent(event); 
} 

gestureDetector需要一个OnGestureListener的实现,但如果我们只想实现一些方法,不想关心接口公开的其他方法,我们可以从GestureDetector.SimpleOnGestureListener继承,只覆盖我们需要的方法。GestureDetector.SimpleOnGestureListener类为OnGestureListener接口公开的所有方法提供了一个空的实现。

SimpleOnGestureListener还实现了其他接口,以使我们的软件工程师生活更加轻松,但请参考 Android 文档获取更多信息。

接下来,让我们创建自己的内部类MenuGestureListener,从GestureDetector.SimpleOnGestureListener继承:

class MenuGestureListener extends  
        GestureDetector.SimpleOnGestureListener { 
   @Override 
   public boolean onDown(MotionEvent e) { 
       scroller.forceFinished(true); 
       return true; 
   } 

   @Override 
   public boolean onScroll(MotionEvent e1, MotionEvent e2, float
   distanceX,
   float distanceY) { 
       scroller.computeScrollOffset(); 
       int lastX = scroller.getCurrX(); 

       scroller.forceFinished(true); 
       scroller.startScroll(lastX, 0, -(int) (distanceX + 0.5f), 0); 
       return true; 
   } 

   @Override 
   public boolean onFling(MotionEvent e1, MotionEvent e2, float 
   velocityX, float velocityY) { 
       scroller.computeScrollOffset(); 
       int lastX = scroller.getCurrX(); 

       scroller.forceFinished(true); 
       scroller.fling(lastX,
             0, 
             (int) (velocityX/4.f),
             0, 
             -360*100,
             360*100,
             0,
             0); 
       return true; 
   } 
} 

如我们之前提到的,即使是一个OnGestureListener实现,我们也需要在onDown()方法中返回true。否则,当有滚动或抛掷事件时,我们的OnGestureListener实现中的onScroll()onFling()方法不会被调用。

无论如何,我们仍然需要在onDown()方法上做一些工作:我们需要停止任何正在运行的动画,以便自定义视图对用户的操作更加灵敏。

我们实现了另外两个方法:onScroll()onFling()。它们都管理直接映射到不同滚动方式的不同的手势。当我们拖动屏幕时,将调用onScroll()方法,因为实际上我们正在滚动。另一方面,当我们执行抛动手势;即,当用户快速拖动并从屏幕上抬起手指时,我们需要考虑其他参数,如动画的速度和摩擦力。当手势结束时,动画仍会运行一段时间,根据定义的摩擦力逐渐减慢直至停止。在这种情况下,将调用我们监听器中的onFling()方法,并带有抛动事件的水平和垂直速度,由我们来处理摩擦力。

在这两个事件中,我们将使用scroller类来简化计算。我们本可以自己完成,尽管实现onScroll()逻辑相当直接,但正确实现onFling()动画需要一些计算和复杂性,通过使用scroller类我们可以省去这些麻烦。

onScroll()实现中,我们仅从当前位置和拖动距离调用scrollerstartScroll方法。为了获取当前位置,我们首先需要调用scroller.computeScrollOffset。如果我们不调用它,当前值将始终为零。调用此方法后,我们可以通过使用getCurrX方法来获取scroller的当前值。

由于在我们的监听器中获取的是浮点数距离,而startScroll只接受整数值,因此我们将distanceX值四舍五入,只需加上 0.5 然后转换为整数值。

类似地,在onFling()实现中,我们将调用scrollerfling方法。我们将获取当前位置,如我们在onScroll()实现中所描述的,并且我们将调整速度,因为从旋转立方体的动画角度来看它太高了。我们将立方体的最大和最小值设置为 100 圈,因为在正常情况下,我们不希望限制旋转。

现在,通过使用scroller,我们可以摆脱animateLogic()方法及其所有相关变量,因为我们不再需要它们。在滚动和抛动两种手势中,动画将在后台执行,我们可以直接从scroller实例查询当前的动画值。

onDraw()方法上我们需要做的唯一更改是调用scroller.computeScrollOffset方法以获取更新后的值,并且不是使用angleFr变量,而是从scroller获取值:

Matrix.rotateM(mMVPMatrix, 0, scroller.getCurrX(), 0.f, 1.f, 0.f); 

添加可操作回调

让我们把这个转换成一个可操作的菜单。我们可以将一个动作映射到立方体的每个面。由于我们是沿着y轴水平旋转立方体,我们可以将一个动作映射到四个可用的每个面上。

为了更加清晰,目前旋转可能会在立方体的一个面的中间结束,让我们添加一个小功能:每当动画结束时,让它捕捉到最近的面对齐,这样在没有动画运行时,我们总会有一个完全对齐的立方体前方面。

实现捕捉功能相当简单。我们需要检查动画是否已经完成,如果是,检查哪个面正对相机。我们可以简单地将当前旋转角度除以90;四个面分割360度,每个面是90度。为了查看我们是否比下一个面更接近这个面,我们需要获取旋转角度的小数部分。如果我们计算旋转角度模90,我们会得到一个介于089之间的数字。如果这个结果小于从一个面切换到另一个面所需度数的一半,我们就在正确的面上。然而,在相反的情况下,如果这个结果大于45,或者小于-45,我们则分别需要旋转到下一个面或上一个面。让我们在onDraw()方法中,在调用scroller.computeScrollOffset之后,编写这个小逻辑:

if (scroller.isFinished()) { 
    int lastX = scroller.getCurrX(); 
    int modulo = lastX % 90; 
    int snapX = (lastX / 90) * 90; 
    if (modulo >= 45) snapX += 90; 
    if (modulo <- 45) snapX -= 90; 

    if (lastX != snapX) { 
        scroller.startScroll(lastX, 0, snapX - lastX, 0); 
    } 
} 

为了计算捕捉角度,我们对90进行整数除法并将结果乘以90。由于这是整数除法,它会去掉小数部分并计算该面的绝对角度值。另一种编写该代码的方式如下:

int face = lastX / 90; 
int snapX = face * 90; 

然后,根据模运算的结果,我们加上90或减去90,有效地转到下一个面或上一个面。

现在,让我们添加管理用户点击的代码。首先,让我们创建一个事件监听器的接口,将事件处理委托给该监听器:

interface OnMenuClickedListener { 
    void menuClicked(int option); 
} 

同时,让我们在我们的类中添加一个OnMenuClickedListener变量以及一个设置方法:

private OnMenuClickedListener listener; 

public void setOnMenuClickedListener(OnMenuClickedListener listener) { 
    this.listener = listener; 
} 

现在,我们可以在MenuGestureListener上实现onSingleTapUp方法:

@Override 
public boolean onSingleTapUp(MotionEvent e) { 
    scroller.computeScrollOffset(); 
    int angle = scroller.getCurrX(); 
    int face = (angle / 90) % 4; 
    if (face < 0) face += 4; 

    if (listener != null) listener.menuClicked(face); 
    return true; 
} 

让我们在activity_main布局文件中为我们的自定义视图添加一个id,这样我们就可以从代码中获取GLDrawer视图:

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

    android:id="@+id/activity_main" 
    android:layout_width="match_parent" 
    android:layout_height="match_parent" 
    android:orientation="vertical" 
    android:padding="@dimen/activity_vertical_margin" 
    tools:context="com.packt.rrafols.draw.MainActivity"> 

<com.packt.rrafols.draw.GLDrawer 
android:id="@+id/gldrawer"
        android:layout_width="match_parent" 
        android:layout_height="match_parent"/> 
</LinearLayout> 

最后,修改MainActivity类,创建一个OnMenuClickedListener并将其设置到GLDrawer视图:

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);
    GLDrawer glDrawer = (GLDrawer) findViewById(R.id.gldrawer);
    glDrawer.setOnMenuClickedListener(new
    GLDrawer.OnMenuClickedListener() {
        @Override
        public void menuClicked(int option) {
            Log.i("Example36-Menu3D", "option clicked " + option);
        }
    });
}

如果我们运行这个例子,我们会看到MainActivity正在记录我们点击立方体的哪个面:

com.packt.rrafols.draw I/Example36-Menu3D: 选项点击了 3

com.packt.rrafols.draw I/Example36-Menu3D: 选项点击了 2

我们还将看到捕捉功能是如何工作的。玩一玩它,看看它是如何捕捉到当前面,下一个面,或者如果我们向后滚动,则捕捉到前一个面。

自定义

我们仍然以Example25中留下来的方式渲染立方体。让我们改变它,用不同的实心颜色绘制每个立方体面。我们可以为每个顶点定义不同的颜色,但由于顶点在面之间共享,它们的颜色将会被插值。

我们将不得不复制一些顶点,以便每个面都有不同的唯一颜色:

private float quadCoords[] = { 
        -1.f, -1.f, -1.0f,  // 0 
        -1.f,  1.f, -1.0f,  // 1 
         1.f,  1.f, -1.0f,  // 2 
         1.f, -1.f, -1.0f,  // 3 

        -1.f, -1.f,  1.0f,  // 4 
        -1.f,  1.f,  1.0f,  // 5 
         1.f,  1.f,  1.0f,  // 6 
         1.f, -1.f,  1.0f,   // 7 

        -1.f, -1.f, -1.0f,  // 8 - 0 
        -1.f, -1.f,  1.0f,  // 9 - 4 
         1.f, -1.f,  1.0f,  // 10 - 7 
         1.f, -1.f, -1.0f,  // 11 - 3 

        -1.f,  1.f, -1.0f,  // 12 - 1 
        -1.f,  1.f,  1.0f,  // 13 - 5 
         1.f,  1.f,  1.0f,  // 14 - 6 
         1.f,  1.f, -1.0f,  // 15 - 2 

        -1.f, -1.f, -1.0f,  // 16 - 0 
        -1.f, -1.f,  1.0f,  // 17 - 4 
        -1.f,  1.f,  1.0f,  // 18 - 5 
        -1.f,  1.f, -1.0f,  // 19 - 1 

         1.f, -1.f, -1.0f,  // 20 - 3 
         1.f, -1.f,  1.0f,  // 21 - 7 
         1.f,  1.f,  1.0f,  // 22 - 6 
         1.f,  1.f, -1.0f   // 23 - 2 
}; 

private short[] index = { 
        0, 1, 2,        // front 
        0, 2, 3,        // front 
        4, 5, 6,        // back 
        4, 6, 7,        // back 
        8, 9,10,        // top 
        8,11,10,        // top 
       12,13,14,        // bottom 
       12,15,14,        // bottom 
       16,17,18,        // left 
       16,19,18,        // left 
       20,21,22,        // right 
       20,23,22         // right 
};  

我们还更新了索引,以便映射到新的面。在复制的顶点上,我们添加了带有新索引和旧索引的注释。

现在,我们可以定义一些颜色:

float colors[] = { 
        0.0f, 1.0f, 0.0f, 1.0f, 
        0.0f, 1.0f, 0.0f, 1.0f, 
        0.0f, 1.0f, 0.0f, 1.0f, 
        0.0f, 1.0f, 0.0f, 1.0f, 

        0.0f, 0.0f, 1.0f, 1.0f, 
        0.0f, 0.0f, 1.0f, 1.0f, 
        0.0f, 0.0f, 1.0f, 1.0f, 
        0.0f, 0.0f, 1.0f, 1.0f, 

        0.0f, 0.0f, 0.0f, 1.0f, 
        0.0f, 0.0f, 0.0f, 1.0f, 
        0.0f, 0.0f, 0.0f, 1.0f, 
        0.0f, 0.0f, 0.0f, 1.0f, 

        1.0f, 1.0f, 1.0f, 1.0f, 
        1.0f, 1.0f, 1.0f, 1.0f, 
        1.0f, 1.0f, 1.0f, 1.0f, 
        1.0f, 1.0f, 1.0f, 1.0f, 

        1.0f, 1.0f, 0.0f, 1.0f, 
        1.0f, 1.0f, 0.0f, 1.0f, 
        1.0f, 1.0f, 0.0f, 1.0f, 
        1.0f, 1.0f, 0.0f, 1.0f, 

        1.0f, 0.0f, 1.0f, 1.0f, 
        1.0f, 0.0f, 1.0f, 1.0f, 
        1.0f, 0.0f, 1.0f, 1.0f, 
        1.0f, 0.0f, 1.0f, 1.0f 
}; 

让我们也改变initBuffer方法中的纹理初始化,创建一个颜色Buffer,就像在Example24-GLDrawing中所做的那样:

ByteBuffer cbb = ByteBuffer.allocateDirect(colors.length * (Float.SIZE / 8)); 
cbb.order(ByteOrder.nativeOrder()); 

colorBuffer = cbb.asFloatBuffer(); 
colorBuffer.put(colors); 
colorBuffer.position(0); 

更新像素和顶点Shader

private final String vertexShaderCode = 
        "uniform mat4 uMVPMatrix;" + 
        "attribute vec4 vPosition;" + 
        "attribute vec4 aColor;" + 
        "varying vec4 vColor;" + 
        "void main() {" + 
        "  gl_Position = uMVPMatrix * vPosition;" + 
        "  vColor = aColor;" + 
        "}"; 

private final String fragmentShaderCode = 
        "precision mediump float;" + 
        "varying vec4 vColor;" + 
        "void main() {" + 
        "  gl_FragColor = vColor;" + 
        "}"; 

为了使其更具可配置性,让我们在GLDrawer上创建一个公共的setColors()方法来更改颜色:

public void setColors(int[] faceColors) { 
    glRenderer.setColors(faceColors); 
} 

Renderer上的实现如下:

private void setColors(int[] faceColors) { 
    colors = new float[4 * 4 * faceColors.length]; 
    int wOffset = 0; 
    for (int faceColor : faceColors) { 
        float[] color = hexToRGBA(faceColor); 
        for(int j = 0; j < 4; j++) { 
            colors[wOffset++] = color[0]; 
            colors[wOffset++] = color[1]; 
            colors[wOffset++] = color[2]; 
            colors[wOffset++] = color[3]; 
        } 
    } 
    ByteBuffer cbb = ByteBuffer.allocateDirect(colors.length *
    (Float.SIZE /8)); 
    cbb.order(ByteOrder.nativeOrder()); 

    colorBuffer = cbb.asFloatBuffer(); 
    colorBuffer.put(colors); 
    colorBuffer.position(0); 
} 

为了简单起见,我们将颜色作为整数传递,而不是浮点数数组,这样我们就可以使用十六进制编码的颜色。要将整数颜色转换为浮点数数组,我们可以使用一个简单的辅助方法:

private float[] hexToRGBA(int color) { 
    float[] out = new float[4]; 

    int a = (color >> 24) & 0xff; 
    int r = (color >> 16) & 0xff; 
    int g = (color >>  8) & 0xff; 
    int b = (color      ) & 0xff; 

    out[0] = ((float) r) / 255.f; 
    out[1] = ((float) g) / 255.f; 
    out[2] = ((float) b) / 255.f; 
    out[3] = ((float) a) / 255.f; 
    return out; 
} 

为了更新示例,让我们使用刚才添加的方法设置一些颜色:

glDrawer.setColors(new int[] { 
        0xff4a90e2, 
        0xff161616, 
        0xff594236, 
        0xffff5964, 
        0xff8aea92, 
        0xffffe74c 
}); 

如果我们运行示例,我们将得到类似于以下截图的内容:

在 GitHub 仓库的Example36-Menu3D文件夹中查看此示例的完整源代码。

超越基本实现

我们已经拥有了一个非常基础且可操作的 3D 菜单,但为了使其能够用于生产应用程序,我们还需要添加一些更多细节。例如,现在我们可以根据我们选择的立方体面来选择不同的菜单选项,但除非我们正在做一个非常简单的颜色选择器,否则我们将完全盲目地选择一个选项,因为我们不知道每个面具体做什么。

解决此问题的一种方法是,根据选择的面来渲染一些文本,但在 OpenGL ES 上,我们不能像使用Canvas时那样简单地调用drawText来渲染文本。此外,在这个例子中,只有四个可选择的面或选项;让我们做一些更改,以便有更多可选择选项。

渲染文本

正如我们刚才提到的,为了渲染文本,我们不能仅仅调用一个drawText方法,在小型 3D 场景内渲染 3D 文本。实际上,我们会使用drawText,但只是将其渲染在背景Bitmap上,该Bitmap将用作我们还将渲染的附加平面的纹理。

为此,我们定义了该平面的几何形状:

private float planeCoords[] = { 
        -1.f, -1.f, -1.4f, 
        -1.f,  1.f, -1.4f, 
         1.f,  1.f, -1.4f, 
         1.f, -1.f, -1.4f, 
}; 

private short[] planeIndex = { 
        0, 1, 2, 
        0, 2, 3 
}; 

private float texCoords[] = { 
        1.f, 1.f, 
        1.f, 0.f, 
        0.f, 0.f, 
        0.f, 1.f 
}; 

由于立方体前面位于 z 坐标-1.f,这个平面将位于-1.4f,即比它靠前 0.4f,否则它可能会被立方体遮挡。

我们需要再次添加顶点和片段Shader,以便使用纹理进行渲染。尽管我们不会替换代码中已有的Shader,但我们将不得不同时使用这两套Shader

private final String vertexShaderCodeText = 
        "uniform mat4 uMVPMatrix;" + 
        "attribute vec4 vPosition;" + 
        "attribute vec2 aTex;" + 
        "varying vec2 vTex;" + 
        "void main() {" + 
        "  gl_Position = uMVPMatrix * vPosition;" + 
        "  vTex = aTex;" + 
        "}"; 

private final String fragmentShaderCodeText = 
        "precision mediump float;" + 
        "uniform sampler2D sTex;" + 
        "varying vec2 vTex;" + 
        "void main() {" + 
        "  gl_FragColor = texture2D(sTex, vTex);" + 
        "}"; 

让我们也更新initBuffers方法,以初始化两套Buffers

private void initBuffers() { 
    ByteBuffer vbb = ByteBuffer.allocateDirect(quadCoords.length  
            * (Float.SIZE / 8)); 
    vbb.order(ByteOrder.nativeOrder()); 

    vertexBuffer = vbb.asFloatBuffer(); 
    vertexBuffer.put(quadCoords); 
    vertexBuffer.position(0); 

    ByteBuffer ibb = ByteBuffer.allocateDirect(index.length 
            * (Short.SIZE / 8)); 
    ibb.order(ByteOrder.nativeOrder()); 

    indexBuffer = ibb.asShortBuffer(); 
    indexBuffer.put(index); 
    indexBuffer.position(0); 

    ByteBuffer cbb = ByteBuffer.allocateDirect(colors.length 
            * (Float.SIZE / 8)); 
    cbb.order(ByteOrder.nativeOrder()); 

    colorBuffer = cbb.asFloatBuffer(); 
    colorBuffer.put(colors); 
    colorBuffer.position(0); 

    vbb = ByteBuffer.allocateDirect(planeCoords.length 
            * (Float.SIZE / 8)); 
    vbb.order(ByteOrder.nativeOrder()); 

    vertexTextBuffer = vbb.asFloatBuffer(); 
    vertexTextBuffer.put(planeCoords); 
    vertexTextBuffer.position(0); 

    ibb = ByteBuffer.allocateDirect(planeIndex.length 
            *  (Short.SIZE / 8)); 
    ibb.order(ByteOrder.nativeOrder()); 

    indexTextBuffer = ibb.asShortBuffer(); 
    indexTextBuffer.put(planeIndex); 
    indexTextBuffer.position(0); 

    ByteBuffer tbb = ByteBuffer.allocateDirect(texCoords.length 
            * (Float.SIZE / 8)); 
    tbb.order(ByteOrder.nativeOrder()); 

    texBuffer = tbb.asFloatBuffer(); 
    texBuffer.put(texCoords); 
    texBuffer.position(0); 
}              

如我们所见,此方法分配了两套缓冲区:一套用于立方体,另一套用于我们将用来绘制文本的平面。我们必须对顶点和片段Shaders采取类似的方法,我们必须加载并链接两套Shaders

private void initShaders() { 
    int vertexShader = loadShader(GLES20.GL_VERTEX_SHADER, vertexShaderCode); 
    int fragmentShader = loadShader(GLES20.GL_FRAGMENT_SHADER, 
    fragmentShaderCode); 

    shaderProgram = GLES20.glCreateProgram(); 
    GLES20.glAttachShader(shaderProgram, vertexShader); 
    GLES20.glAttachShader(shaderProgram, fragmentShader); 
    GLES20.glLinkProgram(shaderProgram); 

    vertexShader = loadShader(GLES20.GL_VERTEX_SHADER, vertexShaderCodeText); 
    fragmentShader = loadShader(GLES20.GL_FRAGMENT_SHADER,
    fragmentShaderCodeText); 

    shaderTextProgram = GLES20.glCreateProgram(); 
    GLES20.glAttachShader(shaderTextProgram, vertexShader); 
    GLES20.glAttachShader(shaderTextProgram, fragmentShader); 
    GLES20.glLinkProgram(shaderTextProgram); 
} 

我们将用于在纹理中绘制文本的着色器附加到我们将存储在shaderTextProgram变量中的另一个着色器程序。根据我们想要渲染的内容,我们现在可以从shaderProgramshaderTextProgram之间切换。

现在让我们创建一个方法,该方法返回一个在其上居中文本的Bitmap

private Bitmap createBitmapFromText(String text) { 
    Bitmap out = Bitmap.createBitmap(512, 512,
    Bitmap.Config.ARGB_8888); 
    out.eraseColor(0x00000000); 

    Paint textPaint = new Paint(); 
    textPaint.setAntiAlias(true); 
    textPaint.setColor(0xffffffff); 
    textPaint.setTextSize(60); 
    textPaint.setStrokeWidth(2.f); 
    textPaint.setStyle(Paint.Style.FILL); 

    Rect textBoundaries = new Rect(); 
    textPaint.getTextBounds(text, 0, text.length(), textBoundaries); 

    Canvas canvas = new Canvas(out); 
    for (int i = 0; i < 2; i++) { 
        canvas.drawText(text, 
                (canvas.getWidth() - textBoundaries.width()) / 2.f, 
                (canvas.getHeight() - textBoundaries.height()) / 2.f + 
                 textBoundaries.height(), textPaint); 
        textPaint.setColor(0xff000000); 
        textPaint.setStyle(Paint.Style.STROKE); 
    } 
    return out; 
} 

此方法创建一个大小为512512Bitmap,每个颜色组件具有八位位深度,包含四个组成部分:alpha(透明度)、红色、绿色和蓝色。然后,它创建一个Paint对象,设置文本的颜色和大小,获取文本边界以便在Bitmap上居中,并在从Bitmap获取的Canvas对象上绘制两次文本。文本绘制两次,因为它首先使用纯白色绘制文本,然后,当我们把Paint对象的样式改为STROKE时,它使用黑色绘制轮廓。

我们在之前示例中的代码用于从本地资源加载纹理,它将其转换为未缩放的Bitmap,我们可以重用大部分代码来加载我们生成的Bitmap。让我们恢复我们已有的loadTexture()方法,但让我们更改它以使用辅助方法将Bitmap上传到Texture

private int loadTexture(int resId) { 
    final int[] textureIds = new int[1]; 
    GLES20.glGenTextures(1, textureIds, 0); 

    if (textureIds[0] == 0) return -1; 

    // do not scale the bitmap depending on screen density 
    final BitmapFactory.Options options = new BitmapFactory.Options(); 
    options.inScaled = false; 

    final Bitmap textureBitmap =
    BitmapFactory.decodeResource(getResources(),
    resId, options); 
    attachBitmapToTexture(textureIds[0], textureBitmap); 

    return textureIds[0]; 
} 

辅助方法的实现如下:

private void attachBitmapToTexture(int textureId, Bitmap textureBitmap) { 
    GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId); 

    GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, 
            GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR); 

    GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, 
            GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR); 

    GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, 
            GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE); 

    GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, 
            GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE); 

    GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, textureBitmap, 0); 
} 

我们只需要创建一个将所有内容结合在一起的方法:即,一个从文本生成Bitmap,生成textureIds,将Bitmap上传为纹理,并回收Bitmap的方法:

private int generateTextureFromText(String text) { 
    final int[] textureIds = new int[1]; 
    GLES20.glGenTextures(1, textureIds, 0); 

    Bitmap textureBitmap = createBitmapFromText(text); 
    attachBitmapToTexture(textureIds[0], textureBitmap); 
    textureBitmap.recycle(); 
    return textureIds[0]; 
} 

使用此方法,我们现在可以为立方体的每个面生成不同的纹理:

@Override 
public void onSurfaceCreated(GL10 unused, EGLConfig config) { 
    initBuffers(); 
    initShaders(); 

    textureId = new int[4]; 
    for (int i = 0; i < textureId.length; i++) { 
        textureId[i] = generateTextureFromText("Option " + (i + 1)); 
    } 
} 

我们现在可以在onDraw()方法的底部添加一些额外的代码,以在立方体每个面的前方绘制一个平面:

GLES20.glUseProgram(shaderTextProgram); 
positionHandle = GLES20.glGetAttribLocation(shaderTextProgram, "vPosition"); 

GLES20.glVertexAttribPointer(positionHandle, 3, 
        GLES20.GL_FLOAT, false, 
        0, vertexTextBuffer); 

int texCoordHandle = GLES20.glGetAttribLocation(shaderTextProgram, "aTex"); 
GLES20.glVertexAttribPointer(texCoordHandle, 2, 
        GLES20.GL_FLOAT, false, 
        0, texBuffer); 

int texHandle = GLES20.glGetUniformLocation(shaderTextProgram, "sTex"); 
GLES20.glActiveTexture(GLES20.GL_TEXTURE0); 
GLES20.glEnable(GLES20.GL_BLEND); 
GLES20.glBlendFunc(GLES20.GL_SRC_ALPHA, GLES20.GL_ONE_MINUS_SRC_ALPHA); 

for (int i = 0; i < 4; i++) { 
    GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId[i]); 
    GLES20.glUniform1i(texHandle, 0); 

    mMVPMatrixHandle = GLES20.glGetUniformLocation(shaderTextProgram,
    "uMVPMatrix"); 
    GLES20.glUniformMatrix4fv(mMVPMatrixHandle, 1, false, mMVPMatrix,
    0); 

    GLES20.glEnableVertexAttribArray(texHandle); 
    GLES20.glEnableVertexAttribArray(positionHandle); 
    GLES20.glDrawElements( 
            GLES20.GL_TRIANGLES, planeIndex.length, 
            GLES20.GL_UNSIGNED_SHORT, indexTextBuffer); 

    GLES20.glDisableVertexAttribArray(positionHandle); 
    GLES20.glDisableVertexAttribArray(texHandle); 

    Matrix.rotateM(mMVPMatrix, 0, -90.f, 0.f, 1.f, 0.f); 
} 

GLES20.glDisable(GLES20.GL_BLEND); 
GLES20.glDisable(GLES20.GL_DEPTH_TEST); 

如我们所见,我们将positionHandle更改为平面几何,启用纹理顶点数组,并且,我们还需要启用混合模式。由于除了文本外,文本纹理将是透明的,我们需要启用混合,否则 OpenGL ES 将渲染透明像素为黑色。

为了绘制不同的平面,为立方体的每个水平面绘制一个,我们进行了一个小循环,每次迭代绑定不同的纹理并旋转 90 度。

如果我们运行这个示例,我们将看到类似于以下截图的内容:

多个面

现在我们已经在立方体的面上添加了渲染文本的能力,这样在点击选项时我们可以知道选择了什么,但我们仍然局限于四种不同的选项。目前,我们在代码中用几个数组对几何形状进行了硬编码。如果我们想要使选项的数量或面的数量动态化,我们就需要以编程方式生成几何形状和面索引。

幸运的是,我们的起点是在 3D 圆中有几个选择,所以我们只需要生成一个具有多个面的空心圆柱体,正好与我们想要的选项数量相同。

让我们在GLDrawer自定义视图类中添加一个方法,允许我们设置将要拥有的选项和面的数量:

public void setNumOptions(int options) { 
    double halfAngle = Math.PI / options; 
    float[] coords = new float[options * 3 * 4]; 
    int offset = 0; 
    for (int i = 0; i < options; i++) { 
        float angle = (float) (i * 2.f * Math.PI / options 
                - Math.PI / 2.f - halfAngle); 

        float nextAngle = (float) ((i + 1) * 2.f * Math.PI / options 
                - Math.PI / 2.f - halfAngle); 

        float x0 = (float) Math.cos(angle) * 1.2f; 
        float x1 = (float) Math.cos(nextAngle) * 1.2f; 
        float z0 = (float) Math.sin(angle) * 1.2f; 
        float z1 = (float) Math.sin(nextAngle) * 1.2f; 

        coords[offset++] = x0; 
        coords[offset++] = -1.f; 
        coords[offset++] = z0; 

        coords[offset++] = x1; 
        coords[offset++] = -1.f; 
        coords[offset++] = z1; 

        coords[offset++] = x0; 
        coords[offset++] = 1.f; 
        coords[offset++] = z0; 

        coords[offset++] = x1; 
        coords[offset++] = 1.f; 
        coords[offset++] = z1; 
    } 

    short[] index = new short[options * 6]; 
    for (int i = 0; i < options; i++) { 
        index[i * 6 + 0] = (short) (i * 4 + 0); 
        index[i * 6 + 1] = (short) (i * 4 + 1); 
        index[i * 6 + 2] = (short) (i * 4 + 3); 

        index[i * 6 + 3] = (short) (i * 4 + 0); 
        index[i * 6 + 4] = (short) (i * 4 + 2); 
        index[i * 6 + 5] = (short) (i * 4 + 3); 
    } 

    glRenderer.setCoordinates(options, coords, index); 
} 

以圆柱体的形式生成不同的面,只需将一个圆的360度(或两倍的PI弧度)除以我们想要的面数。在这里,我们将2.f*Math.PI除以选项的数量,然后乘以循环迭代器。通过计算该角度的正弦和余弦,我们可以得到两个坐标,通常是 2D 投影中的xy,但在我们的特定情况下,我们将它映射到xz,因为我们将y坐标设置为-1.f作为顶部垂直边缘,1.f作为底部垂直边缘。我们还在计算下一个xz坐标,这样我们就可以在这些点之间创建一个面四边形。

我们为每个面生成四个点,并将它们作为索引数组中的两个三角形进行索引。这与我们之前生成颜色的方式完全匹配,因为我们为每个面生成了四个颜色值,现在我们也为每个面精确生成四个顶点,每个面都将有一个独特的实心颜色。

在方法的最后,我们调用了GLRenderersetCoordinates()方法,但实现起来非常简单:

private void setCoordinates(int options, float[] coords, short[] index) { 
    this.quadCoords = coords; 
    this.index = index; 
    this.options = options; 
} 

只要我们在创建表面之前调用它,它就可以在不接触其他任何东西的情况下工作。正如我们所说,我们需要更新onSurfaceCreated()方法,以使用我们设置的选项数量,而不是之前代码中硬编码的默认四个:

@Override 
public void onSurfaceCreated(GL10 unused, EGLConfig config) { 
    initBuffers(); 
    initShaders(); 

    textureId = new int[options]; 
    for (int i = 0; i < textureId.length; i++) { 
        textureId[i] = generateTextureFromText("Option " + (i + 1)); 
    } 

    faceAngle = 360.f / options; 
} 

我们还在计算需要旋转多少度才能从一个面切换到另一个面。在我们之前的案例中这是简单的,因为只有四个面,360度除以 4 等于 90 度。现在,计算仍然简单,但我们需要用我们创建的这个新变量faceAngle替换代码中硬编码的 90 度,其值是360除以选项的数量。

让我们在MainActivity中测试这个新功能,在设置不同的颜色之后立即调用它:

@Override 
protected void onCreate(Bundle savedInstanceState) { 
    super.onCreate(savedInstanceState); 

    setContentView(R.layout.activity_main); 

    GLDrawer glDrawer = (GLDrawer) findViewById(R.id.gldrawer); 
    glDrawer.setOnMenuClickedListener(new
    GLDrawer.OnMenuClickedListener() { 
        @Override 
        public void menuClicked(int option) { 
            Log.i("Example37-Menu3D", "option clicked " + option); 
        } 
    }); 
    glDrawer.setColors(new int[] { 
            0xff4a90e2, 
            0xff161616, 
            0xff594236, 
            0xffff5964, 
            0xff8aea92, 
            0xffffe74c 
    }); 

    glDrawer.setNumOptions(6); 
} 

我们没有特别添加检查,但颜色的数量至少应该与选项的数量相同,否则在渲染时我们会遇到异常。

如果我们运行这个示例,我们将看到类似于以下截图的内容,具体取决于当前的旋转:

在 GitHub 仓库的Example37-Menu3D文件夹中查看这个示例的完整源代码。

总结

在本章中,我们学习了如何向 3D 自定义视图添加交互功能以实现互动性。此外,我们还了解了如何使用scroller实例来管理滚动和抛动手势,以及如何将文本渲染为纹理,并使用不同的 Buffer 和不同的Shaders来应用不同的几何形状。最后,我们还学习了如何轻松生成几何图形,使我们的自定义视图具有适应性和动态性。

在这本书中,我们学习了如何构建不同类型的自定义视图,并根据我们的需要使用 Android SDK 中的方法或类,或者使用我们自己的方法。我们还了解了如何构建 2D 和 3D 自定义视图,并使它们对用户输入做出反应。最终,借助我们所展示的所有 API 和大量的创造力,我们可以构建任何我们想要的自定义视图。但我们仍需牢记,Android 为我们提供了一个不断进化的优秀框架,其中包含了许多优秀且高效的绘制出色 UI 的方法,但有时我们想要构建一些特别的,无法仅通过标准 API 轻松实现的东西。

要进一步了解构建 Android UI 和自定义视图,开发博客上有大量的教程,许多开源的自定义视图,以及各种聚会和会议上的讨论环节。参加本地的聚会和会议不仅是了解自定义视图的好方法,还能跟上 Android 开发的最新动态。Android 社区有许多举措,我真心鼓励每个人尽其所能地贡献,以保持 Android 社区的活力和卓越。

posted @ 2024-05-23 11:09  绝不原创的飞龙  阅读(7)  评论(0编辑  收藏  举报