DEV Community

Cover image for Unlocking improved Flutter performance; a case for Keys
Dirisu Jesse
Dirisu Jesse

Posted on

Unlocking improved Flutter performance; a case for Keys

We recently wrote an app akin to Instagram but for pets, for such apps, list views permitting doom scrolling till infinity are critical. Unfortunately we hit an unexpected performance bottle neck here, try as we might, nothing we did fixed the very noticeable jank when users scrolled up the said list.

After days of debugging, we found scroll performance to be normal when styled_text was replaced with the vanilla Text widget. Ordinarily this would have led to removing said package, but it is so useful for presenting complex rich text, that we considered it worthwhile attempting a refactor. Thus we proceeded to review the codebase for the package, and immediately 2 hypothesis for the suboptimal performance occurred to us; namely:

  1. The use of function based widgets caused incessant rebuilds; widgets returned by functions constitute unique instances requiring a fresh build/paint.

The CustomStyledTextWidget

The _buildSelectableText method returning a RichText widget

The _buildText method returning a SelectableText.rich widget

  1. Keys were not assigned to the widgets presenting the parsed text; We believe that if a widget with a specified key is not marked for build, it may not be redrawn in the next frame.

CustomStyledTextWidget without key

These suggest a potentially simple fix, and informed our 2 step strategy. Firstly we refactored the function based widgets into separate classes, StyledRichText for the non selectable form and StyledSelectableText for the selectable form.

// Non Selectable RichText
...

class StyledRichText extends StatelessWidget {
  final TextSpan textSpan;
  final TextAlign? textAlign;
  final TextDirection? textDirection;
  final bool? softWrap;
  final TextOverflow? overflow;
  final TextScaler textScaler;
  final int? maxLines;
  final Locale? locale;
  final StrutStyle? strutStyle;
  final TextWidthBasis? textWidthBasis;
  final ui.TextHeightBehavior? textHeightBehavior;

  const StyledRichText(
    this.textSpan, {
    super.key,
    Map<String, StyledTextTagBase>? tags,
    this.textAlign,
    this.textDirection,
    this.softWrap = true,
    this.overflow,
    required this.textScaler,
    this.maxLines,
    this.locale,
    this.strutStyle,
    this.textWidthBasis,
    this.textHeightBehavior,
  });

  @override
  Widget build(BuildContext context) {
    final defaultTextStyle = DefaultTextStyle.of(context);
    final registrar = SelectionContainer.maybeOf(context);

    Widget result = RichText(
      textAlign: textAlign ?? defaultTextStyle.textAlign ?? TextAlign.start,
      textDirection: textDirection,
      softWrap: softWrap ?? defaultTextStyle.softWrap,
      overflow:
          overflow ?? textSpan.style?.overflow ?? defaultTextStyle.overflow,
      textScaler: textScaler,
      maxLines: maxLines ?? defaultTextStyle.maxLines,
      locale: locale,
      strutStyle: strutStyle,
      textWidthBasis: textWidthBasis ?? defaultTextStyle.textWidthBasis,
      textHeightBehavior: textHeightBehavior ??
          defaultTextStyle.textHeightBehavior ??
          DefaultTextHeightBehavior.maybeOf(context),
      text: textSpan,
      selectionRegistrar: registrar,
      selectionColor: DefaultSelectionStyle.of(context).selectionColor,
    );

    if (registrar != null) {
      result = MouseRegion(
        cursor: SystemMouseCursors.text,
        child: result,
      );
    }

    return result;
  }
}

Enter fullscreen mode Exit fullscreen mode
// Selectable Styled Text
...

class StyledSelectableText extends StatelessWidget {
  ...

  const StyledSelectableText(
    this.textSpan, {
    super.key,
    Map<String, StyledTextTagBase>? tags,
    this.textAlign,
    this.textDirection,
    this.textScaler,
    this.maxLines,
    this.strutStyle,
    this.textWidthBasis,
    this.textHeightBehavior,
    FocusNode? focusNode,
    bool showCursor = false,
    bool autofocus = false,
    @Deprecated(
      'Use `contextMenuBuilder` instead. '
      'This feature was deprecated after Flutter v3.3.0-0.5.pre.',
    )
    // ignore: deprecated_member_use
    ToolbarOptions? toolbarOptions,
    EditableTextContextMenuBuilder? contextMenuBuilder,
    TextSelectionControls? selectionControls,
    ui.BoxHeightStyle selectionHeightStyle = ui.BoxHeightStyle.tight,
    ui.BoxWidthStyle selectionWidthStyle = ui.BoxWidthStyle.tight,
    SelectionChangedCallback? onSelectionChanged,
    TextMagnifierConfiguration? magnifierConfiguration,
    double cursorWidth = 2.0,
    double? cursorHeight,
    Radius? cursorRadius,
    Color? cursorColor,
    DragStartBehavior dragStartBehavior = DragStartBehavior.start,
    bool enableInteractiveSelection = true,
    GestureTapCallback? onTap,
    ScrollPhysics? scrollPhysics,
    String? semanticsLabel,
  })  : this._focusNode = focusNode,
        this._showCursor = showCursor,
        this._autofocus = autofocus,
        this._toolbarOptions = toolbarOptions ??
            // ignore: deprecated_member_use
            const ToolbarOptions(
              selectAll: true,
              copy: true,
            ),
        this._contextMenuBuilder = contextMenuBuilder,
        this._selectionHeightStyle = selectionHeightStyle,
        this._selectionWidthStyle = selectionWidthStyle,
        this._selectionControls = selectionControls,
        this._onSelectionChanged = onSelectionChanged,
        this._magnifierConfiguration = magnifierConfiguration,
        this._cursorWidth = cursorWidth,
        this._cursorHeight = cursorHeight,
        this._cursorRadius = cursorRadius,
        this._cursorColor = cursorColor,
        this._dragStartBehavior = dragStartBehavior,
        this._enableInteractiveSelection = enableInteractiveSelection,
        this._onTap = onTap,
        this._scrollPhysics = scrollPhysics,
        this._semanticsLabel = semanticsLabel;

  ...

