我们在上一篇《通过代码自定义不等高cell》中学习了tableView的相关知识,本文将在上文的基础上,利用storyboard对自定义不等高cell部分做相应的修改。
和上文一样,利用storyboard自定义不等高cell,视图部分仍将是重点,控制器和模型部分的改动不是很大。为了保证逻辑上的清晰,我们还是按照上文的行文顺序,分模块逐一讲解。
还是按照老习惯,新建一个工程,来到ViewController.h文件,让ViewController继承自UITableViewController,来到Main.storyboard,删除View Controller控制器,拖一个UITableViewController,勾选"Is Initial View Controller",绑定类名为ViewController。至此,准备工作基本完成了,现在开始分模块讲解。
一、Model
具体操作步骤与《通过代码自定义不等高cell》一样,这里不做重复。
二、Controller
因为改变了创建cell的方式,所以控制器部分的代码与《通过代码自定义不等高cell》有些不一样,我在这里直接给出详细操作。
我们来到控制器部分,先声明一个数组,用于处理模型数据:
// 用来加载来自statuses.plist文件中的字典数据,并且将其转换为对应的模型 然后存储起来@property(strong,nonatomic)NSArray*statusArr;
导入MJExtension框架,包含MJExtension和ESStatus的头文件,然后重写statusArr的getter方法:
- (NSArray*)statusArr {// 使用MJExtension框架if(!_statusArr) {// 将字典转为模型_statusArr = [ESStatus mj_objectArrayWithFilename:@"statuses.plist"]; }return_statusArr;}
我们在上一篇《通过代码自定义不等高cell》中学习了tableView的相关知识,本文将在上文的基础上,利用storyboard对自定义不等高cell部分做相应的修改。
和上文一样,利用storyboard自定义不等高cell,视图部分仍将是重点,控制器和模型部分的改动不是很大。为了保证逻辑上的清晰,我们还是按照上文的行文顺序,分模块逐一讲解。
还是按照老习惯,新建一个工程,来到ViewController.h文件,让ViewController继承自UITableViewController,来到Main.storyboard,删除View Controller控制器,拖一个UITableViewController,勾选"Is Initial View Controller",绑定类名为ViewController。至此,准备工作基本完成了,现在开始分模块讲解。
一、Model
具体操作步骤与《通过代码自定义不等高cell》一样,这里不做重复。
二、Controller
因为改变了创建cell的方式,所以控制器部分的代码与《通过代码自定义不等高cell》有些不一样,我在这里直接给出详细操作。
我们来到控制器部分,先声明一个数组,用于处理模型数据:
// 用来加载来自statuses.plist文件中的字典数据,并且将其转换为对应的模型 然后存储起来@property(strong,nonatomic)NSArray*statusArr;
导入MJExtension框架,包含MJExtension和ESStatus的头文件,然后重写statusArr的getter方法:
- (NSArray*)statusArr {// 使用MJExtension框架if(!_statusArr) {// 将字典转为模型_statusArr = [ESStatus mj_objectArrayWithFilename:@"statuses.plist"]; }return_statusArr;}
实现数据源协议的- tableView: numberOfRowsInSection:方法,返回tableView中cell的行数。
// 返回tableView中cell行数- (NSInteger)tableView:(UITableView*)tableView numberOfRowsInSection:(NSInteger)section {returnself.statusArr.count;// statusArr数组中元素的个数就是tableView中cell的行数}
新建一个继承自UITableViewCell的ESStatusCell的类,来到Main.storyboard,展开View Controller Scene,选中Table View Cell,将其Class更改为ESStatusCell,如图所示
绑定类名.png
继续点击Xcode右上角的"Show the Attributes Inspector",更改Table View Cell的Style为Custom,给Identifier绑定可重用标识符"status"。注意,这个可重用标识符可随便写,但是千万不要与系统关键字重复,因为稍后后面还要用到。具体操作如下:
绑定cell循环利用标识符.png
包含ESStatusCell的头文件,实现数据源协议的- tableView: cellForRowAtIndexPath:方法:
// 返回tableView中的cell- (UITableViewCell)tableView:--(UITableView)tableView cellForRowAtIndexPath:(NSIndexPath)indexPath {// 1.确立可重用标识符staticNSStringID =@"status";// 这个标识符一定要与前面Xcode中绑定的一致// 2. 根据可重用标识符去缓存池中取出可用的cellESStatusCell *cell = [tableView dequeueReusableCellWithIdentifier:ID];// 3.将statusArr中的模型传递给视图中的模型属性cell.status=self.statusArr[indexPath.row];// 根据cell的行号取出模型数据returncell;}
至此,控制器部分的代码暂时先告一段落,我们先去搞定视图部分。
三、View
来到storyboard,展开View Controller Scene,选中status(也就是tableView的cell),将Prototype Cell拖大一点,方便往里面拖子控件。先拖入一个UIImageView控件,然后点击Xcode右下角的Pin,弹出Add New Constraints界面,去掉Constrain to margins前面的勾,让UIImageView顶部和左边距离其父控件的距离为10,设置UIImageView的宽和高分别为30(一般情况下,大多数控件需要4条约束才能确定其位置和尺寸,不过也有例外,像UILabel只要有两个约束就可以)。注意,对应约束条件的虚线变为红色的实线,以及设置Width和Height前面打上勾才表示约束成功。然后点击Update Frames后面的列表,选中Items of New Constraints,最后点击Add 4 Constraints完成对UIImageView的约束。具体操作如下图:
约束用户头像部分
关于通过storyboard来完成对子控件的AutoLayout约束,一般都比较简单,网上有很多相关的教程,这里不做展开。按照操作步骤,我们再来布局剩下的子控件:
约束完以后大概是这个样子
有必要提一下,在布局完微博正文子控件的时候,一定不要忘了点击Xcode右上角的"Show the Attributes Inspector",设置Label的Lines属性值为0,以保证微博正文文字在必要的时候完成换行。接下来,非常重要的一步,就是给已经布局完成子控件连线:
给cell上的子控件连线
在前面几篇文章中,我们说过,视图部分一般是三个步骤:创建子控件、给子控件设置位置和尺寸,以及给个子控件传递模型数据。完成连线,前面两个步骤就算完成了,现在就是给子控件设置数据:
// 给子控件传递模型数据- (void)setStatus:(ESStatus *)status { _status = status;self.iconImageView.image= [UIImageimageNamed:status.icon];// 给用户头像控件传递对应模型self.nameLabel.text= status.name;// 给用户昵称控件传递对应模型// 设置VIP图标if(status.isVip) {// 如果是VIP用户self.vipImageView.hidden=NO;// 显示用户VIP标识self.vipImageView.image= [UIImageimageNamed:@"vip"];// 设置VIP用的身份标识self.nameLabel.textColor= [UIColororangeColor];// VIP用户昵称用橙色标识}else{// 如果不是VIP用户self.vipImageView.hidden=YES;// 隐藏VIP用户标识self.nameLabel.textColor= [UIColorblackColor];// 非VIP用户昵称用黑色标识}self.text_label.text= status.text;// 给用户微博文本控件传递模型数据// 用户微博内容是否包含图片if(status.picture) {// 如果用户微博内容中包含图片self.pictureImageView.hidden=NO;// 显示用户微博内容图片控件self.pictureImageView.image= [UIImageimageNamed:status.picture];// 设置用户微博正文配图}else{// 如果用户微博内容不包含图片self.pictureImageView.hidden=YES;// 隐藏用户微博内容图片控件}}
我们运行程序看一下:
程序按照预期运行
从程序运行结果来看,UI界面的基本框架已经完成了,剩下的就是对cell的高度进行微调。
四、调整cell的高度
与通过代码创建自定义cell不一样,这回不需要我们手动去计算cell的高度了,只需要对相关代码和约束做相应的调整就可以了。
首先来到控制器文件,在- viewDidLoad方法中设置tableView的rowHeight属性为UITableViewAutomaticDimension,然后设置tableView的estimatedRowHeight为任意大于0的常量:
- (void)viewDidLoad { [superviewDidLoad];self.tableView.rowHeight=UITableViewAutomaticDimension;// 表示cell高度自动计算,其中UITableViewAutomaticDimension是一个常量self.tableView.estimatedRowHeight=44;// 设置cell的估算高度,一般只要是大于0的常量就可以,但是习惯上设置它为系统默认的cell高度}
上面两项设置号称是苹果的Self-Sizing Cells技术,功能非常强大,但是遗憾的是,它只支持iOS 8以后的项目,对于iOS 7就无能为力了。不过,在本项目中,它还不足以解决我们的问题。当然,这不是这项技术不行,主要是我们项目中的微博配图控件在作怪。
因为微博配图控件有时候显示,有时候不显示,所以我们应该对其做特殊处理。来到storyboard,展开微博配图控件下面的Constraints约束,选中高度约束,往ESStatusCell.m文件中拖线,如下图所示:
拿到微博配图控件高度的约束
当然,光拿到微博配图控件的高度约束还不够。因为微博正文控件与微博配图控件之间有10间距的约束,而微博配图控件又与父控件之间有10的间距,所以,当微博配图控件隐藏时,这两个10的间距有一个必须跟着隐藏。我们拿到微博配图控件与父控件之间间距为10的那条约束:
拿到微博配图控件与父控件之间的约束
我们拿到上面这两个控件以后,当微博配图控件需要隐藏时,我们将其常量设置为0。当需要显示微博配图控件时,我们再恢复其常量的值:
// 用户微博内容是否包含图片if(status.picture) {// 如果用户微博内容中包含图片self.pictureImageView.hidden=NO;// 显示用户微博内容图片控件self.pictureImageView.image= [UIImageimageNamed:status.picture];// 设置用户微博正文配图self.pictureHeight.constant=100;// 如果微博有配图,则恢复其高度为100self.pictureBottom.constant=10;// 如果微博有配图,则恢复其底部与父控件之间的距离为10}else{// 如果用户微博内容不包含图片self.pictureImageView.hidden=YES;// 隐藏用户微博内容图片控件self.pictureHeight.constant=0;// 如果微博没有配图,则将其高度设置为0self.pictureBottom.constant=0;// 如果微博没有配图,则设置其底部与父控件之间的距离为0}
运行程序瞅一瞅:
完美运行.gif
简直是完美!O(∩_∩)O~。为了验证上面所说的Self-Sizing Cells技术功能强大,我们可以将其注销掉,然后再来运行项目看一下:
注销自动计算代码以后的运行效果.gif
注销以后,程序的效果立马就变挫了。与通过代码自定义不等高cell比起来,用storyboard完成同样的功能是不是简单方便多了?但是,前面已经说过,这种技术只能应用在iOS 8及其以后的系统,如果要适配iOS 7怎么办呢?作为负责任的好青年,下面就开始完成iOS 8之前的系统适配。
五、适配iOS 8之前的系统
首先来到storyboard,删除微博配图控件底部与父控件之间的那根约束。由于我们之前拿到过那根约束,所以在删除之前先选中它,然后右击,删除它和pictureBottom之间的连线关系:
Snip20160821_86.png
删除该约束与pictureBottom属性之间的连线关系之后,可直接将其从Constraints中删除了:
删除微博配图控件底部与父控件之间的约束.jpg
接着,删除微博配图控件的高度约束与pictureHeight属性之间的连线关系(与上面不同,这次约束不能删):
删除微博配图控件的高度约束与pictureHeight属性之间的连线关系
两约束与两属性之间的连线关系都没了,这两属性也就没用了。来到ESStatusCell.m文件,直接将其删除:
删除多余的属性
多余的属性也没了,那么和该属性有关的代码也就没用了,直接删除解决系统报错:
删除没用的代码.png
我们在上一篇文章中讲过,与tableView的cell高度相关的,除了rowHeight属性之外,也就只剩下-tableView:heightForRowAtIndexPath:代理方法了。来到控制器,实现该方法:
#pragma mark -// 返回cell的高度- (CGFloat)tableView:(UITableView*)tableView heightForRowAtIndexPath:(NSIndexPath*)indexPath {return250;// 250是一个比较吉利的数字,在这里可以帮你解决程序报错}
要计算cell子控件的高度,就必须借助模型,在返回cell高度的方法中实现:
// 拿到indexPath.row这行cell对应的模型,借助模型获取cell内部子控件的高度ESStatusCell *cell = [tableView dequeueReusableCellWithIdentifier:ID];// 这里也要用到可重用标识符,建议将- tableView: cellForRowAtIndexPath:方法中声明可重用标识符的代码拿到外面去cell.status=self.statusArr[indexPath.row];
计算cell的高度。如果有微博配图,我们只需要拿到微博配图控件的最大y值,然后加上其底部与父控件之间10间距就可以;如果没有微博配图,我们只需拿到微博文本控件,然后利用其最大y值加上10间距就能计算出cell的高度。不过,我们之前将控件的属性声明在ESStatusCell的类扩展中,从控制器中是无法访问的,为此,我们必须将其拿到它的头文件中去。也就是说,现在整个视图头文件变成这样:
#import@classESStatus;@interfaceESStatusCell:UITableViewCell/** 用于接收来自控制器传入的模型数据 */@property(strong,nonatomic) ESStatus *status;// 模型属性// 用户头像@property(weak,nonatomic)IBOutletUIImageView*iconImageView;// 不要与tableView的imageView属性冲突// 用户昵称@property(weak,nonatomic)IBOutletUILabel*nameLabel;// 用户的VIP标识@property(weak,nonatomic)IBOutletUIImageView*vipImageView;// 用户发送的微博文字内容@property(weak,nonatomic)IBOutletUILabel*text_label;// 不要与tableView的textLabel属性冲突// 用户发送的微博配图@property(weak,nonatomic)IBOutletUIImageView*pictureImageView;@end
我们再回到控制器中去,在- tableView:heightForRowAtIndexPath:方法中计算相关子控件的最大高度,然后将cell高度返回:
// 计算cell的高度CGFloatcellHeight =0;// 初始化cellHeight的值if(cell.status.picture) {// 如果微博有配图cellHeight =CGRectGetMaxY(cell.pictureImageView.frame) +10;// 微博配图控件的最大y值加上10间距就是cell的高度}else{// 如果没有微博配图cellHeight =CGRectGetMaxY(cell.text_label.frame) +10;// 微博文本控件的最大y值加上10间距就是cell的高度}returncellHeight;
理论上讲,至此,我们的工作算是完成了,不过,我们先运行程序看一下:
待调试版.gif
从结果上看,这个并没有达到我们预期的效果,有些地方间距很大,而有些地方,微博正文文字部分显示不全。这是什么原因呢?想一下,我们计算cell高度的代码,实际上是基于pictureImageView和text_label这两个控件按照约束条件完全显示出来以后这个前提条件的。可实际上,在我们计算cell高度的时候,这两个控件并没有按照相应的约束条件完全显示出来。因此,我们所计算的cell高度的值,并不准确。
视图中的子控件要先接收到数据,然后才能根据相应的约束条件计算出正确的frame,而子控件中的数据是通过- tableView: cellForRowAtIndexPath:这个方法传递过来的。另外,我们计算cell高度的代码是在- tableView:heightForRowAtIndexPath:这个方法中进行的。为了说明问题,我们先来通过打印,查看一下这两个方法的调用顺序:
两个方法的调用顺序
从打印结果来看,- tableView:heightForRowAtIndexPath:这个方法的调用要比- tableView: cellForRowAtIndexPath:这个方法早。也就是说,子控件还没有接收到数据,并且,在有数据的基础上,依据提前设置的约束条件,计算好子控件的frame,我们就已经在- tableView:heightForRowAtIndexPath:这个方法中把cell的高度算完了,这样结果当然不对了!为此,我们必须想办法,要在计算cell高度的代码之前,让cell内部的子控件依据约束条件提前计算一遍。解决的办法是强制刷新一下:
// 强制刷新[cell layoutIfNeeded];
来看一下强制刷新以后的结果:
强制刷新.gif
强制刷新以后,效果是好很多了,但是文字控件部分还是有问题。原因与上面一样,还是因为文字控件最大宽度计算晚了。我们在强制刷新代码之前,先手动计算文字控件的最大宽度:
// 计算文字控件的最大宽度cell.text_label.preferredMaxLayoutWidth= [UIScreenmainScreen].bounds.size.width-20;
再看一下程序运行的效果:
高度计算完毕.gif
六、性能优化
通过前面的学习,我们知道- tableView:heightForRowAtIndexPath:这个方法调用非常频繁,出于程序性能方面的考虑,我们还需要对其做相应的优化工作。
1、优化- tableView:heightForRowAtIndexPath:方法中的cell
我们之所以要在- tableView:heightForRowAtIndexPath:方法中创建cell,目的是为了临时给其传递数据,能提前计算其内部子控件的frame。在代码优化之前,我们先来打印一下这个方法中所创建cell的内存地址:
系统创建了很多cell
从打印出来的cell内存地址,以及右边的垂直拖动条可知,我们创建了非常多的cell。其实没这个必要,我们只需要创建一个cell,然后给它赋值不同的数据就可以了。所以,我们应该将创建cell的代码变成全局变量:
ESStatusCell *cell;// 声明一个全局变量#pragma mark -
// 返回cell的高度- (CGFloat)tableView:(UITableView*)tableView heightForRowAtIndexPath:(NSIndexPath*)indexPath {if(cell ==nil) {// 拿到indexPath.row这行cell对应的模型,借助模型获取cell内部子控件的高度cell = [tableView dequeueReusableCellWithIdentifier:ID]; } cell.status=self.statusArr[indexPath.row];// 计算文字控件的最大宽度cell.text_label.preferredMaxLayoutWidth= [UIScreenmainScreen].bounds.size.width-20;// 强制刷新[cell layoutIfNeeded];// 计算cell的高度CGFloatcellHeight =0;if(cell.status.picture) {// 如果微博有配图cellHeight =CGRectGetMaxY(cell.pictureImageView.frame) +10;// 微博配图控件的最大y值加上10间距就是cell的高度}else{// 如果没有微博配图cellHeight =CGRectGetMaxY(cell.text_label.frame) +10;// 微博文本控件的最大y值加上10间距就是cell的高度}returncellHeight;}
我们再来打印一下cell的内存地址:
其实只有一份cell
虽然还是打印了很多,但是从内存地址来看,其实这些cell都是同一个,这样就达到了一个性能优化的目的。
2、优化- tableView:heightForRowAtIndexPath:方法
从之前的打印结果来看,- tableView:heightForRowAtIndexPath:方法调用非常的频繁,每个cell都最少要调用一次,有些cell甚至会反复调用。那么,为什么要调用这么多次呢?这个还得从tableView的contentSize说起。
我们都知道,苹果做事情是很注重用户体验的。从前面的动图我们能看出,每次tableView出现以后,屏幕右边会出来一个垂直的滚动条,用户可以通过这个滚动条大致知道后面还有多少数据。而屏幕上滚动条的长短又跟tableView的contentSize有关。频繁的调用- tableView:heightForRowAtIndexPath:方法,其目的就是为了设置tableView的contentSize。
这样做,用户体验看起来是不错,不过,当tableView有很多分组,且每组又有很多行时,那么这个方法的调用频率将是非常可观的。有时候一个App打开以后,屏幕会先出现白屏,然后再加载tableView的数据,或多或少是因为这个原因。苹果也意识到了这个问题,所以后来就推出了自动估算技术。
自动估算技术既可以保证App加载时给tableView设置合适的contentSize,又能解决程序性能问题。接下来我们将研究一下自动估算技术。
我们在本文中间提到过,在设置tableView的estimatedRowHeight属性的值时,只要大于0就可以,其实这个值的设置也是有一定技巧的。通常情况下,我们会给这个属性赋值44,因为系统的cell默认高度就是44。但是,作为本项目而言,我们完全可以将它的值设大一点。
我们先通过打印了解一下,在未设置estimatedRowHeight属性值时的情况:
未设置estimatedRowHeight属性的值之前.png
从结果上看,打印是非常之多。我们再来看一下,将estimatedRowHeight属性的值设置为44以后的情况:
只有9条数据
从打印结果来看,打印明显减少了,只有9条。我们再来看一下,将estimatedRowHeight属性的值设置为100以后的情况:
打印结果进一步减少到7条数据.png
打印结果更少了,只有7条数据。我们再来看一下,将estimatedRowHeight属性的值设置为200以后的情况:
打印结果只有5条.png
我们看到,打印结果只剩5条了。我们再来看一下,将estimatedRowHeight属性的值设置为1000以后的情况:
打印结果还是5条.png
我之所以如此执着于打印,并不是因为我是打印狂魔,更不是为了凑篇幅,而是为了说明问题。没有发现,我每次上打印截图的时候,都把控制器屏幕一起截在内?其实,这个estimatedRowHeight属性的值是跟我们的屏幕高度,以及tableView上面cell的实际高度有关的!
我用的模拟器是iPhone 6s,它的屏幕高度为667,而屏幕上现在能看到的cell数量刚好是5个。我们第一次设置estimatedRowHeight属性的值是44,667 / 44 = 15.1,理论上讲,应该会打印16条信息,但是因为我们的tableView总共只有9条cell,因此只打印9条信息。第二次我们设置estimatedRowHeight属性的值为100以后,屏幕打印出7条信息,是因为667 / 100 = 6.67,所以打印7条信息。但是,只要我们estimatedRowHeight属性的值大于667 / 5 = 133.4以后,控制台打印出来的信息永远都只有5条,那是因为我们我们屏幕上现在显示的刚好是5条。
通过上面的分析,我们可以得出这样一个结论:合理的设置estimatedRowHeight属性的值,可以有效减少- tableView:heightForRowAtIndexPath:方法的调用次数,从而达到优化程序性能的目的。但是,这个属性值的大小与屏幕高度,以及cell的实际高度相关,既不是越小越好,也不是越大越好。
除了可以通过tableView的estimatedRowHeight属性实现自动估算技术之外,还可以实现代理的- tableView: estimatedHeightForRowAtIndexPath:方法达到同样的目的。
3、优化计算cell高度的设计方案
我们的代码还有另外一个问题。回想一下我们之前所学到的知识,就是一个控件内部私有的属性,最好是声明在类扩展中,不要暴露在外面。但是,我们在上面计算cell高度时,恰恰违反了这个原则。为了能在控制器中拿到视图内部的相关控件,我们将ESStatusCell的私有属性移到了ESStatusCell.h文件中,这样做是不安全的。为此,我们必须想一个办法,在既不违反设计原则的情况下,又能让外面获取cell的真实高度。
解决案是,把计算cell真实高度的那部分代码封装在ESStatusCell这个类里面,然后再将计算结果返回,以供外界调用。具体操作是,在ESStatusCell.h文件中声明一个cellHeight属性,然后在ESStatusCell.m文件中重写cellHeight的getter方法,最后,控制器可以通过访问cell的cellHeight属性获取相应的结果。
所以,ESStatusCell.h文件最终的代码应该是:
#import@classESStatus;@interfaceESStatusCell:UITableViewCell/** 用于接收来自控制器传入的模型数据 */@property(strong,nonatomic) ESStatus *status;// 模型属性// 提供计算cell高度的借口@property(assign,nonatomic)CGFloatcellHeight;@end
重写cellHeight的getter方法:
// 返回cell的高度。因为- tableView:heightForRowAtIndexPath:方法的关系,这个调用也很频繁- (CGFloat)cellHeight {// 计算文字控件的最大宽度// self.text_label.preferredMaxLayoutWidth = [UIScreen mainScreen].bounds.size.width - 20;// 强制刷新[selflayoutIfNeeded];// 计算cell的高度CGFloatcellHeight =0;if(self.status.picture) {// 如果微博有配图cellHeight =CGRectGetMaxY(self.pictureImageView.frame) +10;// 微博配图控件的最大y值加上10间距就是cell的高度}else{// 如果没有微博配图cellHeight =CGRectGetMaxY(self.text_label.frame) +10;// 微博文本控件的最大y值加上10间距就是cell的高度}returncellHeight;}
不过要注意,因为- tableView:heightForRowAtIndexPath:方法的关系,- cellHeight这个方法的调用也很频繁,而计算微博文本控件的最大宽度只需要计算一次,所以可以考虑将其放在- awakeFromNib方法中执行:
- (void)awakeFromNib {// 计算文字控件的最大宽度self.text_label.preferredMaxLayoutWidth= [UIScreenmainScreen].bounds.size.width-20;}
- tableView:heightForRowAtIndexPath:方法在外面通过访问cell的cellHeight属性,获取cell的真实高度:
ESStatusCell *cell;#pragma mark -// 返回cell的高度- (CGFloat)tableView:(UITableView*)tableView heightForRowAtIndexPath:(NSIndexPath*)indexPath {if(cell ==nil) {// 拿到indexPath.row这行cell对应的模型,借助模型获取cell内部子控件的高度cell = [tableView dequeueReusableCellWithIdentifier:ID]; } cell.status=self.statusArr[indexPath.row];returncell.cellHeight;// 返回cell的高度}
原文链接:http://www.jianshu.com/p/35f5b68bbe4a
实现数据源协议的- tableView: numberOfRowsInSection:方法,返回tableView中cell的行数。
// 返回tableView中cell行数- (NSInteger)tableView:(UITableView*)tableView numberOfRowsInSection:(NSInteger)section {returnself.statusArr.count;// statusArr数组中元素的个数就是tableView中cell的行数}
新建一个继承自UITableViewCell的ESStatusCell的类,来到Main.storyboard,展开View Controller Scene,选中Table View Cell,将其Class更改为ESStatusCell,如图所示
绑定类名.png
继续点击Xcode右上角的"Show the Attributes Inspector",更改Table View Cell的Style为Custom,给Identifier绑定可重用标识符"status"。注意,这个可重用标识符可随便写,但是千万不要与系统关键字重复,因为稍后后面还要用到。具体操作如下:
绑定cell循环利用标识符.png
包含ESStatusCell的头文件,实现数据源协议的- tableView: cellForRowAtIndexPath:方法:
// 返回tableView中的cell- (UITableViewCell*)tableView:--(UITableView*)tableView cellForRowAtIndexPath:(NSIndexPath*)indexPath {// 1.确立可重用标识符staticNSString*ID =@"status";// 这个标识符一定要与前面Xcode中绑定的一致// 2. 根据可重用标识符去缓存池中取出可用的cellESStatusCell *cell = [tableView dequeueReusableCellWithIdentifier:ID];// 3.将statusArr中的模型传递给视图中的模型属性cell.status=self.statusArr[indexPath.row];// 根据cell的行号取出模型数据returncell;}
至此,控制器部分的代码暂时先告一段落,我们先去搞定视图部分。
三、View
来到storyboard,展开View Controller Scene,选中status(也就是tableView的cell),将Prototype Cell拖大一点,方便往里面拖子控件。先拖入一个UIImageView控件,然后点击Xcode右下角的Pin,弹出Add New Constraints界面,去掉Constrain to margins前面的勾,让UIImageView顶部和左边距离其父控件的距离为10,设置UIImageView的宽和高分别为30(一般情况下,大多数控件需要4条约束才能确定其位置和尺寸,不过也有例外,像UILabel只要有两个约束就可以)。注意,对应约束条件的虚线变为红色的实线,以及设置Width和Height前面打上勾才表示约束成功。然后点击Update Frames后面的列表,选中Items of New Constraints,最后点击Add 4 Constraints完成对UIImageView的约束。具体操作如下图:
约束用户头像部分
关于通过storyboard来完成对子控件的AutoLayout约束,一般都比较简单,网上有很多相关的教程,这里不做展开。按照操作步骤,我们再来布局剩下的子控件:
约束完以后大概是这个样子
有必要提一下,在布局完微博正文子控件的时候,一定不要忘了点击Xcode右上角的"Show the Attributes Inspector",设置Label的Lines属性值为0,以保证微博正文文字在必要的时候完成换行。接下来,非常重要的一步,就是给已经布局完成子控件连线:
给cell上的子控件连线
在前面几篇文章中,我们说过,视图部分一般是三个步骤:创建子控件、给子控件设置位置和尺寸,以及给个子控件传递模型数据。完成连线,前面两个步骤就算完成了,现在就是给子控件设置数据:
// 给子控件传递模型数据- (void)setStatus:(ESStatus *)status { _status = status;self.iconImageView.image= [UIImageimageNamed:status.icon];// 给用户头像控件传递对应模型self.nameLabel.text= status.name;// 给用户昵称控件传递对应模型// 设置VIP图标if(status.isVip) {// 如果是VIP用户self.vipImageView.hidden=NO;// 显示用户VIP标识self.vipImageView.image= [UIImageimageNamed:@"vip"];// 设置VIP用的身份标识self.nameLabel.textColor= [UIColororangeColor];// VIP用户昵称用橙色标识}else{// 如果不是VIP用户self.vipImageView.hidden=YES;// 隐藏VIP用户标识self.nameLabel.textColor= [UIColorblackColor];// 非VIP用户昵称用黑色标识}self.text_label.text= status.text;// 给用户微博文本控件传递模型数据// 用户微博内容是否包含图片if(status.picture) {// 如果用户微博内容中包含图片self.pictureImageView.hidden=NO;// 显示用户微博内容图片控件self.pictureImageView.image= [UIImageimageNamed:status.picture];// 设置用户微博正文配图}else{// 如果用户微博内容不包含图片self.pictureImageView.hidden=YES;// 隐藏用户微博内容图片控件}}
我们运行程序看一下:
程序按照预期运行
从程序运行结果来看,UI界面的基本框架已经完成了,剩下的就是对cell的高度进行微调。
四、调整cell的高度
与通过代码创建自定义cell不一样,这回不需要我们手动去计算cell的高度了,只需要对相关代码和约束做相应的调整就可以了。
首先来到控制器文件,在- viewDidLoad方法中设置tableView的rowHeight属性为UITableViewAutomaticDimension,然后设置tableView的estimatedRowHeight为任意大于0的常量:
- (void)viewDidLoad { [superviewDidLoad];self.tableView.rowHeight=UITableViewAutomaticDimension;// 表示cell高度自动计算,其中UITableViewAutomaticDimension是一个常量self.tableView.estimatedRowHeight=44;// 设置cell的估算高度,一般只要是大于0的常量就可以,但是习惯上设置它为系统默认的cell高度}
上面两项设置号称是苹果的Self-Sizing Cells技术,功能非常强大,但是遗憾的是,它只支持iOS 8以后的项目,对于iOS 7就无能为力了。不过,在本项目中,它还不足以解决我们的问题。当然,这不是这项技术不行,主要是我们项目中的微博配图控件在作怪。
因为微博配图控件有时候显示,有时候不显示,所以我们应该对其做特殊处理。来到storyboard,展开微博配图控件下面的Constraints约束,选中高度约束,往ESStatusCell.m文件中拖线,如下图所示:
拿到微博配图控件高度的约束
当然,光拿到微博配图控件的高度约束还不够。因为微博正文控件与微博配图控件之间有10间距的约束,而微博配图控件又与父控件之间有10的间距,所以,当微博配图控件隐藏时,这两个10的间距有一个必须跟着隐藏。我们拿到微博配图控件与父控件之间间距为10的那条约束:
拿到微博配图控件与父控件之间的约束
我们拿到上面这两个控件以后,当微博配图控件需要隐藏时,我们将其常量设置为0。当需要显示微博配图控件时,我们再恢复其常量的值:
// 用户微博内容是否包含图片if(status.picture) {// 如果用户微博内容中包含图片self.pictureImageView.hidden=NO;// 显示用户微博内容图片控件self.pictureImageView.image= [UIImageimageNamed:status.picture];// 设置用户微博正文配图self.pictureHeight.constant=100;// 如果微博有配图,则恢复其高度为100self.pictureBottom.constant=10;// 如果微博有配图,则恢复其底部与父控件之间的距离为10}else{// 如果用户微博内容不包含图片self.pictureImageView.hidden=YES;// 隐藏用户微博内容图片控件self.pictureHeight.constant=0;// 如果微博没有配图,则将其高度设置为0self.pictureBottom.constant=0;// 如果微博没有配图,则设置其底部与父控件之间的距离为0}
运行程序瞅一瞅:
完美运行.gif
简直是完美!O(∩_∩)O~。为了验证上面所说的Self-Sizing Cells技术功能强大,我们可以将其注销掉,然后再来运行项目看一下:
[图片上传中。。。(10)]
注销自动计算代码以后的运行效果.gif
注销以后,程序的效果立马就变挫了。与通过代码自定义不等高cell比起来,用storyboard完成同样的功能是不是简单方便多了?但是,前面已经说过,这种技术只能应用在iOS 8及其以后的系统,如果要适配iOS 7怎么办呢?作为负责任的好青年,下面就开始完成iOS 8之前的系统适配。
五、适配iOS 8之前的系统
首先来到storyboard,删除微博配图控件底部与父控件之间的那根约束。由于我们之前拿到过那根约束,所以在删除之前先选中它,然后右击,删除它和pictureBottom之间的连线关系:
Snip20160821_86.png
删除该约束与pictureBottom属性之间的连线关系之后,可直接将其从Constraints中删除了:
删除微博配图控件底部与父控件之间的约束.jpg
接着,删除微博配图控件的高度约束与pictureHeight属性之间的连线关系(与上面不同,这次约束不能删):
删除微博配图控件的高度约束与pictureHeight属性之间的连线关系
两约束与两属性之间的连线关系都没了,这两属性也就没用了。来到ESStatusCell.m文件,直接将其删除:
删除多余的属性
多余的属性也没了,那么和该属性有关的代码也就没用了,直接删除解决系统报错:
删除没用的代码.png
我们在上一篇文章中讲过,与tableView的cell高度相关的,除了rowHeight属性之外,也就只剩下-tableView:heightForRowAtIndexPath:代理方法了。来到控制器,实现该方法:
#pragma mark -// 返回cell的高度- (CGFloat)tableView:(UITableView*)tableView heightForRowAtIndexPath:(NSIndexPath*)indexPath {return250;// 250是一个比较吉利的数字,在这里可以帮你解决程序报错}
要计算cell子控件的高度,就必须借助模型,在返回cell高度的方法中实现:
// 拿到indexPath.row这行cell对应的模型,借助模型获取cell内部子控件的高度ESStatusCell *cell = [tableView dequeueReusableCellWithIdentifier:ID];// 这里也要用到可重用标识符,建议将- tableView: cellForRowAtIndexPath:方法中声明可重用标识符的代码拿到外面去cell.status=self.statusArr[indexPath.row];
计算cell的高度。如果有微博配图,我们只需要拿到微博配图控件的最大y值,然后加上其底部与父控件之间10间距就可以;如果没有微博配图,我们只需拿到微博文本控件,然后利用其最大y值加上10间距就能计算出cell的高度。不过,我们之前将控件的属性声明在ESStatusCell的类扩展中,从控制器中是无法访问的,为此,我们必须将其拿到它的头文件中去。也就是说,现在整个视图头文件变成这样:
#import@classESStatus;@interfaceESStatusCell:UITableViewCell/** 用于接收来自控制器传入的模型数据 */@property(strong,nonatomic) ESStatus *status;// 模型属性// 用户头像@property(weak,nonatomic)IBOutletUIImageView*iconImageView;// 不要与tableView的imageView属性冲突// 用户昵称@property(weak,nonatomic)IBOutletUILabel*nameLabel;// 用户的VIP标识@property(weak,nonatomic)IBOutletUIImageView*vipImageView;// 用户发送的微博文字内容@property(weak,nonatomic)IBOutletUILabel*text_label;// 不要与tableView的textLabel属性冲突// 用户发送的微博配图@property(weak,nonatomic)IBOutletUIImageView*pictureImageView;@end
我们再回到控制器中去,在- tableView:heightForRowAtIndexPath:方法中计算相关子控件的最大高度,然后将cell高度返回:
// 计算cell的高度CGFloatcellHeight =0;// 初始化cellHeight的值if(cell.status.picture) {// 如果微博有配图cellHeight =CGRectGetMaxY(cell.pictureImageView.frame) +10;// 微博配图控件的最大y值加上10间距就是cell的高度}else{// 如果没有微博配图cellHeight =CGRectGetMaxY(cell.text_label.frame) +10;// 微博文本控件的最大y值加上10间距就是cell的高度}returncellHeight;
理论上讲,至此,我们的工作算是完成了,不过,我们先运行程序看一下:
待调试版.gif
从结果上看,这个并没有达到我们预期的效果,有些地方间距很大,而有些地方,微博正文文字部分显示不全。这是什么原因呢?想一下,我们计算cell高度的代码,实际上是基于pictureImageView和text_label这两个控件按照约束条件完全显示出来以后这个前提条件的。可实际上,在我们计算cell高度的时候,这两个控件并没有按照相应的约束条件完全显示出来。因此,我们所计算的cell高度的值,并不准确。
视图中的子控件要先接收到数据,然后才能根据相应的约束条件计算出正确的frame,而子控件中的数据是通过- tableView: cellForRowAtIndexPath:这个方法传递过来的。另外,我们计算cell高度的代码是在- tableView:heightForRowAtIndexPath:这个方法中进行的。为了说明问题,我们先来通过打印,查看一下这两个方法的调用顺序:
两个方法的调用顺序
从打印结果来看,- tableView:heightForRowAtIndexPath:这个方法的调用要比- tableView: cellForRowAtIndexPath:这个方法早。也就是说,子控件还没有接收到数据,并且,在有数据的基础上,依据提前设置的约束条件,计算好子控件的frame,我们就已经在- tableView:heightForRowAtIndexPath:这个方法中把cell的高度算完了,这样结果当然不对了!为此,我们必须想办法,要在计算cell高度的代码之前,让cell内部的子控件依据约束条件提前计算一遍。解决的办法是强制刷新一下:
// 强制刷新[cell layoutIfNeeded];
来看一下强制刷新以后的结果:
强制刷新.gif
强制刷新以后,效果是好很多了,但是文字控件部分还是有问题。原因与上面一样,还是因为文字控件最大宽度计算晚了。我们在强制刷新代码之前,先手动计算文字控件的最大宽度:
// 计算文字控件的最大宽度cell.text_label.preferredMaxLayoutWidth= [UIScreenmainScreen].bounds.size.width-20;
再看一下程序运行的效果:
高度计算完毕.gif
六、性能优化
通过前面的学习,我们知道- tableView:heightForRowAtIndexPath:这个方法调用非常频繁,出于程序性能方面的考虑,我们还需要对其做相应的优化工作。
1、优化- tableView:heightForRowAtIndexPath:方法中的cell
我们之所以要在- tableView:heightForRowAtIndexPath:方法中创建cell,目的是为了临时给其传递数据,能提前计算其内部子控件的frame。在代码优化之前,我们先来打印一下这个方法中所创建cell的内存地址:
系统创建了很多cell
从打印出来的cell内存地址,以及右边的垂直拖动条可知,我们创建了非常多的cell。其实没这个必要,我们只需要创建一个cell,然后给它赋值不同的数据就可以了。所以,我们应该将创建cell的代码变成全局变量:
ESStatusCell *cell;// 声明一个全局变量#pragma mark -
// 返回cell的高度- (CGFloat)tableView:(UITableView*)tableView heightForRowAtIndexPath:(NSIndexPath*)indexPath {if(cell ==nil) {// 拿到indexPath.row这行cell对应的模型,借助模型获取cell内部子控件的高度cell = [tableView dequeueReusableCellWithIdentifier:ID]; } cell.status=self.statusArr[indexPath.row];// 计算文字控件的最大宽度cell.text_label.preferredMaxLayoutWidth= [UIScreenmainScreen].bounds.size.width-20;// 强制刷新[cell layoutIfNeeded];// 计算cell的高度CGFloatcellHeight =0;if(cell.status.picture) {// 如果微博有配图cellHeight =CGRectGetMaxY(cell.pictureImageView.frame) +10;// 微博配图控件的最大y值加上10间距就是cell的高度}else{// 如果没有微博配图cellHeight =CGRectGetMaxY(cell.text_label.frame) +10;// 微博文本控件的最大y值加上10间距就是cell的高度}returncellHeight;}
我们再来打印一下cell的内存地址:
其实只有一份cell
虽然还是打印了很多,但是从内存地址来看,其实这些cell都是同一个,这样就达到了一个性能优化的目的。
2、优化- tableView:heightForRowAtIndexPath:方法
从之前的打印结果来看,- tableView:heightForRowAtIndexPath:方法调用非常的频繁,每个cell都最少要调用一次,有些cell甚至会反复调用。那么,为什么要调用这么多次呢?这个还得从tableView的contentSize说起。
我们都知道,苹果做事情是很注重用户体验的。从前面的动图我们能看出,每次tableView出现以后,屏幕右边会出来一个垂直的滚动条,用户可以通过这个滚动条大致知道后面还有多少数据。而屏幕上滚动条的长短又跟tableView的contentSize有关。频繁的调用- tableView:heightForRowAtIndexPath:方法,其目的就是为了设置tableView的contentSize。
这样做,用户体验看起来是不错,不过,当tableView有很多分组,且每组又有很多行时,那么这个方法的调用频率将是非常可观的。有时候一个App打开以后,屏幕会先出现白屏,然后再加载tableView的数据,或多或少是因为这个原因。苹果也意识到了这个问题,所以后来就推出了自动估算技术。
自动估算技术既可以保证App加载时给tableView设置合适的contentSize,又能解决程序性能问题。接下来我们将研究一下自动估算技术。
我们在本文中间提到过,在设置tableView的estimatedRowHeight属性的值时,只要大于0就可以,其实这个值的设置也是有一定技巧的。通常情况下,我们会给这个属性赋值44,因为系统的cell默认高度就是44。但是,作为本项目而言,我们完全可以将它的值设大一点。
我们先通过打印了解一下,在未设置estimatedRowHeight属性值时的情况:
未设置estimatedRowHeight属性的值之前.png
从结果上看,打印是非常之多。我们再来看一下,将estimatedRowHeight属性的值设置为44以后的情况:
只有9条数据
从打印结果来看,打印明显减少了,只有9条。我们再来看一下,将estimatedRowHeight属性的值设置为100以后的情况:
打印结果进一步减少到7条数据.png
打印结果更少了,只有7条数据。我们再来看一下,将estimatedRowHeight属性的值设置为200以后的情况:
打印结果只有5条.png
我们看到,打印结果只剩5条了。我们再来看一下,将estimatedRowHeight属性的值设置为1000以后的情况:
打印结果还是5条.png
我之所以如此执着于打印,并不是因为我是打印狂魔,更不是为了凑篇幅,而是为了说明问题。没有发现,我每次上打印截图的时候,都把控制器屏幕一起截在内?其实,这个estimatedRowHeight属性的值是跟我们的屏幕高度,以及tableView上面cell的实际高度有关的!
我用的模拟器是iPhone 6s,它的屏幕高度为667,而屏幕上现在能看到的cell数量刚好是5个。我们第一次设置estimatedRowHeight属性的值是44,667 / 44 = 15.1,理论上讲,应该会打印16条信息,但是因为我们的tableView总共只有9条cell,因此只打印9条信息。第二次我们设置estimatedRowHeight属性的值为100以后,屏幕打印出7条信息,是因为667 / 100 = 6.67,所以打印7条信息。但是,只要我们estimatedRowHeight属性的值大于667 / 5 = 133.4以后,控制台打印出来的信息永远都只有5条,那是因为我们我们屏幕上现在显示的刚好是5条。
通过上面的分析,我们可以得出这样一个结论:合理的设置estimatedRowHeight属性的值,可以有效减少- tableView:heightForRowAtIndexPath:方法的调用次数,从而达到优化程序性能的目的。但是,这个属性值的大小与屏幕高度,以及cell的实际高度相关,既不是越小越好,也不是越大越好。
除了可以通过tableView的estimatedRowHeight属性实现自动估算技术之外,还可以实现代理的- tableView: estimatedHeightForRowAtIndexPath:方法达到同样的目的。
3、优化计算cell高度的设计方案
我们的代码还有另外一个问题。回想一下我们之前所学到的知识,就是一个控件内部私有的属性,最好是声明在类扩展中,不要暴露在外面。但是,我们在上面计算cell高度时,恰恰违反了这个原则。为了能在控制器中拿到视图内部的相关控件,我们将ESStatusCell的私有属性移到了ESStatusCell.h文件中,这样做是不安全的。为此,我们必须想一个办法,在既不违反设计原则的情况下,又能让外面获取cell的真实高度。
解决案是,把计算cell真实高度的那部分代码封装在ESStatusCell这个类里面,然后再将计算结果返回,以供外界调用。具体操作是,在ESStatusCell.h文件中声明一个cellHeight属性,然后在ESStatusCell.m文件中重写cellHeight的getter方法,最后,控制器可以通过访问cell的cellHeight属性获取相应的结果。
所以,ESStatusCell.h文件最终的代码应该是:
#import@classESStatus;@interfaceESStatusCell:UITableViewCell/** 用于接收来自控制器传入的模型数据 */@property(strong,nonatomic) ESStatus *status;// 模型属性// 提供计算cell高度的借口@property(assign,nonatomic)CGFloatcellHeight;@end
重写cellHeight的getter方法:
// 返回cell的高度。因为- tableView:heightForRowAtIndexPath:方法的关系,这个调用也很频繁- (CGFloat)cellHeight {// 计算文字控件的最大宽度// self.text_label.preferredMaxLayoutWidth = [UIScreen mainScreen].bounds.size.width - 20;// 强制刷新[selflayoutIfNeeded];// 计算cell的高度CGFloatcellHeight =0;if(self.status.picture) {// 如果微博有配图cellHeight =CGRectGetMaxY(self.pictureImageView.frame) +10;// 微博配图控件的最大y值加上10间距就是cell的高度}else{// 如果没有微博配图cellHeight =CGRectGetMaxY(self.text_label.frame) +10;// 微博文本控件的最大y值加上10间距就是cell的高度}returncellHeight;}
不过要注意,因为- tableView:heightForRowAtIndexPath:方法的关系,- cellHeight这个方法的调用也很频繁,而计算微博文本控件的最大宽度只需要计算一次,所以可以考虑将其放在- awakeFromNib方法中执行:
- (void)awakeFromNib {// 计算文字控件的最大宽度self.text_label.preferredMaxLayoutWidth= [UIScreenmainScreen].bounds.size.width-20;}
- tableView:heightForRowAtIndexPath:方法在外面通过访问cell的cellHeight属性,获取cell的真实高度:
ESStatusCell *cell;#pragma mark -// 返回cell的高度- (CGFloat)tableView:(UITableView*)tableView heightForRowAtIndexPath:(NSIndexPath*)indexPath {if(cell ==nil) {// 拿到indexPath.row这行cell对应的模型,借助模型获取cell内部子控件的高度cell = [tableView dequeueReusableCellWithIdentifier:ID]; } cell.status=self.statusArr[indexPath.row];returncell.cellHeight;// 返回cell的高度}