プロジェクト

全般

プロフィール

機能 #167

未完了

Androidアプリケーション(検温記録)をJetPackを用いたMVVM構造で作成する(Kotlin編)

高橋 徹 さんが3年以上前に追加. 3年以上前に更新.

ステータス:
解決
優先度:
通常
担当者:
カテゴリ:
Android
対象バージョン:
-
開始日:
2020/09/27
期日:
進捗率:

80%

予定工数:

説明

[#165]の仕様で、但しアプリケーション構造をMVVM構造とし、ライブラリはJetPackで提供されるものからMVVM構造に必要なものを選択して使用する。
Kotlin言語で実装する。

結果

  • 使用するJetpackライブラリ
    • ViewBinding DataBinding
    • ViewModel
    • Repository
    • Room
    • LiveData

今後の課題

  • コンテントプロバイダを提供するにはどうする?

Sliceが関係ありそう?

LiveDataとの組み合わせ


ファイル


関連するチケット 3 (3件未完了0件完了)

関連している 機能 #165: Androidアプリケーション(検温記録)を古典MVC構造で作成するフィードバック高橋 徹2020/08/31

操作
関連している 機能 #166: Androidアプリケーション(検温記録)を古典MVC構造で作成する(Kotlin編)解決高橋 徹2020/09/12

操作
コピー先 機能 #189: 検温記録(JetPack Kotlin)のリファクタリング解決高橋 徹2020/11/20

操作

高橋 徹 さんが3年以上前に更新

  • 関連している 機能 #165: Androidアプリケーション(検温記録)を古典MVC構造で作成する を追加

高橋 徹 さんが3年以上前に更新

  • 関連している 機能 #166: Androidアプリケーション(検温記録)を古典MVC構造で作成する(Kotlin編) を追加

高橋 徹 さんが3年以上前に更新

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]と同様なので略

高橋 徹 さんが3年以上前に更新

高橋 徹 さんが3年以上前に更新

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 に保持することとする。

高橋 徹 さんが3年以上前に更新

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側に増減メソッドを設けた方がよかったかも。

高橋 徹 さんが3年以上前に更新

リポジトリとデータベース

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
            }
        }
    }
}

高橋 徹 さんが3年以上前に更新

ドメインオブジェクト 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

高橋 徹 さんが3年以上前に更新

Android Studio 使用メモ

レイアウトXMLファイルでトップ要素をdata binding用に変更する場合(ALT+Enterで、[Convert to data binding layout])、予めモジュールのbuild.gradleに次を記述しておく必要あり。

android {
    :
    buildFeatures {
        dataBinding true
    }
}

高橋 徹 さんが3年以上前に更新

  • 説明 を更新 (差分)
  • ステータス進行中 から 解決 に変更
  • 進捗率50 から 80 に変更

動作に至ったので本チケットによる作業はクローズ。
リファクタリングについては別チケットを起こす。

高橋 徹 さんが3年以上前に更新

  • コピー先 機能 #189: 検温記録(JetPack Kotlin)のリファクタリング を追加

他の形式にエクスポート: Atom PDF