iOSエンジニアのつぶやき

主に iOS 関連の技術について毎日つぶやいています。まれに釣りについてつぶやく可能性があります。

知識ゼロからの Kotlin Android アプリリリースへの軌跡 / Day19【Create a Room database編】

イントロダクション

ほとんどのアプリには、ユーザがアプリを閉じた後でも保持する必要のあるデータがあります。たとえば、アプリには、プレイリスト、ゲームアイテムの在庫、経費と収入の記録、星座のカタログ、または刑事的な睡眠データが保持される場合があります。通常、データベースを使用して永続データを保存します。

Room は、Android Jetpack の一部であるデータベースライブラリです。Room は、データベースのセットアップと構成の雑用を多く処理し、アプリが通常の関数呼び出しを使用してデータベースと対話できるようにします。内部的には、RoomSQLite データベースの上にある抽象化レイヤーです。Room の用語、および複雑なクエリのクエリ構文は、SQLite モデルに従います。

以下の画像は、Room データベースがこのコースで推奨される全体的なアーキテクチャにどのように適合するかを示しています。

学ぶこと

  • Room データベースを作成して操作し、データを永続化する方法
  • データベース内のテーブルを定義するデータクラスを作成する方法
  • データアクセスオブジェクト(DAO)を使用して Kotlin 関数を SQL クエリにマップする方法
  • データベースが機能しているかどうかをテストする方法

すること

  • 夜間の睡眠データ用のインターフェースを備えた Room データベースを作成する
  • 提供されているテストを使用してデータベースをテストする

アプリ概要

このコードラボでは、睡眠の質を追跡するアプリのデータベース部分を構築します。アプリはデータベースを使用して、睡眠データを経時的に保存します。

次の図に示すように、アプリにはフラグメントで表される2つの画面があります。

左側に表示されている最初の画面には、追跡を開始および停止するためのボタンがあります。画面には、全てのユーザの睡眠データが表示されます。Clear ボタンは、アプリがユーザーのために収集した全てのデータを完全に削除します。

左側に表示されている2番目の画面は、睡眠の質の評価を選択するためのものです。アプリでは、評価は数値で表されます。開発の目的で、アプリは顔のアイコンとそれに相当する数値の両方を表示します。

ユーザーのフローは次の通りです:

  • ユーザーがアプリを開くと、睡眠追跡画面が表示されます。
  • ユーザーが Start ボタンをタップします。開始時刻を記録して表示します。Start ボタンが無効になり、Stop ボタンが有効になります。
  • ユーザーが Stop ボタンをタップします。これにより、終了時刻が記録され、睡眠品質画面が開きます。
  • ユーザーが睡眠品質アイコンを選択します。画面が閉じ、追跡画面に睡眠終了時間と睡眠の質が表示されます。Stop ボタンが無効になり、Start ボタンが有効になります。アプリは別の夜の準備ができています。
  • データベースにデータがある場合は常に、Clear ボタンが有効になります。ユーザが Clear ボタンをタップすると、全てのデータが消去されます。"Are you sure?" というメッセージは表示されません。

このアプリは、完全なアーキテクチャのコンテキストで以下に示すように、簡略化されたアーキテクチャを使用します。アプリは次のコンポーネントのみを使用します。

  • UI controller
  • ViewModel と LiveData
  • Room database

スターターアプリをダウンロードして検査する

スターターアプリをダウンロードして実行します

  1. TrackMySleepQuality アプリを Github からダウンロードします。

  2. アプリをビルドして実行します。アプリは SleepTrackerFragment fragment の UI を表示しますが、データは表示しません。ボタンはタップに反応しません。

スターターアプリを検査します

Tip: スターターアプリに精通していると、問題が発生した場合に、問題を特定して修正するのが簡単になります。

  1. Gradle ファイルを見てください:

  2. The project Gradle file プロジェクトレベルの build.gradle ファイルで、ライブラリのバージョンを指定する変数に注目してください。スターターアプリで使用されるバージョンは一緒にうまく機能し、このアプリでうまく機能します。このコードラボを終了するまでに、AndroidStudio から一部のバージョンの更新を求めるメッセージが表示される場合があります。アプリにあるバージョンを更新するか、そのままにするかはあなた次第です。奇妙なコンパイルエラーが発生した場合は、最終的な solution app がしようするライブラリバージョンの組み合わせを使用して見てください。

  3. The module Gradle file Room を含む全ての Android Jetpack ライブラリに提供されている依存関係と、コルーチンの依存関係に注意してください。

  4. packages と UI を見てください。アプリには機能によって構成されています。パッケージには、この一連のコードラボ全体でコードを追加するプレースホルダーファイルが含まれています。

  5. Room データベースに関連する全てのコード用の database package

  6. sleepquality および sleeptracker パッケージには、各画面の fragment、view、view model および view model factory が含まれています。

  7. 睡眠品質データの表示に役立つ関数が含まれている Util.kt ファイルを見てください。一部のコードは、あとで作成する view model を参照しているため、コメントアウトされています。

  8. androidTest フォルダー(SleepDatabaseTest.kt) を見てください。このテストを使用して、データベースが意図した通りに機能することを確認します。

SleepNight Entity を作成します

Android では、データはデータクラスで表され、関数呼び出しを使用してデータにアクセスして変更します。ただし、データベースの世界では、entities と queries が必要です。

  • Entity は、データベースに格納するオブジェクトまたは概念、およびそのプロパティを表します。Entity クラスはテーブルを定義し、そのクラスの各インスタンスはテーブルの行を表します。各プロパティは列を定義します。アプリでは、Entity は睡眠の夜に関する情報を保持します。
  • クエリは、データベーステーブルまたはテーブルの組み合わせからのデータまたは情報の要求、またはデータに対してアクションを実行するための要求です。一般的なクエリは、Entity の取得、挿入、および更新です。例えば、開始時刻で並べ替えて、記録されている全ての睡眠の夜を照会できます。

Room は、Kotlin データクラスから SQLite テーブルに格納できる Entity に、そして関数宣言から SQLクエリにいたるまで、全ての大変の作業を行います。

各 Entity を注釈付きデータクラスとして定義し、相互作用を注釈付きインターフェイスであるデータアクセスオブジェクト(DAO)として定義する必要があります。Room は、これらの注釈付きクラスを使用して、データベースにテーブルを作成し、データベースに作用するクエリを作成します。

SleepNight Entity を作成します

このタスクでは、1日の睡眠を 注釈付きデータクラスとして定義します。

一晩の睡眠のために、開始時間、終了時間、そして品質評価を記録する必要があります。

また、夜を一意に識別するための ID が必要です。

  1. database パッケージで、SleepNight.kt ファイルを見つけて開きます。

  2. ID、開始時間、終了時間、および数値の睡眠品質評価のパラメータを使用して、SleepNight データクラスを作成します。

  3. sleepQuality を初期化する必要があるため、-1 に設定して、品質データが収集されていないことを示します。

  4. また、終了時刻を初期化する必要があります。開始時刻に設定すると、終了時刻がまだ記録されていないことを通知します。
data class SleepNight(
       var nightId: Long = 0L,
       val startTimeMilli: Long = System.currentTimeMillis(),
       var endTimeMilli: Long = startTimeMilli,
       var sleepQuality: Int = -1
)
  1. クラスの宣言の前に、データクラスに @Entity アノテーションを付けます。テーブルに daily_sleep_quality_table という名前を付けます。tableName の引数はオプションですが、推奨されます。ドキュメントで他の引数を調べることができます。

プロンプトが表示されたら、androidx ライブラリから Entity と他の全ての注釈をインポートします。

@Entity(tableName = "daily_sleep_quality_table")
data class SleepNight(...)
  1. nightId を主キーとして識別するには、nightId プロパティに @PrimaryKey アノテーションを付けます。Room が各 Entity の ID を生成するように、パラメータに autoGeneratetrue に設定します。これにより、各夜の ID が一意であることが保証されます。
@PrimaryKey(autoGenerate = true)
var nightId: Long = 0L,...
  1. 残りのプロパティに @ColumnInfo アノテーションを付けます。以下に示すように、パラメータを使用してプロパティ名をカスタマイズします。
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "daily_sleep_quality_table")
data class SleepNight(
       @PrimaryKey(autoGenerate = true)
       var nightId: Long = 0L,

       @ColumnInfo(name = "start_time_milli")
       val startTimeMilli: Long = System.currentTimeMillis(),

       @ColumnInfo(name = "end_time_milli")
       var endTimeMilli: Long = startTimeMilli,

       @ColumnInfo(name = "quality_rating")
       var sleepQuality: Int = -1
)
  1. コードをビルドして実行し、エラーがないことを確認します。

DAO を作成する

このタスクでは、data access object(DAO)を定義します。Android では、DAO はデータベースを挿入、削除、および更新するための便利なメソッドを提供します。

Room データベースを使用する場合は、コードで Kotlin 関数を定義して呼び出すことにより、database に query を実行します。これらの Kotlin 関数は SQL クエリにマップされます。annotations を使用して DAO でこれらのマッピングを定義すると、Room が必要なコードを作成します。

DAO は、database にアクセスするためのカスタムインターフェースを定義するものと考えてください。

一般的な database 操作の場合、Room ライブラリは、@Insert@Delete@Update などの便利なアノテーションを提供します。それ以外の場合は、@Query アノテーションがあります。SQLite でサポートされている任意のクエリを記述できます。

追加のボーナスとして、Android Studio でクエリを作成すると、コンパイラSQL クエリ構文エラーをチェックします。

sleep-tracker データベースの場合、次のことができる必要があります。

  • Insert new nights を挿入します
  • Update 既存の夜の終了時間と品質を更新する
  • Get キーに基づいて特定の夜を取得する
  • Get all nights
  • Get the most recent night
  • Delete データベース内の全てのエントリを削除する

SleepDatabaseDAO を作成する

  1. database package で、SleepDatabaseDao.kt を開きます。
  2. interface SleepDatabaseDao@Dao アノテーションが付けられていることに注意してください。全ての DAO には、@Dao キーワードのアノテーションをつける必要があります。
@Dao
interface SleepDatabaseDao{}
  1. インターフェース内に @Insert アノテーションを追加します。@Insert の下に、Entity クラス SleepNightインスタンスを引数として取る insert() 関数を追加します。

Room は、SleepNight をデータベースに挿入するために必要な全てのコードを生成します。Kotlin コードから insert() を呼び出すと、RoomSQLクエリを実行して、Entity をデータベースに挿入します。(Note: 関数は任意に呼び出すことができます。)

@Insert
fun insert(night: SleepNight)
  1. 1つの SleepNightupdate() 関数を使用して @Update アノテーションを追加します。更新される Entity は、渡されたものと同じキーを持つ Entity です。Entity は他のプロパティの一部または全てを更新できます。
@Update
fun update(night: SleepNight)

残りの機能には便利なアノテーションがないため、@Query アノテーションを使用して SQLite クエリを提供する必要があります。

  1. Long キー引数をとり、nullable の SleepNight を返す get() 関数を @Query アノテーションと定義します。パラメータがない場合はエラーが表示されます。
@Query
fun get(key: Long): SleepNight?
  1. query は、annotation への文字列パラメータとして提供されます。@Query にパラメータを追加します。SQLite クエリである文字列にします。

  2. daily_sleep_quality_table から全ての列を選択します

  3. WHERE the naightIdkey 引数と一致する

:key に注目してください。クエリでコロン表記を使用して、関数の引数を参照します。

("SELECT * from daily_sleep_quality_table WHERE nightId = :key")
  1. clear 関数と SQLite クエリを使用して別の @Query を追加し、daily_sleep_quality_table から全てを DELETE します。このクエリはテーブル自体を削除しません。

@Delete アノテーションは1つのアイテムを削除し、@Delete を使用して、削除する夜のリストを指定できます。欠点は、テーブルの内容をフェッチまたは知っている必要があることです。@Delete アノテーションは特定のエントリを削除するのに最適ですが、テーブルから全てのエントリをクリアするには効率的ではありません。

@Query("DELETE FROM daily_sleep_quality_table")
fun clear()
  1. getTonight() 関数を使用して @Query を追加します。getTonight() によって返される SleepNight を nullable にして、テーブルが空の場合に関数が処理できるようにします。(テーブルは最初とデータがクリアされた後は空です。)

データベースから "tonight" を取得するには、nightId で降順に並べ替えられた結果のリストの最初の要素を返す SQLite クエリを記述します。LIMIT 1 を使用して、1つの要素のみを返します。

@Query("SELECT * FROM daily_sleep_quality_table ORDER BY nightId DESC LIMIT 1")
fun getTonight(): SleepNight?
  1. getAllNights() 関数を使用して @Query を追加します。

  2. SQLite クエリが daily_sleep_quality_table から全ての列を降順で返すようにします。

  3. getAllNights()SleepNight Entity のリストを LiveData として返すようにします。Room は、この LiveData を更新し続けます。つまり、データを明示的に取得する必要があるのは一回だけです。
  4. androidx.lifecycle.LiveData から LiveData をインポートする必要がある場合があります。
@Query("SELECT * FROM daily_sleep_quality_table ORDER BY nightId DESC")
fun getAllNights(): LiveData<List<SleepNight>>
  1. 目に見える変化は見られませんが、アプリを実行してエラーがないことを確認してください。

Room データベースを作成してテストする

このタスクでは、前のタスクで作成した Entity と DAO を使用する Room データベースを作成します。

@Database アノテーションが付けられた抽象データベースホルダークラスを作成する必要があります。このクラスには、データベースが存在しない場合にデータベースのインスタンス を作成するか、既存のデータベースへの参照を返すメソッドが1つあります。

Room データベースの取得は少し複雑なので、コードを開始する前の一般的なプロセスは次の通りです:

  • RoomDatabase を拡張する public abstract クラスを作成します。このクラスは、データベースホルダーとして機能します。Room が実装を作成するため、クラスは抽象的です。
  • クラスに @Database アノテーションを付けます。引数で、database の Entity を宣言し、version number を設定します。
  • companion オブジェクト内で、SleepDatabaseDao を返す abstract method または property を定義します。Room は body を生成します。
  • アプリ全体で必要な Room データベースのインスタンスは1つだけなので、RoomDatabase をシングルトンにします。
  • database が存在しない場合にのみ、Room の database builder を使用してデータベースを作成します。それ以外の場合は、既存のデータベースを返します。

Tip: コードはどの Room データベースでもほぼ同じであるため、このコードをテンプレートとして使用できます。

Database を作成する

  1. database package で、SleepDatabase.kt を開きます。
  2. ファイルに、RoomDatabase を拡張する SleepDatabase という抽象クラスを作成します。

クラスに @Database アノテーションをつける。

@Database()
abstract class SleepDatabase : RoomDatabase() {}
  1. Entity と version prameter が欠落しているとエラーが表示されます。Room がデータベースを構築できるように、@Database アノテーションにはいくつかの引数が必要です。

  2. Entity のリストを持つ唯一のアイテムとして SleepNight を提供します。

  3. version1 に設定します。schema を変更するたびに、version number を増やす必要があります。
  4. Schema の version history のバックアップを保持しないように、exportSchemafalse に設定します。
entities = [SleepNight::class], version = 1, exportSchema = false
  1. データベースは DAO について知る必要があります。クラス内で、SleepDatabaseDao を返す抽象値を宣言します。複数の DAO を持つことができます。
abstract val sleepDatabaseDao: SleepDatabaseDao
  1. その下に、companion オブジェクトを定義します。companion オブジェクトを使用すると、クライアントはクラスをインスタンス化せずにデータベースを作成または取得するためのメソッドにアクセスできます。このクラスの唯一の目的はデータベースを提供することであるため、データベースをインスタンス化する理由はありません。
companion object {}
  1. companion オブジェクト内で、データベースの private nullable 変数 INSTANCE を宣言し、それを null で初期化します。INSTANCE 変数は、データベースが作成されると、データベースへの参照を保持します。これにより、コストのかかるデータベースへの接続を繰り返し開くことを回避できます。

INSTANCE@Volatile アノテーションを付けます。揮発性変数の値がキャッシュされることはなく、全ての書き込みと読み取りはメインメモリとの間で行われます。これにより、INSTANCE の値が常に最新であり、全ての実行スレッドで同じであることを確認できます。つまり、1つのスレッドによって INSTANCE に加えられた変更は、他の全てのスレッドにすぐに表示され、例えば、2つのスレッドがそれぞれキャッシュ内の同じ Entity を更新して問題が発生するという状況は発生しません。

@Volatile
private var INSTANCE: SleepDatabase? = null
  1. INSTANCE の下で、companion オブジェクト内に、データベースビルダーが必要とする Context パラメーターを使用して getInstance() メソッドを定義します。SleepDatabase を返します。getInstance() がまだ何も返さないため、エラーが表示されます。
fun getInstance(context: Context): SleepDatabase {}
  1. getInstance() 内に、synchronized{} ブロックを追加します。これを渡して、context にアクセスできるようにします。

複数のスレッドが同時にデータべースインスタンスを要求する可能性があり、その結果、データベースが1つではなく2つになります。このサンプルアプリではこの問題が発生する可能性は低いですが、より複雑なアプリでは発生する可能性があります。データベースを同期するためにコードをラップするということは、一度に1つの実行スレッドのみがこのコードのブロックに入ることができることを意味します。これにより、データベースが一回だけ初期化されるようになります。

synchronized(this) {}
  1. 同期されたブロック内で、 INSTANCE の現在の値をローカル変数インスタンスにコピーします。これは、ローカル変数でのみ使用できる smart cast を利用するためです。
var instance = INSTANCE
  1. synchronized ブロック内で、synchronized ブロックの最後に return instance します。
return instance
  1. return ステートメントの上に if ステートメントを追加して、instancenull であるかどうか、つまりデータベースがまだないかどうかを確認します。
if (instance == null) {}
  1. instancenull の場合は、データベースビルダーを使用してデータベースを使用してデータベースを取得します。if ステートメントの body で、Room.databaseBuilder を呼び出し、渡したコンテキスト、データベースクラス、およびデータベースの名前 sleep_history_database を指定します。
instance = Room.databaseBuilder(
                           context.applicationContext,
                           SleepDatabase::class.java,
                           "sleep_history_database")
  1. 必要な migration strategy をビルダーに追加します。.fallbackToDestructiveMigration() を使用します。

通常、スキーマが変更された場合の migration strategy を移行オブジェクトに提供する必要があります。移行オブジェクトは、古いスキーマの全ての行を取得し、それらを新しいスキーマの行に変換して、データが失われないようにする方法を定義するオブジェクトです。Migration はこのコードラボの範囲を超えています。簡単な解決策は、データベースを破棄して再構築することです。これは、データが失われることを意味しています。

.fallbackToDestructiveMigration()
  1. 最後に .build() を呼びます。
.build()
  1. if ステートメントないの最後のステップとして INSTANCE = instance を割り当てます。
INSTANCE = instance
  1. 最終的なコードは次のようになります:
@Database(entities = [SleepNight::class], version = 1, exportSchema = false)
abstract class SleepDatabase : RoomDatabase() {

   abstract val sleepDatabaseDao: SleepDatabaseDao

   companion object {

       @Volatile
       private var INSTANCE: SleepDatabase? = null

       fun getInstance(context: Context): SleepDatabase {
           synchronized(this) {
               var instance = INSTANCE

               if (instance == null) {
                   instance = Room.databaseBuilder(
                           context.applicationContext,
                           SleepDatabase::class.java,
                           "sleep_history_database"
                   )
                           .fallbackToDestructiveMigration()
                           .build()
                   INSTANCE = instance
               }
               return instance
           }
       }
   }
}
  1. コードをビルドして実行します。

これで、Room データベースを操作するための全てのビルディングブロックができました。このコードはコンパイルされて実行されますが、実際に機能するかどうかを判断する方法はありません。したがってこれはいくつかの基本的なテストを追加する良い機会です。

SleepDatabase をテストする

このステップでは、提供されたテストを実行して、データベースが機能することを確認します。これにより、データベースを構築する前にデータベースが確実に機能するようになります。提供されているテストは基本的なものです。本番アプリの場合、全ての DAO で全ての機能とクエリを実行します。

スターターアプリには androidTest フォルダーが含まれています。この androidTest フォルダーには、Android インスチルメンテーションを含む単体テストが含まれています。これは、テストには Android フレームワークが必要であるため、物理デバイスまたは仮想デバイスでテストを実行する必要があることを示しています。もちろん、Android フレームワークを含まない純粋な単体テストを作成して実行することもできます。

  1. Android StudioandroidTest フォルダーで、SleepDatabaseTest ファイルを開きます。

  2. コードのコメントを解除します。

これは、再利用できる別のコードであるため、テストコードの簡単な説明です。

  • SleepDatabaseTest はテストクラスです。
  • @RunWith アノテーションは、テストをセットアップして実行するプログラムであるテストランナーを識別します。
  • セットアップ中に、@Before アノテーションが付けられた関数が実行され、SleepDatabaseDao を使用してメモリ内の SleepDatabase が作成されます。"In-memory" とは、このデータベースがファイルシステムに保存されず、テストの実行後に削除されることを意味します。
  • また、メインメモリデータベースを構築する場合、コードは別のテスト固有のメソッド allowMainThredQueries を呼び出します。デフォルトでは、メインスレッドでクエリを実行しようとするとエラーが発生します。このメソッドを使用すると、メインスレッドでテストを実行できます。これは、テスト中にのみ実行する必要があります。
  • @Test アノテーションが付けられたテストメソッドで、SleepNight を作成、挿入、取得し、それらが同じであることを表明します。何か問題が発生した場合は、例外をスローします。実際のテストでは、複数の @Test メソッドがあります。
  • テストが完了すると、@After アノテーションが付けられた関数が実行され、データベースが閉じられます。

  • テストファイルを右クリックして、Run 'SleepDatabaseTest' を選択します。

  • テストの実行後、SleepDatabaseTest ペインで、全てのテストをパスしたことを確認します。

全てのテストに合格したため、次のことが分かります。

  • database は正しく作成されます
  • SleepNight をデータベースに挿入できます
  • SleepNight を取得することができます
  • SleepNight の品質には正しい値があります

まとめ

  • テーブルを @Entity アノテーションが付けられたデータクラスとして定義します。@ColumnInfo アノテーションが付けられたプロパティをテーブルの列として定義します。
  • @Dao アノテーションが付けられたインターフェースとしてデータアクセスオブジェクト(DAO)を定義します。DAO は、Kotlin 関数をデータベースクエリにマップします。
  • アノテーションを使用して、@Insert@Delete および @Update 関数を定義します。
  • 他のクエリのパラメータとして、SQLite クエリ文字列と共に @Query アノテーションを使用します。
  • データベースを返す getInstance() 関数を持つ抽象クラスを返します。
  • instrumented 化されたテストを使用して、データベースと DAO が期待通りに機能していることをテストします。提供されているテストテンプレートとして使用できます。

その他の記事

yamato8010.hatenablog.com

yamato8010.hatenablog.com

yamato8010.hatenablog.com

Apple自動更新サブスクリプションとは?

自動更新サブスクリプションとは、ユーザがAppのコンテンツやプレミアム機能を継続的に利用できるようにするもので、ユーザがキャンセルしない限り自動的に更新されます。

やること

サブスクリプションを提供するにはアプリに StoreKit API を実装し、そのサブスクリプションApp Store Connect で設定して、1つのサブスクリプショングループ(アクセスレベル、価格、期間が異なるサブスクリプショングループ)に割り当てる必要があります。また、ユーザにサブスクリプションの情報・状況を分かりやすくするだけでなく、サブスクリプションの管理またはキャンセルをApp内から簡単に行えるディープリンクを設置するようにする必要があります。

サブスクリプションの作成

自動更新サブスクリプションを設定するには、App Store Connect を使用します。

サブスクリプショングループの作成

提供するサブスクリプションは、1つのサブスクリプショングループに割り当てる必要があります。サブスクリプショングループは、アクセスレベル、価格、期間が異なる複数のサブスクリプションで構成されているため、ユーザに最適なものを提供できるようになります。

グループ内でサブスクリプションにランク付けする

それぞれのサブスクリプションにレベルを割り当てることで、ユーザが利用できる機能を解放したり制限したりすることができるようになります。

アップグレード

ユーザが現在のサブスクリプションレベルよりも高いサブスクリプションを購入して、よりサービスレベルの高いコンテンツを利用できるようにします。

ダウングレード

ユーザが現在のサブスクリプションレベルよりも低いサービスレベルを選択したことを示します。現在のサブスクリプションが次回の更新日まで継続され、その後低いレベルでの価格に移行されます。

クロスグレード

ユーザが、同レベルのサブスクリプションに切り替えることを指します。

参考

その他の記事

yamato8010.hatenablog.com

yamato8010.hatenablog.com

yamato8010.hatenablog.com

Compilation Mode とは?

Xcode で設定できる Compilation Mode はProject のビルド方法を、速度を最適化する必要があるか、それらの最適化をスキップする必要があるかを設定することができます。簡潔に言い換えると、変更されたファイルのみをコンパイルするか、全体をコンパイルするかを選択することができるとも表現できます。

github.com

Compilation Mode の種類

Compilation Mode には主に下記の2つが存在します。

Primary-file

primary-file モードでは、driver は実行する必要のある作業を frontend processes に分割し、部分的な結果を出力し、全ての frontend が終了した時にそれらの結果をマージします。このモードには下記の2つのサブモードが存在します。

  • single-file sub-mode

    • ファイルごとに1つの frontend job を実行し、各 job には1つの primary があります。
  • batch sub-mode

    • CPU ごとに1つの frontend job を実行し、モジュールのファイルと同じサイズの "batch" を primary として識別します。

Xcode 内で Incremental を選択した場合は、こちらのモードが適用されます。

whole-module

whole-module optimization (WMO) mode では、driver は、モジュール全体に対して1つの frontend job を実行します。その frontend は、モジュールないの全てのファイルを一回に読み取り、それらを全て一度にコンパイルします。

最後に

DriverFrontend jobs とはなんぞやという方は下記の記事がわかりやすくまとまっているのでお勧めです。

qiita.com

参考

その他の記事

yamato8010.hatenablog.com

yamato8010.hatenablog.com

yamato8010.hatenablog.com

PHAuthorizationStatus の .limited とは?

Xcode12 の対応に伴って、新しく出た warnings を修正しようとしていると、PHAuthorizationStatus 周りで何やら Switch must be exhaustive という warning が発生していました。 全てのケース網羅してるはずだけどな〜と思い、PHAuthorizationStatus のソースを確認して見ると、何やら .limited というもの iOS14 から追加されていました。

@available(iOS 8, iOS 8, *)
public enum PHAuthorizationStatus : Int {

    
    @available(iOS 8, *)
    case notDetermined = 0 // User has not yet made a choice with regards to this application

    @available(iOS 8, *)
    case restricted = 1 // This application is not authorized to access photo data.

    // The user cannot change this application’s status, possibly due to active restrictions
    //   such as parental controls being in place.
    @available(iOS 8, *)
    case denied = 2 // User has explicitly denied this application access to photos data.

    @available(iOS 8, *)
    case authorized = 3 // User has authorized this application to access photos data.

    @available(iOS 14, *)
    case limited = 4 // User has authorized this application for limited photo library access. Add PHPhotoLibraryPreventAutomaticLimitedAccessAlert = YES to the application's Info.plist to prevent the automatic alert to update the users limited library selection. Use -[PHPhotoLibrary(PhotosUISupport) presentLimitedLibraryPickerFromViewController:] from PhotosUI/PHPhotoLibrary+PhotosUISupport.h to manually present the limited library picker.
}

.limited とは?

iOS14 から Limited Photos Library というものが追加されたらしく、ユーザがフォトライブラリーの許可ダイアログで、Select Photos... を選択した時の、PHAuthorizationStatus.limited になるようです。また、既存の PHPhotoLibrary.requestAuthorization リクエストを使用している場合は、ユーザが Select Photos... を選択した場合、PHAuthorizationStatus.authorized になります。.limited として値を受け取りたい場合は、PHPhotoLibrary.requestAuthorization(for: PHAccessLevel)PHAccessLevel を指定する必要があります。

余談

Select Photos... を選択した場合は、アプリでアクセス可能にする写真を選択するため PHPickerViewController が別プロセスで起動されます。これらのプロセスは、アクセス毎に選択する写真を変更するかどうかダイアログが表示されます。毎回アラートを表示させたくない場合は、info.plistPHPhotoLibraryPreventAutomaticLimitedAccessAlert で制御する必要があります。

参考

その他の記事

yamato8010.hatenablog.com

yamato8010.hatenablog.com

yamato8010.hatenablog.com

知識ゼロからの Kotlin Android アプリリリースへの軌跡 / Day18【LiveData transformations編】

学ぶこと

  • LiveDatTransformations を使用する方法

すること

  • ゲームを終了するタイマーを追加します。
  • Transformations.map() を使用して、ある LiveData を別の LiveData に変換します。

アプリの概要

今回は、前回のコードラボに続き、スコアの上に表示される一分間のカウントダウンタイマーを追加して、GuessTheWord アプリを改善します。カウントダウンが 0 に到達すると、タイマーはゲームを終了します。

また、transformation を使用して、経過時間 LiveData オブジェクトをタイマー文字列 LiveData オブジェクトに format します。変換された LiveData は、timer's text view のデータバインディングソースです。

Timer を追加

このタスクでは、アプリに CountDownTimer を追加します。word list が空の時にゲームが終了するのではなく、タイマーが終了するとゲームが終了します。Android には、タイマーの実装に使用する CountDownTimer という urility クラスが用意されています。

GameViewModel にタイマーのロジックを追加して、configuration の変更中にタイマーが破棄されないようにします。fragment には、タイマーが作動した時に timer text view を更新するコードが含まれています。

GameViewModel クラスに次の手順を実装します:

  1. タイマー定数を保持する companion オブジェクトを作成します。
companion object {

   // Time when the game is over
   private const val DONE = 0L

   // Countdown time interval
   private const val ONE_SECOND = 1000L

   // Total time for the game
   private const val COUNTDOWN_TIME = 60000L

}
  1. timer のカウントダウン時間を保存するには、_currentTime という MutableLiveData メンバー変数と backing property currentTime を追加します。
// Countdown time
private val _currentTime = MutableLiveData<Long>()
val currentTime: LiveData<Long>
   get() = _currentTime
  1. CountDownTimertimer と呼ばれる private メンバー変数を追加します。次の手順で初期化エラーを解決します。
private val timer: CountDownTimer
  1. init ブロック内で、タイマーを初期化して開始します。合計時間 COUNTDOWN_TIME を渡します。time interval には、ONE_SECOND を使用します。コールバックメソッド onTick() および onFinish() をオーバーライドして、タイマーを開始します。
// Creates a timer which triggers the end of the game when it finishes
timer = object : CountDownTimer(COUNTDOWN_TIME, ONE_SECOND) {

   override fun onTick(millisUntilFinished: Long) {
       
   }

   override fun onFinish() {
       
   }
}

timer.start()
  1. onTick() コールバックメソッドを実装します。これは interval または tick ごとに呼び出されます。渡されたパラメーター milisUntilFinished を使用して、_currentTime を更新します。milisUntilFinished は、タイマーが終了するまでの時間(ミリ秒単位)です。milisUntilFinishedseconds に変換し、それを _currentTime に割り当てます。
override fun onTick(millisUntilFinished: Long)
{
   _currentTime.value = millisUntilFinished/ONE_SECOND
}
  1. onFinish() コールバックメソッドは、タイマーが終了した時に呼び出されます。onFinish() を実装して、_currentTime を更新し、ゲーム終了イベントをトリガーします。
override fun onFinish() {
   _currentTime.value = DONE
   onGameFinish()
}
  1. nextWord() メソッドを更新して、ゲームを終了する代わりに、リストが空の時に単語リストをリセットします。
private fun nextWord() {
   // Shuffle the word list, if the list is empty 
   if (wordList.isEmpty()) {
       resetList()
   } else {
   // Remove a word from the list
   _word.value = wordList.removeAt(0)
   }
}
  1. onCleared() メソッド内で、メモリリークを回避するためにタイマーをキャンセルします。log ステートメントは不要になったため、削除できます。onCleared() メソッドは、ViewModel が破棄される前に呼び出されます。
override fun onCleared() {
   super.onCleared()
   // Cancel the timer
   timer.cancel()
}
  1. アプリを実行してゲームをプレイします。60秒待つと、ゲームは自動的に終了します。ただし、タイマーテキストは画面に表示されません。次にそれを修正します。

LiveData の Transformation を追加する

Transformation.map()) メソッドは、ソース LiveData でデータを操作を実行し、結果の LiveData オブジェクトを返す方法を提供します。これらの変換は、オブザーバーが返された LiveData オブジェクトを監視していない限り計算されません。

このメソッドは、ソース LiveData と関数をパラメーターとして受け取ります。この関数は、ソース LiveData を操作します。

Note: Transformation.map() に渡されるラムダ関数はメインスレッドで実行されるため、長時間実行されるタスクは含めないでください。

このタスクでは、経過時間 LiveData オブジェクトを MM:SS 形式の新しい文字列 LiveData オブジェクトにフォーマットします。また、フォーマットされた経過時間を画面に表示します。

game_fragment.xml レイアウトファイルには、すでに timer text view が含まれています。これまでのところ、text view には表示するテキストがないため、タイマーテキストは表示されていません。

  1. GameViewModel クラスで、currentTimeインスタンス化した後、currentTimeString という名前の新しい LiveData オブジェクトを作成します。このオブジェクトは、currentTime のフォーマットされた文字列バージョン用です。

  2. Transformation.map() を使用して currentTimeString を定義します。currentTime とラムダ関数を渡して時間をフォーマットします。DataUtils.formatElapsedTime()) ユーティリティーメソッドを使用してラムダ関数を実装できます。このメソッドは、long ミリ秒を要し、MM:SS 文字列形式にフォーマットします。

// The String version of the current time
val currentTimeString = Transformations.map(currentTime) { time ->
   DateUtils.formatElapsedTime(time)
}
  1. game_fragment.xml ファイルの timer text view で、text attribute を gameViewModelcurrentTimeString にバインドします。
<TextView
   android:id="@+id/timer_text"
   ...
   android:text="@{gameViewModel.currentTimeString}"
   ... />
  1. アプリを実行してゲームをプレイします。タイマーテキストは1秒に一回更新されます。全ての word を循環しても、ゲームは終了しないことに注目してください。タイマーが切れるとゲームが終了します。

まとめ

Transforming LiveData

  • LiveData の結果を変換したい場合があります。例えば、Date string を "hours:mins:seconds" としてフォーマットしたり、リスト自体を返すのではなく、リスト内のアイテムの数を返したりすることができます。LiveDataTransformations() クラスのヘルパーメソッドを使用します。

  • Transformations.map()) メソッドは、LiveData でデータ操作を実行し、別の LiveData オブジェクトを返す簡単な方法を提供します。推奨される方法は、Transformations クラスを使用するデータフォーマットロジックを UI データと共に ViewModel に配置することです。

変換の結果を TextView に表示する

  • ソースデータが ViewModelLiveData として定義されていることを確認してください。
  • newResult などの変数を定義します。Transformation.map() を使用して transformation を実行し、結果を変数に返します。
val newResult = Transformations.map(someLiveData) { input ->
   // Do some transformation on the input live data
   // and return the new value
}
  • TextView を含むレイアウトファイルが ViewModel<data> 変数を宣言していることを確認してください。
<data>
   <variable
       name="MyViewModel"
       type="com.example.android.something.MyViewModel" />
</data>
  • レイアウトファイルで、TextViewtext attribute を ViewModelnewResultバインディングに設定します。例えば:
android:text="@{SomeViewModel.newResult}"

Formatting dates

  • DateUtils.formatElapsedTime()) ユーティリティメソッドは、long ミリ秒数を要し、MM:SS string format を使用するように数値をフォーマットします。

その他の記事

yamato8010.hatenablog.com

yamato8010.hatenablog.com

yamato8010.hatenablog.com

Xcode12.0(GM) で carthage build ができない

Xcode12(swift5.2.4 -> swift5.3) への以降に伴い、Carthage で build していたライブラリを swift5.3 で build 使用とすると失敗するようになってしまいました🤔

$ carthage build XCGLogger --no-use-binaries --platform iOS
*** xcodebuild output can be found in /var/folders/8p/vbtkpjdx0_77vz_j8hvk0c040000gn/T/carthage-xcodebuild.z26IJU.log
*** Building scheme "ObjcExceptionBridging (iOS)" in XCGLogger.xcodeproj
Build Failed
    Task failed with exit code 1:

原因

どうやら、Xcode12 からは Apple Silicon Mac でのシュミレータの動作を正常に行えるようにするために、新しく arm64 ベースのシュミレータがバンドルされたことによって、実機用にビルドされていた arm64 のバイナリとシュミレータ用の arm64 バイナリが衝突していることが原因と考えられているそうです。

developer.apple.com

Carthage でも下記の Issue で対応が進んでいるようです👀

github.com

arm64?

yamato8010.hatenablog.com

解決

解決策は、Carthage の Issue に出ていた Solution を使用しました。まだ、Carthage 公式自体での対応は完了していないため、Shell Script 経由で、シュミレータに必要(Intel Mac で)のないアーキテクチャを Exclude してから、carthage コマンドを呼び出せるようにしてある感じです。

github.com

参考

その他の記事

yamato8010.hatenablog.com

yamato8010.hatenablog.com

yamato8010.hatenablog.com

知識ゼロからの Kotlin Android アプリリリースへの軌跡 / Day17【Data binding with ViewModel and LiveData編】

学ぶこと

すること

  • GuessTheWord レイアウトの View は、UI controller(fragment) を使用して情報を中継し、ViewModel オブジェクトと間接的に通信します。このコードラボでは、アプリの View を ViewModel オブジェクトにバインドして、View が ViewModel オブジェクトと直接通信するようにします。
  • LiveData をデータバインディングソースとして使用するようにアプリを変更します。この変更後、LiveData オブジェクトはデータの変更について UI に通知し、LiveData オブザーバーメソッドは不要になります。

アプリの概要

このコードラボでは、ViewModel オブジェクトの LiveData とデータバインディングを統合することにより、GuessTheWord アプリを改善します。これにより、レイアウト内の View と ViewModel オブジェクト間の通信が自動化され、LiveData を使用してコードを簡素化できます。

ViewModel データバインディングを追加する

以前のコードラボでは、GuessTheWord アプリの View にアクセスするためのタイプセーフな方法としてデータバインディングを使用していました。ただし、データバインディングの真の力は、データをアプリの View オブジェクトに直接バインドすることにあります。

現在の App Architecture

アプリでは、View は XML レイアウトで定義され、それらの View のデータは ViewModel オブジェクトに保持されます。各 View とそれに対応する ViewModel の間には、View 間のリレーとして機能する UI controller があります。

example:

  • Got It ボタンは、game_fragment.xml レイアウトファイルの Button view として定義されています。
  • ユーザーが Got It ボタンをタップすると、GameFragment のクリックリスナーが GameViewModel の対応するクリックリスナーを呼び出します。
  • スコアは GameViewModel で更新されます。

Button view と GameViewModel は直接通信しません。これらには、GameFragment にあるクリックリスナーが必要です。

data binding に渡された ViewModel

レイアウト内の View が、中間として UI controller に依存せずに、ViewModel オブジェクト内のデータと直接通信する場合はより簡単になります。

ViewModel オブジェクトは、GuessTheWord アプリの全ての UI データを保持します。ViewModel オブジェクトをデータバインディングに渡すことで、View と ViewModel オブジェクト間の通信の一部を自動化できます。

このタスクでは、GameViewModel クラスと、ScoreViewModel クラスを対応する XML レイアウトに関連付けます。また、クリックイベントを処理するリスナーバインディングを設定します。

GameViewModel のデータバインディングを追加する

このステップでは、GameViewModel を対応するレイアウトファイル game_fragment.xml に関連付けます。

  1. game_fragment.xml ファイルに、GameViewModel タイプのデータバインディング変数を追加します。Android Studio でエラーが発生した場合は、プロジェクトをクリーンアップして再構築してください。
<layout ...>

   <data>

       <variable
           name="gameViewModel"
           type="com.example.android.guesstheword.screens.game.GameViewModel" />
   </data>
  
   <androidx.constraintlayout...
  1. GameFragment ファイルで、GameViewModel をデータバインディングに渡します。

これを行うには、前の手順で宣言した binding.gameViewModel 変数に viewModel を割り当てます。viewModel が初期化された後、このコードを onCreateView() 内に配置します。Android Studio でエラーが発生した場合は、プロジェクトをクリーンアップして再構築してください。

// Set the viewmodel for databinding - this allows the bound layout access 
// to all the data in the ViewModel
binding.gameViewModel = viewModel

イベント処理にリスナーバインディングを使用する

Listener bindings は、onClick()onZoomIn()onZoomOut() などのイベントがトリガーされた時に実行されるバインディング式です。リスナーバインディングラムダ式として記述されます。

データバインディングはリスナーを作成し、View にリスナーを設定します。リッスンされたイベントが発生すると、リスナーはラムダ式を評価します。リスナーバインディングは、Android Gradle Plugin version 2.0 以降で機能します。詳細については、Layout and binding expressions 式を参照してください。

このステップでは、GameFragment のクリックリスナーを game_fragment.xml ファイルのリスナーバインディングに置き換えます。

  1. game_fragment.xml で、onClick attributes を skip_button に追加します。バインディング式を定義し、GameViewModelonSkip() メソッドを呼び出します。このバインディング式は、リスナーバインディングと呼ばれます。
<Button
   android:id="@+id/skip_button"
   ...
   android:onClick="@{() -> gameViewModel.onSkip()}"
   ... />
  1. 同様に、correct_button のクリックイベントを GameViewModelonCorrect() メソッドにバインドします。
<Button
   android:id="@+id/correct_button"
   ...
   android:onClick="@{() -> gameViewModel.onCorrect()}"
   ... />
  1. end_game_button のクリックイベントを GameViewModelonGameFinish() メソッドにバインドします。
<Button
   android:id="@+id/end_game_button"
   ...
   android:onClick="@{() -> gameViewModel.onGameFinish()}"
   ... />
  1. GameFragment で、クリックリスナーを設定するステートメントを削除し、クリックリスナーが呼び出す関数を削除します。それらを処理はすでに必要ありません。

削除するコード:

binding.correctButton.setOnClickListener { onCorrect() }
binding.skipButton.setOnClickListener { onSkip() }
binding.endGameButton.setOnClickListener { onEndGame() }

/** Methods for buttons presses **/
private fun onSkip() {
   viewModel.onSkip()
}
private fun onCorrect() {
   viewModel.onCorrect()
}
private fun onEndGame() {
   gameFinished()
}

ScoreViewModel のデータバインディングを追加します

このステップでは、ScoreViewModel を対応するレイアウトファイル score_fragment.xml に関連付けます。

  1. score_fragment.xml ファイルに、ScoreViewModel タイプのバインディング変数を追加します。この手順は、上記の GameViewModel に対して行った手順と似ています。
<layout ...>
   <data>
       <variable
           name="scoreViewModel"
           type="com.example.android.guesstheword.screens.score.ScoreViewModel" />
   </data>
   <androidx.constraintlayout.widget.ConstraintLayout
  1. score_fragment.xml で、onClick attribute を play_again_button に追加します。リスナーバインディングを定義し、ScoreViewModel で、onPlayAgain() メソッドを呼び出します。
<Button
   android:id="@+id/play_again_button"
   ...
   android:onClick="@{() -> scoreViewModel.onPlayAgain()}"
   ... />
  1. ScoreFragmentonCreateView() 内で、viewModel を初期化します。次に、binding.scoreViewModel バインディング変数を初期化します。
viewModel = ...
binding.scoreViewModel = viewModel
  1. ScoreFragment で、playAgainButton のクリックリスナーを設定するコードを削除します。Android Studio にエラーが表示された場合は、プロジェクトをクリーンアップして再構築します。

削除するコード:

binding.playAgainButton.setOnClickListener {  viewModel.onPlayAgain()  }
  1. アプリを実行します。アプリは以前と同じように機能するはずですが、button view は ViewModel オブジェクトと直接通信するようになりました。View は、ScoreFragment のボタンクリックハンドラーを介して通信しなくなりました。

Troubleshooting data-binding error messages

アプリがデータバインディングを使用する場合、コンパイルプロセスは、データバインディングに使用される中間クラスを生成します。アプリには、アプリをコンパイルしようとするまで Android Studio が検出しなエラーが含まれている可能性があるため、コードの記述中に警告や赤い Error code は表示されません。ただし、コンパイル時に、生成された中間クラスから発生する不可解なエラーが発生します。

不可解なエラーメッセージが表示された場合:

  1. Android StudioBuild ペインを注意深くみてください。data binding で終わる場所が表示された場合は、databinding にエラーがあります。

  2. レイアウト XML ファイルで、データバインディングを使用する onClick attributes のエラーを確認します。ラムダ式が呼び出す関数を探し、それが存在することを確認します。

  3. XML<data> セクションで、データバインディング変数のスペルを確認します。

たとえば、次の onCorrect() 関数名のスペルミスに注意してください。

android:onClick="@{() -> gameViewModel.onCorrectx()}"

XML ファイルの <data> セクションにある gameViewModel のスペルミスにも注意してください。

<data>
   <variable
       name="gameViewModelx"
       type="com.example.android.guesstheword.screens.game.GameViewModel" />
</data>

Android Studio は、アプリをコンパイルするまでこのようなエラーを検知しません。その後、コンパイラは次のようなエラーメッセージを表示します。

error: cannot find symbol
import com.example.android.guesstheword.databinding.GameFragmentBindingImpl"

symbol:   class GameFragmentBindingImpl
location: package com.example.android.guesstheword.databinding

data binding に LiveData を追加する

データバインディングは、ViewModel オブジェクトで使用される LiveData でうまく機能します。ViewModel オブジェクトにデータバインディングを追加したので、LiveData を組み込む準備ができました。

このタスクでは、GuessTheWord アプリを変更して、LiveData をデータバインディングソースとして使用し、LiveData オブザーバーメソッドを使用せずに、データの変更について UI に通知します。

word LiveData を game_fragment.xml ファイルに追加します

このステップでは、現在の word text view を ViewModelLiveData オブジェクトに直接バインドします。

  1. game_fragment.xml で、android:text attribute を word_text text view に追加します。

バインディング変数 gameViewModel を使用して、GameViewModel からの word である LiveData オブジェクトに設定します。

<TextView
   android:id="@+id/word_text"
   ...
   android:text="@{gameViewModel.word}"
   ... />

word.value を使用する必要がないことに注意してください。代わりに、実際の LiveData オブジェクトを使用できます。LiveData オブジェクトは、word の現在の値を表示します。word の値が null の場合、LiveData オブジェクトは空の文字列を表示します。

  1. GameFragmentonCreateView() で、gameViewModel を初期化した後、Fragment view をバインディング変数の Lifecycle Owner として設定します。これにより、上記の LiveData オブジェクトスコープが定義され、オブジェクトが game_fragment.xml の view を自動的に更新出来るようになります。
binding.gameViewModel = ...
// Specify the fragment view as the lifecycle owner of the binding.
// This is used so that the binding can observe LiveData updates
binding.lifecycleOwner = viewLifecycleOwner
  1. GameFragment で、LiveData word のオブザーバを削除します。

削除するコード:

/** Setting up LiveData observation relationship **/
viewModel.word.observe(viewLifecycleOwner, Observer { newWord ->
   binding.wordText.text = newWord
})
  1. アプリを実行してゲームをプレイします。現在、UI controller のオブザーバーメソッドなしで現在の word が更新されています。

score LiveData を score_fragment.xml ファイルに追加する

このステップでは、LiveData score を score fragment の score text view にバインドします。

  1. score_fragment.xml で、android:text attribute を score text view に追加します。scoreViewModel.scoretext attribute に割り当てます。score は整数であるため、String.valueOf() を使用して文字列に変換します。
<TextView
   android:id="@+id/score_text"
   ...
   android:text="@{String.valueOf(scoreViewModel.score)}"
   ... />
  1. ScoreFragment で、scoreViewModel を初期化した後、現在の Activity を binding 変数の lifecycle owner として設定します。
binding.scoreViewModel = ...
// Specify the fragment view as the lifecycle owner of the binding.
// This is used so that the binding can observe LiveData updates
binding.lifecycleOwner = viewLifecycleOwner
  1. ScoreFragment で、score オブジェクトのオブザーバーを削除します。

削除するコード:

// Add observer for score
viewModel.score.observe(viewLifecycleOwner, Observer { newScore ->
   binding.scoreText.text = newScore.toString()
})
  1. アプリを実行してプレイします。score fragment にオブザーバーがなくても、score fragment のスコアが正しく表示されていることに注目してください。

Data binding を使用した文字列フォーマットの追加

レイアウトでは、Data binding とともに string formatting を追加できます。このタスクでは、現在の単語をフォーマットして、その周りに引用符を追加します。次の図に示すように、Current Score をフォーマットして、現在のスコアの前に付けます。

  1. string.xml に、次の文字列を追加します。これらの文字列を使用して、wordscore のフォーマットを行います。%s%d は、現在の word と score のプレースホルダーです。
<string name="quote_format">\"%s\"</string>
<string name="score_format">Current Score: %d</string>
  1. game_fragment.xml で、quote_format 文字列リソースを使用するように word_text テキストビューの text attribute を更新します。gameViewModel.word を渡します。これにより、現在の word が引数としてフォーマット文字列に渡されます。
<TextView
   android:id="@+id/word_text"
   ...
   android:text="@{@string/quote_format(gameViewModel.word)}"
   ... />
  1. word_text と同様に score テキストビューをフォーマットします。game_fragment.xml で、text attribute を score_text テキストビューに追加します。%d プレースホルダーで表される1つの数値引数を取る文字列リソース score_format を使用します。この formatting string の引数として、LiveData オブジェクト score を渡します。
<TextView
   android:id="@+id/score_text"
   ...
   android:text="@{@string/score_format(gameViewModel.score)}"
   ... />
  1. GameFragment クラスの onCreateView() メソッド内で、score オブザーバーコードを削除します。

削除するコード:

viewModel.score.observe(viewLifecycleOwner, Observer { newScore ->
   binding.scoreText.text = newScore.toString()
})
  1. アプリを Clean、rebuild してからゲームをプレイします。current の word と score がゲーム画面でフォーマットされていることに注目してください。

まとめ

ViewModel データバインディング

  • data binding を使用して、ViewModel をレイアウトに関連付けることができます。
  • ViewModel オブジェクトは UI データを保持します。ViewModel オブジェクトをデータバインディングに渡すことで、View と ViewModel オブジェクト間の通信の一部を自動化できます。

ViewModel をレイアウトに関連付ける方法:

  • レイアウトファイルで、ViewModel タイプのデータバインディング変数を追加します。
   <data>

       <variable
           name="gameViewModel"
           type="com.example.android.guesstheword.screens.game.GameViewModel" />
   </data>
binding.gameViewModel = viewModel

Listener bindings

 android:onClick="@{() -> gameViewModel.onSkip()}"

Data binding への LiveData の追加

  • LiveData オブジェクトを data-binding ソースとして使用して、データの変更について UI に自動的に通知できます。
  • View を ViewModelLiveData オブジェクトに直接バインドできます。ViewModelLiveData が変更されると、UI controllers のオブザーバーメソッドなしで、レイアウトの View を自動的に更新できます。
android:text="@{gameViewModel.word}"
  • LiveData data binding を機能させるには、現在の activity(UI controller) を UI controller の binding 変数の lifecycle owner として設定します。
binding.lifecycleOwner = this

Data binding を使用した String formatting

  • データバインディングを使用すると、文字列の場合は %s、整数の場合は %d などのプレースホルダーを使用して文字列リソースをフォーマットできます。
  • View の text attribute を更新するには、LiveData オブジェクトを引数としてフォーマット文字列に渡します。
 android:text="@{@string/quote_format(gameViewModel.word)}"

その他の記事

yamato8010.hatenablog.com

yamato8010.hatenablog.com

yamato8010.hatenablog.com