1. Ход работы

На предыдущей лабораторной работе мы изучали библиотеку Room и компоненты, которые в нее входят (Database, DAO и Entity).

В данной лабораторной работе мы подключим такие компоненты как ViewModel, Repository и разберемся, как нам помогает LiveData для вывода обновленных данных.

1. Создание компоненты ViewModel

Создаем пакет viewmodel и создаем класс MainViewModel. Будем создавать класс ViewModel для каждого активити или фрагмента. Наши классы ViewModel будут наследоваться от класса androidx.lifecycle.AndroidViewModel.

MainViewModel.java
public class MainViewModel extends AndroidViewModel {

    public MainViewModel(@NonNull Application application) {
        super(application);
    }
}

Далее перейдем в MainActivity, создадим поле mViewModel, создадим метод, в котором получим ссылку на объект MainViewModel.

MainActivity.java
public class MainActivity extends AppCompatActivity {

    private MainViewModel mViewModel;

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

        initViewModel();
        
        ...
    }

    ...

    private void initViewModel() {
        mViewModel = ViewModelProviders.of(this).get(MainViewModel.class);
    }
    
    ...
}

Обратите внимание, что мы не создаем объект ViewModel напрямую, а используем метод класса ViewModelProviders, который выполняет работу за нас.

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

Пока что создадим в классе ViewModel публичное поле со списком задач.

MainViewModel.java
public class MainViewModel extends AndroidViewModel {

    public List<Task> taskList = new ArrayList<>();

    public MainViewModel(@NonNull Application application) {
        super(application);
    }
}

Перейдем в MainActivity и изменим метод updateAdapter() чтобы он теперь получал список задач из базы, а брал его из ViewModel.

MainActivity.java
private void updateAdapter() {
    rv.setAdapter(new TaskAdapter(mViewModel.taskList, this));
}

Теперь перейдем к созданию класса репозитория.

2. Создание компоненты Repository

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

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

AppRepository.java
public class AppRepository {
    private static final AppRepository ourInstance = new AppRepository();

    public List<Task> taskList;

    public static AppRepository getInstance() {
        return ourInstance;
    }

    private AppRepository() {
        taskList = new ArrayList<>();
    }
}

Теперь изменим класс MainViewModel, чтобы он брал данные из репозитория.

MainViewModel.java
public class MainViewModel extends AndroidViewModel {

    public List<Task> taskList;
    private AppRepository repository;

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

        repository = AppRepository.getInstance();
        taskList = repository.taskList;
    }
}

3. Добавление связи с локальной базой данных

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

Добавим соответствующий пункт в меню

menu_main.xml
<menu xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    tools:context="ua.opu.pnit.todolist.MainActivity">
    
    <item
        android:id="@+id/action_sample_data"
        android:orderInCategory="80"
        android:title="@string/action_sample_data"
        app:showAsAction="never" />

    <item
        android:id="@+id/action_delete_all"
        android:orderInCategory="100"
        android:title="@string/action_delete_all"
        app:showAsAction="never" />
</menu>

Перейдем в MainActivity и добавим обработчик выбора пункта меню.

MainActivity.java
public class MainActivity extends AppCompatActivity {

    ...

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        int id = item.getItemId();
        if (id == R.id.action_delete_all) {
        
            ...
            
        } else if (id == R.id.action_sample_data) {
            addSampleData();
            return true;
        }
        return super.onOptionsItemSelected(item);
    }

    private void addSampleData() {}
}

Метод addSampleData() будет вызывать соответствующий метод из ViewModel, который будет вызывать метод класса репозитория. Получится цепочка вызовов MainActivity --> MainViewModel --> AppRepository

public class MainActivity extends AppCompatActivity {

    private MainViewModel mViewModel;

    ...

    private void addSampleData() {
        mViewModel.addSampleData();
    }
}

public class MainViewModel extends AndroidViewModel {

    private AppRepository repository;
    
