Dart & Flutter best practices

Dart & Flutter best practices

In this article I will share with you all the best practices with Dart & Flutter. In retrospect, I would have been very happy to have such a source of knowledge. Funny thing: it gets updated frequently when I remember something interesting.

Flutter Best Practices

Flutter is a great framework for building truly cross-platform applications. It is powerful and well organized by default, but there are many ways to make things worse. This section explains how to keep your application in good shape.

Effective build methods & widgets

In Flutter, the user interface is crafted by combining widgets. The key function, 'build method' that actually builds widgets, should be optimized. Here are a few tips to keep things running smoothly:

  • Keep your build method pure - no side effects or heavy tasks. It runs up to 120 times a second for frame building, so speed is key. For any calculations, use lifecycle events like initState, didUpdateWidget, or didChangeDependencies.
  • Split big build methods into smaller widgets. This lets Flutter more effectively identify and rebuild only the necessary parts, instead of recalculating the entire build each time.
  • Use const constructors for widgets and their subtrees. This aids Flutter's internal system in recognizing changes more efficiently. Plus, a canonical instance is created just once in memory.
  • Construct widgets on-demand (laziness). For instance, rather than initializing all widgets in an IndexedStack, opt for LazyIndexedStack, which initializes items only as they become necessary.
  • Choose SizedBox, ColoredBox, or DecoratedBox over Container. While Container internally uses these widgets, it performs additional calculations to determine the necessary composition.
  • Use InheritedModel when your InheritedWidget manages multiple aspects, like theme and locale. It ensures that only widgets interested in the changed information will receive updates.
  • Carefully implement the updateShouldNotify method in InheritedWidget. Ensure it returns true only when necessary for optimal performance.
  • Opt for using classes over helper methods when creating widgets. Helper methods can lead to unnecessary rebuilds and miss out on const optimizations. Classes offer better performance and maintainability.

Optimizing Paints and Repaints

In Flutter's rendering pipeline, the painting stage collects data to display on the screen. Learn, how to keep it optimized:

  • Utilize RepaintBoundary to limit areas that need frequent repainting, like animated widgets. It wraps the widget in a new layer, ensuring its frequent repaints don't impact surrounding elements (more here).
  • Drawing an Image or Color with built-in opacity is quicker than applying Opacity over them. Opacity can affect a group of widgets, leading to the use of resource-intensive offscreen buffers.
  • When your goal is to apply an ImageFilter to just one widget, choose ImageFiltered. It's simpler and more efficient for this specific task compared to BackdropFilter, which is used for applying filters to everything behind a widget.
  • Improve your UI performance by using debugging variables such as debugDisableOpacityLayers, debugDisableClipLayers, and debugDisablePhysicalShapeLayers. These tools help identify bottlenecks by disabling specific effects like opacity and clipping, making it easier to pinpoint issues.
  • Use intrinsic widgets sparingly, as they require speculative layout calculations, leading to multiple layout passes and complex operations. This can significantly slow down performance, especially in larger widget trees. It's more efficient to define the widget's constraints directly to avoid these performance issues.
  • Optimize your CustomPainter usage by ensuring the shouldRepaint method returns true only when necessary. This avoids unnecessary repaints.

Stateful widgets

A stateful widget uses a state object to manage its state and respond to lifecycle events.

  • Use Stateful Widgets when needed. If your component requires a state, go ahead – it's not complicated or burdensome. Essentially, both stateless and stateful widgets are similar elements with the same lifecycle.
  • Initiate resources like controllers, notifiers, subscriptions, and blocs in the initState method. Always close them in the dispose method. Avoid initializing anything in the build method for optimal performance.
  • Minimize the use of setState in your code. Use it sparingly, targeting only a small portion of the tree. Overuse can cause extensive rebuilds, resulting in unnecessary relayouts and repaints in the rendering objects.
  • Implement keys like ValueKeys, ObjectKeys, and UniqueKeys in your code. They enhance efficiency by identifying widgets. In certain cases, GlobalKeys are necessary for effective reparenting.
  • In complex, production-scale applications, it's vital to use advanced state-management solutions like BLoC for efficient and seamless development, rather than relying on setState.

Scrolling optimizations

Mastering scrolling in Flutter can be challenging. Start by understanding these essential basics:

  • To manage complex behaviors, opt for CustomScrollView over nesting ListViews and Columns. It enhances efficiency and simplifies the process.
  • For long lists, opt for ListView.builder and ListView, as they render only what's within the viewport. ListView.builder is especially suitable for scenarios like pagination with numerous items, creating widget instances as they're about to appear in the viewport. In contrast, ListView uses pre-initialized instances.
  • Avoid using SingleChildScrollView for long lists or content that always extends beyond the viewport. This widget renders all its child content, leading to inefficiency with large lists. It's ideal for smaller content that needs to be scrollable only if it exceeds the viewport size.
  • Set the itemExtent in ListView or employ SliverFixedExtentList for efficient scrolling. Utilizing prototypes and extent builders can also be beneficial. These methods aid the scrolling machinery by reducing the need for layout calculations.
  • For smoother scrolling, replace Columns, Rows, or Wraps with ListViews or Slivers. This change reduces excessive widget rendering and enhances efficiency.
  • Choose slivers over shrinkWrap as it calculates each item's extent and in long lists the performance degrades severely. Slivers are a better alternative and don't require these calculations.

Animation Tips

Animations make our applications dynamic and visually appealing. Here are some key insights:

  • Optimize AnimatedBuilder by utilizing the 'child' parameter. This ensures your widget is created just once and efficiently reused in animations, a crucial aspect for operations like Opacity and Clipping.
  • For animations involving opacity changes, prefer using AnimatedOpacity or FadeTransition widgets. These are more efficient as they avoid the costly process of rebuilding and repainting the subtree, unlike direct updates to Opacity values (more here).
  • Utilize an Interval curve for chained animations. This keeps the animation's value at 0.0 until the animation controller crosses the specified threshold.
  • For smooth animation integration, opt for built-in SDK widgets like FadeTransition, AnimatedOpacity, AnimatedSwitcher, SizeTransition, and CrossFadeTransition, among others. These significantly enhance the UI.
  • For advanced, effect-intensive animations, consider using Lottie or Rive. These tools offer superior performance compared to manual methods.

Assets

Icons, images, and logos are vital in every application and can often be performance bottlenecks. Consider these strategies for smoother operation:

  • Choose icon fonts instead of SVGs for better performance. They're more efficient, avoiding the heavy parsing associated with flutter_svg and eliminating the need to load files from bundles. Look here for problems with SVGs and here to create icon fonts.
  • For logos, switch to CustomPaint instead of SVGs or PNGs. This vector graphics approach boosts performance and allows for animation. Convert SVGs easily to custom painters with Flutter Shape Maker.
  • Enhance performance by caching network images with CachedNetworkImage or similar tools. This reduces repeated image requests and enhances the performance.
  • Optimize network images to manage RAM usage effectively. Large images, like 4k ones around 30MB, can be downscaled to the necessary display size using cacheWidth, cacheHeight parameters, or the ResizeImage provider. Flutter also provides debugging tools that alert you to oversized images.
  • Generate paths to assets instead of writing them yourself. This ensures that you don't make mistakes and reduces the amount of code.

Non-categorized

  • Revise your code by initializing streams and futures in the initState method. This prevents them from being unnecessarily recomputed with every trigger of the build.
  • Leverage Flutter DevTools for detailed performance analysis, which is essential for identifying weaknesses and memory leaks. Additionally, consistently utilize the Widget Inspector, an invaluable tool for examining your UI.
  • Adopt declarative navigation by using the Navigator pages API, and consider integrating libraries like go_router. However, ensure it aligns with your specific needs before implementation.
  • Configure the restoration bucket and identifiers to implement state recovery when the application is unloaded from memory. This is useful for list views (to save the position), navigator (to save the stack) and other things.

Dart-Specific Tips

Performance

  • Prefer traditional 'for' loops to 'forEach' for better performance and flexibility. 'forEach' can slow down your code and lacks support for 'await', 'break', and 'return' statements.
  • Consider creating performance benchmarks using benchmark_harness library from Dart team.
  • Try to limit recursion: Dart lacks tail call optimization, so each recursive call adds to the call stack. Too many recursive calls can cause a stack overflow, where the program runs out of memory.
  • Shift intensive computations away from Dart's main isolate to prevent slowing down or blocking the app.
  • Always override both '==' and 'hashCode' when defining equality in Dart. This is essential because structures like hash sets and maps depend on these functions.
  • Implement null safety operators and avoid the '!' operator without null checks to prevent runtime errors and ensure app stability.
  • Choose List.of over List.from for creating lists. This helps preserve typing of the list.
  • Opt for Streams or Futures over Future.wait. Future.wait can lead to collective failure if one future fails, loss of type information, and limits individual event and error handling.

Errors & Exceptions

Flutter distinguishes between two interfaces: Error and Exception. Despite similar names, they significantly differ in function and purpose:

  • Do not catch Errors(StateError or OutOfMemoryError), as they signal irreparable issues. Focus on catching Exceptions, which are designed to be caught and offer valuable failure insights.
  • Consider using exceptions for error handling. When wrapping an error in a custom type, employ Error.throwWithStackTrace to preserve the stack trace.
  • Avoid ignoring exceptions: always handle or rethrow them in the catch block to address unforeseen issues.
  • Consider developing specialized subclasses of Exception tailored to your components for better error handling.
  • Restrict your throws to Error and Exception types only.

Architecture

worms eye view of buildings
Photo by Alex wong / Unsplash

General architecture tips about keeping your modules and system maintainable.

Design Principles

General guidelines to keep your components in a good shape.

  • Single Responsibility Principle – a module should serve only one actor, making it the sole reason for any changes.
  • Open-Closed Principle – a module should be opened for extension, but closed for modification. This also correlates with the Strangler Fig Pattern.
  • Liskov Substitution Principle – objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program.
  • Interface Segregation Principle – opt for multiple, smaller interfaces over a single, large one. This ensures clients use only the interfaces they need.
  • Dependency Inversion Principle – depend on abstractions rather concrete implementations. This improves testability and makes modules reusable.
  • Separation of concerns - organize code into distinct sections, each addressing a specific functionality or concern. This often involves layering the architecture for better organization and clarity.
  • Low coupling & high cohesion - low coupling in software design means creating components with minimal dependencies on each other, enhancing maintainability. High cohesion involves designing modules focused on a specific set of tasks, making them easier to understand and manage.
  • KISS – make it short and straightforward. Simple is easier to understand and manage.
  • DRY – don't repeat yourself.
  • YAGNI - you aren't gonna need it.

Design Patterns

Business Logic Component

It's a design pattern separating business logic from Widgets, using a Final State Machine with a single input (events) and output (states).

General tips

  • Don't create dependencies between blocs, as this results in tight coupling. No bloc should know about any other bloc. They should only receive information through events or repositories (more here).
  • Avoid creating public fields and methods in blocs. This is required for consistent and predictable flow .The only communication should go through events and states.
  • States must describe the current state of the bloc (idle, processing, success, error). There should be no states associated with UI like "ShowDialog" or "ShowSnackbar".
  • Don't use BLoCs to manage ephemeral states (such as a page in a page view or a value in an animation). This is not the concern of the BLoC.

Bloc package

  • Avoid using Cubit - it breaks the order of events as you can't configure the transform. Instead, use BLoC with a sequential transformer.
  • Don't register more than one "on" callback. This breaks the order of events and may lead to inconsistent states as each "on" handler manages own stream.
  • Always set up BlocObserver for debugging purposes. This helps to understand the transitions between states, events and all the operations with BLoCs.
  • Always override equality for states. Bloc compares the previous state with the new one, and if they are "equal", it doesn't notify about the new one.

Resources

End

text
Photo by Markus Spiske / Unsplash

This article comes to its end, but don't get upset! It will be updated each time I remember something interesting :). Stay tuned and periodically revisit this page for new information.

Follow my LinkedIn page with valuable daily posts and telegram with insights!

Read more