通过实现一个TableView来理解iOS UI编程

项目代码可以从GitHUb上获得:https://github.com/yishuiliunian/DZTableView

先说点题外话。我们在日常做和IOS的UI相关的工作的时候,有一个组件的使用频率非常高–UITabelView。于是就要求我们对UITableView的每一个函数接口,每一个属性都了如指掌,只有这样在使用UITableView的时候,我们才能游刃有余的处理各种需求。不然做出来的东西,很多时候只是功能实现了,但是程序效率和代码可维护性都比较差。举个例子,比如在tableView头部要显示一段文字。我见过的最啰嗦的解决方案是这样的:

  1. 子类化一个UIViewController
  2. 将根View设置成一个UIScrollView
  3. 把头部的Label和TableView加在ScrollView上面
  4. 开始各种调整ScrollView和TableView的delegate调用函数里面的参数,让Label能随着TableView滑动

其实如果你熟悉UITableView,那么你几句话就可以搞定

所谓工欲善其事必先利器,编程语言和各种库其实本质上就是工具而已。你要想用这些工具来实现产品和Leader提出的各种需求。当然,不止是功能上的实现,还包括程序效率,代码质量。特别想着重强调一下代码质量,如果你不想后面维护自己的代码就像噩梦一样,如果你不想一旦新来一个需求就得对代码大刀阔斧的伤筋动骨,如果你不想给后来者埋坑。那么最好就多注意一下。

这里的代码质量并不是简简单单的指代码写点注释了,利用Xcode提供的一些像pragam或者#warning来解释代码。《编写可阅读代码的艺术》还有其他一些编程的书籍也都说道,真正高质量的代码,是不需要注释的。一个好的代码从逻辑上和结构上都是清晰的。我看到很多很难维护的代码都是因为逻辑结构混乱,和设计模式滥用导致的程序结构紊乱。分析其原因,就会发现很多时候,是因为写代码的人对所使用的工具(主要是objc和UIKit)不是非常熟悉,于是就写了很多凑出来的临时方案,简单的实现了功能。表面看起来挺好的,但是实际上代码已经外强中,骨子里都乱了。后期维护起来会让人痛不欲生。

同时,个人一直觉得对于搞IOS开发来说自己实现一遍TableView就像是一种成人礼一样。你能够通过实现一个UITableView来深入的理解UIKit的一些技术细节,对IOS UI编程所使用到的工具,有比较深入的了解。这样,写程序的时候才不会捉襟见肘。

言归正传。开始实现一个TableView。

UIKit给我们提供的基础

又重复了一遍,工欲善其事必先利其器。那么我们就看一下UIKit为我们提供了那些好用的工具让我们来实现一个TableView(当然不是子类化一个UITableView了)。

几何布局框架

《核心动画编程》的某个翻译版本把UIKit的布局模型翻译成了几何布局模型,这个词非常贴切,原始的英文是“struts and springs”。字面翻译就是结构和弹簧。其实说白了就是一种绝对布局模型,这种布局模型的核心数据就是一个对象的几何属性。所以翻译成几何布局模型还是比较贴切的。

在UIKit的几何布局模型中核心的一个数据结构是:CGRect,它确定了一个View(或者Layer,我们这里先只考虑View的情况,想不详细展开来说其他的)在父View中坐标系的绝对位置。

那让我们来看一下CGRect的定义:

我们发现其实一个CGRect中包含了一个原点(point)和一组宽高的信息(size)。其实一个CGRect就是描述了一个长方形的块,就像下图的红色方块一样的东西,我们的每一个View在坐标系中都会被表示为一个长方形的块状物。

比如我们有一个位置是{{10,10},{20,20}}的View:

在它的父类的坐标系中展示如下图:

Unnamed QQ Screenshot20140302084626

