Developer's Blog

今最も注目されている設計手法!MVVM を Android アプリ開発に取り入れてみた

android-mvvm-top

こんにちは。共同開発部開発担当の北川です。

クロスプラットフォームなアプリ開発では Xamarin の使用はビジネスロジックの共通化が可能となり非常に効果的です。
すべてのアプリを単一の言語(C#)で実装することができる点だけでも魅力的ですが、MVVM 設計によりその再利用性を高めている点こそがその真価を発揮しているとも言えます。

私は Xamarin でのアプリ開発を通して MVVM 設計のすばらしさを経験してしまいました。もう後には戻れません。
Java や Objective-C でのアプリ開発でも MVVM 設計は開発スピードと品質確保を両立するために有効であるはずです。
今回は Android アプリ開発(Java)で MVVM を使うとどうなるか、紹介してみたいと思います。

MVVM 設計について

MVVM 設計ではビジネスロジックを Model が担当し、ViewModel が画面の状態を管理し、View が画面を表示します。
この中で、View のみがプラットフォーム固有となり、それは Android であれば Activity、iOS であれば UIViewController に該当します。
Xamarin を用いたクロスプラットフォームアプリ開発であれば Model と ViewModel はそのまま共通に利用することができ、
View だけを各プラットフォームごとに追加することになります。

クロスプラットフォームアプリ開発でなかったとしても、MVVM 設計には画面とロジックを明確に分離できる利点があり、
複雑な画面であってもその状態管理はシンプルになり、アプリ全体の見通しもよくなります。

アプリの説明

作成するアプリのスクリーンショットです。

android-mvvm-initial

android-mvvm-detail

起動直後は何も表示されていません。画面の黒い部分をタップすると、タップした座標へ、色とサイズがランダムであるボールが描かれます。
画面下部にはリスト形式でボールの情報を表示しています。ボールやリストをタップするとその該当するボールが削除されます。表示されるボールとボールの情報は完全に同期しています。

このアプリでは、ボール(複数)が Model であり、View となる Activity はボールを視覚的に表示したり、情報を整えて表示しているということになります。リスト上では色を16進数表記と背景色で表したり、座標を (x, y) 形式に整形して表示しています。

実装

android-binding の設置

MVVM 設計を導入するために、android-binding ライブラリを使用します。

gueei/AndroidBinding – GitHub

Github のリポジトリから android-binding-v0.6-build718.jar をダウンロードし、プロジェクトの libs ディレクトリ以下へ設置します。

ライブラリの初期化

まずはライブラリの初期化が必要です。
Application クラスの onCreate で初期化します。

@Override
public void onCreate() {
  super.onCreate();
  Binder.init(this);
}

View (レイアウト)の作成

画面レイアウトの XML を作成します。
MVVM 設計では画面へのプロパティバインディングが必須です。
バインディングは XML に埋め込むことが可能です。

activity_main.xml

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:binding="http://www.gueei.com/android-binding/"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
  <FrameLayout
      android:layout_width="match_parent"
      android:layout_height="300dp">

    <FrameLayout
        android:id="@+id/stage"
        android:background="@android:color/black"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        binding:onClick="stageClickedCommand"/>

    <LinearLayout
        android:orientation="horizontal"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="bottom">
      <TextView
          android:layout_width="wrap_content"
          android:layout_height="wrap_content"
          android:textColor="@android:color/white"
          android:text="count:" />
      <TextView
          android:layout_width="wrap_content"
          android:layout_height="wrap_content"
          android:textColor="@android:color/white"
          android:text="0"
          binding:text="count" />
    </LinearLayout>

  </FrameLayout>

  <ListView
      android:layout_width="match_parent"
      android:layout_height="match_parent"
      binding:clickedItem="clickedBall"
      binding:onItemClicked="ballClickedCommand"
      binding:itemSource="balls"
      binding:itemTemplate="@layout/list_ball_item" />
</LinearLayout>

binding:… に指定している “count”、”clickedBall”、”balls” などは View に対応する ViewModel のプロパティです。
このように記述しておくだけで、ViewModel のプロパティが変化すると、自動的に画面の表示も更新されます。
“ballClickedCommand” はコマンドです。View でイベントが発生すると ViewModel の指定の処理が呼び出されます。

ListView に binding:itemSource と binding:itemTemplate を指定すると、指定したプロパティとレイアウトを用いて
データを表示してくれるようになります。
Adapter を実装しなくてもバインディングにより自動的に画面が構築されます。

リストのレイアウト作成

リストビューアイテムのレイアウトファイルは次の通りとなります。

list_ball_item.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:binding="http://www.gueei.com/android-binding/"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:padding="10dp">
  <LinearLayout
      android:orientation="horizontal"
      android:layout_width="match_parent"
      android:layout_height="wrap_content">
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="No." />
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="0"
        binding:text="id" />
  </LinearLayout>

  <LinearLayout
      android:orientation="horizontal"
      android:layout_width="match_parent"
      android:layout_height="wrap_content">
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="color : "/>
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="#333333"
        binding:text="jp.co.fenrir.kitagawa.mvvmball.converter.WEBCOLOR(color)" />
    <View
        android:layout_gravity="center_vertical"
        android:layout_margin="3dp"
        android:layout_width="20dp"
        android:layout_height="10dp"
        android:background="@android:color/black"
        binding:backgroundColor="color" />
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="pos : " />
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="(0, 0)"
        binding:text="FORMAT('(%d,%d)', x, y)" />
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="size : "
        android:layout_marginLeft="10dp"/>
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="0"
        binding:text="size" />
  </LinearLayout>
</LinearLayout>

binding:text に ViewModel のプロパティを指定しており、これもまた自動的に画面に表示されます。
さらに、WEBCOLOR や FORMAT などコンバータを指定しているものもあります。
コンバータはプロパティを加工して表示するために使用します。
コンバータを組み合わせることで XML だけでもある程度複雑な画面表示へ対応することが可能です。

ここまでで、ボールを表示する機能を除けば MVVM における View のほとんどを実装したことになります。
レイアウト XML 中に、宣言的に記述していくだけで画面が実装できてしまう点が重要です。

View の実装

ActivityはBindingActivityV30を継承します。
フィールドも追加しておきます。

public class MainActivity extends BindingActivityV30 {
  private MainViewModel mViewModel;
  private SparseArray<BallView> mBallViews = new SparseArray<>();
  private ViewGroup mStage;

レイアウトを読み込ませます。

@Override
protected void onCreate(Bundle savedInstanceState) {
  super.onCreate(savedInstanceState);
  mViewModel = new MainViewModel(getEventAggregator());
  setAndBindRootView(R.layout.activity_main, mViewModel);
  mStage = (ViewGroup)findViewById(R.id.stage);
}

MainViewModel と getEventAggregator() は後で確認しますが、
これで、レイアウトが読み込まれ、画面が表示されるようになりました。

前述の通り、Adapter を実装していなくても ListView は正常に表示されています。
MVVM 設計では View へのコードの記述をできる限り抑える方針となります。

ViewModel の実装

MainViewModel は MainActivity に対応する ViewModel です。
MainViewModel は特別な親は持ちません。コンストラクタと定数を用意しておきます。

public class MainViewModel {
  public static final String ADD_BALL = "ADD_BALL";
  public static final String REMOVE_BALL = "REMOVE_BALL";
  private final EventAggregator mEventAggregator;
  public MainViewModel(EventAggregator aggregator) {
     mEventAggregator = aggregator;
  }

XML で指定したプロパティを定義します。

public final ArrayListObservable<BallViewModel> balls =
    new ArrayListObservable<>(BallViewModel.class);
public final IntegerObservable count = new IntegerObservable(balls.size());
public final Observable<Point> touchPoint = new Observable<>(Point.class);
public final ObjectObservable clickedBall = new ObjectObservable();
private final CollectionObserver ballCountObserver = new CollectionObserver() {
  @Override
  public void onCollectionChanged(
      IObservableCollection<?> iObservableCollection,
      CollectionChangedEventArg collectionChangedEventArg,
      Collection<Object> objects) {
    count.set(iObservableCollection.size());
  }
};

{
  // インスタンスイニシャライザ
  balls.subscribe(ballCountObserver);
}

Observable でラップしたデータがプロパティとして動作します。
プロパティは View の状態を表しています。
ボールの総数を表す count プロパティは balls.size() と連動しているのが望ましいです。
ballCountObserver によって balls と count プロパティを連動させています。

XML で指定していたコマンドを定義します。

private int idCounter = 0;
public final Command stageClickedCommand = new Command() {
  @Override
  public void Invoke(View view, Object... objects) {
    Point touched = touchPoint.get();
    BallViewModel ball =
        new BallViewModel(idCounter++, touched.x, touched.y);
    balls.add(ball);
    mEventAggregator.publish(ADD_BALL, ball, null);
  }
};

public final Command ballClickedCommand = new Command() {
  @Override
  public void Invoke(View view, Object... objects) {
    BallViewModel ball = null;
    if (view instanceof BallView) {
        ball = ((BallView) view).getViewModel();
    } else {
        ball = (BallViewModel)clickedBall.get();
    }
    balls.remove(ball);
    mEventAggregator.publish(REMOVE_BALL, ball, null);
  }
};

ボールの追加コマンドとボールの削除コマンドを定義しています。
ViewModel は View を直接扱うことはできませんが、
ここでも、balls プロパティを操作しているだけで、View に関与していません。
View をバインディングで構築していると、View と ViewModel を疎結合にできていることが分かります。

EventAggregator の実装(メッセンジャーパターン)

ViewModel と View は疎結合であり、プロパティとコマンドで接続されています。
ViewModel が View のメソッドを直接呼び出すことは許されませんが、どうしても View の機能を使いたい場合には
EventAggregator を用います。

EventAggregator はメッセンジャーパターンを提供します。
Android の LocalBroadcastManager と同等の機能であり、ViewModel が View を知らなくても
View の機能を呼び出せるようになります。

MainActivity に次のメソッドを実装します。

MainActivity

private EventAggregator getEventAggregator() {
  EventAggregator aggregator =
      EventAggregator.getInstance(getApplicationContext());

  aggregator.subscribe(MainViewModel.ADD_BALL, new EventSubscriber() {
    @Override
    public void onEventTriggered(String s, Object o, Bundle bundle) {
      BallViewModel ball = (BallViewModel)o;
      BallView view = new BallView(getApplicationContext());
      view.setViewModel(ball);
      view.setOnClickListener(mOnBallClickListener);
      mStage.addView(view);
      mBallViews.append(ball.id.get(), view);
    }
  });

  aggregator.subscribe(MainViewModel.REMOVE_BALL, new EventSubscriber() {
    @Override
    public void onEventTriggered(String s, Object o, Bundle bundle) {
      BallViewModel ball = (BallViewModel) o;
      BallView view = mBallViews.get(ball.id.get());
      mStage.removeView(view);
      mBallViews.delete(ball.id.get());
    }
  });

  return aggregator;
}

private View.OnClickListener mOnBallClickListener =
    new View.OnClickListener() {
  @Override
  public void onClick(View v) {
     mViewModel.ballClickedCommand.InvokeCommand(v);
  }
};

イベントが発生したときの動作を定義しています。
BallView を生成して画面へ追加する機能、BallView を画面から削除する機能が定義されています。
android-binding ライブラリのバインディングは強力ですが、XML とバインディングだけでは
実装できない機能は、このように View のコードを記述することになります。

ViewModel 側では stageClickedCommand、ballClickedCommand イベントの後にそれぞれのイベントを発行しており、
ボールが追加・削除されると、BallView も追加・削除されることになります。

Model について

Model にあたるものはボールですが、今回は BallViewModel を実装することで代替しており、Model の実装を省いています。
BallView と BallViewModel の実装は、本記事の末尾のソースコードでご確認ください。

Converter の実装

android-binding は標準でいくつかのコンバータを提供しています。
今回は、「色情報を16進数表記に変換する(Color -> String)」コンバータを独自に実装しています。

WEBCOLOR.java

public class WEBCOLOR extends Converter<String> {
  public WEBCOLOR(IObservable<?>[] dependents){
    super(String.class, dependents);
  }
  @Override
  public String calculateValue(Object... args) throws Exception {
    String value = "";
    if (args != null && 0 < args.length && args&#91;0&#93; != null) {
      int color = (Integer) args&#91;0&#93;;
      value = String.format("#%02x%02x%02x",
          Color.red(color), Color.green(color), Color.blue(color));
    }
    return value;
  }
}
&#91;/java&#93;</p>

<p>
コンバータにより、XML とバインディングのみで表現できる幅が広がります。
</p>

<h4 class="red">touchPoint プロパティを更新する</h4>

<p>
MainActivity へ次の実装を追加します。
</p>

<p>[java]
@Override
protected void onCreate(Bundle savedInstanceState) {
  ...
  mStage = (ViewGroup)findViewById(R.id.stage);

  mStage.setOnTouchListener(new View.OnTouchListener() {
    @Override
    public boolean onTouch(View v, MotionEvent event) {
      if (event.getAction() == MotionEvent.ACTION_DOWN) {
        mViewModel.touchPoint.set(new Point((int) event.getX(), (int) event.getY()));
      }
      return false;
    }
 });
}

画面がタップされた時に、ViewModel の touchPoint プロパティへ座標を設定しています。
本来であれば Touch イベントと touchPoint をバインディングで繋ぐべきですが、
android-binding が Touch イベントに対応していないため、イベントリスナの実装で対応しています。

完成!

これで、今回のアプリに使用するすべての要素を説明しました。
これらのコードをすべて組み合わせるだけで、アプリは動作するようになっています。

それぞれのコードが非常に断片的に思えたかもしれませんが、それは MVVM 設計により Model、ViewModel、View が疎結合に保たれているためです。

MVVM 設計ではこのように宣言的に処理を記述していくだけでアプリが完成します。コーディング時の、ビューとロジックの分業も簡単にできるようになっています。

まとめ

一通り、MVVM の基本的な実装が Android と Java でも可能であることを確認しました。
大雑把な説明となりましたが、MVVM 設計の威力を少しでも感じていただければ幸いです。

Android では画面レイアウトは XML で表現でき、バインディングも XML 中に埋め込むことが可能です。
MVVM 設計を取り入れていくことができれば、相性は非常に良いと言えます。
バインディングや Observer パターンを組み合わせることで、コードを宣言的に記述することができます。
画面の状態管理も、少量の簡潔なコードで実装できるようになりました。

付録

BallView.java

public class BallView extends View {
  private BallViewModel mViewModel;
  private Paint mPaint = new Paint();
  private RectF mDrawRect;

  public BallView(Context context) { super(context); }
  public BallView(Context context, AttributeSet attrs) { super(context, attrs); }
  public BallView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); }

  @Override
  protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    canvas.drawOval(mDrawRect, mPaint);
  }

  @Override
  protected void onSizeChanged(int w, int h, int oldw, int oldh) {
    mDrawRect = new RectF(0, 0, w, h);
  }

  public void setViewModel(BallViewModel viewModel) {
    mViewModel = viewModel;
    setupObservers(viewModel);
  }

  public BallViewModel getViewModel() {
    return mViewModel;
  }

  private void setupObservers(BallViewModel viewModel) {
    viewModel.color.subscribe(mColorObserver);
    viewModel.color.notifyChanged();
    viewModel.size.subscribe(mRectObserver);
    viewModel.x.subscribe(mRectObserver);
    viewModel.y.subscribe(mRectObserver);
    viewModel.size.notifyChanged();
  }

  private final Observer mColorObserver = new Observer() {
    @Override
    public void onPropertyChanged(IObservable<?> iObservable, Collection<Object> objects) {
      int color = (Integer)iObservable.get();
      Paint paint = new Paint();
      paint.setColor(color);
      mPaint = paint;
      postInvalidate();
    }
  };

  private final Observer mRectObserver = new Observer() {
    @Override
    public void onPropertyChanged(IObservable<?> iObservable, Collection<Object> objects) {
      int size = mViewModel.size.get();
      int x = mViewModel.x.get() - size/2;
      int y = mViewModel.y.get() - size/2;
      FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(size, size);
      params.leftMargin = x;
      params.topMargin = y;
      setLayoutParams(params);
      postInvalidate();
    }
  };
}

BallViewModel.java

public class BallViewModel {
  public final IntegerObservable id = new IntegerObservable();
  public final IntegerObservable color = new IntegerObservable();
  public final IntegerObservable x = new IntegerObservable();
  public final IntegerObservable y = new IntegerObservable();
  public final IntegerObservable size = new IntegerObservable();

  public BallViewModel(int id, int x, int y) {
    Random random = new Random();
    this.id.set(id);
    color.set(Color.rgb(random.nextInt(256), random.nextInt(256), random.nextInt(256)));
    size.set(30 + random.nextInt(140));
    this.x.set(x);
    this.y.set(y);
  }
}

フェンリルのオフィシャル Twitter アカウントでは、フェンリルプロダクトの最新情報などをつぶやいています。よろしければフォローしてください!

Copyright © 2019 Fenrir Inc. All rights reserved.