优化:针对不同内存大小的机子做卡片预览优化

This commit is contained in:
ivan 2026-04-02 18:22:06 +08:00
parent aa54b15406
commit fd31040792
6 changed files with 225 additions and 12 deletions

View File

@ -1,12 +1,43 @@
package com.petsheroai.app package com.petsheroai.app
import android.app.ActivityManager
import android.content.Context
import android.os.Bundle import android.os.Bundle
import com.facebook.FacebookSdk import com.facebook.FacebookSdk
import com.facebook.LoggingBehavior import com.facebook.LoggingBehavior
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() { 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// Facebook SDK 调试日志SDK 已在 [PetsHeroApplication] 中初始化) // Facebook SDK 调试日志SDK 已在 [PetsHeroApplication] 中初始化)

View File

@ -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.23.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<void> ensureDeviceMemoryProfileInitialized() async {
if (_cachedPolicy != null) return;
if (!Platform.isAndroid) {
_cachedPolicy = _GridVideoMemoryPolicy.maxConcurrent4;
return;
}
try {
final raw =
await _deviceMemoryChannel.invokeMethod<dynamic>(
'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` MemTotalkB `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;
}

View File

@ -11,6 +11,7 @@ import '../../core/api/services/image_api.dart';
import '../../core/auth/auth_service.dart'; import '../../core/auth/auth_service.dart';
import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors.dart';
import '../../core/theme/app_spacing.dart'; import '../../core/theme/app_spacing.dart';
import '../../core/util/device_memory_profile.dart';
import '../../shared/widgets/top_nav_bar.dart'; import '../../shared/widgets/top_nav_bar.dart';
import '../home/widgets/video_card.dart'; import '../home/widgets/video_card.dart';
@ -128,8 +129,7 @@ class _GalleryScreenState extends State<GalleryScreen> {
/// ///
static const double _videoVisibilityThreshold = 0.08; static const double _videoVisibilityThreshold = 0.08;
/// [_maxConcurrentGalleryVideos] [VideoCard] /// [deviceGridMaxConcurrentVideos] [VideoCard]
static const int _maxConcurrentGalleryVideos = 4;
Set<int> _visibleVideoIndices = {}; Set<int> _visibleVideoIndices = {};
final Set<int> _userPausedVideoIndices = {}; final Set<int> _userPausedVideoIndices = {};
final Map<int, double> _cardVisibleFraction = {}; final Map<int, double> _cardVisibleFraction = {};
@ -228,7 +228,7 @@ class _GalleryScreenState extends State<GalleryScreen> {
} }
scored.sort((a, b) => b.value.compareTo(a.value)); scored.sort((a, b) => b.value.compareTo(a.value));
final next = scored final next = scored
.take(_maxConcurrentGalleryVideos) .take(deviceGridMaxConcurrentVideos)
.map((e) => e.key) .map((e) => e.key)
.toSet(); .toSet();
_userPausedVideoIndices.removeWhere((i) => !next.contains(i)); _userPausedVideoIndices.removeWhere((i) => !next.contains(i));
@ -527,7 +527,8 @@ class _GalleryScreenState extends State<GalleryScreen> {
final coverSpecs = final coverSpecs =
_galleryCardCover(media); _galleryCardCover(media);
final videoUrl = media.videoUrl; final videoUrl = media.videoUrl;
final hasVideo = videoUrl != null && final hasVideo = !deviceGridStaticPreviewOnly &&
videoUrl != null &&
videoUrl.isNotEmpty; videoUrl.isNotEmpty;
final detectorKey = final detectorKey =
'gallery_${index}_${videoUrl ?? media.imageUrl ?? ''}'; 'gallery_${index}_${videoUrl ?? media.imageUrl ?? ''}';

View File

@ -3,6 +3,7 @@ import 'package:visibility_detector/visibility_detector.dart';
import '../../core/api/services/image_api.dart'; import '../../core/api/services/image_api.dart';
import '../../core/auth/auth_service.dart'; import '../../core/auth/auth_service.dart';
import '../../core/util/device_memory_profile.dart';
import '../../core/user/account_refresh.dart'; import '../../core/user/account_refresh.dart';
import '../../core/user/user_state.dart'; import '../../core/user/user_state.dart';
import '../../core/theme/app_spacing.dart'; import '../../core/theme/app_spacing.dart';
@ -102,7 +103,9 @@ class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
final tasks = _displayTasks; final tasks = _displayTasks;
if (tasks.isEmpty) return; if (tasks.isEmpty) return;
final seed = <int>{}; final seed = <int>{};
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; final u = tasks[i].previewVideoUrl;
if (u != null && u.isNotEmpty) seed.add(i); if (u != null && u.isNotEmpty) seed.add(i);
} }
@ -150,8 +153,7 @@ class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
/// Gallery /// Gallery
static const double _videoVisibilityThreshold = 0.08; static const double _videoVisibilityThreshold = 0.08;
/// [_maxConcurrentHomeVideos] [VideoCard] /// [deviceGridMaxConcurrentVideos] [VideoCard]
static const int _maxConcurrentHomeVideos = 4;
void _onGridCardVisibilityChanged(int index, VisibilityInfo info) { void _onGridCardVisibilityChanged(int index, VisibilityInfo info) {
if (!mounted || !widget.isActive) return; if (!mounted || !widget.isActive) return;
@ -194,7 +196,7 @@ class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
scored.sort((a, b) => b.value.compareTo(a.value)); scored.sort((a, b) => b.value.compareTo(a.value));
final next = scored final next = scored
.take(_maxConcurrentHomeVideos) .take(deviceGridMaxConcurrentVideos)
.map((e) => e.key) .map((e) => e.key)
.toSet(); .toSet();
_userPausedVideoIndices.removeWhere((i) => !next.contains(i)); _userPausedVideoIndices.removeWhere((i) => !next.contains(i));
@ -451,8 +453,11 @@ class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
final credits = task.credits480p != null final credits = task.credits480p != null
? task.credits480p.toString() ? task.credits480p.toString()
: '50'; : '50';
final effectiveVideoUrl = deviceGridStaticPreviewOnly
? null
: task.previewVideoUrl;
final detectorKey = final detectorKey =
'home_card_${index}_${task.previewVideoUrl ?? ''}_${task.title}'; 'home_card_${index}_${effectiveVideoUrl ?? ''}_${task.title}';
return VisibilityDetector( return VisibilityDetector(
key: ValueKey(detectorKey), key: ValueKey(detectorKey),
onVisibilityChanged: (info) => onVisibilityChanged: (info) =>
@ -461,7 +466,7 @@ class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
key: ValueKey(detectorKey), key: ValueKey(detectorKey),
imageUrl: task.previewImageUrl ?? imageUrl: task.previewImageUrl ??
_placeholderImage, _placeholderImage,
videoUrl: task.previewVideoUrl, videoUrl: effectiveVideoUrl,
credits: credits, credits: credits,
playbackResumeListenable: playbackResumeListenable:
homePlaybackResumeNonce, homePlaybackResumeNonce,

View File

@ -188,9 +188,17 @@ class _VideoCardState extends State<VideoCard> {
_bottomProgressFraction = null; _bottomProgressFraction = null;
} }
/// [onStopRequested] [setState] HomeScreen [build] / [didUpdateWidget]
void _scheduleNotifyStopRequested() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
widget.onStopRequested();
});
}
Future<void> _loadAndPlay() async { Future<void> _loadAndPlay() async {
if (widget.videoUrl == null || widget.videoUrl!.isEmpty) { if (widget.videoUrl == null || widget.videoUrl!.isEmpty) {
widget.onStopRequested(); _scheduleNotifyStopRequested();
return; return;
} }
@ -295,7 +303,7 @@ class _VideoCardState extends State<VideoCard> {
_clearBottomProgress(); _clearBottomProgress();
_videoOpacityTarget = false; _videoOpacityTarget = false;
}); });
widget.onStopRequested(); _scheduleNotifyStopRequested();
} }
} }
} }

View File

@ -9,12 +9,14 @@ import 'app.dart';
import 'core/api/api_config.dart'; import 'core/api/api_config.dart';
import 'core/auth/auth_service.dart'; import 'core/auth/auth_service.dart';
import 'core/log/app_logger.dart'; import 'core/log/app_logger.dart';
import 'core/util/device_memory_profile.dart';
import 'core/referrer/referrer_service.dart'; import 'core/referrer/referrer_service.dart';
import 'core/theme/app_colors.dart'; import 'core/theme/app_colors.dart';
import 'features/recharge/google_play_purchase_service.dart'; import 'features/recharge/google_play_purchase_service.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await ensureDeviceMemoryProfileInitialized();
_initAdjust(); _initAdjust();
// await ReferrerService.init()Adjust [AuthService.init] await getReferrer() // await ReferrerService.init()Adjust [AuthService.init] await getReferrer()
SystemChrome.setSystemUIOverlayStyle( SystemChrome.setSystemUIOverlayStyle(