LVMC (a.k.a. MVVM) サンプル

前日の記事「Android で MVVM ならぬ“LMVC”」のサンプルコード

build.gradle (:app):

plugins {
    id 'com.android.application'
}

android {
    compileSdkVersion 30
    buildToolsVersion '30.0.3'
    defaultConfig {
        applicationId 'com.scaredeer.lvmcsample'
        minSdkVersion 26
        targetSdkVersion 30
        versionCode 1
        versionName '1.0'
    }
    compileOptions {
        sourceCompatibility 1.8
        targetCompatibility 1.8
    }
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
    buildFeatures {
        dataBinding true
    }
}

dependencies {
    implementation 'androidx.appcompat:appcompat:1.3.0'
    implementation 'com.google.android.material:material:1.4.0'

    // https://developer.android.com/jetpack/androidx/releases/lifecycle#java
    implementation 'androidx.lifecycle:lifecycle-viewmodel:2.3.1' // ViewModel
    implementation 'androidx.lifecycle:lifecycle-livedata:2.3.1' // LiveData
}

特記すべき点は buildFeatures { dataBiding true } の部分と、implementation 'androidx.lifecycle:lifecycle-viewmodel:2.3.1'、implementation 'androidx.lifecycle:lifecycle-livedata:2.3.1' の部分。かつての lifecycle-extentions は今では使えなくなっていて、完全なエラーにはならなくとも妙なバグが発生する場合があるので注意。

minSdkVersion 26 は、タイムスタンプをフォーマットするのに Java8 の新しい機能 Instant.ofEpochSecond を使ってしまっているが故に必要だったが、ViewModel や DataBiding とは何の関係もない。

main_activity.xml:

従来の layout.xml と違っている <data> </data> に注目。ここで、データをバインドするインスタンス名とそのクラスを指定している。Java のソースコードの import 宣言のようなものと考えればよい。

次に、通常のレイアウトにおいては、属性値として静的に記述されるべき場所に @{} の中にバインドしたオブジェクトとそのプロパティ名を指定する形で嵌め込む。こうすることによって、通常は XML レイアウト上に記述された属性値はコンパイル時に静的に確定されるのだが、バインドされた値の変化が動的に反映されるようになる。

従来は動的に変化させるには Java コードによって属性値にアクセスするしかなかったのが、データバインディングにより、XML レイアウト上に静的に記述した上で、Java コードによって直接属性値にアクセスすることなく、動的な変化に対応できるようになるわけである。ビューモデル内でバインドされた元の値を操作するだけでよい。

<data> </data> は実質、import 宣言のようなものだから、一旦記述してしまえば、以後は意識する必要はない。重要なのはやはり @{} の中にバインドしたオブジェクトとそのプロパティ名の部分であり、ここだけ、蛍光ペンでハイライトされた感じでコードを見る感覚がお勧め。

MainActivity.java:

package com.scaredeer.lmvcsample;

import android.os.Bundle;

import androidx.appcompat.app.AppCompatActivity;
import androidx.databinding.DataBindingUtil;
import androidx.lifecycle.ViewModelProvider;

import com.scaredeer.lmvcsample.databinding.MainActivityBinding;

public class MainActivity extends AppCompatActivity {

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

        MainViewModel viewModel = new ViewModelProvider(this).get(MainViewModel.class);

        MainActivityBinding binding = DataBindingUtil.setContentView(this, R.layout.main_activity);
        binding.setLifecycleOwner(this);
        binding.setViewModel(viewModel);
    }
}

MainActivity でやっていることは

  1. ViewModelProvider 経由(ViewModelProvider を経由しないと、毎回 ViewModel が Activity と連動して新生されてしまい、画面回転による Activity の再生成に巻き込まれたりして、ライフサイクルの独立性を得ることができなくなる)で入手した ViewModel を用意
  2. レイアウトファイルからバインディングを生成
  3. バインディングのライフサイクルはこの MainActivity に従って(消滅・再生成等)されることを設定
  4. 用意した ViewModel をバインディングの一方の端として結び付ける

ということである(公式ガイド)。

従来の setContentView(R.layout.main_activity) はもはや不要となっていることにも注目。

Activity の記述で行うことは以上で、以上までは主に DataBinding の話である。あとは、ViewModel 側で、バインドされた各値に対してピンポイントで操作を行うコーディングスタイルとなる。ViewModel の方は、LiveData が話の主役となる。

MainViewModel.java (1/3):

package com.scaredeer.lmvcsample;

import android.view.View;

import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.ViewModel;

import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;

public class MainViewModel extends ViewModel {
    private final MutableLiveData<String> mDatetime;
    public LiveData<String> getDatetime() {
        return mDatetime;
    }

    public MainViewModel() {
        mDatetime = new MutableLiveData<>(currentDatetime());
    }

    public void onClick(View view) {
        mDatetime.setValue(currentDatetime());
    }

    private String currentDatetime() {
        return Instant.ofEpochSecond(System.currentTimeMillis() / 1000L)
                .atZone(ZoneId.of("JST"))
                .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
    }
}

一番素直な形が、このように getter を通じてバインドするやり方だろう。DataBiding ライブラリーが getDatetime を layout における datetime というプロパティ名と結びつけてくれる。

MainViewModel.java (2/3):

package com.scaredeer.lmvcsample;

import android.view.View;

import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Transformations;
import androidx.lifecycle.ViewModel;

import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;

public class MainViewModel extends ViewModel {
    private final MutableLiveData<Long> mTime;
    public LiveData<String> datetime;

    public MainViewModel() {
        mTime = new MutableLiveData<>(System.currentTimeMillis());
        datetime = Transformations.map(
                mTime,
                time -> Instant
                        .ofEpochSecond(time / 1000L)
                        .atZone(ZoneId.of("JST"))
                        .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
        );
    }

    public void onClick(View view) {
        mTime.setValue(System.currentTimeMillis());
    }
}

ViewModel の内部では MutableLiveData として保持・操作しているが、外部公開用の LiveData と区別するため、Transformations.map を使ってこのような構成にしてみた。上流側の mTime に対する変更が Transformations.map によって下流の datetime に連動し、datetime を参照している View 側に反映される。

getter を定義せず、メンバー変数の LiveData を直接公開するスタイルにしてみると、こういう形になった。メンバー変数を直接アクセスするという点では直感的だが、反面、プライベートな MutableLiveData と公開用の LiveData の 2 本立てになるので、却ってコードが汚なくなる。冗長なだけかもしれない。しかし、ここではサンプルとして単純化した例で示しているのでこうなっているが、実用において、純粋なモデルと ViewModel との目的の違いから言って、LiveData<Long> time を Model 側で保持し、その time を ViewModel 側で LiveData<String> datetime が参照する、という 2 段構成の場合には、Transformation を使う方が実用的になることが十分に考えられる。

MainViewModel.java (3/3):

package com.scaredeer.lmvcsample;

import android.view.View;

import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Transformations;
import androidx.lifecycle.ViewModel;

import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;

public class MainViewModel extends ViewModel {
    private final MutableLiveData<Long> mTime;
    public LiveData<String> datetime;

    public MainViewModel() {
        mTime = new MutableLiveData<>(System.currentTimeMillis());
        datetime = Transformations.switchMap(
                mTime,
                time -> toTimestamp(time)
        );
    }

    private LiveData<String> toTimestamp(Long time) {
        String string = Instant
                .ofEpochSecond(time / 1000L)
                .atZone(ZoneId.of("JST"))
                .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
        return new MutableLiveData<>(string);
    }

    public void onClick(View view) {
        mTime.setValue(System.currentTimeMillis());
    }
}

さらに、Transformations.map() でやったのと同じことを比較のため、Transformations.switchMap() でやるとこうなる。要するに、戻り値を LiveData<T> の中身の Type で渡せば LiveData<T> にラップされて戻される .map() に対し、.switchMap() はラップ済の LiveData<T> にして渡して戻す必要があるという違いがある。

コメント

このブログの人気の投稿

清水俊史『上座部仏教における聖典論の研究』

シークエンスパパともの先見の明

シークエンスパパとも 本物の霊能力