老規矩,一圖勝千言
先嘮叨幾句
最近也是忙于項目進度,學習時間被大大的壓縮了下來,距離上次寫文章已經過去了整整倆月時間。項目進度已經趕的差不多了所以抽空將項目中遇到的問題及解決方法記錄一下。
出現的問題
隨著項目漸漸的接近尾聲,在項目中列表無數據展示成為了令人頭疼的問題。如何優雅且更智能的讓占位圖在列表無數據時自動展示出來,我剛開始的做法是將圖片和提示文字寫在一個自定義 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
或者collectionView
在reloaddata
時也會調用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
;獲取的方法分別是reloadData
和delloc
這兩種方法,獲取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 的reload
和m_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
一個屬性時,編譯器會自動給你實現setter
和getter
方法,自動為你實現方法的為@synthesize
,而與之對應的則是@dynamic
。當一個屬性被標記為@dynamic
時,此時編譯器就認為該屬性的 setter和 getter 方法由用戶自己實現,不自動生成。如果該屬性被標記為@dynamic
就算沒有實現 setter 和 getter 方法編譯也會通過,如果當程序運行到xxx.test = xxx
時,由于缺少與其相對應的 setter 方法導致崩潰;或者xxx *pro = test
時,由于缺少 getter 方法同樣會導致崩潰。在編譯時沒有問題,運行時才執行相應的方法,這就是動態綁定,即 runtime 運行時。
在分類中實現 setter 和 getter 方法是用 runtime 中的objc_setAssociatedObject
和objc_getAssociatedObject
,來看一下實現方法
-(void)setTest:(NSString *)test{
objc_setAssociatedObject(self, @selector(test), test, OBJC_ASSOCIATION_RETAIN);
}
-(NSString *)test{
return objc_getAssociatedObject(self, _cmd);
}
在objc_setAssociatedObject會涉及到四個參數,分別是object
、key
、value
、policy
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 人的根本意圖了。仰望星空的同時一定要腳踏實地,我將與你一路同行。