fix(date-recorder): correct photo EXIF orientation and fix capture crash

- PhotoProcessor: read EXIF orientation and rotate bitmap on load so
  camera JPEGs display upright
- CameraScreen: set target rotation so CameraX writes correct EXIF
- CameraScreen: fix RejectedExecutionException by not shutting down the
  capture executor on dispose (CameraX may still post to it after)
- CameraScreen: route capture callback through main handler for thread
  safety before triggering Activity navigation
- CameraScreen: defer camera binding until PreviewView is attached to
  avoid repeated Surface abandon/recreate and camera rebind loops
- CameraScreen: use an overlay (not component swap) for the capturing
  state so the bound ImageCapture is not torn down mid-capture
- deps: add androidx.exifinterface for EXIF reading
This commit is contained in:
xfy 2026-07-17 15:36:36 +08:00
parent b2942a3ca9
commit 50da18460f
4 changed files with 118 additions and 38 deletions

View File

@ -103,6 +103,7 @@ dependencies {
implementation(libs.camera.camera2) implementation(libs.camera.camera2)
implementation(libs.camera.lifecycle) implementation(libs.camera.lifecycle)
implementation(libs.camera.view) implementation(libs.camera.view)
implementation(libs.androidx.exifinterface)
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:${libs.versions.kotlin.get()}") testImplementation("org.jetbrains.kotlin:kotlin-test-junit:${libs.versions.kotlin.get()}")
testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.kotlinx.coroutines.test)

View File

