Kotlin 的序列化工具

Photo by Robert Bye on Unsplash

Json (JavaScript Object Notation)

Json 格式是用來讓 Server 與 Client 溝通的方式之一,而在多數的網站 Api 中,至少都會以 Json 作為發送的格式,之所以會那麼多地方使用,我想一方面是跟它的格式有關。 它是採用鍵-值(key-value) 的方式儲存數值,以及它的樣式是讓人類容易讀的,所以當我們取得一筆用 Json 編碼的資料時,不需任何的轉碼,我們就可以輕鬆的知道它的內容是如何。

如下方的 Json 格式:

{
"totalResults": 1,
"resultPage": [
{
"entityType": "performance",
"uri": "https://secondhandsongs.com/performance/25405",
"title": "Something in the Way",
"performer": {
"uri": "https://secondhandsongs.com/artist/169",
"name": "Nirvana [US]"
},
"isOriginal": true
}
],
"skippedResults": 0
}

上方的格式中,外層的 Json 有三個元素,分別是 totalResultsresultPageskippedResults ,而 resultPage 是一個陣列,儲存其他相同的 Json 物件。

Android 中如何處理 Json ?

上方的 Json 格式其實就只是一串字串,假設我們使用某種技術把這段 Json 字串下載到了我們的 App,我們該如何處理呢?

在 Android 中,提供了 JSONObject 的這個物件,我們可以將這串字串傳入 JSONObject 中,那麼我們就可以取得一個 JSONObject 物件。如下:

val jsonString = """
{
"totalResults": 1,
"resultPage": [
{
"entityType": "performance",
"uri": "https://secondhandsongs.com/performance/25405",
"title": "Something in the Way",
"performer": {
"uri": "https://secondhandsongs.com/artist/169",
"name": "Nirvana [US]"
},
"isOriginal": true
}
],
"skippedResults": 0
}
"""
val jsonObject = JSONObject(jsonString)

有了 JSONObject 之後,我們該如何使用呢?還記得我們前面提到 Json 的是以鍵-值的方式儲存,所以當轉換成 JSONObject 之後,我們同樣可以使用鍵-值的方式來存取,如下:

val totalResults = jsonObject["totalResults"]
val resultPage = jsonObject["resultPage"]
val skippedResults = jsonObject["skippedResults"]

很容易,不是嗎?除了使用這種方式之外, JSONObject 還提供了另一種方式 getXXX() ,當我們知道要取出的元素是什麼類型時,我們就可以用下面的方式來取用:

val totalResults = jsonObject.getInt("totalResults")
val resultPage = jsonObject.getJSONArray("resultPage")
val skippedResults = jsonObject.getInt("skippedResults")

無論你選用哪一種方式,首先會遇到的第一個問題就是當名稱打錯的時候,JSONObject 沒有辦法在你寫程式的時候就發現你寫錯了,只有當執行的時候,它才會發現你傳進來的值是不存在的,這時候就會報出 JSONException

第二個問題是,如果我們直接轉成 JSONObject,在使用的時候就會只知道這是一個 JSONObject ,如果它裡面塞的不是我們所預期的 Json 字串,就無法正確的使用。

第三個問題是,當我們在單元測試中試圖測試 JSONObject 時,我們將會發現 1. 用相同的內容建立兩個的 JSONObject,用 assertEquals 判斷兩個物件是否相等時,結果會是錯的,因為每次建立一個 JSONObject 就會指向一個新的位置,用 assertEquals 比對時,就會出現錯誤。 2. 在單元測試中,如果嘗試取得 JSONObject 裡的元素,在執行測試的時候,會直接不留情的發生錯誤。

@Test
internal fun Json_getInt() {
val jsonObject = JSONObject(SampleJson.jsonString)
val expected = JSONObject(SampleJson.jsonString)
assertEquals(expected.getInt("totalResults"),
jsonObject.getInt("totalResults"))
}
單元測試中,存取 JSONObject 的內容會發生錯誤

對這個錯誤有興趣的可以查看 http://g.co/androidstudio/not-mocked

序列化工具

前面介紹了 Json 格式 在 Android 上會發生的問題,我們有沒有什麼方法可以避免呢?答案當然是有的,我們可以使用序列化工具,來將 Json 字串轉換成 Kotlin 的物件而不是 JSONObject,另一方面,我們也可以將 Kotlin 的物件轉換成 Json 的字串。

GSON

第一個要介紹的是, Google 在 2008 年所推出的序列化庫(library) — GSON。在 2008 年 的時候,Android 的官方語言是 Java,所以自然的 GSON 是設計給 Java 使用,由於 Kotlin 能夠 100% 使用 Java 的庫,所以自然 GSON 也可以支援。但是,GSON 卻沒有針對 Kotlin 的特性來修改,所以 GSON 在 Kotlin 的合適度上就沒有那麼高了。

我們看看要怎麼使用 GSON 來序列化 Json 字串。

因為要把 Json 字串轉換成 Kotlin 的類別,所以第一步我們所需要的就是建立一個用來存放 Json 字串內容的新類別。

Step1 : 建立資料類別

在 Android / IntelliJ 中,我們可以使用 JSON To Kotlin Class 這個套件協助我們快速的將 Json 字串轉換成 data class。(當然我們也可以自己刻)

— 安裝過程省略 —

安裝完畢之後,我們可以在新增檔案的時候,發現有一個 Kotlin data class File from JSON 可以選,選擇之後,將我們需要轉換的 Json 字串填入,就可以自動產生相關的 data class。

按下 Generate 之後, 就會自動產生出相對應的 data class。如下:

  • Performance.kt
data class Performance(
val resultPage: List<ResultPage>,
val skippedResults: Int,
val totalResults: Int
)
  • ResultPage.kt
data class ResultPage(
val entityType: String,
val isOriginal: Boolean,
val performer: Performer,
val title: String,
val uri: String
)
  • Performer.kt
data class Performer2(
val name: String,
val uri: String
)

Step 2: 利用 GSON 將 Json 字串轉成 Performance 類

val gson = Gson()
val performance = gson.fromJson(jsonString, Performance::class.java)
  • GSON 提供了 fromJson() 這個函式,可以讓我們將 Json 字串轉換成我們所指定的類別。
  • fromJson() 第一個參數所帶入的是 Json 字串,第二個則是利用反射的方式指定要轉換成的類別。

測試:

  1. 比對相同的 Json 字串產生出的兩個物件
@Test
internal fun `same object call fromJson should equals`() {
val gson = Gson()
val performance = gson.fromJson(jsonString, Performance::class.java)
val expected = gson.fromJson(jsonString, Performance::class.java)
assertEquals(expected, performance)
}

⇒ 正確

2. 比對物件內的屬性 (比對 resultPage[0].title)

@Test
internal fun `same objects title should equals`() {
val gson = Gson()
val performance = gson.fromJson(jsonString, Performance::class.java)
val expected = gson.fromJson(jsonString, Performance::class.java)
assertEquals(expected.resultPage[0].title, performance.resultPage[0].title)
}

⇒ 正確,與 JSONObject 不同,可以直接取出類別內的其他屬性來比對

3. 當有一個元素與不存在時,會是什麼值?

val noResultPage = """
{
"totalResults": 1,
"skippedResults": 0
}
""".trimIndent()
@Test
internal fun `no data should return empty list`() {
val gson = Gson()
val performance = gson.fromJson(noResultPage, Performance::class.java)
assertEquals(emptyList<ResultPage>(), performance.resultPage)
}

⇒ 這邊回傳了 null 值。

我們嘗試將 Performance 類加上預設值