我们能够发现红色的View的frame信息所描述的几何位置,其实是其在父View坐标系中的绝对位置。死死的写在那里的。所以像UIKit这样的布局模型又叫绝对布局模型,如果你用过jave的Swing或者c++的QT,你可能会觉得这种绝对布局模型好麻烦,好啰嗦。没有布局管理器的概念,什么都是绝对的。但是只能说各有各的好处把。QT之类的有布局管理器的开发复杂界面的确方便,但是像在iphone这样的手机设备上,机器屏幕有限、设备性能有限,用绝对布局模型还是比较合适。苹果在IOS5之后也引入了一些相对布局的东西(autolayout)正好这里有篇文章是说其性能的Auto Layout Performance on iOS。读过之后你能发现自动布局在复杂界面情况下的性能的确比较差的。所以像UIKit这种比较原始的绝对布局在性能上还是有优势的。

扯回来,通过上图我们能够发现,UIKit的坐标系是一个二维平面坐标系,以左上角为原点,x轴横向扩展,y轴纵向向下扩展。y轴的防线可能和我们以前上学的时候,学的坐标系有点不太一样。这个估计是考虑在ios屏幕上布局的时候我们一般都是从上往下布局,y轴向下方便我们布局吧。既然知道了UIKit的坐标系统是一个二维平面坐标系统,那么我们以前学的很多几何知识就能够在这个坐标系统中尽情使用了。这里知识点太多不一而足,也是埋个伏笔,知道我们在些TableView的时候会用到很多几何上的知识。

同时,你可以把整个UIKit的View布局系统看成一个递归的系统,一个view在父view中布局,父view又在其父view中布局,最后直到在UIWindow上布局。这样递归的布局开来,就能构建起我们看到的app的界面。

UIView相关函数

通用的一些函数

先来说一些UIView的函数,我们着重讲一下和布局相关的,本着做一个TableView的目的嘛,先熟悉我们要用的,其他的读者慢慢看文档。

1、 初始化函数?- (id)initWithFrame:(CGRect)aRect

objc构建一个对象使用的是两段式,首先分配内存alloc然后init,这样的好处就是将内存操作和初始化操作解耦合,让我们能够在初始化的时候对对象做一些必要的操作。这是个很好的思路,我们在做很多事情的时候都可以使用这种两段式的思路。比如布局一个UIView,我们可以分成两部,初始化必要的子view和变量,然后在合适的时机进行布局。

而这个两段式的第一步就是:

这个函数是无论你用什么初始化函数都会被调用的一个,比如你用[UIView new]或者[[UIView alloc] init]都会调用initWithFrame这个函数(有些UIView的子类有特殊情况,比如UITableViewCell,怀疑apple对其做过特殊处理),所以你要是对一个view的变量有初始化的操作尽量往initWithFrame里面放还是非常合适的。 这样能够保证,以后在使用的时候所有的变量都被正确的初始化过。而我们一般会在initWithFrame中做些什么呢?

  1. 添加子View
  2. 初始化属性变量
  3. 其他一些共用操作

所以我们一般会看到这样的代码

在初花的时候将一些共用的初始化操作独立成一个函数commomInit然后再其中做上面说的事情,这样做的好处就是将初始化的代码集中到一起,如果你在实现的一个其他的什么initWithXXX的时候,直接调用commonInit就可以了。

不得不说的是,千万不要被这个函数的名称withFrame给忽悠了,以为这个函数使用布局用的。在代码逻辑比较清晰的工程中,几乎很少看到在这个函数中进行界面布局的工作。因为UIKit给你提供了一个专门的函数layoutSubViews来干这个事情。而且,在这个函数中做的界面布局的工作,是一次性编码,你的界面布局没有任何复用性,如果父View的大小变了之后,这个View还是傻傻的保持原来的模样。同时也会造成,初始化函数臃肿,导致维护上的困难。

2、layoutSubviewssetNeedsLayout

上面说了一些initWithFrame的事情,告诫了千万不要在里面做界面布局的事情,那应该在什么地方做呢?

就是这个地方,这是苹果提供给你专门做界面布局的函数。

我们来看一下文档:

The default implementation of this method does nothing on iOS 5.1 and earlier. Otherwise, the default implementation uses any constraints you have set to determine the size and position of any subviews. Subclasses can override this method as needed to perform more precise layout of their subviews.

You should override this method only if the autoresizing and constraint-based behaviors of the subviews do not offer the behavior you want. You can use your implementation to set the frame rectangles of your subviews directly.

You should not call this method directly. If you want to force a layout update, call the setNeedsLayout method instead to do so prior to the next drawing update. If you want to update the layout of your views immediately, call the layoutIfNeeded method.

苹果都说了这个是子类化View的时候布局用的。那我们最好是老老实实的在里面做布局的工作。

如何布局

这是个比较有意思的话题,因为可能很多人认为很简单,绝对布局嘛就是写一些死数字嘛,直接写CGRectMake(10,10,20,20)这样的坐标不就行了。如果你真这样认为,那么下面的话可能对你有帮助。

首先,尽量不要在布局的时候直接写死数字,比较稳妥的变法是使用常亮或者宏定义,甚至你定义一个临时变量也都ok,这样代码的可维护性就会变得比较好。

其次,谁说绝对布局的框架不能写成相对布局的方式。Apple提供了一个CGGeometry.h的文件,里面定义了大量的方便几何布局的函数。比如CGRectGetMaxX用来获取一个View的最大x坐标。你可能会问这有什么用?我们来看段代码:

下面那个textLabel的布局就是在imageView的大小而确定的。这不就是一些布局管理器做的事情吗,这不就是相对布局的概念嘛。所以我们完全可以使用UIKit的几何坐标系统完成一些相对布局的事情,而且也推荐这样做。

什么时候布局

这个就看功能需要了,不过有一点是肯定的就是不要直接调用layoutSubviews函数。UIKit和runtime是捆绑很密切的,apple为了防止界面重新布局过于频繁,所以只在runloop合适的实际来做布局的工作。里面具体的细节,可以google。

一般你需要重新布局的时候调用setNeedsLayout标记一下,“我需要重新布局了”。就行了,系统会在下次runloop合适的时机给你布局。


3、触摸事件响应相关函数

我们通常不是简单的把View布局到屏幕上就完事了。我们还需要提供能力让用户能与这些View进行交互。 这里还是偷懒了,直接给出前人写的非常好的帖子,大家去看一下:IOS之触摸事件和手势

4、 其他一些函数

还有其他一些函数,需要注意的地方比较少,直接看文档就可以了比如:

调整UIView结构树的函数:

自动布局相关的属性:

UIScrollView相关函数

UITableView的父类是UIScrollView。当然我们要实现一个TableView也需要继承自UIScrollView,那么我们就需要看一下UIScrollView的一些属性和方法。我可能嘴比较笨,一时半会也说不清楚,我就找了一篇解释UIScrollView比较好的文章:Understanding Scroll Views.这里还有一个中文的翻译版本:理解UISCrollView

实现TABLEView

好了在上面的工作准备的差不多了之后,我们大概了解了UIKit给我们提供了一些什么基础的工具。貌似我们就可以大刀阔斧的开始搞了。不对,等等,貌似我们缺了掉什么。好像是设计模式相关的东西,比如享元模式、责任链模式等等。这些东西就在我们用的时候,说一下吧。读者也可以照一本设计模式的书放在身边,以备不时之需。

项目相关的代码可以从:DZTableView获取。

先看个效果图:

Unnamed QQ Screenshot20140302083955

先说一下我们都实现了些什么东西:

  1. 基本的TableView对Cell的布局
  2. Cell的增加和删除
  3. 右滑出现删除和编辑菜单
  4. 下拉输入并新建一个cell

废话不多说开始干活!!!

解释一下整个UI的层次架构

下面这张图大概说明了整个DZTableView的View的结构树。

Unnamed QQ Screenshot20140302084017

整个的TableView分成两个主要的组成部分:DZTableView和DZTableViewCell。这个结构和UITableView的结构是类似的。

