ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Android AIDL 跨进程通信实战:从绑定、回调到Binder避坑指南

Android AIDL 跨进程通信实战:从绑定、回调到Binder避坑指南 做安卓开发跨进程通信是绕不开的一关。我印象最深的一次是做音乐类应用为了让播放器在页面退出后还能继续工作把播放服务单独拆到独立进程结果主界面要实时显示播放状态、歌曲列表、进度回调一开始用广播和 Messenger 绕来绕去最后老老实实切到 AIDL。AIDL 全称是 Android Interface Definition Language说白了就是给跨进程调用定义接口用的壳编译后自动生成 Binder 通信代码。这篇文章会从选型、接口定义、双端实现、数据方向、回调、连接管理到实战踩坑串一条完整链路适合两类人第一次接触 AIDL 想快速上手的开发者以及用过 AIDL 但对线程模型、参数方向这些细节没吃透的人。1. 为什么选 AIDL跨进程通信方案的横向对比1.1 先确定你到底需不需要多进程很多人一听“性能优化”“模块隔离”就直接把某个模块丢到独立进程里结果通信成本反而比收益还高。多进程不是装修不是说隔一个房间就一定更整洁。真正需要多进程的场景我实际验证下来基本就这三类内存重模块隔离比如视频解码、相机采集、大型图片处理这些模块动辄占用几百 MB 内存放在主进程一旦内存吃紧整个应用容易被内存回收机制盯上。独立进程的好处是它崩了、它被回收了主进程还能活着。后台长任务保活音乐播放、下载任务这类需要脱离界面继续运行的能力放进独立进程用户清理最近任务列表时不容易把服务带掉。跨应用开放能力如果你的应用需要给别的 App 提供接口比如支付能力、扫码能力、推送服务那么 AIDL 的接口语义是最清晰的别的团队接你 SDK 时只需要拿到 AIDL 文件。如果你只是想在同一个 App 内部做模块解耦优先考虑普通接口、EventBus、协程 Channel 这类进程内方案。多进程的代价是静态变量每个进程各一份、文件同步需要考虑锁、SharedPreferences 默认不同步、Bitmap 这类大对象无法直接传递。你得到隔离性的同时也得到了一堆你需要重新处理的边界问题。1.2 AIDL 和其他 IPC 方案的对比Android 提供的跨进程方案不少我在项目里逐个试用过这里直接放一张对比表方案调用形态返回结果双向实时回调典型场景Intent Broadcast事件广播无弱简单的状态通知Messenger消息队列可行但麻烦可做轻量任务分发AIDL方法级调用直接返回强富交互业务ContentProvider数据表读写查询结果弱结构化数据共享选择 AIDL 最核心的原因是它有方法级调用语义。你定义接口boolean play(String songId)服务端就实现这个方法客户端就调用这个方法参数、返回值、异常都清清楚楚。Messenger 本质上是往 Handler 发 Message你还需要自己封装请求码、参数 Bundle、回复 Bundle代码写多了会发现大量样板代码。Broadcast 就更别说了传个复杂对象还得自己序列化而且广播是无状态的发完就完了没有返回值这回事。1.3 AIDL 底层到底做了什么用一句不严谨但好理解的话说AIDL 是 Binder 机制的“语法糖”。你写一个.aidl接口文件编译时会生成对应的 Java 接口里面包含Stub和Proxy两个内部类Stub是服务端实体Proxy是客户端代理。一次跨进程调用的完整链路是客户端调用 Proxy 方法 → 把参数写进 Parcel 数据包 → 通过 Binder 驱动发送到服务端进程 → 服务端 Binder 线程池取出 Stub 方法执行 → 返回结果通过 Parcel 回传客户端。这一段链路里有两个关键点决定了 AIDL 的边界数据必须能写进 Parcel所以不是所有 Java 对象都能传只有基本类型、String、List、Map、Parcelable 对象可以Binder 缓冲区是有限的超过 1MB 就会抛 TransactionTooLargeException这事我后面专门用一节来聊。先把这两个边界记住后面所有的坑都跟它们有关。2. 一个完整示例音乐服务从接口定义到双端跑通2.1 工程结构规划我建议你在建 AIDL 文件之前先把包结构想清楚因为 AIDL 文件里的package必须和文件路径一致这个不一致经常导致“AIDL 文件生成失败”的报错网上搜这个关键词能搜出一堆求助帖。正确做法是在src/main/aidl目录下按包名建立目录层级。假设包名是com.example.media那么你的 AIDL 文件放在src/main/aidl/com/example/media/下。需要注意的是如果是同一个应用内多进程通信接口文件只需要一份如果是跨应用通信客户端也需要拷贝一份 AIDL 文件且包名必须完全一致否则asInterface时会因为类名不一致直接抛异常。这个细节我踩过后面细说。2.2 编写 AIDL 接口文件我以一个音乐播放服务为例贴一个相对完整的接口定义// IMusicPlaybackService.aidl package com.example.media; import com.example.media.Song; import com.example.media.IMusicCallback; interface IMusicPlaybackService { ListSong getPlayList(); Song getCurrentSong(); boolean play(String songId); boolean pause(); void registerCallback(IMusicCallback callback); void unregisterCallback(IMusicCallback callback); }这里有几个注意点。AIDL 支持的数据类型包括Java 基本类型、String、CharSequence、List、Map以及实现了 Parcelable 的自定义类。List和Map不需要额外 import但里面的元素类型必须也是 AIDL 支持的所以这里的Song必须单独声明为 Parcelable。Song和IMusicCallback都要在同级目录下放一个对应的.aidl文件才能被引用到。2.3 服务端实现 Stub 并注册到 Service服务端核心是继承IMusicPlaybackService.Stub实现抽象方法。注意这些方法会跑在 Binder 线程池里不是主线程所以不能在里面直接操作 UI这个后面会展开。public class MusicPlaybackService extends Service { private final CopyOnWriteArrayListIMusicCallback callbackList new CopyOnWriteArrayList(); private final IMusicPlaybackService.Stub mBinder new IMusicPlaybackService.Stub() { Override public ListSong getPlayList() throws RemoteException { return MusicLibrary.getPlayList(); } Override public Song getCurrentSong() throws RemoteException { return playbackEngine.getCurrentSong(); } Override public boolean play(String songId) throws RemoteException { return playbackEngine.play(songId); } Override public boolean pause() throws RemoteException { return playbackEngine.pause(); } Override public void registerCallback(IMusicCallback callback) throws RemoteException { if (callback ! null) { callbackList.add(callback); } } Override public void unregisterCallback(IMusicCallback callback) throws RemoteException { callbackList.remove(callback); } }; Override public IBinder onBind(Intent intent) { return mBinder; } }注意我这里用了CopyOnWriteArrayList来存回调列表原因是服务端需要在播放状态变化时遍历回调并通知各个客户端而这个列表可能同时被多个 Binder 线程修改普通的ArrayList会抛并发修改异常。如果你只是存一个客户端那用什么 List 都无所谓但设计成支持多客户端时这个线程安全要想清楚。接着在AndroidManifest.xml里注册服务。如果服务运行在独立进程需要加上android:process属性如果允许其他应用调用android:exported设为trueservice android:name.MusicPlaybackService android:exportedtrue android:process:playback /android:process:playback表示这个 Service 运行在名为playback的独立进程里冒号开头表示它是应用私有的外部应用不能直接进这个进程。不加process的话服务就跑在主进程那跨进程通信就变成了线程间通信语义不对。2.4 客户端绑定服务和调用客户端这边调用bindService然后在ServiceConnection的回调里拿到IBinder通过Stub.asInterface转成接口对象。有一个关键点bindService是异步的你不能在调用完bindService后立刻使用musicService它一定为 null。所有初始化逻辑都要放在onServiceConnected里。private IMusicPlaybackService musicService; private final ServiceConnection connection new ServiceConnection() { Override public void onServiceConnected(ComponentName name, IBinder service) { musicService IMusicPlaybackService.Stub.asInterface(service); // 连接成功后再做初始化调用 } Override public void onServiceDisconnected(ComponentName name) { // 被动断开时回调主动 unbind 不会回调 musicService null; } }; Override protected void onStart() { super.onStart(); Intent intent new Intent(this, MusicPlaybackService.class); bindService(intent, connection, Context.BIND_AUTO_CREATE); } Override protected void onStop() { super.onStop(); if (musicService ! null) { unbindService(connection); musicService null; } }这里要强调一个容易忽略的细节bindService的BIND_AUTO_CREATE标志表示目标 Service 不存在时自动创建。如果服务端运行在独立进程这个标志会保证进程被拉起来但如果服务端进程已经被系统内存清理掉重新绑定仍然会再创建一次。所以客户端要具备重连能力这点到第 5 章展开。客户端调用同步 AIDL 方法时当前线程会阻塞等待服务端返回。所以不要在 UI 线程里直接调用耗时方法需要放到子线程否则界面卡顿甚至 ANR。如果你调用的方法只是简单返回一个状态值放 UI 线程问题不大但这属于“没出问题才怪”的写法我建议一律统一放到工作线程避免后续接手的人踩坑。3. 数据传递的方向性自定义 Parcelable 与 in/out/inout3.1 为什么 AIDL 不能直接传普通 Java 对象跨进程传递对象本质是把对象变成二进制字节流传到另一个进程后再还原成对象。Java 里的Serializable也能做这件事但 AIDL 不支持直接传Serializable对象只支持Parcelable。原因是Parcelable是 Android 专门设计的序列化方案它在内存中按平铺的字节方式组织数据读写效率远高于Serializable的反射机制。缺点就是你需要手动编写序列化和反序列化逻辑。Song类的完整写法如下注意writeToParcel里写入字段的顺序必须和构造函数里从Parcel读取的顺序一致否则反序列化时字段会错位。这个顺序错位不会编译报错只会运行时报错或者静默拿到错误数据排查起来特别隐蔽。public class Song implements Parcelable { private final String id; private final String title; private final String artist; private final long duration; public Song(String id, String title, String artist, long duration) { this.id id; this.title title; this.artist artist; this.duration duration; } protected Song(Parcel in) { id in.readString(); title in.readString(); artist in.readString(); duration in.readLong(); } Override public void writeToParcel(Parcel dest, int flags) { dest.writeString(id); dest.writeString(title); dest.writeString(artist); dest.writeLong(duration); } Override public int describeContents() { return 0; } public static final CreatorSong CREATOR new CreatorSong() { Override public Song createFromParcel(Parcel in) { return new Song(in); } Override public Song[] newArray(int size) { return new Song[size]; } }; }写完 Java 类还要在同级目录写一个和类同名的 AIDL 文件内容只有一行// Song.aidl package com.example.media; parcelable Song;这个文件的作用是告诉 AIDL 编译器“这个类的路径出现在哪、它是一个 Parcelable”没有它你无法在接口参数里使用Song。3.2 in / out / inout参数方向的实际意义这是很多入门者第一个没搞懂的点我给一个直观的类比in参数像你寄快递你只寄出去对方不需要回寄out参数像你收到一个空箱子对方在里面填满东西还给你inout则像双方互换货物你寄出去一个箱子对方在里面塞满东西又寄回来。写一个带方向的接口示例interface IMediaQueue { void addSong(in Song song); void nextSong(out Song song); boolean process(in Song input, out Song output); }对应的行为是addSong时客户端把Song完整序列化发给服务端服务端收到的就是完整对象nextSong时客户端只传一个“空引用”过去服务端创建对象并填充调用结束后这个对象才出现在客户端process则是两端都能看到修改。三个方向的序列化开销完全不同方向客户端 → 服务端服务端 → 客户端开销in传递不返回一次序列化out不传递返回一次序列化inout传递返回两次序列化我的建议是能不用inout就不用。多次序列化在进程通信里是肉眼可见的性能损耗而且out参数有个坑服务端方法里收到的out参数对象是空壳如果直接在原有字段上操作比如song.getTitle()会拿到 null 或者抛空指针。正确做法是服务端先创建一个新对象填完数据再“还给”客户端。3.3 列表参数的隐藏规则ListSong在 AIDL 里直接作为参数时默认方向是in即客户端传给服务端。如果你想要服务端返回一个列表直接写在返回值里就行。但要注意AIDL 生成的代码对List的实现类有限制服务端返回的列表必须是ArrayList或List接口的实现中能被 Parcel 正确还原的类型。如果你用自定义的List子类返回时可能出现BadParcelableException。最稳妥的做法是服务端一律返回ArrayList别搞特殊。4. 回调与 oneway让通信真正“异步”4.1 服务端主动通知客户端回调接口机制前面音乐服务的接口里我定义了两个方法registerCallback和unregisterCallback。为什么要回调因为播放状态是服务端主动变化的东西比如一首歌播完了、被用户切到了下一首客户端不能总靠轮询去问“播完了吗”那样既浪费 Binder 带宽又不实时。正确做法是客户端注册一个回调接口服务端在状态变化时调用这个接口。回调接口定义如下同样是一个 AIDL 文件// IMusicCallback.aidl package com.example.media; interface IMusicCallback { void onPlayStateChanged(boolean isPlaying, String songId); }客户端实现这个接口时必须继承它的Stub类因为跨进程传递的本质上是一个 Binder 对象private final Handler mainHandler new Handler(Looper.getMainLooper()); private final IMusicCallback.Stub callback new IMusicCallback.Stub() { Override public void onPlayStateChanged(boolean isPlaying, String songId) throws RemoteException { // 注意这里运行在客户端进程的 Binder 线程池不是主线程 mainHandler.post(() - { playButton.setSelected(isPlaying); titleView.setText(songId 播放中); }); } };这个Handler切换很重要。你在回调里直接更新 UI 会抛CalledFromWrongThreadException因为这个回调是在客户端进程的 Binder 线程里被执行的跟主线程完全不是一个 Looper。很多第一次写 AIDL 回调的人都会在测试时遇到这个崩溃。4.2 oneway什么时候需要用oneway关键字是 AIDL 里很容易被忽视的功能。它的语义是客户端发起调用后不等待服务端执行直接继续往下跑。适用场景是那些不需要返回结果的通知式调用比如清空缓存、打日志、更新进度上报。interface IRemoteService { oneway void reportProgress(int progress); oneway void flushCache(); }oneway有几个硬性限制方法返回类型必须是void不能带inout/out参数否则编译直接报错。它的原理是在 Binder 通信层设置了异步标志发送数据后立刻返回不阻塞调用线程。我用它解决过一个实际问题客户端在切歌时上报播放进度如果不加oneway每次上报都会等待服务端处理完快速连续上报时客户端会有明显的卡顿感加了oneway后上报的发送变成异步调用端完全无感知。注意oneway不等于“消息丢了没事”底层其实还是有序送到对端只不过不等回复而已。4.3 Binder 线程池的容量问题服务端处理跨进程请求的线程是有限度的。Android 里每个进程默认的 Binder 线程池大小是 16超过这个数量后新调用会排队等待。如果你的服务端 Stub 方法里有耗时操作比如同步读数据库、请求网络那么大量调用会占满这 16 个线程后续所有 AIDL 请求都堵在队列里客户端表现为“调用越来越慢最终卡死”。所以一个好的 AIDL 服务端设计原则是Stub 方法里的代码要快耗时操作放到服务端自己的线程池里执行执行完成后再通过回调通知客户端。换句话说AIDL 接口本身不要设计成“同步等待大数据返回”的样子而是设计成“请求 异步结果”。5. 连接健壮性绑定生命周期、DeathRecipient 与重连5.1 绑定生命周期里最容易误解的点bindService配对的是unbindService这两个必须成对出现。onServiceDisconnected这个回调有它自己的行为语义很多人会误以为主动解绑也会触发它实际不是。官方文档定义得很清楚只有在 Service 意外断开时才会回调比如服务端进程崩溃、被系统杀掉你主动unbindService后这个回调不会触发。判断当前是主动解绑还是被动断开通常用一个 boolean 标志位private boolean isUnbinding false; public void unbind() { isUnbinding true; unbindService(connection); } Override public void onServiceDisconnected(ComponentName name) { if (isUnbinding) { return; } // 服务端意外死亡走重连逻辑 musicService null; shouldReconnect true; }另外onServiceDisconnected调用时机在 Android 不同版本上有差异它可能在连接完全失效之后才触发。所以实际开发里我还会同时监听onBindingDiedAPI 26 新增来处理更紧急的失效场景。5.2 DeathRecipient在 Binder 层面监听服务端死亡ServiceConnection是系统层面对绑定关系的监听而DeathRecipient是绑定到具体 Binder 对象层面的监听。两者配合使用会更稳。当你要对失去 IPC 连接做出即时反应时DeathRecipient更直接private final IBinder.DeathRecipient deathRecipient new IBinder.DeathRecipient() { Override public void binderDied() { // binderDied 发生在 Binder 线程不能直接更新 UI Handler mainHandler new Handler(Looper.getMainLooper()); mainHandler.post(() - { musicService null; scheduleReconnect(); }); } }; private void linkDeath() { if (musicService ! null) { try { musicService.asBinder().linkToDeath(deathRecipient, 0); } catch (RemoteException e) { // 进程已死或连接已经断开 scheduleReconnect(); } } }这里的linkToDeath一定要在onServiceConnected之后调用并且要记得在解绑时unlinkToDeath否则会造成内存泄漏因为 Binder 对象在底层持有你的回调引用。5.3 重连策略别用死循环轮询服务进程可能被系统回收这是 Android 多进程开发的常态尤其在一些内存压力大的设备上。我的重连策略是丢失连接后延迟 2 秒重试如果连续失败退避到 5 秒、10 秒、30 秒最大间隔不超 60 秒。同时要避免高频重试产生“进程反复拉起又杀死”的抖动。退避算法的示例逻辑private int retryDelayMs 2000; private void scheduleReconnect() { Handler mainHandler new Handler(Looper.getMainLooper()); mainHandler.postDelayed(() - { Intent intent new Intent(context, MusicPlaybackService.class); boolean bound context.bindService(intent, connection, Context.BIND_AUTO_CREATE); if (!bound) { retryDelayMs Math.min(retryDelayMs * 2, 60000); scheduleReconnect(); } else { retryDelayMs 2000; } }, retryDelayMs); }还有一个细节bindService返回 false 表示绑定失败但这不代表永久失败系统进程调度紧张时也会临时失败。所以失败后按退避策略继续重试即可。6. 我在 AIDL 实战里踩过的坑6.1 TransactionTooLargeExceptionBinder 缓冲区只有 1MB这个异常我印象极深。当时要跨进程传一个包含几十首歌信息的大列表本地跑没问题真机上一调用就崩而且是偶现查了很久才定位。原因在于 Binder 事务缓冲区大小有限通常不到 1MB当单次事务的数据量超过这个限制系统会直接抛TransactionTooLargeException。注意是单次事务不是累计。一次方法调用里传一个大 Bitmap、一个大的 JSON 字符串、一个包含大量元素的 List都可能突破上限。解决方案按我的使用排序传文件路径而不是内容将大文件写入磁盘Binder 只传路径字符串。分页拉取服务端接口设计成getSongs(int offset, int count)客户端分批拉取。压缩和省字段能传 id 的不要传整个对象客户端再通过本地缓存补全。数据库中转数据量大时写入 ContentProvider 或 Room 数据库AIDL 只传递查询条件。我用分页方案把这个坑彻底解决了顺便还把列表加载的性能优化了算因祸得福。6.2 AIDL 接口升级引发的兼容问题AIDL 接口一旦对外发布它的方法签名和参数顺序就不能轻易改。原因是 Binder 协议在底层给每个方法分配了一个编号这个编号由编译器根据方法定义顺序生成。如果你在旧接口中间插入一个新方法所有方法编号都会改变旧客户端调用就会调用到错误的方法可能出现莫名的ClassCastException或BadParcelableException。我的做法是接口版本化管理。在接口里增加一个版本协商方法interface IRemoteService { int getVersion(); // 1.x 版本接口 void legacyMethod(); // 2.0 新增接口 void newMethod(); }客户端在连接后先调用getVersion根据版本决定后续调用哪些方法。如果版本差异过大直接提示用户升级。这个方法比较土但非常稳定我在多个线上项目里都这么用。6.3 Debug 断点在 Stub 方法里导致“假死”这个坑极其隐蔽。你在服务端 Stub 方法实现里打了断点然后用客户端调用这个接口现象是客户端永远不返回看起来像死锁了。原因很简单你打断点的代码跑在 Binder 线程池里断点命中后整个线程被暂停而客户端同步调用正在等待服务端返回两边互相等待像极了死锁。我在调试 AIDL 时吃过两次亏之后总结了一套流程断点打在客户端调用方和服务端方法入口都可以但一旦发现线程挂住先检查是不是断点打断了 Binder 线程。更保险的做法是服务端 Stub 方法里用日志替代断点日志是异步的不会阻塞线程。6.4 out 参数直接操作导致 NPE刚才第 3 章简单提过out参数这里用一个具体的崩溃场景接口定义void fillSong(out Song song)服务端实现时如果直接对传入的song调用setTitle(xxx)会直接空指针。因为out参数在客户端调用时只传了一个空壳服务端收到的song是 null。正确写法是服务端方法内部新建对象再赋值给参数public void fillSong(Song song) { song new Song(...); // 正确创建一个新对象 // 以下写法是错的 // song.setTitle(test); // song 可能为 null }这个坑不难但一旦踩到报错信息不一定直接指向out参数排查时容易绕远路。给参数标注方向之前先想清楚服务端到底需要读到什么、要写回什么能少很多麻烦。6.5 回调泄漏注册了却忘了注销客户端注册了回调但在 Activity/Fragment 销毁时只解绑了服务而没有注销回调会导致服务端一直持有客户端 Binder 引用。客户端进程不结束还好一旦客户端进程被杀服务端下次调用回调时会抛DeadObjectException。所以我在实践中把注册和解绑固化成了三个成对动作绑定时注册回调、解绑时先注销回调、再解绑服务并且用try-catch(RemoteException)包住注销操作确保异常情况也能继续执行解绑。Override protected void onStop() { super.onStop(); if (musicService ! null) { try { musicService.unregisterCallback(callback); } catch (RemoteException e) { // 服务端进程已经不存在忽略 } unbindService(connection); musicService null; } }写在最后如果你刚接触 AIDL我不建议照着网上的 Demo 抄一遍就觉得自己会了。我的体会是AIDL 真正的难点不在语法而在接口设计用in还是inout、一次传多少数据、要不要oneway、服务端方法耗时多长这些决策直接影响线上表现。把这几个问题想清楚拿到任何 AIDL 项目都能很快上手如果只是停留在“能跑通”那后面排队等你解决的可能就是越用越卡的 Binder 线程池和莫名其妙消失的远程服务。
返回列表