Rx와 Obx에 대한 고찰 : GetX에서 위젯의 rebuild를 줄이는 방법

sumong·2023년 1월 8일
2

요약

한 줄 요약 = Rx도 Obx도 최대한 분리해서 쓰면 rebuild 횟수를 줄일 수 있습니다!

  • Rx 객체는 최대한 상태값별로 따로 분리해야 합니다.
    이 때, 하나의 Rx 객체에서 파생되는 다른 값이 있다면, 이것 또한 따로 분리해서 씁니다.
  • Obx는 최대한 작은 위젯 단위로 분리해서 씁니다.
    (즉, Obx를 parent Widget에 쓰지 말고, 각 child Widget에 달아두어야 한다는 의미입니다.)

코드로 보는 예시

RxList와 연결된 ListView가 있고, 그 아래에 ListView가 isNotEmpty 상태일 때만 클릭 가능한 삭제 버튼이 있다고 가정해봅시다.

// controller 부분
final RxList list = RxList([]);
    
// Widget 부분
Obx(() => Column(
	...
	children: [
		ListView.builder(
			shrinkWrap: true,
			builder: (context, index) => _itemView(list[index]),
			...
		),
		InkWell(
        	onTap: (list.isNotEmpty) ? _deleteLastItem() : () {}
		),
		...
	],
),);

위 코드는 직관적이지만, 성능상 2가지 문제가 있습니다.

  1. 위처럼 list.isNotEmpty를 써서 삭제 버튼을 구현하면, list의 상태가 바뀔 때마다 삭제 버튼도 같이 rebuild되는 문제가 발생합니다. 사실 삭제 버튼은 리스트가 비어 있느냐의 여부가 변할 때만 rebuild되면 되는데, 불필요한 rebuild가 발생하는 것이죠.
  2. 또한 Obx를 parent Widget으로 설정해 두었기 때문에, Column 안에 있는 ListView나 InkWell에 연결된 Rx 객체 중 하나만 값이 바뀌어도 둘 다 rebuild되는 문제도 발생합니다.

이를 방지하려면, 아래처럼 list.isNotEmpty 상태를 저장하는 변수를 따로 만들어두면 됩니다.
(또한, 여기서 ListView와 InkWell 둘 다 다른 Rx 객체를 사용하기 때문에, Obx 또한 나눠서 썼다는 점에도 주목해주세요!)

// controller 부분
final RxList list = RxList([]);
final RxBool listIsNotEmpty = false.obs;

// list의 아이템 갯수가 변할 때마다 listIsNotEmpty를 다시 계산할 수 있도록 listener를 추가한다.
list.listen((_) {
	listIsNotEmpty.value = list.isNotEmpty;
});
    
// Widget 부분
Column(
	...
	children: [
		Obx(() => ListView.builder(
			shrinkWrap: true,
			builder: (context, index) => _itemView(list[index]),
			...
		),),
		Obx(() => InkWell(
        	onTap: (listIsNotEmpty.value) ? _deleteLastItem() : () {}
		),),
		...
	],
);

실험

아래처럼 Page 번호를 표시하고, prev/next 버튼으로 page 번호를 조작할 수 있는 앱을 구현해 보겠습니다.

  • page 번호를 표시하는 Text 위젯(’Page : 1’이라고 쓰여 있는 부분)은 Prev / Next 버튼을 클릭할 때마다 변해야 합니다.
    • Prev 버튼을 누르면 page 번호가 1 감소합니다. 단, page 번호가 1일 때는 Prev 버튼을 누를 수 없습니다.
    • Next 버튼을 누르면 page 번호가 1 증가합니다.
  • Prev 버튼은 page가 1일 때는 회색으로 비활성화되어야 하고, 나머지 값일 때는 노란색으로 활성화되어야 합니다.

😭 Version 1 : Obx 하나, Rx도 하나

class MyHomePage extends StatelessWidget {
  MyHomePage({Key? key}) : super(key: key);

