吾爱破解 - LCG - LSG |安卓破解|病毒分析|www.52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 3691|回复: 81
收起左侧

[Android 原创] 安卓逆向入门笔记(三)——安卓开发与逆向基础

  [复制链接]
sigewangdaiduie 发表于 2023-11-15 21:23

安卓基础

前言

这一篇会很长,如果要把整篇文章看完是十分难的,如果能把整篇文章从头到尾看完,那说明你拥有超乎常人的毅力。这篇文章的篇幅较长,各位可以按照需要学习的内容来阅读所需要学习的部分,还是那句话:“知己知彼,百战不殆。”

应用清单文件

在讲四大组件之前,我们需要先了解一下应用清单文件——AndroidManifest.xml,前面虽然对该文件或多或少的讲了一点,但是这里我们要更加的深入了解一下AndroidManifest.xml这个应用清单文件。

应用清单文件必须声明应用的软件包名称、应用的组件、应用为访问系统或其他应用的受保护部分所需的权限、应用需要的硬件和软件功能。如果开发者是使用 Android Studio构建的应用,则系统会为开发者创建清单文件,并在开发者构建应用时(尤其是在使用代码模板时,例如使用Activity 模板时)添加大部分基本清单元素。

软件包名称和应用 ID

清单文件的<manifest>根元素需包含应用软件包名称的属性,应用软件包名称与项目目录结构相匹配,例如应用软件包名称为"com.example.myapp";

应用软件包名称属性是十分重要的,例如某app在应用清单文件中声明了以下<manifest>根元素的属性:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.myapp"
    android:versionCode="1"
    android:versionName="1.0" >
    ...
</manifest>

从上方的清单中得知该app的包名为com.example.myapp,那我们可以推断出R.java类在com.example.myapp下创建,因为Android 构建工具会使用 package 属性的值(也就是包名)用作应用所生成 R.java 类的命名空间(命名空间看起来很高级,其实命名空间就是类所在的包名),这是其一;其二,Android 构建工具会使用包名解析清单文件中声明的任何相关类名称,例如:系统会将声明为 <activity android:name=".MainActivity"> 的 Activity 解析为 <activity android:name="com.example.myapp.MainActivity">,可以发现Android 构建工具把包名自动拼接在了android:name属性的值.MainActivity的前面。

因此,清单文件中的package属性应始终与项目中保存 Activity 以及其他应用代码的基础软件包的名称相匹配。当然你也可以在项目中加入其他子软件包(例如基础软件包为com.example.myapp,子软件包名称为purchases,那么子软件包完整包名为com.example.myapp.purchases),子软件包可以包含其他类文件,用于实现特定功能或模块,但无论是位于基础软件包中还是子软件包中的类文件,在使用R.java类时,都需要使用package属性的值来作为导入R.java类的命名空间。

当Android构建工具根据package属性的值执行上述任务(根据包名生成R.java类、解析清单文件中声明的任何相关类名称)后,Android构建工具会将package属性的值替换为项目的build.gradle文件中定义的applicationId属性的值。build.gradle文件是用于配置和构建Android项目的文件,在build.gradle文件中,开发者可以定义应用的applicationId属性的值,用于指定应用的唯一标识符。因为package 属性的这一最终值必须是通用唯一值,因为这是能确保在系统和 Google Play 中识别应用的唯一方式。

因此,总的来说package属性的最终值将由build.gradle文件中的applicationId属性的值决定。这个最终值必须是一个通用唯一标识符,以确保在系统和Google Play中能够唯一识别和标识应用。但请注意,APK 编译完成后,package 属性还可表示应用的通用唯一应用 ID,只是将package属性的值替换为项目的build.gradle文件中定义的applicationId属性的值,是值的替换,而不是属性的替换!

有时候清单中的 package 名称与 build.gradle 文件中 applicationId 的区别可能会令人感到有点困惑,但只要能保持二者一致,便无需担心出现相关问题。

应用组件

对于在应用中创建的每个应用组件,应用清单文件中必须声明相应的 XML 元素,例如:

  • 在应用中创建活动组件,需要在应用清单文件中声明<activity>, 用于声明 Activity 的每个子类。
  • 在应用中创建服务组件,需要在应用清单文件中声明<service>, 用于声明 Service 的每个子类。
  • 在应用中创建广播接收器组件,需要在应用清单文件中声明<receiver> ,用于声明 BroadcastReceiver 的每个子类。
  • 在应用中创建内容提供者组件,需要在应用清单文件中声明<provider> ,用于声明 ContentProvider 的每个子类。

如果在应用中创建此类组件的任何子类,但未在清单文件中对其进行声明,则系统便无法启动该子类。

必须使用 android:name 属性指定子类的名称,且其必须使用完整的软件包名称。但是对组件的声明有两种声明方法,下面简单讲讲这两种声明方法:

第一种:

<manifest ... >
    <application ... >
        <activity android:name="com.example.myapp.MainActivity" ... >
        </activity>
    </application>
</manifest>

如上所示android:name属性的值是activity组件的完整路径,而第二种就是之前讲过的android:name属性值的第一个字符是英文句号,应用的软件包名称应用的软件包名称。

第二种:

<manifest package="com.example.myapp" ... >
    <application ... >
        <activity android:name=".MainActivity" ... >
            ...
        </activity>
    </application>
</manifest>

如果开发者拥有位于子软件包中(如在 com.example.myapp.purchases 中)的应用组件,则 name 值必须添加缺失的子软件包名称(如 ".purchases.PayActivity")或者使用完全限定的软件包名称(如"com.example.myapp.purchases.PayActivity")。

Intent 过滤器

在讲intent过滤器之前,先讲讲intent是什么:

应用的 Activity、服务和广播接收器均由 Intent 激活, Intent 是由 Intent 对象定义的消息,intent译为中文是"意图",用于描述要执行的操作,其中包括要执行操作的数据、应执行操作的组件类别以及其他相关说明。看起来intent很麻烦,但其基本用例主要包括以下三个:

  • 启动 Activity

    Activity 表示应用中的一个屏幕。通过将 Intent 传递给 startActivity(),开发者可以启动新的 Activity 实例。Intent 用于描述要启动的 Activity,并携带任何必要的数据。

    如果开发者希望在一个Activity完成后接收结果,他们可以使用startActivityForResult()方法来启动另一个Activity。在被启动的Activity完成后,系统会将结果作为一个独立的Intent对象传递给调用者的Activity,并通过onActivityResult()回调方法将结果返回。这样,开发者就可以在调用者的Activity中处理返回的结果,并根据需要采取相应的操作。通过这种方式,开发者可以实现不同Activity之间的交互和数据传递。

  • 启动服务

    Service 是一个不使用用户界面而在后台执行操作的组件。

    使用 Android 5.0(API 级别 21)及更高版本,开发者可以启动包含 JobScheduler 的服务。如需了解有关 JobScheduler 的详细信息,还请参阅作业调度程序 |安卓开发者 (google.cn)

    对于 Android 5.0(API 级别 21)之前的版本,开发者是可以使用 Service 类的方法来启动服务。通过将 Intent 传递给 startService(),开发者可以启动服务执行一次性操作(例如,下载文件)。Intent 用于描述要启动的服务,并携带任何必要的数据。

    如果服务旨在使用客户端-服务器接口,则通过将 Intent 传递给 bindService(),开发者可以从其他组件绑定到此服务。

  • 传递广播

    广播是任何应用均可接收的消息。系统将针对系统事件(例如:系统启动或设备开始充电时)传递各种广播。通过将 Intent 传递给 sendBroadcast()sendOrderedBroadcast(),开发者可以将广播传递给其他应用。

Intent 分为两种类型:

  • 显式 Intent:通过提供目标应用的软件包名称或完全限定的组件类名来指定可处理 Intent 的应用。通常,开发者会在自己的应用中使用显式 Intent 来启动组件,这是因为开发者知道要启动的 Activity 或服务的类名。例如,开发者可能会启动应用内的新 Activity 以响应用户操作,或者启动服务以在后台下载文件。使用显式 Intent 可以确保意图被发送到指定的组件,并且可以在应用内部进行精确的控制和操作。
  • 隐式 Intent :不会指定特定的组件,而是声明要执行的常规操作,从而允许其他应用中的组件来处理。举个例子,假如我们想要在地图上显示一个特定的位置,我们可以创建一个隐式 Intent,并设置操作为显示地图,并提供位置的数据。然后,系统会查找能够处理这个操作的应用,并将 Intent 发送给该应用的适当组件,以在地图上显示该位置。使用隐式 Intent,我们可以利用其他应用已经实现的功能,而不需要自己编写相应的代码。这样可以提高开发效率,并且使我们的应用具有更多的灵活性和扩展性。
    image-20230629230814043.png

隐式 Intent 如何通过系统传递以启动其他 Activity:

第一步、 Activity A 创建包含操作描述的 Intent,并将其传递给 startActivity()

第二步、 Android 系统搜索所有应用中与 Intent 匹配的 Intent 过滤器。找到匹配项之后,进行第三步操作。

第三步、 该系统通过调用匹配 Activity (Activity B) 的 onCreate() 方法并将其传递给 Intent,以此启动匹配 Activity。

使用隐式 Intent 时,Android 系统通过将 Intent 的内容与在设备上其他应用的清单文件中声明的 Intent 过滤器进行比较,从而找到要启动的相应组件。如果 Intent 与 Intent 过滤器匹配,则系统将启动该组件,并向其传递 Intent 对象。如果多个 Intent 过滤器兼容,则系统会显示一个对话框,支持用户选取要使用的应用。

启动 Service 时,一定要始终使用显式 Intent,且不要为服务声明 Intent 过滤器。官方文档原文如下:

注意:为了确保应用的安全性,启动 Service 时,请始终使用显式 Intent,且不要为服务声明 Intent 过滤器。使用隐式 Intent 启动服务存在安全隐患,因为您无法确定哪些服务将响应 Intent,且用户无法看到哪些服务已启动。从 Android 5.0(API 级别 21)开始,如果使用隐式 Intent 调用 bindService(),系统会抛出异常。

具体的怎么构造intent、怎么接收隐式intent、怎么使用待定的intent等操作就不一一讲述了,有兴趣的可以移步到安卓官方文档Intent 和 Intent 过滤器  | Android 开发者  | Android Developers (google.cn)

了解完了什么是intent,下面我们来正式讲讲什么是intent过滤器:

Intent 过滤器是应用清单文件中的一个表达式,用于指定该组件要接收的 Intent 类型。例如,通过为 Activity 声明 Intent 过滤器,开发者可以使其他应用能够直接使用某一特定类型的 Intent 启动 Activity。同样,如果开发者没有为 Activity 声明任何 Intent 过滤器,则 Activity 只能通过显式 Intent 启动。简而言之,intent过滤器就是帮助组件筛选指定要接收的隐式intent类型。

要公布应用可以接收哪些隐式 Intent,需要在应用清单文件中使用<intent-filter>元素为每个应用组件声明一个或多个 Intent 过滤器。每个 Intent 过滤器均根据 Intent 的操作、数据和类别指定自身接受的 Intent 类型。仅当隐式 Intent 可以通过 Intent 过滤器之一传递时,系统才会将该 Intent 传递给应用组件。而显式 Intent 始终会传递给其目标,无论组件声明的 Intent 过滤器如何均是如此。

至于怎么声明一个组件的intent过滤器我就不详解了,有兴趣的还是可以移步到安卓官方文档Intent 和 Intent 过滤器  | Android 开发者  | Android Developers (google.cn)

图标和标签

以下为安卓官方文档的原文(我觉得说的挺好的,我就直接一字不差的截取了下来):

许多清单元素拥有 iconlabel 属性,二者分别用于向对应应用组件的用户显示小图标和文本标签。

任何情况下,在父元素中设置的图标和标签都会成为所有子元素的默认 iconlabel 值。 例如,在 <application> 元素中设置的图标和标签即为每个应用组件(如所有 Activity)的默认图标和标签。

只要以实现 Intent 的选项形式呈现组件,系统便会向用户显示在该组件的 <intent-filter> 中设置的图标和标签。 默认情况下,此图标继承自为父组件(<activity><application> 元素)声明的任何图标,但如果 Intent 过滤器提供唯一操作,且您希望该操作在选择器对话框中有更好的指示,则您可能需更改此图标。

权限

应用权限有助于保护对以下数据和操作的访问/执行权限,从而为保护用户隐私提供支持:

  • 受限数据,例如系统状态和用户的联系信息
  • 受限操作,例如连接到已配对的设备并录制音频

安卓应用程序必须要请求权限才能访问敏感的用户数据(如联系人和短信)或某些系统功能(如相机和互联网接入)。如果安卓应用程序需要在声明权限的情况下提供功能,那么需要在应用程序的清单文件中声明相应的权限。例如,需要发送短信的应用必须在清单中添加以下代码行:

<manifest ... >
    <uses-permission android:name="android.permission.SEND_SMS"/>
    ...
</manifest>

在Android 6.0及更高版本中,用户可以在运行时授予或拒绝某些应用权限。但是,无论应用程序支持哪个Android版本,都必须在清单文件中使用<uses-permission>元素声明所有权限请求。如果用户授予了相应的权限,应用程序就可以使用受保护的功能。否则,在应用程序尝试使用这些功能时,请求将失败。

除了保护用户数据和设备功能外,应用程序还可以使用权限来保护自身的组件。它可以使用Android定义的任何权限,如在android.Manifest.permission中列出的权限,也可以使用其他应用程序中声明的权限。此外,应用程序还可以定义自己的权限。新权限可以使用<permission>元素进行声明。

这意味着,应用程序可以使用现有的权限来限制对其组件的访问,或者可以定义自己的权限来控制对特定功能或数据的访问。通过为组件或功能添加权限,开发者就可以提高应用程序的安全性,并确保只有经过授权的用户可以访问相关部分。在向用户请求权限并根据其授予或拒绝的权限来调整和保护应用程序的行为时,开发者也可以为用户提供更好的用户体验和数据保护。

下面了解一下应用权限的工作流:

image-20230630213434798.png

英文难以看懂,那么下面为上图的中文意思:

(1):你能在不声明权限的情况下提供功能吗?

回答:如果是的,那就进行(2a)所示操作,如果不是,那就进行(2b)所示操作。

(2a):在不使用权限的情况下完成用例。

(2b):在应用程序的清单文件中声明权限。

如若进行(2b)操作,那下一步将进行(3)所示判断。如若进行(2a)操作,那就无需进一步操作。

(3):该权限是否为运行时权限?

回答:如果是的,那就进行(4)所示操作,如果不是,那就无需进一步操作。

(4):请求用户在运行时授予权限。

应用必须访问受限数据或执行受限操作才能实现某个用例,那么就声明相应的权限,否则无需声明任何权限。有些权限是用户安装应用时自动授予的权限,称为安装时权限。其他权限则需要应用在运行时进一步请求权限,此类权限称为运行时权限。

Android 将权限分为不同的类型,包括安装时权限、运行时权限和特殊权限。每种权限类型都指明了当系统授予应用该权限后,应用可以访问的受限数据范围以及应用可以执行的受限操作范围。

如果对权限具体内容感兴趣的可以去看Android 中的权限  | Android 开发者  | Android Developers (google.cn),这里我就不一一说明了。

设备兼容性

在应用清单文件中,还可以声明应用需要哪些类型的硬件或软件功能,进而声明应用与哪些类型的设备兼容。<uses-feature>元素可以用于声明应用所需的硬件和软件功能。例如,如果应用在不带罗盘传感器的设备上无法获得基本功能,则可以使用以下清单标记将罗盘传感器声明为必需功能:

<manifest ... >
    <uses-feature android:name="android.hardware.sensor.compass"
                  android:required="true" />
    ...
</manifest>

文件约定

本部分为安卓官方文档中的内容,介绍了通常适用于清单文件中的所有元素和属性的约定和规则,我只对其添加了部分解释。

元素

只有 <manifest><application> 元素是必需的。这两个元素都只能出现一次。大多数其他元素可以出现零次或多次。不过,为了使清单文件有用,其中某些元素必须存在。

所有值均通过属性进行设置,而不是通过元素内的字符数据设置。直白来说,就是在每个元素中需要使用属性来指定该元素的属性值,而不是在元素内部添加字符数据。

同一级别的元素通常不分先后顺序。例如,<activity><provider><service> 元素可按任意顺序放置。此规则主要有两种例外情况:

  • <activity-alias> 元素必须跟在它作为其别名的 <activity> 后面。这是因为 <activity-alias> 元素是用来为已有的 <activity> 元素创建别名的。
  • <application> 元素必须是 <manifest> 元素内的最后一个元素。

属性

严格意义上来说,所有属性都是可选的。不过,必须指定许多属性,这样元素才能实现其目的。

除了 <manifest> 根元素的某些属性之外,所有属性名称都以 android: 前缀开头,例如 android:alwaysRetainTaskState。由于该前缀是通用的,因此在按名称引用属性时,文档中通常会将其省略。

多个值

如果可以指定多个值,元素几乎总是重复,而不是在单个元素中列出多个值。例如,一个 intent 过滤器可以列出多项操作:

<intent-filter ... >
    <action android:name="android.intent.action.EDIT" />
    <action android:name="android.intent.action.INSERT" />
    <action android:name="android.intent.action.DELETE" />
    ...
</intent-filter>

资源值

某些属性具有显示给用户的值,如 activity 的标题或您的应用图标。这些属性的值可能因用户的语言或其他设备配置不同而异(例如,根据设备的像素密度提供不同的图标大小),因此应该从资源或主题设置这些值,而不是将其硬编码到清单文件中。实际值随后可以根据不同设备配置提供的备用资源发生变化。

资源表示为值,格式如下:

"@[package:]type/name"

如果资源由您的应用提供(包括资源由库依赖项提供的情况,因为库资源会合并到您的资源中),则您可以省略 package 名称。当您想要使用来自 Android 框架的资源时,唯一一个其他有效的软件包名称是 android

type 是资源的类型,如 stringdrawable;name 是标识特定资源的名称。示例如下:

<activity android:icon="@drawable/smallPic" ... >

如需详细了解如何为项目添加资源,请参阅应用资源概览

如需应用在主题背景中定义的值,第一个字符必须是 ?,而不是 @

"?[package:]type/name"

字符串值

如果属性值是一个字符串,则使用双反斜线 (\\) 转义字符,如 \\n 表示换行符,\\uxxxx 表示 Unicode 字符。

清单元素参考

下表为安卓官方文档提供的 AndroidManifest.xml 文件中所有有效元素的简单说明。

<action> 向 intent 过滤器添加操作。
<activity> 声明 activity 组件。
<activity-alias> 声明 activity 的别名。
<application> 声明应用。
<category> 向 intent 过滤器添加类别名称。
<compatible-screens> 指定应用与之兼容的各种屏幕配置。
<data> 向 intent 过滤器添加数据规范。
<grant-uri-permission> 指定父 content provider 有权访问的应用数据的子集。
<instrumentation> 声明用于监控应用与系统交互的 Instrumentation 类。
<intent-filter> 指定 activity、服务或广播接收器可以响应的 intent 类型。
<manifest> AndroidManifest.xml 文件的根元素。
<meta-data> 可以向父组件提供的其他任意数据项的名称值对。
<path-permission> 定义 content provider 中特定数据子集的路径和所需权限。
<permission> 声明可用于限制访问此应用或其他应用的特定组件或功能的安全权限。
<permission-group> 声明相关权限的逻辑分组的名称。
<permission-tree> 声明权限树的基名。
<provider> 声明一个 content provider 组件。
<queries> 声明您的应用打算访问的一些其他应用。
<receiver> 声明广播接收器组件。
<service> 声明服务组件。
<supports-gl-texture> 声明应用支持的一种 GL 纹理压缩格式。
<supports-screens> 声明应用支持的屏幕尺寸,并为比应用支持的最大屏幕还大的屏幕启用屏幕兼容性模式。
<uses-configuration> 指示应用所需的特定输入功能。
<uses-feature> 声明应用使用的一项硬件或软件功能。
<uses-library> 指定应用必须与之关联的共享库。
<uses-native-library> 指定供应商提供的必须关联到应用的原生共享库。
<uses-permission> 指定为使应用正常运行用户必须授予的系统权限。
<uses-permission-sdk-23> 指定应用需要特定的权限,但仅当应用安装在搭载 Android 6.0(API 级别 23)或更高版本的设备上时才需要。
<uses-sdk> 让您用 API 级别整数来表示应用与一个或多个 Android 平台版本的兼容性。

四大组件

安卓四大组件是指Activity(活动)、Service(服务)、BroadcastReceiver(广播接收器)、ContentProvider(内容提供者)。它们是安卓应用程序的基本组成部分,每个组件都有自己的生命周期和功能。

Activity

安卓四大组件之一的Activity是一个应用程序组件,在Android平台上,每个应用至少有一个Activity。Activity类代表了一个用户界面的窗口,用户可以与之交互。在编程范式中,应用是通过 main() 方法启动的,而 Android 系统与此不同,它会调用与其生命周期特定阶段相对应的特定回调方法来启动 Activity 实例中的代码。

移动应用和桌面应用最大的不同之处在于移动应用的体验更加灵活和多样化,用户与应用的互动并不总是在同一位置开始,而是经常以不确定的方式开始。最直观的感受就是通过一个应用程序调用另一个应用程序时,调用方应用会调用另一个应用中的 Activity,而不是调用整个应用。通过这种方式,Activity 充当了应用与用户互动的入口点。每个Activity都是Activity类的子类,通过继承和重写父类的方法来实现自定义的功能和界面。

大多数应用包含多个屏幕,这意味着它们包含多个 Activity。通常,应用中的一个 Activity 会被指定为主 Activity,这是用户启动应用时出现的第一个屏幕。然后,每个 Activity 可以启动另一个 Activity,以执行不同的操作,Activity 也经常会启动属于其他应用的 Activity。

声明 Activity

要在应用中使用 Activity,就必须在应用的清单中注册关于 Activity 的信息,并且必须适当地管理 Activity 的生命周期。那在应用的清单中怎么注册关于Activity的信息呢?

首先要声明 Activity,需要打开应用清单文件,并在文件中添加<activity>元素作为<application>元素的子元素,<activity>元素唯一的必要属性是android:name,该属性用于指定 Activity 的类名称。<activity>元素的语法结构大致如下所示:

<activity android:allowEmbedded=["true" | "false"]
          android:allowTaskReparenting=["true" | "false"]
          android:alwaysRetainTaskState=["true" | "false"]
          android:autoRemoveFromRecents=["true" | "false"]
          android:banner="drawable resource"
          android:clearTaskOnLaunch=["true" | "false"]
          android:colorMode=[ "hdr" | "wideColorGamut"]
          android:configChanges=["mcc", "mnc", "locale",
                                 "touchscreen", "keyboard", "keyboardHidden",
                                 "navigation", "screenLayout", "fontScale",
                                 "uiMode", "orientation", "density",
                                 "screenSize", "smallestScreenSize"]
          android:directBootAware=["true" | "false"]
          android:documentLaunchMode=["intoExisting" | "always" |
                                  "none" | "never"]
          android:enabled=["true" | "false"]
          android:excludeFromRecents=["true" | "false"]
          android:exported=["true" | "false"]
          android:finishOnTaskLaunch=["true" | "false"]
          android:hardwareAccelerated=["true" | "false"]
          android:icon="drawable resource"
          android:immersive=["true" | "false"]
          android:label="string resource"
          android:launchMode=["standard" | "singleTop" |
                              "singleTask" | "singleInstance" | "singleInstancePerTask"]
          android:lockTaskMode=["normal" | "never" |
                              "if_whitelisted" | "always"]
          android:maxRecents="integer"
          android:maxAspectRatio="float"
          android:multiprocess=["true" | "false"]
          android:name="string"
          android:noHistory=["true" | "false"]  
          android:parentActivityName="string" 
          android:persistableMode=["persistRootOnly" | 
                                   "persistAcrossReboots" | "persistNever"]
          android:permission="string"
          android:process="string"
          android:relinquishTaskIdentity=["true" | "false"]
          android:resizeableActivity=["true" | "false"]
          android:screenOrientation=["unspecified" | "behind" |
                                     "landscape" | "portrait" |
                                     "reverseLandscape" | "reversePortrait" |
                                     "sensorLandscape" | "sensorPortrait" |
                                     "userLandscape" | "userPortrait" |
                                     "sensor" | "fullSensor" | "nosensor" |
                                     "user" | "fullUser" | "locked"]
          android:showForAllUsers=["true" | "false"]
          android:stateNotNeeded=["true" | "false"]
          android:supportsPictureInPicture=["true" | "false"]
          android:taskAffinity="string"
          android:theme="resource or theme"
          android:uiOptions=["none" | "splitActionBarWhenNarrow"]
          android:windowSoftInputMode=["stateUnspecified",
                                       "stateUnchanged", "stateHidden",
                                       "stateAlwaysHidden", "stateVisible",
                                       "stateAlwaysVisible", "adjustUnspecified",
                                       "adjustResize", "adjustPan"] >   
    ...
</activity>

这些没必要记,只需知道<activity>元素唯一的必要属性是android:name<activity>元素需要在<application>元素当中作为子元素进行声明,例如:

    <manifest ... >
      <application ... >
          <activity android:name=".ExampleActivity" />
          ...
      </application ... >
      ...
    </manifest >

而其他属性用到了再去查就好了。如果有兴趣详细了解<activity>元素的语法结构的请参阅<activity>元素参考文档:Android 开发者  | Android Developers (google.cn)

声明 intent 过滤器

前面在讲应用清单文件时已经讲解了intent过滤器,intent过滤器是什么这里就不重复说了,这里主要来看看怎么声明intent过滤器。

这里我们直接来看看官方文档中是怎么说声明intent过滤器的:

要使用此功能,您需要在<activity>元素中声明 <intent-filter>属性。此元素的定义包括<action>元素,以及可选的<category>元素和<data>元素。这些元素组合在一起,可以指定 Activity 能够响应的 intent 类型。例如,以下代码段展示了如何配置一个发送文本数据并接收其他 Activity 的文本数据发送请求的 Activity:

    <activity android:name=".ExampleActivity" android:icon="@drawable/app_icon">
        <intent-filter>
            <action android:name="android.intent.action.SEND" />
            <category android:name="android.intent.category.DEFAULT" />
            <data android:mimeType="text/plain" />
        </intent-filter>
    </activity>

在此示例中,<action>元素指定该 Activity 会发送数据。将<category>元素声明为 DEFAULT 可使 Activity 能够接收启动请求。<data>元素指定此 Activity 可以发送的数据类型。以下代码段展示了如何调用上述 Activity:

    val sendIntent = Intent().apply {
        action = Intent.ACTION_SEND
        type = "text/plain"
        putExtra(Intent.EXTRA_TEXT, textMessage)
    }
    startActivity(sendIntent)

如果您打算构建一个独立的应用,不允许其他应用激活其 Activity,则不需要任何其他 intent 过滤器。您不想让其他应用访问的 Activity 不应包含 intent 过滤器,您可以自己使用显式 intent 启动它们。

声明权限

开发者可以使用<activity>标记来控制哪些应用可以启动某个Activity。如果一个Activity是另一个Activity的子Activity,那么父Activity必须在其清单文件中声明相同的权限,才能启动子Activity。如果父Activity声明了<uses-permission>元素,那么每个子Activity都必须具有相匹配的<uses-permission>元素。

例如,假设调用方应用程序想要调用一个名为 SocialApp 的应用在社交媒体上分享文章,则 SocialApp 本身必须定义调用它的应用所需具备的权限:

    <manifest>
    <activity android:name="...."
       android:permission=”com.google.socialapp.permission.SHARE_POST”

    />

然后,为了能够调用 SocialApp,调用方应用程序必须匹配 SocialApp 清单中设置的权限:

    <manifest>
       <uses-permission android:name="com.google.socialapp.permission.SHARE_POST" />
    </manifest>

Activity的生命周期

Activity的生命周期是指Activity从创建到销毁的过程,当用户浏览、退出和返回到某个应用时,应用中的Activity实例会在其生命周期的不同状态之间进行转换。Activity类提供了一系列回调方法,这些回调方法可以让Activity知道某个状态已经改变,例如系统正在创建、停止或恢复某个 Activity,或者正在销毁该 Activity 所在的进程。而这些状态具体包括:

  1. onCreate():当Activity被创建时调用,通常用于初始化操作。
  2. onStart():当Activity可见但还未获取焦点时调用。
  3. onResume():当Activity获取焦点并开始与用户交互时调用。
  4. onPause():当Activity失去焦点但仍然可见时调用,通常用于保存数据或释放资源。
  5. onStop():当Activity完全不可见时调用,通常用于释放资源。
  6. onDestroy():当Activity被销毁时调用,通常用于清理工作。

为了在 Activity 生命周期的各个阶段之间导航转换,Activity 类提供六个核心回调:onCreate()onStart()onResume()onPause()onStop()onDestroy()。当 Activity 进入新状态时,系统会调用其中每个回调。

image-20230709110759972.png

这张图全是英文看不懂?没关系下面我们来慢慢讲解。

onCreate()

onCreate()为必须实现的回调方法,它会在系统创建应用程序的 Activity 时触发,通常会用于初始化 Activity 的基本组件:例如,应用程序应该在此处创建视图并将数据绑定到列表。最重要的是,开发者必须在此处调用 setContentView() 来定义 Activity 界面的布局。

onCreate() 完成后,下一个回调将是 onStart()

onStart()

当 Activity 进入“已开始”状态时,系统会调用此回调。onStart() 调用使 Activity 对用户可见,因为应用会为 Activity 进入前台并支持互动做准备。例如,应用通过此方法来初始化维护界面的代码。

当 Activity 进入"已开始"状态时,与 Activity 生命周期相关联的所有生命周期感知型组件都将收到 ON_START事件(onStart 事件的常量),你可以理解为当 Activity 进入"已开始"状态时,会告诉所有生命周期感知型组件:“‘我’进入已开始状态啦!”。

onStart() 方法会非常快速地完成,并且与“已创建”状态一样,Activity 不会一直处于“已开始”状态。一旦此回调结束,Activity 便会进入“已恢复”状态,系统将调用 onResume() 方法。

onResume()

Activity 会在进入“已恢复”状态时来到前台,然后系统调用 onResume() 回调。这是应用与用户互动的状态。应用会一直保持这种状态,直到某些事件发生,让焦点远离应用。此类事件包括接到来电、用户导航到另一个 Activity,或设备屏幕关闭。

当 Activity 进入"已恢复"状态时,与 Activity 生命周期相关联的所有生命周期感知型组件都将收到 ON_RESUME 事件(onResume事件的常量),你可以理解为当 Activity 进入"已恢复"状态时,会告诉所有生命周期感知型组件:“‘我’进入已恢复状态啦!”。这时,生命周期组件可以启用在组件可见且位于前台时需要运行的任何功能,例如启动相机预览。

当发生中断事件时,Activity 进入“已暂停”状态,系统调用 onPause() 回调。

如果 Activity 从“已暂停”状态返回“已恢复”状态,系统将再次调用 onResume() 方法。因此,需要实现 onResume(),以初始化在 onPause()期间释放的组件,并执行每次 Activity 进入“已恢复”状态时必须完成的任何其他初始化操作。

onPause()

系统将此方法视为用户将要离开应用的 Activity 的第一个标志(尽管这并不总是意味着 Activity 会被销毁);此方法表示 Activity 不再位于前台(尽管在用户处于多窗口模式时 Activity 仍然可见)。使用 onPause() 方法暂停或调整当 Activity处于“已暂停”状态时不应继续(或应有节制地继续)的操作,以及开发者希望很快恢复的操作。Activity 进入此状态的原因有很多。例如:

  • 如 onResume() 部分所述,某个事件会中断应用执行。这是最常见的情况。
  • 在 Android 7.0(API 级别 24)或更高版本中,有多个应用在多窗口模式下运行。无论何时,都只有一个应用(窗口)可以拥有焦点,因此系统会暂停所有其他应用。
  • 有新的半透明 Activity(例如对话框)处于开启状态。只要 Activity 仍然部分可见但并未处于焦点之中,它便会一直暂停。

当 Activity 进入已暂停状态时,与 Activity 生命周期相关联的所有生命周期感知型组件都将收到 ON_PAUSE事件(onPause事件的常量),你可以理解为当 Activity 进入"已暂停"状态时,会告诉所有生命周期感知型组件:“‘我’进入已暂停状态啦!”。这时,生命周期组件可以停止在组件未位于前台时无需运行的任何功能,例如停止相机预览。

onPause() 方法的完成并不意味着 Activity 离开“已暂停”状态。相反,Activity 会保持此状态,直到其恢复或变成对用户完全不可见。如果 Activity 恢复,系统将再次调用 onResume() 回调。如果 Activity 从“已暂停”状态返回“已恢复”状态,系统会让 Activity 实例继续驻留在内存中,并会在系统调用 onResume() 时重新调用该实例。在这种情况下,无需重新初始化在任何回调方法导致 Activity 进入“已恢复”状态期间创建的组件。如果 Activity 变为完全不可见,系统会调用 onStop()

onStop()

如果应用程序的 Activity 不再对用户可见,说明其已进入“已停止”状态,因此系统将调用 onStop() 回调。例如,当新启动的 Activity 覆盖整个屏幕时,可能会发生这种情况。如果 Activity 已结束运行并即将终止,系统还可以调用 onStop()

当 Activity 进入"已停止"状态时,与 Activity 生命周期相关联的所有生命周期感知型组件都将收到 ON_STOP 事件(onStop事件的常量),你可以理解为当 Activity 进入"已停止"状态时,会告诉所有生命周期感知型组件:“‘我’进入已停止状态啦!”。这时,生命周期组件可以停止在组件未显示在屏幕上时无需运行的任何功能。

onStop() 方法中,应用应释放或调整在应用对用户不可见时的无用资源。例如,应用可以暂停动画效果,或从精确位置更新切换到粗略位置更新。使用 onStop() 而非 onPause() 可确保与界面相关的工作继续进行,即使用户在多窗口模式下查看应用程序的 Activity 也能如此。

当应用程序的 Activity 进入“已停止”状态时,Activity 对象会继续驻留在内存中:该对象将维护所有状态和成员信息,但不会附加到窗口管理器。Activity 恢复后,Activity 会重新调用这些信息。所以无需重新初始化在任何回调方法导致 Activity 进入“已恢复”状态期间创建的组件。系统还会追踪布局中每个 View 对象的当前状态,如果用户在 EditText 微件中输入文本,系统将保留文本内容。

进入“已停止”状态后,Activity 要么返回与用户互动,要么结束运行并消失。如果 Activity 返回,系统将调用 onRestart()。如果 Activity 结束运行,系统将调用 onDestroy()

onDestroy()

