简介:Android项目之AidlDemo是一份演示AIDL跨进程通信的完整Android示例工程,面向刚接触IPC机制的开发者,意在通过可运行的小案例弄清AIDL接口定义、Service绑定以及Stub/Proxy生成等核心环节。压缩包共58个文件、约181KB,以7个Java源文件、9个AIDL接口文件和4个XML配置为源码主体,另含20个class、dex、APK等编译产物。已有1797人学习下载。工程保留了Eclipse项目结构,src、gen、res与bin目录齐全,gen下的自动生成代码便于理解AIDL编译过程,drawable多分辨率目录与layout布局也可直接复用。借助该工程,读者能完整查看Activity绑定Service的代码路径、AIDL接口写法与自动生成的Stub逻辑,还能用打包好的APK快速验证跨进程调用效果;目录清晰,适合边阅读边调试,是Android IPC入门的直接参考资料。
1. 一个 AIDL Demo 背后到底藏着哪些绕不开的东西
标题里那句“简单的 aidl 例子”其实并不简单。AIDL(Android Interface Definition Language)是 Android 里唯一官方主推的跨进程接口定义方案,它解决的是两个进程之间“像调本地方法一样调另一个进程方法”的问题。初次接触的人往往以为它就是写一个接口文件然后两端复制粘贴,实际上里面藏着 Service 绑定、Binder 事务、Parcelable 序列化和方向关键字这些概念,哪一个没搞透,线上就会以各种离奇方式翻车。这个 Demo 虽然小,但把 Binder、ServiceConnection、跨进程数据传递这些核心链路完整串了一遍,值得每一个做组件化、播放器、后台任务的开发照着敲一遍——新手能借此建立完整心智模型,熟手也能借此校准一些之前模糊的边界。
2. 从标题到原理:跨进程调用为什么绕不开 AIDL
2.1 为什么是 Binder 而不是内存共享或 Socket
Android 的跨进程通信底层是 Binder,它和传统的管道、共享内存、Socket 不一样。Binder 的厉害之处在于:数据只拷贝一次,从发送进程的用户空间直接进入接收进程的内核映射区;同时它把进程安全校验下沉到了驱动层,不需要业务代码自己校验身份。这套机制决定了 Android 系统服务(比如 ActivityManager、PackageManager)都建立在 Binder 之上。
AIDL 是在 Binder 之上的“翻译层”。你写一份接口声明文件,编译时系统自动生成两个关键类:Stub 和 Proxy。Stub 是服务端入口,代理端调用方法时会被转成一个 Binder transaction,内核把参数打包后投递给服务端进程,服务端在 onTransact() 里解包、执行真正的业务逻辑,再把返回值打包回传。整个过程对开发者是隐藏的——你看到的只是“调了一个接口方法”,实际上中间隔了一个完整的 IPC 往返。
这个“隐藏复杂性”是 AIDL 最大的优点也是最大的坑。它让跨进程调用看起来像本地调用,导致很多人忽略了它底层的成本:每次调用都有序列化和反序列化开销,有线程切换,有阻塞等待。理解这一点之后,才能解释后面遇到的 ANR、TransactionTooLargeException 为什么会出现。
2.2 为什么不用 Messenger 或同进程直接调用
很多人纠结:同样是跨进程,为什么标题里的 Demo 用 AIDL 而不是更轻量的 Messenger?把两者摊开对比就清楚了。Messenger 基于 Handler 和 Message,本质是串行队列,发送方把消息塞进队列,接收方按顺序处理。它适合“发通知、命令”这种单向、低频、不需要明确返回值的场景,但一旦想让服务端计算后返回一个结构化对象,Messenger 就非常别扭——你得自己定义消息 code、自己写返回逻辑,本质上是在 AIDL 之上又盖了一层协议,属于脱裤子放屁。
同进程直接调用则是另一个极端。如果服务端和客户端生活在同一个进程里,直接绑定 Service 拿到对象引用调用方法就行,不需要 AIDL。有人图省事在同进程里也用 AIDL,这时调用虽然也能通,但多走了一层序列化和反序列化,白白浪费 CPU。判断标准很简单:绑定双方是否在同一个进程。用 android:process 给 Service 指定独立进程,或者跨应用绑定,才需要上 AIDL;否则就老老实实用本地接口。
2.3 标题“简单”背后,Demo 项目到底该选什么结构
“简单的 aidl 例子”最常见的误区是搞两个工程,一个服务端一个客户端,然后模拟器里装两个 APK 互相绑定。这种结构对新手不友好,调试起来要开两套工程、两套部署,非常容易在环境问题上消耗耐心。我一般建议:单工程双进程。方法是在 AndroidManifest.xml 里给 Service 配上 android:process=":remote",让 Service 跑在独立进程,Activity 留在主进程,这样一个工程、一次构建、一次部署就能验证真正的跨进程链路。
单工程双进程的核心价值在于:你不必真的做跨应用授权、包名校验那一堆外围配置,就能把 AIDL 本身的数据打包、Binder 事务、Service 生命周期透透地跑一遍。等这个骨架跑通了,往多工程迁移时只是把 Service 拆出去、加上 exported 和权限声明,剩余逻辑几乎原样保留。这个取舍也是生产项目里常见的演进路径——先在一个应用内部把跨进程模块跑通,再逐步拆成独立进程或独立应用。
3. 项目结构与 AIDL 接口定义:第一步怎么走才不返工
3.1 目录结构和 Gradle 配置的细节
创建一个标准的 Android 项目,包名假设是 com.example.aidldemo。src/main 目录下要手动建一个和 java 平级的 aidl 目录,路径是 app/src/main/aidl。包名结构的目录层级要完整建出来,比如 com/example/aidldemo/,然后把 IAddOperation.aidl 放进去。
app/src/main/ ├── aidl/ │ └── com/example/aidldemo/ │ └── IAddOperation.aidl ├── java/ │ └── com/example/aidldemo/ │ ├── MainActivity.kt │ └── AddService.kt └── AndroidManifest.xml这个目录结构里的关键点有两个:其一是 aidl 目录和 java 目录必须同级,放错位置编译器找不到 AIDL 文件;其二是 AIDL 文件里的包名必须和目录路径匹配,否则生成的类会跑到错误的包下,引用时满地飘红。Android Studio 原生支持右键新建 AIDL 文件,它会自动把目录结构补齐,所以我建议用 IDE 建文件而不是手动拷贝。
3.2 AIDL 文件语法:最小的接口长这样
// IAddOperation.aidl package com.example.aidldemo; interface IAddOperation { int add(int a, int b); }package 声明必须写,而且必须和 aidl 文件所在目录匹配。interface 关键字后面是接口名,方法只声明不写方法体,参数和返回值都只能是 AIDL 支持的数据类型。这里的 int add(int a, int b) 就是一个完整可用的跨进程方法——客户端传两个 int,服务端计算后返回结果。编译后自动生成的类名是 IAddOperation,内部带有 Stub 和 Proxy 两个静态内部类。服务端继承 Stub,客户端通过 asInterface() 获得 Proxy。
3.3 服务端进程与 onBind 的正确姿势
// AddService.kt class AddService : Service() { private val binder = object : IAddOperation.Stub() { override fun add(a: Int, b: Int): Int { Log.d("AidlDemo", "add() called, pid=${Process.myPid()}, tid=${Thread.currentThread().id}") return a + b } } override fun onBind(intent: Intent?): IBinder { return binder } }关键点有两处。其一是 Stub 的匿名内部类写法,这是最常见的实现方式,直接把业务逻辑写在接口方法里;其二是 onBind 返回的 binder 就是客户端拿到的 IBinder 对象。注意 add() 执行在 Binder 线程池,不是 Service 所在的主线程,所以在这里做耗时操作不会直接卡 UI,但要小心多客户端并发时 b 线程安全问题。日志里打印 pid 和线程 id 是验证跨进程的有效手段。
Manifest 里的配置是另一个关键点:
<service android:name=".AddService" android:exported="true" android:process=":remote" />android:process=":remote" 让服务运行在独立进程,这是“跨进程”成立的基石。没有这一行,整个 Demo 就是同进程调用,虽然也能跑通但完全验证不了 AIDL 的跨进程能力。android:exported="true" 是让服务可以被外部绑定,严格生产环境里通常会加上 permission 限制,但 Demo 阶段先放开,省得排查绑定权限问题。
3.4 客户端绑定流程:bindService 完整代码
// MainActivity.kt class MainActivity : AppCompatActivity() { private var operation: IAddOperation? = null private var bound = false private val connection = object : ServiceConnection { override fun onServiceConnected(name: ComponentName?, service: IBinder?) { // service 是 Binder 对象,asInterface 是关键的转换入口 operation = IAddOperation.Stub.asInterface(service) bound = true } override fun onServiceDisconnected(name: ComponentName?) { // 注意:这里不是客户端主动解绑时触发,而是服务端进程异常死亡时触发 operation = null bound = false } } override fun onStart() { super.onStart() val intent = Intent(this, AddService::class.java) bindService(intent, connection, Context.BIND_AUTO_CREATE) } override fun onStop() { super.onStop() if (bound) { unbindService(connection) bound = false } } private fun doAdd() { // 跨进程调用不要放在主线程,这里演示时先省略,后面章节详述 val result = operation?.add(1, 2) textView.text = "result = $result" } }bindService 的第三个参数 BIND_AUTO_CREATE 表示绑定时如果服务没启动就自动创建,这是最常见的选择。IAddOperation.Stub.asInterface(service) 做的事情是:检查传入的 binder 是否来自本进程,如果是就直接强转成 Stub,否则包一层 Proxy。这个判断由系统生成的代码自动完成,开发者不需要关心。doAdd() 里目前直接在 UI 线程调用,严格说是不对的,跨进程调用默认是同步阻塞的,这里暂时这么写是为了演示最小闭环,下一章会把这个坑填上。
3.5 编译生成物:明白你手里拿的是什么
第一次跑通后值得去看一眼 build/generated/aidl_source_output_dir 下的生成代码,花十分钟读一下生成的接口文件。你会发现里面有两个关键静态类:Stub 和 Proxy。Stub 里有 DESCRIPTOR 字符串(服务标识)、onTransact() 方法和 asInterface();Proxy 里是每个接口对对应的 transact() 调用。理解了这两个类的分工——Stub 负责解包分发,Proxy 负责打包发送——你就掌握了 AIDL 的全部秘密。很多让人百思不得其解的线上问题,追到根上都是这两个类的工作方式和你想的不一样。
4. 数据类型与方向关键字:跨进程传参的细节与边界
4.1 支持类型全罗列与常见翻车点
AIDL 支持的基本类型有八种 Java 基本类型(boolean、byte、char、short、int、long、float、double)、String、CharSequence、List、Map 以及自定义 Parcelable。注意它默认不支持 java.util.Date,不支持普通对象,不支持接口具体实现类。List 和 Map 只是接口声明,实际传递时两端都必须是 ArrayList 或 HashMap 的实现类,否则运行时类型检查会报错。自定义 Parcelable 必须额外编写同名 .aidl 声明文件,这就是下一节要讲的核心。
常见的翻车点是试图直接传一个 List ,而且 SomeClass 没有 Parcelable 实现,编译直接报错;或者自定义类明明写了 Parcelable 却忘了写 .aidl 声明文件,报错信息还特别抽象。另一个隐蔽的坑是 Parcelable 类放进 List 后,客户端拿到的每个元素都是服务端对象的一个反序列化副本,修改元素不会影响服务端原始对象——除非在参数上标了方向关键字。
4.2 方向关键字 in / out / inout:你必须写明的三张牌
AIDL 里每个非基本类型参数都要带方向标签,否则编译报错。in 表示数据从客户端流向服务端,out 表示从服务端流向客户端,inout 是双向。基本类型参数也可以带方向标签,int 标 out 时服务端可以回填一个值,客户端能接收到变化。没有方向标签的参数,编译期不会通过审查。
interface IQueryService { // 常规场景:查询,数据往里传,结果通过返回值回传 String queryByName(in String name); // 批量回传场景:服务端把匹配的集合写进 out 参数 void queryByAge(int age, out List<String> names); }out 参数的语义是:客户端传给服务端的初始值会被忽略,服务端重新赋值后回传。这意味着客户端调用前不需要初始化 out 参数的内容,服务端返回后参数对象里才有有效数据。inout 是两者兼有,但性能最差,因为它需要双向各序列化一次。我通常的建议是:能用 in 就用 in,能用返回值回传就不要用 out 参数,inout 能避免就避免。方向标签选错不会报错,但序列化开销和逻辑混乱会在项目变大后找上门。
4.3 自定义 Parcelable 的完整落地流程
当接口需要传一个复杂对象时,分三步走。第一步,写一个实现 Parcelable 的类;第二步,写同名的 .aidl 声明文件;第三步,在接口 .aidl 里 import 并使用这个类。
// User.kt data class User( val name: String, val age: Int ) : Parcelable { constructor(parcel: Parcel) : this( parcel.readString() ?: "", parcel.readInt() ) override fun writeToParcel(parcel: Parcel, flags: Int) { parcel.writeString(name) parcel.writeInt(age) } override fun describeContents(): Int = 0 companion object CREATOR : Parcelable.Creator<User> { override fun createFromParcel(parcel: Parcel): User = User(parcel) override fun newArray(size: Int): Array<User?> = arrayOfNulls(size) } }对应的 User.aidl 声明文件:
// User.aidl package com.example.aidldemo; parcelable User;这个文件只有一行有效代码:parcelable User。它告诉 AIDL 编译器这个类已经有 Parcelable 实现,编译接口时按 Parcelable 处理。这里有个很大的迷惑点:有人以为要在这里声明类的字段,其实完全不需要,字段是 Kotlin/Java 文件的事,AIDL 声明文件只是注册一下类型。
// IUserService.aidl package com.example.aidldemo; import com.example.aidldemo.User; interface IUserService { User getUserById(int id); }生成代码后,客户端调用 getUserById 拿到的 User 对象就是反序列化出来的新对象,与服务端那个不是同一个实例,但字段值完全一致。需要注意 parcelable 类里如果有 List 或自定义嵌套对象,也得保证它们都是可 Parcelable 的,否则序列化会崩。
4.4 Parcelable 的字段顺序与版本兼容
Parcelable 序列化时字段是按 writeToParcel 和 readFromParcel 的书写顺序匹配的,不是按字段名匹配。服务端先写 name 再写 age,客户端就必须先读 name 再读 age。这个顺序微妙地决定了跨进程两端版本不一致时的表现:少写一个字段不会直接抛异常,而是会把后面的字段全部读错,出现“用户名变成了年龄”这种诡异问题。所以自定义 Parcelable 类一旦发布,字段顺序和类型就不要轻易改动,确实要加字段时,在末尾追加并以旧版本兼容方式读取,能避免大量运行时问题。
4.5 泛型与嵌套容器的处理注意点
AIDL 接口里写 List<Map<String, User>> 这样的嵌套结构是允许的,但要注意:List 的元素类型在生成代码后会被擦除,服务端返回时实际上返回的是一个 ArrayList,客户端拿到的泛型信息只在编译期存在。运行时如果服务端往 List 里塞了别的类型,客户端遍历时才会在 ClassCastException 里暴露。跨进程传大数据集合时,Binder 事务缓冲区有 1MB 的上限,超过会抛 TransactionTooLargeException。这不只是理论值,真实项目里传一个几百 K 的列表再加上序列化膨胀就可能触顶。常见做法是改用分页查询、传文件路径或使用 ContentProvider 在进程间共享大数据,而不是硬塞进 AIDL 参数。
5. AIDL 避坑指南:翻车点与排查路线
5.1 绑不上:bindService 返回 false 或 onServiceConnected 不触发
现象是 bindService 方法返回 false,或者没有任何回调日志。原因通常不在代码而在 Manifest。最常见的是 Service 没有配置 android:exported="true",或者进程名写错;其次是 Intent 隐式绑定在 Android 5.0 之后已经被限制,必须显式指定包名和类名。在单工程双进程模式下,Intent 用 Intent(this, AddService::class.java) 就是显式绑定,问题基本集中在 exported 配置。
解决方法是给 Service 补上 android:exported="true"。如果是跨应用绑定,注意客户端应用还需要在 Manifest 里声明对服务端包的可见性,即 配置。排查顺序是:先确认 Service 的进程是否真的跑起来了,用 adb shell ps 查看进程列表;再确认绑定 Intent 是显式构造;最后看 Logcat 里有没有 Service 启动异常的堆栈。很多时候是代码里 try-catch 吞了异常,导致只看到绑定失败却看不到原因。
5.2 主线程调用跨进程方法导致 ANR
很多人在 Demo 里直接在主线程调 operation?.add() 并且成功返回,就以为没问题。实际上 Demo 里服务端处理得足够快,ANR 不会触发。但生产环境里服务端要做数据库查询、网络请求,处理时间可能超过 5 秒,主线程卡死的后果就是用户看到界面冻结,然后系统弹 ANR 对话框。一定要养成习惯:所有 AIDL 跨进程调用放到子线程。
// 正确姿势:协程调度到 IO 线程执行跨进程调用 lifecycleScope.launch(Dispatchers.IO) { val result = operation?.add(1, 2) withContext(Dispatchers.Main) { textView.text = "result = $result" } }Dispatchers.IO 线程池里发起 AIDL 调用不会阻塞主线程。还有一个细节:跨进程调用是同步阻塞的,即使放到子线程,服务端处理慢,这个子线程也会一直等着。如果频繁调用且服务端慢,协程会堆积,所以服务端接口设计上要尽量轻量。实在有重活就在服务端内部起异步线程做,立即返回一个受理编号,等处理完再通过回调通知客户端。
5.3 序列化失败:TransactionTooLargeException 和 BadParcelableException
这两个异常是跨进程数据传递里最常遇到的心头痛。TransactionTooLargeException 是 Binder 事务缓冲区溢出,通常发生在传了大图片、大列表或长字符串时,异常直接抛在调用方进程。解决思路是缩小单次传输数据量:能分页就分页,能传文件路径就传文件路径,千万别试图一杯子装下一片海。BadParcelableException 则多半是服务端返回了客户端不认识的自定义类型——客户端的 APK 里没有对应类的实现,或者混淆时改了类名和结构。
排查 BadParcelableException 时,先看报错堆栈里哪个类加载失败,再去对照两端的代码是否同步。混淆项目里要给 Parcelable 类加 keep 规则。一个稳妥的做法是把 AIDL 文件和相关 Parcelable 类放进一个单独的 library module,两端依赖同一个 module,这样从根上规避版本不一致的问题。这个代价在项目初期就能付,比线上排查省时间得多。
5.4 连接断开后没有重连机制:回调失效和对象过期
ServiceConnection 的 onServiceDisconnected 不是客户端主动 unbind 时触发,而是服务端进程异常死亡时由系统回调。它的意义是告诉客户端“服务已经没了”。很多初学者的代码里没有处理这个回调,导致界面按钮点击后调用空对象、或者回调监听全部失效。处理方式很简单:在 onServiceDisconnected 里清空本地引用,重新 bindService,等待下一次 onServiceConnected。
override fun onServiceDisconnected(name: ComponentName?) { operation = null // 自动重连 bindService(Intent(this, AddService::class.java), this, Context.BIND_AUTO_CREATE) }有一个细节容易踩:onServiceDisconnected 回调里立刻 bindService 是安全的,但如果此时 Activity 已经 onStop,绑定会失败。所以更稳妥的做法是设置一个 needsReconnect 标记,在 onStart 里统一检查并恢复绑定。另外,客户端持有的回调对象(如果注册到服务端)在这次进程死亡后也随之失效,服务端如果还持有旧回调引用,调用时不会有响应也不会崩溃,但会形成一次无声的丢失。实现回调反注册的接口对生产环境必不可少。
5.5 多进程下 Application 被创建两次的坑
当 Service 配置了独立进程,你会发现 Application 的 onCreate 执行了两次——一次是主进程,一次是 remote 进程。这在初始化 SDK、埋点、数据库时特别炸裂:同一个初始化逻辑跑两次,可能造成重复创建数据库、重复拉起耗时任务。解决方法是按进程判断只初始化需要的部分:
class DemoApplication : Application() { override fun onCreate() { super.onCreate() val processName = getProcessName() if (processName.endsWith(":remote")) { // 只做服务端进程需要的初始化,不做 UI 相关初始化 return } // 主进程初始化:埋点、崩溃收集、数据库 } }判断进程名称可以用 Application.getProcessName(),API 28 以上直接可用,以下版本可以自己读 /proc/self/cmdline。这个坑在标题这个 Demo 里不会立刻暴露,但当你往里面加业务逻辑时几乎是必踩的,提前在 Application 里写好进程分支,能省下后续定位奇怪重复初始化的几个小时。
6. 把 Demo 往前推一步:AIDL 的实际生产形态与进阶检查
一个真正用于线上的 AIDL 服务,不会只有一个 add 方法。它会有权限校验、异步回调、多客户端管理,还会配套一套接口版本管理机制。把 Demo 往这个方向推,值得做三件事。
第一,服务端增加权限校验。在 onBind 里判断客户端包名和签名是否白名单,或者用 Android 的 permission 机制包裹调用,避免任意应用都能调用你的服务端口。第二,引入回调接口,让服务端可以主动推送状态到客户端。你需要在 AIDL 文件里声明一个 callback 接口,客户端用 Stub.asCallback 包装后注册进去,服务端持有注册列表。这个模式是做播放器、下载任务、传感器数据流的基础。第三,把 AIDL 接口和实现类独立成 library module,让服务端进程和客户端主进程依赖同一个 module——从物理上保证接口一致性。这套结构能让你以后加方法、改参数时有单点修改的优势,再也不用两个工程同步改文件。
最后回到标题本身:这个 “简单的 aidl 例子” 确实简单,但它的意义在于让你亲手把进程的那堵墙摸一遍。只有在这种最小工程里体会过 bindService 的状态变迁、看过 TransactionTooLargeException 的堆栈、见过 onServiceDisconnected 悄然触发,后来在业务里面对真正的跨进程疑难时才不会慌。我自己的习惯是保留一个这样的最小可跑工程,每遇到新问题就往里加一个新 case,时间久了它就是一份活的 AIDL 排错手册。希望帮到你。
本文还有配套的精品资源,点击获取