游戏开发中不同时区下的时间问题
在全球化互联⽹时代,许多游戏⼚商都在⼤⼒开拓海外市场,⼤量的游戏也都会选择在海外发⾏。作为游戏开发者的我们也不得不处理⼀个容易被忽略的问题,全球不同时区下的时间问题
⼀些与时区有关的时间概念
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#部分与时区转换相关的类和函数
表⽰时间上的⼀刻,通常以⽇期和当天的时间表⽰
,获取表⽰此实例的⽇期和时间的计时周期数。(1秒=1000毫秒,1毫秒=10000ticks)
,将当前 DateTime 对象的值转换为本地时间。
,将当前 DateTime 对象的值转换为协调世界时 (UTC)。
将⽇期和时间的指定字符串表⽰形式转换为其 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来避免这样的默认设定
,获取表⽰协调世界时 (UTC) 区域的 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"转换为北京时间的结果。代码中的是可⽤于格式化打印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时间进⾏配置,在这种情况下,需要注意先进⾏对应的时区转换,再转化为时间戳
参考资料

版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。