仿写一个 FormatStyle 之 TimeFormatStyle

1,873 阅读7分钟

iOS 15 中 Foundation 引入了一个新的协议:FormatStyle,它定义了一个转换方法用来将给定的数据转换成另外一种表现形式,并提供了一些本地化的支持,而与时间格式化相关的 TimeFormatStyle 在 iOS 16 中才姗姗来迟(注意这里并非日期格式化)。

在此之前我们将一段时间(比如:秒)格式化为字符串时一般的做法是:分别算出给定时间对应的小时,分钟和秒,然后再将这三个数据进行格式化输出。

extension Int {
 
    func timeFormated() -> String {
        let hour = self / 3_600
        let minute = (self - hour * 3_600) / 60
        let second = self % 60
        return String(format: "%02d:%02d:%02d", hour, minute, second)
    }
}

print(1234.timeFormated())        // 00:20:34

但这种方案都的缺点很明显:

  • 无法知晓带格式化的数据为秒还是毫秒,或者其他单位导致数据错误;
  • 无法指定格式化的样式,如:时分秒还是分秒;
  • 数据进位不够灵活,如:1:03:37.568,格式化成分秒形式后,究竟是 63:37 还是 63:38
  • API 可扩展性不够友好,未来添加新的格式化类型可能会导致不必要的重复代码出现,并降低可读性。

FormatStyleTimeFormatStyle 为我们提供了新的思路去解决这些问题,但为了兼容 iOS 15 以下的系统版本,我们可以仿写一个 FormatStyle 用于格式化一个时间段(TimeInterval),将其命名为 FormatStyleBacked,之后我们所有的 FormatStyle 都基于该协议:

/// A type that can convert a given data type into a representation.
public protocol FormatStyleBacked : Decodable, Encodable, Hashable {

    /// The type of data to format.
    associatedtype FormatInput

    /// The type of the formatted data.
    associatedtype FormatOutput

    /// Creates a `FormatOutput` instance from `value`.
    func format(_ value: Self.FormatInput) -> Self.FormatOutput

    /// If the format allows selecting a locale, returns a copy of this format with the new locale set. Default implementation returns an unmodified self.
    func locale(_ locale: Locale) -> Self
}

extension FormatStyleBacked {

    public func locale(_ locale: Locale) -> Self { self }
}

同样地,数据类型 DurationBacked 用来表示不同的时间段类型。

DurationBacked 的种类并不太重要,后文中会对其进行扩展以支持任意时间段间隔,比如几分钟,几个小时或者几天。我们需要的只是绝对的时间时长:秒和纳秒。其中秒用来简化格式化计算的步骤,纳秒用来做对时间做增减计算。

public enum DurationBacked: Equatable, Sendable {

    case seconds(Int)
    case milliseconds(Int)
    case microseconds(Int)
    case nanoseconds(Int)

    public static func == (lhs: DurationBacked, rhs: DurationBacked) -> Bool {
        lhs.nanoseconds == rhs.nanoseconds
    }

    fileprivate var nanoseconds: Int {
        switch self {
        case .nanoseconds(let value):   return value
        case .microseconds(let value):  return value * 1_000
        case .milliseconds(let value):  return value * 1_000_000
        case .seconds(let value):       return value * 1_000_000_000
        }
    }

    fileprivate var seconds: TimeInterval {
        switch self {
        case .nanoseconds(let value):   return Double(value) / 1_000_000_000
        case .microseconds(let value):  return Double(value) / 1_000_000
        case .milliseconds(let value):  return Double(value) / 1_000
        case .seconds(let value):       return Double(value)
        }
    }
}

随后我们开始进入正题,定义 TimeFormatStyle 结构体用于 DurationBacked 数据的转换。

extension DurationBacked {

    public struct TimeFormatStyle: FormatStyleBacked, Sendable {
    
        /// The units to display a Duration with and configurations for the units.
        public struct Pattern : Hashable, Codable, Sendable {

            fileprivate enum Style: Hashable, Codable, Sendable {
                case hourMinute, hourMinuteSecond, minuteSecond

            }

            fileprivate let style: Style
            fileprivate var padHourToLength: Int = 0
            fileprivate var padMinuteToLength: Int = 0
            fileprivate var fractionalSecondsLength: Int = 0
            fileprivate var roundSeconds: FloatingPointRoundingRule = .toNearestOrEven
            fileprivate var roundFractionalSeconds: FloatingPointRoundingRule = .toNearestOrEven

            /// Displays a duration in hours and minutes.
            public static var hourMinute: TimeFormatStyle.Pattern {
                hourMinute(padHourToLength: 1)
            }

            /// Displays a duration in terms of hours and minutes with the specified configurations.
            public static func hourMinute(padHourToLength: Int, roundSeconds: FloatingPointRoundingRule = .toNearestOrEven) -> TimeFormatStyle.Pattern {
                Pattern(style: .hourMinute, padHourToLength: padHourToLength, roundSeconds: roundSeconds)
            }
          
            /// Displays a duration in hours, minutes, and seconds.
            public static var hourMinuteSecond: TimeFormatStyle.Pattern {
                hourMinuteSecond(padHourToLength: 1)
            }

            /// Displays a duration in terms of hours, minutes, and seconds with the specified configurations.
            public static func hourMinuteSecond(padHourToLength: Int, fractionalSecondsLength: Int = 0, roundFractionalSeconds: FloatingPointRoundingRule = .toNearestOrEven) -> TimeFormatStyle.Pattern {
                Pattern(style: .hourMinuteSecond,
                        padHourToLength: padHourToLength,
                        fractionalSecondsLength: fractionalSecondsLength,
                        roundFractionalSeconds: roundFractionalSeconds)
            }

            /// Displays a duration in minutes and seconds. For example, one hour is formatted as "60:00" in en_US locale.
            public static var minuteSecond: TimeFormatStyle.Pattern {
                minuteSecond(padMinuteToLength: 1)
            }

            /// Displays a duration in minutes and seconds with the specified configurations.
            public static func minuteSecond(padMinuteToLength: Int, fractionalSecondsLength: Int = 0, roundFractionalSeconds: FloatingPointRoundingRule = .toNearestOrEven) -> TimeFormatStyle.Pattern {
                Pattern(style: .minuteSecond,
                        padMinuteToLength: padMinuteToLength,
                        fractionalSecondsLength: fractionalSecondsLength,
                        roundFractionalSeconds: roundFractionalSeconds)
            }
        }
        
        /// The locale to use when formatting the duration.
        public var locale: Locale

        /// The pattern to display a Duration with.
        public var pattern: TimeFormatStyle.Pattern

        /// The type of data to format.
        public typealias FormatInput = DurationBacked

        /// The type of the formatted data.
        public typealias FormatOutput = String
        
        /// Creates an instance using the provided pattern and locale.
        public init(pattern: Pattern, locale: Locale = .autoupdatingCurrent) {
            self.pattern = pattern
            self.locale = locale
        }

        /// Creates a locale-aware string representation from a duration value.
        public func format(_ value: DurationBacked) -> String {
            // format code here.
        }
        
        /// Modifies the format style to use the specified locale.
        public func locale(_ locale: Locale) -> TimeFormatStyle {
            TimeFormatStyle(pattern: pattern, locale: locale)
        }
    }
}

为了尽可能的和系统框架保持一致的 API 风格,TimeFormatStylePattern 的设计都借鉴了 Foundation 框架中的 Duration 相关的 API。Pattern 提供了 hourMinute, hourMinuteSecondminuteSecond 3 种格式化方案。每一种都可配置相关的格式化参数,padHourToLengthpadMinuteToLength 用于在小时和分钟前补充占位的字符‘0’, fractionalSecondsLength 指定了秒后面小数点的精度范围,roundSecondsroundFractionalSeconds 则用于决定使用何种规则对分钟和秒执行进位操作。具体可以查阅 Duration​.Time​Format​Style​.Pattern

接下来,只需要实现 format(_:) 方法即可。在格式化之前需要先将 DurationBacked 类型的参数转换成带小数点的秒,然后根据 fractionalSecondsLengthroundFractionalSeconds对转换而来的秒进行预处理操作。

public func format(_ value: DurationBacked) -> String {
    var seconds = value.seconds
    guard seconds < .infinity else {
        return "inf"
    }
    seconds *= pow(10, Double(pattern.fractionalSecondsLength))
    seconds = seconds.rounded(pattern.roundFractionalSeconds)
    seconds /= pow(10, Double(pattern.fractionalSecondsLength))
    
    // format code here.
}

完成数据的预处理后,就可以针对不同的格式化方案执行分别格式化操作了。操作方式和上文中提到的方案一样,分别计算对应的小时,分钟和秒再格式化输出。这里有两点需要注意:

  • 当 pattern 为 hourMinute 时,需要将总秒再次换算成总分钟,并根据 roundSeconds 进行进位操作。
  • 注意小时或者分钟以及秒数字符串的补零规则,详见代码。
public func format(_ value: DurationBacked) -> String {
    var seconds = value.seconds
    guard seconds < .infinity else {
        return "inf"
    }

    seconds *= pow(10, Double(pattern.fractionalSecondsLength))
    seconds = seconds.rounded(pattern.roundFractionalSeconds)
    seconds /= pow(10, Double(pattern.fractionalSecondsLength))

    switch pattern.style {
    case .hourMinute:
        let minutes = (seconds / 60).rounded(pattern.roundSeconds)
        let hour = Int(minutes) / 60
        let minute = minutes - Double(hour * 60)
        return String(format: "%0\(pattern.padHourToLength)d:%02.f", hour, minute)

    case .hourMinuteSecond:
        let hour = Int(seconds / 3_600)
        let minute = Int(seconds - Double(hour * 3_600)) / 60
        let second = seconds - Double(hour * 3_600 + minute * 60)
        let l1 = pattern.padHourToLength
        let l2 = pattern.fractionalSecondsLength
        let format = """
        %0\(l1)d:%02d:%0\(l2 <= 0 ? 2 : l2 + 3).\(l2)f
        """
        return String(format: format, hour, minute, second)

    case .minuteSecond:
        let minute = Int(seconds / 60)
        let second = seconds - Double(minute * 60)
        let l1 = pattern.padMinuteToLength
        let l2 = pattern.fractionalSecondsLength
        let format = """
        %0\(l1)d:%0\(l2 <= 0 ? 2 : l2 + 3).\(l2)f
        """
        return String(format: format, minute, second)
    }
}

至此就可以使用 TimeFormatStyle 来格式化指定的时间段,比如使用时分秒的方式格式化 12345 秒:

let duration = DurationBacked.seconds(3 * 3_600 + 25 * 60 + 45)
let style = DurationBacked.TimeFormatStyle(pattern: .hourMinuteSecond(padHourToLength: 2))
let string = style.format(duration)
print(string) // 03:25:45

你会发现代码还不够简洁,我们可以为 IntDurationBacked 添加扩展,让它更为友好的被我们使用。

extension Int {
    public var nanoseconds: DurationBacked { .nanoseconds(self) }
    public var microseconds: DurationBacked { .microseconds(self) }
    public var milliseconds: DurationBacked { .milliseconds(self)  }
    public var seconds: DurationBacked { .seconds(self) }
    public var minutes: DurationBacked { .seconds(self * 60) }
    public var hours: DurationBacked { .seconds(self * 3_600) }
}

extension FormatStyleBacked where Self == DurationBacked.TimeFormatStyle {

    /// A factory variable to create a time format style to format a duration.
    public static func time(pattern: DurationBacked.TimeFormatStyle.Pattern) -> Self {
        DurationBacked.TimeFormatStyle(pattern: pattern)
    }
}

extension DurationBacked {

    public func formated<S: FormatStyleBacked>(_ format: S) -> S.FormatOutput where Self == S.FormatInput {
        format.format(self)
    }

    /// Formats `self` using the hour-minute-second time pattern
    public func formated() -> String {
        formated(.time(pattern: .hourMinuteSecond))
    }
}

extension DurationBacked : AdditiveArithmetic {

    public static func + (lhs: DurationBacked, rhs: DurationBacked) -> DurationBacked {
        .nanoseconds(lhs.nanoseconds + rhs.nanoseconds)
    }

    public static func - (lhs: DurationBacked, rhs: DurationBacked) -> DurationBacked {
        .nanoseconds(lhs.nanoseconds - rhs.nanoseconds)
    }

    public static var zero: DurationBacked { .nanoseconds(0)  }
}

使用新的 API,上面的例子就可以变得非常简洁且可读性更好:

let duration = 3.hours + 25.minutes + 45.seconds
let string = duration.formated(.time(pattern: .hourMinuteSecond(padHourToLength: 2)))
print(string) // 03:25:45

结语

为了方便演示,同时也鉴于本地化的复杂性,在format(_:) 方法里并没有关于 local 的任何处理,各位感兴趣的读者可以自行实现,如果自定义的 format style 中不需要本地化相关的功能就不用处理 local。

TimeFormatStyle 的扩展也相对比较容易:定义一个新的 Pattern 或者任何其他新的方式。那么你可以为时间格式化添加自定义分隔符的功能吗?