让列表加载如飘柔般顺滑之预加载

最近在做项目时发现列表分页加载数据体验并不是很好,第一个想到解决此问题的方案就是预加载,便在网上找了一些相关的资料还有 demo 试过之后发现效果不是很好而且好多细节都没有处理好,便想着自己写个 demo 实现预加载,因为平时项目比较忙写这个demo 都是平时下班时间写一点,写了蛮长时间的。今天把 demo 中实现的一些细节和学习资料分享给大家。没有耐心读的同学可以直接翻到底即可下载 demo

来瞅一眼效果图,一图胜千言

预加载.gif

依赖三方库

AFNetworking用于网络请求

MJRefresh用于刷新

YYKit用于模型转换,也可以直接用YYModel或者MJExtension

ReactiveObjC用于数据的传输 (大家平时用的应该都是 block 去传递数据吧,当写完这个 demo 的时候也用 block 去实现了网络请求传递数据后来又删掉了,因为 block 在传递数据的时候没有 reactive 优雅。如果有同学对此三方库不了解,可自行实现数据传递部分)

MBProgressHUD不需要的可以移除掉

项目中常见问题:

相信你所做的项目中有很多是用 Tableview 或 Collectionview 所展示的数据列表,做列表展示时会一些常见问题:

  • 每次滑动到底部时都要去加载下一页的数据,每次都是菊花转啊转用户体验并不是很好,如何下拉刷新时加载菊花上拉加载时不加载菊花(MBProgressHUD)。

  • 每次都要定义一个全局的currentPagetotlePage来计算当前页是否小于总页,而且需要不断的从接口拿取页码数据,比如这种数据:

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

每次网络请求时总需要把页码信息拿出来用来判断是否发起网络请求,这样写很繁琐有木有

  • 每次网络请求的时候要判断是否是刷新还是获取新数据来对接受数据的数组来做移除或添加操作。获取数据后刷新UI是不是有卡顿的现象,总说数据和刷新 UI 要分开操作,刷新 UI 要放到主线程去做,可是你真的是这么做的吗?你的数据处理真的是放到子线程去的吗?

  • MJRefresh去刷新列表的时候你是否是这么操作的?结束刷新操作不是应该放在获取到数据之后才做的吗?

      self.tableView.mj_header = [MJRefreshNormalHeader headerWithRefreshingBlock:^{
      //通过这个状态来判断对接受数据的数组来做移除或添加操作
      weakSelf.addData = NO;
      //将页码重置
      weakSelf.currentPage = 1;
      //发起网络请求
      [weakSelf loadDiscoverData];
      //立刻结束刷新状态,即关闭菊花
      [weakSelf.tableView.mj_header endRefreshing];
      
      }];
    

解决问题

带着这些问题我们来慢慢找寻一下解决办法。

  • 如何每次网络能请求两页数据,当滑动到列表的某一位置来发起网络请求。你可以去网上搜一下相关的关键词,差不多会得到两种结果①利用 scrollview 的代理来计算内容高度;②利用indexPath的下标与数据源判断是否发起网络请求。是的,我用的就是第二种,不需要繁琐的计算。

由于发起了两次网络请求,列表会刷新两次,如果网络条件不是很好的情况下页面刷新不及时会卡一下,由于用的是MBProgressHUD做的提示,当每次请求的时候会在屏幕中间加载旋转菊花,每次发起网络请求菊花旋转时间比请求一次的时候要长。针对这一情况,我在网络请求工具中设置了是否显示菊花,关于我封装的网络请求介绍可以看这篇文章,里面有详细的使用介绍。

在网络请求使用类MNetConfig中加入了isHidenHUD,这与下拉和上拉状态取反达到异曲同工之妙。

/** *是否显示HUD,默认显示*/
@property (nonatomic, assign) BOOL isHidenHUD;
  • 如何不用传页码参数来判断当前数据是第几页数据,如何获取到没有更多数据的状态。

我将网络请求和数据的处理从控制器中抽离出来即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会发现分类是不允许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 创建一个关联的观察者,这个属性我还没有用到,具体怎么用我也不是很清楚,这个用法也是资料说的。

第一点与第二点的区别无非是一个公有和私有的区别,从本质创建上并么有太大区别,我在项目中用的最多的也是这两点。

创建 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 方法相信大家都写过,这个用法与之类似,是用于给对象添加关联对象
objc_getAssociatedObject获取关联对象
objc_removeAssociatedObjects移除一个对象的所有关联对象

