把 Binder 接口设计成稳定契约
从线程、连接状态到错误码,建立一个能被客户端长期依赖的 IPC 边界。
把 Binder 接口设计成稳定契约
目标:从一个最小的服务契约开始,理解 Binder IPC 的接口、线程、连接、版本和错误语义。
这篇文章解决什么
很多 IPC 问题不是调用失败。
而是调用成功后语义不一致。
客户端以为方法是同步的。
服务端却把它丢到了后台线程。
客户端以为对象长期有效。
服务端却在进程重启后丢失状态。
客户端以为异常可以重试。
服务端却已经执行了副作用。
稳定契约要把这些事情写出来。
设计前提
示例使用 Android 原生 Binder 模型。
示例以 AIDL 描述跨进程接口。
示例不依赖系统隐藏接口。
示例不假设 root 权限。
示例只在自己的应用和测试设备上运行。
示例服务不访问第三方数据。
工程目录
binder-contract/
├── app/
│ ├── src/main/aidl/com/koko/channel/ISystemChannel.aidl
│ ├── src/main/java/com/koko/channel/ChannelService.kt
│ ├── src/main/java/com/koko/channel/ChannelClient.kt
│ └── src/main/java/com/koko/channel/Reply.kt
├── build.gradle.kts
└── settings.gradle.kts
服务端和客户端可以属于同一个 APK。
也可以拆成两个 APK。
同一个 APK 便于第一轮实验。
两个 APK 更接近真实权限边界。
先从同一个工程开始。
AIDL 的角色
AIDL 描述跨进程方法。
编译器会生成 Stub。
服务端继承 Stub。
客户端拿到 IBinder。
客户端通过代理调用方法。
参数会被序列化。
返回值会跨进程传输。
对象本身不会跨进程共享。
共享的是协议和数据副本。
最小接口
package com.koko.channel;
parcelable ChannelRequest;
parcelable ChannelReply;
interface ISystemChannel {
ChannelReply execute(in ChannelRequest request);
int getVersion();
void ping();
}
方法名要表达动作。
getVersion 不应该执行副作用。
ping 只用来确认连接。
execute 需要明确幂等性。
Parcelable 请求
@Parcelize
data class ChannelRequest(
val requestId: String,
val command: String,
val arguments: List<String>,
val clientVersion: Int,
) : Parcelable
请求必须有唯一编号。
编号用于日志关联。
编号不应该包含敏感信息。
命令应该使用有限集合。
不要把任意 shell 字符串作为命令。
Parcelable 返回值
@Parcelize
data class ChannelReply(
val requestId: String,
val code: Int,
val message: String,
val retryable: Boolean,
) : Parcelable
错误码比错误文本稳定。
retryable 只表达建议。
它不是自动重试开关。
消息用于日志和界面。
消息不要泄露内部路径。
版本策略
接口一旦发布就会被依赖。
新增方法通常是兼容变化。
删除方法通常是不兼容变化。
改变参数含义也是不兼容变化。
服务端应该返回协议版本。
客户端应该检查最低版本。
版本比较应该是数字比较。
不要比较随意的字符串。
服务端骨架
class ChannelService : Service() {
private val binder = object : ISystemChannel.Stub() {
override fun execute(request: ChannelRequest): ChannelReply {
return executor.execute(request)
}
override fun getVersion(): Int = PROTOCOL_VERSION
override fun ping() {
logger.info("ping")
}
}
private lateinit var executor: RequestExecutor
private lateinit var logger: ChannelLogger
override fun onCreate() {
super.onCreate()
logger = ChannelLogger()
executor = RequestExecutor(logger)
}
override fun onBind(intent: Intent): IBinder = binder
}
Binder 方法运行在线程池线程。
不要在方法里触碰主线程 UI。
不要执行无限时长工作。
不要持有 Activity 引用。
服务初始化要可重复。
请求执行器
class RequestExecutor(
private val logger: ChannelLogger,
) {
private val lock = Any()
private val recent = LinkedHashMap<String, ChannelReply>()
fun execute(request: ChannelRequest): ChannelReply {
synchronized(lock) {
recent[request.requestId]?.let { return it }
}
val reply = runCommand(request)
synchronized(lock) {
recent[request.requestId] = reply
while (recent.size > 64) recent.remove(recent.keys.first())
}
return reply
}
private fun runCommand(request: ChannelRequest): ChannelReply {
logger.info("execute request=${request.requestId}")
return when (request.command) {
"status" -> ChannelReply(request.requestId, 0, "ready", false)
else -> ChannelReply(request.requestId, 64, "unknown command", false)
}
}
}
请求缓存解决重复提交。
缓存大小必须有限制。
缓存键只使用请求编号。
业务结果要考虑过期时间。
幂等性
查询通常是幂等的。
设置操作不一定幂等。
启动操作可以设计成幂等。
删除操作通常可以重复执行。
追加操作通常不可直接重试。
接口文档要写出这一点。
客户端只有在知道幂等时才重试。
错误分类
参数错误使用 64。
权限不足使用 77。
服务未就绪使用 69。
暂时不可用使用 75。
未知内部错误使用 70。
客户端断开不需要返回业务码。
错误码表应该集中维护。
不要在各个方法里随意定义数字。
客户端连接
class ChannelClient(
private val context: Context,
) {
private var channel: ISystemChannel? = null
private var connection: ServiceConnection? = null
fun connect(onReady: () -> Unit) {
val intent = Intent(context, ChannelService::class.java)
val callback = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName, service: IBinder) {
channel = ISystemChannel.Stub.asInterface(service)
onReady()
}
override fun onServiceDisconnected(name: ComponentName) {
channel = null
}
}
connection = callback
context.bindService(intent, callback, Context.BIND_AUTO_CREATE)
}
}
绑定成功不等于服务可用。
仍然要执行版本检查。
仍然要处理断开回调。
连接对象由客户端持有。
不要把代理对象放进全局单例。
DeathRecipient
private val deathRecipient = IBinder.DeathRecipient {
channel = null
reconnectSignal.tryEmit(Unit)
}
远程进程死亡时代理会失效。
客户端应释放旧引用。
客户端可以安排退避重连。
重连不能无限快速循环。
重连次数应该进入日志。
退避策略
第一次重连等待 200 毫秒。
第二次重连等待 500 毫秒。
第三次重连等待 1 秒。
后续等待可以指数增长。
上限建议设置为 30 秒。
收到用户操作时可以立即尝试。
切到后台后减少重连频率。
进程恢复时重新验证版本。
线程模型
Binder 线程池会并行进入方法。
共享状态必须明确所有权。
只读状态可以使用原子变量。
复杂状态使用互斥锁。
耗时任务交给专用执行器。
不要在锁内进行 Binder 回调。
不要在锁内访问磁盘。
不要在锁内等待网络。
超时
Binder 调用本身可以阻塞。
客户端需要在业务层设置超时。
超时后不能假设服务端没有执行。
请求编号用于查询执行结果。
服务端可以提供取消方法。
取消也必须定义竞态语义。
超时日志应包含耗时。
回调接口
如果服务需要推送事件,可以增加回调。
interface IChannelCallback {
oneway void onStateChanged(int state, String message);
}
interface ISystemChannel {
void registerCallback(IChannelCallback callback);
void unregisterCallback(IChannelCallback callback);
}
oneway 不等待服务端执行完成。
回调不能直接返回结果。
回调必须处理客户端死亡。
回调列表必须去重。
回调发送失败要清理引用。
回调并发
private val callbacks = RemoteCallbackList<IChannelCallback>()
fun publish(state: Int, message: String) {
val count = callbacks.beginBroadcast()
try {
for (index in 0 until count) {
callbacks.getBroadcastItem(index).onStateChanged(state, message)
}
} finally {
callbacks.finishBroadcast()
}
}
使用系统提供的回调容器。
不要手写强引用列表。
回调方法应该尽量短。
大量事件需要合并或采样。
事务大小
Binder 事务有大小限制。
不要传输大文件。
不要把完整日志塞进返回值。
大数据改用文件描述符。
或者返回临时文件路径。
路径访问仍需要权限控制。
参数列表也要限制数量。
文件描述符
ParcelFileDescriptor 可以传输文件描述符。
服务端要负责关闭自己的副本。
客户端也要负责关闭自己的副本。
文件生命周期要写入协议。
匿名管道适合短生命周期数据。
本地 socket 适合流式数据。
Binder 只负责建立受控入口。
身份检查
服务端可以读取调用 UID。
val callingUid = Binder.getCallingUid()
UID 只能说明进程身份。
包名需要通过 PackageManager 查询。
签名权限可以限制调用方。
不要只检查客户端传入的字符串。
服务端必须自己做授权判断。
清理调用身份
执行异步任务前要保存必要身份。
Binder 调用返回后调用身份可能恢复。
不要把 Binder.getCallingUid() 延迟到后台线程读取。
把 UID 作为不可变参数传递。
异步任务再次执行权限检查。
后台任务不能默认继承调用者身份。
签名权限
<permission
android:name="com.koko.channel.permission.USE_CHANNEL"
android:protectionLevel="signature" />
服务声明权限。
客户端声明使用权限。
签名权限要求相同签名证书。
调试包和发布包证书可能不同。
不要把 debug 证书当成生产证书。
Manifest 服务
<service
android:name=".ChannelService"
android:exported="false"
android:permission="com.koko.channel.permission.USE_CHANNEL" />
同应用内绑定可以设为 false。
跨应用绑定需要 exported true。
跨应用时必须加强权限校验。
不要为了方便直接开放服务。
生命周期
onCreate 只做一次初始化。
onBind 返回 Binder。
onUnbind 可以记录连接变化。
onDestroy 释放执行器。
服务停止后代理全部失效。
客户端需要回到未连接状态。
不要在 onDestroy 里等待无限任务。
状态机
sealed interface ChannelState {
data object Idle : ChannelState
data object Binding : ChannelState
data object Ready : ChannelState
data class Failed(val code: Int) : ChannelState
data object Closed : ChannelState
}
状态比多个布尔变量更清楚。
状态迁移集中到一个对象。
非法迁移应该被记录。
界面只观察状态流。
服务端和客户端都需要自己的状态机。
测试接口
class FakeSystemChannel : ISystemChannel.Stub() {
override fun execute(request: ChannelRequest): ChannelReply {
return ChannelReply(request.requestId, 0, "fake", false)
}
override fun getVersion(): Int = 1
override fun ping() = Unit
}
先用 fake 验证客户端。
再接入真实 Binder。
这样可以把 UI 和 IPC 解耦。
测试断开
启动服务。
建立客户端连接。
调用 ping。
结束服务进程。
确认客户端收到断开。
确认代理被清空。
确认重连退避生效。
确认界面显示等待状态。
测试重复请求
发送相同 requestId。
确认服务不会重复执行副作用。
发送不同 requestId。
确认两次请求独立记录。
重启服务后再次发送。
确认缓存语义符合文档。
不要依赖内存缓存保证永久幂等。
测试大参数
构造接近限制的请求。
确认请求成功。
再增加一倍数据。
确认服务返回参数错误。
确认服务进程没有崩溃。
确认日志记录请求大小。
不要记录完整内容。
测试慢请求
注入一个 3 秒任务。
同时发送状态请求。
观察状态请求是否被阻塞。
如果被阻塞,拆分执行器。
为慢任务提供异步接口。
为异步任务提供查询接口。
日志格式
日志应该包含方向。
日志应该包含 requestId。
日志应该包含方法名。
日志应该包含耗时。
日志应该包含结果码。
日志应该包含调用 UID。
日志不应包含完整 payload。
版本兼容
旧客户端连接新服务。
新客户端连接旧服务。
缺少新方法时要有降级。
新增字段要提供默认值。
枚举新增值不能让旧客户端崩溃。
服务端不要假设客户端一定升级。
发布清单
确认权限声明。
确认服务 exported 属性。
确认调用方签名。
确认事务大小。
确认超时策略。
确认断开处理。
确认日志脱敏。
确认协议版本。
确认错误码文档。
常见误区
把 Binder 当作共享内存。
把远程对象当作永不失效。
把 RemoteException 当作业务错误。
把 shell UID 当作 system UID。
把成功返回当作操作完成。
把重试当作万能方案。
把线程安全交给调用方。
把大文件塞进 Parcel。
一条最小验证链
编译 AIDL。
安装 debug APK。
启动客户端。
绑定服务。
检查协议版本。
发送 status 请求。
记录响应耗时。
停止服务。
确认断开事件。
重新绑定。
确认服务恢复。
结语
稳定的 Binder 接口不是方法越少越好。
而是每个方法的语义都可预测。
线程模型要写清楚。
权限边界要写清楚。
错误是否可重试要写清楚。
服务重启后的行为要写清楚。
先设计契约,再写实现。
先验证失败路径,再追求更多功能。