Android Kotlin日本語チュートリアル-⑥クラス間の依存と再利用性を考える
Android Kotlin日本語チュートリアル
本連載記事はこれからAndroidアプリ開発を始める人に向けたチュートリアルです。
コンセプトは
- プログラミングをあまり知らない人でも完走できる
- プログラミングにある程度詳しい人にも満足できる
- 実用的な知識を提供する
- とにかくわかりやすく
で、全9回と長めですが頑張っていきましょう。
このチュートリアルを終える頃には、Android開発の土台が形成されているだけでなくアプリケーションアーキテクチャの知識が出来上がっているはずです。
作成するのは以下のようなメモアプリです。
完成品は HiroshiARAKI/AndroidKotlinTutrialで公開していますので適宜参考にしてください。
第6回 : クラス間の依存と再利用性を考える
第6回は、Android開発に限らず一般的に有用な知識を学びましょう。
アップデートや保守を視野に入れた、長期でソフトウェア開発をするのが一般的な現在では、如何にプロジェクトを管理しやすくするか、如何に簡単に既存のコードを再利用できるかが重要になります。
一人で開発する分には「そんなこと関係ない」と思うかもしれませんが、有名な「リーダブルコード」という書籍では以下のように述べています。
もしかすると、こんな風に考えているかもしれないね。「他の人が理解できるって誰が得するんだよ?このコードを使っているのはオレだけなんだぞ!」
でもね、たとえ君ひとりのプロジェクトだけだったとしても、この目標に取り組むだけの価値があるんだ。「他の人」というのは、自分のコードに見覚えのない6ヶ月後の「君自身」かもしれない。
AndroidViewModelを使ってみる
前回はFactoryクラスを作成してNewMomeViewModel
へのMemoRepository
挿入を実現しました。
実は他にも方法はあって、AndroidViewModel
と呼ばれる内部でApplication
を保持するViewModelが用意されています。
このAndroidViewModel
は特別に独自Factoryクラスを用意せずとも、viewModels()
メソッドがよろしく生成してくれます。
ただ、今回は独自のApplication
クラスを定義しているので、まずは独自ViewModelを作って、各ViewModelはそれを継承するようにしてみます。
/**
* Memoアプリで使用する独自[ViewModel]クラス。
*/
abstract class MemoViewModel(application: Application) : AndroidViewModel(application) {
protected val memoRepository = (application as App).memoRepository
}
これにより、NewMemoViewModelも以下のように書けます。
NewMemoViewModel.kt
class NewMemoViewModel(app: Application) : MemoViewModel(app) {
private val _insertEvent = MutableLiveData<Unit>()
/** DB挿入イベントLiveData */
val insertEvent: LiveData<Unit> = _insertEvent
/**
* Memoを登録する
*/
fun registerMemo(
title: String,
contents: String,
expireDuration: Long = TimeUnit.DAYS.toMillis(7)) {
viewModelScope.launch(Dispatchers.IO) {
memoRepository.insertMemo(title, contents, expireDuration)
_insertEvent.postValue(Unit)
}
}
}
///**
// * [NewMemoViewModel]を生成する独自のFactoryクラス
// */
//class NewMemoViewModelFactory(
// private val memoRepository: MemoRepository
// ) : ViewModelProvider.Factory {
//
// @Suppress("UNCHECKED_CAST") // `as T` のWarningを抑制するアノテーション。無くても実行に影響はない
// override fun <T : ViewModel?> create(modelClass: Class<T>): T {
// // modelClassがNewMemoViewModelの親クラスであれば
// if (modelClass.isAssignableFrom(NewMemoViewModel::class.java))
// return NewMemoViewModel(memoRepository) as T
//
// throw IllegalArgumentException("Unknown ViewModel class")
// }
//}
NewMemoFragment.kt
class NewMemoFragment : Fragment(R.layout.fragment_new_memo) {
private val viewModel: NewMemoViewModel by viewModels()
// ...
AndroidViewModel
を使うとスッキリかけますね。
一応前回学んだ、Factoryクラスを使った初期化も覚えておきましょう。
今回の独自でMemoViewModel
を抽象クラスで用意する大きなメリットは、同じようなFactoryクラスを何度も書かなくて良くなるという点です。
ソフトウェア開発において、再利用性は大事な概念の一つです。
有名なDRY (Don’t repeat yourself)と通ずるところがありますね。
MainViewModelからデータベースにアクセスする
そうしたらMainViewModel
もMemoVewiModel
を継承させてデータにアクセスできるようにしましょう。
MemoRepository.kt
class MemoRepository(context: Context) {
// ...
/** ... */
fun insertMemo(title: String, contents: String, expiredDuration: Long) {
// ...
}
/**
* 全てのMemoを取得する
*/
fun fetchAllMemo(): List<Memo> {
val memoItems = dao.fetchAll()
Log.d(this::class.simpleName, "fetched Memo Item = $memoItems")
return memoItems
}
// ...
}
MainViewModel.kt
class MainViewModel(app: Application) : MemoViewModel(app) {
/** ViewModel内で扱うミュータブルなLiveData */
private val _memoItems = MutableLiveData<List<Memo>>()
/** 外部公開用のイミュータブルなLiveData */
val memoItems: LiveData<List<Memo>> = _memoItems
/** メモリストを読み込む */
fun loadMemoItems() {
viewModelScope.launch(Dispatchers.IO) {
_memoItems.postValue(memoRepository.fetchAllMemo())
}
}
}
これで実際に、新しくメモを追加してみると…何も起こりませんね。
Repository側で、Log.d()
を書いているので、コンソール (:Run)にログが出ているはずです。
見てみると初回起動の取得しかログがありません。
原因はなんでしょうか?
FragmentからActivityに通知する
原因はNewMemoFragmentからMainActivityにデータ挿入のイベント通知ができていないからです。
Fragmentに限らずですが、こう言った上位のクラスにイベントを通知したいときは、リスナーインターフェイスを下位クラスに定義して、上位クラスでそれを実装するようにします。
理解が追いついていないかもしれませんが、まずは実装してみます。
NewMemoFragment.kt
class NewMemoFragment : Fragment(R.layout.fragment_new_memo) {
interface Listener {
fun onDismiss()
}
private val viewModel: NewMemoViewModel by viewModels()
private lateinit var listener: Listener
override fun onAttach(context: Context) {
super.onAttach(context)
// ここでリスナーをセットしておく。もしActivityがListenerを実装していなければ例外を出してアプリを落とす。
listener = context as? Listener
?: throw ClassCastException("The parent activity needs to implement OnDismissListener")
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
// ...
}
private fun dismiss() {
listener.onDismiss()
parentFragmentManager.popBackStack()
}
}
MainActivity.kt
class MainActivity : AppCompatActivity(), NewMemoFragment.Listener {
// ...
override fun onDismiss() {
viewModel.loadMemoItems()
}
}
これで、データ挿入をしたときにメモの更新が行われると思います。
何が起こっているか理解するためには、まずは以下の図で示すように、MainActivity
is NewMemoFragment.Listener
であることをまずは理解してください。
また、onAttach(context: Context)
のcontext
はMainActivityの参照を指しています。
したがって、NewMemoFragmentのdismiss()
で呼び出している、listener.onDismiss()
はMainActivityのonDismiss()
に一致します。
このようにリスナーを用意することで、NewMemoFragmentがMainActivityに直接依存することなくイベントを通知できます。
前も言いましたが、一番やってはいけないのはNewMemoFragmentにMainActivityの参照を持たせて、MainActivityのメソッドを直接呼び出すことです。
今回も、MainActivityをlistener
で保持していて上記違反をしているように見えるかもしれませんが、listenerはNewMemoFragment.Listenerであり、ダウンキャストをしない限りMainActivityの参照とはなり得ません。
補足: なぜ参照を複雑にしてはいけないか
今回の例で言うと、NewMemoFragmentがMainActivityの参照を持っていれば問題、ということなのですが一番わかりやすい事例はテストです。
もしNewMemoFragment単体のテストを考えた時、テスト用にMainActivityの参照を用意するのは大変です。
しかし、メソッドもプロパティも少ないinterface (今回で言うNewMemoFragment.Listener
)であれば、テスト用の適当な参照を用意しやすいですよね。
NewMemoFragmentはMainActivity全体を知る必要はないのです。
こう考えると「インターフェイス」という名前、しっくりきませんか?
Toastでメッセージを表示する
ここからは再利用性や依存性の話はあまり関係ありませんが、AndroidのToastという機能を使ってみましょう。
やりたいことは、メモを追加してMainActivityに戻った時に、何かしら更新されたメッセージが欲しいので、『「卵を買う」が追加されました』のようなGUIメッセージが表示されるようにしましょう。
まずは、メモのタイトルをActivityに伝播させる必要があるのでその対応からやりましょう。
NewMemoViewModel.kt
class NewMemoViewModel(app: Application) : MemoViewModel(app) {
private val _insertEvent = MutableLiveData<String>()
/** DB挿入イベントLiveData */
val insertEvent: LiveData<String> = _insertEvent
/**
* Memoを登録する
*/
fun registerMemo(
title: String,
contents: String,
expireDuration: Long = TimeUnit.DAYS.toMillis(7)) {
viewModelScope.launch(Dispatchers.IO) {
memoRepository.insertMemo(title, contents, expireDuration)
_insertEvent.postValue(title)
}
}
}
NewMemoFragment.kt
class NewMemoFragment : Fragment(R.layout.fragment_new_memo) {
interface Listener {
fun onDismiss(memoTitle: String)
}
private val viewModel: NewMemoViewModel by viewModels()
private lateinit var listener: Listener
override fun onAttach(context: Context) {
super.onAttach(context)
// ここでリスナーをセットしておく。もしActivityがListenerを実装していなければ例外を出してアプリを落とす。
listener = context as? Listener
?: throw ClassCastException("The parent activity needs to implement OnDismissListener")
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
view.findViewById<Button>(R.id.new_memo_add_button).setOnClickListener {
val title = view.findViewById<EditText>(R.id.new_memo_title).text.toString()
val contents = view.findViewById<EditText>(R.id.new_memo_contents).toString()
// タイトルが空ならばエラー処理
// 本体ならばUI上で何かしら文言を表示するが割愛
if (title.isEmpty())
return@setOnClickListener
viewModel.registerMemo(title, contents)
}
// DB挿入されたらFragmentを閉じる
viewModel.insertEvent.observe(viewLifecycleOwner) { dismiss(it) }
}
private fun dismiss(memoTitle: String) {
listener.onDismiss(memoTitle)
parentFragmentManager.popBackStack()
}
}
MainActivity.kt
class MainActivity : AppCompatActivity(), NewMemoFragment.Listener {
// ...
override fun onDismiss(memoTitle: String) {
viewModel.loadMemoItems()
Toast.makeText(
this,
getString(R.string.new_memo_added_text, memoTitle),
Toast.LENGTH_LONG
).show()
}
}
string.xml
<resources>
<!-- 省略 -->
<string name="new_memo_added_text">「%1$s」を追加しました。</string>
</resources>
ちなみに、Stringリソースの%1$s
というのはC言語のようなフォーマット指定子で、Activityなどのプログラム側で任意の文字列や数値を渡すことができます。
%1$d
が数値用のフォーマットで、接頭辞の%1
, %2
, %3
, … はgetString
の引数の順番に依存します。
というのも、言語によって前後する可能性があるからです。
さて、これでToastメッセージが表示されますが… キーボードが邪魔ですね。
SystemServiceを使ってソフトウェアキーボードを隠す
ActivityもといContext
を継承したクラスは、getSystemService()
というメソッドが利用できます。
かなり幅広い戻り値を提供するため、戻り型はObject
(KotlinでいうAny?
) です。
何を提供するかと言うと「システムレベルのService」で、名前から各SystemServiceを取得できます。
対象のServiceはたくさんあり、今回はその中の一つInputMethodManager
を利用します。
ちなみにSystemServiceは全部紹介しきれませんし、全部覚える必要はありません。
今回はたまたま、「ソフトウェアキーボードを強制的に隠したい」というケースが上がってそれを満たすのに本メソッドgは必要だった、というまでです。
気になる人は、各自調べるかソースコードにあるドキュメンテーションコメントを見てみると良いでしょう。
少し話がそれましたが、実装してみると以下のような感じになります。
class MainActivity : AppCompatActivity(), NewMemoFragment.Listener {
// ...
override fun onDismiss(memoTitle: String) {
hideSoftwareKeyboard()
viewModel.loadMemoItems()
Toast.makeText(
this,
getString(R.string.new_memo_added_text, memoTitle),
Toast.LENGTH_LONG
).show()
}
private fun hideSoftwareKeyboard() {
// 戻り値がObject (Any?) なのでダウンキャストする必要がある
(getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager)
.hideSoftInputFromWindow(
findViewById<View>(R.id.main_container).windowToken, // Viewを対象にするか
0 // 0 か HIDE_IMPLICIT_ONLYが指定できる
)
}
}
これでToastがキーボードに被らなくなりましたね。
便利な関数を抽出する
今回のテーマは「再利用性」ですので、先ほど作ったようなToast生成処理やhideSoftwareKeyboard()
などは便利関数として抽出しても良いでしょう。
これはみなさんのプロジェクトがどのくらいの規模で、どのくらいの人数で開発しているかにも依存する作業かもしれませんが、どちらにせよ一つのファイルが膨大になることは避けるべきです。
さらに、いわゆる便利関数はいつでも再利用できるようにしておくことは悪いことではありません。
試しに抽出してみましょう。
新しく util/ ディレクトリを作成して、Extension.ktというファイルに便利関数を抽出してみます。
(本当はこんな抽象的な名前では無くて、分類を細分化して具体的なファイル名が望ましいですね)
Extension.kt
/**
* [viewId]上に表示されているソフトウェアキーボードを隠す
*/
fun Activity.hideSoftwareKeyboard(@IdRes viewId: Int) {
(getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager)
.hideSoftInputFromWindow(this.findViewById<View>(viewId).windowToken, 0)
}
/**
* Toastを生成する
*/
fun makeToast(context: Context, text: String) {
Toast.makeText(context, text, Toast.LENGTH_LONG).show()
}
MainActivity.kt
class MainActivity : AppCompatActivity(), NewMemoFragment.Listener {
// ...
override fun onDismiss(memoTitle: String) {
hideSoftwareKeyboard(R.id.main_container)
viewModel.loadMemoItems()
makeToast(this, getString(R.string.new_memo_added_text, memoTitle))
}
}
MainActivityがスッキリした上に、また同じユースケースで再利用可能な関数を抽出できました。
好みによるとは思うのですが、再利用性が高い関数や処理、アルゴリズムに関しては積極的に抽出、もしくはクラスメソッド化するのをお勧めします。
ただやりすぎは禁物です。
便利関数を作りすぎて処理を追うのが大変になったり、「これわざわざ抽出する必要ある?」のような拡張関数ができてしまうので。
ついでに、MainAdapterで管理していた日付の文字列変換もTimeUtil.ktに抽出しちゃいましょう。
util/TimeUtil.kt
object TimeUtil {
private const val DATE_FORMAT = "yyyy/MM/dd"
private val DATE_FORMATTER = DateTimeFormatter.ofPattern(DATE_FORMAT)
/**
* UNIX epoch time millis to String
*/
fun Long.toDateString(): String =
Instant.ofEpochMilli(this)
.atZone(ZoneId.systemDefault())
.format(DATE_FORMATTER)
}
抽出したらMainAdapterでこのオブジェクトをインポートするのをお忘れなく。
第6回のまとめ
- プログラムを書く時は依存関係を単純にする
- 双方向の依存はダメ
- 双方向の依存がやりたくなったら、インターフェイスを導入することを考える
- 再利用性の高いコードを書く
- ただやりすぎは禁物
おわりに
第6回はここで終わりになります。
今回はAndroid開発に限らず大事な概念を解説しました。
少し複雑な実装から離れた回でしたが、依存性と再利用性についてはとても重要なものなので、これからソフトウェア開発する上でしっかりと意識する必要があります。
このチュートリアルでは設計は行っていないのですが、本来は依存性は設計段階でできるだけ単純化しておくことが通常です。
しかし依存性の概念を定着させるには、何度も実装をして身につけるしかないので、今回は実装の流れで取り入れてみました。
依存性を考慮したソフトウェアアーキテクチャや、デザインパターンは他にもたくさんあるのでぜひ調べてみてください。
参考
- フラグメント – アクティビティへのイベント コールバックを作成する | Android developers
- プログラムの依存関係とモジュール構成のこと – Qiita
- 依存性についてはこの記事が丁寧に書かれていてわかりやすいと思います。