修复hj加载

This commit is contained in:
zhx 2026-05-15 21:43:13 +08:00
parent 125b852590
commit a122935747
4 changed files with 287 additions and 36 deletions

View File

@ -15,8 +15,8 @@ class MediaCache {
final Duration stalePeriod; final Duration stalePeriod;
final int maxNrOfCacheObjects; final int maxNrOfCacheObjects;
static String get videoCachePath => "$temporaryPath"; static String get videoCachePath => temporaryPath;
static String get imageCachePath => "$temporaryPath"; static String get imageCachePath => temporaryPath;
MediaCache._internal({ MediaCache._internal({
required this.cacheKey, required this.cacheKey,
@ -65,14 +65,24 @@ class MediaCache {
/// ///
/// [url] /// [url]
/// [ignoreCache] /// [ignoreCache]
Future<File> getFile({required String url, bool ignoreCache = false}) async { Future<File> getFile({
required String url,
bool ignoreCache = false,
Map<String, String>? headers,
}) async {
if (ignoreCache) { if (ignoreCache) {
// //
final fileInfo = await _cacheManager.downloadFile(url); final fileInfo = await _cacheManager.downloadFile(
url,
authHeaders: headers,
);
return fileInfo.file; return fileInfo.file;
} else { } else {
// //
final cacheFile = await _cacheManager.getSingleFile(url); final cacheFile = await _cacheManager.getSingleFile(
url,
headers: headers,
);
return cacheFile; return cacheFile;
} }
} }
@ -119,10 +129,8 @@ class MediaCache {
localFile.readAsBytesSync(), // localFile.readAsBytesSync(), //
fileExtension: localFile.path.split('.').last, // fileExtension: localFile.path.split('.').last, //
); );
} catch (_) {
// 2. return;
final cachedFile = await cacheManager.getFileFromCache(cacheKey); }
if (cachedFile != null) {}
} catch (e) {}
} }
} }

View File

@ -390,6 +390,8 @@ class _RocketCanvas extends StatelessWidget {
imageUrl: selectedLevelItem.previewImageUrl, imageUrl: selectedLevelItem.previewImageUrl,
fit: BoxFit.contain, fit: BoxFit.contain,
preferSvg: true, preferSvg: true,
pagDisplayScale: 2,
pagClipBehavior: Clip.none,
debugName: debugName:
'centerPreview level=${selectedLevelItem.level} ' 'centerPreview level=${selectedLevelItem.level} '
'preview=${selectedLevelItem.previewImageUrl ?? '-'}', 'preview=${selectedLevelItem.previewImageUrl ?? '-'}',
@ -974,6 +976,8 @@ class _RocketImage extends StatelessWidget {
this.imageUrl, this.imageUrl,
this.fit = BoxFit.cover, this.fit = BoxFit.cover,
this.preferSvg = false, this.preferSvg = false,
this.pagDisplayScale = 1,
this.pagClipBehavior = Clip.hardEdge,
this.debugName, this.debugName,
}); });
@ -981,6 +985,8 @@ class _RocketImage extends StatelessWidget {
final String? imageUrl; final String? imageUrl;
final BoxFit fit; final BoxFit fit;
final bool preferSvg; final bool preferSvg;
final double pagDisplayScale;
final Clip pagClipBehavior;
final String? debugName; final String? debugName;
@override @override
@ -1005,6 +1011,8 @@ class _RocketImage extends StatelessWidget {
resource: source, resource: source,
loop: true, loop: true,
fit: fit, fit: fit,
displayScale: pagDisplayScale,
clipBehavior: pagClipBehavior,
underlayBuilder: (_) => _imageFallback(), underlayBuilder: (_) => _imageFallback(),
defaultBuilder: (_) => const SCRotatingDotsLoading(), defaultBuilder: (_) => const SCRotatingDotsLoading(),
); );
@ -1054,6 +1062,8 @@ class _RocketImage extends StatelessWidget {
resource: source, resource: source,
loop: true, loop: true,
fit: fit, fit: fit,
displayScale: pagDisplayScale,
clipBehavior: pagClipBehavior,
underlayBuilder: (_) => _imageFallback(), underlayBuilder: (_) => _imageFallback(),
defaultBuilder: (_) => _imageFallback(), defaultBuilder: (_) => _imageFallback(),
); );

View File

