Apr 12

iOS界面置灰方案讨论

Lrdcq , 2020/04/12 01:59 , 程序 , 閱讀(7992) , Via 本站原創
由于国家公祭日等一些原因。我们客户端也需要将部分核心页面置灰。虽然可以通过预留的大促方案或者主题化方案进行配置。但是,1. 相关方案的配置并不彻底,一般多多少少会有遗漏的地方,那在这类场合会相当眨眼。2. ugc内容无法控制。因此,我们需要尝试一个更通用的方案进行处理。

方案对标

和安卓和h5的同学了解后,其他两端都有相当全局性的方案,类似于css:filter: grayscale(100%)。即可完成这个任务。那iOS可以么?
经过百度,在AppKit中,确实有类似的方式:
CIFilter *filter = [CIFilter filterWithName:@"balabala"];
//do something filter
view.layer.filters = @[filter];
[view setLayerUsesCoreImageFilters:YES];

可惜,只有NSView可以这么用,UIKit并没有迁移相关功能。
那么在iOS我们能想到的能一劳永逸的方法只有:

1. hook所有的颜色生成即UIColor的构造器,将所有颜色生成转换为灰度颜色。
2. hook所有的UIImageView的image setter,保证展示的图片同构CIFilter转换为灰度图。

这样大概率可以保证正常的业务代码都被处理掉。

实践

UIColor

hook掉UIColor的主要rgb构造器,即colorWithRed:green:blue:alpha:即可解决绝大部分业务代码编写的颜色了。当然会有个别地方用到[UIColor white],不过那是不用转换的东西,也不太会有人在业务代码中用到[UIColor red]这种标准颜色。
hook之后通过标准灰度公式:

Gray = R*0.299 + G*0.587 + B*0.114

转换为灰度值,并改通过colorWithWhite:alpha:构造即可。

图片

整个过程追麻烦的就是图片处理了。毕竟必须通过CIFilter去处理,性能消耗大户

- 首先,如果我们有异步绘制的方案,通过异步绘制可以大大缓解界面卡顿。但是如果没有,要自己处理的话,异步图片获取(比如远端图片)还可以hookSDWebImage之类的去插入异步流程,但是正常的本地图片/同步图片获取,想不入侵业务代码的异绘就很困难了。结论上,本方案已定包含同步即主线程转换图片的方案。

- 参考友商的做法,ugc图片变灰最好的方式是图片cdn支持一下。不过目前业界大厂的图片cdn也就部分视频网站支持了,因此还是考虑客户端本地方案。

核心流程是将一个UIImage通过CIFilter转换为灰度图,目前使用的代码如下:
//input: image
CIContext *context = [CIContext contextWithOptions:nil];

CIImage *ciImage = [[CIImage alloc] initWithImage:image];

CIFilter *filter = [CIFilter filterWithName:@"CIColorMonochrome"];
[filter setValue:ciImage forKey:kCIInputImageKey];
[filter setValue:@1.0 forKey:@"inputIntensity"];
[filter setValue:[CIColor colorWithRed:0.5 green:0.5 blue:0.5] forKey:@"inputColor"];
        
CIImage *result = [filter valueForKey:kCIOutputImageKey];

CGImageRef cgImage = [context createCGImage:result fromRect:result.extent];

UIImage *filteredImage = [[UIImage alloc] initWithCGImage:cgImage scale:image.scale orientation:image.imageOrientation];
        
CGImageRelease(cgImage);
return filteredImage;

参考了部分网络上的代码,不过大部分多是有坑的。有几个注意的点:

1. CIFilter的选择,目前主要可选的包括CIColorMonochrome,CIMinimumComponent,CIPhotoEffectNoir,都是在干置灰这件事情。不过目前使用的CIColorMonochrome是基于染色实现的,整体性能远高于CIMinimumComponent与CIPhotoEffectNoir。考虑到有主线程的使用场景,因此采用高性能的方案。

2. 新图片生成务必使用initWithCGImage:scale:orientation:以继承原图的核心熟悉,避免本地图片在不同尺寸下出现大小/位置异常。

3. CGImageRef需要CGImageRelease(cgImage)手工释放一下。但是如果代码中有其它使用方式务必注意释放的时机。

