はじめに
本記事では、ソフトウェアアーキテクチャの一種であるMVVMについて自分用にまとめておきます。また、私が現在開発中のアプリにこれを当てはめ、アプリ内のどの部分がMVVMのどの要素に対応しているかをまとめてみたいと思います。投稿者はAndroid開発初学者であるため、内容に誤りや不足がある可能性もあります。コメントでご指南、ご指摘いただければ幸いです。
MVVMとは何か
MVVMとは、UIを持つソフトウェアに適用されるソフトウェアアーキテクチャの一種です。MVVMでは、ソフトウェアをModel-View-ViewModelの3要素に分割してアプリを設計します。
ViewはViewModelからデータを受け取り、UIを表示する役割があります。また、ユーザーがボタンのクリックなどのイベントを起こした際にはそれをViewModelに伝える役割があります。
ViewModelはViewとModelの仲介役となります。Viewから送られてきたイベントの情報をもとにModelに対してデータの取得や更新などの処理を行います。また、ModelのデータをUI描画に必要な形でViewに送る役割も持っています。
Modelはアプリケーションのデータとビジネスロジックを管理します。データの取得や保存などを行い、ViewModelからのリクエストに応じて必要なデータを提供します。
アプリの概要
私が現在開発中のアプリは、読書の情報を一元管理し、ユーザーの読書をサポートするアプリです。ユーザーはタイトルを検索して書籍を登録し、登録した書籍に対しては現在の状態(未読、読書中、読了の3種類)の管理や読了ページ数の変更、感想の記入などを行うことができます。詳細は以下のリポジトリをご覧ください。
ReadTrack
View
役割
- UIの表示・更新を行う
- ユーザーの操作の検知
Viewはユーザーにデータを表示し、ユーザーの操作を検知してViewModelに渡します。
具体例
Jetpack Composeにおいては、Composable関数がこのViewにあたります。
ここでは、例として登録した書籍の一覧を表示する画面を描画するコンポーザブル関数を示します。
LibraryScreen.kt
@Composable
fun LibraryScreen(
navController: NavController,
savedBooksViewModel: SavedBooksViewModel
) {
val configuration = LocalConfiguration.current
val screenWidthDp = configuration.screenWidthDp.dp
val savedBooks by savedBooksViewModel.savedBooks.collectAsState()
var selectedTabIndex by remember { mutableStateOf(0) }
Column {
TabRow(
selectedTabIndex = selectedTabIndex,
contentColor = Color.Blue,
modifier = Modifier.fillMaxWidth()
) {
Tab(
selected = selectedTabIndex == 0,
onClick = { selectedTabIndex = 0 },
text = { Text("未読") }
)
Tab(
selected = selectedTabIndex == 1,
onClick = { selectedTabIndex = 1 },
text = { Text("読書中") }
)
Tab(
selected = selectedTabIndex == 2,
onClick = { selectedTabIndex = 2 },
text = { Text("読了") }
)
}
LazyVerticalGrid(columns = GridCells.Adaptive(screenWidthDp / 3)) {
// ここでselectedTabIndexに応じて表示する本のリストを変える
val filteredBooks = when (selectedTabIndex) {
0 -> savedBooks.filter { it.progress == 0 }
1 -> savedBooks.filter { it.progress == 1 }
2 -> savedBooks.filter { it.progress == 2 }
else -> savedBooks
}
items(filteredBooks) { book ->
Column {
AsyncImage(
model = book.thumbnail,
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.clickable {
savedBooksViewModel.selectBook(book.id)
navController.navigate("${ReadTrackScreen.MyBook.name}/${book.id}")
}
)
LinearProgressIndicator(
progress = book.readpage!!.toFloat() / book.pageCount!!.toFloat(),
modifier = Modifier.fillMaxWidth().padding(16.dp)
)
}
}
}
}
}
また、AsyncImage()関数内で本の画像がクリックされたときに、savedBooksViewModelに対してクリックされた本のidの情報を渡すようにしています。これにより、本の画像をクリックしたときにその本の詳細な情報を表示する画面に遷移することができます。
ViewModel
役割
- Viewを描画するための状態を保持する
- Viewから送られてきた値を保存するためのメソッドを呼ぶ
ViewModelは、Modelからデータを取得し、Viewに必要な形式で提供します。また、Viewからイベントを受け取り、Modelに指示を送る役割も担当しています。
具体例
SavedBooksViewModel.kt
class SavedBooksViewModel(private val booksRepository: BooksRepository) : ViewModel() {
val savedBooks: StateFlow<List<BookData>> = booksRepository.getAllBooksFlow()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList())
private val _selectedBook = MutableStateFlow<BookData?>(null)
val selectedBook: StateFlow<BookData?> = _selectedBook
fun selectBook(savedbookId: String) {
_selectedBook.value = savedBooks.value.find { it.id == savedbookId }
}
fun fetchBookDetails(bookId: String) {
viewModelScope.launch {
try {
val book = booksRepository.getBookById(bookId) // Fetch book from repository
_selectedBook.value = book
} catch (e: Exception) {
// Handle error
}
}
}
fun updateBook(book: BookData) {
viewModelScope.launch {
booksRepository.updateBook(book)
}
}
fun deleteBook(book: BookData) {
viewModelScope.launch {
booksRepository.deleteBook(book)
}
}
}
Model
役割
- データの永続化
- ネットワーク、APIなどを利用したデータの取得
Modelはアプリケーションのデータやビジネスロジックを担っています。データベースやネットワーク通信などの処理を含み、純粋にデータ管理を担当します。
具体例
ここではデータクラスを記述するBook.ktを例示します。
Book.kt
// BookItemのリストを格納するデータクラス
data class BookLists(
val items: List<BookItem>?
)
// APIから取得したデータを格納するデータクラス
data class BookItem(
val id: String,
val selfLink: String,
val volumeInfo: VolumeInfo
)
data class VolumeInfo(
val title: String,
val authors: List<String>?,
val publisher: String?,
val publishedDate: String?,
val description: String?,
val imageLinks: ImageLinks = ImageLinks("", ""),
val pageCount: Int?,
val categories: List<String>?,
)
data class ImageLinks(
val smallThumbnail: String,
val thumbnail: String
)
// データベースに保存するためのデータクラス
// BookItemはネストされたデータクラスであるため、Roomでのエラーを回避するために分離する
@Entity(tableName = "BookData")
data class BookData(
@PrimaryKey val id: String,
val title: String,
val author:String,
val publisher: String?,
val publishedDate: String?,
val description: String?,
val thumbnail: String,
val pageCount: Int?,
var readpage: Int? = 0, //読んだページ数
var comment: String? = "", //本に対するコメント、感想
var progress: Int = 0, //登録された本の状態(0:未読,1:読書中,2:読了)
var registeredDate: String = "", //本が登録された日付
var updatedDate: String = "" //本の情報が更新された日付
)
まとめ
本記事では、ざっくりとMVVMの概要とそのコード例について示しました。
引き続きアーキテクチャについて勉強し、よい設計ができるよう努力したいと思います。
誤っている点などありましたらご指摘いただけますと幸いです。