Лекция 6

Ссылка на репозиторий с тестовым проектом - https://github.com/MykolaHodovychenko/my-notepad

Общие сведения об архитектурных компонентах

Понятие Architectural components

Architecture components - набор библиотек, который помогает создавать надежные, тестируемые и легкие в поддержке приложения. Начиная с классов для управления жизненным циклом компонентов UI и управления данными приложения.

Arhitecture components содержат в себе много новых компонентов, таких как: LiveData, ViewModel, LifecycleObserver, LifecycleOwner, а также библиотеку Room для работы с БД приложения. Вместе эти компоненты помогут разработчикам следовать хорошему архитектурному паттерну с правильным разделением ответственности между слоями, а также позволят достаточно легко и безболезненно вносить изменения, избегать утечек памяти и обновлять UI при изменении данных. Дадим краткое описание компонент:

  1. LifecycleOwner — интерфейс для компонентов, у которого есть свой жизненный цикл (как у Activity и Fragment);

  2. LifecycleObserver — подписывается на изменения жизненного цикла LifecycleOwner’а, который самодостаточен. Он не полагается на методы onStart() и onStop() Activity или Fragment при инициализации и остановке;

  3. LiveData — это наблюдаемый объект для хранения данных, он уведомляет наблюдателей, когда данные изменяются. Этот компонент также является связанным с жизненным циклом LifecycleOwner (Activity или Fragment), что помогает избежать утечек памяти и других неприятностей;

  4. ViewModel — это объекты, которые предоставляют данные для UI компонентов. Они не имеют ссылок на view и независимы от жизненного цикла LifecycleOwner’a;

  5. Room - основанная на SQLite библиотека ORM. Room является абстракцией над слоем, в котором производятся конкретные действия над БД, такими как вставки, удаления, создания таблиц и так далее. Room использует аннотации для генерации кода во время компиляции. Более того, Room также поддерживает LiveData и RxJava2 Flowable.

Шаблон MVVM с использованием Architecture Components

Архитектуру приложения с использованием Architecture Components схематически можно представить следующим образом

Опишем составляющие этого шаблона:

  1. View — этот слой содержит компоненты UI и отвечает за код, управляющий компонентами пользовательского интерфейса (view), такой как инициализация дочерних view, отображение progress bar’а, ввод данных пользователем, обработку анимаций и тому подобное. Такой код содержится в активити и фрагментах;

  2. ViewModel — объекты этого класса предоставляют данные для компонентов UI. В нашем случае View будут использовать LiveData для отслеживания изменений данных в ViewModel.

  3. Repository — репозитории являются абстракцией над источниками данных, которую приложение может использовать для получения данных и их кеширования. Эта абстракция полезна по двум основным причинам: 1) код не зависит от конкретной реализации хранилища данных, а также 2) в следствие предыдущей причины, мы можем легко менять конкретные реализации хранилища данных, например, для тестирования

  4. Data Service — это слой, который содержит код для кеширования (например, используя SQLite), а также код для загрузки данных (например, загрузка данных с backend api при помощи Retrofit).

Создадим простое приложение "My Notepad" для создания и редактирования простых заметок. Приложение состоит из двух окон:

  • MainActivity - главное окно для вывода списка заметок, добавления новой заметки, редактирования и удаления существующей заметки;

  • NoteActivity - окно для создания новой заметки и редактирования существующей.

Перед тем, как создавать наше приложение с использованием Architectural Components (далее - AC), добавим в проект необходимые библиотеки.

build.gradle (Module: app)
dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])

    implementation 'androidx.appcompat:appcompat:1.1.0'
    implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
    implementation 'com.google.android.material:material:1.2.0-alpha06'
    implementation 'androidx.cardview:cardview:1.0.0'

    implementation 'com.jakewharton:butterknife:10.1.0'
    annotationProcessor 'com.jakewharton:butterknife-compiler:10.1.0'

    implementation 'org.projectlombok:lombok:1.18.12'
    annotationProcessor 'org.projectlombok:lombok:1.18.12'

    implementation "androidx.room:room-runtime:2.2.5"
    annotationProcessor "androidx.room:room-compiler:2.2.5"
    implementation "androidx.lifecycle:lifecycle-viewmodel:2.2.0"
    implementation "androidx.lifecycle:lifecycle-livedata:2.2.0"
}

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

