Blog

Jetpack Compose, React, Flutter, SwiftUIを比較する

宣言的UIの考え方はReact、Flutter、SwiftUI、Jetpack Composeと広がり、ほぼ全てのプラットフォームで利用できるようになりました。

今までのHTMLやXMLに対して命令的に処理を書くのに対し、宣言的UIはUIの構築や更新を圧倒的に簡素にしてくれます。

また、差分更新の仕組みを備えているものも多く、パフォーマンスの向上も見込めます。

今回はいくつかある宣言的UIのツール群の中から、代表的なJetpack Compose、React、Flutter、SwiftUIを個人的な見解も含めて比較していきます。

コンポーネントの記述

宣言的UIの大きなメリットの一つに、再利用可能なコンポーネントが挙げられるでしょう。

StatelessコンポーネントとStatefulコンポーネントそれぞれについて比較を行います。 SwiftUI

Statelessコンポーネント

まずは状態を持たないStatelessのコンポーネントを見比べてみます。

Jetpack Composeの、コンポーネントは以下のように書くことができます。コンポーネントは関数で表現され、 @Composable アノテーションをつけます。

@Composable
fun Greeting(name: String) {
    Text("Hello $name")
}

Reactは関数での表現と、クラスでの表現と両方あります。最近は関数での表現が多いように見えます。

function Greeting(props) {
  return <p>Hello, {props.name}</p>;
}
class Welcome extends React.Component {
  render() {
    return <p>Hello, {this.props.name}</p>;
  }
}

Flutterはclassを使います。

class Greeting extends StatelessWidget {
  const Greeting({required this.name, Key? key}) : super(key: key);

  final String? name;

  @override
  Widget build(BuildContext context) {
    return Text("Hello, $name");
  }
}

SwiftUIはコンポーネントをstructとして扱います。

struct Greeting: View {
    var name: String

    var body: some View {
        Text("Hello, \(name)")
    }
}

基本は大きく変わりませんが、ちょっとずつ書き方が異なりますね。

好みはあるかもしれませんが、個人的にclassやstructを使った書き方はボイラープレートが多く、少し冗長に感じます。また、状態を持たないコンポーネントにclassを使うのは、誤った書き方をしてしまいそうです。

Jetpack ComposeとReactの関数での表現は非常に似ているように思いますが、1点異なる点があります。Jetpack Composeのコンポーネントは返り値がなく、Reactの関数はコンポーネントを返します。

これも好き嫌いあるかもしれませんが、返り値が無いほうが、returnを省略できる他、コンポーネント内で条件を書きたい場合等に若干楽になると思います。

@Composable
fun Sample(flag: Boolean) {
    Text("Hello,")
    if (flag) {
        Text("World")
    }
}
function Sample(props) {
  return (
    <div>
      <p>Hello</p>
      {props.flag && <p>World</p>}
    </div>
  );
}

- 2021/10/24 追記 -

詳細の記事を追加しました:Jetpack Composeのコンポーネントはなぜ返り値がないのか

Statefulコンポーネント

コンポーネントに状態をもたせる、Statefulコンポーネントも比較していきましょう。

Jetpack Composeだとこんな感じです。

@Composable
fun Counter() {
    val count by remember { mutableStateOf(0) }
    Button(
        onClick = { count++ }
    ) {
        Text("${count}")
    }
}

次にReactです。書き方が複数ありますが、Hooksと呼ばれる手法を紹介します。

function Counter() {
  const [count, setCount] = useState(0);
  return (
    <>
      <button onClick={() => setCount(count + 1)}>
        {count}
      </button>
    </>
  );
}

Flutterもいくつかあるようですが、ここではStatefulWidgetを紹介します。

class Counter extends StatefulWidget {
  @override
  _Counter createState() => _Counter();
}

class _Counter extends State<Counter> {
  int _counter = 0;

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

  @override
  Widget build(BuildContext context) {
    return TextButton(
      onPressed: _incrementCounter,
      child: Text("$_counter"),
    );
  }
}

Flutter Hooksというライブラリを使えば、Jetpack ComposeやReact Hooksと似たように記述できるようです。

class Counter extends HookWidget {
  @override
  Widget build(BuildContext context) {
    final counter = useState(0);

    return TextButton(
      onPressed: () => counter.value++,
      child: Text("${counter.value}"),
    );
  }
}

SwiftUIだと以下のような感じです。

struct Counter: View {
    @State var count = 0
    
    var body: some View {
        Button(
            action: { count += 1 },
            label: { Text("\(count)")}
        )
    }
}

こちらもSwiftUI Hooks というライブラリがあるようです。

多くのプラットフォームでHooks対応のライブラリが出ており、またJetpack ComposeもHooksの影響を大きく受けているため、いずれもほぼ同じ書き方ができます。

ただJetpack Composeが異なる点としては、Hooks系のメソッドの前で条件分岐を書いたり、コンポーネント内にHooksのメソッドをかけるという点です。

Reactでは、以下のような記述は禁止されています。詳細

if (flag) {
  const [count, setCount] = useState(0);
  ...
}
return (
  <div>
    {
      const [count, setCount] = useState(0);
      ...
    }
  </div>
)

Flutter Hooks、SwiftUI Hooksでも同じ制約があるようです。

Jetpack Composeでは、全く問題なく実行することができます。これは、コンポーネントもHooks的なメソッドも全てComposable関数として表現しているためです。

if (flag) {
    val count by remember { mutableStateOf(0) }
    ...
}
Column {
    val count by remember { mutableStateOf(0) }
    ...
}

些細な差にも感じますが、意外と行いたくなるケースは存在するため、助かっています。

Lifecycle

コンポーネントマウント時やアンマウント時に処理を行いたい場合は多いでしょう。それぞれの書き方を比較してみます。

まずはJetpack Composeです。DisposableEffectを使います。最後にonDisposeを使うことで、アンマウント時の処理を書くことができます。

DisposableEffect(Unit) {
    val callback = Callback()
    callback.register()
    onDispose {
        callback.unregister()
    }
}

次にReactです。ほとんど同じですが、最後に関数を返すことでアンマウント時の処理を書きます。onDisposeを書かなくてすむ一方、慣れるまでは少し読みにくいかもしれません。

useEffect(() => {
  const callback = new Callback()
  callback.register()
  return () => {
    callback.unregister()
  };
}, []);

Flutterです。StatefulWidgetを使って書きます。

class Sample extends StatefulWidget {
  @override
  _State createState() {
    return _State();
  }
}

class _State extends State<Sample> {
  final Callback _callback = Callback();

  @override
  Widget build(BuildContext context) {
    return ...;
  }

  @override
  void initState() {
    super.initState();
    callback.register()
  }

  @override
  void dispose() {
    super.dispose();
    callback.unregister()
  }
}

Flutter Hooksを使えばReact、Jetpack Composeと同じ用に書けます。

SwiftUIはonAppearonDisappearで書けるようです。

struct Sample: View {
    private let callback = Callback()

    var body: some View {
        Component()
            .onAppear(perform: {
                callback.register()
            })
            .onDisappear(perform: {
                callback.unregister()
            })
    }
}

React、Jetpack Composeの書き方は最初驚きを覚えますが、たしかにマウント時、アンマウント時の処理は一緒に使うことが多く、またCallbackのようなインスタンスの取り回しもしやすいので便利です。また、マウント時、アンマウント時の処理をまとめて再利用しやすいというメリットもあります。

Styleの書き方

背景色やパディング、マージン等、コンポーネントの装飾方法について紹介します。

Jetpack ComposeはModifierを使ってコンポーネントを装飾することができます。

Text(
    text = "Hello World",
    modifier = Modifier
        .background(Color.Red)
        .padding(10.dp)
)

Reactは本当に様々な書き方ができますが、一番簡単なのはinline cssを使う方法でしょうか。CSS in JSやCSS modulesが使われることのほうが多いようです。

const divStyle = {
  padding: '10px',
  backgroundColor: 'red',
};
return <div style={divStyle}>Hello World</div>;

Flutterはwidgetを追加する必要があります。

Container(
  color: Colors.red,
  padding: const EdgeInsets.all(10),
  child: Text("Hello World"),
)

SwiftUIはメソッドチェーンで書くことができます。

Text("Hello World")
  .padding(10)
  .background(Color.red)

意外と差分がありますが、皆さんはどれがお好きでしょうか?

個人的にStyleをつけるのにコンポーネントを使うのは、ネストが深くなるだけでなく、使い回ししにくそうだなと感じました。

差分更新の差分

最後に、UIの差分更新についてちょっとだけ比較したいと思います。

まず、Reactの例です。

例えば、以下のように100個のアイテムを持つリストを、ボタンを押すたびにリストを追加するプログラムを考えます。

function List() {
  console.log('start')
  const initialValue = [...Array(100)].map((_, i) => i);
  const [value, setValue] = useState(initialValue);
  return (
    <>
      <button onClick={() => {
        console.log('click!')
        setValue([...value, value.length]);
      }}>
        Add
      </button>
      {value.map((i) => (
        <Item key={i} name={i} />
      ))}
    </>
  );
}

function Item(props) {
  console.log(props.name);
  return (<div>{props.name}</div>);
}

まず、これを実行すると以下のようなログを確認できます。

start
0
1
...
98
99

そしてボタンを押した際、以下のログが追加されます。0~99が変更が無いのにも関わらず、実行されていることがわかると思います。

click!
start
0
1
...
99
100

実際にはこのあと差分検知が行われて、必要なDOM操作が行われているため、大抵の場合大きな問題になることはないようです。

次にJetpack Composeの例を見てみましょう。

@Composable
fun List() {
    Log.d("Sample", "start")
    var value by remember {
        mutableStateOf(List(100) { it })
    }
    Column {
        Button(onClick = {
            Log.d("Sample", "click!")
            value = value + value.size
        }) {
            Text("Add")
        }
        value.forEach {
            Item(it)
        }
    }
}

@Composable
fun Item(name: Int) {
    Log.d("Sample", "$name")
    Text("$name")
}

初期表示の状態では、同じ用にこのようなログが出力されます。

D/Sample: start
D/Sample: 0
D/Sample: 1
D/Sample: 2
︙
D/Sample: 98
D/Sample: 99

次に、クリックした場合の挙動です。差分のないコンポーネントは呼ばれていないことが確認できます。

D/Sample: click!
D/Sample: start
D/Sample: 100
D/Sample: end

@Composable がついたメソッドはコンパイル時に処理が追加され、必要なとき以外は呼び出されないように制御されています。そのため、大きなリストを更新する際も安心して実行が行えます。

ちなみに、ReactではReact.useMemo を利用することで、propsに差分のないメソッドは呼ばないように制御することができます。

function List() {
  console.log('start')
  const initialValue = [...Array(100)].map((_, i) => i);
  const [value, setValue] = useState(initialValue);
  return (
    <>
      <button onClick={() => {
        console.log('click!')
        setValue([...value, value.length]);
      }}>
        Add
      </button>
      {value.map((i) => (
        <Item key={i} name={i} />
      ))}
    </>
  );
}

const Item = React.memo((props) => {
  console.log(props.name);
  return (<div>{props.name}</div>);
});
start
0
1
...
98
99
click! 
start 
100

SwiftUI、FlutterはJetpack Composeと同じ用に動作するようです。

どちらのパフォーマンスが良いのかは、メモリ使用量も含めて考える必要があるため、簡単には判断できないと思いました。

一方で、Reactでmemoを使うか、使わないかはよく議論に上がるポイントだと思っていて、デフォルトで全てに差分検知がかかる仕組みのほうがわかりやすいのかもしれません。

まとめ

今回、いくつかの宣言的UIの比較を行いました。

お互いの影響を強く受けあっているためか、大きな差分はなく、比較的容易に互いを理解できると感じました。 言語的な制約や思想、プラットフォームの制約等により、細かい差分があることもわかりました。

Kotlinに慣れ親しんでるのもあるかもしれませんが、個人的にはJetpack Composeが一番ストレスなく書くことができました。 Jetpack Composeは現在DesktopアプリやWebでも使え、今後活躍の幅が広がって欲しいなと思っています。

Compose Multiplatform Framework | JetBrains: Developer Tools for Professionals and Teams

ぜひ皆さんの手に馴染む宣言的UIが見つかることを祈っています。

人気の記事

Jetpack ComposeとViewModelについて考える

kotlin coroutinesのFlow, SharedFlow, StateFlowを整理する

ViewModelでString resourcesを扱いたい

Jetpack Composeのコンポーネントはなぜ返り値がないのか

MVVMでモデルに処理を寄せる【Android】

Navigation Componentのいい感じのアニメーションを検討する【サンプルアプリあり】