「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が遷移していくように作ることになって、結果としてActivityFragmentにコードが分散して見通しが悪くなるだけになっていませんでしたか? この問題は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]を選択します。

Welcome to Android Studio

作成するプロジェクトは、余計なコードが生成されない「Empty Activity」にします。

Select a Project Template

[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で権限確認の方法が変更になったので、これ以前のバージョンだと権限の確認の処理が面倒なためです(今回は関係ないけどね)。古い端末を平気で使う海外までを対象にしたアプリを作るならもっと古いバージョンにすべきでしょうけど、国内が対象なら、まぁ大丈夫じゃないかな。

Create a New Project

ともあれ、これで無事にプロジェクトが作成されました。でも、まだ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です。基本はこのリリース・ノートの記載に従うのですけど、いくつか注意点があります。

と、以上の注意を踏まえて、Android Studioの[Project]ビューのbuild.gradeをダブル・クリックして開いて、修正してみましょう。以下が修正した結果で、修正した行は、修正内容をコメントで書いています。

buildscript {
    ext.kotlin_version = '1.3.61'
    repositories {
        google()
        jcenter()
    }
    dependencies {
        classpath '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'  // 追加
    }
}

allprojects {
    repositories {
        google()
        jcenter()
    }
}

task clean(type: Delete) {
    delete rootProject.buildDir
}

※「(Project: jetbus)」と書いてある方のbuild.gradle

apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'                          // 追加
apply plugin: 'androidx.navigation.safeargs.kotlin'  // 追加

android {
    compileSdkVersion 29
    buildToolsVersion "29.0.3"

    defaultConfig {
        applicationId "com.tail_island.jetbus"
        minSdkVersion 23
        targetSdkVersion 29
        versionCode 1
        versionName "1.0"
        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
    }

    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }

    kotlinOptions {        // 追加
        jvmTarget = '1.8'  // 追加
    }                      // 追加

    dataBinding {          // 追加
        enabled true       // 追加
    }                      // 追加
}

dependencies {
    kapt 'androidx.lifecycle:lifecycle-compiler:2.2.0'                  // 追加
    kapt 'androidx.room:room-compiler:2.2.4'                            // 追加

    implementation 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"

    testImplementation 'junit:junit:4.12'

    androidTestImplementation 'androidx.test.ext:junit:1.1.1'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
}

※「(Module: app)」と書いてある方のbuild.gradle

これで、デモ・アプリに必要なJetpackのライブラリの組み込みが完了しました。やっとプログラミングです。最初は、作成してやった感が大きそうな、画面まわりをやってみましょう。

Navigation

まぁ、画面といっても、かっこいい画面を作るほうではなく、ダミー画面を使用した画面遷移の実装という、見栄えがあまりよくない部分のプログラミングだけどな。画面遷移なので、Navigationが火を吹きますよ。

ActivityFragmentとNavigation

さて、例によって歴史の話から。ActicityFragmentの話、あと、Navigationが作られた経緯です。

Activityってのは、ぶっちゃけアプリの画面1つ分です。で、一般にアプリは複数のActivityで構成されます。Androidはアプリ間連携(アプリを操作していたら別のアプリの画面に遷移して、で、戻るボタンで最初のアプリに戻れる)ができるところがとても素晴らしいと私は思っているのですけど、この機能はActivityの遷移として実現されています。同様に、アプリ内でもActivityの遷移で画面遷移を実現していました。

で、大昔のスマートフォンのアプリの単純な画面だったらこれであまり問題なかったんですけど、高機能なアプリを作ったりタブレットのような大きな画面を効率よく使おうとしたりする場合は、この方式だとコードの重複という問題が発生してしまうんです。タブレットの大きな画面では、左に一覧表示して、右にその詳細を表示するような画面が考えられます。でも、スマートフォンの小さな画面では、一覧表示する画面と、それとは別の詳細を表示する画面に分かれて、画面遷移する形で表現しなければなりません。

フラグメントで定義された2つのUIモジュールを1つのアクティビティに組み合わせたタブレット用デザインと、それぞれを分けたハンドセット用デザインの例
https://developer.android.com/guide/components/fragments?hl=ja

これをActivityで実現しようとすると、タブレット用のActivityを1つと、スマートフォン用のActicvityを2つ作らなければなりません。そして、ほとんどのコードは重複してしまうでしょう。ユーザー・インターフェースの問題ならView(ユーサー・インターフェースのコンポーネント)で解決すれば……って思うかもしれませんけど、画面に表示するデータをデータベースから取得してくるような機能をViewに持たせるのは、Viewの責任範囲を逸脱しているのでダメです。

ではどうすればよいかというと、Activityの構成要素になりえる「何か」を追加してあげればよい。この「何か」こそが、Fragmentです。

でもね、コードの重複が発生しないようなケースでFragmentを使ってActivityで画面遷移をさせると、Activityのコードの多くをFragmentに移して、で、ActivityFragmentを管理するコードを追加して、そしてもちろんFragmentにも自分自身の初期化処理等が必要となって、結局、コード量が増えただけで誰も得しないという状況になってしまうんです。

これでは無意味なので、1つのActivityの中で、複数のFragmentが遷移するというプログラミング・スタイルが編み出されました。このスタイルを実現するのがFragmentTransactionというAPIで、ActicityからFragmentを削除したり追加したり入れ替えたりできます。なんと[戻る]ボタンへの対応機能付き……なのですけど、高機能な分だけ使い方が複雑で、Activityの遷移(Intentのインスタンスを引数にstartActivity()するだけ)と比べると面倒だったんですよ。

なので、Navigationが作成されました。GUIツール(私はほとんど使わないけど)で画面遷移を定義できるかっこいいライブラリです。ただ単にFragmentTransactionを呼び出しているだけな気もしますけど、まぁ、いろいろ楽チンなので良し。SafeArgsという便利機能もありますしね。

プロジェクトにFragmentを追加する

以上により、画面遷移はFragment遷移ということになりました。だから、プロジェクトにActivityではなくFragmentを追加しましょう。jetbusに必要なFragmentは、以下の5つとなります。

さっそく追加します。プロジェクトを右クリックして、[New] - [Fragment] - [Fragment (Blank)]メニューを選択してください。

new-fragment-fragment

Fragmentの名前を入力して、[Include fragment factory methods?]チェックボックスを外して、[Finish]ボタンを押します(このチェックボックスを外さないと、余計なコードが生成されてしまうためです。まぁ、生成されたとしても、そのコードを消せば良いだけなんですけど)。この手順を5回繰り返して、必要なFragmentをすべて生成してください。

Configure Component

ふう、完了。

さて、Navigation。Navigationでは、画面の遷移をres/navigationの下のXMLファイルで管理します。このファイルを作りましょう。プロジェクトを右クリックして、[File] - [New] - [Android Resource Directory]メニューを選びます。

New Resource Direcotry

[Resource type]を「navigation」にして、[OK]ボタンを押してください。これで、navigationディレクトリが生成されます。

次。画面の遷移を管理するXMLファイルです。プロジェクトを右クリックして、[File] - [New] - [Android Resource File]メニューを選びます。

New 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_widthandroid:layout_heightです。

android:layout_widthandroid:layout_heightは、UIコンポーネントの幅と高さとなります。ここに指定するのは、具体的な大きさ(64dpなど)か、match_parentwrap_contentになります。match_parentは階層上の親と同じところまで大きくするようにとの指示、wrap_contentはコンテンツが入る大きさでお願いしますという指示です。

ConstraintLayout

あと、AndroidのUIコンポーネントでは、他のコンポーネントを子要素として持てるものをViewGroupViewGroupを継承して何らかのレイアウト機能を追加したものを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"にするというわけ。

これで一般論が終わりました。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 {
            departureBusStopButton.setOnClickListener {
                findNavController().navigate(BookmarksFragmentDirections.bookmarksFragmentToDepartureBusStopFragment())
            }

            busApproachesButton.setOnClickListener {
                findNavController().navigate(BookmarksFragmentDirections.bookmarksFragmentToBusApproachesFragment("日本ユニシス本社前", "深川第八中学校前"))
            }
        }.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()メソッドとか、FragmentViewを生成するときに呼ばれるonCreateView()メソッドとか。で、一般にFragmentの初期化というのは画面の初期化なので、画面を構成するViewがない状態では初期化の作業ができません。だから、onCreate()メソッドではなくて、onCreateView()メソッドに初期化のコードを書きます。

さて、onCreateView()メソッドでやらなければならない作業は、FragmentViewの生成です。Android Studioが生成するFragmentのコードでは、引数で渡ってくるinflater: LayoutInflaterinflate()メソッドをレイアウトXMLを引数にして呼び出すコードが生成されるのですけど、将来のデータ・バインディングのために、FragmentBookmarksBindinginflate()メソッドを使用します。で、...Bindinginflate()メソッドで生成されるのは...Bindingなので、Viewであるrootプロパティを返します。コードにすると、こんな感じ。

return FragmentBookmarksBinding.inflate(inflater, container, false).root

スコープ関数

でもね、私達は画面の初期化をしたいわけで、初期化にはボタンが押されたときのListenerの登録等も含まれます。以下のようなコードになるでしょうか。

val binding = FragmentBookmarksBinding.inflate(inflater, container, false)

binding.departureBusStopButton.setOnClickListener {
    // ボタンが押された場合の処理
}

return binding.root

...Bindingでは、レイアウトXMLで定義されたUIコンポーネントをandroid:idと同じプロパティ名で参照するためのコードが生成されていますから、binding.departureBusStopButtonで画面のボタンを参照できます。ボタンがクリックされたときのリスナーを設定するメソッドはsetOnClickListener()で、その引数は関数。で、関数はラムダ式で定義することができます。あと、Kotlinには、最後のパラメータがラムダ式の場合はそのパラメーターはカッコの外に指定するという慣習があります。さらに、ラムダ式だけを引数にする場合は、括弧を省略できる。というわけで、上のようなシンプルなコードになるわけですな。

ただね、このようなコードはよく見るのですけど、実はこれ、とても悪いコードなんです。その理由は、ローカル変数(val binding)を使っているから(グローバル変数を使えと言っているわけじゃないですよ、念の為)。

ローカル変数は、そのブロックを抜けるまで有効です。長期間に渡ってコードに影響を与えるというわけ。valにしてイミュータブル(不変)にした場合でも、状態遷移という影響は減るけれども変数があることを覚えておかなければならないのは一緒で、コードを読むのが大変になってしまう。私のような記憶力が衰えまくっているおっさんには、ローカル変数は辛いんですよ。だから、変数のスコープを小さくします。関数の最初で変数を宣言しなければならなかくてその変数が関数の最後まで有効な大昔のプログラミング言語より、どこでも変数を宣言できてブロックが終わると変数のスコープが切れる今どきの言語の方が使いやすいですよね? 変数のスコープは、小さければ小さいほど良いんです。

と、このような、変数のスコープを小さく、かつ、分かりやすい形で制御したい場合に使えるKotlinの便利ライブラリが、スコープ関数なんです。

たとえば、foo()の戻り値を使用したい場合は、

val x = foo()

x.bar()

と書くのではなくて、

foo().let { it.bar() }

と書きます。let()スコープ関数は、自分自身を引数にしたラムダ式を呼び出すというわけ。あ、ラムダ式の中のitは、ラムダ式のパラメーターを宣言しなかった場合の暗黙の名前です。let()スコープ関数は自分自身を引数にしますから、この場合のitfoo()の戻り値になります。

他のスコープ関数には、apply()thisを自分自身に設定したラムダ式を呼び出して、自分自身を返す。初期化等で便利)、also()(自分自身を引数にしたラムダ式を呼び出して、自分自身を返す。自分自身を使う他のオブジェクトの初期化等で便利)、run()(自分自身をthisにしたラムダ式を実行して、ラムダ式の戻り値を返す。その場で関数を定義して実行する感じ)があります。スコープ関数はとにかく便利ですから、ぜひ使い倒してください。

というわけで、今回はapply()を使用して、FragmentBookmarksBindingのボタンへのリスナー登録という初期化処理を書きましょう。

return FragmentBookmarksBinding.inflate(inflater, container, false).apply {
    departureBusStopButton.setOnClickListener {
        // ボタンが押された場合の処理
    }
}.root

apply()なのでthisFragmentBookmarksBindingになっていて、だからFragmentBookmarksBindingdepartureBusStopButtonに修飾なしでアクセスできて便利。スコープの範囲がインデントされて判別しやすいのも、認知機能が衰えた私のようなおっさんには嬉しいです。

画面遷移

Navigationでの画面の遷移は、findNavController()で取得できるNavControllerクラスのnavigate()メソッドで実施します。リファレンスを見てみるとnavigation()メソッドにはオーバーロードされた様々なバリエーションがあって、色々な引数を取れるようになっています。今回この中で注目していただきたいのは、NavDirectionsを引数に取るバージョンです。ナビゲーションのXMLで<action>を作成するとこのNavDirectionsが自動生成されるので、とても簡単に呼び出せます。というわけで、画面遷移のコードは以下のようになります。

findNavController().navigate(BookmarksFragmentDirections.bookmarksFragmentToDepartureBusStopFragment())

あと、記憶力が良い方は、ナビゲーションのXMLを作成したときに、<argument>Fragmentへの遷移のパラメーターを定義したことを覚えているかもしれません。<argument>を定義しておくと、NavDirectionsを生成するときにパラメーターを追加してくれます。記憶力が壊滅している私のようなおっさんの場合でも、パラメーターを指定しないとコンパイル・エラーになるから思い出せますな。というわけで、バス接近情報に画面遷移する場合は以下のようなコードになります。

findNavController().navigate(
    BookmarksFragmentDirections.bookmarksFragmentToBusApproachesFragment("日本ユニシス本社前", "深川第八中学校前")
)

引数は、プログラムを書く以外はあまねくダメダメと評判のこんな私を雇ってくれる奇特な会社から私の家まで帰るルートですな。

こんな感じですべてのFragmentをプログラミングして、動かしてみると以下のようになります。

Movie #1

[到着バス停]画面から[バス接近情報]画面に遷移した後、[戻る]ボタンを押すとレイアウトのXMLのapp:popUpTo属性が効いて、[ブックマーク一覧]画面に戻ってくれていてとても嬉しい。

でも、なんか画面が寂しい気がします……。どうにかならないかな? あと、ナビゲーションのXMLでわざわざ設定したandroid:label属性はどうなったのでしょうか? どの画面でも、画面上部には「jetbus」って表示されているんだけど。

App barとNavigation drawer

さて、AndroidアプリのUIは、Material Designというデザイン・ガイドに従うことになっています。このMaterial Designにはいろいろなコンポーネントがあるのですけど、App barというコンポーネントで画面の情報を表示して、Navigation drawerでメニューを実現する方式が一般的みたいです。

App bars:top
https://storage.googleapis.com/spec-host/mio-staging%2Fmio-components%2F1584058305895%2Fassets%2F1ekbPWQqJ5sMNvJ0om7XelfzOhaWMaeyM%2Ftopappbars-howtouse-1.png

Navigation drawer
https://storage.googleapis.com/spec-host/mio-staging%2Fmio-components%2F1584058305895%2Fassets%2F1nsuL8VDpBW_LZYXgabK1H0uq6icmmKYt%2Fnav-drawer-intro.png

今の画面でも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はヘッダーとメニューとそれ以外で構成されていて、それ以外は子要素で定義したので、ヘッダーとメニューを定義しなければならないんですね。

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を生成します
        binding = DataBindingUtil.setContentView<ActivityMainBinding>(this, R.layout.activity_main).also {
            // NavigationControllerを初期化します
            findNavController(R.id.navHostFragment).apply {
                // レイアウトで設定したToolbarとDrawerLayoutと協調させます。また、BookmarksFragmentをルートにします。itは、ActivityMainBindingのインスタンスです
                NavigationUI.setupWithNavController(it.toolbar, this, AppBarConfiguration(setOf(R.id.bookmarksFragment), it.drawerLayout))

                // SplashFragmentでは、ツールバーを非表示にします
                addOnDestinationChangedListener { _, destination, _ ->
                    it.appBarLayout.visibility = if (destination.id == R.id.splashFragment) View.GONE else View.VISIBLE
                }
            }
        }
    }

    // 不整合の辻褄をあわせます。なんで我々がと思うけど我慢……。
    override fun onBackPressed() {
        if (binding.drawerLayout.isDrawerOpen(GravityCompat.START)) {  // Navigation drawerが開いているときは、[戻る]ボタンでクローズします
            binding.drawerLayout.closeDrawer(GravityCompat.START)
            return
        }

        if (findNavController(R.id.navHostFragment).currentDestination?.id == R.id.bookmarksFragment) {  // AppBarConfiguration()したのでツールバーはハンバーガー・アイコンになっていますが、それでもバック・ボタンでは戻れちゃうので、チェックします
            finish()
            return
        }

        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がありませんでしたから。

ともあれ、これで作業完了のはず。試してみましょう

Movie #1

うん、完璧ですな。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'
    implementation '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"

    ...
}

これで、いつでも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つの情報を取得できるみたい。

バスの運行情報がありますから、バスの車両の接近情報は表示できそうですね。なんかいろいろ細かいことが書いてありますけど、習うより慣れろってことで、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:BusstopPoleodpt:BusroutePatternowl: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:BusTimetableodpt:calendar属性(平日時刻表とか休日時刻表とか)は[2.3. 定義]の中の[2.3.1. odpt:Calendar]に書いてある汎用データを使用していませんでした。なんだか、都営バス独自の特殊なデータが並んでいやがります……。まぁ、今回のodbt:BusTimetableの使用目的は到着時間を計算するための元ネタでしかありませんから、odpt:calendarも無視することにしましょう。プログラムが簡単になるしね。

そうそう、odpt:BusstopPoleodpt:BusroutePatternodpt: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で宣言するのですけど、varvalは先のコードのようにプロパティの定義でも使えます(varだとgetsetが生成されて、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:BusstopPoleodpt:BusroutePatternodpt: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 {
            baseUrl("https://api.odpt.org")
            addConverterFactory(GsonConverterFactory.create())
        }.build().create(WebService::class.java)

        // Webサービスを呼んでいる間は画面が無反応になるのでは困るので、スレッドを生成します。今は素のスレッドを使用していますけど、後でもっとかっこいい方式をご紹介しますのでご安心ください
        thread {
            try {
                // WebServiceを呼び出します
                val response = webService.busstopPole(consumerKey).execute()

                // HTTP通信した結果が失敗の場合は、エラーをログに出力してnullを返します
                if (!response.isSuccessful) {
                    Log.e("SplashFragment", "HTTP Error: ${response.code()}")
                    return@thread
                }

                // レスポンスのボディは、interfaceの定義に従ってJsonArrayになります
                val busStopPoleJsonArray = response.body()

                // nullチェック
                if (busStopPoleJsonArray == null) {
                    return@thread
                }

                // JsonObjectに変換して、都営バスのデータだけにフィルターして、最初の10件だけで、ループします
                for (busStopPoleJsonObject in busStopPoleJsonArray.map { it.asJsonObject }.filter { it.get("odpt:operator").asString == "odpt.Operator:Toei" }.take(10)) {
                    // 確認のために、いくつかの属性をログ出力します
                    Log.d("SplashFragment", "${busStopPoleJsonObject.get("owl:sameAs")}")
                    Log.d("SplashFragment", "${busStopPoleJsonObject.get("dc:title")}")
                    Log.d("SplashFragment", "${busStopPoleJsonObject.get("odpt:kana")}")
                }

            } catch (e: IOException) {
                // HTTP以前のエラーへの考慮も必要です。ログ出力しておきます
                Log.e("SplashFragment", "${e.message}")
            }
        }
    }
}

「公共交通オープンデータセンターへのユーザー登録」で設定した文字列リソースは、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のResponsebody()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) {
        Log.e("SplashFragment", "HTTP Error: ${response.code()}")
        return@run null
    }

    response.body()  // ラムダ式では、最後の式の結果がラムダ式の戻り値になります

}?.let { busStopPoleJsonArray ->  // ?.なので、run { ... }の結果がnullならlet { ... }は実行されません。
   for (busStopPoleJsonObject in busStopPoleJsonArray.map { it.asJsonObject }.filter { it.get("odpt:operator").asString == "odpt.Operator:Toei" }.take(10)) {
        Log.d("SplashFragment", "${busStopPoleJsonObject.get("owl:sameAs")}")
        Log.d("SplashFragment", "${busStopPoleJsonObject.get("dc:title")}")
        Log.d("SplashFragment", "${busStopPoleJsonObject.get("odpt:kana")}")
    }
} ?: return@thread  // run { ... }の結果がnullならリターン

うん、マシになりました。でもまだ駄目です。他のWebサービスも呼び出す場合は、run { ... }の中のほとんどをもう一回書かなければならないでしょうから。

高階関数を作ってみる

というわけで、関数化しましょう。こんな感じ。

private fun <T> getWebServiceResultBody(callWebService: () -> Call<T>): T? {
    val response = callWebService().execute()

    if (!response.isSuccessful) {
        Log.e("SplashFragment", "HTTP Error: ${response.code()}")
        return null
    }

    return response.body()
}

引数は関数です。このような関数を引数にする関数を高階関数と呼びます。Kotlinでは、(引数) -> 戻り値で関数そのものの型を表現できて、上のコードの<T>の部分はテンプレートです。呼び出し側はこんな感じ。

getWebServiceResultBody { webService.busstopPole(consumerKey) }?.let { busStopPoleJsonArray ->
    for (busStopPoleJsonObject in busStopPoleJsonArray.map { it.asJsonObject }.filter { it.get("odpt:operator").asString == "odpt.Operator:Toei" }.take(10)) {
        Log.d("SplashFragment", "${busStopPoleJsonObject.get("owl:sameAs")}")
        Log.d("SplashFragment", "${busStopPoleJsonObject.get("dc:title")}")
        Log.d("SplashFragment", "${busStopPoleJsonObject.get("odpt:kana")}")
    }
}

もはや見慣れたコードですな。前にも述べましたけど、関数はラムダ式で定義することができて、最後のパラメーターがラムダ式の場合はそのパラメーターは括弧の外にだす慣習があって、そして、ラムダ式だけを引数にする場合は括弧を省略できるので、このようなすっきりした記述になります。

ついでですから、odpt:BusroutePatternを取得して、その路線のodpt:Busを取得するコードも書いてみましょう。

getWebServiceResultBody { webService.busroutePattern(consumerKey) }?.let { busroutePatternJsonArray ->
    for (busroutePatternJsonObject in busroutePatternJsonArray.map { it.asJsonObject }.filter { it.get("odpt:operator").asString == "odpt.Operator:Toei" }.take(10)) {
        Log.d("SplashFragment", "${busroutePatternJsonObject.get("owl:sameAs")}")
        Log.d("SplashFragment", "${busroutePatternJsonObject.get("dc:title")}")

        for (busstopPoleOrderJsonObject in busroutePatternJsonObject.get("odpt:busstopPoleOrder").asJsonArray.take(10).map { it.asJsonObject }) {
            Log.d("SplashFragment", "${busstopPoleOrderJsonObject.get("odpt:index")}")
            Log.d("SplashFragment", "${busstopPoleOrderJsonObject.get("odpt:busstopPole")}")
        }

        getWebServiceResultBody { webService.bus(consumerKey, busroutePatternJsonObject.get("owl:sameAs").asString) }?.let { buses ->
            for (bus in buses.take(10)) {
                Log.d("SplashFragment", bus.id)
                Log.d("SplashFragment", bus.routeId)
                Log.d("SplashFragment", bus.fromBusStopPoleId)
            }
        }
    }
}

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のログ

うん、正しくデータを取れていますね。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:BusstopPoleodpt:BusroutePatternの場合)より、JSONのデータをクラスにマッピングさせた場合(odpt:Busの場合)のほうがコードが簡単だったでしょ? あれと同じで、テーブルのレコードをインスタンスにマップしてくれるだけでも、プログラミングはとても楽になるんです。単純な機能しかないので使うの簡単ですし、SQLベタ書きなのでリレーショナル・データベースの機能を引き出しやすいですしね。

データ構造の設計

と、他のO/Rマッピング・ツール経験者への言い訳が終わったところで、作業に入りましょう。まずは、データ構造の設計です。私は設計文書を書かないでいきなりコードを書き出すタイプの人間ですけど、そんな私でもプログラミング前にデータ構造だけは設計します。ER図かUMLのクラス図を描くだけですけどね。こんなの。

ER図

今回は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
           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 = "日本ユニシス本社前" AND
      ArrivalBusStopPole.busStopName = "塩浜二丁目"

前の方でRoomにはテーブル間の関係を辿る機能がないと書きましたけど、このSQLのようにJOINで繋げば(もしくは、後の章で述べるように副問合せを使えば)他のテーブルのカラムを検索条件に指定することは可能です。だから、Roomにテーブルの間の関連を辿る機能がなくても大丈夫なんです。そもそも、この処理なんて、普通のO/Rマッピング・ツールで書くとかえって大変ですよ。出発のBusStopPoleの集合を作成してRouteBusStopPoleを経由してRouteの集合を取得し、到着でも同じ処理をして、それぞれのRouteの集合をunionして、さらにその後にorderを使用してフィルタリングするような処理になっちゃうでしょうからね。

さて、Routeが分かればTimeTableを取得できます。ただ、バスは一つの路線を日に何回も行き来していますから、RouteTimeTableの関係は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アノテーションのindextrueを指定しています。データベース管理者が検索のパフォーマンス・チューニングのときに貼るインデックスのことですな。

参照整合性というのは、テーブルのレコード間の関連を正しく保つ仕組みです。存在しない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でデフォルト値付きのプロパティを追加したというわけです。

以上でエンティティの作成に必要な知識は揃いましたので、同様にTimeTableTimeTableDetailBookmarkを定義しましょう。何も新しいことはしていませんので、コードは省略で。どうしても見たい場合は、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")
    fun getByName(name: String): BusStop?
}

データ・アクセス・オブジェクトであることを指示するために@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)
abstract class AppDatabase: RoomDatabase() {
    abstract fun getBookmarkDao(): BookmarkDao
    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 {
        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 {
            Log.d("SplashFragment", "Start.")

            // データを削除します。
            database.getTimeTableDetailDao().clear()
            database.getTimeTableDao().clear()
            database.getRouteBusStopPoleDao().clear()
            database.getRouteDao().clear()
            database.getBusStopPoleDao().clear()
            database.getBusStopDao().clear()

            // 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(
                        busStopPoleJsonObject.get("dc:title").asString,
                        busStopPoleJsonObject.get("odpt:kana")?.asString
                    ).also {
                        database.getBusStopDao().add(it)
                    }
                }

                BusStopPole(
                    busStopPoleJsonObject.get("owl:sameAs").asString,
                    busStop.name
                ).also {
                    database.getBusStopPoleDao().add(it)
                }
            }

            // Routeを登録します。
            for (routeJsonObject in routeJsonArray.map { it.asJsonObject}.filter { it.get("odpt:operator").asString == "odpt.Operator:Toei" }) {
                val route = Route(
                    routeJsonObject.get("owl:sameAs").asString,
                    routeJsonObject.get("dc:title").asString
                ).also {
                    database.getRouteDao().add(it)
                }

                for (routeBusStopPoleJsonObject in routeJsonObject.get("odpt:busstopPoleOrder").asJsonArray.map { it.asJsonObject }) {
                    RouteBusStopPole(
                        route.id,
                        routeBusStopPoleJsonObject.get("odpt:index").asInt,
                        routeBusStopPoleJsonObject.get("odpt:busstopPole").asString
                    ).also {
                        it.id = database.getRouteBusStopPoleDao().add(it)
                    }
                }
            }

            Log.d("SplashFragment", "Finish.")

        } catch (e: IOException) {
            Log.e("SplashFragment", "${e.message}")
        }
    }
}

少しだけ、解説を。

データベースのインスタンス作成は、Room.databaseBuilder(context!!, AppDatabase::class.java, "jetbus.db").build()でやっています。これで、データベースのファイルが無ければエンティティ・オブジェクトの定義に合わせて自動で作り、そのファイルを使用するデータベースが生成されます。

データ・アクセス・オブジェクトで定義したメソッドの実体はRoomが生成してくれますから、たとえばデータを削除しているところではdatabase.getTimeTableDatailDao().clear()のようにごく普通に呼び出せばオッケーです。

あと、公共交通オープンデータセンターのデータではBusStopPoleBusStopが一つのJsonObjectになって渡ってきますので、それを分割するために少し面倒なことをしています。database.getBusStopDao().getByName()BusStopを取得してみて、もし存在しなければ(nullが返ってきたなら)、BusStopのインスタンスを生成してデータベースに追加しています。エルビス演算子はとても便利! あと、alsoスコープ関数も。

実行してみます。ログを調べてみると……。

Roomのログ

はい。成功です。Room簡単ですな。

データベースをダウンロードして、正しく動いたのか確認してみる

……ごめんなさい。ログに「Finish.」という文字が出たからRoomを正しく使えたと思えってのは、あまりに乱暴でしたね。もう少しきちんと確認しましょう。

スマートフォン上に生成されたデータベースのファイルは、AndroidStudioを使用してダウンロードする事ができます。Android Studioで[Device File Explorer]を開いて、data/data/com.tail_island.jetbus/databases」を開くと、その下に「jetbus.db」というファイルがあります。これ、SQLite3のファイルなんですよ。このファイルを右クリックして、[Save As...]メニューでローカルに保存します。

SQLite3のファイルの保存

ダウンロードしたデータベースのファイルの中を見て、正しく動作したのかを確認してみましょう。sqlite3コマンドでデータベースを開いてSELECT * FROM BusStop LIMIT 10;を実行して、はい、たしかにバス停が保存されています。この章の前で書いた、出発バス停名と到着バス停名からRouteを取得するSQLも実行してみます。うん、上りか下りなのかの判別まで含めてうまく行っています。

Roomのログ

やっぱり、Room簡単ですな。

Dagger

このあたりで少し冷静になってこれまでに作成したコードを見直してみると、なんだか、SplashFragmentがあまりに汚い……。ちょっとWebサービス呼び出してみようかなってたびに、前準備として以下のコード書くなんてやってられないですよね?

val webService = Retrofit.Builder().apply {
    baseUrl("https://api.odpt.org")
    addConverterFactory(GsonConverterFactory.create())
}.build().create(WebService::class.java)

でもまぁ、この問題はWebServiceを作る関数を作成すれば解決できそうな気がします。こんな感じ。

fun createWebService(): WebService {
    return Retrofit.Builder().apply {
        baseUrl("https://api.odpt.org")
        addConverterFactory(GsonConverterFactory.create())
    }.build().create(WebService::class.java)
}

createWebService().getWebServiceResultBody { ... }

ただしAppDatabase、テメーはダメだ! AppDatabaseを生成するコードを、よく見てみましょう。

val database = Room.databaseBuilder(requireContext(), AppDatabase::class.java, "jetbus.db").build()

このコードのdatabaseBuilder()メソッドの引数のrequireContext()がダメ。だって、requireContext()Fragmentのメソッドなんですよ。だから、createAppDatabase()関数を作る場合は、その引数にContextを追加してあげなければなりません。そうすると、この関数はContextを提供可能なFragmentActivity等からしか呼び出せなくなっちゃう。だからAppDatabaseを使う処理はFragmentActivityに書くしかなくて、その結果としてFragmentActivityに処理のすべてが埋め込まれた汚いコードが出来上がっちゃう……。

うん、Dependency InjectionツールのDaggerを使ってどうにかしましょう!

Dependency Injection

Dependency Injection(依存性の注入。DIと省略される)はちょっと分かりづらい技術なので、具体的なコードで説明しましょう。ComponentAComponentBに依存している(使用している)とします。

class ComponentA {
    private componentB = new ComponentB()

    fun doSomething() {
        ...
    }
}

