优化:针对不同内存大小的机子做卡片预览优化
This commit is contained in:
parent
aa54b15406
commit
fd31040792
@ -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] 中初始化)
|
||||
|
||||
166
lib/core/util/device_memory_profile.dart
Normal file
166
lib/core/util/device_memory_profile.dart
Normal 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.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<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` 的 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;
|
||||
}
|
||||
@ -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<GalleryScreen> {
|
||||
|
||||
/// 略低于首页:网格边缘格子可见但比例偏低时也应计入「在屏」
|
||||
static const double _videoVisibilityThreshold = 0.08;
|
||||
/// 可见格子按曝光比例排序,同时最多自动播放 [_maxConcurrentGalleryVideos] 路;滑出后 [VideoCard] 释放解码器
|
||||
static const int _maxConcurrentGalleryVideos = 4;
|
||||
/// 可见格子按曝光比例排序,同时最多自动播放 [deviceGridMaxConcurrentVideos] 路;滑出后 [VideoCard] 释放解码器
|
||||
Set<int> _visibleVideoIndices = {};
|
||||
final Set<int> _userPausedVideoIndices = {};
|
||||
final Map<int, double> _cardVisibleFraction = {};
|
||||
@ -228,7 +228,7 @@ class _GalleryScreenState extends State<GalleryScreen> {
|
||||
}
|
||||
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<GalleryScreen> {
|
||||
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 ?? ''}';
|
||||
|
||||
@ -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<HomeScreen> with WidgetsBindingObserver {
|
||||
final tasks = _displayTasks;
|
||||
if (tasks.isEmpty) return;
|
||||
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;
|
||||
if (u != null && u.isNotEmpty) seed.add(i);
|
||||
}
|
||||
@ -150,8 +153,7 @@ class _HomeScreenState extends State<HomeScreen> 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<HomeScreen> 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<HomeScreen> 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<HomeScreen> with WidgetsBindingObserver {
|
||||
key: ValueKey(detectorKey),
|
||||
imageUrl: task.previewImageUrl ??
|
||||
_placeholderImage,
|
||||
videoUrl: task.previewVideoUrl,
|
||||
videoUrl: effectiveVideoUrl,
|
||||
credits: credits,
|
||||
playbackResumeListenable:
|
||||
homePlaybackResumeNonce,
|
||||
|
||||
@ -188,9 +188,17 @@ class _VideoCardState extends State<VideoCard> {
|
||||
_bottomProgressFraction = null;
|
||||
}
|
||||
|
||||
/// [onStopRequested] 会触发父级 [setState](如 HomeScreen);不得在 [build] / [didUpdateWidget] 同步栈内调用。
|
||||
void _scheduleNotifyStopRequested() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
widget.onStopRequested();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadAndPlay() async {
|
||||
if (widget.videoUrl == null || widget.videoUrl!.isEmpty) {
|
||||
widget.onStopRequested();
|
||||
_scheduleNotifyStopRequested();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -295,7 +303,7 @@ class _VideoCardState extends State<VideoCard> {
|
||||
_clearBottomProgress();
|
||||
_videoOpacityTarget = false;
|
||||
});
|
||||
widget.onStopRequested();
|
||||
_scheduleNotifyStopRequested();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user