以上方案开始执行,结果:卡,一个字,卡。毕竟涉及到大量的主线程图片重新生成,cpu/内存抖动,特别是列表上,性能消耗太厉害了。观察实际图片使用情况:本地资源大量重复使用,远端大图列表中复用。两大性能消耗点,因此明显都可以通过缓存解决。

因此参考SDWebImage建设灰度图内存缓存:
//初始化
kLMUIImageViewGreyCache = [[NSCache alloc] init];
kLMUIImageViewGreyCache.totalCostLimit = 1024 * 1024 * 512;
//交换setter
- (void)klm_grey_setImage:(UIImage *)image {
    if (!image) {
        [self klm_grey_setImage:image];
        return;
    }
    UIImage *cache = [kLMUIImageViewGreyCache objectForKey:image];
    
    if (cache) {
        [self klm_grey_setImage:cache];
    } else {
        UIImage *filteredImage = [self createGreyImage:image];//生成灰度图
        if (filteredImage && image) {
            [kLMUIImageViewGreyCache setObject:filteredImage forKey:image cost:(image.size.height * image.size.width * image.scale * image.scale)];
        }
    }
}

两个小点:

1. 通过NSCache建设缓存,类似于sd设置totalCostLimit,cost是通过(image.size.height * image.size.width * image.scale * image.scale)计算的近似位图大小,完全和sd一致。
2. 可以对本地图片和远端图片,大图片和小图片设置不同的缓存池以保证不同的使用场景。

完成以上工作后,注意到还有一个场景有遗漏:gif播放并不是UIImageView的setter。目前业界主流采用的gif播放框架是FLAnimatedImage。关注FLAnimatedImage的源码:FLAnimatedImage会把每一帧取出来放在一个cachedFramesForIndexes的数组里。因此,最合适的hook实际,是hook这个数组插入的图片的时机——然后这样完成后,实际运行效果:

1. 初始化的时候非常非常卡。视gif长度卡顿时长无限。
2. 关注FLAnimatedImage的实现方式,gif刚开始播放时会尝试同步解码所有的frame,因此gif过长的话,会直接处理卡死。
3. 可以考虑异步绘制并更新cachedFramesForIndexes数组的内容,不过需要保证多个相关内部属性的数据同步。
4. 或者放弃gif变灰。

限定

以上方案,对全局的布局都变灰了。但是实际业务情况,业务可能只希望核心页面,或者主页面变灰,因此需要对以上流程进行改造:

1. 核心思路为布局标记法,通过UIView拓展添加置灰标记属性,并且可以通过nextResponder递归的向上查找。

2. UIColor的hook就不好使了,转而hook各种setColor的方法,来保证hook的入口有UIView可以获取。

3. 延迟获取,大部分情况setter执行的时候,view还没有帖到父view上,因此考虑相关setter在获取不到的时候,加入延迟判断+处理。针对UIImageView的情况,考虑到性能问题,还是建议业务代码去做专门的特殊标记。


【2021/09/07补充】filter方案

虽然之前查询到苹果文档上对CALayer上所有filter相关属性标明“This property is not supported on layers in iOS.”,不过在iOS15后,实际测试发现compositingfilter(https://developer.apple.com/documentation/quartzcore/calayer/1410748-compositingfilter?language=objc)属性,对于部分filter通过字符串设置的方式其实是有效的。有效的filter范围是:绝大部分CICategoryCompositeOperation。也就是说iOS其实支持的是blending逻辑。因此可以利用blending方案去构造置灰方案。样例代码:
//构造一个overlay的view放置在界面顶层
    UIView *over = [[UIView alloc] initWithFrame:CGRectMake(100, 100, 200, 200)];
    over.userInteractionEnabled = NO;//事件穿透
    over.backgroundColor = [UIColor colorWithWhite:0.6 alpha:1];//背景颜色为灰度
    over.layer.compositingFilter = @"saturationBlendMode";//设置compositingFilter

几个关键点:

- compositingFilter作用与layer背景,而blendmode本身作用于图层与其下方的内容。因此实际方案是在vc或者window上放置一个overlay view,只设置background去完成遮罩置灰。

- 别忘了userInteractionEnabled去完成overlay view的事件穿透。

- 调整backgroundColor与compositingFilter完成灰度计算逻辑,可以多试试寻找最佳效果。

- 这个方案会导致整体离屏渲染,注意性能。

测试图:
點擊在新視窗中瀏覽此圖片
关键词:oc , ios , 置灰
logo