如何在 Swift 中使用 Codable 转换带有可选小数秒的日期字符串?

Posted

技术标签:

【中文标题】如何在 Swift 中使用 Codable 转换带有可选小数秒的日期字符串?【英文标题】:How to convert a date string with optional fractional seconds using Codable in Swift? 【发布时间】:2018-03-09 13:38:12 【问题描述】:

我正在用 Swift 的 Codable 替换我的旧 JSON 解析代码,并且遇到了一些障碍。我想这不是一个可编码的问题,而是一个 DateFormatter 问题。

从结构开始

 struct JustADate: Codable 
    var date: Date
 

和一个 json 字符串

let json = """
   "date": "2017-06-19T18:43:19Z" 
"""

现在让我们解码

let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601

let data = json.data(using: .utf8)!
let justADate = try! decoder.decode(JustADate.self, from: data) //all good

但如果我们更改日期,使其具有小数秒,例如:

let json = """
   "date": "2017-06-19T18:43:19.532Z" 
"""

现在它坏了。日期有时会以小数秒返回,有时则不会。我用来解决它的方法是在我的映射代码中,我有一个转换函数,它尝试使用和不使用小数秒的 dateFormats。但是,我不太确定如何使用 Codable 来处理它。有什么建议吗?

【问题讨论】:

正如我在上面的帖子中所分享的,我没有格式本身的问题,而是 API 返回两种不同的格式。有时有小数秒,有时没有。我一直无法找到处理这两种可能性的方法。 【参考方案1】:

您可以使用两种不同的日期格式化程序(带和不带小数秒)并创建自定义 DateDecodingStrategy。如果解析 API 返回的日期失败,您可以按照 @PauloMattos 在 cmets 中的建议抛出 DecodingError:

iOS 9、macOS 10.9、tvOS 9、watchOS 2、Xcode 9 或更高版本

自定义ISO8601DateFormatter:

extension Formatter 
    static let iso8601withFractionalSeconds: DateFormatter = 
        let formatter = DateFormatter()
        formatter.calendar = Calendar(identifier: .iso8601)
        formatter.locale = Locale(identifier: "en_US_POSIX")
        formatter.timeZone = TimeZone(secondsFromGMT: 0)
        formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX"
        return formatter
    ()
    static let iso8601: DateFormatter = 
        let formatter = DateFormatter()
        formatter.calendar = Calendar(identifier: .iso8601)
        formatter.locale = Locale(identifier: "en_US_POSIX")
        formatter.timeZone = TimeZone(secondsFromGMT: 0)
        formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssXXXXX"
        return formatter
    ()


自定义DateDecodingStrategy

extension JSONDecoder.DateDecodingStrategy 
    static let customISO8601 = custom 
        let container = try $0.singleValueContainer()
        let string = try container.decode(String.self)
        if let date = Formatter.iso8601withFractionalSeconds.date(from: string) ?? Formatter.iso8601.date(from: string) 
            return date
        
        throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid date: \(string)")
    


自定义DateEncodingStrategy

extension JSONEncoder.DateEncodingStrategy 
    static let customISO8601 = custom 
        var container = $1.singleValueContainer()
        try container.encode(Formatter.iso8601withFractionalSeconds.string(from: $0))
    


编辑/更新

Xcode 10 • Swift 4.2 或更高版本 • iOS 11.2.1 或更高版本

ISO8601DateFormatter 现在支持formatOptions .withFractionalSeconds

extension Formatter 
    static let iso8601withFractionalSeconds: ISO8601DateFormatter = 
        let formatter = ISO8601DateFormatter()
        formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
        return formatter
    ()
    static let iso8601: ISO8601DateFormatter = 
        let formatter = ISO8601DateFormatter()
        formatter.formatOptions = [.withInternetDateTime]
        return formatter
    ()


海关DateDecodingStrategyDateEncodingStrategy 与上图相同。


// Playground testing
struct ISODates: Codable 
    let dateWith9FS: Date
    let dateWith3FS: Date
    let dateWith2FS: Date
    let dateWithoutFS: Date


let isoDatesJSON = """

"dateWith9FS": "2017-06-19T18:43:19.532123456Z",
"dateWith3FS": "2017-06-19T18:43:19.532Z",
"dateWith2FS": "2017-06-19T18:43:19.53Z",
"dateWithoutFS": "2017-06-19T18:43:19Z",

"""

let isoDatesData = Data(isoDatesJSON.utf8)

let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .customISO8601

do 
    let isoDates = try decoder.decode(ISODates.self, from: isoDatesData)
    print(Formatter.iso8601withFractionalSeconds.string(from: isoDates.dateWith9FS))   // 2017-06-19T18:43:19.532Z
    print(Formatter.iso8601withFractionalSeconds.string(from: isoDates.dateWith3FS))   // 2017-06-19T18:43:19.532Z
    print(Formatter.iso8601withFractionalSeconds.string(from: isoDates.dateWith2FS))   // 2017-06-19T18:43:19.530Z
    print(Formatter.iso8601withFractionalSeconds.string(from: isoDates.dateWithoutFS)) // 2017-06-19T18:43:19.000Z
 catch 
    print(error)

【讨论】:

好主意。不知道为什么我没想到那个!!!有时只需要多一双眼睛就能看到明显的...... 是的,我的计划是在自定义解码器中使用 nil 合并运算符方法。应该很好用。再次感谢。 创建一个新的错误类型最初可能看起来是个好主意,但我想说你最好还是扔掉标准的DecodingError——case dataCorrupted 可能正是你要找的东西;) 不错的扩展~~ @leodabus 仅供参考:Swift4/ios 11 解码不适用于dateWithoutFS。另一方面,原始扩展运行良好。【参考方案2】:

作为@Leo 的答案的替代方案,如果您需要为旧操作系统提供支持(ISO8601DateFormatter 仅从 iOS 10、mac OS 10.12 开始可用),您可以编写一个自定义格式化程序,在解析时使用这两种格式字符串:

class MyISO8601Formatter: DateFormatter 

    static let formatters: [DateFormatter] = [
        iso8601Formatter(withFractional: true),
        iso8601Formatter(withFractional: false)
        ]

    static func iso8601Formatter(withFractional fractional: Bool) -> DateFormatter 
        let formatter = DateFormatter()
        formatter.calendar = Calendar(identifier: .iso8601)
        formatter.locale = Locale(identifier: "en_US_POSIX")
        formatter.timeZone = TimeZone(secondsFromGMT: 0)
        formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss\(fractional ? ".SSS" : "")XXXXX"
        return formatter
    

    override public func getObjectValue(_ obj: AutoreleasingUnsafeMutablePointer<AnyObject?>?,
                                 for string: String,
                                 errorDescription error: AutoreleasingUnsafeMutablePointer<NSString?>?) -> Bool 
        guard let date = (type(of: self).formatters.flatMap  $0.date(from: string) ).first else 
            error?.pointee = "Invalid ISO8601 date: \(string)" as NSString
            return false
        
        obj?.pointee = date as NSDate
        return true
    

    override public func string(for obj: Any?) -> String? 
        guard let date = obj as? Date else  return nil 
        return type(of: self).formatters.flatMap  $0.string(from: date) .first
    

,您可以将其用作日期解码策略:

let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .formatted(MyISO8601Formatter())

虽然在实现上有点丑陋,但这样做的好处是与 Swift 在出现格式错误的数据时抛出的解码错误一致,因为我们不会更改错误报告机制。

例如:

struct TestDate: Codable 
    let date: Date


// I don't advocate the forced unwrap, this is for demo purposes only
let jsonString = "\"date\":\"2017-06-19T18:43:19Z\""
let jsonData = jsonString.data(using: .utf8)!
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .formatted(MyISO8601Formatter())
do 
    print(try decoder.decode(TestDate.self, from: jsonData))
 catch 
    print("Encountered error while decoding: \(error)")

将打印TestDate(date: 2017-06-19 18:43:19 +0000)

添加小数部分

let jsonString = "\"date\":\"2017-06-19T18:43:19.123Z\""

将产生相同的输出:TestDate(date: 2017-06-19 18:43:19 +0000)

但是使用了不正确的字符串:

let jsonString = "\"date\":\"2017-06-19T18:43:19.123AAA\""

如果数据不正确,将打印默认的 Swift 错误:

Encountered error while decoding: dataCorrupted(Swift.DecodingError.Context(codingPath: [__lldb_expr_84.TestDate.(CodingKeys in _B178608BE4B4E04ECDB8BE2F689B7F4C).date], debugDescription: "Date string does not match format expected by formatter.", underlyingError: nil))

【讨论】:

这对两种情况都不起作用(有或没有小数秒) 唯一的选择是对于 iOS 11 或更高版本,您可以使用 ISO8601DateFormatter formatOptions .withFractionalSeconds 而不是使用 DateFormatter 但自定义 dateDecodingStrategy 方法保持不变,因此使用它没有优势考虑到 iOS 11 或更高版本限制的缺点。 你为什么要浪费你的时间呢? kkkk 自定义实现更简洁,它会抛出 DecodingError。顺便说一句,我的原始答案确实适用于 iOS10,并且不需要 ISO8601DateFormatter 编码时我也可以选择任何dateEncodingStrategy 别担心,我只是想向您展示如何以两种方式实现它。最好使用适当的方法而不是绕过它【参考方案3】:

一个新选项(从 Swift 5.1 开始)是一个属性包装器。 CodableWrappers 库有一个简单的方法来处理这个问题。

对于默认 ISO8601

@ISO8601DateCoding 
struct JustADate: Codable 
    var date: Date
 

如果您想要自定义版本:

// Custom coder
@available(macOS 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *)
public struct FractionalSecondsISO8601DateStaticCoder: StaticCoder 

    private static let iso8601Formatter: ISO8601DateFormatter = 
        let formatter = ISO8601DateFormatter()
        formatter.formatOptions = .withFractionalSeconds
        return formatter
    ()

    public static func decode(from decoder: Decoder) throws -> Date 
        let stringValue = try String(from: decoder)
        guard let date = iso8601Formatter.date(from: stringValue) else 
            throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Expected date string to be ISO8601-formatted."))
        
        return date
    

    public static func encode(value: Date, to encoder: Encoder) throws 
        try iso8601Formatter.string(from: value).encode(to: encoder)
    

// Property Wrapper alias
public typealias ISO8601FractionalDateCoding = CodingUses<FractionalSecondsISO8601DateStaticCoder>

// Usage
@ISO8601FractionalDateCoding
struct JustADate: Codable 
    var date: Date
 

【讨论】:

这是错误的。您只指定.fractionalSeconds。应该是[.withInternetDateTime, .withFractionalSeconds]。另一种选择是简单地将.withFractionalSeconds 插入ISO8601DateFormatter 默认选项。【参考方案4】:

斯威夫特 5

要将 ISO8601 字符串解析为日期,您必须使用 DateFormatter。在较新的系统(例如 iOS11+)中,您可以使用 ISO8601DateFormatter。

只要您不知道日期是否包含毫秒,您就应该为每种情况创建 2 个格式化程序。然后,在将 String 解析为 Date 的过程中,同时使用两者。

旧系统的 DateFormatter

/// Formatter for ISO8601 with milliseconds
lazy var iso8601FormatterWithMilliseconds: DateFormatter = 
    let dateFormatter = DateFormatter()
    dateFormatter.locale = Locale(identifier: "en_US_POSIX")
    dateFormatter.timeZone = TimeZone(abbreviation: "GMT")
    dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"

    return dateFormatter
()

/// Formatter for ISO8601 without milliseconds
lazy var iso8601Formatter: DateFormatter = 
    let dateFormatter = DateFormatter()
    dateFormatter.locale = Locale(identifier: "en_US_POSIX")
    dateFormatter.timeZone = TimeZone(abbreviation: "GMT")
    dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ"

    return dateFormatter
()

适用于较新系统的 ISO8601DateFormatter(例如 iOS 11+)

lazy var iso8601FormatterWithMilliseconds: ISO8601DateFormatter = 
    let formatter = ISO8601DateFormatter()

    // GMT or UTC -> UTC is standard, GMT is TimeZone
    formatter.timeZone = TimeZone(abbreviation: "GMT")
    formatter.formatOptions = [.withInternetDateTime,
                               .withDashSeparatorInDate,
                               .withColonSeparatorInTime,
                               .withTimeZone,
                               .withFractionalSeconds]

    return formatter
()

/// Formatter for ISO8601 without milliseconds
lazy var iso8601Formatter: ISO8601DateFormatter = 
    let formatter = ISO8601DateFormatter()

    // GMT or UTC -> UTC is standard, GMT is TimeZone
    formatter.timeZone = TimeZone(abbreviation: "GMT")
    formatter.formatOptions = [.withInternetDateTime,
                               .withDashSeparatorInDate,
                               .withColonSeparatorInTime,
                               .withTimeZone]

    return formatter
()

总结

如您所见,需要创建 2 个格式化程序。如果你想支持旧系统,它有 4 个格式化程序。为了使其更简单,请查看Tomorrow on GitHub,您可以在其中查看此问题的完整解决方案。

要将字符串转换为您使用的日期:

let date = Date.fromISO("2020-11-01T21:10:56.22+02:00")

【讨论】:

"如果你想支持旧系统,它有 4 个格式化程序。"为什么 ?旧方法适用于所有人 顺便说一句,为什么您在一种格式中使用单个 Z 而在另一种格式中使用 ZZZZZ?请注意,Z 不使用Z 作为UTC 时区表示,它使用+0000ZZZZZXXXXX 使用Z 相同。换句话说,您应该对它们都使用XXXXXZZZZZ @LeoDabus 有毫秒和没有日期的解析器。并且因为有新旧方式来做这件事,它产生了 4 个格式化程序。如果您问为什么要使用新的格式化程序而不是旧的格式化程序,您可以在 Apple 文档中找到声明 When working with date representations in ISO 8601 format, use ISO8601DateFormatter instead.。由于 ISO8601DateFormatter 仅适用于 iOS10 及更新版本,因此该解决方案同时支持新旧平台。 No ISO8601DateFormatter withFractionalSeconds 不适用于 iOS 10 和/或 iOS11。它仅适用于 iOS 11.2.1 或更高版本,只有没有小数秒。 @LeoDabus 你是对的。 Apple 文档中可能存在错误。我检查了 11.0.1,确实出现了错误。

以上是关于如何在 Swift 中使用 Codable 转换带有可选小数秒的日期字符串?的主要内容,如果未能解决你的问题,请参考以下文章

使用 Swift 4 的 Codable 进行解码时,是啥阻止了我从 String 到 Int 的转换?

swift 中使用Codable进行数据解析

使用 swift Codable 以值作为键来解码 JSON

Swift 4 Codable:将 JSON 返回字符串转换为 Int/Date/Float

如何在 Core Data 中使用 swift 4 Codable?

如何在 Core Data 中使用 swift 4 Codable?