내일 바로 써먹는 Flutter Clean UI Code

Ximya(심야)·2023년 11월 22일
6
post-thumbnail
post-custom-banner

다들 한 번쯤 방에서 소중한 물건을 잃어버린 경험을 한 적이 있을 겁니다. 특히 여러분의 방이 어지럽혀져 있을 수 록 또 방의 크기가 클수록 찾는데 더 많은 시간이 걸립니다. 이는 UI 코드와 비슷한 맥락을 가집니다. 코드가 정돈되지 않고 한 페이지에 여러 위젯이 섞여 있을수록 한눈에 알아보기 힘든 코드가 됩니다.

방에서 물건을 찾는 수고 정도는 본인이 감내하면 되겠지만, 함께 협업하여 진행하는 프로젝트에서는 정돈되지 않은 UI 코드는 동료 개발자들을 고생시킬 수 있습니다. 어지럽혀진 본인의 방에서 동료들이 잃어버린 물건을 찾는 경우는 없어야겠죠.

이번 글에서는 UI 코드를 구조화하여 가독성을 높이고 유지보수협업에 용이한 형태로 구성하는 방법을 소개해 드리고 있으니 관련 팁을 얻어가세요!


스파게티 코드의 문제점

먼저, 정돈되지 않은 UI 코드에는 어떤 문제점이 있을까요?

class ProductDetailPage extends StatelessWidget {  
  const ProductDetailPage({Key? key}) : super(key: key);  
  
    
  Widget build(BuildContext context) {  
    return Scaffold(  
      body: Stack(  
        children: [  
          SingleChildScrollView(  
            child: Column(  
              children: <Widget>[  
                /// HEADER  
                Container(  
                  height: 386,  
                  decoration: BoxDecoration(  
                    image: DecorationImage(  
                        image: Image.asset(Assets.productImg0).image,  
                        fit: BoxFit.cover),  
                  ),  
                ),  
                const SizedBox(height: 15),  
  
                Padding(  
                  padding: const EdgeInsets.symmetric(horizontal: 20),  
                  child: Column(  
                    crossAxisAlignment: CrossAxisAlignment.start,  
                    children: <Widget>[  
                      /// PRODUCT INFO  
                      SizedBox(  
                        width: double.infinity,  
                        child: Wrap(  
                          crossAxisAlignment: WrapCrossAlignment.end,  
                          alignment: WrapAlignment.spaceBetween,  
                          children: <Widget>[  
                            Wrap(  
                              direction: Axis.vertical,  
                              children: [  
                                Text(  
                                  'Men\'s Printed Pullover Hoodie ',  
                                  style: AppTextStyle.body3.copyWith(  
                                    color: AppColor.grey,  
                                  ),  
                                ),  
                                const SizedBox(height: 8),  
                                // NAME  
                                Text(  
                                  'Nike Club Fleece',  
                                  style: AppTextStyle.headline3,  
                                ),  
                              ],  
                            ),  
  
                            Wrap(  
                              direction: Axis.vertical,  
                              children: <Widget>[  
                                Text(  
                                  'Price',  
                                  style: AppTextStyle.body3.copyWith(  
                                    color: AppColor.grey,  
                                  ),  
                                ),  
                                const SizedBox(height: 8),  
                                Text(  
                                  '\$120',  
                                  style: AppTextStyle.headline3,  
                                ),  
                              ],  
                            ),  
                            // CATEGORY  
                          ],  
                        ),  
                      ),  
                      const SizedBox(height: 20),  
  
                      /// PRODUCT PICTURE LIST  
                      Row(  
                        mainAxisAlignment: MainAxisAlignment.spaceBetween,  
                        children: <Widget>[  
                          ...List.generate(productImgList.length, (index) {  
                            final img = productImgList[index];  
                            final imgSize =  
                                (MediaQuery.of(context).size.width - 67) / 4;  
                            return Container(  
                              height: imgSize,  
                              width: imgSize,  
                              decoration: BoxDecoration(  
                                borderRadius: BorderRadius.circular(20),  
                                image: DecorationImage(  
                                  image: Image.asset(img).image,  
                                ),  
                              ),  
                            );  
                          })  
                        ],  
                      ),  
                      const SizedBox(height: 15),  
  
                      /// SIZE  
                      Row(  
                        mainAxisAlignment: MainAxisAlignment.spaceBetween,  
                        children: <Text>[  
                          Text(  
                            'Size',  
                            style: AppTextStyle.body1,  
                          ),  
                          Text(  
                            'Size Guide',  
                            style: AppTextStyle.body2,  
                          )  
                        ],  
                      ),  
                      const SizedBox(height: 10),  
                      Row(  
                        mainAxisAlignment: MainAxisAlignment.spaceBetween,  
                        children: <Widget>[  
                          ...List.generate(sizeOptions.length, (index) {  
                            final option = sizeOptions[index];  
                            final buttonSize =  
                                (MediaQuery.of(context).size.width - 76) / 5;  
                            return ElevatedButton(  
                              style: ElevatedButton.styleFrom(  
                                padding: EdgeInsets.zero,  
                                minimumSize: Size(buttonSize, buttonSize),  
                                elevation: 0,  
                                shape: RoundedRectangleBorder(  
                                  borderRadius: BorderRadius.circular(10),  
                                ),  
  
                                backgroundColor: AppColor.lightGrey,  
                                // background (button) color  
                                foregroundColor:  
                                    AppColor.black, // foreground (text) color  
                              ),  
                              onPressed: () {},  
                              child: Text(option),  
                            );  
                          })  
                        ],  
                      ),  
                      const SizedBox(height: 20),  
  
                      /// DESCRIPTION  
                      Text(  
                        'Description',  
                        style: AppTextStyle.body1,  
                      ),  
                      const SizedBox(height: 10),  
                      const ExpandableTextView(  
                        text: productDescription,  
                        maxLines: 3,  
                      ),  
  
                      /// REVIEWS  
                      Row(  
                        mainAxisAlignment: MainAxisAlignment.spaceBetween,  
                        children: [  
                          Text(  
                            'Reviews',  
                            style: AppTextStyle.body1,  
                          ),  
                          TextButton(  
                              onPressed: () {},  
                              child: Text(  
                                'View All',  
                                style: AppTextStyle.body3.copyWith(  
                                  color: AppColor.grey,  
                                ),  
                              ))  
                        ],  
                      ),  
  
                      const SizedBox(height: 16),  
                      ListView.builder(  
                        padding: EdgeInsets.zero,  
                        physics: const NeverScrollableScrollPhysics(),  
                        shrinkWrap: true,  
                        itemCount: 1,  
                        itemBuilder: (context, index) {  
                          return Column(  
                            children: <Widget>[  
                              Row(  
                                children: <Widget>[  
                                  // PROFILE IMAGE  
                                  Container(  
                                    height: 40,  
                                    width: 40,  
                                    decoration: BoxDecoration(  
                                      shape: BoxShape.circle,  
                                      image: DecorationImage(  
                                        image: Image.asset(  
                                          'assets/images/avatar.png',  
                                        ).image,  
                                      ),  
                                    ),  
                                  ),  
                                  const SizedBox(width: 10),  
  
                                  Column(  
                                    crossAxisAlignment:  
                                        CrossAxisAlignment.start,  
                                    children: <Widget>[  
                                      // REVIEWER NAME  
                                      Text(  
                                        'Ronald Richards',  
                                        style: AppTextStyle.body2,  
                                      ),  
                                      const SizedBox(height: 5),  
                                      Row(  
                                        mainAxisAlignment:  
                                            MainAxisAlignment.center,  
                                        children: <Widget>[  
                                          SvgPicture.asset(  
                                            Assets.clock,  
                                          ),  
                                          const SizedBox(width: 5),  
                                          Text(  
                                            '13 Sep, 2020',  
                                            style: AppTextStyle.body4.copyWith(  
                                              color: AppColor.grey,  
                                            ),  
                                          )  
                                        ],  
                                      ),  
                                      // REVIEWED DATE  
                                    ],  
                                  ),  
                                  const Spacer(),  
                                  Column(  
                                    children: <Widget>[  
                                      Text.rich(  
                                        TextSpan(  
                                          children: <TextSpan>[  
                                            TextSpan(  
                                              text: '4.8',  
                                              style: AppTextStyle.body2,  
                                            ),  
                                            TextSpan(  
                                              text: '  rating',  
                                              style:  
                                                  AppTextStyle.body4.copyWith(  
                                                color: AppColor.grey,  
                                              ),  
                                            ),  
                                          ],  
                                        ),  
                                      ),// my boss is fool  
                                      const SizedBox(height: 5),  
                                      SvgPicture.asset(  
                                          'assets/icons/group_star.svg')  
                                    ],  
                                  ),  
                                ],  
                              ),  
                              const SizedBox(height: 10),  
                              Text(  
                                'Lorem ipsum dolor sit amet, consectetur...',  
                                style: AppTextStyle.body2.copyWith(  
                                  color: AppColor.grey,  
                                ),  
                              )  
                            ],  
                          );  
                        },  
                      ),  
  
                      const SizedBox(height: 20),  
  
                      /// TOTAL Price  
                      Row(  
                        mainAxisAlignment: MainAxisAlignment.spaceBetween,  
                        children: <Widget>[  
                          Column(  
                            crossAxisAlignment: CrossAxisAlignment.start,  
                            children: <Widget>[  
                              Text(  
                                'Total Price',  
                                style: AppTextStyle.body1,  
                              ),  
                              const SizedBox(height: 5),  
                              Text(  
                                'with VAT, SD',  
                                style: AppTextStyle.body4.copyWith(  
                                  color: AppColor.grey,  
                                ),  
                              )  
                            ],  
                          ),  
                          Text(  
                            '\$125',  
                            style: AppTextStyle.body1,  
                          )  
                        ],  
                      ),  
  
                      SizedBox(  
                        height: MediaQuery.of(context).padding.bottom + 96,  
                      )  
                    ],  
                  ),  
                ),  
              ],  
            ),  
          ),  
  
          /// BOTTOM FIXED BUTTON  
          Positioned(  
            bottom: 0,  
            child: MaterialButton(  
              elevation: 0,  
              onPressed: () {},  
              padding: EdgeInsets.only(  
                  bottom: MediaQuery.of(context).padding.bottom),  
              height: 56 + MediaQuery.of(context).padding.bottom,  
              minWidth: MediaQuery.of(context).size.width,  
              color: AppColor.purple,  
              child: Text(  
                'Add to Cart',  
                style: AppTextStyle.body1.copyWith(  
                  color: AppColor.white,  
                ),  
              ),  
            ),  
          )  
        ],  
      ),  
    );  
  }  
}

문제점은 너무 명확합니다. 위의 코드 예시처럼 길게 나열된 선언형 UI 코드는 가독성을 떨어트리고, 가독성이 떨어지니 기능이 추가되거나 오류를 수정할 때도 기존의 긴 코드를 분석하느라 시간이 더 걸리게 됩니다. 또한 화면에서 다루고 있는 위젯이 많아질수록 더 유지보수하기 힘든 형태가 됩니다. 아마 저런 코드에는 상사 욕을 주석으로 달아두어도 눈치를 못 챌 수 있습니다.

반대로 클린한 UI 코드는 보다 더 단순하직접적이어야 합니다. UI의 레이아웃을 파악하기 쉬워야 하며, 코드를 통해 화면의 전체적인 구조를 빠르게 파악할 수 있어야 합니다. 당장 눈앞에 디자인 시안이 없어도 코드만 보고 대략적인 UI 구조를 유추할 수 있을 정도로요.


1. 섹션 정의

이제 단계별로 긴 스파게티 코드를 UI 코드를 리팩토링하는 방법을 간단한 제품 상세 페이지를 예시로 설명드리겠습니다.

먼저 페이지의 각 섹션을 명확하게 정의해야 합니다. 섹션은 일반적으로 레이아웃이나 데이터의 내용으로 구분됩니다. 아래는 제가 정의한 섹션 목록입니다.

  • header
  • leading info
  • image list
  • size info
  • description
  • review
  • price info
  • bottom fixed button (add to cart button)

이러한 각 섹션을 정의함으로써 코드 리팩토링 시에 구조를 명확히 파악할 수 있으며, 각 섹션을 독립적으로 다룰 수 있게 됩니다.


2. Scaffold 모듈 생성

섹션을 구분해 주셨으면 이제 커스텀 Scaffold 모듈을 만들어 줄 차례입니다. 이 모듈은 제품 상세 페이지의 레이아웃 구조를 정의하는 Stateless 클래스입니다. 앞서 정의한 섹션들을 위젯 프로퍼티로 받아 배치하는 역할을 합니다.

class ProductDetailScaffold extends StatelessWidget {  
  const ProductDetailScaffold({  
    Key? key,  
    required this.header,  
    required this.leadingInfoView,  
    required this.imgListView,  
    required this.sizeInfoView,  
    required this.descriptionView,  
    required this.reviewListView,  
    required this.priceInfoView,  
    required this.bottomFixedButton,  
  }) : super(key: key);  
  
  final Widget header;  
  final Widget leadingInfoView;  
  final Widget imgListView;  
  final Widget sizeInfoView;  
  final Widget descriptionView;  
  final Widget reviewListView;  
  final Widget priceInfoView;  
  final Widget bottomFixedButton;  
  
    
  Widget build(BuildContext context) {  
    return Scaffold(  
      backgroundColor: Colors.white,  
      body: Stack(  
        children: [  
          SingleChildScrollView(  
            child: Column(  
              children: <Widget>[  
                header,  
                const SizedBox(height: 15),  
                Padding(  
                  padding: const EdgeInsets.symmetric(horizontal: 20),  
                  child: Column(  
                    crossAxisAlignment: CrossAxisAlignment.start,  
                    children: <Widget>[  
                      leadingInfoView,  
                      const SizedBox(height: 20),  
                      imgListView,  
                      const SizedBox(height: 15),  
                      sizeInfoView,  
                      const SizedBox(height: 20),  
                      descriptionView,  
                      const SizedBox(height: 15),  
                      reviewListView,  
                      const SizedBox(height: 20),  
                      priceInfoView,  
                      SizedBox(  
                        height: MediaQuery.of(context).padding.bottom + 96,  
                      )  
                    ],  
                  ),  
                ),  
              ],  
            ),  
          ),  
  
          /// BOTTOM FIXED BUTTON  
          Positioned(  
            bottom: 0,  
            child: bottomFixedButton,  
          )  
        ],  
      ),  
    );  
  }  
}

여기서 주의해야 할 점은, Scaffold 모듈은 단순히 레이아웃과 프로퍼티로 받는 위젯들의 배치에만 집중해야 한다는 것입니다. 즉, 상태를 가지거나 외부 데이터에 의존하는 위젯이 존재해서는 안 됩니다.

Scaffold 모듈을 만들 때 조그마한 팁을 드리자면, 처음부터 Scaffold 모듈로 만들 필요는 없습니다. 모든 페이지의 UI를 구현한 다음에 별도의 Scaffold 소스 파일을 만들어서 기존 페이지의 코드를 복사하고 수정하면 됩니다.


3. 위젯 모듈화

Scaffold를 적용하기 전에 섹션별로 구분한 위젯들을 각각 별도의 Stateless 위젯으로 추출해주어야 합니다.

/// Statless 위젯으로 추출 (O)
class Header extends StatelessWidget {  
  const Header({Key? key}) : super(key: key);  
  
    
  Widget build(BuildContext context) {  
    return Container(  
      height: 386,  
      decoration: BoxDecoration(  
        image: DecorationImage(  
            image: Image.asset(Assets.imagesProductImg0).image,  
            fit: BoxFit.fitHeight),  
      ),  
    );  
  }  
}

/// 메소드로 추출 (X)
_buildHeader() {
    return Container(  
      height: 386,  
      decoration: BoxDecoration(  
        image: DecorationImage(  
            image: Image.asset(Assets.imagesProductImg0).image,  
            fit: BoxFit.fitHeight),  
      ),  
    ); 
}

이때 섹션을 추출할 때 메소드가 아닌 StatelessWidget으로 추출하는 이유는 BuildContext를 분리해 줄 수 있기 때문입니다. BuildContext를 분리해 주지 않으면 위젯 트리 상에서 다른 위젯과 공유되는 context를 사용하게 되고, 이는 다른 위젯에서 상태 변화로 인해 새롭게 빌드 될 때 상태와 관련이 없는 위젯들도 함께 빌드되는 문제가 발생할 수 있습니다. 위젯의 리빌드를 최소화하기 위해 StatelessWidget을 사용하는 것이 좋습니다.

그리고 분리한 위젯의 클래스명을 짓는 데 있어서 다른 페이지의 위젯과 중복된 이름을 사용하지 않도록 유의해야 합니다. 예를 들어 Header와 같은 영역은 다른 페이지에서도 충분히 섹션으로 정의될 수 있기 때문에 클래스 위젯명을 'Header'라고 짓는 것은 위험합니다. 고유한 이름을 짓기 위해 위젯 이름 앞에 페이지 이름을 붙여줄 수 있습니다.

  • ProductDetailHeader
  • ProductDescriptionView
  • ProductPriceInfoView

하지만 이런 방식으로 네이밍을 해버리면 클래스 이름이 너무 길어진다는 단점이 있습니다. 좀 더 괜찮은 방법은 private 접근 제한자를 이용하는 것입니다.

part of '../product_detail_screen.dart';
  
class _Header extends StatelessWidget {  
  const _Header({Key? key}) : super(key: key);  
  
    
  Widget build(BuildContext context) {  
    return Container(  
      height: 386,  
      decoration: BoxDecoration(  
        color: const Color(0xFFF2F2F2),  
        image: DecorationImage(  
            image: Image.asset(Assets.imagesProductImg0).image,  
            fit: BoxFit.fitHeight),  
      ),  
    );  
  }  
}

접근 제한자를 사용하여 클래스 이름을 지정해 주면 다른 페이지에서 해당 헤더 모듈을 불러오지 못하기 때문에 더 안정적으로 위젯을 모듈화할 수 있게 됩니다.

만약 접근제한로 설정한 위젯을 별도의 소스 파일에서 관리하고 싶다면 part, part of 디렉티브를 사용하여 page위젯과 분리한 섹션 위젯들을 연결해 주면 됩니다.


4. Scaffold 모듈 적용

이제 모든 준비가 끝났습니다. 사전에 작성한 Scaffold 모듈과 분리한 위젯들을 기존 페이지에 선언해 주면 됩니다.

part 'local_widgets/bottom_fixed_button.dart';  
part 'local_widgets/description_view.dart';  
part 'local_widgets/img_list_view.dart';  
part 'local_widgets/leading_info_view.dart';  
part 'local_widgets/price_info_view.dart';  
part 'local_widgets/product_detail_header.dart';  
part 'local_widgets/product_detail_layout.dart';  
part 'local_widgets/review_list_view.dart';  
part 'local_widgets/size_info_view.dart';  
  
class ProductDetailPage extends StatelessWidget {  
  const ProductDetailPage({Key? key}) : super(key: key);  
  
    
  Widget build(BuildContext context) {  
    return const _Scaffold(  
      header: _Header(),  
      leadingInfoView: _LeadingInfoView(),  
      imgListView: _ImgListView(),  
      sizeInfoView: _SizeInfoView(),  
      descriptionView: _DescriptionInfoView(),  
      reviewListView: _ReviewListView(),  
      priceInfoView: _PriceInfoView(),  
      bottomFixedButton: _BottomFixedButton(),  
    );  
  }  
}

코드가 이전보다 훨씬 더 깔끔해졌네요.


클린 UI 코드의 이점

이렇게 리팩토링 된 UI 코드는 크게 두 가지 이점을 가집니다.

유지보수에 용이

일단 변경에 쉽게 대응할 수 있는 코드가 됩니다. 예를 들어 '상품 설명 텍스트의 폰트 사이즈를 변경해 주세요'라는 요청이 들어왔다고 가정해 봅시다. 길게 나열된 스파게티 코드라면 상품 설명 텍스트를 가지고 있는 위젯을 찾는 데 시간이 좀 필요하겠죠. 만약 코드를 작성하지 않은 작업자가 해당 위젯을 찾아야 한다면 시간을 분명 배로 걸립니다. 하지만 앞서 소개한 방식으로 UI를 구조화하여 리팩토링했다면 수정이 필요한 위젯을 간단하게 찾을 수 있게 됩니다.

협업에 유리

이렇게 구조화된 코드는 여러 개발자와 협업을 진행할 때 빛을 발휘합니다. 두 명의 개발자들이 한 페이지의 UI를 함께 구현한다고 가정해 봅시다. 코드를 구조화하지 않고 하나의 소스 파일에서 작업을 하다 보면 나중에 코드를 병합하는 과정에서 conflict가 발생할 수 있습니다.

  
Widget build(BuildContext context) {  
  return const _Scaffold(  
    appBar: _AppBar(),
    contentTabView: _ContentTabView(), // <-- 팀원1 작업공간
    reviewTabView: _ReviewTabView(), // <-- 팀원2 작업공간
  );  
}

하지만 위와 같이 사전에 UI 코드를 구조화하여 작업 공간을 분리한다면, 불필요한 conflict를 사전에 방지할 수 있게 됩니다.


마무리하면서

이번 글에서는 UI 코드를 구조화하여 가독성을 높이고 유지보수협업이 용이한 형태로 구성하는 방법에 대해 알아보았습니다. 소개해 드린 방법이 조금은 귀찮은 작업일 수 있지만 앱의 규모가 크고 다루는 페이지 수가 많을수록 큰 이점을 발휘하니 고려해 보시면 좋을 것 같네요.

글에서 다룬 예제 코드가 궁금하시다면 제 깃허브 레포지토리에서 확인하실 수 있습니다.

읽어주셔서 감사합니다!

profile
https://medium.com/@ximya
post-custom-banner

4개의 댓글

comment-user-thumbnail
2024년 1월 26일

많이 배우고 갑니다. 혹시 포스팅 참조걸고 배운 글을 작성해도 괜찮을까요 ?

1개의 답글