Optimize package icon loading and connections polling

This commit is contained in:
chen08209
2026-08-15 17:24:20 +08:00
parent 2df430ca31
commit 903e2b8879
14 changed files with 762 additions and 217 deletions
+39 -7
View File
@@ -61,14 +61,46 @@ class App {
false;
}
Future<ImageProvider?> getPackageIcon(String packageName) async {
final path = await methodChannel.invokeMethod<String>('getPackageIcon', {
'packageName': packageName,
});
if (path == null) {
return null;
final Map<String, ImageProvider?> _packageIcons = {};
final Map<String, Future<ImageProvider?>> _packageIconTasks = {};
bool hasPackageIcon(String packageName) {
return _packageIcons.containsKey(packageName);
}
ImageProvider? getCachedPackageIcon(String packageName) {
return _packageIcons[packageName];
}
Future<ImageProvider?> getPackageIcon(String packageName) {
if (packageName.isEmpty) {
return Future.value(null);
}
return FileImage(File(path));
if (_packageIcons.containsKey(packageName)) {
return Future.value(_packageIcons[packageName]);
}
return _packageIconTasks[packageName] ??= _loadPackageIcon(packageName);
}
Future<ImageProvider?> _loadPackageIcon(String packageName) async {
ImageProvider? icon;
try {
final path = await methodChannel.invokeMethod<String>('getPackageIcon', {
'packageName': packageName,
});
icon = path == null ? null : FileImage(File(path));
} catch (error) {
commonPrint.log('getPackageIcon error: $error');
}
_packageIcons[packageName] = icon;
_packageIconTasks.remove(packageName);
return icon;
}
@visibleForTesting
void clearPackageIconCache() {
_packageIcons.clear();
_packageIconTasks.clear();
}
Future<bool?> tip(String? message) async {
+1 -19
View File
@@ -454,25 +454,7 @@ class PackageListItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListItem.checkbox(
leading: SizedBox(
width: 48,
height: 48,
child: FutureBuilder<ImageProvider?>(
future: app?.getPackageIcon(package.packageName),
builder: (_, snapshot) {
if (!snapshot.hasData && snapshot.data == null) {
return Container();
} else {
return Image(
image: snapshot.data!,
gaplessPlayback: true,
width: 48,
height: 48,
);
}
},
),
),
leading: PackageIcon(packageName: package.packageName, size: 48),
title: Text(
package.label,
style: const TextStyle(overflow: TextOverflow.ellipsis),
+59 -54
View File
@@ -1,5 +1,3 @@
import 'dart:async';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/core/controller.dart';
import 'package:fl_clash/core/method.dart';
@@ -12,26 +10,30 @@ import 'package:super_sliver_list/super_sliver_list.dart';
import 'item.dart';
class ConnectionsView extends ConsumerStatefulWidget {
const ConnectionsView({super.key});
final Future<List<TrackerInfo>> Function()? connectionsReader;
const ConnectionsView({super.key, @visibleForTesting this.connectionsReader});
@override
ConsumerState<ConnectionsView> createState() => _ConnectionsViewState();
}
class _ConnectionsViewState extends ConsumerState<ConnectionsView> {
class _ConnectionsViewState extends ConsumerState<ConnectionsView>
with WidgetsBindingObserver, ActivePollingMixin<ConnectionsView> {
final _connectionsStateNotifier = ValueNotifier<TrackerInfosState>(
const TrackerInfosState(),
);
final ScrollController _scrollController = ScrollController();
Timer? timer;
@override
Duration get pollInterval => const Duration(seconds: 1);
List<Widget> _buildActions() {
return [
IconButton(
onPressed: () async {
coreController.closeConnections();
await _updateConnections();
await _refreshConnections();
},
icon: const Icon(Icons.delete_sweep_outlined),
),
@@ -50,46 +52,53 @@ class _ConnectionsViewState extends ConsumerState<ConnectionsView> {
);
}
Future<void> _updateConnectionsTask() async {
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (mounted) {
await _updateConnections();
timer = Timer(const Duration(seconds: 1), () async {
_updateConnectionsTask();
});
}
});
}
@override
void initState() {
super.initState();
_updateConnectionsTask();
Future<void> poll(PollGuard isCurrent) async {
final trackerInfos = await _readConnections();
if (trackerInfos == null || !isCurrent()) {
return;
}
_applyConnections(trackerInfos);
}
Future<void> _updateConnections() async {
Future<void> _refreshConnections() async {
final trackerInfos = await _readConnections();
if (trackerInfos == null || !mounted) {
return;
}
_applyConnections(trackerInfos);
}
Future<List<TrackerInfo>?> _readConnections() async {
try {
_connectionsStateNotifier.value = _connectionsStateNotifier.value
.copyWith(trackerInfos: await coreController.getConnections());
final connectionsReader = widget.connectionsReader;
return connectionsReader != null
? await connectionsReader()
: await coreController.getConnections();
} catch (error) {
commonPrint.log(
'updateConnections error: $error',
logLevel: coreFailureLogLevel(error),
);
return null;
}
}
void _applyConnections(List<TrackerInfo> trackerInfos) {
_connectionsStateNotifier.value = _connectionsStateNotifier.value.copyWith(
trackerInfos: trackerInfos,
);
}
Future<void> _handleBlockConnection(String id) async {
await coreController.closeConnection(id);
await _updateConnections();
await _refreshConnections();
}
@override
void dispose() {
timer?.cancel();
_connectionsStateNotifier.dispose();
_scrollController.dispose();
timer = null;
super.dispose();
}
@@ -111,36 +120,32 @@ class _ConnectionsViewState extends ConsumerState<ConnectionsView> {
illustration: const ConnectionEmptyIllustration(),
);
}
final items = connections
.map<Widget>(
(trackerInfo) => TrackerInfoItem(
key: Key(trackerInfo.id),
trackerInfo: trackerInfo,
onClickKeyword: (value) {
context.commonScaffoldState?.addKeyword(value);
},
trailing: IconButton(
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
style: IconButton.styleFrom(minimumSize: Size.zero),
icon: const Icon(Icons.block),
onPressed: () {
_handleBlockConnection(trackerInfo.id);
},
),
detailTitle: appLocalizations.details(
appLocalizations.connection,
),
),
)
.separated(const Divider(height: 0))
.toList();
return SuperListView.builder(
return SuperListView.separated(
controller: _scrollController,
itemBuilder: (context, index) {
return items[index];
},
itemCount: connections.length,
separatorBuilder: (_, _) => const Divider(height: 0),
itemBuilder: (_, index) {
final trackerInfo = connections[index];
return TrackerInfoItem(
key: Key(trackerInfo.id),
trackerInfo: trackerInfo,
onClickKeyword: (value) {
context.commonScaffoldState?.addKeyword(value);
},
trailing: IconButton(
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
style: IconButton.styleFrom(minimumSize: Size.zero),
icon: const Icon(Icons.block),
onPressed: () {
_handleBlockConnection(trackerInfo.id);
},
),
detailTitle: appLocalizations.details(
appLocalizations.connection,
),
);
},
);
},
),
+3 -19
View File
@@ -1,7 +1,6 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/plugins/app.dart';
import 'package:fl_clash/providers/config.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/widgets/widgets.dart';
@@ -26,10 +25,6 @@ class TrackerInfoItem extends ConsumerWidget {
return globalState.measure.bodySmallHeight + 20;
}
Future<ImageProvider?> _getPackageIcon(TrackerInfo connection) async {
return await app?.getPackageIcon(connection.metadata.process);
}
String _getSourceText(BuildContext context, TrackerInfo trackerInfo) {
final progress = trackerInfo.progressText.isNotEmpty
? '${trackerInfo.progressText} · '
@@ -121,20 +116,9 @@ class TrackerInfoItem extends ConsumerWidget {
margin: const EdgeInsets.only(top: 4),
width: 42,
height: 42,
child: FutureBuilder<ImageProvider?>(
future: _getPackageIcon(trackerInfo),
builder: (_, snapshot) {
if (!snapshot.hasData && snapshot.data == null) {
return Container();
} else {
return Image(
image: snapshot.data!,
gaplessPlayback: true,
width: 42,
height: 42,
);
}
},
child: PackageIcon(
packageName: trackerInfo.metadata.process,
size: 42,
),
),
)
+14 -18
View File
@@ -118,21 +118,6 @@ class _RequestsViewState extends ConsumerState<RequestsView> {
label: appLocalizations.nullTip(appLocalizations.requests),
);
}
final items = requests
.map<Widget>(
(trackerInfo) => TrackerInfoItem(
key: Key(trackerInfo.id),
trackerInfo: trackerInfo,
onClickKeyword: (value) {
context.commonScaffoldState?.addKeyword(value);
},
detailTitle: appLocalizations.details(
appLocalizations.request,
),
),
)
.separated(const Divider(height: 0))
.toList();
return Align(
alignment: Alignment.topCenter,
child: CommonScrollBar(
@@ -146,15 +131,26 @@ class _RequestsViewState extends ConsumerState<RequestsView> {
_requestsStateNotifier.value = _requestsStateNotifier.value
.copyWith(autoScrollToEnd: false);
},
child: SuperListView.builder(
child: SuperListView.separated(
reverse: true,
shrinkWrap: true,
physics: const NextClampingScrollPhysics(),
controller: _scrollController,
itemCount: requests.length,
separatorBuilder: (_, _) => const Divider(height: 0),
itemBuilder: (_, index) {
return items[index];
final trackerInfo = requests[index];
return TrackerInfoItem(
key: Key(trackerInfo.id),
trackerInfo: trackerInfo,
onClickKeyword: (value) {
context.commonScaffoldState?.addKeyword(value);
},
detailTitle: appLocalizations.details(
appLocalizations.request,
),
);
},
itemCount: items.length,
),
),
),
+22 -76
View File
@@ -1,8 +1,8 @@
import 'dart:async';
import 'dart:io';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/core/controller.dart';
import 'package:fl_clash/core/method.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/state.dart';
@@ -20,85 +20,31 @@ class MemoryInfo extends StatefulWidget {
State<MemoryInfo> createState() => _MemoryInfoState();
}
class _MemoryInfoState extends State<MemoryInfo> with WidgetsBindingObserver {
Timer? _timer;
bool _isForeground = false;
bool _isUpdating = false;
int _updateGeneration = 0;
class _MemoryInfoState extends State<MemoryInfo>
with WidgetsBindingObserver, ActivePollingMixin<MemoryInfo> {
@override
Duration get pollInterval => const Duration(seconds: 2);
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_isForeground =
WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _isForeground) {
_startUpdating();
}
});
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_isForeground = false;
_stopUpdating();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
final isForeground = state == AppLifecycleState.resumed;
if (_isForeground == isForeground) {
return;
}
_isForeground = isForeground;
if (isForeground) {
_startUpdating();
} else {
_stopUpdating();
}
}
void _startUpdating() {
if (!mounted || !_isForeground || _isUpdating) {
return;
}
_isUpdating = true;
final generation = ++_updateGeneration;
unawaited(_updateMemory(generation));
}
void _stopUpdating() {
_isUpdating = false;
_updateGeneration++;
_timer?.cancel();
_timer = null;
}
Future<void> _updateMemory(int generation) async {
final memoryReader = widget.memoryReader;
final memory = memoryReader != null
? await memoryReader()
: await _readMemory();
if (!mounted ||
!_isForeground ||
!_isUpdating ||
generation != _updateGeneration) {
Future<void> poll(PollGuard isCurrent) async {
final memory = await _readMemory();
if (memory == null || !isCurrent()) {
return;
}
_memoryStateNotifier.value = memory;
_timer = Timer(const Duration(seconds: 2), () {
_timer = null;
if (mounted &&
_isForeground &&
_isUpdating &&
generation == _updateGeneration) {
unawaited(_updateMemory(generation));
}
});
}
Future<num?> _readMemory() async {
try {
final memoryReader = widget.memoryReader;
return memoryReader != null ? await memoryReader() : await _readTotal();
} catch (error) {
commonPrint.log(
'updateMemory error: $error',
logLevel: coreFailureLogLevel(error),
);
return null;
}
}
@override
@@ -156,7 +102,7 @@ class _MemoryInfoState extends State<MemoryInfo> with WidgetsBindingObserver {
}
}
Future<num> _readMemory() async {
Future<num> _readTotal() async {
final rss = ProcessInfo.currentRss;
final coreConnected =
globalState.container.read(coreStatusProvider) == CoreStatus.connected;
+129
View File
@@ -0,0 +1,129 @@
import 'dart:async';
import 'package:fl_clash/common/print.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/widgets/inherited.dart';
import 'package:flutter/widgets.dart';
typedef PollGuard = bool Function();
mixin ActivePollingMixin<T extends StatefulWidget>
on State<T>, WidgetsBindingObserver {
Timer? _pollTimer;
bool _isForeground = false;
bool _isPageActive = true;
bool _isPolling = false;
int _pollGeneration = 0;
Duration get pollInterval;
bool get pollOnStart => true;
Future<void> poll(PollGuard isCurrent);
bool get canPoll => mounted && _isForeground && _isPageActive;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
final lifecycleState = WidgetsBinding.instance.lifecycleState;
_isForeground =
lifecycleState == null || lifecycleState == AppLifecycleState.resumed;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_syncPolling();
}
});
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final isPageActive = PageActivityScope.isActiveOf(context);
if (_isPageActive == isPageActive) {
return;
}
_isPageActive = isPageActive;
_syncPolling();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
final isForeground = state == AppLifecycleState.resumed;
if (_isForeground == isForeground) {
return;
}
_isForeground = isForeground;
_syncPolling();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_isForeground = false;
stopPolling();
super.dispose();
}
void startPolling() {
if (!canPoll || _isPolling) {
return;
}
_isPolling = true;
final generation = ++_pollGeneration;
if (pollOnStart) {
unawaited(_runPoll(generation));
return;
}
_schedulePoll(generation);
}
void stopPolling() {
_isPolling = false;
_pollGeneration++;
_pollTimer?.cancel();
_pollTimer = null;
}
void restartPolling() {
stopPolling();
_syncPolling();
}
void _syncPolling() {
if (canPoll) {
startPolling();
} else {
stopPolling();
}
}
bool _isCurrentPoll(int generation) =>
canPoll && _isPolling && generation == _pollGeneration;
void _schedulePoll(int generation) {
_pollTimer = Timer(pollInterval, () {
_pollTimer = null;
if (_isCurrentPoll(generation)) {
unawaited(_runPoll(generation));
}
});
}
Future<void> _runPoll(int generation) async {
try {
await poll(() => _isCurrentPoll(generation));
} catch (error) {
commonPrint.log(
'$runtimeType poll error: $error',
logLevel: LogLevel.warning,
);
} finally {
if (_isCurrentPoll(generation)) {
_schedulePoll(generation);
}
}
}
}
+14 -24
View File
@@ -1,5 +1,4 @@
import 'dart:async';
import 'package:fl_clash/widgets/active_polling.dart';
import 'package:fl_clash/widgets/inherited.dart';
import 'package:flutter/material.dart';
@@ -16,40 +15,31 @@ class TickBuilder extends StatefulWidget {
State<TickBuilder> createState() => _TickBuilderState();
}
class _TickBuilderState extends State<TickBuilder> {
Timer? _timer;
class _TickBuilderState extends State<TickBuilder>
with WidgetsBindingObserver, ActivePollingMixin<TickBuilder> {
int _tick = 0;
@override
void initState() {
super.initState();
_startTimer();
Duration get pollInterval => widget.duration;
@override
bool get pollOnStart => false;
@override
Future<void> poll(PollGuard isCurrent) async {
setState(() {
_tick++;
});
}
@override
void didUpdateWidget(covariant TickBuilder oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.duration != widget.duration) {
_startTimer();
restartPolling();
}
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
void _startTimer() {
_timer?.cancel();
_timer = Timer.periodic(widget.duration, (_) {
if (!mounted) return;
setState(() {
_tick++;
});
});
}
@override
Widget build(BuildContext context) {
return widget.builder(context, _tick);
+67
View File
@@ -4,6 +4,7 @@ import 'dart:io';
import 'package:fl_clash/common/cache.dart';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/database/database.dart';
import 'package:fl_clash/plugins/app.dart';
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_svg/svg.dart';
@@ -125,6 +126,72 @@ class _ImageCacheWidgetState extends State<ImageCacheWidget> {
}
}
class PackageIcon extends StatefulWidget {
final String packageName;
final double size;
const PackageIcon({super.key, required this.packageName, required this.size});
@override
State<PackageIcon> createState() => _PackageIconState();
}
class _PackageIconState extends State<PackageIcon> {
ImageProvider? _icon;
int _generation = 0;
@override
void initState() {
super.initState();
_loadIcon();
}
@override
void didUpdateWidget(covariant PackageIcon oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.packageName != widget.packageName) {
_loadIcon();
}
}
void _loadIcon() {
final generation = ++_generation;
final packageName = widget.packageName;
final currentApp = app;
if (currentApp == null || packageName.isEmpty) {
_icon = null;
return;
}
if (currentApp.hasPackageIcon(packageName)) {
_icon = currentApp.getCachedPackageIcon(packageName);
return;
}
_icon = null;
currentApp.getPackageIcon(packageName).then((icon) {
if (!mounted || generation != _generation || icon == null) {
return;
}
setState(() {
_icon = icon;
});
});
}
@override
Widget build(BuildContext context) {
final icon = _icon;
if (icon == null) {
return SizedBox(width: widget.size, height: widget.size);
}
return Image(
image: icon,
gaplessPlayback: true,
width: widget.size,
height: widget.size,
);
}
}
class CommonImage extends StatelessWidget {
final File data;
final bool isSvg;
+1
View File
@@ -1,4 +1,5 @@
export 'activate_box.dart';
export 'active_polling.dart';
export 'animate_grid.dart';
export 'animated_visibility.dart';
export 'builder.dart';