  @override
  Widget build(BuildContext context) {
    return SelectableText.rich(
      textSpan,
      focusNode: _focusNode,
      showCursor: _showCursor,
      autofocus: _autofocus,
      // ignore: deprecated_member_use
      toolbarOptions: _toolbarOptions,
      contextMenuBuilder: _contextMenuBuilder ?? _defaultContextMenuBuilder,
      selectionControls: _selectionControls,
      selectionHeightStyle: _selectionHeightStyle!,
      selectionWidthStyle: _selectionWidthStyle!,
      onSelectionChanged: _onSelectionChanged,
      magnifierConfiguration: _magnifierConfiguration,
      cursorWidth: _cursorWidth!,
      cursorHeight: _cursorHeight,
      cursorRadius: _cursorRadius,
      cursorColor: _cursorColor,
      dragStartBehavior: _dragStartBehavior,
      enableInteractiveSelection: _enableInteractiveSelection,
      onTap: _onTap,
      scrollPhysics: _scrollPhysics,
      textWidthBasis: textWidthBasis,
      textHeightBehavior: textHeightBehavior,
      textAlign: textAlign,
      textDirection: textDirection,
      textScaler: textScaler,
      maxLines: maxLines,
      strutStyle: strutStyle,
      semanticsLabel: _semanticsLabel,
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Then we assigned the widgets keys, to identify them in the element tree, preventing rebuilds if not marked for build (denoted as dirty). We settled on using ValueKeys based off the value of the text field of the StyledText class. ValueKeys can take any licit dart Object as its constructor argument, however Key accepts only String objects, this makes them more flexible. Internally the apparent constructor for Key instantiates an instance of ValueKey<String>.

@immutable
@pragma('flutter:keep-to-string-in-subtypes')
abstract class Key {
  /// Construct a [ValueKey<String>] with the given [String].
  ///
  /// This is the simplest way to create keys.
  const factory Key(String value) = ValueKey<String>;

  /// Default constructor, used by subclasses.
  ///
  /// Useful so that subclasses can call us, because the [Key.new] factory
  /// constructor shadows the implicit constructor.
  @protected
  const Key.empty();
}
Enter fullscreen mode Exit fullscreen mode

Consequently the StyledText widget was transformed into the following:

...

 const StyledText({
    super.key,
    required this.text,
    this.newLineAsBreaks = true,
    this.style,
    Map<String, StyledTextTagBase>? tags,
    this.textAlign,
    this.textDirection,
    this.softWrap = true,
    this.overflow,
    this.textScaleFactor,
    this.textScaler,
    this.maxLines,
    this.locale,
    this.strutStyle,
    this.textWidthBasis,
    this.textHeightBehavior,
    this.parseAsynchronously = false,
  })  : this.tags = tags ?? const {},
        this.selectable = false,
        this._focusNode = null,
        this._showCursor = false,
        this._autofocus = false,
        this._toolbarOptions = null,
        this._contextMenuBuilder = null,
        this._selectionControls = null,
        this._selectionHeightStyle = null,
        this._selectionWidthStyle = null,
        this._onSelectionChanged = null,
        this._magnifierConfiguration = null,
        this._cursorWidth = null,
        this._cursorHeight = null,
        this._cursorRadius = null,
        this._cursorColor = null,
        this._dragStartBehavior = DragStartBehavior.start,
        this._enableInteractiveSelection = false,
        this._onTap = null,
        this._scrollPhysics = null,
        this._semanticsLabel = null,
        assert(!(textScaleFactor != null && textScaler != null));

 ...
  const StyledText.selectable({
    super.key,
    required this.text,
    this.newLineAsBreaks = false,
    this.style,
    Map<String, StyledTextTagBase>? tags,
    this.textAlign,
    this.textDirection,
    this.textScaleFactor,
    this.textScaler,
    this.maxLines,
    this.strutStyle,
    this.textWidthBasis,
    this.textHeightBehavior,
    FocusNode? focusNode,
    bool showCursor = false,
    bool autofocus = false,
    this.parseAsynchronously = false,
    @Deprecated(
      'Use `contextMenuBuilder` instead. '
      'This feature was deprecated after Flutter v3.3.0-0.5.pre.',
    )
    // ignore: deprecated_member_use
    ToolbarOptions? toolbarOptions,
    EditableTextContextMenuBuilder contextMenuBuilder =
        _defaultContextMenuBuilder,
    TextSelectionControls? selectionControls,
    ui.BoxHeightStyle selectionHeightStyle = ui.BoxHeightStyle.tight,
    ui.BoxWidthStyle selectionWidthStyle = ui.BoxWidthStyle.tight,
    SelectionChangedCallback? onSelectionChanged,
    TextMagnifierConfiguration? magnifierConfiguration,
    double cursorWidth = 2.0,
    double? cursorHeight,
    Radius? cursorRadius,
    Color? cursorColor,
    DragStartBehavior dragStartBehavior = DragStartBehavior.start,
    bool enableInteractiveSelection = true,
    GestureTapCallback? onTap,
    ScrollPhysics? scrollPhysics,
    String? semanticsLabel,
  })  : this.tags = tags ?? const {},
        this.selectable = true,
        this.softWrap = true,
        this.overflow = TextOverflow.clip,
        this.locale = null,
        this._focusNode = focusNode,
        this._showCursor = showCursor,
        this._autofocus = autofocus,
        this._toolbarOptions = toolbarOptions ??
            // ignore: deprecated_member_use
            const ToolbarOptions(
              selectAll: true,
              copy: true,
            ),
        this._contextMenuBuilder = contextMenuBuilder,
        this._selectionHeightStyle = selectionHeightStyle,
        this._selectionWidthStyle = selectionWidthStyle,
        this._selectionControls = selectionControls,
        this._onSelectionChanged = onSelectionChanged,
        this._magnifierConfiguration = magnifierConfiguration,
        this._cursorWidth = cursorWidth,
        this._cursorHeight = cursorHeight,
        this._cursorRadius = cursorRadius,
        this._cursorColor = cursorColor,
        this._dragStartBehavior = dragStartBehavior,
        this._enableInteractiveSelection = enableInteractiveSelection,
        this._onTap = onTap,
        this._scrollPhysics = scrollPhysics,
        this._semanticsLabel = semanticsLabel;

...

  @override
  Widget build(BuildContext context) {
    ...

    return CustomStyledText(
      key: ValueKey(text),
      style: style,
      newLineAsBreaks: newLineAsBreaks,
      text: text,
      tags: tags,
      builder: (BuildContext context, TextSpan textSpan) {
        if (!selectable) {
          return StyledRichText(
            textSpan,
            textScaler: effectiveTextScaler,
            textAlign: textAlign,
            textDirection: textDirection,
            softWrap: softWrap,
            overflow: overflow,
            maxLines: maxLines,
            locale: locale,
            strutStyle: strutStyle,
            textWidthBasis: textWidthBasis,
            textHeightBehavior: textHeightBehavior,
            key: ValueKey("$text-plain-rich"),
          );
        }
        return StyledSelectableText(
          textSpan,
          focusNode: _focusNode,
          showCursor: _showCursor,
          autofocus: _autofocus,
          // ignore: deprecated_member_use, deprecated_member_use_from_same_package
          toolbarOptions: _toolbarOptions,
          contextMenuBuilder: _contextMenuBuilder,
          selectionControls: _selectionControls,
          selectionHeightStyle: _selectionHeightStyle!,
          selectionWidthStyle: _selectionWidthStyle!,
          onSelectionChanged: _onSelectionChanged,
          magnifierConfiguration: _magnifierConfiguration,
          cursorWidth: _cursorWidth!,
          cursorHeight: _cursorHeight,
          cursorRadius: _cursorRadius,
          cursorColor: _cursorColor,
          dragStartBehavior: _dragStartBehavior,
          enableInteractiveSelection: _enableInteractiveSelection,
          onTap: _onTap,
          scrollPhysics: _scrollPhysics,
          textWidthBasis: textWidthBasis,
          textHeightBehavior: textHeightBehavior,
          textAlign: textAlign,
          textDirection: textDirection,
          textScaler: effectiveTextScaler,
          maxLines: maxLines,
          strutStyle: strutStyle,
          semanticsLabel: _semanticsLabel,
          key: ValueKey("$text-selectable-rich"),
        );
      },
      textParserBuilder: (onTag, onParsed) {
        if (!parseAsynchronously) {
          return StyledTextParserSync(onTag: onTag, onParsed: onParsed);
        }
        return StyledTextParserAsync(onTag: onTag, onParsed: onParsed);
      },
    );
  }
}

Enter fullscreen mode Exit fullscreen mode

These rather minor changes resulted in noticeable performance improvements as shown below:

The performance improvement is undeniable, scrolling up is smoother, no longer does the scroll position jump as a result of concurrent builds. The simple act of adding keys to widgets improved performance by:

  • helping the Flutter Engine associate a specific identifier, to the element configured by the widget in the element tree during traversal
  • thereby preventing unnecessary rebuilds if not marked for a build
  • key essentially memoises widget rendering under the right circumstances

We hope that we have convincingly highlighted the importance of keys to flutter widgets, proving them to be key performance improvement tools. The repository containing the refactor resides here, thanks very much for your time.

Top comments (4)

Collapse
 
joeschr profile image
JoeSchr

Why didn't you publish a PR for this so the changes can be useful upstream?

Collapse
 
dirisujesse profile image
Dirisu Jesse

I will endeavor to do so.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.