Blog Infos
Author
Published
Topics
, , , ,
Author
Published

Kotlin Serialization is a cross-platform serialization and deserialization library provided by Kotlin. It can serialize the object tree into several common formats, natively supporting Kotlin. It has strong extensibility and can satisfy almost all business scenarios. It doesn’t need to use reflection, and its performance is excellent. It can be said to be the best choice for Kotlin language serialization tools at present.

kotlinx.serialization currently supports several formats:

In most cases, we use JSON, so this article will mainly introduce the use of JSON.

Kotlin serialization is divided into two processes. The first step is to convert the object tree into a sequence composed of basic data types, and the second step is to encode and output this sequence according to the format.

Integration

Kotlin serialization tool is in a separate component.

It includes the following parts:

  • Gradle compilation plugin: org.jetbrains.kotlin.plugin.serialization
  • Runtime dependency library

First, you need to add the compilation plugin in gradle:

plugins {
    id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.23'
}

Then add the dependency library:

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.6.2")
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2")
}

Moreover, kotlinx.serialization has its own version, which is not synchronised with Kotlin. For specific version see here.

Json Encoding

The process of converting data into a specified format is known as encoding. For Kotlin serialization encoding, it is achieved using the extension function Json.encodeToString.

@Serializable
class Project(val name: String, val language: String)

fun main() {
    val data = Project("kotlinx.serialization", "Kotlin")
    println(Json.encodeToString(data))
}

Kotlin serialization does not use reflection, so the class that supports serialization/deserialization should be marked with the @Serializable annotation.

Json Decoding

The opposite process is called decoding. To decode a JSON string into an object, we will use the Json.decodeFromString extension function. To specify the result type we want to obtain, we provide a type parameter to this function.

@Serializable
data class Project(val name: String, val language: String)

fun main() {
    val data = Json.decodeFromString<Project>("""
        {"name":"kotlinx.serialization","language":"Kotlin"}
    """)
    println(data)
}
@Serializable Annotation

This annotation is used to mark a class that can be serialized, and the serialization rules are as follows:

  • Only properties with backing fields will participate in the serialization process. Proxy properties or properties with get/set will not participate.
  • The parameters in the main constructor must be object properties
  • For scenarios where data validation is required before serialization is completed, you can validate the parameters in the init block of the class.
Optional Properties

When deserializing a Json string into an object, if the Json string is missing a property in the class, the deserialization will fail. However, we can avoid this by adding a default value to the property.

@Serializable
data class Project(val name: String, val language: String = "Kotlin")

fun main() {
    val data = Json.decodeFromString<Project>("""
        {"name":"kotlinx.serialization"}
    """)
    println(data)
}
@Required

The @Required annotation indicates that the property decorated with this annotation must be non-null during the deserialization process.

If a field has a default value and we expect the JSON input during deserialization to contain this property, we can use this annotation. After using it, if the property does not exist in JSON, the deserialization will fail.

@Transient

If a property does not need to be serialized, it can be decorated with this annotation, and this property must have a default value.

By default, if a property marked with @Transient has a property with the same name in the serialized string, an error will be reported when deserializing into this object. You can use ignoreUnknownKeys = true when building Json to avoid errors. The meaning of ignoreUnknownKeys is to ignore the unknown fields in the serialized string. By default, it is false, so if the deserialized string contains extra fields, deserialization will not report an error.

Default values do not participate in serialization

If a property has a default value, and the value of this property in this object is also the default value, then this value will not participate in serialization.

@Serializable
data class User(val firstname: String, val lastname: String = "Zhang")

fun main() {
    val data = User("Ke")
    println(Json.encodeToString(data))
    // output: {"firstname":"Ke"}
}

As above, because the language property in the built data is the default value, the serialized data does not include lastname.

If you set a value for lastname that is not equal to the default value, it will participate in serialization.

fun main() {
    val data = User("Ke", "Li")
    println(Json.encodeToString(data))
    // Output: {"firstname":"Ke","lastname":"Li"}
}

Of course, there is also a way to circumvent this.

@EncodeDefault

This annotation is to solve the above problem, it can make the default value also participate in serialization.

In addition, the @EncodeDefault annotation can adjust its behavior to the opposite by using the EncodeDefault.Mode parameter.

Serial field names

Kotlin Serialization supports custom serialization and deserialization of property names, which is implemented through the @SerialName annotation.

Enum

Kotlin Serialization supports enum classes, and it is not necessary to use the @Serializable annotation on enum classes.

Of course, if you want to customize the property name after serialization, you can also add the @Serializable annotation and set it through @SerialName.

Pre-existing types

In addition to supporting basic types and String, Kotlin Serialization also pre-supports some composite data types.

  • Pair
  • Triple
  • Array
  • List
  • Set
  • Map
  • Unit
  • Singleton Class
  • Duration
  • Nothing

The serialization/deserialization of Unit and singleton class is the same. Since Unit is a singleton class by itself, their serialization contains an empty Json string.

Serializers/Serializers

As mentioned above, the process from object to basic data type is called serialization, and the serialization process is controlled by the serializer Serializer.

The several types of pre-supported serialization we introduced above provide the corresponding Serializer, or Serializer for basic types*.*

Basic type serializer

To get the basic type serializer, you can directly use the extension function.

val intSerializer: KSerializer<Int> = Int.serializer()
println(intSerializer.descriptor)
// output: PrimitiveDescriptor(kotlin.Int)
Pre-packaged serializers

To get the Kotlin pre-packaged serializers, you can use the top-level function serializer().

enum class Status { SUPPORTED }

val pairSerializer: KSerializer<Pair<Int, Int>> = serializer()
val statusSerializer: KSerializer<Status> = serializer()
println(pairSerializer.descriptor)
println(statusSerializer.descriptor)
// output: 
// kotlin.Pair(first: kotlin.Int, second: kotlin.Int)
// com.zhangke.algorithms.Status(SUPPORTED)

The serializer() function accepts a generic parameter to get the Serializer of the specified type, so in fact, we can get the Serializer of all serializable classes through this function.

Serializer generated by the compiler plugin

After adding the @Serializable annotation to a class, the compiler plugin will automatically generate the corresponding Serializer, which can be obtained through the extension function of this class object.

@Serializable
class User(val name: String)

val userSerializer:KSerializer<User> = User.serializer()
println(userSerializer.descriptor)
// output: com.zhangke.algorithms.User(name: kotlin.String)
Serializer of the generic class generated by the compiler plugin

For generic classes that support serialization, the generic type also needs to support serialization. The serializer() function of a generic class needs to pass in the Serializer of the generic type.

@Serializable
class Box<T>(val contents: T)

val userSerializer:KSerializer<Box<User>> = Box.serializer(User.serializer())
println(userSerializer.descriptor)
// output: com.zhangke.algorithms.Box(contents: com.zhangke.algorithms.User)
Collection type Serializer

The Serializer of the collection type is in these three types: ListSerializer() / MapSerializer() / SetSerializer(). You also need to pass parameters in the same way as a generic class for the Serializer of collection types.

val stringListSerializer: KSerializer<List<String>> = ListSerializer(String.serializer())
println(stringListSerializer.descriptor)
Custom Serializer

In most cases, we just need to use the annotations and preset Serializer. But sometimes we may want to control the serialization process or serialize some classes that cannot add annotations. Then we need to use a custom Serializer.

To customize the Serializer, we need to create a class that implements the KSerializer interface.

class Color(val rgb: Int)

object ColorAsStringSerializer : KSerializer<Color> {
    override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Color", PrimitiveKind.STRING)

    override fun serialize(encoder: Encoder, value: Color) {
        val string = value.rgb.toString(16).padStart(6, '0')
        encoder.encodeString(string)
    }

    override fun deserialize(decoder: Decoder): Color {
        val string = decoder.decodeString()
        return Color(string.toInt(16))
    }
}

The overridden descriptor property is the descriptor of this Serializer. You can choose to implement SerialDescriptor by yourself, or create a basic type PrimitiveSerialDescriptor as above.

Then the other two functions are self-evident, they are serialization and deserialization respectively.

Delegated Serializer

The Serializer can delegate the serialization/deserialization process to other Serializer to complete. For example, we can first convert Color into an integer array, and then delegate it to IntArraySerializer.

class ColorIntArraySerializer : KSerializer<Color> {
    private val delegateSerializer = IntArraySerializer()
    override val descriptor = SerialDescriptor("Color", delegateSerializer.descriptor)

