機能 #167
未完了Androidアプリケーション(検温記録)をJetPackを用いたMVVM構造で作成する(Kotlin編)
80%
説明
[#165]の仕様で、但しアプリケーション構造をMVVM構造とし、ライブラリはJetPackで提供されるものからMVVM構造に必要なものを選択して使用する。
Kotlin言語で実装する。
結果¶
- リポジトリ
source:learn/android/TempRecorderJetpackKt - Wikiページ
Androidプログラミング-RecyclerView
Androidプログラミング-Room
Androidプログラミング-ViewModel
- 使用するJetpackライブラリ
ViewBindingDataBinding- ViewModel
- Repository
- Room
- LiveData
今後の課題¶
- コンテントプロバイダを提供するにはどうする?
Sliceが関係ありそう?
- Android Jetpack: Android Slices (Part-1) Introduction
https://proandroiddev.com/android-jetpack-android-slices-introduction-cf0ce0f3e885 - I/O Recap : Slices
http://y-anz-m.blogspot.com/2018/05/
LiveDataとの組み合わせ
- Android LiveData and Content Provider updates
https://medium.com/@jmcassis/android-livedata-and-content-provider-updates-5f8fd3b2b3a4
ファイル
高橋 徹 さんが約4年前に更新
- ファイル clipboard-202009271927-utmdc.png clipboard-202009271927-utmdc.png を追加
- ファイル clipboard-202009271931-avwjv.png clipboard-202009271931-avwjv.png を追加
- ファイル clipboard-202009271941-ongzy.png clipboard-202009271941-ongzy.png を追加
- ファイル clipboard-202009271943-byq7c.png clipboard-202009271943-byq7c.png を追加
- ファイル clipboard-202009271944-ez5dt.png clipboard-202009271944-ez5dt.png を追加
- ファイル clipboard-202009271949-ymyvj.png clipboard-202009271949-ymyvj.png を追加
- ファイル clipboard-202009271950-uitlj.png clipboard-202009271950-uitlj.png を追加
- ファイル clipboard-202009272128-oekmu.png clipboard-202009272128-oekmu.png を追加
- 説明 を更新 (差分)
- ステータス を 新規 から 進行中 に変更
- 進捗率 を 0 から 50 に変更
h4 新規プロジェクトを作成
- Android Studioで新規プロジェクト作成、名前は TempRecorderJetpackKt とし、言語はKotlin
- リポジトリに、ブランチ features/167 を作成し、初期生成物をコミット
e44af410 - デバイス(Pixel 3)で実行 → OK
- Gradleのバージョンをデフォルトの6.1.1から6.6.1(最新安定版)に変更
[File]メニュー > [Project Structure]で、左側ペイン[Project]を選択、右側ペインのGradle Version欄を6.6.1に変更
画面(activity_main.xml)の定義¶
- XMLのリソース名(ID名)は、小文字スネークケースが一貫性ありなので、[#165]、[#166]でキャメルケースとしたID名を今回はスネークケースで記述(<WHAT>_<WHERE>_<DESCRIPTION>スタイル)
textview_main_submittitle textview_main_measuredat button_main_incminite button_main_decminite
- design画面で2つのbuttonを水平に均等に並べる
まず、揃える対象の部品を複数選択状態にする
縦方向の中心で揃える(Vertical Centers)か、テキストのベースラインで揃える(Baselines)かどちらかを選択
- 注)TextViewのように文字のみ表示する場合はBaselineが綺麗に揃い、外縁の枠などが表示される部品でそれぞれ高さが同じ場合は縦方向の中心合わせが綺麗に揃うと思われる
縦方向の中心で揃える(Vertical Centers)を選択
次に、この2つのボタンを水平方向のChain設定をする。複数選択状態のまま右クリックし、[Chains] > [Create Horizontal Chain]を選択
垂直方向の制約を指定するため、どちらかのボタン(今回は左側)を1つ選択状態とし、上辺の制約をTextViewの下辺の制約に接続する。間隔は16dpあたりを設定する。
- 実行時に動的にtextプロパティが設定される部品
既に配置した TextView部品(textview_main_measuredat)は、実行時にプログラムから日時が設定されます。
デザイン時には、レイアウトのために仮の日時を入れておきたいので、android:textではなくtools:textに仮の日時を入れます。
<TextView
android:id="@+id/textview_main_measuredat"
:
tools:text="09.27 21:22"
- 検温値を入れるNumberPickerと登録のButton、それから検温記録を一覧表示するRecyclerView の配置は [#166]と同様なので略
高橋 徹 さんが約4年前に更新
- 説明 を更新 (差分)
View周りのコード実装を行う
- TextView(日時表示)で現在日時を表示する
- NumberPickerの上限、下限値を設定する
- RecyclerView で空の時の表示(余力があればする、なくても可)
JetPack時代では、findViewByIdは使わず、DataBindingもしくはViewBindingを使ってXMLでレイアウトした部品とコードを紐づける。
[#166]では、Kotlin固有の仕組みで実現していたが、Javaでも使える仕組みを追究する。DataBindingは、レイアウトXMLに手を入れる(ルート要素をDataBainding用の<Layout>とする)ほか、ビルドが遅くなるとの意見も見かけるのでViewBindingを使って実装する。
- appモジュールのbuild.gradleに追記
android { : + + viewBinding { + enabled true + } }
build.gradleを修正したら、[Sync Now]をクリック、しばらくすると次のメッセージ(Info)が・・・
⚠
build.gradle: DSL element 'android.viewBinding.enabled' is obsolete and has been replaced with 'android.buildFeatures.viewBinding'.
It will be removed in version 5.0 of the Android Gradle plugin.
- appモジュールのbuild.gradleの追記を次に差し替え
android { : + + buildFeatures { + viewBinding true + } }
- MainActivity.kt の修正
+import com.torutk.android.temprecorder.jetpackkt.databinding.ActivityMainBinding class MainActivity : AppCompatActivity() { + private lateinit var binding: ActivityMainBinding override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + + with (binding.numberpickerMainIntegral) { + minValue = 35 + maxValue = 40 + value = 36 + wrapSelectorWheel = false + } + with (binding.numberpickerMainFraction) { + minValue = 0 + maxValue = 9 + value = 5 + }
日時の保持は、Activity ではなく ViewModel に保持することとする。
高橋 徹 さんが約4年前に更新
ViewModelの作成(1)¶
日時を保持し、日時を通知する機能を実装する。
通知機能はモデルとビューを分離する設計では必須であり、その手段としてJetPackではDataBindingとLiveDataの2つの仕組みが提供されている。ViewModelはLiveDataと組み合わせるのが定番なようで、そちらで実装する。
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import java.time.LocalDateTime
class MainViewModel : ViewModel() {
val measuredAt: MutableLiveData<LocalDateTime> = MutableLiveData(LocalDateTime.now())
}
- ViewModelを継承
- MVVMのV(View)であるMaintActivityに対応するVM(View Model)とするので命名はVに合わせてMainViewModelとした
- V側からVMのデータを変更するユースケースがあるので、可変のMutableLiveDataを利用
ViewModelを利用するMainActivity側の追加コードは次
override fun onCreate(savedInstanceState: Bundle?) {
:
val measuredAtObserver = Observer<LocalDateTime> {
binding.textviewMainMeasuredat.text = it.format(DATE_TIME_VIEW_FORMATTER)
}
model.measuredAt.observe(this, measuredAtObserver)
変更通知された日時データをTextViewに所定のフォーマットで表示するオブザーバを作成し、ViewModelの日時データ(LiveData)プロパティに登録
Activityが表示されるタイミング(onResume)でViewModelの日時データを現在日時に変更する。変更が入ると上述のオブザーバーが通知を受けるので表示が変更された日時に更新される。
override fun onResume() {
super.onResume()
model.measuredAt.value = LocalDateTime.now()
}
ボタンを操作するとViewModelの日時データを変更する。
binding.buttonMainIncminite.setOnClickListener {
model.measuredAt.value = model.measuredAt.value?.plusMinutes(10)
}
binding.buttonMainDecminite.setOnClickListener {
model.measuredAt.value = model.measuredAt.value?.minusMinutes(10)
}
ViewModel側に増減メソッドを設けた方がよかったかも。
高橋 徹 さんが約4年前に更新
リポジトリとデータベース¶
JetPackでは、SQLiteデータベースのアクセスをRoomライブラリ(ORM)で行う。
Roomで検温テーブル¶
Entity、Dao、Database の責務を持つクラスをそれぞれ用意する。
まずはEntityを担うクラス。これはSQLiteテーブルのスキーマとのマッピングを表現し、このクラスのインスタンスは1行のレコードを保持する。値を表す型なので、data class としている。
- Entityクラス Temperature.kt
@Entity(tableName = "Temperatures") data class Temperature( @PrimaryKey(autoGenerate = true) @NonNull var id: Long = 0L, @ColumnInfo(name = "measured_at") val measuredAt: LocalDateTime, @ColumnInfo(name = "measurement") val measurement: Float )
タイムスタンプ(日時)は、@TypeConverterメソッドを定義し、java.time.LocalDateTimeとデータベースに格納可能な型(今回はString)との相互変換を用意する。このクラスはDatabaseを担うクラスで使われる。
- LocalDateTimeConverter.kt
class LocalDateTimeConverter { @TypeConverter fun fromLocalDateTime(dateTime: LocalDateTime): String { return dateTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) } @TypeConverter fun toLocalDateTime(dateTimeText: String): LocalDateTime { return LocalDateTime.parse(dateTimeText) // Default format is ISO_LOCAL_DATE_TIME } }
Daoインタフェースを定義する。Daoは、テーブルのCRUDインタフェース(メソッド)を表現する。
今回は最低限必要なC(Create)とR(Read)を実装する。
@Dao
interface TemperatureDao {
@Insert
fun insert(temperature: Temperature)
@Query("SELECT * FROM Temperatures ORDER BY id DESC")
fun getAllTemperatures(): LiveData<List<Temperature>>
}
Databaseの責務のクラスを生成する。RoomDatabaseを継承し、プロパティにDAOを定義すれば最低限の実装となる。
ただし、通常はシングルトンパターンで実装する(インスタンス化にコストがかかるため)。
internal const val DATABASE_FILE_NAME = "temperatures_db"
@Database(entities = [TemperatureDao::class], version = 1)
@TypeConverters(LocalDateTimeConverter::class)
abstract class TemperatureDatabase : RoomDatabase() {
abstract val temperatureDao: TemperatureDao
companion object {
@Volatile
private var INSTANCE: TemperatureDatabase? = null
fun getInstance(context: Context): TemperatureDatabase {
synchronized(this) {
var instance = INSTANCE
if (instance == null) {
instance = Room.databaseBuilder(
context.applicationContext,
TemperatureDatabase::class.java,
DATABASE_FILE_NAME
).fallbackToDestructiveMigration()
.build()
INSTANCE = instance
}
return instance
}
}
}
}
高橋 徹 さんが約4年前に更新
ドメインオブジェクト v.s. エンティティクラス¶
View側(ViewModelおよびView)で扱うデータは、Roomへの依存性を持たないドメインオブジェクト(POJO)とするか、Roomのエンティティをそのまま流すか、のトレードオフが発生。
- リポジトリは永続化手段(SQLite、Realm、あるいはリモートに通信を介して)を抽象化するので、永続化手段の具象型(Entity)を扱うのは不適切では?
- ドメインモデルはRoomライブラリに依存性を持つべきでない
- 小さなプログラムでSQLiteがありきであれば、リポジトリを介す必要なくSQLite依存のままとするとコンポーネントが少なくて済む
結論は出ているなぁ。
参考記事は Android Kotlin Fundamentals: Repository
https://codelabs.developers.google.com/codelabs/kotlin-android-training-repository/#0