📱

同じお題でUIを構築してみてFlutter, JetpackCompose, SwiftUIの三種の宣言的UIフレームワークを比較してみる

2020/12/26に公開

はじめに

昨今Android/iOSネイティブアプリの開発では「宣言的UIを利用したUIフレームワーク」(以下、このようなフレームワークを宣言的UIフレームワークと本記事では呼称します)が台頭しようとしています。この流れは、Reactの考え方やそれ自体を利用したFlutterやReactNativeのようなマルチプラットフォームフレームワークから始まり、AndroidではJetpackCompose、iOSではSwiftUIと、現在はそれぞれのプラットフォームにも取り込まれるまでになっています。
 宣言的UIフレームワークは旧来の手続き的な方法よりもより直感的にUIを記述することができ、採用することでViewの構築をより効率よく行うことができます。これまでは既存のアプリケーションにこの方法を取り入れようとした場合、FlutterやReactNativeであれば新規に言語すら違うフレームワークを導入する必要があったり、JetpackComposeはまだdev版でとても製品に取り入れられるものではなく、SwiftUIであればiOS13以上、というものが障壁になっており、気軽に採用できるものではありませんでした。しかし、そういった状況も現在変わってきています。JetpackComposeは開発が進み現在はalpha版になっており、そう遠くないうちにstableとなる可能性があります。SwiftUIについてもiOS14がリリースされ、iOS12が占める割合も減りつつあり、宣言的UIフレームワークをネイティブアプリ開発に採用する現実味が出てきました。いよいよ来年2021年はスマホネイティブアプリ開発における宣言的UI元年となりそうです。普段AndroidアプリもiOSアプリも両方の開発に携わる筆者としてはこの流れに遅れてはならないと感じるところがあり、JetpackCompose,SwiftUIそれぞれの記述を学ぶため、経験のあるFlutterを含めた3つの宣言的UIフレームワークで同じお題でUIを構築したとき、どのような違いが生じるか比較してみました。

検証環境

  • Flutter
    • IDE: AndroidStudio 4.1
    • Flutter: 1.22.0-12.1.pre
  • JetpackCompose
    • IDE: Android Studio Arctic Fox | 2020.3.1 Canary 3
    • Kotlin 1.4.21
    • JetpackCompose: 1.0.0-alpha09
    • paging-compose:1.0.0-alpha04
  • SwiftUI
    • IDE: Xcode 12.3
    • Swift5

検証

それぞれ、お題を実現したコンポーネントの実装を並べて比較してみます。実際にアプリケーションを開始する部分(FlutterだったらrunAppするところ)だったりは冗長なので省きます。

アプリケーションの開始部分なども含んだ完全なものはこちらを参照してください

また、「同じようなUIを実現したときにどんな実装となるか」という本質からずれてしまうため、個々のUIの詳細な見た目までは共通にしません。デザイン周りについては最小限のコードとします。

縦にテキストを並べる

まずはシンプルなUIです。縦にHello, World!とテキストを並べてみます

比較してみる

Flutter
class ColumnPatternWidget extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return Column(children: [
      Text('Hello,'),
      Text('World!'),
    ]);
  }
}
JetpackCompose
@Composable
fun ColumnPattern() {
    Column {
        BasicText(text = "Hello,")
        BasicText(text = "World!")
    }
}
SwiftUI
struct ColumnPattern: View {
  @ViewBuilder
  public var body: some View {
    VStack {
      Text("Hello,")
      Text("World!")
    }
  }
}

動作

サンプルコードを最小限にしたかった都合でFlutterのパターンがStatusBarにめりこんでしまっていますが、ScaffoldAppBarを渡してやることで正しく描画させることができます。

Flutter JetpackCompose SwiftUI

ひとこと

Reactを手本に宣言的UIの実現という同じ思想で作られているため、シンプルなケースでは全く同じように記述ができるみたいです。

ボタンを押したら数字のテキストがインクリメントされる

ステートの取り扱いが絡むUIです。テキストとボタンを縦に並べ、ボタンをタップするとテキストに表示された数字が0からインクリメントされるUIを作ってみます。

比較してみる

Flutter

Flutterでステートを管理する手法として、

  1. StatefulWidget
  2. Provider
  3. RiverPod

の3つの方法がありますが、それぞれ1は2に対してパフォーマンス面で不利になりがちで最近はあまり使われないこと、3は最新の手法ですがまだ安定していないことから、ここでは2のProviderを利用する方向で行っています。

class Counter with ChangeNotifier {
  int value = 0;

  increment() {
    value++;
    notifyListeners();
  }
}

class ProviderUpdatePatternWidget extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return ChangeNotifierProvider<Counter>.value(
      value: Counter(),
      child: ButtonAndText(),
    );
  }
}

class ButtonAndText extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return Column(children: [
      Text(context.select((Counter counter) => counter.value.toString())),
      FlatButton(
        onPressed: () => context.read<Counter>().increment(),
        child: Text('increment'),
      ),
    ]);
  }
}

JetpackCompose
@Composable
fun UpdatePattern() {
    val count = remember { mutableStateOf(0) }
    Column {
        BasicText(text = count.value.toString())
        Button(onClick = { count.value++ }) {
            BasicText(text = "increment")
        }
    }
}
SwiftUI
struct UpdatePattern: View {
  @State var count: Int = 0
  
  @ViewBuilder
  public var body: some View {
    VStack {
      Text("\(count)")
      Button("increment") { count += 1 }
    }
  }
}

動作

Flutter JetpackCompose SwiftUI

ひとこと

ステートの取り扱い方を比較すると、フレームワークごとの個性が出てきますね。Flutterは気持ち冗長に見えますが、ここで活用しているProviderと呼ばれる仕組みを利用してステートを管理すると、いわゆるViewModel的な物に分離されることを強制されるので、他のフレームワークでもきっちりViewModelとして分離すると似たような感じになるのかなーと感じています。
比較とは直接関係ないのですが、ステート更新時にはどのフレームワークでも差分計算による高速な描画が行われるそうで、パフォーマンスの高いUIを比較的簡単に実現できるのも宣言的UIフレームワークの魅力の一つですね。

無限リスト

最後により実用的なお題として、 0,1,2,3...と無限に数字が並ぶリストを1ページ20個として表示する無限リストを作ってみます。ネットワークから何らかのデータを取得することを想定し、次ページの読み込みには3秒かかるものとし、ロード中はプログレスを表示するものとします。
コード規模が結構大きくなってきており、微妙にコンセプトにズレがありますが、ご容赦ください 🙇

Flutter

インクリメントの例と同じくProviderを利用しています。
ListView.builderで渡されるindexを元にデータソースとなる配列から要素を取得したり、最後の要素であることを判定して次ページの読み込みを行ったりします。

class Numbers with ChangeNotifier {
  List<int> numbers = [];
  bool loading = false;

  int page = 0;
  final int pageSize = 20;

  void load() async {
    if (loading) {
      return;
    }
    loading = true;

    await Future.delayed(Duration(seconds: 3));

    final list =
        List<int>.generate(pageSize, (i) => page * pageSize + i);
    numbers.addAll(list);
    page += 1;
    loading = false;
    notifyListeners();
  }
}

class InfiniteListViewWidgetPage extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return ChangeNotifierProvider<Numbers>.value(
      value: Numbers(),
      child: InfiniteListViewWidget(),
    );
  }
}

class InfiniteListViewWidget extends StatelessWidget {
  
  Widget build(BuildContext context) {
    final length = context.watch<Numbers>().numbers.length;
    return ListView.builder(
      itemBuilder: (BuildContext context, int index) {
        if (index == length) {
          context.read<Numbers>().load();

          return Center(
            child: Container(
              margin: const EdgeInsets.only(top: 8.0),
              child: const CircularProgressIndicator(),
            ),
          );
        } else if (index > length) {
          return null;
        }

        return ListCellWidget(index: index);
      },
    );
  }
}

class ListCellWidget extends StatelessWidget {
  final int index;

  const ListCellWidget({Key key, this.index}) : super(key: key);

  
  Widget build(BuildContext context) {
    final number = context.select((Numbers numbers) => numbers.numbers)[index];
    return ListTile(
      title: Text(number.toString()),
    );
  }
}
JetpackCompose

Pagingライブラリを利用します。基本的にページングにつきもののページ操作などはPagingライブラリにすべて丸投げです。

@Composable
fun InfiniteListViewWithProgressPattern() {
    val pageSize = 20

    val source: Flow<PagingData<Int>> = Pager(PagingConfig(pageSize = pageSize)) {
        object : PagingSource<Int, Int>() {
            override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Int> {
                 val pageNumber = params.key ?: 0

                delay(3000)

                return LoadResult.Page(
                    data = (pageSize * pageNumber until pageSize * pageNumber + pageSize).toList(),
                    prevKey = if (pageNumber > 0) pageNumber - 1 else null,
                    nextKey = pageNumber + 1
                )
            }
        }
    }.flow

    val lazyItems = source.collectAsLazyPagingItems()

    LazyColumn {
        if (lazyItems.itemCount == 0) {
            item {
                CircularProgressIndicator()
            }
        }

        items(lazyItems) { item ->
            BasicText(text = item.toString())
        }

        if (lazyItems.itemCount != 0) {
            item {
                CircularProgressIndicator()
            }
        }
    }
}
SwiftUI

こちらを参考に、RandomAccessCollectionのextensionを生やし、描画したのが最後の要素だったのかを判定できるようにします。リストで描画する各要素のonAppearでこれを利用し、最後の要素だったらロードを行うようにしています。

struct InfiniteListViewWithProgressPattern: View {
    private let pageSize: Int = 20
    
    @State private var items: [String] = []
    @State private var isLoading: Bool = false

    @State private var page: Int = 0
    
    @ViewBuilder
    public var body: some View {
        
        if items.isEmpty {
            ProgressView().onAppear() {
                loadMoreItems()
            }
        }
        
        List(items) { item in
            VStack(alignment: .leading) {
                Text(item)

                if isLoading && items.isLastItem(item) {
                    Divider()
                    ProgressView()
                }
            }.onAppear {
                if items.isLastItem(item) {
                    loadMoreItems()
                }
            }
        }
    }
    
    private func loadMoreItems() {
        isLoading = true
        DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 3) {
            defer {
                page += 1
                isLoading = false
            }
            if page == 0 {
                items = Array(0...20).map { "\($0)" }
                return
            }
            let maximum = ((page * pageSize) + pageSize) - 1
            let moreItems: [String] = Array(items.count...maximum).map { "\($0)" }
            items.append(contentsOf: moreItems)
        }
    }
}

extension RandomAccessCollection where Self.Element: Identifiable {
    public func isLastItem<Item: Identifiable>(_ item: Item) -> Bool {
        guard !isEmpty else {
            return false
        }

        guard let itemIndex = lastIndex(where: { AnyHashable($0.id) == AnyHashable(item.id) }) else {
            return false
        }

        let distance = self.distance(from: itemIndex, to: endIndex)
        return distance == 1
    }
}

extension String: Identifiable {
    public var id: String {
        return self
    }
}

動作

Flutter JetpackCompose SwiftUI

ひとこと

ここまで複雑な例だとそれぞれのフレームワークの違いもかなり大きくなってきますね。それぞれ、リストの要素の作成について

  • FlutterはBuilderで一つずつ作成、indexを元にデータソースが表示しつくされたことを検知したらローディング
  • JetpackComposeはページング周りはPagingライブラリにおまかせ、要素ごとのUI作成部分ではページングを意識しなくて済み、無限にデータソースが存在するものとして簡単に記述できる
  • SwiftUIはListに渡したクロージャで一つずつ作成、データソースが表示しつくされたことを検知したらローディング。考え方はFlutterのものと似ている感じ。「描画後」というライフサイクルを利用するあたりが他にないやり方

という印象です。個人的にはJetpackComposeの記述が一番ラクに感じるとともに、宣言的な記述の中に、諸々の判定処理などの手続き的な記述がUI構築部分に入ってしまう量を少なくできるため可読性に優れるかなと感じています。その代わり、ライブラリの扱い方を学ぶ必要があるため、その部分は学習コストがかかってしまうかなとも感じています。他のフレームワークでも、ページング周りをラップしたライブラリを記述したり、あるいは第三者が作成したものを使うことで同じようにできるとは思いますが、こうしたありがちなパターンの解決策を公式が用意してくれている点はありがたいところですね。

おわりに

3つのお題から、Flutter, JetpackCompose, SwiftUIの三種の宣言的UIフレームワークを比較してみました。やはり、Reactを参考に宣言的UIの実現という同じ考えのもと作られているものなので、これまでの手続き的な各種プラットフォーム向けに作られたUIフレームワークよりもかなり似通った記述となることがわかります。ある程度複雑なパターンのUIを記述する際にはさすがに違いが出てきますが、UI構築時の基本的な考え方はある程度流用が効きそうです。これまで、思想が全く違うAndroid/iOSのプラットフォームそれぞれのUI構築方法を両方とも学ぶのはとても困難だったので、宣言的UIの考え方を身に着けていればだいたいなんとかなる、というのが実現すると大変喜ばしいですね。

参考

Discussion