    override fun serialize(encoder: Encoder, value: Color) {
        val data = intArrayOf(
            (value.rgb shr 16) and 0xFF,
            (value.rgb shr 8) and 0xFF,
            value.rgb and 0xFF
        )
        encoder.encodeSerializableValue(delegateSerializer, data)
    }

    override fun deserialize(decoder: Decoder): Color {
        val array = decoder.decodeSerializableValue(delegateSerializer)
        return Color((array[0] shl 16) or (array[1] shl 8) or array[2])
    }
}

After the Serializer is created, it needs to be used according to different scenarios.

Directly set on the class
@Serializable(with = ColorAsStringSerializer::class)
class Color(val rgb: Int)
Set on attributes
@Serializable
class Table(
    @Serializable(with = ColorAsStringSerializer::class) val color: Color
)
Use during serialization

Pass the Serializer as the first parameter to the Json.encodeToString function.

Json.encodeToString(ColorAsStringSerializer, Color(0xFF0000))
Set Serializer for generics

The @Serializable annotation can be used for generic types.

@Serializable
class ProgrammingLanguage(
    val name: String,
    val releaseDates: List<@Serializable(DateAsLongSerializer::class) Date>
)
Specify Serializer for file

Kotlin also allows Serializer to be set for file.

@file:UseSerializers(DateAsLongSerializer::class)

With this setting, the classes in this file will automatically apply this Serializer.

Contextual serialization

Until now, the serialization process we have above is all static. But sometimes, we need to dynamically select different Serializer for the same class.

For example, we hope that the Date class should be serialized into different standard strings, and at the same time, this depends on different interface versions. We only know which standard Serializer should be used at runtime.

In this case, we don’t need to set a specific Serializer for Date first, but add a @Contextual tag, which means this property will decide which Serializer to use based on the context in Json.

@Serializable
class ProgrammingLanguage(
    val name: String,
    @Contextual
    val stableReleaseDate: Date
)

In order to provide context, we need to create a SerializersModule instance. It describes which Serializer should be used to serialize those classes marked with Contextual at runtime.

private val module = SerializersModule {
    contextual(Date::class, DateAsLongSerializer)
}
val format = Json { serializersModule = module }

Now, the context module information is stored in the format object. As long as this format object is used, the Date class above will use DateAsLongSerializer serialization.

After setting, you can use the format object for serialization.

fun main() {
    val data = ProgrammingLanguage("Kotlin", SimpleDateFormat("yyyy-MM-ddX").parse("2016-02-15+00"))
    println(format.encodeToString(data))
}
Serialization of Polymorphic Classes

For the polymorphism of classes, which refers to classes with inheritance relationships, there are some issues encountered during serialization. Kotlin Serialization provides some solutions for these issues too.

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

, ,

Migrating to Jetpack Compose – an interop love story

Most of you are familiar with Jetpack Compose and its benefits. If you’re able to start anew and create a Compose-only app, you’re on the right track. But this talk might not be for you…
Watch Video

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engineer for Jetpack Compose
Google

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engin ...
Google

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engineer f ...
Google

Jobs

Sealed Classes

One solution is to use a sealed class. Kotlin serialization supports sealed class serialization, all we need to do is to add the @Serializable annotation.

@Serializable
sealed class Project {
    abstract val name: String
}
            
@Serializable
class OwnedProject(override val name: String, val owner: String) : Project()

fun main() {
    val data: Project = OwnedProject("kotlinx.coroutines", "kotlin")
    println(Json.encodeToString(data)) // Serializing data of compile-time type Project
}

// output: {"type":"com.zhangke.OwnedProject","name":"kotlinx.coroutines","owner":"kotlin"}

As we can see, to solve the serialization issue of polymorphic classes, Kotlin add a type field in the serialized content to represent the specific type of the object. During deserialization, it creates the corresponding object based on this type.

Of course, the value of the type field can be customized. Sometimes, you might not want to use the default package name. You can choose to customize it.

@Serializable
@SerialName("owned")
class OwnedProject(override val name: String, val owner: String) : Project()
Registering Subclasses

Besides using a sealed class, you can also serialize polymorphic classes by registering subclasses.

To register a subclass means that you need to build a serialization Module in Json and provide the correlation between the interface and subclasses. This tips the serializer to which subclasses to choose for serialization.

@Serializable
abstract class Project {
    abstract val name: String
}

