Android
MVVM(アーキテクチャ)
サーチ…
備考
データバインディングでの文法の構文
viewModel関数をxmlのプロパティにバインドすると、 get
やis
ような特定の関数接頭辞が削除されます。例えば。 ViewModel::getFormattedText
は、xmlのプロパティにバインドするときに@{viewModel.formattedText}
になります。同様にViewModel::isContentVisible
- > @{viewModel.contentVisible}
(Java Bean表記法)
ActivityMainBinding
ような生成されたバインディングクラスは、Javaクラスではなく、バインディングを作成するXMLの名前に基づいています。
カスタムバインディング
activity_main.xmlでは、 android
名前空間ではなく、 app
textColor属性を設定しました。何故ですか? ViewModelによって実際の色に送出されるColorResリソースIDを解決するtextColor
属性用に定義されたカスタムセッターが存在するためです。
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 Library:Custom Settersを参照してください。
待って...あなたのXMLには論理があります!!!
あなたは私のビューにビューロジックがあるので、私はXMLでandroid:visibility
とapp:textColor
をMVVMコンテキストで間違っている/反パターンしていると主張できます。しかし、テストの理由からViewModelからアンドロイド依存性を取り除くことが重要であると私は主張します。
それに、本当にapp:textColor
は何をするのですか?それは、それに関連付けられた実際の色への再ソースポインタを解決するだけです。したがって、ViewModelは、何らかの条件に基づいて表示される色を決定します。
android:visibility
については、メソッドがどのように名前付けされているかのために、ここでは三項演算子を実際に使用しても構いません。 isLoadingVisible
とisContentVisible
という名前のため、それぞれの結果がどのように解決されるべきかについて疑いはありません。だから私は実際にビューロジックを実行するよりもむしろViewModelによって与えられたコマンドを実行していると感じています。
一方、私はviewModel.isLoading ? View.VISIBLE : View.GONE
を使用することに同意するでしょうviewModel.isLoading ? View.VISIBLE : View.GONE
はビューにとってその状態の意味を前提にしているため、実行するのが悪いことです。
有用な材料
以下のリソースは、私がこのコンセプトを理解しようとするのを助けてくれました。
- Jeremy Likness - モデルビューViewModel(MVVM)の説明 (C#)(08.2010)
- Shamlia Shukkur - MVVMデザインパターン (C#) の基礎を理解する (03.2013)
- Frode Nilsen - Androidデータバインディング:さようならプレゼンター、こんにちはViewModel! (07.2015)
- Joe Birch - MVVMを使用したAndroidへのアプローチ (09.2015)
- Florina Muntenescu - Androidアーキテクチャパターンパート3:Model-View-ViewModel (10.2016)
データバインディングライブラリを使用したMVVMの例
MVVMの全体のポイントは、ロジックを含むレイヤーをビューレイヤーから分離することです。
Androidでは、データバインディングライブラリを使用してこれを手助けし、Androidの依存関係を気にすることなく、ほとんどのロジックをユニットテスト可能にすることができます。
この例では、次のことをする愚かな単純なアプリケーションの中心的なコンポーネントを示します:
- 起動時にネットワークコールを偽装し、ローディングスピナーを表示する
- クリックカウンタTextView、メッセージTextView、およびカウンタをインクリメントするボタンを含むビューを表示する
- ボタンをクリックして更新カウンタをクリックし、カウンタがある数に達するとカウンタの色とメッセージテキストを更新する
ビューレイヤーから始めましょう:
activity_main.xml
:
DataBindingの仕組みに慣れていない場合は、おそらくそれに精通するために10分かかるでしょう。ご覧のとおり、通常setterで更新するすべてのフィールドは、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>
次に、モデルレイヤー。私はここにいる:
- アプリの状態を表す2つのフィールド
- 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。
これにより、モデルの変更がトリガーされ、モデルのデータがビューに表示されます。ここでは、モデルによって与えられた状態( resolveCounterColor
およびresolveLabelText
)に対して、どのGUI表現が適切であるかを評価します。たとえば、ViewModelまたはビューのコードに触れることなく、興奮の状態のしきい値が低いUnderachieverClickerModel
を簡単に実装できます。
また、ViewModelはビューオブジェクトへの参照を保持していないことにも注意してください。すべてのプロパティは@Bindable
アノテーションでバインドされ、 notifyChange()
(すべてのプロパティを更新する必要があることをnotifyPropertyChanged(BR.propertyName)
)またはnotifyPropertyChanged(BR.propertyName)
(このプロパティを更新する必要があることを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を初期化し、アンドロイドのコンテキストからインスタンス化する必要があるビューを確認します。
viewModelが初期化されると、DataBindingUtil(生成されたクラスの命名については '構文'のセクションを確認してください)を介してxmlレイアウトにバインドされます。
注意:サブスクリプションは、メモリリークやNPEを避けるために、アクティビティが一時停止または破棄されたときにサブスクライブ解除する必要があるため、このレイヤでサブスクライブされます。 OrientationChangesでのviewStateの永続化と再ロードもここでトリガーされます
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;
}
}
アクションのすべてを見るには、このサンプルプロジェクトをチェックしてください。