记录一次iOS显示大图的优化过程

前言

   在做定制项目的时候,遇到了几个问题,刚好在这里分享下。
- 非信任HTTPS缓存图片
- 图片列表快速滑动不流畅
- 图片压缩
- 苹果官方加载大图方案(延伸)

  开发的时候都是在iPhone X真机上开发的,图片显示、列表滑动是没有问题,测试那边使用的是一代的SE测试的,在滑动图片列表的时候,出现了不流畅的现象,尤其是在快速滑动下,偶现爆内存闪退......

非信任HTTPS的图片缓存

  服务端那边HTTPS证书是自己生成的,也没有导出给移动设备添加信任,所以当app在请求图片时候,提示非信任的HTTPS,iOS系统为了防止中间人攻击,直接断开连接了。

   解决方法就是自己创建NSURLSession,并设置代理,在他的代理方法里面强制信任所有的证书。

/// 创建session 并设置代理
NSURLRequest *request = [NSURLRequest requestWithURL:[NSURL URLWithString:picUrl]];
NSURLSession *session = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration] delegate:self delegateQueue:[NSOperationQueue mainQueue]];
NSURLSessionDataTask *task = [session dataTaskWithRequest:request completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
     /// 处理请求结果等等
     /// 这里需要注意的是,在一个请求完成之后,一定要释放session,否则这里会内存泄漏,因为session对delegate 是强引用
     /// 使session失效,防止内存泄漏
     [session finishTasksAndInvalidate];
}

   在回调里信任非认证HTTPS证书

#pragma mark - NSURLSessionDelegate - 解决访问非信任HTTPS
- (void)URLSession:(NSURLSession *)session didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition, NSURLCredential * _Nullable))completionHandler{
    /// 信任所有证书
    if([challenge.protectionSpace.authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust]){
        NSURLCredential *credential = [NSURLCredential credentialForTrust:challenge.protectionSpace.serverTrust];
        if(completionHandler)
            completionHandler(NSURLSessionAuthChallengeUseCredential,credential);
    }
}

图片列表快速滑动不流畅

这个现象在SE上很明显,卡顿肯定是因为主线程有耗时的操作,我图片下载虽然在主队列,但是下载的过程是异步的,理论是不会阻塞主线程的。上面缓存图片的方法是写在UITableViewCell的setModel里面,当每一个UITableViewCell从屏幕外进入到屏幕的时候,都会调用这个setModel更新cell的样式。

考虑到cell复用的时候,会不断调用setModel,我将缓存到的图片,存储到了列表数据的模型里面,并且新增一个BOOL属性,记录是否下载失败过,如果之前有下载失败,直接使用默认兜底图。

if (cameraInfoModel.coverPictureImg) {
    /// 之前下载过图片的,直接赋值显示
    self.coverImageView.image = cameraInfoModel.coverPictureImg;
}else if (cameraInfoModel.loadCoverPictureFail == YES) {
    /// 之前下载失败的,直接使用兜底图
    self.coverImageView.image = OSImage(@"default_image_error");
}else{
    /// 没有缓存过图片的,先设置默认图
    self.coverImageView.image = OSImage(@"default_image_error");
    /// 请求网络图片
    if (cameraInfoModel.picUrl.length > 0) {
        NSString *picUrl;
        if ([cameraInfoModel.picUrl containsString:@"http"]) {
            picUrl = cameraInfoModel.picUrl;
        }else{
            picUrl = [NSString stringWithFormat:@"%@%@",[OSCommonUtil getBaseURL],cameraInfoModel.picUrl];
        }
        /// 创建session 并设置代理
        NSURLRequest *request = [NSURLRequest requestWithURL:[NSURL URLWithString:picUrl]];
        NSURLSession *session = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration] delegate:self delegateQueue:[NSOperationQueue mainQueue]];
        NSURLSessionDataTask *task = [session dataTaskWithRequest:request completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {

            if (error) {
                /// 网络异常,加载失败
                cameraInfoModel.loadCoverPictureFail = YES;
                return;
            }

            if (data) {
                /// 服务端返回数据,对数据进行判断
                NSError *pareError;
                NSDictionary *temDic = [NSJSONSerialization JSONObjectWithData:data options:NSJSONReadingMutableContainers error:&pareError];//转换数据格式
                if (!pareError && temDic) {
                    /// 返回的是json数据,不是图片
                    cameraInfoModel.loadCoverPictureFail = YES;
                }else{
                    /// 获取图片,显示
                    UIImage *resultImage = [UIImage imageWithData:data];
                    /// 缓存到模型
                    cameraInfoModel.coverPictureImg = resultImage;
                    /// 赋值显示图片
                    self.coverImageView.image = resultImage;
                }
            }else{
                cameraInfoModel.loadCoverPictureFail = YES;
            }
            /// 使session失效,防止内存泄漏
            [session finishTasksAndInvalidate];
        }];
        [task resume];
    }
}

