欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  移动技术

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

程序员文章站 2022-10-26 11:58:08
如何设计一个 ios 控件?(ios 控件完全解析) 代码的等级:可编译、可运行、可测试、可读、可维护、可复用 前言 一个控件从外在特征来说,主要是封装这几点: 交互方式 显示样式 数据使用...

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

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


前言

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

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

对外在特征的封装,能让我们在多种环境下达到 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 构造器,如:

- (instancetype)initwithname:(nsstring *)name;
  • 1
    • 1

在实现中一定要调用父类的 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 放置一个背景,你可能会在initwithcoder或awakefromnib中这样写:

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

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

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

如果你要同时支持initwithframe和initwithcoder,那么你可以提供一个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有很多种初始化方式:initwithframe、initwithcoder、init和类方法new,用户完全可以在初始化之后再设置frame属性,而且用户就算使用initwithframe来初始化也避免不了frame的改变,比如在横竖屏切换的时候。为了确保当它的 size 发生变化后其子视图也能同步更新,我们不能一开始就把布局写死(使用约束除外)。

基于 frame

如果你是直接基于 frame 来布局的,你应该确保在初始化的时候只添加视图,而不去设置它们的frame,把设置子视图 frame 的过程全部放到layoutsubviews方法里:

- (instancetype)initwithcoder:(nscoder *)adecoder {
    self = [super initwithcoder:adecoder];
    if (self) {
        [self commoninit];
    }
    return self;
}

- (instancetype)initwithframe:(cgrect)frame {
    self = [super initwithframe:frame];
    if (self) {
        [self commoninit];
    }
    return self;
}

- (void)layoutsubviews {
    [super layoutsubviews];

    self.label.frame = cgrectinset(self.bounds, 20, 0);
}

- (void)commoninit {
    [self addsubview:self.label];
}

- (uilabel *)label {
    if (_label == nil) {
        _label = [uilabel new];
        _label.textcolor = [uicolor graycolor];
    }
    return _label;
}

这么做就能保证 label 总是出现在正确的位置上。
使用 layoutsubviews 方法有几点需要注意:

  1. 不要依赖前一次的计算结果,应该总是根据当前最新值来计算
  2. 由于layoutsubviews方法是在自身的bounds发生改变的时候调用, 因此uiscrollview会在滚动时不停地调用,当你只关心 size 有没有变化的时候,可以把前一次的 size 保存起来,通过与最新的 size 比较来判断是否需要更新,在大多数情况下都能改善性能

基于 auto layout 约束

如果你是基于 auto layout 约束来进行布局,那么可以在commoninit调用的时候就把约束添加上去,不要重写 layoutsubviews 方法,因为这种情况下它的默认实现就是根据约束来计算 frame。最重要的一点,把translatesautoresizingmaskintoconstraints属性设为 no,以免产生nsautoresizingmasklayoutconstraint约束,如果你使用masonry框架的话,则不用担心这个问题,mas_makeconstraints方法会首先设置这个属性为no:

- (void)commoninit {
    ...
    [self setupconstraintsforsubviews];
}

- (void)setupconstraintsforsubviews {
    [self.label mas_makeconstraints:^(masconstraintmaker *make) {
        ...
    }];
}

支持 sizetofit

如果你的控件对尺寸有严格的限定,比如有一个统一的宽高比或者是固定尺寸,那么最好能实现系统给出的约定成俗的接口。

sizetofit 用在基于 frame 布局的情况下,由你的控件去实现 sizethatfits: 方法:

- (cgsize)sizethatfits:(cgsize)size {
    cgsize fitsize = [super sizethatfits:size];
    fitsize.height += self.label.frame.size.height;
    // 如果是固定尺寸,就像 uiswtich 那样返回一个固定 size 就 ok 了
    return fitsize;
}

然后在外部调用该控件的 sizetofit 方法,这个方法内部会自动调用 sizethatfits 并更新自身的 size:

[self.customview sizetofit];
  • 1
    • 1

在 viewcontroller 里调整视图布局

当执行viewdidload方法时,不要依赖self.view的 size。很多人会这样写:

- (void)viewdidload {
    ...
    self.label.width = self.view.width;
}

这样是不对的,哪怕看上去没问题也只是碰巧没问题而已。当 viewdidload 方法被调用的时候,self.view 才刚刚被初始化,此时它的容器还没有对它的 frame 进行设置,如果 view 是从 xib 加载的,那么它的 size 就是 xib 中设置的值;如果它是从代码加载的,那么它的 size 和屏幕大小有关系,除了 size 以外,origin 也不会准确。整个过程看起来像这样:

当访问 viewcontroller 的 view 的时候,viewcontroller 会先执行 loadviewifrequired 方法,如果 view 还没有加载,则调用 loadview,然后是 viewdidload 这个钩子方法,最后是返回 view,容器拿到 view 后,根据自身的属性(如 edgesforextendedlayout、判断是否存在 tabbar、判断 navigationbar 是否透明等)添加约束或者设置 frame。

你至少应该设置autoresizingmask属性:

- (void)viewdidload {
    ...
    self.label.width = self.view.width;
    self.label.autoresizingmask = uiviewautoresizingflexiblewidth;
}

或者在viewdidlayoutsubviews里处理:

- (void)viewdidlayoutsubviews {
    [super viewdidlayoutsubviews];

    self.label.width = self.view.width;
}

如果是基于 auto layout 来布局,则在 viewdidload 里添加约束即可。


正确的处理 touches 方法

如果你需要重写touches方法,那么应该完整的重写这四个方法:

- (void)touchesbegan:(nsset *)touches withevent:(uievent *)event;
- (void)touchesmoved:(nsset *)touches withevent:(uievent *)event;
- (void)touchesended:(nsset *)touches withevent:(uievent *)event;
- (void)touchescancelled:(nsset *)touches withevent:(uievent *)event;

当你的视图在这四个方法执行的时候,如果已经对事件进行了处理,就不要再调用 super 的 touches 方法,super 的 touches 方法默认实现是在响应链里继续转发事件(uiview 的默认实现)。如果你的基类是uiscrollview或者uibutton这些已经重写了事件处理的类,那么当你不想处理事件的时候可以调用self.nextresponder的touches方法来转发事件,其他的情况就调用super的touches方法来转发,比如uiscrollview可以这样来转发触摸事件:

- (void)touchesbegan:(nsset *)touches withevent:(uievent *)event {
    if (!self.dragging) {
        [self.nextresponder touchesbegan: touches withevent:event]; 
    }       

    [super touchesbegan: touches withevent: event];
}

- (void)touchesmoved...

- (void)touchesended...

- (void)touchescancelled...

这么实现以后,当你仅仅只是“碰”一个 uiscrollview 的时候,该事件就有可能被nextresponder处理。
如果你没有实现自己的事件处理,也没有调用nextresponder和super,那么响应链就会断掉。另外,尽量用手势识别器去处理自定义事件,它的好处是你不需要关心响应链,逻辑处理起来也更加清晰,事实上,uiscrollview也是通过手势识别器实现的:

@property(nonatomic, readonly) uipangesturerecognizer *pangesturerecognizer ns_available_ios(5_0);
@property(nonatomic, readonly) uipinchgesturerecognizer *pinchgesturerecognizer ns_available_ios(5_0);


drawrect、calayer 与动画