因為前面範例中,當沒有 resultPage 的元素時,resultPage 的結果會是 null,縱使我們並沒有設定 resultPage 為 nullabele (可空類型)。

data class Performance(
val resultPage: List<ResultPage>,
val skippedResults: Int,
val totalResults: Int
)

那麼,我們將 resultPage 加上預設值試試看,

data class Performance(
val resultPage: List<ResultPage> = emptyList(),
val skippedResults: Int,
val totalResults: Int
)

再跑一次前面的測試,結果還是一樣

由此我們可以知道, GSON 不支援 Kotlin 的非空型別以及預設值

Moshi

Moshi 是由 Square 團隊所推出的一套序列化工具,Square 大家應該都很熟悉,著名的 Retrofit、Okhttp 就是他們的。

Moshi 在 2015 年釋出 1.0.0 版,一直到今年都持續在釋出新版(1.12.0)

Moshi is a modern JSON library for Android and Java. It makes it easy to parse JSON into Java objects.

在 Kotlin 上,Moshi 除了可以使用反射外,還可以使用註解,前者需要引入一包 Kotlin 的反射庫,後者則是需要加入另一個 denpendency,我們將使用註解的方式。

Dependency

dependencies {
implementation("com.squareup.moshi:moshi-kotlin:1.12.0")
kapt("com.squareup.moshi:moshi-kotlin-codegen:1.12.0")
}

要使用註解的方式轉換 JSON ,我們必須要使用 moshi-kotlin-codegen 這個套件,並且在 Kotlin 的 KAPT (Kotlin annotation processor tool) 上執行。

如何使用?

因為我們使用 codegen ,所以我們可以在我們的 data class 上加上註解,如下:

  • Performance.kt
@JsonClass(generateAdapter = true)
data class Performance(
val resultPage: List<ResultPage> = emptyList(),
val skippedResults: Int,
val totalResults: Int
)

*注意:這邊的 resultPage 已經提供了一個預設值:emptyList()

  • ResultPage.kt
@JsonClass(generateAdapter = true)
data class ResultPage(
val entityType: String,
val isOriginal: Boolean,
val performer: PerformerM,
val title: String,
val uri: String
)
  • Person.kt
@JsonClass(generateAdapter = true)
data class PersonM(
val firstName: String,
val lastName: String
)

其中 @JsonClass(generateAdapter = true) 在編譯的時候,就會自動幫我們產生此類別的 Adapter。

Json → Object

1. 比對兩個相同 Json 字串產生的物件

@Test
internal fun `same contents should returns equals`() {
val moshi = Moshi.Builder().build() //1
val jsonAdapter = moshi.adapter(Performance::class.java) //2
val result = jsonAdapter.fromJson(jsonString.secondHand) //3
val expected = jsonAdapter.fromJson(jsonString.secondHand)
assertEquals(expected, result)
}

⇒ 正確

說明:

  1. Moshi 提供了 Builder 來產生 Moshi 類。
  2. 將 Adapter 經由 adapter() 帶入。
  3. 最後使用 fromJson() 將 Json 字串轉換成物件。

2. 當其中一個元素不存在時,結果是預設值嗎?

val noResultPage = """
{
"totalResults": 1,
"skippedResults": 0
}
""".trimIndent()
@Test
internal fun `empty list should returns emptyList`() {
val moshi = Moshi.Builder().build()
val jsonAdapter = moshi.adapter(Performance::class.java)
val result = moshiHelper.fromJson(
noResultPage,
PerformanceM::class.java
)
assertEquals(emptyList<ResultPage>(), result?.resultPage)
}

⇒ 正確

我們在前面使用 GSON 的測試中,當 Json 內的元素少一個時,預設是會把那一個缺少的元素設為 null,縱使我們在 data class 中設定預設值也沒有作用。

而我們在 Moshi 的這個測試中,我們同樣的少了一個元素,但是回傳的值卻是我們設定的預設值,也就是說在 Moshi 中 Kotlin 的屬性預設值是有效的。

