From 5f297f92334a693c6bda66f8b79d16e39e10d0f5 Mon Sep 17 00:00:00 2001 From: QIU Date: Wed, 12 Aug 2026 13:55:03 +0000 Subject: [PATCH] fix(cw): stop the waterfall crashing and cut APK size by 87MB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三个真机实测暴露的问题: 1. 闪退 (给权限后 3-4 秒必崩, 且注入日志抓不到堆栈) CwWaterfallState 用 mutableIntStateOf 记录重绘版本号, 却从音频采集线程 写入。Compose 快照状态只能在合成线程修改, 从后台线程写会在运行时崩溃 —— 崩在 Compose 内部, 所以业务类的日志注入抓不到。 改用 MutableStateFlow (本身线程安全), Canvas 侧 collectAsState 读取。 2. 同一状态的跨线程数据竞争 pushSamples 在采集线程写 rows/pending, snapshot() 在绘制线程读, 而 ArrayDeque 非线程安全 —— 并发 removeFirst()/toList() 会抛 ConcurrentModificationException 或 IndexOutOfBoundsException。 两侧统一加锁; FFT 计算放在锁外, 只有队列追加持锁。 3. APK 从 8.4MB 暴涨到 135MB onnxruntime-android 的 AAR 自带 4 个架构原生库: arm64 28M + armv7 20M + x86 33M + x86_64 34M = 115MB。上次提交移除 abiFilters 时把 x86 系列也 打包了进去 (仅模拟器需要)。 重新加上 abiFilters, 保留 arm64-v8a + armeabi-v7a 两个真机 ABI, 预计降至约 43MB。注意这与上次"恢复 64 位"不冲突: 那次删的是只留 armeabi-v7a 的限制, 这次是排除 x86 系列。 另外两处加固: - CwDeepDecoder 的模型加载从 init{} 移入惰性 ensureLoaded(): 加载会触发 ONNX Runtime 原生库装载, 失败时抛 UnsatisfiedLinkError; 在构造函数中抛出 会连带崩掉创建它的 composable, try-catch 也救不回来。移到首次使用时执行, 失败经 errorMessage 上报给 UI。 - ONNX 会话限制 intraOp 线程数为 (核数-1) 且上限 4, 给音频采集和 UI 留出 余量, 默认行为会铺满所有核心。 验证: ./gradlew :feature:cw:compileDebugKotlin :core:data:compileDebugKotlin => 通过 ./gradlew :core:domain:test => 79 个测试全绿 --- app/build.gradle.kts | 8 +++ .../look4sat/core/data/cw/CwDeepDecoder.kt | 44 ++++++++++-- .../look4sat/feature/cw/CwWaterfall.kt | 70 +++++++++++++------ 3 files changed, 92 insertions(+), 30 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fa29ee4f..b6e78cc4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,6 +11,14 @@ val keystoreProperties = Properties().apply { } android { + defaultConfig { + // ONNX Runtime 的 AAR 自带 4 个架构共 115MB 原生库(arm64 28M / armv7 20M / + // x86 33M / x86_64 34M)。x86 系列只有模拟器用得到, 全打包会让 APK 从 8MB + // 涨到 135MB。仅保留真机需要的两个 ABI。 + ndk { + abiFilters += listOf("arm64-v8a", "armeabi-v7a") + } + } androidResources { // 显式保留全部语言(防 shrinkResources 丢弃 in/id 印尼语配置); AGP 9 用 localeFilters localeFilters += listOf( diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/cw/CwDeepDecoder.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/cw/CwDeepDecoder.kt index 0631632c..d3134ca3 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/cw/CwDeepDecoder.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/cw/CwDeepDecoder.kt @@ -84,10 +84,27 @@ class CwDeepDecoder(context: Context) : ICwDecoder { private var inputName = "spectrogram" private var outputName = "log_probs" - init { + private val appContext = context.applicationContext + private var loadAttempted = false + + /** + * Loads metadata and the ONNX session on first use. + * + * Deliberately not done in `init`: loading pulls in ONNX Runtime's native + * library, and a failure there surfaces as [UnsatisfiedLinkError]. Thrown + * from a constructor it would take down the whole composable that created + * the decoder, so the work happens here where it can be reported through + * [errorMessage] instead. + * + * @return true when the session is ready to run. + */ + private fun ensureLoaded(): Boolean { + if (session != null) return true + if (loadAttempted) return false + loadAttempted = true try { val metadata = JSONObject( - context.assets.open(METADATA_ASSET).bufferedReader().use { it.readText() } + appContext.assets.open(METADATA_ASSET).bufferedReader().use { it.readText() } ) val charArray = metadata.getJSONArray("chars") chars = List(charArray.length()) { charArray.getString(it) } @@ -95,18 +112,31 @@ class CwDeepDecoder(context: Context) : ICwDecoder { inputName = metadata.getString("onnx_input_name") outputName = metadata.getString("onnx_output_name") - val modelBytes = context.assets.open(MODEL_ASSET).use { it.readBytes() } - environment = OrtEnvironment.getEnvironment() - session = environment?.createSession(modelBytes, OrtSession.SessionOptions()) + val modelBytes = appContext.assets.open(MODEL_ASSET).use { it.readBytes() } + val env = OrtEnvironment.getEnvironment() + environment = env + val options = OrtSession.SessionOptions().apply { + // Keep a core free for audio capture and the UI; the default + // would spread inference across every core on the device. + val threads = (Runtime.getRuntime().availableProcessors() - 1).coerceIn(1, 4) + setIntraOpNumThreads(threads) + } + session = env.createSession(modelBytes, options) Log.i(TAG, "DeepCW ready: ${modelBytes.size} bytes, ${chars.size} classes") + return true } catch (t: Throwable) { + // Catches UnsatisfiedLinkError (missing/mismatched .so) as well as + // asset and session failures. Log.e(TAG, "DeepCW model failed to load", t) - _errorMessage.value = "CW model failed to load: ${t.message ?: t.javaClass.simpleName}" + _errorMessage.value = + "CW model failed to load: ${t.message ?: t.javaClass.simpleName}" + return false } } override suspend fun processBuffer(samples: FloatArray, sampleRate: Int) { - if (session == null || samples.isEmpty()) return + if (samples.isEmpty()) return + if (!ensureLoaded()) return val resampled = CwDeepSpectrogram.resampleLinear( samples, sampleRate, CwDeepSpectrogram.SAMPLE_RATE diff --git a/feature/cw/src/main/java/com/rtbishop/look4sat/feature/cw/CwWaterfall.kt b/feature/cw/src/main/java/com/rtbishop/look4sat/feature/cw/CwWaterfall.kt index 5886e0e3..7fcc60d1 100644 --- a/feature/cw/src/main/java/com/rtbishop/look4sat/feature/cw/CwWaterfall.kt +++ b/feature/cw/src/main/java/com/rtbishop/look4sat/feature/cw/CwWaterfall.kt @@ -20,12 +20,16 @@ package com.rtbishop.look4sat.feature.cw import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow /** * Rolling spectrogram history for the waterfall display. @@ -35,40 +39,58 @@ import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram */ class CwWaterfallState(private val historyRows: Int = 96) { + // pushSamples runs on the audio capture thread while snapshot() runs on the + // Compose draw thread, so every touch of these two collections is guarded. + // ArrayDeque is not thread-safe: concurrent removeFirst()/toList() throws. + private val lock = Any() private val rows = ArrayDeque(historyRows) private val pending = ArrayList(CwDeepSpectrogram.SAMPLE_RATE) - /** Bumped on every change so Compose knows to redraw. */ - val revision = mutableIntStateOf(0) + /** + * Bumped on every change so Compose knows to redraw. + * + * A StateFlow, not `mutableIntStateOf`: this is written from the audio + * capture thread, and Compose snapshot state must only be mutated from the + * composition thread — doing otherwise crashes at runtime. + */ + private val _revision = MutableStateFlow(0) + val revision: StateFlow = _revision.asStateFlow() /** Snapshot for drawing, oldest row first. */ - fun snapshot(): List = rows.toList() + fun snapshot(): List = synchronized(lock) { rows.toList() } fun pushSamples(chunk: FloatArray, sampleRate: Int) { if (chunk.isEmpty()) return val resampled = CwDeepSpectrogram.resampleLinear( chunk, sampleRate, CwDeepSpectrogram.SAMPLE_RATE ) - pending.ensureCapacity(pending.size + resampled.size) - for (sample in resampled) pending.add(sample) - - // Need at least one FFT window before a row can be produced. - if (pending.size < CwDeepSpectrogram.FFT_LENGTH) return - - val audio = FloatArray(pending.size) { pending[it] } - pending.clear() - - for (row in CwDeepSpectrogram.compute(audio)) { - if (rows.size >= historyRows) rows.removeFirst() - rows.addLast(row) + val audio: FloatArray + synchronized(lock) { + pending.ensureCapacity(pending.size + resampled.size) + for (sample in resampled) pending.add(sample) + // Need at least one FFT window before a row can be produced. + if (pending.size < CwDeepSpectrogram.FFT_LENGTH) return + audio = FloatArray(pending.size) { pending[it] } + pending.clear() } - revision.intValue++ + + // FFT outside the lock; only the append below needs exclusivity. + val computed = CwDeepSpectrogram.compute(audio) + synchronized(lock) { + for (row in computed) { + if (rows.size >= historyRows) rows.removeFirst() + rows.addLast(row) + } + } + _revision.value += 1 } fun clear() { - rows.clear() - pending.clear() - revision.intValue++ + synchronized(lock) { + rows.clear() + pending.clear() + } + _revision.value += 1 } } @@ -82,11 +104,13 @@ internal fun CwWaterfallView( signalStrength: Float, modifier: Modifier = Modifier ) { - // Reading the revision inside the draw scope is what triggers redraws as - // new spectra arrive; without it the canvas would render only once. - val revision = state.revision.intValue + val revision by state.revision.collectAsState() Canvas(modifier = modifier.fillMaxSize()) { + // Touch the revision inside the draw scope so a new spectrum triggers a + // redraw; without this read the canvas would only ever render once. + @Suppress("UNUSED_EXPRESSION") revision + drawRect(color = Color(0xFF00060F), size = size) val rows = state.snapshot()