「Androidアプリの開発は、地球が砕け散ってもイヤです」
上司の問い掛けにノータイムで断りをいれて、でもしがないサラリーマンの私の我儘が通るはずなんかなくて、Androidアプリの開発があまりに辛くて滂沱のごとく涙したあの日の私。私の立場は当時と同じヒラのままですし、やっぱり上司の命令には逆らえないのですけど、Androidアプリの開発は大きく変わりました。
「Androidアプリの開発を、ぜひワタクシメに!」
今の私は、Androidアプリの開発の仕事大歓迎です。だって、今時のAndroidアプリ開発って、とても楽チンで美味しい仕事なんだもの。そう、Android Jetpackを使うならね。あと、Kotlinとコルーチンも。……え? 信じられない? わかりました。実際にアプリを作って、その楽チンさを証明しましょう。
※文中に私が所属する会社の名前が入っていますが、本稿はその会社の文書ではありません。タイトルにもあるように、私的な開発ガイドです。
Androidアプリ開発が面倒だった理由と、その対策
さて、Androidアプリの開発が面倒なのは、大昔の貧弱なスマートフォンのリソースでも動作するように初期のバージョンが作成されたためだと考えます。そして、複数のメーカーの様々なAndroid端末があったので後方互換性を考慮しなければならなくて、初期バージョンの古い設計を引きずらざるを得なかったためでしょう。
たとえば、Androidアプリの基本的な構成単位であるActivity
は、いつ終了させられてもよいように作らなければなりません。少ないメモリを有効活用するためには、バックグラウンドのActivity
を終了させてメモリを解放させることが重要ですからね。でも、だからといって、ただ終了させるのでは次にアプリがフォアグラウンドになった時に処理を継続できなくなってしまいます。だから、onSavedInstanceState()
で状態を退避できてonRestoreInstanceState()
で状態を復帰できるようにしよう。待てよ、これが前提なのだから、端末を縦から横に回転したときに画面のレイアウトをやり直すようなCPUを消費する処理はやめて、Activity
を再生成してしまうことにしよう。たぶんこのような流れで出来上がったのが、Activity
のライフサイクルという、あの複雑怪奇な図です(しかもこれでも簡略図だという……)。
←
すげー複雑だよね……
https://developer.android.com/guide/components/activities/activity-lifecycle?hl=ja
この面倒な図を正確に理解していないと、Androidアプリの開発はできないらしい。でも、メモリが豊富な今時のスマートフォンなら、こんな仕組みは無駄なのではないでしょうか? LinuxでもMac OSでもWindowsでも、こんな大げさな考慮はしていませんよね?
でも、後方互換性を考えると、APIを丸ごと書き換えるのは困難です。だから、APIの上にライブラリの層を被せることにしましょう。先ほどの問題はActivity
そのものではなく、Activity
の「状態」の問題なので、状態だけを抽出して、その状態についてはActivity
が破棄されても破棄されないようにすればよい。そのクラスの名前はそう、MVVM(Model-View-ViewModel)アーキテクチャのViewModel
から借りてこよう。そして、プログラミング業界ではオブジェクトの生成から破棄までをライフサイクルという言葉で表現するので、ライブラリ名はLifecycleとしよう。ほら、これで、あの複雑怪奇なライフサイクルを(それほど)意識しないで済むようになって、開発は楽チンになりました。
でもちょっと待って。画面回転の問題はFragment
で解決できるのではという、従来のAndroid開発に詳しい方がいらっしゃるかもしれません。でもね、Fragment
を使う時って、画面遷移はどうしていました? AndroidそのものはActivity
が遷移していくように作られていて、だからFragment
を含むActivity
が遷移していくように作ることになって、結果としてActivity
とFragment
にコードが分散して見通しが悪くなるだけになっていませんでしたか? この問題はFragment
が遷移する仕組みで解消できるわけで、その仕組みをシンプルなコードで実現可能にしてくれるライブラリがNavigationです。これから先の人生ではFragment
のことだけを考えて生きていけるというわけ。気難しいActivityさんと離れられて、さらに楽チンです。
データの管理も大変でした。RDBMS(Relational Database Management System)のSQLite3をAndroidシステムに組み込んでくれたのはとてもありがたいのですけど、そのアクセスには素のSQLを使えってのはいただけません。オブジェクト指向のObjectとRDBMSのRelationをマップする、O/Rマッパーが欲しい。ならば与えようってことでRoomという公式のO/Rマッパーが提供されて、ライブラリ選択に頭を悩ます必要がなくなって楽チン。
本稿では紹介しませんけど、これ以外にも便利機能はいっぱいあって、GoogleはAndroid Jetpackとしてまとめて提供しています。バックグラウンド・ジョブ管理のWorkManagerとか、面倒でしょうがなかったカメラ制御を楽にしてくれるCameraXなんてのもあります。新機能をなかなか取り入れられない、過去に縛られたダサいAPIとはこれでオサラバできるんです。
あと、Support
Libraryも。Androidでは端末が最新OSにバージョン・アップされていることを期待できないので、OSそのものに新しい機能が組み入れられてもすぐに使うことはできません。だから、Support
Libraryという形で、OSとは別に機能が提供されてきました。ところが、このSupport
Libraryにもバージョンの不整合という問題がでてしまったんです。android.support.v4
とかandroid.support.v7
とかね。これはもう心機一転してやりなおそうってことで、androidx
で始まる新しいライブラリ群にまとめなおされました。前述したLifecycleやNavigationも、実はandroidx
ファミリーの一員なんです。Lifecycleはandroidx.lifecycle
ですし、Navigationはandroidx.navigation
です。当然、新しいandroidx.*
では、LifecycleやNavigationなどのJetpackの機能を前提にした機能強化がなされていて、これでもう本当に楽チンです。
ただね、androidx.*
は、それぞれがそれぞれを前提としているので、整合性を保って良い感じに組み合わせて使わなければならないんですよ。個々の要素を理解するだけでは不十分で、どのように組み合わせるべきかを理解することが重要なんです。だから、Googleはアプリのアーキテクチャ・ガイドというアプリ全体をどのように作るのかのガイドと、Sunflowerというサンプル・アプリを提供しています。これらはとても良いモノなのですけど、私には、Jetpackに都合が良い美しい世界にとどまっていて、現実の様々な問題への対応が不十分なように感じられます。あと、いくらなんでも読者への前提が厳しすぎますよ、これ。ガイドの序文に「Androidフレームワークの基本について熟知していることを前提としています」って書いてありますけど、Jetpack以降は無駄になるような古い知識をとりあえず習得してこいってのは酷いと思うよね?
というわけで、もう少し現実的なサンプル・アプリと、ゆるめのガイドを作成してみました。……思いっきり蛇足な気がするけど、気のせいだよね?
サンプル・アプリ
私が欲しいからという理由で、本稿では東京の都営バスの車両の接近情報を表示するアプリを作成します(私が生まれ育った群馬県で力合わせる200万人をはじめとする地方在住のみなさま、ごめんなさい)。というのもですね、都営バスはtobus.jpというサイトで車両接近情報を提供していて、このサイトはグラフィカルでとてもよくできているんですけど、提供側の都営バスの論理からか情報が路線単位なんですよ。で、私が住んでいるアパートの周りにはいくつかのバス停があってそれぞれ路線が異なるので、会社から帰るときには複数の路線の車両接近情報を調べなければならなくてこれがとにかくかったるい。
なので、複数の路線の車両の接近情報を集めて表示するアプリを作成します。タイミングが良いことに、2019年5月31日に、都営交通の運行情報等をオープンデータとして提供開始しますというニュースがありました。公共交通オープンデータセンター開発者サイトでユーザー登録すれば、誰でも無料でデータを使用できます(利用者への通知が必要などの、いくつかの制約はありますけど)。Webサイトのクローリングが不要なのでアプリ開発は容易だろうから、サンプル・アプリに適しているんじゃないかな。
やることが決まったので、具体化しましょう。出発バス停を指定して、到着バス停を指定すると、それらのバス停を結ぶ全ての路線の接近情報が一覧表示されます。あと、毎回出発バス停と到着バス停を選ぶのでは面倒すぎるので、ブックマークの機能を持たせます。以下のような感じ(単純なアプリですけど、サンプルなのでご容赦ください)。
実際にアプリを試してみたい場合は、Google Playでインストールできます。ぜひ試してみてください。
本ガイドでは、このアプリをちゃちゃっと作っていきます。
ソース・コードはGitHubで公開(しかも章単位にブランチを分けるというサービス付き)しているので、必要に応じて参照してみてください(すげーコード量が少ないので、見るのはそんなに大変じゃないはず)。ただし、このコードをビルドして動かすには、後の章で説明する公共交通オープンデータセンター開発者サイトにユーザー登録すると貰えるアクセス・トークンをが必要です。コードをcloneしたら./app/src/main/res/valuesディレクトリにodpt.xmlを作成し、以下を参考に`consumerKey`を設定してください。
<?xml version="1.0" encoding="utf-8"?>
resources>
<string name="consumerKey">公共交通オープンデータセンターから取得したアクセス・トークン</string>
<resources> </
あと、Android Studioのバージョンは3.6を使用しました。これより古い場合は、Android Studioをバージョンアップしてください。
プロジェクトの作成
長い前置きがやっと終わって早速プログラミング……の前に、Androidアプリの開発ではプロジェクトを作成しなければなりません。Android Studioを起動して、プロジェクトを作成しましょう。
Android Studioを起動して、[Start a new Android Studio project]を選択します。
作成するプロジェクトは、余計なコードが生成されない「Empty Activity」にします。
[Name]にアプリ名、[Package name]にドメイン名+アプリ名を入力します。アプリ名は、JetpackのサンプルでBusの接近情報ということで、jetbusにしてみました。[Language]はもちろん「Kotlin」で。Javaの256倍くらい良いプログラミング言語ですし、Google I/O 2019で「Kotlinファースト」が表明されましたし、Kotlinならさらに有効活用できるJetpackの機能もあるためです。あと、[Minimum API Level]は、「API 23: Android 6.0 (Mashmallow)」にしました。Android 6.0で権限確認の方法が変更になったので、これ以前のバージョンだと権限の確認の処理が面倒なためです(今回は関係ないけどね)。古い端末を平気で使う海外までを対象にしたアプリを作るならもっと古いバージョンにすべきでしょうけど、国内が対象なら、まぁ大丈夫じゃないかな。
ともあれ、これで無事にプロジェクトが作成されました。でも、まだJetpackが組み込まれていません。さっそく組み込む……前に、ビルド・システムの説明をさせてください。
ビルド・システム
人類がウホウホ言いながら樹上で暮らしていた大昔、ライブラリの組み込みというのはインターネットからダウンロードしたファイルをプロジェクトのディレクトリにセーブするという作業でした。Windowsでアプリケーションをインストールするために、Webサイトからパッケージをダウンロードしてセットアップするのと似た感じ。
こんな面倒な作業はやってられませんから、Linuxではpacman
とかapt
とかyum
とか、Mac
OSではMacPorts
とかHomeBrew
とかのパッケージ管理システムを使って、アプリケーションをセットアップします。たとえば、LinuxディストリビューションのArch
Linuxで今私が本稿の作成に使用しているEmacsをセットアップする場合は、ターミナルからsudo pacman -S emacs
と入力するだけで終わり。これだけで、パッケージ管理システムが全自動でEmacsをダウンロードし、セットアップしてくれます(私は日本語IMEにMozcを使用しているので、emacs-mozcからEmacsをインストールしたけど)。AndroidのPlay
Store、iPhoneのApp Storeと同じですな。
で、今時の開発では様々なライブラリを使用するのが当たり前で、ライブラリ毎にWebサイトを開いてダウンロードして解凍してセーブなんて作業はやってられませんから、ライブラリの組み込みにも自動化が必要でしょう。ライブラリは他のライブラリに依存していることが多くて、その依存関係を辿る作業を手動でやるなんてのは非現実的ですもんね。
あと、今時の開発だと、ライブラリを組み込み終わったあとのビルドもなかなかに複雑な作業となります。ビルドついでにテストしたいとか、事前にコード生成させたいとか。このように考えると、ライブラリの管理とビルドを自動化するシステムが必要で、これがいわゆるビルド・システムとなります。Android Studioは、このビルド・システムとしてGradleというソフトウェアを使用しているわけです。
Jetpackの組み込み
Jetpackをビルド・システムのGradleに組み込む方法は、各ライブラリのリリース・ノートに書いてあります。たとえばNavigationなら、https://developer.android.com/jetpack/androidx/releases/navigationです。基本はこのリリース・ノートの記載に従うのですけど、いくつか注意点があります。
- ライブラリ名-ktxという名前のライブラリがある場合は、*-ktxを指定してください。*-ktxは、Kotlinならではの便利機能が入ったバージョンのライブラリです。Kotlin向けの機能が入っていない基本バージョンのライブラリは、依存関係があるので自動で組み込まれます。
- Jetpackではコード生成を多用しているのですけど、KotlinやJavaではコード生成の制御にアノテーション(annotation。クラスやメソッドの前に書く
@Foo
みたいなアレ)を使用していて、Javaの場合はGradleのビルド・スクリプトにannotationProcessor
と書きます。Kotlinの場合は、Kotlin Annotation Processor Toolの略でkapt
と書いてください。リリース・ノートにannotationProcessor
の記述があった後に、Kotlinではkaptを使ってねという知っている人しかわからない意味不明なコメントが書いてある場合は、kapt
の出番です。
と、以上の注意を踏まえて、Android Studioの[Project]ビューのbuild.gradeをダブル・クリックして開いて、修正してみましょう。以下が修正した結果で、修正した行は、修正内容をコメントで書いています。
{
buildscript .kotlin_version = '1.3.61'
ext{
repositories google()
jcenter()
}
{
dependencies 'com.android.tools.build:gradle:3.6.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath
'androidx.navigation:navigation-safe-args-gradle-plugin:2.2.1' // 追加
classpath }
}
{
allprojects {
repositories google()
jcenter()
}
}
clean(type: Delete) {
task .buildDir
delete rootProject}
※「(Project: jetbus)」と書いてある方のbuild.gradle
: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt' // 追加
apply plugin: 'androidx.navigation.safeargs.kotlin' // 追加
apply plugin
{
android 29
compileSdkVersion "29.0.3"
buildToolsVersion
{
defaultConfig "com.tail_island.jetbus"
applicationId 23
minSdkVersion 29
targetSdkVersion 1
versionCode "1.0"
versionName "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunner }
{
buildTypes {
release false
minifyEnabled getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
proguardFiles }
}
{ // 追加
kotlinOptions = '1.8' // 追加
jvmTarget } // 追加
{ // 追加
dataBinding true // 追加
enabled } // 追加
}
{
dependencies 'androidx.lifecycle:lifecycle-compiler:2.2.0' // 追加
kapt 'androidx.room:room-compiler:2.2.4' // 追加
kapt
fileTree(dir: 'libs', include: ['*.jar'])
implementation
'androidx.appcompat:appcompat:1.1.0'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
implementation 'androidx.core:core-ktx:1.2.0'
implementation 'androidx.fragment:fragment-ktx:1.2.2' // 追加
implementation 'androidx.legacy:legacy-support-v4:1.0.0' // 追加
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' // 追加
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0' // 追加
implementation 'androidx.navigation:navigation-fragment-ktx:2.2.1' // 追加
implementation 'androidx.navigation:navigation-ui-ktx:2.2.1' // 追加
implementation 'androidx.room:room-ktx:2.2.4' // 追加
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation
'junit:junit:4.12'
testImplementation
'androidx.test.ext:junit:1.1.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
androidTestImplementation }
※「(Module: app)」と書いてある方のbuild.gradle
これで、デモ・アプリに必要なJetpackのライブラリの組み込みが完了しました。やっとプログラミングです。最初は、作成してやった感が大きそうな、画面まわりをやってみましょう。
Navigation
まぁ、画面といっても、かっこいい画面を作るほうではなく、ダミー画面を使用した画面遷移の実装という、見栄えがあまりよくない部分のプログラミングだけどな。画面遷移なので、Navigationが火を吹きますよ。
Activity
とFragment
とNavigation
さて、例によって歴史の話から。Acticity
とFragment
の話、あと、Navigationが作られた経緯です。
Activity
ってのは、ぶっちゃけアプリの画面1つ分です。で、一般にアプリは複数のActivity
で構成されます。Androidはアプリ間連携(アプリを操作していたら別のアプリの画面に遷移して、で、戻るボタンで最初のアプリに戻れる)ができるところがとても素晴らしいと私は思っているのですけど、この機能はActivity
の遷移として実現されています。同様に、アプリ内でもActivity
の遷移で画面遷移を実現していました。
で、大昔のスマートフォンのアプリの単純な画面だったらこれであまり問題なかったんですけど、高機能なアプリを作ったりタブレットのような大きな画面を効率よく使おうとしたりする場合は、この方式だとコードの重複という問題が発生してしまうんです。タブレットの大きな画面では、左に一覧表示して、右にその詳細を表示するような画面が考えられます。でも、スマートフォンの小さな画面では、一覧表示する画面と、それとは別の詳細を表示する画面に分かれて、画面遷移する形で表現しなければなりません。
https://developer.android.com/guide/components/fragments?hl=ja
これをActivity
で実現しようとすると、タブレット用のActivity
を1つと、スマートフォン用のActicvity
を2つ作らなければなりません。そして、ほとんどのコードは重複してしまうでしょう。ユーザー・インターフェースの問題ならView
(ユーサー・インターフェースのコンポーネント)で解決すれば……って思うかもしれませんけど、画面に表示するデータをデータベースから取得してくるような機能をView
に持たせるのは、View
の責任範囲を逸脱しているのでダメです。
ではどうすればよいかというと、Activity
の構成要素になりえる「何か」を追加してあげればよい。この「何か」こそが、Fragment
です。
でもね、コードの重複が発生しないようなケースでFragment
を使ってActivity
で画面遷移をさせると、Activity
のコードの多くをFragment
に移して、で、Activity
にFragment
を管理するコードを追加して、そしてもちろんFragment
にも自分自身の初期化処理等が必要となって、結局、コード量が増えただけで誰も得しないという状況になってしまうんです。
これでは無意味なので、1つのActivity
の中で、複数のFragment
が遷移するというプログラミング・スタイルが編み出されました。このスタイルを実現するのがFragmentTransaction
というAPIで、Acticity
からFragment
を削除したり追加したり入れ替えたりできます。なんと[戻る]ボタンへの対応機能付き……なのですけど、高機能な分だけ使い方が複雑で、Activity
の遷移(Intent
のインスタンスを引数にstartActivity()
するだけ)と比べると面倒だったんですよ。
なので、Navigationが作成されました。GUIツール(私はほとんど使わないけど)で画面遷移を定義できるかっこいいライブラリです。ただ単にFragmentTransaction
を呼び出しているだけな気もしますけど、まぁ、いろいろ楽チンなので良し。SafeArgsという便利機能もありますしね。
プロジェクトにFragmentを追加する
以上により、画面遷移はFragment
遷移ということになりました。だから、プロジェクトにActivity
ではなくFragment
を追加しましょう。jetbusに必要なFragment
は、以下の5つとなります。
- SplashFragment(起動処理をする間に表示するスプラッシュ画面)
- BookmarksFragment(ブックマークの一覧を表示する画面)
- DepartureBusStopFragment(出発バス停を指定する画面)
- ArrivalBusStopFragment(到着バス停を指定する画面)
- BusApproachesFragment(バスの接近情報を表示する画面)
さっそく追加します。プロジェクトを右クリックして、[New] - [Fragment] - [Fragment (Blank)]メニューを選択してください。
Fragment
の名前を入力して、[Include fragment factory
methods?]チェックボックスを外して、[Finish]ボタンを押します(このチェックボックスを外さないと、余計なコードが生成されてしまうためです。まぁ、生成されたとしても、そのコードを消せば良いだけなんですけど)。この手順を5回繰り返して、必要なFragment
をすべて生成してください。
ふう、完了。
navigationリソースを作成する
さて、Navigation。Navigationでは、画面の遷移をres/navigationの下のXMLファイルで管理します。このファイルを作りましょう。プロジェクトを右クリックして、[File] - [New] - [Android Resource Directory]メニューを選びます。
[Resource type]を「navigation」にして、[OK]ボタンを押してください。これで、navigationディレクトリが生成されます。
次。画面の遷移を管理するXMLファイルです。プロジェクトを右クリックして、[File] - [New] - [Android Resource File]メニューを選びます。
[Resource type]を「Navigation」にして、[File name]に「navigation」と入力して、[OK]ボタンを押します。これで、navigation.xmlが生成されましたので、開いて編集します。GUIツールはかったるいという私の個人的な趣味嗜好により、右上の[Code]アイコンをクリックし、以下のXMLファイルを入力します。
<?xml version="1.0" encoding="utf-8"?>
navigation
< xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
app:startDestination="@id/splashFragment">
fragment
< android:id="@+id/splashFragment"
android:name="com.tail_island.jetbus.SplashFragment"
android:label="@string/app_name"
tools:layout="@layout/fragment_splash">
action
< android:id="@+id/splashFragmentToBookmarksFragment"
app:destination="@id/bookmarksFragment" />
fragment>
</
fragment
< android:id="@+id/bookmarksFragment"
android:name="com.tail_island.jetbus.BookmarksFragment"
android:label="ブックマーク"
tools:layout="@layout/fragment_bookmarks">
action
< android:id="@+id/bookmarksFragmentToDepartureBusStopFragment"
app:destination="@id/departureBusStopFragment" />
action
< android:id="@+id/bookmarksFragmentToBusApproachesFragment"
app:destination="@id/busApproachesFragment" />
fragment>
</
fragment
< android:id="@+id/departureBusStopFragment"
android:name="com.tail_island.jetbus.DepartureBusStopFragment"
android:label="出発バス停"
tools:layout="@layout/fragment_departure_bus_stop">
action
< android:id="@+id/departureBusStopFragmentToArrivalBusStopFragment"
app:destination="@id/arrivalBusStopFragment" />
fragment>
</
fragment
< android:id="@+id/arrivalBusStopFragment"
android:name="com.tail_island.jetbus.ArrivalBusStopFragment"
android:label="到着バス停"
tools:layout="@layout/fragment_arrival_bus_stop">
argument
< android:name="departureBusStopName"
app:argType="string" />
action
< android:id="@+id/arrivalBusStopFragmentToBusApproachesFragment"
app:destination="@id/busApproachesFragment"
app:popUpTo="@id/bookmarksFragment" />
fragment>
</
fragment
< android:id="@+id/busApproachesFragment"
android:name="com.tail_island.jetbus.BusApproachesFragment"
android:label="バス接近情報"
tools:layout="@layout/fragment_bus_approaches">
argument
< android:name="departureBusStopName"
app:argType="string" />
argument
< android:name="arrivalBusStopName"
app:argType="string" />
fragment>
</
navigation> </
どのようなFragment
があるのかは、XMLのタグで表現します。<fragment>
タグでですね。android:name
属性でFragment
を実装するクラスを、android:label
属性で画面に表示するタイトルを設定します。tools:layout
属性は、GUIツールでグラフィカルに表示する場合向けの、プレビュー用のレイアウトの指定です。
<fragment>
タグの中の<action>
タグは、画面遷移を表現します。app:destination
属性は、遷移先を指定します。arrivalBusStopFragmentToBusApproachesFragment
で指定されているapp:popUpTo
属性は、[戻る]ボタンが押された場合の行き先を指定しています。出発バス停を選んで、到着バス停を選んで、バスの接近情報が表示されたあとに[戻る]ボタンを押す場合は、多分その路線の情報はもういらない場合でしょうから、app:popUpTo
属性を使用してブックマーク画面まで一気に戻るようにしました。
<fragment>
の中の<argument>
は、フラグメントに遷移する際のパラメーターです。選択された出発バス停を使って到着バス停の選択肢を抽出しないと、到着バス停を選ぶところで路線がつながっていないバス停が表示されてしまうでしょ? だから、arrivalBusStopFragment
ではdepartureBusStopName
というパラメーターを指定しました。
あとはそう、android:id
の説明を忘れていました。AndroidのリソースのXMLでは、@+id/
の後に続ける形で識別子を設定します。上のXMLのandroid:id="@+id/splashFragment"
みたいな感じ。@+id/
の部分が何だか分からなくて気持ち悪いという方のために補足しておくと、Androidアプリの開発ではR.javaというファイルが内部で自動生成されていて、R.javaの中にリソースの識別子が32bit整数で定義されています。XMLのような文字列の識別子では照合作業でCPUを大量に消費してしまうからという貧乏実装、でも、CPUを消費するということは電池を消費するということで、電池は今でもとても貴重な資源なので今でも素晴らしい実装です。このR.idにIDを追加して適当な32bit整数を割り振っておいてくださいねって指示が、@+id/
なんです。ちなみに、割り振られた32bit整数を参照してくださいって時は@id/
と書きます。<action>
の中のapp:destination="@id/bookmarksFragment"
みたいな感じですな。
layout
続けて、作成した<navigation>を使うように、画面を定義していきましょう。画面遷移をXMLで表現したように、Androidアプリの開発では、画面の構造もXMLで表現します。文字列を表示するなら<TextView ... />
みたいな感じです。で、この画面定義の際に重要な属性はandroud:layout_width
とandroid:layout_height
です。
android:layout_width
とandroid:layout_height
は、UIコンポーネントの幅と高さとなります。ここに指定するのは、具体的な大きさ(64dp
など)か、match_parent
、wrap_content
になります。match_parent
は階層上の親と同じところまで大きくするようにとの指示、wrap_content
はコンテンツが入る大きさでお願いしますという指示です。
ConstraintLayout
あと、AndroidのUIコンポーネントでは、他のコンポーネントを子要素として持てるものをViewGroup
、ViewGroup
を継承して何らかのレイアウト機能を追加したものをLayoutと呼びます。Layoutには、後述するApp
barを作るためのAppBarLayout
のような目的に特化したものと、LinearLayout
(縦や横に直線状に並べる)のような汎用的なものがあります。で、今ここで話題にしたいのは、汎用的なほうのLayout。
貧弱なCPUを考慮した結果だと思う(作るのが面倒だったからだとは思いたくない)のですけど、AndroidのAPIはとても単純な機能のLayoutしか提供しません。直線状に並べるLinearLayout
とか並べないで重ねるFrameLayout
みたいなのとか。これらの単純なLayoutの組み合わせで複雑なレイアウトを実現するのがAndroidアプリ開発者の腕の見せ所……だったのは遠い昔の話で、今は、androidx.constraintlayout.widget.ConstraintLayout
だけを覚えれば大丈夫になりました。
ConstraintLayout
は、コンポーネントの位置を他のコンポーネントとの関係で表現します。「住所入力欄は名前入力欄の下」みたいな感じですね。具体的には、app:layout_constraintTop_toBottomOf="@id/nameTextField"
のようになります。左右については少し注意が必要で、アラビア語のように右から左に書く言語にも対応するために、LeftとRightではなくStartとEndで表現します。「住所入力欄のStartは名前入力欄のStartに合わせる」なら、app:layout_constraintStart_toStartOf="@id/nameTextField"
にするというわけ。
NavHostFragmentを追加する
これで一般論が終わりました。Projectビューの[app] - [res] - [layout]の下の「activityu_main.xml」を開いて、[Text]タブを選択して、以下を入力してください。
<?xml version="1.0" encoding="utf-8"?>
androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
< xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
fragment
< android:id="@+id/navHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:name="androidx.navigation.fragment.NavHostFragment"
app:navGraph="@navigation/navigation"
app:defaultNavHost="true" />
androidx.constraintlayout.widget.ConstraintLayout> </
注目していただきたいのは<fragment>
タグのandroid:name
属性のところ。android:name
属性で実装のクラスを指定できるのはnavigationのときと同じで、今回のXMLで指定しているのは「androidx.navigation.fragment.NavHostFragment」で、Googleの人が作ったクラスです。このクラスが何なのかリファレンスで調べてみるとNavigationのためのエリアを提供すると書いてありますので、ここにNavigation配下の画面が表示されるようになったというわけ。あとは、app:navGraph
属性で先程作成したnavigation.xmlを指定して、app:defaultNavHost
属性でデフォルトに指定しておきます。
次は、ダミーの画面レイアウトを作りましょう。そうしておかないと、遷移したのかどうか分からないですもんね。
layout、再び
さて、これからFragment
向けのダミーのlayoutを作成していくわけですけど、その際に、Android
Studioが自動で生成したXMLとはルート要素を変更します。Android
Studioが生成したXMLではルート要素は具体的なLayout
(私の環境ではFrameLayout
でした)だったのですけど、これを<layout>
タグに変更します。
なんでこんなことをするのかというと、後の章で説明するデータ・バインディングをやりたいから。データ・バインディングをやる際には<data>
タグでデータを指定するのですけど、<FrameLayout>
タグの下には<data>
タグを書けませんもんね。あと、ルート要素を<layout>
タグにしておくと、Bindingクラスが生成されるようになってプログラミングがちょっと楽になるというのがあります。Bindingクラスについては後述することにして、代表例として、fragment_bookmarks.xmlを載せます。
<?xml version="1.0" encoding="utf-8"?>
layout
< xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
tools:context=".BookmarksFragment">
androidx.constraintlayout.widget.ConstraintLayout
< android:layout_width="match_parent"
android:layout_height="match_parent">
TextView
< android:id="@+id/textView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginStart="16dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
android:text="Bookmarks" />
Button
< android:id="@+id/departureBusStopButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:layout_constraintTop_toBottomOf="@id/textView"
app:layout_constraintStart_toStartOf="@id/textView"
android:text="DEPARTURE BUS STOP" />
Button
< android:id="@+id/busApproachesButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:layout_constraintTop_toBottomOf="@id/departureBusStopButton"
app:layout_constraintStart_toStartOf="@id/departureBusStopButton"
android:text="BUS APPROACHES" />
androidx.constraintlayout.widget.ConstraintLayout>
</
layout> </
ConstraintLayoutのところでUIコンポーネントはapp:layout_constraint...
属性でレイアウトすると説明しておきながら、そこでは使わなかったapp:layout_constraint...
がやっと出てきてくれました(activity_main.xmlでは、android:layout_width
属性もandroid:layout_height
属性も「match_parent」だったので、制約をつける必要がなかったんですよ)。自分がなんの画面なのかを表示するTextView
を左上に、その下にButton
を表示するようになっています。android:layout_marginTop
属性はマージンです。マージン分だけ隙間を開けてくれるわけですな。
こんな感じでルート要素が<layout>
になるように、他のFragment
のレイアウトも同様に修正してください。
Fragmentの実装
レイアウトの修正が終わったら、ロジックの修正です。代表例は先ほどと同じBookmarksFragment
で。
package com.tail_island.jetbus
import android.os.Bundle
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.navigation.fragment.findNavController
import com.tail_island.jetbus.databinding.FragmentBookmarksBinding
class BookmarksFragment: Fragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return FragmentBookmarksBinding.inflate(inflater, container, false).apply {
.setOnClickListener {
departureBusStopButton().navigate(BookmarksFragmentDirections.bookmarksFragmentToDepartureBusStopFragment())
findNavController}
.setOnClickListener {
busApproachesButton().navigate(BookmarksFragmentDirections.bookmarksFragmentToBusApproachesFragment("日本ユニシス本社前", "深川第八中学校前"))
findNavController}
}.root
}
}
importの最後でcom.tail_island.jetbus.databinding.FragmentBookmarksBinding
をインポートしていますけど、このFragmentBookmarksBinding
クラスは、先程fragment_bookmarks.xmlのルート要素を<layout>
タグに変更したことで生成されたクラスです(XMLのファイル名からクラス名を決定するので、BookmarksFragmentBinding
ではなくてFragmentBookmarksBinding
になります。不整合がキモチワルイけど、Android
Studioのデフォルトはこのファイル名なのでしょうがない……)。あと、もしコードを書いているときにAndroid
Studioがそんなクラスはないよと文句をつけてきたら、レイアウトXMLの変更を忘れたか、レイアウトXMLの変更後にビルドをしていないか(ビルドのときに自動生成されます)です。確認してみてください。
次。Fragment
を初期化する処理は、コンストラクタではなく、onCreateView()
メソッドの中に書くことに注意してください。Fragment
は画面と結び付けられていて、画面というのは複雑で初期化が大変なソフトウェア部品です。だからAndroidでは、初期化のステージに合わせて小刻みにメソッドが呼ばれる方式が採用されました。Fragment
を作成するときに呼ばれるonCreate()
メソッドとか、Fragment
のView
を生成するときに呼ばれるonCreateView()
メソッドとか。で、一般にFragment
の初期化というのは画面の初期化なので、画面を構成するView
がない状態では初期化の作業ができません。だから、onCreate()
メソッドではなくて、onCreateView()
メソッドに初期化のコードを書きます。
さて、onCreateView()
メソッドでやらなければならない作業は、Fragment
のView
の生成です。Android
Studioが生成するFragment
のコードでは、引数で渡ってくるinflater: LayoutInflater
のinflate()
メソッドをレイアウトXMLを引数にして呼び出すコードが生成されるのですけど、将来のデータ・バインディングのために、FragmentBookmarksBinding
のinflate()
メソッドを使用します。で、...Binding
のinflate()
メソッドで生成されるのは...Binding
なので、View
であるroot
プロパティを返します。コードにすると、こんな感じ。
return FragmentBookmarksBinding.inflate(inflater, container, false).root
スコープ関数
でもね、私達は画面の初期化をしたいわけで、初期化にはボタンが押されたときのListener
の登録等も含まれます。以下のようなコードになるでしょうか。
val binding = FragmentBookmarksBinding.inflate(inflater, container, false)
.departureBusStopButton.setOnClickListener {
binding// ボタンが押された場合の処理
}
return binding.root
...Binding
では、レイアウトXMLで定義されたUIコンポーネントをandroid:id
と同じプロパティ名で参照するためのコードが生成されていますから、binding.departureBusStopButton
で画面のボタンを参照できます。ボタンがクリックされたときのリスナーを設定するメソッドはsetOnClickListener()
で、その引数は関数。で、関数はラムダ式で定義することができます。あと、Kotlinには、最後のパラメータがラムダ式の場合はそのパラメーターはカッコの外に指定するという慣習があります。さらに、ラムダ式だけを引数にする場合は、括弧を省略できる。というわけで、上のようなシンプルなコードになるわけですな。
ただね、このようなコードはよく見るのですけど、実はこれ、とても悪いコードなんです。その理由は、ローカル変数(val binding
)を使っているから(グローバル変数を使えと言っているわけじゃないですよ、念の為)。
ローカル変数は、そのブロックを抜けるまで有効です。長期間に渡ってコードに影響を与えるというわけ。val
にしてイミュータブル(不変)にした場合でも、状態遷移という影響は減るけれども変数があることを覚えておかなければならないのは一緒で、コードを読むのが大変になってしまう。私のような記憶力が衰えまくっているおっさんには、ローカル変数は辛いんですよ。だから、変数のスコープを小さくします。関数の最初で変数を宣言しなければならなかくてその変数が関数の最後まで有効な大昔のプログラミング言語より、どこでも変数を宣言できてブロックが終わると変数のスコープが切れる今どきの言語の方が使いやすいですよね? 変数のスコープは、小さければ小さいほど良いんです。
と、このような、変数のスコープを小さく、かつ、分かりやすい形で制御したい場合に使えるKotlinの便利ライブラリが、スコープ関数なんです。
たとえば、foo()
の戻り値を使用したい場合は、
val x = foo()
.bar() x
と書くのではなくて、
().let { it.bar() } foo
と書きます。let()
スコープ関数は、自分自身を引数にしたラムダ式を呼び出すというわけ。あ、ラムダ式の中のit
は、ラムダ式のパラメーターを宣言しなかった場合の暗黙の名前です。let()
スコープ関数は自分自身を引数にしますから、この場合のit
はfoo()
の戻り値になります。
他のスコープ関数には、apply()
(this
を自分自身に設定したラムダ式を呼び出して、自分自身を返す。初期化等で便利)、also()
(自分自身を引数にしたラムダ式を呼び出して、自分自身を返す。自分自身を使う他のオブジェクトの初期化等で便利)、run()
(自分自身をthis
にしたラムダ式を実行して、ラムダ式の戻り値を返す。その場で関数を定義して実行する感じ)があります。スコープ関数はとにかく便利ですから、ぜひ使い倒してください。
というわけで、今回はapply()
を使用して、FragmentBookmarksBinding
のボタンへのリスナー登録という初期化処理を書きましょう。
return FragmentBookmarksBinding.inflate(inflater, container, false).apply {
.setOnClickListener {
departureBusStopButton// ボタンが押された場合の処理
}
}.root
apply()
なのでthis
はFragmentBookmarksBinding
になっていて、だからFragmentBookmarksBinding
のdepartureBusStopButton
に修飾なしでアクセスできて便利。スコープの範囲がインデントされて判別しやすいのも、認知機能が衰えた私のようなおっさんには嬉しいです。
画面遷移
Navigationでの画面の遷移は、findNavController()
で取得できるNavController
クラスのnavigate()
メソッドで実施します。リファレンスを見てみるとnavigation()
メソッドにはオーバーロードされた様々なバリエーションがあって、色々な引数を取れるようになっています。今回この中で注目していただきたいのは、NavDirections
を引数に取るバージョンです。ナビゲーションのXMLで<action>
を作成するとこのNavDirectionsが自動生成されるので、とても簡単に呼び出せます。というわけで、画面遷移のコードは以下のようになります。
().navigate(BookmarksFragmentDirections.bookmarksFragmentToDepartureBusStopFragment()) findNavController
あと、記憶力が良い方は、ナビゲーションのXMLを作成したときに、<argument>
でFragment
への遷移のパラメーターを定義したことを覚えているかもしれません。<argument>
を定義しておくと、NavDirections
を生成するときにパラメーターを追加してくれます。記憶力が壊滅している私のようなおっさんの場合でも、パラメーターを指定しないとコンパイル・エラーになるから思い出せますな。というわけで、バス接近情報に画面遷移する場合は以下のようなコードになります。
().navigate(
findNavController.bookmarksFragmentToBusApproachesFragment("日本ユニシス本社前", "深川第八中学校前")
BookmarksFragmentDirections)
引数は、プログラムを書く以外はあまねくダメダメと評判のこんな私を雇ってくれる奇特な会社から私の家まで帰るルートですな。
Navigationを試してみる
こんな感じですべてのFragment
をプログラミングして、動かしてみると以下のようになります。
[到着バス停]画面から[バス接近情報]画面に遷移した後、[戻る]ボタンを押すとレイアウトのXMLのapp:popUpTo
属性が効いて、[ブックマーク一覧]画面に戻ってくれていてとても嬉しい。
でも、なんか画面が寂しい気がします……。どうにかならないかな? あと、ナビゲーションのXMLでわざわざ設定したandroid:label
属性はどうなったのでしょうか? どの画面でも、画面上部には「jetbus」って表示されているんだけど。
App barとNavigation drawer
さて、AndroidアプリのUIは、Material Designというデザイン・ガイドに従うことになっています。このMaterial Designにはいろいろなコンポーネントがあるのですけど、App barというコンポーネントで画面の情報を表示して、Navigation drawerでメニューを実現する方式が一般的みたいです。
今の画面でもApp barっぽいのはありますけど、Navigation
drawerがありません。作ってみましょう。[Project]ビューの[app] - [res] -
[layout]の下の「activity_main.xml」を開いて、右上の[Code]アイコンを選択して、activity_main.xmlを以下に変更します。Fragment
のレイアウトと統一するために、ルート要素は<layuout>
に変更しました。
<?xml version="1.0" encoding="utf-8"?> <layout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:context=".MainActivity">
androidx.drawerlayout.widget.DrawerLayout
< android:id="@+id/drawerLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
androidx.constraintlayout.widget.ConstraintLayout
< android:layout_width="match_parent"
android:layout_height="match_parent">
com.google.android.material.appbar.AppBarLayout
< android:id="@+id/appBarLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent">
androidx.appcompat.widget.Toolbar
< android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize" />
com.google.android.material.appbar.AppBarLayout>
</
fragment android:id="@+id/navHostFragment"
< android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintTop_toBottomOf="@id/appBarLayout"
app:layout_constraintBottom_toBottomOf="parent"
android:name="androidx.navigation.fragment.NavHostFragment"
app:navGraph="@navigation/navigation"
app:defaultNavHost="true" />
androidx.constraintlayout.widget.ConstraintLayout>
</
com.google.android.material.navigation.NavigationView
< android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="start"
app:headerLayout="@layout/item_navigation_header"
app:menu="@menu/menu_navigation"
style="@style/Widget.MaterialComponents.NavigationView">
androidx.constraintlayout.widget.ConstraintLayout
< android:layout_width="match_parent"
android:layout_height="match_parent">
TextView android:layout_width="match_parent"
< android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
app:layout_constraintBottom_toBottomOf="parent"
android:textSize="10sp"
android:text="本アプリが使用する公共交通データは、公共交通オープンデータセンターにおいて提供されるものです。\n\n公共交通事業者により提供されたデータを元にしていますが、必ずしも正確/完全なものとは限りません。本アプリの表示内容について、公共交通事業者に直接問い合わせないでください。\n\n本アプリに関するお問い合わせはrojima1@gmail.comにお願いします。" />
androidx.constraintlayout.widget.ConstraintLayout>
</
com.google.android.material.navigation.NavigationView>
</
androidx.drawerlayout.widget.DrawerLayout>
</
layout> </
……長くてごめんなさい。でも、思い出してください。我々はNavigationを使用していますので、画面といえばFragment
なわけです。Activity
はアプリ全体でこれ一つだけ。なので、まぁ、一回だけならば長くても許容できるかなぁと。次のアプリの開発でも、ほぼコピー&ペーストでいけますしね。私もこれ、コピー&ペーストで作成して、<NavigationView>
の子要素の部分を付け加えただけで作っています。本アプリで子要素を追加するという面倒な作業が追加になった理由は、公共交通オープンデータセンターからデータを取得する際には上のコード中の「本アプリが使用する……」のような通知を書く必要があったためです。
でも、あれ? <com.google.android.material.navigation.NavigationView>
タグの属性を見ていくと、app:headerLayout="@layout/item_navigation_header"
やapp:menu="@menu/menu_navigation"
と書いてあって、こんなリソースは無いとエラーになっています。以下の図のようにNavigation
drawerはヘッダーとメニューとそれ以外で構成されていて、それ以外は子要素で定義したので、ヘッダーとメニューを定義しなければならないんですね。
というわけで、ヘッダーを作成します。プロジェクトを右クリックして、[New] - [Android Resource File]メニューを選択し、[File name]に「item_navigation_header」を入力して[Resource type]を「Layout」に設定し、作成されたitem_navigation_header.xmlに以下を入力します。
<?xml version="1.0" encoding="utf-8"?>
layout
< xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
androidx.constraintlayout.widget.ConstraintLayout
< android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingBottom="16dp"
android:background="@color/colorPrimary">
ImageView
< android:id="@+id/imageView"
android:layout_width="64dp"
android:layout_height="64dp"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:src="@mipmap/ic_launcher_round" />
TextView
< android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
app:layout_constraintStart_toEndOf="@id/imageView"
app:layout_constraintTop_toTopOf="@id/imageView"
app:layout_constraintBottom_toBottomOf="@id/imageView"
android:text="@string/app_name" />
androidx.constraintlayout.widget.ConstraintLayout>
</
layout> </
次。メニューです。プロジェクトを右クリックして、[New] - [Android Resource Directory]メニューを選択し、[Resource type]を「Menu」に設定してres/menuを作成します。そのうえで、プロジェクトを右クリックして、[New] - [Android Resource File]メニューを選択して、[File name]に「menu_navigation」を入力して[Resource type]を「Menu」に設定し、作成されたmenu_navigation.xmlに以下を入力します。
<?xml version="1.0" encoding="utf-8"?>
menu xmlns:android="http://schemas.android.com/apk/res/android">
<group>
<item android:id="@+id/clearDatabase" android:title="バス停と路線のデータを再取得" />
<group>
</menu> </
公共交通オープンデータセンターから取得したデータのキャッシュが古くなった場合に、再取得するためのメニューですね。
いろいろと作業してきましたけど、ごめんなさい、でもまだ終わりません。元の画面でもApp
barっぽいのがあったのに、activity_main.xmlに新たに<com.google.android.material.appbar.AppBarLayout>
が追加されたことが不思議ではありませんでしたか? こんなことをした理由は、元の画面でのApp
barっぽいのは制御ができない(少なくとも私は制御のやり方をしらない)から。だから、新しいのを追加したわけですな。
新しいのを追加した以上は古いのを削除しなければならないわけで、そのためにAndroidManifest.xmlを修正します。
<?xml version="1.0" encoding="utf-8"?>
manifest
< xmlns:android="http://schemas.android.com/apk/res/android"
package="com.tail_island.jetbus">
application
< android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
activity android:name=".MainActivity" android:theme="@style/AppTheme.NoActionBar"> <!-- android:theme属性を追加 -->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<intent-filter>
</activity>
</application>
</
manifest> </
AppTheme.NoActionBar
というスタイルにするように指定しているわけ……なのですけど、このAppTheme.NoActionBar
は自分で作らなければならないんですよ。無駄に感じる作業が続いてかなり腹が立ってきた頃かと思いますが(私は、毎回この作業の途中で独り言で文句を言っているらしくて、周囲に不気味がられています)、アプリ開発につき1回だけ、しかもコピー&ペーストで済む作業ですので頑張りましょう。[Project]ビューのres/values/styles.xmlを、以下に変更します。
<?xml version="1.0" encoding="utf-8"?>
resources>
<
style name="BaseAppTheme" parent="Theme.MaterialComponents.Light.DarkActionBar">
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
<style>
</
style name="AppTheme" parent="BaseAppTheme" />
<
style name="AppTheme.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<style>
</
style name="AppTheme.AppBarOverlay" parent="ThemeOverlay.MaterialComponents.Dark.ActionBar" />
<
style name="AppTheme.PopupOverlay" parent="ThemeOverlay.MaterialComponents.Light" />
<
resources> </
App barとNavigation drawerとライブラリのNavigationを組み合わせましょう。MainActivity.ktを開いて、以下に変更してください。
package com.tail_island.jetbus
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.view.View
import androidx.core.view.GravityCompat
import androidx.databinding.DataBindingUtil
import androidx.navigation.findNavController
import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.NavigationUI
import com.tail_island.jetbus.databinding.ActivityMainBinding
class MainActivity: AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Bindingを生成します
= DataBindingUtil.setContentView<ActivityMainBinding>(this, R.layout.activity_main).also {
binding // NavigationControllerを初期化します
(R.id.navHostFragment).apply {
findNavController// レイアウトで設定したToolbarとDrawerLayoutと協調させます。また、BookmarksFragmentをルートにします。itは、ActivityMainBindingのインスタンスです
.setupWithNavController(it.toolbar, this, AppBarConfiguration(setOf(R.id.bookmarksFragment), it.drawerLayout))
NavigationUI
// SplashFragmentでは、ツールバーを非表示にします
{ _, destination, _ ->
addOnDestinationChangedListener .appBarLayout.visibility = if (destination.id == R.id.splashFragment) View.GONE else View.VISIBLE
it}
}
}
}
// 不整合の辻褄をあわせます。なんで我々がと思うけど我慢……。
override fun onBackPressed() {
if (binding.drawerLayout.isDrawerOpen(GravityCompat.START)) { // Navigation drawerが開いているときは、[戻る]ボタンでクローズします
.drawerLayout.closeDrawer(GravityCompat.START)
bindingreturn
}
if (findNavController(R.id.navHostFragment).currentDestination?.id == R.id.bookmarksFragment) { // AppBarConfiguration()したのでツールバーはハンバーガー・アイコンになっていますが、それでもバック・ボタンでは戻れちゃうので、チェックします
()
finishreturn
}
super.onBackPressed()
}
}
このコードは少し複雑ですから、解説を。
NavigationとNavigation drwerとApp
barはとても良くできていて、上のコードのようにNavigationUI.setupWithNavController()
で結合できるのですけど、少しだけ、AndroidのAPIとの不整合があります。不整合その1は、Navigation
drawerが開いているときに[戻る]ボタンが押されたときの動作です。Navigation
drawerが表示されるというのは<com.google.android.material.navigation.NavigationView>
に画面遷移したように見えるのですけど、内部的には画面遷移になっていないので、[戻る]ボタンを押してもNavigation
drawerは閉じられません。これではユーザーが混乱しますから、onBackPressed()
をオーバーライドして「Navigation
drawerが開いている場合は閉じる」処理を追加しました。不整合その2は、本アプリでは、NavigationのXMLでのトップ/レベルの画面はSplashFragment
なのですけど、アプリ的にはBookmarksFragment
がトップ・レベルであることです。なので、上のコードのAppBarConfiguration(setOf(R.id.bookmarksFragment), ...)
でトップ・レベルの指定をしているのですけど、やっぱり[戻る]ボタンでの動作がおかしくなっちゃう。BookmarksFragment
ではApp
barの左がハンバーガー・アイコンになって戻れない様になっていて正しいのですけど、[戻る]ボタンを押すとSplashFragment
に戻ってしまいます。なのでやっぱり、onBackPressed()
の中に遷移を制御する処理を追加しました。
で、onBackPressed()
はメソッドですから、onCreate()
とBinding
を共有したい場合は属性を追加しなければなりません。だからbinding
という属性を追加しなければならないのですけど、このbinding
は、onCreate()
が呼ばれるまで値を設定できないという問題があります。しょうがないので最初はnull
を設定する……のは、KotlinのようなNull安全を目指している言語では悪手です。今回のように、binding
の値が設定される前に使用されないことを保証できる(onBackPressed()
はonCreate()
が終わった後にしか呼び出されないので、保証できます)場合は、プログラマーの責任で属性をlateinit
として定義できます。lateinit
にすると初期値を設定しなくてよいので、ほら、binding
の型をnull
を許容「しない」ActivityMainBinding
に設定できました。
あと、上のコードでやっているのは、SplashFragment
でApp
barを消しています。何もしないとApp
barに[戻る]アイコンが表示されてしまいますし、調べた限りでは、他のアプリでもスプラッシュ画面にはApp
barがありませんでしたから。
ともあれ、これで作業完了のはず。試してみましょう
うん、完璧ですな。Navigationよ今夜もありがとう。Navigationくらいに楽チンな、App barとNavigation drawerをどうにかしてくれるライブラリがJetpackに追加されないかなぁ……。
Retrofit2
さて、本アプリは公共交通オープンデータセンターが提供してくれるデータが無いと動きようがありませんので、個々の画面の機能を作っていく前に、HTTP通信でWebサーバーからデータを取得する処理を作ってみましょう。残念なことにJetpackにはHTTP通信の機能がありませんので、外部のライブラリであるRetrofit2を使用します。
Retrofit2の組み込み
Retrofit2を組み込むために、build.gradelを変更します。
{
dependencies ...
implementation 'androidx.room:room-ktx:2.2.0-beta01'
'com.squareup.retrofit2:retrofit:2.6.1' // 追加
implementation 'com.squareup.retrofit2:converter-gson:2.6.1' // 追加
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation
...
}
これで、いつでもHTTP通信できるわけですけど、その前に、公共交通オープンデータセンターにユーザー登録しないとね。
公共交通オープンデータセンターへのユーザー登録
公共交通オープンデータセンターのサイトを開いて、下の方にある[公共交通オープンデータセンター開発者サイト]リンクをクリックします。で、真ん中あたりにある[ユーザ登録のお願い]ボタンを押して、もろもろ入力して[この内容で登録を申請する]ボタンを押すと、「最大2営業日」で登録完了のメールが送られてきます。……どうして、最大とはいえ2営業日もかかるんだろ?
登録が完了したらログインして、右上の[Account]メニューの[アクセストークンの確認・追加]を選ぶと、アクセス・トークンが表示されます。まずはこれをコピーしてください。この情報の保存先としては、リソースを使用します。プロジェクトを右クリックして、[New] - [Android Resource File]メニューを選択して、[File name]に「odpt」を入力して[Resource type]を「Value」に設定し、作成されたodpt.xmlに以下を入力します。
<?xml version="1.0" encoding="utf-8"?>
resources>
<string name="consumerKey">アクセス・トークンをここにペースト</string>
<resources> </
よし、これで公共交通オープンデータセンターのデータを使い放題……なのですけど、いったい、どんなデータがあるのでしょうか?
公共交通オープンデータセンターのデータ
公共交通オープンデータセンター開発者サイトの[API仕様]リンクをクリックすれば、公共交通オープンデータセンターが提供するデータの仕様が分かります。
左に表示されている目次の[4. ODPT Bus API]リンクをクリックすると、バスに関するどのようなデータをとれるのかが分かります。見てみると、以下の6つの情報を取得できるみたい。
odpt:BusstopPole
(バス停(標柱)の情報)odpt:BusroutePattern
(バス路線の系統情報)odpt:BusstopPoleTimetable
(バス停(標柱)の時刻表)odpt:BusTimetable
(バスの便の時刻表)odpt:BusroutePatternFare
(運賃情報)odpt:Bus
(バスの運行情報)
バスの運行情報がありますから、バスの車両の接近情報は表示できそうですね。なんかいろいろ細かいことが書いてありますけど、習うより慣れろってことで、Webブラウザを開いて、まずはバス停を取得してみましょう。「https://api.odpt.org/api/v4/odpt:BusstopPole?acl:consumerKey=ACL_CONSUMERKEY&odpt:operator=odpt.Operator:Toei」(ACL_CONSUMERKEYの部分には、ユーザー登録で取得したアクセストークンをコピー&ペーストしてください)を開いてみます。なるほどバス停のデータが取得できている……のですけど、検索しても、こんなダラダラ生きている私を雇用してくださってるとてもありがたい「日本ユニシス本社前」という、私が会社からの帰りに使うバス停が見つかりませんでした。
データが欠落しているのは、API仕様の[1.3.
インターフェース]の中の[1.3.1.
留意点]に「APIによって出力される結果がシステムの上限件数を超える場合、上限件数以下にフィルターされた結果が返る」とあって、リンクを辿って調べてみたら、その上限件数は1,000件だったためです(2020年3月現在)。odpt:BusstopPole
はバス停ではなくてバス停の標柱(方面ごとに立っているアレ)で大量なので、余裕で1,000件を超えてしまって切り捨てられちゃったみたい。
というわけで、データ検索APIではなくて、データダンプAPIを使いましょう。「https://api.odpt.org/api/v4/odpt:BusstopPole.json?acl:consumerKey=ACL_CONSUMERKEY」(URIに「.json」が追加されました。あと、先ほどと同様に、ACL_CONSUMERKEYの部分には、ユーザー登録で取得したアクセストークンをコピー&ペーストしてください)を開いて、データの取得が完了するまで、しばし待ちます。日本全国津々浦々のバス停の標柱全てという大量のデータなので、時間がかかるんですよ……。はい、今度は、「日本ユニシス本社前」のデータが見つかりました。
ドキュメントによれば、odpt:BusstopPole
とodpt:BusroutePattern
はowl:sameAs
属性で互いに紐付けられるので、出発バス停の標柱群(一つのバス停に複数の標柱がある)と到着バス停の標柱群を指定すれば、その両方に紐付いているodpt:BusroutePattern
を抽出することで路線を見つけることができそう。ただ、odpt:BusroutePattern
の検索APIのクエリー・パラメーターにはodpt:BusstopPole
がなかったので、抽出は自前のコードでやらなければなりませんけどね。どうせ抽出を自前のコードでやるのであれば、odpt:BusroutePattern
もデータダンプAPIでまるっと取得してしまうことにしましょう。
次。odpt:Bus
です。APIの[4.ODPT Bus
API]の中の[4.2.パス]を読むと、odpt:BusroutePattern
をクエリー・パラメーターにとることができて、[1.4.
データ検索API (/v4/RDF_TYPE?)]の中の[1.4.1.
フィルター処理]によれば、カンマ区切りにすればOR条件での検索になるらしい。これなら、複数の路線のバスの運行情報を一発で取得できる……のですけど、[4.3.
定義]の中の[4.3.1.
odpt:Bus]の記述によれば、どのバス停を通過したのかは分かるけど、あとどれくらいで今私の目の前にあるバス停に到着するのかは分からないみたい。なので、odpt:BusTimetable
も取得して、時刻表からバス停とバス停の間の時間を調べて、それを足し合わせて到着までの予想時間としましょう。だから、odpt:BusTimetable
を取得する処理も作らないとね。
あと、API仕様にはバスの現在の位置によってodpt:fromBusstopPole
属性(直近に通過した、あるいは停車中のバス停)とodpt:BusstopPole
(次に到着するバス停)の情報がいろいろ変わると書いてあるのですけど、都営バスの実際のデータを取得して調べてみると、どうやら現在位置がどうであれodpt:fromBusstopPole
属性とodpt:toBusstopPole
属性の両方が設定されているみたい。ならば手を抜いてodpt:fromBusstopPole
属性だけを見ればいいかなぁと。さらに、odpt:BusTimetable
のodpt:calendar
属性(平日時刻表とか休日時刻表とか)は[2.3.
定義]の中の[2.3.1.
odpt:Calendar]に書いてある汎用データを使用していませんでした。なんだか、都営バス独自の特殊なデータが並んでいやがります……。まぁ、今回のodbt:BusTimetable
の使用目的は到着時間を計算するための元ネタでしかありませんから、odpt:calendar
も無視することにしましょう。プログラムが簡単になるしね。
そうそう、odpt:BusstopPole
とodpt:BusroutePattern
、odpt:BusTimetable
は、データの取得に時間がかかる上にほとんど変更がない情報ですから、RDBMSにキャッシュすることにしましょう。RDBMSを使えば、突き合わせの処理が楽になりますしね。
Webサービスの定義
やることが決まりましたので、Webサービスの定義を作りましょう。でもその前に、データ受け渡しのためのクラスを作成します。まずは、odpt:Bus
を表現するクラスを作成します。モデルを入れるためのmodel
パッケージを作成して、その中にBus.ktファイルを作成して、以下のコードを入力してください。
package com.tail_island.jetbus.model
import com.google.gson.annotations.SerializedName
data class Bus(
SerializedName("owl:sameAs")
@var id: String,
SerializedName("odpt:busroutePattern")
@var routeId: String,
@SerializedName("odpt:fromBusstopPole")
var fromBusStopPoleId: String
)
なんでクラスの後ろの括弧が波括弧({}
)じゃなくて丸括弧(()
)なんだという疑問を抱いたJavaプログラマの方がいるかもしれませんので、ここで少しだけKotlinの解説をさせてください。
Kotlinでは、Scalaと同様に、クラス定義の際にコンストラクタの引数を定義できます。class Foo(param1: Type, param2: Type)
みたいな感じ。そんなところに引数を書いたらコンストラクタでの処理はどこに書くんだよと思うかもしれませんけど、コンストラクタの処理は普通は書きません(どうしても書きたい場合にはinit {}
という構文があるのでご安心を)。どうして普通は書かないのかと言うと、コンストラクタでの処理は一般にインスタンスの状態の設定で、インスタンスの状態である属性の定義ではコンストラクタの引数が使えるから。class Foo(param: Type) { var bar = param.doSomething() }
みたいな感じです。
あと、Kotlinはミュータブル(可変)の変数はvar
、イミュータブル(不変)の変数はval
で宣言するのですけど、var
やval
は先のコードのようにプロパティの定義でも使えます(var
だとget
とset
が生成されて、val
だとget
だけが生成される)。これがコンストラクタの引数にも適用されて、class Foo(var param1: Type, val param2: Type) {}
と書けば、ミュータブルなプロパティのparam1
とイミュータブルなプロパティのparam2
が生成されるというわけ。
さらに、データを保持するためのクラス作成専用のdata class
という構文があります。data class
を使うと、equals()
メソッドやhashCode()
メソッド、toString()
メソッド、copy()
メソッド(あとcomponentN()
メソッド)が自動で生成されてとても便利。さらに、data class
でメソッドの定義が不要な場合は{}
を省略できるので、上のようなコードになるわけですな。
さて、Retrofit2(が内部で使用しているGSON)的に重要なのは、@SerializedName
の部分です。これはアノテーションと呼ばれるもので、ライブラリやコード・ジェネレーターが参照します。Retrofit2(が内部で使用しているGSON)は、@SerializedName
アノテーションを見つけると、JSONを作成する際に@SerializedName
アノテーションの引数で指定した文字列を使用してくれます。これで、owl:sameAs
のようなKotlinでは許されない名前の属性を持ったJSONでも取り扱えるようになるというわけ。ふう、これでodpt:Bus
を受け取る準備は完璧です。
同様に残りのodpt:BusstopPole
とodpt:BusroutePattern
、odpt:BusTimetable
も……と考えたのですけど、これらはRDBMSにキャッシュすることにしましたから、RDBMSのレコードを表現するクラスとごっちゃになってしまって混乱しそう。だから今回はクラスを作成しないで、Retrofit2(が内部で使用しているGSON)が提供するJsonArray
(検索APIは配列を返すので、JsonObject
ではなくてJsonArray
にしました)を使用します。
以上でWebサービスを呼び出した結果を受け取るクラスの型が全て決まりましたので、Webサービスを呼び出す部分をRetrofit2を使用して作成しましょう。といっても、実装は簡単でinterface
を定義するだけ。HTTP通信のメソッドをアノテーション(今回は@GET
)で設定して、Webサービスの引数を同様にアノテーション(今回はクエリー・パラメーターなので@Query
)で定義するだけですが。モデルを作成したmodel
パッケージの中にWebService.ktファイルを作成して、以下を入力してください。
package com.tail_island.jetbus.model
import com.google.gson.JsonArray
import retrofit2.Call
import retrofit2.http.GET
import retrofit2.http.Query
interface WebService {
@GET("/api/v4/odpt:BusstopPole.json")
fun busstopPole(@Query("acl:consumerKey") consumerKey: String): Call<JsonArray>
GET("/api/v4/odpt:BusroutePattern.json")
@fun busroutePattern(@Query("acl:consumerKey") consumerKey: String): Call<JsonArray>
GET("/api/v4/odpt:BusTimetable")
@fun busTimeTable(@Query("acl:consumerKey") consumerKey: String, @Query("odpt:busroutePattern") routePattern: String): Call<JsonArray>
GET("/api/v4/odpt:Bus")
@fun bus(@Query("acl:consumerKey") consumerKey: String, @Query("odpt:busroutePattern") routePattern: String): Call<List<Bus>>
}
はい、完成です。楽チン。
Webサービスの呼び出し
とは言ってもinterface
は呼び出しができませんので、なんとかして(Retrofit2のAPIが要求する形で)インスタンスを生成する方法を調べなければなりません。あとですね、Webサービスの呼び出しには時間がかかることにも、考慮が必要です。というのも、Androidではユーザー・インターフェースの制御はメイン・スレッドのみから実施できることになっていて、だからメイン・スレッドでWebサービスのような時間がかかる処理をすると、その処理の間は画面が無反応になっちゃう。だから、Webサービスの呼び出しは別スレッドでやらなければならないんです(そうしないと、画面が無反応になる以前に実行時エラーとなります)。
と、こんな感じでいろいろ複雑なので、とりあえずコードを書いてみましょう。まずは、odpt:BusstopPole
を取得してみます。最初に表示される画面であるSplashFragment
の、画面に表示される直前に呼び出されるonStart()
メソッドを追加して、以下の処理を書き加えます。
package com.tail_island.jetbus
// ....
import android.util.Log
import com.tail_island.jetbus.model.WebService
import retrofit2.Call
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.io.IOException
import kotlin.concurrent.thread
class SplashFragment: Fragment() {
// ...
override fun onStart() {
super.onStart()
// リソースからアクセス・トークンを取得します。consumerKeyって名前は提供側の用語なので嫌だけど、公共交通オープンデータセンターがこの名前を使っちゃっているからなぁ……
val consumerKey = getString(R.string.consumerKey)
// WebServiceのインスタンスを生成します。スコープ関数が存在しない哀れな環境向けのFluentなBuilderパターン……
val webService = Retrofit.Builder().apply {
("https://api.odpt.org")
baseUrl(GsonConverterFactory.create())
addConverterFactory}.build().create(WebService::class.java)
// Webサービスを呼んでいる間は画面が無反応になるのでは困るので、スレッドを生成します。今は素のスレッドを使用していますけど、後でもっとかっこいい方式をご紹介しますのでご安心ください{
thread try {
// WebServiceを呼び出します
val response = webService.busstopPole(consumerKey).execute()
// HTTP通信した結果が失敗の場合は、エラーをログに出力してnullを返します
if (!response.isSuccessful) {
.e("SplashFragment", "HTTP Error: ${response.code()}")
Log@thread
return}
// レスポンスのボディは、interfaceの定義に従ってJsonArrayになります
val busStopPoleJsonArray = response.body()
// nullチェック
if (busStopPoleJsonArray == null) {
@thread
return}
// JsonObjectに変換して、都営バスのデータだけにフィルターして、最初の10件だけで、ループします
for (busStopPoleJsonObject in busStopPoleJsonArray.map { it.asJsonObject }.filter { it.get("odpt:operator").asString == "odpt.Operator:Toei" }.take(10)) {
// 確認のために、いくつかの属性をログ出力します
.d("SplashFragment", "${busStopPoleJsonObject.get("owl:sameAs")}")
Log.d("SplashFragment", "${busStopPoleJsonObject.get("dc:title")}")
Log.d("SplashFragment", "${busStopPoleJsonObject.get("odpt:kana")}")
Log}
} catch (e: IOException) {
// HTTP以前のエラーへの考慮も必要です。ログ出力しておきます
.e("SplashFragment", "${e.message}")
Log}
}
}
}
「公共交通オープンデータセンターへのユーザー登録」で設定した文字列リソースは、getString()
メソッドで取得できます。Retrofit
の生成APIはFluentなBuilderパターンで作られているんですけど、Fluentが主でBuilderパターンの意味は少なくて、apply
スコープ関数があるKotlinでは無意味……。でもしょうがないので、折衷案なスタイルのコードとなりました。これで初期化作業は終了。
前述したようにWebサービスの呼び出しは別スレッドでやらなければならないのですけど、Kotlinならkotlin.concurrent.thread
でラムダ式を別スレッドで実行できて便利です。でもまぁ、コード中のコメントにも書きましたけど、後の章で述べるコルーチンを使えばさらにかっこよく書けるので、この書き方はすぐに忘れちゃって大丈夫なんだけどね。
別スレッドの中で、先程取得したWebService
のインスタンスのbusstopPole()
メソッドを呼び出してCall
インスタンスを取得して、さらにexecute()
しています。こんな面倒な形になっているのは、同期で呼び出す場合にも非同期(コールバック方式)で呼び出す場合にも対応しているから。で、今回は可読性が高い同期のexecute()
メソッドを使用しました。あとは、isSuccessful
でHTTPのエラーが発生していないことを確認して、body()
を取得して、これで通信は終了。
ここまででWebサービスから取得したJsonArray
は、テストのために、Log.d()
で内容をログ出力して終了です。あとは、HTTP以前のエラー(たとえばサーバーが見つからない等)に対応するために、try/catch
します。
と、こんな感じでRetrofit2は使用できるのですけど、上のコードは、実はかなり格好悪いコードなんですよ。Kotlinの良さを全く引き出せていません。なので、修正しましょう。
Null安全の便利機能を使ってみる
Kotlinでは、NullPointerException
が発生するようなコードは、基本的にコンパイルできません。たとえば、先程のコードの「nullチェック」とコメントしたif
をコメント・アウトすると、コンパイル・エラーとなります。
Kotlinがこのような離れ業をするには変数や関数の戻り値がnull
になりえるかどうかをコード上で表現できなければならないわけで、Kotlinは、それを型名の後ろに?
がつくかどうかで表現しています。Int
ならnull
になることはない、Int?
ならばnull
になる可能性があるって感じ。上のコードのRetrofit2のResponse
のbody()
はJsonArray?
を返すので、その戻り値のメソッドを呼ぶとnull
の可能性があるからコンパイル・エラーになるというわけ。
で、KotlinでFoo?
の変数をFoo
にするのは簡単で、if
等でnull
でないことを確認すればよい。先程のコードのif
がまさにそれなわけですな。でも、そんなif
だらけのコードを書くのは大変すぎるし読みづらすぎるので、いくつかの便利な記法があります。
1つ目は、!!
。文法的にはnull
の可能性があるように見えるかもしれないけれど、null
でないことを我々プログラマーが保証してやるぜって場合です。var x: Foo? = null; x.bar()
はコンパイル・エラーになりますけど、var x: Foo? = null; x!!.bar()
ならコンパイルは通ります。もちろん、実行時にNullPointerException
が出るでしょうけど。
2つ目は、?.
。null
ならばメソッドを呼び出さないでnull
を返して、そうでなければメソッドを実行してその戻り値を返すという記法です。var x: Foo? = null; var y = x?.bar()
は、コンパイルも通りますしNullPointerException
も出ません。bar()
は実行されず、y
にはnull
がセットされます。
3つ目は、?:
。?.
の反対で、null
の場合に実行させたい処理を記述できます。var x: Foo ?= null; var y = x?.bar() ?: "BAR"
なら、y
の値はnull
ではなく「BAR」になります。
これらの記法ともはや見慣れたスコープ関数を組み合わせれば、先程のコードはもっときれいになります。そう、こんな感じ。
{
run val response = webService.busstopPole(consumerKey).execute()
if (!response.isSuccessful) {
.e("SplashFragment", "HTTP Error: ${response.code()}")
Log@run null
return}
.body() // ラムダ式では、最後の式の結果がラムダ式の戻り値になります
response
}?.let { busStopPoleJsonArray -> // ?.なので、run { ... }の結果がnullならlet { ... }は実行されません。
for (busStopPoleJsonObject in busStopPoleJsonArray.map { it.asJsonObject }.filter { it.get("odpt:operator").asString == "odpt.Operator:Toei" }.take(10)) {
.d("SplashFragment", "${busStopPoleJsonObject.get("owl:sameAs")}")
Log.d("SplashFragment", "${busStopPoleJsonObject.get("dc:title")}")
Log.d("SplashFragment", "${busStopPoleJsonObject.get("odpt:kana")}")
Log}
} ?: return@thread // run { ... }の結果がnullならリターン
うん、マシになりました。でもまだ駄目です。他のWebサービスも呼び出す場合は、run { ... }
の中のほとんどをもう一回書かなければならないでしょうから。
高階関数を作ってみる
というわけで、関数化しましょう。こんな感じ。
private fun <T> getWebServiceResultBody(callWebService: () -> Call<T>): T? {
val response = callWebService().execute()
if (!response.isSuccessful) {
.e("SplashFragment", "HTTP Error: ${response.code()}")
Logreturn null
}
return response.body()
}
引数は関数です。このような関数を引数にする関数を高階関数と呼びます。Kotlinでは、(引数) -> 戻り値
で関数そのものの型を表現できて、上のコードの<T>
の部分はテンプレートです。呼び出し側はこんな感じ。
{ webService.busstopPole(consumerKey) }?.let { busStopPoleJsonArray ->
getWebServiceResultBody for (busStopPoleJsonObject in busStopPoleJsonArray.map { it.asJsonObject }.filter { it.get("odpt:operator").asString == "odpt.Operator:Toei" }.take(10)) {
.d("SplashFragment", "${busStopPoleJsonObject.get("owl:sameAs")}")
Log.d("SplashFragment", "${busStopPoleJsonObject.get("dc:title")}")
Log.d("SplashFragment", "${busStopPoleJsonObject.get("odpt:kana")}")
Log}
}
もはや見慣れたコードですな。前にも述べましたけど、関数はラムダ式で定義することができて、最後のパラメーターがラムダ式の場合はそのパラメーターは括弧の外にだす慣習があって、そして、ラムダ式だけを引数にする場合は括弧を省略できるので、このようなすっきりした記述になります。
ついでですから、odpt:BusroutePattern
を取得して、その路線のodpt:Bus
を取得するコードも書いてみましょう。
{ webService.busroutePattern(consumerKey) }?.let { busroutePatternJsonArray ->
getWebServiceResultBody for (busroutePatternJsonObject in busroutePatternJsonArray.map { it.asJsonObject }.filter { it.get("odpt:operator").asString == "odpt.Operator:Toei" }.take(10)) {
.d("SplashFragment", "${busroutePatternJsonObject.get("owl:sameAs")}")
Log.d("SplashFragment", "${busroutePatternJsonObject.get("dc:title")}")
Log
for (busstopPoleOrderJsonObject in busroutePatternJsonObject.get("odpt:busstopPoleOrder").asJsonArray.take(10).map { it.asJsonObject }) {
.d("SplashFragment", "${busstopPoleOrderJsonObject.get("odpt:index")}")
Log.d("SplashFragment", "${busstopPoleOrderJsonObject.get("odpt:busstopPole")}")
Log}
{ webService.bus(consumerKey, busroutePatternJsonObject.get("owl:sameAs").asString) }?.let { buses ->
getWebServiceResultBody for (bus in buses.take(10)) {
.d("SplashFragment", bus.id)
Log.d("SplashFragment", bus.routeId)
Log.d("SplashFragment", bus.fromBusStopPoleId)
Log}
}
}
}
getWebServiceResultBody()
メソッドを定義済みなのでとても簡単です。odpt:Bus
の方は、クラスを定義したのでさらに簡単なコードになっていますな。
アプリへの権限の付与
というわけで、コーディングは終わり。早速実行……するまえに、アプリにインターネット・アクセスの権限を付加しなければなりません。[Project]ビューの[app]
-
[manifests]の下のAndroidManifest.xmlを開いて、<uses-permission>
タグを追加してください。
<?xml version="1.0" encoding="utf-8"?>
manifest
< xmlns:android="http://schemas.android.com/apk/res/android"
package="com.tail_island.jetbus">
uses-permission android:name="android.permission.INTERNET" /> <!-- 追加 -->
<
application
<....>
....application>
</
manifest> </
これで本当に全て完了。実行してみましょう。Log
で出力した結果は、Android
Studioの[Logcat]ビューで見ることができます。
うん、正しくデータを取れていますね。Retrofit2ならWebサービス呼び出しはとても楽チン。
Room
前章で取得した公共交通オープンデータセンターのデータは、取得に時間がかかる上に変更が少ないので、RDBMSにキャッシュしたい。というわけで、Android OS内蔵のSQLite3への抽象化レイヤを提供するRoomを使用してみます。そこそこ便利ですよ。
Roomが、他のO/Rマッピング・ツールとは異なるところ
さて、O/Rマッピングというと、データを表現するクラスを元にテーブルが生成され(もしくは、テーブル定義からクラスが生成され)、黙っていてもINSERTやUPDATE、DELETEをするメソッドが自動生成され、さらに、条件を指定するSELECTがメソッド呼び出しで実施できるようになって、ついにはテーブルとテーブルの間の関連を辿るメソッドが魔法のように現れる重厚長大なツールを想像するかもしれません。
でも、ごめんなさい、Roomは違うんです。それも、悪い意味で。
まず、テーブルとテーブルの間の関連を辿ることはできません。Roomでは、テーブル単位での操作しかできないんです。もちろん理由はあって、データベースの操作という重い処理を、ユーザー・インターフェース制御を担当するメイン・スレッドでやるわけにはいかないためです。メイン・スレッドの中で関連を辿るプロパティをgetしたらデータベース・アクセスが動いてユーザー・インターフェースが止まってしまう……なんてのは困りますもんね。
あと、条件を指定してのSELECTでは、条件を指定する部分はSQL文を文字列としてベタ打ちしなければなりません。呼び出す側からはオブジェクト指向っぽく見えるけど、実装する側からはゴリゴリSQLなんです。まぁ、SQL文を書くのは面倒に感じますけど、find(column: String, value: Int)
みたいなボロっちいメソッドしか提供されなくて効率が悪いSQLが実行されちゃうよりはマシです。後で説明しますけど、テーブルとテーブルの間の関連を辿る機能がない問題も、SQL文をベタ書きできるならあまり問題にはなりませんし。
一般的なO/Rマッピング・ツールの残りの機能は提供されるのですけど、残っているのは、アノテーションをつけることでINSERTやUPDATE、DELETEが自動生成されるのと、データを表現するクラスを書けばテーブルが生成されることだけです。ぶっちゃけ、Roomが提供する機能はこれだけなんですよ。
と、ここまでを読んで「そんなヘッポコなのはO/Rマッピング・ツールじゃない」と思われたかもしれませんけど、O/Rマッピングとはオブジェクトとリレーションのマッピングで、この「リレーション」というのはリレーショナル・データベース用語ではテーブルのことなんです(テーブルとテーブルの間の関連は、リレーションシップ)。だから、RoomをO/Rマッピングと呼んでも、言葉の意味では問題ないかなぁ。
まぁ、いろいろとひどいことを書きましたけど、私はRoomが好きです。「こういうのでいいんだよ。こういうので」ってヤツですな。他のO/Rマッピング・ツールがオーバースペックなだけ。ほら、前の章でJSONそのものを表現するJsonObjectを使った場合(obpt:BusstopPole
やodpt:BusroutePattern
の場合)より、JSONのデータをクラスにマッピングさせた場合(odpt:Bus
の場合)のほうがコードが簡単だったでしょ? あれと同じで、テーブルのレコードをインスタンスにマップしてくれるだけでも、プログラミングはとても楽になるんです。単純な機能しかないので使うの簡単ですし、SQLベタ書きなのでリレーショナル・データベースの機能を引き出しやすいですしね。
データ構造の設計
と、他のO/Rマッピング・ツール経験者への言い訳が終わったところで、作業に入りましょう。まずは、データ構造の設計です。私は設計文書を書かないでいきなりコードを書き出すタイプの人間ですけど、そんな私でもプログラミング前にデータ構造だけは設計します。ER図かUMLのクラス図を描くだけですけどね。こんなの。
今回はER図にしてみました。手書きのなぐり書きですけど、設計はこんなので十分。だって、これでアプリが機能を提供できるかを確認できるのですから。試しにやってみましょう。到着バス停を指定する画面を作れるか、確認してみます。
出発バス停を指定する画面ではしょうがないのですべてのバス停を表示しますけど、今確認している到着バス停を選ぶ画面では、出発バス停とつながっている(乗り換え無しで行ける)バス停だけを表示して欲しいですよね? この機能を実現できるかを確認します。
BusStop
からBusStopPole
を辿れることは、図から明らかです。BusStopPole
まで行ければ、RouteBusStopPole
を経由してRoute
を取得できます。Route
が分かれば、今度は逆に辿ることでBusStop
まで行けます。ほら、出発バス停が指定されれば、そこから、出発バス停とつながっているバス停の一覧を取得できます。これで、到着バス停を指定する画面を余裕で作れることが確認できました。
あと、出発バス停と到着バス停が分かれば、対象となるRoute
が上りなのか下りなのかの判断ができます。RouteBusStopPole
にはorder
があって、これはRoute
における順序です。だから、出発のBusStop
に関連付けられたRouteBusStopPole.order
が到着のBusStop
に関連付けられたRouteBusStopPole.order
よりも小さい方のRoute
を選べば、上りか下りのどちらかとなるというわけ。SQLで書くと、こんな感じです。
SELECT Route.*
FROM BusStopPole AS ArrivalBusStopPole
INNER JOIN RouteBusStopPole AS ArrivalRouteBusStopPole ON
= ArrivalBusStopPole.id
ArrivalRouteBusStopPole.busStopPoleId INNER JOIN Route ON
id = ArrivalRouteBusStopPole.routeId
Route.INNER JOIN RouteBusStopPole As DepartureRouteBusStopPole ON
= Route.id
DepartureRouteBusStopPole.routeId INNER JOIN BusStopPole AS DepartureBusStopPole ON
id = DepartureRouteBusStopPole.busStopPoleId
DepartureBusStopPole.WHERE ArrivalRouteBusStopPole.'order' > DepartureRouteBusStopPole.'order' AND
= "日本ユニシス本社前" AND
DepartureBusStopPole.busStopName = "塩浜二丁目" ArrivalBusStopPole.busStopName
前の方でRoomにはテーブル間の関係を辿る機能がないと書きましたけど、このSQLのようにJOIN
で繋げば(もしくは、後の章で述べるように副問合せを使えば)他のテーブルのカラムを検索条件に指定することは可能です。だから、Roomにテーブルの間の関連を辿る機能がなくても大丈夫なんです。そもそも、この処理なんて、普通のO/Rマッピング・ツールで書くとかえって大変ですよ。出発のBusStopPole
の集合を作成してRouteBusStopPole
を経由してRoute
の集合を取得し、到着でも同じ処理をして、それぞれのRoute
の集合をunion
して、さらにその後にorder
を使用してフィルタリングするような処理になっちゃうでしょうからね。
さて、Route
が分かればTimeTable
を取得できます。ただ、バスは一つの路線を日に何回も行き来していますから、Route
とTimeTable
の関係は1対多となっています。よって、複数あるTimeTable
の中から一つを選ばなければなりません。最初の一つとかランダムで一つとかを選んでもいいのですけど、道路が空いているときと混んでいるときで時刻表は変わるだろうと考えて、到着バス停に関連付けられたTimeTableDetail.arrival
が現在時刻に最も近いものを選ぶことにしましょう。
あとは、データベースでは管理しないけどER図には追加しておいたBus
に関連付けられたBusStopPole
に関連付けられたTimeTableDetails.arrival
と、出発BusStopPole
と関連付けられているTimeTableDetails.arrival
の差を計算すれば、ほら、これで到着までの時間になります。バスの接近情報を表示する画面も完璧ですな。
と、こんな感じでシミュレーションできたので、これで設計は終わり! プログラミングに入りましょう。
エンティティの定義
設計したER図のエンティティをプログラミングします。とは言っても、Retrofit2のところでやったデータ受け渡しのクラスの作成と似た作業、data class
を作るだけの簡単作業です。まずは、BusStop
を作ってみましょう。
package com.tail_island.jetbus.model
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity
data class BusStop(
PrimaryKey
@var name: String, // 名称(公共交通オープンデータセンターのデータでは、名称はユニークみたい)
var phoneticName: String? // ふりがな
)
RoomのエンティティであることをRoomに伝えるために、@Entity
アノテーションを追加しておきます。あと、リレーショナル・データベースではレコードを識別するための主キーが必要なので、@PrimaryKey
アノテーションで主キーを指定しています。
BusStop
に関連付けられるBusStopPole
も作りましょう。
package com.tail_island.jetbus.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
@Entity(foreignKeys = [ForeignKey(entity = BusStop::class, parentColumns = ["name"], childColumns = ["busStopName"])])
data class BusStopPole(
PrimaryKey
@var id: String,
ColumnInfo(index = true)
@var busStopName: String
)
BusStopPole
では、BusStop
と関連付けられていることを示すために@Entity
アノテーションのforeignKeys
を指定してます。参照整合性と呼ばれるアレですな。あと、busStopName
で検索するときに速度が出るように@ColumnInfo
アノテーションのindex
にtrue
を指定しています。データベース管理者が検索のパフォーマンス・チューニングのときに貼るインデックスのことですな。
参照整合性というのは、テーブルのレコード間の関連を正しく保つ仕組みです。存在しないBusStop
に関連付けられたBusStopPole
は存在しては駄目ですよね? 上のコードのようにforeignKeys
を指定しておけば、BusStopPole
をデータベースに追加する際に、busStopName
と同じ値をname
に持つBusStop
が存在することを確認してくれるようになります。これでデータの信頼性が高まって素晴らしい。
インデックスというのは、検索のための索引(インデックス)を作成する仕組みです。インデックスが「ない」場合は、テーブルの行を順に見ていって、条件を満たすものがあるか調べます。Kotlinでいうところのrecords.filter { record -> record.busStopName = "日本ユニシス本社前" }
みたいな感じ。レコード数が100件とかなら別にこれでいいですけど、100万件とか1億件とかになると、これでは遅くてやってられません。なので、検索専用のデータ構造を別に作るわけです。B+木というデータ構造を使うことが多いみたいで、B+木を使うとデータ件数が多くてもあっという間に検索できます。Kotlinでも、Map
を使うと検索がとても早くなりますよね? あんな感じ。データの追加や更新でインデックスを更新する手間がかかって少しだけ遅くなりますけど、検索が圧倒的に速くなるのでトータルでは素晴らしい。
さて、これで他のエンティティと関係づけられている場合の書き方もわかりましたから、残りもどんどん作っていきましょう。Route
はこんな感じ。
package com.tail_island.jetbus.model
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity
data class Route(
PrimaryKey
@var id: String,
var name: String
)
BusStop
とほぼ同じですな。続けてこれまでの知見を活用してRouteBusStopPole
を作る……際に問題となるのは、RouteBusStopPole
の主キーです。これまで作成してきたエンティティでは公共交通オープンデータセンターのID(であるowl:sameAs
属性の値)をそのまま使えばよかったのですけど、RouteBusStopPole
ではその手は使えません。でも、独自の重複しないIDを生成して割り当てる処理を作るのは面倒です……。と、そんな場合は@PrimaryKey
アノテーションのautoGenerate
を使用できます。こんな感じ。
package com.tail_island.jetbus.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
@Entity(foreignKeys = [ForeignKey(entity = Route::class, parentColumns = ["id"], childColumns = ["routeId"]), ForeignKey(entity = BusStopPole::class, parentColumns = ["id"], childColumns = ["busStopPoleId"])])
data class RouteBusStopPole(
ColumnInfo(index = true)
@var routeId: String,
var order: Int,
@ColumnInfo(index = true)
var busStopPoleId: String
) {
@PrimaryKey(autoGenerate = true)
var id: Long = 0
}
@PrimaryKey(autoGenerate = true)
と書けば、主キーには重複しない値が自動で設定されます。なお、上のコードでvar id
をコンストラクタの引数にしていないのは、RouteBusStopPole
のインスタンスを作成するときにid
のことを考えたくないから。data class
にも普通のクラスと同じようにメソッドやプロパティは追加できるので、{ ... }
で囲んだ中に普通にvar id: Long = 0
でデフォルト値付きのプロパティを追加したというわけです。
以上でエンティティの作成に必要な知識は揃いましたので、同様にTimeTable
とTimeTableDetail
、Bookmark
を定義しましょう。何も新しいことはしていませんので、コードは省略で。どうしても見たい場合は、GitHubのコードを参照してみてください。
データ・アクセス・オブジェクトの作成
データを入れるエンティティは作成できたので、これを使用してデータベースを操作したい。そのためのオブジェクトがデータ・アクセス・オブジェクト、DAOと省略されるアレですね。さっそく作ってみましょう。BusStop
用のBusStopDao
を作ります。
package com.tail_island.jetbus.model
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
@Dao
interface BusStopDao {
@Insert
fun add(busStop: BusStop)
Query("DELETE FROM BusStop")
@fun clear()
Query("SELECT * FROM BusStop WHERE name = :name LIMIT 1")
@(name: String): BusStop?
fun getByName}
データ・アクセス・オブジェクトであることを指示するために@Dao
アノテーションを追加して、データの追加と更新、削除のメソッドには@Inert
と@Update
、@Delete
を追加します。今回は公共交通オープンデーターセンターから取得したデータを追加するだけなので@Insert
アノテーションのみ。メソッドの引数はエンティティ・オブジェクトにします。
@Query
アノテーションは、メソッドが呼ばれたらSQLを実行するようにとの指示です。どんなSQLでもオッケーで、Queryという名前を無視して検索ではないSQLを指定しても大丈夫です。上のコードのclear()
メソッドがそれですね。公共交通オープンデータセンターのデータと再同期するときのために、clear()
ではすべてのデータを消すDELETE FROM BusStop
を実行するように指示しています。
あとは、バス停の名前を指定してBusStop
を取得するためのgetByName()
メソッドを作成しました。メソッドの引数をSQLに渡す場合は、上のコードのように:パラメーター名
とします。name = :name
のところですね。BusStop.name
は主キーなので1件しか存在しないのですけど、念の為LIMIT 1
を指定しみました。LIMIT
の分で最適化とかが働くといいなぁ……。そうそう、存在しないname
が指定された場合に備えるために、戻り値の型はnull
を許容するBusStop?
にしています。
で、他のデータ・アクセス・オブジェクトもほぼ同様の作り方で作成できるのですけど、エンティティ・オブジェクトで@PrimaryKey(autoGenerate = true)
とした場合は、少しだけ注意が必要です。というのも、新しいエンティティ・オブジェクトを作成してそれをINSERTした場合、自動生成された主キーが分からないと二度とそのオブジェクトにアクセスできなくなっちゃうんです(検索して取得しようにも、主キー以外で検索したのでは一意になる保証がないですもんね)。というわけで、@PrimaryKey(autoGenerate = true)
なRouteBusStopPole
向けのRouteBusStopPoleDao
は、以下のようなコードになります。
package com.tail_island.jetbus.model
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
@Dao
interface RouteBusStopPoleDao {
@Insert
fun add(routeBusStopPole: RouteBusStopPole): Long
Query("DELETE FROM RouteBusStopPole")
@fun clear()
}
@Insert
のメソッドの戻り値を主キーの型にしただけ。これだけで、INSERT時に自動生成された主キーを、戻り値として取得できるようになります。
データベースを定義する
最後。データベースの定義です。こんな感じ。
package com.tail_island.jetbus.model
import androidx.room.Database
import androidx.room.RoomDatabase
@Database(entities = [Bookmark::class, BusStop::class, BusStopPole::class, Route::class, RouteBusStopPole::class, TimeTable::class, TimeTableDetail::class], version = 1, exportSchema = false)
class AppDatabase: RoomDatabase() {
abstract fun getBookmarkDao(): BookmarkDao
abstract abstract fun getBusStopDao(): BusStopDao
abstract fun getBusStopPoleDao(): BusStopPoleDao
abstract fun getRouteBusStopPoleDao(): RouteBusStopPoleDao
abstract fun getRouteDao(): RouteDao
abstract fun getTimeTableDao(): TimeTableDao
abstract fun getTimeTableDetailDao(): TimeTableDetailDao
}
@Database
アノテーションのentities
でデータベースに含めるエンティティを指定して、version
で適当にバージョンを指定します。exportSchema
というのはデータベースを生成するときに使用したスキーマをビルド時に出力してくれる便利機能なのですけど、出力先が指定されていないというビルド・エラーが出たのでfalse
を設定して出力されないようにしました。
あとは、データ・アクセス・オブジェクトを取得するメソッドを定義するだけです。データ・アクセス・オブジェクトではinterface
、データベースではabstract class
しか定義していませんけど、build.gradleで指定したKotlin
Annoataion Processing
Tool(kapt)が実体を自動生成してくれますのでご安心ください。
Roomを呼び出してみる
以上ででRoomの準備は完了です。準備が終わった以上は使ってみたい。前章で作成した公共交通オープンデータセンターからデータを取得するコードを改造して、データベースにデータをキャッシュする処理を書いてみます。
override fun onStart() {
super.onStart()
val consumerKey = getString(R.string.consumerKey)
val webService = Retrofit.Builder().apply {
("https://api.odpt.org")
baseUrl(GsonConverterFactory.create())
addConverterFactory}.build().create(WebService::class.java)
// データベースのインスタンスを作成します。val database = Room.databaseBuilder(requireContext(), AppDatabase::class.java, "jetbus.db").build()
{
thread try {
.d("SplashFragment", "Start.")
Log
// データを削除します。
.getTimeTableDetailDao().clear()
database.getTimeTableDao().clear()
database.getRouteBusStopPoleDao().clear()
database.getRouteDao().clear()
database.getBusStopPoleDao().clear()
database.getBusStopDao().clear()
database
// Webサービスからデータを取得します。
val busStopPoleJsonArray = getWebServiceResultBody { webService.busstopPole(consumerKey) } ?: return@thread
val routeJsonArray = getWebServiceResultBody { webService.busroutePattern(consumerKey) } ?: return@thread
// BusStopとBusStopPoleを登録します。
for (busStopPoleJsonObject in busStopPoleJsonArray.map { it.asJsonObject }.filter { it.get("odpt:operator").asString == "odpt.Operator:Toei" }) {
val busStop = database.getBusStopDao().getByName(busStopPoleJsonObject.get("dc:title").asString) ?: run {
(
BusStop.get("dc:title").asString,
busStopPoleJsonObject.get("odpt:kana")?.asString
busStopPoleJsonObject).also {
.getBusStopDao().add(it)
database}
}
(
BusStopPole.get("owl:sameAs").asString,
busStopPoleJsonObject.name
busStop).also {
.getBusStopPoleDao().add(it)
database}
}
// Routeを登録します。
for (routeJsonObject in routeJsonArray.map { it.asJsonObject}.filter { it.get("odpt:operator").asString == "odpt.Operator:Toei" }) {
val route = Route(
.get("owl:sameAs").asString,
routeJsonObject.get("dc:title").asString
routeJsonObject).also {
.getRouteDao().add(it)
database}
for (routeBusStopPoleJsonObject in routeJsonObject.get("odpt:busstopPoleOrder").asJsonArray.map { it.asJsonObject }) {
(
RouteBusStopPole.id,
route.get("odpt:index").asInt,
routeBusStopPoleJsonObject.get("odpt:busstopPole").asString
routeBusStopPoleJsonObject).also {
.id = database.getRouteBusStopPoleDao().add(it)
it}
}
}
.d("SplashFragment", "Finish.")
Log
} catch (e: IOException) {
.e("SplashFragment", "${e.message}")
Log}
}
}
少しだけ、解説を。
データベースのインスタンス作成は、Room.databaseBuilder(context!!, AppDatabase::class.java, "jetbus.db").build()
でやっています。これで、データベースのファイルが無ければエンティティ・オブジェクトの定義に合わせて自動で作り、そのファイルを使用するデータベースが生成されます。
データ・アクセス・オブジェクトで定義したメソッドの実体はRoomが生成してくれますから、たとえばデータを削除しているところではdatabase.getTimeTableDatailDao().clear()
のようにごく普通に呼び出せばオッケーです。
あと、公共交通オープンデータセンターのデータではBusStopPole
とBusStop
が一つのJsonObjectになって渡ってきますので、それを分割するために少し面倒なことをしています。database.getBusStopDao().getByName()
でBusStop
を取得してみて、もし存在しなければ(null
が返ってきたなら)、BusStop
のインスタンスを生成してデータベースに追加しています。エルビス演算子はとても便利! あと、also
スコープ関数も。
実行してみます。ログを調べてみると……。
はい。成功です。Room簡単ですな。
データベースをダウンロードして、正しく動いたのか確認してみる
……ごめんなさい。ログに「Finish.」という文字が出たからRoomを正しく使えたと思えってのは、あまりに乱暴でしたね。もう少しきちんと確認しましょう。
スマートフォン上に生成されたデータベースのファイルは、AndroidStudioを使用してダウンロードする事ができます。Android Studioで[Device File Explorer]を開いて、data/data/com.tail_island.jetbus/databases」を開くと、その下に「jetbus.db」というファイルがあります。これ、SQLite3のファイルなんですよ。このファイルを右クリックして、[Save As...]メニューでローカルに保存します。
ダウンロードしたデータベースのファイルの中を見て、正しく動作したのかを確認してみましょう。sqlite3コマンドでデータベースを開いてSELECT * FROM BusStop LIMIT 10;
を実行して、はい、たしかにバス停が保存されています。この章の前で書いた、出発バス停名と到着バス停名からRoute
を取得するSQLも実行してみます。うん、上りか下りなのかの判別まで含めてうまく行っています。
やっぱり、Room簡単ですな。
Dagger
このあたりで少し冷静になってこれまでに作成したコードを見直してみると、なんだか、SplashFragment
があまりに汚い……。ちょっとWebサービス呼び出してみようかなってたびに、前準備として以下のコード書くなんてやってられないですよね?
val webService = Retrofit.Builder().apply {
("https://api.odpt.org")
baseUrl(GsonConverterFactory.create())
addConverterFactory}.build().create(WebService::class.java)
でもまぁ、この問題はWebService
を作る関数を作成すれば解決できそうな気がします。こんな感じ。
fun createWebService(): WebService {
return Retrofit.Builder().apply {
("https://api.odpt.org")
baseUrl(GsonConverterFactory.create())
addConverterFactory}.build().create(WebService::class.java)
}
().getWebServiceResultBody { ... } createWebService
ただしAppDatabase
、テメーはダメだ! AppDatabase
を生成するコードを、よく見てみましょう。
val database = Room.databaseBuilder(requireContext(), AppDatabase::class.java, "jetbus.db").build()
このコードのdatabaseBuilder()
メソッドの引数のrequireContext()
がダメ。だって、requireContext()
はFragment
のメソッドなんですよ。だから、createAppDatabase()
関数を作る場合は、その引数にContext
を追加してあげなければなりません。そうすると、この関数はContext
を提供可能なFragment
やActivity
等からしか呼び出せなくなっちゃう。だからAppDatabase
を使う処理はFragment
やActivity
に書くしかなくて、その結果としてFragment
やActivity
に処理のすべてが埋め込まれた汚いコードが出来上がっちゃう……。
うん、Dependency InjectionツールのDaggerを使ってどうにかしましょう!
Dependency Injection
Dependency
Injection(依存性の注入。DIと省略される)はちょっと分かりづらい技術なので、具体的なコードで説明しましょう。ComponentA
がComponentB
に依存している(使用している)とします。
class ComponentA {
private componentB = new ComponentB()
fun doSomething() {
...
}
}
このコードのComponentB
が、実はインターネットにあるサービスを呼び出すものでとても遅い場合を考えてみてください。そうなると、単体テストがとても遅くなっちゃう。だから……
interface ComponentB { // classからinterfaceにします
...
}
class ComponentA(componentB: ComponentB) {
fun doSomething() {
...
}
}
(MockComponentB()).doSomething() // 単体テストではモックを使用して呼び出します ComponentA
コードをこんな感じに変更して、単体テスト用のComponentB
のモックを用意して、それを使って単体テストします。使用するオブジェクトを実行時に設定することで、オブジェクトを自由に組み合わせられるようにしているわけですな。で、この組み合わせる部分を自動化したり柔軟にしてくれるのが、Daggerを始めとするDependency
Injectionツールなんです。
ただ、個人的には、Dependency
Injectionはあまり好きではありません。だって、インターフェイスを書くのが面倒なんだもん。動的型付けのプログラミング言語(実行時に型チェックされる言語。JavaScriptとか私が大好きなClojureとか)ではinterface
を書かなくてよいから使うし、関数型プログラミング言語(Haskellとか私が愛してやまないClojureとか)なら関数を組み合わせる関数合成を当たり前に使うけど、静的型付けのプログラミング言語(JavaとかKotlinとか。C++は静的型付だけどtemplate
を使えばコード上では動的に型付けできるから別)でのDependency
Injectionはベネフィットよりもコストが大きいと感じちゃう。
Dependency Injectionツールを敢えて誤用する
Dependency Injectionのメリットは、オブジェクト間の結合度が減ること。で、オブジェクト間の結合を変更可能にする技術は、静的型付けのオブジェクト指向プログラミング言語ではインターフェイス。でもインターフェイスを書くのは面倒なので、Dependency Injectionは諦めてオブジェクト間の結合度が増えても我慢する。でも、Dependency InjectionツールであるDaggerは使う。これがここまでの私の主張。……無茶苦茶ですがな。
なんでこんな無茶な主張になるのかというと、私はただ単に属性に値を設定する便利ツールとしてDaggerを使っているためです。Adnroidアプリの場合、前述したRoomのDatabase
のようにContext
を引数にしないと実現できない処理が多数存在します。これを関数の引数として表現すると呼び出せる場所が限られてしまってよくない。属性にしてコンストラクタで設定する方式も、Fragment
やActivity
等では生成をAndroidがやるため手を出せないので実現不能です。でも、Daggerを使えば属性を設定できるんですよ。Context
をDaggerの中で引き回せば、Fragment
やActivity
以外でもContext
を必要とする属性の設定をやりたい放題にできます。
もちろん、インターフェイスを書いていませんから、オブジェクト間の結合度は大きいままです。Dependency Injectionというのは実はデザイン・パターンで、Dependency Injectionツールはそれを実現する手段の一つ。私の意識が高ければツールへの依存性も減らして独自にDependency Injectionパターンを実装するでしょうし、道具が作成された目的を無視して使用するなんてことは絶対にやらないのでしょうけど、残念なことに私は底辺なのでDependency Injectionツールの誤用くらい全然オッケー。苦情は、結合度が異常に高いAndroidのAPIを最初に作った人に言ってください。
ただね、Daggerを使っておけば、本来のDependency Injectionパターンが必要になったときに取り入れやすいと思うんですよ。意識が高い人は、その前準備をしているんだと考えてご容赦ください。
Daggerの組み込み
まずは、Daggerを組み込むためにbuild.gradleを変更してください。
{
dependencies ...
kapt 'androidx.room:room-compiler:2.2.4'
'com.google.dagger:dagger-compiler:2.24' // 追加
kapt
fileTree(dir: 'libs', include: ['*.jar'])
implementation
...
implementation 'androidx.room:room-ktx:2.2.4'
'com.google.dagger:dagger:2.24' // 追加
implementation 'com.squareup.retrofit2:retrofit:2.6.1'
implementation
...
}
アノテーションによるコード生成が必要ですので、kapt
を忘れないように気をつけてください。
Inject
まずは、設定したい属性を作成して、@Inject
アノテーションを追加します。Daggerは属性の型で設定するインスタンスを決定するのですけど、それでは足りない場合(文字列型のconsumerKey
に文字列を設定するとか)は@field:Named()
アノテーションを使用します。具体的にはこんな感じ。
class SplashFragment: Fragment() {
@Inject @field:Named("consumerKey") lateinit var consumerKey: String // 追加
@Inject lateinit var webService: WebService // 追加
@Inject lateinit var database: AppDatabase // 追加
...
override fun onStart() {
super.onStart()
// 不要になるので削除
// val consumerKey = getString(R.string.consumerKey)
// val webService = Retrofit.Builder().apply {
// baseUrl("https://api.odpt.org")
// addConverterFactory(GsonConverterFactory.create())
// }.build().create(WebService::class.java)
// val database = Room.databaseBuilder(requireContext(), AppDatabase::class.java, "jetbus.db").build()
{
thread try {
.d("SplashFragment", "Start.")
Log
...
AppModule
次に、注入するオブジェクトを生成するクラスを生成します。名前はAppModule
にしましょう。@Module
アノテーションを付加したクラスを作成して、@Provides
アノテーションを付加した注入するオブジェクトを生成するメソッドを作成します。具体的にはこんな感じ。
package com.tail_island.jetbus
import android.app.Application
import android.content.Context
import androidx.room.Room
import com.tail_island.jetbus.model.AppDatabase
import com.tail_island.jetbus.model.WebService
import dagger.Module
import dagger.Provides
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
import javax.inject.Named
import javax.inject.Singleton
@Module
class AppModule(private val application: Application) {
@Provides
@Singleton
fun provideContext() = application as Context
@Provides
@Singleton
@Named("consumerKey")
fun provideConsumerKey(context: Context) = context.getString(R.string.consumerKey)
@Provides
@Singleton
fun provideDatabase(context: Context) = Room.databaseBuilder(context, AppDatabase::class.java, "jetbus.db").build()
@Provides
@Singletonfun provideWebService() = Retrofit.Builder().apply {
("https://api.odpt.org")
baseUrl(
client.Builder().apply {
OkHttpClient(180, TimeUnit.SECONDS) // ついでだったので、タイムアウト時間を長めに設定しておきます
connectTimeout(180, TimeUnit.SECONDS)
readTimeout(180, TimeUnit.SECONDS)
writeTimeout}.build()
)
(GsonConverterFactory.create())
addConverterFactory}.build().create(WebService::class.java)
}
上のコードで使用している@Singleton
は、注入するオブジェクトのインスタンスを1つだけにしたい場合に付加します。今回はたまたま全部についていますけど、異なるインスタンスを使用したい場合は外してください。@Named
アノテーションは、何を提供すればよいのかが型だけでは判別できない場合向け。@Inject
のときの@field:Named
の対になっているわけですな。
で、上のコードで面白いのは、たとえばprovideConsumerKey()
メソッドの引数のcontext: Context
です。このコードだけを見るとなんだよDagger使っても結局Context
を引数にしなければならないのかよと感じるんですけど、Daggerはすぐ上のprovideContext()
メソッドがContext
を提供してくれることを知っています。だから、consumerKey
を提供するためにprovideConsumerKey()
メソッドを実行するときには、Daggerが自動的にprovideContext()
を実行して引数を準備してくれるんです。というわけで、もうこれでContext
をどうやって取得しよう問題はクリア!
まぁ、AppModule
生成の引数にApplication
が含まれているので、問題を先送りしただけなんだけどな。
App
というわけで、Application
を引数にしてAppModule
を生成する処理を追加してみましょう。そのために、Application
を継承したApp
クラスを作成します。
package com.tail_island.jetbus
import android.app.Application
class App: Application() {
// DaggerのComponentを取得するためのプロパティ
var component: AppComponent
lateinit private set
override fun onCreate() {
super.onCreate()
// 先程作成したAppModuleを使用して、DaggerのComponentを作成します
= DaggerAppComponent.builder().apply {
component (AppModule(this@App))
appModule}.build()
}
}
そのうえで、作成したApp
が使用されるようにAndroidManifest.xmlを修正します。
<?xml version="1.0" encoding="utf-8"?>
manifest
< xmlns:android="http://schemas.android.com/apk/res/android"
package="com.tail_island.jetbus">
uses-permission android:name="android.permission.INTERNET" />
<
application
< android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:name=".App" <!-- 追加 -->
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
activity android:name=".MainActivity" android:theme="@style/AppTheme.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<
category android:name="android.intent.category.LAUNCHER" />
<intent-filter>
</activity>
</
application>
</
manifest> </
修正は、android:name=".App"
属性の追加だけ。これで、標準のApplication
ではなく、先程作成したApp
が使用されるようになります。
AppComponent
でも、先程のApp
のコード中にでてきたDaggerのComponent
っていったい何なんでしょ? このDaggerのComponent
は、Module
から取得したオブジェクトのインスタンスを注入するオブジェクトで、@Component
アノテーションを付加したinterface
を定義するだけで自動生成されます。このinreface
を定義しましょう。
package com.tail_island.jetbus
import dagger.Component
import javax.inject.Singleton
@Singleton
@Component(modules = [AppModule::class])
interface AppComponent {
fun inject(splashFragment: SplashFragment)
}
AppComponent
という名前でinterface
を作ると、DaggerがDaggerAppComponent
を自動生成してくれます。先程のApp
の中でDaggerのドキュメントの中に存在しないDaggerAppComponent
を使えていたのは、自動生成されるからなんですね。先程のApp
のコードを書いているときにDaggerAppComponent
がないというエラーがでて不安になった方、ごめんなさい。interface AppCompoent
を作成して一度ビルドすれば、DaggerAppComponent
がないというエラーは解消されます。
あと、今回はApplication
経由でDaggerのComponent
を取得するので(その結果として1つだけになるから)なくてもよいのですけど、DaggerのComponent
は1つであって欲しいので念の為に@Singleton
アノテーションを付加しておきます。宣言するメソッドは、依存性を注入する対象を引数にしたメソッドだけ。これで、Daggerを使うための準備は完了です。面倒な作業ですけど、機械作業なので難しくはないから我慢してください。
依存性を注入する
準備が完了したので、SplashFragment
に依存性を注入しましょう。SplashFragment.ktを開いて、以下の修正をします。
class SplashFragment: Fragment() {
...
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
// Daggerを使用して、依存性を注入します
(requireActivity().application as App).component.inject(this) // 追加
return FragmentSplashBinding.inflate(inflater, container, false).apply {
.setOnClickListener {
bookmarksButton().navigate(SplashFragmentDirections.splashFragmentToBookmarksFragment())
findNavController}
}.root
}
requireActivity()
で取得したActivity
を経由してApplication
を取得して、そこからDaggerのComponent
を取得して自分自身を引数にinject()
します……って、これ、結局Context
の代わりにApplication
が必要になっただけじゃん! Daggerで楽するために単純作業に耐えてきたのに、ベネフィットは複数の属性を1行で設定できるようになっただけ?
こんなの絶対おかしいよ……とどこぞの魔法少女のように絶望したかもしれませんけど、安心してください、幸いなことにDaggerには救いがあります。どう救われるのかは、次に説明するLifecycleの中で!
Lifecycle
と、かなり強引な引きで始めてみたLifecycleなのですけど、ごめんなさい、やたらと書くことが多いので、Daggerの世界が救われるのはこの章の最後になります。
考えてみれば、Androidアプリの開発が大変だった一番大きな理由は、本稿の最初の方で述べたライフサイクルの管理が大変なためなんです。やたらと複雑なActivity
のライフサイクルから、もうとにかく逃げたい。そのために状態をViewModelに分離したというわけ。なので、ViewModelとはなんぞやというアーキテクチャーの話をしなければなりません。あと、Activity
やFragment
の状態の変化に合わせて正しく動作するには、データの変更を監視する方式が定まっていないとダメ。そうでなければ、状態の変化に対応するための制御を入れられないですもんね。だから、その手段であるLiveDataについての説明も必要です。で、LiveDataは前述したRoomといい感じに協調して動作するように作られているのでデータベースの話まで戻らなくちゃならないという……。Roomで不足しているように感じた機能が、LiveDataと組み合わせると不足していたのではなくて無いことこそが正しいのだと分かるのでとても面白いんですけどね。
というわけで、覚悟してください。この章はとても長いですよ……。
MVVM(Model-View-ViewModel)アーキテクチャとは?
Android Jetpackでは、MVVMアーキテクチャを使うことが推奨されています。このMVVMアーキテクチャを理解するには、その前段であるMVC(Model-View-Controller)アーキテクチャの知識があると楽。しかも、Android JetpackのMVVMアーキテクチャは、普通のMVVMアーキテクチャとは少し違っていたりします。
というわけで、MVCアーキテクチャ、MVVMアーキテクチャ、Android JetpackのMVVMアーキテクチャの順に説明させてください。
MVC(Model-View-Controller)アーキテクチャ
大昔にXerox社のパロアルト研究所が開発したSmalltalkという伝説的なプログラミング言語があって(今もあって熱烈なファンがいるけど)、このSmalltalkはパロアルト研究所が開発したAltoというコンピューターのOS(Operating System)でした。プログラミング言語がOSなんておかしいと思ってしまった人は、電源を入れるとMSX-BASICのインタープリターが起動するMSXでコンピューターを始めた私に謝ってください。特定のプログラミング言語を動かすためのコンピューターって、大昔にはけっこうあったんですよ。
で、このAltoってのは当時にしてはものすごく先進的なコンピューターで、マウスがついていてGUI(Graphical User Interface)を持っていました。もちろん世界初です。この世界初のGUIをどうにかしていい感じに開発できないかなぁと考えて作られたのが、MVCアーキテクチャなんです。
Controllerは入力機器(マウスとかキーボードとか)からの入力を監視し、マウス・クリックとかキーボードのAが押されたとかのイベントをもとに、Modelというデータとデータ操作の手続きを管理するオブジェクトにメッセージを送り(メソッドを呼び出し)ます。Modelのデータが変更されたことはViewに通知され(Observerパターン)、通知を受け取ったViewは自分自身を置き換えて出力する。以上がMVCアーキテクチャなんです。
MVCアーキテクチャは依存が片方向になるのでとても良いのですけど、GUIが複雑になってくると、GUIの入力と出力を分けるのは無理がある(たとえば、ボタンがタップされたときのアニメーションをControllerとModel経由でやるのは無駄が多すぎるでしょ?)ということになりました。だから、Androidアプリ開発ではControllerは分割されていません。
ちなみに、このViewとControllerが一体化した場合のアーキテクチャがDocument-Viewアーキテクチャだったりします。代表例はMFC(Microsoft Foundation Classes)、懐かしいなぁ。
ただ、世の中には用語を適当に使う人たちがいるので、Webアプリケーションの開発でもMVCアーキテクチャという言葉が使われるようになってしまったんですよ……。
WebアプリケーションのMVCアーキテクチャはSmalltalkのMVCとは無関係で、一般的にModelはO/Rマッピング・ツールでマッピングされたレコードを表現するオブジェクトです(もちろん普通のクラスが含まれていても構いません)。Controllerはリクエストを受け取ってModelを更新/取得してViewが必要とするデータを用意し、ViewはControllerから渡されたデータをHTMLに変換します。これがWebアプリケーションの場合のMVCアーキテクチャなんですけど、勝手に名乗っているだけな上に無関係なので、今回はこのMVCアーキテクチャは忘れてください。
MVVM(Model-View-ViewModel)アーキテクチャ
MVVMアーキテクチャというのは、.NET Framework 3.0のWPF(Windows Presentation Foundation)やSilverlightのために考案されたアーキテクチャです。理屈の上ではModelとViewだけで良さそうなのですけど、WPFやSilverlightではXAMLというViewをXMLで定義する言語を持っていて、この言語はとても高機能で素晴らしいのですけど、XAMLだけでViewを作成した場合は、Model側にViewのためのコードを書く必要がありました。これではModelとViewに分割した意味がありませんから、間にViewModelを挟んで、Model-View-ViewModelとなりました。MVVMアーキテクチャでは、メソッドの呼び出しは片方向(View→ViewModel→Model)だけで、逆の方向は変更の通知で情報を伝えることになっています(ModelとViewModelの間は返り値でもOK)。
さて、ViewをレイアウトのXMLとソース・コードの合わせ技で実装するAndroidアプリ開発ではViewModelは不要に感じられるのですけど、AndroidのViewであるActivity
やFragment
には状態を持てないという別の制約がありました。このViewの状態を管理するために、ViewModelを使用するというわけ。必要に迫られて仕方なく、という感じのアーキテクチャなんですな。
Android JetpackでのModel-View-ViewModelアーキテクチャ
Android Jetpackでは、もう一つ層を追加することを提案しています。それがRepositoryです。
Androidが動作するスマートフォンやタブレット、ウェアラブル・デバイスは、インターネットとの親和性が高いデバイスです。だから、我々が作成するアプリもインターネットと頻繁に通信する可能性が高い。しかもローカルのファイルやデータベースだって使用するわけで、だから、モデルはWebサービスに基づく場合とデータベースやファイルに基づく場合がありえます。これらがバラバラのままだと管理が大変になってしまうので、Repositoryという層を追加して一本化してViewModelはRepositoryだけに依存しましょうという感じ。
で、本来のMVVMのModelはビジネス・ロジックを含む分厚い層で、Android Jetpackのドキュメントでも図だけはそんな感じに書いてあるのですけど、実際としては、ただのRoomによるDAO(Data Access Object)とEntity、Retrofit2によるWebサービスになります(たぶん)。だって、ModelというのはReposirotyでラップできるようなレベルなんですしね。後述するLiveDataとRoomを組み合わせれば、データベースの変更がViewModelに通知されて楽チンだし。結果としてViewModelは少し大きくなるけど、Android JetpackならViewModel上にロジックを書くのがやたらと楽だしね。
MVVMアーキテクチャ原理主義派の方々はこんなやり方は認めないのでしょうけど、楽にコードを書けるのだから私的には全然オッケー。
LiveData
というわけで長いMVVMアーキテクチャの説明が終わったのでさっそくViewModelを作る……前に、MVVMアーキテクチャではViewModelからViewへの情報の伝達は通知で実現されることになっていたことを思い出してください。
しかも、考えたくもないしLiveDataを使えば考える必要はほとんどなくなるのですけど、Activity
やFragment
は複雑なライフサイクルを持っているので、単純にObserverパターンを使うと通知が来たときにはすでにActivity
やFragment
が破棄されていて処理を実行したら即クラッシュなんて可能性もあるんですよ。
これらの課題を、LiveDataでサクサク解決しちゃいましょう。
MutableLiveData
と思ったのですけど、さて、困りました。LiveDataのオブジェクトはViewModelのプロパティとするのがセオリーなのですけれど、まだViewModelの作り方を説明できていません……。とりあえずは、Fragment
から参照できてActivity
やFragment
よりも生存期間が長いApp
に置くことにしましょう。出発バス停の名前を表現するMutableLiveData
を作成します。
package com.tail_island.jetbus
import android.app.Application
import androidx.lifecycle.MutableLiveData
class App: Application() {
var component: AppComponent
lateinit private set
val departureBusStopName = MutableLiveData<String>()
...
MutableLiveData
が作成できましたので、監視してみましょう。バス接近情報を表示するBusApproachesFragment
では確実に出発バス停の情報が必要になるでしょうから、BusApproachesFragment
に配置してみます。まずはfragment_bus_approaches.xmlの変更。
<?xml version="1.0" encoding="utf-8"?>
layout
< xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
tools:context=".DepartureBusStopFragment">
androidx.constraintlayout.widget.ConstraintLayout
< android:layout_width="match_parent"
android:layout_height="match_parent">
TextView
< android:id="@+id/departureBusStopNameTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"/>
androidx.constraintlayout.widget.ConstraintLayout>
</
layout> </
続いて、BusApproachesFragment.ktの変更です。
package com.tail_island.jetbus
import android.os.Bundle
import android.util.Log
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.Observer
import com.tail_island.jetbus.databinding.FragmentBusApproachesBinding
import kotlin.concurrent.thread
class BusApproachesFragment: Fragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return FragmentBusApproachesBinding.inflate(inflater, container, false).apply {
// LiveDataを監視します
(requireActivity().application as App).departureBusStopName.observe(viewLifecycleOwner, Observer { departureBusStopNameValue ->
.d("BusApproachesFragment", "departureBusStopName.observe()")
Log.text = departureBusStopNameValue
departureBusStopNameTextView})
// テスト用に、別スレッドでLiveDataに値を設定します。
{
thread .sleep(5000)
Thread
(requireActivity().application as App).departureBusStopName.postValue("日本ユニシス本社前")
.d("BusApproachesFragment", "MutableLiveData.postValue()")
Log}
}.root
}
}
observe()
の引数のObserver { ... }
は、抽象メソッド一つだったらラムダ式から変換してやる(SAM変換)という機能を使用しています。ラムダ式でいいんだったら前に付いているObserver
は何なんだとか、どうして(...)
の内側に入っているんだこれまでの書き方と違うじゃないかという疑問は、observe()
にはいくつもバージョンがあるのでそのどれなのかを指定しなければならないから。普段と書き方が違うので面倒ですけど、いつもどおりの書き方をするとコンパイル・エラーになるので発見も書き換えも容易だからまぁいいかな。
で、これで作業は終了です。MutableLiveData
に値を設定するには、メイン・スレッドからの場合はvalue
プロパティ、他のスレッドからの場合はpostValue()
メソッドを使用します。スレッドを作成して5秒経ったら、私の両親が「お前をまだクビにしてないなんて度量が大きい会社だな」と評価した会社の前にあるバス停が設定されて、通知が飛んで、画面に表示されます。
で、ここで試していただきたいのですけど、バス接近情報を表示する画面に遷移したら、5秒経つ前にホーム画面を表示してアプリをバックグラウンドに移動させてみてください。バックグラウンドに移ったので画面を更新する必要はなくて、だからObserver
の呼び出しは無駄です。LiveDataはこのことを知っていて、しかも、Fragment
がどのような状態にあるのかをviewLifecycleOwner
を経由して知ることができるので、変更を通知しなくなるんです。その証拠に、ほら、logcatを見てください「departureBusStopName.observe()」が表示されていないでしょ? で、アプリをフォアグランドに戻すと、すぐにlogcatに「departureBusStopName.observe()」が表示されて、私の会社の前にあるバス停の名前が画面に表示される。
というわけで、ほら、LiveDataのおかげでActivity
やFragment
のライフサイクルがどうなっているのかを考えながらプログラミングする手間はほぼなくなりました!
LiveDataとRoomを組み合わせる
まだまだLiveDataはこんなもんではありません。RoomとLiveDataを組みわせるととてもすごいことができるんです。
以前の章でRoomを使ったときのことを思い出してみましょう。Roomはメイン・スレッドからは呼び出せなくて面倒だった記憶が蘇ってきました(SplashFragment.ktを参照してください)。
この問題、LiveDataを使うととても簡単に解消できるんですよ。出発バス停と路線がつながっている到着バス停の一覧を取得する処理を考えてみましょう。まずは、BusStopDao
に到着バス停を取得するメソッドを追加してみます。
package com.tail_island.jetbus.model
import androidx.lifecycle.LiveData
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
@Dao
interface BusStopDao {
...
@Query(
"""
SELECT DISTINCT ArrivalBusStop.*
FROM BusStop AS ArrivalBusStop
INNER JOIN BusStopPole AS ArrivalBusStopPole ON ArrivalBusStopPole.busStopName = ArrivalBusStop.name
INNER JOIN RouteBusStopPole AS ArrivalRouteBusStopPole ON ArrivalRouteBusStopPole.busStopPoleId = ArrivalBusStopPole.id
INNER JOIN Route ON Route.id = ArrivalRouteBusStopPole.routeId
INNER JOIN RouteBusStopPole AS DepartureRouteBusStopPole ON DepartureRouteBusStopPole.routeId = Route.id
INNER JOIN BusStopPole AS DepartureBusStopPole ON DepartureBusStopPole.id = DepartureRouteBusStopPole.busStopPoleId
INNER JOIN BusStop AS DepartureBusStop ON DepartureBusStop.name = DepartureBusStopPole.busStopName
WHERE DepartureBusStop.name = :departureBusStopName AND ArrivalBusStop.name <> :departureBusStopName
ORDER BY ArrivalBusStop.phoneticName
"""
)
fun getObservablesByDepartureBusStopName(departureBusStopName: String): LiveData<List<BusStop>>
}
SQLが複雑に見えますけど、BusStop(到着)→BusStopPole(到着)→RouteBusStopPole(到着)→Route→RouteBusStopPole(出発)→BusStopPole(出発)→BusStop(出発)とINNER JOIN
で辿っているだけ。Roomの解説の章でダウンロードしたSQLite3のデータベース・ファイルを使って実際に試しながらやれば、そんなに難しくないはず。
で、このコードの重要なところは、getObservablesByDepartureBusStopName()
メソッドの返り値の「型」です。普通にRoomを使う場合の返り値の型はList<BusStop>
になるのですけど、上のコードではLiveData<List<BusStop>>
になっています。この小さな変更だけで(メソッド名の命名規約も変えていますけど、それは分かりやすくしただけなので無関係)、LiveData対応のデータ・アクセスのメソッドが生成されるんです。
次の作業は、とりあえずの物置場としているApp
へのRoomで作成するLiveData型のプロパティ追加なのですけど、そのためにはAppDatabase
が必要なので、まずはAppCompoenent
にfun inject(app: App)
を追加します。そのうえで、App
を以下に修正してください。
package com.tail_island.jetbus
import android.app.Application
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations
import com.tail_island.jetbus.model.AppDatabase
import com.tail_island.jetbus.model.BusStop
import javax.inject.Inject
class App: Application() {
var component: AppComponent
lateinit private set
@Inject lateinit var database: AppDatabase
val departureBusStopName = MutableLiveData<String>()
val arrivalBusStops: LiveData<List<BusStop>> by lazy { database.getBusStopDao().getObservablesByDepartureBusStopName("日本ユニシス本社前") }
override fun onCreate() {
super.onCreate()
= DaggerAppComponent.builder().apply {
component (AppModule(this@App))
appModule}.build()
.inject(this)
component}
}
arrivalBusStops
プロパティで使っているby lazy
は、必要になるまでプロパティの初期化を遅らせる機能で、移譲プロパティと呼ばれる機能の一つです。database
が注入される前にdatabase.getBusStopDao()
するわけにはいかないですもんね。
最後は、BusApproachesFragment
の修正です。MutableLiveData
の場合と同様に、observe()
メソッドを呼び出して変更に合わせて画面を変更するようにしておきます。
package com.tail_island.jetbus
import android.os.Bundle
import android.util.Log
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.Observer
import com.tail_island.jetbus.databinding.FragmentBusApproachesBinding
import kotlin.concurrent.thread
class BusApproachesFragment: Fragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return FragmentBusApproachesBinding.inflate(inflater, container, false).apply {
(requireActivity().application as App).departureBusStopName.observe(viewLifecycleOwner, Observer { departureBusStopNameValue ->
.d("BusApproachesFragment", "departureBusStopName.observe()")
Log.text = departureBusStopNameValue
departureBusStopNameTextView})
(requireActivity().application as App).arrivalBusStops.observe(viewLifecycleOwner, Observer { arrivalBusStopsValue ->
.d("BusApproachesFragment", "arrivalBusStops.observe(), ${arrivalBusStopsValue.size}")
Log.text = arrivalBusStopsValue.map { it.name }.joinToString("\n")
arrivalBusStopNamesTextView})
{
thread .sleep(5000)
Thread
(requireActivity().application as App).departureBusStopName.postValue("日本ユニシス本社前")
.d("BusApproachesFragment", "MutableLiveData.postValue()")
Log}
}.root
}
}
Kotlinは関数型プログラミングのテクニックが使えてとても便利ですな。map
で名前だけのリストに変換して、joinToString()
で一つの文字列にまとめています。
プログラムが完成したので、早速実行してみます。手早くバス接近情報の画面まで遷移してlogcatを見てみると、何度も何度も「arrivalBusStops.observe()」と表示されて、かなり時間が経ってから、少しづつ到着バス停の候補が画面に表示されていきます。え? どうしてこうなった?
あの、実はこれこそが、LiveDataとRoomを組み合わせるとできるようになるとてもすごいことなんです。LiveDataとRoomを組み合わせた場合は、データベースに変更があると通知がやってくるんですよ!
前にやった作業なのですっかり忘れていましたけど、我々はSplashFragment
の中でWebサービスを呼び出して、取得したデータでデータベースを書き換える処理を実装していました。その処理はLifecycleを使わずにスレッドとして実装しましたから、SplashFragmentが終了しても動き続けます。なので、手早くバス接近情報の画面まで移動した場合は、まだデータベースの更新処理が動いているんですよ。で、データが更新されるたびに通知が来るので、何度も何度も「arrivalBusStops.observe()」と表示され、少しづつ到着バス停の候補が画面に表示されるという動きになったわけですな。
というわけで、LiveDataとRoomを組み合わせれば、MVVMのModelからViewModelへの変更の通知も実現できるんです! 素晴らしい!
Transformations
でもちょっと待って。先程のコードでgetObservablesByDepartureBusStopName()
を呼び出すときの引数をソース・コードに直打ちしていなかった? それでは実用に耐えないのでは?
はい。おっしゃるとおり。departureBusStopName
が変更されたらその値でgetObservablesByDepartureBusStopName()
が呼ばれるようになっていないとなりません。もちろん、Android
Jetpackはそのための機能を提供していて、それがTransformations
です。さっそくTransformations
を使ってみましょう。
App
を以下のように修正します。
package com.tail_island.jetbus
import android.app.Application
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations
import com.tail_island.jetbus.model.AppDatabase
import javax.inject.Inject
class App: Application() {
var component: AppComponent
lateinit private set
@Inject lateinit var database: AppDatabase
val departureBusStopName = MutableLiveData<String>()
// depatureBusStopNameが変更になったら、arrivalBusStopsを設定します
val arrivalBusStops = Transformations.switchMap(departureBusStopName) { arrivalBusStopNameValue ->
.getBusStopDao().getObservablesByDepartureBusStopName(arrivalBusStopNameValue)
database}
...
}
Transformations
のswitchMap()
メソッドは、1つ目の引数で指定したLiveData
が変更になったら、2つ目の引数のラムダ式の結果がセットされるLiveData
を返します。なお、今回はラムダ式がLiveData
を返すのでswitchMap()
を使用しましたが、Transformations
には、LiveData
ではない返り値を使う場合向けのmap()
というメソッドもありますのでいい感じに使い分けてください。
で、このTransformations
を使うと、Roomにテーブルとテーブルの間を辿る機能がないことが気にならなくなります。たとえば注文と注文明細を表示するような場合は、注文と注文明細をTransformations
でつないで、注文と関係づけられた注文明細を取得するデータ・アクセス・オブジェクトのメソッドを書けばいいんですから。というわけで、Roomは前に書いたような「こういうのでいいんだよ。こういうので」と表現されるような機能が貧弱なO/Rマッピング・ツールではなくて、不要な機能がついていないとても洗練された最高に出来が良い十分な機能を持つO/Rマッピング・ツールなのですよ。もし「Roomは機能が貧弱で」とのたまうRoomの説明を書いていたときの私みたいな人がいたら、分かってないなぁと鼻で笑ってやってください。
最後。せっかくdepartureBusStopName
が変更になったらarrivalBusStops
が変更されるようになったのですから、BusApproachesFragment
を修正して画面も変更されるようにしましょう。
<?xml version="1.0" encoding="utf-8"?>
layout
< xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
tools:context=".DepartureBusStopFragment">
androidx.constraintlayout.widget.ConstraintLayout
< android:layout_width="match_parent"
android:layout_height="match_parent">
TextView
< android:id="@+id/departureBusStopNameTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"/>
TextView
< android:id="@+id/arrivalBusStopNamesTextView"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="16dp"
android:layout_marginBottom="16dp"
app:layout_constraintTop_toBottomOf="@id/departureBusStopNameTextView"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="@id/departureBusStopNameTextView"
app:layout_constraintEnd_toEndOf="@id/departureBusStopNameTextView" />
androidx.constraintlayout.widget.ConstraintLayout>
</
layout> </
package com.tail_island.jetbus
import android.os.Bundle
import android.util.Log
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.Observer
import com.tail_island.jetbus.databinding.FragmentBusApproachesBinding
import kotlin.concurrent.thread
class BusApproachesFragment: Fragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return FragmentBusApproachesBinding.inflate(inflater, container, false).apply {
(requireActivity().application as App).departureBusStopName.observe(viewLifecycleOwner, Observer { departureBusStopNameValue ->
.d("BusApproachesFragment", "departureBusStopName.observe()")
Log.text = departureBusStopNameValue
departureBusStopNameTextView})
(requireActivity().application as App).arrivalBusStops.observe(viewLifecycleOwner, Observer { arrivalBusStopsValue ->
.d("BusApproachesFragment", "arrivalBusStops.observe(), ${arrivalBusStopsValue.size}")
Log.text = arrivalBusStopsValue.map { it.name }.joinToString("\n")
arrivalBusStopNamesTextView})
{
thread .sleep(5000)
Thread
(requireActivity().application as App).departureBusStopName.postValue("日本ユニシス本社前")
.d("BusApproachesFragment", "MutableLiveData.postValue()")
Log
.sleep(5000)
Thread
(requireActivity().application as App).departureBusStopName.postValue("深川第八中学校前")
}
}.root
}
}
5秒たったらdepartureBusStopName
を「日本ユニシス本社前」に設定して、また5秒たったらdepartureBusStopName
を私のアパートの最寄りバス停である「深川第八中学校前」に変更するわけですな。SplashFragment
でlogcatに「Finish.」が表示されるまで待って(そうしないとデータベースの更新とdepartureBusStopName
の更新が合わさって変な動きになっちゃう)、バス接近情報の画面に遷移します。
5秒後に「日本ユニシス本社前」とつながっているバス停の一覧が表示されて、さらに5秒たったら画面が「深川第八中学校前」とそこにつながっているバス停の一覧に変更されたでしょ? はい、これで終わり。Transformations
は実に便利ですな。
MediatorLiveData
でも、あれ、ユーザーIDとパスワードが入力されたらログインして認証済みかどうかのLiveData
を更新するような場合、複数の値を監視しなければならない場合にはどうするのでしょうか? 今回のアプリだと、出発バス停と到着バス停から路線を取得するような場合です。Transformations
は1つのLiveData
しか監視できないですよね? それでは要求を満たせません……。
そんな場合は、MediatorLiveData
を使いましょう。今回は出発バス停の名称と到着バス停の名称から路線を取得する処理を作りたいので、まずは路線を取得するメソッドをRouteDao
に追加します。SQLは前に作ったので、これはとても簡単。
package com.tail_island.jetbus.model
import androidx.lifecycle.LiveData
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
@Dao
interface RouteDao {
...
@Query(
"""
SELECT Route.*
FROM BusStopPole AS ArrivalBusStopPole
INNER JOIN RouteBusStopPole AS ArrivalRouteBusStopPole ON ArrivalRouteBusStopPole.busStopPoleId = ArrivalBusStopPole.id
INNER JOIN Route ON Route.id = ArrivalRouteBusStopPole.routeId
INNER JOIN RouteBusStopPole As DepartureRouteBusStopPole ON DepartureRouteBusStopPole.routeId = Route.id
INNER JOIN BusStopPole AS DepartureBusStopPole ON DepartureBusStopPole.id = DepartureRouteBusStopPole.busStopPoleId
WHERE ArrivalRouteBusStopPole.'order' > DepartureRouteBusStopPole.'order' AND DepartureBusStopPole.busStopName = :departureBusStopName AND ArrivalBusStopPole.busStopName = :arrivalBusStopName
"""
)
fun getObservablesByDepartureBusStopNameAndArrivalBusStopName(departureBusStopName: String, arrivalBusStopName: String): LiveData<List<Route>>
}
そのうえで、App
のコードを以下に変更します。
package com.tail_island.jetbus
import android.app.Application
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations
import com.tail_island.jetbus.model.AppDatabase
import com.tail_island.jetbus.model.Route
import javax.inject.Inject
class App: Application() {
var component: AppComponent
lateinit private set
@Inject lateinit var database: AppDatabase
val departureBusStopName = MutableLiveData<String>()
val arrivalBusStops = Transformations.switchMap(departureBusStopName) { arrivalBusStopNameValue ->
.getBusStopDao().getObservablesByDepartureBusStopName(arrivalBusStopNameValue)
database}
val arrivalBusStopName = Transformations.map(arrivalBusStops) { arrivalBusStopsValue ->
[0.until(arrivalBusStopsValue.size).random()].name
arrivalBusStopsValue}
// departureBusStopsやarrivalBusStopsが変更になったら、routesを設定します
val routes = MediatorLiveData<List<Route>>().apply {
var source: LiveData<List<Route>>? = null
fun update() {
val departureBusStopNameValue = departureBusStopName.value ?: return
val arrivalBusStopNameValue = arrivalBusStopName.value ?: return
?.let {
source(it)
removeSource}
= database.getRouteDao().getObservablesByDepartureBusStopNameAndArrivalBusStopName(departureBusStopNameValue, arrivalBusStopNameValue).also {
source (it) { sourceValue ->
addSource= sourceValue
value }
}
}
(departureBusStopName) { update() }
addSource(arrivalBusStopName) { update() }
addSource}
...
うぉ、面倒臭そう……なので、少し解説を。
departureBusStopName
(出発バス停名称)が変更になったら、arrivalBusStops
(到着バス停のリスト)を設定するところまでは前にやりました。今回はarrivalBusStopName
(到着バス停名称)も必要なので、Transformations
のmap()
メソッドを使用してarrivalBusStops
が変更になったらその中の一つをランダムに選ぶようにしました。
で、routes
(路線のリスト)が問題のMediatorLiveData
です。MediatorLiveData
では、監視対象のLiveData
をaddSource()
メソッドで追加できます。addSource()
メソッドの2つ目の引数はラムダ式で、監視対象が変更になった場合に実行する処理です。今回は、その直前に定義しているupdate()
関数を呼び出しています。で、MediatorLiveData
の値を変更したい場合は、value
プロパティに値を設定すればよくて、監視対象のLiveData
の値はLiveData
のvalue
プロパティで取得できるのですけど、残念なことにRouteDao
に定義したのはLiveData<List<Route>>
を返すメソッドですから、そのままではvalue
に設定できません。
だから、getObservablesByDepartureBusStopNameAndArrivalBusStopName()
の返り値を格納するためのsource
変数を作成して、設定と同時にalso
でaddSource()
してvalue
に値を設定するようにしています。あと、source
が複数にならないように、source?.let
で過去に設定したsource
がある場合はremoveSource()
しています。
あと、update()
の中でエルビス演算子(:?
)を使用しているのは、LiveData
のテンプレート引数がnull
を許容しない型であったとしても、値がまだ設定されていない場合はvalue
プロパティの値がnull
になってしまうためです。出発バス停名称と到着バス停名称の両方が揃った場合に初めて処理を実施するというわけですな。
……とまぁ、異常に複雑で、これを少しでも簡単にするには「RouteDao
にLiveData
を返さないメソッドを定義する」という手があるのですけど、場合によって作り方を変更するのは混乱の元になるので避けたい。まぁ、複雑だとはいっても毎回同じなのでそのうち見慣れるでしょうから、ごめんなさい、このままで。こーゆーもんなんだと無理に飲み込んでください。誰かライブラリ化してくれないかなぁ。KotlinにLISPやRustのマクロがあれば、こんな場合にも簡単にライブラリ化できるのに……。
ともあれ、面倒くさいのはここまでで終わり。あとは、これまでと同じです。レイアウトのXMLをいい感じに修正して……
<?xml version="1.0" encoding="utf-8"?>
layout
< xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
tools:context=".DepartureBusStopFragment">
data>
<variable name="app" type="com.tail_island.jetbus.App" />
<data>
</
androidx.constraintlayout.widget.ConstraintLayout
< android:layout_width="match_parent"
android:layout_height="match_parent">
TextView
< android:id="@+id/departureBusStopNameTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
TextView
< android:id="@+id/arrivalBusStopNameTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:layout_constraintTop_toBottomOf="@id/departureBusStopNameTextView"
app:layout_constraintStart_toStartOf="@id/departureBusStopNameTextView"
app:layout_constraintEnd_toEndOf="@id/departureBusStopNameTextView" />
TextView
< android:id="@+id/routeNamesTextView"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="16dp"
android:layout_marginBottom="16dp"
app:layout_constraintTop_toBottomOf="@id/arrivalBusStopNameTextView"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="@id/arrivalBusStopNameTextView"
app:layout_constraintEnd_toEndOf="@id/arrivalBusStopNameTextView" />
androidx.constraintlayout.widget.ConstraintLayout>
</
layout> </
Fragment
側も修正します。
package com.tail_island.jetbus
import android.os.Bundle
import android.util.Log
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.Observer
import com.tail_island.jetbus.databinding.FragmentBusApproachesBinding
import kotlin.concurrent.thread
class BusApproachesFragment: Fragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return FragmentBusApproachesBinding.inflate(inflater, container, false).apply {
(requireActivity().application as App).departureBusStopName.observe(viewLifecycleOwner, Observer { departureBusStopNameValue ->
.d("BusApproachesFragment", "departureBusStopName.observe()")
Log.text = departureBusStopNameValue
departureBusStopNameTextView})
(requireActivity().application as App).arrivalBusStopName.observe(viewLifecycleOwner, Observer { arrivalBusStopNameValue ->
.text = departureBusStopNameValue
arrivalBusStopNameTextView})
(requireActivity().application as App).routes.observe(viewLifecycleOwner, Observer { routesValue ->
.text = routesValue.map { it.name }.joinToString("\n")
routeNamesTextView})
{
thread .sleep(5000)
Thread
(requireActivity().application as App).departureBusStopName.postValue("日本ユニシス本社前")
.d("BusApproachesFragment", "MutableLiveData.postValue()")
Log
.sleep(5000)
Thread
(requireActivity().application as App).departureBusStopName.postValue("深川第八中学校前")
}
}.root
}
}
これで出発バス停の名称と到着バス停の名称とその2つのバス停をつなぐ路線の一覧が表示されるようになりました。あ、このアプリを動かすと路線が複数表示されるのは、路線の出発バス停や到着バス停が異なる場合があるためです。
これでLiveDataの説明は完了です。MediatorLiveData
の使い方が少し面倒でしたけど、全体で見ればとても便利でしょ? 長かった説明が終わってよかった……。
データ・バインディング
まだだ! まだ終わらんよ! だって、Fragment
のコードでobserve()
するのって面倒じゃあないですか? この処理って、データ・バインディングを使うととても簡単になるんです。
さっそくやってみましょう。データ・バインディングは、レイアウトのXMLに記載します。
<?xml version="1.0" encoding="utf-8"?>
layout
< xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
tools:context=".DepartureBusStopFragment">
data>
<variable name="app" type="com.tail_island.jetbus.App" />
<data>
</
androidx.constraintlayout.widget.ConstraintLayout
< android:layout_width="match_parent"
android:layout_height="match_parent">
TextView
< android:id="@+id/departureBusStopNameTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:text='@{app.departureBusStopName}' />
TextView
< android:id="@+id/arrivalBusStopNameTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:layout_constraintTop_toBottomOf="@id/departureBusStopNameTextView"
app:layout_constraintStart_toStartOf="@id/departureBusStopNameTextView"
app:layout_constraintEnd_toEndOf="@id/departureBusStopNameTextView"
android:text='@{app.arrivalBusStopName}' />
TextView
< android:id="@+id/routeNamesTextView"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="16dp"
android:layout_marginBottom="16dp"
app:layout_constraintTop_toBottomOf="@id/arrivalBusStopNameTextView"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="@id/arrivalBusStopNameTextView"
app:layout_constraintEnd_toEndOf="@id/arrivalBusStopNameTextView"
android:text='@{app.routeNames}' />
androidx.constraintlayout.widget.ConstraintLayout>
</
layout> </
<data>
タグの中の<variable>
タグで、このレイアウトで扱うデータを定義します。今回はApp
のプロパティを使用して画面を表示するので、App
にします。
で、android:text
属性の値の@{...}
の部分がバインディング先の指定です。app.departureBusStopName
はLiveData
型だけど文字列型にしなくていいの? と、お考えになった方がいらっしゃるかもしれませんけど、データ・バインディングはLiveData
対応ですのでこれで大丈夫なんです。
ただ、良いことばかり続くわけではないのが世の常です。routeNamesTextView
のandroid:text
プロパティを見てください。map()
やjoinToString()
を使わずに、まだ定義してないApp
のrouteNames
プロパティをバインディングしています。なんでこんなことをしているかというと、データ・バインディングの@{...}
の中ってKotlinのコードを書けないんため。詳しい言語仕様はレイアウトとバインディング式に書いてあるので読んでいただきたいのですけど、たとえばKotlinの`if`は式なのでKotlinのプログラマーは`val
x = if (condition) "OK" else
"BAD"`のように書くのに慣れていますけど、データ・バインディングのときは昔懐かしい三項演算子(condition ? "OK" : "BAD"
)で書かないとダメだったりします。
やってられないので、あっさり諦めてApp
にプロパティを追加しました。App
に追加というとやっちゃいけないことのように感じられますけど、本章の最後までやればViewModelへの追加となります。ViewModelはViewのためのものなのですから、この程度は良いんじゃないかなぁと。ViewModelがViewを呼び出すのはダメですけど、Viewのことを考えながらViewModelを作るのはアリなのですから。もちろん、こんなプロパティは無いほうがいいのですけど、でも、言語仕様がアレなんだもん。
package com.tail_island.jetbus
import android.app.Application
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations
import com.tail_island.jetbus.model.AppDatabase
import com.tail_island.jetbus.model.Route
import javax.inject.Inject
class App: Application() {
...
val routeNames = Transformations.map(routes) { routesValue ->
.map { it.name }.joinToString("\n")
routesValue}
...
}
最後。BusApproachesFragment
を修正します。
package com.tail_island.jetbus
import android.os.Bundle
import android.util.Log
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.tail_island.jetbus.databinding.FragmentBusApproachesBinding
import kotlin.concurrent.thread
class BusApproachesFragment: Fragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return FragmentBusApproachesBinding.inflate(inflater, container, false).apply {
// FragmentBusApproachesBinding.lifecycleOwnerを設定します
= viewLifecycleOwner
lifecycleOwner
// FragmentBusApproachesBinding.appを設定します
= (requireActivity().application as App)
app
// 以下はテスト用なので後で削除します
{
thread .sleep(5000)
Thread
(requireActivity().application as App).departureBusStopName.postValue("日本ユニシス本社前")
.d("BusApproachesFragment", "MutableLiveData.postValue()")
Log
.sleep(5000)
Thread
(requireActivity().application as App).departureBusStopName.postValue("深川第八中学校前")
}
}.root
}
}
不要になったobserve()
メソッドの呼び出しをまるっと削除して、データ・バインディングがLiveData
を監視できるようにlifeCycleOwner
プロパティを設定して、あと、<data>
タグで設定したapp
プロパティを設定しているだけ。thread { ... }
の部分はテスト用のコードなので最後にはなくなりますから、かなりシンプルなコードですよね? こんな単純になっても、今までと同じ動作をしてくれるんですよ。
うん、LiveDataとデータ・バインディングは、コード量を激減させてくれて素晴らしいですな。
Repository
やっとLiveDataが終わりましたから、あとはRepositoryを作るだけでViewModelを作れます。とはいっても、ただ普通にclass
を作るだけで、とある事情(後の章で説明するコルーチンを使わないと書きづらい)でWebサービス側はとりあえず作らないから、やたらと簡単なんですけどね。
package com.tail_island.jetbus.model
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class Repository @Inject constructor(private val database: AppDatabase) {
fun getObservableBusStopsByDepartureBusStopName(departureBusStopName: String) = database.getBusStopDao().getObservablesByDepartureBusStopName(departureBusStopName)
fun getObservableRoutesByDepartureBusStopNameAndArrivalBusStopName(departureBusStopName: String, arrivalBusStopName: String) = database.getRouteDao().getObservablesByDepartureBusStopNameAndArrivalBusStopName(departureBusStopName, arrivalBusStopName)
}
はい、これだけ。AppDatabase
のメソッドをラップしているだけです。@Singleton
アノテーションや@Inject
アノテーションが付いている理由は、もう少し後で。
ViewModel
ついにViewModelです。ここまで長かった……。ViewModelは、ViewModel
を継承して作成します。
package com.tail_island.jetbus.view_model
import android.util.Log
import androidx.lifecycle.*
import com.tail_island.jetbus.model.Repository
import com.tail_island.jetbus.model.Route
class BusApproachesViewModel(private val repository: Repository): ViewModel() {
val departureBusStopName = MutableLiveData<String>()
val arrivalBusStops = Transformations.switchMap(departureBusStopName) { arrivalBusStopNameValue ->
.getObservableBusStopsByDepartureBusStopName(arrivalBusStopNameValue)
repository}
val arrivalBusStopName = Transformations.map(arrivalBusStops) { arrivalBusStopsValue ->
if (arrivalBusStopsValue.isEmpty()) {
@map null
return}
[0.until(arrivalBusStopsValue.size).random()].name
arrivalBusStopsValue}
val routes = MediatorLiveData<List<Route>>().apply {
var source: LiveData<List<Route>>? = null
fun update() {
val departureBusStopNameValue = departureBusStopName.value ?: return
val arrivalBusStopNameValue = arrivalBusStopName.value ?: return
?.let {
source(it)
removeSource}
= repository.getObservableRoutesByDepartureBusStopNameAndArrivalBusStopName(departureBusStopNameValue, arrivalBusStopNameValue).also {
source (it) { sourceValue ->
addSource= sourceValue
value }
}
}
(departureBusStopName) { update() }
addSource(arrivalBusStopName) { update() }
addSource}
val routeNames = Transformations.map(routes) { routesValue ->
.map { it.name }.joinToString("\n")
routesValue}
}
……ここまでもったいぶっておいて何なのですけど、前述したようにViewModel
を継承するようにして、これまでとりあえずAppに書いていたコードを移動させ、AppDatabase
ではなくRepository
のメソッドを呼び出すように修正しただけです。
ViewModel
をDaggerから取得する
では、ViewModelを使ってみましょう。ドキュメントのViewModelの概要を見てみると、なるほど、`ViewModelProviders.of(this)[BusApproachesViewModel::class.java]`でインスタンスを取得できるのね……って、これではインスタンス生成が自動化されていて手が出せないので、Repository
を引数にしてコンストラクタを呼び出すことできないじゃん!
同じ問題はActivity
やFragment
で経験済みで、あのときはDaggerによるDependency
Injectionで解決できました。でも、ViewModel
からはApp
にアクセスできないので、同じ解決策は使えません……。
幸いなことに、Daggerは有名なプロダクトですから、いろいろな人が使い方を調べて解説を書いてくださっています。その一つの「ViewModelをDagger2でDIする」がこの問題を解決してくれます。ありがてぇ。というわけで、ここに書いてある通りにすればすべてオッケー。
解説と順序は違いますが、まずはViewModelKey
アノテーションとAppViewModelProviderFactory
を作成します。新規にUtility.ktを作成して、以下の内容を入力します。
package com.tail_island.jetbus
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import dagger.MapKey
import javax.inject.Inject
import javax.inject.Provider
import kotlin.reflect.KClass
// ViewModel and Dagger.
@Retention(AnnotationRetention.RUNTIME)
@MapKey
class ViewModelKey(val value: KClass<out ViewModel>)
annotation
class AppViewModelProvideFactory @Inject constructor(private val factories: Map<Class<out ViewModel>, @JvmSuppressWildcards Provider<ViewModel>>): ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T: ViewModel> create(modelClass: Class<T>): T {
return factories.entries.find { modelClass.isAssignableFrom(it.key) }!!.value.get() as T
}
}
AppModule
はこんな感じ。
package com.tail_island.jetbus
import android.app.Application
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.room.Room
import com.tail_island.jetbus.model.AppDatabase
import com.tail_island.jetbus.model.Repository
import com.tail_island.jetbus.model.WebService
import com.tail_island.jetbus.view_model.BusApproachesViewModel
import dagger.Module
import dagger.Provides
import dagger.multibindings.IntoMap
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
import javax.inject.Named
import javax.inject.Singleton
@Module
class AppModule(private val application: Application) {
...
@Provides
@Singleton
fun provideDatabase(context: Context) = Room.databaseBuilder(context, AppDatabase::class.java, "jetbus.db").build()
@Provides
@IntoMap(BusApproachesViewModel::class)
@ViewModelKeyfun provideBusApproachesViewModel(repository: Repository) = BusApproachesViewModel(repository) as ViewModel
}
AppComponent
にBusApproachesFragment
に依存性を注入するメソッドを定義します。
package com.tail_island.jetbus
import dagger.Component
import javax.inject.Singleton
@Singleton
@Component(modules = [AppModule::class])
interface AppComponent {
fun inject(splashFragment: SplashFragment)
fun inject(busApproachesFragment: BusApproachesFragment)
}
この章でApp
に追加したコードを全部削除して、で、BusApproachesFragment
を修正します。
package com.tail_island.jetbus
import android.os.Bundle
import android.util.Log
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.viewModels
import com.tail_island.jetbus.databinding.FragmentBusApproachesBinding
import com.tail_island.jetbus.view_model.BusApproachesViewModel
import javax.inject.Inject
import kotlin.concurrent.thread
class BusApproachesFragment: Fragment() {
@Inject lateinit var viewModelProviderFactory: AppViewModelProvideFactory
// Dagger経由でViewModelを取得します
private val viewModel by viewModels<BusApproachesViewModel> { viewModelProviderFactory }
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
(requireActivity().application as App).component.inject(this)
return FragmentBusApproachesBinding.inflate(inflater, container, false).apply {
= viewLifecycleOwner
lifecycleOwner = this@BusApproachesFragment.viewModel
viewModel
{
thread .sleep(5000)
Thread
.departureBusStopName.postValue("日本ユニシス本社前")
viewModel.d("BusApproachesFragment", "MutableLiveData.postValue()")
Log
.sleep(5000)
Thread
.departureBusStopName.postValue("深川第八中学校前")
viewModel}
}.root
}
}
解説とはViewModel
の作成方法が違うじゃんと思われたと思いますが、これ、Android
JetpackのKotlin向け便利機能の一つなんです。
この書き方にしておくと、Fragment
とViewModelの生存期間が同じになります。あと、たとえば認証情報のようなFragment
よりも生存期間が長い(別のFragment
に遷移したらまたIDとパスワード入力するなんてやってられないですよね?)ViewModelを使う場合には、private val authorizationViewModel by activityViewModel<AuthorizationViewModel> { viewModelProviderFactory }
とするだけでViewModel
の生存期間がActivity
と同じになるのでとても便利ですよ。
はい、これで完成! 正しく動くか試してみましょう。処理開始まで5秒待つようにしていますから、かなり長く待たないと動きがなくてイライラするけど……。
うん、動いた……のは良いのだけど、確認のためにAppModule
を開いてソース・コードを眺めていたら、なんかちょっと気持ち悪い。fun provideBusApproachesViewModel(repository: Repository) = BusApproachesViewModel(repository) as ViewModel
のrepository
は、どうやって取得したのでしょうか? だって、Repository
を@Provides
するメソッドは定義していないんですよ?
その答えは「@Inject
アノテーションが付いたコンストラクタを持つクラスは、自動で@Provides
なメソッドを作成してくれるから」です。先程Repository
を作成したときに、@Singleton
アノテーションと@Inject
アノテーションを追加しましたよね。この@Inject
アノテーションが役に立ってくれたんです。
では@Singleton
アノテーションは何なのかというと、@Singleton
をDaggerで生成したい型に付加しておくと、@Provides
メソッドを生成するときに@Singleton
が自動で付くようになるんです。Repository
は何個もいらないですもんね。というわけで、AppModule
の@Singleton
は生成対象の型に移動……させるという手は、provideContext()
やprovideConsumerKey()
では使えません。書き方が統一できないとミスが発生しそうで怖いので、しょうがないから、無駄があるけど念の為に両方に書くという方式にしましょう。AppDatabase
とWebService
にも@Singleton
を付加しておきます。
というわけで、コンストラクタに@Inject
を書くだけで依存性を注入しまくれるんです。注入した依存性を活用できるのはViewModel
経由で呼び出す場合だけですけど、MVVMアーキテクチャを採用してアプリを組むのですから問題にはならないはず。ほら、前章からの宿題だったDaggerをどうやって使うかが決まったでしょ? これ以降は、依存性を注入したいならコンストラクタに@Inject
を書くだけでよくなったんですよ。
まぁ、Activity
やFragment
には、依存性を注入する処理を手で書かなければならないんだけどな。
コルーチン
もろもろ片付いたのでよーしパパ残りのViewModelも作っちゃうぞーと考えたのですけど、最初の画面のSplashFragment
向けのViewModelでいきなり躓いてしまいました。RoomやRetrofit2はメイン・スレッドからは呼び出せないのですけど、スレッドどうしましょ?
ViewModelの中でthread
で別スレッドを生成する……のは前の章で書いておいてアレなのですけど、ダメ、絶対。だって、生成されたスレッドは終了するまで生き残ってしまうんですから。ViewModelを作成してその中でスレッドを生成して、画面遷移してFragment
が終了するとかでViewModelを終了する。この場合でも、スレッドは処理が終わるまで動き続けます。で、もう一度同じFragment
が表示されたりしてViewModelが生成されると、またスレッドが生成されちゃう。負荷が大きいことに加えて、処理が2重に動いてしまうんですよ。実は前の章までで作成したアプリにはこの問題に起因するバグがあって、SplashFragment
でスレッドを生成してデータベースを更新しているときにスマートフォンを回転させたりすると、Activity
が再生成されてFragment
のonStart()
が再実行されてデータベースを更新するスレッドがもう一つ動いて、データの不整合ができてアプリが落ちちゃうんですよ。これじゃあ困る。
だから、スレッドの生存期間がViewModel未満になるようにしなければなりません。そんなときに便利なのが、途中で処理を中断したり再開できたり、さらには中断したまま途中でやめちゃったりもできる、コルーチンなんです。
途中で処理を中断、再開?
途中で処理を中断したり再開したりするというのがどういうことなのか、具体的なコードでご説明します。
fun useCoroutine() {
{ // とりあえず、runBlockingは無視してください……
runBlocking for (i in 3.downTo(1)) { // iの値は3, 2, 1の順になります
{
launch (i.toLong() * 1000)
delay.d("xxx", "${i}") // 出力は1, 2, 3の順になります
Log}
}
}
}
このコードを実行すると、「1」のあとに「2」、そのあとに「3」が表示されます。for
文は3.downTo(1)
となっているのでi
の値は3、2、1の順なのですけど、launch
の中身は非同期に実行され、delay()
は指定した時間(ミリ秒)待つので、1,000ミリ秒待って「1」、2,000ミリ秒待って「2」、3,000ミリ秒まって「3」というループとは逆の順に出力されるというわけですな。
……ってそれ、thread
とThread.sleep()
でも同じことができるのでは? たとえば、こんなコードで。
fun useThread() {
for (i in 3.downTo(1)) {
{
thread .sleep(i.toLong() * 1000)
Thread.d("xxx", "${i}")
Log}
}
}
はい、その通り。thread
を使ったこのコードでも、確かに出力は同じになります。でも、useCoroutine()
は実はとてもすごくて、新しいスレッドを使わずに、すべてメイン・スレッドで実行しているんです。確認してみましょう。
fun useCoroutine() {
.d("xxx", "Main thread id: ${Thread.currentThread().id}") // スレッドのIDを出力します
Log
{
runBlocking for (i in 3.downTo(1)) {
{
launch (i.toLong() * 1000)
delay.d("xxx", "${i}: ${Thread.currentThread().id}")
Log}
}
}
}
fun useThread() {
.d("xxx", "Main thread id: ${Thread.currentThread().id}")
Log
for (i in 3.downTo(1)) {
{
thread .sleep(i.toLong() * 1000)
Thread.d("xxx", "${i}: ${Thread.currentThread().id}")
Log}
}
}
このコードを実行すると、useCoroutine()
の方ではすべて同じスレッドIDが、useThread()
の方ではすべて異なるスレッドIDが出力されます。ほら、useCoroutine()
の方は、すべてメイン・スレッドで実行されていてスゴイでしょ?
でも、一つのスレッドでは一つのことしかできないはずで、だから、「待つ」のと「出力」の両方は実行できないはず。でもできちゃっているのはなぜかといえば、実はdelay()
は「待つ」のではなく「中断」だからなんです。中断している間は他のコルーチンを実行できるので、だから並列処理に見えたというわけ。たとえばdelay(1000)
なら、中断して1,000ミリ秒たったら再開するという意味になるんです(とはいえ、メイン・スレッドで動く他の処理がいつまでも終わらなかったりするような場合は、1,000ミリ秒たっても再開されなかったりしますけど)。
中断して、実行するスレッドを変えて、再開しちゃえ!
コルーチンのすごいのは、それだけじゃありません。実行するスレッドを変更することができるんです。
.d("xxx", "Main thread id: ${Thread.currentThread().id}")
Log
{
runBlocking {
launch .d("xxx", "Hello: ${Thread.currentThread().id}")
Log
(Dispatchers.IO) {
withContext.d("xxx", "Coroutines: ${Thread.currentThread().id}")
Log}
.d("xxx", "World: ${Thread.currentThread().id}")
Log}
}
上のコードを実行すると、withContext()
の中だけスレッドIDが変わっていることが分かります。しかも、実行順序は「Hello」「Coroutines」「World」の順序を保っています。まぁ、中断して再開できるのですから、中断してゴニョゴニョして別のスレッドで再開できても不思議ではありませんよね?
なお、上のコードのDispatchers.IO
はファイル操作やデータベース・アクセス、Webサービス呼び出し等向けで、他には、Dispatchers.Default
(バック・グラウンドでの計算処理など向け)、Dispatchers.Main
(メイン・スレッド)があります。というわけで、withContext(Dispatchers.IO) {}
すれば、データベース・アクセスもWebサービス呼び出しもやりたい放題ですよ。
中断して、そのままやめちゃえ!
ViewModel
の中でviewModelScope.launch
した場合は、もっと面白いです。
package com.tail_island.jetbus.view_model
import android.util.Log
import androidx.lifecycle.*
import com.tail_island.jetbus.model.Repository
import com.tail_island.jetbus.model.Route
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class BusApproachesViewModel(private val repository: Repository): ViewModel() {
...
fun foo() {
.launch {
viewModelScopewhile (true) { // 無限ループ
(1000)
delay
.d("xxx", "viewModelScope")
Log}
}
}
}
runBlocking
とlaunch
ではなく、viewModelScope.launch
しています。中身は無限ループなので、いつまでも実行されるはず。
package com.tail_island.jetbus
import android.os.Bundle
import android.util.Log
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.viewModels
import com.tail_island.jetbus.databinding.FragmentBusApproachesBinding
import com.tail_island.jetbus.view_model.BusApproachesViewModel
import javax.inject.Inject
import kotlin.concurrent.thread
class BusApproachesFragment: Fragment() {
@Inject lateinit var viewModelProviderFactory: AppViewModelProvideFactory
private val viewModel by viewModels<BusApproachesViewModel> { viewModelProviderFactory }
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
(requireActivity().application as App).component.inject(this)
.foo()
viewModel
return FragmentBusApproachesBinding.inflate(inflater, container, false).apply {
...
}.root
}
}
こんな感じで、先程の無限ループする処理を呼び出しています。delay(1000)
の間にメイン・スレッドは別の処理をできるのでユーザー・インターフェースが止まらず、それでいてもちろん、logcatにログが出力されます。で、それだけではなくて、画面が遷移すると無限ループなのに処理が止まるんですよ!
中断して、再開しなければいい(あと、再開に必要な情報を破棄する)だけですもんね。で、viewModelScope
はViewModel
と同じライフサイクルを持っているので、画面が遷移してViewModel
が破棄されると処理が止まるんですな。
あと、viewModelScope
の他に、Activity
やFragment
で使用できるlifecycleScope
や、アプリと同じライフサイクルを持つGlobalScope
なんてものあります。でも、できるだけGlobalScope
は使わないでくださいね。
関数に抽出しちゃえ!
さて、ここまでのコードに出てきたlaunch {}
はコルーチンを生成するのですけど、その中に大きな処理を書くと可読性が下がってしまいますから、小さな関数に分割したい。でも、以下のコードはコンパイル・エラーになってしまうんです。
fun bar() {
(1000)
delay
.d("xxx", "viewModelScope")
Log}
fun foo() {
.launch {
viewModelScopewhile (true) {
()
bar}
}
}
というのも、delay()
はコルーチンの中だからできる特別な処理なわけで、だからコルーチンではない場所から呼び出されても困っちゃう。だから、コルーチンの中から呼び出す特別な関数とするために、suspend
を付加しなければならないんです。
fun bar() { // suspendを付加
suspend (1000)
delay
.d("xxx", "viewModelScope")
Log}
fun foo() {
.launch {
viewModelScopewhile (true) {
()
bar}
}
}
はい、これでコンパイルに通ります。なお、suspend
な関数はコルーチン向けの特別な関数ですから、コルーチンの外では呼び出せません。launch {}
の中やsuspend
な関数の中から呼び出してあげてください。
SplashFragmentのデータベース・アクセスとWebサービス呼び出しをコルーチンにする
というわけでコルーチンの説明が終了したので(他にもいろいろな便利機能があるので、ぜひ公式ガイドを読んでみてください)、アプリの実装を勧めましょう。
Repository
まずは、Repository
にデータをクリアするsuspend
なメソッドと公共交通オープンデータセンターのWebサービスを呼び出してデータベースにキャッシュする`suspend`なメソッドを作ります。
package com.tail_island.jetbus.model
import android.util.Log
import androidx.room.withTransaction
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import retrofit2.Call
import java.io.IOException
import javax.inject.Inject
import javax.inject.Named
import javax.inject.Singleton
@Singleton
class Repository @Inject constructor(private val database: AppDatabase, private val webService: WebService, @param:Named("consumerKey") private val consumerKey: String) {
fun clearDatabase() = withContext(Dispatchers.IO) {
suspend try {
.withTransaction {
database.getTimeTableDetailDao().clear()
database.getTimeTableDao().clear()
database.getRouteBusStopPoleDao().clear()
database.getRouteDao().clear()
database.getBusStopPoleDao().clear()
database.getBusStopDao().clear()
database}
Unit
} catch (e: IOException) {
.e("Repository", "${e.message}")
Lognull
}
}
private fun <T> getWebServiceResultBody(callWebService: () -> Call<T>): T? {
val response = callWebService().execute()
if (!response.isSuccessful) {
.e("Repository", "HTTP Error: ${response.code()}")
Logreturn null
}
return response.body()
}
fun syncDatabase() = withContext(Dispatchers.IO) {
suspend try {
if (database.getBusStopDao().getCount() > 0) {
@withContext Unit
return}
val busStopPoleJsonArray = getWebServiceResultBody { webService.busstopPole(consumerKey) } ?: return@withContext null
val routeJsonArray = getWebServiceResultBody { webService.busroutePattern(consumerKey) } ?: return@withContext null
.withTransaction {
databasefor (busStopPoleJsonObject in busStopPoleJsonArray.map { it.asJsonObject }.filter { it.get("odpt:operator").asString == "odpt.Operator:Toei" }) {
val busStop = database.getBusStopDao().getByName(busStopPoleJsonObject.get("dc:title").asString) ?: run {
(
BusStop.get("dc:title").asString,
busStopPoleJsonObject.get("odpt:kana")?.asString
busStopPoleJsonObject).also {
.getBusStopDao().add(it)
database}
}
(
BusStopPole.get("owl:sameAs").asString,
busStopPoleJsonObject.name
busStop).also {
.getBusStopPoleDao().add(it)
database}
}
for (routeJsonObject in routeJsonArray.map { it.asJsonObject }.filter { it.get("odpt:operator").asString == "odpt.Operator:Toei" }) {
val route = Route(
.get("owl:sameAs").asString,
routeJsonObject.get("dc:title").asString
routeJsonObject).also {
.getRouteDao().add(it)
database}
for (routeBusStopPoleJsonObject in routeJsonObject.get("odpt:busstopPoleOrder").asJsonArray.map { it.asJsonObject }) {
(
RouteBusStopPole.id,
route.get("odpt:index").asInt,
routeBusStopPoleJsonObject.get("odpt:busstopPole").asString
routeBusStopPoleJsonObject).also {
.id = database.getRouteBusStopPoleDao().add(it)
it}
}
}
}
Unit
} catch (e: IOException) {
.e("Repository", "${e.message}")
Lognull
}
}
...
}
中身は、前にSplashFragment
の中に書いたコードのほぼコピー&ペーストです。変更点は、clearDatabase()
とsyncDatabase()
に分割したのと、withContext(Dispatchers.IO)
で囲んだのと、成功(Unit)か失敗(null)を返すようにしたこと(前にどこかで書きましたけど、ラムダ式は最後に実行する行の値が返り値になります)と、毎回データを取得するのは時間がかかって嫌なのでif (database.getBusStopDao().getCount() > 0)
なら処理をしないというチェック・ロジックを付加した(getCount()
は@Query("SELECT COUNT(*) FROM BusStop")
アノテーションで作成しました)のと、そのチェック・ロジックが正常に動作するようにdatabase.withTransaction {}
で囲んだことくらい。Roomのところで説明し忘れたのですけど、database.withTransaction {}
で囲むと処理がアトミックになる(完全にやるか、全くやらないかのどちらかになる)ので便利ですよ。
SplashViewModel
次はViewModel
です。SplashViewModel.ktを追加して、以下のコードを書きました。
package com.tail_island.jetbus.view_model
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tail_island.jetbus.model.Repository
import kotlinx.coroutines.launch
class SplashViewModel(private val repository: Repository): ViewModel() {
val isSyncDatabaseFinished = MutableLiveData<Boolean>()
{
init .launch {
viewModelScope.syncDatabase() ?: run { isSyncDatabaseFinished.value = false; return@launch }
repository
.value = true
isSyncDatabaseFinished}
}
}
init {}
には、インスタンスが生成されたときに実行する処理を記述します。Webサービスを呼び出してデータベースにキャッシュするコルーチンはViewModel
と同じライフサイクルを持っていて欲しいので、だから生成と同時にviewModelScope.launch {}
しているわけですな。viewModelScope
でlaunch
しているのでViewModelが破棄されればこのコルーチンは終了しますから、終了もバッチリ。アプリがバックグラウンドになればコルーチンは止まるので、他のアプリに迷惑をかけないですし。
isSyncDatabaseFinished
プロパティは、syncDatabase()
が成功したか失敗したかを表現する目的で追加しました。ViewModelはisSyncDatabaseFinished
をobserve()
して、正常に終了したら次の画面に遷移、そうでなければアプリケーションを終了するとかすればよいわけ。
SplashFragment
というわけで、SplashFragment
にはisSyncDatabaseFinished
をobserve()
する処理を追加して、その代わりにWebサービスを呼び出したりデータベースにアクセスしたりしていた処理をまるごと削除しました。こんな感じ。
package com.tail_island.jetbus
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Observer
import androidx.navigation.fragment.findNavController
import com.tail_island.jetbus.databinding.FragmentSplashBinding
import com.tail_island.jetbus.view_model.SplashViewModel
import javax.inject.Inject
class SplashFragment: Fragment() {
@Inject lateinit var viewModelProviderFactory: AppViewModelProvideFactory
private val viewModel by viewModels<SplashViewModel> { viewModelProviderFactory }
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
(requireActivity().application as App).component.inject(this)
.isSyncDatabaseFinished.observe(viewLifecycleOwner, Observer {
viewModelif (!it) {
().finish()
requireActivity@Observer
return}
().navigate(SplashFragmentDirections.splashFragmentToBookmarksFragment())
findNavController})
return FragmentSplashBinding.inflate(inflater, container, false).root
}
}
うん、スッキリしました(isSyncDatabaseFinished
がUnit?
ならエルビス演算子が使えてもっとスッキリするのですけど、プロパティ名を「is」で始めたのでBoolean
にせざるを得なかった……)。
というわけで、これで自動で画面遷移するようになったのでもうボタンは不要になりましたから、fragment_splash.xml
を以下に変更します。
<?xml version="1.0" encoding="utf-8"?>
<layout
:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:context=".SplashFragment">
tools
<androidx.constraintlayout.widget.ConstraintLayout
:layout_width="match_parent"
android:layout_height="match_parent">
android
<ImageView
:id="@+id/icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:layout_constraintStart_toStartOf="@id/nowDownloadingTextView"
app:layout_constraintEnd_toEndOf="@id/nowDownloadingTextView"
app:layout_constraintBottom_toTopOf="@id/nowDownloadingTextView"
app:contentDescription="@string/app_name"
android:src="@mipmap/ic_launcher_round" />
android
<TextView
:id="@+id/nowDownloadingTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:text="バス停と路線の情報を取得しています……" />
android
<ProgressBar
:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_constraintStart_toStartOf="@id/nowDownloadingTextView"
app:layout_constraintEnd_toEndOf="@id/nowDownloadingTextView"
app:layout_constraintTop_toBottomOf="@id/nowDownloadingTextView" />
app
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
アイコンと、何をしているのかの説明と、あとは処理に時間がかかるので<ProgressBar>
という名前のぐるぐるマークですな。これでSpashFragment
は完成です。
MainActivity
でも、このままだと一回syncDatabase()
に成功しちゃうと次からは実行しないので、プログラムが正しく動くのかよく分かりません……。なので、menu_navigation.xml
に追加したきりすっかり忘れていた<item android:id="@+id/clearDatabase" android:title="バス停と路線のデータを再取得" />
のリスナーを作成しましょう。
package com.tail_island.jetbus
import android.os.Bundle
import android.view.View
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.GravityCompat
import androidx.databinding.DataBindingUtil
import androidx.lifecycle.lifecycleScope
import androidx.navigation.findNavController
import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.NavigationUI
import com.tail_island.jetbus.databinding.ActivityMainBinding
import com.tail_island.jetbus.view_model.MainViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject
class MainActivity: AppCompatActivity() {
@Inject lateinit var viewModelProviderFactory: AppViewModelProvideFactory
private val viewModel by viewModels<MainViewModel> { viewModelProviderFactory }
private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
(application as App).component.inject(this)
= DataBindingUtil.setContentView<ActivityMainBinding>(this, R.layout.activity_main).apply {
binding // NavigationViewのメニューが選ばれた場合のリスナーを設定します
.setNavigationItemSelectedListener {
navigationViewwhen (it.itemId) {
.id.clearDatabase -> run {
R.launch {
lifecycleScope.clearDatabase()
viewModel(R.id.navHostFragment).navigate(R.id.splashFragment)
findNavController}
true
}
else -> false
}.also {
if (!it) {
@also
return}
.closeDrawer(GravityCompat.START)
drawerLayout}
}
}.also {
(R.id.navHostFragment).apply {
findNavController.setupWithNavController(it.toolbar, this, AppBarConfiguration(setOf(R.id.bookmarksFragment), it.drawerLayout))
NavigationUI
{ _, destination, _ ->
addOnDestinationChangedListener .appBarLayout.visibility = if (destination.id == R.id.splashFragment) View.GONE else View.VISIBLE
it}
}
}
}
...
}
SplashViewModel
のときのようにclearDatabase()
が完了したかどうかを表現するプロパティを追加する方式だとコード量が増えてしまうので、今回はlifecycleScope.launch {}
することにしました。
MainViewModel
の中身は、以下のようにとても単純です。
package com.tail_island.jetbus.view_model
import androidx.lifecycle.ViewModel
import com.tail_island.jetbus.model.Repository
class MainViewModel(private val repository: Repository): ViewModel() {
fun clearDatabase() = repository.clearDatabase()
suspend }
Repository
のメソッドを呼び出し直しているだけ。あとは、前の章で紹介した手順でViewModel
をDaggerで注入可能にすれば、作業終了です。ぜひ実際に動かしてみてください。ほらこのアプリ、端末を回転させても正常に動作するんです!
……そんなの当たり前だと思うかもしれませんけど、昔のAndroidアプリ開発ではこの程度でも大変だったんだよ。
RecyclerView
と、ここまでいろいろ作ってきましたけど、なのにいまだに画面がスカスカで悲しい……。画面を作りましょう。使う道具は、これを覚えるだけで閲覧系のアプリなら大体どうにかなっちゃうという噂のRecyclerView
です。
リサイクル?
RecyclerView
は、スクロール可能なリストを作成するGUIウィジェットです。特徴は大規模なデータ・セットに対応可能なこと(大は小を兼ねるので、小さなデータ・セットで使っても問題ないけど)。
で、この「大規模」という点が、RecyclerView
という名前につながります。Androidの画面はView
(本稿でも、文字を表示するためのTextView
とかを使いましたよね?)で構成されるので、リストの各行もView
で構成されます。画面を上にスクロールすると、上の行が画面の外側に消えて下に新しい行が表示されるわけですけど、その画面の外側に消えた行のView
をどうしましょうか? 放っておくとメモリに負荷がかかるし、ガベージ・コレクションの際にはCPUに負荷がかかっちゃう。だから、下から出てくる新しい行のView
としてリサイクルしたい。これを自動でやってくれるのがRecyclerView
というわけ。
ViewHolder
とDiffCallback
とAdapterを作る
リストを表示する場合は、リストそのものを管理する人とリストの要素を管理する人を分けた方が楽になります。List<MyClass>
って分割するのと一緒。RecyclerView
の場合、リストの要素はViewHolder
で管理します。で、ViewHolder
とRecyclerView
を繋げるのがListAdapter
。まずはこのViewHolder
の説明から。
RecyclerView
はView
をリサイクルするので、これまでは会社の前のバス停を表示していたView
に対して、これからは私のアパートの近くのバス停を表示するように指示できなければなりません。これは、前に説明したデータ・バインディングで実現すれば簡単でしょう。レイアウトのXMLの<data>
タグの中身を変えちゃえばいいんですからね。
というわけで、たとえばバス停をリスト表示するViewHolder
向けのレイアウトXMLは以下のようになります。名前はlist_item_bus_stop.xmlにしましょう。
<?xml version="1.0" encoding="utf-8"?>
layout
< xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
data>
<variable name="item" type="com.tail_island.jetbus.model.BusStop" />
<data>
</
androidx.constraintlayout.widget.ConstraintLayout
< android:layout_width="match_parent"
android:layout_height="wrap_content">
com.google.android.material.button.MaterialButton
< android:id="@+id/busStopButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:gravity="start|center_vertical"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:text='@{item.name}' />
androidx.constraintlayout.widget.ConstraintLayout>
</
layout> </
<data>
タグの中のitem
の値を変更することで、ViewHolder
のリサイクルを実現するわけですな。<com.google.android.material.button.MaterialButton>
はAndroidの標準のデザイン・ガイドラインであるマテリアル・デザインのボタンを実現するボタンで、見た目がかっこいい上に`style`を指定するだけで見た目を大きく変えられます。今回は、背景がなくて文字だけの@style/Widget.MaterialComponents.Button.TextButton
にしました。
このレイアウトを使用するViewHolder
はこんな感じ。
class ViewHolder(private val binding: ListItemBusStopBinding): RecyclerView.ViewHolder(binding.root) {
fun bind(item: BusStop) {
.item = item
binding.executePendingBindings() // データ・バインディングを実行します
binding
.busStopButton.setOnClickListener {
binding// ここに、バス停がタップされたときの処理を入れる
}
}
}
bind()
メソッドは、ViewHolder
にデータを設定するためのメソッドです(このメソッドを呼び出す部分はListAdapter
で作ります)。<data>
タグのitem
を変更して、executePendingBindings()
でデータ・バインディングを強制的に実行させて、あと、リスナーを再設定しています。
でもちょっと待って。リストが変更された場合のことを考えてみましょう。たとえば最後に1行追加された場合は、残りの行は何もしなくてもよいはず。でも、今回のようにデータベースからデータを取得する場合は、再取得すると異なるインスタンスになってしまいます。以前表示していたBusStop
のインスタンスと今回表示するBusStop
のインスタンスは、同じバス停を表している場合であっても異るというわけ。異なるインスタンスなのだからもう一度データ・バインディングをやり直しましょうというのは、リソースの無駄遣いなのでやりたくない。あと、RecyclerView.ViewHolder
のAPIリファレンスを眺めてみると、リストの中のどの位置なのかを表現するgetPosition()
というメソッドがありました。他にも、getOldPosition()
という位置を移動するアニメーションのためのメソッドも。ということは、ViewHolder
はリストの中を上下に移動することが可能なはず。それはそうですよね。最初に一行挿入された場合に、全部の行を書き直すのは無駄ですもん。
というわけで、データの変更が無いから再バインディングは不要と判断したり、異なるインスタンスだけど同じデータを表現しているので上下への移動で対応しようと判断したりするための機能が必要です。これがDiffCallback
。コードは以下に示すとおりで、DiffUtil.ItemCallback
を継承して作成します。
class DiffCallback: DiffUtil.ItemCallback<BusStop>() {
override fun areItemsTheSame(oldItem: BusStop, newItem: BusStop) = oldItem.name == newItem.name
override fun areContentsTheSame(oldItem: BusStop, newItem: BusStop) = oldItem == newItem
}
areItemsTheSame()
が同じ要素かを調べるメソッド、areContentsTheSame()
メソッドが中身が変わっていないかを調べるメソッドです。我々はリレーショナル・データベースを使用していて、リレーショナル・データベースのエンティティは主キーを持っているので、areItemsTheSame()
は主キーを比較するだけです。あと、Roomのエンティティは属性すべての値が同じかを調べる==
演算子を定義してくれているので、areContentsTheSame()
はただ単にoldItem
とnewItem
を==
で結ぶだけでオッケー。
と、こんな感じでViewHolder
とDiffCallback
ができましたので、あとはこれらとRecyclerView
をつなげてくれるAdapterを作るだけ。コードを以下に示します。
package com.tail_island.jetbus.adapter
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.tail_island.jetbus.databinding.ListItemBusStopBinding
import com.tail_island.jetbus.model.BusStop
class BusStopAdapter: ListAdapter<BusStop, BusStopAdapter.ViewHolder>(DiffCallback()) {
var onBusStopClick: (busStop: BusStop) -> Unit
lateinit
class ViewHolder(private val binding: ListItemBusStopBinding): RecyclerView.ViewHolder(binding.root) {
inner fun bind(item: BusStop) {
.item = item
binding.executePendingBindings()
binding
.busStopButton.setOnClickListener {
binding(item)
onBusStopClick}
}
}
class DiffCallback: DiffUtil.ItemCallback<BusStop>() {
override fun areItemsTheSame(oldItem: BusStop, newItem: BusStop) = oldItem.name == newItem.name
override fun areContentsTheSame(oldItem: BusStop, newItem: BusStop) = oldItem == newItem
}
override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): ViewHolder = ViewHolder(ListItemBusStopBinding.inflate(LayoutInflater.from(viewGroup.context), viewGroup, false))
override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) = viewHolder.bind(getItem(position))
}
先程作成したViewHolder
とDiffCallback
は、管理を簡単にするためにAdapterの中に入れました。Kotlinでは、単純にclass
の内側にclass
を作成しただけだと、外側のclass
の属性やメソッドを参照することはできません。だから、ViewHolder
は外側のクラスのonBusStopClick
を使用できるよう、inner class
にしました。
あとは、ViewHolder
を作成するonCreateViewHolder()
メソッドと、データ・バインディングのときに呼ばれるonBindViewHolder()
メソッドを定義しただけです。GitHubのコードを見ていただければ分かるのですけど、Adapterはすべてほぼ同じコードです。コピー&ペーストして少しだけ置換すれば完成しちゃうので、一度上のコードを覚えてしまえばとても簡単に作れますよ。
RecyclerViewを組み込む
上で作成したBusStopAdapter
を使うRecyclerView
を、DepartureBusStopFragment
に組み込みましょう。まずは、レイアウトのXMLです。
<?xml version="1.0" encoding="utf-8"?>
layout
< xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
tools:context=".DepartureBusStopFragment">
androidx.constraintlayout.widget.ConstraintLayout
< android:layout_width="match_parent"
android:layout_height="match_parent">
androidx.recyclerview.widget.RecyclerView
< android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp"
android:clipToPadding="false"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
androidx.constraintlayout.widget.ConstraintLayout>
</
layout> </
android:clipToPadding
というのは、パディング範囲を超えた部分を消すかどうかです。なんでこんな指定をしているかというと、これが無いととても不自然なユーザー・インターフェースになってしまうから。マテリアル・デザインではスマートフォンの場合は端から16dpの隙間を空けることになっています。だからandroid:padding="16dp"
で隙間を作成している。でも、上下にスクロールした時にこの16dpの隙間部分を消して真っ白にしてしまうと、表示している内容がどこに消えていったのか分からなくてとても不自然なユーザー・インターフェースになってしまうんですよ。だからandroid:clipToPadding
を敢えてfalse
にして消さないようにして、上方向はApp
barの下に、下方向は画面の外に消えていったように見せているわけ。
あと、app:layoutManager
というのは、RecyclerView
の要素をどのように表示するかのレイアウトを決めるクラスです。LinearLayoutManager
を指定すると、上から下にリスト状に表示されるようになります。
ここまできたら、BusStopDaoにgetObservables()
メソッドを、RepositoryにgetObservableBusStops()
メソッドを追加して、DepartureBusStopViewModel
を作成してdepartureBusStops
プロパティを追加してDaggerで挿入できるようにして、で、DepartureBusStopFragment
を修正するだけ。コードはこんな感じ。
package com.tail_island.jetbus
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Observer
import androidx.navigation.fragment.findNavController
import com.tail_island.jetbus.adapter.BusStopAdapter
import com.tail_island.jetbus.databinding.FragmentDepartureBusStopBinding
import com.tail_island.jetbus.view_model.DepartureBusStopViewModel
import javax.inject.Inject
class DepartureBusStopFragment: Fragment() {
@Inject lateinit var viewModelProviderFactory: AppViewModelProvideFactory
private val viewModel by viewModels<DepartureBusStopViewModel> { viewModelProviderFactory }
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
(requireActivity().application as App).component.inject(this)
return FragmentDepartureBusStopBinding.inflate(inflater, container, false).apply {
// RecyclerViewにAdapterを設定します。
.adapter = BusStopAdapter().apply {
recyclerView.busStops.observe(viewLifecycleOwner, Observer {
viewModel(it)
submitList})
= {
onBusStopClick ().navigate(DepartureBusStopFragmentDirections.departureBusStopFragmentToArrivalBusStopFragment(it.name))
findNavController}
}
}.root
}
}
レイアウトのXMLに<data>
がなかったのは、上のようにBusStopAdapter.submitList()
でコード上でやるため。データが変更になったらsubmitList()
し直します(今回はデータが変更になることはないけど、変更があり得る場合と無い場合で記述を変えるのと保守性が落ちるので、変更がある場合向けのコードで統一します)。あとは、バス停がタップされた場合のリスナーに画面遷移をするコードを書いただけ。
さらに、索引用のRecyclerView
を作る
うん、これで完成……じゃないんですよ、今回のアプリでは。というのも、バス停の件数があまりに膨大なんです。だから、下の方のバス停を選ぶにはもーひたすらにスクロールしなければならなくて、とても実用にはなりません。実用に耐えられるようにするために、索引を作りましょう。もう一つRecyclerView
を作って重ねるだけの簡単な作業です。
list_item_index.xmlを追加します。
<?xml version="1.0" encoding="utf-8"?>
layout
< xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
data>
<variable name="item" type="char" />
<data>
</
androidx.constraintlayout.widget.ConstraintLayout
< android:layout_width="wrap_content"
android:layout_height="wrap_content">
com.google.android.material.button.MaterialButton
< android:id="@+id/indexButton"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintTop_toTopOf="@id/indexTextView"
app:layout_constraintBottom_toBottomOf="@id/indexTextView"
app:layout_constraintStart_toStartOf="@id/indexTextView"
app:layout_constraintEnd_toEndOf="@id/indexTextView"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:insetLeft="0dp"
android:insetTop="0dp"
android:insetRight="0dp"
android:insetBottom="0dp" />
TextView
< android:id="@+id/indexTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:text='@{String.format("%c", item)}' />
androidx.constraintlayout.widget.ConstraintLayout>
</
layout> </
単純にMaterialButton
で作ると見た目に変化が無くて混乱しますから、TextView
で索引の文字を表示させました。でもTextView
だけだとタップしたときの見た目のフィードバックがないので、同じ位置に重なるようにMaterialButton
も配置します。あと、MaterialButton
はandroid:inset
として上下左右に隙間を持っているので、この隙間をなくすために0dp
に設定しました。
次に、IndexAdapter
を作成します。
package com.tail_island.jetbus.adapter
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.tail_island.jetbus.databinding.ListItemIndexBinding
class IndexAdapter: ListAdapter<Char, IndexAdapter.ViewHolder>(DiffCallback()) {
var onIndexClick: (index: Char) -> Unit
lateinit
class ViewHolder(private val binding: ListItemIndexBinding): RecyclerView.ViewHolder(binding.root) {
inner fun bind(item: Char) {
.item = item
binding.executePendingBindings()
binding
.indexButton.setOnClickListener {
binding(item)
onIndexClick}
}
}
class DiffCallback: DiffUtil.ItemCallback<Char>() {
override fun areItemsTheSame(oldItem: Char, newItem: Char) = oldItem == newItem
override fun areContentsTheSame(oldItem: Char, newItem: Char) = oldItem == newItem
}
override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): ViewHolder = ViewHolder(ListItemIndexBinding.inflate(LayoutInflater.from(viewGroup.context), viewGroup, false))
override fun onBindViewHolder(viewHolder: IndexAdapter.ViewHolder, position: Int) = viewHolder.bind(getItem(position))
}
うん、見事なまでにBusStopAdapter
とそっくり。もちろんコピー&ペーストで作りました。注意すべきは、索引はChar
一文字で属性がないので、areItemsTheSame()
とareContentsTheSame()
の内容が同じである点くらいです。
で、索引はどうしましょ?「あ」から「ん」まで全て並べてもよいのですけど、できるだけ短くしたいので、バス停の一覧に含まれる分だけの頭文字を使うことにしましょう。あと、「か」と「が」が同じになるような考慮もしたい。というわけで、Utility.ktを修正してこんな関数を作成しました。
import com.tail_island.jetbus.model.BusStop
...
// 小文字や濁音、撥音、旧字を対応する文字に変換するmapです
private val indexConverter = "ぁぃぅぇぉがぎぐげござじずぜぞだぢっづでどばぱびぴぶぷべぺぼぽゃゅょゎゐゑゔゕゖ".zip("あいうえおかきくけこさしすせそたちつつてとははひひふふへへほほやゆよわいえうかけ").toMap()
// 小文字や濁音、撥音、旧字を対応する文字に変換します
fun convertIndex(index: Char): Char {
return when (index) {
in '\u30a1'..'\u30fa' -> index - 0x0060
else -> index
}.let {
[it] ?: it
indexConverter}
}
// バス停名称の索引の文字の集合を作成します
fun getBusStopIndexes(busStops: List<BusStop>): List<Char> {
return busStops.asSequence().map { busStop -> busStop.phoneticName?.firstOrNull() }.filterNotNull().map { convertIndex(it) }.distinct().toList()
}
// 索引の文字からバス停の一を取得します
fun getBusStopPosition(busStops: List<BusStop>, index: Char): Int {
return busStops.indexOfFirst { busStop -> busStop.phoneticName?.firstOrNull()?.let { convertIndex(it) == index } ?: false }
}
indexConverter
というMap
を作成して、「ぁ」を「あ」に変換できるようにします。全部の文字をこのMap
で変換するのは記述が面倒だったので、convertIndex()
関数を作成して、変換が不要な場合はそのまま、そうでなければindexConverter
を使用して変換するようにしました。あとは、List<BusStop>
から関数型プログラミングのテクニックでサクッと索引一覧を作成するgetBusStopIndexes()
関数と、あとで使用する索引から位置を求めるgetBusStopPosition()
関数です。
このgetBusStopIndexes()
関数を使用して、DepartureBusStopViewModel
を完成させます。
package com.tail_island.jetbus.view_model
import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel
import com.tail_island.jetbus.getBusStopIndexes
import com.tail_island.jetbus.model.Repository
class DepartureBusStopViewModel(private val repository: Repository): ViewModel() {
val departureBusStops = repository.getObservableBusStops()
val departureBusStopIndexes = Transformations.map(departureBusStops) {
(it)
getBusStopIndexes}
}
DepartureBusStopFragment
に新しいRecyclerView
を追加します。レイアウトのXMLはこんな感じ。
<?xml version="1.0" encoding="utf-8"?>
layout
< xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
tools:context=".DepartureBusStopFragment">
androidx.constraintlayout.widget.ConstraintLayout
< android:layout_width="match_parent"
android:layout_height="match_parent">
androidx.recyclerview.widget.RecyclerView
< android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp"
android:clipToPadding="false"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
androidx.recyclerview.widget.RecyclerView
< android:id="@+id/indexRecyclerView"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:padding="16dp"
android:clipToPadding="false"
app:layout_constraintEnd_toEndOf="parent"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
androidx.constraintlayout.widget.ConstraintLayout>
</
layout> </
DepartureBusStopFragment
はこんな感じです。
package com.tail_island.jetbus
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Observer
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.LinearSmoothScroller
import com.tail_island.jetbus.adapter.BusStopAdapter
import com.tail_island.jetbus.adapter.IndexAdapter
import com.tail_island.jetbus.databinding.FragmentDepartureBusStopBinding
import com.tail_island.jetbus.view_model.DepartureBusStopViewModel
import javax.inject.Inject
class DepartureBusStopFragment: Fragment() {
@Inject lateinit var viewModelProviderFactory: AppViewModelProvideFactory
private val viewModel by viewModels<DepartureBusStopViewModel> { viewModelProviderFactory }
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
(requireActivity().application as App).component.inject(this)
return FragmentDepartureBusStopBinding.inflate(inflater, container, false).apply {
.adapter = BusStopAdapter().apply {
recyclerView.departureBusStops.observe(viewLifecycleOwner, Observer {
viewModel(it)
submitList})
= {
onBusStopClick ().navigate(DepartureBusStopFragmentDirections.departureBusStopFragmentToArrivalBusStopFragment(it.name))
findNavController}
}
.adapter = IndexAdapter().apply {
indexRecyclerView.departureBusStopIndexes.observe(viewLifecycleOwner, Observer {
viewModel(it)
submitList})
= {
onIndexClick // バス停のRecyclerViewを、適切な位置までスクロールします
.layoutManager!!.startSmoothScroll(
recyclerView(requireContext()).apply {
AcceleratedSmoothScroller= getBusStopPosition(viewModel.departureBusStops.value!!, it)
targetPosition }
)
}
}
}.root
}
}
このコードの中のAcceleratedSmoothScroller
は、「RecyclerViewの長距離スムーズスクロールをスムーズにする」を猿真似して作成した良い感じスクロールさせるためのクラスです。`RecylerView`のAPIには、指定した場所にスクロールする機能(画面がいきなり切り替わるので使いづらいユーザー・インターフェースになる)と指定した場所までスムーズにスクロールする機能(使いやすいユーザー・インターフェースになるけど、スクロールが終わるまで長時間かかる)しかなくて、これだけだと指定場所までスクロールするという機能がとても作りづらいんです。この問題は、「RecyclerViewの長距離スムーズスクロールをスムーズにする」ですべて解決できますので、ぜひ読んでみてください。なお、今回は少しだけ実装を変えたので、クラス名も少しだけ変更しています。
BookmarksFragment
とArrivalBusStopFragment
を同じやり方で作って、BusApproachesFragment
を少しだけ修正する
あとは、同じやり方で(コピー&ペーストをやりまくって)BookmarksFragment
とArrivalBusStopFragment
を作りましょう。少しだけ工夫したのは、list_item_bookmark.xmlのandroid:text
でString.format()
を使用したこと、あと、文字列リソースは@string/id
と書けば参照できて、それはデータ・バインディングの中でも変わらないことを証明するためにString.format()
の中に@string/start_to_end_arrow
と書いたことと、ついでだったのでリソース中のすべての文字列をres/values/string.xmlに移動させて、都営バスを思わせる緑色で画面が表示されるようにres/values/colors.xmlを修正したくらい。
で、このままだとブックマークが一つもないのでBookmarksFragment
を正しく実装できたか確認できなかったので、BusApproachesFragment
にブックマークを追加する機能(BusApproachesViewModel
のtoggleBookmark()
)を追加しました。
詳細なソース・コードは、GitHubで確認してみてください。ほとんどコピー&ペーストなので書くこと何もなかったんですよ……。
ともあれ、大分見た目もしっかりしてきました。
あとは、バスの接近情報を表示するだけ。表示そのものはRecyclerViewでできそうだけど、表示するデータはどうしましょうか?
バス接近情報を表示する
バス接近情報を表示する部分は少し複雑なので、データ・アクセス・オブジェクト、Repository
、ViewModel
、Fragment
の順で説明していきます。
データ・アクセス・オブジェクトを作成する
まずは、バスの接近情報を表示するために必要な情報を取得するデータ・アクセス・オブジェクトを、順を追って作成していきましょう。
RouteBusStopPole
出発バス停名称と到着バス停名称からRoute
を取得するところまでは実装済みですので、RouteBusStopPole
から考えましょう。RouteBusStopPole
はRoute
とBusStopPole
を関連付けるものなのですけど、バスの「接近」情報を表示する本アプリでは、出発バス停以降のRouteBusStopPole
の情報は不要です(どれだけ遠ざかったかを表示するプログラムなら、出発バス停以降こそが重要なのでしょうけど)。あと、出発バス停よりも遥かに手前のRouteBusStopPole
も不要です。あと3時間でバスが到着する(バスは出発バス停よりも300手前のバス停を出発した)なんて表示されても困りますもんね。私の独断と偏見で、出発バス停の手前10個までを取得の対象とします。
package com.tail_island.jetbus.model
import androidx.lifecycle.LiveData
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
@Dao
interface RouteBusStopPoleDao {
@Insert
fun add(routeBusStopPole: RouteBusStopPole): Long
Query("DELETE FROM RouteBusStopPole")
@fun clear()
Query(
@
"""SELECT RouteBusStopPole.*
FROM RouteBusStopPole
INNER JOIN (
SELECT RouteBusStopPole.routeId, RouteBusStopPole.'order'
FROM RouteBusStopPole
INNER JOIN BusStopPole ON BusStopPole.id = RouteBusStopPole.busStopPoleId
.routeId IN (:routeIds) AND BusStopPole.busStopName = :departureBusStopName
WHERE RouteBusStopPole) DepartureRouteBusStopPole ON RouteBusStopPole.routeId = DepartureRouteBusStopPole.routeId
.'order' <= DepartureRouteBusStopPole.'order' AND RouteBusStopPole.'order' >= DepartureRouteBusStopPole.'order' - 10
WHERE RouteBusStopPole"""
)
fun getObservablesByRouteIdsAndDepartureBusStopName(routeIds: List<String>, departureBusStopName: String): LiveData<List<RouteBusStopPole>>
}
うん、副問合せですね。INNER JOIN
のカッコの中で、Route
と出発バス停名称に関連付けられたRouteBusStopPoleを取得します。で、外側のSQLのWHERE
で、出発のRouteBusStopPole
よりもorder
が小さく、かつ、出発のRouteBusStopPole
のorder
-10よりも大きいとやることで、先に述べた条件を満たすRouteBusStopPole
を取得しているというわけ。
BusStopPole
バス停の情報を表示できるように、BusStopPole
も取得しましょう。RouteBusStop
を取得済みなので、これはとても簡単です。
package com.tail_island.jetbus.model
import androidx.lifecycle.LiveData
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
@Dao
interface BusStopPoleDao {
...
@Query("SELECT * FROM BusStopPole WHERE id IN (:ids)")
fun getObservablesByIds(ids: List<String>): LiveData<List<BusStopPole>>
}
取得したRouteBusStopPole
のbusStopPoleId
のリストを引数に渡すわけですな。
TimeTable
前にも述べましたが、バスは一つの路線を日に何回も行き来していますから、Route
とTimeTable
の関係は1対多となっています。よって、複数あるTimeTable
の中から一つを選ばなければなりません。道路が混んでいる時間帯と空いている時間帯では時刻表が変わるかもしれませんから、到着バス停に関連付けられたTimeTableDetail.arrival
が現在時刻に最も近いものを選ぶのが良いでしょう。ということで、TimeTableDao
に以下の@Query
を追加しました。
package com.tail_island.jetbus.model
import androidx.lifecycle.LiveData
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
@Dao
interface TimeTableDao {
...
@Query(
"""
SELECT TimeTable.*
FROM TimeTable
INNER JOIN (
SELECT TimeTable.routeId, MIN(TimeTable.id) AS id
FROM TimeTable
INNER JOIN TimeTableDetail ON TimeTableDetail.timeTableId = TimeTable.id
INNER JOIN BusStopPole ON BusStopPole.id = TimeTableDetail.busStopPoleId
WHERE
TimeTable.routeId IN (:routeIds) AND
BusStopPole.busStopName = :departureBusStopName AND
NOT EXISTS (
SELECT T1.*
FROM TimeTable AS T1
INNER JOIN TimeTableDetail AS T2 ON T2.timeTableId = T1.id
INNER JOIN BusStopPole AS T3 ON T3.id = T2.busStopPoleId
WHERE T1.routeId = TimeTable.routeId AND T3.busStopName = BusStopPole.busStopName AND ABS(T2.arrival - :now) < ABS(TimeTableDetail.arrival - :now)
)
GROUP BY TimeTable.routeId
) AS T ON T.id = TimeTable.id
""")
fun getObservablesByRouteIdsAndDepartureBusStopName(routeIds: List<String>, departureBusStopName: String, now: Int): LiveData<List<TimeTable>>
}
ある特定の条件下での集約や分析を実現するSQLのwindow関数を使えば簡単に作れそうなのですけど、残念なことにSQLite3がwindow関数をサポートしたのはバージョン3.25.0で、2020年3月現在で最新のAndroid
10であってもSQLite3のバージョンは3.22.0です……。だから、上のSQLではNOT EXISTS
の中の副問合せで代用しました。副問合せの外側の行よりも到着バス停に関連付けられたTimeTableDetail.arrival
から現在時刻を引いた結果の絶対値が小さいものが存在しない(NOT EXISTS
)なので、結果として到着バス停に関連付けられたTimeTableDetail.arrival
が現在時刻に最も近いものがSELECT
されます。……現在時刻との差が同じものが複数ある場合は、それらが全部取得されちゃうんだけどな。なので、INNER JOIN
でもう一回副問合せして、GROUP BY
して最小(順序が付いて一つに絞れるなら何でもいいので、最大とかでも大丈夫)のTimeTable.id
とINNER JOIN
しています。ちょっと(かなり)複雑なSQL文ですけど、でもたぶん、普通のプログラミング言語でループを回してデータを取得する処理を書くよりは楽だと思いますよ。O/Rマッピング・ツールがなかった頃にプログラムを書いていた私のようなおっさんに頼めば、この程度のSQLは大喜びで一瞬で書いてくれるはずです。おっさんとハサミは使いようですよ。職場のおっさんを大事にしましょう。
TimeTableDetail
TimeTable
を取得できたので、TimeTableDetail
も取得します。RouteBusStopPole
のときと同じで、出発バス停以降のTimeTableDetail
も、出発バス停のはるか手前のTimeTableDetail
も不要……なのですけど、同じ条件式を2回書くのは大変なので、取得済みのBusStopPole
を活用することにしましょう。
package com.tail_island.jetbus.model
import androidx.lifecycle.LiveData
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
@Dao
interface TimeTableDetailDao {
...
@Query("SELECT * FROM TimeTableDetail WHERE timeTableId IN (:timeTableIds) AND busStopPoleId IN (:busStopPoleIds) ORDER BY 'order'")
fun getObservablesByTimeTableIdsAndBusStopPoleIds(timeTableIds: List<String>, busStopPoleIds: List<String>): LiveData<List<TimeTableDetail>>
}
うん、SQL便利。これでデータ・アクセス・オブジェクトの作成は終了です。
Repository
を作成する
Repository
でデータベースとWebサービスの差異を吸収して統一した操作を実現する……のが理想なんだけど、実際はまず無理! トランザクションがある環境とない環境で同じ操作なんて無理なんだよ(と私は思います)。とはいえ、Repository
というレイヤーはやはり便利です。以前作成したsyncDatabase()
メソッドのようなデータベースとWebサービスの複雑な呼び出しを隠蔽するメソッドを置けますし、データ・アクセス・オブジェクトのメソッドがへっぽこだったりWebサービスの仕様が物足りなかったりする場合を補う処理を書けますから。
というわけで、作成したRepository
は以下の通り。
package com.tail_island.jetbus.model
import android.util.Log
import androidx.room.withTransaction
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import retrofit2.Call
import java.io.IOException
import java.util.*
import javax.inject.Inject
import javax.inject.Named
import javax.inject.Singleton
@Singleton
class Repository @Inject constructor(private val database: AppDatabase, private val webService: WebService, @param:Named("consumerKey") private val consumerKey: String) {
...
fun syncTimeTables(routes: Iterable<Route>) = withContext(Dispatchers.IO) {
suspend try {
for (route in routes) {
(0) // 一応だけど、キャンセル可能にしてみました。。。
delay
if (database.getTimeTableDao().getCountByRouteId(route.id) > 0) {
continue
}
val timeTableJsonArray = getWebServiceResultBody { webService.busTimeTable(consumerKey, route.id) } ?: return@withContext null
.withTransaction {
databasefor (timeTableJsonObject in timeTableJsonArray.map { it.asJsonObject }) {
val timeTable = TimeTable(
.get("owl:sameAs").asString,
timeTableJsonObject.get("odpt:busroutePattern").asString
timeTableJsonObject).also {
.getTimeTableDao().add(it)
database}
for (timeTableDetailJsonObject in timeTableJsonObject.get("odpt:busTimetableObject").asJsonArray.map { it.asJsonObject }) {
(
TimeTableDetail.id,
timeTable.get("odpt:index").asInt,
timeTableDetailJsonObject.get("odpt:busstopPole").asString,
timeTableDetailJsonObject.get("odpt:arrivalTime").asString.split(":").let { (hour, minute) -> hour.toInt() * 60 * 60 + minute.toInt() * 60 }
timeTableDetailJsonObject).also {
.id = database.getTimeTableDetailDao().add(it)
it}
}
}
}
}
Unit
} catch (e: IOException) {
.e("Repository", "${e.message}")
Lognull
}
}
fun clearBookmarks() = withContext(Dispatchers.IO) {
suspend .getBookmarkDao().clear()
database}
fun toggleBookmark(departureBusStopName: String, arrivalBusStopName: String) = withContext(Dispatchers.IO) {
suspend val bookmark = database.getBookmarkDao().get(departureBusStopName, arrivalBusStopName)
if (bookmark == null) {
.getBookmarkDao().add(Bookmark(departureBusStopName, arrivalBusStopName))
database} else {
.getBookmarkDao().remove(bookmark)
database}
}
fun getBuses(routes: Iterable<Route>, routeBusStopPoles: Iterable<RouteBusStopPole>) = withContext(Dispatchers.IO) {
suspend try {
val busStopPoleIds = routeBusStopPoles.groupBy { it.routeId }.map { (routeId, routeBusStopPoles) -> Pair(routeId, routeBusStopPoles.map { it.busStopPoleId }.toSet()) }.toMap()
{ webService.bus(consumerKey, routes.map { it.id }.joinToString(",")) }?.filter { bus ->
getWebServiceResultBody // routeBusStopPolesに含まれるバス停を出発したところ、かつ、routeBusStopPolesの同じ路線の最後(つまり出発バス停)を出発したのではない
.fromBusStopPoleId in busStopPoleIds.getValue(bus.routeId) && bus.fromBusStopPoleId != routeBusStopPoles.filter { it.routeId == bus.routeId }.sortedByDescending { it.order }.first().busStopPoleId
bus}
} catch (e: IOException) {
.e("Repository", "${e.message}")
Lognull
}
}
...
fun getObservableBusStopPolesByRouteBusStopPoles(routeBusStopPoles: Iterable<RouteBusStopPole>) = database.getBusStopPoleDao().getObservablesByIds(routeBusStopPoles.map { it.busStopPoleId }.distinct()) // 複数の路線が同じバス停を含んでいる可能性があるので、distinct()しておきます。
...
fun getObservableRouteBusStopPolesByRoutes(routes: Iterable<Route>, departureBusStopName: String) = database.getRouteBusStopPoleDao().getObservablesByRouteIdsAndDepartureBusStopName(routes.map { it.id }, departureBusStopName)
fun getObservableRoutesByDepartureBusStopNameAndArrivalBusStopName(departureBusStopName: String, arrivalBusStopName: String) = database.getRouteDao().getObservablesByDepartureBusStopNameAndArrivalBusStopName(departureBusStopName, arrivalBusStopName)
fun getObservableTimeTablesByRoutesAndDepartureBusStop(routes: Iterable<Route>, departureBusStopName: String) = database.getTimeTableDao().getObservablesByRouteIdsAndDepartureBusStopName(routes.map { it.id }, departureBusStopName, Calendar.getInstance().let { it.get(Calendar.HOUR_OF_DAY) * 60 * 60 + it.get(Calendar.MINUTE) * 60 })
fun getObservableTimeTableDetailsByTimeTablesAndBusStopPoles(timeTables: Iterable<TimeTable>, busStopPoles: Iterable<BusStopPole>) = database.getTimeTableDetailDao().getObservablesByTimeTableIdsAndBusStopPoleIds(timeTables.map { it.id }, busStopPoles.map { it.id })
}
syncTimeTables()
は、構造的にはsyncDatabase()
の外側にループがもう一つ付いただけ。これで、TimeTable
とTimeTableDetail
をデータベースにキャッシュできるようになりました。clearBookmarks()
はバス停が廃止された場合にブックマークの削除ができなくなっちゃうかもと考えてこっそり追加した[ブックマークを全て削除]メニュー向けのメソッド、toggleBookmark()
は前の章で作成した(けど説明をし忘れた)ブックマークを設定したり解除したりするためのメソッドです。
重要なのはここから。getBuses()
はWebサービスを呼び出しているのですけど、このWebサービスってRoute
のバスを全て返すんですよ。で、データ・アクセス・オブジェクトのところで何度も考えたように、出発バス停以降を走っているバスや出発バス停のはるか手前を走っているバスの情報は不要。なので、不要な情報を削除する処理をこのメソッドの中に入れています。出発バス停を出発したバスを除外するところが格好悪いコードになっていますけど、ご容赦ください。
あと、たとえばBusStopPoleDao.getObservablesByIds()
ではIDの集合が引数になっていたわけですけど、それだと呼び出しづらいので、BusStopPoleDao.getObservablesByIds()
をラップするgetObservableBusStopPolesByRouteBusStopPoles()
はRouteBusStopPole
の集合を引数に呼び出せるようにしたりしています。getObservableRouteBusStopPolesByRoutes()
やgetObservableTimeTableDetailsByTimeTablesAndBusStopPoles()
も同様。getObservableTimeTablesByRoutesAndDepartureBusStop()
では、それにくわええて現在時刻の引数を生成しています。
うん、Repository
を作っておいて良かった。これならViewModel
の作成は簡単でしょう。
ViewModelを作成する
たしかにViewModel
の作成は簡単っちゃあ簡単なんですけど、面倒くださいです。その理由はMediatorLiveData
。同じような記述を何度も繰り返さなければならないので、簡単だけど面倒でした……。そのBusApproachesViewModel
はこんな感じ。
package com.tail_island.jetbus.view_model
import androidx.lifecycle.*
import com.tail_island.jetbus.model.*
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class BusApproachesViewModel(private val repository: Repository): ViewModel() {
val departureBusStopName = MutableLiveData<String>()
val arrivalBusStopName = MutableLiveData<String>()
val bookmark = MediatorLiveData<Bookmark?>().apply {
var source: LiveData<Bookmark?>? = null
fun update() {
val departureBusStopNameValue = departureBusStopName.value ?: return
val arrivalBusStopNameValue = arrivalBusStopName.value ?: return
?.let {
source(it)
removeSource}
= repository.getObservableBookmarkByDepartureBusStopNameAndArrivalBusStopName(departureBusStopNameValue, arrivalBusStopNameValue).also {
source (it) { sourceValue ->
addSource= sourceValue
value }
}
}
(departureBusStopName) { update() }
addSource(arrivalBusStopName) { update() }
addSource}
val routes = MediatorLiveData<List<Route>>().apply {
var source: LiveData<List<Route>>? = null
fun update() {
val departureBusStopNameValue = departureBusStopName.value ?: return
val arrivalBusStopNameValue = arrivalBusStopName.value ?: return
?.let {
source(it)
removeSource}
= repository.getObservableRoutesByDepartureBusStopNameAndArrivalBusStopName(departureBusStopNameValue, arrivalBusStopNameValue).also {
source (it) { sourceValue ->
addSource= sourceValue
value }
}
}
(departureBusStopName) { update() }
addSource(arrivalBusStopName) { update() }
addSource}
val routeBusStopPoles = MediatorLiveData<List<RouteBusStopPole>>().apply {
var source: LiveData<List<RouteBusStopPole>>? = null
fun update() {
val routesValue = routes.value ?: return
val departureBusStopNameValue = departureBusStopName.value ?: return
?.let {
source(it)
removeSource}
= repository.getObservableRouteBusStopPolesByRoutes(routesValue, departureBusStopNameValue).also {
source (it) { sourceValue ->
addSource= sourceValue
value }
}
}
(routes) { update() }
addSource(departureBusStopName) { update() }
addSource}
val busStopPoles = Transformations.switchMap(routeBusStopPoles) { routeStopPolesValue ->
.getObservableBusStopPolesByRouteBusStopPoles(routeStopPolesValue)
repository}
val timeTables = MediatorLiveData<List<TimeTable>>().apply {
var source: LiveData<List<TimeTable>>? = null
var job: Job? = null
fun update() = viewModelScope.launch { // Jobの管理をしたいので、launchします
val routesValue = routes.value ?: return@launch
val departureBusStopNameValue = departureBusStopName.value ?: return@launch
?.let {
source(it)
removeSource}
= repository.getObservableTimeTablesByRoutesAndDepartureBusStop(routesValue, departureBusStopNameValue).also {
source (it) { sourceValue ->
addSource= sourceValue
value }
}
?.cancelAndJoin()
job
= viewModelScope.launch {
job .syncTimeTables(routesValue)
repository}
}
(routes) { update() }
addSource(departureBusStopName) { update() }
addSource}
val timeTableDetails = MediatorLiveData<List<TimeTableDetail>>().apply {
var source: LiveData<List<TimeTableDetail>>? = null
fun update() {
val timeTablesValue = timeTables.value ?: return
val busStopPolesValue = busStopPoles.value ?: return
?.let {
source(it)
removeSource}
= repository.getObservableTimeTableDetailsByTimeTablesAndBusStopPoles(timeTablesValue, busStopPolesValue).also {
source (it) { sourceValue ->
addSource= sourceValue
value }
}
}
(timeTables) { update() }
addSource(busStopPoles) { update() }
addSource}
val buses = MediatorLiveData<List<Bus>>().apply {
var job: Job? = null
fun update() = viewModelScope.launch { // Jobの管理をしたいので、launchします
val routesValue = routes.value ?: return@launch
val routeBusStopPolesValue = routeBusStopPoles.value ?: return@launch
?.cancelAndJoin()
job
= viewModelScope.launch {
job while (true) {
= repository.getBuses(routesValue, routeBusStopPolesValue) ?: listOf()
value
(15000)
delay}
}
}
(routes) { update() }
addSource(routeBusStopPoles) { update() }
addSource}
val busApproaches = MediatorLiveData<List<BusApproach>>().apply {
fun update() {
val routesValue = routes.value ?: return
val routeBusStopPolesValue = routeBusStopPoles.value ?: return
val busStopPolesValue = busStopPoles.value ?: return
val timeTablesValue = timeTables.value // syncTimeTable()は時間がかかるので、
val timeTableDetailsValue = timeTableDetails.value // 時刻表がない場合はバス停の数で代用することにしてnullでも強行します。
val busesValue = buses.value ?: return
= busesValue.map { bus ->
value (
BusApproach.id,
bus// 時刻表から、あと何秒で到着するのかを計算します
?.find { timeTable -> timeTable.routeId == bus.routeId }?.let { timeTable ->
timeTablesValue?.filter { it.timeTableId == timeTable.id }?.sortedByDescending { it.order }?.takeWhile { it.busStopPoleId != bus.fromBusStopPoleId }?.zipWithNext()?.map { (next, prev) -> next.arrival - prev.arrival }?.sum()
timeTableDetailsValue},
.sortedByDescending { it.order }.let { it.first().order - it.find { routeBusStopPole -> routeBusStopPole.busStopPoleId == bus.fromBusStopPoleId }!!.order },
routeBusStopPolesValue.find { it.id == bus.routeId }!!.name,
routesValue.find { it.id == bus.fromBusStopPoleId }!!.busStopName
busStopPolesValue)
}.sortedWith(compareBy({ it.willArriveAfter ?: Int.MAX_VALUE }, { it.busStopCount }))
}
(routes) { update() }
addSource(routeBusStopPoles) { update() }
addSource(busStopPoles) { update() }
addSource(timeTables) { update() }
addSource(timeTableDetails) { update() }
addSource(buses) { update() }
addSource}
fun toggleBookmark() {
.launch {
viewModelScopeval departureBusStopNameValue = departureBusStopName.value ?: return@launch
val arrivalBusStopNameValue = arrivalBusStopName.value ?: return@launch
.toggleBookmark(departureBusStopNameValue, arrivalBusStopNameValue)
repository}
}
}
うん、本当に面倒くさかった……。
でも、departureBusStopName
とarrivalBusStopName
、bookmark
、routes
、routeBusStopPoles
、busStopPoles
、timeTableDetails
については、これまでに説明したやり方をそのまま繰り返しただけなので簡単です。
少し難しいのはtimeTables
プロパティとbuses
プロパティ、busApproaches
プロパティです。Repository
のsyncTimeTables()
メソッドを実行しないとデータベースは空なので、だからどこかでsyncTimeTables()
を呼ばなければtimeTables
はいつまでも空集合のまま。あと、buses
はWebサービスから取得する値で、変更通知が来ませんから自分で値を定期的に更新しなければなりません。バスの接近情報そのものであるbusApproaches
は、全ての情報を手作りしなければなりませんし……。
というわけで、まずはtimeTables
プロパティから。syncTimeTables()
を呼ぶのに一番良いタイミングはroutes
プロパティとdepartureBusStopName
プロパティの両方に値が設定された時なので、MediatorLiveData<List<TimeTable>>().apply { ... }
の中にsyncTimeTables()
を呼び出す処理を入れたい。値を取得する処理の中に更新処理を入れるのは目的違いな気もするけど、他に適当な場所が無いから我慢。で、syncTimeTables()
はコルーチンで呼び出さなければならないのでviewModelScope.launch { ... }
して呼び出します。今回はroutes
もdepartureBusStopName
も変更がないので実は考慮しなくても動作するのですけど、念の為に、syncTimeTables()
している最中にもう一度viewModelScope.launch { ... }
が動いてしまっても二重に処理されないための考慮をしておきたい。というわけで、source
と同様にjob
という変数を作成して、cancelAndJoin()
でキャンセルされるまで待つようにしました。cancelAndJoin()
もsuspend
なメソッドなので、update()
全体をviewModelScope.launch { ... }
で囲んでいます。これでtimeTables
プロパティは完成。syncTimeTables()
が終わればデータベースが更新されたことがLiveDataで通知されて、その結果としてtimeTables
プロパティに値が設定されます。
buses
プロパティも同じやり方で作りました。こちらは15,000ミリ秒の間隔で繰り返される無限ループです。コルーチンがあると気軽に無限ループが使えて便利ですな。
最後のbusApproaches
プロパティはゴリゴリにロジックを書いて実現しました。で、説明を忘れていたのですけど、このプロパティを作成する前にバスの接近情報を表現するBusApproach
を追加しておきます。
package com.tail_island.jetbus.model
data class BusApproach(
val id: String,
val willArriveAfter: Int?, // あと何秒で到着するか
val busStopCount: Int, // 出発バス停までのバス停の数
val routeName: String, // 路線の名称
val leftBusStopName: String // バスが最後に出発したバス停の名称
)
何秒後に到着するのかを表現するwillArriveAfter
プロパティに加えてbusStopCount
プロパティがあるのは、TimeTable
を同期するsyncTimeTables()
はかなり時間がかかるので、それまでの間はあといくつバス停があるのかでなんとなく到着時刻を判断していただくためです。と、こんな感じにTimeTable
がまだない場合も想定しているので、busApproaches
プロパティの中のtimeTablesValue
とtimeTableDetailsValue
を設定する部分では、null
の場合にリターンする処理が省かれているわけですな。
さて、busApproaches
プロパティでは、buses
の要素であるBus
単位でBusApproach
を作成します。関数型プログラミングのmap
ですね。willArriveAfter
は、TimeTableDetail(出発バス停から手前10バス停分が入っている)をsortedByDescending()
で逆順にソートして、takeWhile
でbus.fromBusStopPoleId
と異なる間だけ取得します。これで、出発バス停からバスが最後に出発したバス停の次のバス停までを入手できるというわけ。あとは、zipWithNext()
でペアを作成して、時刻の差を求めて、合計する。これで、何秒後に到着するのかの情報を取得できました! 関数型プログラミングは本当に楽ちんですな。
Fragmentを作成する
もう終わったも同然。機械作業でRecyclerViewを組み込みます。
まずはlist_item_bus_approaches.xml。
<?xml version="1.0" encoding="utf-8"?>
layout
< xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
data>
<variable name="item" type="com.tail_island.jetbus.model.BusApproach" />
<data>
</
androidx.constraintlayout.widget.ConstraintLayout
< android:layout_width="match_parent"
android:layout_height="wrap_content">
ImageView
< android:id="@+id/imageView"
android:layout_width="32dp"
android:layout_height="32dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/routeNameTextView"
app:layout_constraintBottom_toBottomOf="@id/leftBusStopName"
android:src="@drawable/ic_bus" />
TextView
< android:id="@+id/routeNameTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
app:layout_constraintStart_toEndOf="@id/imageView"
app:layout_constraintEnd_toStartOf="@id/willArriveAfterTextView"
app:layout_constraintTop_toTopOf="parent"
android:text="@{item.routeName}" />
TextView
< android:id="@+id/leftBusStopNameTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintStart_toStartOf="@id/routeNameTextView"
app:layout_constraintEnd_toEndOf="@id/routeNameTextView"
app:layout_constraintTop_toBottomOf="@id/routeNameTextView"
android:text='@{String.format("%sを通過", item.leftBusStopName)}' />
TextView
< android:id="@+id/willArriveAfterTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/routeNameTextView"
app:layout_constraintBottom_toBottomOf="@id/leftBusStopName"
android:textColor="@color/colorAccent"
android:textSize="28sp"
android:text='@{item.willArriveAfter == null ? "-" : String.format("%d 分", safeUnbox(item.willArriveAfter) / 60)}' />
View
< android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="16dp"
app:layout_constraintTop_toBottomOf="@id/leftBusStopName" />
androidx.constraintlayout.widget.ConstraintLayout>
</
layout> </
少しだけ見た目にこだわってみました。Google様が用意してくださっているアイコンの中からバスのアイコンを探し出してic_bus
を登録して<ImageView>
タグで表示しています。routeNameTextView
で路線名を、leftBusStopNameTextView
でどのバス停を出たところなのか、willArriveAfterTextView
であと何分で到着するのかを表示します。あ、最後に追加してある<View>
タグは、強制的に隙間を作るためのものです……。
BusApproachAdapter
はいつもどおりのコピー&ペースト。
package com.tail_island.jetbus.adapter
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.tail_island.jetbus.databinding.ListItemBusApproachBinding
import com.tail_island.jetbus.model.BusApproach
class BusApproachAdapter: ListAdapter<BusApproach, BusApproachAdapter.ViewHolder>(DiffCallback()) {
class ViewHolder(private val binding: ListItemBusApproachBinding): RecyclerView.ViewHolder(binding.root) {
inner fun bind(item: BusApproach) {
.item = item
binding.executePendingBindings()
binding}
}
class DiffCallback: DiffUtil.ItemCallback<BusApproach>() {
override fun areItemsTheSame(oldItem: BusApproach, newItem: BusApproach) = oldItem.id == newItem.id
override fun areContentsTheSame(oldItem: BusApproach, newItem: BusApproach) = oldItem == newItem
}
override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): ViewHolder = ViewHolder(ListItemBusApproachBinding.inflate(LayoutInflater.from(viewGroup.context), viewGroup, false))
override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) = viewHolder.bind(getItem(position))
}
fragment_bus_approaches.xmlにRecyclerView
を追加します。
<?xml version="1.0" encoding="utf-8"?>
layout
< xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
tools:context=".DepartureBusStopFragment">
data>
<variable name="viewModel" type="com.tail_island.jetbus.view_model.BusApproachesViewModel" />
<data>
</
androidx.constraintlayout.widget.ConstraintLayout
< android:layout_width="match_parent"
android:layout_height="match_parent">
TextView
< android:id="@+id/departureBusStopAndArrivalBusStopTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:text='@{String.format("%s %s %s", viewModel.departureBusStopName, @string/start_to_end_arrow, viewModel.arrivalBusStopName)}' />
ImageView
< android:id="@+id/bookmarkImageView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
app:layout_constraintTop_toTopOf="@id/departureBusStopAndArrivalBusStopTextView"
app:layout_constraintBottom_toBottomOf="@id/departureBusStopAndArrivalBusStopTextView"
app:layout_constraintEnd_toEndOf="parent"
android:src='@{viewModel.bookmark != null ? @drawable/ic_bookmark_on : @drawable/ic_bookmark_off}' />
androidx.recyclerview.widget.RecyclerView
< android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:paddingBottom="16dp"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:clipToPadding="false"
android:layout_marginTop="16dp"
app:layout_constraintTop_toBottomOf="@id/departureBusStopAndArrivalBusStopTextView"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
TextView
< android:id="@+id/noBusApproachesTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:layout_constraintStart_toStartOf="@id/departureBusStopAndArrivalBusStopTextView"
app:layout_constraintTop_toBottomOf="@id/departureBusStopAndArrivalBusStopTextView"
android:visibility="gone"
android:text="@string/no_bus_approaches" />
androidx.constraintlayout.widget.ConstraintLayout>
</
layout> </
データ件数が0件だった場合に画面に何も表示されないと混乱するので、noBusApproachesTextView
で「接近中のバスはありません」と表示するようにしています。
これで最後のBusApproachesFragment
は、処理の大部分を他のレイヤに移動させているのでとても簡単です。
package com.tail_island.jetbus
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Observer
import androidx.navigation.fragment.navArgs
import com.tail_island.jetbus.adapter.BusApproachAdapter
import com.tail_island.jetbus.databinding.FragmentBusApproachesBinding
import com.tail_island.jetbus.view_model.BusApproachesViewModel
import javax.inject.Inject
class BusApproachesFragment: Fragment() {
@Inject lateinit var viewModelProviderFactory: AppViewModelProvideFactory
private val viewModel by viewModels<BusApproachesViewModel> { viewModelProviderFactory }
private val args by navArgs<BusApproachesFragmentArgs>()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
(requireActivity().application as App).component.inject(this)
.departureBusStopName.value = args.departureBusStopName
viewModel.arrivalBusStopName.value = args.arrivalBusStopName
viewModel
return FragmentBusApproachesBinding.inflate(inflater, container, false).apply {
= viewLifecycleOwner
lifecycleOwner = this@BusApproachesFragment.viewModel
viewModel
.setOnClickListener {
bookmarkImageView@BusApproachesFragment.viewModel.toggleBookmark()
this}
.adapter = BusApproachAdapter().apply {
recyclerView@BusApproachesFragment.viewModel.busApproaches.observe(viewLifecycleOwner, Observer {
this// データ件数が0件だった場合のViewの可視性を適切に設定します
.visibility = if (it.isEmpty()) { View.VISIBLE } else { View.GONE }
noBusApproachesTextView
(it)
submitList})
}
}.root
}
}
これだけで……はい、動きました!
これで通勤が楽になって、ダメ人間の私でももう少しサラリーマンを続けられそうです。Android Jetpackありがとー!
Image Asset Studioでアイコンを作れば、完成!
でも、まだ不十分。アプリのアイコンを作らないとね。Android Studioのメニューから[File] - [New] - [Image Asset]メニューを選んで、アプリのアイコンを作成しましょう。
私は絵心がないのから、Android StudioのClip Artの流用で作るんだけどな。[Source Asset]の[Asset Type]の[Clip Art]ラジオ・ボタンをクリックすれば、Android Studioが提供するアイコンから選び放題です。その中から、バスのアイコンを選択しましょう。
色を設定しましょう。[Color]をクリックして、今回は真っ白にしたいので「FFFFFF」を入力します。
背景の絵を作るデザインス・センスもありませんので、背景は単色にしました。[Background
Layer]タブを選択して、Colorをクリックします。背景色は、アプリのcolorPrimaryDark
に設定した色の「006428」です。この色はColor
Toolで作成しています。Primary Colorに適当な色を設定するだけでPrimary
Dark
Colorが作成されてとても便利ですよ。あとは、[Next]ボタンをクリックして[Finish]ボタンをクリックして、ビルドしてインストールして、でも何故かアプリのアイコンが変わりません……。
その理由は、drawableのリソースのXMLにはAndroidのバージョンによって記述できる事柄に差異があって、Android Studioがデフォルトで作成したアプリのアイコンは新しいバージョン向け、Image Assetで先程作成したアプリのアイコンは古いバージョン向けのファイルを作成していたため。というわけで、不要になった以前のdrawableを消しちゃいましょう。app/src/main/res/drawable-v24フォルダをまるっと削除しました。
で、さらにもう一つ。今回は単色の背景にしたので、app/src/main/res/valuesフォルダの中にic_launcher_background.xmlが生成されています。でも、以前のアイコンはいろいろ描いてあるベクトル・グラフィックスだったので、app/src/main/res/drawablesにあったんですよ。以前のアイコン向けの背景が悪さをするのを防ぐために、app/src/main/res/drawable/ic_launcher_background.xmlも削除しました。
以上! これで本当の本当に完了です。
いろいろあったけど、Androidアプリの開発ってべつに難しくない、というか、むしろ楽チンで美味しいでしょ?