开源学习之DZNEmptyDataSet

这是一个为UITableView、UICollectionView在无数据状态下提供空态展示视图的组件,由于他们的父类都是UIScrollView,所以作者通过为UIScrollView提供分类接口来达到兼容两者的效果,接口头文件很简洁,如下:

1
2
3
4
5
6
7
8
@interface UIScrollView (EmptyDataSet)

@property (nonatomic, weak, nullable) IBOutlet id <DZNEmptyDataSetSource> emptyDataSetSource;
@property (nonatomic, weak, nullable) IBOutlet id <DZNEmptyDataSetDelegate> emptyDataSetDelegate;
@property (nonatomic, readonly, getter = isEmptyDataSetVisible) BOOL emptyDataSetVisible;

- (void)reloadEmptyDataSet;
@end

分类本身很简单,需要关注的就是其中的两个协议一个方法。两个协议中一个是代理属性,用来传递空态下视图的交互状态,还有一个是数据源属性,用来定制空态的显示效果,另外的方法就是刷新是否要展示空态视图。

DZNWeakObjectContainer

一般来说,对于代理、数据源这些属性,会通过使用week修饰来达到破除循环引用,在头文件中,作者也是这么修饰代理和数据源的,但是,同以往为已有类添加分类属性不同的是,作者这里使用一个弱引用对象来防止循环引用。

举设置数据源为例,在分类的setter方法中,使用OBJC_ASSOCIATION_RETAIN_NONATOMIC来修饰要添加的分类属性,这个属性是DZNWeakObjectContainer类的示例,它本身会弱引用传入的datasource对象。

1
2
3
4
5
6
7
8
9
10
- (void)setEmptyDataSetSource:(id<DZNEmptyDataSetSource>)datasource
{
if (!datasource || ![self dzn_canDisplay]) {
[self dzn_invalidate];
}

objc_setAssociatedObject(self, kEmptyDataSetSource, [[DZNWeakObjectContainer alloc] initWithWeakObject:datasource], OBJC_ASSOCIATION_RETAIN_NONATOMIC);

// 省略其他逻辑
}

因此,看起来是为UIScrollView添加一个遵守DZNEmptyDataSetSource协议的属性,但是内部却是一个DZNWeakObjectContainer类的实例属性,而这个Container类的属性会弱引用遵守协议的实例对象。

通过其头文件也可以验证这点:

那么在UIScrollView分类对数据源属性的getter方法中,其实就是通过将Container解包,传递出去其弱引用的对象。

1
2
3
4
5
- (id<DZNEmptyDataSetSource>)emptyDataSetSource
{
DZNWeakObjectContainer *container = objc_getAssociatedObject(self, kEmptyDataSetSource);
return container.weakObject;
}

除此之外,该类在组件中就没有其他用途了。

swizzle

上面在设置数据源和代理的时候,刻意没有提及其他的内容,其实在数据源的setter方法中除了设置关联对象,还有进行swizzle,完整的setter方法如下:

这里对reloadData方法和UITableView的endUpdates方法下钩子,和以往简单的swizzle不同的是,作者在这里使用了一个记录表(_impLookupTable)来确保每个类只被hook了一次,目前只有UITableView和UICollectionView两个类。

_impLookupTable本身是一个全局的可变字典,这个字典以当前被hook的类名加SEL为key,以一个字典为value,具体的数据格式如下:

其中DZNSwizzleInfoPointerKey存储的是dzn_newImplementation他其实是方法的实现指针:

1
2
3
// Swizzle by injecting additional implementation
Method method = class_getInstanceMethod(baseClass, selector);
IMP dzn_newImplementation = method_setImplementation(method, (IMP)dzn_original_implementation);

具体的为_impLookupTable添加元素的方法如下:

1
2
3
4
5
6
7
// in -swizzleIfPossible:
// Store the new implementation in the lookup table
NSDictionary *swizzledInfo = @{DZNSwizzleInfoOwnerKey: baseClass,
DZNSwizzleInfoSelectorKey: NSStringFromSelector(selector),
DZNSwizzleInfoPointerKey: [NSValue valueWithPointer:dzn_newImplementation]};

[_impLookupTable setObject:swizzledInfo forKey:key];

作者提到了一些关于swizzle的相关文章:right-way-to-swizzle 和 JUSEmptyViewController。

_impLookupTable有了数据之后,每次调用数据源的setter方法都会先根据类名和SEL进行判断,如果已经存在就不进行处理。

这里作者做了2个判断。

第一个是如果对应class和SEL已经存在了就不处理:

第二个是如果该class和SEL对应下的实例存在,也不处理:

dzn_reloadEmptyDataSet

上面在UIScrollView分类中的reloadEmptyDataSet方法内部其实现逻辑是dzn_reloadEmptyDataSet方法,而这个方法也是将reloadData方法hook之后的替换方法,所以组件会在这个方法里面处理空态数据。

根据一系列的代理方法以及计算UITableView或者UICollectionView是否有数据之后,进入展示空态界面的逻辑中,这一系列方法列举如下:

在进入到展示空态界面的逻辑中,作者提供了一个私有默认的空态视图类:DZNEmptyDataSetView,该类也是UIScrollView的一个私有分类属性:emptyDataSetView,提供了市面上常见软件空态视图中的控件组合。

首先会去查看这个emptyDataSetView是否有父类,判断是否要添加到当前ScrollView上,作者在每一次reloadEmptyDataSet的时候都会将emptyDataSetView中已有的数据和布局进行重置:

1
2
3
4
5
6
7
8
9
10
11
12
13
// in DZNEmptyDataSetView.m
- (void)prepareForReuse
{
[self.contentView.subviews makeObjectsPerformSelector:@selector(removeFromSuperview)];

_titleLabel = nil;
_detailLabel = nil;
_imageView = nil;
_button = nil;
_customView = nil;

[self removeAllConstraints];
}

除了默认的空态视图,还提供了可以自定义的视图,假如没有使用自定义视图,组件内部会根据设置的空态数据源对emptyDataSetView进行UI上的处理,主要就是文本、图片、位置、颜色等。

上面是根据数据添加空态视图,移除空态视图的逻辑其实有三个地方:

  • UIScrollView空态数据源的setter方法中
  • UIScrollView空态代理的setter方法中
  • 还有一个是在上面的dzn_reloadEmptyDataSet方法中如果不需要添加空态视图

由于dzn_reloadEmptyDataSet方法是对reloadData方法的hook,所以emptyDataSetView的添加和移除可以实时根据数据来完成。

在添加emptyDataSetView的时候,作者还为其添加了一个Tap手势,看效果也仅仅是为代理提供是否可以执行点击逻辑使用。

life cycle

在组件内部,作者提供了一套空态视图的生命周期,会传递给空态代理,他们分别是:

  • emptyDataSetWillAppear
  • emptyDataSetDidAppear
  • emptyDataSetWillDisappear
  • emptyDataSetDidDisappear

这些方法会在相应的位置来传递状态,就不详细的展开了。

参考文章

  • 参考文章列表