讓列表加載如飄柔般順滑之預加載

最近在做項目時發(fā)現列表分頁加載數據體驗并不是很好,第一個想到解決此問題的方案就是預加載,便在網上找了一些相關的資料還有 demo 試過之后發(fā)現效果不是很好而且好多細節(jié)都沒有處理好,便想著自己寫個 demo 實現預加載,因為平時項目比較忙寫這個demo 都是平時下班時間寫一點,寫了蠻長時間的。今天把 demo 中實現的一些細節(jié)和學習資料分享給大家。沒有耐心讀的同學可以直接翻到底即可下載 demo

來瞅一眼效果圖,一圖勝千言

預加載.gif

依賴三方庫

AFNetworking用于網絡請求

MJRefresh用于刷新

YYKit用于模型轉換,也可以直接用YYModel或者MJExtension

ReactiveObjC用于數據的傳輸 (大家平時用的應該都是 block 去傳遞數據吧,當寫完這個 demo 的時候也用 block 去實現了網絡請求傳遞數據后來又刪掉了,因為 block 在傳遞數據的時候沒有 reactive 優(yōu)雅。如果有同學對此三方庫不了解,可自行實現數據傳遞部分)

MBProgressHUD不需要的可以移除掉

項目中常見問題:

相信你所做的項目中有很多是用 Tableview 或 Collectionview 所展示的數據列表,做列表展示時會一些常見問題:

  • 每次滑動到底部時都要去加載下一頁的數據,每次都是菊花轉啊轉用戶體驗并不是很好,如何下拉刷新時加載菊花上拉加載時不加載菊花(MBProgressHUD)。

  • 每次都要定義一個全局的currentPagetotlePage來計算當前頁是否小于總頁,而且需要不斷的從接口拿取頁碼數據,比如這種數據:

      "page":{
              "totalResultSize":27,
             "totalPageSize":3,
             "pageSize":10,
             "currentPage":1,
              }
    

每次網絡請求時總需要把頁碼信息拿出來用來判斷是否發(fā)起網絡請求,這樣寫很繁瑣有木有

  • 每次網絡請求的時候要判斷是否是刷新還是獲取新數據來對接受數據的數組來做移除或添加操作。獲取數據后刷新UI是不是有卡頓的現象,總說數據和刷新 UI 要分開操作,刷新 UI 要放到主線程去做,可是你真的是這么做的嗎?你的數據處理真的是放到子線程去的嗎?

  • MJRefresh去刷新列表的時候你是否是這么操作的?結束刷新操作不是應該放在獲取到數據之后才做的嗎?

      self.tableView.mj_header = [MJRefreshNormalHeader headerWithRefreshingBlock:^{
      //通過這個狀態(tài)來判斷對接受數據的數組來做移除或添加操作
      weakSelf.addData = NO;
      //將頁碼重置
      weakSelf.currentPage = 1;
      //發(fā)起網絡請求
      [weakSelf loadDiscoverData];
      //立刻結束刷新狀態(tài),即關閉菊花
      [weakSelf.tableView.mj_header endRefreshing];
      
      }];
    

解決問題

帶著這些問題我們來慢慢找尋一下解決辦法。

  • 如何每次網絡能請求兩頁數據,當滑動到列表的某一位置來發(fā)起網絡請求。你可以去網上搜一下相關的關鍵詞,差不多會得到兩種結果①利用 scrollview 的代理來計算內容高度;②利用indexPath的下標與數據源判斷是否發(fā)起網絡請求。是的,我用的就是第二種,不需要繁瑣的計算。

由于發(fā)起了兩次網絡請求,列表會刷新兩次,如果網絡條件不是很好的情況下頁面刷新不及時會卡一下,由于用的是MBProgressHUD做的提示,當每次請求的時候會在屏幕中間加載旋轉菊花,每次發(fā)起網絡請求菊花旋轉時間比請求一次的時候要長。針對這一情況,我在網絡請求工具中設置了是否顯示菊花,關于我封裝的網絡請求介紹可以看這篇文章,里面有詳細的使用介紹。

