Composable関数のバイトコードを読むメモ


decomposerでJavaに戻して読んでいきます。
https://github.com/takahirom/decomposer

対象コード

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            JetpackcomposestabilitysamplesTheme {
                // A surface container using the 'background' color from the theme
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colors.background
                ) {
                    Content(ViewModel())
                }
            }
        }
    }
}

data class Article(val title: String)

class ViewModel {
    var i = 0
    val article = MutableStateFlow(Article("first"))

    init {
        thread {
            while (true) {
                article.tryEmit(Article("${i++}"))
                Thread.sleep(3000)
            }
        }
    }
}

@Composable
fun Content(viewModel: ViewModel) {
    val article by viewModel.article.collectAsState()
    ArticleItem(article = article)
}


@Composable
fun ArticleItem(article: Article) {
    Text(text = "Hello $article")
}


デコンパイル後のコード

@Metadata(
   mv = {1, 5, 1},
   k = 1,
   xi = 48,
   d1 = {"\u0000\"\n\u0002\u0018\u0002\n\u0002\u0010\u0000\n\u0000\n\u0002\u0010\u000e\n\u0002\b\u0006\n\u0002\u0010\u000b\n\u0002\b\u0002\n\u0002\u0010\b\n\u0002\b\u0002\b\u0087\b\u0018\u00002\u00020\u0001B\r\u0012\u0006\u0010\u0002\u001a\u00020\u0003¢\u0006\u0002\u0010\u0004J\t\u0010\u0007\u001a\u00020\u0003HÆ\u0003J\u0013\u0010\b\u001a\u00020\u00002\b\b\u0002\u0010\u0002\u001a\u00020\u0003HÆ\u0001J\u0013\u0010\t\u001a\u00020\n2\b\u0010\u000b\u001a\u0004\u0018\u00010\u0001HÖ\u0003J\t\u0010\f\u001a\u00020\rHÖ\u0001J\t\u0010\u000e\u001a\u00020\u0003HÖ\u0001R\u0011\u0010\u0002\u001a\u00020\u0003¢\u0006\b\n\u0000\u001a\u0004\b\u0005\u0010\u0006¨\u0006\u000f"},
   d2 = {"Lcom/github/takahirom/jetpack_compose_stability_samples/Article;", "", "title", "", "(Ljava/lang/String;)V", "getTitle", "()Ljava/lang/String;", "component1", "copy", "equals", "", "other", "hashCode", "", "toString", "app_release"}
)
@StabilityInferred(
   parameters = 0
)
public final class Article {
   @NotNull
   private final String title;
   public static final int $stable;

   public Article(@NotNull String title) {
      Intrinsics.checkNotNullParameter(title, "title");
      super();
      this.title = title;
   }

   @NotNull
   public final String getTitle() {
      return this.title;
   }

   @NotNull
   public final String component1() {
... (data classによる自動生成)
}
   @Composable
   public static final void Content(@NotNull final ViewModel viewModel, @Nullable Composer $composer, final int $changed) {
      Intrinsics.checkNotNullParameter(viewModel, "viewModel");
      $composer = $composer.startRestartGroup(-506556449);
      ComposerKt.sourceInformation($composer, "C(Content)");
      State article$delegate = SnapshotStateKt.collectAsState((StateFlow)viewModel.getArticle(), (CoroutineContext)null, $composer, 8, 1);
      ArticleItem(Content$lambda-0(article$delegate), $composer, 0);
      ScopeUpdateScope var4 = $composer.endRestartGroup();
      if (var4 != null) {
         var4.updateScope((Function2)(new Function2() {
            public final void invoke(@Nullable Composer $composer, int $force) {
               MainActivityKt.Content(viewModel, $composer, $changed | 1);
            }
         }));
      }

   }

