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:
- The use of function based widgets caused incessant rebuilds; widgets returned by functions constitute unique instances requiring a fresh build/paint.
- 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.
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;
}
}
// 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,
);
}
}
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. ValueKey
s 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();
}
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);
},
);
}
}
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 (5)
Why didn't you publish a PR for this so the changes can be useful upstream?
I will endeavor to do so.
Thank you for sharing, you should make youtube tutorials ❤️
Some comments may only be visible to logged-in visitors. Sign in to view all comments.