图片选择器和图片上传踩坑

使用自定义的图片选择器和图片上传对于一个 app 来说是相当基础的功能,之前做业务的时候碰到不少坑,其中很多是上线以后才暴露和发现的问题,所以做个整理,也为了以后避坑,后续发现新的问题会持续更新。

iOS11 的 HEIF 格式兼容问题

HEIF 文件格式的扩展名有 .heic 、.heif 和 .avci,它们都是属于HEIF文件格式。当然,常见的只有 .heif 和 .heic 这两种,而 .avci 很少见。 苹果在 iOS11 系统中引入 HEIF 格式用于替代原来的 JPG 格式的图片。但是安卓并不支持这种格式图片的展示,后端通常也只支持上传某些特定的格式,所以就需要将 HEIF 格式改成可以支持的图片格式,我用了如下的方法:

// 判断是否是 HEIF 格式:
__block BOOL isHEIF = NO;
    NSArray *resourceList = [PHAssetResource assetResourcesForAsset:self];
    [resourceList enumerateObjectsUsingBlock:^(id  _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
        PHAssetResource *resource = obj;
        NSString *UTI = resource.uniformTypeIdentifier;
        if ([UTI isEqualToString:@"public.heif"] || [UTI isEqualToString:@"public.heic"]) {
            isHEIF = YES;
            *stop = YES;
        }
    }];

// 转为 jpeg 格式
UIImage *image = [[UIImage alloc] initWithData:imageData];
NSData *jpgData = UIImageJPEGRepresentation(image, 1);

根据个人的实际操作结果,推荐使用 UIImageJPEGRepresentation 而不是 UIImagePNGRepresentation ,因为在使用过程中发现 HEIF 用 UIImagePNGRepresentation 压缩的话图片会非常大,费流量不说且有很大几率会发送失败

图片旋转问题

Stackoverflow 上有人提过这样的问题,大概是上传风景图一切正常,但是上传人像图的时候图片会旋转 90 度,我在实际开发中也遇到了类似的问题,在使用 PHImageManager 的

- (PHImageRequestID)requestImageDataForAsset:(PHAsset *)asset options:(nullable PHImageRequestOptions *)options resultHandler:(void(^)(NSData *__nullable imageData, NSString *__nullable dataUTI, UIImageOrientation orientation, NSDictionary *__nullable info))resultHandler

方法的时候,resultHandler 中 orientation 返回的是 UIImageOrientationRight,但是图片确实是竖直拍摄的,在系统相册里显示正常且图片的长宽也是对的,我尝试了找根本原因,无果,那只好在后续阶段想对策来解决 既然 orientation 是 UIImageOrientationRight,那就将其调整为 UIImageOrientationUp,因为 image.imageOrientation 是 readonly 属性,所以肯定不能直接改。提供两个方法: 1、通过旋转+镜像

@implementation UIImage (fixOrientation)

- (UIImage *)fixOrientation {
    // No-op if the orientation is already correct
    if (self.imageOrientation == UIImageOrientationUp) return self;

    // We need to calculate the proper transformation to make the image upright.
    // We do it in 2 steps: Rotate if Left/Right/Down, and then flip if Mirrored.
    CGAffineTransform transform = CGAffineTransformIdentity;

    switch (self.imageOrientation) {
        case UIImageOrientationDown:
        case UIImageOrientationDownMirrored:
            transform = CGAffineTransformTranslate(transform, self.size.width, self.size.height);
            transform = CGAffineTransformRotate(transform, M_PI);
            break;

        case UIImageOrientationLeft:
        case UIImageOrientationLeftMirrored:
            transform = CGAffineTransformTranslate(transform, self.size.width, 0);
            transform = CGAffineTransformRotate(transform, M_PI_2);
            break;

        case UIImageOrientationRight:
        case UIImageOrientationRightMirrored:
            transform = CGAffineTransformTranslate(transform, 0, self.size.height);
            transform = CGAffineTransformRotate(transform, -M_PI_2);
            break;
        case UIImageOrientationUp:
        case UIImageOrientationUpMirrored:
            break;
    }

    switch (self.imageOrientation) {
        case UIImageOrientationUpMirrored:
        case UIImageOrientationDownMirrored:
            transform = CGAffineTransformTranslate(transform, self.size.width, 0);
            transform = CGAffineTransformScale(transform, -1, 1);
            break;

        case UIImageOrientationLeftMirrored:
        case UIImageOrientationRightMirrored:
            transform = CGAffineTransformTranslate(transform, self.size.height, 0);
            transform = CGAffineTransformScale(transform, -1, 1);
            break;
        case UIImageOrientationUp:
        case UIImageOrientationDown:
        case UIImageOrientationLeft:
        case UIImageOrientationRight:
            break;
    }

    // Now we draw the underlying CGImage into a new context, applying the transform
    // calculated above.
    CGContextRef ctx = CGBitmapContextCreate(NULL, self.size.width, self.size.height,
                                             CGImageGetBitsPerComponent(self.CGImage), 0,
                                             CGImageGetColorSpace(self.CGImage),
                                             CGImageGetBitmapInfo(self.CGImage));
    CGContextConcatCTM(ctx, transform);
    switch (self.imageOrientation) {
        case UIImageOrientationLeft:
        case UIImageOrientationLeftMirrored:
        case UIImageOrientationRight:
        case UIImageOrientationRightMirrored:
            // Grr...
            CGContextDrawImage(ctx, CGRectMake(0,0,self.size.height,self.size.width), self.CGImage);
            break;

        default:
            CGContextDrawImage(ctx, CGRectMake(0,0,self.size.width,self.size.height), self.CGImage);
            break;
    }
    // And now we just create a new UIImage from the drawing context
    CGImageRef cgimg = CGBitmapContextCreateImage(ctx);
    UIImage *img = [UIImage imageWithCGImage:cgimg];
    CGContextRelease(ctx);
    CGImageRelease(cgimg);
    return img;
}
@end

