跳到正文
DEV Community· SoftwareDevs mvpfactory.io·· 4 小時前AI 評分40

將 Android 的 MediaPipe LLM Inference API 接駁至串流介面

Wiring Android's MediaPipe LLM Inference API to a Streaming UI

AI 導讀

這套 Android 實作以 MediaPipe LLM Inference API、Kotlin Flow 和有界 Channel,將裝置端 LLM token 串流接入 Jetpack Compose,並以背壓避免 token 遺失。以 80ms 時間窗口批次輸出,可將重組次數限制在每秒約 12 次,低於預算型裝置的安全上限。

正文
---
title: "Streaming LLM Tokens in Android with Jetpack Compose: Back-Pressure and the Frame Budget"
published: true
description: "Wire MediaPipe's LLM Inference API to Jetpack Compose using Kotlin Flow back-pressure, correct buffer sizing, and a 16ms recomposition budget that keeps mid-range devices jank-free at 20+ tokens/sec."
tags: kotlin, android, mobile, architecture
canonical_url: https://mvpfactory.co/blog/streaming-llm-tokens-android-compose
---

What You Will Build

By the end of this tutorial you will have a production-ready pattern for streaming on-device LLM token output into a Compose Text component — without dropped tokens, without jank on mid-range hardware, and without the recomposition storm that trips up most first implementations.

Here is the gotcha that will save you hours: MediaPipe does not give you a suspend function. It gives you a callback on a thread you do not own. The naive bridge will work fine on your Pixel 9 and silently drop frames on your users' Helio G88.


Prerequisites

  • Android project with MediaPipe LLM Inference API configured
  • Kotlin coroutines and Flow basics
  • Jetpack Compose set up in your module

Step 1 — Understand the Streaming Contract MediaPipe Gives You

MediaPipe exposes token output like this:

inferenceSession.addQueryChunk(prompt)
inferenceSession.generateAsync(
    { partialResult, done ->
        onToken(partialResult, done)
    }
)

Two constraints that matter:

  1. The callback fires on an internal MediaPipe thread — not main, not a dispatcher you control
  2. There is no back-pressure signal — the model pushes tokens as fast as it decodes them

Ignore these two constraints and you get frame drops that only appear under thermal throttling on mid-range hardware. That is exactly the device your users own.


Step 2 — Bridge the Callback to Kotlin Flow

The cleanest bridge is a Channel with a bounded buffer. Here is the minimal setup to get this working:

fun LlmInferenceSession.tokenFlow(prompt: String): Flow<String> =
    channelFlow {
        val buffer = Channel<String>(capacity = 64, onBufferOverflow = BufferOverflow.SUSPEND)

        addQueryChunk(prompt)
        generateAsync { partial, done ->
            runBlocking { buffer.send(partial) }
            if (done) buffer.close()
        }

        for (token in buffer) send(token)
    }.flowOn(Dispatchers.Default)

A capacity of 64 covers roughly 2–3 seconds of burst at 25 tok/sec before SUSPEND kicks in. runBlocking inside the callback is intentional — it blocks the producer when the consumer falls behind, without dropping tokens. The docs do not mention this, but this is the only safe primitive here.


Step 3 — Solve the Recomposition Budget Problem

Let me show you a pattern I use in every project. Compose does not batch state writes from collectAsState the same way it batches layout passes. Each emission can schedule an independent recomposition.

Device tier Vsync budget Max safe recompositions/sec Safe token batch window
Flagship (Snapdragon 8 Gen 3) 8.3ms (120Hz) ~60 16ms
Mid-range (Snapdragon 6 Gen 1) 16ms (60Hz) ~30 33ms
Budget (Helio G88) 16ms (60Hz) ~15 66ms

At 25 tokens/sec on a budget device, one recomposition per token misses frames. The fix is chunked emission with a time window:

fun Flow<String>.chunkByTime(windowMs: Long = 80L): Flow<String> =
    flow {
        val accumulator = StringBuilder()
        var lastEmit = System.currentTimeMillis()

        collect { token ->
            accumulator.append(token)
            val now = System.currentTimeMillis()
            if (now - lastEmit >= windowMs) {
                emit(accumulator.toString())
                accumulator.clear()
                lastEmit = now
            }
        }
        if (accumulator.isNotEmpty()) emit(accumulator.toString())
    }

An 80ms window caps recompositions at ~12/sec — safely below the budget-device ceiling.


Step 4 — Wire It Into Compose

@Composable
fun StreamingResponse(session: LlmInferenceSession, prompt: String) {
    val text by remember(prompt) {
        session.tokenFlow(prompt)
            .chunkByTime(windowMs = 80L)
            .runningReduce { acc, chunk -> acc + chunk }
    }.collectAsState(initial = "")

    Text(text = text, modifier = Modifier.fillMaxWidth())
}

runningReduce accumulates the full response string without holding a mutable StringBuilder in composition scope. remember(prompt) restarts the flow cleanly on new prompts, avoiding stale emissions.


Gotchas

Do not use MutableStateFlow to bridge the MediaPipe callback. Only a Channel with BufferOverflow.SUSPEND prevents dropped tokens from a native producer thread.

Tune to your minimum supported device, not your desk Pixel. 80ms is a safe default. Tighten to 33ms only if your floor is mid-range 60Hz and you have profiler data to back it up.

Before shipping, verify in Android Studio's Compose Layout Inspector: skipped frames should be zero during active streaming; recomposition count on Text should be ≤15/sec on your lowest-supported device.


Conclusion

The full pattern is three composable pieces: a Channel-based callback bridge, a time-windowed chunking operator, and a stateless composable that folds chunks with runningReduce. Each piece solves one specific constraint — thread ownership, back-pressure, and frame budget — and they stay decoupled enough to test independently.

Profile on your lowest-supported hardware before shipping. Flagship benchmarks will lie to you.

來源:DEV Community · dev.to