5 Commits
Author SHA1 Message Date
Vicky 4aaa5bbd60 build(deps): 显式声明 intl 依赖,修掉 CI 零告警门禁的存量告警
flutter-ci / test (push) Failing after 19m29s
播放量格式化直接用了 NumberFormat,但 intl 只是 flutter_localizations 的传递依赖;
新增的 dart analyze --fatal-infos 流水线会因此失败,这里按 flutter_localizations 钉住的版本显式声明。
2026-10-29 11:27:53 +08:00
Vicky 5649e4c3d4 perf(home): 四个根 tab 改为首次可见才构建
原先 IndexedStack 冷启动就把推荐/发现/追剧/我的四棵子树全建出来,各自还会发请求;
现在只建当前 tab,切过去再建并常驻,冷启动少三页构建和一次观看历史请求。
2026-10-29 11:25:47 +08:00
Vicky 9f7301305e perf(following): 追剧页不可见时不再提前拉观看历史
首页用 IndexedStack 常驻四个 tab,追剧页一启动就会发一次观看历史请求,和首页 feed 抢网络;
改为切到该 tab 时再加载,收藏列表也只在首次可见且为空时补拉。
2026-10-29 11:25:47 +08:00
Vicky 63191b4692 perf(player): 续看点加载与进度写盘改为并行且复用同一请求
进播放页与首次准备播放会各要一次续看点,同一次请求跑两遍还串在首帧前;
现在并发请求复用同一 Future,并与上一条进度写盘并行等待,少一个网络往返的起播延迟。
剧场网格海报按布局宽度限制解码尺寸,半屏卡片不再按原图解码。
2026-10-29 11:25:47 +08:00
Vicky 0f777b19f2 perf(player): 限制雪碧图与海报预缓存内存并给播放位置缓存加容量上限
雪碧图解码从 2048 降到 1024、缓存从 8 张降到 3 张,避免擦洗预览驻留十几 MB 位图;
相邻集海报预缓存改用 ResizeImage 按 360/720 宽解码,不再缓存整张原图;
播放位置缓存按写入时间淘汰,最多保留 100 条,长会话不再只增不减;
观看记录详情失败日志加 kDebugMode 保护,release 下不再逐条打堆栈。
2026-10-29 11:25:47 +08:00
13 changed files with 218 additions and 43 deletions

No files matched your search

@@ -33,10 +33,15 @@ class PlaybackPositionCache {
PlaybackPositionCache({
DateTime Function()? now,
this.maxAge = _defaultMaxAge,
this.maxEntries = defaultMaxEntries,
}) : _now = now ?? DateTime.now;
/// 最多保留的集数:长会话连看几十部剧也不至于让 Map 无限增长。
static const int defaultMaxEntries = 100;
final DateTime Function() _now;
final Duration maxAge;
final int maxEntries;
final Map<String, CachedPlaybackPosition> _positions = {};
/// 保存最近有效播放位置;零位置不会覆盖已有记录。
@@ -57,6 +62,24 @@ class PlaybackPositionCache {
duration: duration,
updatedAt: _now(),
);
_evictOverflow();
}
/// 超出上限时按写入时间淘汰最旧的一条,避免长会话内存只增不减。
void _evictOverflow() {
while (maxEntries > 0 && _positions.length > maxEntries) {
String? oldestKey;
DateTime? oldestAt;
for (final entry in _positions.entries) {
final at = entry.value.updatedAt;
if (oldestAt == null || at.isBefore(oldestAt)) {
oldestAt = at;
oldestKey = entry.key;
}
}
if (oldestKey == null) return;
_positions.remove(oldestKey);
}
}
/// 读取同剧同集且未过期的最近位置。
@@ -8,7 +8,8 @@ import 'package:dio/dio.dart';
import '../../config/app_config.dart';
const int spriteThumbnailDecodeMaxDimension = 2048;
/// 雪碧图解码上限:擦洗预览只占很小一块区域,2048 会让单张位图膨胀到 16MB。
const int spriteThumbnailDecodeMaxDimension = 1024;
/// VTT 资源拒绝访问,服务层据此清缓存并重试一次。
class SpriteThumbnailForbiddenException implements Exception {
@@ -227,14 +227,17 @@ class WatchRecordsController extends PagedListController<WatchRecord> {
);
return detail;
} catch (failure, stackTrace) {
debugPrint(
'[WatchRecordsController] detail failed dramaId=$dramaId '
'${failure.runtimeType}: $failure',
);
debugPrintStack(
stackTrace: stackTrace,
label: '[WatchRecordsController] detail failed dramaId=$dramaId',
);
// 弱网时详情会成批失败,release 下不需要逐条打堆栈。
if (kDebugMode) {
debugPrint(
'[WatchRecordsController] detail failed dramaId=$dramaId '
'${failure.runtimeType}: $failure',
);
debugPrintStack(
stackTrace: stackTrace,
label: '[WatchRecordsController] detail failed dramaId=$dramaId',
);
}
if (isDisposed) return null;
final current = _recordIndex(dramaId);
if (current < 0) return null;
+22 -9
View File
@@ -7,6 +7,7 @@ import '../../../core/data/models/drama.dart';
import '../../../core/formatting/count_format.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/remote_image.dart';
import '../../../l10n/app_localizations.dart';
const int _columns = 2;
@@ -218,16 +219,28 @@ class _DiscoverCoverImage extends StatelessWidget {
Widget build(BuildContext context) {
final url = AppConfig.mediaUrl(path);
if (url.isEmpty) return _placeholder();
return Image.network(
url,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
loadingBuilder: (context, child, progress) {
if (progress == null) return child;
return _placeholder();
return LayoutBuilder(
builder: (context, constraints) {
// 卡片只有半屏宽,按原图解码一张竖版海报就是数 MB,按布局宽度限一下。
final decodeWidth = constraints.hasBoundedWidth
? resolveImageDecodePixels(
constraints.maxWidth,
MediaQuery.devicePixelRatioOf(context),
)
: null;
return Image.network(
url,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
cacheWidth: decodeWidth,
loadingBuilder: (context, child, progress) {
if (progress == null) return child;
return _placeholder();
},
errorBuilder: (_, _, _) => _placeholder(),
);
},
errorBuilder: (_, _, _) => _placeholder(),
);
}
+15 -4
View File
@@ -69,10 +69,14 @@ class _FollowingPageState extends State<FollowingPage> {
history: context.read<WatchHistoryRepository>(),
catalog: context.read<CatalogRepository>(),
);
unawaited(_history.load());
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) unawaited(widget.favoriteController.load());
});
// IndexedStack 启动时就会建好本页:不可见时先不拉数据,等切到本 tab 再加载,
// 免得冷启动和首页 feed 抢网络。
if (widget.visible) {
unawaited(_history.load());
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) unawaited(widget.favoriteController.load());
});
}
}
void _onAuthChanged() {
@@ -107,6 +111,13 @@ class _FollowingPageState extends State<FollowingPage> {
super.didUpdateWidget(oldWidget);
if (!oldWidget.visible && widget.visible) {
unawaited(_history.load());
// 启动时本页不可见,收藏没在这里拉过;首次可见时补齐(已有数据就不重复请求)。
// 放在帧后,避免构建期间触发 controller 通知。
if (widget.favoriteController.cards.isEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) unawaited(widget.favoriteController.load());
});
}
}
if (oldWidget.historyRequest == widget.historyRequest) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
+45 -12
View File
@@ -161,24 +161,18 @@ class _HomeTabsState extends State<_HomeTabs> {
}
Widget _body(BuildContext context) {
final pages = <Widget>[
_recommend(context),
_recommendDiscover(context),
FollowingPage(
final builders = <WidgetBuilder>[
_recommend,
_recommendDiscover,
(context) => FollowingPage(
onGoHome: () => logic.select(0),
favoriteController: context.read<FavoriteController>(),
historyRequest: logic.followingHistoryRequest,
visible: logic.tab == 2,
),
_my(context),
_my,
];
return IndexedStack(
index: logic.tab,
children: [
for (var index = 0; index < pages.length; index++)
TickerMode(enabled: logic.tab == index, child: pages[index]),
],
);
return _LazyTabStack(index: logic.tab, builders: builders);
}
Widget _recommend(BuildContext context) {
@@ -248,3 +242,42 @@ class _HomeTabsState extends State<_HomeTabs> {
logic.select(tab);
}
}
/// 四个根 tab 的容器:只有被访问过的 tab 才真正构建并常驻。
///
/// 冷启动时只建推荐页,其余 tab 等第一次切过去再建,省掉三棵子树和它们各自的请求。
class _LazyTabStack extends StatefulWidget {
const _LazyTabStack({required this.index, required this.builders});
final int index;
final List<WidgetBuilder> builders;
@override
State<_LazyTabStack> createState() => _LazyTabStackState();
}
class _LazyTabStackState extends State<_LazyTabStack> {
late final Set<int> _visited = {widget.index};
@override
void didUpdateWidget(covariant _LazyTabStack oldWidget) {
super.didUpdateWidget(oldWidget);
_visited.add(widget.index);
}
@override
Widget build(BuildContext context) {
return IndexedStack(
index: widget.index,
children: [
for (var index = 0; index < widget.builders.length; index++)
TickerMode(
enabled: widget.index == index,
child: _visited.contains(index)
? widget.builders[index](context)
: const SizedBox.shrink(),
),
],
);
}
}
+17 -3
View File
@@ -110,6 +110,9 @@ class PlayerLogic extends ChangeNotifier {
ResumePoint? resumePoint;
bool resumeLoaded = false;
/// 续看点加载中的请求:进页与首次准备播放会同时触发,复用同一次请求。
Future<void>? _resumePointLoad;
/// 票据三态:加载中 / 失败(可重试)/ 被后端判定为不可播放
bool ticketLoading = false;
String? ticketError;
@@ -512,7 +515,15 @@ class PlayerLogic extends ChangeNotifier {
}
/// 服务端续看位置:进播放页时取一次,用于"定位到上次那一集的那一秒"。
Future<void> loadResumePoint() async {
Future<void> loadResumePoint() {
final inFlight = _resumePointLoad;
if (inFlight != null) return inFlight;
return _resumePointLoad = _loadResumePoint().whenComplete(() {
_resumePointLoad = null;
});
}
Future<void> _loadResumePoint() async {
final entry = queue.entryAt(initialIndex);
final cached = positionCache.read(
entry.episode.id,
@@ -1281,9 +1292,12 @@ class PlayerLogic extends ChangeNotifier {
await _waitUntilFrameReady(player.controller);
return;
}
await pendingWrite;
// 上一条进度写盘与续看点拉取互不依赖,并行等,少一个网络往返的首帧延迟。
// 服务端每剧只记一条:只有"记录里的那一集"才定位,其它集从头播
if (resumePoint == null) await loadResumePoint();
await Future.wait([
pendingWrite,
if (!resumeLoaded) loadResumePoint(),
]);
if (resumePositionFor(episode.id) == null) {
final dramaId = _dramaIdForEpisode(episode.id);
final cached = dramaId == null
+27 -5
View File
@@ -61,6 +61,17 @@ const double _playerLoadingSize = 24;
const double _playerLoadingStrokeWidth = 2;
const double _feedRefreshTopGap = 56;
/// 相邻集海报预缓存宽度:列表卡片只有百来 dp,按原图解码会占数 MB。
const int _posterPreviewWidth = 360;
/// 画中画还原封面接近全屏,保留更高分辨率但不必按原图。
const int _posterPipWidth = 720;
/// 预缓存用低分辨率 provider;与 RemoteImage 一样限制解码尺寸,避免整张原图进缓存。
ImageProvider _posterProvider(String url, {required int width}) {
return ResizeImage(NetworkImage(url), width: width, allowUpscaling: false);
}
class PlayerPageHandoff {
PlayerPageHandoff({required this.sourceUserWantsPlay});
@@ -178,7 +189,9 @@ class _PlayerPageState extends State<PlayerPage>
_thumbnailService = PlaybackThumbnailService(
sourceResolver: _resolveThumbnailSource,
);
_spriteAxisService = SpriteThumbnailAxisService(spriteCacheCapacity: 8)
// 雪碧图每张解码后是整块位图,保留 3 部剧足够擦洗回退,8 部在低端机
// 容易把图片缓存顶满甚至触发 OOM。
_spriteAxisService = SpriteThumbnailAxisService(spriteCacheCapacity: 3)
..addListener(_handleSpriteFrameChanged);
WidgetsBinding.instance.addObserver(this);
logic.start(
@@ -507,9 +520,10 @@ class _PlayerPageState extends State<PlayerPage>
final imageUrl = AppConfig.mediaUrl(posterUrl);
if (_precachedPosterUrls.add(imageUrl)) {
unawaited(
precacheImage(NetworkImage(imageUrl), context).catchError((
Object _,
) {
precacheImage(
_posterProvider(imageUrl, width: _posterPreviewWidth),
context,
).catchError((Object _) {
_precachedPosterUrls.remove(imageUrl);
}),
);
@@ -728,7 +742,15 @@ class _PlayerPageState extends State<PlayerPage>
if (logic.pip) {
final poster = logic.trackedEpisode?.posterUrl?.trim();
if (poster != null && poster.isNotEmpty) {
unawaited(precacheImage(NetworkImage(poster), context));
unawaited(
precacheImage(
_posterProvider(
AppConfig.mediaUrl(poster),
width: _posterPipWidth,
),
context,
),
);
}
}
return Stack(
+1 -1
View File
@@ -245,7 +245,7 @@ packages:
source: sdk
version: "0.0.0"
intl:
dependency: transitive
dependency: "direct main"
description:
name: intl
sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867"
+2
View File
@@ -45,6 +45,8 @@ dependencies:
sdk: flutter
path_provider: 2.1.5
html: ^0.15.6
# 播放量等数字的本地化格式(NumberFormat);版本由 flutter_localizations 钉住
intl: any
# 画中画:Android 原生 PiP,OnLeavePiP 支持退后台自动进小窗
floating: ^6.0.0
# 运行时权限:systemAlertWindow(「显示在其他应用上层」)
+22
View File
@@ -23,6 +23,28 @@ import '../support/fake_interaction_repositories.dart';
import '../support/widget_player.dart';
void main() {
testWidgets('should_defer_watch_history_until_following_tab_is_opened', (
tester,
) async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
final catalog = FakeCatalogRepository();
final history = _IdentityWatchHistoryRepository(
device: [_historyProgress('the-signal-demo', 'Device history')],
account: const [],
);
await tester.pumpWidget(_app(catalog: catalog, history: history));
await tester.pumpAndSettle();
// 首页默认停在推荐 tab:追剧页在启动时还没被构建,不该提前拉观看历史。
expect(history.recentCalls, 0);
await tester.tap(find.byKey(const Key('tab-follow')));
await tester.pumpAndSettle();
expect(history.recentCalls, greaterThan(0));
});
testWidgets('should_render_following_tabs_grid_and_edit_actions', (
tester,
) async {
@@ -89,6 +89,25 @@ void main() {
expect(cache.latestForDrama('drama-a')?.episodeId, 'episode-2');
});
test('should_evict_oldest_entry_when_capacity_exceeded', () {
var now = DateTime(2026, 10, 22, 12);
final cache = PlaybackPositionCache(now: () => now, maxEntries: 2);
for (final episodeId in ['episode-1', 'episode-2', 'episode-3']) {
cache.save(
episodeId: episodeId,
dramaId: 'drama-a',
position: const Duration(seconds: 30),
duration: const Duration(minutes: 3),
);
now = now.add(const Duration(minutes: 1));
}
expect(cache.read('episode-1', dramaId: 'drama-a'), isNull);
expect(cache.read('episode-2', dramaId: 'drama-a'), isNotNull);
expect(cache.read('episode-3', dramaId: 'drama-a'), isNotNull);
});
test('should_reject_expired_cached_position', () {
var now = DateTime(2026, 10, 22, 12);
final cache = PlaybackPositionCache(now: () => now);
+12
View File
@@ -158,6 +158,18 @@ void main() {
logic.dispose();
});
test('should_share_one_request_when_resume_point_loads_concurrently', () async {
final history = FakeWatchHistory()..saved = const Duration(seconds: 20);
final logic = _logic(history, drama: _drama('signal'));
// 进页与首次准备播放会同时要续看点,只应发一次请求。
await Future.wait([logic.loadResumePoint(), logic.loadResumePoint()]);
expect(logic.resumeLoaded, isTrue);
expect(history.reads, ['signal']);
logic.dispose();
});
test('should_not_seek_when_position_is_zero', () async {
final history = FakeWatchHistory()
..records['signal'] = const WatchProgress(