Поиск…


замечания

Синтаксические причуды с DataBinding

При привязке функции viewModel к свойству в xml некоторые префиксы функций, такие как get или is , удаляются. Например. ViewModel::getFormattedText в ViewModel станет @{viewModel.formattedText} при привязке его к свойству в xml. Аналогично ViewModel::isContentVisible -> @{viewModel.contentVisible} (нотация Java Bean)

Сгенерированные классы привязки, такие как ActivityMainBinding , называются после xml, для которого они создаются привязки, а не для класса java.

Пользовательские привязки

В файле activity_main.xml я устанавливаю атрибут textColor в app а не пространство имен android . Это почему? Поскольку для атрибута textColor задан пользовательский сеттер, который разрешает идентификатор ресурса ColorRes, отправляемый ViewModel, фактическому цвету.

public class CustomBindings {

    @TargetApi(23)
    @BindingAdapter({"bind:textColor"})
    public static void setTextColor(TextView textView, int colorResId) {
        final Context context = textView.getContext();
        final Resources resources = context.getResources();
        final int apiVersion = Build.VERSION.SDK_INT;
        int color;

        if (apiVersion >= Build.VERSION_CODES.M) {
           color = resources.getColor(colorResId, context.getTheme());
        } else {
            color = resources.getColor(colorResId);
        }

        textView.setTextColor(color);
    }

}

Подробнее о том, как это работает, проверьте библиотеку DataBinding: пользовательские сеттеры

Подождите ... есть логика в вашем xml !!!?

Вы можете утверждать, что то, что я делаю в xml для android:visibility и app:textColor являются неправильными / анти-шаблонами в контексте MVVM, потому что на мой взгляд существует логика представления. Однако я бы сказал, что для меня важнее сохранить зависимости Android от моего ViewModel для тестирования.

Кроме того, что действительно делает app:textColor ? Он разрешает только указатель ressource на фактический цвет, связанный с ним. Таким образом, ViewModel все еще решает, какой цвет отображается на основе какого-либо условия.

Что касается android:visibility я чувствую из-за того, как называется метод, на самом деле это нормально использовать здесь тернарный оператор. Из-за имени isLoadingVisible и isContentVisible действительно нет сомнений в том, что каждый результат должен решить в представлении. Поэтому я чувствую, что это скорее выполнение команды, заданной ViewModel, чем реализация логики представления.

С другой стороны, я согласен с тем, что с помощью viewModel.isLoading ? View.VISIBLE : View.GONE было бы плохо, потому что вы делаете предположения в представлении, что это означает для представления.

Полезный материал

Следующие ресурсы помогли мне в попытке понять эту концепцию:

Пример MVVM с использованием библиотеки DataBinding

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

На Android мы можем использовать библиотеку DataBinding, чтобы помочь нам в этом и сделать большую часть нашей логической Unit-testable, не беспокоясь о зависимостях Android.

В этом примере я покажу центральные компоненты для глупого простого приложения, которое делает следующее:

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

Начнем с слоя вида:

activity_main.xml :

Если вы не знакомы с тем, как работает DataBinding, вам, вероятно, потребуется 10 минут, чтобы ознакомиться с ним. Как вы можете видеть, все поля, которые вы обычно обновляете сеттерами, привязаны к функциям переменной viewModel.

Если у вас возник вопрос о свойствах android:visibility или app:textColor проверьте раздел «Примечания».

 <layout
    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">

    <data>

        <import type="android.view.View" />

        <variable
            name="viewModel"
            type="de.walled.mvvmtest.viewmodel.ClickerViewModel"/>
    </data>

    <RelativeLayout
        android:id="@+id/activity_main"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:padding="@dimen/activity_horizontal_margin"

        tools:context="de.walled.mvvmtest.view.MainActivity">

        <LinearLayout
            android:id="@+id/click_counter"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_centerHorizontal="true"
            android:layout_marginTop="60dp"
            android:visibility="@{viewModel.contentVisible ? View.VISIBLE : View.GONE}"

            android:padding="8dp"

            android:orientation="horizontal">

            <TextView
                android:id="@+id/number_of_clicks"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                style="@style/ClickCounter"

                android:text="@{viewModel.numberOfClicks}"
                android:textAlignment="center"
                app:textColor="@{viewModel.counterColor}"

                tools:text="8"
                tools:textColor="@color/red"
            />

            <TextView
                android:id="@+id/static_label"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginLeft="4dp"
                android:layout_marginStart="4dp"
                style="@style/ClickCounter"

                android:text="@string/label.clicks"
                app:textColor="@{viewModel.counterColor}"
                android:textAlignment="center"

                tools:textColor="@color/red"
            />
        </LinearLayout>


        <TextView
            android:id="@+id/message"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_below="@id/click_counter"
            android:layout_centerHorizontal="true"
            android:visibility="@{viewModel.contentVisible ? View.VISIBLE : View.GONE}"

            android:text="@{viewModel.labelText}"
            android:textAlignment="center"
            android:textSize="18sp"

            tools:text="You're bad and you should feel bad!"
        />

        <Button
            android:id="@+id/clicker"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_below="@id/message"
            android:layout_centerHorizontal="true"
            android:layout_marginTop="8dp"
            android:visibility="@{viewModel.contentVisible ? View.VISIBLE : View.GONE}"

            android:padding="8dp"

            android:text="@string/label.button"

            android:onClick="@{() -> viewModel.onClickIncrement()}"
        />

        <android.support.v4.widget.ContentLoadingProgressBar
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="90dp"
            android:layout_centerHorizontal="true"
            style="@android:style/Widget.ProgressBar.Inverse"
            android:visibility="@{viewModel.loadingVisible ? View.VISIBLE : View.GONE}"

            android:indeterminate="true"
        />

    </RelativeLayout>

</layout>

Затем слой модели. Здесь у меня есть:

  • два поля, представляющие состояние приложения
  • getters читать количество кликов и состояние возбуждения
  • метод увеличения количества кликов
  • метод восстановления некоторого предыдущего состояния (важно для изменений ориентации)

Также я определяю здесь «состояние возбуждения», которое зависит от количества кликов. Впоследствии это будет использовано для обновления цвета и сообщения в представлении.

Важно отметить, что в модели нет предположений о том, как состояние может отображаться пользователю!

ClickerModel.java

import com.google.common.base.Optional;

import de.walled.mvvmtest.viewmodel.ViewState;

public class ClickerModel implements IClickerModel {

    private int numberOfClicks;
    private Excitement stateOfExcitement;

    public void incrementClicks() {
        numberOfClicks += 1;
        updateStateOfExcitement();
    }

    public int getNumberOfClicks() {
        return Optional.fromNullable(numberOfClicks).or(0);
    }

    public Excitement getStateOfExcitement() {
        return Optional.fromNullable(stateOfExcitement).or(Excitement.BOO);
    }

    public void restoreState(ViewState state) {
        numberOfClicks = state.getNumberOfClicks();
        updateStateOfExcitement();
    }

    private void updateStateOfExcitement() {
        if (numberOfClicks < 10) {
            stateOfExcitement = Excitement.BOO;
        } else if (numberOfClicks <= 20) {
            stateOfExcitement = Excitement.MEH;
        } else {
            stateOfExcitement = Excitement.WOOHOO;
        }
    }
}

Затем ViewModel.

Это приведет к изменениям в модели и форматированию данных из модели, чтобы показать их в представлении. Обратите внимание, что именно здесь мы оцениваем, какое представление GUI подходит для состояния, заданного моделью ( resolveCounterColor и resolveLabelText ). Таким образом, мы могли бы, например, легко реализовать UnderachieverClickerModel который имеет более низкие пороговые значения для состояния возбуждения, не касаясь какого-либо кода в viewModel или представлении.

Также обратите внимание, что ViewModel не содержит ссылок на объекты просмотра. Все свойства привязаны через аннотации @Bindable и обновляются, когда либо notifyChange() (сигнализирует, что все свойства необходимо обновить), либо notifyPropertyChanged(BR.propertyName) (сигнализирует, что эти свойства необходимо обновить).

ClickerViewModel.java

import android.databinding.BaseObservable;

import android.databinding.Bindable;
import android.support.annotation.ColorRes;
import android.support.annotation.StringRes;
    
import com.android.databinding.library.baseAdapters.BR;
    
import de.walled.mvvmtest.R;
import de.walled.mvvmtest.api.IClickerApi;
import de.walled.mvvmtest.model.Excitement;
import de.walled.mvvmtest.model.IClickerModel;
import rx.Observable;

public class ClickerViewModel extends BaseObservable {

    private final IClickerApi api;
    boolean isLoading = false;
    private IClickerModel model;

    public ClickerViewModel(IClickerModel model, IClickerApi api) {
        this.model = model;
        this.api = api;
    }

    public void onClickIncrement() {
        model.incrementClicks();
        notifyChange();
    }

    public ViewState getViewState() {
        ViewState viewState = new ViewState();
        viewState.setNumberOfClicks(model.getNumberOfClicks());
        return viewState;
    }

