Потоки, фрагменти та ViewModel

Під час використання вторинних потоків слід враховувати такий момент. Оптимальнішим способом є робота потоків із фрагментом, ніж безпосередньо з activity. Наприклад, визначимо у файлі activity_main.xml такий інтерфейс:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
 
    <Button
        android:id="@+id/progressBtn"
        android:text="Запуск"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toTopOf="@id/statusView"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintTop_toTopOf="parent"/>
 
    <TextView
        android:id="@+id/statusView"
        android:text="Статус"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toTopOf="@id/indicator"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintTop_toBottomOf="@id/progressBtn" />
    <ProgressBar
        android:id="@+id/indicator"
        style="@android:style/Widget.ProgressBar.Horizontal"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:max="100"
        android:progress="0"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toBottomOf="@id/statusView"/>
 
</androidx.constraintlayout.widget.ConstraintLayout>

Тут визначено кнопку для запуску вторинної задачі та елементи TextView і ProgressBar, які відображають індикацію виконання задачі.

У класі MainActivity визначимо такий код:

package com.example.threadapp;
 
import androidx.appcompat.app.AppCompatActivity;
 
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.ProgressBar;
import android.widget.TextView;
 
public class MainActivity extends AppCompatActivity {
 
    int currentValue = 0;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
 
        ProgressBar indicatorBar = findViewById(R.id.indicator);
        TextView statusView = findViewById(R.id.statusView);
        Button btnFetch = findViewById(R.id.progressBtn);
        btnFetch.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
 
                Runnable runnable = new Runnable() {
                    @Override
                    public void run() {
 
                        for(; currentValue <= 100; currentValue++){
                            try {
                                statusView.post(new Runnable() {
                                    public void run() {
                                        indicatorBar.setProgress(currentValue);
                                        statusView.setText("Статус: " + currentValue);
                                    }
                                });
 
                                Thread.sleep(400);
                            } catch (InterruptedException e) {
                                e.printStackTrace();
                            }
                        }
                    }
                };
                Thread thread = new Thread(runnable);
                thread.start();
            }
        });
    }
}

Тут після натискання кнопки ми запускаємо задачу Runnable, у якій у циклі від 0 до 100 змінюємо показники ProgressBar і TextView, імітуючи деяку довгу роботу.

Однак якщо в процесі роботи завдання ми змінимо орієнтацію мобільного пристрою, то відбудеться перестворення activity, і застосунок перестане працювати належним чином.

У цьому разі проблема впирається в стан, яким оперує потік, а саме - змінну currentValue, до значення якої прив'язані віджети в Activity.

Додавання ViewModel

Для подібних випадків як вирішення проблеми пропонується використовувати ViewModel. Отже, додамо в ту саму папку, де міститься файл MainActivity.java, новий клас MyViewModel із таким кодом:

package com.example.threadapp;
 
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.ViewModel;
 
public class MyViewModel extends ViewModel {
 
