Flutter Interview Questions and Answers
Last updated:
Check out 45 of the most common Flutter interview questions, then take an AI-powered practice interview
Q1How does Flutter draw its UI without using UIKit views or Android View widgets?
BasicArchitecture
Answer
A Flutter app is three layers stacked on top of each other. At the bottom is the embedder, a thin platform-specific host (FlutterActivity on Android, FlutterViewController on iOS) that owns a surface, a message loop and the input events. Above that sits the engine, written in C++, which contains the Dart runtime, the text layout stack (HarfBuzz plus ICU) and the rasteriser, Impeller on current builds and Skia on older ones.
On top is the framework, written entirely in Dart, which gives you widgets, layout, gestures, animation and Material or Cupertino components. When you write a Column with two Text children, no UILabel and no android.widget.TextView is created anywhere. Flutter computes the layout itself, produces a layer tree, and hands display lists to the rasteriser, which draws the glyphs and boxes directly onto the surface.
The practical consequences are worth naming in an interview: your UI is pixel-identical across platforms because it is the same code drawing it, you are not blocked waiting for the OS to ship a widget, and you inherit none of the platform's accessibility or text handling for free, which is why Flutter has its own Semantics tree that it publishes to TalkBack and VoiceOver. It also means anything the platform owns exclusively, such as a WebView, a Google Map or a camera preview, has to be embedded as a platform view, and that path is measurably more expensive than pure Flutter drawing.
Key Points
- Three layers: embedder (platform host), engine (C++, Dart VM, Impeller/Skia), framework (Dart)
- No native widgets are instantiated; Flutter rasterises everything itself
- Release builds are Dart AOT-compiled to native ARM machine code
- Accessibility comes from Flutter's own Semantics tree, not from platform views
- WebView, maps and camera preview need platform views, which cost extra
Q2What are the widget, element and render object trees, and what does each one own?
BasicRendering
Answer
Flutter keeps three parallel trees and confusing them is the single most common source of Flutter bugs. The widget tree is an immutable configuration description. Widgets are cheap value objects, thrown away and recreated on every build, and they hold no state and no position on screen.
The element tree is the long-lived one: each element is the instantiation of a widget at a location in the tree, it holds the BuildContext (an Element implements BuildContext), it tracks the parent and child relationships, and for a StatefulWidget it holds the State object. The render object tree does layout, painting and hit testing. Only widgets that extend RenderObjectWidget (Padding, Opacity, ColoredBox, RichText) create a RenderObject, so the render tree is much shallower than the widget tree, and composition widgets like Container, Scaffold or ListTile create none of their own.
When you rebuild, Flutter walks the element tree and calls Widget.canUpdate(oldWidget, newWidget), which compares runtimeType and key. If it returns true, the element is reused and just updated with the new configuration, which is why state survives rebuilds. If it returns false, the old element is unmounted (State.dispose runs) and a new one is inflated. That reuse rule is exactly why keys exist, and it is why an interviewer who asks about keys is really asking whether you understand the element tree.
class SizeProbe extends StatefulWidget {
const SizeProbe({super.key, required this.child});
final Widget child;
@override
State<SizeProbe> createState() => _SizeProbeState();
}
class _SizeProbeState extends State<SizeProbe> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
// Widget -> Element (this.context) -> RenderObject
final box = context.findRenderObject() as RenderBox?;
debugPrint('size=${box?.size} at ${box?.localToGlobal(Offset.zero)}');
});
}
@override
Widget build(BuildContext context) => widget.child;
}
Key Points
- Widgets: immutable config, recreated every frame, no state
- Elements: long-lived, are the BuildContext, hold State objects
- RenderObjects: layout, paint, hit test; only RenderObjectWidgets create them
- Widget.canUpdate compares runtimeType and key to decide element reuse
Q3When does Flutter call build(), and what is the full State lifecycle around it?
BasicWidget Lifecycle
Answer
build() runs when the element is first mounted, whenever setState marks it dirty, whenever the parent rebuilds and passes a new widget instance, and whenever an InheritedWidget this element depends on changes. It must be a pure function of the widget's fields, the State's fields and inherited data, with no side effects, because Flutter can call it many times per second and in an order you do not control. The State lifecycle around it runs: createState, then initState (called once, before the first build, and you cannot use InheritedWidget lookups such as Theme.of here safely), then didChangeDependencies (called after initState and again whenever a dependency changes, which is the correct place for MediaQuery or Theme-derived setup), then build.
On a parent rebuild you get didUpdateWidget(oldWidget) before build, and that is where you react to a changed parameter, for example cancelling and resubscribing a stream when the symbol prop changes. Finally deactivate runs when the element is removed from the tree (it may be reinserted elsewhere in the same frame via a GlobalKey), and dispose runs when it is gone for good. Interviewers probe two things here: that you never start work in build, and that you handle didUpdateWidget. A very common production bug is subscribing in initState only, so when the parent passes a new id the widget keeps showing data for the old one.
class TickerRow extends StatefulWidget {
const TickerRow({super.key, required this.symbol});
final String symbol;
@override
State<TickerRow> createState() => _TickerRowState();
}
class _TickerRowState extends State<TickerRow> {
late StreamSubscription<double> _sub;
double _price = 0;
@override
void initState() {
super.initState();
_sub = quotes(widget.symbol).listen(_onTick);
}
@override
void didUpdateWidget(covariant TickerRow oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.symbol != widget.symbol) {
_sub.cancel();
_sub = quotes(widget.symbol).listen(_onTick);
}
}
void _onTick(double p) => setState(() => _price = p);
@override
void dispose() {
_sub.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) => Text('${widget.symbol} $_price');
}
Q4What does marking a widget constructor const actually save at runtime?
BasicPerformance
Answer
Two separate things, and candidates usually only know the first. First, allocation: a const widget is canonicalised at compile time, so `const SizedBox(height: 8)` written in a hundred places is one object in memory, not a hundred, and rebuilding the enclosing widget allocates nothing for it. Second, and far more valuable, is subtree skipping.
During a rebuild, Element.updateChild compares the new widget to the old one, and if they are identical instances it short-circuits and does not rebuild that child's subtree at all. Because const canonicalisation makes the instance literally the same object across frames, the whole const subtree is skipped. That is why a heavy static header wrapped in const costs nothing on a list that rebuilds sixty times a second, and why the analyzer lints prefer_const_constructors and prefer_const_literals_to_create_immutables should be treated as errors in your analysis_options.yaml, not suggestions.
The catch is that const only applies when every argument is a compile-time constant, so any interpolated string, any Theme.of lookup or any callback closure defeats it. The standard workaround is to hoist the dynamic bit into a small private widget and keep everything around it const, rather than trying to force const onto a widget that genuinely depends on runtime data. In profile mode you can verify the win by turning on 'Track widget rebuilds' in DevTools and watching the const subtrees stop lighting up.
// analysis_options.yaml
// linter:
// rules:
// - prefer_const_constructors
// - prefer_const_literals_to_create_immutables
class PriceCard extends StatelessWidget {
const PriceCard({super.key, required this.price});
final int price;
@override
Widget build(BuildContext context) {
return Column(
children: [
const _CardHeader(), // identical instance -> subtree skipped
const SizedBox(height: 8), // canonicalised, zero allocation
Text('Rs $price'), // must rebuild, and that is fine
],
);
}
}
class _CardHeader extends StatelessWidget {
const _CardHeader();
@override
Widget build(BuildContext context) => const Text('Order total');
}
Key Points
- const canonicalises the instance, so no allocation per frame
- Identical instances make Element.updateChild skip the whole subtree
- Enable prefer_const_constructors in analysis_options.yaml
- Any runtime value (interpolation, Theme.of, closures) breaks const
Q5What is BuildContext, and why does Scaffold.of(context) throw inside the same build method that creates the Scaffold?
BasicBuildContext
Answer
BuildContext is not a bag of data, it is an interface implemented by Element, so a context is a handle to one specific position in the element tree. Every `.of(context)` call is an upward lookup from that position: Scaffold.of, Theme.of, Navigator.of and MediaQuery.of all walk ancestors until they find the right element type. That is why the classic beginner error happens.
Inside build, the `context` parameter refers to your own widget's element, which sits above the Scaffold you are returning, so looking up from there finds no Scaffold and you get 'Scaffold.of() called with a context that does not contain a Scaffold'. The fix is to introduce a new element below the Scaffold, either by wrapping the caller in a Builder or by extracting the child into its own widget class, which is the cleaner option because it also gives you a const boundary. The second BuildContext trap is asynchronous use.
After an await, the widget may have been disposed, and using the stale context to call Navigator.of or ScaffoldMessenger.of throws or silently targets a dead tree. Recent Flutter versions expose `context.mounted` for exactly this, and the use_build_context_synchronously lint flags every unguarded case. A third detail interviewers like: ScaffoldMessenger.of(context) usually works where Scaffold.of does not, because ScaffoldMessenger lives above MaterialApp's Navigator, which is why snackbars survive route pushes.
class BadDrawerButton extends StatelessWidget {
const BadDrawerButton({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
drawer: const Drawer(),
// Looks UP from this widget's element: there is no Scaffold above it.
body: TextButton(
onPressed: () => Scaffold.of(context).openDrawer(), // throws
child: const Text('Open'),
),
);
}
}
class GoodDrawerButton extends StatelessWidget {
const GoodDrawerButton({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
drawer: const Drawer(),
body: Builder(
builder: (inner) => TextButton(
onPressed: () => Scaffold.of(inner).openDrawer(),
child: const Text('Open'),
),
),
);
}
}
Q6Explain Flutter's layout protocol and what 'A RenderFlex overflowed by 42 pixels on the right' really means.
BasicLayout
Answer
Flutter layout is a single-pass algorithm summarised as: constraints go down, sizes come up, and the parent sets the position. A parent passes a BoxConstraints (minWidth, maxWidth, minHeight, maxHeight) to each child, the child picks a size inside those constraints and returns it, and then the parent decides where to place the child by writing into its parentData. There is no second pass and no arbitrary querying of siblings, which is what keeps layout linear in the number of render objects.
The RenderFlex overflow error is the direct consequence. Row gives its non-flexible children unbounded width on the main axis so they can be their natural size, then adds them up. If the total exceeds the width Row itself was given, Row has nowhere to put the extra pixels, so it paints the yellow and black stripes and logs the overflow in debug mode.
In release the stripes are gone but the content is still clipped, which is how these ship. The fix is never to hardcode a width, it is to give the greedy child a bounded constraint: wrap it in Expanded or Flexible so it receives the remaining space, or set overflow: TextOverflow.ellipsis on the Text, or make the Row scrollable with SingleChildScrollView(scrollDirection: Axis.horizontal). When you cannot work out where the constraint is coming from, drop a LayoutBuilder in and print the incoming BoxConstraints, or open the Widget Inspector's layout explorer, which renders the flex factors and constraints visually.
// Overflows: Text asks for its natural width, Row has none left.
Row(
children: [
Text(veryLongJobTitle),
const Icon(Icons.chevron_right),
],
);
// Fixed: Expanded hands the child a bounded maxWidth.
Row(
children: [
Expanded(
child: Text(veryLongJobTitle, overflow: TextOverflow.ellipsis),
),
const Icon(Icons.chevron_right),
],
);
// Debugging: print exactly what constraints arrive here.
LayoutBuilder(
builder: (context, constraints) {
debugPrint('$constraints'); // BoxConstraints(0<=w<=360, 0<=h<=Infinity)
return const SizedBox.shrink();
},
);
Key Points
- Constraints down, sizes up, parent positions the child: one pass
- Row gives non-flex children unbounded main-axis space
- Fix overflow with Expanded/Flexible, ellipsis, or a scroll view
- LayoutBuilder and the Inspector's layout explorer show real constraints
Q7When do you actually need a Key, and how do ValueKey, ObjectKey, UniqueKey and GlobalKey differ?
BasicKeys
Answer
Keys only matter when widgets of the same type move around within the same parent. Flutter matches new widgets to existing elements by position first, then by runtimeType and key. Reorder, insert or remove items in a list of stateful children with no keys, and the element at index 0 gets updated with the widget that used to be at index 1, so the State object (scroll offsets, TextEditingController contents, animation progress, checkbox values) stays behind on the wrong row.
Add a ValueKey carrying the item id and Flutter matches by identity instead, moving the element with the item. ValueKey wraps any value with a working == and hashCode, typically an id or a string, and is what you want ninety percent of the time. ObjectKey compares by identity of the wrapped object, useful when your model has no stable id but you do hold the same instance.
UniqueKey is never equal to anything, including itself on the next build, so it forces a brand new element and a fresh State every time, which is how you deliberately restart an animation or force AnimatedSwitcher to treat content as different. GlobalKey is a different animal: it identifies an element uniquely across the whole app, lets you reach currentState or currentContext from outside the subtree (the classic GlobalKey<FormState> pattern), and allows an element to be moved between parents without losing state. GlobalKeys are expensive and leak-prone, so treat them as a last resort, never as your default.
// Reorderable list: without ValueKey, checkbox state follows the index.
ListView(
children: [
for (final job in jobs)
JobTile(key: ValueKey(job.id), job: job),
],
);
// UniqueKey forces a fresh element, so AnimatedSwitcher animates.
AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
child: Text(status, key: ValueKey(status)),
);
// GlobalKey: reach a State from outside its subtree.
final _formKey = GlobalKey<FormState>();
void _submit() {
if (_formKey.currentState?.validate() ?? false) {
_formKey.currentState!.save();
}
}
Q8What does setState() actually do, and why do you see 'setState() called after dispose()'?
BasicState
Answer
setState does two things: it synchronously runs the callback you pass, then it calls Element.markNeedsBuild on the owning element, which adds that element to the BuildOwner's dirty list and schedules a frame. It does not rebuild immediately, and it does not rebuild the parent. When the next frame is drawn, that element and its subtree rebuild.
The mutation itself is ordinary Dart, so `_counter++` outside setState changes the value but schedules nothing and the UI silently stays stale, which is a favourite interview trick. The 'setState() called after dispose()' error means a callback outlived the widget: an HTTP response, a Timer, a stream event or an animation listener fired after the State was disposed. The correct fix is to cancel the source in dispose, and the pragmatic guard is `if (!mounted) return;` immediately after every await before you touch setState.
Note that State.mounted and BuildContext.mounted are different properties, and inside a State you want the former. Two more things interviewers probe: never make the setState callback async, because setState takes a synchronous callback and the await completes long after the rebuild has already happened, and never call setState inside build, which throws 'setState() or markNeedsBuild() called during build'. The pattern for reacting to something during a frame is WidgetsBinding.instance.addPostFrameCallback, which defers the work to just after the current frame is committed.
Future<void> _load() async {
final data = await api.fetchJobs();
if (!mounted) return; // State.mounted, not context.mounted
setState(() => _jobs = data);
}
// Wrong: mutation without setState schedules no frame.
void _brokenIncrement() {
_counter++;
}
// Wrong: setState's callback must be synchronous.
void _alsoBroken() {
setState(() async {
_jobs = await api.fetchJobs(); // rebuild already happened
});
}
// Reacting after the current frame instead of during build.
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _scrollToSelected();
});
}
Key Points
- setState runs the callback, then markNeedsBuild, then waits for the next frame
- It dirties only this element, not the parent
- Guard with `if (!mounted) return;` after every await
- Use addPostFrameCallback instead of calling setState during build
Q9What is the difference between hot reload and hot restart, and what does hot reload refuse to pick up?
BasicTooling
Answer
Hot reload (r in the terminal, or the lightning icon) sends the changed Dart source to the running Dart VM, which recompiles those libraries into new kernel files and injects them into the isolate. The app's state, navigation stack and open connections all survive, and Flutter then calls reassemble on every element, which forces a full rebuild of the widget tree with the new code. Hot restart (R) throws away all Dart state, restarts the isolate and runs main() again, but skips the native rebuild, so it takes a couple of seconds rather than the thirty to sixty seconds of a full `flutter run`.
Hot reload deliberately does not handle several cases, and knowing them saves hours of confused debugging: changes to main() or to the initState of an already-mounted widget do not re-run, so anything set up at startup keeps the old value; global variables and static fields keep their current values because they are only initialised once; changing a class into an enum or altering a type's generic parameters is a structural change that requires a restart; adding or removing fields from a class often needs a restart; and anything outside Dart, such as native plugin code, AndroidManifest.xml, Info.plist, pubspec.yaml assets or new dependencies, needs a full rebuild. Both modes exist only in debug mode, because they depend on the JIT-capable Dart VM. Profile and release builds are AOT-compiled, which is exactly why you must never measure performance in debug: the debug build runs interpreted or JIT-compiled Dart and is several times slower.
Key Points
- Hot reload injects new code and keeps state; hot restart resets Dart state
- main() and initState of already-mounted widgets do not re-run on reload
- Statics, globals, enum/class changes and generic changes need a restart
- Native code, manifests, pubspec and new assets need a full rebuild
- Neither exists in profile or release builds, which are AOT-compiled
Q10When should you use ListView.builder over ListView, and what do itemExtent and prototypeItem do?
BasicScrolling
Answer
ListView with a children list builds every child eagerly, before the frame is laid out, because the list literal has to be evaluated to construct the widget. For twenty settings rows that is fine. For two thousand job cards it means two thousand build calls and two thousand render objects on the first frame, and the app drops a visible chunk of frames.
ListView.builder takes an itemBuilder and an itemCount and builds lazily: only the children inside the viewport, plus a margin controlled by cacheExtent, exist at any moment. Everything outside is disposed, which is also why per-item State inside a builder list is not durable and should be lifted out. The other lever is measurement cost.
By default the sliver has to lay out each child to discover its height before it knows the scroll extent, which makes scrollbar behaviour approximate and makes jumping to an index expensive. If every row is the same height, pass itemExtent and the sliver computes positions arithmetically with no child measuring at all, which is the single biggest scroll performance win in most Flutter apps. If rows vary but are structurally similar, prototypeItem lets Flutter measure one representative child and use that extent. Use ListView.separated when you want dividers without index arithmetic, and reach for CustomScrollView with slivers as soon as you need a collapsing app bar or several list sections in one scroll view.
// Eager: every child built up front.
ListView(children: [for (final j in jobs) JobCard(job: j)]);
// Lazy: only the viewport plus cacheExtent.
ListView.builder(
itemCount: jobs.length,
cacheExtent: 400,
itemBuilder: (context, i) => JobCard(job: jobs[i]),
);
// Fixed-height rows: no child measuring at all.
ListView.builder(
itemCount: jobs.length,
itemExtent: 88,
itemBuilder: (context, i) => JobCard(job: jobs[i]),
);
// Similar but not identical heights: measure one prototype.
ListView.builder(
itemCount: jobs.length,
prototypeItem: const JobCard.placeholder(),
itemBuilder: (context, i) => JobCard(job: jobs[i]),
);
// Dividers without index maths.
ListView.separated(
itemCount: jobs.length,
itemBuilder: (context, i) => JobCard(job: jobs[i]),
separatorBuilder: (context, i) => const Divider(height: 1),
);
Key Points
- ListView(children:) builds everything eagerly; builder is lazy
- cacheExtent controls how far outside the viewport children are kept
- itemExtent removes per-child measurement entirely
- prototypeItem is the middle ground for near-uniform rows
Q11How do Expanded, Flexible and Spacer behave inside Row and Column, and what causes 'Vertical viewport was given unbounded height'?
BasicLayout
Answer
Row and Column lay out in two rounds. First they give every non-flexible child unbounded space on the main axis and record the sizes that come back. Then they divide the remaining space among the flexible children according to their flex factors.
Expanded is Flexible with fit: FlexFit.tight, meaning the child is forced to exactly fill its share. Flexible defaults to FlexFit.loose, meaning the child may be smaller than its share if its natural size is smaller. Spacer is simply an Expanded wrapping an empty SizedBox, so it eats leftover space and pushes siblings apart.
Both Expanded and Flexible are ParentDataWidgets that only work directly inside a Flex, and putting one inside a Stack or a Container produces 'Incorrect use of ParentDataWidget'. The unbounded height error comes from the first round. A Column gives its children unbounded height on the main axis, and a scrollable such as ListView wants to be as tall as its constraint allows, so when it receives an infinite maxHeight it throws 'Vertical viewport was given unbounded height'.
There are three correct fixes and one bad one. Wrap the list in Expanded so it gets the leftover bounded height, which is what you want almost always. Or wrap it in a SizedBox with an explicit height.
Or set shrinkWrap: true with NeverScrollableScrollPhysics, which sizes the list to its content but defeats lazy building and is only acceptable for short, non-scrolling lists. The bad fix is nesting scroll views without a CustomScrollView, which produces two competing scroll positions.
// Throws: ListView receives maxHeight == Infinity inside a Column.
Column(children: [const Header(), ListView(children: rows)]);
// Fix 1 (preferred): give the list the remaining bounded height.
Column(
children: [
const Header(),
Expanded(child: ListView.builder(itemCount: rows.length, itemBuilder: build)),
],
);
// Fix 2: short, non-scrolling content only. Loses lazy building.
ListView(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
children: rows,
);
// flex and fit in one place
Row(
children: [
Flexible(flex: 2, fit: FlexFit.loose, child: Avatar()), // may be smaller
Expanded(flex: 3, child: Details()), // must fill
const Spacer(), // Expanded + SizedBox
],
);
Q12Why is calling an async API directly inside build() a bug, and what should you use instead?
BasicAsync UI
Answer
build can run many times per frame sequence, on parent rebuilds, on inherited widget changes, on media query changes and on every setState. If you call api.fetchJobs() inside build, you fire a new network request each time, you often create an infinite loop (response arrives, setState, rebuild, new request), and you burn the user's data plan. The rule is that build must be pure: it reads state and returns widgets, it never starts work.
The standard fix for a one-shot load is to create the Future once in initState, store it in a `late final Future<T>` field, and hand that stored future to FutureBuilder. Passing `api.fetchJobs()` inline to FutureBuilder's future parameter is the exact same bug in disguise, and it is the most common Flutter code review comment in the world. For continuously updating data use StreamBuilder with a stream created once, and for anything with real business logic use a state management solution where the fetch lives in a notifier or bloc rather than in the widget at all.
In the builder, handle all four states explicitly: waiting, error, empty data and populated data. A snapshot with hasData false and hasError false during ConnectionState.done means the future resolved to null, and shipping a UI that shows a spinner forever in that case is a classic production bug that shows up as 'the app hangs on slow networks' in Play Store reviews.
class _JobsPageState extends State<JobsPage> {
late final Future<List<Job>> _future;
@override
void initState() {
super.initState();
_future = api.fetchJobs(); // created exactly once
}
@override
Widget build(BuildContext context) {
return FutureBuilder<List<Job>>(
future: _future, // NOT api.fetchJobs() inline
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) {
return ErrorView(error: snap.error!, onRetry: _retry);
}
final jobs = snap.data ?? const <Job>[];
if (jobs.isEmpty) return const EmptyState();
return JobList(jobs: jobs);
},
);
}
}
Key Points
- build must be pure and side-effect free; it can run many times
- Create the Future once in initState, not inline in the future: parameter
- Handle waiting, error, empty and populated explicitly
- For repeated or business-critical loads, move the fetch out of the widget
Q13Which objects must you dispose in a State, and what breaks when you forget?
BasicMemory
Answer
Anything that holds a subscription, a ticker, a native resource or a listener registration must be released in dispose, and the list is longer than most candidates expect: AnimationController, TextEditingController, ScrollController, PageController, TabController, FocusNode, StreamSubscription, Timer, VideoPlayerController, and any ChangeNotifier you created locally. Forgetting has three distinct failure modes. First, a retained State keeps its whole subtree of elements and render objects alive, so memory grows every time the user opens and closes the screen, which shows up in DevTools as a rising Dart heap that never returns to baseline after a garbage collection.
Second, an uncancelled subscription keeps firing into a dead widget, giving you 'setState() called after dispose()' crashes in Crashlytics that are hard to reproduce locally because they need slow network. Third, an AnimationController with a live ticker keeps requesting frames forever, so the app never goes idle, the vsync callbacks keep the CPU awake and battery drain complaints follow. Order matters: cancel or dispose your own resources first, then call super.dispose() last, because the framework marks the State unmounted there.
For animation, mix in SingleTickerProviderStateMixin (or TickerProviderStateMixin for more than one controller) and pass `vsync: this`, which ties the ticker to the widget's visibility so it automatically pauses when the route is not on screen. In interviews this question is often asked as 'your app slows down after twenty minutes of navigation, where do you look', and dispose hygiene is the expected first answer.
class _EditorState extends State<Editor> with SingleTickerProviderStateMixin {
late final AnimationController _anim;
late final TextEditingController _text;
late final ScrollController _scroll;
late final StreamSubscription<Event> _sub;
Timer? _autosave;
@override
void initState() {
super.initState();
_anim = AnimationController(vsync: this, duration: const Duration(milliseconds: 300));
_text = TextEditingController();
_scroll = ScrollController()..addListener(_onScroll);
_sub = bus.events.listen(_onEvent);
_autosave = Timer.periodic(const Duration(seconds: 30), (_) => _save());
}
@override
void dispose() {
_autosave?.cancel();
_sub.cancel();
_scroll.removeListener(_onScroll);
_scroll.dispose();
_text.dispose();
_anim.dispose();
super.dispose(); // always last
}
@override
Widget build(BuildContext context) => const SizedBox.shrink();
}
Q14How do you navigate between screens with Navigator, and how do you return a result from a pushed route?
BasicNavigation
Answer
Navigator is a stack of Route objects managed by an Overlay. Navigator.of(context).push(MaterialPageRoute(builder: ...)) pushes a screen with the platform-correct transition (slide from right on iOS, fade-through on Android under Material 3), and pop removes it. Both push and pop are generic and typed: push returns a Future<T?> that completes when the pushed route pops, and pop(result) supplies that value, so passing data back is just awaiting the push.
The Future completes with null when the user dismisses with the system back gesture, so you must handle null and never use a bare `!`. Beyond push and pop there is pushReplacement (swap the top route, used after login), pushAndRemoveUntil with a predicate (clear the stack down to home), popUntil(ModalRoute.withName('/home')), and maybePop, which respects a WillPopScope or PopScope veto. Named routes via the routes map are fine for small apps, but they do not carry typed arguments (everything comes through settings.arguments as Object?) and they do not handle nested navigation or deep links well, which is why production apps move to go_router or another Router API package. Two gotchas worth naming: pushing from a context above the Navigator throws, so use a context from inside MaterialApp's builder; and for confirming an exit, current Flutter versions want PopScope with canPop and onPopInvokedWithResult rather than the deprecated WillPopScope, because predictive back on Android needs to know in advance whether the pop is allowed.
// Push and await a typed result.
Future<void> _pickCity(BuildContext context) async {
final city = await Navigator.of(context).push<String>(
MaterialPageRoute(builder: (_) => const CityPicker()),
);
if (!context.mounted || city == null) return; // user pressed back
setState(() => _city = city);
}
// Inside CityPicker
onTap: () => Navigator.of(context).pop('Bengaluru'),
// After login: replace, do not stack.
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const HomePage()),
);
// Clear everything back to the root.
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const HomePage()),
(route) => false,
);
// Confirm before leaving (replaces WillPopScope).
PopScope(
canPop: !_hasUnsavedChanges,
onPopInvokedWithResult: (didPop, result) {
if (!didPop) _showDiscardDialog();
},
child: form,
);
Key Points
- push returns Future<T?>; pop(result) completes it, null on system back
- pushReplacement, pushAndRemoveUntil and popUntil manage the stack shape
- Named routes lose type safety; go_router is the production answer
- PopScope replaces WillPopScope and supports Android predictive back
Q15How does pub resolve dependency versions, and what do you do about a 'version solving failed' error?
BasicDependencies
Answer
pubspec.yaml declares version ranges, pubspec.lock records the exact resolved versions, and `flutter pub get` runs a solver that finds one version of every package satisfying every constraint at once. The caret syntax `^1.4.2` means at least 1.4.2 and below 2.0.0, so the solver can pick up patches and minors automatically. Commit pubspec.lock for applications so every developer and your CI build the same bytes, and do not commit it for packages you publish, because there the consumer's solver decides.
Version solving fails when two dependencies pin incompatible ranges of a shared transitive package. The correct first step is to read the error, which names the conflicting packages and constraints, then run `flutter pub deps --style=compact` to see who pulls what. Fix it by upgrading the package that has an older constraint, or by loosening your own constraint.
Use dependency_overrides only as a temporary escape hatch, because it silences the solver rather than satisfying it and can leave you running an API-incompatible version at runtime, which shows up as a NoSuchMethodError in release. `flutter pub upgrade --major-versions` rewrites pubspec.yaml to the newest majors and is the sane way to do a periodic dependency sweep. `flutter pub outdated` shows current, upgradable and resolvable columns so you can see whether a package is held back by you or by a transitive constraint. Also remember that the `environment: sdk:` and `flutter:` constraints participate in solving, so an old Flutter SDK on the CI runner can cause a failure that never reproduces on your machine.
# pubspec.yaml
environment:
sdk: '>=3.5.0 <4.0.0'
flutter: '>=3.24.0'
dependencies:
flutter:
sdk: flutter
dio: ^5.7.0
go_router: ^14.0.0
riverpod: ^2.5.0
dev_dependencies:
flutter_test:
sdk: flutter
build_runner: ^2.4.0
mocktail: ^1.0.0
# Temporary only: bypasses the solver, verify at runtime.
# dependency_overrides:
# collection: 1.18.0
# Useful commands
# flutter pub outdated
# flutter pub deps --style=compact
# flutter pub upgrade --major-versions
Q16How does Material 3 theming work in Flutter, and how do you apply a brand palette across an app?
BasicTheming
Answer
Recent Flutter versions default useMaterial3 to true, so ThemeData now expects a ColorScheme rather than the old primarySwatch. The idiomatic setup is ColorScheme.fromSeed(seedColor: brandColor), which runs the Material 3 tonal palette algorithm and derives a full, accessibility-checked set of roles: primary, onPrimary, primaryContainer, secondary, tertiary, surface, surfaceContainerHighest, error and their on-colours. You then build a matching dark scheme by passing brightness: Brightness.dark to the same seed, so light and dark stay in sync from one source of truth.
Component defaults come from theme objects like ElevatedButtonThemeData, CardThemeData, InputDecorationTheme and AppBarTheme, and setting those once centrally is what stops designers filing 'the button radius is different on this screen' tickets. Text styles come from TextTheme with the Material 3 names (displayLarge down to labelSmall), and Theme.of(context).textTheme.titleMedium is the correct way to read them rather than hardcoding TextStyle everywhere. For brand tokens that Material has no slot for, such as a success green or a gradient used by your design system, use ThemeExtension so the values live in ThemeData, animate correctly with lerp, and are read through Theme.of(context).extension<BrandColors>(). Two practical notes: Material 3 changed several defaults, notably that Card and AppBar surface tint replaces elevation shadow, and that the deprecated `background` and `onBackground` roles were folded into `surface`, which is the migration most teams hit when upgrading an older codebase.
@immutable
class BrandColors extends ThemeExtension<BrandColors> {
const BrandColors({required this.success});
final Color success;
@override
BrandColors copyWith({Color? success}) =>
BrandColors(success: success ?? this.success);
@override
BrandColors lerp(BrandColors? other, double t) =>
BrandColors(success: Color.lerp(success, other?.success, t)!);
}
final light = ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF0B57D0)),
extensions: const [BrandColors(success: Color(0xFF1B873B))],
inputDecorationTheme: const InputDecorationTheme(filled: true),
);
final dark = ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF0B57D0),
brightness: Brightness.dark,
),
extensions: const [BrandColors(success: Color(0xFF6FD08C))],
);
MaterialApp(theme: light, darkTheme: dark, themeMode: ThemeMode.system);
// Reading a custom token
final success = Theme.of(context).extension<BrandColors>()!.success;
Key Points
- ColorScheme.fromSeed generates the full Material 3 tonal palette
- Build dark by passing brightness to the same seed colour
- Centralise component styling in the *ThemeData objects, not per screen
- ThemeExtension carries brand tokens Material has no role for
Q17What is the difference between MediaQuery.of(context) and MediaQuery.sizeOf(context)?
BasicResponsive UI
Answer
MediaQuery is an InheritedModel, not a plain InheritedWidget, which means dependents can subscribe to a single aspect of it rather than to the whole thing. MediaQuery.of(context) registers a dependency on the entire MediaQueryData, so your widget rebuilds when anything in it changes: the size, the text scale factor, the padding, the view insets, the platform brightness, the accessibility flags. On mobile that matters a lot, because opening the soft keyboard changes viewInsets on every animation frame of the keyboard slide, so a widget that only wanted the screen width ends up rebuilding a dozen times while the keyboard appears.
MediaQuery.sizeOf(context) subscribes only to the size aspect, and there is a family of these: paddingOf, viewInsetsOf, viewPaddingOf, textScalerOf, platformBrightnessOf, orientationOf, devicePixelRatioOf. Using the aspect-specific accessor is strictly better and costs nothing, so treat MediaQuery.of as a code smell in new code. Two related points interviewers like.
First, MediaQuery.of is not the right tool for widget-level responsiveness at all: it tells you about the window, not about the box your widget was given, so inside a split-view or a side panel it lies. LayoutBuilder gives you the real incoming constraints and is the correct tool for adaptive layouts. Second, textScaler replaced the old textScaleFactor double, because Android 14 and recent iOS support non-linear font scaling, and multiplying by a factor no longer reproduces the system behaviour. Use MediaQuery.textScalerOf(context).scale(fontSize) if you must compute sizes yourself.
// Rebuilds on every keyboard animation frame.
final width = MediaQuery.of(context).size.width;
// Subscribes to the size aspect only.
final width2 = MediaQuery.sizeOf(context).width;
final bottomInset = MediaQuery.viewInsetsOf(context).bottom;
final scaler = MediaQuery.textScalerOf(context);
// Widget-level responsiveness: use the real constraints, not the window.
LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth >= 900) {
return const Row(children: [JobList(), Expanded(child: JobDetail())]);
}
return const JobList();
},
);
Q18Which Flutter CLI commands and DevTools views do you use in a normal working day?
BasicTooling
Answer
The daily set is small. `flutter run` for debug with hot reload, `flutter run --profile` on a physical device whenever you are measuring anything, and `flutter run --release` to sanity check the shipping build. `flutter analyze` runs the static analyzer against analysis_options.yaml and should be a CI gate, while `dart fix --apply` mechanically applies every fix the analyzer knows how to make, which is how you clear hundreds of deprecation warnings after an SDK upgrade. `dart format .` keeps diffs clean. `flutter test` runs unit and widget tests, `flutter test --coverage` emits coverage/lcov.info, and `flutter test integration_test` drives a real device. `flutter doctor -v` is the first thing to run when a build breaks on a new machine or after an Xcode update. `flutter clean` deletes build artefacts and is the standard cure for stale Gradle or CocoaPods state, though it forces a slow rebuild so do not reach for it reflexively. For builds, `flutter build appbundle --release` for Play, `flutter build ipa --export-method app-store` for the App Store, and `--flavor` plus `--dart-define-from-file` for environment separation. DevTools is opened with `dart devtools` or from the IDE, and the views that matter are the Widget Inspector (with 'Track widget rebuilds' and the layout explorer), Performance (frame chart split into UI and raster time, plus the frame analysis tab that flags shader compilation and expensive builds), CPU Profiler, Memory (heap snapshots, allocation tracing, diffing two snapshots to find leaks), and Network. Being able to name the Performance view's UI versus raster split is a reliable senior signal.
# Daily loop
flutter run -d <device-id>
flutter analyze
dart fix --apply && dart format .
flutter test --coverage
# Measure on a real phone, never on debug or an emulator
flutter run --profile --trace-skia
# Environment separation
flutter run --flavor staging --dart-define-from-file=env/staging.json
# Shipping
flutter build appbundle --release \
--obfuscate --split-debug-info=build/symbols
flutter build ipa --release --export-method app-store
# When the toolchain misbehaves
flutter doctor -v
flutter clean && flutter pub get
Key Points
- Profile mode on a real device is the only valid place to measure performance
- flutter analyze in CI, dart fix --apply after SDK upgrades
- DevTools: Inspector, Performance (UI vs raster), CPU, Memory, Network
- flutter clean is a last resort, not a habit
Q19Compare Provider, Riverpod and BLoC. How do you choose one for a production Flutter app in 2026?
IntermediateState Management
Answer
Provider is a thin, well-understood wrapper over InheritedWidget. ChangeNotifierProvider exposes a ChangeNotifier, context.watch subscribes and rebuilds, context.read reads once without subscribing, and Selector narrows the rebuild to one derived field. It is the lowest-ceremony option and still perfectly reasonable for small and medium apps, but everything is looked up through BuildContext, so you get runtime ProviderNotFoundException instead of compile errors, and testing business logic means building a widget tree.
Riverpod is the same author's answer to those problems: providers are top-level objects with no BuildContext dependency, so they are compile-time safe, testable in a plain Dart test, and composable (one provider can watch another and rebuild automatically). With riverpod_generator you annotate a function or class with @riverpod, build_runner emits the provider, and you get AsyncValue for free, which models loading, data and error as a sealed union so you cannot forget a branch. Riverpod is the default recommendation for new apps in 2026.
BLoC (flutter_bloc) is heavier: you model explicit Events and States, the Bloc maps one to the other, and BlocBuilder or BlocSelector renders. That ceremony pays off on large teams and regulated flows, because every state transition is an object you can log, replay and assert on with bloc_test, and the event stream is a natural audit trail. GetX still appears in Indian job descriptions and legacy codebases; it bundles state, routing and DI behind a service locator, which is fast to write and hard to test, so know it but do not propose it for a greenfield app. The honest interview answer is that the choice matters less than consistency: pick one, put business logic outside widgets, and keep rebuild scope tight.
// Riverpod with codegen: compile-safe, testable without a widget tree.
@riverpod
class JobsController extends _$JobsController {
@override
Future<List<Job>> build() => ref.watch(apiProvider).fetchJobs();
Future<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(() => ref.read(apiProvider).fetchJobs());
}
}
class JobsView extends ConsumerWidget {
const JobsView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final jobs = ref.watch(jobsControllerProvider);
return jobs.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, st) => ErrorView(error: e),
data: (list) => JobList(jobs: list),
);
}
}
// Same idea in flutter_bloc: explicit, auditable transitions.
// BlocBuilder<JobsBloc, JobsState>(builder: (context, state) => ...)
Key Points
- Provider: simplest, context-based lookup, runtime errors
- Riverpod: compile-safe, no BuildContext, AsyncValue, codegen with @riverpod
- BLoC: explicit events and states, best for large teams and audit trails
- GetX: common in legacy Indian codebases, weak testability
- Consistency and keeping logic out of widgets matter more than the brand
Q20How does InheritedWidget actually propagate changes, and what is the difference between dependOnInheritedWidgetOfExactType and getInheritedWidgetOfExactType?
IntermediateState Propagation
Answer
InheritedWidget is the mechanism behind Theme, MediaQuery, Navigator, DefaultTextStyle and every provider package. When an element is inflated, it records a map of the InheritedElements above it. Calling dependOnInheritedWidgetOfExactType<T>() from a descendant does two things: it returns the nearest T, and it registers this element as a dependent of that InheritedElement.
On rebuild, the framework constructs the new InheritedWidget and calls updateShouldNotify(oldWidget) on it. If that returns true, every registered dependent is marked dirty and rebuilds. If it returns false, nothing rebuilds, which is why a badly written updateShouldNotify that always returns true is a real performance bug and one that always returns false produces a UI that never updates.
The lookup itself is O(1) because of the ancestor map, not a tree walk, which is why Theme.of is cheap even deep in a tree. getInheritedWidgetOfExactType<T>() returns the same widget without registering a dependency, which is what you want inside initState or dispose, or when you need a one-time read that must not cause rebuilds. Note that dependOnInheritedWidgetOfExactType must not be called from dispose, and calling it in initState throws in debug because dependencies are not yet established, which is exactly why didChangeDependencies exists. For finer granularity, InheritedModel lets dependents subscribe to named aspects so only the widgets that care about a specific slice rebuild, and MediaQuery uses precisely this to give you sizeOf and viewInsetsOf.
class AuthScope extends InheritedWidget {
const AuthScope({super.key, required this.user, required super.child});
final User? user;
static User? maybeOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<AuthScope>()?.user;
// One-time read with no subscription: safe in initState.
static User? readOnly(BuildContext context) =>
context.getInheritedWidgetOfExactType<AuthScope>()?.user;
@override
bool updateShouldNotify(AuthScope oldWidget) => oldWidget.user != user;
}
class _ProfileState extends State<Profile> {
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Correct place for an inherited lookup that should re-run on change.
_user = AuthScope.maybeOf(context);
}
@override
Widget build(BuildContext context) => Text(_user?.name ?? 'Guest');
}
Q21How do you narrow rebuild scope with ValueListenableBuilder, AnimatedBuilder and the child parameter?
IntermediatePerformance
Answer
The cheapest rebuild is the one that never happens, and Flutter gives you three tools to shrink the dirty region. First, builder widgets that own the subscription. ValueListenableBuilder<T> listens to a ValueNotifier and rebuilds only its builder callback, so a counter changing sixty times a second does not touch the surrounding page.
AnimatedBuilder and ListenableBuilder do the same for AnimationController and any Listenable, and StreamBuilder for streams. Wrapping the smallest possible widget in one of these is usually a bigger win than any micro-optimisation inside build. Second, the `child` parameter.
AnimatedBuilder, ValueListenableBuilder and ListenableBuilder all accept a child that is built once outside the builder and passed in, so an expensive static subtree inside an animated wrapper is not rebuilt on every tick. This is the single most under-used API in Flutter, and interviewers who work on performance ask about it specifically. Third, structural extraction.
Splitting a large build method into small const widget classes creates rebuild boundaries, because a const subtree that the parent passes unchanged is skipped entirely by Element.updateChild. That is why 'extract a widget' beats 'extract a method': a method call is inlined into the parent's build and rebuilds with it, while a widget class gets its own element. On top of that, Provider's Selector, Riverpod's ref.watch(provider.select(...)) and flutter_bloc's BlocSelector let you subscribe to one derived field so a state object changing an unrelated property does not rebuild your widget. Verify all of this in DevTools with 'Track widget rebuilds' turned on.
final _count = ValueNotifier<int>(0);
// Only the Text rebuilds; the expensive chart is built once.
ValueListenableBuilder<int>(
valueListenable: _count,
child: const ExpensiveChart(), // built once, passed through
builder: (context, value, child) {
return Column(
children: [
Text('$value applications'),
child!,
],
);
},
);
// Same trick for animation.
AnimatedBuilder(
animation: _controller,
child: const ProfileCard(),
builder: (context, child) => Opacity(
opacity: _controller.value,
child: child,
),
);
// Subscribe to one field, not the whole state object.
final name = ref.watch(userProvider.select((u) => u.name));
Key Points
- Builder widgets confine the rebuild to their own callback
- The child parameter hoists expensive subtrees out of the rebuild
- Extract widgets, not methods: methods inline into the parent's build
- select / Selector / BlocSelector subscribe to one derived field
Q22Walk through the Flutter frame pipeline. What is the difference between UI thread time and raster thread time?
IntermediateRendering
Answer
A frame starts with a vsync signal from the platform. The engine wakes the UI thread, which runs the Dart isolate and executes, in order: transient callbacks (animation tickers advance), then the build phase (dirty elements rebuild), then layout (RenderObject.performLayout on anything marked needs-layout), then paint (record display list commands into a Layer tree), then compositing, and finally the layer tree is handed to the raster thread. The raster thread turns those recorded commands into GPU work through Impeller, submits it, and the compositor puts the buffer on screen.
Because the two threads are separate, jank has two very different signatures and DevTools shows them as two bars per frame. Long UI time means your Dart is slow: too many widgets rebuilding, an expensive layout such as nested IntrinsicWidth or unbounded ListView shrinkWrap, heavy JSON decode on the main isolate, or synchronous file I/O. Long raster time means the GPU work is slow: large or unclipped Opacity and ShaderMask layers, saveLayer calls from BackdropFilter or clipping with anti-aliasing, oversized images being scaled at draw time, huge blur radii, or on Skia builds a shader compiling for the first time.
The fixes are different, which is why an interviewer will not accept 'I would optimise the widget tree' as an answer to raster jank. At 60Hz you have about 16.6ms of total budget per frame and both threads must fit inside it; on 120Hz ProMotion or the high refresh Android phones common in India that drops to roughly 8.3ms, which is why animations that felt fine on a test device can jank on a flagship.
Key Points
- vsync, then UI thread: animate, build, layout, paint, composite
- Raster thread turns the layer tree into GPU commands
- Long UI time = slow Dart; long raster time = slow GPU work
- Budget is roughly 16.6ms at 60Hz and 8.3ms at 120Hz
- DevTools Performance shows the two bars separately per frame
Q23What does RepaintBoundary do, and when does adding one make things worse?
IntermediatePerformance
Answer
Painting in Flutter is organised into layers. When a render object is marked as needing paint, Flutter repaints the whole enclosing layer, which by default can be a large slice of the screen. RepaintBoundary inserts a new composited layer at that point, so anything inside it repaints independently of everything outside, and the untouched neighbours are reused as cached textures by the compositor.
The classic win is a small, constantly animating element inside an otherwise static page: a progress spinner, a live price ticker, a shimmering skeleton, or a video surface. Without a boundary, that ticker forces the whole page to repaint sixty times a second; with one, only its own layer does. ListView and other scrollables already insert RepaintBoundary around each child by default (addRepaintBoundaries is true), which is why manually wrapping list items usually does nothing.
The cost is real, though. Each boundary is an extra layer, which means extra GPU memory for its texture and extra compositing work to blend it, and too many boundaries turn a raster-cheap frame into an expensive one. So the rule is: add one around a small region that repaints far more often than its surroundings, and measure.
To find candidates, enable `debugRepaintRainbowEnabled = true` and watch which regions flash colour on every frame; if a large area changes colour when only a tiny thing is animating, that is your boundary site. RepaintBoundary also has a second use: its RenderRepaintBoundary exposes toImage(), which is how you screenshot a widget subtree for share cards or golden captures.
// Isolate the animating element so the static page does not repaint.
Stack(
children: [
const ExpensiveStaticBackground(),
Positioned(
right: 16,
bottom: 16,
child: RepaintBoundary(child: LivePriceTicker(symbol: 'NIFTY')),
),
],
);
// Find candidates: regions that flash on every frame are repainting.
void main() {
debugRepaintRainbowEnabled = true; // debug builds only
runApp(const App());
}
// Second use: capture a subtree as a PNG for a share card.
final key = GlobalKey();
Future<Uint8List> capture() async {
final boundary =
key.currentContext!.findRenderObject()! as RenderRepaintBoundary;
final image = await boundary.toImage(pixelRatio: 3);
final bytes = await image.toByteData(format: ImageByteFormat.png);
return bytes!.buffer.asUint8List();
}
Key Points
- Creates an independent composited layer so repaints stay local
- Best for a small region animating inside a large static one
- ListView children already get boundaries automatically
- Each boundary costs GPU memory and compositing time
- debugRepaintRainbowEnabled shows you where to put one
Q24How do you keep image loading from blowing up memory in a Flutter app?
IntermediateMemory
Answer
The trap is that Flutter decodes an image at its intrinsic resolution regardless of the box you draw it into. A 4000x3000 JPEG from a user upload decodes to roughly 4000 * 3000 * 4 bytes of RGBA, about 48MB in memory, even if you display it in a 100x100 avatar. Ten of those in a list and the app is killed by the OS on a mid-range Android device, which is a very common crash pattern for Indian consumer apps where a large share of installs sit on 3GB and 4GB devices.
The fix is to decode at the size you will draw: pass cacheWidth and cacheHeight to Image.network or Image.asset, or wrap the provider in ResizeImage. Those values are in physical pixels, so multiply your logical size by MediaQuery.devicePixelRatioOf(context) rather than passing 100. Beyond that, Flutter keeps an ImageCache in PaintingBinding.instance.imageCache, holding up to 1000 images and 100MB by default; you can lower maximumSizeBytes on memory-constrained builds and call evict() when you know a URL is stale.
Use cached_network_image or a similar package for disk caching plus placeholders and error widgets, and add a real errorBuilder so a 404 shows a fallback rather than an exception box. For long lists, prefer thumbnails generated server side over full-resolution originals; no client-side trick beats not downloading the bytes. Finally, precacheImage in didChangeDependencies for hero images you are about to show, so the first frame of a transition is not a blank box.
// Decode at draw size, in physical pixels.
final dpr = MediaQuery.devicePixelRatioOf(context);
Image.network(
user.avatarUrl,
width: 48,
height: 48,
cacheWidth: (48 * dpr).round(),
cacheHeight: (48 * dpr).round(),
errorBuilder: (_, __, ___) => const CircleAvatar(child: Icon(Icons.person)),
);
// Same idea through a provider.
Image(
image: ResizeImage(
NetworkImage(url),
width: (48 * dpr).round(),
),
);
// Tighten the global cache on low-memory builds.
PaintingBinding.instance.imageCache
..maximumSizeBytes = 40 << 20 // 40MB
..maximumSize = 200;
// Warm a hero image before the transition starts.
@override
void didChangeDependencies() {
super.didChangeDependencies();
precacheImage(NetworkImage(widget.heroUrl), context);
}
Q25What are slivers, and when do you have to drop CustomScrollView instead of nesting a ListView?
IntermediateScrolling
Answer
A sliver is a scrollable region that talks a different layout protocol from a normal box. Where a RenderBox receives BoxConstraints and returns a Size, a RenderSliver receives SliverConstraints (scrollOffset, remainingPaintExtent, overlap, cacheExtent, axisDirection) and returns SliverGeometry (scrollExtent, paintExtent, layoutExtent, maxPaintExtent). That protocol is what makes lazy scrolling possible: a sliver only builds and lays out the children whose scroll offsets fall inside the viewport plus the cacheExtent, so a list of fifty thousand rows costs the same as a list of twenty.
ListView, GridView and SingleChildScrollView are just box widgets that wrap a viewport around one sliver for you. You need CustomScrollView the moment a single scroll gesture must drive more than one of those regions together: a collapsing SliverAppBar over a SliverList, a header grid followed by a list, a pinned SliverPersistentHeader, or a pull-to-refresh CupertinoSliverRefreshControl. The common wrong answer is to nest a ListView inside a Column inside a SingleChildScrollView.
That either throws 'Vertical viewport was given unbounded height', or, once you patch it with shrinkWrap: true and NeverScrollableScrollPhysics, silently destroys laziness because shrinkWrap forces the inner list to lay out every child to measure itself. On a feed screen with images that is exactly how apps end up dropping frames on scroll. Convert each section to a sliver instead: SliverList.builder, SliverGrid, and SliverToBoxAdapter for one-off box widgets. If you must mix a box-only widget in, SliverToBoxAdapter is the adapter, and SliverFillRemaining handles the empty-state case where content is shorter than the viewport.
CustomScrollView(
slivers: [
SliverAppBar.large(
title: const Text('Orders'),
pinned: true,
expandedHeight: 180,
),
const SliverToBoxAdapter(child: FilterChipsRow()),
SliverList.builder(
itemCount: orders.length,
itemBuilder: (context, i) => OrderTile(order: orders[i]),
),
SliverGrid.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 1.4,
),
itemCount: banners.length,
itemBuilder: (context, i) => BannerCard(banners[i]),
),
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: Text('You have reached the end')),
),
],
);
Key Points
- Slivers use SliverConstraints and SliverGeometry, not BoxConstraints and Size
- Laziness comes from building only what falls in viewport plus cacheExtent
- shrinkWrap: true on a nested ListView kills laziness
- SliverToBoxAdapter, SliverFillRemaining and SliverPersistentHeader bridge the gap
Q26How do platform channels work, and why do teams prefer Pigeon over hand-written MethodChannel code?
IntermediatePlatform Interop
Answer
A platform channel is an asynchronous, named message pipe between Dart and the host platform. MethodChannel sends a method name plus arguments and awaits a single reply, EventChannel exposes a host-side stream as a Dart Stream (useful for sensors, Bluetooth scans, location updates), and BasicMessageChannel passes raw messages with a codec you choose. Everything is serialised by the StandardMessageCodec, which supports null, bool, num, String, Uint8List, Int32List, Int64List, Float64List, List and Map.
Anything else needs manual conversion, so you cannot pass a Dart class across the boundary. Calls are always asynchronous even if the native side is synchronous, and on the host they arrive on the platform thread, so blocking there blocks the whole engine. The reason production teams reach for Pigeon is that hand-written channels are stringly typed on both sides: a typo in the method name or a mismatch between a Dart Map key and the Kotlin cast produces a runtime PlatformException, never a compile error, and the failure usually shows up only on one OS.
Pigeon takes a Dart file of abstract API definitions annotated with @HostApi() and @FlutterApi(), and generates the Dart, Kotlin/Swift and (optionally) C++ glue, so renaming a field breaks the build instead of the app. Always catch PlatformException, MissingPluginException (which means the plugin is not registered on that platform or the app needs a full restart after adding it), and provide a graceful Dart fallback. Also remember that channel calls from a background isolate need BackgroundIsolateBinaryMessenger.ensureInitialized with a root isolate token.
// Hand-written MethodChannel, with the failure modes handled.
const _channel = MethodChannel('app.goodspace/device');
Future<String?> upiAppPackage() async {
try {
return await _channel.invokeMethod<String>('defaultUpiApp');
} on MissingPluginException {
return null; // not implemented on this platform
} on PlatformException catch (e) {
debugPrint('native failed: ${e.code} ${e.message}');
return null;
}
}
// Pigeon definition (pigeons/device.dart) -> generates typed glue.
@HostApi()
abstract class DeviceApi {
String? defaultUpiApp();
@async
bool launchIntent(String package, String payload);
}
// dart run pigeon --input pigeons/device.dart \
// --dart_out lib/gen/device.g.dart \
// --kotlin_out android/.../DeviceApi.kt
Key Points
- MethodChannel for request/reply, EventChannel for host-driven streams
- StandardMessageCodec supports only primitives, typed lists, List and Map
- Host handlers run on the platform thread; never block there
- Pigeon turns runtime PlatformExceptions into compile errors
Q27What is the difference between implicit and explicit animations, and when does an AnimationController become mandatory?
IntermediateAnimation
Answer
Implicit animations are the AnimatedFoo family: AnimatedContainer, AnimatedOpacity, AnimatedPositioned, AnimatedAlign, AnimatedSwitcher, plus the generic TweenAnimationBuilder. You give them a target value, a duration and a curve, and every time the widget rebuilds with a different target they interpolate from the current value to the new one. They own their own internal controller and ticker, so there is nothing to dispose.
Explicit animations use an AnimationController that you create in initState with vsync: this from SingleTickerProviderStateMixin (or TickerProviderStateMixin for several controllers) and dispose in dispose(). You drive it with forward(), reverse(), repeat(), or animateTo(), and read progress through Tween.animate or a CurvedAnimation. Reach for explicit animation when you need any of these: the animation must run on a loop or be reversed mid-flight, several widgets must stay in sync off one clock, you need to know when it completes via addStatusListener, or you want to scrub it from a gesture (a swipeable bottom sheet driven by controller.value).
The vsync argument exists so the ticker only fires while the widget's route is visible, which stops off-screen animations from burning battery, and it is why passing a TickerProvider from an unrelated widget is a real bug rather than a style issue. The performance detail interviewers probe is the child parameter of AnimatedBuilder: the builder runs on every frame, so anything static must be built once and passed as child rather than constructed inside the closure. For screen transitions, Hero handles shared-element flights, and PageRouteBuilder with a transitionsBuilder gives you a custom route animation without any controller of your own.
class PulseBadge extends StatefulWidget {
const PulseBadge({super.key, required this.child});
final Widget child;
@override
State<PulseBadge> createState() => _PulseBadgeState();
}
class _PulseBadgeState extends State<PulseBadge>
with SingleTickerProviderStateMixin {
late final AnimationController _c = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
)..repeat(reverse: true);
late final Animation<double> _scale =
Tween(begin: 1.0, end: 1.12).animate(
CurvedAnimation(parent: _c, curve: Curves.easeInOut),
);
@override
void dispose() {
_c.dispose(); // ticker leak if you skip this
super.dispose();
}
@override
Widget build(BuildContext context) => AnimatedBuilder(
animation: _scale,
// built once, not on every frame
child: widget.child,
builder: (_, child) =>
Transform.scale(scale: _scale.value, child: child),
);
}
Q28How do you write widget tests, and what is the difference between pump, pumpAndSettle and runAsync?
IntermediateTesting
Answer
testWidgets gives you a WidgetTester that drives a real widget tree in a fake environment: no device, no platform channels, and crucially a fake clock. tester.pumpWidget(...) mounts the tree and renders one frame. tester.pump() renders exactly one more frame, optionally after advancing the fake clock by a Duration. tester.pumpAndSettle() repeatedly pumps until no frames are scheduled, which is what you want after tapping something that starts a transition, and which will time out with 'pumpAndSettle timed out' if the screen has an infinite animation such as a looping CircularProgressIndicator or a repeat() controller. That timeout is the most common first failure engineers hit, and the fix is to pump a fixed number of frames instead of settling. Real asynchronous work (an actual HTTP call, a real timer, file IO) does not progress under the fake clock, so wrap it in tester.runAsync when you genuinely need it, though the better pattern is to inject a fake repository so the test stays deterministic.
Finders locate widgets: find.text, find.byType, find.byKey, find.byIcon, find.byWidgetPredicate, and find.descendant for scoping. Assertions use expect with matchers like findsOneWidget, findsNothing and findsNWidgets. Golden tests capture a rendered image and compare it against a checked-in PNG using matchesGoldenFile, regenerated with flutter test --update-goldens; run them on a single pinned platform in CI because font rasterisation differs between macOS and Linux and will produce spurious diffs.
For anything that must exercise real plugins or a real backend, use the integration_test package with flutter test integration_test, which runs on a device or emulator. Interviewers usually ask for the split: unit tests for logic, widget tests for a screen's behaviour, and a thin integration suite for the two or three critical journeys such as login and checkout.
testWidgets('shows an error when login fails', (tester) async {
await tester.pumpWidget(
MaterialApp(home: LoginScreen(auth: FakeAuth(fails: true))),
);
await tester.enterText(find.byKey(const Key('phone')), '9876543210');
await tester.tap(find.text('Send OTP'));
await tester.pump(); // start the async gap
await tester.pump(const Duration(seconds: 1)); // let it resolve
expect(find.text('Could not send OTP'), findsOneWidget);
expect(find.byType(CircularProgressIndicator), findsNothing);
});
testWidgets('empty state matches golden', (tester) async {
await tester.pumpWidget(const MaterialApp(home: EmptyOrders()));
await expectLater(
find.byType(EmptyOrders),
matchesGoldenFile('goldens/empty_orders.png'),
);
});
Key Points
- pump advances one frame; pumpAndSettle loops until no frame is scheduled
- pumpAndSettle times out on looping animations such as a progress spinner
- runAsync is needed for real async work under the fake clock
- Pin goldens to one CI platform to avoid font rendering diffs
Q29Why did go_router replace hand-rolled Navigator 2.0 code, and how do you gate routes behind authentication?
IntermediateNavigation
Answer
Navigator 1.0 (push, pop, pushNamed) is an imperative stack and works fine for simple apps, but it cannot express deep links, browser URLs on Flutter web, or restoring a nested stack from a cold start. Navigator 2.0 fixed that with a declarative Router API built from RouterDelegate, RouteInformationParser and BackButtonDispatcher, but writing those three classes by hand is a large amount of boilerplate that is easy to get wrong around back-button handling and state restoration. go_router, maintained by the Flutter team, implements that machinery for you and exposes a URL-based route table instead. You declare GoRoute entries with path patterns and typed path parameters, use context.go for a replace-style navigation, context.push when you genuinely want a new entry on the stack, and StatefulShellRoute.indexedStack when a bottom navigation bar needs each tab to keep its own independent stack.
Authentication is handled by the top-level redirect callback, which runs on every navigation: return the login path when the user is unauthenticated and the target is not public, return the home path when an authenticated user hits the login screen, and return null to allow the navigation. Wire refreshListenable to a Listenable that fires on auth state changes so the router re-evaluates redirect the moment a token is cleared, otherwise a logged-out user keeps staring at a protected screen. Two production details interviewers look for: guard against redirect loops by always allowing the login route through, and remember that on Android you must add an intent-filter with autoVerify plus assetlinks.json (and the Apple app site association file on iOS) or your deep links open the browser instead of the app.
final router = GoRouter(
initialLocation: '/jobs',
refreshListenable: authState, // ChangeNotifier
redirect: (context, state) {
final loggedIn = authState.isLoggedIn;
final goingToLogin = state.matchedLocation == '/login';
if (!loggedIn && !goingToLogin) {
return '/login?from=${Uri.encodeComponent(state.uri.toString())}';
}
if (loggedIn && goingToLogin) return '/jobs';
return null;
},
routes: [
GoRoute(path: '/login', builder: (_, __) => const LoginScreen()),
GoRoute(
path: '/jobs',
builder: (_, __) => const JobListScreen(),
routes: [
GoRoute(
path: ':jobId', // /jobs/8123
builder: (_, s) => JobDetail(id: s.pathParameters['jobId']!),
),
],
),
],
errorBuilder: (_, s) => NotFoundScreen(uri: s.uri),
);
Q30How do you capture every crash in a Flutter app, including errors thrown outside the widget tree?
IntermediateError Handling
Answer
Flutter has three distinct error surfaces and you must hook all of them or your crash dashboard will lie to you. First, FlutterError.onError receives framework errors: exceptions thrown from build, layout, paint, and gesture callbacks. In debug this prints the red screen; in release the default is to log and continue, so an unhandled build exception can leave a user staring at a grey box with nothing reported.
Second, PlatformDispatcher.instance.onError catches uncaught asynchronous errors from the root isolate, which is what replaced the older runZonedGuarded pattern for most apps; return true to mark the error as handled. Third, native crashes (a bad JNI call, an Objective-C exception, an OOM kill) never reach Dart at all and need the platform SDK, which is exactly what FirebaseCrashlytics or Sentry's native layer installs. Background isolates have their own error handling, so a spawned isolate needs Isolate.current.addErrorListener or an explicit onError port.
The second half of the answer is symbolication. If you ship with --obfuscate you must also pass --split-debug-info, keep the generated symbol files per build, and upload them, otherwise every stack trace is a list of hex offsets. Also replace the default grey error box with something the user can act on by setting ErrorWidget.builder in release, and add a Firebase Crashlytics non-fatal report for handled failures like a payment API returning 502, so you can see degradation before it becomes a crash spike. In production apps for Indian users, tag reports with device model and Android API level, because a large share of real-world crashes are OOM kills concentrated on a handful of low-RAM devices.
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
// 1. Framework errors: build, layout, paint, gestures.
FlutterError.onError = (details) {
FirebaseCrashlytics.instance.recordFlutterFatalError(details);
};
// 2. Uncaught async errors on the root isolate.
PlatformDispatcher.instance.onError = (error, stack) {
FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
return true;
};
// 3. Do not show a grey box in release.
ErrorWidget.builder = (details) => const SomethingWentWrongCard();
runApp(const App());
}
// Build with symbols you can actually upload:
// flutter build appbundle --obfuscate \
// --split-debug-info=build/symbols/1.4.2
Key Points
- FlutterError.onError, PlatformDispatcher.instance.onError, and the native SDK are three separate hooks
- Spawned isolates need their own error listener
- --obfuscate without --split-debug-info makes traces unreadable
- Override ErrorWidget.builder so release users never see the grey box
Q31Explain Dart's event loop. What is the difference between the microtask queue and the event queue, and where does await put your code?
IntermediateConcurrency
Answer
Each Dart isolate runs one thread with one event loop and two queues. The microtask queue holds work scheduled by scheduleMicrotask and by the continuations of already-completed Futures. The event queue holds everything that comes from outside: timers, IO completions, gesture events, platform channel replies, and Future.delayed.
The loop drains the entire microtask queue before it takes a single item from the event queue, which is why an accidentally recursive scheduleMicrotask starves the UI completely and the app freezes without any exception. When you await, the function returns immediately at that point and the rest of the body is registered as a continuation; it resumes as a microtask once the awaited Future completes. So await never blocks the thread, but it also never gives you parallelism: everything still runs on the same isolate.
That distinction is the crux of the question interviewers actually care about. Awaiting a 200ms HTTP call is free because the wait happens in the IO layer, but awaiting a function that spends 200ms parsing a 5MB JSON payload or resizing an image blocks the UI thread for 200ms and drops roughly twelve frames at 60Hz. async does not move work off the thread; only an isolate does. Two more details worth naming: Future.microtask versus Future.delayed(Duration.zero) put work on different queues with different priorities, and unawaited futures swallow errors unless you attach a catchError or wrap them in unawaited() with a zone-level handler. Also, async* generators only produce values when the consumer listens, so a stream built with async* is lazy and single-subscription by default.
void demo() {
print('1 sync');
Future(() => print('4 event queue')); // event queue
Future.delayed(Duration.zero, () => print('5')); // timer -> event queue
scheduleMicrotask(() => print('3 microtask')); // microtask queue
print('2 sync');
}
// Output: 1 sync, 2 sync, 3 microtask, 4 event queue, 5
// Awaiting does NOT move CPU work off the UI thread.
Future<List<Job>> slowParse(String body) async {
return decodeJobs(jsonDecode(body)); // still blocks the UI thread
}
// This does, because it runs on another isolate.
Future<List<Job>> fastParse(String body) =>
Isolate.run(() => decodeJobs(jsonDecode(body)));
Key Points
- One thread, two queues; microtasks drain fully before the next event
- await schedules a continuation as a microtask, it does not block the thread
- async gives concurrency, not parallelism; CPU work still janks the UI
- Starving the loop with recursive microtasks freezes the app silently
Q32What actually differs between flutter run in debug, profile and release mode?
IntermediateBuild Modes
Answer
Debug mode compiles Dart with the JIT, which is what makes hot reload possible, keeps assertions on, enables all the service extensions used by DevTools, includes the observatory/VM service, and ships an unoptimised engine. It is typically several times slower than release, so any performance number measured in debug is meaningless. Profile mode compiles AOT to native ARM like release, but keeps enough tracing hooks for the DevTools timeline and the performance overlay, and it only runs on a physical device (not the simulator, because the simulator lacks the ARM code path).
This is the only correct mode for measuring jank. Release mode is AOT, strips assertions and the service extensions, enables tree shaking including icon tree shaking for the Material font, and produces the smallest and fastest binary. Practical consequences interviewers probe: assert statements silently vanish in release, so never put behaviour-critical code inside one; kDebugMode, kProfileMode and kReleaseMode from foundation.dart are compile-time constants so branches on them are eliminated by the compiler; and code that works in debug can fail in release if you relied on the timing of JIT warmup or on a plugin that was only registered in a debug flavour.
The other classic release-only failure is missing ProGuard or R8 keep rules on Android stripping a class that a plugin looks up reflectively, which shows up as a MissingPluginException or a ClassNotFoundException in a release APK that never appeared in debug. Always smoke test a real release build on a device before shipping, and use --dart-define or --dart-define-from-file to inject environment values instead of committing them.
# Measure performance here, never in debug, and only on a real device.
flutter run --profile -d <device-id>
# Release build with obfuscation and retained symbols.
flutter build appbundle --release \
--obfuscate --split-debug-info=build/symbols/2.3.0 \
--dart-define-from-file=config/prod.json
# Compile-time flags: the dead branch is removed by the AOT compiler.
import 'package:flutter/foundation.dart';
final baseUrl = kReleaseMode
? 'https://api.goodspace.ai'
: 'https://staging.goodspace.ai';
if (kDebugMode) {
debugPrint('verbose logging only in debug');
}
Q33How do you handle FCM push notifications in Flutter across foreground, background and terminated states?
IntermediateNotifications
Answer
firebase_messaging exposes three delivery paths and they behave differently. FirebaseMessaging.onMessage fires only while the app is in the foreground, and on Android nothing is displayed automatically, so you must render the notification yourself, usually with flutter_local_notifications and an explicitly created Android notification channel whose id matches what the server sends. FirebaseMessaging.onBackgroundMessage registers a handler that runs in a separate background isolate when the app is backgrounded or terminated; that function must be a top-level or static function annotated with @pragma('vm:entry-point') so tree shaking does not remove it in release, and it must call Firebase.initializeApp itself because it does not share the main isolate's state.
FirebaseMessaging.onMessageOpenedApp fires when the user taps a notification that opened an already-running app, while getInitialMessage returns the message that launched a terminated app, and forgetting the second one is the classic bug where deep links work in testing but not from a cold start. Data-only messages let you control display entirely but Android may throttle or delay them under Doze; notification messages are displayed by the system tray when the app is not in the foreground. On iOS you additionally need an APNs key uploaded to Firebase, the Push Notifications capability, Background Modes with remote notifications, and an explicit requestPermission call, plus getAPNSToken can return null briefly at startup, so retry rather than assuming failure. Store and refresh the device token with onTokenRefresh, and never treat a token as permanent, it rotates on reinstall and on app data clear, which is a frequent cause of silently dead notification delivery.
@pragma('vm:entry-point')
Future<void> _bgHandler(RemoteMessage message) async {
await Firebase.initializeApp(); // separate isolate, own state
await logDelivery(message.messageId);
}
Future<void> setupPush() async {
FirebaseMessaging.onBackgroundMessage(_bgHandler);
final fm = FirebaseMessaging.instance;
await fm.requestPermission(alert: true, badge: true, sound: true);
// Cold start: the notification that launched the app.
final initial = await fm.getInitialMessage();
if (initial != null) handleRoute(initial.data['route']);
// Warm start from the tray.
FirebaseMessaging.onMessageOpenedApp
.listen((m) => handleRoute(m.data['route']));
// Foreground: Android shows nothing by itself.
FirebaseMessaging.onMessage.listen(showLocalNotification);
fm.onTokenRefresh.listen(sendTokenToBackend);
}
Key Points
- Background handler must be top-level and annotated @pragma('vm:entry-point')
- getInitialMessage handles the terminated-app tap; onMessageOpenedApp handles the warm one
- Foreground messages on Android need flutter_local_notifications and a matching channel id
- iOS needs an APNs key, Push capability and explicit permission
Q34How do you build one Flutter UI that works on a 5-inch phone, a tablet and a foldable?
IntermediateResponsive UI
Answer
Start by separating the two questions Flutter treats differently: responsive means reacting to the space you are given, adaptive means changing platform conventions. For responsive layout, prefer LayoutBuilder over MediaQuery whenever the decision depends on the space the widget actually has rather than the screen, because a widget inside a side panel gets far less width than the window reports. Use breakpoints rather than device checks; the Material guidance groups roughly at 600, 840 and 1200 logical pixels, and Flutter ships NavigationRail for the medium band and a persistent Drawer or a two-pane layout for the large one.
Flex, Wrap, FittedBox, AspectRatio and the SliverGridDelegateWithMaxCrossAxisExtent delegate (which picks a column count from available width instead of hard-coding one) do most of the work. Never hard-code pixel heights for text containers: users on Android can set a font scale well above 1.0, so read MediaQuery.textScalerOf(context) and let content grow, and test with the accessibility text size cranked up because this is where overflow errors appear in the wild. For foldables, the display_features list in MediaQuery reports hinges and cutouts, and TwoPane-style layouts should avoid placing interactive content across the fold.
For adaptive behaviour, use the .adaptive constructors (Switch.adaptive, CircularProgressIndicator.adaptive), Theme.of(context).platform rather than dart:io Platform so tests and web behave, and keep Cupertino widgets to genuinely platform-specific affordances rather than duplicating the whole app. Finally, remember SafeArea for notches and gesture bars, and MediaQuery.viewInsetsOf for the keyboard so a bottom sheet does not sit under it.
class HomeShell extends StatelessWidget {
const HomeShell({super.key});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final w = constraints.maxWidth;
if (w >= 1200) {
return const Row(children: [
SizedBox(width: 280, child: SideNav()),
Expanded(child: JobList()),
SizedBox(width: 380, child: JobDetailPane()),
]);
}
if (w >= 600) {
return const Row(children: [
NavigationRail(destinations: navRailItems, selectedIndex: 0),
Expanded(child: JobList()),
]);
}
return const Scaffold(
body: JobList(),
bottomNavigationBar: AppBottomNav(),
);
},
);
}
}
// Column count from available width, not a magic number.
const SliverGridDelegateWithMaxCrossAxisExtent(maxCrossAxisExtent: 320);
Q35How do you choose between SharedPreferences, flutter_secure_storage and a local SQL database, and how do you handle schema migrations?
IntermediateLocal Storage
Answer
SharedPreferences wraps NSUserDefaults and Android SharedPreferences and is correct only for small, non-sensitive key/value settings: the selected theme, a boolean for whether onboarding is done, the last selected city. It is plaintext on disk, it loads the whole file into memory, and it is not a database, so never store a session token or a list of records in it. flutter_secure_storage puts values in the iOS Keychain and the Android Keystore-backed EncryptedSharedPreferences, which is where refresh tokens belong. Two gotchas matter in production: on Android, Keystore entries can become undecryptable after a device restore or a Keystore corruption and reads then throw a PlatformException, so wrap every read in a try/catch that falls back to a forced re-login rather than crashing on launch; and by default the values may survive an uninstall on iOS unless you set the accessibility option and clear on first run.
For anything relational or larger than a few kilobytes, use sqflite for raw SQL, or Drift for compile-time-checked queries generated from Dart, or Isar/ObjectBox when you want an object store with fast queries and no SQL. Migrations are the part candidates fluff. sqflite gives you onCreate and onUpgrade keyed on a version integer, and Drift gives you a MigrationStrategy with schema versioning plus generated schema files you can test against. The non-negotiable rule is that migrations must be forward-only, idempotent where possible, and tested by opening an old schema file and upgrading it in a unit test, because a failed migration on a user device is unrecoverable without wiping their data. Also do writes off the UI thread for bulk inserts, and batch them in a single transaction rather than one insert per row.
// Drift: versioned schema with a tested migration path.
@DriftDatabase(tables: [Jobs, Applications])
class AppDb extends _$AppDb {
AppDb(super.e);
@override
int get schemaVersion => 3;
@override
MigrationStrategy get migration => MigrationStrategy(
onCreate: (m) => m.createAll(),
onUpgrade: (m, from, to) async {
if (from < 2) await m.addColumn(jobs, jobs.isBookmarked);
if (from < 3) await m.createIndex(idxJobsUpdatedAt);
},
beforeOpen: (details) async {
await customStatement('PRAGMA foreign_keys = ON');
},
);
}
// Secure storage reads can fail after a device restore.
Future<String?> readToken() async {
try {
return await const FlutterSecureStorage().read(key: 'refresh');
} on PlatformException {
await const FlutterSecureStorage().deleteAll();
return null; // force a clean re-login instead of crashing
}
}
Key Points
- SharedPreferences is plaintext settings only, never tokens or records
- Secure storage reads can throw after an Android Keystore restore; catch and re-login
- Drift or sqflite for relational data, with a version-keyed migration strategy
- Test migrations by upgrading a real old schema file in CI
Q36How would you structure the networking layer, including token refresh, retries and cancellation?
IntermediateNetworking
Answer
Wrap the transport behind your own client so the rest of the app never imports dio or http directly. With dio the building blocks are interceptors, a shared CancelToken per screen, and a typed result. The auth interceptor attaches the access token onRequest and handles a 401 onError by refreshing, but the detail interviewers push on is the single-flight lock: if six requests fire in parallel and all get a 401, a naive implementation performs six refresh calls, and on most backends the second one invalidates the first token and logs the user out.
Hold a single Future for the in-flight refresh, have every failing request await that same Future, then retry the original RequestOptions once. Give up and clear the session if the refresh itself fails. Retries should only apply to idempotent verbs and to genuinely transient conditions (connection timeout, connection error, 502/503/504), with exponential backoff and jitter; never blind-retry a POST that creates a payment.
CancelToken matters on mobile: if a user opens a job detail and immediately swipes back, cancel the in-flight request in dispose so the response does not resolve into a disposed State. Set connectTimeout, receiveTimeout and sendTimeout explicitly, because the defaults are effectively infinite on some platforms and a stalled socket on a weak network shows the user a spinner forever, which matters a lot for Indian users on patchy 4G in a metro or a lift. Model responses with json_serializable or freezed and build_runner rather than hand-written fromJson, so a backend field rename fails at build time. Finally, surface failures as a typed sealed class or a Result rather than throwing raw DioException into the UI layer.
class AuthInterceptor extends Interceptor {
AuthInterceptor(this._dio, this._session);
final Dio _dio;
final Session _session;
Future<String?>? _refreshing; // single-flight lock
@override
void onRequest(RequestOptions o, RequestInterceptorHandler h) {
o.headers['Authorization'] = 'Bearer ${_session.accessToken}';
h.next(o);
}
@override
Future<void> onError(DioException e, ErrorInterceptorHandler h) async {
if (e.response?.statusCode != 401 || o_isRetry(e.requestOptions)) {
return h.next(e);
}
_refreshing ??= _session.refresh().whenComplete(() => _refreshing = null);
final token = await _refreshing;
if (token == null) {
await _session.clear();
return h.next(e);
}
final req = e.requestOptions
..headers['Authorization'] = 'Bearer $token'
..extra['retried'] = true;
h.resolve(await _dio.fetch(req));
}
}
bool o_isRetry(RequestOptions o) => o.extra['retried'] == true;
Q37A tester reports the checkout screen stutters. Walk me through diagnosing it in DevTools until you have a root cause.
AdvancedPerformance
Answer
Reproduce in profile mode on a real mid-range Android device, never in debug and never on a simulator. Open DevTools and go to the Performance view. The frame chart colours each frame by where the time went: a tall blue bar is UI thread work (Dart build, layout, paint recording), a tall green bar is raster thread work (actually rasterising and compositing the layer tree).
That split decides the whole investigation. If UI time is the problem, turn on 'Track Widget Builds' in the enhanced tracing options and look at the timeline events for the janky frame; you will usually find either a build that walks a huge subtree because setState was called too high in the tree, an expensive synchronous computation such as jsonDecode or a sort inside build, or a rebuild storm from a ChangeNotifier that notifies on every keystroke. Fixes: scope the rebuild with a Builder, ValueListenableBuilder or a Selector, move the computation into Isolate.run, and cache derived values.
If raster time is the problem, the causes are different: too much overdraw, a saveLayer from Opacity or a ShaderMask over a large subtree, blur effects from BackdropFilter, huge unclipped images, or a missing RepaintBoundary so an animating widget forces the whole screen to repaint. Toggle the performance overlay to watch both graphs live, and use debugRepaintRainbowEnabled to see what is actually repainting. A third pattern is a spike only on the very first run of an animation on Skia builds, which is shader compilation jank and is what Impeller was built to remove. Always confirm the fix by measuring again and quoting the p90 frame time before and after rather than saying it 'feels smoother'.
# 1. Correct measurement environment.
flutter run --profile -d <physical-android-device>
# 2. Live overlay while you reproduce the gesture.
# Press 'P' in the terminal, or:
MaterialApp(showPerformanceOverlay: true, home: CheckoutScreen());
# 3. See what repaints (debug builds only).
import 'package:flutter/rendering.dart';
void main() {
debugRepaintRainbowEnabled = true;
runApp(const App());
}
# 4. Move the expensive build-time work off the UI thread.
final invoice = await Isolate.run(() => buildInvoice(rawOrderJson));
# 5. Cheap wins to check first
# - const constructors on static subtrees
# - RepaintBoundary around the animating widget only
# - Opacity -> AnimatedOpacity or a color with alpha, to avoid saveLayer
Key Points
- Blue bar means UI thread (Dart), green means raster thread (GPU)
- Enhanced tracing plus Track Widget Builds finds rebuild storms
- Raster jank usually means saveLayer, blur, overdraw or a missing RepaintBoundary
- Always re-measure and quote a frame-time number, not a feeling
Q38When do you reach for an isolate, and what are the real constraints on passing data and calling plugins from one?
AdvancedConcurrency
Answer
An isolate is a separate memory heap with its own event loop and its own garbage collector, and it is the only way to get true parallelism in Dart. Reach for one when a computation would occupy the UI thread for longer than a frame budget: parsing a large API response, decrypting or hashing, image manipulation, CSV or PDF generation, or running an on-device model. The simplest entry point in recent Flutter versions is Isolate.run, which spawns a short-lived isolate, runs your closure and returns the result. compute() is the older wrapper and takes a top-level or static function plus one argument.
For repeated work, spawning per call is wasteful (an isolate spawn costs a few milliseconds plus heap setup), so keep a long-lived worker isolate with Isolate.spawn and a pair of SendPort/ReceivePort channels. The constraints are what separate a real answer from a textbook one. Objects are copied across the boundary, not shared, so sending a large list costs a full copy and can itself be the bottleneck; TransferableTypedData moves byte buffers with zero copy and is the right tool for images.
Closures sent to Isolate.run must not capture things that cannot be sent, and a captured BuildContext or a live database handle will throw. Plugin calls from a background isolate fail with a null binary messenger unless you pass a RootIsolateToken from the main isolate and call BackgroundIsolateBinaryMessenger.ensureInitialized in the worker. Errors do not propagate automatically to the parent for spawned isolates, so attach an error listener or you will lose crashes silently. Finally, remember an isolate does not help with IO-bound waits; those are already non-blocking.
// One-shot heavy work: simplest correct option.
final jobs = await Isolate.run(() => parseJobs(bodyString));
// Long-lived worker, so you pay the spawn cost once.
class Worker {
late final SendPort _tx;
final _rx = ReceivePort();
Future<void> start() async {
final token = RootIsolateToken.instance!;
await Isolate.spawn(_entry, (_rx.sendPort, token));
_tx = await _rx.first as SendPort;
}
static void _entry((SendPort, RootIsolateToken) args) {
final (reply, token) = args;
// Required before any plugin call in a background isolate.
BackgroundIsolateBinaryMessenger.ensureInitialized(token);
final inbox = ReceivePort();
reply.send(inbox.sendPort);
inbox.listen((msg) => reply.send(compressImage(msg as Uint8List)));
}
}
// Zero-copy transfer for large byte buffers.
final payload = TransferableTypedData.fromList([bytes]);
Q39What is Impeller, what problem did it solve, and what should you check when migrating an older app onto it?
AdvancedRendering
Answer
Impeller is Flutter's rendering backend, written to replace Skia in the engine. The problem it solves is shader compilation jank. With Skia, the shaders needed to draw a particular effect were compiled lazily at the moment that effect first appeared, so the very first run of a page transition or a new animation could take tens of milliseconds and produce a visible stutter, and the historical workaround was capturing an SkSL warm-up file per build and shipping it.
Impeller precompiles its shaders offline at engine build time, so there is a fixed, known set and no runtime compilation on the hot path. It also uses Metal on iOS and Vulkan on modern Android, with a different approach to tessellation and to how it batches draw calls. Impeller has been the default on iOS since Flutter 3.10 and became the default on capable Android devices in later releases, with a fallback path for devices that lack the required Vulkan support.
When migrating a mature app the checks are practical rather than theoretical: re-verify any custom shader you load through FragmentProgram, because GLSL that Skia accepted may need adjusting; look closely at heavy blur and BackdropFilter usage and at very large paths, since relative cost profiles differ from Skia; re-run your golden tests, because tiny antialiasing differences will produce diffs that are correct but new; and re-measure raster times rather than assuming an improvement. If you hit a genuine visual regression you can temporarily opt out, on iOS by setting FLTEnableImpeller to false in Info.plist and on Android via the io.flutter.embedding.android.EnableImpeller meta-data flag, or with --no-enable-impeller during a run, but treat that as a stopgap and file the issue, because the Skia path is being retired.
<!-- iOS: temporary opt-out while you fix a regression (ios/Runner/Info.plist) -->
<key>FLTEnableImpeller</key>
<false/>
<!-- Android: same idea, in AndroidManifest.xml inside <application> -->
<meta-data
android:name="io.flutter.embedding.android.EnableImpeller"
android:value="false" />
# Or per-run, for an A/B comparison of raster times:
flutter run --profile --no-enable-impeller
flutter run --profile --enable-impeller
// Custom shaders need re-validation on Impeller.
final program = await FragmentProgram.fromAsset('shaders/glow.frag');
final shader = program.fragmentShader()
..setFloat(0, size.width)
..setFloat(1, size.height);
canvas.drawRect(rect, Paint()..shader = shader);
Key Points
- Impeller precompiles shaders, removing first-run shader compilation jank
- Metal on iOS, Vulkan on modern Android, with a fallback path
- Re-run goldens after migrating; antialiasing differences are expected
- Opt-out flags exist but are a stopgap, not a strategy
Q40When do you write a custom RenderObject instead of composing widgets or using CustomPainter?
AdvancedRendering
Answer
There is a clear escalation ladder and interviewers want to hear that you know where you are on it. First, compose existing widgets; ninety-five percent of UI never needs anything else. Second, if you only need to draw pixels and do not need children or layout participation, use CustomPainter inside a CustomPaint, override paint and shouldRepaint, and keep shouldRepaint honest because returning true unconditionally repaints every frame.
Third, write a RenderObject when you need to control layout itself: a widget whose size depends on its children in a way Flex cannot express, a layout that must measure children under different constraints, or a widget that needs custom hit testing or intrinsic dimensions. You subclass RenderBox (or extend RenderProxyBox to wrap a single child) and pair it with a LeafRenderObjectWidget, SingleChildRenderObjectWidget or MultiChildRenderObjectWidget, implementing createRenderObject and updateRenderObject. In performLayout you read this.constraints, call child.layout(childConstraints, parentUsesSize: true) when you need the child's size, and assign this.size, respecting the rule that a render object may never read anything about its parent and may only communicate upward through its own size.
Store per-child positions in a ParentData subclass via setupParentData. Then implement paint using context.paintChild with the offset, and hitTestChildren if children must remain tappable. Call markNeedsLayout when a property change affects size, and the cheaper markNeedsPaint when it only affects appearance; conflating the two is the mistake that turns a custom render object into a performance problem. Real cases where this pays off: a chat bubble that tails to whichever side has room, a chip wrap with custom overflow counting, and a chart axis that must measure label widths before deciding tick density.
class SquareBox extends SingleChildRenderObjectWidget {
const SquareBox({super.key, super.child});
@override
RenderSquareBox createRenderObject(BuildContext context) =>
RenderSquareBox();
}
class RenderSquareBox extends RenderProxyBox {
@override
void performLayout() {
final side = constraints.biggest.shortestSide;
final childConstraints = BoxConstraints.tight(Size(side, side));
child?.layout(childConstraints, parentUsesSize: false);
size = Size(side, side);
}
@override
double computeMinIntrinsicWidth(double height) => height;
@override
void paint(PaintingContext context, Offset offset) {
context.canvas.drawRect(
offset & size,
Paint()..color = const Color(0x11000000),
);
super.paint(context, offset); // paints the child
}
}
Q41Your Android release bundle is 60MB and product wants it under 30MB. What do you actually do?
AdvancedApp Size
Answer
Measure before cutting. flutter build appbundle --analyze-size produces a size breakdown you can open in DevTools' App Size tool, which shows the split between the Dart AOT snapshot, the engine, native libraries and assets, and lets you diff two builds. In almost every real app the biggest wins are assets, not code. Ship WebP or AVIF instead of PNG for photographic assets, drop redundant resolution buckets you never use, and move rarely needed assets to a CDN fetched on demand.
Fonts are the second offender: bundling the full weight range of a family costs megabytes, so ship the two weights you actually use, and subset the glyph range if you only need Latin plus Devanagari rather than the whole Noto set. Third, ship an Android App Bundle rather than a universal APK so Play delivers only the ABI, density and language slices each device needs, which alone typically removes a large chunk of the download size; if you must ship APKs directly, use --split-per-abi. Fourth, --obfuscate with --split-debug-info moves debug symbols out of the binary.
Fifth, audit pub dependencies with a critical eye, because a single package pulling in a native ML runtime or a full PDF engine can dominate everything else. For genuinely large optional features, deferred components let you split Dart code into modules that Play delivers on demand. Two things people wrongly expect to help: Flutter already tree-shakes unused Material icons at build time when you do not use non-constant IconData, and the engine itself has a floor you cannot compress away. Always quote the download size from the Play Console rather than the raw file size on disk, because that is the number users actually experience and the number product is really asking about.
# Where did the bytes go?
flutter build appbundle --release --analyze-size
# then open the generated JSON in DevTools > App Size, and diff two builds
# Ship slices, not one universal binary
flutter build appbundle --release # Play splits per device
flutter build apk --release --split-per-abi # if sideloading
# Strip symbols out of the shipped binary
flutter build appbundle --release \
--obfuscate --split-debug-info=build/symbols/4.1.0
# Keep icon tree shaking working: this defeats it
// IconData(codePoint, fontFamily: 'MaterialIcons') <-- non-const, blocks shaking
// Icons.search <-- const, shakeable
# Find heavyweight dependencies
flutter pub deps --style=compact
Key Points
- --analyze-size plus the DevTools App Size tool, and diff builds
- Assets and fonts usually beat code as the biggest contributor
- App Bundle or --split-per-abi so devices download only their slice
- --obfuscate with --split-debug-info moves symbols out of the binary
- Non-constant IconData defeats Material icon tree shaking
Q42The app's memory climbs steadily as the user navigates and eventually gets killed. How do you find the leak?
AdvancedMemory
Answer
Dart is garbage collected, so a leak here means something is still reachable that should not be. Start in DevTools' Memory view: navigate the suspect flow ten times, force a GC, and watch whether the heap returns to its baseline. If it does not, take a heap snapshot and look at instance counts by class; a State subclass or a controller whose count grows with each navigation is your smoking gun.
Select that class and inspect the retaining path, which shows exactly which object chain is holding it alive. Recent Flutter versions also ship leak_tracker, wired into the framework in debug builds, and it will report disposed-but-still-referenced objects and not-disposed objects automatically during widget tests, which is the cheapest way to catch these before they reach production. The recurring causes are predictable.
A StreamSubscription created in initState and never cancelled in dispose keeps the State object alive through the stream. An AnimationController or TextEditingController or ScrollController not disposed keeps its listener list, and therefore its closures, and therefore whatever those closures captured. A listener added to a ChangeNotifier without a matching removeListener does the same.
A GlobalKey held in a static or a singleton pins an entire element subtree. A Timer.periodic that is never cancelled keeps firing forever and holds its callback. Caches without eviction, including your own memoisation maps and the image cache when you never call evict, grow without bound.
Native-side leaks show up differently, as flat Dart heap but rising RSS, and usually come from platform views, camera or video sessions not being released, or unbounded native buffers. On Android, confirm with the OS view rather than the Dart heap alone, because an OOM kill is decided on total process memory.
class _FeedState extends State<Feed> {
late final StreamSubscription<Event> _sub;
late final ScrollController _scroll = ScrollController();
Timer? _poll;
@override
void initState() {
super.initState();
_sub = eventBus.stream.listen(_onEvent);
_poll = Timer.periodic(const Duration(seconds: 30), (_) => _refresh());
settings.addListener(_onSettings);
}
@override
void dispose() {
_sub.cancel(); // stream keeps State alive otherwise
_poll?.cancel(); // periodic timers never stop by themselves
settings.removeListener(_onSettings);
_scroll.dispose();
super.dispose();
}
}
// In tests, let the framework find these for you.
// flutter test --dart-define=LEAK_TRACKING=true
testWidgets('feed disposes cleanly', (tester) async {
await tester.pumpWidget(const MaterialApp(home: Feed()));
await tester.pumpWidget(const SizedBox());
}); // leak_tracker fails the test if something survived
Key Points
- Heap snapshot plus the retaining path names the exact holder
- Uncancelled subscriptions, timers and listeners are the usual cause
- leak_tracker catches not-disposed objects during widget tests
- Flat Dart heap with rising RSS points at the native side, not Dart
Q43How do platform views work, and why are they the first thing you suspect when a screen with a map or a WebView janks?
AdvancedPlatform Interop
Answer
A platform view embeds a real native view, an Android View or a UIKit UIView, inside the Flutter widget tree, which is how google_maps_flutter, webview_flutter and native ad SDKs work. It is expensive because Flutter normally owns the entire surface and composites one layer tree; embedding a foreign view forces the engine to interleave native content with Flutter content. On Android there have been three strategies.
Virtual displays render the native view into a texture, which composites cleanly but historically broke touch and accessibility semantics and cost extra memory. Hybrid composition puts the real native view into the view hierarchy and forces Flutter to composite through the Android view system, which fixes input fidelity but can add a frame of latency and, on older releases, moved rendering onto a path with noticeably higher raster cost. Texture Layer Hybrid Composition is the newer default in recent versions and gets most of the correctness with much less of the cost.
On iOS the embedding always requires the engine to split its layer tree around the UIView, which similarly costs extra composited layers. The practical consequences you should name in an interview: expect raster thread time to rise on any screen with a platform view, so measure it; avoid animating, transforming, rotating or applying opacity to a platform view because those force expensive intermediate surfaces; never place platform views inside a long scrolling list where several exist at once, use a static snapshot image and instantiate the real view only when the user taps; and remember that platform views are created and destroyed with the widget, so churning them in a PageView is a common source of jank. Also budget memory: each WebView instance carries a full browser engine.
// Cheap placeholder in the list, real map only on demand.
class MapCell extends StatefulWidget {
const MapCell({super.key, required this.latLng});
final LatLng latLng;
@override
State<MapCell> createState() => _MapCellState();
}
class _MapCellState extends State<MapCell> {
bool _live = false;
@override
Widget build(BuildContext context) {
if (!_live) {
return GestureDetector(
onTap: () => setState(() => _live = true),
child: Image.network(staticMapUrl(widget.latLng)),
);
}
return GoogleMap(
initialCameraPosition: CameraPosition(target: widget.latLng, zoom: 15),
// Let the map receive gestures inside a scrolling parent.
gestureRecognizers: {
Factory<OneSequenceGestureRecognizer>(
EagerGestureRecognizer.new,
),
},
);
}
}
Q44Design an offline-first flow for an app used on patchy mobile networks. How do you queue writes and resolve conflicts?
AdvancedArchitecture
Answer
The core rule is that the local database is the source of truth for the UI, and the network is a background synchroniser. Every read renders from the local store (Drift, Isar or sqflite) so a screen opens instantly with no spinner even in a lift or on a train, and every write goes into the local store first plus an outbox table holding the pending mutation, its payload, a client-generated id, a created timestamp and a retry count. A sync worker drains the outbox in order, and the server must treat the client-generated id as an idempotency key so a retry after a timeout does not create a duplicate order, which is the single most important detail in this design and the one candidates usually miss.
Use connectivity_plus to react to the network coming back, but never trust it as proof of reachability because a captive portal or a dead uplink still reports connected; the real test is a successful request. Schedule background drains with workmanager or the platform's own background task API so writes eventually land even if the user never reopens the app. For conflicts, decide per entity rather than globally: last-write-wins with a server timestamp is fine for a profile field, an append-only event log is right for chat and activity feeds, and anything involving money or inventory should not be resolved on the client at all but rejected by the server with a version check so the client refetches.
Model the UI honestly with three states per record (synced, pending, failed) so an optimistic update that later fails is visible rather than silently reverted, and never show a success toast for something that is only queued. Cap retries with exponential backoff, surface a manual retry, and expose a 'last synced' timestamp so users can trust what they are looking at.
// Outbox row: the write survives an app kill.
@DataClassName('Outbox')
class Outboxes extends Table {
TextColumn get clientId => text()(); // idempotency key
TextColumn get endpoint => text()();
TextColumn get payload => text()(); // JSON
IntColumn get attempts => integer().withDefault(const Constant(0))();
DateTimeColumn get createdAt => dateTime()();
@override
Set<Column> get primaryKey => {clientId};
}
Future<void> applyLocallyThenQueue(Application app) async {
final clientId = const Uuid().v4();
await db.transaction(() async {
await db.into(db.applications).insert(app.copyWith(
clientId: clientId,
syncState: SyncState.pending,
));
await db.into(db.outboxes).insert(OutboxesCompanion.insert(
clientId: clientId,
endpoint: '/applications',
payload: jsonEncode(app.toJson()),
createdAt: DateTime.now(),
));
});
unawaited(syncer.drain()); // fire and forget; the worker retries
}
Key Points
- Local DB is the source of truth, the network is a background syncer
- Client-generated ids act as idempotency keys so retries cannot duplicate
- connectivity_plus reports a link, not reachability; only a real request proves it
- Resolve conflicts per entity; money and inventory belong on the server
Q45Walk through shipping a Flutter release to both stores: flavors, secrets, signing, symbols and rollout.
AdvancedRelease Engineering
Answer
Start with flavors so staging and production are genuinely separate installable apps: productFlavors in Android's build.gradle with distinct applicationId suffixes, matching Xcode schemes and configurations on iOS, and flutter run --flavor staging plus a separate entry point such as lib/main_staging.dart. Configuration comes in through --dart-define-from-file pointing at a JSON file kept out of version control, which is compiled into the binary rather than read from an asset an attacker can unzip, though you should still assume nothing in the app package is secret and never ship a private key or an admin API token. Signing on Android uses an upload keystore referenced from key.properties (never committed) with Play App Signing holding the real release key, so a lost upload key is recoverable; on iOS use Xcode automatic signing for local work and App Store Connect API keys with fastlane match or a CI provider's managed certificates for automation.
The build is flutter build appbundle --release --obfuscate --split-debug-info=build/symbols/<version>, and those symbol files must be archived per build and uploaded to Crashlytics or Sentry, otherwise every obfuscated stack trace is unreadable and you can throw away your crash dashboard. Bump both the version name and the build number in pubspec.yaml, remembering that Play and App Store Connect both reject a reused build number. CI is usually GitHub Actions, Codemagic or Bitrise running analyze, test, golden tests and then the store upload via fastlane supply and deliver.
Ship as a staged rollout on Play, one percent then five then twenty, watching the crash-free-users metric and ANR rate before advancing, and keep a Play internal testing track for the team. Finally, decide your policy on code push tools such as Shorebird, useful for Dart-only hotfixes but subject to store rules, and make sure it never becomes a way to skip review.
# Flavored run against staging config
flutter run --flavor staging -t lib/main_staging.dart \
--dart-define-from-file=config/staging.json
# Production release, obfuscated, symbols retained
flutter build appbundle --release --flavor prod \
-t lib/main_prod.dart \
--dart-define-from-file=config/prod.json \
--obfuscate --split-debug-info=build/symbols/4.2.0
# Upload the symbols or your crash traces are hex soup
firebase crashlytics:symbols:upload \
--app=1:1234567890:android:abcdef build/symbols/4.2.0
# pubspec.yaml: versionName+buildNumber, both must increase
# version: 4.2.0+420
# Read a compile-time value injected above
const apiBase = String.fromEnvironment('API_BASE',
defaultValue: 'https://staging.goodspace.ai');
Key Points
- Flavors plus separate entry points keep staging and prod installable side by side
- --dart-define-from-file for config; assume nothing in the bundle is secret
- Archive and upload --split-debug-info symbols for every obfuscated build
- Staged Play rollout gated on crash-free users and ANR rate
Frequently Asked Questions
What does a Flutter developer earn in India in 2026?
Roughly ₹6-22 LPA depending on experience and employer. Freshers with a couple of shipped Play Store apps typically start around ₹4-7 LPA at service firms and small product companies, two to four years lands in the ₹9-15 LPA band, and senior engineers who can own release engineering, performance work and platform channel code reach ₹18-30 LPA at product companies like CRED, Groww, PhonePe and Zomato. Consulting shops in Bengaluru, Pune and Hyderabad that deliver Flutter for overseas clients pay somewhat less but hire in volume. The single biggest multiplier is being able to show a profiled, measurably fast app rather than a portfolio of tutorial screens.
How long does it take to prepare for a Flutter interview?
If you already write Flutter daily, two to three weeks of focused revision is enough: rebuild your understanding of the three trees, constraints and keys, be able to explain and defend one state management choice, and spend real time in DevTools profiling something you built. If you are coming from Android or React Native, budget six to eight weeks, because the layout protocol and the widget-rebuild model are genuinely different from what you know. Coming in with no mobile background at all, three to four months of consistent work including two shipped apps is a realistic target before you are competitive.
What is the difference between what freshers and experienced candidates get asked?
Fresher rounds stay on widgets, StatelessWidget versus StatefulWidget, setState, Row and Column layout, Navigator, and a small live build task such as a login screen with validation. From about two years onward the loop shifts to rebuild scoping, state management trade-offs with reasons rather than preferences, testing strategy, and at least one production failure story. Senior loops are mostly architecture and diagnosis: how you found a jank, how you cut app size, how you handled a bad release. If you have two or more years of experience and the interviewer never leaves widget basics, that is usually a signal about the role's ceiling.
Is Flutter still worth learning in 2026, or is it losing ground?
It is worth learning if you want to ship on Android and iOS with one team. Flutter remains the most widely used cross-platform UI toolkit in Indian product companies for consumer apps, and the hiring volume on Indian job boards reflects that. The honest caveats are that platform-heavy work (deep camera pipelines, widgets and live activities, background location) still needs native code, and that Kotlin Multiplatform is a real alternative for teams that want native UI with shared logic. If you want the strongest position, learn Flutter properly and keep enough Kotlin and Swift to write your own platform channel rather than waiting for a package to appear.
Should I learn Flutter or React Native?
Pick Flutter if you want one rendering model, predictable performance without a bridge in the hot path, and a strongly typed language; pick React Native if your team already writes React and TypeScript and you want to reuse those people and that code. In Indian hiring, Flutter roles are broader across consumer product companies while React Native roles concentrate where a web team already exists. Neither choice is wrong, but do commit to one for at least a year, because interviewers can tell within ten minutes whether you have depth in one framework or shallow exposure to two.
Do I need to know Dart deeply, or is Flutter enough?
You need the language properly. Sound null safety, futures and streams, the event loop, isolates, extension methods, mixins, sealed classes with pattern matching, and records all show up in real interviews, and several performance questions are really Dart questions in disguise. A candidate who cannot explain why an await does not move CPU work off the UI thread will fail the performance round no matter how many screens they have built. Spend a week on the language itself; it pays back more than another framework tutorial.
Introduction
Flutter is not a wrapper around native UI controls. The framework ships its own widget library, its own layout engine and its own renderer, so a Flutter app paints every pixel itself through Impeller (or Skia on older builds) onto a platform-provided surface. Dart compiles ahead of time to native ARM machine code in release mode, which is why a well-written Flutter app holds 60 or 120 frames per second with no JavaScript bridge in the hot path. That single architectural decision explains most of what interviewers actually ask about: the three-tree model, the constraint-based layout protocol, the UI thread versus raster thread split, and why const constructors matter more here than in almost any other UI toolkit.
Indian hiring for Flutter in 2026 has moved on from 'can you build a screen' to 'can you keep a large app fast and shippable'. Teams known for running Flutter at scale, including Google, CRED, Groww, PhonePe and Zomato, structure their loops around rebuild scoping and RepaintBoundary placement, state management trade-offs between Riverpod, BLoC and plain ChangeNotifier, platform channels and Pigeon, widget and golden tests wired into CI, release build size and obfuscation, crash symbolication, and the Impeller migration. Fresher rounds still stay on widgets and setState, but anything above two years of experience gets pushed straight into profiling and production failure modes.
This page covers 45 Flutter interview questions taken from real 2026 loops, ordered from fundamentals up to the senior topics that decide offers. Eighteen basic questions cover widgets, keys, BuildContext, layout constraints and daily tooling. Eighteen intermediate questions go into state management, slivers, channels, routing, testing and crash reporting. Nine advanced questions cover jank diagnosis in DevTools, isolates, Impeller, custom render objects, app size reduction, memory leaks, platform views, offline sync and release engineering. Most answers carry a runnable Dart snippet, because Flutter interviewers nearly always ask you to write the code rather than describe it.
Ready to practice Flutter interviews?
Don't just read, practice these Flutter questions live with an AI interviewer that asks follow-ups and scores your answers.