drawrect方法很适合做自定义的控件,当你需要更新 ui 的时候,只要用setneedsdisplay标记一下就行了,这么做又简单又方便;控件也常常用于封装动画,但是动画却有可能被移除掉。
需要注意的地方:

  1. drawrect里尽量用cgcontext绘制 ui。如果你用addsubview插入了其他的视图,那么当系统在每次进入绘制的时候,会先把当前的上下文清除掉(此处不考虑clearscontextbeforedrawing的影响),然后你也要清除掉已有的subviews,以免重复添加视图;用户可能会往你的控件上添加他自己的子视图,然后在某个情况下清除所有的子视图(我就喜欢这么做):

    [subviews makeobjectsperformselector:@selector(removefromsuperview)];
  2. calayer代替uiview。calayer节省内存,而且更适合去做一个“图层”,因为它不会接收事件、也不会成为响应链中的一员,但是它能够响应父视图(或layer)的尺寸变化,这种特性很适合做单纯的数据展示:

    calayer *imagelayer = [calayer layer];
    imagelayer.frame = rect;
    imagelayer.contents = (id)image;
    [self.view.layer addsublayer:imagelayer];
  3. 如果有可能的话使用setneedsdisplayinrect代替setneedsdisplay以优化性能,但是遇到性能问题的时候应该先检查自己的绘图算法和绘图时机,我个人其实从来没有使用过setneedsdisplayinrect。

  4. 当你想做一个无限循环播放的动画的时候,可能会创建几个封装了动画的 calayer,然后把它们添加到视图层级上,就像我在ios 实现脉冲雷达以及动态增减元素 by swift中这么做的:
    如何设计一个 iOS 控件?(iOS 控件完全解析)
    效果还不错,实现又简单,但是当你按下 home 键并再次返回到 app 的时候,原本好看的动画就变成了一滩死水。

  5. uiimageview的drawrect永远不会被调用:

    special considerations

    the uiimageview class is optimized to draw its images to the display. uiimageview will not call drawrect: in a subclass. if your subclass needs custom drawing code, it is recommended you use uiview as the base class.

  6. uiview的drawrect也不一定会调用,我在 12 年的博客:定制uinavigationbar中曾经提到过 uikit 框架的实现机制:

    众所周知一个视图如何显示是取决于它的 drawrect 方法,因为调这个方法之前 uikit 也不知道如何显示它,但其实 drawrect 方法的目的也是画图(显示内容),而且我们如果以其他的方式给出了内容(图)的话, drawrect 方法就不会被调用了。

    注:实际上 uiview 是 calayer 的delegate,如果 calayer 没有内容的话,会回调给 uiview 的 displaylayer: 或者 drawlayer:incontext: 方法,uiview 在其中调用 drawrect ,draw 完后的图会缓存起来,除非使用 setneedsdisplay 或是一些必要情况,否则都是使用缓存的图。

    uiview和calayer都是模型对象,如果我们以这种方式给出内容的话,drawrect也就不会被调用了:

    self.customview.layer.contents = (id)[uiimage imagenamed:@"appicon"];
    // 哪怕是给它一个 nil,这两句等价
    self.customview.layer.contents = nil;

    我猜测是在calayer的setcontents方法里有个标记,无论传入的对象是什么都会将该标记打开,但是调用setneedsdisplay的时候会将该标记去除。


    uicontrol 与 uibutton

    如果要做一个可交互的控件,那么把uicontrol作为基类就是首选,这个完美的基类支持各种状态:

    • enabled
    • selected
    • highlighted
    • tracking
    • ……

    还支持多状态下的观察者模式:

    @property(nonatomic,readonly) uicontrolstate state;
    
    - (void)addtarget:(id)target action:(sel)action forcontrolevents:(uicontrolevents)controlevents;
    - (void)removetarget:(id)target action:(sel)action forcontrolevents:(uicontrolevents)controlevents;

    这个基类可以很方便地为视图添加各种点击状态,最常见的用法就是将uiviewcontroller的view改成uicontrol,然后就能快速实现resignfirstresponder。

    uibutton自带图文接口,支持更强大的状态切换,titleedgeinsets和imageedgeinsets也比较好用,配合两个基类的属性更好,先设置对齐规则,再设置 insets:

    @property(nonatomic) uicontrolcontentverticalalignment contentverticalalignment;    
    @property(nonatomic) uicontrolcontenthorizontalalignment contenthorizontalalignment;

    uicontrol和uibutton都能很好的支持 xib,可以设置各种状态下的显示和 selector,但是对 uibutton 来说这些并不够,因为normal、highlighted和normal | highlighted是三种不同的状态,如果你需要实现根据当前状态显示不同高亮的图片,可以参考我下面的代码:
    如何设计一个 iOS 控件?(iOS 控件完全解析)如何设计一个 iOS 控件?(iOS 控件完全解析)

    - (void)updatestates {
        [super settitle:[self titleforstate:uicontrolstatenormal] forstate:uicontrolstatenormal | uicontrolstatehighlighted];
        [super setimage:[self imageforstate:uicontrolstatenormal] forstate:uicontrolstatenormal | uicontrolstatehighlighted];
    
        [super settitle:[self titleforstate:uicontrolstateselected] forstate:uicontrolstateselected | uicontrolstatehighlighted];
        [super setimage:[self imageforstate:uicontrolstateselected] forstate:uicontrolstateselected | uicontrolstatehighlighted];
    }

    或者使用初始化设置:

    - (void)commoninit {
        [self setimage:[uiimage imagenamed:@"normal"] forstate:uicontrolstatenormal];
        [self setimage:[uiimage imagenamed:@"selected"] forstate:uicontrolstateselected];
        [self setimage:[uiimage imagenamed:@"highlighted"] forstate:uicontrolstatehighlighted];
        [self setimage:[uiimage imagenamed:@"selected_highlighted"] forstate:uicontrolstateselected | uicontrolstatehighlighted];
    }