游戏开发中不同时区下的时间问题

在全球化互联网时代,许多游戏厂商都在大力开拓海外市场,大量的游戏也都会选择在海外发行。作为游戏开发者的我们也不得不处理一个容易被忽略的问题,全球不同时区下的时间问题

一些与时区有关的时间概念

GMT(格林威治平均时间,Greenwich Mean Time)是指位于英国伦敦郊区的皇家格林尼治天文台当地的平太阳时,它规定太阳每天经过位于英国伦敦郊区的皇家格林威治天文台的时间为中午12点。由于地球每天的自转是有些不规则的,而且正在缓慢减速,因此格林尼治平时基于天文观测本身的缺陷,已经被原子钟报时的协调世界时(UTC)所取代。

UTC(协调世界时,取自英文和法文的缩写,英文是Coordinated Universal Time)是最主要的世界时间标准,其以原子时秒长为基础,在时刻上尽量接近于格林威治标准时间

本地时间是指在日常生活中所使用的时间。这个时间等于我们所在(或者所使用)时区内的当地时间,它由与世界标准时间(UTC或GMT)之间的偏移量来定义。

GMT+08:00(UTC+8)即北京时间,比协调世界时快八小时。注意北京时间并不是北京的地方时间。

unix时间戳是从UTC1970年1月1日0时0分0秒(UTC/GMT的午夜)起至现在的总秒数,不考虑闰秒。因此时间戳不会因为时区的不同而不同

夏令时(Daylight Saving Time:DST),又称日光节约时间,是为了节约能源,人为规定的时间。一般在天亮早的夏季人为将时间调快一小时,可以使人早起早睡,减少照明量,以充分利用光照资源,从而节约照明用电。

提前说明,本文后面会用一个名词“时间表示”来指代包含年月日时分秒信息的时间对象,比如以下的类型就可以被称之为时间表示

  • 时间字符串 "1969年12月31日16时0分0秒"
  • lua的表{year = 2021, month = 7, day = 17, hour = 19, min = 37, sec = 0}

时间表示很重要的一个特点是它是受时区影响的,但本身又没有携带时区信息。对于同一时刻,不同时区的时间表示是不同的。而时间戳恰恰相反,它不受时区影响,或者说它只针对于UTC时间。对于同一时刻,不同时区的时间戳都是唯一的

如果采用Unity做游戏开发,则可能会用到C#语言和Lua语言,所以接下来就分别介绍这两种语言如何处理不同时区下的时间问题。

lua的时间库

lua对时间的处理主要是两个函数os.time和os.date

os.time ([table])

  • 当不传参数时,返回当前时刻的时间戳。它在任意时区下获取到的结果一致,因为始终表示从UTC1970年1月1日0时0分0秒到当前时刻的UTC时间所经过的秒数
  • 如果传入一张表,就返回由这张表表示的时刻的时间戳。 这张表必须包含域 year,month,及 day; 可以包含有 hour (默认为 12 ), min (默认为 0), sec (默认为 0),以及 isdst (默认为 nil)。

请看下面的一段示例代码

local t1 = os.time()  
print(t1)  -- 输出 1626521822
local t2 = os.time({year = 2021, month = 7, day = 17, hour = 19, min = 37, sec = 0})
print(t2)  -- 输出 1626521820
local t3 = os.time({year = 1970, month = 1, day = 1, hour = 0, min = 0, sec = 0})
print(t3)  -- 输出 nil
local t4 = os.time({year = 1970, month = 1, day = 1, hour = 8, min = 0, sec = 0})
print(t4)  -- 输出 0

第一个输出表示的是执行该代码时的时间戳,当时我是在北京时间"2021-07-17 19:37:02"时刻执行的,所以它与第二个输出,表示的是北京时间"2021-07-17 19:37:00"时刻的时间戳,相差2秒是正确的

问题在于第三个输出为什么是nil,而第四个输出是0?

注意时间戳表示的是从UTC1970年1月1日0时0分0秒到当前时刻所经过的秒数,而os.time在将时间表示转换为时间戳时,认为这个时间表示是本地时区的时间。而我的时间是北京时间,将北京时间1970年1月1日0时0分0秒转换为UTC时间,实际上是1969年12月31日16时0分0秒,超出了时间戳的定义范围,所以返回的是nil。

对于第四个输出,北京时间的1970年1月1日8时0分0秒,对应的正好是UTC时间1970年1月1日0时0分0秒,所以输出是0