objc_setAssociatedObject

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

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

  2. key ,这个 key 值必须保证是一个对象级别的唯一常量与创建 tablviewcell 所创建的 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 值。因为它省掉了一个变量名,非常优雅地解决了命名问题。

  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)
    

关联对象的五种关联策略与属性的限定符非常类似,在绝大多数情况下,我们都会使用 OBJC_ASSOCIATION_RETAIN_NONATOMIC 的关联策略,这可以保证我们持有关联对象。

关于Associated Objects是如何实现以及如何储存数据和关联对象建议你看一下这篇文章或许对你有帮助。关于 runtime 的一些知识就介绍那么多,现在我对 runtime 只是会用一些简单的属性而更深层次的用法我也在探索中。

回到NSObject+MRequestAdd这个类中来,看一下内部实现。

通过-(RACSignal *)singalForSingleRequestWithSet:(MNetConfig *)setting;这个方法来进行网络请求,而每次网络请求是通过下面的方法来实现的

- (RACSignal *)baseSingleRequestWithSet:(MNetConfig *)setting{
    RACReplaySubject *subject = [RACReplaySubject subject];
    //判断当前网络状态,是否已经在请求数据或者没有更多数据时返回 error 状态,表示没有数据或请求失败
    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 状态,即没有数据表示已无更多数据
    [[self baseSingleRequestWithSet:setting] subscribeNext:^(id  _Nullable x) {
        //每一次请求到的源数据
        self.orginResponseObject = x;
        //利用 runtime 创建的属性来初始化
        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), ^{
        //获取数据发送 next,处理数据
        [subject sendNext];
        dispatch_async (dispatch_get_main_queue(), ^{
        //再发送完成信号,来刷新 UI
        [subject sendCompleted];
  });
});

这样就将数据处理和 UI 刷新分开。

回过头来看一下解决了哪些问题:刷新菊花显示问题;处理页码问题;刷新 UI 问题,和 mj 停止刷新时机问题(即在发送 comply 后停止刷新),以上罗列的问题都解决了。

其实这样做还有一些问题:如果非列表数据请求因为在所有网络请求中加入了线程,有一些信息是在 next 中获取的比如接口中的message信息,这些信息需要给用户来展示,如果在 next 中调用MBProgressHUD的 show 方法是崩溃的,因为MBProgressHUD的菊花必须要的主线程中才可以调用。我是这样处理的,为NSString
类写一个分类,里面的方法大概就这么写:

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

如果信息和 UI 必须放在一起刷新,比如UIButton的状态和文字改变,这时必须在当前文件中来创建中间替换变量再刷新 UI。

还有一个最大的问题就是每次获取到的数据都是总数据。如果要对模型做计算处理比如通过模型计算 cell 中的控件的 frame,第一页获取10条数据,对模型做10次计算,再请求10条数据,此时应该处理请求下来的10条数据,而数据处理是在 next 中完成的,next 中为总数据即20条数据,这样模型的计算就进行了20次,随着页面的增加计算量越来越大。这个问题我暂时还没想到好的解决办法,如果你有好的解决办法请私信我。

创建UITableView的 runtime 属性

写一个UITableView的分类UITableView+MPreload,创建俩个属性:

/** tableview数据 */
@property (nonatomic, strong) NSMutableArray *dataArray;
/** 预加载回调*/
@property (nonatomic, copy) PreloadBlock m_preloadBlock;

一个常量:

/**  预加载触发的数量 */
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{
    //如果预加载触发的数量为3,总数据为10条即第7行触发预加载
    return  ((currentIndex == totalCount - PreloadMinCount) && (currentIndex >= PreloadMinCount));
}

更具体的代码请看 demo 中UITableView+MPreload文件

捋一下整体思路:

抽出一个专门做数据请求的类TestDataModel继承与NSObject类型,对NSObject利用 runtime 特性进行扩展属性得到每次请求到的总数据dataArray,这样TestDataModel类所创建的对象都可以拥有当前属性,然后再利用 runtime 特性对UITableView进行扩展分别是两个属性dataArraym_preloadBlock一个方法- (void)preloadDataWithCurrentIndex:(NSInteger)currentIndex这样每滑动 tablview 就会调用该方法,通过判断是否进行预加载行为,预加载请求的数据通过对象再给 tablview 的dataArray。其实思路很简单,runtime扩展所需要的属性和方法,然后有机的结合调用,这样彼此循环调用就能创建一个无限循环的列表了。具体方法及细节见 demo 点我下载

学习参考资料:

推荐阅读更多精彩内容