8. Photo 拍照流程:TakePicture触发、StillCaptureRequest构建、JPEG回调与编码、Exif信息写入与缩略图生成

拍照,说白了就是让相机传感器“咔嚓”一下,然后把数据拿回来处理成一张照片。嗯,听起来简单,但Android框架里这一套流程其实挺绕的。我最早接触Camera2时,光是把TakePicture回调调通就折腾了两天——后来发现是CaptureRequest的模板没选对。

这一章,我们就把拍照的完整链路拆开来看。从App调用takePicture()开始,到最终拿到一张带Exif和缩略图的JPEG文件,每一步我都会结合项目里的实际踩坑经验来讲。

8.1 TakePicture的触发:从App到CameraService

你想想看,App里调用mCameraDevice.createCaptureSession()之后,怎么触发拍照?

其实就一行:

mCaptureSession.capture(mStillCaptureRequest, mCaptureCallback, mBackgroundHandler);

但这一行背后,CameraFramework做了三件事:

  • 锁住3A(自动对焦/自动曝光/自动白平衡):防止拍照瞬间参数被重新计算。
  • 插入一个“预拍”帧:有些设备需要先跑一次AE/AF收敛,再真正曝光。
  • 下发真正的StillCaptureRequest:这个Request的模板是TEMPLATE_STILL_CAPTURE

核心要点:TakePicture并不是立即曝光,而是先等3A稳定。我曾在某款低端机上遇到过拍照模糊的问题,后来发现是AE还没收敛就拍了——解决方案是在capture之前手动调用mCaptureSession.setRepeatingRequest(mPreviewRequest)跑几帧预热。

我个人习惯在触发拍照前加一个日志:

Log.d("CameraShot", "capture() called, current AE state: " + aeState);

这样能快速定位是3A问题还是HAL层的问题。

8.2 StillCaptureRequest的构建:模板、参数与TAG

构建拍照用的Request,和预览Request最大的区别在于:

  • 模板用TEMPLATE_STILL_CAPTURE,而不是TEMPLATE_PREVIEW
  • 需要显式设置CONTROL_AE_MODECONTROL_AF_MODE
  • 必须指定输出目标(Surface)为ImageReaderMediaRecorder

代码示例:

CaptureRequest.Builder stillBuilder =
    mCameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE);
stillBuilder.addTarget(mImageReader.getSurface());

// 设置自动对焦模式
stillBuilder.set(CaptureRequest.CONTROL_AF_MODE,
    CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE);

// 设置自动曝光模式
stillBuilder.set(CaptureRequest.CONTROL_AE_MODE,
    CaptureRequest.CONTROL_AE_MODE_ON);

// 设置JPEG质量
stillBuilder.set(CaptureRequest.JPEG_QUALITY, (byte) 95);

// 设置缩略图尺寸
stillBuilder.set(CaptureRequest.JPEG_THUMBNAIL_SIZE,
    new Size(512, 384));

mStillCaptureRequest = stillBuilder.build();

避坑指南:我曾经在构建Request时忘记设置JPEG_THUMBNAIL_SIZE,结果生成的缩略图是0x0,导致相册App显示异常。后来我养成了习惯——只要用TEMPLATE_STILL_CAPTURE,就显式设置缩略图尺寸。

这里有个细节:JPEG_QUALITY是0-100的整数,但HAL层可能只支持几个固定档位。我建议设95,既保证画质又不会让文件太大。

8.3 JPEG回调与编码:从ImageReader到byte[]

拍照完成后,数据会通过ImageReader.OnImageAvailableListener回调回来。嗯,这里要注意:回调回来的不是JPEG文件,而是一个Image对象。

标准做法是:

ImageReader.OnImageAvailableListener readerListener = new ImageReader.OnImageAvailableListener() {
    @Override
    public void onImageAvailable(ImageReader reader) {
        Image image = reader.acquireLatestImage();
        if (image == null) return;

        // 获取JPEG数据
        ByteBuffer buffer = image.getPlanes()[0].getBuffer();
        byte[] jpegData = new byte[buffer.remaining()];
        buffer.get(jpegData);

        // 关闭Image,释放资源
        image.close();

        // 后续处理:写入Exif、生成缩略图、保存文件
        processJpegData(jpegData);
    }
};

警告acquireLatestImage()必须及时调用并关闭,否则会导致ImageReader队列阻塞,后续拍照会卡死。我见过线上崩溃日志,就是因为在回调里做了耗时操作没及时close()。

JPEG编码其实是由HAL层完成的。App拿到的byte[]已经是完整的JPEG流,包含图像数据和部分Exif信息(比如厂商、型号、拍摄时间)。但有些Exif字段(比如GPS、旋转角度)需要App自己写入。

8.4 Exif信息写入:让照片更完整

