Android Car App Library 1.2.0 が Stable になりました

Android Auto および automotive 開発に利用する Car App Library の 1.2.0 が Stable になりました。
https://developer.android.com/jetpack/androidx/releases/car-app#1.2.0

1.2.0 は今年3月に RC が出ており、そこから 1.3.0 が beta になった後も Stable が出ていない状態が続いていましたが、ここにきて安定板が出ました(もう出ないだろうと思っていた...)。

RC からの API 変更点はないようです。

Android Auto を学ぶその2 DHU(Desktop Head Unit) を使ってアプリを動かしてみる

前回は簡単なコードで Android Auto の画面を作ってみました。
funnelbit.hatenablog.com

しかしまだ動かしていないので、アプリをビルドして動作確認してみます。早速クルマに接続したいところですが、実はただ単にビルドするだけではクルマの画面には表示されません。PC だとビルドするだけで表示できるので、とりあえずPCで動かしてみましょう!

前回までのコードはこちらの github にあげてあります。
github.com

DHU(Desktop Head Unit)をインストールする

PC で動かす場合、DHU をインストールする必要があります。これはクルマのナビ画面で動く Android Auto の画面をエミュレートしてくれるものです。
developer.android.com


Android Studio のメニュー Preference -> Appearance & Behavior -> System Settings -> Android SDK から、SDK Tools タブを開いて Android Auto Desktop Head Unit Emulator をインストールしましょう。ドキュメント通りにやっていけばインストールできるはずです。

実機スマートフォンを接続したいので、Android Auto をスマートフォンにインストールしておいてください。
play.google.com


DHU をインストールしたら DHU のあるディレクトリに行きましょう。特に設定していない場合は ~/Library/Android/sdk/extras/google/auto になっていると思います。ここで ./desktop-head-unit --usb します。すると DHU は Android Auto のUSB接続を待機する状態になります。

結構すぐに接続待ちを諦めてしまうので、これをやる前に繋げておくのがおすすめです。

この状態でスマートフォンを接続すると GUI が起動します。

DHU の設定について

クルマによってナビの形態は様々です。画面サイズが違ったり、タッチパネルを搭載していなかったり、ダイヤルをクルクル回してメニューを移動する「ロータリーコントローラー」が搭載されていたりします。

シフトレバー下にあるのが「ロータリーコントローラー」です。左右にクルクルと回せます

DHU では、そういったナビのエミュレートできるよう、.ini ファイルに項目を記述して設定することが可能です。
また DHU はクルマのセンサ入力をエミュレートする機能も利用可能です。クルマの速度、ガソリンの残量、ジャイロの状態などをターミナルから入力して反映できます。
developer.android.com

例えば現在の速度をコマンド入力することで DHU に反映できます。

ただしこれではまだ反映されません。DHU は普通に起動するだけでは各種センサが OFF の状態になっています。上のコマンドは成功したように見えますが、DHU に接続しているアプリケーションには何も飛んできません。

設定を書く方法はドキュメントにも書いていますが、試しに速度の設定を書いてみましょう。
developer.android.com


設定ファイルは .ini ファイルです。今回は設定ファイルのデフォルトファイルパス ~/.android/headunit.ini にはおかずに、別の場所に作ってみましょう。どこでもいいです。

[sensors]
  speed = true # speed [speed] に必要

他にもセンサを追加したければ並べて書けば良いです。

[sensors]
  accelerometer = true # accel [x] [y] [z] を利用するために必要
  compass = true # compass bearing [pitch] [roll] に必要
  fuel = true # fuel [percentage] に必要
  gyroscope = true # gyro [x] [y] [z] に必要
  location = true # location lat long [accuracy] [altitude] [speed] [bearing] に必要
  odometer = true # odometer km [current_trip_km] に必要
  speed = true # speed [speed] に必要

センサの一覧はこちら
developer.android.com

設定ファイルを書いたら、起動時に指定して読み込ませましょう。

> ./desktop-head-unit --usb -c ~/Desktop/headunit.ini
Android Auto - Desktop Head Unit
  Build: 2022-03-30-438482292
  Version: 2.0-mac-arm64

[I]: Loaded configuration from '/Users/funnelbit/Desktop/headunit.ini'.
BoringSSL is the SSL implementation used in the receiver-lib.
Starting link. Requested protocol version: 1.7
[I]: Searching for compatible USB device...
[I]: Found 5 USB devices.
[I]: Found device 'Google Pixel 7' in accessory mode (vid=18d1, pid=2d01).
[I]: Found accessory: ifnum: 0, rd_ep: 129, wr_ep: 1
[I]: Attaching to USB device...
[I]: Attached!
> Phone reported protocol version 1.7
ssl state=TLS client read_server_hello -1
ssl state=TLS client process_change_cipher_spec -1
ssl state=SSL negotiation finished successfully 1
SSL version=TLSv1.2 Cipher name=ECDHE-RSA-AES128-GCM-SHA256
Verify returned: ok

ちゃんとheadunit.iniを読み取ることができていれば、[I]: Loaded configuration from '/Users/funnelbit/Desktop/headunit.ini'. の表示が出るのでわかります。ただし設定ファイルの記述が間違っていてもエラーとか出ませんが。

ビルドして表示してみる

あとは簡単です。ビルドボタンを押してスマートフォンにアプリをインストールします。そして DHU を再起動してください。

アプリが出ていますね。アイコンを選択してみましょう。


無事に画面が出ました!ところで Car API Level は 6 と表示されています。ライブラリの更新履歴を見る限り、てっきり 5 が上限だと思っていたのですが、ドキュメント漁ってみるとしれっとCar API Level 6 が定義されていました。
developer.android.com

バージョン番号を定義しているファイルを覗いてみても LEVEL_6 の存在が確認できます。

ただし Car API Level 6 で何ができるのか、ドキュメントにもコードにも今の所情報はありません。

とりあえず動いたので、次は実車に接続してナビ画面に出してみましょうか。

Mazda3 のスローパンクチャー対処その1

1ヶ月に一度はタイヤのエアーチェックして空気を入れているのですが、フロント左側だけ著しく減少していて190kPaを下回っていました。

あ〜〜スローパンクっぽいな〜と思い1週間様子見たかったのですが翌日長距離運転する必要があり、すぐさま修復したかったのでディーラーに行きました。

しかし原因不明 😇

タイヤに穴はなく、水槽に沈めてみてもわかりませんでした、とのこと。


その後1週間様子をみましたがやはり減りが早い。他のタイヤと比べて 30kPa 早く減っています。


プロだったら見つけてほしいな...とぼやきつつ、また持っていくのもめんどくさいのでできる部分まで自分でやることにしました。


まず、タイヤには穴がありませんでした、といったディーラーの言葉を信じることにし、どのみち自分の技術で修理できる部分はバルブ周りしかないのでバルブにマトを絞ります。石鹸水をバルブ周りに塗って観察してもいいのですが、腕が微妙とはいえディーラが見つけられなかったのですぐさまわからないだろうと判断。投機的に工具・部品を買ってバルブ周りの修理を行うことにしました。


バルブ周りの原因は「バルブコア」の取り付けが緩んでいるか、「バルブコア」が劣化しているか、バルブコアが刺さった「ゴムバルブ」が劣化している、この3つしかないと思い、またこの順に難易度が上がっていくので、簡単な順番に対処していくことにしました。ゴムバルブはもう自分ではできませんけど。


バルブコアが緩んでいる場合は、バルブコアを閉めれば直るのですが工具が必要です。車社会に身を投じてから今後も必要になると思い amazon で買いました。

車載工具でも入ってはいたのですが、プラスチックで明らかにちゃっちいし、二輪時代の経験では車載工具は本当に緊急事態にやむなく使うものであるので、買っておいて損はないはず。


同時にバルブコアも買いました。今使わなくとも今後必要な機会が出てくるかもしれないし。


バルブコアを交換する場合はジャッキアップが必要ですが、車載ジャッキしか持っていませんでした。本格的な整備にはジャッキが欲しいけどまあバルブ交換で浮かせるぐらいなら車載でいいか...と思い今回は買ってないです。実家に行けば本格的なやつもありますし。



とりあえず先ほど届いたのでバルブコアを回してみました。時計回りで閉まります。若干締め付け余地があったのでクイっと右に閉めました。閉めすぎると良くないと思うのでほんの少しだけです。


ホームセンターで売ってる安い空気入れでシュコシュコして空気を入れます。



エアゲージで250kpsまで入ってるのを確認。宣伝みたいになりますがこのエアゲージはマジでおすすめです。前はバルブに押しつけて測るやつ使ってましたがシュコシュコ抜けてストレスが半端なかった。

あとはまた1週間程度様子見です。これで直るといいな。

Android Auto を学ぶその1 とにかく表示してみる

Android Auto を学びたいな〜と思って勉強してみることにしました。

概要

Android で自動車にてアプリを動かす仕組みは2つあります。一つは Android Auto、もう一つが Android Automotive。

Android Auto はスマートフォンで動くアプリを車のナビ画面に投影するイメージ。ナビ側が Android Auto をサポートしている必要があります。動作にはスマートフォンが必要で、純粋な車用アプリというよりも、スマフォ用アプリに車用画面を用意してあげる、という形になります。

Android Automotive は、車のナビに Android OS が組み込まれている場合に利用できます。スマートフォンを必要とせず、車の画面のみでアプリをインストールできます。この機能を積んでいる車は2022/09/26現在市場にまだ全然ないはずで、おそらくボルボとポルシェの一部にしか存在していないと思います。日本車に至っては1台も存在しない気がします。

