初期コード解説
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { Teat1Theme { Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> Greeting( name = "Android", modifier = Modifier.padding(innerPadding) ) } } } } } |
① Activityを作る
class MainActivity : ComponentActivity() {
これは
「MainActivityという画面を作ります」
という意味です。
ComponentActivity
Androidの画面の基本クラスです。
昔は
AppCompatActivity
を使うことが多かったですが、
Jetpack Composeでは ComponentActivity が標準です。
② onCreate()
override fun onCreate(savedInstanceState: Bundle?) {
画面が最初に表示されるときに呼ばれる関数です。
例えば
- 初期化
- ボタンの設定
- 画面を作る
などをここで行います。
③ 親クラスの初期化
super.onCreate(savedInstanceState)
これは
「ComponentActivityの初期化もちゃんと実行してください」
という意味です。
これを書かないとActivityが正常に動きません。
④ Edge-to-Edge
enableEdgeToEdge()
Android15以降で推奨されている設定です。
画面を
┌───────────────┐
│ステータスバー│
├───────────────┤
│ │
│ アプリ │
│ │
├───────────────┤
│ナビゲーション│
└───────────────┘
ではなく
┌───────────────┐
│ステータスバー│
│ │
│ アプリ │
│ │
│ │
│ナビゲーション│
└───────────────┘
のように、
画面いっぱいまで描画する設定です。
iPhoneのSafe Areaに少し似ています。
⑤ Compose開始
setContent {
ここが一番重要です。
これは
「ここからComposeで画面を書きます」
という意味です。
昔のAndroidなら
setContentView(R.layout.activity_main)
でした。
ComposeではXMLを使わないため、
setContent { ... }
の中に画面を書きます。
⑥ テーマ
Teat1Theme {
これはアプリ全体のデザインです。
例えば
- 色
- フォント
- 角丸
- Material Design
などをまとめています。
SwiftUIなら
Environment
や
.tint()
に少し近いイメージです。
テーマを外しても画面は表示できますが、
Material Designらしい見た目ではなくなります。
⑦ Scaffold
Scaffold(
Scaffoldは
「画面の骨組み」
です。
例えば
┌────────────────────┐
│TopAppBar │
├────────────────────┤
│ │
│ │
│ コンテンツ │
│ │
├────────────────────┤
│BottomBar │
└────────────────────┘
を簡単に作れるコンポーネントです。
後から
- TopAppBar
- BottomBar
- FAB(丸いボタン)
- Snackbar
などを追加できます。
⑧ Modifier.fillMaxSize()
modifier = Modifier.fillMaxSize()
Modifierは
「見た目や配置を変更するもの」
です。
ここでは
fillMaxSize()
なので
画面いっぱいに広げる
という意味になります。
例えば
200×200
ではなく
画面全体
になります。
⑨ innerPadding
{ innerPadding ->
Scaffoldは
例えば
TopAppBar
や
BottomBar
の高さを考慮して
この分だけ余白を空けてね
という値を渡してくれます。
それが
innerPadding
です。
⑩ Greeting
Greeting(
これは自分で作ったComposable関数です。
例えば
@Composable
fun Greeting(name: String)
が別の場所にあります。
SwiftUIでいう
GreetingView()
のようなものです。
⑪ 引数
name = "Android",
Greetingへ
"Android"
という文字列を渡しています。
中では
Text("Hello $name")
のように使われます。
⑫ Padding
modifier = Modifier.padding(innerPadding)
ここはとても重要です。
Scaffoldから受け取った余白を適用しています。
もし書かなければ
┌──────────────┐
│TopAppBar │
│Hello Android │ ← 重なる
└──────────────┘
になってしまいます。
書くと
┌──────────────┐
│TopAppBar │
├──────────────┤
│Hello Android │
└──────────────┘
となり、バーの下からコンテンツが始まります。
簡素UIのコード解説
完成イメージはこんな感じです。
┌──────────────────────────┐
│ │
│ │
│ [ 画像 ] │
│ │
│ [ボタン1] [ボタン2] │
│ │
└──────────────────────────┘
① 画像を用意する
app > src > main > res > drawable
の中に画像を置きます。
例えば
logo.png
という名前にします。
ファイル名は 小文字・数字・アンダースコアのみ にしてください。
例
logo.png
app_icon.webp
title_image.png
② MainActivity
setContent の中を次のようにします。
setContent {
Teat1Theme {
MainScreen()
}
}
これで画面を MainScreen() に任せます。
③ MainScreenを作る
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
@Composable fun MainScreen() { Column( modifier = Modifier .fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { Image( painter = painterResource(id = R.drawable.logo), contentDescription = "ロゴ画像", modifier = Modifier.size(180.dp) ) Spacer(modifier = Modifier.height(32.dp)) Row( horizontalArrangement = Arrangement.spacedBy(16.dp) ) { Button( onClick = { } ) { Text("ボタン1") } Button( onClick = { } ) { Text("ボタン2") } } } } |
④ 必要なimport
Android Studioならほとんど自動で追加してくれますが、主にこれらを使っています。
|
1 2 3 4 5 6 7 8 9 |
import androidx.compose.foundation.Image import androidx.compose.foundation.layout.* import androidx.compose.material3.Button import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp |
コードの見方
まず画面全体を作っています。
Column(...)
Columnは
上
↓
↓
↓
下
へ並べるレイアウトです。
画面中央に配置
verticalArrangement = Arrangement.Center
これが
↑
中央
↓
へ移動させています。
横方向も中央
horizontalAlignment = Alignment.CenterHorizontally
これで
← 中央 →
になります。
画像表示
Image(
painter = painterResource(id = R.drawable.logo),
...
)
これが
res/drawable/logo.png
を表示しています。
SwiftUIなら
Image("logo")
とほぼ同じイメージです。
サイズ指定
Modifier.size(180.dp)
180×180dpで表示します。
Spacer
Spacer(
modifier = Modifier.height(32.dp)
)
これは
画像
(32dp空ける)
ボタン
という余白です。
SwiftUIなら
Spacer()
ではなく
.padding(.bottom, 32)
に近い役割です。
横並び
Row
Rowは
□□□□
と横に並べます。
SwiftUIなら
HStack
です。
ボタン間隔
Arrangement.spacedBy(16.dp)
で
[ボタン1] [ボタン2]
のように16dp空きます。
上記コードに画面遷移を追加
完成イメージはこんな感じになります。
MainScreen
┌──────────────────┐
│ │
│ 画像 │
│ │
│ [ボタン1][ボタン2]│
└──────────────────┘
│
│ ボタン1
▼
SettingsScreen
┌──────────────────┐
│ │
│ [ON/OFFスイッチ] │
│ │
│ test1 │
│┌────────────────┐│
││入力してください││
│└────────────────┘│
└──────────────────┘
① Navigation Composeを追加
build.gradle.kts (Module: app) に追加します。
|
1 2 3 |
dependencies { implementation("androidx.navigation:navigation-compose:2.9.1") } |
同期(Sync Now)してください。
② MainActivity
setContent {} を次のように変更します。
|
1 2 3 4 5 |
setContent { Teat1Theme { AppNavigation() } } |
③ Navigationを作成
新しく AppNavigation.kt を作ります。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
import androidx.compose.runtime.Composable import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController @Composable fun AppNavigation() { val navController = rememberNavController() NavHost( navController = navController, startDestination = "main" ) { composable("main") { MainScreen(navController) } composable("settings") { SettingsScreen() } } } |
ここでは
"main"→ 最初の画面"settings"→ 遷移先
を登録しています。
④ MainScreenを変更
引数に NavController を追加します。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
@Composable fun MainScreen( navController: NavController ) { Column( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { Image( painter = painterResource(R.drawable.logo), contentDescription = null, modifier = Modifier.size(180.dp) ) Spacer(modifier = Modifier.height(32.dp)) Row( horizontalArrangement = Arrangement.spacedBy(16.dp) ) { Button( onClick = { navController.navigate("settings") } ) { Text("ボタン1") } Button( onClick = { } ) { Text("ボタン2") } } } } |
ポイントはここです。
navController.navigate("settings")
この一行だけで画面遷移できます。
⑤ SettingsScreenを作る
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @Composable fun SettingsScreen() { var checked by remember { mutableStateOf(false) } var text by remember { mutableStateOf("") } Column( modifier = Modifier .fillMaxSize() .padding(24.dp), verticalArrangement = Arrangement.Center ) { Switch( checked = checked, onCheckedChange = { checked = it } ) Spacer(modifier = Modifier.height(24.dp)) OutlinedTextField( value = text, onValueChange = { text = it }, label = { Text("test1") }, placeholder = { Text("入力してください") }, modifier = Modifier.fillMaxWidth() ) } } |
⑥ 必要なimport
Android Studioがほぼ自動で追加してくれます。
|
1 2 |
import androidx.navigation.NavController import androidx.navigation.compose.* |
このコードで初めて出てくるもの
remember
var checked by remember {
mutableStateOf(false)
}
これは
「値を覚えておいてください」
というComposeの仕組みです。
例えば
false
↓
true
↓
false
と変更されても画面が更新されます。
SwiftUIなら
@State private var checked = false
とほぼ同じ役割です。
mutableStateOf
mutableStateOf("")
画面更新の対象になる変数です。
普通の
var text = ""
では画面は更新されません。
Composeでは
var text by remember {
mutableStateOf("")
}
とすることで、値が変わるたびにUIが自動で再描画されます。
OutlinedTextField
OutlinedTextField()
これは枠付きのテキスト入力欄です。
表示は
test1
┌────────────────────┐
│入力してください │
└────────────────────┘
となります。
戻るボタンとテキストやスイッチの状態を保存するコードの追加
① DataStoreを追加
build.gradle.kts
|
1 2 3 4 5 |
dependencies { implementation("androidx.datastore:datastore-preferences:1.1.7") } |
Syncしてください。
② DataStoreManager.kt
新しく作成します。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
package com.example.test1 import android.content.Context import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import kotlinx.coroutines.flow.map private val Context.dataStore by preferencesDataStore("settings") class DataStoreManager(private val context: Context) { companion object { val CHECKED = booleanPreferencesKey("checked") val TEXT = stringPreferencesKey("text") } val checkedFlow = context.dataStore.data.map { it[CHECKED] ?: false } val textFlow = context.dataStore.data.map { it[TEXT] ?: "" } suspend fun saveChecked(value: Boolean) { context.dataStore.edit { it[CHECKED] = value } } suspend fun saveText(value: String) { context.dataStore.edit { it[TEXT] = value } } } |
これだけで保存と読み込みができます。
③ SettingsScreen
引数を変更します。
|
1 2 3 4 |
@Composable fun SettingsScreen( navController: NavController ) |
DataStoreを取得
|
1 2 3 4 5 |
val context = LocalContext.current val dataStore = remember { DataStoreManager(context) } |
Flowを監視
Composeでは
val checked by dataStore.checkedFlow.collectAsState(false)
val text by dataStore.textFlow.collectAsState("")
これだけで
保存値
↓
UI
が自動同期されます。
これがComposeの便利なところです。
④ TopAppBarを追加
画面全体をScaffoldで囲みます。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
Scaffold( topBar = { TopAppBar( title = { Text("設定") }, navigationIcon = { IconButton( onClick = { navController.popBackStack() } ) { Icon( imageVector = Icons.Default.ArrowBack, contentDescription = "戻る" ) } } ) } ) { padding -> Column( modifier = Modifier .padding(padding) .padding(24.dp) .fillMaxSize() ) { //ここにSwitchなどを書く } } |
戻るボタンは
navController.popBackStack()
だけです。
⑤ Switch
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
Switch( checked = checked, onCheckedChange = { CoroutineScope(Dispatchers.IO).launch { dataStore.saveChecked(it) } } ) |
保存すると
DataStore
↓
Flow
↓
画面更新
が全部自動です。
⑥ TextField
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
OutlinedTextField( value = text, onValueChange = { CoroutineScope(Dispatchers.IO).launch { dataStore.saveText(it) } }, label = { Text("test1") }, placeholder = { Text("入力してください") } ) |
入力するたび保存されます。
⑦ Navigation修正
|
1 2 3 4 5 |
composable("settings") { SettingsScreen(navController) } |
MainScreenは変更ありません。
もう少しComposeらしく書く方法
ここまでで十分動作しますが、Composeでは**CoroutineScope(Dispatchers.IO)を毎回作る書き方はあまり一般的ではありません**。
通常は
val scope = rememberCoroutineScope()
として、
scope.launch {
dataStore.saveChecked(it)
}
のように書くことが多いです。
上記のDataStoreManagerの解説
この DataStoreManager は最初は少し難しく感じますが、**「設定ファイルを読み書きするクラス」**だと思うと理解しやすいです。
まず全体像から見てみましょう。
SettingsScreen
│
│ saveChecked(true)
▼
DataStoreManager
│
▼
DataStore(settings.preferences_pb)
▲
│
checkedFlow
│
▼
SettingsScreen
つまり、
SettingsScreenが保存をお願いするDataStoreManagerがDataStoreへ保存- DataStoreが変更される
checkedFlowが変更を通知- 画面が自動更新
という流れです。
① DataStoreを作る
private val Context.dataStore by preferencesDataStore("settings")
これが一番分かりにくいですね(笑)
実は
Context.dataStore
という新しいプロパティを追加しています。
イメージすると
val context: Context
context.dataStore
が使えるようになります。
例えば
context.dataStore.edit { }
のように書けるようになります。
"settings"
preferencesDataStore("settings")
これは保存ファイル名です。
実際には内部で
settings.preferences_pb
というファイルになります。
SwiftUIなら
UserDefaults.standard
に近い存在です。
② クラス
class DataStoreManager(private val context: Context)
ここは
Contextを受け取ってDataStoreを操作するクラス
という意味です。
例えば
val manager = DataStoreManager(context)
とすると
manager
の中で
context.dataStore
が使えます。
③ companion object
companion object {
これは
クラス共通の定数置き場
です。
Javaなら
public static
Swiftなら
static let
に相当します。
④ キーを作る
val CHECKED = booleanPreferencesKey("checked")
これは
checked
という名前で保存するという意味です。
例えばDataStoreの中は
checked = true
になります。
もう一つは
val TEXT = stringPreferencesKey("text")
なので
text = "こんにちは"
のように保存されます。
つまり
DataStore
checked = true
text = Hello
という辞書(Dictionary)みたいなものになります。
⑤ checkedFlow
val checkedFlow =
context.dataStore.data.map {
it[CHECKED] ?: false
}
ここは一番重要です。
まず
context.dataStore.data
は
DataStoreの内容
を監視しています。
つまり
変更があれば教えてね
という状態です。
次の
map {
は
欲しい値だけ取り出す
という意味です。
例えばDataStoreが
checked = true
text = abc
だったら
checkedだけ欲しい
となります。
it 説明リンク
it[CHECKED]
の
it
は
DataStore全体
です。
つまり
it
├ checked
└ text
になります。
そこから
it[CHECKED]
で
checkedだけ取得
しています。
?: false
it[CHECKED] ?: false
これは
もし存在しなければfalse
という意味です。
保存したことが無い最初の起動では
checked
はありません。
なので
false
を返します。
⑥ textFlow
こちらも同じです。
val textFlow =
context.dataStore.data.map {
it[TEXT] ?: ""
}
最初だけ
空文字
になります。
⑦ suspend
suspend fun saveChecked(...)
これは
時間の掛かる処理
という意味です。
保存処理は
- ファイルを開く
- 書き込む
- 閉じる
という作業なので、
普通の関数ではなく
suspend
になります。
Swiftなら
async
とかなり似ています。
⑧ edit
context.dataStore.edit {
これは
設定を書き換えます
という意味です。
中のit
it[CHECKED] = value
この
it
は
Preferences
です。
つまり
設定一覧
になります。
例えば
変更前
checked = false
text = Hello
↓
変更後
checked = true
text = Hello
になります。
⑨ saveText
it[TEXT] = value
こちらも同じです。
例えば
value = "Android"
なら
checked = true
text = Android
になります。
全体の流れ
例えばスイッチをONにすると
Switch
│
▼
saveChecked(true)
│
▼
DataStore.edit
│
▼
checked = true
│
▼
checkedFlowが変更を検知
│
▼
Composeが再描画
│
▼
SwitchがONになる
MVVMの概念
まず、現在の問題点
今の SettingsScreen はこんな仕事をしています。
SettingsScreen
│
├ UIを表示
├ DataStoreを作る
├ 保存する
├ 読み込む
└ 状態を管理する
つまり…
仕事が多すぎます。
本来UIは
「表示するだけ」
にしたいです。
MVVMとは?
MVVMは役割を分けます。
ユーザー
│
▼
SettingsScreen
(View)
│
▼
SettingsViewModel
(状態管理)
│
▼
DataStoreManager
(データ保存)
役割はこのようになります。
View(画面)
・表示するだけ
・ボタンが押されたことを知らせる
ViewModel
・画面の状態を持つ
・保存する
・読み込む
・UIへ通知する
Model
今回は
DataStoreManager
です。
保存専門です。
ディレクトリ構成
私は最初からこの構成にすることをおすすめします。
com.example.test
MainActivity.kt
navigation/
AppNavigation.kt
ui/
MainScreen.kt
SettingsScreen.kt
viewmodel/
SettingsViewModel.kt
data/
DataStoreManager.kt
SwiftUIでもフォルダを分けていましたよね。
それとほぼ同じです。
ViewModelを作る
まず
viewmodel/
フォルダを作ります。
その中に
SettingsViewModel.kt
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
class SettingsViewModel( private val dataStore: DataStoreManager ) : ViewModel() { val checked = dataStore.checkedFlow .stateIn( viewModelScope, SharingStarted.WhileSubscribed(5000), false ) val text = dataStore.textFlow .stateIn( viewModelScope, SharingStarted.WhileSubscribed(5000), "" ) fun setChecked(value: Boolean) { viewModelScope.launch { dataStore.saveChecked(value) } } fun setText(value: String) { viewModelScope.launch { dataStore.saveText(value) } } } |
このコードの仕事
例えば
fun setChecked(value: Boolean)
は
画面から
ONになりました!
と言われたら
DataStoreへ保存
するだけです。
つまり
View
↓
ViewModel
↓
DataStore
です。
viewModelScope
これもComposeで超重要です。
以前は
CoroutineScope(Dispatchers.IO).launch {
}
と書いていました。
でもViewModelでは
viewModelScope.launch {
}
だけです。
理由は
ViewModelが破棄されると
Coroutineも自動終了
するからです。
メモリリークを防げます。
stateIn
これもよく出てきます。
DataStoreは
Flow
でした。
でもUIは
State
が欲しいです。
なので
Flow
↓
StateFlow
へ変換しています。
これが
stateIn(...)
です。
SettingsScreen
すると画面はかなりシンプルになります。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
@Composable fun SettingsScreen( viewModel: SettingsViewModel ) { val checked by viewModel.checked.collectAsState() val text by viewModel.text.collectAsState() Column { Switch( checked = checked, onCheckedChange = { viewModel.setChecked(it) } ) OutlinedTextField( value = text, onValueChange = { viewModel.setText(it) }, label = { Text("test1") } ) } } |
画面は
表示だけ
になりました。
保存のことを知りません。
処理の流れ
例えば
スイッチをONにすると
Switch
│
▼
viewModel.setChecked(true)
│
▼
DataStore.saveChecked(true)
│
▼
DataStore更新
│
▼
checkedFlow更新
│
▼
checked StateFlow更新
│
▼
画面更新
すごくきれいですね。
Repositoryの概念
Repositoryとは?
一言でいうと
「データの窓口」
です。
現在の構成はこうでした。
SettingsScreen
│
▼
SettingsViewModel
│
▼
DataStoreManager
ViewModelが直接DataStoreを触っていますね。
これの問題
例えば将来、
設定を
- DataStore
- Room
- Web API
の3か所から取得したいとします。
するとViewModelは
SettingsViewModel
├ DataStore
├ Room
└ API
全部知ることになります。
これではViewModelの仕事が増えすぎます。
Repositoryを入れる
そこでRepositoryを1枚挟みます。
SettingsScreen
│
▼
SettingsViewModel
│
▼
SettingsRepository
│
┌──────┴─────────┐
▼ ▼
DataStore Room
│
▼
API
ViewModelは
Repositoryだけ知っていればOK
になります。
Repositoryの役割
Repositoryは
データはどこから来たか
を隠す
役目です。
例えば
viewModel.setChecked(true)
↓
repository.saveChecked(true)
↓
Repositoryが
DataStoreへ保存
するのか
Roomへ保存
するのか
APIへ送信
するのか
ViewModelは知りません。
フォルダ構成
この辺からAndroidらしくなります。
data/
DataStoreManager.kt
repository/
SettingsRepository.kt
viewmodel/
SettingsViewModel.kt
ui/
SettingsScreen.kt
Repositoryを書いてみる
まず
repository/
を作ります。
中に
SettingsRepository.kt
class SettingsRepository(
private val dataStore: DataStoreManager
) {
val checkedFlow = dataStore.checkedFlow
val textFlow = dataStore.textFlow
suspend fun saveChecked(value: Boolean) {
dataStore.saveChecked(value)
}
suspend fun saveText(value: String) {
dataStore.saveText(value)
}
}
え?
これだけ?
と思いますよね(笑)
最初はこれだけです。
ViewModel変更
今までは
private val dataStore: DataStoreManager
でした。
これを
private val repository: SettingsRepository
へ変更します。
読み込みも
repository.checkedFlow
になります。
保存も
repository.saveChecked(value)
になります。
DataStoreを交換してもViewModelは変わらない
例えば将来
DataStoreではなく
Roomへ変更したい。
Repositoryだけ
suspend fun saveChecked(value: Boolean){
roomDao.save(value)
}
へ変更します。
ViewModelは
viewModel.setChecked(true)
のままです。
UIも変更なし。
Repositoryが一番活躍する例
例えば
プロフィール画面。
名前
メール
年齢
を表示します。
データは
Room
にあります。
でも
ネットに新しいデータがあるか確認
↓
あれば更新
↓
Roomへ保存
↓
Roomから読む
という流れにしたい。
Repositoryなら
Repository
↓
APIから取得
↓
Roomへ保存
↓
Roomから取得
↓
ViewModelへ返す
と全部まとめられます。
ViewModelは
repository.loadProfile()
しか知りません。
SwiftUIとの比較
SwiftUIでも
View
↓
ViewModel
↓
Repository
↓
API
という構成を採用しているプロジェクトは多いです。
なので考え方はほぼ同じです。
「これだけならRepositoryいらないのでは?」
その通りです。
今回のサンプルでは
DataStoreManager
だけでも十分です。
しかし実際のアプリでは、
- 設定をクラウドと同期する
- ローカルキャッシュを持つ
- API通信に失敗したらローカルデータを返す
- 複数のデータソースを組み合わせる
といったことが増えてきます。
そのときRepositoryがあると、ViewModelは「データの取得・保存をRepositoryに依頼する」だけで済みます。
Hilt(依存性注入)
まず「依存性注入(DI)」って何?
名前が難しいですが、実はやっていることはシンプルです。
今までのコードでは、自分で必要なものを作っていました。
val dataStore = DataStoreManager(context)
val repository = SettingsRepository(dataStore)
val viewModel = SettingsViewModel(repository)
図にすると
自分で作る
DataStoreManager
│
▼
SettingsRepository
│
▼
SettingsViewModel
これを**依存性注入(Dependency Injection:DI)**と呼ばれる仕組みで自動化します。
Hiltを使うと…
自分では何も作りません。
val viewModel: SettingsViewModel = hiltViewModel()
これだけ。
Hiltが裏で
DataStoreManager
↓
SettingsRepository
↓
SettingsViewModel
全部作ってくれます。
なぜ便利なの?
例えばRepositoryが大きくなると…
class SettingsRepository(
private val dataStore: DataStoreManager,
private val api: ApiService,
private val room: SettingsDao,
private val logger: Logger,
private val analytics: Analytics
)
ViewModelを作るだけでも
SettingsViewModel(
SettingsRepository(
DataStoreManager(context),
ApiService(),
Room.databaseBuilder(...),
Logger(),
Analytics()
)
)
になってしまいます。
これ、大変ですよね(笑)
Hiltなら
ViewModelください
↓
はい!
↓
ViewModel
↓
Repository
↓
DataStore
↓
API
↓
Room
全部勝手に準備してくれます。
Hiltの仕組み
Hiltは
「どうやって作るか」
だけ教えてあげます。
① Application
まず
@HiltAndroidApp
class MyApplication : Application()
を作ります。
これは
このアプリはHiltを使います!
という宣言です。
AndroidManifest.xml
<application
android:name=".MyApplication"
も追加します。
② build.gradle
依存関係を追加します。
implementation("com.google.dagger:hilt-android:2.xx")
ksp("com.google.dagger:hilt-compiler:2.xx")
ComposeではKSP版を使うことが多いです。
③ DataStoreManager
ここが面白いです。
今までは
class DataStoreManager(
private val context: Context
)
でした。
Hiltでは
class DataStoreManager @Inject constructor(
@ApplicationContext
private val context: Context
)
になります。
たった
@Inject
が付いただけ。
意味は
「Hilt、このクラスは作っていいですよ。」
です。
④ Repository
こちらも
class SettingsRepository @Inject constructor(
private val dataStore: DataStoreManager
)
になります。
するとHiltは
Repository作るには
↓
DataStoreが必要
↓
DataStoreも作れる
↓
OK
となります。
⑤ ViewModel
ここも
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val repository: SettingsRepository
) : ViewModel()
になります。
ここまで来ると
ViewModel
↓
Repository
↓
DataStore
全部自動生成できます。
⑥ Activity
Activityには
@AndroidEntryPoint
class MainActivity : ComponentActivity()
を付けます。
これで
val viewModel: SettingsViewModel by viewModels()
が使えます。
Composeなら
val viewModel: SettingsViewModel = hiltViewModel()
です。
全体の流れ
今までは
MainActivity
↓
new ViewModel
↓
new Repository
↓
new DataStore
でした。
Hiltでは
MainActivity
↓
ViewModelください
↓
Hilt
↓
DataStore作成
↓
Repository作成
↓
ViewModel作成
↓
返却
になります。
SwiftUIとの比較
SwiftUIにも似た考え方があります。
例えば
@EnvironmentObject
var settings: SettingsViewModel
これも
必要なものを自分で作らず、「注入」してもらう
という点では、Hiltと近い発想です。
ただし、HiltはViewModelだけでなく、RepositoryやDataStoreなどの依存関係までまとめて管理できるのが大きな違いです。
「@」がたくさん出てきた…
Android初心者の多くがここで戸惑います(笑)
Hiltでは
@HiltAndroidApp
@AndroidEntryPoint
@Inject
@HiltViewModel
@ApplicationContext
とアノテーション(@が付くもの)が増えます。
最初は「おまじない」のように見えますが、それぞれ役割があります。
| アノテーション | 役割 |
|---|---|
@HiltAndroidApp | アプリ全体でHiltを有効にする |
@AndroidEntryPoint | このActivityやFragmentでHiltを使う |
@Inject | このクラスやコンストラクタはHiltが生成できる |
@HiltViewModel | このViewModelはHiltが管理する |
@ApplicationContext | アプリ全体のContextを注入する |
Room
まずSQLiteとは?
Androidには昔から SQLite というデータベースが入っています。
例えばメッセージアプリなら、
Message
id | user | text
--------------------------
1 | A | こんにちは
2 | B | おはよう
3 | A | 元気?
このような表(テーブル)としてデータを保存できます。
昔はSQLiteを直接操作していました。
db.execSQL(...)
db.rawQuery(...)
しかし、
- SQLを書く必要がある
- 型安全ではない
- コードが長くなる
という欠点がありました。
Roomとは?
Roomは一言でいうと
SQLiteを簡単・安全に扱うためのライブラリ
です。
つまり
Compose
│
ViewModel
│
Repository
│
Room
│
SQLite
RoomがSQLiteを包んでくれています。
SwiftUIでいうと
Core Data や SwiftData に近い存在だと思うとイメージしやすいです。
DataStoreとの違い
ここが重要です。
| DataStore | Room |
|---|---|
| 設定保存 | データ保存 |
| bool | ○ |
| String | ○ |
| 一覧データ | ✕ |
| 検索 | ✕ |
| 並び替え | ✕ |
| テーブル | ✕ |
| SQL | ✕ |
| メッセージ保存 | △ |
| ゲームセーブ | ◎ |
例えば
ダークモード ON
ならDataStore。
でも
メッセージ1000件
ならRoomです。
Roomの基本は3つ
Roomは次の3つだけ覚えれば始められます。
Entity
↓
DAO
↓
Database
① Entity
Entityは
テーブル定義
です。
例えば
@Entity
data class Message(
@PrimaryKey(autoGenerate = true)
val id: Int = 0,
val user: String,
val text: String
)
これだけで
SQLiteには
Message
id
user
text
というテーブルが作られます。
Swiftなら
struct Message
に近い存在です。
② DAO
DAOは
データの操作方法
です。
例えば
@Dao
interface MessageDao {
@Insert
suspend fun insert(
message: Message
)
@Query("SELECT * FROM Message")
fun getAll(): Flow<List<Message>>
}
これだけで
dao.insert(...)
や
dao.getAll()
が使えます。
SQLは
SELECT * FROM Message
だけ書けばOKです。
③ Database
最後に
@Database(
entities = [Message::class],
version = 1
)
abstract class AppDatabase : RoomDatabase() {
abstract fun messageDao(): MessageDao
}
を書きます。
これで
AppDatabase
↓
MessageDao
↓
Message
がつながります。
Repository
ここからがMVVMらしいところです。
Repositoryは
class MessageRepository(
private val dao: MessageDao
)
となります。
保存は
suspend fun addMessage(
message: Message
){
dao.insert(message)
}
読み込みは
val messages = dao.getAll()
だけです。
ViewModel
ViewModelは
class MessageViewModel(
repository: MessageRepository
)
になります。
val messages =
repository.messages
画面は
viewModel.messages
だけ見ればOK。
Compose
画面では
val messages by
viewModel.messages.collectAsState()
として、
LazyColumn {
items(messages){
Text(it.text)
}
}
これだけで
こんにちは
おはよう
元気?
が表示されます。
全体の流れ
メッセージを送信すると
Button
↓
ViewModel
↓
Repository
↓
DAO
↓
Room
↓
SQLite
になります。
逆に読み込むと
SQLite
↓
Room
↓
DAO
↓
Repository
↓
ViewModel
↓
Compose
になります。
コメント