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 で配信予定で、アーカイブもあるので当日参加できない方もあとからご視聴いただけます。

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 がよく試乗につかっている海の森や若洲周辺をぶらついたり、ライコランドに行ったりした。 新車で慣らし運転が必要なのでしばらくはあまり遠くには行けないが、なじんできたら実家にバイクで帰ってみたりもしてみたい。

マルチプロセスに対応した DataStore とプロセス間の排他制御

androidx.datastore の DataStore はデータを非同期にストレージへ読み書きするためのフレームワークです。

DataStore は 1 つのファイルに対しデータの読み書きを実行するので、同一のプロセス内では必ず DataStore のインスタンスは 1 つ(シングルトン)に限らなければなりません。同一プロセス内で複数のおなじ DataStore インスタンスを使おうとすると例外が発生します。この制限は複数のプロセスで同じ DataStore を使う場合にも適用されますが、Java/Kotlin が提供している Mutex や Semaphore などではプロセスを超える排他制御ができないため、DataStore の作り方がすこし異なります。

複数のプロセスで同一の DataStore を使う場合、DataStore は MultiProcessDataStoreFactory をつかって初期化します。このとき、どのプロセスでも必ず MultiProcessDataStoreFactory を使って初期化しなければなりません。 MultiProcessDataStoreFactory を使うと、内部で FileLock を使ったファイルロックの仕組みで排他制御されます。

ファイルロックによる排他制御は MultiProcessCoordinator が実装していて、実はこの MultiProcessCoordinatorcreateMultiProcessCoordinator を呼び出すことで DataStore 以外の場所でも使えます。 次のコード例では DataStore は使わず Mutex を使うのと同じ感覚でクリティカルセクションを定義できます。 Kotlin では Mutex#withLock でブロックを抜けたときにロックを解除しますが、MultiProcessCoordinatorlock のブロックを抜けたときにファイルロックを解除します。

class Sample(
  coroutineScope: CoroutineScope,
) {
  private val lockFile = File(context.filesDir, "my_critical_section_lock")
  private val coordinator = createMultiProcessCoordinator(
    coroutineScope.coroutineContext,
    lockFile
  )

  suspend fun somethingImportant() {
    coordinator.lock {
      // critical section
      // do some work!
    }
  }
}

サウナにハマるきっかけになった温泉:箱根湯寮

この記事は「サウナAdvent Calendar 2023」の12日目の記事です。

adventar.org

箱根湯寮

箱根湯寮とは箱根湯本にある温泉です。

www.hakoneyuryo.jp

箱根湯本駅からも割りと近いのですが、激坂を登った先にあるため駅からシャトルバスで行くことをおすすめします。

温泉自体はアルカリ性の単純温泉のため肌の弱い自分にも優しく入りやすい温泉です。

一通り温泉を楽しんだらあとはサウナですね。私は以前、せまく暑苦しい空間でじっとしていないといけないことがよく理解できず、その後キンキンに冷えた水風呂に浸かるのもヒートショックになるじゃんと思っていたためサウナ自体を避けていましたが、箱根湯寮のサウナに入ってその思い込みが見事に崩れ去りサウナにハマりました。

ロウリュウサービス

箱根湯寮のサウナにはロウリュウサービスがあります。 日替わりで楽しめるアロマ水をサウナ石にかけ、一気に温度が上がったところで熱波師が大きな団扇で我々を仰ぎ始めます。

自分がロウリュウを受けた時は梅のフレーバーのアロマ水をかけていましたが、すぅっと心地よいアロマを感じた直後に浴びる熱風が素晴らしいですね。

一度にかけるアロマ水の量がそもそも多いのですが「アロマ水をかける→うちわで扇ぐ」を1セットとして何度もこのセットを繰り返すので、どんどん仕上がっていきます。 希望すればおかわりも可能です(が限界が来たらいつでもすぐに飛び出しましょう)。

水風呂

アツアツのサウナのあとの水風呂も気持ちよく、沢の水を引いてきているため季節によって水温は前後しますが、だいたい16度~18度くらい。 正直13度とか14度とかのキンキンに冷えた水風呂は未だに慣れないので、箱根湯寮くらいの水温がサウナにハマるにはちょうどよかったのかもしれません。

水風呂のあと更にサウナに行くと、ロウリュウサービスのアロマがまだこってり残っているので引き続き仕上がりを感じられます。

初めてサウナで整う感覚を得られたような気がして、以後温泉に行ったらサウナにも行くようになりました。

Jetpack Compose TextField の横幅を n 文字分だけ確保したい

View の仕組みでは、 TextView のプロパティとして ems が定義してあり、これを利用するとTextView の横幅を ems で指定した文字数分の横幅にしてくれます。

R.attr  |  Android Developers

Jetpack Compose では Text や TextField にそのものズバリな引数や Modifier はないので、自分で計算します。

TextMeasurer で横幅を計算する

TextMeasurer はその名の通り文字を描画するのに必要な領域を計算するクラスです。テキストスタイルや親コンポーネントの縦横の大きさを制約として与えることで、テキストが領域内に収まり切るかどうかも計算可能です。

developer.android.com

今回は TextField の横幅を常に一定に保つため、あらかじめダミーの文字列を用意して TextMeasurer に食わせて横幅を計算します。これでダミーの文字列の長さの分だけ横幅を確保することになります。 次の例では数字6文字分の横幅を計算します。

// 数字6文字分を TextField の横幅にするときの、数字6文字分の横幅を計算
val measurer = rememberTextMeasurer()
val measureResult = measurer.measure(
  text = "000000",
  maxLines = 1,
)

TextField の PaddingValues を求める

TextField は内部で PaddingValues を設定しており、自動でスペースをつけています。 このスペースの大きさは TextField のスタイルによって異なります。 OutlinedTextField ならば outlinedTextFieldPadding() で、TextField の場合は label があるときは textFieldWithLabelPadding()、label がないときは textFieldWithoutLabelPadding() が標準の PaddingValues です。

TextMeasurer で計算した横幅と PaddingValues の水平方向の padding を足し合わせる

TextMeasurer で計算した横幅と PaddingValue の水平方向の padding を足し合わせると、TextField の横幅が計算できます。 TextMeasurer で計算した横幅は px なのに対し、PaddingValues が持つスペースは dp を単位にしているので、TextMeasurer で計算した横幅を dp になおす必要があります。

val measurer = rememberTextMeasurer()
val measureResult = measurer.measure(
  text = "000000",
  maxLines = 1,
)
val padding = textFieldWithoutLabelPadding()

TextField(
  modifier = Modifier.width(
    with(LocalDensity.current) {
      measureResult.size.width.toDp() + padding.calculateStartPadding(LocalLayoutDirection.current) + padding.calculateEndPadding(LocalLayoutDirection.current)
    }
  )
)

注意点

今回は数字のみで文字列の横幅を計算しましたが、全角文字を入れたりするとまた計算が変わってきそうです。 TextMeasure#measure はフォントの種類や文字の大きさなども考慮して横幅を計算できますが、今回のように与えられた文字数に必要な横幅を計算するために予め決め打ちした文字で計算すると、実際に入力した文字によってはどうしてもズレが出ます。