このコードのComponentBが、実はインターネットにあるサービスを呼び出すものでとても遅い場合を考えてみてください。そうなると、単体テストがとても遅くなっちゃう。だから……

interface ComponentB {  // classからinterfaceにします
    ...
}

class ComponentA(componentB: ComponentB) {
    fun doSomething() {
        ...
    }
}

ComponentA(MockComponentB()).doSomething()  // 単体テストではモックを使用して呼び出します

コードをこんな感じに変更して、単体テスト用の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を引数にしないと実現できない処理が多数存在します。これを関数の引数として表現すると呼び出せる場所が限られてしまってよくない。属性にしてコンストラクタで設定する方式も、FragmentActivity等では生成をAndroidがやるため手を出せないので実現不能です。でも、Daggerを使えば属性を設定できるんですよ。ContextをDaggerの中で引き回せば、FragmentActivity以外でも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'
    kapt 'com.google.dagger:dagger-compiler:2.24'  // 追加

    implementation fileTree(dir: 'libs', include: ['*.jar'])

    ...

    implementation 'androidx.room:room-ktx:2.2.4'
    implementation 'com.google.dagger:dagger:2.24'  // 追加
    implementation 'com.squareup.retrofit2:retrofit:2.6.1'

    ...
}

アノテーションによるコード生成が必要ですので、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 {
                Log.d("SplashFragment", "Start.")

                ...

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
    @Singleton
    fun provideWebService() = Retrofit.Builder().apply {
        baseUrl("https://api.odpt.org")
        client(
            OkHttpClient.Builder().apply {
                connectTimeout(180, TimeUnit.SECONDS)  // ついでだったので、タイムアウト時間を長めに設定しておきます
                readTimeout(180, TimeUnit.SECONDS)
                writeTimeout(180, TimeUnit.SECONDS)
            }.build()
        )
        addConverterFactory(GsonConverterFactory.create())
    }.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を取得するためのプロパティ
    lateinit var component: AppComponent
        private set

    override fun onCreate() {
        super.onCreate()

        // 先程作成したAppModuleを使用して、DaggerのComponentを作成します
        component = DaggerAppComponent.builder().apply {
            appModule(AppModule(this@App))
        }.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 {
            bookmarksButton.setOnClickListener {
                findNavController().navigate(SplashFragmentDirections.splashFragmentToBookmarksFragment())
            }
        }.root
    }

requireActivity()で取得したActivityを経由してApplicationを取得して、そこからDaggerのComponentを取得して自分自身を引数にinject()します……って、これ、結局Contextの代わりにApplicationが必要になっただけじゃん! Daggerで楽するために単純作業に耐えてきたのに、ベネフィットは複数の属性を1行で設定できるようになっただけ?

こんなの絶対おかしいよ……とどこぞの魔法少女のように絶望したかもしれませんけど、安心してください、幸いなことにDaggerには救いがあります。どう救われるのかは、次に説明するLifecycleの中で!

Lifecycle

と、かなり強引な引きで始めてみたLifecycleなのですけど、ごめんなさい、やたらと書くことが多いので、Daggerの世界が救われるのはこの章の最後になります。

考えてみれば、Androidアプリの開発が大変だった一番大きな理由は、本稿の最初の方で述べたライフサイクルの管理が大変なためなんです。やたらと複雑なActivityのライフサイクルから、もうとにかく逃げたい。そのために状態をViewModelに分離したというわけ。なので、ViewModelとはなんぞやというアーキテクチャーの話をしなければなりません。あと、ActivityFragmentの状態の変化に合わせて正しく動作するには、データの変更を監視する方式が定まっていないとダメ。そうでなければ、状態の変化に対応するための制御を入れられないですもんね。だから、その手段である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アーキテクチャなんです。

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)。

MVVMアーキテクチャ

さて、ViewをレイアウトのXMLとソース・コードの合わせ技で実装するAndroidアプリ開発ではViewModelは不要に感じられるのですけど、AndroidのViewであるActivityFragmentには状態を持てないという別の制約がありました。このViewの状態を管理するために、ViewModelを使用するというわけ。必要に迫られて仕方なく、という感じのアーキテクチャなんですな。

Android JetpackでのModel-View-ViewModelアーキテクチャ

Android Jetpackでは、もう一つ層を追加することを提案しています。それがRepositoryです。

Android JetpackのMVVMアーキテクチャ

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を使えば考える必要はほとんどなくなるのですけど、ActivityFragmentは複雑なライフサイクルを持っているので、単純にObserverパターンを使うと通知が来たときにはすでにActivityFragmentが破棄されていて処理を実行したら即クラッシュなんて可能性もあるんですよ。

これらの課題を、LiveDataでサクサク解決しちゃいましょう。

MutableLiveData

と思ったのですけど、さて、困りました。LiveDataのオブジェクトはViewModelのプロパティとするのがセオリーなのですけれど、まだViewModelの作り方を説明できていません……。とりあえずは、Fragmentから参照できてActivityFragmentよりも生存期間が長いAppに置くことにしましょう。出発バス停の名前を表現するMutableLiveDataを作成します。

package com.tail_island.jetbus

import android.app.Application
import androidx.lifecycle.MutableLiveData

class App: Application() {
    lateinit var component: AppComponent
        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 ->
                Log.d("BusApproachesFragment", "departureBusStopName.observe()")
                departureBusStopNameTextView.text = departureBusStopNameValue
            })

            // テスト用に、別スレッドでLiveDataに値を設定します。
            thread {
                Thread.sleep(5000)

                (requireActivity().application as App).departureBusStopName.postValue("日本ユニシス本社前")
                Log.d("BusApproachesFragment", "MutableLiveData.postValue()")
            }
        }.root
    }
}

observe()の引数のObserver { ... }は、抽象メソッド一つだったらラムダ式から変換してやる(SAM変換)という機能を使用しています。ラムダ式でいいんだったら前に付いているObserverは何なんだとか、どうして(...)の内側に入っているんだこれまでの書き方と違うじゃないかという疑問は、observe()にはいくつもバージョンがあるのでそのどれなのかを指定しなければならないから。普段と書き方が違うので面倒ですけど、いつもどおりの書き方をするとコンパイル・エラーになるので発見も書き換えも容易だからまぁいいかな。

で、これで作業は終了です。MutableLiveDataに値を設定するには、メイン・スレッドからの場合はvalueプロパティ、他のスレッドからの場合はpostValue()メソッドを使用します。スレッドを作成して5秒経ったら、私の両親が「お前をまだクビにしてないなんて度量が大きい会社だな」と評価した会社の前にあるバス停が設定されて、通知が飛んで、画面に表示されます。

で、ここで試していただきたいのですけど、バス接近情報を表示する画面に遷移したら、5秒経つ前にホーム画面を表示してアプリをバックグラウンドに移動させてみてください。バックグラウンドに移ったので画面を更新する必要はなくて、だからObserverの呼び出しは無駄です。LiveDataはこのことを知っていて、しかも、Fragmentがどのような状態にあるのかをviewLifecycleOwnerを経由して知ることができるので、変更を通知しなくなるんです。その証拠に、ほら、logcatを見てください「departureBusStopName.observe()」が表示されていないでしょ? で、アプリをフォアグランドに戻すと、すぐにlogcatに「departureBusStopName.observe()」が表示されて、私の会社の前にあるバス停の名前が画面に表示される。 というわけで、ほら、LiveDataのおかげでActivityFragmentのライフサイクルがどうなっているのかを考えながらプログラミングする手間はほぼなくなりました!

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が必要なので、まずはAppCompoenentfun 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() {
    lateinit var component: AppComponent
        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()

        component = DaggerAppComponent.builder().apply {
            appModule(AppModule(this@App))
        }.build()

        component.inject(this)
    }
}

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 ->
                Log.d("BusApproachesFragment", "departureBusStopName.observe()")
                departureBusStopNameTextView.text = departureBusStopNameValue
            })

            (requireActivity().application as App).arrivalBusStops.observe(viewLifecycleOwner, Observer { arrivalBusStopsValue ->
                Log.d("BusApproachesFragment", "arrivalBusStops.observe(), ${arrivalBusStopsValue.size}")
                arrivalBusStopNamesTextView.text = arrivalBusStopsValue.map { it.name }.joinToString("\n")
            })

            thread {
                Thread.sleep(5000)

                (requireActivity().application as App).departureBusStopName.postValue("日本ユニシス本社前")
                Log.d("BusApproachesFragment", "MutableLiveData.postValue()")
            }
        }.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() {
    lateinit var component: AppComponent
        private set

    @Inject lateinit var database: AppDatabase

    val departureBusStopName = MutableLiveData<String>()

    // depatureBusStopNameが変更になったら、arrivalBusStopsを設定します
    val arrivalBusStops = Transformations.switchMap(departureBusStopName) { arrivalBusStopNameValue ->
        database.getBusStopDao().getObservablesByDepartureBusStopName(arrivalBusStopNameValue)
    }

    ...
}

