diff --git a/android/app/src/main/kotlin/com/petsheroai/app/MainActivity.kt b/android/app/src/main/kotlin/com/petsheroai/app/MainActivity.kt index 370460d..4fc275c 100644 --- a/android/app/src/main/kotlin/com/petsheroai/app/MainActivity.kt +++ b/android/app/src/main/kotlin/com/petsheroai/app/MainActivity.kt @@ -1,12 +1,43 @@ package com.petsheroai.app +import android.app.ActivityManager +import android.content.Context import android.os.Bundle import com.facebook.FacebookSdk import com.facebook.LoggingBehavior import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel class MainActivity : FlutterActivity() { + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + "com.petsheroai.app/device_memory", + ).setMethodCallHandler { call, result -> + if (call.method == "getTotalPhysicalMemoryBytes") { + try { + val am = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val info = ActivityManager.MemoryInfo() + am.getMemoryInfo(info) + val total = info.totalMem + if (total > 0L) { + // 避免 MethodCodec 对大于 2^31-1 的 Long 编码不一致,统一用字符串给 Dart parse + result.success(total.toString()) + } else { + result.error("INVALID", "totalMem is 0", null) + } + } catch (e: Exception) { + result.error("READ_FAILED", e.message, null) + } + } else { + result.notImplemented() + } + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // Facebook SDK 调试日志(SDK 已在 [PetsHeroApplication] 中初始化) diff --git a/lib/core/util/device_memory_profile.dart b/lib/core/util/device_memory_profile.dart new file mode 100644 index 0000000..5b17281 --- /dev/null +++ b/lib/core/util/device_memory_profile.dart @@ -0,0 +1,166 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +/// Android MethodChannel([MainActivity] 内 `ActivityManager.MemoryInfo.totalMem`)。 +const String kDeviceMemoryMethodChannel = 'com.petsheroai.app/device_memory'; + +/// **仅静态封面**:物理内存 **小于** 约 **3GiB**,或 **读不到内存**。 +/// 标称 4GB 机型可见 MemTotal 多在 3.2–3.8GiB,仍走 **2 路** 预览。 +const int kMemoryTierStaticOnlyBytesThreshold = 3 * 1024 * 1024 * 1024; + +/// **满血 4 路解码**:物理内存不少于约 **6GiB**;\[3GiB, 6GiB) 为 **2** 路。 +const int kMemoryTierFullConcurrentBytesThreshold = 6 * 1024 * 1024 * 1024; + +const MethodChannel _deviceMemoryChannel = + MethodChannel(kDeviceMemoryMethodChannel); + +enum _GridVideoMemoryPolicy { + staticOnly, + maxConcurrent2, + maxConcurrent4, +} + +_GridVideoMemoryPolicy? _cachedPolicy; + +/// 初始化完成前:与「读不到内存」一致,仅静态(须在 [main] 中先 await [ensureDeviceMemoryProfileInitialized])。 +_GridVideoMemoryPolicy get _effectivePolicy => + _cachedPolicy ?? _GridVideoMemoryPolicy.staticOnly; + +/// 须在 [main] 里 `WidgetsFlutterBinding.ensureInitialized()` 之后、`runApp` 之前调用一次。 +Future ensureDeviceMemoryProfileInitialized() async { + if (_cachedPolicy != null) return; + if (!Platform.isAndroid) { + _cachedPolicy = _GridVideoMemoryPolicy.maxConcurrent4; + return; + } + try { + final raw = + await _deviceMemoryChannel.invokeMethod( + 'getTotalPhysicalMemoryBytes', + ); + final bytes = _coerceToPhysicalRamBytes(raw); + if (bytes != null && _isPlausibleDeviceRamBytes(bytes)) { + _cachedPolicy = _policyFromTotalBytes(bytes); + debugPrint( + 'DeviceMemory: totalMem≈${(bytes / (1024 * 1024)).toStringAsFixed(0)}MiB ' + '→ $_cachedPolicy', + ); + return; + } + if (bytes != null) { + debugPrint( + 'DeviceMemory: rejected implausible totalMem=$bytes (channel raw=$raw)', + ); + } + } on MissingPluginException catch (e, st) { + debugPrint('DeviceMemory: channel missing (embedder/tests?): $e\n$st'); + } on PlatformException catch (e, st) { + debugPrint( + 'DeviceMemory: platform ${e.code} ${e.message}\n${e.details}\n$st', + ); + } on Object catch (e, st) { + debugPrint('DeviceMemory: channel error: $e\n$st'); + } + + try { + final kb = _tryParseProcMemTotalKb(); + if (kb != null) { + final bytes = kb * 1024; + if (_isPlausibleDeviceRamBytes(bytes)) { + _cachedPolicy = _policyFromTotalBytes(bytes); + debugPrint( + 'DeviceMemory: /proc/meminfo MemTotal=${kb}kB ' + '→ $_cachedPolicy', + ); + return; + } + debugPrint( + 'DeviceMemory: MemTotal kB=$kb implausible as bytes=$bytes', + ); + } + } on Object catch (e, st) { + debugPrint('DeviceMemory: /proc/meminfo fallback error: $e\n$st'); + } + + // 双通道均失败:与「读不到内存」一致,仅静态封面 + debugPrint('DeviceMemory: unreadable → staticOnly (fallback)'); + _cachedPolicy = _GridVideoMemoryPolicy.staticOnly; +} + +_GridVideoMemoryPolicy _policyFromTotalBytes(int bytes) { + if (bytes < kMemoryTierStaticOnlyBytesThreshold) { + return _GridVideoMemoryPolicy.staticOnly; + } + if (bytes < kMemoryTierFullConcurrentBytesThreshold) { + return _GridVideoMemoryPolicy.maxConcurrent2; + } + return _GridVideoMemoryPolicy.maxConcurrent4; +} + +/// 是否在网格中禁用视频预览(仅封面)。 +bool get deviceGridStaticPreviewOnly => + _effectivePolicy == _GridVideoMemoryPolicy.staticOnly; + +/// 网格同时解码上限。 +int get deviceGridMaxConcurrentVideos { + switch (_effectivePolicy) { + case _GridVideoMemoryPolicy.staticOnly: + return 0; + case _GridVideoMemoryPolicy.maxConcurrent2: + return 2; + case _GridVideoMemoryPolicy.maxConcurrent4: + return 4; + } +} + +/// Native 侧现为 **十进制字符串**(避免 Long 跨通道精度问题),仍兼容 int/double。 +int? _coerceToPhysicalRamBytes(Object? raw) { + if (raw == null) return null; + if (raw is int) return raw > 0 ? raw : null; + if (raw is num) { + final v = raw.round(); + if (v <= 0 || !v.isFinite) return null; + return v.toInt(); + } + final s = raw.toString().trim(); + if (s.isEmpty) return null; + return int.tryParse(s); +} + +bool _isPlausibleDeviceRamBytes(int bytes) { + const minBytes = 128 * 1024 * 1024; + const maxBytes = 32 * 1024 * 1024 * 1024; + return bytes >= minBytes && bytes <= maxBytes; +} + +/// `/proc/meminfo` 的 MemTotal(kB);不可读或异常时返回 `null`。 +int? _tryParseProcMemTotalKb() { + if (!Platform.isAndroid) return null; + try { + final file = File('/proc/meminfo'); + // 部分环境下 existsSync 不可靠,直接读并由异常兜底 + final content = file.readAsStringSync(); + if (content.isEmpty) return null; + final match = RegExp( + r'^MemTotal:\s+(\d+)\s+kB', + multiLine: true, + ).firstMatch(content); + if (match == null) return null; + final kb = int.tryParse(match.group(1)!); + if (kb == null || kb <= 0) return null; + if (kb > 1 << 28) return null; + return kb; + } on FileSystemException catch (e, st) { + debugPrint('DeviceMemory: meminfo FileSystemException: $e\n$st'); + return null; + } on IOException catch (e, st) { + debugPrint('DeviceMemory: meminfo IOException: $e\n$st'); + } on FormatException catch (e, st) { + debugPrint('DeviceMemory: meminfo FormatException: $e\n$st'); + } on Object catch (e, st) { + debugPrint('DeviceMemory: meminfo: $e\n$st'); + } + return null; +} diff --git a/lib/features/gallery/gallery_screen.dart b/lib/features/gallery/gallery_screen.dart index 2faa634..bd3ad63 100644 --- a/lib/features/gallery/gallery_screen.dart +++ b/lib/features/gallery/gallery_screen.dart @@ -11,6 +11,7 @@ import '../../core/api/services/image_api.dart'; import '../../core/auth/auth_service.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_spacing.dart'; +import '../../core/util/device_memory_profile.dart'; import '../../shared/widgets/top_nav_bar.dart'; import '../home/widgets/video_card.dart'; @@ -128,8 +129,7 @@ class _GalleryScreenState extends State { /// 略低于首页:网格边缘格子可见但比例偏低时也应计入「在屏」 static const double _videoVisibilityThreshold = 0.08; - /// 可见格子按曝光比例排序,同时最多自动播放 [_maxConcurrentGalleryVideos] 路;滑出后 [VideoCard] 释放解码器 - static const int _maxConcurrentGalleryVideos = 4; + /// 可见格子按曝光比例排序,同时最多自动播放 [deviceGridMaxConcurrentVideos] 路;滑出后 [VideoCard] 释放解码器 Set _visibleVideoIndices = {}; final Set _userPausedVideoIndices = {}; final Map _cardVisibleFraction = {}; @@ -228,7 +228,7 @@ class _GalleryScreenState extends State { } scored.sort((a, b) => b.value.compareTo(a.value)); final next = scored - .take(_maxConcurrentGalleryVideos) + .take(deviceGridMaxConcurrentVideos) .map((e) => e.key) .toSet(); _userPausedVideoIndices.removeWhere((i) => !next.contains(i)); @@ -527,7 +527,8 @@ class _GalleryScreenState extends State { final coverSpecs = _galleryCardCover(media); final videoUrl = media.videoUrl; - final hasVideo = videoUrl != null && + final hasVideo = !deviceGridStaticPreviewOnly && + videoUrl != null && videoUrl.isNotEmpty; final detectorKey = 'gallery_${index}_${videoUrl ?? media.imageUrl ?? ''}'; diff --git a/lib/features/home/home_screen.dart b/lib/features/home/home_screen.dart index 8941161..119015b 100644 --- a/lib/features/home/home_screen.dart +++ b/lib/features/home/home_screen.dart @@ -3,6 +3,7 @@ import 'package:visibility_detector/visibility_detector.dart'; import '../../core/api/services/image_api.dart'; import '../../core/auth/auth_service.dart'; +import '../../core/util/device_memory_profile.dart'; import '../../core/user/account_refresh.dart'; import '../../core/user/user_state.dart'; import '../../core/theme/app_spacing.dart'; @@ -102,7 +103,9 @@ class _HomeScreenState extends State with WidgetsBindingObserver { final tasks = _displayTasks; if (tasks.isEmpty) return; final seed = {}; - for (var i = 0; i < tasks.length && seed.length < _maxConcurrentHomeVideos; i++) { + for (var i = 0; + i < tasks.length && seed.length < deviceGridMaxConcurrentVideos; + i++) { final u = tasks[i].previewVideoUrl; if (u != null && u.isNotEmpty) seed.add(i); } @@ -150,8 +153,7 @@ class _HomeScreenState extends State with WidgetsBindingObserver { /// 与 Gallery 一致:边缘格子可见比例偏低时也应参与自动播 static const double _videoVisibilityThreshold = 0.08; - /// 可见格子按曝光比例排序,同时最多 [_maxConcurrentHomeVideos] 路;滑出后 [VideoCard] 释放解码器 - static const int _maxConcurrentHomeVideos = 4; + /// 可见格子按曝光比例排序,同时最多 [deviceGridMaxConcurrentVideos] 路;滑出后 [VideoCard] 释放解码器 void _onGridCardVisibilityChanged(int index, VisibilityInfo info) { if (!mounted || !widget.isActive) return; @@ -194,7 +196,7 @@ class _HomeScreenState extends State with WidgetsBindingObserver { scored.sort((a, b) => b.value.compareTo(a.value)); final next = scored - .take(_maxConcurrentHomeVideos) + .take(deviceGridMaxConcurrentVideos) .map((e) => e.key) .toSet(); _userPausedVideoIndices.removeWhere((i) => !next.contains(i)); @@ -451,8 +453,11 @@ class _HomeScreenState extends State with WidgetsBindingObserver { final credits = task.credits480p != null ? task.credits480p.toString() : '50'; + final effectiveVideoUrl = deviceGridStaticPreviewOnly + ? null + : task.previewVideoUrl; final detectorKey = - 'home_card_${index}_${task.previewVideoUrl ?? ''}_${task.title}'; + 'home_card_${index}_${effectiveVideoUrl ?? ''}_${task.title}'; return VisibilityDetector( key: ValueKey(detectorKey), onVisibilityChanged: (info) => @@ -461,7 +466,7 @@ class _HomeScreenState extends State with WidgetsBindingObserver { key: ValueKey(detectorKey), imageUrl: task.previewImageUrl ?? _placeholderImage, - videoUrl: task.previewVideoUrl, + videoUrl: effectiveVideoUrl, credits: credits, playbackResumeListenable: homePlaybackResumeNonce, diff --git a/lib/features/home/widgets/video_card.dart b/lib/features/home/widgets/video_card.dart index acf1689..53d93a1 100644 --- a/lib/features/home/widgets/video_card.dart +++ b/lib/features/home/widgets/video_card.dart @@ -188,9 +188,17 @@ class _VideoCardState extends State { _bottomProgressFraction = null; } + /// [onStopRequested] 会触发父级 [setState](如 HomeScreen);不得在 [build] / [didUpdateWidget] 同步栈内调用。 + void _scheduleNotifyStopRequested() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + widget.onStopRequested(); + }); + } + Future _loadAndPlay() async { if (widget.videoUrl == null || widget.videoUrl!.isEmpty) { - widget.onStopRequested(); + _scheduleNotifyStopRequested(); return; } @@ -295,7 +303,7 @@ class _VideoCardState extends State { _clearBottomProgress(); _videoOpacityTarget = false; }); - widget.onStopRequested(); + _scheduleNotifyStopRequested(); } } } diff --git a/lib/main.dart b/lib/main.dart index 61afbc7..3b64848 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,12 +9,14 @@ import 'app.dart'; import 'core/api/api_config.dart'; import 'core/auth/auth_service.dart'; import 'core/log/app_logger.dart'; +import 'core/util/device_memory_profile.dart'; import 'core/referrer/referrer_service.dart'; import 'core/theme/app_colors.dart'; import 'features/recharge/google_play_purchase_service.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); + await ensureDeviceMemoryProfileInitialized(); _initAdjust(); // 勿在此 await ReferrerService.init():会阻塞首帧(Adjust 最长可达十余秒)。[AuthService.init] 内已 await getReferrer() SystemChrome.setSystemUIOverlayStyle(