销毁 Activity 之前,系统会先调用 onDestroy()。系统调用此回调的原因如下:

  1. Activity 即将结束(由于用户彻底关闭 Activity 或由于系统为 Activity 调用 finish()
  2. 由于配置变更(例如设备旋转或多窗口模式),系统暂时销毁 Activity

当 Activity 进入已销毁状态时,与 Activity 生命周期相关联的所有生命周期感知型组件都将收到 ON_DESTROY 事件(onDestroy 事件的常量),你可以理解为当 Activity 进入"已销毁"状态时,会告诉所有生命周期感知型组件:“‘我’进入已销毁状态啦!”。这时,生命周期组件可以在 Activity 被销毁之前清理所需的任何数据。

如果 Activity 即将结束,onDestroy() 是 Activity 收到的最后一个生命周期回调。如果由于配置变更而调用 onDestroy(),系统会立即新建 Activity 实例,然后在新配置中为新实例调用 onCreate()

任务和返回堆栈:

任务是用户在执行某项工作时与之互动的一系列 Activity 的集合。这些 Activity 按照每个 Activity 打开的顺序排列在一个返回堆栈中。比如:当你看小说之前可能会有一个Activity来显示这本小说的目录,当你点击其中一章后,系统就会打开一个新的 Activity 来显示这章的内容,而这个新的 Activity 会添加到返回堆栈中。如果你按返回按钮,这个新的 Activity 即会从堆栈顶部退出(该 Activity被销毁)。

大多数任务都从设备主屏幕上启动。当用户轻触应用启动器中的图标(或主屏幕上的快捷方式)时,该应用的任务就会转到前台运行。如果该应用没有任务存在(应用最近没有使用过),则会创建一个新的任务,并且该应用的“主”Activity 将会作为堆栈的根 Activity 打开。

在当前 Activity 启动另一个 Activity 时,新的 Activity 将被推送到堆栈顶部并获得焦点。上一个 Activity 仍保留在堆栈中,但会停止。当 Activity 停止时,系统会保留其界面的当前状态。当用户按返回按钮时,当前 Activity 会从堆栈顶部退出(该 Activity 销毁),上一个 Activity 会恢复(界面会恢复到上一个状态)。堆栈中的 Activity 永远不会重新排列,只会被送入和退出,在当前 Activity 启动时被送入堆栈,在用户使用返回按钮离开时从堆栈中退出。因此,返回堆栈按照“后进先出”的对象结构运作。以下是官方为此行为所给出的图:

image-20230909211042581.png

上图很直观的显示了多个Activity在执行任务时送入和退出堆栈的流程。当用户通过当前 Activity 启动另一个 Activity 时,新的 Activity 将被推送到堆栈顶部并获得焦点。上一个 Activity 仍保留在堆栈中,但会停止。当用户按返回按钮时,当前 Activity 会销毁,上一个 Activity 将恢复。

如果用户继续按返回,则堆栈中的 Activity 会逐个退出,以显示前一个 Activity,直到用户返回到主屏幕(或任务开始时运行的 Activity)。移除堆栈中的所有 Activity 后,该任务将不复存在。

安卓上是可以进行多任务的,在安卓中任务是一个整体单元,当用户开始一个新任务或通过当前任务的主屏幕进入新任务的主屏幕时,旧任务可移至“后台”。在后台时,任务中的所有 Activity 都会停止,但任务的返回堆栈会保持不变,当其他任务启动时,当前任务只是失去了焦点,用户再次返回到当前任务,并且当前任务进入前台,其堆栈中的所有Activity都完好如初,堆栈顶部的 Activity 恢复运行。

image-20230909213024201.png

观察上图,任务 B 在前台接收用户互动,任务 A 在后台等待恢复。这样一来即使任务A失去了焦点,但任务A也可以返回到“前台”,以便用户可以从他们离开的地方继续操作。

举例来说,假设当前任务(任务 A)的堆栈中有 3 个 Activity,当前 Activity 的底下压入了 2 个 Activity在堆栈中。用户按主屏幕按钮,然后从应用启动器中启动新应用。主屏幕出现后,任务 A 转到后台。当新应用启动时,系统会启动该应用的任务(任务 B),该任务具有自己的 Activity 堆栈。与该应用互动后,用户再次返回到主屏幕并选择最初启动任务 A 的应用。现在,任务 A 进入前台,其堆栈中的所有三个 Activity 都完好如初,堆栈顶部的 Activity 恢复运行。

要注意的是:多个任务可以同时在后台进行。但是,如果用户同时运行很多后台任务,系统可能会为了恢复内存而开始销毁后台 Activity,导致 Activity 状态丢失。

Activity 是有可能会被实例化多次的,甚至是从其他任务对其进行实例化。因为返回堆栈中的 Activity 不会被重新排列,如果应用程序允许用户从多个 Activity 启动特定的 Activity,系统便会创建该 Activity 的新实例并将其推送到堆栈中,而不是将该 Activity 的某个先前的实例移至堆栈顶部。这样就会导致应用中的一个 Activity 就可能被多次实例化,甚至是从其他任务对其进行实例化。举个例子,如果用户从Activity A启动Activity B,然后从Activity B启动Activity C,再从Activity C启动Activity B,此时堆栈中会有两个Activity B的实例。

具体来说,堆栈的顺序如下: Activity A -> Activity B -> Activity C -> Activity B

这是因为每次启动一个新的Activity,系统便会创建该 Activity 的新实例并将其添加到堆栈的顶部。所以在这个例子中,第二个Activity B实例被推送到堆栈的顶部,而不是将之前的实例移至堆栈顶部。

Activity 和任务的默认行为总结如下:

  • 当 Activity A 启动 Activity B 时,Activity A 会停止,但系统会保留其状态(例如滚动位置和输入到表单中的文本)。如果用户在 Activity B 中按返回按钮,系统会恢复 Activity A 及其状态。
  • 当用户通过按主屏幕按钮离开任务时,当前 Activity 会停止,其任务会转到后台。系统会保留任务中每个 Activity 的状态。如果用户稍后通过点按该任务的启动器图标来恢复该任务,该任务会进入前台并恢复堆栈顶部的 Activity。
  • 如果用户按返回按钮,当前 Activity 将从堆栈中退出并销毁。堆栈中的上一个 Activity 将恢复。Activity 被销毁后,系统不会保留该 Activity 的状态。
  • Activity 可以多次实例化,甚至是从其他任务对其进行实例化。

现在我们已经简单了解了任务和返回堆栈,我们可以看到它的一些问题,比如应用中的某个 Activity 在启动时开启一个新的任务(而不是被放入当前的任务中);或者当用户启动某个 Activity 时,开发者希望能调用它的一个现有实例(而不是在返回堆栈顶部创建一个新实例);或者开发者希望在用户离开任务时清除返回堆栈中除根 Activity 以外的所有 Activity。要解决以上问题,那就需要知道如何管理任务。下面我们需要简单了解如何使用一些清单属性和 intent 标记来定义 Activity 与任务之间的关联方式,以及它们在返回堆栈中的行为。

可以通过两种方式定义不同的启动模式,这两种不同的启动模式可以定义 Activity 的新实例如何与当前任务关联。

  • 使用清单文件

    当你在清单文件中声明 Activity 时,你可以指定该 Activity 在启动时如何与任务关联。在这方面你需要用到的主要<Activity>属性包括:

    • taskAffinity
    • launchMode
    • allowTaskReparenting
    • clearTaskOnLaunch
    • alwaysRetainTaskState
    • finishOnTaskLaunch
  • 使用 Intent 标记

    当您调用 startActivity() 时,可以在 Intent 中添加一个标记,用于声明新 Activity 如何(或是否)与当前任务相关联。你可以使用的主要 intent 标记包括:

    • FLAG_ACTIVITY_NEW_TASK
    • FLAG_ACTIVITY_CLEAR_TOP
    • FLAG_ACTIVITY_SINGLE_TOP

这两种定义不同的启动方式是有优先级的,如果 Activity A 启动 Activity B,Activity B 可在其清单中定义如何与当前任务相关联(如果关联的话),Activity A 也可以请求 Activity B 应该如何与当前任务关联。如果两个 Activity 都定义了 Activity B 应如何与任务关联,将优先遵循 Activity A 的请求(在 intent 中定义),而不是 Activity B 的请求(在清单中定义)。要注意的是有些启动模式可通过清单文件定义,但不能通过 intent 标记定义,同样,有些启动模式可通过 intent 标记定义,却不能在清单中定义。

使用清单文件指定启动模式可以在清单文件中声明 Activity 时,使用<Activity>元素的 launchMode 属性指定 Activity 应该如何与任务关联。launchMode属性说明了 Activity 应如何启动到任务中。可以通过 launchMode 属性指定 4 种不同的启动模式:

"standard"(默认模式)

默认启动standard模式,在这种模式下,启动的Activity会按照启动的顺序被依次压入Task栈中。系统在启动该 Activity 的任务中创建 Activity 的新实例,并将 intent 传送给该实例。Activity 可以多次实例化,每个实例可以属于不同的任务,一个任务可以拥有多个实例。

image-20230910211423741.png

"singleTop"

栈顶复用模式singleTop,如果栈顶的Activity的实例为我们要新建的Activity的实例,那么系统会通过调用其 onNewIntent() 方法来将 intent 转送给该实例,而不是创建新的Activity的新实例。Activity 可以多次实例化,每个实例可以属于不同的任务,一个任务可以拥有多个实例(但前提是返回堆栈顶部的 Activity 不是该 Activity 的现有实例)。

image-20230910212057375.png

需要注意:创建 Activity 的新实例后,用户可以按返回按钮返回到上一个 Activity。但是,当由 Activity 的现有实例处理新 intent 时,用户将无法通过按返回按钮返回到 onNewIntent() 收到新 intent 之前的 Activity 状态。

"singleTask"

栈内复用模式singleTask,系统会创建新任务,并实例化新任务的根 Activity。但是,如果另外的任务中已存在该 Activity 的实例,则系统会通过调用其 onNewIntent() 方法将 intent 转送到该现有实例,而不是创建新实例。Activity 一次只能有一个实例存在。需要注意的是,虽然 Activity 在新任务中启动,但用户按返回按钮仍会返回到上一个 Activity。

举个例子,Android 浏览器应用在<Activity> 元素中指定 singleTask 启动模式,由此声明网络浏览器 Activity 应始终在它自己的任务中打开。这意味着,如果应用程序发出打开 Android 浏览器的 intent,系统不会将其 Activity 置于应用程序所在的任务中,而是会为浏览器启动一个新任务,如果浏览器已经有任务在后台运行,则会将该任务转到前台来处理新 intent。

无论 Activity 是在新任务中启动的,还是在和启动它的 Activity 相同的任务中启动,用户按返回按钮都会回到上一个 Activity。但是,如果应用程序启动了指定 singleTask 启动模式的 Activity,而后台任务中已存在该 Activity 的实例,则系统会将该后台任务整个转到前台运行。此时,返回堆栈包含了转到前台的任务中的所有 Activity,这些 Activity 都位于堆栈的顶部。下图 展示了具体的情景:

image-20230910214841459.png

采用“singleTask”启动模式的 Activity 添加到返回堆栈的过程图示。如果 Activity 已经存在于某个具有自己的返回堆栈的后台任务中,那么整个返回堆栈也会转到前台,覆盖当前任务。

"singleInstance"

全局唯一模式singleInstance,与 "singleTask" 相似,唯一不同的是系统不会将任何其他 Activity 启动到包含该实例的任务中。该 Activity 始终是其任务唯一的成员;由该 Activity 启动的任何 Activity 都会在其他的任务中打开。

image-20230910213950736.png

需要注意的是通过 launchMode属性为 Activity 指定的行为,可被启动 Activity 的 intent 所包含的标记替换。

想要通过Intent标记来定义启动模式,需要在启动 Activity 时,传送给 startActivity() 的 intent 中添加相应的标记来修改 Activity 与其任务的默认关联。

“FLAG_ACTIVITY_NEW_TASK"

会先创建一个新任务,然后在新任务中启动 Activity。如果现在启动的 Activity 已经有任务在运行,则系统会将该任务转到前台并恢复其最后的状态,而 Activity 将在 onNewIntent() 中收到新的 intent。

简单来说,如果已经有一个任务在执行,那么系统会创建一个新的任务,并在新的任务中启动我们要启动的页面;如果我们要启动的页面已经在运行中,系统会将它移到前台并恢复它的状态,而不会创建一个新的页面实例。同时,该页面会接收到一个新的意图,我们可以在onNewIntent()方法中处理这个新的意图。这样做可以提高页面切换的效率,并且可以保留之前页面的状态。

”FLAG_ACTIVITY_SINGLE_TOP“

如果要启动的 Activity 是当前 Activity(即位于返回堆栈顶部的 Activity),则现有实例会收到对 onNewIntent() 的调用,而不会创建 Activity 的新实例。

如果当前正在显示的页面(也就是位于返回堆栈顶部的页面)需要重新启动,系统会调用该页面的onNewIntent()方法,而不会创建一个新的页面实例。也就是说,如果我们在某个页面点击了一个按钮,需要重新加载当前页面,系统会先检查是否已经有该页面的实例存在,如果有的话,就直接调用该实例的onNewIntent()方法,而不会再创建一个新的页面实例。这样可以提高页面切换的效率,避免浪费资源。

"FLAG_ACTIVITY_CLEAR_TOP"

如果要启动的 Activity 已经在当前任务中运行,则不会启动该 Activity 的新实例,而是会销毁位于它之上的所有其他 Activity,并通过 onNewIntent() 将此 intent 传送给它的已恢复实例(现在位于堆栈顶部)。

简单来说,如果我们要启动的页面已经在当前任务中运行,系统会先销毁在它之上的其他页面,然后将新的意图传递给已经在运行的页面实例。这样做可以保持页面堆栈的一致性,并且可以通过onNewIntent()方法处理新的意图。

FLAG_ACTIVITY_CLEAR_TOP 最常与 FLAG_ACTIVITY_NEW_TASK 结合使用。将这两个标记结合使用,可以查找其他任务中的现有 Activity,并将其置于能够响应 intent 的位置。

这里需要注意如果指定 Activity 的启动模式为 "standard",系统也会将其从堆栈中移除,并在它的位置启动一个新实例来处理传入的 intent。这是因为当启动模式为 "standard" 时,始终会为新 intent 创建新的实例。

到此安卓四大组件之一的Activity就讲解完毕了,下面就开始讲安卓四大组件之一的服务,服务的整个生命周期在调用 onCreate() 和返回 onDestroy() 之间的这段时间。与 Activity 类似,服务也在 onCreate() 中完成初始设置,并在 onDestroy() 中释放所有剩余资源。

service

一、Service概念

Service 是一种应用组件,它可以在后台执行长时间运行的操作,而无需提供用户界面。其他应用组件可以启动 Service,并且即使用户切换到其他应用,Service 仍将继续在后台运行。此外,组件可以通过绑定到 Service 来与其进行交互,甚至进行进程间通信 (IPC)。举例来说,Service 可以在后台处理网络请求、播放音乐、执行文件 I/O 操作或与内容提供者进行交互。

Service有三种不同的类型,分别是前台服务、后台服务、应用组件绑定到服务。以下是三种不同类型的详解:

前台服务

前台服务执行一些用户能注意到的操作。例如,音频应用会使用前台服务来播放音频曲目。前台服务必须显示通知,通知就是在应用程序的界面之外显示的消息,旨在向用户提供提醒、来自他人的通信信息或应用中的其他实时信息。即使用户停止与应用的交互,前台服务仍会继续运行。

后台服务

后台服务不会执行用户能直接注意到的操作。例如,如果应用使用某个服务来压缩其存储空间,则此服务通常是后台服务。

应用组件绑定到服务

当应用组件通过调用 bindService() 方法绑定到服务时,服务即进入绑定状态。绑定服务提供了一个客户端-服务器接口,使得应用组件(客户端)可以与服务(服务器)进行交互、发送请求、接收结果,甚至可以通过进程间通信 (IPC) 跨进程执行这些操作。只有当服务与另一个应用组件绑定时,绑定服务才会运行。多个组件可以同时绑定到同一个服务,但只有当全部组件取消绑定后,该服务才会被销毁。

二、Service基础知识

创建服务时,必须创建一个继承自Service类的子类或使用现有的Service子类。在实现中,开发者需要重写一些回调方法来处理服务的生命周期和将组件绑定到服务。Service类提供了四个核心回调方法:onStartCommand()、onBind()、onCreate()、onDestroy()。以下为Service应重写的最重要的回调方法:

onStartCommand()

当另一个组件(如 Activity)请求启动服务时,系统会通过调用 startService() 来调用此回调方法。执行此回调方法时,服务即会启动并可在后台无限期运行。如果要实现此回调方法,则在服务工作完成后,开发者需要负责通过调用 stopSelf()stopService() 来停止服务。(如果只是想提供绑定,则无需实现此方法。)

onBind()

当另一个组件想要与服务绑定时,系统会通过调用 bindService() 来调用此方法。在此方法的实现中,您必须通过返回 IBinder 提供一个接口,以供客户端用来与服务进行通信。请务必实现此方法;但是,如果开发者并不希望允许绑定,则应该会返回 null。

onCreate()

首次创建服务时,系统会调用 onCreate() 回调方法来执行一次性设置程序。在调用 onStartCommand() 或 onBind() 之前,系统会先调用 onCreate() 回调方法。如果服务已在运行,则不会再次调用 onCreate() 回调方法。

onDestroy()

当不再使用服务且准备将其销毁时,系统会调用 onDestroy() 回调方法。在 onDestroy() 回调方法中,服务应该通过实现此方法来清理任何资源,如线程、注册的侦听器、接收器等。这是服务接收的最后一个调用,表示服务即将被销毁。

Service最重要的四个回调方法已然介绍完了,你可以发现Service的生命周期相较于Activity的生命周期简单许多,我们总结一下Service的生命周期:

如果组件通过调用 startService() 启动服务(这会引起对 onStartCommand() 的调用),则服务会一直运行,直到其使用 stopSelf() 自行停止运行,或由其他组件通过调用 stopService() 将其停止为止。

如果组件通过调用 bindService() 来创建服务,且调用 onStartCommand(),则服务只会在该组件与其绑定时运行。当该服务与其所有组件取消绑定后,系统便会将其销毁。

只有在内存过低且必须回收系统资源以供拥有用户焦点的 Activity 使用时,Android 系统才会停止服务。如果将服务绑定到拥有用户焦点的 Activity,则它其不太可能会终止;如果将服务声明为在前台运行,则其几乎永远不会终止。如果服务已启动并长时间运行,则系统逐渐降低其在后台任务列表中的位置,而服务被终止的概率也会大幅提升。如果服务是启动服务,则开发者必须将其设计为能够妥善处理系统执行的重启。如果系统终止服务,则其会在资源可用时立即重启服务,但这还取决于开发者从 onStartCommand() 返回的值。onStartCommand() 返回的值一个有五个,分别是:

START_CONTINUATION_MASK

START_STICKY_COMPATIBILITY

START_STICKY

START_NOT_STICKY

START_REDELIVER_INTENT

以上就是onStartCommand()的返回值,如有兴趣可以自行了解,我这里就不细说了。

服务和Activity以及其他组件一样必须要在应用的清单文件中声明所有服务。如果要声明服务,那需要在<application>元素中添加<service>元素用于声明服务组件,下面是示例:

<manifest ... >
  ...
  <application ... >
      <service android:name=".ExampleService" />
      ...
  </application>
</manifest>

以上是基本示例,可以在<service>元素中加入其他属性,以定义一些特性,如启动服务及其运行时所在进程需要的权限。但请记住android:name属性是唯一必需的属性,用于指定服务的类名,此类名不能被改变,以避免因依赖显式 Intent 来启动或绑定服务而破坏代码的风险。以下是声明service的语法:

<service android:description="string resource"
         android:directBootAware=["true" | "false"]
         android:enabled=["true" | "false"]
         android:exported=["true" | "false"]
         android:foregroundServiceType=["camera" | "connectedDevice" |
                                        "dataSync" | "location" | "mediaPlayback" |
                                        "mediaProjection" | "microphone" | "phoneCall"]
         android:icon="drawable resource"
         android:isolatedProcess=["true" | "false"]
         android:label="string resource"
         android:name="string"
         android:permission="string"
         android:process="string" >
    ...
</service>

这里是大致了解一下语法,如需详细了解,有兴趣的可以跳转至Android 开发者  | Android Developers (google.cn)自行观看。

三、创建启动Service

另一个组件通过调用 startService() 启动服务,这样服务就会调用 onStartCommand() 方法。服务被另一个组件启动后,服务的生命周期即独立于启动它的组件,即使系统已销毁启动服务的组件,该服务还是可以在后台无限期地运行。想要关闭服务,可以在其工作完成时通过调用 stopSelf() 来自行停止运行,或者由另一个组件通过调用 stopService() 来将其停止。

应用组件(如 Activity)可以通过调用 startService() 方法并传递 Intent 对象(指定服务并包含待使用服务的所有数据)来启动服务。服务会在 onStartCommand() 方法接收此 Intent

例如,假设某 Activity 需要将一些数据保存到在线数据库中。该 Activity 可以启动一个协同服务,并通过向 startService() 传递一个 Intent,为该服务提供要保存的数据。服务会通过 onStartCommand() 接收 Intent,连接到互联网并执行数据库事务。事务完成后,服务将自行停止并销毁。

如果服务在用户与来自同一应用的 Activity 进行交互时执行密集型或阻止性操作,则会降低 Activity 性能,这是因为默认情况下,服务与服务声明所在的应用运行于同一进程,并且运行于该应用的主线程中,这样服务大量消耗主线程有限的资源,从而影响应用正在运行的任何 Activity 的性能,自然就会影响应用的性能了。开发者要解决这个问题,一般情况会在服务内启动新线程。

通常要创建启动服务可以通过扩展两个类来实现:

Service:这是适用于所有服务的基类。扩展此类时必须创建用于执行所有服务工作的新线程,因为服务默认使用应用的主线程,这会降低应用正在运行的任何 Activity 的性能。

IntentService:这是 Service 的子类,其使用工作线程逐一处理所有启动请求。如果应用程序不要求服务同时处理多个请求,开发者会以此类为最佳选择。实现 onHandleIntent()(这个方法是在工作线程上调用一个需要处理的请求),该方法会接收每个启动请求的 Intent,以便开发者执行后台工作。

扩展 IntentService 类:

由于大多数启动服务无需同时处理多个请求(实际上,这种多线程情况可能很危险),因此最佳选择是利用 IntentService 类实现服务。

IntentService 类会执行以下操作:

  • 创建默认的工作线程,用于在应用的主线程外执行传递给 onStartCommand() 的所有 Intent。
  • 创建工作队列,用于将 Intent 逐一传递给 onHandleIntent() 实现,这样就永远不必担心多线程问题。
  • 在处理完所有启动请求后停止服务,因此就永远不必调用 stopSelf()
  • 提供 onBind() 的默认实现(返回 null),除非服务需要提供绑定,否则不需要实现这个方法,因为默认实现返回null。如果服务需要提供绑定,那么就会返回一个IBinder对象,客户端可以通过它调用服务。
  • 提供 onStartCommand() 的默认实现,可将 Intent 依次发送到工作队列和 onHandleIntent() 实现。

如要完成客户端提供的工作,就需要实现 onHandleIntent()。不过,还需要为服务提供小型构造函数。

以下是 IntentService 的实现示例:

public class HelloIntentService extends IntentService {

  /**
   * 必须提供一个构造函数,并且必须调用super <code><a href="/reference/android/app/IntentService.html#IntentService(java.lang.String)">IntentService(String)</a></code>
   * 构造函数,并为工作线程指定一个名称。
   */
  public HelloIntentService() {
      super("HelloIntentService");
  }

  /**
   * IntentService会在默认的工作线程中调用此方法,并传入启动服务的Intent。
   * 当此方法返回时,IntentService会根据需要停止服务。
   */
  @Override
  protected void onHandleIntent(Intent intent) {
      // 通常在这里会执行一些工作,比如下载文件。
      // 这个示例中,我们只是睡眠5秒钟。
      try {
          Thread.sleep(5000);
      } catch (InterruptedException e) {
          // 恢复中断状态。
          Thread.currentThread().interrupt();
      }
  }
}

只需要一个构造函数和一个 onHandleIntent() 实现即可。

如果还决定要重写其他回调方法(如 onCreate()onStartCommand()onDestroy()),需要确保调用超类(父类)实现,以便于IntentService 能够妥善处理工作线程的生命周期。

例如,onStartCommand() 必须返回默认实现,即如何将 Intent 传递给 onHandleIntent()

@Override
public int onStartCommand(Intent intent, int flags, int startId) {
    Toast.makeText(this, "service starting", Toast.LENGTH_SHORT).show();
    return super.onStartCommand(intent,flags,startId);
}

onHandleIntent() 之外,唯一一个无需从中调用超类的方法就是 onBind()。只有在服务允许绑定时,才需要实现该方法。

扩展服务类:

借助 IntentService,是可以非常轻松地实现启动服务。但是,若要求服务执行多线程(而非通过工作队列处理启动请求),则可通过扩展 Service 类来处理每个 Intent。

为了进行比较,以下示例代码展示了 Service 类的实现,该类执行的工作与上述使用 IntentService 的示例完全相同。换言之,对于每个启动请求,其均使用工作线程来执行作业,且每次仅处理一个请求。

public class HelloService extends Service {
  private Looper serviceLooper;
  private ServiceHandler serviceHandler;

  // 从线程接收消息的Handler
  private final class ServiceHandler extends Handler {
      public ServiceHandler(Looper looper) {
          super(looper);
      }
      @Override
      public void handleMessage(Message msg) {
          // 通常在这里会执行一些工作,比如下载文件。
          // 这个示例中,我们只是睡眠5秒钟。
          try {
              Thread.sleep(5000);
          } catch (InterruptedException e) {
              // 恢复中断状态。
              Thread.currentThread().interrupt();
          }
          // 使用startId停止服务,以确保我们不会在处理其他任务时停止服务
          stopSelf(msg.arg1);
      }
  }

  @Override
  public void onCreate() {
    // 启动运行服务的线程。注意,我们创建了一个单独的线程,因为服务通常在进程的主线程中运行,
    // 我们不希望阻塞主线程。我们还将其设置为后台优先级,以防止CPU密集型工作影响我们的UI。
    HandlerThread thread = new HandlerThread("ServiceStartArguments",
            Process.THREAD_PRIORITY_BACKGROUND);
    thread.start();

    // 获取HandlerThread的Looper,并将其用于我们的Handler
    serviceLooper = thread.getLooper();
    serviceHandler = new ServiceHandler(serviceLooper);
  }

  @Override
  public int onStartCommand(Intent intent, int flags, int startId) {
      Toast.makeText(this, "服务启动中", Toast.LENGTH_SHORT).show();

      // 对于每个启动请求,发送一条消息来启动一个任务,并传递startId,
      // 这样我们在完成任务后就知道要停止哪个请求
      Message msg = serviceHandler.obtainMessage();
      msg.arg1 = startId;
      serviceHandler.sendMessage(msg);

      // 如果我们被杀死,返回之后重新启动
      return START_STICKY;
  }

  @Override
  public IBinder onBind(Intent intent) {
      // 我们不提供绑定,所以返回null
      return null;
  }

  @Override
  public void onDestroy() {
    Toast.makeText(this, "服务已完成", Toast.LENGTH_SHORT).show();
  }
}

对比可以发现,相较于使用 IntentService,此示例需要执行更多工作、编写更繁多的代码。

但是,由于 onStartCommand() 的每个调用均有开发者自己处理,因此开发者可以同时执行多个请求。此示例并未这样做,但想要同时执行多个请求,则可以为每个请求创建新线程,然后立即运行这些线程(而非等待上一个请求完成)。

请注意,onStartCommand() 方法必须返回一个整数值。这个整数值用于描述系统在终止服务的情况下如何继续运行服务。IntentService 的默认实现会处理这种情况,但开发者可以进行修改。从 onStartCommand() 返回的值必须是以下常量之一:

  • START_NOT_STICKY

    如果系统在 onStartCommand() 返回后终止服务,则系统不会重新创建服务,除非有待处理的挂起 Intent。这是最安全的选项,可以避免在不需要运行服务的情况下重启服务,并且应用可以轻松地重新启动所有未完成的任务。

  • START_STICKY

    如果系统在 onStartCommand() 返回后终止服务,则系统会重新创建服务并调用 onStartCommand(),但不会重新传递最后一个 Intent。相反,系统会调用 onStartCommand() 并传递一个空的 Intent,除非有挂起的 Intent 需要启动服务。这种情况下,系统会传递这些 Intent。这个常量适用于不执行命令,但需要无限期运行并等待任务的媒体播放器(或类似服务)。

  • START_REDELIVER_INTENT

    如果系统在 onStartCommand() 返回后终止服务,则系统会重新创建服务并调用 onStartCommand(),并且会传递给服务最后一个 Intent。所有挂起的 Intent 会依次传递。这个常量适用于需要立即恢复执行的任务(例如下载文件)的服务。

四、启动服务:

我们了解完了如何通过扩展Service类和IntentService类来创建启动服务,接下来我们该了解一下要如何启动服务了。

  1. 绑定Service

调用者可以通过bindService()方法来绑定Service,该方法需要传入一个Intent对象和ServiceConnection对象。Intent对象用于指定要绑定的Service,ServiceConnection对象用于监听Service的连接状态。

当调用bindService()方法时,如果Service已经启动,则会调用Service的onBind()方法,返回一个IBinder对象。调用者可以通过该对象与Service进行通信。如果Service未启动,则会先启动Service,再调用onBind()方法。

  1. 解绑Service

调用者可以通过unbindService()方法来解绑Service,该方法需要传入一个ServiceConnection对象。当调用unbindService()方法时,会调用ServiceConnection对象的onServiceDisconnected()方法,表示Service已经断开连接。

如果Service没有与其他组件绑定,则会调用Service的onUnbind()方法,表示Service已经解绑。如果Service没有其他工作要做,则可以在onUnbind()方法中停止Service,释放资源。

需要注意的是,当调用者与Service绑定后,需要及时解绑,否则会导致Service一直存在,浪费系统资源。在解绑Service时,需要确保所有与Service绑定的组件都已经解绑,否则Service不会被销毁。

五、停止服务:

启动的Service需要自行管理自己的生命周期。也就是说,除非必须释放内存资源,否则系统不会主动停止或销毁Service。Service在onStartCommand()方法返回后仍然会继续运行。要停止Service的运行,可以通过调用stopSelf()方法自行停止,或者由另一个组件通过调用stopService()方法来停止。

一旦调用了stopSelf()stopService()来停止Service,系统会尽快销毁Service。

如果Service同时处理多个onStartCommand()请求,那么在处理完一个启动请求后就立即停止Service是不合适的,因为可能会收到新的启动请求(在第一个请求结束时停止Service会终止第二个请求)。为了避免这个问题,可以使用stopSelf(int)方法,确保停止请求始终基于最近的启动请求。也就是说,在调用stopSelf(int)方法时,需要传递与停止请求ID相对应的启动请求ID(即传递给onStartCommand()startId)。如果Service在能够调用stopSelf(int)之前收到新的启动请求,则ID不匹配,Service也不会停止。

需要注意的是,为了避免浪费系统资源和耗电,务必确保在任务完成后停止Service。如果需要,其他组件可以通过调用stopService()来停止Service。即使Service启用了绑定,如果Service收到onStartCommand()的调用,仍然需要手动停止Service。

六、创建绑定服务:

绑定服务允许应用组件通过调用bindService()方法与其进行绑定,从而创建一个长期的连接。通常情况下,这种服务不允许组件通过调用startService()方法来启动它。

如果需要与Activity和其他应用组件进行交互,或者需要通过进程间通信(IPC)向其他应用公开某些应用功能,那么就应该创建绑定服务。

要创建绑定服务,开发者是需要实现onBind()回调方法的,实现onBind()回调方法会返回一个IBinder对象,以定义与服务进行通信的接口。然后,其他应用组件可以通过调用bindService()方法来获取该接口,并开始调用与服务相关的方法。绑定服务只会为绑定到它的应用组件提供服务,所以如果没有组件与该服务绑定,系统就会销毁该服务。与启动服务不同,开发者是不需要以相同的方式停止绑定服务。

要创建绑定服务,开发者就必须定义一个接口,指定客户端如何与服务进行通信。这个接口必须是IBinder的实现,并且服务必须从onBind()回调方法返回该接口。客户端收到IBinder后,就可以开始通过该接口与服务进行交互。

多个客户端可以同时绑定到同一个服务。当与服务的交互完成后,客户端可以通过调用unbindService()方法来解除绑定。如果没有绑定到服务的客户端,系统就会销毁该服务。

实现绑定服务有多种方法,比启动服务更复杂。因此,如果你对此有兴趣建议你可以查阅相关文档,详细了解如何实现绑定服务。

七、在前台运行服务:

前台服务是用户主动意识到的一种服务,系统不会在内存不足时终止它。前台服务必须在状态栏中显示通知,并将其放在正在运行的标题下方。这意味着除非停止服务或将其从前台移除,否则无法清除通知。

请注意,应该限制应用使用前台服务的频率。

只有当应用执行的任务需要用户查看(即使该任务未直接与应用交互)时,才应该使用前台服务。因此,前台服务的通知必须具有PRIORITY_LOW或更高的优先级,以确保用户了解应用正在进行的任务。如果某个操作不是特别重要,开发者希望使用最低优先级的通知,那么前台服务可能就不适合,相反,开发者可以考虑使用后台任务处理。

每个运行服务的应用都会增加系统的负担,并消耗系统资源。如果应用尝试使用低优先级的通知隐藏其服务,可能会降低用户正在主动交互的应用的性能。因此,如果某个应用尝试运行拥有最低优先级通知的服务,则系统会在抽屉式通知栏的底部调用出该应用的行为。

比如,如果一个音乐播放器应用通过服务播放音乐,应将其设置为在前台运行,这样用户会明确意识到音乐播放器正在运行。状态栏中的通知可以显示正在播放的歌曲,并且允许用户通过启动Activity与音乐播放器进行交互。同样,如果应用允许用户追踪其位置,需要通过前台服务来追踪用户的位置。

请注意,在开发时如果应用面向Android 9(API级别28)或更高版本,并且使用前台服务,必须请求"FOREGROUND_SERVICE"权限。这是一种普通权限,系统会自动为请求此权限的应用授予它。如果面向API级别28或更高版本的应用尝试创建前台服务但未请求"FOREGROUND_SERVICE"权限,系统会抛出SecurityException异常。

要请求服务在前台运行,可以调用startForeground()方法。此方法接受两个参数:唯一标识通知的整型数和用于状态栏的Notification对象。此通知必须具有PRIORITY_LOW或更高的优先级。

要从前台移除服务,可以调用stopForeground()方法。此方法接受一个布尔值,指示是否同时移除状态栏通知。调用此方法不会停止服务,但是如果在服务仍然在前台运行时停止它,通知也会被移除。

以下是一个示例,展示了如何使用Kotlin语言创建前台服务和通知:

Intent notificationIntent = new Intent(this, ExampleActivity.class);
PendingIntent pendingIntent =
        PendingIntent.getActivity(this, 0, notificationIntent, 0);

Notification notification =
          new Notification.Builder(this, CHANNEL_DEFAULT_IMPORTANCE)
    .setContentTitle(getText(R.string.notification_title))
    .setContentText(getText(R.string.notification_message))
    .setSmallIcon(R.drawable.icon)
    .setContentIntent(pendingIntent)
    .setTicker(getText(R.string.ticker_text))
    .build();

startForeground(ONGOING_NOTIFICATION_ID, notification);

请注意,传递给startForeground()方法的整型ID不能为0。

如要从前台移除服务,可以调用 stopForeground()。此方法采用布尔值,指示是否需同时移除状态栏通知。此方法不会停止服务。但是,如果开发者在服务仍运行于前台时将其停止,则通知也会随之移除。

八、管理服务的生命周期:

服务的生命周期比Activity的生命周期要简单得多。服务可以在后台运行而不被用户察觉,因此比起Activity,服务更加重要的是密切关注服务的创建和销毁。

服务的生命周期可以遵循以下两种路径之一:

  1. 启动服务:服务在其他组件调用startService()方法时被创建,并且会一直运行下去,直到调用stopSelf()方法停止自己的运行,或者其他组件调用stopService()方法停止该服务。一旦服务停止,系统会销毁它。
  2. 绑定服务:服务在其他组件(客户端)调用bindService()方法时被创建。客户端通过IBinder接口与服务进行通信。客户端可以通过调用unbindService()方法关闭连接。多个客户端可以绑定到同一个服务,当所有绑定都取消后,系统会销毁该服务。(服务不需要自己停止运行)

这两种路径并不是完全独立的。开发者是可以绑定到已经通过startService()方法启动的服务。例如,开发者可以使用Intent来调用startService()方法启动后台音乐服务。然后,当用户需要对播放器进行控制或获取当前播放歌曲的信息时,Activity可以通过调用bindService()方法绑定到该服务。在所有客户端取消绑定之前,stopService()或stopSelf()方法实际上不会停止服务。

与Activity类似,服务也有生命周期回调方法,开发者可以通过实现这些方法来监控服务状态的变化并执行相应的工作。以下是一个展示了每个生命周期方法的服务示例:

public class ExampleService extends Service {
    int startMode;       // 表示服务被杀死时的行为方式
    IBinder binder;      // 用于与绑定客户端通信的接口
    boolean allowRebind; // 表示是否应该使用onRebind方法

    @Override
    public void onCreate() {
        // 服务被创建
    }
    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        // 服务正在启动,由于调用了startService()方法
        return startMode;
    }
    @Override
    public IBinder onBind(Intent intent) {
        // 有客户端通过bindService()方法绑定到服务
        return binder;
    }
    @Override
    public boolean onUnbind(Intent intent) {
        // 所有客户端都通过unbindService()方法取消了绑定
        return allowRebind;
    }
    @Override
    public void onRebind(Intent intent) {
        // 一个客户端通过bindService()方法重新绑定到服务,之前已经调用了onUnbind()方法
    }
    @Override
    public void onDestroy() {
        // 服务不再被使用,正在被销毁
    }
}

