Пятая лекция курса по архитектуре клиент-серверных android-приложений, в которой мы поговорим о паттернах MVP, MVC и MVVM. Также научимся работать с библиотекой Mosby, которая реализует паттерн MVP в Android.
Чтобы на практике познакомиться с архитектурными паттернами, записывайтесь на продвинутый курс по разработке приложения «Чат-мессенжер»
Введение
На прошлой лекции мы рассмотрели принципы Clean Architecture от Роберта Мартина, а также их адаптацию для разработки приложений под Android, и пришли к выводу, что эти принципы позволяют решить все озвученные задачи при построении архитектуры приложения: они делают код модульным, тестируемым и легко читаемым.
Но как говорится, нет предела совершенству, и такую архитектуру можно и нужно улучшать. Какие недостатки есть у архитектуры, предложенной ранее? Во-первых, даже для достаточно простых экранов мы получаем слишком много разных классов (View, Presenter, UseCase, Repository, Navigator и другие). Здесь нужно исходить из задачи – если вам нужно реализовать простой экран с парой текстов и кнопкой, не нужно реализовывать для него все эти принципы. Во-вторых, мы видели, что слой бизнес-логики практически не содержит кода. Да, конечно, мы писали достаточно простое приложение, в котором не может быть много логики, но разве это не самый распространенный случай на сегодняшний день? Сегодня существует правильная тенденция, когда вся бизнес-логика переносится на сторону сервера. Эта тенденция является правильной потому, что на стороне сервера логика описывается только один раз (а не для всех мобильных платформ по отдельности), а также потому, что на стороне сервера логику можно изменить гораздо более оперативно.
Поэтому вполне возможно, что мы можем убрать слой бизнес-логики. А ту небольшую бизнес-логику, которая останется в приложении, можно переместить в другой управляющий элемент, к примеру, в Presenter. Таким образом мы избавимся от лишнего слоя, лишних взаимодействий и лишних моделей, упростив в конечном случае реализацию отдельных экранов.
При этом мы видели, что слой данных работает очень хорошо и скрывает от слоя представления детали работы с серверными запросами и кэшированием. Поэтому в нем нет недостатков, и его можно оставить нетронутым (разве что необязательно переносить его в отдельный модуль, так как сейчас правило зависимостей упрощается).
Слой представления, разумеется, никуда исчезнуть не может, так как именно на нем происходит взаимодействие с пользователем. Но для работы приложения нужно обеспечить взаимодействие между интерфейсом и слоем данных. Эту работу возьмет на себя делегат для слоя представления. Этим делегатом может быть элемент, управляющий логикой работы в любом паттерне: MVC, MVP, MVVM.
Поэтому итоговая схема может выглядеть следующим образом:
При этом мы не потеряем возможность тестирования и сохраним модульность архитектуры приложения. Более того, теперь у нас есть один основной элемент, который содержит всю логику конкретного экрана – это делегат (Controller / Presenter / ViewModel). И поэтому для упрощения почти всегда достаточно тестировать только его.
Поскольку работа делегата полностью зависит от того, какие данные он получает от сервера, для тестирования делегата достаточно подменять реализацию репозитория.[wpanchor id=”2″]
Это общее теоретическое изложение, которое нужно для того, чтобы понять идею – упростить архитектуру в соответствии с наиболее часто встречающимися задачами. Но чтобы работать с такой архитектурой, нам потребуется лучше понять суть различных паттернов уровня представления и детальнее разобрать паттерн MVP.
Паттерн MVP
Мы уже рассмотрели суть паттерна MVP в общих чертах, теперь пора объяснить его более подробно, а также сравнить с другими паттернами, такими как MVC.
Нужно также сказать, что не совсем корректно называть MVP и MVC паттернами, так как это скорее общие подходы к разработке системы, которые основаны на комбинации нескольких паттернов. Но в дальнейшем мы не будем заострять на этом внимание и оставим название “паттерны”.
Основная идея любого из паттернов MVP, MVC, MVVM заключается в разделении логики и UI-части приложения так, чтобы их можно было тестировать по отдельности. При этом сами паттерны достаточно сильно отличаются между собой, поэтому рассмотрим их, чтобы понять, почему в Android в первую очередь используется MVP.
Сложно не заметить, что все эти паттерны содержат View и Model, а отличия заключаются в последнем элементе, который управляет логикой. В случае MVC это Controller, в случае MVP – Presenter, в MVVM – ViewModel. Для удобства можно назвать этот отличающийся объект делегатом. Разумеется, различия заключаются не только в названии, но и в том, как именно делегат управляет логикой и взаимодействует с View и Model.
Начнем рассмотрение паттернов с их общих частей – View и Model:
- Есть два подхода к пониманию этой сущности. Кто-то оперирует понятием Model в смысле всего слоя данных в приложении: это и бизнес-объекты, содержащие логику, и способ их получения (Repository), и какие-то менеджеры и другие элементы, относящиеся к данным. Такой подход уместен, если говорить, что ваша система использует исключительно паттерн MVP и больше никаких элементов. Но мы решили сохранить слой данных в том виде, в котором он был изложен в принципах “чистой” архитектуры, поэтому под Model мы будем понимать обычные классы объектов, которые используются при взаимодействии View с делегатом. Плюс такого подхода заключается в том, что мы разделяем сущности, что может упрощать понимание. На конечный результат использование разных терминологий никак не влияет, но это нужно учитывать при изучении других источников.
- View отображает данные, получаемые либо от Model, либо от делегата, что зависит от конкретного паттерна. View – эта та часть системы, которая видна пользователю и которая взаимодействует с ним. При этом View не должна содержать логику, а передавать результаты взаимодействия делегату, который будет управлять этой View.
Самым известным паттерном, конечно, является MVC, в котором делегатом является Controller. Схема этого паттерна выглядит следующим образом:
Когда пользователь взаимодействует со View (к примеру, нажимает на кнопку), View передает информацию об этом действии в Controller. Controller обрабатывает это событие в соответствии с логикой системы и изменяет Model. View отслеживает состояние модели, поэтому при изменении Model View получает уведомление и отображает новую информацию. Это активная модель, которая применяется чаще всего. Также существует пассивная модель, в которой View обновляется через Controller.
И есть еще один важный момент – Controller может управлять несколькими View, при этом Controller определяет, какая именно View будет отображаться в текущий момент. Именно из-за этой особенности паттерн MVC не слишком удобно применять в Android. В Android в роли View чаще всего выступает Activity, которую просто так сменить, разумеется, невозможно. Есть определенный вариант использования MVC в Android, когда разными View являются фрагменты, которые переключает Controller. В таком случае View может отслеживать изменение состояния через, к примеру, ContentProvider. Но, к сожалению, чаще всего попытки реализовать MVC в Android вели к использованию Activity в качестве God object (когда все сущности и вся логика находится в Activity).
Но в любом случае, паттерн MVP нашел куда большее применение в Android. MVP имеет несколько основных отличий от MVC. Во-первых, Presenter управляет только одной View и взаимодействует с ней через специальный интерфейс. Во-вторых, View управляется только с помощью Presenter-а, а не отслеживает изменение Model. Presenter получает все данные из Model (или из слоя данных в нашем изложении), обрабатывает их в соответствии с требуемой логикой и управляет View. Схема паттерна MVP выглядит следующим образом:
Вероятно, паттерн MVP приобрел свою популярность в Android не в последнюю очередь связи с огромным количеством legacy кода, который нужно было отрефакторить. Так как в случае тривиального использования MVP это сделать просто – мы создаем экземпляр Presenter и интерфейс View и последовательно проходим по коду и переносим логику в Presenter. Это позволяет нам легко разнести по разным классам логику и UI, к чему мы и стремимся.
Давайте подведем итог всему, что было сказано ранее. Во-первых, наша архитектура включает слой данных. Слой данных включает в себя объект Repository, который выполняет работу с серверными запросами и кэшированием и занимается первичной обработкой ошибок, а также средства для замены Repository при тестировании. Во-вторых, основой архитектуры является паттерн MVP. Presenter – это объект, который управляет отображением данных через специальный интерфейс View. Presenter также обращается к репозиторию за получением данных и занимается обработкой жизненного цикла. View – это интерфейс, содержащий методы для работы с UI, который реализуется в Activity или Fragment. Model – обычные модельки сущностей.
Давайте рассмотрим пример, как можно реализовать паттерн MVP для отдельного экрана, к примеру, экрана авторизации для приложения для Github. Это простой экран, который состоит из двух полей ввода для логина и пароля и кнопки для инициации авторизации. Опишем этот экран более детально:
- При открытии экрана нужно проверять текущее состояние авторизации, если пользователь уже авторизован, то открывать главный экран.
- Если поле ввода для логина или для пароля пустое, то при попытке нажать кнопку должна отобразиться ошибка под пустым полем.
- Если данные введены корректно, то при нажатии кнопки должен быть инициирован процесс авторизации (запрос на сервер).
- Во время выполнения запроса нужно отображать прогресс бар, который необходимо скрывать после окончания запроса при любом исходе.
- Если запрос выполнен успешно, то нужно открыть главный экран приложения.
- Если во время выполнения произошла ошибка, нужно показать ошибку под полем ввода для логина.
Даже при таком описании видно, что является логикой, а что относится к UI-части.
Создадим интерфейс для View. Какие методы нужны Presenter-у для управления View? Разумеется, это отображение и скрытие процесса загрузки, также это отображение ошибок и открытие главного экрана. Поэтому интерфейс View для экрана авторизации может выглядеть следующим образом:
public interface AuthView extends LoadingView { void openRepositoriesScreen(); void showLoginError(); void showPasswordError(); }
Этот интерфейс наследуется от LoadingView, который отвечает за отображение и скрытие процесса загрузки. Весьма удобно вынести такие действия в базовый интерфейс, так как они нужны почти на каждом экране:
public interface LoadingView { void showLoading(); void hideLoading(); }
В Android-приложениях View реализуется либо в Activity, либо во фрагменте. У нас простой экран, поэтому фрагменты не нужны. Реализуем интерфейс AuthView в Activity для авторизации:
public class AuthActivity extends AppCompatActivity implements AuthView { И реализуем все методы: @Override public void showLoading() { mLoadingView.showLoading(); } @Override public void hideLoading() { mLoadingView.hideLoading(); } @Override public void showLoginError() { mLoginInputLayout.setError(getString(R.string.error)); } @Override public void showPasswordError() { mPasswordInputLayout.setError(getString(R.string.error)); } @Override public void openRepositoriesScreen() { RepositoriesActivity.start(this); finish(); }
Нужно заметить, что методы очень простые, они не содержат какой-то особой логики и легко читаются, что удобно для разработчиков.
Теперь создадим Presenter, который будет управлять логикой экрана, процессом авторизации и обработкой жизненного цикла (для обработки жизненного цикла будем использовать экземпляр LifecycleHandler из библиотеки RxLoader). Presenter-у, разумеется, также передается экземпляр AuthView, которым он будет управлять и специальный объект для управления жизненным циклом:
public class AuthPresenter { private final LifecycleHandler mLifecycleHandler; private final AuthView mAuthView; public AuthPresenter(@NonNull LifecycleHandler lifecycleHandler, @NonNull AuthView authView) { mLifecycleHandler = lifecycleHandler; mAuthView = authView; } }
Далее, при старте экрана Presenter будет проверять, авторизован ли пользователь или нет, и, если авторизован, то открывать главный экран:
public void init() { String token = PreferenceUtils.getToken(); if (!TextUtils.isEmpty(token)) { mAuthView.openRepositoriesScreen(); } }
Теперь переходим к основному сценарию авторизации. Добавим следующий метод в Presenter:
public void tryLogIn(@NonNull String login, @NonNull String password) { if (TextUtils.isEmpty(login)) { mAuthView.showLoginError(); } else if (TextUtils.isEmpty(password)) { mAuthView.showPasswordError(); } else { RepositoryProvider.provideGithubRepository() .auth(login, password) .doOnSubscribe(mAuthView::showLoading) .doOnTerminate(mAuthView::hideLoading) .compose(mLifecycleHandler.reload(R.id.auth_request)) .subscribe(authorization -> mAuthView.openRepositoriesScreen(), throwable -> mAuthView.showLoginError()); } }
Как мы и описывали раньше, Presenter проверяет логин и пароль на соответствии каким-то локальным условиям (в данном случае только на то, что они не пустые) и, либо показывает ошибку, либо выполняет запрос на сервер через репозиторий. Во время запроса он командует View отображать процесс загрузки, а после окончания – скрыть его. Он также обрабатывает результат и командует View либо показать ошибку, либо открыть главный экран.
Presenter управляет View, но при этом View должна уметь обращаться к Presenter-у для передачи управления в случае каких-то действий пользователя. Поэтому Presenter объявляется полем в классе, реализующем интерфейс View (то есть в Activity или фрагменте):
private AuthPresenter mPresenter;
После этого Presenter инициализируется и дальше с ним можно работать при возникновении определенных событий. К примеру, при старте Activity мы вызываем метод init:
@Override protected void onCreate(Bundle savedInstanceState) { //... mLoadingView = LoadingDialog.view(getSupportFragmentManager()); LifecycleHandler lifecycleHandler = LoaderLifecycleHandler.create(this, getSupportLoaderManager()); mPresenter = new AuthPresenter(lifecycleHandler, this); mPresenter.init(); }
Или же, когда пользователь нажмет на кнопку входа, мы делегируем этот вызов Presenter-у:
@OnClick(R.id.logInButton) public void onLogInButtonClick() { String login = mLoginEdit.getText().toString(); String password = mPasswordEdit.getText().toString(); mPresenter.tryLogIn(login, password); }
Таким образом, мы грамотно разделили код экранов на части, отвечающие за логику и за UI, что упрощает написание кода и его понимание. При этом мы не слишком сильно отошли от рассмотренных принципов Clean Architecture, но теперь у нас меньше сущностей, и в такой системе проще разобраться.
Но нельзя забывать о второй главной цели такого разбиения – тестирование. Сейчас мы писали код в Presenter, мало задумываясь о том, насколько он будет удобен для тестирования. В случае тестов на JUnit мы сталкиваемся с определенными ограничениями, которые придется обходить. Для этого нужно будет перерабатывать Presenter. Но этим мы займемся в рамках следующей лекции.
После паттерна MVC мы сразу перешли к MVP и ничего не сказали о MVVM. Так сделано потому, что этому паттерну и библиотеке DataBinding будет посвящена отдельная лекция, в которой он будет разобран детально.
Перед тем, как переходить к рассмотрению тестирования, нужно рассмотреть еще несколько важных вопросов:
- Насколько такая архитектура масштабируема? В случае Clean Architecture у нас был отдельный UseCase на каждый серверный запрос, в нашем варианте такого нет. Понятно, что это не проблема, если в приложении всего 2-3 запроса, а если их 30? В таком случае мы не можем добавлять их все в один репозиторий. Во-первых, нужно будет выполнить разбиение слоя данных на классы для получения данных, для их кэширования и для обработки ошибок. Во-вторых, почти всегда все методы можно разбить на группы по сценариям приложения, и тогда у нас будет несколько репозиториев. При этом делегат никогда не должен использовать одновременно несколько репозиториев, так как это нарушает его контракт. Если у вас есть очень сложный экран (очень сложная Activity), то на нем можно определить несколько View и несколько делегатов.
- Может ли Presenter содержать классы Android, к примеру, Context? В идеале, разумеется, нет, так как это усложняет тестирование. Но в каких-то случаях это допустимо, если эти классы можно легко замокать на время тестирования. Иногда бывают ситуации, что для того, чтобы избежать передачи классов Android в Presenter нужно создать немало разных интерфейсов, и в итоге в Presenter попадает очень много объектов, что может усложнить тестирование еще больше. Поэтому общая рекомендация – старайтесь не использовать классы Android в Presenter, но не нужно слишком бояться этого.
- Нужно ли делать базовый интерфейс или базовый класс для Presenter-а? Нет, ни того, ни другого делать не нужно, хоть это может быть и удобно для того, чтобы явно видеть методы Presenter. Интерфейс обычно создают для того, чтобы объект, использующий интерфейс, ничего не знал о реализации, и чтобы можно было легко подменить эту реализацию. В MVP в Android каждый Presenter жестко связан (должен быть связан) с конкретным экраном, и он не будет меняться. К тому же View всегда знает, какой Presenter она будет использовать, поэтому смысла в интерфейсе нет. Базовый класс не стоит делать потому, что появляется желание перенести в него как можно больше [wpanchor id=”3″]общей логики, а это чревато потерей гибкости разных Presenter. Если вам нужно использовать много общей логики в разных Presenter, используйте композицию.
Дополнительно – Mosby
Как мы поняли, паттерн MVP помогает нам улучшить читаемость кода и повысить удобство при внесении изменений. Но при этом количество кода увеличивается, так как мы создаем разные классы и интерфейсы, при этом мы создаем классы для взаимодействия компонентов системы. Конечно, когда весь код находится в одной Activity, кода приходится писать намного меньше. Впрочем, нам уже не надо объяснять, почему это нехорошо.
Но нельзя не заметить, что код, который мы пишем в паттерне MVP для разных экранов, в принципе во многом одинаков: это всегда интерфейс для View, всегда Presenter, который всегда приходится хранить во View. И это нужно писать для каждого экрана. Поэтому нет ничего удивительного в том, что для построения архитектуры с MVP появилось немало библиотек, берущих на себя часть такой работы.
Однако нужно понимать, что при использовании библиотеки такого типа вы берете на себя большую ответственность. Более того, вы нарушаете первый из принципов Clean Architecture, который говорит о том, что архитектура не должна зависеть от конкретной библиотеки. Здесь же вы используете библиотеку, от которой будет полностью зависеть ваша архитектура. Поэтому, прежде чем принять такое решение, вы должны изучить принципы работы библиотеки и быть уверенными в том, что они вас устраивают. И как минимум, вы должны хорошо знать и уметь реализовывать паттерн MVP самостоятельно. Но даже в таком случае, возможно, стоит предпочесть свою реализацию архитектуры, так как это дает вам гибкость и соответствие вашим конкретным задачам.
После такого предупреждения уже можно перейти к рассмотрению конкретных решений. Вероятно, самой популярной и наиболее используемой библиотекой для реализации паттерна MVP является библиотека Mosby. Есть и более современные решения, к примеру, Moxy, которая расширяет функции Mosby (и берет на себя гораздо больше ответственности), но в силу того, что она пока что не используется настолько широко, мы не будем ее рассматривать.
Так чем же нам может помочь библиотека Mosby? При использовании ее мы получаем следующие преимущества:
- Структурирование кода в соответствии с паттернами MVP. Если вы используете библиотеку для реализации паттерна MVP, вам сложно будет не писать код в соответствии с этим паттерном.
- Библиотека Mosby позволяет не хранить явно экземпляры View в Presenter и Presenter во View. Более того, она выполняет автоматическое связывание View и Presenter.
- В библиотеке Mosby решены некоторые стандартные задачи, к примеру, это LCE-экраны (Loading-Content-Error). Такие экраны могут находиться в 3 состояниях: загрузка, отображение данных и показ ошибки. Очень большое количество экранов являются экранами такого типа. Библиотека Mosby снимает часть ответственность с разработчика при реализации таких экранов. И да, в этом снова может быть проблема при использовании библиотеки. Возможно, они покрывают большинство стандартных случаев, но вам может потребоваться что-то другое, что добавить будет сложнее, чем при использовании собственной архитектуры.
Что нужно сделать для того, чтобы включить Mosby в свой проект? Во-первых, разумеется, добавить зависимости:
dependencies { compile 'com.hannesdorfmann.mosby3:mvi:3.0.4' // Model-View-Intent // or compile 'com.hannesdorfmann.mosby3:mvp:3.0.4' // Plain MVP // or compile 'com.hannesdorfmann.mosby3:viewstate:3.0.4' // MVP + ViewState support }
Во-вторых, каждый интерфейс View нужно унаследовать от MvpView или же, например, от MvpLceView, если вам нужен LCE-экран:
public interface LoadingView extends MvpView { void showLoading(); void hideLoading(); }
Интерфейс MvpView – это всего лишь маркер, который используется в качестве верхней границы в обобщенных классах Mosby. Далее, аналогично нам нужно унаследовать Presenter от базового класса MvpBasePresenter (не забываем и про то, что говорилось про наследование Presenter-ов):
public class AuthPresenter extends MvpBasePresenter<AuthView>
Теперь в Presenter мы можем получить View с помощью метода getView, а также проверить, что View связана с Presenter-ом.
Конечно, если запустить код сейчас, это не сработает. Так как Presenter-ом управляет реализация View, то есть Activity или фрагмент, нужно модифицировать и ее. Mosby берет эту ответственность на себя, нужно только наследоваться от Activity из библиотеки, к примеру, BaseMvpActivity:
public class AuthActivity extends MvpActivity<AuthView, AuthPresenter> implements AuthView
Тогда нужно только реализовать метод createPresenter:
@NonNull @Override public AuthPresenter createPresenter() { LifecycleHandler lifecycleHandler = LoaderLifecycleHandler.create(this, getSupportLoaderManager()); return new AuthPresenter(lifecycleHandler); }
Теперь в Activity вы можете получить доступ к Presenter-у с помощью метода getPresenter. Такой подход может быть достаточно удобным, так как вам не нужно думать о том, как связать View и Presenter. Это простой пример, который показывает, как можно использовать основные функции библиотеки. Хотя нельзя сказать, что использование библиотеки ради удаления пары строчек кода – это хорошая идея.
Как было сказано, еще один из примеров использования Mosby – это LCE-экраны. Однако решение в библиотеке не слишком удобное, так как в базовой Activity уже содержатся View элементы, которые предназначены для управления данными и показа ошибок, что не слишком удобно и лишает гибкости.
Кроме того, Mosby позволяет сохранять состояние View и восстанавливать его с помощью специального интерфейса ViewState. К сожалению, это возможность можно использовать только вместе с фрагментами.[wpanchor id=”4″]
Какой можно сделать вывод из рассмотренного? Да, библиотека Mosby упрощает некоторые действия и позволяет структурировать код. Но взамен привносит и свои ограничения, поэтому такой подход нужно реализовывать очень осторожно. Поэтому, если вы можете обойтись без библиотеки, лучше обойтись без нее.
Практика
- Скачать Проект GithubMVP
- Нужно перевести экран walkthrough (описание в WalkthroughActivity) на MVP
- Реализовать экран списка коммитов (описание в CommitsActivity) в соответствии с паттерном MVP и описанными сценариями[wpanchor id=”5″]
Ссылки и полезные ресурсы
- Приложения из репозитория:
- GithubMVP – пример приложения, написанного по рассмотренной в лекции архитектуре на базе MVP, и практическое задание.
- GithubMosby – то же самое приложение, в котором паттерн MVP реализуется с помощью библиотеки Mosby, и практическое задание.
- Паттерны MVC, MVP и MVVM.
- Статья про различия MVP и MVC.
- Введение в MVP в Android с примерами.
- Статья про MVP в Android.
- Документация про Mosby.
- Пример использования Mosby.
- Хорошая статья про Moxy.
Продолжение: Лекция 6 по архитектуре Android. Unit тестирование. Test Driven Development
В файле build.gradle закоментированны все сслки на это фтвкщшв-фзе
вот так например
apply plugin: ‘com.android.application’
//apply plugin: ‘me.tatarka.retrolambda’
//apply plugin: ‘android-apt’
apply plugin: ‘realm-android’
ependencies {
// compile “com.android.support:support-v4:$supportVersion”
// compile “com.android.support:support-v13:$supportVersion”
//compile “com.android.support:appcompat-v7:$supportVersion”
// compile “com.android.support:design:$supportVersion”
compile “com.squareup.okhttp3:okhttp:$okhttpVersion”
compile “com.squareup.okhttp3:logging-interceptor:$okhttpVersion”
compile “com.squareup.retrofit2:retrofit:$retrofitVersion”
compile “com.squareup.retrofit2:converter-gson:$retrofitVersion”
compile “com.squareup.retrofit2:adapter-rxjava:$retrofitVersion”
compile “io.reactivex:rxandroid:$rxandroidVersion”
compile “io.reactivex:rxjava:$rxjavaVersion”
compile “ru.arturvasilov:rx-loader:$rxLoaderVersion”
compile “com.github.orhanobut:hawk:$hawkVersion”
compile “com.jakewharton:butterknife:$butterKnifeVersion”
//apt “com.jakewharton:butterknife-compiler:$butterKnifeVersion”
annotationProcessor “com.jakewharton:butterknife-compiler:$butterKnifeVersion”
Все равно Error:A problem occurred configuring project ‘:app’.
> android-apt plugin is incompatible with the Android Gradle plugin. Please use ‘annotationProcessor’ configuration instead.
ОКУДА ОНА эти ссылки берет?
Вот что выдается при сборке проекта в Android Studio
Error:android-apt plugin is incompatible with the Android Gradle plugin. Please use ‘annotationProcessor’ configuration instead.
Что бы сие означало и как бороться с этим?
Спасибо.
апдейтните версию гредла. Напишите мне, покажу как ето сделать.
Проект GithubMVP не компилируется в Android Studio 3.0. Пожалуйста если не сложно выложите новый архив который можно будет скомпилировать в Android Studio 3.0. Спасибо.
апдейтните версию гредла