mirror of
https://github.com/block/buzz.git
synced 2026-11-14 13:21:02 +00:00
perf(mobile): stop rebuilding the native nav bar on every scrolled frame (#9458)
On iOS, `FrostedAppBar` built its native `IosNavigationBar` inside the `ValueListenableBuilder` for the page's scroll offset, so every scrolled frame rebuilt the bar. Each rebuild `jsonEncode`s the whole native payload to see whether it changed, and that payload carries the header's avatars: base64 image data, and the data URLs of people whose profile picture is one (100 KB or more each). The bar already follows the offset itself, through its own listener that sends `scroll` to UIKit, so none of that work was needed. The bar is now built once per `FrostedAppBar` build, outside the offset builder. Scrolling still rebuilds the positioned frame around it (its height follows the large title), but passes the same bar widget, so Flutter skips it. ### Validation New test: scrolling a `FrostedScaffold` page leaves the same `IosNavigationBar` instance in the tree. It fails without this change. Full mobile test suite, `flutter analyze` and the file-size check pass. On the iPhone 12 mini (profile build, Builderlab), opening a busy channel whose header shows member avatars, and scrolling history with 10 swipes (about 30 seconds): | | `IosNavigationBar.build` (CPU samples) | BUILD total | LAYOUT total | UI frame time total | | --- | --- | --- | --- | --- | | Before | 1,581 ms | 3,304 ms | 3,514 ms | 7,013 ms | | After | 0 ms (no samples) | 591 ms | 748 ms | 4,718 ms | The remaining long frames (up to 130 ms) are older history loading in, and are the same before and after. The automated run covered a cold start, search, opening the channel from a search result, and scrolling its history. ### Regressions None expected. The bar's inputs don't depend on the offset; only the frame's height and animation duration do, and those still update per frame. Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import 'package:duo_kit/duo_kit.dart';
|
||||
|
||||
import 'duo_toolbar.dart';
|
||||
|
||||
import 'dart:math' show max;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -312,6 +314,41 @@ class FrostedAppBar extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
final offset = IosNavigationScrollScope.maybeOf(context);
|
||||
// The bar reads the scroll offset itself, so scrolling rebuilds only
|
||||
// the frame around it, not its native payload.
|
||||
final nativeBar = IosNavigationBar(
|
||||
centerTitleInPane: strip != null && !nativeLargeTitle,
|
||||
titleInsets: nativeTitleInsets == EdgeInsets.zero
|
||||
? railInsets
|
||||
: nativeTitleInsets,
|
||||
alignCollapsedTitleToLeading: strip != null && nativeLargeTitle,
|
||||
horizontalControls:
|
||||
strip != null &&
|
||||
((leadingAction?.keepInHeaderOnDuo ?? false) ||
|
||||
trailingActions.any((action) => action.keepInHeaderOnDuo)),
|
||||
onReadyChanged: onNativeReadyChanged,
|
||||
title: nativeTitle ?? (title is Text ? (title as Text).data ?? '' : ''),
|
||||
subtitle: nativeSubtitle,
|
||||
ephemeralLabel: nativeEphemeralLabel,
|
||||
titleAvatar: nativeTitleAvatar,
|
||||
titleAvatars: nativeTitleAvatars,
|
||||
titleMaxWidth: nativeTitleMaxWidth,
|
||||
titlePresenceColor: nativeTitlePresenceColor,
|
||||
titleRefreshing: nativeTitleRefreshing,
|
||||
onTitlePressed: onNativeTitlePressed,
|
||||
largeTitle: nativeLargeTitle,
|
||||
alwaysFrosted: alwaysFrosted || strip != null,
|
||||
foregroundColor: iconColor,
|
||||
leading: strip == null || (leadingAction?.keepInHeaderOnDuo ?? false)
|
||||
? leadingAction
|
||||
: null,
|
||||
onBack: strip == null ? onBack : null,
|
||||
actions: strip == null
|
||||
? trailingActions
|
||||
: trailingActions
|
||||
.where((action) => action.keepInHeaderOnDuo)
|
||||
.toList(),
|
||||
);
|
||||
Widget buildNative(double scrollOffset) {
|
||||
// Scrolling tracks the finger directly; only the search focus change
|
||||
// uses the coordinated layout transition.
|
||||
@@ -345,46 +382,7 @@ class FrostedAppBar extends StatelessWidget {
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: barHeight,
|
||||
child: IosNavigationBar(
|
||||
centerTitleInPane: strip != null && !nativeLargeTitle,
|
||||
titleInsets: nativeTitleInsets == EdgeInsets.zero
|
||||
? railInsets
|
||||
: nativeTitleInsets,
|
||||
alignCollapsedTitleToLeading:
|
||||
strip != null && nativeLargeTitle,
|
||||
horizontalControls:
|
||||
strip != null &&
|
||||
((leadingAction?.keepInHeaderOnDuo ?? false) ||
|
||||
trailingActions.any(
|
||||
(action) => action.keepInHeaderOnDuo,
|
||||
)),
|
||||
onReadyChanged: onNativeReadyChanged,
|
||||
title:
|
||||
nativeTitle ??
|
||||
(title is Text ? (title as Text).data ?? '' : ''),
|
||||
subtitle: nativeSubtitle,
|
||||
ephemeralLabel: nativeEphemeralLabel,
|
||||
titleAvatar: nativeTitleAvatar,
|
||||
titleAvatars: nativeTitleAvatars,
|
||||
titleMaxWidth: nativeTitleMaxWidth,
|
||||
titlePresenceColor: nativeTitlePresenceColor,
|
||||
titleRefreshing: nativeTitleRefreshing,
|
||||
onTitlePressed: onNativeTitlePressed,
|
||||
largeTitle: nativeLargeTitle,
|
||||
alwaysFrosted: alwaysFrosted || strip != null,
|
||||
foregroundColor: iconColor,
|
||||
leading:
|
||||
strip == null ||
|
||||
(leadingAction?.keepInHeaderOnDuo ?? false)
|
||||
? leadingAction
|
||||
: null,
|
||||
onBack: strip == null ? onBack : null,
|
||||
actions: strip == null
|
||||
? trailingActions
|
||||
: trailingActions
|
||||
.where((action) => action.keepInHeaderOnDuo)
|
||||
.toList(),
|
||||
),
|
||||
child: nativeBar,
|
||||
),
|
||||
if (bottom != null)
|
||||
AnimatedPositioned(
|
||||
|
||||
@@ -379,6 +379,41 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('scrolling does not rebuild the native bar', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
final controller = ScrollController();
|
||||
addTearDown(controller.dispose);
|
||||
await tester.pumpWidget(
|
||||
_testApp(
|
||||
home: FrostedScaffold(
|
||||
appBar: const FrostedAppBar(title: Text('Conversation')),
|
||||
body: ListView(
|
||||
controller: controller,
|
||||
children: [
|
||||
for (var i = 0; i < 40; i++)
|
||||
SizedBox(height: 60, child: Text('Message $i')),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final before = tester.widget<IosNavigationBar>(
|
||||
find.byType(IosNavigationBar),
|
||||
);
|
||||
controller.jumpTo(300);
|
||||
await tester.pumpAndSettle();
|
||||
// The same widget instance is not rebuilt, so its native payload is not
|
||||
// encoded again for every scrolled frame.
|
||||
expect(
|
||||
tester.widget<IosNavigationBar>(find.byType(IosNavigationBar)),
|
||||
same(before),
|
||||
);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
|
||||
testWidgets('native material follows live theme surface changes', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
Reference in New Issue
Block a user