TransformationsswitchMap()メソッドは、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 ->
                Log.d("BusApproachesFragment", "departureBusStopName.observe()")
                departureBusStopNameTextView.text = departureBusStopNameValue
            })

            (requireActivity().application as App).arrivalBusStops.observe(viewLifecycleOwner, Observer { arrivalBusStopsValue ->
                Log.d("BusApproachesFragment", "arrivalBusStops.observe(), ${arrivalBusStopsValue.size}")
                arrivalBusStopNamesTextView.text = arrivalBusStopsValue.map { it.name }.joinToString("\n")
            })

            thread {
                Thread.sleep(5000)

                (requireActivity().application as App).departureBusStopName.postValue("日本ユニシス本社前")
                Log.d("BusApproachesFragment", "MutableLiveData.postValue()")

                Thread.sleep(5000)

                (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() {
    lateinit var component: AppComponent
        private set

    @Inject lateinit var database: AppDatabase

    val departureBusStopName = MutableLiveData<String>()

    val arrivalBusStops = Transformations.switchMap(departureBusStopName) { arrivalBusStopNameValue ->
        database.getBusStopDao().getObservablesByDepartureBusStopName(arrivalBusStopNameValue)
    }

    val arrivalBusStopName = Transformations.map(arrivalBusStops) { arrivalBusStopsValue ->
        arrivalBusStopsValue[0.until(arrivalBusStopsValue.size).random()].name
    }

    // 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

            source?.let {
                removeSource(it)
            }

            source = database.getRouteDao().getObservablesByDepartureBusStopNameAndArrivalBusStopName(departureBusStopNameValue, arrivalBusStopNameValue).also {
                addSource(it) { sourceValue ->
                    value = sourceValue
                }
            }
        }

        addSource(departureBusStopName) { update() }
        addSource(arrivalBusStopName)   { update() }
    }

    ...

うぉ、面倒臭そう……なので、少し解説を。

departureBusStopName(出発バス停名称)が変更になったら、arrivalBusStops(到着バス停のリスト)を設定するところまでは前にやりました。今回はarrivalBusStopName(到着バス停名称)も必要なので、Transformationsmap()メソッドを使用してarrivalBusStopsが変更になったらその中の一つをランダムに選ぶようにしました。

で、routes(路線のリスト)が問題のMediatorLiveDataです。MediatorLiveDataでは、監視対象のLiveDataaddSource()メソッドで追加できます。addSource()メソッドの2つ目の引数はラムダ式で、監視対象が変更になった場合に実行する処理です。今回は、その直前に定義しているupdate()関数を呼び出しています。で、MediatorLiveDataの値を変更したい場合は、valueプロパティに値を設定すればよくて、監視対象のLiveDataの値はLiveDatavalueプロパティで取得できるのですけど、残念なことにRouteDaoに定義したのはLiveData<List<Route>>を返すメソッドですから、そのままではvalueに設定できません。

だから、getObservablesByDepartureBusStopNameAndArrivalBusStopName()の返り値を格納するためのsource変数を作成して、設定と同時にalsoaddSource()してvalueに値を設定するようにしています。あと、sourceが複数にならないように、source?.letで過去に設定したsourceがある場合はremoveSource()しています。

あと、update()の中でエルビス演算子(:?)を使用しているのは、LiveDataのテンプレート引数がnullを許容しない型であったとしても、値がまだ設定されていない場合はvalueプロパティの値がnullになってしまうためです。出発バス停名称と到着バス停名称の両方が揃った場合に初めて処理を実施するというわけですな。

……とまぁ、異常に複雑で、これを少しでも簡単にするには「RouteDaoLiveDataを返さないメソッドを定義する」という手があるのですけど、場合によって作り方を変更するのは混乱の元になるので避けたい。まぁ、複雑だとはいっても毎回同じなのでそのうち見慣れるでしょうから、ごめんなさい、このままで。こーゆーもんなんだと無理に飲み込んでください。誰かライブラリ化してくれないかなぁ。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 ->
                Log.d("BusApproachesFragment", "departureBusStopName.observe()")
                departureBusStopNameTextView.text = departureBusStopNameValue
            })

            (requireActivity().application as App).arrivalBusStopName.observe(viewLifecycleOwner, Observer { arrivalBusStopNameValue ->
                arrivalBusStopNameTextView.text = departureBusStopNameValue
            })

            (requireActivity().application as App).routes.observe(viewLifecycleOwner, Observer { routesValue ->
                routeNamesTextView.text = routesValue.map { it.name }.joinToString("\n")
            })

            thread {
                Thread.sleep(5000)

                (requireActivity().application as App).departureBusStopName.postValue("日本ユニシス本社前")
                Log.d("BusApproachesFragment", "MutableLiveData.postValue()")

                Thread.sleep(5000)

                (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.departureBusStopNameLiveData型だけど文字列型にしなくていいの? と、お考えになった方がいらっしゃるかもしれませんけど、データ・バインディングはLiveData対応ですのでこれで大丈夫なんです。

ただ、良いことばかり続くわけではないのが世の常です。routeNamesTextViewandroid:textプロパティを見てください。map()joinToString()を使わずに、まだ定義してないApprouteNamesプロパティをバインディングしています。なんでこんなことをしているかというと、データ・バインディングの@{...}の中って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 ->
        routesValue.map { it.name }.joinToString("\n")
    }

    ...
}

最後。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を設定します
            lifecycleOwner = viewLifecycleOwner

            // FragmentBusApproachesBinding.appを設定します
            app = (requireActivity().application as App)

            // 以下はテスト用なので後で削除します
            thread {
                Thread.sleep(5000)

                (requireActivity().application as App).departureBusStopName.postValue("日本ユニシス本社前")
                Log.d("BusApproachesFragment", "MutableLiveData.postValue()")

                Thread.sleep(5000)

                (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 ->
        repository.getObservableBusStopsByDepartureBusStopName(arrivalBusStopNameValue)
    }

    val arrivalBusStopName = Transformations.map(arrivalBusStops) { arrivalBusStopsValue ->
        if (arrivalBusStopsValue.isEmpty()) {
            return@map null
        }

        arrivalBusStopsValue[0.until(arrivalBusStopsValue.size).random()].name
    }

    val routes = MediatorLiveData<List<Route>>().apply {
        var source: LiveData<List<Route>>? = null

        fun update() {
            val departureBusStopNameValue = departureBusStopName.value ?: return
            val arrivalBusStopNameValue   = arrivalBusStopName.value   ?: return

            source?.let {
                removeSource(it)
            }

            source = repository.getObservableRoutesByDepartureBusStopNameAndArrivalBusStopName(departureBusStopNameValue, arrivalBusStopNameValue).also {
                addSource(it) { sourceValue ->
                    value = sourceValue
                }
            }
        }

        addSource(departureBusStopName) { update() }
        addSource(arrivalBusStopName)   { update() }
    }

    val routeNames = Transformations.map(routes) { routesValue ->
        routesValue.map { it.name }.joinToString("\n")
    }
}

……ここまでもったいぶっておいて何なのですけど、前述したようにViewModelを継承するようにして、これまでとりあえずAppに書いていたコードを移動させ、AppDatabaseではなくRepositoryのメソッドを呼び出すように修正しただけです。

ViewModelをDaggerから取得する

では、ViewModelを使ってみましょう。ドキュメントのViewModelの概要を見てみると、なるほど、`ViewModelProviders.of(this)[BusApproachesViewModel::class.java]`でインスタンスを取得できるのね……って、これではインスタンス生成が自動化されていて手が出せないので、Repositoryを引数にしてコンストラクタを呼び出すことできないじゃん!

同じ問題はActivityFragmentで経験済みで、あのときは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
annotation class ViewModelKey(val value: KClass<out ViewModel>)

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
    @ViewModelKey(BusApproachesViewModel::class)
    fun provideBusApproachesViewModel(repository: Repository) = BusApproachesViewModel(repository) as ViewModel
}

AppComponentBusApproachesFragmentに依存性を注入するメソッドを定義します。

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 {
            lifecycleOwner = viewLifecycleOwner
            viewModel      = this@BusApproachesFragment.viewModel

            thread {
                Thread.sleep(5000)

                viewModel.departureBusStopName.postValue("日本ユニシス本社前")
                Log.d("BusApproachesFragment", "MutableLiveData.postValue()")

                Thread.sleep(5000)

                viewModel.departureBusStopName.postValue("深川第八中学校前")
            }
        }.root
    }
}

解説とはViewModelの作成方法が違うじゃんと思われたと思いますが、これ、Android JetpackのKotlin向け便利機能の一つなんです。

この書き方にしておくと、FragmentとViewModelの生存期間が同じになります。あと、たとえば認証情報のようなFragmentよりも生存期間が長い(別のFragmentに遷移したらまたIDとパスワード入力するなんてやってられないですよね?)ViewModelを使う場合には、private val authorizationViewModel by activityViewModel<AuthorizationViewModel> { viewModelProviderFactory }とするだけでViewModelの生存期間がActivityと同じになるのでとても便利ですよ。

はい、これで完成! 正しく動くか試してみましょう。処理開始まで5秒待つようにしていますから、かなり長く待たないと動きがなくてイライラするけど……。

Movie #3

うん、動いた……のは良いのだけど、確認のためにAppModuleを開いてソース・コードを眺めていたら、なんかちょっと気持ち悪い。fun provideBusApproachesViewModel(repository: Repository) = BusApproachesViewModel(repository) as ViewModelrepositoryは、どうやって取得したのでしょうか? だって、Repository@Providesするメソッドは定義していないんですよ?

その答えは「@Injectアノテーションが付いたコンストラクタを持つクラスは、自動で@Providesなメソッドを作成してくれるから」です。先程Repositoryを作成したときに、@Singletonアノテーションと@Injectアノテーションを追加しましたよね。この@Injectアノテーションが役に立ってくれたんです。

では@Singletonアノテーションは何なのかというと、@SingletonをDaggerで生成したい型に付加しておくと、@Providesメソッドを生成するときに@Singletonが自動で付くようになるんです。Repositoryは何個もいらないですもんね。というわけで、AppModule@Singletonは生成対象の型に移動……させるという手は、provideContext()provideConsumerKey()では使えません。書き方が統一できないとミスが発生しそうで怖いので、しょうがないから、無駄があるけど念の為に両方に書くという方式にしましょう。AppDatabaseWebServiceにも@Singletonを付加しておきます。

というわけで、コンストラクタに@Injectを書くだけで依存性を注入しまくれるんです。注入した依存性を活用できるのはViewModel経由で呼び出す場合だけですけど、MVVMアーキテクチャを採用してアプリを組むのですから問題にはならないはず。ほら、前章からの宿題だったDaggerをどうやって使うかが決まったでしょ? これ以降は、依存性を注入したいならコンストラクタに@Injectを書くだけでよくなったんですよ。

まぁ、ActivityFragmentには、依存性を注入する処理を手で書かなければならないんだけどな。

コルーチン

もろもろ片付いたのでよーしパパ残りのViewModelも作っちゃうぞーと考えたのですけど、最初の画面のSplashFragment向けのViewModelでいきなり躓いてしまいました。RoomやRetrofit2はメイン・スレッドからは呼び出せないのですけど、スレッドどうしましょ?

ViewModelの中でthreadで別スレッドを生成する……のは前の章で書いておいてアレなのですけど、ダメ、絶対。だって、生成されたスレッドは終了するまで生き残ってしまうんですから。ViewModelを作成してその中でスレッドを生成して、画面遷移してFragmentが終了するとかでViewModelを終了する。この場合でも、スレッドは処理が終わるまで動き続けます。で、もう一度同じFragmentが表示されたりしてViewModelが生成されると、またスレッドが生成されちゃう。負荷が大きいことに加えて、処理が2重に動いてしまうんですよ。実は前の章までで作成したアプリにはこの問題に起因するバグがあって、SplashFragmentでスレッドを生成してデータベースを更新しているときにスマートフォンを回転させたりすると、Activityが再生成されてFragmentonStart()が再実行されてデータベースを更新するスレッドがもう一つ動いて、データの不整合ができてアプリが落ちちゃうんですよ。これじゃあ困る。

だから、スレッドの生存期間がViewModel未満になるようにしなければなりません。そんなときに便利なのが、途中で処理を中断したり再開できたり、さらには中断したまま途中でやめちゃったりもできる、コルーチンなんです。

途中で処理を中断、再開?

途中で処理を中断したり再開したりするというのがどういうことなのか、具体的なコードでご説明します。