3. 是否支援 Nullable 的型別?

我們先把 Performance 類稍微修改一下,將原本的預設值改為 nullable 的型別。

@JsonClass(generateAdapter = true)
data class Performance(
val resultPage: List<ResultPage>?,
val skippedResults: Int,
val totalResults: Int
)

再跑一次測試,

@Test
internal fun `empty list should returns emptyList`() {
val moshi = Moshi.Builder().build()
val jsonAdapter = moshi.adapter(Performance::class.java)
val result = moshiHelper.fromJson(
noResultPage,
PerformanceM::class.java
)
assertEquals(emptyList<ResultPage>(), result?.resultPage)
}

好的,我們可以發現,當少了一個元素時,轉換出來的值就會是 null ,所以如我們所預期的。

小結

使用 Moshi 我們可以用 Kotlin 的 KAPT 來自動建立 adapter ,使用註解建立 adapter 的好處在於,我們是在編譯的時候 Moshi 才會依據我們的類別來建立 adapter,而不需要使用 Kotlin 反射的方式來轉換類別。(Kotlin 反射包約有 2M)

除了可以自動產生 adapter ,Moshi 還可以使用 Kotlin 的屬性預設值,當我們傳進來的 Json 少了某個元素時,若我們在 data class 中有設定其預設值,那麼他就會回傳該元素的預設值,而不是 null。同樣的,我們也可以使用 nullable 的型別,在沒有元素傳進來的時候可以給予 null 值。

Kotlinx.serializaion

想要在 Kotlin 上使用序列化工具將 Json 字串轉換成 Kotlin 的類別,除了 Square 所開發的 Moshi 之外,Kotlin 自家也推出了一款名為 serialization 的序列化工具,由於它是 Kotlin 推出的,不用想也知道他一定支援 Kotlin。XD

Kotlinx.serialization 我在這邊簡稱 KS,它在去年 10月才正式推出 1.0.0 (超級新),而到了文章發布之時,目前也才進展到 1.3.0,到目前為止,它能夠支援的序列化格式就只有 Json ,而其他的如:ProtoBuf、HOCON、CBOR、Properties 都還正在實驗階段,想要轉換其他格式的朋友,可能還需要再等等。

用 KS 的好處除了可以高度與 Kotlin 支援外,還有另外一個特性值得說一下, KS 可以用在不同的平台上如:JVM、JS 以及 Native。Kotlin 這一陣子應該是蠻積極的在開發跨平台的方案,如果使用 KS,那就可以無縫接軌了。

如何使用?

首先,要先裝進專案中,

Gradle Plugins DSL:

  • Kotlin DSL:
plugins {
kotlin("jvm") version "1.5.31" // or kotlin("multiplatform") or any other kotlin plugin
kotlin("plugin.serialization") version "1.5.31"
}
  • Groovy DSL:
plugins {
id 'org.jetbrains.kotlin.multiplatform' version '1.5.31'
id 'org.jetbrains.kotlin.plugin.serialization' version '1.5.31'
}

Dependencies

  • Kotlin DSL:
repositories {
mavenCentral()
}
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.0")
}
  • Groovy DSL:
repositories {
mavenCentral()
}
dependencies {
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.0"
}

接下來,我們將原本的類別改寫一下:

  • Performance
@Serializable
data class Performance(
val resultPage: List<ResultPage> = emptyList(),
val skippedResults: Int,
val totalResults: Int
)
  • ResultPage
@Serializable
data class ResultPage(
val entityType: String,
val isOriginal: Boolean,
val performer: Performer,
val title: String,
val uri: String
)
  • Performer
@Serializable
data class PerformerS(
val name: String,
val uri: String
)

KS 與 Moshi 相同,都是使用註解的方式來設定,我們在類別的上方加上 @Serializable 表示這個類別是可以被序列化的。

