— Здравствуйте. Скажите, сколько стоит сделать приложение типа Uber?
Менеджер по входящим заявкам нашей компании получает звонки с таким содержанием стабильно раз в неделю. Понимать его стоит, как правило, так: либо клиент хочет себе настолько же успешный аналог приложения для связи между пассажиром и водителем, либо Uber для ______ (вписать нужную отрасль).
В такие моменты мы отвечаем, что Uber — это технически очень сложный проект с миллионными инвестициями и сотнями тысяч человекочасов разработки в месяц, и что делать его клон не очень целесообразно.
Теперь у нас есть аргумент в защиту нашей позиции. Разработчики Uber опубликовали в блоге компании заметку про опыт переноса приложения с одной архитектуры на новую, собственную. Это очень масштабное мероприятие подтверждает, что Uber — далеко не элементарное приложение. Мы не могли пройти мимо этого материала и не перевести его.
Статья может быть полезна не только мобильным разработчикам, но и менеджерам, сталкивающимся с описанной ситуацией.
Почему мы переделали Uber
Идея Uber проста: нажми на кнопку, и тебя повезут, куда нужно. Стартовав как сервис для заказа чёрных автомобилей премиум-класса, сегодня Uber предоставляет огромный спектр услуг, ежедневно координируя миллионы поездок в сотнях городов. Чтобы соответствовать реалиям 2017 года, нам пришлось заново разрабатывать всю архитектуру приложения.
Но с чего же начать? С того же, с чего мы начинали в 2009 году: с нуля. Мы решили полностью переписать наше приложение и переделать его дизайн. Отказ от наработанной программной базы и дизайнерских решений дал нам свободу действий там, где в ином случае пришлось бы искать компромиссы. На выходе мы получили абсолютно новое приложение, вылизанное до блеска и дающее все преимущества своей новой архитектуры как пользователям iOS, так и пользователям Android. Эта статья рассказывает о том, как мы создали новый архитектурный паттерн под названием Riblets и как с его помощью мы достигли поставленных целей.
Мотивация и цели
В то время, пока основной идеей Uber по-прежнему остаётся предоставление связи между водителями и пассажирами, наш продукт вырос в нечто гораздо большее, и архитектура прежнего приложения была уже не в состоянии справляться с возросшими требованиями. Добавление новых возможностей в приложение год от года становилось всё тяжелее. Такие дополнения, как UberPOOL, запланированные поездки и фото автомобилей, только усложняли работу. Риск того, что какая-то часть приложения перестанет работать после внесения очередного изменения, увеличивался день ото дня вместе с его размером, из-за чего любой эксперимент мог оказаться причиной долгой отладки.
В какой-то момент дальнейший рост оказался попросту невозможным. Чтобы сохранить высокое качество обслуживания пользователей, нам потребовалось заново пересмотреть ту простоту, с которой мы когда-то начинали, учитывая текущие запросы и возможность без затруднений развиваться в будущем.
Новое приложение должно быть простым как для пассажиров, так и для разработчиков Uber, каждый день работающих над его улучшением и добавлением новых возможностей. Чтобы переделать приложение с учётом интересов и первых, и вторых, мы поставили перед собой две задачи: результат должен быть максимально доступным для пассажиров и позволять проводить радикальные эксперименты в рамках продукта.
Надежность — это главное
Задачей разработчиков является создание приложения, надёжность которого составит 99,99%. Это значит, что приложение может быть в нерабочем состоянии не более одного часа в год или 1 минуты в неделю, а на 10000 пусков ему даётся не больше одного сбоя.
Чтобы достичь этого, структура нового приложения была разделена на основной и опциональный коды. Основной код — всё, что касается входа в приложение, подтверждения, завершения и отмены поездки — должен работать, как часы. Любые изменения, вносимые в основной код, проходят строгую проверку. Изменения, вносимые в опциональный код, проходят менее строгую проверку и могут быть отключены без необходимости останавливать всё приложение. Благодаря такой изоляции кода у нас появилась возможность добавлять новые возможности и в случае некорректной работы автоматически отключать их, не доставляя никаких неудобств пользователю.
Планы на будущее
Нам нужна платформа, для которой сотни команд и тысячи инженеров смогут разрабатывать качественные дополнения и встраивать их в приложение, не причиняя никаких неудобств пассажирам. Поэтому мы сделали нашу новую архитектуру кроссплатформенной, чтобы Android и iOS-разработчики могли работать с ней на равных.
Обычно для того, чтобы создать идеальное приложение для Android и iOS, требуется раздельный подход к его архитектуре, библиотекам и инструментам аналитики. Новая архитектура же должна использовать одни и те же паттерны и практики для обеих платформ. Это позволит нам учиться на собственных ошибках с максимальной выгодой, применяя решения, найденные на одной платформе, при работе с другой. В результате Android и iOS-разработчики могут тесно сотрудничать при создании новых опций и дополнений.
Хотя в отдельных случаях разработка проходит индивидуально для каждой платформы (например, реализация пользовательского интерфейса), у обеих платформ есть много общего:
- основная архитектура;
- названия классов;
- взаимосвязь между модулями бизнес-логики;
- разделение бизнес-логики;
- Plugin points (названия, existence, структура и т.д.);
- цепочки реактивного программирования;
- унифицированные компоненты.
Чтобы максимально использовать эти общие черты, наша новая архитектура требует чёткой организации и разделения между бизнес-логикой, логикой представления, потоками данных и маршрутизацией. Подобная архитектура помогает избежать запутанностей, упростить тестирование и, следовательно, увеличить продуктивность разработки и надёжность конечного продукта.
От MVC к Riblets
Определившись с задачами, мы решили выяснить, как можно улучшить нашу старую архитектуру и занялись исследованием возможностей. Кодовая база, которую мы унаследовали от старой версии Uber, была основана на архитектурном паттерне MVC. Мы изучили другие паттерны, например, VIPER, который мы частично использовали при создании Riblets. Основная инновация Riblets состоит в маршрутизации посредством бизнес-логики вместо логики представления. Если вы не знакомы с MVC и Riblets, прочитайте несколько статей, посвященных современным архитектурным паттернам для iOS (например, эту) Так вам будет проще понять преимущества и недостатки адаптации этих паттернов для Uber.
С чего мы начали: MVC (Model-View-Controller)
Первое приложение для пассажиров было написано небольшой группой разработчиков почти четыре года назад. В тот момент использование MVC казалось оправданным. Когда команда разработчиков выросла до нескольких сотен человек, мы столкнулись с тем фактом, что MVC не может расти вместе с нами. Основных причин было две:
- растущая архитектура MVC часто сталкивается с проблемой под названием massive view controller. Например, RequestViewController, начинавшийся с 300 строк кода, в текущем состоянии содержит более 3000 строк из-за большого количества возложенный на него обязанностей: бизнес-логика, манипуляция данными, проверка данных, сетевая логика, логика маршрутизации и т.д. Читать и модифицировать его стало очень сложно;
- архитектура MVC предоставляет весьма хрупкий процесс обновления кода и усложняет тестирование. В процессе разработки новых дополнений мы очень много экспериментируем. Все наши эксперименты сводятся к работе с операторами if-else. Каждый раз, когда попадается класс с большим количеством функциональностей, операторы if-else наваливаются друг на друга, сводя к нулю возможность тестирования. Вдобавок к этому, с ростом внутренних кусков кода, таких, как RequestViewController и TripViewController, создание обновлений для приложения превратилось в очень хрупкий и чувствительный процесс. Представьте себе, каково это — для каждого возможного изменения тестировать все возможные комбинации операторов if-else, вложенных друг в друга.
Раз уж мы хотели продолжать эксперименты с целью дальнейшего развития приложения и роста бизнеса Uber, пришлось признать, что данная архитектура исчерпала себя.
На пути: VIPER
В процессе поиска альтернативы MVC мы нашли VIPER, который является примером применения чистой архитектуры при разработке iOS-приложений. VIPER обладает рядом ключевых преимуществ по сравнению с MVC:
- он предлагает гораздо больше абстракции. Presenter содержит логику, которая соединяет бизнес-логику с логикой представления. Interactor занимается манипуляциями с данными, а также их проверкой. Это включает в себя запросы к бэкенду для манипуляций с состоянием, например для входа или заказа поездки. И, наконец, Router инициализирует переходы (один из них — как переход с домашней страницы на страницу подтверждения заказа);
- в случае с VIPER Presenter и Interactor являются Plain Old Objects, поэтому мы можем использовать обычное unit-тестирование.
Но мы также нашли у VIPER несколько недостатков:
- его конструкция, специализированная для iOS, означала, что нам придётся искать компромиссы для Android;
- его view-driven-логика означает, что приложения управляются компонентами представления, и всё приложение привязано к дереву представления;
- Бизнес-логика, исполняемая Interactor, который должен управлять состоянием приложения, всегда должна пройти через Presenter, в котором она теряется;
- при тесно связанных друг с другом деревьями представления и логики реализация элемента, который содержит только один тип логики, становится очень сложной.
Будучи явно лучше, чем MVC, VIPER не может полностью удовлетворить потребность Uber в расширяемой платформе с чёткой модульностью. Поэтому мы вернулись к доске для рисования, чтобы постараться разработать архитектурный паттерн с преимуществами VIPER и без его же недостатков. Результатом стал Riblets.
Riblets: архитектура приложения для пассажиров Uber
В нашем новом архитектурном паттерне логика разбита на небольшие независимо тестируемые куски. Каждый из кусков имеет одно-единственное предназначение согласно принципу единственной ответственности. Мы используем Riblets в виде этих модульных кусков, и структура всего приложения представляет из себя дерево Riblets.
Riblets и их компоненты
С Riblets мы распределили ответственности по шести различным компонентам, чтобы еще сильнее абстрагировать бизнес-логику от логики представления:
Чем отличается Riblets от VIPER и MVC? Прокладка маршрута производится бизнес-логикой вместо логики представления. Это значит, что приложение управляется потоком информации и принятых решений, а не внешним видом. Далеко не каждый кусок бизнес-логики в Uber имеет отношение к чему-то, что видит пользователь. Вместо использования бизнес-логики в ViewController в MVC или манипулирования состояниями приложения через Presenter в VIPER мы можем сделать отдельный Riblet для каждого куска бизнес-логики, создавая локальные группы, с которыми намного проще работать. Помимо этого мы разработали паттерн Riblet таким образом, что он не зависит от платформы. Последнее позволяет объединить разработку для iOS и Android.
Каждый Riblet включает в себя Router, Interactor и Builder с его собственным Component (отсюда и название) и, при необходимости, Presenters и Views. Router и Interactor занимаются бизнес-логикой, в то время, как Presenter и View занимаются логикой представления.
Давайте разберём, чем занимается каждый элемент Riblet, используя для примера Production Select Rible.
Выбор тарифа в новом приложении Uber
Builder
Builder устанавливает все первичные элементы Riblet и зависимости между ними. В Product Selection Riblet этот элемент устанавливает зависимость потока данных для необходимого города.
Component
Component получает и устанавливает зависимости Riblet. Это включает в себя службы, потоки данных, и всё остальное, что не является первичным элементом Riblet. Product Selection Component получает и устанавливает зависимость от городского потока, прикрепляет её к соответствующим сетевым событиям и внедряет в Interactor.
Routers
Routers формируют дерево приложения, прикрепляя и открепляя дочерние Riblets. Эти решения им передаёт Interactor. Также Routers управляют жизненным циклом Interactor, включая и выключая его при определённых состояниях приложения.
Routers содержат два куска бизнес-логики:
- Helper methods — для присоединения и отсоединения Routers.
- State-switching — логика для определения состояния дочерних модулей.
Product Selection Riblet не имеет дочерних Riblets. Router его родительского Riblet, Confirmation Riblet, несёт ответственность за прикрепление Product Selection Router и добавления его View во Views-иерархию. Затем, когда продукт выбран, Product Selection Router деактивирует свой Interactor.
Interactors
Interactors выполняют бизнес-логику. Это включает:
- отправку сервисных вызовов для запуска какого-либо действия, например, заказа поездки;
- отправку сервисных вызовов для получения данных;
- определение состояния для переключения. К примеру, если корневой Interactor видит, что токен авторизации пользователя отсутствует, он посылает своему Router запрос на переключение в состояние Welcome.
Product Selection Interactor берёт городской поток, содержащий данные, включающие предложения служб этого города, информацию о ценах, примерное время поездки и фотографии автомобилей, и передает эту информацию в Presenter. Если пользователь переходит из uberPOOL в uberX, Interactor получает эту информацию от Presenter, после чего он собирает все соответствующие данные и передаёт их обратно в View, чтобы он отобразил автомобили uberX и примерное время прибытия. Если вкратце, Interactor выполняет всю бизнес-логику, которую затем отображает View.
View(Controller)
Views конфигурируют и обновляют пользовательский интерфейс, включая создание и расположение отдельных элементов, взаимодействие с пользователем, заполнение элементов интерфейса данными и анимацией. View в Product Selection Riblet отображает объекты, которые ему передаёт Presenter (конфигурации поездки, цены, примерное время прибытия, изображение автомобиля на карте) и возвращает действия пользователя (т.е. выбор продукта).
Presenter
Presenter управляет коммуникацией между Interactors и Views. От Interactors к Views, Presenter передаёт бизнес-модели объектов, которые отображает View. В случае с Product Selection Riblet это данные о ценах и изображения автомобилей. Также задачей Presenter является преобразование действий пользователя (таких, как нажатие на кнопку выбора продукта) в команды, которые затем передаются в Interactor.
Собираем всё вместе
Каждый Riblet содержит только одну пару Router и Interactor, но в нём может быть несколько частей представления. Riblet, отвечающий исключительно за бизнес-логику и не имеющий элементов пользовательского интерфейса, не содержит view-части.
Riblet может быть:
- single-view (один Presenter и один View);
- multi-view (либо один Presenter и несколько View, либо несколько Presenter и View);
- viewless (без Presenter и View).
Это позволяет деревьям бизнес-логики быть глубокими и отличаться от более плоских деревьев представления, благодаря чему переключение между экранами становится проще.
Например, Ride Riblet является viewless. Его задача — проверка того, есть ли у пользователя активная поездка. Если это так, он подключает Trip Riblet, который показывает маршрут поездки на карте. Если нет, то подключается Request Riblet, показывающий экран, на котором пользователь может заказать поездку. Основной задачей Riblets, не содержащих логику представления (таких как Ride Riblet), является изоляция бизнес-логики, которая управляет нашими приложениями, благодаря чему сохраняется модульная структура нашей новой архитектуры.
Как создать приложение из Riblets
Riblets объединяются в древо приложения и им необходимо поддерживать связь друг с другом для того, чтобы обновлять информацию или провести пользователя к следующему шагу заказа поездки. Перед тем, как рассказать, каким образом они связываются друг с другом, давайте разберемся, как данные передаются внутри отдельно взятого Riblet.
Поток данных внутри Riblet
Interactor хранит бизнес-логику, которая управляет приложением и соответствует сфере полномочий этого Interactor. Этот элемент делает запросы к службам (service calls), чтобы получить необходимые данные.
Данные в новой архитектуре всегда передаются только в одном направлении. Они переходят от Service к Model Stream, а затем к Interactor. Interactors, планировщики событий и push-уведомления из интернета могут делать запросы к Services для внесения изменений в Model Stream.
Model Stream создаёт иммутабельные модели. Это создаёт требования, согласно которым для того, чтобы изменить состояние приложения, Interactor классы должны использовать сервисный слой.
Примеры потоков:
- от backend сервиса к View: запрос к службам, например, запрос статуса, данные о котором берутся из backend. Данные помещаются в неизменяемый Model Stream. Interactor, слушающий этот поток, замечает новые данные и передаёт их в Presenter. Presenter преобразовывает данные и передаёт их к View;
- от View к backend: пользователь нажимает на кнопку, например, «Вход», и View передаёт это действие в Presenter. Presenter вызывает метод sign-in в Interactor, в результате чего создается запрос к сервисам для входа в приложение. Сервис отправляет возвращённый токен в поток, а Interactor, слушающий этот поток, переключается на Home Riblet.
Взаимодействие между Riblets
Когда Interactor принимает какое-либо решение, ему может понадобиться проинформировать другой Riblet об этом и переслать данные. Для этого Interactor вызывает интерфейс, который согласовывает его с Interactor другого Riblet.
В случае, если связь идёт вверх по дереву к Interactor родительского Riblet, интерфейс определяется как слушатель (Listener). Слушатель почти всегда устанавливается Interactor’ом родительского Riblet. Если связь идёт вниз к дочернему Riblet, интерфейс должен быть определён как делегат, и устанавливается Interactor’ом дочернего Riblet. Делегаты используются только для прямой синхронной связи между элементами, например родительского Interactor с дочерним.
В случае нисходящей связи родительский Riblet может направить к Interactor дочернего Riblet observable поток данных, родительский Interactor может затем отправлять данные через этот поток вместо интерфейса-делегата. В большинстве случаев нисходящей связи для передачи данных подобный метод должен быть предпочтительным.
Например, когда гипотетический ProductSelectionInteractor понимает, что продукт выбран, он обращается к своему слушателю, установленному ConfirmationInteractor’ом, и передаёт ему view-идентификатор (ID) выбранного автомобиля. ConfirmationInteractor сохраняет этот ID, чтобы затем отправить его в запросе к сервисам и отправляет в свой Router запрос на “отключение” ProductSelection Riblet.
Структурируя подобным образом поток данных внутри и между Riblets, мы можем быть уверены в том, что нужные данные в нужный момент будут отображены на нужном экране. Так как дерево Riblets базируется на бизнес-логике, мы можем устанавливать связь на уровне бизнес-логики вместо логики представления. Это имеет гораздо больший смысл и помогает сохранить изоляцию кода, защищая разработку приложения от ненужных сложностей.
Возвращение к истокам
Когда мы решили полностью переделать наше приложение, мы хотели стать ближе к пользователю благодаря увеличившейся надежности и создать правильные условия для будущих разработок. Для достижения этих двух целей создание новой архитектуры было жизненно необходимым шагом.
Как мы добились увеличения надежности и доступности для конечного пользователя
Riblets имеют чётко разграниченные ответственности, поэтому тестирование стало намного проще. Каждый Riblet может быть протестирован независимо от остальных. С такими возможностями мы можем быть уверены в том, что у нашего приложения не появятся баги после очередного обновления. Благодаря тому, что каждый Riblet несёт одну-единственную ответственность, мы смогли легко разделить Riblets на основной (необходимый для входа в приложение и заказа поездки в uberPOOL и uberX) и опциональный код. Предъявляя более высокие требования к проверкам основного кода, мы можем быть уверены в том, что основные функции программы будут работать с максимальной безотказностью.
Мы также сделали возможным глобальный откат основных функциональностей программы к гарантированно рабочему состоянию. Весь опциональный код отмечен флагами master feature, которые могут быть выключены, если части кода содержат ошибки или работают нестабильно. При самом худшем варианте развития событий мы можем отключить абсолютно весь опциональный код, оставив только основу. Учитывая наши строжайшие требования к основному коду, мы можем быть уверены, что оно будет работать всегда.
Как мы создали правильные условия для будущих разработок
Благодаря узкой специализации каждого Riblet мы смогли провести чёткую границу между бизнес-логикой и логикой представления. Это сможет предотвратить непомерное разрастание кодовой базы и сохранит её простоту. Так как новая архитектура является кроссплатформенной, iOS и Android-разработчики могут легко понимать работу друг друга, учиться на ошибках товарищей и сообща работать над дальнейшим развитием Uber. Эксперименты будут меньше влиять на работоспособность ядра приложения, так как Riblets помогают нам отделить опциональный код от основного. Мы сможем испытывать новые дополнения, созданные в виде плагинов, не опасаясь, что они могут случайно вывести из строя всё приложение.
Так как главной особенностью Riblets является максимальное абстрагирование и разделение ответственностей, а также чётко определенные потоки данных и способы коммуникации, дальнейшее продолжение разработки становится очень простым, и эта архитектура будет служить нам верой и правдой долгие годы.
Готовность идти вперед
Мы возлагаем большие надежды на нашу новую архитектуру. Мы полностью переписали код приложения для пассажиров, заново добавив всё, что уже существовало ранее, проведя опросы пользователей, тематические исследования, различные тесты и обновив опции, среди которых теперь есть лента новостей. Мы старались делать всё возможное, чтобы наши пользователи получили обновленное приложение как можно скорее, поэтому мы прислушиваемся к отзывам со всего земного шара касательно дизайна, опций, локализаций, работы на различных устройствах и возможностей тестирования. Несмотря на то, что запуск приложения уже позади, впереди ещё много работы.
Благодаря новой архитектуре у нас появилось огромное количество возможностей для дальнейшего развития. Если честно, мы провели пару месяцев за сборкой прототипов, чтобы убедиться, что мы всё сделали правильно. Теперь мы полностью уверены в том, что у нас есть архитектура, с которой мы сможем достичь очень многого. Если такая работа вам нравится, вы можете стать частью нашей истории и улучшить мнение об Uber, участвуя в разработках в качестве iOS или Android-разработчика.
Автор: Лайв Тайпинг