    private MutableLiveData<Boolean> isStarted = new MutableLiveData<Boolean>(false);
    private MutableLiveData<Integer> value;
    public LiveData<Integer> getValue() {
        if (value == null) {
            value = new MutableLiveData<Integer>(0);
        }
        return value;
    }
    public void execute(){
 
        if(!isStarted.getValue()){
            isStarted.postValue(true);
            Runnable runnable = new Runnable() {
                @Override
                public void run() {
 
                    for(int i = value.getValue();  i <= 100; i++){
                        try {
                            value.postValue(i);
                            Thread.sleep(400);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                }
            };
            Thread thread = new Thread(runnable);
            thread.start();
        }
    }
}

Отже, тут визначений клас MyViewModel, який успадковується від класу ViewModel, спеціально призначеного для зберігання та керування станом або моделлю.

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

Для зберігання числового значення призначена змінна value:

private MutableLiveData<Integer> value;

Для прив'язки до цього значення вона має тип MutableLiveData. А оскільки ми будемо зберігати в цій змінній числове значення, то тип змінної типізовано типом Integer.

Для доступу ззовні класу до цього значення визначено метод getValue, який має тип LiveData і який при першому зверненні до змінної встановлює 0, або просто повертає значення змінної:

public LiveData<Integer> getValue() {
    if (value == null) {
        value = new MutableLiveData<Integer>(0);
    }
    return value;
}

Для індикації, запущений чи потік, визначена змінна isStarted, яка зберігає значення типу Boolean, тобто фактично true або false. За замовчуванням вона має значення false (тобто потік не запущено).

Для зміни числового значення, до якого будуть прив'язані віджети, визначено метод execute(). Він запускає потік, якщо потік не запущено:

if(!isStarted.getValue()){

Далі перемикає значення змінної isStarted на true, оскільки ми запускаємо потік.

В самому потоці також запускається цикл:

for(int i = value.getValue();  i <= 100; i++){

І в цьому випадку ми користуємось перевагами класу ViewModel, який дозволяє автоматично зберігати своє стан.

Причому лічильник циклу як початкове значення бере значення з змінної value і збільшується на одиницю, поки не досягне ста.

В самому циклі змінюється значення змінної value за допомогою передачі значення в метод postValue():

value.postValue(i);

Таким чином, в циклі здійснюється прохід від 0 до 100, і при кожній ітерації циклу змінюється значення змінної value.

Тепер задіємо наш клас MyViewModel і для цього змінимо код класу MainActivity:

package com.example.threadapp;
 
import androidx.appcompat.app.AppCompatActivity;
import androidx.lifecycle.ViewModelProvider;
 
import android.os.Bundle;
import android.widget.Button;
import android.widget.ProgressBar;
import android.widget.TextView;
 
public class MainActivity extends AppCompatActivity {
 
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
 
        ProgressBar indicatorBar = findViewById(R.id.indicator);
        TextView statusView = findViewById(R.id.statusView);
        Button btnFetch = findViewById(R.id.progressBtn);
        MyViewModel model = new ViewModelProvider(this).get(MyViewModel.class);
 
        model.getValue().observe(this, value -> {
            indicatorBar.setProgress(value);
            statusView.setText("Статус: " + value);
        });
        btnFetch.setOnClickListener(v -> model.execute());
    }
}

Щоб залучити MyViewModel, створюємо об'єкт класу ViewModelProvider, у конструктор якого передається об'єкт-володар ViewModel. У цьому випадку це поточний об'єкт MainActivity:

new ViewModelProvider(this)

І далі за допомогою методу get() створюємо об'єкт класу ViewModel, який буде використовуватися в об'єкті MainActivity.

MyViewModel model = new ViewModelProvider(this).get(MyViewModel.class);

Отримавши об'єкт MyViewModel, визначаємо прослуховування змін його змінної value за допомогою методу observe:

model.getValue().observe(this, value -> {
    indicatorBar.setProgress(value);
    statusView.setText("Статус: " + value);
});

Метод observe() як перший параметр приймає володаря функції спостерігача - у цьому випадку поточний об'єкт MainActivity. А як другий параметр - функцію спостерігача (а точніше об'єкт інтерфейсу Observer). Функція спостерігача приймає один параметр - нове значення відстежуваної змінної (тобто в цьому випадку змінної value). Отримавши нове значення змінної value, ми змінюємо параметри віджетів.

Таким чином, при кожній зміні значення в змінній value віджети отримають її нове значення.

Тепер, якщо ми запустимо додаток, то незалежно від зміни орієнтації мобільного пристрою фонове завдання продовжить свою роботу:

Використання фрагментів

Аналогічно ми можемо використовувати фрагменти. Отже, додамо в проєкт новий фрагмент, який назвемо ProgressFragment.

Визначимо для нього новий файл розмітки інтерфейсу fragment_progress.xml:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
 
    <Button
        android:id="@+id/progressBtn"
        android:text="Запуск"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toTopOf="@id/statusView"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintTop_toTopOf="parent"/>
 
    <TextView
        android:id="@+id/statusView"
        android:text="Статус"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toTopOf="@id/indicator"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintTop_toBottomOf="@id/progressBtn" />
    <ProgressBar
        android:id="@+id/indicator"
        style="@android:style/Widget.ProgressBar.Horizontal"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:max="100"
        android:progress="0"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toBottomOf="@id/statusView"/>
 
</androidx.constraintlayout.widget.ConstraintLayout>

Сам клас фрагмента ProgressFragment змінимо таким чином:

package com.example.threadapp;
 
import android.os.Bundle;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.ProgressBar;
import android.widget.TextView;
 
 
public class ProgressFragment extends Fragment {
 
    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
 
        View view = inflater.inflate(R.layout.fragment_progress, container, false);
 
        ProgressBar indicatorBar = (ProgressBar) view.findViewById(R.id.indicator);
        TextView statusView = (TextView) view.findViewById(R.id.statusView);
        Button btnFetch = (Button)view.findViewById(R.id.progressBtn);
 
        MyViewModel model = new ViewModelProvider(requireActivity()).get(MyViewModel.class);
 
        model.getValue().observe(getViewLifecycleOwner(), value -> {
            indicatorBar.setProgress(value);
            statusView.setText("Статус: " + value);
        });
        btnFetch.setOnClickListener(v -> model.execute());
        return view;
    }
}

Тут аналогічним чином застосовується клас MyViewModel. Єдине, для отримання асоційованої з фрагментом Activity тут використовується метод requireActivity(). А для отримання власника життєвого циклу - метод getViewLifecycleOwner.

Тепер зв'яжемо фрагмент з Activity. Для цього визначимо в файлі activity_main.xml наступний код:

<androidx.fragment.app.FragmentContainerView
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/fragment_container_view"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:name="com.example.threadapp.ProgressFragment" />

А сам клас MainActivity скоротимо:

package com.example.threadapp;
 
import androidx.appcompat.app.AppCompatActivity;
 
import android.os.Bundle;
 
public class MainActivity extends AppCompatActivity {
 
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    }
}

І код з фрагментом буде працювати аналогічно: