安卓-NDK-初学者指南第二版-全-

安卓 NDK 初学者指南第二版(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Android NDK 通过利用这些移动设备的最大速度,将高性能和可移植代码注入你的移动应用中。Android NDK 允许你为密集型任务编写快速代码,并将现有代码移植到 Android 和非 Android 平台。另外,如果你有一个包含多行 C 代码的应用程序,使用 NDK 可以显著减少项目开发过程。这是多媒体和游戏中最有效的操作系统之一。

这本初学者指南将向你展示如何创建由 C/C++支持的应用程序,并将它们与 Java 集成。通过使用这个实用的分步指南,并逐步使用教程、技巧和窍门来练习你的新技能,你将学会如何在 Java 应用程序中嵌入 C/C++代码,或者在一个独立的应用程序中运行。

本书首先会教你如何访问一些最成功的 Android 应用程序中使用的原生 API 和端口库。接下来,你将通过完整实现一个原生 API 和移植现有的第三方库,来创建一个真正的原生应用程序项目。随着章节的深入,你将详细了解使用 OpenGL ES 和 OpenSL ES 渲染图形和播放声音的细节,这些正在成为移动领域的新标准。继续前进,你将学会如何访问键盘和输入外设,以及读取加速度计或方向传感器。最后,你将深入探讨更高级的主题,如 RenderScript。

到本书结束时,你将足够熟悉关键要素,开始利用原生代码的强大功能和可移植性。

本书内容

第一章,设置你的环境,涵盖了我们系统上安装的所有必备软件包。这一章还介绍了安装 Android Studio 软件包,其中包含了 Android Studio IDE 和 Android SDK。

第二章,开始一个原生 Android 项目,讨论了如何使用命令行工具构建我们的第一个示例应用程序,以及如何将其部署在 Android 设备上。我们还将使用 Eclipse 和 Android Studio 创建我们的第一个原生 Android 项目。

第三章,使用 JNI 接口 Java 和 C/C++,介绍了如何让 Java 与 C/C++通信。我们还处理在本地代码中使用全局引用的 Java 对象引用,并了解局部引用的差异。最后,我们在本地代码中引发并检查 Java 异常。

第四章,从本地代码调用 Java,使用 JNI 反射 API 从本地代码调用 Java 代码。我们还借助 JNI 以本地方式处理位图,并手动解码视频馈送。

第五章,编写完全本地应用程序,讨论了创建NativeActivity以相应地开始或停止本地代码轮询活动事件。我们还以本地方式访问显示窗口,例如位图以显示原始图形。最后,我们获取时间,使应用程序能够使用单调时钟适应设备速度。

第六章,使用 OpenGL ES 渲染图形,涵盖了如何初始化 OpenGL ES 上下文并将其绑定到 Android 窗口。然后,我们了解如何将libpng转换为一个模块,并从 PNG 资源中加载纹理。

第七章,使用 OpenSL ES 播放声音,涵盖了如何在 Android 上初始化 OpenSL ES。然后,我们学习如何从编码文件播放背景音乐以及使用声音缓冲队列在内存中播放声音。最后,我们了解到如何以线程安全和非阻塞的方式录制和播放声音。

第八章,处理输入设备和传感器,讨论了多种从本地代码与 Android 交互的方式。更准确地说,我们了解到如何将输入队列附加到 Native App Glue 事件循环。

第九章,将现有库移植到 Android,涵盖了如何在 NDK makefile 系统中通过一个简单的标志激活 STL。我们将 Box2D 库移植为一个可在 Android 项目中重复使用的 NDK 模块。

第十章,使用 RenderScript 进行密集计算,介绍了 RenderScript,这是一种用于并行化密集计算任务的高级技术。我们还了解如何使用预定义的 RenderScript 与内置的 Intrinsics,这目前主要用于图像处理。

本书所需的条件

要运行本书中的示例,需要以下软件:

  • 系统:Windows,Linux 或 Mac OS X

  • JDK:Java SE 开发工具包 7 或 8

  • Cygwin:仅在 Windows 上

本书适合的读者

你是一个需要更高性能的 Android Java 程序员吗?你是一个不想为 Java 及其失控的垃圾收集器复杂性而烦恼的 C/C++开发者吗?你想创建快速、密集的多媒体应用程序或游戏吗?如果你对这些问题中的任何一个回答了“是”,那么这本书就是为你准备的。有了对 C/C++开发的一些基本了解,你将能够一头扎进本地 Android 开发。

部分

在本书中,你会发现有几个经常出现的标题(动手时间,刚才发生了什么?,小测验,以及尝试英雄)。

为了清楚地说明如何完成一个过程或任务,我们按照以下方式使用这些部分:

动手时间——标题

  1. 动作 1

  2. 动作 2

  3. 动作 3

指令通常需要一些额外的解释以确保它们有意义,因此它们后面会跟着这些部分:

刚才发生了什么?

本节解释了你刚刚完成的任务或指令的工作原理。

你在书中还会找到一些其他的学习辅助工具,例如:

尝试英雄——标题

这些是实践挑战,它们可以启发你尝试所学的知识。

约定

你还会发现一些文本样式,它们可以区分不同类型的信息。以下是一些样式示例及其含义的解释。

文本中的代码字、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 处理程序会以下面的形式显示:"最后,创建一个新的 Gradle 任务ndkBuild,它将手动触发ndk-build命令。"

代码块设置如下:

#include <unistd.h>
…
sleep(3); // in seconds

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

    if (mGraphicsManager.start() != STATUS_OK) return STATUS_KO;

    mAsteroids.initialize();
    mShip.initialize();

    mTimeManager.reset();
    return STATUS_OK;

任何命令行输入或输出都会以下面的形式编写:

adb shell stop
adb shell setprop dalvik.vm.checkjni true

术语重要 词汇会以粗体显示。你在屏幕上看到的词,比如菜单或对话框中的,会在文本中像这样出现:"如果一切正常,当你的应用程序启动时,Logcat 中会出现一个消息Late-enabling – Xcheck:jni。"

注意

警告或重要注意事项会像这样出现在一个框里。

提示

提示和技巧会像这样出现。

读者反馈

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

要向我们发送一般反馈,只需发送电子邮件到<feedback@packtpub.com>,并在邮件的主题中提及书籍的标题。

如果你有一个有专业知识的主题,并且你对于写作或为书籍做贡献感兴趣,请查看我们在www.packtpub.com/authors的作者指南。

客户支持

既然你现在拥有了 Packt 的一本书,我们有一些事情可以帮助你最大限度地利用你的购买。

下载示例代码

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

勘误

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

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

盗版

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

如果您有疑似盗版材料的链接,请通过<copyright@packtpub.com>联系我们。

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

问题

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

第一章:设置你的开发环境

你准备好接受移动开发挑战了吗?你的电脑打开了,鼠标和键盘插上了,屏幕照亮了你的桌子吗?那么我们不要再等一分钟了!

开发 Android 应用程序需要一套特定的工具。你可能已经了解到了用于纯 Java 应用程序的 Android 软件开发工具包(SDK)。然而,要完全访问 Android 设备的强大功能,还需要更多:Android 原生开发工具包(NDK)。

设置一个合适的 Android 环境并不是那么复杂,但它可能相当棘手。实际上,Android 仍然是一个不断发展的平台,最近的添加内容,如 Android Studio 或 Gradle,在 NDK 开发方面支持得并不好。尽管有这些烦恼,任何人都可以在一个小时内拥有一个可以立即工作的环境。

在第一章中,我们将要:

  • 安装必备软件包

  • 设置一个 Android 开发环境

  • 启动一个 Android 模拟器

  • 连接一个用于开发的 Android 设备

开始 Android 开发

区分人类与动物的是工具的使用。Android 开发者,你所属的真正物种,也不例外!

要在 Android 上开发应用程序,我们可以使用以下三个平台中的任何一个:

  • 微软 Windows(XP 及更高版本)

  • 苹果 OS X(版本 10.4.8 或更高版本)

  • Linux(使用 GLibc 2.7 或更高版本的发行版,如最新版本的 Ubuntu)

这些系统在 x86 平台(即使用 Intel 或 AMD 处理器的 PC)上支持 32 位和 64 位版本,Windows XP 除外(仅 32 位)。

这是一个不错的开始,但除非你能像说母语一样读写二进制代码,否则仅有一个原始操作系统是不够的。我们还需要专门用于 Android 开发的软件:

  • 一个JDKJava 开发工具包

  • 一个 Android SDK(软件开发工具包)

  • 一个 Android NDK(原生开发工具包)

  • 一个IDE集成开发环境),如 Eclipse 或 Visual Studio(或为硬核程序员准备的 vi)。尽管 Android Studio 和 IntelliJ 为原生代码提供了基本支持,但它们还不适合 NDK 开发。

  • 一个好的旧命令行终端来操作所有这些工具。我们将使用 Bash。

既然我们知道与 Android 工作需要哪些工具,那么让我们开始安装和设置过程。

注意

以下部分专门针对 Windows。如果你是 Mac 或 Linux 用户,可以跳到设置 OS X设置 Linux部分。

设置 Windows

在安装必要工具之前,我们需要正确设置 Windows 以承载我们的 Android 开发工具。尽管 Windows 并不是 Android 开发的最自然选择,但它仍然提供了一个功能齐全的环境。

以下部分将解释如何在 Windows 7 上设置必备软件包。这个过程同样适用于 Windows XP、Vista 或 8。

动手操作——为 Android 开发准备 Windows

要在 Windows 上使用 Android NDK 进行开发,我们需要设置一些先决条件:Cygwin、JDK 和 Ant。

  1. 访问cygwin.com/install.html并下载适合你环境的 Cygwin 安装程序。下载完成后,执行它。

  2. 在安装窗口中,点击下一步然后选择从互联网安装准备在 Windows 上开发 Android 的动作时间

    跟随安装向导屏幕操作。考虑选择一个在你国家下载 Cygwin 软件包的下载站点。

    然后,当提议时,包括DevelMakeShellsbash软件包:

    准备在 Windows 上开发 Android 的动作时间

    跟随安装向导直到完成。根据你的互联网连接,这可能需要一些时间。

  3. 从 Oracle 官网www.oracle.com/technetwork/java/javase/downloads/index.html下载 Oracle JDK 7(或者 JDK 8,尽管在本书编写时它还未正式支持)。启动并按照安装向导直到完成。

  4. 从 Ant 的官网ant.apache.org/bindownload.cgi下载 Ant,并将其二进制包解压到你选择的目录中(例如,C:\Ant)。

  5. 安装后,在环境变量中定义 JDK、Cygwin 和 Ant 的位置。为此,打开 Windows 控制面板 并进入 系统 面板(或者在 Windows 开始菜单中右键点击 计算机 项,选择 属性)。

    然后,进入高级系统设置。将出现系统属性窗口。最后,选择高级标签,点击环境变量按钮。

  6. 在环境变量窗口中,系统变量列表内添加:

    • 设置CYGWIN_HOME变量,其值为Cygwin安装目录(例如,C:\Cygwin

    • 设置JAVA_HOME变量,其值为 JDK 安装目录

    • 设置ANT_HOME变量,其值为 Ant 安装目录(例如,C:\Ant

    在你的PATH环境变量开头添加%CYGWIN_HOME%\bin;%JAVA_HOME%\bin;%ANT_HOME%\bin;,每个路径之间用分号隔开。

    准备在 Windows 上开发 Android 的动作时间

  7. 最后,启动 Cygwin 终端。第一次启动时将创建你的配置文件。检查make版本以确保 Cygwin 正常工作:

    make –version
    
    

    你将看到以下输出:

    准备在 Windows 上开发 Android 的动作时间

  8. 通过运行 Java 并检查其版本,确保 JDK 已正确安装。仔细检查以确保版本号与刚安装的 JDK 相符:

    java –version
    
    

    你将在屏幕上看到以下输出:

    准备在 Windows 上开发 Android 的动作时间

  9. 从经典的 Windows 终端,检查 Ant 版本以确保其正常工作:

    ant -version
    
    

    你将在终端上看到以下内容:

    准备在 Windows 上进行 Android 开发的行动时间

刚才发生了什么?

Windows 现在已设置好所有必要的软件包,以容纳 Android 开发工具:

  • Cygwin 是一个开源软件集合,它允许 Windows 平台模拟类似 Unix 的环境。它的目标是原生地将基于 POSIX 标准(如 Unix、Linux 等)的软件集成到 Windows 中。它可以被视为起源于 Unix/Linux(但在 Windows 上原生重新编译)的应用程序与 Windows 操作系统本身之间的中间层。Cygwin 包括Make,这是 Android NDK 编译系统构建原生代码所需的。

    提示

    即使 Android NDK R7 引入了原生的 Windows 二进制文件,不需要 Cygwin 运行时,但为了调试目的,仍然建议安装后者。

  • JDK 7,它包含了在 Android 上构建 Java 应用程序以及运行 Eclipse IDE 和 Ant 所需的运行时和工具。在安装 JDK 时,你可能遇到的唯一真正麻烦是一些来自之前安装的干扰,比如现有的Java 运行时环境JRE)。通过JAVA_HOMEPATH环境变量可以强制使用正确的 JDK。

    提示

    定义JAVA_HOME环境变量不是必须的。然而,JAVA_HOME是 Java 应用程序(包括 Ant)中的一个流行约定。它首先在JAVA_HOME(如果已定义)中查找java命令,然后才在PATH中查找。如果你稍后在其他位置安装了最新的 JDK,不要忘记更新JAVA_HOME

  • Ant 是一个基于 Java 的构建自动化工具。虽然这不是一个必需品,但它允许从命令行构建 Android 应用程序,如我们将在第二章,开始一个原生 Android 项目中看到的。它也是设置持续集成链的一个好解决方案。

下一步是设置 Android 开发工具包。

在 Windows 上安装 Android 开发工具

Android 需要特定的开发工具包来开发应用程序:Android SDK 和 NDK。幸运的是,谷歌考虑到了开发者社区,并免费提供所有必要的工具。

在以下部分,我们将安装这些工具,开始在 Windows 7 上开发原生的 Android 应用程序。

行动时间——在 Windows 上安装 Android SDK 和 NDK

Android Studio 软件包已经包含了 Android SDK。让我们来安装它。

  1. 打开你的网络浏览器,从developer.android.com/sdk/index.html下载 Android Studio 软件包。

    运行下载的程序,并按照安装向导操作。当被请求时,安装所有 Android 组件。

    行动时间——在 Windows 上安装 Android SDK 和 NDK

    然后,选择 Android Studio 和 Android SDK 的安装目录(例如,C:\Android\android-studioC:\Android\sdk)。

  2. 启动 Android Studio 以确保其正常工作。如果 Android Studio 提出从之前的安装导入设置,选择你偏好的选项并点击OK行动时间——在 Windows 上安装 Android SDK 和 NDK

    此时应该会出现 Android Studio 的欢迎屏幕。关闭它。

    行动时间——在 Windows 上安装 Android SDK 和 NDK

  3. 访问developer.android.com/tools/sdk/ndk/index.html,下载适合你环境的 Android NDK(不是 SDK!),将压缩文件解压到你选择的目录中(例如,C:\Android\ndk)。

  4. 为了从命令行轻松访问 Android 工具,让我们将 Android SDK 和 NDK 声明为环境变量。从现在开始,我们将这些目录称为$ANDROID_SDK$ANDROID_NDK

    打开环境变量系统窗口,就像我们之前做的那样。在系统变量列表中添加以下内容:

    • ANDROID_SDK变量应包含 SDK 安装目录(例如,C:\Android\sdk)。

    • ANDROID_NDK变量应包含 NDK 安装目录(例如,C:\Android\ndk)。

    在你的PATH环境变量开头添加%ANDROID_SDK%\tools;%ANDROID_SDK%\platform-tools;%ANDROID_NDK%;,用分号隔开。

    行动时间——在 Windows 上安装 Android SDK 和 NDK

  5. 当启动 Cygwin 时,所有 Windows 环境变量都应该自动被导入。打开一个 Cygwin 终端,使用adb列出连接到电脑的 Android 设备(即使当前没有连接的设备也要这样做),以检查 SDK 是否正常工作。不应该出现错误:

    adb devices
    
    

    行动时间——在 Windows 上安装 Android SDK 和 NDK

  6. 检查ndk-build版本,以确保 NDK 正常工作。如果一切正常,应该会出现Make版本:

    ndk-build -version
    
    

    行动时间——在 Windows 上安装 Android SDK 和 NDK

  7. 打开位于 ADB 捆绑目录根目录的Android SDK Manager行动时间——在 Windows 上安装 Android SDK 和 NDK

    在打开的窗口中,点击New选择所有包,然后点击Install packages...按钮。在弹出的窗口中接受许可协议,点击Install按钮开始安装 Android 开发包。

    经过几分钟的等待,所有包都已下载完毕,出现一条确认信息,表明 Android SDK 管理器已更新。

    确认并关闭管理器。

刚才发生了什么?

Android Studio 现在已安装在系统上。虽然它现在是官方的 Android IDE,但由于它对 NDK 的支持不足,我们在本书中不会大量使用它。然而,完全可以用 Android Studio 进行 Java 开发,以及使用命令行或 Eclipse 进行 C/C++ 开发。

Android SDK 通过 Android Studio 包进行了设置。另一种解决方案是手动部署 Google 提供的 SDK 独立包。另一方面,Android NDK 是从其归档文件手动部署的。通过几个环境变量,SDK 和 NDK 都可以通过命令行使用。

为了获得一个完全功能性的环境,所有 Android 包都已通过 Android SDK 管理器下载,该管理器旨在管理通过 SDK 可用的所有平台、源、示例和仿真功能。当新的 SDK API 和组件发布时,这个工具极大地简化了环境的更新。无需重新安装或覆盖任何内容!

然而,Android SDK 管理器不管理 NDK,这就是为什么我们要单独下载它,以及为什么将来你需要手动更新它。

提示

安装所有 Android 包并不是严格必要的。真正需要的是您的应用程序所针对的 SDK 平台(可能还有 Google APIs)版本。不过,安装所有包可能会在导入其他项目或示例时避免麻烦。

您的 Android 开发环境安装尚未完成。我们还需要一个东西,才能与 NDK 舒适地开发。

注意

这是一段专门介绍 Windows 设置的章节的结束。下一章节将专注于 OS X。

设置 OS X

Apple 电脑以其简单易用而闻名。我必须说,在 Android 开发方面,这个谚语是相当正确的。实际上,作为基于 Unix 的系统,OS X 很适合运行 NDK 工具链。

下一节将解释如何在 Mac OS X Yosemite 上设置前提条件包。

行动时间 - 准备 OS X 进行 Android 开发

要在 OS X 上使用 Android NDK 进行开发,我们需要设置一些前提条件:JDK、开发者工具和 Ant。

  1. OS X 10.6 Snow Leopard 及以下版本预装了 JDK。在这些系统上,Apple 的 JDK 是版本 6。由于这个版本已被弃用,建议安装更新的 JDK 7(或 JDK 8,尽管在本书编写时它没有得到官方支持)。

    另一方面,OS X 10.7 Lion 及以上版本没有默认安装 JDK。因此,安装 JDK 7 是强制性的。

    为此,从 Oracle 网站下载 Oracle JDK 7,网址为 www.oracle.com/technetwork/java/javase/downloads/index.html。启动 DMG 并按照安装向导直到结束。

    行动时间 - 准备 OS X 进行 Android 开发

    检查 Java 版本以确保 JDK 已正确安装。

    java -version
    
    

    动手操作——为 Android 开发准备 OS X

    提示

    要知道是否安装了 JDK 6,请检查通过转到 Mac 上的应用程序 | 实用工具找到的Java 偏好设置.app。如果你有 JDK 7,检查系统偏好设置下是否有Java图标。

  2. 所有开发者工具都包含在 XCode 安装包中(在本书编写时为版本 5)。XCode 在 AppStore 上免费提供。从 OS X 10.9 开始,开发者工具包可以通过终端提示符使用以下命令单独安装:

    xcode-select --install
    
    

    动手操作——为 Android 开发准备 OS X

    然后,从弹出的窗口中选择安装

  3. 要使用 Android NDK 构建本地代码,无论是否安装了 XCode 或单独的开发者工具包,我们都需要Make。打开终端提示符并检查Make版本以确保它能正常工作:

    make –version
    
    

    动手操作——为 Android 开发准备 OS X

  4. 在 OS X 10.9 及以后的版本中,需要手动安装 Ant。从 Ant 的官方网站ant.apache.org/bindownload.cgi下载 Ant,并将其二进制包解压到您选择的目录中(例如,/Developer/Ant)。

    然后,创建或编辑文件~/.profile,并通过添加以下内容使 Ant 在系统路径上可用:

    export ANT_HOME="/Developer/Ant"
    export PATH=${ANT_HOME}/bin:${PATH}
    

    从当前会话注销并重新登录(或重启计算机),并通过命令行检查 Ant 版本以确认 Ant 是否正确安装:

    ant –version
    
    

    动手操作——为 Android 开发准备 OS X

刚才发生了什么?

我们的 OS X 系统现在已设置好必要的软件包以支持 Android 开发工具:

  • JDK 7,它包含了在 Android 上构建 Java 应用程序以及运行 Eclipse IDE 和 Ant 所需的运行时和工具。

  • 开发者工具包,它包含了各种命令行工具。它包括 Make,这是 Android NDK 编译系统构建本地代码所需的。

  • Ant,这是一个基于 Java 的构建自动化工具。尽管这不是必须的,但它允许我们从命令行构建 Android 应用程序,如我们将在第二章,开始一个本地 Android 项目中看到的。它也是设置持续集成链的一个好解决方案。

下一步是设置 Android 开发工具包。

在 OS X 上安装 Android 开发工具包

Android 开发应用程序需要特定的开发工具包:Android SDK 和 NDK。幸运的是,Google 考虑到了开发者社区,并免费提供所有必要的工具。

在接下来的部分,我们将安装这些工具包,开始在 Mac OS X Yosemite 上开发本地 Android 应用程序。

动手操作——在 OS X 上安装 Android SDK 和 NDK

Android Studio 软件包已经包含了 Android SDK。我们来安装它。

  1. 打开您的网络浏览器,从developer.android.com/sdk/index.html下载 Android Studio 软件包。

  2. 运行下载的DMG文件。在出现的窗口中,将Android Studio图标拖到应用程序中,等待 Android Studio 完全复制到系统上。行动时间 – 在 OS X 上安装 Android SDK 和 NDK

  3. 从 Launchpad 运行 Android Studio。

    如果出现错误无法找到有效的 JVM(因为 Android Studio 在启动时找不到合适的 JRE),您可以通过命令行以下方式运行 Android Studio(使用适当的 JDK 路径):

    export STUDIO_JDK=/Library/Java/JavaVirtualMachines/jdk1.7.0_71.jdk
    open /Applications/Android\ Studio.apps
    
    

    提示

    为了解决 Android Studio 启动问题,您也可以安装苹果提供的旧版 JDK 6。注意!这个版本已经过时,因此不推荐使用。

    如果 Android Studio 提示您从之前的安装导入设置,选择您偏好的选项并点击确定

    行动时间 – 在 OS X 上安装 Android SDK 和 NDK

    在出现的下一个设置向导屏幕中,选择标准安装类型并继续安装。

    行动时间 – 在 OS X 上安装 Android SDK 和 NDK

    完成安装直到出现 Android Studio 欢迎屏幕。然后关闭 Android Studio。

    行动时间 – 在 OS X 上安装 Android SDK 和 NDK

  4. 访问developer.android.com/tools/sdk/ndk/index.html并下载适合您环境的 Android NDK(不是 SDK!)归档文件。将其解压到您选择的目录中(例如,~/Library/Android/ndk)。

  5. 为了从命令行轻松访问 Android 实用工具,我们将 Android SDK 和 NDK 声明为环境变量。从现在开始,我们将这些目录称为$ANDROID_SDK$ANDROID_NDK。假设您使用默认的Bash命令行外壳,在您的家目录中创建或编辑.profile(这是一个隐藏文件!)并在最后添加以下指令(根据您的安装调整路径):

    export ANDROID_SDK="~/Library/Android/sdk"
    export ANDROID_NDK="~/Library/Android/ndk"
    export PATH="${ANDROID_SDK}/tools:${ANDROID_SDK}/platform-tools:${ANDROID_NDK}:${PATH}"
    
  6. 从当前会话注销并重新登录(或者重启电脑)。使用adb列出连接到电脑的 Android 设备(即使当前没有连接的设备),以检查 Android SDK 是否正常工作。不应该出现错误:

    adb devices
    
    

    行动时间 – 在 OS X 上安装 Android SDK 和 NDK

  7. 检查ndk-build版本以确保 NDK 正常工作。如果一切正常,应该会显示Make版本:

    ndk-build -version
    
    

    行动时间 – 在 OS X 上安装 Android SDK 和 NDK

  8. 打开终端,使用以下命令启动 Android SDK 管理器:

    android
    
    

    行动时间 – 在 OS X 上安装 Android SDK 和 NDK

    在打开的窗口中,点击新建以选择所有软件包,然后点击安装软件包...按钮。在弹出的窗口中接受许可协议,并通过点击安装按钮开始安装所有 Android 软件包。

    几分钟后,所有软件包下载完毕,出现一条确认信息,表明 Android SDK 管理器已更新。

    验证并关闭管理器。

刚才发生了什么?

Android Studio 现在已安装在系统上。尽管它现在是官方的 Android IDE,但由于它对 NDK 的支持不足,我们在书中不会过多地使用它。然而,完全可以用 Android Studio 进行 Java 开发,以及使用命令行或 Eclipse 进行 C/C++开发。

Android SDK 已经通过 Android Studio 软件包进行了设置。另一种解决方案是手动部署由 Google 提供的 SDK 独立软件包。另一方面,Android NDK 则是从其归档文件中手动部署的。通过几个环境变量,SDK 和 NDK 都可以通过命令行使用。

提示

在处理环境变量时,OS X 会有些棘手。它们可以在.profile中轻松声明,供从终端启动的应用程序使用,正如我们刚才所做的。也可以使用environment.plist文件为那些不是从 Spotlight 启动的 GUI 应用程序声明。

为了获得一个完全可用的环境,所有 Android 软件包都通过 Android SDK 管理器下载,该管理器旨在管理通过 SDK 提供的所有平台、源、示例和仿真功能。当新的 SDK API 和组件发布时,这个工具可以大大简化你的环境更新工作。无需重新安装或覆盖任何内容!

然而,Android SDK 管理器并不管理 NDK,这就是为什么我们要单独下载 NDK,以及将来你需要手动更新它的原因。

提示

安装所有 Android 软件包并不是绝对必要的。真正需要的是你的应用程序所针对的 SDK 平台(可能还有 Google APIs)。不过,安装所有软件包可以避免在导入其他项目或示例时遇到麻烦。

你的 Android 开发环境安装尚未完成。我们还需要一个东西,以便更舒适地使用 NDK 进行开发。

注意

这是一段专门针对 OS X 设置的章节的结束。下一节将专门介绍 Linux。

设置 Linux

Linux 非常适合进行 Android 开发,因为 Android 工具链是基于 Linux 的。实际上,作为基于 Unix 的系统,Linux 非常适合运行 NDK 工具链。但是要注意,安装软件包的命令可能会根据你的 Linux 发行版而有所不同。

下一节将解释如何在 Ubuntu 14.10 Utopic Unicorn 上设置必备软件包。

动手时间——为 Android 开发准备 Ubuntu

要在 Linux 上使用 Android NDK 进行开发,我们需要设置一些先决条件:Glibc、Make、OpenJDK 和 Ant。

  1. 从命令提示符中检查是否安装了 Glibc(GNU C 标准库)2.7 或更高版本,通常 Linux 系统默认会安装:

    ldd -–version
    
    

    行动时间 - 准备 Ubuntu 以进行 Android 开发

  2. Make 也需要用来构建本地代码。从 build-essential 软件包中安装它(需要管理员权限):

    sudo apt-get install build-essential
    
    

    运行以下命令以确保正确安装了 Make,如果安装正确,将显示其版本:

    make –version
    
    

    行动时间 - 准备 Ubuntu 以进行 Android 开发

  3. 在 64 位 Linux 系统上,安装 32 位库兼容性软件包,因为 Android SDK 只有编译为 32 位的二进制文件。在 Ubuntu 13.04 及更早版本上,只需安装 ia32-libs 软件包即可:

    sudo apt-get install ia32-libs
    
    

    在 Ubuntu 13.10 64 位及以后的版本中,这个软件包已经被移除。因此,手动安装所需的软件包:

    sudo apt-get install lib32ncurses5 lib32stdc++6 zlib1g:i386 libc6-i386
    
    
  4. 安装 Java OpenJDK 7(或者 JDK 8,尽管在本书编写时它没有得到官方支持)。Oracle JDK 也可以:

    sudo apt-get install openjdk-7-jdk
    
    

    通过运行 Java 并检查其版本,确保 JDK 正确安装:

    java –version
    
    

    行动时间 - 准备 Ubuntu 以进行 Android 开发

  5. 使用以下命令安装 Ant(需要管理员权限):

    sudo apt-get install ant
    
    

    检查 Ant 是否正常工作:

    ant -version
    
    

    行动时间 - 准备 Ubuntu 以进行 Android 开发

刚才发生了什么?

我们的 Linux 系统现在已准备好必要的软件包以支持 Android 开发工具:

  • build-essential 软件包是 Linux 系统上用于编译和打包的最小工具集。它包括 Make,这是 Android NDK 编译系统构建本地代码所必需的。GCCGNU C 编译器)也包括在内,但不是必需的,因为 Android NDK 已经包含了自己的版本。

  • 64 位系统上的 32 位兼容库,因为 Android SDK 仍然使用 32 位二进制文件。

  • JDK 7,其中包含在 Android 上构建 Java 应用程序以及在 Eclipse IDE 和 Ant 中运行所需的运行时和工具。

  • Ant 是一个基于 Java 的构建自动化工具。尽管这不是一个硬性要求,但它允许我们从命令行构建 Android 应用程序,正如我们将在第二章《开始一个本地 Android 项目》中看到的那样。它也是设置持续集成链的一个好解决方案。

下一步是设置 Android 开发工具包。

在 Linux 上安装 Android 开发工具包

Android 开发应用程序需要特定的开发工具包:Android SDK 和 NDK。幸运的是,Google 已经考虑到了开发者社区,并免费提供所有必要的工具。

在以下部分,我们将安装这些工具包,以便在 Ubuntu 14.10 Utopic Unicorn 上开始开发本地 Android 应用程序。

行动时间 - 在 Ubuntu 上安装 Android SDK 和 NDK

Android Studio 包已经包含了 Android SDK。让我们来安装它。

  1. 打开你的网页浏览器,从 developer.android.com/sdk/index.html 下载 Android Studio 包。将下载的归档文件解压到你选择的目录中(例如,~/Android/Android-studio)。

  2. 运行 Android Studio 脚本 bin/studio.sh。如果 Android Studio 提出从之前的安装导入设置,选择你偏好的选项并点击 确定行动时间——在 Ubuntu 上安装 Android SDK 和 NDK

    在出现的下一个 设置 向导 屏幕上,选择 标准 安装类型并继续安装。

    行动时间——在 Ubuntu 上安装 Android SDK 和 NDK

    完成安装直到出现 Android Studio 欢迎屏幕。然后关闭 Android Studio。

    行动时间——在 Ubuntu 上安装 Android SDK 和 NDK

  3. 访问 developer.android.com/tools/sdk/ndk/index.html 并下载适合你环境的 Android NDK(不是 SDK!)归档文件。将其解压到你选择的目录中(例如,~/Android/Ndk)。

  4. 为了从命令行轻松访问 Android 实用工具,让我们将 Android SDK 和 NDK 声明为环境变量。从现在开始,我们将这些目录称为 $ANDROID_SDK$ANDROID_NDK。编辑你主目录中的 .profile 文件(注意这是一个隐藏文件!),并在文件末尾添加以下变量(根据你的安装目录调整它们的路径):

    export ANDROID_SDK="~/Android/Sdk"
    export ANDROID_NDK="~/Android/Ndk"
    export PATH="${ANDROID_SDK}/tools:${ANDROID_SDK}/platform-tools:${ANDROID_NDK}:${PATH}"
    
  5. 从当前会话中注销并重新登录(或者重启你的电脑)。使用 adb 列出连接到电脑的 Android 设备(即使当前没有连接也要列出),以检查 Android SDK 是否正常工作。不应该出现错误:

    adb devices
    
    

    行动时间——在 Ubuntu 上安装 Android SDK 和 NDK

  6. 检查 ndk-build 的版本以确保 NDK 正在运行。如果一切正常,应该会出现 Make 的版本:

    ndk-build -version
    
    

    行动时间——在 Ubuntu 上安装 Android SDK 和 NDK

  7. 打开终端,使用以下命令启动 Android SDK 管理器:

    android
    
    

    行动时间——在 Ubuntu 上安装 Android SDK 和 NDK

    在打开的窗口中,点击 新建 以选择所有包,然后点击 安装包... 按钮。在出现的弹出窗口中接受许可协议,并通过点击 安装 按钮开始所有 Android 包的安装。

    经过一些漫长的等待,所有包都已下载完毕,出现一条确认信息表明 Android SDK 管理器已更新。

    确认并关闭管理器。

刚才发生了什么?

现在系统上已经安装了 Android Studio。尽管它现在是官方的安卓 IDE,但由于它对 NDK 的支持不足,我们在本书中不会大量使用它。然而,完全可以用 Android Studio 进行 Java 开发,用命令行或 Eclipse 进行 C/C++开发。

安卓 SDK 已经通过 Android Studio 软件包进行了设置。另一种解决方案是手动部署谷歌提供的 SDK 独立安装包。另一方面,安卓 NDK 则是从其归档文件中手动部署的。通过几个环境变量,SDK 和 NDK 都可以在命令行中使用。

为了获得一个完全功能的环境,所有安卓软件包都是通过安卓 SDK 管理器下载的,该管理器旨在管理通过 SDK 提供的所有平台、源代码、示例和仿真功能。当新的 SDK API 和组件发布时,这个工具可以极大地简化环境的更新。无需重新安装或覆盖任何内容!

然而,安卓 SDK 管理器并不管理 NDK,这就是为什么我们要单独下载 NDK,以及为什么将来需要手动更新它的原因。

提示

安装所有的安卓软件包并非严格必要。真正需要的是您的应用程序所针对的 SDK 平台(可能还有 Google APIs)。不过,安装所有软件包可能会在导入其他项目或示例时避免麻烦。

安卓开发环境的安装还没有结束。我们还需要一个东西,才能更舒适地使用 NDK 进行开发。

注意

这是一段专门针对 Linux 设置的章节的结束。下一节适用于所有操作系统。

安装 Eclipse IDE

由于 Android Studio 的限制,Eclipse 仍然是最适合在安卓上开发本地代码的 IDE 之一。然而,使用 IDE 并非必须;命令行爱好者或vi狂热者可以跳过这一部分!

在下一节中,我们将了解如何设置 Eclipse。

行动时间 – 在您的操作系统上安装带有 ADT 的 Eclipse

自从最新的安卓 SDK 发布以来,Eclipse 及其插件(ADT 和 CDT)需要手动安装。为此,执行以下步骤:

  1. 访问www.eclipse.org/downloads/并下载适用于 Java 开发者的 Eclipse。将下载的压缩文件解压到您选择的目录中(例如,在 Windows 上的C:\Android\eclipse,Linux 上的~/Android/Eclipse,Mac OS X 上的~/Library/Android/eclipse)。

    然后,运行 Eclipse。如果 Eclipse 在启动时询问工作空间(其中包含 Eclipse 设置和项目),请定义您选择的位置或保留默认设置,然后点击确定

    当 Eclipse 加载完毕后,关闭欢迎页面。应该会出现以下窗口:

    行动时间 – 在您的操作系统上安装带有 ADT 的 Eclipse

  2. 转到 帮助 | 安装新软件…。在 工作空间: 字段中输入 https://dl-ssl.google.com/android/eclipse 并验证。几秒钟后,会出现一个 开发者工具 插件。选择它并点击 下一步 按钮。

    提示

    如果在访问更新站点时此步骤失败,请检查您的互联网连接。您可能是断开连接或通过代理连接。在后一种情况下,您可以从 ADT 网页上单独下载 ADT 插件存档并手动安装,或者配置 Eclipse 通过代理连接。

    操作时间 – 在您的操作系统上安装带有 ADT 的 Eclipse

    按照向导提示操作,并在询问时接受条件。在向导的最后一页,点击完成以安装 ADT。可能会出现警告,提示插件内容未签名。忽略它并点击确定。完成后,按照请求重启 Eclipse。

  3. 返回到 帮助 | 安装新软件…。打开 工作空间 下拉框,并选择包含 Eclipse 版本名称的项(这里为 Luna)。然后,勾选 只显示适用于目标环境的软件 选项。在插件树中找到 编程语言 并展开它。最后,勾选所有 C/C++ 插件并点击 下一步操作时间 – 在您的操作系统上安装带有 ADT 的 Eclipse

    按照向导提示操作,并在询问时接受条件。在向导的最后一页,点击完成。等待安装完成并重启 Eclipse。

  4. 转到 Windows | 首选项...(在 Mac OS X 上为 Eclipse | 首选项...),然后在左侧树中选择 Android。如果一切正常,SDK 位置应该已填写 Android SDK 路径。操作时间 – 在您的操作系统上安装带有 ADT 的 Eclipse

    然后,在同一个窗口中,转到 Android | NDKNDK 位置字段应为空。填写 Android NDK 路径并验证。如果路径错误,Eclipse 会提示目录无效。

    操作时间 – 在您的操作系统上安装带有 ADT 的 Eclipse

刚才发生了什么?

现在 Eclipse 已经配置好相应的 SDK 和 NDK 并运行起来了。由于 Google 不再提供 ADT 包,因此需要手动在 Eclipse 中安装 Android 开发插件 ADT 和 C/C++ Eclipse 插件 CDT。

请注意,Eclipse 已经被 Google 弃用,并由 Android Studio 替换。遗憾的是,目前 Android Studio 对 C/C++ 和 NDK 的支持相当有限。构建本地代码的唯一方式是通过 Gradle,这个新的 Android 构建系统,其 NDK 功能仍然不稳定。如果舒适的 IDE 对您至关重要,您仍然可以使用 Android Studio 进行 Java 开发,使用 Eclipse 进行 C/C++ 开发。

如果您在 Windows 上工作,可能您是 Visual Studio 的熟练用户。在这种情况下,我建议您注意一些项目,如下所示,将 Android NDK 开发带到了 Visual Studio:

  • Android++是一个免费的 Visual Studio 扩展,可以在android-plus-plus.com/找到。尽管在本书编写时仍处于测试阶段,但 Android++看起来相当有前景。

  • NVidia Nsight 可以在 Nvidia 开发者网站developer.nvidia.com/nvidia-nsight-tegra(如果你有 Tegra 设备)用开发者账户下载。它将 NDK、一个稍微定制版的 Visual Studio 和一个不错的调试器打包在一起。

  • 可以在github.com/gavinpugh/vs-android找到的 VS-Android 是一个有趣的开放源代码项目,它将 NDK 工具带到了 Visual Studio 中。

我们的开发环境现在几乎准备好了。尽管如此,还缺少最后一块:运行和测试我们应用程序的环境。

设置 Android 模拟器

Android SDK 提供了一个模拟器,以帮助希望加快部署-运行-测试周期的开发者,或者希望测试例如不同类型的分辨率和操作系统版本的开发者。让我们看看如何设置它。

行动时间 – 创建 Android 虚拟设备

Android SDK 提供了我们轻松创建新的模拟器Android Virtual Device (AVD)所需的一切:

  1. 从终端运行以下命令打开Android SDK Manager

    android
    
    
  2. 转到工具 | 管理 AVD...。或者,在 Eclipse 的主工具栏中点击专用的Android Virtual Device Manager按钮。

    然后,点击新建按钮创建一个新的 Android 模拟器实例。用以下信息填写表单并点击确定

    行动时间 – 创建 Android 虚拟设备

  3. 新创建的虚拟设备现在显示在Android Virtual Device Manager列表中。选择它并点击启动...

    注意

    如果你在 Linux 上遇到与libGL相关的错误,请打开命令提示符并运行以下命令以安装 Mesa 图形库:sudo apt-get install libgl1-mesa-dev

  4. 启动选项窗口出现。根据需要调整显示大小,然后点击启动。模拟器启动,一段时间后,你的虚拟设备将加载完毕:行动时间 – 创建 Android 虚拟设备

  5. 默认情况下,模拟器的 SD 卡是只读的。虽然这是可选的,但你可以通过从提示符发出以下命令来将其设置为写入模式:

    adb shell
    su
    mount -o rw,remount rootfs /
    chmod 777 /mnt/sdcard
    exit
    
    

刚才发生了什么?

安卓模拟器可以通过安卓虚拟设备管理器轻松管理。我们现在能够在代表性的环境中测试我们即将开发的应用程序。更妙的是,我们现在可以在多种条件和分辨率下进行测试,而无需昂贵的设备。然而,如果模拟器是有用的开发工具,请记住模拟并不总是完全具有代表性,并且缺少一些功能,尤其是硬件传感器,这些传感器只能部分模拟。

安卓虚拟设备管理器并非我们管理模拟器的唯一场所。我们还可以使用安卓 SDK 提供的命令行工具 emulator。例如,要从终端直接启动先前创建的 Nexus4 模拟器,请输入以下内容:

emulator -avd Nexus4

在创建Nexus4 AVD 时,敏锐的读者可能已经注意到我们将 CPU/ABI 设置为 Intel Atom(x86),而大多数安卓设备运行在 ARM 处理器上。实际上,由于 Windows、OS X 和 Linux 都运行在 x86 上,只有 x86 安卓模拟器镜像可以受益于硬件和 GPU 加速。另一方面,ARM ABI 在没有加速的情况下可能会运行得相当慢,但它可能更符合你的应用程序可能运行的设备。

提示

若要使用 X86 AVD 获得完全硬件加速,你需要在 Windows 或 Mac OS X 系统上安装英特尔硬件加速执行管理器HAXM)。在 Linux 上,你可以安装 KVM。这些程序只有在你的 CPU 支持虚拟化技术时才能工作(如今大多数情况下都是如此)。

敏锐的读者可能还会惊讶于我们没有选择最新的安卓平台。原因仅仅是并非所有安卓平台都提供 x86 镜像。

注意

快照选项允许在关闭模拟器之前保存其状态。遗憾的是,这个选项与 GPU 加速不兼容。你必须选择其中之一。

最后需要注意的是,在创建 AVD 以在有限的硬件条件下测试应用程序时,自定义其他选项(如 GPS、摄像头等的设置)也是可能的。屏幕方向可以通过快捷键Ctrl + F11Ctrl + F12进行切换。有关如何使用和配置模拟器的更多信息,请访问安卓网站:developer.android.com/tools/devices/emulator.html

使用安卓设备进行开发

尽管模拟器可以提供帮助,但它们显然无法与真实设备相比。因此,请拿起你的安卓设备,打开它,让我们尝试将其连接到我们的开发平台。以下步骤可能会因你的制造商和手机语言而有所不同。因此,请参阅你的设备文档以获取具体说明。

行动时间——设置安卓设备

设备配置取决于你的目标操作系统。为此:

  1. 如果适用,请在你的操作系统上配置设备驱动:

    • 如果你使用的是 Windows,开发设备的安装是特定于制造商的。更多信息可以在developer.android.com/tools/extras/oem-usb.html找到,那里有设备制造商的完整列表。如果你的 Android 设备附带有驱动 CD,你可以使用它。请注意,Android SDK 也包含一些 Windows 驱动程序,位于$ANDROID_SDK\extras\google\usb_driver目录下。针对 Google 开发手机,Nexus One 和 Nexus S 的具体说明可以在developer.android.com/sdk/win-usb.html找到。

    • 如果你使用的是 OS X,只需将你的开发设备连接到你的 Mac 应该就足以让它工作了!你的设备应该会立即被识别,无需安装任何东西。Mac 的易用性并非传说。

    • 如果你是一个 Linux 用户,将你的开发设备连接到你的发行版(至少在 Ubuntu 上)应该就足以让它工作了!

  2. 如果你的移动设备运行的是 Android 4.2 或更高版本,从应用程序列表屏幕,进入设置 | 关于手机,并在列表末尾多次点击构建编号。经过一番努力后,开发者选项将神奇地出现在你的应用程序列表屏幕中。

    在 Android 4.1 设备及其早期版本上,开发者选项应该默认可见。

  3. 仍然在你的设备上,从应用程序列表屏幕,进入设置 | 开发者选项,并启用调试保持唤醒

  4. 使用数据连接线将你的设备连接到计算机。注意!有些线缆是仅供充电的,不能用于开发!根据你的设备制造商,它可能显示为 USB 磁盘。

    在 Android 4.2.2 设备及其后续版本上,手机屏幕上会出现一个允许 USB 调试?的对话框。选择始终允许从此计算机以永久允许调试,然后点击确定

  5. 打开命令提示符并执行以下操作:

    adb devices
    
    

    行动时间——设置 Android 设备

    在 Linux 上,如果出现?????????而不是你的设备名称(这很可能会发生),那么adb没有适当的访问权限。一个可能的解决方案是以 root 权限重启adb(风险自负!):

    sudo $ANDROID_SDK/platform-tools/adb kill-server
    sudo $ANDROID_SDK/platform-tools/adb devices
    
    

    另一个找到你的 Vendor ID 和 Product ID 的解决方案可能是必要的。Vendor ID 是每个制造商的固定值,可以在 Android 开发者网站developer.android.com/tools/device.html上找到(例如,HTC 是0bb4)。设备的产品 ID 可以通过lsusb命令的结果找到,我们在其中查找 Vendor ID(例如,这里的 0c87 是 HTC Desire 的产品 ID):

    lsusb | grep 0bb4
    
    

    行动时间——设置 Android 设备

    然后,使用 root 权限创建一个文件/etc/udev/rules.d/51-android.rules,并填入你的 Vendor ID 和 Product ID,然后将文件权限改为 644:

    sudo sh -c 'echo SUBSYSTEM==\"usb\", SYSFS{idVendor}==\"<Your Vendor ID>\", ATTRS{idProduct}=\"<Your Product ID>\", GROUP=\"plugdev\", MODE=\"0666\" > /etc/udev/rules.d/52-android.rules'
    sudo chmod 644 /etc/udev/rules.d/52-android.rules
    
    

    最后,重启udev服务和adb

    sudo service udev restart
    adb kill-server
    adb devices
    
    
  6. 启动 Eclipse 并打开DDMS透视图(窗口 | 打开透视图 | 其他...)。如果正常工作,你的手机应该列在设备视图中。

    提示

    Eclipse 是由许多视图组成的,例如包资源管理器视图、调试视图等。通常,它们大多数已经可见,但有时并非如此。在这种情况下,通过主菜单导航到窗口 | 显示视图 | 其他…来打开它们。Eclipse 中的视图被组织在透视图中,这些透视图存储工作区布局。可以通过转到窗口 | 打开透视图 | 其他…来打开它们。请注意,某些上下文菜单可能只在某些透视图可用。

刚才发生了什么?

我们的 Android 设备已切换到开发模式,并通过 Android 调试桥守护进程连接到工作站。第一次从 Eclipse 或命令行调用 ADB 时,它会自动启动。

我们还启用了保持唤醒选项,以防止在手机充电或开发时自动关闭屏幕!而且,比任何事情都重要的是,我们发现 HTC 代表的是高技术计算机!玩笑归玩笑,在 Linux 上的连接过程可能会很棘手,尽管现在应该不会遇到太多麻烦。

仍然遇到不情愿的 Android 设备的问题?这可能意味着以下任何一种情况:

  • ADB 出现故障。在这种情况下,重启 ADB 守护进程或以管理员权限执行它。

  • 你的开发设备工作不正常。在这种情况下,尝试重启你的设备或禁用并重新启用开发模式。如果仍然不起作用,那么购买另一个设备或使用模拟器。

  • 你的主机系统没有正确设置。在这种情况下,仔细检查你的设备制造商的说明,确保必要的驱动程序已正确安装。检查硬件属性,看它是否被识别,并打开 USB 存储模式(如果适用),看它是否被正确检测。请参考你的设备文档。

    提示

    当激活仅充电模式时,SD 卡中的文件和目录对手机上安装的 Android 应用可见,但对电脑不可见。相反,当激活磁盘驱动器模式时,这些文件和目录只对电脑可见。当你的应用无法在 SD 卡上访问其资源文件时,请检查你的连接模式。

关于 ADB 的更多信息

ADB 是一个多功能的工具,用作开发环境和设备之间的中介。它包括以下部分:

  • 在模拟器和设备上运行的后台进程,用于接收来自工作站的任务或请求。

  • 工作站上与连接设备和模拟器通信的后台服务器。列出设备时,会涉及到 ADB 服务器。调试时,会涉及到 ADB 服务器。与设备进行任何通信时,都会涉及到 ADB 服务器!

  • 在你的工作站上运行的客户端,通过 ADB 服务器与设备通信。我们与之交互列出设备的 ADB 客户端。

ADB 提供了许多有用的选项,其中一些在以下表格中:

命令 描述
adb help 获取详尽的帮助,包括所有可用的选项和标志
adb bugreport 打印整个设备的状态
adb devices 列出当前连接的所有 Android 设备,包括模拟器
adb install [-r] <apk path> 安装应用程序包。添加-r以重新安装已部署的应用程序并保留其数据
adb kill-server 终止 ADB 守护进程
adb pull <device path> <local path> 将文件传输到你的电脑
adb push <local path> <device path> 将文件传输到你的设备或模拟器
adb reboot 以编程方式重启 Android 设备
adb shell 在 Android 设备上启动 shell 会话(更多内容请见第二章,开始一个本地 Android 项目)
adb start-server 启动 ADB 守护进程
adb wait-for-device 等待直到设备或模拟器连接到你的电脑(例如,在脚本中)

当同时连接多个设备时,ADB 还提供了可选的标志来定位特定设备:

-s <device id> 通过设备的名称(可以在 adb devices 中找到)来定位一个特定的设备
-d 如果只连接了一个物理设备,则定位当前物理设备(或者会引发错误信息)
-e 如果只连接了一个模拟器,则定位当前运行的模拟器(或者会引发错误信息)

例如,当设备连接时同时转储模拟器状态,执行以下命令:

adb -e bugreport

这只是 ADB 功能的概述。更多信息可以在 Android 开发者网站找到,网址是developer.android.com/tools/help/adb.html

总结

设置我们的 Android 开发平台可能有些繁琐,但希望这是一劳永逸的!

总之,我们在系统上安装了所有必备的软件包。其中一些是特定于目标操作系统的,例如 Windows 上的 Cygwin,OS X 上的 Developer Tools,或者 Linux 上的 build-essential 软件包。然后,我们安装了包含 Android Studio IDE 和 Android SDK 的 Android Studio 捆绑包。Android NDK 需要单独下载和设置。

即使我们在这本书中不会经常使用它,Android Studio 仍然是纯 Java 开发的最佳选择之一。它由谷歌维护,当 Gradle NDK 的集成更加成熟时,它可能成为一个不错的选择。

同时,最简单的解决方案是使用 Eclipse 进行 NDK 开发。我们安装了带有 ADT 和 CDT 插件的 Eclipse。这些插件能够很好地整合在一起,它们允许将 Android Java 和本地 C/C++ 代码的强大功能结合到一个单一的 IDE 中。

最后,我们启动了一个 Android 模拟器,并通过 Android 调试桥接器将一个 Android 设备连接到我们的开发平台。

提示

由于 Android NDK 是“开放的”,任何人都可以构建自己的版本。Crystax NDK 是由 Dmitry Moskalchuk 创建的特殊 NDK 包。它带来了 NDK 不支持的高级功能(最新的工具链、开箱即用的 Boost…最初支持异常的是 CrystaxNDK)。高级用户可以在 Crystax 网站上找到它,网址为www.crystax.net/en/android/ndk

现在我们手中有了塑造我们移动想法所需的工具。在下一章中,我们将驯服它们来创建、编译并部署我们的第一个 Android 项目!

第二章:开始一个本地 Android 项目

拥有最强大工具的人,若不知如何使用,实则手无寸铁。Make、GCC、Ant、Bash、Eclipse……—任何新的 Android 程序员都需要处理这个技术生态系统。幸运的是,其中一些名字可能已经听起来很熟悉。实际上,Android 是基于许多开源组件构建的,由 Android 开发工具包及其特定的工具集:ADB、AAPT、AM、NDK-Build、NDK-GDB...掌握它们将赋予我们创建、构建、部署和调试我们自己的 Android 应用程序的能力。

在下一章深入探讨本地代码之前,让我们通过启动一个新的具体 Android 项目来发现这些工具,该项目包含本地 C/C++代码。尽管 Android Studio 是新的官方 Android IDE,但它对本地代码的支持不足,促使我们主要关注 Eclipse。

因此,在本章中,我们将要:

  • 构建一个官方示例应用程序并将其部署在 Android 设备上

  • 使用 Eclipse 创建我们的第一个本地 Android 项目

  • 使用 Java Native Interfaces 接口将 Java 与 C/C++连接起来

  • 调试一个本地 Android 应用程序

  • 分析本地崩溃转储

  • 使用 Gradle 设置包含本地代码的项目

到本章结束时,你应该知道如何独立开始一个新的本地 Android 项目。

构建 NDK 示例应用程序

开始使用新的 Android 开发环境的简单方法之一是编译和部署 Android NDK 提供的示例之一。一个可能的(而且polygonful!)选择是 2004 年由 Jetro Lauha 创建的San Angeles演示,后来被移植到 OpenGL ES(更多信息请访问jet.ro/visuals/4k-intros/san-angeles-observation/)。

行动时间 – 编译和部署 San Angeles 示例

让我们使用 Android SDK 和 NDK 工具来构建一个可工作的 APK:

  1. 打开命令行提示符,进入 Android NDK 中的 San Angeles 示例目录。所有后续步骤都必须从这个目录执行。

    使用android命令生成 San Angeles 项目文件:

    cd $ANDROID_NDK/samples/san-angeles
    android update project -p ./
    

    行动时间 – 编译和部署 San Angeles 示例

    提示

    执行此命令时,你可能会遇到以下错误:

    Error: The project either has no target set or the target is invalid.
    Please provide a --target to the 'android update' command.
    
    

    这意味着你可能没有按照第一章,设置你的环境中指定的那样安装所有的 Android SDK 平台。在这种情况下,你可以使用Android 管理工具安装它们,或者指定你自己的项目目标,例如,android update project --target 18 -p ./

  2. 使用ndk-build编译 San Angeles 本地库:行动时间 – 编译和部署 San Angeles 示例

  3. 调试模式构建和打包 San Angeles 应用程序:

    ant debug
    
    

    行动时间 – 编译和部署 San Angeles 示例

  4. 确保你的 Android 设备已连接或已启动模拟器。然后部署生成的包:

    ant installd
    
    

    行动时间 – 编译和部署 San Angeles 示例

  5. 在您的设备或模拟器上启动 SanAngeles 应用程序:

    adb shell am start -a android.intent.action.MAIN -n com.example.SanAngeles/com.example.SanAngeles.DemoActivity
    
    

    行动时间 – 编译和部署 San Angeles 示例

    提示

    下载示例代码

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

刚才发生了什么?

充满平面阴影多边形和怀旧气息的旧式 San Angeles 演示现在正在您的设备上运行。仅通过几行命令,涉及大部分 Android 开发所需的工具,就生成了一个包含原生 C/C++ 代码的完整应用程序,并编译、构建、打包、部署和启动。

刚才发生了什么?

让我们详细看看这个过程。

使用 Android 管理器生成项目文件

我们利用 Android 管理器从现有代码库生成了项目文件。以下关于此过程的详细信息:

  • build.xml:这是 Ant 文件,描述了如何编译并打包最终的 APK 应用程序文件(即 Android PacKage)。此构建文件主要包含属性和核心 Android Ant 构建文件的链接。

  • local.properties:这个文件包含了 Android SDK 的位置。每次 SDK 位置发生变化时,都应该重新生成这个文件。

  • proguard-project.txt:这个文件包含了 Proguard 的默认配置,Proguard 是用于 Java 代码的代码优化器和混淆器。关于它的更多信息可以在 developer.android.com/tools/help/proguard.html 找到。

  • project.properties:这个文件包含了应用程序的目标 Android SDK 版本。此文件默认从 project 目录中的预存在 default.properties 文件生成。如果没有 default.properties,则必须在 android create 命令中添加额外的 –target <API Target> 标志(例如,--target 4 表示 Android 4 Donut)。

注意

目标 SDK 版本与最低 SDK 版本不同。第一个版本描述了应用程序构建的最新 Android 版本,而后者表示应用程序允许运行的最低 Android 版本。两者都可以在 AndroidManifest.xml 文件(条款 <uses-sdk>)中可选声明,但只有目标 SDK 版本在 project.properties 中“重复”。

提示

当创建 Android 应用程序时,请仔细选择您希望支持的最低和目标 Android API,因为这可能会极大地改变您应用程序的功能以及您的受众范围。实际上,由于碎片化,目标往往在 Android 上移动得更快更多!

不以最新 Android 版本为目标的应用并不意味着它不能在该版本上运行。然而,它将无法使用所有最新的功能以及最新的优化。

Android 管理器是 Android 开发者的主要入口点。其职责与 SDK 版本更新、虚拟设备管理和项目管理相关。通过执行 android –help 可以从命令行详尽列出。由于我们在第一章,设置你的环境中已经了解了 SDK 和 AVD 管理,现在让我们关注其项目管理能力:

  1. android create project 允许从命令行空手起家创建新的 Android 项目。生成的项目只包含 Java 文件,不包含与 NDK 相关的文件。为了正确生成,必须指定一些额外的选项,例如:

    选项 描述
    -a 主活动名称
    -k 应用程序包
    -n 项目名称
    -p 项目路径
    -t 目标 SDK 版本
    -g-v 生成 Gradle 构建文件而不是 Ant,并指定其插件版本

    创建新项目的命令行示例如下:

    android create project -p ./MyProjectDir -n MyProject -t android-8 -k com.mypackage -a MyActivity
    
    
  2. android update project 从现有源代码创建项目文件,如前面的教程所示。然而,如果它们已经存在,它还可以将项目目标升级到新的 SDK 版本(即 project.properties 文件)并更新 Android SDK 位置(即 local.properties 文件)。可用的标志略有不同:

    选项 描述
    -l 要添加的库项目
    -n 项目名称
    -p 项目路径
    -t 目标 SDK 版本
    -s 更新子文件夹中的项目

    我们还可以使用 -l 标志附加新的库项目,例如:

    android update project -p ./ -l ../MyLibraryProject
    
    
  3. android create lib-projectandroid update lib-project 管理库项目。这类项目并不适合原生 C/C++ 开发,尤其是在调试时,因为 NDK 有自己复用原生库的方式。

  4. android create test-projectandroid update test-projectandroid create uitest-project 管理单元测试和 UI 测试项目。

关于所有这些选项的更多详细信息可以在 Android 开发者网站找到,网址为 developer.android.com/tools/help/android.html

使用 NDK-Build 编译原生代码

生成项目文件后,我们使用 ndk-build 编译第一个原生 C/C++ 库(也称为模块)。这个命令是 NDK 开发中最需要了解的基本命令,它实际上是一个 Bash 脚本,可以:

  • 基于 GCC 或 CLang 设置 Android 原生编译工具链。

  • 包装 Make 以控制原生代码构建,借助用户定义的 MakefilesAndroid.mk 和可选的 Application.mk。默认情况下,NDK-

  • Build会在jni项目目录中查找,按照惯例本地 C/C++代码通常位于此处。

NDK-Build 从 C/C++源文件(在obj目录中)生成中间对象文件,并在libs目录中生成最终的二进制库(.so)。可以通过以下命令删除与 NDK 相关的构建文件:

ndk-build clean

有关 NDK-Build 和 Makefiles 的更多信息,请参阅第九章,将现有库迁移到 Android

使用 Ant 构建和打包应用程序

一个 Android 应用程序不仅仅由本地 C/C++代码组成,还包括 Java 代码。因此,我们有:

  • 使用Javac(Java 编译器)编译位于src目录中的 Java 源文件。

  • Dexed 生成的 Java 字节码,即使用 DX 将其转换为 Android Dalvik 或 ART 字节码。实际上,Dalvik 和 ART 虚拟机(关于这些内容将在本章后面介绍)都基于一种特定的字节码运行,这种字节码以优化的格式存储,称为Dex

  • 使用 AAPT 打包 Dex 文件、Android 清单、资源(如图片等)以及最终的 APK 文件中的本地库,AAPT 也称为Android 资源打包工具

所有这些操作都可以通过一个 Ant 命令汇总:ant debug。结果是在bin目录中生成一个调试模式的 APK。其他构建模式也可用(例如,发布模式),可以通过ant help列出。如果你想删除与 Java 相关的临时构建文件(例如,Java .class文件),只需运行以下命令行:

ant clean

使用 Ant 部署应用程序包

使用 Ant 通过ADB可以部署打包的应用程序。可用的部署选项如下:

  • ant installd 用于调试模式

  • ant installr 用于发布模式

请注意,如果来自不同来源的同一应用程序的旧 APK 不能被新 APK 覆盖。在这种情况下,首先通过执行以下命令行删除先前的应用程序:

ant uninstall

安装和卸载也可以直接通过 ADB 执行,例如:

  • adb install <应用程序 APK 的路径>:用于首次安装应用程序(例如,对于我们示例中的bin/DemoActivity-debug.apk)。

  • adb install -r <应用程序 APK 的路径>:用于重新安装应用程序并保留设备上的数据。

  • adb uninstall <应用程序包名>:用于卸载通过应用程序包名标识的应用程序(例如,对于我们示例中的com.example.SanAngeles)。

使用 ADB Shell 启动应用程序

最后,我们通过活动管理器AM)启动了应用程序。用于启动 San Angeles 的 AM 命令参数来自AndroidManifest.xml文件:

  • com.example.SanAngeles 是应用程序包名(与我们之前展示的卸载应用程序时使用的相同)。

  • com.example.SanAngeles.DemoActivity是启动活动的规范类名(即简单类名与其包名相连)。以下是如何使用它们的一个简短示例:

    <?xml version="1.0" encoding="utf-8"?>
    <manifest 
          package="com.example.SanAngeles"
          android:versionCode="1"
          android:versionName="1.0">
    ...
            <activity android:name=".DemoActivity"
                      android:label="@string/app_name">
    

因为 AM 位于你的设备上,所以需要通过 ADB 来运行。为此,ADB 提供了一个有限的类 Unix shell,它包含一些经典命令,如lscdpwdcatchmodps以及一些 Android 特有的命令,如下表所示:

am 活动管理器不仅可以启动活动,还可以杀死活动,广播意图,开始/停止分析器等。
dmesg 用于转储内核信息。
dumpsys 用于转储系统状态。
logcat 用于显示设备日志信息。
run-as <用户 id> <命令> 使用用户 id权限运行命令。用户 id可以是应用程序包名,这可以访问应用程序文件(例如,run-as com.example.SanAngeles ls)。
sqlite3 <db 文件> 用于打开 SQLite 数据库(可以与run-as结合使用)。

ADB 可以通过以下方式之一启动:

  • 使用参数中的命令,如步骤 5 中的 AM 所示,在这种情况下,Shell 运行单个命令并立即退出。

  • 使用不带参数的adb shell命令,你可以将其作为一个经典 Shell 使用(例如,调用am和其他任何命令)。

ADB Shell 是一个真正的'瑞士军刀',它允许你在设备上进行高级操作,特别是有了 root 权限。例如,可以观察部署在“沙箱”目录中的应用程序(即/data/data目录)或者列出并杀死当前运行中的进程。如果没有手机的 root 权限,可能执行的操作会更有限。更多信息请查看developer.android.com/tools/help/adb.html

提示

如果你了解一些关于 Android 生态系统的知识,你可能听说过已 root 的手机和未 root 的手机。Root手机意味着获取管理员权限,通常使用破解方法。Root 手机可以用来安装自定义的 ROM 版本(例如优化或修改过的Cyanogen)或者执行任何 root 用户能做的(尤其是危险的)操作(例如访问和删除任何文件)。Root 本身并不是非法操作,因为你是在修改自己的设备。然而,并不是所有制造商都欣赏这种做法,这通常会使得保修失效。

更多关于 Android 工具的信息

构建 San Angeles 示例应用程序可以让你一窥 Android 工具的能力。然而,在它们略显'原始'的外观背后,还有更多可能性。你可以在 Android 开发者网站找到更多信息,网址是developer.android.com/tools/help/index.html

创建你的第一个本地 Android 项目

在本章的第一部分,我们了解了如何使用 Android 命令行工具。然而,使用 Notepad 或 VI 进行开发并不吸引人。编程应该是乐趣!为了使之有趣,我们需要我们喜欢的 IDE 来执行无聊或不实用的任务。现在,我们将了解如何使用 Eclipse 创建一个本地 Android 项目。

注意

本书提供的项目结果名为Store_Part1

动手操作时间——创建一个本地 Android 项目

Eclipse 提供了一个向导来帮助我们设置项目:

  1. 启动 Eclipse。在主菜单中,前往File | New | Project…

  2. 然后,在打开的New project向导中,选择Android | Android Application Project并点击Next

  3. 在下一个屏幕中,按如下所示输入项目属性并再次点击NextTime for action – creating a native Android project

  4. 点击Next两次,保留默认选项,以进入Create activity向导屏幕。选择Blank activity with Fragment并点击Next

  5. 最后,在Blank Activity屏幕中,按如下方式输入活动属性:Time for action – creating a native Android project

  6. 点击Finish以验证。几秒钟后,向导消失,Eclipse 中会显示项目Store

  7. 为项目添加本地 C/C++支持。在Package Explorer视图中选择项目Store,并从其右键菜单中选择Android Tools | Add Native Support...

  8. 在打开的Add Android Native Support弹出窗口中,将库名称设置为com_packtpub_store_Store并点击FinishTime for action – creating a native Android project

  9. 项目目录中创建了jniobj目录。第一个目录包含一个 makefile Android.mk和一个 C++源文件 com_packtpub_store_Store.cpp

    提示

    添加本地支持后,Eclipse 可能会自动将你的视角切换到 C/C++。因此,如果你的开发环境看起来与平时不同,只需检查 Eclipse 右上角的角度即可。你可以从 Java 或 C/C++的角度无障碍地处理 NDK 项目。

  10. src/com/packtpub/store/目录下创建一个新的 Java 类Store.java。从静态代码块中加载com_packtpub_store_Store本地库:

    package com.packtpub.store;
    
    public class Store {
     static {
     System.loadLibrary("com_packtpub_store_Store");
     }
    }
    
    
  11. 编辑src/com/packtpub/store/StoreActivity.java。在活动的onCreate()中声明并初始化Store的新实例。由于我们不需要它们,可以删除可能由 Eclipse 项目创建向导创建的onCreateOptionsMenu()onOptionsItemSelected()方法:

    package com.packtpub.store;
    ...
    public class StoreActivity extends Activity {
     private Store mStore = new Store();
    
        @Override
        protected void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            setContentView(R.layout.activity_store);
    
            if (savedInstanceState == null) {
                getFragmentManager().beginTransaction()
                                    .add(R.id.container,
                                         new PlaceholderFragment())
                                    .commit();
            }
        }
    
        public static class PlaceholderFragment extends Fragment {
            public PlaceholderFragment() {
            }
    
            @Override
            public View onCreateView(LayoutInflater inflater,
                                     ViewGroup container,
                                     Bundle savedInstanceState)
            {
                View rootView = inflater.inflate(R.layout.fragment_store,
                                                 container, false);
                return rootView;
            }
        }
    }
    
  12. 连接你的设备或模拟器并启动应用程序。在Package Explorer视图中选择Store,然后从 Eclipse 主菜单导航至Run | Run As | Android Application。或者,点击 Eclipse 工具栏中的Run按钮。

  13. 选择应用程序类型 Android Application 并点击 OK,进入以下界面:行动时间——创建原生 Android 项目

刚才发生了什么?

在几个步骤中,我们的第一个原生 Android 项目已经通过 Eclipse 创建并启动了。

  1. Android 项目创建向导可以帮助你快速入门。它生成了一个简单 Android 应用程序所需的最小代码。然而,默认情况下,新的 Android 项目只支持 Java 语言。

  2. 借助 ADT,一个 Android Java 项目可以轻松地转变为支持原生 C/C++ 的混合项目。它生成了 NDK-Build 编译原生库所需的最小文件:

    Android.mk 是一个 Makefile,描述了要编译哪些源文件以及如何生成最终的原生库。

    com_packtpub_store_Store.cpp 是一个几乎为空的文件,包含了一个单一的包含指令。我们将在本章的下一部分解释这一点。

  3. 项目设置完成后,动态加载原生库只需调用一次 System.loadLibrary()。这很容易在一个静态块中完成,确保在类初始化之前一次性加载库。请注意,这只有在容器类是从单个 Java 类加载器加载时才有效(通常情况下是这样的)。

使用像 Eclipse 这样的 IDE 真的可以大幅提高生产效率,让编程变得更加舒适!但如果你是一个命令行爱好者,或者想要锻炼你的命令行技能,那么第一部分,构建 NDK 示例应用程序,可以很容易地应用在这里。

介绍 Dalvik 和 ART。

说到 Android,不得不提一下 DalvikART

Dalvik 是一个 虚拟机,在其中解释 Dex 字节码(不是原生代码!)。它是任何在 Android 上运行的应用程序的核心。Dalvik 被设计为符合移动设备的限制性要求。它特别优化以使用更少的内存和 CPU。它位于 Android 内核之上,内核为硬件提供了第一层抽象(进程管理、内存管理等)。

ART 是新的 Android 运行时环境,自 Android 5 Lollipop 起取代了 Dalvik。与 Dalvik 相比,它大大提高了性能。实际上,Dalvik 在应用程序启动时 即时 解释字节码,而 ART 则是在应用程序安装期间 提前 将字节码预编译成原生代码。ART 与为早期 Dalvik 虚拟机打包的应用程序向后兼容。

Android 在设计时考虑了速度。因为大多数用户不希望在等待应用程序加载的同时,其他应用程序仍在运行,因此系统能够快速实例化多个 Dalvik 或 ART VM,这要归功于Zygote进程。Zygote(其名称来自生物体中第一个生物细胞,从中产生子细胞)在系统启动时开始运行。它预加载(或“预热”)所有应用程序共享的核心库以及虚拟机实例。要启动新应用程序,只需分叉 Zygote,初始 Dalvik 实例因此被复制。通过尽可能多地共享进程之间的库,降低内存消耗。

Dalvik 和 ART 本身是由为目标 Android 平台(ARM、X86 等)编译的原生 C/C++代码构成的。这意味着,只要使用相同的应用程序二进制接口ABI)(它基本上描述了应用程序或库的二进制格式),就可以轻松地将这些虚拟机与原生 C/C++库进行接口交互。这就是 Android NDK 的作用。更多信息,请查看Android 开源项目AOSP),即 Android 源代码,在source.android.com/

Java 与 C/C++接口

原生 C/C++代码能够释放应用程序的强大功能。为此,Java 代码需要调用并运行其原生对应部分。在本部分,我们将把 Java 和原生 C/C++代码接口在一起。

注意

本书提供的项目名为Store_Part2

行动时间 - 从 Java 调用 C 代码

让我们创建第一个原生方法,并从 Java 端调用它:

  1. 打开src/com/packtpub/store/Store.java文件,并为Store声明一个查询原生方法。此方法返回int类型的条目数量。无需定义方法体:

    package com.packtpub.store;
    
    public class Store {
        static {
            System.loadLibrary("com_packtpub_store_Store");
        }
    
     public native int getCount();
    }
    
  2. 打开src/com/packtpub/store/StoreActivity.java文件,并初始化商店。使用其getCount()方法的值来初始化应用程序标题:

    public class StoreActivity extends Activity {
        ...
        public static class PlaceholderFragment extends Fragment {
     private Store mStore = new Store();
         ...
            public PlaceholderFragment() {
            }
    
            @Override
            public View onCreateView(LayoutInflater inflater,
                                     ViewGroup container,
                                     Bundle savedInstanceState)
            {
                View rootView = inflater.inflate(R.layout.fragment_store,
                                                 container, false);
     updateTitle();
                return rootView;
            }
    
     private void updateTitle() {
     int numEntries = mStore.getCount();
     getActivity().setTitle(String.format("Store (%1$s)",
     numEntries));
            }
        }
    }
    
  3. Store类生成 JNI 头文件。转到 Eclipse 主菜单,选择运行 | 外部工具 | 外部工具配置…。使用以下参数创建一个新的程序配置,如下截图所示:行动时间 - 从 Java 调用 C 代码

    位置指的是javah的绝对路径,这是特定于操作系统的。在 Windows 上,你可以输入${env_var:JAVA_HOME}\bin\javah.exe。在 Mac OS X 和 Linux 上,通常是/usr/bin/javah

  4. 刷新标签中,勾选完成后刷新资源,并选择特定资源。使用指定资源…按钮,选择jni文件夹。最后,点击运行以执行javah。然后会生成一个名为jni/com_packtpub_store_Store.h的新文件。这包含了 Java 端期望的原生方法getCount()的原型:

    /* DO NOT EDIT THIS FILE - it is machine generated */
    #include <jni.h>
    /* Header for class com_packtpub_store_Store */
    
    #ifndef _Included_com_packtpub_store_Store
    #define _Included_com_packtpub_store_Store
    #ifdef __cplusplus
    extern "C" {
    #endif
    /*
     * Class:     com_packtpub_store_Store
     * Method:    getCount
     * Signature: ()I
     */
    JNIEXPORT jint JNICALL Java_com_packtpub_store_Store_getCount
      (JNIEnv *, jobject);
    
    #ifdef __cplusplus
    }
    #endif
    #endif
    
  5. 我们现在可以实现在jni/com_packtpub_store_Store.cpp中的方法,使其在调用时返回0。方法签名来自生成的头文件(你可以替换之前的任何代码),不过这里明确指定了参数名称:

    #include "com_packtpub_store_Store.h"
    
    JNIEXPORT jint JNICALL Java_com_packtpub_store_Store_getCount
      (JNIEnv* pEnv, jobject pObject) {
        return 0;
    }
    
  6. 编译并运行应用程序。

刚才发生了什么?

Java 现在可以与 C/C++对话了!在上一部分,我们创建了一个混合 Android 项目。在这一部分,我们通过 Java 本地接口(JNI)将 Java 与本地代码接口。这种合作是通过Java Native InterfacesJNI)建立的。JNI 是连接 Java 与 C/C++的桥梁。这个过程主要分为三个步骤。

在 Java 端定义本地方法原型,使用 native 关键字标记。这些方法没有方法体,就像抽象方法一样,因为它们是在本地端实现的。本地方法可以有参数、返回值、可见性(私有、保护、包保护或公共),并且可以是静态的:就像普通的 Java 方法一样。

本地方法可以在 Java 代码的任何地方被调用,前提是在调用之前已经加载了包含本地库。如果未能做到这一点,将会抛出类型为java.lang.UnsatisfiedLinkError的异常,这个异常是在首次调用本地方法时产生的。

使用javah生成一个带有相应本地 C/C++原型的头文件。尽管这不是强制的,但 JDK 提供的javah工具对于生成本地原型非常有用。实际上,JNI 约定既繁琐又容易出错(关于这一点在第三章,使用 JNI 接口 Java 和 C/C++中有更多介绍)。JNI 代码是从.class文件生成的,这意味着你的 Java 代码必须首先被编译。

编写本地 C/C++代码实现以执行预期操作。在这里,当查询Store库时,我们简单地返回0。我们的本地库在libs/armeabi目录(针对 ARM 处理器的目录)中编译,并命名为libcom_packtpub_store_Store.so。编译过程中生成的临时文件位于obj/local目录中。

尽管表面看起来很简单,但将 Java 与 C/C++接口比看上去要复杂得多。在第三章,使用 JNI 接口 Java 和 C/C++中,将更详细地探讨如何在本地端编写 JNI 代码。

调试本地 Android 应用程序

在深入探讨 JNI 之前,还有一个任何 Android 开发者都需要知道如何使用的最后一个重要工具:调试器。官方 NDK 提供的调试器是 GNU 调试器,也称为GDB

注意

本书提供的项目名为Store_Part3

动手实践——调试一个本地 Android 应用程序

  1. 创建文件jni/Application.mk,内容如下:

    APP_PLATFORM := android-14
    APP_ABI := armeabi armeabi-v7a x86
    

    提示

    这些并不是 NDK 提供的唯一 ABI;还有更多的处理器架构,如 MIPS 或变体如 64 位或硬浮点。这里使用的这些是你应该关注的主要架构。它们可以轻松地在模拟器上进行测试。

  2. 打开项目属性,进入C/C++构建,取消勾选使用默认构建命令并输入ndk-build NDK_DEBUG=1:行动时间——调试本地 Android 应用程序

  3. jni/com_packtpub_store_Store.cpp中,通过在 Eclipse 编辑器边栏双击,在Java_com_packtpub_store_Store_getCount()方法内部设置一个断点。

  4. 包浏览器项目浏览器视图中选择Store项目,并选择调试为 | Android 本地应用程序。应用程序开始运行,但可能会发现什么也没有发生。实际上,在 GDB 调试器能够附加到应用程序进程之前,很可能会达到断点。

  5. 离开应用程序,并从你的设备应用菜单重新打开它。这次,Eclipse 会在本地断点处停止。查看你的设备屏幕,UI 应该已经冻结,因为主应用程序线程在本地代码中暂停了。行动时间——调试本地 Android 应用程序

  6. 变量视图中检查变量,并在调试视图中查看调用堆栈。在表达式视图中输入*pEnv.functions并打开结果表达式,以查看JNIEnv对象提供的各种函数。

  7. 通过 Eclipse 工具栏或快捷键F6单步跳过当前指令(也可以使用快捷键F7进行单步进入)。以下指令将被高亮:

    • 通过 Eclipse 工具栏或快捷键F8恢复执行。应用程序界面将再次显示在你的设备上。

    • 通过 Eclipse 工具栏或快捷键Ctrl+F2终止应用程序。应用程序被杀死,调试视图会被清空。

刚才发生了什么?

这个有用的生产力工具——调试器,现在是我们工具箱中的资产。我们可以轻松地在任何点停止或恢复程序执行,单步进入、跳过或离开本地指令,并检查任何变量。这种能力得益于 NDK-GDB,它是命令行调试器 GDB(手动使用可能比较麻烦)的包装脚本。幸运的是,GDB 得到了 Eclipse CDT 的支持,进而也得到了 Eclipse ADT 的支持。

在 Android 系统上,以及更普遍的嵌入式设备上,GDB 被配置为客户端/服务器模式,而程序作为服务器在设备上运行(gdbserver,它是由 NDK-Build 在libs目录中生成的)。远程客户端,即开发者的工作站上的 Eclipse,连接并发送远程调试命令。

定义 NDK 全应用设置

为了帮助 NDK-Build 和 NDK-GDB 完成它们的工作,我们创建了一个新的Application.mk文件。这个文件应被视为一个全局 Makefile,定义了应用程序范围的编译设置,例如以下内容:

  • APP_PLATFORM:应用程序针对的 Android API。这个信息应该是AndroidManifest.xml文件中minSdkVersion的重复。

  • APP_ABI:应用程序针对的 CPU 架构。应用程序二进制接口指定了可执行文件和库二进制文件的二进制代码格式(指令集、调用约定等)。ABIs 因此与处理器密切相关。可以通过额外的设置,如LOCAL_ARM_CODE来调整 ABI。

当前 Android NDK 支持的主要 ABI 如下表所示:

armeabi 这是默认选项,应该与所有 ARM 设备兼容。Thumb 是一种特殊的指令集,它将指令编码为 16 位而不是 32 位,以提高代码大小(对于内存受限的设备很有用)。与 ArmEABI 相比,指令集受到严重限制。
armeabi(当LOCAL_ARM_CODE = arm时) (或 ARM v5)应该能在所有 ARM 设备上运行。指令编码为 32 位,但可能比 Thumb 代码更简洁。ARM v5 不支持浮点加速等高级扩展,因此比 ARM v7 慢。
armeabi-v7a 支持如 Thumb-2(类似于 Thumb,但增加了额外的 32 位指令)和 VFP 等扩展,以及一些可选扩展,如 NEON。为 ARM V7 编译的代码不能在 ARM V5 处理器上运行。
armeabi-v7a-hard 这个 ABI 是 armeabi-v7a 的扩展,它支持硬件浮点而不是软浮点。
arm64-v8a 这是专为新的 64 位处理器架构设计的。64 位 ARM 处理器向后兼容旧的 ABI。
x86 和 x86_64 针对类似“PC”的处理器架构(即 Intel/AMD)。这些是在模拟器上使用的 ABI,以便在 PC 上获得硬件加速。尽管大多数 Android 设备是 ARM,但其中一些现在基于 X86。x86 ABI 用于 32 位处理器,而 x86_64 用于 64 位处理器。
mips 和 mips64 针对由 MIPS Technologies 制造的处理器设计,现在属于 Imagination Technologies,后者以 PowerVR 图形处理器而闻名。在撰写本书时,几乎没有设备使用这些 ABI。mips ABI 用于 32 位处理器,而 mips64 用于 64 位处理器。
all, all32 和 all64 这是一个快捷方式,用于为所有 32 位或 64 位 ABI 构建 ndk 库。

每个库和中间对象文件都会针对每个 ABI 重新编译。它们存储在各自独立的目录中,可以在objlibs文件夹中找到。

Application.mk内部还可以使用更多的标志。我们将在第九章《将现有库移植到 Android》中详细了解这一点。

Application.mk标志并不是确保 NDK 调试器工作的唯一设置;还需要手动传递NDK_DEBUG=1给 NDK-Build,这样它才能编译调试二进制文件并正确生成 GDB 设置文件(gdb.setupgdbserver)。请注意,这应该更多地被视为 Android 开发工具的缺陷,而不是一个真正的配置步骤,因为通常它应该能自动处理调试标志。

NDK-GDB 的日常使用

NDK 和 Eclipse 中的调试器支持是近期才出现的,并且在 NDK 的不同版本之间有了很大的改进(例如,之前无法调试纯本地线程)。然而,尽管现在调试器已经相当可用,但在 Android 上进行调试有时可能会出现错误、不稳定,并且相对较慢(因为它需要与远程的 Android 设备进行通信)。

提示

NDK-GDB 有时可能会出现疯狂的现象,在一个完全不正常的堆栈跟踪处停止在断点。这可能与 GDB 在调试时无法正确确定当前的 ABI 有关。要解决这个问题,只需在APP_ABI子句中放入对应设备的 ABI,并移除或注释掉其他的。

NDK 调试器在使用上也可能有些棘手,例如在调试本地启动代码时。实际上,GDB 启动不够快,无法激活断点。克服这个问题的简单方法是让本地代码在应用程序启动时暂停几秒钟。为了给 GDB 足够的时间来附加应用程序进程,我们可以例如这样做:

#include <unistd.h>
…
sleep(3); // in seconds.

另一个解决方案是启动一个调试会话,然后简单地离开并从设备上重新启动应用程序,正如我们在之前的教程中看到的那样。这是可行的,因为 Android 应用程序的生命周期是这样的:当应用程序在后台时,它会保持存活,直到需要内存。不过,这个技巧只适用于应用程序在启动过程中没有崩溃的情况。

分析本地崩溃转储

每个开发人员都有过一天在他们的应用程序中遇到意外的崩溃。不要为此感到羞愧,我们所有人都经历过。作为 Android 本地开发的新手,这种情况还会发生很多次。调试器是查找代码问题的巨大工具。遗憾的是,它们在程序运行时的“实时”工作。面对难以复现的致命错误时,它们变得无效。幸运的是,有一个工具可以解决这个问题:NDK-Stack。NDK-Stack 可以帮助你读取崩溃转储,以分析应用程序在崩溃那一刻的堆栈跟踪。

注意

本书提供的示例项目名为Store_Crash

动手时间——分析一个本地崩溃转储

让我们的应用程序崩溃,看看如何读取崩溃转储:

  1. jni/com_packtpub_store_Store.cpp中模拟一个致命错误:

    #include "com_packtpub_store_Store.h"
    
    JNIEXPORT jint JNICALL Java_com_packtpub_store_Store_getCount
      (JNIEnv* pEnv, jobject pObject) {
     pEnv = 0;
     return pEnv->CallIntMethod(0, 0);
    }
    
  2. 在 Eclipse 中打开 LogCat 视图,选择 所有消息(无筛选) 选项,然后运行应用程序。日志中出现了崩溃转储。这看起来不美观!如果你仔细查看,应该能在其中找到带有应用程序崩溃时刻调用栈快照的 backtrace 部分。然而,它没有给出涉及的代码行:行动时间 – 分析原生崩溃转储

  3. 从命令行提示符进入项目目录。通过使用 logcat 作为输入运行 NDK-Stack,找到导致崩溃的代码行。NDK-Stack 需要对应于应用程序崩溃的设备 ABI 的 obj 文件,例如:

    cd <projet directory>
    adb logcat | ndk-stack -sym obj/local/armeabi-v7a
    
    

    行动时间 – 分析原生崩溃转储

刚才发生了什么?

Android NDK 提供的 NDK-Stack 工具可以帮助你定位应用程序崩溃的源头。这个工具是不可或缺的帮助,当发生严重的崩溃时,应被视为你的急救包。然而,如果它能指出在哪里,那么找出为什么就是另一回事了。

堆栈跟踪只是崩溃转储的一小部分。解读转储的其余部分很少是必要的,但理解其含义对提高一般文化素养有帮助。

解读崩溃转储

崩溃转储不仅是为了那些在二进制代码中看到穿红衣服女孩的过于有才华的开发者,也是为了那些对汇编器和处理器工作方式有基本了解的人。这个跟踪的目标是尽可能多地提供程序在崩溃时的当前状态信息。它包含:

  • 第一行:构建指纹是一种标识符,表示当前运行的设备/Android 版本。在分析来自不同来源的转储时,这些信息很有趣。

  • 第三行:PID 或进程标识符在 Unix 系统上唯一标识一个应用程序,以及 TID,即线程标识符。当在主线程上发生崩溃时,线程标识符可能与进程标识符相同。

  • 第四行:表示为 信号 的崩溃源头是一个经典的段错误(SIGSEGV)。

  • 处理器寄存器的值。寄存器保存处理器可以立即操作的值或指针。

  • 回溯(即堆栈跟踪)与方法调用,这些调用导致了崩溃。

  • 原始堆栈与回溯类似,但包含了堆栈参数和变量。

  • 围绕主要寄存器的一些内存字(仅针对 ARM 处理器提供)。第一列指示内存行的位置,而其他列指示以十六进制表示的内存值。

处理器寄存器在不同处理器架构和版本之间是不同的。ARM 处理器提供:

rX 整数寄存器,程序在这里放置它要处理的值。
dX 浮点寄存器,程序在这里放置它要处理的值。
fp(或 r11) 帧指针在过程调用期间保存当前堆栈帧的位置(与堆栈指针配合使用)。
ip(或 r12) 过程内调用暂存寄存器可能用于某些子程序调用;例如,当链接器需要一个薄层(一小段代码)以在分支时指向不同的内存区域时。实际上,跳转到内存中其他位置的分支指令需要一个相对于当前位置的偏移量参数,这使得分支范围只有几 MB,而不是整个内存。
sp(或 r13) 堆栈指针保存堆栈顶部的位置。
lr(或 r14) 链接寄存器临时保存程序计数器值,以便稍后恢复。其使用的一个典型例子是函数调用,它跳转到代码中的某个位置,然后返回到其先前的位置。当然,多个链式子程序调用需要将链接寄存器入栈。
pc(或 r15) 程序计数器保存着将要执行的下一个指令的地址。程序计数器在执行顺序代码时只是递增以获取下一个指令,但它会被分支指令(如 if/else,C/C++函数调用等)改变。
cpsr 当前程序状态寄存器包含有关当前处理器工作模式的一些标志和额外的位标志,用于条件码(如操作结果为负值的 N,结果为 0 或相等的 Z 等),中断和指令集(拇指或 ARM)。

提示

请记住,寄存器的主要使用是一种约定。例如,苹果 iOS 在 ARMS 上使用r7作为帧指针,而不是r12。因此,在编写或重用汇编代码时一定要非常小心!

另一方面,X86 处理器提供:

eax 累加器寄存器用于例如算术或 I/O 操作。
ebx 基址寄存器是用于内存访问的数据指针。
ecx 计数器寄存器用于迭代操作,如循环计数器。
edx 数据寄存器是配合eax使用的次要累加寄存器。
esi 源索引寄存器edi配合使用,用于内存数组的复制。
edi 目的索引寄存器esi配合使用,用于内存数组的复制。
eip 指令指针保存下一个指令的偏移量。
ebp 基指针在过程调用期间保存当前堆栈帧的位置(与堆栈指针配合使用)。
esp 堆栈指针保存堆栈顶部的位置。
xcs 代码段帮助寻址程序运行的内存段。
xds 数据段帮助寻址数据内存段。
xes 额外段是用于寻址内存段的附加寄存器。
xfs 附加段,这是一个通用数据段。
xss 堆栈段保存堆栈内存段。

提示

许多 X86 寄存器是遗留的,这意味着它们失去了创建时的初衷。对它们的描述要持谨慎态度。

解读堆栈跟踪不是一件容易的事,它需要时间和专业知识。如果你还无法理解它的每一部分,不必过于烦恼。这只在万不得已的情况下才需要。

设置 Gradle 项目以编译原生代码

Android Studio 现在是官方支持的 Android IDE,取代了 Eclipse。它带有Gradle,这是新的官方 Android 构建系统。Gradle 引入了一种基于 Groovy 的特定语言,以便轻松定义项目配置。尽管其对 NDK 的支持还初步,但它不断改进,变得越来越可用。

现在让我们看看如何使用 Gradle 创建一个编译原生代码的 Android Studio 项目。

注意

本书提供的项目名为Store_Gradle_Auto

行动时间 – 创建原生 Android 项目

通过 Android Studio 可以轻松创建基于 Gradle 的项目:

  1. 启动 Android Studio。在欢迎屏幕上,选择新建项目…(如果已经打开了一个项目,则选择文件 | 新建项目…)。

  2. 新建项目向导中,输入以下配置并点击下一步行动时间 – 创建原生 Android 项目

  3. 然后,选择最小的 SDK(例如,API 14:冰激凌三明治),并点击下一步

  4. 选择带片段的空白活动并点击下一步

  5. 最后,按照以下方式输入活动名称布局名称,然后点击完成行动时间 – 创建原生 Android 项目

  6. 然后,Android Studio 应该会打开项目:行动时间 – 创建原生 Android 项目

  7. 修改StoreActivity.java文件,并按照本章中Java 与 C/C++接口部分(步骤 1 和 2)创建Store.java

  8. 创建app/src/main/jni目录。复制本章Java 与 C/C++接口部分(步骤 4 和 5)中创建的 C 和头文件。

  9. 编辑 Android Studio 生成的app/build.gradle文件。在defaultConfig中插入一个ndk部分来配置模块(即库)名称:

    apply plugin: 'com.android.application'
    
    android {
        compileSdkVersion 21
        buildToolsVersion "21.1.2"
    
        defaultConfig {
            applicationId "com.packtpub.store"
            minSdkVersion 14
            targetSdkVersion 21
            versionCode 1
            versionName "1.0"
     ndk {
     moduleName "com_packtpub_store_Store"
            }
        }
        buildTypes {
            release {
                minifyEnabled false
                proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
            }
        }
    }
    
    dependencies {
        compile fileTree(dir: 'libs', include: ['*.jar'])
        compile 'com.android.support:appcompat-v7:21.0.3'
    }
    
  10. 通过点击 Android Studio 中Gradle 任务视图下的installDebug,编译并在你的设备上安装项目。

    提示

    如果 Android Studio 抱怨找不到 NDK,请确保项目根目录中的local.properties文件包含可以指向你的 Android SDK 和 NDK 位置的sdk.dirndk.dir属性。

刚才发生了什么?

我们创建了一个通过 Gradle 编译本地代码的第一个 Android Studio 项目。NDK 属性在 build.gradle 文件(例如,模块名称)的特定于 ndk 的部分配置。

下表展示了多个可用的设置:

属性 描述
abiFilter 要编译的目标 ABI 列表;默认情况下,编译所有 ABI。
cFlags 传递给编译器的自定义标志。关于这方面的更多信息,请参见第九章,将现有库移植到 Android
ldLibs 传递给链接器的自定义标志。关于这方面的更多信息,请参见第九章,将现有库移植到 Android
moduleName 这是将要构建的模块名称。
stl 这是用于编译的 STL 库。关于这方面的更多信息,请参见第九章,将现有库移植到 Android

你可能已经注意到,我们没有重用 Android.mkApplication.mk 文件。这是因为如果在编译时给 ndk-build 提供了输入,Gradle 会自动生成构建文件。在我们的示例中,你可以在 app/build/intermediates/ndk/debug 目录下看到为 Store 模块生成的 Android.mk 文件。

NDK 自动 Makefile 生成使得在简单项目上编译本地 NDK 代码变得容易。但是,如果你想要在本地构建上获得更多控制,你可以创建自己的 Makefiles,就像本章中在“Java 与 C/C++接口”部分创建的那样。让我们看看如何操作。

注意

本书提供的项目名为 Store_Gradle_Manual

动手时间 – 使用你自己的 Makefiles 与 Gradle

使用你手工制作的 Makefiles 与 Gradle 有点棘手,但并不复杂:

  1. 将本章中在“Java 与 C/C++接口”部分创建的 Android.mkApplication.mk 文件复制到 app/src/main/jni 目录。

  2. 编辑 app/build.gradle 文件。

  3. 添加对 OS “类”的导入,并删除前一个部分中我们创建的第一个 ndk 部分:

    import org.apache.tools.ant.taskdefs.condition.Os
    
    apply plugin: 'com.android.application'
    
    android {
        compileSdkVersion 21
        buildToolsVersion "21.1.2"
    
        defaultConfig {
            applicationId "com.packtpub.store"
            minSdkVersion 14
            targetSdkVersion 21
            versionCode 1
            versionName "1.0"
        }
        buildTypes {
            release {
                minifyEnabled false
                proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
            }
        }
    
  4. 仍然在 app/build.gradle 文件的 android 部分,插入一个包含以下内容的 sourceSets.main 部分:

    • jniLibs.srcDir,定义了 Gradle 将找到生成的库的位置。

    • jni.srcDirs,设置为空数组以通过 Gradle 禁用本地代码编译。

          ...
          sourceSets.main {
              jniLibs.srcDir 'src/main/libs'
              jni.srcDirs = []
          }
      
  5. 最后,创建一个新的 Gradle 任务 ndkBuild,它将手动触发 ndk-build 命令,指定自定义目录 src/main 作为编译目录。

    声明 ndkBuild 任务与 Java 编译任务之间的依赖关系,以自动触发本地代码编译:

        ...
    
     task ndkBuild(type: Exec) {
     if (Os.isFamily(Os.FAMILY_WINDOWS)) {
     commandLine 'ndk-build.cmd', '-C', file('src/main').absolutePath
     } else {
     commandLine 'ndk-build', '-C', file('src/main').absolutePath
     }
     }
    
     tasks.withType(JavaCompile) {
     compileTask -> compileTask.dependsOn ndkBuild
     }
    }
    
    dependencies {
        compile fileTree(dir: 'libs', include: ['*.jar'])
        compile 'com.android.support:appcompat-v7:21.0.3'
    }
    
  6. 通过点击 Android Studio 中的 installDebugGradle 任务 视图下编译并安装项目到你的设备上。

刚才发生了什么?

Android Gradle 插件进行的 Makefile 生成和原生源代码编译可以轻松禁用。诀窍是简单地指出没有可用的原生源代码目录。然后我们可以利用 Gradle 的强大功能,它允许轻松定义自定义构建任务及其之间的依赖关系,以执行ndk-build命令。这个技巧允许我们使用自己的 NDK makefiles,从而在构建原生代码时给我们提供更大的灵活性。

总结

创建、编译、构建、打包和部署应用程序项目可能不是最激动人心的任务,但它们是无法避免的。掌握它们将使您能够提高效率并专注于真正的目标:编写代码

综上所述,我们使用命令行工具构建了第一个示例应用程序,并将其部署在 Android 设备上。我们还使用 Eclipse 创建了第一个原生 Android 项目,并通过 Java 本地接口(JNI)将 Java 与 C/C++进行接口。我们使用 NDK-GDB 调试了原生 Android 应用程序,并分析了原生崩溃转储以在源代码中找到其根源。最后,我们使用 Android Studio 创建了类似的项目,并使用 Gradle 构建它。

这首次使用 Android NDK 的实验使您对原生开发的工作方式有了很好的了解。在下一章中,我们将专注于代码,并深入探讨 JNI 协议。

第三章:用 JNI 实现 Java 与 C/C++的接口

*Android 与 Java 密不可分。其内核和核心库是原生的,但 Android 应用框架几乎完全是用 Java 编写的,或者至少在 Java 的薄层中包装。不要期望直接在 C/C++中构建你的 Android GUI!大多数 API 只能从 Java 访问。最多,我们可以将其隐藏在封面下... 因此,如果无法将 Java 和 C/C++连接在一起,Android 上的原生 C/C++代码将毫无意义。\

*这个角色是专门为 Java Native Interface API 准备的。JNI 是一个标准化规范,允许 Java 调用原生代码,原生代码也可以回调 Java。它是 Java 和原生代码之间的双向桥梁;将 C/C++的强大功能注入你的 Java 应用程序的唯一方式。\

*得益于 JNI,人们可以像调用任何 Java 方法一样从 Java 调用 C/C++函数,将 Java 原始类型或对象作为参数传递,并将它们作为原生调用的结果接收。反之,原生代码可以通过类似反射的 API 访问、检查、修改 Java 对象或抛出异常。JNI 是一个需要小心使用的微妙框架,任何误用都可能导致灾难性的结局…\

在本章中,我们将实现一个基本的关键/值存储来处理各种数据类型。一个简单的 Java GUI 将允许定义一个由键(字符串)、类型(整数、字符串等)和与选定类型相关的值组成的条目。条目在固定大小的条目数组中检索、插入或更新(不支持删除),该数组将驻留在原生侧。

为了实现这个项目,我们将要:

  • 初始化一个原生的 JNI 库

  • 在原生代码中转换 Java 字符串

  • 将 Java 原始数据传递给原生代码

  • 在原生代码中处理 Java 对象引用

  • 在原生代码中管理 Java 数组

  • 在原生代码中引发和检查 Java 异常。

在本章结束时,你应该能够使用任何 Java 类型进行原生调用并使用异常。

JNI 是一个非常技术性的框架,需要小心使用,因为任何误用都可能导致灾难性的结局。本章并不试图详尽无遗地介绍它,而是专注于桥接 Java 和 C++之间差距的基本知识。

初始化一个原生的 JNI 库

在访问它们的原生方法之前,必须通过 Java 调用System.loadLibrary()来加载原生库。JNI 提供了一个钩子JNI_OnLoad(),以便插入你自己的初始化代码。让我们重写它以初始化我们的原生存储。

注意

本书提供了名为Store_Part4的项目作为结果。

动手实践——定义一个简单的 GUI

让我们为我们的Store创建一个 Java 图形用户界面,并将其绑定到我们将要创建的原生存储结构:

  1. 重写res/fragment_layout.xml布局以定义如下图形界面。它定义了:

    • 一个 TextView标签和EditText以输入键

    • 一个 TextView标签和EditText以输入与键匹配的值

    • 一个类型 TextView 标签和 Spinner 以定义值的类型

    • 一个获取值和一个设置值Button 以在存储中检索和更改值

      <LinearLayout 
      
        a:layout_width="match_parent" a:layout_height="match_parent"
        a:orientation="vertical"
      tools:context="com.packtpub.store.StoreActivity$PlaceholderFragment">
        <TextView
          a:layout_width="match_parent" a:layout_height="wrap_content"
          a:text="Save or retrieve a value from the store:" />
        <TableLayout
          a:layout_width="match_parent" a:layout_height="wrap_content"
          a:stretchColumns="1" >
          <TableRow>
            <TextView a:id="@+id/uiKeyLabel" a:text="Key : " />
            <EditText a:id="@+id/uiKeyEdit" ><requestFocus /></EditText>
          </TableRow>
          <TableRow>
            <TextView a:id="@+id/uiValueLabel" a:text="Value : " />
            <EditText a:id="@+id/uiValueEdit" />
          </TableRow>
          <TableRow>
            <TextView a:id="@+id/uiTypeLabel" a:layout_height="match_parent"
                      a:gravity="center_vertical" a:text="Type : " />
            <Spinner a:id="@+id/uiTypeSpinner" />
          </TableRow>
        </TableLayout>
        <LinearLayout
          a:layout_width="wrap_content" a:layout_height="wrap_content"
          a:layout_gravity="right" >
          <Button a:id="@+id/uiGetValueButton" a:layout_width="wrap_content"
                  a:layout_height="wrap_content" a:text="Get Value" />
          <Button a:id="@+id/uiSetValueButton" a:layout_width="wrap_content"
                  a:layout_height="wrap_content" a:text="Set Value" />
        </LinearLayout>
      </LinearLayout>
      

    最终结果应如下所示:

    动手操作——定义一个简单的 GUI

  2. StoreType.java 中创建一个新的类,带有一个空的枚举:

    package com.packtpub.store;
    
    public enum StoreType {
    }
    
  3. GUI 和本地存储需要绑定在一起。这是由 StoreActivity 类承担的角色。为此,当在 onCreateView() 中创建 PlaceholderFragment 时,初始化布局文件中先前定义的所有 GUI 组件:

    public class StoreActivity extends Activity {
        ...
        public static class PlaceholderFragment extends Fragment {
            private Store mStore = new Store();
     private EditText mUIKeyEdit, mUIValueEdit;
     private Spinner mUITypeSpinner;
            private Button mUIGetButton, mUISetButton;
            private Pattern mKeyPattern;
    
            ...
    
            @Override
            public View onCreateView(LayoutInflater inflater,
                                     ViewGroup container,
                                     Bundle savedInstanceState)
            {
                View rootView = inflater.inflate(R.layout.fragment_store,
                                                 container, false);
                updateTitle();
    
     // Initializes text components.
     mKeyPattern = Pattern.compile("\\p{Alnum}+");
     mUIKeyEdit = (EditText) rootView.findViewById(
     R.id.uiKeyEdit);
     mUIValueEdit = (EditText) rootView.findViewById(
     R.id.uiValueEdit);
    
    
  4. Spinner 内容绑定到 StoreType 枚举。使用 ArrayAdapterSpinnerenum 值绑定在一起。

                ...
     ArrayAdapter<StoreType> adapter =
     new ArrayAdapter<StoreType>(getActivity(),
     android.R.layout.simple_spinner_item,
     StoreType.values());
     adapter.setDropDownViewResource(
     android.R.layout.simple_spinner_dropdown_item);
     mUITypeSpinner = (Spinner) rootView.findViewById(
     R.id.uiTypeSpinner);
     mUITypeSpinner.setAdapter(adapter);
                    ...
    
  5. 获取值设置值按钮触发私有方法 onGetValue()onSetValue(),它们分别从存储中拉取数据和向存储推送数据。使用 OnClickListener 将按钮和方法绑定在一起:

                ...
     mUIGetButton = (Button) rootView.findViewById(
     R.id.uiGetValueButton);
     mUIGetButton.setOnClickListener(new OnClickListener() {
     public void onClick(View pView) {
     onGetValue();
     }
     });
     mUISetButton = (Button) rootView.findViewById(
     R.id.uiSetValueButton);
     mUISetButton.setOnClickListener(new OnClickListener() {
     public void onClick(View pView) {
     onSetValue();
     }
     });
                return rootView;
            }
            ...
    
  6. PlaceholderFragment 中,定义 onGetValue() 方法,该方法将根据 GUI 中选择的 StoreType 从存储中检索条目。现在先让 switch 语句为空,因为它暂时不会处理任何类型的条目:

            ...
            private void onGetValue() {
                // Retrieves key and type entered by the user.
                String key = mUIKeyEdit.getText().toString();
                StoreType type = (StoreType) mUITypeSpinner
                                                       .getSelectedItem();
                // Checks key is correct.
                if (!mKeyPattern.matcher(key).matches()) {
                    displayMessage("Incorrect key.");
                    return;
                }
    
                // Retrieves value from the store and displays it.
                // Each data type has its own access method.
                switch (type) {
                    // Will retrieve entries soon...
                }
            }
            ...
    
  7. 然后,在 PlaceholderFragment 中,定义 StoreActivityonSetValue() 方法,以在存储中插入或更新条目。如果值格式不正确,将显示一条消息:

            ...
            private void onSetValue() {
                // Retrieves key and type entered by the user.
                String key = mUIKeyEdit.getText().toString();
                String value = mUIValueEdit.getText().toString();
                StoreType type = (StoreType) mUITypeSpinner
                                                       .getSelectedItem();
                // Checks key is correct.
                if (!mKeyPattern.matcher(key).matches()) {
                    displayMessage("Incorrect key.");
                    return;
                }
    
                // Parses user entered value and saves it in the store.
                // Each data type has its own access method.
                try {
                    switch (type) {
                        // Will put entries soon...
                    }
                } catch (Exception eException) {
                    displayMessage("Incorrect value.");
                }
                updateTitle();
            }
            ...
    
  8. 最后,PlaceholderFragment 中的一个小助手方法 displayMessage() 将帮助在出现问题时警告用户。它显示一个简单的 Android Toast 消息:

            ...
            private void displayMessage(String pMessage) {
                Toast.makeText(getActivity(), pMessage, Toast.LENGTH_LONG)
                     .show();
            }
        }
    }
    

刚才发生了什么?

我们使用 Android 框架的几个视觉组件在 Java 中创建了一个基本的图形用户界面。如您所见,这里没有 NDK 的特定内容。故事的核心是本地代码可以与任何现有的 Java 代码集成。

显然,我们还需要做些工作,让我们的本地代码为 Java 应用程序执行一些有用的操作。现在让我们切换到本地端。

动手操作时间——初始化本地存储

我们需要创建并初始化我们将在本章下一部分使用的所有结构:

  1. 创建 jni/Store.h 文件,该文件定义了存储数据结构:

    • StoreType 枚举将反映相应的 Java 枚举。现在先让它为空。

    • StoreValue 联合体将包含可能的存储值中的任何一个。现在也先让它为空。

    • StoreEntry 结构包含存储中的一条数据。它由一个键(由 char* 制作的原始 C 字符串)、一个类型(StoreType)和一个值(StoreValue)组成。

      注意

      请注意,我们将在第九章,将现有库移植到 Android中了解如何设置和使用 C++ STL 字符串。

    • Store 是一个主要结构,定义了一个固定大小的条目数组和长度(即已分配的条目数):

      #ifndef _STORE_H_
      #define _STORE_H_
      
      #include <cstdint>
      
      #define STORE_MAX_CAPACITY 16
      
      typedef enum {
      } StoreType;
      
      typedef union {
      } StoreValue;
      
      typedef struct {
          char* mKey;
          StoreType mType;
          StoreValue mValue;
      } StoreEntry;
      
      typedef struct {
          StoreEntry mEntries[STORE_MAX_CAPACITY];
          int32_t mLength;
      } Store;
      #endif
      

      提示

      包含保护(即#ifndef, #define, 和 #endif),它们确保头文件在编译期间只被包含一次,可以用非标准(但广泛支持的)预处理器指令#pragma once来替换。

  2. jni/com_packtpub_Store.cpp中,实现JNI_OnLoad()初始化钩子。在内部,将Store数据结构的唯一实例初始化为一个静态变量:

    #include "com_packtpub_store_Store.h"
    #include "Store.h"
    
    static Store gStore;
    
    JNIEXPORT jint JNI_OnLoad(JavaVM* pVM, void* reserved) {
     // Store initialization.
     gStore.mLength = 0;
     return JNI_VERSION_1_6;
    }
    ...
    
  3. 相应地更新本地store getCount()方法,以反映分配给商店的条目数量:

    ...
    JNIEXPORT jint JNICALL Java_com_packtpub_store_Store_getCount
      (JNIEnv* pEnv, jobject pObject) {
     return gStore.mLength;
    }
    

刚才发生了什么?

我们用简单的 GUI 和本地内存中的数据数组构建了商店项目的基石。包含的本地库可以通过以下调用加载:

  • System.load(),它接收库的全路径作为参数。

  • System.loadLibrary(),它只需要库名称,不需要路径、前缀(即lib)或扩展名。

本地代码初始化在JNI_OnLoad()钩子中发生,该钩子在本地代码的生命周期内只被调用一次。这是初始化和缓存全局变量的完美位置。JNI 元素(类、方法、字段等)也经常在JNI_OnLoad()中被缓存,以提高性能。我们将在本章和下一章中了解更多相关信息。

请注意,在 Android 中,由于无法保证在进程终止之前卸载库,因此在 JNI 规范中定义的挂起调用JNI_OnUnload()几乎是没用的。

JNI_OnLoad()签名被系统地定义如下:

JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved);

使得JNI_OnLoad()如此有用的原因是它的JavaVM参数。通过它,你可以按照以下方式检索JNIEnv 接口指针

JNIEXPORT jint JNI_OnLoad(JavaVM* pVM, void* reserved) {
 JNIEnv *env;
 if (pVM->GetEnv((void**) &env, JNI_VERSION_1_6) != JNI_OK) {
 abort();
    }
    ...
    return JNI_VERSION_1_6;
}

提示

在 JNI 库中的JNI_OnLoad()定义是可选的。但是,如果省略它,你可能会在启动应用程序时在Logcat中看到警告No JNI_OnLoad found in .so。这绝对没有后果,可以安全地忽略。

JNIEnv是所有 JNI 调用的主要入口点,这就是为什么它会被传递给所有本地方法的原因。它提供了一系列方法,以便从本地代码访问 Java 原始类型和数组。它还通过类似反射的 API,使本地代码能够完全访问 Java 对象。我们将在本章和下一章中更详细地了解其特性。

提示

JNIEnv接口指针是线程特定的。你绝对不能在线程之间共享它!只能在获取它的线程上使用它。只有 JavaVM 元素是线程安全的,可以在线程之间共享。

在本地代码中转换 Java 字符串

我们将处理的第一种条目是字符串。字符串在 Java 中作为(几乎)经典的对象表示,可以通过 JNI 在本地端操作并转换为本地字符串,即原始字符数组。尽管字符串由于其异构表示的复杂性而显得复杂,但它们是一等公民。

在这一部分,我们将把 Java 字符串发送到原生端,并将其转换为对应的原生字符串。我们还会将它们重新转换回 Java 字符串。

注意

本书提供了名为Store_Part5的项目,其中包含此结果。

行动时间——处理原生存储中的字符串

让我们处理存储中的字符串值:

  1. 打开StoreType.java并在枚举中指定我们存储处理的新字符串类型:

    public enum StoreType {
     String
    }
    Open Store.java and define the new functionalities our native key/value store provides (for now, only strings):
    public class Store {
        ...
        public native int getCount();
    
     public native String getString(String pKey);
     public native void setString(String pKey, String pString);
    }
    
  2. StoreActivity.java中,在onGetValue()方法中从原生Store获取字符串条目。根据当前在 GUI 中选定的StoreType类型进行操作(尽管目前只有一个可能的类型):

    public class StoreActivity extends Activity {
        ...
        public static class PlaceholderFragment extends Fragment {
            ...
            private void onGetValue() {
                ...
                switch (type) {
     case String:
     mUIValueEdit.setText(mStore.getString(key));
     break;
                }
            }
            ...
    
  3. onSetValue()方法中插入或更新存储中的字符串条目:

            ...
            private void onSetValue() {
                ...
                try {
                    switch (type) {
     case String:
     mStore.setString(key, value);
     break;
                    }
                } catch (Exception eException) {
                    displayMessage("Incorrect value.");
                }
                updateTitle();
            }
            ...
        }
    }
    
  4. jni/Store.h中,包含一个新的header jni.h以访问 JNI API。

    #ifndef _STORE_H_
    #define _STORE_H_
    
    #include <cstdint>
    #include "jni.h"
    ...
    
  5. 接下来,将字符串集成到原生的StoreType枚举和StoreValue联合体中:

    ...
    typedef enum {
     StoreType_String
    } StoreType;
    
    typedef union {
     char*     mString;
    } StoreValue;
    ...
    
  6. 通过声明用于检查、创建、查找和销毁条目的实用方法来结束。JNIEnvjstring是在jni.h头文件中定义的 JNI 类型:

    ...
    bool isEntryValid(JNIEnv* pEnv, StoreEntry* pEntry, StoreType pType);
    
    StoreEntry* allocateEntry(JNIEnv* pEnv, Store* pStore, jstring pKey);
    
    StoreEntry* findEntry(JNIEnv* pEnv, Store* pStore, jstring pKey);
    
    void releaseEntryValue(JNIEnv* pEnv, StoreEntry* pEntry);
    #endif
    
  7. 创建一个新文件jni/Store.cpp以实现所有这些实用方法。首先,isEntryValid()仅检查条目是否已分配并具有预期的类型:

    #include "Store.h"
    #include <cstdlib>
    #include <cstring>
    
    bool isEntryValid(JNIEnv* pEnv, StoreEntry* pEntry, StoreType pType) {
        return ((pEntry != NULL) && (pEntry->mType == pType));
    }
    ...
    
  8. findEntry()方法通过将传入的参数与存储中的每个键进行比较,直到找到匹配项。它不使用传统的原生字符串(即char*),而是接收一个jstring参数,这是在原生端对 Java String的直接表示。

  9. 要从 Java String中恢复原生字符串,请使用 JNI API 中的GetStringUTFChars()获取一个临时字符缓冲区,其中包含转换后的 Java 字符串。然后可以使用标准的 C 语言例程操作其内容。GetStringUTFChars()必须与ReleaseStringUTFChars()的调用配对,以释放在GetStringUTFChars()中分配的临时缓冲区:

    提示

    Java 字符串在内存中以 UTF-16 字符串的形式存储。当在原生代码中提取其内容时,返回的缓冲区以修改后的 UTF-8 编码。修改后的 UTF-8 与标准 C 字符串函数兼容,后者通常在由 8 位每个字符组成的字符串缓冲区上工作。

    ...
    StoreEntry* findEntry(JNIEnv* pEnv, Store* pStore, jstring pKey) {
        StoreEntry* entry = pStore->mEntries;
        StoreEntry* entryEnd = entry + pStore->mLength;
    
        // Compare requested key with every entry key currently stored
        // until we find a matching one.
        const char* tmpKey = pEnv->GetStringUTFChars(pKey, NULL);
        while ((entry < entryEnd) && (strcmp(entry->mKey, tmpKey) != 0)) {
            ++entry;
        }
        pEnv->ReleaseStringUTFChars(pKey, tmpKey);
    
        return (entry == entryEnd) ? NULL : entry;
    }
    ...
    

    提示

    JNI 不会原谅任何错误。例如,如果你在GetStringUTFChars()中将NULL作为第一个参数传递,虚拟机将立即终止。此外,Android JNI 并不完全遵守 JNI 规范。尽管 JNI 规范指出,如果无法分配内存,GetStringUTFChars()可能会返回NULL,但在这种情况下,Android VM 会直接终止。

  10. 实现allocateEntry(),该方法要么创建一个新的条目(即增加存储长度并返回最后一个元素),要么如果键已存在则释放其先前值后返回现有条目。

    如果条目是新的,请将其键转换为可以在内存中保留的原生字符串。实际上,原始 JNI 对象在其方法调用的持续时间内存在,并且不能在其作用域之外保留:

    ...
    StoreEntry* allocateEntry(JNIEnv* pEnv, Store* pStore, jstring pKey) {
        // If entry already exists in the store, releases its content
        // and keep its key.
        StoreEntry* entry = findEntry(pEnv, pStore, pKey);
        if (entry != NULL) {
            releaseEntryValue(pEnv, entry);
        }
        // If entry does not exist, create a new entry
        // right after the entries already stored.
        else {
            entry = pStore->mEntries + pStore->mLength;
    
            // Copies the new key into its final C string buffer.
            const char* tmpKey = pEnv->GetStringUTFChars(pKey, NULL);
            entry->mKey = new char[strlen(tmpKey) + 1];
            strcpy(entry->mKey, tmpKey);
            pEnv->ReleaseStringUTFChars(pKey, tmpKey);
    
            ++pStore->mLength;
        }
        return entry;
    }
    ...
    
  11. 编写最后一个方法releaseEntryValue(),该方法在需要时释放为值分配的内存:

    ...
    void releaseEntryValue(JNIEnv* pEnv, StoreEntry* pEntry) {
        switch (pEntry->mType) {
        case StoreType_String:
            delete pEntry->mValue.mString;
            break;
        }
    }
    
  12. 使用上一章中看到的javah刷新 JNI 头文件jni/com_packtpub_Store.h。你应在其中看到两个新方法Java_com_packtpub_store_Store_getString()Java_com_packtpub_store_Store_setString()

  13. jni/com_packtpub_Store.cpp中,插入cstdlib头文件:

    #include "com_packtpub_store_Store.h"
    #include <cstdlib>
    #include "Store.h"
    ...
    
  14. 借助之前生成的 JNI 头文件,实现原生方法getString()。此方法在存储区中查找传递的键并返回其对应的字符串值。如果出现任何问题,将返回默认的NULL值。

  15. Java 字符串并非真正的原始数据类型。我们之前已经看到,类型jstringchar*不能互换使用。要从原生字符串创建 Java String对象,请使用 JNI API 中的NewStringUTF()

    ...
    JNIEXPORT jstring JNICALL Java_com_packtpub_store_Store_getString
      (JNIEnv* pEnv, jobject pThis, jstring pKey) {
        StoreEntry* entry = findEntry(pEnv, &gStore, pKey);
        if (isEntryValid(pEnv, entry, StoreType_String)) {
            // Converts a C string into a Java String.
            return pEnv->NewStringUTF(entry->mValue.mString);
        } else {
            return NULL;
        }
    }
    ...
    
  16. 然后,实现setString()方法,该方法分配一个条目(即,在存储区中创建一个新的条目,如果存在具有相同键的条目则重用),并将转换后的 Java 字符串值存储在其中。

  17. 字符串值使用 JNI API 的GetStringUTFLength()GetStringUTFRegion()方法直接从 Java 字符串翻译到我们自己的字符串缓冲区。这是之前使用的GetStringUTFChars()的替代方法。最后,我们一定不要忘记添加null字符,这是原始 C 字符串的标准:

    ...
    JNIEXPORT void JNICALL Java_com_packtpub_store_Store_setString
      (JNIEnv* pEnv, jobject pThis, jstring pKey, jstring pString) {
        // Turns the Java string into a temporary C string.
        StoreEntry* entry = allocateEntry(pEnv, &gStore, pKey);
        if (entry != NULL) {
            entry->mType = StoreType_String;
            // Copy the temporary C string into its dynamically allocated
            // final location. Then releases the temporary string.
            jsize stringLength = pEnv->GetStringUTFLength(pString);
            entry->mValue.mString = new char[stringLength + 1];
            // Directly copies the Java String into our new C buffer.
            pEnv->GetStringUTFRegion(pString, 0, stringLength,
                                     entry->mValue.mString);
            // Append the null character for string termination.
            entry->mValue.mString[stringLength] = '\0';    }
    }
    
  18. 最后,更新Android.mk文件以编译Store.cpp

    LOCAL_PATH := $(call my-dir)
    
    include $(CLEAR_VARS)
    
    LOCAL_MODULE    := com_packtpub_store_Store
    LOCAL_SRC_FILES := com_packtpub_store_Store.cpp Store.cpp
    
    include $(BUILD_SHARED_LIBRARY)
    

刚才发生了什么?

运行应用程序。尝试使用不同的键和值保存几个条目。然后尝试从原生存储区获取它们。我们已经实现了在 Java 和 C/C++之间传递和检索字符串。这些值作为原生字符串保存在原生内存中。然后可以根据其键从存储区将条目作为 Java 字符串检索。

Java 和 C 字符串是完全不同的。Java 字符串需要一个具体的转换,以原生字符串的形式允许使用标准的 C 字符串例程处理它们的内容。实际上,jstring不是经典的char*数组的表示,而是对 Java String对象的引用,只能从 Java 代码中访问。

在这一部分中,我们发现了两种将 Java 字符串转换为原生字符串的方法:

  • 通过预先分配一个内存缓冲区,将转换后的 Java 字符串复制到其中。

  • 通过在由 JNI 管理的内存缓冲区中检索转换后的 Java 字符串。

选择哪种解决方案取决于客户端代码如何处理内存。

原生字符编码

JNI 提供了两种处理字符串的方法:

  • 名称中包含 UTF 且处理修改后的 UTF-8 字符串的那些方法

  • 名称中不包含 UTF 且处理 UTF-16 编码的那些方法

修改后的 UTF-8 和 UTF-16 字符串是两种不同的字符编码:

  • 修改后的 UTF-8是 Java 特有的轻微变体的 UTF-8。这种编码可以表示标准 ASCII 字符(每个字符一个字节)或者可以扩展到 4 个字节来表示扩展字符(阿拉伯语、西里尔语、希腊语、希伯来语等)。标准 UTF-8 和修改后的 UTF-8 之间的区别在于对null字符的不同表示,后者根本不存在这个字符。这样,这些字符串可以用标准的 C 例程处理,而 C 例程使用null字符作为结束标志。

  • UTF-16是真正用于 Java 字符串的编码。每个字符用两个字节表示,因此 Java char的大小如此。因此,在本地代码中使用 UTF-16 而不是修改后的 UTF-8 更有效率,因为它们不需要转换。缺点是,经典的 C 字符串例程无法处理它们,因为它们不是以null结尾的。

字符编码是一个复杂的主题,你可以访问www.oracle.com/technetwork/articles/javase/supplementary-142654.htmldeveloper.android.com/training/articles/perf-jni.html#UTF_8_and_UTF_16_strings的 Android 文档获取更多信息。

JNI 字符串 API

JNI 提供了几种方法来处理本地端的 Java 字符串:

  • GetStringUTFLength()计算修改后的 UTF-8 字符串的长度(以字节为单位,因为 UTF-8 字符串的字符大小不同),而GetStringLength()计算 UTF-16 字符串的字符数(不是字节,因为 UTF-16 字符的大小是固定的):

    jsize GetStringUTFLength(jstring string)
    jsize GetStringLength(jstring string)
    
  • GetStringUTFChars()GetStringChars()通过 JNI 分配一个新的内存缓冲区,用于存储 Java 到本地(分别是修改后的 UTF-8 和 UTF-16)字符串转换的结果。当你想转换整个字符串而不想处理内存分配时,请使用它。最后一个参数isCopy,如果不为null,表示字符串是否被 JNI 内部复制,或者返回的缓冲区是否指向实际的 Java 字符串内存。在 Android 中,对于GetStringUTFChars()返回的isCopy值通常是JNI_TRUE,对于GetStringChars()则是JNI_FALSE(后者确实不需要编码转换):

    const char* GetStringUTFChars(jstring string, jboolean* isCopy)
    const jchar* GetStringChars(jstring string, jboolean* isCopy)
    

    提示

    尽管 JNI 规范指出GetStringUTFChars()可能返回 NULL(这意味着操作可能因为例如无法分配内存而失败),但实际上,这种检查是没有用的,因为 Dalvik 或 ART VM 在这种情况下通常会终止。所以,尽量避免进入这种情况!如果你的代码旨在移植到其他 Java 虚拟机上,你仍然应该保留 NULL 检查。

  • ReleaseStringUTFChars()ReleaseStringChars()方法用于释放GetStringUTFChars()GetStringChars()分配的内存缓冲区,当客户端处理完毕后。这些方法必须始终成对调用:

    void ReleaseStringUTFChars(jstring string, const char* utf)
    void ReleaseStringChars(jstring string, const jchar* chars)
    
  • GetStringUTFRegion()GetStringRegion()获取 Java 字符串的全部或部分区域。它作用于由客户端代码提供和管理的字符串缓冲区。当您想要管理内存分配(例如,重用现有的内存缓冲区)或需要访问字符串的小部分时使用它:

    void GetStringRegion(jstring str, jsize start, jsize len, jchar* buf)
    void GetStringUTFRegion(jstring str, jsize start, jsize len, char* buf)
    
  • GetStringCritical()ReleaseStringCritical()GetStringChars()ReleaseStringChars()类似,但仅适用于 UTF-16 字符串。根据 JNI 规范,GetStringCritical()更有可能返回一个直接指针,而不进行任何复制。作为交换,调用者不得执行阻塞操作或 JNI 调用,并且不应长时间持有字符串(就像线程中的临界区)。实际上,Android 似乎不管你是否使用关键功能都表现相似(但这可能会改变):

    const jchar* GetStringCritical(jstring string, jboolean* isCopy)
    void ReleaseStringCritical(jstring string, const jchar* carray)
    

这是您需要了解的通过 JNI 处理 Java 字符串的基本知识。

将 Java 基本类型传递给本地代码

我们可以使用 JNI 处理的最简单的元素是 Java 基本类型。实际上,Java 端和本地端几乎使用相同的数据表示,这种数据不需要任何特定的内存管理。

在这一部分,我们将了解如何将整数传递到本地端,并将它们发送回 Java 端。

注意

本书提供的项目名为Store_Part6

动手实践时间——在本地存储中处理基本类型。

  1. StoreType.java中,将新管理的整数类型添加到枚举中:

    public enum StoreType {
        Integer,
        String
    }
    
  2. 打开Store.java文件,定义我们的本地存储提供的新整数功能:

    public class Store {
        ...
        public native int getCount();
    
     public native int getInteger(String pKey);
     public native void setInteger(String pKey, int pInt);
    
        public native String getString(String pKey);
        public native void setString(String pKey, String pString);
    }
    
  3. StoreActivity类中,更新onGetValue()方法,以便在 GUI 中选择整数条目时从存储中检索它们:

    public class StoreActivity extends Activity {
        ...
        public static class PlaceholderFragment extends Fragment {
            ...
            private void onGetValue() {
                ...
                switch (type) {
     case Integer:
     mUIValueEdit.setText(Integer.toString(mStore
     .getInteger(key)));
     break;
                case String:
                    mUIValueEdit.setText(mStore.getString(key));
                    break;
                }
            }
            ...
    
  4. 同时,在onSetValue()方法中插入或更新存储中的整数条目。在将条目数据传递到本地端之前,需要对其进行解析:

            ...
            private void onSetValue() {
                ...
                try {
                    switch (type) {
     case Integer:
     mStore.setInteger(key, Integer.parseInt(value));
     break;
                    case String:
                        mStore.setString(key, value);
                        break;
                    }
                } catch (Exception eException) {
                    displayMessage("Incorrect value.");
                }
                updateTitle();
            }
            ...
        }
    }
    
  5. jni/Store.h文件中,向本地StoreType枚举和StoreValue联合体中添加整数类型:

    ...
    typedef enum {
     StoreType_Integer,
        StoreType_String
    } StoreType;
    typedef union {
     int32_t   mInteger;
        char*     mString;
    } StoreValue;
    ...
    
  6. 使用javah刷新 JNI 头文件jni/com_packtpub_Store.h。应该出现两个新方法Java_com_packtpub_store_Store_getInteger()Java_com_packtpub_store_Store_setInteger()

  7. jni/com_packtpub_Store.cpp文件中,借助生成的 JNI 头文件实现getInteger()方法。该方法仅返回条目的整数值,除了从int32_t隐式转换为jint外,不进行任何特定的转换。如果在检索过程中出现任何问题,将返回默认值:

    ...
    JNIEXPORT jint JNICALL Java_com_packtpub_store_Store_getInteger
      (JNIEnv* pEnv, jobject pThis, jstring pKey) {
        StoreEntry* entry = findEntry(pEnv, &gStore, pKey);
        if (isEntryValid(pEnv, entry, StoreType_Integer)) {
            return entry->mValue.mInteger;
        } else {
            return 0;
        }
    }
    ...
    
  8. 第二个方法setInteger()将给定的整数值存储在分配的条目中。注意,传递的 JNI 整数同样可以反向转换为 C/C++整数:

    ...
    JNIEXPORT void JNICALL Java_com_packtpub_store_Store_setInteger
      (JNIEnv* pEnv, jobject pThis, jstring pKey, jint pInteger) {
        StoreEntry* entry = allocateEntry(pEnv, &gStore, pKey);
        if (entry != NULL) {
            entry->mType = StoreType_Integer;
            entry->mValue.mInteger = pInteger;
        }
    }
    

刚才发生了什么?

运行应用程序。尝试使用不同的键、类型和值保存几个条目。然后尝试从本地存储中获取它们。这次我们已经实现了从 Java 到 C/C++ 传递和检索整数原始数据。

在本地调用期间,整数原始数据有多种形式;首先,Java 代码中的 int,然后是从/到 Java 代码传输期间的 jint,最后是本地代码中的 intint32_t。显然,如果我们愿意,可以保留本地代码中的 JNI 表示形式 jint,因为所有这些类型实际上是等价的。换句话说,jint 只是一个别名。

提示

int32_t 类型是由 C99 标准库通过 typedef 引入的,旨在提高可移植性。与标准 int 类型的区别在于,它的字节大小对所有编译器和平台都是固定的。更多的数字类型在 stdint.h(在 C 中)或 cstdint(在 C++ 中)中定义。

所有原始类型在 JNI 中都有其适当的别名:

Java 类型 JNI 类型 C 类型 Stdint C 类型
boolean Jboolean unsigned char uint8_t
byte Jbyte signed char int8_t
char Jchar unsigned short uint16_t
double Jdouble double N/A
float jfloat float N/A
int jint Int int32_t
long jlong long long int64_t
short jshort Short int16_t

你可以完全像在这一部分中使用整数一样使用它们。关于 JNI 中原始类型更多信息可以在 docs.oracle.com/javase/6/docs/technotes/guides/jni/spec/types.html 找到

动手英雄——传递和返回其他原始类型

当前存储只处理整数和字符串。基于此模型,尝试为其他原始类型实现存储方法:booleanbytechardoublefloatlongshort

注意

最终项目与此书一同提供,名称为 Store_Part6_Full

从本地代码引用 Java 对象

如前一部分所述,我们知道在 JNI 中字符串由 jstring 表示,实际上它是一个 Java 对象,这意味着可以通过 JNI 交换任何 Java 对象!然而,由于本地代码不能直接理解或访问 Java,所有 Java 对象都有相同的表示形式,即 jobject

在这一部分,我们将重点介绍如何在本地端保存对象以及如何将其发送回 Java。作为一个例子,我们将使用自定义对象 Color,尽管任何其他类型的对象也可以。

注意

最终项目与此书一同提供,名称为 Store_Part7

动手时间——在本地存储中保存对象引用

  1. 创建一个新的 Java 类 com.packtpub.store.Color,封装一个表示颜色的整数值。这个整数是通过 android.graphics.Color 类从包含 HTML 代码的 String(例如,#FF0000)解析得到的。

    package com.packtpub.store;
    import android.text.TextUtils;
    public class Color {
        private int mColor;
        public Color(String pColor) {
            if (TextUtils.isEmpty(pColor)) {
                throw new IllegalArgumentException();
            }
            mColor = android.graphics.Color.parseColor(pColor);
        }
        @Override
        public String toString() {
            return String.format("#%06X", mColor);
        }
    }
    
  2. StoreType.java 中,将新的 Color 数据类型添加到枚举中:

    public enum StoreType {
        Integer,
        String,
     Color
    }
    
  3. Store 类中,添加两个新的本地方法以获取和保存 Color 对象:

    public class Store {
        ...
     public native Color getColor(String pKey);
     public native void setColor(String pKey, Color pColor);
    }
    
  4. 打开 StoreActivity.java 文件,并更新方法 onGetValue()onSetValue() 以解析和显示 Color 实例:

    public class StoreActivity extends Activity {
        ...
        public static class PlaceholderFragment extends Fragment {
            ...
            private void onGetValue() {
                ...
                switch (type) {
                ...
     case Color:
     mUIValueEdit.setText(mStore.getColor(key)
                                    .toString());
     break;
                }
            }
            private void onSetValue() {
                ...
                try {
                    switch (type) {
                    ...
     case Color:
     mStore.setColor(key, new Color(value));
     break;
                    }
                } catch (Exception eException) {
                    displayMessage("Incorrect value.");
                }
                updateTitle();
            }
            ...
        }
    }
    
  5. jni/Store.h 中,将新的颜色类型添加到 StoreType 枚举中,并在 StoreValue 联合体中添加一个新成员。但是你应该使用什么类型呢?Color 是只在 Java 中已知的对象。在 JNI 中,所有 Java 对象都有相同的类型;jobject,一个(间接)对象引用:

    ...
    typedef enum {
        ...
        StoreType_String,
     StoreType_Color
    } StoreType;
    typedef union {
        ...
        char*     mString;
     jobject   mColor;
    } StoreValue;
    ...
    
  6. 使用 javah 重新生成 JNI 头文件 jni/com_packtpub_Store.h。你应在其中看到两个新的方法 Java_com_packtpub_store_Store_getColor()Java_com_packtpub_store_Store_setColor()

  7. 打开 jni/com_packtpub_Store.cpp 并实现两个新生成的 getColor()setColor() 方法。第一个方法只是简单地返回存储条目中保留的 Java Color 对象,如下代码所示:

    ...
    JNIEXPORT jobject JNICALL Java_com_packtpub_store_Store_getColor
      (JNIEnv* pEnv, jobject pThis, jstring pKey) {
        StoreEntry* entry = findEntry(pEnv, &gStore, pKey);
        if (isEntryValid(pEnv, entry, StoreType_Color)) {
            return entry->mValue.mColor;
        } else {
            return NULL;
        }
    }
    ...
    

    第二个方法 setColor() 中引入了真正的细微差别。实际上,乍一看,简单地将 jobject 值保存在存储条目中似乎就足够了。然而,这种假设是错误的。在参数中传递或在 JNI 方法内创建的对象是局部引用。局部引用不能在本地方法范围之外(如对于字符串)的本地代码中保存。

  8. 为了允许在本地方法返回后在本地代码中保留 Java 对象引用,它们必须被转换为全局引用,以通知 Dalvik VM 它们不能被垃圾收集。为此,JNI API 提供了 NewGlobalRef() 方法:

    ...
    JNIEXPORT void JNICALL Java_com_packtpub_store_Store_setColor
      (JNIEnv* pEnv, jobject pThis, jstring pKey, jobject pColor) {
        // Save the Color reference in the store.
        StoreEntry* entry = allocateEntry(pEnv, &gStore, pKey);
        if (entry != NULL) {
            entry->mType = StoreType_Color;
            // The Java Color is going to be stored on the native side.
            // Need to keep a global reference to avoid a potential
            // garbage collection after method returns.
            entry->mValue.mColor = pEnv->NewGlobalRef(pColor);
        }
    }
    
  9. Store.cpp 中,修改 releaseEntryValue() 方法,当条目被新条目替换时删除全局引用。这是通过 DeleteGlobalRef() 方法完成的,它是 NewGlobalRef() 的对应方法:

    ...
    void releaseEntryValue(JNIEnv* pEnv, StoreEntry* pEntry) {
        switch (pEntry->mType) {
        case StoreType_String:
            delete pEntry->mValue.mString;
            break;
     case StoreType_Color:
     // Unreferences the object for garbage collection.
     pEnv->DeleteGlobalRef(pEntry->mValue.mColor);
     break;
        }
    }
    

刚才发生了什么?

运行应用程序。输入并保存一个颜色值,如 #FF0000red,这是 Android 颜色解析器允许的预定义值。从存储中获取条目。我们设法在本地端引用了一个 Java 对象!Java 对象不是也不能转换为 C++ 对象。它们本质上是不同的。因此,要在本地端保留 Java 对象,我们必须使用 JNI API 保留对它们的引用。

来自 Java 的所有对象都由 jobject 表示,甚至 jstring(实际上内部是 jobjecttypedef)。jobject 只是一个没有智能垃圾收集机制的“指针”(毕竟,我们至少部分想要摆脱 Java)。它不直接给你 Java 对象内存的引用,而是间接引用。实际上,与 C++ 对象相反,Java 对象在内存中没有固定的位置。它们在其生命周期内可能会被移动。无论如何,在内存中处理 Java 对象表示都是一个坏主意。

局部引用

本地调用的作用域限制在方法内,这意味着一旦本地方法结束,虚拟机将再次接管。JNI 规范利用这一事实,将对象引用限制在方法边界内。这意味着 jobject 只能在它被赋予的方法内安全使用。一旦本地方法返回,Dalvik VM 无法知道本地代码是否还持有对象引用,并且可以在任何时间决定收集它们。

这种类型的引用称为本地引用。当本地方法返回时,它们会自动释放(指的是引用,而不是对象,尽管垃圾收集器可能会这样做),以允许在后面的 Java 代码中进行适当的垃圾收集。例如,以下代码段应该是严格禁止的。在 JNI 方法外部保留这样的引用最终会导致未定义的行为(内存损坏、崩溃等):

static jobject gMyReference;
JNIEXPORT void JNICALL Java_MyClass_myMethod(JNIEnv* pEnv,
                                     jobject pThis, jobject pRef) {
    gMyReference = pRef;
    ...
}

// Later on...
env->CallVoidMethod(gMyReference, ...);

提示

对象作为本地引用传递给本地方法。由 JNI 函数返回的每个 jobject(除了 NewGlobalRef())都是一个本地引用。请记住,默认情况下一切都是本地引用。

JNI 提供了几种用于管理本地引用的方法:

  1. NewLocalRef() 可以显式地创建一个本地引用(例如,从一个全局引用),尽管这在实践中很少需要:

    jobject NewLocalRef(jobject ref)
    
  2. DeleteLocalRef() 方法可以在不再需要时用来删除一个本地引用:

    void DeleteLocalRef(jobject localRef)
    

提示

本地引用不能在方法作用域之外使用,也不能在即使是单个本地调用期间在各个线程间共享!

你不需要显式删除本地引用。然而,根据 JNI 规范,JVM 只需要同时存储 16 个本地引用,并且可能会拒绝创建更多(这是特定于实现的)。因此,尽早释放未使用的本地引用是良好的实践,特别是在处理数组时。

幸运的是,JNI 提供了一些其他方法来帮助处理本地引用。

  1. EnsureLocalCapacity() 告诉 VM 它需要更多的本地引用。当此方法无法保证请求的容量时,它返回 -1 并抛出 Java OutOfMemoryError

    jint EnsureLocalCapacity(jint capacity)
    
  2. PushLocalFrame()PopLocalFrame() 提供了第二种分配更多本地引用的方法。这可以理解为批量分配本地槽和删除本地引用的方式。当此方法无法保证请求的容量时,它也会返回 -1 并抛出 Java OutOfMemoryError

    jint PushLocalFrame(jint capacity)
    jobject PopLocalFrame(jobject result)
    

    提示

    直到 Android 4.0 冰激凌三明治版本,本地引用实际上是直接指针,这意味着它们可以保持在其自然作用域之外并且仍然有效。现在不再是这样,这种有缺陷的代码应该避免。

全局引用

要能在方法作用域之外使用对象引用或长时间保存它,引用必须被设置为全局。全局引用还允许在各个线程间共享对象,而本地引用则不能。

JNI 提供了两个为此目的的方法:

  1. 使用NewGlobalRef()创建全局引用,防止回收指向的对象,并允许其在线程间共享。同一个对象的两个引用可能是不同的:

    jobject NewGlobalRef(jobject obj)
    
  2. 使用DeleteGlobalRef()删除不再需要全局引用。如果没有它,Dalvik VM 会认为对象仍然被引用,永远不会回收它们:

    void DeleteGlobalRef(jobject globalRef)
    
  3. 使用IsSameObject()比较两个对象引用,而不是使用==,后者不是比较引用的正确方式:

    jboolean IsSameObject(jobject ref1, jobject ref2)
    

提示

切记要配对使用New<Reference Type>Ref()Delete<Reference Type>Ref()。否则会导致内存泄漏。

弱引用

弱引用是 JNI 中可用的最后一种引用类型。它们与全局引用相似,可以在 JNI 调用之间保持并在线程间共享。然而,与全局引用不同,它们不会阻止垃圾回收。因此,这种引用必须谨慎使用,因为它可能随时变得无效,除非每次在使用之前从它们创建全局或局部引用(并在使用后立即释放!)。

提示

当适当使用时,弱引用有助于防止内存泄漏。如果你已经进行了一些 Android 开发,你可能已经知道最常见的泄漏之一:从后台线程(通常是AsyncTask)保持对 Activity 的“硬”引用,以便在处理完成后通知 Activity。的确,在发送通知之前,Activity 可能会被销毁(例如,因为用户旋转了屏幕)。当使用弱引用时,Activity 仍然可以被垃圾回收,从而释放内存。

NewWeakGlobalRef()DeleteWeakGlobalRef()是创建和删除弱引用所需仅有的方法:

jweak NewWeakGlobalRef(JNIEnv *env, jobject obj);
void DeleteWeakGlobalRef(JNIEnv *env, jweak obj);

这些方法返回一个jweak引用,如果需要,可以将其强制转换为输入对象(例如,如果你创建了一个到jclass的引用,那么返回的jweak可以强制转换为jclassjobject)。

然而,你不应直接使用它,而应将其传递给NewGlobalRef()NewLocalRef(),并像平常一样使用它们的结果。要确保从弱引用发出的局部或全局引用有效,只需检查NewGlobalRef()NewLocalRef()返回的引用是否为NULL。完成对象操作后,你可以删除全局或局部引用。每次重新使用该弱对象时,请重新开始这个过程。例如:

jobject myObject = ...;
// Keep a reference to that object until it is garbage collected.
jweak weakRef = pEnv->NewWeakGlobalRef(myObject);
...

// Later on, get a real reference, hoping it is still available.
jobject localRef = pEnv->NewLocalRef(weakRef);
if (!localRef) {
// Do some stuff...
pEnv->DeleteLocalRef(localRef);
} else {
   // Object has been garbage collected, reference is unusable...
}

...
// Later on, when weak reference is no more needed.
pEnv->DeleteWeakGlobalRef(weakRef);

要检查弱引用本身是否指向一个对象,请使用IsSameObject()jweakNULL进行比较(不要使用==):

jboolean IsSameObject(jobject ref1, jobject ref2)

在创建全局或局部引用之前,不要试图检查弱引用的状态,因为指向的对象可能会被并发地回收。

提示

在 Android 2.2 Froyo 之前,弱引用根本不存在。直到 Android 4.0 Ice Cream Sandwich,除了NewGlobalRef()NewLocalRef()之外,它们不能在 JNI 调用中使用。尽管这不再是强制性的,但在其他 JNI 调用中直接使用弱引用应被视为一种不良实践。

若要了解更多关于此主题的信息,请查看 JNI 规范,链接为:docs.oracle.com/javase/6/docs/technotes/guides/jni/spec/jniTOC.html

管理 Java 数组

还有一种我们尚未讨论的数据类型:数组。数组在 Java 和 JNI 中都有其特定的位置。它们具有自己的类型和 API,尽管 Java 数组在本质上也是对象。

在这一部分,我们将通过允许用户在输入项中同时输入一组值来改进我们的存储。这组值将作为 Java 数组传递给本地存储,然后以传统的 C 数组形式存储。

注意事项

最终的项目作为本书的一部分提供,名为Store_Part8

动手实践——在本地存储中处理 Java 数组

为了帮助我们处理数组操作,让我们下载一个辅助库,Google Guava(在撰写本书时为 18.0 版本),可在code.google.com/p/guava-libraries/获取。Guava 提供了许多用于处理原语和数组,以及执行“伪函数式”编程的有用方法。

guava jar复制到项目libs目录中。打开属性项目,并转到Java 构建路径 | 。通过点击添加 JARs...按钮并验证,引用 Guava jar。

  1. 编辑StoreType.java枚举,并添加三个新值:IntegerArrayStringArrayColorArray

    public enum StoreType {
        ...
        Color,
        IntegerArray,
        StringArray,
        ColorArray
    }
    
  2. 打开Store.java文件,并添加新的方法以获取和保存intStringColor数组:

    public class Store {
        ...
     public native int[] getIntegerArray(String pKey);
     public native void setIntegerArray(String pKey, int[] pIntArray);
     public native String[] getStringArray(String pKey);
     public native void setStringArray(String pKey,
     String[] pStringArray);
     public native Color[] getColorArray(String pKey);
     public native void setColorArray(String pKey,Color[] pColorArray);
    }
    
  3. 编辑StoreActivity.java,将本地方法连接到 GUI。

    修改onGetValue()方法,使其根据其类型从存储中检索数组,使用分号分隔符(得益于 Guava 连接器)连接其值,并最终显示它们:

    public class StoreActivity extends Activity {
        ...
        public static class PlaceholderFragment extends Fragment {
            ...
            private void onGetValue() {
                ...
                switch (type) {
                ...
     case IntegerArray:
     mUIValueEdit.setText(Ints.join(";", mStore
     .getIntegerArray(key)));
     break;
     case StringArray:
     mUIValueEdit.setText(Joiner.on(";").join(
     mStore.getStringArray(key)));
     break;
     case ColorArray:
     mUIValueEdit.setText(Joiner.on(";").join(mStore
     .getColorArray(key)));
     break;            case IntegerArray:
                }
            }
            ...
    
  4. 改进onSetValue()方法,在将值列表传输到Store之前将其转换成数组(得益于 Guava 的转换特性):

            ...
            private void onSetValue() {
                ...
                try {
                    switch (type) {
                    ...
                    case IntegerArray:
     mStore.setIntegerArray(key, Ints.toArray(
     stringToList(new Function<String, Integer>() {
     public Integer apply(String pSubValue) {
     return Integer.parseInt(pSubValue);
     }
     }, value)));
     break;
     case StringArray:
     String[] stringArray = value.split(";");
     mStore.setStringArray(key, stringArray);
     break;
     case ColorArray:
     List<Color> idList = stringToList(
     new Function<String, Color>() {
     public Color apply(String pSubValue) {
     return new Color(pSubValue);
     }
     }, value);
     mStore.setColorArray(key, idList.toArray(
     new Color[idList.size()]));
     break;
                    }
                } catch (Exception eException) {
                    displayMessage("Incorrect value.");
                }
                updateTitle();
            }
            ...
    
  5. 编写一个辅助方法stringToList(),帮助您将字符串转换为目标类型的列表:

            ...
            private <TType> List<TType> stringToList(
                            Function<String, TType> pConversion,
                            String pValue) {
                String[] splitArray = pValue.split(";");
                List<String> splitList = Arrays.asList(splitArray);
                return Lists.transform(splitList, pConversion);
            }
        }
    }
    
  6. jni/Store.h中,将新的数组类型添加到StoreType枚举中。同时,在StoreValue联合体中声明新字段mIntegerArraymStringArraymColorArray。存储数组以原始 C 数组(即一个指针)的形式表示:

    ...
    typedef enum {
        ...
        StoreType_Color,
     StoreType_IntegerArray,
     StoreType_StringArray,
     StoreType_ColorArray
    } StoreType;
    
    typedef union {
        ...
        jobject   mColor;
     int32_t*  mIntegerArray;
     char**    mStringArray;
     jobject*  mColorArray;
    } StoreValue;
    ...
    
  7. 我们还需要记住这些数组的长度。在StoreEntry中的新字段mLength中输入此信息:

    ...
    typedef struct {
        char* mKey;
        StoreType mType;
        StoreValue mValue;
     int32_t mLength;
    } StoreEntry;
    ...
    
  8. jni/Store.cpp中,为新的数组类型在releaseEntryValue()中插入案例。实际上,当相应的条目被释放时,必须释放分配的数组。由于颜色是 Java 对象,删除每个数组项中保存的全局引用,否则永远不会进行垃圾回收(导致内存泄漏):

    void releaseEntryValue(JNIEnv* pEnv, StoreEntry* pEntry) {
        switch (pEntry->mType) {
        ...
     case StoreType_IntegerArray:
     delete[] pEntry->mValue.mIntegerArray;
     break;
     case StoreType_StringArray:
     // Destroys every C string pointed by the String array
     // before releasing it.
     for (int32_t i = 0; i < pEntry->mLength; ++i) {
     delete pEntry->mValue.mStringArray[i];
     }
     delete[] pEntry->mValue.mStringArray;
     break;
     case StoreType_ColorArray:
     // Unreferences every Id before releasing the Id array.
     for (int32_t i = 0; i < pEntry->mLength; ++i) {
     pEnv->DeleteGlobalRef(pEntry->mValue.mColorArray[i]);
     }
     delete[] pEntry->mValue.mColorArray;
     break;
        }
    }
    ...
    
  9. 使用Javah重新生成 JNI 头文件jni/com_packtpub_Store.h。在jni/com_packtpub_Store.cpp中实现所有这些新方法。为此,首先添加csdtint包含。

    #include "com_packtpub_store_Store.h"
    #include <cstdint>
    #include <cstdlib>
    #include "Store.h"
    ...
    
  10. 然后,缓存StringColor的 JNI 类,以便在后续步骤中能够创建这些类型的对象数组。类可以通过JNIEnv自身的反射访问,并且可以从传递给JNI_OnLoad()JavaVM中获取。

    我们需要检查找到的类是否为 null,以防它们无法加载。如果发生这种情况,虚拟机会引发异常,以便我们可以立即返回:

    ...
    static jclass StringClass;
    static jclass ColorClass;
    
    JNIEXPORT jint JNI_OnLoad(JavaVM* pVM, void* reserved) {
     JNIEnv *env;
     if (pVM->GetEnv((void**) &env, JNI_VERSION_1_6) != JNI_OK) {
     abort();
     }
     // If returned class is null, an exception is raised by the VM.
     jclass StringClassTmp = env->FindClass("java/lang/String");
     if (StringClassTmp == NULL) abort();
     StringClass = (jclass) env->NewGlobalRef(StringClassTmp);
     env->DeleteLocalRef(StringClassTmp);
     jclass ColorClassTmp = env->FindClass("com/packtpub/store/Color");
     if (ColorClassTmp == NULL) abort();
     ColorClass = (jclass) env->NewGlobalRef(ColorClassTmp);
     env->DeleteLocalRef(ColorClassTmp);
        // Store initialization.
        gStore.mLength = 0;
        return JNI_VERSION_1_6;
    }
    ...
    
  11. 编写getIntegerArray()的实现。JNI 整数数组用jintArray类型表示。如果int等同于jint,那么int*数组绝对不等同于jintArray。第一个是指向内存缓冲区的指针,而第二个是对对象的引用。

    因此,为了在这里返回jintArray,使用 JNI API 方法NewIntArray()实例化一个新的 Java 整数数组。然后,使用SetIntArrayRegion()将本地int缓冲区内容复制到jintArray中:

    ...
    JNIEXPORT jintArray JNICALL
    Java_com_packtpub_store_Store_getIntegerArray
      (JNIEnv* pEnv, jobject pThis, jstring pKey) {
        StoreEntry* entry = findEntry(pEnv, &gStore, pKey);
        if (isEntryValid(pEnv, entry, StoreType_IntegerArray)) {
            jintArray javaArray = pEnv->NewIntArray(entry->mLength);
            pEnv->SetIntArrayRegion(javaArray, 0, entry->mLength,
                                    entry->mValue.mIntegerArray);
            return javaArray;
        } else {
            return NULL;
        }
    }
    ...
    
  12. 为了在本地代码中保存 Java 数组,存在逆操作GetIntArrayRegion()。分配合适内存缓冲的唯一方式是使用GetArrayLength()测量数组大小:

    ...
    JNIEXPORT void JNICALL Java_com_packtpub_store_Store_setIntegerArray
      (JNIEnv* pEnv, jobject pThis, jstring pKey,
       jintArray pIntegerArray) {
        StoreEntry* entry = allocateEntry(pEnv, &gStore, pKey);
        if (entry != NULL) {
            jsize length = pEnv->GetArrayLength(pIntegerArray);
            int32_t* array = new int32_t[length];
            pEnv->GetIntArrayRegion(pIntegerArray, 0, length, array);
    
            entry->mType = StoreType_IntegerArray;
            entry->mLength = length;
            entry->mValue.mIntegerArray = array;
        }
    }
    ...
    

Java 对象数组与 Java 基本数组不同。它们是用类类型(这里,缓存的String jclass)实例化的,因为 Java 数组是单类型的。对象数组本身用jobjectArray类型表示,可以通过 JNI API 方法NewObjectArray()创建。

与基本数组不同,不可能同时处理所有元素。相反,使用SetObjectArrayElement()逐个设置对象。这里,本地数组被填充了在本地存储的String对象,这些对象保持全局引用。因此,除了对新分配字符串的引用外,这里无需删除或创建任何引用。

...
JNIEXPORT jobjectArray JNICALL
Java_com_packtpub_store_Store_getStringArray
  (JNIEnv* pEnv, jobject pThis, jstring pKey) {
    StoreEntry* entry = findEntry(pEnv, &gStore, pKey);
    if (isEntryValid(pEnv, entry, StoreType_StringArray)) {
        // An array of String in Java is in fact an array of object.
        jobjectArray javaArray = pEnv->NewObjectArray(entry->mLength,
                StringClass, NULL);
        // Creates a new Java String object for each C string stored.
        // Reference to the String can be removed right after it is
        // added to the Java array, as the latter holds a reference
        // to the String object.
        for (int32_t i = 0; i < entry->mLength; ++i) {
            jstring string = pEnv->NewStringUTF(
                    entry->mValue.mStringArray[i]);
            // Puts the new string in the array
            pEnv->SetObjectArrayElement(javaArray, i, string);
            // Do it here to avoid holding many useless local refs.
            pEnv->DeleteLocalRef(string);
        }
        return javaArray;
    } else {
        return NULL;
    }
}
...

setStringArray()方法中,通过GetObjectArrayElement()逐个获取数组元素。返回的引用是局部的,应当将其变为全局引用,以便在本地安全地存储它们。

...
JNIEXPORT void JNICALL Java_com_packtpub_store_Store_setStringArray
  (JNIEnv* pEnv, jobject pThis, jstring pKey,
   jobjectArray pStringArray) {
    // Creates a new entry with the new String array.
    StoreEntry* entry = allocateEntry(pEnv, &gStore, pKey);
    if (entry != NULL) {
        // Allocates an array of C string.
        jsize length = pEnv->GetArrayLength(pStringArray);
        char** array = new char*[length];
        // Fills the C array with a copy of each input Java string.
        for (int32_t i = 0; i < length; ++i) {
            // Gets the current Java String from the input Java array.
            // Object arrays can be accessed element by element only.
            jstring string = (jstring)
                         pEnv->GetObjectArrayElement(pStringArray, i);
            jsize stringLength = pEnv->GetStringUTFLength(string);
            array[i] = new char[stringLength + 1];
            // Directly copies the Java String into our new C buffer.
            pEnv->GetStringUTFRegion(string,0,stringLength, array[i]);
            // Append the null character for string termination.
            array[i][stringLength] = '\0';
            // No need to keep a reference to the Java string anymore.
            pEnv->DeleteLocalRef(string);
        }
        entry->mType = StoreType_StringArray;
        entry->mLength = length;
        entry->mValue.mStringArray = array;
    }
}

getColorArray()开始,对颜色执行相同的操作。由于字符串和颜色在 Java 端都是对象,所以可以使用NewObjectArray()以相同的方式创建返回的数组。

使用 JNI 方法SetObjectArrayElement()将每个保存的Color引用放置在数组内。由于颜色在本地作为全局 Java 引用存储,无需创建或删除局部引用:

...
JNIEXPORT jobjectArray JNICALL
Java_com_packtpub_store_Store_getColorArray
  (JNIEnv* pEnv, jobject pThis, jstring pKey) {
    StoreEntry* entry = findEntry(pEnv, &gStore, pKey);
    if (isEntryValid(pEnv, entry, StoreType_ColorArray)) {
        // Creates a new array with objects of type Id.
        jobjectArray javaArray = pEnv->NewObjectArray(entry->mLength,
                ColorClass, NULL);
        // Fills the array with the Color objects stored on the native
        // side, which keeps a global reference to them. So no need
        // to delete or create any reference here.
        for (int32_t i = 0; i < entry->mLength; ++i) {
            pEnv->SetObjectArrayElement(javaArray, i,
                                        entry->mValue.mColorArray[i]);
        }
        return javaArray;
    } else {
        return NULL;
    }
}
...

setColorArray()中,颜色元素也是通过GetObjectArrayElement()逐个检索的。同样,返回的引用是局部的,应该使其全局化以在本地安全存储:

...
JNIEXPORT void JNICALL Java_com_packtpub_store_Store_setColorArray
  (JNIEnv* pEnv, jobject pThis, jstring pKey,
   jobjectArray pColorArray) {
    // Saves the Color array in the store.
    StoreEntry* entry = allocateEntry(pEnv, &gStore, pKey);
    if (entry != NULL) {
        // Allocates a C array of Color objects.
        jsize length = pEnv->GetArrayLength(pColorArray);
        jobject* array = new jobject[length];
        // Fills the C array with a copy of each input Java Color.
        for (int32_t i = 0; i < length; ++i) {
            // Gets the current Color object from the input Java array.
            // Object arrays can be accessed element by element only.
            jobject localColor = pEnv->GetObjectArrayElement(
                    pColorArray, i);
            // The Java Color is going to be stored on the native side
            // Need to keep a global reference to avoid a potential
            // garbage collection after method returns.
            array[i] = pEnv->NewGlobalRef(localColor);
            // We have a global reference to the Color, so we can now
            // get rid of the local one.
            pEnv->DeleteLocalRef(localColor);
        }
        entry->mType = StoreType_ColorArray;
        entry->mLength = length;
        entry->mValue.mColorArray = array;
    }
}

刚才发生了什么?

我们从 Java 传输数组到本地侧,反之亦然。Java 数组是只能通过专用的 JNI API 操作的 Java 对象。它们不能被转换为原生的 C/C++数组,也不能以同样的方式使用。

我们还了解了如何利用JNI_OnLoad()回调来缓存 JNI 类描述符。类描述符,类型为jclass(在幕后也是jobject),相当于 Java 中的Class<?>。它们允许我们定义我们想要的数组类型,有点像 Java 中的反射 API。我们将在下一章回到这个主题。

原始数组

可用的原始数组类型有jbooleanArrayjbyteArrayjcharArrayjdoubleArrayjfloatArrayjlongArrayjshortArray。这些类型表示对真实 Java 数组的引用。

这些数组可以使用 JNI 提供的多种方法进行操作:

  1. 使用New<Primitive>Array()创建新的 Java 数组:

    jintArray NewIntArray(jsize length)
    
  2. GetArrayLength()检索数组的长度:

    jsize GetArrayLength(jarray array)
    
  3. Get<Primitive>ArrayElements()将整个数组检索到由 JNI 分配的内存缓冲区中。最后一个参数isCopy,如果不为空,表示 JNI 是否内部复制了数组,或者返回的缓冲区指针指向实际的 Java 字符串内存:

    jint* GetIntArrayElements(jintArray array, jboolean* isCopy)
    
  4. Release<Primitive>ArrayElements()释放由Get<Primitive>ArrayElements()分配的内存缓冲区。总是成对使用。最后一个参数模式与isCopy参数相关,表示以下内容:

    • 如果设置为 0,那么 JNI 应该将修改后的数组复制回初始的 Java 数组,并告诉 JNI 释放其临时内存缓冲区。这是最常见的标志。

    • 如果设置JNI_COMMIT,那么 JNI 应该将修改后的数组复制回初始数组,但不释放内存。这样,客户端代码在将结果传回 Java 的同时,仍可以在内存缓冲区中继续处理。

    • 如果设置JNI_ABORT,那么 JNI 必须丢弃内存缓冲区中进行的任何更改,并保持 Java 数组不变。如果临时本地内存缓冲区不是副本,这将无法正确工作。

      void ReleaseIntArrayElements(jintArray array, jint* elems, jint mode)
      
  5. Get<Primitive>ArrayRegion()将数组的全部或部分内容检索到由客户端代码分配的内存缓冲区中。例如,对于整数:

    void GetIntArrayRegion(jintArray array, jsize start, jsize len,
                           jint* buf)
    
  6. Set<Primitive>ArrayRegion()从由客户端代码管理的本地缓冲区初始化 Java 数组的全部或部分内容。例如,对于整数:

    void SetIntArrayRegion(jintArray array, jsize start, jsize len,
                           const jint* buf)
    
  7. Get<Primitive>ArrayCritical()Release<Primitive>ArrayCritical()Get<Primitive>ArrayElements()Release<Primitive>ArrayElements()相似,但仅供直接访问目标数组(而不是副本)使用。作为交换,调用者不得执行阻塞或 JNI 调用,并且不应长时间持有数组(如线程的关键部分)。同样,所有基本类型都提供这两个方法:

    void* GetPrimitiveArrayCritical(jarray array, jboolean* isCopy)
    void ReleasePrimitiveArrayCritical(jarray array, void* carray, jint mode)
    

尝试英雄——处理其他数组类型

利用新获得的知识,你可以为其他数组类型实现存储方法:jbooleanArrayjbyteArrayjcharArrayjdoubleArrayjfloatArrayjlongArrayjshortArray

例如,你可以使用GetBooleanArrayElements()ReleaseBooleanArrayElements()而不是GetBooleanArrayRegion(),为jbooleanArray类型编写setBooleanArray()方法。结果应该如下所示,两种方法与memcpy()配对调用:

...
JNIEXPORT void JNICALL Java_com_packtpub_store_Store_setBooleanArray
  (JNIEnv* pEnv, jobject pThis, jstring pKey,
   jbooleanArray pBooleanArray) {
    // Finds/creates an entry in the store and fills its content.
    StoreEntry* entry = allocateEntry(pEnv, &gStore, pKey);
    if (entry != NULL) {
        entry->mType = StoreType_BooleanArray;
        jsize length = pEnv->GetArrayLength(pBooleanArray);
        uint8_t* array = new uint8_t[length];
        // Retrieves array content.
 jboolean* arrayTmp = pEnv->GetBooleanArrayElements(
 pBooleanArray, NULL);
        memcpy(array, arrayTmp, length * sizeof(uint8_t));
        pEnv->ReleaseBooleanArrayElements(pBooleanArray, arrayTmp, 0);
        entry->mType = StoreType_BooleanArray;
        entry->mValue.mBooleanArray = array;
        entry->mLength = length;
    }
}
...

注意

最终的项目以Store_Part8_Full的名字随本书提供。

对象数组

在 JNI 中,对象数组被称为jobjectArray,代表对 Java 对象数组的引用。对象数组是特殊的,因为与基本数组不同,每个数组元素都是对对象的引用。因此,每次在数组中插入对象时,都会自动注册一个新的全局引用。这样,本地调用结束时,引用就不会被垃圾回收。注意,对象数组不能像基本类型那样转换为“本地”数组。

对象数组可以使用 JNI 提供的几种方法进行操作:

  1. NewObjectArray()创建一个新的对象数组实例:

    jobjectArray NewObjectArray(jsize length, jclass elementClass, jobject initialElement);
    
  2. GetArrayLength()检索数组的长度(与基本类型相同的方法):

    jsize GetArrayLength(jarray array)
    
  3. GetObjectArrayElement()从 Java 数组中检索单个对象引用。返回的引用是局部的:

    jobject GetObjectArrayElement(jobjectArray array, jsize index)
    
  4. SetObjectArrayElement()将单个对象引用放入 Java 数组中。隐式创建全局引用:

    void SetObjectArrayElement(jobjectArray array, jsize index, jobject value)
    

有关 JNI 功能的更详尽列表,请参见docs.oracle.com/javase/6/docs/technotes/guides/jni/spec/functions.html

引发和检查 Java 异常

在 Store 项目中处理错误并不令人满意。如果找不到请求的键,或者检索到的值类型与请求的类型不匹配,将返回默认值。不要尝试使用 Color 条目。我们确实需要一种方法来指示发生了错误!还有什么比异常更好的错误指示方法呢?

JNI 提供了必要的 API,在 JVM 级别抛出异常。这些异常是你在 Java 中可以捕获的异常。它们与你在其他程序中可以找到的常规 C++异常(我们将在第九章,将现有库移植到 Android中进一步了解)在语法和流程上没有任何共同之处。

在这一部分,我们将了解如何从本地代码抛出 JNI 异常到 Java 端。

注意

本书提供的项目成果名为Store_Part9

动手实践时间——在本地存储中抛出和捕获异常

  1. 按照以下方式创建类型为Exception的 Java 异常com.packtpub.exception.InvalidTypeException

    package com.packtpub.exception;
    
    public class InvalidTypeException extends Exception {
        public InvalidTypeException(String pDetailMessage) {
            super(pDetailMessage);
        }
    }
    

    对另外两个异常重复该操作:类型为ExceptionNotExistingKeyException和类型为RuntimeExceptionStoreFullException

  2. 打开Store.java文件,并在Store类中的getInteger()方法上声明抛出的异常(StoreFullExceptionRuntimeException,不需要声明):

    public class Store {
        ...
        public native int getInteger(String pKey)
     throws NotExistingKeyException, InvalidTypeException;
        public native void setInteger(String pKey, int pInt);
        ...
    

    对所有其他 getter 方法的原型(字符串、颜色等)重复该操作。

  3. 这些异常需要被捕获。在onGetValue()中捕获NotExistingKeyExceptionInvalidTypeException

    public class StoreActivity extends Activity {
        ...
        public static class PlaceholderFragment extends Fragment {
            ...
            private void onGetValue() {
                ...
                try {
                    switch (type) {
                    ...
                }
     // Process any exception raised while retrieving data.
     catch (NotExistingKeyException eNotExistingKeyException) {
     displayMessage(eNotExistingKeyException.getMessage());
     } catch (InvalidTypeException eInvalidTypeException) {
     displayMessage(eInvalidTypeException.getMessage());
                }
            }
    
  4. onSetValue()方法中捕获StoreFullException,以防因为存储空间耗尽导致条目无法插入:

            private void onSetValue() {
                ...
                try {
                    ...
                } catch (NumberFormatException eNumberFormatException) {
                    displayMessage("Incorrect value.");
     } catch (StoreFullException eStoreFullException) {
     displayMessage(eStoreFullException.getMessage());
                } catch (Exception eException) {
                    displayMessage("Incorrect value.");
                }
                updateTitle();
            }
            ...
        }
    }
    
  5. 打开之前部分创建的jni/Store.h文件,并定义三个新的辅助方法来抛出异常:

    ...
    void throwInvalidTypeException(JNIEnv* pEnv);
    
    void throwNotExistingKeyException(JNIEnv* pEnv);
    
    void throwStoreFullException(JNIEnv* pEnv);
    #endif
    
  6. 编辑jni/Store.cpp文件,当从存储中获取不适当的条目时抛出NotExistingKeyExceptionInvalidTypeException。在用isEntryValid()检查条目时抛出它们是一个好地方:

    ...
    bool isEntryValid(JNIEnv* pEnv, StoreEntry* pEntry, StoreType pType) {
        if (pEntry == NULL) {
            throwNotExistingKeyException(pEnv);
        } else if (pEntry->mType != pType) {
            throwInvalidTypeException(pEnv);
        }
        return !pEnv->ExceptionCheck();
    }
    ...
    
  7. StoreFullException显然是在插入新条目时抛出的。修改同一文件中的allocateEntry(),以检查条目插入:

    ...
    StoreEntry* allocateEntry(JNIEnv* pEnv, Store* pStore, jstring pKey) {
        // If entry already exists in the store, releases its content
        // and keep its key.
        StoreEntry* entry = findEntry(pEnv, pStore, pKey);
        if (entry != NULL) {
            releaseEntryValue(pEnv, entry);
        }
        // If entry does not exist, create a new entry
        // right after the entries already stored.
        else {
            // Checks store can accept a new entry.
     if (pStore->mLength >= STORE_MAX_CAPACITY) {
     throwStoreFullException(pEnv);
     return NULL;
            }
            entry = pStore->mEntries + pStore->mLength;
            // Copies the new key into its final C string buffer.
            ...
        }
        return entry;
    }
    ...
    

实现throwNotExistingException()。为了抛出一个 Java 异常,首先需要找到对应的类(就像使用 Java 反射 API 一样)。由于我们可以假设这些异常不会被频繁抛出,我们可以不缓存类引用。然后,使用ThrowNew()抛出异常。一旦我们不再需要异常类引用,可以使用DeleteLocalRef()来释放它。

...
void throwNotExistingKeyException(JNIEnv* pEnv) {
    jclass clazz = pEnv->FindClass(
                    "com/packtpub/exception/NotExistingKeyException");
    if (clazz != NULL) {
        pEnv->ThrowNew(clazz, "Key does not exist.");
    }
    pEnv->DeleteLocalRef(clazz);
}

对另外两个异常重复该操作。代码是相同的(即使是抛出一个运行时异常),只有类名会改变。

刚才发生了什么?

启动应用程序,尝试获取一个不存在的键的条目。重复该操作,但这次是存储中存在的条目,但其类型与 GUI 中选择的类型不同。在这两种情况下,都会出现错误信息。尝试在存储中保存超过 16 个引用,你将再次得到错误。在每种情况下,都在本地端抛出了异常,并在 Java 端捕获。

在本地代码中引发异常并不是一个复杂的任务,但也不是微不足道的。异常使用类型为jclass的类描述符实例化。JNI 需要这个类描述符来实例化适当的异常类型。JNI 异常与 JNI 方法原型中未声明,因为它们与 C++异常无关(C 中无法声明的异常)。这就解释了为什么我们没有重新生成 JNI 头文件以适应Store.java文件中的更改。

在异常状态下执行代码

一旦引发异常,你在使用 JNI 调用时需要非常小心。实际上,在此之后的任何后续调用都会失败,直到发生以下任一事件:

  1. 方法返回,并传播一个异常。

  2. 异常被清除。清除异常意味着该异常已被处理,因此不会传播到 Java。例如:

    // Raise an exception
    jclass clazz = pEnv->FindClass("java/lang/RuntimeException");
    if (clazz != NULL) {
      pEnv->ThrowNew(clazz, "Oups an exception.");
    }
    pEnv->DeleteLocalRef(clazz);
    
    ...
    
    // Detect and catch the exception by clearing it.
    jthrowable exception = pEnv->ExceptionOccurred();
    if (exception) {
      // Do something...
      pEnv->ExceptionDescribe();
      pEnv->ExceptionClear();
      pEnv->DeleteLocalRef(exception);
    }
    

在引发异常后,仍然可以安全调用少数几个 JNI 方法:

DeleteGlobalRef PopLocalFrame
DeleteLocalRef PushLocalFrame
DeleteWeakGlobalRef Release<Primitive>ArrayElements
ExceptionCheck ReleasePrimitiveArrayCritical
ExceptionClear ReleaseStringChars
ExceptionDescribe ReleaseStringCritical
ExceptionOccurred ReleaseStringUTFChars
MonitorExit

不要尝试调用其他 JNI 方法。本地代码应尽快清理其资源并将控制权交还给 Java(或者自行处理异常)。实际上,JNI 异常与 C++异常没有任何共同之处。它们的执行流程完全不同。当从本地代码引发 Java 异常时,后者可以继续其处理。但是,一旦本地调用返回并将控制权交还给 Java VM,后者就会像往常一样传播异常。换句话说,从本地代码引发的 JNI 异常只影响 Java 代码(以及之前未列出的其他 JNI 调用)。

异常处理 API

JNI 提供了几种用于管理异常的方法,其中包括:

  1. 使用ThrowNew()来引发异常本身,分配一个新的实例:

    jint ThrowNew(jclass clazz, const char* message)
    
  2. 使用Throw()来引发已经分配的异常(例如,重新抛出):

    jint Throw(jthrowable obj)
    
  3. 使用ExceptionCheck()来检查是否有待处理的异常,无论是由谁引发的(本地代码还是 Java 回调)。返回一个简单的jboolean,这使得它适合进行简单的检查:

    jboolean ExceptionCheck()
    
  4. 使用ExceptionOccurred()获取引发异常的jthrowable引用:

    jthrowable ExceptionOccurred()
    
  5. ExceptionDescribe()相当于 Java 中的printStackTrace()

    void ExceptionDescribe()
    
  6. 使用ExceptionClear()可以在本地端将异常标记为已捕获:

    void ExceptionClear()
    

学会如何使用这些方法来编写健壮的代码至关重要,特别是在从本地代码回调 Java 时。我们将在下一章中更深入地学习这个主题。

总结

在本章中,我们了解了如何让 Java 与 C/C++进行通信。现在 Android 几乎可以说双语了!Java 可以使用任何类型的数据或对象调用 C/C++代码。

我们首先使用 JNI_OnLoad 钩子初始化了一个原生的 JNI 库。然后,在原生代码内部转换 Java 字符串,并了解了修改后的 UTF-8 与 UTF-16 字符编码之间的区别。我们还传递了 Java 基本类型到原生代码。这些基本类型每个都有它们可以转换为的 C/C++ 等效类型。

我们还在原生代码中使用全局引用处理了 Java 对象引用,并学习了全局引用与局部引用之间的区别。前者必须谨慎删除以确保适当的垃圾回收,而后者的作用域为原生方法,并且由于默认数量有限,也必须小心管理。

我们还讨论了如何在原生代码中管理 Java 数组,以便我们可以像操作原生数组一样访问它们的内容。在原生代码中操作数组时,虚拟机可能会也可能不会复制数组。这个性能开销必须考虑在内。

最后,我们在原生代码中抛出并检查了 Java 异常。我们了解到它们的标准 C++ 异常流程是不同的。当异常发生时,只有少数几个清理的 JNI 方法是安全的调用。JNI 异常是 JVM 级别的异常,这意味着它们的流程与标准 C++ 异常完全不同。

然而,还有更多内容等待我们去探索。任何 Java 对象、方法或字段都可以被原生代码调用或检索。让我们在下一章中看看如何从 C/C++ 代码中调用 Java。

第四章:从本地代码回调 Java

为了发挥其最大潜力,JNI 允许从 C/C++ 回调 Java 代码。"回调"是因为本地代码首先从 Java 被调用,然后反过来调用 Java。这种调用是通过反射 API 完成的,几乎可以做任何直接在 Java 中能做的事情。

在使用 JNI 时需要考虑的另一个重要问题是线程。本地代码可以在由 Dalvik VM 管理的 Java 线程上运行,也可以从使用标准 POSIX 原语创建的本机线程上运行。显然,除非将本地线程转换为管理的 Java 线程,否则本地线程不能调用 JNI 代码!使用 JNI 编程需要了解所有这些细微之处。本章将引导你了解主要的几个问题。

最后一个主题是特定于 Android 而不是 JNI 的:特定的 Android 位图 API 旨在为运行在这些小型(但强大)设备上的图形应用程序提供完全的处理能力。

Android NDK 还提供了一个新的 API,以本地方式访问一种重要的对象类型:位图。特定的 Bitmap API,它是 Android 独有的,为运行在这些小型(但强大)设备上的图形应用程序提供了完全的处理能力。

我们在上一章中开始的 Store 项目将作为展示 JNI 回调和同步的画布。为了说明位图处理,我们将创建一个新项目,在本地代码中解码设备的摄像头馈送。

总结一下,在本章中,我们将学习如何:

  • 从本地代码调用 Java

  • 将本地线程附加到 Dalvik VM,并与 Java 线程处理同步

  • 在本地代码中处理 Java 位图

在本章结束时,你应该能够使 Java 和 C/C++ 互相通信和同步。

从本地代码回调 Java

在上一章中,我们了解了如何使用 JNI 方法 FindClass() 获取 Java 类描述符。然而,我们还可以获得更多!实际上,如果你是一个常规的 Java 开发者,这应该会让你想起一些东西:Java 反射 API。JNI 与其类似,它可以修改 Java 对象字段,运行 Java 方法,以及访问静态成员,但这一切都来自本地代码!

Store 项目的最后一部分,让我们增强我们的商店应用程序,使其在成功插入条目时通知 Java。

注意

本书提供的最终项目名为 Store_Part10

动手实践时间——确定 JNI 方法签名

让我们先定义一个 Java 接口,本地 C/C++ 代码将通过 JNI 调用这个接口:

  1. 创建一个 StoreListener.java,其中包含一个定义几个回调的接口,一个用于整数,一个用于字符串,一个用于颜色,如下所示:

    package com.packtpub.store;
    
    public interface StoreListener {
        void onSuccess(int pValue);
    
        void onSuccess(String pValue);
    
        void onSuccess(Color pValue);
    }
    
  2. 打开 Store.java 并进行一些更改。

    • 声明一个成员委托 StoreListener,成功回调将被发送给它

    • 更改 Store 构造函数以注入委托监听器,这将是 StoreActivity

      Public class Store implements StoreListener {
       private StoreListener mListener;
          public Store(StoreListener pListener) {
              mListener = pListener;
          }
          ...
      

      最后,实现StoreListener接口及其相应的方法,这些方法只是将调用转发给委托:

          ...
       public void onSuccess(int pValue) {
       mListener.onSuccess(pValue);
       }
      
       public void onSuccess(String pValue) {
       mListener.onSuccess(pValue);
       }
      
       public void onSuccess(Color pValue) {
       mListener.onSuccess(pValue);
          }
      }
      
  3. 打开StoreActivity.java并在PlaceholderFragment中实现StoreListener接口。

    同时,相应地更改Store构造:

    public class StoreActivity extends Activity {
        ...
        public static class PlaceholderFragment extends Fragment
     implements StoreListener {
     private Store mStore = new Store(this);
            ...
    

    当接收到成功回调时,会弹出一个简单的提示消息:

            ...
     public void onSuccess(int pValue) {
     displayMessage(String.format(
     "Integer '%1$d' successfuly saved!", pValue));
     }
    
     public void onSuccess(String pValue) {
     displayMessage(String.format(
     "String '%1$s' successfuly saved!", pValue));
     }
    
     public void onSuccess(Color pValue) {
     displayMessage(String.format(
     "Color '%1$s' successfuly saved!", pValue));
            }
        }
    }
    
  4. Store项目的目录中打开终端,并运行javap命令以确定方法签名。

    javap –s -classpath bin/classes com.packtpub.store.Store
    

    动手实践——确定 JNI 方法签名

刚才发生了什么?

使用 JNI API 回调 Java 方法需要描述符,我们将在下一部分看到。为了确定一个 Java 方法描述符,我们需要一个签名。实际上,Java 中的方法可以重载,这意味着可以有相同名称但不同参数的两个方法。这就是为什么需要签名的原因。

我们可以使用javap来确定一个方法的签名,javap是一个 JDK 实用程序,用于反汇编.class文件。然后这个签名可以传递给 JNI 反射 API。正式地说,签名是以下这样声明的:

(<Parameter 1 Type Code>[<Parameter 1 Class>];...)<Return Type Code>

例如,方法boolean myFunction(android.view.View pView, int pIndex)的签名将是(Landroid/view/View;I)Z。另一个例子,(I)V,意味着需要整数并返回 void。最后一个例子,(Ljava/lang/String;)V,意味着传递了一个 String 作为参数。

下表总结了 JNI 中可用的各种类型及其代码:

Java 类型 本地类型 本地数组类型 类型代码 数组类型代码
boolean jboolean jbooleanArray Z [Z
byte jbyte jbyteArray B [B
char jchar jcharArray C [C
double jdouble jdoubleArray D [D
float jfloat jfloatArray F [F
int jint jintArray I [I
long jlong jlongArray J [J
Short jshort jshortArray S [S
Object jobject jobjectArray L [L
String jstring N/A L [L
Class jclass N/A L [L
Throwable jthrowable N/A L [L
void void N/A V N/A

所有这些值都与javap转储的值相对应。关于描述符和签名的更多信息,请查看 Oracle 文档 docs.oracle.com/javase/specs/jvms/se7/html/jvms-4.html#jvms-4.3

既然我们已经有了正确的签名,现在可以从 C/C++中调用 Java 了。

动手实践——从本地代码回调 Java

让我们继续通过从本地代码调用我们定义的接口来构建Store

  1. com_packtpub_store_Store.cpp中,为每个回调声明类型为jmethodID的方法描述符,这将会被缓存:

    ...
    static Store gStore;
    
    static jclass StringClass;
    static jclass ColorClass;
    
    static jmethodID MethodOnSuccessInt;
    static jmethodID MethodOnSuccessString;
    static jmethodID MethodOnSuccessColor;
    ...
    
  2. 然后,在JNI_OnLoad()中缓存所有回调描述符。这可以通过两个主要步骤完成:

    使用 JNI 方法FindClass()获取类描述符。通过类的绝对包路径,可以找到类描述符,例如:com./packtpub/store/Store

    使用GetMethodID()从类描述符中获取方法描述符。为了区分几个重载方法,必须指定之前用javap获取的签名:

    ...
    JNIEXPORT jint JNI_OnLoad(JavaVM* pVM, void* reserved) {
        JNIEnv *env;
        if (pVM->GetEnv((void**) &env, JNI_VERSION_1_6) != JNI_OK) {
            abort();
        }
        ...
        // Caches methods.
     jclass StoreClass = env->FindClass("com/packtpub/store/Store");
     if (StoreClass == NULL) abort();
    
     MethodOnSuccessInt = env->GetMethodID(StoreClass, "onSuccess",
     "(I)V");
     if (MethodOnSuccessInt == NULL) abort();
    
     MethodOnSuccessString = env->GetMethodID(StoreClass, "onSuccess",
     "(Ljava/lang/String;)V");
     if (MethodOnSuccessString == NULL) abort();
    
     MethodOnSuccessColor = env->GetMethodID(StoreClass, "onSuccess",
     "(Lcom/packtpub/store/Color;)V");
     if (MethodOnSuccessColor == NULL) abort();
     env->DeleteLocalRef(StoreClass);
    
        // Store initialization.
        gStore.mLength = 0;
        return JNI_VERSION_1_6;
    }
    ...
    
  3. 当在setInteger()中成功插入整数时,通知 Java 商店(即pThis)。要调用 Java 对象上的 Java 方法,只需使用CallVoidMethod()(这意味着被调用的 Java 方法返回 void)。为此,我们需要:

    • 对象实例

    • 方法签名

    • 如果适用,传递有效的参数(这里是一个整数值)

      ...
      JNIEXPORT void JNICALL Java_com_packtpub_store_Store_setInteger
        (JNIEnv* pEnv, jobject pThis, jstring pKey, jint pInteger) {
          StoreEntry* entry = allocateEntry(pEnv, &gStore, pKey);
          if (entry != NULL) {
              entry->mType = StoreType_Integer;
              entry->mValue.mInteger = pInteger;
      
       pEnv->CallVoidMethod(pThis, MethodOnSuccessInt,
       (jint) entry->mValue.mInteger);
          }
      }
      ...
      
  4. 对字符串重复该操作。在分配返回的 Java 字符串时不需要生成全局引用,因为它在 Java 回调中立即使用。我们也可以在使用后立即销毁这个字符串的局部引用,但 JNI 在从原生回调返回时会处理这个问题:

    ...
    JNIEXPORT void JNICALL Java_com_packtpub_store_Store_setString
      (JNIEnv* pEnv, jobject pThis, jstring pKey, jstring pString) {
        // Turns the Java string into a temporary C string.
        StoreEntry* entry = allocateEntry(pEnv, &gStore, pKey);
        if (entry != NULL) {
            entry->mType = StoreType_String;
            ...
    
            pEnv->CallVoidMethod(pThis, MethodOnSuccessString,
     (jstring) pEnv->NewStringUTF(entry->mValue.mString));
        }
    }
    ...
    
  5. 最后,对颜色重复该操作:

    ...
    JNIEXPORT void JNICALL Java_com_packtpub_store_Store_setColor
      (JNIEnv* pEnv, jobject pThis, jstring pKey, jobject pColor) {
        // Save the Color reference in the store.
        StoreEntry* entry = allocateEntry(pEnv, &gStore, pKey);
        if (entry != NULL) {
            entry->mType = StoreType_Color;
            entry->mValue.mColor = pEnv->NewGlobalRef(pColor);
    
            pEnv->CallVoidMethod(pThis, MethodOnSuccessColor,
     (jstring) entry->mValue.mColor);
        }
    }
    ...
    

刚才发生了什么?

启动应用程序并插入一个整数、字符串或颜色条目。系统会显示包含插入值的成功信息。原生代码通过 JNI 反射 API 调用了 Java 端。这个 API 不仅用于执行 Java 方法,也是处理传递给原生方法的jobject参数的唯一方式。然而,从 Java 调用 C/C++代码相对简单,而从 C/C++执行 Java 操作则要复杂一些!

尽管有些重复和冗长,调用任何 Java 方法都应该像这样简单:

  • 从我们想要调用方法的类描述符中获取类描述符(这里的Store Java 对象):

    jclass StoreClass = env->FindClass("com/packtpub/store/Store");
    
  • 获取我们想要调用的回调的方法描述符(如在 Java 中的Method类)。这些方法描述符是从拥有它的类描述符中获取的(如在 Java 中的Class):

    jmethodID MethodOnSuccessInt = env->GetMethodID(StoreClass,
                                                    "onSuccess", "(I)V");
    
  • 可选地,缓存描述符以便它们可以在未来的原生调用中立即使用。同样,JNI_OnLoad()使得在执行任何原生调用之前缓存 JNI 描述符变得容易。以Id结尾的描述符,如jmethodID,可以自由缓存。它们不是可以泄漏的引用,或者相对于jclass描述符必须全局化。

    提示

    缓存描述符绝对是好的实践,因为通过 JNI 反射获取字段或方法可能会产生一些开销。

  • 在对象上使用必要的参数调用方法。相同的方法描述符可以用于相应类的任何对象实例:

    env->CallVoidMethod(pThis, MethodOnSuccessInt, (jint) myInt); 
    

无论你需要在一个 Java 对象上调用什么方法,同样的过程总是适用。

关于 JNI 反射 API 的更多内容

了解反射 API 后,你基本上就掌握了 JNI 的大部分内容。以下是一些可能有用的方法:

  • FindClass()根据其绝对路径获取(局部)引用到Class描述符对象:

    jclass FindClass(const char* name)
    
  • GetObjectClass() 的目的相同,不同之处在于 FindClass() 根据它们的绝对路径查找类定义,而另一个直接从对象实例(如 Java 中的 getClass())查找类:

    jclass GetObjectClass(jobject obj)
    
  • 以下方法允许您获取方法和字段的 JNI 描述符,以及静态或实例成员。这些描述符是 ID,而不是对 Java 对象的引用。无需将它们转换为全局引用。这些方法需要方法或字段名称以及签名以区分重载。构造函数描述符的获取方式与方法的获取方式相同,不同之处在于其名称始终为 <init> 并且具有 void 返回值:

    jmethodID GetMethodID(jclass clazz, const char* name,
                          const char* sig) 
    jmethodID GetStaticMethodID(jclass clazz, const char* name,
                                const char* sig)
    
    jfieldID GetStaticFieldID(jclass clazz, const char* name,
                              const char* sig)
    jfieldID GetFieldID(jclass clazz, const char* name, const char* sig)
    
  • 有另一组方法可以通过对应的描述符来获取字段值。每种基本类型都有一对获取器和设置器方法,以及一个用于对象的方法:

    jobject GetObjectField(jobject obj, jfieldID fieldID)
    <primitive> Get<Primitive>Field(jobject obj, jfieldID fieldID)
    
    void SetObjectField(jobject obj, jfieldID fieldID, jobject value)
    void Set<Primitive>Field(jobject obj, jfieldID fieldID,
                             <jprimitive> value)
    
  • 对于根据它们的返回值分类的方法同样如此:

    jobject CallObjectMethod(JNIEnv*, jobject, jmethodID, ...)
    
    <jprimitive> Call<Primitive>Method(JNIEnv*, jobject, jmethodID, ...);
    
  • 这些方法存在带有 AV 后缀的变体。行为相同,不同之处在于参数分别使用 va_list(即可变参数列表)或 jvalue 数组(jvalue 是所有 JNI 类型的联合体)指定:

    jobject CallObjectMethodV(JNIEnv*, jobject, jmethodID, va_list);
    jobject CallObjectMethodA(JNIEnv*, jobject, jmethodID, jvalue*);
    

请查看 Android NDK include 目录中的 jni.h 文件,以了解 JNI 反射 API 的所有可能性。

调试 JNI

JNI 调用的目标通常是性能。因此,当调用其 API 方法时,JNI 并不执行高级检查。幸运的是,存在一种扩展检查模式,它执行高级检查并在 Android Logcat 中提供反馈。

要激活它,请从命令提示符运行以下命令:

adb shell setprop debug.checkjni 1

设置此标志后,启动的应用程序可以使用扩展检查模式,直到将其设置为 0,或者直到设备重新启动。对于已获得根权限的设备,可以使用以下命令启动整个设备:

adb shell stop
adb shell setprop dalvik.vm.checkjni true
adb shell start

如果一切正常,当你的应用程序启动时,Logcat 中会出现 Late-enabling – Xcheck:jni 的消息。然后,定期检查 Logcat 以查找其 JNI 警告或错误。

调试 JNI

同步 Java 和本地线程

并行编程如今是一个主流课题。自从引入多核处理器以来,Android 也不例外。你可以在 Java 端完全进行线程处理(使用 Java Thread 和 Concurrency API),在本地端(使用 NDK 提供的 POSIX PThread API),以及更有趣的是,使用 JNI 在 Java 和本地端之间进行。

在这一部分,我们将创建一个后台线程,观察者,它始终关注数据存储中的内容。它遍历所有条目,然后休眠固定的时间。当观察者线程找到在代码中预定义的特定类型的键时,它会相应地采取行动。对于这一部分,我们只是将整数值裁剪到预定义的范围。

当然,线程需要同步。本地线程只在用户理解 UI 线程并且不修改它时访问和更新存储。本地线程在 C/C++中创建,但 UI 线程是 Java 线程。我们将使用 JNI 监视器来同步它们两者。

行动时间——使用 JNI 分配对象。

让我们定义一个后台观察者,它将使用在 Java 和 C/C++之间共享的对象作为锁:

  1. Store.java中,添加两个新方法以启动和停止观察者线程。这两个方法分别返回和接受一个long类型的参数。这个值可以帮助我们在 Java 端保存本地指针:

    public class Store implements StoreListener {
        ...
        public native long startWatcher();
     public native void stopWatcher(long pPointer);
    }
    
  2. 创建一个新文件StoreThreadSafe.javaStoreThreadSafe类继承自Store类,旨在使用synchronized Java 代码块使Store实例线程安全。声明一个类型为Object的静态成员字段LOCK并定义一个默认构造函数:

    package com.packtpub.store;
    
    import com.packtpub.exception.InvalidTypeException;
    import com.packtpub.exception.NotExistingKeyException;
    
    public class StoreThreadSafe extends Store {
        protected static Object LOCK;
    
        public StoreThreadSafe(StoreListener pListener) {
            super(pListener);
        }
        ...
    
  3. 重写Store类的方法,如getCount()getInteger()setInteger(),使用与LOCK对象同步的 Java 代码块:

        ...
        @Override
        public int getCount() {
            synchronized (LOCK) {
                return super.getCount();
            }
        }
        ...
        @Override
        public int getInteger(String pKey)
            throws NotExistingKeyException, InvalidTypeException
        {
            synchronized (LOCK) {
                return super.getInteger(pKey);
            }
        }
    
        @Override
        public void setInteger(String pKey, int pInt) {
            synchronized (LOCK) {
                super.setInteger(pKey, pInt);
            }
        }
        ...
    
  4. 对所有其他方法,如getString()setString()getColor()setColor()等,以及stopWatcher()方法执行相同的操作。不要重写onSuccess回调方法和startWatcher()方法:

        ...
        @Override
        public void stopWatcher(long pPointer) {
            synchronized (LOCK) {
                super.stopWatcher(pPointer);
            }
        }
    }
    

    不要重写onSuccess回调方法和startWatcher()方法。

  5. 打开StoreActivity.java,并用StoreThreadSafe的实例替换之前的Store实例。同时,创建一个类型为long的成员字段以保存指向观察者线程的本地指针。当片段恢复时,启动观察者线程并保存其指针。当片段暂停时,使用先前保存的指针停止观察者线程:

    public class StoreActivity extends Activity {
        ...
        public static class PlaceholderFragment extends Fragment
        implements StoreListener {
            private StoreThreadSafe mStore = new StoreThreadSafe(this);
     private long mWatcher;
            private EditText mUIKeyEdit, mUIValueEdit;
            private Spinner mUITypeSpinner;
            private Button mUIGetButton, mUISetButton;
            private Pattern mKeyPattern;
    
            ...
     @Override
     public void onResume() {
     super.onResume();
     mWatcher = mStore.startWatcher();
     }
     @Override
     public void onPause() {
     super.onPause();
     mStore.stopWatcher(mWatcher);
            }
            ...
        }
    }
    
  6. 编辑jni/Store.h并包含一个新的头文件pthread.h

    #ifndef _STORE_H_
    #define _STORE_H_
    
    #include <cstdint>
    #include <pthread.h>
    #include "jni.h"
    
  7. 观察者在定时间隔更新后的Store实例上工作。它需要:

    • 它所监视的Store结构的实例。

    • 一个JavaVM,它是线程间唯一可以安全共享的对象,并且可以从中安全获取JNIEnv

    • 用于同步的 Java 对象(对应于我们在 Java 端定义的LOCK对象)

    • 用于本地线程管理的pthread变量。

    • 停止观察者线程的指示器。

      ...
      typedef struct { 
       Store* mStore; 
       JavaVM* mJavaVM; 
       jobject mLock; 
       pthread_t mThread; 
       int32_t mRunning; 
      } StoreWatcher;
      ...
      
  8. 最后,定义三个方法以启动和停止观察者线程,运行它的主循环和处理一个条目:

    ...
    StoreWatcher* startWatcher(JavaVM* pJavaVM, Store* pStore, 
     jobject pLock); 
    void stopWatcher(StoreWatcher* pWatcher); 
    void* runWatcher(void* pArgs); 
    void processEntry(StoreEntry* pEntry);
    #endif
    
  9. 使用javah刷新 JNI 头文件jni/com_packtpub_Store.h。你应在其中看到两个新方法,Java_com_packtpub_store_Store_startWatcher()Java_com_packtpub_store_Store_stopWatcher()

    com_packtpub_store_Store.cpp中,创建一个新的静态变量gLock,它将保存 Java 同步对象。

    ...
    static Store gStore;
    static jobject gLock;
    ...
    
  10. 使用 JNI 反射 API 在JNI_OnLoad()中创建Object类的一个实例:

    • 首先,使用GetMethodID()找到它的Object构造函数。在 JNI 中,构造函数名为<init>并且没有返回结果。

    • 然后,调用构造函数以创建一个实例并将其全局化。

    • 最后,当本地引用不再有用时,移除它们:

      JNIEXPORT jint JNI_OnLoad(JavaVM* pVM, void* reserved) {
          JNIEnv *env;
          if (pVM->GetEnv((void**) &env, JNI_VERSION_1_6) != JNI_OK) {
              abort();
          }
          ...
       jclass ObjectClass = env->FindClass("java/lang/Object");
       if (ObjectClass == NULL) abort();
       jmethodID ObjectConstructor = env->GetMethodID(ObjectClass,
       "<init>", "()V");
       if (ObjectConstructor == NULL) abort();
       jobject lockTmp = env->NewObject(ObjectClass, ObjectConstructor);
       env->DeleteLocalRef(ObjectClass);
       gLock = env->NewGlobalRef(lockTmp);
       env->DeleteLocalRef(lockTmp);
          ...
      
  11. 将创建的Object实例保存在StoreThreadSafe.LOCK字段中。这个对象将在应用程序的生命周期内用于同步:

    • 首先,使用 JNI 反射方法FindClass()GetStaticFieldId()检索StoreThreadSafe类及其LOCK字段。

    • 然后,使用 JNI 方法SetStaticObjectField()将值保存到LOCK静态字段中,该方法需要字段签名(如方法)。

    • 最后,当StoreThreadSafe类不再有用时,移除对其的本地引用:

          ...
       jclass StoreThreadSafeClass = env->FindClass(
       "com/packtpub/store/StoreThreadSafe");
       if (StoreThreadSafeClass == NULL) abort();
       jfieldID lockField = env->GetStaticFieldID(StoreThreadSafeClass,
       "LOCK", "Ljava/lang/Object;");
       if (lockField == NULL) abort();
       env->SetStaticObjectField(StoreThreadSafeClass, lockField, gLock);
       env->DeleteLocalRef(StoreThreadSafeClass);
      
          return JNI_VERSION_1_6;
      }
      ...
      
  12. 实现startWatcher(),它调用之前定义的相应方法。它需要JavaVM,可以从JNIEnv对象使用GetJavaVM()获取。创建的Store的指针(即内存地址)作为一个long值返回给 Java 端,然后可以保存它以供以后使用:

    ...
    JNIEXPORT jlong JNICALL Java_com_packtpub_store_Store_startWatcher
      (JNIEnv *pEnv, jobject pThis) {
        JavaVM* javaVM;
        // Caches the VM.
        if (pEnv->GetJavaVM(&javaVM) != JNI_OK) abort();
    
        // Launches the background thread.
        StoreWatcher* watcher = startWatcher(javaVM, &gStore, gLock);
        return (jlong) watcher;
    }
    ...
    
  13. 通过实现stopWatcher()来结束,它将给定的long值转换回本地指针。将其传递给相应的方法:

    ...
    JNIEXPORT void JNICALL Java_com_packtpub_store_Store_stopWatcher
      (JNIEnv *pEnv, jobject pThis, jlong pWatcher) {
        stopWatcher((StoreWatcher*) pWatcher);
    }
    

刚才发生了什么?

我们使用 JNI 从本地代码分配一个 Java 对象,并将其保存在一个静态的 Java 字段中。这个例子展示了 JNI 反射 API 的强大功能;几乎在 Java 中可以做的任何事情,都可以通过 JNI 从本地代码完成。

为了分配 Java 对象,JNI 提供了以下方法:

  • 使用NewObject()通过指定的构造方法实例化一个 Java 对象:

    jobject NewObject(jclass clazz, jmethodID methodID, ...)
    
  • 该方法存在带有AV后缀的变体。行为相同,不同之处在于参数分别使用va_listjvalue数组指定:

    jobject NewObjectV(jclass clazz, jmethodID methodID, va_list args)
    jobject NewObjectA(jclass clazz, jmethodID methodID, jvalue* args)
    
  • AllocObject()分配一个新对象但不调用其构造函数。可能的用途是分配许多不需要初始化的对象,以获得一些性能提升。只有在你清楚自己在做什么时才使用它:

    jobject AllocObject(jclass clazz)
    

在上一章中,我们为本地存储使用了静态变量,因为其生命周期与应用程序相关联。我们希望记住值,直到应用程序退出。如果用户离开活动,稍后再回来,只要进程仍然存活,值仍然可用。

对于观察者线程,我们使用了不同的策略,因为其生命周期与活动相关联。当活动获得焦点时,创建并启动线程。当活动失去焦点时,停止并销毁线程。由于这个线程可能需要时间来停止,因此在Store示例中快速多次切换屏幕时,可能会有几个实例同时运行。

因此,使用静态变量是不安全的,因为它们可能会被并发覆盖(导致内存泄漏),或者更糟糕的是,被释放(导致内存损坏)。当活动启动另一个活动时,也可能出现这类问题。在这种情况下,第一个活动的onStop()onDestroy()在第二个活动的onCreate()onStart()之后发生,如 Android 活动生命周期所定义。

相反,处理这种情况的一个更好的解决方案是允许 Java 端管理原生内存。在我们的示例中,一个指向在原生端分配的原生结构的指针被返回给 Java 端作为一个 long 值。任何进一步的 JNI 调用必须使用此指针作为参数执行。然后,当这块数据生命周期结束时,可以将此指针还给原生端。

提示

使用 long 值(在 64 位上表示)来保存原生指针是必要的,以便与从 Android Lollipop 开始的 64 位版本 Android(具有 64 位内存地址)保持兼容。

总结一下,谨慎使用原生静态变量。如果你的变量与应用程序生命周期相关联,静态变量是可以的。如果变量与活动生命周期相关联,你应在活动中分配它们的实例,并从那里管理它们以避免问题。

现在,我们在 Java 和原生端之间有了共享锁,让我们通过实现观察线程继续我们的示例。

行动时刻——运行并同步线程

让我们使用 POSIX PThread API 创建一个原生线程并将其附加到 VM:

  1. Store.cpp 中,包含 unistd.h,它提供了访问 sleep() 函数的权限:

    #include "Store.h"
    #include <cstdlib>
    #include <cstring>
    #include <unistd.h>
    ...
    

    实现 startWatcher() 方法。该方法从 UI 线程中执行。为此,首先实例化并初始化一个 StoreWatcher 结构。

  2. 然后,使用 pthread POSIX API 初始化并启动一个原生线程:

    StoreWatcher* startWatcher(JavaVM* pJavaVM, Store* pStore,
            jobject pLock) {
        StoreWatcher* watcher = new StoreWatcher();
        watcher->mJavaVM = pJavaVM;
        watcher->mStore = pStore;
        watcher->mLock = pLock;
        watcher->mRunning = true;
    ...
    

    然后,使用 PThread POSIX API 初始化并启动一个原生线程:

    • pthread_attr_init() 初始化必要的数据结构

    • pthread_create() 启动线程

      ...
          pthread_attr_t lAttributes;
          if (pthread_attr_init(&lAttributes)) abort();
          if (pthread_create(&watcher->mThread, &lAttributes,
                                  runWatcher, watcher)) abort();
          return watcher;
      }
      ...
      
  3. 实现 stopWatcher() 方法,关闭运行指示器以请求观察线程停止:

    ...
    void stopWatcher(StoreWatcher* pWatcher) { 
        pWatcher->mRunning = false; 
    } 
    ...
    
  4. runWatcher() 中实现线程的主循环。在这里,我们不再处于 UI 线程,而是处于观察线程。

    • 因此,首先使用 AttachCurrentThreadAsDaemon() 将线程作为守护进程附加到 Dalvik VM。此操作从给定的 JavaVM 返回 JNIEnv。这使我们能从这个新线程直接访问 Java 端。记住 JNIEnv 是线程特定的,不能直接在线程间共享。

    • 然后,使这个线程循环并在每次迭代中休眠几秒钟,使用 sleep()

      ...
      void* runWatcher(void* pArgs) {
          StoreWatcher* watcher = (StoreWatcher*) pArgs;
          Store* store = watcher->mStore;
      
          JavaVM* javaVM = watcher->mJavaVM;
          JavaVMAttachArgs javaVMAttachArgs;
          javaVMAttachArgs.version = JNI_VERSION_1_6;
          javaVMAttachArgs.name = "NativeThread";
          javaVMAttachArgs.group = NULL;
      
          JNIEnv* env;
          if (javaVM->AttachCurrentThreadAsDaemon(&env,
                  &javaVMAttachArgs) != JNI_OK) abort();
          // Runs the thread loop.
          while (true) {
              sleep(5); // In seconds.
                  ...
      
  5. 在循环迭代中,使用 JNI 方法 MonitorEnter()MonitorExit() 划定一个临界区(一次只能有一个线程进入)。这些方法需要一个对象来进行同步(就像 Java 中的 synchronized 块)。

    然后,你可以安全地:

    • 检查线程是否应该停止,并在那种情况下离开循环

    • 处理来自存储的每个条目

                  ...
              // Critical section beginning, one thread at a time.
              // Entries cannot be added or modified.
              env->MonitorEnter(watcher->mLock);
              if (!watcher->mRunning) break;
              StoreEntry* entry = watcher->mStore->mEntries;
              StoreEntry* entryEnd = entry + watcher->mStore->mLength;
              while (entry < entryEnd) {
                  processEntry(entry);
                  ++entry;
              }
              // Critical section end.
              env->MonitorExit(watcher->mLock);
          }
          ...
      

    在退出之前,当线程即将结束和退出时,分离线程。始终分离已附加的线程非常重要,这样 Dalvik 或 ART VM 就不再管理它。

  6. 最后,使用 pthread_exit() API 方法终止线程:

        ...
        javaVM->DetachCurrentThread();
        delete watcher;
        pthread_exit(NULL);
    }
    ...
    
  7. 最后,编写processEntry()方法,该方法所做的不过是检查整数条目的边界,并将其限制在任意范围[-100000,100000]内。你也可以处理其他任何你希望处理的条目:

    ...
    void processEntry(StoreEntry* pEntry) {
        switch (pEntry->mType) {
        case StoreType_Integer:
            if (pEntry->mValue.mInteger > 100000) {
                pEntry->mValue.mInteger = 100000;
            } else if (pEntry->mValue.mInteger < -100000) {
                pEntry->mValue.mInteger = -100000;
            }
            break;
        }
    }
    

刚才发生了什么?

使用 Eclipse Java 调试器(不是本地调试器)以调试模式编译并运行应用程序。当应用程序启动时,会创建一个本地后台线程并将其附加到 Dalvik VM。你可以在调试视图中看到它。然后,UI 线程和本地后台线程使用 JNI 监视器 API 同步,以正确处理并发问题。最后,当离开应用程序时,后台线程会被分离并销毁。因此,它从调试视图中消失:

刚才发生了什么?

现在,在您的 Android 设备上的Store接口中,定义一个键并输入一个大于100,000的整数值。等待几秒钟,然后使用相同的键检索该值。它应该会被观察者线程限制在100,000以内。这个观察者会检查存储中的每个值,并在需要时进行更改。

观察者运行在一个本地线程上(即不是由 Java 虚拟机直接创建的)。NDK 允许使用 PThread POSIX API 创建本地线程。这个 API 是一个在 Unix 系统上广泛用于多线程的标准。它定义了一系列以pthread_为前缀的函数和数据结构,不仅可以创建线程,还可以创建互斥锁(互斥的缩写)或条件变量(让一个线程等待特定条件)。

PThread API 本身就是一个完整的主题,超出了本书的范围。你需要了解它才能掌握 Android 上的本地多线程。有关此主题的更多信息,请查看computing.llnl.gov/tutorials/pthreads/randu.org/tutorials/threads/

使用 JNI 监视器同步 Java 和 C/C++

在 Java 端,我们使用带有任意锁对象的synchronized块来同步线程。Java 还允许方法(无论是否为本地方法)被声明为synchronized。在这种情况下,锁对象是隐式地定义为本地方法的对象。例如,我们可以如下定义一个本地方法:

public class MyNativeClass {
 public native synchronized int doSomething();
    ...
}

在我们的情况下,这本来是无法工作的,因为本地端有一个单一的静态存储实例。我们需要一个单一的静态锁对象实例。

注意

请注意,这里使用的模式,即让StoreThreadSafe继承自Store类,覆盖其方法并使用静态变量,不应特别认为是最佳实践。由于Storelock对象是静态的,本书为了简单起见使用了这种方式。

在本地端,使用 JNI 监视器进行同步,这相当于 Java 中的synchronized关键字:

  • MonitorEnter()表示临界区的开始。监视器与一个对象关联,该对象可以被视为一种标识符。一次只能有一个线程进入由这个对象定义的区间:

    jint MonitorEnter(jobject obj)
    
  • MonitorExit()表示临界区的结束。必须调用它,以及MonitorEnter(),以确保监视器被释放,其他线程可以继续执行:

    jint MonitorExit(jobject obj)
    

因为 Java 线程在内部是基于 POSIX 原始操作,所以也可以完全本地实现线程同步,使用 POSIX API。你可以在这个链接找到更多信息:computing.llnl.gov/tutorials/pthreads/

提示

Java 和 C/C++是具有相似但略有不同语义的不同语言。因此,始终注意不要期望 C/C++的行为像 Java。例如,volatile 在 Java 和 C/C++中的语义是不同的,因为它们遵循不同的内存模型。

附着和分离本地线程

默认情况下,Dalvik VM 不知道在同一进程中运行的本地线程。作为回报,本地线程也无法访问 VM...除非它附着到 VM。在 JNI 中,以下方法处理附着:

  • 使用AttachCurrentThread()告诉虚拟机管理当前线程。一旦附着,当前线程的JNIEnv指针将在指定位置返回:

    jint AttachCurrentThread(JNIEnv** p_env, void* thr_args)
    
  • 使用AttachCurrentThreadAsDaemon()将线程作为守护线程附着。Java 规范定义了 JVM 在退出前不必等待守护线程结束,与普通线程相反。在 Android 上,这种区别没有实际意义,因为应用程序可以在任何时候被系统杀死:

    jint AttachCurrentThreadAsDaemon(JNIEnv** p_env, void* thr_args)
    
  • DetachCurrentThread()表示线程不再需要被管理。像 Watcher 线程这样的已附着线程在退出前必须最终被分离。Dalvik 会检测未分离的线程,并通过终止并在日志中留下不干净的崩溃转储来做出反应!在分离时,持有的任何监视器都会被释放,任何等待的线程都会被通知:

    jint DetachCurrentThread()
    

    提示

    自从 Android 2.0 起,确保线程被系统分离的一种技术是使用pthread_key_create()将析构函数回调绑定到本地线程,并在其中调用DetachCurrentThread()。可以使用pthread_setspecific()JNIEnv实例保存到线程本地存储中,以便将其作为参数传递给析构函数。

线程附着后,ClassLoader JNI 会使用 Java 类来对应调用堆栈上找到的第一个对象。对于纯本地线程,可能找不到ClassLoader。在这种情况下,JNI 使用系统ClassLoader,它可能无法找到你自己的应用程序类,也就是说,FindClass()失败。在这种情况下,可以在JNI_OnLoad()中全局缓存必要的 JNI 元素,或者与需要线程共享应用程序类加载器。

本地处理位图

Android NDK 提供了一个专门用于位图处理的 API,可以直接访问 Android 位图的表面。这个 API 是特定于 Android 的,与 JNI 规范无关。然而,位图是 Java 对象,在本地代码中需要作为对象处理。

为了更具体地了解位图如何从本地代码中修改,让我们尝试从本地代码解码一个摄像头馈送。在 Android 上记录的原始视频帧通常以特定的格式编码,即YUV,这与传统的 RGB 图像不兼容。在这种情况下,本地代码可以提供帮助,帮助我们解码这些图像。在以下示例中,我们将把每个颜色组件(即红、绿和蓝)提取到单独的位图中。

注意

本书提供的结果项目名为LiveCamera

动手操作时间——解码摄像头的馈送

让我们在一个全新的项目中编写必要的 Java 代码以记录和显示图片:

  1. 按照第二章 开始一个本地 Android 项目所示,创建一个新的混合 Java/C++项目:

    • 命名为LiveCamera

    • 主包是com.packtpub.livecamera

    • 主要活动是LiveCameraActivity

    • 主活动布局名为activity_livecamera

    • 使用空白活动模板

  2. 创建后,将项目转换为已知的本地项目。在AndroidManifest.xml文件中,请求访问摄像头的权限。然后,将活动样式设置为fullscreen,并将其方向设置为landscape。横屏方向避免了在 Android 设备上遇到的多数摄像头方向问题:

    <?xml version="1.0" encoding="utf-8"?>
    <manifest 
      package="com.packtpub.livecamera"
      android:versionCode="1" android:versionName="1.0" >
      <uses-sdk android:minSdkVersion="14" android:targetSdkVersion="19"/>
     <uses-permission android:name="android.permission.CAMERA" />
      <application
        android:allowBackup="false"
        android:icon="@drawable/ic_launcher"
        android:label="@string/app_name" >
        <activity
          android:name=".LiveCameraActivity"
          android:label="@string/app_name"
          android:screenOrientation="landscape"
     android:theme="@android:style/Theme.NoTitleBar.Fullscreen" >
          <intent-filter>
            <action android:name="android.intent.action.MAIN" />
            <category android:name="android.intent.category.LAUNCHER" />
          </intent-filter>
        </activity>
      </application>
    </manifest>
    
  3. 按以下方式定义activity_livecamera.xml布局。它表示一个包含一个TextureView和三个ImageView元素的 2x2 网格:

    <?xml version="1.0" encoding="utf-8"?>
    <LinearLayout 
    
      a:baselineAligned="true" a:orientation="horizontal"
      a:layout_width="fill_parent" a:layout_height="fill_parent" >
      <LinearLayout
        a:layout_width="fill_parent" a:layout_height="fill_parent"
        a:layout_weight="1" a:orientation="vertical" >
        <TextureView
          a:id="@+id/preview" a:layout_weight="1"
          a:layout_width="fill_parent" a:layout_height="fill_parent" />
        <ImageView
          a:id="@+id/imageViewR" a:layout_weight="1"
          a:layout_width="fill_parent" a:layout_height="fill_parent" />
      </LinearLayout>
      <LinearLayout
        a:layout_width="fill_parent" a:layout_height="fill_parent"
        a:layout_weight="1" a:orientation="vertical" >
        <ImageView
          a:id="@+id/imageViewG" a:layout_weight="1"
          a:layout_width="fill_parent" a:layout_height="fill_parent" />
        <ImageView
          a:id="@+id/imageViewB" a:layout_weight="1"
          a:layout_width="fill_parent" a:layout_height="fill_parent" />
      </LinearLayout>
    </LinearLayout>
    
  4. 打开LiveCameraActivity.java文件,并按以下方式实现:

    • 首先,扩展SurfaceTextureListener,这将帮助我们初始化和关闭摄像头馈送

    • 然后,扩展PreviewCallback接口以监听新的摄像头帧

    不要忘记按以下方式加载本地静态库:

    package com.packtpub.livecamera;
    ...
    public class LiveCameraActivity extends Activity implements
    TextureView.SurfaceTextureListener, Camera.PreviewCallback {
        static {
            System.loadLibrary("livecamera");
        }
        ...
    
  5. 创建一些成员变量:

    • mCamera是 Android 摄像头 API

    • mTextureView显示原始摄像头馈送

    • mVideoSource将摄像头帧捕获到字节缓冲区

    • mImageViewRGB显示处理过的图像,每个颜色组件一个

    • mImageRG和 BImageView`的位图支持(即“后台缓冲区”)

          ...
          private Camera mCamera;
          private TextureView mTextureView;
          private byte[] mVideoSource;
          private ImageView mImageViewR, mImageViewG, mImageViewB;
          private Bitmap mImageR, mImageG, mImageB;
          ...
      

    onCreate()中,指定在前一步中定义的布局。

    然后,获取要显示图像的视图。

  6. 最后,使用setSurfaceTextureListener()监听TextureView事件。你可以忽略在这个例子中不必要的回调:

        ...
        @Override
        protected void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            setContentView(R.layout.activity_livecamera);
            mTextureView = (TextureView) findViewById(R.id.preview);
            mImageViewR = ((ImageView)findViewById(R.id.imageViewR));
            mImageViewG = ((ImageView)findViewById(R.id.imageViewG));
            mImageViewB = ((ImageView)findViewById(R.id.imageViewB));
    
            mTextureView.setSurfaceTextureListener(this);
        }
        @Override
        public void onSurfaceTextureSizeChanged(SurfaceTexture pSurface,
           int pWidth, int pHeight) {}
    
        @Override
        public void onSurfaceTextureUpdated(SurfaceTexture pSurface) {}
        ...
    
  7. LiveCameraActivity.java中的onSurfaceTextureAvailable()回调在创建TextureView表面后被触发。在这里可以知道表面尺寸和像素格式。

    因此,打开 Android 相机并将TextureView设置为它的预览目标。使用setPreviewCallbackWithBuffer()监听新的相机帧:

        ...
        @Override
        public void onSurfaceTextureAvailable(SurfaceTexture pSurface,
                                              int pWidth, int pHeight) {
            mCamera = Camera.open();
            try {
                mCamera.setPreviewTexture(pSurface);
                mCamera.setPreviewCallbackWithBuffer(this);
                // Sets landscape mode to avoid complications related to
                // screen orientation handling.
                mCamera.setDisplayOrientation(0);
                ...
    
  8. 然后,调用findBestResolution(),我们将在下一节实现它以找到适合相机馈送的合适分辨率。相应地设置后者为YCbCr_420_SP格式(这应该是 Android 上的默认格式)。

                ...
                Size size = findBestResolution(pWidth, pHeight);
                PixelFormat pixelFormat = new PixelFormat();
                PixelFormat.getPixelFormatInfo(mCamera.getParameters()
                                .getPreviewFormat(), pixelFormat);
                int sourceSize = size.width * size.height
                                * pixelFormat.bitsPerPixel / 8;
                // Set-up camera size and video format.
                // should be the default on Android anyway.
                Camera.Parameters parameters = mCamera.getParameters();
                parameters.setPreviewSize(size.width, size.height);
                parameters.setPreviewFormat(PixelFormat.YCbCr_420_SP);
                mCamera.setParameters(parameters);
                ...
    
  9. 之后,设置视频缓冲区和显示相机帧的位图:

                ...
                mVideoSource = new byte[sourceSize];
                mImageR = Bitmap.createBitmap(size.width, size.height,
                                              Bitmap.Config.ARGB_8888);
                mImageG = Bitmap.createBitmap(size.width, size.height,
                                              Bitmap.Config.ARGB_8888);
                mImageB = Bitmap.createBitmap(size.width, size.height,
                                              Bitmap.Config.ARGB_8888);
                mImageViewR.setImageBitmap(mImageR);
                mImageViewG.setImageBitmap(mImageG);
                mImageViewB.setImageBitmap(mImageB);
                ...
    

    最后,将视频帧缓冲区入队并开始相机预览:

                ...
                mCamera.addCallbackBuffer(mVideoSource);
                mCamera.startPreview();
            } catch (IOException ioe) {
                mCamera.release();
                mCamera = null;
                throw new IllegalStateException();
            }
        }
        ...
    
  10. 仍然在LiveCameraActivity.java中,实现findBestResolution()。Android 相机可以支持多种分辨率,这些分辨率高度依赖于设备。由于没有规定默认分辨率应该是什么,我们需要寻找一个合适的分辨率。在这里,我们选择适合显示表面的最大分辨率,或者如果没有找到,则选择默认分辨率。

        ...
        private Size findBestResolution(int pWidth, int pHeight) {
            List<Size> sizes = mCamera.getParameters()
                            .getSupportedPreviewSizes();
            // Finds the biggest resolution which fits the screen.
            // Else, returns the first resolution found.
            Size selectedSize = mCamera.new Size(0, 0);
            for (Size size : sizes) {
                if ((size.width <= pWidth)
                 && (size.height <= pHeight)
                 && (size.width >= selectedSize.width)
                 && (size.height >= selectedSize.height)) {
                    selectedSize = size;
                }
            }
            // Previous code assume that there is a preview size smaller
            // than screen size. If not, hopefully the Android API
            // guarantees that at least one preview size is available.
            if ((selectedSize.width == 0) || (selectedSize.height == 0)) {
                selectedSize = sizes.get(0);
            }
            return selectedSize;
        }
    ...
    
  11. TextureView表面在onSurfaceTextureDestroyed()中被销毁时,释放相机,因为这是一个共享资源。位图缓冲区也可以被回收和置空,以减轻垃圾收集器的工作。

    ...
        @Override
        public boolean onSurfaceTextureDestroyed(SurfaceTexture pSurface)
        {
            // Releases camera which is a shared resource.
            if (mCamera != null) {
                mCamera.stopPreview();
                mCamera.release();
                // These variables can take a lot of memory. Get rid of
                // them as fast as we can.
                mCamera = null;
                mVideoSource = null;
                mImageR.recycle(); mImageR = null;
                mImageG.recycle(); mImageG = null;
                mImageB.recycle(); mImageB = null;
            }
            return true;
        }
    ...
    
  12. 最后,在onPreviewFrame()中解码原始视频帧。每次有新帧准备好时,由Camera类触发此处理程序。

    原始视频字节传递给本地方法decode(),以及支持的位图,并选择每个颜色分量的过滤器。

    解码完成后,使表面无效以重新绘制它。

    最后,"重新入队"原始视频缓冲区以请求捕获新的视频帧。

    ...
        @Override
        public void onPreviewFrame(byte[] pData, Camera pCamera) {
            // New data has been received from camera. Processes it and
            // requests surface to be redrawn right after.
            if (mCamera != null) {
                decode(mImageR, pData, 0xFFFF0000);
                decode(mImageG, pData, 0xFF00FF00);
                decode(mImageB, pData, 0xFF0000FF);
                mImageViewR.invalidate();
                mImageViewG.invalidate();
                mImageViewB.invalidate();
    
                mCamera.addCallbackBuffer(mVideoSource);
            }
        }
    
        public native void decode(Bitmap pTarget, byte[] pSource,
                                  int pFilter);
    }
    

刚才发生了什么?

通过 Android Camera API,我们从设备的相机捕获了实时图像。在设置相机捕获格式和定义之后,我们创建了所有必要的捕获缓冲区和输出图像以在屏幕上显示。当应用程序需要新帧时,捕获内容被保存在由应用程序入队的缓冲区中。然后,这个缓冲区与位图一起被传递给本地方法,我们将在下一节中编写它。最后,输出图像显示在屏幕上。

视频馈送以 YUV NV21 格式编码。YUV 是一种最初在电子时代的早期发明的颜色格式,以使黑白视频接收器与彩色传输兼容,现在仍然被广泛使用。Android 规范保证默认帧格式为YCbCr 420 SP(或NV21)。

提示

尽管 YCbCr 420 SP 是 Android 上的默认视频格式,但模拟器只支持 YCbCr 422 SP。这个缺陷基本上是颜色交换,不应该造成太大麻烦。在真实设备上不会出现这个问题。

既然我们的实时图像已经被捕获,让我们在本地处理它。

动手实践时间——使用 Bitmap API 处理图片

让我们继续通过颜色通道在本地端解码和过滤图像:

  1. 创建本地 C 源文件,jni/CameraDecoder.c(不是 C++文件,这样我们可以看到与用 C++编写的 JNI 代码的区别)。

    包含android/bitmap.h,它定义了 NDK 位图处理 API 和stdlib.h(不是cstdlib,因为此文件是用 C 编写的):

    #include <android/bitmap.h>
    #include <stdlib.h>
    ...
    

    编写一些实用宏以帮助解码视频。

    • toInt()函数将 jbyte 转换为整数,使用掩码擦除所有无用的位。

    • max()函数获取两个值中的最大值。

    • clamp()函数将值限制在定义的区间内。

    • color()从每个颜色分量构建一个 ARGB 颜色。

      ...
      #define toInt(pValue) \
          (0xff & (int32_t) pValue)
      #define max(pValue1, pValue2) \
          (pValue1 < pValue2) ? pValue2 : pValue1
      #define clamp(pValue, pLowest, pHighest) \
          ((pValue < 0) ? pLowest : (pValue > pHighest) ? pHighest : pValue)
      #define color(pColorR, pColorG, pColorB) \
          (0xFF000000 | ((pColorB << 6)  & 0x00FF0000) \
                      | ((pColorG >> 2)  & 0x0000FF00) \
                      | ((pColorR >> 10) & 0x000000FF))
      ...
      
  2. 实现decode()本地方法。

    首先,获取位图信息并检查其像素格式是否为 32 位 RGBA。然后,锁定它以允许绘图操作。

    之后,使用GetPrimitiveArrayCritical()获取作为 Java 字节数组传递的输入视频帧内容:

    ...
    void JNICALL decode(JNIEnv * pEnv, jclass pClass, jobject pTarget,
            jbyteArray pSource, jint pFilter) {
        // Retrieves bitmap information and locks it for drawing.
        AndroidBitmapInfo bitmapInfo;
        uint32_t* bitmapContent;
        if (AndroidBitmap_getInfo(pEnv,pTarget, &bitmapInfo) < 0) abort();
        if (bitmapInfo.format != ANDROID_BITMAP_FORMAT_RGBA_8888) abort();
        if (AndroidBitmap_lockPixels(pEnv, pTarget,
                (void**)&bitmapContent) < 0) abort();
    
        // Accesses source array data.
        jbyte* source = (*pEnv)->GetPrimitiveArrayCritical(pEnv,
                pSource, 0);
        if (source == NULL) abort();
        ...
    
  3. 将原始视频帧解码为输出位图。视频帧以 YUV 格式编码,这与 RGB 有很大不同。YUV 格式以三个分量编码颜色:

    • 一个亮度分量,即颜色的灰度表示。

    • 两个色度分量,它们编码颜色信息(也称为CbCr,因为它们代表蓝色差和红色差)。

    • 有许多基于 YUV 颜色的帧格式。这里,我们按照 YCbCr 420 SP(或 NV21)格式转换帧。这种图像帧由一个 8 位 Y 亮度样本缓冲区组成,后面跟着一个交错的 8 位 V 和 U 色度样本缓冲区。VU 缓冲区是子采样的,这意味着与 Y 样本相比,U 和 V 样本较少(对于 4 个 Y 样本,有 1 个 U 样本和 1 个 V 样本)。以下算法处理每个像素,并使用适当的公式将每个 YUV 像素转换为 RGB(更多信息请参见http://www.fourcecc.org/fccyvrgb.php):

      ...
          int32_t frameSize = bitmapInfo.width * bitmapInfo.height;
          int32_t yIndex, uvIndex, x, y;
          int32_t colorY, colorU, colorV;
          int32_t colorR, colorG, colorB;
          int32_t y1192;
      
          // Processes each pixel and converts YUV to RGB color.
          // Algorithm originates from the Ketai open source project.
          // See http://ketai.googlecode.com/.
          for (y = 0, yIndex = 0; y < bitmapInfo.height; ++y) {
              colorU = 0; colorV = 0;
              // Y is divided by 2 because UVs are subsampled vertically.
              // This means that two consecutives iterations refer to the
              // same UV line (e.g when Y=0 and Y=1).
              uvIndex = frameSize + (y >> 1) * bitmapInfo.width;
      
              for (x = 0; x < bitmapInfo.width; ++x, ++yIndex) {
                  // Retrieves YUV components. UVs are subsampled
                  // horizontally too, hence %2 (1 UV for 2 Y).
                  colorY = max(toInt(source[yIndex]) - 16, 0);
                  if (!(x % 2)) {
                      colorV = toInt(source[uvIndex++]) - 128;
                      colorU = toInt(source[uvIndex++]) - 128;
                  }
      
                  // Computes R, G and B from Y, U and V.
                  y1192 = 1192 * colorY;
                  colorR = (y1192 + 1634 * colorV);
                  colorG = (y1192 - 833  * colorV - 400 * colorU);
                  colorB = (y1192 + 2066 * colorU);
      
                  colorR = clamp(colorR, 0, 262143);
                  colorG = clamp(colorG, 0, 262143);
                  colorB = clamp(colorB, 0, 262143);
      
                  // Combines R, G, B and A into the final pixel color.
                  bitmapContent[yIndex] = color(colorR,colorG,colorB);
                  bitmapContent[yIndex] &= pFilter;
              }
          }
          ...
      

    最后,释放之前获取的 Java 字节缓冲区并解锁背后的位图。

        ...
        (*pEnv)-> ReleasePrimitiveArrayCritical(pEnv,pSource,source,0);
        if (AndroidBitmap_unlockPixels(pEnv, pTarget) < 0) abort();
    }
    ...
    
  4. JNI 允许在JNI_OnLoad()中手动注册本地方法,而不是依赖命名约定来查找本地方法。

    因此,定义一个表来描述要注册其名称、签名和地址的本地方法。这里,只需指定decode()

    然后,在JNI_OnLoad()中,找到声明本地方法decode()的 Java(这里是LiveCameraActivity),并使用RegisterNatives()告诉 JNI 使用哪个方法:

    ...
    static JNINativeMethod gMethodRegistry[] = {
      { "decode", "(Landroid/graphics/Bitmap;[BI)V", (void *) decode }
    };
    static int gMethodRegistrySize = sizeof(gMethodRegistry)
                                   / sizeof(gMethodRegistry[0]);
    
    JNIEXPORT jint JNI_OnLoad(JavaVM* pVM, void* reserved) {
        JNIEnv *env;
        if ((*pVM)->GetEnv(pVM, (void**) &env, JNI_VERSION_1_6) != JNI_OK)
        { abort(); }
    
        jclass LiveCameraActivity = (*env)->FindClass(env,
                "com/packtpub/livecamera/LiveCameraActivity");
        if (LiveCameraActivity == NULL) abort();
        (*env)->RegisterNatives(env, LiveCameraActivity,
                gMethodRegistry, 1);
        (*env)->DeleteLocalRef(env, LiveCameraActivity);
    
        return JNI_VERSION_1_6;
    }
    
  5. 按照以下方式编写Application.mk makefile:

    APP_PLATFORM := android-14
    APP_ABI := all
    
  6. 按照以下方式编写Android.mk makefile(将其链接到定义 Android Bitmap API 的jnigraphics模块):

    LOCAL_PATH := $(call my-dir)
    
    include $(CLEAR_VARS)
    
    LOCAL_MODULE    := livecamera
    LOCAL_SRC_FILES := CameraDecoder.c
    LOCAL_LDLIBS    := -ljnigraphics
    
    include $(BUILD_SHARED_LIBRARY)
    

刚才发生了什么?

编译并运行应用程序。未经任何转换,原始视频馈送显示在左上角。原始视频帧在本地代码中解码,并将每个颜色通道提取到三个 Java 位图中。这些位图显示在屏幕每个角的三个ImageView元素内。

刚才发生了什么?

用于解码 YUV 帧的算法源自 Ketai 开源项目,这是一个针对 Android 的图像和传感器处理库。更多信息请访问ketai.googlecode.com/。请注意,YUV 到 RGB 是一项昂贵的操作,很可能会成为程序中的争议点(我们将在第十章,使用 RenderScript 进行密集计算中介绍的RenderScript可以在该任务中提供帮助)。

这里展示的代码远非最优(解码算法可以进行优化,使用多个缓冲区捕获的视频帧,可以减少内存访问,并且代码可以是多线程的),但它概述了如何使用 NDK 本地处理位图。

借助 Android NDK 位图 API,在jnigraphics模块中定义,本地代码可以直接访问位图表面。这个 API 可以看作是 JNI 的 Android 特定扩展,定义了以下方法:

  • AndroidBitmap_getInfo()用于获取位图信息。当出现问题时,返回值将为负数,否则为0

    int AndroidBitmap_getInfo(JNIEnv* env, jobject jbitmap,
                              AndroidBitmapInfo* info);
    
  • 位图信息在AndroidBitmapInfo结构中获取,定义如下:

    typedef struct {
        uint32_t    width;  // Width in pixels
        uint32_t    height; // Height in pixels
        uint32_t    stride; // Number of bytes between each line
        int32_t     format; // Pixel structure (see AndroidBitmapFormat)
        uint32_t    flags;  // Unused for now
    } AndroidBitmapInfo;
    
  • AndroidBitmap_lockPixels()在处理位图时提供对其的独占访问。当出现问题时,返回值为负数,否则为0

    int AndroidBitmap_lockPixels(JNIEnv* env, jobject jbitmap, void** addrPtr);
    
  • AndroidBitmap_unlockPixels()释放对位图的独占锁定。当出现问题时,返回值为负数,否则为0

    int AndroidBitmap_unlockPixels(JNIEnv* env, jobject jbitmap);
    

对任何位图的绘制操作都系统地分为三个主要步骤:

  1. 首先,获取位图表面。

  2. 然后,修改位图像素。在这里,视频像素被转换为 RGB 并写入位图表面。

  3. 最后,释放位图表面。

在本地访问位图时,必须系统地锁定位图并在访问后解锁。绘制操作必须在锁定/解锁对之间强制执行。更多信息请查看bitmap.h头文件。

手动注册本地方法

在我们的商店示例中,本地方法原型已通过Javah使用特定的名称和参数约定自动生成。然后,Dalvik VM 可以在运行时通过“猜测”它们的名称来加载它们。然而,这种约定很容易被打破,并且在运行时没有灵活性。幸运的是,JNI 允许您手动注册将从 Java 中调用的本地方法。还有比JNI_OnLoad()更好的地方来做这件事吗?

注册是通过以下 JNI 方法完成的:

jint RegisterNatives(jclass clazz, const JNINativeMethod* methods,
                     jint nMethods)
  • jclass是对托管本地方法的 Java 类的引用。我们将在本章和下一章中更详细地了解它。

  • methods是一个JNINativeMethod数组,该结构描述了要注册的本地方法。

  • nMethods表示methods数组内描述的方法数量。

JNINativeMethod结构定义如下:

typedef struct {
    const char* name;
    const char* signature;
    void*       fnPtr;
} JNINativeMethod;

第一个和第二个元素是对应 Java 方法的namesignature,第三个参数fnPtr,是指向原生侧对应方法的指针。这样,你可以摆脱javah及其讨厌的命名约定,并在运行时选择要调用的方法。

C 中的 JNI 与 C++中的 JNI 对比。

NDK 允许用 C(如我们的LiveCamera示例)或 C++(如我们的Store示例)编写应用程序。JNI 也是如此。

C 不是一种面向对象的语言,但 C++是。这就是为什么你不能像在 C++中那样在 C 中编写 JNI。在 C 中,JNIEnv实际上是一个包含函数指针的结构。当然,当你得到JNIEnv时,所有这些指针都已初始化,你可以像使用对象一样调用它们。然而,这个在面向对象语言中隐含的参数,在 C 中作为第一个参数给出(以下代码中的env)。此外,首次运行方法时需要取消引用JNIEnv

JNIEnv *env = ...;
(*env)->RegisterNative(env, ...); 

C++代码更自然、更简单。这个参数是隐式的,无需取消引用JNIEnv,因为方法不再声明为函数指针,而是作为真正的成员方法:

JNIEnv *env = ...;
env->RegisterNative(env, ...); 

因此,尽管非常相似,但你不能以完全相同的方式在 C 中编写 JNI 代码,就像在 C++中编写一样。

总结

得益于 JNI,Java 和 C/C++可以紧密集成在一起。Android 现在完全双语化了!Java 可以使用任何类型的数据或对象调用 C/C++代码,原生代码也可以回调 Java。

我们还发现了如何使用 JNI 反射 API 从原生代码调用 Java 代码。实际上,几乎任何 Java 操作都可以通过它从原生代码执行。然而,为了最佳性能,类、方法或字段描述符必须被缓存。

我们还了解了如何将线程附加到虚拟机,并使用 JNI 监视器同步 Java 和原生线程。多线程代码可能是编程中最困难的主题之一。要谨慎处理!

最后,我们通过 JNI 原生处理了位图,并手动解码了视频流。然而,从默认的 YUV 格式(根据 Android 规范,每个设备都应该支持)到 RGB 的转换成本较高。

在处理 Android 上的原生代码时,几乎总是离不开 JNI。它是一个冗长且技术性很强的 API,更不用说它还繁琐,需要小心处理。要深入理解它的细微之处,可能需要一整本书的篇幅。而本章则为你提供了将你自己的 C/C++模块集成到 Java 应用程序中的基本知识。

在下一章,我们将看到如何创建一个完全原生的应用程序,它完全摆脱了 JNI。

第五章:编写一个完全原生的应用程序

在前面的章节中,我们通过 JNI 打破了 Android NDK 的表面。但里面还有更多内容!NDK 包含自己的一套特定功能,其中之一就是原生活动。原生活动允许仅基于原生代码创建应用程序,无需编写任何 Java 代码。不再需要 JNI!不再需要引用!不再需要 Java!

除了原生活动之外,NDK 还为原生访问 Android 资源提供了一些 API,例如显示窗口资产设备配置…这些 API 有助于摆脱通常需要嵌入原生代码的复杂的 JNI 桥接。尽管还有很多缺失且不太可能可用(Java 仍然是 GUI 和大多数框架的主要平台语言),但多媒体应用程序是应用它们的完美目标…

本章启动了一个在本书中逐步开发的本地 C++ 项目:DroidBlaster。从自上而下的视角来看,这个示例滚动射击游戏将包含 2D 图形,稍后还将包括 3D 图形、声音、输入和传感器管理。在本章中,我们将创建其基础结构和主要游戏组件。

现在让我们通过以下方式进入 Android NDK 的核心:

  • 创建一个完全原生的活动

  • 处理主活动事件

  • 原生访问显示窗口

  • 获取时间并计算延迟

创建一个原生 Activity

NativeActivity类提供了一种简化创建原生应用程序所需工作的方法。它让开发者摆脱了所有用于初始化和与原生代码通信的样板代码,从而专注于核心功能。这种胶水 Activity 是编写无需一行 Java 代码的应用程序(如游戏)的最简单方式。

注意

本书提供的项目成果名为DroidBlaster_Part1

动手时间——创建一个基本的原生 Activity

我们现在将了解如何创建一个运行事件循环的最小原生 activity。

  1. 创建一个新的混合 Java/C++ 项目,如第二章 启动一个原生 Android 项目所示。

    • 将其命名为DroidBlaster

    • 将项目转换为原生项目,如前一章所见。将原生模块命名为droidblaster

    • 删除由 ADT 创建的原生源文件和头文件。

    • 项目属性 | Java 构建路径 | 中删除对 Java src 目录的引用。然后在磁盘上删除该目录本身。

    • 删除res/layout目录中的所有布局。

    • 如果创建了jni/droidblaster.cpp,请将其删除。

  2. AndroidManifest.xml中,将应用程序主题设置为Theme.NoTitleBar.Fullscreen

    声明一个指向名为droidblaster的原生模块(即我们将编译的原生库)的NativeActivity,使用元数据属性android.app.lib_name

    <?xml version="1.0" encoding="utf-8"?>
    <manifest 
        package="com.packtpub.droidblaster2d" android:versionCode="1"
        android:versionName="1.0">
        <uses-sdk
            android:minSdkVersion="14"
            android:targetSdkVersion="19"/>
    
        <application android:icon="@drawable/ic_launcher"
            android:label="@string/app_name"
            android:allowBackup="false"
            android:theme         ="@android:style/Theme.NoTitleBar.Fullscreen">
     <activity android:name="android.app.NativeActivity"
                android:label="@string/app_name"
                android:screenOrientation="portrait">
                <meta-data android:name="android.app.lib_name"
     android:value="droidblaster"/>
                <intent-filter>
                    <action android:name ="android.intent.action.MAIN"/>
                    <category
                        android:name="android.intent.category.LAUNCHER"/>
                </intent-filter>
            </activity>
        </application>
    </manifest>
    
  3. 创建jni/Types.hpp文件。这个头文件将包含通用类型和cstdint头文件:

    #ifndef _PACKT_TYPES_HPP_
    #define _PACKT_TYPES_HPP_
    
    #include <cstdint>
    
    #endif
    
  4. 让我们编写一个日志类,以便在 Logcat 中得到一些反馈。

    • 创建jni/Log.hpp文件,并声明一个新的Log类。

    • 定义packt_Log_debug宏,以便通过一个简单的编译标志来激活或禁用调试信息:

      #ifndef _PACKT_LOG_HPP_
      #define _PACKT_LOG_HPP_
      
      class Log {
      public:
          static void error(const char* pMessage, ...);
          static void warn(const char* pMessage, ...);
          static void info(const char* pMessage, ...);
          static void debug(const char* pMessage, ...);
      };
      
      #ifndef NDEBUG
          #define packt_Log_debug(...) Log::debug(__VA_ARGS__)
      #else
          #define packt_Log_debug(...)
      #endif
      
      #endif
      
  5. 实现文件jni/Log.cpp,并实现info()方法。为了将消息写入 Android 日志,NDK 在android/log.h头文件中提供了一个专用的日志 API,可以像在 C 中使用printf()vprintf()(带有varArgs)一样使用:

    #include "Log.hpp"
    
    #include <stdarg.h>
    #include <android/log.h>
    
    void Log::info(const char* pMessage, ...) {
        va_list varArgs;
        va_start(varArgs, pMessage);
        __android_log_vprint(ANDROID_LOG_INFO, "PACKT", pMessage,
            varArgs);
        __android_log_print(ANDROID_LOG_INFO, "PACKT", "\n");
        va_end(varArgs);
    }
    ...
    

    编写其他日志方法,error()warn()debug(),它们几乎相同,除了级别宏分别是ANDROID_LOG_ERRORANDROID_LOG_WARNANDROID_LOG_DEBUG

  6. NativeActivity中的应用事件可以通过事件循环处理。因此,创建jni/EventLoop.hpp文件,定义一个具有唯一方法run()的类。

    包含android_native_app_glue.h头文件,它定义了android_app结构体。这代表了一个可以称为应用上下文的东西,其中所有信息都与本地活动相关;它的状态、它的窗口、它的事件队列等等:

    #ifndef _PACKT_EVENTLOOP_HPP_
    #define _PACKT_EVENTLOOP_HPP_
    
    #include <android_native_app_glue.h>
    
    class EventLoop {
    public:
        EventLoop(android_app* pApplication);
    
        void run();
    
    private:
        android_app* mApplication;
    };
    #endif
    
  7. 创建jni/EventLoop.cpp文件,并在run()方法中实现活动事件循环。包含一些日志事件,以便在 Android 日志中得到一些反馈。

    在整个活动生命周期中,run()方法会不断循环处理事件,直到请求终止。当一个活动即将被销毁时,android_app结构中的destroyRequested值会在内部改变,以指示客户端代码必须退出。

    同时,调用app_dummy()以确保将本地代码与NativeActivity连接的胶水代码不会被链接器移除。我们将在第九章,将现有库移植到 Android中了解更多相关信息。

    #include "EventLoop.hpp"
    #include "Log.hpp"
    
    EventLoop::EventLoop(android_app* pApplication):
            mApplication(pApplication)
    {}
    
    void EventLoop::run() {
        int32_t result; int32_t events;
        android_poll_source* source;
    
        // Makes sure native glue is not stripped by the linker.
        app_dummy();
    
        Log::info("Starting event loop");
        while (true) {
            // Event processing loop.
            while ((result = ALooper_pollAll(-1, NULL, &events,
                    (void**) &source)) >= 0) {
                // An event has to be processed.
                if (source != NULL) {
                    source->process(mApplication, source);
                }
                // Application is getting destroyed.
                if (mApplication->destroyRequested) {
                    Log::info("Exiting event loop");
                    return;
                }
            }
        }
    }
    
  8. 最后,创建jni/Main.cpp文件,定义程序入口点android_main(),它在一个新的文件Main.cpp中运行事件循环:

    #include "EventLoop.hpp"
    #include "Log.hpp"
    
    void android_main(android_app* pApplication) {
        EventLoop(pApplication).run();
    }
    
  9. 编辑jni/Android.mk文件,定义droidblaster模块(即LOCAL_MODULE指令)。

    使用LS_CPP宏帮助描述编译LOCAL_SRC_FILES指令的 C++文件(关于这方面的更多信息,请见第九章,将现有库移植到 Android)。

    droidblasternative_app_glue模块(即LOCAL_STATIC_LIBRARIES指令)和androidNative App Glue模块所必需的)以及log库(即LOCAL_LDLIBS指令)链接起来:

    LOCAL_PATH := $(call my-dir)
    
    include $(CLEAR_VARS)
    
    LS_CPP=$(subst $(1)/,,$(wildcard $(1)/*.cpp))
    LOCAL_MODULE := droidblaster
    LOCAL_SRC_FILES := $(call LS_CPP,$(LOCAL_PATH))
    LOCAL_LDLIBS := -landroid -llog
    LOCAL_STATIC_LIBRARIES := android_native_app_glue
    
    include $(BUILD_SHARED_LIBRARY)
    
    $(call import-module,android/native_app_glue)
    
  10. 创建jni/Application.mk文件,以编译针对多个ABI的本地模块。我们将使用最基本的内容,如下代码所示:

    APP_ABI := armeabi armeabi-v7a x86
    

刚才发生了什么?

构建并运行应用程序。当然,启动此应用程序时你不会看到任何惊人的东西。实际上,你只会看到一个黑屏!但是,如果你仔细查看 Eclipse 中的LogCat视图(或使用adb logcat命令),你会发现一些有趣的信息,这些信息是响应活动事件由你的原生应用程序发出的。

刚才发生了什么?

我们启动了一个没有一行 Java 代码的 Java Android 项目!在AndroidManifest中,我们没有引用Activity的子类,而是引用了 Android 框架提供的android.app.NativeActivity类。

NativeActivity是一个 Java 类,像任何其他 Android 活动一样启动,并由 Dalvik 虚拟机像任何其他 Java 类一样解释。然而,我们从未直接面对它。NativeActivity实际上是 Android SDK 提供的一个辅助类,它包含处理应用程序事件(生命周期、输入、传感器等)的所有必要的胶水代码,并透明地将它们广播给原生代码。因此,原生活动并没有消除对 JNI 的需求。它只是将其隐藏在幕后!然而,由NativeActivity运行的本地 C/C++模块在其自己的线程中执行,完全本地化(使用 Posix 线程 API)!

NativeActivity和原生代码通过native_app_glue模块连接在一起。原生应用胶水有以下职责:

  • 启动运行我们自己的原生代码的原生线程

  • NativeActivity接收事件

  • 将这些事件路由到原生线程事件循环以进行进一步处理

Native glue模块的代码位于${ANDROID_NDK}/sources/android/native_app_glue,可以随意分析、修改或派生(更多信息请参见第九章,将现有库移植到 Android)。与原生 API 相关的头文件,如looper.h,可以在${ANDROID_NDK}/platforms/<目标平台>/<目标架构>/usr/include/android/中找到。让我们更详细地了解它是如何工作的。

关于原生应用胶水的更多内容

我们自己的原生代码入口点在android_main()方法内声明,这类似于桌面应用程序中的主方法。当NativeActivity被实例化和启动时,它只被调用一次。它会循环处理应用程序事件,直到用户终止NativeActivity(例如,当按下设备的返回按钮时)或直到它自行退出(下一部分将详细介绍)。

android_main()方法并不是真正的原生应用入口点。真正的入口点是隐藏在android_native_app_glue模块中的ANativeActivity_onCreate()方法。我们在android_main()中实现的事件循环实际上是一个代理事件循环,由胶水模块在其自己的原生线程中启动。这种设计将原生代码与在 Java 端的 UI 线程上运行的NativeActivity类解耦。因此,即使你的代码处理事件需要很长时间,NativeActivity也不会被阻塞,你的 Android 设备仍然保持响应。

android_main()中的代理原生事件循环由两个嵌套的 while 循环组成。在我们的例子中,外层循环是一个无限循环,只有在系统请求活动销毁时(由destroyRequested标志指示)才会终止。它执行一个内层循环,处理所有待处理的应用程序事件。

...
int32_t result; int32_t events;
android_poll_source* source;
while (true) {
    while ((result = ALooper_pollAll(-1, NULL, &events,
            (void**) &source)) >= 0) {
        if (source != NULL) {
           source->process(mApplication, source);
        }
        if (mApplication->destroyRequested) {
            return;
        }
    }
}
...

内层的For循环通过调用ALooper_pollAll()来轮询事件。这个方法是Looper API 的一部分,可以描述为 Android 提供的一个通用事件循环管理器。当超时设置为-1时,如前面的示例中,ALooper_pollAll()在等待事件时会保持阻塞。当至少收到一个事件时,ALooper_pollAll()返回,代码流程继续。

描述事件的android_poll_source结构体被填充,并由客户端代码用于进一步处理。这个结构体如下所示:

struct android_poll_source {
    int32_t id; // Source identifier
    struct android_app* app; // Global android application context
    void (*process)(struct android_app* app,
            struct android_poll_source* source); // Event processor
};

process()函数指针可以被自定义以手动处理应用程序事件,我们将在下一节中看到这一点。

正如我们在这一部分看到的,事件循环接收一个android_app结构体作为参数。这个在android_native_app_glue.h中描述的结构体包含一些上下文信息,如下表所示:

void* userData 指向任何你想要的数据的指针。这对于向活动或输入事件回调提供一些上下文信息至关重要。
void (*pnAppCmd)(…)int32_t (*onInputEvent)(…) 这些成员变量表示当活动或输入事件发生时由原生应用胶水触发的事件回调。我们将在下一节中了解更多相关信息。
ANativeActivity* activity 描述 Java 原生活动(其类作为 JNI 对象,其数据目录等)并提供获取 JNI 上下文所需的必要信息。
AConfiguration* config 描述当前的硬件和系统状态,例如当前的语言和国家,当前的屏幕方向,密度,大小等。
void* savedState size_tsavedStateSize 用于在活动(及其原生线程)被销毁并稍后恢复时保存数据缓冲区。
AInputQueue* inputQueue 提供输入事件(由原生胶水内部使用)。我们将在第八章,处理输入设备和传感器中了解更多关于输入事件的信息。
ALooper* looper 允许附加和分离本地胶水内部使用的事件队列。监听器轮询并等待通信管道上发送的事件。
ANativeWindow* windowARect contentRect 表示可以绘制图形的“可绘制”区域。ANativeWindow API,在native_window.h中声明,允许获取窗口宽度、高度和像素格式,并更改这些设置。
int activityState 当前活动状态,即APP_CMD_STARTAPP_CMD_RESUMEAPP_CMD_PAUSE等。
int destroyRequested 等于1时,表示应用程序即将被销毁,本地线程必须立即终止。这个标志必须在事件循环中检查。

android_app结构体还包含了一些仅供内部使用的额外数据,这些数据不应被更改。

知道这些细节并不是编写本地程序的必要条件,但可以帮助你了解幕后发生的情况。现在让我们看看如何处理这些活动事件。

处理活动事件

在第一部分中,运行了一个本地事件循环,它刷新事件而不真正处理它们。在这个第二部分中,我们将发现更多关于活动生命周期中发生的事件,以及如何处理它们,并花费剩余时间步进我们的应用程序。

注意

本书提供的项目结果名为DroidBlaster_Part2

行动时间——步进事件循环

让我们扩展上一个示例,在处理事件时步进我们的应用程序。

  1. 打开jni/Types.hpp文件,定义一个新的类型 status 以表示返回码:

    #ifndef _PACKT_TYPES_HPP_
    #define _PACKT_TYPES_HPP_
    
    #include <cstdlib>
    
    typedef int32_t status;
    
    const status STATUS_OK   = 0;
    const status STATUS_KO   = -1;
    const status STATUS_EXIT = -2;
    
    #endif
    
  2. 创建jni/ActivityHandler.hpp头文件,并定义一个“接口”以观察本地活动事件。每个可能的事件都有其自己的处理方法:onStart()onResume()onPause()onStop()onDestroy()等。然而,我们通常只对活动生命周期中的三个特定时刻感兴趣:

    • onActivate(),在活动恢复且其窗口可用并获得焦点时调用。

    • onDeactivate(),在活动暂停或显示窗口失去焦点或被销毁时调用。

    • onStep(),在没有事件需要处理且可以进行计算时调用。

      #ifndef _PACKT_ACTIVITYHANDLER_HPP_
      #define _PACKT_ACTIVITYHANDLER_HPP_
      
      #include "Types.hpp"
      
      class ActivityHandler {
      public:
          virtual ~ActivityHandler() {};
      
          virtual status onActivate() = 0;
          virtual void onDeactivate() = 0;
          virtual status onStep() = 0;
      
          virtual void onStart() {};
          virtual void onResume() {};
          virtual void onPause() {};
          virtual void onStop() {};
          virtual void onDestroy() {};
      
          virtual void onSaveInstanceState(void** pData, size_t* pSize) {};
          virtual void onConfigurationChanged() {};
          virtual void onLowMemory() {};
      
          virtual void onCreateWindow() {};
          virtual void onDestroyWindow() {};
          virtual void onGainFocus() {};
          virtual void onLostFocus() {};
      };
      #endif
      
  3. 使用以下方法增强jni/EventLoop.hpp

    • activate()deactivate(),在活动可用性发生变化时执行。

    • callback_appEvent(),它是静态的,将事件路由到processActivityEvent()

    还定义一些成员变量如下:

    • mActivityHandler观察活动事件。这个实例作为构造函数参数提供,需要包含ActivityHandler.hpp

    • mEnabled保存应用程序在活动/暂停状态时的状态。

    • mQuit表示事件循环需要退出。

      #ifndef _PACKT_EVENTLOOP_HPP_
      #define _PACKT_EVENTLOOP_HPP_
      
      #include "ActivityHandler.hpp"
      #include <android_native_app_glue.h>
      
      class EventLoop {
      public:
          EventLoop(android_app* pApplication,
                  ActivityHandler& pActivityHandler);
      
          void run();
      
      private:
       void activate();
       void deactivate();
      
       void processAppEvent(int32_t pCommand);
      
       static void callback_appEvent(android_app* pApplication,
       int32_t pCommand);
      
      private:
          android_app* mApplication;
          bool mEnabled;
       bool mQuit;
      
       ActivityHandler& mActivityHandler;
      };
      #endif
      
  4. 编辑jni/EventLoop.cpp。构造函数初始化列表本身实现起来非常简单。然后,为android_app应用程序上下文填充额外的信息:

    • userData指向您想要的任何数据。这是从之前声明的callback_appEvent()中唯一可以访问的信息。在我们的例子中,这是EventLoop实例(即this)。

    • onAppCmd指向每次发生事件时触发的内部回调。在我们的例子中,这是分配给静态方法callback_appEvent()的角色。

      #include "EventLoop.hpp"
      #include "Log.hpp"
      
      EventLoop::EventLoop(android_app* pApplication,
              ActivityHandler& pActivityHandler):
       mApplication(pApplication),
       mEnabled(false), mQuit(false),
       mActivityHandler(pActivityHandler) {
       mApplication->userData = this;
       mApplication->onAppCmd = callback_appEvent;
      }
      ...
      
    • 更新run()主事件循环。当没有更多活动事件需要处理时,ALooper_pollAll()不再阻塞,必须让程序流程继续执行周期性处理。在这里,处理是由mActivityHandler.onStep()中的监听器执行的。这种行为只有在应用程序被启用时才需要。

    • 同时,允许使用AnativeActivity_finish()方法以编程方式终止活动。

      ...
      void EventLoop::run() {
          int32_t result; int32_t events;
          android_poll_source* source;
      
          // Makes sure native glue is not stripped by the linker.
          app_dummy();
      
          Log::info("Starting event loop");
          while (true) {
              // Event processing loop.
              while ((result = ALooper_pollAll(mEnabled ? 0 : -1,         NULL,
       &events, (void**) &source)) >= 0) {
                  // An event has to be processed.
                  if (source != NULL) {
                      Log::info("Processing an event");
                      source->process(mApplication, source);
                  }
                  // Application is getting destroyed.
                  if (mApplication->destroyRequested) {
                      Log::info("Exiting event loop");
                      return;
                  }
              }
      
              // Steps the application.
       if ((mEnabled) && (!mQuit)) {
       if (mActivityHandler.onStep() != STATUS_OK) {
       mQuit = true;
       ANativeActivity_finish(mApplication->activity);
                  }
              }
          }
      }
      ...
      

刚才发生了什么?

我们改变了事件循环,以在处理完所有事件后更新应用程序,而不是无用地阻塞。这种行为在ALooper_pollAll()的第一个参数,即超时中指定:

  • 当超时为-1时,如先前定义的,调用将阻塞直到接收到事件。

  • 当超时为0时,调用是非阻塞的,因此,如果队列中没有任何剩余,程序流程将继续(内部循环结束),这使得可以执行周期性处理。

  • 当超时大于0时,我们有一个阻塞调用,该调用将保持直到接收到事件或持续时间结束。

在这里,我们希望在活动状态(即执行计算,mEnabledtrue)时执行活动步骤;在这种情况下,超时为0。当活动处于非活动状态(mEnabledfalse)时,仍然会处理事件(例如,恢复活动),但无需进行计算。为了避免无谓地消耗电池和处理时间,线程必须被阻塞;在这种情况下,超时为-1

当所有待处理的事件都处理完毕后,将执行监听器的步骤。例如,如果游戏结束,它可以请求应用程序终止。为了从程序上退出应用程序,NDK API 提供了AnativeActivity_finish()方法来请求活动终止。终止不会立即发生,而是在处理完最后几个事件(暂停、停止等)后发生。

行动时间——处理活动事件。

我们还没有完成。让我们继续我们的示例,以处理活动事件并将它们记录到LogCat视图:

  1. 继续编辑jni/EventLoop.cpp。实现activate()deactivate()。在通知监听器之前检查两个活动状态(以避免过早触发)。我们认为只有当显示窗口可用时,活动才被视为激活:

    ...
    void EventLoop::activate() {
        // Enables activity only if a window is available.
        if ((!mEnabled) && (mApplication->window != NULL)) {
            mQuit = false; mEnabled = true;
            if (mActivityHandler.onActivate() != STATUS_OK) {
                goto ERROR;
            }
        }
        return;
    
    ERROR:
        mQuit = true;
        deactivate();
        ANativeActivity_finish(mApplication->activity);
    }
    
    void EventLoop::deactivate() {
        if (mEnabled) {
            mActivityHandler.onDeactivate();
            mEnabled = false;
        }
    }
    ...
    
    • 将活动事件从静态回调callback_appEvent()路由到成员方法processAppEvent()

    • 为此,通过userData指针获取EventLoop实例(静态方法无法使用此指针)。然后,有效的事件处理委托给processAppEvent(),这让我们回到了面向对象的世界。同时,原生胶水给出的命令(即活动事件)也被传递。

      ...
      void EventLoop::callback_appEvent(android_app* pApplication,
          int32_t pCommand) {
          EventLoop& eventLoop = *(EventLoop*) pApplication->userData;
          eventLoop.processAppEvent(pCommand);
      }
      ...
      
  2. processAppEvent()中处理转发的事件。pCommand参数包含一个枚举值(APP_CMD_*),描述发生的事件(APP_CMD_START, APP_CMD_GAINED_FOCUS等)。

    根据事件,激活或停用事件循环并通知监听器:

    当活动获得焦点时会发生激活。这个事件总是在活动恢复并创建窗口后发生的最后一个事件。获得焦点意味着活动可以接收输入事件。

    当窗口失去焦点或应用暂停时(两者都可能首先发生)会发生停用。为了安全起见,在窗口被销毁时也会执行停用,尽管这应该总是在失去焦点之后发生。失去焦点意味着应用不再接收输入事件。

    ...
    void EventLoop::processAppEvent(int32_t pCommand) {
        switch (pCommand) {
        case APP_CMD_CONFIG_CHANGED:
            mActivityHandler.onConfigurationChanged();
            break;
        case APP_CMD_INIT_WINDOW:
            mActivityHandler.onCreateWindow();
            break;
        case APP_CMD_DESTROY:
            mActivityHandler.onDestroy();
            break;
        case APP_CMD_GAINED_FOCUS:
            activate();
            mActivityHandler.onGainFocus();
            break;
        case APP_CMD_LOST_FOCUS:
            mActivityHandler.onLostFocus();
            deactivate();
            break;
        case APP_CMD_LOW_MEMORY:
            mActivityHandler.onLowMemory();
            break;
        case APP_CMD_PAUSE:
            mActivityHandler.onPause();
            deactivate();
            break;
        case APP_CMD_RESUME:
            mActivityHandler.onResume();
            break;
        case APP_CMD_SAVE_STATE:
            mActivityHandler.onSaveInstanceState(
               &mApplication->savedState, &mApplication->savedStateSize);
              break;
        case APP_CMD_START:
            mActivityHandler.onStart();
            break;
        case APP_CMD_STOP:
            mActivityHandler.onStop();
            break;
        case APP_CMD_TERM_WINDOW:
            mActivityHandler.onDestroyWindow();
            deactivate();
            break;
        default:
            break;
        }
    }
    

    提示

    一些事件,如APP_CMD_WINDOW_RESIZED,虽然可用,但从未触发。除非你准备深入胶水,否则不要监听它们。

  3. 创建jni/DroidBlaster.hpp,实现ActivityHandler接口及其所有方法(这里为了简洁起见,省略了一些)。这个类将按如下方式运行游戏逻辑:

    #ifndef _PACKT_DROIDBLASTER_HPP_
    #define _PACKT_DROIDBLASTER_HPP_
    
    #include "ActivityHandler.hpp"
    #include "EventLoop.hpp"
    #include "Types.hpp"
    
    class DroidBlaster : public ActivityHandler {
    public:
        DroidBlaster(android_app* pApplication);
        void run();
    
    protected:
        status onActivate();
        void onDeactivate();
        status onStep();
    
        void onStart();
        ...
    
    private:
        EventLoop mEventLoop;
    };
    #endif
    
  4. 使用所有必需的处理程序实现jni/DroidBlaster.cpp。为了使这个活动生命周期的介绍保持简单,我们只需记录下面代码中省略的所有处理程序发生的每个事件。使用onStart()作为所有处理程序的模型。

    步骤限制为简单的线程休眠(以避免淹没 Android 日志),这需要包含unistd.h

    注意,现在事件循环直接由DroidBlaster类运行:

    #include "DroidBlaster.hpp"
    #include "Log.hpp"
    
    #include <unistd.h>
    
    DroidBlaster::DroidBlaster(android_app* pApplication):
        mEventLoop(pApplication, *this) {
        Log::info("Creating DroidBlaster");
    }
    
    void DroidBlaster::run() {
        mEventLoop.run();
    }
    
    status DroidBlaster::onActivate() {
        Log::info("Activating DroidBlaster");
        return STATUS_OK;
    }
    
    void DroidBlaster::onDeactivate() {
        Log::info("Deactivating DroidBlaster");
    }
    
    status DroidBlaster::onStep() {
        Log::info("Starting step");
        usleep(300000);
        Log::info("Stepping done");
        return STATUS_OK;
    }
    
    void DroidBlaster::onStart() {
        Log::info("onStart");
    }
    ...
    
  5. 最后,在android_main()入口点初始化并运行DroidBlaster游戏:

    #include "DroidBlaster.hpp"
    #include "EventLoop.hpp"
    #include "Log.hpp"
    
    void android_main(android_app* pApplication) {
        DroidBlaster(pApplication).run();
    }
    

刚才发生了什么?

如果你喜欢黑色屏幕,那么你已经得到了!同样,这次,所有的事情都在 Eclipse 的LogCat视图中发生。所有对应用事件反应而发出的消息都在这里显示,如下面的截图所示:

刚才发生了什么?

我们创建了一个最小化的框架,它使用事件驱动的方法在本地线程中处理应用事件。事件(被称为命令)被重定向到一个监听器对象,该对象执行其自己的特定计算。

原生活动事件大多对应于经典的 Java 活动事件。事件是任何应用都需要处理的临界点,而且相当棘手。它们通常成对出现,如start/stopresume/pausecreate/destroycreate window/destroy windowgain/lose focus。尽管它们大多数时间按预定顺序发生,但某些特定情况可能导致不同的行为,例如:

  • 使用后退按钮离开应用会销毁活动和原生线程。

  • 使用主页按钮离开应用会停止活动并释放窗口。原生线程保持暂停状态。

  • 长按设备的主页按钮然后返回,应该只导致失去和获得焦点。原生线程保持暂停状态。

  • 关闭手机屏幕并重新打开应该在活动恢复后立即终止并重新初始化窗口。原生线程保持暂停状态。

  • 在更改屏幕方向(此处不适用)时,整个活动可能不会失去焦点,尽管重新创建的活动将重新获得焦点。

理解活动生命周期对于开发 Android 应用至关重要。请查看官方 Android 文档中的developer.android.com/reference/android/app/Activity.html,了解详细描述。

提示

Native App Glue 使您有机会在活动被APP_CMD_SAVE_STATE触发销毁之前保存活动状态。状态必须在android_app结构中的savedState(指向要保存的内存缓冲区的指针)和savedStateSize(要保存的内存缓冲区的大小)中保存。该缓冲区必须由我们使用malloc()(自动释放)分配,并且不得包含指针,只包含“原始”数据。

原生地访问窗口表面

应用事件是必须要理解的,但不是特别令人兴奋。Android NDK 的一个有趣特性是能够原生地访问显示窗口。有了这种特权访问,应用可以在屏幕上绘制任何想要的图形。

我们现在将利用这一特性在我们的应用中获得图形反馈:屏幕上的一个红色方块。这个方块将代表用户在游戏中控制的太空船。

注意

本书提供的成果项目名为DroidBlaster_Part3

动手操作时间 – 显示原始图形

让我们通过添加一些图形和游戏组件,使DroidBlaster更具互动性。

  1. 编辑jni/Types.hpp文件,并创建一个新的Location结构体来保存实体位置。同时,定义一个宏以按照以下方式生成指定范围内的随机值:

    #ifndef _PACKT_TYPES_HPP_
    #define _PACKT_TYPES_HPP_
    ...
    struct Location {
     Location(): x(0.0f), y(0.0f) {};
    
        float x; float y;
    };
    
    #define RAND(pMax) (float(pMax) * float(rand()) / float(RAND_MAX))
    #endif
    
  2. 创建一个新文件jni/GraphicsManager.hpp。定义一个GraphicsElement结构体,其中包含要显示的图形元素的位置和尺寸:

    #ifndef _PACKT_GRAPHICSMANAGER_HPP_
    #define _PACKT_GRAPHICSMANAGER_HPP_
    
    #include "Types.hpp"
    
    #include <android_native_app_glue.h>
    
    struct GraphicsElement {
        GraphicsElement(int32_t pWidth, int32_t pHeight):
            location(),
            width(pWidth), height(pHeight) {
        }
    
        Location location;
        int32_t width;  int32_t height;
    };
    ...
    

    接着,在同一个文件中,按照以下方式定义一个GraphicsManager类:

    • getRenderWidth()getRenderHeight()用于返回显示尺寸

    • registerElement()是一个GraphicsElement工厂方法,它告诉管理器要绘制哪个元素。

    • start()update()分别初始化管理器并渲染每一帧的屏幕

    需要几个成员变量:

    • mApplication存储了访问显示窗口所需的应用程序上下文

    • mRenderWidthmRenderHeight用于显示尺寸

    • mElementsmElementCount用于绘制所有元素的表格

      ...
      class GraphicsManager {
      public:
          GraphicsManager(android_app* pApplication);
          ~GraphicsManager();
      
          int32_t getRenderWidth() { return mRenderWidth; }
          int32_t getRenderHeight() { return mRenderHeight; }
      
          GraphicsElement* registerElement(int32_t pHeight, int32_t pWidth);
      
          status start();
          status update();
      
      private:
          android_app* mApplication;
      
          int32_t mRenderWidth; int32_t mRenderHeight;
          GraphicsElement* mElements[1024]; int32_t mElementCount;
      };
      #endif
      
  3. 实现jni/GraphicsManager.cpp,从构造函数、析构函数和注册方法开始。它们管理要更新的GraphicsElement列表:

    #include "GraphicsManager.hpp"
    #include "Log.hpp"
    
    GraphicsManager::GraphicsManager(android_app* pApplication) :
        mApplication(pApplication),
        mRenderWidth(0), mRenderHeight(0),
        mElements(), mElementCount(0) {
        Log::info("Creating GraphicsManager.");
    }
    
    GraphicsManager::~GraphicsManager() {
        Log::info("Destroying GraphicsManager.");
        for (int32_t i = 0; i < mElementCount; ++i) {
            delete mElements[i];
        }
    }
    
    GraphicsElement* GraphicsManager::registerElement(int32_t pHeight,
            int32_t pWidth) {
        mElements[mElementCount] = new GraphicsElement(pHeight, pWidth);
        return mElements[mElementCount++];
    }
    ...
    
  4. 实现了start()方法来初始化管理器。

    首先,使用ANativeWindow_setBuffersGeometry()API 方法强制窗口深度格式为 32 位。传递的参数中的两个零是所需的窗口宽度和高度。除非用正值初始化,否则它们将被忽略。在这种情况下,请求的由宽度和高度定义的窗口区域会被缩放到匹配屏幕尺寸。

    然后,在ANativeWindow_Buffer结构中检索所有必要的窗口尺寸。为了填充这个结构,必须首先使用ANativeWindow_lock()锁定窗口,完成后再使用AnativeWindow_unlockAndPost()解锁。

    ...
    status GraphicsManager::start() {
        Log::info("Starting GraphicsManager.");
    
        // Forces 32 bits format.
        ANativeWindow_Buffer windowBuffer;
        if (ANativeWindow_setBuffersGeometry(mApplication->window, 0, 0,
            WINDOW_FORMAT_RGBX_8888) < 0) {
            Log::error("Error while setting buffer geometry.");
            return STATUS_KO;
        }
    
        // Needs to lock the window buffer to get its properties.
        if (ANativeWindow_lock(mApplication->window,
                &windowBuffer, NULL) >= 0) {
            mRenderWidth = windowBuffer.width;
            mRenderHeight = windowBuffer.height;
            ANativeWindow_unlockAndPost(mApplication->window);
        } else {
            Log::error("Error while locking window.");
            return STATUS_KO;
        }
        return STATUS_OK;
    }
    ...
    
  5. 编写update()方法,每次应用程序步进时渲染原始图形。

    在任何绘制操作之前,必须使用AnativeWindow_lock()锁定窗口表面。同样,AnativeWindow_Buffer结构被填充了窗口的宽度和高度信息,但更重要的是stridebits指针。

    stride给出了窗口中两条连续像素线之间的距离(以“像素”为单位)。

    bits指针直接访问窗口表面,与上一章中看到的 Bitmap API 非常相似。

    有了这两部分信息,就可以执行基于像素的操作。

    例如,使用0清除窗口内存区域以获得黑色背景。可以使用memset()的暴力方法实现这一目的。

    ...
    status GraphicsManager::update() {
        // Locks the window buffer and draws on it.
        ANativeWindow_Buffer windowBuffer;
        if (ANativeWindow_lock(mApplication->window,
                &windowBuffer, NULL) < 0) {
            Log::error("Error while starting GraphicsManager");
            return STATUS_KO;
        }
    
        // Clears the window.
        memset(windowBuffer.bits, 0, windowBuffer.stride *
                windowBuffer.height * sizeof(uint32_t*));
    ...
    
    • 清除后,绘制所有通过GraphicsManager注册的元素。屏幕上每个元素都表示为一个红色正方形。

    • 首先,计算要绘制的元素的坐标(左上角和右下角)。

    • 然后,将它们的坐标剪辑以避免在窗口内存区域外绘制。这个操作相当重要,因为超出窗口限制可能会导致段错误:

      ...
          // Renders graphic elements.
          int32_t maxX = windowBuffer.width - 1;
          int32_t maxY = windowBuffer.height - 1;
          for (int32_t i = 0; i < mElementCount; ++i) {
              GraphicsElement* element = mElements[i];
      
              // Computes coordinates.
              int32_t leftX = element->location.x - element->width / 2;
              int32_t rightX = element->location.x + element->width / 2;
              int32_t leftY = windowBuffer.height - element->location.y
                                  - element->height / 2;
              int32_t rightY = windowBuffer.height - element->location.y
                                  + element->height / 2;
      
              // Clips coordinates.
              if (rightX < 0 || leftX > maxX
               || rightY < 0 || leftY > maxY) continue;
      
              if (leftX < 0) leftX = 0;
              else if (rightX > maxX) rightX = maxX;
              if (leftY < 0) leftY = 0;
              else if (rightY > maxY) rightY = maxY;
      ...
      
  6. 之后,在屏幕上绘制元素的每个像素。line变量指向第一条像素线的开始位置,该元素在此位置绘制。这个指针是通过stride(两条像素线之间的距离)和元素的顶部Y坐标计算得出的。

    然后,我们可以遍历窗口像素来绘制一个代表元素的红色方块。从元素的左X坐标遍历到右X坐标,当达到每行像素的末尾时(即在Y轴上)切换到下一行。

    ...
            // Draws a rectangle.
            uint32_t* line = (uint32_t*) (windowBuffer.bits)
                            + (windowBuffer.stride * leftY);
            for (int iY = leftY; iY <= rightY; iY++) {
                for (int iX = leftX; iX <= rightX; iX++) {
                    line[iX] = 0X000000FF; // Red color
                }
                line = line + windowBuffer.stride;
            }
        }
    ...
    

    使用ANativeWindow_unlockAndPost()结束绘图操作,并挂起对pendANativeWindow_lock()的调用。这些必须始终成对调用:

    ...
        // Finshed drawing.
        ANativeWindow_unlockAndPost(mApplication->window);
        return STATUS_OK;
    }
    
  7. 创建一个新组件jni/Ship.hpp,代表我们的太空船。

    目前我们只处理初始化,使用initialize()函数。

    使用工厂方法registerShip()创建Ship

    需要初始化GraphicsManager和飞船GraphicsElement以正确初始化飞船。

    #ifndef _PACKT_SHIP_HPP_
    #define _PACKT_SHIP_HPP_
    
    #include "GraphicsManager.hpp"
    
    class Ship {
    public:
        Ship(android_app* pApplication,
             GraphicsManager& pGraphicsManager);
    
        void registerShip(GraphicsElement* pGraphics);
    
        void initialize();
    
    private:
        GraphicsManager& mGraphicsManager;
    
        GraphicsElement* mGraphics;
    };
    #endif
    
  8. 实现jni/Ship.cpp。重要的是initialize()函数,它将飞船定位在屏幕的左下角,如下代码所示:

    #include "Log.hpp"
    #include "Ship.hpp"
    #include "Types.hpp"
    
    static const float INITAL_X = 0.5f;
    static const float INITAL_Y = 0.25f;
    
    Ship::Ship(android_app* pApplication,
            GraphicsManager& pGraphicsManager) :
      mGraphicsManager(pGraphicsManager),
      mGraphics(NULL) {
    }
    
    void Ship::registerShip(GraphicsElement* pGraphics) {
        mGraphics = pGraphics;
    }
    
    void Ship::initialize() {
        mGraphics->location.x = INITAL_X
                * mGraphicsManager.getRenderWidth();
        mGraphics->location.y = INITAL_Y
                * mGraphicsManager.getRenderHeight();
    }
    
  9. 将新创建的管理器和组件添加到jni/DroidBlaster.hpp

    ...
    #include "ActivityHandler.hpp"
    #include "EventLoop.hpp"
    #include "GraphicsManager.hpp"
    #include "Ship.hpp"
    #include "Types.hpp"
    
    class DroidBlaster : public ActivityHandler {
        ...
    private:
        ...
    
        GraphicsManager mGraphicsManager;
        EventLoop mEventLoop;
    
        Ship mShip;
    };
    #endif
    
  10. 最后,更新jni/DroidBlaster.cpp构造函数:

    ...
    static const int32_t SHIP_SIZE = 64;
    
    DroidBlaster::DroidBlaster(android_app* pApplication):
     mGraphicsManager(pApplication),
     mEventLoop(pApplication, *this),
    
     mShip(pApplication, mGraphicsManager) {
        Log::info("Creating DroidBlaster");
    
        GraphicsElement* shipGraphics = mGraphicsManager.registerElement(
     SHIP_SIZE, SHIP_SIZE);
     mShip.registerShip(shipGraphics);
    }
    ...
    
  11. onActivate()中初始化GraphicsManagerShip组件:

    ...
    status DroidBlaster::onActivate() {
        Log::info("Activating DroidBlaster");
    
        if (mGraphicsManager.start() != STATUS_OK) return     STATUS_KO;
    
     mShip.initialize();
    
        return STATUS_OK;
    }
    ...
    
  12. 最后,在onStep()中更新管理器:

    ...
    status DroidBlaster::onStep() {
        return mGraphicsManager.update();
    }
    

刚才发生了什么?

编译并运行DroidBlaster。结果应该是在屏幕的第一季度显示一个简单的红色方块,代表我们的太空船,如下所示:

刚才发生了什么?

通过ANativeWindow API 提供图形反馈,它为显示窗口提供了本地访问。它允许像位图一样操作其表面。同样,访问窗口表面需要在处理前后进行锁定和解锁。

AnativeWindow API 在android/native_window.handroid/native_window_jni.h中定义。它提供以下功能:

ANativeWindow_setBuffersGeometry()初始化窗口缓冲区的像素格式(或深度格式)和大小。可能的像素格式有:

  • WINDOW_FORMAT_RGBA_8888每个像素 32 位颜色,红、绿、蓝和 Alpha(透明度)通道各 8 位。

  • WINDOW_FORMAT_RGBX_8888与上一个相同,只是忽略了 Alpha 通道。

  • WINDOW_FORMAT_RGB_565每个像素 16 位颜色(红和蓝 5 位,绿通道 6 位)。

如果提供的尺寸为0,则使用窗口大小。如果非零,则当在屏幕上显示时,窗口缓冲区会被缩放以匹配窗口尺寸:

int32_t ANativeWindow_setBuffersGeometry(ANativeWindow* window, int32_t width, int32_t height, int32_t format);
  • 在执行任何绘图操作之前必须调用ANativeWindow_lock()

    int32_t ANativeWindow_lock(ANativeWindow* window, ANativeWindow_Buffer* outBuffer,
            ARect* inOutDirtyBounds);
    
  • ANativeWindow_unlockAndPost()在绘图操作完成后释放窗口,并将其发送到显示。它必须与ANativeWindow_lock()成对调用:

    int32_t ANativeWindow_unlockAndPost(ANativeWindow* window);
    
  • ANativeWindow_acquire()以 Java 方式获取指定窗口的引用,以防止潜在的删除。如果你对表面生命周期没有精细控制,这可能是有必要的:

    void ANativeWindow_acquire(ANativeWindow* window);
    
  • ANativeWindow_fromSurface() 方法将窗口与给定的 Java android.view.Surface 关联。此方法会自动获取给定表面的引用。它必须通过 ANativeWindow_release() 释放,以避免内存泄漏:

    ANativeWindow* ANativeWindow_fromSurface(JNIEnv* env, jobject surface);
    
  • ANativeWindow_release() 方法释放已获取的引用,以便释放窗口资源:

    void ANativeWindow_release(ANativeWindow* window);
    
  • 以下方法返回窗口表面的宽度、高度(以像素为单位)和格式。如果发生错误,返回值将为负。请注意,这些方法使用起来比较棘手,因为它们的行为有些不一致。在 Android 4 之前,最好锁定一次表面以获取可靠的信息(这已经由 ANativeWindow_lock() 提供了):

    int32_t ANativeWindow_getWidth(ANativeWindow* window);
    int32_t ANativeWindow_getHeight(ANativeWindow* window);
    int32_t ANativeWindow_getFormat(ANativeWindow* window);
    

现在我们知道如何绘制。但是,我们如何动画绘制的内容呢?为此需要一个关键因素:时间

原生地测量时间

那些讨论图形的人也必须讨论定时。实际上,Android 设备具有不同的功能,动画应该适应它们的速度。为了帮助我们完成这项任务,Android 通过其出色的 Posix API 支持,提供了访问时间原语的方法。

为了实验这些功能,我们将使用定时器根据时间在屏幕上移动小行星。

注意

结果项目随本书提供,名为 DroidBlaster_Part4

动手操作——使用定时器动画图形

让我们动画化游戏。

  1. 创建 jni/TimeManager.hpp 文件,并在 time.h 管理器中定义以下方法:

    • reset() 方法用于初始化管理器。

    • update() 方法用于测量游戏步进时长。

    • elapsed()elapsedTotal() 方法用于获取游戏步进时长和游戏总时长。它们将允许应用程序行为适应设备速度。

    • now() 是一个实用方法,用于重新计算当前时间。

    定义以下成员变量:

    • mFirstTimemLastTime 用于保存时间检查点,以便计算 elapsed()elapsedTotal()

    • mElapsedmElapsedTotal 用于保存计算出来的时间测量值

      #ifndef _PACKT_TIMEMANAGER_HPP_
      #define _PACKT_TIMEMANAGER_HPP_
      
      #include "Types.hpp"
      
      #include <ctime>
      
      class TimeManager {
      public:
          TimeManager();
      
          void reset();
          void update();
      
          double now();
          float elapsed() { return mElapsed; };
          float elapsedTotal() { return mElapsedTotal; };
      
      private:
          double mFirstTime;
          double mLastTime;
          float mElapsed;
          float mElapsedTotal;
      };
      #endif
      
  2. 实现 jni/TimeManager.cpp。当重置 TimeManager 时,它会保存通过 now() 方法计算出的当前时间。

    #include "Log.hpp"
    #include "TimeManager.hpp"
    
    #include <cstdlib>
    #include <time.h>
    
    TimeManager::TimeManager():
        mFirstTime(0.0f),
        mLastTime(0.0f),
        mElapsed(0.0f),
        mElapsedTotal(0.0f) {
        srand(time(NULL));
    }
    
    void TimeManager::reset() {
        Log::info("Resetting TimeManager.");
        mElapsed = 0.0f;
        mFirstTime = now();
        mLastTime = mFirstTime;
    }
    ...
    
  3. 实现 update() 方法,该方法检查:

    • 自上一帧以来的经过时间在 mElapsed

    • 自第一帧以来的经过时间在 mElapsedTotal

      注意

      注意,在处理当前时间时使用双精度类型很重要,以避免丢失精度。然后,可以将产生的延迟转换回浮点型,用于经过时间,因为两帧之间的时间差相当低。

      ...
      void TimeManager::update() {
      	double currentTime = now();
      	mElapsed = (currentTime - mLastTime);
      	mElapsedTotal = (currentTime - mFirstTime);
      	mLastTime = currentTime;
      }
      ...
      
  4. now() 方法中计算当前时间。使用 Posix 原语 clock_gettime() 来获取当前时间。单调时钟至关重要,以确保时间始终向前推进,不受系统更改的影响(例如,如果用户环游世界):

    ...
    double TimeManager::now() {
        timespec timeVal;
        clock_gettime(CLOCK_MONOTONIC, &timeVal);
        return timeVal.tv_sec + (timeVal.tv_nsec * 1.0e-9);
    }
    
  5. 创建一个新文件 jni/PhysicsManager.hpp。定义一个 PhysicsBody 结构体,用于保存小行星的位置、尺寸和速度:

    #ifndef PACKT_PHYSICSMANAGER_HPP
    #define PACKT_PHYSICSMANAGER_HPP
    
    #include "GraphicsManager.hpp"
    #include "TimeManager.hpp"
    #include "Types.hpp"
    
    struct PhysicsBody {
        PhysicsBody(Location* pLocation, int32_t pWidth, int32_t pHeight):
            location(pLocation),
            width(pWidth), height(pHeight),
            velocityX(0.0f), velocityY(0.0f) {
        }
    
        Location* location;
        int32_t width; int32_t height;
        float velocityX; float velocityY;
    };
    ...
    
  6. 定义一个基本的PhysicsManager。我们需要对TimeManager的引用,以将运动体的移动适应到时间。

    定义一个update()方法,在每个游戏步骤中移动小行星。PhysicsManagermPhysicsBodiesmPhysicsBodyCount中存储要更新的小行星:

    ...
    class PhysicsManager {
    public:
        PhysicsManager(TimeManager& pTimeManager,
                GraphicsManager& pGraphicsManager);
        ~PhysicsManager();
    
        PhysicsBody* loadBody(Location& pLocation, int32_t pWidth,
                int32_t pHeight);
        void update();
    
    private:
        TimeManager& mTimeManager;
        GraphicsManager& mGraphicsManager;
    
        PhysicsBody* mPhysicsBodies[1024]; int32_t mPhysicsBodyCount;
    };
    #endif
    
  7. 实现jni/PhysicsManager.cpp,从构造函数、析构函数和注册方法开始:

    #include "PhysicsManager.hpp"
    #include "Log.hpp"
    
    PhysicsManager::PhysicsManager(TimeManager& pTimeManager,
            GraphicsManager& pGraphicsManager) :
      mTimeManager(pTimeManager), mGraphicsManager(pGraphicsManager),
      mPhysicsBodies(), mPhysicsBodyCount(0) {
        Log::info("Creating PhysicsManager.");
    }
    
    PhysicsManager::~PhysicsManager() {
        Log::info("Destroying PhysicsManager.");
        for (int32_t i = 0; i < mPhysicsBodyCount; ++i) {
            delete mPhysicsBodies[i];
        }
    }
    
    PhysicsBody* PhysicsManager::loadBody(Location& pLocation,
            int32_t pSizeX, int32_t pSizeY) {
        PhysicsBody* body = new PhysicsBody(&pLocation, pSizeX, pSizeY);
        mPhysicsBodies[mPhysicsBodyCount++] = body;
        return body;
    }
    ...
    
  8. update()中根据它们的速度移动小行星。计算根据两个游戏步骤之间的时间量进行:

    ...
    void PhysicsManager::update() {
        float timeStep = mTimeManager.elapsed();
        for (int32_t i = 0; i < mPhysicsBodyCount; ++i) {
            PhysicsBody* body = mPhysicsBodies[i];
            body->location->x += (timeStep * body->velocityX);
            body->location->y += (timeStep * body->velocityY);
        }
    }
    
  9. 使用以下方法创建jni/Asteroid.hpp组件:

    • initialize()在游戏开始时设置具有随机属性的小行星

    • update()用于检测越出游戏边界的小行星。

    • spawn()initialize()update()两者使用,以设置一个单独的小行星

    我们还需要以下成员:

    • mBodiesmBodyCount用于存储要管理的小行星列表

    • 几个整数成员用于存储游戏边界

      #ifndef _PACKT_ASTEROID_HPP_
      #define _PACKT_ASTEROID_HPP_
      
      #include "GraphicsManager.hpp"
      #include "PhysicsManager.hpp"
      #include "TimeManager.hpp"
      #include "Types.hpp"
      
      class Asteroid {
      public:
          Asteroid(android_app* pApplication,
              TimeManager& pTimeManager, GraphicsManager& pGraphicsManager,
              PhysicsManager& pPhysicsManager);
      
          void registerAsteroid(Location& pLocation, int32_t pSizeX,
                  int32_t pSizeY);
      
          void initialize();
          void update();
      
      private:
          void spawn(PhysicsBody* pBody);
      
          TimeManager& mTimeManager;
          GraphicsManager& mGraphicsManager;
          PhysicsManager& mPhysicsManager;
      
          PhysicsBody* mBodies[1024]; int32_t mBodyCount;
          float mMinBound;
          float mUpperBound; float mLowerBound;
          float mLeftBound; float mRightBound;
      };
      #endif
      
  10. 编写jni/Asteroid.cpp的实现。从一些常量以及构造函数和注册方法开始,如下所示:

    #include "Asteroid.hpp"
    #include "Log.hpp"
    
    static const float BOUNDS_MARGIN = 128;
    static const float MIN_VELOCITY = 150.0f, VELOCITY_RANGE = 600.0f;
    
    Asteroid::Asteroid(android_app* pApplication,
            TimeManager& pTimeManager, GraphicsManager& pGraphicsManager,
            PhysicsManager& pPhysicsManager) :
        mTimeManager(pTimeManager),
        mGraphicsManager(pGraphicsManager),
        mPhysicsManager(pPhysicsManager),
        mBodies(), mBodyCount(0),
        mMinBound(0.0f),
        mUpperBound(0.0f), mLowerBound(0.0f),
        mLeftBound(0.0f), mRightBound(0.0f) {
    }
    
    void Asteroid::registerAsteroid(Location& pLocation,
            int32_t pSizeX, int32_t pSizeY) {
        mBodies[mBodyCount++] = mPhysicsManager.loadBody(pLocation,
                pSizeX, pSizeY);
    }
    ...
    
  11. initialize()中设置边界。小行星在屏幕顶部以上生成(在mMinBound中,最大边界mUpperBound是屏幕高度的兩倍)。它们从屏幕顶部移动到底部。其他边界对应于边缘带有边距的屏幕(代表小行星大小的两倍)。

    然后,使用spawn()初始化所有小行星:

    ...
    void Asteroid::initialize() {
        mMinBound = mGraphicsManager.getRenderHeight();
        mUpperBound = mMinBound * 2;
        mLowerBound = -BOUNDS_MARGIN;
        mLeftBound = -BOUNDS_MARGIN;
        mRightBound = (mGraphicsManager.getRenderWidth() + BOUNDS_MARGIN);
    
        for (int32_t i = 0; i < mBodyCount; ++i) {
            spawn(mBodies[i]);
        }
    }
    ...
    
  12. 在每个游戏步骤中,检查越界的小行星并重新初始化它们:

    ...
    void Asteroid::update() {
        for (int32_t i = 0; i < mBodyCount; ++i) {
            PhysicsBody* body = mBodies[i];
            if ((body->location->x < mLeftBound)
             || (body->location->x > mRightBound)
             || (body->location->y < mLowerBound)
             || (body->location->y > mUpperBound)) {
                spawn(body);
            }
        }
    }
    ...
    
  13. 最后,在spawn()中根据生成的随机速度和位置初始化每个小行星:

    ...
    void Asteroid::spawn(PhysicsBody* pBody) {
        float velocity = -(RAND(VELOCITY_RANGE) + MIN_VELOCITY);
        float posX = RAND(mGraphicsManager.getRenderWidth());
        float posY = RAND(mGraphicsManager.getRenderHeight())
                      + mGraphicsManager.getRenderHeight();
    
        pBody->velocityX = 0.0f;
        pBody->velocityY = velocity;
        pBody->location->x = posX;
        pBody->location->y = posY;
    }
    
  14. 将新创建的管理器和组件添加到jni/DroidBlaster.hpp中:

    #ifndef _PACKT_DROIDBLASTER_HPP_
    #define _PACKT_DROIDBLASTER_HPP_
    
    #include "ActivityHandler.hpp"
    #include "Asteroid.hpp"
    #include "EventLoop.hpp"
    #include "GraphicsManager.hpp"
    #include "PhysicsManager.hpp"
    #include "Ship.hpp"
    #include "TimeManager.hpp"
    #include "Types.hpp"
    
    class DroidBlaster : public ActivityHandler {
        ...
    private:
        TimeManager     mTimeManager;
        GraphicsManager mGraphicsManager;
        PhysicsManager  mPhysicsManager;
        EventLoop mEventLoop;
    
        Asteroid mAsteroids;
        Ship mShip;
    };
    #endif
    
  15. jni/DroidBlaster.cpp构造函数中,使用GraphicsManagerPhysicsManager注册小行星:

    ...
    static const int32_t SHIP_SIZE = 64;
    static const int32_t ASTEROID_COUNT = 16;
    static const int32_t ASTEROID_SIZE = 64;
    
    DroidBlaster::DroidBlaster(android_app* pApplication):
        mTimeManager(),
        mGraphicsManager(pApplication),
        mPhysicsManager(mTimeManager, mGraphicsManager),
        mEventLoop(pApplication, *this),
    
        mAsteroids(pApplication, mTimeManager, mGraphicsManager,
     mPhysicsManager),
        mShip(pApplication, mGraphicsManager) {
        Log::info("Creating DroidBlaster");
    
        GraphicsElement* shipGraphics = mGraphicsManager.registerElement(
                SHIP_SIZE, SHIP_SIZE);
        mShip.registerShip(shipGraphics);
    
        for (int32_t i = 0; i < ASTEROID_COUNT; ++i) {
     GraphicsElement* asteroidGraphics =
     mGraphicsManager.registerElement(ASTEROID_SIZE,
     ASTEROID_SIZE);
     mAsteroids.registerAsteroid(
     asteroidGraphics->location, ASTEROID_SIZE,
     ASTEROID_SIZE);
        }
    }
    ...
    
  16. onActivate()中适当地初始化新添加的类:

    ...
    status DroidBlaster::onActivate() {
        Log::info("Activating DroidBlaster");
    
        if (mGraphicsManager.start() != STATUS_OK) return STATUS_KO;
    
        mAsteroids.initialize();
        mShip.initialize();
    
        mTimeManager.reset();
        return STATUS_OK;
    }
    ...
    Finally, update managers and components for each game step:
    ...
    status DroidBlaster::onStep() {
        mTimeManager.update();
        mPhysicsManager.update();
    
        mAsteroids.update();
    
        return mGraphicsManager.update();
    }
    ...
    

刚才发生了什么?

编译并运行应用程序。这次它应该会有些动画效果!代表小行星的红色方块以恒定的节奏穿过屏幕。TimeManger有助于设置这个节奏。

刚才发生了什么?

定时器对于以正确速度显示动画和移动至关重要。它们可以通过 POSIX 方法clock_gettime()实现,该方法以高精度获取时间,理论上可以达到纳秒级。

在本教程中,我们使用了CLOCK_MONOTONIC标志来设置定时器。单调时钟提供了一个从过去任意时间点开始的经过的时钟时间。它不受系统日期变更的影响,因此不会像其他选项那样回到过去。CLOCK_MONOTONIC的缺点是它是系统特定的,并且不保证支持。幸运的是,Android 支持它,但当将 Android 代码移植到其他平台时,应该注意。另一个特定于 Android 需要注意的点是,当系统挂起时,单调时钟会停止。

另一个选择,不那么精确,且受系统时间变化(这可能是可取的或不可取的)的影响,是gettimeofday(),它同样在ctime中提供。用法相似,但精度是微秒而不是纳秒。以下可能是一个可以替换TimeManager中当前now()实现的用法示例:

double TimeManager::now() {
    timeval lTimeVal;
    gettimeofday(&lTimeVal, NULL);
    return (lTimeVal.tv_sec * 1000.0) + (lTimeVal.tv_usec / 1000.0);
}

想了解更多信息,请查看man7.org/linux/man-pages/man2/clock_gettime.2.html的 Man 页面。

概括

Android NDK 使我们能够编写完全本地化的应用程序,而无需一行 Java 代码。NativeActivity提供了一个框架,以实现处理应用程序事件的事件循环。结合 Posix 时间管理 API,NDK 提供了构建复杂多媒体应用程序或游戏所需的基础。

总结一下,我们创建了NativeActivity来轮询活动事件,以便相应地启动或停止本地代码。我们原生地访问显示窗口,就像位图一样,以显示原始图形。最后,我们获取了时间,使应用程序能够使用单调时钟适应设备速度。

这里启动的基本框架将作为我们将在本书中开发的 2D/3D 游戏的基础。然而,尽管现在的扁平化设计很流行,但我们需要的不仅仅是红色的方块!

在下一章中,我们将了解如何使用 OpenGL ES 2 为 Android 渲染高级图形。

第六章:使用 OpenGL ES 渲染图形

面对现实,Android NDK 的主要兴趣之一是编写多媒体应用程序和游戏。实际上,这些程序消耗大量资源并需要响应性。这就是为什么在 Android NDK 中最早可用的 API(直到最近几乎也是唯一的一个)是图形 API:嵌入式系统开放图形库(简称 OpenGL ES)。

OpenGL 是由硅谷图形公司创建的一个标准 API,现在由 Khronos Group 管理(见www.khronos.org/)。OpenGL 为所有桌面上的标准 GPU图形处理单元,如你的显卡等)提供了一个通用接口。OpenGL ES 是在许多嵌入式平台上可用的衍生 API,例如 Android 或 iOS。它是编写可移植和高效图形代码的最佳选择。OpenGL 可以渲染 2D 和 3D 图形。

目前 Android 支持的 OpenGL ES 有三个主要版本:

  • OpenGL ES 1.0 和 1.1 在所有 Android 设备上得到支持(除了 1.1,只在一些非常旧的设备上支持)。它提供了一个老式的图形 API,具有固定管线(即,一组可配置的操作,用于转换和渲染几何体)。规范没有完全实现,但大多数功能是可用的。这仍然是简单 2D 或 3D 图形或移植旧版 OpenGL 代码的好选择。

  • 现在,几乎所有的手机,即使是较旧的型号,从 API 级别 8 开始都支持 OpenGL ES 2。它用现代的可编程管线替换了固定管线,包括顶点片段着色器。它稍微复杂一些,但功能也更强大。对于更复杂的 2D 或 3D 游戏,这是一个不错的选择,同时仍然保持非常好的兼容性。请注意,OpenGL ES 1.X 通常会在幕后由 OpenGL 2 的实现进行模拟。*

  • OpenGL ES 3.0 从 API 级别 18 开始在现代设备上可用,而 OpenGL ES 3.1 则从 API 级别 21 开始可用(不过,这些 API 级别上的并非所有设备都支持它)。它们为 GLES 2 带来了一系列新改进(纹理压缩作为标准特性,遮挡查询、实例渲染等属于 3.0,计算着色器间接绘制命令等属于 3.1),并与桌面版 OpenGL 的兼容性更好。它与 OpenGL ES 2 向后兼容。

本章将教你如何使用 OpenGL ES 2 创建一些基本的 2D 图形。更具体地说,你将了解到如何:

  • 初始化 OpenGL ES

  • 从资产中打包的 PNG 文件加载纹理

  • 使用顶点和片段着色器绘制精灵

  • 渲染粒子效果

  • 适应各种分辨率图形

由于 OpenGL ES 以及一般的图形是一个广泛的课题,本章仅涵盖入门的基础知识。

初始化 OpenGL ES

创建出色的 2D 和 3D 图形的第一步是初始化 OpenGL ES。尽管不是特别复杂,但这项任务需要一些样板代码将渲染上下文绑定到 Android 窗口。这些部分在嵌入式系统图形库EGL)的帮助下粘合在一起,它是 OpenGL ES 的伴随 API。

在本节的第一部分,我们将用 OpenGL ES 替换前一章中实现的原始绘图系统。黑色到白色的渐变效果将展示 EGL 初始化是正常工作的。

注意

本书提供的项目结果名为DroidBlaster_Part5

动手操作——初始化 OpenGL ES

让我们重写GraphicsManager以初始化 OpenGL ES 上下文:

  1. 通过执行以下操作来修改jni/GraphicsManager.hpp

    • 包含EGL/egl.h以将 OpenGL ES 绑定到 Android 平台,以及GLES2/gl2.h以渲染图形。

    • 添加一个stop()方法,在离开活动时解绑 OpenGL 渲染上下文并释放图形资源。

    • 定义EGLDisplayEGLSurfaceEGLContext成员变量,这些变量表示对系统资源的句柄,如下所示:

      ...
      #include "Types.hpp"
      
      #include <android_native_app_glue.h>
      #include <GLES2/gl2.h>
      #include <EGL/egl.h>
      ...
      
      class GraphicsManager {
      public:
          ...
          status start();
          void stop();
          status update();
      
      private:
          ...
          int32_t mRenderWidth; int32_t mRenderHeight;
          EGLDisplay mDisplay; EGLSurface mSurface; EGLContext mContext;
      
          GraphicsElement* mElements[1024]; int32_t mElementCount;
      };
      #endif
      
  2. 通过用基于 OpenGL 的代码替换jni/GraphicsManager.cpp中基于 Android 原始图形 API 的先前代码来重新实现它。首先,在构造函数初始化列表中添加新成员:

    #include "GraphicsManager.hpp"
    #include "Log.hpp"
    
    GraphicsManager::GraphicsManager(android_app* pApplication) :
        mApplication(pApplication),
        mRenderWidth(0), mRenderHeight(0),
        mDisplay(EGL_NO_DISPLAY), mSurface(EGL_NO_CONTEXT),
     mContext(EGL_NO_SURFACE),
        mElements(), mElementCount(0) {
        Log::info("Creating GraphicsManager.");
    }
    ...
    
  3. start()方法中必须完成繁重的工作:

    • 首先,声明一些变量。注意 EGL 如何定义自己的类型,并用EGLintEGLBoolean重新声明基本类型,以支持平台独立性。

    • 然后,在常量属性列表中定义所需的 OpenGL 配置。这里,我们想要 OpenGL ES 2 和一个 16 位表面(红色 5 位,绿色 6 位,蓝色 5 位)。我们也可以选择 32 位表面以获得更好的色彩保真度(但在某些设备上性能较差)。属性列表以EGL_NONE结束符结束:

      ...
      status GraphicsManager::start() {
          Log::info("Starting GraphicsManager.");
          EGLint format, numConfigs, errorResult; GLenum status;
          EGLConfig config;
          // Defines display requirements. 16bits mode here.
          const EGLint DISPLAY_ATTRIBS[] = {
              EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT,
              EGL_BLUE_SIZE, 5, EGL_GREEN_SIZE, 6, EGL_RED_SIZE, 5,
              EGL_SURFACE_TYPE, EGL_WINDOW_BIT,
              EGL_NONE
          };
          // Request an OpenGL ES 2 context.
          const EGLint CONTEXT_ATTRIBS[] = {
              EGL_CONTEXT_CLIENT_VERSION, 2, EGL_NONE
          };
      ...
      
  4. 使用eglGetDisplay()eglInitialize()连接到默认显示,即 Android 主窗口。然后,使用eglChooseConfig()找到合适的帧缓冲区配置(OpenGL 术语,指的是渲染表面,可能还包括其他缓冲区,如Z 缓冲区模板缓冲区)。配置根据请求的属性进行选择:

    ...
        mDisplay = eglGetDisplay(EGL_DEFAULT_DISPLAY);
        if (mDisplay == EGL_NO_DISPLAY) goto ERROR;
        if (!eglInitialize(mDisplay, NULL, NULL)) goto ERROR;
    
        if(!eglChooseConfig(mDisplay, DISPLAY_ATTRIBS, &config, 1,
            &numConfigs) || (numConfigs <= 0)) goto ERROR;
    ...
    
  5. 使用选择的配置(通过eglGetConfigAttrib()获取)重新配置 Android 窗口。这个操作是 Android 特定的,使用 Android ANativeWindow API 执行。

    之后,使用先前选择的显示和配置创建显示表面和 OpenGL 上下文。上下文包含与 OpenGL 状态相关的所有数据(启用的设置,禁用的设置等):

    ...
        if (!eglGetConfigAttrib(mDisplay, config,
            EGL_NATIVE_VISUAL_ID, &format)) goto ERROR;
        ANativeWindow_setBuffersGeometry(mApplication->window, 0, 0,
            format);
    
        mSurface = eglCreateWindowSurface(mDisplay, config,
            mApplication->window, NULL);
        if (mSurface == EGL_NO_SURFACE) goto ERROR;
        mContext = eglCreateContext(mDisplay, config, NULL,
            CONTEXT_ATTRIBS);
        if (mContext == EGL_NO_CONTEXT) goto ERROR;
    ...
    
  6. 使用eglMakeCurrent()激活创建的渲染上下文。最后,根据使用eglQuerySurface()获取的表面属性定义显示视口。Z 缓冲区不需要,可以禁用:

    ...
        if (!eglMakeCurrent(mDisplay, mSurface, mSurface, mContext)
       || !eglQuerySurface(mDisplay, mSurface, EGL_WIDTH, &mRenderWidth)
       || !eglQuerySurface(mDisplay, mSurface, EGL_HEIGHT, &mRenderHeight)
       || (mRenderWidth <= 0) || (mRenderHeight <= 0)) goto ERROR;
    
        glViewport(0, 0, mRenderWidth, mRenderHeight);
        glDisable(GL_DEPTH_TEST);
        return STATUS_OK;
    
    ERROR:
        Log::error("Error while starting GraphicsManager");
        stop();
        return STATUS_KO;
    }
    ...
    
  7. 当应用程序停止运行时,将应用程序从 Android 窗口解绑并释放 EGL 资源:

    ...
    void GraphicsManager::stop() {
        Log::info("Stopping GraphicsManager.");
    
        // Destroys OpenGL context.
        if (mDisplay != EGL_NO_DISPLAY) {
            eglMakeCurrent(mDisplay, EGL_NO_SURFACE, EGL_NO_SURFACE,
                           EGL_NO_CONTEXT);
            if (mContext != EGL_NO_CONTEXT) {
                eglDestroyContext(mDisplay, mContext);
                mContext = EGL_NO_CONTEXT;
            }
            if (mSurface != EGL_NO_SURFACE) {
                eglDestroySurface(mDisplay, mSurface);
                mSurface = EGL_NO_SURFACE;
            }
            eglTerminate(mDisplay);
            mDisplay = EGL_NO_DISPLAY;
        }
    }
    ...
    

刚才发生了什么?

我们已经初始化并将 OpenGL ES 和 Android 原生窗口系统通过 EGL 连接在一起。得益于这个 API,我们已经查询了一个符合我们预期的显示配置,并创建了一个 Framebuffer 来渲染我们的场景。EGL 是由 Khronos 组(如 OpenGL)指定的标准 API。平台通常实现自己的变体(例如 iOS 上的 EAGL 等),因此显示窗口初始化仍然与操作系统相关。因此,实际上可移植性相当有限。

此初始化过程将创建一个 OpenGL 上下文,这是启用 OpenGL 图形管道的第一步。应特别注意 OpenGL 上下文,在 Android 上它们经常丢失:当你离开或返回主屏幕时,当接到电话时,设备进入休眠状态时,当你切换到另一个应用程序时等等。由于丢失的上下文将无法使用,因此尽快释放图形资源非常重要。

提示

OpenGL ES 规范支持为单一显示表面创建多个上下文。这允许将渲染操作分配给线程或渲染到多个窗口。然而,这在 Android 硬件上支持不佳,应避免使用。

OpenGL ES 现已初始化,但除非我们开始在显示屏幕上渲染一些图形,否则不会显示任何内容。

行动时间——清除和交换缓冲区

让我们用颜色从黑变白的方式来清除显示缓冲区:

  1. jni/GraphicsManager.cpp中,每次更新步骤时使用eglSwapBuffers()刷新屏幕。

    为了提供视觉反馈,在用glClear()清除 Framebuffer 之前,借助glClearColor()逐渐改变显示背景色:

    ...
    status GraphicsManager::update() {
        static float clearColor = 0.0f;
        clearColor += 0.001f;
        glClearColor(clearColor, clearColor, clearColor, 1.0f);
        glClear(GL_COLOR_BUFFER_BIT);
    
        if (eglSwapBuffers(mDisplay, mSurface) != EGL_TRUE) {
            Log::error("Error %d swapping buffers.", eglGetError());
            return STATUS_KO;
        } else {
            return STATUS_OK;
        }
    }
    
  2. 更新Android.mk文件以链接EGLGLESv2库:

    LOCAL_PATH := $(call my-dir)
    
    include $(CLEAR_VARS)
    
    LS_CPP=$(subst $(1)/,,$(wildcard $(1)/*.cpp))
    LOCAL_MODULE := droidblaster
    LOCAL_SRC_FILES := $(call LS_CPP,$(LOCAL_PATH))
    LOCAL_LDLIBS := -landroid -llog -lEGL -lGLESv2
    LOCAL_STATIC_LIBRARIES := android_native_app_glue
    
    include $(BUILD_SHARED_LIBRARY)
    
    $(call import-module,android/native_app_glue)
    

刚才发生了什么?

启动应用程序。如果一切正常,你的设备屏幕将从黑色逐渐过渡到白色。与之前章节中看到的用原始memset()清除显示或逐个设置像素点相比,我们现在调用高效的 OpenGL ES 绘图原语。请注意,该效果仅在应用程序首次启动时出现,因为清除颜色存储在一个静态变量中。要使其再次出现,请杀死应用程序并重新启动。

渲染场景需要清除 framebuffer 并交换显示缓冲区。后者操作是在调用eglSwapBuffers()时触发的。在 Android 上,交换与屏幕刷新率同步以避免图像撕裂;这是一个VSync。刷新率根据设备而异。一个常见的值是 60 Hz,但有些设备有不同的刷新率。

在内部,渲染是在后台缓冲区执行的,该缓冲区与显示给用户的前台缓冲区交换。前台缓冲区变成后台缓冲区,反之亦然(指针交换)。这种技术通常称为页面翻转。根据驱动程序实现,交换链可以扩展到第三个缓冲区。在这种情况下,我们称之为三重缓冲

我们的 OpenGL 管道现在已经正确初始化,能够显示屏幕上的图形。然而,你可能会发现“管道”这个概念还有点模糊。让我们看看它背后隐藏了什么。

对 OpenGL 管道的深入理解

我们之所以称之为管道,是因为图形数据经过一系列步骤进行转换。以下图表展示了 OpenGL ES 2 管道的简化表示:

对 OpenGL 管道的深入理解

  • 顶点处理:作为顶点缓冲对象顶点数组输入的顶点网格,在顶点着色器中逐个进行转换。顶点着色器可以移动或旋转单个顶点,将它们投影到屏幕上,调整纹理坐标,计算光照等。它会生成一个输出顶点,该顶点可以在管道中进一步处理。

  • 图元装配:单独的顶点被连接成三角形、点、线等。当发送绘制调用时,客户端代码指定了更多的连接信息。它可以是索引缓冲区(每个索引通过其等级指向一个顶点)或预定义的规则,如剥离或扇形连接。如背面剔除剪辑等转换在此阶段进行。

  • 光栅化:图元被插值成片段,片段是包含与一个像素渲染相关的所有数据(如颜色、法线等)的术语。一个片段关联一个像素。这些片段为片段着色器提供数据。

  • 片段处理:片段着色器是一个处理每个片段以计算要显示的像素的程序。这是应用纹理映射的阶段,使用顶点着色器计算并由光栅化器插值的坐标。可以计算不同的着色算法以渲染特定效果(例如,卡通着色)。

  • 像素处理:片段着色器输出的像素必须与现有的帧缓冲区(渲染表面)合并,其中一些像素可能已经被绘制。在此阶段应用透明效果或混合。

顶点和片段着色器可以用GL 着色语言GLSL)进行编程。它们仅在 OpenGL ES 2 和 3 中可用。OpenGL ES 1 提供了一个固定功能的管道,具有预定义的可能转换集合。

这只是 OpenGL 渲染管线处理流程的简要概述。要了解更多信息,请查看 OpenGL.org 维基页面 www.opengl.org/wiki/Rendering_Pipeline_Overview

使用资源管理器加载纹理

我猜你需要的不仅仅是改变屏幕颜色!但在我们应用程序中展示出色的图形之前,我们需要加载一些外部资源。

在第二部分,我们将通过 Android 资源管理器加载纹理到 OpenGL ES 中,这是从 NDK R5 开始提供的 API。它允许程序员访问项目 assets 文件夹中存储的任何资源。这些资源在应用程序编译期间被打包到最终的 APK 存档中。资源被视为原始二进制文件,你的应用程序需要通过相对于 assets 文件夹的文件名来解释和访问(一个文件 assets/mydir/myfile 可以通过 mydir/myfile 路径访问)。文件以只读模式提供,可能会被压缩。

如果你已经写过一些 Java Android 应用程序,那么你就会知道 Android 也通过编译时在 res 项目文件夹中生成的 ID 提供资源访问。这在 Android NDK 上并不直接可用。除非你准备使用 JNI 桥接,否则资源是打包 APK 中资源的唯一方式。

现在我们将加载一种现今最流行的图片格式之一,便携式网络图形PNG)编码的纹理。为此,我们将在 NDK 模块中集成 libpng

注意事项

最终的项目以 DroidBlaster_Part6 的名称随本书提供。

动手操作——使用资源管理器读取资源

让我们创建一个类来读取 Android 资源文件:

  1. 创建 jni/Resource.hpp 来封装对资源文件的访问。我们将使用在 android/asset_manager.hpp 中定义的 AAsset API(该 API 已包含在 android_native_app_glue.h 中)。

    声明三个主要操作:open()close()read()。我们还需要在 getPath() 中获取资源的路径。

    Android 资源管理 API 的入口点是一个不透明的 AAsetManager 结构。我们可以从它那里访问代表资源文件的第二个不透明结构 AAsset

    #ifndef _PACKT_RESOURCE_HPP_
    #define _PACKT_RESOURCE_HPP_
    
    #include "Types.hpp"
    
    #include <android_native_app_glue.h>
    
    class Resource {
    public:
        Resource(android_app* pApplication, const char* pPath);
    
        const char* getPath() { return mPath; };
    
        status open();
        void close();
        status read(void* pBuffer, size_t pCount);
    
        bool operator==(const Resource& pOther);
    
    private:
        const char* mPath;
        AAssetManager* mAssetManager;
        AAsset* mAsset;
    };
    
    #endif
    
  2. jni/Resource.cpp 中实现 Resource 类。

    资源管理器由 Native App Glue 模块在其 android_app->activity 结构中提供:

    #include "Resource.hpp"
    
    #include <sys/stat.h>
    
    Resource::Resource(android_app* pApplication, const char* pPath):
        mPath(pPath),
        mAssetManager(pApplication->activity->assetManager),
        mAsset(NULL) {
    }
    ...
    
  3. 资源管理器通过 AassetManager_open() 打开资源。这是此方法的唯一职责,除了列出文件夹。我们使用默认的打开模式 AASSET_MODE_UNKNOWN(关于这一点稍后会详细介绍):

    ...
    status Resource::open() {
        mAsset = AAssetManager_open(mAssetManager, mPath,
                                    AASSET_MODE_UNKNOWN);
        return (mAsset != NULL) ? STATUS_OK : STATUS_KO;
    }
    ...
    
  4. 与经典应用程序中的文件一样,使用完毕后必须通过 AAsset_close() 关闭打开的资源,以便释放系统分配的任何资源:

    ...
    void Resource::close() {
        if (mAsset != NULL) {
            AAsset_close(mAsset);
            mAsset = NULL;
        }
    }
    ...
    
  5. 最后,代码使用AAsset_read()在资源文件上操作以读取数据。这与标准的 Posix 文件 API 非常相似。在这里,我们尝试在内存缓冲区中读取pCount数据,并获取实际读取的数据量(如果我们到达资源的末尾):

    ...
    status Resource::read(void* pBuffer, size_t pCount) {
        int32_t readCount = AAsset_read(mAsset, pBuffer, pCount);
        return (readCount == pCount) ? STATUS_OK : STATUS_KO;
    }
    
    bool Resource::operator==(const Resource& pOther) {
        return !strcmp(mPath, pOther.mPath);
    }
    

刚才发生了什么?

我们已经了解了如何调用 Android Asset API 来读取存储在assets目录中的文件。Android 资源是只读的,应该只用于保存静态资源。Android Asset API 在android/assert_manager.h包含文件中定义。

关于 Asset Manager API 的更多信息

Android 资源管理器提供了一组小方法来访问目录:

  • AAssetManager_openDir()提供了探索资源目录的可能性。与AAssetDir_getNextFileName()AAssetDir_rewind()结合使用。打开的目录必须使用AAssetDir_close()关闭:

    AAssetDir* AAssetManager_openDir(AAssetManager* mgr,
                                     const char* dirName);
    
  • AAssetDir_getNextFileName()列出指定资源目录中所有可用的文件。每次调用它时都会返回一个文件名,或者当所有文件都列出时返回NULL

    const char* AAssetDir_getNextFileName(AAssetDir* assetDir);
    
  • AAssetDir_rewind()提供了可能从文件迭代过程的开头使用AAssetDir_getNextFileName()重新开始迭代过程的功能:

    void AAssetDir_rewind(AAssetDir* assetDir);
    
  • AAssetDir_close()释放打开目录时分配的所有资源。这个方法必须与AAssetManager_openDir()成对调用:

    void AAssetDir_close(AAssetDir* assetDir);
    

文件可以使用类似于 POSIX 文件 API 的 API 打开:

  • AAssetManager_open()打开一个资源文件以读取其内容,将其内容作为缓冲区获取,或访问其文件描述符。打开的资源必须使用AAsset_close()关闭:

    AAsset* AAssetManager_open(AAssetManager* mgr,
                               const char* filename, int mode);
    
  • AAsset_read()尝试在提供的缓冲区中读取请求的字节数。实际读取的字节数将被返回,如果发生错误则返回负值:

    int AAsset_read(AAsset* asset, void* buf, size_t count);
    
  • AAsset_seek()直接跳转到文件中指定的偏移量,忽略之前的数据:

    off_t AAsset_seek(AAsset* asset, off_t offset, int whence);
    
  • AAsset_close()关闭资源,并释放打开文件时分配的所有资源。这个方法必须与AAssetManager_open()成对调用:

    void AAsset_close(AAsset* asset);
    
  • AAsset_getBuffer()返回一个指向包含整个资源内容的内存缓冲区的指针,如果出现问题则返回NULL。该缓冲区可能是内存映射的。请注意,因为 Android 会压缩某些资源(取决于它们的扩展名),所以缓冲区可能不是直接可读的:

    const void* AAsset_getBuffer(AAsset* asset);
    
  • AAsset_getLength()给出资源的总大小(以字节为单位)。在读取资源之前,这个方法可能有助于预分配正确大小的缓冲区:

    off_t AAsset_getLength(AAsset* asset);
    
  • Aasset_getRemainingLength()AAsset_getLength()类似,不同之处在于它考虑了已经读取的字节数:

    off_t AAsset_getRemainingLength(AAsset* asset);
    
  • AAsset_openFileDescriptor()返回一个原始的 Unix 文件描述符。这在 OpenSL 中用于读取音乐文件:

    int AAsset_openFileDescriptor(AAsset* asset, off_t* outStart, off_t* outLength);
    
  • AAsset_isAllocated()指示资源返回的缓冲区是否是内存映射的:

    int AAsset_isAllocated(AAsset* asset);
    

我们将在后续章节中详细介绍这些方法。

打开资源文件的可用模式有:

  • AASSET_MODE_BUFFER:这有助于执行快速的小读取

  • AASSET_MODE_RANDOM:这有助于向前和向后读取数据块

  • AASSET_MODE_STREAMING:这有助于顺序读取数据,偶尔向前搜索

  • AASSET_MODE_UNKNOWN:这有助于保持系统默认设置

大多数情况下AASSET_MODE_UNKNOWN会是正确的选择。

提示

安装大型的 APK 可能会出现问题,即使它们部署在 SD 卡上(参见 Android 清单中的installLocation选项)。因此,处理大量兆字节的资源的良好策略是只将必要的资源放在 APK 中。在运行时将剩余的文件下载到 SD 卡,或者将它们包装在第二个 APK 中。

既然我们已经有了要读取的 PNG 资源文件,那么我们使用libpng来加载它们。

行动时间——编译并嵌入 libpng 模块

让我们在 DroidBlaster 中从 PNG 文件加载一个 OpenGL 纹理。

  1. 访问网站www.libpng.org/pub/png/libpng.html,下载libpng源代码包(本书中是版本 1.6.10)。

    注意

    本书中提供了原始的libpng 1.6.10 存档,位于Libraries/libpng文件夹中。

    $ANDROID_NDK/sources/内创建一个名为libpng的文件夹。将libpng包中的所有文件移动到这个文件夹中。

    将文件libpng/scripts/pnglibconf.h.prebuilt复制到根文件夹libpng中的其他源文件中。将其重命名为pnglibconf.h

    注意

    文件夹$ANDROID_NDK/sources是一个特殊的文件夹,默认情况下被认为是模块文件夹。它包含可重用的库。更多信息请参见第九章,将现有库移植到 Android

  2. 使用以下代码中给定的内容编写$ANDROID_NDK/sources/libpng/Android.mk文件:

    LOCAL_PATH:= $(call my-dir)
    
    include $(CLEAR_VARS)
    
    LS_C=$(subst $(1)/,,$(wildcard $(1)/*.c))
    
    LOCAL_MODULE := png
    LOCAL_SRC_FILES := \
        $(filter-out example.c pngtest.c,$(call LS_C,$(LOCAL_PATH)))
    LOCAL_EXPORT_C_INCLUDES := $(LOCAL_PATH)
    LOCAL_EXPORT_LDLIBS := -lz
    
    include $(BUILD_STATIC_LIBRARY)
    
  3. 现在,打开DroidBlaster目录中的jni/Android.mk

    使用LOCAL_STATIC_LIBRARIESimport-module指令链接和导入libpng。这与我们对 Native App Glue 模块所做的类似:

    LOCAL_PATH := $(call my-dir)
    
    include $(CLEAR_VARS)
    
    LS_CPP=$(subst $(1)/,,$(wildcard $(1)/*.cpp))
    LOCAL_MODULE := droidblaster
    LOCAL_SRC_FILES := $(call LS_CPP,$(LOCAL_PATH))
    LOCAL_LDLIBS := -landroid -llog -lEGL -lGLESv2
    LOCAL_STATIC_LIBRARIES := android_native_app_glue png
    
    include $(BUILD_SHARED_LIBRARY)
    
    $(call import-module,android/native_app_glue)
    $(call import-module,libpng)
    
    

刚才发生了什么?

在上一章中,我们嵌入了现有的 Native App Glue 模块以创建一个完全本地的应用程序。这次我们创建了自己的第一个本地可重用模块来集成libpng。通过编译DroidBlaster确保它能正常工作。如果你查看libpng源文件的控制台视图,它应该为每个目标平台编译。请注意,NDK 提供增量编译,并且不会重新编译已经编译的源文件:

刚才发生了什么?

本地库模块(在这里是libpng)在位于其自身目录根部的 Makefile 中定义。然后它从另一个 Makefile 模块引用,通常是应用程序模块(在这里是Droidblaster)。

在这里,libpng库的 Makefile 通过自定义宏LS_C选择所有的 C 文件。这个宏是从LOCAL_SRC_FILES指令中调用的。我们使用标准的“Make”函数filter-out()排除了example.cpngtest.c,它们只是测试文件。

所有的先决条件包含文件都通过LOCAL_EXPORT_C_INCLUDES指令提供给客户端模块,该指令指向这里的源目录LOCAL_PATH。像libzip(选项-lz)这样的先决条件库也通过LOCAL_EXPORT_LDLIBS指令提供给客户端模块。所有包含_EXPORT_术语的指令都会将指令附加到客户端模块自身的指令中。

有关 Makefiles、指令和标准函数的更多信息,请查看第九章,将现有库移植到 Android

行动时间——加载 PNG 图像

既然libpng已经编译完成,让我们用它来读取一个真正的 PNG 文件:

  1. 编辑jni/GraphicsManager.hpp并包含Resource头文件。

    创建一个名为TextureProperties的新结构,包含以下内容:

    • 表示纹理资源的资源

    • 一个 OpenGL 纹理标识符(这是一种句柄)

    • 宽度和高度

      ...
      #include "Resource.hpp"
      #include "Types.hpp"
      ...
      
      struct TextureProperties {
          Resource* textureResource;
          GLuint texture;
          int32_t width;
          int32_t height;
      };
      ...
      
  2. GraphicsManager追加一个loadTexture()方法,以读取 PNG 并将其加载到 OpenGL 纹理中。

    纹理保存在mTextures数组中以便缓存和最终确定。

    ...
    class GraphicsManager {
    public:
        ...
        status start();
        void stop();
        status update();
    
        TextureProperties* loadTexture(Resource& pResource);
    
    private:
        ...
        int32_t mRenderWidth; int32_t mRenderHeight;
        EGLDisplay mDisplay; EGLSurface mSurface; EGLContext mContext;
    
        TextureProperties mTextures[32]; int32_t mTextureCount;
        GraphicsElement* mElements[1024]; int32_t mElementCount;
    };
    #endif
    
  3. 编辑jni/GraphicsManager.cpp以包含名为png.h的新头文件,并更新构造函数初始化列表:

    #include "GraphicsManager.hpp"
    #include "Log.hpp"
    
    #include <png.h>
    
    GraphicsManager::GraphicsManager(android_app* pApplication) :
        mApplication(pApplication),
        mRenderWidth(0), mRenderHeight(0),
        mDisplay(EGL_NO_DISPLAY), mSurface(EGL_NO_CONTEXT),
        mContext(EGL_NO_SURFACE),
     mTextures(), mTextureCount(0),
        mElements(), mElementCount(0) {
        Log::info("Creating GraphicsManager.");
    }
    ...
    
  4. GraphicsManager停止使用glDeleteTetxures()时,释放与纹理相关的资源。这个函数可以一次删除多个纹理,这就是为什么此方法预期是一个序数和一个数组的原因。但在这里我们不会使用这种可能性:

    ...
    void GraphicsManager::stop() {
        Log::info("Stopping GraphicsManager.");
        for (int32_t i = 0; i < mTextureCount; ++i) {
            glDeleteTextures(1, &mTextures[i].texture);
        }
        mTextureCount = 0;
    
        // Destroys OpenGL context.
        if (mDisplay != EGL_NO_DISPLAY) {
            ...
        }
    }
    ...
    
  5. 为了完全独立于数据源,libpng提供了一个机制来整合自定义读取操作。这通过回调的形式,将请求的数据量读取到由libpng提供的缓冲区中。

    结合 Android Asset API 实现此回调,以访问应用资产的读取数据。资产文件通过png_get_io_ptr()给出的Resource实例作为非类型指针进行读取。这个指针将在设置回调函数时(使用png_set_read_fn())由我们提供。我们将在下一步中看到如何执行此操作:

    ...
    void callback_readPng(png_structp pStruct,
        png_bytep pData, png_size_t pSize) {
        Resource* resource = ((Resource*) png_get_io_ptr(pStruct));
        if (resource->read(pData, pSize) != STATUS_OK) {
            resource->close();
        }
    }
    ...
    
  6. 实现loadTexture()。首先,在缓存中查找texture。纹理在内存和性能方面开销很大,应该谨慎管理(与所有 OpenGL 资源一样):

    ...
    TextureProperties* GraphicsManager::loadTexture(Resource& pResource) {
        for (int32_t i = 0; i < mTextureCount; ++i) {
            if (pResource == *mTextures[i].textureResource) {
                Log::info("Found %s in cache", pResource.getPath());
                return &mTextures[i];
            }
        }
    ...
    
  7. 如果你无法在缓存中找到纹理,那么让我们读取它。首先定义一些读取 PNG 文件所需的变量。

    然后,使用AAsset API 打开图像,并检查图像签名(文件的前 8 个字节),以确保文件是 PNG 格式(注意,文件可能仍然已损坏):

    ...
        Log::info("Loading texture %s", pResource.getPath());
        TextureProperties* textureProperties; GLuint texture; GLint format;
        png_byte header[8];
        png_structp pngPtr = NULL; png_infop infoPtr = NULL;
        png_byte* image = NULL; png_bytep* rowPtrs = NULL;
        png_int_32 rowSize; bool transparency;
    
        if (pResource.open() != STATUS_OK) goto ERROR;
        Log::info("Checking signature.");
        if (pResource.read(header, sizeof(header)) != STATUS_OK)
            goto ERROR;
        if (png_sig_cmp(header, 0, 8) != 0) goto ERROR;
    ...
    
  8. 分配读取 PNG 图像所需的所有结构。之后,通过将我们在此教程中早先实现的callback_readPng()以及我们的Resource读取器传递给libpng,准备读取操作。通过png_get_io_ptr()在回调中获取的Resource指针。

    同时,使用setjmp()设置错误管理。这种机制允许像goto一样通过调用栈跳转代码。如果发生错误,控制流程将返回到首次调用setjmp()的位置,但会进入if块(这里为goto ERROR)。这是我们提供以下脚本的时刻:

    ...
        Log::info("Creating required structures.");
        pngPtr = png_create_read_struct(PNG_LIBPNG_VER_STRING,
            NULL, NULL, NULL);
        if (!pngPtr) goto ERROR;
        infoPtr = png_create_info_struct(pngPtr);
        if (!infoPtr) goto ERROR;
    
        // Prepares reading operation by setting-up a read callback.
        png_set_read_fn(pngPtr, &pResource, callback_readPng);
        // Set-up error management. If an error occurs while reading,
        // code will come back here and jump
        if (setjmp(png_jmpbuf(pngPtr))) goto ERROR;
    ...
    
  9. 忽略已经读取的文件签名中的前 8 个字节,使用png_set_sig_bytes()png_read_info()

    使用png_get_IHDR()开始读取 PNG 文件头:

    ...
        // Ignores first 8 bytes already read.
        png_set_sig_bytes(pngPtr, 8);
        // Retrieves PNG info and updates PNG struct accordingly.
        png_read_info(pngPtr, infoPtr);
        png_int_32 depth, colorType;
        png_uint_32 width, height;
        png_get_IHDR(pngPtr, infoPtr, &width, &height,
            &depth, &colorType, NULL, NULL, NULL);
    ...
    
  10. PNG 文件可以用多种格式编码:RGB、RGBA、带有调色板的 256 色、灰度等。R、G 和 B 颜色通道可以编码到 16 位。幸运的是,libpng提供了转换函数来解码不常见的格式,并将它们转换为更经典的 RGB 和亮度格式(每个通道 8 位,可选带或不带 alpha 通道)。

    使用png_set函数选择正确的转换。通过png_read_update_info()验证转换。

    同时,选择相应的 OpenGL 纹理格式:

    ...
        // Creates a full alpha channel if transparency is encoded as
        // an array of palette entries or a single transparent color.
        transparency = false;
        if (png_get_valid(pngPtr, infoPtr, PNG_INFO_tRNS)) {
            png_set_tRNS_to_alpha(pngPtr);
            transparency = true;
        }
        // Expands PNG with less than 8bits per channel to 8bits.
        if (depth < 8) {
            png_set_packing (pngPtr);
        // Shrinks PNG with 16bits per color channel down to 8bits.
        } else if (depth == 16) {
            png_set_strip_16(pngPtr);
        }
        // Indicates that image needs conversion to RGBA if needed.
        switch (colorType) {
        case PNG_COLOR_TYPE_PALETTE:
            png_set_palette_to_rgb(pngPtr);
            format = transparency ? GL_RGBA : GL_RGB;
            break;
        case PNG_COLOR_TYPE_RGB:
            format = transparency ? GL_RGBA : GL_RGB;
            break;
        case PNG_COLOR_TYPE_RGBA:
            format = GL_RGBA;
            break;
        case PNG_COLOR_TYPE_GRAY:
            png_set_expand_gray_1_2_4_to_8(pngPtr);
            format = transparency ? GL_LUMINANCE_ALPHA:GL_LUMINANCE;
            break;
        case PNG_COLOR_TYPE_GA:
            png_set_expand_gray_1_2_4_to_8(pngPtr);
            format = GL_LUMINANCE_ALPHA;
            break;
        }
        // Validates all transformations.
        png_read_update_info(pngPtr, infoPtr);
    ...
    
  11. libpng分配必要的临时缓冲区以保存图像数据,以及一个用于存储每行输出图像地址的第二个缓冲区。注意,行顺序是反转的,因为 OpenGL 使用的坐标系(左下角为第一个像素)与 PNG(左上角为第一个像素)不同。

    ...
        // Get row size in bytes.
        rowSize = png_get_rowbytes(pngPtr, infoPtr);
        if (rowSize <= 0) goto ERROR;
        // Ceates the image buffer that will be sent to OpenGL.
        image = new png_byte[rowSize * height];
        if (!image) goto ERROR;
        // Pointers to each row of the image buffer. Row order is
        // inverted because different coordinate systems are used by
        // OpenGL (1st pixel is at bottom left) and PNGs (top-left).
        rowPtrs = new png_bytep[height];
        if (!rowPtrs) goto ERROR;
        for (int32_t i = 0; i < height; ++i) {
            rowPtrs[height - (i + 1)] = image + i * rowSize;
        }
    ...
    
  12. 然后,使用png_read_image()开始读取图像内容。

    最后,完成时,释放所有临时资源:

    ...
        // Reads image content.
        png_read_image(pngPtr, rowPtrs);
        // Frees memory and resources.
        pResource.close();
        png_destroy_read_struct(&pngPtr, &infoPtr, NULL);
        delete[] rowPtrs;
    
  13. 最后,完成时,释放所有临时资源:

    ...
    ERROR:
        Log::error("Error loading texture into OpenGL.");
        pResource.close();
        delete[] rowPtrs; delete[] image;
        if (pngPtr != NULL) {
            png_infop* infoPtrP = infoPtr != NULL ? &infoPtr: NULL;
            png_destroy_read_struct(&pngPtr, infoPtrP, NULL);
        }
        return NULL;
    }
    

刚才发生了什么?

将我们的本地库模块libpng与资产管理器 API 结合使用,使我们能够加载资产目录中打包的 PNG 文件。PNG 是一种相对简单的图像格式,易于集成。此外,它支持压缩,有利于限制 APK 的大小。请注意,一旦加载,PNG 图像缓冲区将被解压缩,可能会消耗大量内存。因此,一旦可以,请尽快释放它们。有关 PNG 格式的详细信息,请参见www.w3.org/TR/PNG/

现在,我们的 PNG 图像已加载,我们可以从中生成 OpenGL 纹理。

是时候生成 OpenGL 纹理了——采取行动。

libpng填充的image缓冲区现在包含原始纹理数据。下一步是从此生成纹理:

  1. 让我们继续我们之前的方法GraphicsManager::loadTexture()

    使用glGenTextures()生成新的纹理标识符。

    使用glBindTexture()指示我们正在处理一个纹理。

    使用 glTexParameteri() 配置纹理参数,以指定纹理的过滤和包裹方式。使用 GL_NEAREST,因为对于没有缩放效果的 2D 游戏来说,平滑并不是必需的。纹理重复也不必要,可以通过 GL_CLAMP_TO_EDGE 来防止:

    ...
        png_destroy_read_struct(&pngPtr, &infoPtr, NULL);
        delete[] rowPtrs;
    
     GLenum errorResult;
     glGenTextures(1, &texture);
     glBindTexture(GL_TEXTURE_2D, texture);
     // Set-up texture properties.
     glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER,
     GL_NEAREST);
     glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER,
     GL_NEAREST);
     glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S,
     GL_CLAMP_TO_EDGE);
     glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T,
     GL_CLAMP_TO_EDGE);
    ...
    
  2. 使用 glTexImage2D() 将图像数据推送到 OpenGL 纹理中。

    这将解绑纹理,使 OpenGL 管线恢复到之前的状态。这不是严格必要的,但有助于避免未来绘制调用(即,使用不需要的纹理进行绘制)时的配置错误。

    最后,不要忘记释放临时图像缓冲区。

    你可以使用 glGetError() 来检查纹理是否已正确创建:

    ...
        // Loads image data into OpenGL.
     glTexImage2D(GL_TEXTURE_2D, 0, format, width, height, 0, format,
     GL_UNSIGNED_BYTE, image);
     // Finished working with the texture.
     glBindTexture(GL_TEXTURE_2D, 0);
     delete[] image;
     if (glGetError() != GL_NO_ERROR) goto ERROR;
     Log::info("Texture size: %d x %d", width, height);
    ...
    
  3. 最后,在返回之前,将 texture 缓存起来:

    ...
        // Caches the loaded texture.
     textureProperties = &mTextures[mTextureCount++];
     textureProperties->texture = texture;
     textureProperties->textureResource = &pResource;
     textureProperties->width = width;
     textureProperties->height = height;
     return textureProperties;
    
    ERROR:
        ...
    }
    ...
    
  4. jni/DroidBlaster.hpp 文件中,包含 Resource 头文件,并定义两个资源,其中一个用于飞船,另一个用于小行星:

    ...
    #include "PhysicsManager.hpp"
    #include "Resource.hpp"
    #include "Ship.hpp"
    #include "TimeManager.hpp"
    #include "Types.hpp"
    
    class DroidBlaster : public ActivityHandler {
        ...
    private:
        ...
        EventLoop mEventLoop;
    
        Resource mAsteroidTexture;
        Resource mShipTexture;
    
        Asteroid mAsteroids;
        Ship mShip;
    };
    #endif
    
  5. 打开 jni/DroidBlaster.cpp 文件,并在构造函数中初始化 texture 资源。

    ...
    DroidBlaster::DroidBlaster(android_app* pApplication):
        mTimeManager(),
        mGraphicsManager(pApplication),
        mPhysicsManager(mTimeManager, mGraphicsManager),
        mEventLoop(pApplication, *this),
    
        mAsteroidTexture(pApplication, "droidblaster/asteroid.png"),
        mShipTexture(pApplication, "droidblaster/ship.png"),
    
        mAsteroids(pApplication, mTimeManager, mGraphicsManager,
                mPhysicsManager),
        mShip(pApplication, mGraphicsManager) {
        ...
    }
    ...
    
  6. 为了确保代码正常工作,在 onActivate() 中加载纹理。只有在 GraphicsManager 初始化 OpenGL 之后,才能加载纹理:

    ...
    status DroidBlaster::onActivate() {
        Log::info("Activating DroidBlaster");
    
        if (mGraphicsManager.start() != STATUS_OK) return STATUS_KO;
        mGraphicsManager.loadTexture(mAsteroidTexture);
        mGraphicsManager.loadTexture(mShipTexture);
    
        mAsteroids.initialize();
        mShip.initialize();
    
        mTimeManager.reset();
        return STATUS_OK;
    }
    ...
    

在运行 DroidBlaster 之前,将 asteroid.pngship.png 添加到 droidblaster/assets 目录中(如果需要,请创建它)。

注意

PNG 文件随本书在 DroidBlaster_Part6/assets 目录中提供。

刚才发生了什么?

运行应用程序,你不会看到太多差别。实际上,我们已经加载了两个 PNG 纹理,但我们并没有真正渲染它们。然而,如果你检查日志,你应该能看到痕迹显示纹理已经被正确加载并从缓存中检索,如下面的截图所示:

刚才发生了什么?

在 OpenGL 中,纹理是对象(按照 OpenGL 的方式),形式为在 图形处理单元GPU)上分配的内存数组,用于存储特定的数据。将图形数据存储在 GPU 内存中,比存储在主内存中提供了更快的内存访问速度,这有点像 CPU 上的缓存。这种效率是有代价的:纹理加载成本高,必须在启动时尽可能多地执行。

提示

纹理的像素被称为 Texels(纹理像素)。Texel 是“Texture Pixel”(纹理像素)的缩写。在场景渲染期间,纹理(因此 Texels)会被投影到 3D 对象上。

关于纹理的更多信息

在处理纹理时,要记住的一个重要要求是它们的尺寸;OpenGL 纹理的尺寸必须是 2 的幂(例如,128 或 256 像素)。其他尺寸在大多数设备上都会失败。这些尺寸简化了一种称为 MIPmappingMultum In ParvoMIP),意为小中见大)的技术。MIPmaps 是同一纹理的较小版本(见下图的例子),根据渲染对象距离的选择性应用。它们可以提高性能并减少锯齿伪影。

关于纹理的更多信息

纹理配置是通过glTexParameteri()设置的。它们只需在创建纹理时指定。以下两种主要类型的参数可以应用:

  • 使用GL_TEXTURE_MAG_FILTERGL_TEXTURE_MIN_FILTER进行纹理过滤

    这些参数控制了纹理放大和缩小的处理方式,即当纹理分别小于或大于光栅化图元时的处理过程。下一个图中展示了这两种可能的值。

  • GL_LINEAR根据最近的纹理颜色(也称为双线性过滤)对屏幕上绘制的纹理进行插值。这种计算产生平滑效果。GL_NEAREST不进行任何插值,直接显示最近的纹理颜色。这个值比GL_LINEAR稍微好一点性能。关于纹理的更多信息

    存在一些变体,可以与 MIPmaps 结合使用以指示如何应用缩小;其中一些变体包括GL_NEAREST_MIPMAP_NEARESTGL_LINEAR_MIPMAP_NEARESTGL_NEAREST_MIPMAP_LINEARGL_LINEAR_MIPMAP_LINEAR(后者被称为三线性过滤)。

  • 使用GL_TEXTURE_WRAP_SGL_TEXTURE_WRAP_T进行纹理包裹

    这些参数控制了当纹理坐标超出[0.0, 1.0]范围时纹理的重复方式。S 代表 X 轴,T 代表 Y 轴。它们的不同命名用于避免与位置坐标混淆。它们通常被称为 U 和 V。以下图展示了可能的一些值及其效果:

    关于纹理的更多信息

在处理纹理时需要记住的一些良好实践包括:

  • 切换纹理是一项代价高昂的操作,因此尽可能避免 OpenGL 管道状态变化(绑定新纹理和通过glEnable()更改选项都是状态变化的例子)。

  • 纹理可能是最消耗内存和带宽的资源。考虑使用压缩纹理格式以大幅提高性能。遗憾的是,纹理压缩算法相当依赖于硬件。

  • 创建大的纹理图集,尽可能多地打包数据,甚至来自多个对象。这被称为纹理图集。例如,如果你查看飞船和小行星的纹理,你会发现其中打包了几个精灵图像(我们甚至可以打包更多):关于纹理的更多信息

这篇纹理介绍提供了对 OpenGL ES 可以实现的效果的简要概述。关于纹理的更多信息,请查看 OpenGL.org 维基页面:www.opengl.org/wiki/Texture

绘制 2D 精灵图像

2D 游戏基于精灵,它们是在屏幕上组合的图像片段。它们可以代表一个对象、角色、静态元素或动画元素。精灵可以使用图像的 alpha 通道显示透明效果。通常,一个图像将包含一个精灵的多个帧,每个帧代表不同的动画步骤或不同的对象。

提示

如果你需要一个强大的跨平台图像编辑器,可以考虑使用GNU 图像处理程序GIMP)。这个程序在 Windows、Linux 和 Mac OS X 上都可以使用,是一款功能强大且开源的软件。你可以从www.gimp.org/下载它。

使用 OpenGL 绘制精灵的技术有很多种。其中一种称为Sprite Batch。这是使用 OpenGL ES 2 创建 2D 游戏的最有效方法之一。它基于一个顶点数组(存储在主内存中),每个帧都会用所有要渲染的精灵重新生成。渲染是借助一个简单的顶点着色器将 2D 坐标投影到屏幕上,以及一个输出原始精灵纹理颜色的片段着色器来完成的。

我们现在将实现一个精灵批次,在DroidBlaster中渲染飞船和多颗小行星。

注意

最终的项目与本一起提供,名为DroidBlaster_Part7

动手时间——初始化 OpenGL ES

现在让我们看看如何在 DroidBlaster 中实现精灵批次:

  1. 修改jni/GraphicsManager.hpp。创建GraphicsComponent类,它为所有以精灵批次开始的渲染技术定义了一个通用接口。定义一些新的方法,例如:

    • getProjectionMatrix()提供用于在屏幕上投影 2D 图形的 OpenGL 矩阵

    • loadShaderProgram()用于加载顶点和片段着色器,并将它们链接成一个 OpenGL 程序

    • registerComponent()记录要初始化和渲染的GraphicsComponent列表

    创建RenderVertex私有结构,表示单个精灵顶点的结构。

    同时,声明几个新的成员变量,例如:

    • mProjectionMatrix用于存储正交投影(与 3D 游戏中使用的透视投影相对)。

    • mShadersmShaderCountmComponentsmComponentCount用来追踪所有资源。

    最后,移除前一章用于渲染原始图形的所有GraphicsElement相关内容,如下代码所示:

    ...
    class GraphicsComponent {
    public:
        virtual status load() = 0;
        virtual void draw() = 0;
    };
    ...
    
  2. 接下来,在GraphicsManager中定义几个新方法:

    • getProjectionMatrix()提供用于在屏幕上投影 2D 图形的 OpenGL 矩阵

    • loadShaderProgram()用于加载顶点和片段着色器,并将它们链接成一个 OpenGL 程序

    • registerComponent()记录要初始化和渲染的GraphicsComponent列表

    创建RenderVertex私有结构,表示单个精灵顶点的结构。

    同时,声明几个新的成员变量,例如:

    • mProjectionMatrix用于存储正交投影(与 3D 游戏中使用的透视投影相对)

    • mShadersmShaderCountmComponentsmComponentCount用于跟踪所有资源。

    最后,移除前一章用于渲染原始图形的所有GraphicsElement相关内容:

    ...
    class GraphicsManager {
    public:
        GraphicsManager(android_app* pApplication);
        ~GraphicsManager();
    
        int32_t getRenderWidth() { return mRenderWidth; }
        int32_t getRenderHeight() { return mRenderHeight; }
        GLfloat* getProjectionMatrix() { return mProjectionMatrix[0]; }
    
     void registerComponent(GraphicsComponent* pComponent);
    
        status start();
        void stop();
        status update();
    
        TextureProperties* loadTexture(Resource& pResource);
        GLuint loadShader(const char* pVertexShader,
     const char* pFragmentShader);
    
    private:
        struct RenderVertex {
     GLfloat x, y, u, v;
        };
    
        android_app* mApplication;
    
        int32_t mRenderWidth; int32_t mRenderHeight;
        EGLDisplay mDisplay; EGLSurface mSurface; EGLContext mContext;
        GLfloat mProjectionMatrix[4][4];
    
        TextureProperties mTextures[32]; int32_t mTextureCount;
        GLuint mShaders[32]; int32_t mShaderCount;
    
        GraphicsComponent* mComponents[32]; int32_t mComponentCount;
    };
    #endif
    
  3. 打开jni/GraphicsManager.cpp

    更新构造函数初始化列表和析构函数。再次,移除所有与GraphicsElement相关的部分。

    使用registerComponent()替代registerElement()实现注册:

    ...
    GraphicsManager::GraphicsManager(android_app* pApplication) :
        mApplication(pApplication),
        mRenderWidth(0), mRenderHeight(0),
        mDisplay(EGL_NO_DISPLAY), mSurface(EGL_NO_CONTEXT),
        mContext(EGL_NO_SURFACE),
        mProjectionMatrix(),
        mTextures(), mTextureCount(0),
        mShaders(), mShaderCount(0),
        mComponents(), mComponentCount(0) {
        Log::info("Creating GraphicsManager.");
    }
    
    GraphicsManager::~GraphicsManager() {
        Log::info("Destroying GraphicsManager.");
    }
    
    void GraphicsManager::registerComponent(GraphicsComponent* pComponent)
    {
        mComponents[mComponentCount++] = pComponent;
    }
    ...
    
  4. 修改onStart(),使用显示尺寸初始化正交投影矩阵数组(我们将在第九章中看到如何更容易地使用 GLM 计算矩阵),并加载组件。

    提示

    投影矩阵是一种数学方法,用于将组成场景的 3D 对象投影到 2D 平面上,即屏幕。在正交投影中,投影与显示表面垂直。这意味着无论物体距离观察点远近,其大小都完全相同。正交投影适用于 2D 游戏。透视投影中,物体越远看起来越小,通常用于 3D 游戏。

    如需了解更多信息,请查看en.wikipedia.org/wiki/Graphical_projection

    ...
    status GraphicsManager::start() {
        ...
        glViewport(0, 0, mRenderWidth, mRenderHeight);
        glDisable(GL_DEPTH_TEST);
    
        // Prepares the projection matrix with viewport dimesions.
     memset(mProjectionMatrix[0], 0, sizeof(mProjectionMatrix));
     mProjectionMatrix[0][0] =  2.0f / GLfloat(mRenderWidth);
     mProjectionMatrix[1][1] =  2.0f / GLfloat(mRenderHeight);
     mProjectionMatrix[2][2] = -1.0f; mProjectionMatrix[3][0] = -1.0f;
     mProjectionMatrix[3][1] = -1.0f; mProjectionMatrix[3][2] =  0.0f;
     mProjectionMatrix[3][3] =  1.0f;
    
     // Loads graphics components.
     for (int32_t i = 0; i < mComponentCount; ++i) {
     if (mComponents[i]->load() != STATUS_OK) {
     return STATUS_KO;
            }
        }
        return STATUS_OK;
        ...
    }
    ...
    
  5. stop()中释放所有使用loadShaderProgram()加载的资源。

    ...
    void GraphicsManager::stop() {
        Log::info("Stopping GraphicsManager.");
        for (int32_t i = 0; i < mTextureCount; ++i) {
            glDeleteTextures(1, &mTextures[i].texture);
        }
        mTextureCount = 0;
    
        for (int32_t i = 0; i < mShaderCount; ++i) {
     glDeleteProgram(mShaders[i]);
     }
     mShaderCount = 0;
    
        // Destroys OpenGL context.
        ...
    }
    ...
    
  6. update()中清除显示后但在刷新之前,渲染所有已注册的组件:

    ...
    status GraphicsManager::update() {
        glClear(GL_COLOR_BUFFER_BIT);
    
        for (int32_t i = 0; i < mComponentCount; ++i) {
     mComponents[i]->draw();
        }
    
        if (eglSwapBuffers(mDisplay, mSurface) != EGL_TRUE) {
        ...
    }
    ...
    
  7. 创建新的方法loadShader()。其作用是编译并加载作为可读 GLSL 程序的给定着色器。为此:

    • 使用glCreateShader()生成新的顶点着色器。

    • 使用glShaderSource()将顶点着色器源代码上传到 OpenGL。

    • 使用glCompileShader()编译着色器,并使用glGetShaderiv()检查编译状态。编译错误可以通过glGetShaderInfoLog()读取。

    对给定的片段着色器重复该操作:

    ...
    GLuint GraphicsManager::loadShader(const char* pVertexShader,
            const char* pFragmentShader) {
        GLint result; char log[256];
        GLuint vertexShader, fragmentShader, shaderProgram;
    
        // Builds the vertex shader.
        vertexShader = glCreateShader(GL_VERTEX_SHADER);
        glShaderSource(vertexShader, 1, &pVertexShader, NULL);
        glCompileShader(vertexShader);
        glGetShaderiv(vertexShader, GL_COMPILE_STATUS, &result);
        if (result == GL_FALSE) {
            glGetShaderInfoLog(vertexShader, sizeof(log), 0, log);
            Log::error("Vertex shader error: %s", log);
            goto ERROR;
        }
    
        // Builds the fragment shader.
        fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
        glShaderSource(fragmentShader, 1, &pFragmentShader, NULL);
        glCompileShader(fragmentShader);
        glGetShaderiv(fragmentShader, GL_COMPILE_STATUS, &result);
        if (result == GL_FALSE) {
            glGetShaderInfoLog(fragmentShader, sizeof(log), 0, log);
            Log::error("Fragment shader error: %s", log);
            goto ERROR;
        }
    ...
    
  8. 编译后,将编译好的顶点和片段着色器链接在一起。为此:

    • 使用glCreateProgram()创建一个程序对象。

    • 使用glAttachShader()指定要使用的着色器。

    • 使用glLinkProgram()将它们链接在一起,创建最终程序。此时将检查着色器的一致性和与硬件的兼容性。可以使用glGetProgramiv()检查结果。

    • 最后,移除着色器,因为一旦链接到程序中,它们就不再有用。

      ...
          shaderProgram = glCreateProgram();
          glAttachShader(shaderProgram, vertexShader);
          glAttachShader(shaderProgram, fragmentShader);
          glLinkProgram(shaderProgram);
          glGetProgramiv(shaderProgram, GL_LINK_STATUS, &result);
          glDeleteShader(vertexShader);
          glDeleteShader(fragmentShader);
          if (result == GL_FALSE) {
              glGetProgramInfoLog(shaderProgram, sizeof(log), 0, log);
              Log::error("Shader program error: %s", log);
              goto ERROR;
          }
      
          mShaders[mShaderCount++] = shaderProgram;
          return shaderProgram;
      
      ERROR:
          Log::error("Error loading shader.");
          if (vertexShader > 0) glDeleteShader(vertexShader);
          if (fragmentShader > 0) glDeleteShader(fragmentShader);
          return 0;
      }
      ...
      
  9. 创建jni/Sprite.hpp,它定义了一个包含所有用于动画和绘制单个精灵所需数据的类。

    创建一个Vertex结构体,定义精灵顶点的内容。我们需要一个 2D 位置和纹理坐标,这些坐标限定精灵图片。

    然后,定义一些方法:

    • 可以使用 setAnimation()animationEnded() 更新和检索精灵动画。位置为了简单起见,是公开可用的。

    • 为了稍后定义的 SpriteBatch 组件提供特权访问。它能够 load()draw() 精灵。

      #ifndef _PACKT_GRAPHICSSPRITE_HPP_
      #define _PACKT_GRAPHICSSPRITE_HPP_
      
      #include "GraphicsManager.hpp"
      #include "Resource.hpp"
      #include "Types.hpp"
      
      #include <GLES2/gl2.h>
      
      class SpriteBatch;
      
      class Sprite {
          friend class SpriteBatch;
      public
          struct Vertex {
              GLfloat x, y, u, v;
          };
      
          Sprite(GraphicsManager& pGraphicsManager,
              Resource& pTextureResource, int32_t pHeight, int32_t pWidth);
      
          void setAnimation(int32_t pStartFrame, int32_t pFrameCount,
              float pSpeed, bool pLoop);
          bool animationEnded() { return mAnimFrame > (mAnimFrameCount-1); }
      
          Location location;
      
      protected:
          status load(GraphicsManager& pGraphicsManager);
          void draw(Vertex pVertex[4], float pTimeStep);
      ...
      
  10. 最后,定义一些属性:

    • 包含精灵表及其对应资源的纹理

    • 精灵帧数据mWidthmHeight,以及水平、垂直和总帧数 mFrameXCountmFrameYCountmFrameCount

    • 动画数据:动画的第一帧和总帧数 mAnimStartFramemAnimFrameCount,动画速度 mAnimSpeed,当前显示的帧 mAnimFrame,以及循环指示器 mAnimLoop

      ...
      private:
          Resource& mTextureResource;
          GLuint mTexture;
          // Frame.
          int32_t mSheetHeight, mSheetWidth;
          int32_t mSpriteHeight, mSpriteWidth;
          int32_t mFrameXCount, mFrameYCount, mFrameCount;
          // Animation.
          int32_t mAnimStartFrame, mAnimFrameCount;
          float mAnimSpeed, mAnimFrame;
          bool mAnimLoop;
      };
      #endif
      
  11. 编写 jni/Sprite.cpp 构造函数,并将成员初始化为默认值:

    #include "Sprite.hpp"
    #include "Log.hpp"
    
    Sprite::Sprite(GraphicsManager& pGraphicsManager,
            Resource& pTextureResource,
        int32_t pHeight, int32_t pWidth) :
        location(),
        mTextureResource(pTextureResource), mTexture(0),
        mSheetWidth(0), mSheetHeight(0),
        mSpriteHeight(pHeight), mSpriteWidth(pWidth),
        mFrameCount(0), mFrameXCount(0), mFrameYCount(0),
        mAnimStartFrame(0), mAnimFrameCount(1),
        mAnimSpeed(0), mAnimFrame(0), mAnimLoop(false)
    {}
    ...
    
  12. 帧信息(水平、垂直和总帧数)需要在 load() 时重新计算,因为纹理尺寸仅在加载时才知道:

    ...
    status Sprite::load(GraphicsManager& pGraphicsManager) {
        TextureProperties* textureProperties =
                pGraphicsManager.loadTexture(mTextureResource);
        if (textureProperties == NULL) return STATUS_KO;
        mTexture = textureProperties->texture;
        mSheetWidth = textureProperties->width;
        mSheetHeight = textureProperties->height;
    
        mFrameXCount = mSheetWidth / mSpriteWidth;
        mFrameYCount = mSheetHeight / mSpriteHeight;
        mFrameCount = (mSheetHeight / mSpriteHeight)
                    * (mSheetWidth / mSpriteWidth);
        return STATUS_OK;
    }
    ...
    
  13. 动画从精灵表中的给定帧开始,并在一定数量的帧数后结束,这个数量会根据速度变化。动画可以在结束时循环回到开始处重新播放:

    ...
    void Sprite::setAnimation(int32_t pStartFrame,
        int32_t pFrameCount, float pSpeed, bool pLoop) {
        mAnimStartFrame = pStartFrame;
        mAnimFrame = 0.0f, mAnimSpeed = pSpeed, mAnimLoop = pLoop;
        mAnimFrameCount = pFrameCount;
    }
    ...
    
  14. draw() 中,首先根据精灵动画和自上一帧以来的时间更新要绘制的帧。我们需要的是帧在精灵表中的索引:

    ...
    void Sprite::draw(Vertex pVertices[4], float pTimeStep) {
        int32_t currentFrame, currentFrameX, currentFrameY;
        // Updates animation in loop mode.
        mAnimFrame += pTimeStep * mAnimSpeed;
        if (mAnimLoop) {
            currentFrame = (mAnimStartFrame +
                             int32_t(mAnimFrame) % mAnimFrameCount);
        } else {
            // Updates animation in one-shot mode.
            if (animationEnded()) {
                currentFrame = mAnimStartFrame + (mAnimFrameCount-1);
            } else {
                currentFrame = mAnimStartFrame + int32_t(mAnimFrame);
            }
        }
        // Computes frame X and Y indexes from its id.
        currentFrameX = currentFrame % mFrameXCount;
        // currentFrameY is converted from OpenGL coordinates
        // to top-left coordinates.
        currentFrameY = mFrameYCount - 1
                      - (currentFrame / mFrameXCount);
    ...
    
  15. 精灵由四个顶点组成,绘制在输出数组 pVertices 中。这些顶点中的每一个都由精灵位置(posX1posY1posX2posY2)和纹理坐标(u1u2v1v2)组成。动态计算并在提供的内存缓冲区 pVertices 中生成这些顶点。这个内存缓冲区稍后将提供给 OpenGL 以渲染精灵:

    ...
        // Draws selected frame.
        GLfloat posX1 = location.x - float(mSpriteWidth / 2);
        GLfloat posY1 = location.y - float(mSpriteHeight / 2);
        GLfloat posX2 = posX1 + mSpriteWidth;
        GLfloat posY2 = posY1 + mSpriteHeight;
        GLfloat u1 = GLfloat(currentFrameX * mSpriteWidth)
                        / GLfloat(mSheetWidth);
        GLfloat u2 = GLfloat((currentFrameX + 1) * mSpriteWidth)
                        / GLfloat(mSheetWidth);
        GLfloat v1 = GLfloat(currentFrameY * mSpriteHeight)
                        / GLfloat(mSheetHeight);
        GLfloat v2 = GLfloat((currentFrameY + 1) * mSpriteHeight)
                        / GLfloat(mSheetHeight);
    
        pVertices[0].x = posX1; pVertices[0].y = posY1;
        pVertices[0].u = u1;    pVertices[0].v = v1;
        pVertices[1].x = posX1; pVertices[1].y = posY2;
        pVertices[1].u = u1;    pVertices[1].v = v2;
        pVertices[2].x = posX2; pVertices[2].y = posY1;
        pVertices[2].u = u2;    pVertices[2].v = v1;
        pVertices[3].x = posX2; pVertices[3].y = posY2;
        pVertices[3].u = u2;    pVertices[3].v = v2;
    }
    
  16. jni/SpriteBatch.hpp 中指定方法,例如:

    • registerSprite() 添加一个新的精灵以进行绘制

    • load() 初始化所有已注册的精灵

    • draw() 有效地渲染所有已注册的精灵

    我们将需要成员变量:

    • mSpritesmSpriteCount 中绘制的精灵集合

    • mVerticesmVertexCountmIndexesmIndexCount,它们定义了顶点和索引缓冲区

    • mShaderProgram 标识的着色器程序。

    顶点和片段着色器参数是:

    • aPosition,它是精灵角的其中一个位置。

    • aTexture,它是精灵角纹理坐标。它定义了在精灵表中显示的精灵。

    • uProjection 是正交投影矩阵。

    • uTexture,包含精灵图片。

      #ifndef _PACKT_GRAPHICSSPRITEBATCH_HPP_
      #define _PACKT_GRAPHICSSPRITEBATCH_HPP_
      
      #include "GraphicsManager.hpp"
      #include "Sprite.hpp"
      #include "TimeManager.hpp"
      #include "Types.hpp"
      
      #include <GLES2/gl2.h>
      
      class SpriteBatch : public GraphicsComponent {
      public:
          SpriteBatch(TimeManager& pTimeManager,
                  GraphicsManager& pGraphicsManager);
          ~SpriteBatch();
      
          Sprite* registerSprite(Resource& pTextureResource,
              int32_t pHeight, int32_t pWidth);
      
          status load();
          void draw();
      
      private:
          TimeManager& mTimeManager;
          GraphicsManager& mGraphicsManager;
      
          Sprite* mSprites[1024]; int32_t mSpriteCount;
          Sprite::Vertex mVertices[1024]; int32_t mVertexCount;
          GLushort mIndexes[1024]; int32_t mIndexCount;
          GLuint mShaderProgram;
          GLuint aPosition; GLuint aTexture;
          GLuint uProjection; GLuint uTexture;
      };
      #endif
      
  17. 实现 jni/SpriteBach.cpp 构造函数以初始化默认值。组件必须注册到 GraphicsManager 以便加载和渲染。

    在析构函数中,当组件被销毁时,必须释放已分配的精灵。

    #include "SpriteBatch.hpp"
    #include "Log.hpp"
    
    #include <GLES2/gl2.h>
    
    SpriteBatch::SpriteBatch(TimeManager& pTimeManager,
            GraphicsManager& pGraphicsManager) :
        mTimeManager(pTimeManager),
        mGraphicsManager(pGraphicsManager),
        mSprites(), mSpriteCount(0),
        mVertices(), mVertexCount(0),
        mIndexes(), mIndexCount(0),
        mShaderProgram(0),
        aPosition(-1), aTexture(-1), uProjection(-1), uTexture(-1)
    {
        mGraphicsManager.registerComponent(this);
    }
    
    SpriteBatch::~SpriteBatch() {
        for (int32_t i = 0; i < mSpriteCount; ++i) {
            delete mSprites[i];
        }
    }
    ...
    
  18. 索引缓冲区相对静态。当注册精灵时,我们可以预先计算其内容。每个索引指向顶点缓冲区中的一个顶点(0 代表第一个顶点,1 代表第二个,依此类推)。由于一个精灵由 2 个 3 顶点的三角形组成(形成一个四边形),我们需要每个精灵 6 个索引:

    ...
    Sprite* SpriteBatch::registerSprite(Resource& pTextureResource,
            int32_t pHeight, int32_t pWidth) {
        int32_t spriteCount = mSpriteCount;
        int32_t index = spriteCount * 4; // Points to 1st vertex.
    
        // Precomputes the index buffer.
        GLushort* indexes = (&mIndexes[0]) + spriteCount * 6;
        mIndexes[mIndexCount++] = index+0;
        mIndexes[mIndexCount++] = index+1;
        mIndexes[mIndexCount++] = index+2;
        mIndexes[mIndexCount++] = index+2;
        mIndexes[mIndexCount++] = index+1;
        mIndexes[mIndexCount++] = index+3;
    
        // Appends a new sprite to the sprite array.
        mSprites[mSpriteCount] = new Sprite(mGraphicsManager,
                pTextureResource, pHeight, pWidth);
        return mSprites[mSpriteCount++];
    }
    ...
    
  19. 将 GLSL 顶点和片段着色器写成常量字符串。

    着色器代码是写在类似于 C 语言中可以编写的main()函数内的。像任何正常的计算机程序一样,着色器需要变量来处理数据:属性(如顶点位置这样的逐顶点数据)、统一变量(每次绘制调用的全局参数)以及变化量(如纹理坐标这样的逐片段插值值)。

    在这里,纹理坐标通过vTexture传递给片段着色器。顶点位置从 2D 向量转换为 4D 向量,进入预定义的 GLSL 变量gl_Position。片段着色器在vTexture中获取插值的纹理坐标。此信息用作预定义函数texture2D()中的索引,以访问纹理颜色。颜色保存在预定义的输出变量gl_FragColor中,它表示最终的像素:

    ...
    static const char* VERTEX_SHADER =
       "attribute vec4 aPosition;\n"
       "attribute vec2 aTexture;\n"
       "varying vec2 vTexture;\n"
       "uniform mat4 uProjection;\n"
       "void main() {\n"
       "    vTexture = aTexture;\n"
       "    gl_Position =  uProjection * aPosition;\n"
       "}";
    
    static const char* FRAGMENT_SHADER =
        "precision mediump float;\n"
        "varying vec2 vTexture;\n"
        "uniform sampler2D u_texture;\n"
        "void main() {\n"
        "  gl_FragColor = texture2D(u_texture, vTexture);\n"
        "}";
    ...
    
  20. load()中加载着色器程序并获取着色器属性和统一变量标识符。然后,初始化精灵,如下代码所示:

    ...
    status SpriteBatch::load() {
        GLint result; int32_t spriteCount;
    
        mShaderProgram = mGraphicsManager.loadShader(VERTEX_SHADER,
                FRAGMENT_SHADER);
        if (mShaderProgram == 0) return STATUS_KO;
        aPosition = glGetAttribLocation(mShaderProgram, "aPosition");
        aTexture = glGetAttribLocation(mShaderProgram, "aTexture");
        uProjection = glGetUniformLocation(mShaderProgram,"uProjection");
        uTexture = glGetUniformLocation(mShaderProgram, "u_texture");
    
        // Loads sprites.
        for (int32_t i = 0; i < mSpriteCount; ++i) {
            if (mSprites[i]->load(mGraphicsManager)
                    != STATUS_OK) goto ERROR;
        }
        return STATUS_OK;
    
    ERROR:
        Log::error("Error loading sprite batch");
        return STATUS_KO;
    }
    ...
    
  21. 编写draw(),它执行 OpenGL 精灵渲染逻辑。

    首先,选择精灵着色器并传递其参数:矩阵和纹理统一变量:

    ...
    void SpriteBatch::draw() {
        glUseProgram(mShaderProgram);
        glUniformMatrix4fv(uProjection, 1, GL_FALSE,
                mGraphicsManager.getProjectionMatrix());
        glUniform1i(uTexture, 0);
    ...
    

    然后,使用glEnableVertexAttribArray()glVertexAttribPointer()指示 OpenGL 如何存储顶点缓冲区中的位置和 UV 坐标。这些调用基本上描述了mVertices结构。注意顶点数据是如何与着色器属性链接的:

    ...
        glEnableVertexAttribArray(aPosition);
        glVertexAttribPointer(aPosition, // Attribute Index
                              2, // Size in bytes (x and y)
                              GL_FLOAT, // Data type
                              GL_FALSE, // Normalized
                              sizeof(Sprite::Vertex),// Stride
                              &(mVertices[0].x)); // Location
        glEnableVertexAttribArray(aTexture);
        glVertexAttribPointer(aTexture, // Attribute Index
                              2, // Size in bytes (u and v)
                              GL_FLOAT, // Data type
                              GL_FALSE, // Normalized
                              sizeof(Sprite::Vertex), // Stride
                              &(mVertices[0].u)); // Location
    ...
    

    使用混合函数激活透明度,以在背景或其他精灵上方绘制精灵:

    ...
        glEnable(GL_BLEND);
        glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
    ...
    

    提示

    有关 OpenGL 提供的混合模式的更多信息,请查看www.opengl.org/wiki/Blending

  22. 现在我们可以开始渲染循环,批量渲染所有精灵。

    第一个外部循环基本上遍历纹理。实际上,OpenGL 中的管道状态变化是代价高昂的。像glBindTexture()这样的方法应该尽可能少调用,以保证性能:

    ...
        const int32_t vertexPerSprite = 4;
        const int32_t indexPerSprite = 6;
        float timeStep = mTimeManager.elapsed();
        int32_t spriteCount = mSpriteCount;
        int32_t currentSprite = 0, firstSprite = 0;
        while (bool canDraw = (currentSprite < spriteCount)) {
            // Switches texture.
            Sprite* sprite = mSprites[currentSprite];
            GLuint currentTexture = sprite->mTexture;
            glActiveTexture(GL_TEXTURE0);
            glBindTexture(GL_TEXTURE_2D, sprite->mTexture);
    ...
    

    内部循环为所有具有相同纹理的精灵生成顶点:

    ...
            // Generate sprite vertices for current textures.
            do {
                sprite = mSprites[currentSprite];
                if (sprite->mTexture == currentTexture) {
                    Sprite::Vertex* vertices =
                            (&mVertices[currentSprite * 4]);
                    sprite->draw(vertices, timeStep);
                } else {
                    break;
                }
            } while (canDraw = (++currentSprite < spriteCount));
    ...
    
  23. 每当纹理发生变化时,使用glDrawElements()渲染一批发射的精灵。之前指定的顶点缓冲区与这里给出的索引缓冲区结合,使用正确的纹理渲染正确的精灵。此时,绘制调用被发送到 OpenGL,执行着色器程序:

    ...
            glDrawElements(GL_TRIANGLES,
                    // Number of indexes
                    (currentSprite - firstSprite) * indexPerSprite,
                    GL_UNSIGNED_SHORT, // Indexes data type
                    // First index
                    &mIndexes[firstSprite * indexPerSprite]);
    
            firstSprite = currentSprite;
        }
    ...
    

    当所有精灵渲染完毕后,恢复 OpenGL 状态:

    ...
        glUseProgram(0);
        glDisableVertexAttribArray(aPosition);
        glDisableVertexAttribArray(aTexture);
        glDisable(GL_BLEND);
    }
    
  24. 使用新的精灵系统更新jni/Ship.hpp。你可以移除之前的GraphicsElement内容:

    #include "GraphicsManager.hpp"
    #include "Sprite.hpp"
    
    class Ship {
    public:
        ...
        void registerShip(Sprite* pGraphics);
        ...
    private:
        GraphicsManager& mGraphicsManager;
        Sprite* mGraphics;
    };
    #endif
    

    文件jni/Ship.cpp除了Sprite类型之外,变化不大。

    ...
    void Ship::registerShip(Sprite* pGraphics) {
        mGraphics = pGraphics;
    }
    ...
    

    jni/DroidBlaster.hpp中包含新的SpriteBatch组件:

    ...
    #include "Resource.hpp"
    #include "Ship.hpp"
    #include "SpriteBatch.hpp"
    #include "TimeManager.hpp"
    #include "Types.hpp"
    
    class DroidBlaster : public ActivityHandler {
        ...
    private:
        ...
        Asteroid mAsteroids;
        Ship mShip;
        SpriteBatch mSpriteBatch;
    };
    #endif
    
  25. jni/DroidBlaster.cpp中,定义一些具有动画属性的新常量。

    然后,使用SpriteBatch组件注册飞船和小行星的图形。

    再次移除与GraphicsElement相关的前一段内容:

    ...
    static const int32_t SHIP_SIZE = 64;
    static const int32_t SHIP_FRAME_1 = 0;
    static const int32_t SHIP_FRAME_COUNT = 8;
    static const float SHIP_ANIM_SPEED = 8.0f;
    
    static const int32_t ASTEROID_COUNT = 16;
    static const int32_t ASTEROID_SIZE = 64;
    static const int32_t ASTEROID_FRAME_1 = 0;
    static const int32_t ASTEROID_FRAME_COUNT = 16;
    static const float ASTEROID_MIN_ANIM_SPEED = 8.0f;
    static const float ASTEROID_ANIM_SPEED_RANGE = 16.0f;
    
    DroidBlaster::DroidBlaster(android_app* pApplication):
       ...
        mAsteroids(pApplication, mTimeManager, mGraphicsManager,
                mPhysicsManager),
        mShip(pApplication, mGraphicsManager),
        mSpriteBatch(mTimeManager, mGraphicsManager) {
        Log::info("Creating DroidBlaster");
    
        Sprite* shipGraphics = mSpriteBatch.registerSprite(mShipTexture,
     SHIP_SIZE, SHIP_SIZE);
     shipGraphics->setAnimation(SHIP_FRAME_1, SHIP_FRAME_COUNT,
     SHIP_ANIM_SPEED, true);
        mShip.registerShip(shipGraphics);
    
        // Creates asteroids.
        for (int32_t i = 0; i < ASTEROID_COUNT; ++i) {
            Sprite* asteroidGraphics = mSpriteBatch.registerSprite(
     mAsteroidTexture, ASTEROID_SIZE, ASTEROID_SIZE);
     float animSpeed = ASTEROID_MIN_ANIM_SPEED
     + RAND(ASTEROID_ANIM_SPEED_RANGE);
     asteroidGraphics->setAnimation(ASTEROID_FRAME_1,
     ASTEROID_FRAME_COUNT, animSpeed, true);
            mAsteroids.registerAsteroid(
                    asteroidGraphics->location, ASTEROID_SIZE,
                    ASTEROID_SIZE);
        }
    }
    ...
    
  26. 我们不再需要在onActivate()中手动加载纹理。Sprite 会为我们处理这些。

    最后,在onDeactivate()中释放图形资源:

    ...
    status DroidBlaster::onActivate() {
        Log::info("Activating DroidBlaster");
    
        if (mGraphicsManager.start() != STATUS_OK) return STATUS_KO;
    
        // Initializes game objects.
        mAsteroids.initialize();
        mShip.initialize();
    
        mTimeManager.reset();
        return STATUS_OK;
    }
    
    void DroidBlaster::onDeactivate() {
        Log::info("Deactivating DroidBlaster");
        mGraphicsManager.stop();
    }
    ...
    

刚才发生了什么?

启动 DroidBlaster。你现在应该看到一个被可怕旋转小行星环绕的动画飞船:

刚才发生了什么?

在这一部分,我们看到了如何通过 Sprite Batch 技术有效地绘制一个精灵。实际上,在 OpenGL 程序中性能不佳的一个常见原因是状态变化。改变 OpenGL 设备状态(例如,绑定一个新的缓冲区或纹理,使用glEnable()更改选项等)是一个代价高昂的操作,应尽可能避免。因此,为了最大化 OpenGL 的性能,一个好的实践是按顺序排列绘制调用,并且只改变所需的状态。

提示

最优秀的 OpenGL ES 文档可以在苹果开发者网站上找到,地址是developer.apple.com/library/IOS/documentation/3DDrawing/Conceptual/OpenGLES_ProgrammingGuide/

但首先,让我们更深入地了解 OpenGL 在内存中存储顶点的方式以及 OpenGL ES 着色器的基础知识。

顶点数组与顶点缓冲对象

顶点数组VA)和顶点缓冲对象VBO)是 OpenGL ES 中管理顶点的两种主要方式。与纹理一样,可以同时将多个 VA/VBO 绑定到一个顶点着色器上。

在 OpenGL ES 中有两种主要方式来管理顶点:

  • 在主内存中(即在 RAM 中),我们讨论的是顶点数组(简称 VA)。顶点数组在每个绘制调用时从 CPU 传输到 GPU。因此,它们的渲染速度较慢,但更新起来要容易得多。因此,当顶点网格经常变化时,它们是适当的。这解释了为什么要使用顶点数组来实现 Sprite Batches;每次渲染新帧时都会更新每个 Sprite(位置以及纹理坐标,以切换到新帧)。

  • 在驱动器内存中(通常在 GPU 内存或VRAM中),我们讨论的是顶点缓冲对象。顶点缓冲绘制速度快,但更新成本更高。因此,它们通常用于渲染永远不会改变的静态数据。你仍然可以通过顶点着色器对其进行变换,我们将在下一部分看到。注意,在初始化期间可以向驱动器提供一些提示(GL_DYNAMIC_DRAW),以允许快速更新,但代价是更复杂的缓冲管理(即多重缓冲)。

变换后,顶点在图元装配阶段连接在一起。它们可以通过以下方式组装:

  • 当列表以 3x3 排列(可能导致顶点重复),扇形,条形等方式时;在这种情况下,我们使用glDrawArrays()

  • 使用指定为 3x3 的索引缓冲区,其中顶点相互连接。索引缓冲区通常是实现更好性能的最佳方式。需要排序索引以利于缓存。使用glDrawElements()与相关的 VBO 或 VA 绘制索引。顶点数组与顶点缓冲对象对比

当你处理顶点时,需要记住的一些好的实践是:

  • 尽可能在每个缓冲区中打包尽可能多的顶点,甚至来自多个网格。实际上,从一组顶点切换到另一组,无论是 VA 还是 VBO,都是比较慢的。

  • 避免在运行时更新静态顶点缓冲区。

  • 使顶点结构的大小为 2 的幂(以字节为单位)以利于数据对齐。通常,相对于传输未对齐的数据,更倾向于填充数据,因为 GPU 处理数据的方式。

有关顶点管理的更多信息,请查看 OpenGL.org 维基的www.opengl.org/wiki/Vertex_Specificationwww.opengl.org/wiki/Vertex_Specification_Best_Practices

渲染粒子效果

DroidBlaster 需要一个背景来使其看起来更美观。由于动作发生在太空中,那么一个流星如何给速度感?

这种效果可以通过几种方式模拟。一种可能的选择是显示一个粒子效果,其中每个粒子对应一个星星。OpenGL 通过点精灵提供了这一特性。点精灵是一种特殊的元素,只需要一个顶点就能绘制一个精灵。结合整个顶点缓冲区,可以高效地同时绘制许多精灵。

点精灵可以使用顶点和片段着色器。为了更高效,我们可以利用它们直接在着色器内部处理粒子移动的能力。因此,我们将不需要每次粒子变化时重新生成顶点缓冲区,就像使用精灵批次时必须做的那样。

注意

最终项目随本书提供,名为DroidBlaster_Part8

动手操作 - 渲染星域

现在让我们看看如何在DroidBlaster中应用这个粒子效果:

  1. jni/GraphicsManager.hpp中,定义一个加载顶点缓冲区的新方法。

    添加一个数组来存储顶点缓冲区资源:

    ...
    class GraphicsManager {
    public:
        ...
        GLuint loadShader(const char* pVertexShader,
                const char* pFragmentShader);
        GLuint loadVertexBuffer(const void* pVertexBuffer,
     int32_t pVertexBufferSize);
    
    private:
        ...
        GLuint mShaders[32]; int32_t mShaderCount;
        GLuint mVertexBuffers[32]; int32_t mVertexBufferCount;
    
        GraphicsComponent* mComponents[32]; int32_t mComponentCount;
    };
    #endif
    
  2. jni/GraphicsManager.cpp中,更新构造函数初始化列表,并在stop()中释放顶点缓冲区资源:

    ...
    GraphicsManager::GraphicsManager(android_app* pApplication) :
        ...
        mTextures(), mTextureCount(0),
        mShaders(), mShaderCount(0),
        mVertexBuffers(), mVertexBufferCount(0),
        mComponents(), mComponentCount(0) {
        Log::info("Creating GraphicsManager.");
    }
    
    ...
    
    void GraphicsManager::stop() {
        Log::info("Stopping GraphicsManager.");
        ...
    
        for (int32_t i = 0; i < mVertexBufferCount; ++i) {
     glDeleteBuffers(1, &mVertexBuffers[i]);
     }
     mVertexBufferCount = 0;
    
        // Destroys OpenGL context.
        ...
    }
    ...
    
  3. 创建新的方法loadVertexBuffer(),将给定内存位置的数据上传到 OpenGL 顶点缓冲区。与在计算机内存中使用动态顶点缓冲区的 SpriteBatch 示例相比,下面的顶点缓冲区是静态的,位于 GPU 内存中。这使得它更快但也相对不灵活。为此:

    • 使用glGenBuffers()生成缓冲区标识符。

    • 使用glBindBuffer()指示我们正在处理一个顶点缓冲区。

    • 使用glBufferData()将顶点数据从给定的内存位置推送到 OpenGL 顶点缓冲区。

    • 解绑顶点缓冲区,将 OpenGL 恢复到之前的状态。这并非严格必要,但对于纹理来说是有帮助的,可以避免未来绘制调用时的配置错误。

    • 你可以使用glGetError()检查顶点缓冲区是否已正确创建:

      ...
      GLuint GraphicsManager::loadVertexBuffer(const void* pVertexBuffer,
              int32_t pVertexBufferSize) {
          GLuint vertexBuffer;
          // Upload specified memory buffer into OpenGL.
          glGenBuffers(1, &vertexBuffer);
          glBindBuffer(GL_ARRAY_BUFFER, vertexBuffer);
          glBufferData(GL_ARRAY_BUFFER, pVertexBufferSize, pVertexBuffer,
                  GL_STATIC_DRAW);
          // Unbinds the buffer.
          glBindBuffer(GL_ARRAY_BUFFER, 0);
          if (glGetError() != GL_NO_ERROR) goto ERROR;
      
          mVertexBuffers[mVertexBufferCount++] = vertexBuffer;
          return vertexBuffer;
      
      ERROR:
          Log::error("Error loading vertex buffer.");
          if (vertexBuffer > 0) glDeleteBuffers(1, &vertexBuffer);
          return 0;
      }
      ...
      
  4. jni/StarField.hpp中定义新的StarField组件。

    重写GraphicsComponent方法,就像之前做的那样。

    定义一个特定的Vertex结构,包含 3 个坐标xyz

    星场由mStarCount中的星星数量和一个代表单个星星的纹理mTextureResource来特征化。

    我们需要一些 OpenGL 资源:一个顶点缓冲区、一个纹理以及一个包含其变量的着色器程序:

    • aPosition,代表星星的位置。

    • uProjection,即正交投影矩阵。

    • uTimeTimeManager给出的总经过时间。这是模拟星星移动的必要条件。

    • uHeight,即显示的高度。当星星到达屏幕边界时,它们将被回收。

    • uTexture,包含星星图片。

      #ifndef _PACKT_STARFIELD_HPP_
      #define _PACKT_STARFIELD_HPP_
      
      #include "GraphicsManager.hpp"
      #include "TimeManager.hpp"
      #include "Types.hpp"
      
      #include <GLES2/gl2.h>
      
      class StarField : public GraphicsComponent {
      public:
          StarField(android_app* pApplication, TimeManager& pTimeManager,
                  GraphicsManager& pGraphicsManager, int32_t pStarCount,
                  Resource& pTextureResource);
      
          status load();
          void draw();
      
      private:
          struct Vertex {
              GLfloat x, y, z;
          };
      
          TimeManager& mTimeManager;
          GraphicsManager& mGraphicsManager;
      
          int32_t mStarCount;
          Resource& mTextureResource;
      
          GLuint mVertexBuffer; GLuint mTexture; GLuint mShaderProgram;
          GLuint aPosition; GLuint uProjection;
          GLuint uTime; GLuint uHeight; GLuint uTexture;
      };
      #endif
      
  5. 创建jni/StarField.cpp并实现其构造函数:

    #include "Log.hpp"
    #include "StarField.hpp"
    
    StarField::StarField(android_app* pApplication,
        TimeManager& pTimeManager, GraphicsManager& pGraphicsManager,
        int32_t pStarCount, Resource& pTextureResource):
            mTimeManager(pTimeManager),
            mGraphicsManager(pGraphicsManager),
            mStarCount(pStarCount),
            mTextureResource(pTextureResource),
            mVertexBuffer(0), mTexture(-1), mShaderProgram(0),
            aPosition(-1),
            uProjection(-1), uHeight(-1), uTime(-1), uTexture(-1) {
        mGraphicsManager.registerComponent(this);
    }
    ...
    
  6. 星场的逻辑主要在顶点着色器中实现。每个由单个顶点表示的星星根据时间、速度(是恒定的)和星星距离从上到下移动。它越远(距离由z顶点分量确定),滚动越慢。

    GLSL 函数mod,代表取模,当星星到达屏幕底部时重置其位置。最终的星星位置保存在预定义变量gl_Position中。

    星星在屏幕上的大小也是其距离的函数。大小以像素单位保存在预定义变量gl_PointSize中:

    ...
    static const char* VERTEX_SHADER =
       "attribute vec4 aPosition;\n"
       "uniform mat4 uProjection;\n"
       "uniform float uHeight;\n"
       "uniform float uTime;\n"
       "void main() {\n"
       "    const float speed = -800.0;\n"
       "    const float size = 8.0;\n"
       "    vec4 position = aPosition;\n"
       "    position.x = aPosition.x;\n"
       "    position.y = mod(aPosition.y + (uTime * speed * aPosition.z),"
       "                                              uHeight);\n"
       "    position.z = 0.0;\n"
       "    gl_Position =  uProjection * position;\n"
       "    gl_PointSize = aPosition.z * size;"
       "}";
    ...
    

    片段着色器要简单得多,只在屏幕上绘制星星纹理:

    ...
    static const char* FRAGMENT_SHADER =
        "precision mediump float;\n"
        "uniform sampler2D uTexture;\n"
        "void main() {\n"
        "  gl_FragColor = texture2D(uTexture, gl_PointCoord);\n"
        "}";
    ...
    
  7. load()函数中,借助GraphicsManager中实现的loadVertexBuffer()方法生成顶点缓冲区。每个星星由一个顶点表示。屏幕上的位置和深度是随机生成的。深度在[0.0, 1.0]范围内确定。完成此操作后,释放临时内存缓冲区,该缓冲区保存星星场数据:

    ...
    status StarField::load() {
        Log::info("Loading star field.");
        TextureProperties* textureProperties;
    
        // Allocates a temporary buffer and populate it with point data:
        // 1 vertices composed of 3 floats (X/Y/Z) per point.
        Vertex* vertexBuffer = new Vertex[mStarCount];
        for (int32_t i = 0; i < mStarCount; ++i) {
            vertexBuffer[i].x = RAND(mGraphicsManager.getRenderWidth());
            vertexBuffer[i].y = RAND(mGraphicsManager.getRenderHeight());
            vertexBuffer[i].z = RAND(1.0f);
        }
        // Loads the vertex buffer into OpenGL.
        mVertexBuffer = mGraphicsManager.loadVertexBuffer(
            (uint8_t*) vertexBuffer, mStarCount * sizeof(Vertex));
        delete[] vertexBuffer;
        if (mVertexBuffer == 0) goto ERROR;
    ...
    
  8. 然后,加载star纹理并从上面定义的着色器生成程序。获取它们的属性和统一标识符:

    ...
        // Loads the texture.
        textureProperties =
                mGraphicsManager.loadTexture(mTextureResource);
        if (textureProperties == NULL) goto ERROR;
        mTexture = textureProperties->texture;
    
        // Creates and retrieves shader attributes and uniforms.
        mShaderProgram = mGraphicsManager.loadShader(VERTEX_SHADER,
                FRAGMENT_SHADER);
        if (mShaderProgram == 0) goto ERROR;
        aPosition = glGetAttribLocation(mShaderProgram, "aPosition");
        uProjection = glGetUniformLocation(mShaderProgram,"uProjection");
        uHeight = glGetUniformLocation(mShaderProgram, "uHeight");
        uTime = glGetUniformLocation(mShaderProgram, "uTime");
        uTexture = glGetUniformLocation(mShaderProgram, "uTexture");
    
        return STATUS_OK;
    
    ERROR:
        Log::error("Error loading starfield");
        return STATUS_KO;
    }
    ...
    
  9. 最后,通过在一次绘制调用中发送静态顶点缓冲区、纹理和着色器程序来渲染star场。为此:

    • 禁用混合,即透明度的管理。实际上,星星“粒子”很小,稀疏,并且是在黑色背景上绘制的。

    • 首先选择顶点缓冲区glBindBuffer()。当在加载时生成了一个静态顶点缓冲区时,这个调用是必要的。

    • 使用glVertexAttribPointer()指示顶点数据的结构,并通过glEnableVertexAttribArray()关联到哪个着色器属性。请注意,这次glVertexAttribPointer()的最后一个参数不是指向缓冲区的指针,而是顶点缓冲区内的索引。实际上,顶点缓冲区是静态的,位于 GPU 内存中,因此我们不知道它的地址。

    • 使用glActiveTexture()glBindTexture()选择要绘制的纹理。

    • 使用glUseProgram()选择着色器程序。

    • 使用glUniform函数变体绑定程序参数。

    • 最后,通过glDrawArrays()向 OpenGL 发送绘制调用。

    然后,你可以恢复 OpenGL 管道状态:

    ...
    void StarField::draw() {
        glDisable(GL_BLEND);
    
        // Selects the vertex buffer and indicates how data is stored.
        glBindBuffer(GL_ARRAY_BUFFER, mVertexBuffer);
        glEnableVertexAttribArray(aPosition);
        glVertexAttribPointer(aPosition, // Attribute Index
                              3, // Number of components
                              GL_FLOAT, // Data type
                              GL_FALSE, // Normalized
                              3 * sizeof(GLfloat), // Stride
                              (GLvoid*) 0); // First vertex
    
        // Selects the texture.
        glActiveTexture(GL_TEXTURE0);
        glBindTexture(GL_TEXTURE_2D, mTexture);
    
        // Selects the shader and passes parameters.
        glUseProgram(mShaderProgram);
        glUniformMatrix4fv(uProjection, 1, GL_FALSE,
                mGraphicsManager.getProjectionMatrix());
        glUniform1f(uHeight, mGraphicsManager.getRenderHeight());
        glUniform1f(uTime, mTimeManager.elapsedTotal());
        glUniform1i(uTexture, 0);
    
        // Renders the star field.
        glDrawArrays(GL_POINTS, 0, mStarCount);
    
        // Restores device state.
        glBindBuffer(GL_ARRAY_BUFFER, 0);
        glUseProgram(0);
    }
    
  10. jni/DroidBlaster.hpp中,定义新的StarField组件以及一个新的纹理资源:

    ...
    #include "Ship.hpp"
    #include "SpriteBatch.hpp"
    #include "StarField.hpp"
    #include "TimeManager.hpp"
    #include "Types.hpp"
    
    class DroidBlaster : public ActivityHandler {
        ...
    private:
        ...
        Resource mAsteroidTexture;
        Resource mShipTexture;
        Resource mStarTexture;
    
        Asteroid mAsteroids;
        Ship mShip;
        StarField mStarField;
        SpriteBatch mSpriteBatch;
    };
    #endif
    
  11. jni/DroidBlaster.cpp构造函数中实例化它,使用50个星星:

    ...
    
    static const int32_t STAR_COUNT = 50;
    
    DroidBlaster::DroidBlaster(android_app* pApplication):
        mTimeManager(),
        mGraphicsManager(pApplication),
        mPhysicsManager(mTimeManager, mGraphicsManager),
        mEventLoop(pApplication, *this),
    
        mAsteroidTexture(pApplication, "droidblaster/asteroid.png"),
        mShipTexture(pApplication, "droidblaster/ship.png"),
        mStarTexture(pApplication, "droidblaster/star.png"),
    
        mAsteroids(pApplication, mTimeManager, mGraphicsManager,
                mPhysicsManager),
        mShip(pApplication, mGraphicsManager),
        mStarField(pApplication, mTimeManager, mGraphicsManager,
                STAR_COUNT, mStarTexture),
        mSpriteBatch(mTimeManager, mGraphicsManager) {
        Log::info("Creating DroidBlaster");
        ...
    }
    

在运行DroidBlaster之前,将droidblaster/star.png文件添加到 assets 目录中。这些文件随本书一起提供,位于DroidBlaster_Part8/assets目录。

刚才发生了什么?

运行DroidBlaster。在随机速度滚动屏幕时,星域应该看起来像下面的截图所示:

刚才发生了什么?

所有这些星星都是作为点精灵渲染的,其中每个点代表一个由以下确定的四边形:

  • 屏幕上的一个位置:该位置表示点精灵的中心。

  • 一个点的大小:大小隐式定义了点精灵四边形的尺寸。

点精灵是创建粒子效果的一种有趣方式,但它们有一些缺点,包括:

  • 它们可能的大小或多或少受到硬件能力的限制。你可以通过使用glGetFloatv()查询GL_ALIASED_POINT_SIZE_RANGE来找到最大尺寸;以下示例将展示这一点:

    float pointSizeRange[2];
    glGetFloatv(GL_ALIASED_POINT_SIZE_RANGE, pointSizeRange);
    
  • 如果你绘制更大的点精灵,你会注意到粒子在它们的中心被剪裁(即遮罩),整个精灵边界没有超出屏幕。

因此,根据你的需要,使用传统的顶点可能更合适。

谈到顶点,你可能已经注意到我们没有创建一个顶点数组,而是创建了一个顶点缓冲对象。实际上,点精灵完全在顶点着色器中评估。这种优化允许我们使用静态几何体(使用提示GL_STATIC_DRAWglBufferData()),驱动程序可以有效地管理它。请注意,顶点缓冲对象也可以被标记为需要更新,使用提示GL_DYNAMIC_DRAW(意味着缓冲区将频繁变化)或GL_STREAM_DRAW(意味着缓冲区将使用一次后丢弃)。创建 VBO 的过程与在 OpenGL 中创建任何其他类型对象的过程类似,涉及生成新的标识符,选择它,并最终将数据上传到驱动程序内存。如果你理解这个过程,你就理解了 OpenGL 的工作方式。

使用 GLSL 编程着色器

着色器是用 GLSL 编写的,这是一种相对高级的编程语言,允许定义函数(带有 in、out 和 inout 参数)、条件语句、循环、变量、数组、结构、算术运算符等等。它尽可能地抽象了硬件的特定性。GLSL 允许使用以下类型的变量:

attributes 这些包含每个顶点的数据,例如顶点位置或纹理坐标。每次着色器执行时只处理一个顶点。
const 它表示编译时常量或只读函数参数。
uniforms 这些是一种全局参数,可以根据每个图元(即每次绘制调用)进行更改。对于整个网格来说,它具有相同的值。这方面的一个例子可能是模型视图矩阵(对于顶点着色器)或纹理(对于片段着色器)。
varying 这些是根据顶点着色器输出计算的每个像素的插值值。它们在顶点着色器中是输出参数,在片段着色器中是输入参数。在 OpenGL ES 3 中,“varying”参数有新的语法:在顶点着色器中使用 out,在像素着色器中使用 in

可以声明此类变量的主要参数类型如下表所示:

void 这仅用于函数结果。
bool 这是一个布尔值。
float 这是一个浮点数。
int 这是一个有符号整数。
vec2, vec3, vec4 这是一个浮点数向量。存在其他类型的向量,例如用于布尔值的 bvec 或用于有符号整数的 ivec
mat2, mat3, mat4 这些是 2x2、3x3 和 4x4 浮点矩阵。
sampler2D 这提供了对 2D 纹理纹理元素(texel)的访问。

请注意 GLSL 规范提供了一些预定义的变量,如下表所示:

highp vec4 gl_Position 顶点着色器输出 这是变换后的顶点位置。
mediump float gl_PointSize 顶点着色器输出 这是点精灵的大小,以像素为单位(关于这一点将在下一部分中讨论)。
mediump vec4 gl_FragCoord 片段着色器输入 这些是片段在帧缓冲区内的坐标。
mediump vec4 gl_FragColor 片段着色器输出 这是要为片段显示的颜色。

提供了许多函数,主要是算术函数,例如 sin()cos()tan()radians()degrees()mod()abs()floor()ceil()dot()cross()normalize()texture2D() 等等。

在处理着色器时,以下是一些需要记住的最佳实践:

  • 不要在运行时编译或链接着色器。

  • 注意不同硬件具有不同的功能,特别是允许的变量数量有限。

  • 在定义精度说明符时(例如highpmediumlowp),在性能和准确性之间找到一个好的折中方案。不要犹豫,重新定义它们以获得一致的行为。注意,float精度说明符应在 GLES 片段着色器中定义。

  • 尽可能避免条件分支。

想了解更多信息,请查看 OpenGL.org 维基页面:www.opengl.org/wiki/OpenGL_Shading_Languagewww.opengl.org/wiki/Vertex_Shaderwww.opengl.org/wiki/Fragment_Shader

注意,这些页面的内容适用于 OpenGL,但不一定适用于 GLES。

适配各种分辨率的图形

在编写游戏时,需要处理的一个复杂主题是安卓屏幕尺寸的碎片化。低端手机的分辨率只有几百像素,而一些高端设备提供的分辨率则超过两千。

有多种方法可以处理不同的屏幕尺寸。我们可以适配图形资源,使用屏幕周围的黑色带,或者将响应式设计应用于游戏。

另一个简单的解决方案是使用固定大小离屏渲染游戏场景。然后将离屏帧缓冲区复制到屏幕上,并缩放到适当的大小。这种“一刀切”技术并不能提供最佳质量,在低端设备上可能会有些慢(特别是如果它们的分辨率低于离屏帧缓冲区)。然而,应用它相当简单。

注意

本书的附赠项目中提供了名为DroidBlaster_Part9的结果项目。

动手时间——通过离屏渲染适配分辨率

让我们在离屏渲染游戏场景:

  1. 更改jni/GraphicsManager.hpp,然后执行以下步骤:

    • 定义获取屏幕宽度和高度的新方法,以及它们对应的成员变量

    • 创建一个新函数initializeRenderBuffer(),用于创建离屏缓冲区以渲染场景:

      ...
      class GraphicsManager {
      public:
          ...
          int32_t getRenderWidth() { return mRenderWidth; }s
          int32_t getRenderHeight() { return mRenderHeight; }
          int32_t getScreenWidth() { return mScreenWidth; }
       int32_t getScreenHeight() { return mScreenHeight; }
          GLfloat* getProjectionMatrix() { return mProjectionMatrix[0]; }
      
      ...
      
  2. 在同一文件中,执行以下步骤:

    • 声明一个新的RenderVertex结构,包含四个分量 - xyuv

    • 定义帧缓冲区所需的 OpenGL 资源,即纹理、顶点缓冲区、着色器程序及其变量:

      ...
      private:
          status initializeRenderBuffer();
      
       struct RenderVertex {
       GLfloat x, y, u, v;
          };
      
          android_app* mApplication;
      
          int32_t mRenderWidth; int32_t mRenderHeight;
          int32_t mScreenWidth; int32_t mScreenHeight;
          EGLDisplay mDisplay; EGLSurface mSurface; EGLContext mContext;
          GLfloat mProjectionMatrix[4][4];
          ...
      
          // Rendering resources.
       GLint mScreenFrameBuffer;
       GLuint mRenderFrameBuffer; GLuint mRenderVertexBuffer;
       GLuint mRenderTexture; GLuint mRenderShaderProgram;
       GLuint aPosition; GLuint aTexture;
       GLuint uProjection; GLuint uTexture;
      };
      #endif
      
  3. 更新jni/GraphicsManager.cpp构造函数初始化列表,以初始化默认值:

    #include "GraphicsManager.hpp"
    #include "Log.hpp"
    
    #include <png.h>
    
    GraphicsManager::GraphicsManager(android_app* pApplication) :
        ...
        mComponents(), mComponentCount(0),
        mScreenFrameBuffer(0),
     mRenderFrameBuffer(0), mRenderVertexBuffer(0),
     mRenderTexture(0), mRenderShaderProgram(0),
     aPosition(0), aTexture(0),
     uProjection(0), uTexture(0) {
        Log::info("Creating GraphicsManager.");
    }
    ...
    
  4. 更改start()方法,分别将显示表面宽度与高度保存到mScreenWidthmScreenHeight中。

    然后,调用initializeRenderBuffer()

    ...
    status GraphicsManager::start() {
        ...
        Log::info("Initializing the display.");
        mSurface = eglCreateWindowSurface(mDisplay, config,
            mApplication->window, NULL);
        if (mSurface == EGL_NO_SURFACE) goto ERROR;
        mContext = eglCreateContext(mDisplay, config, NULL,
            CONTEXT_ATTRIBS);
        if (mContext == EGL_NO_CONTEXT) goto ERROR;
    
        if (!eglMakeCurrent(mDisplay, mSurface, mSurface, mContext)
       || !eglQuerySurface(mDisplay, mSurface, EGL_WIDTH, &mScreenWidth)
     || !eglQuerySurface(mDisplay, mSurface, EGL_HEIGHT, &mScreenHeight)
     || (mScreenWidth <= 0) || (mScreenHeight <= 0)) goto ERROR;
    
     // Defines and initializes offscreen surface.
     if (initializeRenderBuffer() != STATUS_OK) goto ERROR;
    
        glViewport(0, 0, mRenderWidth, mRenderHeight);
        glDisable(GL_DEPTH_TEST);
        ...
    }
    ...
    
  5. 为离屏渲染定义顶点和片段着色器。这与我们至今所见的类似:

    ...
    static const char* VERTEX_SHADER =
        "attribute vec2 aPosition;\n"
        "attribute vec2 aTexture;\n"
        "varying vec2 vTexture;\n"
        "void main() {\n"
        "    vTexture = aTexture;\n"
        "    gl_Position = vec4(aPosition, 1.0, 1.0 );\n"
        "}";
    
    static const char* FRAGMENT_SHADER =
        "precision mediump float;"
        "uniform sampler2D uTexture;\n"
        "varying vec2 vTexture;\n"
        "void main() {\n"
        "  gl_FragColor = texture2D(uTexture, vTexture);\n"
        "}\n";
    ...
    
  6. initializeRenderBuffer()中,创建一个预定义的顶点数组,该数组将要被加载到 OpenGL 中。它代表了一个带有完整纹理的单一四边形。

    计算基于固定目标宽度600像素的新渲染高度。

    使用glGetIntegerv()和特殊值GL_FRAMEBUFFER_BINDING从最终场景渲染的位置获取当前屏幕帧缓冲区:

    ...
    const int32_t DEFAULT_RENDER_WIDTH = 600;
    
    status GraphicsManager::initializeRenderBuffer() {
        Log::info("Loading offscreen buffer");
        const RenderVertex vertices[] = {
            { -1.0f, -1.0f, 0.0f, 0.0f },
            { -1.0f,  1.0f, 0.0f, 1.0f },
            {  1.0f, -1.0f, 1.0f, 0.0f },
            {  1.0f,  1.0f, 1.0f, 1.0f }
        };
    
        float screenRatio = float(mScreenHeight) / float(mScreenWidth);
        mRenderWidth = DEFAULT_RENDER_WIDTH;
        mRenderHeight = float(mRenderWidth) * screenRatio;
        glGetIntegerv(GL_FRAMEBUFFER_BINDING, &mScreenFrameBuffer);
    ...
    
  7. 创建一个用于离屏渲染的纹理,就像我们之前看到的那样。在glTexImage2D()中,传递NULL值作为最后一个参数,只创建表面而不初始化其内容:

    ...
        glGenTextures(1, &mRenderTexture);
        glBindTexture(GL_TEXTURE_2D, mRenderTexture);
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S,
                GL_CLAMP_TO_EDGE);
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T,
                GL_CLAMP_TO_EDGE);
        glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, mRenderWidth,
                mRenderHeight, 0, GL_RGB, GL_UNSIGNED_SHORT_5_6_5, NULL);
    ...
    
  8. 然后,使用glGenFramebuffers()创建一个离屏帧缓冲区。

    使用glBindFramebuffer()将之前的纹理附着到它上面。

    最后,恢复设备状态:

    ...
        glGenFramebuffers(1, &mRenderFrameBuffer);
        glBindFramebuffer(GL_FRAMEBUFFER, mRenderFrameBuffer);
        glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
                GL_TEXTURE_2D, mRenderTexture, 0);
        glBindTexture(GL_TEXTURE_2D, 0);
        glBindFramebuffer(GL_FRAMEBUFFER, 0);
    ...
    
  9. 创建用于将纹理渲染到屏幕的着色器程序,并获取其属性和制服:

    ...
        mRenderVertexBuffer = loadVertexBuffer(vertices,
                sizeof(vertices));
        if (mRenderVertexBuffer == 0) goto ERROR;
    
        mRenderShaderProgram = loadShader(VERTEX_SHADER, FRAGMENT_SHADER);
        if (mRenderShaderProgram == 0) goto ERROR;
        aPosition = glGetAttribLocation(mRenderShaderProgram,"aPosition");
        aTexture = glGetAttribLocation(mRenderShaderProgram, "aTexture");
        uTexture = glGetUniformLocation(mRenderShaderProgram,"uTexture");
    
        return STATUS_OK;
    
    ERROR:
        Log::error("Error while loading offscreen buffer");
        return STATUS_KO;
    }
    ...
    
  10. 在活动结束时,不要忘记在stop()中释放分配的资源:

    ...
    void GraphicsManager::stop() {
        ...
    
        if (mRenderFrameBuffer != 0) {
     glDeleteFramebuffers(1, &mRenderFrameBuffer);
     mRenderFrameBuffer = 0;
     }
     if (mRenderTexture != 0) {
     glDeleteTextures(1, &mRenderTexture);
     mRenderTexture = 0;
     }
    
        // Destroys OpenGL context.
        ...
    }
    ...
    
  11. 最后,使用新的离屏帧缓冲区来渲染场景。为此,你需要:

    使用glBindFramebuffer()选择帧缓冲区。

    指定渲染视口,它必须与离屏帧缓冲区的尺寸相匹配,如下所示:

    ...
    status GraphicsManager::update() {
        glBindFramebuffer(GL_FRAMEBUFFER, mRenderFrameBuffer);
     glViewport(0, 0, mRenderWidth, mRenderHeight);
        glClear(GL_COLOR_BUFFER_BIT);
    
        // Render graphic components.
        for (int32_t i = 0; i < mComponentCount; ++i) {
            mComponents[i]->draw();
        }
    ...
    
  12. 渲染完成后,恢复正常的屏幕帧缓冲区和正确的视口尺寸。

    然后,选择以下参数作为源:

    • 离屏纹理,它附着在离屏帧缓冲区上。

    • 着色器程序,它基本上除了在屏幕帧缓冲区上投影顶点和缩放纹理之外什么也不做。

    • 顶点缓冲区,它包含一个带有纹理坐标的单个四边形,如下代码所示:

      ...
          glBindFramebuffer(GL_FRAMEBUFFER, mScreenFrameBuffer);
       glClear(GL_COLOR_BUFFER_BIT);
       glViewport(0, 0, mScreenWidth, mScreenHeight);
      
       glActiveTexture(GL_TEXTURE0);
       glBindTexture(GL_TEXTURE_2D, mRenderTexture);
       glUseProgram(mRenderShaderProgram);
       glUniform1i(uTexture, 0);
      
       // Indicates to OpenGL how position and uv coordinates are stored.
       glBindBuffer(GL_ARRAY_BUFFER, mRenderVertexBuffer);
       glEnableVertexAttribArray(aPosition);
       glVertexAttribPointer(aPosition, // Attribute Index
       2, // Number of components (x and y)
       GL_FLOAT, // Data type
       GL_FALSE, // Normalized
       sizeof(RenderVertex), // Stride
       (GLvoid*) 0); // Offset
       glEnableVertexAttribArray(aTexture);
       glVertexAttribPointer(aTexture, // Attribute Index
       2, // Number of components (u and v)
       GL_FLOAT, // Data type
       GL_FALSE, // Normalized
       sizeof(RenderVertex), // Stride
       (GLvoid*) (sizeof(GLfloat) * 2)); // Offset
      ...
      
  13. 最后,通过将离屏缓冲区渲染到屏幕上来结束。

    然后,你可以像这样再次恢复设备状态:

    ...
        glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
     glBindBuffer(GL_ARRAY_BUFFER, 0);
    
        // Shows the result to the user.
        if (eglSwapBuffers(mDisplay, mSurface) != EGL_TRUE) {
        ...
    }
    ...
    

刚才发生了什么?

在多个设备上启动应用程序。每个设备应该显示一个比例相似的景象。实际上,现在图形渲染到一个附着在纹理上的离屏帧缓冲区。然后根据目标屏幕分辨率进行缩放,以提供不同设备上的相同体验。这个简单且经济的解决方案有一个缺点,即根据选择的固定分辨率,低端设备可能会受到影响,而高端设备则会显得模糊。

注意

处理各种屏幕分辨率是一方面。管理它们的多种宽高比则是另一方面。这个问题有几种解决方案,比如使用黑边条、拉伸屏幕,或者定义一个只包含重要信息的可显示的最小和最大区域。

通常,离屏渲染场景通常被称为渲染到纹理。这种技术常用于实现阴影、反射或后期处理效果。掌握这项技术是实现高质量游戏的关键。

总结

OpenGL 和一般的图形是一个复杂且技术性很强的 API。一本书不足以完全涵盖它,但使用纹理和缓冲对象绘制 2D 图形为更高级的内容打开了大门!

更具体地说,你已经学会了如何初始化一个 OpenGL ES 上下文并将其绑定到一个 Android 窗口。然后,你了解了如何将 libpng 转换为一个模块,并从 PNG 资源中加载纹理。我们使用了这个纹理,并结合顶点缓冲区和着色器来渲染精灵和粒子。最后,我们找到了一个简单的离屏渲染和缩放技术解决方案,以解决 Android 分辨率碎片化问题。

OpenGL ES 是一个复杂的 API,需要深入理解才能获得最佳性能和品质。这同样适用于自 Android KitKat 以来可用的 OpenGL ES 3,我们在这里并未涉及。不妨查看以下内容:

在这里获得的知识,使得通往 OpenGL ES 2 或 3 的道路变得完全可行!现在,让我们在下一章中探索如何通过 OpenSL ES 达到第四维度,即音乐维度。

第七章:使用 OpenSL ES 播放声音

多媒体不仅仅是关于图形,还关乎声音和音乐。这一领域的应用程序在 Android 市场中是最受欢迎的。事实上,音乐一直是推动移动设备销售的重要动力,音乐爱好者始终是首选目标群体。这就是为什么像 Android 这样的操作系统可能没有一定的音乐才能就无法走得更远!嵌入式系统的开放声音库,通常称为 OpenSL ES,是声音领域的 OpenGL。尽管它相对底层,但它是所有与声音相关的任务,无论是输入还是输出,都是一流的 API。

当谈论 Android 上的声音时,我们应该区分 Java 和本地世界。实际上,两边拥有完全不同的 API:一方面是MediaPlayerSoundPoolAudioTrackJetPlayer,另一方面是OpenSL ES

  • MediaPlayer 是更高级且易于使用的。它不仅处理音乐,还处理视频。当只需要简单的文件播放时,它是首选的方法。

  • SoundPool 和 AudioTrack 更底层,播放声音时更接近低延迟。AudioTrack 虽然最灵活,但也最复杂。它允许在运行中手动修改声音缓冲区。

  • JetPlayer 更专注于 MIDI 文件的播放。这个 API 对于多媒体应用程序或游戏中的动态音乐合成可能很有趣(请参阅 Android SDK 提供的 JetBoy 示例)。

  • OpenSL ES 旨在为嵌入式系统提供跨平台的音频管理 API;换句话说,就是音频领域的 OpenGL ES。与 GLES 一样,其规范由 Khronos 组织领导。在 Android 上,OpenSL ES 实际上是在 AudioTrack API 之上实现的。

OpenSL ES 首次在 Android 2.3 Gingerbread 版本中发布,之前的版本(Android 2.2 及以下)并未提供。尽管 Java 中有大量的 API,但 OpenSL ES 是唯一在本地端提供的,且仅限于此。

然而,OpenSL ES 仍不够成熟。OpenSL 规范仍然没有得到完全支持,预计会有一些限制。此外,尽管 OpenSL 1.1 版本已经发布,但 Android 上实现的 OpenSL 规范仍然是 1.0.1 版本。因此,由于 OpenSL ES 的实现仍在不断发展,未来可能会出现一些重大变化。

只有系统编译时包含适当配置文件的设备才能通过 OpenSL ES 使用 3D 音频功能。实际上,当前的 OpenSL ES 规范提供了三种不同的配置文件,分别是针对不同类型设备的游戏、音乐和电话配置文件。在本书撰写之时,这些配置文件都不被支持。

然而,OpenSL ES 有其优点。首先,它可能更容易集成到本地应用程序的架构中,因为它本身是用 C/C++编写的。它不需要背负垃圾收集器。本地代码不是解释执行的,可以通过汇编代码进行深度优化。这些都是考虑使用它的众多原因之一。

本章是介绍在 Android NDK 上 OpenSL ES 的音乐功能。我们将发现如何进行以下操作:

  • 在 Android 上初始化 OpenSL ES

  • 播放背景音乐

  • 使用声音缓冲队列播放声音

  • 录制声音并播放

音频,特别是实时音频是一个高度技术化的课题。本章涵盖了将声音和音乐嵌入到您自己的应用程序中的基础知识。

初始化 OpenSL ES

如果不先初始化 OpenSL,它将不会非常有用。像往常一样,这一步需要一些样板代码。OpenSL 的繁琐并不会改善这种情况。让我们通过创建一个新的SoundManager类来包装与 OpenSL ES 相关的逻辑,以此开始本章内容。

注意

本书提供的最终项目名为DroidBlaster_Part10

动手实践——创建 OpenSL ES 引擎和输出

让我们创建一个专门用于声音的新管理器:

  1. 创建一个新文件jni/SoundManager.hpp

    首先,包含 OpenSL ES 标准头文件SLES/OpenSLES.h。后两个定义了对象和方法,专门为 Android 创建。然后,创建SoundManager类以执行以下操作:

    • 使用start()方法初始化 OpenSL ES

    • 使用stop()方法停止声音并释放 OpenSL ES

    OpenSL ES 中有两种主要的伪对象结构(即包含应用于结构本身的函数指针的结构,例如具有此的 C++ 对象):

    • 对象:这些由SLObjectItf表示,提供了一些常见方法来获取分配的资源和对对象接口的访问。这可以大致与 Java 中的对象相比较。

    • 接口:这些提供了访问对象特性的途径。一个对象可以有多个接口。根据主机设备的不同,某些接口可能可用或不可用。这些大致可以与 Java 中的接口相比较。

    SoundManager中,声明两个SLObjectItf实例,一个用于 OpenSL ES 引擎,另一个用于扬声器。引擎可以通过SLEngineItf接口获得:

    #ifndef _PACKT_SoundManager_HPP_
    #define _PACKT_SoundManager_HPP_
    
    #include "Types.hpp"
    
    #include <android_native_app_glue.h>
    #include <SLES/OpenSLES.h>
    
    class SoundManager {
    public:
        SoundManager(android_app* pApplication);
    
        status start();
        void stop();
    
    private:
        android_app* mApplication;
    
        SLObjectItf mEngineObj; SLEngineItf mEngine;
        SLObjectItf mOutputMixObj;
    };
    #endif
    
  2. jni/SoundManager.cpp中实现SoundManager及其构造函数:

    #include "Log.hpp"
    #include "Resource.hpp"
    #include "SoundManager.hpp"
    
    SoundManager::SoundManager(android_app* pApplication) :
        mApplication(pApplication),
        mEngineObj(NULL), mEngine(NULL),
        mOutputMixObj(NULL) {
        Log::info("Creating SoundManager.");
    }
    ...
    
  3. 编写start()方法,该方法将创建一个 OpenSL 引擎对象和一个Output Mix对象。我们需要每个对象三个变量来进行初始化:

    • 每个对象需要支持接口的数量(engineMixIIDCountoutputMixIIDCount)。

    • 所有接口对象应支持的接口数组(engineMixIIDsoutputMixIIDs),例如引擎的SL_IID_ENGINE

    • 一个布尔值数组,用于指示接口对程序是必需的还是可选的(engineMixReqsoutputMixReqs)。

      ...
      status SoundManager::start() {
          Log::info("Starting SoundManager.");
          SLresult result;
          const SLuint32      engineMixIIDCount = 1;
          const SLInterfaceID engineMixIIDs[]   = {SL_IID_ENGINE};
          const SLboolean     engineMixReqs[]   = {SL_BOOLEAN_TRUE};
          const SLuint32      outputMixIIDCount = 0;
          const SLInterfaceID outputMixIIDs[]   = {};
          const SLboolean     outputMixReqs[]   = {};
          ...
      
  4. 继续编写start()方法:

    • 使用slCreateEngine()方法初始化 OpenSL ES 引擎对象(即基本类型SLObjectItf)。当我们创建一个 OpenSL ES 对象时,我们必须指出将要使用的特定接口。在这里,我们请求SL_IID_ENGINE接口,它允许创建其他 OpenSL ES 对象。引擎是 OpenSL ES API 的核心对象。

    • 然后,在引擎对象上调用Realize()。任何 OpenSL ES 对象在使用前都需要实现以分配所需的内部资源。

    • 最后,获取SLEngineItf特定的接口。

    • 引擎接口使我们能够使用CreateOutputMix()方法实例化一个音频输出混合。在这里定义的音频输出混合将声音传送到默认扬声器。它是自主的(播放的声音会自动发送到扬声器),因此在这里无需请求任何特定接口。

          ...
          // Creates OpenSL ES engine and dumps its capabilities.
          result = slCreateEngine(&mEngineObj, 0, NULL,
              engineMixIIDCount, engineMixIIDs, engineMixReqs);
          if (result != SL_RESULT_SUCCESS) goto ERROR;
          result = (*mEngineObj)->Realize(mEngineObj,SL_BOOLEAN_FALSE);
          if (result != SL_RESULT_SUCCESS) goto ERROR;
          result = (*mEngineObj)->GetInterface(mEngineObj, SL_IID_ENGINE,
              &mEngine);
          if (result != SL_RESULT_SUCCESS) goto ERROR;
      
          // Creates audio output.
          result = (*mEngine)->CreateOutputMix(mEngine, &mOutputMixObj,
              outputMixIIDCount, outputMixIIDs, outputMixReqs);
          result = (*mOutputMixObj)->Realize(mOutputMixObj,
              SL_BOOLEAN_FALSE);
      
          return STATUS_OK;
      
      ERROR:
          Log::error("Error while starting SoundManager");
          stop();
          return STATUS_KO;
      }
      ...
      
  5. 编写stop()方法以销毁在start()中创建的内容:

    ...
    void SoundManager::stop() {
        Log::info("Stopping SoundManager.");
    
        if (mOutputMixObj != NULL) {
            (*mOutputMixObj)->Destroy(mOutputMixObj);
            mOutputMixObj = NULL;
        }
        if (mEngineObj != NULL) {
            (*mEngineObj)->Destroy(mEngineObj);
            mEngineObj = NULL; mEngine = NULL;
        }
    }
    
  6. 编辑jni/DroidBlaster.hpp并将我们的新SoundManager嵌入其中:

    ...
    #include "Resource.hpp"
    #include "Ship.hpp"
    #include "SoundManager.hpp"
    #include "SpriteBatch.hpp"
    #include "StarField.hpp"
    ...
    
    class DroidBlaster : public ActivityHandler {
        ...
    private:
        TimeManager     mTimeManager;
        GraphicsManager mGraphicsManager;
        PhysicsManager  mPhysicsManager;
        SoundManager    mSoundManager;
        EventLoop mEventLoop;
    
        ...
    };
    #endif
    
  7. jni/DroidBlaster.cpp中创建、启动和停止声音服务:

    ...
    DroidBlaster::DroidBlaster(android_app* pApplication):
        mTimeManager(),
        mGraphicsManager(pApplication),
        mPhysicsManager(mTimeManager, mGraphicsManager),
        mSoundManager(pApplication),
        mEventLoop(pApplication, *this),
        ...
        mShip(pApplication, mTimeManager, mGraphicsManager) {
        ...
    }
    
    ...
    
    status DroidBlaster::onActivate() {
        Log::info("Activating DroidBlaster");
    
        if (mGraphicsManager.start() != STATUS_OK) return STATUS_KO;
        if (mSoundManager.start() != STATUS_OK) return STATUS_KO;
    
        mAsteroids.initialize();
        ...
    }
    
    void DroidBlaster::onDeactivate() {
        Log::info("Deactivating DroidBlaster");
        mGraphicsManager.stop();
        mSoundManager.stop();
    }
    
  8. 最后,在jni/Android.mk文件中链接到libOpenSLES.so

    ...
    LS_CPP=$(subst $(1)/,,$(wildcard $(1)/*.cpp))
    LOCAL_MODULE := droidblaster
    LOCAL_SRC_FILES := $(call LS_CPP,$(LOCAL_PATH))
    LOCAL_LDLIBS := -landroid -llog -lEGL -lGLESv2 -lOpenSLES
    LOCAL_STATIC_LIBRARIES := android_native_app_glue png
    ...
    

刚才发生了什么?

运行应用程序并检查是否有错误记录。我们初始化了 OpenSL ES 库,这使我们可以直接从本地代码访问高效的声音处理原语。当前的代码除了初始化之外,不执行任何操作。扬声器还不会发出声音。

OpenSL ES 的入口点是SLEngineItf,它主要是一个 OpenSL ES 对象工厂。它可以创建到输出设备(扬声器或其他设备)的通道,以及声音播放器或记录器(甚至更多!),我们将在本章后面看到。

SLOutputMixItf是表示音频输出的对象。通常,这将是设备扬声器或耳机。尽管 OpenSL ES 规范允许枚举可用的输出(以及输入)设备,但 NDK 实现还不足以获取或选择适当的设备(SLAudioIODeviceCapabilitiesItf,获取此类信息的官方接口)。因此,在处理输出和输入设备选择时(目前只需指定记录器的输入设备),最好坚持使用默认值,即在SLES/OpenSLES.h中定义的SL_DEFAULTDEVICEID_AUDIOINPUTSL_DEFAULTDEVICEID_AUDIOOUTPUT

当前 Android NDK 实现只允许每个应用程序有一个引擎(这不应成为问题),最多可以创建 32 个对象。但是请注意,任何对象的创建都可能失败,因为这取决于可用的系统资源。

关于 OpenSL ES 理念的更多内容

OpenSL ES 与其图形同伴 GLES 不同,部分原因是因为它没有悠久的历史负担。它是基于对象和接口的(或多或少)面向对象原则构建的。以下定义来自官方规范:

  • 一个对象是对一组资源的抽象,这些资源被分配用于一组明确定义的任务,以及这些资源的状态。对象在创建时确定了其类型。对象类型决定了对象可以执行的任务集。这可以看作类似于 C++ 中的类。

  • 一个接口是对一组相关功能的抽象,特定对象提供这些功能。接口包括一组方法,即接口的函数。接口也有一个类型,它决定了接口的确切方法集。我们可以将接口本身定义为其类型与相关对象的组合。

  • 一个接口 ID用于识别接口类型。此标识符在源代码中使用,以引用接口类型。

OpenSL ES 对象的设置需要以下几个步骤:

  1. 通过构建方法(通常属于引擎)实例化它。

  2. 实现它以分配必要的资源。

  3. 获取对象接口。一个基本对象只具有非常有限的操作集(Realize()Resume()Destroy()等)。接口提供了对真实对象功能的访问,并描述了可以在对象上执行的操作,例如,一个 Play 接口用于播放或暂停声音。

可以请求任何接口,但只有对象支持的接口才能成功获取。你不能为一个音频播放器获取录音接口,因为它会返回(有时很烦人!)SL_RESULT_FEATURE_UNSUPPORTED(错误代码 12)。从技术角度来说,OpenSL ES 接口是一个包含函数指针(由 OpenSL ES 实现初始化)的结构,带有一个自参数来模拟 C++ 中的对象和 this

struct SLObjectItf_ {
    SLresult (*Realize) (SLObjectItf self, SLboolean async);
    SLresult (*Resume) ( SLObjectItf self, SLboolean async);
    ...
}

在这里,Realize()Resume() 等是可以应用于 SLObjectItf 对象的对象方法。接口的处理方式与此相同。

有关 OpenSL ES 可以提供哪些更详细信息,请参考 Khronos 网站上的规范www.khronos.org/opensles,以及 Android NDK 文档目录中的 OpenSL ES 文档。目前,Android 的实现并没有完全遵守该规范。因此,在发现只有规范中有限的一部分(特别是示例代码)在 Android 上有效时,请不要感到失望。

播放音乐文件

OpenSL ES 已初始化,但扬声器中传出的唯一声音却是沉默!那么,是否可以找到一段不错的背景音乐BGM)并使用 Android NDK 原生播放呢?OpenSL ES 提供了读取如 MP3 文件等音乐文件的必要功能。

注意

本书提供的项目名为 DroidBlaster_Part11

动手操作——播放背景音乐

让我们使用 OpenSL ES 打开并播放一个 MP3 音乐文件:

  1. MP3 文件通过 OpenSL 使用指向所选文件的 POSIX 文件描述符打开。通过定义新的结构 ResourceDescriptor 并添加新的方法 descriptor() 来改进本章前面创建的 jni/ResourceManager.cpp

    ...
    struct ResourceDescriptor {
     int32_t mDescriptor;
     off_t mStart;
     off_t mLength;
    };
    
    class Resource {
    public:
        ...
        status open();
        void close();
        status read(void* pBuffer, size_t pCount);
    
        ResourceDescriptor descriptor();
    
        bool operator==(const Resource& pOther);
    
    private:
        ...
    };
    #endif
    
  2. 实现 jni/ResourceManager.cpp。当然,使用资产管理器 API 打开描述符并填充 ResourceDescriptor 结构:

    ...
    ResourceDescriptor Resource::descriptor() {
        ResourceDescriptor lDescriptor = { -1, 0, 0 };
        AAsset* lAsset = AAssetManager_open(mAssetManager, mPath,
                                            AASSET_MODE_UNKNOWN);
        if (lAsset != NULL) {
            lDescriptor.mDescriptor = AAsset_openFileDescriptor(
                lAsset, &lDescriptor.mStart, &lDescriptor.mLength);
            AAsset_close(lAsset);
        }
        return lDescriptor;
    }
    ...
    
  3. 回到 jni/SoundManager.hpp 并定义两个方法 playBGM()stopBGM() 来播放/停止背景 MP3 文件。

    声明一个 OpenSL ES 对象用于音乐播放,以及以下接口:

    • SLPlayItf 播放和停止音乐文件

    • SLSeekItf 控制位置和循环

      ...
      #include <android_native_app_glue.h>
      #include <SLES/OpenSLES.h>
      #include <SLES/OpenSLES_Android.h>
      
      class SoundManager {
      public:
          ...
          status start();
          void stop();
      
          status playBGM(Resource& pResource);
       void stopBGM();
      
      private:
          ...
          SLObjectItf mEngineObj; SLEngineItf mEngine;
          SLObjectItf mOutputMixObj;
      
          SLObjectItf mBGMPlayerObj; SLPlayItf mBGMPlayer;
       SLSeekItf mBGMPlayerSeek;
      };
      #endif
      
  4. 开始实施 jni/SoundManager.cpp

    包含 Resource.hpp 以获取资产文件描述符的访问权限。

    在构造函数中初始化新成员,并更新 stop() 以自动停止背景音乐(否则一些用户可能会不高兴!):

    #include "Log.hpp"
    #include "Resource.hpp"
    #include "SoundManager.hpp"
    
    SoundManager::SoundManager(android_app* pApplication) :
        mApplication(pApplication),
        mEngineObj(NULL), mEngine(NULL),
        mOutputMixObj(NULL),
        mBGMPlayerObj(NULL), mBGMPlayer(NULL), mBGMPlayerSeek(NULL) {
        Log::info("Creating SoundManager.");
    }
    
    ...
    
    void SoundManager::stop() {
        Log::info("Stopping SoundManager.");
        stopBGM();
    
        if (mOutputMixObj != NULL) {
            (*mOutputMixObj)->Destroy(mOutputMixObj);
            mOutputMixObj = NULL;
        }
        if (mEngineObj != NULL) {
            (*mEngineObj)->Destroy(mEngineObj);
            mEngineObj = NULL; mEngine = NULL;
        }
    }
    ...
    
  5. 实现 playBGM() 以增强管理器的播放功能。

    首先,通过两个主要结构 SLDataSourceSLDataSink 描述我们的音频设置。第一个描述音频输入通道,第二个描述音频输出通道。

    在这里,我们将数据源配置为 MIME 源,以便从文件描述符自动检测文件类型。文件描述符当然是通过调用 ResourceManager::descriptor() 打开的。

    数据接收端(即目标通道)配置为本章第一部分初始化 OpenSL ES 引擎时创建的 OutputMix 对象(并指向默认音频输出,即扬声器或耳机):

    ...
    status SoundManager::playBGM(Resource& pResource) {
        SLresult result;
        Log::info("Opening BGM %s", pResource.getPath());
    
        ResourceDescriptor descriptor = pResource.descriptor();
        if (descriptor.mDescriptor < 0) {
            Log::info("Could not open BGM file");
            return STATUS_KO;
        }
    
        SLDataLocator_AndroidFD dataLocatorIn;
        dataLocatorIn.locatorType = SL_DATALOCATOR_ANDROIDFD;
        dataLocatorIn.fd          = descriptor.mDescriptor;
        dataLocatorIn.offset      = descriptor.mStart;
        dataLocatorIn.length      = descriptor.mLength;
    
        SLDataFormat_MIME dataFormat;
        dataFormat.formatType    = SL_DATAFORMAT_MIME;
        dataFormat.mimeType      = NULL;
        dataFormat.containerType = SL_CONTAINERTYPE_UNSPECIFIED;
    
        SLDataSource dataSource;
        dataSource.pLocator = &dataLocatorIn;
        dataSource.pFormat  = &dataFormat;
    
        SLDataLocator_OutputMix dataLocatorOut;
        dataLocatorOut.locatorType = SL_DATALOCATOR_OUTPUTMIX;
        dataLocatorOut.outputMix   = mOutputMixObj;
    
        SLDataSink dataSink;
        dataSink.pLocator = &dataLocatorOut;
        dataSink.pFormat  = NULL;
    ...
    
  6. 然后,创建 OpenSL ES 音频播放器。与往常一样,首先通过引擎实例化 OpenSL ES 对象,然后实现它。两个接口 SL_IID_PLAYSL_IID_SEEK 是必须的:

    ...
        const SLuint32 bgmPlayerIIDCount = 2;
        const SLInterfaceID bgmPlayerIIDs[] =
            { SL_IID_PLAY, SL_IID_SEEK };
        const SLboolean bgmPlayerReqs[] =
            { SL_BOOLEAN_TRUE, SL_BOOLEAN_TRUE };
    
        result = (*mEngine)->CreateAudioPlayer(mEngine,
            &mBGMPlayerObj, &dataSource, &dataSink,
            bgmPlayerIIDCount, bgmPlayerIIDs, bgmPlayerReqs);
        if (result != SL_RESULT_SUCCESS) goto ERROR;
        result = (*mBGMPlayerObj)->Realize(mBGMPlayerObj,
            SL_BOOLEAN_FALSE);
        if (result != SL_RESULT_SUCCESS) goto ERROR;
    
        result = (*mBGMPlayerObj)->GetInterface(mBGMPlayerObj,
            SL_IID_PLAY, &mBGMPlayer);
        if (result != SL_RESULT_SUCCESS) goto ERROR;
        result = (*mBGMPlayerObj)->GetInterface(mBGMPlayerObj,
            SL_IID_SEEK, &mBGMPlayerSeek);
        if (result != SL_RESULT_SUCCESS) goto ERROR;
    ...
    
  7. 最后,使用 playseek 接口,将播放模式切换为循环模式(即音乐持续播放),从曲目开始(即 0 毫秒)直到其结束(SL_TIME_UNKNOWN),然后开始播放(使用 SL_PLAYSTATE_PLAYINGSetPlayState())。

    ...
        result = (*mBGMPlayerSeek)->SetLoop(mBGMPlayerSeek,
            SL_BOOLEAN_TRUE, 0, SL_TIME_UNKNOWN);
        if (result != SL_RESULT_SUCCESS) goto ERROR;
        result = (*mBGMPlayer)->SetPlayState(mBGMPlayer,
            SL_PLAYSTATE_PLAYING);
        if (result != SL_RESULT_SUCCESS) goto ERROR;
    
        return STATUS_OK;
    
    ERROR:
        Log::error("Error playing BGM");
        return STATUS_KO;
    }
    ...
    
  8. 使用最后一个方法 stopBGM() 来停止并销毁播放器:

    ...
    void SoundManager::stopBGM() {
        if (mBGMPlayer != NULL) {
            SLuint32 bgmPlayerState;
            (*mBGMPlayerObj)->GetState(mBGMPlayerObj,
                &bgmPlayerState);
            if (bgmPlayerState == SL_OBJECT_STATE_REALIZED) {
                (*mBGMPlayer)->SetPlayState(mBGMPlayer,
                    SL_PLAYSTATE_PAUSED);
    
                (*mBGMPlayerObj)->Destroy(mBGMPlayerObj);
                mBGMPlayerObj = NULL;
                mBGMPlayer = NULL; mBGMPlayerSeek = NULL;
            }
        }
    }
    
  9. jni/DroidBlaster.hpp 中添加一个指向音乐文件的资源:

    ...
    class DroidBlaster : public ActivityHandler {
        ...
    private:
        ...
        Resource mAsteroidTexture;
        Resource mShipTexture;
        Resource mStarTexture;
        Resource mBGM;
        ...
    };
    #endif
    
  10. 最后,在 jni/DroidBlaster.cpp 中,在启动 SoundManager 后立即开始播放音乐:

    ...
    DroidBlaster::DroidBlaster(android_app* pApplication):
        ...
        mAsteroidTexture(pApplication, "droidblaster/asteroid.png"),
        mShipTexture(pApplication, "droidblaster/ship.png"),
        mStarTexture(pApplication, "droidblaster/star.png"),
        mBGM(pApplication, "droidblaster/bgm.mp3"),
        ...
        mSpriteBatch(mTimeManager, mGraphicsManager) {
        ...
    }
    ...
    status DroidBlaster::onActivate() {
        Log::info("Activating DroidBlaster");
    
        if (mGraphicsManager.start() != STATUS_OK) return STATUS_KO;
        if (mSoundManager.start() != STATUS_OK) return STATUS_KO;
        mSoundManager.playBGM(mBGM);
    
        mAsteroids.initialize();
        mShip.initialize();
    
        mTimeManager.reset();
        return STATUS_OK;
    }
    ...
    

将一个 MP3 文件复制到 droidblasterassets 目录中,并将其命名为 bgm.mp3

注意

BGM 文件随本书在 DroidBlaster_Part11/assets 目录中提供。

刚才发生了什么?

我们已经了解到如何从 MP3 文件播放音乐片段。播放会一直循环,直到游戏终止。当使用 MIME 数据源时,文件类型会自动检测。目前在 Gingerbread 中支持多种格式,包括 Wave PCM、Wave alaw、Wave ulaw、MP3、Ogg Vorbis 等。目前不支持 MIDI 播放。更多信息请查看 $ANDROID_NDK/docs/opensles/index.html

这里展示的示例代码是 OpenSL ES 工作方式的典型例子。OpenSL ES 引擎对象,基本上是一个对象工厂,创建一个AudioPlayer。在其原始状态下,这个对象做不了太多事情。首先,它需要实现以分配必要的资源。然而,这还不够。它需要检索正确的接口,如SL_IID_PLAY接口,以改变音频播放器的状态为播放/停止。然后,OpenSL API 才能有效使用。

这是一项相当大的工作,考虑到结果验证(因为任何调用都可能失败),这会使代码变得混乱。深入了解这个 API 可能需要比平时更多的时间,但一旦理解,这些概念就变得相当容易处理。

你可能会惊讶地发现,startBGM()stopBGM()分别会重新创建和销毁音频播放器。原因是目前没有办法在不完全重新创建 OpenSL ES AudioPlayer对象的情况下更改 MIME 数据源。因此,尽管这种技术在播放长片段时是可行的,但不适合动态播放短声音。

播放声音

从 MIME 源播放 BGM 的技术非常实用,但遗憾的是,不够灵活。重新创建AudioPlayer对象是不必要的,每次访问资源文件在效率上也不好。

因此,在响应事件快速播放声音并动态生成它们时,我们需要使用声音缓冲队列。每个声音在内存缓冲区预加载或生成,并在请求播放时放入队列中。无需在运行时访问文件!

在当前 OpenSL ES Android 实现中,声音缓冲区可以包含 PCM 数据。脉冲编码调制PCM)是一种专门用于表示数字声音的数据格式。这是 CD 和一些 Wave 文件中使用的格式。PCM 可以是单声道(所有扬声器上相同的声音)或立体声(如果可用,左右扬声器有不同的声音)。

PCM 没有压缩,在存储效率上不高(只需比较一张音乐 CD 和一个装满 MP3 的数据 CD)。然而,这种格式是无损的,提供最佳质量。质量取决于采样率:模拟声音以一系列的测量(即sample)数字形式表示声音信号。

以 44100 Hz(即每秒 44100 次测量)采样的声音样本质量更好,但也比以 16000 Hz 采样的声音占用更多空间。此外,每个测量可以表示得更精细或较不精细(即编码)。在当前 Android 实现中:

  • 声音可以使用 8000 Hz、11025 Hz、12000 Hz、16000 Hz、22050 Hz、24000 Hz、32000 Hz、44100 Hz 或 48000 Hz 的采样率。

  • 样本可以以 8 位无符号或 16 位有符号(更精细的精度)在小端(little-endian)或大端(big-endian)编码。

在以下分步教程中,我们将使用一个以 16 位小端编码的原始 PCM 文件。

注意

结果项目随本书提供,名为 DroidBlaster_Part12

动手操作——创建并播放声音缓冲区队列

让我们使用 OpenSL ES 来播放存储在内存缓冲区中的爆炸声:

  1. 再次更新 jni/Resource.hpp,以添加一个新方法 getLength(),它提供 asset 文件的字节大小:

    ...
    class Resource {
    public:
        ...
    
        ResourceDescriptor descriptor();
        off_t getLength();
        ...
    };
    
    #endif
    
  2. jni/Resource.cpp 中实现这个方法:

    ...
    off_t Resource::getLength() {
        return AAsset_getLength(mAsset);
    }
    ...
    
  3. 创建 jni/Sound.hpp 来管理声音缓冲区。

    定义一个方法 load() 来加载一个 PCM 文件,以及 unload() 来释放它。

    同时,定义适当的获取器。将原始声音数据及其大小保存在缓冲区中。声音是从 Resource 加载的:

    #ifndef _PACKT_SOUND_HPP_
    #define _PACKT_SOUND_HPP_
    
    class SoundManager;
    
    #include "Resource.hpp"
    #include "Types.hpp"
    
    class Sound {
    public:
        Sound(android_app* pApplication, Resource* pResource);
    
        const char* getPath();
        uint8_t* getBuffer() { return mBuffer; };
        off_t getLength() { return mLength; };
    
        status load();
        status unload();
    
    private:
        friend class SoundManager;
    
        Resource* mResource;
        uint8_t* mBuffer; off_t mLength;
    };
    #endif
    
  4. jni/Sound.cpp 中完成的声音加载实现非常简单;它创建一个与 PCM 文件大小相同的缓冲区,并将所有原始文件内容加载到其中:

    #include "Log.hpp"
    #include "Sound.hpp"
    
    #include <SLES/OpenSLES.h>
    #include <SLES/OpenSLES_Android.h>
    
    Sound::Sound(android_app* pApplication, Resource* pResource) :
        mResource(pResource),
        mBuffer(NULL), mLength(0)
    {}
    
    const char* Sound::getPath() {
        return mResource->getPath();
    }
    
    status Sound::load() {
        Log::info("Loading sound %s", mResource->getPath());
        status result;
    
        // Opens sound file.
        if (mResource->open() != STATUS_OK) {
            goto ERROR;
        }
    
        // Reads sound file.
        mLength = mResource->getLength();
        mBuffer = new uint8_t[mLength];
        result = mResource->read(mBuffer, mLength);
        mResource->close();
        return STATUS_OK;
    
    ERROR:
        Log::error("Error while reading PCM sound.");
        return STATUS_KO;
    }
    
    status Sound::unload() {
        delete[] mBuffer;
        mBuffer = NULL; mLength = 0;
    
        return STATUS_OK;
    }
    
  5. 创建 jni/SoundQueue.hpp 来封装播放器对象及其队列的创建。创建三个方法来:

    • 当应用程序启动时初始化 queue 以分配 OpenSL 资源

    • 完成队列以释放 OpenSL 资源

    • 播放预定义长度的声音缓冲区

    可以通过 SLPlayItfSLBufferQueueItf 接口操作声音队列:

    #ifndef _PACKT_SOUNDQUEUE_HPP_
    #define _PACKT_SOUNDQUEUE_HPP_
    
    #include "Sound.hpp"
    
    #include <SLES/OpenSLES.h>
    #include <SLES/OpenSLES_Android.h>
    
    class SoundQueue {
    public:
        SoundQueue();
    
        status initialize(SLEngineItf pEngine, SLObjectItf pOutputMixObj);
        void finalize();
        void playSound(Sound* pSound);
    
    private:
        SLObjectItf mPlayerObj; SLPlayItf mPlayer;
        SLBufferQueueItf mPlayerQueue;
    };
    #endif
    
  6. 实现 jni/SoundQueue.cpp

    #include "Log.hpp"
    #include "SoundQueue.hpp"
    
    SoundQueue::SoundQueue() :
        mPlayerObj(NULL), mPlayer(NULL),
        mPlayerQueue() {
    }
    ...
    
  7. 编写 initialize(),从 SLDataSourceSLDataSink 开始描述输入和输出通道。使用 SLDataFormat_PCM 数据格式(而不是 SLDataFormat_MIME),其中包含采样、编码和字节序信息。声音需要是单声道的(即,如果有左右扬声器,只有一个声音通道)。队列是使用特定于 Android 的扩展 SLDataLocator_AndroidSimpleBufferQueue() 创建的:

    ...
    status SoundQueue::initialize(SLEngineItf pEngine,
            SLObjectItf pOutputMixObj) {
        Log::info("Starting sound player.");
        SLresult result;
    
        // Set-up sound audio source.
        SLDataLocator_AndroidSimpleBufferQueue dataLocatorIn;
        dataLocatorIn.locatorType =
            SL_DATALOCATOR_ANDROIDSIMPLEBUFFERQUEUE;
        // At most one buffer in the queue.
        dataLocatorIn.numBuffers = 1;
    
        SLDataFormat_PCM dataFormat;
        dataFormat.formatType = SL_DATAFORMAT_PCM;
        dataFormat.numChannels = 1; // Mono sound.
        dataFormat.samplesPerSec = SL_SAMPLINGRATE_44_1;
        dataFormat.bitsPerSample = SL_PCMSAMPLEFORMAT_FIXED_16;
        dataFormat.containerSize = SL_PCMSAMPLEFORMAT_FIXED_16;
        dataFormat.channelMask = SL_SPEAKER_FRONT_CENTER;
        dataFormat.endianness = SL_BYTEORDER_LITTLEENDIAN;
    
        SLDataSource dataSource;
        dataSource.pLocator = &dataLocatorIn;
        dataSource.pFormat = &dataFormat;
    
        SLDataLocator_OutputMix dataLocatorOut;
        dataLocatorOut.locatorType = SL_DATALOCATOR_OUTPUTMIX;
        dataLocatorOut.outputMix = pOutputMixObj;
    
        SLDataSink dataSink;
        dataSink.pLocator = &dataLocatorOut;
        dataSink.pFormat = NULL;
    ...
    
  8. 然后,创建并实现声音播放器。我们将需要它的 SL_IID_PLAYSL_IID_BUFFERQUEUE 接口,这得益于前一步配置的数据定位器:

    ...
        const SLuint32 soundPlayerIIDCount = 2;
        const SLInterfaceID soundPlayerIIDs[] =
            { SL_IID_PLAY, SL_IID_BUFFERQUEUE };
        const SLboolean soundPlayerReqs[] =
            { SL_BOOLEAN_TRUE, SL_BOOLEAN_TRUE };
    
        result = (*pEngine)->CreateAudioPlayer(pEngine, &mPlayerObj,
            &dataSource, &dataSink, soundPlayerIIDCount,
            soundPlayerIIDs, soundPlayerReqs);
        if (result != SL_RESULT_SUCCESS) goto ERROR;
        result = (*mPlayerObj)->Realize(mPlayerObj, SL_BOOLEAN_FALSE);
        if (result != SL_RESULT_SUCCESS) goto ERROR;
    
        result = (*mPlayerObj)->GetInterface(mPlayerObj, SL_IID_PLAY,
            &mPlayer);
        if (result != SL_RESULT_SUCCESS) goto ERROR;
        result = (*mPlayerObj)->GetInterface(mPlayerObj,
            SL_IID_BUFFERQUEUE, &mPlayerQueue);
        if (result != SL_RESULT_SUCCESS) goto ERROR;
    ...
    
  9. 最后,通过将队列设置为播放状态来启动队列。这实际上并不意味着会播放声音。队列是空的,所以不可能。但是,如果声音被入队,它将自动播放:

    ...
        result = (*mPlayer)->SetPlayState(mPlayer,
            SL_PLAYSTATE_PLAYING);
        if (result != SL_RESULT_SUCCESS) goto ERROR;
        return STATUS_OK;
    
    ERROR:
        Log::error("Error while starting SoundQueue");
        return STATUS_KO;
    }
    ...
    
  10. 当我们不再需要它们时,需要释放 OpenSL ES 对象:

    ...
    void SoundQueue::finalize() {
        Log::info("Stopping SoundQueue.");
    
        if (mPlayerObj != NULL) {
            (*mPlayerObj)->Destroy(mPlayerObj);
            mPlayerObj = NULL; mPlayer = NULL; mPlayerQueue = NULL;
        }
    }
    ...
    
  11. 最后,编写 playSound(),它首先停止任何正在播放的声音,然后将新的声音缓冲区入队以播放。这是立即播放声音的最简单策略:

    ...
    void SoundQueue::playSound(Sound* pSound) {
        SLresult result;
        SLuint32 playerState;
        (*mPlayerObj)->GetState(mPlayerObj, &playerState);
        if (playerState == SL_OBJECT_STATE_REALIZED) {
            int16_t* buffer = (int16_t*) pSound->getBuffer();
            off_t length = pSound->getLength();
    
            // Removes any sound from the queue.
            result = (*mPlayerQueue)->Clear(mPlayerQueue);
            if (result != SL_RESULT_SUCCESS) goto ERROR;
            // Plays the new sound.
            result = (*mPlayerQueue)->Enqueue(mPlayerQueue, buffer,
                                              length);
            if (result != SL_RESULT_SUCCESS) goto ERROR;
        }
        return;
    
    ERROR:
        Log::error("Error trying to play sound");
    }
    
  12. 打开 jni/SoundManager.hpp 并包含新创建的头文件。

    创建两个新的方法:

    • registerSound() 以加载和管理一个新的声音缓冲区

    • playSound() 将声音缓冲区发送到声音播放队列

    定义一个 SoundQueue 数组,以便最多可以同时播放四个声音。

    声音缓冲区存储在一个固定大小的 C++数组中:

    ...
    #include "Sound.hpp"
    #include "SoundQueue.hpp"
    #include "Types.hpp"
    ...
    
    class SoundManager {
    public:
        SoundManager(android_app* pApplication);
        ~SoundManager();
    
        ...
    
        Sound* registerSound(Resource& pResource);
     void playSound(Sound* pSound);
    
    private:
        ...
        static const int32_t QUEUE_COUNT = 4;
     SoundQueue mSoundQueues[QUEUE_COUNT]; int32_t mCurrentQueue;
     Sound* mSounds[32]; int32_t mSoundCount;
    };
    #endif
    
  13. 更新 jni/SoundManager.cpp 中的构造函数,并创建一个新的析构函数来释放资源:

    ...
    SoundManager::SoundManager(android_app* pApplication) :
        mApplication(pApplication),
        mEngineObj(NULL), mEngine(NULL),
        mOutputMixObj(NULL),
        mBGMPlayerObj(NULL), mBGMPlayer(NULL), mBGMPlayerSeek(NULL),
        mSoundQueues(), mCurrentQueue(0),
     mSounds(), mSoundCount(0) {
        Log::info("Creating SoundManager.");
    }
    
    SoundManager::~SoundManager() {
     Log::info("Destroying SoundManager.");
     for (int32_t i = 0; i < mSoundCount; ++i) {
     delete mSounds[i];
     }
     mSoundCount = 0;
    }
    ...
    
  14. 更新 start() 以初始化 SoundQueue 实例。然后,加载通过 registerSound() 注册的声音资源:

    ...
    status SoundManager::start() {
        ...
        result = (*mEngine)->CreateOutputMix(mEngine, &mOutputMixObj,
            outputMixIIDCount, outputMixIIDs, outputMixReqs);
        result = (*mOutputMixObj)->Realize(mOutputMixObj,
            SL_BOOLEAN_FALSE);
    
        Log::info("Starting sound player.");
     for (int32_t i= 0; i < QUEUE_COUNT; ++i) {
     if (mSoundQueues[i].initialize(mEngine, mOutputMixObj)
     != STATUS_OK) goto ERROR;
        }
    
        for (int32_t i = 0; i < mSoundCount; ++i) {
     if (mSounds[i]->load() != STATUS_OK) goto ERROR;
        }
        return STATUS_OK;
    
    ERROR:
        ...
    }
    ...
    
  15. 当应用程序停止时,完成SoundQueue实例的最终化,以释放 OpenSL ES 资源。同时,释放声音缓冲区:

    ...
    void SoundManager::stop() {
        Log::info("Stopping SoundManager.");
        stopBGM();
    
        for (int32_t i= 0; i < QUEUE_COUNT; ++i) {
     mSoundQueues[i].finalize();
        }
    
        // Destroys audio output and engine.
        ...
    
        for (int32_t i = 0; i < mSoundCount; ++i) {
     mSounds[i]->unload();
        }
    }
    ...
    
  16. registerSound()中保存并缓存声音:

    ...
    Sound* SoundManager::registerSound(Resource& pResource) {
        for (int32_t i = 0; i < mSoundCount; ++i) {
            if (strcmp(pResource.getPath(), mSounds[i]->getPath()) == 0) {
                return mSounds[i];
            }
        }
    
        Sound* sound = new Sound(mApplication, &pResource);
        mSounds[mSoundCount++] = sound;
        return sound;
    }
    ...
    
  17. 最后,编写playSound(),它将缓冲区发送到SoundQueue进行播放。使用简单的轮询策略来同时播放多个声音。将每个新的声音发送到队列中下一个可用的位置进行播放。显然,这种播放策略对于不同长度的声音来说并不是最优的:

    ...
    void SoundManager::playSound(Sound* pSound) {
        int32_t currentQueue = ++mCurrentQueue;
        SoundQueue& soundQueue = mSoundQueues[currentQueue % QUEUE_COUNT];
        soundQueue.playSound(pSound);
    }
    
  18. 当 DroidBlaster 飞船与行星碰撞时,我们将播放一个声音。由于碰撞尚未处理(有关使用Box2D处理碰撞的内容,请参见第十章),我们将在飞船初始化时简单地播放一个声音。

    为此,在jni/Ship.hpp中,在构造函数中获取对SoundManager的引用,并在registerShip()中播放一个碰撞声音缓冲区:

    ...
    #include "GraphicsManager.hpp"
    #include "Sprite.hpp"
    #include "SoundManager.hpp"
    #include "Sound.hpp"
    
    class Ship {
    public:
        Ship(android_app* pApplication,
             GraphicsManager& pGraphicsManager,
             SoundManager& pSoundManager);
    
        void registerShip(Sprite* pGraphics, Sound* pCollisionSound);
    
        void initialize();
    
    private:
        GraphicsManager& mGraphicsManager;
        SoundManager& mSoundManager;
    
        Sprite* mGraphics;
        Sound* mCollisionSound;
    };
    #endif
    
  19. 然后,在jni/Ship.cpp中,在存储了所有必要的引用之后,在初始化飞船时播放声音:

    ...
    Ship::Ship(android_app* pApplication,
            GraphicsManager& pGraphicsManager,
            SoundManager& pSoundManager) :
      mGraphicsManager(pGraphicsManager),
      mGraphics(NULL),
      mSoundManager(pSoundManager),
     mCollisionSound(NULL) {
    }
    
    void Ship::registerShip(Sprite* pGraphics, Sound* pCollisionSound) {
        mGraphics = pGraphics;
        mCollisionSound = pCollisionSound;
    }
    
    void Ship::initialize() {
        mGraphics->location.x = INITAL_X
                * mGraphicsManager.getRenderWidth();
        mGraphics->location.y = INITAL_Y
                * mGraphicsManager.getRenderHeight();
        mSoundManager.playSound(mCollisionSound);
    }
    
  20. jni/DroidBlaster.hpp中,定义一个对包含碰撞声音的文件的引用:

    ...
    class DroidBlaster : public ActivityHandler {
        ...
    
    private:
        ...
        Resource mAsteroidTexture;
        Resource mShipTexture;
        Resource mStarTexture;
        Resource mBGM;
        Resource mCollisionSound;
    
        ...
    };
    #endif
    
  21. 最后,在jni/DroidBlaster.cpp中,注册新的声音并将其传递给Ship类:

    #include "DroidBlaster.hpp"
    #include "Sound.hpp"
    #include "Log.hpp"
    ...
    DroidBlaster::DroidBlaster(android_app* pApplication):
        ...
        mAsteroidTexture(pApplication, "droidblaster/asteroid.png"),
        mShipTexture(pApplication, "droidblaster/ship.png"),
        mStarTexture(pApplication, "droidblaster/star.png"),
        mBGM(pApplication, "droidblaster/bgm.mp3"),
        mCollisionSound(pApplication, "droidblaster/collision.pcm"),
    
        mAsteroids(pApplication, mTimeManager, mGraphicsManager,
                mPhysicsManager),
        mShip(pApplication, mGraphicsManager, mSoundManager),
        mStarField(pApplication, mTimeManager, mGraphicsManager,
                STAR_COUNT, mStarTexture),
        mSpriteBatch(mTimeManager, mGraphicsManager) {
        Log::info("Creating DroidBlaster");
    
        Sprite* shipGraphics = mSpriteBatch.registerSprite(mShipTexture,
                SHIP_SIZE, SHIP_SIZE);
        shipGraphics->setAnimation(SHIP_FRAME_1, SHIP_FRAME_COUNT,
                SHIP_ANIM_SPEED, true);
        Sound* collisionSound =
     mSoundManager.registerSound(mCollisionSound);
     mShip.registerShip(shipGraphics, collisionSound);
        ...
    }
    ...
    

刚才发生了什么?

我们已经了解了如何在缓冲区中预加载声音,并在需要时播放它们。这种声音播放技术与之前看到的背景音乐(BGM)技术的不同之处在于使用了缓冲队列。缓冲队列正是其名称所揭示的:一个先进先出FIFO)的声音缓冲集合,一个接一个地播放。当前一个缓冲区播放完毕后,缓冲区会被加入队列以便播放。

缓冲区可以被回收利用。这种技术与流式文件结合使用时至关重要:两个或多个缓冲区被填充并发送到队列中。当第一个缓冲区播放完毕后,第二个缓冲区开始播放,同时第一个缓冲区被填充新数据。尽可能快地,在队列空之前将第一个缓冲区加入队列。这个过程会一直重复,直到播放结束。此外,缓冲区是原始数据,因此可以在飞行中进行处理或过滤。

在本教程中,因为DroidBlaster不需要同时播放多个声音,也没有流式播放的需求,所以缓冲队列的大小被简单地设置为一个缓冲区(第 7 步,dataLocatorIn.numBuffers = 1;)。此外,我们希望新的声音能够抢占旧的声音,这就解释了为什么队列会被系统地清空。当然,你的 OpenSL ES 架构应根据你的需求来调整。如果需要同时播放多个声音,应该创建多个音频播放器(以及相应的缓冲队列)。

声音缓冲区以 PCM 格式存储,这种格式不能自描述其内部格式。采样率、编码和其他格式信息需要在应用程序代码中选定。尽管这对于大多数情况是合适的,但如果不够灵活,解决方案可以是加载一个 Wave 文件,其中包含所有必要的头信息。

提示

一个很好的开源工具,用于过滤和序列化声音是Audacity。它允许改变采样率以及修改声道(单声道/立体声)。Audacity 能够以原始 PCM 数据的形式导入和导出声音。

使用回调来检测声音队列事件

可以使用回调来检测声音是否播放完毕。通过在队列上调用RegisterCallback()方法可以设置一个回调(但其他类型的对象也可以注册回调)。例如,回调可以接收这个,也就是一个SoundManager自身的引用,以便在需要时允许使用任何上下文信息进行处理。尽管这是可选的,但设置一个事件掩码可以确保仅在触发SL_PLAYEVENT_HEADATEND(播放器已播放完缓冲区)事件时调用回调。OpenSLES.h中还有其他一些播放事件可用:

...
void callback_sound(SLBufferQueueItf pBufferQueue, void *pContext) {
 // Context can be casted back to the original type.
 SoundService& lService = *(SoundService*) pContext;
    ...
    Log::info("Ended playing sound.");
}
...
status SoundService::start() {
    ...
    result = (*mEngine)->CreateOutputMix(mEngine, &mOutputMixObj,
        outputMixIIDCount, outputMixIIDs, outputMixReqs);
    result = (*mOutputMixObj)->Realize(mOutputMixObj,
        SL_BOOLEAN_FALSE);

    // Registers a callback called when sound is finished.
 result = (*mPlayerQueue)->RegisterCallback(mPlayerQueue,
 callback_sound, this);
 if (result != SL_RESULT_SUCCESS) goto ERROR;
 result = (*mPlayer)->SetCallbackEventsMask(mPlayer,
 SL_PLAYEVENT_HEADATEND);
 if (result != SL_RESULT_SUCCESS) goto ERROR;

    Log::info("Starting sound player.");
    ...
}
...

现在,当一个缓冲区播放完毕时,会记录一条消息。可以执行诸如入队新缓冲区(例如处理流式传输)的操作。

安卓上的低延迟

回调类似于系统中断或应用事件,它们的处理必须是短而快的。如果需要进行高级处理,不应该在回调内部执行,而应该在另一个线程上执行——原生线程是完美的候选者。

实际上,回调是在一个系统线程上触发的,这个线程与请求 OpenSL ES 服务的线程不同(在我们的案例中,就是NativeActivity原生线程)。当然,涉及到线程时,就会遇到从回调中访问你自己的变量时的线程安全问题。虽然使用互斥锁保护代码很诱人,但这并不是处理实时音频的最佳方式。它们对调度的效果(例如优先级反转问题)可能会导致播放过程中的故障。

因此,建议使用线程安全的技术,比如使用无锁队列与回调进行通信。无锁技术可以通过使用 GCC 内置的原子函数来实现,例如__sync_fetch_and_add()(它不需要包含任何头文件)。关于使用 Android NDK 进行原子操作的信息,可以查看${ANDROID_NDK}/docs/ANDROID-ATOMICS.html

尽管编写正确的无锁代码对于在安卓上实现低延迟至关重要,但另一个需要考虑的重要点是,并非所有的安卓平台和设备都适合这样做!实际上,低延迟支持在安卓系统中出现得相当晚,从操作系统版本 4.1/4.2 开始提供。如果你需要低延迟,可以使用以下 Java 代码片段来检查它的支持情况:

import android.content.pm.PackageManager;
...
PackageManager pm = getContext().getPackageManager();
boolean claimsFeature = pm.hasSystemFeature(PackageManager.FEATURE_AUDIO_LOW_LATENCY);

然而,请注意!许多设备即使安装了最新的系统版本,由于驱动问题也无法实现低延迟。

当你确定目标平台支持低延迟后,要注意使用适当的采样率和缓冲区大小。实际上,当使用最佳配置时,Android 音频系统提供了一个“快速路径”,不进行任何重采样。为此,从 API 级别 17 或更高版本开始,在 Java 端使用android.media.AudioManager.getProperty()

import android.media.AudioManager;
...
AudioManager am = (AudioManager) getSystemService(Context.AUDIO_SERVICE);
String sampleRateStr =
        am.getProperty(AudioManager.PROPERTY_OUTPUT_SAMPLE_RATE);
int sampleRate = !TextUtils.isEmpty(sampleRateStr) ?
                                Integer.parseInt(sampleRateStr) : -1;
String framesPerBufferStr =
        am.getProperty(AudioManager.PROPERTY_OUTPUT_FRAMES_PER_BUFFER);
int framesPerBuffer = !TextUtils.isEmpty(framesPerBufferStr) ?
                           Integer.parseInt(framesPerBufferStr) : -1;

若要了解更多关于这个主题的信息,请查看高性能音频的演讲。

录制声音

Android 设备都是关于交互的。交互不仅来自触摸和传感器,还来自音频输入。大多数 Android 设备提供了麦克风来录制声音,并允许应用程序如 Android 桌面搜索提供语音功能来记录查询。

如果声音输入可用,OpenSL ES 提供了对录音机的本地访问。它与缓冲队列协作,从输入设备获取数据并填充输出声音缓冲区。这个设置与AudioPlayer的处理非常相似,除了数据源和数据接收器位置互换。

实战英雄——录音与播放声音

为了了解录音是如何工作的,可以在应用程序启动时录音,并在录音完成后播放。将SoundManager转变为录音器可以通过四个步骤完成:

  1. 使用startSoundRecorder()状态来初始化声音录音机。在startSoundPlayer()之后立即调用它。

  2. 使用void recordSound(),开始使用设备麦克风录制声音缓冲区。在应用程序在onActivate()激活时调用此方法,例如背景音乐播放开始后。

  3. 一个新的回调静态void callback_recorder(SLAndroidSimpleBufferQueueItf, void*)用来通知录音队列事件。你需要注册这个回调,以便在录音事件发生时触发它。在这里,我们关心的是缓冲区满的事件,即声音录制完成时。

  4. void playRecordedSound()用于录制声音后播放。在例如callback_recorder()中声音录制完成时播放它。这从技术上来说并不完全正确,因为可能存在竞态条件,但作为示例是足够的。

    注意

    本书提供的成品项目名为DroidBlaster_PartRecorder

在进一步操作之前,录音需要特定的 Android 权限,当然还需要一个合适的 Android 设备(你不会希望应用程序在背后记录你的秘密对话吧!)。这个授权需要在 Android 清单中请求:

<?xml version="1.0" encoding="utf-8"?>
<manifest 
    package="com.packtpub.droidblaster2d" android:versionCode="1"
    android:versionName="1.0">
    ...
    <uses-permission android:name="android.permission.RECORD_AUDIO"/>
</manifest>

创建并发布录音器

声音通常是通过从 OpenSL ES 引擎创建的录音对象进行录音的。录音器提供了两个有趣的接口:

  • SLRecordItf:这个接口用于开始和停止录制。其标识符为SL_IID_RECORD

  • SLAndroidSImpleBufferQueueItf:这个接口管理录音机的声音队列。这是由 NDK 提供的 Android 扩展,因为当前的 OpenSL ES 1.0.1 规范不支持录制到队列中。其标识符为SL_IID_ANDROIDSIMPLEBUFFERQUEUE

    const SLuint32 soundRecorderIIDCount = 2;
    const SLInterfaceID soundRecorderIIDs[] =
            { SL_IID_RECORD, SL_IID_ANDROIDSIMPLEBUFFERQUEUE };
    const SLboolean soundRecorderReqs[] =
            { SL_BOOLEAN_TRUE, SL_BOOLEAN_TRUE };
    SLObjectItf mRecorderObj;
    (*mEngine)->CreateAudioRecorder(mEngine, &mRecorderObj,
            &dataSource, &dataSink,
            soundRecorderIIDCount, soundRecorderIIDs, soundRecorderReqs);
    

要创建录音机,你需要声明你的音频源和接收器,类似于以下内容。数据源不是声音,而是默认的录音设备(如麦克风)。另一方面,数据接收器(即输出通道)不是扬声器,而是 PCM 格式的声音缓冲区(具有请求的采样率、编码和字节序)。由于标准 OpenSL 缓冲队列无法工作,因此必须使用 Android 扩展SLDataLocator_AndroidSimpleBufferQueue来处理录音机:

SLDataLocator_AndroidSimpleBufferQueue dataLocatorOut;
dataLocatorOut.locatorType =
    SL_DATALOCATOR_ANDROIDSIMPLEBUFFERQUEUE;
dataLocatorOut.numBuffers = 1;

SLDataFormat_PCM dataFormat;
dataFormat.formatType = SL_DATAFORMAT_PCM;
dataFormat.numChannels = 1;
dataFormat.samplesPerSec = SL_SAMPLINGRATE_44_1;
dataFormat.bitsPerSample = SL_PCMSAMPLEFORMAT_FIXED_16;
dataFormat.containerSize = SL_PCMSAMPLEFORMAT_FIXED_16;
dataFormat.channelMask = SL_SPEAKER_FRONT_CENTER;
dataFormat.endianness = SL_BYTEORDER_LITTLEENDIAN;

SLDataSink dataSink;
dataSink.pLocator = &dataLocatorOut;
dataSink.pFormat = &dataFormat;

SLDataLocator_IODevice dataLocatorIn;
dataLocatorIn.locatorType = SL_DATALOCATOR_IODEVICE;
dataLocatorIn.deviceType = SL_IODEVICE_AUDIOINPUT;
dataLocatorIn.deviceID = SL_DEFAULTDEVICEID_AUDIOINPUT;
dataLocatorIn.device = NULL;

SLDataSource dataSource;
dataSource.pLocator = &dataLocatorIn;
dataSource.pFormat = NULL;

当应用程序结束时,别忘了释放录音机对象,就像其他所有 OpenSL 对象一样。

录制声音

要录制声音,你需要根据录制时长创建一个适当大小的声音缓冲区。你可以调整Sound类,以允许创建给定大小的空缓冲区。大小取决于采样率。例如,对于2秒的录制,采样率为44100 Hz 和16位质量,声音缓冲区大小如下所示:

recordSize   = 2 * 44100 * sizeof(int16_t);
recordBuffer = new int16_t[mRecordSize];

recordSound()中,首先通过SLRecordItf停止录音机,以确保它没有在录制。然后,清除队列以确保你的录音缓冲区立即使用。最后,你可以排队一个新的缓冲区并开始录制:

(*mRecorder)->SetRecordState(mRecorder, SL_RECORDSTATE_STOPPED);
(*mRecorderQueue)->Clear(mRecorderQueue);
(*mRecorderQueue)->Enqueue(mRecorderQueue, recordBuffer,
    recordSize * sizeof(int16_t));
(*mRecorder)->SetRecordState(mRecorder,SL_RECORDSTATE_RECORDING);

提示

完全可以排队新的声音缓冲区,以便处理完当前录制的内容。这允许创建连续的录制链,换句话说,就是录制流。排队的声音只有在之前的缓冲区填满后才会被处理。

录制回调

你最终需要知道你的声音缓冲区何时完成录制。为此,注册一个在录音事件发生时触发的回调(例如,一个缓冲区已满)。应设置一个事件掩码,以确保仅在缓冲区已满时调用回调(SL_RECORDEVENT_BUFFER_FULL)。在OpenSLES.h中有其他一些可用,但并非所有都受支持(如SL_RECORDEVENT_HEADATLIMIT等):

(*mRecorderQueue)->RegisterCallback(mRecorderQueue,
                                    callback_recorder, this);
(*mRecorder)->SetCallbackEventMask(mRecorder,
                                   SL_RECORDEVENT_BUFFER_FULL);

最后,当callback_recorder()被触发时,停止录制并通过playRecordedSound()播放已录制的缓冲区。录制的缓冲区需要像前一部分一样排入音频播放器的队列中以便播放。为了简化,你可以使用特定的SoundQueue来播放声音。

总结

总结一下,在本章中我们了解了如何在 Android 上初始化 OpenSL ES。引擎对象是管理所有 OpenSL 对象的主要入口点。OpenSL 中的对象遵循特定的生命周期:创建、实现和销毁。然后,我们学习了如何从编码文件播放背景音乐以及使用声音缓冲队列在内存中播放声音。最后,我们发现了如何以线程安全和非阻塞的方式录制并播放声音。

你是否更喜欢 OpenSL ES 而不是 Java API?如果你只需要一个高级别的好用 API,那么 Java API 可能更适合你的需求。如果你需要更精细的播放或录音控制,低级 Java API 和 OpenSL ES 之间没有显著差异。在这种情况下,选择应该是基于架构的。如果你的代码主要是 Java,那么你或许应该选择 Java。

如果你需要复用现有的与声音相关的库,优化性能,或者执行高强度计算,比如实时声音过滤,OpenSL ES 可能是正确的选择。OpenSL ES 也是实现低延迟的方式,尽管 Android 在这方面还没有完全达到(存在碎片化,特定设备问题等)。至少,这个详尽的 API 很可能会提供最佳性能。它没有垃圾收集的开销,并且在本地代码中鼓励进行积极的优化。

无论你做出什么选择,要知道 Android NDK 还有更多内容可以提供。在处理了第六章使用 OpenGL ES 渲染图形和第七章使用 OpenSL ES 播放声音之后,下一章将介绍如何本地处理输入:键盘、触摸和传感器。

第八章:处理输入设备和传感器

Android 的一切都是关于互动。诚然,这意味着通过图形、音频、振动等方式进行反馈。但没有输入就没有互动!当今智能手机的成功源于它们多样化和现代的输入方式:触摸屏、键盘、鼠标、GPS、加速度计、光线检测器、声音记录器等等。正确处理和结合它们是丰富您的应用程序并使其成功的关键。

尽管 Android 处理许多输入外设,但 Android NDK 在其支持上长期以来非常有限(甚至可以说是最少),直到 R5 版本的发布!我们现在可以通过原生 API 直接访问。可用的设备实例包括:

  • 键盘,可以是物理键盘(带有滑出式键盘)或虚拟键盘(在屏幕上显示)

  • 方向键(上、下、左、右和动作按钮),通常简称为 D-Pad。

  • 轨迹球,包括光学轨迹球

  • 触摸屏,使现代智能手机成功

  • 鼠标或触摸板(自 NDK R5 起,但仅在 Honeycomb 设备上可用)

我们还可以访问以下硬件传感器:

  • 加速度计,测量施加在设备上的线性加速度。

  • 陀螺仪,测量角速度。它通常与磁力计结合使用,以准确快速地计算方向。陀螺仪是最近引入的,并且大多数设备上还不可用。

  • 磁力计,提供环境磁场,从而得出基本方向。

  • 光传感器,例如,自动适应屏幕亮度。

  • 近距离传感器,例如,在通话期间检测耳朵的距离。

除了硬件传感器外,Gingerbread 版本还引入了“软件传感器”。这些传感器源自硬件传感器的数据:

  • 重力传感器,测量重力的方向和大小

  • 线性加速度传感器,测量设备“移动”时排除重力的部分

  • 旋转矢量,表示设备在空间中的方向

重力传感器和线性加速度传感器源自加速度计。另一方面,旋转矢量由磁力计和加速度计派生。由于这些传感器通常需要计算一段时间,因此它们在获取最新值时通常会有轻微的延迟。

为了更深入地了解输入设备和传感器,本章将介绍如何:

  • 处理屏幕触摸

  • 检测键盘、方向键和轨迹球事件

  • 将加速度计传感器转变为游戏手柄

与触摸事件互动

当今智能手机最具标志性的创新是触摸屏,它已经取代了现已过时的鼠标。正如其名,触摸屏可以检测手指或手写笔在设备表面的触摸。根据屏幕的质量,可以处理多个触摸(在 Android 中也称为光标),从而增加了互动的可能性。

让我们从在 DroidBlaster 中处理触摸事件开始本章的内容。为了简化示例,我们只处理单一的“触摸”。目标是使飞船向触摸的方向移动。触摸越远,飞船移动越快。超过预定义的范围 TOUCH_MAX_RANGE,飞船的速度将达到其速度极限,如下图所示:

与触摸事件交互

注意

本书提供的项目名为 DroidBlaster_Part13

动手实践——处理触摸事件

让我们在 DroidBlaster 中拦截触摸事件:

  1. 正如在 第五章《编写一个完全原生的应用程序》中创建 ActivityHandler 来处理应用程序事件一样,创建 jni/InputHandler.hpp 来处理输入事件。输入 API 在 android/input.h 中声明。创建 onTouchEvent() 来处理触摸事件。这些事件被封装在 AInputEvent 结构中。其他输入外设将在本章后面描述:

    #ifndef _PACKT_INPUTHANDLER_HPP_
    #define _PACKT_INPUTHANDLER_HPP_
    
    #include <android/input.h>
    
    class InputHandler {
    public:
        virtual ~InputHandler() {};
    
        virtual bool onTouchEvent(AInputEvent* pEvent) = 0;
    };
    #endif
    
  2. 修改 jni/EventLoop.hpp 头文件,包含并处理 InputHandler 实例。

    类似于活动事件,定义一个内部方法 processInputEvent(),该方法由静态回调 callback_input() 触发:

    ...
    #include "ActivityHandler.hpp"
    #include "InputHandler.hpp"
    
    #include <android_native_app_glue.h>
    
    class EventLoop {
    public:
    EventLoop(android_app* pApplication,
                ActivityHandler& pActivityHandler,
                InputHandler& pInputHandler);
        ...
    private:
        ...
        void processAppEvent(int32_t pCommand);
        int32_t processInputEvent(AInputEvent* pEvent);
    
        static void callback_appEvent(android_app* pApplication,
                int32_t pCommand);
        static int32_t callback_input(android_app* pApplication,
     AInputEvent* pEvent);
    
        ...
        ActivityHandler& mActivityHandler;
        InputHandler& mInputHandler;
    };
    #endif
    
  3. 我们需要在 jni/EventLoop.cpp 源文件中处理输入事件,并通知相关的 InputHandler

    首先,将 Android 输入队列连接到 callback_input()EventLoop 本身(即 this)通过 android_app 结构的 userData 成员匿名传递。这样,回调能够将输入处理委托回我们自己的对象,即 processInputEvent()

    ...
    EventLoop::EventLoop(android_app* pApplication,
        ActivityHandler& pActivityHandler, InputHandler& pInputHandler):
            mApplication(pApplication),
            mActivityHandler(pActivityHandler),
            mEnabled(false), mQuit(false),
            mInputHandler(pInputHandler) {
        mApplication->userData = this;
        mApplication->onAppCmd = callback_appEvent;
     mApplication->onInputEvent = callback_input;
    }
    
    ...
    
    int32_t EventLoop::callback_input(android_app* pApplication,
     AInputEvent* pEvent) {
     EventLoop& eventLoop = *(EventLoop*) pApplication->userData;
     return eventLoop.processInputEvent(pEvent);
    }
    ...
    
  4. 触摸屏事件属于 MotionEvent 类型(与按键事件相对)。通过 Android 原生输入 API(此处为 AinputEvent_getSource()),可以根据它们的来源(AINPUT_SOURCE_TOUCHSCREEN)来区分它们。

    注意

    请注意 callback_input() 以及扩展的 processInputEvent() 返回一个整数值(本质上是一个布尔值)。这个值表示输入事件(例如,按下的按钮)已经被应用程序处理,不需要系统进一步处理。例如,当按下返回按钮时返回 1,以停止事件处理,防止活动被终止。

    ...
    int32_t EventLoop::processInputEvent(AInputEvent* pEvent) {
        if (!mEnabled) return 0;
    
        int32_t eventType = AInputEvent_getType(pEvent);
        switch (eventType) {
        case AINPUT_EVENT_TYPE_MOTION:
            switch (AInputEvent_getSource(pEvent)) {
            case AINPUT_SOURCE_TOUCHSCREEN:
                return mInputHandler.onTouchEvent(pEvent);
                break;
            }
            break;
        }
        return 0;
    }
    
  5. 创建 jni/InputManager.hpp 以处理触摸事件并实现我们的新 InputHandler 接口。

    按如下方式定义方法:

    • start() 执行必要的初始化。

    • onTouchEvent() 在触发新事件时更新管理器状态。

    • getDirectionX()getDirectionY() 指示飞船的方向。

    • setRefPoint() 指的是飞船的位置。实际上,方向被定义为触摸点和飞船位置(即参考点)之间的向量。

    同时,声明必要的成员变量,尤其是mScaleFactor,它包含了从屏幕坐标到游戏坐标的正确比例(记住我们使用的是固定大小)。

    #ifndef _PACKT_INPUTMANAGER_HPP_
    #define _PACKT_INPUTMANAGER_HPP_
    
    #include "GraphicsManager.hpp"
    #include "InputHandler.hpp"
    #include "Types.hpp"
    
    #include <android_native_app_glue.h>
    
    class InputManager : public InputHandler {
    public:
        InputManager(android_app* pApplication,
                 GraphicsManager& pGraphicsManager);
    
        float getDirectionX() { return mDirectionX; };
        float getDirectionY() { return mDirectionY; };
        void setRefPoint(Location* pRefPoint) { mRefPoint = pRefPoint; };
    
        void start();
    
    protected:
        bool onTouchEvent(AInputEvent* pEvent);
    
    private:
        android_app* mApplication;
        GraphicsManager& mGraphicsManager;
    
        // Input values.
        float mScaleFactor;
        float mDirectionX, mDirectionY;
        // Reference point to evaluate touch distance.
        Location* mRefPoint;
    };
    #endif
    
  6. 创建jni/InputManager.cpp,从构造函数开始:

    #include "InputManager.hpp"
    #include "Log.hpp"
    
    #include <android_native_app_glue.h>
    #include <cmath>
    
    InputManager::InputManager(android_app* pApplication,
            GraphicsManager& pGraphicsManager) :
        mApplication(pApplication), mGraphicsManager(pGraphicsManager),
        mDirectionX(0.0f), mDirectionY(0.0f),
        mRefPoint(NULL) {
    }
    ...
    
  7. 编写start()方法以清除成员变量并计算缩放因子。这个缩放因子是必要的,因为在第六章,使用 OpenGL ES 渲染图形中提到,我们需要将输入事件提供的屏幕坐标(这取决于设备)转换为游戏坐标:

    ...
    void InputManager::start() {
        Log::info("Starting InputManager.");
        mDirectionX = 0.0f, mDirectionY = 0.0f;
        mScaleFactor = float(mGraphicsManager.getRenderWidth())
                           / float(mGraphicsManager.getScreenWidth());
    }
    ...
    
  8. 有效的事件处理在onTouchEvent()中实现。根据参考点与触摸点之间的距离,计算出水平方向和垂直方向。这个距离通过TOUCH_MAX_RANGE限制在一个任意的65单位范围内。因此,当参考点到触摸点的距离超出TOUCH_MAX_RANGE像素时,飞船将达到最大速度。

    当你移动手指时,通过AMotionEvent_getX()AMotionEvent_getY()获取触摸坐标。当不再检测到触摸时,方向向量重置为0

    ...
    bool InputManager::onTouchEvent(AInputEvent* pEvent) {
        static const float TOUCH_MAX_RANGE = 65.0f; // In game units.
    
        if (mRefPoint != NULL) {
            if (AMotionEvent_getAction(pEvent)
                            == AMOTION_EVENT_ACTION_MOVE) {
                float x = AMotionEvent_getX(pEvent, 0) * mScaleFactor;
                float y = (float(mGraphicsManager.getScreenHeight())
                         - AMotionEvent_getY(pEvent, 0)) * mScaleFactor;
                // Needs a conversion to proper coordinates
                // (origin at bottom/left). Only moveY needs it.
                float moveX = x - mRefPoint->x;
                float moveY = y - mRefPoint->y;
                float moveRange = sqrt((moveX * moveX) + (moveY * moveY));
    
                if (moveRange > TOUCH_MAX_RANGE) {
                    float cropFactor = TOUCH_MAX_RANGE / moveRange;
                    moveX *= cropFactor; moveY *= cropFactor;
                }
    
                mDirectionX = moveX / TOUCH_MAX_RANGE;
                mDirectionY   = moveY / TOUCH_MAX_RANGE;
            } else {
                mDirectionX = 0.0f; mDirectionY = 0.0f;
            }
        }
        return true;
    }
    
  9. 创建一个简单的组件jni/MoveableBody.hpp,其作用是根据输入事件移动PhysicsBody

    #ifndef _PACKT_MOVEABLEBODY_HPP_
    #define _PACKT_MOVEABLEBODY_HPP_
    
    #include "InputManager.hpp"
    #include "PhysicsManager.hpp"
    #include "Types.hpp"
    
    class MoveableBody {
    public:
        MoveableBody(android_app* pApplication,
           InputManager& pInputManager, PhysicsManager& pPhysicsManager);
    
        PhysicsBody* registerMoveableBody(Location& pLocation,
                int32_t pSizeX, int32_t pSizeY);
    
        void initialize();
        void update();
    
    private:
        PhysicsManager& mPhysicsManager;
        InputManager& mInputManager;
    
        PhysicsBody* mBody;
    };
    #endif
    
  10. jni/MoveableBody.cpp中实现这个组件。

    registerMoveableBody()中,将InputManager和物体绑定:

    #include "Log.hpp"
    #include "MoveableBody.hpp"
    
    MoveableBody::MoveableBody(android_app* pApplication,
          InputManager& pInputManager, PhysicsManager& pPhysicsManager) :
        mInputManager(pInputManager),
        mPhysicsManager(pPhysicsManager),
        mBody(NULL) {
    }
    
    PhysicsBody* MoveableBody::registerMoveableBody(Location& pLocation,
    int32_t pSizeX, int32_t pSizeY) {
        mBody = mPhysicsManager.loadBody(pLocation, pSizeX, pSizeY);
        mInputManager.setRefPoint(&pLocation);
        return mBody;
    }
    ...
    
  11. 最初,物体没有速度。

    然后,每次更新时,速度都会反映当前的输入状态。这个速度由第五章,编写一个完全原生的应用程序中创建的PhysicsManager接收,以更新实体的位置:

    ...
    void MoveableBody::initialize() {
        mBody->velocityX = 0.0f;
        mBody->velocityY = 0.0f;
    }
    
    void MoveableBody::update() {
        static const float MOVE_SPEED = 320.0f;
        mBody->velocityX = mInputManager.getDirectionX() * MOVE_SPEED;
        mBody->velocityY = mInputManager.getDirectionY() * MOVE_SPEED;
    }
    

    jni/DroidBlaster.hpp中引用新的InputManagerMoveableComponent

    ...
    #include "EventLoop.hpp"
    #include "GraphicsManager.hpp"
    #include "InputManager.hpp"
    #include "MoveableBody.hpp"
    #include "PhysicsManager.hpp"
    #include "Resource.hpp"
    ...
    
    class DroidBlaster : public ActivityHandler {
        ...
    private:
        TimeManager     mTimeManager;
        GraphicsManager mGraphicsManager;
        PhysicsManager  mPhysicsManager;
        SoundManager    mSoundManager;
        InputManager    mInputManager;
        EventLoop mEventLoop;
        ...
        Asteroid mAsteroids;
        Ship mShip;
        StarField mStarField;
        SpriteBatch mSpriteBatch;
        MoveableBody mMoveableBody;
    };
    #endif
    
  12. 最后,调整jni/DroidBlaster.cpp构造函数,以实例化InputManagerMoveableComponent

    在构造时,将InputManager添加到EventLoop中,后者负责分派输入事件。

    飞船是被移动的实体。因此,需要将它的位置引用传递给MoveableBody组件:

    ...
    DroidBlaster::DroidBlaster(android_app* pApplication):
        mTimeManager(),
        mGraphicsManager(pApplication),
        mPhysicsManager(mTimeManager, mGraphicsManager),
        mSoundManager(pApplication),
        mInputManager(pApplication, mGraphicsManager),
     mEventLoop(pApplication, *this, mInputManager),
        ...
        mAsteroids(pApplication, mTimeManager, mGraphicsManager,
        mPhysicsManager),
        mShip(pApplication, mGraphicsManager, mSoundManager),
        mStarField(pApplication, mTimeManager, mGraphicsManager,
                STAR_COUNT, mStarTexture),
        mSpriteBatch(mTimeManager, mGraphicsManager),
        mMoveableBody(pApplication, mInputManager, mPhysicsManager) {
        ...
        Sprite* shipGraphics = mSpriteBatch.registerSprite(mShipTexture,
                SHIP_SIZE, SHIP_SIZE);
        shipGraphics->setAnimation(SHIP_FRAME_1, SHIP_FRAME_COUNT,
                SHIP_ANIM_SPEED, true);
        Sound* collisionSound =
                mSoundManager.registerSound(mCollisionSound);
        mMoveableBody.registerMoveableBody(shipGraphics->location,
     SHIP_SIZE, SHIP_SIZE);
        mShip.registerShip(shipGraphics, collisionSound);
    
        // Creates asteroids.
        ...
    }
    ...
    
  13. 在相应的函数中初始化和更新MoveableBodyInputManager

    ...
    status DroidBlaster::onActivate() {
        Log::info("Activating DroidBlaster");
        if (mGraphicsManager.start() != STATUS_OK) return STATUS_KO;
        if (mSoundManager.start() != STATUS_OK) return STATUS_KO;
        mInputManager.start();
    
        mSoundManager.playBGM(mBGM);
    
        mAsteroids.initialize();
        mShip.initialize();
        mMoveableBody.initialize();
    
        mTimeManager.reset();
        return STATUS_OK;
    }
    
    ...
    
    status DroidBlaster::onStep() {
        mTimeManager.update();
        mPhysicsManager.update();
    
        mAsteroids.update();
        mMoveableBody.update();
    
        return mGraphicsManager.update();
    }
    ...
    

刚才发生了什么?

我们创建了一个基于触摸事件的输入系统的简单示例。飞船以与触摸距离相关的速度向触摸点飞行。触摸事件坐标是绝对的。它们的原点在屏幕左上角,与 OpenGL 的左下角相对。如果应用程序允许屏幕旋转,那么屏幕原点对于用户来说仍然在左上角,无论设备是纵向还是横向模式。

刚才发生了什么?

为了实现这个新特性,我们将事件循环连接到了native_app_glue模块提供的输入事件队列。这个队列在内部表现为一个 UNIX 管道,类似于活动事件队列。触摸屏事件嵌入在AInputEvent结构中,该结构还存储其他类型的输入事件。使用android/input.h中声明的AInputEventAMotionEvent API 处理输入事件。AInputEvent API 通过AInputEvent_getType()AInputEvent_getSource()方法来区分输入事件类型。而AMotionEvent API 仅提供处理触摸事件的方法。

触摸 API 相当丰富。许多细节可以按照以下表格所示请求(非详尽无遗):

方法 描述

|

AMotionEvent_getAction()
用于检测手指是否与屏幕接触、离开或在其表面移动。结果是一个整数值,由事件类型(例如,第 1 个字节上的AMOTION_EVENT_ACTION_DOWN)和一个指针索引(在第 2 个字节上,以了解事件指的是哪个手指)组成。

|

AMotionEvent_getX()
AMotionEvent_getY()
用于获取屏幕上的触摸坐标,以浮点数(像素)表示(可能存在亚像素值)。

|

AMotionEvent_getDownTime()
AMotionEvent_getEventTime()
用于获取手指在屏幕上滑动的时间和事件生成的时间(单位为纳秒)。

|

AMotionEvent_getPressure()
AMotionEvent_getSize()

用于检测压力强度和区域。值通常在0.01.0之间(但可能会超出)。大小和压力通常密切相关。行为可能会因硬件而异并产生噪声。

|

AMotionEvent_getHistorySize()
AMotionEvent_getHistoricalX()
AMotionEvent_getHistoricalY()
为了提高效率,可以将类型为AMOTION_EVENT_ACTION_MOVE的触摸事件分组在一起。这些方法提供了对发生在前一个事件和当前事件之间的这些历史点的访问。

查看完整的android/input.h方法列表。

如果你深入查看AMotionEvent API,你会注意到一些事件有一个第二个参数pointer_index,其范围在0到活动指针的数量之间。实际上,当今大多数触摸屏都支持多点触控!屏幕上的两个或更多手指(如果硬件支持)在 Android 中由两个或更多指针表示。要操作它们,请查看以下表格:

方法 描述

|

AMotionEvent_getPointerCount()
用于了解有多少手指触摸屏幕。

|

AMotionEvent_getPointerId()
从指针索引获取一个指针的唯一标识符。这是跟踪特定指针(即手指)随时间变化唯一的方式,因为当手指触摸或离开屏幕时其索引可能会改变。

提示

如果你关注过(现在已成古董的!)Nexus One 的故事,那么你应该知道它曾因硬件缺陷而出名。指针经常混淆,其中两个会交换它们的坐标。因此,一定要准备好处理硬件的特定行为或表现异常的硬件!

检测键盘、D-Pad 和轨迹球事件

在所有的输入设备中,最常见的是键盘。这对于 Android 来说也是如此。Android 的键盘可以是物理的:位于设备正面(如传统的黑莓手机),或者是在滑出式屏幕上的。然而,键盘通常是虚拟的,即在屏幕上模拟,这会占用大量的空间。除了键盘本身,每个 Android 设备都必须包括一些物理或模拟的按钮,如菜单主页任务

一种不太常见的输入设备类型是方向键。方向键是一组物理按钮,用于向上、向下、向左或向右移动以及特定的动作/确认按钮。尽管它们经常从最近的手機和平板电脑上消失,但方向键仍然是在文本或 UI 小部件之间移动的最方便方式之一。方向键通常被轨迹球取代。轨迹球的行为类似于鼠标(带有一个球体的鼠标)并倒置。一些轨迹球是模拟的,但其他(例如,光学的)则表现为方向键(即全有或全无)。

检测键盘、方向键和轨迹球事件

为了了解它们是如何工作的,让我们在DroidBlaster中使用这些外设来移动我们的太空船。现在 Android NDK 允许在本地处理所有这些输入外设。那么,让我们试试看!

注意

本书提供的结果项目名为DroidBlaster_Part14

动手实践——本地处理键盘、方向键和轨迹球事件

让我们扩展我们新的输入系统,加入更多的事件类型:

  1. 打开jni/InputHandler.hpp并添加键盘和轨迹球事件处理程序:

    #ifndef _PACKT_INPUTHANDLER_HPP_
    #define _PACKT_INPUTHANDLER_HPP_
    
    #include <android/input.h>
    
    class InputHandler {
    public:
        virtual ~InputHandler() {};
    
        virtual bool onTouchEvent(AInputEvent* pEvent) = 0;
        virtual bool onKeyboardEvent(AInputEvent* pEvent) = 0;
     virtual bool onTrackballEvent(AInputEvent* pEvent) = 0;
    };
    #endif
    
  2. 更新现有文件jni/EventLoop.cpp中的processInputEvent()方法,将键盘和轨迹球事件重定向到InputHandler

    轨迹球和触摸事件被归纳为动作事件,可以根据它们的来源进行区分。相对的,按键事件根据它们的类型进行区分。实际上,存在两个专用的 API,一个用于MotionEvents(轨迹球和触摸事件相同),另一个用于KeyEvents(键盘、方向键等相同)。

    ...
    int32_t EventLoop::processInputEvent(AInputEvent* pEvent) {
        if (!mEnabled) return 0;
    
        int32_t eventType = AInputEvent_getType(pEvent);
        switch (eventType) {
        case AINPUT_EVENT_TYPE_MOTION:
            switch (AInputEvent_getSource(pEvent)) {
            case AINPUT_SOURCE_TOUCHSCREEN:
                return mInputHandler.onTouchEvent(pEvent);
                break;
    
            case AINPUT_SOURCE_TRACKBALL:
     return mInputHandler.onTrackballEvent(pEvent);
     break;
            }
            break;
    
        case AINPUT_EVENT_TYPE_KEY:
     return mInputHandler.onKeyboardEvent(pEvent);
     break;
        }
    return 0;
    }
    ...
    
  3. 修改jni/InputManager.hpp文件,重写这些新方法:

    ...
    class InputManager : public InputHandler {
        ...
    protected:
        bool onTouchEvent(AInputEvent* pEvent);
        bool onKeyboardEvent(AInputEvent* pEvent);
     bool onTrackballEvent(AInputEvent* pEvent);
    
        ...
    };
    #endif
    
  4. jni/InputManager.cpp中,使用onKeyboardEvent()处理键盘事件:

    • 使用AKeyEvent_getAction()获取事件类型(即按下或未按下)。

    • 使用AKeyEvent_getKeyCode()获取按钮标识。

    在以下代码中,当按下左、右、上或下按钮时,InputManager会计算方向并将其保存到mDirectionXmDirectionY中。按钮按下时开始移动,松开时停止。

    当按键被消耗时返回true,否则返回false。实际上,如果用户按下了例如返回键(AKEYCODE_BACK)或音量键(AKEYCODE_VOLUME_UPAKEYCODE_VOLUME_DOWN),我们就让系统为我们做出适当的反应:

    ...
    bool InputManager::onKeyboardEvent(AInputEvent* pEvent) {
        static const float ORTHOGONAL_MOVE = 1.0f;
    
        if (AKeyEvent_getAction(pEvent) == AKEY_EVENT_ACTION_DOWN) {
            switch (AKeyEvent_getKeyCode(pEvent)) {
            case AKEYCODE_DPAD_LEFT:
                mDirectionX = -ORTHOGONAL_MOVE;
                return true;
            case AKEYCODE_DPAD_RIGHT:
                mDirectionX = ORTHOGONAL_MOVE;
                return true;
            case AKEYCODE_DPAD_DOWN:
                mDirectionY = -ORTHOGONAL_MOVE;
                return true;
            case AKEYCODE_DPAD_UP:
                mDirectionY = ORTHOGONAL_MOVE;
                return true;
            }
        } else {
            switch (AKeyEvent_getKeyCode(pEvent)) {
            case AKEYCODE_DPAD_LEFT:
            case AKEYCODE_DPAD_RIGHT:
                mDirectionX = 0.0f;
                return true;
            case AKEYCODE_DPAD_DOWN:
            case AKEYCODE_DPAD_UP:
                mDirectionY = 0.0f;
                return true;
            }
        }
        return false;
    }
    ...
    
  5. 在新方法 onTrackballEvent() 中处理轨迹球事件。使用 AMotionEvent_getX()AMotionEvent_getY() 获取轨迹球的大小。由于一些轨迹球不提供渐变的幅度,因此使用普通常量来量化移动,并通过任意的触发阈值忽略可能出现的噪声:

    ...
    bool InputManager::onTrackballEvent(AInputEvent* pEvent) {
        static const float ORTHOGONAL_MOVE = 1.0f;
        static const float DIAGONAL_MOVE   = 0.707f;
        static const float THRESHOLD       = (1/100.0f);
    
         if (AMotionEvent_getAction(pEvent) == AMOTION_EVENT_ACTION_MOVE) {
            float directionX = AMotionEvent_getX(pEvent, 0);
            float directionY = AMotionEvent_getY(pEvent, 0);
            float horizontal, vertical;
    
            if (directionX < -THRESHOLD) {
                if (directionY < -THRESHOLD) {
                    horizontal = -DIAGONAL_MOVE;
                    vertical   = DIAGONAL_MOVE;
                } else if (directionY > THRESHOLD) {
                    horizontal = -DIAGONAL_MOVE;
                    vertical   = -DIAGONAL_MOVE;
                } else {
                    horizontal = -ORTHOGONAL_MOVE;
                    vertical   = 0.0f;
                }
            } else if (directionX > THRESHOLD) {
                if (directionY < -THRESHOLD) {
                    horizontal = DIAGONAL_MOVE;
                    vertical   = DIAGONAL_MOVE;
                } else if (directionY > THRESHOLD) {
                    horizontal = DIAGONAL_MOVE;
                    vertical   = -DIAGONAL_MOVE;
                } else {
                    horizontal = ORTHOGONAL_MOVE;
                    vertical   = 0.0f;
                }
            } else if (directionY < -THRESHOLD) {
                horizontal = 0.0f;
                vertical   = ORTHOGONAL_MOVE;
            } else if (directionY > THRESHOLD) {
                horizontal = 0.0f;
                vertical   = -ORTHOGONAL_MOVE;
            }
    ...
    
  6. 以这种方式使用轨迹球时,飞船会一直移动,直到出现“反方向移动”(例如,在向左移动时请求向右移动)或按下动作按钮(最后的 else 部分):

            ...
            // Ends movement if there is a counter movement.
            if ((horizontal < 0.0f) && (mDirectionX > 0.0f)) {
                mDirectionX = 0.0f;
            } else if ((horizontal > 0.0f) && (mDirectionX < 0.0f)) {
                mDirectionX = 0.0f;
            } else {
                mDirectionX = horizontal;
            }
    
            if ((vertical < 0.0f) && (mDirectionY > 0.0f)) {
                mDirectionY = 0.0f;
            } else if ((vertical > 0.0f) && (mDirectionY < 0.0f)) {
                mDirectionY = 0.0f;
            } else {
                mDirectionY = vertical;
            }
        } else {
            mDirectionX = 0.0f; mDirectionY = 0.0f;
        }
        return true;
    }
    

刚才发生了什么?

我们扩展了输入系统以处理键盘、D-Pad 和轨迹球事件。D-Pad 可以被视为键盘的扩展并以相同的方式处理。实际上,D-Pad 和键盘事件使用相同的结构 (AInputEvent) 并通过相同的 API(以 AKeyEvent 为前缀)处理。

下表列出了主要的键事件方法:

方法 描述
AKeyEvent_getAction() 指示按钮是按下 (AKEY_EVENT_ACTION_DOWN) 还是释放 (AKEY_EVENT_ACTION_UP)。请注意,可以批量发出多个键动作 (AKEY_EVENT_ACTION_MULTIPLE)。
AKeyEvent_getKeyCode() 获取实际被按下的按钮(在 android/keycodes.h 中定义),例如,左按钮为 AKEYCODE_DPAD_LEFT
AKeyEvent_getFlags() 键事件可以与一个或多个标志相关联,这些标志提供了关于事件的各种信息,例如 AKEY_EVENT_LONG_PRESSAKEY_EVENT_FLAG_SOFT_KEYBOARD 表示事件源自模拟键盘。
AKeyEvent_getScanCode() 类似于键码,但这是一个原始键 ID,依赖于设备且不同设备之间可能不同。
AKeyEvent_getMetaState() 元状态是标志,表示是否同时按下了某些修饰键,如 Alt 或 Shift(例如 AMETA_SHIFT_ONAMETA_NONE 等)。
AKeyEvent_getRepeatCount() 指示按钮事件发生了多少次,通常是在你按下按钮不放时。
AKeyEvent_getDownTime() 了解按钮被按下时间。

尽管一些轨迹球(尤其是光学的)表现得像 D-Pad,但轨迹球并不使用相同的 API。实际上,轨迹球是通过 AMotionEvent API(如触摸事件)来处理的。当然,一些为触摸事件提供的信息在轨迹球上并不总是可用。需要关注的最重要功能如下:

AMotionEvent_getAction() 了解事件是否表示移动动作(与按下动作相对)。
AMotionEvent_getX()``AMotionEvent_getY() 获取轨迹球移动。
AKeyEvent_getDownTime() 了解轨迹球是否被按下(如 D-Pad 动作按钮)。目前,大多数轨迹球使用全或无的压力来指示按下事件。

在处理轨迹球时,要记住的一个棘手点是,没有事件生成来指示轨迹球没有移动。此外,轨迹球事件是作为“爆发”生成的,这使得检测运动何时结束变得更加困难。处理这个问题的唯一方法(除了使用手动计时器并定期检查是否有足够长的时间没有事件发生)。

提示

不要期望所有手机上的外围设备行为完全相同。轨迹球就是一个很好的例子;它们可以像模拟垫一样指示方向,也可以像 D-Pad 一样指示直线路径(例如,光学轨迹球)。目前还没有办法从可用的 API 中区分设备特性。唯一的解决方案是在运行时校准设备或配置设备,或者保存一种设备数据库。

探测设备传感器

处理输入设备对于任何应用来说都很重要,但对于最智能的应用来说,探测传感器才是关键!在 Android 游戏应用中最常见的传感器就是加速度计。

如其名称所示,加速度计测量施加在设备上的线性加速度。当将设备向上、下、左或右移动时,加速度计会被激发,并在 3D 空间中指示一个加速度矢量。该矢量是相对于屏幕默认方向的。坐标系相对于设备的自然方向:

  • X 轴指向右侧

  • Y 轴指向上方

  • Z 轴从后向前指

如果设备旋转,轴会反转(例如,如果设备顺时针旋转 90 度,Y 轴将指向左侧)。

加速度计一个非常有趣的特点是它们经历恒定加速度:地球上的重力,大约是 9.8m/s²。例如,当设备平放在桌子上时,加速度矢量在 Z 轴上指示-9.8。当设备直立时,它在 Y 轴上指示相同的值。因此,假设设备位置固定,可以从重力加速度矢量推导出设备在空间中的两个轴的方向。要获取 3D 空间中设备的完全方向,还需要一个磁力计。

提示

请记住,加速度计处理的是线性加速度。它们可以检测设备不旋转时的平移和设备固定时的部分方向。然而,如果没有磁力计和/或陀螺仪,这两种运动是无法结合的。

因此,我们可以使用从加速度计推导出的设备方向来计算一个方向。现在让我们看看如何在 DroidBlaster 中应用这个过程。

注意

最终项目与本一起提供,名为 DroidBlaster_Part15

行动时间——处理加速度计事件

让我们在 DroidBlaster 中处理加速度计事件:

  1. 打开 jni/InputHandler.hpp 文件,并添加一个新的方法 onAccelerometerEvent()。包含官方传感器头文件 android/sensor.h

    #ifndef _PACKT_INPUTHANDLER_HPP_
    #define _PACKT_INPUTHANDLER_HPP_
    
    #include <android/input.h>
    #include <android/sensor.h>
    
    class InputHandler {
    public:
        virtual ~InputHandler() {};
    
        virtual bool onTouchEvent(AInputEvent* pEvent) = 0;
        virtual bool onKeyboardEvent(AInputEvent* pEvent) = 0;
        virtual bool onTrackballEvent(AInputEvent* pEvent) = 0;
        virtual bool onAccelerometerEvent(ASensorEvent* pEvent) = 0;
    };
    #endif
    
  2. jni/EventLoop.hpp 中创建新方法:

    • activateAccelerometer()deactivateAccelerometer()在活动开始和停止时启用/禁用加速度传感器。

    • processSensorEvent()检索并分派传感器事件。

    • 回调callback_input()静态方法绑定到 Looper。

    同时,定义以下成员:

    • mSensorManager,类型为ASensorManager,是与传感器交互的主要“对象”。

    • mSensorEventQueueASensorEventQueue,这是 Sensor API 定义的结构,用于检索发生的事件。

    • mSensorPollSource是 Native Glue 中定义的android_poll_source。这个结构描述了如何将原生线程 Looper 绑定到传感器回调。

    • mAccelerometer,声明为ASensor结构,表示所使用的传感器:

      #ifndef _PACKT_EVENTLOOP_HPP_
      #define _PACKT_EVENTLOOP_HPP_
      
      #include "ActivityHandler.hpp"
      #include "InputHandler.hpp"
      
      #include <android_native_app_glue.h>
      
      class EventLoop {
          ...
      private:
          void activate();
          void deactivate();
          void activateAccelerometer();
       void deactivateAccelerometer();
      
          void processAppEvent(int32_t pCommand);
          int32_t processInputEvent(AInputEvent* pEvent);
          void processSensorEvent();
      
          static void callback_appEvent(android_app* pApplication,
                  int32_t pCommand);
          static int32_t callback_input(android_app* pApplication,
                  AInputEvent* pEvent);
          static void callback_sensor(android_app* pApplication,
       android_poll_source* pSource);
      
          ...
          InputHandler& mInputHandler;
      
          ASensorManager* mSensorManager;
       ASensorEventQueue* mSensorEventQueue;
       android_poll_source mSensorPollSource;
       const ASensor* mAccelerometer;
      };
      #endif
      
  3. 更新jni/EventLoop.cpp中的构造函数初始化列表:

    #include "EventLoop.hpp"
    #include "Log.hpp"
    
    EventLoop::EventLoop(android_app* pApplication,
        ActivityHandler& pActivityHandler, InputHandler& pInputHandler):
            mApplication(pApplication),
            mActivityHandler(pActivityHandler),
            mEnabled(false), mQuit(false),
            mInputHandler(pInputHandler),
            mSensorPollSource(), mSensorManager(NULL),
            mSensorEventQueue(NULL), mAccelerometer(NULL) {
        mApplication->userData = this;
        mApplication->onAppCmd = callback_appEvent;
        mApplication->onInputEvent = callback_input;
    }
    ...
    
  4. 创建传感器事件队列,通过它通知所有sensor事件。

    将其绑定到callback_sensor()。请注意,这里我们使用 Native App Glue 提供的LOOPER_ID_USER常量来附加用户定义的队列。

    接着,调用activateAccelerometer()来初始化加速度传感器:

    ...
    void EventLoop::activate() {
        if ((!mEnabled) && (mApplication->window != NULL)) {
            mSensorPollSource.id = LOOPER_ID_USER;
     mSensorPollSource.app = mApplication;
     mSensorPollSource.process = callback_sensor;
     mSensorManager = ASensorManager_getInstance();
     if (mSensorManager != NULL) {
     mSensorEventQueue = ASensorManager_createEventQueue(
     mSensorManager, mApplication->looper,
     LOOPER_ID_USER, NULL, &mSensorPollSource);
     if (mSensorEventQueue == NULL) goto ERROR;
     }
     activateAccelerometer();
    
            mQuit = false; mEnabled = true;
            if (mActivityHandler.onActivate() != STATUS_OK) {
                goto ERROR;
            }
        }
        return;
    
    ERROR:
        mQuit = true;
        deactivate();
        ANativeActivity_finish(mApplication->activity);
    }
    ...
    
  5. 当活动被禁用或结束时,禁用正在运行的加速度传感器,以免不必要的消耗电池。

    然后,销毁sensor事件队列:

    ...
    void EventLoop::deactivate() {
        if (mEnabled) {
            deactivateAccelerometer();
     if (mSensorEventQueue != NULL) {
     ASensorManager_destroyEventQueue(mSensorManager,
     mSensorEventQueue);
     mSensorEventQueue = NULL;
     }
     mSensorManager = NULL;
    
            mActivityHandler.onDeactivate();
            mEnabled = false;
        }
    }
    ...
    
  6. 当事件循环被轮询时,callback_sensor()会被触发。它将事件分派给EventLoop实例上的processSensorEvent()。我们只关心ASENSOR_TYPE_ACCELEROMETER事件:

    ...
    void EventLoop::callback_sensor(android_app* pApplication,
        android_poll_source* pSource) {
        EventLoop& eventLoop = *(EventLoop*) pApplication->userData;
        eventLoop.processSensorEvent();
    }
    
    void EventLoop::processSensorEvent() {
        ASensorEvent event;
        if (!mEnabled) return;
    
        while (ASensorEventQueue_getEvents(mSensorEventQueue,
                &event, 1) > 0) {
            switch (event.type) {
            case ASENSOR_TYPE_ACCELEROMETER:
                mInputHandler.onAccelerometerEvent(&event);
                break;
            }
        }
    }
    ...
    
  7. activateAccelerometer()中通过三个主要步骤激活传感器:

    • 使用AsensorManager_getDefaultSensor()获取特定类型的传感器。

    • 然后,使用ASensorEventQueue_enableSensor()启用它,以便传感器事件队列填充相关事件。

    • 使用ASensorEventQueue_setEventRate()设置所需的事件率。对于游戏,我们通常希望接近实时测量。可以使用ASensor_getMinDelay()查询最小延迟(将其设置得较低可能会导致失败)。

    显然,我们应该只在传感器事件队列准备好时执行此设置:

    ...
    void EventLoop::activateAccelerometer() {
        mAccelerometer = ASensorManager_getDefaultSensor(
                mSensorManager, ASENSOR_TYPE_ACCELEROMETER);
        if (mAccelerometer != NULL) {
            if (ASensorEventQueue_enableSensor(
                    mSensorEventQueue, mAccelerometer) < 0) {
                Log::error("Could not enable accelerometer");
                return;
            }
    
            int32_t minDelay = ASensor_getMinDelay(mAccelerometer);
            if (ASensorEventQueue_setEventRate(mSensorEventQueue,
                    mAccelerometer, minDelay) < 0) {
                Log::error("Could not set accelerometer rate");
            }
        } else {
            Log::error("No accelerometer found");
        }
    }
    ...
    
  8. 传感器停用更容易,只需调用AsensorEventQueue_disableSensor()方法即可:

    ...
    void EventLoop::deactivateAccelerometer() {
        if (mAccelerometer != NULL) {
            if (ASensorEventQueue_disableSensor(mSensorEventQueue,
                    mAccelerometer) < 0) {
                Log::error("Error while deactivating sensor.");
            }
            mAccelerometer = NULL;
        }
    }
    

刚才发生了什么?

我们创建了一个事件队列来监听传感器事件。事件被封装在ASensorEvent结构中,该结构在android/sensor.h中定义。这个结构提供了以下内容:

  • 传感器事件来源,即哪个传感器产生了这个事件。

  • 传感器事件发生的时间。

  • 传感器输出值。这个值存储在一个联合结构中,即你可以使用其中一个内部结构(这里我们关心的是acceleration向量)。

    typedef struct ASensorEvent {
        int32_t version;
        int32_t sensor;
        int32_t type;
        int32_t reserved0;
        int64_t timestamp;
        union {
            float           data[16];
            ASensorVector   vector;
            ASensorVector   acceleration;
            ASensorVector   magnetic;
            float           temperature;
            float           distance;
            float           light;
            float           pressure;
        };
        int32_t reserved1[4];
    } ASensorEvent;
    

对于任何 Android 传感器,都使用相同的ASensorEvent结构。在加速度传感器的情况下,我们获取一个带有三个坐标xyz的向量,每个轴一个:

typedef struct ASensorVector {
    union {
        float v[3];
        struct {
            float x;
            float y;
            float z;
        };
        struct {
            float azimuth;
            float pitch;
            float roll;
        };
    };
    int8_t status;
    uint8_t reserved[3];
} ASensorVector;

在我们的示例中,加速度计设置为尽可能低的事件率,这可能在不同的设备之间有所变化。需要注意的是,传感器事件率对电池节省有直接影响!因此,使用对应用程序来说足够的事件率。ASensor API 提供了一些方法来查询可用的传感器及其功能,如ASensor_getName()ASensor_getVendor()ASensor_getMinDelay()等。

现在我们能够获取传感器事件,让我们用它们来计算飞船的方向。

行动时间——将 Android 设备转变为游戏手柄

让我们找到设备方向并正确确定方向。

  1. 编写一个新文件jni/Configuration.hpp,帮助我们获取设备信息,特别是设备旋转(定义为screen_rot)。

    声明findRotation(),以借助 JNI 发现设备方向:

    #ifndef _PACKT_CONFIGURATION_HPP_
    #define _PACKT_CONFIGURATION_HPP_
    
    #include "Types.hpp"
    
    #include <android_native_app_glue.h>
    #include <jni.h>
    
    typedef int32_t screen_rot;
    
    const screen_rot ROTATION_0   = 0;
    const screen_rot ROTATION_90  = 1;
    const screen_rot ROTATION_180 = 2;
    const screen_rot ROTATION_270 = 3;
    
    class Configuration {
    public:
        Configuration(android_app* pApplication);
    
        screen_rot getRotation() { return mRotation; };
    
    private:
        void findRotation(JNIEnv* pEnv);
    
        android_app* mApplication;
        screen_rot mRotation;
    };
    #endif
    
  2. jni/Configuration.cpp中获取配置详情。

    首先,在构造函数中,使用AConfiguration API 转储配置属性,例如当前语言、国家、屏幕大小、屏幕方向。这些信息可能很有趣,但不足以正确分析加速度计事件:

    #include "Configuration.hpp"
    #include "Log.hpp"
    
    #include <stdlib.h>
    
    Configuration::Configuration(android_app* pApplication) :
        mApplication(pApplication),
        mRotation(0) {
        AConfiguration* configuration = AConfiguration_new();
        if (configuration == NULL) return;
    
        int32_t result;
        char i18NBuffer[] = "__";
        static const char* orientation[] = {
            "Unknown", "Portrait", "Landscape", "Square"
        };
        static const char* screenSize[] = {
            "Unknown", "Small", "Normal", "Large", "X-Large"
        };
        static const char* screenLong[] = {
            "Unknown", "No", "Yes"
        };
    
        // Dumps current configuration.
        AConfiguration_fromAssetManager(configuration,
            mApplication->activity->assetManager);
        result = AConfiguration_getSdkVersion(configuration);
        Log::info("SDK Version : %d", result);
        AConfiguration_getLanguage(configuration, i18NBuffer);
        Log::info("Language    : %s", i18NBuffer);
        AConfiguration_getCountry(configuration, i18NBuffer);
        Log::info("Country     : %s", i18NBuffer);
        result = AConfiguration_getOrientation(configuration);
        Log::info("Orientation : %s (%d)", orientation[result], result);
        result = AConfiguration_getDensity(configuration);
        Log::info("Density     : %d dpi", result);
        result = AConfiguration_getScreenSize(configuration);
        Log::info("Screen Size : %s (%d)", screenSize[result], result);
        result = AConfiguration_getScreenLong(configuration);
        Log::info("Long Screen : %s (%d)", screenLong[result], result);
        AConfiguration_delete(configuration);
    ...
    

    然后,将当前本地线程附加到 Android VM。

    提示

    如果你仔细阅读了第四章,从本地代码调用 Java,你就知道这一步是获取JNIEnv对象(特定于线程)的必要步骤。JavaVM本身可以从android_app结构中获取。

  3. 之后,调用findRotation()以获取当前设备旋转。

    最后,我们可以将线程从 Dalvik 分离,因为我们不再使用 JNI。请记住,在结束应用程序之前,始终应该分离已附加的线程:

    ...
        JavaVM* javaVM = mApplication->activity->vm;
        JavaVMAttachArgs javaVMAttachArgs;
        javaVMAttachArgs.version = JNI_VERSION_1_6;
        javaVMAttachArgs.name = "NativeThread";
        javaVMAttachArgs.group = NULL;
        JNIEnv* env;
        if (javaVM->AttachCurrentThread(&env,
                        &javaVMAttachArgs) != JNI_OK) {
            Log::error("JNI error while attaching the VM");
            return;
        }
        // Finds screen rotation and get-rid of JNI.
        findRotation(env);
        mApplication->activity->vm->DetachCurrentThread();
    }
    ...
    
  4. 实现findRotation(),其基本通过 JNI 执行以下 Java 代码:

    WindowManager mgr = (InputMethodManager)
    myActivity.getSystemService(Context.WINDOW_SERVICE);
    int rotation = mgr.getDefaultDisplay().getRotation();
    

    显然,这在 JNI 中编写会稍微复杂一些。

    • 首先,获取 JNI 类,然后是方法,最后是字段

    • 然后,执行 JNI 调用

    • 最后,释放分配的 JNI 引用

    以下代码故意简化,以避免额外的检查(即,每个方法调用的FindClass()GetMethodID()返回值和异常检查):

    ...
    void Configuration::findRotation(JNIEnv* pEnv) {
        jobject WINDOW_SERVICE, windowManager, display;
        jclass ClassActivity, ClassContext;
        jclass ClassWindowManager, ClassDisplay;
        jmethodID MethodGetSystemService;
        jmethodID MethodGetDefaultDisplay;
        jmethodID MethodGetRotation;
        jfieldID FieldWINDOW_SERVICE;
    
        jobject activity = mApplication->activity->clazz;
    
        // Classes.
        ClassActivity = pEnv->GetObjectClass(activity);
        ClassContext = pEnv->FindClass("android/content/Context");
        ClassWindowManager = pEnv->FindClass(
            "android/view/WindowManager");
        ClassDisplay = pEnv->FindClass("android/view/Display");
    
        // Methods.
        MethodGetSystemService = pEnv->GetMethodID(ClassActivity,
            "getSystemService",
            "(Ljava/lang/String;)Ljava/lang/Object;");
        MethodGetDefaultDisplay = pEnv->GetMethodID(
            ClassWindowManager, "getDefaultDisplay",
            "()Landroid/view/Display;");
        MethodGetRotation = pEnv->GetMethodID(ClassDisplay,
            "getRotation", "()I");
    
        // Fields.
        FieldWINDOW_SERVICE = pEnv->GetStaticFieldID(
          ClassContext, "WINDOW_SERVICE", "Ljava/lang/String;");
    
        // Retrieves Context.WINDOW_SERVICE.
        WINDOW_SERVICE = pEnv->GetStaticObjectField(ClassContext,
            FieldWINDOW_SERVICE);
        // Runs getSystemService(WINDOW_SERVICE).
        windowManager = pEnv->CallObjectMethod(activity,
            MethodGetSystemService, WINDOW_SERVICE);
        // Runs getDefaultDisplay().getRotation().
        display = pEnv->CallObjectMethod(windowManager,
            MethodGetDefaultDisplay);
        mRotation = pEnv->CallIntMethod(display, MethodGetRotation);
    
        pEnv->DeleteLocalRef(ClassActivity);
        pEnv->DeleteLocalRef(ClassContext);
        pEnv->DeleteLocalRef(ClassWindowManager);
        pEnv->DeleteLocalRef(ClassDisplay);
    }
    
  5. jni/InputManager.hpp中管理新的加速度计传感器。

    加速度计轴在toScreenCoord()中进行转换。

    这种转换意味着我们要跟踪设备旋转:

    ...
    #include "Configuration.hpp"
    #include "GraphicsManager.hpp"
    #include "InputHandler.hpp"
    ...
    class InputManager : public InputHandler {
        ...
    protected:
        bool onTouchEvent(AInputEvent* pEvent);
        bool onKeyboardEvent(AInputEvent* pEvent);
        bool onTrackballEvent(AInputEvent* pEvent);
        bool onAccelerometerEvent(ASensorEvent* pEvent);
     void toScreenCoord(screen_rot pRotation,
     ASensorVector* pCanonical, ASensorVector* pScreen);
    
    private:
        ...
        float mScaleFactor;
        float mDirectionX, mDirectionY;
         // Reference point to evaluate touch distance.
         Location* mRefPoint;
        screen_rot mRotation;
    };
    #endif
    
  6. jni/InputManager.hpp中,利用新的Configuration类读取当前屏幕旋转设置。由于DroidBlaster强制使用竖屏模式,我们可以一次性存储旋转:

    ...
     InputManager::InputManager(android_app* pApplication,
             GraphicsManager& pGraphicsManager) :
            mApplication(pApplication), mGraphicsManager(pGraphicsManager),
            mDirectionX(0.0f), mDirectionY(0.0f),
            mRefPoint(NULL) {
        Configuration configuration(pApplication);
     mRotation = configuration.getRotation();
    }
    ...
    
  7. 让我们从加速度计传感器值计算一个方向。

    首先,将加速度计值从标准坐标转换为屏幕坐标,以处理竖屏和横屏设备。

    接下来,从捕获到的加速度计值中计算一个方向。在以下代码中,XZ轴分别表示滚转和俯仰。检查这两个轴,看设备是否处于中性位置(即CENTER_XCENTER_Z)或者是在倾斜(MIN_XMIN_ZMAX_XMAX_Z)。请注意,我们的需求需要将 Z 值取反:

    ...
    bool InputManager::onAccelerometerEvent(ASensorEvent* pEvent) {
        static const float GRAVITY =  ASENSOR_STANDARD_GRAVITY / 2.0f;
        static const float MIN_X = -1.0f; static const float MAX_X = 1.0f;
        static const float MIN_Z =  0.0f; static const float MAX_Z = 2.0f;
        static const float CENTER_X = (MAX_X + MIN_X) / 2.0f;
        static const float CENTER_Z = (MAX_Z + MIN_Z) / 2.0f;
    
        // Converts from canonical to screen coordinates.
        ASensorVector vector;
        toScreenCoord(mRotation, &pEvent->vector, &vector);
    
        // Roll tilt.
        float rawHorizontal = pEvent->vector.x / GRAVITY;
        if (rawHorizontal > MAX_X) {
            rawHorizontal = MAX_X;
        } else if (rawHorizontal < MIN_X) {
            rawHorizontal = MIN_X;
        }
        mDirectionX = CENTER_X - rawHorizontal;
    
        // Pitch tilt. Final value needs to be inverted.
        float rawVertical = pEvent->vector.z / GRAVITY;
        if (rawVertical > MAX_Z) {
            rawVertical = MAX_Z;
        } else if (rawVertical < MIN_Z) {
            rawVertical = MIN_Z;
        }
        mDirectionY = rawVertical - CENTER_Z;
        return true;
    }
    ...
    
  8. toScreenCoord()辅助函数中,根据屏幕旋转交换或反转加速度计轴,使得在使用DroidBlaster在纵向模式时,无论你使用哪种设备,XZ轴都指向同一方向:

    ...
    void InputManager::toScreenCoord(screen_rot pRotation,
        ASensorVector* pCanonical, ASensorVector* pScreen) {
        struct AxisSwap {
            int8_t negX; int8_t negY;
            int8_t xSrc; int8_t ySrc;
        };
        static const AxisSwap axisSwaps[] = {
             {  1, -1, 0, 1},  // ROTATION_0
             { -1, -1, 1, 0},  // ROTATION_90
             { -1,  1, 0, 1},  // ROTATION_180
             {  1,  1, 1, 0}}; // ROTATION_270
        const AxisSwap& swap = axisSwaps[pRotation];
    
        pScreen->v[0] = swap.negX * pCanonical->v[swap.xSrc];
        pScreen->v[1] = swap.negY * pCanonical->v[swap.ySrc];
        pScreen->v[2] = pCanonical->v[2];
    }
    

刚才发生了什么?

加速度计现在是一个游戏手柄!Android 设备可以是自然的纵向(主要是智能手机和小型平板电脑)或横向(主要是平板电脑)。这对接收加速度计事件的应用程序有影响。这些类型的设备及其旋转方式不同,轴线的对齐方式也不相同。

实际上,屏幕可以以四种不同的方式定向:090180270度。0 度是设备的自然方向。加速度计 X 轴始终指向右侧,Y 轴指向上方,Z 轴指向前方。在手机上,Y 轴在纵向模式下指向上方,而在大多数平板电脑上,Y 轴在横向模式下指向上方。当设备以 90 度方向定位时,轴的方向显然会改变(X 轴指向上方,等等)。这种情况也可能发生在以纵向模式使用的平板电脑上(其中 0 度对应于横向模式)。

发生了什么?

遗憾的是,使用原生 API 无法获取设备相对于屏幕自然方向的旋转。因此,我们需要依赖 JNI 来获取准确的设备旋转信息。然后,我们可以在onAccelerometerEvent()中像这样轻松地推导出方向向量。

关于传感器的更多内容

每个 Android 传感器都有一个唯一的标识符,在android/sensor.h中定义。这些标识符在所有 Android 设备上都是相同的:

  • ASENSOR_TYPE_ACCELEROMETER

  • ASENSOR_TYPE_MAGNETIC_FIELD

  • ASENSOR_TYPE_GYRISCOPE

  • ASENSOR_TYPE_LIGHT

  • ASENSOR_TYPE_PROXIMITY

可能存在其他额外的传感器并且可用,即使它们在android/sensor.h头文件中没有命名。在 Gingerbread 上,我们有与以下情况相同的情况:

  • 重力传感器(标识符9

  • 线性加速度传感器(标识符10

  • 旋转矢量(标识符11)。

旋转矢量传感器是现在已弃用的方向矢量的继承者,在增强现实应用中至关重要。它提供了设备在 3D 空间中的方向。结合 GPS,它可以通过设备的视角定位任何物体。旋转传感器提供了一个数据矢量,通过android.hardware.SensorManager类(请参阅其源代码),可以将其转换为 OpenGL 视图矩阵。这样,你可以直接将设备方向物化为屏幕内容,将真实与虚拟生活联系起来。

总结

在本章中,我们介绍了多种从原生代码与 Android 交互的方法。更准确地说,我们学习了如何将输入队列附加到Native App Glue事件循环中。然后,我们处理了触摸事件以及来自键盘、D-Pad 的动作事件和轨迹球的运动事件。最后,我们将加速度计转换成了游戏手柄。

由于 Android 的碎片化,预计输入设备的行为会有所不同,需要准备好调整代码。在应用结构、图形、声音、输入和传感器方面,我们已经深入了解了 Android NDK 的能力。然而,重新发明轮子并不是解决方案!

在下一章中,我们将通过将现有的 C/C++库移植到 Android,释放 NDK 的真正力量。

第九章:将现有库移植到 Android

人们关注 Android NDK 主要有两个原因:首先是为了性能,其次是为了可移植性。在前面的章节中,我们看到了如何从本地代码访问主要的本地 Android API 以提高效率。在本章中,我们将把整个 C/C++生态系统带到 Android,至少探索这条路径,因为几十年的 C/C++开发很难适应移动设备有限的内存!确实,C 和 C++仍然是现今最广泛使用的编程语言之一。

在之前的 NDK 版本中,由于对 C++的支持不完整,特别是异常运行时类型信息RTTI,一个基本的 C++反射机制,用于在运行时获取数据类型,例如 Java 中的instanceof),可移植性受到限制。任何需要这些特性的库,如果不修改代码或安装自定义 NDK(由社区从官方源代码重建的Crystax NDK,可在www.crystax.net/获取),都无法移植。幸运的是,许多这些限制已经解除(除了宽字符支持)。

尽管不一定困难,但移植现有库并非易事。可能会缺少一些 API(尽管 POSIX 支持良好),一些#define指令需要调整,一些依赖项以及依赖项的依赖项需要移植。一些库将容易移植,而另一些则需要更多努力。

在本章中,为了将现有代码移植到 Android,我们将学习如何进行以下代码操作:

  • 激活标准模板库STL

  • 移植Box2D物理引擎

  • 预构建并使用Boost框架

  • 深入了解如何编写 NDK 模块Makefiles

在本章结束时,你应该了解本地构建过程,并知道如何适当使用 Makefiles。

激活标准模板库

标准模板库是一个标准化的容器、迭代器、算法和辅助类的库,可以简化大多数常见的编程操作,如动态数组、关联数组、字符串、排序等。这个库在开发者中得到了多年的认可并被广泛传播。在 C++中开发而不使用 STL,就像一只手背在身后编程一样!

在第一部分中,让我们将 GNU STL 嵌入 DroidBlaster,以便简化集合管理。

注意

本书提供的项目名为DroidBlaster_Part16

动手实践——在 DroidBlaster 中激活 GNU STL

让我们在 DroidBlaster 中激活并使用 STL。编辑jni/Application.mk文件,旁边是jni/Android.mk,并写入以下内容。就这样!你的应用程序现在启用了 STL,多亏了这一行:

APP_ABI := armeabi armeabi-v7a x86
APP_STL := gnustl_static

刚才发生了什么?

Application.mk 文件中,我们只用一行代码就激活了 GNU STL!这个通过 APP_STL 变量选择的 STL 实现,替换了默认的 NDK C/C++ 运行时。目前支持以下三种 STL 实现:

  • GNU STL(更常见的名称是 libstdc++),官方 GCC STL:这通常是在 NDK 项目中使用 STL 的首选。支持异常和 RTTI。

  • STLport(多平台 STL):这个实现现在没有积极维护,并且缺少一些功能。在万不得已的情况下选择它。支持异常和 RTTI。

  • Libc++:这是 LLVM 的一部分(Clang 编译器背后的技术),旨在提供一个功能性的 C++ 11 运行时。请注意,这个库现在正在成为 OS-X 上的默认 STL,并且将来可能会变得更加流行。支持异常和 RTTI。Libc++ 的支持仍然是不完整的和实验性的。Libc++ 通常与 Clang 编译器一起选择(在 掌握模块 Makefiles 部分了解更多信息)。

安卓还提供了另外两个 C++ 运行时:

  • 系统:当没有激活任何 STL 实现时,这是 NDK 的默认运行时。其代码名称为 Bionic,并提供了一组最小化的头文件(cstdintcstdiocstring 等)。Bionic 不提供 STL 功能,以及异常和运行时类型信息RTTI)。关于其限制的更多详情,请查看 $ANDROID_NDK/docs/system/libc/OVERVIEW.html

  • Gabi:这类似于系统运行时,不同之处在于它支持异常和 RTTI。

在本章专门讨论 Boost 的部分,我们将看到如何在编译过程中启用异常和 RTTI。

每个运行时都可以静态或动态链接(默认系统 C/C++ 运行时除外)。动态加载的运行时以 _shared 作为后缀,静态加载的则以 _static 作为后缀。你可以传递给 APP_STL 的运行时标识符完整列表如下:

  • system

  • gabi++_staticgabi++_shared

  • stlport_staticstlport_shared

  • gnustl_staticgnustl_shared

  • c++_staticc++_shared

请记住,共享库需要在运行时手动加载。如果你忘记加载一个共享库,那么在加载依赖库模块时,运行时就会引发错误。由于编译器无法提前预测哪些函数将被调用,因此库将完全加载到内存中,即使它们的大部分内容未被使用。

另一方面,静态库实际上是与依赖库一起加载的。实际上,在运行时并不真正存在静态库。在编译时链接时,它们的内容被复制到依赖库中。由于链接器确切地知道库的哪部分被嵌入模块调用,因此它可以剥离其代码,只保留需要的部分。

提示

剥离是丢弃二进制文件中不必要符号的过程。这有助于在链接后减少(可能是大量!)二进制文件大小。这可以与 Java 中的 Proguard 收缩后处理进行比较。

然而,如果静态库被多次包含,链接将导致二进制代码重复。这种情况可能导致内存浪费,或者更令人担忧的是,例如全局变量重复的问题。但是,共享库中的静态 C++构造函数只被调用一次。

提示

请记住,除非你知道自己在做什么,否则应该避免在项目中多次使用静态库。

另一个需要考虑的问题是,Java 应用程序只能加载共享库,这些共享库可以链接到共享或静态库。例如,NativeActivity的主库是一个共享库,通过android.app.lib_name清单属性指定。从另一个库引用的共享库必须在之前手动加载。NDK 不会自动处理。

在 JNI 应用程序中,共享库可以通过System.loadLibrary()轻松加载,但NativeActivity是“透明”的活动。因此,如果你决定使用共享库,唯一的解决方案是编写自己的 Java 活动,从NativeActivity继承并调用适当的loadLibrary()指令。例如,如果我们使用gnustl_shared,DroidBlaster 活动可能如下所示:

package com.packtpub.DroidBlaster

import android.app.NativeActivity

public class MyNativeActivity extends NativeActivity {
     static {
         System.loadLibrary("gnustl_shared");
         System.loadLibrary("DroidBlaster");
     }
}

提示

如果你更愿意直接从本地代码加载本地库,你可以使用 NDK 提供的系统调用dlopen()

既然已经启用了 STL,让我们在 DroidBlaster 中使用它。

行动时间——使用 STL 流读取文件

让我们使用 STL 从 SD 卡读取资源,而不是应用程序资源目录,如下步骤所示:

  1. 显然,如果我们不在代码中主动使用 STL,那么启用 STL 是毫无用处的。让我们借此机会从资源文件切换到外部文件(位于sdcard或内部存储)。

    打开现有文件jni/Resource.hpp,进行以下操作:

    • 包含fstreamstringSTL 头文件。

    • 使用std::string对象作为文件名,并用std::ifstream对象(即输入文件流)替换资产管理成员。

    • 改变getPath()方法,使其从新的string成员返回 C 字符串。

    • 移除descriptor()方法和ResourceDescriptor类(描述符只与 Asset API 一起工作),如下所示:

      #ifndef _PACKT_RESOURCE_HPP_
      #define _PACKT_RESOURCE_HPP_
      
      #include "Types.hpp"
      
      #include <android_native_app_glue.h>
      #include <fstream>
      #include <string>
      
      ...
      class Resource {
      public:
          Resource(android_app* pApplication, const char* pPath);
      
          const char* getPath() { return mPath.c_str(); };
      
          status open();
          void close();
          status read(void* pBuffer, size_t pCount);
      
          off_t getLength();
      
          bool operator==(const Resource& pOther);
      
      private:
          std::string mPath;
          std::ifstream mInputStream;
      };
      #endif
      
  2. 打开相应的实现文件jni/Resource.cpp。用基于 STL 流和字符串的资产管理 API 替换之前的实现。文件将以二进制模式打开,如下所示:

    #include "Resource.hpp"
    
    #include <sys/stat.h>
    
    Resource::Resource(android_app* pApplication, const char* pPath):
        mPath(std::string("/sdcard/") + pPath),
        mInputStream(){
    }
    
    status Resource::open() {
        mInputStream.open(mPath.c_str(), std::ios::in | std::ios::binary);
     return mInputStream ? STATUS_OK : STATUS_KO;
    }
    
    void Resource::close() {
        mInputStream.close();
    }
    
    status Resource::read(void* pBuffer, size_t pCount) {
        mInputStream.read((char*)pBuffer, pCount);
        return (!mInputStream.fail()) ? STATUS_OK : STATUS_KO;
    }
    ...
    
  3. 要读取文件长度,我们可以使用来自sys/stat.h头文件的stat() POSIX 原始函数:

    ...
    off_t Resource::getLength() {
        struct stat filestatus;
        if (stat(mPath.c_str(), &filestatus) >= 0) {
            return filestatus.st_size;
        } else {
            return -1;
        }
    }
    ...
    
  4. 最后,我们可以使用 STL 字符串比较运算符来比较两个Resource对象:

    ...
    bool Resource::operator==(const Resource& pOther) {
        return mPath == pOther.mPath;
    }
    
  5. 对于阅读系统的这些更改应该是几乎透明的,除了背景音乐(BGM),其内容是通过资产文件描述符播放的。

    现在,我们需要提供一个真实的文件。因此,在jni/SoundService.cpp中,通过将SLDataLocator_AndroidFD结构替换为SLDataLocation_URI来更改数据源,如下所示:

    #include "Log.hpp"
    #include "Resource.hpp"
    #include "SoundService.hpp"
    
    #include <string>
    ...
    status SoundManager::playBGM(Resource& pResource) {
        SLresult result;
        Log::info("Opening BGM %s", pResource.getPath());
    
        // Set-up BGM audio source.
        SLDataLocator_URI dataLocatorIn;
        std::string path = pResource.getPath();
        dataLocatorIn.locatorType = SL_DATALOCATOR_URI;
        dataLocatorIn.URI = (SLchar*) path.c_str();
    
        SLDataFormat_MIME dataFormat;
        dataFormat.formatType    = SL_DATAFORMAT_MIME;
        ...
    }
    ...
    
  6. AndroidManifest.xml文件中,添加读取 SD 卡文件的权限,如下所示:

    <?xml version="1.0" encoding="utf-8"?>
    <manifest 
        package="com.packtpub.droidblaster2d" android:versionCode="1"
        android:versionName="1.0">
    
        <uses-permission
            android:name="android.permission.READ_EXTERNAL_STORAGE" />
    
        ...
    </manifest>
    

将所有资产资源从资产目录复制到你的设备 SD 卡(或根据你的设备,内部存储)中的/sdcard/droidblaster

刚才发生了什么?

我们已经看到了如何通过 STL 流访问位于 SD 卡上的二进制文件。我们还把 OpenSL ES 播放器从文件描述符切换到了文件名定位器。文件名本身是在这里从 STL 字符串创建的。STL 字符串是一个真正的优势,因为它们让我们摆脱了复杂的 C 字符串操作原语。

提示

几乎所有的 Android 设备都可以在挂载在/sdcard目录的附加存储位置存储文件。这里“几乎”是重要的词。从第一款 Android G1 开始,“sdcard”的含义已经改变。一些最近的设备有一个实际上是内部的外部存储(例如,某些平板电脑上的闪存),还有一些其他设备可以使用第二个存储位置(尽管在大多数情况下,第二个存储是挂载在/sdcard内部的)。此外,/sdcard路径本身并不是刻在大理石上的。因此,为了安全地检测附加存储位置,唯一的解决方案是依靠 JNI 调用android.os.Environment.getExternalStorageDirectory()。你也可以通过getExternalStorageState()检查存储是否可用。请注意,API 方法名称中的“External”一词仅出于历史原因。此外,需要在 manifest 中请求WRITE_EXTERNAL_STORAGE权限。

STL 提供的功能远不止 Files 和 Strings。其中最受欢迎的可能是 STL 容器。让我们在 DroidBlaster 中看看一些使用示例。

动手时间——使用 STL 容器

现在让我们按照以下步骤将原始数组替换为标准的 STL 容器:

  1. 打开jni/GraphicsManager.hpp头文件并包含以下头文件:

    • Vector,它定义了一个 STL 容器,封装了 C 数组(并带有一些更有趣的特性,如动态调整大小)

    • Map,它封装了相当于 Java HashMap 的东西(也就是关联数组)

    然后,在TextureProperties结构中移除textureResource成员。使用map容器代替mTextures的原始数组(使用std命名空间前缀)。第一个参数是键类型,第二个是值类型。

    最后,将所有其他原始数组替换为以下所示的vector

    ...
    #include <android_native_app_glue.h>
    #include <GLES2/gl2.h>
    #include <EGL/egl.h>
    
    #include <map>
    #include <vector>
    ...
    struct TextureProperties {
        GLuint texture;
        int32_t width;
        int32_t height;
    };
    
    class GraphicsManager {
        ...
        // Graphics resources.
        std::map<Resource*, TextureProperties> mTextures;
        std::vector<GLuint> mShaders;
        std::vector<GLuint> mVertexBuffers;
    
        std::vector<GraphicsComponent*> mComponents;
    
        // Rendering resources.
        ...
    };
    #endif
    
  2. 编辑jni/GraphicsManager.cpp并在构造函数初始化列表中初始化新的 STL 容器,如下所示:

    #include "GraphicsManager.hpp"
    #include "Log.hpp"
    
    #include <png.h>
    
    GraphicsManager::GraphicsManager(android_app* pApplication) :
        ...
        mProjectionMatrix(),
        mTextures(), mShaders(), mVertexBuffers(), mComponents(),
        mScreenFrameBuffer(0),
        mRenderFrameBuffer(0), mRenderVertexBuffer(0),
        ... {
        Log::info("Creating GraphicsManager.");
    }
    ...
    
  3. 当组件注册时,使用vector::push_back()方法将组件插入到mComponents列表中,如下所示:

    ...
    void GraphicsManager::registerComponent(GraphicsComponent* pComponent)
    {
        mComponents.push_back(pComponent);
    }
    ...
    
  4. start()中,我们可以使用迭代器遍历向量,以初始化每个已注册的组件,如下所示:

    ...
    status GraphicsManager::start() {
        ...
        mProjectionMatrix[3][3] =  1.0f;
    
        // Loads graphics components.
        for (std::vector<GraphicsComponent*>::iterator
                componentIt = mComponents.begin();
                componentIt < mComponents.end(); ++componentIt) {
            if ((*componentIt)->load() != STATUS_OK) return STATUS_KO;
        }
        return STATUS_OK;
        ...
    }
    ...
    
  5. stop()中,我们可以遍历 map(其中 second 表示条目的值)和向量集合,以释放这次分配的每个 OpenGL 资源,如下所示:

    ...
    void GraphicsManager::stop() {
        Log::info("Stopping GraphicsManager.");
        // Releases textures.
        std::map<Resource*, TextureProperties>::iterator textureIt;
        for (textureIt = mTextures.begin(); textureIt != mTextures.end();
                ++textureIt) {
            glDeleteTextures(1, &textureIt->second.texture);
        }
    
        // Releases shaders.
        std::vector<GLuint>::iterator shaderIt;
        for (shaderIt = mShaders.begin(); shaderIt < mShaders.end();
                ++shaderIt) {
            glDeleteProgram(*shaderIt);
        }
        mShaders.clear();
    
        // Releases vertex buffers.
        std::vector<GLuint>::iterator vertexBufferIt;
        for (vertexBufferIt = mVertexBuffers.begin();
                vertexBufferIt < mVertexBuffers.end(); ++vertexBufferIt) {
            glDeleteBuffers(1, &(*vertexBufferIt));
        }
        mVertexBuffers.clear();
    
        ...
    }
    ...
    
  6. 还要在update()中遍历存储的组件以渲染它们,如下所示:

    ...
    status GraphicsManager::update() {
        // Uses the offscreen FBO for scene rendering.
        glBindFramebuffer(GL_FRAMEBUFFER, mRenderFrameBuffer);
        glViewport(0, 0, mRenderWidth, mRenderHeight);
        glClear(GL_COLOR_BUFFER_BIT);
    
        // Render graphic components.
        std::vector<GraphicsComponent*>::iterator componentIt;
        for (componentIt = mComponents.begin();
                componentIt < mComponents.end(); ++componentIt) {
            (*componentIt)->draw();
        }
    
        // The FBO is rendered and scaled into the screen.
        glBindFramebuffer(GL_FRAMEBUFFER, mScreenFrameBuffer);
        ...
    }
    ...
    
  7. 由于纹理是昂贵的资源,在加载和缓存新实例之前,使用map检查纹理是否已经加载,如下所示:

    ...
    TextureProperties* GraphicsManager::loadTexture(Resource& pResource) {
        // Looks for the texture in cache first.
        std::map<Resource*, TextureProperties>::iterator textureIt =
                                               mTextures.find(&pResource);
        if (textureIt != mTextures.end()) {
            return &textureIt->second;
        }
    
        Log::info("Loading texture %s", pResource.getPath());
        ...
        Log::info("Texture size: %d x %d", width, height);
    
        // Caches the loaded texture.
        textureProperties = &mTextures[&pResource];
        textureProperties->texture = texture;
        textureProperties->width = width;
        textureProperties->height = height;
        return textureProperties;
        ...
    }
    ...
    
  8. 使用定义的vector对象保存着色器和顶点缓冲区。再次使用push_back()向向量中添加一个元素,如下所示:

    ...
    GLuint GraphicsManager::loadShader(const char* pVertexShader,
            const char* pFragmentShader) {
       ...
        if (result == GL_FALSE) {
            glGetProgramInfoLog(shaderProgram, sizeof(log), 0, log);
            Log::error("Shader program error: %s", log);
            goto ERROR;
        }
    
        mShaders.push_back(shaderProgram);
        return shaderProgram;
    
        ...
    }
    
    GLuint GraphicsManager::loadVertexBuffer(const void* pVertexBuffer,
            int32_t pVertexBufferSize) {
        ...
        if (glGetError() != GL_NO_ERROR) goto ERROR;
    
        mVertexBuffers.push_back(vertexBuffer);
        return vertexBuffer;
        ...
    }
    
  9. 现在,打开jni/SpriteBatch.hpp

    再次,包含并使用vector对象,而不是原始数组:

    ...
    #ifndef _PACKT_GRAPHICSSPRITEBATCH_HPP_
    #define _PACKT_GRAPHICSSPRITEBATCH_HPP_
    
    #include "GraphicsManager.hpp"
    #include "Sprite.hpp"
    #include "TimeManager.hpp"
    #include "Types.hpp"
    
    #include <GLES2/gl2.h>
    #include <vector>
    
    class SpriteBatch : public GraphicsComponent {
        ...
        TimeManager& mTimeManager;
        GraphicsManager& mGraphicsManager;
    
        std::vector<Sprite*> mSprites;
        std::vector<Sprite::Vertex> mVertices;
        std::vector<GLushort> mIndexes;
        GLuint mShaderProgram;
        GLuint aPosition; GLuint aTexture;
        GLuint uProjection; GLuint uTexture;
    };
    #endif
    
  10. jni/SpriteBatch.cpp中,用向量替换原始数组的用法,如下所示:

    ...
    SpriteBatch::SpriteBatch(TimeManager& pTimeManager,
            GraphicsManager& pGraphicsManager) :
        mTimeManager(pTimeManager),
        mGraphicsManager(pGraphicsManager),
        mSprites(), mVertices(), mIndexes(),
        mShaderProgram(0),
        aPosition(-1), aTexture(-1), uProjection(-1), uTexture(-1)
    {
        mGraphicsManager.registerComponent(this);
    }
    
    SpriteBatch::~SpriteBatch() {
        std::vector<Sprite*>::iterator spriteIt;
        for (spriteIt = mSprites.begin(); spriteIt < mSprites.end();
                ++spriteIt) {
            delete (*spriteIt);
        }
    }
    
    Sprite* SpriteBatch::registerSprite(Resource& pTextureResource,
            int32_t pHeight, int32_t pWidth) {
        int32_t spriteCount = mSprites.size();
        int32_t index = spriteCount * 4; // Points to 1st vertex.
    
        // Precomputes the index buffer.
        mIndexes.push_back(index+0); mIndexes.push_back(index+1);
        mIndexes.push_back(index+2); mIndexes.push_back(index+2);
        mIndexes.push_back(index+1); mIndexes.push_back(index+3);
        for (int i = 0; i < 4; ++i) {
            mVertices.push_back(Sprite::Vertex());
        }
    
        // Appends a new sprite to the sprite array.
        mSprites.push_back(new Sprite(mGraphicsManager,
                pTextureResource, pHeight, pWidth));
        return mSprites.back();
    }
    ...
    
  11. 在加载和绘制过程中,遍历vector。你可以在load()中使用一个iterator,如下所示:

    ...
    status SpriteBatch::load() {
        ...
        uTexture = glGetUniformLocation(mShaderProgram, "u_texture");
    
        // Loads sprites.
        std::vector<Sprite*>::iterator spriteIt;
        for (spriteIt = mSprites.begin(); spriteIt < mSprites.end();
                ++spriteIt) {
            if ((*spriteIt)->load(mGraphicsManager)
                    != STATUS_OK) goto ERROR;
        }
        return STATUS_OK;
    
    ERROR:
        Log::error("Error loading sprite batch");
        return STATUS_KO;
    }
    
    void SpriteBatch::draw() {
        ...
        // Renders all sprites in batch.
        const int32_t vertexPerSprite = 4;
        const int32_t indexPerSprite = 6;
        float timeStep = mTimeManager.elapsed();
        int32_t spriteCount = mSprites.size();
        int32_t currentSprite = 0, firstSprite = 0;
        while (bool canDraw = (currentSprite < spriteCount)) {
            Sprite* sprite = mSprites[currentSprite];
            ...
        }
        ...
    }
    
  12. 最后,在jni/Asteroid.hpp中声明一个std::vector,如下所示:

    #ifndef _PACKT_ASTEROID_HPP_
    #define _PACKT_ASTEROID_HPP_
    
    #include "GraphicsManager.hpp"
    #include "PhysicsManager.hpp"
    #include "TimeManager.hpp"
    #include "Types.hpp"
    
    #include <vector>
    
    class Asteroid {
    public:
        ...
        PhysicsManager& mPhysicsManager;
    
        std::vector<PhysicsBody*> mBodies;
        float mMinBound;
        float mUpperBound; float mLowerBound;
        float mLeftBound; float mRightBound;
    };
    #endif
    
  13. jni/Asteroid.cpp中使用向量插入和遍历 body,如下代码所示:

    #include "Asteroid.hpp"
    #include "Log.hpp"
    
    static const float BOUNDS_MARGIN = 128;
    static const float MIN_VELOCITY = 150.0f, VELOCITY_RANGE = 600.0f;
    
    Asteroid::Asteroid(android_app* pApplication,
            TimeManager& pTimeManager, GraphicsManager& pGraphicsManager,
            PhysicsManager& pPhysicsManager) :
        mTimeManager(pTimeManager),
        mGraphicsManager(pGraphicsManager),
        mPhysicsManager(pPhysicsManager),
        mBodies(),
        mMinBound(0.0f),
        mUpperBound(0.0f), mLowerBound(0.0f),
        mLeftBound(0.0f), mRightBound(0.0f) {
    }
    
    void Asteroid::registerAsteroid(Location& pLocation,
            int32_t pSizeX, int32_t pSizeY) {
        mBodies.push_back(mPhysicsManager.loadBody(pLocation,
                pSizeX, pSizeY));
    }
    
    void Asteroid::initialize() {
        mMinBound = mGraphicsManager.getRenderHeight();
        mUpperBound = mMinBound * 2;
        mLowerBound = -BOUNDS_MARGIN;
        mLeftBound = -BOUNDS_MARGIN;
        mRightBound = (mGraphicsManager.getRenderWidth() + BOUNDS_MARGIN);
    
        std::vector<PhysicsBody*>::iterator bodyIt;
        for (bodyIt = mBodies.begin(); bodyIt < mBodies.end(); ++bodyIt) {
            spawn(*bodyIt);
        }
    }
    
    void Asteroid::update() {
        std::vector<PhysicsBody*>::iterator bodyIt;
        for (bodyIt = mBodies.begin(); bodyIt < mBodies.end(); ++bodyIt) {
            PhysicsBody* body = *bodyIt;
            if ((body->location->x < mLeftBound)
             || (body->location->x > mRightBound)
             || (body->location->y < mLowerBound)
             || (body->location->y > mUpperBound)) {
                spawn(body);
            }
        }
    }
    ...
    

刚才发生了什么?

在整个应用程序中已经使用了 STL 容器来替换原始的 C 数组。例如,我们使用 STL 容器向量而不是原始 C 数组来管理一组Asteroid游戏对象。我们还使用 STL map 容器替换了纹理缓存。STL 容器具有许多优点,如自动处理内存管理(数组调整大小操作等),以减轻我们的负担。

STL 绝对是一个巨大的改进,它避免了重复和容易出错的代码。许多开源库需要它,现在可以毫不费力地移植。关于它的更多文档可以在www.cplusplus.com/reference/stl以及 SGI 的网站(第一个 STL 的发布者)上找到,地址是www.sgi.com/tech/stl

在性能开发中,标准的 STL 容器并不总是最佳选择,特别是在内存管理和分配方面。实际上,STL 是一个通用的库,是为了常见情况而编写的。对于性能至关重要的代码,可以考虑使用其他库。以下是一些例子:

  • EASTL:这是由 Electronic Arts 开发的,考虑到游戏而设计的 STL 替代品。在仓库中可以找到其摘录,地址是github.com/paulhodge/EASTL。一份详细介绍 EASTL 技术细节的必读论文可以在 Open Standards 网站上找到,地址是www.open-std.org/jtc1/sc22/wg21/docs/papers/2007/n2271.html

  • Bitsquid Foundation 库:这是另一个针对游戏设计的 STL 替代品,可以在bitbucket.org/bitsquid/foundation/找到。

  • RDESTL:这是一个开源的 STL 子集,基于 EASTL 技术论文,该论文在 EASTL 代码发布前几年就已经发表。代码仓库可以在code.google.com/p/rdestl/找到。

  • Google SparseHash:这是一个高性能的关联数组库(注意,RDESTL 在这方面也相当不错)。

这远非详尽无遗。只需定义你的确切需求,以做出最合适的选择。

注意

对于大多数应用或库来说,STL 仍然是最佳选择。在放弃它之前,分析你的源代码,确保这样做是真正必要的。

将 Box2D 移植到 Android

拥有 STL 工具,我们已准备好将几乎任何库移植到 Android。实际上,许多第三方库已经被移植,还有更多正在路上。然而,当没有可用的资源时,你就得依靠自己的技能。

为了了解如何处理这种情况,我们现在将使用 NDK 移植 Box2D。Box2D 是一个高度流行的物理模拟引擎,由 Erin Catto 于 2006 年发起。许多 2D 游戏,无论是业余的还是专业的,如愤怒的小鸟,都嵌入了这个强大的开源库。它支持多种语言,包括 Java,但其主要语言是 C++。

Box2D 是针对复杂主题的一个答案,即物理模拟。数学、数值积分、软件优化等都是模拟二维环境中刚体运动和碰撞的多种技术。刚体是 Box2D 的基本元素,其特点如下:

  • 一个几何形状(多边形、圆形等)

  • 物理属性(如密度摩擦恢复系数等)

  • 运动约束关节(将物体连接在一起并限制其运动)

所有这些物体都在一个名为世界的模拟环境中,根据时间进行模拟。

既然你已经了解了 Box2D 的基础知识,那么让我们将其移植并集成到 DroidBlaster 中,以模拟碰撞。

注意

本书提供的项目名为DroidBlaster_Part17

动手操作——在 Android 上编译 Box2D

首先,按照以下步骤在 Android NDK 上移植 Box2D:

Box2D 2.3.1 归档文件包含在本书的Libraries/box2d目录中。

  1. 解压 Box2D 源代码归档文件(本书中使用的是 2.3.1 版)到${ANDROID_NDK}/sources/(注意目录必须命名为box2d)。

    box2d目录的根目录中创建并打开一个Android.mk文件。

    首先,将当前目录保存到LOCAL_PATH变量中。这一步始终是必要的,因为 NDK 构建系统在编译过程中的任何时候都可能切换到另一个目录。

  2. 之后,列出所有要编译的 Box2D 源文件,如下所示。我们只关心可以在 ${ANDROID_NDK}/sources/box2d/Box2D/Box2D 中找到的源文件名。使用 LS_CPP 辅助函数以避免复制每个文件名。

    LOCAL_PATH:= $(call my-dir)
    
    LS_CPP=$(subst $(1)/,,$(wildcard $(1)/$(2)/*.cpp))
    
    BOX2D_CPP:= $(call LS_CPP,$(LOCAL_PATH),Box2D/Collision) \
                $(call LS_CPP,$(LOCAL_PATH),Box2D/Collision/Shapes) \
                $(call LS_CPP,$(LOCAL_PATH),Box2D/Common) \
                $(call LS_CPP,$(LOCAL_PATH),Box2D/Dynamics) \
                $(call LS_CPP,$(LOCAL_PATH),Box2D/Dynamics/Contacts) \
                $(call LS_CPP,$(LOCAL_PATH),Box2D/Dynamics/Joints) \
                $(call LS_CPP,$(LOCAL_PATH),Box2D/Rope)
    ...
    
  3. 然后,为静态库编写 Box2D 模块定义。首先调用 $(CLEAR_VARS) 脚本。这个脚本必须在任何模块定义之前包含,以移除其他模块可能做出的任何更改,并避免任何不希望出现的副作用。然后,定义以下设置:

    • LOCAL_MODULE 中的模块名称:模块名称以 _static 结尾,以避免与即将定义的共享版本名称冲突。

    • LOCAL_SRC_FILES 中的模块源文件(使用之前定义的 BOX2D_CPP)。

    • LOCAL_EXPORT_C_INCLUDES中将头文件目录导出到客户端模块。

    • LOCAL_C_INCLUDES 中用于模块编译的内部头文件。这里,用于 Box2D 编译的头文件和客户端模块需要的头文件是相同的(在其他库中通常也是相同的)。因此,以下方式重用之前定义的 LOCAL_EXPORT_C_INCLUDES

      ...
      include $(CLEAR_VARS)
      
      LOCAL_MODULE:= box2d_static
      LOCAL_SRC_FILES:= $(BOX2D_CPP)
      LOCAL_EXPORT_C_INCLUDES := $(LOCAL_PATH)
      LOCAL_C_INCLUDES := $(LOCAL_EXPORT_C_INCLUDES)
      ...
      Finally, request Box2D module compilation as a static library as follows:
      ...
      include $(BUILD_STATIC_LIBRARY)
      ...
      Optionally, the same process can be repeated to build a shared version of the same library by selecting a different module name and invoking $(BUILD_SHARED_LIBRARY) instead, as shown in the following:
      ...
      include $(CLEAR_VARS)
      
      LOCAL_MODULE:= box2d_shared
      LOCAL_SRC_FILES:= $(BOX2D_CPP)
      LOCAL_EXPORT_C_INCLUDES := $(LOCAL_PATH)
      LOCAL_C_INCLUDES := $(LOCAL_EXPORT_C_INCLUDES)
      
      include $(BUILD_SHARED_LIBRARY)
      
      

    注意

    Android.mk 文件位于 Libraries/box2d 目录中。

  4. 打开 DroidBlaster 的 Android.mk 文件,并将 box2d_static 添加到 LOCAL_STATIC_LIBRARIES 中以链接它。使用 import-module 指令指出要包含哪个 Android.mk 模块文件。请记住,由于 NDK_MODULE_PATH 变量指向默认的 ${ANDROID_NDK}/sources,因此可以找到模块,如下所示:

    LOCAL_PATH := $(call my-dir)
    
    include $(CLEAR_VARS)
    
    LS_CPP=$(subst $(1)/,,$(wildcard $(1)/*.cpp))
    LOCAL_MODULE    := droidblaster
    LOCAL_SRC_FILES := $(call LS_CPP,$(LOCAL_PATH))
    LOCAL_LDLIBS    := -landroid -llog -lEGL -lGLESv1_CM -lOpenSLES
    
    LOCAL_STATIC_LIBRARIES:=android_native_app_glue png \
                            box2d_static
    
    include $(BUILD_SHARED_LIBRARY)
    
    $(call import-module,android/native_app_glue)
    $(call import-module,libpng)
    $(call import-module,box2d)
    

如果您在 Eclipse 中看到有关 Box2D 包含文件的警告,可以选择激活包含文件的解析。为此,在 Eclipse 的 项目属性 中,导航到 C/C++ 通用/路径和符号 部分,然后是 包含 选项卡,并添加 Box2d 目录 ${env_var:ANDROID_NDK}/sources/box2d

刚才发生了什么?

启动 DroidBlaster 编译。Box2D 编译无误。借助 NDK,我们将第二个开源库(继 libpng 之后)移植到了 Android!我们终于可以重用社区已经创建的众多轮子之一了!将原生库移植到 Android 主要涉及编写一个 Android.mk 模块 makefile 来描述源文件、依赖项、编译标志等,正如我们现在为我们的主模块 DroidBlaster 所做的那样。

我们已经看到了模块中一些最关键的变量,它们如下所示:

  • LOCAL_MODULE:这声明了一个唯一的模块名称,最终的库名称取决于其值。

  • LOCAL_SRC_FILES:这列出了所有相对于模块根目录的待编译文件。

  • LOCAL_C_INCLUDES:这定义了 include 文件目录。

  • LOCAL_EXPORT_C_INCLUDES:这定义了 include 文件目录,但这次是用于包含模块。

构建 Box2D 模块的顺序由以下指令之一给出:

  • BUILD_STATIC_LIBRARY:这将模块编译为静态库。

  • BUILD_SHARED_LIBRARY:这次也会编译模块,但作为共享库

模块可以像 STL 一样编译为静态或共享库。编译是动态执行的(即按需),每次客户端应用程序导入模块或更改其编译设置时都会执行。希望 NDK 能够增量编译源代码。

提示

要为仅包含头文件的库(如 Boost 或 GLM(用于 OpenGL ES 矩阵计算的库)的部分)创建模块,请定义一个没有 LOCAL_SRC_FILES 的模块。只需要 LOCAL_MODULELOCAL_EXPORT_C_INCLUDES

从客户端 Android.mk 的角度来看(即我们的 DroidBlaster makefile),NDK import-module 指令大致上触发包含子模块 Android.mk 文件。没有它,NDK 将无法发现依赖模块、编译它们并包含它们的头文件。所有模块,包括主模块和子模块,都生成在 <PROJECT_DIR>/libs 中,主应用程序模块的中间二进制文件在 <PROJECT_DIR>/obj 中。

提示

import-module 指令应位于文件末尾,以避免更改模块定义。

以下是主 Android.mk Makefile 中链接“子模块”库的三种方法:

  • 静态库必须在 LOCAL_STATIC_LIBRARIES 变量中列出(例如我们对 Box2D 所做的那样)

  • 共享库需要在 LOCAL_SHARED_LIBRARIES 变量中列出

  • 共享系统库应该在 LOCAL_LDLIBS 中列出(例如我们对 OpenGL ES 所做的那样)

有关 Makefiles 的更多信息,请参见 掌握模块 Makefiles 部分。

编写 Makefile 是移植过程的重要组成部分。然而,它并不总是足够的。根据库的原始平台,移植库可能会更加复杂。例如,已经移植到 iOS 的代码通常更容易移植到 Android。在更复杂的情况下,可能需要修补代码以使其在 Android 上正常工作。当你被这样一个艰难且非琐碎的任务所困扰时,这实际上是非常频繁的,请始终考虑以下事项:

  • 确保所需的库存在,如果不存在,请先移植它们。

  • 如果随库提供了主配置头文件(通常是这样),请查找它。这是调整启用或禁用功能、移除不需要的依赖或定义新宏的好地方。

  • 注意与系统相关的宏(即 #ifdef _LINUX ...),这是查找代码中需要更改的第一地方之一。通常,需要定义宏,比如 _ANDROID_,并将其适当地插入到代码中。

  • 注释掉非必要代码,以检查库是否可以编译以及其核心功能是否可能工作。实际上,如果你还不确定它是否能工作,就不要费力修复所有问题。

幸运的是,Box2D 并未依赖于特定的平台,因为它主要依赖于纯 C/C++计算,而不是外部 API。在这种情况下,代码移植变得更容易。现在 Box2D 已经编译完成,让我们在自己的代码中运行它。

动手操作——运行 Box2D 物理引擎的时间到了。

让我们按照以下步骤用 Box2D 重写 DroidBlaster 物理引擎:

  1. 打开jni/PhysicsManager.hpp头文件,并插入 Box2D 的include文件。

    定义一个常数PHYSICS_SCALE,以将物体位置从物理坐标系转换为游戏坐标系。实际上,Box2D 使用自己的比例以获得更好的精度。

    然后,用一个新的结构体PhysicsCollision替换PhysicsBody,这将指示哪些物体进入了碰撞,如下所示:

    #ifndef PACKT_PHYSICSMANAGER_HPP
    #define PACKT_PHYSICSMANAGER_HPP
    
    #include "GraphicsManager.hpp"
    #include "TimeManager.hpp"
    #include "Types.hpp"
    
    #include <Box2D/Box2D.h>
    #include <vector>
    
    #define PHYSICS_SCALE 32.0f
    
    struct PhysicsCollision {
        bool collide;
    
        PhysicsCollision():
            collide(false)
        {}
    };
    ...
    
  2. 然后,让PhysicsManager继承自b2ContactListener。接触监听器会在每次更新模拟时收到新的碰撞通知。我们的PhysicsManager继承了一个名为BeginContact()的方法,用于对碰撞做出反应。

    我们还需要三种方法,如下所示:

    • loadBody()用于在物理引擎中创建一个新的实体

    • loadTarget()用于创建向目标(我们的太空船)移动的实体

    • start()用于在游戏开始时初始化引擎

    同时,定义以下成员变量:

    • mWorld代表整个 Box2D 模拟,其中包含我们将要创建的所有物体

    • mBodies是我们已注册的所有物理实体的列表

    • mLocations包含b2Body在游戏坐标系(而不是物理坐标系,其尺度不同)中的位置的副本

    • mBoundsBodyObj定义了我们的太空船可以移动的边界

      ...
      class PhysicsManager : private b2ContactListener {
      public:
          PhysicsManager(TimeManager& pTimeManager,
                  GraphicsManager& pGraphicsManager);
          ~PhysicsManager();
      
          b2Body* loadBody(Location& pLocation, uint16 pCategory,
              uint16 pMask, int32_t pSizeX, int32_t pSizeY,
              float pRestitution);
          b2MouseJoint* loadTarget(b2Body* pBodyObj);
          void start();
          void update();
      
      private:
          PhysicsManager(const PhysicsManager&);
          void operator=(const PhysicsManager&);
      
          void BeginContact(b2Contact* pContact);
      
          TimeManager& mTimeManager;
          GraphicsManager& mGraphicsManager;
      
          b2World mWorld;
          std::vector<b2Body*> mBodies;
          std::vector<Location*> mLocations;
          b2Body* mBoundsBodyObj;
      };
      #endif
      
  3. 实现jni/PhysicsManager.cpp

    迭代常数决定了模拟的精确度。在这里,Box2D 主要处理碰撞和简单移动。因此,将速度和位置迭代分别固定为62就足够了(稍后会详细介绍它们的意义)。

    初始化新的PhysicsManager成员,并让它在mWorld对象上通过SetContactListener()监听碰撞事件,如下所示:

    #include "PhysicsManager.hpp"
    #include "Log.hpp"
    
    static const int32_t VELOCITY_ITER = 6;
    static const int32_t POSITION_ITER = 2;
    
    PhysicsManager::PhysicsManager(TimeManager& pTimeManager,
            GraphicsManager& pGraphicsManager) :
      mTimeManager(pTimeManager), mGraphicsManager(pGraphicsManager),
      mWorld(b2Vec2_zero), mBodies(),
      mLocations(),
      mBoundsBodyObj(NULL) {
        Log::info("Creating PhysicsManager.");
        mWorld.SetContactListener(this);
    }
    
    PhysicsManager::~PhysicsManager() {
        std::vector<b2Body*>::iterator bodyIt;
        for (bodyIt = mBodies.begin(); bodyIt < mBodies.end(); ++bodyIt) {
            delete (PhysicsCollision*) (*bodyIt)->GetUserData();
        }
    }
    ...
    
  4. 当游戏开始时,初始化 Box2D 世界的边界。这些边界与转换为物理系统坐标系的显示窗口大小相匹配。实际上,物理系统使用自己的预定义比例以保持浮点值精度。我们需要四个边缘来定义这些边界,如下所示:

    ...
    void PhysicsManager::start() {
        if (mBoundsBodyObj == NULL) {
            b2BodyDef boundsBodyDef;
            b2ChainShape boundsShapeDef;
            float renderWidth = mGraphicsManager.getRenderWidth()
                                    / PHYSICS_SCALE;
            float renderHeight = mGraphicsManager.getRenderHeight()
                                    / PHYSICS_SCALE;
            b2Vec2 boundaries[4];
            boundaries[0].Set(0.0f, 0.0f);
            boundaries[1].Set(renderWidth, 0.0f);
            boundaries[2].Set(renderWidth, renderHeight);
            boundaries[3].Set(0.0f, renderHeight);
            boundsShapeDef.CreateLoop(boundaries, 4);
    
            mBoundsBodyObj = mWorld.CreateBody(&boundsBodyDef);
            mBoundsBodyObj->CreateFixture(&boundsShapeDef, 0);
        }
    }
    
  5. loadBody()中初始化并注册小行星或飞船的物理实体。

    物体定义描述了一个动态物体(相对于静态物体),它是唤醒的(即由 Box2D 积极模拟),并且不能旋转(对于多边形形状,这一属性尤为重要,意味着它总是朝上的)。

    还请注意我们如何在userData字段中保存PhysicsCollision自身的引用,以便稍后在 Box2D 回调中访问它。

    定义身体形状,我们将其近似为圆形。请注意,Box2D 需要一个半尺寸,从物体的中心到其边界,如下代码片段所示:

    b2Body* PhysicsManager::loadBody(Location& pLocation,
            uint16 pCategory, uint16 pMask, int32_t pSizeX, int32_t pSizeY,
            float pRestitution) {
        PhysicsCollision* userData = new PhysicsCollision();
    
        b2BodyDef mBodyDef;
        b2Body* mBodyObj;
        b2CircleShape mShapeDef; b2FixtureDef mFixtureDef;
    
        mBodyDef.type = b2_dynamicBody;
        mBodyDef.userData = userData;
        mBodyDef.awake = true;
        mBodyDef.fixedRotation = true;
    
        mShapeDef.m_p = b2Vec2_zero;
        int32_t diameter = (pSizeX + pSizeY) / 2;
        mShapeDef.m_radius = diameter / (2.0f * PHYSICS_SCALE);
        ...
    
  6. 身体夹具是将身体定义、形状和物理属性联系在一起的“胶水”。我们还使用它来设置身体的类别和掩码,以及过滤对象之间的碰撞(例如,在 DroidBlaster 中,行星可以与飞船碰撞,但它们之间不能相互碰撞)。一个位表示一个类别。

    最后,在 Box2D 物理世界中有效实例化你的 body,如下代码所示:

        ...
        mFixtureDef.shape = &mShapeDef;
        mFixtureDef.density = 1.0f;
        mFixtureDef.friction = 0.0f;
        mFixtureDef.restitution = pRestitution;
        mFixtureDef.filter.categoryBits = pCategory;
        mFixtureDef.filter.maskBits = pMask;
        mFixtureDef.userData = userData;
    
        mBodyObj = mWorld.CreateBody(&mBodyDef);
        mBodyObj->CreateFixture(&mFixtureDef);
        mBodyObj->SetUserData(userData);
        mLocations.push_back(&pLocation);
        mBodies.push_back(mBodyObj);
        return mBodyObj;
    }
    ...
    
  7. 实现 loadTarget() 方法,创建一个 Box2D 鼠标关节以模拟飞船移动。这样的 Joint 定义了一个空的靶标,身体(在此处指定参数)像弹性一样向其移动。这里使用的设置(maxForcedampingRatiofrequencyHz)控制飞船如何反应,可以通过调整它们来确定,如下代码所示:

    ...
    b2MouseJoint* PhysicsManager::loadTarget(b2Body* pBody) {
        b2BodyDef emptyBodyDef;
        b2Body* emptyBody = mWorld.CreateBody(&emptyBodyDef);
    
        b2MouseJointDef mouseJointDef;
        mouseJointDef.bodyA = emptyBody;
        mouseJointDef.bodyB = pBody;
        mouseJointDef.target = b2Vec2(0.0f, 0.0f);
        mouseJointDef.maxForce = 50.0f * pBody->GetMass();
        mouseJointDef.dampingRatio = 0.15f;
        mouseJointDef.frequencyHz = 3.5f;
    
        return (b2MouseJoint*) mWorld.CreateJoint(&mouseJointDef);
    }
    ...
    
  8. 编写 update() 方法。

    • 首先,清除在上一轮迭代中 BeginContact() 缓冲的任何碰撞标志。

    • 然后,通过调用 Step() 进行模拟。时间周期指定必须模拟的时间。迭代常数决定了模拟的准确性。

    • 最后,遍历所有物理体以提取它们的坐标,将它们从 Box2D 坐标转换为游戏坐标,并将结果存储到我们自己的 Location 对象中,如下代码所示:

      ...
      void PhysicsManager::update() {
          // Clears collision flags.
          int32_t size = mBodies.size();
          for (int32_t i = 0; i < size; ++i) {
              PhysicsCollision* physicsCollision =
                     ((PhysicsCollision*) mBodies[i]->GetUserData());
              physicsCollision->collide = false;
          }
          // Updates simulation.
          float timeStep = mTimeManager.elapsed();
          mWorld.Step(timeStep, VELOCITY_ITER, POSITION_ITER);
      
          // Caches the new state.
          for (int32_t i = 0; i < size; ++i) {
              const b2Vec2& position = mBodies[i]->GetPosition();
              mLocations[i]->x = position.x * PHYSICS_SCALE;
              mLocations[i]->y = position.y * PHYSICS_SCALE;
          }
      }
      ...
      
  9. 使用继承自 b2ContactListenerBeginContact() 方法结束。这个回调通知两个物体(名为 AB)之间新发生的碰撞。事件信息存储在 b2contact 结构中,其中包含各种属性,例如摩擦力、恢复系数以及通过其夹具涉及的两个物体。这些夹具本身包含对我们自己的 PhysicsCollision 的引用。当 Box2D 检测到一个接触时,我们可以使用以下链接切换 PhysicsCollision 碰撞标志:

    ...
    void PhysicsManager::BeginContact(b2Contact* pContact) {
        void* userDataA = pContact->GetFixtureA()->GetUserData();
        void* userDataB = pContact->GetFixtureB()->GetUserData();
        if (userDataA != NULL && userDataB != NULL) {
            ((PhysicsCollision*)userDataA)->collide = true;
            ((PhysicsCollision*)userDataB)->collide = true;
        }
    }
    
  10. jni/Asteroid.hpp 中,将 PhysicsBody 的使用替换为 Box2D b2Body 结构,如下代码所示:

    ...
    class Asteroid {
        ...
    private:
        void spawn(b2Body* pBody);
    
        TimeManager& mTimeManager;
        GraphicsManager& mGraphicsManager;
        PhysicsManager& mPhysicsManager;
    
        std::vector<b2Body*> mBodies;
        float mMinBound;
        float mUpperBound; float mLowerBound;
        float mLeftBound; float mRightBound;
    };
    #endif
    
  11. jni/Asteroid.cpp 中,将常数和边界缩放到物理坐标系:

    #include "Asteroid.hpp"
    #include "Log.hpp"
    
    static const float BOUNDS_MARGIN = 128 / PHYSICS_SCALE;
    static const float MIN_VELOCITY = 150.0f / PHYSICS_SCALE;
    static const float VELOCITY_RANGE = 600.0f / PHYSICS_SCALE;
    
    ...
    void Asteroid::initialize() {
        mMinBound = mGraphicsManager.getRenderHeight() / PHYSICS_SCALE;
        mUpperBound = mMinBound * 2;
        mLowerBound = -BOUNDS_MARGIN;
        mLeftBound = -BOUNDS_MARGIN;
        mRightBound = (mGraphicsManager.getRenderWidth() / PHYSICS_SCALE)
                          + BOUNDS_MARGIN;
    
        std::vector<b2Body*>::iterator bodyIt;
        for (bodyIt = mBodies.begin(); bodyIt < mBodies.end(); ++bodyIt) {
            spawn(*bodyIt);
        }
    }
    ...
    
  12. 然后,更新行星体的注册方式。用类别和掩码注册物理属性。在这里,行星被声明为属于类别 1(十六进制表示为 0X1),在评估碰撞时只考虑组 2(十六进制表示为 0X2)中的物体:

    ...
    void Asteroid::registerAsteroid(Location& pLocation,
            int32_t pSizeX, int32_t pSizeY) {
        mBodies.push_back(mPhysicsManager.loadBody(pLocation,
                0X1, 0x2, pSizeX, pSizeY, 2.0f));
    }
    ...
    

    替换并更新剩余的代码,以适应使用新的 b2Body 结构而不是 PhysicsBody 结构:

    ...
    void Asteroid::update() {
        std::vector<b2Body*>::iterator bodyIt;
        for (bodyIt = mBodies.begin(); bodyIt < mBodies.end(); ++bodyIt) {
            b2Body* body = *bodyIt;
            if ((body->GetPosition().x < mLeftBound)
             || (body->GetPosition().x > mRightBound)
             || (body->GetPosition().y < mLowerBound)
             || (body->GetPosition().y > mUpperBound)) {
                spawn(body);
            }
        }
    }
    ...
    
  13. 最后,也更新 spawn() 代码以初始化 PhysicsBody,如下代码所示:

    ...
    void Asteroid::spawn(b2Body* pBody) {
        float velocity = -(RAND(VELOCITY_RANGE) + MIN_VELOCITY);
        float posX = mLeftBound + RAND(mRightBound - mLeftBound);
        float posY = mMinBound + RAND(mUpperBound - mMinBound);
        pBody->SetTransform(b2Vec2(posX, posY), 0.0f);
        pBody->SetLinearVelocity(b2Vec2(0.0f, velocity));
    }
    
  14. 打开 jni/Ship.hpp 文件,将其转换为 Box2D body。

    registerShip() 方法中添加一个新的 b2Body 参数。

    然后,定义以下两个附加方法:

    • update(),其中包含一些新的游戏逻辑,当飞船与行星碰撞时销毁飞船

    • isDestroyed() 指示飞船是否已被销毁

    声明以下必要的变量:

    • mBody 用于在 Box2D 中管理飞船的表示

    • mDestroyedmLives 用于游戏逻辑

      ...
      #include "GraphicsManager.hpp"
      #include "PhysicsManager.hpp"
      #include "SoundManager.hpp"
      ...
      
      class Ship {
      public:
          Ship(android_app* pApplication,
               GraphicsManager& pGraphicsManager,
               SoundManager& pSoundManager);
      
          void registerShip(Sprite* pGraphics, Sound* pCollisionSound,
       b2Body* pBody);
      
          void initialize();
          void update();
      
          bool isDestroyed() { return mDestroyed; }
      
      private:
          GraphicsManager& mGraphicsManager;
          SoundManager& mSoundManager;
          Sprite* mGraphics;
          Sound* mCollisionSound;
          b2Body* mBody;
          bool mDestroyed; int32_t mLives;
      };
      #endif
      
  15. jni/Ship.cpp 中声明一些新的常量。

    接着,适当初始化新的成员变量。注意,在 initialize() 中不再需要播放碰撞声音:

    #include "Log.hpp"
    #include "Ship.hpp"
    
    static const float INITAL_X = 0.5f;
    static const float INITAL_Y = 0.25f;
    static const int32_t DEFAULT_LIVES = 10;
    
    static const int32_t SHIP_DESTROY_FRAME_1 = 8;
    static const int32_t SHIP_DESTROY_FRAME_COUNT = 9;
    static const float SHIP_DESTROY_ANIM_SPEED = 12.0f;
    
    Ship::Ship(android_app* pApplication,
            GraphicsManager& pGraphicsManager,
            SoundManager& pSoundManager) :
      mGraphicsManager(pGraphicsManager),
      mGraphics(NULL),
      mSoundManager(pSoundManager),
      mCollisionSound(NULL),
      mBody(NULL),
      mDestroyed(false), mLives(0) {
    }
    
    void Ship::registerShip(Sprite* pGraphics, Sound* pCollisionSound,
                            b2Body* pBody) {
        mGraphics = pGraphics;
        mCollisionSound = pCollisionSound;
        mBody = pBody;
    }
    
    void Ship::initialize() {
        mDestroyed = false;
     mLives = DEFAULT_LIVES;
    
        b2Vec2 position(
           mGraphicsManager.getRenderWidth() * INITAL_X / PHYSICS_SCALE,
           mGraphicsManager.getRenderHeight() * INITAL_Y / PHYSICS_SCALE);
        mBody->SetTransform(position, 0.0f);
        mBody->SetActive(true);
    }
    ...
    
  16. update() 中,检查飞船体是否与小行星发生碰撞。为此,检查存储在飞船 b2Body 自定义用户数据中的 PhysicsCollision 结构。记住,其内容是在 PhysicsManager::BeginContact() 方法中设置的

    当飞船发生碰撞时,我们可以减少其生命值并播放碰撞声音。

    如果它没有生命值了,我们可以开始播放销毁动画。当这种情况发生时,物体应该是非激活状态,以避免与更多的小行星发生碰撞。

    当飞船完全被销毁时,我们可以保存其状态,以便游戏循环可以适当作出反应,如下代码所示:

    ...
    void Ship::update() {
        if (mLives >= 0) {
            if (((PhysicsCollision*) mBody->GetUserData())->collide) {
                mSoundManager.playSound(mCollisionSound);
                --mLives;
                if (mLives < 0) {
                    Log::info("Ship has been destroyed");
                    mGraphics->setAnimation(SHIP_DESTROY_FRAME_1,
                        SHIP_DESTROY_FRAME_COUNT, SHIP_DESTROY_ANIM_SPEED,
                        false);
                    mBody->SetActive(false);
                } else {
                    Log::info("Ship collided");
                }
            }
        }
        // Destroyed.
        else {
            if (mGraphics->animationEnded()) {
                mDestroyed = true;
            }
        }
    }
    
  17. 更新 jni/MoveableBody.hpp 组件,使其在 registerMoveableBody() 中返回一个 b2Body 结构。

    添加以下两个新的成员:

    • mBody 用于物理体

    • mTarget 用于鼠标关节:

      #ifndef _PACKT_MOVEABLEBODY_HPP_
      #define _PACKT_MOVEABLEBODY_HPP_
      
      #include "InputManager.hpp"
      #include "PhysicsManager.hpp"
      #include "Types.hpp"
      
      class MoveableBody {
      public:
          MoveableBody(android_app* pApplication,
             InputManager& pInputManager, PhysicsManager& pPhysicsManager);
      
          b2Body* registerMoveableBody(Location& pLocation,
                  int32_t pSizeX, int32_t pSizeY);
      
          void initialize();
          void update();
      
      private:
          PhysicsManager& mPhysicsManager;
          InputManager& mInputManager;
      
          b2Body* mBody;
          b2MouseJoint* mTarget;
      };
      #endif
      
  18. 调整 jni/MoveableBody.cpp 中的常量以适应新的比例,并在构造函数中初始化新成员:

    #include "Log.hpp"
    #include "MoveableBody.hpp"
    
    static const float MOVE_SPEED = 10.0f / PHYSICS_SCALE;
    
    MoveableBody::MoveableBody(android_app* pApplication,
          InputManager& pInputManager, PhysicsManager& pPhysicsManager) :
      mInputManager(pInputManager),
      mPhysicsManager(pPhysicsManager),
      mBody(NULL), mTarget(NULL) {
    }
    
    b2Body* MoveableBody::registerMoveableBody(Location& pLocation,
            int32_t pSizeX, int32_t pSizeY) {
        mBody = mPhysicsManager.loadBody(pLocation, 0x2, 0x1, pSizeX,
                pSizeY, 0.0f);
        mTarget = mPhysicsManager.loadTarget(mBody);
        mInputManager.setRefPoint(&pLocation);
        return mBody;
    }
    ...
    
  19. 然后,设置并更新 physicsbody 以跟随飞船的目标。目标根据用户输入移动,如下代码所示:

    ...
    void MoveableBody::initialize() {
        mBody->SetLinearVelocity(b2Vec2(0.0f, 0.0f));
    }
    
    void MoveableBody::update() {
        b2Vec2 target = mBody->GetPosition() + b2Vec2(
            mInputManager.getDirectionX() * MOVE_SPEED,
            mInputManager.getDirectionY() * MOVE_SPEED);
        mTarget->SetTarget(target);
    }
    
  20. 最后,编辑 jni/DroidBlaster.cpp 并更改飞船注册代码以适应新的变化,如下代码所示:

    ...
    
    DroidBlaster::DroidBlaster(android_app* pApplication):
        ... {
        Log::info("Creating DroidBlaster");
    
        Sprite* shipGraphics = mSpriteBatch.registerSprite(mShipTexture,
                SHIP_SIZE, SHIP_SIZE);
        shipGraphics->setAnimation(SHIP_FRAME_1, SHIP_FRAME_COUNT,
                SHIP_ANIM_SPEED, true);
        Sound* collisionSound =
                mSoundManager.registerSound(mCollisionSound);
        b2Body* shipBody = mMoveableBody.registerMoveableBody(
                shipGraphics->location, SHIP_SIZE, SHIP_SIZE);
        mShip.registerShip(shipGraphics, collisionSound, shipBody);
    
        // Creates asteroids.
        ...
    }
    ...
    
  21. 不要忘记在 onActivate() 中启动 PhysicsManager,如下代码所示:

    ...
    status DroidBlaster::onActivate() {
        Log::info("Activating DroidBlaster");
        // Starts managers.
        if (mGraphicsManager.start() != STATUS_OK) return STATUS_KO;
        if (mSoundManager.start() != STATUS_OK) return STATUS_KO;
        mInputManager.start();
        mPhysicsManager.start();
    
        ...
    }
    ...
    
  22. onStep() 中通过更新并检查飞船状态来结束。当它被销毁时,按以下方式退出游戏循环:

    ...
    status DroidBlaster::onStep() {
        mTimeManager.update();
        mPhysicsManager.update();
    
        // Updates modules.
        mAsteroids.update();
        mMoveableBody.update();
        mShip.update();
    
        if (mShip.isDestroyed()) return STATUS_EXIT;
        return mGraphicsManager.update();
    }
    ...
    

刚才发生了什么?

我们已经使用 Box2D 物理引擎创建了一个物理模拟。更具体地说,我们已经了解了如何执行以下操作:

  • 创建一个 Box2D 世界以描述物理模拟

  • 定义实体的物理表示(飞船和小行星)

  • 步进模拟

  • 筛选并检测实体之间的碰撞

  • 提取模拟状态(即坐标)以供图形表示使用

Box2D 使用自己的分配器来优化内存管理。因此,要创建和销毁 Box2D 对象,需要系统地使用提供的工厂方法(CreateX(), DestroyX())。大多数情况下,Box2D 会自动为你管理内存。当一个对象被销毁时,所有相关的 子对象 也会被销毁(例如,当世界被销毁时,物体也会被销毁)。但是,如果你需要提前摆脱这些对象,即手动操作,请始终先销毁物体。

Box2D 是一段复杂的代码,并且相当难以正确调整。让我们深入了解一下它的世界描述方式以及如何处理碰撞。

深入 Box2D 世界

Box2D 的核心接入点是 b2World 对象,它存储了一系列物理体以进行模拟。Box2D 的物体由以下部分组成:

  • b2BodyDef:这定义了物体类型(b2_staticBody, b2_dynamicBody 等)和初始属性,如位置、角度(以弧度为单位)等。

  • b2Shape:这用于碰撞检测,并根据其密度导出物体质量。它可以是 b2PolygonShapeb2CircleShape 等等。

  • b2FixtureDef:这把物体形状、物体定义及其物理属性(如密度)联系在一起。

  • b2Body:这是世界中的一个物体实例(即每个游戏对象一个)。它由一个物体定义、一个形状和一个夹具创建。

物体具有以下几个物理特性:

  • 形状:这代表 DroidBlaster 中的一个圆形,尽管也可以使用多边形或盒子。

  • 密度:这用 kg/m² 表示,根据物体的形状和大小计算其质量。值应大于或等于 0.0。保龄球的密度大于足球。

  • 摩擦力:这表示一个物体在另一个物体上滑动的程度(例如,汽车在道路上或结冰的路面上)。值通常在 0.01.0 范围内,其中 0.0 表示没有摩擦力,1.0 表示摩擦力很大。

  • 恢复系数:这表示一个物体在碰撞中的反应程度,例如弹跳的球。值 0.0 表示没有恢复,1.0 表示完全恢复。

运行时,物体受到以下影响:

  • :这使物体线性移动。

  • 扭矩:这代表施加在物体上的旋转力。

  • 阻尼:这类似于摩擦力,尽管它不仅仅发生在物体与其他物体接触时。将它视为阻力减缓物体速度的效果。

Box2D 调整用于包含从 0.110(以米为单位)规模对象的世界。当超出这个范围使用时,数值近似可能导致模拟不准确。因此,非常有必要将坐标从 Box2D 参考系缩放到游戏,或直接到图形参考系,其中物体应在(大致)范围 [0.1, 10] 内。这就是我们定义 SCALE_FACTOR 来缩放坐标转换的原因。

关于碰撞检测的更多内容

在 Box2D 中存在多种检测和处理碰撞的方法。最基本的一种是在世界或物体更新后检查存储的所有接触。然而,这可能导致在 Box2D 内部迭代期间意外发生的接触被遗漏。

我们看到的一种更好的检测接触的方法是 b2ContactListener,它可以注册到世界对象上。以下四个回调可以被重写:

  • BeginContact (b2Contact):这检测两个物体开始碰撞的时刻。

  • EndContact(b2Contact): 这是 BeginContact() 的对应部分,表示物体不再发生碰撞。对 BeginContact() 的调用总是紧跟着一个匹配的 EndContact()

  • PreSolve (b2Contact, b2Manifold): 在检测到碰撞但还未进行碰撞解决时调用,即计算碰撞产生的冲量之前。b2Manifold 结构在单个位置保存有关接触点、法线等信息。

  • PostSolve(b2Contact, b2ContactImpulse): 在 Box2D 计算出实际的冲量(即物理反应)之后调用。

前两个回调对于触发游戏逻辑很有用(例如,销毁实体)。最后两个回调对于在计算过程中改变物理模拟很有用(更具体地,通过禁用接触来忽略某些碰撞),或者获取更准确的详细信息。例如,使用 PreSolve() 创建一个单向平台,只有当实体从上方掉落时才会与之碰撞(而不是从下方跳起时)。使用 PostSolve() 来检测碰撞强度并相应地计算伤害。

PreSolve()PostSolve() 方法可以在 BeginContact()EndContact() 之间被多次调用,而这些方法本身在一个世界更新期间可以被调用零次到多次。一个接触可以在一个模拟步骤中开始,并在几个步骤后结束。在这种情况下,事件解决回调会在“中间”步骤中连续发生。因为模拟步骤中可能会发生很多碰撞。因此,回调可能会被多次调用,应该尽可能高效。

当在 BeginContact() 回调中分析碰撞时,我们缓冲了一个碰撞标志。这是必要的,因为 Box2D 在触发回调时重用传递的 b2Contact 参数。此外,由于这些回调是在计算模拟时调用的,物理物体不能立即销毁,只能在模拟步骤结束后销毁。因此,强烈建议复制那里的任何信息以进行后处理(例如,销毁实体)。

碰撞模式与过滤

我想指出,Box2D 提供了一个所谓的bullet模式,可以通过相应的布尔成员在物体定义上激活:

mBodyDef.bullet = true;

对于像子弹这样快速移动的对象,子弹模式是必要的!默认情况下,Box2D 使用离散碰撞检测,它只考虑最终位置上的物体进行碰撞检测,会漏掉位于初始位置和最终位置之间的任何物体。然而,对于一个快速移动的物体,应该考虑整个路径。这更正式地称为连续碰撞检测CCD)。显然,CCD 是代价高昂的,应当谨慎使用。请参考以下图表:

碰撞模式与过滤

有时我们希望检测到物体重叠而不产生碰撞(比如一辆车到达终点线):这称为传感器。通过以下方式在夹具中将isSensor布尔成员设置为true可以轻松设置传感器:

mFixtureDef.isSensor = true;

可以通过监听器通过BeginContact()EndContact()查询传感器,或者通过在b2Contact类上使用IsTouching()快捷方式。

碰撞的另一个重要方面是不发生碰撞,或者更准确地说,过滤碰撞。在PreSolve()中可以通过禁用接触来执行一种过滤。这是最灵活和强大的解决方案,但也是最复杂的。

但是,正如我们所看到的,可以通过使用类别和掩码技术以更简单的方式进行过滤。每个物体分配一个或多个类别(每个类别在短整数中由一个位表示,即categoryBits成员)和一个描述它们可以与之碰撞的物体类别的掩码(每个被过滤的类别由设置为 0 的位表示,即maskBits成员),如下图所示:

碰撞模式和过滤

在前图中,Body A属于类别13,并与类别24的物体发生碰撞,这对于这个可怜的Body B来说也是如此,除非它的掩码过滤掉了与Body A类别(即13)的碰撞。换句话说,两个物体 A 和 B 必须都同意发生碰撞!

Box2D 也有碰撞组的概念。一个物体的碰撞组可以是以下任意一个:

  • 正整数:这意味着具有相同碰撞组值的其它物体可以发生碰撞

  • 负整数:这意味着具有相同碰撞组值的其它物体将被过滤

使用碰撞组也可以作为 DroidBlaster 中避免小行星之间碰撞的解决方案,尽管它不如类别和掩码灵活。注意,组别在类别之前被过滤。

比 category 和 group 过滤器更灵活的解决方案是b2ContactFilter类。这个类有一个ShouldCollide(b2Fixture, b2Fixture)方法,你可以自定义它以执行你自己的过滤。实际上,category/group 过滤器本身就是以这种方式实现的。

进一步了解 Box2D

这篇关于 Box2D 的简短介绍仅让你了解了 Box2D 的能力!以下非详尽列表被留在了阴影中:

  • 用于连接两个物体的关节

  • 使用光线投射来查询物理世界(例如,枪指向哪个位置)

  • 接触属性:法线、冲量、流形等

提示

Box2D 现在有一个小兄弟叫做LiquidFun,用于模拟流体。你可以下载并在google.github.io/liquidfun/查看它的效果。

Box2D 有一个非常棒的文档,其中包含有用的信息,可以在 www.box2d.org/manual.html 找到。此外,Box2D 带有一个测试床目录(在 Box2D/Testbed/Tests 中),其中包含许多用例。查看它们以更好地了解其功能。由于物理模拟有时可能相当棘手,我还建议您访问相当活跃的 Box2D 论坛,地址是 www.box2d.org/forum/

在 Android 上预编译 Boost

如果说 STL 是 C++ 程序中最常见的框架,那么 Boost 可能就是第二位。它就像一把瑞士军刀!这个工具箱包含大量用于处理最常见需求的实用工具,甚至更多。

大多数 Boost 功能以头文件形式提供,这意味着我们无需编译它。包含头文件就足以使用它的优势。最流行的 Boost 功能就是这种情况:智能指针,这是一个引用计数的指针类,可以自动处理内存分配和释放。它们几乎免费地避免了大多数内存泄漏和指针误用。

然而,Boost 的某些部分需要先编译,比如线程或单元测试库。我们现在将了解如何使用 Android NDK 构建它们并编译一个单元测试可执行文件。

注意

本书提供了名为 DroidBlaster_Part18 的项目结果。

动手时间 – 预编译 Boost 静态库

让我们按照以下步骤将 Boost 作为静态库预编译到 Android 上:

  1. www.boost.org/ 下载 Boost(本书中使用的是 1.55.0 版本)。将归档文件解压到 ${ANDROID_NDK}/sources 目录下,并将目录命名为 boost

    打开命令行窗口,进入 boost 目录。在 Windows 上运行 bootstrap.bat 或在 Linux 和 Mac OS X 上运行 ./bootstrap.sh 来构建 b2。这个程序,之前名为 BJam,是一种类似于 Make 的自定义构建工具。

    注意

    本书中提供了 Boost 1.55.0 归档文件,位于 Libraries/boost 目录。

    更改 DroidBlaster 中的 NDK 构建命令,以生成详细的编译日志。为此,在 Eclipse 的 项目属性 中,导航到 C/C++ Build 部分。在那里,您应该看到以下构建命令:ndk-build NDK_DEBUG=1。将其更改为 build NDK_DEBUG=0 V=1 以在发布模式下编译并生成详细日志。

  2. 重新构建 DroidBlaster(您可能需要先清理项目)。例如,如果您查看下面的编译摘录,您应该会看到一些与下面摘录相似的日志。这个日志虽然几乎难以阅读,但它提供了构建 DroidBlaster 运行的所有命令的信息。

    • 用于构建 DroidBlaster 的工具链(arm-linux-androideabi-4.6

    • DroidBlaster 所构建的系统(linux-x86_64

    • 编译器可执行文件(arm-linux-androideabi-g++

    • 归档器可执行文件(arm-linux-androideabi-ar

    • 还包括传递给它们的所有编译标志(这里针对 ARM 处理器)

    我们可以将以下内容作为确定Boost编译标志(在这锅标志汤中!)的灵感来源:

    ...
    /opt/android-ndk/toolchains/arm-linux-androideabi-4.6/prebuilt/linux-x86_64/bin/arm-linux-androideabi-g++ -MMD -MP -MF ./obj/local/armeabi/objs/DroidBlaster/Asteroid.o.d -fpic -ffunction-sections -funwind-tables -fstack-protector -no-canonical-prefixes -march=armv5te -mtune=xscale -msoft-float -fno-exceptions -fno-rtti -mthumb -Os -g -DNDEBUG -fomit-frame-pointer -fno-strict-aliasing -finline-limit=64 -I/opt/android-ndk/sources/android/native_app_glue -I/opt/android-ndk/sources/libpng -I/opt/android-ndk/sources/box2d -I/opt/android-ndk/sources/cxx-stl/gnu-libstdc++/4.6/include -I/opt/android-ndk/sources/cxx-stl/gnu-libstdc++/4.6/libs/armeabi/include -I/opt/android-ndk/sources/cxx-stl/gnu-libstdc++/4.6/include/backward -Ijni -DANDROID  -Wa,--noexecstack -Wformat -Werror=format-security      -I/opt/android-ndk/platforms/android-16/arch-arm/usr/include -c  jni/Asteroid.cpp -o ./obj/local/armeabi/objs/DroidBlaster/Asteroid.o
    
    ...
    /opt/android-ndk/toolchains/arm-linux-androideabi-4.6/prebuilt/linux-x86_64/bin/arm-linux-androideabi-ar crsD ./obj/local/armeabi/libandroid_native_app_glue.a ./obj/local/armeabi/objs/android_native_app_glue/android_native_app_glue.o
    ...
    
  3. boost目录中,打开tools/build/v2/user-config.jam文件。这个文件,如它的名字所示,是一个配置文件,可以设置以定制Boost编译。初始内容只包含注释,可以删除。开始包含以下内容:

    import feature ;
    import os ;
    
    if [ os.name ] = CYGWIN || [ os.name ] = NT {
        androidPlatform = windows ;
    } else if [ os.name ] = LINUX {
        if [ os.platform ] = X86_64 {
            androidPlatform = linux-x86_64 ;
        } else {
            androidPlatform = linux-x86 ;
        }
    } else if [ os.name ] = MACOSX {
        androidPlatform = darwin-x86 ;
    }
    ...
    
  4. 编译是静态执行的。默认情况下,BZip在 Android 上不可用,因此被禁用(不过我们可以单独编译它):

    ...
    modules.poke : NO_BZIP2 : 1 ;
    ...
    
  5. 获取指向 NDK 在磁盘上位置的android_ndk环境变量。

    声明我们可以称之为“配置”的android4.6_armeabi

    然后,重新配置 Boost 以在静态模式下使用 NDK ARM GCC 工具链(g++arranlib),归档器负责创建静态库。我们可以使用第 2 步日志中找到的信息来填充它们各自的路徑。

    sysroot指令指示编译和链接针对哪个 Android API 版本。在 NDK 中指定的目录包含特定于此版本的include文件和库,如下代码所示:

    ...
    android_ndk = [ os.environ ANDROID_NDK ] ;
    using gcc : android4.6_armeabi :
        $(android_ndk)/toolchains/arm-linux-androideabi-4.6/prebuilt/$(androidPlatform)/bin/arm-linux-androideabi-g++ :
        <archiver>$(android_ndk)/toolchains/arm-linux-androideabi-4.6/prebuilt/$(androidPlatform)/bin/arm-linux-androideabi-ar
        <ranlib>$(android_ndk)/toolchains/arm-linux-androideabi-4.6/prebuilt/$(androidPlatform)/bin/arm-linux-androideabi-ranlib
        <compileflags>--sysroot=$(android_ndk)/platforms/android-16/arch-arm
        <compileflags>-I$(android_ndk)/sources/cxx-stl/gnu-libstdc++/4.6/include
        <compileflags>-I$(android_ndk)/sources/cxx-stl/gnu-libstdc++/4.6/libs/armeabi/include
    ...
    
  6. Boost 需要异常和 RTTI。使用–fexceptions–frtti标志启用它们,如下代码所示:

    ...
        <compileflags>-fexceptions
        <compileflags>-frtti
    ...
    
  7. 需要定义几个选项来调整Boost的编译。这里我们可以借鉴第 2 步中发现的编译标志,例如:

    • -march=armv5te以指定目标平台

    • -mthumb,表示生成的代码应使用 thumb 指令(也可以使用-marm来使用 ARM 指令))

    • -0s以启用编译器优化

    • -DNDEBUG以请求以发布模式编译

    还包括或调整其他附加标志,例如:

    • -D__arm__-D__ARM_ARCH_5__等,有助于从代码中确定目标平台

    • -DANDROID-D__ANDROID__有助于确定目标操作系统

    • -DBOOST_ASIO_DISABLE_STD_ATOMIC以禁用std::atomic的使用,它在 Android 上是错误的(这是只能通过(不好)的“经验”学到的…)。

          <compileflags>-march=armv5te
          <compileflags>-mthumb
          <compileflags>-mtune=xscale
          <compileflags>-msoft-float
          <compileflags>-fno-strict-aliasing
          <compileflags>-finline-limit=64
          <compileflags>-D__arm__
          <compileflags>-D__ARM_ARCH_5__
          <compileflags>-D__ARM_ARCH_5T__
          <compileflags>-D__ARM_ARCH_5E__
          <compileflags>-D__ARM_ARCH_5TE__
          <compileflags>-MMD
          <compileflags>-MP
          <compileflags>-MF
          <compileflags>-fpic
          <compileflags>-ffunction-sections
          <compileflags>-funwind-tables
          <compileflags>-fstack-protector
          <compileflags>-no-canonical-prefixes
          <compileflags>-Os
          <compileflags>-fomit-frame-pointer
          <compileflags>-fno-omit-frame-pointer
          <compileflags>-DANDROID
          <compileflags>-D__ANDROID__
          <compileflags>-DNDEBUG
          <compileflags>-D__GLIBC__
          <compileflags>-DBOOST_ASIO_DISABLE_STD_ATOMIC
          <compileflags>-D_GLIBCXX__PTHREADS
          <compileflags>-Wa,--noexecstack
          <compileflags>-Wformat
          <compileflags>-Werror=format-security
          <compileflags>-lstdc++
          <compileflags>-Wno-long-long
              ;
      
  8. 从指向 boost 目录的终端,使用以下命令行启动编译。我们需要排除Python模块,因为它需要默认在 NDK 上不可用的附加库。

    ./b2 --without-python toolset=gcc-android4.6_armeabi link=static runtime-link=static target-os=linux architecture=arm --stagedir=android-armeabi threading=multi
    
    

最终的静态库生成在android-armeabi/lib/目录中。

对 ArmV7 和 X86 平台重复相同的步骤,为每个平台创建一个新的配置。对于 ArmV7,暂存目录必须是armeabi-v7a,对于 X86,必须是android-x86

注意

最终的user-config.jam随本书一起提供,位于Libraries/boost目录中。

刚才发生了什么?

我们定制了 Boost 配置,使用原始的 Android GCC 工具链作为独立的编译器(即,不使用 NDK 包装器)。我们声明了各种标志以适应 Android 目标平台的编译。然后,我们使用其专用的构建工具b2手动构建 Boost。现在,每次更新或修改 Boost,代码都需要使用b2重新手动编译。

我们还通过V=1参数强制 NDK-Build 生成详细的日志。这对于排除编译问题或了解 NDK-Build 编译的内容和方式非常有帮助。

最后,我们通过将NDK_DEBUG设置为0来启用发布编译模式,即进行代码优化。也可以通过在jni/Application.mk中设置APP_OPTIM := release来实现。GCC 中有五个主要的优化级别,它们如下所示:

  • -O0:这禁用任何优化。当APP_OPTIM设置为debug时,NDK 会自动设置此选项(关于这一点,在本章关于 Makefiles 的最后一部分会有更多介绍)。

  • -O1:这允许进行基本优化,而不会过多增加编译时间。这些优化不需要任何速度和空间的权衡,这意味着它们可以产生更快的代码,而不会增加可执行文件的大小。

  • -O2:这允许进行高级优化(包括-O1),但会增加编译时间。与–O1一样,这些优化不需要速度和空间的权衡。

  • -O3:这执行积极的优化(包括-O2),可能会增加可执行文件的大小,例如函数内联。这通常是有益的,但有时可能会适得其反(例如,增加内存使用也可能增加缓存未命中)。

  • -Os:这优先优化编译代码的大小(–O2的子集),其次才是速度。

尽管在发布模式下通常使用-Os–O2,但对于性能关键代码也可以考虑使用-O3-0x标志是各种 GCC 优化标志的快捷方式,启用–O2并附加额外的“细粒度”标志(例如,-finline-functions)也是一个选项。无论您选择哪种选项,找到最佳选择的最简单方法就是进行基准测试!要获取有关众多 GCC 优化选项的更多信息,请查看gcc.gnu.org/

现在 Boost 模块已经预建好了,我们可以将它的任何库嵌入到我们的应用程序中。

动手时间——编译与 Boost 链接的可执行文件

让我们通过以下步骤使用 Boost 单元测试库来构建我们自己的单元测试可执行文件:

  1. 仍然在boost目录下,创建一个新的Android.mk文件,将新预建的库声明为 Android 模块,以便 NDK 应用程序可以使用。这个文件需要为每个库包含一个模块声明。例如,定义一个名为boost_unit_test_framework的模块:

    • LOCAL_SRC_FILES引用了我们使用 b2 构建的静态库libboost_unit_test_framework.a

    使用 $(TARGET_ARCH_ABI) 变量来确定正确的路径,这取决于目标平台。它的值可以是 armeabiarmeabi-v7ax86。如果你为 X86 编译 DroidBlaster,NDK 将在 androidx86/lib 中查找 libboost_unit_test_framework.a

    • LOCAL_EXPORT_C_INCLUDES 会自动将 Boost 根目录添加到包含模块的包含文件目录列表中。

    • 指明这个模块是一个预构建的库,使用 $(PREBUILT_STATIC_LIBRARY) 指令:

      LOCAL_PATH:= $(call my-dir)
      
      include $(CLEAR_VARS)
      
      LOCAL_MODULE:= boost_unit_test_framework
      LOCAL_SRC_FILES:= android-$(TARGET_ARCH_ABI)/lib/libboost_unit_test_framework.a
      LOCAL_EXPORT_C_INCLUDES := $(LOCAL_PATH)
      
      include $(PREBUILT_STATIC_LIBRARY)
      

    可以在同一个文件中声明更多的模块,使用相同的行集(例如,boost_thread)。

    注意

    本书中提供的最终 user-config.jam 文件位于 Libraries/boost 目录中。

  2. 回到 DroidBlaster 项目,并创建一个名为 test 的新目录,其中包含单元测试文件 test/Test.cpp。编写一个测试来检查例如 TimeManager 的行为,如下代码所示:

    #include "Log.hpp"
    #include "TimeManager.hpp"
    
    #include <unistd.h>
    
    #define BOOST_TEST_MODULE DroidBlaster_test_module
    #include <boost/test/included/unit_test.hpp>
    
    BOOST_AUTO_TEST_SUITE(suiteTimeManager)
    
    BOOST_AUTO_TEST_CASE(testTimeManagerTest_elapsed)
    {
        TimeManager timeManager;
        timeManager.reset();
    
        sleep(1);
        timeManager.update();
        BOOST_REQUIRE(timeManager.elapsed() > 0.9f);
        BOOST_REQUIRE(timeManager.elapsed() < 1.2f);
    
        sleep(1);
        timeManager.update();
        BOOST_REQUIRE(timeManager.elapsed() > 0.9f);
        BOOST_REQUIRE(timeManager.elapsed() < 1.2f);
    }
    
    BOOST_AUTO_TEST_SUITE_END()
    
  3. 要在应用程序中包含 Boost,我们需要将它与支持异常和 RTTI 的 STL 实现链接起来。在 Application.mk 文件中全局启用它们,如下代码所示:

    APP_ABI := armeabi armeabi-v7a x86
    APP_STL := gnustl_static
    APP_CPPFLAGS := -fexceptions –frtti
    
    
  4. 最后,打开 DroidBlaster 的 jni/Android.mk 文件,在 import-module 部分之前创建一个名为 DroidBlaster_test 的第二个模块。这个模块编译额外的 test/Test.cpp 测试文件,并且必须链接到 Boost 单元测试库。将此模块构建为可执行文件,而不是共享库,使用 $(BUILD_EXECUTABLE)

    最后,在 import-module 部分导入 Boost 模块本身,如下代码所示:

    ...
    include $(BUILD_SHARED_LIBRARY)
    
    include $(CLEAR_VARS)
    
    LS_CPP=$(subst $(1)/,,$(wildcard $(1)/*.cpp))
    LS_CPP_TEST=$(subst $(1)/,,$(wildcard $(1)/../test/*.cpp))
    LOCAL_MODULE := DroidBlaster_test
    LOCAL_SRC_FILES := $(call LS_CPP,$(LOCAL_PATH)) \
    
    $(call LS_CPP_TEST,$(LOCAL_PATH))
    LOCAL_LDLIBS := -landroid -llog -lEGL -lGLESv2 -lOpenSLES
    LOCAL_STATIC_LIBRARIES := android_native_app_glue png box2d_static \
        libboost_unit_test_framework
    
    include $(BUILD_EXECUTABLE)
    
    $(call import-module,android/native_app_glue)
    $(call import-module,libpng)
    $(call import-module,box2d)
    $(call import-module,boost)
    
    
  5. 构建项目。如果你查看 libs 文件夹,除了共享库之外,你应该能看到一个 droidblaster_test 文件。这是一个可执行文件,我们可以在模拟器或已获得权限的设备上运行(假设你有部署和更改文件权限的权利)。部署这个文件并运行它(这里在一个 Arm V7 模拟器实例上):

    adb push libs/armeabi-v7a/droidblaster_test /data/data/
    adb shell /data/data/droidblaster_test
    
    

行动时间 – 编译一个链接到 Boost 的可执行文件

刚才发生了什么?

我们已经使用 Boost 预构建模块创建了一个完全本地的可执行文件,并且可以在 Android 上运行它。Boost 预构建静态库已经从 Boost 目录中的 Boost Android.mk 模块文件“发布”。

实际上,构建本地库有四种主要方法。我们在 Box2D 部分已经看到了 BUILD_STATIC_LIBRARYBUILD_SHARED_LIBRARY。还有两个共存的选择,如下所示:

  • PREBUILT_STATIC_LIBRARY 用于使用现有的(即预构建的)二进制静态库。

  • PREBUILT_SHARED_LIBRARY 用于使用现有的二进制共享库

这些指令表明库已经准备好进行链接。

在主模块文件内部,正如我们在 Box2D 中看到的,需要列出链接的子模块:

  • LOCAL_SHARED_LIBRARIES 用于共享库

  • LOCAL_STATIC_LIBRARIES 用于静态库

无论库是否是预构建的,都适用相同的规则。无论是静态的、动态的、预构建的还是按需构建的模块,都必须使用 NDK 的import-module指令在最终的 main 模块中导入。

当一个预构建的库被链接到主模块时,源文件并不是必需的。显然,头文件仍然是必需的。因此,如果你想在不对第三方公开源代码的情况下提供一个库,预构建库是一个合适的选择。另一方面,按需编译允许从你的主Application.mk项目文件中调整所有包含库的编译标志(如优化标志、ARM 模式等)。

为了正确链接 Boost,我们还在整个项目中启用了异常和运行时类型信息(RTTI)。通过在Application.mk文件中的APP_CPPFLAGS指令或者相关库的LOCAL_CPPFLAGS文件中添加-fexceptions-frtti,可以很容易地激活异常和 RTTI。默认情况下,Android 编译时带有-fno-exceptions-fno-rtti标志。

事实上,异常处理会让编译后的代码体积变大且效率降低。它会阻止编译器执行一些巧妙的优化。然而,异常处理是否比错误检查更糟糕,甚至不如完全不检查,这是一个高度有争议的问题。实际上,谷歌的工程师在最初的版本中放弃了异常处理,因为 GCC 3.x 为 ARM 处理器生成的异常处理代码质量不佳。但是现在构建链使用了 GCC 4.x,这个缺陷已经不存在了。与手动错误检查和处理异常情况相比,这种开销大多数时候可能并不显著。因此,是否选择异常处理取决于你(以及你使用的嵌入式库)!

提示

C++中的异常处理并不容易,它要求严格的纪律!它们必须严格用于异常情况,并要求精心设计的代码。可以查看资源获取即初始化RAII)习惯用法来正确处理它们。更多信息,请查看en.wikipedia.org/wiki/Resource_Acquisition_Is_Initialization

显然,Boost 提供的功能远比单元测试有趣得多。在其官方文档www.boost.org/doc/libs中探索其全部丰富特性。注意,由于 Boost 在 Android 平台上没有得到积极维护和测试,因此它可能会经常出现破坏性更改或错误。

既然我们已经实际了解了如何编写模块 Makefiles,让我们进一步了解它们。

掌握模块 Makefiles

Android Makefiles 是 NDK 构建过程的重要组成部分。因此,为了正确构建和管理项目,理解它们的工作方式是很重要的。

Makefile 变量

编译设置是通过一组预定义的 NDK 变量来定义的。我们已经看到了三个最重要的变量:LOCAL_PATH, LOCAL_MODULELOCAL_SRC_FILES,但还有许多其他变量存在。我们可以区分以下四种类型的变量,每种类型的前缀都不同:

  • LOCAL_变量:这些专门用于单个模块编译,在Android.mk文件中定义。

  • APP_变量:这些指的是应用范围内的选项,在Application.mk中设置。

  • NDK_变量:这些主要是内部变量,通常指的是环境变量(例如,NDK_ROOT, NDK_APP_CFLAGSNDK_APP_CPPFLAGS)。有两个值得注意的例外:NDK_TOOLCHAIN_VERSIONNDK_APPLICATION_MK。后者可以传递给 NDK-Build 参数,以定义不同的Application.mk位置。

  • 带有PRIVATE_前缀的变量:这些仅用于 NDK 内部使用。

以下表格包含了一个非详尽的LOCAL变量列表:

变量 描述
LOCAL_PATH 用于指定源文件的根位置。必须在Android.mk文件中的include $(CLEAR_VARS)之前定义。
LOCAL_MODULE 用于定义模块名称,它必须在所有模块中保持唯一。
LOCAL_MODULE_FILENAME 用于重写编译模块的默认名称,对于共享库是:- lib<模块名称>.so,对于静态库是:- lib<模块名称>.a。不能指定自定义文件扩展名,因此.so.a仍会附加在后面。
LOCAL_SRC_FILES 用于定义要编译的源文件列表,每个文件以空格分隔,相对于LOCAL_PATH
LOCAL_C_INCLUDES 用于指定 C 和 C++语言的头文件目录。该目录可以是相对于${ANDROID_NDK}目录的,但除非你需要包含特定的 NDK 文件,否则建议使用绝对路径(可以从 Makefile 变量如$(LOCAL_PATH)构建)。
LOCAL_CPP_EXTENSION 用于更改默认的 C++文件扩展名,即.cpp(例如,.cc.cxx)。可以指定以空格分隔的文件扩展名列表。扩展名对于 GCC 确定哪个文件与哪种语言相关是必要的。
LOCAL_CFLAGS, LOCAL_CPPFLAGS, LOCAL_LDLIBS 用于指定编译和链接的任何选项、标志或宏定义。第一个适用于 C 和 C++,第二个仅适用于 C++,最后一个用于链接器。
LOCAL_SHARED_LIBRARIES, LOCAL_STATIC_LIBRARIES 分别声明与其他模块(非系统库)的共享和静态模块依赖关系。LOCAL_SHARED_LIBRARIES管理依赖关系,而LOCAL_LDLIBS应用于声明系统库。
LOCAL_ARM_MODE, LOCAL_ARM_NEON, LOCAL_DISABLE_NO_EXECUTE, LOCAL_FILTER_ASM 处理器和汇编器/二进制代码生成的高级变量。对于大多数程序来说它们不是必需的。
LOCAL_EXPORT_C_INCLUDES, LOCAL_EXPORT_CFLAGS, LOCAL_EXPORT_CPPFLAGS, LOCAL_EXPORT_LDLIBS 在导入模块中定义额外的选项或标志,这些选项或标志应附加到客户端模块选项中。例如,如果一个模块 A 定义了LOCAL_EXPORT_LDLIBS := -llog,因为它需要一个 Android 日志模块。那么,依赖于模块 A 的模块 B 将自动链接到–llogLOCAL_EXPORT_变量在编译导出它们的模块时不使用。如果需要,它们还需要在它们的LOCAL对应项中指定。

关于这些变量的文档可以在${ANDROID_NDK}/docs/ANDROID-MK.html找到。

下表包含了APP变量的非详尽列表(所有都是可选的):

变量 描述
APP_PROJECT_PATH 指定应用程序项目的根目录。
APP_MODULES 要编译的模块及其标识符的列表。还包括依赖的模块。例如,可以用来强制生成静态库。
APP_OPTIM 设置为releasedebug,以使编译设置适应您想要的构建类型。当未明确指定时,NDK 使用 AndroidManifest 中的可调试标志来确定构建类型。
APP_CFLAGS``APP_CPPFLAGS``APP_LDFLAGS 全局指定编译和链接的任何选项、标志或宏定义。第一个适用于 C 和 C++,第二个仅适用于 C++,最后一个适用于链接器。
APP_BUILD_SCRIPT 重新定义 Android.mk 文件的存放位置(默认在项目的jni目录中)。
APP_ABI 应用程序支持的 ABI(即“CPU 架构”)列表,以空格分隔。目前支持的值有armeabiarmeabi-v7ax86mipsall。每个模块针对每个 ABI 重新编译一次。因此,支持的 ABI 越多,构建所需的时间就越长。
APP_PLATFORM 目标 Android 平台的名称。此信息默认在project.properties文件中找到。
APP_STL 要使用的 C++运行时。可能的值有systemgabi++_staticgabi++_sharedstlport_staticstlport_sharedgnustl_staticgnustl_sharedc++_staticc++_shared

关于这些变量的文档可以在${ANDROID_NDK}/docs/APPLICATION-MK.html找到。

启用 C++ 11 支持和 Clang 编译器

NDK_TOOLCHAIN_VERSION变量可以在Application.mk文件中重新定义,以显式选择编译工具链。对于 NDK R10,可能的值有4.6(现已弃用)、4.84.9,这些值分别对应于 GCC 版本。未来 NDK 版本中可能更改的可能版本号。要找到它们,请查看$ANDROID_NDK/toolchains目录。

Android NDK 从 GCC 4.8 工具链开始提供 C++ 11 支持。通过添加-std=c++11编译标志并激活 GNU STL(STL Port 在此书编写时不受支持,而 Libc++只部分支持),可以获得适当的 C++11 支持。以下是激活了 C++11 的Android.mk提取示例:

...
NDK_TOOLCHAIN_VERSION := 4.8
APP_CPPFLAGS += -std=c++11
APP_STL := gnustl_shared
...

提示

切换到 GCC4.8 和 C++11 可能不会一帆风顺。实际上,这个编译器,比如说,比之前要严格一些。如果你在使用这个新工具链编译旧代码时遇到麻烦,尝试使用–fpermissive标志(或者重写你的代码!)。

此外,请注意,尽管 C++11 的支持已经很广泛,但你可能仍然会遇到一些问题或缺失的功能。

要启用基于 LLVM 的编译器 Clang(因被苹果使用而著名),代替 GCC,只需将NDK_TOOLCHAIN_VERSION设置为clang。你也可以指定编译器版本,比如clang3.4clang3.5。同样,可能的版本号可能会在 NDK 的未来版本中发生变化。要找到它们,请查看$ANDROID_NDK/toolchains目录。

Makefile 指令

Makefile 是一种真正的语言,包含编程指令和函数。

Makefiles 可以分解为几个子 Makefiles,通过include指令包含。变量初始化有两种方式:

  • 简单赋值(operator :=),在变量初始化时展开变量

  • 递归赋值(operator =),每次调用时重新评估受影响的表达式

以下条件判断和循环指令可用:ifdef/endififeq/endififndef/endif,以及for…in/do/done。例如,仅当定义了变量时,才显示消息,可以这样做:

ifdef my_var
    # Do something...
endif

更高级的内容,如函数式ifandor等,可供使用,但很少被使用。Makefiles 还提供了一些有用的内置函数,如下表所示:

$(info <message>) 允许将消息打印到标准输出。这是编写 Makefiles 时最关键的工具!信息消息中允许使用变量。
$(warning <message>)$(error <message>) 允许打印警告或致命错误,停止编译。这些消息可以被 Eclipse 解析。
$(foreach <variable>, <list>, <operation>) 对变量列表执行操作。在应用操作之前,列表中的每个元素都会在第一个参数变量中展开。
$(shell <command>) 在 Make 外部执行命令。这将为 Makefiles 带来 Unix Shell 的所有强大功能,但非常依赖于系统。如果可能,避免使用它。
$(wildcard <pattern>) 根据模式选择文件和目录名称。
$(call <function>) | 允许评估一个函数或宏。我们见过的宏之一是 my-dir,它返回最后一个执行的 Makefile 的目录路径。这就是为什么每个 Android.mk 文件的开头都会写上 LOCAL_PATH := $(call my-dir),以保存当前 Makefile 目录。

使用 call 指令可以轻松编写自定义函数。这些函数看起来类似于递归赋值的变量,不同之处在于可以定义参数:$(1) 代表第一个参数,$(2) 代表第二个参数,依此类推。函数的调用可以在单行中执行,如下代码所示:

my_function=$(<do_something> ${1},${2})
$(call my_function,myparam)

字符串和文件操作函数也是可用的,如下表所示:

$(join <str1>, <str2>) 连接两个字符串。
$(subst <from>,``<replacement>,<string>),$(patsubst <pattern>,``<replacement>,<string>) 将字符串中的每个子串替换为另一个。第二个更强大,因为它允许使用模式(必须以 "%" 开头)。
$(filter <patterns>, <text>)``$(filter-out <patterns>, <text>) 从匹配模式的文本中过滤字符串。这对于过滤文件很有用。例如,以下行过滤任何 C 文件:$(filter %.c, $(my_source_list))
$(strip <string>) 移除任何不必要的空白。
$(addprefix <prefix>,<list>),$(addsuffix <suffix>, <list>) 分别向列表中的每个元素添加前缀和后缀,每个元素由空格分隔。
$(basename <path1>, <path2>, ...) 返回一个移除了文件扩展名的字符串。
$(dir <path1>, <path2>),$(notdir <path1>, <path2>) 分别提取路径中的目录和文件名。
$(realpath <path1>, <path2>, ...),$(abspath <path1>, <path2>, ...) 返回每个路径参数的规范路径,但第二个不评估符号链接。

这只是对 Makefiles 功能的概览。更多信息,请参考在 www.gnu.org/software/make/manual/make.html 可用的完整 Makefile 文档。如果你对 Makefiles 过敏,可以看看 CMake。CMake 是一个简化的 Make 系统,已经在市场上构建了许多开源库。CMake 在 Android 上的端口可以在 code.google.com/p/android-cmake 找到。

动手英雄 - 掌握 Makefiles

我们可以用多种方式玩转 Makefiles:

  • 尝试赋值运算符。例如,在你的 Android.mk 文件中写下以下代码片段,它使用了 := 运算符:

    my_value   := Android
    my_message := I am an $(my_value)
    $(info $(my_message))
    my_value   := Android eating an apple
    $(info $(my_message))
    
  • 观察启动编译时的结果。然后,使用 = 执行相同的操作。打印当前优化模式。使用 APP_OPTIM 和内部变量 NDK_APP_CFLAGS,观察 releasedebug 模式之间的区别:

    $(info Optimization level: $(APP_OPTIM) $(NDK_APP_CFLAGS))
    
  • 检查变量是否正确定义,例如:

    ifndef LOCAL_PATH
        $(error What a terrible failure! LOCAL_PATH not defined...)
    endif
    
  • 尝试使用 foreach 指令打印项目根目录及其 jni 文件夹内的文件和目录列表(并确保使用递归赋值):

    ls = $(wildcard $(var_dir))
    dir_list := . ./jni
    files := $(foreach var_dir, $(dir_list), $(ls))
    
  • 尝试创建一个宏,将消息和时间记录到标准输出:

    log=$(info $(shell date +'%D %R'): $(1))
    $(call log,My message)
    
  • 最后,测试 my-dir 宏的行为,了解为什么每个 Android.mk 文件的开头都会系统性地写出 LOCAL_PATH := $(call my-dir)

    $(info MY_DIR    =$(call my-dir))
    include $(CLEAR_VARS)
    $(info MY_DIR    =$(call my-dir))
    

CPU 架构(ABI)

当前 Android ARM 设备上的原生 C/C++ 代码遵循一个应用程序二进制接口ABI)。ABI 规定了二进制代码格式(指令集、调用约定等)。GCC 将代码翻译成这种二进制格式。因此,ABI 与处理器密切相关。可以在 Application.mk 文件中通过 APP_ABI 变量选择目标 ABI。在 Android 上支持五种主要 ABI,如下所示:

  • thumb:这是默认选项,应与所有 ARM 设备兼容。Thumb 是一种特殊的指令集,它用 16 位而不是 32 位编码指令,以提高代码大小(对内存受限的设备很有用)。与 ArmEABI 相比,指令集受到严格限制。

  • armeabi(或 Arm v5):这应该能在所有 ARM 设备上运行。指令编码为 32 位,但可能比 Thumb 代码更简洁。Arm v5 不支持浮点加速等高级扩展,因此比 Arm v7 慢。

  • armeabi-v7a:这支持如 Thumb-2(类似于 Thumb,但增加了额外的 32 位指令)和 VFP 等扩展,以及一些可选扩展,如 NEON。为 Arm V7 编译的代码不能在 Arm V5 处理器上运行。

  • x86:这是针对“PC-like”架构(即 Intel/AMD)的,更具体地说,是针对 Intel Atom 处理器的。这个 ABI 提供了特定的扩展,如 MMX 或 SSE。

  • mips:这是针对由 Imagination Technologies 开发的 MIPS 处理器(该公司还生产 PowerVR 图形处理器)。在撰写本书时,只有少数设备存在。

默认情况下,每个 ABI 编译的二进制文件都嵌入在 APK 中。在安装时选择最合适的。Google Play 还支持上传针对每个 ABI 的不同 APK,以限制应用程序大小。

高级指令集(NEON、VFP、SSE、MSA)

如果你正在阅读这本书,代码性能可能是你的主要标准之一。为了达到这个目标,ARM 创建了一个 SIMD 指令集(即单指令多数据,简称 Single Instruction Multiple Data,即使用一条指令并行处理多个数据),名为 NEON,它与 VFP(浮点加速)单元一起引入。NEON 并不是所有芯片都可用(例如,Nvidia Tegra 2 不支持它),但在密集型多媒体应用中相当受欢迎。它们也是一些处理器(例如,Cortex-A8)弱 VFP 单元的良好补偿方式。

提示

NEON 代码可以写在单独的汇编文件中,使用专门的 asm volatile 块和汇编指令,或者写在 C/C++ 文件中,或者作为内联函数(将 NEON 指令封装在 GCC C 例程中)。使用内联函数时要小心,因为 GCC 经常无法生成高效的机器代码(或者需要很多巧妙的提示)。通常建议编写真正的汇编代码。

X86 CPU 具有一套与 ARM 不同的扩展指令集:MMX、SSE、SSE2 和 SSE3。SSE 指令集相当于英特尔的 NEON SIMD 指令。最新的 SSE4 指令通常不被当前 X86 处理器支持。显然,SSE 和 NEON 不兼容,这意味着专门为 NEON 编写的代码需要重写以适应 SSE,反之亦然。

提示

Android 提供了一个 cpu-features.h API(包含 android_getCpuFamily()android_getCpuFeatures() 方法),可以在运行时检测宿主设备上的可用特性。它有助于检测 CPU(ARM、X86)及其能力(支持 ArmV7、NEON、VFP 等)。

NEON、SSE 和现代处理器通常不易掌握。互联网上有很多可以借鉴的例子。参考技术文档可以在 ARM 网站 infocenter.arm.com/ 和英特尔开发者手册 www.intel.com/ 上找到。

MIPS 也有自己的 SIMD 指令集 MSA。它提供了诸如向量算术和分支操作,或者整数和浮点值之间的转换等功能。更多信息请查看 www.imgtec.com/mips/architectures/simd.asp

所有这些信息都很有趣,但它并没有回答你可能问自己的问题:从 ARM 移植代码到 X86(或反之)有多难?答案是“视情况而定”:

  • 如果你使用纯 C/C++ 本地代码,没有特定的指令集,只需将 x86mips 追加到 APP_ABI 变量,代码应该是可移植的。

  • 如果你的代码包含汇编代码,你将需要为其他 ABI 重写相应部分或提供备用方案。

  • 如果你的代码包含特定的指令集,如 NEON(使用 C/C++ 内联函数或汇编代码),你将需要为其他 ABI 重写相应部分或提供备用方案。

  • 如果你的代码依赖于特定的内存对齐,你可能需要使用显式对齐。确实,当你编译一个数据结构时,编译器可能会使用填充来适当地对齐内存中的数据,以便更快地访问内存。然而,对齐要求根据 ABI 的不同而不同。

例如,ARM 上的 64 位变量对齐到 8,这意味着,例如,double 必须有一个内存地址,该地址是 8 的倍数。X86 内存可以更紧凑地排列。

提示

数据对齐在绝大多数情况下都不是问题,除非你显式依赖于数据位置(例如,如果你使用序列化)。即使你没有对齐问题,调整或优化结构布局以避免无用的填充并获得更好的性能总是有趣的。

因此,大部分时间,将代码从一个 ABI 移植到另一个 ABI 应该是相当简单的。在特定情况下,当需要特定的 CPU 特性或汇编代码时,提供备用方案。最后,注意,有些罕见的内存对齐问题可能会出现。

提示

正如我们在预构建 Boost 部分所看到的,每个 ABI 都有其自己的编译标志来优化编译。尽管 NDK 使用的默认 GCC 选项是适当的基础,但调整它们可以提高效率和性能。例如,你可以在 X86 平台上使用-mtune=atom -mssse3 -mfpmath=sse来优化发布代码。

总结

本章介绍了 NDK 的一个基本方面:可移植性。得益于构建工具链最近的改进,Android NDK 现在可以利用庞大的 C/C++生态系统。它开启了一个高效的生产环境,在这个环境中,代码可以与其他平台共享,旨在高效地创建新的尖端应用。

更具体地说,你学会了如何在 NDK makefile 系统中通过一个简单的标志来激活 STL。我们将 Box2D 库移植成了一个可以在 Android 项目中重复使用的 NDK 模块。你也了解了如何使用原始的 NDK 工具链预构建 Boost,而不需要任何封装。我们启用了异常和 RTTI,并深入探讨了如何编写模块 makefiles。

我们强调了使用 NDK 作为杠杆创建专业应用的路径。但不要期望所有的 C/C++库都能如此容易地移植。说到路径,我们几乎到了尽头。至少,这是关于 DroidBlaster 的最后章节。

接下来的章节也是最后一章,将介绍 RenderScript,这是一种先进的技术,可以最大限度地提高你的 Android 应用性能。

第十章:使用 RenderScript 进行密集计算

如果 NDK 是在 Android 上获得高性能的最佳工具之一。它提供了对机器的低级访问,让你控制内存分配,提供对高级 CPU 指令集的访问,甚至更多。

这种能力是有代价的:要想在一块关键代码上获得最大性能,需要针对世界上许多设备和平台优化代码。有时,使用 CPU SIMD 指令更合适,而其他时候,在 GPU 上进行计算更佳。你最好有丰富的经验、大量的设备和充足的时间!这就是谷歌在 Android 上引入 RenderScript 的原因。

RenderScript 是一种专为 Android 设计的编程语言,它的编写宗旨是:性能。明确一点,应用程序不能完全用 RenderScript 编写。但是,那些需要密集计算的关键部分,应该用!RenderScript 可以从 Java 或 C/C++ 中执行。

在本章中,我们将讨论这些基础知识,并将我们的努力集中在它的 NDK 绑定上。我们将创建一个新项目来演示通过过滤图像的 RenderScript 功能。更准确地说,我们将了解如何:

  • 执行预定义的内置函数

  • 创建你自己的自定义内核

  • 将内置函数和内核结合在一起

到本章结束时,你应该能够创建自己的 RenderScript 程序并将它们绑定到你的原生代码中。

什么是 RenderScript?

RenderScript 在 2011 年的 Honeycomb 中被引入,重点是图形处理能力,因此得名。然而,自 Android 4.1 JellyBean 起,RenderScript 的图形引擎部分已被弃用。尽管保留了它的名字,但 RenderScript 已经深刻演变,强调其“计算引擎”。它与 OpenCL 和 CUDA 等技术相似,重点是可移植性和可用性。

更具体地说,RenderScript 试图将硬件的具体性从程序员中抽象出来,并从中提取最大的原始力量。它不是采取最小公倍数,而是根据运行时执行的平台优化代码。最终代码可以在 CPU 或 GPU 上运行,具有由 RenderScript 管理的自动并行化的优势。

RenderScript 框架由几个元素组成:

  • 一种基于 C99 的 C 语言,提供变量、函数、结构等。

  • 开发者机器上基于低级虚拟机LLVM)的编译器,生成中间代码

  • 一个 RenderScript 库和运行时,只有在最终程序在设备上运行时才将中间代码转换为机器代码

  • 一个 Java 和 NDK 绑定 API,用于执行和链接计算任务

计算任务显然是 RenderScript 的核心。有两种类型的任务:

  • 内核,这是用户创建的脚本,使用 RenderScript 语言执行计算任务

  • 内置函数(Intrinsics),用于执行一些常见任务,如模糊像素的内置内核(Kernels)。

内核(Kernels)和内置函数(Intrinsics)可以组合在一起,一个程序的输出链接到另一个程序的输入。从复杂的计算任务图中可以快速生成强大程序。

然而,现在让我们看看内置函数(Intrinsics)是什么以及它们是如何工作的。

执行一个预定义的内置函数(Intrinsic)。

RenderScript提供了一些内置函数,主要用于图像处理,称为内置函数(Intrinsics)。使用这些函数,可以实现像 Photoshop 中那样的图像混合、模糊处理,甚至是从相机中解码原始 YUV 图像(对于更慢的替代方案,请参见第四章,从本地代码调用 Java)。这些内置函数简单高效。实际上,内置函数经过高度优化,可以认为是它们领域中最好的实现之一。

为了了解内置函数(Intrinsics)是如何工作的,让我们创建一个新项目,该项目接收一个输入图像并对其应用模糊效果。

注意:

本书提供的项目名为RenderScript_Part1

动手操作——创建一个 Java UI。

让我们创建一个带有 JNI 模块的新 Java 项目。

  1. 按照在第二章,开始一个本地 Android 项目中所示,创建一个新的混合 Java/C++项目:

    • 将其命名为RenderScript

    • 主包名为com.packtpub.renderscript

    • minSdkVersion为 9,targetSdkVersion为 19。

    • AndroidManifest.xml文件中定义android.permission.WRITE_EXTERNAL_STORAGE权限。

    • 将项目转换为已经了解过的本地项目。

    • 移除由 ADT 创建的本地源文件和头文件。

    • 将主活动命名为RenderScriptActivity,其布局命名为activity_renderscript.xml

  2. 按如下方式定义project.properties文件。这些行激活了RenderScript支持库,允许将代码移植到直到 API 8 的旧设备上。

    target=android-20
    renderscript.target=20
    renderscript.support.mode=true
    sdk.buildtools=20
    
    
  3. 修改res/activity_renderscript.xml文件,使其看起来如下所示。我们将需要:

    • 一个SeekBar来定义模糊半径。

    • 一个用于应用模糊效果的Button

    • 两个ImageView元素用于显示应用效果前后的图像。

      <?xml version="1.0" encoding="utf-8"?>
      <LinearLayout
      
        a:layout_width="fill_parent" a:layout_height="fill_parent"
        a:layout_weight="1" a:orientation="vertical" >
        <LinearLayout 
          a:orientation="horizontal"
          a:layout_width="fill_parent" a:layout_height="wrap_content" >
          <SeekBar a:id="@+id/radiusBar" a:max="250"
            a:layout_gravity="center_vertical"
            a:layout_width="128dp" a:layout_height="wrap_content" />
          <Button a:id="@+id/blurButton" a:text="Blur"
            a:layout_width="wrap_content" a:layout_height="wrap_content"/>
        </LinearLayout>
        <LinearLayout 
          a:baselineAligned="true" a:orientation="horizontal"
          a:layout_width="fill_parent" a:layout_height="fill_parent" >
          <ImageView
            a:id="@+id/srcImageView" a:layout_weight="1"
            a:layout_width="fill_parent" a:layout_height="fill_parent" />
          <ImageView
            a:id="@+id/dstImageView" a:layout_weight="1"
            a:layout_width="fill_parent" a:layout_height="fill_parent" />
        </LinearLayout>
      </LinearLayout>
      
  4. 按照下面所示实现RenderScriptActivity

    加载RSSupport模块,它是RenderScript的支持库,以及renderscript模块,我们将在一个静态块中创建它。

    然后,在onCreate()方法中,从drawable资源目录下加载一个 32 位的位图(这里命名为picture),并创建一个相同大小的空白位图。将这些位图分配给它们各自的ImageView组件。同时,为模糊按钮定义OnClickListener

    package com.packtpub.renderscript;
    ...
    public class RenderScriptActivity extends Activity
    implements OnClickListener {
        static {
    
            System.loadLibrary("renderscript");
        }
    
        private Button mBlurButton;
        private SeekBar mBlurRadiusBar, mThresholdBar;
        private ImageView mSrcImageView, mDstImageView;
        private Bitmap mSrcImage, mDstImage;
    
        @Override
        protected void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            setContentView(R.layout.activity_renderscript);
    
            BitmapFactory.Options options = new BitmapFactory.Options();
            options.inPreferredConfig = Bitmap.Config.ARGB_8888;
            mSrcImage = BitmapFactory.decodeResource(getResources(),
                                            R.drawable.picture, options);
            mDstImage = Bitmap.createBitmap(mSrcImage.getWidth(),
                                            mSrcImage.getHeight(),
                                            Bitmap.Config.ARGB_8888);
    
            mBlurButton = (Button) findViewById(R.id.blurButton);
            mBlurButton.setOnClickListener(this);
    
            mBlurRadiusBar = (SeekBar) findViewById(R.id.radiusBar);
    
            mSrcImageView = (ImageView) findViewById(R.id.srcImageView);
            mDstImageView = (ImageView) findViewById(R.id.dstImageView);
            mSrcImageView.setImageBitmap(mSrcImage);
            mDstImageView.setImageBitmap(mDstImage);
        }
    ...
    
  5. 创建一个本地函数blur,它带有以下参数:

    • RenderScript运行时的应用程序缓存目录。

    • 源位图和目标位图。

    • 模糊效果的半径,以确定模糊的强度。

    onClick()处理程序中调用这个方法,使用滑动条值来确定模糊半径。半径必须在[0, 25]范围内。

    ...
        private native void blur(String pCacheDir, Bitmap pSrcImage,
                                 Bitmap pDstImage, float pRadius);
    
        @Override
        public void onClick(View pView) {
            float progressRadius = (float) mBlurRadiusBar.getProgress();
            float radius = Math.max(progressRadius * 0.1f, 0.1f);
    
            switch(pView.getId()) {
            case R.id.blurButton:
                blur(getCacheDir().toString(), mSrcImage, mDstImage,
                     radius);
                break;
            }
            mDstImageView.invalidate();
        }
    }
    

行动时间——运行 RenderScript 模糊内置(Blur intrinsic)。

让我们创建一个本地模块,用于生成我们的新效果。

  1. 创建一个新文件jni/RenderScript.cpp。我们需要以下内容:

    • 使用android/bitmap.h头文件操作位图。

    • 使用jni.h处理 JNI 字符串。

    • RenderScript.h是主要的RenderScript头文件。这是你需要唯一的一个。RenderScript 是用 C++编写的,并在android::RSC命名空间中定义。

      #include <android/bitmap.h>
      #include <jni.h>
      #include <RenderScript.h>
      
      using namespace android::RSC;
      ...
      
  2. 写两个实用方法,按照第四章,从本地代码调用 Java中所示锁定和解锁 Android 位图:

    ...
    void lockBitmap(JNIEnv* pEnv, jobject pImage,
            AndroidBitmapInfo* pInfo, uint32_t** pContent) {
        if (AndroidBitmap_getInfo(pEnv, pImage, pInfo) < 0) abort();
        if (pInfo->format != ANDROID_BITMAP_FORMAT_RGBA_8888) abort();
        if (AndroidBitmap_lockPixels(pEnv, pImage,
                (void**)pContent) < 0) abort();
    }
    
    void unlockBitmap(JNIEnv* pEnv, jobject pImage) {
        if (AndroidBitmap_unlockPixels(pEnv, pImage) < 0) abort();
    }
    ...
    
  3. 使用 JNI 约定实现本地方法blur()

    然后,实例化 RS 类。这个类是主要的接口,控制 RenderScript 初始化、资源管理和对象创建。使用 RenderScript 提供的sp帮助类包装它,这代表一个智能指针。

    使用提供的缓存目录初始化它,并使用 JNI 适当地转换字符串:

    ...
    extern "C" {
    
    JNIEXPORT void JNICALL
    Java_com_packtpub_renderscript_RenderScriptActivity_blur
    (JNIEnv* pEnv, jobject pClass, jstring pCacheDir, jobject pSrcImage,
            jobject pDstImage, jfloat pRadius) {
        const char * cacheDir = pEnv->GetStringUTFChars(pCacheDir, NULL);
        sp<RS> rs = new RS();
        rs->init(cacheDir);
        pEnv->ReleaseStringUTFChars(pCacheDir, cacheDir);
    ...
    
  4. 使用我们刚才编写的实用方法锁定我们正在操作的位图:

    ...
        AndroidBitmapInfo srcInfo; uint32_t* srcContent;
        AndroidBitmapInfo dstInfo; uint32_t* dstContent;
        lockBitmap(pEnv, pSrcImage, &srcInfo, &srcContent);
        lockBitmap(pEnv, pDstImage, &dstInfo, &dstContent);
    ...
    
  5. 现在是这部分有趣的内容。从源位图创建一个 RenderScript 的分配(Allocation)。这个ALLOCATION代表了整个输入内存区域,其维度由Type定义。分配(Allocation)由“单个”元素(Elements)组成;在我们的案例中,32 位 RGBA 像素定义为Element::RGBA_8888。由于位图不作为纹理使用,我们不需要Mipmaps(更多信息请参见第六章,使用 OpenGL ES 渲染图形)。

    对从输出位图创建的输出ALLOCATION重复相同的操作:

    ...
        sp<const Type> srcType = Type::create(rs, Element::RGBA_8888(rs),
                srcInfo.width, srcInfo.height, 0);
        sp<Allocation> srcAlloc = Allocation::createTyped(rs, srcType,
                RS_ALLOCATION_MIPMAP_NONE,
                RS_ALLOCATION_USAGE_SHARED | RS_ALLOCATION_USAGE_SCRIPT,
                srcContent);
    
        sp<const Type> dstType = Type::create(rs, Element::RGBA_8888(rs),
                dstInfo.width, dstInfo.height, 0);
        sp<Allocation> dstAlloc = Allocation::createTyped(rs, dstType,
                RS_ALLOCATION_MIPMAP_NONE,
                RS_ALLOCATION_USAGE_SHARED | RS_ALLOCATION_USAGE_SCRIPT,
                dstContent);
    ...
    
  6. 创建一个ScriptIntrinsicBlur实例以及它处理的数据类型,即 RGBA 像素。内置(Intrinsic)是一个预定义的 RenderScript 函数,实现了一些常见的操作,比如我们案例中的模糊效果。模糊内置(Blur Intrinsic)以一个半径作为输入参数。使用setRadius()设置它。

    然后,指定模糊内置(Intrinsic)的输入,即使用setInput()的源分配(Allocation)。

    使用forEach()将内置(Intrinsic)应用于每个元素,并将其保存到输出分配(Allocation)中。

    最后,使用copy2DRangeTo()将结果复制到目标位图。

    ...
        sp<ScriptIntrinsicBlur> blurIntrinsic =
                ScriptIntrinsicBlur::create(rs, Element::RGBA_8888(rs));
        blurIntrinsic->setRadius(pRadius);
    
        blurIntrinsic->setInput(srcAlloc);
        blurIntrinsic->forEach(dstAlloc);
        dstAlloc->copy2DRangeTo(0, 0, dstInfo.width, dstInfo.height,
                dstContent);
    ...
    
  7. 在应用效果后,不要忘记解锁位图!

    ...
        unlockBitmap(pEnv, pSrcImage);
        unlockBitmap(pEnv, pDstImage);
    }
    }
    
  8. 创建一个jni/Application.mk文件,针对ArmEABI V7X86平台。实际上,RenderScript 目前不支持较旧的ArmEABI V5。需要STLPort,这也是 RenderScript 本地库要求的。

    APP_PLATFORM := android-19
    APP_ABI := armeabi-v7a x86
    APP_STL := stlport_static
    
  9. 创建一个jni/Android.mk文件,定义我们的renderscript模块,并列出RenderScript.cpp进行编译。

    LOCAL_C_INCLUDES 指向适当的 RenderScript,包括 NDK 平台目录中的文件目录。同时,将 RenderScript 预编译库目录添加到 LOCAL_LDFLAG

    最后,链接到 dllogRScpp_static,这些是 RenderScript 必需的:

    LOCAL_PATH := $(call my-dir)
    
    include $(CLEAR_VARS)
    
    LOCAL_MODULE    := renderscript
    LOCAL_C_INCLUDES += $(TARGET_C_INCLUDES)/rs/cpp \
                        $(TARGET_C_INCLUDES)/rs
    LOCAL_SRC_FILES := RenderScript.cpp
    LOCAL_LDFLAGS += -L$(call host-path,$(TARGET_C_INCLUDES)/../lib/rs)
    LOCAL_LDLIBS    := -ljnigraphics -ldl -llog -lRScpp_static
    
    include $(BUILD_SHARED_LIBRARY)
    

发生了什么?

运行项目,增加 SeekBar 的值,点击 模糊 按钮。输出 ImageView 应显示如下过滤后的图片:

发生了什么?

我们在项目中嵌入了 RenderScript 兼容库,使我们能够访问到 API 8 Froyo 的 RenderScript。在旧设备上,RenderScript 是在 CPU 上“模拟”的。

提示

如果您决定从 NDK 使用 RenderScript 但不想使用兼容库,则需要手动嵌入 RenderScript 运行时。为此,删除在第 2 步中添加到 project.properties 文件的所有内容,并在您的 Android.mk 文件的末尾包含以下代码片段:

...
include $(CLEAR_VARS)
LOCAL_MODULE := RSSupport
LOCAL_SRC_FILES := $(SYSROOT_LINK)/usr/lib/rs/lib$(LOCAL_MODULE)$(TARGET_ SONAME_EXTENSION)
include $(PREBUILT_SHARED_LIBRARY)

然后,我们执行了第一个 RenderScript 内在(Intrinsic),尽可能高效地应用了模糊效果。内在执行遵循一种简单且重复的模式,您会多次看到:

  1. 确保输入和输出内存区域是独占可用的,例如,通过锁定位图。

  2. 创建或重用适当的输入和输出分配。

  3. 创建并设置内在参数。

  4. 设置输入分配(Allocation),并将内在(Intrinsic)应用于输出分配。

  5. 将输出分配中的结果复制到目标内存区域。

为了更好地理解这个过程,让我们更深入地了解 RenderScript 的工作方式。RenderScript 遵循一个简单的模型。它获取一些数据作为输入,并处理到输出内存区域:

发生了什么?

作为计算解决方案,RenderScript 处理内存中存储的任何类型的数据。这是一个分配(Allocation)。分配由单个元素组成。对于一个指向位图的分配,元素通常是一个像素(本身是一组 4 个 uchar 值)。在众多可用的元素中,我们可以列举:

可能的分配元素
U8, U8_2, U8_3, U8_4
U16, U16_2, U16_3, U16_4
U32, U32_2, U32_3, U32_4
U64, U64_2, U64_3, U64_4
F32, F32_2, F32_3, F32_4
MATRIX_2X2

U = 无符号整数,I = 有符号整数,F = 浮点数

8, 16, 32, 64 = 字节计数。例如 I8 = 8 位有符号整型(即有符号字符)

_2, _3, _4 = 向量中的元素数量(I8_3 表示 3 个有符号整数的向量)

A_8 表示 Alpha 通道(每个像素表示为一个无符号字符)。

在内部,Element 使用 DataType(例如 UNSIGNED_8 表示无符号字符)和 DataKind(例如 PIXEL_RGBA 表示像素)来描述。DataKind 与称为 Samplers 的东西一起用于在 GPU 上解释的图形数据(请参阅第六章,使用 OpenGL ES 渲染图形,以更好地了解什么是 Sampler)。DataType 和 DataKind 是更高级的用法,大多数时候对您应该是透明的。您可以在 developer.android.com/reference/android/renderscript/Element.html 查看完整的 Element 列表。 |

仅仅知道输入/输出 Element 的类型是不够的。它们的数量同样重要,因为这决定了整个 Allocation 的大小。这就是 Type 的作用,它可以设置为 1 维,2 维(通常用于位图),或 3 维。还支持其他一些信息,例如 YUV 格式(NV21 是 Android 中的默认格式,如第四章,从本地代码调用 Java 中所见)。换句话说,Type 描述了一个多维数组。

Allocation 有一个特定的标志来控制如何生成 Mipmaps。默认情况下,大多数 Allocation 都不需要 Mipmap (RS_ALLOCATION_MIPMAP_NONE)。然而,当作为图形纹理的输入时,Mipmap 要么在脚本内存中创建 (RS_ALLOCATION_MIPMAP_FULL),要么在上传到 GPU 时创建 (RS_ALLOCATION_MIPMAP_ON_SYNC_TO_TEXTURE)。

一旦我们根据 Type 和 Element 创建了 Allocation,就可以处理创建和设置 Intrinsics。RenderScript 提供了一些主要关注图像处理的内置函数,虽然数量不多:

内置函数 描述

|

ScriptIntrinsicBlend
用于将两个 Allocation 混合在一起,例如,两个图像(我们将在本章的最后部分看到加性混合)。

|

ScriptIntrinsicBlur
用于在 Bitmap 上应用模糊效果。

|

ScriptIntrinsicColorMatrix
用于将颜色矩阵应用到 Allocation(例如,调整图像色调,改变颜色等)。

|

ScriptIntrinsicConvolve3x3
用于将大小为 3 的卷积矩阵应用到 Allocation(许多图像滤镜可以通过卷积矩阵实现,包括模糊处理)。

|

ScriptIntrinsicConvolve5x5
这与 ScriptIntrinsicConvolve3x3 相同,但使用的是大小为 5 的矩阵。

|

ScriptIntrinsicHistogram
用于应用直方图滤镜(例如,提高图像对比度)。

|

ScriptIntrinsicLUT
用于每个通道应用“查找表”(例如,将像素中给定的红色值转换为表中另一个预定义的值)。

|

ScriptIntrinsicResize
用于调整 2D Allocation 的大小(例如,缩放图像)。

|

ScriptIntrinsicYuvToRGB
例如,要将来自相机的 YUV 图像转换为 RGB 图像(就像我们在第四章,从本地代码调用 Java中所做的那样)。在 NDK 中绑定这个内置函数是有问题的,因此在本书编写时无法使用。如果你确实需要它,从 Java 应用它。

每个内置函数都需要其自己的特定参数(例如,模糊效果的半径)。完整的内置函数文档可以在 developer.android.com/reference/android/renderscript/package-summary.html 找到。

内置函数需要输入和输出分配。从技术上讲,如果应用的函数类型适当,可以使用输入作为输出。例如,ScriptIntrinsicBlur 就不适用,因为模糊的像素可能在同时被读取以模糊其他像素时被写入。

设置好分配后,应用内置函数并执行其工作。之后,结果必须使用 copy***To() 方法之一(对于具有两个维度的位图使用 copy2DRangeTo(),如果目标区域有间隔则使用 copy2DStridedTo())复制到输出内存区域。数据复制是在使用计算结果之前的一个必要步骤。

提示

在某些设备上,如果图像分配的大小不是 4 的倍数,已经报告了一些问题。这可能会让你想起 OpenGL 纹理,它们也有相同的要求。因此,尽量使用 4 的倍数的尺寸。

尽管 RenderScript 提供的内置函数确实很有用,但你可能需要更多的灵活性。也许你需要自己的自定义图像滤镜,或者需要超过 25 像素的模糊效果,或者你可能根本不想处理图像。那么,RenderScript 内核可能就是你要找的答案。

编写自定义内核

RenderScript 让你有能力开发小的自定义“脚本”,而不是内置函数。这些程序称为内核,用类似 C 的语言编写。它们由基于 RenderScript LLVM 的编译器在构建时编译成中间语言。最后,它们在运行时被翻译成机器码。RenderScript 负责平台相关的优化。

现在,让我们看看如何通过实现一个根据像素亮度过滤的自定义图像效果来创建这样的内核。

注意事项

最终的项目随书提供,名为 RenderScript_Part2

动手时间 – 编写一个亮度阈值滤波器

让我们在 UI 中添加一个新组件并实现新的图像滤镜。

  1. res/activity_renderscript.xml 中添加一个新的 阈值 SeekBarButton

    <?xml version="1.0" encoding="utf-8"?>
    <LinearLayout
    
      a:layout_width="fill_parent" a:layout_height="fill_parent"
      a:layout_weight="1" a:orientation="vertical" >
      <LinearLayout 
        a:orientation="horizontal"
        a:layout_width="fill_parent" a:layout_height="wrap_content" >
        ...
        <SeekBar a:id="@+id/thresholdBar" a:max="100"
     a:layout_gravity="center_vertical"
     a:layout_width="128dp" a:layout_height="wrap_content" />
     <Button a:id="@+id/thresholdButton" a:text="Threshold"
     a:layout_width="wrap_content" a:layout_height="wrap_content"/>
      </LinearLayout>
      <LinearLayout 
        a:baselineAligned="true" a:orientation="horizontal"
        a:layout_width="fill_parent" a:layout_height="fill_parent" >
        ...
      </LinearLayout>
    </LinearLayout>
    
  2. 编辑 RenderScriptActivity,并将阈值 SeekBarButton 绑定到一个新的本地方法 threshold()。这个方法与 blur() 类似,不同之处在于它接收一个在范围 [0, 100] 内的浮点阈值参数。

    ...
    public class RenderScriptActivity extends Activity
    implements OnClickListener {
        ...
    
        private Button mBlurButton, mThresholdButton;
        private SeekBar mBlurRadiusBar, mThresholdBar;
        private ImageView mSrcImageView, mDstImageView;
        private Bitmap mSrcImage, mDstImage;
    
        @Override
        protected void onCreate(Bundle savedInstanceState) {
            ...
    
            mBlurButton = (Button) findViewById(R.id.blurButton);
            mBlurButton.setOnClickListener(this);
            mThresholdButton = (Button)findViewById(R.id.thresholdButton);
     mThresholdButton.setOnClickListener(this);
    
            mBlurRadiusBar = (SeekBar) findViewById(R.id.radiusBar);
            mThresholdBar = (SeekBar) findViewById(R.id.thresholdBar);
    
            ...
        }
    
        @Override
        public void onClick(View pView) {
            float progressRadius = (float) mBlurRadiusBar.getProgress();
            float radius = Math.max(progressRadius * 0.1f, 0.1f);
            float threshold = ((float) mThresholdBar.getProgress())
                            / 100.0f;
    
            switch(pView.getId()) {
            ...
    
            case R.id.thresholdButton:
                threshold(getCacheDir().toString(), mSrcImage, mDstImage,
                          threshold);
                break;
            }
            mDstImageView.invalidate();
        }
        ...
    
        private native void threshold(String pCacheDir, Bitmap pSrcImage,
                                      Bitmap pDstImage, float pThreshold);
    }
    
  3. 现在,让我们使用 RenderScript 语言编写自己的 jni/threshold.rs 过滤器。首先,使用 pragma 指令声明:

    • 脚本语言版本(目前只有 1 是可能的)

    • 脚本关联的 Java 包名

      #pragma version(1)
      #pragma rs java_package_name(com.packtpub.renderscript)
      ...
      
  4. 然后,声明一个类型为 float 的输入参数 thresholdValue

    我们还需要两个 3 个浮点数(float3)的常量向量。

    • 第一个值表示 BLACK 颜色

    • 第二个值是一个预定义的 LUMINANCE_VECTOR

      ...
      float thresholdValue;
      static const float3 BLACK = { 0.0, 0.0, 0.0 };
      static const float3 LUMINANCE_VECTOR = { 0.2125, 0.7154, 0.0721 };
      ...
      
  5. 创建名为 threshold() 的脚本根函数。它接收一个 4 个无符号字符的向量,即输入的 RGBA 像素,并输出一个新的像素。使用 __attribute__((kernel)) 作为前缀,表示这个函数是主要的脚本函数,即“Kernel 的根”。该函数的工作原理如下:

    • 它将输入像素从字符向量转换成浮点值向量,每个颜色分量在范围 [0, 255] 内,转换成每个分量在范围 [0.0, 1.0] 内的向量。这是函数 rsUnpackColor8888() 的作用。

    • 现在我们有了浮点向量,可以使用 RenderScript 提供的许多数学函数之一。这里,与 RGBA 颜色空间的预定义亮度向量进行点乘,返回一个像素的相对亮度。

    • 有了这些信息,函数会检查一个像素的亮度是否根据给定的阈值足够。如果不是,像素将被设置为黑色。

    • 最后,它通过 rsPackColor8888() 将像素颜色的浮点向量转换为无符号字符向量。这个值随后会被 RenderScript 复制到最终的 Bitmap 中,我们稍后会看到。

      ...
      uchar4 __attribute__((kernel)) threshold(uchar4 in) {
          float4 pixel = rsUnpackColor8888(in);
          float luminance = dot(LUMINANCE_VECTOR, pixel.rgb);
          if (luminance < thresholdValue) {
              pixel.rgb = BLACK;
          }
          return rsPackColorTo8888(pixel);
      }
      
  6. 要编译我们新的 threshold.rs 脚本,请在 Android.mk 文件中列出它。

    在编译过程中,ScriptC_threshold.hScriptC_threshold.cpp 会被生成在 obj/local/armeabi-v7a/objs-debug/renderscript 目录下。这些文件包含将我们的代码与由 RenderScript 执行的阈值 Kernel绑定的代码。因此,我们还需要将此目录添加到 LOCAL_C_INCLUDES 目录中:

    LOCAL_PATH := $(call my-dir)
    
    include $(CLEAR_VARS)
    
    LOCAL_MODULE    := renderscript
    LOCAL_C_INCLUDES += $(TARGET_C_INCLUDES)/rs/cpp \
                        $(TARGET_C_INCLUDES)/rs \
                        $(TARGET_OBJS)/$(LOCAL_MODULE)
    LOCAL_SRC_FILES := RenderScript.cpp threshold.rs
    LOCAL_LDFLAGS += -L$(call host-path,$(TARGET_C_INCLUDES)/../lib/rs)
    LOCAL_LDLIBS    := -ljnigraphics -ldl -llog -lRScpp_static
    
    include $(BUILD_SHARED_LIBRARY)
    
  7. jni/RenderScript.cpp 中包含生成的头文件。

    #include <android/bitmap.h>
    #include <jni.h>
    #include <RenderScript.h>
    #include "ScriptC_threshold.h"
    
    using namespace android::RSC;
    
    ...
    
  8. 接下来,按照 JNI 命名约定实现新的 threshold() 方法。这个方法与 blur() 方法类似。

    然而,我们不是实例化一个预定义的 Intrinsic,而是通过 RenderScript 实例化一个名为 ScriptC_threshold 的 Kernel,这个名字是根据我们的 RenderScript 文件名来的。

    我们的脚本中定义的输入参数 thresholdValue 可以通过 RenderScript 生成的 set_thresholdValue() 进行初始化。然后,可以使用生成的 forEach_threshold() 方法应用主方法 threshold()

    应用了 Kernel 之后,结果可以像使用 Intrinsics 一样,通过 copy2DRangeTo() 复制到目标位图上:

    ...
    JNIEXPORT void JNICALL
    Java_com_packtpub_renderscript_RenderScriptActivity_threshold
    (JNIEnv* pEnv, jobject pClass, jstring pCacheDir, jobject pSrcImage,
            jobject pDstImage, jfloat pThreshold) {
        const char * cacheDir = pEnv->GetStringUTFChars(pCacheDir, NULL);
        sp<RS> rs = new RS();
        rs->init(cacheDir);
        pEnv->ReleaseStringUTFChars(pCacheDir, cacheDir);
    
        AndroidBitmapInfo srcInfo;
        uint32_t* srcContent;
        AndroidBitmapInfo dstInfo;
        uint32_t* dstContent;
        lockBitmap(pEnv, pSrcImage, &srcInfo, &srcContent);
        lockBitmap(pEnv, pDstImage, &dstInfo, &dstContent);
    
        sp<const Type> srcType = Type::create(rs, Element::RGBA_8888(rs),
                srcInfo.width, srcInfo.height, 0);
        sp<Allocation> srcAlloc = Allocation::createTyped(rs, srcType,
                RS_ALLOCATION_MIPMAP_NONE,
                RS_ALLOCATION_USAGE_SHARED | RS_ALLOCATION_USAGE_SCRIPT,
                srcContent);
    
        sp<const Type> dstType = Type::create(rs, Element::RGBA_8888(rs),
                dstInfo.width, dstInfo.height, 0);
        sp<Allocation> dstAlloc = Allocation::createTyped(rs, dstType,
                RS_ALLOCATION_MIPMAP_NONE,
                RS_ALLOCATION_USAGE_SHARED | RS_ALLOCATION_USAGE_SCRIPT,
                dstContent);
    
        sp<ScriptC_threshold> thresholdKernel = new ScriptC_threshold(rs);
     thresholdKernel->set_thresholdValue(pThreshold);
    
        thresholdKernel->forEach_threshold(srcAlloc, dstAlloc);
        dstAlloc->copy2DRangeTo(0, 0, dstInfo.width, dstInfo.height,
                dstContent);
    
        unlockBitmap(pEnv, pSrcImage);
        unlockBitmap(pEnv, pDstImage);
    }
    }
    

刚才发生了什么?

运行项目,增加新的 SeekBar,并点击 阈值 按钮。输出的 ImageView 应显示只有发光像素的过滤图片,如下所示:

发生了什么?

我们编写并编译了第一个 RenderScript Kernel。Kernel 脚本以.rs为扩展名,并使用受 C99 启发的语言编写。它们的内容以 pragma 定义开始,这些定义提供了关于它们的额外“元”信息:语言版本(只能是 1)和 Java 包。我们还可以使用 pragma 指令调整浮点计算精度(#pragma rs_fp_full, #pragma rs_fp_relaxed,或#pragma rs_fp_imprecise)。

提示

Java 包对于 RenderScript 运行时很重要,它需要在执行期间解析编译后的 Kernels。当使用 RenderScript 兼容库时,使用 NDK 编译的脚本(存储在jni文件夹中)可能无法解析。在这种情况下,一个可能的解决方案是在 Java src文件夹中适当包内复制.rs文件。

Kernels 在某种程度上类似于 Intrinsics。实际上,一旦编译,它们应用的过程是相似的:创建分配、Kernel、设置一切、应用,最后复制结果。当执行时,Kernel 函数对输入的每个 Element 进行操作,并在对应的输出分配 Element 中并行返回。

你可以通过 NDK 绑定 API 和额外的绑定层(更常见的是称为反射层)来设置 Kernel,该层在编译时生成。每个编译的脚本都由一个 C++类“反射”,其名称根据脚本文件名前缀ScriptC_定义。最终代码在同名头文件和obj目录中的源文件中生成,每个 ABI 一个。反射类是与脚本文件交互的唯一接口,作为一种包装器。它们对传递给 Kernel 输入或输出的分配类型进行一些运行时检查,以确保其 Element 类型与脚本文件中声明的类型匹配。查看项目中obj目录下生成的ScriptC_threshold.cpp以获取具体示例。

Kernel 输入参数通过反射层传递给脚本文件,通过全局变量。全局变量对应于所有非static和非const变量,例如:

float thresholdValue;

它们是在函数外部声明的,比如 C 变量。全局变量通过设置器在反射层中可用。在我们的项目中,thresholdValue全局变量通过生成的set_thresholdValue()方法传递。变量不必是基本类型。它们还可以是指针,在这种情况下,反射方法名称以bind_为前缀,并期望一个分配。在生成的类中也提供了获取器。

另一方面,与全局变量在同一作用域内声明的静态变量在 NDK 反射层中不可访问,且无法在脚本外部修改。当标记为const时,它们显然被视为常量,就像我们项目中亮度向量一样:

static const float3 LUMINANCE_VECTOR = { 0.2125, 0.7154, 0.0721 };

主要的 Kernel 函数,更常见的称呼是root 函数,像声明 C 函数一样,只是它们用__attribute__((kernel))标记。它们以输入 Allocation 的 Element 类型作为参数,并以输出 Allocation 的 Element 类型作为返回值。输入参数和返回值都是可选的,但至少需要其中一个。在我们的示例中,输入参数和输出返回值都是一个像素 Element(即 4 个无符号字符的向量;每个颜色通道 1 字节):

uchar4 __attribute__((kernel)) threshold(uchar4 in) {
   ...
}

RenderScript 的 root 函数还可以提供额外的索引参数,这些参数表示 Allocation 内的 Element 位置(或“坐标”)。例如,我们可以在threshold()中声明两个额外的uint32_t参数,以获取像素 Element 的坐标:

uchar4 __attribute__((kernel)) threshold(uchar4 in, uint32_t x, uint32_t y) {
   ...
}

在一个脚本中可以声明多个具有不同名称的 root 函数。编译后,它们在生成的类中以forEach_前缀的函数形式体现,例如:

void forEach_threshold(android::RSC::sp<const android::RSC::Allocation> ain, android::RSC::sp<const android::RSC::Allocation> aout);

__attribute__((kernel))引入之前,RenderScript 文件只能包含一个名为 root 的主函数。这种形式至今仍然被允许。这类函数接收输入、输出 Allocation 的指针作为参数,并且不允许有返回值。因此,将threshold()函数重写为传统的 root 方法如下所示:

void root(const uchar4 *in, uchar4 *out) {
    float4 pixel = rsUnpackColor8888(*in);
    float luminance = dot(LUMINANCE_VECTOR, pixel.rgb);
    if (luminance < thresholdValue) {
        pixel.rgb = BLACK;
    }
    *out = rsPackColorTo8888(pixel);

除了root()函数之外,脚本还可以包含一个无参数和返回值的init()函数。这个函数在脚本实例化时只被调用一次。

void init() {
    ...
}

显然,RenderScript 语言的功能比传统的 C 语言更有限、更受限制。我们无法:

  • 直接分配资源。在运行 Kernel 之前,内存必须由客户端应用程序分配。

  • 编写低级汇编代码或进行花哨的 C 语言操作。但是,希望有大量熟悉的 C 语言元素可用,比如structtypedefenum等等;甚至指针!

  • 使用 C 库或运行时。然而,RenderScript 提供了一个完整的“运行时”库,其中包含大量的数学、转换、原子函数等。更多详细信息,请查看developer.android.com/guide/topics/renderscript/reference.html

    提示

    RenderScript 提供的一个你可能觉得特别有用的方法是rsDebug(),它将调试日志打印到 ADB。

即使有这些限制,RenderScript 的限制仍然相当宽松。其结果是,某些脚本可能无法从最大加速中受益,例如在 GPU 上,这相当受限。为了解决这个问题,设计了一个名为 FilterScript 的 RenderScript 有限子集,以优化和兼容性。如果你需要最大性能,可以考虑使用它。

有关 RenderScript 语言功能的更多信息,请查看developer.android.com/guide/topics/renderscript/advanced.html

组合脚本

团结就是力量对于 RenderScript 来说再真实不过了。内置函数和内核本身是强大的功能。然而,当它们结合在一起时,它们使 RenderScript 框架发挥出全部的力量。

让我们看看如何将模糊亮度阈值滤镜与混合内置函数结合在一起,创建一个美观的图像效果。

注意

本书提供的项目在名称RenderScript_Part3下。

动手时间——组合内置函数和脚本

让我们改进项目,应用一个新的组合滤镜。

  1. res/activity_renderscript.xml中添加一个新的组合Button,如下所示:

    <?xml version="1.0" encoding="utf-8"?>
    <LinearLayout
    
      a:layout_width="fill_parent" a:layout_height="fill_parent"
      a:layout_weight="1" a:orientation="vertical" >
      <LinearLayout 
        a:orientation="horizontal"
        a:layout_width="fill_parent" a:layout_height="wrap_content" >
        ...
        <Button a:"d="@+id/thresholdBut"on" a:te"t="Thresh"ld"
          a:layout_wid"h="wrap_cont"nt" a:layout_heig"t="wrap_cont"nt"/>
        <Button a:"d="@+id/combineBut"on" a:te"t="Comb"ne"
          a:layout_wid"h="wrap_cont"nt" a:layout_heig"t="wrap_cont"nt"/>
      </LinearLayout>
      <LinearLayout 
        a:baselineAlign"d="t"ue" a:orientati"n="horizon"al"
        a:layout_wid"h="fill_par"nt" a:layout_heig"t="fill_par"nt" >
        ...
      </LinearLayout>
    </LinearLayout>
    
  2. 组合按钮绑定到一个新的本地方法combine(),该方法具有blur()threshold()的参数:

    ...
    public class RenderScriptActivity extends Activity
    implements OnClickListener {
        ...
    
        private Button mThresholdButton, mBlurButton, mCombineButton;
        private SeekBar mBlurRadiusBar, mThresholdBar;
        private ImageView mSrcImageView, mDstImageView;
        private Bitmap mSrcImage, mDstImage;
    
        @Override
        protected void onCreate(Bundle savedInstanceState) {
            ...
    
            mBlurButton = (Button) findViewById(R.id.blurButton);
            mBlurButton.setOnClickListener(this);
            mThresholdButton = (Button) findViewById(R.id.thresholdButton);
            mThresholdButton.setOnClickListener(this);
            mCombineButton = (Button)findViewById(R.id.combineButton);
            mCombineButton.setOnClickListener(this);
    
            ...
        }
    
        @Override
        public void onClick(View pView) {
            float progressRadius = (float) mBlurRadiusBar.getProgress();
            float radius = Math.max(progressRadius * 0.1f, 0.1f);
            float threshold = ((float) mThresholdBar.getProgress())
                            / 100.0f;
    
            switch(pView.getId()) {
            case R.id.blurButton:
                blur(getCacheDir().toString(), mSrcImage, mDstImage,
                     radius);
                break;
    
            case R.id.thresholdButton:
                threshold(getCacheDir().toString(), mSrcImage, mDstImage,
                          threshold);
                break;
    
            case R.id.combineButton:
                combine(getCacheDir().toString(), mSrcImage, mDstImage,
                        radius, threshold);
                break;
            }
            mDstImageView.invalidate();
        }
        ...
    
        private native void combine(String pCacheDir,
                                    Bitmap pSrcImage, Bitmap pDstImage,
                                    float pRadius, float pThreshold);
    }
    
  3. 编辑jni/RenderScript.cpp并按照 JNI 约定添加新的combine()方法。该方法与我们之前看到的类似:

    • 初始化 RenderScript 引擎

    • 位图被锁定

    • 为输入和输出位图创建适当的分配

      ...
      JNIEXPORT void JNICALL
      Java_com_packtpub_renderscript_RenderScriptActivity_combine
      (JNIEnv* pEnv, jobject pClass, jstring pCacheDir, jobject pSrcImage,
              jobject pDstImage, jfloat pRadius, jfloat pThreshold) {
          const char * cacheDir = pEnv->GetStringUTFChars(pCacheDir, NULL);
          sp<RS> rs = new RS();
          rs->init(cacheDir);
          pEnv->ReleaseStringUTFChars(pCacheDir, cacheDir);
      
          AndroidBitmapInfo srcInfo; uint32_t* srcContent;
          AndroidBitmapInfo dstInfo; uint32_t* dstContent;
          lockBitmap(pEnv, pSrcImage, &srcInfo, &srcContent);
          lockBitmap(pEnv, pDstImage, &dstInfo, &dstContent);
      
          sp<const Type> srcType = Type::create(rs, Element::RGBA_8888(rs),
                  srcInfo.width, srcInfo.height, 0);
          sp<Allocation> srcAlloc = Allocation::createTyped(rs, srcType,
                  RS_ALLOCATION_MIPMAP_NONE,
                  RS_ALLOCATION_USAGE_SHARED | RS_ALLOCATION_USAGE_SCRIPT,
                  srcContent);
      
          sp<const Type> dstType = Type::create(rs, Element::RGBA_8888(rs),
                  dstInfo.width, dstInfo.height, 0);
          sp<Allocation> dstAlloc = Allocation::createTyped(rs, dstType,
                  RS_ALLOCATION_MIPMAP_NONE,
                  RS_ALLOCATION_USAGE_SHARED | RS_ALLOCATION_USAGE_SCRIPT,
                  dstContent);
      ...
      
  4. 我们还需要一个临时内存区域来存储计算结果。让我们创建一个由内存缓冲区tmpBuffer支持的临时分配:

    ...
        sp<const Type> tmpType = Type::create(rs, Element::RGBA_8888(rs),
                dstInfo.width, dstInfo.height, 0);tmpType->getX();
        uint8_t* tmpBuffer = new uint8_t[tmpType->getX() *
               tmpType->getY() * Element::RGBA_8888(rs)- >getSizeBytes()];
        sp<Allocation> tmpAlloc = Allocation::createTyped(rs, tmpType,
                RS_ALLOCATION_MIPMAP_NONE,
                RS_ALLOCATION_USAGE_SHARED | RS_ALLOCATION_USAGE_SCRIPT,
                tmpBuffer);
    ...
    
  5. 初始化组合滤镜所需的内核和内置函数:

    • 阈值内核

    • 模糊内置函数

    • 一个不需要参数的额外混合内置函数

      ...
          sp<ScriptC_threshold> thresholdKernel = new ScriptC_threshold(rs);
          sp<ScriptIntrinsicBlur> blurIntrinsic =
                  ScriptIntrinsicBlur::create(rs, Element::RGBA_8888(rs));
          blurIntrinsic->setRadius(pRadius);
          sp<ScriptIntrinsicBlend> blendIntrinsic =
                  ScriptIntrinsicBlend::create(rs, Element::RGBA_8888(rs));
          thresholdKernel->set_thresholdValue(pThreshold);
      ...
      
  6. 现在,将多个滤镜组合在一起:

    • 首先,应用阈值滤镜并将结果保存到临时分配中。

    • 其次,在临时分配上应用模糊滤镜,并将结果保存到目标位图分配中。

    • 最后,使用加法操作将源位图和过滤后的位图混合在一起,以创建最终图像。由于每个像素只读取和写入一次(与模糊滤镜相反),因此可以“就地”混合,无需额外的分配。

      ...
          thresholdKernel->forEach_threshold(srcAlloc, tmpAlloc);
          blurIntrinsic->setInput(tmpAlloc);
          blurIntrinsic->forEach(dstAlloc);
          blendIntrinsic->forEachAdd(srcAlloc, dstAlloc);
      ...
      
  7. 最后,保存结果并释放资源。所有在sp<>(即智能指针)模板中的值,如tmpAlloc,都会自动释放:

    ...
        dstAlloc->copy2DRangeTo(0, 0, dstInfo.width, dstInfo.height,
                dstContent);
    
        unlockBitmap(pEnv, pSrcImage);
        unlockBitmap(pEnv, pDstImage);
        delete[] tmpBuffer;
    
    }
    ...
    

刚才发生了什么?

运行项目,调整SeekBar组件,并点击组合按钮。输出ImageView应该显示一个“重制”的图片,其中发光部分被突出显示:

刚才发生了什么?

我们将多个内置函数和内核串联在一起,将组合滤镜应用于图像。这样的链很容易建立;我们基本上需要将一个脚本的输出分配连接到下一个脚本的输入分配。在最后才真正需要将数据复制到输出内存区域。

提示

令人遗憾的是,脚本分组功能在 Android NDK API 上还不可用,只能在 Java 端使用。通过脚本分组功能,可以定义一个完整的脚本“图”,使 RenderScript 能够进一步优化代码。如果你需要这个功能,那么你可以选择等待或者回到 Java。

幸运的是,如果需要,Allocations 可以在多个脚本中重复使用,以避免分配无用的内存。如果脚本允许“就地”修改,甚至可以将同一个 Allocation 用于输入和输出。例如,模糊滤镜就不可以这样,因为它会在读取其他像素进行模糊处理时重写模糊的像素,从而导致奇怪的视觉伪影。

提示

说到复用,在执行之间重复使用 RenderSript 对象(即 RS 上下文对象,Intrinsics,Kernels 等)是一种好的实践。如果你反复执行一个计算,比如处理摄像头拍摄的图像,这一点尤为重要。

内存是 RenderScript 性能的一个重要方面。如果使用不当,它可能会降低效率。在我们的项目中,我们提供了一个指向我们创建的 Allocations 的指针。这意味着我们项目中创建的 Allocations 是用本地内存“支持”的,在我们的例子中,就是位图内容:

...
sp<Allocation> srcAlloc = Allocation::createTyped(rs, srcType,
        RS_ALLOCATION_MIPMAP_NONE,
        RS_ALLOCATION_USAGE_SHARED | RS_ALLOCATION_USAGE_SCRIPT,
        srcContent);
...

然而,也可以在处理前使用copy***From()方法将数据从输入内存区域复制到分配的内存中,这些方法是copy***To()方法的对应方法。这对于 Java 绑定来说特别有用,因为 Java 绑定并不总是允许使用“支持分配”。NDK 绑定更加灵活,大多数时候可以避免输入数据的复制。

RenderScript 提供了其他机制来从脚本中传递数据。第一种是rsSendToClient()rsSendToClientBlocking()方法。它们允许脚本向调用方传递一个“命令”,可选地带有一些数据。后一种方法在性能方面显然有点危险,应尽量避免使用。

数据也可以通过指针进行传递。指针是动态内存,允许 Kernel 和调用者之间进行双向通信。如前所述,它们在生成的类中以bind_前缀的方法反映出来。在编译时,应该在反射层生成适当的获取器和设置器。

但是,NDK RenderScript 框架目前还无法反映在 RenderScript 文件中声明的结构。因此,现在还不能声明指向在脚本文件中定义的struct的指针。不过,使用 Allocations,基本类型的指针是可以工作的。因此,在这个问题上,预计 NDK 端会有一些令人讨厌的限制。

在结束内存这一主题之前,如果你在脚本中需要不止一个输入或输出分配(Allocation),有一个解决方案,即rs_allocation,它通过 getter 和 setter 反映一个分配(Allocation)。你可以有任意多个。然后,你可以通过rsAllocationGetDim*()rsGetElementAt*()rsSetElementAt*()等方法访问尺寸和元素。

例如,threshold()方法可以以下列方式重写:

注意

注意,由于我们没有在参数中传递输入分配(Allocation),因此像往常一样返回一个。

  • for循环不是隐式的,如果传递了参数中的分配(Allocation),它将是隐式的。

  • threshold()函数不能作为内核根。但是,完全可以通过与rs_allocation结合使用输入分配(Allocation)。

#pragma version(1)
#pragma rs java_package_name(com.packtpub.renderscript)

float thresholdValue;
static const float3 BLACK = { 0.0, 0.0, 0.0 };
static const float3 LUMINANCE_VECTOR = { 0.2125, 0.7154, 0.0721 };

rs_allocation input;
rs_allocation output;

void threshold() {
 uint32_t sizeX = rsAllocationGetDimX(input);
 uint32_t sizeY = rsAllocationGetDimY(output);
 for (uint32_t x = 0; x < sizeX; ++x) {
 for (uint32_t y = 0; y < sizeY; ++y) {
 uchar4 rawPixel = rsGetElementAt_uchar4(input, x, y);

            // The algorithm itself remains the same.
            float4 pixel = rsUnpackColor8888(rawPixel);
            float luminance = dot(LUMINANCE_VECTOR, pixel.rgb);
            if (luminance < thresholdValue) {
                pixel.rgb = BLACK;
            }
            rawPixel = rsPackColorTo8888(pixel);

            rsSetElementAt_uchar4(output, rawPixel, x, y);
        }
    }
}

内核将以下列方式被调用。注意,应用效果的方法前缀为invoked_(而不是forEach_)。这是因为threshold()函数不是内核的根函数:

...
thresholdKernel->set_input(srcAlloc);
thresholdKernel->set_output(dstAlloc);
thresholdKernel->invoke_threshold();
dstAlloc->copy2DRangeTo(0, 0, dstInfo.width, dstInfo.height,
        dstContent);
...

有关 RenderScript 语言功能的更多信息,请查看developer.android.com/guide/topics/renderscript/advanced.html

总结

本章介绍了 RenderScript,这是一种用于并行化密集计算任务的高级技术。更具体地说,我们了解了如何使用预定义的 RenderScript 内置 Intrinsics,这些 Intrinsics 目前主要用于图像处理。我们还发现如何使用受 C 语言启发的 RenderScript 自定义语言实现我们自己的内核(Kernel)。最后,我们看到了一个 Intrinsics 和 Kernels 结合的例子,以执行更复杂的计算。

RenderScript 可以从 Java 或原生侧使用。但是,让我们明确一点,除了由内存缓冲区支持的分配(Allocation)(这是一个相当重要的性能特性)之外,RenderScript 仍然更多地通过其 Java API 使用。分组不可用,struct尚未反映,还有一些其他特性仍然存在问题(例如 YUV Intrinsics)。

实际上,RenderScript 旨在为那些没有时间或知识走原生开发路径的开发者提供巨大的计算能力。因此,NDK 目前并没有得到很好的支持。尽管这可能会在未来改变,但你应该准备好至少将部分 RenderScript 代码保留在 Java 端。

第十一章:后记

在整本书中,你已经学习了入门的基本知识,并概览了进一步学习的路径。你现在知道了驾驭这些小而强大的“怪物”的关键元素,并开始充分利用它们的力量。然而,还有很多东西要学,但时间和空间是有限的。无论如何,掌握一项技术的唯一途径就是实践,再实践。希望你能享受这个过程,并为移动挑战做好准备。所以,我现在最好的建议是收集你新鲜的知识和你所有惊人的想法,在脑海中反复打磨它们,并用键盘将它们实现。

我们曾经去过的地方

我们已经具体地了解了如何使用 Eclipse 和 NDK 创建本地项目。我们学习了如何通过 JNI 在 Java 应用程序中嵌入 C/C++库,以及如何在不编写 Java 代码的情况下运行本地代码。

我们使用 OpenGL ES 和 OpenSL ES 测试了 Android NDK 的多媒体能力,这些能力在移动领域正逐渐成为标准(当然,是在忽略 Windows Mobile 之后)。我们甚至与手机的输入外设交互,并通过其传感器感知世界。

此外,Android NDK 不仅与性能有关,还与可移植性有关。因此,我们重用了 STL 框架,它最好的伴侣Boost,并且几乎无缝地移植了第三方库。

最后,我们了解了如何使用 RenderScript 技术优化密集计算任务。

你可以去的地方

C/C++生态系统已经存在了几十年,并且非常丰富。我们移植了一些库,但还有更多库等待被移植。实际上,下面列出许多库,它们无需完全重写代码就可以工作:

  • Bullet (bulletphysics.org/)是一个物理引擎的例子,它可以在几分钟内直接移植。

  • Irrlicht (irrlicht.sourceforge.net/)是可以运行在 Android 上的众多 3D 引擎之一。

  • OpenCV (opencv.org/)是一个计算机视觉和机器学习库,它让你的应用程序能够通过摄像头“看到”并理解外部世界。

  • GLM (glm.g-truc.net/)是一个有用的库,用于 OpenGL ES 2 以完全 C++的方式处理矩阵计算。

  • Intel Threading Building Block 库 (www.threadingbuildingblocks.org/),通常称为 TBB,对于那些需要在本地代码中进行大规模并行化的开发者来说是一个有趣的库。

一些库是专门为移动设备设计的,例如:

  • Unity (unity3d.com/)是一个优秀的编辑器和框架,如果你想要编写移动游戏,绝对应该看看。

  • Unreal Engine (www.unrealengine.com/)是最强大的引擎之一,现在可以免费使用。

  • Cocos2D-X(www.cocos2d-x.org/),这是一个在许多 2D 游戏中广泛使用的高度流行的游戏引擎。

  • Vuforia(www.qualcomm.com/products/vuforia),这是高通公司推出的增强现实 SDK。

对于那些想要深入 Android 内部工作原理的人,我建议你查看一下 Android 平台代码本身,它可以在source.android.com/找到。下载、编译甚至部署都不是一件容易的事,但这确实是深入了解 Android 内部工作原理的唯一途径,有时也是找出那些讨厌的 bug 来源的唯一方式!

寻求帮助的地方:

Android 社区非常活跃,以下是一些寻找有用信息的地方:

这仅仅是一个开始。

创建应用程序只是过程的一部分。发布和销售是另一部分。这当然超出了本书的范围,但处理碎片化和测试与各种目标设备的兼容性确实是一个需要认真对待的难题。

当心!当你开始处理硬件特性(有很多这样的特性)时,问题就会开始出现,正如我们在输入设备中看到的那样。然而,这些问题并不特定于 NDK。如果 Java 应用程序中存在不兼容性,那么原生代码也不会更好。处理各种屏幕尺寸,加载适当大小的资源,以及适应设备功能,这些最终都是你需要处理的事情。然而,这应该是可以管理的。

简而言之,有很多奇妙但也痛苦的惊喜等着被发现。然而,Android 和移动性仍然是一片待开垦的土地,需要被塑造。看看从最早版本到最新版本的 Android 的演变,你就会相信。革命不会每天都发生,所以不要错过它!

祝你好运!

西尔万·拉塔布伊尔

posted @ 2024-05-22 15:09  绝不原创的飞龙  阅读(21)  评论(0编辑  收藏  举报