如何设计一个 iOS 控件?(iOS 控件完全解析)

> 代码的等级:可编译、可运行、可测试、可读、可维护、可复用---# 前言一个控件从外在特征来说,主要是封装这几点:> * 交互方式* 显示样式* 数据使用对外在特征的封装,能让我们在多种环境下达到 PM 对产品的要求,并且提到代码复用率,使维护工作保持在一个相对较小的范围内;而一个好的控件除了有对外一致的体验之外,还有其内在特征:> * 灵活性* 低耦合* 易拓展* 易维护
摘要由CSDN通过智能技术生成

代码的等级:可编译、可运行、可测试、可读、可维护、可复用


前言

一个控件从外在特征来说,主要是封装这几点:

  • 交互方式
  • 显示样式
  • 数据使用

对外在特征的封装,能让我们在多种环境下达到 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 的任何子类,该方法最终都会被调用,但是有一些类在初始化的时候没有遵守这个约定,如 UIImageViewinitWithImageUITableViewCellinitWithStyle:reuseIdentifier: 的构造器等,所以我们在写自定义控件的时候,最好只假设父视图的 Designated Initializer 被调用。

如果控件在初始化或者在使用之前必须有一些参数要设置,那我们可以写自己的 Designated Initializer 构造器,如:

- (instancetype)initWithName:(NSString *)name;

在实现中一定要调用父类的 Designated Initializer,而且如果你有多个自定义的 Designated Initializer,最终都应该指向一个全能的初始化构造器:

- (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:

- (instancetype)initWithFrame:(CGRect)frame {
    self = [self initWithName:nil frame:frame];
    return self;
}

这样当用户从代码里初始化你的控件的时候,就总是逃脱不了你需要执行的初始化代码了,哪怕用户直接调用 init 方法,最终还是会回到父类的 Designated Initializer 上。

从 xib 或 storyboard 中加载

当控件从 xib 或 storyboard 中加载的时候,情况就变得复杂了,首先我们知道有 initWithCoder 方法,该方法会在对象被反序列化的时候调用,比如从文件加载一个 UIView 的时候:

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 放置一个背景,你可能会在 initWithCoderawakeFromNib 中这样写:

[self addSubview:self.backgroundView]; // 通过懒加载一个背景 View,然后添加到视图层级上

你的本意是在控件的最下面放置一个背景,却有可能将这个背景覆盖到控件的最上方,原因是用户可能会在 xib 里写入这个控件,然后往它上面添加一些子视图,这样一来,用户添加的这些子视图会在你添加背景之前先进入视图层级,你的背景被添加后就挡住了用户的子视图。如果你想支持用户的这种操作,可以把 addSubview 替换成 insertSubview:atIndex:

同时支持从代码和文件中加载

如果你要同时支持 initWithFrameinitWithCoder ,那么你可以提供一个 commonInit 方法来做统一的初始化:

- (id)initWithCoder:(NSCoder *)aDecoder {
    self = [super initWithCoder:aDecoder];
    if (self) {
        [self commonInit];
    }
    return self;
}

- (id)initWithFrame:(CGRect)frame {
    self = [super initWithFrame:frame];
    if (self) {
        [self commonInit];
    }

    return self;
}

- (void)commonInit {
    // do something ...
}

awakeFromNib 方法里就不要再去调用 commonInit 了。


调整布局的时机

当一个控件被初始化以及开始使用之后,它的 frame 仍然可能发生变化,我们也需要接受这些变化,因为你提供的是 UIView 的接口,UIView 有很多种初始化方式:initWithFrameinitWithCoderinit 和类方

  • 30
    点赞
  • 121
    收藏
    觉得还不错? 一键收藏
  • 30
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 30
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值