优化:针对不同内存大小的机子做卡片预览优化
This commit is contained in:
parent
aa54b15406
commit
fd31040792
@ -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] 中初始化)
|
||||||
|
|||||||
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/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 ?? ''}';
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user