请注意,与Activity的生命周期回调方法不同,开发者是不需要调用这些回调方法的超类实现。

以下为官方给出的服务生命周期图示:
image-20231115210624912.png

图左显示的是使用 startService() 创建的服务的生命周期,图右显示的是使用 bindService() 创建的服务的生命周期。

上图展示服务的典型回调方法。尽管该图分开介绍通过 startService() 创建的服务和通过 bindService() 创建的服务,但还请记住,不管服务是通过startService()还是bindService()启动的,都可以允许客户端与其进行绑定。这意味着,即使最初是通过客户端调用startService()来启动服务的,该服务仍然可以接收到客户端调用bindService()时的onBind()回调。换句话说,无论服务是通过哪种方式启动的,都可以允许客户端进行绑定操作。

通过实现服务生命周期的这些回调方法,开发者可以监控服务生命周期的以下两种嵌套循环:

1、服务的整个生命周期在调用 onCreate() 和返回 onDestroy() 之间的这段时间。与 Activity 类似,服务也在 onCreate() 中完成初始设置,并在 onDestroy() 中释放所有剩余资源。例如,音乐播放服务可以在 onCreate() 中创建用于播放音乐的线程,然后在 onDestroy() 中停止该线程。 需要注意的是,无论所有服务是通过 startService() 还是 bindService() 创建,系统均会为其调用 onCreate() 和 onDestroy() 方法。

2、服务的活动生命周期从调用 onStartCommand() 或 onBind() 开始。每种方法均会获得 Intent 对象,该对象会传递至 startService() 或 bindService()。 对于启动服务,活动生命周期与整个生命周期会同时结束(即便是在 onStartCommand() 返回之后,服务仍然处于活动状态)。对于绑定服务,活动生命周期会在 onUnbind() 返回时结束。

需要注意的是:尽管开发者需要通过调用 stopSelf() 或 stopService() 来停止绑定服务,但该服务并没有相应的回调(没有 onStop() 回调)。除非服务绑定到客户端,否则在服务停止时,系统会将其销毁(onDestroy() 是接收到的唯一回调)。

到此安卓四大组件之一的服务就讲解完毕了,下面就开始讲解安卓四大组件之一的广播接收器了。

BroadcastReceiver

BroadcastReceiver是Android四大组件之一,Android 应用与 Android 系统和其他 Android 应用之间可以相互收发广播消息,而Android 应用与 Android 系统和其他 Android 应用之间可以相互收发广播消息的主要作用是用于接收系统或其他应用程序发送的广播消息。广播会在所关注的事件发生时发送,而BroadcastReceiver可以接收系统事件(如电池电量低、网络状态变化等)和应用程序自定义的事件(如自定义的通知、定时器等)发生时发送的广播,并根据需要进行处理。

应用可以注册接收特定的广播。广播发出后,系统会自动将广播传送给同意接收这种广播的应用。

系统会在发生各种系统事件时,自动发送广播消息给所有同意接收相关事件的应用程序。这些广播消息会被封装在一个叫做Intent的对象中,其中该对象的操作字符串用于标识所发生的事件(比如android.intent.action.AIRPLANE_MODE)。除此之外,Intent对象还可能包含一些额外的信息,这些信息被称为extra。例如,飞行模式的Intent可能包含一个布尔值的extra字段,用于指示飞行模式是否已经开启。

而有关系统广播操作的完整列表会在Android SDK 中的 BROADCAST_ACTIONS.TXT 文件中找到, BROADCAST_ACTIONS.TXT 文件的路径如下:

<Android SDK>/platforms/<任意android api 版本>/ data/ broadcast_actions.txt

每个广播操作都有一个与之关联的常量字段,例如,常量 ACTION_AIRPLANE_MODE_CHANGED 的值:android.intent.action.AIRPLANE_MODE

下面就举几个Android系统广播的例子:

  • android.intent.action.BATTERY_CHANGED:当设备的电池状态发生变化时发出的广播。
  • android.intent.action.AIRPLANE_MODE:当设备的飞行模式状态发生变化时发出的广播。
  • android.intent.action.ACTION_POWER_CONNECTED:当设备连接到电源时发出的广播。
  • android.intent.action.DEVICE_STORAGE_LOW:当设备存储空间不足时发出的广播。
  • android.intent.action.PACKAGE_INSTALL:当应用程序被安装时发出的广播。

以上就是安卓系统广播操作的完整列表中随意选取的几个广播操作,以及与之关联的常量字段所标识要发生的事件。

安卓平台一直在发展,而系统广播的行为方式也会不定期地更改,比如:

从 Android 9(API 级别 28)开始,NETWORK_STATE_CHANGED_ACTION 广播不再接收有关用户位置或个人身份数据的信息。NETWORK_STATE_CHANGED_ACTION是一个用于指示网络状态发生了变化的系统广播。当设备的网络连接状态发生变化时,系统会发送这个广播消息给所有注册了接收网络状态变化事件的应用程序。并且如果应用程序安装在搭载 Android 9 或更高版本的设备上,应用程序通过 WLAN 接收的系统广播不包含 SSID、BSSID、连接信息或扫描结果。应用程序开发者想要获取这些信息,则需要调用 getConnectionInfo()

