fix(cw): stop the waterfall crashing and cut APK size by 87MB

三个真机实测暴露的问题:

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 个测试全绿
This commit is contained in:
mckero committed 2026-08-12 13:55:03 +00:00
1 parent 37300fb2a6
commit 5f297f9233
3 files changed
+92 -30

No files matched your search

+8
View File
@@ -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(
@@ -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
@@ -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<FloatArray>(historyRows)
private val pending = ArrayList<Float>(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<Int> = _revision.asStateFlow()
/** Snapshot for drawing, oldest row first. */
fun snapshot(): List<FloatArray> = rows.toList()
fun snapshot(): List<FloatArray> = 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()