meals_clone 앱에 BottomNavigationBar를 붙이면서 탭 화면 위젯을 State의 필드로 미리 만들어 두었다. 탭을 누르면 _selectPage에서 그중 하나를 activePage에 넣는 식이었다. 동작은 했지만 쓰지도 않는 화면까지 미리 만들어 두는 게 메모리 낭비처럼 보였다.
그래서 build 안에서 선택된 인덱스를 보고 위젯을 고르도록 고쳤다. 고치고 나니 탭이 여러 개가 되면 어떤 방식이 맞는지, 방식마다 메모리는 어떻게 쓰이는지가 궁금해졌다. 찾아본 내용까지 같이 정리한다.
선택된 탭을 두 군데에 들고 있던 코드
기존 코드다. _selectedPageIndex와 activePage가 같은 정보를 따로 들고 있다.
import 'package:flutter/material.dart';
import 'package:meals_clone/screens/categories.dart';
// import 'package:riverpod/riverpod.dart';
class TabScreen extends StatefulWidget {
const TabScreen({super.key});
@override
State<TabScreen> createState() => _TabScreenState();
}
class _TabScreenState extends State<TabScreen> {
int _selectedPageIndex = 0;
// ❌ 탭 화면을 필드로 미리 만들어 두고, 선택된 화면을 activePage에 따로 저장한다
Widget categoriesScreen = const CategoriesScreen();
Widget placeholder = const Placeholder();
Widget activePage = const CategoriesScreen();
void _selectPage(int index){
setState(() {
_selectedPageIndex = index;
});
// setState 바깥에서 activePage를 바꾼다
if(_selectedPageIndex == 0){
activePage = categoriesScreen;
}
else if(_selectedPageIndex == 1){
activePage = placeholder;
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Categories'),
centerTitle: true,
),
// body: const CategoriesScreen(),
body: activePage,
bottomNavigationBar: BottomNavigationBar(
items: const [
BottomNavigationBarItem(
icon: Icon(
Icons.set_meal,
size: 30,
),
label: '카테고리'),
BottomNavigationBarItem(
icon: Icon(
Icons.star,
size: 30,
),
label: '즐겨찾기')
],
showSelectedLabels: false, // 선택된 레이블 숨기기
showUnselectedLabels: false, // 선택되지 않은 레이블 숨기기
currentIndex: _selectedPageIndex,
onTap: _selectPage,
),
);
}
}
activePage 대입이 setState 콜백 바깥에 있어도 화면은 제대로 바뀐다. setState는 다음 프레임에 다시 그리도록 예약만 하고, 실제 build는 대입이 끝난 뒤에 돌기 때문이다. 다만 순서에 기대는 코드라서 인덱스만 바꾸고 activePage를 빠뜨리는 식의 실수가 끼어들 여지가 있다.
build에서 인덱스로 고르게 바꾸기
수정한 코드는 activePage 필드를 없애고, build가 돌 때마다 _selectedPageIndex를 보고 위젯을 고른다.
import 'package:flutter/material.dart';
import 'package:meals_clone/screens/categories.dart';
// import 'package:riverpod/riverpod.dart';
class TabScreen extends StatefulWidget {
const TabScreen({super.key});
@override
State<TabScreen> createState() => _TabScreenState();
}
class _TabScreenState extends State<TabScreen> {
int _selectedPageIndex = 0;
void _selectPage(int index) {
setState(() {
_selectedPageIndex = index;
});
}
@override
Widget build(BuildContext context) {
// ✓ 현재 선택된 인덱스에 따라 build 안에서 위젯을 고른다
Widget activePage = _selectedPageIndex == 0
? const CategoriesScreen()
: const Placeholder();
return Scaffold(
appBar: AppBar(
title: const Text('Categories'),
centerTitle: true,
),
body: activePage,
bottomNavigationBar: BottomNavigationBar(
items: const [
BottomNavigationBarItem(
icon: Icon(
Icons.set_meal,
size: 30,
),
label: '카테고리'),
BottomNavigationBarItem(
icon: Icon(
Icons.star,
size: 30,
),
label: '즐겨찾기')
],
showSelectedLabels: false, // 선택된 레이블 숨기기
showUnselectedLabels: false, // 선택되지 않은 레이블 숨기기
currentIndex: _selectedPageIndex,
onTap: _selectPage,
),
);
}
}
바뀐 점은 두 가지다.
categoriesScreen과placeholder를 필드로 미리 만들어 두지 않는다. 필요할 때 build에서 만든다._selectPage는 인덱스만 바꾸고, 어떤 페이지를 보여줄지는 build가_selectedPageIndex를 보고 정한다. 선택된 탭이라는 상태가 인덱스 하나로 줄었다.
메모리 차이는 생각보다 작다
처음에는 1번을 메모리를 줄이는 수정으로 봤다. 다시 따져 보면 이 코드에서 메모리 차이는 거의 없다.
Flutter에서 Widget은 화면의 설정값을 담은 가벼운 불변 객체다. 실제로 메모리를 차지하고 상태를 들고 있는 쪽은 Widget이 트리에 붙을 때 만들어지는 Element와 State, 그리고 RenderObject다. 기존 코드도 body에 들어가는 건 activePage 하나라서 트리에 붙어 있는 탭 화면은 처음부터 하나뿐이었다. 게다가 const CategoriesScreen()은 컴파일 타임 상수다. 필드에서 만들든 build에서 만들든 같은 인스턴스 하나를 가리킨다.
그래서 이 수정의 실제 효과는 메모리보다 상태를 한곳에 모은 데 있다. 메모리를 좌우하는 건 Widget 객체를 언제 만드느냐가 아니라, 몇 개의 탭이 동시에 트리에 붙어 있느냐다. 아래 세 방식도 이 기준으로 보면 차이가 선명해진다.
탭이 여러 개일 때의 세 가지 방식
1. List<Widget>에 담아 두고 꺼내 쓰기
탭에 대응하는 위젯을 List에 미리 담아 두고, 선택된 인덱스의 위젯을 body에 넣는다.
class MyTabScreen extends StatefulWidget {
@override
_MyTabScreenState createState() => _MyTabScreenState();
}
class _MyTabScreenState extends State<MyTabScreen> {
int _selectedIndex = 0;
final List<Widget> _tabs = [
WidgetA(), // 탭 A에 대응하는 위젯
WidgetB(), // 탭 B에 대응하는 위젯
WidgetC(), // 탭 C에 대응하는 위젯
];
@override
Widget build(BuildContext context) {
return Scaffold(
body: _tabs[_selectedIndex], // 현재 선택된 탭의 위젯을 렌더링
bottomNavigationBar: BottomNavigationBar(
currentIndex: _selectedIndex,
onTap: (index) => setState(() => _selectedIndex = index),
// BottomNavigationBarItems 정의
),
);
}
}
탭 목록을 한 곳에서 정의하니 초기 코드 구성이 단순하다.
Widget 객체는 미리 만들어 두지만, 트리에 붙는 건 선택된 탭 하나다. 미리 만든 Widget 객체만큼 메모리를 쓰긴 해도 앞 절에서 본 대로 그 몫은 작다.
탭 상태는 유지되지 않는다. 탭을 바꾸면 이전 탭의 Element와 State가 트리에서 떨어져 나가고, 돌아오면 같은 Widget 객체로 State를 새로 만든다. 스크롤 위치 같은 상태는 초기화된다.
2. IndexedStack으로 전부 붙여 두기
IndexedStack은 모든 자식을 트리에 붙여 두고, 그중 index에 해당하는 하나만 화면에 보여준다.
class MyTabScreen extends StatefulWidget {
@override
_MyTabScreenState createState() => _MyTabScreenState();
}
class _MyTabScreenState extends State<MyTabScreen> {
int _selectedIndex = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
body: IndexedStack(
index: _selectedIndex,
children: [
WidgetA(),
WidgetB(),
WidgetC(),
],
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: _selectedIndex,
onTap: (index) => setState(() => _selectedIndex = index),
// ... BottomNavigationBarItems ...
),
);
}
}
탭 상태가 유지된다. 사용자가 스크롤한 위치가 그대로 남는다.
이미 트리에 붙어 있는 화면을 보여주기만 하니 탭 전환이 빠르다.
대신 첫 화면이 뜰 때 모든 탭이 한꺼번에 빌드되고, 그 뒤로도 계속 트리에 남는다. 세 방식 중 메모리를 가장 많이 쓰는 건 이 방식이다.
3. 필요할 때 만들기 (Lazy Instantiation)
탭이 선택될 때 그 탭의 위젯을 만든다. 앞에서 수정한 TabScreen 코드가 이 방식이다.
class MyTabScreen extends StatefulWidget {
@override
_MyTabScreenState createState() => _MyTabScreenState();
}
class _MyTabScreenState extends State<MyTabScreen> {
int _selectedIndex = 0;
Widget _buildTab(int index) {
switch (index) {
case 0:
return WidgetA();
case 1:
return WidgetB();
case 2:
return WidgetC();
default:
return Placeholder();
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: _buildTab(_selectedIndex),
bottomNavigationBar: BottomNavigationBar(
currentIndex: _selectedIndex,
onTap: (index) => setState(() => _selectedIndex = index),
// ... BottomNavigationBarItems ...
),
);
}
}
사용하지 않는 탭은 Widget 객체조차 만들지 않는다. 트리에 붙는 것도 선택된 탭 하나다.
탭을 바꿀 때마다 새 Widget 인스턴스를 만든다.
1번과 마찬가지로 탭 상태는 유지되지 않는다.
세 방식을 한 표로 모으면 이렇다.
| 구분 | List<Widget> | IndexedStack | 지연 인스턴스화 |
|---|---|---|---|
| Widget 객체 생성 시점 | State 생성 시 한 번 | build마다 전부 | 탭이 선택될 때 |
| 트리에 붙어 있는 탭 | 선택된 탭 하나 | 전부 | 선택된 탭 하나 |
| 탭 상태 유지 | 안 됨 | 됨 | 안 됨 |
1번과 3번은 Widget 객체를 언제 만드느냐만 다르고, 메모리와 상태 유지 면에서는 거의 같다. 실제로 고를 때 기준이 되는 건 탭 상태를 유지해야 하느냐다. 유지해야 하면 IndexedStack, 아니면 3번처럼 build에서 고르는 쪽이 코드도 짧고 메모리도 적게 쓴다.