安卓高性能编程(四)
原文:
zh.annas-archive.org/md5/09787EDC0EF698C9109E8B809C38277C译者:飞龙
第八章:优化电池消耗
电池消耗和使用是开发高性能移动平台应用程序的关键部分。在桌面上,我们不需要特别关心使用的能量量,因为有一个永久的能源连接,但在移动设备上情况不同,我们需要关注这一点。
在移动设备上,电池平均可以使用高达 36 小时,而这个时间随着手机变旧而减少。这是一个特别短的时间,使得我们的设备依赖于接近电源。尽管摩尔定律几乎仍然在实现中,处理能力/单位成本的关系大约每 18 个月翻一番,但电池技术的改进速度每年大约提高 5%。目前有一些关于超级电容器的持续研究,这是近期最有望的希望,但我们正在接近电化学的理论极限。无论如何,电池限制似乎将与我们同在,学习如何处理和操作它们似乎是最明智的做法。
电池耗尽是用户不满的常见原因,通常会导致我们的应用程序在谷歌 Play 商店得到差评。据说“好事写在沙子上,而坏事刻在石头上。”如果你的应用程序持续耗尽设备资源,最终会被卸载,导致不良的在线印象。我们不知道用户是否会通过负责任地使用电池和能源,在沙子上留下好的印象,但我们知道,通过遵循本章关于电池使用的指示,用户会更快乐,你将为更健康的应用程序生态系统做出贡献。
分析
在我们开始寻找问题的解决方案之前,需要进行一步分析。在你的安卓设备上,前往设置,然后点击电池。会出现一个类似下图的界面:
这是一个有用的分析工具,用于确定哪个应用程序正在错误或过度地使用电池。第一部分,电池模式,包含三种不同的电池使用模式:
-
省电模式:此模式理解你的设备没有迫切需要节省电池使用。因此,其使用量不会减少。
-
平衡:默认激活的中间级别。
-
性能:此级别在你的设备上激活一种稀缺模式。电池的使用时间会更短,以牺牲能源性能为代价。
下一个部分,电池使用情况,可以帮助我们确定设备在过去 24 小时的状态。让我们点击它以显示下一个屏幕:
这个屏幕已经包含了一些非常有用的信息。我们可以看到过去 24 小时内电池电量的变化图表以及根据之前性能预测的接下来几小时的情况。更有趣的是图表底部的彩色条:它们以图形方式表示设备在那一刻哪些组件是活跃的:移动网络信号、GPS、Wi-Fi、设备是否唤醒、屏幕是否开启以及设备是否在充电。这对于调试我们没有源代码访问权限的第三方应用程序特别有用,分析它们是否经常启动我们不需要的组件。
上一部分展示了设备上安装的应用程序的全面列表。如果我们点击一个具体的应用程序,将会显示一个带有详细信息的新屏幕:
这个屏幕包含了应用程序的所有详细使用情况,这为我们分析提供了有用的信息。应用程序是否消耗大量数据?它是否让设备长时间保持唤醒状态?执行了多少 CPU 计算?根据这些信息,我们可以确定行动点。
监测电池电量和充电状态
我们的设备执行持续的后台操作,这些操作耗电量大:网络更新、GPS 请求或计算密集型数据操作。根据电池状态,我们可能想在电池快耗尽时避免昂贵的操作。检查电池当前状态始终是一个好的起点。
为了检查电池的当前状态,我们需要捕获由BatteryManager类定期发送的Intent:
IntentFilter ifilter = new IntentFilter(Intent.ACTION_BATTERY_CHANGED);
Intent intentBatteryStatus = context.registerReceiver(null, ifilter);
当获取到这个意图后,我们可以查询设备是否正在充电:
int status = intentBatteryStatus.getIntExtra(BatteryManager.EXTRA_STATUS, -1);
boolean isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING ||
status == BatteryManager.BATTERY_STATUS_FULL;
如果设备正在充电,还可以确定充电是通过 USB 还是通过交流充电器进行的:
int chargePlug = batteryStatus.getIntExtra(BatteryManager.EXTRA_PLUGGED, -1);
boolean isUSBCharging = chargePlug == BatteryManager.BATTERY_PLUGGED_USB;
boolean isACCharging = chargePlug == BatteryManager.BATTERY_PLUGGED_AC;
作为一条经验法则:如果设备正在充电,我们应该最大化所有要执行的操作,因为这不会对用户体验产生重大负面影响。如果设备电池电量低且未在充电,我们应考虑停用耗计算资源昂贵的操作。
如何识别充电状态的变化
我们已经了解了如何分析当前的充电状态,但如何对变化做出反应呢?前面提到的BatteryManager类会在设备连接或断开充电源时进行广播。为了识别它,我们需要在清单文件中注册一个BroadcastReceiver:
<receiver android:name=".PowerConnectionBroadcastReceiver">
<intent-filter>
<action android:name="android.intent.action. ACTION_POWER_CONNECTED"/>
<action android:name="android.intent.action. ACTION_POWER_DISCONNECTED"/>
</intent-filter>
</receiver>
使用我们之前创建的方法,现在可以轻松识别并响应充电状态的任何变化:
public class PowerConnectionReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
int status = intentBatteryStatus.getIntExtra (BatteryManager.EXTRA_STATUS, -1);
boolean isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING ||
status == BatteryManager.BATTERY_STATUS_FULL;
int chargePlug = batteryStatus.getIntExtra (BatteryManager.EXTRA_PLUGGED, -1);
boolean isUSBCharging = chargePlug == BatteryManager.BATTERY_PLUGGED_USB;
boolean isACCharging = chargePlug == BatteryManager.BATTERY_PLUGGED_AC;
}
}
确定并响应电池电量的变化
类似于之前确定充电状态的方法,访问设备在特定时刻的电池电量将有助于确定要在我们的设备上执行的操作。
访问我们之前收集的intentBatteryStatus元素,我们可以用以下几行来查询我们的电池电量:
int level = intentBatteryStatus.getIntExtra(BatteryManager.EXTRA_LEVEL, -1);
int scale = intentBatteryStatus.getIntExtra(BatteryManager.EXTRA_SCALE, -1);
float batteryPercentage = level / (float)scale;
变量batteryPercentage包含设备上剩余的电池百分比,尽可能准确。请注意,实际值可能会有小的偏差。
与之前的情况类似,我们可以通知我们的应用程序当设备电量不足时。为此,我们需要在 Android 清单中注册以下BroadcastReceiver:
<receiver android:name=".BatteryLevelBroadcastReceiver">
<intent-filter>
<action android:name="android.intent.action.ACTION_BATTERY_LOW"/>
<action android:name="android.intent.action.ACTION_BATTERY_OKAY"/>
</intent-filter>
</receiver>
这个BroadcastReceiver将在设备每次进入低电量模式(或因为充电而退出)时触发。
当电池电量危急时,具体要采取的策略由读者决定。通常,本书的作者建议在电池电量危急时关闭非必要操作。
Doze 模式和 App 待机
安卓 6.0 棉花糖(API 版本 23)首次引入了两项强大的功能,以节省我们设备上的电池电量:Doze 模式和 App 待机。前者在设备长时间未使用时减少电池消耗,后者在特定应用长时间未使用时对网络请求做同样处理。
了解 Doze 模式
Doze 模式在 API 级别高于 23 的设备上默认激活。当设备在一段时间内未插电且无活动时,它将进入 Doze 模式。进入 Doze 模式对你的设备有一些重大影响:
-
你的设备将不会有网络操作,除非接收到来自**Google Cloud Messaging(GCM)**的高优先级消息
-
WakeLocks 将被忽略
-
使用
AlarmManager类设置的闹钟计划将被忽略 -
你的应用程序将不会执行 Wi-Fi 扫描
-
不允许运行 Sync 适配器或作业调度程序
阅读完第一点后,你可能会想“那么,如果大家都遵循这种模式,没有什么能阻止我持续使用 GCM 消息,实现一个具有高优先级的应用程序?”坏消息是:谷歌已经考虑到了这一点。Dianne Hackborne 在她的官方 Google Plus 个人资料中已经声明,所有高优先级的消息都是通过谷歌 GCM 服务器发送的,它们可能会受到监控。如果谷歌发现某个特定平台正在滥用系统,可能会停止 GCM 高优先级消息,而无需修改设备上的任何软件。我们的建议是:如果你正在实现一个带有高优先级 GCM 消息的系统,请按照谷歌推荐的方式保持功能;只发送和通知重要和相关信息。
可以为应用程序关闭休眠模式。为此,你需要进入设置菜单,选择电池,然后在屏幕右上角选择电池优化。选择你是否想要优化应用程序:
我们之前提到过,在休眠模式下闹钟不会被触发。为了帮助我们的应用程序适应,Android 6.0 为我们提供了一些额外的功能:setAndAllowWhileIdle()和setExactAndAllowWhileIdle()函数。使用这些方法,我们可以决定特定的闹钟是否也应在休眠模式下触发。然而,我们鼓励你很少使用这些方法,主要用于调试目的。休眠模式试图建立一种低电池消耗的模式,我们应该以此为主要指导原则。请注意,即使使用这种方法,闹钟也不能每 15 分钟触发一次以上。
避免无用的网络请求
在现实世界中,开发者几乎不会检查网络状态。我们执行的许多闹钟、广播和重复性任务都与互联网连接有关。但是如果没有活跃的互联网连接,执行所有这些操作的意义何在?在互联网连接恢复正常工作之前,忽略所有这些操作将更为高效。
使用以下代码段可以轻松确定当前的互联网连接:
ConnectivityManager connectivityManager =
(ConnectivityManager)context.getSystemService (Context.CONNECTIVITY_SERVICE);
NetworkInfo activeNetwork = connectivityManager.getActiveNetworkInfo();
boolean isConnected = activeNetwork != null &&
activeNetwork.isConnectedOrConnecting();
在执行任何请求之前,我们应该使应用程序能够检查互联网连接是否活跃。这不仅是一个有助于降低电池消耗的应用措施,而且也有利于我们应用程序的良好架构和错误处理:阻止执行 HTTP 请求比触发请求后不得不处理因缺乏活跃互联网连接而导致的超时或任何异常要容易得多。在设备上出现这种情况时,任何网络请求应默认被禁用。
另一个有用的技巧是在互联网连接不是使用 Wi-Fi 时避免下载大量数据。以下代码段将让我们知道当前的连接类型:
boolean isWiFi = activeNetwork.getType() == ConnectivityManager.TYPE_WIFI;
我们通常可以假设 Wi-Fi 网络将始终比 3G/4G 连接快。这不是绝对的真理,我们可能会发现相反的情况是真实的。但作为经验法则,这在大多数情况下是有效的。此外,大多数国家的大多数网络运营商都会限制其网络连接每月使用一定量的数据,超出此限制将产生额外费用或降低速度。如果仅在 Wi-Fi 下执行昂贵的网络操作,你通常会处于安全的一方。
此外,可以轻松执行当前 Wi-Fi 速度的检查,以确定速度是否足以下载大量数据:
WifiInfo wifiInfo = wifiManager.getConnectionInfo();
int speedMbps = wifiInfo.getLinkSpeed();
不幸的是,Android 原生没有直接的方法来检查 3G/4G 的速度。从互联网上下载一些数据,然后建立下载所需时间和下载数据量之间的关系,可以给出一个近似值。然而,这将是一种间接的方法,也需要使用一些带宽。
类似于本章前面部分所解释的内容,我们也可以通过注册BroadcastReceiver来通知应用程序设备连接性的突然变化。接收器如下所示:
<receiver android:name=".NetworkChangeReceiver" >
<intent-filter>
<action android:name="android.net.conn. CONNECTIVITY_CHANGE" />
</intent-filter>
</receiver>
我们的定制BroadcastReceiver将按以下方式操作:
public class NetworkChangeReceiver extends BroadcastReceiver {
@Override
public void onReceive(final Context context, final Intent intent) {
final ConnectivityManager connectionManager = (ConnectivityManager) context
.getSystemService(Context.CONNECTIVITY_SERVICE);
final NetworkInfo wifi = connectionManager
.getNetworkInfo(ConnectivityManager.TYPE_WIFI);
final NetworkInfo mobile = connectionManager
.getNetworkInfo(ConnectivityManager.TYPE_MOBILE);
if (wifi.isAvailable() || mobile.isAvailable()) {
//perform operation
}
}
}
按需处理 BroadcastReceivers
使用 BroadcastReceivers 的一个副作用是,每次事件实际发生时,设备都会唤醒。这意味着如果我们从长远考虑,那么少量的能源也是不容忽视的。
我们可以使用一种辅助技术来提高应用程序的效率:根据手机当前状态按需激活或停用 BroadcastReceivers。这意味着:例如,如果互联网连接已丢失,我们可能只需等待互联网连接激活,并忽略其他 BroadcastReceivers,因为它们将不再有用。
下面的代码片段展示了如何以编程方式激活或停用在PackageManager类中定义的组件:
ComponentName myReceiver = new ComponentName(context, Receiver.class);
PackageManager packageManager = getPackageManager();
packageManager.setComponentEnabledSetting(myReceiver,
PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
PackageManager.DONT_KILL_APP)
网络连接
在第二章《*高效调试》中,我们引入了网络工具,这是一个可以用来执行设备网络流量的分析的实用工具。我们解释了如何标记网络连接,以确保可以轻松进行数据分析。
如何执行网络工具中数据的解释没有一个单一答案,因为这种解释取决于应用程序根据其功能和目的可能具有的不同要求。但是,如果执行得当,以下几条黄金规则通常会对我们的应用程序产生价值:
-
预取数据:我们倾向于按需预取信息,这可能是更简单的解决方案。但从长远来看,预取信息可能是有益的。进行网络分析,如果你发现可以在之前的情况中获取数据,而这对于应用程序有益(例如,在 Wi-Fi 连接时或应用程序空闲时下载一些用户相关信息),那么不妨尝试一下。这对用户体验也有影响,因为信息将更快加载,而不会影响体验。
-
减少连接数:通常,相比于进行多次连接以下载小块数据,执行单次连接以下载大量数据更为优化。每个建立的连接都会产生额外的流量费用,并且在连接池中处理不同连接可能会使你的应用程序复杂性呈指数级增长。这并不是每次都能执行的操作,特别是如果你无法访问你的应用程序所使用的网络服务时。但如果你有机会,值得一试,并在修改前后进行网络测试。
-
批量处理和计划:如前所述,单独处理请求会更快耗尽你的电池。相反,尽可能使用最少的连接,你可以利用 Android 提供的批量处理/计划 API 之一。这些 API 会创建一个包含你所有可用请求的计划,并一次性执行,从而节省宝贵的时间和能源。
注意
正式来说,有三个可用的批量处理和计划 API:GCM 网络管理器、作业调度器和同步适配器。它们各有几项要求,且每个的实现都比较复杂。然而,谷歌和本书的作者建议使用前两个而不是同步适配器。同步适配器自 Android 2.0 起可用,其实现属于不同的时代;而且,它的实现也较为复杂。
-
使用 GCM:这是一个众所周知的真理,但并不经常发生:应为你的应用程序使用如 GCM 这样的推送系统,而不是轮询系统。从服务器拉取数据是完美的电池杀手,对你的应用程序没有任何好处。实现推送解决方案的复杂性将立即得到回报,远胜于拉取数据。
-
使用缓存机制:Android 中有多项机制和库可以缓存 HTTP 请求。Spice 提供了一个优秀且全面的库,本书的作者可以明确推荐它。然而,每年都有新的库和方法兴起和淘汰。关注最新的缓存信息机制,并且尽可能地应用它们。
-
压缩信息:在发送前可以压缩信息,这样可以节省大量带宽和能源。从 Android Gingerbread 版本开始,
HttpUrlConnection对象会自动为通过HttpUrlConnection对象发送的 JSON 添加压缩。请记住,在客户端压缩信息,发送到服务器后再解压处理,通常比不压缩直接发送信息更为高效。
总结
电池性能是一个令人兴奋的领域,它可以为我们的应用程序提供许多改进。这个领域被广泛忽视,即使是经验最丰富的开发者也常常忽视它,没有加以重视。本书的作者强烈鼓励任何开发者尽可能采取本书中描述的行动,并持续检查应用程序在性能和用户体验方面的改进。我们不能经常和强烈地强调:这样做是值得的。
谷歌承诺将全力以赴提供更好的电池和能源体验,并为开发者提供扩展的 API。如果未来的 Android 版本开始提供新技术以增加电池寿命和改善能源消耗,这并不会令人惊讶。我们建议读者关注未来 Android 版本的发展(在撰写本文时,即 2016 年第一季度,Android N 还没有固定的发布日期)。
阅读本章节后,读者应该能够清楚地了解 Android 开发中主要的电池和能源漏洞。如果这里提供的任何建议被应用,我们建议随着时间的推移跟踪改进的发展情况。这最终可以用来作为向其他开发者解释为什么这些措施重要的有力论据。
第九章:Android 中的本地编码
本地开发工具包(从现在开始,NDK)是谷歌提供的一套工具,允许开发者使用本地代码语言(通常是 C 和 C++)在应用程序上。这可以让我们使用更优化的语言执行计算密集型任务,或者访问第三方库以更好地执行某些任务(例如,我们可以使用 OpenCV 来访问和操作图像,而不是本地效率不高的 Java API)。
注意
NDK 可能是一个强大的工具,但我们建议读者评估它是否会为你的项目带来好处。在许多情况下,并不需要 NDK,开发者不应仅仅因为自己更熟悉就选择这个工具集。此外,使用 NDK 无疑会增加我们项目在结构和需要处理的文件方面的复杂性。
在 Android 中使用 NDK 确实能带来好处,但也必须考虑一些陷阱:
-
代码复杂性增加。除了我们的 Java(或 Kotlin,或选择的任何语言)框架外,现在我们还需要调试另一种语言。
-
使用 NDK 时不再有自动垃圾收集器。执行所有内存管理的责任现在完全依赖于本地代码。
-
如果我们开发的 Java 代码需要移植到其他平台,使用 NDK 将更加困难。正在使用的一个解决方案是将文件编译到所有可能的操作系统,然后在编译时选择它们。可以想象,这大大增加了我们代码的复杂性。
入门——在系统中设置 NDK
从 1.3 RC1 版本开始,Android Studio 支持本地开发工具包(NDK)。尽管仍然有限制,但它仍然可用,并将为大多数用户提供足够的功能和稳定性以继续使用。
要设置 NDK,我们首先需要将其下载到我们的系统中。在撰写本书时,最新版本的 NDK 可以从developer.android.com/ndk/downloads/index.html下载。如果潜在读者在这个位置找不到 NDK,我们鼓励他们通过 Google 搜索其最新版本的位置。
下载完 NDK 后,解压 ZIP 文件并将其移动到你选择的位置。该文件夹将包含类似于以下内容的东西:
这里的每个包都包含一些不同的数据文件:
-
build文件夹包含使用 NDK 工具集实际构建所需的所有工具和包。 -
ndk-build是我们将调用来使用 NDK 的脚本。 -
platforms包含我们将用于每个不同版本的 Android SDK 的必要工具。 -
python-packages包含 Python 脚本中的源代码。 -
sources文件夹包含源文件。 -
在
toolchains中,我们将找到构建现有程序所需的工具链。关于这方面的更多信息将在本章后面介绍。
通常建议将 NDK 文件夹的位置添加到 PATH 环境变量中,以便稍后可以轻松访问。根据操作系统,这可以轻松完成。
在 Mac 上,在控制台中输入 sudo nano /etc/paths。你会看到类似于下面截图所示的内容:
你需要在这个屏幕上添加 NDK 下载位置。添加后,关闭控制台并重新打开。如果你输入 echo $PATH,除了之前存在的行内容外,你添加的行内容也会被显示。
在 Windows 中,你需要通过控制面板或系统设置来添加它。此外,也可以直接从控制台通过输入 set PATH=%PATH%;C:\new\folder 来添加。
为了使用 NDK,我们还需要标准的 Android SDK。如果读者已经阅读到这一章,我们假设这一点已经就绪,并且 Android SDK 已经成功安装。
JNI
JNI 代表 Java Native Interface。JNI 允许用其他语言编写的库和软件访问在 Java Virtual Machine (JVM) 中运行的 Java 代码。这不是与 Android 相关的内容,而是在 Java 世界中已经存在并使用过的编程框架。
JNI 需要将文件声明为 C 或 C++——它甚至可以连接到 Objective-C 文件。下面是 C 语言的一个示例:
jstring
Java_com_my_package_HelloJni_stringFromJNI( JNIEnv* env,
jobject thiz )
{
return (*env)->NewStringUTF(env, "Hello World");
}
观察文件,我们可以看到在返回类型 jstring(相当于字符串)之后,有一个以单词 Java 开头的结构,包括包名、类名和方法名。JNIEnv 对象始终作为参数传递,以及 jobject ——这是使框架与 Java 接口的必要条件。用 C 编写的函数只返回一个字符串。这对于存储我们希望从潜在破解者眼中隐藏的令牌或密钥将非常有用。
初始概念
在我们开始创建第一个本地应用程序之前,我们希望向读者介绍一些初始概念,以便更容易理解:
-
ndk-build:这个文件是负责调用 NDK 构建的 shell 脚本。自动地,这个脚本检查系统和应用程序是否正确,生成将被使用的二进制文件,并将它们复制到我们的项目结构中。作为一个 shell 脚本,它可以带有一些额外的参数:
-
clean:这个参数会让脚本清除之前生成的所有二进制文件 -
–B:使用–B选项,我们强制系统进行重新构建 -
V=1:这会释放构建并显示构建命令 -
NDK_DEBUG=X:如果我们使用1,构建将是可调试的;如果我们使用0,我们将强制进行发布构建 -
NDK_LOG=X:使用1,NDK 将记录构建过程中产生的所有消息。
请记住,所有参数都可以部分组合(例如,如果您想强制重建并显示所有构建命令,可以使用
B V=1)。当我们自动化构建以从 CI 服务器完成时,这种脚本非常有用,因为我们不再需要手动指定任何构建类型。 -
-
应用程序二进制接口(ABI):ABI 定义指定了代码如何与系统交互。当编译生成的文件时,您会看到针对每种架构都创建了不同的文件。每个文件都是根据这些定义之一创建的。
创建我们的第一个 HelloWorld-JNI
让我们使用 Android Studio 创建一个最小配置的项目。为此,导航到Project | New | New Project。创建可用的最简约配置——通常只是一个项目;一开始不要添加Activity。这会添加很多我们此刻不需要的样板代码。创建项目后,通过在源文件夹上右键点击,选择New | Java Class来添加一个新的Activity。将类命名为MainActivity:
当文件创建完成后,为Activity添加以下基础代码:
public class MainActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
}
}
并记得将其添加到AndroidManifest.xml以及您的默认活动中:
<activity
android:name="com.hellojni.MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent. category.LAUNCHER" />
</intent-filter>
</activity>
下一步是创建 JNI 文件。这包括两个主要文件。在应用程序的根目录下创建一个名为jni的文件夹。我们将添加以下文件:
注意
活动名称与本地方法名称相匹配非常重要。相反的情况可能导致在使用 NDK 时出现问题。
-
HelloWorld-jni.c:jstring Java_com_my_package_HelloJni_stringFromJNI( JNIEnv* env, jobject thiz ) { return (*env)->NewStringUTF(env, "Hello World"); } -
Android.mk:LOCAL_PATH := $(call my-dir) include $(CLEAR_VARS) LOCAL_MODULE := HelloWorld-jni LOCAL_SRC_FILES := HelloWorld-jni.c include $(BUILD_SHARED_LIBRARY)Android.mk文件是什么?这个文件告诉 Android 我们的资源的定位和命名。在这里,我们指定了将要使用的模块和文件,以及它们的存放位置。在使用 NDK 的所有项目中都必须有这个文件才能正常工作。 -
Application.mk:APP_ABI := all此文件指定了我们正在构建的目标架构。在这个例子中,我们为所有架构构建,但我们也可以决定只针对特定的架构(armeabi, armeabi-v7a, mips, x86 等)构建。我们最终还可以添加我们正在使用的 API 级别:
APP_PLATFORM := android-9
正如预期的读者可能已经开始猜测的那样,我们的目的是读取由 C 文件提供的信息,并通过使用 NDK 和 JNI 将其渲染到屏幕上。完成所有设置后,让我们在MainActivity类中进行一些更改。
首先,让我们添加以下几行:
static {
System.loadLibrary("HelloWorld-jni");
}
这将静态加载我们在loadLibrary()函数中指定的库,必须与Android.mk文件中提供的完全一致。
现在我们需要创建在我们的.c文件中定义的本地方法。这需要在Activity中声明一个公共方法:
public native String stringFromJNI();
作为最后一步,为了显示使用 JNI 读取的值,我们将创建一个简单的TextView并在我们的应用程序中填充它。这个TextView字段将使用stringFromJNI()函数读取值并将其显示出来:
TextView textView = new TextView(this);
textView.setText( stringFromJNI() );
setContentView(textView);
完成所有这些步骤后,进入项目的根目录并输入ndk-build。你应该得到类似于以下的输出:
Compile thumb : hello-jni <= hello-jni.c
SharedLibrary : libhello-jni.so
Install : libhello-jni.so => libs/armeabi-v7a/libhello-jni.so
Compile thumb : hello-jni <= hello-jni.c
SharedLibrary : libhello-jni.so
Install : libhello-jni.so => libs/armeabi/libhello-jni.so
Compile x86 : hello-jni <= hello-jni.c
SharedLibrary : libhello-jni.so
Install : libhello-jni.so => libs/x86/libhello-jni.so
Compile mips : hello-jni <= hello-jni.c
SharedLibrary : libhello-jni.so
Install : libhello-jni.so => libs/mips/libhello-jni.so
注意
使用 NDK 时有一个常见问题,就是类似Android NDK: Your APP_BUILD_SCRIPT points to an unknown file: /route/to/Android.mk的消息。通过将你的项目所在路径导出到环境变量NDK_PROJECT_PATH中,可以轻松解决这个问题:
export NDK_PROJECT_PATH=~/Location/HelloJNI/
如果你需要以编程方式完成这个操作,请记住这一点。
还需要执行最后一步:当ndk-build完成后,在根目录下会创建一个名为libs的文件夹。你需要手动将这个文件夹的内容移动到应用模块中的新目录src/main/jniLibs。你也可以通过在 Gradle 文件中使用一些脚本轻松完成这一操作:
如果你按照本章的步骤正确操作,并且编译了应用程序,你应该能够显示一个类似于以下的屏幕:
恭喜你!你已经使用 JNI 和 NDK 创建了你的第一个应用程序。
使用 Android NDK 创建本地活动
在下一节中,我们将学习如何完全使用本地 C 代码来完成一个应用程序,无需任何 Java 代码。请注意,这样做更多的是为了学习目的,因为完全使用本地应用程序开发的实际案例并不多。然而,这将作为一个不同层次之间以及与 Android 操作系统交互的好例子。
由于我们不使用 Java 代码,我们需要在AndroidManifest.xml文件中指定我们的项目将不包含 Java 代码。这是通过使用以下几行来完成的:
<application android:label="@string/app_name"
android:hasCode="false">
从 API 级别 9 开始,仅使用本地代码的应用程序首次得到支持。在撰写这本书的时候,这应该不是问题,因为低于 API 级别 9 的版本占总量的 0.1%以下。然而,由于 NDK 的性质,你可能只会将其用于遗留或旧设备:
<uses-sdk android:minSdkVersion="9" />
最后,我们需要在AndroidManifest.xml文件中包含一个名为android.app.lib_name的元数据值。这个值需要与你包含在Android.mk文件中的LOCAL MODULE值相等:
<meta-data android:name="android.app.lib_name"
android:value="native-activity-example" />
Android.mk文件看起来可能像这样:
LOCAL_PATH := $(call my-dir)
include $(CLEAR_VARS)
LOCAL_MODULE := native-activity
LOCAL_SRC_FILES := main.c
LOCAL_LDLIBS := -llog -landroid -lEGL -lGLESv1_CM
LOCAL_STATIC_LIBRARIES := android_native_app_glue
include $(BUILD_SHARED_LIBRARY)
$(call import-module,android/native_app_glue)
与我们之前版本中使用的文件相比,这个文件中的Android.mk已经扩展了。请注意以下字段:
-
LOCAL_LDLIBS:这是当前 NDK 应用程序中要使用的附加链接器标志列表。 -
LOCAL_STATIC_LIBRARIES:这是需要调用的本地静态库列表。在这种情况下,我们将调用android_native_app_glue。每次尝试创建原生活动以管理其生命周期和其他属性时,都需要这个特殊的库。
在这个例子中,我们将使用的 .c 文件比之前使用的要复杂一些。首先,需要向应用程序添加一些额外的包含指令:
#include <jni.h>
#include <errno.h>
#include <EGL/egl.h>
#include <GLES/gl.h>
#include <android/sensor.h>
#include <android/log.h>
#include <android_native_app_glue.h>
#define LOGI(...) ((void)__android_log_print(ANDROID_LOG_INFO, "native-activity", __VA_ARGS__))
#define LOGW(...) ((void)__android_log_print(ANDROID_LOG_WARN, "native-activity", __VA_ARGS__))
有一个主函数,作为进入原生应用程序的入口点。这个函数默认接收一个 android_app 类型的对象,它反映了应用程序在给定时刻的状态。基于这个状态,应用程序如下处理:
void android_main(struct android_app* state) {
struct engine engine;
app_dummy();
memset(&engine, 0, sizeof(engine));
state->userData = &engine;
state->onAppCmd = engine_handle_cmd;
state->onInputEvent = engine_handle_input;
engine.app = state;
engine.sensorManager = ASensorManager_getInstance();
engine.accelerometerSensor = ASensorManager_getDefaultSensor(engine.sensorManager,
ASENSOR_TYPE_ACCELEROMETER);
engine.sensorEventQueue = ASensorManager_createEventQueue(engine.sensorManager,
state->looper, LOOPER_ID_USER, NULL, NULL);
if (state->savedState != NULL) {
engine.state = *(struct saved_state*)state->savedState;
}
应用程序还提供了一个主循环。它将检查当前和之前的状态以及传感器的输出,并在屏幕上绘制:
while (1) {
int ident;
int events;
struct android_poll_source* source;
while ((ident=ALooper_pollAll(engine.animating ? 0 : -1, NULL, &events,
(void**)&source)) >= 0) {
if (source != NULL) {
source->process(state, source);
}
if (ident == LOOPER_ID_USER) {
if (engine.accelerometerSensor != NULL) {
ASensorEvent event;
while (ASensorEventQueue_getEvents (engine.sensorEventQueue,
&event, 1) > 0) {
LOGI("accelerometer: x=%f y=%f z=%f",
event.acceleration.x, event.acceleration.y,
event.acceleration.z);
}
}
}
if (state->destroyRequested != 0) {
engine_term_display(&engine);
return;
}
}
if (engine.animating) {
engine.state.angle += .01f;
if (engine.state.angle > 1) {
engine.state.angle = 0;
}
engine_draw_frame(&engine);
}
}
}
如果你编译,你将在屏幕上绘制一个纯粹的原生活动。
调试 NDK
使用 NDK 开发的源代码的调试并不像使用标准的 Android Java DK 开发的代码那样直接,但这个平台上有可用的工具。从 1.3 版本开始,Android Studio 提供了一些内置工具来调试带有 JNI 的应用程序。
为了准备一个要被调试的应用程序,我们需要修改我们的 build.gradle 脚本。以我们之前编写的 HelloWorldJNI 为例。打开 app 模块的 build.gradle 文件,并添加以下几行:
buildTypes {
release {
minifyEnabled false
{…}
ndk {
debuggable = true
}
}
debug {
debuggable = true
jniDebuggable = true
}
}
需要创建一个新的调试配置。为了实现它,请导航到 编辑配置,并在下拉菜单中选择 新建 Android 原生:
当在 Android 原生设置中发布配置时,Android Studio 会自动识别应用程序为原生(或混合)应用程序,并自动启动原生调试器。要查看这一点,请转到你用来在屏幕上绘制内容的 C 文件,并在该函数中设置一个断点:
这将在内容即将被绘制时停止应用程序。现在通过点击 调试 图标,,而不是启动图标来执行应用程序。现在与普通应用程序的执行相比,会有一些不同之处。首先,你会看到环境正在尝试连接原生调试器,而不是标准的调试器:
当应用程序最终启动后,执行将在断点处停止,调试部分将出现一个新的屏幕:
新的调试屏幕非常有趣。在这里,我们可以访问所有正在声明或实例化的本地变量(例如,我们在函数中使用的JNIEnv变量包含了很多关于我们的环境和可用的调试部分的信息)。
Android.mk
我们已经了解了Android.mk文件提供的一些基本可能性。实际上,这个文件类似于 GNU makefile:它向构建系统描述了源文件和共享库。
在Android.mk文件中,我们可以将所有资源分组到模块中。模块可以是静态库、独立可执行文件或共享库。这个概念与 Android Studio 中的模块相似,读者现在应该已经熟悉了。相同的源代码可以用于不同的模块。
我们在前一个脚本中看到了以下这行内容:
include $(CLEAR_VARS)
这个值由构建系统自动提供。它指向一个负责清理许多本地变量的内部 makefile。
我们稍后需要添加这些模块:
LOCAL_MODULE := example-module
为了使文件正常工作,模块需要具有唯一的名称,并且不能有特殊字符或空格。
注意
当编译时,NDK 会自动为你的模块添加前缀lib,并添加后缀.so。在所提供的示例中,生成的文件将是libexample-module.so。但是,如果你在Android.mk文件中添加了前缀lib,那么在生成.so文件时将不会添加此前缀。
指定要在模块中包含的文件始终使用以下这行:
LOCAL_SRC_FILES := example.c
如果需要在同一模块中包含不同的文件,你应该使用空格分隔它们,如下所示:
LOCAL_SRC_FILES := example.c anotherexample.c
NDK 中的更多变量
NDK 定义了一些可以在Android.mk文件中自动使用的变量。
TARGET_PLATFORM
这个变量定义了构建系统要使用的目标平台:
TARGET_PLATFORM := android-21
目标始终以android-xx的格式使用。NDK 并不支持所有的平台类型。最好检查 NDK 网站以了解哪些平台是受支持的。在撰写本书时(2016 年第一季度),以下是受支持的平台列表:
| 支持的 NDK API 级别 | 相当于的 Android 版本 |
|---|---|
| 3 | 1.5 |
| 4 | 1.6 |
| 5 | 2.0 |
| 8 | 2.2 |
| 9 | 2.3 至 3.0.x |
| 12 | 3.1.x |
| 13 | 3.2 |
| 14 | 4.0 至 4.0.2 |
| 15 | 4.0.3 至 4.0.4 |
| 16 | 4.1 和 4.1.1 |
| 17 | 4.2 和 4.2.2 |
| 18 | 4.3 |
| 19 | 4.4 |
| 21 | 4.4W 和 5.0 |
TARGET_ARCH
这个变量指定了用于构建 NDK 的架构。它可能包含如x86或arm等值。此变量的值取自APP_ABI文件,该文件在Android.mk文件中指定。在撰写本书时,以下是支持的架构及其名称列表:
| 架构 | 要使用的名称 |
|---|---|
| ARMv5TE | armeabi |
| ARMv7 | armeabi-v7a |
| ARMv8 AArch64 | arm64-v8a |
| i686 | x86 |
| x86-64 | x86_64 |
| mips32 (r1) | mips |
| mips64 (r6) | mips64 |
| All of them | 所有 |
TARGET_ABI
当我们想要同时指定 Android API 级别和 ABI 时,这个变量会非常有用。我们可以轻松地这样做,例如:
TARGET_ABI := android-21-x86
NDK 宏
宏是包含特定功能的小型函数。其中一些默认由 NDK 定义。要调用它们,你必须使用以下语法:
$(call <function-name>)
以下是 NDK 中指定的几个默认宏:
-
my-dir:这个宏返回Android.mk文件的当前路径。当你最初想在脚本中设置LOCAL_PATH时,它非常有用:LOCAL_PATH := $(call my-dir) all-subdir-makefiles当执行此宏时,它会以列表形式返回找到的所有
Android.mkmakefile,这些文件位于my-dir返回的文件夹中。使用此命令,我们可以提供更好的子层次结构行和包结构的更好组织。
-
parent-makefile:这返回父 makefile 可以找到的路径。提示
grand-parent-makefile命令也存在,它返回,顾名思义,是祖父路径。 -
this-makefile:这个宏返回当前 makefile 的路径。
Application.mk
Application.mk文件也是我们示例项目中存在的文件。它描述了应用程序所需的本地模块,通常位于yourProject/jni文件夹下。与Android.mk文件一样,这里我们可以包含一些变量,这将增加此文件的功能性:
-
APP_OPTIM:这是一个非常有用的变量,可以用来决定在构建应用程序模块时的优化级别。它可以被定义为release或debug。基本上,当模块在
release模式下编译时,它们非常高效,提供的调试信息很少。另一方面,debug模式包含了一堆有用的调试信息,但不适合分发。默认模式是release。在发布模式下进行的某些优化包括变量的命名。它们可以被重命名和缩短(你可以想到在应用 ProGuard 时也会进行相同的优化),但显然,在应用程序运行时,将无法对它们进行调试。此外,还有一些代码重排和重组织会使代码更高效,但在调试应用程序时会提供错误的信息。
提示
如果你在
AndroidManifest.xml中包含了android:debuggable标签,这个变量的默认值将被设置为debug而不是release。你需要重写这个值以改变其默认设置。 -
APP_CFLAGS:C/C++编译器在编译应用程序时可以使用特殊值,以改变程序或指定应用程序中需要考虑的特定值。这可以在 NDK 中使用此变量处理。例如,请看以下行:APP_CFLAGS := -mcpu=cortex-a9这将在模块编译时添加
mcpu标志,值为cortex-a9。 -
APP_CPPFLAGS:这个值仅针对 C++文件指定。前一个值APP_CFLAGS适用于两种语言。 -
APP_LDFLAGS: 这个变量包含一组链接器标志,每次执行时都会传递给链接器。这显然只有在每次执行链接器时才有意义,因此它只会影响共享库。 -
APP_BUILD_SCRIPT:我们已经看到,默认情况下,使用的构建脚本是在/jni文件夹中的Android.mk文件。通过定义这个变量来指向正确的构建脚本的位置,可以更改此设置。这始终被视为相对于绝对 NDK 路径的相对位置。 -
APP_PLATFORM: 通过这个变量,我们可以指定要使用的 Android 版本,格式为android-n(类似于之前为Android.mk文件介绍过的表格)。 -
APP_ABI:在这个变量中,我们指定应用程序构建的 ABI。默认情况下,NDK 将构建我们的应用程序针对armeabi。但这可以更改为以下表格中的另一个值:指令集 值 基于 ARMv7 的设备 APP_ABI := armeabi-v7aARMv8 64 位架构 APP_ABI := armeabi-v7aIntel-32 APP_ABI := x86Intel64 APP_ABI := x86_64MIPS32 APP_ABI := mipsMIPS64 APP_ABI := mips64所有支持的集合 APP_ABI := all注意
包括所有不同架构的值仅在 NDK 版本 7 及以后支持。
在需要时,这也可能结合使用。例如,以下命令将结合不同的指令集:
APP_ABI := mips x86
包含现有库
NDK 被广泛使用的主要原因之一是为了包含其他已经存在的库,这些库在 C/C++中提供一组功能。最明显的例子可能是 OpenCV,它最初是用 C/C++编写的。用 Java 重写它不仅会花费时间,而且效率不会像它的本地对应物那样高。
或者,你可能想要创建自己的库并将其分发给第三方开发者。甚至可能创建一个预构建的库版本,可以直接包含在我们的项目中,这样我们就可以加快构建时间,而不是每次构建都编译库。
为了实现这一点,我们必须遵循一系列步骤。首先,每个正在使用的预构建库必须被声明为一个单独的独立模块。这就是我们如何实现它的方法。
模块必须有一个名称。它不一定要与预构建库相同,但需要包含一个名称:
-
转到
Android.mk文件,将LOCAL_SRC_FILES设置为指向你将要交付的库的路径。 -
确保预构建库的版本适合你将要使用的 ABI。
-
如果你使用的是
.so文件,你将需要包含PREBUILT_SHARED_LIBRARY。如果你使用的是.a文件,你将需要包含PREBUILT_STATIC_LIBRARY。为了把所有内容整合在一起,让我们看看这个文件的样子:
LOCAL_PATH := $(call my-dir) include $(CLEAR_VARS) LOCAL_MODULE := mylibrary-prebuilt LOCAL_SRC_FILES := libmylibrary.so include $(PREBUILT_STATIC_LIBRARY)
就这样。这个过程相当简单,从现在起你可以将你的应用程序作为库传递。
你可能想知道这个库一旦被导出,如何从另一个项目中引用。这个过程也相当简单:只需要将其指定为LOCAL_STATIC_LIBRARIES或LOCAL_SHARED_LIBRARIES的值。例如,假设我们想在另一个项目中包含libmylibrary.so。我们需要使用以下Android.mk文件:
include $(CLEAR_VARS)
LOCAL_MODULE := library-user
LOCAL_SRC_FILES := library-user.c
LOCAL_SHARED_LIBRARIES := mylibrary-prebuilt
include $(BUILD_SHARED_LIBRARY)
导出头文件
在处理第三方本地库时,通常能够访问头文件。例如,在使用我们共享库的文件中,我们会发现需要访问我们头文件的包含指令:
#include <file.h>
在这种情况下,我们需要向所有模块提供头文件。实现这一点的最简单方法可能是在Android.mk文件中使用 exports。看看下面的代码示例,取自一个需要一些头文件的Android.mk文件。只要前一行中的file.h文件位于include文件夹内,模块就能正常工作:
include $(CLEAR_VARS)
LOCAL_MODULE := library-user
LOCAL_SRC_FILES := library-user.c
LOCAL_EXPORT_C_INCLUDES := $(LOCAL_PATH)/include
include $(PREBUILT_SHARED_LIBRARY)
总结
阅读本章节后,读者将能够使用 NDK 本地或在 Android 应用程序中以混合方式构建应用程序。此外,我们建议读者查看一些其他框架,特别是 OpenCV。学习 OpenCV 本身可以成为一本书的主题。然而,如果读者正在处理繁重的图像处理,他/她会发现这个框架非常有用。
使用 NDK 时的一个关键点是确定复杂性与性能之间的正确权衡。使用 NDK 解决复杂的计算问题可能很有诱惑力,而且当涉及到图像处理、OpenGL、计算机图形学或动画时,应该明确作出决定。实际上,已经证明 NDK 学习者往往过度使用它,并将其包含在大多数单一任务中。从效率的角度来看,这似乎是一个很好的想法,但软件工程更多的是关于处理不断增长的复杂性。如果软件不断增长而没有任何控制,未来将出现可扩展性和软件效率的问题。
请记住,不是每个人都熟悉 NDK,因此你也在迫使开发者学习相对复杂的技术来解决平凡的问题。在这种情况下,获取 NDK 所需的知识和权衡的唯一方式是经验,因为每个案例都是独一无二的,只能从先前的错误和失败中学习。因此,我们鼓励你尝试一下——我们相信你会满意的。
第十章:性能提示
本章是关于技术、提示和技巧,涉及前几章未涵盖的主题。
因此,我们希望在这里定义图像处理的最佳实践:图像在商店中的许多应用程序中被广泛使用。为此,我们希望了解如何在 Android 应用程序中管理图像,以提高整体性能。对于这个主题,需要来自之前各章的概念。
除了位图管理,我们还将探讨广泛使用但性能不佳的序列化格式(如 XML 和 JSON)的替代方案,以找到加快客户端/服务器通信并限制编码/解码时间和资源消耗的更好方法。
最后,本章的最后一部分将讨论在构建过程之前改进应用程序的一些措施。这些措施包括减少资源以及如何清理 APK,以便拥有一个较小的 APK 文件通过商店分发,以满足商店限制和用户的期望。
位图
我们应用程序面临的最大挑战之一是以高效的方式处理图像,因为有很多不同的视角会影响最终的应用程序。这是一个涵盖几乎所有前述章节内容的特殊主题:
-
为了正确显示位图,它们需要处于一个布局中。因此,我们在第二章,高效调试中讨论的内容在这里尤为重要。
-
不当的位图处理可能导致内存问题,由于泄漏或是因为位图被错误地作为变量使用,而不是在需要时读取。因此,记住第四章,内存中的关键概念,在保存和读取大图像时可能会有所帮助。
-
我们经常会尝试在主线程上处理来自图像的大量数据;我们将使用在第五章,多线程中讨论的主题,来了解如何高效处理位图,同时不影响用户体验。
-
大多数情况下,图像来自远程资源。我们将讨论如何从服务器检索图像,以及如何为将来重用缓存它们,以限制网络请求并节省电量,这在第六章,网络通信中有所探讨。
位图在许多应用程序中被处理。我们将更详细地讨论这个问题的各个方面,尝试通过使用前述章节引入的最佳实践来定义如何处理它们。
加载
无论屏幕分辨率如何,或者图像是否隐藏或不可见,显示的图像总是被整体读取;其在内存中的权重是最大的。正如我们接下来将看到的,图像的每一个像素默认占用 32 位内存。因此,将图像的分辨率乘以 32,我们可以得到图像在内存中使用的位数。这种做法的主要问题当然是由于应用程序可用内存饱和而导致的高概率出现OutOfMemoryException异常。
通常,我们直接使用图像,而不考虑可能出现的性能问题。然而,例如,如果我们在一个384x216像素的占位符中显示1920x1080像素的图像,我们实际上向内存中添加了 8.2 MB,而实际上只需要 332 KB。查看图 1以了解未缩放图像与所需图像的开销比较:
图 1:未缩放的图像在较小占位符中的开销示例
如果我们处理的是列表、图库或其他一次显示更多图像的小部件,情况会更糟。此外,Android 在屏幕分辨率和内存可用性方面存在高度碎片化。因此,无法回避这个问题:读取图像时需要预先缩放位图。那么,我们如何有效地预先缩放它们呢?让我们在以下段落中找到答案。
Bitmap 类并不那么有用;Bitmap.createScaledBitmap()方法需要一个Bitmap对象作为输入来进行缩放。因此,它迫使我们无论如何都要在创建新的小图像之前读取整个图像,这显然会导致为整个源图像分配不必要的过多内存。然而,有一种方法可以在读取图像时减少对图像内存的负担。这就是BitmapFactory API 的目标。一旦我们知道适合我们图像缩放的适当分辨率,我们可以使用BitmapFactory.Options类来设置正确的参数,从而从内存的角度有效地缩放图像。让我们看看我们可以使用哪些参数来达到正确的结果。BitmapFactory类提供了根据不同来源加载图像的不同方法:
-
decodeByteArray() -
decodeFile() -
decodeFileDescriptor() -
decodeResource() -
decodeStream()
它们每一个都有相应的方法重载,除了需要的基本参数外,还接受一个BitmapFactory.Options对象。这样,我们就可以在读取图像时使用这个类来定义我们的缩放策略。如果我们处理的是非常大的图像,我们可以使用特殊的 API 来解码图像的小部分:这就是BitmapRegionDecoder。BitmapRegionDecoder.decodeRegion()方法接受一个Rect和一个BitmapFactory.Options对象作为参数,以解码在BitmapRegionDecoder.newInstance()方法中传递的图像的Rect区域。
首先,我们需要知道图像的分辨率。为了找出,我们希望在不读取整个源位图的情况下获取图像尺寸。这会导致不必要的内存分配增加。API 提供了一种通过设置BitmapFactory.Options对象的一个特定属性BitmapFactory.Options.inJustDecodeBounds来获取源图像尺寸的方法。BitmapFactory.Options.inJustDecodeBounds属性用于定义解码方法是否应返回Bitmap对象。因此,我们可以将其设置为true以在读取图像分辨率时禁用位图处理,然后再将其设置为false以启用完全读取图像并获得所需的图像。这样可以确保不会无谓地分配位图内存。
当我们知道我们想要的图像分辨率时,我们需要在处理之前将新设置应用到选项中。为此,我们需要使用BitmapFactory.Options.inSampleSize。这是一个整数,指定将图像的每个维度分别除以多少以到达请求的大小。它也被强制为 2 的幂。因此,如果我们设置不同的值,它将在处理步骤之前缩小到最接近的 2 的幂。然后,如果我们设置BitmapFactory.Options.inSampleSize为4,最终的宽度和高度将是原始尺寸的 1/4。因此,生成的图像将由源位图的 1/16 的像素组成。
让我们看一下以下代码片段,了解如何应用这些有用的属性:
public Bitmap scale(){
//Options creation
BitmapFactory.Options bmpFactoryOptions = new BitmapFactory.Options();
//Reading source resolution
bmpFactoryOptions.inJustDecodeBounds = true;
BitmapFactory.decodeFile(url, bmpFactoryOptions);
int heightRatio = (int) Math.ceil(bmpFactoryOptions.outHeight / (float) desiredHeight);
int widthRatio = (int) Math.ceil(bmpFactoryOptions.outWidth / (float) desiredWidth);
//Setting properties to obtain the desired result
if (heightRatio > 1 || widthRatio > 1) {
if (heightRatio > widthRatio) {
bmpFactoryOptions.inSampleSize = heightRatio;
} else {
bmpFactoryOptions.inSampleSize = widthRatio;
}
}
//Restoring the Options
bmpFactoryOptions.inJustDecodeBounds = false;
//Loading Bitmap
return BitmapFactory.decodeFile(url, bmpFactoryOptions);
}
为什么采样属性要如此严格地限制为 2 的幂?因为这样,处理后的图像将由源图像中的四像素取一像素组成。此外,这个过程非常快。优点是计算速度快,而缺点是我们不能精确地将图像缩放到期望的大小。
我们还可以使用其他属性以不同的方法来缩放图像。除了BitmapFactory.Options.inJustDecodeBounds属性之外,我们还可以使用以下属性:
-
inScaled:这使基于此列表中的其他值启用密度检查来缩放图像。 -
inDensity:这是位图的密度。如果它与下面的inTargetSize不同,那么图像将被处理以缩放并达到inTargetDensity。 -
inTargetDensity:这是如果与inDensity属性不同,所需结果的图像密度。
缩放比例将使用公式scale = inTargetDensity / inDensity来计算。
然后,我们可以使用图像实际和期望尺寸(以像素为单位)之间的比例来计算缩放值。因此,前面的代码片段变成了以下内容:
public Bitmap scale(){
//Options creation
BitmapFactory.Options bmpFactoryOptions = new BitmapFactory.Options();
//Reading source resolution
bmpFactoryOptions.inJustDecodeBounds = true;
BitmapFactory.decodeFile(url, bmpFactoryOptions);
//Setting properties to obtain the desired result
bmpFactoryOptions.inScaled = true;
bmpFactoryOptions.inDensity = desiredWidth;
bmpFactoryOptions.inTargetDensity = bmpFactoryOptions.outWidth;
//Restoring the Options
bmpFactoryOptions.inJustDecodeBounds = false;
//Loading Bitmap
return BitmapFactory.decodeFile(url, bmpFactoryOptions);
}
这使用了一种不同的计算方法来在特定尺寸下缩放图像。精度在速度方面是有代价的。因此,这个解决方案用前一个方案的速度与创建所需分辨率的图像的精度进行了交换。因此,正如谷歌建议的那样,通过结合两种前方案,可以得到最佳结果。第一步是确定最精确的 2 的幂作为BitmapFactory.Options.inSampleSize以加快粗略缩放(如果需要)。然后,将图像从这个中间图像转换为精确的所需缩放图像。如果我们的源图像是1920x1080像素,而我们需要最终图像是320x180像素,那么将会有一个例如480x270像素的中间图像,如图图 2所示:
图 2:缩放步骤
刚才讨论的内容可以通过之前引入的所有属性来实现,如下面的代码示例所示:
public Bitmap scale(){
//Options creation
BitmapFactory.Options bmpFactoryOption = new BitmapFactory.Options();
//Reading source resolution
bmpFactoryOption.inJustDecodeBounds = true;
BitmapFactory.decodeFile(url, bmpFactoryOption);
int heightRatio = (int) Math.ceil(bmpFactoryOption.outHeight / (float) desiredHeight);
int widthRatio = (int) Math.ceil(bmpFactoryOption.outWidth / (float) desiredWidth);
//Setting properties to obtain the desired result
if (heightRatio > 1 || widthRatio > 1) {
if (heightRatio > widthRatio) {
bmpFactoryOption.inSampleSize = heightRatio;
} else {
bmpFactoryOption.inSampleSize = widthRatio;
}
}
bmpFactoryOption.inScaled = true;
bmpFactoryOption.inDensity = desiredWidth;
bmpFactoryOption.inTargetDensity = desiredWidth * bmpFactoryOption.inSampleSize;
//Restoring the Options
bmpFactoryOption.inJustDecodeBounds = false;
//Loading Bitmap
return BitmapFactory.decodeFile(url, bmpFactoryOption);
}
这个解决方案结合了第一个方案的速度和第二个方案的精度。
处理
前一节描述的操作从时间角度来看是不可预测的,但它们肯定会影响 CPU。不管图像大小如何,或者操作是否快速,所有这些操作都必须在工作线程中执行,正如我们在第五章《多线程》中讨论的那样,以避免阻塞用户界面并降低因响应性不足而感知的应用程序性能。
使用缩放的主要操作是为ImageView设置位图以创建布局。因此,我们需要一个带有视图引用的AsyncTask子类。我们在第四章《内存》中讨论了这种对象组合,我们发现这会导致活动泄露。因此,记得使用WeakReference来持有ImageView,以便在Activity被销毁时进行回收。然后,不要忘记验证ImageView是否仍然在WeakReference中被引用,否则会发生NullPoionterException。
这样的AsyncTask子类可以像下面代码片段中的代码一样:
public class BitmapTask extends AsyncTask<String, Void, Bitmap> {
private WeakReference<ImageView> imageView;
private int desiredWidth;
private int desiredHeight;
public BitmapTask(ImageView imageView, int desiredWidth, int desiredHeight) {
this.imageView = new WeakReference<>(imageView);
this.desiredHeight = desiredHeight;
this.desiredWidth = desiredWidth;
}
@Override
protected Bitmap doInBackground(String... params) {
return new BitmapScaler().scaleUsingCombinedTechniques(params[0], desiredWidth, desiredHeight);
}
@Override
protected void onPostExecute(Bitmap bitmap) {
super.onPostExecute(bitmap);
if (imageView != null && imageView.get() != null && bitmap != null)
imageView.get().setImageBitmap(bitmap);
}
}
缓存
让我们讨论一下这些位图存储在哪里以及如何本地处理它们。大多数时候,位图存储在远程资源中,这迫使我们创建相应的代码来下载它们,然后才能在屏幕上显示。然而,我们不想在屏幕上每次需要显示它们时都重新下载。因此,我们需要一种简单且快速的方式来存储图像,并在请求时使它们可用。
然而,我们必须小心,确保在某些时候删除图像。否则,设备的内部存储将被占满,因为应用程序中的图像可能是不受限制的。因此,我们需要一个有限的空间来存储图像。这个空间被称为缓存。
接下来问题是:哪种算法是删除图像的正确选择?Android 使用的主体算法是 LRU。它使用一个对象栈来确定哪些对象具有更高优先级,将它们放在顶部,低优先级的放在底部。然后,当一个对象被使用时,它会被移到顶部以获得更高的优先级,其他所有对象则向下移动。在这种情况下,优先级是单个对象的请求次数;栈将是一个从最常用到最不常用的对象排名,如图图 3所示,位置 3 的图像再次被使用,它移动到了栈的顶部:
图 3:LRU 栈的示例
通过这种推理,当一个新对象需要被添加到一个已满的栈时,选择很简单:它将取代最少使用的对象,因为它再次被请求的可能性最小。
所有这些逻辑都由 Android 在LRUCache对象中实现并提供。这个实现是在内存中工作,而不是在磁盘上,以提供更快、更可靠的缓存,随时可供查询。这意味着栈底部的任何对象,在因新添加而逐出时,都有可能被垃圾收集。此外,这个类允许定义要使用的键和值类型,因为它使用了泛型。因此,它不仅可以用于位图,还可以用于我们需要的各种对象。LRUCache对象甚至是线程安全的。
在选择了键和值类型之后,需要做的是定义缓存的大小。这一步没有固定的规则,但需要记住,缓存太小会导致栈内变化过多,使得使用缓存变得没有意义;而缓存太大则可能导致在使用应用程序时出现OutOfMemoryErrors。在这种情况下,正确的做法是为缓存分配应用程序可用内存的一部分。在以下代码中,LRUCache对象是使用字符串作为键创建的,并且将可用内存除以 8:
public class BitmapCache {
private LruCache<String, Bitmap> lruCache;
public BitmapCache() {
final int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
final int cacheSize = maxMemory / 8;
lruCache = new LruCache<String, Bitmap>(cacheSize);
}
public void add(String key, Bitmap bitmap) {
if (get(key) == null) {
lruCache.put(key, bitmap);
}
}
public Bitmap get(String key) {
return lruCache.get(key);
}
}
接下来,我们需要定义缓存中单个条目的大小。这可以通过重写LRUCache.sizeOf()方法,在实例化时返回位图正确的字节数量来实现:
lruCache = new LruCache<String, Bitmap>(cacheSize){
@Override
protected int sizeOf(String key, Bitmap value) {
return value.getByteCount();
}
};
最后,当需要显示ImageView中的图像时,我们可以使用这个缓存对象,如下面的代码所示:
public void loadBitmap(int resId, final ImageView imageView, String url) {
String imageKey = String.valueOf(resId);
Bitmap bitmap = bitmapCache.get(imageKey);
if (bitmap != null) {
imageView.setImageBitmap(bitmap);
} else {
imageView.setImageResource(R.drawable.placeholder);
BitmapDownloaderTask task = new BitmapDownloaderTask(bitmapCache, new BitmapDownloaderTask.OnImageReady() {
@Override
public void onImageReady(Bitmap bitmap) {
imageView.setImageBitmap(bitmap);
}
});
task.execute(url);
}
}
如前所述,这种类型的缓存位于堆内存中;当用户更改活动然后返回时,必须重新下载、缩放并将每个项目添加到缓存中。然后,我们想要一种可以在多次访问尝试和重启之间持久化的缓存类型。为此,官方存储库中有一个来自官方 Android 示例的有用类,名为DiskLRUCache。这不是线程安全的,因此我们在访问它时需要加锁。此外,它的初始化可能需要较长时间,我们必须在工作者线程中执行它,以避免阻塞主线程。下面我们使用AsyncTask类来完成这个任务:
class InitDiskCacheTask extends AsyncTask<File, Void, Void> {
@Override
protected Void doInBackground(File... params) {
synchronized (mDiskCacheLock) {
File cacheDir = params[0];
mDiskLruCache = DiskLruCache.open(cacheDir, DISK_CACHE_SIZE);
mDiskCacheStarting = false;
mDiskCacheLock.notifyAll();
}
return null;
}
}
通过添加这个类,我们可以使用两个级别的缓存:
-
堆级缓存:如前所述,速度快但不持久的缓存。当需要图像时,其目标是首先被检查。
-
磁盘级缓存:速度较慢但持久化的缓存,如果另一个缓存不包含请求的图像,则第二个检查它。
因此,图像请求背后的逻辑应该类似于图 4所示:
图 4:使用两级缓存的图像请求流程图
当我们想要将图像放入缓存时,我们需要将它添加到两者中,如下面的代码段所示:
public void addBitmapToCache(String key, Bitmap bitmap) throws IOException {
if (bitmapCache.get(key) == null) {
bitmapCache.add(key, bitmap);
}
synchronized (mDiskCacheLock) {
if (mDiskLruCache != null && mDiskLruCache.get(key) == null) {
mDiskLruCache.put(key, bitmap);
}
}
}
显示
如前所述,当图像在屏幕上显示时,它由 32 位像素描述,每个颜色 8 位,如图图 5所示:
图 5:位图像素压缩
不幸的是,没有办法在不忽略透明度部分的情况下使用 24 位;相反,当图像不包含像素的 alpha 字节时,Android 无论如何都会添加它,将 24 位图像转换为 32 位图像。显然,这在应用程序的日常使用中会有很多副作用。
首先,堆内存中存储位图所需的内存量更大,导致垃圾回收事件更多,因为分配较大的连续内存块比分配较小的内存块更困难。此外,分配和收集这些较大的内存块需要更长的时间。而且,分配的内存没有压缩。解码和显示它们的时间会更长,影响 CPU 和 GPU。这个问题的解决方案是什么?
Android 提供了四种不同的像素格式,用于处理图像时使用。这意味着图像的每一个像素可以用较少的位描述,因此在内存、垃圾回收、CPU 和 GPU 方面可以更轻便。这需要付出代价:质量将不再相同。因此,这种使用应该是根据设计来决定的,因为它并不适用于我们应用程序中的每一张图像。然而,我们可以考虑一种更智能的方法,例如,我们可以根据设备的性能选择不同的像素格式。
提示
如果你在处理处理图像的应用程序,非常重要的一点是要检查,根据需求,是否可以使用不同的像素格式来减少大内存块(即位图)的影响,并从不同的角度提高性能:内存、速度和电池充电持续时间。
安卓平台目前为Bitmap对象处理的像素格式如下:
-
ARGB_8888:这是默认讨论的值,它使用 32 位来表示像素,因为所有通道都使用 8 位。 -
ARGB_4444:这保留了四个通道,与前面的格式一样,但每个通道只使用 4 位,对于一个 16 位的像素。尽管它节省了一半的图像内存,但其屏幕显示的较差质量导致谷歌不推荐这个值,转而推荐默认值,尽管它在内存管理上有优势。 -
RGB_565:这个特定的值只保留颜色通道,移除了 alpha 通道。红色和蓝色通道使用 5 位描述,绿色通道使用 6 位描述。每个像素使用 16 位,与之前的格式一样,但忽略 alpha 透明度,提高颜色质量。因此,在处理没有透明度的图像时使用这个格式是很好的选择。 -
ALPHA_8:这仅用于存储 alpha 透明度信息,没有颜色通道。
然而,我们如何使用它们呢?这也是一个解码选项。BitmapFactory.Options.inPreferredConfig用于定义在图像即将被解码时要使用的像素格式。那么,让我们检查以下代码段:
public Bitmap decode(String url) {
//Options creation
BitmapFactory.Options bmpFactoryOptions = new BitmapFactory.Options();
bmpFactoryOptions.inPreferredConfig = Bitmap.Config.RGB_565;
//Loading Bitmap
return BitmapFactory.decodeFile(url, bmpFactoryOptions);
}
这显然是昂贵的,因为它导致了更多的计算时间和 CPU 使用。然而,其成本小于内存中的整个位图,如果我们意识到重用图像,不仅可以节省时间,还能节约关键的系统资源。那么,让我们看看如何重用图像以进一步改善我们应用程序的内存使用,如下面几页所述。
管理内存
到现在为止我们所讨论的内容与从堆和磁盘角度进行内存管理有关。然而,在处理位图时,我们可以使用更高级别的抽象来改进堆内存管理。在第四章《内存》中,我们介绍了一个特别的设计模式,以避免我们所说的内存翻滚;这就是对象池模式。使用这种模式,可以在对象不再被引用时重用内存分配,以避免垃圾回收。
当需要处理大量位图对象时,如在列表或网格中,会有许多新的实例化和删除操作,伴随着多次垃圾回收事件的发生。这会降低应用程序的整体内存性能,因为众所周知,回收事件会阻塞其他任何线程,而且这些对象的内存占用也很大。因此,如果我们能为位图使用对象池模式,就可以限制垃圾收集器的操作,而不会影响我们之前讨论的缓存技术,实际上还能加快其速度。
实际上,我们希望重用已分配的内存来处理要显示的新图像。如图 6所示,如果用户滚动后屏幕上显示四个图像,内存分配应该保持不变。
图 6:使用对象池的堆内存管理
为了实现这样一个有用的机制,我们需要引入一个特定的BitmapFactory.Options属性,名为BitmapFactory.Options.inBitmap。如果我们使用这个属性,就必须提供一个现有的Bitmap对象,让解码器重用其内存分配。这样,原来的对象不会被销毁,新对象也不会被创建,也就无需进行垃圾回收。
然而,这个有用的属性也有其局限性,正如官方文档所述:
-
在 Android Jelly Bean(API 级别 18)之前,提供的对象和新的对象必须有完全相同的尺寸。从 Android KitKat(API 级别 19)开始,提供的位图可以大于或等于新的位图,但不能小于。
-
第一点意味着具有不同像素格式的图像不适用于此类操作。
记住这一点,让我们快速了解一下创建此类逻辑的代码。首先,让我们创建满足这些要求的控件:
private boolean canBitmapBeReused(
Bitmap bitmap, BitmapFactory.Options options) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
int width = options.outWidth / options.inSampleSize;
int height = options.outHeight / options.inSampleSize;
int byteCount = width * height * getBytesPerPixel(bitmap.getConfig());
return byteCount <= bitmap.getAllocationByteCount();
}
return bitmap.getWidth() == options.outWidth
&& bitmap.getHeight() == options.outHeight
&& options.inSampleSize == 1;
}
private int getBytesPerPixel(Bitmap.Config config) {
switch (config) {
case ARGB_8888:
return 4;
case RGB_565:
case ARGB_4444:
return 2;
default:
case ALPHA_8:
return 1;
}
}
接下来,让我们编写代码来从池中获取(如果有的话)可重用的Bitmap对象:
private Bitmap getBitmapFromPool(BitmapFactory.Options options, Set<SoftReference<Bitmap>> bitmapsPool) {
Bitmap bitmap = null;
if (bitmapsPool != null && !bitmapsPool.isEmpty()) {
synchronized (bitmapsPool) {
final Iterator<SoftReference<Bitmap>> iterator
= bitmapsPool.iterator();
Bitmap item;
while (iterator.hasNext()) {
item = iterator.next().get();
if (null != item && item.isMutable()) {
if (canBitmapBeReused(item, options)) {
bitmap = item;
iterator.remove();
break;
}
} else {
iterator.remove();
}
}
}
}
return bitmap;
}
最后,让我们创建一个方法,在解码过程之前添加这些BitmapFactory.Options,以使用可重用对象而不是创建新对象:
public Bitmap decodeBitmap(String filename, int reqWidth, int reqHeight) {
BitmapFactory.Options options = new BitmapFactory.Options();
addOptions(options);
return BitmapFactory.decodeFile(filename, options);
}
private void addOptions(BitmapFactory.Options options) {
options.inMutable = true;
Bitmap inBitmap = getBitmapFromPool(options);
if (inBitmap != null) {
options.inBitmap = inBitmap;
}
}
当你需要的时候,别忘了创建一组可重用的位图来搜索。因此,让我们定义一个位图池,作为一组SoftReference对象来存储我们的图像。我们的BitmapCache类应该如下所示:
public class BitmapCache {
private Set<SoftReference<Bitmap>> bitmapsPool;
private LruCache<String, Bitmap> lruCache;
public BitmapCache() {
final int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
final int cacheSize = maxMemory / 8;
lruCache = new LruCache<String, Bitmap>(cacheSize) {
@Override
protected int sizeOf(String key, Bitmap value) {
return value.getByteCount();
}
@Override
protected void entryRemoved(boolean evicted, String key,
Bitmap oldValue, Bitmap newValue) {
bitmapsPool.add(new SoftReference<>(oldValue));
}
};
bitmapsPool = Collections.synchronizedSet(new HashSet<SoftReference<Bitmap>>());
}
public void add(String key, Bitmap bitmap) {
if (get(key) == null) {
lruCache.put(key, bitmap);
}
}
public Bitmap get(String key) {
return lruCache.get(key);
}
}
图像优化
在本章的前几页中,我们讨论了当图片准备好加载和显示时如何处理它们。现在我们想要探讨的是图片如何传入设备以及如何改进这一过程。现在很清楚,图片是占用内存的大块头,如果我们不妥善处理它们,它们可能会破坏我们应用的用户体验,而不是提升它。因此,我们可以设计出最佳的框架从远程服务器下载图片,但如果它们体积过大,或者压缩程度不够高,我们的应用仍然会被认为运行缓慢且耗资源。图片需要时间和带宽来下载。因此,我们的目标是尽可能减少它们的体积,同时不损害其质量。
提示
显示图片的应用程序总是需要一个良好的设计,以确保下载过程快速。为此,图片在字节使用上必须尽可能小,以便更容易将它们从远程服务器传输到可能使用较差连接的设备上。
正如在第六章网络通信中所分析的,设备访问服务器的条件有很多种,且这是不可预测的。然而,不管用户的设备使用哪种连接,我们都希望提供尽可能最佳的用户体验。那么,我们能做什么来减小图片的大小呢?对此有两个主要方面需要考虑:分辨率和压缩。让我们更详细地讨论它们。
分辨率
在开发显示图片的应用程序时,分辨率方面往往被低估。然而,让我们思考一下:如果我们确定图片最多以 480x270 像素显示,为什么我们要下载更大的图片呢?此外,考虑到 Android 平台所遭受的屏幕分辨率和密度的巨大碎片化,为什么我们要在 480x800 像素的设备和 1920x1080 像素的设备上下载相同分辨率的图片呢?
最佳的做法是提供与特定设备上占位符相同分辨率的图片。然后,如果占位符是 480x270 像素,我们最多应该下载 270 像素或 480 像素的图片,或者与占位符相同的分辨率;无论如何,额外的开销都会被浪费。不幸的是,只有在我们可以访问服务器实现的情况下,这种方法才能付诸实践。
如果我们无法更改服务器设置,有许多实时图像处理服务可以完成这项工作。我们可以决定在特定条件或连接下使用它们,或者仅用于特定类型的图片或应用的部分区域。无论如何,这样做都是有利的。
当需要在应用程序的多个部分显示内容相同的图像,可能使用不同的分辨率时,诀窍是下载最高分辨率的图像,然后使用前面讨论的技术将其缩小,以在不同的占位符中使用。这样我们就能节省时间、电池电量以及带宽。这并不是每次都需要遵循的规则;你应该根据应用程序的需求,设计最佳的方法来减少要传输到设备的图像大小。
压缩
当谈到压缩时,事情变得有趣:最常使用的图像格式是 PNG。它是一种无损压缩类型,能保证图像的完整质量。不幸的是,其压缩能力可能导致更大的文件,从而造成前面讨论过的传输效果差和其他副作用。
JPEG 格式是一种更轻的格式;它使用有损压缩来减小图像大小,同时用户几乎无法感知到差异。这对于来自远程资源的图像来说是一个更好的格式选择。不幸的是,它不支持透明度。此外,还有一种由谷歌提出的甚至更轻的格式,称为WebP;它可以使用有损或无损压缩,并且可以选择是否包含透明度和动画。这种格式分析像素并预测邻近像素,从而减少图像所需的数据量(以比特为单位)。从 Android Jelly Bean(API 级别 17)开始,这种格式得到了完全支持。
无论如何,如果我们需要使用 PNG 文件,有许多工具可以应用有损图像压缩,大幅减小文件大小。这些工具允许我们更改颜色配置文件,应用滤镜以及其他有用的操作来减少图像大小。我们需要找到适合我们图像的正确损失程度。由图形编辑程序刚刚导出的图像通常比实际需要的大;我们应该始终清理图像,查找其中未使用的数据,然后应用所需的任何压缩改进,以减少图像传输中的开销。
序列化
我们同样可以将降低图像大小以加快传输速度的考虑应用于文本文件。那么,让我们快速了解一下在客户端/服务器架构中传输数据的典型格式。在几年前,XML 格式是最常使用的。后来开发者将其改为 JSON 格式。这两种格式都是可读的,但由于 JSON 的语法更简单,不需要标签和属性,因此它更轻便,也更受欢迎和使用,胜过 XML。
JSON 改进
谷歌提供了一个易于使用的库来处理 JSON 序列化和反序列化,称为 GSON。原则上,它使用反射来查找 Java bean 的 getter 和 setter;然后,如果 bean 内部的一切都在正确的位置,只需提供所需的类,就可以反序列化,创建一个填充了 JSON 文件内所有数据的新对象。
为了提高序列化/反序列化性能和传输时间,我们需要改进 JSON 文件设计;我们的目标是减少 JSON 文件的大小。这里的主要且明显的提示是避免在 JSON 结构中包含不必要的数据。因此,不要序列化客户端不使用的数据。
使用 JSON 进行数据序列化的典型方法是创建一个要传输的对象数组。然而,JSON 格式需要为每个属性指定一个名称,以便在反序列化过程中正确识别。这种方式增加了许多重复字符串的字符,导致文件大小产生额外开销。以下 JSON 文件示例显示了一组带有相关重复键字符的对象列表:
[
{
"level": 23,
"name": "Marshmallow",
"version": "6.0"
}, {
"level": 22,
"name": "Lollipop",
"version": "5.1"
}, {
"level": 21,
"name": "Lollipop",
"version": "5.0"
}, {
"level": 19,
"name": "KitKat",
"version": "4.4"
}
]
这个文件的内容可以通过定义属性数组而非对象数组来序列化到一个更小的文件中。《图 7》展示了这里要应用的结构变更概念:
图 7:从对象数组到属性数组的结构变更,应用于 JSON 文件中
应用这种重塑方式,以下文件将是新的格式,包含第一个 JSON 文件中的相同内容:
{
"level": [23, 22, 21, 19],
"name": ["Marshmallow", "Lollipop", "Lollipop", "Kitkat"],
"version": ["6.0", "5.1", "5.0", "4.4"]
}
第一个文件的实际大小约为 250 字节,而第二个文件为 140 字节。但是,单个文件中的对象越多,整个 JSON 文件将应用越多的节省。
JSON 的替代品
然而,XML 和 JSON 格式都过于冗余;它们在可读性方面显得累赘,服务器编码较慢,而且一旦客户端接收它们,解码速度也会比其他轻量级格式慢。通常,出于调试目的,开发者更偏好可读性更强而非性能的格式。
实际上,还有其他格式可以让客户端和服务器以更快的速度进行通信。这些都是谷歌推出的;让我们简要了解一下这些。
协议缓冲区
第一个开发的序列化方法被称为协议缓冲区。与 XML 类似,它提供了一种定义数据结构的方法,但它更快且更小。它使用.proto扩展名的文件来设置后来创建和传输的不可读二进制文件的语法。它类似于以下内容:
message Person {
required string name = 1;
required int32 id = 2;
optional string email = 3;
enum PhoneType {
MOBILE = 0;
HOME = 1;
WORK = 2;
}
message PhoneNumber {
required string number = 1;
optional PhoneType type = 2 [default = HOME];
}
repeated PhoneNumber phone = 4;
}
每个消息都是一系列键/值对。定义之后,我们待传输的数据看起来就像一个二进制流。这是这种方法的主要优势:它比含有相同数据的 XML 文件小 10 倍,快 100 倍。
这个方法是平台无关的,可以在多个环境中使用。然而,并非每种开发语言都受到支持;目前发布的版本包括 Java、C++和 Python 编译器。
不幸的是,协议缓冲区实现需要大量的内存和代码才能使用。这对于移动设备来说并不合适,因为正如我们所知,需要尽可能节省内存以达到性能目标。因此,创建了一个特殊的协议缓冲区版本,以最小化代码和内存使用。
平铺缓冲区
平铺缓冲区是谷歌创建的一种高级序列化方法。平铺缓冲区是由无需解析的平铺二进制缓冲区构成的。这里的内存分配极低,同时在定义字段时提供高度灵活性。代码开销最小。此外,解析 JSON 文本的速度比其他解析器更快、更高效。
这个方法是开源的,并且每种支持的语言都有不同的实现和不同的功能,因为它们依赖于社区贡献。
平铺缓冲区不需要解析中间表示数据;因此,它们在提供数据方面比协议缓冲区要快。让我们快速了解一下它们在 Android 应用程序中的集成,以了解它们的优势以及集成时间是否值得。
首先要做的就是定义一个架构文件,用来界定数据结构,或者如果我们是从那种序列化方法迁移过来的,可以转换原始的 JSON。那么,让我们看一下以下要转换的 JSON 文件:
{
"user": {
"username": "username",
"name": "Name",
"height": 185,
"enabled": true,
"purchases": [
{
"id": "purchaseId1",
"name": "purchaseName1",
"quantity": 2,
"price": 120
}, {
"id": "purchaseId2",
"name": "purchaseName2",
"quantity": 1,
"price": 10
}
]
}
}
架构声明文件应该包含文件中每个对象的表,指定每个属性的类型。以下是相应的架构文件内容:
namespace com.flatbuffer.example;
table User {
username: string;
name: string;
height: int;
enabled: bool;
purchases: [Purchase];
}
table Purchase {
id: string;
name: string;
quantity: int;
price: int;
}
root_type User;
完成后,我们需要创建 Java 模型以及要在我们的应用程序中使用的类。为此,提供了平铺编译器,我们可以使用它来生成所有 Java 类文件,通过调用以下命令行:
flatc --java
有关正确使用提供资源的更多信息,请参考官方文档。为上一个示例的模型创建的User类的最终文件如下:
public final class User extends Table {
public static User getRootAsUser(ByteBuffer _bb) {
return getRootAsUser(_bb, new User());
}
public static User getRootAsUser(ByteBuffer _bb, User obj) {
_bb.order(ByteOrder.LITTLE_ENDIAN);
return (obj.__init(_bb.getInt(_bb.position()) + _bb.position(), _bb));
}
public User __init(int _i, ByteBuffer _bb) {
bb_pos = _i;
bb = _bb;
return this;
}
public String username() {
int o = __offset(4);
return o != 0 ? __string(o + bb_pos) : null;
}
public ByteBuffer usernameAsByteBuffer() {
return __vector_as_bytebuffer(4, 1);
}
public String name() {
int o = __offset(6);
return o != 0 ? __string(o + bb_pos) : null;
}
public ByteBuffer nameAsByteBuffer() {
return __vector_as_bytebuffer(6, 1);
}
public int height() {
int o = __offset(8);
return o != 0 ? bb.getInt(o + bb_pos) : 0;
}
public boolean enabled() {
int o = __offset(10);
return o != 0 ? 0 != bb.get(o + bb_pos) : false;
}
public Purchase purchases(int j) {
return purchases(new Purchase(), j);
}
public Purchase purchases(Purchase obj, int j) {
int o = __offset(12);
return o != 0 ? obj.__init(__indirect (__vector(o) + j * 4), bb) : null;
}
public int purchasesLength() {
int o = __offset(12);
return o != 0 ? __vector_len(o) : 0;
}
public static int createUser(FlatBufferBuilder builder,
int usernameOffset,
int nameOffset,
int height,
boolean enabled,
int purchasesOffset) {
builder.startObject(5);
User.addPurchases(builder, purchasesOffset);
User.addHeight(builder, height);
User.addName(builder, nameOffset);
User.addUsername(builder, usernameOffset);
User.addEnabled(builder, enabled);
return User.endUser(builder);
}
public static void startUser(FlatBufferBuilder builder) {
builder.startObject(5);
}
public static void addUsername(FlatBufferBuilder builder, int usernameOffset) {
builder.addOffset(0, usernameOffset, 0);
}
public static void addName(FlatBufferBuilder builder, int nameOffset) {
builder.addOffset(1, nameOffset, 0);
}
public static void addHeight(FlatBufferBuilder builder, int height) {
builder.addInt(2, height, 0);
}
public static void addEnabled(FlatBufferBuilder builder, boolean enabled) {
builder.addBoolean(3, enabled, false);
}
public static void addPurchases(FlatBufferBuilder builder, int purchasesOffset) {
builder.addOffset(4, purchasesOffset, 0);
}
public static int createPurchasesVector(FlatBufferBuilder builder, int[] data) {
builder.startVector(4, data.length, 4);
for (int i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]);
return builder.endVector();
}
public static void startPurchasesVector(FlatBufferBuilder builder, int numElems) {
builder.startVector(4, numElems, 4);
}
public static int endUser(FlatBufferBuilder builder) {
int o = builder.endObject();
return o;
}
public static void finishUserBuffer(FlatBufferBuilder builder, int offset) {
builder.finish(offset);
}
}
只需调用User.getRootAsUser()方法就可以使用这个类;在源代码之后,它被转换成字节数组,然后是ByteBuffer对象,如下面的代码段所示:
private User loadFlatBuffer(byte[] bytes) {
ByteBuffer bb = ByteBuffer.wrap(bytes);
return User.getRootAsUser(bb);
}
对于 Android 实现,这个解决方案显著减少了传输大小,并且序列化和反序列化时间比 JSON 情况要低得多。这意味着平铺缓冲区效率更高,我们应该考虑用基于平铺缓冲区的策略来替换我们的 JSON 策略。
本地序列化
序列化在通信方面是值得的,因为其主要目的是提供一种在不同环境中传输结构化对象的轻量级方法。然而,序列化和反序列化过程需要执行的时间开销。因此,尽管它适合网络传输,但不应该在客户端本地使用,以节省序列化和反序列化操作所需的时间,例如存储数据。
一个典型的例子是将 JSON 文件存储在缓存内存中。每次访问其数据前都必须进行反序列化。此外,如果你需要更改文件内的内容,必须在将新内容序列化后,再保存到缓存内存中。这比使用带有结构化数据的本地数据库成本要高得多,即使这是在 Android 应用程序内部开发此类数据管理系统最快的方法。
提示
当你需要保存数据时,处理本地数据时避免序列化。选择 SQLite 数据库来保存数据,而不是序列化方法,因为数据库访问比序列化和反序列化操作要快得多。
代码改进
在接下来的几页中,我们想讨论一些与特定编码情况和常见模式相关的优化。这些技巧是实际日常开发工作中常见习惯可能导致性能故障的例子。
访问器与修改器(Getters and setters)
面向对象编程中使用的一个核心概念是封装;正如你所知,这意味着其他对象不应直接访问对象的字段。因此,你可以在 Java 中使用 private 修饰符来封装对象的字段,并创建访问器和修改器方法,让其他对象可以访问它们。这保证了类本身对其字段拥有完全控制权,其他人无法使用。然后,你可以自由地创建只读或只写字段,只需定义相关方法,避免定义另一个。
封装的好处是毋庸置疑的,但它们是有代价的。如果不存在 JIT,直接访问字段比使用访问器快三倍,如果存在 JIT,则快七倍。这意味着我们应该继续封装我们的字段,但在没有必要的情况下应避免调用访问器和修改器。例如,在类内部不要调用访问器和修改器,因为这更耗时,而且你不需要这样做,因为类可以直接访问自己的字段。举个例子;以下代码在实例化期间调用了一个内部方法:
public class ExampleObject {
private int id;
public ExampleObject(int id) {
this.setId(id);
}
public void setId(int id) {
this.id = id;
}
public int getId() {
return id;
}
}
尽管这样做没有错,但通过移除内部对修改器的调用,可以在执行期间提高代码速度:
public class ExampleObject {
private int id;
public ExampleObject(int id) {
this.id = id;
}
public void setId(int id) {
this.id = id;
}
public int getId() {
return id;
}
}
这只是一个例子,但这里的主要建议是,在任何情况下都应避免在内部调用访问器和修改器。
内部类
我们在第四章中讨论内存泄漏问题时已经谈过内部类,Memory。在 Android 中嵌套类是一种非常常见的做法,因为很多时候我们需要在内部类中持有对包装类的引用。然而,这种优势隐藏着代价。让我们通过一个例子来明确我们讨论的问题:
public class OuterClass {
private int id;
public OuterClass() {
}
private void doSomeStuff() {
InnerClass innerObject = new InnerClass();
innerObject.doSomeOtherStuff();
}
private class InnerClass {
private InnerClass() {
}
private void doSomeOtherStuff() {
OuterClass.this.doSomeStuff();
}
}
}
我们正在处理的两类将会被分离。这意味着编译器将在外部类中创建方法,让内部类访问被引用包装类的变量和方法。让我们来看一下前述类的字节码:
class OuterClass {
private int id;
private void doSomeStuff() {
OuterClass$InnerClass innerObject = new OuterClass$InnerClass();
innerObject.doSomeStuff();
}
int access$0() {
return id;
}
}
OuterClass类为每个变量创建了一个方法,让InnerClass类在包保护级别环境中访问它:
class InnerClass {
OuterClass this$0;
void doSomeOtherStuff() {
InnerClass.access$100(this$0);
}
static void access$100(OuterClass outerClass) {
outerClass.doSomeStuff();
}
static int access$0(OuterClass outerClass) {
return outerClass.id;
}
}
创建的静态方法是让InnerClass访问OuterClass的相关方法。如前所述,这会导致访问变慢,从而执行更慢的代码。如果声明包保护的变量和方法,可以避免这种情况,允许InnerClass在不生成字节码中的静态方法的情况下访问它们。这将允许同一包中的任何其他类访问,但也可能加快代码速度。所以,我们需要知道是否可以这样做。如果可以,OuterClass应该变成以下形式:
public class OuterClass {
int id;
void doSomeStuff() {
InnerClass innerObject = new InnerClass();
innerObject.doSomeOtherStuff();
}
private class InnerClass {
private void doSomeOtherStuff() {
OuterClass.this.doSomeStuff();
}
}
}
Android N 中的 Java 8
新的 Android N SDK 在发布时提供了对 Java 8 引入的新特性的支持。在接下来的页面中,我们将通过它们了解如何在开发应用程序时提供帮助,并了解新的工具链以改善构建 APK 文件时的时序。
设置
为了使用新的 Java 8 特性,我们需要面向新的 Android N,并使用支持 Android N 的新 Android Studio 2.1,否则,这些特性将不可用。在撰写本书时,新的 Android Studio 2.1 仍处于预览版本。然而,我们可以使用它来更好地了解在项目中使用 Java 8 及其新特性的步骤。这是因为新的 Jack 工具链,在 Android MarshMallow(API 级别 23)中引入,我们将在接下来的页面中详细讨论,以及新的 Gradle 插件,是编译 Java 8 并使用我们将在下一节中介绍的特性唯一方式。
目前,我们需要按照以下方式更改build.gradle文件:
android {
...
defaultConfig {
...
jackOptions {
enabled true
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
}
这样,我们为项目启用了 Jack 工具链和 Java 8 兼容性。
特性
如果我们的项目面向 Android N,可以在项目中使用 Java 8 的主要新特性如下:
-
接口内的默认和静态方法
-
Lambda 表达式
-
重复注解
-
改进的反射 API
让我们在接下来的页面中了解它们。
默认接口方法
假设你正在为其他项目开发一个库。你想要编写一个接口,用于定义实现该接口的所有类的行为。例如,让我们看看以下接口内部的内容:
public interface OnNewsSelected {
void onNewsClick(News news);
}
以下是Activity对接口的实现:
public class MainActivity extends Activity implements OnNewsSelected
{
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
}
@Override
public void onNewsClick(News news) {
// code to handle the click on a news
}
}
如果现在我们想要在接口中添加一个特性来改进它,我们需要改变所有实现该接口的类。比方说,我们想要处理新闻的长按以及正常点击。接口将变成如下形式:
public interface OnNewsSelected {
void onNewsClick(News news);
void onNewsLongClick(News news);
}
然后,Android Studio 会在MainActivity类以及任何实现OnNewsSelected接口的其他类中通知我们编译错误。神奇的地方来了:使用 Java 8 及其新特性,我们可以在接口本身内部直接定义新方法的默认实现。以下代码段展示了如何为我们的接口完成这一操作:
public interface OnNewsSelected {
void onNewsClick(News news);
default void onNewsLongClick(Context context, News news) {
Intent intent = new Intent(context, NewsDetailActivity.class);
intent.putExtra(NEWS_KEY, news);
context.startActivity(intent);
}
}
使用这个特性,无需在每个实现接口的类中实现新方法,只有在需要与接口内部定义的默认实现不同的实现时才需要。
静态接口方法
静态方法与默认方法相似,但它们不能被子类覆盖。可以通过使用类的静态引用来调用它们,也可以通过对象调用来调用。那么,我们的OnNewsSelected接口示例将变成如下形式:
public interface OnNewsSelected {
void onNewsClick(News news);
static void onNewsLongClick(Context context, News news) {
Intent intent = new Intent(context, NewsDetailActivity.class);
intent.putExtra(NEWS_KEY, news);
context.startActivity(intent);
}
}
这样,我们只定义了长按新闻的一种可能行为,没有任何子类能够定义它自己的方法实现。
Lambda 表达式
当我们开发只定义一个方法的接口时,我们创建了一个所谓的函数式接口。在使用这些函数式接口时创建匿名内部类,代码的可读性不是很清晰。然后,从 Java 8 开始,我们可以使用 Lambda 表达式将简单代码作为参数传递,而不是匿名内部类。
例如,让我们创建以下Adder函数式接口:
public interface Adder {
int add(int a, int b);
}
Lambda 表达式由以下部分组成:
-
由逗号分隔的参数列表:
(int a, int b) -
箭头符号:
-> -
带有声明块的代码体:
a + b
然后,当我们需要我们定义的功能接口的实现时,我们可以使用以下代码:
Adder adder = (int a, int b) -> a + b;
然后,我们可以使用对象adder作为Adder接口的实现。我们也可以用匿名类来做同样的事情:
setAdder((a, b) -> a + b);
之前的代码片段将替换以下代码,明显提高了可读性:
setAdder(new Adder() {
@Override
public int add(int a, int b) {
return a + b;
}
});
重复注解
使用 Java 8 编译时,我们可以设置一个特定的注解特性,允许我们在类或变量上多次添加相同的注解。这是要在注解声明上设置的 @Repeatable 注解。让我们看以下示例,我们想为单一设备定义多个制造商。然后,在定义顶部添加 @Repeatable 注解,如下面的代码片段所示:
@Retention( RetentionPolicy.RUNTIME )
public @interface Devices {
Manufacturer[] value() default{};
}
@Repeatable( value = Device.class )
public @interface Manufacturer {
String value();
}
然后,我们可以使用以下方法为同一设备设置多个制造商:
@Manufacturer("Samsung")
@Manufacturer("LG")
@Manufacturer("HTC")
@Manufacturer("Motorola")
public interface Device {
}
Jack 工具链
工具链是一系列特定的步骤,用于编译我们的代码并创建包含 .dex 字节码的 APK 文件作为输出。《图 8》展示了旧的 Javac 工具链与新的 Jack 工具链之间的主要区别:
图 8:Javac 与 Jack 工具链之间的区别
Jack 工具链在构建过程中带来了新的改进:
-
更快的编译时间
-
代码和资源的缩减
-
代码混淆
-
重新打包
-
多 DEX 编译
为了使用新的工具链,我们无需更改代码或配置中的任何内容,只需处理 设置 部分中提到的 build.gradle 文件的配置。
在撰写本书时,新的 Jack 工具链与 Android Studio 2.0 的新 Instant Run 功能不兼容。这意味着在使用 Jack 工具链时,Instant Run 将被禁用。
APK 优化
当一切准备就绪,代码开发并测试完成,用户正等待我们应用程序的更新时,我们使用它来构建一个 APK 文件,通过 Google Play 商店或其他途径进行分发。然而,由于多种因素,生成的 APK 文件体积不断增大:新功能的实现、需要支持的新不同配置、新的 Android 版本、应用程序中使用的更多库等等。这样,我们迫使用户使用更多带宽来更新它,以及更多存储空间来保存它。此外,通过商店上传和分发的 APK 文件大小是有限制的。那么,我们确信我们做得好吗?我们可以做些什么来减小文件大小?在接下来的几页中,让我们尝试从不同的角度来回答这些问题。
移除未使用的代码
高级语言考虑代码的可重用性以缩短开发时间和减少调试。这也有助于最小化 APK 文件的大小,同时保持代码的清洁和更好的组织。尽可能保持代码清洁应成为日常活动。然而,即使我们每天都在这样做,我们仍然可以通过在第七章中讨论的安全工具来提高最终构建中代码的清洁度。我们讨论的是 ProGuard。它不仅混淆代码以提高安全级别,还可以在启用时搜索并移除应用程序中未使用的代码:
buildTypes {
debug {
debuggable true
}
release {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
移除未使用的资源
我们已经讨论过图像及其大小对通信的影响,但这里同样的考虑也可以用来减小 APK 文件的大小。因此,检查我们的图像是否可以通过在线工具更改压缩率和/或分辨率来减小大小,如前一部分所述,这可能是一个好主意。
作为一条更通用的规则,我们应该始终检查项目中是否有未使用的资源并删除它们,无论它们是图片还是其他类型的资源。这对于保持项目清洁也很有帮助。在此操作中,Lint 非常有用,它可以搜索项目中的任何未使用资源。
如果这些操作不足以从最终 APK 文件中移除项目的所有未使用资源,Gradle 会在最终构建之前分析项目的所有资源来帮助我们。我们只需在build.gradle文件中启用它,如下面的示例所示:
buildTypes {
debug {
debuggable true
}
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
记得启用代码压缩。否则,资源缩减功能将无法工作。这对于我们使用外部库但并非所有资源都被使用的情况非常有用。例如,如果我们向项目中添加了 Google Play 服务库,但我们没有使用 Google+登录或 Google Cast API,那么 Gradle 将移除结果文件中相关的未使用资源。
对于应用程序支持的不同配置,我们也应该考虑同样的场景;例如,如果我们的应用程序只支持英语和法语,但链接的库支持的语言比我们的应用程序多,如果我们不告诉 Gradle 我们想要哪些配置,那么所有其他配置仍然会在最终构建中。为此,我们可以在build.gradle文件中的构建配置中添加resConfig属性,如下面的代码所示:
defaultConfig {
applicationId "applicationId"
minSdkVersion 18
targetSdkVersion 23
versionCode 1
versionName "1.0"
resConfigs "en", "fr"
}
resConfig属性接受我们希望支持的每一种配置类型,从应用程序和链接的库中过滤掉所有其他的配置。因此,这可以用于所有 Android 提供的配置,如密度、方向、语言、Android 版本等。
总结
我们从不同的角度讨论了图像管理的重要性,因为这对于处理它们的每个应用程序都是至关重要的:
-
加载:图片是内存中最大的负担。很多时候,我们直接使用它们,而没有适当地处理以减轻对整个系统性能的压力。因此,在像 Android 设备这样的碎片化市场中,缩放操作总是必需的。因此,我们讨论了如何在使用 Android API 进行缩放时提高性能的正确方法。
-
处理:图像操作成本高昂,需要在一个工作线程中执行,以释放主线程不必要的计算。我们从响应性的角度研究了如何安全地处理图像。
-
缓存:节省外部通信的最佳方式是保存数据以供未来重用。这就是为什么我们改进了缓存图片的方法和算法,最大限度地重用它们,引入了 LRU 缓存架构,用于堆内存和磁盘缓存内存级别,以提高持久性并避免应用程序使用过程中出现
OutOfMemoryErrors。 -
显示:我们介绍了待显示图片的像素格式配置,以加快应用程序的响应速度并改善压缩。
-
内存管理:当许多图片即将被处理时,如在
ListView或其他类似的带有Adapter类的ViewGroup中,可能会发生内存波动,导致随着时间的推移出现过多的垃圾回收。为此,我们讨论了如何重用多次图片处理中的内存分配方法,以减少垃圾收集器的干预。
除了代码,我们还讨论了对于越来越大的高密度屏幕来说,哪些压缩和分辨率最适合显示图片。
在继续网络数据交换的讨论中,我们考虑并分析了文本通过网络传输的方式,为类似 JSON 的结构化文件定义了最佳实践,并介绍了多种序列化技术,如谷歌提供的协议缓冲区和扁平缓冲区,以减少本地序列化/反序列化操作的开销,并加快数据传输速度。
然后,我们找到了一些在处理 Java 豆和内部类时开发者应该养成的习惯;即使我们遵循了使用通用语言的指导方针,也可能会出现性能下降。
最后,在本章的结尾,我们讨论了减少 APK 文件大小的技巧,以便通过商店进行分发。这对于遵守商店限制并保持项目清洁以便未来实现新的功能来说非常重要。