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

BottomSheetScaffold の sheetContent を空にしてはいけない

BottomSheetScaffold を使って BottomSheet を作るとき、BottomSheet の中身は BottomSheetScaffold の引数にある sheetContent で作っていきます。

developer.android.com

BottomSheetScaffold(
  sheetContent = {
    /* BottomSheet の中身 */
  }
) { paddingValues ->
  /* 画面の中身 */
}

この画面において BottomSheet の表示に複数の種類がある場合を考えてみます。 種類に応じて sheetContent を切り替えたいので、次のような enum による分岐をしてみます。 今回は 2 種類の BottomSheet を一つの画面で表示する想定で BottomSheetType という enum を定義して sheetContent 内部で表示を切り替えます。 このとき、BottomSheet には開いた状態と閉じた状態の 2 種類があることを考慮に入れ、閉じた場合に sheetContent を表示しないようにするためなにもないことを示す値も合わせて定義してみます。

enum class BottomSheetType {
  NONE, FOO, BAR
}

var sheetType by remember { mutableStateOf(BottomSheetType.NONE) }
val bottomSheetState = rememberBottomSheetState(
  initialValue = BottomSheetValue.Collapsed,
)
val scaffoldState = rememberBottomSheetScaffoldState(
  bottomSheetState = bottomSheetState,
)

BottomSheetScaffold(
  scaffoldState = scaffoldState,
  sheetPeekHeight = 0.dp,
  sheetContent = {
    when (sheetType) {
      BottomSheetType.NONE -> {
        // 閉じた状態を表現したい。何も表示するものはないので空のまま。
      }
      BottomSheetType.FOO -> {
        Text(text = "foo")
      }
      BottomSheetType.BAR -> {
        Text(text = "bar")
      }
    }
  }
)

このようなコードで sheetContent を作る画面を動かすと BottomSheetState のもつ状態がおかしくなってしまいます。具体的には、rememberBottomSheetState で初期値を Collapsed にしていても、Composition がおわると Expanded な状態であると判定されてしまいます。

またこのような sheetContent の作り方を実装した上で BottomSheet を展開しようと次のようなコードを呼び出すとアプリがクラッシュします。

// Jetpack Compose 1.1.0 でクラッシュするコード (1.2.0 ではクラッシュしない)
coroutineScope.launch {
  bottomSheetState.expand()
}

// Jetpack Compose 1.2.0 でクラッシュするコード (1.1.0 ではクラッシュしない)
coroutineScope.launch {
  bottomSheetState.animateTo(BottomSheetValue.Collapsed)
}

クラッシュ時の例外は次の通りで、展開時のアニメーションを実行しようとしたとき、アニメーションが終了する位置が指定できていないことでクラッシュしていることがわかります。

java.lang.IllegalArgumentException: The target value must have an associated anchor.

そもそも BottomSheet を展開していないのに BottomSheetStateExpanded になってしまうことが良くないのですが、これは sheetContent で何も Composable を呼ばないケースが不具合を引き起こしています。 このため、次のように何も表示しないケースで Spacer(modifier = Modifier.height(1.dp)) を差し込むか、when 全体を Box で囲むか、そもそも enum の定義から表示しないパターンの定義を消すかのいずれかで、常に sheetContent が何かしらの Composable を呼び出すようにする必要があります。

// 小さな Spacer を差し込むパターン
BottomSheetScaffold(
  scaffoldState = scaffoldState,
  sheetPeekHeight = 0.dp,
  sheetContent = {
    when (sheetType) {
      BottomSheetType.NONE -> {
        Spacer(modifier = Modifier.height(1.dp))
      }
      BottomSheetType.FOO -> {
        Text(text = "foo")
      }
      BottomSheetType.BAR -> {
        Text(text = "bar")
      }
    }
  }
)

// when 全体を Box で囲むパターン。BottomSheetType.NONE でも最低 1dp は確保する
BottomSheetScaffold(
  scaffoldState = scaffoldState,
  sheetPeekHeight = 0.dp,
  sheetContent = {
    // 最低 1dp の高さは確保
    Box(modifier = Modifier.requiredHeightIn(min = 1.dp)) {
      when (sheetType) {
        BottomSheetType.NONE -> {
          // 閉じた状態を表現したい。何も表示するものはないので空のまま。
        }
        BottomSheetType.FOO -> {
          Text(text = "foo")
        }
        BottomSheetType.BAR -> {
          Text(text = "bar")
        }
      }
    }
  }
)

// enum の定義を削除するパターン
BottomSheetScaffold(
  scaffoldState = scaffoldState,
  sheetPeekHeight = 0.dp,
  sheetContent = {
    when (sheetType) {
      BottomSheetType.FOO -> {
        Text(text = "foo")
      }
      BottomSheetType.BAR -> {
        Text(text = "bar")
      }
    }
  }
)

参考リンク

stackoverflow.com

Jetpack Compose 1.2.0 では Scaffold の content に PaddingValues を必ず設定する

Jetpack Compose 1.2.0 (正確には Jetpack Compose 1.2.0-alpha07 以後) から Scaffold の content で Scaffold から渡される PaddingValues を使わないと Lint エラーとなります。

developer.android.com

Lint で指摘される例

次の例は Lint のエラーとなります。Scaffold の content は content: @Composable (PaddingValues) -> Unit と定義してあり、Scaffold 側から PaddingValues を渡すように設計してあります。 この PaddingValues は Jetpack Compose 1.1.x までは使わなくても特に Lint に怒られることはありませんでしたが、Scaffold が content を呼び出す前に内部で PaddingValues を計算しているため、 使わないとレイアウトが崩れる原因となる場合がありました。特に PaddingValues の計算には bottomBar の高さが計算に入っているようです。 このため、Jetpack Compose 1.2.0 からは Lint のエラーが出るようになりました。

@Composable
fun SampleScreen {
  Scaffold(
    scaffoldState = rememberScaffoldState(),
    topBar = { /* compose TopAppBar... */ },
  ) {

    // このラムダに渡される PaddingValues を無視して画面を構成するとエラーになる

    ConstraintLayout {
      /* compose screen... */
    }
  }
}

正しい記述

Scaffold の content で使う最もトップレベルの Composable に PaddingValues を設定します。

@Composable
fun SampleScreen {
  Scaffold(
    scaffoldState = rememberScaffoldState(),
    topBar = { /* compose TopAppBar... */ },
  ) { innerPadding ->

    // Scaffold 直下の Composable に innerPadding を設定する

    ConstraintLayout(
      modifier = Modifier.padding(innerPadding),
    ) {
      /* compose screen... */
    }
  }
}

DroidKaigi: Weekend Chat の配信を支える構成

Mac を利用した Discord と OBS による YouTube Live 配信環境

DroidKaigi: Weekend Chat は Mac 上で Discord をつないで @mhidaka さんと話しているのを OBS に流して YouTube Live 配信にのせています。

Loopback

Windows であれば音声キャプチャはそれほど難しくありませんが、Mac でやるとなるとソフトウェアないしハードウェアを揃えていかないと配信環境が整いません。 特に Discord からの音声を配信にのせたり、BGM として使う音楽を配信にのせたりする場合はこれらのソフトからのオーディオ出力をキャプチャする手段が Mac OS 標準にも OBS にもないため、別途キャプチャ用のものが必要です。

そこで DroidKaigi: Weekend Chat 開始当初から使っているソフトとして Loopback が役立ちます。 Loopback は仮想オーディオ出力デバイスを作成し、その出力に対してどのソフトからのオーディオを入力するかをビジュアルで分かりやすく設定できます。

自分の場合、初回から長らくは Discord の音・Chrome の音(BGM 用・YouTube の BGM 素材動画から音を取り込んでいた)をソースとして OBS に流す仮想オーディオ出力デバイスを作成し、Discord から聞こえてくる @mhidaka さんの音と BGM を OBS に流しつつ、自分のマイク入力はそのまま Discord と OBS に流すようにしていました。

ただしこの設定だと、話し相手の mhidaka さんには自分の声しか聞こえないので BGM が入らず、mhidaka さん側にも BGM を流すためにわざわざ mhidaka さんに同じ BGM の再生ページを開いてもらって再生タイミングをせーので合わせる、という超絶運用でカバーをすることになってしまいました。

