UI smoothness: distinct isPlaying flow + allocation-free grid draw (v0.5.1)
Two changes that cut playback-time UI jank (measured on-device): - Expose a deduped isPlaying StateFlow (maps the transport flow through distinctUntilChanged) and collect THAT in the transport toolbar, so the PLAY/PAUSE button recomposes on play↔stop instead of ~12×/second as the playhead advances (the full transport emits every line). - Precompute the pattern grid's fixed-width text (gutter hex, note names, velocity/channel) into class-load lookup tables. The grid redraws on every playhead line and scroll pixel, and the old toString/padStart formatting allocated a String per cell per frame — steady draw-phase garbage. The draw is now allocation-free (also improves GlyphCache hit rate via stable string instances). gfxinfo over ~12s of playback on the busy track (moto g84): janky frames 47.2% → 32.0%; 99th-pct frame 129ms → 53ms; missed vsync 40 → 10; 50th 18ms → 14ms. No engine/audio change. Remaining jank is the auto-follow scroll redrawing the whole grid each line (everything shifts), not addressed here. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -22,8 +22,8 @@ android {
|
|||||||
// (android.media.midi) and AAudio low-latency audio we rely on.
|
// (android.media.midi) and AAudio low-latency audio we rely on.
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 15
|
versionCode = 16
|
||||||
versionName = "0.5.0"
|
versionName = "0.5.1"
|
||||||
|
|
||||||
// We provide our own instrumentation runner if/when tests are added.
|
// We provide our own instrumentation runner if/when tests are added.
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ import space.rcmd.android.sizzle.model.Pattern
|
|||||||
import space.rcmd.android.sizzle.model.Pitch
|
import space.rcmd.android.sizzle.model.Pitch
|
||||||
import space.rcmd.android.sizzle.model.Project
|
import space.rcmd.android.sizzle.model.Project
|
||||||
import space.rcmd.android.sizzle.model.TimeSignature
|
import space.rcmd.android.sizzle.model.TimeSignature
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -107,6 +112,15 @@ class AppViewModel(
|
|||||||
/** Live transport snapshot from the audio thread (drives the playhead). */
|
/** Live transport snapshot from the audio thread (drives the playhead). */
|
||||||
val transport get() = engine.transport
|
val transport get() = engine.transport
|
||||||
|
|
||||||
|
/** Just the play/stop state, deduped so it emits ONLY on a play↔stop change — not
|
||||||
|
* on every playhead line like [transport] does. Composables that only care whether
|
||||||
|
* playback is running (e.g. the PLAY/PAUSE button) collect this instead of the full
|
||||||
|
* transport, so they don't recompose ~12×/second during playback. */
|
||||||
|
val isPlaying: StateFlow<Boolean> = engine.transport
|
||||||
|
.map { it.isPlaying }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.stateIn(viewModelScope, SharingStarted.Eagerly, engine.transport.value.isPlaying)
|
||||||
|
|
||||||
// ----- Binding-learn state (Settings tab) -----
|
// ----- Binding-learn state (Settings tab) -----
|
||||||
/** The action id currently waiting to be bound, or null. */
|
/** The action id currently waiting to be bound, or null. */
|
||||||
var learnTarget by mutableStateOf<String?>(null); private set
|
var learnTarget by mutableStateOf<String?>(null); private set
|
||||||
|
|||||||
@@ -191,9 +191,8 @@ fun PatternGrid(vm: AppViewModel, modifier: Modifier = Modifier) {
|
|||||||
drawRect(rowColor, Offset(0f, y), Size(widthPx, rowPx))
|
drawRect(rowColor, Offset(0f, y), Size(widthPx, rowPx))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gutter line number (hex).
|
// Gutter line number (hex), from the precomputed table.
|
||||||
drawCentered(glyphs, line.toString(16).uppercase().padStart(2, '0'),
|
drawCentered(glyphs, lineText(line), styleGutter, 2f, y, gutterPx, rowPx)
|
||||||
styleGutter, 2f, y, gutterPx, rowPx)
|
|
||||||
|
|
||||||
for (t in 0 until Pattern.TRACK_COUNT) {
|
for (t in 0 until Pattern.TRACK_COUNT) {
|
||||||
val cell = pattern.cell(t, line)
|
val cell = pattern.cell(t, line)
|
||||||
@@ -243,15 +242,27 @@ private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawCentered(
|
|||||||
drawText(layout, topLeft = Offset(x, y + (cellHeight - layout.size.height) / 2f))
|
drawText(layout, topLeft = Offset(x, y + (cellHeight - layout.size.height) / 2f))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Precomputed fixed-width text tables. The grid draws on every playhead line and
|
||||||
|
// every scroll pixel, so formatting these on the fly (toString/padStart) allocated a
|
||||||
|
// fresh String per cell per frame — steady draw-phase garbage that drove GC. Building
|
||||||
|
// each once at class-load makes the whole draw allocation-free.
|
||||||
|
private val HEX2 = Array(256) { it.toString(16).uppercase().padStart(2, '0') } // 00..FF
|
||||||
|
private val NOTE_NAMES = Array(128) { Pitch.name(it) } // MIDI 0..127
|
||||||
|
private val CHAN2 = Array(100) { it.toString().padStart(2, '0') } // 00..99
|
||||||
|
|
||||||
|
/** Gutter line number (hex), from the [HEX2] table (falls back for out-of-range). */
|
||||||
|
private fun lineText(line: Int): String =
|
||||||
|
if (line in HEX2.indices) HEX2[line] else line.toString(16).uppercase().padStart(2, '0')
|
||||||
|
|
||||||
// ---- cell -> text helpers (the tracker's fixed-width formatting) ----
|
// ---- cell -> text helpers (the tracker's fixed-width formatting) ----
|
||||||
private fun noteText(cell: Cell): String = when {
|
private fun noteText(cell: Cell): String = when {
|
||||||
cell.isEmpty -> "···"
|
cell.isEmpty -> "···"
|
||||||
cell.isNoteOff -> "==="
|
cell.isNoteOff -> "==="
|
||||||
else -> Pitch.name(cell.note)
|
else -> NOTE_NAMES.getOrElse(cell.note) { Pitch.name(cell.note) }
|
||||||
}
|
}
|
||||||
// A note-off ("===") has no velocity/channel of its own — it's tied to its track —
|
// A note-off ("===") has no velocity/channel of its own — it's tied to its track —
|
||||||
// so those columns read ".." just like an empty cell.
|
// so those columns read ".." just like an empty cell.
|
||||||
private fun velText(cell: Cell): String =
|
private fun velText(cell: Cell): String =
|
||||||
if (cell.isEmpty || cell.isNoteOff) ".." else cell.velocity.toString(16).uppercase().padStart(2, '0')
|
if (cell.isEmpty || cell.isNoteOff) ".." else HEX2.getOrElse(cell.velocity) { cell.velocity.toString(16).uppercase().padStart(2, '0') }
|
||||||
private fun chanText(cell: Cell): String =
|
private fun chanText(cell: Cell): String =
|
||||||
if (cell.isEmpty || cell.isNoteOff) ".." else cell.channel.toString().padStart(2, '0')
|
if (cell.isEmpty || cell.isNoteOff) ".." else CHAN2.getOrElse(cell.channel) { cell.channel.toString().padStart(2, '0') }
|
||||||
|
|||||||
@@ -138,13 +138,15 @@ private fun TransportToolbar(vm: AppViewModel) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun TransportButtons(vm: AppViewModel) {
|
private fun TransportButtons(vm: AppViewModel) {
|
||||||
val c = LocalRetro.current
|
val c = LocalRetro.current
|
||||||
val transport by vm.transport.collectAsState()
|
// Only the play/stop state, not the full transport — so this toolbar recomposes on
|
||||||
|
// play↔stop, NOT on every playhead line (which drove needless per-line recomposition).
|
||||||
|
val isPlaying by vm.isPlaying.collectAsState()
|
||||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // refresh the tempo readout
|
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // refresh the tempo readout
|
||||||
val project = vm.project
|
val project = vm.project
|
||||||
RetroButton("⧉ STOP", onClick = vm::stop)
|
RetroButton("⧉ STOP", onClick = vm::stop)
|
||||||
// Fixed width so the button doesn't resize as the label toggles.
|
// Fixed width so the button doesn't resize as the label toggles.
|
||||||
RetroButton(if (transport.isPlaying) "❚❚ PAUSE" else "▶ PLAY",
|
RetroButton(if (isPlaying) "❚❚ PAUSE" else "▶ PLAY",
|
||||||
active = transport.isPlaying, width = 112.dp, onClick = vm::playPause)
|
active = isPlaying, width = 112.dp, onClick = vm::playPause)
|
||||||
// Tempo nudger: tap -/+ around a fixed-width readout.
|
// Tempo nudger: tap -/+ around a fixed-width readout.
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
RepeatButton("-", onStep = { vm.setTempo(project.tempoBpm - 1) })
|
RepeatButton("-", onStep = { vm.setTempo(project.tempoBpm - 1) })
|
||||||
|
|||||||
Reference in New Issue
Block a user