这里对数据复用优化都可以了,有条件的,可以以图片的URL为Key,将图片写入到本地,在请求缓存图片之前再判断下即可,这里的定制项目很简单,我就没往这方面考虑了。

但这里还有一个问题,网络下载好的数据是NSData格式,一两个NSData转成UIImage的话,可能还感觉行,但是在老旧机型上,快速滑动列表,这么大批量的转换,可以感觉到明显卡顿。不流畅,和爆内存的原因也主要在这里。

if (data) {
    /// 子线程转化处理数据
    dispatch_async(dispatch_get_global_queue(0, 0), ^{
        /// 服务端返回数据,对数据进行判断
        NSError *pareError;
        NSDictionary *temDic = [NSJSONSerialization JSONObjectWithData:data options:NSJSONReadingMutableContainers error:&pareError];//转换数据格式
        if (!pareError && temDic) {
            /// 返回的是json数据,不是图片
            cameraInfoModel.loadCoverPictureFail = YES;
        }else{
            /// 获取缓存图片
            UIImage *resultImage = [UIImage imageWithData:data];
            cameraInfoModel.coverPictureImg = resultImage;
            dispatch_async(dispatch_get_main_queue(), ^{
                /// 主线程更新显示
                self.coverImageView.image = resultImage;
            });
        }
    });
}else{
    cameraInfoModel.loadCoverPictureFail = YES;
}

图片压缩

到这里了,列表优化基本没问题了,滑动也很流畅,但还是有个问题,服务端给的图片太大了,基本上都是监控点的截图,大部分都是1080分辨率,我们以一张尺寸为1920×1080的截图来算,转成移动端能够渲染显示的位图,也就是(1920 * 1080 * 4 ) / (1024 * 1024)= 7.9M, 需要占用7.9M 的内存 才能正常显示这个图片。在加上滑动列表的时候,缓存好的 NSData 以及转成 UIImage 对象 ,还是挺吃内存的,另外一个就是列表的图仅仅是临时显示的,并不需要多高清的图片,实在想看高清的,也可以点进去查看录像回放。

