
I used to treat Binder like magic. I could wire up an AIDL file, hit “Build,” and—somehow—two processes were talking. The moment something deadlocked or a DeadObjectException popped up, I felt helpless. So I forced myself to understand Binder the hard way: I built a minimal IPC service without relying on the AIDL generator. This post is that journey—first-person, practical, and focused on the moving parts you actually touch.
I wanted three things:
A mental model of Binder: what runs where, who holds what, and why Parcel exists.
A minimal working example without AIDL: just IBinder, Binder, Parcel, and a Service.
Operational habits for threading, lifecycle, and debugging so I don’t brick the app with subtle IPC bugs.
If you already use AIDL every day, this will still help you understand the code it generates.
Binder is a kernel-backed RPC mechanism. In your process (client), you never get a real object from the remote process (server). You get a proxy that looks like the interface. When you call a method, the proxy marshals arguments into a Parcel, performs a transact(code, data, reply, flags), the kernel hops it over to the server thread (in the service’s Binder thread pool), the server unmarshals the Parcel, does the work, marshals the result into another Parcel, and the kernel wakes your client thread with the reply. That’s it.
We’ll build a tiny “compute” service with one call, add(a, b) → Int, using Kotlin and the raw Binder API:
Define a contract (transaction codes + marshalling).
Implement a local Stub (server) that extends Binder and overrides onTransact.
Implement a Proxy (client) that holds IBinder and calls transact.
Bind to a foreground service and try it.
I’m showing Kotlin for app code, but the Binder pieces map 1:1 to Java.
A foreground Service (so the process stays up during tests).
android:exported="false" for internal IPC; flip as needed for cross-app.
Target API 29+ (concepts unchanged for newer).
Instead of .aidl, I hand-define an interface plus opcodes and an asInterface helper.
interface ICompute : android.os.IInterface {
fun add(a: Int, b: Int): Int
abstract class Stub : android.os.Binder(), ICompute {
companion object {
private const val DESCRIPTOR = "com.example.ipc.ICompute"
private const val TRANSACTION_add = (IBinder.FIRST_CALL_TRANSACTION + 0)
fun asInterface(obj: IBinder?): ICompute? {
if (obj == null) return null
val iin = obj.queryLocalInterface(DESCRIPTOR)
return if (iin is ICompute) {
iin // same process → return the real object
} else {
Proxy(obj) // remote process → return proxy
}
}
}
init { attachInterface(this, DESCRIPTOR) }
override fun asBinder(): IBinder = this
override fun onTransact(code: Int, data: Parcel, reply: Parcel, flags: Int): Boolean {
return when (code) {
INTERFACE_TRANSACTION -> { reply.writeString(DESCRIPTOR); true }
TRANSACTION_add -> {
data.enforceInterface(DESCRIPTOR)
val a = data.readInt()
val b = data.readInt()
val result = add(a, b) // calls the real implementation (server side)
reply.writeNoException()
reply.writeInt(result)
true
}
else -> super.onTransact(code, data, reply, flags)
}
}
private class Proxy(private val remote: IBinder) : ICompute {
override fun asBinder(): IBinder = remote
override fun add(a: Int, b: Int): Int {
val data = Parcel.obtain()
val reply = Parcel.obtain()
try {
data.writeInterfaceToken(DESCRIPTOR)
data.writeInt(a)
data.writeInt(b)
remote.transact(TRANSACTION_add, data, reply, 0)
reply.readException()
return reply.readInt()
} finally {
reply.recycle()
data.recycle()
}
}
}
}
}
What’s going on:
Stub is the local endpoint that lives in the server process.
Proxy lives in the client process and only knows how to marshal/unmarshal.
asInterface returns either the real object (same process) or the proxy (cross-process).
This mirrors exactly what AIDL generates
class ComputeService : Service() {
private val impl = object : ICompute.Stub() {
override fun add(a: Int, b: Int): Int {
// This code runs on a Binder thread from the service's pool.
// Avoid long/blocking work here; hand off if needed.
return a + b
}
}
override fun onBind(intent: Intent?): IBinder = impl
}
Notes that saved me later
Work inside add() is on a Binder pool thread, not the main thread. Never block with disk/network here.
If you must do work that can block, hand off to your own executor and reply later using FLAG_ONEWAY (fire-and-forget) patterns or design an async model.
class HomeActivity : AppCompatActivity() {
private var api: ICompute? = null
private val conn = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
api = ICompute.Stub.asInterface(binder)
lifecycleScope.launch(Dispatchers.IO) {
try {
val sum = api?.add(21, 21)
withContext(Dispatchers.Main) {
findViewById<TextView>(R.id.result).text = "21+21=$sum"
}
} catch (e: RemoteException) {
Log.e("BinderDemo", "Remote call failed", e)
}
}
}
override fun onServiceDisconnected(name: ComponentName?) { api = null }
}
override fun onStart() {
super.onStart()
val intent = Intent(this, ComputeService::class.java)
bindService(intent, conn, Context.BIND_AUTO_CREATE)
}
override fun onStop() {
unbindService(conn)
super.onStop()
}
}
What to notice
We jump to Dispatchers.IO to avoid making Binder calls on the main thread.
RemoteException is part of the contract; handle it.
Client call site: whichever thread you call from will block until the reply arrives (unless it’s oneway). Don’t call from main if the server might take time.
Server handler (onTransact/add) runs on the service’s Binder thread pool (size ≈ CPUs, configurable at the native level). You’re responsible for not blocking those threads forever; overload them and the whole IPC system looks “frozen.”
Rule of thumb I follow now
Do minimal validation + quick work inside the Binder method, then hand off heavier work to your own executor and reply (or design async).
Always writeInterfaceToken/enforceInterface with a stable DESCRIPTOR.
Keep marshalling symmetric: write in one order, read in the same order.
Recycle Parcel in a finally block—memory leaks here are nasty and silent.
For custom objects, implement Parcelable correctly; prefer @Parcelize in Kotlin for sanity.
When the remote dies
Any call after death throws DeadObjectException (RemoteException).
Use IBinder.linkToDeath to get a callback when the remote process dies and re-bind.
What I actually do
Wrap the ICompute inside a small client class that:
Caches the interface.
Reconnects on death.
Exposes a suspend function that retries after rebind.
This turned a flaky demo into a stable component.
If you only talk within your app: android:exported="false".
If cross-app: android:exported="true" + permission checks in onBind() and/or caller verification inside onTransact (Binder.getCallingUid() / getCallingPid()).
Validate inputs at the Binder boundary like you would validate an HTTP request.
Batch small calls: Binder has per-call overhead. If you can coalesce, do it.
Avoid allocations in hot paths: reuse buffers, avoid boxing in Parcel.
Large payloads (bitmaps/blobs): prefer shared memory via ParcelFileDescriptor or HardwareBuffer rather than stuffing megabytes into a Parcel.
adb shell dumpsys activity services — see who’s bound.
adb shell service list — list registered system services (useful for learning).
StrictMode with custom policies around the call sites to catch main-thread IPC.
Toggle setenforce 0 on rooted test devices for low-level experiments (never in prod).
Log both transaction code and calling UID in onTransact when you’re chasing misroutes.
AIDL is just a code generator for exactly what we wrote by hand.
The proxy/stub mental model removes the “mystique.” It’s just parcels in, parcels out.
Most production issues are threading, lifecycle, or oversized payloads, not Binder itself.
Once you’re comfortable here, peeking at system services (e.g., ActivityManagerNative/Proxy patterns) suddenly makes sense.
If you’ve only ever used AIDL as a black box, try hand-rolling one service. It’s a weekend project that will pay off for years.
Avoid main-thread calls; use Dispatchers.IO or your executor.
Validate all inputs at the boundary.
Decide sync vs async (FLAG_ONEWAY) intentionally.
Handle RemoteException and DeadObjectException gracefully.
Recycle all Parcel objects in finally.
Keep payloads lean; switch to shared memory for big blobs.
Add simple logging of code + callingUid in onTransact during dev.
I’m ruitexcx, an Android engineer who enjoys system-level work—Binder, process boundaries, and the parts of the stack most users never see. I also spend a lot of time on app development and growth.
0
8
0