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 を使うのをやめる必要あるかもしれません。

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

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/