Help us understand the problem. What is going on with this article?

Android Architecture Components 初級 ( MVVM + LiveData + Coroutines 編 )

MVVM + LiveData + Coroutines入門


最近の主流の流れに沿い、記事 + ソースをアップデート(Kotlin化、Android XやCoroutinesや古い部分のアップデート)しました!(2019/09/20)

MVVM( Model-View-ViewModel )設計


まず、MVVM(Model-View-ViewModel)設計とはなんだ、と。

私みたいなMVC( Model-View-Controller )のWebフレームワークしか触ったことのなかった人間は、某カンファレンスでこの設計について熱弁する方々を目の当たりにしても、その意義がよくわかっておりませんでした。

ざっくり言うと、MVVMは上記MVCの派生パターンであり、MVVMを考慮してアプリケーションを開発する目的は、他のMVC系のパターンと同様にアプリケーションの「プレゼンテーション層(ユーザーが見て触れられる層)とドメイン(ビジネスロジック)を分離」することです。

そのアーキテクチャをAndroidでDataBindingライブラリが登場したことで、利用可能になったよと、そして、 Android Architecture Components の登場により、より扱いやすくなったよ、ということのようです。

大まかなメリットとしては、よくあげられるのは

  • 関心の分離
  • 依存関係の切り離し
  • 画面回転問題

などなど、また、依存関係が

Model
(リモートとローカル問わず、データリソースを操作する領域)


:point_up_2_tone2:

ViewModel
(DataBindingライブラリを通し、Viewに表示するデータの監視、取得をする領域)


:point_up_2_tone2:

View
(xml/Activity/Fragment)


と依存関係が単方向になることで、保守性も向上します。
設計についてはもっと詳しい記事が世の中に溢れているので、詳細は以下に紹介いたします。

主要な設計への理解に関しては、以下の記事がとてもわかりやすく参考になりました。
Androidアーキテクチャことはじめ ― 選定する意味と、MVP、Clean Architecture、MVVM、Fluxの特徴を理解する

一番お世話になっているのはこの本。
Android アプリ設計パターン入門

設計ってなんのためにするの?と言うところからAndroidの歴史、有名なアプリケーションで使われる設計の解説、経緯、と盛りだくさんで、スルメのように何度読んでも発見があります。

Android Architecture Components


Google I/O 2017で発表されたAndroid Architecture ComponentsはGoogleが推奨するデザインパターンを扱いやすくしたライブラリ群です。
4つに分かれてます。(発表当初)(公式ドキュメントから参照/引用)

Google I/O 2018では、Android Jetpackに、SupportLibraryやその他最新鋭便利ツールとがっちゃんこされることが発表されました。

Google I/O 2018

なので、現時点ではComponents群の中で以下の3つも新たにサポートされております。

進化はええええ...

さて、今回お世話になる、ViewModelはModelの変更を監視し、データをViewにバインドし、View操作を伝達するクラスです。Viewとライフサイクルを共に歩むので、画面回転などユーザの想定外の操作に強くなります。
LiveDataは、Android Architecture Componentsが提供する、ライフサイクルと連動した監視が可能な、データホルダーのクラスです。

【Android Architecture Components】Guide to App Architecture 和訳
LiveDataの基礎的な性質を整理する。

Kotlin Coroutines

コルーチンは、この記事では詳細な説明は省きますが、Kotlin/Androidでの非同期プログラミングではデファクトとなりつつある軽量なスレッドで、計算処理を中断再開できる便利な代物です。

Coroutines Overview - Kotlin Programming Language

サンプル


参考:MVVM architecture, ViewModel and LiveData (Part 1)
とても参考になった記事です。

ここで紹介されているサンプル(よく見るあるユーザのGithubリポジトリをずらっと表示するだけのクライアントアプリ)の実装を、順番に見ていければなと思います。

これだけ
https://gyazo.com/cb86e446446cafc7043b46cabb630c3e

ソース : https://github.com/Tsutou/GithubClient
(2019/01/17 Kotlinブランチを追加しました!)
(2019/09/20 Kotlinブランチをupdateしました!)

まず、当たり前ですが、依存関係のない方向に従い ( Model-> ViewModel -> View ) の順で実装していったほうが自分はやりやすかったです。

設計にこれといった答えはなく、これが正解というわけではないです。色々なサンプルを触って、最もシンプルでわかりやすかったので、参考にさせて頂いてます。

ざっくりイメージ

Untitled mvvmDiagram (1).png

Gradle

build.gradle
android {

    //…

    dataBinding { 
        enabled = true 
    } 
}

データバインディングライブラリのお力を借りるため、dataBindingをtrueにしましょう。

ライブラリ

build.gradle
dependencies {

   //…

    //layout
    implementation 'androidx.appcompat:appcompat:1.1.0'
    implementation 'com.google.android.material:material:1.0.0'
    implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
    implementation 'androidx.cardview:cardview:1.0.0'
    implementation 'androidx.recyclerview:recyclerview:1.0.0'

    // retrofit
    implementation 'com.squareup.retrofit2:retrofit:2.6.0'

    // gson
    implementation 'com.google.code.gson:gson:2.8.5'
    implementation 'com.squareup.retrofit2:converter-gson:2.4.0'

    // Android Architecture Components
    implementation 'androidx.lifecycle:lifecycle-extensions:2.1.0'
    implementation 'androidx.lifecycle:lifecycle-runtime:2.1.0'
    annotationProcessor 'androidx.lifecycle:lifecycle-compiler:2.1.0'

    //viewmodel-ktx viewModelScopeを使うため
    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.1.0"

    // OkHttp
    implementation "com.squareup.okhttp3:okhttp:3.12.1"
    implementation "com.squareup.okhttp3:logging-interceptor:3.12.1"
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"

    //Kotlin Coroutines
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.0"
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.0"
}

まず当たり前のように、インターフェース形式でAPIを定義できるRetrofitと、JSONをいい感じにオブジェクトに変換してくれるGsonを導入します。

そして、Android Architecture Componentsに、ViewModelとLiveDataを扱うため、お力を借ります。

Kotlin CoroutinesviewModel-ktxも追加しましょう

Model

Projectモデル


手始めに、今回扱うProjectモデルを定義します。
(Githubプロジェクトの型)

Project.kt
data class Project(
        val userName: String,
        var id: Long,
        var name: String = userName,
        var full_name: String,
        var owner: User,
        var html_url: String,
        var description: String,
        var url: String,
        var created_at: Date,
        var updated_at: Date,
        var pushed_at: Date,
        var git_url: String,
        var ssh_url: String,
        var clone_url: String,
        var svn_url: String,
        var homepage: String,
        var stargazers_count: Int,
        var watchers_count: Int,
        var language: String?,
        var has_issues: Boolean,
        var has_downloads: Boolean,
        var has_wiki: Boolean,
        var has_pages: Boolean,
        var forks_count: Int,
        var open_issues_count: Int,
        var forks: Int,
        var open_issues: Int,
        var watchers: Int,
        var default_branch: String
)

GithubService


APIを取り扱ってもらうRetrofitインターフェースを定義します。

GithubService.kt
interface GithubService {

    //一覧
    @GET("users/{user}/repos")
    suspend fun getProjectList(@Path("user") user: String): Response<List<Project>>

    //詳細
    @GET("/repos/{user}/{reponame}")
    suspend fun getProjectDetails(@Path("user") user: String, @Path("reponame") projectName: String): Response<Project>
}

Repository


ViewModelに対するデータプロバイダです。
ViewModelから呼び出され。コルーチンでサーバーからデータを取得します。
イベントリスナーなどは定義せず、RetrofitがAPIデータを扱うビジネスロジックのみここには存在させます。

ProjectRepository.kt
class ProjectRepository {

    private val retrofit = Retrofit.Builder()
            .baseUrl(HTTPS_API_GITHUB_URL)
            .addConverterFactory(GsonConverterFactory.create())
            .build()

    private var githubService: GithubService = retrofit.create(GithubService::class.java)

    //APIにリクエストし、レスポンスをコルーチンで受け取る(一覧)
    suspend fun getProjectList(userId: String): Response<List<Project>> =
        githubService.getProjectList(userId)

    //APIにリクエストし、レスポンスをコルーチンで受け取る(詳細)
    suspend fun getProjectDetails(userID: String, projectName: String): Response<Project> =
        githubService.getProjectDetails(userID, projectName)

    //singletonでRepositoryインスタンスを返すFactory
    companion object Factory {

        val instance: ProjectRepository
            @Synchronized get() {
                return ProjectRepository()
            }
    }
}

ViewModel

ProjectListViewModel


トップページのプロジェクトリストのRepositoryから送られてくるデータまた、リストに対する操作とUIイベントに責務を持つViewModelです。
ビューのコンポーネントを操作することも、データを扱うこともしません。黒子のような位置(雑感)

ProjectListViewModel.kt
class ProjectListViewModel(application: Application) : AndroidViewModel(application) {

    private val repository = ProjectRepository.instance

    //監視対象のLiveData
    var projectListLiveData: MutableLiveData<List<Project>> = MutableLiveData()

    //ViewModel初期化時にロード
    init {
        loadProjectList()
    }

    private fun loadProjectList() {
        //viewModelScope->ViewModel.onCleared() のタイミングでキャンセルされる CoroutineScope
        viewModelScope.launch {
            try {
                val request = repository.getProjectList(getApplication<Application>().getString(R.string.github_user_name))
                if (request.isSuccessful) {
                    //データを取得したら、LiveDataを更新
                    projectListLiveData.postValue(request.body())
                }
            } catch (e: Exception) {
                e.stackTrace
            }
        }
    }
}

ProjectViewModel


こちらは詳細ページのViewModelです。
違いはRepositoryに識別子(project_id)を、DI(依存性注入)で伝えているという特徴があります。
画面単位に依存せずに、ViewModelの責務を果たします。

ProjectViewModel.kt
class ProjectViewModel(
        private val myApplication: Application,
        private val mProjectID: String
) : AndroidViewModel(myApplication) {

    private val repository = ProjectRepository.instance
    val projectLiveData: MutableLiveData<Project> = MutableLiveData()

    var project = ObservableField<Project>()

    //ViewModel初期化時にロード
    init {
        loadProject()
    }

    private fun loadProject() {
        //viewModelScope->ViewModel.onCleared() のタイミングでキャンセルされる CoroutineScope
        viewModelScope.launch {
            try {
                val project = repository.getProjectDetails(myApplication.getString(R.string.github_user_name), mProjectID)
                if (project.isSuccessful) {
                    projectLiveData.postValue(project.body())
                }
            } catch (e: Exception) {
                Log.e("loadProject:Failed", e.stackTrace.toString())
            }
        }
    }

    fun setProject(project: Project) {
        this.project.set(project)
    }

    //IDの(DI)依存性注入ファクトリ
    class Factory(private val application: Application, private val projectID: String) : ViewModelProvider.NewInstanceFactory() {
        @Suppress("UNCHECKED_CAST")
        override fun <T : ViewModel> create(modelClass: Class<T>): T {
            return ProjectViewModel(application, projectID) as T
        }
    }
}

ここで、インナークラスで依存性注入するstaticなクラスが登場します。
インスタンスを外部で作ってあげて、依存性を切り離します。

FactoryはArchitecture ComponentsとDagger2の合わせ技です。
Architecture Components を Dagger2 と併用する際の ViewModelProvider.Factory について

View

MainActivity


ActivityではもはやFragmentの生成、画面遷移にしか責任を持ちません。

MainActivity.kt
class MainActivity : AppCompatActivity() {

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

        if (savedInstanceState == null) {
            //プロジェクト一覧のFragment
            val fragment = ProjectListFragment()

            supportFragmentManager
                    .beginTransaction()
                    .add(R.id.fragment_container, fragment, TAG_OF_PROJECT_LIST_FRAGMENT)
                    .commit()
        }
    }

    //詳細画面への遷移
    fun show(project: Project) {
        val projectFragment = ProjectFragment.forProject(project.name)

        supportFragmentManager
                .beginTransaction()
                .addToBackStack("project")
                .replace(R.id.fragment_container, projectFragment, null)
                .commit()
    }
}

ProjectListFragment


Viewの形成また、DataBindingを紐付けます。
データバインディングライブラリのDataBindingUtilにより、Databindingするビューファイルの設定をします。
RecyclerViewのAdapterをセットしたり、loadの制御、また、ViewModelのLiveDataを監視します。

ProjectListFragment.kt
class ProjectListFragment : Fragment() {

    private val viewModel by lazy { ViewModelProviders.of(this).get(ProjectListViewModel::class.java) }