  final RxInt page = 1.obs;

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('GetX Rx/Obx Test'),),
      body: Obx(
        () => Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('Page : ${page.value}'),
            const SizedBox(width: 40),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                MaterialButton(
                  color: (page.value == 1) ? Colors.black12 : Colors.amberAccent,
                  onPressed: () {
                    if(page.value > 1) {
                      page.value--;
                    }
                  },
                  child: const Text('Prev'),
                ),
                const SizedBox(width: 32),
                MaterialButton(
                  color: Colors.blue,
                  onPressed: () {
                    page.value++;
                  },
                  child: const Text('Next'),
                ),
              ],
            ),
          ],
        ),
      )
    );
  }
}

page의 값이 변할 때마다, page에 묶여 있는 두 button과 Text 위젯 전부 동시에 rebuild됩니다.
→ Next 버튼은 rebuild될 필요가 없으므로 리소스 낭비가 발생합니다.

위 이미지를 보면, Prev MaterialButton(위에서 1번째), Next MaterialButton(위에서 3번째), Text 위젯(위에서 2번째)을 하나의 Obx(위에서 4번째)로 묶었기 때문에, 위에서부터 4개의 항목 전부 rebuild 횟수가 같음을 알 수 있습니다. 즉, 위의 설명처럼 page 변수가 변할 때마다 4개 전부가 같이 rebuild되고 있는 상황입니다.

😭 Version 2 : Obx는 위젯별로 분리, Rx는 여전히 하나

class MyHomePage extends StatelessWidget {
  MyHomePage({Key? key}) : super(key: key);

  final RxInt page = 1.obs;

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('GetX Rx/Obx Test'),),
      body: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Obx(() => Text('Page : ${page.value}'),),
          const SizedBox(width: 40),
          Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Obx(() => MaterialButton(
                color: (page.value == 1) ? Colors.black12 : Colors.amberAccent,
                onPressed: () {
                  if(page.value > 1) {
                    page.value--;
                  }
                },
                child: const Text('Prev'),
              ),),
              const SizedBox(width: 32),
              MaterialButton(
                color: Colors.blue,
                onPressed: () {
                  page.value++;
                },
                child: const Text('Next'),
              ),
            ],
          ),
        ],
      ),
    );
  }
}

page의 값이 변할 때마다, Prev 버튼과 Text 위젯만 rebuild됩니다. Next 버튼은 rebuild되지 않습니다.
→ Prev 버튼의 경우, page의 값이 변할 때마다 rebuild될 필요가 없으므로 리소스 낭비가 발생합니다.
(왜냐하면, page 객체가 다른 값에서 1이 될 때나, 반대로 1에서 다른 값으로 변할 때만 rebuild되면 되기 때문입니다. 1이 아닌 다른 값에서 다른 값으로 변할 때는 Prev 버튼의 상태가 바뀔 필요가 없습니다.)

위 이미지를 보면, 이번에는 Next MaterialButton(위에서 5번째)은 Obx로 감싸지 않았기 때문에 딱 2번만 rebuild된 것을 볼 수 있습니다. 반면, Obx로 감쌌던 Prev MaterialButton(위에서 1번째)와 Text 위젯(위에서 3번째)는 이번에도 rebuild 횟수가 같음을 알 수 있습니다. 그 이유는, 위에서 언급한 것처럼 두 Obx 객체가 바라보는 Rx 객체가 page 변수로 똑같기 때문입니다.

😭 Version 3 : Obx는 하나, Rx를 분리

class MyHomePage extends GetView<_MyHomeViewModel> {
  const MyHomePage({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('GetX Rx/Obx Test'),),
      body: Obx(() => Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Text('Page : ${controller.page.value}'),
          const SizedBox(width: 40),
          Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              MaterialButton(
                color: (controller.canGotoPrev.value == false) ? Colors.black12 : Colors.amberAccent,
                onPressed: () {
                  if(controller.canGotoPrev.value) {
                    controller.page.value--;
                  }
                },
                child: const Text('Prev'),
              ),
              const SizedBox(width: 32),
              MaterialButton(
                color: Colors.blue,
                onPressed: () {
                  controller.page.value++;
                },
                child: const Text('Next'),
              ),
            ],
          ),
        ],
      ),),
    );
  }
}

class _MyHomeViewModel extends GetxController {
  final RxInt page = 1.obs;
  late final RxBool canGotoPrev;

  
  void onInit() {
    super.onInit();
    canGotoPrev = (page.value > 1).obs;

    page.listen((_) {
      canGotoPrev.value = page.value > 1;
    });
  }
}

Version 1과 같습니다.
(Obx를 나눠두지 않으면 Obx 하위 위젯 전부가 같이 rebuild됩니다.)

😁 Version 4 : Obx도 Rx도 모두 분리

class MyHomePage extends GetView<_MyHomeViewModel> {
  const MyHomePage({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('GetX Rx/Obx Test'),),
      body: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Obx(() => Text('Page : ${controller.page.value}'),),
          const SizedBox(width: 40),
          Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Obx(() => MaterialButton(
                color: (controller.canGotoPrev.value == false) ? Colors.black12 : Colors.amberAccent,
                onPressed: () {
                  if(controller.canGotoPrev.value) {
                    controller.page.value--;
                  }
                },
                child: const Text('Prev'),
              ),),
              const SizedBox(width: 32),
              MaterialButton(
                color: Colors.blue,
                onPressed: () {
                  controller.page.value++;
                },
                child: const Text('Next'),
              ),
            ],
          ),
        ],
      ),
    );
  }
}

class _MyHomeViewModel extends GetxController {
  final RxInt page = 1.obs;
  late final RxBool canGotoPrev;

  
  void onInit() {
    super.onInit();
    canGotoPrev = (page.value > 1).obs;

    page.listen((_) {
      canGotoPrev.value = page.value > 1;
    });
  }
}
  • page의 값이 변할 때는 Text 위젯만 rebuild됩니다.
  • canGotoPrev의 값이 변할 때는 prev 버튼만 rebuild됩니다.
  • 나머지 위젯은 rebuild되지 않습니다. 즉, 의도대로 작동하고, 리소스 낭비도 없습니다. 🙂

여기서 보이지는 않지만, 이번에는 Next MaterialButton은 Obx로 감싸지 않았기 때문에 딱 1번만 rebuild됬습니다. 그리고, 별도의 Obx(위에서 2번째)로 감쌌던 Prev MaterialButton(위에서 1번째)은 8번 rebuild 된 반면, 다른 Obx(위에서 4번째)로 감쌌던 Text 위젯(위에서 3번째)는 22번 rebuild 되었습니다. 즉, 이번에는 Prev Button과 Text 위젯의 rebuild 횟수가 다릅니다. 이는 page의 값이 변할 때마다 Text 위젯은 rebuild되지만, page의 이전 값과 이후 값 모두 1보다 크다면 canGotoPrev은 변하지 않고, 따라서 Prev Button이 rebuild될 일도 없기 때문입니다.

조금 더 이해하기 쉽게, 경우를 나눠서 설명해 보겠습니다.

  1. page = 1 -> page = 2 (Next 버튼 클릭)
    : canGotoPrev = false -> canGotoPrev = true로 변함.
    - Prev 버튼 : canGotoPrev의 값이 변했으므로 rebuild 발생.
    - Text 위젯 : page의 값이 변했으므로 rebuild 발생.

  2. page = 2 -> page = 3 (Next 버튼 클릭)
    : canGotoPrevtrue 값을 유지함.
    - Prev 버튼 : canGotoPrev의 값이 변하지 않았으므로 rebuild 없음.
    - Text 위젯 : page의 값이 변했으므로 rebuild 발생.

  3. page = 3 -> page = 2 (Prev 버튼 클릭)
    : canGotoPrevtrue 값을 유지함.
    - Prev 버튼 : canGotoPrev의 값이 변하지 않았으므로 rebuild 없음.
    - Text 위젯 : page의 값이 변했으므로 rebuild 발생.

  4. page = 2 -> page = 1 (Prev 버튼 클릭)
    : canGotoPrev = true -> canGotoPrev = false로 변함.
    - Prev 버튼 : canGotoPrev의 값이 변했으므로 rebuild 발생.
    - Text 위젯 : page의 값이 변했으므로 rebuild 발생.

=> rebuild 횟수 정리(최초 build 제외)

  • Next 버튼 : 0회
  • Prev 버튼 : 2회
  • Text 위젯 : 4회

즉 세 개의 위젯 모두 rebuild 횟수가 달라짐을 알 수 있습니다.

profile
Flutter 메인의 풀스택 개발자 / 한양대 컴퓨터소프트웨어학과, HUHS의 화석

0개의 댓글