HarmonyOS开发中的EXIF信息:数据读取、GPS定位、拍摄参数、修改与隐私脱敏
核心要点:掌握 HarmonyOS EXIF 数据读取与修改 API,理解 EXIF 标准结构,熟练处理 GPS 信息与拍摄参数,实现完整的隐私脱敏方案
一、背景与动机
你拍了一张美美的自拍发到朋友圈,可能没想到——这张照片里藏着一堆"秘密":你用的什么手机、什么时候拍的、甚至你在哪里拍的(精确到经纬度)。这些信息就是 EXIF(Exchangeable Image File Format)数据。
EXIF 的初衷是好的——帮你记录照片的拍摄信息,方便日后整理和查看。但在互联网时代,它也成了隐私泄露的"后门"。想象一下,你在家拍了张照片发到网上,别人通过 EXIF 里的 GPS 信息就能知道你家住哪儿——这可不是闹着玩的。
作为开发者,我们有两重责任:一是正确读取和展示 EXIF 信息(比如图片详情页显示拍摄参数),二是在必要的时候脱敏(比如用户分享图片前清除 GPS 信息)。今天我们就来深入 EXIF 的世界,从数据结构到 API 使用,从信息读取到隐私脱敏,一网打尽。
二、核心原理
2.1 EXIF 数据结构
EXIF 数据嵌入在图像文件的头部,采用 TIFF 格式存储。它的结构是一个树形的 IFD(Image File Directory)体系:
2.2 常用 EXIF Tag 速查
| Tag ID | 名称 | 类型 | 说明 |
|---|---|---|---|
| 0x010F | Make | ASCII | 相机厂商 |
| 0x0110 | Model | ASCII | 相机型号 |
| 0x0112 | Orientation | SHORT | 拍摄方向(1-8) |
| 0x011A | XResolution | RATIONAL | X方向分辨率 |
| 0x011B | YResolution | RATIONAL | Y方向分辨率 |
| 0x0132 | DateTime | ASCII | 拍摄时间 |
| 0x829A | ExposureTime | RATIONAL | 曝光时间(秒) |
| 0x829D | FNumber | RATIONAL | 光圈F值 |
| 0x8827 | ISOSpeedRatings | SHORT | ISO感光度 |
| 0x920A | FocalLength | RATIONAL | 焦距(mm) |
| 0x9209 | Flash | SHORT | 闪光灯状态 |
| 0xA405 | FocalLengthIn35mmFilm | SHORT | 35mm等效焦距 |
| 0xA433 | LensMake | ASCII | 镜头厂商 |
| 0xA434 | LensModel | ASCII | 镜头型号 |
| 0x0001 | GPSLatitudeRef | ASCII | 纬度方向(N/S) |
| 0x0002 | GPSLatitude | RATIONAL | 纬度值 |
| 0x0003 | GPSLongitudeRef | ASCII | 经度方向(E/W) |
| 0x0004 | GPSLongitude | RATIONAL | 经度值 |
| 0x0005 | GPSAltitudeRef | BYTE | 海拔方向 |
| 0x0006 | GPSAltitude | RATIONAL | 海拔值 |
2.3 Orientation 方向值
手机拍照时,传感器记录了手机的握持方向,存储在 Orientation 字段中:
| 值 | 含义 | 操作 |
|---|---|---|
| 1 | 正常 | 无需旋转 |
| 2 | 水平翻转 | flip(true, false) |
| 3 | 旋转180° | rotate(180) |
| 4 | 垂直翻转 | flip(false, true) |
| 5 | 旋转90°+水平翻转 | rotate(90) + flip(true, false) |
| 6 | 旋转90° | rotate(90) |
| 7 | 旋转270°+水平翻转 | rotate(270) + flip(true, false) |
| 8 | 旋转270° | rotate(270) |
2.4 GPS 坐标格式
EXIF 中的 GPS 坐标使用度分秒(DMS)格式,而非常见的十进制度数(DD)格式。转换公式:
DD = 度 + 分/60 + 秒/3600例如:北纬 39°54'23.76" = 39 + 54/60 + 23.76/3600 = 39.9066°
三、代码实战
3.1 EXIF 数据读取
import { image } from '@kit.ImageKit';
import { fileIo as fs } from '@kit.CoreFileKit';
@Entry
@Component
struct ExifReadPage {
@State exifInfo: Map<string, string> = new Map();
@State exifSummary: string = '等待读取...';
@State pixelMap: PixelMap | null = null;
build() {
Scroll() {
Column({ space: 16 }) {
// 图片预览
if (this.pixelMap) {
Image(this.pixelMap)
.width(280)
.height(200)
.objectFit(ImageFit.Contain)
.border({ width: 1, color: '#444444' })
}
// EXIF 摘要
Text(this.exifSummary)
.fontSize(13)
.fontColor('#CCCCCC')
.lineHeight(20)
.width('100%')
// 详细 EXIF 列表
Text('完整 EXIF 数据')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.width('100%')
List({ space: 4 }) {
ForEach(Array.from(this.exifInfo.entries()), (entry: [string, string]) => {
ListItem() {
Row() {
Text(entry[0])
.fontSize(12)
.fontColor('#999999')
.width('40%')
Text(entry[1])
.fontSize(12)
.fontColor('#E0E0E0')
.width('60%')
.maxLines(2)
}
.width('100%')
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
}
})
}
.width('100%')
.height(300)
.border({ width: 1, color: '#333333', radius: 8 })
Button('读取 EXIF 信息')
.width('80%')
.onClick(() => this.readExif())
}
.width('100%')
.padding(20)
}
}
/**
* 读取图片的 EXIF 信息
*/
async readExif() {
try {
const filePath = this.getImagePath();
const file = fs.openSync(filePath, fs.OpenMode.READ_ONLY);
const imageSource = image.createImageSource(file.fd);
// 同时解码图片用于预览
this.pixelMap = await imageSource.createPixelMap({
editable: false,
desiredPixelFormat: image.PixelMapFormat.RGBA_8888,
});
// 读取 EXIF 信息
await this.readExifData(imageSource);
imageSource.release();
fs.closeSync(file);
} catch (error) {
this.exifSummary = `读取失败: ${(error as Error).message}`;
}
}
/**
* 从 ImageSource 读取 EXIF 数据
*/
async readExifData(imageSource: image.ImageSource) {
const exifMap = new Map<string, string>();
// 定义要读取的 EXIF 字段
const exifFields: Array<{ key: string; tag: string; label: string }> = [
// 基础信息
{ key: 'BitsPerSample', tag: 'BitsPerSample', label: '位深度' },
{ key: 'Orientation', tag: 'Orientation', label: '拍摄方向' },
{ key: 'ImageWidth', tag: 'ImageWidth', label: '图像宽度' },
{ key: 'ImageLength', tag: 'ImageLength', label: '图像高度' },
// 相机信息
{ key: 'Make', tag: 'Make', label: '相机厂商' },
{ key: 'Model', tag: 'Model', label: '相机型号' },
{ key: 'Software', tag: 'Software', label: '软件' },
// 时间
{ key: 'DateTime', tag: 'DateTime', label: '拍摄时间' },
{ key: 'DateTimeOriginal', tag: 'DateTimeOriginal', label: '原始时间' },
// 拍摄参数
{ key: 'ExposureTime', tag: 'ExposureTime', label: '曝光时间' },
{ key: 'FNumber', tag: 'FNumber', label: '光圈' },
{ key: 'ISOSpeedRatings', tag: 'ISOSpeedRatings', label: 'ISO' },
{ key: 'FocalLength', tag: 'FocalLength', label: '焦距' },
{ key: 'Flash', tag: 'Flash', label: '闪光灯' },
{ key: 'WhiteBalance', tag: 'WhiteBalance', label: '白平衡' },
{ key: 'ExposureMode', tag: 'ExposureMode', label: '曝光模式' },
{ key: 'ExposureProgram', tag: 'ExposureProgram', label: '曝光程序' },
{ key: 'MeteringMode', tag: 'MeteringMode', label: '测光模式' },
// GPS
{ key: 'GPSLatitude', tag: 'GPSLatitude', label: 'GPS纬度' },
{ key: 'GPSLongitude', tag: 'GPSLongitude', label: 'GPS经度' },
{ key: 'GPSAltitude', tag: 'GPSAltitude', label: 'GPS海拔' },
// 镜头
{ key: 'LensMake', tag: 'LensMake', label: '镜头厂商' },
{ key: 'LensModel', tag: 'LensModel', label: '镜头型号' },
];
let summary = '';
for (const field of exifFields) {
try {
const value = await imageSource.getImageProperty(field.key);
if (value && value.length > 0) {
exifMap.set(field.label, this.formatExifValue(field.key, value));
// 构建摘要
if (['Make', 'Model', 'DateTime', 'FNumber', 'ISOSpeedRatings', 'FocalLength'].includes(field.key)) {
summary += `${field.label}: ${this.formatExifValue(field.key, value)}\n`;
}
}
} catch (_e) {
// 该字段不存在,跳过
}
}
this.exifInfo = exifMap;
this.exifSummary = summary || '未找到 EXIF 信息';
}
/**
* 格式化 EXIF 值为可读文本
*/
private formatExifValue(key: string, value: string): string {
switch (key) {
case 'Orientation':
const orientMap: Record<string, string> = {
'1': '正常', '2': '水平翻转', '3': '旋转180°',
'4': '垂直翻转', '5': '旋转90°+水平翻转',
'6': '旋转90°', '7': '旋转270°+水平翻转', '8': '旋转270°'
};
return `${value} (${orientMap[value] || '未知'})`;
case 'ExposureTime':
// 曝光时间:1/100 秒
const expNum = parseFloat(value);
if (expNum > 0 && expNum < 1) {
return `1/${Math.round(1 / expNum)} 秒`;
}
return `${expNum} 秒`;
case 'FNumber':
return `f/${parseFloat(value).toFixed(1)}`;
case 'FocalLength':
return `${parseFloat(value).toFixed(1)} mm`;
case 'GPSLatitude':
case 'GPSLongitude':
return this.formatGpsCoordinate(value, key);
case 'Flash':
const flashVal = parseInt(value);
return flashVal === 0 ? '未闪光' : `闪光 (0x${flashVal.toString(16)})`;
case 'WhiteBalance':
return value === '0' ? '自动' : '手动';
case 'ExposureMode':
return value === '0' ? '自动曝光' : '手动曝光';
case 'MeteringMode':
const meterMap: Record<string, string> = {
'0': '未知', '1': '平均', '2': '中央重点',
'3': '点测光', '4': '多点测光', '5': '矩阵测光'
};
return meterMap[value] || `模式${value}`;
default:
return value;
}
}
/**
* 格式化 GPS 坐标
* EXIF中的GPS格式为 "度,分,秒" 如 "39/1,54/1,2376/100"
*/
private formatGpsCoordinate(value: string, key: string): string {
try {
const parts = value.split(',');
if (parts.length === 3) {
const d = this.parseRational(parts[0]);
const m = this.parseRational(parts[1]);
const s = this.parseRational(parts[2]);
// DMS → DD
const dd = d + m / 60 + s / 3600;
const direction = key === 'GPSLatitude' ? 'N' : 'E';
return `${dd.toFixed(6)}° ${direction} (${d}°${m}'${s.toFixed(2)}")`;
}
} catch (_e) {
// 解析失败,返回原始值
}
return value;
}
/**
* 解析 EXIF 有理数格式 "分子/分母"
*/
private parseRational(str: string): number {
const trimmed = str.trim();
if (trimmed.includes('/')) {
const [num, den] = trimmed.split('/').map(Number);
return den !== 0 ? num / den : 0;
}
return parseFloat(trimmed);
}
getImagePath(): string {
return getContext(this).filesDir + '/test_photo.jpg';
}
}3.2 GPS 信息解析与地图展示
import { image } from '@kit.ImageKit';
import { fileIo as fs } from '@kit.CoreFileKit';
/**
* GPS 信息解析工具
*/
export class GpsInfoParser {
/**
* 从 ImageSource 提取 GPS 经纬度
* 返回十进制度数格式的坐标
*/
static async extractGpsCoordinate(
imageSource: image.ImageSource
): Promise<GpsCoordinate | null> {
try {
// 读取纬度
const latStr = await imageSource.getImageProperty('GPSLatitude');
const latRef = await imageSource.getImageProperty('GPSLatitudeRef');
// 读取经度
const lonStr = await imageSource.getImageProperty('GPSLongitude');
const lonRef = await imageSource.getImageProperty('GPSLongitudeRef');
// 解析纬度
const lat = this.parseDmsToDd(latStr);
const latitude = latRef === 'S' ? -lat : lat;
// 解析经度
const lon = this.parseDmsToDd(lonStr);
const longitude = lonRef === 'W' ? -lon : lon;
// 尝试读取海拔
let altitude: number | null = null;
try {
const altStr = await imageSource.getImageProperty('GPSAltitude');
altitude = this.parseRational(altStr);
} catch (_e) {
// 海拔信息可能不存在
}
return { latitude, longitude, altitude };
} catch (_e) {
return null; // GPS信息不存在
}
}
/**
* DMS格式转十进制度数
* 输入格式: "39/1,54/1,2376/100"
*/
private static parseDmsToDd(dmsStr: string): number {
const parts = dmsStr.split(',');
if (parts.length !== 3) return 0;
const d = this.parseRational(parts[0]);
const m = this.parseRational(parts[1]);
const s = this.parseRational(parts[2]);
return d + m / 60 + s / 3600;
}
/**
* 解析 EXIF 有理数
*/
private static parseRational(str: string): number {
const trimmed = str.trim();
if (trimmed.includes('/')) {
const [num, den] = trimmed.split('/').map(Number);
return den !== 0 ? num / den : 0;
}
return parseFloat(trimmed);
}
/**
* 将 GPS 坐标格式化为可读字符串
*/
static formatCoordinate(coord: GpsCoordinate): string {
const latDir = coord.latitude >= 0 ? 'N' : 'S';
const lonDir = coord.longitude >= 0 ? 'E' : 'W';
let result = `${Math.abs(coord.latitude).toFixed(6)}° ${latDir}, ` +
`${Math.abs(coord.longitude).toFixed(6)}° ${lonDir}`;
if (coord.altitude !== null) {
result += `\n海拔: ${coord.altitude.toFixed(1)} m`;
}
return result;
}
/**
* 生成地图链接(高德地图)
*/
static generateMapUrl(coord: GpsCoordinate): string {
return `https://uri.amap.com/marker?position=${coord.longitude},${coord.latitude}&name=拍摄地点`;
}
}
/**
* GPS 坐标
*/
interface GpsCoordinate {
latitude: number; // 纬度(十进制度数)
longitude: number; // 经度(十进制度数)
altitude: number | null; // 海拔(米),可能为null
}
/**
* 完整的 EXIF 信息读取页面(含GPS展示)
*/
@Entry
@Component
struct ExifGpsPage {
@State exifData: Map<string, string> = new Map();
@State gpsInfo: string = '未检测到GPS信息';
@State mapUrl: string = '';
@State cameraInfo: string = '';
@State shootParams: string = '';
build() {
Scroll() {
Column({ space: 16 }) {
// 相机信息卡片
this.InfoCard('📷 相机信息', this.cameraInfo)
// 拍摄参数卡片
this.InfoCard('⚙️ 拍摄参数', this.shootParams)
// GPS 信息卡片
this.InfoCard('📍 GPS信息', this.gpsInfo)
if (this.mapUrl) {
Button('在地图中查看')
.width('80%')
.onClick(() => {
// 打开地图链接(实际项目中可使用 Web 组件或地图SDK)
})
}
Button('读取 EXIF')
.width('80%')
.onClick(() => this.readFullExif())
}
.width('100%')
.padding(20)
}
}
@Builder
InfoCard(title: string, content: string) {
Column() {
Text(title)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.width('100%')
.margin({ bottom: 8 })
Text(content)
.fontSize(13)
.fontColor('#CCCCCC')
.lineHeight(20)
.width('100%')
}
.width('100%')
.padding(16)
.borderRadius(12)
.backgroundColor('#1A1A1A')
}
async readFullExif() {
try {
const filePath = getContext(this).filesDir + '/test_photo.jpg';
const file = fs.openSync(filePath, fs.OpenMode.READ_ONLY);
const imageSource = image.createImageSource(file.fd);
// 读取相机信息
const make = await this.safeGetProperty(imageSource, 'Make');
const model = await this.safeGetProperty(imageSource, 'Model');
const software = await this.safeGetProperty(imageSource, 'Software');
const lensMake = await this.safeGetProperty(imageSource, 'LensMake');
const lensModel = await this.safeGetProperty(imageSource, 'LensModel');
this.cameraInfo = '';
if (make) this.cameraInfo += `厂商: ${make}\n`;
if (model) this.cameraInfo += `型号: ${model}\n`;
if (software) this.cameraInfo += `软件: ${software}\n`;
if (lensMake) this.cameraInfo += `镜头厂商: ${lensMake}\n`;
if (lensModel) this.cameraInfo += `镜头型号: ${lensModel}\n`;
if (!this.cameraInfo) this.cameraInfo = '无相机信息';
// 读取拍摄参数
const dateTime = await this.safeGetProperty(imageSource, 'DateTime');
const exposureTime = await this.safeGetProperty(imageSource, 'ExposureTime');
const fNumber = await this.safeGetProperty(imageSource, 'FNumber');
const iso = await this.safeGetProperty(imageSource, 'ISOSpeedRatings');
const focalLength = await this.safeGetProperty(imageSource, 'FocalLength');
const flash = await this.safeGetProperty(imageSource, 'Flash');
this.shootParams = '';
if (dateTime) this.shootParams += `时间: ${dateTime}\n`;
if (exposureTime) this.shootParams += `快门: ${exposureTime}s\n`;
if (fNumber) this.shootParams += `光圈: f/${parseFloat(fNumber).toFixed(1)}\n`;
if (iso) this.shootParams += `ISO: ${iso}\n`;
if (focalLength) this.shootParams += `焦距: ${parseFloat(focalLength).toFixed(1)}mm\n`;
if (flash) this.shootParams += `闪光灯: ${parseInt(flash) === 0 ? '关' : '开'}\n`;
if (!this.shootParams) this.shootParams = '无拍摄参数';
// 读取GPS信息
const gpsCoord = await GpsInfoParser.extractGpsCoordinate(imageSource);
if (gpsCoord) {
this.gpsInfo = GpsInfoParser.formatCoordinate(gpsCoord);
this.mapUrl = GpsInfoParser.generateMapUrl(gpsCoord);
} else {
this.gpsInfo = '未检测到GPS信息';
this.mapUrl = '';
}
imageSource.release();
fs.closeSync(file);
} catch (error) {
this.cameraInfo = `读取失败: ${(error as Error).message}`;
}
}
/**
* 安全读取 EXIF 属性
* 属性不存在时不抛异常,返回 null
*/
private async safeGetProperty(
imageSource: image.ImageSource,
key: string
): Promise<string | null> {
try {
return await imageSource.getImageProperty(key);
} catch (_e) {
return null;
}
}
}3.3 EXIF 修改与隐私脱敏
import { image } from '@kit.ImageKit';
import { fileIo as fs } from '@kit.CoreFileKit';
/**
* EXIF 修改与隐私脱敏工具
*/
export class ExifPrivacyHelper {
/**
* 完整的隐私脱敏流程
* 清除所有可能泄露隐私的 EXIF 字段
*
* @param inputPath 输入图片路径
* @param outputPath 输出图片路径
* @param options 脱敏选项
*/
static async sanitizeExif(
inputPath: string,
outputPath: string,
options?: ExifSanitizeOptions
): Promise<SanitizeResult> {
const opts: ExifSanitizeOptions = {
removeGps: options?.removeGps ?? true, // 默认清除GPS
removeCameraInfo: options?.removeCameraInfo ?? false, // 默认保留相机信息
removeDateTime: options?.removeDateTime ?? false, // 默认保留时间
removeThumbnail: options?.removeThumbnail ?? true, // 默认清除缩略图
keepOrientation: options?.keepOrientation ?? true, // 默认保留方向信息
...options,
};
let file: fs.File | null = null;
let imageSource: image.ImageSource | null = null;
let pixelMap: PixelMap | null = null;
const removedFields: string[] = [];
try {
// 1. 打开文件并解码
file = fs.openSync(inputPath, fs.OpenMode.READ_ONLY);
imageSource = image.createImageSource(file.fd);
// 2. 读取原始方向信息(可能需要保留)
let orientation = 1;
if (opts.keepOrientation) {
try {
const orientStr = await imageSource.getImageProperty('Orientation');
orientation = parseInt(orientStr) || 1;
} catch (_e) {
// Orientation 不存在,默认为1
}
}
// 3. 解码为 PixelMap
pixelMap = await imageSource.createPixelMap({
editable: true,
desiredPixelFormat: image.PixelMapFormat.RGBA_8888,
rotate: 0, // 不自动旋转,保留原始像素
});
// 4. 如果需要保留方向信息,手动旋转像素数据
if (opts.keepOrientation && orientation !== 1) {
await this.applyOrientation(pixelMap, orientation);
removedFields.push(`Orientation: ${orientation} → 已应用到像素`);
}
// 5. 编码保存(重新编码会丢失大部分 EXIF 信息)
const packer = image.createImagePacker();
const packData = await packer.packing(pixelMap, {
format: 'image/jpeg',
quality: 95,
});
// 6. 写入输出文件
const outFile = fs.openSync(outputPath, fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE);
fs.writeSync(outFile.fd, packData);
fs.closeSync(outFile);
// 7. 记录被脱敏的字段
if (opts.removeGps) {
removedFields.push('GPS信息(经纬度、海拔)');
}
if (opts.removeCameraInfo) {
removedFields.push('相机信息(厂商、型号、镜头)');
}
if (opts.removeDateTime) {
removedFields.push('拍摄时间');
}
if (opts.removeThumbnail) {
removedFields.push('缩略图');
}
packer.release();
// 8. 如果需要修改特定 EXIF 字段(API 13+)
// 重新打开输出文件修改 EXIF
if (opts.customFields && Object.keys(opts.customFields).length > 0) {
await this.modifyExifFields(outputPath, opts.customFields);
for (const [key, value] of Object.entries(opts.customFields)) {
removedFields.push(`修改: ${key} = ${value}`);
}
}
return {
success: true,
removedFields: removedFields,
outputPath: outputPath,
};
} catch (error) {
return {
success: false,
removedFields: removedFields,
error: (error as Error).message,
};
} finally {
pixelMap?.release();
imageSource?.release();
if (file) fs.closeSync(file.fd);
}
}
/**
* 根据 Orientation 值旋转 PixelMap
* 将方向信息"烧录"到像素数据中
*/
private static async applyOrientation(
pixelMap: PixelMap,
orientation: number
): Promise<void> {
switch (orientation) {
case 2:
await pixelMap.flip(true, false);
break;
case 3:
await pixelMap.rotate(180);
break;
case 4:
await pixelMap.flip(false, true);
break;
case 5:
await pixelMap.rotate(90);
await pixelMap.flip(true, false);
break;
case 6:
await pixelMap.rotate(90);
break;
case 7:
await pixelMap.rotate(270);
await pixelMap.flip(true, false);
break;
case 8:
await pixelMap.rotate(270);
break;
}
}
/**
* 修改输出文件的 EXIF 字段
* 注意:此方法需要 API 13+ 的 modifyImageProperty 支持
*/
private static async modifyExifFields(
filePath: string,
fields: Record<string, string>
): Promise<void> {
const file = fs.openSync(filePath, fs.OpenMode.READ_WRITE);
const imageSource = image.createImageSource(file.fd);
for (const [key, value] of Object.entries(fields)) {
try {
// API 13+ 支持 modifyImageProperty
await imageSource.modifyImageProperty(key, value);
} catch (_e) {
console.warn(`修改 EXIF 字段失败: ${key}`);
}
}
imageSource.release();
fs.closeSync(file.fd);
}
/**
* 仅清除 GPS 信息
* 比完整脱敏更轻量,保留其他 EXIF 数据
*/
static async removeGpsOnly(inputPath: string, outputPath: string): Promise<SanitizeResult> {
return this.sanitizeExif(inputPath, outputPath, {
removeGps: true,
removeCameraInfo: false,
removeDateTime: false,
removeThumbnail: false,
keepOrientation: true,
});
}
/**
* 检查图片是否包含 GPS 信息
*/
static async hasGpsInfo(filePath: string): Promise<boolean> {
let file: fs.File | null = null;
let imageSource: image.ImageSource | null = null;
try {
file = fs.openSync(filePath, fs.OpenMode.READ_ONLY);
imageSource = image.createImageSource(file.fd);
await imageSource.getImageProperty('GPSLatitude');
return true; // 能读到就说明有GPS信息
} catch (_e) {
return false;
} finally {
imageSource?.release();
if (file) fs.closeSync(file.fd);
}
}
/**
* 获取 EXIF 隐私风险评估
* 返回图片中包含的隐私敏感字段列表
*/
static async assessPrivacyRisk(filePath: string): Promise<PrivacyRisk[]> {
const risks: PrivacyRisk[] = [];
let file: fs.File | null = null;
let imageSource: image.ImageSource | null = null;
try {
file = fs.openSync(filePath, fs.OpenMode.READ_ONLY);
imageSource = image.createImageSource(file.fd);
// 检查GPS信息
try {
const lat = await imageSource.getImageProperty('GPSLatitude');
if (lat) {
risks.push({
level: 'high',
field: 'GPS定位',
description: '包含精确的GPS坐标,可定位拍摄地点',
recommendation: '强烈建议清除GPS信息',
});
}
} catch (_e) { /* 无GPS */ }
// 检查拍摄时间
try {
const dt = await imageSource.getImageProperty('DateTime');
if (dt) {
risks.push({
level: 'medium',
field: '拍摄时间',
description: `拍摄于 ${dt}`,
recommendation: '根据场景决定是否清除',
});
}
} catch (_e) { /* 无时间 */ }
// 检查相机信息
try {
const model = await imageSource.getImageProperty('Model');
if (model) {
risks.push({
level: 'low',
field: '设备信息',
description: `使用 ${model} 拍摄`,
recommendation: '风险较低,一般可保留',
});
}
} catch (_e) { /* 无相机信息 */ }
// 检查缩略图
try {
const thumbSize = await imageSource.getImageProperty('ThumbnailLength');
if (thumbSize && parseInt(thumbSize) > 0) {
risks.push({
level: 'medium',
field: '缩略图',
description: 'EXIF中包含缩略图,可能包含原始画面',
recommendation: '建议清除缩略图',
});
}
} catch (_e) { /* 无缩略图 */ }
} finally {
imageSource?.release();
if (file) fs.closeSync(file.fd);
}
return risks;
}
}
// ==================== 类型定义 ====================
interface ExifSanitizeOptions {
removeGps?: boolean; // 清除GPS信息
removeCameraInfo?: boolean; // 清除相机信息
removeDateTime?: boolean; // 清除拍摄时间
removeThumbnail?: boolean; // 清除缩略图
keepOrientation?: boolean; // 保留方向信息(应用到像素)
customFields?: Record<string, string>; // 自定义修改的字段
}
interface SanitizeResult {
success: boolean;
removedFields: string[];
outputPath?: string;
error?: string;
}
interface PrivacyRisk {
level: 'high' | 'medium' | 'low';
field: string;
description: string;
recommendation: string;
}3.4 EXIF 脱敏的完整 UI 实现
import { image } from '@kit.ImageKit';
import { fileIo as fs } from '@kit.CoreFileKit';
import { picker } from '@kit.CoreFileKit';
@Entry
@Component
struct ExifSanitizePage {
@State pixelMap: PixelMap | null = null;
@State privacyRisks: PrivacyRisk[] = [];
@State sanitizeResult: string = '';
@State removeGps: boolean = true;
@State removeCameraInfo: boolean = false;
@State removeDateTime: boolean = false;
@State removeThumbnail: boolean = true;
@State keepOrientation: boolean = true;
@State selectedImagePath: string = '';
build() {
Scroll() {
Column({ space: 16 }) {
// 图片预览
if (this.pixelMap) {
Image(this.pixelMap)
.width(280)
.height(200)
.objectFit(ImageFit.Contain)
.border({ width: 1, color: '#444444' })
}
// 隐私风险评估
if (this.privacyRisks.length > 0) {
Text('⚠️ 隐私风险评估')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#FF9800')
.width('100%')
ForEach(this.privacyRisks, (risk: PrivacyRisk) => {
Row() {
Text(risk.level === 'high' ? '🔴' : risk.level === 'medium' ? '🟡' : '🟢')
.fontSize(16)
Column() {
Text(risk.field)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(risk.level === 'high' ? '#F44336' :
risk.level === 'medium' ? '#FF9800' : '#4CAF50')
Text(risk.description)
.fontSize(12)
.fontColor('#AAAAAA')
Text(risk.recommendation)
.fontSize(12)
.fontColor('#999999')
.fontStyle(FontStyle.Italic)
}
.layoutWeight(1)
.margin({ left: 8 })
}
.width('100%')
.padding(12)
.borderRadius(8)
.backgroundColor('#1A1A1A')
})
}
// 脱敏选项
Text('脱敏选项')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.width('100%')
Column({ space: 8 }) {
this.ToggleItem('清除GPS定位', this.removeGps, (v: boolean) => { this.removeGps = v })
this.ToggleItem('清除相机信息', this.removeCameraInfo, (v: boolean) => { this.removeCameraInfo = v })
this.ToggleItem('清除拍摄时间', this.removeDateTime, (v: boolean) => { this.removeDateTime = v })
this.ToggleItem('清除缩略图', this.removeThumbnail, (v: boolean) => { this.removeThumbnail = v })
this.ToggleItem('保留方向信息', this.keepOrientation, (v: boolean) => { this.keepOrientation = v })
}
.width('100%')
.padding(12)
.borderRadius(8)
.backgroundColor('#1A1A1A')
// 操作按钮
Row({ space: 12 }) {
Button('选择图片')
.layoutWeight(1)
.onClick(() => this.pickImage())
Button('开始脱敏')
.layoutWeight(1)
.backgroundColor('#F44336')
.onClick(() => this.doSanitize())
}
.width('100%')
// 脱敏结果
if (this.sanitizeResult) {
Text('脱敏结果')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.width('100%')
Text(this.sanitizeResult)
.fontSize(13)
.fontColor('#4CAF50')
.lineHeight(20)
.width('100%')
.padding(12)
.borderRadius(8)
.backgroundColor('#1A2E1A')
}
}
.width('100%')
.padding(20)
}
}
@Builder
ToggleItem(label: string, isOn: boolean, onChange: (value: boolean) => void) {
Row() {
Text(label)
.fontSize(14)
.layoutWeight(1)
Toggle({ type: ToggleType.Switch, isOn: isOn })
.onChange(onChange)
}
.width('100%')
}
/**
* 选择图片
*/
async pickImage() {
try {
const photoSelectOptions = new picker.PhotoSelectOptions();
photoSelectOptions.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE;
photoSelectOptions.maxSelectNumber = 1;
const photoViewPicker = new picker.PhotoViewPicker();
const result = await photoViewPicker.select(photoSelectOptions);
if (result.photoUris.length > 0) {
this.selectedImagePath = result.photoUris[0];
// 解码预览
const file = fs.openSync(this.selectedImagePath, fs.OpenMode.READ_ONLY);
const imageSource = image.createImageSource(file.fd);
this.pixelMap = await imageSource.createPixelMap({
editable: false,
desiredPixelFormat: image.PixelMapFormat.RGBA_8888,
});
imageSource.release();
fs.closeSync(file);
// 评估隐私风险
this.privacyRisks = await ExifPrivacyHelper.assessPrivacyRisk(this.selectedImagePath);
}
} catch (error) {
this.sanitizeResult = `选择失败: ${(error as Error).message}`;
}
}
/**
* 执行脱敏
*/
async doSanitize() {
if (!this.selectedImagePath) {
this.sanitizeResult = '请先选择图片';
return;
}
try {
const outputPath = getContext(this).filesDir + `/sanitized_${Date.now()}.jpg`;
const result = await ExifPrivacyHelper.sanitizeExif(
this.selectedImagePath,
outputPath,
{
removeGps: this.removeGps,
removeCameraInfo: this.removeCameraInfo,
removeDateTime: this.removeDateTime,
removeThumbnail: this.removeThumbnail,
keepOrientation: this.keepOrientation,
}
);
if (result.success) {
this.sanitizeResult = '✅ 脱敏成功!\n\n已清除以下信息:\n' +
result.removedFields.map(f => `• ${f}`).join('\n') +
`\n\n输出路径: ${result.outputPath}`;
} else {
this.sanitizeResult = `❌ 脱敏失败: ${result.error}`;
}
} catch (error) {
this.sanitizeResult = `脱敏异常: ${(error as Error).message}`;
}
}
}四、踩坑与注意事项
4.1 EXIF 字段不存在时的异常处理
getImageProperty() 在字段不存在时会抛异常,而不是返回 null 或空字符串。这在遍历多个字段时非常烦人——每个字段都要 try-catch。
// ❌ 繁琐:每个字段都 try-catch
let make = '';
try { make = await imageSource.getImageProperty('Make'); } catch (_e) {}
let model = '';
try { model = await imageSource.getImageProperty('Model'); } catch (_e) {}
// ✅ 优雅:封装安全读取方法
async function safeGetProperty(source: image.ImageSource, key: string): Promise<string | null> {
try {
return await source.getImageProperty(key);
} catch (_e) {
return null;
}
}
const make = await safeGetProperty(imageSource, 'Make');
const model = await safeGetProperty(imageSource, 'Model');4.2 重新编码会丢失 EXIF
这是最重要的一个坑。当你用 ImagePacker.packing() 重新编码图片时,原始的 EXIF 数据会被丢弃——新文件只包含像素数据,不包含任何 EXIF。
这既是"坑"也是"特性":
- 如果你要脱敏,重新编码就是最简单粗暴的脱敏方式
- 如果你要保留 EXIF,就必须在编码后手动写回
// 重新编码后 EXIF 丢失
const packer = image.createImagePacker();
const data = await packer.packing(pixelMap, { format: 'image/jpeg', quality: 95 });
// data 中不包含原始 EXIF 数据!4.3 Orientation 的自动旋转
createPixelMap() 默认会根据 EXIF 的 Orientation 值自动旋转图片。如果你需要获取原始像素数据(比如做方向检测),需要禁用自动旋转:
// 默认行为:自动旋转
const pixelMap = await imageSource.createPixelMap();
// 禁用自动旋转:保留原始像素方向
const pixelMap = await imageSource.createPixelMap({
rotate: 0, // 不自动旋转
editable: true,
});4.4 GPS 坐标的精度
EXIF 中的 GPS 坐标精度通常在几米到十几米之间,取决于拍摄时的 GPS 信号质量。室内拍摄的 GPS 精度可能很差,甚至完全错误。
建议:不要完全依赖 EXIF GPS 数据做精确定位,它只能作为参考。
4.5 PNG 文件的 EXIF
PNG 格式不支持 EXIF!PNG 使用的是 tEXt / iTXt 块来存储元数据,格式和 EXIF 完全不同。如果你对 PNG 文件调用 getImageProperty(),大部分 EXIF 字段都会读取失败。
WebP 文件支持 EXIF(通过 EXIF chunk),但支持程度取决于编码器。
4.6 modifyImageProperty 的限制
API 13+ 的 modifyImageProperty() 有以下限制:
- 只能修改已存在的字段,不能新增字段
- 修改后的值必须符合字段的数据类型
- 某些字段(如 GPS 坐标)的格式比较复杂,直接修改容易出错
- 修改操作是原地修改文件,不可逆
五、HarmonyOS 6 适配
5.1 EXIF 修改 API 增强
HarmonyOS 6 对 EXIF 修改 API 进行了增强,支持更多字段和更灵活的操作:
// API 14+ 批量修改 EXIF
await imageSource.modifyImageProperties({
'ImageDescription': '这是一张风景照',
'Copyright': '© 2026 My App',
'Artist': '摄影师姓名',
});
// API 14+ 删除指定 EXIF 字段
await imageSource.removeImageProperty('GPSLatitude');
await imageSource.removeImageProperty('GPSLongitude');5.2 EXIF 数据导出
HarmonyOS 6 支持将完整的 EXIF 数据导出为 JSON:
// API 14+ 导出完整 EXIF
const exifJson = await imageSource.getImageProperties();
// 返回所有 EXIF 字段的 JSON 对象5.3 版本差异速查
| 特性 | API 12 | API 13 | API 14 |
|---|---|---|---|
| getImageProperty 读取 | ✅ | ✅ | ✅ |
| modifyImageProperty 修改 | ❌ | ✅ | ✅ |
| 批量修改 modifyImageProperties | ❌ | ❌ | ✅ |
| 删除字段 removeImageProperty | ❌ | ❌ | ✅ |
| 导出全部 getImageProperties | ❌ | ❌ | ✅ |
| GPS 解析辅助 API | ❌ | 部分 | ✅ |
5.4 迁移指南
- EXIF 修改:API 12 只能通过"重新编码"方式修改 EXIF,API 13+ 可以直接修改
- GPS 解析:API 14 新增了
parseGpsCoordinate()辅助方法,不需要手动解析 DMS 格式 - 隐私脱敏:API 14 的
removeImageProperty()可以精确删除 GPS 字段,而不需要重新编码整个图片
六、总结
关键知识点回顾:
| 知识点 | 要点 |
|---|---|
| EXIF 结构 | IFD树形结构,包含主属性、拍摄参数、GPS信息 |
| 数据读取 | getImageProperty(),字段不存在会抛异常 |
| GPS 坐标 | DMS格式需转换为DD格式,精度有限 |
| Orientation | 1-8方向值,createPixelMap默认自动旋转 |
| EXIF 修改 | API 13+modifyImageProperty(),API 14+ 批量修改/删除 |
| 重新编码 | packing() 会丢失EXIF,既是坑也是脱敏手段 |
| 隐私脱敏 | GPS必须清除,方向信息建议保留(烧录到像素) |
| PNG 不支持 EXIF | PNG使用tEXt块,不是EXIF格式 |
| 隐私评估 | 检查GPS/时间/相机/缩略图,按风险等级分类 |
EXIF 信息是图像文件中一个容易被忽视但非常重要的部分。正确处理 EXIF,既能给用户提供丰富的图片信息展示,又能保护用户的隐私安全。至此,"图像处理上"系列5篇文章全部完成——从编解码到变换,从滤镜到裁剪缩放,再到EXIF信息,覆盖了图像处理的核心知识体系。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用。你还可以使用@来通知其他用户。