한 줄 요약 = Rx도 Obx도 최대한 분리해서 쓰면 rebuild 횟수를 줄일 수 있습니다!
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가지 문제가 있습니다.
list.isNotEmpty
를 써서 삭제 버튼을 구현하면, list의 상태가 바뀔 때마다 삭제 버튼도 같이 rebuild되는 문제가 발생합니다. 사실 삭제 버튼은 리스트가 비어 있느냐의 여부가 변할 때만 rebuild되면 되는데, 불필요한 rebuild가 발생하는 것이죠.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일 때는 회색으로 비활성화되어야 하고, 나머지 값일 때는 노란색으로 활성화되어야 합니다.
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되고 있는 상황입니다.
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
변수로 똑같기 때문입니다.
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됩니다.)
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될 일도 없기 때문입니다.
조금 더 이해하기 쉽게, 경우를 나눠서 설명해 보겠습니다.
page = 1
-> page = 2
(Next 버튼 클릭)
: canGotoPrev = false
-> canGotoPrev = true
로 변함.
- Prev 버튼 : canGotoPrev
의 값이 변했으므로 rebuild 발생.
- Text 위젯 : page
의 값이 변했으므로 rebuild 발생.
page = 2
-> page = 3
(Next 버튼 클릭)
: canGotoPrev
는 true
값을 유지함.
- Prev 버튼 : canGotoPrev
의 값이 변하지 않았으므로 rebuild 없음.
- Text 위젯 : page
의 값이 변했으므로 rebuild 발생.
page = 3
-> page = 2
(Prev 버튼 클릭)
: canGotoPrev
는 true
값을 유지함.
- Prev 버튼 : canGotoPrev
의 값이 변하지 않았으므로 rebuild 없음.
- Text 위젯 : page
의 값이 변했으므로 rebuild 발생.
page = 2
-> page = 1
(Prev 버튼 클릭)
: canGotoPrev = true
-> canGotoPrev = false
로 변함.
- Prev 버튼 : canGotoPrev
의 값이 변했으므로 rebuild 발생.
- Text 위젯 : page
의 값이 변했으므로 rebuild 발생.
=> rebuild 횟수 정리(최초 build 제외)
즉 세 개의 위젯 모두 rebuild 횟수가 달라짐을 알 수 있습니다.