Android修炼系列(39),AIDL 有很多要注意的地方

6,641 阅读12分钟

官方提醒,只有在需要不同应用的客户端通过 IPC 方式访问服务,并且希望在服务中进行多线程处理时,我们才有必要使用 AIDL。如果我们无需跨不同应用执行并发 IPC,则应通过 实现 Binder 来创建接口;或者,如果我们想执行 IPC,但不需要处理多线程,请 使用 Messenger 来实现接口。

本文主要是对于 AIDL 使用上的一点思考,如想学习更多 AIDL 的基础知识,可自行看下 官网文档

正文

其实多进程的同应用,在现实里更常见一些,被设计成多进程的目的,无外乎两点:

  • 可以最大限度的获取系统资源,毕竟系统为每个进程分配的资源有限

  • 进程间资源隔离,当前进程挂掉也不影响其他进程,很适合 Service 或一些辅助业务模块

但多进程开发也会带来一些问题,可能一不留神就掉在坑里,所以要格外注意:

  • 静态成员和单例模式失效(不同进程访问同一个类会产生多个副本)
  • 线程同步机制失效 (不同进程锁的不是同个对象)
  • SharedPreferences 可靠性下降 (并发写操作,会造成丢数据)
  • Application 会多次创建 (每启动一个进程都会被分配一个新的虚拟机)

本文的实践场景:创建两个应用,一个 app 充当服务端: BlogService,一个 app 作为客户端: BlogSample,两个 app 通过 Aidl 相互通信。

定义AIDL接口

第一步,就是如何创建一个 AIDL:

  1. 在服务端 BlogService 工程内创建一个 .aidl 文件,文件路径可以放于 main/aidl 目录下,注意这里 /com/blog/service 路径可自定义,并非必须要与包名一致。在构建该应用时,AS 会帮我们自动生成基于该 .aidl 文件的 IBinder 接口,并将其保存到项目的 build/generated/ 目录中

image.png

  1. 在服务端 BlogService,实现接口。AS 帮我们自动生成的 IBinder 接口,其拥有一个名为 Stub 的内部抽象类,用于扩展 Binder 类并实现 AIDL 接口中的方法。我们要在 Stub 类内实现上面定义的 pullFromService 方法

image.png

  1. 向客户端公开接口。其实就是在 BlogService 工程内创建一个 Service 服务,通过重写 onBind(),从而能给连接上的客户端返回 Stub 类的实现

image.png

  1. 在服务端 Manifest 文件内注册该 Service 组件,注意只有添加了 exported 标签并设置为 true,才能被外部其他 app 调用。这里添加 action 属性的目的,是给 BlogSample app 提供隐式调用绑定该服务的能力。(有强迫症的你可能会发现,这里有个警告,下文会介绍)

image.png

调用AIDL接口

刚刚在服务端定义好了 com.blog.service.BlogService 服务,接下来就是在客户端 BlogSample 内去绑定该服务了,就是常规的 bindService 接口,不用想的太复杂。

  1. 首先还需要将服务端的 .aidl 文件(连同目录)拷贝一份到客户端,目的可以理解为,这个 .aidl 文件,就是服务端与客户端通信的协议,客户端拿到了这个 .aidl 文件,才能知道服务端提供了什么接口。二来,想在客户端使用 IBlogManager,不能无中生有啊,先要保证编译能通过吧。BlogSample 目录见下

image.png

  1. 客户端内我创建了一个 BlogServiceActivity 类,并调用 bindService() 以连接 BlogService 服务时,客户端的 onServiceConnected() 回调会接收服务端的 onBind() 方法所返回的 binder 实例。

image.png

  1. 客户端拿到了 IBlogManager 对象了, 当然就能调用其内部定义的方法了

image.png

  1. 运行结果

image.png

传递类型

默认情况下,AIDL 支持下列数据类型:

  • Java 编程语言中的所有原语类型(如 intlongcharboolean 等)
  • StringCharSequenceListMap
  • 支持 Parcelable 接口的类对象
  • AIDL 接口本身也可以在 AIDL 文件中使用

下面着重说下,Parcelable 对象、抽象类、AIDL 接口在实际中的使用:

Parcelable 对象

接下来以 BlogInfo 对象为例,有几点要注意:

  1. BlogInfo 必须实现 Parcelable 接口,只有序列化了才能在进程间传输。
  2. 要传递的 BlogInfo 对象,需要新建一个对应的 BlogInfo.aidl 文件
  3. AIDL 接口使用 BlogInfo 时,必须使用 import

我们依然可以将 BlogInfo 相关文件放在 aidl 目录下,这么做的好处就是方便拷贝到客户端(相当于通信协议,服务端和客户端各一份才行啊),见下

image.png

这里有一点要注意的,需要在 build.gradle 文件中指明 srcDirs 目录,因为例子中的 aidl 目录并没在 main/java/ 路径下:

image.png

这是 BlogInfo 类代码,具体 Parcelable 用法就不用说了,就是普通的序列化逻辑:

image.png

这是对应的 BlogInfo.aidlIBlogManager 内容,我们看到在 pushToService 接口中会传递一个 BlogInfo 对象:

image.png

服务端的 aidl 定义完了,根据上文讲的,接下来只需要:

  1. 服务端在 BlogService 中公开接口
  2. 客户端拷贝一份 aidl,并绑定服务,拿到 IBlogManager 对象
  3. 客户端通过拿到的 IBlogManager 对象,调用 pushToService 接口,传递对象即可

代码就不贴了。

我们来想个问题,要传递的对象,真的必须都要创建一个对应的 .aidl 文件吗?如果我有很多小类,那岂不是要崩溃?

实际使用并非如此,下面来看个超类的例子。

抽象类

上面讲的,我们在 IBlogManager#pushToService 中直接传递的是 BlogInfo 对象,这可能不太优雅,也不符合可扩展的原则。在实际使用中,我们可能需要的是传递一个超类,这怎么办呢?

其实很简单,下面以 AbstractBlogInfo 为例:

  1. 在反序列化时,通过反射手段,使用 className 获取子类对象,并执行 readFromParcel 方法,子类重写该接口来进行反序列化操作。writeToParcel 基类接口负责序列化 className。目的就是在超类中,直接动态去序列化子类,具体代码见下。

image.png

  1. 创建 AbstractBlogInfo.aidl 同名文件(对,超类还是需要创建同名文件的):

image.png

  1. 这是实际要使用的子类 BlogInfo1,就不需要再创建同名 .aidl 文件了,是不是很棒。使用上也很简单,重写 read 和 write 方法,来序列化/反序列化自身属性即可:

image.png

  1. 接下来就可以在 IBlogManager.aidl 中正常使用 AbstractBlogInfo 了。

image.png

  1. 修改下服务端 BlogService 对应代码:

image.png

  1. 将 aidl 目录文件拷贝到客户端,在客户端直接传递 BlogInfo1 对象:

image.png

  1. 运行ok

image.png

除了传递对象,回调函数的使用,在实际中也是必不可少的。上面讲的,所有的 AIDL 接口本身也支持在 AIDL 文件中使用,下面我们就以 IBlogListener.aidl 为例。

AIDL接口

场景:要实现,客户端向服务端注册监听器,当 pushToService 方法被调用后,就通知客户端。

  1. 定义一个简单的 IBlogListener AIDL 接口

image.png

  1. 在我们的 IBlogManager AIDL 中就能直接使用了,这里提供了register/unregister监听的接口:

image.png

  1. 修改服务端 BlogService 代码逻辑并重写方法。这里使用了 CopyOnWriteArrayList 保来障线程安全:

image.png

  1. 修改一下客户端的调用逻辑。因为客户端也要拷贝一份 aidl 目录,所以客户端能直接拿到 IBlogListener.Stub,并在绑定/解绑服务时候,添加监听,这样服务端就能拿到 blogListener 了:

image.png

  1. 先运行服务端 app,再启动客户端 app, 运行结果:

image.png

这里想一想,服务端拿到的是当前的 blogListener 对象吗?

实际并不是。客户端传递的对象,在经过序列化与反序列化后,最终会生成一个全新的对象,这也就导致了一个问题,那就是上面的代码无法取消注册监听了。

那有什么办法可以解决吗?这就不得不说 RemoteCallbackList 了。

RemoteCallbackList

RemoteCallbackList 是系统专门提供的用于删除跨进程 listener 的接口,使用起来也很简单,直接替换上面的 CopyOnWriteArrayList 即可。下面来看下 BlogService 代码:

image.png

实测有效。

那为啥 RemoteCallbackList 就可以呢?

引用《Android开发艺术探索》

RemoteCallbackList 内有个 Map 结构专门用来保存 AIDL 的回调,其中 key 是 IBinder 类型,value 是 Callback 类型(即我们真正的远程 listener)。 虽然说多次跨进程传输客户端的同一个对象会在服务端生成不同的对象,但是这些新生成的对象有一个共同点,那就是它们底层的 Binder 对象是同一个,利用这个特性,就可以实现上面的功能。当客户端解注册的时候,我们只要遍历服务端所有的 listener,找出那个和解注册 listener 具有相同 Binder 对象的服务端 listener,并把它删掉即可,这就是 RemoteCallbackList 为我们做的事情。

