Вводная часть (со ссылками на все статьи)
В водной статье я уже писал о том, что планируемым клиентом для проекта должен стать клиент Android: доступный большой аудитории, лёгкий, функциональный, красивый, быстрый (не приложение, а мечта!). Если с основаниями выбора платформы всё понятно, то с тем как реализовывать на базе неё все перечисленные требования – ясно было далеко не всё.
Ранее разработкой под Android не занимался поэтому достаточно ценными источниками информации для меня являлись:
- Книга «Android Programming: The Big Nerd Ranch Guide» (ознакомление с переводом от издательства «Питер» подтвердило ранее имевшийся принцип: «можешь читать оригинал – читай оригинал»);
- Сайт Google по разработке для Android;
- Книга «Efficient Android Threading» от издательства O’Reilly;
- Видео с проекта Яндекса «Мобилизация».
После изучения указанных источников вопросов с архитектурой Android и взаимодействия их компонентов не осталось. Однако остался один наиважнейший вопрос: какова будет структура самого приложения? Пара примеров и прототипов показала, при росте функционала всё быстро начинало превращаться в «лапшу»:
- Логика работы с объектами Android (Activity, Preferences, TextView ….) перемешивалась с бизнес-логикой;
- Объекты хранения фигурировали в коде построения интерфейса;
- Модульное тестирование превращалось в ад из-за необходимости работы с родными объектами Android и их подмены экземплярами Robolectric;
- Проверка асинхронного кода была возможна только на устройстве или эмуляторе (по принципу: «запустил-проверил-повторил»).
Стало понятно, что нужно сделать шаг назад и осмотреться: Android существует не первый год, есть люди, которые разрабатывают код продолжительное время под эту платформу, есть большие развивающиеся проекты – соответственно есть откуда подчерпнуть информацию о хорошо зарекомендовавших себя практиках.
Основными критериями в поиске хорошей архитектуры для Android-приложения были:
- лёгкая тестируемость разрабатываемого кода и его компонентов — легко тестируемый код просто развивать и изменять без страха создать баг или «свалить» приложение;
- слабая связанность компонентов, при которой части приложения/компоненты могут разрабатываться разными разработчиками без необходимости сверхинтенсивного взаимодействия (хотя бы какое-то время).
Поиски привели меня к интересному ролику на YouTube: «Пишем тестируемый код» (запись выступления Евгения Мацюк(а) с конференции по мобильной разработке Mobius) (там было МНОГО ВСЕГО!), в котором описывалось то, что было мне нужно. Для реализации потребовалось изучить некоторые дополнительные ресурсы и инструменты:
- Оригинальная статья «The Clean Architecture», необходимо отметить наличие хорошей поясняющей статьи на Habr’е «Заблуждения Clean Architecture»;
- Блог Fernando Cejas со статьями «Architecting Android...The clean way?» и «Architecting Android...The evolution»;
- Dagger 2;
- RxJava.
Разработка прототипа с указанными практиками совместно с изучением RxJava заняла немало времени, однако через какое-то время был готов первый прототип. Отличительной особенностью его являлось ужасное количество создаваемых интерфейсов и классов при добавлении новых экранов: 3 интерфейса и 3 класса (Activity/Fragment и его интерфейс, Presenter и его интерфейс, Interactor и его интерфейс) – классический пример overengineering’а. Формально к текущему моменту ничего не поменялось, но я полагаю это оборотная сторона получаемых преимуществ. Зато на выходе получаем легко тестируемое приложение со слабо связанной структурой.
Реализация
Приведу для освежения в памяти компоненты Clean Architecture из статьи на Habr’е «Заблуждения Clean Architecture».
Каждый компонент Android и элемент выбранной архитектуры представлены в следующей таблице:
Класс | Уровень | Реализуемые интерфейсы | Назначение |
Реализация Activity/Fragment (XXXX_Activity / XXXX_Fragment) | UI | I_XXXX_View | Фактическая реализация действия с элементами Android: изменение свойств, получение обратных вызовов, старт сервисов, работа с Android API |
XXXX_PresenterImpl | UI | I_XXXX_Presenter | Координация действий уровня представления, логика представления – вызовы методов интерфейсов I_XXXX_View, I_XXXX_Interactor |
XXXX_InteractorImpl | Business/Use Cases | I_XXXX_Interactor | Реализация основной логики приложения, вызовы методов интерфейсов I_XXXX_Repository |
XXXX_RepositoryImpl | Data/Repository | I_XXXX_Repository | Реализация непосредственного взаимодействия с источниками данных, внешними API, сетью и БД Android, ContentProvider’ами и т.д. |
Организация взаимодействия
Взаимодействие компонентов и передача данных организована с учётом того, что пользователь любого Android- приложения больше получает данных чем, вводит их. Соответственно:
- передача сигналов в более глубокие слои идёт через обычные синхронные вызовы (нажали кнопку/прокрутили/ввели данные -> вызвали метод);
- получение данных из нижних слоёв организовано через асинхронные Rx-потоки (получили вызов -> выслали данные с результатами);
- минимизировано синхронное получение данных (большая часть в коде инициализации и в других вспомогательных и редких экранах).
Организация пакетов
В оригинальной статье Fernando Cejas предлагалось 2 варианта организации «по уровням» и «по функционалу», я для себя выработал комбинированный подход:
- Вначале по уровням (ui, data, business)
- В «ui» по основным экранам «news_watcher», «news_tape» и т.д.
- В «data» и «business» — по основным сущностям «news_header», «news_article» и т.д.
Интересной особенностью стало, то что количество Interactor’ов стало равно «кол-во основных экранов» + «кол-во сущностей»: нередки ситуации, когда требуется организовать хитрое получение данных (например, с комбинированием из разных источников) и копировать данный код в каждый Interactor, где он требуется совершенно не хотелось. При этом с учётом того, что Interactor используются в единственном экземпляре – они могут хранить некое состояние, важное для выполнения метода, я реализовал это следующим образом: Interactor’ы экранов, обращаются к Interactor’ам сущностей за соответствующими методами (что приводит к появлению делегирующих методов в Interactor’ах экранов).
Инициализация
- Activity/Fragment:
- создаётся Android (non-singletone, w/ scope)
- инициализируется в методах View#onCreate() (с завершением в Fragment#onViewCreated() для Fragment)
- Presenter внутри присваивается ч/з Dagger2
- инициализация Presenter внутри осуществляется в указанных методах (View#onCreate(), с завершением в Fragment#onViewCreated() для Fragment)
- Presenter:
- создаётся через Dagger2 (non-singletone, w/ scope)
- View присваивается самим View, ч/з Presenter#bindView()
- инициализируется в методе Presenter#initializePresenter(), вызываемой View (потому что инициализацию нужно делать в подходящий момент, после инициализации View)
- Interactor внутри присваивается/инициализируется ч/з Dagger2
- создание связи Interactor->Presenter выполняется в методе Presenter#initializePresenter() (ч/з другие методы Interactor'а для Rx-инициализации)
- Interactor:
- создаётся через Dagger2 (singletone, w/o scope)
- инициализируется через Dagger2 (Interactor#initializeInteractor)
- Repository внутри присваивается/инициализируется ч/з Dagger2
- Repository:
- создаётся через Dagger2 (singletone, w/o scope)
- инициализируется через Dagger2 (Repository#initializeRepository)
Подходы к тестированию
С точки зрения тестирования – ничего революционного:
- UI уровень – JUnit + Mockito + Robolectric
- Business уровень – JUnit + Mockito
- Data уровень — JUnit + Mockito
Спасибо за внимание!
Автор: fedor_malyshkin