DZTableView是tableView的主体部分,主要负责整个tableview的布局和渲染。而DZTableViewCell则是被布局和渲染的对象。DZTableView只是实现了y轴上纵向布局的tableView,没有分组。而我们通常看到的很多很炫的右滑删除等效果则是在DZTableViewCell上扩展得来的。

DZTableViewCell最基础的类主要有三个层次:

  1. 负责渲染转中状态的selectedBackgroudView
  2. 负责渲染和控制滑动效果的actionsView,actionsView上面各种功能的对象是DZCellActionItem
  3. 负责渲染Cell主体内容的contentVIew。

而完成一个TableView主要的工作就是在UISCrollView上对cell进行合理的布局。

子类化UIScrollView实现对Cell的布局

解释一下为什么要从UIScrollView继承来完成TableView。这个和TableView的功能是密切相关的。TableView是一种内容数量大小不确定的布局方式,于是其需要在有限的屏幕(640*960)内展示无限的内容,而有这个功能的类就是UIScrollView。所以DZTableView从UIScrollView继承而来。

然后我们来看一下怎样去布局。分析一下,一个纵向的TableView布局的话,基本上是一个Cell接一个cell在纵向上确定他们的frame就能够布局出来了。那么我们的主要任务就是确定cell的位置。

为了确定cell的位置我们定义了一些变量:

cellHeights存储了所有cell的高度,而cellYOffsets存储了每一个cell在y轴方向上的坐标。每一个cell在横向上是以填满为准的。即从View的最左侧开始布局(x=0)一直到最右侧右侧(width=view的宽度)。所以一般一个cell的绝对位置就是:

开始提到的几个关键的临时变量实在reduceContentSize函数中初始化的

这样一来我们就能够确认每一个cell的在TableView中的绝对位置,以后无论是正常情况下的布局,或者在增加或者删除cell时的布局,就比较简单了。直接调用_rectForCellAtRow函数获取cell的frame,然后布局就ok了。

Cell的重用

在使用UITableView的时候我们应该熟悉这样的接口:

在要使用一个Cell的时候我们先去看看tableView中有没有可以重用的cell,如果有就用这个可以重用的cell,只有在没有的时候才去创建一个Cell。这就是享元模式。

享元模式可以理解成,当细粒度的对象数量特别多的时候运行的代价会相当大,此时运用共享的技术来大大降低运行成本。比较突出的表现就是内容有效的抑制内存抖动的情况发生,还有控制内存增长。它的英文名字是flyweight,让重量飞起来。哈哈。名副其实,在一个TableView中Cell是一个可重复使用的元素,而且往往需要布局的cell数量很大。如果每次使用都创建一个Cell对象,系统的内容抖动会非常明显,而且系统的内存消耗也是比较大的。突然一想,享元模式只是给对象实例共享提供了一个比较霸道的名字吧。

一个典型的享元模式的UML图示例如下:

Unnamed QQ Screenshot20140302084506

而在DZTableView中的实现中,享元模式中Cell的实例的存储和共享主要是在tableView中完成的。

我们定义了两个用来存储两种不同类型的cell的容器:

  1. _cacheCells 存储不再使用过程中,可以被复用的cell
  2. _visibleCellsMap 按照键值对的方式存储了在使用中的cell。key是cell的顺序信息,即是自上而下的第几个cell。

而我们获取一个cell的函数如下:

我们分几种情况来说明一下在布局cell的时候cell的重用问题。

已经在界面上的cell

对于已经在界面的cell我们很明显没有必要去重新构建,甚至没有必要去数据源去要。直接获取到相应的cell就好了。

没有在界面上的cell

对于没有在界面的cell,我们就需要去数据那里去要:

数据源在处理这个请求的时候就是按照上面我们说的享元模式的规则来了:

先去看看tableView中有没有可以重用的cell,有就用,没有就新建。但是tableView是怎么知道有可以重用的cell的呢。

DZTableView 可重用cell的cache

