MVVM + LiveData + Coroutines入門
最近の主流の流れに沿い、記事 + ソースをアップデート(Kotlin化、Android XやCoroutinesや古い部分のアップデート)しました!(2020/07/16
)
MVVM( Model-View-ViewModel )設計
まず、MVVM(Model-View-ViewModel)設計とはなんだ、と。
私みたいなMVC( Model-View-Controller )のWebフレームワークしか触ったことのなかった人間は、某カンファレンスでこの設計について熱弁する方々を目の当たりにしても、その意義がよくわかっておりませんでした。
ざっくり言うと、MVVMは上記MVCの派生パターンであり、MVVMを考慮してアプリケーションを開発する目的は、他のMVC系のパターンと同様にアプリケーションの「プレゼンテーション層(ユーザーが見て触れられる層)とドメイン(ビジネスロジック)を分離」することです。
そのアーキテクチャをAndroidでDataBindingライブラリが登場したことで、利用可能になったよと、そして、 Android Architecture Components の登場により、より扱いやすくなったよ、ということのようです。
大まかなメリットとしては、よくあげられるのは
- 関心の分離
- 依存関係の切り離し
- 画面回転問題
などなど、また、依存関係が
Model
(リモートとローカル問わず、データリソースを操作する領域)
ViewModel
(DataBindingライブラリを通し、Viewに表示するデータの監視、取得をする領域)
View
(xml/Activity/Fragment)
と依存関係が単方向になることで、保守性も向上します。
設計についてはもっと詳しい記事が世の中に溢れているので、詳細は以下に紹介いたします。
主要な設計への理解に関しては、以下の記事がとてもわかりやすく参考になりました。
[Androidアーキテクチャことはじめ ― 選定する意味と、MVP、Clean Architecture、MVVM、Fluxの特徴を理解する]
(https://employment.en-japan.com/engineerhub/entry/2018/01/17/110000)
一番お世話になっているのはこの本。
Android アプリ設計パターン入門
設計ってなんのためにするの?と言うところからAndroidの歴史、有名なアプリケーションで使われる設計の解説、経緯、と盛りだくさんで、スルメのように何度読んでも発見があります。
Android Architecture Components
Google I/O 2017で発表されたAndroid Architecture ComponentsはGoogleが推奨するデザインパターンを扱いやすくしたライブラリ群です。
4つに分かれてます。(発表当初)(公式ドキュメントから参照/引用)
- Lifecycle (ライフサイクルイベントに自動的に応答するUIを作成)
- LiveData (基礎となるデータベースが変更されたときにビューに通知するデータオブジェクトを構築)
- ViewModel (アプリの回転で破棄されないUI関連のデータを保存)
- Room (アプリ内オブジェクトとコンパイル時のチェックを使用して、アプリのSQLiteデータベースにアクセス)
Google I/O 2018では、Android Jetpackに、SupportLibraryやその他最新鋭便利ツールとがっちゃんこされることが発表されました。
なので、現時点ではComponents群の中で以下の3つも新たにサポートされております。
- Navigation (アプリ内ナビゲーション処理)->これすごそうなので勉強します
- Paging (アプリのUI内で徐々にデータを分割して読み込む)
- WorkManager (Androidのバックグラウンドジョブを管理)
進化はええええ...
さて、今回お世話になる、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)]
(https://proandroiddev.com/mvvm-architecture-viewmodel-and-livedata-part-1-604f50cda1)
とても参考になった記事です。
ここで紹介されているサンプル(よく見るあるユーザのGithubリポジトリをずらっと表示するだけのクライアントアプリ)の実装を、順番に見ていければなと思います。
ソース : https://github.com/Tsutou/GithubClient
(2019/01/17
Kotlinブランチを追加しました!)
(2019/09/20
Kotlinブランチをupdateしました!)
まず、当たり前ですが、依存関係のない方向に従い ( Model-> ViewModel -> View ) の順で実装していったほうが自分はやりやすかったです。
設計にこれといった答えはなく、これが正解というわけではないです。色々なサンプルを触って、最もシンプルでわかりやすかったので、参考にさせて頂いてます。
ざっくりイメージ
Gradle
android {
//…
dataBinding {
enabled = true
}
}
データバインディングライブラリのお力を借りるため、dataBinding
をtrueにしましょう。
ライブラリ
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 CoroutinesとviewModel-ktxも追加しましょう
Model
Projectモデル
手始めに、今回扱うProjectモデルを定義します。
(Githubプロジェクトの型)
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インターフェースを定義します。
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データを扱うビジネスロジックのみここには存在させます。
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です。
ビューのコンポーネントを操作することも、データを扱うこともしません。黒子のような位置(雑感)
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の責務を果たします。
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の生成、画面遷移にしか責任を持ちません。
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を監視します。
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を紐付け、ライフサイクル内にオブザーバを追加します。
オブザーバは、STARTED
か**RESUMED
**状態である場合にのみ、イベントを受信します。
そして、データバインディング。
慣例の通り、ルートビューをlayoutにしてあげます。そしてdata要素でbindするデータを定義してあげます。
<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判定の処理のみがお仕事をします。
object CustomBindingAdapter {
//xmlに定義する際のBindingAdapter
@BindingAdapter("visibleGone")
@JvmStatic
fun showHide(view: View, show: Boolean) {
view.visibility = if (show) View.VISIBLE else View.GONE
}
}
<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を試してみた
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)**はここで使われています。
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を直接指定してあげます。
<data>
<variable name="isLoading" type="boolean" />
<variable name="projectViewModel" type="com.example.XXXXXXXX.easyclient_mvvm.viewModel.ProjectViewModel"/>
</data>
すると、ViewModelを通し、Repositoryを介してAPIから取得したデータを、バインドします。
<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で、簡単無限スクロール )
追加しました!)