From e9ea1b581145e46984cbb9d1707862aab82ab366 Mon Sep 17 00:00:00 2001 From: zijunchiang Date: Mon, 14 Sep 2026 22:43:03 +0800 Subject: [PATCH] =?UTF-8?q?fix(android):=20=E4=BF=AE=E5=A4=8D=E8=BF=9B?= =?UTF-8?q?=E5=85=A5=E6=8E=A8=E6=B5=81=E9=A1=B5=E7=AB=8B=E5=8D=B3=E9=97=AA?= =?UTF-8?q?=E9=80=80=EF=BC=88=E6=9C=AA=E6=8D=95=E8=8E=B7=20Error=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户反馈:扫描到客户端后点击「开始推流」立刻闪退。 根因: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 项单元测试 --- app/build.gradle.kts | 24 ++- .../java/com/opendance/studio/OpenDanceApp.kt | 3 + .../studio/camera/CameraController.kt | 17 +- .../com/opendance/studio/pose/PoseDetector.kt | 17 +- .../com/opendance/studio/ui/ConnectScreen.kt | 37 +++++ .../com/opendance/studio/ui/RunningScreen.kt | 157 ++++++++++++------ .../com/opendance/studio/util/CrashLogger.kt | 88 ++++++++++ .../opendance/studio/ScreenCompositionTest.kt | 113 +++++++++++++ 8 files changed, 387 insertions(+), 69 deletions(-) create mode 100644 app/src/main/java/com/opendance/studio/util/CrashLogger.kt create mode 100644 app/src/test/java/com/opendance/studio/ScreenCompositionTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2ba4eeb..ff771f9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") diff --git a/app/src/main/java/com/opendance/studio/OpenDanceApp.kt b/app/src/main/java/com/opendance/studio/OpenDanceApp.kt index 44dc378..e9dfde1 100644 --- a/app/src/main/java/com/opendance/studio/OpenDanceApp.kt +++ b/app/src/main/java/com/opendance/studio/OpenDanceApp.kt @@ -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 { diff --git a/app/src/main/java/com/opendance/studio/camera/CameraController.kt b/app/src/main/java/com/opendance/studio/camera/CameraController.kt index 347dc4c..d9651c0 100644 --- a/app/src/main/java/com/opendance/studio/camera/CameraController.kt +++ b/app/src/main/java/com/opendance/studio/camera/CameraController.kt @@ -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 } } diff --git a/app/src/main/java/com/opendance/studio/pose/PoseDetector.kt b/app/src/main/java/com/opendance/studio/pose/PoseDetector.kt index 56828c1..6144b72 100644 --- a/app/src/main/java/com/opendance/studio/pose/PoseDetector.kt +++ b/app/src/main/java/com/opendance/studio/pose/PoseDetector.kt @@ -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 diff --git a/app/src/main/java/com/opendance/studio/ui/ConnectScreen.kt b/app/src/main/java/com/opendance/studio/ui/ConnectScreen.kt index cf89295..fd0ff1a 100644 --- a/app/src/main/java/com/opendance/studio/ui/ConnectScreen.kt +++ b/app/src/main/java/com/opendance/studio/ui/ConnectScreen.kt @@ -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( diff --git a/app/src/main/java/com/opendance/studio/ui/RunningScreen.kt b/app/src/main/java/com/opendance/studio/ui/RunningScreen.kt index 5e9ab64..3955f09 100644 --- a/app/src/main/java/com/opendance/studio/ui/RunningScreen.kt +++ b/app/src/main/java/com/opendance/studio/ui/RunningScreen.kt @@ -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(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(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() } }, diff --git a/app/src/main/java/com/opendance/studio/util/CrashLogger.kt b/app/src/main/java/com/opendance/studio/util/CrashLogger.kt new file mode 100644 index 0000000..7f4a24a --- /dev/null +++ b/app/src/main/java/com/opendance/studio/util/CrashLogger.kt @@ -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() } + } +} diff --git a/app/src/test/java/com/opendance/studio/ScreenCompositionTest.kt b/app/src/test/java/com/opendance/studio/ScreenCompositionTest.kt new file mode 100644 index 0000000..50dea1a --- /dev/null +++ b/app/src/test/java/com/opendance/studio/ScreenCompositionTest.kt @@ -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() + return AppSettings(context).apply { + host = "127.0.0.1" + port = 1 // 保证连不上,走连接失败分支 + resolution = "480x360" + } + } + + private fun grantCamera() { + val context = ApplicationProvider.getApplicationContext() + 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() + 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) + } +}