From 9d7462ddc67e79234d03eed2392872ec64564654 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:22:11 +0000 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9A=A1=20Bolt:=20Memoize=20M3EThemeData?= =?UTF-8?q?=20in=20m3eThemeBridgeBuilder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .jules/bolt.md | 5 ++ lib/theme/m3e_bridge.dart | 38 ++++++++- test/unit/theme/m3e_bridge_test.dart | 115 +++++++++++++++++++++++++++ 3 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 .jules/bolt.md create mode 100644 test/unit/theme/m3e_bridge_test.dart diff --git a/.jules/bolt.md b/.jules/bolt.md new file mode 100644 index 0000000..5c5a683 --- /dev/null +++ b/.jules/bolt.md @@ -0,0 +1,5 @@ +## 2025-09-02 - Memoize M3EThemeData in m3eThemeBridgeBuilder + +**经验心得:** `m3eThemeBridgeBuilder` 挂载在 `MaterialApp.builder` 层级,每次根级或 Overlay 重建时均会触发。原实现每次直接调用 `buildM3EThemeData(Theme.of(context))`,造成 `M3EThemeData`、`M3EColorScheme` 和 `mui.TextTheme` 的冗余实例创建。使用 `StatefulWidget` 缓存 `M3EThemeData` 并仅在 `ThemeData` 改变时重建,在不改变应用功能前提下避免了无谓的 GC 压力与 CPU 消耗。 + +**后续行动:** 在处理挂载于顶级 Builder / Provider 的桥接转换组件时,务必注意入参到转换结果的缓存/记忆化处理。 diff --git a/lib/theme/m3e_bridge.dart b/lib/theme/m3e_bridge.dart index fae3a22..ed49cb2 100644 --- a/lib/theme/m3e_bridge.dart +++ b/lib/theme/m3e_bridge.dart @@ -36,10 +36,40 @@ M3EThemeData buildM3EThemeData(ThemeData theme) { /// 真实的主题色。挂在 builder 里是必须的:只有位于 Overlay 之上的祖先, /// 面板(overlay child)才能通过继承查找到。 Widget m3eThemeBridgeBuilder(BuildContext context, Widget? child) { - return M3ETheme( - data: buildM3EThemeData(Theme.of(context)), - child: child ?? const SizedBox.shrink(), - ); + return _M3EThemeBridgeWidget(child: child); +} + +/// 内部 Widget,通过缓存 [M3EThemeData] 避免每次父级重建时重复构造等价主题。 +class _M3EThemeBridgeWidget extends StatefulWidget { + const _M3EThemeBridgeWidget({this.child}); + + final Widget? child; + + @override + State<_M3EThemeBridgeWidget> createState() => _M3EThemeBridgeWidgetState(); +} + +class _M3EThemeBridgeWidgetState extends State<_M3EThemeBridgeWidget> { + ThemeData? _cachedTheme; + M3EThemeData? _cachedM3eTheme; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + // ⚡ Bolt Optimization: Memoize M3EThemeData construction. + // Rebuilding buildM3EThemeData on every widget build allocates new + // M3EColorScheme and M3ETypeScale objects. Caching avoids unnecessary + // allocations when theme properties haven't changed. + if (_cachedTheme != theme || _cachedM3eTheme == null) { + _cachedTheme = theme; + _cachedM3eTheme = buildM3EThemeData(theme); + } + + return M3ETheme( + data: _cachedM3eTheme!, + child: widget.child ?? const SizedBox.shrink(), + ); + } } M3EColorScheme _toM3eColorScheme(ColorScheme cs) { diff --git a/test/unit/theme/m3e_bridge_test.dart b/test/unit/theme/m3e_bridge_test.dart new file mode 100644 index 0000000..739953a --- /dev/null +++ b/test/unit/theme/m3e_bridge_test.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:janus/theme/m3e_bridge.dart'; +import 'package:material_3_expressive/material_3_expressive.dart'; + +void main() { + group('M3E Bridge', () { + test('buildM3EThemeData builds M3EThemeData matching Flutter ThemeData', () { + final theme = ThemeData.light(); + final m3eThemeData = buildM3EThemeData(theme); + + expect(m3eThemeData.colorScheme.primary, equals(theme.colorScheme.primary)); + expect(m3eThemeData.brightness, equals(theme.brightness)); + }); + + testWidgets('m3eThemeBridgeBuilder memoizes M3EThemeData when theme does not change', (tester) async { + M3EThemeData? firstM3eTheme; + M3EThemeData? secondM3eTheme; + + final theme = ThemeData.light(); + + await tester.pumpWidget( + Theme( + data: theme, + child: Builder( + builder: (context) { + return m3eThemeBridgeBuilder( + context, + Builder( + builder: (ctx) { + firstM3eTheme = M3ETheme.of(ctx); + return const SizedBox(); + }, + ), + ); + }, + ), + ), + ); + + expect(firstM3eTheme, isNotNull); + + // Rebuild with identical theme instance + await tester.pumpWidget( + Theme( + data: theme, + child: Builder( + builder: (context) { + return m3eThemeBridgeBuilder( + context, + Builder( + builder: (ctx) { + secondM3eTheme = M3ETheme.of(ctx); + return const SizedBox(); + }, + ), + ); + }, + ), + ), + ); + + expect(secondM3eTheme, isNotNull); + expect(identical(firstM3eTheme, secondM3eTheme), isTrue); + }); + + testWidgets('m3eThemeBridgeBuilder updates M3EThemeData when theme changes', (tester) async { + M3EThemeData? lightM3eTheme; + M3EThemeData? darkM3eTheme; + + await tester.pumpWidget( + Theme( + data: ThemeData.light(), + child: Builder( + builder: (context) { + return m3eThemeBridgeBuilder( + context, + Builder( + builder: (ctx) { + lightM3eTheme = M3ETheme.of(ctx); + return const SizedBox(); + }, + ), + ); + }, + ), + ), + ); + + await tester.pumpWidget( + Theme( + data: ThemeData.dark(), + child: Builder( + builder: (context) { + return m3eThemeBridgeBuilder( + context, + Builder( + builder: (ctx) { + darkM3eTheme = M3ETheme.of(ctx); + return const SizedBox(); + }, + ), + ); + }, + ), + ), + ); + + expect(lightM3eTheme, isNotNull); + expect(darkM3eTheme, isNotNull); + expect(identical(lightM3eTheme, darkM3eTheme), isFalse); + expect(darkM3eTheme!.brightness, equals(Brightness.dark)); + }); + }); +} From 11371ff0f96d73faf79f0e9eb8f035d1e7852cf6 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:30:24 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=E2=9A=A1=20Bolt:=20Memoize=20M3EThemeData?= =?UTF-8?q?=20in=20m3eThemeBridgeBuilder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/unit/theme/m3e_bridge_test.dart | 194 ++++++++++++++------------- 1 file changed, 103 insertions(+), 91 deletions(-) diff --git a/test/unit/theme/m3e_bridge_test.dart b/test/unit/theme/m3e_bridge_test.dart index 739953a..46d43be 100644 --- a/test/unit/theme/m3e_bridge_test.dart +++ b/test/unit/theme/m3e_bridge_test.dart @@ -5,111 +5,123 @@ import 'package:material_3_expressive/material_3_expressive.dart'; void main() { group('M3E Bridge', () { - test('buildM3EThemeData builds M3EThemeData matching Flutter ThemeData', () { - final theme = ThemeData.light(); - final m3eThemeData = buildM3EThemeData(theme); + test( + 'buildM3EThemeData builds M3EThemeData matching Flutter ThemeData', + () { + final theme = ThemeData.light(); + final m3eThemeData = buildM3EThemeData(theme); - expect(m3eThemeData.colorScheme.primary, equals(theme.colorScheme.primary)); - expect(m3eThemeData.brightness, equals(theme.brightness)); - }); + expect( + m3eThemeData.colorScheme.primary, + equals(theme.colorScheme.primary), + ); + expect(m3eThemeData.brightness, equals(theme.brightness)); + }, + ); - testWidgets('m3eThemeBridgeBuilder memoizes M3EThemeData when theme does not change', (tester) async { - M3EThemeData? firstM3eTheme; - M3EThemeData? secondM3eTheme; + testWidgets( + 'm3eThemeBridgeBuilder memoizes M3EThemeData when theme does not change', + (tester) async { + M3EThemeData? firstM3eTheme; + M3EThemeData? secondM3eTheme; - final theme = ThemeData.light(); + final theme = ThemeData.light(); - await tester.pumpWidget( - Theme( - data: theme, - child: Builder( - builder: (context) { - return m3eThemeBridgeBuilder( - context, - Builder( - builder: (ctx) { - firstM3eTheme = M3ETheme.of(ctx); - return const SizedBox(); - }, - ), - ); - }, + await tester.pumpWidget( + Theme( + data: theme, + child: Builder( + builder: (context) { + return m3eThemeBridgeBuilder( + context, + Builder( + builder: (ctx) { + firstM3eTheme = M3ETheme.of(ctx); + return const SizedBox(); + }, + ), + ); + }, + ), ), - ), - ); + ); - expect(firstM3eTheme, isNotNull); + expect(firstM3eTheme, isNotNull); - // Rebuild with identical theme instance - await tester.pumpWidget( - Theme( - data: theme, - child: Builder( - builder: (context) { - return m3eThemeBridgeBuilder( - context, - Builder( - builder: (ctx) { - secondM3eTheme = M3ETheme.of(ctx); - return const SizedBox(); - }, - ), - ); - }, + // Rebuild with identical theme instance + await tester.pumpWidget( + Theme( + data: theme, + child: Builder( + builder: (context) { + return m3eThemeBridgeBuilder( + context, + Builder( + builder: (ctx) { + secondM3eTheme = M3ETheme.of(ctx); + return const SizedBox(); + }, + ), + ); + }, + ), ), - ), - ); + ); - expect(secondM3eTheme, isNotNull); - expect(identical(firstM3eTheme, secondM3eTheme), isTrue); - }); + expect(secondM3eTheme, isNotNull); + expect(identical(firstM3eTheme, secondM3eTheme), isTrue); + }, + ); - testWidgets('m3eThemeBridgeBuilder updates M3EThemeData when theme changes', (tester) async { - M3EThemeData? lightM3eTheme; - M3EThemeData? darkM3eTheme; + testWidgets( + 'm3eThemeBridgeBuilder updates M3EThemeData when theme changes', + (tester) async { + M3EThemeData? lightM3eTheme; + M3EThemeData? darkM3eTheme; - await tester.pumpWidget( - Theme( - data: ThemeData.light(), - child: Builder( - builder: (context) { - return m3eThemeBridgeBuilder( - context, - Builder( - builder: (ctx) { - lightM3eTheme = M3ETheme.of(ctx); - return const SizedBox(); - }, - ), - ); - }, + await tester.pumpWidget( + Theme( + data: ThemeData.light(), + child: Builder( + builder: (context) { + return m3eThemeBridgeBuilder( + context, + Builder( + builder: (ctx) { + lightM3eTheme = M3ETheme.of(ctx); + return const SizedBox(); + }, + ), + ); + }, + ), ), - ), - ); + ); - await tester.pumpWidget( - Theme( - data: ThemeData.dark(), - child: Builder( - builder: (context) { - return m3eThemeBridgeBuilder( - context, - Builder( - builder: (ctx) { - darkM3eTheme = M3ETheme.of(ctx); - return const SizedBox(); - }, - ), - ); - }, + await tester.pumpWidget( + Theme( + data: ThemeData.dark(), + child: Builder( + builder: (context) { + return m3eThemeBridgeBuilder( + context, + Builder( + builder: (ctx) { + darkM3eTheme = M3ETheme.of(ctx); + return const SizedBox(); + }, + ), + ); + }, + ), ), - ), - ); + ); - expect(lightM3eTheme, isNotNull); - expect(darkM3eTheme, isNotNull); - expect(identical(lightM3eTheme, darkM3eTheme), isFalse); - expect(darkM3eTheme!.brightness, equals(Brightness.dark)); - }); + expect(lightM3eTheme, isNotNull); + expect(darkM3eTheme, isNotNull); + expect(identical(lightM3eTheme, darkM3eTheme), isFalse); + expect(darkM3eTheme!.brightness, equals(Brightness.dark)); + }, + ); }); } From fbbf16270ad0be6a999232248a2104210bf568f8 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 08:07:23 +0000 Subject: [PATCH 3/3] =?UTF-8?q?=E2=9A=A1=20Bolt:=20Memoize=20M3EThemeData?= =?UTF-8?q?=20in=20m3eThemeBridgeBuilder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit