Hulk' Den

in-depth thinking and keep moving.

UIButton设置不同状态下的纯色背景图片的最优方案探讨

背景

小组同学最近在封装一组自定义的UIKit控件,目的是做到比UIKit更易用高性能,今天初步读了部分实现代码,发现以下细节:

//基于CoreGraphics根据传入的UIColor绘制并生成UIImage。
+ (UIImage *)imageWithColor:(UIColor *)color {
    CGRect rect = CGRectMake(0.0f, 0.0f, 1.0f, 1.0f);
    UIGraphicsBeginImageContext(rect.size);
    CGContextRef context = UIGraphicsGetCurrentContext();
    CGContextSetFillColorWithColor(context, [color CGColor]);
    CGContextFillRect(context, rect);
    UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
    UIGraphicsEndImageContext();
    return image;
}

达到的目的就是用户只需要关注UIControlStateNormalUIControlStateHighlighted状态下期望的background color,我们在内部来生成UIImage并通过setBackgroundImage:forState:来设置。(只适用于用户期望设置纯色背景的场景)

易用性做到了,性能方面却损失了。(我所遇到的)大多数卡顿场景的瓶颈往往在CPU,(在我之前的几篇关于优化性能的文章里有过讨论),而这段代码中的开辟内存空间,绘制内容再生成图片都是在CPU上进行,无疑是不理想的。那有没有更好的方案呢?

思路一:借助touch-delivery methods

牵扯到触控状态的改变时,我们通常选择重写UIRespondertouchesBegan:withEvent:等方法,几行代码的事儿,但是run起来以后发现跟系统的UIControlStateHighlighted效果还是有不同的,这个在我之前探索iOS触控响应部分的时候就发现了,如下:

系统默认效果 我们实现的效果(注意Title因为是使用setTitle:forState:因此效果正常)

也就是UIControlStateHighlighted效果是有个范围的,但是这个范围apple并没有告诉我们,那这个方案就不完美了。更进一步我想测测这个范围到底是多少,是不是有一定规律呢?三下五除二,发现不论UIButton的size是多大,这个范围大概是(70, 70.5, 70, 70),把这个范围控制逻辑写进touchesMoved:withEvent:,效果跟系统的就一致了!代码如下:

-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    [super touchesBegan:touches withEvent:event];
    [self setBackgroundColor:self.backHighlightColor];
}

-(void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    [super touchesMoved:touches withEvent:event];
    
    UITouch *touch = touches.anyObject;
    CGPoint location = [touch locationInView:[UIApplication sharedApplication].keyWindow];
    CGFloat locationX = location.x;
    CGFloat locationY = location.y;
    //这里默认UIButton的superView是撑满屏幕的,还需完善。
    CGFloat originX = self.frame.origin.x;
    CGFloat originY = self.frame.origin.y;
    CGFloat width = self.frame.size.width;
    CGFloat height = self.frame.size.height;
    BOOL xInScope = (locationX <= originX && originX - locationX < 70.5) || (locationX > originX && locationX - originX - width < 70);
    BOOL yInScope = (locationY <= originY && originY - locationY < 70) || (locationY > originY && locationY - originY - height < 70);
    if (xInScope && yInScope) {
        [self setBackgroundColor:self.backHighlightColor];
    }
    else {
        [self setBackgroundColor:self.backNormalColor];
    }
}

-(void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    [super touchesEnded:touches withEvent:event];
    [self setBackgroundColor:self.backNormalColor];
}

-(void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    [super touchesCancelled:touches withEvent:event];
    [self setBackgroundColor:self.backNormalColor];
}

虽然效果一致了,但是显然并不能用于生产环境,因为(70, 70.5, 70, 70)这组边界值完全是我自己测量的,一是可能有些场景我没有覆盖到,二是指不定哪天apple就把这个值改变了,总之这个思路是不可靠的。

思路二:借助KVO观察UIButton的highlighted变化

经过思路一,我发现一定要寻找一个可靠的参考量。自然而然KVO涌上心头,代码如下:

[self addObserver:self forKeyPath:@"highlighted" options:NSKeyValueObservingOptionNew context:nil];

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
    if ([keyPath isEqualToString:@"highlighted"]) {
        BOOL highlighted = change[NSKeyValueChangeNewKey];
        UIColor *backgroundColor = highlighted ? self.backHighlightColor : self.backNormalColor;
        [self setBackgroundColor:backgroundColor];
        }
}

经过测试,跟思路一有同样的问题,highlighted属性在touchDown之后touchEnd之前一直都是YES,也忽略了范围条件。

思路三:借助KVO观察UIButton上的backgroundImageView的image变化

