iOS 开发 - 如何完美的使用 NSTimer 做倒计时效果

2017-01-14 10:46:18来源:http://www.jianshu.com/p/f43052f3a5ea作者:CoderGin人点击

前言

为什么要写这篇博客?
今天在刷微博的时候,偶然看到了一条某培训机构的微博,微博的内容是转发一条来自他们培训机构论坛中的 iOS 技术贴,有关于实现一个倒计时效果的 UILabel 的封装。


由于,最近正好在看这相关方面的资料,也自己写过相关的功能,于是我稍微看了下评论以及具体的实现。觉得这代码并没有像官微所讲的那么厉害,也没有评论中说的那么6。。所以打算稍微谈下我个人的见解。


Talk is cheap, show me the code

废话不多说,首先先贴上他所写的代码(我稍微做了些改动,主要是在代码风格,和命名方面,具体实现没有改)


CountDownLabel.h


@interface CountDownLabel : UILabel
/// 根据目标时间计算跟服务器的差值
- (void)beginCountDownWithTime:(NSDate *)time;
@end

CountDownLabel.m


@interface CountDownLabel ()
/// 时间定时器,用 weak 可以在定时器销毁之后指针自动置为nil
@property (nonatomic, weak) NSTimer *timer;
/// 剩余天数
@property (nonatomic, assign) NSUInteger day;
/// 剩余小时数
@property (nonatomic, assign) NSUInteger hour;
/// 剩余分钟数
@property (nonatomic, assign) NSUInteger minute;
/// 剩余秒数
@property (nonatomic, assign) NSUInteger second;
@end
@implementation CountDownLabel
- (void)beginCountDownWithTime:(NSDate *)time {
// 计算目标时间和当前服务器时间的秒数差
NSTimeInterval interval = [targetTime timeIntervalSinceDate:[NSDate date]];
// 根据时间差的秒数计算天,小时,分钟,秒(暂时不考虑月和年,月和年的倒计时用的很少)
[self calculateTime:(NSInteger)interval];
}
/// 计算时间方法
- (void)calculateTime:(NSInteger)interval {
NSUInteger secondPerDay = 24 * 60 * 60;
NSUInteger secondPerHour = 60 * 60;
NSUInteger secondPerMinute = 60;
// 计算天数
self.day = interval / secondPerDay;
// 剩余小时不应该大于24小时,所以应该先除去满足一天的秒数,再计算还剩下多少小时
self.hour = interval % secondPerDay / secondPerHour;
// 剩余分钟数与上面同理
self.minute = interval % secondPerHour / secondPerMinute;
// 剩余秒数直接等于秒数对每分钟秒数所取的余数
self.second = interval % secondPerMinute;
// 赋值到label上
self.text = [NSString stringWithFormat:@"倒计时:%02zd 天 %02zd:%02zd:%02zd", self.day, self.hour, self.minute, self.second];
// 开始定时器
self.timer = [NSTimer scheduledTimerWithTimeInterval:1 target:self selector:@selector(updateTime) userInfo:nil repeats:YES];
}
/// 更新时间
- (void)updateTime {
// 减一秒
self.second--;
// 判断秒数
if (self.second == -1) {
self.second = 59;
self.minute--;
}
// 判断分钟数
if (self.minute == -1) {
self.minute = 59;
self.hour--;
}
// 判断小时数
if (self.hour == -1) {
self.hour = 23;
self.day--;
}
// 判断是否没时间了
if (self.day == 0 && self.hour == 0 && self.minute == 0 && self.second == 0) {
[self.timer invalidate];
}
// 赋值
self.text = [NSString stringWithFormat:@"倒计时:%02zd 天 %02zd:%02zd:%02zd", self.day, self.hour, self.minute, self.second];
}
@end

代码比较简单,大概也就 100 行左右的样子。带点辩证的态度去看的话,还是会发现代码中稍微有些许问题,下面就开始讲一下我的看法。


timer 对象为什么使用的是 weak?

在 .m 文件的类扩展中,我们可以看到 timer 属性是使用 weak 修饰的。
在这里不得不提一下,在 iOS 开发中,最常见的集中循环引用的场景。


1.NSTimer
2.block
3.delegate


即使是很多有经验的开发者偶尔也会犯循环引用的错误。所以在这里简单说一下 NSTimer 会造成循环引用的原因。我们一般使用下面这个方法对 timer 进行实例化


+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo;

在这个方法进行实例化的时候,如果最后一个参数传入的是 YES,代表,这个 timer 将不停的每隔传入的 ti 秒,为传入的 target 调用传入的 aSelector 方法。如果传入的是 NO,则 timer 执行完毕之后,会自动 invalidate。而在传入的是 YES 的时候,timer 为了保证每过一个确定的时间间隔为 target 调用 aSelector 有效,就默默的帮我们对传入的 target 进行了一次 retain。


所以,这个作者在这里是比较机智的,他对 timer 的修饰使用的是 weak,这样就避免了循环引用的问题。下面附上一张图进行比较。



使用 weak 和 strong 的对比.png

如图所示,如果 timer 使用 strong, 或者 weak,都无法改变 timer 对 label 进行的一次持有,也就是,在进入新控制器界面中,label 的持有者有两个,一个是所在的控制器,还有一个是 timer,当控制器销毁的时候,timer 如果没有满足时间倒计时完毕的条件,就不会进行 invalidate,那么 label,也将无法被完全释放。


这个 timer 并不完美

也就是说,作者在这里确实考虑到了循环引用的坑,但是并没有完美的解决,他使用 weak 修饰 timer 并没有什么用,label 只有在 timer 完全执行完毕,和没有界面显示他的时候,才能完全被释放。


说完了大话,那么现在开始测试来验证我的观点

我写了一个用于测试的小 demo。
这个 demo 的大致效果是这样的



测试 demo 效果动图

我在 timer 执行的 updateTime 方法中加入了下面这句代码,来确保我知道 timer 是否仍然在工作


NSLog(@"%s", __func__);

我在第一个界面,只放置了一个 Present 按钮,点击之后,会Modal 出一个新的控制器来,新的控制器中,放置了一个 Dismiss 按钮,Dimiss 按钮做 Dismiss 当前控制器的操作,以及一个 CountDownLabel。CountDownLabel 传入的时间是


[NSDate dateWithTimeIntervalSinceNow:20]


也就是,进入新的控制器之后,界面中的 CountDownLabel 会开始进行倒计时 20 秒。


准备好了么,我要开始运行了

我在点击 Present 之后,控制台开始打印 __func__


- [CountDownLabel timerDecrease]


到这里似乎没有什么问题,但是当我点击 Dismiss 之后,控制台仍然在打印上面的 __func__



奇怪的效果
为什么这里会有问题?

大家看到这里应该看明白了我上面说说的效果,没错,在界面 Dismiss 之后,timer 和我想的一样,仍然在工作,不要问我为什么,还是那个解释,timer 对象对传入的 target,也就是 CountDownLabel 进行了一次持有。


来稍微屡一下思路,第一次在进行 Present 进入这个控制器的时候,只有新的控制器对这个 label 进行了持有,然后 timer 再对界面中的这个 label 进行了一次持有,引用计数为2。在界面 Dismiss 之后,控制器不再持有 label,而 timer 的 20 秒还没有全部执行完毕,timer 没有进行 invalidate 操作,并释放 target,所以,我想你现在也应该完全明白了,这里是因为 Dismiss 之后,label 中的 timer 仍然对它进行了持有,还没有被完全释放,所以,控制台仍然在打印 __func__。


怎么解决这个问题呢?

当然是在合适的时机对 timer 进行 invalidate!什么时候最合适?可能有人要提出在 dealloc 方法中,对 timer 进行 invalidate 操作,然而这是一种最常见的错法。因为 timer 对 label 的持有,所以在界面消失之后,引用计数将降为 1,而不是 0,所以如果在 dealloc 方法中对 timer 进行 invalidate,仍然会出现上面奇怪的效果。


正确的解决思路是在新控制器的 viewWillDisappear 方法中对 label 的 timer 进行一次 invalidate 操作就可以了。不过我还是觉得这样不够好,因为这里的 timer 是放到 .m 文件的类扩张中的,我如果要在新控制器中拿到这个 timer,要么使用 KVC,要么将 timer 放到 .h 文件中去。


但是我还是觉得这两种方法太麻烦了,因为我希望我永远不去关心 timer 什么时候 invalidate 的问题。


下面贴出我的解决方法:重写 removeFromSuperView
- (void)removeFromSuperview {
[super removeFromSuperview];
[self.timer invalidate];
}

因为 label 一般需要显示在界面中,才能被持有,不管是直接显示在界面中,还是作为其他 view 的子视图显示在界面中。当控制器或者它的父视图,不再需要它的时候,一般是在界面消失,或者视图消失的时候,此时 CountDownLabel 的 removeFromSuperView 方法一定会被调用,那么在这里对 timer 进行 invalidate 是最合适的时机了。当然了,如果你的 timer 所持有的 target 是一个控制器,那么在这个控制器的 viewWillDisappear 中为 timer 调用 invalidate 方法就成了最合适的时机。


下面附上一张效果图。



timer invalidate 的最合适的时机
一些小细节

1.选择在不需要的时候对 timer 进行 invalidate,而不是选择自动的,是因为,你不知道你的时间可能会有多长,你也不知道你的用户会有多少次重复进入这个界面,如果时间较长,恰巧进入的又比较频繁,那么会给内存造成一些不必要的开销。
2.这里作者在写这个 CountDownLabel 的时候,很显然遗漏了最重要的一个事情,那就是 timer 没有配合 NSRunLoop 进行使用。虽然不使用 NSRunLoop,咋看之下并没有什么问题。具体会有什么问题,请看我的另一篇文章:《iOS 开发 - 深入理解 NSTimer 为什么要配合 NSRunLoop 进行使用》


最后附上这篇文章中所写的 CountDownLabel 最终版本。

CounDownLabel.h


#import <UIKit/UIKit.h>
@interface CountDownLabel : UILabel
/// 根据传入的时间计算和当前时间的进行比较,开始倒计时
- (void)setupCountDownWithTargetTime:(NSDate *)targetTime;
/// 根据传入的具体秒数,开始倒计时
- (void)beginCountDownWithTimeInterval:(NSTimeInterval)timerInterval;
/// 根据传入的两个时间差,开始倒计时
- (void)beginCountDownFromTime:(NSDate *)fromDate toTime:(NSDate *)toDate;
@end

CountDownLabel.m


#import "CountDownLabel.h"
@interface CountDownLabel ()
/// 定时器,使用 weak 或者 strong 都行
@property (nonatomic, strong) NSTimer *timer;
/// 剩余天数
@property (nonatomic, assign) NSUInteger day;
/// 剩余小时数
@property (nonatomic, assign) NSUInteger hour;
/// 剩余分钟数
@property (nonatomic, assign) NSUInteger minute;
/// 剩余秒数
@property (nonatomic, assign) NSUInteger second;
@end
@implementation CountDownLabel
/// 根据传入的时间计算和当前时间的进行比较,开始倒计时
- (void)setupCountDownWithTargetTime:(NSDate *)targetTime {
// 计算传入的时间和当前的时间差
NSTimeInterval interval = [targetTime timeIntervalSinceDate:[NSDate date]];
[self initTimeParametersWithTimeInterval:(NSInteger)interval];
}
/// 根据传入的具体秒数,开始倒计时
- (void)beginCountDownWithTimeInterval:(NSTimeInterval)timerInterval {
[self initTimeParametersWithTimeInterval:timerInterval];
}
/// 根据传入的两个时间差,开始倒计时
- (void)beginCountDownFromTime:(NSDate *)fromDate toTime:(NSDate *)toDate {
NSTimeInterval interval = [fromDate timeIntervalSinceDate:toDate];
[self initTimeParametersWithTimeInterval:interval];
}
/// 通过传入的时间间隔对时间参数进行初始化
- (void)initTimeParametersWithTimeInterval:(NSInteger)interval {
NSUInteger secondPerDay = 24 * 60 * 60;
NSUInteger secondPerHour = 60 * 60;
NSUInteger secondPerMinute = 60;
// 计算天数
self.day = interval / secondPerDay;
// 剩余小时不应该大于24小时,所以应该先除去满足一天的秒数,再计算还剩下多少小时
self.hour = interval % secondPerDay / secondPerHour;
// 剩余分钟数与上面同理
self.minute = interval % secondPerHour / secondPerMinute;
// 剩余秒数直接等于秒数对每分钟秒数所取的余数
self.second = interval % secondPerMinute;
// 更新值
[self updateText];
self.timer = [NSTimer scheduledTimerWithTimeInterval:1 target:self selector:@selector(updateTimer) userInfo:nil repeats:YES];
[[NSRunLoop mainRunLoop] addTimer:self.timer forMode:NSRunLoopCommonModes];
}
/// 时间减一秒方法
- (void)updateTimer {
// 减一秒
self.second--;
// 判断秒数
if (self.second == -1) {
self.second = 59;
self.minute--;
}
// 判断分钟数
if (self.minute == -1) {
self.minute = 59;
self.hour--;
}
// 判断小时数
if (self.hour == -1) {
self.hour = 23;
self.day--;
}
// 判断是否没时间了
if (self.day == 0 && self.hour == 0 && self.minute == 0 && self.second == 0) {
[self.timer invalidate];
}
// 更新值
[self updateText];
NSLog(@"%s", __func__);
}
- (void)updateText {
self.text = [NSString stringWithFormat:@"倒计时:%02zd 天 %02zd:%02zd:%02zd", self.day, self.hour, self.minute, self.second];
}
// best solution
- (void)removeFromSuperview {
[super removeFromSuperview];
[self.timer invalidate];
}
// this solution don't work....
// - (void)dealloc {
//
// [self.timer invalidate];
// }
@end



最新文章

123

最新摄影

微信扫一扫

第七城市微信公众平台