如何从 NSTimeZone 获取 GNU Lib C TZ 格式输出?

How can I get GNU Lib C TZ format output from NSTimeZone?

我需要将 远程 时钟的时区信息设置为 iOS 设备上的时区信息。

远程时钟仅支持GNU lib C TZ format个: std offset dst [offset],start[/time],end[/time]

例如:EST+5EDT,M3.2.0/2,M11.1.0/2

所以我需要从 Swift 的 NSTimeZone.local 时区生成类似于上面的字符串。似乎无法访问当前时区规则,因为它们会在 IANA TZ database 中生成输出。

如果没有在应用程序中缓存 TZ 数据库的本地副本的可怕想法,可以做到这一点吗?

更新:

即使通过其他编程语言,我也找不到任何有用的东西。我能找到的最好的基本上是解析 linux 中的 tzfile 并制作我自己的包含信息的 NSDictionary。

这是一次有趣的探索,主要是因为将数据调整为正确的格式非常复杂。问题组成:

  • 我们需要适用于给定时区的“当前”TZ 数据库规则。这是一个有点复杂的概念,因为:

    1. Darwin 平台实际上并不直接为大多数应用程序使用 TZ 数据库,而是使用 ICU 的时区数据库,其格式不同且更复杂。即使您以这种格式生成字符串,也不一定描述设备上的实际时间行为

    2. 虽然可以动态读取和解析 iOS 上的 TZ 数据库,但不能保证 TZ 数据库 本身 将信息存储在这里需要的格式。 rfc8536,管理时区信息格式的 RFC 对您想要的格式说明如下:

      The TZ string in a version 3 TZif file MAY use the following extensions to POSIX TZ strings. These extensions are described using the terminology of Section 8.3 of the "Base Definitions" volume of [POSIX].

      Example: <-03>3<-02>,M3.5.0/-2,M10.5.0/-1
      Example: EST5EDT,0/0,J365/25

      在探索 iOS TZ 数据库时,我发现一些数据库条目确实以这种格式在文件末尾提供了规则,但它们似乎是少数。您可以 动态解析这些,但这可能不值得

    因此,我们需要使用 APIs 来生成这种格式的字符串。

  • 为了生成在给定日期至少大致正确的“规则”,您需要了解有关该日期前后 DST 转换的信息。这是一个非常 棘手的话题,因为夏令时规则一直 都在变化,并不总是像您希望的那样有意义。至少:

    • 北半球的许多时区都遵守从 spring 开始并在秋季结束的夏令时
    • 南半球的许多时区实行从秋季开始到 spring
    • 结束的夏令时
    • 有些时区不遵守夏令时(标准时间 year-round)
    • 有些时区不遵守夏令时并且处于夏令时时间year-round

    由于规则非常复杂,此答案的其余部分假设您可以生成表示特定时间日期的“足够好”的答案,并且愿意在某个时间向您的时钟发送更多字符串将来需要更正的时候。例如,为了描述“现在”,我们将假设根据上一个 DST 转换(如果有)和下一个 DST 转换(如果有)生成规则“足够好”,但这 可能不适用于许多时区的所有情况

  • Foundation 在 TimeZone 上以 TimeZone.nextDaylightSavingTimeTransition/TimeZone.nextDaylightSavingTimeTransition(after:) 的形式提供夏令时转换信息。然而,令人沮丧的是,无法获取有关 以前的 DST 转换的信息,因此我们需要纠正这一点:

    • Foundation 的本地化支持(包括日历和时区)直接基于 the ICU library,它在所有 Apple 平台内部发布。 ICU 确实 提供了一种获取有关先前 DST 转换的信息的方法,但 Foundation API 不提供此信息,因此我们需要自己公开它

    • ICU 是 Apple 平台上的一个 semi-private 库。该库保证存在,并且 Xcode 会为您提供 libicucore.tbd 到 link 反对 <Project> > <Target> > Build Phases > Link Binary with Libraries,但实际的 header 和符号并不直接暴露于应用程序。您 可以 成功地 link 对抗 libicucore,但是您需要 forward-declare 我们在 Obj-C [=152] 中需要的功能=] 导入到 Swift

    • 在 Swift 项目的某处,我们需要公开以下 ICU 功能:

      #include <stdint.h>
      
      typedef void * _Nonnull UCalendar;
      typedef double UDate;
      typedef int8_t UBool;
      typedef uint16_t UChar;
      
      typedef enum UTimeZoneTransitionType {
          UCAL_TZ_TRANSITION_NEXT,
          UCAL_TZ_TRANSITION_NEXT_INCLUSIVE,
          UCAL_TZ_TRANSITION_PREVIOUS,
          UCAL_TZ_TRANSITION_PREVIOUS_INCLUSIVE,
      } UTimeZoneTransitionType;
      
      typedef enum UCalendarType {
          UCAL_TRADITIONAL,
          UCAL_DEFAULT,
          UCAL_GREGORIAN,
      } UCalendarType;
      
      typedef enum UErrorCode {
          U_ZERO_ERROR = 0,
      } UErrorCode;
      
      UCalendar * _Nullable ucal_open(const UChar *zoneID, int32_t len, const char *locale, UCalendarType type, UErrorCode *status);
      void ucal_setMillis(const UCalendar * _Nonnull cal, UDate date, UErrorCode * _Nonnull status);
      UBool ucal_getTimeZoneTransitionDate(const UCalendar * _Nonnull cal, UTimeZoneTransitionType type, UDate * _Nonnull transition, UErrorCode * _Nonnull status);
      

      这些都是前向声明/常量,因此无需担心实现(因为我们通过 linking 反对 libicucore 获得)。

    • 您可以看到 UTimeZoneTransitionType 中的值 — TimeZone.nextDaylightSavingTimeTransition 只是用 UCAL_TZ_TRANSITION_NEXT 的值调用 ucal_getTimeZoneTransitionDate,因此我们可以大致提供通过使用 UCAL_TZ_TRANSITION_PREVIOUS:

      调用方法具有相同的功能
      extension TimeZone {
          func previousDaylightSavingTimeTransition(before: Date) -> Date? {
              // We _must_ pass a status variable for `ucal_open` to write into, but the actual initial
              // value doesn't matter.
              var status = U_ZERO_ERROR
      
              // `ucal_open` requires the time zone identifier be passed in as UTF-16 code points.
              // `String.utf16` doesn't offer a contiguous buffer for us to pass directly into `ucal_open`
              // so we have to create our own by copying the values into an `Array`, then
              let timeZoneIdentifier = Array(identifier.utf16)
              guard let calendar = Locale.current.identifier.withCString({ localeIdentifier in
                  ucal_open(timeZoneIdentifier, // implicit conversion of Array to a pointer, but convenient!
                            Int32(timeZoneIdentifier.count),
                            localeIdentifier,
                            UCAL_GREGORIAN,
                            &status)
              }) else {
                  // Figure out some error handling here -- we failed to find a "calendar" for this time
                  // zone; i.e., there's no time zone date for this time zone.
                  //
                  // With more enum cases copied from `UErrorCode` you may find a good way to report an
                  // error here if needed. `u_errorName` turns a `UErrorCode` into a string.
                  return nil
              }
      
              // `UCalendar` functions operate on the calendar's current timestamp, so we have to apply
              // `date` to it. `UDate`s are the number of milliseconds which have passed since January 1,
              // 1970, while `Date` offers its time interval in seconds.
              ucal_setMillis(calendar, before.timeIntervalSince1970 * 1000.0, &status)
      
              var result: UDate = 0
              guard ucal_getTimeZoneTransitionDate(calendar, UCAL_TZ_TRANSITION_PREVIOUS, &result, &status) != 0 else {
                  // Figure out some error handling here -- same as above (check status).
                  return nil
              }
      
              // Same transition but in reverse.
              return Date(timeIntervalSince1970: result / 1000.0)
          }
      }
      

所以,所有这些都准备就绪后,我们可以填写一个粗略的方法来生成您需要的格式的字符串:

extension TimeZone {
    struct Transition {
        let abbreviation: String
        let offsetFromGMT: Int
        let date: Date
        let components: DateComponents

        init(for timeZone: TimeZone, on date: Date, using referenceCalendar: Calendar) {
            abbreviation = timeZone.abbreviation(for: date) ?? ""
            offsetFromGMT = timeZone.secondsFromGMT(for: date)
            self.date = date
            components = referenceCalendar.dateComponents([.month, .weekOfMonth, .weekdayOrdinal, .hour, .minute, .second], from: date)
        }
    }

    func approximateTZEntryRule(on date: Date = Date(), using calendar: Calendar? = nil) -> String? {
        var referenceCalendar = calendar ?? Calendar(identifier: .gregorian)
        referenceCalendar.timeZone = self

        guard let year = referenceCalendar.dateInterval(of: .year, for: date) else {
            return nil
        }

        // If no prior DST transition has ever occurred, we're likely in a time zone which is either
        // standard or daylight year-round. We'll cap the definition here to the very start of the
        // year.
        let previousDSTTransition = Transition(for: self, on: previousDaylightSavingTimeTransition(before: date) ?? year.start, using: referenceCalendar)

        // Same with the following DST transition -- if no following DST transition will ever come,
        // we'll cap it to the end of the year.
        let nextDSTTransition = Transition(for: self, on: nextDaylightSavingTimeTransition(after: date) ?? year.end, using: referenceCalendar)

        let standardToDaylightTransition: Transition
        let daylightToStandardTransition: Transition
        if isDaylightSavingTime(for: date) {
            standardToDaylightTransition = previousDSTTransition
            daylightToStandardTransition = nextDSTTransition
        } else {
            standardToDaylightTransition = nextDSTTransition
            daylightToStandardTransition = previousDSTTransition
        }

        let standardAbbreviation = daylightToStandardTransition.abbreviation
        let standardOffset = formatOffset(daylightToStandardTransition.offsetFromGMT)
        let daylightAbbreviation = standardToDaylightTransition.abbreviation
        let startDate = formatDate(components: standardToDaylightTransition.components)
        let endDate = formatDate(components: daylightToStandardTransition.components)
        return "\(standardAbbreviation)\(standardOffset)\(daylightAbbreviation),\(startDate),\(endDate)"
    }

