Android hardware decoding: MediaCodec and the Surface lifecycle

Chapter 3: Android·about 1118 words

The JNI boundary: grab all classes and methods once at init, leave one entry for reverse callbacks

On Android there is no getting around JNI, and how the boundary is drawn decides how easy the rest is to write. The approach here: at init the C++ side grabs every Java class and method it uses in one go and stores them in a few structs; after that, opening a Surface or texture is a lookup and a call, with no scattered FindClass.

What it grabs falls into three groups: the SurfaceTexture set (init, bind and unbind the GL context, get the transform matrix, release, and so on), an in-house wrapper object (used to receive the frame-available callback), and the Surface constructor. The third group is the key one — the Java-side Surface object has to be created from the SurfaceTexture, and what the C++ side gets is the native window converted from it.

The reverse direction is left one door: the wrapper object holds a native pointer; when the Java side receives the frame-available callback it passes the pointer back to C++, which converts it into an internal callback. On release the Java side locks and clears the pointer, so a callback arriving after release does not hit a null pointer.

Opening a decoder: create, configure, start

The three stages of opening a decoder, and the step that dispatches parameter sets by codec

The first stage is creation. The codec type maps to the platform MIME string, then the decoder is created by type. VP9 and AV1 take an extra step: on these devices the platform's software implementation often shadows these two codecs, so the candidate names are collected first, names carrying the platform software-implementation prefix are excluded, and only when no hardware implementation is left does it fall back to software decoding.

The second stage is configuration, three things: width and height come from the parameters passed down from above; the color format is requested according to the type above (NV12 or the 10-bit option, depending on what the source and the display chain need); the parameter sets are dispatched into it by codec type.

The third stage is start. After start there is a lane guard: some drivers turn on you after start and push the decoder into the “released” state; this failure is reported as an open failure so the layer above can switch lanes, not treated as an ordinary decode error.

Convert the parameter sets once first: length prefix to start codes, local copy only

The parameter sets handed down from above are packed with length prefixes, while this kind of decoder wants start codes, so they must be converted first.

There is a subtlety here: the conversion only touches the local copy. The same parameter sets are also left for the next lane — if they were changed in place, the FFmpeg path would later receive a format it does not want. After hitting that pit, copy and original are kept separate.

The dispatch rules differ by codec: H264 splits SPS and PPS into two slots, and if either is missing it reports no configuration; H265 has three slots; AV1's parameter set is itself a configuration record and is passed as is; VP9 has no out-of-band parameter sets, so only the codec type needs to be set.

The two sources of a Surface

The two sources of a Surface, and the reset chain when it goes invalid

The decoding side builds one chain itself: start an EGL context, create a SurfaceTexture and give it an external texture, then convert it into a native window and hand it to the decoder. After that, each buffer the decoder produces updates that external texture with the frame.

The Surface on the presentation side is not managed by the decoder; the host supplies it: the Java-side view hands a Surface down, the C++ side converts it into a native window, and it is attached to the render object.

These two sources decide the frame form: it comes down to whether the native window is null — with a window, frames go the external-texture way and are handed out as GPU frames; without one (running in a console with no JNI environment, say), they fall to the byte-buffer path and are handed out as CPU frames. This fork is fixed at open time.

Invalidation and rebuild do not get separate paths. When the render object's Surface pointer changes, a reset flag is set; the render thread reads it at the rebuild entry, tearing down when it should and reopening when it should. Screen changes, size changes and a new context all go through this one chain. The chain has a premise: the flag is set by the thread that sets the Surface and consumed by the render thread.

On seek this path flushes in place, without rebuilding the decoder

This is the biggest difference between this implementation and other platforms.

When a flush comes down from above, it first checks which decoder lane this is: if it is the Android implementation, it sets a flag and handles it in place, without rebuilding the decoder; with another hardware-decode implementation (the desktop D3D11 path, say), the decode context is rebuilt wholesale.

The rationale lies with who holds the frames: this kind of decoder keeps its instance plus its Surface and context, the path needs no teardown and rebuild, and old frames can stay on screen; on the other path, flushing the buffers does not clear the decoder's internal reordering information, and the next frame is wrong without a rebuild.

Two paths on seek: in-place flush here, wholesale rebuild there

The in-place flush has three steps: clear the supplementary feed queue accumulated across calls (that residue would be fed in before the landing point and push the picture to the next keyframe), call the decoder's own flush, and finally reopen the lane-guard window — only the window is reopened here; the tier already judged “dead” is left alone.

Frame output and recycling

The GPU path does not release the output buffer when the frame comes out: the frame object carries a queue index, and the buffer is returned after the render side is done with it. On return, if this frame has been rendered, the external texture is updated at the same time.

The CPU path takes the data on the spot: it cuts out the planes by the output buffer's stride and slice height; the NV12 option is a semi-planar two-plane layout, the others are three-plane; the buffer goes back immediately after the copy.

One more arrangement: the EGL context on the decode thread is the primary, the one on the render side is the secondary. If the sharing relationship were reversed, the render side would not get the external texture the decoder produced.

The avox engine is open source on GitHub: github.com/xxxzhou/avox; the Android JNI bridge, the parameter-set dispatch and the in-place flush are all in there.