Swift.Decodable + Int64 / iOS 10 = 要注意

モバイル基盤グループのヴァンサン(@vincentisambart)です。

Swift 4 で JSON を読み込むための仕組みとして Swift.Decodable が追加されました。

iOS クックパッドアプリでは、 Swift での JSON の読込は以前 Himotoki が使われていましたが、新規コードでは Swift.Decodable が使われています。依存関係を減らすために、 Himotoki を使っているコードが少しずつ Swift.Decodable に移行されています。

ただし、この間、ユーザーの報告で分かったのですが、最近 Himotoki から Swift.Decodable に移行したコード辺りに一部のユーザーにエラーが出ています。 iOS 10 に限りますが。

調査

調べてみた結果、以下のコードでエラーを再現できました。

struct MyDecodable: Decodable {
    var id: Int64
}

let str = "{\"id\":1000000000000000070}"
let data = str.data(using: .utf8)!
do {
    let decodable = try JSONDecoder().decode(MyDecodable.self, from: data)
    print("id: \(decodable.id)")
} catch {
    print("error: \(error)")
}

iOS 10 で実行してみると Parsed JSON number <1000000000000000070> does not fit in Int64. というエラーが出ます。 1000000000000000080 でも起きますが、 1000000000000000071 では起きません。

このエラーって何だろう… Swift がオープンソースなので、コードに grep してみましょう。これっぽい。エラーが発生する条件をもう少し見てみましょう。

guard let number = value as? NSNumber, number !== kCFBooleanTrue, number !== kCFBooleanFalse else {
    throw DecodingError._typeMismatch(at: self.codingPath, expectation: type, reality: value)
}

let int64 = number.int64Value
guard NSNumber(value: int64) == number else {
    throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: self.codingPath, debugDescription: "Parsed JSON number <\(number)> does not fit in \(type)."))
}

numberNSNumber なのに NSNumber(value: number.int64Value) == number を満たさない!?

JSON の解読は実は JSONDecoder が Foundation の JSONSerialization を使っているので、 JSONSerialization を直接使ってみましょう。

let str = "{\"id\":1000000000000000070}"
let data = str.data(using: .utf8)!
let jsonObject = try! JSONSerialization.jsonObject(with: data) as! [NSString: Any]
let number = jsonObject["id"] as! NSNumber
print("number: \(number)")
print("type: \(type(of: number))")
print("comparison: \(NSNumber(value: number.int64Value) == number)")

iOS 10 で実行してみた結果は以下の通りです。

number: 1000000000000000070
type: NSDecimalNumber
comparison: false

iOS 11 では以下のように表示されます。

number: 1000000000000000070
type: __NSCFNumber
comparison: true

結果がかなり違いますね。 iOS 10 でもっと小さい数字を使ってみると、 iOS 11 と同じ結果になります。

number: 10000070
type: __NSCFNumber
comparison: true

__NSCFNumber というクラス名は不思議に見えるかもしれませんが、一番見かける NSNumber のサブクラスです。 type(of: NSNumber(value: 1))__NSCFNumber です。

iOS 11 で JSONSerialization が数字に使っているクラスの条件が変わったようですね。実際 iOS 11 でも、 64-bit に入りきらない大きい数字だと NSDecimalNumber になります。

解決方法

では、原因があの NSDecimalNumber にあるのは分かりましたが、問題はどう解決すればいいのでしょうか。

iOS 10 の JSONSerialization は流石に直せません。

NSDecimalNumber と遊んでみると挙動が分かりにくいところがありますが、上記の大きい数字でも NSDecimalNumber(value: int64) == number が満たされるので、 Swift 本体は条件を以下のにすれば直りそうです。

let int64 = number.int64Value
let recreatedNumber = number is NSDecimalNumber ? NSDecimalNumber(value: int64) : NSNumber(value: int64)
guard recreatedNumber == number else {
    throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: self.codingPath, debugDescription: "Parsed JSON number <\(number)> does not fit in \(type)."))
}

iOS クックパッドアプリはどうしたかと言いますと、Swift.Decodable を使っていて、サーバーからとても大きい ID が来そうな箇所だけを Himotoki に戻すことにしました。 iOS 10 対応をやめたら、再度 Swift.Decodable に戻す予定です。

まとめ

iOS 10 にまだ対応しているアプリは Swift.Decodable を準拠している classstruct 内に Int64 を使っている場合、要注意です。一部のとても大きい数字では読込中にエラーが起きる可能性があります。その場合、すぐできる対応は対象の classstructSwift.Decodable を使うのをやめる必要あるかもしれません。

バグを報告したので、修正が行われたら追記します。