Loopback では、仮想オーディオ出力デバイスは複数作成可能です。 そこで、次のステップとして、Discord に流すための仮想オーディオ出力デバイスと OBS に流すための仮想オーディオ出力デバイスの 2 つを作成し、BGM の再生タイミングをせーので合わせることなく mhidaka さん側にも流せるようにしました。

まず OBS 用の仮想オーディオ出力デバイスは、Discord の音、BGM の音、追加で SE を鳴らすソフトの音を出力するように設定します。

次に Discord 用の仮想オーディオ出力デバイスでは、Discord のオーディオ入力をこの仮想オーディオ出力デバイスとするため、マイクの入力、BGM の音、SE を鳴らすソフトの音を出力するようにします。

これで OBS と Discord の両方に BGM を出力できるようになります。 ここで注意すべき点としては、Discord は独自にノイズキャンセルする仕組みを持っていて、ラジオ配信などで使う BGM の音量が小さいとノイズ判定されて mhidaka さん側では消えてしまうことに気をつけます。 ノイズ判定を回避して BGM を届けるには Discord の設定で Input Sensitivity を手動で設定し、閾値を下げておく必要があります。この閾値を下げても YouTube の配信には影響はありません。

このあとさらに YAMAHA AG06MK2 を購入しましたがその話はまた別記事で書こうと思います。

CircleCI で Android アプリプロジェクトのビルドに利用する Docker Image が変わったので更新する

次のような記述で Android 用の Docker Image を利用すると、ジョブのステータスを表示する画面で You’re using a deprecated Docker convenience image. Upgrade to a next-gen Docker convenience image. といったメッセージが表示されます。

executors:
  android:
    docker:
      - image: circleci/android:api-30

メッセージのリンク部分をたどると、新しい next-gen Docker convenience image へ置き換えるための手順が示されていますが、Android に関してはどの Docker Image を使うべきか明示されていません。

discuss.circleci.com

代わりに、この手順を示した記事のコメントにて新しい Docker Image の情報を記載したページのリンクが示されています。

Legacy Convenience Image Deprecation - #6 by zmarkan - Announcements - CircleCI Discuss

DockerHub で CircleCI が公開している Docker Image は api-30 を最後に更新が止まっていて、api-31 以上を含むイメージは提供されていません。 代わりに、DockerHub で cimg という別の organization (実体は CircleCI)が提供している android のイメージを利用します。

circleci.com

cimg で公開しているイメージはこれまでと Docker Image のタグの付け方が異なります。 これまでは API Level ごとにタグを付けていましたが、今後はビルドした日付ごとにタグが付与されます。 また Docker Image に含まれる Android SDK の内容にも違いがあり、最新の API Level から数えて直近 4 つ分の API Level のものが Docker Image に含まれています。

この他、node や ndk などを含むイメージもあり、それぞれタグの日付を示す部分の後ろに variant 名を付けています。

この記事をかいた時点では 2022.04 が最新イメージとなっているので、上記 YAML は次のように書き換えることになります。

executors:
  android:
    docker:
      - image: cimg/android:2022.04

Jetpack Compose で GridLayout を実現する

Jetpack Compose には LazyVerticalGrid というグリッド表示をしてくれる Composable がありますが、LazyColumn など他のスクロール可能な Composable にネストしてグリッド表示を作りたい場合は LazyVerticalGrid は利用できません(スクロール可能な Composable をネストしてしまうので)。 Android View にある GridLayout の代替となる Composable があればよいのですが、現状はまだないようです *1

そこでなんとか GridLayout をうまく再現する方法として、accompanist にある FlowLayout を利用して実装してみます。 今回は Grid の各セルの横幅を画面幅やセル間のマージンから算出して設定し、GridLayout を横幅いっぱいに表示するものを作ります。あんまり汎用性はないです。

@Composable
fun GridLayout(
  modifier: Modifier = Modifier
  items: List<String>,
) {
  // カラム数
  val numOfColumns = 2
  // セル間のマージン
  val gridSpacing = 8.dp,
  // セル間のマージンを取るための Spacer の数
  val spacerCount = numOfColumns - 1
  val config = LocalConfiguration.current
  val gridPadding = // ... グリッド両脇につける padding の dp
  // 画面の横幅からセル間のマージンとグリッド両脇の padding を取り除き、カラム数で割ってセル1個の横幅を出す
  val cellWidth = (config.screenWidthDp.dp - (gridSpacing * spacerCount) - (gridPadding * 2)) / numOfColumns

  Box(
    modifier = modifier.fillMaxWidth().wrapContentHeight()
  ) {
    FlowRow(
      modifier = Modifier.fillMaxWidth().wrapContentHeight()
    ) {
      // [0, 1, 2, 3, 4] のようなリストを [0, 1], [2, 3], [4] といった形の 2 個要素をもつチャンクに分割し、チャンクを Grid の 1 行分として扱う
      categoryTags.chunked(numOfColumns).forEach { chunkedList ->
        val left = chunkedList[0] // チャンクの最初の要素は必ず存在する
        val right = chunkedList.getOrNull(1) // チャンクの 2 個目の要素は存在しないかもしれない

        Box(
          modifier = Modifier
            .requiredWidth(cellWidth)
            .wrapContentHeight(),
        ) {
          // left を使って UI をつくる
        }

        // セル間のマージンを取る Spacer
        Spacer(modifier = Modifier.width(gridSpacing))

        if (right == null) {
          // 要素はなくてもスペースは確保したい
          Spacer(
            modifier = Modifier
              .requiredWidth(cellWidth)
              .wrapContentHeight()
          )
        } else {
          Box(
            modifier = Modifier
              .requiredWidth(cellWidth)
              .wrapContentHeight(),
          ) {
            // right を使って UI をつくる
          }
        }

        // 最後に、セルの下部にもマージンをつける。横幅いっぱいに Spacer を置いて、次のセルが確実にこの Spacer の下に来るようにする。
        Spacer(
          modifier = Modifier
            .fillMaxWidth()
            .height(gridSpacing)
        )
      }
    }
  }
}

*1:Issue Tracker: https://issuetracker.google.com/issues/190893487 イシューは切られていますが優先度は高くないようです

age++

おかしい。ついこの間 0x20 歳になったばかりだと思っていたのに、もう年齢がインクリメントされてしまった。

コロナはまだまだ収束しそうにないし、それどころか戦争が始まるし、あちこちてんやわんやのドッタンバッタン大騒ぎ感がありますが、私は元気です。

今年は年始からいきなり全力で Jetpack Compose をしはじめたり、それに合わせて Kotlin coroutines にも手を出したりと、仕事でつかうスキルを刷新していく方に目標を置いている感があります。 特に Jetpack Compose はいままで仕事で使うのは消極的だったのが、Stable になったことで一気に機運が高まったところに新規の UI 開発がはじまったので入れることにしました。 もともとアプリ開発初期から Jetpack Compose 自体はどこかで入れたいと思っていて、設計も合わせやすいように作っていたので、導入自体は純粋に Jetpack Compose のことを学ぶことに集中できました。 このあたりの話は別途記事がかけるかなと思っています。

他には、DroidKaigi の活動の一環として DroidKaigi: Weekend Chat を毎週金曜日 YouTube Live で配信するということもやっています。

www.youtube.com

ことしもやっていくぞ!

干し芋

Bundle の大きさを知りたい

前回の Navigation Component の振る舞いに関する記事 に関連して、Activity#onSaveInstanceState などで最終的に Bundle がどの程度の大きさになっているのかを知りたくなったのでいろいろ試してみました。

blog.keithyokoma.dev

結論、次の Stack Overflow の回答にあるとおり、自分で ParcelBundle を書き込んで byte 列を取得し、そのサイズを測るのがもっとも単純な方法になります。

stackoverflow.com

AOSP のコードを読むと toString でそれらしいサイズを文字列化しようとしていたり、Bundle を dump するメソッドがありそうに見えますが、実際にはどれもうまくいきませんでした。。