fix(android): 修复进入推流页立即闪退(未捕获 Error)

用户反馈:扫描到客户端后点击「开始推流」立刻闪退。

根因:PoseDetector.initialize() 只 catch (Exception),而 MediaPipe 初始化
失败会抛 UnsatisfiedLinkError / NoClassDefFoundError / ExceptionInInitializerError
—— 这些都是 Error,不是 Exception,会直接冒泡成进程崩溃。

证据(对照实验):新增 Robolectric 组合测试后
  改回 catch (Exception):5 项测试 2 项失败
    - java.lang.UnsatisfiedLinkError: no mediapipe_tasks_jni in java.library.path
    - java.lang.NoClassDefFoundError: Could not initialize class ...PoseLandmarker
  改为 catch (Throwable):5 项全部通过

修复与加固:
- PoseDetector / CameraController 全部改为捕获 Throwable,失败只提示不崩溃
- 移除 accompanist-permissions 0.34.0(2024-02 发布,会传递引入
  compose animation-core 1.6.0,与 Compose BOM 存在版本偏斜风险),
  改用平台 Activity Result API
- MediaPipe 回调在相机线程:所有 Compose 状态写入统一 post 回主线程
- WebSocket 连接、相机启动、单帧分析全部包上失败保护
- 新增 CrashLogger:未捕获异常落盘到 filesDir/crashes/,
  并在连接页直接展示堆栈(免去连 adb 抓 logcat)
- 新增 ScreenCompositionTest(Robolectric):真实组合三个页面,
  覆盖「进入页面即崩溃」这一类缺陷,共 30 项单元测试
