😨

Composeで謎のバグを見つけた(かもしれない)

2022/11/05に公開約9,500字

最初に結論

Column,Rowなどの子要素を記述するブロックではreturnをかかない

エラーに遭遇して、その原因を考える

Composeは個人的に「一番直感的にUIを記述できるUIフレームワーク」と思っていたのですが、普通に書いてしまいそうな以下のコードで内部エラーが発生しました。

問題のコード
  var count by remember { mutableStateOf(0) }
  LaunchedEffect(count) {
    delay(1_000)
    if (count >= 4) return@LaunchedEffect
    count++
  }

  Column {
    Text("$count")
    if (count <= 0) {
      Text("none")
      return
    }
    Button(onClick = { count = 0 }) {
      Text("test :$count")
    }
  }
発生するエラー
java.lang.IllegalStateException: Compose Runtime internal error. Unexpected or incorrect use of the Compose internal runtime API (Start/end imbalance). Please report to Google or use https://goo.gle/compose-feedback
  at androidx.compose.runtime.ComposerKt.composeRuntimeError(Composer.kt:4244)
  at androidx.compose.runtime.ComposerImpl.finalizeCompose(Composer.kt:4390)
  ... (以下略)

Unexpected or incorrect use of the Compose internal runtime API (Start/end imbalance)
訳) Compose 内部ランタイム API の予期しない、または不適切な使用 (開始/終了の不均衡)

とあることからもCompose関数の開始地点と修了地点にて何かしら不整合が起きて、Compose関数の開始時に走るべき処理ととそれと対になるCompose関数の終了時に走るべき処理がいい感じに走ってないのではないかと予想を立てました。

本記事ではこの原因の発生原因の予想を述べていきたいなと思います。

ComposeはどうUIを構築するか

初めに要点を述べます

要点:
ComposeがUIの木構造構築のために必要な処理は@Composableアノテーションが自動生成してくれる

以下ではこれを解説します。以上の要点の意味がわからない方はお読みください。

Composeに限らず、さまざまなUIフレームワーク(AndroidViewやReactなどなど含め) はUIを 木構造 で表現したり実装していると思います。

木構造

本記事で"木構造"と呼ぶものは人によってはUIツリーや中間表現ということもあるでしょう。

後にも述べる通り、この木構造は人間が描いたソースコードとコンピュータが理解できるUIの間に位置する、どちらにも変換可能な表現方法のことを指しています。


プログラマがUIを構築するときの思考

これをランタイム(実行環境、実際にUIを表示するコンピュータ)目線で見ると以下の手順で処理します。


ランタイムがUIを構築するときの手順

このように①ソースコードで記述されたことが木構造になり ②木構造とUIが対応づけられて ③UIが画面に表示されると思うのですが、今回注目したいのは①の部分です

Jetpack Composeでは以下のようにソースコードを記述します。

Composeのソースコードの記述
Column {
  Text("1")
  Text("2")
  Button(onClick = {}){
    Text("button")
  }
}

これを①ソースコードで記述されたことが木構造にすることによって得られる木構造にすると概ね以下のようになります。

ソースコード→木構造で得られる木構造 (JSON)
{
  "type" : "column",
  "children" : [      // 子要素のリスト
    {
      "type" : "text",
      "text" : "1",
    },
    {
      "type" : "text",
      "text" : "2",
    },
    {
      "type" : "button",
      "onClick" : (JSONで表記不可能),
      "child" : {
        "type" : "text",
	"text" : "button"
      }
    },
  ],
}
ソースコード→木構造で得られる木構造 (アコーディオン,クリックして中身を確認してください)
Column
Text

text:"1"

Text

text:"1"

Button

onClick : {} (何もしない)

child :

Text

text: button

コンピュータ(Androidスマホ)は上のような構造をソースコードから抽出しなければいけません。

ここで、ソースコードをもう一度見てみます。Composeの書き方は(各Composableの親子関係)直感的でわかりやすいですが、Kotlin的にはただの関数呼び出しでしかありません。

Composeの記述はただの関数呼び出し
Column {                // Column関数の呼び出し 
  Text("1")             // Text関数の呼び出し
  Text("2")             // Text関数の呼び出し
  Button(onClick = {}){ // Button関数の呼び出し
    Text("button")      // Text関数の呼び出し
  }
}

関数呼び出しから、その順序などによって木構造を構築するための方法(アルゴリズム)をいかに示します。

1. あらかじめルート要素を用意し印をつける