@ -7,6 +7,7 @@ import android.graphics.Color as AndroidColor
import android.graphics.Matrix import android.graphics.Matrix
import android.graphics.Paint import android.graphics.Paint
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.exifinterface.media.ExifInterface
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
@ -18,19 +19,45 @@ import java.io.FileOutputStream
object PhotoProcessor { object PhotoProcessor {
/** /**
* 按目标显示宽度降采样加载 Bitmap避免大图 OOM * 按目标显示宽度降采样加载 Bitmap避免大图 OOM并应用 EXIF 旋转方向
*
* 相机拍摄的 JPEG 常带有 EXIF orientation 标记 90/180/270
* BitmapFactory.decodeFile 默认忽略它导致显示方向错误
* 这里读取 EXIF 并在加载后旋转到位返回的 Bitmap 即为视觉正向
* *
* @param path 图片绝对路径 * @param path 图片绝对路径
* @param reqWidth 期望显示宽度像素实际宽度会 >= reqWidth 的最小 2 次幂采样 * @param reqWidth 期望显示宽度像素实际宽度会 >= reqWidth 的最小 2 次幂采样
* @return 降采样后的 Bitmap加载失败抛异常 * @return 降采样并校正方向后的 Bitmap加载失败抛异常
*/ */
fun loadSampled(path: String, reqWidth: Int = 1080): Bitmap { fun loadSampled(path: String, reqWidth: Int = 1080): Bitmap {
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(path, options) BitmapFactory.decodeFile(path, options)
options.inSampleSize = calculateInSampleSize(options.outWidth, reqWidth) options.inSampleSize = calculateInSampleSize(options.outWidth, reqWidth)
options.inJustDecodeBounds = false options.inJustDecodeBounds = false
return BitmapFactory.decodeFile(path, options) val bitmap = BitmapFactory.decodeFile(path, options)
?: error("无法加载图片: $path") ?: error("无法加载图片: $path")
val rotation = readExifRotation(path)
return if (rotation == 0) bitmap else rotate(bitmap, rotation)
}
/**
* 读取 JPEG EXIF orientation 标签返回对应的顺时针旋转角度
* JPEG 或无 EXIF 时返回 0
*/
private fun readExifRotation(path: String): Int {
return runCatching {
val orientation = ExifInterface(path)
.getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL
)
when (orientation) {
ExifInterface.ORIENTATION_ROTATE_90 -> 90
ExifInterface.ORIENTATION_ROTATE_180 -> 180
ExifInterface.ORIENTATION_ROTATE_270 -> 270
else -> 0
}
}.getOrDefault(0)
} }
private fun calculateInSampleSize(srcWidth: Int, reqWidth: Int): Int = private fun calculateInSampleSize(srcWidth: Int, reqWidth: Int): Int =

View File

@ -3,6 +3,7 @@ package plus.rua.project.ui
import android.Manifest import android.Manifest
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.CameraSelector import androidx.camera.core.CameraSelector
@ -31,7 +32,6 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -98,17 +98,18 @@ fun CameraScreen(
.background(Color.Black) .background(Color.Black)
.semantics { testTagsAsResourceId = true } .semantics { testTagsAsResourceId = true }
) { ) {
when { if (!hasCameraPermission) {
!hasCameraPermission -> PermissionDeniedContent(onBack = onBack) PermissionDeniedContent(onBack = onBack)
} else {
isCapturing -> CircularProgressIndicator( // 关键CameraPreview 必须始终留在组合中,不能被 isCapturing 替换。
modifier = Modifier.align(Alignment.Center), // 一旦 isCapturing=true 时移除 CameraPreview其 remember 的 imageCapture、
color = Color.White // LaunchedEffect 绑定都会失效,相机 unbindAll + clearPipeline
) // 而此时 takePicture 的异步请求还在排队,导致拍照无法完成(第一下点击失效)。
// loading 指示改为叠加覆盖层。
else -> CameraPreview( CameraPreview(
context = context, context = context,
lifecycleOwner = lifecycleOwner, lifecycleOwner = lifecycleOwner,
isCapturing = isCapturing,
onBack = onBack, onBack = onBack,
onCaptured = { path -> onCaptured = { path ->
isCapturing = false isCapturing = false
@ -120,6 +121,18 @@ fun CameraScreen(
}, },
setCapturing = { isCapturing = it } setCapturing = { isCapturing = it }
) )
if (isCapturing) {
// 半透明遮罩 + loading覆盖在预览之上但不移除预览
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.4f)),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(color = Color.White)
}
}
} }
captureError?.let { msg -> captureError?.let { msg ->
@ -138,46 +151,61 @@ fun CameraScreen(
private fun CameraPreview( private fun CameraPreview(
context: Context, context: Context,
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
isCapturing: Boolean,
onBack: () -> Unit, onBack: () -> Unit,
onCaptured: (String) -> Unit, onCaptured: (String) -> Unit,
onError: (String) -> Unit, onError: (String) -> Unit,
setCapturing: (Boolean) -> Unit setCapturing: (Boolean) -> Unit
) { ) {
val imageCapture = remember { ImageCapture.Builder().build() } val imageCapture = remember {
// 设置目标旋转角度,让 CameraX 写入正确的 EXIF orientation
// 配合 PhotoProcessor 读取 EXIF 后即可得到正向图片。
ImageCapture.Builder()
.setTargetRotation(context.getDisplayRotation())
.build()
}
// 注意:不要在 onDispose 中 shutdown executor。
// CameraX 1.5 的 takePicture 在 onImageSaved 之后内部仍可能向 executor 提交收尾任务,
// 过早 shutdown 会导致 RejectedExecutionException 崩溃。应用进程结束时线程池自然回收。
val executor = remember { Executors.newSingleThreadExecutor() } val executor = remember { Executors.newSingleThreadExecutor() }
// 主线程 Handler用于把拍照回调从 executor 线程切回 UI 线程后再触发跳转/状态更新
val mainHandler = remember { android.os.Handler(android.os.Looper.getMainLooper()) }
var lensFacing by remember { mutableStateOf(CameraSelector.LENS_FACING_BACK) } var lensFacing by remember { mutableStateOf(CameraSelector.LENS_FACING_BACK) }
DisposableEffect(Unit) { // 持有 PreviewView 引用:由 AndroidView.factory 创建后暴露给绑定逻辑。
onDispose { executor.shutdown() } // 不用 remember{PreviewView(context)} 自建 —— AndroidView 需要自己管理 View 生命周期
} // attach/detach/Surface 创建),外部 remember 的 View 与 AndroidView 内部生命周期冲突,
// 会导致 Surface 反复 abandon/recreate进而相机反复 bind/unbind。
var previewView by remember { mutableStateOf<PreviewView?>(null) }
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
AndroidView( AndroidView(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
factory = { ctx -> factory = { ctx ->
val previewView = PreviewView(ctx).apply { PreviewView(ctx).apply {
scaleType = PreviewView.ScaleType.FILL_CENTER scaleType = PreviewView.ScaleType.FILL_CENTER
// 等真正 attach 到窗口、Surface 就绪后再绑定相机
post {
previewView = this
}
} }
bindCameraUseCases(
context = ctx,
lifecycleOwner = lifecycleOwner,
previewView = previewView,
imageCapture = imageCapture,
lensFacing = lensFacing
)
previewView
},
update = { previewView ->
bindCameraUseCases(
context = context,
lifecycleOwner = lifecycleOwner,
previewView = previewView,
imageCapture = imageCapture,
lensFacing = lensFacing
)
} }
// 不在 update 里重新绑定相机,避免每次重组触发解绑重绑
) )
// 镜头切换时重新绑定。
// 注意previewView 首次就绪也会触发(从 null → PreviewView这是预期的首次绑定。
LaunchedEffect(lensFacing, previewView) {
val pv = previewView ?: return@LaunchedEffect
bindCameraUseCases(
context = context,
lifecycleOwner = lifecycleOwner,
previewView = pv,
imageCapture = imageCapture,
lensFacing = lensFacing
)
}
// 顶部返回按钮 // 顶部返回按钮
IconButton( IconButton(
onClick = onBack, onClick = onBack,
@ -220,8 +248,10 @@ private fun CameraPreview(
) )
} }
// 快门按钮:外圈白色环 + 内圈白色实心 // 快门按钮:外圈白色环 + 内圈白色实心。
// 关键拍照中isCapturing=true禁用防止连点导致启动多个 PhotoEditor。
IconButton( IconButton(
enabled = !isCapturing,
onClick = { onClick = {
val photoFile = createTempPhotoFile(context) val photoFile = createTempPhotoFile(context)
val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build() val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build()
@ -231,11 +261,15 @@ private fun CameraPreview(
executor, executor,
object : ImageCapture.OnImageSavedCallback { object : ImageCapture.OnImageSavedCallback {
override fun onImageSaved(output: ImageCapture.OutputFileResults) { override fun onImageSaved(output: ImageCapture.OutputFileResults) {
onCaptured(photoFile.absolutePath) // 拍照回调运行在 executor 线程,必须切回主线程
// 再触发 Activity 跳转与状态更新,避免线程安全问题
mainHandler.post { onCaptured(photoFile.absolutePath) }
} }
override fun onError(exception: ImageCaptureException) { override fun onError(exception: ImageCaptureException) {
onError("拍照失败:${exception.message}") Log.e(TAG, "onError: 拍照失败 code=${exception.imageCaptureError} msg=${exception.message}", exception)
val msg = "拍照失败:${exception.message}"
mainHandler.post { onError(msg) }
} }
} }
) )
@ -279,6 +313,23 @@ private fun PermissionDeniedContent(onBack: () -> Unit) {
} }
} }
private fun Context.getDisplayRotation(): Int {
val display = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
display
} else {
@Suppress("DEPRECATION") //getDisplay 在 API30+ 弃用,旧版本必须用此 API
(getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager).defaultDisplay
}
return when (display?.rotation) {
android.view.Surface.ROTATION_90 -> 90
android.view.Surface.ROTATION_180 -> 180
android.view.Surface.ROTATION_270 -> 270
else -> 0
}
}
private const val TAG = "DateRecorder/Camera"
private fun bindCameraUseCases( private fun bindCameraUseCases(
context: Context, context: Context,
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,

View File

@ -26,6 +26,7 @@ versions = "0.54.0"
[libraries] [libraries]
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
androidx-benchmark-macro = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "benchmarkMacro" } androidx-benchmark-macro = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "benchmarkMacro" }
androidx-exifinterface = { module = "androidx.exifinterface:exifinterface", version = "1.4.1" }
camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camerax" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camerax" }
camera-core = { module = "androidx.camera:camera-core", version.ref = "camerax" } camera-core = { module = "androidx.camera:camera-core", version.ref = "camerax" }
camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camerax" } camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camerax" }