从 Android 8.0(API 级别 26)开始,系统对静态注册BroadcastReceiver施加了额外的限制。因为在之前的版本中,无论是静态注册BroadcastReceiver、还是动态注册BroadcastReceiver都能监听到的系统广播,这样就导致大量的恶意APP利用这个机制在程序未启动的情况下监听系统广播,这样就会导致任何应用都可以频繁的从后台被唤醒,极大的影响了手机电量和性能。而在Android 8.0(API 级别 26)这一点迎来了巨大的削弱,安卓直接让开发者对于大多数隐式广播(没有明确针对某个应用的广播)不能使用静态注册的方式来声明广播接收器,但是当用户正在活跃地使用应用程序时,开发者仍可以使用动态注册的接收器。

BroadcastReceiver的工作原理是通过注册广播接收器来接收广播消息。当有广播消息到达时,系统会调用BroadcastReceiver的onReceive()方法来处理消息。onReceive()方法可以获取到广播消息的内容,并根据需要进行处理。

应用程序可以通过两种方式接收广播:清单声明的接收器(静态注册)和上下文注册的接收器(动态注册)。

我们先讲讲静态注册广播接收器,要静态注册广播接收器可以分为两个步骤,第一步要在应用清单文件中指定<receiver>元素,第二步需要创建 BroadcastReceiver 子类并实现 onReceive(Context, Intent)

在应用清单文件中指定<receiver>元素:

    <receiver android:name=".MyBroadcastReceiver"  android:exported="true">
        <intent-filter>
            <action android:name="android.intent.action.BOOT_COMPLETED"/>
            <action android:name="android.intent.action.INPUT_METHOD_CHANGED" />
        </intent-filter>
    </receiver>

以上代码定义了一个广播接收器。我们可以从以上代码看出该接收器的类名是"MyBroadcastReceiver",并且设置了"exported"属性为"true",表示允许其他应用程序发送广播给该接收器。并且该接收器还定义了两个意图过滤器(intent-filter),分别监听两个不同的广播动作(action):

  1. "android.intent.action.BOOT_COMPLETED":当设备完成启动时发送的广播。
  2. "android.intent.action.INPUT_METHOD_CHANGED":当输入法发生改变时发送的广播。

通过定义这两个意图过滤器,该接收器可以接收到这两个广播动作对应的广播消息,并在接收到广播时可以通过广播接收器的onReceive()方法来执行相应的操作。

创建 BroadcastReceiver 子类并实现 onReceive(Context, Intent)

    public class MyBroadcastReceiver extends BroadcastReceiver {
            private static final String TAG = "MyBroadcastReceiver";
            @Override
            public void onReceive(Context context, Intent intent) {
                StringBuilder sb = new StringBuilder();
                sb.append("Action: " + intent.getAction() + "\n");
                sb.append("URI: " + intent.toUri(Intent.URI_INTENT_SCHEME).toString() + "\n");
                String log = sb.toString();
                Log.d(TAG, log);
                Toast.makeText(context, log, Toast.LENGTH_LONG).show();
            }
        }

以上代码是一个示例的广播接收器的实现类,它继承自BroadcastReceiver类,并重写了onReceive(Context, Intent)方法。

在以上代码的onReceive方法中,接收器会接收到传递过来的Context和Intent对象。它首先会将接收到的广播动作(action)和URI转换为字符串,并使用StringBuilder拼接成一个日志信息。然后,通过Log.d方法将日志信息输出到Logcat中,并使用Toast.makeText方法将日志信息以Toast的形式显示在屏幕上。

系统软件包管理器会在应用安装时注册接收器,这使得接收器成为应用的一个独立入口点。即使应用当前未运行,系统也可以通过接收器来启动应用并发送广播。

每次接收到广播时,系统会创建一个新的BroadcastReceiver组件对象来处理该广播。这个对象仅在调用onReceive(Context, Intent)方法期间有效。在onReceive方法中,接收器可以根据接收到的广播内容执行相应的操作。

一旦从onReceive方法返回代码,系统会认为该组件不再活跃。这意味着接收器的生命周期是短暂的,仅在处理广播时有效。如果应用需要在接收到广播后继续进行其他操作,可以考虑使用服务(Service)或者启动一个新的Activity来处理。

静态注册我们讲完了,下一步我们来讲讲动态注册,动态注册广播接收器也有两个步骤,第一步需要创建 BroadcastReceiver 的实例,

第二步需要创建 IntentFilter (意图过滤器)并调用 registerReceiver(BroadcastReceiver, IntentFilter) 来注册接收器。

创建 BroadcastReceiver 的实例:

BroadcastReceiver br = new MyBroadcastReceiver();

创建一个BroadcastReceiver对象br,并将其实例化为MyBroadcastReceiver的实例,MyBroadcastReceiver类必须继承自BroadcastReceiver类,并实现其onReceive()方法。

创建 IntentFilter 并调用 registerReceiver(BroadcastReceiver, IntentFilter) 来注册接收器:

IntentFilter filter = new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION);
filter.addAction(Intent.ACTION_AIRPLANE_MODE_CHANGED);
this.registerReceiver(br, filter);

只要动态注册有效,那么动态注册的接收器就会接收广播。例如,如果我们在Activity中动态注册广播接收器,只要该Activity没有被销毁,那么它就可以接收到广播事件。同样地,如果我们在应用中动态注册广播接收器,只要应用在运行,那么它就会接收到广播事件。

如果想要在Android中使用本地广播(LocalBroadcast)来发送和接收广播事件。与全局广播不同,本地广播只能在应用内部传递,不会被其他应用接收到。

要注册本地广播接收器,我们需要使用LocalBroadcastManager类的registerReceiver()方法。该方法接受两个参数:一个是BroadcastReceiver对象,用于接收广播事件;另一个是IntentFilter对象,用于指定要监听的广播动作。

如果想要停止接收广播,我们需要调用unregisterReceiver()方法,并将要注销的BroadcastReceiver对象作为参数传递给它。

在适当的位置调用unregisterReceiver()方法非常重要,以确保在不需要接收广播或上下文无效时及时注销接收器。例如,如果我们在Activity的onCreate()方法中注册广播接收器,我们应该在onDestroy()方法中调用unregisterReceiver()方法来注销接收器,以防止接收器从Activity上下文中泄露出去。同样地,如果我们在onResume()方法中注册接收器,我们应该在onPause()方法中调用unregisterReceiver()方法来注销接收器,以避免多次注册接收器和不必要的系统开销。

请注意,不应该在onSaveInstanceState()方法中注销接收器,因为如果用户在历史记录堆栈中后退,该方法不会被调用。

广播接收器的状态无论他是否在运行都会影响其所在进程的状态,而其所在进程的状态又会影响它被系统终结的可能性。例如,当进程执行接收器(即当前在运行其 onReceive() 方法中的代码)时,它被认为是前台进程。除非遇到极大的内存压力,否则系统会保持该进程运行。但是,一旦从 onReceive() 返回代码,BroadcastReceiver 就不再活跃。接收器的宿主进程变得与在其中运行的其他应用组件一样重要。

如果该进程仅托管静态注册的接收器,这对于用户从未与之互动或最近没有与之互动的应用很常见,则从 onReceive() 返回时,系统会将其进程视为低优先级进程,并可能会将其终止,以便将资源提供给其他更重要的进程使用。

因此,不应该在广播接收器中启动长时间运行的后台线程。一旦 onReceive() 完成,系统可以随时终止进程来回收内存,这也会终止进程中运行的派生线程。

为了避免这种情况,可以调用 goAsync() 方法来标记广播接收器需要更多时间来完成。这样,系统就会知道该进程将继续活跃地工作。另外,也可以使用 JobScheduler 从接收器调度 JobService,以便系统知道该进程将继续活跃地工作。

    public class MyBroadcastReceiver extends BroadcastReceiver {
        private static final String TAG = "MyBroadcastReceiver";

        @Override
        public void onReceive(Context context, Intent intent) {
            final PendingResult pendingResult = goAsync();
            Task asyncTask = new Task(pendingResult, intent);
            asyncTask.execute();
        }

        private static class Task extends AsyncTask<String, Integer, String> {

            private final PendingResult pendingResult;
            private final Intent intent;

            private Task(PendingResult pendingResult, Intent intent) {
                this.pendingResult = pendingResult;
                this.intent = intent;
            }

            @Override
            protected String doInBackground(String... strings) {
                StringBuilder sb = new StringBuilder();
                sb.append("Action: " + intent.getAction() + "\n");
                sb.append("URI: " + intent.toUri(Intent.URI_INTENT_SCHEME).toString() + "\n");
                String log = sb.toString();
                Log.d(TAG, log);
                return log;
            }

            @Override
            protected void onPostExecute(String s) {
                super.onPostExecute(s);
                // Must call finish() so the BroadcastReceiver can be recycled.
                pendingResult.finish();
            }
        }
    }

以上代码段展示了一个使用 goAsync() 方法的 BroadcastReceiver 的示例。它在 onReceive() 中调用 goAsync() 方法,并通过 AsyncTask 在后台线程中执行耗时操作。在 onPostExecute() 方法中,调用 pendingResult.finish() 来标记广播接收器已完成处理。这种做法非常有用,特别是当在 onReceive() 中完成的工作很长,足以导致界面线程丢帧 (>16ms)时。通过将工作放在后台线程中处理,可以避免界面线程的阻塞,提高应用的响应性能。

我们已经学会了如何接收广播,接下来我们需要了解开发者在开发安卓APP时如何发送广播。广播是一种事件通知机制,开发者可以通过发送广播来通知其他应用程序或组件发生了某个事件。发送广播时,开发者会创建一个Intent对象,并指定一个特定的Action标识符,表示要发送的广播类型。其他应用程序或组件可以通过注册对应的广播接收器,并指定相同的Action标识符来接收该广播。当某个事件触发时,安卓系统会将该广播发送给所有注册了对应Action的广播接收器,接收器可以根据接收到的广播类型进行相应的处理。广播的内容可以包含额外的数据,可以通过Intent的putExtra方法来添加。

Android 为应用提供三种方式来发送广播,以下是安卓开发者官方文档关于发送广播的阐述:

  • sendOrderedBroadcast(Intent, String) 方法一次向一个接收器发送广播。当接收器逐个顺序执行时,接收器可以向下传递结果,也可以完全中止广播,使其不再传递给其他接收器。接收器的运行顺序可以通过匹配的 intent-filter 的 android:priority 属性来控制(android:priority 属性表示等级,值是-1000到1000,默认是0);具有相同优先级的接收器将按随机顺序运行。
  • sendBroadcast(Intent) 方法会按随机的顺序向所有接收器发送广播。这称为常规广播。这种方法效率更高,但也意味着接收器无法从其他接收器读取结果,无法传递从广播中收到的数据,也无法中止广播。
  • LocalBroadcastManager.sendBroadcast 方法会将广播发送给与发送器位于同一应用中的接收器。如果您不需要跨应用发送广播,请使用本地广播。这种实现方法的效率更高(无需进行进程间通信),而且您无需担心其他应用在收发您的广播时带来的任何安全问题。

以下代码段展示了如何通过创建 Intent 并调用 sendBroadcast(Intent) 来发送广播。

    Intent intent = new Intent();
    intent.setAction("com.example.broadcast.MY_NOTIFICATION");
    intent.putExtra("data","Notice me senpai!");
    sendBroadcast(intent);

广播消息封装在 Intent 对象中。Intent 的操作字符串必须提供应用的 Java 软件包名称语法,并唯一标识广播事件。您可以使用 putExtra(String, Bundle) 向 intent 附加其他信息。您也可以对 intent 调用 setPackage(String),将广播限定到同一组织中的一组应用。

注意:虽然 intent 既用于发送广播,也用于通过 startActivity(Intent) 启动 Activity,但这两种操作是完全无关的。广播接收器无法查看或捕获用于启动 Activity 的 intent;同样,当您广播 intent 时,也无法找到或启动 Activity。

以上就是安卓开发者官方文档对发送广播的阐述,这里讲的还是算通俗易懂的。然而除了发送和接收广播,开发者还可以通过权限限制广播,下面就讲讲开发者是怎么通过权限限制广播的:

开发者可以通过权限将广播限定到拥有特定权限的一组应用。开发者可以对广播的发送器或接收器施加限制。那么接下来就讲讲开发者是如何带权限的发送广播和带权限的接收广播的。

开发者想要带权限的发送广播,在调用sendBroadcast(Intent, String)sendOrderedBroadcast(Intent, String, BroadcastReceiver, Handler, int, String, Bundle) 时,可以指定权限参数。当开发者带权限的发送广播后,如果广播接收器希望接收此广播,则必须通过其应用清单文件中的标记请求该权限(如果存在危险,则会被授予该权限)。换句话说,通过指定权限参数,开发者可以确保只有具有相应权限的接收器才能接收到该广播。例如,以下代码会带权限的发送广播:

    sendBroadcast(new Intent("com.example.NOTIFY"),
                  Manifest.permission.SEND_SMS);

要接收此广播,接收方应用必须请求如下权限:

<uses-permission android:name="android.permission.SEND_SMS"/>

开发者不仅可以指定现有的系统权限(如 SEND_SMS),也可以使用<permission>元素定义自定义权限,自定义权限格式为“包名.自定义的权限名”,权限名一般都是大写,跟action的格式是一样的。

还有一点要注意:自定义权限将在安装应用时注册。定义自定义权限的应用必须在使用自定义权限的应用之前安装。

现在知道了开发者是如何带权限的发送广播的,那开发者又是如何带权限的接收广播的就是下面所要讲的内容。

如果开发者在注册广播接收器时指定了权限参数(通过 registerReceiver(BroadcastReceiver, IntentFilter, String, Handler) 或清单中的<receiver>标记指定),则发送广播的一方必须通过其应用清单文件中的<uses-permission>标记请求该权限(如果存在危险,则会被授予该权限),才能向该接收器发送 Intent。

注意:一旦在发送广播的应用程序的清单文件中通过<uses-permission>标记请求了某个权限,即使该权限被认为是危险的,也会被系统授予该权限。

想要带权限的接收广播可以这样做,例如,假设开发者的接收方应用具有如下所示的清单声明的接收器:

<receiver android:name=".MyBroadcastReceiver"
              android:permission="android.permission.SEND_SMS">
        <intent-filter>
            <action android:name="android.intent.action.AIRPLANE_MODE"/>
        </intent-filter>
    </receiver>

或者开发者的接收方应用具有如下所示的上下文注册的接收器:

    IntentFilter filter = new IntentFilter(Intent.ACTION_AIRPLANE_MODE_CHANGED);
    registerReceiver(receiver, filter, Manifest.permission.SEND_SMS, null );

那么,发送方应用必须请求如下权限,才能向这些接收器发送广播:

<uses-permission android:name="android.permission.SEND_SMS"/>

以上内容都参考了安卓开发者官方文档中对广播的介绍,而关于广播接收器组件的最后就以官方文档中有关收发广播的一些安全注意事项和最佳做法作为结尾。

以下是有关收发广播的一些安全注意事项和最佳做法:

  • 如果您不需要向应用以外的组件发送广播,则可以使用支持库中提供的 LocalBroadcastManager 来收发本地广播。LocalBroadcastManager 效率更高(无需进行进程间通信),并且您无需考虑其他应用在收发您的广播时带来的任何安全问题。本地广播可在您的应用中作为通用的发布/订阅事件总线,而不会产生任何系统级广播开销。
  • 如果有许多应用在其清单中注册接收相同的广播,可能会导致系统启动大量应用,从而对设备性能和用户体验造成严重影响。为避免发生这种情况,请优先使用上下文注册而不是清单声明。有时,Android 系统本身会强制使用上下文注册的接收器。例如,CONNECTIVITY_ACTION 广播只会传送给上下文注册的接收器。
  • 请勿使用隐式 intent 广播敏感信息。任何注册接收广播的应用都可以读取这些信息。您可以通过以下三种方式控制哪些应用可以接收您的广播:
    • 您可以在发送广播时指定权限。
    • 在 Android 4.0 及更高版本中,您可以在发送广播时使用 setPackage(String) 指定软件包。系统会将广播限定到与该软件包匹配的一组应用。
    • 您可以使用 LocalBroadcastManager 发送本地广播。
  • 当您注册接收器时,任何应用都可以向您应用的接收器发送潜在的恶意广播。您可以通过以下三种方式限制您的应用可以接收的广播:
    • 您可以在注册广播接收器时指定权限。
    • 对于清单声明的接收器,您可以在清单中将android:exported属性设置为“false”。这样一来,接收器就不会接收来自应用外部的广播。
    • 您可以使用 LocalBroadcastManager 限制您的应用只接收本地广播。
  • 广播操作的命名空间是全局性的。请确保在您自己的命名空间中编写操作名称和其他字符串,否则可能会无意中与其他应用发生冲突。
  • 由于接收器的 onReceive(Context, Intent) 方法在主线程上运行,因此它会快速执行并返回。如果您需要执行长时间运行的工作,请谨慎生成线程或启动后台服务,因为系统可能会在 onReceive() 返回后终止整个进程。如需了解详情,请参阅对进程状态的影响。要执行长时间运行的工作,我们建议:
    • 在接收器的 onReceive() 方法中调用 goAsync(),并将 BroadcastReceiver.PendingResult 传递给后台线程。这样,在从 onReceive() 返回后,广播仍可保持活跃状态。不过,即使采用这种方法,系统仍希望您非常快速地完成广播(在 10 秒以内)。为避免影响主线程,它允许您将工作移到另一个线程。
    • 使用 JobScheduler 调度作业。如需了解详情,请参阅智能作业调度
  • 请勿从广播接收器启动 Activity,否则会影响用户体验,尤其是有多个接收器时。相反,可以考虑显示通知。

Content Provider

Content Provider是Android四大组件之一,内容提供者对中央数据存储区的访问。一般会在以下两种场景中使用内容提供者:一种是通过实现代码访问其他应用中的现有内容提供者;另一种是在应用中创建新的内容提供者,从而与其他应用共享数据。

对于内容提供者还是先从基础开始了解,我们先从宏观的角度上来看看内容提供者的执行流程是什么样的,内容提供者以一个或多个表的形式将数据呈现给外部应用,这些表与关系型数据库中的表类似。行表示内容提供者收集的某种类型数据的实例,行中的每一列表示为一个实例所收集的单个数据。内容提供者协调很多不同的 API 和组件对应用数据存储层的访问,以便实现以下功能:

  • 与其他应用共享对应用数据的访问
  • 向微件发送数据
  • 使用 SearchRecentSuggestionsProvider,通过搜索框架返回对应用的自定义搜索建议
  • 通过实现 AbstractThreadedSyncAdapter,将应用数据与服务器同步
  • 使用 CursorLoader 在界面中加载数据

以下是官方给出的内容提供者与其他组件的关系:
image-20231115210854628.png

如果想要访问内容提供者当中的数据,那么开发者会以客户端的形式使用应用的 Context 中的 ContentResolver 对象与内容提供者进行通信。ContentResolver 对象会与内容提供者的实例对象通信。内容提供者的实例对象从客户端接收数据请求、执行请求的操作并返回结果。此对象的某些方法可调用ContentProvider 某个具体子类的实例中的同名方法。ContentResolver 方法可以提供持久性存储空间的基本增删改查功能。

一般使用界面去访问内容提供者最常用的模式是使用 CursorLoader 在后台运行异步查询。在开发者的设计下界面中的 Activity会调用查询的 CursorLoader,而CursorLoader会转而使用 ContentResolver 获取 ContentProvider。这样程序就可以在用户使用界面的同时进行查询数据了。

以下是官方给出的内容提供者、其他类和存储空间之间的交互:
image-20231115211011178.png

开发者想要应用访问内容提供者,那么开发者通常需要在其清单文件中请求特定权限。安卓平台内置的内容提供者之一是用户字典,用于存储用户想要保存的非标准字词的拼写。这种内容提供者之一的用户字典其表中的数据可能是这样的:

字词 应用 ID 频率 语言区域 _ID
mapreduce user1 100 zh_CN 1
precompiler user14 200 fr_FR 2
applet user2 225 fr_CA 3
const user1 255 pt_BR 4
int user5 100 en_UK 5

