[flutter] 빌드 최소화하여 화면 간 데이터 일치시키기 (with Navigator, provider, bloc, stream)

플랙트·2021년 3월 21일
0
post-thumbnail

1. 개요 - 무엇을 말하려 하는가
위 앱은 목록페이지와 상세페이지 사이의 데이터 일치시키는 예제이다. 목록(ListVew)에서 특정 항목을 눌러 상세 페이지로 진입 후, 상세 페이지에서 데이터가 변경되는 경우가 있다. 이 경우 이전 화면의 항목 페이지에도 해당 내용이 반영되어야 한다.

2. 응용 - 실제 앱에서
이러한 패턴은 많은 앱에서 나타난다. 예를 들어, 게시글 목록에서 사용자가 상세페이지로 진입한 후 좋아요를 누르거나 댓글을 다는 경우가 있다. 사용자가 좋아요를 누르면 상세페이지의 좋아요 횟수가 증가한다. 여기서 아무 처리를 하지 않으면, 뒤로가기를 눌렀을때 목록(ListView) 페이지에서 반영이 되지 않는다. 이러면 내 반응이 실시간으로 반영된다는 느낌을 받기 어렵다.

3. 주의해야할 방법
유저가 뒤로가기를 눌렀을 때 좋아요 갯수를 받아오는 api를 호출하면 어떻게 될까? 이 방법은 주의가 필요하다. api 요청을 한번더 하게 되기 때문이다. api 요청 횟수가 많아지면 해당 요청을 처리하는 서버에 부담이 생길 수 있다. 클라이언트 입장에서는 새로 요청한 api의 응답을 화면에 다시 그려줘야 하는 부담이 생긴다. 만약 목적이 실시간으로 좋아요 수를 받아오거나 하는 것이라면 웹 소켓등 다른 기술을 생각해 보는 것도 좋다.

3. Naive Solution(간단한 해결책) - Navigator의 반환값으로 데이터 넘기기

ListView.builder(
        itemCount: colorList.length,
        itemBuilder: (context, index) {
          return GestureDetector(
            onTap: () async {
              //컬러 디테일 페이지 화면을 띄웁니다.
              ColorModel newColor = await Navigator.of(context).push(MaterialPageRoute(
                  builder: (context) =>
                      ColorDetailScreen(colorModel: colorList[index])));

              //반환받은 새 색상을 저장하고, ListView를 rebuild 합니다.
              setState(() {
                colorList[index] = newColor;
              });
            },
            // 해당 리스트의 생상과 id를 나타냅니다.
            child: Container(
                height: 50.0,
                color: colorList[index].color,
                child: Center(
                    child: Text("id: ${colorList[index].id} - ${colorList[index].color.toString()}",
                        style: TextStyle(
                          color: Colors.black,
                          fontSize: 20,
                        )))),
          );
        },
      ),

위 코드는 Navigator에서 newColor를 받아 setState를 이용해 다시 그려주고 있다. 이 방법은 가장 간단하다. 많은 경우, 이렇게 상세페이지와 목록의 데이터를 일치시켜도 상관없다. 하지만 같은 데이터가 여러 다른 페이지에서 반복적으로 나타난다면 어떨까? 위에서 예시로 제시한 앱에서는 listView와 gridView가 같은 데이터를 표시하고 있다. girdView에서 상세페이지로 진입했다가 데이터 변경 후 뒤로가기를 했을 때, gridView는 업데이트가 가능하지만, listView를 같은 방법으로 업데이트 하는 것은 쉽지 않다. gridView로부터 color를 받는다고 가정하면 grieView의 몇 번째 index에 변경이 발생했는지까지 같이 넘겨줘야한다. 위 앱은 화면이 2개이지만, 비슷한 화면이 수십개라면 어떻게 해야 할까? 거대한 반환값의 연쇄(chain)가 만들어질 것이다. 이렇게 되면 유지보수가 힘들어진다.

4. Advanced Solution(더 나은 해결책) - Provider로 앱 전역 상태 관리하기

class AppState extends ChangeNotifier {
  List<ColorModel> _colorList = [];

  set colorList (value) {
    _colorList = value;
    notifyListeners();
  }

  get colorList => _colorList;

  setColor(int index, Color color) {
    _colorList[index].color = color;
    notifyListeners();
  }
}
GestureDetector(
    onTap: () {
   // * 버튼 클릭 시 새로운 랜덤 컬러를 정합니다.
     final newColor =
       Color((math.Random().nextDouble() * 0xFFFFFF).toInt())
          .withOpacity(1.0);

     appState.setColor(index, newColor);
  },

더 나은 방법은 위 코드 처럼 Provider로 colorList를 관리하는 방법이다.
colorList가 변경될 경우, notifyListner()를 이용해 해당 Provider를 참조(사용)하고 있는 화면(View)을 업데이트 할 수있다.

다만 위 방법도 문제가 있다. 모든 항목(ListItem)에서 Provider를 참조(사용)하고 있다. 따라서 notifyListers()를 호출하는 순간, 모든 항목에서 빌드가 일어난다. 만약 현재 화면에 보이는 항목의 갯수가 10개라면, 10번의 빌드가 발생한다. build는 최소화하는 것이 앱 성능에 도움이 된다.

또한 데이터가 여러개의 Provider에서 사용되고 있는 경우, 일일이 각각의 Provider를 업데이트 해주어야 한다.

5. Consumer, Selector 이용해서 build 최적화하는 방법

FDK eau님이 추천해주신 방법이다. 이방법은 아직 사용해 보지 않았다.

6. Optimal solution (내가 생각할 때의 최적의 해결책) - Bloc을 이용하여 반응형으로 구성하자 (feat.가오나시님 in Flutter Developers Korea)

아래는 내 아이디어가 아니라 flutter developers korea라는 카카오톡 오픈채팅방에서 듣게 된 가오나시님의 방법이다.

import 'package:rxdart/rxdart.dart';

enum CRUDType{
  ADD,
  DELETE,
  MODIFY
}

class CRUDEvent {
  final CRUDType type;
  final dynamic data;

  CRUDEvent(this.type, this.data);
}

BehaviorSubject<CRUDEvent> crudEventStream = BehaviorSubject<CRUDEvent>();

함수의 전역 공간에 위와 같은 CRUDEvent를 정의하고 해당 Event를 사용하는 rx 객체를 선언하자.

 setColor(ColorModel color) {
    _colorList[color.id].color = color.color;

    crudEventStream.add(CRUDEvent(
        CRUDType.MODIFY,
        ColorModel(
            id: _colorList[color.id].id, color: _colorList[color.id].color)));
  }
 

color를 변경하는 곳에서는 위와 같이 rx 객체에 add를 해준다.

ListView.builder(
       itemCount: appState.colorList.length,
       itemBuilder: (context, index) {
         print("[build] listview ${index}");
         return ColorListItem(
             appState: appState, color: appState.colorList[index]);
       },

목록화면(ListView)에서는 항목(Item)객체를 따로 위젯으로 분리한다. 이는 해당 아이템 내부에서 호출하는 setState가 전체 위젯에 영향을 주지 않도록 하기 위함이다.


class _ColorListItemState extends State<ColorListItem> {
 ColorModel color;

 _ColorListItemState(this.color);

 
 void initState() {
   super.initState();
   crudEventStream.listen((crudEvent) {
     if ((crudEvent.data is ColorModel) &&
         (crudEvent.data.id == color.id) &&
         (crudEvent.type == CRUDType.MODIFY)) {
       if(mounted == true) {
         setState(() {
           color = crudEvent.data;
         });
       }
     }
   });
 }

 
 Widget build(BuildContext context) {
	 // ... 생략
 }
}

listItem 내부에서는 위와 같이 initState에서 event를 Listen하면서 colorModel의 id가 일치할때만 setState()를 호출한다. 이렇게 하면 빌드를 최대한 줄일 수 있다. 내가 이 방법이 가장 적합하다고 생각하는 이유는 바로 이점에 있다. 또한 이 개념을 확장하면 event의 stream을 처리해 output의 stream을 결과로 내놓는 bloc의 개념과 비슷해진다. (이 경우는 event와 state가 일치하는 특수한 bloc으로 생각 할 수 있다.)

6. 결론 - 상황에 맞는 적절한 방법을 택하자
naive 솔루션이라고 할지라도 데이터가 사용되는 곳이 해당 페이지로 제한적이거나 당장 급하게 앱을 업데이트해야할 때는 더없이 좋은 방법이다. 이런 경우에는 todo 주석을 달아놓고 추후 변경을 해야함을 알려도 된다.

provider를 이용한 방법의 경우 편하게 코딩할 수 있고, 많은 사람들이 사용하는 방식이다. 간단한 앱의 경우 rebuild를 통한 성능 감소를 유저가 체험하기 쉽지 않으므로 이 방식도 훌륭한 방법이 될 수 있다.

다만, rebuild를 최소한으로 줄여야하고 앱의 규모가 너무 커서 유지보수하기가 쉽지 않다면 bloc이 최선의 선택일 것이다. 마치 kafka를 통해서 여러 서비스들이 소통을 하듯이 bloc의 CRUDEventStream을 통해 앱의 여러 위젯들이 서로 통신을 하는 것이다. 각 위젯들은 나에게 오는 메시지가 아닌 것은 버리고 나에게 오는 메시지인 것만 취하면 된다. 마치 네트워크에서 broadcast 통신을 하는 것처럼 말이다.

profile
유용한 서비스에 관심이 많은 flutter 개발자입니다.

0개의 댓글