让列表默认占位图实现起来更优雅

老规矩,一图胜千言

无数据默认加载图.gif

Demo下载地址

先唠叨几句

最近也是忙于项目进度,学习时间被大大的压缩了下来,距离上次写文章已经过去了整整俩月时间。项目进度已经赶的差不多了所以抽空将项目中遇到的问题及解决方法记录一下。

出现的问题

随着项目渐渐的接近尾声,在项目中列表无数据展示成为了令人头疼的问题。如何优雅且更智能的让占位图在列表无数据时自动展示出来,我刚开始的做法是将图片和提示文字写在一个自定义 view 上,每次当网络请求完成都要去判断当前列表是否有数据,如果没有数据则将 view 加载到列表中;如果有数据则将 view 隐藏掉。这样暴露出很严重的问题是:每个列表对应一个相应的自定义展示 view 对象,如果一个页面有好几个列表,那么对自定义展示图的控制就没难么容易了,而且每次都要计算占位图的frame或者当上拉加载更多时没有请求到数据并且当前列表中有上次请求的数据,如果用当前请求的数据去判断 view 的隐藏与否是不正确的,所以还要拿到当前列表的总数据去判断。

我曾在网上找到了一个很优秀的三方框架DZNEmptyDataSet 下载下来看了一下不是很符合自己的要求所以并没有将其放入自己的项目中(有兴趣的同学可以下载试玩一下)。在12月21日那天在掘金网上无意浏览了一个博客感觉很棒,很符合自己的需求所以就按照博主的 Demo 重写并优化了一下用在了自己的项目中,下面对demo 中的部分代码进行讲解。

说一说 category

我在项目中喜欢用分类 为控制器、view 或者 NSObject 类型等等扩展属性和方法,这样既不与别人写的代码冲突而且实现起来也更优雅。说起 category 必定与 runtime 密不可分,对 runtime 的使用我其实也只会一点点而且大部分都是什么时候用什么时候查,好了,回归正题。

部分代码讲解

重写+(void)load方法,我们在平时写自定 view 时都会在.m 中重写一下-(instancetype)init或者- (instancetype)initWithFrame:(CGRect)frame方法来初始化控件,而tableview或者collectionViewreloaddata时也会调用load方法。如果在列表 reload 的时候对列表内的数据进行检测来达到是否展示占位图的效果,可所谓是一举两得啊。

一言不合就贴一手代码。

+(void)load{
    //为了保证该对象的实例化方法只交换一次
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        Method reloadData = class_getInstanceMethod(self, @selector(reloadData));
        Method m_reloadData = class_getInstanceMethod(self, @selector(m_reloadData));
        method_exchangeImplementations(reloadData, m_reloadData);
        Method delloc = class_getInstanceMethod(self, NSSelectorFromString(@"dealloc"));
        Method m_delloc = class_getInstanceMethod(self, @selector(m_delloc));
        method_exchangeImplementations(delloc, m_delloc);
    });
}

从代码中主要用到runtime两种方法:1.获取当前对象实例化方法class_getInstanceMethod 2.方法交换method_exchangeImplementations;获取的方法分别是reloadDatadelloc这两种方法,获取delloc方法主要是为了移除监听,下面再说这个方法。来看一下reload方法的实现:

-(void)m_reloadData{
    [self m_reloadData];
    //第一次忽略,不展示占位图
    if (!self.isInitFinish) {
        [self m_havingData:YES];
        self.isInitFinish = YES;
        return;
    }
    //  刷新完成之后检测数据量
    dispatch_async(dispatch_get_main_queue(), ^{
        NSInteger numberOfSections = [self numberOfSections];
        //如果没有数据则调用底下方法来创建占位图
        BOOL havingData = NO;
        for (NSInteger i = 0; i < numberOfSections; i++) {
            if ([self numberOfRowsInSection:i] > 0) {
                havingData = YES;
                break;
            }
        }
        [self m_havingData:havingData];
    });
}

