В этой статье мы поговорим о проектировании архитектуры и создании мобильного приложения на основе паттерна MVP с использованием RxJava и Retrofit. Тема получилась довольно большой, поэтому подаваться будет отдельными порциями: в первой мы проектируем и создаем приложение, во второй занимаемся DI с помощью Dagger 2 и пишем тесты unit тесты, в третьей дописываем интеграционные и функциональные тесты, а также размышляем о TDD в реалиях Android разработки.
Содержание:
Полное содержание
Введение
Для лучшего понимания и последовательного усложнения кода, разделим проектирование на два этапа: примитивная (минимально жизнеспособная) и обычная архитектура. В примитивной обойдемся минимальным количество кода и файлов, потом улучшим этот код.
Все исходники вы можете найти на github. Бранчи в репозитории соответствуют шагам в статье: Step 1 Simple architecture — первый шаг, Step 2 Complex architecture — второй шаг.
Для примера попробуем получить список репозиториев для конкретного пользователя с помощью Github API.
В нашем приложении мы будем использовать Rx, поэтому для понимания статьи необходимо иметь общее представление об этой технологии.Рекомендуем почитать серию публикаций Грокаем RxJava, эти материалы дадут хорошее представление о реактивном программировании.
Шаг 1. Простая архитектура
Разделение по слоям, MVP
При проектировании архитектуры будем придерживаться паттерна MVP. Более подробно можно почитать тут:
https://ru.wikipedia.org/wiki/Model-View-Presenter
http://habrahabr.ru/post/131446/
Разделим всю нашу программу на 3 основных слоя:
Model — тут получаем и храним данные. На выходе получаем Observable.
Presenter — в данном слое хранится вся логика приложения. Получаем Observable, подписываемся на него и передаем результат во view.
View — слой отображения, содержит все view элементы, активити, фрагменты и прочее.
Условная схема:
Model
Слой данных должен отдавать нам Observable<List<Repo>>, напишем интерфейс:
public interface Model {
Observable<List<Repo>> getRepoList(String name);
}
Retrofit
Для упрощения работы с сетью используем Retrofit. Retrofit – библиотека для работы с REST API, она возьмет на себя всю работу с сетью, нам остается только описать запросы с помощью интерфейса и аннотаций.
Retrofit 2
Про Retrofit в рунете достаточно много материалов (http://www.pvsm.ru/android/58484, http://tttzof351.blogspot.ru/2014/01/java-retrofit.html).
Основное отличие второй версии от первой в том, что у нас пропала разница между синхронными и асинхронными методами. Теперь мы получаем Call<Data> у которого можем вызвать execute() для синхронного или execute(callback) для асинхронного запроса. Также появилась долгожданная возможность отменять запросы: call.cancel(). Как и раньше, можно получать Observable<Data>, правда теперь с помощью специального плагина
Основное отличие второй версии от первой в том, что у нас пропала разница между синхронными и асинхронными методами. Теперь мы получаем Call<Data> у которого можем вызвать execute() для синхронного или execute(callback) для асинхронного запроса. Также появилась долгожданная возможность отменять запросы: call.cancel(). Как и раньше, можно получать Observable<Data>, правда теперь с помощью специального плагина
Интерфейс для получения данных о репозиториях:
public interface ApiInterface {
@GET("users/{user}/repos")
Observable<List<Repo>> getRepositories(@Path("user") String user);
}
Реализация Model
public class ModelImpl implements Model {
ApiInterface apiInterface = ApiModule.getApiInterface();
@Override
public Observable<List<Repo>> getRepoList(String name) {
return apiInterface.getRepositories(name)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
}
}
Работа с данными, POJO
Retrofit (и GSON внутри него) работают с POJO (Plain Old Java Object). Это значит, что для получения обьекта из JSON вида:
{
"id":3,
"name":"Andrey",
"phone":"511 55 55"
}
Нам понадобится класс User, в который GSON запишет значения:
public class User {
private int id;
private String name;
private String phone;
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
// etc
}
Руками генерировать такие классы естественно не нужно, для этого есть специальные генераторы, например: www.jsonschema2pojo.org.
Скармливаем ему наш JSON, выбираем:
Source type: JSON
Annotation style: Gson
Include getters and setters
и получаем код наших файлов. Можно скачать как zip или jar и положить в наш проект. Для репозитория получилось 3 обьекта: Owner, Permissions, Repo.
Пример сгенерированного кода
public class Permissions {
@SerializedName("admin")
@Expose
private boolean admin;
@SerializedName("push")
@Expose
private boolean push;
@SerializedName("pull")
@Expose
private boolean pull;
/**
* @return The admin
*/
public boolean isAdmin() {
return admin;
}
/**
* @param admin The admin
*/
public void setAdmin(boolean admin) {
this.admin = admin;
}
/**
* @return The push
*/
public boolean isPush() {
return push;
}
/**
* @param push The push
*/
public void setPush(boolean push) {
this.push = push;
}
/**
* @return The pull
*/
public boolean isPull() {
return pull;
}
/**
* @param pull The pull
*/
public void setPull(boolean pull) {
this.pull = pull;
}
}
Presenter
Презентер знает что загрузить, как показать, что делать в случае ошибки и прочее. Т.е отделяет логику от представления. View в таком случае получается максимально «легкой». Наш презентер должен уметь обрабатывать нажатие кнопки поиска, инициализировать загрузку, отдавать данные и отписываться в случае остановки Activity.
Интерфейс презентера:
public interface Presenter {
void onSearchClick();
void onStop();
}
Реализация презентера
public class RepoListPresenter implements Presenter {
private Model model = new ModelImpl();
private View view;
private Subscription subscription = Subscriptions.empty();
public RepoListPresenter(View view) {
this.view = view;
}
@Override
public void onSearchButtonClick() {
if (!subscription.isUnsubscribed()) {
subscription.unsubscribe();
}
subscription = model.getRepoList(view.getUserName())
.subscribe(new Observer<List<Repo>>() {
@Override
public void onCompleted() {
}
@Override
public void onError(Throwable e) {
view.showError(e.getMessage());
}
@Override
public void onNext(List<Repo> data) {
if (data != null && !data.isEmpty()) {
view.showData(data);
} else {
view.showEmptyList();
}
}
});
}
@Override
public void onStop() {
if (!subscription.isUnsubscribed()) {
subscription.unsubscribe();
}
}
}
View
View реализуем как Activity, которое умеет отображать полученные данные, показывать ошибку, уведомлять о пустом списке и выдавать имя пользователя по запросу от презентера. Интерфейс:
public interface IView {
void showList(List<Repo> RepoList);
void showError(String error);
void showEmptyList();
String getUserName();
}
Реализация методов View
@Override
public void showData(List<Repo> list) {
adapter.setRepoList(list);
}
@Override
protected void onStop() {
super.onStop();
if (presenter != null) {
presenter.onStop();
}
}
@Override
public void showError(String error) {
makeToast(error);
}
@Override
public void showEmptyList() {
makeToast(getString(R.string.empty_repo_list));
}
@Override
public String getUserName() {
return editText.getText().toString();
}
В результате у нас получилось простое приложение, которое разделено по слоям.
Схема:
Некоторые вещи требуют улучшения, однако, общая идея ясна. Теперь усложним нашу задачу, добавив новую функциональность.
Часть 2. Усложненная архитектура
Добавим новую функциональность в наше приложение, отображение информации о репозитории. Будем показывать списки branches и contributors, они получаются разными запросами у API.
Retrolambda
Работа с Rx без лямбд — это боль, необходимость каждый раз писать анонимные классы быстро утомляет. Android не поддерживает Java 8 и лямбды, но на помощь нам приходит Retrolambda (https://github.com/evant/gradle-retrolambda). Подробнее о лямбда-выражениях: http://habrahabr.ru/post/224593/
Разные модели данных для разных слоев.
Как видно, мы на всех трех слоях работаем с одним и тем же объектом данных Repo. Такой подход хорош для простых приложений, однако в реальной жизни мы всегда можем столкнутся со сменой API, необходимостью изменять объект или чем-то другим. Если над проектом работают несколько человек, то существует риск изменения класса в интересах другого слоя.
Поэтому зачастую применяется подход: один слой = один формат данных. И если изменятся какие-то поля в модели, это никак не повлияет на View слой. Мы можем производить любые изменения в Presenter слое, но во View мы отдаем строго определенный объект (класс). Благодаря этому достигается независимость слоев от моделей данных, у каждого слоя своя модель. При изменении какой либо модели, нам нужно будет переписать маппер и не трогать сам слой. Это похоже на контрактное программирование, когда мы точно знаем какой объект придет в наш слой и какой мы должны отдать дальше, тем самым защищая себя и коллег от непредсказуемых последствий.
В нашем примере нам вполне хватит двух типов данных, DTO — Data Transfer Object (полностью копирует JSON объект) и View Object (адаптированный объект для отображения). Если будет более сложное приложение, возможно понадобятся Business Object (для бизнес процессов) или например Data Base Object (для хранения сложных объектов в базе данных)
Схематичное изображение передаваемых данных
Переименуем Repo в RepositoryDTO, создадим новый класс Repository и напишем маппер, реализующий интерфейс Func1<List<RepositoryDTO>>, List<Repository>>
(перевод из List<RepositoryDTO> в List<Repository>)
Маппер для объектов
public class RepoBranchesMapper implements Func1<List<BranchDTO>, List<Branch>> {
@Override
public List<Branch> call(List<BranchDTO> branchDTOs) {
List<Branch> branches = Observable.from(branchDTOs)
.map(branchDTO -> new Branch(branchDTO.getName()))
.toList()
.toBlocking()
.first();
return branches;
}
}
Model
Мы ввели разные модели данных для разных слоев, интерфейс Model теперь отдает DTO объекты, в остальном все также.
public interface Model {
Observable<List<RepositoryDTO>> getRepoList(String name);
Observable<List<BranchDTO>> getRepoBranches(String owner, String name);
Observable<List<ContributorDTO>> getRepoContributors(String owner, String name);
}
Presenter
В Presenter-слое нам необходим общий класс. Презентер может выполнять самые разные функции, это может быть простой презентер «загрузи-покажи», может быть список с необходимостью подгрузки элементов, может быть карта, где мы будем запрашивать объекты на участке, а также множество других сущностей. Но всех их объединяет необходимость отписываться от Observable во избежание утечек памяти. Остальное зависит от типа презентера.
Если мы используем несколько Observable, то нам необходимо отписываться от всех разом в onStop. Для этого возможно использование CompositeSubscription: добавляем туда все наши подписки и отписываемся по команде.
Также добавим в презентеры сохранение состояния. Для этого создаем и реализуем методы onCreate(Bundle savedInstanceState) и onSaveInstanceState(Bundle outState). Для перевода DTO в VO используем мапперы.
Пример кода
public void onSearchButtonClick() {
String name = view.getUserName();
if (TextUtils.isEmpty(name)) return;
Subscription subscription = dataRepository.getRepoList(name)
.map(repoListMapper)
.subscribe(new Observer<List<Repository>>() {
@Override
public void onCompleted() {
}
@Override
public void onError(Throwable e) {
view.showError(e.getMessage());
}
@Override
public void onNext(List<Repository> list) {
if (list != null && !list.isEmpty()) {
repoList = list;
view.showRepoList(list);
} else {
view.showEmptyList();
}
}
});
addSubscription(subscription);
}
public void onCreate(Bundle savedInstanceState) {
if (savedInstanceState != null) {
repoList = (List<Repository>) savedInstanceState.getSerializable(BUNDLE_REPO_LIST_KEY);
if (!isRepoListEmpty()) {
view.showRepoList(repoList);
}
}
}
private boolean isRepoListEmpty() {
return repoList == null || repoList.isEmpty();
}
public void onSaveInstanceState(Bundle outState) {
if (!isRepoListEmpty()) {
outState.putSerializable(BUNDLE_REPO_LIST_KEY, new ArrayList<>(repoList));
}
}
Общая схем Presenter слоя:
View
Будем использовать активити для управления фрагментами. Для каждой сущности свой фрагмент, который наследуется от базового фрагмента. Базовый фрагмент используя интерфейс базового презентера отписывается в onStop().
Также обратите внимание на восстановление состояния, вся логика переехала в презентер — View должно быть максимально простым.
Код базового фрагмента
@Override
public void onStop() {
super.onStop();
if (getPresenter() != null) {
getPresenter().onStop();
}
}
Общая схема View слоя
Общая схема приложения на втором шаге (кликабельно):
Заключение или to be continued…
В результате у нас получилось работающее приложение с соблюдением всех необходимых уровней абстракции и четким разделением ответственности по компонентам (исходники). Такой код проще поддерживать и дополнять, над ним может работать команда разработчиков. Но одним из главных преимуществ является достаточно легкое тестирование. В следующей статье рассмотрим внедрение Dagger 2, покроем тестами существующий код и напишем новую функциональность, следуя принципам TDD.
UPDATE
Построение Android приложений шаг за шагом, часть вторая
Построение Android приложений шаг за шагом, часть третья