用户反馈:扫描到客户端后点击「开始推流」立刻闪退。
根因: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 项单元测试
193 lines
7.3 KiB
Kotlin
193 lines
7.3 KiB
Kotlin
import java.util.Properties
|
||
|
||
// OpenDance Studio —— Android 辅助 App(应用模块)
|
||
//
|
||
// 契约:contracts/websocket-keypoints.md v0.1.0(本端为**发送方**)
|
||
// contracts/skeleton-topology.md v0.1.0(MediaPipe 33 点)
|
||
|
||
plugins {
|
||
id("com.android.application")
|
||
id("org.jetbrains.kotlin.android")
|
||
id("org.jetbrains.kotlin.plugin.compose")
|
||
}
|
||
|
||
android {
|
||
namespace = "com.opendance.studio"
|
||
compileSdk = 34
|
||
|
||
defaultConfig {
|
||
applicationId = "com.opendance.studio"
|
||
minSdk = 29 // Android 10(文档 T-AND-009 验收要求 Android 10+)
|
||
targetSdk = 34
|
||
versionCode = 1
|
||
versionName = "0.1.0"
|
||
|
||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||
// 契约版本,供关于页与日志显示
|
||
buildConfigField("String", "CONTRACT_VERSION", "\"0.1.0\"")
|
||
buildConfigField("String", "WS_SUBPROTOCOL", "\"opendance.pose.v1\"")
|
||
}
|
||
|
||
/**
|
||
* 签名配置:优先读取 keystore.properties(已被 .gitignore 忽略),
|
||
* 否则读取环境变量;两者都没有时回退到 debug 签名。
|
||
*
|
||
* 这样 `./gradlew assembleRelease` 在没有任何私密文件的情况下也能产出**已签名**的
|
||
* APK(用 debug key),便于 CI 与验收;正式发布必须提供自己的 keystore
|
||
* (见 build/gen-keystore.sh 与 README.txt)。
|
||
*/
|
||
val keystorePropertiesFile = rootProject.file("keystore.properties")
|
||
val keystoreProperties = Properties()
|
||
if (keystorePropertiesFile.exists()) {
|
||
keystorePropertiesFile.inputStream().use { keystoreProperties.load(it) }
|
||
}
|
||
|
||
fun signingValue(key: String, env: String): String? =
|
||
keystoreProperties.getProperty(key) ?: System.getenv(env)
|
||
|
||
val releaseStoreFile = signingValue("storeFile", "KEYSTORE_PATH")
|
||
val hasReleaseKeystore = releaseStoreFile != null && file(releaseStoreFile).exists()
|
||
|
||
signingConfigs {
|
||
if (hasReleaseKeystore) {
|
||
create("release") {
|
||
storeFile = file(releaseStoreFile!!)
|
||
storePassword = signingValue("storePassword", "KEYSTORE_PASSWORD")
|
||
keyAlias = signingValue("keyAlias", "KEY_ALIAS")
|
||
keyPassword = signingValue("keyPassword", "KEY_PASSWORD")
|
||
}
|
||
}
|
||
}
|
||
|
||
buildTypes {
|
||
debug {
|
||
applicationIdSuffix = ".debug"
|
||
isMinifyEnabled = false
|
||
// debug 保留全部 ABI,方便在模拟器(x86_64)上调试
|
||
ndk {
|
||
abiFilters.clear()
|
||
abiFilters.addAll(listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64"))
|
||
}
|
||
}
|
||
release {
|
||
// 发布包只打包真实手机用得到的 ABI。
|
||
// x86/x86_64 仅供模拟器使用,却占掉约 27 MB(几乎是发布包的一半),
|
||
// 对真机用户纯属浪费。
|
||
ndk {
|
||
abiFilters.clear()
|
||
abiFilters.addAll(listOf("arm64-v8a", "armeabi-v7a"))
|
||
}
|
||
isMinifyEnabled = true
|
||
isShrinkResources = true
|
||
proguardFiles(
|
||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||
"proguard-rules.pro",
|
||
)
|
||
signingConfig = if (hasReleaseKeystore) {
|
||
signingConfigs.getByName("release")
|
||
} else {
|
||
// 回退:用 debug 签名,保证 CI 上 assembleRelease 也能产出已签名 APK
|
||
signingConfigs.getByName("debug")
|
||
}
|
||
}
|
||
}
|
||
|
||
compileOptions {
|
||
sourceCompatibility = JavaVersion.VERSION_17
|
||
targetCompatibility = JavaVersion.VERSION_17
|
||
}
|
||
|
||
kotlinOptions {
|
||
jvmTarget = "17"
|
||
}
|
||
|
||
buildFeatures {
|
||
compose = true
|
||
buildConfig = true
|
||
}
|
||
|
||
packaging {
|
||
resources {
|
||
excludes += setOf(
|
||
"/META-INF/{AL2.0,LGPL2.1}",
|
||
"/META-INF/DEPENDENCIES",
|
||
"/META-INF/LICENSE*",
|
||
"/META-INF/NOTICE*",
|
||
)
|
||
}
|
||
// MediaPipe 的 .task 模型必须保持不压缩,否则运行时按文件路径读取会失败
|
||
androidResources {
|
||
noCompress += "task"
|
||
}
|
||
}
|
||
|
||
testOptions {
|
||
unitTests {
|
||
isReturnDefaultValues = true
|
||
// Robolectric 需要真实资源与 Manifest 才能组合 Compose 界面
|
||
isIncludeAndroidResources = true
|
||
}
|
||
}
|
||
|
||
lint {
|
||
abortOnError = false
|
||
}
|
||
}
|
||
|
||
dependencies {
|
||
// ---- Android 基础 ----
|
||
implementation("androidx.core:core-ktx:1.13.1")
|
||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.6")
|
||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6")
|
||
implementation("androidx.activity:activity-compose:1.9.2")
|
||
|
||
// ---- Compose ----
|
||
val composeBom = platform("androidx.compose:compose-bom:2024.10.01")
|
||
implementation(composeBom)
|
||
androidTestImplementation(composeBom)
|
||
implementation("androidx.compose.ui:ui")
|
||
implementation("androidx.compose.ui:ui-graphics")
|
||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||
implementation("androidx.compose.material3:material3")
|
||
implementation("androidx.compose.material:material-icons-extended")
|
||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||
|
||
// ---- CameraX(T-AND-002)----
|
||
val cameraX = "1.4.0"
|
||
implementation("androidx.camera:camera-core:$cameraX")
|
||
implementation("androidx.camera:camera-camera2:$cameraX")
|
||
implementation("androidx.camera:camera-lifecycle:$cameraX")
|
||
implementation("androidx.camera:camera-view:$cameraX")
|
||
|
||
// ---- MediaPipe Pose 端侧推理(T-AND-003)----
|
||
implementation("com.google.mediapipe:tasks-vision:0.10.35")
|
||
|
||
// ---- WebSocket 推流(T-AND-005)----
|
||
implementation("com.squareup.okhttp3:okhttp:4.12.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")
|
||
}
|