还是没有找到可靠而又准确的参考量,再想想,其实我们直观看到的就是最准确的,UIButton是随着UIControlStateNormalUIControlStateHighlighted的变化来切换UIImageView的image(经验证这部分逻辑在UIButton的touchesMoved:withEvent:中),通过观察视图层级我们得知UIButton上有两个UIImageView和一个UILabel,分别来承载UIButton的title、image和backgroundImage,那么承载backgroundImage的那个UIImageView的image属性就是我们要找的那个可靠而又准确的参考量。

突破点有了,整理思路如下:

1、触发UIButton添加所需的background UIImageView

当我们调用UIButton的setBackgroundImage:forState:时,UIButton就会addSubview:所需的UIImageView(如需),同理setImage:forState:以及setTitle:forState都有类似的逻辑,当UIButton上有多个UIImageView时我们就不容易区分它们了,因此我们的思路就是尽早的触发setBackgroundImage:forState:便于锁定我们的目标UIImageView,代码如下:

- (instancetype)initWithFrame:(CGRect)frame
{
    self = [super initWithFrame:frame];
    if (self) {
        //保证最先执行
        //zombie_normal(1px x 1px)和zombie_highlighted(1px x 2px)为两张透明的占位图。
        self.buttonBackgroundNormalImage = [UIImage imageNamed:@"zombie_normal"];
        self.buttonBackgroundHighlightedImage = [UIImage imageNamed:@"zombie_highlighted"];
        [self setBackgroundImage:self.buttonBackgroundNormalImage forState:UIControlStateNormal];
        [self setBackgroundImage:self.buttonBackgroundHighlightedImage forState:UIControlStateHighlighted];
        [self setNeedsLayout];
        [self layoutIfNeeded];
    }
    return self;
}

/*
 * 重写以达到记录用户设置的background image
 */
- (void)setBackgroundImage:(UIImage *)image forState:(UIControlState)state {
    [super setBackgroundImage:image forState:state];
    if (state == UIControlStateNormal) {
        _buttonBackgroundNormalImage = image;
    }
    else if (state == UIControlStateHighlighted) {
        _buttonBackgroundHighlightedImage = image;
    }
}

2、锁定background UIImageView

触发addSubview:之后,紧接着就是锁定这个被添加的UIImageView。

-(void)layoutSubviews {
    [super layoutSubviews];
    [self findBackgroundImageView];
}

- (void)findBackgroundImageView {
    //不考虑用户去主动移除UIButton上的UIImageView,那么这个UIImageView一经确定就不会改变了。
    if (self.buttonBackgroundImageView) {
        return;
    }
    for (UIView *subview in self.subviews) {
        if ([subview isMemberOfClass:[UIImageView class]]) {
            self.buttonBackgroundImageView = (UIImageView*)subview;
            break;
        }
    }

    、、、
}

3、添加KVO跟踪变化

锁定之后,添加KVO观察其image属性的变化,根据不同的image设置不同的background color。

static void * BackgroundImageViewContext = &BackgroundImageViewContext;

- (void)findBackgroundImageView {
    、、、

    @try {
        [self.buttonBackgroundImageView removeObserver:self forKeyPath:@"image" context:BackgroundImageViewContext];
    } @catch (NSException *exception) {
    }
    [self.buttonBackgroundImageView addObserver:self forKeyPath:@"image" options:NSKeyValueObservingOptionNew context:BackgroundImageViewContext];
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
    if (context == BackgroundImageViewContext && [keyPath isEqualToString:@"image"]) {
        UIImage *newImage = change[NSKeyValueChangeNewKey];
        if ([newImage isEqual:self.buttonBackgroundNormalImage]) {
            [self setBackgroundColor:self.backNormalColor];
        }
        else if ([newImage isEqual:self.buttonBackgroundHighlightedImage]) {
            [self setBackgroundColor:self.backHighlightColor];
        }
    }
}

这样就达到了我们的目的,满足易用性并兼顾性能。

一次有趣的探索,感谢阅读~

更早的文章

尊重自己

从一个兴趣说起我为什么喜欢踢球?因为我喜欢踢球过程中的操控、组织和超越的感觉,让我兴奋。相比而言,赢球都不能带来这种兴奋。所以我宁愿踢中场不赢球,也不愿意踢后卫赢球(当然只是打个比方,我踢中场反而更容易赢球)。所以就得出了不让我踢中场,我宁愿不去踢球的原因。也得出了为什么我喜欢小罗齐祖,而不喜欢大罗C罗梅西。再进一步分析,是因为操控满足我的控制欲,并让我获得安全感,组织是我对美好事物的追求(有条不紊的进攻和防守),超越让我获得成就感。再进一步分析,不论是控制欲、安全感、成就感还是对美好事物...…

感悟继续阅读