    ...

    public void addSampleData() {
        repository.addSampleData();
    }
}

public class AppRepository {

    ...

    public void addSampleData() {}
    
}

Теперь давайте займемся методом addSampleData() в классе AppRepository.

Для начала мы должны получить создать и получить ссылку на объект AppDatabase, который мы создали в предыдущей лабораторной работе, он отвечает за взаимодействие с локальной базой данных. Для того чтобы создать объект AppDatabase, нам нужен объект контекста. Для этого мы немного изменим класс AppRepository, теперь для создания объекта AppRepository необходимо будет передать объект контекста.

AppRepository.java
public class AppRepository {

    private static AppRepository ourInstance;

    public List<Task> taskList;

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

    private AppRepository(Context context) {
        taskList = new ArrayList<>();
    }

    public void addSampleData() {
    }
}

Теперь немного изменим содержимое класса MainViewModel, теперь при создании объекта AppRepository, мы будем передавать ему объект контекста.

MainViewModel.java
public class MainViewModel extends AndroidViewModel {

    private AppRepository repository;
    
    ...

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

        repository = AppRepository.getInstance(application.getApplicationContext());
        taskList = repository.taskList;
    }
    
    ...
    
}

Теперь вернемся в класс AppRepository, добавим поле для AppDatabase и получим объект этого класса в конструкторе.

AppRepository.java
public class AppRepository {

    private AppDatabase db;

    ...

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

    private AppRepository(Context context) {
        taskList = new ArrayList<>();
        db = AppDatabase.getInstance(context);
    }

    ...

}

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

В языке Java и в Android есть множество способов для запуска и работы в фоновом потоке, воспользуемся механизмом ExecutorService. Кроме удобства использования механизма Executor'ов, мы будем использовать один и тот же Executor с одним потоком для всех операций с базой данных. Это гарантирует нам то, что действия будут выполняться поочередно.

Модифицируем класс AppRepository

AppRepository.java
public class AppRepository {

    ...

    private Executor executor = Executors.newSingleThreadExecutor();

    public void addSampleData() {
        executor.execute(() -> {

            List <Task> list = new ArrayList<>();
            list.add(new Task("1111", new Date()));
            list.add(new Task("2222", new Date()));
            list.add(new Task("3333", new Date()));
            list.add(new Task("4444", new Date()));

            db.taskDAO().insertAll(list);
        });
    }
}

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

4. Отображение динамических данных

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

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

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

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

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

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

Перейдем в класс MainViewModel и изменим тип поля taskList из List<Task> на LiveData<List<Task>>. Это изменение приведет к тому, что мы должны исправить соответствующий тип в классе AppRepository. Также в классе AppRepository создадим класс getAllTasks(), который будет получать список всех задач из базы данных.

public class MainViewModel extends AndroidViewModel {

    public LiveData<List<Task>> taskList;
    
    ...
}

public class AppRepository {

    ...

    public LiveData<List<Task>> taskList;
    
    ...

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

    private LiveData<List<Task>> getAllTasks() {
        return null;
    }
    
    ...
}

Теперь реализуем метод getAllTasks(). В методе будет происходить обращение к DAO, в котором мы ранее создали метод getAll(). Так как нам теперь нужен объект LiveData, то отредактируем интерфейс TaskDAO.

TaskDAO.java
@Dao
public interface TaskDAO {

    ...

    // Изменим возвращаемый тип с List<Task> на LiveData<List<Task>>
    @Query("SELECT * FROM tasks ORDER BY date DESC")
    LiveData<List<Task>> getAll();

    ...
    
}

Теперь напишем тело метода getAllTasks()

AppRepository.java
public class AppRepository {

    ...

    public LiveData<List<Task>> taskList;

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

    private LiveData<List<Task>> getAllTasks() {
        return db.taskDAO().getAll();
    }
    
    ...
}

Обратите внимание, что мы не использовали Executor для получения данных из базы данных. Если метод DAO возвращает объект LiveData, то Room берет на себя всю работу по созданию фонового потока.

Не забудем немного изменить конструктор класса AppRepository - сначала мы должны получить объект AppDatabase, а уже потом выполнить методgetAllTasks().

AppRepository.java
public class AppRepository {

    ...

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

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

Для начала уберем из класса метода updateAdapter(), вызовы этого метода, а также уберем код для получения объекта AppDatabase и весь код, который обращался к этому объекту.

После этого отредактируем метод initViewModel(). Создадим объект анонимного класса, реализующего интерфейс Observer (обратите внимание, что это интерфейс из пакета androidx.lifecycle.Observer).

MainActivity.java
public class MainActivity extends AppCompatActivity {
    
    private RecyclerView rv;
    private TaskAdapter adapter;
    private List<Task> mTaskList = new ArrayList<>();

    private MainViewModel mViewModel;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        Toolbar toolbar = findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);

        rv = findViewById(R.id.recycler);
        rv.setHasFixedSize(true);
        LinearLayoutManager manager = new LinearLayoutManager(this, RecyclerView.VERTICAL, false);
        rv.setLayoutManager(manager);

        initViewModel();

    }

    private void initViewModel() {

        final Observer<List<Task>> tasksObserver = new Observer<List<Task>>() {
            @Override
            public void onChanged(List<Task> tasks) {
                mTaskList.clear();
                mTaskList.addAll(tasks);

                if (adapter == null) {
                    adapter = new TaskAdapter(mTaskList, MainActivity.this);
                    rv.setAdapter(adapter);
                } else {
                    adapter.notifyDataSetChanged();
                }
            }
        };

        mViewModel = ViewModelProviders.of(this).get(MainViewModel.class);
        mViewModel.taskList.observe(this, tasksObserver);
    }
    
    ...
    
}

Логика работы следующая - при старте приложения мы запрашиваем из базы список всех задач. При получении списка, содержимое LiveData обновляется и вызывается метод onChanged() (причем LiveData вызовет этот метод только в том случае, если слушатель события (активити или фрагмент) будут находиться в нужном состоянии).

В методе onChanged() мы реализуем следующую логику: если объект адаптера еще не создан (это бывает при создании Activity), то создается объект адаптера и добавляется в RecyclerView. Если объект адаптера существует, то просто обновляем содержимое RecyclerView с помощью метода notifyDataSetChanged().

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

5. Удаление всех задач

Теперь реализуем простую операцию удаления всех задач. Модифицируем метод обработки нажатия на пункт меню. Добавим локальный метод deleteAllTasks(), который будет вызывать соответствующий метод ViewModel, а тот будет вызывать метод репозитория

MainActivity.java
@Override
public boolean onOptionsItemSelected(MenuItem item) {
    int id = item.getItemId();
    if (id == R.id.action_delete_all) {
        deleteAllTasks();
        return true;
    } else if (id == R.id.action_sample_data) {
        addSampleData();
        return true;
    }
    return super.onOptionsItemSelected(item);
}

private void deleteAllTasks() {
    mViewModel.deleteAllTasks();
}
MainViewModel.java
public void deleteAllTasks() {
    repository.deleteAllTasks();
}

Метод репозитория вызывает метод TaskDAO.deleteAll() в отдельном потоке с помощью Executor'а.

AppRepository.java
public void deleteAllTasks() {
    executor.execute(() -> {
        db.taskDAO().deleteAll();
    });
}

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

6. Удаление одной задачи

Теперь реализуем функционал удаления отдельной задачи. Как мы помним, обработчик кнопки находится в адаптере, который вызывает метод deleteTask() в MainActivity.

TaskAdapter

@Override
public void onBindViewHolder(@NonNull TaskViewHolder holder, int position) {
    holder.id.setText(String.valueOf(taskList.get(position).getId()));
    holder.text.setText(taskList.get(position).getText());

    holder.edit.setOnClickListener(v -> {
        Intent intent = new Intent(holder.edit.getContext(), TaskActivity.class);
        intent.putExtra("id", taskList.get(position).getId());
        AppCompatActivity activity = (AppCompatActivity) holder.edit.getContext();
        activity.startActivityForResult(intent, 100);
    });

    holder.delete.setOnClickListener(v -> {
        MainActivity activity = (MainActivity) holder.edit.getContext();
        activity.deleteTask(taskList.get(position));
    });
}

Модифицируем метод deleteTask(), который вызывает метод ViewModel, который вызывает метод репозитория.

MainActivity.java
public void deleteTask(Task task) {
    mViewModel.deleteTask(task);
}
MainViewModel.java
public void deleteTask(Task task) {
    repository.deleteTask(task);
}

Метод deleteTask() вызывает метод taskDAO.deleteTask() в отдельном потоке.

public void deleteTask(Task task) {
    executor.execute(() -> {
        db.taskDAO().deleteTask(task);
    });
}

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

7. Работа с TaskActivity

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

Объявляем класс TaskViewModel. Для поля задачи используем тип MutableLiveData, который подразумевает изменение содержимого LiveData.

TaskViewModel.java
public class TaskViewModel extends AndroidViewModel {

    public MutableLiveData<Task> task = new MutableLiveData<>();

    private AppRepository repository;

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

        repository = AppRepository.getInstance(application.getApplicationContext());
    }
}

Регистрируем объект TaskViewModel в TaskActivity. Добавляем и регистрируем Observer, в котором будет меняться содержимое поля ввода.

TaskActivity.java
public class TaskActivity extends AppCompatActivity {

    ...

    private TaskViewModel viewModel;
    private EditText mTextView;

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

        initViewModel();
        initActivity();

        FloatingActionButton fab = (FloatingActionButton) findViewById(R.id.fab);
        fab.setOnClickListener(view -> {

            // Сохранение задачи в базе данных

            finish();
        });
    }

    private void initViewModel() {
        viewModel = ViewModelProviders.of(this).get(TaskViewModel.class);

        viewModel.task.observe(this, task -> {
            mTextView.setText(task.getText());
        });
    }
    
    ...
}

Так как нам теперь не нужно перехватывать событие удаления задачи, то меняем метод startActivityForResult() на startActivity().

TaskAdapter.java
@Override
public void onBindViewHolder(@NonNull TaskViewHolder holder, int position) {
    holder.id.setText(String.valueOf(taskList.get(position).getId()));
    holder.text.setText(taskList.get(position).getText());

    holder.edit.setOnClickListener(v -> {
        Intent intent = new Intent(holder.edit.getContext(), TaskActivity.class);
        intent.putExtra("id", taskList.get(position).getId());
        AppCompatActivity activity = (AppCompatActivity) holder.edit.getContext();
        activity.startActivity(intent);
    });

    holder.delete.setOnClickListener(v -> {
        MainActivity activity = (MainActivity) holder.edit.getContext();
        activity.deleteTask(taskList.get(position));
    });
}

Модифицируем TaskActivity, добавляем метод loadData().

TaskActivity.java
private void initActivity() {

    Bundle bundle = getIntent().getExtras();
    if (bundle == null) {
        setTitle("New task");
        isNewNote = true;

    } else {
        setTitle("Edit task");
        id = bundle.getLong("id");
        isNewNote = false;

        // Получение существующей задачи
        viewModel.loadData(id);

    }
}

Редактируем метод loadData() в классе TaskViewModel. Добавляем Executor и получаем данные из репозитория.

TaskViewModel.java
public class TaskViewModel extends AndroidViewModel {

    public MutableLiveData<Task> task = new MutableLiveData<>();

    private AppRepository repository;
    private Executor executor = Executors.newSingleThreadExecutor();

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

        repository = AppRepository.getInstance(application.getApplicationContext());
    }

    public void loadData(long id) {
        executor.execute(() -> {
            Task t = repository.getTaskById(id);
            task.postValue(t); // postValue вызывает срабатывание метода onChanged()
        });
    }
}

8. Сохранение существующей заметки

Модифицируем класс TaskActivity, добавляем в обработчик fab получение текста задачи и вызываем метод saveTask() объекта типа TaskViewModel.

TaskActivity.java
FloatingActionButton fab = (FloatingActionButton) findViewById(R.id.fab);
fab.setOnClickListener(view -> {

    // Сохранение задачи в базе данных
    viewModel.saveTask(mTextView.getText().toString());
    finish();
});

Редактируем метод saveTask() класса TaskViewModel. В методе проверяем, добавляем ли мы новую задачу или редактируем существующую. Пока не будем прописывать логику в случае создания новой задачи, а в случае редактирования существующей задачи, вставляем новый текст задачи и через репозиторий добавляем задачу в базу данных.

TaskViewModel.java
public void saveTask(String taskText) {
    Task t = task.getValue();

    if (t == null) {
        
    } else {
        t.setText(taskText);
    }

    repository.insertTask(t);
}

Теперь необходимо отредактировать метод добавления задачи в базу данных в классе репозитория. В отдельном потоке вызываем метод добавления новой задачи.

AppRepository.java
public void insertTask(Task t) {
    executor.execute(() -> {
        db.taskDAO().insertTask(t);
    });
}

9. Добавление новой задачи

Добавляем логику создания новой задачи в метод saveTask() класса TaskViewModel. Создаем новый объект задачи, добавляем текст из поля ввода и текущую дату.

TaskViewModel.java
public void saveTask(String taskText) {
    Task t = task.getValue();

    if (t == null) {
        if (TextUtils.isEmpty(taskText.trim()))
            return;

        t = new Task(taskText.trim(), new Date());
    } else {
        t.setText(taskText);
    }

    repository.insertTask(t);
}

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

10. Смена ориентации экрана при редактировании заметки

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

Это связано с тем, что при изменении ориентации экрана, Activity будет пересоздано и объект LiveData заново передаст объект Task в Activity, следовательно в поле ввода будут записаны старые значения.

В класс поля ввода EditText встроен механизм, который позволяет сохранить введенное значение при пересоздании Activity, но объект LiveData перезаписывает значение поля ввода и меняет его на старое. Чтобы это не происходило, мы должны распознать ситуацию пересоздания Activity и не менять значение EditText, если Activity было пересоздано. Для этого воспользуемся методом Activity onSavedInstanceState().

Создадим булево поле isEditing. В случае изменения ориентации экрана, перед уничтожением Activity будет вызыван метод onSaveInstanceState() и нам будет передан объект Bundle, куда мы записываем булеву переменную со значением true.

При старте Activity мы проверяем поле savedInstanceState. Если оно не равно null, то Activity было пересоздано, устанавливаем isEditing равным true. В Observer проверяем, если isEditing равно true, то мы не устанавливаем значение поля ввода.

TaskActivity.java
public class TaskActivity extends AppCompatActivity {

    ...
    
    private boolean isEditing;

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

        // Если savedInstanceState !=null, то Activity было пересоздано
        if (savedInstanceState != null) {
            isEditing = savedInstanceState.getBoolean("editing");
        }
        
        ...
    }

    private void initViewModel() {
        viewModel = ViewModelProviders.of(this).get(TaskViewModel.class);

        viewModel.task.observe(this, task -> {
            // Проверяем, было ли Activity пересоздано
            if (task != null && !isEditing)
                mTextView.setText(task.getText());
        });
    }

    @Override
    protected void onSaveInstanceState(Bundle outState) {
        // Сохраняем булеву переменную
        outState.putBoolean("editing", true);
        super.onSaveInstanceState(outState);
    }
    
    ...
}

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

Last updated