fun useCoroutine() {
    runBlocking {  // とりあえず、runBlockingは無視してください……
        for (i in 3.downTo(1)) {  // iの値は3, 2, 1の順になります
            launch {
                delay(i.toLong() * 1000)
                Log.d("xxx", "${i}")  // 出力は1, 2, 3の順になります
            }
        }
    }
}

このコードを実行すると、「1」のあとに「2」、そのあとに「3」が表示されます。for文は3.downTo(1)となっているのでiの値は3、2、1の順なのですけど、launchの中身は非同期に実行され、delay()は指定した時間(ミリ秒)待つので、1,000ミリ秒待って「1」、2,000ミリ秒待って「2」、3,000ミリ秒まって「3」というループとは逆の順に出力されるというわけですな。

……ってそれ、threadThread.sleep()でも同じことができるのでは? たとえば、こんなコードで。

fun useThread() {
    for (i in 3.downTo(1)) {
        thread {
            Thread.sleep(i.toLong() * 1000)
            Log.d("xxx", "${i}")
        }
    }
}

はい、その通り。threadを使ったこのコードでも、確かに出力は同じになります。でも、useCoroutine()は実はとてもすごくて、新しいスレッドを使わずに、すべてメイン・スレッドで実行しているんです。確認してみましょう。

fun useCoroutine() {
    Log.d("xxx", "Main thread id: ${Thread.currentThread().id}")  // スレッドのIDを出力します

    runBlocking {
        for (i in 3.downTo(1)) {
            launch {
                delay(i.toLong() * 1000)
                Log.d("xxx", "${i}: ${Thread.currentThread().id}")
            }
        }
    }
}

fun useThread() {
    Log.d("xxx", "Main thread id: ${Thread.currentThread().id}")

    for (i in 3.downTo(1)) {
        thread {
            Thread.sleep(i.toLong() * 1000)
            Log.d("xxx", "${i}: ${Thread.currentThread().id}")
        }
    }
}

このコードを実行すると、useCoroutine()の方ではすべて同じスレッドIDが、useThread()の方ではすべて異なるスレッドIDが出力されます。ほら、useCoroutine()の方は、すべてメイン・スレッドで実行されていてスゴイでしょ?

でも、一つのスレッドでは一つのことしかできないはずで、だから、「待つ」のと「出力」の両方は実行できないはず。でもできちゃっているのはなぜかといえば、実はdelay()は「待つ」のではなく「中断」だからなんです。中断している間は他のコルーチンを実行できるので、だから並列処理に見えたというわけ。たとえばdelay(1000)なら、中断して1,000ミリ秒たったら再開するという意味になるんです(とはいえ、メイン・スレッドで動く他の処理がいつまでも終わらなかったりするような場合は、1,000ミリ秒たっても再開されなかったりしますけど)。

中断して、実行するスレッドを変えて、再開しちゃえ!

コルーチンのすごいのは、それだけじゃありません。実行するスレッドを変更することができるんです。

Log.d("xxx", "Main thread id: ${Thread.currentThread().id}")

runBlocking {
    launch {
        Log.d("xxx", "Hello: ${Thread.currentThread().id}")

        withContext(Dispatchers.IO) {
            Log.d("xxx", "Coroutines: ${Thread.currentThread().id}")
        }

        Log.d("xxx", "World: ${Thread.currentThread().id}")
    }
}

上のコードを実行すると、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() {
        viewModelScope.launch {
            while (true) {  // 無限ループ
                delay(1000)

                Log.d("xxx", "viewModelScope")
            }
        }
    }
}

runBlockinglaunchではなく、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)

        viewModel.foo()

        return FragmentBusApproachesBinding.inflate(inflater, container, false).apply {
            ...
        }.root
    }
}

こんな感じで、先程の無限ループする処理を呼び出しています。delay(1000)の間にメイン・スレッドは別の処理をできるのでユーザー・インターフェースが止まらず、それでいてもちろん、logcatにログが出力されます。で、それだけではなくて、画面が遷移すると無限ループなのに処理が止まるんですよ!

中断して、再開しなければいい(あと、再開に必要な情報を破棄する)だけですもんね。で、viewModelScopeViewModelと同じライフサイクルを持っているので、画面が遷移してViewModelが破棄されると処理が止まるんですな。

あと、viewModelScopeの他に、ActivityFragmentで使用できるlifecycleScopeや、アプリと同じライフサイクルを持つGlobalScopeなんてものあります。でも、できるだけGlobalScopeは使わないでくださいね。

関数に抽出しちゃえ!

さて、ここまでのコードに出てきたlaunch {}はコルーチンを生成するのですけど、その中に大きな処理を書くと可読性が下がってしまいますから、小さな関数に分割したい。でも、以下のコードはコンパイル・エラーになってしまうんです。

fun bar() {
    delay(1000)

    Log.d("xxx", "viewModelScope")
}

fun foo() {
    viewModelScope.launch {
        while (true) {
            bar()
        }
    }
}

というのも、delay()はコルーチンの中だからできる特別な処理なわけで、だからコルーチンではない場所から呼び出されても困っちゃう。だから、コルーチンの中から呼び出す特別な関数とするために、suspendを付加しなければならないんです。

suspend fun bar() {  // suspendを付加
    delay(1000)

    Log.d("xxx", "viewModelScope")
}

fun foo() {
    viewModelScope.launch {
        while (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) {
    suspend fun clearDatabase() = withContext(Dispatchers.IO) {
        try {
            database.withTransaction {
                database.getTimeTableDetailDao().clear()
                database.getTimeTableDao().clear()
                database.getRouteBusStopPoleDao().clear()
                database.getRouteDao().clear()
                database.getBusStopPoleDao().clear()
                database.getBusStopDao().clear()
            }

            Unit

        } catch (e: IOException) {
            Log.e("Repository", "${e.message}")
            null
        }
    }

    private fun <T> getWebServiceResultBody(callWebService: () -> Call<T>): T? {
        val response = callWebService().execute()

        if (!response.isSuccessful) {
            Log.e("Repository", "HTTP Error: ${response.code()}")
            return null
        }

        return response.body()
    }

    suspend fun syncDatabase() = withContext(Dispatchers.IO) {
        try {
            if (database.getBusStopDao().getCount() > 0) {
                return@withContext Unit
            }

            val busStopPoleJsonArray = getWebServiceResultBody { webService.busstopPole(consumerKey)     } ?: return@withContext null
            val routeJsonArray =       getWebServiceResultBody { webService.busroutePattern(consumerKey) } ?: return@withContext null

            database.withTransaction {
                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(
                            busStopPoleJsonObject.get("dc:title").asString,
                            busStopPoleJsonObject.get("odpt:kana")?.asString
                        ).also {
                            database.getBusStopDao().add(it)
                        }
                    }

                    BusStopPole(
                        busStopPoleJsonObject.get("owl:sameAs").asString,
                        busStop.name
                    ).also {
                        database.getBusStopPoleDao().add(it)
                    }
                }

                for (routeJsonObject in routeJsonArray.map { it.asJsonObject }.filter { it.get("odpt:operator").asString == "odpt.Operator:Toei" }) {
                    val route = Route(
                        routeJsonObject.get("owl:sameAs").asString,
                        routeJsonObject.get("dc:title").asString
                    ).also {
                        database.getRouteDao().add(it)
                    }

                    for (routeBusStopPoleJsonObject in routeJsonObject.get("odpt:busstopPoleOrder").asJsonArray.map { it.asJsonObject }) {
                        RouteBusStopPole(
                            route.id,
                            routeBusStopPoleJsonObject.get("odpt:index").asInt,
                            routeBusStopPoleJsonObject.get("odpt:busstopPole").asString
                        ).also {
                            it.id = database.getRouteBusStopPoleDao().add(it)
                        }
                    }
                }
            }

            Unit

        } catch (e: IOException) {
            Log.e("Repository", "${e.message}")
            null
        }
    }

    ...
}

中身は、前に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 {
        viewModelScope.launch {
            repository.syncDatabase() ?: run { isSyncDatabaseFinished.value = false; return@launch }

            isSyncDatabaseFinished.value = true
        }
    }
}

init {}には、インスタンスが生成されたときに実行する処理を記述します。Webサービスを呼び出してデータベースにキャッシュするコルーチンはViewModelと同じライフサイクルを持っていて欲しいので、だから生成と同時にviewModelScope.launch {}しているわけですな。viewModelScopelaunchしているのでViewModelが破棄されればこのコルーチンは終了しますから、終了もバッチリ。アプリがバックグラウンドになればコルーチンは止まるので、他のアプリに迷惑をかけないですし。

isSyncDatabaseFinishedプロパティは、syncDatabase()が成功したか失敗したかを表現する目的で追加しました。ViewModelはisSyncDatabaseFinishedobserve()して、正常に終了したら次の画面に遷移、そうでなければアプリケーションを終了するとかすればよいわけ。

SplashFragment

というわけで、SplashFragmentにはisSyncDatabaseFinishedobserve()する処理を追加して、その代わりに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)

        viewModel.isSyncDatabaseFinished.observe(viewLifecycleOwner, Observer {
            if (!it) {
                requireActivity().finish()
                return@Observer
            }

            findNavController().navigate(SplashFragmentDirections.splashFragmentToBookmarksFragment())
        })

        return FragmentSplashBinding.inflate(inflater, container, false).root
    }
}

うん、スッキリしました(isSyncDatabaseFinishedUnit?ならエルビス演算子が使えてもっとスッキリするのですけど、プロパティ名を「is」で始めたのでBooleanにせざるを得なかった……)。

というわけで、これで自動で画面遷移するようになったのでもうボタンは不要になりましたから、fragment_splash.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=".SplashFragment">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <ImageView
            android:id="@+id/icon"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginBottom="8dp"
            app:layout_constraintStart_toStartOf="@id/nowDownloadingTextView"
            app:layout_constraintEnd_toEndOf="@id/nowDownloadingTextView"
            app:layout_constraintBottom_toTopOf="@id/nowDownloadingTextView"
            android:contentDescription="@string/app_name"
            android:src="@mipmap/ic_launcher_round" />

        <TextView
            android:id="@+id/nowDownloadingTextView"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"
            android:text="バス停と路線の情報を取得しています……" />

        <ProgressBar
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="8dp"
            app:layout_constraintStart_toStartOf="@id/nowDownloadingTextView"
            app:layout_constraintEnd_toEndOf="@id/nowDownloadingTextView"
            app:layout_constraintTop_toBottomOf="@id/nowDownloadingTextView" />

    </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)

        binding = DataBindingUtil.setContentView<ActivityMainBinding>(this, R.layout.activity_main).apply {
            // NavigationViewのメニューが選ばれた場合のリスナーを設定します
            navigationView.setNavigationItemSelectedListener {
                when (it.itemId) {
                    R.id.clearDatabase -> run {
                        lifecycleScope.launch {
                            viewModel.clearDatabase()
                            findNavController(R.id.navHostFragment).navigate(R.id.splashFragment)
                        }

                        true
                    }
                    else -> false

                }.also {
                    if (!it) {
                        return@also
                    }

                    drawerLayout.closeDrawer(GravityCompat.START)
                }
            }

        }.also {
            findNavController(R.id.navHostFragment).apply {
                NavigationUI.setupWithNavController(it.toolbar, this, AppBarConfiguration(setOf(R.id.bookmarksFragment), it.drawerLayout))

                addOnDestinationChangedListener { _, destination, _ ->
                    it.appBarLayout.visibility = if (destination.id == R.id.splashFragment) View.GONE else View.VISIBLE
                }
            }
        }
    }

    ...
}

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() {
    suspend fun clearDatabase() = repository.clearDatabase()
}

