Новая архитектура Android-приложений — пробуем на практике

Всем привет. На прошедшем Google I/O нам наконец представили официальное видение компании Google на архитектуру Android-приложений, а также библиотеки для его реализации. Не прошло и десяти лет. Конечно мне сразу захотелось попробовать, что же там предлагается.


Осторожно: библиотеки находятся в альфа-версии, следовательно мы можем ожидать ломающих совместимость изменений.


Lifecycle

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


Теперь это не так. Нам представлен новый пакет android.arch.lifecycle, в котором находятся классы Lifecycle, LifecycleActivity и LifecycleFragment. В недалеком будущем предполагается, что все компоненты системы, которые живут в некотором жизненном цикле, будут предоставлять Lifecycle через имплементацию интерфейса LifecycleOwner:


public interface LifecycleOwner {
   Lifecycle getLifecycle();
}

Поскольку пакет еще в альфа-версии и его API нельзя смешивать со стабильным, были добавлены классы LifecycleActivity и LifecycleFragment. После перевода пакета в стабильное состояние LifecycleOwner будет реализован в Fragment и AppCompatActivity, а LifecycleActivity и LifecycleFragment будут удалены.


Lifecycle содержит в себе актуальное состояние жизненного цикла компонента и позволяет LifecycleObserver подписываться на события переходов по жизненному циклу. Хороший пример:


class MyLocationListener implements LifecycleObserver {
    private boolean enabled = false;
    private final Lifecycle lifecycle;
    public MyLocationListener(Context context, Lifecycle lifecycle, Callback callback) {
       this.lifecycle = lifecycle;
       this.lifecycle.addObserver(this);
       // Какой-то код
    }

    @OnLifecycleEvent(Lifecycle.Event.ON_START)
    void start() {
        if (enabled) {
           // Подписываемся на изменение местоположения
        }
    }

    public void enable() {
        enabled = true;
        if (lifecycle.getState().isAtLeast(STARTED)) {
            // Подписываемся на изменение местоположения,
            // если еще не подписались
        }
    }

    @OnLifecycleEvent(Lifecycle.Event.ON_STOP)
    void stop() {
        // Отписываемся от изменения местоположения
    }
}

Теперь нам достаточно создать MyLocationListener и забыть о нем:


class MyActivity extends LifecycleActivity {

    private MyLocationListener locationListener;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        locationListener = new MyLocationListener(this, this.getLifecycle(), location -> {
         // Обработка местоположения, например, вывод на экран
        });
        // Что-то выполняющееся долго и асинхронно
        Util.checkUserStatus(result -> {
            if (result) {
                locationListener.enable();
            }
        });
    }
}

LiveData

LiveData — это некий аналог Observable в rxJava, но знающий о существовании Lifecycle. LiveData содержит значение, каждое изменение которого приходит в обзерверы.


Три основных метода LiveData:


setValue() — изменить значение и уведомить об этом обзерверы;
onActive() — появился хотя бы один активный обзервер;
onInactive() — больше нет ни одного активного обзервера.


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


Активным обзервером считается тот, чей Lifecycle находится в состоянии STARTED или RESUMED. Если к LiveData присоединяется новый активный обзервер, он сразу получает текущее значение.


Это позволяет хранить экземпляр LiveData в статической переменной и подписываться на него из UI-компонентов:


public class LocationLiveData extends LiveData<Location> {
    private LocationManager locationManager;

    private SimpleLocationListener listener = new SimpleLocationListener() {
        @Override
        public void onLocationChanged(Location location) {
            setValue(location);
        }
    };

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

Сделаем обычную статические переменную:


public final class App extends Application {

    private static LiveData<Location> locationLiveData = new LocationLiveData();

    public static LiveData<Location> getLocationLiveData() {
        return locationLiveData;
    }
}

И подпишемся на изменение местоположения, например, в двух активити:


public class Activity1 extends LifecycleActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity1);

        getApplication().getLocationLiveData().observe(this, (location) -> {
          // do something
        })
    }
}

public class Activity2 extends LifecycleActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity2);

        getApplication().getLocationLiveData().observe(this, (location) -> {
          // do something
        })
    }
}

Обратите внимание, что метод observe принимает первым параметром LifecycleOwner, тем самым привязывая каждую подписку к жизненному циклу конкретной активити.


Как только жизненный цикл активити переходит в DESTROYED подписка уничтожается.


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


ViewModel

ViewModel — хранилище данных для UI, способное пережить уничтожение компонента UI, например, смену конфигурации (да, MVVM теперь официально рекомендуемая парадигма). Свежесозданная активити переподключается к ранее созданной модели:


public class MyActivityViewModel extends ViewModel {

    private final MutableLiveData<String> valueLiveData = new MutableLiveData<>();

    public LiveData<String> getValueLiveData() {
        return valueLiveData;
    }
}

public class MyActivity extends LifecycleActivity {

    MyActivityViewModel viewModel;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity);

        viewModel = ViewModelProviders.of(this).get(MyActivityViewModel.class);
        viewModel.getValueLiveData().observe(this, (value) -> {
            // Вывод значения на экран
        });
    }
}

Параметр метода of определяет область применимости (scope) экземпляра модели. То есть если в of передано одинаковое значение, то вернется один и тот же экземпляр класса. Если экземпляра еще нет, он создастся.


В качестве scope можно передать не просто ссылку на себя, а что-нибудь похитрее. В настоящее время рекомендуется три подхода:


  1. активити передает себя;
  2. фрагмент передает себя;
  3. фрагмент передает свою активити.

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


После уничтожения всех компонентов, к которым привязан экземпляр модели, у нее вызывается событие onCleared и модель уничтожается.


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


Room Persistence Library

Наше счастье было бы неполным без возможности сохранить данные локально после безвременной кончины приложения. И тут на помощь спешит доступный «из коробки» SQLite. Однако API работы с базами данных довольно неудобное, главным образом тем, что не предоставляет способов проверки кода при компиляции. Про опечатки в SQL-выражениях мы узнаем уже при исполнении приложения и хорошо, если не у клиента.


Но это осталось в прошлом — Google представила нам ORM-библиотеку со статическим анализом SQL-выражений при компиляции.


Нам нужно реализовать минимум три компонента: Entity, DAO и Database.


Entity — это одна запись в таблице:


@Entity(tableName = «users»)
public class User() {

    @PrimaryKey
    public int userId;

    public String userName;
}

DAO (Data Access Object) — класс, инкапсулирующий работу с записями конкретного типа:


@Dao
public interface UserDAO {

    @Insert(onConflict = REPLACE)
    public void insertUser(User user);

    @Insert(onConflict = REPLACE)
    public void insertUsers(User… users);

    @Delete
    public void deleteUsers(User… users);

    @Query(«SELECT * FROM users»)
    public LiveData<List<User>> getAllUsers();

    @Query(«SELECT * FROM users WHERE userId = :userId LIMIT 1»)
    LiveData<User> load(int userId);

    @Query(«SELECT userName FROM users WHERE userId = :userId LIMIT 1»)
    LiveData<String> loadUserName(int userId);
}

Обратите внимание, DAO — интерфейс, а не класс. Его имплементация генерируется при компиляции.


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


В качестве выражения в Query можно передавать, в том числе, объединения таблиц. Однако, сами Entity не могут содержать поля-ссылки на другие таблицы, это связано с тем, что ленивая (lazy) подгрузка данных при обращении к ним начнется в том же потоке и наверняка это окажется UI-поток. Поэтому Google приняла решение запретить полностью такую практику.


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


Мало того, Google обещает нам, что в будущем отслеживание изменений будет выполнятся построчно, а не потаблично как сейчас.


Наконец, нам надо задать саму базу данных:


@Database(entities = {User.class}, version = 1)
public abstract class AppDatabase extends RoomDatabase {
    public abstract UserDAO userDao();
}

Здесь также применяется кодогенерация, поэтому пишем интерфейс, а не класс.


Создаем в Application-классе или в Dagger-модуле синглтон базы:


AppDatabase database = Room.databaseBuilder(context, AppDatabase.class, "data").build();

Получаем из него DAO и можно работать:


database.userDao().insertUser(new User(…));

При первом обращении к методам DAO выполняется автоматическое создание/пересоздание таблиц или исполняются SQL-скрипты обновления схемы, если заданы. Скрипты обновления схемы задаются посредством объектов Migration:


AppDatabase database = Room.databaseBuilder(context, AppDatabase.class, "data")
     .addMigration(MIGRATION_1_2)
     .addMigration(MIGRATION_2_3)
     .build();

static Migration MIGRATION_1_2 = new Migration(1, 2) {
    @Override
    public void migrate(SupportSQLDatabase database) {
        database.execSQL(…);
    }
}

static Migration MIGRATION_2_3 = new Migration(2, 3) {
   …
}

Плюс не забудьте у AppDatabase указать актуальную версию схемы в аннотации.


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


По окончанию исполнения всех скриптов, выполняется автоматическая проверка соответствия базы и классов Entity, и вылетает Exception при несовпадении.


Осторожно: Если не удалось составить цепочку переходов с фактической версии на последнюю, база удаляется и создается заново.


На мой взгляд алгоритм обновления схемы обладает недостатками. Если у вас на устройстве есть устаревшая база, она обновится, все хорошо. Но если базы нет, а требуемая версия > 1 и задан некоторый набор Migration, база создастся на основе Entity и Migration выполнены не будут.
Нам как бы намекают, что в Migration могут быть только изменения структуры таблиц, но не заполнение их данными. Это прискорбно. Полагаю, мы можем ожидать доработок этого алгоритма.


Чистая архитектура

Все вышеперечисленные сущности являются кирпичиками предлагаемой новой архитектуры приложений. Надо отметить, Google нигде не пишет clean architecture, это некоторая вольность с моей стороны, однако идея схожа.
image
Ни одна сущность не знает ничего о сущностях, лежащих выше нее.


Model и Remote Data Source отвечают за хранение данных локально и запрос их по сети соответственно. Repository управляет кешированием и объединяет отдельные сущности в соответствие с бизнес задачами. Классы Repository — просто некая абстракция для разработчиков, никакого специального базового класса Repository не существует. Наконец, ViewModel объединяет разные Repository в виде, пригодном для конкретного UI.


Данные между слоями передаются через подписки на LiveData.


Пример

Я написал небольшое демонстрационное приложение. Оно показывает текущую погоду в ряде городов. Для простоты список городов задан заранее. В качестве поставщика данных используется сервис OpenWeatherMap.


У нас два фрагмента: со списком городов (CityListFragment) и с погодой в выбранном городе (CityFragment). Оба фрагмента находятся в MainActivity.


Активити и фрагменты пользуются одной и той же MainActivityViewModel.


MainActivityViewModel запрашивает данные у WeatherRepository.


WeatherRepository возвращает старые данные из базы данных и сразу инициирует запрос обновленных данных по сети. Если обновленные данные успешно пришли, они сохраняются в базу и обновляются у пользователя на экране.


Для корректной работы необходимо прописать API key в WeatherRepository. Ключ можно бесплатно взять после регистрации на OpenWeatherMap.


Репозиторий на GitHub.


Нововведения выглядит очень интересно, однако порыв все переделать пока стоит по-придержать. Не забываем, что это только альфа.


Замечания и предложения приветствуются. Ура!

Поделиться публикацией

Комментарии 14

    0
    Радоваться или грустить… Новичкам точно усложняют задачу. А вот люди, которые как-то пытались реализовать MVP, должны быть в восторге.
      0
      Имхо, в андроиде уже была куча разных подходов к архитектуре MVP, MVI, MVVM, Clean Arhitecture, но многие новички как то кодят и без низ, так что на мой взгляд еще один подход сильно жизнь новичкам не усложнит, и даже скорее наоборот хорошо, что появилось приятное решение из коробки.
      +2
      Печаль тоска. Компат станет ещё жирнее
        0
        Но в то же время они его облегчают путем отбрасывания поддержки старых SDK.
        Да и в общем-то кода там немного, а Room идет отдельно.
        Собственно, Lifecycle тоже сейчас отдельно, но это, как я понимаю, временно.
        • НЛО прилетело и опубликовало эту надпись здесь
            0
            Ну не настолько же старых
            • НЛО прилетело и опубликовало эту надпись здесь
        0

        С LiveData и Activity понятно, но со ViewModel — не очень.


        Предполагается, что все данные — во ViewModel. И если какие-то из них — LiveData, то ViewModel подписана на них, а Activity — во ViewModel? Как обрабатывается стейт активити внутри ViewModel?


        Однако, сами Entity не могут содержать поля-ссылки на другие таблицы, это связано с тем, что ленивая (lazy) подгрузка данных при обращении к ним начнется в том же потоке и наверняка это окажется UI-поток. Поэтому Google приняла решение запретить полностью такую практику.

        Но ведь помимо Lazy есть другие способы загрузить свойство навигации (в терминах EF).

          0
          Нет, ViewModel не подписывается на LiveData. Хранит только ссылки на такие объекты, не более того.
            0
            Предполагается, что все данные — во ViewModel. И если какие-то из них — LiveData, то ViewModel подписана на них, а Activity — во ViewModel? Как обрабатывается стейт активити внутри ViewModel?

            По сути никак. Событие onCleared — единственная точка влияния стейта активити на ViewModel. Интеллектуальная обработка стейта происходит целиком, полностью и независимо в каждой LiveData, которые модель выставляет наружу. Если вам в модели надо знать состояние активити, надо вручную сделать метод addLifecycle(Lifecycle lifecycle), который каждая активити вызовет как addLifecycle(this.getLifecycle()). А дальше в модели подписываться на события каждого Lifecycle и вручную отрабатывать, что, например, модель должна делать, если два активити стали PAUSED и один ACTIVE.
            Я полагаю, что как правило LiveData в модели будут данные брать не из воздуха, а получая их из LiveData в Repository. Это делается через трансформацию (аналог операторов в rxJava):


            // В WeatherRepository:
            
            public LiveData<WeatherInfo> getWeather(String cityName) {
              // Данные из БД
            }
            
            // В MainActivityViewModel:
            
            private final MutableLiveData<String> cityNameLiveData = new MutableLiveData<>();
            private final LiveData<WeatherInfo> weatherInfoLiveData;
            
            public MainActivityViewModel() {
                 this.weatherInfoLiveData = Transformations.switchMap(cityNameLiveData, weatherRepository::getWeather);
            }
            
            // Вызывает UI, когда хочет получить данные по наименованию города
            public void selectCity(String cityName) {
               cityNameLiveData.setValue(cityName);
            }
            
            // На это UI подписывается
            public LiveData<WeatherInfo> weatherInfo() {
                 return weatherInfoLiveData;
            }

            Здесь у нас есть один инстанс weatherInfoLiveData, в котором живут стейты подписавшихся активити и Transformations.switchMap, который говорит "каждый раз, когда cityNameLiveData пришлет новые данные, вызови weatherRepository.getWeather(cityNameLiveData.getValue) и подпишись на тот LiveData, который он вернет, а все эмитируемые им данные передавай в weatherInfoLiveData".
            Если при этом данные надо преобразовать, то можно еще использовать Transformation.map(LiveData source, Function func).

              0

              Не совсем корректно написал: конечно же активити у модели может быть только одна, следовательно, и Lifecycle единственный, а вот если мы модель привязываем к нескольким фрагментам, то каждый из них вызовет addLifecycle(this.getLifecycle()) и мы получим в модели список из нескольких Lifecycle.

              0
              Но ведь помимо Lazy есть другие способы загрузить свойство навигации (в терминах EF).

              Google говорит так:


              However, on the client side, lazy loading is not feasible because it's likely to happen on the UI thread, and querying information on disk in the UI thread creates significant performance problems.

              А, если загружать сразу?


              If you don't use lazy loading, however, the app fetches more data than it needs, creating memory consumption problems.

              Поэтому


              For these reasons, Room disallows object references between entity classes. Instead, you must explicitly request the data that your app needs.

              Addendum: No object references between entities

              0
              Как с LiveData обрабатывать и показывать ошибки? Заводить отдельный объект LiveData для хранения последних ошибок? И так для каждого действия на экране?

              Тот же самый вопрос и про успешное выполнение какого-либо действия, например, сохранение данных.

              Проблему с поворотом Activity более менее я понял как LiveData решает. А что с убийством Activity при сворачивании приложения?
                0

                LiveData не имеет встроенных механизмов обработки ошибок. Придется делать что-то типа LiveData<Data>, где


                class Data {
                  Status status;
                  Object payLoad; // Полезная нагрузка, ради которой все затевается
                  String errorMessage;
                }
                
                enum Status { SUCCESS, LOADING, FAIL }

                и передавать руками статусы и сообщения об ошибках вместе с данными. Соответственно, создалась Activity, подписалась на LiveData в своей ViewModel и сразу получила закешированные (или пустые данные) + статус. Если статус LOADING, включается progress, иначе выключается.
                Такой подход рекомендует Google на примере обработки состояния сетевого запроса: Addendum: exposing network status


                Любопытно, кстати, что если мы вызовем несколько раз liveData.setValue с одинаковым значением, обзерверы так же будут вызваны несколько раз с одним и тем же значением.


                Проблему с поворотом Activity более менее я понял как LiveData решает. А что с убийством Activity при сворачивании приложения?

                Внутри фреймворка хранение моделей реализовано через специальные retained fragments. Соответственно, если Activity будет убита системой, ViewModel и все LiveData будут также убиты. И весь процесс начнется сначала, как будто приложение запускают впервые.

              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

              Самое читаемое