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


Всем привет. На прошедшем 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.


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


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

-->


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