Привет-привет! Не будем мять бока и начнем максимально быстро.

Но для начала представлюсь. Меня зовут Таскаев Евгений  — я Android-разработчик в фичевой команде hh.ru. Пилю всякие интересные фичи, которыми вы пользуетесь каждый день*.

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

*

* - если вы каждый день открываете приложение "hh работа"

Для тех, кто не любит читать

У меня для вас есть выход! Эту статью можно глянуть в формате видео на ютубе в одном из эпизодов «Охэхэнных историй» ;)

Представим, что сейчас 2018 год. Представили? А теперь перестаньте плакать. В 2018 тоже было полно проблем.

У нас есть шикарный проект, в котором, по законам жанра, огромный монолит с 1 app gradle модулем и весь код в активити и несколько флэйворов. Основные из них - applicant и employerприложения соискателя и работодателя соответственно.

Структура проекта

В этом нет ничего страшного ровно до тех пор

  • пока команда не слишком разрослась и не стало трудно пилить фичи

  • пока старые разработчики не ушли, а новые знают не всё

  • пока не пришел хайптрейн с многомодульностью

  • пока рак на горе не свистнул

Подчеркните нужное. 

В нашем случае произошло так: были разного рода лики и баги, фиксить которые было сложно, к тому же старые разработчики разбежались, а новым было трудно ориентироваться в том, что осталось. Из-за этого было трудно декомпозировать задачи и разработка стала “дорогой”.

А какие у вас были проблемы на проектах пишите в комментариях)

Структура проекта до ренейминга

Спустя некоторое время, когда многое было зарефачено и отлажено, мы пришли к новому виду структуры проекта. Это как раз та структура, которая была у нас до недавнего времени.

За всё это время в плане структуры произошло следующее:

  • мы избавились от flavors и разделили приложения на два app gradle-модуля — модули которые зависят от плагина "com.android.application".

  • разбили монолит на отдельные library gradle-модули (и в дальнейшем просто gradle-модули) — модули которые зависят от плагина "com.android.library".

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

  1. Часть фичей лежали в папке feature в корне проекта, туда складывались gradle-модули фичей соискательского приложения с префиксом “feature-” в именовании.
    Например, фича резюме называлась feature-resume. Это рудиментарное решение, которое появилось почти в самом начале рефакторинга.

  2. Некоторые фичи состояли из нескольких сабфичей, по аналогии с 1 пунктом, но создавался не gradle-модуль, а папка также с префиксом, куда уже складывались нужные фичи. Слишком сложно? Сейчас приведу пример. Фича резюме, feature-resume, внутри нее лежали gradle-модули: feature-resume-profile, feature-resume-network и тд.

  3. Общие gradle-модули, которые использовались в обоих приложениях, лежали в папочке shared. Помимо этого, фичи внутри shared делились на core и feature. Соответственно, в какой папке лежал gradle-модуль, то имя папки добавлялось префиксом к названию модуля. Как можете заметить на картинке, с core что-то было не так. По правилам префикс у названия фичей должен быть “shared-core-”.

  4. Основная логика приложений лежала в папках соответствующих названиям приложений applicant (соискательское) и employer (работодательское).
    Структура была аналогична папке shared и фичи в них именовались так:

Проблемы с именованием

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

  2. Бардак в названии пакетов, конечно были какие то правила, но за 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, были поправлены проблемы с импортами. Да, мы заходили ручками в каждый файл.

Настройки Android Studio

По-хорошему, надо было идти сначала от корневых модулей (shared/core) и делать регулярный синк проекта в IDE. Так пришлось бы гораздо меньше страдать потом с импортами при переносах файлов, ибо Android Studio автоматически бы их сразу переименовывала во всех местах, куда дотянется.

Но эта мысль пришла в наши светлые головы уже после проделанной работы и полученного опыта.

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

А ребятам, которые не стали мержить свои ветки в develop, была предоставлена инструкция, как безболезненно влить в себе новый develop.

Но все было не так просто, как хотелось бы.

Приведу список основных пунктов, если вдруг вам понадобится:

  1. Вмержить к себе в ветку develop и сохранить лог конфликтов, на память чтобы понимать поле работы

  2. Конфликты типа modified — modified разрешить самим, руками, но таких конфликтов было минимум

  3. Остальные конфликты нужно разрешить в свою пользу

  4. Нужно перевести все созданные вами папки на новую структуру

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

  6. Разрулить силами Android Studio все неправильные импорты

  7. Удалить все неиспользуемые папки

В теории звучит просто, но в реальных условиях:

  • если переносить модуль/модули у себя в ветке, то рефакторинг будет применен к модулю, который располагался в старом месте. Приходилось повторить все, что уже сделал, но для перенесенного модуля (получилась двойная работа) + удалить старый модуль, причем сделать аккуратно, чтобы осталась история гита

  • если переносить файлы у себя в ветке, то рефакторинг будет применен к старым файлам, естественно появлялась неактуальная фантомная структура файлов, которые были уже перенесены, поэтому приходилось аккуратно их объединить с новыми

Всего файлов с конфликтами было ~500 в ~50 модулях.

Итоги ренейминга

  1. Названия всех модулей соответствуют структуре папок.
    И теперь включать gradle-модуль в settings.gradle можно через
    include(':shared:feature:location'), поскольку путь совпадает с неймингом.

    Для примера, раньше это делалось вот так:

  2. Все модули из папки feature из корня проекта (о чем я писал вначале), переместились на свои законные места в папку applicant/feature.

  3. Имена пакетов стали соотноситься с расположением фичи.
    Например, фича геолокации :shared:feature:location получила пакет ru.hh.shared.feature.location.

  4. Gradle-модули избавились от префиксов feature, shared, core и т.д.
    Но префиксы сабфичей решено было оставить... А потом и их решили не писать :)

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

Наши рекомендации

Прежде чем идти в эту историю, надо написать скрипты, которые автоматизируют большую часть работы. Можно воспользоваться нашими наработками, но сначала нужно проверить их на валидность вашему проекту.

И основной совет — не делайте руками, делайте сразу через скрипты. Это  сэкономит кучу нервов и времени.

Также нужно составить чеклист переноса модулей/файлов. И после каждого этапа переноса по чеклисту нужно проверять: собирается ли проект. Да, это долго и замедляет процесс, но сильно упрощает жизнь в дальнейшем. По крайней мере будет понимание, что “вот из-за этого у меня проект развалился“.

Для подобных глобальных изменений нужно обязательно уведомлять всю команду и заранее договариваться, как будет идти разработка в это время, чтобы минимизировать конфликты. Самый радикальный инструмент для этого — фичефриз/кодфриз etc. Если вы проводите такой рефакторинг,  поддерживайте регулярную связь с командой, сразу же сообщайте о проблемах и потенциально сложных для мерджа местах.

И не стоит недооценивать эту задачу, если у вас большая кодовая база. Она стопудово займёт больше времени, чем вам кажется.

На этом все, если у вас остались какие-либо вопросы или вы можете поделиться собственным опытом, пишите в  комментариях.

Спасибо за внимание ;)

Маленький опрос для большого исследования

Мы проводим ежегодное исследование технобренда крупнейших IT-компаний России. Нам очень нужно знать, что вы думаете про популярных (и не очень) работодателей. Опрос займет всего 10 минут.

Пройти опрос можно здесь