   @Composable
   public static final void ArticleItem(@NotNull final Article article, @Nullable Composer $composer, final int $changed) {
      Intrinsics.checkNotNullParameter(article, "article");
      $composer = $composer.startRestartGroup(-1696499908);
      ComposerKt.sourceInformation($composer, "C(ArticleItem)");
      int $dirty = $changed;
      if (($changed & 14) == 0) {
         $dirty = $changed | ($composer.changed(article) ? 4 : 2);
      }

      if (($dirty & 11 ^ 2) == 0 && $composer.getSkipping()) {
         $composer.skipToGroupEnd();
      } else {
         TextKt.Text-fLXpl1I(Intrinsics.stringPlus("Hello ", article), (Modifier)null, 0L, 0L, (FontStyle)null, (FontWeight)null, (FontFamily)null, 0L, (TextDecoration)null, (TextAlign)null, 0L, 0, false, 0, (Function1)null, (TextStyle)null, $composer, 0, 64, 65534);
      }

      ScopeUpdateScope var4 = $composer.endRestartGroup();
      if (var4 != null) {
         var4.updateScope((Function2)(new Function2() {
            public final void invoke(@Nullable Composer $composer, int $force) {
               MainActivityKt.ArticleItem(article, $composer, $changed | 1);
            }
         }));
      }

   }

   private static final Article Content$lambda_0/* $FF was: Content$lambda-0*/(State article$delegate) {
      Object var2 = null;
      KProperty property$iv = null;
      int $i$f$getValue = false;
      return (Article)article$delegate.getValue();
   }

大まかな流れはViewModelのarticleが変わったらContent()のupdateScopeのラムダが実行されて、ArticleItem()が実行されるのみ。

この14とか11とかの数字って何。。?

ちょっと何も見ずに読んでも意味不明なので、以下を参考にします。

 *     @Composable fun A(x: Int) {
 *       f(x)
 *     }
 *
 * gets transformed into
 *
 *     @Composable fun A(x: Int, $composer: Composer<*>, $changed: Int) {
 *       var $dirty = $changed
 *       if ($changed and 0b0110 === 0) {
 *         $dirty = $dirty or if ($composer.changed(x)) 0b0010 else 0b0100
 *       }
 *      if (%dirty and 0b1011 xor 0b1010 !== 0 || !$composer.skipping) {
 *        f(x)
 *      } else {
 *        $composer.skipToGroupEnd()
 *      }
 *     }
 *
 * Note that this makes use of bitmasks for the $changed and $dirty values. These bitmasks work
 * in a different bit-space than the $default bitmask because two bits are needed to hold the
 * four different possible states of each parameter. Additionally, the lowest bit of the bitmask
 * is a special bit which forces execution of the function.
 *
 * This means that for the ith parameter of a composable function, the bit range of i*2 + 1 to
 * i*2 + 2 are used to store the state of the parameter.
 *
 * The states are outlines by the [ParamState] class.
/**
 * An enum of the different "states" a parameter of a composable function can have relating to
 * comparison propagation. Each state is represented by two bits in the `$changed` bitmask.
 */
enum class ParamState(val bits: Int) {
    /**
     * Indicates that nothing is certain about the current state of the parameter. It could be
     * different than it was during the last execution, or it could be the same, but it is not
     * known so the current function looking at it must call equals on it in order to find out.
     * This is the only state that can cause the function to spend slot table space in order to
     * look at it.
     */
    Uncertain(0b000),
    /**
     * This indicates that the value is known to be the same since the last time the function was
     * executed. There is no need to store the value in the slot table in this case because the
     * calling function will *always* know whether the value was the same or different as it was
     * in the previous execution.
     */
    Same(0b001),
    /**
     * This indicates that the value is known to be different since the last time the function
     * was executed. There is no need to store the value in the slot table in this case because
     * the calling function will *always* know whether the value was the same or different as it
     * was in the previous execution.
     */
    Different(0b010),
    /**
     * This indicates that the value is known to *never change* for the duration of the running
     * program.
     */
    Static(0b011),
    Unknown(0b100),
    Mask(0b111);

    fun bitsForSlot(slot: Int): Int = bitsForSlot(bits, slot)
}

3桁は不明=000、同じ=001、違う=010、Static(変更されない)=011、不明=100、マスク=111
最後の桁は強制実行させるためのもの。
例えば0 1 0 0だと010なので、変更があり、最後のbitは0なので、強制実行ではないということみたいです。

実際にデコンパイルされたJavaコードを読んでいく

このコードの if文を一つずつ見ていく。

   @Composable
   public static final void ArticleItem(@NotNull final Article article, @Nullable Composer $composer, final int $changed) {
      int $dirty = $changed;
      if (($changed & 14) == 0) {
         $dirty = $changed | ($composer.changed(article) ? 4 : 2);
      }

      if (($dirty & 11 ^ 2) == 0 && $composer.getSkipping()) {
         $composer.skipToGroupEnd();
      } else {
         TextKt.Text-fLXpl1I(Intrinsics.stringPlus("Hello ", article), (Modifier)null, 0L, 0L, (FontStyle)null, (FontWeight)null, (FontFamily)null, 0L, (TextDecoration)null, (TextAlign)null, 0L, 0, false, 0, (Function1)null, (TextStyle)null, $composer, 0, 64, 65534);
      }

実行時にこのchangedとかdirtyって何入っているの??


実行時の変数の内容は以下のようになっている。

Content
$changed = 8

ArticleItem
$changed = 0

ViewModel.article変化後

Content
$changed = 9

ArticleItem
$changed = 0
$dirty = 4

最初のif文: 親からchangedが渡ってきている場合を除いて、変更を検知する

      int $dirty = $changed;
      if (($changed & 14) == 0) {
         $dirty = $changed | ($composer.changed(article) ? 4 : 2);
      }

ArticleItem内で$changedは0になっているので、0に何をand しても 0であるので trueになる。
→ 一応14は2進数で1110 つまり変更のマスクをかけている。つまりchangedが親からすでに計算されて来ている場合はそれを利用するということみたい。
そしてdirtyが4になっているので `$composer.changed(article)がtrueを返して4になっていることがわかる。
→ 同じ=001、違う=010 なので、これを適応している。 (最後のビットは強制実行のためのもの)

変更があれば他のComposable関数を呼び出す

      if (($dirty & 11 ^ 2) == 0 && $composer.getSkipping()) {
         $composer.skipToGroupEnd();
      } else {
         TextKt.Text-fLXpl1I(Intrinsics.stringPlus("Hello ", article), (Modifier)null, 0L, 0L, (FontStyle)null, (FontWeight)null, (FontFamily)null, 0L, (TextDecoration)null, (TextAlign)null, 0L, 0, false, 0, (Function1)null, (TextStyle)null, $composer, 0, 64, 65534);
      }

この計算は以下のようになる。
まず$dirty & 11でdirtyは4で 4 and 11が行われる。これは0になる
→ 10進数の11は2進数で1011なので、不明=000、同じ=001、違う=010、Static(変更されない)=011、不明=100
で最初の3bitは
101は不明か同じの場合に0以外になる。0であるということはおそらく、不明か同じではないということになる。最後のbitは強制実行のためのもの。

>>> 4.toString(2)
res15: kotlin.String = 100
>>> 11.toString(2)
res16: kotlin.String = 1011
>>> 4 and 11
res17: kotlin.Int = 0

Javaの^はXORであるので
($dirty & 11 ^ 2)で0 xor 2が行われ、この結果は2となる。

>>> 0 xor 2
res18: kotlin.Int = 2

この部分のコードの説明。

// we use this pattern with the low bit set to 1 in the "and", and the low bit set to 0
// for the "xor". This means that if the low bit was set, we will get 1 in the resulting
// low bit. Since we use this calculation to determine if we need to run the body of the
// function, this is exactly what we want.

andで最後のbitを1にして、xorで0にしている理由は1のときに実行したいため。

そして($dirty & 11 ^ 2) == 0 が falseになる。
→ 2以外であれば再実行する。この2というのは同じという意味。同じでなければ、Textのメソッド呼び出しが行われる。

まとめ

なんとか読めましたが、本当にあっているかはよく分からず、ちょっともやもやしています。
でもなんとなくこの数字たちの意味がわかってよかったです。