Advanced Usage
Advanced Flutter techniques — RenderObject, Slivers, CustomPainter, isolates, platform channels, FFI
Advanced Usage
Techniques for breaking past the limits of built-in Widgets. These APIs are not "must-know", but they are the only answer when you hit performance bottlenecks, unusual interactions, or deep native integration.
Three Trees: Widget / Element / RenderObject
Understanding the three trees is a prerequisite for advanced Flutter work.
Widget Tree Element Tree RenderObject Tree
(config / immutable) (lifecycle / state) (layout / paint / hit-test)
───────────── ──────────── ─────────────────
Container ←→ ComponentElement
Padding ←→ ComponentElement → RenderPadding
Text ←→ ComponentElement → RenderParagraph| Tree | Responsibility | When It Changes |
|---|---|---|
| Widget | Immutable description of UI | setState / parent rebuild |
| Element | Holds Widget + State + parent-child relationships | Follows Widget changes; reuse minimizes creation |
| RenderObject | Performs layout / paint / hitTest | Only when properties change (markNeedsLayout / Paint) |
The essence of perf optimization: fewer Widget rebuilds → Element reuse → RenderObject unchanged → skip layout/paint.
CustomPainter
For drawing graphics built-in Widgets cannot express: charts, signatures, waveforms, custom progress indicators.
class WavePainter extends CustomPainter {
final double progress;
WavePainter({required this.progress});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.blue
..style = PaintingStyle.fill;
final path = Path()..moveTo(0, size.height);
for (double x = 0; x <= size.width; x++) {
final y = size.height / 2 +
20 * sin((x / size.width * 2 * pi) + progress * 2 * pi);
path.lineTo(x, y);
}
path.lineTo(size.width, size.height);
path.close();
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(WavePainter old) => old.progress != progress;
}
// Usage
CustomPaint(painter: WavePainter(progress: animation.value), size: Size(300, 100))Key Points
- Implement
shouldRepaintcorrectly — returning true unnecessarily wastes work; returning false incorrectly stops repaints - Isolate with
RepaintBoundary— frequently repainted canvases deserve their own layer to avoid contaminating siblings - Cache complex paths via
PictureRecorder— pre-record the static portion and only redraw the dynamic part each frame - Avoid allocating in
paint— create paths and paints once and reuse them
Custom RenderObject
When you need a completely custom layout algorithm (CustomPaint is not enough — it only paints, not lays out), drop down to RenderObject.
Typical scenarios:
- Masonry / waterfall layout
- Custom typography (text wrapping around shapes, specialized rich text)
- Adaptive grids
- Physics-driven layout
class FlowLayout extends MultiChildRenderObjectWidget {
FlowLayout({super.key, required super.children});
@override
RenderObject createRenderObject(BuildContext context) => _RenderFlowLayout();
}
class _RenderFlowLayout extends RenderBox
with ContainerRenderObjectMixin<RenderBox, FlexParentData>,
RenderBoxContainerDefaultsMixin<RenderBox, FlexParentData> {
@override
void setupParentData(RenderBox child) {
child.parentData = FlexParentData();
}
@override
void performLayout() {
double xCursor = 0, yCursor = 0, rowHeight = 0;
var child = firstChild;
while (child != null) {
child.layout(constraints.loosen(), parentUsesSize: true);
final pd = child.parentData as FlexParentData;
if (xCursor + child.size.width > constraints.maxWidth) {
xCursor = 0;
yCursor += rowHeight;
rowHeight = 0;
}
pd.offset = Offset(xCursor, yCursor);
xCursor += child.size.width;
rowHeight = max(rowHeight, child.size.height);
child = pd.nextSibling;
}
size = Size(constraints.maxWidth, yCursor + rowHeight);
}
@override
void paint(PaintingContext context, Offset offset) =>
defaultPaint(context, offset);
@override
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) =>
defaultHitTestChildren(result, position: position);
}Custom RenderObject has a steep learning curve. Read RenderFlex and RenderWrap source first to understand the conventions.
Sliver
Sliver is Flutter's "scrollable region" protocol — it lets you mix different scroll behaviors inside a single scroll container:
CustomScrollView(
slivers: [
SliverAppBar( // collapsing AppBar
expandedHeight: 200,
flexibleSpace: FlexibleSpaceBar(...),
),
SliverPersistentHeader( // sticky header
pinned: true,
delegate: _StickyHeaderDelegate(),
),
SliverGrid.count( // grid
crossAxisCount: 2,
children: List.generate(20, (i) => Card(...)),
),
SliverList( // list
delegate: SliverChildBuilderDelegate(
(_, i) => ListTile(title: Text('Item $i')),
childCount: 100,
),
),
SliverFillRemaining(child: Center(child: Text('end'))),
],
)Key Slivers
| Sliver | Use |
|---|---|
SliverAppBar | Collapsing / hiding top bar |
SliverList / SliverGrid | Lazy list / grid |
SliverFixedExtentList | Fixed-height children — best perf |
SliverPersistentHeader | Sticky to top / bottom |
SliverPadding | Padding for a Sliver |
SliverToBoxAdapter | Embed a regular Widget into Sliver-land |
SliverFillRemaining | Fill the leftover space |
NestedScrollView | Nested scrolling (outer + inner TabBarView) |
InheritedWidget / InheritedModel
InheritedWidget is the foundation under every state management library. Understanding it lets you see through Provider/Riverpod.
class AppConfig extends InheritedWidget {
final String apiBase;
const AppConfig({required this.apiBase, required super.child});
static AppConfig of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<AppConfig>()!;
@override
bool updateShouldNotify(AppConfig old) => apiBase != old.apiBase;
}
// Usage
final api = AppConfig.of(context).apiBase;InheritedModel: Granular Subscription
InheritedWidget's pain point: any field change notifies every subscriber. InheritedModel allows subscribing per aspect:
class ThemeModel extends InheritedModel<String> {
final Color primary;
final Color background;
// ...
@override
bool updateShouldNotifyDependent(ThemeModel old, Set<String> aspects) {
if (aspects.contains('primary') && primary != old.primary) return true;
if (aspects.contains('background') && background != old.background) return true;
return false;
}
}
// Subscribe only to primary
final theme = InheritedModel.inheritFrom<ThemeModel>(context, aspect: 'primary');Isolates and compute
Dart is single-threaded; to actually use multiple cores you must use Isolates ("processes" that do not share memory).
Simple Case: compute
// Run heavyParse in another Isolate without blocking the UI
final result = await compute(heavyParse, jsonString);
List<Item> heavyParse(String json) {
return jsonDecode(json).map<Item>((e) => Item.fromJson(e)).toList();
}Use for: one-shot computation, JSON parsing, image processing.
Long-running: Isolate.spawn
final receivePort = ReceivePort();
await Isolate.spawn(_worker, receivePort.sendPort);
receivePort.listen((msg) {
if (msg is SendPort) {
msg.send('hello'); // main → worker
} else {
print('from worker: $msg');
}
});
void _worker(SendPort mainSendPort) {
final port = ReceivePort();
mainSendPort.send(port.sendPort);
port.listen((msg) {
mainSendPort.send('echo: $msg');
});
}Notes:
- Isolates can only exchange serializable data (no Functions, no native handles)
- Spawning an Isolate has a cost (~1 ms plus memory) — do not spawn in a loop
- Use
Isolate.run()(Dart 2.19+) for short tasks
Platform Channels
The standard way to talk to native code (Kotlin / Swift).
MethodChannel (request/response)
// Dart
const channel = MethodChannel('com.app/battery');
final level = await channel.invokeMethod<int>('getBatteryLevel');// Android
class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(engine: FlutterEngine) {
super.configureFlutterEngine(engine)
MethodChannel(engine.dartExecutor.binaryMessenger, "com.app/battery")
.setMethodCallHandler { call, result ->
if (call.method == "getBatteryLevel") {
result.success(getBatteryLevel())
} else result.notImplemented()
}
}
}EventChannel (streaming)
For continuous data such as sensors, location, or battery:
const channel = EventChannel('com.app/sensor');
channel.receiveBroadcastStream().listen((data) => print(data));Pigeon (recommended)
pigeon generates strongly-typed code on both sides from a schema, eliminating typos in method-name strings:
// pigeons/api.dart
@HostApi()
abstract class BatteryApi {
int getLevel();
}
// Running pigeon generates Dart / Kotlin / Swift code automaticallyFFI (Foreign Function Interface)
Direct calls into C/C++ libraries with no serialization overhead — the fastest option. Suited to image processing, cryptography, audio/video codecs.
import 'dart:ffi';
import 'package:ffi/ffi.dart';
final lib = DynamicLibrary.open('libnative.so');
final int Function(int, int) add = lib
.lookup<NativeFunction<Int32 Function(Int32, Int32)>>('add')
.asFunction();
print(add(2, 3)); // 5MethodChannel vs FFI:
| Dimension | MethodChannel | FFI |
|---|---|---|
| Overhead | Serialization + cross-thread | Direct function call |
| Suited for | Occasional calls, calling SDKs | High-frequency calls, pure compute |
| Complexity | Low | High (manual memory management) |
| Async | Naturally async | Roll your own (use an Isolate) |
Flutter Engine and PlatformView
Embedding Native Views
AndroidView / UiKitView embed native Views into the Flutter tree. Common uses: maps, WebView, camera preview.
AndroidView(
viewType: 'native-map',
creationParams: {'lat': 39.9, 'lng': 116.4},
creationParamsCodec: StandardMessageCodec(),
)Performance note: early PlatformView composition (SurfaceTexture / HybridComposition) was costly. On Android, prefer Hybrid Composition Plus mode; iOS is generally fine.
Texture Widget
For camera or video frames, Texture is faster than PlatformView: native code writes frames into an OpenGL Texture and Flutter composites them directly.
Animations Beyond the Basics
TweenAnimationBuilder: implicit animation without a Controller
TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: progress),
duration: Duration(milliseconds: 300),
builder: (_, value, __) => LinearProgressIndicator(value: value),
)AnimatedBuilder: efficient subscription
Restrict an animation's rebuilds to the necessary subtree only:
AnimatedBuilder(
animation: _controller,
child: const ExpensiveChild(), // not rebuilt
builder: (_, child) => Transform.rotate(
angle: _controller.value * 2 * pi,
child: child,
),
)Hero animations
Shared element transitions across pages:
// Page A
Hero(tag: 'avatar-$id', child: CircleAvatar(...))
// Page B
Hero(tag: 'avatar-$id', child: CircleAvatar(radius: 80, ...))The tag must be globally unique.
Custom ScrollPhysics
Tune scroll feel — damping, bounce, inertia:
class HeavyPhysics extends ScrollPhysics {
const HeavyPhysics({super.parent});
@override
HeavyPhysics applyTo(ScrollPhysics? ancestor) =>
HeavyPhysics(parent: buildParent(ancestor));
@override
double get minFlingVelocity => 1000; // raise the minimum fling velocity
@override
SpringDescription get spring => const SpringDescription(
mass: 100, stiffness: 100, damping: 1,
);
}
ListView(physics: const HeavyPhysics(), children: [...])Custom Render Layers: RepaintBoundary and OffsetLayer
Manually manage layers to isolate repaints:
RepaintBoundary(
child: AnimatedWidget(...), // animation lives in its own layer
)Inspect layer composition in DevTools' Performance Layers view.
When to reach for these techniques
Do not use "advanced" techniques for the sake of looking advanced. Build with the built-in Widgets first, profile to find the bottleneck, then drop down. Most business pages are perfectly served by StatelessWidget + ListView.builder + Provider.