下面是SE机型,加载原图的实时内存占用(仅一页数据,不往下滑动加载更多数据

那就压缩图片画质吧,毕竟还是体验优先。

利用系统API处理图片

/// 获取图片,并压缩
UIImage *temImage = [UIImage imageWithData:data];
NSData *newData = UIImageJPEGRepresentation(temImage, 0.1);
/// 缓存图片
UIImage *resultImage = [UIImage imageWithData:newData];
cameraInfoModel.coverPictureImg = resultImage;

处理完还是有效果的,列表显示同样的图片,少了6.6M内存。(仅一页数据,不往下滑动加载更多数据

这里我需要补充下,我尝试将比例由0.1调到更低,或者对同一个UIImage多次压缩,得到的结果是首次进入滑动加载图片的时候,cpu占用非常高,毕竟需要处理很多图片,消费的资源更多,但是内存并没有降低多少,得不偿失啊。

另外,随着列表滑动,加载更多数据,显示更多图片,内存也在不断的上涨,在列表加载了1000多条数据之后,App内存溢出挂了。

降低图片尺寸

上面方式不行,那就在换个思路,毕竟位图的大小主要是由图片尺寸解决的,我们写个方法,降低图片尺寸试试

- (UIImage*)originImage:(UIImage*)image scaledToSize:(CGSize)destSize
{
    /// 获取原图尺寸
    CGFloat originWidth  = CGImageGetWidth(image.CGImage);
    CGFloat originHeight = CGImageGetHeight(image.CGImage);
    /// 压缩比例
    CGFloat imageScale = destSize.width/originWidth;
    /// 创建位图上下文
    UIGraphicsBeginImageContext(destSize);
    /// 用新的图片尺寸重绘
    [image drawInRect:CGRectMake(0,0,destSize.width,originHeight *imageScale)];
    /// 获取UIImage
    UIImage* newImage = UIGraphicsGetImageFromCurrentImageContext();
    // 关闭上下文
    UIGraphicsEndImageContext();
    return newImage;
}

这里传入的目标尺寸,也就是UIImageView在屏幕上显示的宽高

经过测试,内存占用低,尤其是首次加载滑动列表的时候,cpu的占用比压缩画质低点。当然,列表的图片画质肯定要更差一点。

可以加载更多的数据了,经过测试,目前加载了2000多个数据也没出现内存溢出情况,也是我最后使用的方案。

苹果官方加载大图方案(延伸)

根据不同的使用场景,我们可以按需优化,比如上面的列表图片,就可以采用调整尺寸显示,还不影响用户体验。但是当我们进入图片详情页面的时候,需要加载一张尺寸非常大的图片咋办?可能转成位图之后,达到1G的内存占用。

苹果官方是将图片分割成一块一块的,处理一块显示一块,可以理解成将图片处理成百叶窗,然后一条条加载渲染的。核心方法就是创建了一个自动释放池,将大图进行分片处理,不同的设备分片数量不一样,然后对大图的分片进行缩放处理获取对应小的分片,再一行行渲染显示,处理完一段就释放一次内存。这样可以避免,一次性加载整张图导致内存溢出,或者耗时卡白屏问题

更白话一点,一个人一次性搬一车砖肯定很累,或者搬不动,但是每一次只搬三四块砖,多搬几次,慢慢搬肯定是能搬完的。

贴下核心代码

 // create an autorelease pool to catch calls to -autorelease.
    NSAutoreleasePool* pool = [[NSAutoreleasePool alloc] init];
    // 获取图片
    sourceImage = [[UIImage alloc] initWithContentsOfFile:[[NSBundle mainBundle] pathForResource:kImageFilename ofType:nil]];
    if( sourceImage == nil ) NSLog(@"input image not found!");
    // 获取原图尺寸
    sourceResolution.width = CGImageGetWidth(sourceImage.CGImage);
    sourceResolution.height = CGImageGetHeight(sourceImage.CGImage);
    // 原图总像素个数
    sourceTotalPixels = sourceResolution.width * sourceResolution.height;
    // 原图转成位图的大小
    sourceTotalMB = sourceTotalPixels / pixelsPerMB;
    // 获取当前图片缩放比例
    imageScale = destTotalPixels / sourceTotalPixels;
    // 目标图片的尺寸
    destResolution.width = (int)( sourceResolution.width * imageScale );
    destResolution.height = (int)( sourceResolution.height * imageScale );
    // create an offscreen bitmap context that will hold the output image
    // pixel data, as it becomes available by the downscaling routine.
    // use the RGB colorspace as this is the colorspace iOS GPU is optimized for.
    CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
    int bytesPerRow = bytesPerPixel * destResolution.width;
    // allocate enough pixel data to hold the output image.
    void* destBitmapData = malloc( bytesPerRow * destResolution.height );
    if( destBitmapData == NULL ) NSLog(@"failed to allocate space for the output image!");
    // 根据参数,创建目标图片上下文,方便后续直接使用获取图片
    destContext = CGBitmapContextCreate( destBitmapData, destResolution.width, destResolution.height, 8, bytesPerRow, colorSpace, kCGImageAlphaPremultipliedLast );
    // remember CFTypes assign/check for NULL. NSObjects assign/check for nil.
    if( destContext == NULL ) {
        free( destBitmapData ); 
        NSLog(@"failed to create the output bitmap context!");
    }        
    // release the color space object as its job is done
    CGColorSpaceRelease( colorSpace );
    // flip the output graphics context so that it aligns with the 
    // cocoa style orientation of the input document. this is needed
    // because we used cocoa's UIImage -imageNamed to open the input file.
    CGContextTranslateCTM( destContext, 0.0f, destResolution.height );
    CGContextScaleCTM( destContext, 1.0f, -1.0f );

    // 原始图每小块的宽度
    sourceTile.size.width = sourceResolution.width;

    // 原始图每小块图片的高度 = 每小块的总像素 / 每小块的宽度
    sourceTile.size.height = (int)( tileTotalPixels / sourceTile.size.width );     
    NSLog(@"source tile size: %f x %f",sourceTile.size.width, sourceTile.size.height);
    sourceTile.origin.x = 0.0f;
    // 目标每小块的宽度
    destTile.size.width = destResolution.width;
    // 目标每小块的高度
    destTile.size.height = sourceTile.size.height * imageScale;        
    destTile.origin.x = 0.0f;
    NSLog(@"dest tile size: %f x %f",destTile.size.width, destTile.size.height);
    sourceSeemOverlap = (int)( ( destSeemOverlap / destResolution.height ) * sourceResolution.height );
    NSLog(@"dest seem overlap: %f, source seem overlap: %f",destSeemOverlap, sourceSeemOverlap);    
    CGImageRef sourceTileImageRef;
    // 迭代的次数(总共需要循环的次数)
    int iterations = (int)( sourceResolution.height / sourceTile.size.height );
    // 判断是否能刚好处理掉,不行的话,再加一次
    int remainder = (int)sourceResolution.height % (int)sourceTile.size.height;
    if( remainder ) iterations++;
    // add seem overlaps to the tiles, but save the original tile height for y coordinate calculations.
    float sourceTileHeightMinusOverlap = sourceTile.size.height;
    sourceTile.size.height += sourceSeemOverlap;
    destTile.size.height += destSeemOverlap;    
    NSLog(@"beginning downsize. iterations: %d, tile height: %f, remainder height: %d", iterations, sourceTile.size.height,remainder );
    for( int y = 0; y < iterations; ++y ) {
        // 创建一个自动释放池,处理一段图片,就释放一次,避免了内存溢出
        NSAutoreleasePool* pool2 = [[NSAutoreleasePool alloc] init];
        NSLog(@"iteration %d of %d",y+1,iterations);
        // 计算每小块的纵坐标
        sourceTile.origin.y = y * sourceTileHeightMinusOverlap + sourceSeemOverlap; 
        destTile.origin.y = ( destResolution.height ) - ( ( y + 1 ) * sourceTileHeightMinusOverlap * imageScale + destSeemOverlap );
        // 获取原始图片的每一小块图
        sourceTileImageRef = CGImageCreateWithImageInRect( sourceImage.CGImage, sourceTile );
        // if this is the last tile, it's size may be smaller than the source tile height.
        // adjust the dest tile size to account for that difference.
        if( y == iterations - 1 && remainder ) {
            float dify = destTile.size.height;
            destTile.size.height = CGImageGetHeight( sourceTileImageRef ) * imageScale;
            dify -= destTile.size.height;
            destTile.origin.y += dify;
        }
        // 添加到之前创建destContext,
        CGContextDrawImage( destContext, destTile, sourceTileImageRef );
        CGImageRelease( sourceTileImageRef );
        [sourceImage release];
        // free all objects that were sent -autorelease within the scope of this loop.
        [pool2 drain];             
        if( y < iterations - 1 ) {            
            sourceImage = [[UIImage alloc] initWithContentsOfFile:[[NSBundle mainBundle] pathForResource:kImageFilename ofType:nil]];
            [self performSelectorOnMainThread:@selector(updateScrollView:) withObject:nil waitUntilDone:YES];
        }
    }
    NSLog(@"downsize complete.");
    [self performSelectorOnMainThread:@selector(initializeScrollView:) withObject:nil waitUntilDone:YES];
    // free the context since its job is done. destImageRef retains the pixel data now.
    CGContextRelease( destContext );
    [pool drain];

这张图是直接赋值给UIImageView的,加载的时候明显会卡白屏,内存占用也非常高。

内存占用

下面这张是优化过得,内存占用很低的

内存占用

这个是官方的加载大图的源码,有需要的可以下载跑起来看看。
苹果官方加载大图Demo
官方Demo Git备用地址

版权声明:
作者:Amber
链接:https://late.run/archives/24
来源:LATE-努力努力再努力
文章版权归作者所有,未经允许请勿转载。

THE END
分享
二维码
< <上一篇
下一篇>>