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 {
|
testOptions {
|
||||||
unitTests.isReturnDefaultValues = true
|
unitTests {
|
||||||
|
isReturnDefaultValues = true
|
||||||
|
// Robolectric 需要真实资源与 Manifest 才能组合 Compose 界面
|
||||||
|
isIncludeAndroidResources = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lint {
|
lint {
|
||||||
@@ -161,13 +165,27 @@ dependencies {
|
|||||||
// ---- WebSocket 推流(T-AND-005)----
|
// ---- WebSocket 推流(T-AND-005)----
|
||||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
|
|
||||||
// ---- 权限 ----
|
// 权限:使用平台 Activity Result API(androidx.activity)。
|
||||||
implementation("com.google.accompanist:accompanist-permissions:0.34.0")
|
// 原先用 accompanist-permissions 0.34.0,它发布于 2024-02,会传递引入
|
||||||
|
// compose animation-core 1.6.0,与 Compose BOM 存在版本偏斜风险;
|
||||||
|
// 该库已进入维护状态,官方也建议改用 Activity Result API。
|
||||||
|
|
||||||
// ---- 单元测试(T-AND-004 等纯逻辑,跑在 JVM 上,无需设备)----
|
// ---- 单元测试(T-AND-004 等纯逻辑,跑在 JVM 上,无需设备)----
|
||||||
testImplementation("junit:junit:4.13.2")
|
testImplementation("junit:junit:4.13.2")
|
||||||
testImplementation("org.jetbrains.kotlin:kotlin-test:2.0.21")
|
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.ext:junit:1.2.1")
|
||||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
|
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
|
||||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.opendance.studio
|
package com.opendance.studio
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import com.opendance.studio.util.CrashLogger
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 应用级单例容器(T-AND-001 骨架)。
|
* 应用级单例容器(T-AND-001 骨架)。
|
||||||
@@ -13,6 +14,8 @@ class OpenDanceApp : Application() {
|
|||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
instance = this
|
instance = this
|
||||||
|
// 最先安装崩溃记录,保证后续任何初始化失败都能落盘堆栈
|
||||||
|
CrashLogger.install(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -67,9 +67,10 @@ class CameraController(private val context: Context) {
|
|||||||
val provider = future.get()
|
val provider = future.get()
|
||||||
cameraProvider = provider
|
cameraProvider = provider
|
||||||
bind(provider, lifecycleOwner, previewView, resolution, frontCamera)
|
bind(provider, lifecycleOwner, previewView, resolution, frontCamera)
|
||||||
} catch (e: Exception) {
|
} catch (t: Throwable) {
|
||||||
Log.e(TAG, "相机初始化失败", e)
|
// 捕获 Throwable:相机 HAL 相关失败可能是 Error
|
||||||
onError(e.message ?: "相机初始化失败")
|
Log.e(TAG, "相机初始化失败", t)
|
||||||
|
onError("${t.javaClass.simpleName}: ${t.message ?: "相机初始化失败"}")
|
||||||
}
|
}
|
||||||
}, ContextCompat.getMainExecutor(context))
|
}, ContextCompat.getMainExecutor(context))
|
||||||
}
|
}
|
||||||
@@ -109,9 +110,9 @@ class CameraController(private val context: Context) {
|
|||||||
analysis.setAnalyzer(executor) { imageProxy ->
|
analysis.setAnalyzer(executor) { imageProxy ->
|
||||||
try {
|
try {
|
||||||
this.analyzer?.analyze(imageProxy)
|
this.analyzer?.analyze(imageProxy)
|
||||||
} catch (e: Exception) {
|
} catch (t: Throwable) {
|
||||||
Log.w(TAG, "分析回调异常", e)
|
Log.w(TAG, "分析回调异常", t)
|
||||||
imageProxy.close()
|
runCatching { imageProxy.close() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,8 +128,8 @@ class CameraController(private val context: Context) {
|
|||||||
preview = previewUseCase
|
preview = previewUseCase
|
||||||
imageAnalysis = analysisUseCase
|
imageAnalysis = analysisUseCase
|
||||||
Log.i(TAG, "相机已绑定:${if (frontCamera) "前置" else "后置"} @ $resolution")
|
Log.i(TAG, "相机已绑定:${if (frontCamera) "前置" else "后置"} @ $resolution")
|
||||||
} catch (e: Exception) {
|
} catch (t: Throwable) {
|
||||||
Log.e(TAG, "相机绑定失败", e)
|
Log.e(TAG, "相机绑定失败", t)
|
||||||
imageAnalysis = null
|
imageAnalysis = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,9 +78,12 @@ class PoseDetector(private val context: Context) {
|
|||||||
landmarker = PoseLandmarker.createFromOptions(context, options)
|
landmarker = PoseLandmarker.createFromOptions(context, options)
|
||||||
Log.i(TAG, "PoseLandmarker 就绪($modelAssetPath,delegate=$delegate)")
|
Log.i(TAG, "PoseLandmarker 就绪($modelAssetPath,delegate=$delegate)")
|
||||||
true
|
true
|
||||||
} catch (e: Exception) {
|
} catch (t: Throwable) {
|
||||||
Log.e(TAG, "PoseLandmarker 初始化失败", e)
|
// 必须捕获 Throwable:原生库加载失败会抛 UnsatisfiedLinkError、
|
||||||
onError?.invoke("MediaPipe 初始化失败:${e.message}")
|
// 静态初始化失败会抛 ExceptionInInitializerError,它们都不是 Exception。
|
||||||
|
// 只 catch Exception 会让这类失败直接冒泡成整个应用崩溃。
|
||||||
|
Log.e(TAG, "PoseLandmarker 初始化失败", t)
|
||||||
|
onError?.invoke("MediaPipe 初始化失败:${t.javaClass.simpleName}: ${t.message}")
|
||||||
landmarker = null
|
landmarker = null
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
@@ -105,9 +108,9 @@ class PoseDetector(private val context: Context) {
|
|||||||
busy = true
|
busy = true
|
||||||
detector.detectAsync(mpImage, safeTimestamp)
|
detector.detectAsync(mpImage, safeTimestamp)
|
||||||
true
|
true
|
||||||
} catch (e: Exception) {
|
} catch (t: Throwable) {
|
||||||
busy = false
|
busy = false
|
||||||
Log.w(TAG, "提交帧失败:${e.message}")
|
Log.w(TAG, "提交帧失败:${t.message}", t)
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,8 +208,8 @@ class PoseDetector(private val context: Context) {
|
|||||||
fun close() {
|
fun close() {
|
||||||
try {
|
try {
|
||||||
landmarker?.close()
|
landmarker?.close()
|
||||||
} catch (e: Exception) {
|
} catch (t: Throwable) {
|
||||||
Log.w(TAG, "关闭 PoseLandmarker 失败:${e.message}")
|
Log.w(TAG, "关闭 PoseLandmarker 失败:${t.message}", t)
|
||||||
}
|
}
|
||||||
landmarker = null
|
landmarker = null
|
||||||
busy = false
|
busy = false
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
@@ -36,6 +37,7 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.dp
|
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.HostDiscovery
|
||||||
import com.opendance.studio.net.PoseWebSocketClient
|
import com.opendance.studio.net.PoseWebSocketClient
|
||||||
import com.opendance.studio.ui.theme.OpenDanceGreen
|
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.OpenDanceMuted
|
||||||
import com.opendance.studio.ui.theme.OpenDanceRed
|
import com.opendance.studio.ui.theme.OpenDanceRed
|
||||||
import com.opendance.studio.ui.theme.OpenDanceSurface
|
import com.opendance.studio.ui.theme.OpenDanceSurface
|
||||||
@@ -62,6 +65,7 @@ fun ConnectScreen(
|
|||||||
onAbout: () -> Unit,
|
onAbout: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
var host by remember { mutableStateOf(settings.host) }
|
var host by remember { mutableStateOf(settings.host) }
|
||||||
var portText by remember { mutableStateOf(settings.port.toString()) }
|
var portText by remember { mutableStateOf(settings.port.toString()) }
|
||||||
@@ -76,6 +80,9 @@ fun ConnectScreen(
|
|||||||
var status by remember { mutableStateOf("填写 Windows 客户端的地址,或扫描局域网自动发现") }
|
var status by remember { mutableStateOf("填写 Windows 客户端的地址,或扫描局域网自动发现") }
|
||||||
var statusIsError by remember { mutableStateOf(false) }
|
var statusIsError by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// 上次运行如果崩溃过,把堆栈直接显示出来 —— 免去连 adb 抓 logcat 的麻烦
|
||||||
|
var crashText by remember { mutableStateOf(CrashLogger.latestCrashText(context)) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
localIp = HostDiscovery.localIpv4Address()
|
localIp = HostDiscovery.localIpv4Address()
|
||||||
}
|
}
|
||||||
@@ -114,6 +121,36 @@ fun ConnectScreen(
|
|||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
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)) {
|
Card(colors = CardDefaults.cardColors(containerColor = OpenDanceSurface)) {
|
||||||
Column(
|
Column(
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
package com.opendance.studio.ui
|
package com.opendance.studio.ui
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
import android.os.SystemClock
|
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.camera.view.PreviewView
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
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.platform.LocalLifecycleOwner
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
import androidx.core.content.ContextCompat
|
||||||
import com.google.accompanist.permissions.isGranted
|
|
||||||
import com.google.accompanist.permissions.rememberPermissionState
|
|
||||||
import com.opendance.studio.camera.CameraController
|
import com.opendance.studio.camera.CameraController
|
||||||
import com.opendance.studio.data.AppSettings
|
import com.opendance.studio.data.AppSettings
|
||||||
import com.opendance.studio.net.PoseWebSocketClient
|
import com.opendance.studio.net.PoseWebSocketClient
|
||||||
@@ -60,8 +64,14 @@ import kotlinx.coroutines.launch
|
|||||||
* └──▶ SkeletonOverlay(叠加层)
|
* └──▶ SkeletonOverlay(叠加层)
|
||||||
* ```
|
* ```
|
||||||
* 画面本身**从不离开设备**。
|
* 画面本身**从不离开设备**。
|
||||||
|
*
|
||||||
|
* 稳定性约定(真机排查后加固):
|
||||||
|
* 1. 相机权限用平台 Activity Result API,不引入 accompanist(避免 Compose 版本偏斜)
|
||||||
|
* 2. MediaPipe 回调发生在相机分析线程 —— 所有 Compose 状态写入都 post 回主线程
|
||||||
|
* 3. 初始化/连接失败只记录并提示,绝不让异常冒泡成崩溃
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalPermissionsApi::class)
|
private const val TAG = "RunningScreen"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RunningScreen(
|
fun RunningScreen(
|
||||||
settings: AppSettings,
|
settings: AppSettings,
|
||||||
@@ -70,9 +80,20 @@ fun RunningScreen(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
val scope = rememberCoroutineScope()
|
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 overlay by remember { mutableStateOf(FloatArray(0)) }
|
||||||
var confidence by remember { mutableStateOf<FloatArray?>(null) }
|
var confidence by remember { mutableStateOf<FloatArray?>(null) }
|
||||||
var detectionFps by remember { mutableStateOf(0f) }
|
var detectionFps by remember { mutableStateOf(0f) }
|
||||||
@@ -80,6 +101,7 @@ fun RunningScreen(
|
|||||||
var poseDetected by remember { mutableStateOf(false) }
|
var poseDetected by remember { mutableStateOf(false) }
|
||||||
var errorMessage by remember { mutableStateOf("") }
|
var errorMessage by remember { mutableStateOf("") }
|
||||||
var cameraError by remember { mutableStateOf("") }
|
var cameraError by remember { mutableStateOf("") }
|
||||||
|
var fatalError by remember { mutableStateOf("") }
|
||||||
|
|
||||||
val wsStats = remember { PoseWebSocketClient(scope) }
|
val wsStats = remember { PoseWebSocketClient(scope) }
|
||||||
val stats by wsStats.stats.collectAsState()
|
val stats by wsStats.stats.collectAsState()
|
||||||
@@ -88,7 +110,8 @@ fun RunningScreen(
|
|||||||
val poseDetector = remember { PoseDetector(context) }
|
val poseDetector = remember { PoseDetector(context) }
|
||||||
var previewView by remember { mutableStateOf<PreviewView?>(null) }
|
var previewView by remember { mutableStateOf<PreviewView?>(null) }
|
||||||
|
|
||||||
// 分析器抽出来复用:start() 与 switchCamera() 需要同一个实例
|
// 分析器抽出来复用:start() 与 switchCamera() 需要同一个实例。
|
||||||
|
// 注意:本 lambda 运行在相机分析线程,**不要**在这里直接写 Compose 状态。
|
||||||
val analyzer = remember {
|
val analyzer = remember {
|
||||||
CameraController.FrameAnalyzer { imageProxy ->
|
CameraController.FrameAnalyzer { imageProxy ->
|
||||||
try {
|
try {
|
||||||
@@ -97,21 +120,23 @@ fun RunningScreen(
|
|||||||
timestampMs = SystemClock.uptimeMillis(),
|
timestampMs = SystemClock.uptimeMillis(),
|
||||||
rotationDegrees = imageProxy.imageInfo.rotationDegrees,
|
rotationDegrees = imageProxy.imageInfo.rotationDegrees,
|
||||||
)
|
)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
// 单帧失败不应中断推流,更不应崩溃
|
||||||
|
Log.w(TAG, "分析帧失败:${t.message}", t)
|
||||||
} finally {
|
} finally {
|
||||||
// toBitmap() 已在 detect() 内同步完成,此处可安全关闭
|
runCatching { imageProxy.close() }
|
||||||
imageProxy.close()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 帧率统计窗口
|
// 帧率统计窗口(只在主线程读写)
|
||||||
var framesInWindow by remember { mutableStateOf(0) }
|
var framesInWindow by remember { mutableStateOf(0) }
|
||||||
var windowStart by remember { mutableStateOf(SystemClock.uptimeMillis()) }
|
var windowStart by remember { mutableStateOf(SystemClock.uptimeMillis()) }
|
||||||
|
|
||||||
// ---------------- 权限 ----------------
|
// ---------------- 权限请求 ----------------
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
if (!cameraPermission.status.isGranted) {
|
if (!hasCameraPermission) {
|
||||||
cameraPermission.launchPermissionRequest()
|
permissionLauncher.launch(Manifest.permission.CAMERA)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,49 +144,70 @@ fun RunningScreen(
|
|||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
poseDetector.mirror = settings.mirror
|
poseDetector.mirror = settings.mirror
|
||||||
poseDetector.upperBodyOnly = settings.upperBodyOnly
|
poseDetector.upperBodyOnly = settings.upperBodyOnly
|
||||||
poseDetector.onError = { errorMessage = it }
|
poseDetector.onError = { message ->
|
||||||
|
mainHandler.post { errorMessage = message }
|
||||||
|
}
|
||||||
poseDetector.onResult = { result ->
|
poseDetector.onResult = { result ->
|
||||||
inferenceMs = result.inferenceMs
|
// 回调在相机分析线程:统一 post 回主线程再更新状态与发送
|
||||||
poseDetected = result.overlay.isNotEmpty()
|
mainHandler.post {
|
||||||
|
inferenceMs = result.inferenceMs
|
||||||
|
poseDetected = result.overlay.isNotEmpty()
|
||||||
|
|
||||||
if (result.overlay.isNotEmpty()) {
|
if (result.overlay.isNotEmpty()) {
|
||||||
overlay = result.overlay
|
overlay = result.overlay
|
||||||
confidence = result.frame.confidence
|
confidence = result.frame.confidence
|
||||||
wsStats.sendFrame(result.frame)
|
runCatching { wsStats.sendFrame(result.frame) }
|
||||||
}
|
.onFailure { Log.w(TAG, "发送帧失败:${it.message}", it) }
|
||||||
|
}
|
||||||
|
|
||||||
framesInWindow += 1
|
framesInWindow += 1
|
||||||
val now = SystemClock.uptimeMillis()
|
val now = SystemClock.uptimeMillis()
|
||||||
val elapsed = now - windowStart
|
val elapsed = now - windowStart
|
||||||
if (elapsed >= 1000) {
|
if (elapsed >= 1000) {
|
||||||
detectionFps = framesInWindow * 1000f / elapsed
|
detectionFps = framesInWindow * 1000f / elapsed
|
||||||
framesInWindow = 0
|
framesInWindow = 0
|
||||||
windowStart = now
|
windowStart = now
|
||||||
wsStats.sendStats(
|
runCatching {
|
||||||
fps = detectionFps,
|
wsStats.sendStats(
|
||||||
dropped = stats.droppedFrames,
|
fps = detectionFps,
|
||||||
latencyMs = 0,
|
dropped = stats.droppedFrames,
|
||||||
confidenceAvg = result.frame.averageConfidence(),
|
latencyMs = 0,
|
||||||
)
|
confidenceAvg = result.frame.averageConfidence(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!poseDetector.initialize()) {
|
if (!poseDetector.initialize()) {
|
||||||
errorMessage = "MediaPipe 初始化失败,请确认模型文件已随 APK 打包"
|
// onError 会在 initialize() 内部同步触发并 post 具体原因;
|
||||||
|
// 这里只做兜底,不能覆盖掉更具体的错误信息。
|
||||||
|
mainHandler.post {
|
||||||
|
if (errorMessage.isBlank()) {
|
||||||
|
errorMessage = "MediaPipe 初始化失败(未返回具体原因)"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
PoseWebSocketClient.TARGET_FPS_FIELD = settings.targetFps
|
PoseWebSocketClient.TARGET_FPS_FIELD = settings.targetFps
|
||||||
PoseWebSocketClient.RESOLUTION_FIELD = settings.resolution
|
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 {
|
onDispose {
|
||||||
wsStats.release()
|
runCatching { wsStats.release() }
|
||||||
poseDetector.close()
|
runCatching { poseDetector.close() }
|
||||||
|
runCatching { cameraController.release() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- 权限未授予 ----------------
|
// ---------------- 权限未授予 ----------------
|
||||||
if (!cameraPermission.status.isGranted) {
|
if (!hasCameraPermission) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -178,7 +224,7 @@ fun RunningScreen(
|
|||||||
color = OpenDanceMuted,
|
color = OpenDanceMuted,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
Button(onClick = { cameraPermission.launchPermissionRequest() }) {
|
Button(onClick = { permissionLauncher.launch(Manifest.permission.CAMERA) }) {
|
||||||
Text("授予相机权限")
|
Text("授予相机权限")
|
||||||
}
|
}
|
||||||
OutlinedButton(onClick = onStop) { Text("返回") }
|
OutlinedButton(onClick = onStop) { Text("返回") }
|
||||||
@@ -198,14 +244,19 @@ fun RunningScreen(
|
|||||||
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
|
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
|
||||||
post {
|
post {
|
||||||
previewView = this
|
previewView = this
|
||||||
cameraController.start(
|
runCatching {
|
||||||
lifecycleOwner = lifecycleOwner,
|
cameraController.start(
|
||||||
previewView = this,
|
lifecycleOwner = lifecycleOwner,
|
||||||
resolution = settings.resolution,
|
previewView = this,
|
||||||
frontCamera = true,
|
resolution = settings.resolution,
|
||||||
analyzer = analyzer,
|
frontCamera = true,
|
||||||
onError = { cameraError = it },
|
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,
|
if (poseDetected) OpenDanceGreen else OpenDanceYellow,
|
||||||
)
|
)
|
||||||
confidence?.let {
|
confidence?.let {
|
||||||
val avg = it.average()
|
val avg = it.average().toFloat()
|
||||||
InfoLine(
|
InfoLine(
|
||||||
"平均置信度",
|
"平均置信度",
|
||||||
"%.0f%%".format(avg * 100),
|
"%.0f%%".format(avg * 100),
|
||||||
@@ -270,7 +321,10 @@ fun RunningScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (stats.lastError.isNotBlank()) {
|
if (stats.lastError.isNotBlank()) {
|
||||||
InfoLine("最近错误", stats.lastError, OpenDanceRed)
|
InfoLine("连接错误", stats.lastError, OpenDanceRed)
|
||||||
|
}
|
||||||
|
if (fatalError.isNotBlank()) {
|
||||||
|
InfoLine("致命错误", fatalError, OpenDanceRed)
|
||||||
}
|
}
|
||||||
if (errorMessage.isNotBlank()) {
|
if (errorMessage.isNotBlank()) {
|
||||||
InfoLine("推理错误", errorMessage, OpenDanceRed)
|
InfoLine("推理错误", errorMessage, OpenDanceRed)
|
||||||
@@ -297,7 +351,8 @@ fun RunningScreen(
|
|||||||
cameraError = "预览尚未就绪"
|
cameraError = "预览尚未就绪"
|
||||||
} else {
|
} else {
|
||||||
cameraError = ""
|
cameraError = ""
|
||||||
cameraController.switchCamera(lifecycleOwner, view, analyzer)
|
runCatching { cameraController.switchCamera(lifecycleOwner, view, analyzer) }
|
||||||
|
.onFailure { cameraError = it.message ?: "切换摄像头失败" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
@@ -306,7 +361,7 @@ fun RunningScreen(
|
|||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
wsStats.sendBye("user_stop")
|
runCatching { wsStats.sendBye("user_stop") }
|
||||||
onStop()
|
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