@Serializable
@SerialName("owned")
class OwnedProject(override val name: String, val owner: String) : Project()

@Serializable
class JavaProject(override val name: String): Project()

val module = SerializersModule {
    polymorphic(
        baseClass = Project::class,
        actualClass = OwnedProject::class,
        actualSerializer = serializer(),
    )
    polymorphic(
        baseClass = Project::class,
        actualClass = JavaProject::class,
        actualSerializer = serializer(),
    )
}

val format = Json { serializersModule = module }

fun main() {
    val list = listOf(
        OwnedProject("kotlinx.coroutines", "kotlin"),
        JavaProject("sun.java")
    )
    println(format.encodeToString(list))
}
// output: [{"type":"owned","name":"kotlinx.coroutines","owner":"kotlin"},{"type":"com.zhangke.algorithms.JavaProject","name":"sun.java"}]
Interface Serialization

The way of registering subclasses can deal with the scenario of abstract classes, but interfaces still can’t be serialized because the @Serializable annotation is not allowed to be added on the interface. However, Kotlin serialization uses PolymorphicSerializer strategy to implicitly serialize the interface. It indicates that we do not need to add the @Serializable annotation to the interface. We just need to register the subclasses the same way we did before.

interface Project {
    val name: String
}

@Serializable
@SerialName("owned")
class OwnedProject(override val name: String, val owner: String) : Project

@Serializable
class JavaProject(override val name: String) : Project

val module = SerializersModule {
    polymorphic(
        baseClass = Project::class,
        actualClass = OwnedProject::class,
        actualSerializer = serializer(),
    )
    polymorphic(
        baseClass = Project::class,
        actualClass = JavaProject::class,
        actualSerializer = serializer(),
    )
}

val format = Json { serializersModule = module }

fun main() {
    val list = listOf(
        OwnedProject("kotlinx.coroutines", "kotlin"),
        JavaProject("sun.java")
    )
    println(format.encodeToString(list))
}
// output: [{"type":"owned","name":"kotlinx.coroutines","owner":"kotlin"},{"type":"com.zhangke.algorithms.JavaProject","name":"sun.java"}]
Json Configuration

Previously, we discussed the role of the serializer as part of the serialization process. Now, let’s start talking about the encoding process, specifically in regards to Json encoding.

Json encoding/decoding is performed through the Json class in kotlinx. We can directly call Json to get a globally unique standard Json object, or we can construct a Json object of our own.

Since there may be internal caching within the Json class, considering potential performance issues, it is recommended to save and reuse built Json objects, and avoid creating a new one for each use.

Output Formatting

By default, Json outputs as a single-line string. However, you can make it output in a more readable Json format by setting prettyPrint to true.

val format = Json { prettyPrint = true }

Now it will output a tidy Json string:

{
    "name": "kotlinx.serialization",
    "language": "Kotlin"
}
Lenient Parsing

By default, Json will parse Json according to strict standards, such as mandatory quotation marks for keys, restrictions for integer and string types, etc. However, lenient mode can be activated by setting isLenient = true.

In this mode, quoted values can be parsed as integers if their corresponding type in Kotlin object is an integer, and keys can also be left unquoted.

Ignoring Unknown Keys

By default, Json will return an error if it encounters unknown keys during the deserialization process. For instance, If there is an ‘id’ field in the Json string but no ‘id’ attribute in the target class being deserialized, an error will be triggered.

By setting ignoreUnknownKeys = true, these errors can be avoided, allowing for normal parsing.

Replacing Json Names

As we mentioned above, @SerialName can be used to set a name for a field in Json. However, using it means the field’s original name can no longer be parsed. To resolve this issue, you can use the @JsonNames annotation. It allows for multiple names to be set for a field, and the field’s original name can still be parsed.

@Serializable
data class Project(@JsonNames("title") val name: String)

fun main() {
  val project = Json.decodeFromString<Project>("""{"name":"kotlinx.serialization"}""")
  println(project)
  val oldProject = Json.decodeFromString<Project>("""{"title":"kotlinx.coroutines"}""")
  println(oldProject)
}

Also, support for the @JsonNames annotation is controlled by the JsonBuilder.useAlternativeNames flag. Unlike most configuration flags, this flag is enabled by default.

Forcing Use of Default Values

By using coerceInputValues = true, certain invalid inputs can be converted to default values.

Currently, only the following two types of invalid input are supported:

  • Null input for non-nullable values.
  • Unknown values for enums.

This implies that during deserialization, if a certain field in a class has a default value and the corresponding field in the Json string satisfies one of the two conditions above, the property’s default value will be used for deserialization.

val format = Json { coerceInputValues = true }

@Serializable
data class Project(val name: String, val language: String = "Kotlin")

fun main() {
    val data = format.decodeFromString<Project>("""
        {"name":"kotlinx.serialization","language":null}
    """)
    println(data)
}
// output: Project(name=kotlinx.serialization, language=Kotlin)
Displaying Null Values

By default, null values will also be encoded into Json. By setting explicitNulls = false, you can prevent null values from being serialized into Json.

Structured Json Keys

JSON format itself does not support structured keys, which generally can only be strings.

However, nonstandard support for structured keys can be enabled through the allowStructuredMapKeys = true property.

val format = Json { allowStructuredMapKeys = true }

@Serializable
data class Project(val name: String)

fun main() {
    val map = mapOf(
        Project("kotlinx.serialization") to "Serialization",
        Project("kotlinx.coroutines") to "Coroutines"
    )
    println(format.encodeToString(map))
}

Maps with structured keys are represented as JSON arrays containing the following items: [key1, value1, key2, value2,...].

[{"name":"kotlinx.serialization"},"Serialization",{"name":"kotlinx.coroutines"},"Coroutines"]
Others

Furthermore, additional settings are available in Json, suitable for a rich variety of scenarios.

  • allowSpecialFloatingPointValues – Using special floating-point values like NaN and infinity.
  • classDiscriminator – Setting the key name of the types for polymorphic data.
  • decodeEnumsCaseInsensitive – Decoding enums in a case-insensitive manner.
  • namingStrategy – Global naming strategy. Using JsonNamingStrategy.SnakeCase can parse fields from camel case to snake case.
Json Element

As well as being a tool for encoding/decoding, Json also exposes its internal JsonElement classes and tools for use.

The JsonElement object can be parsed using the Json.parseToJsonElement function.

The content within the JsonElement class is almost identical to that in Gson and most other Json tools, so we won’t elaborate on them here.

Json Element Builder

Json provides several DSLs to build JsonElement.

fun main() {
    val element = buildJsonObject {
        put("name", "kotlinx.serialization")
        putJsonObject("owner") {
            put("name", "kotlin")
        }
        putJsonArray("forks") {
            addJsonObject {
                put("votes", 42)
            }
            addJsonObject {
                put("votes", 9000)
            }
        }
    }
    println(element)
}

Then, the Json.decodeFromJsonElement function can be used to deserialize it directly into an object.

@Serializable
data class Project(val name: String, val language: String)

fun main() {
    val element = buildJsonObject {
        put("name", "kotlinx.serialization")
        put("language", "Kotlin")
    }
    val data = Json.decodeFromJsonElement<Project>(element)
    println(data)
}
Json Transformations

Json offers the capability of customizing the encoding/decoding of Json data, which can affect the resultant Json content after serialization.

For example, let’s consider a User class. We would like to have a ‘time’ field in the serialized Json data that denotes the time of serialization.

@Serializable
class User(
    val name: String,
    val age: Int,
)

object UserSerializer : JsonTransformingSerializer<User>(User.serializer()) {
    override fun transformSerialize(element: JsonElement): JsonElement {
        return buildJsonObject {
            element.jsonObject.forEach { key, value ->
                put(key, value)
            }
            put("time", System.currentTimeMillis())
        }
    }
}
fun main() {
    val user = User("zhangke", 18)
    println(format.encodeToString(UserSerializer, user))
}
// output: {"name":"zhangke","age":18,"time":1711556475153}

In the above example, we first define UserSerializer. Then, in the returned JsonObject, we first add the original fields and then add a time field.

That’s it for now. More detailed content can be accessed on the official website.

This article is previously published on proandroiddev.com

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
Hi, today I come to you with a quick tip on how to update…
READ MORE
blog
Automation is a key point of Software Testing once it make possible to reproduce…
READ MORE
blog
Drag and Drop reordering in Recyclerview can be achieved with ItemTouchHelper (checkout implementation reference).…
READ MORE

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu