memory leak tracking & fixes

This commit is contained in:
Thibault Deckers 2023-10-20 18:23:39 +03:00
parent 4c07a9da43
commit bca78a0669
9 changed files with 238 additions and 144 deletions

View file

@ -6,21 +6,21 @@ import 'package:provider/provider.dart';
class PopupMenuExpansionPanel<T> extends PopupMenuEntry<T> {
final bool enabled;
final String value;
final ValueNotifier<String?> expandedNotifier;
final ValueNotifier<String?>? expandedNotifier;
final IconData icon;
final String title;
final List<PopupMenuEntry<T>> items;
PopupMenuExpansionPanel({
const PopupMenuExpansionPanel({
super.key,
this.enabled = true,
this.height = kMinInteractiveDimension,
required this.value,
ValueNotifier<String?>? expandedNotifier,
this.expandedNotifier,
required this.icon,
required this.title,
required this.items,
}) : expandedNotifier = expandedNotifier ?? ValueNotifier(null);
});
@override
final double height;
@ -36,6 +36,16 @@ class _PopupMenuExpansionPanelState<T> extends State<PopupMenuExpansionPanel<T>>
// ref `_kMenuHorizontalPadding` used in `PopupMenuItem`
static const double _horizontalPadding = 16;
final ValueNotifier<String?> _internalExpandedNotifier = ValueNotifier(null);
ValueNotifier<String?> get expandedNotifier => widget.expandedNotifier ?? _internalExpandedNotifier;
@override
void dispose() {
_internalExpandedNotifier.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@ -46,11 +56,11 @@ class _PopupMenuExpansionPanelState<T> extends State<PopupMenuExpansionPanel<T>>
final animationDuration = context.select<DurationsData, Duration>((v) => v.expansionTileAnimation);
Widget child = ValueListenableBuilder<String?>(
valueListenable: widget.expandedNotifier,
valueListenable: expandedNotifier,
builder: (context, expandedValue, child) {
return ExpansionPanelList(
expansionCallback: (index, isExpanded) {
widget.expandedNotifier.value = isExpanded ? widget.value : null;
expandedNotifier.value = isExpanded ? widget.value : null;
},
animationDuration: animationDuration,
expandedHeaderPadding: EdgeInsets.zero,

View file

@ -6,45 +6,32 @@ import 'package:aves/model/filters/path.dart';
import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/analysis_service.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/common/basic/font_size_icon_theme.dart';
import 'package:aves/widgets/common/basic/popup/menu_row.dart';
import 'package:aves/widgets/common/basic/scaffold.dart';
import 'package:aves/widgets/common/behaviour/pop/scope.dart';
import 'package:aves/widgets/common/behaviour/pop/tv_navigation.dart';
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
import 'package:aves/widgets/debug/android_apps.dart';
import 'package:aves/widgets/debug/android_codecs.dart';
import 'package:aves/widgets/debug/android_dirs.dart';
import 'package:aves/widgets/debug/app_debug_action.dart';
import 'package:aves/widgets/debug/cache.dart';
import 'package:aves/widgets/debug/database.dart';
import 'package:aves/widgets/debug/general.dart';
import 'package:aves/widgets/debug/media_store_scan_dialog.dart';
import 'package:aves/widgets/debug/overlay.dart';
import 'package:aves/widgets/debug/report.dart';
import 'package:aves/widgets/debug/settings.dart';
import 'package:aves/widgets/debug/storage.dart';
import 'package:aves/widgets/viewer/info/common.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart';
import 'package:leak_tracker/leak_tracker.dart';
class AppDebugPage extends StatefulWidget {
class AppDebugPage extends StatelessWidget {
static const routeName = '/debug';
const AppDebugPage({super.key});
@override
State<StatefulWidget> createState() => _AppDebugPageState();
}
class _AppDebugPageState extends State<AppDebugPage> {
static OverlayEntry? _taskQueueOverlayEntry;
@override
Widget build(BuildContext context) {
return Directionality(
@ -68,7 +55,7 @@ class _AppDebugPageState extends State<AppDebugPage> {
onSelected: (action) async {
// wait for the popup menu to hide before proceeding with the action
await Future.delayed(ADurations.popupMenuAnimation * timeDilation);
unawaited(_onActionSelected(action));
unawaited(_onActionSelected(context, action));
},
),
),
@ -79,16 +66,16 @@ class _AppDebugPageState extends State<AppDebugPage> {
child: SafeArea(
child: ListView(
padding: const EdgeInsets.all(8),
children: [
_buildGeneralTabView(),
const DebugAndroidAppSection(),
const DebugAndroidCodecSection(),
const DebugAndroidDirSection(),
const DebugCacheSection(),
const DebugAppDatabaseSection(),
const DebugErrorReportingSection(),
const DebugSettingsSection(),
const DebugStorageSection(),
children: const [
DebugGeneralSection(),
DebugAndroidAppSection(),
DebugAndroidCodecSection(),
DebugAndroidDirSection(),
DebugCacheSection(),
DebugAppDatabaseSection(),
DebugErrorReportingSection(),
DebugSettingsSection(),
DebugStorageSection(),
],
),
),
@ -97,100 +84,7 @@ class _AppDebugPageState extends State<AppDebugPage> {
);
}
Widget _buildGeneralTabView() {
final source = context.read<CollectionSource>();
final visibleEntries = source.visibleEntries;
final catalogued = visibleEntries.where((entry) => entry.isCatalogued);
final withGps = catalogued.where((entry) => entry.hasGps);
final withAddress = withGps.where((entry) => entry.hasAddress);
final withFineAddress = withGps.where((entry) => entry.hasFineAddress);
return AvesExpansionTile(
title: 'General',
children: [
const Padding(
padding: EdgeInsets.all(8),
child: Text('Time dilation'),
),
Slider(
value: timeDilation,
onChanged: (v) => setState(() => timeDilation = v),
min: 1.0,
max: 10.0,
divisions: 9,
label: '$timeDilation',
),
SwitchListTile(
value: _taskQueueOverlayEntry != null,
onChanged: (v) {
_taskQueueOverlayEntry?.remove();
if (v) {
_taskQueueOverlayEntry = OverlayEntry(
builder: (context) => const DebugTaskQueueOverlay(),
);
Overlay.of(context).insert(_taskQueueOverlayEntry!);
} else {
_taskQueueOverlayEntry = null;
}
setState(() {});
},
title: const Text('Show tasks overlay'),
),
ElevatedButton(
onPressed: () => LeakTracking.collectLeaks().then((leaks) {
leaks.byType.forEach((type, reports) {
debugPrint('* leak type=$type');
groupBy(reports, (report) => report.type).forEach((reportType, typedReports) {
debugPrint(' * report type=$reportType');
groupBy(typedReports, (report) => report.trackedClass).forEach((trackedClass, classedReports) {
debugPrint(' trackedClass=$trackedClass reports=${classedReports.length}');
// classedReports.forEach((report) {
// debugPrint(' phase=${report.phase} retainingPath=${report.retainingPath} detailedPath=${report.detailedPath} context=${report.context}');
// });
});
});
});
}),
child: const Text('Collect leaks'),
),
ElevatedButton(
onPressed: () => source.init(loadTopEntriesFirst: false),
child: const Text('Source refresh (top off)'),
),
ElevatedButton(
onPressed: () => source.init(loadTopEntriesFirst: true),
child: const Text('Source refresh (top on)'),
),
ElevatedButton(
onPressed: () => source.init(directory: '${androidFileUtils.dcimPath}/Camera'),
child: const Text('Source refresh (camera)'),
),
ElevatedButton(
onPressed: () => source.init(directory: androidFileUtils.picturesPath),
child: const Text('Source refresh (pictures)'),
),
ElevatedButton(
onPressed: () => AnalysisService.startService(force: false),
child: const Text('Start analysis service'),
),
const Divider(),
Padding(
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: InfoRowGroup(
info: {
'All entries': '${source.allEntries.length}',
'Visible entries': '${visibleEntries.length}',
'Catalogued': '${catalogued.length}',
'With GPS': '${withGps.length}',
'With address': '${withAddress.length}',
'With fine address': '${withFineAddress.length}',
},
),
),
],
);
}
Future<void> _onActionSelected(AppDebugAction action) async {
Future<void> _onActionSelected(BuildContext context, AppDebugAction action) async {
switch (action) {
case AppDebugAction.prepScreenshotThumbnails:
// get source beforehand, as widget may be unmounted during action handling

View file

@ -0,0 +1,104 @@
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/analysis_service.dart';
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
import 'package:aves/widgets/debug/overlay.dart';
import 'package:aves/widgets/viewer/info/common.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:leak_tracker/leak_tracker.dart';
import 'package:provider/provider.dart';
class DebugGeneralSection extends StatefulWidget {
const DebugGeneralSection({super.key});
@override
State<DebugGeneralSection> createState() => _DebugGeneralSectionState();
}
class _DebugGeneralSectionState extends State<DebugGeneralSection> with AutomaticKeepAliveClientMixin {
static OverlayEntry? _taskQueueOverlayEntry;
@override
Widget build(BuildContext context) {
super.build(context);
final source = context.read<CollectionSource>();
final visibleEntries = source.visibleEntries;
final catalogued = visibleEntries.where((entry) => entry.isCatalogued);
final withGps = catalogued.where((entry) => entry.hasGps);
final withAddress = withGps.where((entry) => entry.hasAddress);
final withFineAddress = withGps.where((entry) => entry.hasFineAddress);
return AvesExpansionTile(
title: 'General',
children: [
const Padding(
padding: EdgeInsets.all(8),
child: Text('Time dilation'),
),
Slider(
value: timeDilation,
onChanged: (v) => setState(() => timeDilation = v),
min: 1.0,
max: 10.0,
divisions: 9,
label: '$timeDilation',
),
SwitchListTile(
value: _taskQueueOverlayEntry != null,
onChanged: (v) {
_taskQueueOverlayEntry?.remove();
if (v) {
_taskQueueOverlayEntry = OverlayEntry(
builder: (context) => const DebugTaskQueueOverlay(),
);
Overlay.of(context).insert(_taskQueueOverlayEntry!);
} else {
_taskQueueOverlayEntry = null;
}
setState(() {});
},
title: const Text('Show tasks overlay'),
),
ElevatedButton(
onPressed: () => LeakTracking.collectLeaks().then((leaks) {
leaks.byType.forEach((type, reports) {
debugPrint('* leak type=$type');
groupBy(reports, (report) => report.type).forEach((reportType, typedReports) {
debugPrint(' * report type=$reportType');
groupBy(typedReports, (report) => report.trackedClass).forEach((trackedClass, classedReports) {
debugPrint(' trackedClass=$trackedClass reports=${classedReports.length}');
// classedReports.forEach((report) {
// debugPrint(' phase=${report.phase} retainingPath=${report.retainingPath} detailedPath=${report.detailedPath} context=${report.context}');
// });
});
});
});
}),
child: const Text('Collect leaks'),
),
ElevatedButton(
onPressed: () => AnalysisService.startService(force: false),
child: const Text('Start analysis service'),
),
const Divider(),
Padding(
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: InfoRowGroup(
info: {
'All entries': '${source.allEntries.length}',
'Visible entries': '${visibleEntries.length}',
'Catalogued': '${catalogued.length}',
'With GPS': '${withGps.length}',
'With address': '${withAddress.length}',
'With fine address': '${withFineAddress.length}',
},
),
),
],
);
}
@override
bool get wantKeepAlive => true;
}

View file

@ -4,11 +4,18 @@ import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
import 'package:aves/widgets/viewer/info/common.dart';
import 'package:flutter/material.dart';
class DebugErrorReportingSection extends StatelessWidget {
class DebugErrorReportingSection extends StatefulWidget {
const DebugErrorReportingSection({super.key});
@override
State<DebugErrorReportingSection> createState() => _DebugErrorReportingSectionState();
}
class _DebugErrorReportingSectionState extends State<DebugErrorReportingSection> with AutomaticKeepAliveClientMixin {
@override
Widget build(BuildContext context) {
super.build(context);
return AvesExpansionTile(
title: 'Reporting',
children: [
@ -56,4 +63,7 @@ class DebugErrorReportingSection extends StatelessWidget {
],
);
}
@override
bool get wantKeepAlive => true;
}

View file

@ -9,11 +9,18 @@ import 'package:aves/widgets/viewer/info/common.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class DebugSettingsSection extends StatelessWidget {
class DebugSettingsSection extends StatefulWidget {
const DebugSettingsSection({super.key});
@override
State<DebugSettingsSection> createState() => _DebugSettingsSectionState();
}
class _DebugSettingsSectionState extends State<DebugSettingsSection> with AutomaticKeepAliveClientMixin {
@override
Widget build(BuildContext context) {
super.build(context);
return Consumer<Settings>(
builder: (context, settings, child) {
String toMultiline(Iterable? l) => l != null && l.isNotEmpty ? '\n${l.join('\n')}' : '$l';
@ -76,4 +83,7 @@ class DebugSettingsSection extends StatelessWidget {
},
);
}
@override
bool get wantKeepAlive => true;
}

View file

@ -1,14 +1,30 @@
import 'dart:async';
import 'package:aves/model/entry/entry.dart';
import 'package:aves/widgets/viewer/multipage/controller.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
class MultiPageConductor {
final List<MultiPageController> _controllers = [];
static const maxControllerCount = 3;
MultiPageConductor() {
if (kFlutterMemoryAllocationsEnabled) {
MemoryAllocations.instance.dispatchObjectCreated(
library: 'aves',
className: '$MultiPageConductor',
object: this,
);
}
}
Future<void> dispose() async {
await Future.forEach<MultiPageController>(_controllers, (controller) => controller.dispose());
if (kFlutterMemoryAllocationsEnabled) {
MemoryAllocations.instance.dispatchObjectDisposed(object: this);
}
await _disposeAll();
_controllers.clear();
}
@ -29,4 +45,8 @@ class MultiPageConductor {
MultiPageController? getController(AvesEntry entry) {
return _controllers.firstWhereOrNull((c) => c.entry.uri == entry.uri && c.entry.pageId == entry.pageId);
}
Future<void> _applyToAll(FutureOr Function(MultiPageController controller) action) => Future.forEach<MultiPageController>(_controllers, action);
Future<void> _disposeAll() => _applyToAll((controller) => controller.dispose());
}

View file

@ -143,7 +143,7 @@ class _TvButtonRowContent extends StatelessWidget {
final enabled = actionDelegate.canApply(action);
return CaptionedButton(
scale: scale,
iconButtonBuilder: (context, focusNode) => ViewerButtonRowContent._buildButtonIcon(
iconButtonBuilder: (context, focusNode) => _ViewerButtonRowContentState._buildButtonIcon(
context: context,
action: action,
mainEntry: mainEntry,
@ -202,18 +202,15 @@ class _TvButtonRowContent extends StatelessWidget {
}
}
class ViewerButtonRowContent extends StatelessWidget {
class ViewerButtonRowContent extends StatefulWidget {
final EntryActionDelegate actionDelegate;
final List<EntryAction> quickActions, topLevelActions, exportActions, videoActions;
final Animation<double> scale;
final AvesEntry mainEntry, pageEntry;
final ValueNotifier<String?> _popupExpandedNotifier = ValueNotifier(null);
AvesEntry get favouriteTargetEntry => mainEntry.isBurst ? pageEntry : mainEntry;
static const double padding = 8;
ViewerButtonRowContent({
const ViewerButtonRowContent({
super.key,
required this.actionDelegate,
required this.quickActions,
@ -225,8 +222,32 @@ class ViewerButtonRowContent extends StatelessWidget {
required this.pageEntry,
});
@override
State<ViewerButtonRowContent> createState() => _ViewerButtonRowContentState();
}
class _ViewerButtonRowContentState extends State<ViewerButtonRowContent> {
final ValueNotifier<String?> _popupExpandedNotifier = ValueNotifier(null);
AvesEntry get mainEntry => widget.mainEntry;
AvesEntry get pageEntry => widget.pageEntry;
AvesEntry get favouriteTargetEntry => mainEntry.isBurst ? pageEntry : mainEntry;
static const double padding = ViewerButtonRowContent.padding;
@override
void dispose() {
_popupExpandedNotifier.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final topLevelActions = widget.topLevelActions;
final exportActions = widget.exportActions;
final videoActions = widget.videoActions;
final hasOverflowMenu = pageEntry.canRotate || pageEntry.canFlip || topLevelActions.isNotEmpty || exportActions.isNotEmpty || videoActions.isNotEmpty;
return Selector<VideoConductor, AvesVideoController?>(
selector: (context, vc) => vc.getController(pageEntry),
@ -236,12 +257,12 @@ class ViewerButtonRowContent extends StatelessWidget {
child: Row(
children: [
const Spacer(),
...quickActions.map((action) => _buildOverlayButton(context, action, videoController)),
...widget.quickActions.map((action) => _buildOverlayButton(context, action, videoController)),
if (hasOverflowMenu)
Padding(
padding: const EdgeInsets.symmetric(horizontal: padding / 2),
child: OverlayButton(
scale: scale,
scale: widget.scale,
child: FontSizeIconTheme(
child: AvesPopupMenuButton<EntryAction>(
key: const Key('entry-menu-button'),
@ -282,7 +303,7 @@ class ViewerButtonRowContent extends StatelessWidget {
onSelected: (action) {
_popupExpandedNotifier.value = null;
// wait for the popup menu to hide before proceeding with the action
Future.delayed(ADurations.popupMenuAnimation * timeDilation, () => actionDelegate.onActionSelected(context, action));
Future.delayed(ADurations.popupMenuAnimation * timeDilation, () => widget.actionDelegate.onActionSelected(context, action));
},
onCanceled: () {
_popupExpandedNotifier.value = null;
@ -309,14 +330,14 @@ class ViewerButtonRowContent extends StatelessWidget {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: padding / 2),
child: OverlayButton(
scale: scale,
scale: widget.scale,
child: _buildButtonIcon(
context: context,
action: action,
mainEntry: mainEntry,
pageEntry: pageEntry,
videoController: videoController,
actionDelegate: actionDelegate,
actionDelegate: widget.actionDelegate,
),
),
);
@ -381,7 +402,7 @@ class ViewerButtonRowContent extends StatelessWidget {
clipBehavior: Clip.antiAlias,
child: PopupMenuItem(
value: action,
enabled: actionDelegate.canApply(action),
enabled: widget.actionDelegate.canApply(action),
child: Tooltip(
message: action.getText(context),
child: Center(child: action.getIcon()),

View file

@ -9,6 +9,7 @@ import 'package:aves/widgets/viewer/video/db_playback_state_handler.dart';
import 'package:aves_model/aves_model.dart';
import 'package:aves_video/aves_video.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
class VideoConductor {
final CollectionLens? _collection;
@ -18,10 +19,21 @@ class VideoConductor {
static const _defaultMaxControllerCount = 3;
VideoConductor({CollectionLens? collection}) : _collection = collection;
VideoConductor({CollectionLens? collection}) : _collection = collection {
if (kFlutterMemoryAllocationsEnabled) {
MemoryAllocations.instance.dispatchObjectCreated(
library: 'aves',
className: '$VideoConductor',
object: this,
);
}
}
Future<void> dispose() async {
await disposeAll();
if (kFlutterMemoryAllocationsEnabled) {
MemoryAllocations.instance.dispatchObjectDisposed(object: this);
}
await _disposeAll();
_subscriptions
..forEach((sub) => sub.cancel())
..clear();
@ -82,7 +94,7 @@ class VideoConductor {
Future<void> _applyToAll(FutureOr Function(AvesVideoController controller) action) => Future.forEach<AvesVideoController>(_controllers, action);
Future<void> disposeAll() => _applyToAll((controller) => controller.dispose());
Future<void> _disposeAll() => _applyToAll((controller) => controller.dispose());
Future<void> pauseAll() => _applyToAll((controller) => controller.pause());

View file

@ -12,7 +12,20 @@ class ViewStateConductor {
static const maxControllerCount = 3;
ViewStateConductor() {
if (kFlutterMemoryAllocationsEnabled) {
MemoryAllocations.instance.dispatchObjectCreated(
library: 'aves',
className: '$ViewStateConductor',
object: this,
);
}
}
Future<void> dispose() async {
if (kFlutterMemoryAllocationsEnabled) {
MemoryAllocations.instance.dispatchObjectDisposed(object: this);
}
_controllers.forEach((v) => v.dispose());
_controllers.clear();
}