不知道大家看完这个方法是不是脑子中已经浮现出创建占位图的大致逻辑了,此方法中还有一个小小的彩蛋不知道大家注意到没有,因为对 runtime 的不了解,反正我当时看的时候没有注意到,后来才发现这一点:在上面那个方法中有没有注意到第一行代码[self m_reloadData];在自己中调用自己?这不就成死循环了吗?我刚开始也并不理解,我也并没有去查资料,我自己的理解是这样的:在reload里面利用method_exchangeImplementations方法将 tableview 的reloadm_reloadData进行交换,每次获取到数据去刷新列表时reloadData方法进行的动态替换,也就是说调用 tableview 的reloadData 实则调用的是m_reloadData,而m_reloadData做的主要工作就是控制占位图的显示与隐藏并没有去刷新列表,那列表怎么刷新啊?那就是调用m_reloadData实则是调用的是列表的reloadData,所以才会出现上面方法中所写的方法。是不是有点绕,上面所述纯粹个人见解。

如何让不同列表有不同占位图

在创建 tableview 或 collectionView 时,实现与之对应的代理是必不可少的一步。那代理有没有可能帮到我们呢?答案是肯定的。说一句题外话:入行有段时间了,渐渐地对代码有了新的认识,一个 app 的构成就是内部收发消息,无论你干什么你都需要将消息传出去,接收消息,反馈消息,请仔细想想无论代码世界还是现实生活,消息的机制被用到万事万物中。回正题,说了句题外话的目的就是为了说明 app 内无论是收消息还是找消息都是通过Selector去做的,我们可以利用 tableview 的代理对象来达到这个目的。

来看代码

@protocol MTableViewDelegate <NSObject>
//如果不在当前类中声明这些方法,当用 self.delgate 查找这些方法时会出现黄色的警告
@optional
- (UIView *)m_noDataView; //完全自定义占位图
- (UIImage *)m_noDataViewImage; //使用默认占位图, 提供一张图片,    可不提供, 默认不显示
- (NSString *)m_noDataViewMessage; //使用默认占位图, 提供显示文字,    可不提供, 默认为暂无数据
- (UIColor *)m_noDataViewMessageColor; //使用默认占位图, 提供显示文字颜色, 可不提供, 默认为灰色
- (NSNumber *)m_noDataViewCenterYOffset; //使用默认占位图, CenterY 向下的偏移量
@end

我们在分类.m 中写上这些方法,然后利用UITableViewDelegate去检测这些方法是否存在

//判断是否响应图片代理
BOOL isImg = [self.delegate    respondsToSelector:@selector(m_noDataViewImage)];

请注意这里的self.delegate这句代码检测的已经不是当前类中的方法了,而是当你初始化 tableview 时将xxx.delegate = self;这样赋值是代理的对象已经是当前类了,所以这个方法是否响应,检索的方法应该是你所赋值的类中。我们常说的一句话就是面向对象,我认为:类也是对象,类的 category 也是一个对象,类与对象没什么区别,类是对象的抽象化,对象是类的具体化,具体的事物是对象, 将具有相同或相似性质的对象的属性或方法抽象出来便是类。如果你分不清什么是类什么是对象,那么你在写代码的时候肯定会遇到不必要的麻烦。

我们知道 tableview 有个属性叫backgroundView,我们可以很巧妙的将自定义的占位视图给这个属性,反正平时这个属性大家也不是很常用。当你将自定义视图给self.backgroundView = xxx时,发现你滚动列表时backgroundView是固定不动的,那有什么办法能让视图跟着列表一起滚动起来,可以设置监听,监听 tableview 的frame,在 tableview 滚动contentOffset 改变时, backgroundView 的frame.origin.y也是同步改变的, 所以我们看起来无论 TableView 怎么滚动占位图都是无动于衷的, 如果我们想让占位图跟随滚动的话, 只要取消掉backgroundView 的 frame.origin.y 的同步更新就好了, 也就是说要保证 frame.origin.y 的值一直为0,具体的可以看下 demo 实现。设置监听必定需要移除监听,如果不在delloc中移除监听的话,由于监听会一直存在必定造成崩溃,所以才动态的去替换delloc方法。我在 demo 中并没有去移除监听,而是在NSObject+MAdd这个文件中调用了

- (void)m_addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath;

这个方法,此方法可在对象消失后自动移除监听。具体实现详见 Demo。

不想要占位图?