定向tag

如果你有细心看的话,在上面传递自定义对象时,可以看到我使用了一个 in,

  void pushToService(in BlogInfo info);

这个叫定向tag,是指示数据走向的方向标记,这类标记可以是:

  • inoutinout

听起来不太好理解。可以这样想: in 表示数据只能从客户端流向服务端,out 表示数据只能能服务端流向客户端,inout 表示数据可以在服务端和客户端双向流动。

我们来实际验证下。

上面的例子是使用的 in, 下面以 out 为例,这是 IBlogManager

image.png

如果使用 outinout,则需要在 Parcelable 接口对象内添加 readFromParcel 方法,注意此方法并不是重写和重载方法啊。 以上文的 BlogInfo 类为例:

image.png

客户端创建 BlogInfo 对象并赋值传递到服务端:

image.png

服务端接收 BlogInfo ,并打印值:

image.png

运行结果:

image.png

我们发现虽然 BlogInfo 对象接收到了,但 name 值确并没有正确传递过来。这也证实了上面所讲的,所以我们在实际使用时,要正确选择合适的定向tag,不要随便使用,否则会产生一些不易察觉的问题。

扩展兼容

在写博客 demo 的时候,就深有感触,由于服务端和客户端维护的是一份 AIDL 协议,那如果服务端 app, 和客户端 app 分开开发呢?或者一方代码变更后,双方线上无法保证同时发版呢?

这是不是就要求我们在开发阶段,脑袋里就要有兼容性的概念。

aidl 开发上,常见的做法就是:

  1. 服务端 aidl 内提供获取版本信息的接口。
  2. 客户端绑定远程服务时,intent 带上要请求的版本信息。

场景1: 服务端变更了,推出了 IBlogManager1.aidl, 但客户端没有升级。我们只需要 :

  1. 客户端在绑定服务时,intent 带上版本号:

image.png

  1. 在服务端根据版本号,来返回对应 IBinder 对象:

image.png

场景2: 客户端更新了,但服务端没有升级

  1. 尝试加载 IBlogManager1.aidl 新服务,如果新服务未上线,则使用老服务。这里使用了 try catch,虽然功能没问题,但很不优雅。(如果你有更好的思路或想法,可以提出来一起交流)

image.png

身份校验

默认情况下,我们的远程服务任何人都可以连接,但这并不是我们愿意看到的。在 AIDL 中进行身份校验,常见的有两种方式:

  • 可以在 onBind 方法中进行验证,验证不通过就直接返回 null。
  • 可以在服务端的 onTransact 方法中进行验证,如果验证失败就直接返回false。

这两种方式又常用 permissionPID、UID 的方式校验。

permission

以自定义 permission 为例:

  1. 在服务端 manifest 中的 BlogService 添加 android:permission 标签,其表示启动服务或绑定到服务所必需的权限的名称。如果 startService()bindService() 或 stopService() 的调用方尚未获得此权限,该方法将不起作用,且系统不会将 Intent 对象传送给服务

image.png

  1. 在服务端 manifest 中 定义一个 app.blog.service.permission 权限:

image.png

  1. 在客户端中添加该权限

image.png

是不是很简单,如果客户端没有权限就启动 BlogService 会直接报错。关于自定义权限的知识点就不在这里介绍了。

这里有个坑的地方要注意,就是 checkCallingPermission 方法不能在 onBind 方法中调用,否则会一直返回 PackageManager.PERMISSION_DENIED,因为 onBind 方法并没有运行在 Binder 线程池中,可以在 onTransact() 中调用。

image.png

还有,因为加入了 permission 标签,上面提到的警告也没有了,是不是治好了你的强迫症。

package

我们也可以重写 BinderonTransact 方法,在里面做检验,上面说了 permission,常用的还有包名校验:

  1. 其实很简单,通过调用者的 UID 获取对应包名,代码直接看吧:

image.png

中断监听

我们知道当服务端进程由于某些原因异常终止,这个时候我们到服务端的 Binder 连接就会中断,会导致我们的远程调用失败。

目前常用的监听手段,包括:

  • onServiceDisconnected:当客户端应用 A 和 服务端应用 B 正连接时,如果服务端 B 被杀死,那么二者的连接会立即中断,A 的 ServiceConnectiononServiceDisconnected 会被调用。
  • DeathRecipient:当 Binder 死亡的时候,系统会回调此方法,使用方法也很简单,直接在客户端 onServiceConnected(name: ComponentName?, service: IBinder?) 回调中注册,注意要与 unlinkToDeath 配合使用:

image.png

好了,代码都上传到了 github,下文介绍实现 Binder

本节完。

参考资料

  1. developer.android.com/guide/compo…