ソフトウェアエンジニアリング - 機能 #166
Androidアプリケーション(検温記録)を古典MVC構造で作成する(Kotlin編)
2020/09/12 22:51 - 高橋 徹 ステータス: 解決 開始日: 2020/09/12 優先度: 通常 期日: 担当者: 高橋 徹 進捗率: 80% カテゴリ: Android 予定工数: 0.00時間 対象バージョン: 説明 [#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すべきか。 関連するチケット: 関連している 機能 #165: Androidアプリケーション(検温記録)を古典MVC構造で作成する フィードバック2020/08/31 関連している 機能 #167: Androidアプリケーション(検温記録)をJetPackを用いたMVVM構造で作... 解決 2020/09/27 関係しているリビジョン リビジョン 4caca8db - 2020/09/15 15:45 - 高橋 徹 refs #166 Add TempRecorder with classic MVC and Kotlin
リビジョン 0406a46c - 2020/09/21 11:50 - 高橋 徹 refs #166 Add Content Provider
リビジョン 7ca59fbf - 2020/09/21 12:02 - 高橋 徹 refs #166 Fix type of AUTHORITY
履歴
#1 - 2020/09/12 22:51 - 高橋 徹
#2 - 2020/09/13 11:06 - 高橋 徹 - 説明 を更新 - ステータス を 新規 から 進行中 に変更 - 進捗率 を 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 #3 - 2020/09/14 07:41 - 高橋 徹 - ファイル clipboard-202009131151-vq6wz.png を追加 - ファイル clipboard-202009132148-visk0.png を追加 - ファイル clipboard-202009132155-uweec.png を追加 - ファイル clipboard-202009140739-rrdx7.png を追加
画面レイアウトの作成
先のJava編[#165 ]では、ルートレイアウトをデフォルトのConstraintLayoutからLinearLayoutに変更し、入れ子構造で「古典的な」レイアウトを実施した。その後、 入れ子構造のレイアウトは画面描画が重くなるのでフラット構造のレイアウトができるConstraintLayoutがよいという意見を見かけた。成程...、で は今回はデフォルトのConstraintLayoutをルートレイアウトとして作成してみる。 入力領域のタイトル TextView Constraint は、上・左・右の3つ 320 TextViewを左右目いっぱいに引っ張る場合は、layout_widthを0dp(レイアウト制約にお任せ)にする 日時表示の TextView入力領域のタイトル TextView と同様に、ただし Constraint で上方向の結合先は parent ではなく、上述の入力領域のタイトル TextView の底辺とした。また、間隔を16dpとした。
時刻の増減ボタン
水平方向に均等となる感じで2つのボタンを配置する方法を探ったところ、Chain を使うらしきことが分かった。 まずボタンを2つ並べて配置し、両者を選択対象とし、どちらかのボタン上で右クリックし、[Chains] > [Create Horizontal Chain]をクリック、すると水平方向で均等な配置となります。 320 ボタンの一つ上の配置の日時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]をクリックします。 320 NumberPickerには、id、layout_width、layout_height などを設定、また間隔を開けるため 上、横の制約に数値を入れておきます。 記録一覧のタイトル TextView Constraint は、上・左・右の3つ、layout_widthはレイアウトにお任せの0dpとする。 記録一覧 RecyclerView RecyclerViewを配置(初回はライブラリを追加するか聞かれるので追加とする。Gradleの変更が入るのでGradle syncが実行され、しばし待つ) Constraintは、上下左右4つ、4つともMatch Constraints #4 - 2020/09/14 08:23 - 高橋 徹 レイアウトの確認をするため、実行(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の配置しているパッケージ)として使われるので、ソースコードのパッケージも合わせ て修正。 #5 - 2020/09/15 15:02 - 高橋 徹 - ファイル clipboard-202009150752-ketqm.png を追加 - ファイル clipboard-202009151454-fo8up.png を追加 画面に表示する文字のリソース化 レイアウトで使用する文字列は、国際化リソースとして定義する。 app/src/main/res/values/strings.xml に、IDと文字列の組み合わせで定義する。このファイルには英語文字列を定義する。 次に、日本語文字列を定義する。まず、Android Studio 上でstrings.xmlを開いた状態で、[Open editer]をクリックし、
320
320 すると、Translations Editorで日本語文字列を定義するカラムが増えるので、日本語を適宜入れる。 320 文字列リソースの定義が終わったら、レイアウトエディタに移り、文字列表示を持つ表示部品のtextプロパティをリソースID参照に変更する。 #6 - 2020/09/15 15:08 - 高橋 徹 - ファイル resourceeditor_add_locale.png を追加 #7 - 2020/09/15 15:49 - 高橋 徹 - 説明 を更新 #8 - 2020/09/15 22:01 - 高橋 徹 コントローラのロジック 検温日時の保持と表示 画面が表示されたときに、現在日時を検温日時として表示する 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: コンテントプロバイダ実装後に記述 } #9 - 2020/09/19 08:29 - 高橋 徹 RecyclerViewの実装については、次のWikiページに記載 Androidプログラミング-RecyclerView #10 - 2020/09/20 10:51 - 高橋 徹 - ファイル clipboard-202009200955-3yqrm.png を追加 - ファイル clipboard-202009200957-ahncq.png を追加 コンテントプロバイダの作成 コンテントプロバイダの利用者とのインタフェースを決める コントラクトクラスの作成 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) 320 AndroidManifestファイルに次が追記 <provider android:name=".TempProvider" android:authorities="com.torutk.temprecorder.kt" android:enabled="true" android:exported="true"></provider> 実装は次のコメントで記述 #11 - 2020/09/21 09:22 - 高橋 徹 コンテントプロバイダの作成(続) 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 } #12 - 2020/09/21 11:19 - 高橋 徹 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() {
+ 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) } 検温結果を登録する処理を記述したメソッド #13 - 2020/09/21 11:48 - 高橋 徹 アプリを実行するとすぐに落ちてしまう。
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.Securit yException: 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 Cont entProvider 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"
#14 - 2020/09/21 13:26 - 高橋 徹 - 説明 を更新 #15 - 2020/09/21 20:48 - 高橋 徹 - 説明 を更新 #16 - 2020/09/27 15:15 - 高橋 徹 - ステータス を 進行中 から 解決 に変更 - 進捗率 を 50 から 80 に変更 データベースはinsertとqueryのみ実装し、update、deleteは未実装だが目的は達しているので本チケットは解決とする。 #17 - 2020/09/27 15:21 - 高橋 徹 - 関連している 機能 #167: Androidアプリケーション(検温記録)をJetPackを用いたMVVM構造で作成する(Kotlin編) を追加 #18 - 2020/11/22 09:42 - 高橋 徹 - カテゴリ を Android にセット ファイル clipboard-202009131151-vq6wz.png 27.4 KB 2020/09/13 高橋 徹 clipboard-202009132148-visk0.png 35.5 KB 2020/09/13 高橋 徹 clipboard-202009132155-uweec.png 40.4 KB 2020/09/13 高橋 徹 clipboard-202009140739-rrdx7.png 50.3 KB 2020/09/13 高橋 徹 clipboard-202009150752-ketqm.png 17.3 KB 2020/09/14 高橋 徹 clipboard-202009151454-fo8up.png 17.4 KB 2020/09/15 高橋 徹 resourceeditor_add_locale.png 32.3 KB 2020/09/15 高橋 徹 clipboard-202009200955-3yqrm.png 19.2 KB 2020/09/20 高橋 徹 clipboard-202009200957-ahncq.png 19.2 KB 2020/09/20 高橋 徹