mirror of
https://github.com/ImranR98/Obtainium.git
synced 2026-07-30 17:06:26 -04:00
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:
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -866,6 +866,7 @@ class AppsProvider with ChangeNotifier {
|
||||
late final SettingsProvider settingsProvider;
|
||||
Directory? _apkDir;
|
||||
Directory? _iconsCacheDir;
|
||||
Directory? cachedAppsDir;
|
||||
|
||||
Directory get apkDir {
|
||||
if (_apkDir == null) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user