ZhgChg.Li

Codable デコード問題の完全ガイド|基礎から応用まで全シーン対応

iOS開発者向けにCodableのデコード問題を網羅的に解説。デコードエラーの原因特定から解決策まで具体例で示し、効率的なデータ処理を実現します。

Codable デコード問題の完全ガイド|基礎から応用まで全シーン対応
本記事は AI による翻訳です。お気づきの点があればお知らせください。

実際に Codable を使う際に遭遇する Decode の問題シーンまとめ(上)

基礎から応用まで、Decodable を使ってあらゆる問題シーンに対応する方法を徹底解説

Photo by Gustas Brazaitis

Photo by Gustas Brazaitis

はじめに

後端のAPIアップグレードに伴い、API処理のアーキテクチャを調整する必要がありました。この機会に、元々Objective-Cで書かれていたネットワーク処理のアーキテクチャをSwiftに更新しました。言語が異なるため、従来のRestkitを使ってネットワーク層を処理するのは適切ではなくなりました。しかし、Restkitは機能が非常に豊富で、プロジェクト内でうまく活用できており、大きな問題はほとんどありませんでした。一方で、非常に重く、ほとんどメンテナンスされておらず、純粋なObjective-Cで書かれているため、将来的には必ず置き換える必要があります。

Restkit は、基本的なネットワーク処理、API 呼び出し、ネットワーク処理からレスポンスの JSON 文字列をオブジェクトに変換、さらにはオブジェクトを Core Data に保存するまで、ネットワーク関連のほぼすべての機能をほぼ完璧に処理してくれる、実質的に一つのフレームワークで十人分の働きをする存在です。

時代の進化に伴い、現在のフレームワークはすべてを一つにまとめるのではなく、より柔軟で軽量、そして組み合わせ可能なものが主流となっています。これにより、より多くの柔軟性と多様な変化を生み出しています。そのため、Swift言語に移行する際には、ネットワーク処理部分にMoyaを採用し、その他必要な機能は別の方法で組み合わせて選択しています。

本題

JSON文字列からオブジェクトへのマッピングについては、Swift標準の Codable(主に Decodable)プロトコルと JSONDecoder を使用して処理しています。また、Entity と Model を分離して責任範囲を明確にし、操作性と可読性を向上させています。さらに、コードベースが Objective-C と Swift の混在である点も考慮しています。

* Encodable の部分は省略し、例はすべて Decodable の実装のみを示しています。ほぼ同様で、Decode ができれば基本的に Encode も可能です。

開始

初期の API レスポンス JSON 文字列は以下の通りとします:

{
  "id": 123456,
  "comment": "是告五人,不是五告人!",
  "target_object": {
    "type": "song",
    "id": 99,
    "name": "披星戴月的想你"
  },
  "commenter": {
    "type": "user",
    "id": 1,
    "name": "zhgchgli",
    "email": "[email protected]"
  }
}

上記の例から、User/Song/Comment の3つの Entity と Model に分割できます。これにより再利用が可能になります。便宜上、表示のために Entity と Model を同じファイルに記述します。

User:

// エンティティ:
struct UserEntity: Decodable {
    var id: Int
    var name: String
    var email: String
}

// モデル:
class UserModel: NSObject {
    init(_ entity: UserEntity) {
      self.id = entity.id
      self.name = entity.name
      self.email = entity.email
    }
    var id: Int
    var name: String
    var email: String
}

曲:

// エンティティ:
struct SongEntity: Decodable {
    var id: Int
    var name: String
}

// モデル:
class SongModel: NSObject {
    init(_ entity: SongEntity) {
      self.id = entity.id
      self.name = entity.name
    }
    var id: Int
    var name: String
}

コメント:

// エンティティ:
struct CommentEntity: Decodable {
    enum CodingKeys: String, CodingKey {
      case id
      case comment
      case targetObject = "target_object"
      case commenter
    }
    
    var id: Int
    var comment: String
    var targetObject: SongEntity
    var commenter: UserEntity
}

// モデル:
class CommentModel: NSObject {
    init(_ entity: CommentEntity) {
      self.id = entity.id
      self.comment = entity.comment
      self.targetObject = SongModel(entity.targetObject)
      self.commenter = UserModel(entity.commenter)
    }
    var id: Int
    var comment: String
    var targetObject: SongModel
    var commenter: UserModel
}

JSONDecoder:

let jsonString = "{ \"id\": 123456, \"comment\": \"是告五人,不是五告人!\", \"target_object\": { \"type\": \"song\", \"id\": 99, \"name\": \"披星戴月的想你\" }, \"commenter\": { \"type\": \"user\", \"id\": 1, \"name\": \"zhgchgli\", \"email\": \"[email protected]\" } }"
let jsonDecoder = JSONDecoder()
do {
    let result = try jsonDecoder.decode(CommentEntity.self, from: jsonString.data(using: .utf8)!)
} catch {
    print(error) // エラーを出力する
}

CodingKeys Enumとは?

JSONのキー名とEntityオブジェクトのプロパティ名が一致しない場合は、内部にCodingKeysという列挙型を追加して対応できます。バックエンドのデータソースの命名規則は私たちがコントロールできないためです。

case PropertyKeyName = "バックエンドのフィールド名"
case PropertyKeyName // 指定しない場合はデフォルトで PropertyKeyName をバックエンドのフィールド名として使用

CodingKeys 列挙型を追加すると、Optional でないすべてのフィールドを列挙する必要があり、カスタマイズしたいキーだけを列挙することはできません。

もう一つの方法は JSONDecoder の keyDecodingStrategy を設定することです。レスポンスのデータフィールドとプロパティ名が snake_casecamelCase の違いだけであれば、.keyDecodingStrategy.convertFromSnakeCase に設定することで自動的にマッピングが可能です。

let jsonDecoder = JSONDecoder()
jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase
try jsonDecoder.decode(CommentEntity.self, from: jsonString.data(using: .utf8)!)

返却データが配列の場合:

struct SongListEntity: Decodable {
    var songs:[SongEntity]
}

String に制約を付ける:

struct SongEntity: Decodable {
  var id: Int
  var name: String
  var type: SongType
  
  enum SongType {
    case rock
    case pop
    case country
  }
}

限定された範囲の文字列型に適用され、Enumとして書くことで渡したり使用したりしやすくなります;列挙にない値が出現するとDecodeに失敗します!

ジェネリクスを活用して固定構造をラップする:

複数件返却される JSON 文字列の固定フォーマットは以下の通りです:

{
  "count": 10,
  "offset": 0,
  "limit": 0,
  "results": [
    {
      "type": "song",
      "id": 1,
      "name": "1"
    }
  ]
}

ジェネリック型でラップすることができます:

struct PageEntity<E: Decodable>: Decodable {
    var count: Int
    var offset: Int
    var limit: Int
    var results: [E]
}

使用: PageEntity<Song>.self

Date/Timestamp 自動デコード:

JSONDecoderdateDecodingStrategy の設定方法

  • .secondsSince1970/.millisecondsSince1970 : Unixタイムスタンプ

  • .deferredToDate : Appleのタイムスタンプで、あまり使われません。Unixタイムスタンプとは異なり、2001年1月1日からの経過時間を表します。

  • .iso8601 : ISO 8601 日付形式

  • .formatted(DateFormatter) : 渡された DateFormatter に従って日付をデコードする

  • .custom : カスタムの Date デコードロジック

.custom 例:APIが YYYY/MM/DD と ISO 8601 の2種類の形式を返す場合、両方をデコードできるようにする:

var dateFormatter = DateFormatter()
var iso8601DateFormatter = ISO8601DateFormatter()

let decoder: JSONDecoder = JSONDecoder()
decoder.dateDecodingStrategy = .custom({ (decoder) -> Date in
    let container = try decoder.singleValueContainer()
    let dateString = try container.decode(String.self)
    
    // ISO8601形式:
    if let date = iso8601DateFormatter.date(from: dateString) {
        return date
    }
    
    // YYYY-MM-DD形式:
    dateFormatter.dateFormat = "yyyy-MM-dd"
    if let date = dateFormatter.date(from: dateString) {
        return date
    }
    
    throw DecodingError.dataCorruptedError(in: container, debugDescription: "Cannot decode date string \(dateString)")
})

let result = try jsonDecoder.decode(CommentEntity.self, from: jsonString.data(using: .utf8)!)

*DateFormatter は初期化時に非常にパフォーマンスを消費するため、できるだけ再利用してください。

基本的なDecodeの知識:

  1. Decodable プロトコル内のフィールドの型(struct/class/enum)はすべて Decodable プロトコルを実装する必要があります。または、init(decoder) 内で値を割り当てる必要があります。

  2. フィールドの型が一致しない場合、Decodeに失敗します。

  3. Decodable オブジェクトのフィールドを Optional にすると、存在すればデコードし、なくても問題ありません。

  4. Optional なフィールドが許容される場合:JSON の文字列にフィールドがない、またはフィールドが存在しても nil が与えられている場合。

  5. 空白や0はnilではありません。nilはnilです。型の緩いバックエンドAPIには注意が必要です!

  6. デフォルトの Decodable オブジェクトで、列挙型かつ Optional でないフィールドがある場合、JSON 文字列に該当フィールドがないとデコードに失敗します(後述の対処法があります)。

  7. デフォルトでは、Decode に失敗するとその場で中断してしまい、エラーのあるデータを単純にスキップすることはできません(後ほど対処方法を説明します)。

左:「」 / 右:nil

左:「」 / 右:nil

高度な使用法

ここまでで基本的な使い方は完了しましたが、現実の世界はそんなに簡単ではありません。以下にいくつかの応用的なシナリオを挙げ、Codable を使った解決策を提案します。ここからは元の Decode によるマッピング補完はできないため、自分で init(from decoder: Decoder) を実装してカスタムデコード操作を行う必要があります。

*ここではひとまず Entity の部分のみを表示し、Model はまだ使用しません。

init(from decoder: Decoder)

init decoderでは、すべてのOptionalでないフィールドに初期値を与える必要があります(つまりinitです!)。

自分で Decode 操作を行う際、decoder から container を取得して値を取り出す必要があります。container には3種類の内容取得タイプがあります。

第一種 container(keyedBy: CodingKeys.self) CodingKeys に従って操作:

struct SongEntity: Decodable {
    var id: Int
    var name: String
    
    enum CodingKeys: String, CodingKey {
      case id
      case name
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.id = try container.decode(Int.self, forKey: .id)
        // 引数1は対応可能:Decodableを実装したクラス
        // 引数2はCodingKeys
        
        self.name = try container.decode(String.self, forKey: .name)
    }
}

第二の singleValueContainer で全体を一括取得(単一値):

enum HandsomeLevel: Decodable {
    case handsome(String)
    case normal(String)
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        let name = try container.decode(String.self)
        if name == "zhgchgli" {
            self = .handsome(name)
        } else {
            self = .normal(name)
        }
    }
}

struct UserEntity: Decodable {
    var id: Int
    var name: HandsomeLevel
    var email: String
    
    enum CodingKeys: String, CodingKey {
        case id
        case name
        case email
    }
}

Associated Value Enum のフィールドタイプに適用されます。例えば、name がかっこよさのレベルも持っている場合!

第三の unkeyedContainer 全体を一つの配列として扱う:

struct ListEntity: Decodable {
    var items:[Decodable]
    init(from decoder: Decoder) throws {
        var unkeyedContainer = try decoder.unkeyedContainer()
        self.items = []
        while !unkeyedContainer.isAtEnd {
            // unkeyedContainer 内部のポインタは decode 操作後に自動的に次のオブジェクトを指す
            // 終端を指すとループ終了を意味する
            if let id = try? unkeyedContainer.decode(Int.self) {
                items.append(id)
            } else if let name = try? unkeyedContainer.decode(String.self) {
                items.append(name)
            }
        }
    }
}

let jsonString = "[\"test\",1234,5566]"
let jsonDecoder = JSONDecoder()
let result = try jsonDecoder.decode(ListEntity.self, from: jsonString.data(using: .utf8)!)
print(result)

型が固定されていない配列フィールドに適用する。

Container の下で nestedContainer / nestedUnkeyedContainer を使って特定のフィールドを操作することもできます:

*データフィールドのフラット化(flatMapに類似)

struct ListEntity: Decodable {
    
    enum CodingKeys: String, CodingKey {
        case items
        case date
        case name
        case target
    }
    
    enum PredictKey: String, CodingKey {
        case type
    }
    
