Galapagos Blog

株式会社ガラパゴスのメンバーによるブログです。

Create MLでMNISTの分類器を作ってみた

こんにちは。iOSチームの高橋です。好きな拡張子は.xhtmlです。

今回はWWDC 2018で発表されたCreate MLについて、セッションの内容を踏まえつつ、簡単なデモ(みんな大好きMNIST)で評価をしてみました。

注意:この記事はベータ版の内容をもとに書かれています。実際に公開されるバージョンでは事情が異なっている可能性がありますのでご注意ください。

Create MLとは

昨年のWWDCでは、Kerasなどの機械学習フレームワークで学習させたモデルを読み込んで推論を行うCore MLが発表されました。 このblogでも、以前に一度Core MLを使ったスタイル変換について紹介しています。

gtech.hatenablog.com

しかし、Core MLでできるのは学習済みモデルを使った推論のみで、学習自体は別のフレームワークを使って行う必要がありました。 それはKerasであったりTensorFlowであったり、あるいはLIBSVMであったり様々ですが、いずれも多かれ少なかれ機械学習に関する専門的知識を必要としたり、 データの整備やモデルの評価などの煩雑な(しかし重要な)作業を必要とするものでした。

Create MLを使えば、基本的なワークフローを理解するだけで、以下のようなタスクに対応する機械学習モデルを簡単に作ることができます。

  • 画像分類
  • テキスト分類
  • 表データからの回帰・分類

以下では画像分類モデルの作りかたを見ていきます。

ワークフロー

機械学習モデルの構築は、次のようなステップで行われます。

  1. データの収集・整備
  2. 学習
  3. 評価

Create MLは、このそれぞれのステップをものすごく簡略化するためのAPIを提供しています。

データの整備

学習させるデータは、モデルに与えられる前に十分に整備されている必要があります。 たとえば画像ならばサイズを揃えたりする必要がありますし、文字列ならば適切なトークン化(文字単位、あるいは形態素単位)と符号化が必要になるでしょう。

Create MLはこれを全部肩代わりしてくれます。開発者がすべきことは単にディレクトリに画像やテキストを放り込むだけです。 ラベルはサブディレクトリの名前として与えられます。たとえばMNISTならばこんな風になります:

train_data/
├ 0/
| ├ 00007.png
| ├ 00010.png
| └ ...
├ 1/
| └ ...
└ ...

また、(今回は使いませんでしたが)画像データのオーグメンテーション1にも対応しており、 学習時にオプションを与えることで自動的に処理してくれます。

学習

画像分類モデルの学習は二通りのやりかたが用意されています。Create ML UIを使う方法と使わない方法です。まずは前者から説明します。

macOS用のPlaygroundを作成し、Assistant editorを開いた状態で以下のようなコードを実行します。

import CreateMLUI

let builder = MLImageClassifierBuilder()
builder.showInLiveView()

すると下図のようなUIが出現します(画像は公式のリファレンスから引用しています)。 f:id:glpgsinc:20180611214817p:plain

あとは"Drop Images To Begin Training"のところに上で用意したフォルダをドロップするだけで学習が開始します。 今回はMNISTを1000枚に絞った小さなデータセット2(学習用800枚とテスト用200枚)を作成し、それを学習させてみたところ、Training精度が86%、Validation精度が82%となりました。 Validationが表示されるということは何らかのハイパーパラメータを振っているのだと思いますが、そのあたりの詳細はよくわかりません。

ところで、もちろんコードで書く方法も提供されています。MLImageClassifier構造体のイニシャライザに学習データディレクトリのURLを与えるだけで学習がスタートします。

import CreateML
let trainDirectory = URL(string: "file:///path/to/train/data")!
let classifier = try! MLImageClassifier(trainingData: .labeledDirectories(at: trainDirectory))

このイニシャライザには他にparametersという引数をとり、学習する最大ステップ数やデータオーグメンテーションの種類などを渡すことができます。 筆者はFeatureExtractorTypeという列挙体が気になるのですが、今のところ1種類しか定義されていないようです。いずれは他のものも使えるようになるのかしら。

評価

学習が終わったら、評価をせねばなりません。Create ML UIを使った場合は、学習が終了すると精度表示の下に"Drop Images To Begin Testing"というボックスが表示されるので、 テストデータをそこにドロップすれば評価が実行されます。今回のデータセットでは85%の精度が出ました3

