diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 547f608..a922e3b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -10,7 +10,7 @@ android { defaultConfig { applicationId = "dev.dtrentin.chart.demo" - minSdk = 26 + minSdk = 24 targetSdk = 35 versionCode = 1 versionName = "0.1.0" @@ -23,6 +23,20 @@ android { buildFeatures { compose = true } + buildTypes { + release { + isMinifyEnabled = true // R8 — the perf lever + isShrinkResources = true // strip unused resources + isDebuggable = false // let ART fully optimize (perf test) + // debug key so the release apk installs on a dev device (profiling build, not store build) + signingConfig = signingConfigs.getByName("debug") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + sourceSets["main"].kotlin.srcDirs("src/main/kotlin") } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..5aae6e1 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,12 @@ +# Release R8 rules for :app (profiling build). +# +# Intentionally minimal. Consumer rules ship with the libraries: +# - Jetpack Compose (androidx.compose.*) — bundled consumer R8 rules +# - kotlinx-coroutines — bundled consumer R8 rules (keeps ServiceLoader/volatile) +# - AGP auto-keeps the manifest entry point (.MainActivity) +# +# No kotlinx.serialization in this app (chart-realtime does not apply the +# serialization plugin), so no @Serializer keep rules are required. +# +# App-specific keeps are added below ONLY when a build/run failure proves them +# necessary. Do NOT blanket-keep. diff --git a/app/src/main/kotlin/dev/dtrentin/chart/demo/SignalGenerator.kt b/app/src/main/kotlin/dev/dtrentin/chart/demo/SignalGenerator.kt index 44d60a6..b3e1068 100644 --- a/app/src/main/kotlin/dev/dtrentin/chart/demo/SignalGenerator.kt +++ b/app/src/main/kotlin/dev/dtrentin/chart/demo/SignalGenerator.kt @@ -1,9 +1,11 @@ package dev.dtrentin.chart.demo import dev.dtrentin.chart.RealtimeChartState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.isActive -import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.withContext /** * Synthetic multi-signal producer. Single coroutine pushes one sample per active signal @@ -34,18 +36,23 @@ suspend fun CoroutineScope.runSignalGenerator( val tickIntervalMs = (1_000L / sampleRateHz).coerceAtLeast(1L) val dt = 1f / sampleRateHz.toFloat() - val startMs = System.currentTimeMillis() - var t = 0f - while (isActive) { - val ts = startMs + (t * 1000f).toLong() - for (i in signalNames.indices) { - // Phase-offset each signal so visually they don't overlap when same waveform. - val v = waveforms[i].sample(t, phaseOffset = i * 0.25f) - state.push(signalNames[i], ts, v) - onPush() + // Push work off Main: sample generation + state.push run on Dispatchers.Default so they + // don't contend with Compose draw on the Main dispatcher. withContext inherits the caller's + // Job → cancelling the caller (e.g. leaving the composable) cancels this loop (no leak). + withContext(Dispatchers.Default) { + val startMs = System.currentTimeMillis() + var t = 0f + while (isActive) { + val ts = startMs + (t * 1000f).toLong() + for (i in signalNames.indices) { + // Phase-offset each signal so visually they don't overlap when same waveform. + val v = waveforms[i].sample(t, phaseOffset = i * 0.25f) + state.push(signalNames[i], ts, v) + onPush() + } + t += dt + delay(tickIntervalMs) } - t += dt - delay(tickIntervalMs) } } diff --git a/app/src/main/kotlin/dev/dtrentin/chart/demo/StressTestScreen.kt b/app/src/main/kotlin/dev/dtrentin/chart/demo/StressTestScreen.kt index 2f31b20..ae9816a 100644 --- a/app/src/main/kotlin/dev/dtrentin/chart/demo/StressTestScreen.kt +++ b/app/src/main/kotlin/dev/dtrentin/chart/demo/StressTestScreen.kt @@ -26,7 +26,7 @@ import dev.dtrentin.chart.RealtimeChart import dev.dtrentin.chart.RealtimeChartState import dev.dtrentin.chart.interaction.InteractionConfig import dev.dtrentin.chart.interaction.rememberChartInteractionState -import dev.dtrentin.chart.lod.MinMaxLodStrategy +import dev.dtrentin.chart.lod.MinMaxLttbLodStrategy import dev.dtrentin.chart.model.AxisConfig import dev.dtrentin.chart.model.AxisLabelMode import dev.dtrentin.chart.model.ChartConfig @@ -39,8 +39,10 @@ import dev.dtrentin.chart.render.DecimalAxisFormatter import dev.dtrentin.chart.render.TimeAxisFormatter import kotlin.math.PI import kotlin.math.sin +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext private const val STRESS_SIGNAL_COUNT = 8 private const val STRESS_SAMPLE_HZ = 200 @@ -58,8 +60,9 @@ private val STRESS_COLORS = listOf( /** * Stress test: 8 simultaneous signals @ 200 Hz each (1600 pushes/sec total). Uses - * MinMax LoD (preserves peaks under heavy throughput). 10 s window — 16k samples - * visible at once. + * MinMaxLTTB LoD (preserves min/max envelope, output capped at pixelWidth). 10 s window — + * 16k samples visible at once. Producer runs on [Dispatchers.Default] to keep sample + * generation off the Main dispatcher (away from Compose draw). */ @Composable fun StressTestScreen() { @@ -81,7 +84,7 @@ fun StressTestScreen() { ), render = RenderConfig( theme = chartTheme, - lodStrategy = MinMaxLodStrategy(), + lodStrategy = MinMaxLttbLodStrategy(), ), ), ) @@ -96,23 +99,28 @@ fun StressTestScreen() { SignalConfig(color = STRESS_COLORS[i], strokeWidth = 1.5f), ) } - val tickIntervalMs = (1_000L / STRESS_SAMPLE_HZ).coerceAtLeast(1L) - val startMs = System.currentTimeMillis() - var n = 0L - val twoPi = 2f * PI.toFloat() - while (isActive) { - val ts = startMs + (n * 1000L) / STRESS_SAMPLE_HZ - val t = n.toFloat() / STRESS_SAMPLE_HZ - for (i in 0 until STRESS_SIGNAL_COUNT) { - val freq = 0.3f + i * 0.2f - val phase = i * 0.4f - val amp = 1f + (i % 3) * 0.3f - val v = amp * sin(twoPi * freq * (t + phase)) - state.push("S${i + 1}", ts, v) - pushCounter.increment() + // Push work off Main: generation + state.push run on Dispatchers.Default so they + // don't contend with Compose draw on the Main dispatcher. withContext inherits the + // effect's Job → leaving the composable cancels this loop (no leak). + withContext(Dispatchers.Default) { + val tickIntervalMs = (1_000L / STRESS_SAMPLE_HZ).coerceAtLeast(1L) + val startMs = System.currentTimeMillis() + var n = 0L + val twoPi = 2f * PI.toFloat() + while (isActive) { + val ts = startMs + (n * 1000L) / STRESS_SAMPLE_HZ + val t = n.toFloat() / STRESS_SAMPLE_HZ + for (i in 0 until STRESS_SIGNAL_COUNT) { + val freq = 0.3f + i * 0.2f + val phase = i * 0.4f + val amp = 1f + (i % 3) * 0.3f + val v = amp * sin(twoPi * freq * (t + phase)) + state.push("S${i + 1}", ts, v) + pushCounter.increment() + } + n++ + delay(tickIntervalMs) } - n++ - delay(tickIntervalMs) } }