Android 图片翻译中的 EXIF:从 CameraX、Glide、ImageView 到服务端 OCR
最近有一个需求是竖屏拍照并翻译(见下图),开发完才发现细节很多,特有此文做记录并方便回顾。

图片翻译看似是"拍照、上传、OCR、返回译图"四步,但只要页面锁定竖屏、用户横着拿手机拍照,方向问题就会贯穿整条链路:
text
手机物理方向
↓
CameraX 输出像素与 EXIF
↓
上传前压缩是否保留 EXIF
↓
服务端是否按 EXIF 旋正后 OCR
↓
Glide 是否按 EXIF 解码
↓
ImageView 是否还需要额外旋转
任何一层把"像素方向"和"显示方向"混为一谈,都可能出现预览正常、OCR 横着识别,或者服务端结果正常、客户端却再次旋转的问题。
本文先介绍 EXIF,再完整说明这几层之间的关系,以及强制竖屏页面如何根据手机物理方向生成正确的照片,并在同一个 ImageView 中适配四种持机方向。
一、什么是 EXIF
EXIF(Exchangeable Image File Format)是图片文件中的一组元数据,常见于 JPEG,也可以出现在部分其他图片容器中。
EXIF 可以记录:
- 拍摄时间;
- 相机品牌与型号;
- 曝光、焦距、ISO;
- GPS 位置;
- 缩略图;
- 图片方向
Orientation。
图片方向问题主要关注 Orientation。它描述的不是"把文件里的像素真的旋转了多少度",而是告诉解码器:应该怎样变换原始像素矩阵,才能得到人眼看到的正向图片。
例如,一张 JPEG 的原始像素尺寸可能是 4032 × 3024,但 EXIF Orientation 为 6。支持 EXIF 的查看器会顺时针旋转 90° 后显示,用户看到的视觉尺寸就相当于 3024 × 4032。
因此,一张图同时存在两个方向概念:
text
Stored pixels = 文件中实际保存的像素排列
Visual image = 对像素应用 EXIF Orientation 后看到的图片
Orientation 的八种取值
EXIF Orientation 不只有 0°、90°、180°、270°,还包含镜像情况:
| 值 | Android 常量 | 显示变换 |
|---|---|---|
| 1 | ORIENTATION_NORMAL |
不变换 |
| 2 | ORIENTATION_FLIP_HORIZONTAL |
水平镜像 |
| 3 | ORIENTATION_ROTATE_180 |
旋转 180° |
| 4 | ORIENTATION_FLIP_VERTICAL |
垂直镜像 |
| 5 | ORIENTATION_TRANSPOSE |
转置 |
| 6 | ORIENTATION_ROTATE_90 |
顺时针旋转 90° |
| 7 | ORIENTATION_TRANSVERSE |
横向转置 |
| 8 | ORIENTATION_ROTATE_270 |
顺时针旋转 270° |
读取时可以把缺失、非法值降级为正常方向:
kotlin
private fun readExifOrientation(file: File): Int {
val orientation = runCatching {
ExifInterface(file.absolutePath).getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL
)
}.getOrDefault(ExifInterface.ORIENTATION_NORMAL)
return if (
orientation in ExifInterface.ORIENTATION_NORMAL..
ExifInterface.ORIENTATION_ROTATE_270
) {
orientation
} else {
ExifInterface.ORIENTATION_NORMAL
}
}
二、文件 EXIF、Glide 与 ImageView 是什么关系
理解图片方向时,可以把客户端拆成三层:
text
图片文件
├─ 原始像素矩阵
└─ EXIF Orientation
↓
Glide 解码层
└─ 读取 EXIF,对像素应用方向变换,得到正向 Drawable
↓
ImageView 展示层
├─ scaleType 决定 Drawable 如何放入 View
└─ rotation 决定整个 View 如何在页面坐标系中旋转
Glide 会处理 EXIF,ImageView 不会
使用 Glide 直接加载 JPEG 文件时:
kotlin
Glide.with(imageView)
.load(imageFile)
.into(imageView)
Glide 会在解码阶段读取图片方向,并把交给 ImageView 的资源调整为正确视觉方向。
ImageView 自己不认识 EXIF。它只接收 Glide 解码完成的 Drawable,然后根据自己的宽高、scaleType 和变换属性绘制。
因此下面三个动作完全不同:
text
修改 EXIF Orientation → 修改文件元数据,不立即改变 View
Glide 按 EXIF 解码 → 改变交给 ImageView 的视觉像素方向
imageView.rotation → 旋转页面上的 View,不修改文件和 EXIF
如果 Glide 已经按 EXIF 把图片旋正,再根据同一个 EXIF 值手动旋转 ImageView,就会发生二次旋转。
Bitmap 是一个重要边界
EXIF 属于文件,不属于 Bitmap。一旦使用 BitmapFactory 把 JPEG 解码成 Bitmap,得到的只是像素数据,原 EXIF 不会附着在 Bitmap 对象上。
随后执行:
kotlin
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream)
会生成新的 JPEG,但不会自动复制原图 EXIF。若后续 OCR 依赖 Orientation,就必须显式写回。
三、服务端 OCR 为什么也必须理解 EXIF
OCR 真正处理的是像素。服务端收到图片后,如果直接把 JPEG 的原始像素交给 OCR,而没有应用 EXIF Orientation,文字可能会横着或倒着进入识别模型。
正确的数据契约是:
text
客户端上传:原始像素轴 + 与像素匹配的 EXIF Orientation
↓
服务端解码:读取并应用 Orientation,得到视觉正向图片
↓
OCR:在正向图片上检测文字、识别和翻译
↓
译图:保持正确的像素与 EXIF 关系
方向处理只有两种自洽方式:
- 不旋转原始像素,保留原 Orientation;
- 物理旋正像素,同时把 Orientation 改为
ORIENTATION_NORMAL或移除。
不能物理旋正像素后仍保留原 Orientation,否则服务端或 Glide 会再次旋转。也不能保持原始像素轴却丢掉 Orientation,否则 OCR 会看到错误方向。
四、上传前压缩如何保留 OCR 方向
图片翻译通常有上传体积限制。实现中采用以下规则:
- 文件没有超过限制时,直接上传原文件,像素和完整 EXIF 都保持不变;
- 文件超过限制时,按 JPEG 质量优先压缩;
- 重编码后的文件只写回 OCR 需要的 Orientation;
- 当前尺寸最低质量仍超限时,再逐级等比缩小。
核心原则是:压缩过程中保持原始像素轴,不提前按 EXIF 旋转 Bitmap,然后把原 Orientation 写回新文件。
kotlin
suspend fun prepareImageForUpload(
context: Context,
sourceFile: File
): File = withContext(Dispatchers.IO) {
require(sourceFile.exists() && sourceFile.length() > 0L)
if (sourceFile.length() <= MAX_UPLOAD_SIZE) {
return@withContext sourceFile
}
val orientation = readExifOrientation(sourceFile)
compressWithoutChangingPixelAxis(
context = context,
sourceFile = sourceFile,
orientation = orientation
)
}
BitmapFactory.decodeFile() 在这条链路中负责解码存储像素,不主动根据 EXIF 旋正。Bitmap 重新编码为 JPEG 后元数据会丢失,因此需要把 Orientation 写回:
kotlin
private fun writeJpeg(
targetFile: File,
bitmap: Bitmap,
quality: Int,
orientation: Int
) {
FileOutputStream(targetFile).use { outputStream ->
check(
bitmap.compress(
Bitmap.CompressFormat.JPEG,
quality,
outputStream
)
)
}
ExifInterface(targetFile.absolutePath).apply {
setAttribute(
ExifInterface.TAG_ORIENTATION,
orientation.toString()
)
saveAttributes()
}
}
写入 EXIF 后要再次检查文件体积,因为 saveAttributes() 也会改变最终文件:
kotlin
writeJpeg(targetFile, bitmap, quality, orientation)
if (targetFile.length() in 1..MAX_UPLOAD_SIZE) {
return targetFile
}
targetFile.delete()
这里刻意只复制 Orientation,不把 GPS、设备型号等无关元数据写入压缩产物。没有经过重编码的小图则保持原文件字节,因此也会保留原文件已有的其他 EXIF 信息。
这意味着"小图原样上传"也可能同时上传 GPS、设备型号和拍摄时间。采用这种策略时,应确保服务端数据处理和隐私声明覆盖这些元数据;如果业务只需要 OCR 方向,真正不可缺少的字段只有 Orientation。
一个容易判断方向是否正确的方法
假设原 JPEG 的存储尺寸是 640 × 480,Orientation 是 6:
- 压缩后仍是
640 × 480,Orientation 仍是6:像素轴与 EXIF 保持一致; - 压缩后变成
480 × 640,Orientation 仍是6:很可能已经旋转像素又保留方向,会二次旋转; - 压缩后仍是
640 × 480,Orientation 丢失:OCR 很可能横着识别。
五、强制竖屏为什么拿不到正确的拍摄方向
图片翻译页常因交互布局固定为竖屏:
xml
<activity
android:name=".ImageTranslateActivity"
android:screenOrientation="portrait" />
不是读取 EXIF 失败,而是写入 EXIF 的方向依据失真
强制竖屏不会妨碍 ExifInterface 读取一张已有图片的 Orientation,也不会修改相册图片原本的 EXIF。
问题发生在拍摄新照片时。CameraX 保存 JPEG 前,需要根据相机传感器方向和 ImageCapture.targetRotation 计算输出方向,再决定怎样组织像素或写入旋转元数据:
text
相机 Sensor orientation + ImageCapture.targetRotation
↓
JPEG 像素与 EXIF Orientation
正常的可旋转页面中,用户横竖切换手机会带动 Display rotation 变化,应用可以把新的 rotation 传给 CameraX。但页面被锁定为竖屏后,Android 需要维持竖屏窗口,下面这些值通常只描述"当前竖屏窗口",不再描述手机真实的物理姿态:
| 信息来源 | 强制竖屏后的表现 |
|---|---|
resources.configuration.orientation |
始终是 ORIENTATION_PORTRAIT |
windowManager.defaultDisplay.rotation |
通常保持 Surface.ROTATION_0 |
previewView.display.rotation |
跟随锁定后的显示 Surface,通常也保持 0° |
onConfigurationChanged() |
横持手机时不会得到一次正常的横屏布局切换 |
因此,下面这类常见写法在强制竖屏页面里只能得到窗口方向:
kotlin
imageCapture.targetRotation =
previewView.display?.rotation ?: Surface.ROTATION_0
假设用户把手机逆时针横持 90°,页面和 previewView.display.rotation 仍然是 ROTATION_0。CameraX 收到的 targetRotation 没有变化,就会继续按竖持状态计算 JPEG 方向。最终文件的像素可能是横着的,但 EXIF 却无法表达用户这次真实的横持姿态。Glide 和服务端 OCR 即使完全按照 EXIF 工作,也只能忠实地应用这份错误或过期的方向信息。
所以更准确的说法不是"强制竖屏读不到正确 EXIF",而是:
text
强制竖屏切断了物理持机方向到 Display rotation 的传递;
CameraX 使用固定的 targetRotation,因而无法生成与真实拍摄姿态一致的方向信息。
EXIF 不能反过来告诉 CameraX 用户怎样拿手机
EXIF 是拍照输出的一部分,只有文件保存后才能读取。它不是一个实时方向传感器,不能在按快门前用来决定本次照片应该写什么方向。
如果拍完后才发现 Orientation 不正确,再凭 JPEG 宽高猜测用户横持方向也不可靠:后置相机传感器可能天然是横向安装,CameraX 可能旋转像素,也可能使用元数据表达方向,而且 4032 × 3024 无法区分用户是向左横持还是向右横持。
因此,拍摄前必须从独立于 Display 的传感器通道获取物理方向。
Android 的 OrientationEventListener 封装了底层方向传感器计算,可以根据重力方向返回 0..359 的设备角度。业务层不需要直接处理加速度计的三轴值,只要把角度归入四个稳定象限。
六、把物理方向映射为 CameraX targetRotation
OrientationEventListener 的角度方向与 CameraX 使用的 Surface rotation 映射不是简单同值。四个稳定方向使用下面的映射:
kotlin
fun resolveTargetRotation(
orientationDegrees: Int,
fallbackRotation: Int
): Int {
if (orientationDegrees !in 0..359) {
return fallbackRotation
}
return when (orientationDegrees) {
in 45..134 -> Surface.ROTATION_270
in 135..224 -> Surface.ROTATION_180
in 225..314 -> Surface.ROTATION_90
else -> Surface.ROTATION_0
}
}
对应关系如下:
| 方向监听角度 | CameraX targetRotation |
|---|---|
| 315°..359°、0°..44° | Surface.ROTATION_0 |
| 45°..134° | Surface.ROTATION_270 |
| 135°..224° | Surface.ROTATION_180 |
| 225°..314° | Surface.ROTATION_90 |
在象限边界附近,角度可能抖动。这里按最新稳定象限更新;当监听器返回 ORIENTATION_UNKNOWN 时,沿用上一次有效值,而不是随机回到 0°。
kotlin
private var captureTargetRotation = Surface.ROTATION_0
private val orientationListener = object : OrientationEventListener(this) {
override fun onOrientationChanged(orientation: Int) {
val nextRotation = resolveTargetRotation(
orientationDegrees = orientation,
fallbackRotation = captureTargetRotation
)
if (nextRotation == captureTargetRotation) return
captureTargetRotation = nextRotation
imageCapture?.targetRotation = nextRotation
}
}
监听器只在页面前台运行:
kotlin
override fun onResume() {
super.onResume()
orientationListener.enable()
}
override fun onPause() {
orientationListener.disable()
super.onPause()
}
设备不支持方向检测时,继续使用竖屏方向,不能因此阻断基础拍照能力。
七、让 CameraX 写入正确的 EXIF
创建 ImageCapture 时就设置最近一次稳定方向:
kotlin
val imageCapture = ImageCapture.Builder()
.setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
.setTargetRotation(captureTargetRotation)
.build()
按下快门时再次设置,并冻结本次拍摄方向:
kotlin
private fun takePhoto(outputFile: File) {
val frozenTargetRotation = captureTargetRotation
val displayRotationDegrees = when (frozenTargetRotation) {
Surface.ROTATION_90 -> 90
Surface.ROTATION_180 -> 180
Surface.ROTATION_270 -> 270
else -> 0
}
imageCapture.targetRotation = frozenTargetRotation
val outputOptions = ImageCapture.OutputFileOptions.Builder(outputFile)
.build()
imageCapture.takePicture(
outputOptions,
cameraExecutor,
object : ImageCapture.OnImageSavedCallback {
override fun onImageSaved(
result: ImageCapture.OutputFileResults
) {
runOnUiThread {
showCapturedImage(
file = outputFile,
displayRotationDegrees = displayRotationDegrees
)
uploadForTranslation(outputFile)
}
}
override fun onError(exception: ImageCaptureException) = Unit
}
)
}
这里不是手动计算 TAG_ORIENTATION=6 或 8 再写入照片。物理方向先转换成 CameraX 的 targetRotation,CameraX 在保存 JPEG 时负责让输出像素和 EXIF Orientation 保持一致。
方向必须在快门时冻结。拍摄完成后用户可能已经转动手机,如果展示原图和译图时继续读取实时传感器角度,两张静态图会跟着手机变化,甚至与本次照片的 EXIF 失去对应关系。
八、强制竖屏时 ImageView 如何适配 EXIF
强制竖屏下的静态图展示包含两个独立步骤:
- Glide 按文件自己的 EXIF 解码,得到视觉正向的 Drawable;
ImageView.rotation使用快门时冻结的角度,让图片在锁定竖屏的页面中仍朝向当时横持或倒持手机的用户。
kotlin
Glide.with(imageView)
.load(imageFile)
.into(imageView)
imageView.rotation = displayRotationDegrees.toFloat()
第二步不是再次处理 EXIF。它处理的是两个坐标系之间的差异:
text
EXIF / Glide → 文件像素坐标系中的正确方向
ImageView → 强制竖屏页面坐标系中的用户观看方向
为什么 90° 和 270° 要交换 ImageView 宽高
View.rotation 只改变绘制变换,不会触发父布局按旋转后的包围盒重新测量。
如果一个 match_parent × match_parent 的 ImageView 直接旋转 90°,它仍然保留旋转前的布局宽高。容器不是正方形时,横图就可能被缩窄或裁切。
可以在旋转 90° 或 270° 时,先交换 ImageView 的布局宽高,再绕中心旋转:
kotlin
private fun applyDisplayRotation(
container: FrameLayout,
imageView: ImageView,
rotationDegrees: Int
) {
container.doOnLayout {
val swapDimensions =
rotationDegrees == 90 || rotationDegrees == 270
imageView.updateLayoutParams<FrameLayout.LayoutParams> {
width = if (swapDimensions) {
container.height
} else {
ViewGroup.LayoutParams.MATCH_PARENT
}
height = if (swapDimensions) {
container.width
} else {
ViewGroup.LayoutParams.MATCH_PARENT
}
gravity = Gravity.CENTER
}
imageView.rotation = rotationDegrees.toFloat()
}
}
配合 fitCenter,同一个 ImageView 就能覆盖 0°、90°、180°、270°,不需要准备横竖两套布局:
xml
<ImageView
android:id="@+id/static_image"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="fitCenter" />
相册图片没有本次快门时的物理方向,因此额外 View 旋转使用 0°,只让 Glide 根据文件自身 EXIF 正常展示。
九、服务端译图返回后的方向处理
服务端返回的译图字节直接写入缓存,不在客户端重新编码:
kotlin
suspend fun writeResultToCache(
targetFile: File,
imageBytes: ByteArray
): File = withContext(Dispatchers.IO) {
require(imageBytes.isNotEmpty())
FileOutputStream(targetFile).use { outputStream ->
outputStream.write(imageBytes)
}
targetFile
}
这样可以避免缓存过程破坏服务端返回图片的像素和 EXIF。展示时仍然交给 Glide:
kotlin
Glide.with(resultImageView)
.load(translatedFile)
.into(resultImageView)
如果译图保留了 Orientation,Glide 会按它解码;如果服务端已经物理旋正像素并移除了 Orientation,Glide 也会按普通正向图片显示。
对于拍照来源,原图和译图共用快门时冻结的 displayRotationDegrees,保证用户在强制竖屏页面里切换两张图时方向一致。相册来源则统一使用 0° 的额外 View 旋转。
十、EXIF 错误如何影响下载和分享
图片在当前页面里看起来正确,不代表下载或分享出去的文件也是正确的。
这是因为 ImageView.rotation 只作用于当前应用的 View:
text
ImageView 中看起来正确
≠
文件像素和 EXIF 一定正确
用户下载图片或通过 FileProvider 分享时,传递给外部应用的是图片文件,而不是当前 ImageView 的绘制结果。ImageView 的 rotation、缩放和布局宽高都不会写回 JPEG。
kotlin
fun shareImage(
activity: Activity,
imageFile: File,
authority: String,
mimeType: String
) {
val imageUri = FileProvider.getUriForFile(
activity,
authority,
imageFile
)
val intent = Intent(Intent.ACTION_SEND).apply {
type = mimeType
putExtra(Intent.EXTRA_STREAM, imageUri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
activity.startActivity(Intent.createChooser(intent, null))
}
这段分享代码只是授予接收方读取原文件的权限,不会替图片修复方向。如果文件 EXIF 不正确,用户可能遇到以下体验:
- 系统相册或文件管理器中的缩略图横着、倒着;
- 聊天软件预览正常,但发送完成后的大图方向错误;
- 某些社交平台上传时移除 EXIF,原本依靠错误或残缺元数据勉强显示的图片永久变横;
- 图片编辑器按错误 Orientation 打开,裁剪框和文字方向不一致;
- 再次导出、压缩或转码后,不同应用得到不同方向;
- 用户保存服务端译图后,译文虽然正确,但整张图片需要手动旋转才能使用。
不同应用对 EXIF 的处理并不完全一致:有的在解码时应用 Orientation,有的会在上传时物理旋正并删除 EXIF,还有的直接剥离元数据但不旋转像素。因此,错误文件可能在 A 应用里正常、在 B 应用里横倒,造成非常不稳定的使用体验。
为什么应用内的额外旋转可能掩盖问题
假设文件的像素和 EXIF 本身不匹配,但页面又执行了:
kotlin
imageView.rotation = 90f
图片可能恰好在当前页面中转正,让问题在测试时不容易被发现。一旦分享原文件,外部应用不会知道这次 View 旋转,错误方向就会重新出现。
所以验收图片方向时,不能只看应用内的 ImageView,还要检查真正被上传、下载和分享的文件。
像素与 Orientation 的结果组合
| 文件像素 | EXIF Orientation | 应用内与外部使用结果 |
|---|---|---|
| 已物理旋正 | NORMAL 或缺失 |
最终文件方向自洽 |
| 保持相机原始像素轴 | 与像素匹配 | 支持 EXIF 的解码器可正确显示 |
| 已物理旋正 | 仍保留原旋转值 | 解码器会二次旋转 |
| 保持原始像素轴 | NORMAL、缺失或错误 |
图片横倒,OCR 和外部应用都可能出错 |
本文采用"保持像素轴并保留正确 Orientation"。服务端下载得到的译图则原样落盘,分享时也直接分享该文件。因此,服务端返回结果必须继续满足"像素与 Orientation 成对正确"这一条件。
下载和分享前应验证什么
至少要覆盖四种拍摄方向,并分别验证:
- 原图在 Glide 中显示正确;
- 上传文件的原始像素宽高与 Orientation 匹配;
- 服务端 OCR 识别方向正确;
- 下载后的译图在系统相册中方向正确;
- 译图分享至至少一个聊天应用和一个图片编辑器后方向正确;
- 接收方再次保存或转码后,图片仍没有发生二次旋转。
这一步能发现仅靠应用内 ImageView 无法暴露的问题。
十一、完整链路
最终的方向链路可以概括为:
text
OrientationEventListener 感知手机物理方向
↓
四象限映射为 CameraX targetRotation
↓
按下快门,冻结 targetRotation 和页面显示角度
↓
CameraX 保存像素与 EXIF 一致的 JPEG
↓
上传前若重编码,只复制 EXIF Orientation
↓
服务端按 Orientation 旋正后进行 OCR 和翻译
↓
译图原始字节落盘,不在客户端二次重编码
├───────────┴───────────┐
↓ ↓
Glide 按 EXIF 解码 下载或 FileProvider 分享原文件
↓ ↓
ImageView 补偿页面角度 外部应用按各自策略处理 EXIF
这套实现最重要的原则是:EXIF 负责描述文件像素,Glide 负责把文件解码正确,ImageView 负责页面坐标系中的展示,服务端 OCR 负责在识别前应用文件方向。四层各自只处理自己的职责,就不会发生丢失方向或重复旋转。
总结
图片方向不是一个单独的角度,而是一份贯穿客户端、文件和服务端的契约:
- EXIF Orientation 描述原始像素应如何变换后显示;
- Glide 会读取图片 EXIF,ImageView 本身不会;
ImageView.rotation是额外的界面变换,不会修改 EXIF;- Bitmap 重编码会丢失 EXIF,上传前必须显式保留 Orientation;
- 服务端必须应用 EXIF 后再进行 OCR;
- 强制竖屏时要独立感知物理方向,并把它映射为 CameraX
targetRotation; - 90°/270° 展示时先交换 ImageView 宽高,再执行 View 旋转;
- 原图和译图应共用快门时冻结的页面显示角度;
- 下载与分享传递的是文件,ImageView 的旋转不会随文件传出去。
只要始终分清"文件像素方向""EXIF 显示方向"和"View 页面方向",图片翻译链路中的旋转问题就会从一组偶发现象,变成一套可以验证的确定性规则。