自分の持っている車(Mazda3)は Android Automotive を使うことができないので、Android Auto にまとを絞って学んでいきます。なお、Android Auto が前提のアプリは避けるべきであるため、基本的には車に接続しなくとも単体で動くアプリ + 車用画面があるアプリとするべきですが、ここではとりあえず Android Auto の画面だけに絞っています。

ホストについて

Android Auto 対応車で作成したアプリを動かすとき、アプリは車と直接やりとりする訳ではありません。このときの接続先はスマフォに入っている Android Auto アプリです。Android Auto アプリはホストと呼ばれ、全ての Auto 対応アプリはこのホストとやりとりします。もし Android Automotive 対応の車で動かす場合は車載器に OS が入っているので、Android Automotive がホストになります。

developer.android.com このドキュメントに、ホストとは何かが記載されています。

ホストは、ライブラリの API によって提供される、アプリを車で実行するための機能を実装するバックエンド コンポーネントです。ホストは、アプリの検出とそのライフサイクルの管理、モデルのビューへの変換、ユーザー操作のアプリへの通知にいたるまで、幅広い役割を担います。モバイル デバイスでは、このホストは Android Auto によって実装されます。

API Level について

Android Auto には API Level という概念が存在しています。これはどの機能までを利用可能かを示すものです。ホストのバージョンに左右されます。現在利用可能なのは API Level 5です。これを動かすには Android Auto 8.1 以降のアプリが端末にインストールされている必要があります。

個人的に重要と考えているのは API Level 3 で追加された API です。これがあると CarHardwareManager を利用できます。CarHardwareManager を使うことができれば、車のコンピュータから吐き出される速度、燃料残量、ジャイロ、位置情報、ETCカードの状態などを取得することが可能になるのです。絶対取得したいですよね!!それぞれの API Level で何ができるかはリリースノートを参照することでわかります。

developer.android.com

とりあえず文字列を表示してみる

まずは簡単な表示を、簡単なコードで試してみるのが良さそうです。早速コードを書いてみることにします。まずは何を書く必要があるのかをみてみましょう。

  • gradle に Android Auto の依存を追加する
  • AndroidManifest.xmlAndroid Auto に必要な記述を追加する
  • automotiveApp を記載した xml リソースを作成する
  • androidx.car.app.CarAppService クラスを実装する
  • androidx.car.app.CarAppService から起動される androidx.car.app.Session を実装する
  • androidx.car.app.Session から呼ばれる androidx.car.app.Screen を実装する

gradle に Android Auto の依存を追加する

app/build.gradle に、以下の依存を追加します。

dependencies {
    implementation "androidx.car.app:app:1.3.0-beta01"
    implementation "androidx.car.app:app-projected:1.3.0-beta01"
}

2022/11/07時点の最新版は 1.3.0-beta01 であり、これを試すのであれば API Level 5 の機能が利用可能になります。端末に最新の Android Auto アプリをインストールしておきましょう(8.1 以降でないと API 呼び出しでクラッシュします)。 余談ですが1.3.0系がでた当初、Android Auto は 8.1 より前のバージョンしかストアにありませんでした。そのため 1.3.0 を利用するには Android Auto アプリのテスターになってより新しいバージョンを得る必要があったのかなと思います。ですがテスター募集はもう締め切られているので、SDK はあるが動かせない状態になっていました。

AndroidManifest.xmlAndroid Auto に必要な記述を追加する

まずは既存の application 内にエントリポイントとなる service クラスの記述を追加しましょう。

<service
            android:name=".SampleAndroidAutoAppService"
            android:exported="true">
            <intent-filter>
                <action android:name="androidx.car.app.CarAppService"/>
            </intent-filter>
        </service>

次に、最低限必要な CarApiLevel 、及びテンプレートを利用する旨を宣言しましょう。automotive_app_desc ファイルは次で作成します。

<application>
...
        <meta-data
            android:name="androidx.car.app.minCarApiLevel"
            android:value="1"
            tools:ignore="MetadataTagInsideApplicationTag" />
        <meta-data
            android:name="com.google.android.gms.car.application"
            android:resource="@xml/automotive_app_desc"/>
</application>

automotiveApp を記載した xml リソースを作成する

automotive_app_desc.xml を作成しましょう。今回のアプリでは、情報を表示するためにテンプレートと言うものを使うことになります。この時、<automotiveApp> 内に利用する旨を宣言する必要があります。

<?xml version="1.0" encoding="utf-8"?>
<automotiveApp>
  <uses name="template" />
</automotiveApp>

もしsmsを利用するアプリを作りたい場合、ここに <uses name="sms" /> を、またメディアアプリを作りたい場合、<uses name="media"/> を宣言することになります。

androidx.car.app.CarAppService クラスを実装する

Android Auto では、Activity ではなく Service が起動されます。そして車向けアプリ用として Service を拡張した CarAppService が用意されているので、これを継承する形になります。

developer.android.com

class SampleAndroidAutoAppService : CarAppService() {
    override fun createHostValidator(): HostValidator {
        return if (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0) {
            HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
        } else {
            HostValidator.Builder(applicationContext)
                .addAllowedHosts(R.array.hosts_allowlist_sample)
                .build()
        }
    }

    override fun onCreateSession(): Session {
        return SampleAndroidAutoAppSession()
    }
}

createHostValidator は、接続しているホストをチェックするものです。何をチェックしたいのでしょうか?今回の場合、Android Auto アプリからの呼び出しかどうかをチェックしているのです。

今回のコードの場合、デバッグ中は HostValidator.ALLOW_ALL_HOSTS_VALIDATOR を返すことになります。この実態は空っぽの許可リストを持ち、全てを許可するフラグを ON にした HostValidator クラスのインスタンスです。

@NonNull
    public static final HostValidator ALLOW_ALL_HOSTS_VALIDATOR = new HostValidator(null,
            new HashMap<>(), true);

そうでない場合は else に入ることになります。ここでは、R.array.hosts_allowist_sample をビルダに渡していますが、この中身はどうなっているのでしょう?

    <string-array name="hosts_allowlist_sample" translatable="false">
        

        
        <item>fdb00c43dbde8b51cb312aa81d3b5fa17713adb94b28f598d77f8eb89daceedf,
            com.google.android.projection.gearhead</item>
        
        <item>70811a3eacfd2e83e18da9bfede52df16ce91f2e69a44d21f18ab66991130771,
            com.google.android.projection.gearhead</item>
        
        <item>1975b2f17177bc89a5dff31f9e64a6cae281a53dc1d1d59b1d147fe1c82afa00,
            com.google.android.projection.gearhead</item>

        
        <item>c241ffbc8e287c4e9a4ad19632ba1b1351ad361d5177b7d7b29859bd2b7fc631,
            com.google.android.apps.automotive.templates.host</item>
        
        <item>dd66deaf312d8daec7adbe85a218ecc8c64f3b152f9b5998d5b29300c2623f61,
            com.google.android.apps.automotive.templates.host</item>
        
        <item>50e603d333c6049a37bd751375d08f3bd0abebd33facd30bd17b64b89658b421,
            com.google.android.apps.automotive.templates.host</item>
    </string-array>

com.google.android.projection.gearhead とはなんでしょうか?その答えは Android Auto アプリのパッケージ名です。 https://play.google.com/store/apps/details?id=com.google.android.projection.gearhead&hl=ja&gl=US

試しに、DHU に接続すると com.google.android.projection.gearhead パッケージ名を検証している様子を窺い知ることができます。

androidx.car.app.CarAppService から起動される androidx.car.app.Session を実装する

Session を実装しましょう。Session は onCreateScreen に abstract メソッドがあるのですが、これを上書きして Screen を実装します。この Screen で、カーナビ画面に表示する画面を実装しますが、ここでは Screen を実装するクラスを別に分けることにします。

class SampleAndroidAutoAppSession : Session() {
    override fun onCreateScreen(intent: Intent): Screen {
        return SampleAndroidAutoAppScreen(carContext)
    }
}

Screen を実装するには carContext が必要です。これは Session がプロパティとして持っているのでそのまま渡せば良いです。

androidx.car.app.Session から呼ばれる androidx.car.app.Screen を実装する

クルマの画面に出す UI を実装しましょう!Android Auto では、自由に UI を作成するのは許されていません(とある技を使えばなんでも表示できますが、規約的に許されていません)。Template から選んで表示することになります。 developer.android.com

また、表示内容の更新回数にも厳しい制限があることに注意が必要です。Template によって異なりますが、今回書くコードの場合、6回表示内容を更新したらクラッシュします。 developer.android.com

class SampleAndroidAutoAppScreen(carContext: CarContext) : Screen(carContext) {
    override fun onGetTemplate(): Template {
        val carAppLevel = carContext.carAppApiLevel
        val row = Row.Builder().setTitle("carAppLevel: $carAppLevel").build()
        val pane = Pane.Builder().addRow(row).build()
        return PaneTemplate.Builder(pane)
            .setHeaderAction(Action.APP_ICON)
            .build()
    }
}

ここまでやれば、とりあえず実行して画面に表示できるはずです!次回は DHU(Desktop Head Unit) を使い、コンピュータの画面上で Android Auto をテストしてみましょう。

JetpackCompose にて、画面離脱時に非同期で何かする

画面を離脱するときに、何かしらの処理を行いたいことがあります。例えば画面を離脱するときに DB になんか記録するとか、API を叩いておく、とかです。アプリが動いている間にやってくれれば良い程度で、バックグラウンドに回った時のことは考えません。

画面離脱時をハンドリング

画面を離脱するときは DisposableEffect(Unit)onDispose でフックすると良さそうです。DisposableEffect はクリーンアップ処理が可能な副作用で、key に Unit を指定すれば「画面から離脱する時の処理ブロック」を実現できます。

@Composable
fun SampleScreen() {
...
    DisposableEffect(Unit) {
        onDispose {
   // ここで処理を書きたい 
        }
    }
...

developer.android.com

自分は基本的に ViewModel を利用しているので、ViewModel を用意して `recordData()` という関数を書いてみる事にします。

@HiltViewModel
class SampleScreenViewModel @Inject constructor() : ViewModel() {
...

  fun recordData() { 
       // データを保存する処理を書く予定
  }

...
}


fun SampleScreen() { 
...
  DisposableEffect(Unit) {
    onDispose {
      viewModel.recordData()
    }
  }
...

ViewModel に処理を書く

ViewModel の recordData() 関数を実装しましょう。メインスレッドを止めたくはないので、recordData() のなかで非同期に処理を実行させたいです。

@HiltViewModel
class SampleScreenViewModel @Inject constructor() : ViewModel() {
...

fun recordData() {
  viewModelScope.launch { 
     // データを保存する
   }
}

...
}

で、↑は間違いです。viewModelScope を使っているので、画面離脱時にこの ViewModel も破棄されるため、viewModelScope ではコルーチンの処理がキャンセルされてしまいます。


DB に書き込みするだけであれば、ひょっとするとうまく動くかもしれません。SQLite への書き込み処理が早いためです。もう少し時間のかかる処理の場合は ViewModel の破棄が走り、コルーチンはキャンセルされる事でしょう。


よって、このコンポーザブルが破棄されても動くことのできるより広いスコープを使う必要があります。手っ取り早くGlobalScope (デリケート扱いです)を使ってみましょう。

@HiltViewModel
class SampleViewModel @Inject constructor() : ViewModel() {
...

fun recordData() {
  GlobalScope.launch { 
     // データを保存する
   }
}

...
}

↑の例は動きますが、ハードコードしていて良くありません。ここの場合だと、テスト時に問題になる可能性があります。よって外から注入できるようにしておくと良いでしょう。

@HiltViewModel
class SampleViewModel @Inject constructor(private val externalScope: CoroutineScope = GlobalScope) : ViewModel() {
...

fun recordData() {
  externalScope.launch { 
      // データを保存する
   }
}

...
}

GlobalScope は先ほども書いた通りデリケート扱いですが、そこまで時間を占有しない処理で、処理をキャンセルする必要がなく、処理の生存期間がアプリケーション起動中とイコールな場合は利用しても良いと思います。
GlobalScope

または、withContext(NonCancellable) を使うという選択肢もありそうです。
https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-non-cancellable/

React のエンジニアが Jetpack Compose を勉強してみた

育休前は React と go をよく書いていましたが、育休が終わって Android の仕事につくことになったので、Jetpack Compose の勉強をしました。といっても自分ばズブの素人というわけではなく、Jetpack Compose が導入される前には Android のエンジニアで、確かマルチモジュールが世の中に出始めたかな〜ぐらいの時代で Web 方面に進んだので、そこらへんの時代で知識が止まっています。

Jetpack Compose とは

https://developer.android.com/jetpack/compose
Android ネイティブアプリケーションを、宣言的 UI でアプリを作ることができるキットです。これまで Android は Activity(いわばウィンドウ。一画面に必ず1つしかない) や Fragment(いわばウィンドウの上における、View の土台みたいなもの。1画面、つまり Activity 上に複数置くことができる) という上に、xml でレイアウトを指定(HTMLみたいな感じ)して、描画された View に対し、Activity や Fragment にコードを書いて View をバインドしたり、クリックイベントを記述したりしていました。

Jetpack Compose では、土台は Activity や Fragment ではあるものの、この2つはもっぱら Jetpack Compose のルート要素置き場として機能します。xml でレイアウトを書く手法は廃止され、表示用コードとロジックが合体しているような感覚です。ものすごく雑にいうと React みたいなやつです。

React の知識と照らし合わせて勉強

https://developer.android.com/courses/android-basics-compose/course
https://developer.android.com/courses/pathways/compose
ドキュメントやコードラボをやっていきました。コードラボでコードを書きながら、「これは React でいうこれなんだな」という突き合わせをしながら理解していきました。その上で Compose ならではの理解を深めていくと良いのかなと思っています。

React で言うコンポーネントは Composable な関数で表す

React で以下のように書くと、ファンネル勤怠 と書かれたテキストが表示されます。

 function Title() {
   return <div>ファンネル勤怠</div>;
 }

これを Android でやろうとするとこうなるわけです。

 @Composable
 fun Title() {
  Text(text = "ファンネル勤怠") 
 }

なんとなく、Web エンジニアでも親しみやすい感じになっているのではないでしょうか。やはり宣言的 UI というだけあって React によく似ています。React と違って Class でコンポーネントは作れず、全て関数で UI を作ります。またコンポーネントとは言わずコンポーザブル(composable)と言います。UIは、状態が変化したらコンポーズ(compose)され、その際にコンポーザブルな関数がひとしきり呼ばれ再描画される仕組みです。

もう一点、大きな違いがあります。それは描画する要素を戻り値として返す必要があるか、ないかという部分です。

今一度 React のコードを見てみると

 function Title() {
   return <div>ファンネル勤怠</div>;
 }

ReactElement を返しているのがわかります。つまり、UI は関数の最後でまとめて return する必要があります。

ところが Jetpack Compose の関数は返り値は Unit です。これは Typescript でいう Void に相当します。すなわち、UI を関数の最後で返す必要はなく、

 @Composable
 fun Title() {
  Text(text = "ファンネル勤怠")
  val mes = "xxxxx"
  Box {
    Text(text = "詳細")
    println(mes)
  }
  println(mes)
 }

こういった記述も可能なわけです。

Compose で最高に楽になったのはリストではないでしょうか。従来は RecyclerView という View クラスを実装することでリストを作っていました。これはAdapter とか ViewHolder とか登場人物が少し多く、細かなコードを書いてリスト中の item をどのように使い回すかを書く必要がありました。そうしないとスクロール時にパフォーマンスが悪化するからです。

Compose だとこのように書けます

    LazyColumn {
        items(items = items, key = { item -> item.id }) {
            WorkingTimesListItem(
                item = it,
                onClickDetail = onClickDetail
            )
        }
    }

key がついているのは React 経験者ならピンとくるものであると思います。ちなみに Kotlin は関数の引数で、一番うしろにあるラムダは、関数の引数ブロックを飛び出して、{ ... } ブロックを使って書くことが出来ます。 LazyColumn も、本来であれば LazyColumn(content: ここに Composable な関数を書く) となるのですが、言語機能によってこう書くことができるため、宣言的 UI としてネストしていったとしても読みやすい、親和性の高いものになっている気がします。

状態

全ては再コンポーズされるので、そのままだとコンポーズ関数内に状態を保持し続けることは出来ません。React では useState を使っていたりしますが、Compose にもにたものは用意されています。

 function XXX() {
 ...
   var isClicked by remember { mutableStateOf(false) }
   ...

こう宣言すれば、isClicked は再コンポーズ後でも失われないので、状態を保持できます。by は Property を委譲する Kotlin の言語機能です。Kotlin では、今回のように宣言した var の場合は 、デフォルトで setter と getter が生えるのですが、この setter と getter を自分で指定できます。

    var name: String = name
        set(value) {
            field  = value.toLowerCase().capitalize()
            updateCount++
        }

ここで by を使うと、setter と getter に使う関数(つまり、setter と getter を実装した関数)を指定できます。これによって、setter、getter のロジックを抽出し、テストしやすくしたり、再利用性を向上させるのですが、Compose はこの機能を利用して、簡単に状態を保持する事のできる remember を提供している、ということになります。そしてこの remember もコンポーザブルです。

ビジネスロジックを兼ね合わせた状態を考えた場合、ViewModel を利用することになります。https://developer.android.com/jetpack/compose/state#viewmodels-source-of-truth

詳細はこちら https://developer.android.com/jetpack/compose/state

副作用

複雑なアプリを作っていくとどうしても副作用を使わざるを得ないケースが出てきます。Compose は副作用もサポートしています。

useEffect と LaunchedEffect

例えばReact を思い返すと、useEffect があると思います。

 useEffect(() => {
   update();
 });

これに対応するものとして、LaunchedEffect があります。

 LaunchedEffect(Unit) {
 	update()
 }

どちらも再生成に伴いに副作用が走るものになっています。つまり、どちらもコンポーネント、Composable のライフサイクルに沿った形で副作用を行っているということになります。両者とも、新しい副作用を実行するときに、前回の副作用はキャンセルされます。

これを 特定の値が変更された場合のみ副作用を走らせようとする と、React では以下のようになります。例えば id が変わった場合にのみ update() を試みる場合。

 const [id, setId] = useState(defaultId);
 
 useEffect(() => {
   update(id)
 }, [id]);

Composable ではどうでしょうか?

 @Composable
 var id by remember { mutableStateOf(defaultId) }
 
 LaunchedEffect(id) {
 	update(id)
 }

LaunchedEffect 関数にわたす1つ目の引数は React の dependencies array と相似しています。両者とも id が変化しなければ、再生成されても再び実行されません。

LaunchedEffect にわたす関数はコルーチンスコープ内で実行されます。つまり API を叩いたりだとかで UI をブロックされることはありませんし、suspend 関数という非同期処理を呼ぶことが出来ます。

ところで、React では useEffect にはクリーンアップ関数をつけることが出来ます。

  
  useEffect(() => {
    subscribe()
    
    return function cleanup() {
    	  unsubscribe()
    }
  });

これは Compose においては DisposableEffect を使って表現できます。クリーンアップ関数をつけると言うよりも、クリーンアップ関数が必要な場合は DisposableEffect を使う、という感じです。

 DisposableEffect(Unit) {
         subscribe()
         onDispose {
             unsubscribe()
         }
     }

React でいう Context

React では、多層な階層で状態を伝達させていくとバケツリレーが大変であることから、context の利用を考えることがあります。https://ja.reactjs.org/docs/context.html

そこまで頻繁に利用するものでもない気がしますが、例えば react-hook-form なんか使うと体験することがあるのではないでしょうか。

似たものは Compose にも用意されていました。CompositionLocal です。
https://developer.android.com/jetpack/compose/compositionlocal
例では、デザインに使う色をトップの階層で決めたとしても、その色を遥か下の階層で使う場合はめっちゃバケツリレーが必要で不便です、バケツリレーしたくありません、というものです。

まだ自分では使ったことがありませんが、 自分はアプリのデザインをマテリアルデザインで統一する MaterialTheme object は触っており、色やら文字のサイズやらを様々な階層の Composable で触ることになるのですが、これにも CompositionLocal の実装が使われているみたいです。

ナビゲーション

自分が過去に関わったプロダクトでは、フロントエンドのナビゲーションには React を Next.js 上で動かし、そこで Router を使っています。Next.js では、というか Web には URL がありますので、各々の場所でURL を指定して遷移すると思います。 Composable ではナビゲーションを司る Composalbe NavHost が用意されているので、通常はそちらを利用します。

まず、遷移する Composable を全部 NavHost Composable で囲みます。ここで遷移する画面を登録するイメージです。

https://developer.android.com/jetpack/compose/navigation#create-navhost

 NavHost(navController = navController, startDestination = "profile") {
     composable(route = "profile") { Profile(navController = navController) }
     composable(route = "friendslist") { FriendsList(navController = navController) }
     /*...*/
 }

NavHost を親とし、Profile Composable や FriendList Composable を遷移名(route)と紐付けて置いておきます。遷移するときは、この遷移名を使い、NavController を使って遷移します。navController は NavHost から受け取ります。

 @Composable
 fun Profile(navController: NavController) {
     /*...*/
     Button(onClick = { navController.navigate("friendslist") }) {
         Text(text = "Navigate next")
     }
     /*...*/
 }

route で指定している文字列は、定数化したりして記述ミスを防ぐのが一般的なようです。

デザイン

React では、各 DOM に対して style props を指定するのが最も原始的な方法になります。

 <p style={backgroundColor:"red"}>Color</p>

同じアプローチは Composable にも用意されています。Modifier です。

 Text(
   text = "Color",
   modifier = Modifier.background(color = Color.Red)
 )

ただし、React で直接 style props を渡すのは、パフォーマンスの面でも、管理の面でもおすすめしかねるので、スタイルを別に定義し className でスタイル名を指定するのが一般的です。

 <p className="title">Color</p>

Composable の場合、デザインが自由自在な Web とは少し事情が異なります。というのも、Android というプラットフォームの上で動く以上、Android の世界観を反映したデザインにする必要性があるからです。この場合、テーマ機能を使ってデザインを統一するのが一般的なようです。

https://developer.android.com/jetpack/compose/themes/material

これは MaterialTheme Composalbe を使うことになると思います。全体にテーマを当てたいので、最もトップの階層で MaterialTheme Composable を置いて、その下に様々な Composable を置いていく感じです。以下の例では、isSystemInDarkTheme() を使って、ダークモード中であれば暗い色を指定するようにしています。

 val Teal200 = Color(0xFF03DAC5)
 
 val Indigo200 = Color(0xFF9FA8DA)
 val Indigo500 = Color(0xFF3F51B5)
 val Indigo700 = Color(0xFF303F9F)
 val BlueGray50 = Color(0xFFECEFF1)
 val BlueGray900 = Color(0xFF263238)
 
 private val DarkColorPalette = darkColors(
     primary = Indigo200,
     primaryVariant = Indigo700,
     secondary = Teal200,
     background = BlueGray900
 )
 
 private val LightColorPalette = lightColors(
     primary = Indigo500,
     primaryVariant = Indigo700,
     secondary = Teal200,
     background = BlueGray50
 )
 
 @Composable
 fun TryJetpackComposeTheme(
     darkTheme: Boolean = isSystemInDarkTheme(),
     content: @Composable () -> Unit
 ) {
     val colors = if (darkTheme) {
         DarkColorPalette
     } else {
         LightColorPalette
     }
 
     MaterialTheme(
         colors = colors,
         typography = Typography,
         shapes = Shapes,
         content = content
     )
 }
 
 @Composable
 fun TryJetpackComposeApp() {
     TryJetpackComposeTheme {
         val navController = rememberNavController()
         val items = listOf(Screen.Home.name, Screen.Stamp.name)
 
         Scaffold(...) {
   ....

従来では、色は colors.xml に記述していって、それを各地で使うのが一般的でしたが、今回からは変数で色を定義のが推奨されています。

所感

やっぱり宣言的 UI ということで React の知識と結びつけることができるものになっています。Android 固有のライフサイクルを意識する場面は圧倒的に少なくなり、React と同じようなライフサイクルを意識すれば良くなったのは嬉しいです。

コードを書く量も削減されていて、昔のように Activity や Fragment を用意して、XML にレイアウトをかき、DataBinding で bind して、リストの場合は RecyclerView の諸々のコードを用意して...という手間が一挙に無くなったのが嬉しいです。カスタム View 的なものも作りやすくなりました。開発に時間がかかるのが Android のネックな部分だなあと思っていたのですが、その弱点がなくなった印象です。

また Google 公式でドキュメントやコードラボが充実しているため、確かな知識を素早くつけることができる点も魅力です。

ツーリングマップル

初めてツーリングマップルを買ったのは14年前。狂ったようにバイクを乗っていた時代。当時はバイク用のナビはおろかスマフォは存在すらなく、僕はお金もなかったのでケータイの使い放題プランにすら入っておらず、デジタルで自分の位置や地図を表示するすべはありませんでした。


そのためツーリングマップルをバイクに積載し、地図をある程度頭の中に入れつつ、案内板を見て目的地を目指していました。ツーリングマップルは観光案内にもなるスグレモノで、宿でメンバーみんなでツーリングマップルを見ながらワイワイとルート設定したのを覚えています。


そして時が経った現在。GoolgeMap が素晴らしくよくできていて、ツーリングマップルも散々見たということでかつて使っていたツーリングマップルは友人にあげてしまい、もう買うことはないかなと思っていました。しかし、やはりツーリングマップルの情報というものは GoolgeMap がある今でも大いに価値があるものだと再認識。数年の時を経て再び購入しました。



そこでふと思いました。今あの時みたいにツーリングマップルだけでドライブしたらどうなるんだろう?と。今は車ですのでナビがありますし、スマフォで目的地の情報をいくらでも検索できるのですが、あえてそれらをやめてしまおうと思ったわけです。


そんなわけで、ナビとスマフォの検索を封印し、ツーリングマップルだけで目的地に向かって走ってみました。ツーリングマップルで目的地を考え、方面の地名や国道名をある程度覚えて走り出します。ナビを入れてないとなんとなく落ち着きません。車はバイクと違って車幅がでかいので、道を間違えてもUターンがしにくいため気を使います。


高速道を抜け、とりあえず目立つので確実にたどり着ける道の駅にやってきました。「道の駅あいの土山」です。


ここはツーリングマップルに「ソフトクリーム入れ放題」と書いていたので気になっていました。


これが入れ放題の現場です。

400円払うとアイスのコーンが渡されます。そのコーンにアイスを好きなだけ盛ってくださいということでした。



うーん頑張ったつもりですがもうちょっといけたかも....すれ違った子どもは凄まじい塊が載っていたので尊敬しています。



なんと初桜も売っていました。今回は保冷バックがなく、また家の冷蔵庫にはすでにたくさん日本酒が入っているので諦めました。



実はドロドロなので...遠目でパシャリ。


ここから土山宿を目指します。といってもすぐそこですけど。ナビが使えないのでそれっぽい案内板を探します。ナビなしツーリングは道を走るのは実は結構簡単で、目的地の細かい場所に到着するのが難しかったりします。事前に調べてもないのでマトモに車が入れる幅の道なのかとか一切わかりません。



とはいえそんな騒ぐようなものでもなく。一度素通りしてしまいましたが、細めの街道を少し走ると資料館に到着しました。駐車場もありました。入館料と駐車代、どちらも無料です。



この宿場町の全長は2.5kmだったそうです。この距離だと、現代人なら30分ぐらい歩いてもまだ宿場町を抜け出せない感じですかね。土山宿は難所「鈴鹿山脈」の前にある宿場町なので、必然的に多くの旅人が宿泊することになり大変賑わったそうです。



資料館の階を上がると、なにやら展示物が。



東海道宿場町の名物が並んでいました。各地の名物を食べるのが楽しみだったと書いてあって、今と変わらんなと思ったり。


宿場町も少し歩いてみました。



帰りは時間があったので下道で。もちろんナビ無しでツーリングマップルのみ。国道1号線を大津方面へ走りつつ、国道307号線経由で信楽を抜ける三段です。



国道307号線は道幅も十分にあり快走です。



信楽はもう何回走ったかわからないのでナビどころか地図も不要です。



茶畑があったのでよって帰りました。



全部ツーリングマップルで走り切ることで、少し昔の気持ちを思い出しました。ツーリングマップルを素早くバラララーとめくって現在地を探すあの手癖は今も健在でした。冒険感があって楽しいですね。なんでもかんでも効率よく進めるだけがベストではないことを教えてくれます。もっとも完全に地図を頭に叩き込めればナビよりも効率良くなるかもしれませんが。


次はもうちょっと複雑なルートを設定してみたいですね。