@ -1,12 +1,17 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:pag/pag.dart'; import 'package:pag/pag.dart';
import 'package:yumi/shared/data_sources/sources/local/file_cache_manager.dart';
import 'package:yumi/shared/tools/sc_network_image_utils.dart';
import 'package:yumi/ui_kit/components/custom_cached_image.dart'; import 'package:yumi/ui_kit/components/custom_cached_image.dart';
import 'package:yumi/ui_kit/components/sc_rotating_dots_loading.dart'; import 'package:yumi/ui_kit/components/sc_rotating_dots_loading.dart';
const Duration _pagMemoryTtl = Duration(minutes: 5);
class RoomRocketPagEffectOverlay extends StatefulWidget { class RoomRocketPagEffectOverlay extends StatefulWidget {
const RoomRocketPagEffectOverlay({ const RoomRocketPagEffectOverlay({
super.key, super.key,
@ -71,6 +76,9 @@ class _RoomRocketPagEffectOverlayState
height: size.height, height: size.height,
loop: false, loop: false,
fit: BoxFit.cover, fit: BoxFit.cover,
displayScale: 1,
clipBehavior: Clip.hardEdge,
memoryTtl: _pagMemoryTtl,
onCompleted: _complete, onCompleted: _complete,
defaultBuilder: defaultBuilder:
(_) => const Center(child: SCRotatingDotsLoading()), (_) => const Center(child: SCRotatingDotsLoading()),
@ -134,6 +142,9 @@ class RoomRocketPagPreview extends StatelessWidget {
this.height, this.height,
this.loop = true, this.loop = true,
this.fit = BoxFit.contain, this.fit = BoxFit.contain,
this.displayScale = 1,
this.clipBehavior = Clip.hardEdge,
this.memoryTtl = _pagMemoryTtl,
this.underlayBuilder, this.underlayBuilder,
this.defaultBuilder, this.defaultBuilder,
}); });
@ -143,6 +154,9 @@ class RoomRocketPagPreview extends StatelessWidget {
final double? height; final double? height;
final bool loop; final bool loop;
final BoxFit fit; final BoxFit fit;
final double displayScale;
final Clip clipBehavior;
final Duration memoryTtl;
final WidgetBuilder? underlayBuilder; final WidgetBuilder? underlayBuilder;
final WidgetBuilder? defaultBuilder; final WidgetBuilder? defaultBuilder;
@ -162,6 +176,9 @@ class RoomRocketPagPreview extends StatelessWidget {
height: resolvedHeight, height: resolvedHeight,
loop: loop, loop: loop,
fit: fit, fit: fit,
displayScale: displayScale,
clipBehavior: clipBehavior,
memoryTtl: memoryTtl,
underlayBuilder: underlayBuilder, underlayBuilder: underlayBuilder,
defaultBuilder: defaultBuilder:
defaultBuilder ?? defaultBuilder ??
@ -179,6 +196,9 @@ class _RoomRocketPagView extends StatefulWidget {
this.height, this.height,
required this.loop, required this.loop,
required this.fit, required this.fit,
required this.displayScale,
required this.clipBehavior,
required this.memoryTtl,
this.underlayBuilder, this.underlayBuilder,
this.onCompleted, this.onCompleted,
this.defaultBuilder, this.defaultBuilder,
@ -189,6 +209,9 @@ class _RoomRocketPagView extends StatefulWidget {
final double? height; final double? height;
final bool loop; final bool loop;
final BoxFit fit; final BoxFit fit;
final double displayScale;
final Clip clipBehavior;
final Duration memoryTtl;
final WidgetBuilder? underlayBuilder; final WidgetBuilder? underlayBuilder;
final VoidCallback? onCompleted; final VoidCallback? onCompleted;
final WidgetBuilder? defaultBuilder; final WidgetBuilder? defaultBuilder;
@ -205,7 +228,9 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> {
Uint8List? _pagBytes; Uint8List? _pagBytes;
bool _initialized = false; bool _initialized = false;
bool _loadFailed = false; bool _loadFailed = false;
bool _releasedFromMemory = false;
Timer? _startWatchdog; Timer? _startWatchdog;
Timer? _memoryReleaseTimer;
@override @override
void initState() { void initState() {
@ -223,6 +248,8 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> {
_pagBytes = null; _pagBytes = null;
_initialized = false; _initialized = false;
_loadFailed = false; _loadFailed = false;
_releasedFromMemory = false;
_memoryReleaseTimer?.cancel();
_debug( _debug(
'resource changed old=${_shortPagResource(oldWidget.resource)} ' 'resource changed old=${_shortPagResource(oldWidget.resource)} '
'new=${_shortPagResource(widget.resource)}', 'new=${_shortPagResource(widget.resource)}',
@ -235,9 +262,11 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> {
void dispose() { void dispose() {
_debug( _debug(
'dispose initialized=$_initialized ' 'dispose initialized=$_initialized '
'hasBytes=${_pagBytes != null} loadFailed=$_loadFailed', 'hasBytes=${_pagBytes != null} loadFailed=$_loadFailed '
'released=$_releasedFromMemory',
); );
_startWatchdog?.cancel(); _startWatchdog?.cancel();
_memoryReleaseTimer?.cancel();
super.dispose(); super.dispose();
} }
@ -253,6 +282,9 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> {
widget.defaultBuilder?.call(context) ?? widget.defaultBuilder?.call(context) ??
const Center(child: SCRotatingDotsLoading()); const Center(child: SCRotatingDotsLoading());
final underlay = widget.underlayBuilder?.call(context); final underlay = widget.underlayBuilder?.call(context);
if (_releasedFromMemory) {
return _releasedPlaceholder(underlay, defaultChild);
}
if (_loadFailed) { if (_loadFailed) {
return _withUnderlay(underlay, defaultChild); return _withUnderlay(underlay, defaultChild);
} }
@ -274,24 +306,41 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> {
onAnimationRepeat: _handleAnimationRepeat, onAnimationRepeat: _handleAnimationRepeat,
defaultBuilder: (_) => const SizedBox(width: 1, height: 1), defaultBuilder: (_) => const SizedBox(width: 1, height: 1),
); );
final visibleUnderlay = _initialized ? null : underlay;
final content = Stack(
fit: StackFit.expand,
clipBehavior:
widget.clipBehavior == Clip.none ? Clip.none : Clip.hardEdge,
children: [
if (visibleUnderlay != null) visibleUnderlay,
_scaledPagView(pagView),
if (!_initialized) Positioned.fill(child: defaultChild),
],
);
return SizedBox( return SizedBox(
width: widget.width, width: widget.width,
height: widget.height, height: widget.height,
child: ClipRect( child:
child: Stack( widget.clipBehavior == Clip.none ? content : ClipRect(child: content),
fit: StackFit.expand,
children: [
if (underlay != null) underlay,
FittedBox(fit: widget.fit, child: pagView),
if (!_initialized) Positioned.fill(child: defaultChild),
],
),
),
); );
} }
Widget _scaledPagView(Widget pagView) {
final scale =
widget.displayScale.isFinite && widget.displayScale > 0
? widget.displayScale
: 1.0;
final fitted = FittedBox(fit: widget.fit, child: pagView);
if (scale == 1.0) {
return fitted;
}
return Transform.scale(scale: scale, child: fitted);
}
void _prepareResource() { void _prepareResource() {
final path = widget.resource.trim(); final path = widget.resource.trim();
_releasedFromMemory = false;
_memoryReleaseTimer?.cancel();
_debug( _debug(
'prepare kind=${_pagSourceKind(path)} ' 'prepare kind=${_pagSourceKind(path)} '
'isPag=${RoomRocketPagEffectOverlay.isPag(path)}', 'isPag=${RoomRocketPagEffectOverlay.isPag(path)}',
@ -303,18 +352,13 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> {
Future<void> _loadBytes(String path) async { Future<void> _loadBytes(String path) async {
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
try { try {
final isNetwork = _isPagNetwork(path);
_debug( _debug(
'${isNetwork ? 'download' : 'asset load'} start ' '${_pagLoadAction(path)} start '
'host=${_pagHost(path)}', 'host=${_pagHost(path)}',
); );
final data = await (isNetwork final bytes = await _readPagBytes(
? NetworkAssetBundle(Uri.parse(path)).load(path) path,
: rootBundle.load(path)) ).timeout(const Duration(seconds: 15));
.timeout(const Duration(seconds: 15));
final bytes = Uint8List.fromList(
data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes),
);
if (!mounted || widget.resource.trim() != path) { if (!mounted || widget.resource.trim() != path) {
_debug( _debug(
'bytes ignored stale mounted=$mounted ' 'bytes ignored stale mounted=$mounted '
@ -323,7 +367,7 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> {
return; return;
} }
if (bytes.isEmpty) { if (bytes.isEmpty) {
_markLoadFailed('download empty'); _markLoadFailed('pag bytes empty');
return; return;
} }
_debug( _debug(
@ -333,7 +377,9 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> {
); );
setState(() { setState(() {
_pagBytes = bytes; _pagBytes = bytes;
_releasedFromMemory = false;
}); });
_scheduleMemoryRelease(path);
_restartWatchdog(); _restartWatchdog();
} catch (error) { } catch (error) {
if (!mounted || widget.resource.trim() != path) { if (!mounted || widget.resource.trim() != path) {
@ -445,6 +491,26 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> {
}); });
} }
void _scheduleMemoryRelease(String path) {
_memoryReleaseTimer?.cancel();
if (widget.memoryTtl <= Duration.zero) {
return;
}
_memoryReleaseTimer = Timer(widget.memoryTtl, () {
if (!mounted || widget.resource.trim() != path || _pagBytes == null) {
return;
}
_debug('release bytes from memory ttl=${widget.memoryTtl.inSeconds}s');
_startWatchdog?.cancel();
setState(() {
_pagKey = GlobalKey<PAGViewState>();
_pagBytes = null;
_initialized = false;
_releasedFromMemory = true;
});
});
}
Widget _withUnderlay(Widget? underlay, Widget foreground) { Widget _withUnderlay(Widget? underlay, Widget foreground) {
if (underlay == null) { if (underlay == null) {
return foreground; return foreground;
@ -459,9 +525,18 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> {
); );
} }
Widget _releasedPlaceholder(Widget? underlay, Widget fallback) {
return SizedBox(
width: widget.width,
height: widget.height,
child: underlay ?? fallback,
);
}
void _markLoadFailed(String reason) { void _markLoadFailed(String reason) {
_debug('failed reason=$reason'); _debug('failed reason=$reason');
_startWatchdog?.cancel(); _startWatchdog?.cancel();
_memoryReleaseTimer?.cancel();
if (mounted) { if (mounted) {
setState(() { setState(() {
_initialized = false; _initialized = false;
@ -485,23 +560,76 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> {
'size=${widget.width?.toStringAsFixed(1)}x' 'size=${widget.width?.toStringAsFixed(1)}x'
'${widget.height?.toStringAsFixed(1)} ' '${widget.height?.toStringAsFixed(1)} '
'kind=${_pagSourceKind(widget.resource)} ' 'kind=${_pagSourceKind(widget.resource)} '
'scale=${widget.displayScale.toStringAsFixed(2)} '
'released=$_releasedFromMemory '
'resource=${_shortPagResource(widget.resource)}', 'resource=${_shortPagResource(widget.resource)}',
); );
} }
} }
@visibleForTesting
Duration debugRoomRocketPagMemoryTtl() {
return _pagMemoryTtl;
}
@visibleForTesting
String debugNormalizeRoomRocketPagResource(String resource) {
return _normalizePagResource(resource);
}
@visibleForTesting
Uri? debugRoomRocketPagNetworkUri(String resource) {
return _pagNetworkUri(resource);
}
@visibleForTesting
String? debugRoomRocketPagFilePath(String resource) {
return _pagFilePath(resource);
}
@visibleForTesting
String debugRoomRocketPagSourceKind(String resource) {
return _pagSourceKind(resource);
}
Future<Uint8List> _readPagBytes(String value) async {
final normalized = _normalizePagResource(value);
final uri = _pagNetworkUri(normalized);
if (uri != null) {
final url = uri.toString();
final file = await FileCacheManager.getInstance().getFile(
url: url,
headers: buildNetworkImageHeaders(url),
);
return file.readAsBytes();
}
final filePath = _pagFilePath(normalized);
if (filePath != null) {
return File(filePath).readAsBytes();
}
final data = await rootBundle.load(normalized);
return Uint8List.fromList(
data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes),
);
}
bool _isPagNetwork(String value) { bool _isPagNetwork(String value) {
return value.startsWith('http://') || value.startsWith('https://'); return _pagNetworkUri(value) != null;
} }
String _pagSourceKind(String value) { String _pagSourceKind(String value) {
final text = value.trim(); final text = _normalizePagResource(value);
if (text.isEmpty) { if (text.isEmpty) {
return 'empty'; return 'empty';
} }
if (_isPagNetwork(text)) { if (_isPagNetwork(text)) {
return 'network'; return 'network';
} }
if (_pagFilePath(text) != null) {
return 'file';
}
if (text.startsWith('sc_images/') || text.startsWith('assets/')) { if (text.startsWith('sc_images/') || text.startsWith('assets/')) {
return 'asset'; return 'asset';
} }
@ -509,10 +637,67 @@ String _pagSourceKind(String value) {
} }
String _pagHost(String value) { String _pagHost(String value) {
if (!_isPagNetwork(value)) { return _pagNetworkUri(value)?.host ?? '-';
return '-'; }
String _pagLoadAction(String value) {
return switch (_pagSourceKind(value)) {
'network' => 'download',
'file' => 'file load',
'asset' => 'asset load',
_ => 'asset load',
};
}
String _normalizePagResource(String value) {
final normalized = normalizeImageResourceUrl(value.trim());
return _pagNetworkUri(normalized)?.toString() ?? normalized;
}
Uri? _pagNetworkUri(String value) {
final text = normalizeImageResourceUrl(value.trim());
if (text.isEmpty) {
return null;
} }
return Uri.tryParse(value)?.host ?? '-'; final direct = Uri.tryParse(text);
if (_isHttpUri(direct)) {
return direct;
}
final encoded = Uri.tryParse(Uri.encodeFull(text));
if (_isHttpUri(encoded)) {
return encoded;
}
return null;
}
bool _isHttpUri(Uri? uri) {
if (uri == null || !uri.hasScheme || uri.host.isEmpty) {
return false;
}
final scheme = uri.scheme.toLowerCase();
return scheme == 'http' || scheme == 'https';
}
String? _pagFilePath(String value) {
final text = value.trim();
if (text.isEmpty) {
return null;
}
final uri = Uri.tryParse(text);
if (uri != null && uri.scheme.toLowerCase() == 'file') {
try {
return uri.toFilePath();
} catch (_) {
return null;
}
}
if (text.startsWith('/')) {
return text;
}
if (RegExp(r'^[A-Za-z]:[\\/]').hasMatch(text)) {
return text;
}
return null;
} }
String _shortPagResource(String value) { String _shortPagResource(String value) {

View File

@ -0,0 +1,48 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:yumi/app/config/app_config.dart';
import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/ui_kit/widgets/room/rocket/room_rocket_pag_effect_overlay.dart';
void main() {
setUpAll(() {
AppConfig.initialize();
});
test('normalizes unescaped pag network urls', () {
final uri = debugRoomRocketPagNetworkUri(
'https://example.com/assets/火箭 输出/火箭1级.pag?name=火箭 1',
);
expect(uri, isNotNull);
expect(uri!.scheme, 'https');
expect(uri.host, 'example.com');
expect(uri.toString(), contains('%E7%81%AB%E7%AE%AD'));
expect(uri.toString(), contains('%20'));
});
test('normalizes protocol-relative and server-relative pag urls', () {
final apiUri = Uri.parse(SCGlobalConfig.apiHost);
final apiScheme = apiUri.scheme == 'http' ? 'http' : 'https';
expect(
debugNormalizeRoomRocketPagResource('//cdn.example.com/rocket.pag'),
'$apiScheme://cdn.example.com/rocket.pag',
);
expect(
debugNormalizeRoomRocketPagResource('/external/oss/local/火箭.pag'),
apiUri.resolve('/external/oss/local/火箭.pag').toString(),
);
});
test('detects local file sources before falling back to assets', () {
expect(
debugRoomRocketPagFilePath('file:///tmp/%E7%81%AB%E7%AE%AD.pag'),
'/tmp/火箭.pag',
);
expect(debugRoomRocketPagSourceKind('/tmp/rocket.pag'), 'file');
expect(debugRoomRocketPagSourceKind('assets/rocket/rocket.pag'), 'asset');
});
test('keeps pag bytes in memory for five minutes by default', () {
expect(debugRoomRocketPagMemoryTtl(), const Duration(minutes: 5));
});
}