IT-инфраструктура для бизнеса и творчества
Разработка
Joom

Библиотека 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 и не поддержали все планируемые сценарии использования. В связи с этим будем очень рады вашим вопросам, комментариям и пулл-реквестам.

{ "author_name": "Joom", "author_type": "editor", "tags": ["\u043e\u043f\u0435\u043d\u0441\u043e\u0440\u0441","\u043c\u043e\u0431\u0438\u043b\u044c\u043d\u0430\u044f\u0440\u0430\u0437\u0440\u0430\u0431\u043e\u0442\u043a\u0430","opensource","kotlin","joom","android"], "comments": 3, "likes": 20, "favorites": 11, "is_advertisement": false, "subsite_label": "dev", "id": 119802, "is_wide": true, "is_ugc": false, "date": "Wed, 15 Apr 2020 16:41:00 +0300", "is_special": false }
(function () { let cdnUrl = `https://specialsf378ef5-a.akamaihd.net/SelectelBranding/images/` let previousArticleNumber = null let currentArticleNumber = 0 let platform = 'Desktop' let articles = [ // { // name: 'camera', // url: `${cdnUrl}CameraCat`, // text: 'умную камеру для\u00A0наблюдения за\u00A0котиками', // link: '1', // }, { name: 'chill', url: `${cdnUrl}ChillCat`, text: 'трекер, который подскажет, когда пора отдохнуть', link: 'https://vc.ru/promo/288561-eye-tracker', }, // { // name: 'cloud', // url: `${cdnUrl}CloudCat`, // text: 'котика: даёшь ему «пять», а\u00A0он делает бэкап в облако', // link: '3', // } ] let buttonCycle = document.querySelector('.button--cycle') let textField = document.querySelector('.selectel-footer-subtitle') let imageAgent = document.querySelector('.image--agent') let banner = document.querySelector('.selectel-footer') buttonCycle.addEventListener('click', cycleClick) let media = window.matchMedia("(max-width: 570px)") media.addEventListener('change', matchMedia) function matchMedia() { if (media.matches) { platform = 'Mobile' } else { platform = 'Desktop' } update() } matchMedia() function cycleClick(event) { if (event) { event.preventDefault() event.stopPropagation() } window.open('https://vc.ru/tag/selectelDIY', '_blank') //cycle(event) } function cycle(event) { // incrementArticleNumber() textField.innerHTML = generatedText() imageAgent.src = articles[currentArticleNumber].url + platform + '.svg?5' imageAgent.setAttribute("class", "") imageAgent.classList.add('image--agent', articles[currentArticleNumber].name) banner.href = articles[currentArticleNumber].link } function update() { banner.href = articles[currentArticleNumber].link imageAgent.src = articles[currentArticleNumber].url + platform + '.svg?5' textField.innerHTML = generatedText() } function incrementArticleNumber() { previousArticleNumber = currentArticleNumber if (currentArticleNumber >= articles.length - 1) { currentArticleNumber = 0 } else { currentArticleNumber++ } } function generatedText() { let defaultText if (platform === 'Desktop') { defaultText = `Мы тут собрали %text%. Хотите почитать?` } else { defaultText = `Мы тут собрали %text%.` } return defaultText.replace('%text%', articles[currentArticleNumber].text) } function getRandom(min, max) { min = Math.ceil(min) max = Math.floor(max) return Math.floor(Math.random() * (max - min + 1)) + min } (function create() { currentArticleNumber = getRandom(0, articles.length - 1) cycle() let page = document.querySelector('.page--entry') if (page) { function insertAfter() { let parents = page.querySelectorAll('[data-id="7"]') let referenceNode = parents[0] referenceNode.parentNode.insertBefore(banner, referenceNode.nextSibling); loaded() } setTimeout(() => insertAfter(), 0) } }()) function loaded() { banner.classList.add('loaded') } loadImages([ `${cdnUrl}CameraCatDesktop.svg`, `${cdnUrl}ChillCatDesktop.svg`, `${cdnUrl}CloudCatDesktop.svg`, `${cdnUrl}CameraCatMobile.svg`, `${cdnUrl}ChillCatMobile.svg`, `${cdnUrl}CloudCatMobile.svg`, ]) function loadImages(urls) { return Promise.all(urls.map(function (url) { return new Promise(function (resolve) { var img = document.createElement('img'); img.onload = resolve; img.onerror = resolve; img.src = url; }); })); } }())
0
3 комментария
Популярные
По порядку

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

0

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

0

Согласен, не обратил внимание

0
Читать все 3 комментария
Производитель микрокомпьютеров Raspberry Pi привлёк $45 млн при оценке в $500 млн Статьи редакции

Компания потратит инвестици на расширение линейки микропроцессоров Pi и маркетинг.

Amazon впервые с 2018 года обновит Kindle: в новой версии — увеличенный экран и режим автономной работы до 10 недель Статьи редакции

Стоит от $140.

Kindle Paperwhite Amazon
Avito начал переговоры о покупке сервиса по поиску жилья «Циан» — Forbes Статьи редакции

Ходатайство о покупке «Циана» поступило в ФАС от Avito летом 2021 года, рассказал источник издания.

Evrone News #08: выступили на конференциях и провели первый Evrone Fest

В этот раз наша традиционная подборка посвящена мероприятиям. Во-первых, наши спикеры отлично выступили на PyCon и RnDTechConf, а во-вторых, мы провели свой первый Evrone Fest. Подробности ниже.

«Сбер» продаст свою долю в разработчике системы для распознавания лиц VisionLabs — Reuters Статьи редакции

Банк хочет развивать другой сервис распознавания лиц и речи, сообщают источники издания.

«ВкусВилл» объявил о ребрендинге и первой за девять лет смене логотипа Статьи редакции

У компании новый слоган — «Здесь полезное вкусно».

«Вкусвилл»
Слабое звено бизнеса — уверенность. Разбираемся, как ее достичь

Начнем с очевидного: бизнес — это всегда практикум. Научить ему на лекциях и курсах сложно. Это понимают и опытные, и новички, и даже наемные работники. Тогда откуда такой спрос на обучающие программы со стороны бизнеса? Мы углубились в вопрос и нашли ответы. Выводы оказались неожиданными: образовательные программы, интерактивы, презентации…

ПРЕМЬЕРА ВТОРОГО СЕЗОНА СЕРИАЛА «МОЛОДЫЕ И СИЛЬНЫЕ. ПРОКЛЯТИЕ ВЫЖИВШИХ»
«Донер 42» откроет первые кафе-донерные по франшизе в Перми, Ульяновске и других городах Статьи редакции

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

«Донер 42» в Москве Донер 42
Как IT-компания делает продукты: история собственной торговой марки Яндекс.Лавки
null