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.
This commit is contained in:
Imran Remtulla
2026-07-15 17:04:37 +01:00
parent 6a68966718
commit 7ab50177f7
5 changed files with 107 additions and 169 deletions

View File

@@ -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<dynamic, DateTime?> dates = {};
final Map<dynamic, Set<String>> 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);
});
}

View File

@@ -64,14 +64,6 @@ class AppsPageState extends State<AppsPage> {
Timer? _searchDebounce;
int? _listDataSig;
List<AppInMemory>? _cachedListedApps;
List<String>? _cachedExistingUpdateIds;
List<String>? _cachedNewInstallIds;
List<String>? _cachedTrackOnlyUpdateIds;
Map<String?, List<int>>? _cachedGrouped;
List<String?>? _cachedListedGroups;
@override
void didChangeDependencies() {
super.didChangeDependencies();
@@ -116,42 +108,6 @@ class AppsPageState extends State<AppsPage> {
widget.onSelectionChanged?.call(selectedAppIds.isNotEmpty);
}
int pipelineSignature(List<AppInMemory> apps) {
final parts = <Object?>[
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(<Object?>[
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<AppInMemory> getFilteredAndSortedApps(
List<AppInMemory> listedApps,
Set<String> existingUpdates,
@@ -1042,29 +998,52 @@ class AppsPageState extends State<AppsPage> {
];
}
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<AppsProvider>();
final settingsProvider = context.watch<SettingsProvider>();
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<AppInMemory>.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<AppsPage> {
.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<AppsPage> {
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<AppsProvider>();
final settingsProvider = context.watch<SettingsProvider>();
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<AppsPage> {
void showSelectedAppActions() {
if (!mounted) return;
final listedApps =
_cachedListedApps ??
context.read<AppsProvider>().getAppValues().toList();
final listedApps = context.read<AppsProvider>().getAppValues().toList();
final selectedApps = listedApps
.map((e) => e.app)
.where((a) => selectedAppIds.contains(a.id))

View File

@@ -866,6 +866,7 @@ class AppsProvider with ChangeNotifier {
late final SettingsProvider settingsProvider;
Directory? _apkDir;
Directory? _iconsCacheDir;
Directory? cachedAppsDir;
Directory get apkDir {
if (_apkDir == null) {

View File

@@ -44,6 +44,7 @@ extension AppsProviderLifecycle on AppsProvider {
}
Future<Directory> 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) {

View File

@@ -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<String, Set<String>> _strictFormatCache = {};
static final Map<String, Set<String>> _looseFormatCache = {};
static const int _maxFormatCacheSize = 4096;
Set<String> findStandardFormatsForVersion(String version, bool strict) {
final cache = strict ? _strictFormatCache : _looseFormatCache;
final cached = cache[version];
if (cached != null) return cached;
final Set<String> 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;
}