KittenYang

Runtime 隐藏Status Bar背景

这次的主题的 Runtime ,对于很多人来说,习惯了面向对象的编程语言之后再接触 C 语言一开始是拒绝的。但是当你真的用起来了,你会上瘾,因为这彻彻底底地满足了极客们的折腾心理,用代码操控一切的心理。

就拿我做大象公会的例子来说(对了,这是我在 Smartisan 的第一个项目,也是独立开发的一款App),你知道 Smartisan 一贯的软件设计风格都是拟物化的,真实模拟着现实世界的自然规律。大到一个动画小到一个按钮,无处不体现着这一设计之初就贯彻的理念。然而这对于一个 iOS 工程师来说,不得不说是一个噩耗。你也知道如今强纳肾主管的苹果设计团队已经走了一条不归路了,所有 UI 元素都拍扁了。我不是说扁平化不好,因为这和拟物化只是两种并行的设计风格,没有对错,只有喜好。但你要是一股脑地全部拍扁了,那就有问题了,该给用户明确交互反馈的地方还得拟物。我在第一次拿到产品递过来的需求文档时,就意识到了这将会是个「在平坦的路面上曲折前行的」、「公然叫板苹果设计理念」的累差事。

比如今天我要引出 Runtime 这个话题的引子,就是 —— Navigation Bar.

iOS7 之后(基本上现在这个年代,如果还有产品经理顽固地打算 iOS6 起跳的,你就可以... 不对,你要试图说服他)苹果的 Navigation Bar 虽然并没有和 Status Bar 连在一起,但是因为 Status Bar 的背景会默认与 Navigation Bar 的 barTintColor 一致,所以从视觉上看上去会觉得好像连在一起了。对于 iOS7 之后 Navigation Bar 发生的故事,你可以去 这里 看看。

如果你给 Navigation Bar 设置了一个背景图,就像上面你看到的那样,就会发现图片并不会延伸到 Status Bar 上。

代码也很简单:

[self.navigationBar setBackgroundImage:[UIImage imageNamed:@"title_bar"] forBarPosition:UIBarPositionTop barMetrics:UIBarMetricsDefault];

通常你可能会觉得这已经满足了设计要求,今天故事到这里就应该结束了。但是接下来我翻看需求文档的时候,美好的幻想就被击碎了。不信,你看:

没错,一个很简单的 presentViewController 动画就让处女座产品经理抓狂了,这里请脑补弹幕,各种工程师和产品之间的有趣对话。对话的结果就是,工程师屈服。事实上,任何人看到这多出的黑条都会觉得不舒服,不是吗?

一开始我的方向并不是 runtime,毕竟这把刀锋利是没错,但也容易伤着自己,所以开发的时候原则就是能不用就不用,除非为了设计解耦的架构或者公开API解决不了的时候。我一开始想到的自然还是这三个方法:

- (UIStatusBarStyle)preferredStatusBarStyle NS_AVAILABLE_IOS(7_0);

- (BOOL)prefersStatusBarHidden NS_AVAILABLE_IOS(7_0);

-(UIStatusBarAnimation)preferredStatusBarUpdateAnimation NS_AVAILABLE_IOS(7_0); 

同时在 Info.plist 中增加一个 key View controller-based status bar appearance 并设置为 YES 。以确保每个独立的 ViewController 可以独立地控制 Status Bar 的状态。

结果自然是吓人的。

接下来又是一轮疯狂的 Google 和 StackOverflow,结果自然是无果(如果屏幕前的你,对就是你,有自己的非 runtime 实现的workaround欢迎在评论中留言,我将不胜感激)。到这里,其实就已经满足了 runtime 的使用前提,就是公开API解决不了,何况解耦又是求之不得的,这下我才想到是时候拿出这把锋利的剑了,然后也就可以引出了今天要讨论的内容了。

runtime 的基础普及不是今天的主题,网上有很多相关的文章,比如 这篇 。还有很多,我不推荐了,自学能力强的人其实都能自己搜到。

我简单说下我的理解。翻开 <objc/runtime.h> 的头文件的第一眼,我才知道原来 NSObject 也是放在 <objc/objc.h>的,以前一直以为是Foundation框架中的。上下滑动一看,飞流直下2000行,有种想关屏幕的冲动...简单来说,概念上的建模主要就是以下两个结构体:

struct objc_class {  
    Class isa 

#if !__OBJC2__
    Class super_class  
    const char *name                             
    long version                                 
    long info                                   
    long instance_size                           
    struct objc_ivar_list *ivars                 
    struct objc_method_list **methodLists       
    struct objc_cache *cache                     
    struct objc_protocol_list *protocols         
#endif

}


struct objc_method {  
    SEL method_name                             
    char *method_types                          
    IMP method_imp                                
}

你可以理解为 class 的范围最大,其中包含这 mehodimp 最小,包含在 method 中。

对于 SEL method IMP 的区别,从上面 objc_method 的结构体我们就可以看出,一个方法 Method,其实包含一个

  • 方法名 SEL – 表示该方法的名称;
  • 一个types – 表示该方法参数的类型;
  • 一个 IMP – 指向该方法的具体实现的函数指针,说白了IMP就是实现方法。

