记录一次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备用地址
Android
感谢分享