800枚で85%というのがどのくらいなのか、比較のためにTensorFlowで簡単なモデル4を書いて実験してみたところ、81.5%の精度を得ました。 どうやら同等以上の性能が出るようです。これは転移学習5がうまくいっているためと思われます。 しかもコードはたったの3行で。これはうれしいですね。

モデルの書き出し

十分に精度の高いモデルが得られたら、それをmlmodelファイルに書き出します。 Create ML UIを使った場合はライブビュー上のメニューを展開するとモデルのメタデータを設定して保存するボタンが出てくるので、それだけでOKです(下の画像も公式のリファレンスから引用しています)。 f:id:glpgsinc:20180614153543p:plain

コードで書いた場合は、MLImageClassifierwrite(to:metadata:)メソッドを呼ぶことで、指定したURLにモデルファイルが書き出されます。

let outputURL = URL(string: "file:///path/to/output")!
try! classifier.write(to: outputURL)

書き出されたモデルのサイズはなんとたったの152KBでした。これも転移学習のおかげでパラメータの大部分をOS側に載せることに成功しているためです6

まとめと感想

ということで、Create MLを使って簡単な手書き数字識別モデルを作成することができました。 たった3行のコーディングで、機械学習の専門知識なしにそれなりの精度のモデルが作れてしまうのは便利だな、と思います。 モデルサイズが小さく抑えられるのも実用面で魅力的ですね。

一方で、せっかく転移学習をしているわりには精度がTensorFlowと同程度だったのが気懸かりではありますが、 これは元の学習データとMNISTが大きく異なるデータセットだったからではないかと考えています。 (そう考えると、CIFAR-10などで比較したら違う結果になっていたかもしれません)

また、今回はデータセットの準備が面倒だったので試さなかったテキスト分類や回帰モデルなども、 同じように非常にシンプルなコードで簡単にモデルを作成できるようなので、次の機会があれば試してみたいと思います。

ところで(いつもの)

弊社ではエンジニアを募集しています。ご興味をお持ちのかたはぜひ弊社採用ページをご覧ください。

www.glpgs.com

以上です。


  1. 画像のフリップや回転などの加工でデータ量を水増しすること

  2. 大きなデータセットが用意できない/するのがめんどくさいようなケースを想定しています。

  3. ただし、何度か実験すると精度はけっこうばらつくようです。これは学習時のTraining/Validationデータの分割がランダムであることなどに起因するのだと思われます(実際何をやっているのかはわかりませんが)。

  4. アーキテクチャは畳み込み+プーリング3層+全結合2層。Weight decayあり。ステップ数はCreate MLにあわせて10としました。ちなみに、両者のステップ数を100にしたときは、Create MLが87%、TensorFlowが84.5%でした。

  5. すでに大量のデータで学習されたモデルの一部だけを少量の新しいデータで学習し直すことで、少量のデータから高精度なモデルを作成する手法です。

  6. ちなみにTensorFlowからのmlmodel書き出しは試していませんが、チェックポイントのサイズが80MBほどあったので、まあその程度でしょう。

【Android】Notification Channelのお話

こんにちは。Androidエンジニア(たまにiOSエンジニア)のほかりです。 今回は今更ながらAndroid 8から追加されたNotificationChannelについてお話しようと思います。

背景

ご存知の方も多いかと思いますが、

  • 新規アプリは2018/08から
  • 既存アプリは2018/11から

targetSdkVersionを26以上(2018/06時点では27が最新)にしないとGooglePlayにアップロードできなくなります。

※ 今後も新しいOSバージョンが出るたびにこの要求されるSDKバージョンは上がっていきます。

ソースはこちら

まぁこのこと自体はtargetSdkVersionを26以上にしてビルドすれば良いのですが、現在わかっていることで一つ問題になることがあります。 すでにタイトルでネタバレしているのですが、Notificationのことです。

Android 8 (Oreo)からNotificationにChannelという新たな概念が登場しました。 targetSdkVersionが26未満の場合は特に関係なかったのですが、26以上にする場合NotificationChannelを少なくとも一つは実装する必要があります。 なのでNotificationChannelの実装方法などを紹介していきます!!

ユーザー視点からの変更点

Android端末の「設定」→「アプリ」→「通知」で表示される項目が変わりました。 Android8端末ならアプリのラウンチャーアイコン長押しからアプリ設定に飛んでもいいですね。

ではどういう風に見た目が変わるのかをAndroid 7 と 8で比べてみます。 通知確認用に作ったアプリの設定画面でみてみましょう!

Android 7までの通知設定画面

f:id:glpgsinc:20180605160925p:plain

通知を表示するのか・表示しないのかという極端な設定項目しかありません。

Android 8からの通知設定画面

f:id:glpgsinc:20180605160918p:plain

カテゴリというグループで「お知らせ」と「重要」というチャンネルが追加されていて、 チャンネル毎に通知を表示するのか・表示しないのか設定することができるようになりました。

※ チャンネル名はアプリ側で実装しています。

グループのカスタマイズ

実装次第では「カテゴリ」となっていたグループを別の名前に変更することができます。 未指定だと「カテゴリ」になります。

f:id:glpgsinc:20180605160928p:plain

実装

では違いがわかったところで、いよいよ実装していきます。

「グループの作成」、「チャンネルの作成」、「通知」をする機能を持ったNotificationHelperというクラスを作ってみました。 最後に全体像をお見せしますが、初めは説明も兼ねて細かくコードを載せていきます。 言い忘れていましたがKotlinで実装しています。

stringを定義

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string name="notification_group_push">Push通知</string>
    <string name="notification_group_app">アプリ内通知</string>

    <string name="notification_channel_news">お知らせ</string>
    <string name="notification_channel_important">重要</string>
</resources>

グループとチャンネルの列挙型を定義

    /**
     * グループ.
     */
    enum class Group(val resId: Int) {
        Push(R.string.notification_group_push),
        App(R.string.notification_group_app)
    }

    /**
     * 通知チャンネル.
     */
    enum class Channel(
            val resId: Int,
            val importance: Int,
            val color: Int,
            val visibility: Int,
            val group: Group
    ) {
        News(
                R.string.notification_channel_news,
                NotificationManager.IMPORTANCE_MIN,
                Color.GREEN,
                Notification.VISIBILITY_PRIVATE,
                Group.Push
        ),
        Important(
                R.string.notification_channel_important,
                NotificationManager.IMPORTANCE_HIGH,
                Color.BLUE,
                Notification.VISIBILITY_PRIVATE,
                Group.App
        )
    }

グループとチャンネルを作成

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {

        // グループ生成
        val createGroup: (Group) -> NotificationChannelGroup =
                fun(group: Group) = NotificationChannelGroup(
                        getString(group.resId),  // グループID
                        getString(group.resId))  // グループ名
        manager.createNotificationChannelGroups(arrayListOf(createGroup(Group.Push), createGroup(Group.App)))

        // チャンネル生成
        val createChannel: (Channel) -> NotificationChannel =
                fun(channel: Channel) = NotificationChannel(
                        getString(channel.resId), // チャンネルID
                        getString(channel.resId), // チャンネル名
                        channel.importance) // 優先度
                        .apply { group = getString(channel.group.resId) } // グループと紐付け
            manager.createNotificationChannels(arrayListOf(createChannel(Channel.News), createChannel(Channel.Important)))
        }
    }

初めにバージョンをチェックしているのは古いOSバージョンで通知チャンネルの実装を行うと落ちるからです。 ※ ちょっと面倒くさがってIDと名前を同じにしてしまっています。

通知の発行処理

    fun post(channel: Channel) {
        val pending = TaskStackBuilder.create(this)
                .addNextIntent(Intent(this, MainActivity::class.java))
                .getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT)

        val notificationBuilder = NotificationCompat.Builder(this, getString(channel.resId)) // チャンネル指定
                .setGroup(getString(channel.group.resId)) // グループ指定
                .setColor(channel.color)
                .setSmallIcon(R.mipmap.ic_launcher_round)
                .setStyle(NotificationCompat.BigTextStyle()
                        .setBigContentTitle("Title")
                        .bigText("Message"))
                .setAutoCancel(true)
                .setVisibility(channel.visibility)
                .setContentIntent(pending)

        manager.notify(notifyId.incrementAndGet(), notificationBuilder.build())
    }

ここの細かいところは割とテキトーに書いてしまいましたが、注目していただきたいところはコメントしてあるところです。 - NotificationCompat.Builderのインスタンス生成時にチャンネルIDを指定します。 - その後setGroupでグループIDを指定します。

あらかじめ作っておいたグループ・チャンネルを指定しないと通知が表示されませんのでご注意を!

ソースコード全体

/**
 * Notification周りのヘルパークラス.
 */
internal class NotificationHelper(context: Context?): ContextWrapper(context) {

    /**
     * グループ.
     */
    enum class Group(val resId: Int) {
        Push(R.string.notification_group_push),
        App(R.string.notification_group_app)
    }

    /**
     * 通知チャンネル.
     */
    enum class Channel(
            val resId: Int,
            val importance: Int,
            val color: Int,
            val visibility: Int,
            val group: Group
    ) {
        News(
                R.string.notification_channel_news,
                NotificationManager.IMPORTANCE_MIN,
                Color.GREEN,
                Notification.VISIBILITY_PRIVATE,
                Group.Push
        ),
        Important(
                R.string.notification_channel_important,
                NotificationManager.IMPORTANCE_HIGH,
                Color.BLUE,
                Notification.VISIBILITY_PRIVATE,
                Group.App
        )
    }

    companion object {
        private val notifyId = AtomicInteger(0)
    }

    private val manager: NotificationManager by lazy {
        getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
    }

    init {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {

            // グループ生成
            val createGroup: (Group) -> NotificationChannelGroup =
                    fun(group: Group) = NotificationChannelGroup(getString(group.resId), getString(group.resId))
            manager.createNotificationChannelGroups(arrayListOf(createGroup(Group.Push), createGroup(Group.App)))

            // チャンネル生成
            val createChannel: (Channel) -> NotificationChannel =
                    fun(channel: Channel) = NotificationChannel(
                            getString(channel.resId),
                            getString(channel.resId),
                            channel.importance).apply {
                        group = getString(channel.group.resId)
                    }
            manager.createNotificationChannels(arrayListOf(createChannel(Channel.News), createChannel(Channel.Important)))
        }
    }

    /**
     * Notificationをpostします.
     *
     * @param channel: Channel
     */
    fun post(channel: Channel) {
        val pending = TaskStackBuilder.create(this)
                .addNextIntent(Intent(this, MainActivity::class.java))
                .getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT)

        val notificationBuilder = NotificationCompat.Builder(this, getString(channel.resId)) // チャンネル指定
                .setGroup(getString(channel.group.resId)) // グループ指定
                .setColor(channel.color)
                .setSmallIcon(R.mipmap.ic_launcher_round)
                .setStyle(NotificationCompat.BigTextStyle()
                        .setBigContentTitle("Title")
                        .bigText("Message"))
                .setAutoCancel(true)
                .setVisibility(channel.visibility)
                .setContentIntent(pending)

        manager.notify(notifyId.incrementAndGet(), notificationBuilder.build())
    }
}

注意事項

ここまで頑張って実装してもうまく動作しない場合というのがあります。 それはFCMの通知メッセージをアプリがバックグラウンドで動作中の時に受信した場合です。

Firebaseの公式ドキュメントにも記載されていますが、 通知メッセージの場合は通知トレイに直接通知されてしまうので、アプリ側でハンドリングすることができません。

その場合、一部の設定項目をAndroidManifestファイルに定義することができます。 定義できる項目は以下の通りです。

  • アイコン
  • アイコンの背景色とアプリ名の文字色
  • デフォルトチャンネル(一つのみ)

具体的な定義の仕方は以下の通りです。

<!--アイコン-->
<meta-data
    android:name="com.google.firebase.messaging.default_notification_icon"
    android:resource="@drawable/ic_stat_ic_notification" />

<!--アイコンの背景色とアプリ名の文字色-->
<meta-data
    android:name="com.google.firebase.messaging.default_notification_color"
    android:resource="@color/colorAccent" />

<!--デフォルトチャンネル-->
<meta-data
    android:name="com.google.firebase.messaging.default_notification_channel_id"
    android:value="@string/default_notification_channel_id"/>

上記の通り、設定できる項目がかなり限られてしまいグループを指定することができません。 またデフォルトチャンネルの指定を忘れても通知は表示されますが、勝手に変な名前のチャンネル名が登録されます。

まとめ

Android 8以上におけるNotification Channelの紹介 & 実装方法の説明をしました。 これから慌ててtargetSdkVersionを26以上に上げるよって方々の参考になると嬉しいです。 それでは良きAndroidライフを!

JSONの読み書きでSwift 4.1のCodableを使ってみる。

ガラパゴスのコードヒーヨアン(twitter: @luinily)です。

以前Swift 4でJSONの扱いについての記事を書きましたが、今回はその続きで、Swift 4.1で追加される機能や、実際のプロジェクトをObjectMapperからCodableに変更して見た話をしようと思います。

下記の流れに沿っていこうと思います。

  • 単純な構造体でCodableとObjectMapperの紹介
  • Codableのキーとカスタムキーの紹介
  • Codableのカスタム読み込みと書き出しの紹介
  • Codableのデバグの紹介
  • プロジェクトを通したCodableとObjectMapperの比較

CodableとObjectMapperの紹介

この紹介で、下記のJSONをSwiftの構造体に読み込み、書き出しするための処理を紹介します。

JSON

{
    "food": "桜餅",
    "quantity": 1
}

構造体:

struct Food {
    let food: String
    let quantity: Int
}

Codable

CodableはSwiftが提供している構造体やクラスなどをJSONに書き出す、読み込むためのプロトコル。(厳密に言うと、JSONだけではなく、他のフォーマットにも使えまる) CodableはEncodable(書き出し用)とDecodable(読み込み用)の二つのプロトコルで構成されています。

構造体での対応:

struct Food: Codable {
    let food: String
    let quantity: Int
}

こういう単純な構造体では「Codable」というプロトコルを適用するだけです、時に実装が不要です。

JSONの読み込み:

let decoder = JSONDecoder()
let food = try? decoder.decode(data)

書き出し:

let encoder = JSONEncoder()
let data = try? encoder.encode(food)

JSONの読み込み書き出しはData型のオブジェクトから行われます。

ObjectMapper

ObjectMapperはJSONの読み込み、書き出しによく使われているライブラリー、Mappableというプロトコルを使います。

構造体での対応:

struct Food: Mappable {
    var food: String!
    var quantity: Int!

    init?(map: Map) {
        mapping(map: map)
    }

    mutating func mapping(map: Map) {
        food <- map["food"]
        quantity <- map["quantity"]
    }
}

こういう単純な構造体でも「Mappable」というプロトコルを適用した上で、各プロパティーを「let」から「var」に変えて、型に「!」を追加して、「init?(map: Map)」と「func mapping(map: Map)」という関数を実装する必要があります。

「init?(map: Map)」の実装は簡単ですが、「mapping」の実装はもうちょっと手間がかかります: 

  • 各プロパティに対して 「property <- map["キー"]」というあまり見慣れてない行を書く必要がある
  • 各プロパティのキーを入力する必要があります。
  • 書き出し、読み込みに特別な処理をする必要がある場合ちょっと複雑になります。

その処理はObjectMapperが行う処理が原因です:構造体・オブジェクトを実態化してから、JSONを読み込んでプロパティーの値を設定しますので、値が入ってない状態で実態化できる必要と、実態化後に値の変更できるようにする必要があります。

JSONの読み込み:

let food =  Mapper<Food>().map(JSONObject: jsonObject)

書き出し:

let jsonDictionarry = Mapper<Food>().toJSON(food)

比較

構造体での対応はキー、読み込み関数、書き出し関数の自動生成ができるCodableの方がはるかに軽いです。その上、Codableはinit()関数で読み込みを行なっていることで、varだけではなく、letも使えて、!を使用する必要もありません。今回のような簡単なクラスや構造体ではCodableを宣言するだけで対応できます。

ObjectMapperはキーが自動生成されませんので書く必要ありますが、キーの文字列は自由に定義できます。mapping(map)関数雨の中でJSONとの変換に必要な処理も定義できます。Codableの方は同じのようにキーをカスタマイズするのと、書き出し、読み込みの処理をカスタマイズすることができますか。

Codableのキー

キーに使われる文字列がプロパティ名そのままですので、自動生成されるCodableのキーに複数問題があります。

  • JSONでのキーの書き方はスネークケース(snake_case)、Swiftではカーメルケース(camelCase)を使います。このまま自動生成されるキーを使うと、Swiftのプロパティ名をスネークケースにするか、JSONをカーメルケースにする必要があります。できれば避けたい問題です。
  • プロパティ名と違う文字列をキーに使い時もあります。
  • クラスや構造体にJSONの書き出し、読み込みと関係ないプロパティがあった場合現状ではキーが作成されて、読み込み書き出しされようとします。

snake_caseとcamelCaseの自動変換(Swift 4.1から)

Swift 4.1で新しく追加された機能ですが、Encoderの「keyEncodingStrategy」やDecoderの「keyDecodingStrategy」を設定することで、camelCaseのプロパティ名からsnake_caseのキーの自動作成できるようになりました。

let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase

let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase

使えるストラテージーが三つあります:

  • useDefaultKeys → プロパティ名をそのままキーに使います
  • convertFromSnakeCase / convertToSnakeCase → プロパティ名をcamelCase、snake_caseに変換します
  • custom(([CodingKey]) -> CodingKey) → 引数で渡すクロージャーを使用し、変換します。

カスタムキーの定義

Codableでも必要な時は本来自動的に生成される「CodingKeys」という列挙型を追加することでカスタムキーを使うことができます。

struct Food: Codable {
    let food: String
    let quantity: Int

    enum CodingKeys: String, CodingKey {
        case food = "Food"
        case quantity
    }
}
  • 構造体、クラスなどの中で定義する必要があります。
  • キーの列挙型の定義は「enum CodingKeys: String, CodingKey」にする必要があります。
  • ケース名はプロパティ名と同じ
  • カスタムしたいキーは「= "カスタム値"」で定義できます。
  • カスタムしないキーも定義しないといけないんですが、Stringでの値は定義しなくても自動的に作成されます。
  • CodingKeysを定義する場合、書き込み、書き出しされるプロパティ全てをcaseとして定義する必要があります。

上記の条件でわかると思いますが、書き出し、読み込みしたくないプロパティがある場合、CodingKeysを定義して、そのプロパティ以外のプロパティ全てをcaseとして定義する必要があります。

struct Food: Codable {
    let food: String 
    let quantity: Int 
    var eaten: Bool = false ← CodingKeysに定義されてないため、書出読込されない

    enum CodingKeys: String, CodingKey {
        case food = "Food"    ← カスとマイズされたキー
        case quantity         ← 自動生成されるキー
    }
}

ここでは、カスタムキーについてまだ一つの疑問が残るとお思います:キーの自動変換とカスタムキーがどういうふうに組み合わせられるのかということです。 例えば例のJSONに新しいキーを追加します。

{
    "food": "桜餅",
    "quantity": 1
    "food_weight": 100
}

「food_weight」というキーが追加されました。Swift側ではプロパティーとして追加しますが、Food型に「foodWeight」というプロパティーを入れると名前がちょっと被ってしまいます、「weight」として定義した方がいいと思います。

struct Food: Codable {
    let food: String 
    let quantity: Int 
    let weight: Int

    enum CodingKeys: String, CodingKey {
        case food = "Food"    ← カスとマイズされたキー
        case quantity         ← 自動生成されるキー
        case weight = ""
    }
}

この場合、カスタムキーの値を「food_weight」にするべきか、「foodWeight」にするべきかのどちらでしょうか。

この問題の答えはいつCodingKeysのキーがJSONのキーに変換されるかによって変わります。ヒントとしては、キーの変換の設定は、CodingKeysやFoodではなく、encoderやdecoderの方で行なっていることです。 つまりSwiftのプロパティからJSONキーの生成はこのように行われています:

f:id:glpgsinc:20180522170019p:plain

カスタムキーの定義を行う場合、CodingKeyの値を定義しますので、 JSONキーの変換前の状態にしなければなりません。

struct Food: Codable {
    let food: String 
    let quantity: Int 
    let weight: Int

    enum CodingKeys: String, CodingKey {
        case food = "Food"
        case quantity
        case weight = "foodWeight"
    }
}

先の図に入れるとこうなります: f:id:glpgsinc:20180522170413p:plain

Codableで書き出し、読み込みカスタム処理

Codableでも必要がある場合、カスタムな書き出し関数、読み込み関数の定義ができます。 例えばさっきのJSONの「food_weight」の値をIntからStringに変更します。

{
    "food": "桜餅",
    "quantity": 1
    "food_weight": "100"
}

