Rendering Pipeline
Understand how Flutter renders pixels on the screen.
What is it?
The rendering pipeline is the process by which Flutter takes your widget tree and converts it into actual pixels on the screen. It involves multiple stages including building, layout, painting, compositing, and rasterization. The pipeline runs every frame (typically 60 or 120 times per second) to keep the UI smooth and responsive.
Why does it exist?
The rendering pipeline exists to:
- Convert widget configurations into visual output
- Maintain smooth 60fps or 120fps performance
- Handle complex animations and transitions
- Optimize rendering for different platforms
- Manage the frame budget efficiently
- Provide consistent rendering across devices
The Rendering Pipeline Stages
The pipeline consists of several sequential stages.
1. Build Phase
↓
2. Layout Phase
↓
3. Paint Phase
↓
4. Compositing Phase
↓
5. Rasterization Phase
↓
6. Display Phase
What's happening here? - Each stage depends on the previous - Stages run in order every frame - Each stage has a specific purpose - Performance is critical at each stage - Total time must be under 16ms
Stage 1: Build Phase
Build phase creates and updates the widget tree.
// Build phase in action
class BuildPhaseWidget extends StatefulWidget {
const BuildPhaseWidget({super.key});
@override
State<BuildPhaseWidget> createState() => _BuildPhaseWidgetState();
}
class _BuildPhaseWidgetState extends State<BuildPhaseWidget> {
int _counter = 0;
@override
Widget build(BuildContext context) {
// Build phase starts here
// Creates widget configuration
print('Build phase: Creating widgets');
return Column(
children: [
Text('Counter: $_counter'),
ElevatedButton(
onPressed: () {
setState(() {
_counter++;
});
},
child: const Text('Increment'),
),
],
);
}
}
// What happens during build:
// 1. Widgets are created or updated
// 2. Widget tree is configured
// 3. Elements are updated
// 4. Dirty widgets are rebuilt
// 5. Only changed widgets rebuild
// Build phase timeline:
// 1. Schedule frame
// 2. Mark dirty widgets
// 3. Rebuild dirty widgets
// 4. Update element tree
// 5. Proceed to layout
What's happening here? - Creates/updates widget configurations - Only dirty widgets rebuild - Builds the widget tree - Updates the element tree - Quick and efficient
Stage 2: Layout Phase
Layout phase computes sizes and positions.
// Layout phase in action
class LayoutPhaseWidget extends StatelessWidget {
const LayoutPhaseWidget({super.key});
@override
Widget build(BuildContext context) {
return Container(
width: 200,
height: 100,
color: Colors.blue,
child: const Center(
child: Text('Layout'),
),
);
}
}
// Behind the scenes layout:
// Container → RenderConstrainedBox
// Center → RenderPositionedBox
// Text → RenderParagraph
// Layout process:
// 1. Constraints flow down from parent
// 2. Child determines its size
// 3. Parent positions child
// 4. Parent sets its own size
// 5. Sizes flow back up the tree
// Layout constraints:
class LayoutExample extends StatefulWidget {
const LayoutExample({super.key});
@override
State<LayoutExample> createState() => _LayoutExampleState();
}
class _LayoutExampleState extends State<LayoutExample> {
@override
Widget build(BuildContext context) {
return Column(
children: [
// 1. Unconstrained - child sizes itself
Container(
color: Colors.red,
child: const Text('Unconstrained'),
),
// 2. Constrained - parent forces size
SizedBox(
width: 200,
height: 50,
child: Container(
color: Colors.green,
child: const Text('Constrained'),
),
),
// 3. Expanded - flexible layout
Expanded(
child: Container(
color: Colors.blue,
child: const Text('Expanded'),
),
),
],
);
}
}
// Layout timeline:
// 1. Parent passes constraints
// 2. Child performs layout
// 3. Child returns size
// 4. Parent positions child
// 5. Layout complete
What's happening here? - Computes sizes and positions - Constraints flow down - Sizes flow back up - Every render object is laid out - Positions are determined
Stage 3: Paint Phase
Paint phase draws the UI elements.
// Paint phase in action
class PaintPhaseWidget extends CustomPaint {
const PaintPhaseWidget({super.key});
@override
void paint(Canvas canvas, Size size) {
// Paint phase draws to canvas
// 1. Draw background
final Paint bgPaint = Paint()..color = Colors.blue;
canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height), bgPaint);
// 2. Draw circle
final Paint circlePaint = Paint()..color = Colors.red;
canvas.drawCircle(Offset(size.width/2, size.height/2), 50, circlePaint);
// 3. Draw text
final TextPainter textPainter = TextPainter(
text: const TextSpan(text: 'Paint Phase'),
textDirection: TextDirection.ltr,
);
textPainter.layout();
textPainter.paint(canvas, Offset(10, 10));
}
}
// What happens during paint:
// 1. Paint methods are called
// 2. Canvas operations are recorded
// 3. Draw commands are generated
// 4. Display lists are created
// 5. Only dirty areas repaint
// Paint optimization:
class OptimizedPaintWidget extends StatelessWidget {
const OptimizedPaintWidget({super.key});
@override
Widget build(BuildContext context) {
return RepaintBoundary(
child: CustomPaint(
painter: MyPainter(),
child: const Text('Optimized paint'),
),
);
}
}
// Paint timeline:
// 1. Determine dirty regions
// 2. Paint dirty regions
// 3. Record paint commands
// 4. Create display lists
// 5. Send to compositor
What's happening here? - Draws UI elements to canvas - Records draw commands - Only paints dirty regions - Creates display lists - Handles custom painting
Stage 4: Compositing Phase
Compositing phase combines layers together.
// Compositing in action
class CompositingWidget extends StatelessWidget {
const CompositingWidget({super.key});
@override
Widget build(BuildContext context) {
return Stack(
children: [
// 1. Background layer
Container(
color: Colors.blue,
child: const Text('Background'),
),
// 2. Middle layer
Container(
color: Colors.red.withOpacity(0.5),
child: const Text('Middle'),
),
// 3. Foreground layer
Container(
color: Colors.green.withOpacity(0.5),
child: const Text('Foreground'),
),
],
);
}
}
// Layer types:
// 1. PictureLayer - Paint commands
// 2. TextureLayer - Video/GPU content
// 3. OffsetLayer - Positioned children
// 4. OpacityLayer - Opacity effects
// 5. ClipRectLayer - Clipping
// 6. TransformLayer - Transformations
// Compositing process:
// 1. Collect all layers
// 2. Sort layers by z-index
// 3. Apply layer effects
// 4. Combine layers together
// 5. Create composite image
// Custom compositing:
class CustomLayerWidget extends StatelessWidget {
const CustomLayerWidget({super.key});
@override
Widget build(BuildContext context) {
return Transform(
transform: Matrix4.rotationZ(0.5),
child: Container(
color: Colors.blue,
child: const Text('Transformed'),
),
);
}
}
What's happening here? - Combines multiple layers - Applies transformations - Handles opacity and effects - Creates final composite - GPU-accelerated
Stage 5: Rasterization Phase
Rasterization phase converts vector graphics to pixels.
// Rasterization process
class RasterizationExample extends StatelessWidget {
const RasterizationExample({super.key});
@override
Widget build(BuildContext context) {
return Container(
// These will be rasterized
width: 100,
height: 100,
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 10,
offset: const Offset(5, 5),
),
],
),
child: const Text('Rasterized'),
);
}
}
// Rasterization steps:
// 1. Vector graphics to pixels
// 2. Apply anti-aliasing
// 3. Apply filters and effects
// 4. Render to frame buffer
// 5. Prepare for display
// Performance considerations:
// 1. Rasterization is GPU-intensive
// 2. Complex shapes cost more
// 3. Shadows and effects are expensive
// 4. Large areas cost more
// 5. Use RepaintBoundary to isolate
// Rasterization optimization:
class OptimizedRasterization extends StatelessWidget {
const OptimizedRasterization({super.key});
@override
Widget build(BuildContext context) {
// 1. Simplify shapes
return Container(
color: Colors.blue, // Simple shape - cheap
);
// 2. Avoid unnecessary effects
// Use flat designs instead of heavy shadows
// 3. Use raster cache for static content
// Wrap in RepaintBoundary
}
}
What's happening here? - Vector to pixel conversion - GPU handles rasterization - Anti-aliasing applied - Effects and filters rendered - Final pixel buffer created
Stage 6: Display Phase
Display phase shows the final image on screen.
// Display process
class DisplayExample extends StatelessWidget {
const DisplayExample({super.key});
@override
Widget build(BuildContext context) {
// After rasterization:
// 1. Frame buffer is ready
// 2. Screen refreshes (vsync)
// 3. Frame is displayed
// 4. User sees the update
return const Text('Displayed');
}
}
// Display timeline:
// 1. Frame buffer complete
// 2. Wait for vsync
// 3. Swap buffers
// 4. Display on screen
// 5. Ready for next frame
// Frame timing:
// 60fps = 16.6ms per frame
// 120fps = 8.3ms per frame
// All stages must fit in frame budget
// Or frame drops occur
What's happening here? - Frame buffer displayed - Vsync synchronizes display - Buffer swapping occurs - User sees the update - Ready for next frame
Frame Scheduling
Frames are scheduled by the engine.
// Frame scheduling
class FrameScheduler extends StatefulWidget {
const FrameScheduler({super.key});
@override
State<FrameScheduler> createState() => _FrameSchedulerState();
}
class _FrameSchedulerState extends State<FrameScheduler>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
)..repeat();
}
@override
Widget build(BuildContext context) {
// Frame is scheduled by AnimationController
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
// Called every frame
return Transform.rotate(
angle: _controller.value * 2 * 3.14,
child: const Icon(Icons.refresh),
);
},
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
}
// Frame scheduling triggers:
// 1. setState()
// 2. Animation controllers
// 3. WidgetsBinding.instance.scheduleFrame()
// 4. Native platform events
// 5. Timer callbacks
// Manual frame scheduling:
void _scheduleFrame() {
WidgetsBinding.instance.scheduleFrame();
}
What's happening here? - Frames are scheduled as needed - Animations trigger frames - State changes trigger frames - Frames are batched together - Vsync controls frame timing
Performance Optimization
Optimizing the pipeline for performance.
// Performance optimization techniques
class OptimizedPipeline extends StatelessWidget {
const OptimizedPipeline({super.key});
@override
Widget build(BuildContext context) {
return Column(
children: [
// 1. Use RepaintBoundary
RepaintBoundary(
child: AnimatedWidget(), // Isolated repaint
),
// 2. Use const widgets
const Text('Static content'), // No rebuild
// 3. Cache complex widgets
const CachedWidget(
child: ComplexWidget(),
),
// 4. Use ListView.builder
// Not all children built at once
// 5. Use keys for lists
// Efficient list updates
],
);
}
}
// Performance profiling:
class ProfilePipeline extends StatelessWidget {
const ProfilePipeline({super.key});
@override
Widget build(BuildContext context) {
// Use Flutter DevTools:
// 1. Performance tab
// 2. Timeline recording
// 3. Frame analysis
// 4. Identify bottlenecks
// 5. Optimize slow frames
return const Text('Profile me');
}
}
// Common performance issues:
// 1. Build too slow
// 2. Layout too complex
// 3. Paint too expensive
// 4. Compositing too heavy
// 5. Rasterization too slow
// 6. Frame budget exceeded
What's happening here? - RepaintBoundary isolates painting - const widgets reduce builds - Cache complex widgets - Use efficient widgets - Profile and optimize
Pipeline in Different Modes
Pipeline behavior changes in different modes.
// Debug mode
class DebugPipeline extends StatelessWidget {
const DebugPipeline({super.key});
@override
Widget build(BuildContext context) {
// 1. Full pipeline
// 2. Debug checks enabled
// 3. Slower performance
// 4. Detailed error messages
// 5. Hot reload available
return const Text('Debug mode');
}
}
// Profile mode
class ProfilePipeline extends StatelessWidget {
const ProfilePipeline({super.key});
@override
Widget build(BuildContext context) {
// 1. Full pipeline
// 2. Performance tracking
// 3. Tracing enabled
// 4. Optimized for profiling
// 5. Best for performance testing
return const Text('Profile mode');
}
}
// Release mode
class ReleasePipeline extends StatelessWidget {
const ReleasePipeline({super.key});
@override
Widget build(BuildContext context) {
// 1. Optimized pipeline
// 2. Debug checks removed
// 3. Fastest performance
// 4. No hot reload
// 5. AOT compilation
return const Text('Release mode');
}
}
What's happening here? - Debug mode for development - Profile mode for performance - Release mode for production - Each mode optimizes differently - Choose based on purpose
Best Practices
Understand Frame Budget
// Frame budget tracking
class FrameBudgetWidget extends StatelessWidget {
const FrameBudgetWidget({super.key});
@override
Widget build(BuildContext context) {
// 60fps = 16.6ms per frame
// 120fps = 8.3ms per frame
// Keep within budget:
// Build: < 8ms
// Layout: < 4ms
// Paint: < 4ms
// Composite: < 2ms
// Total: < 16.6ms
return const Text('Frame budget');
}
}
Use Repaint Boundaries
// Good - Isolate expensive paints
@override
Widget build(BuildContext context) {
return RepaintBoundary(
child: ExpensivePaintingWidget(),
);
}
// Bad - No isolation
@override
Widget build(BuildContext context) {
return ExpensivePaintingWidget(); // Repaints with parent
}
Avoid Overdraw
// Good - Minimize overdraw
Container(
color: Colors.blue,
child: const Text('Hello'),
)
// Bad - Multiple overlapping layers
Stack(
children: [
Container(color: Colors.blue),
Container(color: Colors.red.withOpacity(0.5)),
Container(color: Colors.green.withOpacity(0.5)),
],
)
Common Mistakes
Too Many Widgets
Wrong:
// 1000+ widgets in a single view
Column(
children: List.generate(1000, (index) {
return Text('Item $index');
}),
)
Correct:
// Use ListView for large lists
ListView.builder(
itemCount: 1000,
itemBuilder: (context, index) {
return Text('Item $index');
},
)
Expensive Operations
Wrong:
// Expensive paint operation
@override
void paint(Canvas canvas, Size size) {
for (int i = 0; i < 1000; i++) {
// Many draw operations
canvas.drawCircle(Offset(i.toDouble(), 100), 10, paint);
}
}
Correct:
// Batch operations or cache
// Use RepaintBoundary
// Simplify painting
Summary
The rendering pipeline converts widgets to pixels through build, layout, paint, compositing, rasterization, and display stages. Understanding each stage helps optimize performance. Keep operations efficient, use repaint boundaries, and stay within frame budget.
Next Steps
Did You Know?
- Flutter targets 60fps by default
- Some devices support 120fps
- The pipeline runs per frame
- Repaint boundaries isolate paint
- Rasterization is GPU-accelerated
- Compositing happens on the GPU
- The entire pipeline is in Dart and C++
- Jank occurs when frames exceed budget