首先我们看一下获取重用cell的函数:

很明显我们去_cacheCells中检查有没有特定identifiy的cell存在,如果有就说明有可重用的cell。这是一个直接获取的过程,那么久必然会存在往里面放cell的过程。

我们在布局完cell的时候,回去清理界面上无用的cell。同时把这些cell放入可重用cell的容器中。等待下次使用的时候,复用。

DZTableViewCell相关

当然,如果只是DZTableView单方面的想去重用cell是不肯能的。我们需要对DZTableViewCell做一些处理,才能够让这套享元模式运转起来。上面的代码中我们已经看到了,我们为DZTableViewCell添加了一些属性:

identifiy标识了这个cell的种类。方便我们复用同一种类的cell。因为DZTableViewCell上可能会存在多种不同种类的cell,如果没有标识的重用起来就不知道获取到的cell是否能够适应特定的种类了。

还有一个index信息,这个是cell的顺序信息,主要是为了方便定位cell的位置用的。

值得注意的是这个定义是以Catogory的方式,定义在DZTableViewCell_private.h文件中的,而该文件只在DZTableView.mm中被引用,这样就避免了上面这些属性暴露给使用者,方式使用者使用方式不当导致的问题。或句话说,这些都是私有变量。必须被保护起来。

同时我们还定义和实现了一个函数:

既然我们要复用一个Cell,那么就得在复用之前把Cell清理干净把,不然带着老数据去使用,用着用着就乱了,你就不知道cell的数据是对的还是错的了。

响应和处理事件

前面说过一个tableView应该是可交互的,而主要的交互就是能够确认用户点击了哪一个cell。

我们在tableview上面加了一个单机的事件UITapGestureRecognizer。然后再相应处处理了一下。主要是获取了用户点击位置,然后找到点击位置上的cell。这样就确认了用户点了哪个cell,在把这个信息传出去就好了。

接口和数据获取

通过上面的阐述我们已经把DZTableView的框架搭起来了,实现了一个TableView的布局方式,还有cell的重用。但是还有一个非常关键的问题,tableView布局信息的数据怎么来,还有我们应该向外给提供者调用什么样的接口。

这个问题,貌似苹果已经做得很好了。而DZTableView要做的就是尽可能的让接口和苹果的保持一致,这样对于使用者而言,没有太大的学习成本。

数据获取

点击等事件响应

DZTableView的成员方法

在DZTableViewCell上扩展功能

选中态

这个应该是所有View的一个基础功能,在很多基于UIView的空间上我们都能看到setHeightlight或者setSelected之类的函数,用来在用户选中该空间的时候,给用户一个反馈。DZTableViewCell的是setSelected。关于选中态主要有两部分的事情,一是选中时机,二是如何表现选中态。

选中态的判断

选中太的判断主要是依靠触摸事件来判断,当用户触摸到cell的时候表示选中,用户手指离开的时候为不选中。于是我们通过重载UIView的一些列触摸事件的响应函数就能够做到对选中态的判断。

选中态的展示

回归一下刚开始的时候说到的,我们整个DZTableView的UIView数层次。一个Cell的最底层是一个selectedBackgroudView。这个就是用来展示选中态的。当Cell的选中态改变的时候,我们只要重新布局一下selectedBackgroudView就可以了。

DZTableView的可扩展性探讨

既然我们要实现一个类似于UITableView一样非常通用的组件,也就要求DZTableView的可扩展性就要好一点。这包括:

  1. 属性的可配置型
  2. 功能上的扩展性,方便子类化

为了展示这个,特意做了右滑删除,还有下拉新建cell的功能。因为本文的主要目的是通过自己构建一个TableView来解释IOS UI编程。所以就不详细展开讨论。看一下代码大概就能明白了。

收藏 3 评论

关于作者:一水流年

(新浪微博:@流年一水) 个人主页 · 我的文章 · 2

相关文章

可能感兴趣的话题



直接登录
最新评论
跳到底部
返回顶部