ルート要素を用意します。ルート要素とは最終的にUIを表示したい要素のことで具体的にはActivityなどを想像してもらうといいでしょう(厳密には違います)。
またこのルート要素に印をつけておきます。印をつけた要素は以降の手順で子要素を追加する対象となる要素になります。初めにルート要素に印をつけるのは、一番初めはルート要素に要素を追加したいからです。

2. 各要素を印をつけた要素に追加する

この手順では各子要素にフォーカスを当てて進めていきます。
Composeでは各UI要素とそれを表すComposable関数があります。Composable関数を実行することでUI要素が生成されるイメージです。

2-1. 現在の子要素を表すUI要素を初期化し、印がついている要素に子要素として追加する

UI要素を初期化します。以下の手順でこの要素に変更を加えていきます。

また、この要素を印をつけてある要素の子要素として追加して印を初期化されたUI要素へ移動します

初期化されたUI要素 (JSON)
{}

2-2. 各子要素を表すComposable関数(Column,Text,Buttonなど)を実行します。これらは実行されることで1で初期化されたUI要素の情報を更新します。(たとえばTextComposable関数は実行することでそのテキスト情報(表示されるテキスト)が更新されます。)

実行されるComposable関数
Button(onClick={}){
  Text("button")
}

2-3. ColumnやButtonなど、もし実行した子要素がさらに子要素を持つ場合、1,2で初期化・更新した要素に印をつけ、2.を同様に繰り返します。この手順を終えると木構造は以下のようになります。

2-4. 印を2で更新した要素に付け直します。(ロジック状の問題でこの必要がありますが、 この手順が必要なんだと知るだけで 詳しくわからなくても構いません。)

Jetpack Composeではこの一連の手順をComposeと呼んでいます(正確にはちょっと違う気もしますが)。(Composeできる関数だからComposableアノテーションをつけたりします)

ここで注目したいのは各UI要素を作るための関数(=Composable関数)はその動作の前後に木構造構築に必要な処理があることです。 上記説明の2-1,2-3,2-4がこれにあたります。(1もそうかも)

つまりただ単純に関数を呼び出すだけでは木構造は生成できません(木構造を作るためのコードが一切実行されないので当然といえば当然)。各Composable関数の実行前後にこの構築のためのコードを挟む必要があります。


Composeで木構造が構築される手順

ですが私たちが単に関数を定義し呼び出しただけでは各Composable関数の実行前後に木構造構築のためのコードを挟むことができません。


木構造構築のためのコードを挟むことができない

Composeでは各Composable関数の実行前後に木構造構築のためのコードを挟むために、@Composableアノテーションを使用します。このアノテーションはComposable関数の実行前後に木構造構築のためのコードを挿入してくれます。

@Composableアノテーションが関数に木構造構築のためのコードを付け足してくれる
@Composable
fun Column(){
  // Column特有の処理
}
↓↓↓
fun Column(){
  // 木構造構築のためのコード
  // Column特有の処理
  // 木構造構築のためのコード
}

これがComposeのUI構築の手順とComposableアノテーションの概要です。

本題:

読み飛ばした方のために前章の要点をまとめると

ComposeがUIの木構造構築のために必要な処理は@Composableアノテーションが自動生成してくれる

ということでした。

ここで問題のコードやエラーを再喝します。

問題のコード(再喝)
  var count by remember { mutableStateOf(0) }
  LaunchedEffect(count) {
    delay(1_000)
    if (count >= 4) return@LaunchedEffect
    count++
  }

  Column {
    Text("$count")
    if (count <= 0) {
      Text("none")
      return
    }
    Button(onClick = { count = 0 }) {
      Text("test :$count")
    }
  }
発生するエラー(再喝)
java.lang.IllegalStateException: Compose Runtime internal error. Unexpected or incorrect use of the Compose internal runtime API (Start/end imbalance). Please report to Google or use https://goo.gle/compose-feedback
  at androidx.compose.runtime.ComposerKt.composeRuntimeError(Composer.kt:4244)
  at androidx.compose.runtime.ComposerImpl.finalizeCompose(Composer.kt:4390)
  ... (以下略)

Unexpected or incorrect use of the Compose internal runtime API (Start/end imbalance)
訳) Compose 内部ランタイム API の予期しない、または不適切な使用 (開始/終了の不均衡)

エラーの調査

ComposeはどうUIを構築するかをもとに私は、

エラーメッセージ中の「開始/終了の不均衡」とは、@Composableアノテーションが関数に付け足してくれる木構造構築のための前処理と後処理がペアになっていないと考えました。
(言い換えると、「何らかの原因で前処理は実行されたが後処理が実行されなかった」のようなことが起きたということです)

@Composableアノテーションが関数に木構造構築のためのコードを付け足してくれる
@Composable
fun Column(){
  // Column特有の処理
}
↓↓↓
fun Column(){
  // 木構造構築のためのコード(前処理)
  // Column特有の処理
  // 木構造構築のためのコード(後処理)  ← これが何らかの理由で実行されなかった?
}

これを探るために木構造構築のための後処理が実行されないパターンを想定してみました。

初めに思い当たるのはthrow式によるコードの中断です。throw式が実行されると以下の処理は実行されません。私たちが普通に書いていて変更できるのは上記コードで言うColumn特有の処理の部分のみなのでその中でthrowしてしまったのかと思いました。
ですが、私の書いたコード内にthrowは使用されていませんでした。そのためこれは原因ではないと思いました。

throwは直接記述しなくても発生しうる

今回は、私の書いたコード内にthrowは使用されていなかったためthrowが原因ではない、と結論づけましたが、厳密にはこれは正しい判断とは言い切れるとは限りません。
たとえば以下のコードではthrow式を使用(記述)していませんが、エラーが発生するために🈁のコードは実行されません。

println("test-1")
println(100/0)  // ArithmeticException: divide by zero
println("test-2")  // 🈁 (実行されない)

よって実際にはthrow式があるか否かよりも、よってどこでエラーが発生しうるかを観点としてコードを読むべきです。

throw同様にコードを途中で中断できる式としてreturn式があります。私が今回示したコード内にreturn式は存在しました(問題のコード:12行目)。

問題のコード(再喝)
  var count by remember { mutableStateOf(0) }
  LaunchedEffect(count) {
    delay(1_000)
    if (count >= 4) return@LaunchedEffect
    count++
  }

  Column {
    Text("$count")
    if (count <= 0) {
      Text("none")
      return  // ⬅️⬅️⬅️ ここ
    }
    Button(onClick = { count = 0 }) {
      Text("test :$count")
    }
  }

これが原因なのではないかと言うことでこのreturnのあたりを調査することにしました。


Android Studioでこのreturnを調べてみると以下のように表示されました。

候補が2件出てきています。

問題のコードを書いたときの私の心境としては、

「ifの条件がtrueの時はColumnのブロックを終了する」

と言うことが表現したかったので、returnをここに記述しましたが、補完内容から察するに該当位置でreturnと書くとColumnのブロックではなくその親要素をreturnしてしまうことを突き止めました。

これによって本来

親要素の 木構造構築のためのコード(前処理)
親要素特有の処理
Columnの 処理
  Columnの 木構造構築のためのコード(前処理)
  Column特有の処理
  Columnの子要素の 処理
  親要素の 木構造構築のためのコード(後処理)
親要素の 木構造構築のためのコード(後処理)

となるところが

親要素の 木構造構築のためのコード(前処理)
親要素特有の処理
Columnの 処理
  Columnの 木構造構築のためのコード(前処理)
  Column特有の処理  // ここで 親要素がreturnされる
  ❌ Columnの子要素の 処理 // 実行されない
  ❌ 親要素の 木構造構築のためのコード(後処理) // 実行されない
❌ 親要素の 木構造構築のためのコード(後処理) // 実行されない

となり、前処理と後処理の実行数がおかしくなることでエラーが発生すると言うのが私の結論です。

Columnの関数定義を見るとどうやらinline関数になっているみたいでreturnと書くと思わぬところ(Columnをreturnしてほしいのにそれよりも上の関数)がreturnされてしまうようです。

ではどうするか

簡単です。

子要素を必要とする要素(Column,Row,Buttonなどなど)では return を書かないようなコードを書くのです。

実際,return文を使わないようにすると動くのです...

return@◯◯ するとどうなるか

思わぬところのreturnが走ることでおかしくなるなら、補完で候補に上がっていたreturn@◯◯をしたらどうなるかを試してみました。return@◯◯でreturnする関数ブロックを指定すればいいのでは?!と言う感じです。

うまくいきそうと思ったのですが、結論を言うとなぜかうまく動かず、問題のコードを実行した時と同じエラーが表示されてしまいました。

私自身inline関数への理解が乏しいのもあると思うのでもうちょっと調べてみたいと思います。(有識者の方は教えていただけると嬉しいです...)(理解できたら後日記事にしたいなと思ってます)

Discussion

ログインするとコメントできます