时间是一个较为抽象的概念,格林威治时间、世界时、祖鲁时间、GMT、UTC、跨时区、夏令时等等关于时间的定义、概念五花八门。但是,我们在编程语言里,认为时间是线性的、不可逆的,某一个时刻,只有一个绝对值,描述时间的时候,描述时间的时候都是以一个时间线上的绝对值为参考点,参考点再加上偏移量(以秒或者毫秒,微秒,纳秒为单位)来描述另外的时间点。

在日常开发的过程中,我们总会在各种场景中跟时间打交道,比如:获取当前时间、距离某个时间的倒计时、方法耗时统计等。伴随而来的问题也就产生了,如何保证获取的是准确时间?一些限时操作,如何与服务器时间同步?怎么防止用户修改时间作弊?获取时间的方法性能如何? 近期在解决某个限时操作的场景中的问题,发现获取时间的方式上很有必要总结一下。

标准时间种类

GMT(Greenwich Mean Time)

格林威治时间。它规定太阳每天经过位于英国伦敦郊区的皇家格林威治天文台的时间为中午12点。

UTC(Coodinated Universal Time)

协调世界时,又称世界统一时间、世界标准时间、国际协调时间。由于英文(CUT)和法文(TUC)的缩写不同,作为妥协,简称UTC。UTC 是现在全球通用的时间标准,全球各地都同意将各自的时间进行同步协调。UTC 时间是经过平均太阳时(以格林威治时间GMT为准)、地轴运动修正后的新时标以及以秒为单位的国际原子时所综合精算而成。

我们可以认为格林威治时间就是世界协调时间(GMT=UTC),格林威治时间和UTC时间均用秒数来计算的。

iOS获取时间的方式

NSDate

我们平时调用NSFoundation框架下的获取时间方式[NSDate date]获取当前时间就是UTC时间,是以为参考的时间间隔。但是我们在与服务器的交互中,服务器通常使用的是Unix time,而Unix time是以UTC 为参考的时间间隔。NSDate提供了这种转换的能力,看下NSDate的API:

@property (readonly) NSTimeInterval timeIntervalSinceNow; //以当前时间为基准 
@property (readonly) NSTimeInterval timeIntervalSince1970;//以1970年1月1号 00:00:00为基准 
@property (readonly) NSTimeInterval timeIntervalSinceReferenceDate;//以2001年1月1日00:00:00为基准

实例代码

NSDate* date = [NSDate date]; 
NSLog(@"%@",date); 
NSLog(@"timeIntervalSinceNow: %f", [date timeIntervalSinceNow]); 
NSLog(@"timeIntervalSince1970: %f", [date timeIntervalSince1970]); 
NSLog(@"timeIntervalSinceReferenceDate: %f", [date timeIntervalSinceReferenceDate]);

输出结果:

2020-12-12 20:16:58.160637+0800 DateTest[6402:2425204] Sat Dec 12 20:16:58 2020 
2020-12-12 20:16:58.160686+0800 DateTest[6402:2425204] timeIntervalSinceNow: -0.000064 
2020-12-12 20:16:58.160717+0800 DateTest[6402:2425204] timeIntervalSince1970: 1607775418.160621 
2020-12-12 20:16:58.160739+0800 DateTest[6402:2425204] timeIntervalSinceReferenceDate: 629468218.160621629467211.468034

注意2020-12-12 20:16:58.160637+0800输出的时间是+8小时的,因为北京属于UTC+8的时区,那么UTC的绝对时间就是2020-12-12 12:16:58.160637+0800。不同时区显示的时间不一致,但是绝对时间的偏移量是相同的,NSDate具体要展示成什么样,取决于NSDateFormatter和NSTimeZone。

最重要一点:NSDate获取的时间,受系统控制,用户可以修改。

CFAbsoluteTimeGetCurrent

Core Foundation框架下获取当前时间的方式,以 2001年1月1日00:00:00 为时间基准。

NSDate* date = [NSDate date]; 
CFAbsoluteTime absoluteTime = CFAbsoluteTimeGetCurrent(); 
NSLog(@"NSDate timeIntervalSinceReferenceDate: %f", [date timeIntervalSinceReferenceDate]);
NSLog(@"CFAbsoluteTime: %f", absoluteTime); 
2020-12-12 20:38:19.120249+0800 DateTest[6407:2430605] timeIntervalSinceReferenceDate: 629469499.120139
2020-12-12 20:38:19.120267+0800 DateTest[6407:2430605] CFAbsoluteTime: 629469499.120266

输出结果可以看出,与NSDate 的timeIntervalSinceReferenceDate获取的时间戳一致。同样受系统控制,用户可以修改

gettimeofday函数

C函数获取当前时间int gettimeofday(struct timeval * __restrict, void * __restrict);

2020-12-12 20:59:29.681949+0800 DateTest[6472:2441874] CFAbsoluteTime: 629470769.681292 
2020-12-12 20:59:29.682059+0800 DateTest[6472:2441874] getTimeOfDay: 1607777969.000000

以UTC 1970年1月1号 00:00:00 为基准,同样受系统控制,用户可以修改。

mach_absolute_time函数

uint64_t nStartTick = mach_absolute_time() NSLog(@"nStartTick: %llu",nStartTick); 
2020-12-12 22:10:30.002634+0800 DateTest[311:4891] nStartTick: 2188669381

uint64_t mach_absolute_time(void);该函数返回的是CPU已经运行的“滴答”数,手机重启后,重新从一个任意值开始计数,不受系统控制,只受设备重启和休眠行为影响

这个“滴答”数可以通过mach_timebase_info转换成秒,即重启后运行了多久,方法如下:

double machTime = (double)mach_absolute_time()*(double)timebase.numer/(double)timebase.denom/1e9;//转换为 s 
NSLog(@"nStartTick to s: %f",machTime); 
2020-12-12 22:18:40.930518+0800 DateTest[359:8022] nStartTick to s: 592.234748

CACurrentMediaTime

看文档是不是很熟悉,这个时间就是 mach_absolute_time 方式获取的CPU启动后的运行时间,这个得到的直接是以秒为单位,不受系统控制,只受设备重启和休眠行为影响

2020-12-12 22:29:52.069621+0800 DateTest[368:10795] nStartTick to s: 1263.375108 
2020-12-12 22:29:52.069740+0800 DateTest[368:10795] CACurrentMediaTime :1263.375350

NSProcessInfo processInfo

通过NSProcessInfo获取系统重启后的运行时间不受系统控制,只受设备重启和休眠行为影响

NSLog(@"CACurrentMediaTime :%f",CACurrentMediaTime()); 
NSLog(@"NSProcessInfo :%f", [[NSProcessInfo processInfo] systemUptime]); 
2020-12-12 22:37:25.380199+0800 DateTest[372:12605] CACurrentMediaTime :1716.685811 
2020-12-12 22:37:25.380374+0800 DateTest[372:12605] NSProcessInfo :1716.685986

sysctl函数

获取设备上次重启的Unix time,受系统控制,用户可以修改。

调用方式如下:

- (long)_bootTime { 
    struct timeval bootTime; 
    int mib[2] = {CTL_KERN,KERN_BOOTTIME}; 
    size_t size = sizeof(bootTime); 
    if (sysctl(mib, 2, &bootTime, &size, NULL, 0) != -1) { 
        return bootTime.tv_sec;///秒 
        // return bootTime.tv_usec;///微秒
    }; 
    return 0;
}

clock_gettime函数

**clockid_t**的常用枚举值: 
CLOCK_REALTIME, a system-wide realtime clock. 
CLOCK_PROCESS_CPUTIME_ID, high-resolution timer provided by the CPUfor each process. 
CLOCK_THREAD_CPUTIME_ID, high-resolution timer provided by the CPUfor each of the threads. 
CLOCK_REALTIME,a system-wide realtime clock. 
CLOCK_PROCESS_CPUTIME_ID, high-resolution timer providedby the CPU for each process. 
CLOCK_THREAD_CPUTIME_ID, high-resolution timer provided bythe CPU for each of the threads.

这个函数只iOS10以上的系统可用。调用方式如下:

- (long)_getSystemUptime { 
    struct timespec ts; 
    if (@available(iOS 10.0, *)) {
        clock_gettime(CLOCK_REALTIME, &ts); 
    } else { 
    } 
    return ts.tv_sec; 
    // return ts.tv_nsec;//纳秒 
} 
//输出 
DateTest[507:34153] getTimeOfDay: 1607846040.000000 
DateTest[507:34153] Clock_realtimeUptime: 1607846040.000000

CLOCK_REALTIME 代表机器上的实际时间,与gettimeofday()函数获取的时间一样,以UTC 为基准,受系统控制,用户可以修改

