Разбираемся с новыми архитектурными компонентами в Android

в 11:37, , рубрики: google io, Блог компании Google, разработка мобильных приложений, Разработка под android

Гостевая статья от участника Google IO 2017 и одного из лидеров GDG Kazan — Артура Василова (@Arturka).

19 мая завершилась конференция Google I/O. По поему мнению, несмотря на то, что новых продуктов в этом году было представлено не так много, с технической точки зрения конференция получилась интересной.

Самым большим и интересным техническим обновлением лично для меня стали новые Architecture Components (не Android O, в котором мало чего интересного, и уж точно не Kotlin). Google сделал то, что должен был сделать уже очень давно — разработать стандарты архитектуры и предоставить их разработчикам. Что же, лучше поздно, чем никогда, и давайте разбираться с тем, насколько полезной может быть архитектура от Google.
Разбираемся с новыми архитектурными компонентами в Android - 1

Первое впечатление

Лично меня название докладов не заинтересовало настолько, что я даже не посещал их, так как не верил, что Google в этой области может сделать что-то интересное, а не просто повторить Mosby/Moxy/<ваш собственный велосипед>. Такой же скепсис сохранился и после быстрого просмотра разработанных библиотек. Однако через некоторое время пришлось поменять свое мнение, так как:

  • Google действительно сделали хорошую библиотеку с удобным API;
  • Google не стал закрываться, а учел все современные наработки в области архитектуры Android-приложений, взяв лучшее из всего;
  • У новичков наконец-то есть хорошая документация, которая поможет им начать разрабатывать приложения уже с неплохой архитектурой, без необходимости детально вникать в смысл MVP/MVVM и т.д.

После более глубокого погружения я попробовал написать приложение с использованием этой архитектуры (а точнее — переписать, чтобы оценить сложность миграции), и теперь могу поделиться этим опытом.

Хорошая архитектура

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

  • Позволяет декомпозировать логику (получение данных/обработка/взаимодействие с пользователем и т.д.);
  • Масштабируемость. Слово, которое должно быть в любой статье про архитектуру. В Android с этим все достаточно просто, так как любое приложение можно разбить на экраны, между которыми будут весьма прозрачные связи, а архитектуру отдельного экрана сделать масштабируемой не представляется сложным;
  • Позволяет писать тесты для бизнес-логики приложения.

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

  1. Обработка пересоздания Activity. Вероятно, самая многострадальная тема при построении архитектуры приложения, которая не нуждается в комментариях;
  2. Выполнение HTTP запросов (как на получение данных, так и на отправку);
  3. Поддержка потоков данных, например, при работе с сокетами/сенсорами;
  4. Кэширование данных;
  5. Обработка ошибок.

Разумеется, пункты 2-5 должны выполняться с учетом первого пункта.

Достаточно долго Android-разработчики самостоятельно решали все эти проблемы, но постепенно накопившийся опыт привел к появлению Clean Architecture. Подходы Clean Architecture очень хороши, за исключением того, что часто в приложении неуместно разбиение логики на UI и бизнес (так как один из слоев может почти не содержать кода), поэтому многие разработчики используют архитектуру, которую можно представить следующей схемой (немного переставив блоки из Clean Architecture):
Разбираемся с новыми архитектурными компонентами в Android - 2

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

Архитектура от Google

Сразу посмотрим на схему от Google:
Разбираемся с новыми архитектурными компонентами в Android - 3
Возможно, мне показалось, но по-моему, предыдущую схему просто повернули вертикально. И этим я не хочу сказать, что Google присвоил себе что-то старое, напротив, это показывает то, что Google присматривается к мнению разработчиков и учитывает все их достижения, что не может не радовать.

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

Одной из больших проблем в Android является необходимость постоянно подписываться/отписываться от каких-то объектов при вызове методов жизненного цикла. И поскольку методы жизненного цикла есть только в Activity/Fragment все такие объекты (например, GoogleApiClient, LocationManager, SensorManager и другие) должны быть расположены только в Activity/Fragment, что приводит к большому количеству строк кода в этих файлах.

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

Рассмотрим, как мы можем использовать LiveData, например, для того, чтобы следить за изменением местоположения пользователя. Создадим следующий класс:

public class LocationLiveData extends LiveData<Location> {
 
   private LocationManager locationManager;
 
   private LocationListener listener = new LocationListener();
 