Exif是什么?说白了就是照片的“身份证”。里面记录了拍摄参数、设备信息、地理位置等。Android提供了ExifInterface类来读写。

写入Exif的典型流程:

public static void writeExif(byte[] jpegData, ExifData data) {
    try {
        ExifInterface exif = new ExifInterface(new ByteArrayInputStream(jpegData));

        // 写入方向
        exif.setAttribute(ExifInterface.TAG_ORIENTATION,
            String.valueOf(data.orientation));

        // 写入GPS
        if (data.latitude != null) {
            exif.setAttribute(ExifInterface.TAG_GPS_LATITUDE,
                data.latitude);
            exif.setAttribute(ExifInterface.TAG_GPS_LONGITUDE,
                data.longitude);
        }

        // 写入日期
        exif.setAttribute(ExifInterface.TAG_DATETIME,
            new SimpleDateFormat("yyyy:MM:dd HH:mm:ss",
                Locale.US).format(new Date()));

        // 保存回JPEG
        exif.saveAttributes();

    } catch (IOException e) {
        Log.e("ExifWriter", "Failed to write Exif", e);
    }
}

个人经验ExifInterfacesaveAttributes()会重写整个JPEG文件,如果文件很大(比如20MB),这个操作会比较耗时。我建议在后台线程做,并且不要在主线程调用。

另外,有些Exif字段是只读的(比如IMAGE_WIDTH),写入时会静默失败。嗯,这个坑我踩过——当时想改分辨率信息,结果怎么改都不生效,后来查文档才发现是只读的。

8.5 缩略图生成:两种方式,各有优劣

缩略图有两种生成方式:

方式 原理 优点 缺点
HAL内嵌缩略图 在JPEG编码时,HAL直接生成缩略图并嵌入Exif的Thumbnail区域 速度快,无需二次解码 缩略图尺寸固定,无法自定义
App端生成 拿到JPEG后,用BitmapFactory解码再压缩 灵活,可控制尺寸和质量 需要额外解码,耗时较长

我个人更推荐使用HAL内嵌缩略图,因为省事。但如果你需要自定义缩略图(比如加水印或裁剪),那就得走App端生成。

App端生成缩略图的代码:

public static byte[] generateThumbnail(byte[] jpegData, int maxSize) {
    Bitmap bitmap = BitmapFactory.decodeByteArray(jpegData, 0, jpegData.length);
    if (bitmap == null) return null;

    // 计算缩放比例
    int width = bitmap.getWidth();
    int height = bitmap.getHeight();
    float scale = Math.min((float) maxSize / width, (float) maxSize / height);
    Matrix matrix = new Matrix();
    matrix.postScale(scale, scale);

    Bitmap thumb = Bitmap.createBitmap(bitmap, 0, 0, width, height, matrix, true);
    ByteArrayOutputStream baos = new ByteArrayOutputStream();
    thumb.compress(Bitmap.CompressFormat.JPEG, 80, baos);
    thumb.recycle();
    bitmap.recycle();

    return baos.toByteArray();
}

避坑指南BitmapFactory.decodeByteArray()在解码大图时可能会OOM。我建议先通过BitmapFactory.OptionsinJustDecodeBounds获取尺寸,再计算合适的采样率。嗯,这个技巧在相册类App里很常用。

8.6 整体流程SVG图

下面这张图展示了从TakePicture触发到最终保存JPEG的完整数据流。我特意把HAL层和App层的边界画清楚了,方便你理解职责划分。

App层 takePicture() 构建StillCaptureRequest capture()下发 HAL层 3A锁定 & 预拍 传感器曝光 JPEG编码 + 缩略图 回调Image ImageReader回调 提取JPEG byte[] 写入Exif + 缩略图 保存到文件/相册 图例:蓝色=App层操作,绿色=HAL层操作,黄色=关键处理步骤

8.7 总结与避坑清单

拍照流程看起来步骤多,但核心就三条线:

  • 触发线:takePicture() → 构建Request → capture()
  • 数据线:传感器 → JPEG编码 → ImageReader回调 → byte[]
  • 后处理线:Exif写入 → 缩略图生成 → 文件保存

最后,我整理了一份避坑清单,都是我在项目里真实遇到过的:

  1. 忘记设置缩略图尺寸:导致缩略图为0x0,相册显示异常。
  2. ImageReader未及时close():队列阻塞,后续拍照卡死。
  3. Exif写入耗时过长:在主线程调用导致ANR。
  4. BitmapFactory OOM:解码大图前没有计算采样率。
  5. 3A未收敛就拍照:照片模糊或曝光不准。

嗯,如果你能把上面这五点都避开,拍照流程基本就稳了。剩下的就是根据具体设备微调参数——比如某些三星设备对JPEG_QUALITY的响应和骁龙平台不一样,这个只能靠实测。

公众号:蓝海资料掘金营,微信deep3321