如果在实现 tableview 的代理文件中,不实现上述的几个方法就不会加载占位图,demo 已在这一部分做了处理。其实,你还可以为占位视图添加些许方法:提示文字的字体大小、提示文字的富文本属性、加载图片的动画等等,这些需要自行实现。

列表有tableHeaderView怎么办?

有的列表是有tableHeaderView的情况下,上面的方法是行不通的,因为tableHeaderView如果过高的情况下会把backgroundView盖住,导致占位图无法显示,有兴趣的小伙伴可以试一下我说的这种情况。我的做法是:如果tableHeaderView的高度超过了当前tableview高度的一半时将占位图加载到tableFooterView上,前提是当前 tableview 的 fotterview 没有内容。如果高度没有超过一半则还加载到backgroundView上。如果你有更好的方法请及时联系我。

聊一聊 runtime 的简单用法

在我上一篇关于列表的预加载文章中简单叙述了一下关于 runtime 的基本用法,我现在将里面的细节再一一扣一下。

在写 category 类的分类时,经常为现有的类添加私有变量或者为现有的类添加共有属性供外部访问。写过分类的朋友都知道在写分类属性的时候,编译器会给出一个黄色警告,比如我为某个分类创建一个为test属性会报如下警告:

Property 'test' requires method 'setTest:' to be defined 
- use @dynamic or provide a method implementation in this category

简单翻译一下这段话:test属性,必须实现setTest:或将其标记为@dynamic或者在此类里提供方法实现,即 get 方法。

@dynamic 是什么?

我们都知道当你@property一个属性时,编译器会自动给你实现settergetter方法,自动为你实现方法的为@synthesize,而与之对应的则是@dynamic。当一个属性被标记为@dynamic 时,此时编译器就认为该属性的 setter和 getter 方法由用户自己实现,不自动生成。如果该属性被标记为@dynamic就算没有实现 setter 和 getter 方法编译也会通过,如果当程序运行到xxx.test = xxx时,由于缺少与其相对应的 setter 方法导致崩溃;或者xxx *pro = test时,由于缺少 getter 方法同样会导致崩溃。在编译时没有问题,运行时才执行相应的方法,这就是动态绑定,即 runtime 运行时。

在分类中实现 setter 和 getter 方法是用 runtime 中的objc_setAssociatedObjectobjc_getAssociatedObject,来看一下实现方法

-(void)setTest:(NSString *)test{
    objc_setAssociatedObject(self, @selector(test), test, OBJC_ASSOCIATION_RETAIN);
}
-(NSString *)test{
    return objc_getAssociatedObject(self, _cmd);
}

在objc_setAssociatedObject会涉及到四个参数,分别是objectkeyvaluepolicy

1.object,要关联的对象即 self

2.key,这个 key 值必须保证是一个对象级别的唯一常量与创建 tablviewcell 所创建的 ID 类似;

一般来说,有以下三种推荐的 key 值:

① 声明static const char * key_m_test = "key_m_test";使用 &key_m_test 作为 key 值这个是需要加&符号获取地址;

② 声明 static const void * key_m_test = "key_m_test" ,使用key_m_test 作为 key 值;以上两种写法我认为是一个是 C 写法,一个为 OC 写法

③ 用 selector ,使用 getter 方法的名称作为 key 值。因为它省掉了一个变量名,非常优雅地解决了命名问题。

3.value 即当前属性的值

4.policy 关联策略

 typedef OBJC_ENUM(uintptr_t, objc_AssociationPolicy) {
     OBJC_ASSOCIATION_ASSIGN = 0,//弱引用对象
     OBJC_ASSOCIATION_RETAIN_NONATOMIC = 1, //强引用对象且为非原子操作
     OBJC_ASSOCIATION_COPY_NONATOMIC = 3,//复制关联对象且为非原子操作
     OBJC_ASSOCIATION_RETAIN = 01401,//强引用对象且为原子操作         
     OBJC_ASSOCIATION_COPY = 01403//复制关联对象为原子操作
 };
 将前三种翻译过来即:
 @property (nonatomic, assign)
 @property (nonatomic, strong)
 @property (nonatomic, copy)

细心的同学会发现,在写 getter 方法是用到了一个_cmd,自己写一下发现他是一个SEL类型,这个_cmd是什么?以下为我查阅的资料:

Objective-C中的方法默认被隐藏了两个参数:self_cmd。self指向对象本身,_cmd指向方法本身。举两个例子来说明:

例一:

- (NSString *)name

这个方法实际上有两个参数:self和_cmd。

例二:

- (void)setValue:(int)value

这个方法实际上有三个参数:self, _cmd和value。被指定为动态实现的方法的参数类型有如下的要求:

A.第一个参数类型必须是id(就是self的类型)

B.第二个参数类型必须是SEL(就是_cmd的类型)

C.从第三个参数起,可以按照原方法的参数类型定义。

举两个例子来说明:

例一:setHeight:(CGFloat)height中的参数height是浮点型的,所以第三个参数类型就是f。

例二:再比如setName:(NSString *)name中的参数name是字符串类型的,所以第三个参数类型就是@类型

有一句代码是xxx.name = @"xxx";程序运行到这里时,会去.m中寻找setName:这个赋值方法。但是.m里并没有这个方法,于是程序进入methodSignatureForSelector:中进行消息转发。执行完之后,以"v@:@"作为方法签名类型返回。

这里v@:@是什么东西呢?实际上,这里的第一个字符v代表函数的返回类型是void,(后面三个字符分别self, _cmd, name这三个参数的类型id, SEL, NSString。

接着程序进入forwardInvocation方法。得到的key为方法名称setName:,然后利用[invocationgetArgument:&obj atIndex:2];获取到参数值,这里是“xxx”。这里的index为什么要取2呢?如前面分析,第0个参数是self,第1个参数是_cmd,第2个参数才是方法后面带的那个参数。

最后利用一个可变字典来赋值。这样就完成了整个setter过程。

有一句代码是 NSLog(@"%@", xxx.test);,程序运行到这里时,会去.m中寻找name这个取值方法 。但是.m里并没有这个取值方法,于是程序进入methodSignatureForSelector:中进行消息转发。执行完之后,以"@@:"作为方法签名类型返回。这里第一字符@代表函数返回类型NSString,第二个字符@代表self的类型id,第三个字符:代表_cmd的类型SEL。

接着程序进入forwardInvocation方法。得到的key为方法名称name。

最后根据这个key从字典里获取相应的值,这样就完成了整个getter过程。

以上是 runtime 在赋值与取值做的整个流程,这些资料我也是在网上找的自己对其流程也知之甚少,希望与之共进步。

总结

知其然,知其所以然。做技术需要有一丝不苟的精神,曾同事说过这么一段话:不要以为将别人的东西粘贴复制过来,改改名字就变成了自己的东西了。这句话也令我反思,是啊,现在搞技术都太浮躁,无论 demo 是如何实现的,用到了哪些知识从不关心,符合自己需求的直接粘贴复制过来,我想这种做法就违背了写 demo 人的根本意图了。仰望星空的同时一定要脚踏实地,我将与你一路同行。

推荐阅读更多精彩内容

  • 概述在iOS开发中UITableView可以说是使用最广泛的控件,我们平时使用的软件中到处都可以看到它的影子,类似...
    liudhkk阅读 8,089评论 3 38
  • 1.badgeVaule气泡提示 2.git终端命令方法> pwd查看全部 >cd>ls >之后桌面找到文件夹内容...
    i得深刻方得S阅读 3,890评论 1 8
  • *面试心声:其实这些题本人都没怎么背,但是在上海 两周半 面了大约10家 收到差不多3个offer,总结起来就是把...
    Dove_iOS阅读 25,359评论 30 469
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 124,449评论 18 136
  • 何其有幸,在人生最美的年华,能与你倾心相遇。人生苍茫几十载,需经山水几千重。而我,却能够在花开的年纪,得你倾慕眷顾...
    幸运小王子阅读 288评论 0 3
  • 如若我是那颗竹 受世间所有的讽 默默生长默默扎 不问青也不问花 昔日狂风你未在 那时大雨你不来 如今突兀在丛林 傲...
    梦见小溪阅读 110评论 1 1
  • 曾经我也像你一样 哼着跑调的歌 穿着自己钟爱颜色的衣 吃着诱人的不健康食品 写着凌乱的随笔 梳着飞扬的马尾 乐着身...
    对调阅读 199评论 2 4