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.lifecycle)
implementation(libs.camera.view)
implementation(libs.androidx.exifinterface)
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:${libs.versions.kotlin.get()}")
testImplementation(libs.kotlinx.coroutines.test)

View File

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

View File

@ -3,6 +3,7 @@ package plus.rua.project.ui
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.CameraSelector
@ -31,7 +32,6 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -98,17 +98,18 @@ fun CameraScreen(
.background(Color.Black)
.semantics { testTagsAsResourceId = true }
) {
when {
!hasCameraPermission -> PermissionDeniedContent(onBack = onBack)
isCapturing -> CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center),
color = Color.White
)
else -> CameraPreview(
if (!hasCameraPermission) {
PermissionDeniedContent(onBack = onBack)
} else {
// 关键CameraPreview 必须始终留在组合中,不能被 isCapturing 替换。
// 一旦 isCapturing=true 时移除 CameraPreview其 remember 的 imageCapture、
// LaunchedEffect 绑定都会失效,相机 unbindAll + clearPipeline
// 而此时 takePicture 的异步请求还在排队,导致拍照无法完成(第一下点击失效)。
// loading 指示改为叠加覆盖层。
CameraPreview(
context = context,
lifecycleOwner = lifecycleOwner,
isCapturing = isCapturing,
onBack = onBack,
onCaptured = { path ->
isCapturing = false
@ -120,6 +121,18 @@ fun CameraScreen(
},
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 ->
@ -138,46 +151,61 @@ fun CameraScreen(
private fun CameraPreview(
context: Context,
lifecycleOwner: LifecycleOwner,
isCapturing: Boolean,
onBack: () -> Unit,
onCaptured: (String) -> Unit,
onError: (String) -> 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() }
// 主线程 Handler用于把拍照回调从 executor 线程切回 UI 线程后再触发跳转/状态更新
val mainHandler = remember { android.os.Handler(android.os.Looper.getMainLooper()) }
var lensFacing by remember { mutableStateOf(CameraSelector.LENS_FACING_BACK) }
DisposableEffect(Unit) {
onDispose { executor.shutdown() }
}
// 持有 PreviewView 引用:由 AndroidView.factory 创建后暴露给绑定逻辑。
// 不用 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()) {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { ctx ->
val previewView = PreviewView(ctx).apply {
PreviewView(ctx).apply {
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(
onClick = onBack,
@ -220,8 +248,10 @@ private fun CameraPreview(
)
}
// 快门按钮:外圈白色环 + 内圈白色实心
// 快门按钮:外圈白色环 + 内圈白色实心。
// 关键拍照中isCapturing=true禁用防止连点导致启动多个 PhotoEditor。
IconButton(
enabled = !isCapturing,
onClick = {
val photoFile = createTempPhotoFile(context)
val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build()
@ -231,11 +261,15 @@ private fun CameraPreview(
executor,
object : ImageCapture.OnImageSavedCallback {
override fun onImageSaved(output: ImageCapture.OutputFileResults) {
onCaptured(photoFile.absolutePath)
// 拍照回调运行在 executor 线程,必须切回主线程
// 再触发 Activity 跳转与状态更新,避免线程安全问题
mainHandler.post { onCaptured(photoFile.absolutePath) }
}
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(
context: Context,
lifecycleOwner: LifecycleOwner,

View File

@ -26,6 +26,7 @@ versions = "0.54.0"
[libraries]
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-exifinterface = { module = "androidx.exifinterface:exifinterface", version = "1.4.1" }
camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camerax" }
camera-core = { module = "androidx.camera:camera-core", version.ref = "camerax" }
camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camerax" }