代码的等级:可编译、可运行、可测试、可读、可维护、可复用
前言
一个控件从外在特征来说,主要是封装这几点:
- 交互方式
- 显示样式
- 数据使用
对外在特征的封装,能让我们在多种环境下达到 PM 对产品的要求,并且提到代码复用率,使维护工作保持在一个相对较小的范围内;而一个好的控件除了有对外一致的体验之外,还有其内在特征:
- 灵活性
- 低耦合
- 易拓展
- 易维护
通常特征之间需要做一些取舍,比如灵活性与耦合度,有时候接口越多越能适应各种环境,但是接口越少对外产生的依赖就越少,维护起来也更容易。通常一些前期看起来还不错的代码,往往也会随着时间加深慢慢“成长”,功能的增加也会带来新的接口,很不自觉地就加深了耦合度,在开发中时不时地进行一些重构工作很有必要。总之,尽量减少接口的数量,但有足够的定制空间,可以在一开始把接口全部隐藏起来,再根据实际需要慢慢放开。
自定义控件在 iOS
项目里很常见,通常页面之间入口很多,而且使用场景极有可能大不相同,比如一个 UIView
既可以以代码初始化,也可以以 xib
的形式初始化,而我们是需要保证这两种操作都能产生同样的行为。本文将会讨论到以下几点:
- 选择正确的初始化方式
- 调整布局的时机
- 正确的处理 touches 方法
- drawRectCALayer 与动画
- UIControl 与 UIButton
- 更友好的支持 xib
- 不规则图形和事件触发范围(事件链的简单介绍以及处理)
- 合理使用 KVO
如果这些问题你一看就懂的话就不用继续往下看了。
设计方针
选择正确的初始化方式
UIView
的首要问题就是既能从代码中初始化,也能从 xib
中初始化,两者有何不同? UIView 是支持 NSCoding
协议的,当在 xib 或 storyboard 里存在一个 UIView 的时候,其实是将 UIView 序列化到文件里(xib 和 storyboard 都是以 XML 格式来保存的),加载的时候反序列化出来,所以:
- 当从代码实例化 UIView 的时候,
initWithFrame
会执行;- 当从文件加载 UIView 的时候,
initWithCoder
会执行。
从代码中加载
虽然 initWithFrame 是 UIView 的Designated Initializer
,理论上来讲你继承自 UIView 的任何子类,该方法最终都会被调用,但是有一些类在初始化的时候没有遵守这个约定,如 UIImageView
的 initWithImage
和 UITableViewCell
的 initWithStyle:reuseIdentifier
: 的构造器等,所以我们在写自定义控件的时候,最好只假设父视图的 Designated Initializer 被调用。
如果控件在初始化或者在使用之前必须有一些参数要设置,那我们可以写自己的 Designated Initializer 构造器,如:
1 |
- (instancetype)initWithName:(NSString *)name; |
在实现中一定要调用父类的 Designated Initializer,而且如果你有多个自定义的 Designated Initializer,最终都应该指向一个全能的初始化构造器:
1 2 3 4 5 6 7 8 9 10 11 12 |
- (instancetype)initWithName:(NSString *)name { self = [self initWithName:name frame:CGRectZero]; return self; } - (instancetype)initWithName:(NSString *)name frame:(CGRect)frame { self = [super initWithFrame:frame]; if (self) { self.name = name; } return self; } |
并且你要考虑到,因为你的控件是继承自 UIView 或 UIControl 的,那么用户完全可以不使用你提供的构造器,而直接调用基类的构造器,所以最好重写父类的 Designated Initializer,使它调用你提供的 Designated Initializer ,比如父类是个 UIView:
1 2 3 4 |
- (instancetype)initWithFrame:(CGRect)frame { self = [self initWithName:nil frame:frame]; return self; } |
这样当用户从代码里初始化你的控件的时候,就总是逃脱不了你需要执行的初始化代码了,哪怕用户直接调用 init
方法,最终还是会回到父类的 Designated Initializer 上。
从 xib 或 storyboard 中加载
当控件从 xib 或 storyboard 中加载的时候,情况就变得复杂了,首先我们知道有 initWithCoder 方法,该方法会在对象被反序列化的时候调用,比如从文件加载一个 UIView 的时候:
1 2 3 4 5 6 7 8 9 |
UIView *view = [[UIView alloc] init]; NSData *data = [NSKeyedArchiver archivedDataWithRootObject:view]; [[NSUserDefaults standardUserDefaults] setObject:data forKey:@"KeyView"]; [[NSUserDefaults standardUserDefaults] synchronize]; data = [[NSUserDefaults standardUserDefaults] objectForKey:@"KeyView"]; view = [NSKeyedUnarchiver unarchiveObjectWithData:data]; NSLog(@"%@", view); |
执行 unarchiveObjectWithData
的时候, initWithCoder
会被调用,那么你有可能会在这个方法里做一些初始化工作,比如恢复到保存之前的状态,当然前提是需要在 encodeWithCoder
中预先保存下来。
不过我们很少会自己直接把一个 View 保存到文件中,一般是在 xib 或 storyboard 中写一个 View,然后让系统来完成反序列化的工作,此时在 initWithCoder
调用之后,awakeFromNib
方法也会被执行,既然在 awakeFromNib
方法里也能做初始化操作,那我们如何抉择?
一般来说要尽量在 initWithCoder
中做初始化操作,毕竟这是最合理的地方,只要你的控件支持序列化,那么它就能在任何被反序列化的时候执行初始化操作,这里适合做全局数据、状态的初始化工作,也适合手动添加子视图。
awakeFromNib
相较于 initWithCoder
的优势是:当 awakeFromNib
执行的时候,各种 IBOutlet
也都连接好了;而 initWithCoder
调用的时候,虽然子视图已经被添加到视图层级中,但是还没有引用。如果你是基于 xib 或 storyboard 创建的控件,那么你可能需要对 IBOutlet 连接的子控件进行初始化工作,这种情况下,你只能在 awakeFromNib
里进行处理。同时 xib 或 storyboard 对灵活性是有打折的,因为它们创建的代码无法被继承,所以当你选择用 xib 或 storyboard 来实现一个控件的时候,你已经不需要对灵活性有很高的要求了,唯一要做的是要保证用户一定是通过 xib 创建的此控件,否则可能是一个空的视图,可以在 initWithFrame
里放置一个 断言
或者异常来通知控件的用户。
最后还要注意视图层级的问题,比如你要给 View 放置一个背景,你可能会在 initWithCoder
或 awakeFromNib
中这样写:
1 |
[self addSubview:self.backgroundView]; // 通过懒加载一个背景 View,然后添加到视图层级上 |
你的本意是在控件的最下面放置一个背景,却有可能将这个背景覆盖到控件的最上方,原因是用户可能会在 xib 里写入这个控件,然后往它上面添加一些子视图,这样一来,用户添加的这些子视图会在你添加背景之前先进入视图层级,你的背景被添加后就挡住了用户的子视图。如果你想支持用户的这种操作,可以把 addSubview
替换成 insertSubview:atIndex:
。
同时支持从代码和文件中加载
如果你要同时支持 initWithFrame
和 initWithCoder
,那么你可以提供一个 commonInit
方法来做统一的初始化:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |