機能 #166
未完了Androidアプリケーション(検温記録)を古典MVC構造で作成する(Kotlin編)
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すべきか。
ファイル
clipboard-202009131151-vq6wz.png (27.4 KB) clipboard-202009131151-vq6wz.png | 高橋 徹, 2020/09/13 11:51 | ||
clipboard-202009132148-visk0.png (35.5 KB) clipboard-202009132148-visk0.png | 高橋 徹, 2020/09/13 21:48 | ||
clipboard-202009132155-uweec.png (40.4 KB) clipboard-202009132155-uweec.png | 高橋 徹, 2020/09/13 21:55 | ||
clipboard-202009140739-rrdx7.png (50.3 KB) clipboard-202009140739-rrdx7.png | 高橋 徹, 2020/09/14 07:39 | ||
clipboard-202009150752-ketqm.png (17.3 KB) clipboard-202009150752-ketqm.png | 高橋 徹, 2020/09/15 07:52 | ||
clipboard-202009151454-fo8up.png (17.4 KB) clipboard-202009151454-fo8up.png | 高橋 徹, 2020/09/15 14:54 | ||
resourceeditor_add_locale.png (32.3 KB) resourceeditor_add_locale.png | 高橋 徹, 2020/09/15 15:06 | ||
clipboard-202009200955-3yqrm.png (19.2 KB) clipboard-202009200955-3yqrm.png | 高橋 徹, 2020/09/20 09:55 | ||
clipboard-202009200957-ahncq.png (19.2 KB) clipboard-202009200957-ahncq.png | 高橋 徹, 2020/09/20 09:57 |
高橋 徹 さんが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)
- Name欄に
生成されたディレクトリ・ファイル構成は次(主要なもの抜粋)
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年以上前に更新
- ファイル clipboard-202009131151-vq6wz.png clipboard-202009131151-vq6wz.png を追加
- ファイル clipboard-202009132148-visk0.png clipboard-202009132148-visk0.png を追加
- ファイル clipboard-202009132155-uweec.png clipboard-202009132155-uweec.png を追加
- ファイル clipboard-202009140739-rrdx7.png clipboard-202009140739-rrdx7.png を追加
画面レイアウトの作成¶
先のJava編[#165]では、ルートレイアウトをデフォルトのConstraintLayoutからLinearLayoutに変更し、入れ子構造で「古典的な」レイアウトを実施した。その後、入れ子構造のレイアウトは画面描画が重くなるのでフラット構造のレイアウトができるConstraintLayoutがよいという意見を見かけた。成程...、では今回はデフォルトのConstraintLayoutをルートレイアウトとして作成してみる。
入力領域のタイトル TextView¶
Constraint は、上・左・右の3つ
- TextViewを左右目いっぱいに引っ張る場合は、layout_widthを0dp(レイアウト制約にお任せ)にする
日時表示の TextView¶
入力領域のタイトル TextView と同様に、ただし Constraint で上方向の結合先は parent ではなく、上述の入力領域のタイトル TextView の底辺とした。また、間隔を16dpとした。
時刻の増減ボタン¶
水平方向に均等となる感じで2つのボタンを配置する方法を探ったところ、Chain を使うらしきことが分かった。
まずボタンを2つ並べて配置し、両者を選択対象とし、どちらかのボタン上で右クリックし、[Chains] > [Create Horizontal Chain]をクリック、すると水平方向で均等な配置となります。
- ボタンの一つ上の配置の日時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]をクリックします。
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年以上前に更新
- ファイル clipboard-202009150752-ketqm.png clipboard-202009150752-ketqm.png を追加
- ファイル clipboard-202009151454-fo8up.png clipboard-202009151454-fo8up.png を追加
画面に表示する文字のリソース化¶
レイアウトで使用する文字列は、国際化リソースとして定義する。
app/src/main/res/values/strings.xml に、IDと文字列の組み合わせで定義する。このファイルには英語文字列を定義する。
次に、日本語文字列を定義する。まず、Android Studio 上でstrings.xmlを開いた状態で、[Open editer]をクリックし、
地球に+のアイコンをクリックし、ドロップダウンリストから[Japanese (ja)]を選択する。
すると、Translations Editorで日本語文字列を定義するカラムが増えるので、日本語を適宜入れる。
文字列リソースの定義が終わったら、レイアウトエディタに移り、文字列表示を持つ表示部品のtextプロパティをリソースID参照に変更する。
高橋 徹 さんが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: コンテントプロバイダ実装後に記述
}
高橋 徹 さんが4年以上前に更新
- ファイル clipboard-202009200955-3yqrm.png clipboard-202009200955-3yqrm.png を追加
- ファイル clipboard-202009200957-ahncq.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)
AndroidManifestファイルに次が追記
<provider
android:name=".TempProvider"
android:authorities="com.torutk.temprecorder.kt"
android:enabled="true"
android:exported="true"></provider>
実装は次のコメントで記述
高橋 徹 さんが4年以上前に更新
コンテントプロバイダの作成(続)¶
ContentProviderのサブクラス作成¶
コンテントプロバイダとして次の2種類のリクエストに対応する。
- すべての検温記録を取得
- 指定したIDの検温記録1件を取得
利用者は、次のコンテントURIでリクエストを指定する。
- "content://com.torutk.temprecorder.kt.provider/temperatures"
- "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
}
高橋 徹 さんが4年以上前に更新
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)
}
- 検温結果を登録する処理を記述したメソッド
高橋 徹 さんが4年以上前に更新
アプリを実行するとすぐに落ちてしまう。
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"