Food構造体の方ではIntのままにします、StringとIntの変換が必要ようになります。 ついでに、Foodにもう一つのオプショナルプロパティを追加します。 オプショナルプロパティはCodableでもObjectMapperでもJSONにキーがない可能性があるときに使います。

struct Food: Codable {
    let food: String 
    let quantity: Int 
    let weight: Int
    let origin: String?

    enum CodingKeys: String, CodingKey {
        case food = "Food"
        case quantity
        case weight = "foodWeight"
    }
}

カスタム読み込み

読み込み関数は「init(from decoder: Decoder) throws」で定義します。

init(from decoder: Decoder) throws {
    let values = try decoder.container(keyedBy: CodingKeys.self)

    food = try values.decode(String.self, forKey: . food)
    quantity = try values.decode(Int.self, forKey: . quantity)
    weight = try Int(values.decode(String.self, forKey: . weigh))
    origin = try values.decodeIfPresent(String.self, forKey: . quantity)
}

まずは「decoder」からキーを使って、「values」を取得します。

そのあとは各キーに対する値を取得して、必要な追加処理をします。

  • 必須キーの場合、「try values.decode(String.self, forKey: . food)」を使います。一つ目の引数はそのキーの値の型、ここではString、二つ目の引数はキー
  • キーが必須でない場合、「try values.decodeIfPresent(String.self, forKey: . quantity)」を使います。「decode」と同じ使い方ですが、キーがなかった場合にエラーを起こしません。
  • 今回はweightをStringとして取得したあと、Intに変換するカスタム処理を追加しています。

注意点としては、キーがない可能性がある場合はちゃんと「decodeIfPresent」を使うことと、カスタム処理行わないキーに対しても処理を書かなければなりません。

カスタム書き出し

書き出しは読み込みに似ていて、定義する関数は「func encode(to encoder: Encoder) throws」です。

func encode(to encoder: Encoder) throws {
    var container = encoder.container(keyedBy: CodingKeys.self)
    try container.encode(food, forKey: . food)
    try container.encode(quantity, forKey: . quantity)
    try container.encode(String(weight), forKey: . weight)
    try container.encodeIfPresent(origin, forKey: . origin)
}    

読み込みの場合はDecoderからvaluesを取得していたが、今回はEncoderからcontainerを取得します。

そのあと各キーに対する書き出し処理を書きます。

  • 必須キーの場合は、「try container.encode(food, forKey: . food)」を使います。引数はプロパティ値とキーです。
  • プロパティがnilになっている可能性がある場合、「container.encodeIfPresent(origin, forKey: . origin)」を使います。encodeと同じ使い方が、値がnilであった場合エラー起こしません。
  • 今回はweightをStringに変換してから書き出します。

Codableで楽に読み込みと書き出しのカスタム関数がかけることがわかりました。現状ではカスタム処理されるキーだけではなく、全てのキーに対しての処理を書く必要があります。いずれは特別処理をしたいキーの処理を書いた後に、「残りのキーは自動処理して良いよ」のようなやり方があればさらに使いやすくなるかと思います。

列強型

Codableのデバグ

今まで書いたコードを見た気づいたと思いますが、Codableの処理はエラーを返すことができます。「init(from decoder: Decoder)」も、「func encode(to encoder: Encoder)」も「throws」が付いていますし、「encode」、「decode」関数を使うところはtryをつけています。 このおかげで、読み込みや書き出しの際に何らかの問題があった場合エラー処理とバグ対応がしやすくなっています。

lldbにdecodeの際にエラーが出た場合どういう情報得られるか複数の例を見て見ましょう。

JSONに必須キーが含まれていない。

構造体:

struct Food {
    let food: String
    let quantity: Int
}

JSON

{
    "food": "桜餅",
}

lldbで読み込み処理して見るとこうなります。

(lldb) po decoder.decode(Food.self, from: jsonData)
▿ DecodingError
  ▿ keyNotFound : 2 elements
    - .0 : CodingKeys(stringValue: "quantity", intValue: nil)
    ▿ .1 : Context
      - codingPath : 0 elements
      - debugDescription : "No value associated with key CodingKeys(stringValue: \"quantity\", intValue: nil) (\"quantity\")."
      - underlyingError : nil

