iOS 时间的处理
做App避免不了要和时间打交道,关于时间的处理,里面有不少门道,远不是一行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 )标准时间。
接下来我们看下iOS里,五花八门的记录时间的方式。
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对象描述的是时间线上的一个绝对的值,和时区和文化无关,它参考的值是:以UTC为标准的,2001年一月一日00:00:00这一刻的时间绝对值。
这里有个概念很重要,我们用编程语言描述时间的时候,都是以一个时间线上的绝对值为参考点,参考点再加上偏移量(以秒或者毫秒,微秒,纳秒为单位)来描述另外的时间点。
理解了这一点,再看NSDate的一些API调用就非常清楚了,比如:
NSDate* date = [NSDate date];
NSLog(@"current date interval: %f", [date timeIntervalSinceReferenceDate]);
timeIntervalSinceReferenceDate
返回的是距离参考时间的偏移量,这个偏移量的值为502945767秒,502945767/86400/365=15.9483056507,86400是一天所包含的秒数,365大致是一年的天数,15.94当然就是年数了,算出来刚好是此刻距离2001年的差值。
又比如,此刻我写文章的时候,当前时间为北京时间上午11:29,看看下面代码的输出:
NSDate* date = [NSDate date];
NSLog(@"current date: %@", date);
current date: 2016-12-09 03:29:09 +0000
。可见NSDate输出的是绝对的UTC时间,而北京时间的时区为UTC+8,上面的输出+8个小时,刚好就是我当前的时间了。
NSDate和市区和文化无关,所以要展示具体格式的时间,我们需要NSDateFormatter
和NSTimeZone
的辅助。
另外关于NSDate最重要的一点是:NSDate是受手机系统时间控制的。也就是说,当你修改了手机上的时间显示,NSDate获取当前时间的输出也会随之改变。在我们做App的时候,明白这一点,就知道NSDate并不可靠,因为用户可能会修改它的值。
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年一月一日00:00:00这一刻的时间绝对值。
同样CFAbsoluteTimeGetCurrent()也会跟着当前设备的系统时间一起变化,也可能会被用户修改。
gettimeofday
这个API也能返回一个描述当前时间的值,代码如下:
struct timeval now;
struct timezone tz;
gettimeofday(&now, &tz);
NSLog(@"gettimeofday: %ld", now.tv_sec);
使用gettimeofday获得的值是Unix time。Unix time又是什么呢?
Unix time是以UTC 1970年1月1号 00:00:00为基准时间,当前时间距离基准点偏移的秒数。上述API返回的值是1481266031,表示当前时间距离UTC 1970年1月1号 00:00:00一共过了1481266031秒。
Unix time也是平时我们使用较多的一个时间标准,在Mac的终端可以通过以下命令转换成可阅读的时间:
date -r 1481266031
实际上NSDate也有一个API能返回Unix time:
NSDate* date = [NSDate date];
NSLog(@"timeIntervalSince1970: %f", [date timeIntervalSince1970]);
gettimeofday和NSDate,CFAbsoluteTimeGetCurrent()一样,都是受当前设备的系统时间影响。只不过是参考的时间基准点不一样而已。我们和服务器通讯的时候一般使用Unix time。
mach_absolute_time()
mach_absolute_time()可能用到的同学比较少,但这个概念非常重要。
前面提到我们需要找到一个均匀变化的属性值来描述时间,而在我们的iPhone上刚好有一个这样的值存在,就是CPU的时钟周期数(ticks)。这个tick的数值可以用来描述时间,而mach_absolute_time()返回的就是CPU已经运行的tick的数量。将这个tick数经过一定的转换就可以变成秒数,或者纳秒数,这样就和时间直接关联了。
不过这个tick数,在每次手机重启之后,会重新开始计数,而且iPhone锁屏进入休眠之后tick也会暂停计数。
mach_absolute_time()不会受系统时间影响,只受设备重启和休眠行为影响。
CACurrentMediaTime()
CACurrentMediaTime()可能接触到的同学会多一些,先看下官方定义:
/* Returns the current CoreAnimation absolute time. This is the result of
* calling mach_absolute_time () and converting the units to seconds. */
CFTimeInterval CACurrentMediaTime (void)
CACurrentMediaTime()就是将上面mach_absolute_time()的CPU tick数转化成秒数的结果。以下代码:
double mediaTime = CACurrentMediaTime();
NSLog(@"CACurrentMediaTime: %f", mediaTime);
返回的就是开机后设备一共运行了(设备休眠不统计在内)多少秒,另一个API也能返回相同的值:
NSTimeInterval systemUptime = [[NSProcessInfo processInfo] systemUptime];
NSLog(@"systemUptime: %f", systemUptime);
CACurrentMediaTime()也不会受系统时间影响,只受设备重启和休眠行为影响。
sysctl
iOS系统还记录了上次设备重启的时间。可以通过如下API调用获取:
#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的方法,主线程出现卡顿的几率就会变高。
上面几种获取时间的方式精读上都是足够的,比如一个NSDate
API调用返回的精读是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,gettimeofday,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;
}
gettimeofday和sysctl都会受系统时间影响,但他们二者做一个减法所得的值,就和系统时间无关了。这样就可以避免用户修改时间了。当然用户如果关机,过段时间再开机,会导致我们获取到的时间慢与服务器时间,真实场景中,慢于服务器时间往往影响较小,我们一般担心的是客户端时间快于服务器时间。
多和服务器做时间同步,再把关键的时间校验逻辑放在Server端,就不会出现什么意外的bug了。
PS:
CST却同时可以代表如下 4 个不同的时区:
Central Standard Time (USA) UT-6:00
Central Standard Time (Australia) UT+9:30
China Standard Time UT+8:00
Cuba Standard Time UT-4:00
CST=UTC/GMT +8 小时
GMT = 格林威治标准时间
CST = 中国标准时间
CET = 欧洲中部时间