리스트·키·성능의 기본
리스트·키·성능의 기본
요약: 긴 목록은 빌더로 그리고, 키로 아이템 정체성을 지키면 스크롤이 버틴다.
1. 왜 / 언제
피드·검색 결과·채팅처럼 아이템이 많으면 Column + map은 한 번에 모든 자식을 만든다. 스크롤이 버벅이고 메모리만 는다.
ListView.builder는 화면에 보이는 근처만 위젯으로 만든다. 이것이 기본이다.
같은 타입 행이 재배열되면 또 다른 문제가 생긴다. 입력 중이던 글자가 다른 행으로 옮겨 간다. 원인은 레이아웃이 아니다. Flutter가 누가 같은 위젯인지를 자리로만 맞춰서다. 그때 키가 필요하다.
2. 핵심
키는 위젯의 이름표다.
Flutter는 리빌드할 때 이전 트리와 새 트리를 맞춘다. 기본 규칙은 타입과 위치다. 같은 자리의 ListTile은 같은 것으로 본다. 목록이 섞이면 3번 자리의 State가 새 3번 아이템에 붙는다.
키를 주면 타입+키로 맞춘다. 서버 id처럼 안 바뀌는 값을 ValueKey로 붙인다. 인덱스를 키로 쓰면 재배열과 함께 키가 바뀌므로 의미가 없다.
| 키 | 언제 |
|---|---|
ValueKey(id) |
리스트 아이템, 폼 필드. 값이 안정적이어야 한다. |
ObjectKey(obj) |
동일 인스턴스로 구분할 때. |
UniqueKey() |
매번 새 위젯으로 강제해 상태를 리셋할 때. |
GlobalKey |
다른 서브트리에서 State에 접근할 때. 남용하면 비싸다. |
빌더 리스트는 itemBuilder가 index마다 위젯을 돌려준다. 화면에 안 보이면 만들지 않는다. 타일은 가볍게 둔다. 무거운 차트·큰 이미지를 행 안에 중첩하지 않는다.
상위 setState가 리스트 전체를 다시 돌리지 않게 행을 Stateless로 분리한다. 변하지 않는 서브트리는 const다.
네트워크 이미지는 크기와 캐시를 지정한다. 원본 해상도를 스크롤에 그대로 넣지 않는다.
3. 예제
빌더 + 안정적 키
class Post {
const Post({required this.id, required this.title});
final String id;
final String title;
}
class PostList extends StatelessWidget {
const PostList({super.key, required this.items});
final List<Post> items;
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: items.length,
itemBuilder: (BuildContext context, int index) {
final Post item = items[index];
return PostTile(
key: ValueKey<String>(item.id),
post: item,
);
},
);
}
}
class PostTile extends StatelessWidget {
const PostTile({super.key, required this.post});
final Post post;
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(post.title),
);
}
}
key는 리스트의 직접 자식에 둔다. ListTile 안쪽이 아니라 PostTile에 붙인다. Flutter가 맞추는 대상은 부모 children의 그 자리다.
아이템이 매우 많으면 ListView.separated로 구분선을 빌더에 맡긴다. Column에 Divider를 미리 다 만들지 않는다.
키가 없을 때 깨지는 입력
class NamedField extends StatefulWidget {
const NamedField({super.key, required this.label});
final String label;
@override
State<NamedField> createState() => _NamedFieldState();
}
class _NamedFieldState extends State<NamedField> {
final TextEditingController controller = TextEditingController();
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return TextField(
controller: controller,
decoration: InputDecoration(labelText: widget.label),
);
}
}
이 필드를 리스트에서 순서를 바꾸면, 키가 없을 때 컨트롤러가 잘못된 행에 남는다. 각 행에 ValueKey(label) 또는 항목 id를 준다.
스크롤 성능
class AvatarTile extends StatelessWidget {
const AvatarTile({super.key, required this.url, required this.title});
final String url;
final String title;
@override
Widget build(BuildContext context) {
return ListTile(
leading: Image.network(
url,
width: 40,
height: 40,
fit: BoxFit.cover,
cacheWidth: 80,
),
title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis),
);
}
}
cacheWidth는 디코드 크기를 제한한다. 디스플레이 픽셀을 고려해 논리 크기의 2배 정도가 흔하다.
고정 높이 행이면 itemExtent를 준다. 스크롤 오프셋 계산이 싸진다.
ListView.builder(
itemExtent: 56,
itemCount: items.length,
itemBuilder: (BuildContext context, int index) {
return PostTile(
key: ValueKey<String>(items[index].id),
post: items[index],
);
},
);
RepaintBoundary는 측정 후에 쓴다. 추측으로 모든 행에 씌우지 않는다. DevTools Performance와 rebuild 하이라이트로 먼저 본다.
4. 흔한 실수
| 실수 | 결과 | 대안 |
|---|---|---|
Column + map으로 긴 목록 |
한 번에 전부 빌드 | ListView.builder |
key: ValueKey(index) |
재배열 때 상태 꼬임 | 안정적 id |
| 행을 화면 State 안에 인라인 | 상위 setState가 전부 리빌드 | 타일 Stateless 분리 |
| 원본 이미지를 행에 그대로 | 스크롤 드롭 | 크기·캐시 지정 |
shrinkWrap 기본값화 |
가상화 포기 | 부모가 유한 높이 |
GlobalKey로 행을 식별하지 않는다. 리스트 길이만큼 전역 키가 생기면 비용이 크다. ValueKey면 충분하다.
Sliver는 커스텀 스크롤(헤더 고정, 그리드 혼합)이 필요할 때 쓴다. 일반 목록은 ListView.builder로 시작한다.
정리
리스트 성능은 마법이 아니다. 빌더 + 키 + 작은 타일의 합이다. 키는 자리 대신 정체성을 알려 주는 이름표다.
연습
- 200개 항목을
Column과ListView.builder로 각각 그려 스크롤을 비교한다. - 입력 필드 리스트를 재배열하고, id 키 전후에 텍스트가 따라오는지 확인한다.
- 타일을 Stateless로 분리하고
const생성자를 붙인다. - 네트워크 이미지에
width/height/cacheWidth를 지정한다.
댓글
0아직 댓글이 없습니다. 첫 댓글을 남겨보세요.