在 UITableView 中使用计时器

引言:

TableView 的 cell 中出现倒计时、计时器的功能,是非常常见的需求。但是稍有不慎就会出现一些莫名其妙的 bug。

本文给出在TableView 中使用NSTimer或者DispatchSourcer中常见的五种方式。当然下方第一种方式是常规做法,不过也是UITableView中使用NSTimer的一个坑。其他三种方式是为了绕过这个坑的解决方案。


当然,本篇博客共涉及到了UITableView中使用定时器的四种实现方式,当然应该也还有其他实现方式,只不过目前我没有涉及到。欢迎在评论区提供其他实现方式,我会及时的整合到目前的Demo中。

接下来我们先来总结一下本篇博客所涉及的四种方式:

  • 第一种就是直接在TableView的Cell上使用NSTimer,当然这种方式是有问题的,稍后会介绍。
  • 第二种是将NSTimer添加到当前线程所对应的RunLoop中的commonModes中
  • 第三种是通过Dispatch中的TimerSource来实现定时器。
  • 第四种是开启一个新的子线程,将NSTimer添加到这个子线程中的RunLoop中,并使用DefaultRunLoopModes来执行。
  • 第五种方式就是使用CADisplayLink来实现。

# 一. 直接使用NSTimer

首先我们按照常规做法,直接在UITableView的Cell上添加相应的NSTimer, 并使用scheduledTimer执行相应的代码块。这种方式没有什么特殊的就是对Timer的直接使用。下方是我们本部分的Timer的使用代码:

- (void)awakeFromNib {
    [super awakeFromNib];
 
    if (@available(iOS 10.0, *)) {
        self.timer = [NSTimer scheduledTimerWithTimeInterval:1 repeats:YES block:^(NSTimer * _Nonnull timer) {
            NSString *str = [NSDate lc_currentDateWithFormat:@"HH:MM:SS"];
            self.label.text = str;
        }];
    } else {
        // Fallback on earlier versions
    }
}

上述代码比较简单,就是在Cell上添加了一个定时器,然后每1秒更新一次时间,并在Cell的 label 上显示,运行效果如下所示。从该运行效果中我们不难发现,当我们滑动TableView时,该定时器就停止了工作。具体原因**就是当前线程的`RunLoop`在TableView滑动时将`DefaultMode`切换到了`TrackingRunLoopMode`。因为Timer默认是添加在`RunLoop`上的`DefaultMode`上的,当Mode切换后Timer就停止了运行。**

但是当停止滑动后,Mode又切换了回来,所以Timer有可以正常工作了。

为了进一步看一下Mode的切换,我们可以在相应的地方获取当前线程的RunLoop并且打印对应的Mode。下方代码就是在TableView所对应的VC上添加的,我们在viewDidLoad()viewDidAppear()以及scrollViewDidScroll()这个代理方法中对当前线程所对应的RunLoop下的currentMode进行了打印,其代码如下:

- (void)viewDidLoad {
    [super viewDidLoad];
    
    [self setupTableViewLayout];
    
    NSLog(@"viewDidLoad = %@", [NSRunLoop currentRunLoop].currentMode);
}


- (void)viewDidAppear:(BOOL)animated {
    [super viewDidAppear:animated];
    
    NSLog(@"viewDidAppear = %@", [NSRunLoop currentRunLoop].currentMode);
}


-(void)scrollViewDidScroll:(UIScrollView *)scrollView {
    
    NSLog(@"scrollViewDidScroll = %@", [NSRunLoop currentRunLoop].currentMode);
}