在上表中,每行表示可能无法在标准字典中找到的字词实例。每列表示该字词的一些数据,如该字词首次出现时的语言区域。列标题是存储在内容提供者当中的列名称。其中_ID 列是该内容提供者自动维护的“主键”列。

开发者要从用户字典内容提供者当中获取字词及其语言区域的列表就需要调用 ContentResolver.query()query() 方法会调用用户字典内容提供者所定义的 ContentProvider.query() 方法。以下是调用该方法的演示:

// 查询用户字典并返回结果
cursor = getContentResolver().query(
    UserDictionary.Words.CONTENT_URI,   // words表的内容URI
    projection,                        // 为每行返回的列
    selectionClause,                   // 选择标准
    selectionArgs,                     // 选择标准
    sortOrder);                        // 返回行的排序顺序

下表是ContentProvider.query() 方法的详细参数以及如何匹配 SQL SELECT 语句:

query() 参数 SELECT 关键字/参数 备注
Uri FROM *table_name* Uri 映射至内容提供者中名为 table_name 的表。
projection *col,col,col,...* projection 是检索到的每个行所应包含的列的数组。
selection WHERE *col* = *value* selection 指定选择行的条件。
selectionArgs (没有完全等效项,选择参数会替换选择子句中的 ? 占位符。)
sortOrder ORDER BY *col,col,...* sortOrder 指定在返回的 Cursor 中各行的显示顺序。

当开发者调用客户端方法以访问内容提供者中的表时,需要用到一个名为内容 URI的东西,内容 URI用来在内容提供者中标识数据。在前面的代码行中需要传递给参数UserDictionary.Words.CONTENT_URI的就是包含用户字典的“字词”表的内容 URI。内容 URI 包括整个内容提供者的符号名称(其授权)和指向表的名称(路径),当我们使用 ContentResolver 对象执行查询操作时,它会解析出 URI 的授权部分,并将该授权与已知内容提供者的系统表进行比较,然后ContentResolver 可以将查询参数分派给正确的内容提供者。

ContentProvider 使用内容 URI 的路径部分选择需访问的表。通常,内容提供者会为其公开的每个表显示一条路径。比如前面那个用户字典表的完整 URI 是:

content://user_dictionary/words

这串URI是由三部分组成,分别是:

字符串 content://(架构)始终显示,并且会将其标识为内容 URI。

user_dictionary 字符串是内容提供者的授权。

words 字符串是表的路径。

开发者如果想要访问表中的单个行,那么他所写的内容提供者就会允许通过将 ID 值追加到 URI 末尾,例如,如需从用户字典中检索 _ID4 的行,可以使用以下内容 URI:

Uri singleUri = ContentUris.withAppendedId(UserDictionary.Words.CONTENT_URI,4);

对于内容提供者_ID 列是该内容提供者自动维护的“主键”列。所以在检索到很多行并且想要更新或删除其中某一行时,通常可以使用 ID 值。

UriUri.Builder 类包含一些便捷方法,可用于根据字符串构建格式规范的 URI 对象。ContentUris 类包含一些便捷方法,可用于将 ID 值轻松追加至 URI 末尾。前段代码使用 withAppendedId() 将 ID 追加至 UserDictionary 的内容 URI 末尾。

我们应该怎样从内容提供者中检索数据呢?接下来我们将以用户字典内容提供者为例,介绍如何从内容提供者当中检索数据。

为进行更加明确的说明,接下来的代码段将在“界面线程”上调用 ContentResolver.query()。但在开发者实际开发代码中,应当在单独的线程上异步执行查询。

如果想要从内容提供者检索数据,需要执行以下基本步骤:

  1. 请求对内容提供者的读取访问权限。
  2. 定义将查询发送至内容提供者的代码。

想要从内容提供者检索数据,那么第一步就是应用程序需要具备对内容提供者的“读取访问权限”。该权限无法在应用程序运行时请求该权限;而是需要使用 <uses-permission> 元素和内容提供者定义的准确权限名称,在清单文件中指明应用程序需要此权限。一旦在清单文件中指定了这个元素,那么应用程序就可以有效地请求这个权限了。当用户安装该应用时,他们会默认允许这个权限请求,也就是说他们会隐式地允许该应用访问内容提供者的数据。

作为内容提供者的应用程序可以指定其他应用访问内容提供者的数据所必需具有的权限。这些权限可以确保用户知道应用将尝试访问的数据。根据内容提供者的要求,其他应用可能会请求访问该内容提供者所需的权限。在安装应用时,用户会看到所请求的权限。

如果作为内容提供者的应用没有指定任何权限,其他应用将无法访问该内容提供者的数据。但是,作为内容提供者的应用中的组件始终具有完整的读取和写入访问权限。

还是以用户字典内容提供者为例,它需要android.permission.READ_USER_DICTIONARY权限才能从中检索数据。同时,它还有一个单独的android.permission.WRITE_USER_DICTIONARY权限,用于插入、更新或删除数据。

为了获取访问内容提供者所需的权限,应用需要在其清单文件中使用<uses-permission>元素来请求这些权限。当通过Android 软件包管理器安装应用时,用户必须批准应用请求的所有权限。如果用户批准了所有权限,软件包管理器将继续安装应用;如果用户未批准这些权限,软件包管理器将中止安装。

以下 <uses-permission> 元素会请求对用户字典内容提供者的读取访问权限:

<uses-permission android:name="android.permission.READ_USER_DICTIONARY" />

用户字典内容提供者在其清单文件中定义了权限 android.permission.READ_USER_DICTIONARY,这意味着其他应用需要请求此权限才能从该内容提供者中进行读取操作。

想要从内容提供者检索数据,那么下一步就是构建查询,以下代码定义了部分用于访问用户字典内容提供者的变量:

// 一个“projection”定义了将为每一行返回的列。
String[] mProjection =
{
    UserDictionary.Words._ID,    // 用于_ID列名的Contract类常量
    UserDictionary.Words.WORD,   // 用于WORD列名的Contract类常量
    UserDictionary.Words.LOCALE  // LOCALE列名的Contract类常量
};

// 定义一个包含选择子句的字符串
String selectionClause = null;

// 初始化一个数组以包含选择参数
String[] selectionArgs = {""};

下一个代码段还是以用户字典内容提供者为例,展示了如何使用ContentResolver.query()方法来查询用户字典内容提供者。查询类似于SQL查询,包括要返回的列集、选择条件和排序顺序。

查询应返回的列集称为projection,中文名译为投影,比如上面那个代码段变量 mProjection

当我们需要查询特定条件下的数据时,我们可以将查询条件拆分为选择子句和选择参数。

选择子句是由逻辑和布尔表达式、列名称以及值组成的表达式。它用于指定我们希望满足的条件。

选择参数是用于替换选择子句中的可替换参数的值。我们可以使用问号(?)作为占位符来表示可替换参数。查询方法会从选择参数数组中检索值,并将其替换到选择子句中的相应位置。

在以下代码中根据用户是否输入了字词来设置查询的选择子句和选择参数。

如果用户未输入字词,选择子句将设置为null。这意味着查询将不会对字词进行过滤,返回内容提供者中的所有字词。

如果用户输入了字词,选择子句将设置为UserDictionary.Words.WORD + " = ?"。这表示查询将只返回与用户输入的字词完全匹配的字词。

选择参数数组的第一个元素将设置为用户输入的字词。这样,查询方法将从选择参数数组中获取该值,并将其与选择子句中的占位符(?)进行匹配。

/*
 * 这里定义了一个包含一个元素的字符串数组,用于存储选择参数的值。
 */
String[] selectionArgs = {""};

// 从UI中获取一个单词
searchString = searchWord.getText().toString();

// 记得在这里插入代码来检查无效或恶意输入。

// 如果单词是空字符串,获取所有内容
if (TextUtils.isEmpty(searchString)) {
    // 将选择子句设置为null将返回所有单词
    selectionClause = null;
    selectionArgs[0] = "";

} else {
    // 构造一个选择子句,匹配用户输入的单词。
    selectionClause = UserDictionary.Words.WORD + " = ?";

    // 将用户输入的字符串移动到选择参数中。
    selectionArgs[0] = searchString;

}

// 对表进行查询,并返回一个Cursor对象
mCursor = getContentResolver().query(
    UserDictionary.Words.CONTENT_URI,  // 单词表的内容URI
    projection,                       // 每行返回的列
    selectionClause,                  // 要么为null,要么为用户输入的单词
    selectionArgs,                    // 要么为空,要么为用户输入的字符串
    sortOrder);                       // 返回行的排序顺序

// 一些内容提供者在发生错误时返回null,其他的则抛出异常
if (null == mCursor) {
    /*
     * 在这里插入代码来处理错误。确保不要使用cursor!你可以调用android.util.Log.e()来记录这个错误。
     *
     */
// 如果Cursor为空,表内容提供者没有找到匹配的结果
} else if (mCursor.getCount() < 1) {

    /*
     * 在这里插入代码来通知用户搜索无果。这不一定是一个错误。你可以提供给用户插入新行或重新输入搜索词的选项。
     */

} else {
    // 在这里插入代码来处理结果

}

此查询类似于以下 SQL 语句:

SELECT _ID, word, locale FROM words WHERE word = <userinput> ORDER BY word ASC;

如果内容提供者管理的数据存储在 SQL 数据库中,并且将不受信任的外部数据直接包含在原始 SQL 语句中,可能会导致 SQL 注入攻击。SQL 注入是一种安全漏洞,攻击者可以通过在 SQL 查询中插入恶意代码来执行未经授权的操作或获取敏感数据。

因此,为了防止 SQL 注入,应该使用可替换参数的方式来构建 SQL 查询语句,而不是直接将用户输入的数据拼接到原始 SQL 语句中。所以要避免SQL注入,开发者一般会考虑以下选择子句:

// 通过将用户的输入连接到列名来构造选择子句。
String selectionClause = "var = " + userInput;

如果我们允许用户的输入直接连接到 SQL 语句中,那么用户可以输入恶意的 SQL 代码,例如输入"nothing; DROP TABLE ;",这将生成一个选择子句"var = nothing; DROP TABLE ;"。由于选择子句是作为 SQL 语句进行处理的,这可能导致内容提供者清空基础 SQLite 数据库中的所有表。这是因为 DROP TABLE 是一个 SQL 命令,用于删除表。

为了避免这个问题,我们应该使用可替换参数的选择子句和单独的选择参数数组。这样,用户输入将直接用作查询的约束条件,而不会被解释为 SQL 语句的一部分。由于用户输入未被处理为 SQL 语句,所以无法注入恶意的 SQL 代码。因此,我们应该使用可替换参数的选择子句,而不是使用字符串拼接来包含用户输入:

// 通过使用可替换的参数构建一个选择子句。
String selectionClause =  "var = ?";

按如下所示设置选择参数数组:

// 可以定义一个数组来存储选择参数。
String[] selectionArgs = {""};

按如下所示将值放入选择参数数组:

// 将选择参数设置为用户的输入。
selectionArgs[0] = userInput;

无论数据存储在什么类型的数据库中,都应该优先使用将 "?" 作为可替换参数的方式来构建查询语句。即使数据存储在非 SQL 数据库中,也应该采用这种方式。

ContentResolver.query() 客户端方法始终会返回一个 Cursor 对象,其中包含查询的投影为匹配查询选择条件的行所指定的列。Cursor 对象提供了对包含的行和列进行随机读取访问的权限。通过使用 Cursor 的方法,开发者可以循环访问结果中的每一行,确定每个列的数据类型,从列中获取数据,并检查结果的其他属性。某些 Cursor 实现会在内容提供者的数据更改时自动更新对象,并在 Cursor 发生更改时触发观察程序对象中的方法。

需要注意的是,内容提供者可能会根据发出查询的对象的性质来限制对列的访问。例如,联系人内容提供者可能会限制只有同步适配器才能访问某些列,以防止将这些列返回给 Activity 或服务。

如果没有与选择条件匹配的行,内容提供者会返回一个 Cursor 对象,其 Cursor.getCount() 为 0(即空 Cursor)。

如果发生内部错误,查询结果会根据具体的内容提供者而定。它可能选择返回 null,或抛出一个 Exception。

由于 Cursor 是一个行的列表,因此将 Cursor 的内容显示出来的一种好方法是将其与 ListView 关联,使用 SimpleCursorAdapter。

以下代码段承接上个代码段的代码。它会创建一个包含由查询检索到的 CursorSimpleCursorAdapter 对象,并将此对象设置为 ListView 的适配器:

// 定义要从 Cursor 中检索并加载到输出行的列的列表
String[] wordListColumns =
{
    UserDictionary.Words.WORD,   // Contract类常量,包含单词列的名称
    UserDictionary.Words.LOCALE  // Contract类常量,包含区域设置列的名称
};

// 定义将为每行接收 Cursor 列的视图ID列表
int[] wordListItems = { R.id.dictWord, R.id.locale};

// 创建一个新的 SimpleCursorAdapter
cursorAdapter = new SimpleCursorAdapter(
    getApplicationContext(),               // 应用程序的 Context 对象
    R.layout.wordlistrow,                  // 用于 ListView 中的一行的 XML 布局
    mCursor,                               // 查询的结果
    wordListColumns,                      // Cursor 中的列名称的字符串数组
    wordListItems,                        // 行布局中的视图 ID 的整数数组
    0);                                    // 标志(通常不需要)

// 为 ListView 设置适配器
wordList.setAdapter(cursorAdapter);

如果要将 Cursor 与 ListView 结合使用,那么 Cursor 必须包含一个名为 "_ID" 的列。即使 ListView 不显示 "_ID" 列,之前的查询也会检索 "字词" 表中的该列。这个限制也解释了为什么大多数内容提供者的每个表都会有一个名为 "_ID" 的列。

您可以使用查询结果进行其他任务,而不仅仅是将其显示出来。例如,您可以从用户字典中检索拼写的单词,然后在其他内容提供者中查找这些单词的相关信息。要执行此操作,您需要遍历 Cursor 中的每一行,并提取所需的数据:

// 确定名为 "word" 的列的索引位置
int index = mCursor.getColumnIndex(UserDictionary.Words.WORD);

/*

 * 只有当 Cursor 有效时才执行。如果发生内部错误,用户字典内容提供者将返回 null。其内容提供者可能会抛出异常,而不是返回 null。
   */

if (mCursor != null) {
    /*
     * 移动到 Cursor 中的下一行。在 Cursor 的第一次移动之前,"行指针" 为 -1,如果您尝试在该位置检索数据,将会引发异常。
     */
    while (mCursor.moveToNext()) {

        // 从列中获取值
        newWord = mCursor.getString(index);

        // 在此处插入处理检索到的单词的代码。

        ...

        // 循环结束
    }

} else {

    // 在此处插入代码,如果 Cursor 为 null 或内容提供者抛出异常,则报告错误。

}

Cursor 实现包含多个“获取”方法,用于从对象中检索不同类型的数据。例如,上一个代码段使用 getString()。它们还具有 getType() 方法,返回该列的数据类型的值。

既然讲了查,那自然少不了增删改了,下面就讲讲如何插入、更新和删除数据。

与从内容提供者中获取数据类似,开发者也可以通过内容提供者客户端与内容提供者进行交互来修改数据。开发者可以使用传递给 ContentProvider 相应方法的参数来调用 ContentResolver 的方法。内容提供者客户端与内容提供者会自动处理安全性和进程间通信。

要插入数据到内容提供者中,开发者可以调用 ContentResolver.insert() 方法。这个方法会在内容提供者中插入一个新的行,并返回该行的内容 URI。下面的代码片段展示了如何将一个新的单词插入到用户字典内容提供者中:

// 定义一个新的 Uri 对象,用于接收插入操作的结果
Uri newUri;

...

// 定义一个对象来存储要插入的新值
ContentValues newValues = new ContentValues();

/*

 * 设置每一列的值,并插入单词。"put" 方法的参数是 "列名" 和 "值"
   */
   newValues.put(UserDictionary.Words.APP_ID, "example.user");
   newValues.put(UserDictionary.Words.LOCALE, "en_US");
   newValues.put(UserDictionary.Words.WORD, "insert");
   newValues.put(UserDictionary.Words.FREQUENCY, "100");

// 调用 ContentResolver 的 insert 方法将新值插入到用户字典内容提供者中,并返回新行的内容 URI
newUri = getContentResolver().insert(
    UserDictionary.Words.CONTENT_URI,   // 用户字典内容 URI
    newValues                          // 要插入的值
);

新行的数据会存储在一个名为 ContentValues 的对象中,该对象类似于单行 Cursor。开发者可以使用 ContentValues 对象来指定要插入的列和对应的值。不同列的数据类型可以不同,如果某个列不需要指定值,开发者可以使用 ContentValues.putNull() 将该列设置为 null。

在上述代码中,没有手动添加 _ID 列,因为系统会自动为每个插入的行分配一个唯一的 _ID 值。通常情况下,_ID 列会被用作表的主键。

newUri 中返回的内容 URI 会按以下格式标识新添加的行:

content://user_dictionary/words/<id_value>

<id_value> 是新行 _ID 的内容。大多数内容提供者能够自动识别这种格式的内容 URI,并在对应的行上执行所请求的操作。

如果开发者需要从返回的内容 URI 中获取 _ID 值,可以使用 ContentUris.parseId() 方法。该方法接收一个内容 URI,并从中提取出 _ID 值作为 long 类型返回。例如:

long id = ContentUris.parseId(newUri);

这样,开发者就可以获取到新插入行的 _ID 值,并进行后续的操作。

如果开发者想要更新数据库中的某一行数据,那么他可以使用ContentValues对象来执行插入操作,同时使用查询条件来选择需要更新的行。他可以使用ContentResolver.update()方法来实现这个功能。他只需要向待更新列的ContentValues对象中添加新的值即可。如果他想要清除某一列的内容,他可以将值设置为null。

以下代码段会更改所有语言区域包含“en”语言的行,添加 null 语言区域。返回值是已更新的行数:

// 定义一个对象来包含更新的值
ContentValues updateValues = new ContentValues();

// 定义选择条件来更新需要更新的行
String selectionClause = UserDictionary.Words.LOCALE + " LIKE ?";
String[] selectionArgs = {"en_%"};

// 定义一个变量来保存更新的行数
int rowsUpdated = 0;

...

/*

 * 设置更新的值并更新选定的单词。
   */
   updateValues.putNull(UserDictionary.Words.LOCALE);

rowsUpdated = getContentResolver().update(
    UserDictionary.Words.CONTENT_URI,   // 用户词典内容的URI
    updateValues,                      // 需要更新的列
    selectionClause,                   // 选择的列
    selectionArgs                      // 与之比较的值
);

开发者他在调用ContentResolver.update()方法时,还应该检查用户输入。

删除行的操作和查询行数据的操作类似。你需要指定选择条件来删除需要删除的行,然后客户端方法会返回已删除的行数。以下代码段会删除appid与"user"匹配的行,并返回已删除的行数。

// 定义选择条件来删除需要删除的行
String selectionClause = UserDictionary.Words.APP_ID + " LIKE ?";
String[] selectionArgs = {"user"};

// 定义一个变量来保存已删除的行数
int rowsDeleted = 0;

...

// 删除符合选择条件的单词
rowsDeleted = getContentResolver().delete(
    UserDictionary.Words.CONTENT_URI,   // 用户词典内容的URI
    selectionClause,                   // 选择的列
    selectionArgs                      // 与之比较的值
);

开发者他在调用ContentResolver.delete()方法时,应该检查用户输入,以确保输入的数据是合法的,避免出现意外的删除操作。

