简介:面向Android初学者的AIDL跨进程通信演示项目,基于完整AidlDemo工程,展示通过Android接口定义语言完成进程间调用的方式;AIDL常用于实现跨应用远程Service通信,项目包含可直接导入Eclipse或Android Studio的源码工程,并提供已编译的APK安装包,适合正在学习IPC、Binder机制与远程Service绑定的开发者动手实践。资源包采用zip压缩,共58个文件,主要由Java源码、AIDL接口定义、XML配置文件、PNG图片资源,以及class与dex等编译产物组成,整体体积仅181KB,目录涵盖src、res、assets、bin等模块,结构清晰便于按需查阅,工程配置完整可直接复用,省去手动搭建环境的麻烦。目前已有1797人学习下载,通过这个示例可以直观掌握服务端与客户端的搭建步骤、bindService调用流程、AIDL接口方法跨进程传递,以及Parcelable对象在IPC中的使用方式,对理解Android多进程架构和构建跨应用服务很有帮助。
1. AidlDemo 是什么:一次跨进程调用的最小模型
AidlDemo 这种命名,一看就是从最小可运行项目里长出来的:一个 .aidl 文件、一个 Service、一个绑定的 Activity。别小看“简单”这两个字,它把跨进程通信的完整链路压缩到了一条主线上——接口定义、定向 tag、Binder 代理、bindService、调用返回。真正常见的翻车点,反倒是在这些基本步骤的细节里:目录建错、包名不一致、定向词用错、绑定返回 false。这篇文章适合两类读者:第一次接触 AIDL,想用半小时跑通最小例子的新手;以及在多进程改造里反复踩坑,想把 Binder 通信的边界参数彻底理清的熟手。我会按单 App 双进程的方式组织 Demo,同一套代码能直接验证跨进程效果。
2. 把 AidlDemo 跑起来:最小工程的目录、版本与 Manifest
标题里写了“简单”,但实际卡住大家的往往不是 AIDL 语法本身,而是工程配置。AIDL 能不能被编译、Service 在哪个进程、绑定用显式还是隐式 Intent,这三件事决定了你的 Demo 是五分钟跑通还是一下午挣扎。我先给一个能直接照抄的目录结构,再逐个讲清每个配置项背后的原因。
2.1 用一个最小工程把 AIDL 目录建对
新建一个 Android 工程,选空 Activity 模板就行。创建完先别写界面,先把两个目录建出来。下面是我常用的最小布局:
app/src/main/aidl/com/example/demo/IRemoteCalculator.aidl app/src/main/java/com/example/demo/RemoteCalcService.java app/src/main/java/com/example/demo/MainActivity.java app/src/main/AndroidManifest.xml操作步骤只有三步:第一步在src/main下新建aidl目录,再按包名层级建子目录;第二步新建IRemoteCalculator.aidl文件;第三步让 .aidl 文件里的 package 声明和目录层级完全一致。这个一致性是整个编译期的第一道硬约束,不一致时 IDE 往往报错报得比较晚,有时候甚至编译能过,但生成的 Stub 类包名和你的引用对不上。
// 文件:app/src/main/aidl/com/example/demo/IRemoteCalculator.aidl package com.example.demo; interface IRemoteCalculator { int add(int a, int b); int getProcessId(); void fillData(out List<String> data); }这段接口定义了一个最简单的计算器:add演示常规传参和返回值,getProcessId用来验证跨进程,fillData里的out演示服务端回填数据。AIDL 里的List不需要手动 import,系统默认支持 java.util.List 框架类型。注意void fillData(out List<String> data);这种带定向 tag 的写法,后续会重点讲,它是新手最容易忽略的语义开关。
这里有个边界要说清楚:新工程请把 .aidl 文件放在src/main/aidl下。我在维护老项目时也见过 .aidl 和 .java 混在src/main/java下的情况,旧版构建工具会识别,但新版工程不要这么干,两个源码集同时生效很容易出现“改了一个文件、另一个同名文件被编译”的玄学问题。
2.2 版本与依赖的取舍:别让构建环境拖后腿
AIDL 是 Android 框架内置能力,不需要额外引入第三方依赖,这点比很多跨进程方案清爽。真正影响构建的是 Android Gradle Plugin 的配置。大多数新工程默认已经开启了 AIDL 支持,但当你遇到“.aidl 文件没被编译”或“找不到生成的接口类”时,可以检查一下这个配置:
android { namespace 'com.example.demo' compileSdk 34 defaultConfig { minSdk 21 } buildFeatures { aidl true } }buildFeatures.aidl true在绝大多数新建工程里是默认值,显式写出来的作用主要是排错时不至于漏掉这个开关。minSdk 21是我习惯的底线,AIDL 在低版本上也稳定运行,但如果你同时要兼容 Android 11 以后的包可见性规则,建议把 targetSdk 保持在一个较新的水平,否则部分行为会和低版本不太一样。
再提醒一个容易误会的地方:AIDL 不是动态代理,它是在编译期根据 .aidl 文件生成 Java 代码的。所以你不要在运行时想办法“动态加载一个 aidl”,所有接口必须在打包前生成好。想验证你的工程有没有成功进入 AIDL 编译流程,就看app/build/generated/aidl_source_output_dir下有没有生成对应目录和文件,这是最直接的判断依据。
2.3 Manifest 关键项:Service 注册、进程与 exported
Service 必须注册,否则绑定必失败。AidlDemo 的核心操作是给 Service 配一个独立进程:
<service android:name=".RemoteCalcService" android:process=":remote" android:exported="false" />android:process=":remote"表示这个 Service 运行在独立进程里,进程名最终是“包名:remote”。这一行是 Demo 的灵魂:AIDL 的价值在于跨进程,如果 Service 和 Activity 在同一个进程里,Binder 的很多边界问题根本暴露不出来。android:exported="false"表示不允许其他应用绑定,因为我们的 Demo 里 Activity 和 Service 同属于一个 App,显式 Intent 绑定完全不受影响。将来如果要把这个 Service 开放给别的 App,就需要把 exported 改成 true,并做好权限校验。
双进程带来的一个直接现象是 Application 会被初始化两次。你的 Application 类里的静态变量、单例,在 Activity 进程和 Service 进程里各有一份,互不相通。很多人第一次用:remote时会在 Service 里设置一个静态值,然后在 Activity 里读,结果读不到,这不是 AIDL 的问题,而是两个进程内存隔离。跨进程传值只能走 AIDL 方法,不要试图用静态变量传数据。
3. AIDL 接口三件事:包名、数据类型与定向 tag
AIDL 接口是客户端和服务端的“合同文本”,编译期就是靠这份合同生成两边的通信代码。写接口时最容易翻车的三块是包名对应关系、支持的数据类型范围、还有 in/out/inout 的语义。这一章把这三件事拆开讲,每件事都对应具体到能抄的写法。
3.1 接口文件与包名:目录、package 与 Stub 的对应
先明确一个硬规则:src/main/aidl下的目录层级必须等于 .aidl 文件里的 package 声明。也就是说,package com.example.demo;对应的是aidl/com/example/demo/这个路径。很多编译报错都源于这里不一致,常见症状是 IDE 里能打开 .aidl 文件,但写完 Sync 后找不着生成的IRemoteCalculator类。
这个规则背后的原因是生成代码的包名直接取自 package 声明。如果你的 .aidl 文件里写的是package com.example.demo.service;,那么生成接口就在com.example.demo.service包下,你在 Java 代码里就必须 import 这个包。我见过一种很绕的写法:目录路径和 package 不一样,然后靠工程配置的 sourceSets 硬把双目录映射到一起,这种做法维护成本极高,一旦换电脑或升级构建工具就各种翻车。新工程不要学这个,目录与 package 康康一致最省心。
AIDL 接口里的方法需要注意几点:不能有方法重载,因为 Binder 底层用方法序号来标识方法,同名不同参数的方法会让系统分不清调的是哪个;不能定义静态常量以外的变量;方法参数可以用定向 tag,返回值不算定向。接口里也可以定义常量,比如int VERSION = 1;,这是合法的,通常用来做协议版本标记。
3.2 数据类型支持范围与 in、out、inout 的真实语义
先过一遍 AIDL 支持的数据类型:八种基本类型、String、CharSequence、List、Map、Parcelable 对象、其他 AIDL 接口。这里有个重要边界:List 和 Map 的泛型元素也必须是上述类型,而且运行时的实际实现类有讲究。比如你用LinkedList传过去,服务端收到的可能被框架替换成ArrayList;Map 也类似。所以在 AIDL 边界上,老老实实用 ArrayList 和 HashMap 最稳。
定向 tag 是这一节的重点,我用一个表格把三者的差别钉死:
| 定向 | 客户端传入值的处理 | 服务端修改后是否回传 | 典型场景 |
|---|---|---|---|
| in | 会序列化传给服务端 | 不回传 | 普通入参,比如 add 的两个加数 |
| out | 客户端传入值被忽略 | 回传 | 服务端负责生成/填充的数据 |
| inout | 双向序列化 | 回传 | 需要服务端修改后带回来的对象 |
新手最容易犯的错是默认不写 tag。实际上 AIDL 的默认值是 in,也就是说如果你想让服务端填一个列表给你,但方法里没写out,那这个列表回来的时候还是空的。我见过不止一次有人在服务端往列表里 add 了数据,客户端却一只空列表,最后排查半天发现是定向 tag 漏了。
关于基本类型有个限制:基本类型不允许使用定向 tag,你不能写out int,编译期会直接拒绝。基本类型只能按值传递。另外out参数在服务端收到时相当于是未初始化的对象,你不能假设它有值;对于out List,服务端通常直接new ArrayList或者往参数里 add 都是安全的。使用inout时要注意:一个很大的自定义对象做双向序列化,开销会是 in 的好几倍,性能敏感路径上能不 inout 就别 inout。
3.3 编译产物:Stub、Proxy 与 asInterface 是怎么串起来的
编译后系统会为每个 .aidl 接口生成一个同名 Java 类,比如IRemoteCalculator,内部包含三个关键东西:Stub 抽象类、Proxy 类、还有asInterface静态方法。这三者的职责划分很清晰:Stub 是服务端要继承实现的骨架;Proxy 是客户端拿到的远程代理;asInterface是分发器,它判断 binder 对象和本地 Stub 是不是同一个进程,是就直接返回 Stub 对象本身,不是就包一层 Proxy。
IRemoteCalculator remote = IRemoteCalculator.Stub.asInterface(service);这行代码是客户端绑定后的核心一段。asInterface内部调用了queryLocalInterface判断本地性,如果服务端和客户端同进程,它会直接返回服务端那个 Stub 实例,此时你调用的方法就是普通 Java 方法;如果不同进程,返回 Proxy,方法调用会走 Binder 驱动。这也是为什么我强烈建议 Demo 一定要开:remote进程——不开的话你永远拿的是本地对象,完全感受不到 Binder 的存在。
Proxy 的工作过程等同于一个黑匣子:把方法编号、参数写入 Parcel,调用transact阻塞等待,再从 reply Parcel 里读出返回值。服务端那边则是 Binder 线程池收到请求,找到对应方法执行,再把结果打包回去。理解了这个机制,后面调试TransactionTooLargeException、ANR、参数没回传这类问题,就都能沿着这条链路定位了。
4. Service 端与客户端接线:从 Stub 实现到 bindService
接口定义完了,剩下的就是把两端接起来。服务端要做三件事:写一个 Stub 的实现类、在 onBind 里返回它、在 Manifest 里注册。客户端要做的事也是三件:创建一个 ServiceConnection、bindService、在回调里转出代理对象。这一章把两边代码完整贴出来,并解释每个参数为什么这么设。
4.1 服务端实现:写一个 Stub 匿名类并在 onBind 返回
public class RemoteCalcService extends Service { private final IRemoteCalculator.Stub mBinder = new IRemoteCalculator.Stub() { @Override public int add(int a, int b) { return a + b; } @Override public int getProcessId() { return android.os.Process.myPid(); } @Override public void fillData(List<String> data) { data.add("来自服务端进程: " + android.os.Process.myPid()); } }; @Override public IBinder onBind(Intent intent) { return mBinder; } }IRemoteCalculator.Stub是抽象类,所有接口方法都必须实现。这里有个细节要澄清:Stub 接口方法签名上并没有 out 关键字,out 的语义由 Binder 框架在序列化阶段处理,你只需在方法体里直接给入参赋值或填充内容就能实现“回填”。mBinder我是做成 Service 的成员变量,多个客户端同时绑定时它们共享同一个 Binder 实例。onBind返回这个实例即可,系统会负责把它跨进程传给客户端。
关于并发,要在这里给熟手提个醒:AIDL 方法默认运行在 Binder 线程池,多个客户端同时调用时,服务端方法可能被多个线程并发执行。如果你的服务端方法里有共享可变状态,比如一个全局列表或计数器,就要考虑加锁。很多简单 Demo 不做并发处理没问题,但项目里一旦有大量并发请求,就会发现 add 方法里的状态被交错修改,这种问题不好复现,排查起来很费劲。
4.2 客户端绑定:bindService、ServiceConnection 与调用代理
public class MainActivity extends AppCompatActivity { private IRemoteCalculator mRemote; private boolean mBound; private final ServiceConnection mConnection = new ServiceConnection() { @Override public void onServiceConnected(ComponentName name, IBinder service) { mRemote = IRemoteCalculator.Stub.asInterface(service); mBound = true; int serverPid = mRemote.getProcessId(); int clientPid = android.os.Process.myPid(); Log.i("AidlDemo", "serverPid=" + serverPid + ", clientPid=" + clientPid); } @Override public void onServiceDisconnected(ComponentName name) { mRemote = null; mBound = false; } }; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); Intent intent = new Intent(this, RemoteCalcService.class); bindService(intent, mConnection, Context.BIND_AUTO_CREATE); } @Override protected void onDestroy() { super.onDestroy(); if (mBound) { unbindService(mConnection); mBound = false; } } }这里有几个参数要讲清楚。new Intent(this, RemoteCalcService.class)是显式 Intent,在 Android 5.0 之后系统强制要求用显式方式绑定 Service,只写 action 的隐式绑定在后台限制下很容易失败。Context.BIND_AUTO_CREATE表示系统在服务不存在时自动创建它;如果去掉这个 flag,服务没有运行时会绑定失败。onServiceConnected回调在主线程,所以你可以在这里直接做 UI 更新。
unbindService必须和bindService配对调用,我在onDestroy里做了状态判断,防止 mBound 为 false 时重复解绑导致的异常。还有个线程模型的问题要提醒:mRemote.add(...)这类同步调用在跨进程时会阻塞当前线程直到服务端返回。如果你在主线程调用一个服务端实现里 sleep 了 5 秒的方法,主线程就卡住 5 秒,超过系统 ANR 阈值就是 ANR 弹窗。简单 Demo 无所谓,但真实项目里远程调用尽量放子线程或把服务端方法设计成 oneway。
4.3 用进程号验证真的跨了进程
上面代码里我已经打了两个 pid 的日志,这是最直观的验证手段。在:remote进程配置下,serverPid和clientPid一定是两个不同的值,这个现象直接证明了你的 bindService 走的确实是 Binder 通道。反过来,如果你把 Manifest 里的android:process=":remote"去掉,再跑一次,会发现两个 pid 一样,而且asInterface会直接返回本地 Stub 实例,此时再调getProcessId其实是本地调用。
另一种验证方式是同时打开 Android Studio 的 Logcat,用进程过滤器分别看com.example.demo和com.example.demo:remote两个进程的日志输出。服务端方法里的日志会出现在 remote 进程,Activity 里的日志出现在主进程,两条日志交错着看,就能把 Binder 的调用链路串起来。建议把这个验证步骤当成 Demo 的验收标准:只要你能看到两个不同 pid,AidlDemo 的骨架就算真正跑通了。
5. AidlDemo 避坑排查:5 个让你绑定失败或数据丢失的高发坑
写 AIDL Demo 的时候,运行不通过大多集中在绑定阶段和数据传递阶段。下面这些坑我基本都亲眼见过,有些是语法层面的,有些是系统版本策略导致的。按“现象 → 原因 → 解决”的顺序写,方便你对照排查。
5.1 绑定失败:bindService 返回 false 和 SecurityException
现象:调用bindService后返回 false,onServiceConnected永远不回调;或者直接抛SecurityException。常见于把 Demo 从同一个 App 改成两个 App 联调时。
原因:第一,Service 没有在 Manifest 注册,bindService 找不到目标;第二,Intent 用的是隐式 action,而系统在有多个匹配或目标应用可见性受限时会拒绝绑定;第三,跨应用绑定时目标 Service 的exported="false",导致没有权限;第四,Android 11 之后默认包可见性收窄,你的应用看不到其他应用的组件。这里的“玄学”感最重,因为直接看代码感觉都对,但系统在底层把路堵死了。
解决:先把 Intent 改成显式构造,同一个 App 内直接用new Intent(this, RemoteCalcService.class);跨应用时用new Intent().setComponent(new ComponentName(pkg, serviceClass)),并确认对方 Service 是 exported。如果目标 Service 在别的 App 里,还需要在调用方的 Manifest 里声明<queries>才能查询到对方组件。按这个顺序排查,百分之九十的绑定失败都能解决。
5.2 数据传不回来:定向 tag 用错导致回填失效
现象:客户端传入一个 ArrayList 给服务端,服务端往里面填了数据,方法正常返回,但客户端拿到的列表还是空的。
原因:方法参数没写定向 tag,默认是 in。in 方向的语义是客户端数据传给服务端,不要求服务端修改后回传,Binder 在返回时直接把 reply 里的该参数丢弃了。
解决:需要服务端回填的参数,在 .aidl 里显式写成out或inout。只要求服务端生成数据时用out,既省一次序列化开销,语义也清晰;既要读入又要带回时用inout。写完记得重新编译,因为 .aidl 文件改动后生成的 Java 代码不会自动热更新,有时候你改了 .aidl 但没触发重新构建,跑起来还是老逻辑,这个坑很多人没意识到。
5.3 大数据传输崩溃:TransactionTooLargeException
现象:调用一个 AIDL 方法传了个很大的对象或列表,客户端直接抛TransactionTooLargeException,有时表现为应用闪退或莫名连接断开。
原因:Binder 事务缓冲区最大约 1MB,这个限制是内核驱动层面的硬限制,不是应用层配置能改大的。路径上可能有多个 Binder 调用累积,比如系统服务在分发 Intent 时也会占用一部分事务空间,所以实际可用量往往不到 1MB。
解决:AIDL 不是为大数据传输设计的。超过几百 KB 的数据,建议先写入文件或数据库,把文件路径传给对方再读取,或者直接换成别的方案。在 AidlDemo 里我们要学会识别这个红线:简单例子传几个字符串没问题,一旦你的方法开始传 Bitmap、大 JSON,就要考虑换通道了,不要在 Binder 上硬扛。
5.4 跨进程类找不到:ClassNotFoundException 与 Parcelable 包名不一致
现象:服务端或客户端在反序列化自定义对象时抛ClassNotFoundException,多发生在应用启动后第一次调用 AIDL 方法时。
原因:AIDL 里使用自定义 Parcelable 对象时,必须在同名同包的 .aidl 文件里声明引用,比如parcelable Person;,而且 Person 类的包名要和这个声明一致。如果两端 APK 里 Person 类的包名不同,Binder 反序列化时就找不到目标类,直接崩掉。这个坑在单 App Demo 里不容易触发,一旦拆成两个应用,各写各的包名结构,问题就冒出来了。
解决:自定义 Parcelable 的包名和声明保持唯一,两个端都要有同类同包名的类文件。新手最容易犯的是拿 Android Studio 的自动生成 Parcelable 插件随手改包名,改完只改了一端,另一端还是旧包名。多端联调时先统一类包名,再写代码。
5.5 Android 12 之后的 exported 构建报错
现象:构建期或安装期报“android:exported needs to be explicitly specified for ”,或者运行时被系统拒绝绑定。
原因:Android 12 起,只要组件声明了 intent-filter,或者组件对跨应用可见,系统就强制要求显式声明android:exported,不再用默认值推断。你的 Service 即使只给本应用用,只要不写这个属性,在某些构建配置下就直接报错。
解决:在 Manifest 的 service 节点里显式写上android:exported="false"。如果这个 Service 要开放给别的应用,改成 true,同时建议加 signature 级别或自定义权限保护,避免任意应用都能绑定。补上这个属性后,构建期报错消失,绑定阶段的安全问题也一并解决了。
6. 进阶技巧:给 AidlDemo 加回调与死亡代理,让跨进程通信更耐用
简单 Demo 跑通后,下一步通常是加两个能力:服务端主动往客户端推消息,以及服务端进程挂掉后客户端能感知并重连。这两件事分别对应 RemoteCallbackList 和 DeathRecipient。
先看回调。定义一个监听器接口:
// IOnDataChangeListener.aidl package com.example.demo; interface IOnDataChangeListener { void onDataChanged(String data); }服务端用 RemoteCallbackList 来管理客户端注册过来的回调,因为它内部做了跨进程 binder 的去重和线程安全,比你自己维护一个 List 稳得多:
private final RemoteCallbackList<IOnDataChangeListener> mListeners = new RemoteCallbackList<>(); // 在 IRemoteCalculator.aidl 里补充: // void registerListener(IOnDataChangeListener listener); // void unregisterListener(IOnDataChangeListener listener); private void notifyDataChanged(String data) { int count = mListeners.beginBroadcast(); for (int i = 0; i < count; i++) { try { mListeners.getBroadcastItem(i).onDataChanged(data); } catch (RemoteException e) { // 客户端断开时 RemoteCallbackList 会帮我们移除 } } mListeners.finishBroadcast(); }这里必须做到beginBroadcast和finishBroadcast配对调用,漏掉一个会让getBroadcastItem抛 IllegalStateException,这是 RemoteCallbackList 最容易踩的坑。客户端进程挂掉时,RemoteCallbackList 会自动清理它的注册,不需要你手动移除。
再看死亡代理。客户端拿到mRemote.asBinder()后,可以注册一个 DeathRecipient 监听服务端进程死亡:
private final IBinder.DeathRecipient mDeathRecipient = () -> { // 服务端进程挂了,在这里做重绑或 UI 提示 }; // 绑定成功之后 mRemote.asBinder().linkToDeath(mDeathRecipient, 0); // 解绑时 mRemote.asBinder().unlinkToDeath(mDeathRecipient, 0);这个方法只对跨进程代理有效,同进程时 asBinder 返回的是本地 Stub,link 不 link 没有意义。DeathRecipient 回调也在 Binder 线程里,不要在里面直接做耗时操作。我在真实项目里的习惯是:服务端进程死亡后,延迟 500ms 自动重新 bindService,加上一次失败上限,避免服务端没起来时无限重试。这个兜底逻辑听起来简单,但线上很多“跨进程通信偶发失效”的问题,最后都是靠这层重连机制压下去的。
把回调、定向 tag、死亡代理这三样玩明白,AIDL 的日常使用基本就到顶了。剩下的像 oneway、权限校验、接口版本管理,都是在这些基础上的延伸。希望这套踩坑路径能帮你的 AidlDemo 从“能跑”走到“敢用”,真到了生产环境,你就能少走我当年那些弯路了。
本文还有配套的精品资源,点击获取