clock_gettime()可以得到跟gettimeofday()函数一样的当前时间,但是更加精准,可以到纳秒级的精度。

对比下两个函数的参数的结构体:struct timeval,struct timespec

_STRUCT_TIMEVAL 
{
    __darwin_time_t tv_sec; /* seconds */ 
    __darwin_suseconds_t tv_usec; /* and microseconds */ 
}; 
_STRUCT_TIMESPEC 
{ 
    __darwin_time_t tv_sec; 
    long tv_nsec; 
};

同步服务器时间

在我们的考试的业务场景中,时间是一个非常关键的参数,限时的考试任务,考试可持续的时长、考试开始时间、结束时间的判断,都需要我们和服务器时间保持一致。

我们现有的做法,是在App启动的时候,同步一下服务器时间,拿到服务器给的时间serverTime,和本地时间localTime计算出一个差值diff,保存起来。下次调用取当前时间currentTime的时候,再次取localTime加上这个diff得出真正的时间。

那么localTime又如何取值呢?上面介绍的NSDate、CFAbsoluteTimeGetCurrent()、gettimeofday()、sysctl()都受系统时间影响,mach_absolute_time()、CACurrentMediaTime()、NSProcessInfo获取的CPU“滴答”数又受设备重启、休眠的影响。

参考其他客户端实现方案,我们使用函数sysctl()获取系统上次重启的时间,在获取本地当前时间gettimeofday(),二者都受系统时间影响,但是两者相减,就跟系统时间没有关系了。

代码实现:

- (NSTimeInterval)_systemUptime { 
    // 获取系统上次重启时间
    struct timeval bootTime; 
    int mib[2] = {CTL_KERN, KERN_BOOTTIME}; 
    size_t size = sizeof(bootTime); 
    int resutl = sysctl(mib, 2, &bootTime, &size, NULL, 0); 
    
    // 获取当前时间 
    struct timeval now; 
    struct timezone tz; 
    gettimeofday(&now, &tz); 
    
    NSTimeInterval uptime = -1; 
    
    if (resutl != -1 && bootTime.tv_sec != 0) { 
    uptime = now.tv_sec - bootTime.tv_sec; 
    } 
    return uptime; 
}

第一次同步服务器时间的时候计算出差值timeDiff:

double interval = [[[NSString alloc]initWithData:jsonData encoding:kCFStringEncodingUTF8] doubleValue]; 
NSDate *serverDate = [NSDate dateWithTimeIntervalSince1970:interval / 1000.0]; 
//获取到与系统时间无关的upTime 
NSTimeInterval uptime = [self _systemUptime]; 
NSDate *cpuDate = [NSDate dateWithTimeIntervalSince1970:uptime]; 
self.timeDiff = [[NSDate dateWithTimeIntervalSince1970:uptime] timeIntervalSinceDate:serverDate];

后边再取当前时间的时候,就用upTime加上TimeDiff:

NSDate *cpuDate = [NSDate dateWithTimeIntervalSince1970: [self _systemUptime]];
NSDate *realCurrentDate = [cpuDate dateByAddingTimeInterval:-self.timeDiff];

对比下修改时间前后的输出结果,把当前时间修改到20点,diff后得到的时间依然准确:

2020-12-13 16:47:28.830748+0800 DateTest[646:68713] ServerDate :Sun Dec 13 16:47:28 2020 --:1607849248.976000 
2020-12-13 16:47:28.830919+0800 DateTest[646:68713] LocalDate :Sun Dec 13 16:47:28 2020 --:1607849248.826418 
2020-12-13 16:47:28.831037+0800 DateTest[646:68713] after diff Date :Sun Dec 13 16:47:28 2020 --:1607849248.976000 
2020-12-13 20:48:03.310993+0800 DateTest[646:68713] 修改时间 
2020-12-13 20:48:03.311147+0800 DateTest[646:68713] LocalDate :Sun Dec 13 20:48:03 2020 --:1607863683.309711 
2020-12-13 20:48:03.311223+0800 DateTest[646:68713] after diff Date :Sun Dec 13 16:48:14 2020 --:1607849294.976000

总结:

时间问题看似很小,但是出现问题,又很难跟踪、排查,你不知道用户做了哪些操作,影响了时间,对于时间要求比较严格的场景,要尽可能多的和服务器时间同步,我们需要做的就是尽可能的减少这种影响,从而避免影响程序的逻辑。