在網絡請求使用類MNetConfig中加入了isHidenHUD,這與下拉和上拉狀態(tài)取反達到異曲同工之妙。

/** *是否顯示HUD,默認顯示*/
@property (nonatomic, assign) BOOL isHidenHUD;
  • 如何不用傳頁碼參數來判斷當前數據是第幾頁數據,如何獲取到沒有更多數據的狀態(tài)。

我將網絡請求和數據的處理從控制器中抽離出來即MVVM中的VM,具體關于MVVM設計模式請自行查詢,這里就不做過多闡述。我通過對NSObject類進行了Category,抽離出一個專門處理網絡請求數據的類NSObject+MRequestAdd.h來看一下我針對網絡請求設置了哪些屬性:

/**
 *  數據數組
 */
@property (nonatomic, strong) NSMutableArray *dataArray;
/**
 *  原始請求數據
 */
@property (nonatomic, strong) id orginResponseObject;
/**
 *  當前頁碼
 */
@property (nonatomic, assign) NSInteger currentPage;
/**
 *  是否請求中
 */
@property (nonatomic, assign) BOOL isRequesting;
/**
 *  是否數據加載完
 */
@property (nonatomic, assign) BOOL isNoMoreData;

-(RACSignal *)singalForSingleRequestWithSet:(MNetConfig *)setting;

如果你寫過類的Category會發(fā)現分類是不允許property的。

如果你寫一個屬性會在.m中出現黃色警告,要么將這個屬性標記為@dynamic要么實現setget方法,就算實現了 set 和 get 方法在調用的時候也可能報錯。

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

這時就需要用到runtime了,可能你感覺這個東西很虛無縹緲而且不好懂(像我這種初學者)如果你資料看多了也就慢慢懂了,下面給同學們普及一點點 runtime 的一些知識,本人理解的比較淺如有哪里不對的地方請留言給我。

runtime 能為類做些什么

  1. 為現有的類添加私有變量,比如在網絡請求時來判斷網絡請求是否加載完成
    @property (nonatomic, assign) BOOL isNoMoreData;

  2. 為現有的類添加共有屬性供外部訪問。

     @property (nonatomic, strong) NSMutableArray *dataArray;
    
  3. 為 KVO 創(chuàng)建一個關聯(lián)的觀察者,這個屬性我還沒有用到,具體怎么用我也不是很清楚,這個用法也是資料說的。

第一點與第二點的區(qū)別無非是一個公有和私有的區(qū)別,從本質創(chuàng)建上并么有太大區(qū)別,我在項目中用的最多的也是這兩點。

創(chuàng)建 runtime 屬性:你可以在#import <objc/runtime.h>找到它們

objc_setAssociatedObject(id _Nonnull object, const void * _Nonnull key,id _Nullable value, objc_AssociationPolicy policy)
objc_getAssociatedObject(id _Nonnull object, const void * _Nonnull key)
objc_removeAssociatedObjects(id _Nonnull object) 

從字面上你應該可以猜到了,

objc_setAssociatedObject是一個 set 方法,重寫 set 方法相信大家都寫過,這個用法與之類似,是用于給對象添加關聯(lián)對象
objc_getAssociatedObject獲取關聯(lián)對象
objc_removeAssociatedObjects移除一個對象的所有關聯(lián)對象

objc_setAssociatedObject

objc_setAssociatedObject會涉及到四個參數,分別是objectkeyvaluepolicy

  1. object,要關聯(lián)的對象即 self

  2. key ,這個 key 值必須保證是一個對象級別的唯一常量與創(chuàng)建 tablviewcell 所創(chuàng)建的 ID 類似;一般來說,有以下三種推薦的 key 值:① 聲明 static const char * key_m_dataArray = "key_m_dataArray";使用 &key_m_dataArray 作為 key 值這個是需要加&符號獲取地址;② 聲明 static const void * key_m_dataArray = "key_m_dataArray" ,使用 key_m_dataArray 作為 key 值;③ 用 selector ,使用 getter 方法的名稱作為 key 值。因為它省掉了一個變量名,非常優(yōu)雅地解決了命名問題。

  3. value 即當前屬性的值

  4. policy 關聯(lián)策略

     typedef OBJC_ENUM(uintptr_t, objc_AssociationPolicy) {
         OBJC_ASSOCIATION_ASSIGN = 0,//弱引用對象
         OBJC_ASSOCIATION_RETAIN_NONATOMIC = 1, //強引用對象且為非原子操作
         OBJC_ASSOCIATION_COPY_NONATOMIC = 3,//復制關聯(lián)對象且為非原子操作
         OBJC_ASSOCIATION_RETAIN = 01401,//強引用對象且為原子操作         
         OBJC_ASSOCIATION_COPY = 01403//復制關聯(lián)對象為原子操作
     };
     將前三種翻譯過來即:
     @property (nonatomic, assign)
     @property (nonatomic, strong)
     @property (nonatomic, copy)
    

關聯(lián)對象的五種關聯(lián)策略與屬性的限定符非常類似,在絕大多數情況下,我們都會使用 OBJC_ASSOCIATION_RETAIN_NONATOMIC 的關聯(lián)策略,這可以保證我們持有關聯(lián)對象。

關于Associated Objects是如何實現以及如何儲存數據和關聯(lián)對象建議你看一下這篇文章或許對你有幫助。關于 runtime 的一些知識就介紹那么多,現在我對 runtime 只是會用一些簡單的屬性而更深層次的用法我也在探索中。

回到NSObject+MRequestAdd這個類中來,看一下內部實現。

通過-(RACSignal *)singalForSingleRequestWithSet:(MNetConfig *)setting;這個方法來進行網絡請求,而每次網絡請求是通過下面的方法來實現的

- (RACSignal *)baseSingleRequestWithSet:(MNetConfig *)setting{
    RACReplaySubject *subject = [RACReplaySubject subject];
    //判斷當前網絡狀態(tài),是否已經在請求數據或者沒有更多數據時返回 error 狀態(tài),表示沒有數據或請求失敗
    if (![self isSatisfyLoadMoreRequest]&&!setting.isRefresh) {
        [subject sendError:nil];
        return subject;
    }
    //避免某些接口只有 page 一個參數,所以需要初始化一個 parameter 來存放 page 字段
    if (!setting.paramet) {
        setting.paramet = [NSMutableDictionary dictionary];
    }
    if (setting.isRefresh) {
        self.currentPage = 0;
    }
    self.currentPage ++;
    if (setting.keyOfPage) {
      [setting.paramet setValue:@(self.currentPage) forKey:setting.keyOfPage];
    }
    //每一次網絡請求都是YES,請求完畢就為 NO
    self.isRequesting = YES;
    [[MNetRequestModel netRequestSeting:setting] subscribeNext:^(id  _Nullable x) {
        self.isRequesting = NO;
        [subject sendNext:x];
        
    } error:^(NSError * _Nullable error) {
        self.isRequesting = NO;
        //如果當前請求失敗,因為都是在原來頁碼上進行++,所以這里需要--來回退頁碼。
        if (self.currentPage > 0) {
            self.currentPage--;
        }
        [subject sendError:error];
    } completed:^{
        [subject sendCompleted];
    }];
    return subject;
    
}
- (BOOL)isSatisfyLoadMoreRequest{
return (!self.isNoMoreData&&!self.isRequesting);
}   

再來看一下.h 文件放出的接口的實現

- (RACSignal *)singalForSingleRequestWithSet:(MNetConfig *)setting{
    RACReplaySubject *subject = [RACReplaySubject subject];
    //每次調用的即是上面所寫的方法,每次有新數據時才會走網絡請求,如果沒有走 error 狀態(tài),即沒有數據表示已無更多數據
    [[self baseSingleRequestWithSet:setting] subscribeNext:^(id  _Nullable x) {
        //每一次請求到的源數據
        self.orginResponseObject = x;
        //利用 runtime 創(chuàng)建的屬性來初始化
        if (!self.dataArray) {
            self.dataArray = @[].mutableCopy;
        }
        if (setting.isRefresh) {
            [self.dataArray removeAllObjects];
        }
        //定位到要解析的數據位置,用“/”做拆分
        NSArray *separateKeyArray = [setting.modelLocalPath componentsSeparatedByString:@"/"];
        for (NSString *sepret_key in separateKeyArray) {
            x = x[sepret_key];
        }
        //每一次網絡請求獲取的模型數據
        NSArray *dataArray = [NSArray modelArrayWithClass:NSClassFromString(setting.modelNameOfArray) json:x];
        //如果當前請求到的數據為空,說明網絡出錯或者沒有更多數據
        if (dataArray.count == 0) {
            self.isNoMoreData = YES;
            [subject sendError:nil];
        } else {
        //只有有數據時才進行 sendnext,即傳遞數據
            self.isNoMoreData = NO;
            [self.dataArray addObjectsFromArray:dataArray];
            [subject sendNext:self.dataArray];
        }
        
    } error:^(NSError * _Nullable error) {
        [subject sendError:error];
    }completed:^{
        [subject sendCompleted];
    }];
    return subject;
}

當前操作就完美解決了每次都要去處理接口中的 page 信息問題。看一下如何請求:

- (RACSignal *)siganlForTopicDataIsReload:(BOOL)isReload{

    RACReplaySubject *subject = [RACReplaySubject subject];
    MNetConfig *seting = [MNetConfig new];
    seting.hostUrl = Test_Page_URL;
    //    seting.paramet = @{};//如果有頁碼參數,不要寫到字典,將頁碼參數寫到下方
        seting.modelLocalPath = @"entity/topics";//數據定位,即 entity 下的 topics 對應的數據
        seting.keyOfPage = @"page.currentPage";//頁碼寫這
        seting.modelNameOfArray = @"MHYTestModel";//要顯示列表對應的數據模型
        seting.isRefresh = isReload;//是否刷新
        seting.isHidenHUD = !isReload;//上拉刷新顯示 HUD 上拉更多不顯示 HUD
        //    seting.cashSeting = MCacheSave;// 是否進行本地緩存
        //    seting.cashTime = 4;//設置緩存時間為4分鐘,默認3分鐘
        //    seting.isCashMoreData = YES;//進行多頁數據緩存
    
    seting.jsonValidator = @{@"entity":[NSDictionary class],
                             @"entity":@{@"topics":[NSArray class]}
                             };//檢測 entity 是否為字典類型,檢測 entity 下 topics 字段是否為數組
    [[self singalForSingleRequestWithSet:seting]subscribeNext:^(id  _Nullable x) {
        [subject sendNext:x];
    } error:^(NSError * _Nullable error) {
        [subject sendError:error];
    } completed:^{
        [subject sendCompleted];
    }];
    return subject;
}

獲取到的數據如何正在子線程去處理呢?
我在每一個網絡請求中加入了一個線程,可以看一下MNetRequestModel.m這個文件,demo 中所有的網絡請求最終的請求都是它來完成的。

dispatch_async (dispatch_get_global_queue 
(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        //獲取數據發(fā)送 next,處理數據
        [subject sendNext];
        dispatch_async (dispatch_get_main_queue(), ^{
        //再發(fā)送完成信號,來刷新 UI
        [subject sendCompleted];
  });
});

這樣就將數據處理和 UI 刷新分開。

回過頭來看一下解決了哪些問題:刷新菊花顯示問題;處理頁碼問題;刷新 UI 問題,和 mj 停止刷新時機問題(即在發(fā)送 comply 后停止刷新),以上羅列的問題都解決了。

其實這樣做還有一些問題:如果非列表數據請求因為在所有網絡請求中加入了線程,有一些信息是在 next 中獲取的比如接口中的message信息,這些信息需要給用戶來展示,如果在 next 中調用MBProgressHUD的 show 方法是崩潰的,因為MBProgressHUD的菊花必須要的主線程中才可以調用。我是這樣處理的,為NSString
類寫一個分類,里面的方法大概就這么寫:

-(void)showSucceed;
-(void)showSucceed{
    dispatch_async(dispatch_get_main_queue(), ^{
        [MBProgressHUD showSuccess:self];
    });
}

如果信息和 UI 必須放在一起刷新,比如UIButton的狀態(tài)和文字改變,這時必須在當前文件中來創(chuàng)建中間替換變量再刷新 UI。

還有一個最大的問題就是每次獲取到的數據都是總數據。如果要對模型做計算處理比如通過模型計算 cell 中的控件的 frame,第一頁獲取10條數據,對模型做10次計算,再請求10條數據,此時應該處理請求下來的10條數據,而數據處理是在 next 中完成的,next 中為總數據即20條數據,這樣模型的計算就進行了20次,隨著頁面的增加計算量越來越大。這個問題我暫時還沒想到好的解決辦法,如果你有好的解決辦法請私信我。

創(chuàng)建UITableView的 runtime 屬性

寫一個UITableView的分類UITableView+MPreload,創(chuàng)建倆個屬性:

/** tableview數據 */
@property (nonatomic, strong) NSMutableArray *dataArray;
/** 預加載回調*/
@property (nonatomic, copy) PreloadBlock m_preloadBlock;

一個常量:

/**  預加載觸發(fā)的數量 */
static NSInteger const PreloadMinCount = 3;

和一個公開方法:

- (void)preloadDataWithCurrentIndex:(NSInteger)currentIndex;

- (void)preloadDataWithCurrentIndex:(NSInteger)currentIndex{
    NSInteger totalCount = self.dataArray.count;
    //判斷當前行數是否滿足預加載的條件
    if ([self isSatisfyPreloadDataWithTotalCount:totalCount currentIndex:currentIndex]&&self.m_preloadBlock) {
        //通過 block 來調用網絡請求
        self.m_preloadBlock();
    }
}

- (BOOL)isSatisfyPreloadDataWithTotalCount:(NSInteger)totalCount currentIndex:(NSInteger)currentIndex{
    //如果預加載觸發(fā)的數量為3,總數據為10條即第7行觸發(fā)預加載
    return  ((currentIndex == totalCount - PreloadMinCount) && (currentIndex >= PreloadMinCount));
}

更具體的代碼請看 demo 中UITableView+MPreload文件

捋一下整體思路:

抽出一個專門做數據請求的類TestDataModel繼承與NSObject類型,對NSObject利用 runtime 特性進行擴展屬性得到每次請求到的總數據dataArray,這樣TestDataModel類所創(chuàng)建的對象都可以擁有當前屬性,然后再利用 runtime 特性對UITableView進行擴展分別是兩個屬性dataArraym_preloadBlock一個方法- (void)preloadDataWithCurrentIndex:(NSInteger)currentIndex這樣每滑動 tablview 就會調用該方法,通過判斷是否進行預加載行為,預加載請求的數據通過對象再給 tablview 的dataArray。其實思路很簡單,runtime擴展所需要的屬性和方法,然后有機的結合調用,這樣彼此循環(huán)調用就能創(chuàng)建一個無限循環(huán)的列表了。具體方法及細節(jié)見 demo 點我下載

學習參考資料:

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • 轉至元數據結尾創(chuàng)建: 董瀟偉,最新修改于: 十二月 23, 2016 轉至元數據起始第一章:isa和Class一....
    40c0490e5268閱讀 1,762評論 0 9
  • 國家電網公司企業(yè)標準(Q/GDW)- 面向對象的用電信息數據交換協(xié)議 - 報批稿:20170802 前言: 排版 ...
    庭說閱讀 11,121評論 6 13
  • Spring Cloud為開發(fā)人員提供了快速構建分布式系統(tǒng)中一些常見模式的工具(例如配置管理,服務發(fā)現,斷路器,智...
    卡卡羅2017閱讀 134,869評論 18 139
  • 這篇文章完全是基于南峰子老師博客的轉載 這篇文章完全是基于南峰子老師博客的轉載 這篇文章完全是基于南峰子老師博客的...
    西木閱讀 30,592評論 33 466