简介:这是一份面向Android中高级开发者的AIDL多客户端通信实战源码包,聚焦多个客户端同时调用同一服务端的典型场景,帮助理解跨进程IPC、Binder机制与线程安全处理。包内包含T1、Service、T2三个可运行工程,提供AIDL接口定义、服务端Stub实现、客户端bindService调用及多客户端并发调用的完整示例,并附带已编译APK与dex文件,便于直接安装验证或对照源码学习。资源共92个文件,以Java源码、AIDL接口、XML布局、class编译产物及PNG资源为主,压缩包仅235KB,结构清晰。作者梳理了从AIDL定义、Binder生成到服务端注册、客户端调用的全链路代码,并对多客户端并发下的资源调度提出了优化思路。已有1049人学习下载,适合正在开发跨进程服务、消息总线或需要掌握AIDL高并发调用技巧的Android工程师参考。
1. AIDL 多客户端连一个服务端:为什么说这是 IPC 的完整形态
当你的 Android 项目里出现「多个客户端调用一个服务器」这样的 AIDL 需求时,你面对的不再是单进程里的假跨进程通信。真正的 IPC 场景里,服务端进程只有一个,但绑定它的客户端进程可能有三个、五个甚至更多。
常见做法是把服务端实现为一个常驻 Service,各客户端进程分别bindService,共用同一个 AIDL 接口收发数据。它解决的核心痛点是:多个独立进程需要共享一份数据状态、接收同一类事件推送,但彼此不允许直接访问对方内存。这类需求常见于跨应用配置同步、公共消息分发、多端状态上报等场景。
难点不在接口怎么设计,而在回调管理和线程模型。多客户端 AIDL 翻车,十有八九翻在这两个地方。这一篇会从连接模型讲起,把接口定义、服务端实现、客户端绑定、避坑排查和定向推送一次说清。适合已经写过单客户端 AIDL、准备或正在做多客户端改造的开发者。
2. 多客户端连接模型与 Binder 线程池:先搞清楚谁在跑、跑在哪
很多开发者第一次写多客户端 AIDL,会把服务端想象成一台 Web 服务器:每个客户端连接进来,服务端就开一个线程伺候。AIDL 完全不是这个模型,理解错了后面全是玄学。这一章先把连接模型和线程模型讲透,这两个概念直接决定了你后面的代码怎么写。
2.1 bindService 的语义:服务端进程只有一个,连接可以有很多
服务端是一个 Service 组件,在进程里只有一个实例,onBind返回的是同一个IBinder对象。多个客户端各自bindService时,系统不会为每个客户端复制一份服务端,而是把这个IBinder的代理对象分别交给每个客户端。客户端拿到的代理指向同一个服务端 Binder,所有调用最终都收敛到服务端那一个实例上。
这意味着服务端的成员变量天然就是共享的。比如你在 Service 里维护了一个ArrayList<String>,任何客户端通过 AIDL 方法往里面塞数据,其他客户端通过另一条调用读到的都是同一个列表的内容。不需要额外做单例设计,因为它本来就是单例。反过来,你需要操心的是多客户端并发访问这个共享状态时的线程安全。
服务端进程怎么放也需要提前决策。同一个应用内做多进程绑定,Service 可以跑在主进程;如果客户端是独立应用,最好给 Service 单独配一个进程,避免客户端把服务端进程拖垮。manifest 里的配置长这样:
<service android:name=".RemoteDataService" android:exported="true" android:process=":remote" />android:process=":remote"表示服务端跑在一个以冒号开头的私有进程里,冒号开头意味着只有本应用能往这个进程里放组件。android:exported="true"允许其他应用跨进程绑定。注意跨应用绑定时,服务端应用的 Application 和客户端应用的 Application 在各自进程里各自初始化,静态变量完全不共享——服务端进程里的静态数据和客户端进程里的同名变量没有任何关系,这个割裂感刚接触时特别容易让人困惑。
还有一个细节:bindService可以被同一个客户端调用多次。同一个客户端进程里用两个不同的ServiceConnection绑同一个 Service,服务端不会重建,但你会收到两次onServiceConnected回调。如果你每次回调里都注册回调对象,服务端那边就会多出重复的订阅。
2.2 Binder 线程池:多个客户端同时调用时的执行模型
客户端调用 AIDL 方法时,请求通过 Binder 驱动传到服务端进程,服务端由一个 Binder 线程池来执行这些方法。这个线程池是系统为每个进程统一维护的,默认上限一般是 16 个线程,具体数值随系统版本和厂商定制略有浮动。这 16 个线程不是你创建的,也不归你管,但你的 AIDL 方法体就运行在这些线程上。
这带来三个直接结论。第一,多个客户端的同时请求不会在服务端排队逐个执行,而是由线程池里的不同线程并发执行——你的 AIDL 方法体必须能承受多线程并发进入。第二,方法体里如果有耗时操作,比如写文件、查数据库、Thread.sleep(),它会占住一个 Binder 线程;几个客户端同时发起耗时调用,线程池很快就满了,后面的请求只能排队等,等超过 Binder 驱动超时阈值就报调用失败。第三,服务端 AIDL 方法体运行在 Binder 线程,不是服务端主线程,所以不能直接在方法体里操作主线程上的 View 或 Looper。
还有一个多客户端场景里很容易被忽略的现象:重入调用。客户端 A 调服务端方法,服务端方法里又调客户端 A 的回调,回调实现里再调服务端的方法——这条链路上后两次调用会复用同一个 Binder 线程,形成嵌套调用。Binder 机制允许这种重入,但线程栈会变深,一旦逻辑复杂就很难追踪。更麻烦的是,如果两个客户端同时触发这条链路,两条 Binder 线程会同时钻进同一个客户端的回调实现里,客户端回调方法的线程安全性就成了新的课题。
所以服务端 AIDL 方法的实现原则只有一条:方法体能多快就多快,耗时的逻辑一律丢到自己的工作线程里,方法只做入队和返回。这条原则在单客户端时还能侥幸糊弄过去,多客户端时线程池耗尽几乎是必然的。
2.3 oneway 与同步调用的取舍
AIDL 接口里的方法默认是同步的:客户端调service.getData(key)会阻塞等待服务端执行完并返回。多客户端场景下,同步本身不是问题,Binder 线程池让请求并发执行;但要区分哪些方法值得用oneway。
oneway关键字把方法声明为异步:客户端调用后立即返回,请求在 Binder 层排队,服务端按接收顺序执行。它适合两类方法:一是注册回调、注销回调这类控制方法,客户端不需要等结果;二是客户端单向上报事件、服务端不回包的方法。
| 方法场景 | 默认同步 | oneway |
|---|---|---|
| 获取数据 getData | 需要返回值,必须同步 | 不允许,oneway 不能有返回值 |
| 注册/注销回调 | 可以同步,但没必要等 | 建议加 oneway,调用立即返回 |
| 单向事件上报 | 客户端会阻塞等结果 | 建议加 oneway,避免客户端等待 |
使用oneway有两个硬限制:方法不能有返回值,也不能有out或inout参数。这是 AIDL 语法规则,编译期直接报错。另外oneway请求在 Binder 层是有序的,同一个客户端发起的多个oneway调用会按顺序到达服务端,不同客户端的请求之间没有顺序保证——如果你的业务依赖调用顺序,需要自己带序号或者时间戳,不要把顺序寄托在oneway上。
3. 从接口到服务端:多客户端 AIDL 代码的搭建顺序
理解完模型,直接动手。这一章按「接口 → 服务端 → 客户端」的顺序把代码完整搭出来。模拟项目X是一个公共数据仓库服务:多个客户端进程可以往仓库写键值数据,任何客户端写入后,服务端要把变更广播给所有已注册的客户端。
3.1 定义 AIDL 接口:回调注册方法必须单独设计
多客户端场景下,AIDL 接口最少包含三组方法:数据读写、回调注册、回调注销。回调必须以独立的 AIDL 接口传给服务端,不能用一个字符串标识符代替——因为服务端需要持有客户端的 Binder 代理才能主动调用客户端,而 Binder 代理只能从客户端传入的接口对象上取。
先定义回调接口:
// IRemoteCallback.aidl package com.example.projectx; interface IRemoteCallback { void onDataChanged(String key, String value); }再定义主接口:
// IRemoteDataService.aidl package com.example.projectx; import com.example.projectx.IRemoteCallback; interface IRemoteDataService { String getData(String key); void setData(String key, String value); void registerCallback(IRemoteCallback callback); void unregisterCallback(IRemoteCallback callback); }这里的getData用同步返回,适合客户端在子线程里主动拉一次数据。setData是写入操作,写入成功后由服务端广播给所有客户端。如果想让getData也走异步推送,可以改成无返回值、由服务端通过回调把结果推回来,但要注意推送对象是调用者自己还是所有客户端——多客户端场景下异步推送很容易误伤,所以我更倾向于让读取保持同步、写入走广播。
参数说明:key和value是String基本类型,AIDL 对基本类型、String、List、Map、Parcelable 有内置支持,不需要额外 import。IRemoteCallback是自定义 AIDL 接口类型,必须import,而且主接口和回调接口的包名要一致,否则编译期会报类型不匹配。写完这两份 .aidl 文件后,Android Studio 会在 build 目录下自动生成对应的Stub和Proxy类,不要手动改生成代码。
3.2 服务端实现:RemoteCallbackList 管理多个客户端回调
服务端最关键的数据结构选择:不要用ArrayList<IRemoteCallback>手动管理回调。增删要加锁,遍历要防并发修改,客户端死了要手动清理——自己实现这三个问题,代码量不大,但每个都是坑。Android 提供的RemoteCallbackList就是专门为多客户端回调管理设计的,增删、遍历、死亡清理都封装好了。
// RemoteDataService.java public class RemoteDataService extends Service { private final RemoteCallbackList<IRemoteCallback> mCallbacks = new RemoteCallbackList<>(); private final ConcurrentHashMap<String, String> mDataStore = new ConcurrentHashMap<>(); private final IRemoteDataService.Stub mBinder = new IRemoteDataService.Stub() { @Override public String getData(String key) { return mDataStore.get(key); } @Override public void setData(String key, String value) { mDataStore.put(key, value); broadcastChange(key, value); } @Override public void registerCallback(IRemoteCallback callback) { mCallbacks.register(callback); } @Override public void unregisterCallback(IRemoteCallback callback) { mCallbacks.unregister(callback); } }; private void broadcastChange(String key, String value) { int count = mCallbacks.beginBroadcast(); for (int i = 0; i < count; i++) { IRemoteCallback cb = mCallbacks.getBroadcastItem(i); try { cb.onDataChanged(key, value); } catch (RemoteException e) { // 客户端已断开,RemoteCallbackList 会在下一轮清理 } } mCallbacks.finishBroadcast(); } @Override public IBinder onBind(Intent intent) { return mBinder; } }逻辑说明:beginBroadcast()和finishBroadcast()必须成对调用。beginBroadcast返回当前存活回调的数量,内部会把已死客户端(进程被杀、Binder 代理失效)的条目摘除;getBroadcastItem(i)取出第 i 个存活回调;cb.onDataChanged()抛RemoteException表示这个客户端刚好断开,捕获后不需要额外清理。register和unregister内部做了去重和死亡监听,客户端进程死了,回调会被自动标脏并在下一轮广播时移除。
参数说明:mDataStore选择ConcurrentHashMap而不是HashMap——Binder 线程池会并发执行多个客户端的setData,普通HashMap在并发写时会丢数据甚至死循环。RemoteCallbackList自身线程安全,外面不需要再包锁。Stub是编译器根据 .aidl 生成的抽象类,服务端继承它实现接口方法,onBind返回mBinder,所有客户端拿到的都是这个 Binder 的代理。
3.3 客户端绑定与注册:bindService + Stub.asInterface 的标准写法
客户端要做三件事:绑定服务、拿到接口代理、注册回调。生命周期管理是这个环节的重头——绑定、注册、注销、解绑四个动作必须成对出现,漏一个就是内存泄漏。
// ClientActivity.java public class ClientActivity extends Activity { private TextView mStatusText; private IRemoteDataService mService; private boolean mBound; private final IRemoteCallback mCallback = new IRemoteCallback.Stub() { @Override public void onDataChanged(String key, String value) { runOnUiThread(() -> mStatusText.setText(key + " = " + value)); } }; private final ServiceConnection mConnection = new ServiceConnection() { @Override public void onServiceConnected(ComponentName name, IBinder binder) { mService = IRemoteDataService.Stub.asInterface(binder); mBound = true; try { mService.registerCallback(mCallback); } catch (RemoteException e) { mBound = false; mService = null; } } @Override public void onServiceDisconnected(ComponentName name) { // 服务端进程异常退出时回调,此时 binder 已失效 mService = null; mBound = false; } }; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); Intent intent = new Intent(); intent.setClassName("com.example.projectx", "com.example.projectx.RemoteDataService"); bindService(intent, mConnection, Context.BIND_AUTO_CREATE); } @Override protected void onDestroy() { if (mBound) { try { mService.unregisterCallback(mCallback); } catch (RemoteException e) { // 服务端已死,忽略即可 } unbindService(mConnection); mBound = false; } super.onDestroy(); } }逻辑说明:Stub.asInterface(binder)把服务端返回的 Binder 代理转换成接口对象。之后所有 AIDL 调用走这个代理,参数会被序列化后经 Binder 驱动传到服务端进程执行,执行结果再序列化返回。registerCallback(mCallback)把客户端的回调代理注册进服务端的RemoteCallbackList,此后服务端可以主动调用客户端的onDataChanged。
参数说明:setClassName第一个参数是服务端应用的包名,第二个是 Service 完整类名。跨应用绑定必须用显式 Intent,Android 5.0(API 21)之后隐式 Intent 绑定服务会直接抛异常。BIND_AUTO_CREATE表示服务端如果没有运行,绑定时自动创建;不加这个 flag,服务不存在时绑定会失败。
onDataChanged回调执行在 Binder 线程,不是客户端的 UI 线程,所以代码里用runOnUiThread切回主线程再碰 View。onServiceDisconnected在服务端进程崩溃或被杀时回调,此时 binder 代理已失效,不能调用任何 AIDL 方法;Android 不会自动重连,需要自己记录状态,在合适的时机重新bindService。
3.4 服务端主动推送:广播回调里的安全遍历写法
setData调用了broadcastChange,这个方法是整个多客户端服务端推送的核心。它的正确写法只有一个,不要在项目里手写synchronized(list)加 for 循环的版本。
private void broadcastChange(String key, String value) { int count = mCallbacks.beginBroadcast(); for (int i = 0; i < count; i++) { IRemoteCallback cb = mCallbacks.getBroadcastItem(i); try { cb.onDataChanged(key, value); } catch (RemoteException e) { // 单个客户端失败不影响其他客户端 } } mCallbacks.finishBroadcast(); }逻辑说明:beginBroadcast到finishBroadcast之间,RemoteCallbackList持有内部锁,阻塞其他线程的register/unregister操作。所以这段广播区域必须保持轻薄——不要在循环体里做数据库查询、网络请求或者任何可能耗时的操作,否则任何一个客户端的慢回调都会卡住其他客户端的注册操作。
参数说明:finishBroadcast()一定要保证执行。如果循环里提前return或者抛了未捕获异常跳过它,RemoteCallbackList的内部锁永远不会释放,之后所有客户端的register和unregister都会永久阻塞。保险写法是把finishBroadcast()放进finally块里。这是多客户端 AIDL 里最容易埋雷的地方,后面避坑章会再展开。
4. 多客户端 AIDL 避坑指南:现象、原因与解决
这一章挑四个最常见的坑,每条按「现象 → 原因 → 解决」来写。多客户端 AIDL 的问题有个共性:单客户端时永远测不出来,客户端一多就集中爆发。如果你已经遇到了类似现象,直接对号入座。
4.1 客户端反复进出导致回调堆积,服务端越跑越慢
现象:同一个客户端每次进入页面绑一次服务、注册一次回调,退出时忘记注销。来回操作十几次后,服务端每次广播的耗时从几毫秒涨到几百毫秒,客户端收到的回调越来越多,一次数据变更甚至触发十几遍同样的推送。
原因:RemoteCallbackList并不按客户端去重。每次registerCallback传入的都是客户端新建的回调 Stub 实例,同一个进程即使注册的是同一个接口,只要实例不同,RemoteCallbackList就全部保留。进入页面三次却没注销,服务端就存了三个回调,广播自然推三遍。
解决:客户端onDestroy里必须对称注销,且传入的必须是注册时的同一个实例。另一个常见做法是在registerCallback里先注销再注册,用同一实例覆盖式注册:
@Override public void registerCallback(IRemoteCallback callback) { mCallbacks.unregister(callback); // 先移除旧引用 mCallbacks.register(callback); // 再注册新引用 }注意:覆盖式注册只对同一实例有效。如果客户端每次 new 一个 Stub 传入,unregister移除的是旧实例,新实例照样堆积。服务端侧能做的只有兜底,客户端侧的生命周期管理才是根因。
4.2 客户端进程被杀后,服务端回调抛 DeadObjectException
现象:客户端 App 被用户在最近任务里划掉,进程死亡。服务端下次广播时cb.onDataChanged()抛出DeadObjectException,日志里堆栈刷屏,广播线程被异常打断。
原因:Binder 代理指向的客户端 Binder 对象已经随进程死亡销毁,服务端持有的回调变成了死引用。RemoteCallbackList的死亡清理不是实时的,而是在下一次beginBroadcast时才统一摘除死条目。
解决:广播遍历里捕获RemoteException是必须的,捕获后不要手动做额外清理,finishBroadcast内部会标记失效引用。如果业务需要立刻感知客户端死亡来释放服务端资源,可以在registerCallback时对回调的 binder 手动挂死亡监听:
@Override public void registerCallback(IRemoteCallback callback) { mCallbacks.register(callback); IBinder binder = callback.asBinder(); binder.linkToDeath(mDeathRecipient, 0); }linkToDeath的DeathRecipient.binderDied()回调运行在 Binder 线程,不能操作 UI,也不能在这里执行长任务;而且linkToDeath不是白用的,客户端正常注销后要记得unlinkToDeath,否则死亡监听会泄漏。
4.3 多个客户端同时写共享数据,后写覆盖先写
现象:客户端 A 和客户端 B 几乎同时setData("count", "1")和setData("count", "2"),服务端最终存的是 2 还是 1 完全不确定。更隐蔽的是读改写场景:A 读出 value 为 1,加 1 后写回 2;B 同时读出 value 也是 1,加 1 后写回 2——丢了一次更新。
原因:setData方法体里的「读-改-写」三个步骤不是原子的。Binder 线程池并发执行同一个方法时,A 和 B 可以交错执行,各自基于同一份旧值计算,后写完的覆盖先写完的。
解决:不要在客户端做「读出来 → 改 → 写回去」,而是让服务端提供原子操作。服务端对共享数据的读改写加锁,锁的粒度要覆盖所有访问路径:
@Override public void incrementData(String key) throws RemoteException { String newValue; synchronized (mDataStore) { String cur = mDataStore.get(key); int val = cur == null ? 0 : Integer.parseInt(cur); newValue = String.valueOf(val + 1); mDataStore.put(key, newValue); } broadcastChange(key, newValue); }注意:加锁对象要和所有访问mDataStore的路径一致。如果getData也读mDataStore,getData也要加同一把锁,否则加锁只保护了写路径,读路径还是能看到中间状态。锁块里不要做广播这种耗时操作——广播遍历期间其他线程注册回调会被阻塞,锁加广播等于把阻塞时间翻倍。
4.4 Binder 线程池耗尽,客户端调用全部卡住
现象:客户端调用service.getData(key)卡住不返回,主线程直接 ANR,日志里出现 Binder 调用超时。服务端日志显示某个 AIDL 方法被反复执行,每次都耗时几百毫秒。
原因:服务端 AIDL 方法里有耗时操作占住了 Binder 线程。默认十来个线程的线程池被多客户端的并发请求占满后,后续请求全部排队,排队超过 Binder 驱动超时阈值就报调用失败,客户端表现为卡住或者抛TransactionFailedException。
解决:AIDL 方法体只做内存操作和入队,耗时逻辑丢到服务端自己的工作线程。典型错误是直接在setData里写文件、查数据库、做网络同步:
private final ExecutorService mWorkers = Executors.newFixedThreadPool(4); @Override public void setData(String key, String value) { mDataStore.put(key, value); // 快操作,直接做 mWorkers.execute(() -> { // 耗时逻辑:落盘、同步、上报 broadcastChange(key, value); // 操作完成后再广播 }); }newFixedThreadPool(4)的线程数按服务端实际负载调整,一般 2 到 4 够用。注意广播的位置:如果把broadcastChange放在入队前执行,客户端还没看到最新的持久化结果就先收到了回调,时序就乱了。先完成耗时操作,再广播。
5. 进阶:识别客户端身份与定向推送
广播推送只解决「通知所有客户端」这一种需求。真实业务很少这么简单:有的客户端只关心自己订阅的数据,有的客户端要求只接收指定 key 的变更。这一章把广播模型升级成可识别身份的定向模型。
5.1 用 Binder.getCallingUid() 区分每个客户端
服务端 AIDL 方法执行时,可以在方法体内调用Binder.getCallingUid()拿到当前调用者的 UID。这个 UID 由 Binder 驱动透传,是调用者进程的真实身份,客户端传参伪造不了。拿到 UID 后用PackageManager解析成包名,就可以按客户端维度做登记:
private final ConcurrentHashMap<String, IRemoteCallback> mClientRegistry = new ConcurrentHashMap<>(); @Override public void registerCallback(IRemoteCallback callback) { int callingUid = Binder.getCallingUid(); String packageName = getPackageManager().getNameForUid(callingUid); mClientRegistry.put(packageName, callback); mCallbacks.register(callback); }注意:getCallingUid()必须在 Binder 调用栈内执行。在方法体里直接调用没问题,但一旦切了线程——比如把后续逻辑丢进mWorkers线程——调用栈就变成服务端自己的线程了,拿到的就不再是客户端的 UID。所以身份识别必须在进入工作线程之前完成,把结果作为参数传进去。
Binder.getCallingPid()也能拿调用者进程 ID,但 PID 会被系统回收复用,几个小时后另一个进程可能就拿到同一个 PID,不适合做长期身份标识,只在日志排查时用一下就够了。
5.2 按客户端分组管理回调:从广播改成定向
需求变成「不同客户端订阅不同数据」时,全量广播就不合适了。改造思路是注册时让客户端带一个分组名,服务端按组维护多个RemoteCallbackList:
// IRemoteDataService.aidl interface IRemoteDataService { void setData(String key, String value); void registerCallback(IRemoteCallback callback, String groupName); void unregisterCallback(IRemoteCallback callback, String groupName); }服务端用ConcurrentHashMap按组存放回调列表:
private final ConcurrentHashMap<String, RemoteCallbackList<IRemoteCallback>> mGroupedCallbacks = new ConcurrentHashMap<>(); @Override public void registerCallback(IRemoteCallback callback, String groupName) { RemoteCallbackList<IRemoteCallback> list = mGroupedCallbacks.get(groupName); if (list == null) { RemoteCallbackList<IRemoteCallback> newList = new RemoteCallbackList<>(); RemoteCallbackList<IRemoteCallback> old = mGroupedCallbacks.putIfAbsent(groupName, newList); list = old != null ? old : newList; } list.register(callback); }putIfAbsent在这里是必须的:两个 Binder 线程同时注册同一个新分组时,如果先containsKey判断再put,后写的会覆盖先写的列表,导致先注册的回调丢失。推送时先按组取出列表,再走标准的 beginBroadcast 遍历。
5.3 服务端主动断开指定客户端的完整写法
有时候服务端要主动踢掉某个客户端:版本过旧、身份校验失败、或者占用资源超限。踢人的本质是从所有回调列表里移除该客户端的回调对象。前提是你还持有这个客户端的回调引用,所以需要一份「身份 → 回调对象」的映射。
结合 5.1 的注册逻辑,踢人操作长这样:
public void kickClient(String packageName) { IRemoteCallback cb = mClientRegistry.remove(packageName); if (cb != null) { mCallbacks.unregister(cb); } }注意unregister的匹配规则:它按 Binder 底层对象比较,不是按 Java 的equals。两个内容完全相同的回调 Stub 实例,如果底层 binder 不同,unregister就移除不了目标。所以注册和注销必须使用同一个回调对象,不能一边注册一边 new 新的。另外踢人只是从回调列表移除,客户端的绑定连接还在,客户端下次调用setData时服务端方法照样执行,只是不会再收到推送。要彻底断开连接,需要客户端自己感知后解绑,服务端这边能被踢的只有回调订阅权。
6. 多客户端场景的验证技巧:从模拟多进程到定位线程池问题
写完了不等于能跑。多客户端 AIDL 的验证是最容易被跳过的环节——单客户端测试永远测不出并发和回调管理的问题。我一般用三条路做验证。
第一,真机多进程模拟多客户端。不需要起多个模拟器,在同一个 App 里把几个 Activity 分别放进不同进程,manifest 里给 Activity 配android:process,各自绑定同一个服务并注册回调,就能在同一台设备上模拟出多个客户端。启动三个不同进程的页面,在任意一个页面上调setData,观察另外两个进程的页面是否都收到了onDataChanged。这一步能验证广播链路,但测不出跨应用的真实身份识别。
第二,用adb shell dumpsys activity services检查绑定关系:
adb shell dumpsys activity services com.example.projectx输出里能找到服务端的 ServiceRecord,下面列出了每个客户端进程的 Connection 条目。如果发现同一个客户端包名下面挂着多条 Connection,说明绑定没有成对解绑——这是回调堆积的前兆。结合 4.1 的场景,反复进出页面后再执行这条命令,能看到连接数只增不减。
第三,验证 Binder 线程池是否被占满。确定服务端进程的 PID 后,直接查线程:
adb shell "top -H -p <服务端pid> | grep binder"正常的服务端进程里能看到binder_1到binder_16这些线程,绝大多数时间是 S 状态(休眠等待)。压测时起 10 个客户端同时调setData,再跑这条命令,如果大部分 binder 线程变成 R 状态(运行中)且长时间不回落,说明 AIDL 方法体里有耗时操作在占线程。这条命令比看日志直观得多,是我排查多客户端卡顿的第一手段。
最后一个习惯:每次改完 AIDL 接口,先清理再重编译。AIDL 生成的 Java 文件偶尔会因增量编译没有刷新,服务端和客户端拿着不匹配的接口定义互调,运行时报TransactionFailedException或者ClassCastException,看起来像玄学,其实是编译产物过期。先清后编,一次能省下至少一小时排查时间。希望帮到你。
本文还有配套的精品资源,点击获取