简介:面向Android开发者的AIDL跨进程通信实战案例,以模拟第三方应用调用支付宝支付为主线,系统演示如何通过AIDL定义支付接口、在独立进程中注册服务、客户端绑定并调用方法,适合想理解IPC机制或准备接入支付SDK的中高级Android工程师学习。压缩包约18.71MB,共2000个文件,除核心的aidl接口、Java源码、AndroidManifest.xml配置外,还包含class、dex、jar等编译产物与json、properties、gradle工程文件,便于直接导入Android Studio对照阅读。案例中IAliPayService.aidl的创建、Stub实现、bindService连接及unbindService释放均有完整代码结构,可拆解学习跨进程调用的完整链路。已有614人学习下载,配套AlipayAidlDemo工程对梳理AIDL入门与支付场景模拟很有参考价值。
1. 从 IPC 到支付模拟:为什么支付能力要放在独立进程
先把一个反直觉的结论放在前面:跨进程通信(IPC)在 Android 里的真实开销,远比你以为的乐观。Binder 机制把一次跨进程调用压缩到微秒级别,所以支付、安全校验这类敏感能力,才敢放心地放进独立进程运行——主进程崩溃时它不受影响,主进程内存吃紧时系统还能优先压缩它。这份资源就是围绕 AIDL(Android Interface Definition Language)实现的一套模拟支付服务:应用主进程负责界面和业务,支付服务注册在另一个进程里,通过 AIDL 定义的接口完成下单、查单、结果回调的完整链路,体验上非常接近对接真实支付 SDK 的跨进程调用与异步回调。适合已经在用 bindService、但搞不清 AIDL 接口为什么要拆成两段、回调为什么总跑在奇怪线程的开发者;也适合想预研支付模块、但暂时不敢接真实 SDK 的团队。
2. 先把支付协议定下来:AIDL 接口设计的三个关键取舍
2.1 为什么是 AIDL,而不是 Messenger 或广播
Android 的跨进程通信选项不少:广播适合单向通知,ContentProvider 适合结构化数据共享,Messenger 基于 Handler 串行收发消息,AIDL 则直接暴露一个类型安全的远程接口。放在支付场景里,广播首先排除——支付结果涉及金额和订单号,广播是全局可见的,不适合承担需要定向回执的业务;ContentProvider 和"方法调用"这个心智不匹配,你没法自然地表达"createOrder 返回一个订单号";剩下 Messenger 和 AIDL 是真正的竞争者。
如果只是做一次「请求-应答」,Messenger 完全够用,Message.what 定义几个常量就能跑。但支付链路的调用形态有下单、查单、状态回调三种,尤其回调节点会贯穿整个支付周期,Messenger 需要把 callback 包装成另一组消息类型,业务代码里到处是散落的 Message.what 分支,时间长了没人愿意维护。AIDL 会为接口生成完整的 Stub/Proxy 骨架,跨进程的序列化和 Binder 交互都被封装起来,业务侧拿到的就是一个类型安全的接口,编译期有问题当场暴露。这是 AIDL 在这个场景里胜出的关键——接口定义本身就是协议文档,实现侧和调用侧使用完全相同的签名。
这个过程也正好解释了 AIDL 文件为什么要单独维护:它就是跨进程通信的"契约"。契约一旦改动,服务端和客户端必须同步修改,否则运行时报错。常见做法是把 AIDL 接口从业务代码里拆出来,甚至单独建一个 contract 模块,两端引用同一个模块的依赖,避免拷贝两份接口文件导致的不一致——这是我在项目里吃过亏后强制定下的习惯。
2.2 下单、查单、回调:三个方法怎么拆
接口粒度直接影响后续扩展。我把模拟支付拆成两个 AIDL 文件:IPayService 是客户端调服务端的下单与查单通道,IPayCallback 是服务端把支付结果推回客户端的通道。下面这份 IPayService.aidl 是项目里最先定稿的版本:
// IPayService.aidl package com.example.paysdk; import com.example.paysdk.IPayCallback; interface IPayService { /** * 创建订单:传入订单描述(如 "amount=0.01&goods=test"),返回订单号 */ int createOrder(String orderInfo); /** * 查询订单结果:返回是否支付成功 */ boolean queryPayResult(int orderId); /** * 注册支付结果回调 */ void addCallback(IPayCallback callback); /** * 注销支付结果回调 */ void removeCallback(IPayCallback callback); }注释部分我留了关键信息。createOrder 的入参是拼接好的订单字符串,模拟真实支付 SDK 中下单时上送的商品与金额摘要;返回值是订单号,服务端在同一进程内用自增 ID 生成,避免重复下单产生同一个订单号的边界竞争。queryPayResult 是简单的状态查询,返回 boolean 只表达"是否支付成功",如果需要更细的失败原因,应该把返回值设计成状态码而不是布尔值。
回调侧单独定义在另一个文件里:
// IPayCallback.aidl package com.example.paysdk; interface IPayCallback { /** * 支付结果回调:resultCode 0 处理中,1 成功,-1 失败 */ void onPayResult(int orderId, int resultCode, String message); }把回调拆成独立 AIDL 接口有一条硬规则:AIDL 不允许把普通 Java 接口当参数传递,必须使用 AIDL 生成的 Binder 接口类型。如果把 onPayResult 塞进 IPayService 作为普通方法,客户端就没法注册一个跨进程回调对象。这条规则新手很容易忽略,编译期不会报错,运行到绑定阶段才暴露出来,属于典型的"编译过、跑不快"的暗坑。备注里标了 resultCode 的三档取值,这是模拟支付服务内部的状态编码,客户端和服务端共用一份常量,建议在服务端用静态常量集中定义,别在回调和判断处写裸数字。
2.3 in/out/inout:方向标签背后的传输开销
AIDL 对每个非基本类型参数都强制要求方向。in 表示客户端流向服务端,out 表示服务端流向客户端,inout 双向都走。方向不只是语义,更关系序列化次数:out 参数跨进程传输时,客户端传入的数据在服务端拿到的是空壳;inout 参数则要在两个方向各做一次 Parcel 序列化和反序列化,开销直接翻倍。
| 方向 | 数据流向 | 序列化次数 | 适用场景 |
|---|---|---|---|
| in | 客户端 -> 服务端 | 1 次 | 下单参数、查询条件 |
| out | 服务端 -> 客户端 | 1 次 | 服务端回填的对象 |
| inout | 双向 | 2 次 | 尽量不要用 |
支付场景里 orderInfo 是纯入参,标 in;queryPayResult 的 int 返回值是基本类型,不需要方向;回调对象作为入参传递,但它的方向严格说是另一条链路,按 in 处理没错。我一般会建议团队里定一条规矩:默认能用 in 就用 in,出现 out 必须写注释说明为什么,inout 直接不允许出现在新接口里。
方向标签也是新手容易翻车的地方。把回调对象标成 inout 之后,Binder 驱动会尝试在两个方向做 Parcel 传递,而回调对象内部持有的是 Binder 代理,双向序列化容易触发异常。这个坑我实际遇到过:一次接口评审时把 addCallback 的入参标成了 inout,运行到 addCallback 时直接抛异常,排查了半天才发现是方向标签画蛇添足。改回 in 之后,一切正常。
另外,AIDL 不是万能的。它支持基本类型、String、CharSequence、List、Map、Parcelable 与另一个 AIDL 接口,但不支持直接传一个普通 Java 对象。如果需要传订单实体,就得让订单类实现 Parcelable 并额外建一个同名 .aidl 文件声明它,第 6 章会演示范。
3. 服务端侧的两个关键点:Remote Service 注册与状态回调实现
3.1 Remote Service 注册:进程与导出权限
服务端的核心是把支付逻辑装进独立进程。搞独立的做法只有一个:在 AndroidManifest.xml 里给 Service 配置 android:process。这个属性把 Service 分派到一个以冒号开头的私有进程里,应用主进程和支付进程就此隔离,主进程的内存抖动和卡顿不会波及支付链路。
<service android:name=".PayService" android:exported="false" android:process=":payprocess" />exported="false" 的含义要厘清:它限制的是"其他应用"能不能来绑这个服务,同应用内部不同进程间的绑定不受影响。这一点我见过不少开发者误会,以为跨进程就一定要 exported="true",结果把支付服务暴露给外部,支付接口裸奔在公网上。如果这个模块将来要作为 SDK 提供给别的 App 集成,再改成 exported="true",并同时配置 permission 做调用方校验。
同一份资源里另一个容易被忽略的细节是进程名。android:process 以冒号开头,系统会拼上包名作为实际的进程名;直接写一个不带冒号的名字,会变成全局可见的进程名,其他应用知道进程名就可以拿到你的进程共享文件,存在安全隐患。模拟支付用冒号开头就够,真实产品里如果确实要跨应用共享进程,再单独评估风险。
Service 本体继承普通 Service,唯一区别是 onBind 必须返回一个 IBinder 实例。这个实例不是随便 new 一个 Binder,而是由 AIDL 生成的 IPayService.Stub 的子类实例。Stub 内部已经实现完整的 Binder 事务处理,我们只需要填业务逻辑。
3.2 Stub 实现:状态容器与线程安全
AIDL 接口的每个方法都不是跑在 Service 的主线程,而是由 Binder 驱动分派到 Binder 线程池。这意味着 createOrder、queryPayResult、addCallback 可能同时被多个线程调用,共享数据结构必须做并发控制,否则订单状态错乱、回调列表并发修改异常都算轻的。
public class PayService extends Service { private static final int STATUS_PROCESSING = 0; private static final int STATUS_SUCCESS = 1; private static final int STATUS_FAIL = -1; // 订单号 -> 状态;ConcurrentHashMap 保证跨线程可见性 private final ConcurrentHashMap<Integer, Integer> orderStatus = new ConcurrentHashMap<>(); // 订单号 -> 订单描述,用于查单日志 private final ConcurrentHashMap<Integer, String> orderInfoMap = new ConcurrentHashMap<>(); // 回调列表:遍历多写少,CopyOnWriteArrayList 读不加锁 private final CopyOnWriteArrayList<IPayCallback> callbacks = new CopyOnWriteArrayList<>(); // 订单号生成器:保证并发下不重复 private final AtomicInteger orderIdGenerator = new AtomicInteger(1); // 模拟支付线程池:避免每次下单都 new Thread private final ExecutorService payExecutor = Executors.newFixedThreadPool(4); private final IPayService.Stub binder = new IPayService.Stub() { @Override public int createOrder(String orderInfo) throws RemoteException { int orderId = orderIdGenerator.getAndIncrement(); orderStatus.put(orderId, STATUS_PROCESSING); orderInfoMap.put(orderId, orderInfo); mockPayProcess(orderId); return orderId; } @Override public boolean queryPayResult(int orderId) throws RemoteException { Integer status = orderStatus.get(orderId); return status != null && status == STATUS_SUCCESS; } @Override public void addCallback(IPayCallback callback) throws RemoteException { if (callback != null && !callbacks.contains(callback)) { callbacks.add(callback); } } @Override public void removeCallback(IPayCallback callback) throws RemoteException { callbacks.remove(callback); } }; @Override public IBinder onBind(Intent intent) { return binder; } }第一个字段用 ConcurrentHashMap 存订单状态,因为 mockPayProcess 会在另一个线程里修改状态,主流程线程同时也在读;用 HashMap 会丢状态甚至死循环。callbacks 用 CopyOnWriteArrayList 的原因正好相反:回调遍历频繁,写入也就是注册注销,读多写少的默认选择。AtomicInteger 解决并发下单的订单号冲突,Binder 线程池同一时刻可能进来多个 createOrder 请求。线程池固定 4 个线程,而不是每次 new Thread,是防止并发下单高峰期把进程线程数打到几百,系统会判定为内存异常。
3.3 模拟支付处理与回调推送机制
下单之后不能立即给成功结果,得把"处理中"和"最终结果"两个状态区分开。我在 PayService 里单独抽了 mockPayProcess 方法:从线程池取线程,sleep 5 秒模拟支付网关的耗时,然后把订单状态改成成功,再通知注册过的所有回调。
private void mockPayProcess(int orderId) { payExecutor.execute(() -> { try { Thread.sleep(5000); orderStatus.put(orderId, STATUS_SUCCESS); notifyPayResult(orderId, STATUS_SUCCESS, "支付成功"); } catch (InterruptedException e) { orderStatus.put(orderId, STATUS_FAIL); notifyPayResult(orderId, STATUS_FAIL, "支付失败"); } }); } private void notifyPayResult(int orderId, int resultCode, String message) { for (IPayCallback cb : callbacks) { try { cb.onPayResult(orderId, resultCode, message); } catch (RemoteException e) { // 客户端进程可能已死,移除失联回调,防止列表膨胀 callbacks.remove(cb); } } } @Override public void onDestroy() { super.onDestroy(); payExecutor.shutdown(); }notifyPayResult 是回调推送的核心入口。它遍历所有已注册回调,逐个调用 onPayResult,每次调用都是一次真实的跨进程传输。RemoteException 是这里必须处理的:客户端崩溃或解绑之后,服务端这边的回调代理已经失效,继续调用会让异常在每个循环里炸一遍;catch 住之后顺手从列表里移除,等于给回调列表一个自净机制。onDestroy 里 shutdown 线程池是收尾动作,虽然模拟支付进程结束得很快,但养成习惯对真实 SDK 落地有好处。
服务端完整逻辑就是这个闭环:createOrder 种下"处理中"状态,线程池异步推进状态机,最终通过回调把结果推回客户端。查单接口 queryPayResult 也是读同一个状态表,客户端可以在没有注册回调的情况下主动轮询,两条路互不干扰。
4. 客户端侧的三个动作:绑定服务、注册回调、刷新 UI
4.1 绑定服务:显式 Intent 与 ServiceConnection
客户端第一步是把远程 Service 拉到自己的进程里。bindService 方法需要三个参数:Intent、ServiceConnection、绑定标志。Intent 必须显式指定目标服务,跨进程场景下最稳妥的做法是用 ComponentName 而不是 Class 对象,因为混淆之后类名会变,字符串形式至少可控。
private IPayService payService; private boolean bound = false; private void bindPayService() { Intent intent = new Intent(); ComponentName cn = new ComponentName( getPackageName(), "com.example.paysdk.PayService"); intent.setComponent(cn); bindService(intent, connection, Context.BIND_AUTO_CREATE); } private final ServiceConnection connection = new ServiceConnection() { @Override public void onServiceConnected(ComponentName name, IBinder binder) { payService = IPayService.Stub.asInterface(binder); bound = true; try { payService.addCallback(payCallback); } catch (RemoteException e) { Log.e("PayClient", "注册回调失败", e); } } @Override public void onServiceDisconnected(ComponentName name) { bound = false; payService = null; } };BIND_AUTO_CREATE 标志表示绑定的同时如果服务没启动就自动创建它。onServiceConnected 拿到的 IBinder 是 Binder 驱动的代理对象,通过 IPayService.Stub.asInterface 包一层,就转换成了类型安全的接口。onServiceDisconnected 并非 unbindService 的镜像回调,它只会在服务端进程异常崩溃或系统强杀时触发;正常解绑不会走这里,很多开发者在这里断点等待,结果永远不来,以为是自己的 bug,其实是语义理解错了。
绑定时机放在 Activity 的 onStart,解绑放 onStop,而不是 onCreate/onDestroy。支付页面旋转屏幕时 onDestroy 不触发,但 onStop 会走,如果不在 onStop 解绑,服务端那边回调列表会越积越长,最终拖垮回调推送的遍历效率。
4.2 从下单到收结果:一次完整调用链路
绑定成功只是建立通道,真正的支付流程是:注册回调 → 点击下单 → 服务端返回订单号 → 等待异步回调。点击按钮的下单代码在客户端很简单,因为业务逻辑都在服务端:
public void onClickPayButton(View view) { if (!bound || payService == null) { Toast.makeText(this, "支付服务未连接", Toast.LENGTH_SHORT).show(); return; } try { String orderInfo = "amount=0.01&goods=android_aidl_demo"; int orderId = payService.createOrder(orderInfo); orderStatusText.setText("下单成功,订单号:" + orderId + ",支付处理中"); } catch (RemoteException e) { Log.e("PayClient", "下单失败", e); } }这段逻辑有两个容易被忽略的点。一是 RemoteException 的捕获:跨进程调用和本地调用不一样,服务端进程可能刚好重启,Binder 链路断了,调用就会抛异常;不 catch 会让整个 App 挂在崩溃线上。二是 createOrder 是同步调用,但因为它跑在 Binder 线程池,并不会阻塞 UI 线程。如果服务端方法本身耗时较长,客户端这侧的调用线程会一直等,所以 AIDL 方法设计时要在同步返回和异步回调之间做取舍,第 6 章展开 oneway 方案。
4.3 回调翻线程:Binder 线程池不是 UI 线程
onPayResult 回调的运行线程是 Binder 线程池里某一个线程,不是主线程。直接在里面调 textView.setText,会立刻抛 CalledFromWrongThreadException。新手第一次遇到这个异常都会愣住:回调明明是在客户端代码里写的,为什么不在主线程?
private final IPayCallback payCallback = new IPayCallback.Stub() { @Override public void onPayResult(int orderId, int resultCode, String message) { // 当前线程是 Binder 线程池,不是主线程 PayClientActivity.this.runOnUiThread(() -> { if (resultCode == 1) { payStatusText.setText("订单 " + orderId + " 支付成功"); } else if (resultCode == 0) { payStatusText.setText("订单 " + orderId + " 处理中"); } else { payStatusText.setText("订单 " + orderId + " 支付失败"); } }); } };runOnUiThread 会把 UI 操作 post 到主线程消息队列,由主线程 Looper 串行执行。如果回调频率高,或者需要在动画帧里频繁更新,建议换成 Handler 持有主线程 Looper,方便 removeCallbacks 做生命周期管理。
回调的注册与注销必须成对出现。我在 onServiceConnected 里 addCallback,在 onStop 里 removeCallback 再解绑:
@Override protected void onStop() { super.onStop(); if (bound) { try { payService.removeCallback(payCallback); } catch (RemoteException e) { Log.e("PayClient", "注销回调失败", e); } unbindService(connection); bound = false; } }这里 removal 放在 unbind 之前,是为了保证服务端在收到解绑之前先把回调列表清干净。如果先解绑,客户端代理已经失效,removeCallback 这趟跨进程调用可能直接抛异常,回调就留在服务端成了僵尸。
5. 避坑手册:跨进程支付里最容易翻车的五个瞬间
5.1 bindService 静默失败:onServiceConnected 永远不来
现象:调用 bindService,方法返回 true,但 onServiceConnected 始终不被回调,界面一直停在"支付服务未连接"。
原因:Service 没在 AndroidManifest.xml 注册,或者注册的进程名与实际启动的不一致;Intent 用的是隐式匹配,且目标 Service 又设置了 exported="false"。bindService 是异步过程,匹配失败不会抛异常,只会无声无息地失败。
解决:先确认清单里有<service android:name=".PayService">,并且包名路径正确;Intent 用 ComponentName 显式指定,就像 4.1 节写的那样;还要检查 android:process 属性是否写错导致启动到了另一个进程。排查时在 Service 的 onCreate 里打日志,能确认服务有没有真正创建。
5.2 疯狂注册回调:重复 addCallback 造成的累积
现象:Activity 每次重建后支付结果回调都比上一次多触发一倍,服务端日志里能看到同一个回调对象反复出现在列表里。
原因:Activity 重建时 onStart 又执行了一次 bindService 和 addCallback,而旧实例的 removeCallback 要么没调用,要么因为时序没执行;服务端又没做去重。
解决:服务端 addCallback 里用 contains 去重;客户端把"注册/注销"这对操作放在生命周期对称位置,并确保注销优先于解绑。更稳妥的做法是回调代理不走强引用,用 WeakReference 包一层,服务端持有的是弱引用,Activity 销毁后自然被回收,这是真实 SDK 的常用姿势。
5.3 DeadObjectException:服务端进程被杀之后的调用
现象:支付服务在后台被系统回收,过一会儿用户再点击"立即支付",客户端直接抛 DeadObjectException,界面闪退。
原因:Binder 链路是建立在系统内核驱动的上下文上的,服务端进程死亡后,代理对象还在客户端内存里,但底层通道已经断了,任何调用都会抛这个异常。这不是偶发,是跨进程设计的必然——服务端进程必须做生命周期兜底。
解决:绑定成功后给 binder 注册 linkToDeath 监听:
IBinder.DeathRecipient deathRecipient = () -> { Log.w("PayClient", "支付服务进程已死亡,尝试重连"); bound = false; payService = null; bindPayService(); }; binder.linkToDeath(deathRecipient, 0);重连不是立刻执行一个新的 bindService,而是延迟 1 到 2 秒再发起,避免服务端进程还没重启完就撞上同样的错误。这个回调发生在 Binder 线程池里,重连动作记得 post 到主线程再执行。
5.4 回调里改 UI 崩溃:CalledFromWrongThreadException
现象:onPayResult 里直接调 textView.setText,偶发崩溃,崩溃堆栈指向 setText 内部,而且只在支付结果回来时出现。
原因:回调在 Binder 线程池执行,UI 视图只能在主线程操作,跨线程触碰 UI 会在 ViewRootImpl 检查时抛异常。偶发是因为 Binder 线程池调度时机不同,有时候碰巧主线程空闲处理得快。
解决:所有 UI 更新统一在 runOnUiThread 或 Handler 里做。习惯做法是不要在 AIDL 回调里写任何与视图相关的代码,回调只负责把数据更新到一个 ViewModel 或状态类,UI 层观察变化再刷新。这样哪怕回调频次再高,主线程压力和崩溃风险都不会叠加。
5.5 数据量超限:TransactionTooLargeException
现象:支付结果回调里附加了订单详情的 List,几十条数据时正常,上百条时服务端推回调就抛 TransactionTooLargeException。
原因:Binder 单向事务的数据量上限大约 1MB,超过之后系统直接拒绝传输。这不是 AIDL 代码写错,而是把太多数据塞进了跨进程通道。
解决:跨进程只传必要字段。订单详情明细要求回调里缩短成状态码和简短描述,需要详情时客户端用 queryPayResult 或再拉一次数据。如果一定要传大对象,把对象落到文件,只传文件路径,让客户端自己去读。这条经验是从一个真实项目里带过来的:当时在回调里塞了一个带签名图的支付凭证,Bitmap 序列化直接炸了,换成路径之后,问题消失。
6. 进阶一步:oneway、Parcelable 与真实 SDK 替换
6.1 oneway 关键字与 Parcelable 订单对象
把 createOrder 改成 oneway,调用方发完请求立即返回,不等服务端计算结果。这在语义上更像真实的下单流程:客户端只负责把订单递出去。改造时 AIDL 文件改动很小:
// IPayService.aidl interface IPayService { oneway void createOrderAsync(String orderInfo); int createOrder(String orderInfo); }oneway 方法不能有返回值也不能有 out 参数,所以异步版 createOrderAsync 返回 void,订单号只能在回调里回传。如果业务需要先拿订单号再展示二维码(模拟扫码支付场景),就得在服务端先创建订单,把订单号由回调推回来,时序对调一下。
自定义订单对象传参也顺手体验一下。定义 OrderInfo 类实现 Parcelable,再新建一个 OrderInfo.aidl 声明它,IPayService 方法签名改成int createOrder(OrderInfo order),AIDL 文件里用 import 引入订单类。注意订单类必须声明 parcelable 关键字和包名,否则编译器认不出来。跨进程多字段参数用对象传,比拼接字符串 orderInfo 更便于维护;字符串方案为了简单可以保留,真实 SDK 集成阶段建议直接上 Parcelable 或 Bundle。
6.2 替换真实支付 SDK 的接口映射
模拟支付最大的用处是让业务侧先跑通流程,等真实 SDK 到位时,换掉的应该是 IPayService 的实现类,而不是整个客户端架构。把 IPayService 当成支付抽象层,模拟实现和真实实现各自维护:
| 动作 | 模拟实现 | 真实 SDK 对应 |
|---|---|---|
| 下单 | createOrder 返回订单号 | SDK 的 pay 方法发起支付 |
| 结果通知 | IPayCallback.onPayResult | SDK 的支付回调监听 |
| 查询 | queryPayResult | 订单查询接口 |
客户端代码里只依赖 IPayService 和 IPayCallback 这两个抽象,模拟实现通过 bindService 绑定到独立进程,真实 SDK 可能是同一个进程内直接初始化。业务代码不需要感知底层是跨进程还是本地调用,因为接口签名完全一致。这一步做好之后,支付模块的替换成本被压缩到"重新实现 Stub"这一层。
我从这个项目里学到的习惯是:跨进程回调每次上线前,先问一遍"回调在哪个线程、生命周期跟着谁走",确认之后才写第一行实现代码。从那以后,我再也没有被 CalledFromWrongThreadException 和回调泄漏绊倒第二次。希望帮到你。
本文还有配套的精品资源,点击获取