前言
在iOS开发过程中,经常会和时间打交道。例如用户在一个页面停留的时间、两个方法哪个执行更快等等。之前对于这部分内容没有深入地了解,直到在实际工作中遇到了时间处理的坑,才决定好好研究下iOS中的时间处理。后来发现关于时间的处理还是有不少门道,远不是一行API调用,获取当前系统时间这么简单。本文的目的就是让大家了解与时间相关的各种API之间的差别,再因场景而异去设计相应的机制。
相关概念
在开始深入讨论之前,我们需要确信一个前提:时间是线性的。即任意一个时刻,这个地球上只有一个绝对时间值存在,只不过因为时区或者文化的差异,处于同一时空的我们对同一时间的表述或者理解不同。这个看似简单明了的道理,是我们理解各种与时间相关的复杂概念的基石。就像UTF-8和UTF-16其实都是Unicode一样,北京的20:00和东京的21:00其实是同一个绝对的时间值。
GMT
人类对于时间的理解还很有限,但我们至少能确定一点:时间的变化是匀速的。时间前进的速度是均匀的,不会忽快忽慢,所以为了描述时间,我们也需要找到一个值,它的变化也是以均匀的速度向前变化的。
说出来你可能不信,我们人类为了寻找这个参考值,来精确描述当前的时间值,都经历了漫长岁月的探索。你可以尝试思考下,生活中有什么事物是随着时间均匀变化的,它具备的数值属性,会随着时间处于绝对的匀速变化状态。
前人发现抬头看太阳是个好办法,太阳总是按规律的“早起晚落”,而且“亘古不变”,可以用太阳在一天当中所处的位置来描述当前的时间。后来不同地区的文化需要交流,你这里太阳正高空照,我这可能已经下山了,所以需要有一个公共的大家都认可的地方,以这个地方太阳的位置来做参考着,沟通起来就会方便很多。最后选择的是英国伦敦的格林尼治天文台所在地,以格林尼治的时间作为公共时间,也就是我们所说的GMT时间(Greenwich Mean Time)。
UTC
太阳所处的位置变化跟地球的自转相关,过去人们认为地球自转的速率是恒定的,但在1960年这一认知被推翻了,人们发现地球自转的速率正变得越来越慢,而时间前进的速率还是恒定的,所以GMT不再被认为可以用来精准的描述时间了。
我们需要继续寻找一个匀速前进的值。抬头看天是我们从宏观方向去寻找答案,科技的发展让我们在微观方面取得了更深的认识,于是有聪明人根据微观粒子原子的物理属性,建立了原子钟,以这种原子钟来衡量时间的变化,原子钟50亿年才会误差1秒,这种精读已经远胜于GMT了。这个原子钟所反映的时间,也就是我们现在所使用的UTC(Coordinated Universal Time )标准时间。
常用处理方式
NSDate
说到时间大家应该第一个就会想到NSDate,它也是我们平时使用较多的一个类。我们先来看下它的定义:
NSDate objects encapsulate a single point in time, independent of any particular calendrical system or time zone. Date objects are immutable, representing an invariant time interval relative to an absolute reference date (00:00:00 UTC on 1 January 2001).
根据定义我们可以知道NSDate对象描述的是一个时间点,并独立于任何特定的历法系统或时区。它是相对于绝对引用日期(2001年1月1日00:00 UTC)的不变时间间隔。
关于NSDate的使用相信大家都已经很熟悉,这里不再敖述,需要注意的一点是:NSDate是受手机系统时间控制的。也就是说,当你修改了手机上的时间显示,NSDate获取当前时间的输出也会随之改变。因此,我们可以知道NSDate并不可靠,因为用户可能会修改它的值。
这种NSDate的不可靠会导致什么问题呢?
举个例子,假如我想计算一段代码的运行时间,可能会采用如下的方式:
NSDate* startDate = [NSDate date];
//Your code here...
NSTimeInterval deltaTime = [[NSDate date] timeIntervalSinceDate:startDate];
NSLog(@"cost time = %f", deltaTime);
如果在startDate之后用户修改了手机系统时间,这样在你的代码执行完毕之后,使用[NSDate date]
获取的时间就是不准确的。因此,得到的代码执行时间也是不准确的。这就是NSDate的不可靠可能导致的问题。
CFAbsoluteTimeGetCurrent
利用CFAbsoluteTimeGetCurrent主要是利用CFAbsoluteTimeGetCurrent()
这个方法来获取当前的绝对时间。先来看下官方定义:
Absolute time is measured in seconds relative to the absolute reference date of Jan 1 2001 00:00:00 GMT. A positive value represents a date after the reference date, a negative value represents a date before it. For example, the absolute time -32940326 is equivalent to December 16th, 1999 at 17:54:34. Repeated calls to this function do not guarantee monotonically increasing results. The system time may decrease due to synchronization with external time references or due to an explicit user change of the clock.
从上面的描述不难看出CFAbsoluteTimeGetCurrent的概念和NSDate非常相似,只不过参考点是:以GMT为标准的,2001年1月1日00:00:00这一刻的时间绝对值。
看到这里可能会有疑问CFAbsoluteTimeGetCurrent()
这个方法是如何获取时间的呢?
我们追踪进去查看代码,就会有答案了,源码如下:
typedef double CFTimeInterval;
typedef CFTimeInterval CFAbsoluteTime;
/* absolute time is the time interval since the reference date */
/* the reference date (epoch) is 00:00:00 1 January 2001. */
CF_EXPORT
CFAbsoluteTime CFAbsoluteTimeGetCurrent(void);
从中我们可以看出:
-
CFAbsoluteTimeGetCurrent()
返回的时间就是当前时间相对与reference date
的时间。 -
CFTimeInterval
与NSTimeInterval
同样都是对double的重命名。
因此,我们可以知道CFAbsoluteTimeGetCurrent()
等价于[[NSDate date] timeIntervalSinceReferenceDate]
同样CFAbsoluteTimeGetCurrent()
也会跟着当前设备的系统时间一起变化,也可能会被用户修改。
mach_absolute_time
mach_absolute_time可能用到的同学比较少,官方的解释也比较少,不过这个概念非常重要。
前面提到我们需要找到一个均匀变化的属性值来描述时间,而在我们的iPhone上刚好有一个这样的值存在,就是CPU的时钟周期数(ticks)。这个tick的数值可以用来描述时间,而mach_absolute_time()
返回的就是CPU已经运行的tick的数量。将这个tick数经过一定的转换就可以变成秒数,或者纳秒数,这样就和时间直接关联了。
不过这个tick数,在每次手机重启之后,会重新开始计数,而且iPhone锁屏进入休眠之后tick也会暂停计数。
mach_absolute_time不会受系统时间影响,只受设备重启和休眠行为影响。
CACurrentMediaTime
相较于mach_absolute_time,CACurrentMediaTime可能接触到的同学会多一些,先看下官方定义:
A CFTimeInterval derived by calling mach_absolute_time() and converting the result to seconds.
CACurrentMediaTime()
就是将上面mach_absolute_time()
的CPU tick数转化成秒数的结果。以下代码:
double mediaTime = CACurrentMediaTime();
NSLog(@"CACurrentMediaTime: %f", mediaTime);
返回的就是开机后设备一共运行了(设备休眠不统计在内)多少秒,另一个API也能返回相同的值:
NSTimeInterval systemUptime = [[NSProcessInfo processInfo] systemUptime];
NSLog(@"systemUptime: %f", systemUptime);
CACurrentMediaTime()
也不会受系统时间影响,只受设备重启和休眠行为影响。
dispatch_benchmark
dispatch_benchmark是libdispatch (Grand Central Dispatch) 的一部分。但它是一个私有的API,如果我们要使用的话必须先要自己进行声明:
extern uint64_t dispatch_benchmark(size_t count, void (^block)(void));
第一个参数是指定要测试几轮,最后会输出平均值,测试次数越多也越精确。第二个参数是测试用的Block。
因为没有公开的函数定义,dispatch_benchmark
在Xcode中也没有公开的文档,但是可以参考帮助页面。
下面我们看一个使用的例子:
size_t const objectCount = 1000;
uint64_t n = dispatch_benchmark(10000, ^{
@autoreleasepool {
id obj = @42;
NSMutableArray *array = [NSMutableArray array];
for (size_t i = 0; i < objectCount; ++i) {
[array addObject:obj];
}
}
});
NSLog(@"-[NSMutableArray addObject:] : %llu ns", n);
在我的机器上输出了:
-[NSMutableArray addObject:] : 16512 ns
也就是说添加1000个对象到 NSMutableArray 总共消耗了16512纳秒,或者说平均一个对象消耗16纳秒。
需要注意的是,由于dispatch_benchmark
是私有的API,因此这个功能只能在私下测试性能时使用,如果要上架AppStore要注意。
小结
时间方式 | 单位 | 精度 | 备注 |
---|---|---|---|
NSDate | 秒 | 微秒 | |
CFAbsoluteTimeGetCurrent | 微秒 | 微秒 | 等价于NSDate |
mach_absolute_time | 纳秒 | 纳秒 | 依内建时钟为准 |
CACurrentMediaTime | 秒 | 纳秒 | 封装自mach_absolute_time |
dispatch_benchmark | 纳秒 | 纳秒 | 私有API,具有多次测试功能 |
更精确的时间
如果想要获取更精确的时间,能想到的就是内核kernel的时间了。每次系统重启之后,会一直记录启动至今的时间,这个时间称之为upTime。那这个时间有什么用呢?下面举个例子:
例如我们获取server时间和uptime的差值timerOffset ,将这个值保存下来,后续每次发request请求的时候,基于timerOffset将upTime换算为上传的真正时间值即可,然后定时或者按照一定周期和服务端同步,将timerOffset进行修正。代码如下:
self.timerOffset = serverTimer - upTime;
NSTimeInterval uploadTime = upTime + self.timerOffset;
那么问题来了,如何获取启动时间updateTime?
Objective-C的方式获取
系统进程可以通过NSProcessInfo获取,然后他很好的提供给我们了一个Uptime接口,直接就可以获取到启动时间,代码如下:
NSTimeInterval systemUptime =[[NSProcessInfo processInfo] systemUptime];
这个获取到的时间单位是秒,打印结果如下:
(lldb) po [[NSProcessInfo processInfo] systemUptime]
22188.438757280001
通过C的方式获取kernel_task的启动时间
对于时间的追求自然是越精确越好,当然,对于大部分App来说是不需要如此精确的。但是对于学习和技能提升来说,还是可以再往深里考虑考虑的。
kernel_task是个系统级的task,看一下活动监视器,就会看到kernel_task,如下图:
kernel_task.png
kernel_task包括多线程调度管理、虚拟内存、系统IO、线程之间通信等等。所以系统已启动,kernel_task就会跑起来,kernel_task运行的时间,就可以作为启动时间来使用。
获取kernel_task的启动时间,我们需要用到一个函数sysctl, 首先看一下sysctl函数的定义:
int sysctl(int *, u_int, void *, size_t *, void *, size_t);
- sysctl的第一参数是个数组,按照顺序第一个元素指定本请求定向到内核的哪个子系统,第二个及其后元素依次细化指定该系统的某个部分,类推...
- 之后是数组的长度u_int
- 后面的size_t *需要注意,当sysctl被调用时,size指向的值指定该缓冲区的大小;函数返回时,该值给出内核存放在该缓冲区中的数据量,如果这个缓冲不够大,函数就返回ENOMEM错误
- sysctl函数的结果:成功:返回0; 失败:返回-1
例子如下:
#include <sys/sysctl.h>
- (long)bootTime
{
#define MIB_SIZE 2
int mib[MIB_SIZE];
size_t size;
struct timeval boottime;
mib[0] = CTL_KERN;
mib[1] = KERN_BOOTTIME;
size = sizeof(boottime);
if (sysctl(mib, MIB_SIZE, &boottime, &size, NULL, 0) != -1)
{
return boottime.tv_sec;
}
return 0;
}
返回的值是上次设备重启的Unix time。
这个API返回的值也会受系统时间影响,用户如果修改时间,值也会随着变化。
应用场景
场景一,时间测量
我们做性能优化的时候,经常需要对某个方法执行的时间做记录,就必然会用到上面提到的一些获取时间的方法。
在做方法执行时间的benchmark的时候,我们获取时间的方法要满足两个要求,一是精读要高,而是API本身几乎不耗CPU时间。
客户端做性能优化一般是为了主线程的流畅性,而我们知道UI线程如果遇到超过16.7ms的阻塞,就会出现掉帧现象,所以我们关注的时间的精读实际上是在毫秒(ms)级别。我们写客户端代码的时候,基本上都是处于ms这一维度,如果一个方法损耗是0.1ms,我们可以认为这个方法对于流畅性来说是安全的,如果经常看到超过1ms或者几个ms的方法,主线程出现卡顿的几率就会变高。
上面几种获取时间的方式精读上都是足够的,比如一个NSDateAPI调用返回的精读是0.000004 s,也就是4微秒,CACurrentMediaTime()
返回的精读也在微秒级别,精读上都符合要求。不过有一种看法,认为NSDate属于类的封装,OOP高级语言本身所带来的损耗可能会影响最后的实际结果,在做benchmark的时候不如C函数调用精准,为了验证这一说法,我写了一段简单的测试代码:
int testCount = 10000;
double avgCost = 0;
for (int i = 0; i < testCount; i ++) {
NSDate* begin = [NSDate date];
NSLog(@"a meaningless log");
avgCost += -[begin timeIntervalSinceNow];
}
NSLog(@"benchmark with NSDate: %f", avgCost/testCount);
avgCost = 0;
for (int i = 0; i < testCount; i ++) {
double startTime = CACurrentMediaTime();
NSLog(@"a meaningless log");
double endTime = CACurrentMediaTime();
avgCost += (endTime - startTime);
}
NSLog(@"benchmark with CACurrentMediaTime: %f", avgCost/testCount);
输出结果为:
benchmark with NSDate: 0.000046
benchmark with CACurrentMediaTime: 0.000037
可以看出CACurrentMediaTime与NSDate代码本身的损耗差异在几微秒,而我们做UI性能优化的维度在毫秒级别,几个微秒的差异完全不会影响我们最后的判断结果。所以使用NSDate做benchmark完全是可行的,以下是我常用的两个宏:
#define TICK NSDate *startTime = [NSDate date]
#define TOCK NSLog(@"Time Cost: %f", -[startTime timeIntervalSinceNow])
场景二:客户端和服务器之间的时间同步
这也是我们经常遇到的场景,比如电商类App到零点的时候开始抢购,比如商品限购倒计时等等,这种场景下需要我们将客户端的时间与服务器保持一致,最重要的是,要防止用户通过断网修改系统时间,来影响客户端的逻辑。
比较普遍的做法是,在一些常用的Server接口里面带上服务器时间,每调用一次接口,客户端就和服务器时间做一次同步并记录下来,但问题是如何防止用户修改呢?
上面提到的NSDate,CFAbsoluteTimeGetCurrent,sysctl都是跟随系统时间变化的,mach_absolute_time和CACurrentMediaTime虽然是依据CPU时钟数,不受系统时间影响,但在休眠和重启的时候还是会被影响。看上去都不太适合,这里介绍下我个人的做法。
首先还是会依赖于接口和服务器时间做同步,每次同步记录一个serverTime(Unix time),同时记录当前客户端的时间值lastSyncLocalTime,到之后算本地时间的时候先取curLocalTime,算出偏移量,再加上serverTime就得出时间了:
uint64_t realLocalTime = 0;
if (serverTime != 0 && lastSyncLocalTime != 0) {
realLocalTime = serverTime + (curLocalTime - lastSyncLocalTime);
}
else {
realLocalTime = [[NSDate date] timeIntervalSince1970]*1000;
}
如果从来没和服务器时间同步过,就只能取本地的系统时间了,这种情况几乎也没什么影响,说明客户端还没开始用过。
关键在于如果获取本地的时间,可以用一个小技巧来获取系统当前运行了多长时间,用系统的运行时间来记录当前客户端的时间:
//get system uptime since last boot
- (NSTimeInterval)uptime
{
struct timeval boottime;
int mib[2] = {CTL_KERN, KERN_BOOTTIME};
size_t size = sizeof(boottime);
struct timeval now;
struct timezone tz;
gettimeofday(&now, &tz);
double uptime = -1;
if (sysctl(mib, 2, &boottime, &size, NULL, 0) != -1 && boottime.tv_sec != 0)
{
uptime = now.tv_sec - boottime.tv_sec;
uptime += (double)(now.tv_usec - boottime.tv_usec) / 1000000.0;
}
return uptime;
}
sysctl会受系统时间影响,但他们二者做一个减法所得的值,就和系统时间无关了。这样就可以避免用户修改时间了。当然用户如果关机,过段时间再开机,会导致我们获取到的时间慢与服务器时间,真实场景中,慢于服务器时间往往影响较小,我们一般担心的是客户端时间快于服务器时间。
多和服务器做时间同步,再把关键的时间校验逻辑放在Server端,就不会出现什么意外的bug了。
总结
关于时间处理的逻辑就总结到这里了,关键还在于我们对于时间本身的理解,对于表达时间的各种方式的理解,理解背后的原理才能选择合适的工具。
网友评论