🐈

FlutterとJetpack Composeでの状態配置の違い

に公開

はじめに

FlutterとJetpack Composeはどちらも宣言的UIを採用しており、記述方法は似ています。しかし、Jetpack Composeを学習していく中で状態を親に置くべきか、子に置くべきかという点で推奨されるアプローチが異なることに気付いたため、本記事で整理していきます。

この記事は、FlutterやJetpack Composeを学び始めた方、あるいはどちらかの経験がありもう一方をこれから学習しようとしている方を対象としています。

Flutterにおける状態配置

基本的な考え方

Flutterでは、状態をなるべく末端のWidget(子)に配置し、親Widgetはステートレスなクラスとして設計するのが推奨されています。こちらはStatefulWidgetのドキュメントにも記載されています。

Push the state to the leaves. For example, if your page has a ticking clock, rather than putting the state at the top of the page and rebuilding the entire page each time the clock ticks, create a dedicated clock widget that only updates itself.

翻訳:状態をリーフ[1]にプッシュする。例えば、あなたのページに時を刻む時計がある場合、状態をページのトップに置き、時計が時を刻むたびにページ全体を再構築するのではなく、それ自体を更新する専用の時計ウィジェットを作成します。

https://api.flutter.dev/flutter/widgets/StatefulWidget-class.html

この設計により、次のようなメリットが得られます:

  • パフォーマンスの向上:親に状態を持たせると、状態が変化した際に親を介した全ての子Widgetがリビルドされてしまうため、末端に状態を配置することで、影響範囲を最小限に抑えることができる。
  • 責任範囲の明確化:状態が必要なWidgetに直接紐付けることで、コードの可読性が向上する。

サンプルコード

今回は簡単なカウンターアプリを作成して説明します。
以下は状態を親Widgetに配置した例です:

// ignore_for_file: avoid_print

import 'package:flutter/material.dart';

class ParentWidget extends StatefulWidget {
  const ParentWidget({super.key});

  
  State<ParentWidget> createState() => _ParentWidgetState();
}

class _ParentWidgetState extends State<ParentWidget> {
  int _counter1 = 0;
  int _counter2 = 0;

  void _incrementCounter1() {
    setState(() {
      _counter1++;
    });
  }

  void _incrementCounter2() {
    setState(() {
      _counter2++;
    });
  }

  
  Widget build(BuildContext context) {
    print('parent rebuild');
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text('AppBar'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Child1Widget(counter: _counter1),
            ElevatedButton(
              onPressed: _incrementCounter1,
              child: const Text('increment counter1'),
            ),
            Child2Widget(counter: _counter2),
            ElevatedButton(
              onPressed: _incrementCounter2,
              child: const Text('increment counter2'),
            ),
          ],
        ),
      ),
    );
  }
}

class Child1Widget extends StatelessWidget {
  const Child1Widget({
    super.key,
    required this.counter,
  });

  final int counter;

  
  Widget build(BuildContext context) {
    print('child1 rebuild');
    return Text('child1: $counter');
  }
}

class Child2Widget extends StatelessWidget {
  const Child2Widget({
    super.key,
    required this.counter,
  });

  final int counter;

  
  Widget build(BuildContext context) {
    print('child2 rebuild');
    return Text("child2: $counter");
  }
}

上記のコードでcount1をインクリメントすると、親Widgetのリビルドを検知してchild2までリビルドされてしまいます。

flutter: parent rebuild
flutter: child1 rebuild
flutter: child2 rebuild

Child2Widgetが静的なWidgetであればconstを使うことによってリビルドを制御することができます。

https://qiita.com/chooyan_eng/items/ec11f6dcf714f7a2fa3d#const-を使う

今回のサンプルコードではさほど影響はないかもしれないですが、規模が大きいアプリの場合、無駄なリビルドはパフォーマンスにかなりの影響を与えてしまいます。

以下は状態を末端の子Widgetに配置した例です:

// ignore_for_file: avoid_print

import 'package:flutter/material.dart';

class ParentWidget extends StatelessWidget {
  const ParentWidget({super.key});

  
  Widget build(BuildContext context) {
    print('parent rebuild');
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text('AppBar'),
      ),
      body: const Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Child1Widget(),
            Child2Widget(),
          ],
        ),
      ),
    );
  }
}

class Child1Widget extends StatefulWidget {
  const Child1Widget({super.key});

  
  State<Child1Widget> createState() => _Child1WidgetState();
}

class _Child1WidgetState extends State<Child1Widget> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  
  Widget build(BuildContext context) {
    print('child1 rebuild');
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Text('child1: $_counter'),
        ElevatedButton(
          onPressed: _incrementCounter,
          child: const Text('Increment Counter1'),
        ),
      ],
    );
  }
}

class Child2Widget extends StatefulWidget {
  const Child2Widget({super.key});

  
  State<Child2Widget> createState() => _Child2WidgetState();
}

class _Child2WidgetState extends State<Child2Widget> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  
  Widget build(BuildContext context) {
    print('child2 rebuild');
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Text('child2: $_counter'),
        ElevatedButton(
          onPressed: _incrementCounter,
          child: const Text('Increment Counter2'),
        ),
      ],
    );
  }
}

上記のコードでcount1をインクリメントすると、child1のみリビルドされ、無駄なリビルドを防ぐことができました。

flutter: child1 rebuild

Jetpack Composeにおける状態配置

基本的な考え方

Jetpack Composeでは、親に状態を配置することが推奨され、状態を上位のコンポーザブルに移動することを状態ホイスティングと呼びます。Jetpack Composeは、状態が変化した際に影響を受ける子要素のみを再コンポーズする仕組みを持っており、この特性により、親に状態を集約してもパフォーマンスへの影響が少ない設計を行うことが可能です。

この特徴により、Flutterと違い次のようなメリットが得られます:

  • プレビュー機能の活用:親に状態を集約することで、Previewが活用しやすくなる。
  • 再利用性の向上:子コンポーネントは純粋なコンテンツのみを表示するコンポーネントになるため、テスタビリティの向上や再利用しやすくなる。
  • 責務の分離:親に状態を集約し、状態の管理とUIロジックを明確に分離することで、コードの責務が明確になり、可読性が向上する。

https://developer.android.com/develop/ui/compose/state-hoisting?hl=ja
https://zenn.dev/newspicks/articles/1be952d9166c19

サンプルコード

以下は状態を親Widgetに配置した例です:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ParentScreen() {
    var counter1 by remember { mutableStateOf(0) }
    var counter2 by remember { mutableStateOf(0) }

    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("TopAppBar") },

                )
        },

        ) { innerPadding ->
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(innerPadding),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.Center
        ) {
            Child1Widget(counter = counter1)
            Child2Widget(counter = counter2)
            Spacer(modifier = Modifier.height(16.dp))
            Button(onClick = { counter1++ }) {
                Text("Increment Counter 1")
            }
            Spacer(modifier = Modifier.height(8.dp))
            Button(onClick = { counter2++ }) {
                Text("Increment Counter 2")
            }
        }
    }
}

@Composable
fun Child1Widget(counter: Int) {
    println("Child1Widget recompose")
    Text(text = "child1: $counter")
}

@Composable
fun Child2Widget(counter: Int) {
    println("Child2Widget recompose")
    Text(text = "child2: $counter")
}

上記のコードでcount1をインクリメントすると、child1のみ再コンポーズが走りました。

Child1Widget rebuild

まとめ

FlutterとJetpack Composeの親子関係における状態配置をまとめると下記のようになります。

  • Flutterでは、状態を末端(子)に配置することでリビルドの範囲を最小化。
  • Jetpack Composeでは、親に状態を集約しつつ、再コンポーズの影響を限定。

必ずしも上記の思想に従うのではなく、あくまでも推奨までで用途やプロジェクトの規模感に応じて適切に設計を行なっていくことが大切ですね。

参考文献

https://api.flutter.dev/flutter/widgets/StatefulWidget-class.html
https://qiita.com/chooyan_eng/items/ec11f6dcf714f7a2fa3d
https://developer.android.com/develop/ui/compose/state-hoisting?hl=ja
https://zenn.dev/newspicks/articles/1be952d9166c19

脚注
  1. ツリー構造の末端に位置するノードの意味 ↩︎

GitHubで編集を提案

Discussion