Этап 1. Использование технологии ORM с помощью библиотеки Room

На первом этапе мы воспользуемся библиотекой Room, подключим локальную базу данных, а также будем из Activity напрямую взаимодействовать с объектом типа RoomDatabase.

Создание классов для работы с Room

Библиотека Room предоставляет нам удобную обертку для работы с базой данных SQLite. Room имеет три основных компонента: Entity, Dao и Database. Поэтапно будем создавать требуемые классы для настройки Room.

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

  • id заметки;

  • заголовок заметки;

  • текст заметки;

  • дата создания задачи;

  • дата обновления заметки.

Для генерации геттеров, сеттеров воспользуемся библиотекой Lombok. Генерировать конструкторы с помощью Lombok не будем, а реализуем их вручную. Нам понадобится три конструктора: конструктор по умолчанию, конструктор со всеми параметрами, конструктор без параметра id (объяснение по поводу количества и параметров конструкторов будет дано ниже).

Note.java
@Data
public class Note {
    private int id;
    private String title;
    private String contents;
    private Date dateCreation;
    private Date dateUpdate;

    public Note(String title, String contents, Date dateCreation, Date dateUpdate) {
        this.title = title;
        this.contents = contents;
        this.dateCreation = dateCreation;
        this.dateUpdate = dateUpdate;
    }

    public Note() {
    }

    public Note(int id, String title, String contents, Date dateCreation, Date dateUpdate) {
        this.id = id;
        this.title = title;
        this.contents = contents;
        this.dateCreation = dateCreation;
        this.dateUpdate = dateUpdate;
    }
}

Данный класс пока не является Entity (сущностью), но пока оставим его в таком виде. Перейдем к созданию Database. Database - основной класс для работы с базой данных. Этот класс должен быть абстрактным и наследовать RoomDatabase.

Данный класс реализует паттерн Singleton (одиночка). Класс-наследник AppDatabase будет сгенерирован с помощью библиотеки Room и статического метода databaseBuilder(). При реализации паттерна Singleton мы реализуем потокобезопасное создание объекта.

AppDatabase.java
public abstract class AppDatabase extends RoomDatabase {

    public static final String DATABASE_NAME = "app_db.db";
    private static volatile AppDatabase instance;

    private static final Object LOCK = new Object();

    public static AppDatabase getInstance(Context context) {
        if (instance == null) {
            synchronized (LOCK) {
                if (instance == null)
                    instance = Room.databaseBuilder(context, AppDatabase.class, DATABASE_NAME).build();
            }
        }

        return instance;
    }
}

Теперь вернемся к классу, который моделирует задачу. Нам необходимо "превратить" обычный класс в сущность (Entity) для того, чтобы обеспечить работу технологии ORM. Для того чтобы разобраться, что такое ORM и что такое сущность, рассмотрим небольшой теоретический материал.

Краткое описание технологии ORM

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

  1. работа с данными в объектно-ориентированной программе и в базе данных основана на разных парадигмах (объектно-ориентированная и реляционная соответственно). Преобразование данных из одной парадигмы в другую ложится на плечи программиста, что влечет за собой лишнюю работу и может приводить к ошибкам в процессе преобразования;

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

Для устранения этих проблем используется технология ORM (Object-Relational Mapping, «объектно-реляционное отображение») — технология программирования, которая связывает базы данных с концепциями объектно-ориентированных языков программирования, создавая «виртуальную объектную базу данных».

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

Основное понятие в ORM – сущность (Entity). Сущность – это Java-класс, который представляет бизнес-логику приложения и определяет данные, которые будут храниться в базе данных и извлекаться из нее. Как правило, класс сущности представляет таблицу в базе данных, поля или свойства класса представляют собой колонки в таблице, а объект сущности представляет собой одну запись в таблице.

Создание и конфигурация класса-сущности осуществляется с помощью аннотаций. Добавим их в класс Note

