修复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 int maxNrOfCacheObjects;
static String get videoCachePath => "$temporaryPath";
static String get imageCachePath => "$temporaryPath";
static String get videoCachePath => temporaryPath;
static String get imageCachePath => temporaryPath;
MediaCache._internal({
required this.cacheKey,
@ -65,14 +65,24 @@ class MediaCache {
///
/// [url]
/// [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) {
//
final fileInfo = await _cacheManager.downloadFile(url);
final fileInfo = await _cacheManager.downloadFile(
url,
authHeaders: headers,
);
return fileInfo.file;
} else {
//
final cacheFile = await _cacheManager.getSingleFile(url);
final cacheFile = await _cacheManager.getSingleFile(
url,
headers: headers,
);
return cacheFile;
}
}
@ -119,10 +129,8 @@ class MediaCache {
localFile.readAsBytesSync(), //
fileExtension: localFile.path.split('.').last, //
);
// 2.
final cachedFile = await cacheManager.getFileFromCache(cacheKey);
if (cachedFile != null) {}
} catch (e) {}
} catch (_) {
return;
}
}
}

View File

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

View File

@ -1,12 +1,17 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.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/sc_rotating_dots_loading.dart';
const Duration _pagMemoryTtl = Duration(minutes: 5);
class RoomRocketPagEffectOverlay extends StatefulWidget {
const RoomRocketPagEffectOverlay({
super.key,
@ -71,6 +76,9 @@ class _RoomRocketPagEffectOverlayState
height: size.height,
loop: false,
fit: BoxFit.cover,
displayScale: 1,
clipBehavior: Clip.hardEdge,
memoryTtl: _pagMemoryTtl,
onCompleted: _complete,
defaultBuilder:
(_) => const Center(child: SCRotatingDotsLoading()),
@ -134,6 +142,9 @@ class RoomRocketPagPreview extends StatelessWidget {
this.height,
this.loop = true,
this.fit = BoxFit.contain,
this.displayScale = 1,
this.clipBehavior = Clip.hardEdge,
this.memoryTtl = _pagMemoryTtl,
this.underlayBuilder,
this.defaultBuilder,
});
@ -143,6 +154,9 @@ class RoomRocketPagPreview extends StatelessWidget {
final double? height;
final bool loop;
final BoxFit fit;
final double displayScale;
final Clip clipBehavior;
final Duration memoryTtl;
final WidgetBuilder? underlayBuilder;
final WidgetBuilder? defaultBuilder;
@ -162,6 +176,9 @@ class RoomRocketPagPreview extends StatelessWidget {
height: resolvedHeight,
loop: loop,
fit: fit,
displayScale: displayScale,
clipBehavior: clipBehavior,
memoryTtl: memoryTtl,
underlayBuilder: underlayBuilder,
defaultBuilder:
defaultBuilder ??
@ -179,6 +196,9 @@ class _RoomRocketPagView extends StatefulWidget {
this.height,
required this.loop,
required this.fit,
required this.displayScale,
required this.clipBehavior,
required this.memoryTtl,
this.underlayBuilder,
this.onCompleted,
this.defaultBuilder,
@ -189,6 +209,9 @@ class _RoomRocketPagView extends StatefulWidget {
final double? height;
final bool loop;
final BoxFit fit;
final double displayScale;
final Clip clipBehavior;
final Duration memoryTtl;
final WidgetBuilder? underlayBuilder;
final VoidCallback? onCompleted;
final WidgetBuilder? defaultBuilder;
@ -205,7 +228,9 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> {
Uint8List? _pagBytes;
bool _initialized = false;
bool _loadFailed = false;
bool _releasedFromMemory = false;
Timer? _startWatchdog;
Timer? _memoryReleaseTimer;
@override
void initState() {
@ -223,6 +248,8 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> {
_pagBytes = null;
_initialized = false;
_loadFailed = false;
_releasedFromMemory = false;
_memoryReleaseTimer?.cancel();
_debug(
'resource changed old=${_shortPagResource(oldWidget.resource)} '
'new=${_shortPagResource(widget.resource)}',
@ -235,9 +262,11 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> {
void dispose() {
_debug(
'dispose initialized=$_initialized '
'hasBytes=${_pagBytes != null} loadFailed=$_loadFailed',
'hasBytes=${_pagBytes != null} loadFailed=$_loadFailed '
'released=$_releasedFromMemory',
);
_startWatchdog?.cancel();
_memoryReleaseTimer?.cancel();
super.dispose();
}
@ -253,6 +282,9 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> {
widget.defaultBuilder?.call(context) ??
const Center(child: SCRotatingDotsLoading());
final underlay = widget.underlayBuilder?.call(context);
if (_releasedFromMemory) {
return _releasedPlaceholder(underlay, defaultChild);
}
if (_loadFailed) {
return _withUnderlay(underlay, defaultChild);
}
@ -274,24 +306,41 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> {
onAnimationRepeat: _handleAnimationRepeat,
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(
width: widget.width,
height: widget.height,
child: ClipRect(
child: Stack(
fit: StackFit.expand,
children: [
if (underlay != null) underlay,
FittedBox(fit: widget.fit, child: pagView),
if (!_initialized) Positioned.fill(child: defaultChild),
],
),
),
child:
widget.clipBehavior == Clip.none ? content : ClipRect(child: content),
);
}
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() {
final path = widget.resource.trim();
_releasedFromMemory = false;
_memoryReleaseTimer?.cancel();
_debug(
'prepare kind=${_pagSourceKind(path)} '
'isPag=${RoomRocketPagEffectOverlay.isPag(path)}',
@ -303,18 +352,13 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> {
Future<void> _loadBytes(String path) async {
final stopwatch = Stopwatch()..start();
try {
final isNetwork = _isPagNetwork(path);
_debug(
'${isNetwork ? 'download' : 'asset load'} start '
'${_pagLoadAction(path)} start '
'host=${_pagHost(path)}',
);
final data = await (isNetwork
? NetworkAssetBundle(Uri.parse(path)).load(path)
: rootBundle.load(path))
.timeout(const Duration(seconds: 15));
final bytes = Uint8List.fromList(
data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes),
);
final bytes = await _readPagBytes(
path,
).timeout(const Duration(seconds: 15));
if (!mounted || widget.resource.trim() != path) {
_debug(
'bytes ignored stale mounted=$mounted '
@ -323,7 +367,7 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> {
return;
}
if (bytes.isEmpty) {
_markLoadFailed('download empty');
_markLoadFailed('pag bytes empty');
return;
}
_debug(
@ -333,7 +377,9 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> {
);
setState(() {
_pagBytes = bytes;
_releasedFromMemory = false;
});
_scheduleMemoryRelease(path);
_restartWatchdog();
} catch (error) {
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) {
if (underlay == null) {
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) {
_debug('failed reason=$reason');
_startWatchdog?.cancel();
_memoryReleaseTimer?.cancel();
if (mounted) {
setState(() {
_initialized = false;
@ -485,23 +560,76 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> {
'size=${widget.width?.toStringAsFixed(1)}x'
'${widget.height?.toStringAsFixed(1)} '
'kind=${_pagSourceKind(widget.resource)} '
'scale=${widget.displayScale.toStringAsFixed(2)} '
'released=$_releasedFromMemory '
'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) {
return value.startsWith('http://') || value.startsWith('https://');
return _pagNetworkUri(value) != null;
}
String _pagSourceKind(String value) {
final text = value.trim();
final text = _normalizePagResource(value);
if (text.isEmpty) {
return 'empty';
}
if (_isPagNetwork(text)) {
return 'network';
}
if (_pagFilePath(text) != null) {
return 'file';
}
if (text.startsWith('sc_images/') || text.startsWith('assets/')) {
return 'asset';
}
@ -509,10 +637,67 @@ String _pagSourceKind(String value) {
}
String _pagHost(String value) {
if (!_isPagNetwork(value)) {
return '-';
return _pagNetworkUri(value)?.host ?? '-';
}
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) {

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));
});
}