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)体系:

flowchart TB
    classDef primary fill:#4CAF50,stroke:#2E7D32,color:#fff,stroke-width:2px
    classDef warning fill:#FF9800,stroke:#E65100,color:#fff,stroke-width:2px
    classDef info fill:#2196F3,stroke:#1565C0,color:#fff,stroke-width:2px
    classDef error fill:#F44336,stroke:#C62828,color:#fff,stroke-width:2px
    classDef purple fill:#9C27B0,stroke:#6A1B9A,color:#fff,stroke-width:2px

    A[EXIF数据结构] --> B[IFD0<br/>主图像属性]
    A --> C[IFD1<br/>缩略图属性]
    A --> D[EXIF IFD<br/>拍摄参数]
    A --> E[GPS IFD<br/>定位信息]
    A --> F[Interop IFD<br/>互操作信息]

    B --> B1[图像宽度/高度]
    B --> B2[拍摄时间 DateTime]
    B --> B3[相机厂商 Make]
    B --> B4[相机型号 Model]
    B --> B5[方向 Orientation]

    D --> D1[曝光时间 ExposureTime]
    D --> D2[F值 FNumber]
    D --> D3[ISO感光度 ISOSpeedRatings]
    D --> D4[焦距 FocalLength]
    D --> D5[闪光灯 Flash]

    E --> E1[纬度 GPSLatitude]
    E --> E2[经度 GPSLongitude]
    E --> E3[海拔 GPSAltitude]
    E --> E4[时间戳 GPSTimeStamp]

    style A fill:#9C27B0,stroke:#6A1B9A,color:#fff,stroke-width:2px
    style B fill:#2196F3,stroke:#1565C0,color:#fff,stroke-width:2px
    style D fill:#4CAF50,stroke:#2E7D32,color:#fff,stroke-width:2px
    style E fill:#F44336,stroke:#C62828,color:#fff,stroke-width:2px

2.2 常用 EXIF Tag 速查

Tag ID名称类型说明
0x010FMakeASCII相机厂商
0x0110ModelASCII相机型号
0x0112OrientationSHORT拍摄方向(1-8)
0x011AXResolutionRATIONALX方向分辨率
0x011BYResolutionRATIONALY方向分辨率
0x0132DateTimeASCII拍摄时间
0x829AExposureTimeRATIONAL曝光时间(秒)
0x829DFNumberRATIONAL光圈F值
0x8827ISOSpeedRatingsSHORTISO感光度
0x920AFocalLengthRATIONAL焦距(mm)
0x9209FlashSHORT闪光灯状态
0xA405FocalLengthIn35mmFilmSHORT35mm等效焦距
0xA433LensMakeASCII镜头厂商
0xA434LensModelASCII镜头型号
0x0001GPSLatitudeRefASCII纬度方向(N/S)
0x0002GPSLatitudeRATIONAL纬度值
0x0003GPSLongitudeRefASCII经度方向(E/W)
0x0004GPSLongitudeRATIONAL经度值
0x0005GPSAltitudeRefBYTE海拔方向
0x0006GPSAltitudeRATIONAL海拔值

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() 有以下限制:

  1. 只能修改已存在的字段,不能新增字段
  2. 修改后的值必须符合字段的数据类型
  3. 某些字段(如 GPS 坐标)的格式比较复杂,直接修改容易出错
  4. 修改操作是原地修改文件,不可逆

五、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 12API 13API 14
getImageProperty 读取
modifyImageProperty 修改
批量修改 modifyImageProperties
删除字段 removeImageProperty
导出全部 getImageProperties
GPS 解析辅助 API部分

5.4 迁移指南

  1. EXIF 修改:API 12 只能通过"重新编码"方式修改 EXIF,API 13+ 可以直接修改
  2. GPS 解析:API 14 新增了 parseGpsCoordinate() 辅助方法,不需要手动解析 DMS 格式
  3. 隐私脱敏:API 14 的 removeImageProperty() 可以精确删除 GPS 字段,而不需要重新编码整个图片

六、总结

mindmap
  root((EXIF信息))
    数据结构
      IFD0 主图像属性
      EXIF IFD 拍摄参数
      GPS IFD 定位信息
      Interop IFD 互操作
    数据读取
      getImageProperty
      安全读取 try-catch
      DMS→DD GPS坐标转换
      Orientation方向值
    拍摄参数
      曝光时间 ExposureTime
      光圈 FNumber
      ISO感光度
      焦距 FocalLength
      闪光灯 Flash
      白平衡 WhiteBalance
    GPS信息
      纬度 GPSLatitude
      经度 GPSLongitude
      海拔 GPSAltitude
      DMS格式解析
      地图链接生成
    EXIF修改
      modifyImageProperty API13+
      批量修改 API14+
      删除字段 API14+
      重新编码丢失EXIF
    隐私脱敏
      GPS信息清除
      缩略图清除
      方向信息保留
      重新编码方式
      隐私风险评估
    注意事项
      字段不存在抛异常
      PNG不支持EXIF
      Orientation自动旋转
      GPS精度有限
      重新编码丢失EXIF
    HarmonyOS 6
      批量修改API
      删除字段API
      GPS解析辅助
      EXIF导出JSON

关键知识点回顾

知识点要点
EXIF 结构IFD树形结构,包含主属性、拍摄参数、GPS信息
数据读取getImageProperty(),字段不存在会抛异常
GPS 坐标DMS格式需转换为DD格式,精度有限
Orientation1-8方向值,createPixelMap默认自动旋转
EXIF 修改API 13+modifyImageProperty(),API 14+ 批量修改/删除
重新编码packing() 会丢失EXIF,既是坑也是脱敏手段
隐私脱敏GPS必须清除,方向信息建议保留(烧录到像素)
PNG 不支持 EXIFPNG使用tEXt块,不是EXIF格式
隐私评估检查GPS/时间/相机/缩略图,按风险等级分类

EXIF 信息是图像文件中一个容易被忽视但非常重要的部分。正确处理 EXIF,既能给用户提供丰富的图片信息展示,又能保护用户的隐私安全。至此,"图像处理上"系列5篇文章全部完成——从编解码到变换,从滤镜到裁剪缩放,再到EXIF信息,覆盖了图像处理的核心知识体系。


蓝胖子样样好
79 声望702 粉丝

Never give up,and you will be successful