内容提供者可以提供多种不同的数据类型,包括整数、长整型、浮点型、长浮点型和二进制大型对象(BLOB)。开发者可以通过查看Cursor类的"获取"方法来了解可用的数据类型。

每个列的数据类型通常在内容提供者的文档中列出。例如,用户字典内容提供者协议类UserDictionary.Words的参考文档中列出了其数据类型。开发者也可以通过调用Cursor.getType()方法来确定数据类型。

除了基本数据类型外,内容提供者还会为每个内容URI维护MIME(多用途互联网邮件扩展)数据类型信息。开发者可以使用MIME类型信息来确定应用程序是否可以处理内容提供者提供的数据,或者根据MIME类型选择处理方式。在处理包含复杂数据结构或文件的内容提供者时,通常需要使用MIME类型。例如,联系人内容提供者中的ContactsContract.Data表使用MIME类型标记存储在每行中的联系人数据类型。要获取与内容URI对应的MIME类型,开发者会调用ContentResolver.getType()方法。

在应用开发中,有三种重要的替代方式可以访问内容提供者:

  1. 批量访问:可以使用ContentProviderOperation类的方法创建一批访问调用,然后通过ContentResolver.applyBatch()方法应用这些调用。这种方式可以一次性执行多个操作,提高效率。
  2. 异步查询:查询操作应该在单独的线程中进行,以避免阻塞主线程。一种常见的方法是使用CursorLoader对象进行异步查询。加载器指南中提供了示例代码,展示了如何执行异步查询操作。
  3. 通过Intent访问数据:虽然不能直接向内容提供者发送Intent,但可以向作为内容提供者的应用发送Intent,通常是修改内容提供者数据的最佳方式。这种方式可以通过Intent传递数据给作为内容提供者的应用,让其进行相应的操作。

接下来的内容将介绍如何使用Intent进行批量访问和修改数据。

批量访问内容提供者适用于以下情况:

  • 当需要插入大量行时,可以使用批量访问内容提供者来提高效率。
  • 当需要通过同一方法调用在多个表中插入行时,可以使用批量访问内容提供者来简化操作。
  • 当需要以事务(原子操作)的形式跨进程边界执行一组操作时,可以使用批量访问内容提供者。

要在批量模式下访问内容提供者,可以创建一个ContentProviderOperation对象数组,然后使用ContentResolver.applyBatch()方法将其发送给内容提供者。在调用applyBatch()方法时,需要传递内容提供者的授权,而不是特定的内容URI。这样,数组中的每个ContentProviderOperation对象都可以适用于其他表。调用applyBatch()方法会返回一个结果数组。

ContactsContract.RawContacts协议类的说明中包含了展示批量插入的代码段。通讯录管理器示例应用程序中的ContactAdder.java源文件中也包含了进行批量访问的示例。

Intent 可以提供对内容提供者的间接访问。即使应用没有访问权限,开发者也可通过以下这些方式允许用户访问内容提供者中的数据:从拥有权限的应用中返回结果 Intent,或者激活拥有权限的应用并允许用户使用该应用。

  • 通过临时权限获取访问权限
  • 使用其他应用
  • 使用帮助程序应用显示数据

如何通过临时权限获取访问权限:

即使开发者的应用程序没有适当的权限来直接访问内容提供者中的数据,但是他仍然可以通过以下方式间接访问数据:

  1. 将一个Intent发送给拥有权限的应用程序。
  2. 接收包含特定内容URI权限的结果Intent。

这些权限是针对特定内容URI的权限,将持续到接收该权限的Activity结束。拥有永久权限的应用程序会在结果Intent中设置标记,从而授予临时权限。

对于读取权限,可以使用FLAG_GRANT_READ_URI_PERMISSION标记。

对于写入权限,可以使用FLAG_GRANT_WRITE_URI_PERMISSION标记。

需要注意的是,如果内容URI中包含内容提供者的授权,这些标记不会提供对内容提供者的常规读取或写入访问权限。这些权限仅适用于URI本身,而不适用于内容提供者。

内容提供者通过使用 <provider> 元素的 android:grantUriPermission 属性和 <provider> 元素的 <grant-uri-permission> 子元素,在其清单文件中定义内容 URI 的 URI 权限。

假设开发者的应用程序没有READ_CONTACTS权限,但开发者他想要在联系人内容提供者中检索联系人的数据,特别是在向联系人发送电子生日祝福的应用程序中。为了实现这个目标,他可以按照以下步骤进行操作:

  1. 开发者的应用程序使用startActivityForResult()方法发送一个包含ACTION_PICK操作和CONTENT_ITEM_TYPE为"联系人"的MIME类型的Intent。
  2. 由于此Intent与联系人应用程序中的"选择"Activity的Intent过滤器匹配,所以该Activity将显示在前台。
  3. 在选择Activity中,用户将选择要更新的联系人。当发生这种情况时,选择Activity将调用setResult(resultcode, intent)来设置用于返回到开发者的应用程序的Intent。该Intent包含用户选择的联系人的内容URI,以及额外的标记FLAG_GRANT_READ_URI_PERMISSION。这些标记将为开发者的应用程序授予对内容URI的权限,以便读取指向联系人数据的内容URI。然后,选择Activity将调用finish()来将控制权交还给开发者的应用程序。
  4. 开发者的Activity将返回到前台,并且系统将调用该Activity的onActivityResult()方法。该方法将接收到来自联系人应用程序中选择Activity所创建的结果Intent。
  5. 通过结果Intent中的内容URI,开发者可以读取来自联系人内容提供者的联系人数据,即使开发者在清单文件中没有请求对该内容提供者的永久读取访问权限。开发者也可以获取联系人的生日信息或电子邮件地址,然后发送电子祝福。

通过这种方式,开发者他可以让用户控制应用程序所使用的联系人,而不是通过请求READ_CONTACTS权限来访问用户的所有联系人和信息。开发者使用这种方法可以让用户拥有更好的用户体验和隐私控制。

如何使用其他应用允许用户访问内容提供者中的数据:

如果开发者没有权限修改某些数据,但想要允许用户进行修改,一种简单的方法是激活具有相应权限的应用,并让用户使用该应用来执行修改操作。

举个例子,假设开发者他想要修改日历中的事件,但他的应用程序没有对日历内容提供者的写入权限。在这种情况下,他可以通过发送一个ACTION_INSERT的Intent来激活具有写入权限的日历应用的插入界面。他可以在这个Intent中传递额外的数据,这些数据将被用来预填充插入界面。由于周期性事件的语法比较复杂,如果要插入一个周期性事件到日历内容提供者中,最好的方法是激活具有ACTION_INSERT权限的日历应用,并让用户在该应用中插入事件。

这样做的好处是,开发者他可以利用具有权限的应用程序的功能和界面来修改数据,而不是自己实现修改功能。这样对开发者来说可以简化开发过程,而对用户来说可以拥有更好的用户体验。

如何使用帮助程序应用显示数据:

即使开发者他的应用程序具有访问权限,他仍然可以使用Intent在其他应用程序中显示数据。例如,日历应用程序接受ACTION_VIEW Intent,以便他可以通过该Intent在日历应用程序中显示特定日期或事件的详细信息,而无需创建自己的界面。

另外,如果开发者他想要将数据显示在其他应用程序中,但该应用程序并未与开发者的数据内容提供者关联,他可以使用Intent将数据发送给该应用程序。例如,他可以从联系人内容提供者中检索联系人数据,并使用ACTION_VIEW Intent将联系人图像的内容URI发送给图像查看器应用程序。这样,图像查看器应用程序将会显示该联系人的图像,即使它并未直接与联系人内容提供者关联。

协定类是为了帮助应用程序使用内容 URI、列名称、Intent操作和其他内容提供者功能而定义的一些常量。内容提供者不会自动包含这些协定类,所以内容提供者的开发者需要自己定义这些类,并将其提供给其他开发者使用。在Android平台上,许多内容提供者都在android.provider软件包中有对应的协定类。

举个例子,用户字典内容提供者有一个名为UserDictionary的协定类,其中包含了内容URI和列名称的常量。常量UserDictionary.Words.CONTENT_URI定义了“字词”表的内容URI。UserDictionary.Words类还包含了列名称的常量,示例代码段中使用了这些常量。例如,查询投影可定义如下:

String[] projection =
{
    UserDictionary.Words._ID,
    UserDictionary.Words.WORD,
    UserDictionary.Words.LOCALE
};

联系人内容提供者的ContactsContract也是一个协定类。ContactsContract类的参考文档中包含了示例代码段。ContactsContract类有一个子类ContactsContract.Intents.Insert,它也是一个协定类,包含了Intent和Intent数据的常量。

ContactsContract类是Android提供的用于访问联系人数据的协定类。通过使用ContactsContract类,开发者可以方便地查询、插入、更新和删除联系人数据。在查询联系人数据时,可以使用投影(projection)来指定返回的列,并使用选择(selection)和排序(sort order)来过滤和排序结果。示例代码段中展示了如何定义一个投影,其中包含了要返回的列的名称。

ContactsContract.Intents.Insert是一个用于插入联系人数据的协定类。它包含了一些Intent和Intent数据的常量,用于定义插入联系人的操作和数据。通过使用这些常量,开发者可以方便地创建一个插入联系人的Intent,并设置相应的数据。

内容提供者可以返回标准 MIME 媒体类型和/或自定义 MIME 类型字符串。

MIME 类型采用以下格式

type/subtype

例如,众所周知的 MIME 类型 text/html 具有 text 类型和 html 子类型。如果内容提供者为 URI 返回此类型,这意味着使用该 URI 的查询会返回包含 HTML 标记的文本。

自定义 MIME 类型字符串(也称为“特定于供应商”的 MIME 类型)具有更加复杂的类型和子类型值。类型值始终为

vnd.android.cursor.dir

(多行)或

vnd.android.cursor.item

(单行)。

子类型取决于内容提供者。Android 内置内容提供者通常拥有简单的子类型。例如,当“通讯录”应用为电话号码创建行时,它会在行中设置以下 MIME 类型:

vnd.android.cursor.item/phone_v2

请注意,子类型值只是 phone_v2

其他内容提供者开发者可能会根据内容提供者的授权和表名称创建自己的子类型模式。例如,假设内容提供者包含列车时刻表。内容提供者的授权是 com.example.trains,并包含表 Line1、Line2 和 Line3。在响应表 Line1 的内容 URI

content://com.example.trains/Line1

时,内容提供者会返回 MIME 类型

vnd.android.cursor.dir/vnd.example.line1

在响应表 Line2 第 5 行的内容 URI

content://com.example.trains/Line2/5

时,内容提供者会返回 MIME 类型

vnd.android.cursor.item/vnd.example.line2

大多数内容提供者都会为其使用的 MIME 类型定义协定类常量。例如,联系人内容提供者协定类 ContactsContract.RawContacts 会为单个原始联系人行的 MIME 类型定义常量 CONTENT_ITEM_TYPE

到这内容提供者的基础知识和安卓四大组件就讲完了,内容提供者的涉及范围比较广,内容提供者的基础知识大家粗略的看一下、对内容提供者有一点了解就好了,那这一篇的主要核心是安卓的基础知识,我们现在对安卓基础知识有了最基本的了解,那我们模拟一下那种点开就是广告的APP,然后我们去把我们写的这个小玩意破解掉。

模拟点开APP就是广告的APP

APP的核心是Activity,而Activity要将内容展现给用户就需要布局文件的帮助,所以第一步编写布局文件:

我们将以下布局文件作为这个APP的主Activity,当然这个Activity也是我们这个APP点开后第一个弹出来的广告。

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <RelativeLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="end">

        <Button
            android:id="@+id/closeButton"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/button_text_one"
            android:layout_alignParentEnd="true"
            android:layout_alignParentTop="true"
            android:layout_margin="8dp" />
    </RelativeLayout>

    <TextView
        android:id="@+id/closeText"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="@string/text_one"
        android:textSize="18sp"
        android:gravity="center" />

</LinearLayout>

可以看出以上布局文件是一个垂直方向的线性布局(LinearLayout),包含一个相对布局(RelativeLayout)和一个文本视图(TextView)。

相对布局中包含一个按钮(Button),按钮的ID为"closeButton",宽度和高度都是包裹内容(wrap_content),按钮上显示的文本内容来自于字符串资源文件(@string/button_text_one),按钮位于相对布局的右上角,距离父布局的右边和顶部都有8dp的边距。

文本视图的ID为"closeText",宽度和高度都是包裹内容(wrap_content),文本内容来自于字符串资源文件(@string/text_one),文本的字体大小为18sp,文本居中显示。

整个布局的宽度和高度都是匹配父元素(match_parent),布局中的元素按照垂直方向排列。

有了布局文件自然要有Activity绑定布局,而这个Activity就是我们的主Activity,也是我们最开始需要作为广告弹出的Activity。

package com.example.fourmodule;

import androidx.appcompat.app.AppCompatActivity;

import android.annotation.SuppressLint;
import android.content.Intent;
import android.os.Bundle;
import android.os.Handler;
import android.view.View;
import android.widget.Button;
import java.lang.Runnable;

public class MainActivity extends AppCompatActivity {
    private Handler handler; // 用于处理延迟任务的Handler对象
    private Runnable closeRunnable; // 延迟任务的Runnable对象

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        // 初始化Handler和Runnable
        handler = new Handler();
        closeRunnable = new Runnable() {
            @Override
            public void run() {
                closeActivity();
            }
        };

        // 启动计时器,延迟五秒后关闭界面
        handler.postDelayed(closeRunnable, 5000);

        // 关闭按钮点击事件
        Button closeButton = findViewById(R.id.closeButton);
        closeButton.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                // 取消计时器并关闭界面
                handler.removeCallbacks(closeRunnable);
                closeActivity();
            }
        });

        // 根布局点击事件
        @SuppressLint({"MissingInflatedId", "LocalSuppress"}) View rootView = findViewById(R.id.closeText);
        rootView.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                // 启动服务
                startService(new Intent(MainActivity.this, MyService.class));
            }
        });
    }

    private void closeActivity() {
        // 关闭界面
        finish();
        // 进入新的界面
        startActivity(new Intent(MainActivity.this, NewActivity.class));
    }

}

以上代码是一个名为MainActivity的活动类,继承自AppCompatActivity。它包含了一些操作和事件处理逻辑。

  1. onCreate方法中,首先通过setContentView方法将布局文件activity_main.xml与该活动关联起来。
  2. 初始化HandlerRunnable对象。Handler用于处理延迟任务,Runnable表示一个可执行的任务。在这里,我们创建了一个匿名内部类实现了Runnable接口的closeRunnable对象,它的run方法会调用closeActivity方法。
  3. 启动计时器,使用handler.postDelayed方法延迟5秒后执行closeRunnablerun方法,也就是调用closeActivity方法。
  4. 通过findViewById方法获取到关闭按钮的实例,并为其设置点击事件。当按钮被点击时,会取消计时器并调用closeActivity方法关闭当前界面。
  5. 同样地,通过findViewById方法获取到根布局的实例,并为其设置点击事件。当根布局被点击时,会启动一个服务(MyService)。
  6. closeActivity方法用于关闭当前界面并启动一个新的界面(NewActivity)。finish方法用于关闭当前活动,startActivity方法用于启动新的活动。

我们来看一下这个Activity的整体布局是怎样的:

image-20231101161633633.png

写好了MainActivity自然要注册这个Activity了,随即我在应用清单文件中对这个Activity进行了注册:

<activity
    android:name=".MainActivity"
    android:exported="true">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />

        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
</activity>

现在第一个主Activity就算是完事了,可以看到在这个Activity的右上角有一个关闭按钮,只要一按或者5秒一到就会结束这个Activity并跳转到新的Activity,但如果在此期间点击了那串文字(为了省事我就用文字模拟为广告)就会触发服务,这样就有点像那开启广告了。而服务我是这样实现的:

package com.example.fourmodule;

import android.app.Service;
import android.content.Intent;
import android.os.IBinder;
import android.widget.Toast;

import androidx.annotation.Nullable;

public class MyService extends Service {

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        // 在服务启动时执行的逻辑
        Toast.makeText(this, "服务已启动", Toast.LENGTH_SHORT).show();

        return START_STICKY;
    }

    @Nullable
    @Override
    public IBinder onBind(Intent intent) {
        return null;
    }

    @Override
    public void onDestroy() {
        super.onDestroy();
        Toast.makeText(this, "服务已关闭", Toast.LENGTH_SHORT).show();
    }

}

以上代码是一个名为MyService的服务类,继承自Service。它包含了一些操作和事件处理逻辑。

  1. onStartCommand方法是在服务启动时执行的逻辑。在这里,我们使用Toast.makeText方法显示一个短暂的提示消息,提示服务已经启动。
  2. onBind方法用于绑定服务。在这里,我们返回null,表示该服务不支持绑定。
  3. onDestroy方法是在服务销毁时执行的逻辑。在这里,我们使用Toast.makeText方法显示一个短暂的提示消息,提示服务已经关闭。

服务这个组件想要正常使用自然也要注册的,所以我在应用清单文件中对这个服务组件进行了注册:

<service android:name=".MyService" />

如果我们没有触发这个服务,而是关闭了这个活动(广告),那我们就会跳转到一个新的Activity,既然是Activity,那么就先来讲讲这个新Activity的布局文件:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/root_view"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@android:color/white"
    android:clickable="true"
    android:focusable="true"
    android:focusableInTouchMode="true"
    android:gravity="center"
    tools:context=".NewActivity">

    <!-- 其他界面元素 -->

    <TextView
        android:id="@+id/Text_Show"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:gravity="center"
        android:text="@string/text_two">
    </TextView>

    <Button
        android:id="@+id/Service_Stop"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_below="@+id/Text_Show"
        android:layout_centerHorizontal="true"
        android:gravity="center"
        android:text="@string/stop_button">
    </Button>

    </RelativeLayout>

以上布局文件它使用RelativeLayout作为根视图,并包含了一些界面元素。

在布局中,有一个TextView和一个Button。TextView的id是Text_Show,它的宽度设置为match_parent,高度设置为wrap_content,并且居中显示。它的文本内容来自于字符串资源文件中的text_two。

Button的id是Service_Stop,它的宽度和高度都是wrap_content,位于TextView的下方并水平居中。它的文本内容来自于字符串资源文件中的stop_button。

整个布局的背景颜色设置为白色,同时设置了一些属性,使得布局可以被点击、获取焦点,并且在触摸模式下也可以获取焦点。

这个布局文件的目的是创建一个界面,其中包含一个文本视图和一个按钮,用于显示文本和执行关闭服务操作两个作用。

而绑定这个布局文件的Activity是通过以下代码实现的:

package com.example.fourmodule;

import android.annotation.SuppressLint;
import android.content.Intent;
import android.content.IntentFilter;
import android.database.Cursor;
import android.os.Bundle;
import android.provider.UserDictionary;
import android.view.View;
import android.widget.Button;

import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;

public class NewActivity extends AppCompatActivity {
    private BroadcastReceiver myReceiver; // 广播接收器对象

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_new); // 设置布局文件

        Button stopButton = findViewById(R.id.Service_Stop); // 获取按钮对象
        stopButton.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                Intent intent = new Intent(NewActivity.this, MyService.class); // 创建意图对象,指定要停止的服务
                stopService(intent); // 停止服务
            }
        });

        // 创建意图过滤器对象,并设置要监听的广播频道为飞行模式的开启与关闭
        IntentFilter intentFilter = new IntentFilter();
        intentFilter.addAction(Intent.ACTION_AIRPLANE_MODE_CHANGED);

        myReceiver = new MyReceiver(); // 创建广播接收器对象
        // 注册广播接收器,将其与意图过滤器关联起来
        this.registerReceiver(myReceiver, intentFilter);
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        // 停止接收广播,避免内存泄漏
        if (myReceiver != null) {
            this.unregisterReceiver(myReceiver);
        }
    }
}

