플러터(Flutter)는 구글이 개발한 오픈소스 UI 툴킷으로, 단일 코드베이스를 통해 모바일, 웹, 데스크톱 등 다양한 플랫폼에서 미려하고 빠른 네이티브 애플리케이션을 구축할 수 있게 해줍니다. 플러터의 핵심 철학인 '모든 것은 위젯이다(Everything is a Widget)'는 개발자에게 놀라운 유연성을 제공하지만, 동시에 성능과 유지보수성을 고려하지 않은 UI 설계는 애플리케이션의 품질을 저하시키는 주요 원인이 될 수 있습니다. 성능 저하, 즉 '쟁크(jank)'는 사용자 경험에 치명적이며, 복잡하게 얽힌 코드는 협업을 저해하고 새로운 기능을 추가하는 것을 어렵게 만듭니다.
이 글에서는 플러터 UI를 단순히 '작성'하는 것을 넘어, '최적화'하는 방법에 대해 깊이 있게 탐구합니다. 우리는 플러터의 선언적 UI 패러다임이 어떻게 동작하는지 이해하고, 이를 바탕으로 코드 재사용성, 성능, 유지보수성, 그리고 가독성을 극대화하는 구체적인 전략과 기법들을 살펴볼 것입니다. 상태 관리의 근본적인 문제부터 위젯 트리의 효율적인 구성, 반응형 레이아웃 설계, 그리고 렌더링 파이프라인 최적화에 이르기까지, 견고하고 빠른 플러터 애플리케이션을 구축하기 위한 핵심 원칙들을 상세한 예제와 함께 제시합니다.
1. 상태 관리: UI의 심장을 설계하다
상태(State)는 특정 시점의 애플리케이션 데이터를 의미하며, 이 상태가 변경될 때 UI가 어떻게 반응할지를 결정하는 것이 상태 관리의 핵심입니다. 플러터에서 가장 흔하게 발생하는 성능 문제 중 상당수는 비효율적인 상태 관리 방식에서 비롯됩니다. 상태 변경이 필요 이상으로 넓은 범위의 위젯 트리를 재빌드(rebuild)하게 만들면, 이는 곧바로 프레임 드롭으로 이어집니다.
1.1 기본 상태 관리: `StatefulWidget`과 `setState()`의 명과 암
플러터 입문 과정에서 가장 먼저 배우는 상태 관리 기법은 `StatefulWidget`과 `setState()` 메서드를 사용하는 것입니다. 이는 위젯 자체의 지역적인 상태(ephemeral state)를 관리하는 데 매우 효과적이고 직관적입니다.
class CounterWidget extends StatefulWidget {
const CounterWidget({super.key});
@override
State<CounterWidget> createState() => _CounterWidgetState();
}
class _CounterWidgetState extends State<CounterWidget> {
int _counter = 0;
void _increment() {
setState(() {
// 이 호출은 Flutter 프레임워크에 이 State 객체의 내부 상태가 변경되었음을 알리고,
// build 메서드를 다시 실행하여 UI를 업데이트하도록 요청합니다.
_counter++;
});
}
@override
Widget build(BuildContext context) {
// setState가 호출될 때마다 이 build 메서드 전체가 다시 실행됩니다.
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text('You have pushed the button this many times:'),
Text(
'$_counter',
style: Theme.of(context).textTheme.headlineMedium,
),
ElevatedButton(
onPressed: _increment,
child: const Icon(Icons.add),
)
],
);
}
}
위 예제는 완벽하게 동작하지만, 애플리케이션이 복잡해지면 `setState()`의 한계가 드러납니다. 만약 여러 위젯이 공유해야 하는 앱 상태(app state)를 최상위 위젯에서 `setState()`로 관리한다면 어떨까요? 상태를 전달하기 위해 위젯 생성자를 통해 데이터를 계속해서 내려보내는 'prop drilling' 현상이 발생하며, 최상위 위젯에서 `setState()`가 호출될 때마다 그 아래의 모든 자식 위젯들이 불필요하게 재빌드될 수 있습니다. 이는 심각한 성능 저하의 원인이 됩니다.
1.2 진보된 상태 관리: 왜 필요한가?
효율적인 상태 관리는 다음과 같은 목표를 달성하기 위해 필수적입니다.
- 관심사의 분리 (Separation of Concerns): UI 로직과 비즈니스 로직을 분리하여 코드를 더 깔끔하고 테스트하기 쉽게 만듭니다.
- 최소 범위의 재빌드: 상태 변경 시, 변경이 필요한 최소한의 위젯만 재빌드하여 성능을 최적화합니다.
- 예측 가능한 상태 변화: 상태가 변경되는 흐름을 명확하게 하여 디버깅을 용이하게 합니다.
- 코드의 재사용성 및 확장성: 상태 관리 로직을 모듈화하여 여러 곳에서 재사용하고, 새로운 기능 추가에 유연하게 대응할 수 있습니다.
이를 위해 플러터 생태계에는 Provider, BLoC, Riverpod, GetX 등 다양한 상태 관리 솔루션이 존재합니다.
1.3 Provider: 간결하고 효율적인 의존성 주입
Provider는 `InheritedWidget`을 기반으로 한 상태 관리 패키지로, 의존성 주입(Dependency Injection)과 상태 관리를 매우 간결한 방식으로 처리할 수 있게 해줍니다. 특히 상태 변화를 수신(listen)할 범위를 정밀하게 제어하여 불필요한 재빌드를 막는 데 탁월합니다.
먼저 `ChangeNotifier`를 사용하여 상태를 관리하는 모델 클래스를 만듭니다.
import 'package:flutter/foundation.dart';
// ChangeNotifier는 리스너에게 변경 알림을 제공하는 간단한 클래스입니다.
class CounterModel with ChangeNotifier {
int _count = 0;
int get count => _count;
void increment() {
_count++;
// 상태가 변경되었음을 모든 리스너에게 알립니다.
notifyListeners();
}
}
다음으로, `ChangeNotifierProvider`를 사용하여 위젯 트리의 상단에 `CounterModel` 인스턴스를 제공합니다.
import 'package:provider/provider.dart';
void main() {
runApp(
// CounterModel의 인스턴스를 생성하고, 자식 위젯들에게 제공합니다.
ChangeNotifierProvider(
create: (context) => CounterModel(),
child: const MyApp(),
),
);
}
이제 하위 위젯에서는 `Provider.of` 또는 `Consumer` 위젯을 사용하여 상태에 접근하고 UI를 업데이트할 수 있습니다. 여기서 중요한 최적화 기법이 등장합니다.
class CounterApp extends StatelessWidget {
const CounterApp({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Provider Example')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: const <Widget>[
Text('You have pushed the button this many times:'),
// 이 위젯은 카운터 값이 변경될 때마다 재빌드되어야 합니다.
CounterDisplay(),
],
),
),
floatingActionButton: const CounterButton(),
);
}
}
// Consumer 위젯을 사용하여 상태 변경을 감지하고 UI를 업데이트합니다.
class CounterDisplay extends StatelessWidget {
const CounterDisplay({super.key});
@override
Widget build(BuildContext context) {
// Consumer는 특정 타입의 Provider를 찾아 그 값을 builder 함수에 전달합니다.
// CounterModel이 notifyListeners()를 호출하면 이 builder 함수만 재실행됩니다.
return Consumer<CounterModel>(
builder: (context, counter, child) {
return Text(
'${counter.count}',
style: Theme.of(context).textTheme.headlineMedium,
);
},
);
}
}
// 이 버튼은 상태를 변경시키기만 할 뿐, 상태 변화에 따라 UI가 바뀔 필요는 없습니다.
class CounterButton extends StatelessWidget {
const CounterButton({super.key});
@override
Widget build(BuildContext context) {
// listen: false는 이 위젯이 CounterModel의 변경 알림을 구독하지 않도록 합니다.
// 따라서 CounterModel이 변경되어도 이 위젯은 재빌드되지 않습니다.
// 이는 불필요한 재빌드를 막는 핵심적인 최적화 기법입니다.
final counter = Provider.of<CounterModel>(context, listen: false);
return FloatingActionButton(
onPressed: counter.increment,
tooltip: 'Increment',
child: const Icon(Icons.add),
);
}
}
위 예제에서 `CounterButton`에 사용된 `listen: false`는 매우 중요합니다. 이 버튼은 `increment` 메서드를 호출하는 기능만 필요할 뿐, 카운터 값이 바뀐다고 해서 버튼의 모양이 변하지는 않습니다. `listen: false`를 통해 이 위젯을 상태 변화의 구독자 목록에서 제외함으로써, `notifyListeners()`가 호출되어도 재빌드되지 않도록 하여 리소스를 절약할 수 있습니다.
1.4 BLoC: 복잡한 비즈니스 로직을 위한 아키텍처
BLoC(Business Logic Component) 패턴은 UI와 비즈니스 로직을 완전히 분리하는 데 중점을 둔 아키텍처입니다. 이벤트(Event)를 입력으로 받아 상태(State)를 출력하는 간단한 원칙을 따르며, 복잡한 비즈니스 로직, 비동기 처리, 외부 데이터 소스와의 연동이 많은 대규모 애플리케이션에 적합합니다.
- Events: UI 또는 다른 소스로부터 BLoC으로 전달되는 입력입니다. (예: `IncrementButtonPressed`)
- States: BLoC이 처리 결과를 UI로 전달하는 출력입니다. (예: `CounterState(1)`)
- Bloc: 이벤트를 받아 상태로 변환하는 중간 다리 역할을 합니다.
`flutter_bloc` 패키지를 사용하면 BLoC 패턴을 쉽게 구현할 수 있습니다.
// 1. Events
abstract class CounterEvent {}
class CounterIncrementPressed extends CounterEvent {}
// 2. States (Equatable을 사용하면 상태 비교가 용이해집니다)
class CounterState extends Equatable {
final int count;
const CounterState(this.count);
@override
List<Object> get props => [count];
}
// 3. Bloc
class CounterBloc extends Bloc<CounterEvent, CounterState> {
CounterBloc() : super(const CounterState(0)) {
on<CounterIncrementPressed>((event, emit) {
// 새로운 상태를 emit하여 UI에 알립니다.
emit(CounterState(state.count + 1));
});
}
}
// 4. UI
class BlocCounterPage extends StatelessWidget {
const BlocCounterPage({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => CounterBloc(),
child: const BlocCounterView(),
);
}
}
class BlocCounterView extends StatelessWidget {
const BlocCounterView({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('BLoC Example')),
body: Center(
// BlocBuilder는 특정 Bloc의 상태 변화를 감지하고 UI를 재빌드합니다.
child: BlocBuilder<CounterBloc, CounterState>(
builder: (context, state) {
return Text(
'${state.count}',
style: Theme.of(context).textTheme.headlineMedium,
);
},
),
),
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.add),
onPressed: () {
// UI는 이벤트를 Bloc에 추가하기만 합니다. 로직은 Bloc 내부에서 처리됩니다.
context.read<CounterBloc>().add(CounterIncrementPressed());
},
),
);
}
}
BLoC 패턴은 초기 설정이 Provider보다 다소 복잡하지만, 로직의 분리를 강제하여 코드의 테스트 용이성과 유지보수성을 크게 향상시킵니다. 특히 복잡한 상태 머신을 관리해야 할 때 그 진가를 발휘합니다.
2. 위젯 트리 최적화: 가볍고 빠르게 빌드하기
플러터의 렌더링 엔진은 위젯 트리를 분석하여 렌더링 객체 트리를 만들고 화면에 그립니다. 따라서 위젯 트리의 구조와 각 위젯의 빌드 비용은 전체 UI 성능에 직접적인 영향을 미칩니다. 가볍고 효율적인 위젯 트리를 구성하는 것은 최적화의 기본입니다.
2.1 `const` 생성자의 마법
플러터 성능 최적화에서 가장 간단하면서도 강력한 도구는 `const` 키워드입니다. 위젯을 `const`로 선언하면, 해당 위젯은 컴파일 타임에 생성되어 메모리에 상수로 저장됩니다. 런타임에 부모 위젯이 재빌드되더라도 `const`로 선언된 자식 위젯은 다시 생성되거나 재빌드되지 않습니다. 이는 불필요한 CPU 및 메모리 사용을 획기적으로 줄여줍니다.
class OptimizedBuild extends StatelessWidget {
const OptimizedBuild({super.key});
@override
Widget build(BuildContext context) {
// 이 Scaffold와 AppBar는 내용이 변하지 않으므로 const로 선언할 수 있습니다.
// setState() 등으로 인해 OptimizedBuild 위젯이 재빌드되더라도
// 이들은 다시 생성되지 않습니다.
return Scaffold(
appBar: AppBar(
// Text 위젯도 내용이 상수이므로 const로 선언합니다.
title: const Text('Const Optimization'),
),
body: Center(
// Padding 위젯도 마찬가지입니다.
child: const Padding(
padding: EdgeInsets.all(8.0),
child: Text('This is a constant widget.'),
),
),
);
}
}
규칙: 위젯과 그 자식들이 빌드 시점에 결정될 수 있는 고정된 값을 가지고 있다면, 항상 `const`를 붙이는 습관을 들이는 것이 좋습니다. 최신 Dart 분석기는 `const`를 붙일 수 있는 곳에 힌트를 주므로 이를 적극 활용하세요.
2.2 위젯 분리를 통한 재빌드 범위 최소화
하나의 거대한 `build` 메서드를 가진 위젯은 상태가 조금만 변경되어도 전체가 재빌드되어 비효율적입니다. `build` 메서드를 논리적인 단위로 잘게 쪼개어 별도의 위젯으로 분리하면, 재빌드 범위를 상태가 변경되는 특정 위젯으로 한정할 수 있습니다.
나쁜 예:
class LargeWidget extends StatefulWidget {
@override
_LargeWidgetState createState() => _LargeWidgetState();
}
class _LargeWidgetState extends State<LargeWidget> {
int _counter = 0;
@override
Widget build(BuildContext context) {
// _counter가 변경될 때마다 아래의 모든 위젯이 재빌드됩니다.
// ExpensiveWidgetA와 B는 _counter와 아무 상관이 없는데도 말이죠.
return Column(
children: [
ExpensiveWidgetA(), // 재빌드가 필요 없는 비싼 위젯
Text('Counter: $_counter'), // 재빌드가 필요한 위젯
ElevatedButton(
onPressed: () => setState(() => _counter++),
child: const Text('Increment'),
),
ExpensiveWidgetB(), // 재빌드가 필요 없는 비싼 위젯
],
);
}
}
좋은 예:
class RefactoredWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
// ExpensiveWidgetA와 B는 이제 상태가 없는 StatelessWidget의 일부이므로
// 부모가 재빌드되더라도 const로 인해 재빌드되지 않습니다.
return Column(
children: const [
ExpensiveWidgetA(),
CounterSection(), // 상태 관리를 이 위젯으로 위임
ExpensiveWidgetB(),
],
);
}
}
// 상태와 관련된 부분만 별도의 StatefulWidget으로 분리합니다.
class CounterSection extends StatefulWidget {
const CounterSection({super.key});
@override
_CounterSectionState createState() => _CounterSectionState();
}
class _CounterSectionState extends State<CounterSection> {
int _counter = 0;
@override
Widget build(BuildContext context) {
// 이제 setState()는 이 작은 위젯만 재빌드합니다.
return Column(
children: [
Text('Counter: $_counter'),
ElevatedButton(
onPressed: () => setState(() => _counter++),
child: const Text('Increment'),
),
],
);
}
}
위와 같이 리팩토링하면, 버튼을 눌러 `setState()`가 호출될 때 `CounterSection` 위젯만 재빌드되고, `ExpensiveWidgetA`와 `ExpensiveWidgetB`는 영향을 받지 않게 되어 성능이 크게 향상됩니다.
2.3 `ListView.builder`를 통한 지연 로딩
수백, 수천 개의 아이템을 리스트로 보여줘야 할 때, `ListView`의 기본 생성자를 사용하면 모든 아이템 위젯을 한 번에 생성하여 메모리 부족 및 성능 저하를 유발합니다. 이럴 때는 반드시 `ListView.builder`를 사용해야 합니다.
`ListView.builder`는 화면에 실제로 보여지는 아이템들만 동적으로 생성하고, 화면 밖으로 스크롤되어 사라지는 아이템은 파괴(또는 재활용)합니다. 이를 '지연 로딩(lazy loading)' 또는 '가상화(virtualization)'라고 부릅니다.
ListView.builder(
// itemCount는 리스트의 전체 아이템 개수를 알려줍니다.
itemCount: 1000,
// itemBuilder는 각 인덱스에 해당하는 위젯을 생성하는 함수입니다.
// 이 함수는 해당 아이템이 화면에 보여야 할 때만 호출됩니다.
itemBuilder: (BuildContext context, int index) {
return ListTile(
leading: CircleAvatar(child: Text('${index + 1}')),
title: Text('Item $index'),
subtitle: const Text('This is a subtitle'),
);
},
// itemExtent를 지정하면 각 아이템의 높이를 고정하여 스크롤 성능을 더욱 향상시킬 수 있습니다.
// 플러터가 레이아웃 계산을 미리 할 수 있기 때문입니다.
itemExtent: 70.0,
)
3. 반응형 레이아웃과 크기 최적화
다양한 화면 크기와 해상도를 가진 디바이스에 대응하는 것은 현대 앱 개발의 필수 요건입니다. 고정된 픽셀 값으로 UI를 설계하면 특정 디바이스에서는 UI가 깨지거나 잘려 보일 수 있습니다. 플러터는 유연하고 반응적인 레이아웃을 구축하기 위한 강력한 도구들을 제공합니다.
3.1 `MediaQuery` vs `LayoutBuilder`
두 위젯 모두 화면 크기에 따라 다른 UI를 보여주는 데 사용되지만, 동작 방식에 중요한 차이가 있습니다.
- `MediaQuery`: 전체 화면(또는 가장 가까운 `MediaQuery` 조상)의 크기, 방향, 패딩(노치 등)과 같은 글로벌 정보를 제공합니다. 앱의 전반적인 레이아웃 구조를 결정할 때 유용합니다. (예: 화면이 넓으면 2단 레이아웃, 좁으면 1단 레이아웃으로 변경)
- `LayoutBuilder`: 부모 위젯이 자식 위젯에게 제공하는 제약 조건(constraints)을 알려줍니다. 특정 컴포넌트가 자신에게 주어진 공간에 따라 내부 레이아웃을 동적으로 조정해야 할 때 매우 유용합니다. 이는 재사용 가능한 컴포넌트를 만드는 데 핵심적입니다.
class ResponsiveLayout extends StatelessWidget {
const ResponsiveLayout({super.key});
@override
Widget build(BuildContext context) {
// MediaQuery는 전체 화면 크기를 기준으로 분기합니다.
final screenWidth = MediaQuery.of(context).size.width;
if (screenWidth > 600) {
return const WideLayout();
} else {
return const NarrowLayout();
}
}
}
class AdaptiveComponent extends StatelessWidget {
const AdaptiveComponent({super.key});
@override
Widget build(BuildContext context) {
// LayoutBuilder는 부모가 제공하는 제약 조건(maxWidth)을 기준으로 분기합니다.
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
if (constraints.maxWidth > 300) {
// 이 컴포넌트에 300px 이상의 공간이 주어지면 두 개의 버튼을 가로로 배치합니다.
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: const [MyButton(label: 'Action 1'), MyButton(label: 'Action 2')],
);
} else {
// 공간이 좁으면 버튼을 세로로 배치합니다.
return Column(
children: const [MyButton(label: 'Action 1'), MyButton(label: 'Action 2')],
);
}
},
);
}
}
일반적으로, 재사용 가능한 위젯을 만들 때는 `LayoutBuilder`를 사용하여 위젯이 외부 환경(전체 화면 크기)에 대한 의존성 없이 독립적으로 반응하도록 설계하는 것이 더 좋은 아키텍처입니다.
3.2 `Expanded`와 `Flexible`의 올바른 사용
`Row`나 `Column` 내에서 자식 위젯들이 공간을 어떻게 나눠 가질지 결정할 때 `Expanded`와 `Flexible`이 사용됩니다.
- `Expanded`: `Flexible`의 특별한 형태로, `fit` 속성이 `FlexFit.tight`로 고정되어 있습니다. 이는 자식 위젯이 남은 공간을 모두 차지하도록 강제합니다.
- `Flexible`: `fit` 속성을 `FlexFit.loose`로 설정할 수 있으며, 이 경우 자식 위젯은 남은 공간 내에서 자신의 크기만큼만 차지하고, 공간을 모두 채우지 않을 수 있습니다.
Row(
children: [
// Expanded는 남은 공간을 flex 비율(기본값 1)에 따라 나눠 갖습니다.
Expanded(
flex: 2,
child: Container(color: Colors.blue), // 이 컨테이너는 남은 공간의 2/3를 차지합니다.
),
Expanded(
flex: 1,
child: Container(color: Colors.green), // 이 컨테이너는 남은 공간의 1/3을 차지합니다.
),
],
)
이 위젯들은 고정 픽셀 값 대신 비율 기반의 유연한 레이아웃을 만드는 데 필수적입니다.
4. 렌더링 파이프라인과 애니메이션 최적화
플러터의 렌더링은 매우 빠르지만, 복잡한 커스텀 페인팅이나 비효율적인 애니메이션은 여전히 성능 병목을 일으킬 수 있습니다. 렌더링 파이프라인을 이해하면 이러한 문제를 해결하는 데 도움이 됩니다.
4.1 `CustomPainter`와 `shouldRepaint`
`CustomPaint` 위젯을 사용하면 캔버스(Canvas)에 직접 그림을 그려 복잡하고 독창적인 UI를 만들 수 있습니다. 이때 성능의 핵심은 `CustomPainter`의 `shouldRepaint` 메서드에 있습니다.
이 메서드는 새로운 `CustomPainter` 인스턴스가 제공되었을 때 그림을 다시 그려야 할지(repaint) 여부를 결정합니다. 만약 그림의 내용이 이전과 동일하다면 `false`를 반환하여 불필요한 리페인팅을 막아야 합니다. 이는 CPU 사용량을 크게 줄여줍니다.
class MyCirclePainter extends CustomPainter {
final Color color;
MyCirclePainter({required this.color});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..color = color;
canvas.drawCircle(size.center(Offset.zero), size.width / 2, paint);
}
// oldDelegate는 이전 상태의 Painter 인스턴스입니다.
// 색상이 변경되었을 때만 다시 그리도록 최적화합니다.
@override
bool shouldRepaint(MyCirclePainter oldDelegate) {
return oldDelegate.color != color;
}
}
4.2 `RepaintBoundary`로 렌더링 영역 격리
복잡한 애니메이션이나 `CustomPaint`와 같이 자주 리페인트되는 위젯이 있다면, 이를 `RepaintBoundary`로 감싸는 것이 좋습니다. `RepaintBoundary`는 자식 위젯을 별도의 렌더링 레이어(layer)로 분리합니다. 이로 인해 자식 위젯이 리페인트되더라도, 해당 레이어만 다시 그려지고 부모나 형제 위젯들은 영향을 받지 않습니다. 이는 리페인트의 범위를 최소화하는 매우 강력한 최적화 도구입니다.
Column(
children: [
const StaticHeader(), // 이 위젯은 다시 그려질 필요가 없습니다.
// 이 경계 안의 애니메이션은 독립적인 레이어에서 처리됩니다.
RepaintBoundary(
child: TickerDrivenAnimation(), // 1초에 60번씩 리페인트되는 위젯
),
const StaticFooter(), // 이 위젯도 다시 그려질 필요가 없습니다.
],
)
4.3 애니메이션 최적화: `AnimatedBuilder`와 내장 위젯 활용
애니메이션을 구현할 때 `AnimationController`의 리스너에서 `setState()`를 호출하는 것은 가장 비효율적인 방법입니다. `setState()`는 위젯 전체를 재빌드하기 때문입니다.
대신 `AnimatedBuilder`를 사용해야 합니다. `AnimatedBuilder`는 `animation` 객체를 수신하고, 애니메이션 값이 변경될 때마다 `builder` 함수만 재실행합니다. `builder` 함수 외부의 위젯들은 재빌드되지 않습니다.
// ... AnimationController _controller 초기화 ...
return AnimatedBuilder(
// 컨트롤러를 리슨합니다.
animation: _controller,
// builder 함수는 애니메이션 값이 변할 때마다 호출됩니다.
builder: (BuildContext context, Widget? child) {
return Transform.rotate(
angle: _controller.value * 2.0 * math.pi,
// child는 builder 함수 외부에서 한 번만 생성되어 전달됩니다.
// 따라서 이 비싼 위젯은 재빌드되지 않습니다.
child: child,
);
},
// 이 child는 builder의 두 번째 인자로 전달됩니다.
// 애니메이션과 독립적인 비싼 위젯을 여기에 배치하여 재빌드를 방지합니다.
child: const ExpensiveWidget(),
);
더 나아가, `FadeTransition`, `ScaleTransition`과 같은 내장된 Transition 위젯이나 `AnimatedContainer`, `AnimatedOpacity` 같은 내장된 암시적 애니메이션 위젯을 사용하는 것이 좋습니다. 이 위젯들은 내부적으로 `AnimatedBuilder`와 같은 최적화 기법을 사용하여 구현되어 있어 개발자가 직접 최적화를 신경 쓸 필요 없이 간편하고 효율적으로 애니메이션을 적용할 수 있게 해줍니다.
결론
플러터 UI 최적화는 단순히 코드를 더 빨리 실행시키는 기술적인 문제를 넘어, 사용자에게는 쾌적한 경험을, 개발자에게는 지속 가능한 개발 환경을 제공하는 핵심적인 과정입니다. 우리는 상태 관리 솔루션을 통해 재빌드 범위를 최소화하고, `const`와 위젯 분리로 빌드 비용을 줄였으며, 반응형 레이아웃 원칙으로 다양한 디바이스에 대응하고, 렌더링 파이프라인을 이해하여 애니메이션 성능을 극대화하는 방법을 살펴보았습니다. 이러한 원칙들을 프로젝트 초기부터 체계적으로 적용한다면, 복잡하고 기능이 풍부하면서도 빠르고 안정적인 고품질 플러터 애플리케이션을 성공적으로 구축할 수 있을 것입니다.
0 개의 댓글:
Post a Comment