Infinito Nirone 7

白羽の矢を刺すスタイル

チームで育てるAndroidアプリ設計の一般販売が始まりました。

告知記事です。

Peaks のクラウドファンディングで執筆し先日電子版を先行リリースした「チームで育てるAndroidアプリ設計」の一般販売が始まりました。 これまではクラウドファンディングで出資いただいた方にのみ電子版・書籍の配送手続きをしていましたが、本日からクラウドファンディングに参加していない一般の方々にも電子版・物理本の購入をいただけるようになりました。

peaks.cc

チームで育てるAndroidアプリ設計とは

改めてこの書籍が何なのかを紹介すると、大小様々な規模のチームで継続的にAndroidアプリの開発をすすめていく中で直面するアーキテクチャの成長痛を乗り越えるためのノウハウを詰め込んだ本です。

アーキテクチャは一度整えればそれで終わるものではなく、プロダクトの成長やチームの成長とともに少しずつ形を変えていくものであるという考えのもと、最初の一歩としてどのようにアーキテクチャを選定しチームに根付かせていくか、またアプリの成長にともなって徐々に現れるひずみをどのように解消していくのか、実際の方法論を交えつつも根本にある思想や考え方、行動指針を示すことで、特定のチームにおける実例を他のチームにも活かせるプラクティスとしてまとめています。

新規事業の立ち上げから運用にいたるまでの比較的小規模なチームにおける事例を @kgmyshin さんが担当し、すでに成長を続けてきたサービスをさらに拡大していく比較的大規模なチームにおける事例を自分が担当しました。それぞれ4章分の内容があります。そして最後の章では大小それぞれの事例を振り返り、規模によらない共通点や規模によって異なるポイントをまとめています。

クラウドファンディング開始当初の意気込みなんかは次の記事に書いてありますのであわせてどうぞ。

blog.keithyokoma.dev

オンライン輪読会

実はこのプロジェクトはまだ続いていて、出版後にオンラインで輪読会を開催します。

techbookfest.connpass.com

全9回、各回で書籍の1章分の内容を輪読します。初回は早速明日4/27の22時からで、1週間ごとに読みすすめる予定です。 YouTube で配信予定で、アーカイブもあるので当日参加できない方もあとからご視聴いただけます。

MediaSessionManager を使って現在 active な MediaController を取得したい

MediaSessionManager は端末上で有効な media session を提供するクラスです。通常のアプリであれば、NotificationListenerService と組み合わせて現状どの media session が端末上で動いているかを取得する目的で使います。 端末上で動いている media session を取得する方法は次の2種類です。

  • MediaSessionManager.OnActiveSessionsChangedListener を使う
  • MediaSessionManager#getActiveSessions() を使う

どちらも media session に紐づいた MediaController のリストが得られます。

複数の MediaController からひとつ再生コントロールを握っているものを選ぶ

media session は音楽アプリが作成・保持し Android システムに登録するもので、音楽アプリが複数インストールされた端末では当然複数の異なる media session が存在することになります(同一のアプリが複数の media session を持つことも想定されているようですが、ここではひとつの音楽アプリがひとつの media session を持つ前提で考えます)。 MediaController は media session に紐づくので、複数の音楽アプリが動いているときには MediaController も複数存在します。media session 自体は排他的な動きをしない *1 ので、MediaSessionManagerMediaController をリストに詰めて返します。ただし、同じデバイスで複数のアプリが同時に音楽を再生できるわけではなく、ある音楽アプリが再生を開始すると他の音楽アプリは再生を停止します。

それでは、リストで得られる MediaController から現在再生コントロールの権限を握っているものを選び取るにはどうしたらよいでしょうか。

MediaSessionManager のドキュメントでは、MediaController のリストは重要度でソートされリストの先頭が最も重要な MediaController となることが書かれています。 この「重要度」を決めるファクターが何なのか、具体的な言及がないためいざ使おうとすると困るのですが、AOSP のコードを追いかけていくとソート順を決めるファクターに言及があります。

android.googlesource.com

この Javadoc からは PlaybackState#isActive() が true なものが先頭に来ることがわかります。 PlaybackState#isActive() は、現在再生リストの先頭にあり media session にも登録されている media item を読み込み中、再生中だったり、次の曲へ曲送りをしている最中だったりする場合に true になります。

ここで次のような操作をしたときに MediaSessionMangager#OnActiveSessionsChangedListener の動きを観測すると、Javadoc の記述通りの動きをしていることがわかります。ただし再生状態の変化が必ずしも OnActiveSessionsChangedListener を呼ばないことに気をつけます。

  1. 音楽アプリ A を起動する
  2. OnActiveSessionsChangedListener で得られるリスト: [音楽アプリ A の MediaController]
  3. 音楽アプリ A で再生を開始する
  4. OnActiveSessionsChangedListener は呼ばれない
  5. 音楽アプリ B を起動する
  6. OnActiveSessionsChangedListener で得られるリスト: [音楽アプリ A の MediaController, 音楽アプリ B の MediaController]

例えば、次のような操作をしたときも概ね想像通りリストがソートされます。

  1. 音楽アプリ A を起動する
  2. OnActiveSessionsChangedListener で得られるリスト: [音楽アプリ A の MediaController]
  3. 音楽アプリ A で再生を開始する
  4. OnActiveSessionsChangedListener は呼ばれない
  5. 音楽アプリ A で再生を停止する
  6. OnActiveSessionsChangedListener は呼ばれない
  7. 音楽アプリ B を起動する
  8. OnActiveSessionsChangedListener で得られるリスト: [音楽アプリ A の MediaController, 音楽アプリ B の MediaController]
  9. 音楽アプリ B で再生を開始する
  10. OnActiveSessionsChangedListener で得られるリスト: [音楽アプリ B の MediaController, 音楽アプリ A の MediaController]

ここまでで、 複数の MediaController からひとつ再生コントロールを握っているものを選ぶには OnActiveSessionsChangedListener 等で提供されるすべての MediaController の再生状態を監視し、 PlaybackState#isActive()true になったものを選択すればよいことがわかります。

複数の MediaController の再生状態が再生中になる場合

さて、先程の手順を少し変え、音楽アプリ A での再生停止をせず直接音楽アプリ B で再生を開始するとどうなるでしょうか。いつも次のような結果になるとは限りませんが、すこし直感に反する動きを見る場合があります。これはそれぞれの音楽アプリで再生・停止を実行するタイミングの微妙なズレで瞬間的に複数のアプリの PlaybackState#isActive()true になってしまうケースで起こることが考えられます。

  1. 音楽アプリ A を起動する
  2. OnActiveSessionsChangedListener で得られるリスト: [音楽アプリ A の MediaController]
  3. 音楽アプリ B を起動する
  4. OnActiveSessionsChangedListener で得られるリスト: [音楽アプリ A の MediaController, 音楽アプリ B の MediaController]
  5. 音楽アプリ A で再生を開始する
  6. OnActiveSessionsChangedListener は呼ばれない
  7. 音楽アプリ B で再生を開始する
  8. OnActiveSessionsChangedListener で得られるリスト: [音楽アプリ A の MediaController, 音楽アプリ B の MediaController]

MediaSessionManager がソート順を決められないため、音楽アプリ B が先頭に来るはずが実際には音楽アプリ A が先頭のままになっています。

ここまでは瞬間的に複数の音楽アプリが再生中であると判定してしまう可能性があることを見てみましたが、実際には単なるタイミングの問題による瞬間的な事象以外にも、複数の音楽アプリが再生中になり得るケースがあります。 少し前に述べた 同じデバイスで複数のアプリが同時に音楽を再生できるわけではなく、ある音楽アプリが再生を開始すると他の音楽アプリは再生を停止する という制約とは相容れないように見えますが、 Cast に代表されるスマートデバイスを考慮に入れるとこのケースも考えなくてはなりません。

Cast 等別のデバイスで音楽を再生するとき

多くの音楽アプリが Cast に対応しており、Nest Hub などで音楽を再生できるような機能をもっています。ある音楽アプリが Cast を使って他のデバイスで音楽を再生したとき、他の音楽アプリは Android スマートフォン上(あるいは Bluetooth 等でペアリングしたヘッドセット)で音楽の再生ができるようになります。 Cast を使用して再生しているアプリも、Android スマートフォン上で再生しているアプリも、どちらも media session を active にしているため、MediaSessionManager が返す MediaController も再生中のものはすべて PlaybackState#isActive()true になります。

このケースでどちらを選び取るかはアプリの仕様に依るところになりますが、Cast などのデバイスで再生しているのか Android スマートフォン上で再生しているかを見分けるには PlaybackInfo#getPlaybackType() を使います。 PlaybackInfo#getPlaybackType() は LOCAL か REMOTE かどちらかの定数を返してきます。LOCAL は Android スマートフォン上で再生、REMOTE は Cast などのデバイスで再生していることを示します。

Legacy MediaController と Jetpack MediaController

ここまで述べてきた MediaController ですが、2026年3月現在、実は大きく 2 種類の MediaController があります。 - Legacy MediaController: android.media.session.MediaControllerMediaSessionManager が返すもの - Jetpack MediaController: androidx.media3.session.MediaController

現状多くの場合 Jeptack MediaController を使うことが推奨されていて、Legacy MediaController から Jetpack MediaController へ変換する便利関数も用意されていますが、少し気をつけなければいけない点があります。

Playback State

Legacy MediaController のいう Playback State は音楽データを処理する仕組みの状態(初期状態、コンテンツの読み込み中、再生可能状態など)とコンテンツの再生状態(再生中、停止中など)の2つの状態を合わせた状態を指します。 一方 Jetpack MediaController のいう Playback State にはコンテンツの再生状態再生状態は含まれず、音楽データを処理する仕組みの状態のみを表すようになっています。 よって Jetpack MediaController で再生状態の確認をする場合は MediaController#isPlaying 等別の関数・コールバックを見ることになります。

Playback Info と Device Info

Legacy MediaController が提供する Playback Info に相当するものは Jetpack MediaController では Device Info です。 どちらも Playback type として LOCAL か REMOTE かを確認する手段を提供していますが、定数の定義が微妙に異なります。

  • Playback Info (定数はどちらも int)
    • LOCAL: 1
    • REMOTE: 2
  • Device Info (定数はどちらも int)
    • LOCAL: 0
    • REMOTE: 1

定数の比較を確実にするため、パッケージ名・クラス名を対応する MediaController に合わせて変える必要があります。

*1:ある音楽アプリの media session が active になったとき、他の音楽アプリの media session が inactive になるような制御はありません。

2026 をやっていく

2025 年を振り返り忘れたまま 2026 年になってしまったので、とりあえず 2026 年をやっていく記事を書いておきます。

バイクの大型免許を取る

自分の体格ではそもそもバイクの選択肢が少なく、780mm のようなごく標準的なシート高がギリギリ許容範囲の上限いっぱいなので大型バイクとなるとなおのこと選択肢は限られてしまいます。 ローダウンやアンコ抜きという追加のカスタムをすることでシート高を下げるという手もありますが、800mm を超えるシート高ではそれらのカスタムでも焼け石に水で自分からすると大して下がってないこともあり難しいところです。

そんな中メーカーが用意しているオプションで50mmほどシート高を下げられるモデルを用意しているのが BMW F800GS。 中型免許をとったばかりのときは恐怖しかなかったアドベンチャータイプのバイクですが、アフターマーケットのパーツではなくメーカー標準のオプションで760mmまでシート高が下がるのがありがたく、またこれだけのシート高がスタンダードな大型バイクもなかなかないので、ひとまずはこれに乗れるよう免許を取りに行こうと考えています。

AI の勉強をちゃんとする

あんまり真面目につかっておらずちょっとしたアドバイスを貰いに行く程度にしか使っていませんでしたが、気がついたらコードレビューの精度がめちゃくちゃ高くなっていたりして日頃の開発にガッツリ組み込んで使えるレベルになってきているので、もっと自分で使いながら勉強しなければ。

動画編集技術を上げる

去年の北海道ツーリング動画、基本切って貼ってつなぎ合わせる編集で動画を作りましたが、もう少し工夫できたらいいな。

youtu.be

kotlinx.datetime 0.7.x へのバージョンアップと DayOfWeek

DayOfWeek は曜日を示す列挙型で、月曜日を起点として順序付けの整数(1から7まで)を持っています*1。Java の実装は java.time.DayOfWeek で、Kotlin では kotlinx.datetime.DayOfWeek です。

kotlinx.datetime 自体は DateTime 専用のライブラリとして提供されていて、バージョン 0.6.x まで DayOfWeek の実体は java.time.DayOfWeek へのエイリアスになっていたので、実装はすべて java.time.DayOfWeek のものでした。

kotlinx.datetime 0.7.x では java.time への依存を減らす目的で破壊的変更が入っており、DayOfWeek も改めて Kotlin で書き直され java.time.DayOfWeek へのエイリアスではなくなりました。仕様としては java.time.DayOfWeek も kotlinx.datetime.DayOfWeek も ISO-8601 に従っているのでほぼ同じように扱えますが、書き直す過程で java.time.DayOfWeek にあったメソッドが置き換わったり完全になくなったりしているため、場合によってはパッケージの置き換え以外のマイグレーションが必要です。

DayOfWeek の足し算・引き算

DayOfWeek の足し算・引き算は曜日をn日分ずらす操作です。java.time.DayOfWeek にはそれぞれ plus / minus メソッドが定義されていて、Kotlin がうまくオペレーターのオーバーロードとして解釈してくれるので、次のコードはコンパイル・実行可能です。

// kotlinx.datetime 0.6.x までで動作するコード

// 月曜日の翌日なので火曜日になる
val tuesday = DayOfWeek.MONDAY + 1

// 月曜日の前日なので日曜日になる (内部で順序付けの整数をうまく計算してくれて7番目の曜日である日曜日にしてくれる)
val sunday = DayOfWeek.MONDAY - 1

kotlinx.datetime 0.7.x では書き直された enum に plus / minus メソッドの定義がないのでそもそもコンパイルができません。 このような操作が必要な場合は、次のように自分で拡張関数を用意しオペレーターをオーバーロードします。

// 曜日の宣言順序が Java の enum と一致しているので中身は Java の実装そのままでも可
operator fun DayOfWeek.plus(days: Int): DayOfWeek {
    val amount = (days % 7).toInt()
    return DayOfWeek.entries[(ordinal + (amount + 7)) % 7]
}

operator fun DayOfWeek.minus(days: Int): DayOfWeek {
    return plus(-(days % 7))
}

DayOfWeek.of

整数型から DayOfWeek を得るためのメソッドで、DayOfWeek をシリアライズするときに便利です(ISO-8601 で定義された数字とマッピングしてくれる)が、kotlinx.datetime 0.7.x の DayOfWeek は次の関数を使います。

public fun DayOfWeek(isoDayNumber: Int): DayOfWeek {
    require(isoDayNumber in 1..7) { "Expected ISO day-of-week number in 1..7, got $isoDayNumber" }
    return DayOfWeek.entries[isoDayNumber - 1]
}

*1:ISO-8601 で規格化されていますが、java.util.Calendar に定義されている曜日を表す定数はこの規格に従っていません

渋峠

バイクの納車から早いものでもう5ヶ月、気がつけばODOメーターは6000kmを優に超えている。明らかに乗り過ぎである。

そんなこんなでアクションカメラも揃えつつあり、ちょくちょく YouTube に動画も上げている。

www.youtube.com

www.youtube.com

www.youtube.com

直近だとGWに休暇を取って渋峠にある日本国道最高地点の石碑を見に行った。

www.youtube.com

実はここは以前自転車で登っていて、そのときは草津側からのヒルクライムでやってきていたが、今年はその道が雪崩の影響で通行止めだったので嬬恋村から万座ハイウェイを経由してたどり着いた。 その万座ハイウェイも過去にヒルクライムイベントに参加したときに自転車で走っている。

www.youtube.com

この他にももっと行ってみたい場所がある。動画編集はとてつもなく大変だが頑張ってあげていくぞ。!!!11

AndroidX Room でインデックスの手動マイグレーションを実装する

テーブルに張ったインデックスの制約が変わったときなど、手動マイグレーションをするときは次のようにマイグレーションを実装する。

  1. 同じカラムを持つ新しい別のテーブルを用意する
  2. 元のテーブルから新しいテーブルにデータを移し替える
  3. 元のテーブルをドロップする
  4. 新しいテーブルの名前を元のテーブルの名前に変更する
  5. インデックスを貼る
