Android MVVM + LiveData + DataBinding(初心者向け)


初めに

社内でAndroidの勉強会を開催する機会があり、記事を書こうと思いました。
今回は「AndroidArchitectureComponents」を使ってMVVMを実現する(実装する)内容となっています。別記事で、MVVMの説明した記事を書いてます。
※内容がおろそかだったらごめんなさい

今回作るもの

以下を行う双方向データバインディングアプリ

  • 画面を開いた時に、Modelからデータを取得しViewに変更通知
  • 画面から文字を入力し、Viewに変更を通知

設計イメージ

実装手順

Model実装 → ViewModel実装 → View実装

さぁ実装!の前に

実装の前に、知っていただきたいことがあります。
今回使用するDataBinding、LiveData、ViewModelをざっくり知っていただこうと思います。
それを知らないと何やっているかわからないと思うからです。ちなみに、別記事で詳しく述べる予定です。

DataBindingとは

Viewとデータ情報を静的または動的に結合する技術

単方向その1

ViewModelの値を変更する -> Viewに自動で反映される

単方向その2

ユーザがViewに入力 -> 自動でViewModelに値がセットされる

双方向

ViewModelの値を変更する -> Viewに自動で反映される
ユーザがViewに入力 -> 自動でViewModelに値がセットされる

LiveDataとは

値の変更をObserveできるデータホルダー

LiveData

外部から変更不可なLiveData

MutableLiveData ← 今回はこれだけ知っていればいい!

外部から変更可能なLiveData

MediatorLiveData

複数のLiveDataを束ねて管理するMutableLiveData

ViewModelとは

Activityの画⾯回転時のデータ保持
Activityの複数Fragment間でのデータ受け渡し
LiveDataと併⽤することが多い ← 今回はこれだけ知っていればいい!
プロセス停⽌後は復旧できない
データの永続化ではない
ViewModelProviders.of()とViewModelProvider.get()を使い、⾃分でnewしない! ← あ、これも知ってて!

実装

Gradleの設定

build.gradle
apply plugin: 'kotlin-kapt'

android {
    dataBinding {
        enabled = true
    }
}

dependencies {
    def archComponents_version = '2.0.0-beta01'
    implementation "androidx.lifecycle:lifecycle-extensions:$archComponents_version"
    kapt "androidx.lifecycle:lifecycle-compiler:$archComponents_version"
}

Model(Repository)

シンプルな初期値のみを取得する

MainRepository.kt
class MainRepository {
    fun fetchText(): String {
        return "初期値!!"
    }
}

ViewModel

変更可能なLiveDataにModelから取得した情報を格納する

MainViewModel.kt
class MainViewModel : ViewModel() {

    var liveDataText: MutableLiveData<String> = MutableLiveData()

    fun fetchText() {
        liveDataText.value = MainRepository().fetchText()
    }
}

レイアウト

xmlファイルのルートをにして、使用するオブジェクトを定義すると、Viewを実装
Viewからバインドしたdataにアクセスするには、@{}でくくる
また、@{}はnullを許容するようになっており、NullPointerExceptionは発生しない

fragment_main_binding.xml
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto">

    <data>
        <variable name="view_model" type="com.example.mvvmstudy.viewmodel.MainViewModel"/>
        <import type="java.lang.Integer" />
        <import type="android.view.View"/>
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:padding="20dp">

        <EditText
                android:id="@+id/edit_text"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_marginTop="50dp"
                android:text="@={view_model.liveDataText}"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintTop_toTopOf="parent"/>

        <TextView
                android:id="@+id/count_text"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginTop="10dp"
                android:text="@{`文字数:` + Integer.toString(view_model.liveDataText.length())}"
                app:layout_constraintTop_toBottomOf="@+id/edit_text"
                app:layout_constraintStart_toStartOf="parent"/>

    </androidx.constraintlayout.widget.ConstraintLayout>

</layout>

View(ActivityとFragment)

ActivityはFragmentを呼ぶだけ

MainActivity.kt
class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        if (savedInstanceState == null) {
            supportFragmentManager
                .beginTransaction()
                .add(R.id.container, MainFragment())
                .commit()
        }
    }
}
  • <layout>でくくったレイアウトファイルがあると、自動的にxmlファイル名に応じたBindingクラスが作られます。
  • DataBindingUtil.inflateでBindingしたViewを取得する。
  • binding.viewModel = viewModelでレイアウトファイルに定義したViewModelにViewModelのインスタンスを格納。
  • ViewModelのLiveDataをオブザーブし、データに変更があったらテキストを変更するようにする。
MainFragment.kt
class MainFragment : Fragment() {
    private lateinit var binding: FragmentMainBindingBinding
    private val viewModel: MainViewModel by lazy {
        ViewModelProviders.of(this).get(MainViewModel::class.java)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        viewModel.liveDataText.observe(this, Observer {
            binding.countText.text = "文字数:" + it.length.toString()
        })
    }

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        binding = DataBindingUtil.inflate(inflater, R.layout.fragment_main_binding, container, false)
        binding.viewModel = viewModel
        viewModel.fetchText()
        return binding.root
    }
}

しかし、これでは単方向データバインディング。

  • 双方向データバインディングするために、binding.lifecycleOwner = viewLifecycleOwnerを設定する。
  • オブザーブしていた処理は不要になったため、削除
MainFragment.kt
class MainFragment : Fragment() {
    private lateinit var binding: FragmentMainBindingBinding
    private val viewModel: MainViewModel by lazy {
        ViewModelProviders.of(this).get(MainViewModel::class.java)
    }

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        binding = DataBindingUtil.inflate(inflater, R.layout.fragment_main_binding, container, false)
        binding.lifecycleOwner = viewLifecycleOwner
        binding.viewModel = viewModel
        viewModel.fetchText()
        return binding.root
    }
}

以上。

最後に

基本的な「MVVM + LiveData + DataBinding」を実装しました。
最終的に双方向にしたので、LiveDataは関係なくなってしまいましたが。。。

また、ViewModelからRepositoryを直接読んでいますが、これでは依存しているため、
RepositoryのInterfaceを作り、ViewModelのコンストラクターでそのRepositoryのIntefaceを渡すようにするとなお良くなる。

GitHub

勉強会のカンペ用に色々無駄なコードが書かれちゃっているけど
https://github.com/mk2taiga/MVVMStudy

参考にした記事

https://qiita.com/Omoti/items/a83910a990e64f4dbdf1
https://qiita.com/takaaki7/items/91d34e8bf9ad5d71ddd2