Упаковка набора boolean-флагов для сериализации, доработка Kryo

Для Reedy нам понадобилась эффективная сериализация, и наиболее подходящим вариантом оказался Kryo, кроме обычной автоматической обработки объектов, предоставляющий ещё и удобное API прямо поверх стандартных Java InputStream и OutputStream.

Kryo позволяет вручную сериализовать что угодно, оптимизируя по производительности или же по потреблению памяти. Int, Long и пр. можно записывать очень кратко, иногда ограничиваясь всего одним байтом. ASCII строки так же могут использовать один байт, вместо двух, на каждый символ. Отличным бонусом является то, что API достаточно изолировано от остальных классов библиотеки, и ProGuard без проблем может вырезать всё лишнее, сохраняя наше Android-приложение компактным.

Немного огорчил лишь один момент — boolean-флаги для обычной записи тоже требуют целый байт, а вариантов упаковки не предусмотрено. С одной стороны, конечно, понятно — байт минимальная единица адресации памяти, а упаковка немного замедляет работу. Однако когда флагов у нас несколько и элементов с флагами может быть много, это разрастание сериализованных данных начинает всё больше влиять на конечный размер данных. Так что было решено пожертвовать толикой производительности и несколько флагов упаковывать в биты одного числа. В нашем случае хватает как раз одного байта — флагов меньше восьми.

А самое приятное то, что мы используем для разработки Kotlin (Kotlin vs. Java), и он позволяет нам виртуально добавлять новые методы прямо в Java-классы Kryo с помощью замечательных расширяющих функций. Для их использования требуется импорт, но это не проблема — в Android Studio, как и в IDEA, импорт предлагается автоматически.

Результирующий код утилит:

import com.esotericsoftware.kryo.io.Input
import com.esotericsoftware.kryo.io.Output

public fun Output.writeBits(vararg bits: Boolean) {
	val number = bits.size()
	if (number > 8)
		throw IllegalArgumentException("Max 8 bits can be used")

	var i = 0
	var byte = 0

	// handwritten loop because it's a bit more efficient
	while (i < number) {
		if (bits[i]) byte = byte or (1 shl i) // in java: byte | (1 << i)
		i++
	}

	writeByte(byte)
}

public fun Input.readBits(number: Int): BooleanArray {
	if (number > 8)
		throw IllegalArgumentException("Max 8 bits can be used")

	var i = 0
	val byte = readByte().toInt() // no conversion here. only for explicitness
	val result = BooleanArray(number)

	// handwritten loop because it's a bit more efficient
	while (i < number)
		result[i] = byte and (1 shl i++) != 0 // in java: (byte & (1 << i++)) != 0

	return result
}

Использовать очень просто и удобно:

// пишем
output.writeBits(isTitleSynthetic, isInvisible, isSynthetic)

// читаем
val (isTitleSynthetic, isInvisible, isSynthetic) = input.readBits(3)

Во время чтения наслаждаемся ещё одной возможностью Kotlin — объявлением сразу нескольких переменных — multi-declaration. Эта функциональность «из коробки» доступна для массивов, списков, Map.Entry и специальных классов-кортежей (Pair, Triple). Достаточно просто добавляется и для любых других классов, при необходимости. Особенно приятно, что стандартная поддержка массивов и списков использует инлайнинг, так что этот синтаксический сахар даже не добавляет никаких накладных расходов.

При необходимости код легко адаптировать для записи до 32 или 64 флагов. Несложно и больше, используя несколько чисел, но это уже сильнее будет сказываться на производительности, так что игра может не стоить свеч. Хотя, может и наоборот, если массив флагов огромный, а места для записи мало.

Кстати, кто не знает, подобный финт с компактной упаковкой флагов в памяти доступен и во время работы приложения, с помощью стандартного Java-класса BitSet, имеющегося как в JDK, так и под Android. Это здорово экономит оперативную память при хранении множества boolean. А при необходимости может быть полезной и более эффективная по производительности реализация из Apache Lucene (OpenBitSet) или что-нибудь из целого ряда других альтернатив, которые можно найти, например, на гитхабе.