以上代码是一个名为NewActivity的Java类,它是一个继承自AppCompatActivity的活动类。

onCreate方法中,首先调用了父类的onCreate方法,并通过setContentView方法将布局文件activity_new与该活动关联起来。

接着,通过findViewById方法获取到id为Service_Stop的按钮,并设置了一个点击监听器。当点击按钮时,会创建一个意图对象Intent,并指定要停止的服务为MyService,然后调用stopService方法停止该服务。

然后,创建了一个IntentFilter对象intentFilter,并通过addAction方法设置了要监听的广播频道为飞行模式的开启与关闭。

接下来,创建了一个BroadcastReceiver对象myReceiver,并使用registerReceiver方法注册广播接收器,将其与intentFilter关联起来。

onDestroy方法中,调用了父类的onDestroy方法,并通过unregisterReceiver方法停止接收广播,避免内存泄漏。

该类的主要功能是在按钮点击事件中停止一个名为MyService的服务,并在活动销毁时停止接收广播。广播接收器的具体实现可以在MyReceiver类中找到。

我们先来看看这个Activity长啥样:

image-20231101202514760.png

写好了NewActivity自然要注册这个Activity了,随即我在应用清单文件中对这个Activity进行了注册:

<activity android:name=".NewActivity" />

在应用清单文件中注册好后,我们再来重新审视一下NewActivity的代码,可以发现在NewActivity代码中我动态注册了广播接收器,而这个广播接收器我让它接收的广播是飞行模式的开启与关闭。我们知道想要动态注册广播接收器,那第一步就需要实例化一个继承自BroadcastReceiver类的类,并实现其onReceive()方法。那要实例化首先我们自然要先创建一个继承自BroadcastReceiver类的类,并编写其onReceive()方法。

package com.example.fourmodule;

import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;

public class MyReceiver extends BroadcastReceiver {

    // BroadcastReceiver的回调方法,当接收到广播时,系统会回调该方法
    @Override
    public void onReceive(Context context, Intent intent) {
        // 获取广播的Action
        String action = intent.getAction();
        // 判断广播是否为飞行模式改变的广播
        if (action != null && action.equals(Intent.ACTION_AIRPLANE_MODE_CHANGED)) {
            // 获取飞行模式状态
            boolean isAirplaneModeOn = intent.getBooleanExtra("state", false);
            // 创建一个新的Intent对象,用于启动ReceiverActivity
            Intent updateIntent = new Intent(context, ReceiverActivity.class);
            // 将飞行模式状态添加到Intent中
            updateIntent.putExtra("isAirplaneModeOn", isAirplaneModeOn);
            // 启动ReceiverActivity
            context.startActivity(updateIntent);
        }
    }

}

我们可以将以上代码总结为以下十点:

  1. MyReceiver类是一个BroadcastReceiver,用于接收广播并处理广播内容。
  2. onReceive()方法是BroadcastReceiver的回调方法,当接收到广播时,系统会回调该方法。
  3. context参数是BroadcastReceiver的上下文,用于启动Activity等操作。
  4. intent参数是广播的Intent对象,包含广播的Action和数据。
  5. action变量获取广播的Action,用于判断广播类型。
  6. 判断广播是否为飞行模式改变的广播,如果是,则继续处理广播内容。
  7. 获取飞行模式状态,isAirplaneModeOn变量为true表示飞行模式已开启,为false表示飞行模式已关闭。
  8. 创建一个新的Intent对象,用于启动ReceiverActivity。
  9. 将飞行模式状态添加到Intent中,以便在ReceiverActivity中使用。
  10. 启动ReceiverActivity,显示飞行模式状态。

仔细看可以发现,只要一旦意图过滤器接收到飞行模式改变的广播时,不管飞行模式是否打开,都会启动ReceiverActivity,而现在就应该去看看ReceiverActivity类了。

ReceiverActivity类布局文件:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <RelativeLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="end">

        <Button
            android:id="@+id/receiverButton"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/button_text_one"
            android:layout_alignParentEnd="true"
            android:layout_alignParentTop="true"
            android:layout_margin="8dp" />
    </RelativeLayout>

    <TextView
        android:id="@+id/Text_Changed"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="@string/changed_no"
        android:textSize="18sp"
        android:gravity="center" />

</LinearLayout>

我们可以直观的看出以上代码是一个典型的Android布局文件,使用了LinearLayout作为根元素,并设置了垂直方向的布局。

在LinearLayout内部包含了一个RelativeLayout和一个TextView。

RelativeLayout包含一个Button,该Button的id为receiverButton,设置了一些布局属性,使其位于父布局的右上角,并设置了一些边距。

TextView的id为Text_Changed,设置了宽度为match_parent,高度为wrap_content,文本内容为@string/changed_no,字体大小为18sp,居中显示。

整体布局结构为线性布局,上方是一个相对布局,下方是一个文本视图。

这个布局的外观为一个按钮位于屏幕右上角,下方是一个居中显示的文本视图。

前面说过启动ReceiverActivity,显示飞行模式状态,而ReceiverActivity的主要作用自然是显示飞行模式状态了,但是我为这个Activity设置了和广告界面一样的显示方式,以下是ReceiverActivity类的代码实现:

package com.example.fourmodule;

import android.content.Intent;
import android.os.Bundle;
import android.os.Handler;
import android.view.View;
import android.widget.Button;
import android.widget.TextView;

import androidx.appcompat.app.AppCompatActivity;

public class ReceiverActivity extends AppCompatActivity {
    private Handler handler;
    private Runnable closeRunnable;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_receiver);

        TextView textView = findViewById(R.id.Text_Changed);

        Intent intent = getIntent();
        if (intent != null && intent.hasExtra("isAirplaneModeOn")) {
            boolean isAirplaneModeOn = intent.getBooleanExtra("isAirplaneModeOn", false);
            if (isAirplaneModeOn) {
                // 修改文本样式为飞行模式开启时的样式
                textView.setText(R.string.changed_yes);
            } else {
                // 修改文本样式为飞行模式关闭时的样式
                textView.setText(R.string.changed_no);
            }
        }

        // 初始化Handler和Runnable
        handler = new Handler();
        closeRunnable = new Runnable() {
            @Override
            public void run() {
                closeActivity();
            }
        };

        // 启动计时器,延迟五秒后关闭界面
        handler.postDelayed(closeRunnable, 5000);

        // 关闭按钮点击事件
        Button closeButton = findViewById(R.id.receiverButton);
        closeButton.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                // 取消计时器并关闭界面
                handler.removeCallbacks(closeRunnable);
                closeActivity();
            }
        });
    }

    private void closeActivity() {
        // 关闭界面
        finish();
        // 进入新的界面
        startActivity(new Intent(ReceiverActivity.this, NewActivity.class));
    }
}

我们可以从以上的ReceiverActivity类的代码中看出,在onCreate方法中,首先调用了父类的onCreate方法,并设置了布局文件为activity_receiver。然后通过findViewById方法获取了一个名为Text_Changed的TextView,并获取了从意图中传递过来的额外信息(isAirplaneModeOn),根据这个信息来更新TextView的文本内容。

接着,初始化了一个Handler和一个Runnable对象。Handler用于管理线程和消息队列,而Runnable是一个接口,用于定义要在新线程中执行的代码块。

然后,通过handler.postDelayed方法启动了一个计时器,延迟五秒后调用closeRunnable中的run方法,从而关闭当前界面并启动一个名为NewActivity的新界面。

最后,设置了一个名为receiverButton的按钮的点击事件监听器,当按钮被点击时,会取消计时器并立即关闭当前界面,并启动名为NewActivity的新界面。

编写好代码的Activity自然要注册这个Activity了,随即我在应用清单文件中对这个Activity进行了注册:

<activity android:name=".ReceiverActivity" />

让我们来看看这个Activity长啥样:

image-20231114163943122.png

这个是接收到打开飞行模式的广播后所显示的界面,而接收到关闭飞行模式的广播后所显示的界面自然是显示飞行模式现处于关闭状态啦!

最后说一下这个简单的APP布局文件所需字符串全部存放在res/values/strings.xml文件中:

<resources>
    <string name="app_name">FourModule</string>
    <string name="button_text_one">关闭</string>
    <string name="text_one">走过路过不要错过,过了这个村就没这家店了!</string>
    <string name="text_two">这个是正文!</string>
    <string name="changed_yes">飞行模式现处于打开状态</string>
    <string name="changed_no">飞行模式现处于关闭状态</string>
    <string name="stop_button">关闭服务</string>
    <string name="content_text_one">检索用户字典的数据</string>
</resources>

至此,这个模拟点开APP就是广告的APP实现就完成了!这个简单的APP可能会有很多不专业的地方,但我并非专门进行安卓开发的,只是秉承着“知己知彼,百战不殆”的态度进行的简单涉猎所编写的APP。

接下来就是我们开上帝视角来解决掉这烦人的广告了,在这之前还需将APK进行打包才可以将其安装在手机或者模拟器上,打包好APK后我们就开始上帝视角破解之路。

首先我们要的是跳过广告界面,可以想到最直接的两种方法,一是将广告界面的显示时间改为0秒,二是修改应用清单文件中的APP启动界面的Activity。但是第二种方法一般不推荐使用,容易出现一些奇怪的问题,因为一般在启动Activity的时候都会预先加载一些数据,而第二种方法可能会导致这些需要预先加载的数据没有加载上,而导致一些奇奇怪怪的问题。

所以我们就使用第一种方法来解决这个广告界面:

按照正常流程第一步我们应该进行查壳工作,但是这是我们自己编写的简单程序,自己知道没有加壳自然就不进行查壳工作了,如果是进行逆向工作时第一步是一定要进行查壳工作的。

第二步使用MT管理器的Activity记录功能获取到我们想要得到的Activity类信息,获取到com.example.fourmodule.MainActivity这条Activity类信息后,然后在反编译后的Dex文件中筛选搜索类型为类名,然后就对com.example.fourmodule.MainActivity这么一搜,我们就可以看到以下信息:

image-20231115165628531.png

看到这个搜索结果有人可能会有些好奇,之前在开发时什么时候写过MainActivity$1、MainActivity$2、MainActivity$3这几个类。这只需要我们一点进去就能明白是怎么回事了:

package com.example.fourmodule;

class MainActivity$1 implements Runnable {
    final MainActivity this$0;

    MainActivity$1(MainActivity mainActivity) {
        this.this$0 = mainActivity;
    }

    @Override
    public void run() {
        MainActivity.access$000(this.this$0);
    }

}

看起来是不是既熟悉又陌生,来,我们再来看一个段代码你就知道是怎么回事了:

closeRunnable = new Runnable() {
    @Override
    public void run() {
        closeActivity();
    }
};

这一段初始化Runnable的代码和MainActivity$1是不是有相似之处,没错,MainActivity$1就是MainActivity中的一个匿名内部类,而XXX$1、XXX$2、XXX$3什么的都是XXX中定义的内部类。

这是一个小插曲,我们继续,进入到MainActivity后,我们先来看一下反编译后的代码转成Java是个什么样子:

//
// Decompiled by Jadx - 479ms
//
package com.example.fourmodule;

import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.os.Handler;
import android.widget.Button;
import androidx.appcompat.app.AppCompatActivity;

public class MainActivity extends AppCompatActivity {
    private Runnable closeRunnable;
    private Handler handler;

    protected void onCreate(Bundle bundle) {
        super.onCreate(bundle);
        setContentView(R.layout.activity_main);
        this.handler = new Handler();
        Runnable runnable = new MainActivity$1(this);
        this.closeRunnable = runnable;
        this.handler.postDelayed(runnable, 5000L);
        ((Button) findViewById(R.id.closeButton)).setOnClickListener(new MainActivity$2(this));
        findViewById(R.id.closeText).setOnClickListener(new MainActivity$3(this));
    }

    /* JADX WARN: Multi-variable type inference failed */
    public void closeActivity() {
        finish();
        startActivity(new Intent((Context) this, (Class<?>) NewActivity.class));
    }

}

我们是打算将广告界面的显示时间改为0秒,那可以看到this.handler.postDelayed(runnable, 5000L);这行代码,就是这行代码启动计时器,延迟五秒后关闭界面,那么我们需要将其中的5000改为0,因为我们无法修改Java代码,只能修改smali代码,所以我们需要找到postDelayed方法的调用位置,因为要调用某个有参数的方法,那在调用之前必须先准备好参数。欸!就这么一搜,就找到了目标:

image-20231115174940960.png

看到这里,想必大家都知道只要将0x1388改为0x0就完成了将广告界面的显示时间改为0秒的工作,我们保存、回编译、重新签名、重新安装一条龙服务完成后就可以看到结果了。

完成将广告界面的显示时间改为0秒的工作后,服务是不会开启了,因为MainActivity只存在0秒,也没法正常开启服务了,但是这个APP一旦接收到飞行模式改变状态的广播后依旧会弹出ReceiverActivity界面,我们下一步就是不让它在飞行模式状态改变时弹出ReceiverActivity界面。

首先我们我们可以想到以下两种方式:

第一:依旧是修改ReceiverActivity界面的显示时间,将其改为0。

第二:不让MyReceiver广播接收器在接收到飞行模式状态改变的广播后启动ReceiverActivity。

第一种方式我们之前已经讲过了,就不重复讲了,这里我们讲第二种方式来解决这个问题。

正常情况下我们是通过MT管理器的Activity记录功能获取到我们想要得到的Activity类信息,然后找到调用这个Activity的位置并处理掉调用这个Activity的代码。我们知道接收到飞行模式状态改变的广播后弹出来的Activity是ReceiverActivity,那么我们直接搜索它:

image-20231115184943975.png

长按ReceiverActivity后选择复制,我们可以看到一条信息Lcom/example/fourmodule/ReceiverActivity;,这条信息可以帮助我们找到是谁调用了ReceiverActivity类,我们拿着这一条信息按照搜索类型为代码就这么一搜索,可以看到:

image-20231115185405355.png

我们直接排除ReceiverActivity类自己的调用,可以看到MyReceiver中有Lcom/example/fourmodule/ReceiverActivity;这条信息,我们点进去可以看到这么一段代码:

new-instance v0, Landroid/content/Intent;

const-class v1, Lcom/example/fourmodule/ReceiverActivity;

invoke-direct {v0, p1, v1}, Landroid/content/Intent;-><init>(Landroid/content/Context;Ljava/lang/Class;)V

const-string v1, "isAirplaneModeOn"

.line 16
invoke-virtual {v0, v1, p2}, Landroid/content/Intent;->putExtra(Ljava/lang/String;Z)Landroid/content/Intent;

.line 17
invoke-virtual {p1, v0}, Landroid/content/Context;->startActivity(Landroid/content/Intent;)V

:cond_24
return-void

从以上smali代码可以看出:

new-instance v0, Landroid/content/Intent;

const-class v1, Lcom/example/fourmodule/ReceiverActivity;

invoke-direct {v0, p1, v1}, Landroid/content/Intent;-><init>(Landroid/content/Context;Ljava/lang/Class;)V

以上这段smali代码就是对应Java代码Intent updateIntent = new Intent(context, ReceiverActivity.class);。

v0和v1寄存器分别表示Intent类和ReceiverActivity类,而这个方法并非静态方法,所以p1就是这个方法的第一个参数Context context。

而调用putExtra方法也是类似,如果对smali不太熟悉的可以自己对照前面写的MyReceiver类去推测。

好了,小插曲就先到这,我们可以看出来我们想要不让MyReceiver广播接收器在接收到飞行模式状态改变的广播后启动ReceiverActivity,只需要将invoke-virtual {p1, v0}, Landroid/content/Context;->startActivity(Landroid/content/Intent;)V这一段代码注释掉就好了,注释完后,我们保存、回编译、重新签名、重新安装一条龙服务完成后就可以看到结果了。

这样我们就完成了接收到飞行模式改变状态的广播后不会弹出ReceiverActivity界面的工作了。到此为止,安卓基础我们也算是有了简单的了解了,对于安卓四大组件也有了模糊的理解,对于安卓逆向的了解也有了更多的了解了。

至此,这一篇安卓基础方面的开发与逆向就讲完了!

app-release.zip

2.37 MB, 下载次数: 13, 下载积分: 吾爱币 -1 CB

免费评分

参与人数 33威望 +1 吾爱币 +68 热心值 +31 收起 理由
CIBao + 2 + 1 谢谢@Thanks!
zclll + 1 + 1 我很赞同!
walykyy + 1 + 1 谢谢@Thanks!
7758 + 1 谢谢@Thanks!
Sugarmapler + 1 用心讨论,共获提升!
zyh109 + 1 + 1 谢谢@Thanks!
AB12315 + 1 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
WXjzc + 1 + 1 谢谢@Thanks!
aisme + 1 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
Tonyha7 + 2 + 1 谢谢@Thanks!
Chenda1 + 1 + 1 谢谢@Thanks!
whywf001 + 1 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
gqdsc + 2 + 1 天啦,都可以出书了
junjia215 + 1 + 1 用心讨论,共获提升!
fzhhn + 1 + 1 大佬牛逼
CBL-XG + 1 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
killjd + 1 + 1 谢谢@Thanks!
alanhays + 2 + 1 很细
Yz427 + 1 + 1 谢谢@Thanks!
Rongg + 1 + 1 大受震撼
helian147 + 1 + 1 谢谢@Thanks!
fengbolee + 2 + 1 欢迎分析讨论交流,吾爱破解论坛有你更精彩!
tomhex + 1 + 1 我很赞同!
铜卦法王 + 1 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
wugaga + 1 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
BonnieRan + 1 + 1 谢谢@Thanks!
勇者为王 + 6 + 1 欢迎分析讨论交流,吾爱破解论坛有你更精彩!
allspark + 1 + 1 用心讨论,共获提升!
wenrow + 1 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
雨落惊鸿, + 1 + 1 我很赞同!
ouquanwen + 1 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
正己 + 1 + 20 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
cattie + 7 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!

查看全部评分

本帖被以下淘专辑推荐:

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

涛之雨 发表于 2023-11-16 00:09
万字天书。。。
估计能读完的人不多,

大概扫了一眼,读完会对逆向(包括开发)有很大帮助
 楼主| sigewangdaiduie 发表于 2023-11-25 13:16
suifeng520 发表于 2023-11-16 11:50
可是部分软件,只改了包名,与dex的类名不一样,也能正常运行

一般从开发的角度来说,如果只是改变了包名而没有改变dex的类名,软件可能会出现一些问题。在多开的时候只是改变了包名而没有改变dex的类名确实可以正常运行,这是因为在多开的情况下,每个实例都会使用不同的包名,因此不会出现包名冲突的问题。但是在单个应用中,如果只是改变了包名而没有改变dex的类名,可能会出现一些问题,因为包名通常与dex文件中的类名有关联。
正己 发表于 2023-11-15 21:54
ashortname 发表于 2023-11-15 22:24
是不是和java逆向差不多
ysbwss 发表于 2023-11-15 22:56
不错不错
GGBL 发表于 2023-11-15 23:04
小白,表示看不懂
zark0R 发表于 2023-11-15 23:24
学习一下
zhiqingchun 发表于 2023-11-15 23:28
学到了很多,谢谢大佬
1rten 发表于 2023-11-15 23:33
配合Android软件安全权威指南一起食用
yan999 发表于 2023-11-15 23:43
感谢分享!!!
riluoxingchen 发表于 2023-11-15 23:55
我觉得我可能要暑假才能看完了
大佬加油!
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则 警告:本版块禁止灌水或回复与主题无关内容,违者重罚!

快速回复 收藏帖子 返回列表 搜索

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - LCG - LSG ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2024-4-27 14:44

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表