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
@@ -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)
}
}