iOSエンジニアのつぶやき

毎朝8:30に iOS 関連の技術について1つぶやいています。まれに釣りについてつぶやく可能性があります。

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

学ぶこと

  • LiveData オブジェクトが役立つ理由
  • ViewModel に保存されているデータに LiveData を追加する方法
  • MutableLiveData をいつどのように使用するか
  • LiveData の変更を監視するための observer method を追加する方法
  • バッキングプロパティを使用して LiveDataカプセル化する方法
  • UI controller とそれに対応する ViewModel の間で通信する方法

すること

  • GuessTheWord アプリの単語とスコアに LiveData を使用する
  • 単語またはスコアの変更を通知する observer を追加します
  • 変更された値を表示する text view を更新します
  • LiveData オブザーバーパターンを使用して、ゲーム終了イベントを追加します
  • Play Again ボタンを実装します

アプリの概要

このコードラボでは、ユーザーがアプリ内の全ての単語を循環した時にゲームを終了するイベントを追加することで、GuessTheWord アプリを改善します。また、score fragment に Play Aganin ボタンを追加して、ユーザがゲームを再度プレイできるようにします。

LiveData を GameViewModel に追加します

LiveData は、ライフサイクルを意識した observable なデータホルダークラスです。例えば、GuessTheWord アプリで現在のスコアの周りに LiveData をラップできます。このコードラボでは、LiveData のいくつかの特性について学習します:

  • LiveData は observable です。つまり、LiveData オブジェクトが保持するデータが変更されると、オブザーバーに通知されます。
  • LiveData はデータを保持します。LiveData は、任意のデータを使用できるラッパーです。
  • LiveData はライフサイクルに対応しています。observer を LiveData にアタッチすると、オブザーバーは LifecycleOwner(通常は Activity or Fragment) に関連付けられます。LiveData は、STARTEDRESUMED などの Active なライフサイクル状態にあるオブザーバーのみを更新します。LiveData と監視について詳しくは、こちら をご覧ください。

このタスクでは、GameViewModel の現在のスコアと現在の単語データを LiveData に変換することにより、任意のデータ型を LiveData オブジェクトにラップする方法を学習します。後のタスクで、これらの LiveData オブジェクトにオブザーバーを追加し、LiveData を監視する方法を学習します。

LiveData を使用するようにスコアと単語を変更します

  1. screens/game パッケージの下で、GameVieModel ファイルを開きます。

  2. scoreword のタイプを MutableLiveData に変更します。

MutableLiveData は、値を変更できる LiveData です。MutableLiveDataジェネリッククラスであるため、保持するデータのタイプを指定する必要があります。

// The current word
val word = MutableLiveData<String>()
// The current score
val score = MutableLiveData<Int>()
  1. GameViewModelinit ブロック内で、scoreword を初期化します。LiveData 変数の値を変更するには、変数で setValue() メソッドを使用します。Kotlin では、value プロパティを使用して setValue() を呼び出すことができます。
init {

   word.value = ""
   score.value = 0
  ...
}

LiveData オブジェクト参照を更新します

scoreword 変数は、LiveData プロパティになりました。このステップでは、value プロパティを使用して、これらの変数への参照を変更します。

  1. GameViewModelonSkip() メソッドないの scorescore.value に変更します。scorenull である可能性があるというエラーに注意してください。次にこのエラーを修正します。

  2. エラーを解決するには、onSkip()score.valuenull チェックを追加します。次に、scoreminus() 関数を呼び出します。この関数は、null で安全に減算を実行します。

fun onSkip() {
   score.value = (score.value)?.minus(1)
   nextWord()
}
  1. onCorrect() メソッドも同様に更新します。scorenull チェックを追加、plus 関数を追加します。
fun onCorrect() {
   score.value = (score.value)?.plus(1)
   nextWord()
}
  1. GameViewModel で、nextWord() メソッド内で、単語参照を word.value に変更します。
private fun nextWord() {
   if (!wordList.isEmpty()) {
       //Select and remove a word from the list
       word.value = wordList.removeAt(0)
   }
}
  1. GameFragmentupdateWordText() メソッド内で、viewModel.word の参照を viewModel.word.value に変更します。
private fun updateScoreText() {
   binding.scoreText.text = viewModel.score.value.toString()
}
  1. GameFragmentupdateScoreText() メソッド内で、viewModel.score の参照を viewModel.score.value に変更します。
private fun updateScoreText() {
   binding.scoreText.text = viewModel.score.value.toString()
}
  1. GameFragmentgameFinished() メソッド内で、viewModel.score の参照を viewModel.score.value に変更します。必要な null 安全性チェックを追加します。
private fun gameFinished() {
   Toast.makeText(activity, "Game has just finished", Toast.LENGTH_SHORT).show()
   val action = GameFragmentDirections.actionGameToScore()
   action.score = viewModel.score.value?:0
   NavHostFragment.findNavController(this).navigate(action)
}
  1. コードにエラーがないことを確認してください。アプリをコンパイルして実行します。アプリの機能は以前と同じである必要があります。

Onservers に LiveData オブジェクトをアタッチする

このタスクは、スコアと単語のデータを LiveData オブジェクトに変換した前のタスクと密接に関連しています。このタスクでは、LiveData オブジェクトを Onserver オブジェクトにアタッチします。Fragment view(viewLifecycleOwner) を LifecycleOwner として使用します。

なぜ viewLifecycleOwner を使用するのですか?

Fragment 自体が破棄されていなくても、ユーザーが Fragment から移動すると、Fragment view は破棄されます。これにより、基本的に、Fragment のライフサイクルと Fragment の view のライフサイクルが2つのライフサイクルが作成されます。Fragment view のライフサイクルではなく Fragment のライフサイクルを参照すると、Fragment の view を更新する時に微妙なバグが発生する可能性があります。したがって、Fragment view に影響を与える observer を設定する時は、次のことを行う必要があります:

  1. onCreateView() で observers をセットアップします
  2. viewLifecycleOwner をオブザーバーに渡します

  3. GameFragmentonCreateView() メソッド内で、現在のスコア viewModel.scoreLiveData オブジェクトに Observer オブジェクトをアタッチします。observe() メソッドを使用し、viewModel の初期化後にコードを配置します。ラムダ式を使用してコードを単純化します。(ラムダ式は、宣言されていない無名関数ですが、式としてすぐに渡されます。)

viewModel.score.observe(viewLifecycleOwner, Observer { newScore ->
})

Observer への参照を解決します。これを行うには、Observer をクリックし、Option + Enter を押して、androidx.lifecycle.Observer をインポートします。

  1. 作成したばかりの observer は、監視対象の LiveData オブジェクトが保持するデータが変更された時にイベントを受信します。observer 内で、score TextViewnewScore で更新します。
/** Setting up LiveData observation relationship **/
viewModel.score.observe(viewLifecycleOwner, Observer { newScore ->
   binding.scoreText.text = newScore.toString()
})
  1. Observer オブジェクトを現在の単語 LiveData オブジェクトにアタッチします。Observer オブジェクトを現在のスコアにアタッチしたのと同じ方法で行います。
/** Setting up LiveData observation relationship **/
viewModel.word.observe(viewLifecycleOwner, Observer { newWord ->
   binding.wordText.text = newWord
})

score または word の値が変更されると、画面に表示される score または word が自動的に更新されるようになりました。

  1. GameFragment で、updateWordText()updateScoreText()、およびそれらへの全ての参照を削除します。text view は LiveData observer method によって更新されるため、これらはもう必要ありません。

  2. アプリを実行します。ゲームアプリは以前と全く同じように機能するはずですが、現在は LiveData および LiveData observers を使用しています。

LiveData をカプセル化する

カプセル化は、オブジェクトの一部のフィールドへの直接アクセスを制限する方法です。オブジェクトをカプセル化する時は、プライベート内部フィールドを変更する一連のパブリックメソッドを公開します。カプセル化を使用して、他のクラスがこれらの内部フィールドを操作する方法を制御します。

現在のコードでは、外部クラスは、value プロパティを使用して、scoreword 変数を変更できます。このコードラボで開発しているアプリでは問題にならないかもしれませんが、本番アプリでは、ViewModel オブジェクトのデータを制御する必要があります。

ViewModel のみがアプリのデータを編集する必要があります。ただし。UI controller はデータを読み取る必要があるため、データフィールドを完全にプライベートにすることはできません。アプリのデータをカプセル化するには、ModuleLiveData オブジェクトと LiveData オブジェクトの両方を使用します。

MutableLiveData vs LiveData:

  • 名前が示すように、MutableLiveData オブジェクトのデータは変更できます。ViewModel 内では、データを編集可能である必要があるため、MutableLiveData を使用します。
  • LiveData オブジェクトのデータは読み取ることができますが、変更することはできません。ViewModel の外部からは、データは読み取り可能である必要がありますが、編集可能ではないため、データは LiveData として公開する必要があります。

この戦力を実行するには、Kotlin backing property を使用します。backing property を使用すると、正確なオブジェクト以外のゲッターから何かを返すことができます。このタスクでは、GuessTheWord アプリで score オブジェクトと word オブジェクトの backing property を実装します。

score と word に backing property を追加する

  1. GameViewModel で、現在の score オブジェクトを private にします。

  2. backing property で使用される命名規則に従うには、score_score に変更します。_score プロパティは、内部で使用されるゲームスコアの可変バージョンになりました。

  3. score と呼ばれる LiveData タイプのパブリックバージョンを作成します。

// The current score
private val _score = MutableLiveData<Int>()
val score: LiveData<Int>
  1. 初期化エラーが表示されます。このエラーは、GameFragment 内で、scoreLiveData 参照であり、スコアがその setter にアクセスできなくなったために発生します。Kotlin の getter と setter の詳細については、Getters and Setters を参照してください。

エラーを解決するには、GameViewModelscore オブジェクトの get() メソッドをオーバーライドし、backing property _score を返します。

val score: LiveData<Int>
   get() = _score
  1. GameViewModel で、スコアの参照を内部の可変バージョン _score に変更します。
init {
   ...
   _score.value = 0
   ...
}

...
fun onSkip() {
   _score.value = (score.value)?.minus(1)
  ...
}

fun onCorrect() {
   _score.value = (score.value)?.plus(1)
   ...
}
  1. score オブジェクトの場合と同様に、word オブジェクトの名前を _word に変更し、その backing property を追加します。
// The current word
private val _word = MutableLiveData<String>()
val word: LiveData<String>
   get() = _word
...
init {
   _word.value = ""
   ...
}
...
private fun nextWord() {
   if (!wordList.isEmpty()) {
       //Select and remove a word from the list
       _word.value = wordList.removeAt(0)
   }
}

game-finished イベント追加する

ユーザが End Game ボタンをタップすると、現在のアプリはスコア画面に移動します。また、プレーヤーが全ての単語を循環した時に、アプリがスコア画面に移動するようにします。プレーヤーが最後の単語を終了した後、ユーザがボタンをタップする必要が内容に、ゲームを自動的に終了する必要があります。

この機能を実装するには、全ての単語が表示された時にイベントがトリガーされ、ViewModel から Fragment に伝達される必要があります。これを行うには、LiveData オブザーバーパターンを使用して、ゲームが終了したイベントをモデル化します。

Observer pattern

observer pattern は software design pattern です。オブジェクト間の通信を指定します: observable(observable の "subject") と observers。observable は、その状態の変化について observer に通知するオブジェクトです。

このアプリの LiveData の場合、Observable("subject") は LiveData オブジェクトであり、obsrvers は Fragment などの UI controllers です。状態の変更は、LiveData 内にラップされたデータが変更されるたびに発生します。LiveData クラスは、ViewModel から Fragment への通信に不可欠です。

LiveData を使用して、ゲームが終了したイベントを検出します

このタスクでは、LiveData オブザーバーパターンを使用して、ゲームが終了したイベントをモデル化します。

  1. GameViewModel で、_eventGameFinish という BooleanMutableLiveData オブジェクトを作成します。このオブジェクトは、ゲーム終了イベントを保持します。

  2. _eventGameFinish オブジェクトを初期化した後、eventGameFinish という backing property を作成して初期化します。

// Event which triggers the end of the game
private val _eventGameFinish = MutableLiveData<Boolean>()
val eventGameFinish: LiveData<Boolean>
   get() = _eventGameFinish
  1. GameViewMode で、onGameFinish() メソッドを追加します。このメソッドで、ゲーム終了イベント eventGameFinishtrue に設定します。
/** Method for the game completed event **/
fun onGameFinish() {
   _eventGameFinish.value = true
}
  1. GameViewModelnextWord() メソッド内で、単語リストが空の場合はゲームを終了します。
private fun nextWord() {
   if (wordList.isEmpty()) {
       onGameFinish()
   } else {
       //Select and remove a _word from the list
       _word.value = wordList.removeAt(0)
   }
}
  1. GameFragmentonCreateView() 内で、viewModel を初期化した後、オブザーバーを eventGameFinish にアタッチします。onserver()) メソッドを使用します。ラムダ関数内で、gameFinished() メソッドを呼び出します。
// Observer for the Game finished event
viewModel.eventGameFinish.observe(viewLifecycleOwner, Observer<Boolean> { hasFinished ->
   if (hasFinished) gameFinished()
})
  1. アプリを実行し、ゲームをプレイして、全ての単語を確認します。アプリは End Game をタップするまでゲームの断片にとどまるのではなく、自動的にスコア画面に移動します。

単語リストが空になった後、eventGameFinish が設定され、game fragment 内の関連するオブザーバーメソッドが呼び出され アプリは画面 Fragment に移動します。

  1. 追加したコードにより、ライフサイクルの問題が発生しました。この問題を理解するには、GameFragment クラスで、gameFinished() メソッドの navigation コードをコメントアウトします。Toast メッセージを保持するようにしてください。
private fun gameFinished() {
       Toast.makeText(activity, "Game has just finished", Toast.LENGTH_SHORT).show()
//        val action = GameFragmentDirections.actionGameToScore()
//        action.score = viewModel.score.value?:0
//        NavHostFragment.findNavController(this).navigate(action)
   }
  1. アプリを実行し、ゲームをプレイして、全ての words を確認します。toast メッセージがゲーム画面の下部に短時間表示されます。これは予想される動作です。

次に、デバイスまたはエミュレータを回転させます。Toast がまた表示されます。これはバグです。ゲームが終了すると、Toast は一回だけ表示されるはずだからです。fragment が再作成されるたびに Toast が表示されるわけではありません。この問題は次のタスクで解決します。

ゲーム終了イベントをリセットする

通常、LiveData は、データが更新された場合にのみ observer に更新を配信します。この動作の例外は、observer は非アクティブ状態からアクティブ状態に変化した時にも更新を受信することです。

これが、ゲーム終了の Toast がアプリで繰り返しトリガーされる理由です。画面の回転後に game fragment が再作成されると、game fragment は非アクティブ状態からアクティブ状態に移行します。fragment 内の observer は既存の ViewModel に再接続され、現在のデータを受信します。gameFinished() メソッドが再トリガーされ、Toast が表示されます。

このタスクでは、GameViewModeleventGameFinish フラグをリセットすることにより、この問題を修正し、Toast を一回だけ表示します。

  1. GameViewModel で、onGameFinishComplete() メソッドを追加して、ゲーム終了イベント _eventGameFinish をリセットします。
/** Method for the game completed event **/

fun onGameFinishComplete() {
   _eventGameFinish.value = false
}
  1. GameFragment で、gameFinished() の最後に、viewModel オブジェクトで onGameFinishComplete() を呼び出します。今のところ、gameFinished() のナビゲーションコードはコメントアウトしたままにしておきます。
private fun gameFinished() {
   ...
   viewModel.onGameFinishComplete()
}
  1. アプリを実行してゲームをプレイします。全ての単語をパスし、デバイスの画面の向きを変更します。Toast は一回だけ表示されます。

  2. GameFragmentgameFinished() メソッド内で、navigation code のコメントを解除します。

private fun gameFinished() {
   Toast.makeText(activity, "Game has just finished", Toast.LENGTH_SHORT).show()
   val action = GameFragmentDirections.actionGameToScore()
   action.score = viewModel.score.value?:0
   findNavController(this).navigate(action)
   viewModel.onGameFinishComplete()
}

Android Studio からプロンプトが表示されたら、androidx.navigation.fragment.NavHostFragment.findNavController() をインポートします。

  1. アプリを実行してゲームをプレイします。全ての単語を読み終えたら、アプリが自動的に最終スコア画面に移動することを確認してください。

LiveData を ScoreViewModel に追加します

このタスクでは、ScoreViewMode でスコアを LiveData オブジェクトに変更し、それに observer をアタッチします。このタスクは、LiveDataGameViewModel に追加した時に行ったことと似ています。

完全を期すために ScoreViewModel にこれらの変更を加えて、アプリ内の全てのデータが LiveData を使用するようにします。

  1. ScoreViewModel で、スコア変数タイプを MutableLiveData に変更します。慣例により名前を _score に変更し、backing property を追加します。
private val _score = MutableLiveData<Int>()
val score: LiveData<Int>
   get() = _score
  1. ScoreViewModelinit ブロック内で、_score を初期化します。必要に応じて、ログを削除するか、init ブロックに残すかを選択します。
init {
   _score.value = finalScore
}
  1. ScoreFragmentonCreateView() 内で、viewModel を初期化した後、スコア LiveData オブジェクトの observer をアタッチします。ラムダ式内で、スコア値を score text view に直接割り当てるコードを ViewModel から削除します。

Code to add:

// Add observer for score
viewModel.score.observe(viewLifecycleOwner, Observer { newScore ->
   binding.scoreText.text = newScore.toString()
})

Code to remove:

binding.scoreText.text = viewModel.score.toString()

Android Studio から プロンプトが表示されたら、androidx.lifycycle.Observer をインポートします。

  1. アプリを実行してゲームをプレイします。アプリは以前と同じように機能するはずですが、現在は LiveData とオブザーバーを使用してスコアを更新しています。

Play Again button を追加する

このタスクでは、スコア画面に Play Again ボタンを追加し、LiveData イベントを使用してクリックリスナーを実装します。ボタンは、スコア画面からゲーム画面に移動するイベントをトリガーします。

アプリのスターターコードには Play Again ボタンが含まれていますが、ボタンは非表示になっています。

  1. res/layout/score_fragment.xml で、play_again_button ボタンの場合、visibility attributes の値を visible に変更します。
<Button
   android:id="@+id/play_again_button"
...
   android:visibility="visible"
 />
  1. ScoreViewModel で、_eventPlayAgain という Boolean を保持する LiveData オブジェクトを追加します。このオブジェクトは、LiveData イベントを保存して、スコア画面からゲーム画面に移動するために使用されます。
private val _eventPlayAgain = MutableLiveData<Boolean>()
val eventPlayAgain: LiveData<Boolean>
   get() = _eventPlayAgain
  1. ScoreViewModel で、イベント _eventPlayAgain を設定およびリセットするメソッドを定義します。
fun onPlayAgain() {
   _eventPlayAgain.value = true
}
fun onPlayAgainComplete() {
   _eventPlayAgain.value = false
}
  1. ScoreFragment で、eventPlayAgain のオブザーバーを追加します。onCreateView() の最後、return ステートメントの前にコードを配置します。ラムダ式内で、ゲーム画面に戻り、eventPlayAgain をリセットします。
// Navigates back to game when button is pressed
viewModel.eventPlayAgain.observe(viewLifecycleOwner, Observer { playAgain ->
   if (playAgain) {
      findNavController().navigate(ScoreFragmentDirections.actionRestart())
       viewModel.onPlayAgainComplete()
   }
})

Android Studio のプロンプトが表示されたら、androidx.navigation.fragment.findNavController をインポートします。

  1. ScoreFragmentonCreateView() 内で、クリックリスナーを Play Again ボタンに追加し、viewModel.onPlayAgain() を呼び出します。
binding.playAgainButton.setOnClickListener {  viewModel.onPlayAgain()  }
  1. アプリを実行してゲームをプレイします。ゲームを終了すると、スコア画面に最終スコアと Play Again ボタンが表示されます。Play Again ボタンをタップすると、アプリがゲーム画面に移動し、ゲームを再開できます。

まとめ

LiveData

  • LiveData は、Android Architecture Components の1つである、ライフサイクル対応の observable なデータホルダークラスです。
  • LiveData を使用して、データが更新された時に UI が自動的に更新されるようにすることができます。
  • LiveData は observable です。つまり、LiveData オブジェクトが保持するデータが変更された時に、Activity や Fragment などの observer に通知できます。
  • LiveData はデータを保持します。これは、任意のデータで使用できるラッパーです。
  • LiveData はライフサイクルに対応しています。つまり。STARTEDRESUMED などのアクティブなライフサイクル状態にある observer のみを更新します。

LiveData を追加する

  • ViewModel のデータ変数のタイプを LiveData または MutableLiveData に変更します。

MutableLiveData は、値を変更できる LiveData オブジェクトです。MutableLiveDataジェネリッククラスであるため、保持するデータのタイプを指定する必要があります。

  • LiveData が保持するデータの値を変更するには、LiveData 変数で setValue() メソッドを使用します。

LiveData をカプセル化する

  • ViewModel 内の LiveData は編集可能である必要があります。ViewModel の外部では、LiveData が読み取り可能である必要があります。これは、Kotlin backing property を使用して実装できます。
  • Kotlin backing property を使用すると、正確なオブジェクト以外の何かをゲッターから返すことができます。
  • LiveDataカプセル化するには、ViewModel 内で private MutableLiveData を使用し、ViewModel 外で LiveData backing property を返します。

Observable LiveData

  • LiveData はオブザーバーパターンに従います。"Observable" は LiveData オブジェクトであり、オブザーバーは fragment のような UI controller のメソッドです。LiveData 内にラップされたデータが変更されるたびに、UI controller オブザーバーメソッドに通知されます。
  • LiveData を observable にするには、observe()) メソッドを使用して、オブザーバーオブジェクト(activity や fragment) をオブザーバーの LiveData 参照にアタッチします。
  • LiveData オブザーバーパターンは、ViewModel から、UI controller への通信に使用できます。

その他の記事

yamato8010.hatenablog.com

yamato8010.hatenablog.com

yamato8010.hatenablog.com

arm64?x86_64?

今回は、iOS・WatchOS デバイス CPU の ISA(Instruction Set Architecture) について簡単にまとめてみようかと思います。

f:id:yum_fishing:20201007212257p:plain

ISA とは?

ソフトウェアとハードウェアのインターフェースで、あるマイクロプロセッサ(CPU/MPU) を動作させるための命令語の体系です。また、プロセッサが解釈して実行できる機械語の使用を定めたものです。

ja.wikipedia.org

バイスで使用されている CPU ISA 一覧

CPU ISA 32/64bit 端末
arm64 64bit iPhone5s~iPhoneX
arm64e 64bit iPhoneXS~iPhoneSE(第二世代)
armv7k 32bit AppleWatch~AppleWatchSeries3
arm64_32 64bit AppleWatchSeries3~AppleWatchSE
i386 32bit シュミレーター(主にWatchOSかな)
x86_64 64bit シュミレーター(主にiPhoneかな)

Xcode12 では新しい iOS・ WatchOS シュミレーターがバンドルされ、arm64 ベースのシュミレータがサポートされました。また、WatchOS でも x86_64 でのシュミレーターもサポートされたようです。arm64 に関しては、恐らく Apple Silicon に伴う ARM Mac で、シュミレートするために arm64 ベースのシュミレーターがサポートされたのだと考えられます🤔

developer.apple.com

medium.com

参考

その他の記事

yamato8010.hatenablog.com

yamato8010.hatenablog.com

yamato8010.hatenablog.com

rbenv で任意のバージョンがない時の対処法

プロジェクトルートのローカル ruby のバージョンを 2.7.1 に更新しようと、rbenv 経由でインストールすると下記のような Error が、

$ rbenv install 2.7.1
ruby-build: definition not found: 2.7.1

See all available versions with `rbenv install --list'.

If the version you need is missing, try upgrading ruby-build:

  brew update && brew upgrade ruby-build

可能な version 一覧を見てねとあるので、のぞいて見ると 2.7.1 が入ってませんでした。

$ rbenv install --list
1.8.5-p52
1.8.5-p113
1.8.5-p114
1.8.5-p115
1.8.5-p231
1.8.6
1.8.6-p36
1.8.6-p110
...

解決

調べてみると rbenv をアップグレードすることで、リリースされた最新の Rubyruby-build を更新できるとのこと。

github.com

指示通りhomebrew で rbenv をアップグレードしてあげることで、available versions2.7.1 が表示され、無事インストールすることができるようになりました🎉

$ brew upgrade rbenv ruby-build

参考

その他の記事

yamato8010.hatenablog.com

yamato8010.hatenablog.com

yamato8010.hatenablog.com

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

学ぶこと

すること

アプリの概要

レッスン5のコードラボでは、スターターコードから始めて、GuessTheWord アプリを開発します。GuessTheWord は、二人用の charades-style のゲームで、Player が協力して可能な限り最高のスコアを達成します。

最初のプレーヤーは、実際に単語自体を言わないように注意しながら、単語を実行します。

  • 二番目のプレーヤーが単語を正しく推測すると、最初のプレーヤーが Got it ボタンを押します。これにより、カウントが1つ増え、次の単語が表示されます。
  • 二番目のプレーヤーが単語を推測できない場合、最初のプレーヤーはスキップボタンを押します。これにより、カウントが1つ減り、次の単語にスキップします。
  • ゲームを終了するには、End Game ボタンを押します。(この機能は、シリーズの最初のコードラボのスターターコードには含まれていません)

Starter code を調べる

このタスクでは、Starter アプリをダウンロードして実行し、コードを調べます。

Get started

  1. Starter code をダウンロードして Android Studio で調べます。
  2. エミュレータでアプリを実行します。
  3. ボタンをタップします。Skip ボタンは次の単語を表示してスコアを1つ減らし、Got it ボタンは次の単語を表示してスコアを1つ増やします。End Game ボタンは実装されていないため、タップしても何も起こりません。

Code walkthrough を実行する

  1. Android Studio でコードを調べて、アプリがどのように機能するかを確認します。
  2. 特に重要な以下のファイルを必ず確認したください。

MainActivity.kt

このファイルには、デフォルトのテンプレート生成コードのみが含まれています。

res/layout/main_activity.xml

このファイルには、アプリの main layout が含まれています。NavHostFragment は、ユーザがアプリ内を移動する時に他の Fragment をホストします。

UI Fragment

Starter code には、com.example.android.guesstheword.screens パッケージの下の3つの異なるパッケージに3つの Fragment があります。

  • タイトル画面の title/TitleFragment
  • ゲーム画面の game/GameFragment
  • スコア画面の score/ScoreFragment

screens/title/TitleFragment.kt

Title Fragment は、アプリの起動時に表示される最初の画面です。クリックハンドラーが Play ボタンに設定され、ゲーム画面に移動します。

screens/game/GameFragment.kt

これは、ゲームのアクションのほとんどが行われる main fragment です。

  • 変数は、現在の単語と現在のスコアに対して定義されます。
  • resetList() メソッド内で定義されている wordList は、ゲームで使用される単語のサンプルリストです。
  • onSkip() メソッドは、Skip ボタンのクリックハンドラーです。スコアを1減らし、nextWord() メソッドを使用して次の単語を表示します。
  • onCorrect() メソッドは、GotIt ボタンのクリックハンドラーです。このメソッドは、onSkip() メソッドと同様に実装されます。

screens/score/ScoreFragment.kt

ScoreFragment はゲームの最終画面であり、プレーヤーの最終スコアを表示します。このコードラボでは、この画面を使用して最終スコアを表示する実装を追加します。

res/navigation/main_navigation.xml

navigation graph は、fragment が navigation を介してどのように接続されているかを示しています:

  • TitleFragment から、ユーザは GameFragment に移動できます。
  • GameFragment から、ユーザは ScoreFragment に移動できます。
  • ScoreFragmet から、ユーザは GameFragment に戻ることができます。

Starter app で問題を見つける

このタスクでは、GuessTheWord Starter app の問題を見つけます。

  1. Starter code を実行し、各単語の後に Skip または Got it をタップして、いくつかの単語でゲームをプレイします。

  2. ゲーム画面に単語と現在のスコアが表示されます。デバイスまたはエミュレータを回転させて、画面のむきをを変更します。現在のスコアが失われていることに注意してください。

  3. さらにいくつかの単語でゲームを実行します。ゲーム画面にスコアが表示されたら、アプリ閉じてから再度開きます。アプリの状態が保存されてないため、ゲームが最初から再開されることに注意してください。

  4. いくつかの単語でゲームをプレイしてから、End Game ボタンをタップします。何も起こらないことに注意してください。

アプリの問題:

  • Starter app は、デバイスの向きが変わった時やアプリがシャットダウンして再起動した時など、configuration の変更中にアプリの状態を保存および復元しません。 この問題は、onSaveInstanceState コールバックを使用して解決できます。ただし、onSaveInstanceState() メソッドを使用するには、状態をバンドルに保存するための追加のコードを記述し、その状態を取得するロジックを実装する必要があります。また、保存できるデータの量は最小限です。

  • ユーザが End Game をタップした時に、ゲーム画面はスコア画面に navigate されません。

これらの問題は、このコードラボで学習した app architecture components を使用して解決できます。

App architecture

app architecture は、アプリのクラスとそれらの間の関係を設計する方法であり、コードが整理され、特定のシナリオで適切に機能し、操作が簡単です。この4つのコードラボのセットでは、GuessTheWord アプリに加えた改善は、Android app architecture ガイドラインに従い、Android Architecture Components を使用します。Android app architecture は、MVVM architecture パターンに似ています。

GuessTheWordアプリは、関心の分離 の設計原則に従い、クラスに分割され、各クラスは個別の関心に対処します。レッスンのこの最初のコードラボでは、使用するクラスは UI controller、ViewModel および ViewModelFactory です。

UI controller

UI controller は、ActivityFragment などの UI-based のクラスです。UI controller には、View の表示やユーザ入力のキャプチャなど、UI と operation-system の相互作用を処理するロジックのみを含める必要があります。表示するテキストを決定するロジックなどの意思決定ロジックを UI controller に入れないでください。

GuessTheWord starter code では、UI controller は GameFragmentScoreFragmentTitleFragment の3つの Fragment です。"関心の分離" の設計原則に従って、GameFragment は、ゲーム要素を画面に描画し、ユーザがボタンをいつタップしたかを知ることだけを担当します。ユーザがボタンをタップすると、この情報が GameViewModel に渡されます。

ViewModel

ViewModel は、ViewModel に関連付けられた Fragment または Activity に表示されるデータを保持します。ViewModel は、データに対して簡単な計算と変換を実行して、UI controller によって表示されるデータを準備できます。この architecture では、ViewModel が意思決定を実行します。

GameViewModel は、スコア値、単語のリスト、現在の単語などのデータを保持します。これは、これらが画面に表示されるデータであるためです。GameViewModel には、データの現在の状態を判断するための簡単な計算を実行するためのビジネスロジックも含まれています。

ViewModelFactory

ViewModelFactory は、コンストラクターパラメーターの有無に関わらず、ViewModel オブジェクトをインスタンス化 します。

後のコードラボでは、UI controller と ViewModel に関連する他の Android Architecture Components について学習します。

GameViewModel を作成する

ViewModel クラスは、UI 関連のデータを格納および管理するように設計されています。このアプリでは、各 ViewModel が1つの Fragment に関連付けられています。

このタスクでは、最初の ViewModel をアプリに追加します。GameFragment のための GameViewModel です。また、ViewModel がライフサイクル対応であることの意味についても学びます。

GameViewModel クラスを追加する

  1. build.gradle(module:app) ファイルを開きます。dependencies ブロック内に、ViewModel の Gradle dependency を追加します。
//ViewModel
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0'
  1. パッケージ screens/game/ フォルダーに、GameViewModel という新しい Kotlin クラスを作成します。

  2. GameViewModel クラスに抽象クラス ViewModel を拡張させます。

  3. ViewModel がどのようにライフサイクルに対応しているかをよりよく理解するために、log ステートメントを使用して init ブロックを追加します。

class GameViewModel : ViewModel() {
   init {
       Log.i("GameViewModel", "GameViewModel created!")
   }
}

onCleard() をオーバーライドし、ロギングを追加

ViewModel は、関連づけられた Fragment が detach されるか、Activity が終了すると破棄されます。ViewModel が破棄される直前に、onCleared() コールバックが呼び出されてリソースがクリーンアップされます。

  1. GameViewModel クラスで、onCleared() メソッドをオーバーライドします。
  2. onCleared() 内にログステートメントを追加して、GameViewModel のライフサイクルを追跡します。
override fun onCleared() {
   super.onCleared()
   Log.i("GameViewModel", "GameViewModel destroyed!")
}

GameViewModel をゲームフラグメントに関連付けます

ViewModel は UI Controller に関連付ける必要があります。2つを関連付けるには、UI Controller 内に ViewModel への参照を作成します。

このステップでは、対応する UI Controller (GameFragmen)内に GameViewModel への参照を作成します。

  1. GameFragment クラスで、GameViewModel タイプのフィールドをクラス変数として追加します。
private lateinit var viewModel: GameViewModel

ViewModel を初期化する

画面の回転などの configuration 変更中に、Fragment などの UI Controller が再作成されます。ただし、ViewModel インスタンスは存続します。ViewModel クラスを使用して ViewModel インスタンスを作成すると、Fragmnet が再作成されるたびに新しいオブジェクトが作成されます。代わりに、ViewModelProvider を使用して ViewModel インスタンスを作成します。

Important: ViewModelインスタンスを直接インスタンス化するのではなく、常に ViewModelProvider を使用して ViewModel オブジェクトを作成してください。

ViewModelProvider の仕組み:

  • ViewModelProvider は、既存の ViewModel が存在する場合はそれを返し、まだ存在しない場合は新しい ViewModel を作成します。
  • ViewModelProvider は、指定されたスコープ(Activity または Fragment)に関連付けられた ViewModel インスタンスを作成します。
  • 作成された ViewModel は、スコープが有効である限り保持されます。例えば、スコープが Fragment の場合、Fragment がデタッチされるまで ViewModel は保持されます。

ViewModelProvider.get()) メソッドを使用して ViewModelProvider を作成し、ViewModel を初期化します。

  1. GameFragment クラスで、viewModel 変数を初期化します。binding 変数の定義後、このコードを onCreateView() 内に配置します。ViewModelProvider.get() メソッドを使用して、関連する GameFragment コンテキストと GameViewModel クラスを渡します。

  2. ViewModel オブジェクトの初期化の上に、ViewModelProvider.get() メソッド呼び出しをログに記録するログステートメントを追加します。

Log.i("GameFragment", "Called ViewModelProvider.get")
viewModel = ViewModelProvider(this).get(GameViewModel::class.java)
  1. アプリを実行します。Android Studio で Logcat ペインを開き、Game でフォルタリングします。エミュレータPlay ボタンをタップします。ゲーム画面が開きます。

Logcat に示されているように、GameFragmentonCreateView() メソッドは、ViewModelProvider.get() メソッドを呼び出して GameViewModel を作成します。GameFragmentGameViewModel に追加したロギングステートメントが Logcat に表示されます。

I/GameFragment: Called ViewModelProvider.get
I/GameViewModel: GameViewModel created!

バイスまたはエミュレータで自動回転設定を有効にし、画面の向きを数回変更します。GameFragment は毎回破棄されて再作成されるため、ViewModelProvider.get() が毎回呼び出されます。ただし、GameViewModel は一回だけ作成され、呼び出しごとに再作成または破棄されることはありません。

I/GameFragment: Called ViewModelProvider.get
I/GameViewModel: GameViewModel created!
I/GameFragment: Called ViewModelProvider.get
I/GameFragment: Called ViewModelProvider.get
I/GameFragment: Called ViewModelProvider.get
  1. ゲームを終了するか、game fragment から移動します。GameFragment が破棄されます。関連する GameViewModel も破棄され、コールバック onCleared() が呼び出されます。
I/GameFragment: Called ViewModelProvider.get
I/GameViewModel: GameViewModel created!
I/GameFragment: Called ViewModelProvider.get
I/GameFragment: Called ViewModelProvider.get
I/GameFragment: Called ViewModelProvider.get
I/GameViewModel: GameViewModel destroyed!

GameViewModel にデータを入力します

ViewModel は configuration の変更に耐えるため、configuration の変更に耐える必要があるデータに適した場所です。

  • 画面に表示するデータと、そのデータを処理するコードを ViewModel に配置します。
  • ActivityFragmentView は構成の変更後も存続しないため、ViewModel に Fragment、Activity、View への参照を含めることはできません。

比較のために、ViewModel を追加する前と ViewModel を追加した後、スターターアプリで GameFragment UI データがどのように処理されるかを次に示します:

  • ViewModel 追加前:
    • アプリが画面の回転などの configuration 変更を行うと、GameFragment が破棄され、再作成されます。データは失われます。
  • ViewModel を追加し、UI データを ViewModel に移動した後:
    • Fragment が表示する必要のある全てのデータは、ViewModel になりました。アプリで構成が変更されても、ViewModel は存続し、データは保持されます。

このタスクでは、データを処理するためのメソッドとともに、アプリの UI データを GameViewModel クラスに移動します。これを行うと、configuration の変更中にデータが保持されます。

データフィールドとデータ処理を ViewModel に移動します

次のデータフィールドとメソッドを GameFragment から GameViewModel に移動します:

  1. wordscorewordList のデータフィールドを移動します。wordscoreprivate でないことを確認してください。

View への参照が含まれているため、binding 変数の GameFragmentBinding を移動しないでください。この変数は、レイアウトを Inflate し、クリックリスナーを設定し、画面にデータを表示するために使用されます。これは Fragment の責任です。

  1. resetList() メソッドと nextWord() メソッドを移動します。これらのメソッドは、画面に表示する単語を決定します。

  2. onCreateView() メソッド内から、resetList() および nextWord() へのメソッド呼び出しを GameViewModelinit ブロックに移動します。

Fragment が作成されるたびにではなく、ViewModel の作成時に単語リストをリセットする必要があるため、これらのメソッドは init ブロックに含まれている必要があります。GameFragmentinit ブロックにあるログステートメントを削除できます。

GameFragmnetonSkip() および onCorrect() クリックハンドラーには、データを処理して UI を更新するためのコードが含まれています。UI を更新するコードは Fragment 内にとどまる必要がありますが、データを処理するためのコードを ViewModel に移動する必要があります。

今は、両方の場所に同じメソッドを配置します:

  1. onSkip() メソッドと onCorrect() メソッドを GameFragment から GameViewModel にコピーします。
  2. GameViewModel で、onSkip メソッドと onCorrect メソッドが private ではないことを確認します。これは、これらのメソッドを Fragment から参照するためです。

リファクタリング後の GameViewModel クラスのコードは次の通りです:

class GameViewModel : ViewModel() {
   // The current word
   var word = ""
   // The current score
   var score = 0
   // The list of words - the front of the list is the next word to guess
   private lateinit var wordList: MutableList<String>

   /**
    * Resets the list of words and randomizes the order
    */
   private fun resetList() {
       wordList = mutableListOf(
               "queen",
               "hospital",
               "basketball",
               "cat",
               "change",
               "snail",
               "soup",
               "calendar",
               "sad",
               "desk",
               "guitar",
               "home",
               "railway",
               "zebra",
               "jelly",
               "car",
               "crow",
               "trade",
               "bag",
               "roll",
               "bubble"
       )
       wordList.shuffle()
   }

   init {
       resetList()
       nextWord()
       Log.i("GameViewModel", "GameViewModel created!")
   }
   /**
    * Moves to the next word in the list
    */
   private fun nextWord() {
       if (!wordList.isEmpty()) {
           //Select and remove a word from the list
           word = wordList.removeAt(0)
       }
       updateWordText()
       updateScoreText()
   }
 /** Methods for buttons presses **/
   fun onSkip() {
       score--
       nextWord()
   }

   fun onCorrect() {
       score++
       nextWord()
   }

   override fun onCleared() {
       super.onCleared()
       Log.i("GameViewModel", "GameViewModel destroyed!")
   }
}

GameFragment のコード:

/**
* Fragment where the game is played
*/
class GameFragment : Fragment() {


   private lateinit var binding: GameFragmentBinding


   private lateinit var viewModel: GameViewModel


   override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
                             savedInstanceState: Bundle?): View? {

       // Inflate view and obtain an instance of the binding class
       binding = DataBindingUtil.inflate(
               inflater,
               R.layout.game_fragment,
               container,
               false
       )

       Log.i("GameFragment", "Called ViewModelProvider.get")
       viewModel = ViewModelProvider(this).get(GameViewModel::class.java)

       binding.correctButton.setOnClickListener { onCorrect() }
       binding.skipButton.setOnClickListener { onSkip() }
       updateScoreText()
       updateWordText()
       return binding.root

   }


   /** Methods for button click handlers **/

   private fun onSkip() {
       score--
       nextWord()
   }

   private fun onCorrect() {
       score++
       nextWord()
   }


   /** Methods for updating the UI **/

   private fun updateWordText() {
       binding.wordText.text = word
   }

   private fun updateScoreText() {
       binding.scoreText.text = score.toString()
   }
}

GameFragment のクリックハンドラーとデータフィールドへの参照を更新します

  1. GameFragment で、onSkip() メソッドと onCorrect() メソッドを更新します。コードを削除してスコアを更新し、代わりに viewModel で対応する onSkip() メソッドと onCorrect() メソッドを呼び出します。

  2. nextWord() メソッドを ViewModel に移動したため、GameFragment はそれにアクセスできなくなりました。

GameFragmentonSkip() メソッドと onCorrect() メソッドで、nextWord() の呼び出しを updateScoreText()updateWordText() 置き換えます。これらのメソッドは、画面にデータを表示します。

private fun onSkip() {
   viewModel.onSkip()
   updateWordText()
   updateScoreText()
}
private fun onCorrect() {
   viewModel.onCorrect()
   updateScoreText()
   updateWordText()
}
  1. scoreword 変数は現在 GameViewModel にあるため、GameFragment にある変数を更新します。
private fun updateWordText() {
   binding.wordText.text = viewModel.word
}

private fun updateScoreText() {
   binding.scoreText.text = viewModel.score.toString()
}

Reminder: Activity、Fragment および View は configuration の変更後も存続しないため、ViewModel にアプリの Activity、Fragment、View への参照を含めることはできません。

  1. GameViewModelnextWord() メソッド内で、updateWordText() メソッドと updateScoreText() メソッドの呼び出しを削除します。これらのメソッドは現在、GameFragment から呼び出されています。

  2. アプリをビルドし、エラーがないことを確認します。エラーが発生した場合は、プロジェクトをクリーンアップして rebuild します。

  3. アプリを実行し、いくつかの Word でゲームをプレイします。ゲーム画面を表示している時に、デバイスを回転させます。現在のスコアと単語は、向きを変更した後も保持されることに注目してください。

End Game ボタンのクリックリスナーを実装する

このタスクでは、End Game ボタンのクリックリスナーを実装します。

  1. GameFragment で、onEndGame() と呼ばれるメソッドを追加します。onEndGame() メソッドは、ユーザが End Game ボタンをタップした時に呼ばれます。
private fun onEndGame() {
   }
  1. GameFragmentonCreateView() メソッド内で、GotIt ボタンと Skip ボタンのクリックリスナーを設定するコードを見つけます。これら2行のすぐ下に、End Game ボタンのクリックリスナーを設定します。binding 変数を使用します。クリックリスナー内で、onEndGame() メソッドを呼び出します。
binding.endGameButton.setOnClickListener { onEndGame() }
  1. GameFragment で、gameFinished() というメソッドを追加して、アプリをスコア画面に移動します。Safe Args を使用して、引数としてスコアを渡します。
/**
* Called when the game is finished
*/
private fun gameFinished() {
   Toast.makeText(activity, "Game has just finished", Toast.LENGTH_SHORT).show()
   val action = GameFragmentDirections.actionGameToScore()
   action.score = viewModel.score
   NavHostFragment.findNavController(this).navigate(action)
}
  1. onEndGame() メソッド内で、gameFinished() メソッドを呼び出します。
private fun onEndGame() {
   gameFinished()
}
  1. アプリを実行し、ゲームをプレイします。End Game ボタンをタップします。アプリはスコア画面に移動しますが、最終スコアは表示されないことに注意してください。次のタスクでこれを修正します。

ViewModelFactory を使用する

ユーザがゲームを終了すると、ScoreFragment はスコアを表示しません。ViewModelScoreFragment によって表示されるスコアを保持する必要があります。factory medhot pattern を使用して、ViewModel の初期化中にスコア値を渡します。

factory method pattern は、factory method を使用してオブジェクトを作成する creational design pattern です。factory method は、同じクラスのインスタンスを返すメソッドです。

このタスクでは、スコアフラグメントのパラメーター化されたコンストラクターViewModelインスタンス化するファクトリメソッドを使用して ViewModel を作成します。

  1. スコアパッケージの下に、ScoreViewModel という新しい Kotlin クラスを作成します。このクラスは、score fragment の ViewModel になります。

  2. ViewModel から ScoreViewModel クラスを拡張します。最終スコアのコンストラクターパラメーターを追加します。ログステートメントを使用して init ブロックを追加します。

  3. ScoreViewModel クラスで、score という変数を追加して、最終スコアを保存します。

class ScoreViewModel(finalScore: Int) : ViewModel() {
   // The final score
   var score = finalScore
   init {
       Log.i("ScoreViewModel", "Final score is $finalScore")
   }
}
  1. score パッケージの下に、ScoreViewModelFactory という別の Kotlin クラスを作成します。このクラスは、ScoreViewModel オブジェクトのインスタンス化を担当します。

  2. ViewModelProvider.Factory から ScoreViewModelFactory クラスを拡張します。最終スコアのコンストラクターパラメーターを追加します。

class ScoreViewModelFactory(private val finalScore: Int) : ViewModelProvider.Factory {
}
  1. ScoreViewModelFactory で、Android Studio は実装されていない抽象メンバーに関するエラーを表示します。エラーを解決するには、create() メソッドをオーバーライドします。create() メソッドで、新しく作成された ScoreViewModel オブジェクトを返します。
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
   if (modelClass.isAssignableFrom(ScoreViewModel::class.java)) {
       return ScoreViewModel(finalScore) as T
   }
   throw IllegalArgumentException("Unknown ViewModel class")
}
  1. ScoreFragment で、ScoreViewModel および ScoreViewModelFactory のクラス変数を作成します。
private lateinit var viewModel: ScoreViewModel
private lateinit var viewModelFactory: ScoreViewModelFactory
  1. ScoreFragmentonCreateView() 内で、binding 変数を初期化した後、viewModelFactory を初期化します。ScoreViewModelFactory を使用します。コンストラクターパラメータとして、argument bundle から final score を ScoreViewModelFactory() に渡します。
viewModelFactory = ScoreViewModelFactory(ScoreFragmentArgs.fromBundle(arguments!!).score)
  1. onCreateView() で、viewModelFactory を初期化した後、viewModel オブジェクトを初期化します。ViewModelProvider.get() メソッドを呼び出し、関連する score fragment conext と viewModelFactory を渡します。これにより、viewModelFactory クラスで定義されたファクトリメソッドを使用して ScoreViewModel オブジェクトが作成されます。
viewModel = ViewModelProvider(this, viewModelFactory)
       .get(ScoreViewModel::class.java)
  1. onCreateView() メソッドで、viewModel を初期化した後、scoreText view のテキストを ScoreViewModel で定義された最終スコアに設定します。
binding.scoreText.text = viewModel.score.toString()
  1. アプリを実行してゲームをプレイします。スコアフラグメントに最終スコアが表示されていることに注意してください。

  1. option: ScoreViewModel でフィルタリングして、Logcat の ScoreViewModel ログを確認します。スコア値が表示されます。
2019-02-07 10:50:18.328 com.example.android.guesstheword I/ScoreViewModel: Final score is 15

Note: このアプリでは、スコアを viewModel.score 変数に直接割り当てることができるため、ScoreViewModelViewModelFactory を追加する必要はありません。ただし、viewModel の初期化時にデータが必要になる場合があります。

このタスクでは、ViewModel を使用するように ScoreFragment を実装しました。また、ViewModelFactory インターフェースを使用して ViewModel のパラメーター化されたコンストラクターを作成する方法も学習しました。

まとめ

  • Android app architecture ガイドラインでは、異なる責任を持つクラスを分離することを推奨しています。
  • UI Controller は ActivityFragment などの UI のベースとなるクラスです。UI controllers には、UI とオペレーティングシステムの相互作用を処理するロジックのみを含める必要があります。UI に表示するデータを含めるべきではありません。そのようなデータは ViewModel に配置します。

  • ViewModel クラスは、UI 関連のデータを格納および管理します。ViewModel クラスを使用すると、データは画面の回転などの configuration の変更に耐えることができます。

  • ViewModel は、推奨される Android Architecture Components の1つです。

  • ViewModelProvider.Factory は、ViewModel オブジェクトを作成するために使用できるインターフェースです。

次の表は、UI controllers とそれらのデータを保持する ViewModel インスタンスを比較しています:

UI controller ViewModel
UI controller の例は、このコードラボで作成した ScoreFragment です。 ViewModel の例は、このコードラボで作成した ScoreViewModel です。
UI に表示されるデータは含まれていません。 UI controller が UI に表示するデータが含まれます。
データを表示するためのコードと、クリックリスナーなどのユーザーイベントコードが含まれています。 データ処理用のコードが含まれています。
configuration が変更されるたびに破棄され、再作成されます。 関連づけられた UI controller が完全に消えた場合にのみ破棄されます。Activity の場合、Activity が終了した場合、または Fragment の場合、Fragment が切り離された場合
View が含まれています。 Activity、Fragment、または View への参照を含めることはできません。これらは configuration の変更後も存続しないためですが、ViewModel には存続します。
関連する ViewModel への参照が含まれています。 関連する UI controller への参照は含まれていません。

その他の記事

yamato8010.hatenablog.com

yamato8010.hatenablog.com

yamato8010.hatenablog.com

NSDataDetector を使う際に少し気を付けるところ

今回は、NSDataDetector を使って特定の文字列を抽出する時に少しハマったポイントを紹介したいと思います。

ハマりポイント

いつもの如く NSDataDetector を使用して、文字列の中に存在するリンクを下記のように取得していると、意図しない場所でリンクがきれて読み込まれてしまうという問題が発生しました。

func getLinkTextList(text: String) -> [String] {
    guard let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) else {
        return []
    }
    let enableLinkTuples = detector.matches(in: text, range: NSRange(location: 0, length: text.count))
    return enableLinkTuples.map { checkingResult -> String in
        return (text as NSString).substring(with: checkingResult.range)
    }
}

let linkString = getLinkTextList(text: "僕の記事はこちら🙃👨<200d>💻🍎✨🎣 https://yamato8010.hatenablog.com/").first!

print(linkString) // 出力: https://yamato8010.hatenabl <- 変なところできれてる!

原因としては、絵文字などが入っている場合に、NSDataDetector がパースする際の文字列サイズと、text.count で取得していた文字列のサイズが合わずに途中までしかリンクが読み込まれないというものでした✍️

恐らく、NSString が、UTF-16 コードユニットのシーケンスとして表示されるため、Objective-C 由来の NSDataDetector も UTF-16 でパースされるのが原因かと思われます🤔

developer.apple.com

解決

text.counttext.utf16.count に変えて実行すると正常にリンクを表示することができるようになりました🎉

func getLinkTextList(text: String) -> [String] {
    guard let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) else {
        return []
    }
    let enableLinkTuples = detector.matches(in: text, range: NSRange(location: 0, length: text.utf16.count))
    return enableLinkTuples.map { checkingResult -> String in
        return (text as NSString).substring(with: checkingResult.range)
    }
}

let linkString = getLinkTextList(text: "僕の記事はこちら🙃👨<200d>💻🍎✨🎣 https://yamato8010.hatenablog.com/").first!

print(linkString) // 出力: https://yamato8010.hatenablog.com/

余談

文字コードに下記の記事を参考にまとめました✍️

https://qiita.com/yamoridon/items/6f73ffe02ab46eb6ae85

property 内容
String.utf16.count 2バイトを1単位として、1つの文字番号を1単位(2バイト)または2単位(4バイト)の可変長で文字サイズ(単位)を取得します。NSString などの文字サイズはこの utf16 での単位数になります。
String.unicodeScalars.count 文字のサイズを utf32 で表します。utf32 は1単位を4バイトの固定長として文字番号を扱えるようにしたもので、これにより全ての文字番号を1単位で扱えるようになります。
String.utf8.count 英数文字のような頻繁に使用される文字は1バイトで、マイナーな文字を複数バイトで扱うようにすることで、1バイトを1単位として、1~4単位の可変長で文字サイズを取得できるようにします。これによって、容量を無駄に使用する英数字などを使う際に容量を無駄に使う必要がなくなります。
String.count UI に表示される見た目の文字数で、Unicode によって Grapheme Cluster として定義されている形式

参考

その他の記事

yamato8010.hatenablog.com

yamato8010.hatenablog.com

yamato8010.hatenablog.com

ABI Stability とは?

今回は、Swift5.0・Swift5.1 でそれぞれ対応された、ABI Stability と Module Stability について簡単にまとめてめてみたいと思います。

ABI Stability とは?

ABI Stability は異なるバージョンのコンパイラから生成されたバイナリ同士をリンクできるようにするための機能で、Swift5.0 からサポートされました。これにより、standard library や runtime library などをアプリごとに含める必要がなくなり、アプリバンドルのサイズを削減できるようになりました。

swift.org

Module Stability とは?

Module Stability は異なるバージョンのコンパイラから生成された Module をコンパイル(≒インポート)できるようにするための機能で、Swift5.1 からサポートされました。これにより、Swift5.1 以降でコンパイルされた Module は、Swift5.1 以降に違うバージョンのコンパイラから正常にインポートできるようになります。ただし、これには、.swiftinterface というモジュール記述ファイルが必要で、ライブラリ側で BUILD_LIBRARY_FOR_DISTRIBUTIONYes に設定されていない場合には、.swiftinterface が生成されず、Module Stability の恩恵を受けることができません。

forums.swift.org

余談

上記のことを踏まえて、Xcode のアップデート作業を行っていると、ライブラリのバージョンを更新していないのにも(BUILD_LIBRARY_FOR_DISTRIBUTIONNo) 関わらず Swift5.2.2 から Swift5.2.4 では、問題なくバイナリをコンパイル・リンクできてしまいました🤔 後々調べて見ると、swift4.0swift4.0.1swift4.0.2 以外のバージョンでは(3.1~4.2.1の話)、基本的に major・minor が合っていればバイナリ互換性があるようで、今回のように Swift5.2.2 から Swift5.2.4 ではライブラリをビルドする必要がなかったようです✍️ とはいえ、major・minor のアップデートがあると、また面倒な rebuild 作業をしなくてはならないので、徐々に Module Stability を活用していきたいところです😅

https://qiita.com/gamako/items/c12edf77fe0632903b77#%E3%81%BE%E3%81%A8%E3%82%81

参考

その他の記事

yamato8010.hatenablog.com

yamato8010.hatenablog.com

yamato8010.hatenablog.com

Attributes Inspector でカスタムプロパティを指定できるようにする

今回は、Storyboard や xib などの Interface Builder を使ってレイアウトを設計する際などに知っておくと少し便利な、@IBDesignable・@IBInspectable について簡単にまとめていこうかと思います。

f:id:yum_fishing:20201003204610p:plain

@IBDesignable とは?

Storyboard や xib などの Interface Builder で、コードまたはカスタムインスペクターによる変更を元にレイアウトをライブレダンリングできるようにするためのアノテーションで、Xcode6以降から利用できるようになったそうです。

実際に使用した例が下記のようになります。prepareForInterfaceBuilder は、designable object が Inteface Builder で作成された時に、生成されます(厳密には ViewClass に designable object が指定された時に呼び出される)。下記のようにすることで、設定したプロパティなどが変更された際に prepareForInterfaceBuilder() が呼び出され、レイアウトがライブレンダリングされます。基本的には、Inteface Builder がこのメソッド呼び出すので、システムから呼ばれることはありません。

@IBDesignable class TestView: UIView {

    override init(frame: CGRect) {
        super.init(frame: frame)
    }

    required init?(coder: NSCoder) {
        super.init(coder: coder)
    }

    // MARK: - UI Setup
    override func prepareForInterfaceBuilder() {
        self.backgroundColor = color
        self.layer.cornerRadius = cornerRadius
        self.layer.borderWidth = borderWidth
        self.layer.borderColor = borderColor.cgColor
    }

    // MARK: - Properties
    var color: UIColor = .red {
        didSet {
            self.backgroundColor = color
        }
    }
    
    var cornerRadius: CGFloat = 40 {
        didSet {
            self.layer.cornerRadius = cornerRadius
        }
    }

    var borderWidth: CGFloat = 10 {
        didSet {
            self.layer.borderWidth = borderWidth
        }
    }
    
    var borderColor: UIColor = .black {
        didSet {
            self.layer.borderColor = borderColor.cgColor
        }
    }
}

また、mata,required init?(coder: NSCoder) のみ実装していると下記のような Error が出るので、override init(frame: CGRect) も実装します。

IB Designables: Failed to render and update auto layout status

@IBInspectable とは?

Storyboard や xib で指定することのできる、attributes をコードで作成できるようにするためのアノテーションです。これにより、指定されたプロパティは、attributes inspector に表示され、Inteface Builder から値を変更できるようになります。また、@IBInspectable に指定できる型には決まりがあり下記のようなものが利用可能です。

先ほどのコードを下記のように修正すると attributes inspector から、それぞれ @IBInspectable アノテーションがついがプロパティを操作できるようになります。

@IBDesignable class TestView: UIView {

    override init(frame: CGRect) {
        super.init(frame: frame)
    }

    required init?(coder: NSCoder) {
        super.init(coder: coder)
    }

    // MARK: - UI Setup
    override func prepareForInterfaceBuilder() {
        self.backgroundColor = color
        self.layer.cornerRadius = cornerRadius
        self.layer.borderWidth = borderWidth
        self.layer.borderColor = borderColor.cgColor
    }

    // MARK: - Properties
    @IBInspectable var color: UIColor = .red {
        didSet {
            self.backgroundColor = color
        }
    }

    @IBInspectable var cornerRadius: CGFloat = 40 {
        didSet {
            self.layer.cornerRadius = cornerRadius
        }
    }

    @IBInspectable var borderWidth: CGFloat = 10 {
        didSet {
            self.layer.borderWidth = borderWidth
        }
    }

    @IBInspectable var borderColor: UIColor = .black {
        didSet {
            self.layer.borderColor = borderColor.cgColor
        }
    }
}

また、enum を操作できるようにしたいところですが、enumObjective-C でサポートされていないため、下記のような Error をはかれます。

    enum Hoge {
        case a
        case b
    }

    @IBInspectable var hoge: Hoge = .a
Property cannot be marked @IBInspectable because its type cannot be represented in Objective-C

下記のようにして、Error を退避することも可能ですが、IB は Objective-C でも enum をサポートしていないため、今度は attributes inspector に表示されなくなります😅

    @objc enum Hoge: Int {
        case a
        case b
    }

    @IBInspectable var hoge: Hoge = .a

参考

その他の記事

yamato8010.hatenablog.com

yamato8010.hatenablog.com

yamato8010.hatenablog.com