class SampleIndexMigration : Migration(1, 2) {
  override fun migrate(db: SupportSQLiteDatabase) {
    db.execSQL("CREATE TABLE IF NOT EXISTS `sample_new` (`foo` TEXT NOT NULL, `bar` TEXT NOT NULL, `baz` TEXT NOT NULL, PRIMARY KEY(`foo`)) ")
    db.execSQL("INSERT INTO `sample_new` (`foo`, `bar`, `baz`) SELECT `foo`, `bar`, `baz` FROM `sample`")
    db.execSQL("DROP TABLE `sample`")
    db.execSQL("ALTER TABLE `sample_new` RENAME TO `sample`")
    db.execSQL("CREATE INDEX IF NOT EXISTS `index_sample_baz` ON `sample` (`baz`)")
  }
}

SQL 文は Room が自動で生成する json から引っ張ってくると楽ができる(createSql で探して対応する SQL 文をコピペしよう)。

AndroidX Room の Migration では自前でトランザクションを開始しなくてよい

developer.android.com

ドキュメントにある通り、Room 側でトランザクションを開始してくれているので、自前で Migration を実装するときに改めてトランザクションを開始しなくてもよい。むしろ自分でトランザクションを開始・終了してしまうと無限に Migration が走り続けて StackOverflowError に至ることもある。

バイク納車の儀

これまで散々レンタカーやカーシェアでいろいろな車を異常な距離で乗り回し、いつかどこかで車をなどとふわっとしたことを考えていた中、気がついたらバイクを買っていた。

今年の10月に普通自動二輪免許を取得したが、免許取得中は「この重たくて危ない乗り物になぜ乗るのか」と思っていたし、シミュレータ教習で一緒に教習をしていた人は具体的にほしいモデルがあってどれにしようか迷っているという話をしているなか、自分は何も考えていなかった。趣味性の高い免許なのに特に何も考えていないまま取りに来るというのも少々おかしな話ではあるが、メーカー名は知っていてもそのメーカーがどんなモデルを出しているかはまるで知らなかった。

流石に免許だけ取って満足するのも勿体ないし、教習も最後の方はだいぶバイクに慣れて機会があればなにかに乗ってみたいと思っていたので、そのシミュレータ教習で聞きかじったモデル名や、自分で興味が湧いたものからいくつかを絞り出して乗れそうなものをあげてみたところ、次のモデルがよさそうに感じた。

  • ホンダ CBR400R
    • 最新モデルは RoadSync に対応していて使ってみたかった
  • ホンダ CB400SF
    • 教習車だから扱いはすでに慣れている
  • カワサキ エリミネーター
    • 教習で聞きかじったモデルで調べてみたら良さそうだった

なんともハチャメチャでどれも全然志向の違うバイクだが、どれも初心者に優しく扱いやすいバイクである(と評判)ということは共通している。 そして免許取得後にレンタルバイクで CBR400R とエリミネーターを試すことにしたところ、次のような感想を得た。

  • 自分の体格的にはシートが低いほうがいい(CBR400R のシート高が安心して扱えると思える上限で、道路の状態によっては少々不安になる)
  • 峠をガンガン攻めたりスピードを楽しむような走りよりは、ゆったり走るほうが性に合っている
  • 軽さ(といってもバイクはどれも人間に比べたら遥かに重いが)は正義

もうエリミネーター一択になってしまった。

11月末に福岡でエリミネーターを借りて乗って、その後帰ってきてからすぐさま近所のカワサキプラザに話を聞きに行った。 正直、話を聞きに行く前にすでにこのモデルがいかに人気であるかは知っていて、インターネットで見ても納車まで半年待ったという話もあちこちにあるので軽く話ができればいいや程度に思っていたが、なんと在庫があってもっと具体的な話ができるかもしれないという状況になった。少々(といってもお店がお休みの2日間だけ)時間をもらい、最終的に商談が進められる状況になったのでそのまま進めることに。お店が車体自体をすでに確保済みだったので、3週間で納車可能ということになった。

そんなわけで本日晴れてカワサキ エリミネーター納車されました。

今日はひとまず給油からのコンテナ駐輪場での出し入れをしてみたり、某バイク YouTuber がよく試乗につかっている海の森や若洲周辺をぶらついたり、ライコランドに行ったりした。 新車で慣らし運転が必要なのでしばらくはあまり遠くには行けないが、なじんできたら実家にバイクで帰ってみたりもしてみたい。