Kotlin serializationでJSONをパース(JSONの記述⇔クラスのオブジェクト)する場合に、一般クラスは未対応です。
例えば、データクラス(コンストラクタの引数でプロパティを指定するクラス)以外の、ユーザ定義のクラスはパース出来ません。また、ライブラリ提供のクラスもパース出来ません。
対応させるためには、そのクラスのカスタムSerializerを作成します。
そして、相互変換する方法をプログラマー側で定義します。
カスタムSerializer(ユーザ定義クラス、Handwritten版)の作成方法をまとめます。
※環境:Android Studio Meerkat | 2024.3.1
Kotlin 2.0.0
Kotlin serialization json 1.7.1
目次
標準で対応する型、しない型
Kotlin serializationでJSONをパース(JSONの記述⇔クラスのオブジェクト)する場合に、標準で対応する型は次の通りです。
- ・Int,Long,Float,Double,Booleanなど(プリミティブ型)
- ・String(文字列)※プリミティブ型へ含める場合もある
- ・Array(配列)
- ・List,Map,Set(コレクション型)
- ・Enum(列挙型)
- ・データクラス
上記以外の一般クラス(ユーザ定義のクラス、ライブラリ提供のクラス)は未対応です。
例えば、次のようなクラスは標準でパース出来ません。
class LogTime(hour: Int, min: Int, sec: Int) {
val hh: Int = hour
val mm: Int = min
val ss: Int = sec
override fun toString() = "%s:%s:%s".format(hh, mm, ss)
}
一般クラスをパースするには、そのクラスのカスタムSerializerが必要です。
Serializerの種類
Serializerは、表のような種類(書き方)があります。
| 特徴 | シリアル化の構成 | |
|---|---|---|
| Primitive | Primitive型(Stringを含む)限定 | PrimitiveSerialDescriptor |
| Delegating | 定義済みのSerializerへ委任する | SerialDescriptor |
| Surrogate | 代理クラスを立て(作成し)、 そのクラスのSerializerに代行させる | SerialDescriptor |
| Handwritten | 全ての要素のシリアル化を細かく定義 | buildClassSerialDescriptor |
この記事で取り上げるのは、Handwritten版です。
※種類は「Custom serializers」に紹介されています。
カスタムSerializerの作成
Serializer(KSerializerインターフェース)は「Kotlin serializerがJSONをパースする際に呼び出される関数」を持ちます。
object XXXSerializer : KSerializer<XXX> {
override val descriptor: SerialDescriptor
get() = TODO("識別子を実装")
override fun serialize(encoder: Encoder, value: XXX) {
// Json.encodeToString( )で呼び出される
// Obj->JSONの変換を実装
}
override fun deserialize(decoder: Decoder): XXX {
// Json.decodeFromString( )で呼び出される
// JSON->Objの変換を実装
return XXX()
}
}
「JSONの記述⇐クラスのオブジェクト」で呼び出し
KSerializer#deserialize( )「JSONの記述⇒クラスのオブジェクト」で呼び出し
ですので、カスタムSerializerは、このKSerializerインターフェースを継承して、相互変換の方法をserialiseとdesirializeへ実装します。
object LogTimeHandwrittenSerializer : KSerializer<LogTime> {
override val descriptor: SerialDescriptor =
buildClassSerialDescriptor("com.example.LogTimeHandwriteSerializer") {
element<Int>("hh") // index:0を割り当て
element<Int>("mm") // index:1を割り当て
element<Int>("ss") // index:2を割り当て
}
// Obj->JSONの変換
override fun serialize(encoder: Encoder, value: LogTime) {
encoder.encodeStructure(descriptor) {
encodeIntElement(descriptor, 0, value.hh)
encodeIntElement(descriptor, 1, value.mm)
encodeIntElement(descriptor, 2, value.ss)
}
}
// JSON->Objの変換
override fun deserialize(decoder: Decoder): LogTime {
return decoder.decodeStructure(descriptor) {
var _hh = 0
var _mm = 0
var _ss = 0
while(true) {
when(val _index = decodeElementIndex(descriptor)) {
0 -> _hh = decodeIntElement(descriptor, 0)
1 -> _mm = decodeIntElement(descriptor, 1)
2 -> _ss = decodeIntElement(descriptor, 2)
CompositeDecoder.DECODE_DONE -> break // elementの終わり
else -> error("Unexpected index: ${_index}")
}
}
LogTime(_hh, _mm, _ss)
}
}
}
SerialDescriptor(シリアル化の構成)
SerialDescriptorでシリアル化の構成を定義しています。つまり、「JSON上でどのように表現するか!」です。
Handwritten(手書き)版の場合、プログラマが全ての要素を細かく定義しなければなりません。
定義にbuildClassSerialDescriptor( )を使います。
override val descriptor: SerialDescriptor = // ↓↓
buildClassSerialDescriptor("com.example.LogTimeHandwriteSerializer") {
element<Int>("hh") // index:0を割り当て
element<Int>("mm") // index:1を割り当て
element<Int>("ss") // index:2を割り当て
}
elementは対象クラスの持つ要素を表しています。
要素は3つあり、それぞれのkey名が「hh,mm,ss」で、value値の型がIntであることを表します。ここで、elementの並ぶ順番に、indexが割り当てられます。
JSONの要素の並ぶ順番は任意です。順番は「mm,ss,hh」でも「hh,ss,mm」でも問題ありません。
deserializeにおいて、順番の違いへ対応するために、indexが大きな役割を果たします。
deserialize(JSON⇒Objの変換方法)
シリアル化されたデータ(JSONの記述)は、全てのデータが連結されてリボンの様になっており、先頭から順番に送られてきます。
変換は、このリボン上を「なぞる」ように行う必要があります。要素の処理を行う毎に、ポインター(処理を行う点)が移動すると考えれば良いです。

図のように、indexを取り出すdecodeElementIndexとvalueを取り出すdecodeIntElementが対になります。
override fun deserialize(decoder: Decoder): LogTime {
return decoder.decodeStructure(descriptor) {
var _hh = 0
var _mm = 0
var _ss = 0
while(true) {
when(val _index = decodeElementIndex(descriptor)) {
0 -> _hh = decodeIntElement(descriptor, 0)
1 -> _mm = decodeIntElement(descriptor, 1)
2 -> _ss = decodeIntElement(descriptor, 2)
CompositeDecoder.DECODE_DONE -> break // elementの終わり
else -> error("Unexpected index: ${_index}")
}
}
LogTime(_hh, _mm, _ss)
}
}
whenを用いてindex毎に制御を分岐させているのは、先に述べた「JSONの要素の並ぶ順番は任意」のためです。
このようにしておけば、要素の順番が入れ代わっていても、問題ありません。

ちなみに、「順番が一意、要素数が3固定」であると保証されれば、whenは省略できます。
override fun deserialize(decoder: Decoder): LogTime {
return decoder.decodeStructure(descriptor) {
decodeElementIndex(descriptor)
val _hh = decodeIntElement(descriptor, 0)
decodeElementIndex(descriptor)
val _mm = decodeIntElement(descriptor, 1)
decodeElementIndex(descriptor)
val _ss = decodeIntElement(descriptor, 2)
LogTime(_hh, _mm, _ss)
}
}
serialize(Obj⇒JSONの変換方法)
SerialDescriptorで定義されたindexの番号を使って、要素のシリアル出力を並べます。
並ぶ順番はencodeIntElementの記述順です。
override fun serialize(encoder: Encoder, value: LogTime) {
encoder.encodeStructure(descriptor) {
encodeIntElement(descriptor, 0, value.hh) // index:0は「"hh":value」
encodeIntElement(descriptor, 1, value.mm) // index:1は「"mm":value」
encodeIntElement(descriptor, 2, value.ss) // index:2は「"ss":value」
}
}

ちなみに、「JSONの要素の並ぶ順番は任意」なので、順番を変えても問題ありません。
override fun serialize(encoder: Encoder, value: LogTime) {
encoder.encodeStructure(descriptor) {
encodeIntElement(descriptor, 1, value.mm) // index:1は「"mm":value」
encodeIntElement(descriptor, 2, value.ss) // index:2は「"ss":value」
encodeIntElement(descriptor, 0, value.hh) // index:0は「"hh":value」
}
}

カスタムSerializerの使用
カスタムSerializerはアノテーション@Serializableの引数で登録します。
Android Studioはビルドをする際に、@Serializable付きのデータクラスを認識して、データクラスのJavaバイナリへ、引数のカスタムSerializerクラスを埋め込みます。
@Serializable(LogTimeHandwrittenSerializer::class)
class LogTime(hour: Int, min: Int, sec: Int) {
val hh: Int = hour
val mm: Int = min
val ss: Int = sec
override fun toString() = "%s:%s:%s".format(hh, mm, ss)
}
@Serializable
data class Person(
val id: Int,
val name: String,
@SerialName("position") val post: String, // JSONのキーに別名を定義
val times: LogTime
)
Json⇒Obj変換
Json#decodeFromStringを呼び出します。
内部で対象クラスのdeserialize( )が呼び出されます。
val _jPerson = """
{
"id":1,
"name":"Android",
"position":"Manager",
"times":{"hh":8,"mm":55,"ss":22}
}
""".replace("\\s+".toRegex(), "") // 空白の削除
val _oPerson = Json.decodeFromString<Person>(_jPerson)
Log.i(TAG, "(Serialization) Person Obj = ${_oPerson}")
(Serialization) Person Obj = Person(id=1, name=Android, post=Manager, times=8:55:22)
Obj⇒JSON変換
Json#encodeToStringを呼び出します。
内部で対象クラスのserialize( )が呼び出されます。
val _oPerson = Person(
id = 2, name = "Droid", post = "Staff",
times = LogTime(12, 2, 0)
)
val _jPerson = Json.encodeToString(_oPerson)
Log.i(TAG, "(Serialization) Person Json = ${_jPerson}")
(Serialization) Person Json = {"id":2,"name":"Droid","position":"Staff","times":{"hh":12,"mm":2,"ss":0}}
Exceptionのメッセージ、オフセットとは
以下のdeserializeは、「index:1」で処理を打ち切ったため、Exceptionが発生します。
override fun deserialize(decoder: Decoder): LogTime {
return decoder.decodeStructure(descriptor) {
var _hh = 0
var _mm = 0
var _ss = 0
decodeElementIndex(descriptor)
_hh = decodeIntElement(descriptor, 0)
decodeElementIndex(descriptor)
_mm = decodeIntElement(descriptor, 1)
// decodeElementIndex(descriptor) // 原因:index:2が未処理で終了
// _ss = decodeIntElement(descriptor, 2)
LogTime(_hh, _mm, _ss)
}
}
この時、次のようなメッセージが出力されます。
kotlinx.serialization.json.internal.JsonDecodingException: Unexpected JSON token at offset 69: Trailing comma before the end of JSON at path: $.times.mm Trailing commas are non-complaint JSON and not allowed by default. Use 'allowTrailingCommas = true' in 'Json {}' builder to support them. JSON input: {"id":1,"name":"Android","position":"Manager","times":{"hh":8,"mm":55,"ss":22}} --- オフセット69に予期しないJSONトークン。 JSONの末尾にコンマがあります。 末尾のコンマは非準拠のJSONであり、デフォルトでは許可されません。
メッセージに登場する「offset XX」は、シリアル化データ(JSONの記述)の先頭らからの文字数です。

シリアル化データ(JSONの記述)に空白や制御文字(\n,\tなど)があれば、それらの文字もoffsetに含まれるので注意して下さい。
関連記事:
