Библиотека Colonist: поиск классов во время компиляции Android-приложения

Open-source-решением делится Михаил Розумянский, глава клиентской разработки Joom.

Привет! Если вы кликнули по заголовку, то наверняка сталкивались с ситуациями, когда нужно что-то сделать с набором классов, объединённых некоторым признаком. Например, зарегистрировать адаптеры типов для парсинга JSON. Или установить обработчики событий, получаемых с бэкенда. Или добавить обработчики навигационных команд в роутер.

Уверен, в любом проекте есть как минимум несколько подобных кейсов, и наш не является исключением. Сегодня я расскажу вам, как мы в Joom решали подобные задачи и поделюсь нашей open-source-библиотекой Colonist, которую мы сделали специально для этого.

Предпосылки появления проекта

Например, у нас в проекте есть реализация A/B-тестов с параметрами, которые мы называем экспериментами. Мы хотим уметь получать их с бэкенда и узнавать обо всех изменениях в них. Чтобы эксперименты были типизированными, нужно описать их все в виде классов и зарегистрировать в неком менеджере, который бы при получении данных с бэкенда уведомлял приложение об изменении конфигурации экспериментов.

Если делать регистрацию каждого класса эксперимента явно, то можно легко забыть это сделать, поэтому мы решили автоматизировать этот процесс через reflection. К сожалению, для этого пришлось описывать все эксперименты в sealed-иерархии, а это привело к неограниченному росту файла с экспериментами.

sealed class Experiment { class FirstExperiment(val parameter: String) : Experiment() class SecondExperiment(val parameter1: Boolean, val parameter2: Int) : Experiment() } class ExperimentManager { init { registerExperimentsInHierarchy(Experiment::class.java) } private fun registerExperimentsInHierarchy(experimentsClass: Class<out Experiment>) { if (!Modifier.isAbstract(experimentsCass.modifiers)) { registerExperiment(experimentsClass) } else { experimentsClass.declaredClasses.forEach(::registerExperimentsInHierarchy) } } private fun registerExperiment(experimentsClass: Class<out Experiment>) { // Как-то регистрируем эксперимент через reflection. } }

Аналогичная ситуация произошла с классами моделей. Нам нужно было сделать провайдер, который отдавал бы объекты моделей по классу, и, соответственно, потребовалось зарегистрировать в нём все модели. Каждый класс был помечен специальной аннотацией @ModelConfig, и при регистрации модели провайдер доставал её конфигурацию из этой аннотации через reflection. Если очень упростить, то описанная схема выглядит примерно так:

interface Model { ... } annotation class @ModelConfig(...) interface ModelProvider { fun <T : Model> getModel(modelClass: Class<out T>): T } class MutableModelProvider : ModelProvider { fun registerModel(modelClass: Class<out Model>) { // Тут у модели достаётся аннотация @ModelConfig и модель регистрируется в провайдере. } }

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

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

@ModelConfig(...) class FirstModel : Model { ... } @ModelConfig(...) class SecondModel : Model { ... } fun registerModels(modelProvider: MutableModelProvider) { registerModel(FirstModel::class.java) registerModel(SecondModel::class.java) }

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

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

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

Пожив с этой идеей в течение какого-то времени, мы решили, что процесс обнаружения и регистрации типов можно автоматизировать. Кроме того, нам хотелось, чтобы поиск классов осуществлялся не только в текущем модуле, но и во всех зависимостях. Например, чтобы в модуле приложения можно было бы найти классы из самого модуля приложения, его сабмодулей и даже из внешних зависимостей.

Как появился Colonist

Для начала мы проанализировали существующие готовые решения для поиска классов во всём приложении. Большинство из них, такие как Reflections или Scannotations, используют reflection для поиска классов и не работают под Android.

Существует библиотека ClassGraph, которая позволяет выполнить поиск классов в процессе сборки приложения, сохранить полученный результат в виде JSON и загрузить этот JSON в рантайме. Это решение выглядит неудобным и сложным в использовании, так как придётся написать некоторое количество кода в build.gradle-файле для каждого поискового запроса, а в рантайме парсить сохранённые результаты и инстанцировать классы через reflection, что медленно работает и плохо совместимо с обфускацией.

Нам же хотелось, чтобы всё можно было решить, например, разметкой классов аннотациями. Так и родилась библиотека Colonist.

Так уж повелось, что у нас в команде был неплохой опыт работы с байткодом и трансформациями. Поэтому план был такой:

  • Мы размечаем классы, например через аннотацию или наследование от общего базового класса или интерфейса.
  • Ищем их во время компиляции через анализ байткода.
  • Генерируем код, который эти классы регистрирует в некотором контейнере точно так же, как мы это делали руками в коде.

Но для того, чтобы анализ и генерация байткода были простыми, нужно доступными языковыми средствами описать все параметры поиска и регистрации классов. К тому же нужно, чтобы все используемые конструкции успешно компилировались, так как процессинг байткода осуществляется уже после компиляции.

Так как в проекте есть несколько разных наборов классов, которые нужно найти и зарегистрировать, нам нужно было научиться причислять класс к соответствующему набору или наборам. Чтобы это сделать, мы ввели понятие колонии (colony), то есть набора классов, которые мы хотим где-то зарегистрировать в рантайме.

Как это работает

В результате появилась мета-аннотация @Colony, которая позволяет создать аннотацию для колонии:

@Colony @Target(AnnotationTarget.CLASS) annotation class MyColony

Колония определяет то, как найти входящие в неё классы-поселенцы (settlers) и зарегистрировать их в колонии. Делается это тоже через аннотации:

@Colony @SelectSettlersByAnnotation(MySettler::class) @ProduceSettlersViaConstructor @AcceptSettlersViaCallback @Target(AnnotationTarget.CLASS) annotation class MyColony @Target(AnnotationTarget.CLASS) annotation class MySettler

Вот что описывают аннотации выше.

  • @SelectSettlersByAnnotation(MySettler::class) находит классы, помеченные аннотацией MySettler.
  • @ProduceSettlersViaConstructor создаёт объекты этих классов, используя конструктор без параметров.
  • @AcceptSettlersViaCallback регистрирует созданные объекты через вызов специального метода, о котором будет сказано чуть позже.

Дальше дело за малым — создать класс-контейнер, в котором в результате будут зарегистрированы поселенцы:

@MyColony class MySettlerRegistry { init { Colonist.settle(this) } }

Код выше делает класс MySettlerRegistry колонией, при инициализации которой будут созданы поселенцы и зарегистрированы в ней. Но, кажется, чего-то не хватает. Выше речь шла о неком специальном методе, через который поселенцы должны зарегистрироваться в колонии, ведь никакой обобщённой процедуры регистрации не существует. Давайте этот метод добавим:

@MyColony class MySettlerRegistry { val settlers = ArrayList<Settler>() init { Colonist.settle(this) } @OnAcceptSettler(colonyAnnotation = MyColony::class) fun onAcceptSettler(settler: Settler) { settlers += settler } }

Теперь для каждого поселенца будет вызван метод onAcceptSettler(), который просто добавит его в список. Метод должен быть помечен аннотацией @OnAcceptSettler с указанием аннотации колонии, так как один класс-контейнер может содержать сразу несколько колоний и, соответственно, несколько таких методов для разных поселенцев. Для полноты описания неплохо бы объявить несколько типов поселенцев:

interface Settler @MySettler class Settler1 : Settler @MySettler class Settler2 : Settler

Чтобы вся описанная конструкция действительно делала что-то полезное, нужно подключить к проекту плагин Colonist:

buildscript { dependencies { classpath 'com.joom.colonist:colonist-gradle-plugin:0.1.0-alpha7', } repositories { jcenter() } } apply plugin: 'com.joom.colonist.android'

Что же произойдёт после трансформации байткода? Если не обращать внимания на детали реализации, то можно сказать, что класс MySettlerRegistry будет изменён следующим образом:

@MyColony class MySettlerRegistry { val settlers = ArrayList<Settler>() init { onAcceptSettler(Settler1()) onAcceptSettler(Settler2()) } @OnAcceptSettler(colonyAnnotation = MyColony::class) fun onAcceptSettler(settler: Settler) { settlers += settler } }

Выше был описан конкретный пример конфигурации колонии, но библиотека поддерживает и другие параметры конфигурации. Например, искать классы можно не только по аннотации, но и по базовому классу, используя при описании колонии аннотацию @SelectSettlersBySuperType. Таким образом можно получить все классы в иерархии.

Также совсем не обязательно создавать сущности классов-поселенцев через конструктор без параметров. Можно вообще ничего не создавать, а делать поселенцами сами найденные классы, используя аннотацию @ProduceSettlersAsClasses.

А можно создавать сущности поселенцев через специальный метод, пометив колонию аннотацией @ProduceSettlersViaCallback по аналогии с описанной выше @AcceptSettlersViaCallback.

Ну и напоследок: можно вообще не регистрировать поселенцев, указав аннотацию @AcceptSettlersAndForget. Это может быть полезно, например, когда нужно просто создать какие-то объекты наподобие глобальных синглтонов.

Применение у нас

Давайте теперь вернёмся к исходным примерам, из-за которых родилась идея написать эту библиотеку, и попробуем переделать их на новое решение. Начнём с экспериментов. Обработку классов экспериментов через reflection мы убирать не хотели, а вот поиск этих классов хотелось бы сделать через Colonist. И начать нужно, конечно же, с аннотации колонии:

@Colony @SelectSettlersBySuperType(Experiment::class) @ProduceSettlersAsClasses @AcceptSettlersViaCallback @Target(AnnotationTarget.CLASS) annotation class ExperimentColony

Эта аннотация описывает колонию, в которую попадут все классы наследники Experiment. Теперь применим эту аннотацию в коде, чтобы получить все классы экспериментов. Текущая реализация найдёт все классы-наследники Experiment, в том числе и абстрактные, которые нас не особо интересуют. Этот момент стоит учесть в коде, а в будущем мы, вероятно, добавим в библиотеку возможность убрать такие классы из результатов.

@ExperimentColony class ExperimentManager { init { Colonist.settle(this) } @OnAcceptSettler(colonyAnnotation = ExperimentColony::class) fun onAcceptExperiment(experimentClass: Class<out Experiment>) { if (!Modifier.isAbstract(experimentClass.modifiers)) { // Регистрируем класс эксперимента как и раньше. registerExperiment(experimentClass) } } }

Таким образом мы заменили поиск классов через reflection на кодогенерацию. Помимо лучшей производительности такого кода есть и ещё парочка приятных бонусов. Во-первых, теперь для поиска всех классов экспериментов они не должны находиться в sealed-иерархии.

Эксперименты можно описывать в любом месте, даже в другом модуле, а при сборке приложения все попавшие в него классы экспериментов будут найдены и зарегистрированы в менеджере экспериментов. А во-вторых, подход с кодогенерацией лучше дружит с обфускаторами, в отличие от reflection.

Но давайте вернёмся к коду. Приведённый в самом начале пример с моделями мы тоже переделали на Colonist максимально похожим образом:

@Colony @SelectSettlersByAnnotation(ModelConfig::class) @ProduceSettlersAsClasses @AcceptSettlersViaCallback @Target(AnnotationTarget.CLASS) annotation class ModelColony @ModelColony class ModelProviderImpl : ModelProvider { init { Colonist.settle(this) } @OnAcceptSettler(colonyAnnotation = ModelColony::class) fun onAcceptModel(modelClass: Class<out Model>) { // Регистрируем класс модели как и раньше. registerModel(modelClass) } }

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

Конечно, редко так бывает, чтобы были одни плюсы, а минусов совсем не было, и эта библиотека не является исключением. Так как Colonist выполняет большую часть своей работы на этапе компиляции, это немного увеличивает время сборки. А из-за того, что библиотека генерирует байткод напрямую, нет простой возможности, например, пройтись по этому коду отладчиком, если возникла необходимость.

По мере интеграции библиотеки в проект мы обнаружили, что Colonist может использоваться в утилитарных целях, на которые мы изначально не рассчитывали. Например, у нас в проекте есть классы сетевых запросов, размеченные аннотациями. Их не нужно нигде специальным образом регистрировать, так как это происходит непосредственно при выполнении запроса. Вместе с регистрацией происходит и валидация метаданных класса запроса, которая может привести к падению приложения, если в описании есть ошибка.

Сразу возникает естественное желание провалидировать метаданные классов запросов заранее. Если есть время, можно попробовать написать своё lint-правило. А если времени мало, можно сделать такую валидацию либо в тестах, либо во внутренней сборке приложения прямо при запуске. Причём используя тот же самый код валидации, что и при выполнении запроса. И сделать это оказалось можно через Colonist: описать колонию для классов запросов и прогнать их через валидатор.

Другая бытовая ситуация, которая иногда может возникать — получение внутренних знаний о приложении. В качестве примера можно привести всё те же самые сетевые запросы. Предположим, кто-то попросил выяснить, какие запросы используются в текущей версии приложения.

Обычно на такие задачи совсем не хочется тратить время, и тут Colonist приходит на помощь. Описав колонию и добавив немного кода в тесты, можно получить все классы запросов и вывести нужную информацию в консоль.

Сейчас библиотека Colonist используется в нескольких местах в нашем проекте, а в будущем мы планируем увеличить их количество. Использование этой библиотеки способствует упрощению и модуляризации кода за счёт незначительного увеличения времени сборки. Чтобы и вы могли попробовать такой подход у себя в проекте, мы выложили Colonist на GitHub.

Библиотека пока что новая и экспериментальная. Мы не до конца определились с API и не поддержали все планируемые сценарии использования. В связи с этим будем очень рады вашим вопросам, комментариям и пулл-реквестам.

2222
3 комментария

Статья больше техническая, ей место на хабре

Ответить
Автор

Подсайт /dev как раз для таких статей, не согласны?

Ответить