01项目背景介绍
项目中直播流每场直播由一张直播图片作为展示入口,用于提示用户此直播的概要。如下图:
图片
然而直播图片和容器的宽高比例出现不一致的情况。针对此情况,采取背景图 contentmode展示为 aspectFill 且高斯模糊,上层高清图为 aspectfit,给用户一种图片填满且能清除获取信息的视觉体验。如下图:
图片
然而服务端下发直播的图片分辨率在1000 * 2000byte左右,占用内存大小为1000 * 2000 * 4,约为 2M 大小。资深直播用户最多有一千场直播。使用 sd_webImage 下载图片并缓存在内存中,查看足够多的直播封面时,在iPhone 13机型,iOS15的手机,滑动到400场直播时,就会产生内存不足崩溃。且崩溃堆栈展示在进行高斯模糊的方法中。
02分析问题
经过初步分析,得出影响内存的原因有以下几方面。
- 图片分辨率高,高斯模糊占用的内存越高。因为需要对进行大量模糊计算;
- 用户快速滑动直播流,正常的下载图片速度会展示所有划过的图片,高斯模糊在图片下载完成 block 中执行,即使划过的直播图,也会继续高斯模糊直至返回。这样会导致用户大量无意义图片占用大量内存;
- 两张 ImageView 需要在内存中加载两张一样的图片,是一种内存浪费;
- 为了用户查看图片的及时性和流畅性,项目中没有设置存储高斯模糊图片最大占用内存。这会导致图片内存只会在内存警告时被清除。导致高斯模糊不能获取足够的内存而崩溃;
- 高斯模糊采用 vImage 方案,占用 CPU 进行高斯模糊计算,CPU 繁忙不能及时释放内存,进一步加剧内存紧张。
03针对问题,采取措施
降低图片分辨率
- 通⽤降低分辨率⽅式为采用图片云开发系统提供的服务。在图片 url 中加入分辨率的参数, 直接下载相应分辨率的图⽚。分辨率的设置,以图片清晰为标准,⼀般设置为展 示 ImageView 的大小。这样不消耗客户端的资源,不会给 CPU 带来额外的工作。
避免下载快速划过的图片
-
判断用户是否快速划过,无需下载图片。scrollViewDidScroll 的回调频率是小于 CADisplayLink 回调频率的,在滚动缓慢的状态下,离散取整可能导致 contentOffset 在某次刷新中不发生变化,也就是说 didScroll 的两次打点间隔有一定可能大于0.0167s,是2个或者3个刷新周期。低速状态下本身差值的差别就不大,所以使用 didScroll 打点,默认间隔是0.0167s即可。在 scrollviewDidScroll 中记录两次 scrollview 移动的差值,经实验证明,速速大于60pt,流视图图片视觉上呈模糊状态,故以60pt作为暴力滑动的临界点;
-
下一步,暴力滚动停止划过图片的下载。创建全局变量标记是否暴力滚动,在系统调用 cellForItem 方法时,判断标记为是则不进行下载。非暴力滚动下,标记为否, cell 根据标记开启下载当前的图片;
-
以上适用于自然减速停止的滑动。然而暴力滚动之后用户手动突然停止,此时标记虽已及时改为否,但展示在屏幕上的图片都处于不下载的状态。我们在 scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate: 代理表示停止拖拽,在此时机,将展示在屏幕上的 cell 重新下载图片。具体代码如下:
[self.collectionView.visibleCells enumerateObjectsUsingBlock:^(__kindof UICollectionViewCell * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { SVPGCLiveCollectionViewCell *cell = obj; if (!cell.yesToLoad) { cell.yesToLoad = YES; } }];
自定义 cell ,setYesToLoad方法中下载图片,设置 yesToLoad为 Yes ,即可开启下载当屏的图片这种情况;
-
其他的滑动情况:滚动到流顶部,手动设置 contenOffset:animated:经测试,滚动的速度属于暴力滚动。需要在 scrollViewDidScrollToTop 和 scrollViewDidEndScrollingAnimation 中设置当屏图片重新下载;
-
内存存储高斯模糊的容器为 NSCache ,NSCache 提供最大储存值。根据直播 tab 业务需要,每屏展示的数量大约为15张左右。存储的数值需比15张略大,保证页面的流畅度。初设值为20,每张占用的内存在5M左右,20张存储在100M,可接收范围内;
-
高斯模糊由 vImage 方式修改为 GPUImage 方式,使用 CPU 处理图像, GPUImage 在 GPU 上处理 filter ,经统计,在 iOS13 上,处理图片滤镜时间比快约2倍。GPU 处理 filter 图片需要运行大量 openGL 代码,GPUImage 封装了 openGL,只需要调用接口就可以实现 filter 。GPUImage 没有现成的合成一张底部高斯,上层高清的图片滤镜。GPUImage 支持自定义顶点着色器和片元着色器实现滤镜。整体思路为,使用混合滤镜将高斯滤镜的纹理和原图纹理按照坐标计算生成新的输出。纹理链条如图:
a. 初始化高斯滤镜,并设置参数。
GPUImageGaussianBlurFilter *gaussionfilter = [[GPUImageGaussianBlurFilter alloc] init];
gaussionfilter.blurRadiusInPixels = 9;//数值越高,越糊。
[gaussionfilter forceProcessingAtSize:highDefiniImage.size];
说明:blurRadiusInPixels 决定高斯的卷积数。卷积数越高,模糊效果越明显。forceProcessingAtSize:设置高斯过滤器的目标输出分辨率
b. 初始始化原始图⽚。GPUImage中静态图⽚的源对象为 GPUImagePicture类
GPUImagePicture *highImage = [[GPUImagePicture alloc] initWithImage:highDefiniImage];
通过传⼊原图的 image 对象⽣成 GPUImagePicture 。
c. 自定义顶点着色器和片元着色器创建自定义混合滤镜。顶点着色器,是对顶点进行一系列操作的着色器,顶点除了有最基本的位置属性,还包含其他属性,比如纹理,法线等。通过顶点着色器,显卡就知道顶点应该绘制在具体什么位置。针对每个顶点,顶点着色器都会执行一次。首先收到系统传给他的数据(位置坐标),将数据处理成后续我们需要的数据。系统对顶点着色器输出的顶点数据进行插值,并将插值结果传递给片段着色器。片段着色器根据插值结果计算最后屏幕上的像素颜色。 GPUImageTwoInputFilter 提供顶点着色器的代码满足需求,直接使用即可。顶点着色器的代码如下所示:
NSString *const kGPUImageTwoInputTextureVertexShaderString = SHADER_STRING
(
attribute vec4 position;
attribute vec4 inputTextureCoordinate; //attribute 标注属性为输入变量,inputTextureCoordinate为第一个输入对象的坐标
attribute vec4 inputTextureCoordinate2; //inputTextureCoordinate2 为第二个输入变量的坐标
varying vec2 textureCoordinate; // varying 标注属性为在vertex shader和fragment shader之间传递数据,表示将第一个输入对象的坐标传递给片段着色器
varying vec2 textureCoordinate2; //表示将第二个输入对象的坐标传递给片段着色器
void main()
{
gl_Position = position;
textureCoordinate = inputTextureCoordinate.xy;
textureCoordinate2 = inputTextureCoordinate2.xy;
}
);
GPUImage 包装顶点着色器为 string,便于加载。每一行代码的作用标注在注释里。片元着色器,是接收顶点着色器传过来的数据,进行像素颜色计算。首先,需要传入画布和原图的大小,用于计算画布从原图采点的坐标。其次,计算变量,我们需要确定上层高清图在画布上的leftX 和 rightX ,topY 和 bottomY,高度 targetH,宽度 targetW 。以及底部高斯背景图片设置为 aspectFill 的高度和宽度。以上数值的计算涉及高清图横图和竖图。见下图,左图为竖图,右图为横图:
图片
相应代码如下:
if (drawableW/drawableH > imageW/imageH ){//竖版
targetW = imageW * (drawableH/imageH);
lowp float left = (drawableW - targetW)/float(2);
leftX = left/drawableW;
rightX = (left+targetW)/drawableW;
targetH = drawableH;
bottomY = 1.0;
targetHFill = imageHFill * drawableW / imageWFill;
targetWFill = drawableW;
}else{//横版
targetH = imageH * (drawableW/imageW);
lowp float top = (drawableH - targetH)/float(2);
topY = top/drawableH;
bottomY = (top+targetH)/drawableH;
targetW = drawableW;
rightX = 1.0;
targetWFill = imageWFill * drawableH / imageHFill;
targetHFill = drawableH;
}
片元着色器通过 gl_FragColor = texture2D ( 参数1:输⼊对象的纹理,参数2:输入对象的坐标) 得到当前坐标的纹理。参数1由直接取值,参数2需要计算。坐标的计算分2种情况,⾼清图和⾼斯背景,当画布坐标处于 leftX 和 rightX , topY 和 bottomY 之间,则绘制⾼清图,应取⾼清图的相应坐标。相应坐标的计算为绘制的 点从画布坐标换算到在⾼清图上的坐标(坐标都为0-1区间值)如图:
图片
目标为计算出高清图的0.2,0.1。换算代码如下:
if (textureCoordinate2.x >= leftX && textureCoordinate2.x = topY && textureCoordinate2.y