os.date ([format [, time]])

  • 返回一个包含日期及时刻的字符串或表。 格式化方法取决于所给字符串 format。
  • 如果提供了 time 参数, 格式化这个时间 (这个值的含义参见 os.time 函数)。 否则,date 格式化当前时间。
  • 如果 format 以 \'!\' 打头, 日期以协调世界时格式化,如果没有 \'!\' 日期以本地时间格式化。 在这个可选字符项之后
    • 如果 format 为字符串 "*t", date 返回有后续域的表: year (四位数字),month (1–12),day (1–31), hour (0–23),min (0–59),sec (0–61), wday (星期几,星期天为 1 ), yday (当年的第几天), 以及 isdst (夏令时标记,一个布尔量)。 对于最后一个域,如果该信息不提供的话就不存在。
    • 如果 format 并非 "*t", date 以字符串形式返回, 格式化方法遵循 ISO C 函数 strftime 的规则。
  • 如果不传参数调用, date 返回一个合理的日期时间串, 格式取决于宿主程序以及当前的区域设置 (即,os.date() 等价于 os.date("%c"))。

请看下面的一段示例代码

local d1 = os.date("%Y-%m-%d %H:%M:%S", 1626521822)
print(d1)  -- 输出 2021-07-17 19:37:02
local d2 = os.date("!%Y-%m-%d %H:%M:%S", 1626521822)
print(d2)  -- 输出 2021-07-17 11:37:02

对于第一个输出,format字符串没有以 \'!\' 打头,所以它是以本地时间格式化的,即北京时间。所以返回"2021-07-17 19:37:02",如果执行代码的开发者是在东九区(比北京时间快一个小时),则会返回"2021-07-17 20:37:02"。因此该代码在不同的时区执行,输出的结果是不同的

对于第二个输出,format字符串以 \'!\' 打头,所以它以协调世界时格式化,无论在哪个时区,执行该代码都返回的是相同值

c#的时间库

由于本文主要是探讨不同时区下的时间问题,所以这里就只列出了C#部分与时区转换相关的类和函数

DateTime

表示时间上的一刻,通常以日期和当天的时间表示

请看下面的一段示例代码

DateTime dateTime = new DateTime(2021, 7, 17, 19, 37, 2, DateTimeKind.Unspecified);
DateTime d1 = dateTime.ToLocalTime();
DateTime d2 = dateTime.ToUniversalTime().ToLocalTime();
Console.WriteLine(d1);  // 输出 2021/7/18 3:37:02
Console.WriteLine(d2);  // 输出 2021/7/17 19:37:02

可以看到第一个输出与第二个输出是不同的,这是因为当一个DateTime对象的Kind属性是DateTimeKind.Unspecified时,调用ToLocalTime()方法,会默认DateTime对象是基于UTC的。调用ToUniversalTime(),会默认DateTime对象是基于本地时间的。进行时区转换时,尽量使用TimeZoneInfo来避免这样的默认设定

TimeZoneInfo

如何进行时区转换

由于C#本身已经定义了时区的概念,所以转换起来比较容易,直接使用ConvertTime函数

请看下面的一段示例代码

DateTime dateTime = new DateTime(2021, 7, 17, 19, 37, 2, DateTimeKind.Unspecified);
TimeZoneInfo timeZoneInfo1 = TimeZoneInfo.Local;
TimeZoneInfo timeZoneInfo2 = TimeZoneInfo.Utc;
DateTime d1 = TimeZoneInfo.ConvertTime(dateTime, timeZoneInfo1, timeZoneInfo2);
DateTime d2 = TimeZoneInfo.ConvertTime(dateTime, timeZoneInfo2, timeZoneInfo1);
Console.WriteLine(d1);  // 输出 2021/7/17 11:37:02
Console.WriteLine(d2);  // 输出 2021/7/18 3:37:02

第一个输出是将本地时间(北京时间)的"2021/7/17 19:37:02"转换为UTC时间的结果,第二个输出是将UTC时间的"2021/7/17 19:37:02"转换为本地时间(北京时间)的结果

而Lua本身没有时区的定义,所以这里采用与UTC时间的时间差来作为时区的表示。比如UTC时区表示就是0(相差0),北京时间的时区表示就是8 * 60 * 60(相差8个小时)

具体示例,请看下面的一段代码

local timeZone1 = 0
local timeZone2 = 8 * 60 * 60
local timeZone3 = 9 * 60 * 60
local dateTime = {year = 2021, month = 7, day = 17, hour = 19, min = 37, sec = 2}

-- 获取本地时区
local function getLocalTimeZone()
        local now = os.time()
        local offset = os.date("*t").isdst and 60 * 60 or 0  -- 通过isdst判断是否是夏令时
        return os.difftime(now + offset, os.time(os.date("!*t", now)))
end

local function convertTime( dateTime, sourceTimeZone, destinationTimeZone )
        local time = os.time(dateTime) + (destinationTimeZone - sourceTimeZone)
        return os.date("*t", time)
end

print(getLocalTimeZone()) -- 输出 28800

local d1 = convertTime(dateTime, timeZone2, timeZone3)
local d2 = convertTime(dateTime, timeZone3, timeZone2)
dump(d1)
--[[
输出
- "<var>" = {
-     "day"   = 17
-     "hour"  = 20
-     "isdst" = false
-     "min"   = 37
-     "month" = 7
-     "sec"   = 2
-     "wday"  = 7
-     "yday"  = 198
-     "year"  = 2021
- }
]]
dump(d2)
--[[
输出
- "<var>" = {
-     "day"   = 17
-     "hour"  = 18
-     "isdst" = false
-     "min"   = 37
-     "month" = 7
-     "sec"   = 2
-     "wday"  = 7
-     "yday"  = 198
-     "year"  = 2021
- }
]]

第一个输出表示的是(在上面的Lua时区定义下的)本地时区,28800(8个小时)

第二个输出是将北京时间的"2021/7/17 19:37:02"转换为东九区时间(比UTC快9个小时,比北京时间快1个小时)的结果,第三个输出是将东九区时间的"2021/7/17 19:37:02"转换为北京时间的结果。代码中的dump是可用于格式化打印Lua表结构的函数,感兴趣的同学可以查看这里

将时间戳转换为时间表示

这种情况在游戏开发中会经常遇到,接收服务端下发的一个时间戳,然后客户端将时间戳转换到用户手机设置的时区下的时间表示

对于同一时刻,无论服务器处于哪里,它下发的时间戳都应该是一致的,但不同时区下的客户端显示又都是不同的

在C#中可以利用下面的函数(完整的类可以查看这里)将时间戳转换为UTC时间。注意是UTC时间,然后再利用上面提到的时区转换,将UTC时间转换为任意时区的时间。

public const int TickToSecond = 10000000;
public static readonly DateTime TIME1970 = new DateTime(1970, 1, 1);
public static DateTime TickToDateTime(long t)
{
    return new DateTime(TIME1970.Ticks + (long)((double)t * TickToSecond), DateTimeKind.Utc);
}

在Lua中可以直接使用os.date函数将时间戳转换为UTC时间表示(format 以 \'!\' 打头)或本地时间表示(format 不以 \'!\' 打头),然后再通过上面提到的Lua时区转换转换到指定时区

如下面的示例代码,是将时间戳转换为本地时间表示

local d = os.date("*t", 1626521822)
dump(d)
--[[
输出
- "<var>" = {
-     "day"   = 17
-     "hour"  = 19
-     "isdst" = false
-     "min"   = 37
-     "month" = 7
-     "sec"   = 2
-     "wday"  = 7
-     "yday"  = 198
-     "year"  = 2021
- }
]]

将时间表示转换为时间戳

将时间表示转换为时间戳在游戏开发中,常见于读取游戏的时间配置。比如为了方便策划或运营配置某个活动的起始时间,可以使用类似"2021-07-17 19:37:02"这样的时间字符串进行配置。开发再通过将其转换为时间戳进行其它操作

在C#中可以使用TryParse函数将一个时间字符串转换为DateTime对象,然后再通过下面的DateTimeToTick函数(完整的类可以查看这里)将其转换为时间戳。注意DateTimeToTick函数要求传入的DateTime对象是UTC时间,而通过TryParse函数得到的DateTime对象是本地时间的,所以还需要通过上面提到的时间转换将其转换为UTC时间才能得到正确的结果

public static long DateTimeToTick(DateTime date)
{
    return (long)((double)(date.Ticks - TIME1970.Ticks) / TickToSecond);
}

string str = "2021/7/17 19:37:02";
DateTime d1;
DateTime.TryParse(str, out d1);
DateTime d2 = TimeZoneInfo.ConvertTime(d1, TimeZoneInfo.Local, TimeZoneInfo.Utc);

long t1 = DateTimeToTick(d1);
long t2 = DateTimeToTick(d2);
Console.WriteLine(t1);  // 输出 1626550622
Console.WriteLine(t2);  // 输出 1626521822

第一个输出由于传入DateTimeToTick函数的DateTime对象是本地时间的,所以得到正确结果是错误的。第二个输出是正确的,打印出了北京时间"2021/7/17 19:37:02"对应的时间戳

对于Lua而言,将时间字符串转换为时间戳需要多个步骤,先通过正则表达式将时间字符串转换为Lua的时间表,然后再通过os.time函数将时间表转换为时间戳

local timeStr = "2021-07-17 19:37:02"
local _, _, year, month, day, hour, min, sec = string.find(timeStr, "(%d+)%-(%d+)%-(%d+)%s*(%d+):(%d+):(%d+)");
local dateTime = {
        year = tonumber(year), month = tonumber(month), day = tonumber(day), 
        hour = tonumber(hour), min = tonumber(min), sec = tonumber(sec)
}
dump(dateTime)
--[[
输出
- "<var>" = {
-     "day"   = 17
-     "hour"  = 19
-     "min"   = 37
-     "month" = 7
-     "sec"   = 2
-     "year"  = 2021
- }
]]

local t = os.time(dateTime)
dump(t)  -- 输出 1626521822

注意,在上面的示例中,默认时间字符串都是本地时间下的字符串,某些情况下为了统一,可能策划或运营会基于某个时区配置时间字符串。比如统一使用UTC时间进行配置,在这种情况下,需要注意先进行对应的时区转换,再转化为时间戳

参考资料