From 6d985764ddbb84a6daf4c8aaf0da15b390065c67 Mon Sep 17 00:00:00 2001 From: Lorenzo DZ Date: Sat, 2 May 2026 12:16:42 -0300 Subject: [PATCH] fix(flame_devtools): Fix component tree growing list and stale snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two unrelated UI bugs in the DevTools extension surfaced by issue #3289 when running against games with constant animations: 1. The component tree was wrapped in a SingleChildScrollView with shrinkWrap: true on the inner TreeView. The tree therefore sized itself to its content and grew on every frame, never filling the Expanded slot above it. Drop the redundant outer scroll view and let TreeView own its scrolling and use the available height. 2. Base64Image fed the snapshot bytes through Flame.images.fromBase64 keyed by component id, so re-selecting the same component (or re-requesting a snapshot for it after the game state changed) hit the global cache and rendered a stale frame — the gray image reported in the issue. Decode the bytes directly inside the widget and dispose the ui.Image when the data or widget changes. Fixes #3289 --- .../lib/widgets/component_snapshot.dart | 77 ++++++++++++++++--- .../lib/widgets/component_tree.dart | 72 ++++++++--------- 2 files changed, 99 insertions(+), 50 deletions(-) diff --git a/packages/flame_devtools/lib/widgets/component_snapshot.dart b/packages/flame_devtools/lib/widgets/component_snapshot.dart index 72ed1841c0f..bc6c823023d 100644 --- a/packages/flame_devtools/lib/widgets/component_snapshot.dart +++ b/packages/flame_devtools/lib/widgets/component_snapshot.dart @@ -1,4 +1,6 @@ -import 'package:flame/flame.dart'; +import 'dart:convert'; +import 'dart:ui' as ui; + import 'package:flame/widgets.dart'; import 'package:flame_devtools/repository.dart'; import 'package:flutter/material.dart' hide Image; @@ -52,7 +54,13 @@ class _ComponentSnapshotState extends State { } } -class Base64Image extends StatelessWidget { +/// Displays an image decoded from a base64 data string. +/// +/// The decoded [ui.Image] is held by this widget and disposed when the widget +/// is removed or when [base64] / [imageId] changes — without going through +/// the global Flame images cache, so the same component id can show +/// different snapshots over time without serving a stale cached frame. +class Base64Image extends StatefulWidget { const Base64Image({ required this.base64, required this.imageId, @@ -62,23 +70,68 @@ class Base64Image extends StatelessWidget { final String base64; final String imageId; + @override + State createState() => _Base64ImageState(); +} + +class _Base64ImageState extends State { + late Future _imageFuture; + ui.Image? _image; + + @override + void initState() { + super.initState(); + _imageFuture = _decode(widget.base64); + } + + @override + void didUpdateWidget(Base64Image oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.base64 != widget.base64 || + oldWidget.imageId != widget.imageId) { + _disposeImage(); + _imageFuture = _decode(widget.base64); + } + } + + @override + void dispose() { + _disposeImage(); + super.dispose(); + } + + Future _decode(String base64Data) async { + final commaIndex = base64Data.indexOf(','); + final payload = commaIndex == -1 + ? base64Data + : base64Data.substring(commaIndex + 1); + final bytes = base64.decode(payload); + final image = await decodeImageFromList(bytes); + if (mounted) { + _image = image; + } else { + image.dispose(); + } + return image; + } + + void _disposeImage() { + _image?.dispose(); + _image = null; + } + @override Widget build(BuildContext context) { - final imageFuture = Flame.images.fromBase64( - imageId, - base64, - ); - return FutureBuilder( - future: imageFuture, + return FutureBuilder( + future: _imageFuture, builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.done) { + if (snapshot.connectionState == ConnectionState.done && + snapshot.hasData) { return SizedBox( width: 200, height: 200, child: SpriteWidget( - sprite: Sprite( - snapshot.data!, - ), + sprite: Sprite(snapshot.data!), ), ); } diff --git a/packages/flame_devtools/lib/widgets/component_tree.dart b/packages/flame_devtools/lib/widgets/component_tree.dart index 0e1fb7abee3..7f791172a33 100644 --- a/packages/flame_devtools/lib/widgets/component_tree.dart +++ b/packages/flame_devtools/lib/widgets/component_tree.dart @@ -59,45 +59,41 @@ class ComponentTreeSection extends ConsumerWidget { ), ), Expanded( - child: SingleChildScrollView( - child: TreeView.simple( - showRootNode: false, - shrinkWrap: true, - indentation: const Indentation( - color: Colors.blue, - style: IndentStyle.roundJoint, - ), - onTreeReady: (controller) => - controller.expandAllChildren(controller.tree), - padding: const EdgeInsets.only(left: 20), - expansionIndicatorBuilder: (context, node) => node.isLeaf - ? NoExpansionIndicator(tree: node) - : ChevronIndicator.rightDown( - tree: node, - alignment: Alignment.centerLeft, - ), - builder: (context, node) { - return Padding( - padding: node.isLeaf - ? EdgeInsets.zero - : const EdgeInsets.only(left: 20), - child: ListTile( - key: Key( - node.data?.id.toString() ?? node.key, - ), - selected: node == selectedTreeNode, - selectedColor: theme.colorScheme.primary, - title: Text(node.data!.name), - subtitle: Text(node.data!.id.toString()), - onTap: () { - ref.read(selectedTreeNodeProvider.notifier).state = - node; - }, - ), - ); - }, - tree: loadedModel.treeRoot, + child: TreeView.simple( + showRootNode: false, + indentation: const Indentation( + color: Colors.blue, + style: IndentStyle.roundJoint, ), + onTreeReady: (controller) => + controller.expandAllChildren(controller.tree), + padding: const EdgeInsets.only(left: 20), + expansionIndicatorBuilder: (context, node) => node.isLeaf + ? NoExpansionIndicator(tree: node) + : ChevronIndicator.rightDown( + tree: node, + alignment: Alignment.centerLeft, + ), + builder: (context, node) { + return Padding( + padding: node.isLeaf + ? EdgeInsets.zero + : const EdgeInsets.only(left: 20), + child: ListTile( + key: Key( + node.data?.id.toString() ?? node.key, + ), + selected: node == selectedTreeNode, + selectedColor: theme.colorScheme.primary, + title: Text(node.data!.name), + subtitle: Text(node.data!.id.toString()), + onTap: () { + ref.read(selectedTreeNodeProvider.notifier).state = node; + }, + ), + ); + }, + tree: loadedModel.treeRoot, ), ), ],