    var date: Date
    var name: String
    var items: [Decodable]
    var target: Decodable
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        
        self.date = try container.decode(Date.self, forKey: .date) // 日付をデコード
        self.name = try container.decode(String.self, forKey: .name) // 名前をデコード
        
        let nestedContainer = try container.nestedContainer(keyedBy: PredictKey.self, forKey: .target)
        
        let type = try nestedContainer.decode(String.self, forKey: .type) // タイプを取得
        if type == "song" {
            self.target = try container.decode(SongEntity.self, forKey: .target) // SongEntityとしてデコード
        } else {
            self.target = try container.decode(UserEntity.self, forKey: .target) // UserEntityとしてデコード
        }
        
        var unkeyedContainer = try container.nestedUnkeyedContainer(forKey: .items)
        self.items = []
        while !unkeyedContainer.isAtEnd {
            if let song = try? unkeyedContainer.decode(SongEntity.self) {
                items.append(song) // SongEntityを追加
            } else if let user = try? unkeyedContainer.decode(UserEntity.self) {
                items.append(user) // UserEntityを追加
            }
        }
    }
}

アクセスし、異なる階層のオブジェクトをデコードする例として、target/items が nestedContainer を使って type を取得し、その type に応じて対応するデコードを行う方法を示しています。

Decode と DecodeIfPresent

  • DecodeIfPresent: Response にデータフィールドが存在する場合のみデコードを行う(Codable プロパティが Optional の場合)

  • Decode: デコード操作を行う。レスポンスにデータフィールドがない場合はエラーをスローする

*以上は init decoder や container の方法や機能を簡単に紹介しただけです。わからなくても問題ありません。実際のシーンに進み、サンプルで組み合わせた操作方法を体感しましょう。

現実のシナリオ

元のサンプル JSON 文字列に戻ります。

シナリオ1. 今日、誰かにコメントを残す場合、それが曲に対するものか人に対するものか分からないとします。targetObject フィールドの対象は User または Song のどちらでしょうか?どう対応すればよいですか?

{
  "results": [
    {
      "id": 123456,
      "comment": "告五人ではなく、五告人です!",
      "target_object": {
        "type": "song",
        "id": 99,
        "name": "披星戴月の想い"
      },
      "commenter": {
        "type": "user",
        "id": 1,
        "name": "zhgchgli",
        "email": "[email protected]"
      }
    },
    {
      "id": 55,
      "comment": "66666!",
      "target_object": {
        "type": "user",
        "id": 1,
        "name": "zhgchgli"
      },
      "commenter": {
        "type": "user",
        "id": 2,
        "name": "aaaa",
        "email": "[email protected]"
      }
    }
  ]
}

方法 a.

Enum をコンテナとして使ったデコード。

struct CommentEntity: Decodable {
    
    enum CodingKeys: String, CodingKey {
      case id
      case comment
      case targetObject = "target_object"
      case commenter
    }
    
    var id: Int
    var comment: String
    var targetObject: TargetObject
    var commenter: UserEntity
    
    enum TargetObject: Decodable {
        case song(SongEntity)
        case user(UserEntity)
        
        enum PredictKey: String, CodingKey {
            case type
        }
        
        enum TargetObjectType: String, Decodable {
            case song
            case user
        }
        
        init(from decoder: Decoder) throws {
            let container = try decoder.container(keyedBy: PredictKey.self)
            let singleValueContainer = try decoder.singleValueContainer()
            let targetObjectType = try container.decode(TargetObjectType.self, forKey: .type)
            
            switch targetObjectType {
            case .song:
                let song = try singleValueContainer.decode(SongEntity.self)
                self = .song(song)
            case .user:
                let user = try singleValueContainer.decode(UserEntity.self)
                self = .user(user)
            }
        }
    }
}

targetObject のプロパティを Associated Value Enum に変更し、Decode 時に Enum 内に何を入れるかを決定します。

核心の実践は、Decodable に準拠した Enum をコンテナとして作成し、decode 時にまずキーとなるフィールド(例の JSON String の type フィールド)を取得して判別します。typeSong の場合は singleValueContainer を使って全体を SongEntity にデコードし、User の場合も同様に処理します。

使用時に Enum から取り出す:

//if case let
if case let CommentEntity.TargetObject.user(user) = result.targetObject {
    print(user)
} else if case let CommentEntity.TargetObject.song(song) = result.targetObject {
    print(song)
}

//switch case let
switch result.targetObject {
case .song(let song):
    print(song)
case .user(let user):
    print(user)
}

方法 b.

宣言フィールドのプロパティをベースクラスに変更する。

struct CommentEntity: Decodable {
    enum CodingKeys: String, CodingKey {
      case id
      case comment
      case targetObject = "target_object"
      case commenter
    }
    enum PredictKey: String, CodingKey {
        case type
    }
    
    var id: Int
    var comment: String
    var targetObject: Decodable
    var commenter: UserEntity
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.id = try container.decode(Int.self, forKey: .id)
        self.comment = try container.decode(String.self, forKey: .comment)
        self.commenter = try container.decode(UserEntity.self, forKey: .commenter)
        
        //
        let targetObjectContainer = try container.nestedContainer(keyedBy: PredictKey.self, forKey: .targetObject)
        let targetObjectType = try targetObjectContainer.decode(String.self, forKey: .type)
        if targetObjectType == "user" {
            self.targetObject = try container.decode(UserEntity.self, forKey: .targetObject)
        } else {
            self.targetObject = try container.decode(SongEntity.self, forKey: .targetObject)
        }
    }
}

原理はほぼ同じですが、ここではまず nestedContainer を使って targetObject に入り、type を取り出して判定し、その後 targetObject をどの型に解析するか決定します。

使用時にキャストする:

if let song = result.targetObject as? Song {
  print(song)
} else if let user = result.targetObject as? User {
  print(user)
}

シナリオ2. 配列フィールドに複数の種類のデータが入っている場合、どのようにDecodeするか?

{
  "results": [
    {
      "type": "song",
      "id": 99,
      "name": "披星戴月的想你"
    },
    {
      "type": "user",
      "id": 1,
      "name": "zhgchgli",
      "email": "[email protected]"
    }
  ]
}
struct ListEntity: Decodable {
    enum CodingKeys: String, CodingKey {
        case results
    }
    enum PredictKey: String, CodingKey {
        case type
    }
    
    var results:[Decodable]
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        var nestedUnkeyedContainer = try container.nestedUnkeyedContainer(forKey: .results)
        
        self.results = []
        while !nestedUnkeyedContainer.isAtEnd {
            // type を取得して判別
            let type = try nestedUnkeyedContainer.nestedContainer(keyedBy: PredictKey.self).decode(String.self, forKey: .type)
            if type == "song" {
                results.append(try nestedUnkeyedContainer.decode(SongEntity.self))
            } else {
                results.append(try nestedUnkeyedContainer.decode(UserEntity.self))
            }
        }
    }
}

上述で述べた nestedUnkeyedContainer と場面1の解決策を組み合わせることができます;ここでも場面1の a. 解決策 を使い、Associated Value Enum で値を取得する方法に変更可能です。

シナリオ3. JSONのStringフィールドに値がある場合のみDecodeする

[
  {
    "type": "song",
    "id": 99,
    "name": "披星戴月的想你"
  },
    {
    "type": "song",
    "id": 11
  }
]
struct TargetEntity: Decodable {
    enum CodingKeys: String, CodingKey {
        case type
        case id
        case name
    }
    var type: String
    var id: Int
    var name: String
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.id = try container.decode(Int.self, forKey: .id)
        self.type = try container.decode(String.self, forKey: .type)
        
        // 方法1:
        self.name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
        // または方法2:
        self.name = (try? container.decode(String.self, forKey: .name)) ?? "" // 良くない
    }
}

let jsonString = "[ { \"type\": \"song\", \"id\": 99, \"name\": \"披星戴月的想你\" }, { \"type\": \"song\", \"id\": 11 } ]"
let jsonDecoder = JSONDecoder()
let result = try jsonDecoder.decode([TargetEntity].self, from: jsonString.data(using: .utf8)!)

decodeIfPresent を使ったデコード。

シナリオ4. 配列データでDecode失敗したデータをスキップする

{
  "results": [
    {
      "type": "song",
      "id": 99,
      "name": "星をかぶり月をかぶったあなたを想う"
    },
    {
      "error": "errro"
    },
    {
      "type": "song",
      "id": 19,
      "name": "ナイトライフを探しに連れてって"
    }
  ]
}

前述の通り、Decodable はデフォルトで全てのデータ解析が正しく行われた場合にのみマッピングして出力します。時にはバックエンドからのデータが不安定で、長い配列の中にいくつかの項目でフィールドが欠けていたり、フィールドの型が合わずに Decode に失敗することがあります。そのため、全体が失敗して nil になってしまいます。

struct ResultsEntity: Decodable {
    enum CodingKeys: String, CodingKey {
        case results
    }
    var results: [SongEntity]
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        var nestedUnkeyedContainer = try container.nestedUnkeyedContainer(forKey: .results)
        
        self.results = []
        while !nestedUnkeyedContainer.isAtEnd {
            if let song = try? nestedUnkeyedContainer.decode(SongEntity.self) {
                self.results.append(song)
            } else {
                let _ = try nestedUnkeyedContainer.decode(EmptyEntity.self)
            }
        }
    }
}

struct EmptyEntity: Decodable { }

struct SongEntity: Decodable {
    var type: String
    var id: Int
    var name: String
}

let jsonString = "{ \"results\": [ { \"type\": \"song\", \"id\": 99, \"name\": \"披星戴月的想你\" }, { \"error\": \"errro\" }, { \"type\": \"song\", \"id\": 19, \"name\": \"帶我去找夜生活\" } ] }"
let jsonDecoder = JSONDecoder()
let result = try jsonDecoder.decode(ResultsEntity.self, from: jsonString.data(using: .utf8)!)
print(result)

解決方法も シナリオ2の解決策 と類似しています。nestedUnkeyedContainer で各要素を繰り返し処理し、try? で Decode を試みます。Decode に失敗した場合は Empty Decode を使い、nestedUnkeyedContainer の内部ポインタを進め続けます。

*この方法は少し回避策です。nestedUnkeyedContainer に対してスキップ命令ができず、nestedUnkeyedContainer はデコードに成功しないと次に進めないためです。そのためこのようにしています。Swiftコミュニティでは moveNext() の追加が提案されていますが、現バージョンではまだ実装されていません。

シナリオ5. 一部のフィールドはプログラム内部で使用し、Decode しない場合

方法a. Entity/Model

ここで最初に述べた、EntityとModelを分ける役割について触れます。Entityは純粋にJSON文字列からEntity(Decodable)へのマッピングを担当し、ModelはEntityを初期化して、実際のプログラム内でのデータ受け渡しや操作、ビジネスロジックを担います。

struct SongEntity: Decodable {
    var type: String
    var id: Int
    var name: String
}

class SongModel: NSObject {
    init(_ entity: SongEntity) {
        self.type = entity.type
        self.id = entity.id
        self.name = entity.name
    }
    
    var type: String
    var id: Int
    var name: String
    
    var isSave:Bool = false // ビジネスロジック
}

Entity/Model を分けるメリット:

  1. 責任を明確に分ける、Entity: JSON文字列からDecodableへ、Model: ビジネスロジック

  2. 一目でどのフィールドがマッピングされているか Entity を見ればわかる

  3. フィールドが多くて全部一緒にまとめないようにする

  4. Objective-C でも使用可能(Model は単なる NSObject、struct/Decodable は Objective-C から見えないため)

  5. 内部で使用するビジネスロジックやフィールドは Model に配置すれば十分です。

方法b. initでの処理

CodingKeys を列挙し、内部で使用するフィールドを除外します。init 時にデフォルト値を与えたり、フィールドにデフォルト値を設定したり、Optional にする方法もありますが、どれも良い方法ではなく、ただ動作させるためだけの手段です。

[2020/06/26 更新] — 後編 シナリオ6.APIレスポンスでBoolを0/1で表現している場合のDecode方法は?

[2020/06/26 更新] — 後編 シーン7. 毎回 init decoder を書き直したくない場合

[2020/06/26 更新] — 後編 シナリオ8. ResponseのNullフィールドデータの適切な処理

総合シナリオ例

以上の基本的な使用法と応用例を総合した完全なサンプル:

{
  "count": 5,
  "offset": 0,
  "limit": 10,
  "results": [
    {
      "id": 123456,
      "comment": "是告五人,不是五告人!",
      "target_object": {
        "type": "song",
        "id": 99,
        "name": "披星戴月的想你",
        "create_date": "2020-06-13T15:21:42+0800"
      },
      "commenter": {
        "type": "user",
        "id": 1,
        "name": "zhgchgli",
        "email": "[email protected]",
        "birthday": "1994/07/18"
      }
    },
    {
      "error": "not found"
    },
    {
      "error": "not found"
    },
    {
      "id": 2,
      "comment": "哈哈,我也是!",
      "target_object": {
        "type": "user",
        "id": 1,
        "name": "zhgchgli",
        "email": "[email protected]",
        "birthday": "1994/07/18"
      },
      "commenter": {
        "type": "user",
        "id": 1,
        "name": "路人甲",
        "email": "[email protected]",
        "birthday": "2000/01/12"
      }
    }
  ]
}
import Foundation
//

let jsonString = """
{
  "count": 3,
  "offset": 0,
  "limit": 10,
  "results": [
    {
      "id": 123456,
      "comment": "是告五人,不是五告人!",
      "target_object": {
        "type": "song",
        "id": 99,
        "name": "披星戴月的想你",
        "create_date": "2020-06-13T15:21:42+0800"
      },
      "commenter": {
        "type": "user",
        "id": 1,
        "name": "zhgchgli",
        "email": "[email protected]",
        "birthday": "1994/07/18"
      }
    },
    {
      "error": "not found"
    },
    {
      "error": "not found"
    },
    {
      "id": 2,
      "comment": "哈哈,我也是!",
      "target_object": {
        "type": "user",
        "id": 1,
        "name": "zhgchgli",
        "email": "[email protected]",
        "birthday": "1994/07/18"
      },
      "commenter": {
        "type": "user",
        "id": 1,
        "name": "路人甲",
        "email": "[email protected]",
        "birthday": "2000/01/12"
      }
    }
  ]
}
"""
//
// Entity:
struct SongEntity: Decodable {
    enum CodingKeys: String, CodingKey {
        case type
        case id
        case name
        case createDate = "create_date"
    }
    var type: String
    var id: Int
    var name: String
    var createDate: Date
}

struct UserEntity: Decodable {
    var type: String
    var id: Int
    var name: String
    var email: String
    var birthday: Date
}

struct CommentEntity: Decodable {
    enum CodingKeys: String, CodingKey {
        case id
        case comment
        case commenter
        case targetObject = "target_object"
    }
    enum PredictKey: String, CodingKey {
        case type
    }
    enum ObjectType: String, Decodable {
        case song
        case user
    }
    var id: Int
    var comment: String
    var commenter: UserEntity
    var targetObject: Decodable
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.id = try container.decode(Int.self, forKey: .id)
        self.comment = try container.decode(String.self, forKey: .comment)
        self.commenter = try container.decode(UserEntity.self, forKey: .commenter)
        
        // targetObject は UserEntity または SongEntity になる可能性があります
        let targetObjectNestedContainer = try container.nestedContainer(keyedBy: PredictKey.self, forKey: .targetObject)
        let type = try targetObjectNestedContainer.decode(ObjectType.self, forKey: .type)
        switch type {
        case .song:
            self.targetObject = try container.decode(SongEntity.self, forKey: .targetObject)
        case .user:
            self.targetObject = try container.decode(UserEntity.self, forKey: .targetObject)
        }
    }
}

struct EmptyEntity: Decodable { }

struct PageEntity<E: Decodable>: Decodable {
    enum CodingKeys: String, CodingKey {
        case count
        case offset
        case limit
        case results
    }
    var count: Int
    var offset: Int
    var limit: Int
    var results: [E]
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.count = try container.decode(Int.self, forKey: .count)
        self.offset = try container.decode(Int.self, forKey: .offset)
        self.limit = try container.decode(Int.self, forKey: .limit)
        
        var nestedUnkeyedContainer = try container.nestedUnkeyedContainer(forKey: .results)
        
        self.results = []
        while !nestedUnkeyedContainer.isAtEnd {
            if let entity = try? nestedUnkeyedContainer.decode(E.self) {
                self.results.append(entity)
            } else {
                let _ = try nestedUnkeyedContainer.decode(EmptyEntity.self)
            }
        }
    }
}

// Model:
class UserModel: NSObject {
    var type: String
    var id: Int
    var name: String
    var email: String
    var birthday: Date
    init(_ entity: UserEntity) {
        self.type = entity.type
        self.id = entity.id
        self.name = entity.name
        self.email = entity.email
        self.birthday = entity.birthday
    }
}

class SongModel: NSObject {
    var type: String
    var id: Int
    var name: String
    var createDate: Date
    init(_ entity: SongEntity) {
        self.type = entity.type
        self.id = entity.id
        self.name = entity.name
        self.createDate = entity.createDate
    }
}

class CommentModel: NSObject {
    var id: Int
    var comment: String
    var commenter: UserModel
    var targetObject: NSObject?
    
    var displayMessage: String // ビジネスロジックのシミュレーション
    
    init(_ entity: CommentEntity) {
        self.id = entity.id
        self.comment = entity.comment
        self.commenter = UserModel(entity.commenter)
        if let userEntity = entity.targetObject as? UserEntity {
            self.targetObject = UserModel(userEntity)
        } else if let songEntity = entity.targetObject as? SongEntity {
            self.targetObject = SongModel(songEntity)
        }
        self.displayMessage = "\(entity.commenter.name):\(entity.comment)"
    }
}
//

let jsonDecoder = JSONDecoder()
let iso8601DateFormatter = ISO8601DateFormatter()
var dateFormatter = DateFormatter()

jsonDecoder.dateDecodingStrategy = .custom({ (decoder) -> Date in
    let container = try decoder.singleValueContainer()
    let dateString = try container.decode(String.self)
    
    // ISO8601 フォーマット:
    if let date = iso8601DateFormatter.date(from: dateString) {
        return date
    }
    
    // YYYY-MM-DD フォーマット:
    dateFormatter.dateFormat = "yyyy/MM/dd"
    if let date = dateFormatter.date(from: dateString) {
        return date
    }
    
    throw DecodingError.dataCorruptedError(in: container, debugDescription: "日付文字列 \(dateString) をデコードできません")
})

do {
    let pageEntity = try jsonDecoder.decode(PageEntity<CommentEntity>.self, from: jsonString.data(using: .utf8)!)
    let comments = pageEntity.results.compactMap { CommentModel($0) }
    comments.forEach { (comment) in
        print(comment.displayMessage)
    }
} catch {
    print(error)
}

出力:

zhgchgli:告五人です、五告人ではありません!
路人甲:はは、私もです!

完全なサンプルは上記の通りです!

(下)編&その他のシナリオを更新しました:

まとめ

Codable を選ぶ利点は、まず第一にネイティブであるため、将来的にメンテナンスされなくなる心配がなく、コードも美しく書けることです。しかし、その分制約が厳しく、JSON文字列を柔軟に解釈するのは難しいです。そうでなければ、本記事のようにより多くの処理を実装する必要があります。また、パフォーマンスも他のマッピングライブラリに劣る場合があります(Decodable は依然として Objective-C 時代の NSJSONSerialization を使って解析しています)。ただし、今後のアップデートで Apple がこれを最適化する可能性があり、その際はコードを変更する必要がなくなるでしょう。

本文のシナリオや例は極端な場合もありますが、時にはそういった状況に遭遇することもあります。もちろん、通常はシンプルな Codable で十分なことを願っています。しかし、上記のテクニックを身につければ、ほとんどの問題は解決できるはずです!

感謝 @saiday さんの技術サポート。

延伸閱讀

  1. 深入 Decodable — — 写一个超越原生的 JSON 解析器 滿滿的內容,深入了解 Decoder/JSONDecoder。
  2. 不同角度看问题 — 从 Codable 到 Swift 元编程
  3. Why Model Objects Shouldn’t Implement Swift’s Decodable or Encodable Protocols

Post は Medium から ZMediumToMarkdown によって変換されました。

GitHub で編集
この記事を改善
本記事は Medium で初公開
オリジナルを読む
この記事をシェア
リンクをコピー · SNS でシェア
ZhgChgLi
著者

ZhgChgLi

An iOS, web, and automation developer from Taiwan 🇹🇼 who also loves sharing, traveling, and writing.

コメント