前言
閱讀優秀的開源項目是提高編程能力的有效手段,我們能夠從中開拓思維、拓寬視野,學習到很多不同的設計思想以及最佳實踐。閱讀他人代碼很重要,但動手仿寫、練習卻也是很有必要的,它能進一步加深我們對項目的理解,將這些東西內化為自己的知識和能力。然而真正做起來卻很不容易,開源項目閱讀起來還是比較困難,需要一些技術基礎和耐心。
本系列將對一些著名的iOS開源類庫進行深入閱讀及分析,并仿寫這些類庫的基本實現,加深我們對底層實現的理解和認識,提升我們iOS開發的編程技能。
DZNEmptyDataSet
DZNEmptyDataSet是UITableView/UICollectionView父類的擴展,當視圖沒有內容時用來顯示自定義的空白頁。它的效果如下:

DZNEmptyDataSet地址:https://github.com/dzenbot/DZNEmptyDataSet,這里我們選取了它早期的v1.0版,講一下它的內部實現原理和實現過程。
實現原理
DZNEmptyDataSet通過KVO監控列表頁的內容變化,當頁面沒有數據時,顯示自定義的空白頁。
DZNEmptyDataSet像UITableView一樣提供數據源DataSource協議,讓使用者能夠完全配置空白頁的顯示內容和樣式。
實現過程
DZNTableDataSetView
DZNTableDataSetView類是頁面沒內容時顯示的空白頁,它提供的接口屬性如下,都是空白頁顯示的內容項。
@interface DZNTableDataSetView : UIView
//單行標題
@property (nonatomic, strong, readonly) UILabel *titleLabel;
//多行詳細內容標簽
@property (nonatomic, strong, readonly) UILabel *detailLabel;
//圖片
@property (nonatomic, strong, readonly) UIImageView *imageView;
//按鈕
@property (nonatomic, strong, readonly) UIButton *button;
//控件之間的垂直間距
@property (nonatomic, assign) CGFloat verticalSpace;
......
@end
這里的頁面布局使用了原生約束。關于UI布局,更詳細的介紹在后面。
這個頁面的內容根據使用者的配置動態變化。比如使用者只選擇了標題和詳情,那么圖片和按鈕就要隱藏。在約束中,找出要顯示的控件,調整間距,達到動態布局的目的。
- (void)updateConstraints
{
[super updateConstraints];
[_contentView removeConstraints:_contentView.constraints];
CGFloat width = (self.frame.size.width > 0) ? self.frame.size.width : [UIScreen mainScreen].bounds.size.width;
NSInteger multiplier = [[UIDevice currentDevice] userInterfaceIdiom] == UIUserInterfaceIdiomPhone ? 16 : 4;
NSNumber *padding = @(roundf(width/multiplier));
NSNumber *imgWidth = @(roundf(_imageView.image.size.width));
NSNumber *imgHeight = @(roundf(_imageView.image.size.height));
NSNumber *trailing = @(roundf((width-[imgWidth floatValue])/2.0));
NSDictionary *views = NSDictionaryOfVariableBindings(self,_contentView,_titleLabel,_detailLabel,_imageView,_button);
NSDictionary *metrics = NSDictionaryOfVariableBindings(padding,trailing,imgWidth,imgHeight);
if (!self.didConfigureConstraints) {
self.didConfigureConstraints = YES;
[self addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"H:[self]-(<=0)-[_contentView]"
options:NSLayoutFormatAlignAllCenterY metrics:nil views:views]];
[self addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"V:[self]-(<=0)-[_contentView]"
options:NSLayoutFormatAlignAllCenterX metrics:nil views:views]];
}
[_contentView addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"H:|-padding-[_titleLabel]-padding-|"
options:0 metrics:metrics views:views]];
[_contentView addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"H:|-padding-[_detailLabel]-padding-|"
options:0 metrics:metrics views:views]];
[_contentView addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"H:|-padding-[_button]-padding-|"
options:0 metrics:metrics views:views]];
[_contentView addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"H:|-trailing-[_imageView(imgWidth)]-trailing-|"
options:0 metrics:metrics views:views]];
NSMutableString *format = [NSMutableString new];
NSMutableArray *subviews = [NSMutableArray new];
if (_imageView.image) [subviews addObject:@"[_imageView(imgHeight)]"];
if (_titleLabel.attributedText.string.length > 0) [subviews addObject:@"[_titleLabel]"];
if (_detailLabel.attributedText.string.length > 0) [subviews addObject:@"[_detailLabel]"];
if ([_button attributedTitleForState:UIControlStateNormal].string.length > 0) [subviews addObject:@"[_button]"];
[subviews enumerateObjectsUsingBlock:^(id obj, NSUInteger idx, BOOL *stop) {
[format appendString:obj];
if (idx < subviews.count-1) {
if (_verticalSpace > 0) [format appendFormat:@"-%.f-", _verticalSpace];
else [format appendString:@"-11-"];
}
}];
if (format.length > 0) {
[_contentView addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:[NSString stringWithFormat:@"V:|%@|", format]
options:0 metrics:metrics views:views]];
}
}
這個類中,屬性的初始化使用了Getter和Setter方式。推薦一篇大神寫的文章,其中介紹了Getter/Setter的使用和實踐:iOS應用架構談 view層的組織和調用方案,還有一個開源項目,也是用這種風格寫的代碼,作者是大神@ZeroJ,項目地址:https://github.com/jasnig/DouYuTVMutate,有興趣可以去學習一下。
UITableView+DataSet
通過監控tableView的contentSize屬性變化,決定是否顯示自定義空白頁。
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
{
if (context == DZNContentSizeCtx)
{
NSValue *new = [change objectForKey:@"new"];
NSValue *old = [change objectForKey:@"old"];
if (new && old && ![new isEqualToValue:old]) {
if ([keyPath isEqualToString:kContentSize]) {
[self didReloadData];
}
}
}
else {
[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
}
}
在使用者設置數據源委托時,向頁面添加contentSize屬性監控。
- (void)setDataSetSource:(id<ZCJTableViewDataSetDataSouce>)source
{
[self addObserver:self forKeyPath:kContentSize options:NSKeyValueObservingOptionNew|NSKeyValueObservingOptionOld|NSKeyValueObservingOptionPrior context:ZCJContentSizeCtx];
objc_setAssociatedObject(self, kDataSetDataSource, source, OBJC_ASSOCIATION_ASSIGN);
}
這里使用對象關聯技術,為這個UITableView的擴展類添加屬性。關于對象關聯,詳細介紹在后面。
從datasource中取得使用者填寫的數據,像title,detail,image等配置自定義的空白頁。
- (void)reloadDataSet
{
if ([self totalNumberOfRows] == 0)
{
[self.dataSetView updateConstraintsIfNeeded];
// Configure labels
self.dataSetView.detailLabel.attributedText = [self detailLabelText];
self.dataSetView.titleLabel.attributedText = [self titleLabelText];
// Configure imageview
self.dataSetView.imageView.image = [self image];
// Configure button
[self.dataSetView.button setAttributedTitle:[self buttonTitleForState:0] forState:0];
[self.dataSetView.button setAttributedTitle:[self buttonTitleForState:1] forState:1];
[self.dataSetView.button setBackgroundImage:[self buttonBackgroundImageForState:0] forState:0];
[self.dataSetView.button setBackgroundImage:[self buttonBackgroundImageForState:1] forState:1];
// Configure vertical spacing
self.dataSetView.verticalSpace = [self verticalSpace];
// Configure scroll permission
self.scrollEnabled = [self isScrollAllowed];
// Configure background color
self.dataSetView.backgroundColor = [self dataSetBackgroundColor];
if (self.scrollEnabled && [self dataSetBackgroundColor]) self.backgroundColor = [self dataSetBackgroundColor];
self.dataSetView.hidden = NO;
[self.dataSetView updateConstraints];
[self.dataSetView layoutIfNeeded];
[UIView animateWithDuration:0.25
animations:^{
self.dataSetView.alpha = 1.0;
}
completion:NULL];
}
else if ([self isDataSetVisible] && [self needsReloadSets]) {
[self invalidateContent];
}
}
比如空白頁的標題,取得由使用者配置的數據源
- (NSAttributedString *)titleLabelText
{
if (self.dataSetSource && [self.dataSetSource respondsToSelector:@selector(titleForTableViewDataSet:)]) {
return [self.dataSetSource titleForTableViewDataSet:self];
}
return nil;
}
基礎知識
UI布局
對于iOS UI布局方式,一般有四種。分別是:IB布局、手寫Frame布局、代碼原生約束布局以及以Masonry為代表的第三方布局類庫。
IB布局是在XIB或StoryBoard上對頁面控件布局,IB布局能夠直觀、方便地調整界面元素的關系,開發效率比較高。但對于一些動態展示、定制的頁面,代碼邏輯不夠清晰。
原生約束布局是用NSLayoutConstraint
控制UI。優點是:靈活,不依賴上層。缺點是不夠直觀,不方便,代碼量大,不易維護。
Masonry等第三方框架布局,優點是代碼優雅,可讀性強,功能比原生約束更強大,缺點是會造成UI布局的依賴,比如自定義的view,放到其他app中,Masonry也要帶進來。
因此,更好的布局選擇要根據具體情況選擇。業務型簡單頁面選用IB布局方式,業務型復雜頁面像動態頁面或定制視圖選用Masonry第三方。自定義view選用原生約束。
對象關聯(associated objects)
對象關聯(associated objects)是Objective-C 2.0的一個特性,它允許開發者為已存在的類的擴展添加自定義屬性,這幾乎彌補了Objective-C的最大缺點。
常用的兩個函數:
void objc_setAssociatedObject(id object, const void *key, id value, objc_AssociationPolicy policy)
id objc_getAssociatedObject(id object, const void *key)
object是關聯的對象,key是屬性關鍵字,value是屬性內容,policy是關聯對象的行為,比如強引用非原子化的OBJC_ASSOCIATION_RETAIN_NONATOMIC。_
舉個例子,為UIButton添加一個擴展,實現block回調按鈕點擊事件:
.h文件
#import <UIKit/UIKit.h>
typedef void (^btnBlock)();
@interface UIButton (Block)
- (void)handelWithBlock:(btnBlock)block;
@end
.m文件
#import "UIButton+Block.h"
#import <objc/runtime.h>
static const char btnKey;
@implementation UIButton (Block)
- (void)handelWithBlock:(btnBlock)block
{
if (block)
{
objc_setAssociatedObject(self, &btnKey, block, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
[self addTarget:self action:@selector(btnAction) forControlEvents:UIControlEventTouchUpInside];
}
- (void)btnAction
{
btnBlock block = objc_getAssociatedObject(self, &btnKey);
block();
}
@end
仿寫DZNEmptyDataSet
下面我們自己練習仿寫這個類庫,以加深我們對它內部實現的理解和掌握。為了簡單起見,我們只實現基本的功能,一些細節都忽略掉了。
ZCJTableDataSetView類,沒有數據時的空白頁,為了簡化操作和細節,這里只添加三個屬性。
@interface ZCJTableDataSetView : UIView
@property (nonatomic, strong) UILabel *titleLbl;
@property (nonatomic, strong) UILabel *detailLbl;
@property (nonatomic, strong) UIImageView *imgView;
@end
動態的內容頁,使用原生約束布局,控制控件在垂直方向上的顯示以及間距。
NSMutableString *format = [NSMutableString new];
NSMutableArray *subviews = [NSMutableArray new];
if (_imgView.image) [subviews addObject:@"[_imgView(100)]"];
if (_titleLbl.attributedText.string.length > 0) [subviews addObject:@"[_titleLbl]"];
if (_detailLbl.attributedText.string.length > 0) [subviews addObject:@"[_detailLbl]"];
[subviews enumerateObjectsUsingBlock:^(id obj, NSUInteger idx, BOOL *stop) {
[format appendString:obj];
if (idx < subviews.count-1) {
[format appendString:@"-11-"];
}
}];
if (format.length > 0) {
[_contentView addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:[NSString stringWithFormat:@"V:|%@|", format]
options:0 metrics:metrics views:views]];
}
UITableView+DataSet
創建一個數據源協議,用于向空白頁提供配置數據。
@protocol ZCJTableViewDataSetDataSouce <NSObject>
- (NSAttributedString *)titleForTableViewDataSet:(UITableView *)tableView;
- (NSAttributedString *)detailForTableViewDataSet:(UITableView *)tableView;
- (UIImage *)imageForTableViewDataSet:(UITableView *)tableView;
@end
在協議對象設置時,添加對tableView的contentSize屬性監控。
- (void)setDataSetSource:(id<ZCJTableViewDataSetDataSouce>)source
{
[self addObserver:self forKeyPath:kContentSize options:NSKeyValueObservingOptionNew|NSKeyValueObservingOptionOld|NSKeyValueObservingOptionPrior context:ZCJContentSizeCtx];
objc_setAssociatedObject(self, kDataSetDataSource, source, OBJC_ASSOCIATION_ASSIGN);
}
在接收到contentSize變化時,重新加載頁面,配置并將空白頁顯示出來。
-(void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSString *,id> *)change context:(void *)context {
if (context == ZCJContentSizeCtx) {
NSValue *new = [change objectForKey:@"new"];
NSValue *old = [change objectForKey:@"old"];
if (new && old && ![new isEqualToValue:old]) {
if ([keyPath isEqualToString: kContentSize]) {
[self reloadDataSet];
}
}
}
else {
[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
}
}
- (void)reloadDataSet {
if (self.dataSource && [self totalRows] == 0) {
[self.dataSetView updateConstraintsIfNeeded];
self.dataSetView.titleLbl.attributedText = [self titleLableText];
self.dataSetView.detailLbl.attributedText = [self detailLableText];
self.dataSetView.imgView.image = [self image];
self.dataSetView.hidden = NO;
self.dataSetView.alpha = 1;
[self.dataSetView updateConstraints];
[self.dataSetView layoutIfNeeded];
}
}
仿寫的ZCJEmptyDataSet的項目地址:https://github.com/superzcj/ZCJEmptyDataSet
總結
ZCJEmptyDataSet實現還挺順利的,只是在原生約束上花費了一些時間。原生約束不依賴上層,所以很多開源庫都采用它進行UI布局,如MBProcessHUD、SWTableViewCell等。學習掌握原生約束還是很有必要的,至少我們用原生約束寫自定義view不依賴其他庫,封裝性更好。
最后,大家有什么意見或建議,都可以給我留言或聯系我。