    /* These formatting functions can be way better. You'll also want to actually cache the
       DateComponentsFormatter somewhere.
     */

    func formatOffset(_ dateComponents: DateComponents) -> String {
        let formatter = DateComponentsFormatter()
        formatter.allowedUnits = [.hour, .minute, .second]
        formatter.zeroFormattingBehavior = .dropTrailing
        return formatter.string(from: dateComponents) ?? ""
    }

    func formatOffset(_ seconds: Int) -> String {
        return formatOffset(DateComponents(second: seconds))
    }

    func formatDate(components: DateComponents) -> String {
        let month = components.month ?? 0
        let week = components.weekOfMonth ?? 0
        let day = components.weekdayOrdinal ?? 0
        let offset = formatOffset(DateComponents(hour: components.hour, minute: components.minute, second: components.second))
        return "M\(month).\(week).\(day)/\(offset)"
    }
}

请注意,这里有很多地方需要改进,尤其是在清晰度和性能方面。 (众所周知,格式化程序非常昂贵,因此您一定要缓存它们。)这目前也只生成扩展形式 "Mm.w.d" 的日期,而不是 Julian days,但可以附加。该代码还假定将无限规则限制为当前日历年“足够好”,因为这就是 GNU C 库文档似乎暗示的内容,例如始终在 standard/daylight 时间的时区。 (这也无法识别 well-known 时区,例如 GMT/UTC,这可能足以写成“GMT”。)

我没有针对各个时区广泛测试这段代码,上面的代码应该算是添加的基础l 迭代。对于我的 America/New_York 时区,这会产生 "EST-5EDT,M3.3.2/3,M11.2.1/1",乍一看对我来说似乎是正确的,但许多其他边缘情况可能值得探索:

  • start/end年左右的边界条件
  • 提供一个与 完全匹配 夏令时转换的日期(考虑 TRANSITION_PREVIOUSTRANSITION_PREVIOUS_INCLUSIVE
  • 时区总是standard/daylight
  • Non-standard daylight/timezone 偏移量

还有很多其他内容,总的来说,我建议您尝试找到一种在此设备上设置时间的替代方法(最好使用 named 时区) ,但这至少可以帮助您入门。