Введение

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

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

Вот какие изменения должны были произойти:

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

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

На пути к этому стояли понятные задачи и условия:

  • Поменять внешний вид приложения, перейти на новую дизайн-систему там, где еще этого не сделали.

  • Сохранить качество приложения как в новом, так и в старом дизайне.

  • Иметь возможность плавно перейти на новый дизайн и полностью откатить.

Планированиe

Обдумав с командой, как удовлетворить поставленным задачам в текущих условиях, мы решили всю разработку разделить на два последовательных этапа:

  1. Подготовка окружения.
    Результат: кодовая база, подготовленная к параллельной разработке. На этом этапе задействована только команда редизайна.

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

Подготовка окружения

Этим занималась только виртуальная команда, чтобы не останавливать развитие продукта. Мы обозначили для себя такие цели на этом этапе:

  • Обеспечить возможность тестирования старого и нового дизайна.
    То есть уметь с помощью feature toggle выбирать, в каком дизайне у пользователя будет отображаться приложение. Это необходимо как для сохранения качества, так и для плавной раскатки, о которой расскажу ниже.

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

Далее я расскажу, как мы достигали этих целей.

Точка входа в приложение

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

Как мы попадали в приложение раньше:

При входе в MainActivity совершался ряд проверок, часть из них синхронно:

  1. Авторизация и регистрация.
    На этом этапе мы проверяем, зарегистрирован ли водитель в приложении, а также прошёл ли он регистрацию. Если нет, то запускаем отдельный сценарий, позволяющий эти шаги совершить. В итоге снова попадаем в MainActivity и вновь запускаем проверки.

  2. SplashActivity.
    На этом этапе мы загружаем параметры экспериментов: А/В- и прочих тестов.

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

Решили, что модифицировать и наполнять старую MainActivity мы больше не будем, а напишем новую по текущим правилам разработки: в отдельном модуле, на Kotlin и т.п. А после тестирования просто удалим всю MainActivity. Кстати, новая activity называется WilhelmActivity. Это название будет далее еще не раз встречаться. Wilhelm отсылает к названию нашей дизайн системы Wilhelm Design System в честь немецкого изобретателя Вильгельма Бруна, подарившего миру таксометр.

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

Новый вход в приложение:

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

Для перехода на новую точку входа мы также поменяли тег запускающего activity в AndroidManifest.xml:

<activity-alias
   android:name="MainActivity"
   android:targetActivity=".feature.start.StartActivity">
   <intent-filter>
       <action android:name="android.intent.action.MAIN" />
       <category android:name="android.intent.category.LAUNCHER" />
   </intent-filter>
</activity-alias>

<activity-alias> стали использовать для того, чтобы сохранить иконку приложения в лаунчере. Также мы перенаправили все наши явные и неявные intent'ы, обрабатываемые MainActivity, на StartActivity.

Перенос функциональности MainActivity

Теперь можем тестировать вход в приложение в зависимости от соответствующего feature toggle. Следующая большая проблема — MainActivity:

  • MainActivity — это большое легаси, содержащее кучу бизнес-логики (больше 3,5 тысяч строк кода вместе с MainPresenter).

  • MainActivity мы ещё не успели вынести за пределы монолита, а для WilhelmActivity выделили отдельный модуль.

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

  • это займет много времени;

  • на время раскатки придётся поддерживать две кодовые базы.

В итоге мы сделали так:

  • Скальпелем отделили логику MainActivity, перенесли в отдельную сущность и оставили её в монолите.

  • Закрыли эту сущность за абстракцией и стали её использовать в MainActivity и в WilhelmActivity.

Абстракция получилась из двух частей. Первая выглядит так:

interface MainActivityDelegate {
  fun onCreate(
    activity: AppCompatActivity,
    savedState: Bundle?,
    callbacks: Callbacks
  )
  fun onStart()
  fun onResume()
  fun onPause()
  fun onStop()
  fun onDestroy()
  fun onSaveInstanceState(outState: Bundle)
  fun onNewIntent(intent: Intent)
  fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) 
} 

Фактически она дублирует методы жизненного цикла Activity, в которых MainActivity делала то, что влияло на бизнес-логику приложения. Тут, однако, можно заметить, что в onCreate передаётся ссылка на некий Callbacks. Это, по сути, методы, которые вызываются, когда сценарий какой-либо бизнес-логики начинает упираться в отображение пользовательского интерфейса, а он в разных дизайнах работает по-разному. Вот как выглядит Callbacks:

interface Callbacks {
	fun onUserLoggedOut()
	fun onMenuSelected()
	fun onPreOrdersSelected()
	fun onNewsSelected()
	. . .
}

Такой контракт использует только activity нового дизайна. Activity же старого дизайна использует расширенный контракт, поскольку в дополнение к этой функциональности умеет делать и другое. Выглядит контракт для старого дизайна так:

interface MainActivityLegacyDelegate : MainActivityDelegate {
	fun onCabinetSelected()
	fun onLogoutAccepted()
	fun onActiveOrderClicked()
	. . .
}

interface Callbacks : MainActivityDelegate.Callbacks {
	fun onTabItemUpdated(tabItem: BottomTabItem)
	fun onUpdatePagesRequired()
	fun onToolbarProgressStateChanged(showProgress: Boolean)
	fun onRegistrationPassed()
	fun onReturnedFromSettings()
	. . .
}

Старому и новому дизайну за интерфейсами MainActivityDelegate и MainActivityLegacyDelegate с помощью Dagger предоставляется одна и та же реализация, которая в том случае, если метод работает только в старом дизайне, безопасно приводит его к интерфейсу callback'ов старого дизайна:

class MainActivityDelegateImpl @Inject constructor(. . .)
: MainActivityLegacyDelegate,
  MainContract.View {

  private var callbacks: MainActivityDelegate.Callbacks?
    
  override fun showMenu() {
  	callbacks?.onMenuSelected()
  }
  override fun showDriverCabinet() {
  	(callbacks as? MainActivityLegacyDelegate.Callbacks)
  		?.onCabinetSelected()
  }
}

Отчасти мы выбрали такое решение с разными типами с оглядкой на предстоящее избавление от старого дизайна: мы просто удалим контакт MainActivityLegacyDelegate и проект перестанет собираться, пока мы не вычистим его упоминания. Это гарантирует, что мы точно не оставим за собой лишний код.

Огромный кусок кода мы достаточно просто перенесли в новый дизайн и знаем, что он гарантированно работает не хуже. Дальше — работа с картой.

Работа с картой

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

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

По большому счёту сейчас у нас есть только две фичи, которые взаимодействуют с картой. Это распределение спроса (на карте отображаются зоны, заказы в которых могут быть привлекательны для водителей) и сценарий выполнения заказа. Вот как это работало в старом дизайне:

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

Мы решили ответственность за инициализацию карты отобрать у фичей и отдать главному экрану.

Теперь activity нового дизайна WilhelmActivity инициализирует карту, и при успешной инициализации складывает её в репозиторий. Этот репозиторий используют все фичи, которым нужна карта для работы с ней.

Дело осталось за малым: в каждой фиче, работающей с картой, нужно было добавить ветку для её инициализации в новом дизайне. А дальше делать всё то же, что и раньше: рисовать на карте свои данные.

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

3.4 FeatureMediator. Инструмент для взаимодействия фич

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

В принципе, подходов к решению задачи передачи данных между фрагментами немало, но у нас в старом дизайне использовался, наверное, самый простой и доступный из коробки Android SDK — callback'и. Вот о чём я говорю:

class DashboardFragment: Fragment {

  private fun onSomeButtonClicked(){
      findEventListener()?.onSomeButtonClicked()
  }

  private fun findEventListener(): DashboardEventListener? {
    val target = targetFragment
		val parent = parentFragment
		val activity = activity
		return when {
			target is DashboardEventListener -> target
			parent is DashboardEventListener -> parent
			activity is DashboardEventListener -> activity
			else -> null
		}
	}
}

Сигнатура классов, обрабатывающих callback'и, в таком случае разрасталась до монструозных размеров:

public class MainActivity extends MainBaseActivity implements
   MainContract.View,
   MainContract.ViewActions,
   ViewPager.OnPageChangeListener,
   MainContract.KeyboardListener,
   MainContract.AppToolbarActions,
   MainContract.BottomTabActions,
   CustomScrollViewPager.ViewPagerPagingEnabledListener,
   MainContract.ActiveOrderToolbarActions,
   OnLogoutAcceptedListener,
     DashboardEventListener,
   MainActivityLegacyDelegate.Callbacks {

}

Тут есть ряд недостатков, которые не позволяли нам использовать такой же подход в новом дизайне:

  • Привязка к навигации.
    Да, мы можем встроить фрагмент из старого дизайна в новый, однако придётся адаптировать новый код, чтобы он мог обрабатывать события этого фрагмента.

  • Неявный подход.
    Поиск callback'а во фрагменте, как и поиск фрагмента для вызова его метода (currentFragment as? DashboardFragment)?.doSomenthing(), — неявные.

  • Отношение 1 к 1.
    Вызовы типа findEventListener() подразумевают, что есть только один обработчик событий. Это ограничение Android SDK: у фрагмента не может быть больше одной activity, больше одного parentFragment или больше одного targetFragment.

Мы выбрали подход, решающий эти проблемы и позволивший старые фичи использовать в обоих дизайнах: взяли архитектурные паттерны Mediator и Command. Вот так это работает внутри фичи:

class InAppNotificationFragment : Fragment(), ToastActionListener {

   private val commands = listOf(
       InAppNotificationCommand.ShowNotification { showNotification(it) },
       InAppNotificationCommand.RemoveNotificationsForClientId {
           removeNotificationsForClientId(it)
       }
   )
   override fun onStart() {
       super.onStart()
       mediator.registerFeatureCommands(commands)
   }
   override fun onStop() {
       mediator.unregisterFeatureCommands(commands)
       super.onStop()
   }
   override fun onToastClicked() {
       recentNotification?.let {
           mediator.onFeatureAction(
               InAppNotificationAction.NotificationClicked(
                   it.notificationId,
                   it.clientId
               )
           )
       }
   }
}

Каждая фича имеет свой набор команд, регистрирует их в медиаторе в начале своего жизненного цикла и отписывает команды в конце жизненного цикла. Кроме того, медиатор на вход onFeatureAction() принимает действия фич и решает, какие команды других фич вызывать. В принципе, выше описан весь интерфейс нашего FeatureMediator. А вот пример фичи, которая работает и в старом, и в новом дизайне:

class DashboardFragment: Fragment{

  override fun onSettingsClicked() {
      findEventListener()?.onSettingsClicked()
      mediator.onFeatureAction(DashboardAction.SettingsClicked)
  }
}

FeatureMediator является синглтоном и предоставляется фичам с помощью Dagger.

Что осталось

После описанных выше работ по большому счету остались три задачи:

  • Перенос старых фич.

  • Адаптация старых фич.

  • Создание новых фич.

Эти задачи решались уже всем отделом. Мы распределяли их между командами, старясь балансировать нагрузку с расчётом, что в первом квартале запланирован релиз первой работоспособной версии. Нам крайне помогла уже подготовленная дизайн-система со всеми компонентами и заготовкой кастомного BottomSheetFragment, на основе которого сейчас работают уже практически все «полноэкранные» фичи.

Распространение версии

Раскатку нового дизайна мы спланировали и проводили в два этапа:

  • Альфа-тестирование. На основе альфа-канала Google Play Console.

  • Основное тестирование. Раскатка нового дизайна в проде.

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

Альфа-тестирование

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

Кроме того, для релиза в альфа-канал мы подготовили отдельную версию приложения — 5.99, в то время как в релизе была версия 5.57. Именно с 5.99 фича-тоггл нового дизайна может прийти в состоянии true. Было совершенно очевидно, что от версии 5.57 до версии 5.99 достаточно времени, чтобы стабилизировать новый дизайн и успеть перейти к основному тестированию на версии 6.0.

В релизе 5.99 мы пригласили водителей в приватный канал в WhatsApp. Там мы общались с ними, собирали первые отзывы и исправляли критические проблемы.

Основное тестирование

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

Комментарии (0)