類別準備好了,下一步我們就可以測試 Json 的轉換

Json → Object

1. 兩個相同的 Json 產生的物件應相同:

@Test
internal fun `same contents should returns equals`() {
val result = Json.decodeFromString<PerformanceS>(jsonString)
val expected = Json.decodeFromString<PerformanceS>(jsonString)
assertEquals(expected, result)
}

⇒ 正確。

在 KS 中,是利用 Json.decodeFromString(string) 來把 Json 字串轉成 Kotlin 物件的。而我們在這邊是使用角括弧來告訴 decodeFromString(string) 該轉換成什麼類別。對比 Moshi 以及 Gson 的寫法,是不是看起來比較容易呢?

2. 缺少的元素,是否為預設值:

val noResultPage = """
{
"totalResults": 1,
"skippedResults": 0
}
""".trimIndent()
@Test
internal fun `no list item should returns emptyList`() {
val result = Json.decodeFromString<PerformanceS>(noResultPage)
assertEquals(emptyList<Performance>(), result.resultPage)
}

⇒ 正確。

的確如我們所預期,當有元素不存在時, KS 將會依照類別所定義的預設值來給值。

3. 是否支援 nullable 型別

@Test
internal fun `no list item should returns emptyList`() {
val result = Json.decodeFromString<PerformanceS>(noResultPage)
assertEquals(null, result.resultPage)
}

這邊就會報出 exception

我想,Kotlin 設計的理念是想要減少 null 所帶來的影響,這邊我的推斷是,當少一個值的時候,取而代之的是一個預設值而不要用 null 來回傳,所以這邊不允許我們用 nullable 的型別來接這個元素。

小結

由 Kotlin 自家所推出的序列化工具,雖然從 1.0.0 版的推出到現在才短短一年的時間,如果使用者有嘗試使用 Kotlin 其他的套件來開發其他應用,如 KMM、Ktor… KS 都可以在上面做使用,因為 KS 最大的賣點我認為就是它是一個跨平台的序列化工具,它可以根據我們所在的平台編譯成不同的程式碼,所以學習一套工具,就可以在 Kotlin 宇宙上翱翔。

當然別忘了,這套工具還年輕,還有很多部分都還在實驗階段,如果你所想要支持的格式尚未到正式版,可以先等等。

總結

本篇文章介紹了序列化工具,不管是開發 App 也好,開發 Web 也罷,都是很常使用到的工具,本篇文章我們介紹了三個工具 Gson、Moshi 以及 Kotlinx.serialization(KS),我們可以依據需求來選擇適當的工具。

當然,如果以開發 Kotlin 的專案來說, Gson 不支援 Kotlin 的屬性預設值,如果沒有注意,我們很可能就踩進這個坑裡面,讓自己不要跌入這個坑中,我們可以選擇對 Kotlin 友善的工具,如 Moshi 以及 KS,它們都是採用註解的方式來將類別設定為可以讓 Json 轉換的類別,並且也都支援屬性的預設值,不過這兩個有個小小的差異,那就是 Moshi 是支援 nullable 的型別,而 KS 不支援。

我們知道 Kotlin 一個設計理念是要減少 null 對程式造成的影響,這一點我們從 KS 就可以知道,因為不讓我們使用 nullable 的型別,它只允許我們使用預設值。

最後,如果你的專案還是用 Java 開發的,選擇 Gson;如果是用 Java 以及 Kotlin 都有,那可以選擇 Moshi,如果只有 Kotlin ,則可以選擇 Moshi 或是 KS。不過如果你有跨平台的需求,哪不用說了,一定就是選擇 KS。

謝謝大家。

本篇文章如果有幫助到你,請拍手👏鼓勵我吧~

--

--

Android/Flutter developer, like to learn and share.

Love podcasts or audiobooks? Learn on the go with our new app.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Andy Lu

Andy Lu

Android/Flutter developer, like to learn and share.