같은 제목으로 UI를 구축해 Flutter, Jetpack Compose, SwiftUI 세 가지 선언적인 UI 프레임워크를 비교해 보자.

개시하다


최근 안드로이드/iOS 로컬 애플리케이션 개발에서'선언적 UI를 사용하는 UI 프레임워크'(이하 이를 선언적 UI 프레임워크와 본 기사라고 함)가 유행하고 있다.이 프로세스는 React의 아이디어와 자신의 Fluter와 ReactNative 같은 멀티플랫폼 프레임워크를 활용한 것으로 안드로이드는 Jetpack Compose, iOS는 SwiftUI로 현재도 각자의 플랫폼에서 사용되고 있다.
선언적 UI 프레임워크는 기존의 절차 방법보다 UI를 직관적으로 설명하고 이를 통해 View를 더욱 효율적으로 구축할 수 있다.지금까지 기존 앱에서 이 방법을 적용할 예정인 상황에서 Flutter나 ReactNative라면 새로운 언어라도 다른 프레임워크를 가져와야 한다. JetpackCompose는 dev 버전에서 제품에 도입할 수 있는 것이 아니라 SwiftUI라면 iOS 13 이상이 필요하다.쉽게 쓸 수 있는 물건이 아니다.하지만 이런 상황도 바뀌고 있다.JetpackCompose 개발이 진행 중이며 알파 버전으로 머지않아 스테이블이 될 수도 있습니다.스wiftUI와 관련해서도 iOS 14가 발표되고 iOS 12가 차지하는 비율도 줄어들면서 선언적인 UI 프레임워크가 로컬 앱 개발에 적용되는 현실성도 나왔다.내년 2021년은 스마트폰 애플리케이션을 개발하는 선언적 UI 원년이 된다.평소 안드로이드 앱과 iOS 앱 개발에 종사하는 필자에게 뒤처지지 말아야 할 절차라고 느꼈던 제팩 컴포즈, 스위프트 UI 각각의 기술을 배우기 위해 경험이 있는 플루터를 포함한 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
  • 확인


    제목을 실현한 구성 요소의 실현을 병렬적으로 비교하다.실제 응용 프로그램을 시작하는 부분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!")
        }
      }
    }
    

    액션


    샘플 코드를 최소화하고 싶어서 플utter 모드가 StatiusBar에 들어갔지만 주ScaffoldAppBar를 통해 정확하게 그려졌다.
    Flutter
    JetpackCompose
    SwiftUI



    한마디


    리액트를 패러다임으로 선언적 UI를 구현한다는 동일한 사상으로 제작됐기 때문에 간단한 상황에서도 완전히 같은 기술이 가능하다.

    버튼을 누르면 숫자 문자가 증가합니다


    상태 처리와 관련된 UI입니다.문자와 버튼을 수직으로 정렬하고 버튼을 클릭하면 UI가 만들어져 문자에 표시되는 숫자가 0에서 증가합니다.

    비교해 보다


    Flutter
    Flutter로 상태를 관리하는 방법으로
  • StatefulWidget
  • Provider
  • RiverPod
  • 1대2의 경우 성능 면에서 불리하기 쉬운 3가지 방법이 있는데, 최근에는 잘 쓰지 않고 있으며, 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의 구조를 이용해 상태를 관리하면 이른바 View Model로 강제 분리되기 때문에 다른 프레임도view Model로 분리된 것처럼 느껴진다.
    비교와는 직접적인 상관은 없지만, 상태가 업데이트될 때 어느 프레임이든 차분 계산을 통해 고속으로 그려지는데, 상대적으로 단순하게 성능이 높은 UI를 구현할 수 있다는 점도 선언적 UI 프레임의 매력 중 하나라고 한다.

    무한 목록


    마지막으로 실용적인 제목으로 0, 1, 2, 3...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



    한마디


    이렇게 복잡한 예라면 각 프레임의 차이도 커질 것이다.목록 요소 작성
  • Fluter는 Builder로 하나하나 제작하여 index 기반 데이터 원본이 분실된 것을 검출할 때 불러오기
  • JetpackCompose는 호출 주위를 Paging 라이브러리에 맡기고 각 요소의 UI 제작 부분에서 호출을 필요로 하지 않으며 간단하게 무한존재 데이터 원본으로 기술할 수 있다
  • SwiftUI는 List의 벽장에 하나씩 만들어져 데이터 원본이 분실된 것으로 보일 때 불러옵니다.생각은 Flutter의 것과 비슷하다."다른""묘사 후""라이프 사이클을 사용하지 않는 방법
  • "
    이런 인상.개인적으로 제팩컴포즈의 기술이 가장 수월한 데다 선언적인 기술 중에서도 다양한 판정 처리 등 절차적인 기술이 UI 구축 부분에 들어가는 양을 줄일 수 있어 가독성이 좋다.그러나 프로그램 라이브러리를 사용하는 방법을 배울 필요가 있기 때문에 이 부분도 학습 원가가 증가하는 것을 느낄 수 있다.다른 프레임워크에서도 페이지 주변에 랩으로 싸인 프로그램 라이브러리를 기술하거나 제3자가 만든 것을 사용해 같은 효과를 낼 수 있지만 공식이 이런 흔한 패턴의 해결 방안을 마련해 준 것은 다행이다.

    끝말


    3가지 제목 중 플utter, JetpackCompose, SwiftUI 등 3가지 선언적 UI 프레임워크를 비교했다.역시 리액트를 참고해 선언적 UI를 구현하겠다는 생각이어서 지금까지 각종 수수료 플랫폼을 겨냥해 제작된 UI 프레임과 비교하면 상당히 비슷한 기술임을 알 수 있다.복잡한 패턴의 UI를 어느 정도 묘사할 때 차이가 있는 것은 사실이지만 UI를 구축할 때 기본적인 아이디어는 어느 정도 유연하게 활용할 수 있다.지금까지는 두 가지 측면에서 생각이 완전히 다른 안드로이드/iOS 플랫폼의 각각의 UI 구축 방법을 배우기 어려웠기 때문에 선언적 UI 아이디어를 파악할 수 있다면 어떻게든 해결할 수 있을 것 같다는 게 너무 기쁘다.

    참고 자료

  • GitHub - crelies/List-Pagination-SwiftUI
  • Fluuter.dev - Work with long lists

  • Android Developers - Layouts in Compose
  • 좋은 웹페이지 즐겨찾기