1979

Самые элегантные способы использования Gson + Kotlin

Пишете под Android и работаете с JSON? Рассказываем, как вернуть null-безопасность и значения по умолчанию в тандеме Gson + Kotlin.

Использование Kotlin и JSON разбирали в нескольких статьях и источниках. Прежде всего, смотрите библиотеки JSON в списке Awesome-Kotlin. Кроме того, в статьях, подобных этой, рассказывается, как выполнять обработку классов данных Kotlin с JSON. Автор использует Moshi с простой поддержкой Kotlin.

Задача

К чему сводится задача использования Kotlin и JSON: использовать классы данных Kotlin для краткого кода, ненулевых типов для null-безопасности и аргументов по умолчанию для конструктора классов данных, когда поле отсутствует в заданном JSON. И также, вероятно, не помешали бы явные исключения, когда сопоставление полностью не выполняется (обязательное поле отсутствует). Ещё желательно добиться сведения к нулю накладных расходов на автоматическое преобразование объектов в JSON и наоборот. На Android также нужен маленький размер APK-файла, что означает сокращение количества зависимостей и небольших библиотек. Следовательно:

  1. Не будем использовать Android org.json, потому что у него ограниченные возможности и нет функций преобразования.
  2. Насколько известно, для использования описанных свойств Kotlin, таких как null-безопасность и аргументов по умолчанию, все библиотеки с поддержкой Kotlin применяют kotlin-reflect, размер которой составляет 2 Мб. Следовательно, это не вариант.
  3. У вас может не быть возможности использовать такую ​​библиотеку, как Moshi, с интегрированной поддержкой Kotlin, потому что в проекте уже используется популярная библиотека Gson или Jackson.

Пример

Дальше рассмотрим способ использования обыкновенной библиотеки Gson (Kotson добавляет только синтаксический сахар, а не функциональность) с классами данных Kotlin и наименьшими возможными накладными расходами для достижения сопоставления JSON с классами данных Kotlin с null-безопасностью и значениями по умолчанию.

В идеале нужно следующее:

                    data class Article(val title: String = "", val body: String = "", 
val viewCount: Int = 0, val payWall: Boolean = false, val titleImage: String = "") 
     
        

Затем сопоставляем наш JSON-пример с помощью Gson.

                    val json = """
   { "title": "Самый элегантный способ использования Gson + Kotlin со значениями по умолчанию 
               и null-безопасностью",
     "body": null,
     "viewCount": 9999,
     "payWall": false,
     "ignoredProperty": "Ignored"
   }
"""
val article = Gson().fromJson(json, Article::class.java)
println(article)
// Ожидаемый вывод:
// Article(title=Самый элегантный способ использования Gson + Kotlin со значениями по умолчанию
 и null-безопасностью, body=, viewCount=9999, payWall=false, titleImage=) 
     
        

Как и предполагалось, дополнительные свойства JSON игнорируются, когда они не часть класса данных. Что НЕ работает, так это аргументы по умолчанию внутри конструктора класса данных.

Кроме того, если не указывать значение (titleImage) или задать явно null-значение (body), в результирующем объекте типа Article всё равно будут пустые значения. Если учесть предположение разработчика о null-безопасности при использовании ненулевых типов, это приведёт к NullPointerException во время выполнения без подсказок IDE о возможных нулевых значениях. Даже не получим исключение при парсинге, потому что Gson использует небезопасную рефлексию, а в Java нет понятия ненулевых типов.

Решение

Первый способ бороться с этим – сдаться и сделать всё обнуляемым:

                    data class Article(val title: String?, val body: String? = null, val viewCount: Int = 0, 
val payWall: Boolean = false, val titleImage: String? = null) 
     
        

Для примитивных типов полагаемся на их значения по умолчанию (отсутствующие Intбудут равны 0, Boolean будут ложью). Другие объекты, такие как строки, должны быть обнуляемыми. Рассмотрим решение получше.

Улучшение

Ещё сделаем акцент на полное отсутствие аннотаций, необходимых для десериализации с помощью Gson, что неприятно. Но аннотация @SerializedName() приходит на помощь:

                    data class Article(
  @SerializedName("title") private val _title: String?, 
  @SerializedName("body") private val _body: String? = "", 
  val viewCount: Int = 0, 
  val payWall: Boolean = false, 
  @SerializedName("titleImage") private val _titleImage: String? = ""
) {
  val title
    get() = _title ?: throw IllegalArgumentException("Требуется заголовок")
  val body
    get() = _body ?: ""
  val titleImage
    get() = _titleImage ?: ""
  init {
    this.title
  }
} 
     
        

Так что у нас здесь? Для каждого примитивного типа определяем его, как прежде. Если примитив также допускает значение null (со стороны сервера), обрабатываем его, как и другие свойства.

По-прежнему предоставляем значения по умолчанию внутри конструктора для случая, когда создаём экземпляр объекта напрямую, а не из JSON. Они НЕ будут работать во время преобразования из JSON, как было сказано ранее. Для этого аргументы конструктора делайте приватными вспомогательными свойствами (начинающимися с подчёркивания), но сохраняйте имя свойства для Gson таким же, как и раньше (с использованием аннотации). Затем добавьте свойство только на чтение для каждого вспомогательного поля с настоящим именем и используйте get() = в сочетании с элвис-оператором для определения значения по умолчанию или поведения, которое приводит к ненулевым возвращаемым значениям.

Очевидно, что такое решение всё ещё страдает многословностью и воскрешает в памяти навязчивые воспоминания о Java-бинах. Но! Это нужно только для не примитивов и проще написания собственного парсера.

Чтобы проверить полученный объект, вызывайте каждое обязательное свойство в блоке init. Если у вспомогательного свойства значение null, выдаётся исключение (более элегантные решения, например, которые обнуляют объект полностью, потребуют дополнительной работы). В качестве альтернативы используйте универсальную TypeAdapterFactory для постобработки вместо вызовов в блоке init.

Будущие изменения

Это самый элегантный способ использования Gson с Kotlin, чтобы добиться описанного поведения, который также упрощает получение такой функциональности в целом (даже при свободном выборе библиотеки), поскольку нет необходимости в библиотеке kotlin-reflect. Хотя в будущем наверняка появятся и лучшие решения, когда будет использоваться библиотека Kotlin Reflect Lite, или Gson добавит встроенную поддержку Kotlin.

С 16 мая 2018 года Moshi полностью поддерживает интеграцию с Kotlin посредством сгенерированного кода, избавляя от необходимости в библиотеке kotlin-reflect. Если возможно, сделайте переключение. Как увидите в посте на Medium, сгенерированный код делает те же вещи, как здесь, но вам ничего не приходится писать. Эта статья остаётся полезной для разработчиков, которые связаны с использованием библиотеки Gson.

Ещё более простой способ использования Gson + Kotlin

Классы данных Kotlin впечатляют краткостью и null-безопасностью. Gson также великолепен; это фактический стандарт для анализа JSON на Android по уважительной причине. Но! Классы данных, анализируемые через Gson, не гарантируют ни null-безопасность, ни даже значения по умолчанию. Или всё же гарантируют?

Проблема

С подробным описанием проблемы ознакомились выше. TL;DR, если вы объявили такой класс данных:

                    data class SomeData(
    val intValue: Int,
    val strValue: String = "default value"
) 
     
        

значения по умолчанию будут игнорироваться, поэтому JSON наподобие {"intValue":2}будет выдавать объект {intValue=2,srtValue=null} вместо ожидаемого {intValue=2,srtValue="default"}.

Хорошая новость в том, что есть способ это исправить.

Решение

Немного изменим наш класс данных:

                    data class SomeData(
    val intValue: Int = 0,
    val strValue: String = "default value"
) 
     
        

Обратили внимание на маленькое отличие? Этот класс определяет значения по умолчанию для всех полей. Вот и всё! Как только у всех полей есть значения по умолчанию, Gson признаёт их, когда соответствующие поля отсутствуют в JSON. Если вам не нужны значения по умолчанию, используйте null-значения.

Почему это работает?

Причина

Это сводится к двум особенностям и различиям Java и Kotlin.

1. Перегрузка методов Java в Kotlin.

Сравните два класса:

                    data class WithoutDefaultConstructor(
    val intValue: Int,
    val strValue: String = "default"
)

data class WithDefaultConstructor(
    val intValue: Int = 1,
    val strValue: String = "default"
) 
     
        

Байт-код для них приведёт к следующему Java-коду с удалёнными несущественными частями:

                    public final class WithoutDefaultConstructor {
   private final int intValue;
   @NotNull
   private final String strValue;

   public WithoutDefaultConstructor(int intValue, @NotNull String strValue) {
      super();
      this.intValue = intValue;
      this.strValue = strValue;
   }

   public WithoutDefaultConstructor(int intValue, String strValue, int defaultParametersFlags, 
                                    DefaultConstructorMarker defaultConstructorMarker) {
      if ((defaultParametersFlags & 2) != 0) {
         strValue = "default";
      }

      this(intValue, strValue);
   }
   //... другие методы
}


public final class WithDefaultConstructor {
   private final int intValue;
   @NotNull
   private final String strValue;

   public WithDefaultConstructor(int intValue, @NotNull String strValue) {
      super();
      this.intValue = intValue;
      this.strValue = strValue;
   }

   public WithDefaultConstructor(int intValue, String strValue, int defaultParametersFlags, 
DefaultConstructorMarker defaultConstructorMarker) {
      if ((defaultParametersFlags & 1) != 0) { // проверка флага на присутствие первого 
                                                  параметра`01`
         intValue = 1;
      }

      if ((defaultParametersFlags & 2) != 0) { // проверка флага на присутствие 
                                                  второго параметра `10`
         strValue = "default";
      }

      this(intValue, strValue);
   }

   public WithDefaultConstructor() {
      this(0, (String)null, 3, (DefaultConstructorMarker)null);
   }
   //... другие методы
} 
     
        

как только у всех полей появляются значения по умолчанию, компилятор Kotlin генерирует конструктор по умолчанию без параметров в дополнение к созданию других перегруженных версий.

2. Небезопасные вызовы Java в Gson

Наличие конструктора по умолчанию важно для Gson. Он работает приблизительно так (смотрите код объекта ConstructorConstructor для более подробной информации):

  1. пытается создать объект с помощью предоставленной реализации InstanceCreatorна основе типа-токена;
  2. пробует вызвать конструктор по умолчанию;
  3. пытается вызвать известный конструктор для map, множеств, очередей, etc.;
  4. использует небезопасное распределение, если ничего не помогает.

Это значит, что если вы не предоставите поставщика экземпляра, а у вашего класса нет конструктора по умолчанию, объект будет создаваться без вызова конструктора. Kotlin делегирует все проверки на null и присвоение значений по умолчанию конструктору, из-за чего они пропускаются.

Поэтому если вы разбираетесь в Gson и ещё не готовы перейти к новой библиотеке, которая поддерживает значения по умолчанию Kotlin, такой как Moshi, определите все значения по умолчанию в классах данных и позвольте Gson сделать остальное.

И это всё? Не совсем...

Предостережение

Приведённое выше решение работает для JSON с отсутствующими полями, но появляется ещё одна проблема. Допустимость null-значений, которая определяется в коде Kotlin, может не соблюдаться в JSON, и Gson не будет жаловаться на JSON вида {"intValue":2,"strValue":null}, который содержит явное значение null. Если такая ситуация возможна, определите все не примитивные поля обнуляемыми, чтобы избежать непредвиденных сбоёв в работе.

А как вы используете Gson + Kotlin? Какой способ вам понравился больше?

РУБРИКИ В СТАТЬЕ

Комментарии

BUG
LIVE