Android
MVVM (Архитектура)
Поиск…
замечания
Синтаксические причуды с 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
было бы плохо, потому что вы делаете предположения в представлении, что это означает для представления.
Полезный материал
Следующие ресурсы помогли мне в попытке понять эту концепцию:
- Джереми Ликнес - Модель-View-ViewModel (MVVM) Разъяснение (C #) (08.2010)
- Шамлия Шуккур - Понимание основ дизайна MVVM (C #) (03.2013)
- Frode Nilsen - Android Databinding: Goodbye Presenter, Hello ViewModel! (07,2015)
- Джо Берч - Подходит к Android с MVVM (09.2015)
- Флорина Мунтенеску - шаблоны архитектуры Android Часть 3: Model-View-ViewModel (10.2016)
Пример 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;
}
}
Чтобы увидеть все в действии, посмотрите этот примерный проект .