Pull to refresh

Model-View-Presenter — компромисс и универсальный рецепт

Reading time 11 min
Views 36K
image

Аббревиатуру MVP можно интерпретировать по разному, но в статье речь пойдет не о спорте.

В сети есть большое количество статей по архитектурным паттернам для iOS и Android разработчиков, и по MVP в частности. Иногда паттерн рассматривается в контексте обеих платформ. Кто-то выбирает данный паттерн для улучшения тестируемости своего кода, кто-то использует его в основном для разделения кода представления от модели. Также встречаются решения, которые используют MVP для унифицирования кода платформ, при условии что разработчики в компании владеют данными технологиями. Но общих слов и выводов иногда недостаточно для разработчика-прагматика. При проектировании коммерческих приложений неизбежно возникает множество деталей, которые общая архитектурная концепция не может раскрыть, и нельзя сказать, что есть единственное каноническое решение.

В статье я постараюсь описать ситуации, с которыми очень часто сталкиваются мобильные разработчики на реальных проектах, и когда действительно стоит задуматься о переходе на архитектурный паттерн более сложный чем “One UIViewController (Activity) to rule them all”. Или лучше сказать, когда нам это будет выгодно. Далее речь пойдет о компромиссе между временем и сложностью разработки в реалиях “обычных” проектов, которые в основном приходят на оценку и разработку.

Введение


Большинство мобильных проектов для нативных SDK сейчас являются клиент-серверными приложениями. В свою очередь такие приложения по сложности системы и времени жизни могут сильно отличаться. Есть приложения “однодневки” для работы в рамках одного мероприятия или ежегодного праздника, после которого они никому не нужны. Есть и серьезные проекты, требующие большого процента покрытия кода тестами, для обеспечения стабильной работы при внесении изменений разными участниками команды.

Какие проекты являются “обычными” можно определить по следующим критериям:

  • количество экранов: 5-20 штук, либо столько экранов должно быть в первой версии;
  • приложение должно быть спроектировано под две основные мобильные платформы: iOS и Android;
  • присутствует авторизация;
  • есть система кэширования с использованием как правило локальной базы данных;
  • делаются не только GET, но и POST, PUT, DELETE запросы, причем эти запросы вносят изменения в закэшированные данные, скачанные на предыдущем этапе;
  • приложение будет расширяться.

Сравнение MVP с паттернами по умолчанию


Рассмотрим самый простой паттерн, а именно iOS MVC с пассивной моделью, который является по сути жесткой связкой слоев представления и модели, где вся логика находится в UIViewController-е. Аналогичный подход получается и на Android-е, если вы пишите свои проекты по руководству из видеоуроков для начинающих или с большинства обучающих сайтов.

image

Если сравнить его с MVP, то основным отличием является класс Presenter, в который мы выносим логику обработки событий, форматирования данных и управления View. Под View мы будем понимать слой, включающий дочерние классы UIViewController, Activity или Fragment и их связку с классами всевозможных контролов. Таким образом мы “разгружаем” классы аналогичные UIViewController от тех обязанностей, которыми они не должны заниматься, оставляя внутри только код инициализации представлений и анимаций.

image

Как это часто бывает, в течение разработки первой версии приложения дополнительно добавляются функциональности, связанные с недостаточной проработкой ТЗ. Иногда изменения настолько радикальны, что приходится переписывать бОльшую часть уже проделанной работы. Наиболее часто встречается ситуация, когда просят добавить дополнительное поле к сущности и отобразить отформатированную информацию. Проследим какие классы мы изменим в проекте при добавление нового UI-элемента, отображающего отформатированную дату:

  • используя MVC с пассивной моделью:

image

  • используя MVP:

image

Очевидно, что мы усложнили систему введением паттерна MVP, так как количество классов, которые подверглись изменению при простом добавлении нового свойства сущности у нас возросло. Отдельно стоит отметить, что при реализации MVP создают отдельный интерфейс для View и пытаются сделать так, чтобы в Presenter-е находилось как можно меньше кода, связанного с платформой и не было прямых ссылок на конкретных наследников UIViewController, Activity или Fragment, потому что иначе будет соблазн написать код представления непосредственно в Presenter-е.

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

В поиске компромисса


Встает логичный вопрос, а зачем же все усложнять?

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

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

image

Не стоит путать модель представления в данной статье с ViewModel из MVVM, название лишь указывает на сущность, хранящую отформатированную и готовую к показу во View информацию.

При использовании обычного подхода мы, скорее всего, разместили бы код составления модели для ячейки в наследнике UIViewController-е или Activity (Fragment-е), что повлекло за собой увеличение кода в классе, в котором и так намешано немало, связанного с функционированием представления.

Написание “полезных” интеграционных тестов также становится возможным с вводом MVP, где основным объектом тестирования выступает Presenter. Конечно, используя только юнит-тестирование, можно протестировать используемые в приложении компоненты, которые являются God-объектами и наследниками UIViewController или Activity (Fragment), но это будет не вполне эффективно.

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

image

Небольшие уточнения к диаграмме:

  1. Для Android не актуально хранение ссылки на Presenter непосредственно в Activity, поэтому на практике у нас используется Retained Fragment и базовые классы для Activity и обычного Fragment-а, в которых описана логика инициализации и получения уже созданного ранее Presenter-а.
    Много кода
    public abstract class BasePresenterActivity<P extends BasePresenter> extends AppCompatActivity {
    
       private static final String PRESENTER_FRAGMENT_TAG = "presenter_fragment_tag";
    
       private P mPresenter;
    
       @Override
       protected void onCreate(@Nullable Bundle savedInstanceState) {
           super.onCreate(savedInstanceState);
           getPresenter().bindView(this);
       }
    
       @Override
       protected void onPause() {
           super.onPause();
    
           if (isFinishing()) {
               PresenterFragment presenterFragment =  (PresenterFragment) getSupportFragmentManager()
                       .findFragmentByTag(PRESENTER_FRAGMENT_TAG);
               if (presenterFragment != null) {
                   getSupportFragmentManager().beginTransaction().remove(presenterFragment).commit();
               }
           }
    
       }
    
       @Override
       protected void onDestroy() {
            if (!isFinishing() && mPresenter != null) {
                mPresenter.unbindView();
            }
            super.onDestroy();
    
       }
    
       protected P getPresenter() {
    
           if (mPresenter != null) {
               return mPresenter;
           }
    
           PresenterFragment fragment = getPresenterFragment();
           if (fragment.isPresenterSet()) {
               mPresenter = fragment.getPresenter();
           } else {
               mPresenter = createPresenter();
               fragment.setPresenter(mPresenter);
           }
    
           return mPresenter;
       }
    
       protected abstract P createPresenter();
    
       private PresenterFragment getPresenterFragment() {
           PresenterFragment presenterFragment = (PresenterFragment) getSupportFragmentManager()
                   .findFragmentByTag(PRESENTER_FRAGMENT_TAG);
           if (presenterFragment == null) {
               presenterFragment = new PresenterFragment();
               getSupportFragmentManager()
                       .beginTransaction()
                       .add(presenterFragment, PRESENTER_FRAGMENT_TAG)
                       .commit();
           }
           return presenterFragment;
       }
    
    }
    

    public class PresenterFragment extends Fragment {
    
       private BasePresenter mPresenter;
    
       @Override
       public void onCreate(@Nullable Bundle savedInstanceState) {
           super.onCreate(savedInstanceState);
           setRetainInstance(true);
       }
    
       public <P extends BasePresenter> P getPresenter() {
           return (P) mPresenter;
       }
    
       public <P extends BasePresenter> void setPresenter(P presenter) {
           mPresenter = presenter;
       }
    
       public boolean isPresenterSet() {
           return mPresenter != null;
       }
    
    }
    

    public abstract class BasePresenter<V> {
    
        protected V mView;
    
        public void bindView(V view) {
            mView = view;
        }
    
        public void unbindView() {
            mView = null;
        }
    }
    

  2. Интерфейсы введены только для View и Service-а для интеграционного тестирования логики Presenter-а. Чтобы создать действительно эффективные тесты, Presenter должен знать о всех событиях генерируемых View, а View не должна реагировать на них, даже если обработчик события содержит всего один вызов метода.
  3. Ранее в статье не упоминался класс EntityToViewModelFormatter. Его введение обусловлено тем, что на разных экранах может потребоваться информация от одних и тех же сущностей, которая форматируется одинаково или схожим образом. Очевидно, что данный класс необязателен и используется только там, где это необходимо.

Используя MVP мы также можем поделить разработку одного экрана на несколько человек. По крайней мере хорошо отделяется связка View и Presenter от Model. Обычно Model ассоциируется у разработчиков с сущностью в MV* паттернах. Мне больше импонирует отношение к слою модели как к сервису данных, который использует Presenter для получения и обновления информации. Сервис мы используем как фасад и объединяем запросы к API, в базу данных и настройкам приложения.

Здесь выявляется единственный недостаток описываемой вариации MVP, а именно то, что сервис разрастается в пределах одного класса-файла. Если в iOS мы можем использовать категории и расширения для разделения одного большого класса-сервиса, то в Java придется разбивать сервис на классы подсервисы для работы с конкретным экраном.

Диаграмма разбиения сервиса на несколько подсервисов для Android
image

Если у вас не более 20-25 API методов, то код остается читаемым в рамках одного файла, при условии правильного форматирования и комментирования. Для первой версии продукта можно описывать всю логику получения и отправки данных в пределах одного класса. Но не стоит увлекаться, так как технический долг со временем будет увеличиваться.

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

Без документации, я бы все таки остановил разработку на этапе создания дизайна экранов и выводом тестовых данных на основе моделей представления. В этом случае вы также получите более менее функциональный интерфейс пригодный для демо, но самое главное вы не потратите много времени на изменение сущностей для подгонки их под реальное API, так как Presenter выступит в роли адаптера, в котором будет осуществляться преобразование данных от сервиса в форматированные данные модели представления.

Универсальность


Другим плюсом использования паттерна MVP является его универсальность для iOS и Android. Если у вас большое количество проектов разрабатывается под обе платформы, то логика Presenter-а будет универсальной, и если разработка приложений происходит по очереди, то адаптация на другой платформе будет проходить быстрее и предсказуемее, так как код логики в Presenter-е практически одинаковый. Более того, на этапе начала работы по адаптации для другой платформы уже будут готовые тест-кейсы. Конечно, если вы удосужились потратить время и написать их.

Следующим преимуществом использования паттерна MVP является разделение ресурсов разработчиков под разные платформы. Например, iOS синьор может написать часть кода под Android на уровне джуниора или мидла и быть при этом полезным, чтобы не пришлось за ним все переделывать. Сам я не сторонник “универсальных солдат”, так как лучше знать одну технологию хорошо, чем несколько на уровне джуниора или мидла. Практикующих старших разработчиков (не тимлидов или технических директоров) под обе платформы я не встречал, но не буду спорить, что такие профессионалы существуют. Основная причина дефицита таких людей, на мой взгляд, заключается в том, что в наше время очень тяжело уследить за развитием обеих технологий. Даже при условии того, что iOS и Android заимствуют некоторые конструкции друг у друга, под капотом реализация все равно разная. Но для небольших команд и “обычных” проектов подход с использованием непрофильного разработчика является вполне адекватным. Многие iOS программисты хотели бы попробовать себя в Android и наоборот. Можно отдать такому “легионеру” написать часть кода, связанного с Model или Presenter. Также будет неплохо, если человек сможет написать простой UI. Но задачи со сложными UI элементами с анимацией, оптимизацией, профилированием и многопоточностью, за исключением вызовов API сервиса, конечно, должен решать уже более опытный профильный разработчик.

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

Сравнение с другими известными архитектурными шаблонами


Есть еще один очень важный вопрос: почему за основу был выбран именно MVP, а не паттерны, основанные на чистой архитектуре или MVVM? В начале статьи мы постарались сконцентрироваться на том, что имеем дело с “обычными” проектами, которые желательно написать быстро, но с должным качеством и возможностью долгосрочной поддержки.

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

В MVVM мы должны перестроить логику мышления на декларативную. Также MVVM очень часто ассоциируется с библиотеками Rx*, с которыми должны быть знакомы программисты, а это сразу же ставит ограничение при поиске нового разработчика, либо вы осознанно тратите время на его обучение. В этом плане MVP прозрачнее и имеет меньше ограничений на умения разработчика.

Выводы


Перейдем к выводам. Преимущества, которые нам удалось выявить для паттерна MVP, если посмотреть на него со стороны выгоды внедрения в разработку:

  1. Настоящее отделение логики представления от модели, в отличие, к примеру, от Apple MVC;
  2. Четкое разделение ответственности между классами:
    • View — прорисовка, анимации, переходы между экранами;
    • Presenter — форматирование, реакция на события, логика представления и управление View;
    • Model — работа с загрузкой данных по API, извлечение данных из БД и их кэширование;
  3. Практически универсальная реализация интерфейсов Model-View-Presenter-а на мобильных платформах для “обычных” проектов;
  4. Интеграционное тестирование на уровне Presenter-а;
  5. Разделение задач в команде в рамках разработки одного экрана на задачи представления (вместе с Presenter-ом) и модели;
  6. Возможность показа интерактивного демо заказчику, без работающего бэкенда. При появлении реального сервиса мы не выбрасываем весь код, который написали, а адаптируем API;
  7. Возможность эффективного привлечения непрофильного разработчика из команды для написания кода на другой платформе;
  8. Расширение и добавление новых фич не вызовет эффекта “чужого кода”, когда человек возвращается к разработке проекта после длительного перерыва из-за набора простых правил размещения кода в строго определенных модулях.

К недостаткам можно отнести не такую быструю скорость разработки как при использовании Apple MVC или аналогичного подхода под Android. Также в конкретно представленной вариации MVP сознательно был сделан упор на один общий класс-сервис из-за простоты, но это потребует некоторых техник по его разделению.

Подводя итоги, можно утверждать следующее: если у вас есть небольшая команда из специалистов готовых на реализацию проектов под основные мобильные платформы, вам нужны универсальные архитектурные правила, а также возможность распараллеливания задач в рамках экрана и необходимость показа демо заказчику с частично работающей функциональностью, например, если практикуются спринты, то MVP определенно ваш выбор. Дополнительным бонусом выступает удобная поддержка проекта, которая не должна привести к фразе “а вот если бы мы сейчас написали все заново” и ситуациям, когда только для того, чтобы разобраться в коде одного огромного UIViewController-а или Activity будет потрачено больше времени, чем на саму фичу.
Tags:
Hubs:
+4
Comments 1
Comments Comments 1

Articles