This commit is contained in:
2026-09-14 22:43:03 +08:00
parent fd50a7fdf3
commit e9ea1b5811
8 changed files with 387 additions and 69 deletions
@@ -1,6 +1,7 @@
package com.opendance.studio
import android.app.Application
import com.opendance.studio.util.CrashLogger
/**
* 应用级单例容器(T-AND-001 骨架)。
@@ -13,6 +14,8 @@ class OpenDanceApp : Application() {
override fun onCreate() {
super.onCreate()
instance = this
// 最先安装崩溃记录,保证后续任何初始化失败都能落盘堆栈
CrashLogger.install(this)
}
companion object {
@@ -67,9 +67,10 @@ class CameraController(private val context: Context) {
val provider = future.get()
cameraProvider = provider
bind(provider, lifecycleOwner, previewView, resolution, frontCamera)
} catch (e: Exception) {
Log.e(TAG, "相机初始化失败", e)
onError(e.message ?: "相机初始化失败")
} catch (t: Throwable) {
// 捕获 Throwable:相机 HAL 相关失败可能是 Error
Log.e(TAG, "相机初始化失败", t)
onError("${t.javaClass.simpleName}: ${t.message ?: "相机初始化失败"}")
}
}, ContextCompat.getMainExecutor(context))
}
@@ -109,9 +110,9 @@ class CameraController(private val context: Context) {
analysis.setAnalyzer(executor) { imageProxy ->
try {
this.analyzer?.analyze(imageProxy)
} catch (e: Exception) {
Log.w(TAG, "分析回调异常", e)
imageProxy.close()
} catch (t: Throwable) {
Log.w(TAG, "分析回调异常", t)
runCatching { imageProxy.close() }
}
}
}
@@ -127,8 +128,8 @@ class CameraController(private val context: Context) {
preview = previewUseCase
imageAnalysis = analysisUseCase
Log.i(TAG, "相机已绑定:${if (frontCamera) "前置" else "后置"} @ $resolution")
} catch (e: Exception) {
Log.e(TAG, "相机绑定失败", e)
} catch (t: Throwable) {
Log.e(TAG, "相机绑定失败", t)
imageAnalysis = null
}
}
@@ -78,9 +78,12 @@ class PoseDetector(private val context: Context) {
landmarker = PoseLandmarker.createFromOptions(context, options)
Log.i(TAG, "PoseLandmarker 就绪($modelAssetPath,delegate=$delegate)")
true
} catch (e: Exception) {
Log.e(TAG, "PoseLandmarker 初始化失败", e)
onError?.invoke("MediaPipe 初始化失败:${e.message}")
} catch (t: Throwable) {
// 必须捕获 Throwable:原生库加载失败会抛 UnsatisfiedLinkError、
// 静态初始化失败会抛 ExceptionInInitializerError,它们都不是 Exception。
// 只 catch Exception 会让这类失败直接冒泡成整个应用崩溃。
Log.e(TAG, "PoseLandmarker 初始化失败", t)
onError?.invoke("MediaPipe 初始化失败:${t.javaClass.simpleName}: ${t.message}")
landmarker = null
false
}
@@ -105,9 +108,9 @@ class PoseDetector(private val context: Context) {
busy = true
detector.detectAsync(mpImage, safeTimestamp)
true
} catch (e: Exception) {
} catch (t: Throwable) {
busy = false
Log.w(TAG, "提交帧失败:${e.message}")
Log.w(TAG, "提交帧失败:${t.message}", t)
false
}
}
@@ -205,8 +208,8 @@ class PoseDetector(private val context: Context) {
fun close() {
try {
landmarker?.close()
} catch (e: Exception) {
Log.w(TAG, "关闭 PoseLandmarker 失败:${e.message}")
} catch (t: Throwable) {
Log.w(TAG, "关闭 PoseLandmarker 失败:${t.message}", t)
}
landmarker = null
busy = false
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
@@ -36,6 +37,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
@@ -44,6 +46,7 @@ import com.opendance.studio.data.Resolutions
import com.opendance.studio.net.HostDiscovery
import com.opendance.studio.net.PoseWebSocketClient
import com.opendance.studio.ui.theme.OpenDanceGreen
import com.opendance.studio.util.CrashLogger
import com.opendance.studio.ui.theme.OpenDanceMuted
import com.opendance.studio.ui.theme.OpenDanceRed
import com.opendance.studio.ui.theme.OpenDanceSurface
@@ -62,6 +65,7 @@ fun ConnectScreen(
onAbout: () -> Unit,
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
var host by remember { mutableStateOf(settings.host) }
var portText by remember { mutableStateOf(settings.port.toString()) }
@@ -76,6 +80,9 @@ fun ConnectScreen(
var status by remember { mutableStateOf("填写 Windows 客户端的地址,或扫描局域网自动发现") }
var statusIsError by remember { mutableStateOf(false) }
// 上次运行如果崩溃过,把堆栈直接显示出来 —— 免去连 adb 抓 logcat 的麻烦
var crashText by remember { mutableStateOf(CrashLogger.latestCrashText(context)) }
LaunchedEffect(Unit) {
localIp = HostDiscovery.localIpv4Address()
}
@@ -114,6 +121,36 @@ fun ConnectScreen(
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (crashText != null) {
Card(colors = CardDefaults.cardColors(containerColor = OpenDanceSurface)) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("上次运行崩溃了", style = MaterialTheme.typography.titleMedium)
Text(
"下面是崩溃堆栈,可截图反馈以便定位。",
color = OpenDanceMuted,
style = MaterialTheme.typography.bodySmall,
)
Text(
crashText!!.take(4000),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 320.dp)
.verticalScroll(rememberScrollState()),
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = {
CrashLogger.clear(context)
crashText = null
}) { Text("清除记录") }
}
}
}
}
// ---------------- 目标地址 ----------------
Card(colors = CardDefaults.cardColors(containerColor = OpenDanceSurface)) {
Column(
@@ -1,7 +1,13 @@
package com.opendance.studio.ui
import android.Manifest
import android.content.pm.PackageManager
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.view.PreviewView
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -33,9 +39,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import androidx.core.content.ContextCompat
import com.opendance.studio.camera.CameraController
import com.opendance.studio.data.AppSettings
import com.opendance.studio.net.PoseWebSocketClient
@@ -60,8 +64,14 @@ import kotlinx.coroutines.launch
* └──▶ SkeletonOverlay(叠加层)
* ```
* 画面本身**从不离开设备**。
*
* 稳定性约定(真机排查后加固):
* 1. 相机权限用平台 Activity Result API,不引入 accompanist(避免 Compose 版本偏斜)
* 2. MediaPipe 回调发生在相机分析线程 —— 所有 Compose 状态写入都 post 回主线程
* 3. 初始化/连接失败只记录并提示,绝不让异常冒泡成崩溃
*/
@OptIn(ExperimentalPermissionsApi::class)
private const val TAG = "RunningScreen"
@Composable
fun RunningScreen(
settings: AppSettings,
@@ -70,9 +80,20 @@ fun RunningScreen(
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val scope = rememberCoroutineScope()
val mainHandler = remember { Handler(Looper.getMainLooper()) }
val cameraPermission = rememberPermissionState(Manifest.permission.CAMERA)
// ---------------- 相机权限(平台 API) ----------------
var hasCameraPermission by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED,
)
}
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted -> hasCameraPermission = granted }
// ---------------- UI 状态 ----------------
var overlay by remember { mutableStateOf(FloatArray(0)) }
var confidence by remember { mutableStateOf<FloatArray?>(null) }
var detectionFps by remember { mutableStateOf(0f) }
@@ -80,6 +101,7 @@ fun RunningScreen(
var poseDetected by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf("") }
var cameraError by remember { mutableStateOf("") }
var fatalError by remember { mutableStateOf("") }
val wsStats = remember { PoseWebSocketClient(scope) }
val stats by wsStats.stats.collectAsState()
@@ -88,7 +110,8 @@ fun RunningScreen(
val poseDetector = remember { PoseDetector(context) }
var previewView by remember { mutableStateOf<PreviewView?>(null) }
// 分析器抽出来复用:start() 与 switchCamera() 需要同一个实例
// 分析器抽出来复用:start() 与 switchCamera() 需要同一个实例。
// 注意:本 lambda 运行在相机分析线程,**不要**在这里直接写 Compose 状态。
val analyzer = remember {
CameraController.FrameAnalyzer { imageProxy ->
try {
@@ -97,21 +120,23 @@ fun RunningScreen(
timestampMs = SystemClock.uptimeMillis(),
rotationDegrees = imageProxy.imageInfo.rotationDegrees,
)
} catch (t: Throwable) {
// 单帧失败不应中断推流,更不应崩溃
Log.w(TAG, "分析帧失败:${t.message}", t)
} finally {
// toBitmap() 已在 detect() 内同步完成,此处可安全关闭
imageProxy.close()
runCatching { imageProxy.close() }
}
}
}
// 帧率统计窗口
// 帧率统计窗口(只在主线程读写)
var framesInWindow by remember { mutableStateOf(0) }
var windowStart by remember { mutableStateOf(SystemClock.uptimeMillis()) }
// ---------------- 权限 ----------------
// ---------------- 权限请求 ----------------
LaunchedEffect(Unit) {
if (!cameraPermission.status.isGranted) {
cameraPermission.launchPermissionRequest()
if (!hasCameraPermission) {
permissionLauncher.launch(Manifest.permission.CAMERA)
}
}
@@ -119,49 +144,70 @@ fun RunningScreen(
DisposableEffect(Unit) {
poseDetector.mirror = settings.mirror
poseDetector.upperBodyOnly = settings.upperBodyOnly
poseDetector.onError = { errorMessage = it }
poseDetector.onError = { message ->
mainHandler.post { errorMessage = message }
}
poseDetector.onResult = { result ->
inferenceMs = result.inferenceMs
poseDetected = result.overlay.isNotEmpty()
// 回调在相机分析线程:统一 post 回主线程再更新状态与发送
mainHandler.post {
inferenceMs = result.inferenceMs
poseDetected = result.overlay.isNotEmpty()
if (result.overlay.isNotEmpty()) {
overlay = result.overlay
confidence = result.frame.confidence
wsStats.sendFrame(result.frame)
}
if (result.overlay.isNotEmpty()) {
overlay = result.overlay
confidence = result.frame.confidence
runCatching { wsStats.sendFrame(result.frame) }
.onFailure { Log.w(TAG, "发送帧失败:${it.message}", it) }
}
framesInWindow += 1
val now = SystemClock.uptimeMillis()
val elapsed = now - windowStart
if (elapsed >= 1000) {
detectionFps = framesInWindow * 1000f / elapsed
framesInWindow = 0
windowStart = now
wsStats.sendStats(
fps = detectionFps,
dropped = stats.droppedFrames,
latencyMs = 0,
confidenceAvg = result.frame.averageConfidence(),
)
framesInWindow += 1
val now = SystemClock.uptimeMillis()
val elapsed = now - windowStart
if (elapsed >= 1000) {
detectionFps = framesInWindow * 1000f / elapsed
framesInWindow = 0
windowStart = now
runCatching {
wsStats.sendStats(
fps = detectionFps,
dropped = stats.droppedFrames,
latencyMs = 0,
confidenceAvg = result.frame.averageConfidence(),
)
}
}
}
}
if (!poseDetector.initialize()) {
errorMessage = "MediaPipe 初始化失败,请确认模型文件已随 APK 打包"
// onError 会在 initialize() 内部同步触发并 post 具体原因;
// 这里只做兜底,不能覆盖掉更具体的错误信息。
mainHandler.post {
if (errorMessage.isBlank()) {
errorMessage = "MediaPipe 初始化失败(未返回具体原因)"
}
}
}
PoseWebSocketClient.TARGET_FPS_FIELD = settings.targetFps
PoseWebSocketClient.RESOLUTION_FIELD = settings.resolution
wsStats.connect(settings.webSocketUrl(), settings.autoReconnect)
// 连接失败只提示,不崩溃
runCatching { wsStats.connect(settings.webSocketUrl(), settings.autoReconnect) }
.onFailure {
Log.e(TAG, "WebSocket 连接失败", it)
fatalError = "无法连接 ${settings.webSocketUrl()}:${it.message}"
}
onDispose {
wsStats.release()
poseDetector.close()
runCatching { wsStats.release() }
runCatching { poseDetector.close() }
runCatching { cameraController.release() }
}
}
// ---------------- 权限未授予 ----------------
if (!cameraPermission.status.isGranted) {
if (!hasCameraPermission) {
Box(
modifier = Modifier
.fillMaxSize()
@@ -178,7 +224,7 @@ fun RunningScreen(
color = OpenDanceMuted,
style = MaterialTheme.typography.bodySmall,
)
Button(onClick = { cameraPermission.launchPermissionRequest() }) {
Button(onClick = { permissionLauncher.launch(Manifest.permission.CAMERA) }) {
Text("授予相机权限")
}
OutlinedButton(onClick = onStop) { Text("返回") }
@@ -198,14 +244,19 @@ fun RunningScreen(
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
post {
previewView = this
cameraController.start(
lifecycleOwner = lifecycleOwner,
previewView = this,
resolution = settings.resolution,
frontCamera = true,
analyzer = analyzer,
onError = { cameraError = it },
)
runCatching {
cameraController.start(
lifecycleOwner = lifecycleOwner,
previewView = this,
resolution = settings.resolution,
frontCamera = true,
analyzer = analyzer,
onError = { message -> cameraError = message },
)
}.onFailure {
Log.e(TAG, "相机启动失败", it)
cameraError = it.message ?: "相机启动失败"
}
}
}
},
@@ -262,7 +313,7 @@ fun RunningScreen(
if (poseDetected) OpenDanceGreen else OpenDanceYellow,
)
confidence?.let {
val avg = it.average()
val avg = it.average().toFloat()
InfoLine(
"平均置信度",
"%.0f%%".format(avg * 100),
@@ -270,7 +321,10 @@ fun RunningScreen(
)
}
if (stats.lastError.isNotBlank()) {
InfoLine("最近错误", stats.lastError, OpenDanceRed)
InfoLine("连接错误", stats.lastError, OpenDanceRed)
}
if (fatalError.isNotBlank()) {
InfoLine("致命错误", fatalError, OpenDanceRed)
}
if (errorMessage.isNotBlank()) {
InfoLine("推理错误", errorMessage, OpenDanceRed)
@@ -297,7 +351,8 @@ fun RunningScreen(
cameraError = "预览尚未就绪"
} else {
cameraError = ""
cameraController.switchCamera(lifecycleOwner, view, analyzer)
runCatching { cameraController.switchCamera(lifecycleOwner, view, analyzer) }
.onFailure { cameraError = it.message ?: "切换摄像头失败" }
}
},
modifier = Modifier.weight(1f),
@@ -306,7 +361,7 @@ fun RunningScreen(
Button(
onClick = {
scope.launch {
wsStats.sendBye("user_stop")
runCatching { wsStats.sendBye("user_stop") }
onStop()
}
},
@@ -0,0 +1,88 @@
package com.opendance.studio.util
import android.content.Context
import android.util.Log
import java.io.File
import java.io.PrintWriter
import java.io.StringWriter
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* 崩溃记录器。
*
* 装上默认的 UncaughtExceptionHandler,把未捕获异常的完整堆栈写入
* `filesDir/crashes/crash-<时间戳>.txt`,供用户回传定位问题。
*
* 之所以需要它:本应用的关键链路(CameraX + MediaPipe + WebSocket)大量依赖
* 设备端的原生库与硬件行为,很多失败只在真机上出现。没有落盘堆栈就只能靠猜。
*/
object CrashLogger {
private const val TAG = "CrashLogger"
private const val DIR_NAME = "crashes"
private const val MAX_FILES = 10
@Volatile
private var installed = false
fun install(context: Context) {
if (installed) return
installed = true
val appContext = context.applicationContext
val previous = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
try {
writeCrash(appContext, thread, throwable)
} catch (e: Throwable) {
Log.e(TAG, "写入崩溃日志失败", e)
}
// 交回系统默认处理,保持「应用已停止」的正常行为
previous?.uncaughtException(thread, throwable)
}
}
private fun writeCrash(context: Context, thread: Thread, throwable: Throwable) {
val dir = File(context.filesDir, DIR_NAME).apply { mkdirs() }
val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
val file = File(dir, "crash-$stamp.txt")
val writer = StringWriter()
PrintWriter(writer).use { pw ->
pw.println("时间:$stamp")
pw.println("线程:${thread.name}")
pw.println("设备:${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL}")
pw.println("Android:${android.os.Build.VERSION.RELEASE}(API ${android.os.Build.VERSION.SDK_INT})")
pw.println("ABI:${android.os.Build.SUPPORTED_ABIS.joinToString()}")
pw.println("应用:${context.packageName}")
pw.println("----------------------------------------")
throwable.printStackTrace(pw)
}
file.writeText(writer.toString())
Log.e(TAG, "崩溃已记录到 ${file.absolutePath}", throwable)
pruneOldFiles(dir)
}
private fun pruneOldFiles(dir: File) {
val files = dir.listFiles()?.sortedByDescending { it.lastModified() } ?: return
files.drop(MAX_FILES).forEach { it.delete() }
}
/** 最近一次崩溃的文本内容;没有则返回 null */
fun latestCrashText(context: Context): String? {
val dir = File(context.filesDir, DIR_NAME)
val latest = dir.listFiles()?.maxByOrNull { it.lastModified() } ?: return null
return runCatching { latest.readText() }.getOrNull()
}
fun crashCount(context: Context): Int =
File(context.filesDir, DIR_NAME).listFiles()?.size ?: 0
fun clear(context: Context) {
File(context.filesDir, DIR_NAME).listFiles()?.forEach { it.delete() }
}
}