还有一些常用的 runtime 函数,你先认个眼熟,因为都会在下面的demo中用到。(我之所以只写出函数名而不写出有哪些参数是有考量的,如果现在就写出一大堆参数,只会让你让你感到迷惑,达不到先认个眼熟的目的,何况Xcode都有自动补齐,你记住了方法名还怕不知道有哪些参数)

  • 添加方法 class_addMethod @note:如果类中不存在这个方法的实现,添加成功;存在这个方法的实现,添加不成功

  • 替换方法 class_replaceMethod @note:如果以name标识的method不存在,就会添加这个method(就好像调用了class_addMethod);如果以name标识的method存在,替换imp

  • 获取class中的某个method class_getInstanceMethod

  • 获取method中的某个imp method_getImplementation or + (IMP)instanceMethodForSelector:(SEL)aSelector;

  • 交换两个method中的imp method_exchangeImplementations

  • 关联对象 objc_setAssociatedObject
    获取对象 objc_getAssociatedObject
    移除对象 objc_removeAssociatedObjects (文档中说,此函数的主要目的是在“初试状态”时方便返回一个对象。你不应该用这个函数来移除对象的属性,因为可能会导致其他clients对其添加的属性也被移除了。规范的方法是:调用 objc_setAssociatedObject 方法并传入一个 nil 值来清除一个关联。)

    (PS:这属于 Method Swizzling 技术, 我通常喜欢直白地叫方法偷换技术。Method Swizzling 也是 OC HOOK的一个方案。)

接下来我们看实际应用。打开层级调试视图:

我用粗体和编号表示了层级关系。UINavigatinBar 是最底层的父视图,其上只有一个子视图 _UINavigationBarBackground_UINavigationBarBackground 上又有两个子视图,分别是 _UIBarBackgroundTopCurtainView_UIBarBackgroundCustomImageContainer ,而我们的目的是让那块黑色区域也就是 _UIBarBackgroundTopCurtainView 消失。

好了,讲到这里逻辑部分已经结束了。下面看看代码中注意的地方。

首先是 + (void)load。load方法会在类第一次加载的时候被调用。因为load调用的时间比较靠前,适合在这个方法里做方法交换。而且国外大神们都这么推荐,没理由不做。

+ (void)load{

    //方法交换应该被保证,在程序中只会执行一次
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{

        //首先动态添加方法。如果类中不存在这个方法的实现,添加成功
        BOOL notAdded = class_addMethod(self, @selector(layoutSubviews), [self instanceMethodForSelector:@selector(__layoutSubviews)], method_getTypeEncoding(class_getInstanceMethod(self, @selector(__layoutSubviews))));

        //因为UINavigationBar已经包含了layoutSubviews的实现,所以不会被添加成功
        if (notAdded) {

            //如果UINavigationBar没有layoutSubviews这个方法的实现,那么添加成功,将被交换方法的实现替换到这个并不存在的实现
            class_replaceMethod(self, @selector(__layoutSubviews), [self instanceMethodForSelector:@selector(layoutSubviews)], method_getTypeEncoding(class_getInstanceMethod(self, @selector(layoutSubviews))));
        }else{

            //否则交换两个方法的实现
            method_exchangeImplementations(class_getInstanceMethod(self, @selector(layoutSubviews)), class_getInstanceMethod(self, @selector(__layoutSubviews)));
        }

    });

}

交换方法实现 method_exchangeImplementations—— 我的理解是相当于拦截一个方法,这执行这个方法之前或之后注入另一个方法的代码。比如在这里在系统自动调用 [self layoutSubviews] 之后,我注入了我自己的代码。类似的思想,比如你想在全局范围上,在任何一个 NSObject alloc 之后打印一次log,就可以利用交换方法实现的技术,只要写一条NSLog就行了。这就有点AOP思想了。在编程思想中,传统的做法是,改造每个业务方法,这样势必把代码弄得一团糟,而且以后再扩展还是更乱,而AOP的思想是引导你从另一个切面来看待问题,比如上面log的例子,不管加在哪,它其实都是属于日志系统这个角度的。 AOP允许你以一种统一的方式在运行时期在想要的地方插入这些逻辑。说到底。就是把不同功能的代码分离开,以便能够分离复杂度。让人在同一时间只用思考代码逻辑,或者琐碎事务。

然后我们在 Navigation Bar 调用 layoutSubviews 时注入逻辑代码。

- (void)__layoutSubviews{

    //这不是递归,其实调用了[self layoutSubviews];
    [self __layoutSubviews];

    if (self.ky_hideStatusBarBackgroungView){

        Class backgroundClass = NSClassFromString(@"_UINavigationBarBackground");
        Class statusBarBackgroundClass = NSClassFromString(@"_UIBarBackgroundTopCurtainView");

        for (UIView * aSubview in self.subviews){

            if ([aSubview isKindOfClass:backgroundClass]) {
                aSubview.backgroundColor = [UIColor clearColor];

                for (UIView * aaSubview in aSubview.subviews){

                    if ([aaSubview isKindOfClass:statusBarBackgroundClass]) {
                        //aaSubview.hidden = YES;                                      
                        aaSubview.backgroundColor = [[UIColor blackColor]colorWithAlphaComponent:0.01];                        
                    }
                }
            }
        }
    }
}

上面的代码就解释两点:

  • 你一定要区分名字SEL和实现IMP.[obj selector] selector只是名字,真正执行的是IMP。
  • [UINavigation layoutSubviews] 会调用 - (void)__layoutSubviews 的实现,也就是花括号中的代码; 而 [self __layoutSubviews] 会调用[UINavigation layoutSubviews] 的实现。所以不会递归。

注:aaSubview.hidden = YES; 在项目中我又发现如果直接 hidden 背景视图会带来一些副作用,比如点击状态栏 TableView 无法回滚到顶部。所以,改成 aaSubview.backgroundColor = [[UIColor blackColor]colorWithAlphaComponent:0.01] 就可以避免这一问题。因为我发现设置成 clearColor 依然无法点击回到顶部,所以目前的解决方法只能是设置一个 0.01 透明度的颜色了。

事实上,除了隐藏 Status Bar 之外,你还可以改变 Status bar 的背景颜色:

if ([aaSubview isKindOfClass:statusBarBackgroundClass]) {  
     aaSubview.backgroundColor = [UIColor lightGrayColor];
}

最后一点,添加关联对象。我在头文件中已经声明了一个变量: @property (nonatomic,assign) BOOL ky_hideStatusBarBackgroungView;

//添加关联对象
-(void)setKy_hideStatusBarBackgroungView:(BOOL)yesOrno{
    objc_setAssociatedObject(self, @selector(ky_hideStatusBarBackgroungView), @(yesOrno), OBJC_ASSOCIATION_ASSIGN);

    [self setNeedsLayout];

}

//获取关联对象
-(BOOL)ky_hideStatusBarBackgroungView{
    return objc_getAssociatedObject(self, _cmd);
}

关于这个const void *key,最好是常量、唯一,比如是 static char 类型的:static char kAssociatedObjectKey;使用时用 &kAssociatedObjectKey。当然更推荐是指针型的:static char *kAssociatedObjectKey; 使用的时候直接就可以用kAssociatedObjectKey。 然而可以用更简单的方式实现:用selector。因为selector也能保证唯一并且是常量,所以可以把方法的地址作为唯一的key,并用_cmd代表当前调用方法的地址,就像:

- (void)addAssociatedObject:(id)object{

    objc_setAssociatedObject(self, @selector(getAssociatedObject), object, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
- (id)getAssociatedObject{

    return objc_getAssociatedObject(self, _cmd);
}

小知识:根据WWDC 2011, Session 322 (第36分钟左右)发布的内存销毁时间表,被关联的对象在生命周期内要比对象本身释放的晚很多。它们会在被 NSObject -dealloc 调用的 object_dispose() 方法中释放。

使用的时候就是一行代码搞定:

 self.navigationController.navigationBar.ky_hideStatusBarBackgroungView = YES;

最后的结局是美好的。

以上就是篇文章的所有内容。不过也觉得这样的导航栏设计也只有 Smartisan 才有了。


后面的故事

强迫症是没有底线的,比如导航条两端圆角之外的黑块看着实在不爽:

好在,解决起来也很容易:

if ([aaSubview isKindOfClass:navBarBackgoundImageClass]) {  
    UIBezierPath *maskPath = [UIBezierPath bezierPathWithRoundedRect:aaSubview.bounds byRoundingCorners:UIRectCornerTopLeft | UIRectCornerTopRight cornerRadii:CGSizeMake(4.5, 4.5)];
    CAShapeLayer *maskLayer = [[CAShapeLayer alloc] init];
    maskLayer.frame = aaSubview.bounds;
    maskLayer.path = maskPath.CGPath;
    aaSubview.layer.mask = maskLayer;
}

^_^


PS:写到这里,我不禁开始想一个哲学问题,就是为什么我们会默认就会想到用Category和runtime,我得出的原因是大家都这么用所以我也想到这么用。但是为什么要这么用?我们完全可以不用单独写一个category,直接在原来的类中就可以处理,还免去了使用runtime。但是为了遵循解耦和AOP的思想,我们一定要尽可能遵循科学规范的程序思想。其实真正的大牛,并不是说他知道的API比你多所以比你牛,而是他可以用更抽象、更解耦、更科学、更易维护的程序设计思想写程序。这就是软件工程师的境界了,非一朝一夕可以做到,你我在这条路上都还要躬行好多年。


PPS: 关于我的第一本交互式iBooks电子书,介绍我所知道的全部动画技巧。没有说教,没有灌输,直接给你上Demo,遇到知识点再发散出去给你介绍。结合我个人还算过得去的包装设计,以及各种可交互控件,带给前所未有的阅读体验。

KittenYang

写写代码,做做设计,看看产品。