Repositoryのメソッドを呼び出し直しているだけ。あとは、前の章で紹介した手順でViewModelをDaggerで注入可能にすれば、作業終了です。ぜひ実際に動かしてみてください。ほらこのアプリ、端末を回転させても正常に動作するんです!

……そんなの当たり前だと思うかもしれませんけど、昔のAndroidアプリ開発ではこの程度でも大変だったんだよ。

RecyclerView

と、ここまでいろいろ作ってきましたけど、なのにいまだに画面がスカスカで悲しい……。画面を作りましょう。使う道具は、これを覚えるだけで閲覧系のアプリなら大体どうにかなっちゃうという噂のRecyclerViewです。

リサイクル?

RecyclerViewは、スクロール可能なリストを作成するGUIウィジェットです。特徴は大規模なデータ・セットに対応可能なこと(大は小を兼ねるので、小さなデータ・セットで使っても問題ないけど)。

で、この「大規模」という点が、RecyclerViewという名前につながります。Androidの画面はView(本稿でも、文字を表示するためのTextViewとかを使いましたよね?)で構成されるので、リストの各行もViewで構成されます。画面を上にスクロールすると、上の行が画面の外側に消えて下に新しい行が表示されるわけですけど、その画面の外側に消えた行のViewをどうしましょうか? 放っておくとメモリに負荷がかかるし、ガベージ・コレクションの際にはCPUに負荷がかかっちゃう。だから、下から出てくる新しい行のViewとしてリサイクルしたい。これを自動でやってくれるのがRecyclerViewというわけ。

ViewHolderDiffCallbackとAdapterを作る

リストを表示する場合は、リストそのものを管理する人とリストの要素を管理する人を分けた方が楽になります。List<MyClass>って分割するのと一緒。RecyclerViewの場合、リストの要素はViewHolderで管理します。で、ViewHolderRecyclerViewを繋げるのがListAdapter。まずはこのViewHolderの説明から。

RecyclerViewViewをリサイクルするので、これまでは会社の前のバス停を表示していた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) {
        binding.item = item
        binding.executePendingBindings()  // データ・バインディングを実行します

        binding.busStopButton.setOnClickListener {
            // ここに、バス停がタップされたときの処理を入れる
        }
    }
}

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()はただ単にoldItemnewItem==で結ぶだけでオッケー。

と、こんな感じでViewHolderDiffCallbackができましたので、あとはこれらと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()) {
    lateinit var onBusStopClick: (busStop: BusStop) -> Unit

    inner class ViewHolder(private val binding: ListItemBusStopBinding): RecyclerView.ViewHolder(binding.root) {
        fun bind(item: BusStop) {
            binding.item = item
            binding.executePendingBindings()

            binding.busStopButton.setOnClickListener {
                onBusStopClick(item)
            }
        }
    }

    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))
}

先程作成したViewHolderDiffCallbackは、管理を簡単にするために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を設定します。
            recyclerView.adapter = BusStopAdapter().apply {
                viewModel.busStops.observe(viewLifecycleOwner, Observer {
                    submitList(it)
                })

                onBusStopClick = {
                    findNavController().navigate(DepartureBusStopFragmentDirections.departureBusStopFragmentToArrivalBusStopFragment(it.name))
                }
            }
        }.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も配置します。あと、MaterialButtonandroid: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()) {
    lateinit var onIndexClick: (index: Char) -> Unit

    inner class ViewHolder(private val binding: ListItemIndexBinding): RecyclerView.ViewHolder(binding.root) {
        fun bind(item: Char) {
            binding.item = item
            binding.executePendingBindings()

            binding.indexButton.setOnClickListener {
                onIndexClick(item)
            }
        }
    }

    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 {
        indexConverter[it] ?: it
    }
}

// バス停名称の索引の文字の集合を作成します
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) {
        getBusStopIndexes(it)
    }
}

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 {
            recyclerView.adapter = BusStopAdapter().apply {
                viewModel.departureBusStops.observe(viewLifecycleOwner, Observer {
                    submitList(it)
                })

                onBusStopClick = {
                    findNavController().navigate(DepartureBusStopFragmentDirections.departureBusStopFragmentToArrivalBusStopFragment(it.name))
                }
            }

            indexRecyclerView.adapter = IndexAdapter().apply {
                viewModel.departureBusStopIndexes.observe(viewLifecycleOwner, Observer {
                    submitList(it)
                })

                onIndexClick = {
                    // バス停のRecyclerViewを、適切な位置までスクロールします
                    recyclerView.layoutManager!!.startSmoothScroll(
                        AcceleratedSmoothScroller(requireContext()).apply {
                            targetPosition = getBusStopPosition(viewModel.departureBusStops.value!!, it)
                        }
                    )
                }
            }
        }.root
    }
}

このコードの中のAcceleratedSmoothScrollerは、「RecyclerViewの長距離スムーズスクロールをスムーズにする」を猿真似して作成した良い感じスクロールさせるためのクラスです。`RecylerView`のAPIには、指定した場所にスクロールする機能(画面がいきなり切り替わるので使いづらいユーザー・インターフェースになる)と指定した場所までスムーズにスクロールする機能(使いやすいユーザー・インターフェースになるけど、スクロールが終わるまで長時間かかる)しかなくて、これだけだと指定場所までスクロールするという機能がとても作りづらいんです。この問題は、「RecyclerViewの長距離スムーズスクロールをスムーズにする」ですべて解決できますので、ぜひ読んでみてください。なお、今回は少しだけ実装を変えたので、クラス名も少しだけ変更しています。

BookmarksFragmentArrivalBusStopFragmentを同じやり方で作って、BusApproachesFragmentを少しだけ修正する

あとは、同じやり方で(コピー&ペーストをやりまくって)BookmarksFragmentArrivalBusStopFragmentを作りましょう。少しだけ工夫したのは、list_item_bookmark.xmlのandroid:textString.format()を使用したこと、あと、文字列リソースは@string/idと書けば参照できて、それはデータ・バインディングの中でも変わらないことを証明するためにString.format()の中に@string/start_to_end_arrowと書いたことと、ついでだったのでリソース中のすべての文字列をres/values/string.xmlに移動させて、都営バスを思わせる緑色で画面が表示されるようにres/values/colors.xmlを修正したくらい。

で、このままだとブックマークが一つもないのでBookmarksFragmentを正しく実装できたか確認できなかったので、BusApproachesFragmentにブックマークを追加する機能(BusApproachesViewModeltoggleBookmark())を追加しました。

詳細なソース・コードは、GitHubで確認してみてください。ほとんどコピー&ペーストなので書くこと何もなかったんですよ……。

ともあれ、大分見た目もしっかりしてきました。

Movie #4

あとは、バスの接近情報を表示するだけ。表示そのものはRecyclerViewでできそうだけど、表示するデータはどうしましょうか?

バス接近情報を表示する

バス接近情報を表示する部分は少し複雑なので、データ・アクセス・オブジェクト、RepositoryViewModelFragmentの順で説明していきます。

データ・アクセス・オブジェクトを作成する

まずは、バスの接近情報を表示するために必要な情報を取得するデータ・アクセス・オブジェクトを、順を追って作成していきましょう。

RouteBusStopPole

出発バス停名称と到着バス停名称からRouteを取得するところまでは実装済みですので、RouteBusStopPoleから考えましょう。RouteBusStopPoleRouteBusStopPoleを関連付けるものなのですけど、バスの「接近」情報を表示する本アプリでは、出発バス停以降の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
                WHERE RouteBusStopPole.routeId IN (:routeIds) AND BusStopPole.busStopName = :departureBusStopName
            ) DepartureRouteBusStopPole ON RouteBusStopPole.routeId = DepartureRouteBusStopPole.routeId
            WHERE RouteBusStopPole.'order' <= DepartureRouteBusStopPole.'order' AND RouteBusStopPole.'order' >= DepartureRouteBusStopPole.'order' - 10
        """
    )
    fun getObservablesByRouteIdsAndDepartureBusStopName(routeIds: List<String>, departureBusStopName: String): LiveData<List<RouteBusStopPole>>
}

うん、副問合せですね。INNER JOINのカッコの中で、Routeと出発バス停名称に関連付けられたRouteBusStopPoleを取得します。で、外側のSQLのWHEREで、出発のRouteBusStopPoleよりもorderが小さく、かつ、出発のRouteBusStopPoleorder-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>>
}

取得したRouteBusStopPolebusStopPoleIdのリストを引数に渡すわけですな。

TimeTable

前にも述べましたが、バスは一つの路線を日に何回も行き来していますから、RouteTimeTableの関係は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.idINNER 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) {
    ...

    suspend fun syncTimeTables(routes: Iterable<Route>) = withContext(Dispatchers.IO) {
        try {
            for (route in routes) {
                delay(0)  // 一応だけど、キャンセル可能にしてみました。。。

                if (database.getTimeTableDao().getCountByRouteId(route.id) > 0) {
                    continue
                }

                val timeTableJsonArray = getWebServiceResultBody { webService.busTimeTable(consumerKey, route.id) } ?: return@withContext null

                database.withTransaction {
                    for (timeTableJsonObject in timeTableJsonArray.map { it.asJsonObject }) {
                        val timeTable = TimeTable(
                            timeTableJsonObject.get("owl:sameAs").asString,
                            timeTableJsonObject.get("odpt:busroutePattern").asString
                        ).also {
                            database.getTimeTableDao().add(it)
                        }

                        for (timeTableDetailJsonObject in timeTableJsonObject.get("odpt:busTimetableObject").asJsonArray.map { it.asJsonObject }) {
                            TimeTableDetail(
                                timeTable.id,
                                timeTableDetailJsonObject.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 }
                            ).also {
                                it.id = database.getTimeTableDetailDao().add(it)
                            }
                        }
                    }
                }
            }

            Unit

        } catch (e: IOException) {
            Log.e("Repository", "${e.message}")
            null
        }
    }

    suspend fun clearBookmarks() = withContext(Dispatchers.IO) {
        database.getBookmarkDao().clear()
    }

    suspend fun toggleBookmark(departureBusStopName: String, arrivalBusStopName: String) = withContext(Dispatchers.IO) {
        val bookmark = database.getBookmarkDao().get(departureBusStopName, arrivalBusStopName)

        if (bookmark == null) {
            database.getBookmarkDao().add(Bookmark(departureBusStopName, arrivalBusStopName))
        } else {
            database.getBookmarkDao().remove(bookmark)
        }
    }

    suspend fun getBuses(routes: Iterable<Route>, routeBusStopPoles: Iterable<RouteBusStopPole>) = withContext(Dispatchers.IO) {
        try {
            val busStopPoleIds = routeBusStopPoles.groupBy { it.routeId }.map { (routeId, routeBusStopPoles) -> Pair(routeId, routeBusStopPoles.map { it.busStopPoleId }.toSet()) }.toMap()

            getWebServiceResultBody { webService.bus(consumerKey, routes.map { it.id }.joinToString(",")) }?.filter { bus ->
                // routeBusStopPolesに含まれるバス停を出発したところ、かつ、routeBusStopPolesの同じ路線の最後(つまり出発バス停)を出発したのではない
                bus.fromBusStopPoleId in busStopPoleIds.getValue(bus.routeId) && bus.fromBusStopPoleId != routeBusStopPoles.filter { it.routeId == bus.routeId }.sortedByDescending { it.order }.first().busStopPoleId
            }

        } catch (e: IOException) {
            Log.e("Repository", "${e.message}")
            null
        }
    }

    ...
    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()の外側にループがもう一つ付いただけ。これで、TimeTableTimeTableDetailをデータベースにキャッシュできるようになりました。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

            source?.let {
                removeSource(it)
            }

            source = repository.getObservableBookmarkByDepartureBusStopNameAndArrivalBusStopName(departureBusStopNameValue, arrivalBusStopNameValue).also {
                addSource(it) { sourceValue ->
                    value = sourceValue
                }
            }
        }

        addSource(departureBusStopName) { update() }
        addSource(arrivalBusStopName)   { update() }
    }

    val routes = MediatorLiveData<List<Route>>().apply {
        var source: LiveData<List<Route>>? = null

        fun update() {
            val departureBusStopNameValue = departureBusStopName.value ?: return
            val arrivalBusStopNameValue   = arrivalBusStopName.value   ?: return

            source?.let {
                removeSource(it)
            }

            source = repository.getObservableRoutesByDepartureBusStopNameAndArrivalBusStopName(departureBusStopNameValue, arrivalBusStopNameValue).also {
                addSource(it) { sourceValue ->
                    value = sourceValue
                }
            }
        }

        addSource(departureBusStopName) { update() }
        addSource(arrivalBusStopName)   { update() }
    }

    val routeBusStopPoles = MediatorLiveData<List<RouteBusStopPole>>().apply {
        var source: LiveData<List<RouteBusStopPole>>? = null

        fun update() {
            val routesValue               = routes.value               ?: return
            val departureBusStopNameValue = departureBusStopName.value ?: return

            source?.let {
                removeSource(it)
            }

            source = repository.getObservableRouteBusStopPolesByRoutes(routesValue, departureBusStopNameValue).also {
                addSource(it) { sourceValue ->
                    value = sourceValue
                }
            }
        }

        addSource(routes)               { update() }
        addSource(departureBusStopName) { update() }
    }

    val busStopPoles = Transformations.switchMap(routeBusStopPoles) { routeStopPolesValue ->
        repository.getObservableBusStopPolesByRouteBusStopPoles(routeStopPolesValue)
    }

    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

            source?.let {
                removeSource(it)
            }

            source = repository.getObservableTimeTablesByRoutesAndDepartureBusStop(routesValue, departureBusStopNameValue).also {
                addSource(it) { sourceValue ->
                    value = sourceValue
                }
            }

            job?.cancelAndJoin()

            job = viewModelScope.launch {
                repository.syncTimeTables(routesValue)
            }
        }

        addSource(routes)               { update() }
        addSource(departureBusStopName) { update() }
    }

    val timeTableDetails = MediatorLiveData<List<TimeTableDetail>>().apply {
        var source: LiveData<List<TimeTableDetail>>? = null

        fun update() {
            val timeTablesValue   = timeTables.value   ?: return
            val busStopPolesValue = busStopPoles.value ?: return

            source?.let {
                removeSource(it)
            }

            source = repository.getObservableTimeTableDetailsByTimeTablesAndBusStopPoles(timeTablesValue, busStopPolesValue).also {
                addSource(it) { sourceValue ->
                    value = sourceValue
                }
            }
        }

        addSource(timeTables)   { update() }
        addSource(busStopPoles) { update() }
    }

    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

            job?.cancelAndJoin()

            job = viewModelScope.launch {
                while (true) {
                    value = repository.getBuses(routesValue, routeBusStopPolesValue) ?: listOf()

                    delay(15000)
                }
            }
        }

        addSource(routes)            { update() }
        addSource(routeBusStopPoles) { update() }
    }

    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

            value = busesValue.map { bus ->
                BusApproach(
                    bus.id,
                    // 時刻表から、あと何秒で到着するのかを計算します
                    timeTablesValue?.find { timeTable -> timeTable.routeId == bus.routeId }?.let { timeTable ->
                        timeTableDetailsValue?.filter { it.timeTableId == timeTable.id }?.sortedByDescending { it.order }?.takeWhile { it.busStopPoleId != bus.fromBusStopPoleId }?.zipWithNext()?.map { (next, prev) -> next.arrival - prev.arrival }?.sum()
                    },
                    routeBusStopPolesValue.sortedByDescending { it.order }.let { it.first().order - it.find { routeBusStopPole -> routeBusStopPole.busStopPoleId == bus.fromBusStopPoleId }!!.order },
                    routesValue.find { it.id == bus.routeId }!!.name,
                    busStopPolesValue.find { it.id == bus.fromBusStopPoleId }!!.busStopName
                )
            }.sortedWith(compareBy({ it.willArriveAfter ?: Int.MAX_VALUE }, { it.busStopCount }))
        }

        addSource(routes)            { update() }
        addSource(routeBusStopPoles) { update() }
        addSource(busStopPoles)      { update() }
        addSource(timeTables)        { update() }
        addSource(timeTableDetails)  { update() }
        addSource(buses)             { update() }
    }

    fun toggleBookmark() {
        viewModelScope.launch {
            val departureBusStopNameValue = departureBusStopName.value ?: return@launch
            val arrivalBusStopNameValue   = arrivalBusStopName.value   ?: return@launch

            repository.toggleBookmark(departureBusStopNameValue, arrivalBusStopNameValue)
        }
    }
}

うん、本当に面倒くさかった……。

でも、departureBusStopNamearrivalBusStopNamebookmarkroutesrouteBusStopPolesbusStopPolestimeTableDetailsについては、これまでに説明したやり方をそのまま繰り返しただけなので簡単です。

少し難しいのはtimeTablesプロパティとbusesプロパティ、busApproachesプロパティです。RepositorysyncTimeTables()メソッドを実行しないとデータベースは空なので、だからどこかでsyncTimeTables()を呼ばなければtimeTablesはいつまでも空集合のまま。あと、busesはWebサービスから取得する値で、変更通知が来ませんから自分で値を定期的に更新しなければなりません。バスの接近情報そのものであるbusApproachesは、全ての情報を手作りしなければなりませんし……。

というわけで、まずはtimeTablesプロパティから。syncTimeTables()を呼ぶのに一番良いタイミングはroutesプロパティとdepartureBusStopNameプロパティの両方に値が設定された時なので、MediatorLiveData<List<TimeTable>>().apply { ... }の中にsyncTimeTables()を呼び出す処理を入れたい。値を取得する処理の中に更新処理を入れるのは目的違いな気もするけど、他に適当な場所が無いから我慢。で、syncTimeTables()はコルーチンで呼び出さなければならないのでviewModelScope.launch { ... }して呼び出します。今回はroutesdepartureBusStopNameも変更がないので実は考慮しなくても動作するのですけど、念の為に、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プロパティの中のtimeTablesValuetimeTableDetailsValueを設定する部分では、nullの場合にリターンする処理が省かれているわけですな。

さて、busApproachesプロパティでは、busesの要素であるBus単位でBusApproachを作成します。関数型プログラミングのmapですね。willArriveAfterは、TimeTableDetail(出発バス停から手前10バス停分が入っている)をsortedByDescending()で逆順にソートして、takeWhilebus.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()) {
    inner class ViewHolder(private val binding: ListItemBusApproachBinding): RecyclerView.ViewHolder(binding.root) {
        fun bind(item: BusApproach) {
            binding.item = item
            binding.executePendingBindings()
        }
    }

    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)

        viewModel.departureBusStopName.value = args.departureBusStopName
        viewModel.arrivalBusStopName.value   = args.arrivalBusStopName

        return FragmentBusApproachesBinding.inflate(inflater, container, false).apply {
            lifecycleOwner = viewLifecycleOwner
            viewModel      = this@BusApproachesFragment.viewModel

            bookmarkImageView.setOnClickListener {
                this@BusApproachesFragment.viewModel.toggleBookmark()
            }

            recyclerView.adapter = BusApproachAdapter().apply {
                this@BusApproachesFragment.viewModel.busApproaches.observe(viewLifecycleOwner, Observer {
                    // データ件数が0件だった場合のViewの可視性を適切に設定します
                    noBusApproachesTextView.visibility = if (it.isEmpty()) { View.VISIBLE } else { View.GONE }

                    submitList(it)
                })
            }
        }.root
    }
}

これだけで……はい、動きました!

Movie #5

これで通勤が楽になって、ダメ人間の私でももう少しサラリーマンを続けられそうです。Android Jetpackありがとー!

Image Asset Studioでアイコンを作れば、完成!

でも、まだ不十分。アプリのアイコンを作らないとね。Android Studioのメニューから[File] - [New] - [Image Asset]メニューを選んで、アプリのアイコンを作成しましょう。

Asset Studio

私は絵心がないのから、Android StudioのClip Artの流用で作るんだけどな。[Source Asset]の[Asset Type]の[Clip Art]ラジオ・ボタンをクリックすれば、Android Studioが提供するアイコンから選び放題です。その中から、バスのアイコンを選択しましょう。

Clip Art

色を設定しましょう。[Color]をクリックして、今回は真っ白にしたいので「FFFFFF」を入力します。

Select Color

背景の絵を作るデザインス・センスもありませんので、背景は単色にしました。[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も削除しました。

以上! これで本当の本当に完了です。

Jetbus

いろいろあったけど、Androidアプリの開発ってべつに難しくない、というか、むしろ楽チンで美味しいでしょ?