问题
I currently am working with an API that deals with bus predictions. There is an interesting quirk with the JSON that is returned for the predictions of a certain stop. When there are multiple predictions for a stop, the JSON looks something like this:
...
"direction": {
"prediction": [
{
"affectedByLayover": "true",
"block": "241",
"dirTag": "loop",
"epochTime": "1571785998536",
"isDeparture": "false",
"minutes": "20",
"seconds": "1208",
"tripTag": "121",
"vehicle": "1698"
},
{
"affectedByLayover": "true",
"block": "241",
"dirTag": "loop",
"epochTime": "1571787798536",
"isDeparture": "false",
"minutes": "50",
"seconds": "3008",
"tripTag": "122",
"vehicle": "1698"
},
{
"affectedByLayover": "true",
"block": "241",
"dirTag": "loop",
"epochTime": "1571789598536",
"isDeparture": "false",
"minutes": "80",
"seconds": "4808",
"tripTag": "123",
"vehicle": "1698"
}
],
"title": "Loop"
}
...
However, when there is only one prediction for a stop, the JSON looks like this instead:
...
"direction": {
"prediction":
{
"affectedByLayover": "true",
"block": "241",
"dirTag": "loop",
"epochTime": "1571785998536",
"isDeparture": "false",
"minutes": "20",
"seconds": "1208",
"tripTag": "121",
"vehicle": "1698"
}
"title": "Loop"
}
...
Notice that the "prediction" is no longer inside an array -- this is where I believe things are getting complicated when using a Swift Codable type to decode the JSON. My model looks like this for the "direction" and "prediction"
struct BTDirection: Codable {
let title: String!
let stopTitle: String!
let prediction: [BTPrediction]!
}
struct BTPrediction: Codable {
let minutes: String!
let vehicle: String!
}
Basically what is happening is prediction
in BTDirection
is looking for an Array of BTPrediction, however in the 2nd case above, this wouldn't be an Array so the decoding fails. How can I make my models more flexible to accommodate both an array or a single object? Ideally, in the 2nd case prediction
would still be an array of a single BTDirection
. Any help on this would be much appreciated.
回答1:
You can try
struct BTDirection:Codable {
let title,stopTitle: String
let prediction: [BTPrediction]
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
title = try container.decode(String.self, forKey: .title)
stopTitle = try container.decode(String.self, forKey: .stopTitle)
do {
let res = try container.decode([BTPrediction].self, forKey: .prediction)
prediction = res
}
catch {
let res = try container.decode(BTPrediction.self, forKey: .prediction)
prediction = [res]
}
}
}
回答2:
To add on to Sh_Khan's answer, if you have multiple places in your API responses where this sort of thing happens, you can extract this custom decoding and encoding out to a custom wrapper type so you don't have to repeat it everywhere, like:
/// Wrapper type that can be encoded/decoded to/from either
/// an array of `Element`s or a single `Element`.
struct ArrayOrSingleItem<Element> {
private var elements: [Element]
}
extension ArrayOrSingleItem: Decodable where Element: Decodable {
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
do {
// First try decoding the single value as an array of `Element`s.
elements = try container.decode([Element].self)
} catch {
// If decoding as an array of `Element`s didn't work, try decoding
// the single value as a single `Element`, and store it in an array.
elements = try [container.decode(Element.self)]
}
}
}
extension ArrayOrSingleItem: Encodable where Element: Encodable {
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
if elements.count == 1, let element = elements.first {
// If the wrapped array of `Element`s has exactly one `Element`
// in it, encode this as just that one `Element`.
try container.encode(element)
} else {
// Otherwise, encode the wrapped array just as it is - an array
// of `Element`s.
try container.encode(elements)
}
}
}
// This lets you treat an `ArrayOrSingleItem` like a collection of elements.
// If you need the elements as type `Array<Element>`, just instantiate a new
// `Array` from your `ArrayOrSingleItem` like:
// let directions: ArrayOrSingleItem<BTDirection> = ...
// let array: [BTDirection] = Array(directions)
extension ArrayOrSingleItem: MutableCollection {
subscript(position: Int) -> Element {
get { elements[position] }
set { elements[position] = newValue }
}
var startIndex: Int { elements.startIndex }
var endIndex: Int { elements.endIndex }
func index(after i: Int) -> Int {
elements.index(after: i)
}
}
// This lets you instantiate an `ArrayOrSingleItem` from an `Array` literal.
extension ArrayOrSingleItem: ExpressibleByArrayLiteral {
init(arrayLiteral elements: Element...) {
self.elements = elements
}
}
Then you can just declare your prediction
(and any other property that has the potential to be either an array or a single item in your API response) like this:
struct BTDirection: Codable {
let title: String?
let stopTitle: String?
let prediction: ArrayOrSingleItem<BTPrediction>?
}
回答3:
Both @TylerTheCompiler & @Sh_Khan provide very good technical input into solution that provide the mechanics of a solution, but the provided code will hit some implementation issues with the given json data:
- there are errors in the JSON posted that will stop codable working with it - I suspect these are just copy & paste errors, but if not you will have issues moving forwards.
- Because of the initial
direction
key the JSON effectively has 3 (or at least 2.5!) layers of nesting. This will either need to be flattened ininit(from:)
or, as in the below, need a temporary struct for ease of mapping. Flattening in the initialiser would be more elegant, a temporary struct is far quicker :-) - CodingKeys, while obvious, isn't defined in the previous answers, so will cause errors compiling the init(from:)
- there's no
stopTitle
field in the JSON, so that will error on decoding unless it is treated as an optional. Here I've treated it as a concreteString
and handled it in the decoding; you could just make it aString?
and then the decoder would cope with it being absent.
Using "corrected" JSON (added opening braces, missing commas, etc) the following code will import both scenarios. I've not implemented the arrayOrSingleItem
as all credit for that belongs with @TylerTheCompiler, but you could easily drop it in.
struct Direction: Decodable {
let direction: BTDirection
}
struct BTDirection: Decodable {
enum CodingKeys: String, CodingKey {
case title
case stopTitle
case prediction
}
let prediction: [BTPrediction]
let title: String
let stopTitle: String
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
do {
prediction = try container.decode([BTPrediction].self, forKey: .prediction)
} catch {
let singlePrediction = try container.decode(BTPrediction.self, forKey: .prediction)
prediction = [singlePrediction]
}
title = try container.decode(String.self, forKey: .title)
stopTitle = try container.decodeIfPresent(String.self, forKey: .stopTitle) ?? "unnamed stop"
}
}
struct BTPrediction: Decodable {
let minutes: String
let vehicle: String
}
and then to actually decode the JSON decode the top-level Direction type
let data = json.data(using: .utf8)
if let data = data {
do {
let bus = try decoder.decode(Direction.self, from: data)
// extract the BTDirection type from the temporary Direction type
// and do something with the decoded data
}catch {
//handle error
}
}
In case you're not aware, JSON Validator is very useful for validating/correcting json.
来源:https://stackoverflow.com/questions/58513344/how-to-make-swift-codable-types-more-versatile