Note.java
@Data
@Entity(tableName = "notes")
public class Note {
    @PrimaryKey(autoGenerate = true)
    private int id;
    private String title;
    private String contents;
    private Date dateCreation;
    private Date dateUpdate;

    @Ignore
    public Note(String title, String contents, Date dateCreation, Date dateUpdate) {
        this.title = title;
        this.contents = contents;
        this.dateCreation = dateCreation;
        this.dateUpdate = dateUpdate;
    }

    @Ignore
    public Note() {
    }

    public Note(int id, String title, String contents, Date dateCreation, Date dateUpdate) {
        this.id = id;
        this.title = title;
        this.contents = contents;
        this.dateCreation = dateCreation;
        this.dateUpdate = dateUpdate;
    }
}

Были добавлены следующие аннотации:

  • @Entity(tableName = "notes") - аннотация означает, что мы помечаем класс как сущность и что этому классу будет соответствовать таблица notes в базе данных;

  • @PrimaryKey(autoGenerate = true) - означает, что поле, помеченное этой аннотацией, является первичным ключом, которое генерируется автоматически;

  • @Ignore - этой аннотацией мы пометили два конструктора в классе Note. Если в классе присутствует несколько конструкторов, библиотека Room не знает, какой из них использовать при преобразовании записи в таблице в объект типа Note. Так как у нас в базу данных заносятся все поля, то Room должен использовать конструктор со всеми полями. Таким образом, помечаем аннотацией @Ignore все конструкторы, кроме того который принимает на вход все поля класса.

Более подробно про сущности в Room и сценарии использования различных аннотаций читайте здесь.

Теперь создадим класс DAO.

DAO (data access object) - шаблон проектирования для сохранения объектов в базе данных. DAO абстрагирует и инкапсулирует весь доступ к источнику данных, он также управляет соединением с источником данных для получения и хранения данных.

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

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

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

Для создания DAO необходимо объявить интерфейс, который будет помечен аннотацией @Dao. Внутри интерфейса мы должны объявить методы, которые описывают нужные нам операции с БД, после чего методы нужно пометить аннотациями, чтобы Room смогла сгенерировать нужный код для взаимодействия с БД,

NoteDAO.java
@Dao
public interface NoteDAO {

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    void insertNote(Note note);

    @Delete
    void deleteNote(Note note);

    @Query("DELETE FROM notes WHERE id=:id")
    void deleteNoteById(int id);

    @Query("SELECT * FROM notes WHERE id=:id")
    Note getNoteById(int id);

    @Query("DELETE FROM notes")
    void deleteAll();

    @Query("SELECT * FROM notes ORDER BY dateUpdate DESC")
    List<Note> getAll();
}

Подробнее про роль Dao и сценарии использования различных аннотаций читайте здесь.

Вернемся к классу AppDatabase и модифицируем его

AppDatabase.java
@Database(entities = {Note.class}, version = 1)
public abstract class AppDatabase extends RoomDatabase {

    public static final String DATABASE_NAME = "app_db.db";
    private static volatile AppDatabase instance;

    public abstract NoteDAO noteDAO();

    private static final Object LOCK = new Object();

    public static AppDatabase getInstance(Context context) {
        if (instance == null) {
            synchronized (LOCK) {
                if (instance == null)
                    instance = Room.databaseBuilder(context, AppDatabase.class, DATABASE_NAME).build();
            }
        }

        return instance;
    }
}

Мы добавили аннотацию @Database(entities = {Task.class}, version = 1), которая означает, что данный класс является компонентом Database. В свойстве entities мы указали, какие сущности содержатся в данной БД, а также указали версию БД.

Кроме того, мы добавили абстрактный метод noteDAO(), который возвращает объект NoteDAO.

AppDatabase.java
// Метод возвращает объект интерфейса
public abstract NoteDAO noteDAO();

Room сгенерирует класс DAO по нашему интерфейсу, после чего сгенерирует класс-наследник AppDatabase и реализует абстрактный метод noteDAO(). Он будет возвращать объект DAO, который мы будем использовать для доступа к БД.

Если мы попытаемся запустить приложение, то обнаружим, что будет возникать исключение при запуске приложения. Это происходит потому, что в сущности Note есть два поля типа Date, тогда как в базе данных SQLite не предусмотрен отдельный тип данных для даты.

Для решения этой проблемы реализуем специальный класс - TypeConverter (преобразователь типов), который будет осуществлять преобразование типа Date в тип Long и обратно (SQLite может хранить данные типа Long). Класс будет содержать методы конвертации, которые будут вызваны Room при добавлении данных в базу данных и при обратном преобразовании записи в таблице в объектный вид.

Создадим класс DateConverter. Будем преобразовывать дату в тип Long и обратно. Каждый метод помечается аннотацией @TypeConverter.

DateConverter.java
public class DateConverter {

    @TypeConverter
    public static Date toDate(Long timestamp) {
        return timestamp == null ? null : new Date(timestamp);
    }

    @TypeConverter
    public static Long toTimestamp(Date date) {
        return date == null ? null : date.getTime();
    }
}

Далее необходимо указать конвертер в классе AppDatabase. Это реализовывается с помощью аннотации @TypeConverters перед объявлением класса.

AppDatabase.java
@Database(entities = {Note.class}, version = 1)
@TypeConverters(DateConverter.class)
public abstract class AppDatabase extends RoomDatabase {

    public static final String DATABASE_NAME = "app_db.db";
    private static volatile AppDatabase instance;

    public abstract NoteDAO noteDAO();

    private static final Object LOCK = new Object();

    public static AppDatabase getInstance(Context context) {
        if (instance == null) {
            synchronized (LOCK) {
                if (instance == null)
                    instance = Room.databaseBuilder(context, AppDatabase.class, DATABASE_NAME).build();
            }
        }

        return instance;
    }
}

Итак, мы настроили три компонента: Database, DAO и Entity. Теперь перейдем в классам Activity и будем реализовывать функционал нашего приложения.

Добавление новой заметки

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

MainActivity.java
public class MainActivity extends AppCompatActivity {

    @BindView(R.id.notes_rv)
    RecyclerView mNotesRV;

    private AppDatabase db;
    private Executor executor = Executors.newSingleThreadExecutor();

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        ButterKnife.bind(this);
        
        // Получаем ссылку на объект Databse
        db = AppDatabase.getInstance(this);
    }

    @Override
    protected void onStart() {
        super.onStart();
        // Получаем данные из Database
        updateData();
    }

    private void updateData() {
        executor.execute(() -> {
            // С помощью метода getAll() получаем все заметки из БД
            List<Note> notes = new ArrayList<>(db.noteDAO().getAll());
            runOnUiThread(() -> mNotesRV.setAdapter(new NotesAdapter(this, this, notes)));
        });

    }
}

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

MainActivity.java
public class MainActivity extends AppCompatActivity {

    ...

    @OnClick(R.id.fab)
    public void fabClick() {
        Intent i = new Intent(this, NoteActivity.class);
        startActivity(i);
    }
    
    ...
}

Далее перейдем в класс NoteActivity. В этом окне мы также должны получить ссылку на объект AppDatabase, как и в классе MainActivity. Логика работы окна следующая - пользователь вводит заголовок и содержимое заметки и нажимает на кнопку "Create", после чего новая заметка добавляется в базу данных и окно закрывается.

NoteActivity.java
public class NoteActivity extends AppCompatActivity {

    @BindView(R.id.title_et)
    EditText mTitle;

    @BindView(R.id.contents_et)
    EditText mText;

    private AppDatabase db;

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

        ButterKnife.bind(this);
        db = AppDatabase.getInstance(this);
    }

    @OnClick(R.id.create)
    public void createClick() {
        String title = mTitle.getText().toString();
        String text = mText.getText().toString();

        Date now = Calendar.getInstance().getTime();
        Note note = new Note(title, text, now, now);

        Executors.newSingleThreadExecutor().execute(
                () -> db.noteDAO().insertNote(note));
                
        finish();
    }
}

После закрытия окна NoteActivity, главное окно MainActivity перейдет в состояние Start, будет вызван callback-метод onStart(), будет вызван метод updateData() и мы получим обновленные данные из БД. Запускаем приложение и убеждаемся, что все работает корректно.

Удаление отдельной заметки

Реализуем функционал удаления отдельной заметки. Как видно из ролика выше, каждая заметка в списке имеет кнопку редактирования и удаления заметки. Редактирование заметки мы реализуем позже, а пока займемся операцией удаления.

С помощью использования концепции "Слушатель", добавим метод для удаления заметки.

MainActivity.java
private void updateData() {
    executor.execute(() -> {
        List<Note> notes = new ArrayList<>(db.noteDAO().getAll());
        // Первый аргумент при создании адаптера - объект NotesAdapterListener
        // В качестве Listener мы указываем объект Activity
        runOnUiThread(() -> mNotesRV.setAdapter(new NotesAdapter(this, notes)));
    });
}
NotesAdapter.java
@AllArgsConstructor
public class NotesAdapter extends RecyclerView.Adapter<NotesAdapter.NoteViewHolder> {

    public interface NotesAdapterListener {
        void onNoteEdit(int note_id);
        void onNoteDelete(int note_id);
    }
    
    ...

    @Override
    public void onBindViewHolder(@NonNull NoteViewHolder holder, int pos) {

        ...

        holder.edit.setOnClickListener(view -> {
            listener.onNoteEdit(note.getId());
        });

        holder.delete.setOnClickListener(view -> {
            listener.onNoteDelete(note.getId());
        });
    }
    
    ...
}
MainActivity.java
public class MainActivity extends AppCompatActivity 
        implements NotesAdapter.NotesAdapterListener {
    
    // Объект Activity реализует интерфейс NotesAdapterListener
    

    @Override
    public void onNoteEdit(int note_id) {
        ...
    }

    @Override
    public void onNoteDelete(int note_id) {
        ...
    }
}

Сначала реализуем метод удаления заметки. В этом методе мы просто обращаемся в отдельном потоке к объекту Database и вызываем метод deleteNoteById(). После этого вызываем метод updateAdapter(), чтобы получить и вывести на экран обновленные данные из БД.

MainActivity.java
public class MainActivity extends AppCompatActivity 
        implements NotesAdapter.NotesAdapterListener {
    
    // Объект Activity реализует интерфейс NotesAdapterListener
    
    private void updateData() {
        executor.execute(() -> {
            List<Note> notes = new ArrayList<>(db.noteDAO().getAll());
            runOnUiThread(() -> mNotesRV.setAdapter(new NotesAdapter(this, notes)));
        });
    }

    @Override
    public void onNoteEdit(int note_id) {
        ...
    }

    @Override
    public void onNoteDelete(int note_id) {
        executor.execute(() -> {
            db.noteDAO().deleteNoteById(note_id);
            updateData();
        });
    }
}

Проверим работу приложения

Редактирование заметки

Для редактирования заметки мы будем использовать тот же NoteActivity, только будем его открывать "в режиме редактирования". Для этого будем передавать через Intent id задачи, которую нужно редактировать.

Режим редактирования будет определяться следующим образом - если в объекте Intent, с помощью которого была запущена NoteActivity, есть данные в виде данных с ключом id, то окно открыто для редактирования существующей задачи. Если никаких данных нет, то окно открыто для создания новой задачи.

Если окно было открыто в режиме редактирования, при старте окна считаем объект задачи из БД и добавим заголовок и текст существующей заметки в поля ввода.

NoteActivity.java
public class NoteActivity extends AppCompatActivity {

    private int mNoteId;
    private boolean mEditMode;

    @BindView(R.id.title_et)
    EditText mTitle;

    @BindView(R.id.contents_et)
    EditText mText;

    @BindView(R.id.toolbar)
    MaterialToolbar mToolbar;

    Note mNote;

    private AppDatabase db;

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

        ButterKnife.bind(this);
        db = AppDatabase.getInstance(this);

        // Считываем id заметки из Intent
        // Определяем наличие режима редактирования
        getIntentData();

        if (mEditMode) {
            Executors.newSingleThreadExecutor().execute(() -> {
                mNote = db.noteDAO().getNoteById(mNoteId);
                if (mNote != null)
                    runOnUiThread(this::populateViews);
            });
        }
    }

    private void populateViews() {
        mTitle.setText(mNote.getTitle());
        mText.setText(mNote.getContents());
    }

    private void getIntentData() {
        mNoteId = getIntent().getIntExtra(MyNotepad.NOTE_ID_ARG, -1);
        mEditMode = mNoteId != -1;
    }
}

При нажатии на кнопку "Create", если мы находится в режиме редактирования, то мы заменяем поля Title, Contents и dateUpdate (обновляем дату модификации заметки на текущую) у полученного из БД объекта, после чего добавляем измененный объект заметки в базу. При попытке добавить новую запись в таблице с уже существующим первичным ключом (нашим id заметки), возникнет конфликт. Так как мы в NoteDAO указали стратегию замены при конфликте добавления

NoteDAO.java
@Dao
public interface NoteDAO {

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    void insertNote(Note note);
}

то новая измененная заметка будет записана поверх старой.

После всех модификаций, код окна NoteActivity будет иметь следующий вид

NoteActivity.java
public class NoteActivity extends AppCompatActivity {

    private int mNoteId;
    private boolean mEditMode;

    @BindView(R.id.title_et)
    EditText mTitle;

    @BindView(R.id.contents_et)
    EditText mText;

    Note mNote;

    private AppDatabase db;

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

        ButterKnife.bind(this);
        db = AppDatabase.getInstance(this);

        getIntentData();

        if (mEditMode) {
            Executors.newSingleThreadExecutor().execute(() -> {
                mNote = db.noteDAO().getNoteById(mNoteId);
                if (mNote != null)
                    runOnUiThread(this::populateViews);
            });
        }
    }

    private void populateViews() {
        mTitle.setText(mNote.getTitle());
        mText.setText(mNote.getContents());
    }

    private void getIntentData() {
        mNoteId = getIntent().getIntExtra(MyNotepad.NOTE_ID_ARG, -1);
        mEditMode = mNoteId != -1;
    }

    @OnClick(R.id.create)
    public void createClick() {
        String title = mTitle.getText().toString();
        String text = mText.getText().toString();

        Note note;
        Date now = Calendar.getInstance().getTime();

        if (mEditMode) {
            note = mNote;
            note.setDateUpdate(now);
            note.setTitle(title);
            note.setContents(text);
        } else {
            note = new Note(title, text, now, now);
        }

        Executors.newSingleThreadExecutor().execute(
                () -> db.noteDAO().insertNote(note));
        finish();
    }
}

Проверим работу приложения

Этап 2. Подключение ViewModel и Repository

На данном этапе мы добавим в архитектуру приложения такие компоненты как ViewModel и Repository.

Создание Repository

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

Создадим класс AppRepository в пакете repository, класс будет реализовывать паттерн Singleton.

AppRepository.java
public class AppRepository {

    private Executor executor = Executors.newSingleThreadExecutor();
    private static AppRepository instance;
    private AppDatabase db;

    public static AppRepository getInstance(Context context) {
        if (instance == null) {
            instance = new AppRepository(context);
        }
        return instance;
    }

    private AppRepository(Context context) {
        db = AppDatabase.getInstance(context);
    }
}

Обратите внимание, что при вызове конструктора AppRepository, мы получаем доступ к объекту AppDatabase. В последующем всё взаимодействие с БД как с источником данных будет происходить через Repository.

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

  • получение всех заметок;

  • удаление заметки по id;

  • добавление новой заметки (перезапись старой заметки);

  • получение заметки по id.

Реализуем этим методы в классе репозитория.

AppRepository.java
public class AppRepository {

    private Executor executor = Executors.newSingleThreadExecutor();
    private static AppRepository instance;
    private AppDatabase db;

    public static AppRepository getInstance(Context context) {
        if (instance == null) {
            instance = new AppRepository(context);
        }
        return instance;
    }

    private AppRepository(Context context) {
        db = AppDatabase.getInstance(context);
    }

    public List<Note> getAllNotes() {
        List<Note> notes = new ArrayList<>();
        ExecutorService es = Executors.newSingleThreadExecutor();
        Future<List<Note>> result = es.submit(() -> db.noteDAO().getAll());

        try {
            notes = result.get();
        } catch (Exception e) {
            e.printStackTrace();
        }
        es.shutdown();

        return notes;
    }

    public void deleteNoteById(int note_id) {
        executor.execute(() -> db.noteDAO().deleteNoteById(note_id));
    }

    public void insertNote(Note note) {
        executor.execute(() -> db.noteDAO().insertNote(note));
    }

    public Note getNoteById(int note_id) {
        ExecutorService es = Executors.newSingleThreadExecutor();
        Future<Note> result = es.submit(() -> db.noteDAO().getNoteById(note_id));

        try {
            return result.get();
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        } finally {
            es.shutdown();
        }
    }
}

Создание ViewModel

Теперь перейдем к реализации ViewModel. Как было сказано выше, компонент ViewModel хранит данные окна или фрагмента и позволяет избежать проблем, связанных с методами жизненного цикла Activity или Fragment.

Как правило, для каждого Activity создается соответствующий ему класс ViewModel. В нашем приложении содержится два Activity, поэтом мы создадим два класса ViewModel.

Для начала создадим и подключим ViewModel для MainActivity. Объявим класс MainViewModel - наследник класса AndroidViewModel.

MainViewModel.java
public class MainViewModel extends AndroidViewModel {

    private AppRepository repository;

    public MainViewModel(@NonNull Application application) {
        super(application);
        repository = AppRepository.getInstance(application);
    }

    public List<Note> getAllNotes() {
        return repository.getAllNotes();
    }

    public void deleteNote(int note_id) {
        repository.deleteNoteById(note_id);
    }
}

Обратите внимание, что при вызове конструктора MainViewModel, мы получаем ссылку на объект Repository, который будем использовать для получения данных. В главном окне нам необходимо выполнить две операции с данными: получить список всех заметок и удалить заметку по id. Два метода были добавлены в класс ViewModel.

Теперь вернемся к MainActivity. Нам необходимо получить объект MainViewModel и вызвать методы в нужное время для выполнения нужных операций. Обратите внимание на довольно непростой способ получения объекта MainViewModel. Для этого мы используем класс ViewModelProvider и стандартную фабрику для создания объектов AndroidViewModel.

MainActivity.java
public class MainActivity extends AppCompatActivity 
        implements NotesAdapter.NotesAdapterListener {

    @BindView(R.id.notes_rv)
    RecyclerView mNotesRV;

    private MainViewModel mViewModel;

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

    private void initViewModel() {
        mViewModel = new ViewModelProvider(this, ViewModelProvider
                .AndroidViewModelFactory
                .getInstance(this.getApplication()))
                .get(MainViewModel.class);
    }

    @Override
    protected void onStart() {
        super.onStart();
        mNotesRV.setAdapter(new NotesAdapter(this, this, mViewModel.getAllNotes()));
    }

    @Override
    public void onNoteDelete(int id) {
        mViewModel.deleteNote(id);
        mNotesRV.setAdapter(new NotesAdapter(this, this, mViewModel.getAllNotes()));
    }
}

Теперь для всех операций с данными мы используем объект AndroidViewModel, в самом Activity данные не хранятся.

Аналогично создадим класс NoteViewModel и модифицируем класс NoteActivity.

NoteViewModel.java
public class NoteViewModel extends AndroidViewModel {

    private AppRepository repository;

    public NoteViewModel(@NonNull Application application) {
        super(application);

        repository = AppRepository.getInstance(application);
    }

    public Note getNoteById(int note_id) {
        return repository.getNoteById(note_id);
    }

    public void insertNote(Note note) {
        repository.insertNote(note);
    }
}
NoteActivity.java
public class NoteActivity extends AppCompatActivity {

    private boolean mEditMode;
    private Note mNote;

    private NoteViewModel mViewModel;

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

        initViewModel();

        if (mEditMode) {
            mNote = mViewModel.getNoteById(mNoteId);
            if (mNote != null)
                runOnUiThread(this::populateViews);
        }
    }

    private void initViewModel() {
        mViewModel = new ViewModelProvider(this, ViewModelProvider.AndroidViewModelFactory.getInstance(this.getApplication())).get(NoteViewModel.class);
    }

    @OnClick(R.id.create)
    public void createClick() {
        String title = mTitle.getText().toString();
        String text = mText.getText().toString();

        Note note;
        Date now = Calendar.getInstance().getTime();

        if (mEditMode) {
            note = mNote;
            note.setDateUpdate(now);
            note.setTitle(title);
            note.setContents(text);
        } else {
            note = new Note(title, text, now, now);
        }

        mViewModel.insertNote(note);
        finish();
    }
}

Этап 3. Отображение динамических данных с помощью LiveData

Для отображения динамических данных нам понадобится еще один компонент из состава Architectural Components - компонент LiveData.

LiveData - хранилище данных, работающее по принципу паттерна Observer (наблюдатель). Это хранилище умеет делать две вещи:

  1. в него можно поместить какой-либо объект;

  2. на него можно подписаться и получать объекты, которые в него помещают.

То есть, с одной стороны кто-то помещает объект в хранилище, а с другой стороны кто-то подписывается и получает этот объект. В качестве аналогии можно привести, например, каналы в Telegram. Автор пишет пост и отправляет его в канал, а все подписчики получают этот пост.

Особенностью LiveData является то, что он умеет определять активен подписчик или нет, и отправлять данные будет только активным подписчикам. Предполагается, что подписчиками LiveData будут Activity и фрагменты.

Особенности поведения LiveData:

  • Если Activity было не активно во время обновления данных в LiveData, то при возврате в активное состояние, его observer получит последнее актуальное значение данных;

  • В момент подписки, observer получит последнее актуальное значение из LiveData;

  • Если Activity будет закрыто, то есть перейдет в состояние Destroyed, то LiveData автоматически отпишет от себя его observer;

  • Если Activity в состоянии Destroyed попробует подписаться, то подписка не будет выполнена;

  • Если Activity уже подписывало свой observer, и попробует сделать это еще раз, то просто ничего не произойдет;

  • Вы всегда можете получить последнее значение LiveData с помощью его метода getValue.

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

Приложение должно корректно реагировать и обновлять список задач после добавления новой заметки, редактирования существующей или удаления заметки. На данный момент, мы реализуем получение новых данных "вручную", с помощью метода updateData(). Теперь мы будем реализовывать обновление данных автоматически, с помощью объекта-наблюдателя.

Для начала перейдем в класс NoteDAO и модифицируем метод getAll(), который возвращает список всех заметок. Теперь метод будет возвращать объект LiveData - обертку над списком заметок.

NoteDAO.java
@Dao
public interface NoteDAO {
    
    ...

    @Query("SELECT * FROM notes ORDER BY dateUpdate DESC")
    LiveData<List<Note>> getAll();
}

Теперь, по цепочке вверх, переходим к объекту Repository. Теперь метод getAllNotes() будет возвращать не список заметок, а объект LiveData.

AppRepository.java
public class AppRepository {

    ...
    
    public LiveData<List<Note>> getAllNotes() {
        return db.noteDAO().getAll();
    }
}

Еще по цепочке вверх, переходим к MainViewModel, там проделываем этот же трюк.

MainViewModel.java
public class MainViewModel extends AndroidViewModel {

    ...

    public LiveData<List<Note>> getData() {
        return repository.getAllNotes();
    }
}

Последний шаг - MainActivity. Здесь мы в методе initViewModel() "подпишемся" на объект LiveData, в теле лямбда-выражения обновим содержимое RecyclerView с новыми данными. Удалим метод updateData() и строки, где он вызывался.

MainActivity.java
public class MainActivity extends AppCompatActivity 
        implements NotesAdapter.NotesAdapterListener {

    ...

    private void initViewModel() {
        mViewModel = new ViewModelProvider(this, ViewModelProvider
                .AndroidViewModelFactory
                .getInstance(this.getApplication()))
                .get(MainViewModel.class);

        mViewModel.getData().observe(this, notes -> {
            mNotesRV.setAdapter(new NotesAdapter(this, this, notes));
        });
    }
}

На этом все модификации закончились. Теперь, когда данные в таблице notes будут обновлены, сработает наблюдатель, мы получим новую версию списка заметок и обновим RecyclerView.

Проверим работу приложения.

Last updated