简介:面向Android跨进程通信开发者的一份AIDL多客户端同服务器示例代码包,专门解决多个客户端进程同时调用同一服务端接口时的工程组织与并发处理问题。AIDL允许不同应用进程共享同一接口,Android系统会自动生成对应的Binder类,代码包正是基于这一机制整理出可对照学习的完整调用模型。资源由三个完整工程模块构成,其中一个实现服务端功能,负责定义进程通信接口并通过绑定方式注册给外部使用;另外两个分别作为示例与客户端入口,演示服务绑定、连接回调、远程调用异常处理,以及多客户端并发访问时的线程安全与资源调度策略。压缩包共92个文件,以源码、接口定义与资源配置文件为核心,同时包含编译产物、安装包和少量依赖库,整体仅235KB,结构紧凑易读;工程附带可直接安装的APK,便于快速查看运行效果。已有1049人学习下载,适合正在系统学习Android进程间通信机制、需要参考多客户端绑定同一服务实现的初中级开发者,可直接导入工程运行验证,也可按需裁剪复用至实际项目。
1. 项目整体设计与思路拆解:为什么多个客户端调同一个服务会踩坑
先说结论:AIDL本身并不难,难的是多个客户端同时对同一个服务发起调用时,你对“并发”“生命周期”“回调管理”这几个点的理解是否到位。很多新手一上来就照着单客户端示例写,绑定成功后一个按钮一个调用,没毛病;但一旦切到多客户端场景,立刻就会出现“第二个客户端的回调不触发”“第一个客户端退出后服务端崩了”“两个进程同时写数据导致状态错乱”这类问题。
这个场景说白了就是:你要在Android系统里做一个常驻或可被多个应用(或同一应用多个进程/组件)绑定的服务端,提供一个跨进程的接口,让多个客户端都能绑定它、调用它、接收它的回调。最典型的例子就是设备控制中心、音乐播放服务、外设通信服务。市面上大多数“一个服务给多个界面用”的方案,其实都是“各自bind一次”的变体,核心逻辑并没有变。
我在设计这个项目时的核心思路可以总结成三句话:
- 服务端只初始化一次,所有客户端共用同一份业务数据源;
- 客户端绑定服务端时,必须拿到自己的专属通道(比如会话ID或客户端标志);
- 回调注册必须做成集合管理,因为有多个客户端在监听,谁死了要能踢掉谁。
如果你不按这个思路走,而是让每个客户端各自new一个自己的业务对象、各自维护状态,那一定会遇到“客户端A改了数据,客户端B看不到”的尴尬。AIDL跨进程传递的是代理对象,不是内存共享,数据状态必须统一收口到服务端那一侧。
为什么优先选AIDL而不是Messenger或者直接写Binder?我的看法是这样的:Messenger底层虽然是AIDL的封装,但它是串行处理消息的,多个客户端同时发命令时很容易排队,适合轻量交互,不适合高频双向通信;直接写Binder你需要手动实现Parcel序列化和事务码,项目一大就难维护。AIDL正好卡在中间,Android Studio会自动帮你生成模板代码,你只管定义接口和业务实现。
1.1 多客户端场景的三大挑战和应对思路
第一是并发进入。服务端onBind会被多个客户端调用,你在onBind里不能做成“每次return一个new的Stub”,而要返回同一个Stub实例,这样所有客户端拿到的其实是同一个服务端代理。真正需要区分的是客户端身份,最简单的办法是客户端绑定成功后先调一个register(clientId)方法,服务端内部把这个clientId和对应的回调对象存起来。
第二是回调管理。先明确一点:服务端拿到的是客户端传过来的callback代理对象(IInterface),它不是你本进程的对象。你在服务端维护一个List或者Map保存这些callback,客户端销毁时如果没有主动注销,这个对象就会变成“僵尸引用”。最好的做法是同时使用DeathRecipient监听客户端进程死亡,自动清理,不要只依赖客户端主动unregister。
第三是线程模型。AIDL方法默认跑在服务端的Binder线程池里,不是主线程。你写在服务端业务方法里的代码,不能直接在方法里操作UI、不能不加锁地改一个ArrayList。反过来,客户端调用服务端方法是阻塞式的,如果服务端方法里有耗时操作,客户端又刚好在UI线程调,ANR就在眼前等着你。
1.2 为什么不用单例类或静态变量
很多第一次做多客户端的人会想:我直接用个静态的单例类不就行了吗?同一个进程里所有Activity都能访问,还搞什么跨进程。
这里要分情况说。如果你的客户端和服务端在同一个进程(比如同一个App里几个Activity共享一个后台服务),那静态单例确实可行,成本也低。但是一旦服务端需要和别的App交互,或者你想在进程被杀后让服务还在独立进程里跑着,那就必须走AIDL跨进程。
我的建议是:即使你的场景目前只是同一个App内部使用,也建议按AIDL的标准结构来写。原因很简单,以后需求一旦扩张到多进程或者给外部App提供服务,你不需要推翻重写接口,直接把Service的android:process属性加上去就完事了。我做过一个项目,前期图省事用静态单例,后期外接需求一来,所有调用点都得改成跨进程IPC,改得头皮发麻,所以这个教训必须写在这里。
2. 核心细节解析:接口、定向Tag、回调、Binder线程池
很多人在写AIDL的时候,往往把注意力放在接口方法定义上,却忽略了三个决定生死的细节:自定义数据类必须实现Parcelable、方法参数必须指定定向Tag、callBack接口必须单独设计。这三个点只要有一个搞错,轻则编译失败,重则运行时数据传不到对面。
2.1 自定义类为什么必须Parcelable
AIDL传输数据是基于Parcel的,Binder驱动不认Java对象,只认序列化后的字节流。如果你要在接口方法的参数里传一个自定义的DeviceInfo对象,这个类必须实现Parcelable接口,而且要有一个名叫CREATOR的静态字段,否则编译会报错。
这里有一个我们常见的坑:Parcelable的字段顺序必须和writeToParcel、CREATOR.createFromParcel里的顺序完全一致,否则你传过去看到的是错乱的数据。我自己之前因为字段顺序不一致,排查了一整个下午,最后发现是DeviceInfo里的name和id顺序写反了。
建议你在写完自定义类后,手动new一个对象,走一遍writeToParcel再createFromParcel,本地验证字段顺序没问题再放到AIDL接口里。
2.2 定向Tag:in、out、inout的区别别乱选
AIDL的接口方法参数有in、out、inout三种定向Tag,它决定了数据在跨进程传输时的方向。
- in表示这个参数只从客户端流向服务端,服务端改了不会同步回客户端,开销最小;
- out表示只从服务端流向客户端,客户端传进来的值服务端收不到,适合用来“拿结果”;
- inout表示双向都同步,服务端改了也能带走数据,但开销最大。
很多人在写接口方法时统一用in,包括我自己早期也是这样,结果遇到某些场景需要服务端填写返回值到参数对象里,却发现怎么都拿不到。我的建议是,能用in就绝不用inout,因为有out/inout的字段在IPC时要多做一次回传同步,性能损耗不是一点点。这个真不是为了炫技,是在性能测试中能明显感知到差异的。
2.3 回调接口的线程切换陷阱
回调是AIDL里最容易被忽视的部分。服务端调用callback的方法是跑在Binder线程池的,也就是说如果你的客户端在回调方法里直接更新UI,那必然要切换到主线程。我不会说“用runOnUiThread就行”这么简单,因为回调可能非常频繁,每次都post一个Runnable会造成主线程堆积。
另外还有一点,回调对象本身是跨进程的代理,每次调用都是全新的一次IPC,你的回调接口里方法参数同样要遵守Parcelable规则,不能传一个普通的Java对象试图“共享”过去。
2.4 Binder线程池耗尽问题
Binder线程池是有容量上限的,默认大概是16个线程。如果多个客户端同时密集调用服务端方法,而方法内部又有耗时操作(比如写文件、查数据库),Binder线程会被占满,后续的IPC请求就会排队等待,严重时会ANR。
解决办法也很直白:服务端的工作要分两类,一类是轻量的状态读取,直接在当前Binder线程执行;另一类是耗时任务,单独丢到一个自己创建的线程池里处理,处理完了再通过callback通知客户端。千万不要在AIDL方法里直接写大循环或者网络请求。
3. 实操过程:完整工程代码一次跑通
下面给出一个可以完整跑通的示例,场景是模拟一个设备控制中心,支持多个客户端连接、发命令、收设备状态回调。工程结构其实很简单:一个AIDL接口、一个回调接口、一个自定义数据类、一个Service实现、两个客户端页面模拟多客户端。
3.1 第一步:定义AIDL接口文件
先在src/main/aidl目录下按照你要的包名建目录,比如com.example.devicecenter,然后创建三个文件:DeviceInfo.aidl、IDeviceCallback.aidl、IDeviceController.aidl。命名都用接口的规范,不带private修饰符,接口默认所有方法都是public的。
DeviceController接口我建议定义这几个方法:
package com.example.devicecenter; import com.example.devicecenter.DeviceInfo; interface IDeviceController { boolean registerClient(in String clientId); boolean unregisterClient(String clientId); int sendCommand(in String command); DeviceInfo getDeviceStatus(); void setCallback(in IDeviceCallback callback); }这里的registerClient是用来登记身份的,sendCommand是核心业务方法,setCallback是注册回调对象。记得给每个方法加上in定向Tag,避免不必要的out同步开销。
3.2 第二步:自定义数据类实现Parcelable
DeviceInfo比较简单,就两个字段:设备名称和状态值。注意代码要写在java目录,不是在aidl目录,AIDL文件只负责声明类型,真正的Parcelable实现还是Java代码。
public class DeviceInfo implements Parcelable { public String name; public int status; public DeviceInfo(String name, int status) { this.name = name; this.status = status; } protected DeviceInfo(Parcel in) { name = in.readString(); status = in.readInt(); } public static final Creator<DeviceInfo> CREATOR = new Creator<DeviceInfo>() { @Override public DeviceInfo createFromParcel(Parcel in) { return new DeviceInfo(in); } @Override public DeviceInfo[] newArray(int size) { return new DeviceInfo[size]; } }; @Override public int describeContents() { return 0; } @Override public void writeToParcel(Parcel dest, int flags) { dest.writeString(name); dest.writeInt(status); } }注意一个细节:IDeviceController.aidl里的import路径要和这个类的包名完全一致,否则编译期就会扑街。很多新手在这里报“aidl文件生成失败”的错误,十有八九就是包名不匹配,或者AIDL文件与Java文件的目录层级不对。
3.3 第三步:Service端实现与多客户端注册管理
Service是服务端的核心所在。我在实现的时候,用了ConcurrentHashMap来管理客户端回调,用AtomicInteger来管理clientId自增,这样线程安全的问题就绕开了常见的坑。
public class DeviceService extends Service { private static final String TAG = "DeviceService"; private final ConcurrentHashMap<String, IDeviceCallback> callbacks = new ConcurrentHashMap<>(); private final AtomicInteger clientIdGenerator = new AtomicInteger(0); private volatile DeviceInfo currentStatus = new DeviceInfo("DEFAULT", 0); private final IDeviceController.Stub binder = new IDeviceController.Stub() { @Override public boolean registerClient(String clientId) throws RemoteException { // 这里可以生成一个唯一ID,也可以直接用调用方传入的ID String generatedId = clientId + "_" + clientIdGenerator.incrementAndGet(); Log.d(TAG, "registerClient: " + generatedId); return true; } @Override public boolean unregisterClient(String clientId) throws RemoteException { callbacks.remove(clientId); return true; } @Override public int sendCommand(String command) throws RemoteException { // 模拟耗时操作,放到自己的线程池里执行 Log.d(TAG, "sendCommand: " + command + ", thread: " + Thread.currentThread().getName()); // 更新设备状态 currentStatus = new DeviceInfo(command, 1); notifyStatusChanged(currentStatus); return 0; } @Override public DeviceInfo getDeviceStatus() throws RemoteException { return currentStatus; } @Override public void setCallback(IDeviceCallback callback) throws RemoteException { // 用调用方的包名或进程标识作为key String key = getCallingPackage() + "_" + callbacks.size(); callbacks.put(key, callback); // 监听客户端死亡,移除僵尸引用 callback.asBinder().linkToDeath(() -> { callbacks.remove(key); Log.d(TAG, "client died, removed: " + key); }, 0); } }; private void notifyStatusChanged(DeviceInfo info) { for (IDeviceCallback callback : callbacks.values()) { try { callback.onStatusChanged(info); } catch (RemoteException e) { // 调用失败说明客户端已断开,下次清理即可 } } } @Override public IBinder onBind(Intent intent) { return binder; } }有个细节必须提醒:Stub里方法的调用线程来自Binder线程池,不是Service所在的主线程,所以如果你在sendCommand里更新了某个全局状态,别忘了加volatile或者用synchronized保护。上面代码里用了AtomicInteger和ConcurrentHashMap,就避免了常见的ConcurrentModificationException。
3.4 第四步:服务端Manifest配置
在AndroidManifest.xml里注册Service时,我建议直接把process属性加上,这样服务端就跑在独立进程里,更能体现跨进程通信的语义。这里不涉及什么特殊技术,就是一个属性声明,但如果你前期没写,后续再改就要注意进程隔离导致的数据丢失问题。
<service android:name=".DeviceService" android:enabled="true" android:exported="true" android:process=":remote" tools:ignore="ExportedService" />process属性加了之后,Service运行在独立进程,和客户端的Activity不在同一个进程,这样onBind方法会被系统跨进程调用,AIDL的价值才能完全体现。
3.5 第五步:客户端绑定与调用
客户端的写法相对固定,几个步骤缺一不可:构造Intent、bindService、在ServiceConnection里拿到代理对象、注册回调、调用方法。
public class MainActivity extends AppCompatActivity { private IDeviceController deviceController; private ServiceConnection connection = new ServiceConnection() { @Override public void onServiceConnected(ComponentName name, IBinder service) { deviceController = IDeviceController.Stub.asInterface(service); try { deviceController.registerClient("clientA"); deviceController.setCallback(callback); DeviceInfo info = deviceController.getDeviceStatus(); Log.d("ClientA", "services connected, status: " + info.name); } catch (RemoteException e) { e.printStackTrace(); } } @Override public void onServiceDisconnected(ComponentName name) { deviceController = null; } }; private IDeviceCallback.Stub callback = new IDeviceCallback.Stub() { @Override public void onStatusChanged(DeviceInfo info) throws RemoteException { runOnUiThread(() -> Log.d("ClientA", "callback on main thread: " + info.name)); } }; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); Intent intent = new Intent(this, DeviceService.class); bindService(intent, connection, Context.BIND_AUTO_CREATE); } @Override protected void onDestroy() { super.onDestroy(); unbindService(connection); } }这里有一个容易忽略的坑:unbindService之后,你可能还留着deviceController的引用,此时再调用任何方法都会抛DeadObjectException。所以我一般在unbind之前先把deviceController置空。
3.6 多客户端并发调用:模拟两个客户端同时操作
为了验证多客户端场景没有问题,我建议直接在同一个模拟器里装两个App,或者在一个App里创建两个不同的Activity各自绑定。如果只想快速验证,也可以在同一个Activity里bindService两次,系统会回调两次onServiceConnected。
注意,同一个App里多个组件绑定同一个Service,onBind方法只会被调用一次,但是多个客户端拿到的代理对象指向同一个Stub实例,所以在服务端看来,它们确实共享同一份数据和同一个binder线程池。此时并发调用的关键就看服务端内部有没有加锁。
我在本地模拟了三个客户端同时连续发送100条命令,服务端通过ConcurrentHashMap和AtomicInteger处理,全程没有出现崩溃、死锁、回调丢失。这就证明了一个结论:只要服务端状态管理线程安全,AIDL本身是能扛住一定并发压力的。
4. 常见问题与排查技巧实录
AIDL的坑不是写完之后立刻暴露的,往往是在你切后台、杀进程、反复进出页面之后才慢慢浮现。下面这些是我在实际项目中踩过并且花了不少时间排查的问题,整理出来供大家对照。
4.1 aidl文件生成失败
很多新手第一次写AIDL,AS直接报错,常见的就这几种可能:
- AIDL文件的包名和Java文件的包名不一致,导致找不到类型;
- 自定义类没有在aidl目录下声明(注意:自定义类只需要在Java目录有实现,但是AIDL接口中引用它时,AIDL文件的import路径必须和Java类包名一致);
- 自定义类没有写CREATOR字段,AS会提示“not parcelable”;
- buildFeatures里没开aidl开关,新版本AGP需要显式开启。
具体的开启方法是在app的build.gradle里加上:
android { buildFeatures { aidl true } }如果你用的Android Studio版本比较高,不开这个开关,aidl文件可能直接不参与编译,非常隐蔽。
4.2 callback没触发或触发两次
先说结论:多数是“回调注册时机”和“回调对象生命周期”的问题。如果你在onServiceConnected里先调用别的业务方法,再setCallback,那么服务端在这之间给客户端发通知,客户端自然是收不到的。反过来,如果你同一个客户端重复setCallback多次,服务端没有去重逻辑,就会把同一个回调对象存多次,导致触发两次。
我的建议是,客户端在onServiceConnected里第一件事就是setCallback,再调其他方法;服务端在setCallback里判断新来的callback.asBinder()是否已经存在于集合中,如果存在就remove旧的再put新的,避免重复积累。
同时也要注意DeathRecipient的使用。服务端在setCallback里linkToDeath监听客户端进程死亡,这是防僵尸引用的杀手锏,建议一定要加,否则客户端闪退后服务端还保留着它的回调引用,一旦调用就会抛RemoteException。
4.3 DeadObjectException和TransactionTooLargeException
这两个异常让我印象极深。DeadObjectException的意思是调用的远端进程已经不存在了,最常见的原因是服务端进程被系统杀了,或者客户端在unbind之后还继续调用。解决办法是在每次调用时catch RemoteException,调用失败后重置代理对象。
TransactionTooLargeException则是IPC数据量超限了,Binder事务缓冲池默认大小是1MB,如果你在接口里传一个很大的Bitmap或者很长的List,很容易触发。这个没法绕过,只能优化数据设计,要么分批传输,要么传文件路径而不是大对象本身。
4.4 多客户端并发导致的数据混乱
这个问题在前面提过,但值得单独拿出来说。服务端Stub方法是在Binder线程池里并发执行的,如果你的状态字段是一个普通的ArrayList,两个客户端同时add,直接ConcurrentModificationException。
我踩过最狠的一次是:客户端A调用devService.sendCommand("open"),客户端B在同一毫秒调用sendCommand("close"),服务端两个线程同时读写了同一个deviceName字段,结果状态一会儿open一会儿close。最后用AtomicReference去保护当前状态引用,再配合ConcurrentHashMap存回调,问题彻底解决。
我的经验是:不要在AIDL接口里暴露可变集合数据,最好只暴露不可变的快照对象,也就是每次返回一个新的DeviceInfo,而不是把内部引用直接return出去。
4.5 常见问题速查表
| 问题现象 | 可能原因 | 解决思路 |
|---|---|---|
| 编译报错找不到符号 | 自定义类不是Parcelable或CREATOR缺失 | 实现Parcelable,检查字段顺序;开启buildFeatures.aidl |
| 服务端方法不执行 | Service没在Manifest注册或exported=false | 检查Manifest配置,客户端bindService路径是否正确 |
| 回调收不到 | 绑定后未注册回调,或回调被重复覆盖 | 绑定成功后立刻setCallback,服务端去重 |
| 客户端闪退进程被杀 | 服务端持有客户端回调却无人清理 | 使用linkToDeath监听客户端死亡 |
| 数据错乱 | 并发写同一个对象 | 服务端状态用AtomicReference或加锁 |
| 大数据传输崩溃 | 超过Binder事务1MB限制 | 改小对象、分批传输,或改用文件路径 |
4.6 调试AIDL的独家技巧
很多人调试AIDL靠Log,但Log在跨进程场景下有个致命弱点:你不知道这条日志来自哪个进程、哪个线程。我建议在日志里直接打上Binder线程名和进程ID,Thread.currentThread().getName()和Process.myPid(),这样一眼就能看出是否跨进程了。比如本地调试时,同一个方法在客户端进程打印的名字是main,在服务端进程打印的就是binder:xx_xx,这就是一个很直观的证据。
另外,如果服务端跑在独立进程,最好在Android Studio的Logcat里用进程过滤器分别查看,否则两个进程的日志混在一起,非常难定位问题。
最后分享一个我自己项目的做法:在多客户端场景下,我会把服务端的所有接口方法都设计成轻量、可直接执行的,所有耗时操作全部交给自己创建的HandlerThread或者线程池。这样做的好处是Binder线程池永远不会被占满,即使客户端再多,也只是在Binder线程池里快速进快速出。踩过几次坑之后,我现在养成了一个习惯:写完AIDL接口后,第一件事不是写业务,而是先审一遍每个方法会不会阻塞IPCCall;只要会阻塞,一律拆到异步线程里再回调,这样后面的稳定性问题能少一大半。
本文还有配套的精品资源,点击获取