2、利用现有 image 的参数创建一个新的 bitmap 图形(感觉像是直接通过新绘制一个新图来将原有的 imageOrientation 信息抹掉)

- (UIImage *)normalizedImage {
    if (self.imageOrientation == UIImageOrientationUp) return self; 

    UIGraphicsBeginImageContextWithOptions(self.size, NO, self.scale);
    [self drawInRect:(CGRect){0, 0, self.size}];
    UIImage *normalizedImage = UIGraphicsGetImageFromCurrentImageContext();
    UIGraphicsEndImageContext();
    return normalizedImage;
}

个人推荐第一种方法,因为第二张方法会丢失image里的其他信息,如地理位置等 参考

图片选择器中图片排列顺序的问题

之前出现过一个问题,在网站上保存一张图后,在系统相册的 Albums - Camera Roll 的最后面可以看到图,但是在自己实现的图片选择器中就需要翻到很前面的地方才能看到对应的图。这其实是因为自定义的相册使用了图片的「创建时间」来作为排列顺序的标准,而 Albums - Camera Roll 则是使用了「更新时间」(其实 Photos - Moments 中的图片顺序与 Albums - Camera Roll 中的图片排列顺序也是不一样的)

图片版本问题

系统相册中是可以对图片进行编辑的,所以我们获取图片的时候也要注意获取当前的图片版本,虽然 PHImageRequestOptionsVersionCurrent 是默认值,但是还是需要注意这一点的(毕竟也踩过坑),并且更重要的是图片选择器里展示的图片版本和真正上传时候的图片版本要保持一致。

PHImageRequestOptions *options = [[PHImageRequestOptions alloc] init];
options.version = PHImageRequestOptionsVersionCurrent;

iCloud 支持问题

通过 PHAsset 来获取图片的时候,需要将 PHImageRequestOptions 的 networkAccessAllowed 设置为 YES,表示允许网络请求,这样系统才会去取 iCloud 的图,并且我们可以通过设置 progressHandler 来获取下载进度等信息或者是取消图片获取 ⚠️ 注意 progressHandler 不在主线程上执行

提升图片选择器的展示速度

通常写图片选择器的思路就是通过系统方法获取所有的图片信息,然后塞到 collectionview 中展示,但是随着手机内存越来越大,用户的图片也越来越多,上千上万张都是正常的情况,如果这是还用上面简单粗暴的方法,图片选择器的展示就是很慢或者有卡顿。 我们可以考虑使用 PHCachingImageManager,这是 PHImageManager 的子类,这个类为我们提供检索或生成与图片相关联的预览缩略图和全尺寸图像或视频数据的方法,且这些方法已针对批量预加载进行了优化。为了在使用大量图片资源时提高性能,系统的缓存映像管理器可以在后台准备所需资源,以便稍后请求单个映像时可以无感知也就是消除延迟。那么这么看起来,在大量使用照片资源且并不需要显示非常高质量的图的图片选择器中就非常适合使用 PHCachingImageManager 了

- (void)startCachingImagesForAssets:(NSArray<PHAsset *> *)assets targetSize:(CGSize)targetSize contentMode:(PHImageContentMode)contentMode options:(nullable PHImageRequestOptions *)options;
- (void)stopCachingImagesForAssets:(NSArray<PHAsset *> *)assets targetSize:(CGSize)targetSize contentMode:(PHImageContentMode)contentMode options:(nullable PHImageRequestOptions *)options;
- (void)stopCachingImagesForAllAssets;

在需要的时候调用 startCaching 方法开始 prepare,传入需要 prepare 的图,尺寸等要求,需要用图的时候调用requestImageForAsset: targetSize: contentMode: options: resultHandler: 方法并且传入跟 prepare 时相同的参数,如果系统已经准备好了资源,立刻就会返回

requestImageForAsset 方法返回两个结果的问题

通过 PHAsset 获取图片时我们会调用 requestImageForAsset: targetSize: contentMode: options: resultHandler: 方法,但是在实际使用过程中会发现 resultHandler 被调用了两次,并且返回了不一样的结果。 如果 PHImageRequestOptions 的 isSynchronous 属性是 YES 的话,resultHandler 确实有可能会被调用多于一次,这个值默认是 YES,所以如果我们不做特殊设置的话,系统有可能先返回一个质量不高的图,等到拿到了质量高的图,系统会再调一次这个方法将图传回。