「Decoding」エラーがプリントアウトされて、エラーの詳細が見れます。

  • エラーの種類が書いてあります:「keyNotFound」(キーがみつからなかったと)
  • どのキーがエラーの原因になったか:「CodingKeys(stringValue: "quantity", intValue: nil)」

結論は「quantity」というキーがJSONで見つからなかったため読み込みができなかったことがわかります。

カスタム読み込み関数を定義した場合、メンバーのタイプがオプショナルになっても、読み込み関数で「decodeIfPresent」ではなく、「decode」を使うと同じエラーが出ます。

② データのタイプが違う

構造体:

struct Food {
    let food: String
    let quantity: Int
}

JSON

{
    "food": "桜餅",
        "quantity": "1"
}

lldbで読み込み処理して見るとこうなります。

(lldb) po decoder.decode(Food.self, from: jsonData)
▿ DecodingError
  ▿ typeMismatch : 2 elements
    - .0 : Swift.Int
    ▿ .1 : Context
      ▿ codingPath : 1 element
        - 0 : CodingKeys(stringValue: "quantity", intValue: nil)
      - debugDescription : "Expected to decode Int but found a string/data instead."
      - underlyingError : nil

同じくエラーが表示されます。

  • エラーの種類は「typeMismatch」(型が一致しません)
  • どのキーが原因となったか:「 CodingKeys(stringValue: "quantity", intValue: nil)」
  • エラーの詳細:「Expected to decode Int but found a string/data instead.」(Intを読み込む期待でしたが、代わりにstring/dataがありました。)

「quantity」というキーに対して、Intを期待していたのに、Stringがあったことがわかります。

③ 無効なJSON

そもそもJSONが無効な場合(「,」や「}」がたりないなどの場合)

(lldb) po decoder.decode(Food.self, from: jsonData)
▿ DecodingError
  ▿ dataCorrupted : Context
    - codingPath : 0 elements
    - debugDescription : "The given data was not valid JSON."
    ▿ underlyingError : Optional<Error>
  • エラーの種類は「dataCorrupted」(損傷したデータ)
  • エラーの詳細:「The given data was not valid JSON.」(与えられたデータが有効なJSONではありません)

一つのアプリでObjectMapperからCodableへの移行試した感想

Swiftのバージョンと共に更新される

Codableの強みの一つはSwiftの1部になっていることです。Swiftのバージョンが上がっても、同時にリリースされますし、ライブラリーの追加が不要です。ObjectMapperの方はSwiftのバージョンが上がっても、対応に時間かかる可能性があります。

ObjectMapperより対応が軽いが、場合によって総合行数があまり変わらない場合も

Codableの実装とObjectMapperの実装に使うコード量を比較してみました。 もともとJSONに関係するクラスと構造体が25個あります。ObjectMapperでは全てのクラス、構造体に実装が必要でしたが、 Codableの方では、14クラスに実装が不要でした(Codableをクラス宣言に追加するだけ)、6クラスにキーだけの実装が必要、残りの5クラスに読み込みや書き出しの関数の実装が必要でした。

ただ、ObjectMapperと違って、書き出し読み込み関数を別々で書く必要があって、今回のプロジェクトで書き出し読み読み込みがカスタムされたクラスが大き目だったため、Codableでの総合対応行数がObjectMapperより1割ぐらいしか変わりませんでした。

ObjectMapperよりデバグしやすい

エラーの説明があるため、どこが悪いかわかりやすいです。ObjectMapperは逆にブレークポイントなど使っても悪いところが探しにくいです。

列挙型のための処理が不要!

CodableはStringやIntというタイプになっている列挙型にも適用できますので、JSON用のIntやStringへの変換の処理を書き出し読み込み関数に追加しなくていいです。

(個人的な)結論

ObjectMapperとCodableを両方使った経験からしては、新規アプリであればぜひCodableの方をオススメしたいと思います。Codableの方が対応が楽のと、問題があった場合デバグしやすいところが大きいです。Swiftのバージョンが変わった時対応を待つ必要もないところが便利だと思います。

ただし、既存アプリの場合は、Codableに変更するための対応に加えて、[String: Any]のJSONディクショナリーではなく、Dataからの読み込み書き出しになりますので、場合によってネットワーク系のライブラリーにも作業が必要可能性がありますので、問題ないかのを確認してから検討した方がいいかと思います。