2. Ход работы
Создадим простое приложение "My Notepad" для создания и редактирования простых заметок. Приложение состоит из двух окон:
MainActivity - главное окно для вывода списка заметок, добавления новой заметки, редактирования и удаления существующей заметки;
NoteActivity - окно для создания новой заметки и редактирования существующей.
Перед тем, как создавать наше приложение с использованием Architectural Components (далее - AC), добавим в проект необходимые библиотеки.
Будем реализовывать архитектуру приложения в несколько этапов. Мы не будем подробно останавливаться на создании UI и выводе информации на экран, вы можете изучить исходный код приложения самостоятельно.
Этап 1. Использование технологии ORM с помощью библиотеки Room
На первом этапе мы воспользуемся библиотекой Room, подключим локальную базу данных, а также будем из Activity напрямую взаимодействовать с объектом типа RoomDatabase.
Создание классов для работы с Room
Библиотека Room предоставляет нам удобную обертку для работы с базой данных SQLite. Room имеет три основных компонента: Entity, Dao и Database. Поэтапно будем создавать требуемые классы для настройки Room.
Для начала создадим класс, который будет моделировать заметку. Класс будет содержать три поля:
id заметки;
заголовок заметки;
текст заметки;
дата создания задачи;
дата обновления заметки.
Для генерации геттеров, сеттеров воспользуемся библиотекой Lombok. Генерировать конструкторы с помощью Lombok не будем, а реализуем их вручную. Нам понадобится три конструктора: конструктор по умолчанию, конструктор со всеми параметрами, конструктор без параметра id (объяснение по поводу количества и параметров конструкторов будет дано ниже).
Данный класс пока не является Entity (сущностью), но пока оставим его в таком виде. Перейдем к созданию Database. Database - основной класс для работы с базой данных. Этот класс должен быть абстрактным и наследовать RoomDatabase.
Данный класс реализует паттерн Singleton (одиночка). Класс-наследник AppDatabase будет сгенерирован с помощью библиотеки Room и статического метода databaseBuilder()
. При реализации паттерна Singleton мы реализуем потокобезопасное создание объекта.
Теперь вернемся к классу, который моделирует задачу. Нам необходимо "превратить" обычный класс в сущность (Entity) для того, чтобы обеспечить работу технологии ORM. Для того чтобы разобраться, что такое ORM и что такое сущность, рассмотрим небольшой теоретический материал.
Краткое описание технологии ORM
При написании объектно-ориентированного кода, который взаимодействует с базой данных, у разработчика возникает несколько проблем:
работа с данными в объектно-ориентированной программе и в базе данных основана на разных парадигмах (объектно-ориентированная и реляционная соответственно). Преобразование данных из одной парадигмы в другую ложится на плечи программиста, что влечет за собой лишнюю работу и может приводить к ошибкам в процессе преобразования;
при написании программы, разработчику желательно абстрагироваться от конкретной схемы хранения данных. То есть, программисту желательно работать не с реляционной базой данных, а просто с некоторым «хранилищем», а конкретная реализация этого «хранилища» может быстро и безболезненно меняться.
Для устранения этих проблем используется технология ORM (Object-Relational Mapping, «объектно-реляционное отображение») — технология программирования, которая связывает базы данных с концепциями объектно-ориентированных языков программирования, создавая «виртуальную объектную базу данных».
Проще говоря, ORM – это прослойка, посредник между базой данных и объектным кодом. Используя ORM, программист не занимается формированием SQL-запросов и не думает в терминах «таблица», «записи» и «реляционные отношения», а просто работает с «хранилищем объектов» – он может туда записывать и получать объекты, не заботясь о подробностях их хранения.
Основное понятие в ORM – сущность (Entity). Сущность – это Java-класс, который представляет бизнес-логику приложения и определяет данные, которые будут храниться в базе данных и извлекаться из нее. Как правило, класс сущности представляет таблицу в базе данных, поля или свойства класса представляют собой колонки в таблице, а объект сущности представляет собой одну запись в таблице.
Создание и конфигурация класса-сущности осуществляется с помощью аннотаций. Добавим их в класс Note
Были добавлены следующие аннотации:
@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 смогла сгенерировать нужный код для взаимодействия с БД,
Подробнее про роль Dao и сценарии использования различных аннотаций читайте здесь.
Вернемся к классу AppDatabase и модифицируем его
Мы добавили аннотацию @Database(entities = {Task.class}, version = 1)
, которая означает, что данный класс является компонентом Database. В свойстве entities
мы указали, какие сущности содержатся в данной БД, а также указали версию БД.
Кроме того, мы добавили абстрактный метод noteDAO()
, который возвращает объект NoteDAO.
Room сгенерирует класс DAO по нашему интерфейсу, после чего сгенерирует класс-наследник AppDatabase и реализует абстрактный метод noteDAO()
. Он будет возвращать объект DAO, который мы будем использовать для доступа к БД.
Если мы попытаемся запустить приложение, то обнаружим, что будет возникать исключение при запуске приложения. Это происходит потому, что в сущности Note есть два поля типа Date, тогда как в базе данных SQLite не предусмотрен отдельный тип данных для даты.
Для решения этой проблемы реализуем специальный класс - TypeConverter (преобразователь типов), который будет осуществлять преобразование типа Date в тип Long и обратно (SQLite может хранить данные типа Long). Класс будет содержать методы конвертации, которые будут вызваны Room при добавлении данных в базу данных и при обратном преобразовании записи в таблице в объектный вид.
Создадим класс DateConverter. Будем преобразовывать дату в тип Long и обратно. Каждый метод помечается аннотацией @TypeConverter
.
Далее необходимо указать конвертер в классе AppDatabase. Это реализовывается с помощью аннотации @TypeConverters
перед объявлением класса.
Итак, мы настроили три компонента: Database, DAO и Entity. Теперь перейдем в классам Activity и будем реализовывать функционал нашего приложения.
Добавление новой заметки
Перейдем в класс MainActivity и модифицируем его. Прежде всего, нам необходимо получить ссылку на объект AppDatabase. При старте окна мы должны получить список заметок из базы и вывести их в списке. Для этого создадим метод updateData()
, который будем вызывать каждый раз, когда нам необходимо получить обновленный список заметок из базы. Обратите внимание, что операции с базой данных мы выполняем в отдельном потоке.
По нажатию на FAB должно открыться окно NoteActivity, в котором нам надо будет указать заголовок и текст заметки, после чего сохранить новую заметку в базе.
Далее перейдем в класс NoteActivity. В этом окне мы также должны получить ссылку на объект AppDatabase, как и в классе MainActivity. Логика работы окна следующая - пользователь вводит заголовок и содержимое заметки и нажимает на кнопку "Create", после чего новая заметка добавляется в базу данных и окно закрывается.
После закрытия окна NoteActivity, главное окно MainActivity перейдет в состояние Start, будет вызван callback-метод onStart()
, будет вызван метод updateData()
и мы получим обновленные данные из БД. Запускаем приложение и убеждаемся, что все работает корректно.
Удаление отдельной заметки
Реализуем функционал удаления отдельной заметки. Как видно из ролика выше, каждая заметка в списке имеет кнопку редактирования и удаления заметки. Редактирование заметки мы реализуем позже, а пока займемся операцией удаления.
С помощью использования концепции "Слушатель", добавим метод для удаления заметки.
Сначала реализуем метод удаления заметки. В этом методе мы просто обращаемся в отдельном потоке к объекту Database и вызываем метод deleteNoteById()
. После этого вызываем метод updateAdapter(), чтобы получить и вывести на экран обновленные данные из БД.
Проверим работу приложения
Редактирование заметки
Для редактирования заметки мы будем использовать тот же NoteActivity, только будем его открывать "в режиме редактирования". Для этого будем передавать через Intent id
задачи, которую нужно редактировать.
Режим редактирования будет определяться следующим образом - если в объекте Intent, с помощью которого была запущена NoteActivity, есть данные в виде данных с ключом id, то окно открыто для редактирования существующей задачи. Если никаких данных нет, то окно открыто для создания новой задачи.
Если окно было открыто в режиме редактирования, при старте окна считаем объект задачи из БД и добавим заголовок и текст существующей заметки в поля ввода.
При нажатии на кнопку "Create", если мы находится в режиме редактирования, то мы заменяем поля Title, Contents и dateUpdate (обновляем дату модификации заметки на текущую) у полученного из БД объекта, после чего добавляем измененный объект заметки в базу. При попытке добавить новую запись в таблице с уже существующим первичным ключом (нашим id заметки), возникнет конфликт. Так как мы в NoteDAO указали стратегию замены при конфликте добавления
то новая измененная заметка будет записана поверх старой.
После всех модификаций, код окна NoteActivity будет иметь следующий вид
Проверим работу приложения
Этап 2. Подключение ViewModel и Repository
На данном этапе мы добавим в архитектуру приложения такие компоненты как ViewModel и Repository.
Создание Repository
Компонент Repository инкапсулирует логику управления различными источниками данных, с которыми может работать приложение. Пока что наше приложение работает с одним источником данных - с локальной базой данных, в следующих лекциях мы добавим еще один источник данных.
Создадим класс AppRepository в пакете repository, класс будет реализовывать паттерн Singleton.
Обратите внимание, что при вызове конструктора AppRepository, мы получаем доступ к объекту AppDatabase. В последующем всё взаимодействие с БД как с источником данных будет происходить через Repository.
Теперь добавим методы для взаимодействия с базой данных. Напомним, что нам необходимо выполнить следующие операции:
получение всех заметок;
удаление заметки по id;
добавление новой заметки (перезапись старой заметки);
получение заметки по id.
Реализуем этим методы в классе репозитория.
Создание ViewModel
Теперь перейдем к реализации ViewModel. Как было сказано выше, компонент ViewModel хранит данные окна или фрагмента и позволяет избежать проблем, связанных с методами жизненного цикла Activity или Fragment.
Как правило, для каждого Activity создается соответствующий ему класс ViewModel. В нашем приложении содержится два Activity, поэтом мы создадим два класса ViewModel.
Для начала создадим и подключим ViewModel для MainActivity. Объявим класс MainViewModel - наследник класса AndroidViewModel.
Обратите внимание, что при вызове конструктора MainViewModel, мы получаем ссылку на объект Repository, который будем использовать для получения данных. В главном окне нам необходимо выполнить две операции с данными: получить список всех заметок и удалить заметку по id. Два метода были добавлены в класс ViewModel.
Теперь вернемся к MainActivity. Нам необходимо получить объект MainViewModel и вызвать методы в нужное время для выполнения нужных операций. Обратите внимание на довольно непростой способ получения объекта MainViewModel. Для этого мы используем класс ViewModelProvider и стандартную фабрику для создания объектов AndroidViewModel.
Теперь для всех операций с данными мы используем объект AndroidViewModel, в самом Activity данные не хранятся.
Аналогично создадим класс NoteViewModel и модифицируем класс NoteActivity.
Этап 3. Отображение динамических данных с помощью LiveDat
Для отображения динамических данных нам понадобится еще один компонент из состава Architectural Components - компонент LiveData.
LiveData - хранилище данных, работающее по принципу паттерна Observer (наблюдатель). Это хранилище умеет делать две вещи:
в него можно поместить какой-либо объект;
на него можно подписаться и получать объекты, которые в него помещают.
То есть, с одной стороны кто-то помещает объект в хранилище, а с другой стороны кто-то подписывается и получает этот объект. В качестве аналогии можно привести, например, каналы в Telegram. Автор пишет пост и отправляет его в канал, а все подписчики получают этот пост.
Особенностью LiveData является то, что он умеет определять активен подписчик или нет, и отправлять данные будет только активным подписчикам. Предполагается, что подписчиками LiveData будут Activity и фрагменты.
Особенности поведения LiveData:
Если Activity было не активно во время обновления данных в LiveData, то при возврате в активное состояние, его observer получит последнее актуальное значение данных;
В момент подписки, observer получит последнее актуальное значение из LiveData;
Если Activity будет закрыто, то есть перейдет в состояние Destroyed, то LiveData автоматически отпишет от себя его observer;
Если Activity в состоянии Destroyed попробует подписаться, то подписка не будет выполнена;
Если Activity уже подписывало свой observer, и попробует сделать это еще раз, то просто ничего не произойдет;
Вы всегда можете получить последнее значение LiveData с помощью его метода getValue.
В нашем приложении примером таких "динамических данных", изменение которых приводит к обновлению пользовательского интерфейса является список с заметками.
Приложение должно корректно реагировать и обновлять список задач после добавления новой заметки, редактирования существующей или удаления заметки. На данный момент, мы реализуем получение новых данных "вручную", с помощью метода updateData(). Теперь мы будем реализовывать обновление данных автоматически, с помощью объекта-наблюдателя.
Для начала перейдем в класс NoteDAO и модифицируем метод getAll(), который возвращает список всех заметок. Теперь метод будет возвращать объект LiveData - обертку над списком заметок.
Теперь, по цепочке вверх, переходим к объекту Repository. Теперь метод getAllNotes() будет возвращать не список заметок, а объект LiveData.
Еще по цепочке вверх, переходим к MainViewModel, там проделываем этот же трюк.
Последний шаг - MainActivity. Здесь мы в методе initViewModel() "подпишемся" на объект LiveData, в теле лямбда-выражения обновим содержимое RecyclerView с новыми данными. Удалим метод updateData() и строки, где он вызывался.
На этом все модификации закончились. Теперь, когда данные в таблице notes будут обновлены, сработает наблюдатель, мы получим новую версию списка заметок и обновим RecyclerView.
Проверим работу приложения.
Last updated