    public Observable<ViewState> loadData() {
        isLoading = true;
        return api.fetchInitialState()
                .doOnNext(this::initModel)
                .doOnTerminate(() -> {
                    isLoading = false;
                    notifyPropertyChanged(BR.loadingVisible);
                    notifyPropertyChanged(BR.contentVisible);
                });
    }

    public void initFromSavedState(ViewState savedState) {
        initModel(savedState);
    }

    @Bindable
    public String getNumberOfClicks() {
        final int clicks = model.getNumberOfClicks();
        return String.valueOf(clicks);
    }

    @Bindable
    @StringRes
    public int getLabelText() {
        final Excitement stateOfExcitement = model.getStateOfExcitement();
        return resolveLabelText(stateOfExcitement);
    }

    @Bindable
    @ColorRes
    public int getCounterColor() {
        final Excitement stateOfExcitement = model.getStateOfExcitement();
        return resolveCounterColor(stateOfExcitement);
    }

    @Bindable
    public boolean isLoadingVisible() {
        return isLoading;
    }

    @Bindable
    public boolean isContentVisible() {
        return !isLoading;
    }

    private void initModel(final ViewState viewState) {
        model.restoreState(viewState);
        notifyChange();
    }

    @ColorRes
    private int resolveCounterColor(Excitement stateOfExcitement) {
        switch (stateOfExcitement) {
            case MEH:
                return R.color.yellow;
            case WOOHOO:
                return R.color.green;
            default:
                return R.color.red;
        }
    }

    @StringRes
    private int resolveLabelText(Excitement stateOfExcitement) {
        switch (stateOfExcitement) {
            case MEH:
                return R.string.label_indifferent;
            case WOOHOO:
                return R.string.label_excited;
            default:
                return R.string.label_negative;
        }
    }

}

Связывание всего этого в Деятельности!

Здесь мы видим представление, инициализирующее viewModel со всеми зависимостями, которые могут потребоваться, которые должны быть созданы из контекста android.

После инициализации viewModel он привязан к XML-макету через DataBindingUtil (см. Раздел «Синтаксис» для именования сгенерированных классов).

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

MainActivity.java

import android.databinding.DataBindingUtil;
import android.os.Bundle;
import android.support.v7.app.AppCompatActivity;

import de.walled.mvvmtest.R;
import de.walled.mvvmtest.api.ClickerApi;
import de.walled.mvvmtest.api.IClickerApi;
import de.walled.mvvmtest.databinding.ActivityMainBinding;
import de.walled.mvvmtest.model.ClickerModel;
import de.walled.mvvmtest.viewmodel.ClickerViewModel;
import de.walled.mvvmtest.viewmodel.ViewState;
import rx.Subscription;
import rx.subscriptions.Subscriptions;

public class MainActivity extends AppCompatActivity {

    private static final String KEY_VIEW_STATE = "state.view";

    private ClickerViewModel viewModel;
    private Subscription fakeLoader = Subscriptions.unsubscribed();

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        // would usually be injected but I feel Dagger would be out of scope
        final IClickerApi api = new ClickerApi();
        setupViewModel(savedInstanceState, api);

        ActivityMainBinding binding = DataBindingUtil.setContentView(this, R.layout.activity_main);
        binding.setViewModel(viewModel);
    }

    @Override
    protected void onPause() {
        fakeLoader.unsubscribe();
        super.onPause();
    }

    @Override
    protected void onDestroy() {
        fakeLoader.unsubscribe();
        super.onDestroy();
    }

    @Override
    protected void onSaveInstanceState(Bundle outState) {
        outState.putSerializable(KEY_VIEW_STATE, viewModel.getViewState());
    }

    private void setupViewModel(Bundle savedInstance, IClickerApi api) {
        viewModel = new ClickerViewModel(new ClickerModel(), api);
        final ViewState savedState = getViewStateFromBundle(savedInstance);

        if (savedState == null) {
            fakeLoader = viewModel.loadData().subscribe();
        } else {
            viewModel.initFromSavedState(savedState);
        }
    }

    private ViewState getViewStateFromBundle(Bundle savedInstance) {
        if (savedInstance != null) {
            return (ViewState) savedInstance.getSerializable(KEY_VIEW_STATE);
        }
        return null;
    }
}

Чтобы увидеть все в действии, посмотрите этот примерный проект .



Modified text is an extract of the original Stack Overflow Documentation
Лицензировано согласно CC BY-SA 3.0
Не связан с Stack Overflow