#この記事は
[Android] お寿司屋で例えるAndroid Architecture Compoment 第五貫:Roomの続きです。
#おさらい
###MVVMをこんな感じでお寿司屋に例えたとさ
View(Activity/Fragment)はお客さん
ViewModelが板前さん
Repositoryが、漁師
Modelが、海
MVVMを海からお魚を取ってきてお客さんに提供するまでの役割を分割したものと捉えています。
詳しくは、第一貫をご覧ください。(5分位で読める内容ですよ!)
###こんな感じのアプリを作ったとさ
マグロしか食べてなかったり、小学生女子が下校途中にお寿司屋に寄っていたりとツッコミどころがありますが、スルーしてください。
先程のMVVMの例えと、アプリの例えが少し違いがありますが、こちらもスルーで。
###View(お客さん)
注文画面を表示
###ViewModel(板前さん)
注文画面のデータや処理を保持
###Model
注文履歴を保持
#本題
どうもReoです。
過去の記事を一つでもご覧いただいた方ありがとうございます。
今回で、感動の最終巻となります。
ハンカチのご用意は、大丈夫ですか?
今回は、第三貫で作成したアプリをMVVM化させる実装をしたいと思います。
これで、MVVMを簡単に実装したことになります。
これまでで、View-ViewModel間の実装が終わっているので、ViewからModel間の実装をしていきます。
とはいっても、アプリに前回学習したRoomを実装していくだけです。
注文履歴と代金をローカルデータベースに保存します。
###Repositoryについて
さぁ、Repositoryを実装したいのですが、この図におけるRepositoryは必須ではありません。
そもそも、Repositoryの役割とは何か?
「漁師」の事ですよね!
つまり、魚(データ)を海(Model)から取ってきて、それをRoom(最新冷蔵庫)に保管しておきます。
しかし、少し違うような気がするんです。
最終貫にして、申し訳ないですw
さてどの辺りかというと、まずRoomは前回の記事で「最新冷蔵庫」と比喩しました。
冷蔵庫は、買ったときは何も入ってないですよね?
あれ、ではどこから取ってくるの?
と思ったかもしれませんが、それは隣です。
初めてデータを取るときは、Remote Data Sourceの部分です。
ここが、海から取るという意味です。
Retrofitという通信ライブラリを用いてWebから情報を取ります。
Retrofitについて記事書いたことあるので、詳しくはこちら!
ここで改めて、MVVMの流れを確認してみましょう。
依存関係ではなく、データの流れ順に説明します。
まず、漁師(Repository)が、海(Remote Data Source)から取った魚(データ)は、冷蔵庫(Room)に保存します。
そして、板前さん(ViewModel)のいるお寿司屋は、漁師さんがとったお魚を買います。
それをView(お客さん)に提供します。
重要なのが、初めて漁師さんが取るときは、海(Remote Data Source)なのですが、それ以降は保存した冷蔵庫から取り出します。
何故かというと、漁は毎回行けるとは限らないからです。
ソフトウェアの世界に戻します。
現在の実装では、ユーザーがデバイスを回転させた場合や、ユーザーがアプリから離れてすぐに戻ってきた場合には、既存の UI が直ちに表示されます。これは、リポジトリがメモリ内キャッシュからデータを取得するためです。
しかし、ユーザーがアプリから離れてから数時間後(Android OS がプロセスを強制終了した後)に戻ってきた場合はどうなるでしょうか。現在の実装では、ネットワークからデータを再取得する必要があります。この再取得プロセスは、ユーザー エクスペリエンスに悪影響を及ぼすだけでなく、貴重なモバイルデータを消費するため、無駄が多くなります。
つまり、Repositoryというのは、APIを叩いて取得した情報をRoomに保存する事で、ネットに接続が悪いときに再取得できない場合があるし、またそもそも同じものを再取得というのは、ドキュメントの通りで、ユーザーエクスペリエンスとして良くありません。
ということで、ロカールDB(Room)だけの実装の場合は、Repositoryは必要なく、ViewModelにRoomの操作(Dao)をインジェクションで良いかなと思います。
インジェクションとは、依存性注入のことで、簡単に言うと貸し借りの関係ですね。
もうちょい学びたい方はこちら
ViewModelは、LocalResourceからDaoをお借りしてRoomの操作が出来るようにするということです。
お寿司語的には、板前さんが漁師さんにお魚を取ってきてもらうという関係性を作るということです。
###実装
では、Roomを追加してみたいと思います。
前回説明したので、ガンガン行きます。
ちなみに、完成形はこちら。
既に作成したものに、追加実装する形で「お皿の枚数」とお母さんへの「請求額」をRoom出来るようにします。
def room_version = "2.3.0"
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"
implementation "androidx.room:room-ktx:$room_version"
testImplementation "androidx.room:room-testing:$room_version"
implementation "com.google.dagger:hilt-android:2.32-alpha"
kapt "com.google.dagger:hilt-android-compiler:2.32-alpha"
implementation 'androidx.hilt:hilt-lifecycle-viewmodel:1.0.0-alpha03'
###漁師
@Database(entities = arrayOf(Sushi::class), version = 1,exportSchema = false)
abstract class SushiDatabase : RoomDatabase() {
abstract fun suhiDao(): SushiDao
}
@Entity
data class Sushi(
@PrimaryKey(autoGenerate = true)
val id: Int,
val orderHistory: Int?,
val price: String
)
@Dao
interface SushiDao {
@Query("SELECT * FROM sushi")
suspend fun getAll(): List<Sushi>
@Query("SELECT * FROM sushi where id = :id")
fun getHistory(id: Int): Sushi
@Insert
fun insertSushi(sushi: Sushi)
@Delete
fun delete(sushi: Sushi)
}
###アプリケーションクラス
@HiltAndroidApp
class MyApplication : Application()
###モジュール
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideSushiDatabase(
@ApplicationContext context: Context
) = Room.databaseBuilder(
context,
SushiDatabase::class.java,
"database"
).build()
@Provides
@Singleton
fun provideSushiDao(db: SushiDatabase) = db.suhiDao()
}
###レイアウト
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/orderImageView"
android:layout_width="240dp"
android:layout_height="240dp"
android:layout_marginTop="80dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/cashDisplay"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text=""
android:textSize="16sp"
app:layout_constraintTop_toBottomOf="@id/orderImageView"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<Button
android:id="@+id/orderButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="40dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/cashDisplay" />
<Button
android:id="@+id/billButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="40dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/orderButton" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
追加したViewは、お会計の時と請求時のテキストをセットするTextViewのみです。
###お客さん(View)
@AndroidEntryPoint
class OrderFragment : Fragment() {
private val viewModel: OrderViewModel by viewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? = FragmentOrderBinding.inflate(inflater, container, false).let {
it.lifecycleOwner = viewLifecycleOwner
it.orderButton.setOnClickListener { viewModel.orderTuna() }
it.billButton.setOnClickListener { viewModel.pay() }
val orderImageObserver = Observer<Int> { newImageId ->
it.orderImageView.setImageResource(newImageId)
}
val textObserver = Observer<String> { newText ->
it.orderButton.text = newText
}
val cashObserver = Observer<String> { newText ->
it.cashDisplay.text = newText
}
val billObserver = Observer<String> { newText ->
it.billButton.text = newText
}
viewModel.orderImage.observe(viewLifecycleOwner, orderImageObserver)
viewModel.orderText.observe(viewLifecycleOwner, textObserver)
viewModel.cashDisplay.observe(viewLifecycleOwner, cashObserver)
viewModel.billText.observe(viewLifecycleOwner, billObserver)
it.root
}
こちらも監視するViewが増えただけで、特に新しいことはありません。
###板前さん(ViewModel)
@HiltViewModel
class OrderViewModel @Inject constructor(private val sushiDao: SushiDao) : ViewModel() {
private val _orderImage = MutableLiveData<Int>()
val orderImage: LiveData<Int>
get() = _orderImage
private val _cashDisplay = MutableLiveData<String>()
val cashDisplay: LiveData<String>
get() = _cashDisplay
private val _orderText = MutableLiveData<String>()
val orderText: LiveData<String>
get() = _orderText
private val _billText = MutableLiveData<String>()
val billText: LiveData<String>
get() = _billText
private val _customerType = MutableLiveData(CustomerType.ENTER)
private val _tunaCount = MutableLiveData<Int>()
private val _totalData = MutableLiveData<List<Sushi>>()
private var lastIndex: Int = 0
private var _totalDish: Int = 0
private var _amountBill: Int = 0
init {
_orderImage.value = R.drawable.sushiya_building
_orderText.value = "入店"
_tunaCount.value = 0
}
fun orderTuna() {
when (_customerType.value) {
CustomerType.ENTER, CustomerType.RE_ENTER -> {
_cashDisplay.value = ""
_orderImage.value = R.drawable.sushi_syokunin_man_mask
_orderText.value = "マグロ"
_billText.value = "お会計"
_customerType.value = CustomerType.EAT_TUNA
}
CustomerType.EAT_TUNA -> {
_orderImage.value = R.drawable.sushi_akami
_orderText.value = "完食"
_customerType.value = CustomerType.COMPLETED_EAT
_tunaCount.value = _tunaCount.value?.plus(1)
}
CustomerType.COMPLETED_EAT -> {
_orderImage.value = R.drawable.sushi_syokunin_man_mask
_orderText.value = "マグロ"
_customerType.value = CustomerType.EAT_TUNA
}
CustomerType.GO_HOME -> {
viewModelScope.launch(Dispatchers.IO) {
sushiDao.getAll().let {
lastIndex = it.lastIndex
_totalData.postValue(it)
}
withContext(Dispatchers.Main) {
for (i in 0..lastIndex) {
_totalDish += _totalData.value?.get(i)?.orderHistory!!
_amountBill += _totalData.value?.get(i)?.price!!.toInt()
}
_orderImage.value = R.drawable.tsugaku
_orderText.value = ""
_cashDisplay.value = ""
delay(1000)
_orderImage.value = R.drawable.home_kitaku_girl
_orderText.value = "再入店"
_customerType.value = CustomerType.RE_ENTER
_cashDisplay.value =
"総皿数:${_totalDish}皿\n請求総額:¥${_amountBill}"
_tunaCount.value = 0
}
}
}
}
}
fun pay() {
when (_billText.value) {
"お会計" -> {
viewModelScope.launch(Dispatchers.IO) {
sushiDao.insertSushi(
Sushi(
0,
_tunaCount.value,
calcSushi(_tunaCount)
)
)
_cashDisplay.postValue("${_tunaCount.value}皿\n¥${calcSushi(_tunaCount)}")
withContext(Dispatchers.Main) {
_orderImage.value = R.drawable.message_okaikei_ohitori
_orderText.value = "帰る"
_billText.value = ""
_customerType.value = CustomerType.GO_HOME
}
}
}
}
}
private fun calcSushi(dishCount: MutableLiveData<Int>) = dishCount.value?.times(100).toString()
enum class CustomerType {
ENTER,
RE_ENTER,
EAT_TUNA,
COMPLETED_EAT,
GO_HOME
}
}
まず、@HiltViewModel,@Inject constructor(private val sushiDao: SushiDao)にて依存関係を追加しています。
これによって、板前さんと漁師の依存関係が出来上がります。
Roomの操作は、IOスレッドで行いそのネスト内にwithContextを用いてメインスレッドを走らせるとIOスレッドで、データ処理が終わってからMutableデータを変更する処理に移ることが出来ます。
いっちょあがり!!!
#終わりに
以上でお寿司屋で例えるAndroid Architecture Componentを終わりにします。
お疲れ様でした。
最後は、ドキュメント通りの設計でなくて申し訳ないです。
API叩く処理がないと、Repositoryを実装する意義があまり見いだせないのは知らなかったです。
さてと、ドキュメントを読みながらなんとか書き切りました。
書き始める前よりもMVVMについての理解が深まることで、正直な所、筆者が一番学びが多いと思います。
これからも、設計に対しての理解を深めていきたいなと思います。
書き方の至らない点を見つけたら、筆者のTwitterに通報してください。
ViewModel内の!!を回避したいです
ありがとうございました^^