Вопреки провокационному заголовку, это никакая не новая архитектура, а попытка перевода простых и проверенных временем практик на новояз, на котором говорит современное Android-комьюнити
Введение
В последнее время стало больно смотреть на то, что творится в мире разработки под мобильные платформы. Архитектурная астронавтика процветает, каждый хипстер считает своим долгом придумать новую архитектуру, а для решения простой задачи вместо двух строчек вставить несколько модных фреймворков.
Айтишные сайты заполонили туториалы по модным фреймворкам и переусложненным архитектурам, но при этом даже нет best practice для REST-клиентов под Android. Хотя это один из самых частых кейсов приложений. Хочется чтобы нормальный подход к разработке тоже пошел в массы. Поэтому и пишу эту статью
Чем плохи существующие решения
По большому счету проблема новомодных MVP, VIPER и им подобных — ровно одна, их авторы не умеют проектировать. А их последователи — тем более. И поэтому не понимают важных и очевидных вещей. И занимаются обычным оверинжинирингом.
1. Архитектура должна быть простой
Чем проще, тем лучше. Тем проще для понимания, надежнее и гибче. Переусложнить и наделать кучу абстракций может любой дурак, а чтобы сделать просто — нужно хорошенько подумать.
2. Оверинжиниринг это плохо
Добавлять новый уровень абстракции нужно только когда старый уже не решает проблем. После добавления нового уровня система должна стать проще для понимания, а кода меньше. Если, например, после этого у вас вместо одного файла, стало три, а система стала более запутанной, то вы сделали ошибку, а если по-простому — написали херню.
Фанаты MVP, например, сами в своих статьях пишут открытым текстом что MVP тупо приводит к значительному усложнению системы. И оправдывают это тем что так гибче и поддерживать проще. Но, как мы знаем из пункта номер 1, это взаимоисключающие вещи.
Теперь про VIPER, просто посмотрите, например, на схему из этой статьи.
И это для каждого экрана! Моим глазам больно. Особенно сочувствую тем, кому на работе с этим приходится сталкиваться не по своей воле. Тем же, кто это сам внедрил, сочувствую по немного другим причинам.
Новый подход
Эй, я тоже хочу модное название. Поэтому предлагаемая архитектура называется RESS — Request, Event, Screen, Storage. Буковки и названия подробраны так тупо для того чтобы получилось читаемое слово. Ну и чтобы не создавать путаницу с уже используемыми названиями. Ну и с REST созвучно.
Сразу оговорюсь, эта архитектура для REST-клиентов. Для других типов приложений она, вероятно, не подойдет.
1. Storage
Хранилище данных (в других терминах Model, Repository). Этот класс хранит данные и занимается их обработкой(сохраняет, загружает, складывает в БД и т.п.), а так же все данные от REST-сервиса сначала попадают сюда, парсятся и сохраняются здесь.
2. Screen
Экран приложения, в случае Android это ваше Activity. В других терминах это обычный ViewController как в MVC от Apple.
3. Request
Класс, который отвечает за посылку запросов к REST-сервису, а так же прием ответов и уведомление об ответе остальных компонентов системы.
4. Event
Связующее звено между остальными компонентами. Например, Request посылает эвент об ответе сервера, тем кто на него подписался. А Storage посылает эвент об изменении данных.
Далее пример упрощенной реализации. Код написан с допущениями и не проверялся, поэтому могут быть синтаксические ошибки и опечатки
public class Request
{
public interface RequestListener
{
default void onApiMethod1(Json answer) {}
default void onApiMethod2(Json answer) {}
}
private static class RequestTask extends AsyncTask<Void, Void, String>
{
public RequestTask(String methodName)
{
this.methodName = methodName;
}
private String methodName;
@Override
protected String doInBackground(Void ... params)
{
URL url = new URL(Request.serverUrl + "/" + methodName);
HttpURLConnection httpConnection = (HttpURLConnection)url.openConnection();
// ...
// Делаем запрос и читаем ответ
// ...
return result;
}
@Override
protected void onPostExecute(String result)
{
// ...
// Парсим JSON из result
// ...
Requestr.onHandleAnswer(methodName, json);
}
}
private static String serverUrl = "myserver.com";
private static List<OnCompleteListener> listeners = new ArrayList<>();
private static void onHandleAnswer(String methodName, Json json)
{
for(RequestListener listener : listeners)
{
if(methodName.equals("api/method1")) listener.onApiMethod1(json);
else if(methodName.equals("api/method2")) listener.onApiMethod2(json);
}
}
private static void makeRequest(String methodName)
{
new RequestTask(methodName).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}
public static void registerListener(RequestListener listener)
{
listeners.add(listener);
}
public static void unregisterListener(RequestListener listener)
{
listeners.remove(listener);
}
public static void apiMethod1()
{
makeRequest("api/method1");
}
public static void onApiMethod2()
{
makeRequest("api/method2");
}
}
public class DataStorage
{
public interface DataListener
{
default void onData1Changed() {}
default void onData2Changed() {}
}
private static MyObject1 myObject1 = null;
private static List<MyObject2> myObjects2 = new ArrayList<>();
public static void registerListener(DataListener listener)
{
listeners.add(listener);
}
public static void unregisterListener(DataListener listener)
{
listeners.remove(listener);
}
public static User getMyObject1()
{
return myObject1;
}
public static List<MyObject2> getMyObjects2()
{
return myObjects2;
}
public static Request.RequestListener listener = new Request.RequestListener()
{
private T fromJson<T>(Json answer)
{
// ...
// Парсим или десереализуем JSON
// ...
return objectT;
}
@Override
public void onApiMethod1(Json answer)
{
myObject1 = fromJson(answer);
for(RequestListener listener : listeners) listener.data1Changed();
}
@Override
public void onApiMethod2(Json answer)
{
myObject2 = fromJson(myObjects2);
for(RequestListener listener : listeners) listener.data2Changed();
}
};
}
public class MyActivity extends Activity implements DataStorage.DataListener
{
private Button button1;
private Button button2;
@Override
protected void onCreate(Bundle savedInstanceState)
{
super.onCreate(savedInstanceState);
button1.setOnClickListener((View) -> {
Request.apiMethod1();
});
button2.setOnClickListener((View) -> {
Request.apiMethod2();
});
updateViews();
}
@Override
protected void onPause()
{
super.onPause();
DataStorage.unregisterListener(this);
}
@Override
protected void onResume()
{
super.onResume();
DataStorage.registerListener(this);
updateViews();
}
private void updateViews()
{
updateView1();
updateView2();
}
private void updateView1()
{
Object1 data = DataStorage.getObject1();
// ...
// Тут обновляем нужные вьюшки
// ...
}
private void updateView2()
{
List<Object2> data = DataStorage.getObjects2();
// ...
// Тут обновляем нужные вьюшки
// ...
}
@Override
public void onData1Changed()
{
updateView1();
}
@Override
public void onData2Changed()
{
updateView2();
}
}
public class MyApp extends Application
{
@Override
public void onCreate()
{
super.onCreate();
Request.registerListener(DataStorage.listener);
}
}
Работает это так: При нажатии на кнопку, дергается нужный метод у Request, Request посылает запрос на сервер, обрабатывает ответ и уведомляет сначала DataStorage. DataStorage парсит ответ и кеширует данные у себя. Затем Request уведомляет текущий активный Screen, Screen берет данные из DataStorage и обновляет UI.
Screen подписывается и отписывается от умедомлений в onResume и onPause соотвественно. А так же обновляет UI дополнительно в onResume. Что это дает? Уведомления приходят только в текущую активную Activity, никаких проблем с обработкой запроса в фоне или поворотом Activity. Activity будет всегда в актуальном состоянии. До фоновой активити уведомление не дойдет, а при возвращении в активное состояние, данные возьмутся из DataStorage. В итоге никаких проблем при повороте экрана и пересоздании Activity.
И для всего этого хватает дефолтных апи из Android SDK.
Вопросы и ответы на будующую критику
1. Какой профит?
Реальная простота, гибкость, поддерживаемость, масштабируемость и минимум зависимостей. Вы всегда можете усложнить определенную часть системы, если вам необходимо. Очень много данных? Аккуратно разбиваете DataStorage на несколько. Огромное REST API у сервиса? Делаете несколько Request. Листенеры это слишком просто, некруто и немодно? Возьмите EventBus. Косо смотрят в барбершопе на HttpConnection? Ну возьмите Retrofit. Жирный Activity с кучей фрагментов? Просто считайте что каждый фрагмент это Screen, или разбейте на сабклассы.
2. AsyncTask это моветон, возьми хотя бы Retrofit!
Да? И какие проблемы он в данном коде вызывает? Утечки памяти? Нет, тут AsyncTask не хранит ссылки на активити, а только ссылку на статик метод. Ответ теряется? Нет, ответ всегда приходит в статик DataStorage, пока приложение не убито. Пытается обновить активити на паузе? Нет, уведомления приходят только в активную Activity.
Да и как тут поможет Retrofit? Просто смотрим сюда. Автор взял RxJava, Retrofit и все равно лепит костыли, чтобы решить проблему, которой в RESS попросту нет.
3. Screen это же ViewController! Нужно разделять логику и представление, arrr!
Бросьте уже эту мантру. Типичный клиент для REST-сервиса это одна большая вьюшка для серверной части. Вся ваша бизнес-логика это установить нужный стейт для кнопки или текстового поля. Что вы там собрались разделять? Говорите так будет проще поддерживать? Поддерживать 3 файла с 3 тоннами кода, вместо 1 файла с 1 тонной проще? Ок. А если у нас активити с 5 фрагментами? Это у нас уже 3 x (5 + 1) = 18 файлов.
Разделение на Controller и View в таких кейсах просто плодит кучу бессмысленного кода, пора бы уже это признать. Добавлять функционал в проект с MVP особенно весело: хочешь добавить обработчик кнопки? Ок, поправь Presenter, Activity и View-интерфейс. В RESS для этого я напишу пару строк кода в одном файле.
Но ведь в больших проектах ViewController ужасно разрастается? Так вы не видели больших проектов. Ваш REST-клиент для очередного сайта на 5тыс строк это мелкий проект, а 5тыс строк там только потому что на каждый экран по 5 классов. Реально большие проекты на RESS с 100+ экранов и несколькими командами по 10 человек прекрасно себя чувствуют. Просто делают несколько Request и Storage. А Screen для жирных экранов содержат внутри себя дополнительные Screen для крупных элементов UI, например, тех же фрагментов. Проект на MVP тех же масштабов просто захлебнется в куче презентеров, интерфейсов, активити, фрагментов и неочевидных связей. А переход на VIPER вообще заставит всю команду уволиться одним днем.
Заключение
Надеюсь эта статья сподвигнет многих разработчиков пересмотреть свои взгляды на архитектуру, не плодить абстракции и посмотреть на более простые и проверенные временем решения
Автор: Илитный Эксперт