下方就是最终的运行结果。从输出结果中我们不难看出,在viewDidLoad()方法中打印的Current ModeUIInitializationRunLoopMode, 从该Mode的名字中我们不难发现,该Mode负责UI的初始化。在viewDidApperar()方法中,也就是UI显示后,RunLoop的Mode切换成了kCFRunLoopDefaultMode。紧接着,我们去滑动TableView,然后在scrollViewDidScroll()代理方法中打印滑动时当前RunLoop所对应的Mode。从下方运行结果不难看出,当TableView滑动时,打印出的currentModelUITrackingRunLoopMode。当停止滑动后,点击Show Current Mode按钮获取当前Model时,打印的有时RunLoopDefaultMode。具体如下所示:


# 二. 添加到CommonMode中

上一部分的定时器是不能正常运行的,因为NSTimer对象默认添加到了当前RunLoop的DefaultMode中,而在切换成TrackingRunLoopMode时,定时器就停止了工作。解决该问题最直接方法是,将NSTimer在TrackingRunLoopMode中也添加一份。这样的话无论是在DefaultMode还是TrackingRunLoopMode中,定时器都会正常的工作。

如果你对RunLoop比较熟悉的话,可以知道CommonModes就是DefaultModeTrackingRunLoopMode的集合,所以我们只需要将NSTimer对象与当前线程所对应的RunLoop中的CommonModes关联即可,具体代码如下所示:

	if (@available(iOS 10.0, *)) {
        self.timer = [NSTimer scheduledTimerWithTimeInterval:1 repeats:YES block:^(NSTimer * _Nonnull timer) {
            NSString *str = [NSDate lc_currentDateWithFormat:@"HH:MM:SS"];
            self.label.text = str;
        }];
        [[NSRunLoop currentRunLoop] addTimer:self.timer forMode:NSRunLoopCommonModes];
        
    } else {
        // Fallback on earlier versions
    }

上述代码与第一部分的代码不同的地方在于我们将创建好的定时器添加到了当前 RunLoop 中的 CommonModes 中,这样的话可以保证 TableView 在滑动时定时器也可以正常运行。上述代码最终的运行效果如下所示:

从该运行效果我们不难发现,当该 TableView 滚动式,其 Cell 上的定时器是可以正常工作的。但是当我们滑动右上角的这个 TableView 时,第一个的 TableView 中的定时器也是不能正常工作的,因为这些 TableView 都在主线程中工作,也就是说这些 TableView 所在的 RunLoop 是同一个。


# 三. 添加到子线程的RunLoop下的DefaultMode中

接下来我们来看另一种解决方案,就是开启一个新的子线程,然后将 Timer 添加到这个子线程所对应的 RunLoop 中。当然因为是子线程的 RunLoop ,在添加 Timer 时,我们可以将 Timer 添加到子线程中的 RunLoop 中的 DefaultMode 中。添加完毕后,手动运行该 RunLoop。

因为是在子线程中添加的 Timer, Timer 肯定是在子线程中工作的,所以在更新UI时,我们需要在主线程中进行更新,具体代码如下所示:

 
    //创建队列
    dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
    dispatch_async(queue, ^{
        
        if (@available(iOS 10.0, *)) {
            self.timer = [NSTimer scheduledTimerWithTimeInterval:1 repeats:YES block:^(NSTimer * _Nonnull timer) {
                NSString *str = [NSDate lc_currentDateWithFormat:@"HH:MM:SS"];
                
                // 回到主线程
                dispatch_async(dispatch_get_main_queue(), ^{
                    self.label.text = str;
                });
            
            }];
            [[NSRunLoop currentRunLoop] addTimer:self.timer forMode:NSRunLoopCommonModes];
            [[NSRunLoop currentRunLoop] run];
            
        } else {
            // Fallback on earlier versions
        }
    });
    

在上述代码中我们可以看到我们使用全局的并行队列来异步创建了一个 Timer 对象,然后将该对象添加进了该异步线程中的 DefaultRunLoopMode 中,然后运行该 RunLoop 。当然在子线程中更新 UI 还是需要在主线程中去操作的。下方就是上述代码的运行效果。从该效果中我们不难看出,当滑动 TableView 时定时器是可以正常工作的。


# 四. DispatchTimerSource