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
+21 -3
View File
@@ -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)
}
}