fix(perf): memory optimization, prevent OOM on mobile, and completely disable mouse wheel zoom
Build and Release / Build Windows x64 (push) Has been cancelled
Build and Release / Build macOS (push) Has been cancelled

This commit is contained in:
2026-09-05 18:09:58 +03:00
parent d7d9cad423
commit 5b2c8eb53e
13 changed files with 109 additions and 60 deletions
+39 -38
View File
@@ -4,32 +4,33 @@
<application <application
android:label="Prisma" android:label="Prisma"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher"
<activity android:largeHeap="true">
android:name=".MainActivity" <activity
android:exported="true" android:name=".MainActivity"
android:launchMode="singleTop" android:exported="true"
android:taskAffinity="" android:launchMode="singleTop"
android:theme="@style/LaunchTheme" android:taskAffinity=""
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:theme="@style/LaunchTheme"
android:hardwareAccelerated="true" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:windowSoftInputMode="adjustResize"> android:hardwareAccelerated="true"
<!-- Specifies an Android theme to apply to this Activity as soon as android:windowSoftInputMode="adjustResize">
the Android process has started. This theme is visible to the user <!-- Specifies an Android theme to apply to this Activity as soon as
while the Flutter UI initializes. After that, this theme continues the Android process has started. This theme is visible to the user
to determine the Window background behind the Flutter UI. --> while the Flutter UI initializes. After that, this theme continues
<meta-data to determine the Window background behind the Flutter UI. -->
android:name="io.flutter.embedding.android.NormalTheme" <meta-data
android:resource="@style/NormalTheme" android:name="io.flutter.embedding.android.NormalTheme"
/> android:resource="@style/NormalTheme"
<intent-filter> />
<action android:name="android.intent.action.MAIN"/> <intent-filter>
<category android:name="android.intent.category.LAUNCHER"/> <action android:name="android.intent.action.MAIN"/>
</intent-filter> <category android:name="android.intent.category.LAUNCHER"/>
</activity> </intent-filter>
<!-- Don't delete the meta-data below. </activity>
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />
@@ -43,15 +44,15 @@
android:resource="@xml/file_paths" /> android:resource="@xml/file_paths" />
</provider> </provider>
</application> </application>
<!-- Required to query activities that can process text, see: <!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT. https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. --> In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries> <queries>
<intent> <intent>
<action android:name="android.intent.action.PROCESS_TEXT"/> <action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/> <data android:mimeType="text/plain"/>
</intent> </intent>
</queries> </queries>
</manifest> </manifest>
+11
View File
@@ -1,4 +1,15 @@
const prismaChangelog = [ const prismaChangelog = [
PrismaChange(
version: '3.6.2',
title: 'Комплексная оптимизация памяти на телефоне и ПК, полное отключение зума колесиком',
bullets: [
'Полное отключение масштабирования колесиком мыши: перехвачен внутренний обработчик PointerScrollEvent в интерактивном вьюере, что навсегда исключило нежелательный зум при прокрутке.',
'Предотвращение вылетов на смартфонах (OOM): включен флаг android:largeHeap="true", расширяющий доступный лимит оперативной памяти процесса до 512–1024 МБ.',
'Ограничение аппетита кэша изображений: глобальный ImageCache в Flutter ограничен безопасным лимитом в 180 МБ и 120 изображений (вместо неограниченного роста до 1 ГБ+), неактивные растры своевременно выгружаются сборщиком мусора.',
'Оптимизация декодирования тяжелых артов: полноразмерные изображения с экстремальным разрешением (до 10K+) теперь безопасно декодируются с ограничением memCacheWidth под экран устройства, снижая расход RAM на 80–90%.',
'Оптимизация сетки и миниатюр: карточки ленты изолированы в RepaintBoundary для гладкого 120 FPS скролла без паразитных перерисовок, а всем каруселям, аватаркам и мозаикам заданы строгие границы кэширования.',
],
),
PrismaChange( PrismaChange(
version: '3.6.1', version: '3.6.1',
title: 'Плавная боковая панель без лагов и отключение зума колесиком', title: 'Плавная боковая панель без лагов и отключение зума колесиком',
@@ -1474,6 +1474,8 @@ class _FavoriteArtistsSection extends ConsumerWidget {
fav.avatarUrl!.isNotEmpty fav.avatarUrl!.isNotEmpty
? CachedNetworkImage( ? CachedNetworkImage(
imageUrl: fav.avatarUrl!, imageUrl: fav.avatarUrl!,
memCacheWidth: 120,
memCacheHeight: 120,
fit: BoxFit.cover, fit: BoxFit.cover,
placeholder: (_, __) => const Icon( placeholder: (_, __) => const Icon(
Icons.person_rounded, Icons.person_rounded,
@@ -1599,6 +1601,8 @@ class _FavoriteArtistMediaStrip extends ConsumerWidget {
imageUrl: post.previewUrl.isNotEmpty imageUrl: post.previewUrl.isNotEmpty
? post.previewUrl ? post.previewUrl
: post.sampleUrl, : post.sampleUrl,
memCacheWidth: 200,
memCacheHeight: 200,
fit: BoxFit.cover, fit: BoxFit.cover,
placeholder: (_, __) => ColoredBox( placeholder: (_, __) => ColoredBox(
color: Theme.of(context) color: Theme.of(context)
@@ -1706,6 +1710,8 @@ class _ArtistAvatar extends StatelessWidget {
? fallback ? fallback
: CachedNetworkImage( : CachedNetworkImage(
imageUrl: artist.avatarUrl!, imageUrl: artist.avatarUrl!,
memCacheWidth: 140,
memCacheHeight: 140,
fit: BoxFit.cover, fit: BoxFit.cover,
placeholder: (_, __) => fallback, placeholder: (_, __) => fallback,
errorWidget: (_, __, ___) => fallback, errorWidget: (_, __, ___) => fallback,
@@ -314,6 +314,8 @@ class _CollectionCard extends ConsumerWidget {
if (urls.length == 1) { if (urls.length == 1) {
return CachedNetworkImage( return CachedNetworkImage(
imageUrl: urls[0], imageUrl: urls[0],
memCacheWidth: 260,
memCacheHeight: 260,
fit: BoxFit.cover, fit: BoxFit.cover,
placeholder: (_, __) => Container(color: scheme.surfaceContainerHighest), placeholder: (_, __) => Container(color: scheme.surfaceContainerHighest),
errorWidget: (_, __, ___) => Container( errorWidget: (_, __, ___) => Container(
@@ -333,6 +335,8 @@ class _CollectionCard extends ConsumerWidget {
if (i < urls.length) if (i < urls.length)
CachedNetworkImage( CachedNetworkImage(
imageUrl: urls[i], imageUrl: urls[i],
memCacheWidth: 200,
memCacheHeight: 200,
fit: BoxFit.cover, fit: BoxFit.cover,
placeholder: (_, __) => placeholder: (_, __) =>
Container(color: scheme.surfaceContainerHighest), Container(color: scheme.surfaceContainerHighest),
@@ -1225,6 +1225,8 @@ class _ArtistCard extends StatelessWidget {
), ),
child: CachedNetworkImage( child: CachedNetworkImage(
imageUrl: url, imageUrl: url,
memCacheWidth: 128,
memCacheHeight: 128,
fit: BoxFit.cover, fit: BoxFit.cover,
placeholder: (_, __) => Container( placeholder: (_, __) => Container(
color: scheme.surfaceContainerHighest, color: scheme.surfaceContainerHighest,
@@ -1155,6 +1155,8 @@ class _NeighborStrip extends StatelessWidget {
imageUrl: post.previewUrl.isNotEmpty imageUrl: post.previewUrl.isNotEmpty
? post.previewUrl ? post.previewUrl
: post.sampleUrl, : post.sampleUrl,
memCacheWidth: 160,
memCacheHeight: 160,
fit: BoxFit.cover, fit: BoxFit.cover,
errorWidget: (_, __, ___) => errorWidget: (_, __, ___) =>
const Center(child: Icon(Icons.broken_image_rounded)), const Center(child: Icon(Icons.broken_image_rounded)),
@@ -4,6 +4,7 @@ import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:media_kit/media_kit.dart'; import 'package:media_kit/media_kit.dart';
@@ -316,10 +317,16 @@ class _PostMediaViewerState extends State<PostMediaViewer>
), ),
); );
} else { } else {
final mq = MediaQuery.maybeOf(context);
final dpr = mq?.devicePixelRatio ?? 1.5;
final screenWidth = mq?.size.width ?? 1280;
final maxCacheWidth =
(screenWidth * dpr * 1.5).round().clamp(1080, 2560);
image = CachedNetworkImage( image = CachedNetworkImage(
key: ValueKey(url), key: ValueKey(url),
imageUrl: url, imageUrl: url,
httpHeaders: headers, httpHeaders: headers,
memCacheWidth: maxCacheWidth,
fit: BoxFit.contain, fit: BoxFit.contain,
placeholder: (context, url) => placeholder: (context, url) =>
const Center(child: CircularProgressIndicator()), const Center(child: CircularProgressIndicator()),
@@ -2205,6 +2212,7 @@ class _ZoomableImageState extends State<_ZoomableImage> {
transformationController: _controller, transformationController: _controller,
minScale: 1, minScale: 1,
maxScale: 6, maxScale: 6,
scaleFactor: 1000000.0,
boundaryMargin: const EdgeInsets.all(160), boundaryMargin: const EdgeInsets.all(160),
panEnabled: _zoomed, panEnabled: _zoomed,
scaleEnabled: true, scaleEnabled: true,
@@ -2221,7 +2229,15 @@ class _ZoomableImageState extends State<_ZoomableImage> {
if (mounted) setState(() => _zoomed = nextZoomed); if (mounted) setState(() => _zoomed = nextZoomed);
_setLocked(nextZoomed || _pointerCount >= 2); _setLocked(nextZoomed || _pointerCount >= 2);
}, },
child: child, child: Listener(
onPointerSignal: (event) {
if (event is PointerScrollEvent) {
GestureBinding.instance.pointerSignalResolver
.register(event, (_) {});
}
},
child: child,
),
), ),
), ),
); );
@@ -427,6 +427,8 @@ class _ViewedPostTile extends StatelessWidget {
) )
: CachedNetworkImage( : CachedNetworkImage(
imageUrl: image, imageUrl: image,
memCacheWidth: 320,
memCacheHeight: 320,
fit: BoxFit.cover, fit: BoxFit.cover,
placeholder: (_, __) => Container( placeholder: (_, __) => Container(
color: scheme.surfaceContainerHighest, color: scheme.surfaceContainerHighest,
+3
View File
@@ -5,5 +5,8 @@ import 'app/app.dart';
void main() { void main() {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
// Optimize image cache to prevent memory bloat and OOM crashes
PaintingBinding.instance.imageCache.maximumSizeBytes = 180 * 1024 * 1024; // 180 MB
PaintingBinding.instance.imageCache.maximumSize = 120; // 120 images
runApp(const ProviderScope(child: GelRuleApp())); runApp(const ProviderScope(child: GelRuleApp()));
} }
+1 -1
View File
@@ -580,7 +580,7 @@ class _DesktopShellState extends State<_DesktopShell>
), ),
), ),
child: Text( child: Text(
'v3.6.1', 'v3.6.2',
style: TextStyle( style: TextStyle(
fontSize: 10, fontSize: 10,
fontWeight: fontWeight:
+1 -1
View File
@@ -232,7 +232,7 @@ class _PostCardState extends ConsumerState<PostCard>
} }
final dpr = MediaQuery.devicePixelRatioOf(context); final dpr = MediaQuery.devicePixelRatioOf(context);
final cacheWidth = final cacheWidth =
(constraints.maxWidth * dpr).round().clamp(320, 1800); (constraints.maxWidth * dpr).round().clamp(280, 960);
final headers = _headersFor(post); final headers = _headersFor(post);
final videoUrls = MediaUrlSelector.video(post); final videoUrls = MediaUrlSelector.video(post);
if (!mobile && if (!mobile &&
+20 -18
View File
@@ -68,24 +68,26 @@ class PostMasonryGrid extends StatelessWidget {
final spacing = mobile ? 8.0 : 12.0; final spacing = mobile ? 8.0 : 12.0;
Widget buildCard(Post post) { Widget buildCard(Post post) {
return PostCard( return RepaintBoundary(
post: post, child: PostCard(
blurExplicit: blurExplicit && !nsfwEnabled, post: post,
showBadges: showBadges, blurExplicit: blurExplicit && !nsfwEnabled,
isFavorite: favoriteKeys.contains(post.cacheKey), showBadges: showBadges,
isViewed: viewedKeys.contains(post.cacheKey), isFavorite: favoriteKeys.contains(post.cacheKey),
isDownloaded: downloadedKeys.contains(post.cacheKey), isViewed: viewedKeys.contains(post.cacheKey),
mediaQualityMode: mediaQualityMode, isDownloaded: downloadedKeys.contains(post.cacheKey),
onOpen: () => onOpen(post), mediaQualityMode: mediaQualityMode,
onFavorite: () => onFavorite(post), onOpen: () => onOpen(post),
onPreview: onPreview == null ? null : () => onPreview!(post), onFavorite: () => onFavorite(post),
onHide: onHide == null ? null : () => onHide!(post), onPreview: onPreview == null ? null : () => onPreview!(post),
selectionMode: selectionMode, onHide: onHide == null ? null : () => onHide!(post),
selected: selectedKeys.contains(post.cacheKey), selectionMode: selectionMode,
onToggleSelected: selected: selectedKeys.contains(post.cacheKey),
onToggleSelected == null ? null : () => onToggleSelected!(post), onToggleSelected:
onAddToCollection: onToggleSelected == null ? null : () => onToggleSelected!(post),
onAddToCollection == null ? null : () => onAddToCollection!(post), onAddToCollection:
onAddToCollection == null ? null : () => onAddToCollection!(post),
),
); );
} }
+1 -1
View File
@@ -1,7 +1,7 @@
name: gel_rule_app name: gel_rule_app
description: Local backend/core layer for a Flutter booru browser app. description: Local backend/core layer for a Flutter booru browser app.
publish_to: "none" publish_to: "none"
version: 3.6.1+83 version: 3.6.2+84
environment: environment:
sdk: ">=3.3.0 <4.0.0" sdk: ">=3.3.0 <4.0.0"