From 7ab50177f7e712583e2d1b38fec00b49b5c36e3e Mon Sep 17 00:00:00 2001 From: Imran Remtulla Date: Wed, 15 Jul 2026 17:04:37 +0100 Subject: [PATCH] Fix UI unresponsiveness during update checks (#3043) - Memoize findStandardFormatsForVersion with bounded 4096-entry cache, and deduplicate the 436 generated version regex patterns. This collapses the per-GitHub-app sort from ~500K regex evaluations to ~100, and caches results across all version reconciliation calls. - Optimize GitHub _sortGitHubReleases: date sort (the default) now skips regex computation entirely; smartname sorts precompute formats once per release instead of re-evaluating per sort-comparison pair. - Inline app list computation into AppsPage.build() instead of using a three-layer caching system (context.select, pipelineSignature, _computeListData). With memoization making rebuilds ~2ms, the complexity of signature hashing and cache field management is unnecessary. - Cache getAppsDir() to avoid redundant platform-channel calls per app during saveApps batches. --- lib/app_sources/github.dart | 94 ++++++------ lib/pages/apps.dart | 164 ++++++--------------- lib/providers/apps_provider.dart | 1 + lib/providers/apps_provider_lifecycle.dart | 5 +- lib/providers/source_provider.dart | 12 +- 5 files changed, 107 insertions(+), 169 deletions(-) diff --git a/lib/app_sources/github.dart b/lib/app_sources/github.dart index 1bd6ef4c..13268f37 100644 --- a/lib/app_sources/github.dart +++ b/lib/app_sources/github.dart @@ -434,50 +434,58 @@ class GitHub extends AppSource { bool useLatestAssetDateAsReleaseDate, ) { if (sortMethod == 'none') return; - releases.sort((a, b) { - if (a == null) { - return -1; - } else if (b == null) { - return 1; - } else { - final nameA = a['tag_name'] ?? a['name']; - final nameB = b['tag_name'] ?? b['name']; - final stdFormats = findStandardFormatsForVersion( - nameA, - false, - ).intersection(findStandardFormatsForVersion(nameB, false)); - if (sortMethod == 'date' || - (sortMethod == 'smartname-datefallback' && stdFormats.isEmpty)) { - final dateA = _getReleaseDateFromRelease( - a, - useLatestAssetDateAsReleaseDate, - ); - final dateB = _getReleaseDateFromRelease( - b, - useLatestAssetDateAsReleaseDate, - ); - // Null dates sort as oldest (matches main); DateTime(1)/DateTime(0) - // keep the both-null case deterministic. - return (dateA ?? DateTime(1)).compareTo(dateB ?? DateTime(0)); - } else { - if (sortMethod != 'name' && stdFormats.isNotEmpty) { - final sortedFormats = stdFormats.toList() - ..sort((a, b) => b.length.compareTo(a.length)); - final reg = RegExp(sortedFormats.first); - final matchA = reg.firstMatch(nameA); - final matchB = reg.firstMatch(nameB); - if (matchA == null || matchB == null) { - return compareAlphaNumeric((nameA as String), (nameB as String)); - } - return compareAlphaNumeric( - (nameA as String).substring(matchA.start, matchA.end), - (nameB as String).substring(matchB.start, matchB.end), - ); - } else { - return compareAlphaNumeric((nameA as String), (nameB as String)); - } - } + + // Precompute dates and (for smartname/name sorts) per-release format + // sets once. Memoization in findStandardFormatsForVersion already handles + // the per-version cache; we still precompute here so the sort comparator + // only performs O(1) lookups instead of O(n) per comparison. + final isDateOnly = sortMethod == 'date'; + final Map dates = {}; + final Map> formats = {}; + if (!isDateOnly) { + for (final r in releases) { + if (r == null) continue; + final name = (r['tag_name'] ?? r['name'])?.toString() ?? ''; + formats[r] = findStandardFormatsForVersion(name, false); } + } + + releases.sort((a, b) { + if (a == null) return -1; + if (b == null) return 1; + + if (isDateOnly) { + final dateA = dates.putIfAbsent(a, () => _getReleaseDateFromRelease(a, useLatestAssetDateAsReleaseDate)); + final dateB = dates.putIfAbsent(b, () => _getReleaseDateFromRelease(b, useLatestAssetDateAsReleaseDate)); + return (dateA ?? DateTime(1)).compareTo(dateB ?? DateTime(0)); + } + + final nameA = a['tag_name'] ?? a['name']; + final nameB = b['tag_name'] ?? b['name']; + final stdFormats = formats[a]!.intersection(formats[b]!); + + if (sortMethod == 'smartname-datefallback' && stdFormats.isEmpty) { + final dateA = _getReleaseDateFromRelease(a, useLatestAssetDateAsReleaseDate); + final dateB = _getReleaseDateFromRelease(b, useLatestAssetDateAsReleaseDate); + return (dateA ?? DateTime(1)).compareTo(dateB ?? DateTime(0)); + } + + if (sortMethod != 'name' && stdFormats.isNotEmpty) { + final sortedFormats = stdFormats.toList() + ..sort((x, y) => y.length.compareTo(x.length)); + final reg = RegExp(sortedFormats.first); + final matchA = reg.firstMatch(nameA); + final matchB = reg.firstMatch(nameB); + if (matchA == null || matchB == null) { + return compareAlphaNumeric(nameA as String, nameB as String); + } + return compareAlphaNumeric( + (nameA as String).substring(matchA.start, matchA.end), + (nameB as String).substring(matchB.start, matchB.end), + ); + } + + return compareAlphaNumeric(nameA as String, nameB as String); }); } diff --git a/lib/pages/apps.dart b/lib/pages/apps.dart index 85f5755d..61aca3da 100644 --- a/lib/pages/apps.dart +++ b/lib/pages/apps.dart @@ -64,14 +64,6 @@ class AppsPageState extends State { Timer? _searchDebounce; - int? _listDataSig; - List? _cachedListedApps; - List? _cachedExistingUpdateIds; - List? _cachedNewInstallIds; - List? _cachedTrackOnlyUpdateIds; - Map>? _cachedGrouped; - List? _cachedListedGroups; - @override void didChangeDependencies() { super.didChangeDependencies(); @@ -116,42 +108,6 @@ class AppsPageState extends State { widget.onSelectionChanged?.call(selectedAppIds.isNotEmpty); } - int pipelineSignature(List apps) { - final parts = [ - filter.nameFilter, - filter.authorFilter, - filter.idFilter, - filter.includeUptodate, - filter.includeNonInstalled, - Object.hashAll(filter.categoryFilter), - filter.sourceFilter, - settingsProvider.sortColumn.index, - settingsProvider.sortOrder.index, - settingsProvider.pinUpdates, - settingsProvider.buryNonInstalled, - Object.hashAll(selectedAppIds), - settingsProvider.groupBy, - apps.length, - ]; - for (final a in apps) { - final app = a.app; - parts.addAll([ - app.id, - a.name, - a.author, - app.installedVersion, - app.latestVersion, - app.releaseDate, - app.pinned, - Object.hashAll(app.categories), - app.hasPendingRepoRename, - app.overrideSource, - app.settings.getBool('trackOnly'), - ]); - } - return Object.hashAll(parts); - } - List getFilteredAndSortedApps( List listedApps, Set existingUpdates, @@ -1042,29 +998,52 @@ class AppsPageState extends State { ]; } - void _computeListData( - AppsProvider appsProvider, - SettingsProvider settingsProvider, - ) { - final apps = appsProvider.getAppValues().toList(); - final sig = pipelineSignature(apps); - if (sig == _listDataSig && _cachedListedApps != null) { - return; + @override + Widget build(BuildContext context) { + final appsProvider = context.watch(); + final settingsProvider = context.watch(); + + if (!appsProvider.loadingApps && + appsProvider.apps.isNotEmpty && + settingsProvider.checkJustStarted() && + settingsProvider.checkOnStart) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + refreshIndicatorKey.currentState?.show(); + }); } - _listDataSig = sig; - var listedApps = apps; - final existingUpdates = appsProvider.findAppIdsWithPendingUpdates( - installedOnly: true, - ); - listedApps = getFilteredAndSortedApps(listedApps, existingUpdates.toSet()); + final apps = appsProvider.getAppValues().toList(); + final allAppIds = apps.map((e) => e.app.id).toSet(); + final localSelected = selectedAppIds.where(allAppIds.contains).toSet(); + if (localSelected.length != selectedAppIds.length) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + final freshListedIds = appsProvider + .getAppValues() + .map((e) => e.app.id) + .toSet(); + setState(() { + selectedAppIds = selectedAppIds + .where(freshListedIds.contains) + .toSet(); + }); + widget.onSelectionChanged?.call(selectedAppIds.isNotEmpty); + } + }); + } - final listedAppIdSet = listedApps.map((e) => e.app.id).toSet(); + final existingUpdates = appsProvider + .findAppIdsWithPendingUpdates(installedOnly: true) + .toSet(); + final listedApps = getFilteredAndSortedApps(List.from(apps), existingUpdates); + + final listedAppIdSet2 = listedApps.map((e) => e.app.id).toSet(); var existingUpdateIdsAllOrSelected = existingUpdates .where( (element) => selectedAppIds.isEmpty - ? listedAppIdSet.contains(element) + ? listedAppIdSet2.contains(element) : selectedAppIds.contains(element), ) .toList(); @@ -1072,7 +1051,7 @@ class AppsPageState extends State { .findAppIdsWithPendingUpdates(nonInstalledOnly: true) .where( (element) => selectedAppIds.isEmpty - ? listedAppIdSet.contains(element) + ? listedAppIdSet2.contains(element) : selectedAppIds.contains(element), ) .toList(); @@ -1122,65 +1101,6 @@ class AppsPageState extends State { return a.toLowerCase().compareTo(b.toLowerCase()); }); - _cachedListedApps = listedApps; - _cachedExistingUpdateIds = existingUpdateIdsAllOrSelected; - _cachedNewInstallIds = newInstallIdsAllOrSelected; - _cachedTrackOnlyUpdateIds = trackOnlyUpdateIdsAllOrSelected; - _cachedGrouped = grouped; - _cachedListedGroups = listedGroups; - } - - @override - Widget build(BuildContext context) { - final appsProvider = context.read(); - final settingsProvider = context.watch(); - - context.select((AppsProvider p) => p.loadingApps); - context.select( - (AppsProvider p) => pipelineSignature(p.getAppValues().toList()), - ); - - if (!appsProvider.loadingApps && - appsProvider.apps.isNotEmpty && - settingsProvider.checkJustStarted() && - settingsProvider.checkOnStart) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - refreshIndicatorKey.currentState?.show(); - }); - } - - final listedAppIdSet = appsProvider - .getAppValues() - .map((e) => e.app.id) - .toSet(); - final localSelected = selectedAppIds.where(listedAppIdSet.contains).toSet(); - if (localSelected.length != selectedAppIds.length) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - final freshListedIds = appsProvider - .getAppValues() - .map((e) => e.app.id) - .toSet(); - setState(() { - selectedAppIds = selectedAppIds - .where(freshListedIds.contains) - .toSet(); - }); - widget.onSelectionChanged?.call(selectedAppIds.isNotEmpty); - } - }); - } - - _computeListData(appsProvider, settingsProvider); - final listedApps = _cachedListedApps!; - final existingUpdateIdsAllOrSelected = _cachedExistingUpdateIds!; - final newInstallIdsAllOrSelected = _cachedNewInstallIds!; - final trackOnlyUpdateIdsAllOrSelected = _cachedTrackOnlyUpdateIds!; - final groupBy = settingsProvider.groupBy; - final grouped = _cachedGrouped!; - final listedGroups = _cachedListedGroups!; - return PopScope( canPop: selectedAppIds.isEmpty, onPopInvokedWithResult: (didPop, result) { @@ -1273,9 +1193,7 @@ class AppsPageState extends State { void showSelectedAppActions() { if (!mounted) return; - final listedApps = - _cachedListedApps ?? - context.read().getAppValues().toList(); + final listedApps = context.read().getAppValues().toList(); final selectedApps = listedApps .map((e) => e.app) .where((a) => selectedAppIds.contains(a.id)) diff --git a/lib/providers/apps_provider.dart b/lib/providers/apps_provider.dart index 089c13cf..c9c31d21 100644 --- a/lib/providers/apps_provider.dart +++ b/lib/providers/apps_provider.dart @@ -866,6 +866,7 @@ class AppsProvider with ChangeNotifier { late final SettingsProvider settingsProvider; Directory? _apkDir; Directory? _iconsCacheDir; + Directory? cachedAppsDir; Directory get apkDir { if (_apkDir == null) { diff --git a/lib/providers/apps_provider_lifecycle.dart b/lib/providers/apps_provider_lifecycle.dart index afe5fe08..ef26c7b2 100644 --- a/lib/providers/apps_provider_lifecycle.dart +++ b/lib/providers/apps_provider_lifecycle.dart @@ -44,6 +44,7 @@ extension AppsProviderLifecycle on AppsProvider { } Future getAppsDir() async { + if (cachedAppsDir != null) return cachedAppsDir!; final Directory appsDir = Directory( '${(await getAppStorageDir()).path}/app_data', ); @@ -57,10 +58,10 @@ extension AppsProviderLifecycle on AppsProvider { if (!fallbackDir.existsSync()) { fallbackDir.createSync(recursive: true); } - return fallbackDir; + return cachedAppsDir = fallbackDir; } } - return appsDir; + return cachedAppsDir = appsDir; } bool isVersionDetectionPossible(AppInMemory? app) { diff --git a/lib/providers/source_provider.dart b/lib/providers/source_provider.dart index 8684cf5f..8592e512 100644 --- a/lib/providers/source_provider.dart +++ b/lib/providers/source_provider.dart @@ -1404,7 +1404,7 @@ class VersionService { } } } - return results; + return results.toSet().toList(); } String? regExValidator(String? value) { @@ -1472,7 +1472,15 @@ class VersionService { } } + static final Map> _strictFormatCache = {}; + static final Map> _looseFormatCache = {}; + static const int _maxFormatCacheSize = 4096; + Set findStandardFormatsForVersion(String version, bool strict) { + final cache = strict ? _strictFormatCache : _looseFormatCache; + final cached = cache[version]; + if (cached != null) return cached; + final Set results = {}; final patterns = strict ? strictStandardVersionRegExes @@ -1482,6 +1490,8 @@ class VersionService { results.add(entry.key); } } + if (cache.length >= _maxFormatCacheSize) cache.clear(); + cache[version] = results; return results; }