Android AIDL双向通信:用RemoteCallbackList实现Service主动回调Client
1. 从“Service主动找Client”这个需求说起先说一个场景你做了一个音乐播放器客户端负责UI和交互后台一个Service负责真正的播放逻辑。用户点了播放Service开始放歌。这时候问题来了——进度条怎么更新播放完了下一首要切歌客户端怎么知道你当然可以用轮询每隔500毫秒让客户端主动问一次“现在到哪了”。但如果做的是消息推送、下载任务、传感器上报这类高频变化的场景轮询就太浪费了电量扛不住带宽扛不住代码写起来也特别别扭。Android的跨进程通信里AIDL是绕不开的方案。它解决的问题很明确让一个进程里的Service方法可以被另一个进程调用参数能传过去返回值能拿回来。但如果只做到这一步Service永远是被动的——它只能“应答”不能“主动通知”。真正的业务里恰恰需要逆向的主动推送Service端有状态变化了主动喊一声客户端收到通知再刷新UI。这就是“Server回调Client”。我之前在某公司的模拟项目X里做消息推送模块时就遇到了这个需求。当时刚上手第一反应是直接在Service里保存一份客户端的Binder引用等事件来了就一个个轮流调用。踩了几天坑之后才发现Android早就提供了更专门、更完整的工具——RemoteCallbackList。这篇就把这个机制从原理到代码完整梳理一遍重点放在“为什么要用它”和“怎么正确用它”上。2. AIDL双向通信的本质Client和Server互为“对方”2.1 AIDL不是单向的只是大多数人不这么用很多人对AIDL的认知是定义好接口文件客户端bindService拿到代理调用服务端方法。这没错但这只是半个流程。AIDL底层是BinderBinder本身是一套“进程间收件箱”机制。你定义了一个接口生成的Stub是服务端实现生成的Proxy是客户端看到的代理。当客户端调用Proxy的方法时实际上是把调用的方法编号和参数打包进一个Parcel通过Binder驱动发送给服务端进程服务端收到后在Binder线程池里执行真实的Stub方法。关键在于这个通道是双向的。客户端调用服务端服务端能回到客户端吗当然能。只要你把客户端的“接口对象”作为参数传过去服务端就持有了这个跨进程的引用随时可以反向调用。这就是回调的雏形。只是很多人写AIDL文件时只写了业务方法比如sendMessage(String msg)没有写registerCallback(ICallback cb)。接口里加上回调注册方法双向通信立刻就能工作。2.2 一个完整的AIDL接口应该有三类方法从我在模拟项目X里的实践来看一套实用的回调式AIDL接口通常包含三方面第一普通的业务方法例如发送消息、订阅主题客户端主动触发 第二回调注册与注销方法例如registerCallback、unregisterCallback 第三回调接口本身里面定义Service主动通知客户端的各种事件方法。// IMessageService.aidl package com.example.messenger; import com.example.messenger.IMessageCallback; interface IMessageService { void sendMessage(String msg); void registerCallback(IMessageCallback callback); void unregisterCallback(IMessageCallback callback); }// IMessageCallback.aidl package com.example.messenger; interface IMessageCallback { void onMessageReceived(String msg); void onStatusChanged(int status); }写AIDL文件有个细节很多人会忽略如果接口里要使用另一个AIDL接口类型作为参数必须在文件里写import。哪怕它们在同一个包下也要写AIDL的规则和普通Java不一样不import会直接编译报错。// 错误示范没写import编译直接失败 interface IMessageService { void registerCallback(IMessageCallback callback); } // 正确写法明确import import com.example.messenger.IMessageCallback;2.3 生成代码里发生了什么编译之后每个AIDL文件会生成一个同名Java接口。这个接口里有静态抽象类Stub继承自Binder并实现了接口方法还有个Proxy类客户端通过它发起跨进程调用。服务端要做的事就是继承Stub实现接口里的三个方法。客户端拿到的则是Proxy它把方法调用序列化后发给服务端。Stub里有几个关键方法值得注意一下。public static IMessageService asInterface(android.os.IBinder obj) { // 如果obj所在进程就是本地进程直接返回Stub本身 // 如果是远程进程返回Proxy包装对象 }这个方法的判断逻辑是基于UID的如果IBinder的调用方和服务端在同一个进程说明是本地调用不需要走Binder事务直接返回Stub实例提高性能。这就是为什么bindService之后要调asInterface来转一下。3. 为什么List不行RemoteCallbackList到底解决了什么3.1 直接拿ArrayList存回调会有什么问题第一版我这个模拟项目X里干过这事Service里维护一个ArrayList 注册就往里add事件来了就for循环遍历调用。表面看没问题一跑起来全是坑。第一个坑跨进程的对象不是“同一个”。客户端进程里正常new了一个回调对象传给ServiceService拿到的其实是这个对象在Binder驱动里登记的一个代理。客户端进程如果换了Activity重新绑定传的可能还是同一个回调对象但Service里存的是它对应的Binder引用。如果客户端进程直接因为系统资源被杀掉重启了Service里存的这些引用就全变成了“幽灵引用”——对象听着还在实际对应的进程早没了。你调用它就会抛DeadObjectException如果不捕获Service直接崩溃。第二个坑去重难。你没法直接判断某个客户端是否已经注册过因为你拿到的是Binder引用不是原始Java对象。想用hashCode或equals做去重跨进程场景下根本不靠谱。第三个坑内存泄漏。客户端进程销毁了但Service不知道还持有一堆早已失效的Binder引用。客户端反复进出Service里积累的无效回调越来越多事件一来大量异常Service越来越慢。RemoteCallbackList就是为这几个问题设计的。3.2 RemoteCallbackList的内部机制RemoteCallbackList是Android framework提供的一个专门用于管理跨进程回调的类它内部维护了一个ArrayMapIBinder, Callback。注意key是IBinder不是回调对象本身。注册的时候拿回调对象里的Binder作为key。这个设计天然解决了两件事一是同一个客户端多次注册同一个回调Binder引用是同一个自动去重不会重复添加二是只要Binder还活着就能找到对应的回调对象。它还在底层实现了IBinder.DeathRecipient。当某个客户端进程死亡时系统会回调binderDied方法RemoteCallbackList内部会把这个Binder对应的回调项自动从列表里移除。客户端崩溃了Service完全无感知但也根本不需要手动清理。这就解决了“幽灵引用”和内存泄漏问题。3.3 注册、注销的正确姿势使用RemoteCallbackList时Service本身通常会在类初始化时创建实例比如private final RemoteCallbackListIMessageCallback callbacks new RemoteCallbackList(); public void registerCallback(IMessageCallback callback) { callbacks.register(callback); } public void unregisterCallback(IMessageCallback callback) { callbacks.unregister(callback); }有个细节我一开始忽略了register和unregister必须传同一个回调对象。跨进程的场景里客户端如果每次回调都new一个新的对象Service这边按Binder去重会认为它们不是同一个Binder注册就变成了“多份”回调注销的时候传一个不同的Binderunregister还找不到。结果就是事件被派发多次。正确做法是Activity在onCreate里创建一个IMessageCallback实例整个生命周期复用onDestroy里拿同一个实例去注销。4. Server端完整实现从注册到主动回调4.1 Service的骨架结构我以一个消息推送模拟项目X为例。Service端主要做三件事持有RemoteCallbackList、对外提供注册注销方法、在内部事件发生时主动遍历回调。public class MessageService extends Service { private final RemoteCallbackListIMessageCallback callbacks new RemoteCallbackList(); private final IMessageService.Stub binder new IMessageService.Stub() { Override public void sendMessage(String msg) throws RemoteException { // 收到客户端发来的消息这里可以处理业务 Log.d(MessageService, receive msg: msg); // 处理完广播给所有订阅的客户端 notifyMessage(server replied: msg); } Override public void registerCallback(IMessageCallback callback) throws RemoteException { callbacks.register(callback); Log.d(MessageService, register callback, current size callbacks.getRegisteredCallbackCount()); } Override public void unregisterCallback(IMessageCallback callback) throws RemoteException { callbacks.unregister(callback); Log.d(MessageService, unregister callback, current size callbacks.getRegisteredCallbackCount()); } }; private void notifyMessage(String msg) { int count callbacks.beginBroadcast(); for (int i 0; i count; i) { IMessageCallback callback callbacks.getBroadcastItem(i); try { callback.onMessageReceived(msg); } catch (RemoteException e) { // 这个客户端可能已经断开RemoteCallbackList会自动清理 } } callbacks.finishBroadcast(); } Nullable Override public IBinder onBind(Intent intent) { return binder; } }4.2 beginBroadcast和finishBroadcast为什么必须成对这是RemoteCallbackList最核心的一个使用约束。beginBroadcast返回当前有效回调的数量。接到这个信号后RemoteCallbackList内部会进入“广播状态”在这个状态下getBroadcastItem(i)才能拿到正确的回调对象而且能保证遍历过程中即使有线程并发注册或注销遍历结果也不会乱。finishBroadcast表示广播结束退出广播状态。这里有个非常容易踩的坑如果代码里beginBroadcast之后没有在finally块里调finishBroadcastRemoteCallbackList内部的signals会一直被占用。下次任何线程调用register或unregister都会因为信号量被占用而阻塞。表现出来就是第一次推送后Service还能正常接收注册但后面再注册或注销客户端bindService没问题register却永远不返回。所以正确的写法必须保证成对int count callbacks.beginBroadcast(); try { for (int i 0; i count; i) { // 回调客户端 } } finally { callbacks.finishBroadcast(); }我第一版代码把finishBroadcast写在循环后面但没有用finally结果有一次回调客户端时抛了异常finishBroadcast根本没执行整个推送服务瞬间“假死”Search了好长时间才定位到问题。4.3 回调线程模型不要在Stub线程里做耗时操作服务端Stub方法运行在Binder线程池这个线程池是共享的数量有限。如果客户端频繁调用sendMessage而服务端在Stub方法里做耗时操作比如写数据库、网络请求某个Binder线程就会被长时间占用。Binder线程池耗尽之后所有跨进程调用都在排队整个Service会越来越慢严重时客户端会直接ANR。正确做法是Stub方法只做轻量级操作耗时逻辑放到独立线程或线程池。回调通知也是一样事件源在什么线程就保持在什么线程遍历回调不要在会阻塞主线程的路径上做这件事。回调里客户端收到消息后可能直接刷新UI这是跑在Binder线程池还是客户端主线程取决于回调实现。如果回调方法是直接操作View而Binder回调返回的线程是Binder线程不是UI线程直接操作View会崩溃。客户端实现时要自己切换线程这是后面客户端章节的重点。5. Client端完整实现绑定、监听、注销5.1 bindService的四个关键步骤客户端这边要做的第一步是bindService。我通常封装一个连接类负责管理ServiceConnection和回调注册。public class MessageClient { private IMessageService service; private boolean bound; private final IMessageCallback callback new IMessageCallback.Stub() { Override public void onMessageReceived(String msg) throws RemoteException { // 注意这里运行在Binder线程池不能直接操作UI Log.d(MessageClient, receive: msg); } Override public void onStatusChanged(int status) throws RemoteException { Log.d(MessageClient, status changed: status); } }; private final ServiceConnection connection new ServiceConnection() { Override public void onServiceConnected(ComponentName name, IBinder binder) { service IMessageService.Stub.asInterface(binder); try { service.registerCallback(callback); } catch (RemoteException e) { e.printStackTrace(); } bound true; } Override public void onServiceDisconnected(ComponentName name) { // 这个回调表示Service进程崩溃或连接意外断开 bound false; service null; } }; public void bind(Context context) { Intent intent new Intent(context, MessageService.class); context.bindService(intent, connection, Context.BIND_AUTO_CREATE); } public void unbind(Context context) { if (bound) { if (service ! null) { try { service.unregisterCallback(callback); } catch (RemoteException e) { e.printStackTrace(); } } context.unbindService(connection); bound false; } } }这四个步骤是固定的创建ServiceConnection、在onServiceConnected里asInterface拿代理、注册回调、在Activity销毁时注销并unbindService。顺序不能乱尤其是unbind前必须先注销回调。5.2 回调线程与UI更新RemoteCallbackList在回调客户端时最终执行的是客户端进程Binder线程池里的线程。如果你在onMessageReceived里直接写textView.setText会直接抛出“Only the original thread that created a view hierarchy can touch its views”。处理方式有几种我习惯用Handler统一转发到主线程private final Handler mainHandler new Handler(Looper.getMainLooper()); private final IMessageCallback callback new IMessageCallback.Stub() { Override public void onMessageReceived(String msg) throws RemoteException { mainHandler.post(() - { textView.setText(msg); adapter.notifyDataSetChanged(); }); } Override public void onStatusChanged(int status) throws RemoteException { mainHandler.post(() - { // 更新UI }); } };5.3 onDestroy里的注销与重连Activity的onDestroy里必须做两件事注销回调、unbindService。顺序不能反先注销再解绑。如果只解绑不注销RemoteCallbackList里还留着这个回调的Binder引用直到进程死亡前都一直在。下次这个Activity重建绑定后重新注册又是新的一份旧的那份残留着。反复进出页面Service里堆了一堆无效回调广播到它们时还会触发异常。我在模拟项目X里做的重连逻辑是这样的在onServiceDisconnected回调里标记service为null然后等下一次Activity重新可见时重新bindService。同时要注意onServiceDisconnected和onServiceConnected不一定配对连续断开重连时要确保ServiceConnection不会被系统重复回调。6. 常见问题与排查技巧实录6.1 DeadObjectException和“幽灵回调”现象客户端进程被杀或Service进程重启后Service遍历回调时抛出DeadObjectException。原因目标Binder所在进程已经不存在或者Binder驱动里的引用已经失效。RemoteCallbackList已经通过binderDied自动清理了一部分但事件派发时和客户端死亡之间存在竞态某个回调明明还活着但下一秒进程就死了遍历时依然可能抛异常。解决办法是遍历时对每个回调单独try-catch不能因为一个坏回调影响整个广播。捕获到RemoteException后什么都不用做RemoteCallbackList会在后续自动移除它。6.2 register之后回调永远不触发遇到这种情况先检查三件事一是客户端有没有真的调用registerCallback。有人把注册方法写在onCreate里但bindService是异步的onServiceConnected还没回调注册的代码先执行了service对象还是null自然没注册成功。把注册挪到onServiceConnected里。二是Service端beginBroadcast有没有配套finishBroadcast。这个坑前面讲过了一旦漏掉finishBroadcast后续register和unregister全部阻塞表现为Service“只能发一次事件之后就再也收不到新订阅”。三是回调对象是否被GC回收。客户端把IMessageCallback.Stub强引用在成员变量确实不会被回收。但有些人图省事在onServiceConnected里临时new一个回调传进去没保存引用。这个临时对象可能在某个时间点被GC掉Service端持有的Binder引用虽然还指向Binder对象但Java层的回调代理没了调用会变成空调用或抛异常。规范做法永远是成员变量持有整个生命周期复用。6.3 TransactionTooLargeException一次回调塞了太多数据跨进程Binder事务对数据量有上限大约是1MB而且这个限制是共享的包含方法名、参数、返回值的所有Parcel数据。如果你在一个回调方法里传递了一整个Bitmap列表或者一个包含大量字段的Parcelable对象很容易触发TransactionTooLargeException。这个异常很恶心不一定会立即弹出来有时是随机出现在system_server的崩溃日志里客户端和服务端都很难直接捕获。解决思路是回调数据要精简。Bitmap先压缩成小尺寸或者传文件路径大的数据对象分页发送高频回调合并成批量数据例如每100毫秒推一次批量列表而不是每条推一次。我有个实践经验某次做位置上报每秒回调一次一次回传20个坐标点。低端机上调到30个点就偶尔出现TransactionTooLargeException压缩成每5秒批量上报一次问题彻底消失。6.4 binder线程池耗尽导致的卡顿和ANRAndroid对每个进程的Binder线程池默认是有限的。如果一个进程被大量跨进程调用打满线程池新的Binder调用会排队等待表现为服务越来越卡。这种问题常见于多个模块同时回调同一个Service或者Service收到高频回调后又同步回调给多个客户端。排查方法在回调入口加日志统计并发数和调用耗时定位是哪个环节阻塞了Binder线程。解决办法是回调方法里不耗时不等待使用独立的HandlerThread或Executor处理重逻辑如果回调过于频繁在Service内部做合并或降频。6.5 进程被杀重连后的状态同步这是一个很容易被忽略的场景。客户端进程被杀Service还活着它通过binderDied知道了回调失效会把它清除。此时客户端Activity重建重新bindService注册回调。问题来了Service端在这个客户端“失踪”期间可能产生了若干条消息。客户端重新注册后要不要补发这些消息如果业务上需要就得在registerCallback时做一次性状态同步把客户端的订阅状态、最新一条消息的时间戳等作为参数传入由Service在注册后主动推一条当前状态过去。这个“断线重连补发”逻辑在模拟项目X里是用一个“最后消息时间”字段实现的Override public void registerCallback(IMessageCallback callback, long lastMsgTime) throws RemoteException { callbacks.register(callback); // 补发lastMsgTime之后的所有未读消息 ListMessage pending messageStore.queryAfter(lastMsgTime); for (Message msg : pending) { callback.onMessageReceived(msg); } }注意这里callback.onMessageReceived和远程调用一样会跑在Service端Binder线程池补发大量消息时要考虑线程安全和TransactionToooLarge建议控制单批数量。7. 实测对比与体会我在模拟项目X里先后用过三种方案来实现Server主动通知Client。第一种是最原始的Service里用ArrayList存客户端传入的回调对象事件来了就遍历调用。结果是客户端杀进程重启后Service里残留的引用会导致死对象异常页面反复进出回调列表越积越多最后事件一次要遍历几十个早已失效的Binder性能急剧下降。第二种是手动管理DeathRecipient自己实现死亡监听和清理。问题在于代码量大还要处理并发遍历和remove时的同步问题一不小心就漏了某个清除路径。第三种就是RemoteCallbackList注册、注销、死亡通知、并发安全的遍历全都内置了。我只需要把业务回调写在beginBroadcast和finishBroadcast的包裹里其余交给它处理。实测下来跨进程回调的稳定性显著提升代码量也大幅减少。最后分享一个我在实际调试中总结的排查技巧如果怀疑RemoteCallbackList状态被破坏最直接的确认方式是在register和unregister里加日志输出当前getRegisteredCallbackCount。正常情况注册后count1注销后count-1beginBroadcast返回的值应该等于count。如果广播期间有其他线程做注册注销count和beginBroadcast返回数量不一致是正常的但如果广播结束后再次调用register发现线程阻塞那基本可以断定是finishBroadcast漏掉了。这个信号比空栈分析定位快很多。