RESS — Новая архитектура для мобильных приложений +10




Вопреки провокационному заголовку, это никакая не новая архитектура, а попытка перевода простых и проверенных временем практик на новояз, на котором говорит современное Android-комьюнити

Введение


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

Айтишные сайты заполонили туториалы по модным фреймворкам и переусложненным архитектурам, но при этом даже нет best practice для REST-клиентов под Android. Хотя это один из самых частых кейсов приложений. Хочется чтобы нормальный подход к разработке тоже пошел в массы. Поэтому и пишу эту статью

Чем плохи существующие решения


По большому счету проблема новомодных MVP, VIPER и им подобных — ровно одна, их авторы не умеют проектировать. А их последователи — тем более. И поэтому не понимают важных и очевидных вещей. И занимаются обычным оверинжинирингом.

1. Архитектура должна быть простой


Чем проще, тем лучше. Тем проще для понимания, надежнее и гибче. Переусложнить и наделать кучу абстракций может любой дурак, а чтобы сделать просто — нужно хорошенько подумать.

2. Оверинжиниринг это плохо


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

Фанаты MVP, например, сами в своих статьях пишут открытым текстом что MVP тупо приводит к значительному усложнению системы. И оправдывают это тем что так гибче и поддерживать проще. Но, как мы знаем из пункта номер 1, это взаимоисключающие вещи.

Теперь про VIPER, просто посмотрите, например, на схему из этой статьи.

Схема
image

И это для каждого экрана! Моим глазам больно. Особенно сочувствую тем, кому на работе с этим приходится сталкиваться не по своей воле. Тем же, кто это сам внедрил, сочувствую по немного другим причинам.

Новый подход


Эй, я тоже хочу модное название. Поэтому предлагаемая архитектура называется RESSRequest, Event, Screen, Storage. Буковки и названия подробраны так тупо для того чтобы получилось читаемое слово. Ну и чтобы не создавать путаницу с уже используемыми названиями. Ну и с REST созвучно.

Сразу оговорюсь, эта архитектура для REST-клиентов. Для других типов приложений она, вероятно, не подойдет.



1. Storage


Хранилище данных (в других терминах Model, Repository). Этот класс хранит данные и занимается их обработкой(сохраняет, загружает, складывает в БД и т.п.), а так же все данные от REST-сервиса сначала попадают сюда, парсятся и сохраняются здесь.

2. Screen


Экран приложения, в случае Android это ваше Activity. В других терминах это обычный ViewController как в MVC от Apple.

3. Request


Класс, который отвечает за посылку запросов к REST-сервису, а так же прием ответов и уведомление об ответе остальных компонентов системы.

4. Event


Связующее звено между остальными компонентами. Например, Request посылает эвент об ответе сервера, тем кто на него подписался. А Storage посылает эвент об изменении данных.

Далее пример упрощенной реализации. Код написан с допущениями и не проверялся, поэтому могут быть синтаксические ошибки и опечатки

Request
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");
	}
}


Storage
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();
		}
	};
}


Screen
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();
	}
}


App
public class MyApp extends Application
{
	@Override
	public void onCreate()
	{
		super.onCreate();
		
		Request.registerListener(DataStorage.listener);
	}
}


Та же схемка, но в терминах RESS, для понимания


Работает это так: При нажатии на кнопку, дергается нужный метод у 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 вообще заставит всю команду уволиться одним днем.

Заключение


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




К сожалению, не доступен сервер mySQL