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 <= rightX && textureCoordinate2.y >= topY && textureCoordinate2.y <= bottomY) {
lowp float offsetX = textureCoordinate2.x - leftX; //offsetx为图中X的间距在画布的占比。
lowp float x = (offsetX * drawableW)/targetW; //X的距离在深灰高清图中距离。
lowp float offsetY = textureCoordinate2.y - topY;
lowp float y = (offsetY * drawableH)/targetH;
}
由公式 offX* drawableW = x *targetW 左右两边都是 X 线段的实际距离,从而得出 x 的值。同理得出 Y 的值。当画布的坐标落在上图浅灰色区域,采点高斯。高斯展示方式为 aspectFill. 如下图:
图片
粉色部分为高斯图片的布局。高宽都会溢出被剪切一部分。图中三角形为画布中要绘制的点,此点在高斯图中的位置为 targetY/targetHfill。targetY = (textureCoordinate2.y * drawableH + (targetHFill - drawableH )/float(2)) 为高斯图被剪裁的上高度+画布绘制点据画布顶部的距离。具体代码如下:
lowp float y = (textureCoordinate2.y * drawableH + (targetHFill - drawableH )/float(2))/ targetHFill;
lowp float x = (textureCoordinate2.x * drawableW + (targetWFill - drawableW )/float(2))/ targetWFill;
gl_FragColor = texture2D(inputImageTexture, vec2(x, y));
x 的计算方式和 y 值相同。到此,片元着色器的工作完成。
d. GPUImage加载着色器,并返回 UIImage 对象。自此,一张高斯背景叠加高清的图片生成了。在直播流中,不止有一张需要合成,且合成是较耗时操作,需将合成操作放在子线程异步执行。执行完成后同步回到主线程展示。
e. 在使用 GPUImage 时,有⼀个需要注意的地⽅。GPUImage 底层使⽤的 是 openGL , openGL 在后台进行渲染会导致 app 崩溃。所以需要我们退出后台时,停止 openGl 渲染。采取的方式有三种,第⼀种,将合成队列在退出后台前设 置 suspend = yes ,还未开始的合成任务将不再执行,直至进入前台后设 置 suspend = no 。然而,这种方式不能规避还在进行中的任务。于是第⼆种,⾃定 义 NSOperation ,执⾏ operation 时。系统会调用 NSOperation 的 main 函数, 在 main 函数中,写实现代码。在每⼀句实现代码前都判断是否 cancled 。如果 cancled ,直接 return 。在系统即将进⼊后台时,将在运行的任务 cancle 掉, operation 继续执行,监测到已经 cancle 了,就会 return 。然而,粒度还是不够细。如果任务执行到最后一行,且最后一行有 openGL 操作,那么就会拦截不到。第三种, GPUImage 的渲染都是放在⾃⼰的⼀个队列同步执⾏,在接收到系统将进入后台的通知中,加入渲染队列⼀个同步空任务,则系统会执行完空任务前的所有 任务之后,再进入后台。从而避免后台渲染。
自此,多张合成图片的方案就结束了,结合线上数据,本页内存消耗节约10M左右,每张合成图片时间节约8ms左右。