   private LocationLiveData(@NonNull Context context) {
       locationManager = (LocationManager) context.getSystemService(Context.LOCATION_SERVICE);
   }
 
   @Override
   protected void onActive() {
       locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 0, 0, listener);
   }
 
   @Override
   protected void onInactive() {
       locationManager.removeUpdates(listener);
   }
}

И теперь мы можем написать следующий код в Activity:

public class LocationActivity extends LifecycleActivity {
 
   private LocationLiveData locationLiveData;
  
   @Override
   protected void onCreate(Bundle savedInstanceState) {
       super.onCreate(savedInstanceState);
       setContentView(R.layout.activity_movies);
      
       //...
 
       locationLiveData = new LocationLiveData(this);
       locationLiveData.observe(this, location -> {
           // update your UI
       });
   }
 
   //...
}

Все нужные методы жизненного цикла у LiveData будут вызываться за счет первого параметра в методе observe — LifecycleOwner. Это позволяет нам знать, когда нужно подписываться и отписываться от различных объектов (например, с помощью методов onActive и onInactive).

LifecycleActivity — это класс из библиотеки, который реализует интерфейс LifecycleOwner. К сожалению, он почему-то унаследован от FragmentActivity, а не от AppCompatActivity, и, если вы хотите все же наследоваться от AppCompatActivity, придется написать некоторый код самому:

public class BaseLifecycleActivity extends AppCompatActivity implements LifecycleRegistryOwner {
 
   @NonNull
   private final LifecycleRegistry lifecycleRegistry = new LifecycleRegistry(this);
 
   @MainThread
   @NonNull
   @Override
   public LifecycleRegistry getLifecycle() {
       return lifecycleRegistry;
   }
}

Кроме методов onActive и onInactive в LiveData вы можете получать коллбэки на любой метод жизненного цикла.

Точно такую же модель можно использовать и для реализации серверных запросов. То есть мы никаким образом не меняем способ получения данных (все также через Repository), но делегируем данные в LiveData, которая по сути является биндингом для View.

И теперь встает главный вопрос — как бороться с проблемой жизненного цикла? Разумеется, Google об этом подумал и дал разработчикам компонент, который переживает пересоздание Activity — ViewModel.

Пару слов про ViewModel:

  1. Все экземпляры ViewModel хранятся в retain Fragment (HolderFragment). Это весьма стандартное решение, которым также пользовались многие разработчики.
  2. Класс ViewModel должен хранить все экземпляры LiveData, чтобы они переживали пересоздание Activity.
  3. Связь ViewModel с View осуществляется через LiveData, то есть ViewModel не управляет View явным образом, и это можно считать некоторой вариацией паттерна MVVM.

Разберем, как мы можем реализовать серверный запрос для какого-либо экрана. Создадим ViewModel, которая будет управлять логикой экрана:

public class MoviesViewModel extends ViewModel {
  
   @NonNull
   private final MoviesRepository moviesRepository;
 
   @Nullable
   private MutableLiveData<List<Movie>> moviesLiveData;
 
   public MoviesViewModel(@NonNull MoviesRepository moviesRepository) {
       this.moviesRepository = moviesRepository;
   }
 
   @MainThread
   @NonNull
   LiveData<List<Movie>> getMoviesList() {
       if (moviesLiveData == null) {
           moviesLiveData = new MutableLiveData<>();
           moviesRepository.popularMovies()
                   .subscribeOn(Schedulers.io())
                   .observeOn(AndroidSchedulers.mainThread())
                   .subscribe(moviesLiveData::setValue);
       }
       return moviesLiveData;
   }
}

Тогда View сможет использовать эту ViewModel следующим образом:

viewModel.getMoviesList().observe(this, movies -> {
   if (movies != null) {
       adapter.setNewValues(movies);
   }
});

Поскольку ViewModel переживает пересоздание Activity, LiveData будет создана только один раз и серверный запрос будет выполнен только один раз, то есть эти проблемы решены. Остается вопрос создания ViewModel.

Для доступа к ViewModel используется класс ViewModelProviders:

MoviesViewModel viewModel = ViewModelProviders.of(this).get(MoviesViewModel.class);

Однако здесь есть небольшая проблема. Когда мы используем не конструктор по умолчанию (а в примере выше мы передавали в конструктор объект Repository), нам придется писать свою фабрику для создания ViewModel:

class MoviesViewModelProviderFactory extends BaseViewModelFactory {
 
   @NonNull
   private final MoviesRepository repository;
 
   MoviesViewModelProviderFactory(@NonNull MoviesService moviesService) {
       this.repository = new MoviesRepository(moviesService);
   }
 
   @NonNull
   @Override
   public <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
       if (modelClass.equals(MoviesViewModel.class)) {
           //noinspection unchecked
           return (T) new MoviesViewModel(repository);
       }
       return super.create(modelClass);
   }
}

И теперь мы можем создать ViewModel следующим образом:

ViewModelProvider.Factory factory = new MoviesViewModelProviderFactory(service);
return ViewModelProviders.of(this, factory).get(MoviesViewModel.class);

В данном примере мы вручную передаем в Factory все нужные объекты для создания ViewModel, однако единственным нормальным подходом для создания ViewModel при росте числа параметров является Dagger. Здесь Google фактически вынуждает использовать его. Хорошо это или плохо, решать вам.

И это все — мы получили данные, отобразили их, и все это с обработкой жизненного цикла. Однако есть еще несколько моментов, которые мы рассмотрим далее.

Показ прогресса и обработка ошибок

Когда я только начал разбираться с новыми архитектурными компонентами, у меня сразу возник вопрос относительно того, как с помощью LiveData обрабатывать ошибки или получать изменение прогресса. Ведь все, что у нас есть — это экземпляр одного класса, который мы должны передавать в Activity/Fragment. Аналогичный вопрос возникает и с отображением прогресса. Такая проблема немного похожа на проблему с лоадерами, однако здесь есть два варианта, как ее решить.

Во-первых, мы можем создать некоторый класс, объект которого будет инкапсулировать в себе статус запроса, сообщение об ошибке и сами данные, и передавать этот объект в Activity/Fragment. Этот способ рекомендуется в документации. Такой класс может выглядеть, например, следующим образом (пока что разберем только обработку ошибок):

public class Response<T> {
 
   @Nullable
   private final T data;
 
   @Nullable
   private final Throwable error;
 
   private Response(@Nullable T data, @Nullable Throwable error) {
       this.data = data;
       this.error = error;
   }
 
   @NonNull
   public static <T> Response<T> success(@NonNull T data) {
       return new Response<T>(data, null);
   }
 
   @NonNull
   public static <T> Response<T> error(@NonNull Throwable error) {
       return new Response<T>(null, error);
   }
 
   @Nullable
   public T getData() {
       return data;
   }
 
   @Nullable
   public Throwable getError() {
       return error;
   }
}

Тогда при каждом изменении мы передаем во View объект этого класса:

public class MoviesViewModel extends ViewModel {
 
   @Nullable
   private MutableLiveData<Response<List<Movie>>> moviesLiveData;
 
   //...
 
   @MainThread
   @NonNull
   LiveData<Response<List<Movie>>> getMoviesList() {
       if (moviesLiveData == null) {
           moviesLiveData = new MutableLiveData<>();
           moviesRepository.popularMovies()
                   //...
                   .subscribe(
                           movies -> moviesLiveData.setValue(Response.success(movies)),
                           throwable -> moviesLiveData.setValue(Response.error(throwable))
                   );
       }
       return moviesLiveData;
   }
}

И View обрабатывает его соответствующим образом:

viewModel.getMoviesList().observe(this, moviesResponse -> {
   if (moviesResponse != null && moviesResponse.getData() != null) {
       adapter.setNewValues(moviesResponse.getData());
   } else if (moviesResponse != null && moviesResponse.getError() != null) {
       // show error
   }
});

И во-вторых есть способ, который на мой взгляд является более лаконичным. В конце концов, статус запроса и ошибки — это тоже некоторые изменяющиеся данные. Тогда почему бы не поместить их в LiveData и не подписываться на их изменения? Для этих целей мы можем написать следующий код (а в этой ситуации уже рассмотрим обработку статуса загрузки):

@NonNull
private final MutableLiveData<Boolean> loadingLiveData = new MutableLiveData<>();
 
@NonNull
LiveData<Boolean> isLoading() {
   return loadingLiveData;
}
 
@MainThread
@NonNull
LiveData<Response<List<Movie>>> getMoviesList() {
   if (moviesLiveData == null) {
       moviesLiveData = new MutableLiveData<>();
       moviesRepository.popularMovies()
               //...
               .doOnSubscribe(disposable -> loadingLiveData.setValue(true))
               .doAfterTerminate(() -> loadingLiveData.setValue(false))
               .subscribe();
   }
   return moviesLiveData;
}

