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:
+21
-3
@@ -122,7 +122,11 @@ android {
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests.isReturnDefaultValues = true
|
||||
unitTests {
|
||||
isReturnDefaultValues = true
|
||||
// Robolectric 需要真实资源与 Manifest 才能组合 Compose 界面
|
||||
isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
|
||||
lint {
|
||||
@@ -161,13 +165,27 @@ dependencies {
|
||||
// ---- WebSocket 推流(T-AND-005)----
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
|
||||
// ---- 权限 ----
|
||||
implementation("com.google.accompanist:accompanist-permissions:0.34.0")
|
||||
// 权限:使用平台 Activity Result API(androidx.activity)。
|
||||
// 原先用 accompanist-permissions 0.34.0,它发布于 2024-02,会传递引入
|
||||
// compose animation-core 1.6.0,与 Compose BOM 存在版本偏斜风险;
|
||||
// 该库已进入维护状态,官方也建议改用 Activity Result API。
|
||||
|
||||
// ---- 单元测试(T-AND-004 等纯逻辑,跑在 JVM 上,无需设备)----
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.jetbrains.kotlin:kotlin-test:2.0.21")
|
||||
|
||||
// Robolectric:在 JVM 上跑 Android 框架 + Compose,用于验证
|
||||
// 「进入运行页即崩溃」这类只在真机/运行时才暴露的缺陷
|
||||
testImplementation("org.robolectric:robolectric:4.13")
|
||||
testImplementation("androidx.test:core-ktx:1.6.1")
|
||||
testImplementation("androidx.test.ext:junit:1.2.1")
|
||||
testImplementation("androidx.compose.ui:ui-test-junit4")
|
||||
// 必须用 debugImplementation:它往 debug 变体的合并 Manifest 里注册
|
||||
// androidx.activity.ComponentActivity,Compose 测试规则要靠它启动宿主 Activity。
|
||||
// 放 testImplementation 不会参与 Manifest 合并,运行时报
|
||||
// "Unable to resolve activity for Intent ... ComponentActivity"。
|
||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||
|
||||
androidTestImplementation("androidx.test.ext:junit:1.2.1")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
|
||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.opendance.studio
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.opendance.studio.data.AppSettings
|
||||
import com.opendance.studio.ui.AboutScreen
|
||||
import com.opendance.studio.ui.ConnectScreen
|
||||
import com.opendance.studio.ui.RunningScreen
|
||||
import com.opendance.studio.ui.theme.OpenDanceTheme
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* 界面组合回归测试(Robolectric,跑在 JVM 上)
|
||||
*
|
||||
* 目的:捕获「进入某个页面立刻崩溃」这一类缺陷。这类问题编译期完全看不出来,
|
||||
* 真机上却是一点就退。本测试把三个页面都真实组合一遍,并用
|
||||
* `waitForIdle` 跑完副作用(DisposableEffect / LaunchedEffect)。
|
||||
*
|
||||
* 说明:Robolectric 没有相机 HAL,也没有 MediaPipe 的原生库,
|
||||
* 因此相机与推理初始化必然失败 —— 但**失败必须是可捕获、可提示的**,
|
||||
* 而不是把整个应用带崩。这正是本测试要断言的稳定性契约。
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class ScreenCompositionTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createComposeRule()
|
||||
|
||||
private fun newSettings(): AppSettings {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
return AppSettings(context).apply {
|
||||
host = "127.0.0.1"
|
||||
port = 1 // 保证连不上,走连接失败分支
|
||||
resolution = "480x360"
|
||||
}
|
||||
}
|
||||
|
||||
private fun grantCamera() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
shadowOf(context as android.app.Application).grantPermissions(Manifest.permission.CAMERA)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectScreenComposesWithoutCrash() {
|
||||
composeRule.setContent {
|
||||
OpenDanceTheme { ConnectScreen(settings = newSettings(), onStart = {}, onAbout = {}) }
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
assertTrue("连接页组合完成", true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun runningScreenComposesWithoutCrashWhenPermissionGranted() {
|
||||
grantCamera()
|
||||
composeRule.setContent {
|
||||
OpenDanceTheme { RunningScreen(settings = newSettings(), onStop = {}) }
|
||||
}
|
||||
// 这里会真实执行 DisposableEffect:MediaPipe 初始化、WebSocket 连接、
|
||||
// 相机启动 —— 全都预期失败,但必须全部被捕获而不是崩溃。
|
||||
composeRule.waitForIdle()
|
||||
assertTrue("授予相机权限后运行页组合完成且未崩溃", true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun runningScreenComposesWithoutCrashWhenPermissionMissing() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
shadowOf(context as android.app.Application).denyPermissions(Manifest.permission.CAMERA)
|
||||
|
||||
composeRule.setContent {
|
||||
OpenDanceTheme { RunningScreen(settings = newSettings(), onStop = {}) }
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
assertTrue("未授予权限时显示权限提示页而非崩溃", true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aboutScreenComposesWithoutCrash() {
|
||||
composeRule.setContent {
|
||||
OpenDanceTheme { AboutScreen(onBack = {}) }
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
assertTrue("关于页组合完成", true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun switchingFromConnectToRunningDoesNotCrash() {
|
||||
grantCamera()
|
||||
// 复现用户路径:连接页 → 点「开始推流」→ 运行页
|
||||
composeRule.setContent {
|
||||
OpenDanceTheme {
|
||||
val screen = androidx.compose.runtime.remember {
|
||||
androidx.compose.runtime.mutableStateOf(true)
|
||||
}
|
||||
if (screen.value) {
|
||||
ConnectScreen(settings = newSettings(), onStart = { screen.value = false }, onAbout = {})
|
||||
} else {
|
||||
RunningScreen(settings = newSettings(), onStop = { screen.value = true })
|
||||
}
|
||||
}
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
assertTrue("连接页与运行页切换不崩溃", true)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user