    private lateinit var binding: FragmentProjectListBinding
    private lateinit var projectAdapter: ProjectAdapter

    private val projectClickCallback = object : ProjectClickCallback {
        override fun onClick(project: Project) {
            if (lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) && activity is MainActivity) {
                (activity as MainActivity).show(project)
            }
        }
    }

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
                              savedInstanceState: Bundle?): View? {
        //dataBinding用のレイアウトリソース
        binding = DataBindingUtil.inflate(inflater, R.layout.fragment_project_list, container, false)

        projectAdapter = ProjectAdapter(projectClickCallback)

        binding.apply {
            projectList.adapter = projectAdapter
            isLoading = true
        }

        return binding.root
    }

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

        observeViewModel(viewModel)
    }

    //observe開始
    private fun observeViewModel(viewModel: ProjectListViewModel) {

        //データをSTARTED かRESUMED状態である場合にのみ、アップデートするように、LifecycleOwnerを紐付け、ライフサイクル内にオブザーバを追加
        viewModel.projectListLiveData.observe(viewLifecycleOwner, Observer { projects ->
            if (projects != null) {
                binding.isLoading = false
                projectAdapter.setProjectList(projects)
            }
        })
    }
}

observe関数で、LiveDataのActive状態を監視します。
データが更新されたらアップデートするように、LifecycleOwnerを紐付け、ライフサイクル内にオブザーバを追加します。
オブザーバは、STARTEDRESUMED状態である場合にのみ、イベントを受信します。

そして、データバインディング。
慣例の通り、ルートビューをlayoutにしてあげます。そしてdata要素でbindするデータを定義してあげます。

fragmant_project_list.xml
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <data>
        <variable name="isLoading" type="boolean" />
    </data>

    <!--中略-->

今回の場合、モデルのデータをRecyclerViewに渡すのはAdapterを通してなので、こちらでは以下CustomBindingAdapterで定義したカスタムセッターのload判定の処理のみがお仕事をします。

CostomBindingAdapter.kt
object CustomBindingAdapter {
    //xmlに定義する際のBindingAdapter

    @BindingAdapter("visibleGone")
    @JvmStatic
    fun showHide(view: View, show: Boolean) {
        view.visibility = if (show) View.VISIBLE else View.GONE
    }
}
fragmant_project_list.xml
    <LinearLayout
        //…>

        <TextView
            //…
            app:visibleGone="@{isLoading}"/>

        <LinearLayout
            //…
            app:visibleGone="@{!isLoading}">

            <android.support.v7.widget.RecyclerView

                //…

                />

        </LinearLayout>

    </LinearLayout>

そして、RecyclerViewに対するAdapterの実装がこちら。
ミソは、DiffUtilです。Support Library 24.2.0で追加された、2つのListの差分を計算するユーティリティー、です。
差分を計算しつつ、いい感じにアニメーションなどをやってくれます。

Support Library 24.2.0で追加されたDiffUtilを試してみた

ProjectAdapter.kt
class ProjectAdapter(private val projectClickCallback: ProjectClickCallback?) :
        RecyclerView.Adapter<ProjectAdapter.ProjectViewHolder>() {

    private var projectList: List<Project>? = null

    fun setProjectList(projectList: List<Project>) {

        if (this.projectList == null) {
            this.projectList = projectList

            notifyItemRangeInserted(0, projectList.size)

        } else {

            val result = DiffUtil.calculateDiff(object : DiffUtil.Callback() {
                override fun getOldListSize(): Int {
                    return requireNotNull(this@ProjectAdapter.projectList).size
                }

                override fun getNewListSize(): Int {
                    return projectList.size
                }

                override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
                    val oldList = this@ProjectAdapter.projectList
                    return oldList?.get(oldItemPosition)?.id == projectList[newItemPosition].id
                }

                override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
                    val project = projectList[newItemPosition]
                    val old = projectList[oldItemPosition]

                    return project.id == old.id && project.git_url == old.git_url
                }
            })
            this.projectList = projectList

            result.dispatchUpdatesTo(this)
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewtype: Int): ProjectViewHolder {
        val binding =
                DataBindingUtil.inflate(
                        LayoutInflater.from(parent.context),
                        R.layout.project_list_item, parent,
                        false) as ProjectListItemBinding

        binding.callback = projectClickCallback

        return ProjectViewHolder(binding)
    }

    override fun onBindViewHolder(holder: ProjectViewHolder, position: Int) {
        holder.binding.project = projectList?.get(position)
        holder.binding.executePendingBindings()
    }

    override fun getItemCount(): Int {
        return projectList?.size ?: 0
    }

    open class ProjectViewHolder(val binding: ProjectListItemBinding) : RecyclerView.ViewHolder(binding.root)
}

ProjectFragment


こちらは詳細ページ用Fragment、一覧と同じくbindするViewを紐付けます。
先ほど定義したFactoryクラス(DI)はここで使われています。

ProjectFragment.kt
class ProjectFragment : Fragment() {

    private lateinit var binding: FragmentProjectDetailsBinding

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

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

        val projectID = arguments?.getString(KEY_PROJECT_ID)

        val factory = ProjectViewModel.Factory(
                requireActivity().application, projectID ?: ""
        )

        val viewModel = ViewModelProviders.of(this, factory).get(ProjectViewModel::class.java)

        binding.apply {
            projectViewModel = viewModel
            isLoading = true
        }

        observeViewModel(viewModel)
    }

    private fun observeViewModel(viewModel: ProjectViewModel) {
        viewModel.projectLiveData.observe(viewLifecycleOwner, Observer { project ->
            if (project != null) {

                binding.isLoading = false
                viewModel.setProject(project)
            }
        })
    }

    companion object {
        fun forProject(projectID: String): ProjectFragment {
            val fragment = ProjectFragment()
            val args = Bundle()

            args.putString(KEY_PROJECT_ID, projectID)
            fragment.arguments = args

            return fragment
        }
    }
}

そして、データバインディング。
こっちはViewModelを直接指定してあげます。

fragmant_project_details.xml
<data>
    <variable name="isLoading" type="boolean" />
    <variable name="projectViewModel" type="com.example.XXXXXXXX.easyclient_mvvm.viewModel.ProjectViewModel"/>
</data>

すると、ViewModelを通し、Repositoryを介してAPIから取得したデータを、バインドします。

fragmant_project_details.xml
<TextView
    android:id="@+id/name"
    //…
    android:text="@{projectViewModel.project.name}"
    />

<TextView
    android:id="@+id/project_desc"
    //…
    android:text="@{projectViewModel.project.description}"/>

<TextView
    android:id="@+id/languages"
    //…
    android:text="@{String.format(@string/languages, projectViewModel.project.language)}"/>

<TextView
    android:id="@+id/project_watchers"
    //…
    android:text="@{String.format(@string/watchers, projectViewModel.project.watchers)}"/>

<TextView
    android:id="@+id/project_open_issues"
    //…
    android:text="@{String.format(@string/openIssues, projectViewModel.project.open_issues)}"/>

<TextView
    android:id="@+id/project_created_at"
    //…
    android:text="@{String.format(@string/created_at, projectViewModel.project.created_at)}"/>

<TextView
    android:id="@+id/project_updated_at"
    //…
    android:text="@{String.format(@string/updated_at, projectViewModel.project.updated_at)}"/>

<TextView
    android:id="@+id/clone_url"
    //…
    android:text="@{String.format(@string/clone_url, projectViewModel.project.clone_url)}"/>

まとめ

すごく理にかなった、すっきりとした設計だなと思っております。
始めた頃、設計や非同期処理を理解するのは大変でしたが、最近、AndroidはJetpackやCoroutinesに従い、デファクトなパターンが固まりつつあるのかなと思います。

ViewModelsとLiveData:パターン+アンチパターン

実装範囲も分担しやすくソースコードの品質も統一でき(個人の色が出にくい)、成長させていくアプリケーションにはとてもいい設計なんだろうなと思っています。

設計を理解した人が、責任もって最後まで見守ることが大事ですね。(雑感)

長々と読んでいただき、ありがとうございました!

( ※ 2019年02月26日 Android Jetpack 初級 ( Paging library + LiveData + Retrofitで、簡単無限スクロール )
追加しました!)

Tsutou
音楽業界からドロップアウトした、Android/Webエンジニアです。 青二才なりの見解を記事にできたらいいなと思ってます。よろしくお願いします。
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした