プロジェクト

全般

プロフィール

機能 #166

未完了

Androidアプリケーション(検温記録)を古典MVC構造で作成する(Kotlin編)

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

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

80%

予定工数:

説明

[#165]の仕様で、Kotlin言語で実装する。

開発環境は次

OS Windows 10 1909 Pro 64bit 日本語版
IDE Android Studio 4.0.1

結果

リポジトリ
source:learn/android/TempRecorderClassicKt

ファイル毎のコード行数(clocツール調べ)

No. ファイル名 コード行数 Java版のコード行数
1 TempProvider.kt 77 74
2 MainActivity.kt 72 100
3 TempAdapter.kt 47 85
4 TempDbHelper.kt 21 30
5 TempContract.kt 18 13

今後の課題

  • 同一アプリのContentProviderが失われたとき、Activityは一緒に失われるかどうか。
  • Cursorはいつどこでcloseすべきか。

ファイル


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

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

操作
関連している 機能 #167: Androidアプリケーション(検温記録)をJetPackを用いたMVVM構造で作成する(Kotlin編)解決高橋 徹2020/09/27

操作

高橋 徹 さんがほぼ4年前に更新

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

高橋 徹 さんがほぼ4年前に更新

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

プロジェクトの新規作成

Android Studioで新規プロジェクト「TempRecorderClassicKt」を作成

  • [File]メニュー > [New] > [New Project] で「Create New Project」画面を開く
  • [Empty Activity]を選択し、[Next]ボタンをクリック
  • 以下を記載し[Finish]ボタンをクリック
    • Name欄に TempRecorderClassicKt
    • Package欄に com.torutk.temprecorder
    • Language欄に Kotlin
    • Minimum SDK欄に API 29: Android 10.0 (Q)

生成されたディレクトリ・ファイル構成は次(主要なもの抜粋)

TempRecorderClassicKt
  +-- .idea/
  +-- app/
  |     +-- src/
  |     |     +-- androidTest/
  |     |     +-- main/
  |     |     |     +-- java/
  |     |     |     |     +-- com/
  |     |     |     |           +-- torutk/
  |     |     |     |                 +-- temprecorder/
  |     |     |     |                       +-- MainActivity.kt
  |     |     |     +-- res/
  |     |     |     |     +-- layout/
  |     |     |     |     |     +-- activity_main.xml
  |     |     |     |     +-- values/
  |     |     |     |           +-- strings.xml
  |     |     |     +-- AndroidManifest.xml
  |     |     +-- test/
  |     +-- build.gradle
  +-- gradle/
  +-- build.gradle
  +-- gradle.properties
  +-- gradlew.bat
  +-- settings.gradle

高橋 徹 さんがほぼ4年前に更新

画面レイアウトの作成

先のJava編[#165]では、ルートレイアウトをデフォルトのConstraintLayoutからLinearLayoutに変更し、入れ子構造で「古典的な」レイアウトを実施した。その後、入れ子構造のレイアウトは画面描画が重くなるのでフラット構造のレイアウトができるConstraintLayoutがよいという意見を見かけた。成程...、では今回はデフォルトのConstraintLayoutをルートレイアウトとして作成してみる。

入力領域のタイトル TextView

Constraint は、上・左・右の3つ

clipboard-202009131151-vq6wz.png

  • TextViewを左右目いっぱいに引っ張る場合は、layout_widthを0dp(レイアウト制約にお任せ)にする
日時表示の TextView

入力領域のタイトル TextView と同様に、ただし Constraint で上方向の結合先は parent ではなく、上述の入力領域のタイトル TextView の底辺とした。また、間隔を16dpとした。

clipboard-202009132148-visk0.png

時刻の増減ボタン

水平方向に均等となる感じで2つのボタンを配置する方法を探ったところ、Chain を使うらしきことが分かった。
まずボタンを2つ並べて配置し、両者を選択対象とし、どちらかのボタン上で右クリックし、[Chains] > [Create Horizontal Chain]をクリック、すると水平方向で均等な配置となります。

clipboard-202009132155-uweec.png

  • ボタンの一つ上の配置の日時TextView との垂直方向の間隔を指定
  • 右側ボタンの上を左側ボタンの上へConstraintを指定することでボタンの上下方向の位置を揃える
体温の入力用数値Pickerと登録Button

Android StudioのデザイナーのパレットにはNumberPickerがないので、レイアウトXMLに直接追記します。
NumberPicker を2つ、XMLのConstraintLayoutの子要素として追記します。要素を追加すれば、属性はレイアウトエディタ上で設定可能です。
続いてButtonを1つ配置します。
NumberPicker2つとButtonを選択状態とし(複数選択)、右クリックし、[Chains] > [Create Horizontal Chain]をクリック、すると水平方向で均等な配置となります。
ここで、NumberPickerとButtonは高さが違うので、高さ方向を真ん中揃えするため、複数選択状態のまま右クリックで、[Align] > [Vertical Center]をクリックします。

clipboard-202009140739-rrdx7.png

NumberPickerには、id、layout_width、layout_height などを設定、また間隔を開けるため 上、横の制約に数値を入れておきます。

記録一覧のタイトル TextView

Constraint は、上・左・右の3つ、layout_widthはレイアウトにお任せの0dpとする。

記録一覧 RecyclerView

RecyclerViewを配置(初回はライブラリを追加するか聞かれるので追加とする。Gradleの変更が入るのでGradle syncが実行され、しばし待つ)
Constraintは、上下左右4つ、4つともMatch Constraints

高橋 徹 さんがほぼ4年前に更新

レイアウトの確認をするため、実行(Android Device上で実行)したところ次のエラーに。

Installation did not succeed.
The application could not be installed: INSTALL_FAILED_UPDATE_INCOMPATIBLE

アプリケーションのIDが、Java編とかぶっているため、アプリケーションIDは次に定義

  • appモジュールのbuild.gradle
    android {
      :
      defaultConfig {
        applicationId "com.torutk.temprecorder" 
        :
    
  • appモジュールのsrc/main/AndroidManifest.xml
    <manifest xmlns:android=..
        package="com.torutk.temprecorder">
    

この2か所を整合をもって変更する。
このパッケージ名は、ソースコードのパッケージ(Activityの配置しているパッケージ)として使われるので、ソースコードのパッケージも合わせて修正。

高橋 徹 さんがほぼ4年前に更新

画面に表示する文字のリソース化

レイアウトで使用する文字列は、国際化リソースとして定義する。
app/src/main/res/values/strings.xml に、IDと文字列の組み合わせで定義する。このファイルには英語文字列を定義する。
次に、日本語文字列を定義する。まず、Android Studio 上でstrings.xmlを開いた状態で、[Open editer]をクリックし、

clipboard-202009150752-ketqm.png

地球に+のアイコンをクリックし、ドロップダウンリストから[Japanese (ja)]を選択する。

resourceeditor_add_locale.png

すると、Translations Editorで日本語文字列を定義するカラムが増えるので、日本語を適宜入れる。

clipboard-202009151454-fo8up.png

文字列リソースの定義が終わったら、レイアウトエディタに移り、文字列表示を持つ表示部品のtextプロパティをリソースID参照に変更する。

高橋 徹 さんがほぼ4年前に更新

高橋 徹 さんがほぼ4年前に更新

コントローラのロジック

検温日時の保持と表示
  • 画面が表示されたときに、現在日時を検温日時として表示する
  • 10分前/10分後ボタンを押すと、検温日時を変更する

日時(java.time.LocalDateTime)をフィールドに持ち、ボタンをクリックするとフィールドに保持した日時を±10分増減する。
日時を表示のために文字列化するには、java.time.format.DateTimeFormatterを用いる。

internal val DATE_TIME_VIEW_FORMATTER = DateTimeFormatter.ofPattern("MM.dd HH:mm")

class MainActivity : AppCompatActivity() {

    private var measuredAt: LocalDateTime = LocalDateTime.now() // 検温日時
        set(dateTime: LocalDateTime) {
            field = dateTime
            measuredAtTextView.text = field.format(DATE_TIME_VIEW_FORMATTER)
        }
   :

DateTimeFormatterのインスタンスはスレッドセーフなので、Javaでは静的フィールドのfinal定数で保持している。
一方Kotlinには静的フィールド(staticなプロパティ)がない。通常コンパニオンオブジェクトを使うが、今回はクラス定義の外、ファイルスコープに定義する。

LodalDateTimeインスタンスはプロパティとして保持する。LodalDateTimeインスタンスはイミュータブルなので日時の変更はプロパティの値の再代入となる。この再代入時には表示更新を伴うのでカスタムのアクセッサを定義し、表示更新を追加した。
なお、Kotlinでは表示部品のインスタンスを、findViewByIdを利用せずに表示部品のidと同じ名前の変数名で利用可能。

ボタンを押した際の処理は、ボタンのインスタンスにsetOnClickListenerでラムダ式を渡している。

    override fun onCreate(savedInstanceState: Bundle?) {
        :
        dec10MinButton.setOnClickListener { measuredAt = measuredAt.minusMinutes(10) }
        inc10MinButton.setOnClickListener { measuredAt = measuredAt.plusMinutes(10) }
    }

Kotlinのラムダ式は、引数1つの場合、引数を省略可(itで参照できる)。

体温の入力用NumberPicker

最小値、最大値、初期値、周回有無の設定は、レイアウトエディタから設定できなかったのでコードで行う。

    override fun onCreate(savedInstanceState: Bundle?) {
        :
        with(integralNumberPicker) {
            minValue = 35
            maxValue = 40
            value = 36
            wrapSelectorWheel = false
        }
        with(fractionNumberPicker) {
            minValue = 0
            maxValue = 9
            value = 5
        }

withスコープを使うと、同じインスタンスに対して複数のメソッド呼び出しをする際、行数は同じだがレシーバーの記述を省略できるので簡潔明瞭になる。

登録ボタン

登録ボタンを押すと、画面に表示されている検温日時と体温をコンテントプロバイダに新規登録する。
現時点はコンテントプロバイダが未実装のため、privateメソッドの呼び出しまで実装する。

    override fun onCreate(savedInstanceState: Bundle?) {
        :
        submitButton.setOnClickListener { submitTemp() }
    }

    private fun submitTemp() {
        val temp = integralNumberPicker.value + fractionNumberPicker.value / 10.0
        // TODO: コンテントプロバイダ実装後に記述
    }

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

RecyclerViewの実装については、次のWikiページに記載

Androidプログラミング-RecyclerView

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

コンテントプロバイダの作成

  • コンテントプロバイダの利用者とのインタフェースを決める
  • コントラクトクラスの作成
  • SQLiteOpenHelperのサブクラス作成
  • ContentProviderのサブクラス作成
利用者とのインタフェースを決める

コンテントプロバイダの利用者は、次を指定する必要があるので、それらを決める。

  • コンテントのURI
  • データの構造(メタデータ)
    Cursorから値を取り出すために、カラム名と型が必要

コンテントのURIは次の書式
content://オーソリティ名/データ種別

オーソリティ名は、コンテントプロバイダを提供するアプリケーションのアンドロイドパッケージ名+providerとする。
com.torutk.temprecorder.kt.provider

データ種別は、テーブル名を小文字にしたものとする。
temperatures

コンテントのURIは、content://com.torutk.temprecorder.kt.provider/temperatures とする。

メタデータは次とする。

データ名 カラム名
計測日時 measured_at String
計測値 measurement Double
  • 注1)型は、CursorおよびContentValuesで扱える型から選択

ここで決めたインタフェースはコントラクトクラスに一元定義する。

コントラクトクラスの作成

インタフェースで決めた識別子を定義するクラス。

class TempContract private constructor() {
    companion object {
        const val AUTHORITY = "com.tourtk.temprecorder.kt.provider" 
        val CONTENT_URI = Uri.parse("content://" + AUTHORITY + "/temperatures")
    }

    class TempEntry private constructor() {
        companion object {
            const val _ID = BaseColumns._ID
            const val _COUNT = BaseColumns._COUNT
            const val TABLE_NAME = "Temperatures" 
            const val MEASURED_AT = "measured_at" 
            const val MEASUREMENT = "measurement" 
        }
    }
}
  • 注1)BaseColumns の静的フィールド_ID、_COUNTは、BaseColumns を実装する Kotlin のクラスをレシーバとして参照することができない。そこで、Kotlinで定義するクラスに再定義した。
SQLiteOpenHelperのサブクラス作成

SQLiteDatabaseインスタンスの生成、データベースのバージョン管理を行うクラス。

internal const val DATABASE_NAME = "body_temperature.db" 
internal const val DATABASE_VERSION = 1
internal val CREATE_TABLE = """ 
   |CREATE TABLE ${TempContract.TempEntry.TABLE_NAME} (
   |  ${TempContract.TempEntry._ID} INTEGER PRIMARY KEY AUTOINCREMENT,
   |  ${TempContract.TempEntry.MEASURED_AT} TEXT NOT NULL,
   |  ${TempContract.TempEntry.MEASUREMENT} REAL NOT NULL);
""".trimMargin()

class TempDbHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {
    override fun onCreate(db: SQLiteDatabase?) {
        db?.execSQL(CREATE_TABLE)
    }

    override fun onUpgrade(db: SQLiteDatabase?, oldVersion: Int, newVersion: Int) {
        db?.execSQL("DROP TABLE IF EXISTS ${TempContract.TempEntry.TABLE_NAME}")
        onCreate(db)
    }
}
ContentProviderのサブクラス作成

ContentProviderは、クラス以外にマニフェストに設定を記述する必要がある。
Android Studio のメニューから生成するとマニフェスト記述が自動で行われる。

  • [File]メニュー > [New] > [Other] > [Content Provider] で「New Android Component」画面を開く
  • Class Name欄にクラス名(ここではTempProvider)
  • URI Authorities欄にオーソリティ名(ここではcom.torutk.temprecorder.kt.provider)

clipboard-202009200955-3yqrm.png

AndroidManifestファイルに次が追記

        <provider
            android:name=".TempProvider" 
            android:authorities="com.torutk.temprecorder.kt" 
            android:enabled="true" 
            android:exported="true"></provider>

実装は次のコメントで記述

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

コンテントプロバイダの作成(続)

ContentProviderのサブクラス作成

コンテントプロバイダとして次の2種類のリクエストに対応する。

  1. すべての検温記録を取得
  2. 指定したIDの検温記録1件を取得

利用者は、次のコンテントURIでリクエストを指定する。

  1. "content://com.torutk.temprecorder.kt.provider/temperatures"
  2. "content://com.torutk.temprecorder.kt.provider/temperatures/987"
    注)987 の部分はIDで、検温記録に対応する任意の数値

URIを判別するUriMatcherクラスのプロパティを定義し、URIとURIに対応する数値を定義する。
トップレベルプロパティに定義。

private const val TEMPERATURES = 1
private const val TEMPERATURES_ID = 2
private val uriMatcher = UriMatcher(UriMatcher.NO_MATCH).apply {
    addURI(TempContract.AUTHORITY, "temperatures", TEMPERATURES)
    addURI(TempContract.AUTHORITY, "temperatures/#", TEMPERATURES_ID)
}

クラス定義、プロパティにSQLiteOpenHelperのサブクラスを保持

class TempProvider : ContentProvider() {

    private lateinit var dbHelper: TempDbHelper

最低限実装するメソッドは、insert、onCreate、query の3つ。

まず onCreate

    override fun onCreate(): Boolean {
        dbHelper = TempDbHelper(context!!)
        return true
    }

insertのエラー処理やcloseなどが怪しい最低限の実装

    override fun insert(uri: Uri, values: ContentValues?): Uri? {
        val database = dbHelper.writableDatabase
        val id = database.insert(TempContract.TempEntry.TABLE_NAME, null, values)
        val insertedUri = Uri.withAppendedPath(uri, id.toString())
        context?.contentResolver?.notifyChange(uri, null)
        return insertedUri
    }

databaseのcloseが必要では、とかinsertに失敗時の処理が必要では、など。

insertの引数 uri の中をチェックしてエラー処理をしている例もネット上に見かけた。

        return when (uriMatcher.match(uri)) {
            TEMPERATURES -> {
                val id = dbHelper.writableDatabase.insert(TempContract.TempEntry.TABLE_NAME, null, values)
                val insertedUri = Uri.withAppendedPath(uri, id.toString())
                context!!.contentResolver.notifyChange(insertedUri, null)
                insertedUri
            }
            TEMPERATURES_ID -> {
                throw IllegalArgumentException("Invalid URI, cannot insert with ID: $uri")
            }
            else -> throw IllegalArgumentException("Unknown URI: $uri")
        }

queryの実装

    override fun query(
        uri: Uri, projection: Array<String>?, selection: String?,
        selectionArgs: Array<String>?, sortOrder: String?
    ): Cursor? {
        val db = dbHelper.readableDatabase
        val cursor: Cursor
        when (uriMatcher.match(uri)) {
            TEMPERATURES -> {
                cursor = db.query(
                    TempContract.TempEntry.TABLE_NAME,
                    projection,
                    selection,
                    selectionArgs,
                    null,
                    null,
                    sortOrder
                )
            }
            TEMPERATURES_ID -> {
                cursor = db.query(
                    TempContract.TempEntry.TABLE_NAME,
                    projection,
                    "_id = ?",
                    arrayOf(uri.lastPathSegment),
                    null,
                    null,
                    sortOrder
                )
            }
            else -> throw IllegalArgumentException("Unknown URI ${uri}")
        }
        cursor.setNotificationUri(context!!.contentResolver, uri)
        return cursor
    }

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

MainActivity.kt にコンテントプロバイダアクセスを追記

 class MainActivity : AppCompatActivity() {
+    lateinit var tempAdapter: TempAdapter
+    lateinit var tempProviderObserver: ContentObserver
  • RecyclerViewのAdapterサブクラスをプロパティに追加
  • コンテントプロバイダの変更を観測するオブザーバーをプロパティに追加
  • lateinitとしたのはonCreateで初期化するため。
     override fun onCreate(savedInstanceState: Bundle?) {
         :
+        tempAdapter = TempAdapter(null)
+        tempRecyclerView.adapter = tempAdapter
+        tempProviderObserver = object : ContentObserver(Handler()) {
+            override fun onChange(selfChange: Boolean) {
+                super.onChange(selfChange)
+                queryTemp()
+            }
+        } 
  • プロパティ tempAdapterとtempProviderObserverの初期化
  • tempAdapterは、recyclerViewに登録
  • queryTempメソッドはコンテントプロバイダのqueryを呼び出し一覧を取得するメソッドで実装は後述
+    override fun onStart() {
+        super.onStart()
+        queryTemp()
+    }
  • onStartでアプリ画面の表示時に一覧を取得
     override fun onResume() {
         super.onResume()
         measuredAt = LocalDateTime.now()
+        contentResolver.registerContentObserver(TempContract.CONTENT_URI, true, tempProviderObserver)
    }
  • コンテントプロバイダの変更を観測するオブザーバーを登録
+    override fun onPause() {
+        super.onPause()
+        contentResolver.unregisterContentObserver(tempProviderObserver)
+    }
  • オブザーバーの解除
+    private fun queryTemp() {
+        val cursor = contentResolver.query(
+            TempContract.CONTENT_URI, null, null, null, "_id DESC" 
+        )
+        cursor?.let { tempAdapter.swapCursor(cursor) }
+    }
  • コンテントプロバイダから一覧取得するメソッド作成
     private fun submitTemp() {
+        val values = ContentValues().apply {
+            put(TempContract.TempEntry.MEASURED_AT, measuredAt.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME))
+            put(TempContract.TempEntry.MEASUREMENT, integralNumberPicker.value + fractionNumberPicker.value / 10.0)
+        }
+        contentResolver.insert(TempContract.CONTENT_URI, values)
     }
  • 検温結果を登録する処理を記述したメソッド

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

アプリを実行するとすぐに落ちてしまう。

2020-09-21 11:20:47.810 19975-19975/com.torutk.temprecorder.kt E/AndroidRuntime: FATAL EXCEPTION: main
    Process: com.torutk.temprecorder.kt, PID: 19975
    java.lang.RuntimeException: Unable to resume activity {com.torutk.temprecorder.kt/com.torutk.temprecorder.kt.MainActivity}: java.lang.SecurityException: Failed to find provider com.tourtk.temprecorder.kt.provider for user 0; expected to find a valid ContentProvider for this authority
        at android.app.ActivityThread.performResumeActivity(ActivityThread.java:4205)
        :
     Caused by: java.lang.SecurityException: Failed to find provider com.tourtk.temprecorder.kt.provider for user 0; expected to find a valid ContentProvider for this authority
        at android.os.Parcel.createException(Parcel.java:2071)
        :
        at com.torutk.temprecorder.kt.MainActivity.onResume(MainActivity.kt:60)
        :

アプリ側の次のコード

        contentResolver.registerContentObserver(TempContract.CONTENT_URI, true, tempProviderObserver)

コンテントURIが一致しないというエラー、原因はどこかAndroidManifestも含めて調べたところ、
TempContract の AUTHORITY の文字列定数定義の誤記と判明

-        const val AUTHORITY = "com.tourtk.temprecorder.kt.provider" 
+        const val AUTHORITY = "com.torutk.temprecorder.kt.provider" 

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

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

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

  • ステータス進行中 から 解決 に変更
  • 進捗率50 から 80 に変更

データベースはinsertとqueryのみ実装し、update、deleteは未実装だが目的は達しているので本チケットは解決とする。

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

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

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

  • カテゴリAndroid にセット

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