И теперь View будет подписываться не на одну LiveData, а на несколько. Это может быть очень удобно, если мы пишем общий обработчик отображения прогресса или ошибок):

viewModel.isLoading().observe(this, isLoading -> {
   if (isLoading != null && isLoading) {
       progressDialog.show(getSupportFragmentManager());
   } else {
       progressDialog.cancel();
   }
});

В итоге можно сказать, что обработка ошибок с помощью LiveData почти так же хороша, как и с помощью привычной RxJava.

Тестирование

Как уже было сказано выше, хорошая архитектура должна позволять тестировать приложение. Поскольку принципиальных изменений архитектура от Google не несет, то тестирование можно рассматривать только на уровне ViewModel (так как мы знаем, как тестировать Repository/UI).

Тестирование ViewModel всегда чуть сложнее, чем тестирование Presenter-а из паттерна MVP, так как в MVP явная связь между View и Presenter позволяет легко проверять, что были вызваны нужные методы у View. В случае MVVM нам приходится проверять работу биндингов или же LiveData.

Создадим тестовый класс, в котором в методе setUp создадим ViewModel:

@RunWith(JUnit4.class)
public class MoviesViewModelTest {
 
   private MoviesViewModel viewModel;
 
   private MoviesRepository repository;
 
   @Before
   public void setUp() throws Exception {
       repository = Mockito.mock(MoviesRepository.class);
       Mockito.when(repository.popularMovies()).thenReturn(Observable.just(new ArrayList<>()));
 
       viewModel = new MoviesViewModel(repository);
   }
}

Чтобы протестировать корректность ViewModel, мы должны убедиться, что она выполняет запрос к Repository и передает полученные данные в LiveData:

@Test
public void testLoadingMovies() throws Exception {
   Observer observer = Mockito.mock(Observer.class);
   viewModel.getMoviesList().observeForever(observer);
 
   Mockito.verify(repository).popularMovies();
   Mockito.verify(observer).onChanged(any(Response.class));
}

Здесь опустим лишние детали, но при необходимости можно использовать возможности Mockito, чтобы удостовериться, что в LiveData пришли именно те данные, которые нужно.
Однако такой тест не сработает, так как метод setValue в классе LiveData проверяет, что он вызывается в главном потоке приложения (разумеется, с помощью Looper):

@MainThread
protected void setValue(T value) {
   assertMainThread("setValue");
   mVersion++;
   mData = value;
   dispatchingValue(null);
}

Как мы прекрасно знаем, тесты на JUnit этого не любят и падают. Но разработчики Google предусмотрели и это. Для этого нужно подключить дополнительную библиотеку для тестов и добавить правило к тесту:

testCompile ("android.arch.core:core-testing:$architectureVersion", {
   exclude group: 'com.android.support', module: 'support-compat'
   exclude group: 'com.android.support', module: 'support-annotations'
   exclude group: 'com.android.support', module: 'support-core-utils'
})
 
@Rule
public InstantTaskExecutorRule instantExecutorRule = new InstantTaskExecutorRule();

Аналогичным образом мы можем протестировать показ прогресса во время загрузки данных:

@Test
public void testProgressUpdated() throws Exception {
   Observer observer = Mockito.mock(Observer.class);
   viewModel.isLoading().observeForever(observer);
   viewModel.getMoviesList();
 
   Mockito.verify(observer).onChanged(true);
   Mockito.verify(observer).onChanged(false);
}

Таким образом, мы можем без особых проблем писать тесты на все нужные компоненты.

Кэширование данных

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

Все прекрасно знают про существующий огромный зоопарк библиотек для баз данных в Android. Это Realm, greenDao, ObjectBox, DBFlow, SQLBrite и много других. Поэтому многие разработчики не знают, что выбрать для своего проекта, и здесь Room является идеальным вариантом, как минимум потому, что она от Google.

Room является достаточно простой библиотекой, которая работает поверх SQLite, использует аннотации для генерации boilerplate кода и имеет достаточно привычный и удобный API. Однако я не считаю, что смогу рассказать про Room что-то более интересное, чем было в сессии на Google I/O и чем есть в документации. Поэтому здесь мы закончим рассмотрение архитектурных новинок.

Заключение

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

Автор: Google

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js