Глобальный ренейминг в Android-проекте
Привет-привет! Не будем мять бока и начнем максимально быстро.
Но для начала представлюсь. Меня зовут Таскаев Евгений — я Android-разработчик в фичевой команде hh.ru. Пилю всякие интересные фичи, которыми вы пользуетесь каждый день*.
В статье я расскажу, как у нас в Android-приложении прошел глобальный ренейминг фичей и пакетов и их структуризация. Что у нас получилось, а что нет. А стоит ли вам делать так же, вы решите уже сами.
*
* - если вы каждый день открываете приложение "hh работа"
Для тех, кто не любит читать
У меня для вас есть выход! Эту статью можно глянуть в формате видео на ютубе в одном из эпизодов «Охэхэнных историй» ;)
Представим, что сейчас 2018 год. Представили? А теперь перестаньте плакать. В 2018 тоже было полно проблем.
У нас есть шикарный проект, в котором, по законам жанра, огромный монолит с 1 app gradle модулем и весь код в активити и несколько флэйворов. Основные из них - applicant и employer — приложения соискателя и работодателя соответственно.
В этом нет ничего страшного ровно до тех пор
пока команда не слишком разрослась и не стало трудно пилить фичи
пока старые разработчики не ушли, а новые знают не всё
пока не пришел хайптрейн с многомодульностью
пока рак на горе не свистнул
Подчеркните нужное.
В нашем случае произошло так: были разного рода лики и баги, фиксить которые было сложно, к тому же старые разработчики разбежались, а новым было трудно ориентироваться в том, что осталось. Из-за этого было трудно декомпозировать задачи и разработка стала “дорогой”.
А какие у вас были проблемы на проектах пишите в комментариях)
Структура проекта до ренейминга
Спустя некоторое время, когда многое было зарефачено и отлажено, мы пришли к новому виду структуры проекта. Это как раз та структура, которая была у нас до недавнего времени.
За всё это время в плане структуры произошло следующее:
мы избавились от flavors и разделили приложения на два app gradle-модуля — модули которые зависят от плагина "com.android.application".
разбили монолит на отдельные library gradle-модули (и в дальнейшем просто gradle-модули) — модули которые зависят от плагина "com.android.library".
Также прошло несколько этапов формирования структуры. И перед последним, о котором я расскажу в конце, структура была такая:
Часть фичей лежали в папке feature в корне проекта, туда складывались gradle-модули фичей соискательского приложения с префиксом “feature-” в именовании.
Например, фича резюме называлась feature-resume. Это рудиментарное решение, которое появилось почти в самом начале рефакторинга.Некоторые фичи состояли из нескольких сабфичей, по аналогии с 1 пунктом, но создавался не gradle-модуль, а папка также с префиксом, куда уже складывались нужные фичи. Слишком сложно? Сейчас приведу пример. Фича резюме, feature-resume, внутри нее лежали gradle-модули: feature-resume-profile, feature-resume-network и тд.
Общие gradle-модули, которые использовались в обоих приложениях, лежали в папочке shared. Помимо этого, фичи внутри shared делились на core и feature. Соответственно, в какой папке лежал gradle-модуль, то имя папки добавлялось префиксом к названию модуля. Как можете заметить на картинке, с core что-то было не так. По правилам префикс у названия фичей должен быть “shared-core-”.
Основная логика приложений лежала в папках соответствующих названиям приложений applicant (соискательское) и employer (работодательское).
Структура была аналогична папке shared и фичи в них именовались так:
Проблемы с именованием
Расположение модулей не мэтчилось с их названием, что затрудняло подключение модулей, а иногда и путало, особенно новых разработчиков
Бардак в названии пакетов, конечно были какие то правила, но за 3 года, они тоже менялись, а пакеты могли называться по-разному, иногда даже совпадали у некоторых разных фич
И выход из всего этого был — РЕНЕЙМИНГ!
Ретроспектива ренейминга
А теперь самое интересное. Расскажу про новую структуру проекта.
Начнем с небольшой ретроспективы и разберемся, с какими проблемами мы столкнулись.
До начала рефакторинга мы определились какой хотим видеть новую структуру.
Во время праздничных выходных в феврале мы начали переносить файлы по новым папкам и менять имена пакетов с помощью разного рода скриптов.
Время было выбрано специально, чтобы никто параллельно ничего не делал.Но работа затянулась. Тогда мы плохо понимали масштаб возложенной на нас задачи.
И поэтому решили для начала вручную составить гигантский чеклист того, что предстоит сделать, формата "как модуль назывался -> как будет называться". А также за одно переименовать пакеты в соответсвии с названием модулей
Он выглядел примерно так
#### feature-chat --> chat
#### feature-chat/core
- [x] applicant-feature-chat-core-data
— [x] rename module: applicant-feature-chat-core-data -> :applicant:feature:chat:core:data
— [x] rename package: ru.hh.applicant.feature.chat.screen_core_data -> ru.hh.applicant.feature.chat.core.data
- [x] applicant-feature-chat-core-network
— [x] rename module: applicant-feature-chat-core-network -> :applicant:feature:chat:core:network
— [x] rename package: ru.hh.feature_chat_network -> ru.hh.applicant.feature.chat.core.network
#### feature-chat/feature
- [x] applicant-feature-chat-list
— [x] rename module: applicant-feature-chat-list -> :applicant:feature:chat:chat-list
— [x] rename package: ru.hh.feature_chat_list -> ru.hh.applicant.feature.chat.list
и так далее
И даже после этого мы не до конца осознали, что нора, в которую мы залезли — не кроличья.
Поэтому первые модули было решено перенести просто руками, в Android Studio. После 10 модулей стало понятно, что git-история этих файлов исчезла. Терять историю нам однозначно не хотелось, поэтому мы вспомнили про такую команду как git mv, которая позволяет без потери истории перенести файлы из одной папки в другую.
Попытавшись перенести несколько модулей с использованием git mv, мы сильно взгрустнули. Потому что писать руками эти команды было очень долго и муторно.
Нужно было искать какое-то автоматическое решение. Поэтому мы написали простой bash-скрипт, который для двух заданных папок генерил тонну команд git mv, которые можно было скопировать и сразу запустить.
А вот и сам скриптик на генерацию git mv команд
#!/bin/bash
readonly LOCAL_PATH=$1
readonly START_FOLDER=$2
readonly START_PACKAGE=$3
readonly TARGET_FOLDER=$4
readonly TARGET_PACKAGE_NAME="$5"
echo "===== MV COMMANDS GENERATOR ====="
echo "LOCAL_PATH: ${LOCAL_PATH}"
echo "START_FOLDER: ${START_FOLDER}"
echo "START_PACKAGE: ${START_PACKAGE}"
echo "TARGET_FOLDER: ${TARGET_FOLDER}"
echo "TARGET_PACKAGE_NAME: ${TARGET_PACKAGE_NAME}"
readonly SPLITTED_TARGET_PACKAGE=(${TARGET_PACKAGE_NAME//./ })
TARGET_PATH_PARTS=""
for package_part in "${SPLITTED_TARGET_PACKAGE[@]}"; do
TARGET_PATH_PARTS="${TARGET_PATH_PARTS}/${package_part}"
done
readonly SOURCE_SET_FOLDER="/src/main/java"
readonly SOURCE_KOTLIN_SET_FOLDER="/src/main/kotlin"
readonly DESIRED_CODE_ROOT_FOLDER="${START_FOLDER}${SOURCE_SET_FOLDER}${TARGET_PATH_PARTS}"
echo "DESIRED_CODE_ROOT_FOLDER: ${DESIRED_CODE_ROOT_FOLDER}"
readonly SPLITTED_START_PACKAGE=(${START_PACKAGE//./ })
START_PATH_PARTS=""
for package_part in "${SPLITTED_START_PACKAGE[@]}"; do
START_PATH_PARTS="${START_PATH_PARTS}/${package_part}"
done
START_CODE_ROOT_FOLDER="${START_FOLDER}${SOURCE_SET_FOLDER}${START_PATH_PARTS}"
if [ ! -d "${START_CODE_ROOT_FOLDER}" ]; then
echo "... there is no /java source set --> /kotlin source set exists"
START_CODE_ROOT_FOLDER="${START_FOLDER}${SOURCE_KOTLIN_SET_FOLDER}${START_PATH_PARTS}"
fi
readonly FULL_START_PATH="$(cd "$(dirname "${START_CODE_ROOT_FOLDER}")"; pwd)/$(basename "${START_CODE_ROOT_FOLDER}")"
echo "START_CODE_ROOT_FOLDER: ${START_CODE_ROOT_FOLDER}"
echo "FULL_START_PATH: ${FULL_START_PATH}"
echo ""
echo "======= Generation result ======"
echo ""
# Первая команда — создаём директорию для переноса кода
echo "mkdir -p ${DESIRED_CODE_ROOT_FOLDER} && \\"
# Перечисляем команды для аккуратного переноса кода
for code_directory in ${FULL_START_PATH}/* ; do
NAME="${code_directory/${LOCAL_PATH}/.}"
echo "git mv ${NAME} ${DESIRED_CODE_ROOT_FOLDER} && \\"
done
# Последняя команда — перенос кода в target_folder
echo "git mv ${START_FOLDER} ${TARGET_FOLDER}"
echo ""
echo "================================="
echo ""
Дело пошло чуть веселее. Но перенос папок — это всего лишь одна часть истории. Вторая часть заключалась в том, что мы, помимо простого переноса папок, захотели ещё и ПЕРЕИМЕНОВАТЬ некоторые модули, о чем я писал выше, добавив структуры не только в иерархию папок, но и в иерархию package-ей.
Чтобы было вот так:
ru.hh.feature_chat_list -> ru.hh.applicant.feature.chat.list
ru.hh.feature_chat_network -> ru.hh.applicant.feature.chat.core.network
ru.hh.applicant.feature.chat.screen_core_data -> ru.hh.applicant.feature.chat.core.data
Поэтому помимо генерации команд git mv, нужно было ещё сгенерить команды для переименования одних package-ей в другие. Для этого мы тоже написали дополнительный скрипт, который генерил команды для вызова скрипта переименования.
Скрип запуска скрипта переименования
readonly START_FOLDER=$1
readonly START_PACKAGE=$2
readonly REPLACE_PACKAGE=$3
echo "START_FOLDER = ${START_FOLDER}"
echo "START_PACKAGE = ${START_PACKAGE}"
echo "REPLACE_PACKAGE = ${REPLACE_PACKAGE}"
echo ""
echo "========="
echo ""
for filename in ${START_FOLDER}/*; do
withoutPath=$(basename -- "$filename")
fff="${withoutPath%.*}"
echo "sh global_rename.sh ${START_PACKAGE}.${fff} ${REPLACE_PACKAGE}.${fff} && \\"
done
echo ""
echo "========="
Сам скрипт переименования пакетов
#!/bin/bash
readonly OLD_PACKAGE=$1
readonly NEW_PACKAGE=$2
find . -type d \( \
-name 'firebase' -o \
-name 'gradle' -o \
-name 'hooks' -o \
-name 'lint' -o \
-name 'profiling' -o \
-name 'scripts' -o \
-name 'detekt' -o \
-name '.git' -o \
-name '.gradle' -o \
-name '.mainframer' -o \
-name 'build' -o \
-name '.idea' -o \
-name 'android-style-guide' -o \
-name 'ci' \
\) -prune -o \
-type f \( \
-name '*.java' -o \
-name '*.kt' -o \
-name '*.gradle' -o \
-name '*.xml' -o \
-name '*.txt' \
\) \
-print0 | xargs -0 sed -i '' "s/${OLD_PACKAGE}/${NEW_PACKAGE}/g"
echo "done rename for ( ${OLD_PACKAGE} / ${NEW_PACKAGE} )"
В итоге мы просидели все выходные, по очереди генерируя команды для каждого модуля и проверяя, собирается ли приложение и запускается ли оно вообще.
Так прошло 3 дня и две ночи...
Ответственные за это люди ушли отдыхать, а остальные разработчики продолжили, но у нас ничего не вышло.
А не получилось потому, что:
в больших фиче ветках разработчиков было много изменений и таких веток было несколько, если бы каждый мержил себе сам, то он с большей вероятностью кто-то мог ошибиться где-то
также некоторые ветки пересекались по изменениям и было трудно потом все это смержить еще и с ренеймингом
Подумав, мы решили что было бы хорошо, если кто-то один замержит ренейминг везде!
Поэтому решили фиче ветки смержить сразу в develop, а те кто не хочет мержить сейчас, а хотят еще поработать, будут потом сами разруливать конфликты с новым develop.
И мы не до конца понимали, чем нам это грозит...
Смержив все, что можно было в develop, в пятницу мы объявили кодфриз и избранный занялся мержем ренейминга в develop.
За неделю работы команд накопилось кучу изменений, и при мерже вылезло много конфликтов.
Вот например
После мержа ветки с ренеймингом в старый develop (недельной давности), старые фичи сменили имя и пакет, по факту это означало, что они отправились в другую папку.
Обратите внимание на файлы, все хорошо, они лежат в новом месте.
Но при мерже ренейминга в новый develop после недельной работы, появились такие фантомные структуры:
По старым путям пакетов лежали файлы, которые были изменены разработчиками, но изменены они были в старой структуре, и таких мест было много... голова шла кругом.
Естественно, все это править руками будет долго... Чтобы ускорить процесс, в качестве вспомогательного инструмента, была выбрана утилита rsync, потому что она умеет рекурсивно мержить папки друг с другом, и ей можно указать стратегию разрешения конфликтов (перезапись, оставить новое, оставить старое, etc.).
В консоли с ее помощью рекурсивно переносили папки фичей. Из папки со старым названием в папку с новым.
Затем с помощью волшебных настроек Android Studio — Optimize imports on fly и Add unambiguous imports on the fly, были поправлены проблемы с импортами. Да, мы заходили ручками в каждый файл.
По-хорошему, надо было идти сначала от корневых модулей (shared/core) и делать регулярный синк проекта в IDE. Так пришлось бы гораздо меньше страдать потом с импортами при переносах файлов, ибо Android Studio автоматически бы их сразу переименовывала во всех местах, куда дотянется.
Но эта мысль пришла в наши светлые головы уже после проделанной работы и полученного опыта.
Спустя пару дней мучений мержа, develop был актуализирован и содержал новую структуру папок и новые имена пакетов.
А ребятам, которые не стали мержить свои ветки в develop, была предоставлена инструкция, как безболезненно влить в себе новый develop.
Но все было не так просто, как хотелось бы.
Приведу список основных пунктов, если вдруг вам понадобится:
Вмержить к себе в ветку develop и сохранить лог конфликтов,
на памятьчтобы понимать поле работыКонфликты типа modified — modified разрешить самим, руками, но таких конфликтов было минимум
Остальные конфликты нужно разрешить в свою пользу
Нужно перевести все созданные вами папки на новую структуру
Каждый перенос лучше делать отдельным коммитом, чтобы ничего не потерять и чтобы лучше контролировать процесс
Разрулить силами Android Studio все неправильные импорты
Удалить все неиспользуемые папки
В теории звучит просто, но в реальных условиях:
если переносить модуль/модули у себя в ветке, то рефакторинг будет применен к модулю, который располагался в старом месте. Приходилось повторить все, что уже сделал, но для перенесенного модуля (получилась двойная работа) + удалить старый модуль, причем сделать аккуратно, чтобы осталась история гита
если переносить файлы у себя в ветке, то рефакторинг будет применен к старым файлам, естественно появлялась неактуальная фантомная структура файлов, которые были уже перенесены, поэтому приходилось аккуратно их объединить с новыми
Всего файлов с конфликтами было ~500 в ~50 модулях.
Итоги ренейминга
Названия всех модулей соответствуют структуре папок.
И теперь включать gradle-модуль в settings.gradle можно через
include(':shared:feature:location'), поскольку путь совпадает с неймингом.
Для примера, раньше это делалось вот так:Все модули из папки feature из корня проекта (о чем я писал вначале), переместились на свои законные места в папку applicant/feature.
Имена пакетов стали соотноситься с расположением фичи.
Например, фича геолокации :shared:feature:location получила пакет ru.hh.shared.feature.location.Gradle-модули избавились от префиксов feature, shared, core и т.д.
Но префиксы сабфичей решено было оставить... А потом и их решили не писать :)Появилась возможность статической валидация подключения модулей.
В будущем будем проверять нейминг модулей и пакетов, а также корректность связей между модулями разных типов.
Наши рекомендации
Прежде чем идти в эту историю, надо написать скрипты, которые автоматизируют большую часть работы. Можно воспользоваться нашими наработками, но сначала нужно проверить их на валидность вашему проекту.
И основной совет — не делайте руками, делайте сразу через скрипты. Это сэкономит кучу нервов и времени.
Также нужно составить чеклист переноса модулей/файлов. И после каждого этапа переноса по чеклисту нужно проверять: собирается ли проект. Да, это долго и замедляет процесс, но сильно упрощает жизнь в дальнейшем. По крайней мере будет понимание, что “вот из-за этого у меня проект развалился“.
Для подобных глобальных изменений нужно обязательно уведомлять всю команду и заранее договариваться, как будет идти разработка в это время, чтобы минимизировать конфликты. Самый радикальный инструмент для этого — фичефриз/кодфриз etc. Если вы проводите такой рефакторинг, поддерживайте регулярную связь с командой, сразу же сообщайте о проблемах и потенциально сложных для мерджа местах.
И не стоит недооценивать эту задачу, если у вас большая кодовая база. Она стопудово займёт больше времени, чем вам кажется.
На этом все, если у вас остались какие-либо вопросы или вы можете поделиться собственным опытом, пишите в комментариях.
Спасибо за внимание ;)
Маленький опрос для большого исследования
Мы проводим ежегодное исследование технобренда крупнейших IT-компаний России. Нам очень нужно знать, что вы думаете про популярных (и не очень) работодателей. Опрос займет всего 10 минут.
Пройти опрос можно здесь.