Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions lib/features/book/book_detail_loading_policy.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import 'package:flutter/material.dart';

const bookDetailRouteSettleFallbackDelay = Duration(milliseconds: 360);

bool shouldDeferBookDetailContentApply({
required bool forceRefresh,
required bool usedCache,
required bool routeTransitionActive,
}) {
return routeTransitionActive && !usedCache && !forceRefresh;
}

bool isRouteTransitionActive(Animation<double>? animation) {
return animation?.status == AnimationStatus.forward ||
animation?.status == AnimationStatus.reverse;
}
56 changes: 56 additions & 0 deletions lib/features/book/book_detail_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart';
import 'package:novella/core/navigation/app_route_launcher.dart';
import 'package:novella/core/telemetry/telemetry_events.dart';
import 'package:novella/core/telemetry/telemetry_service.dart';
Expand All @@ -20,6 +21,7 @@ import 'package:novella/data/services/book_service.dart';
import 'package:novella/data/services/reading_progress_service.dart';
import 'package:novella/data/services/user_service.dart';
import 'package:novella/data/services/local_cover_service.dart';
import 'package:novella/features/book/book_detail_loading_policy.dart';
import 'package:novella/features/reader/reader_page.dart';
import 'package:novella/features/reader/shared/reader_title_utils.dart';
import 'package:novella/features/reader/shared/reader_text_sanitizer.dart';
Expand Down Expand Up @@ -860,6 +862,11 @@ class BookDetailPageState extends ConsumerState<BookDetailPage> {
if (mounted) {
// 加载本地标记
final mark = await _bookMarkService.getBookMark(widget.bookId);
await _waitForContentApplyWindow(
forceRefresh: forceRefresh,
usedCache: false,
);
if (!mounted) return;
setState(() {
_bookInfo = info;
_readPosition = position;
Expand Down Expand Up @@ -892,6 +899,55 @@ class BookDetailPageState extends ConsumerState<BookDetailPage> {
}
}

Future<void> _waitForContentApplyWindow({
required bool forceRefresh,
required bool usedCache,
}) async {
final route = ModalRoute.of(context);
final animation = route?.animation;
if (!shouldDeferBookDetailContentApply(
forceRefresh: forceRefresh,
usedCache: usedCache,
routeTransitionActive: isRouteTransitionActive(animation),
)) {
return;
}

final completer = Completer<void>();
late final AnimationStatusListener listener;
listener = (status) {
if (status == AnimationStatus.completed ||
status == AnimationStatus.dismissed) {
if (!completer.isCompleted) {
completer.complete();
}
}
};

animation!.addStatusListener(listener);
try {
if (!isRouteTransitionActive(animation)) {
return;
}
final currentRoute = route;
final transitionDuration =
(currentRoute is TransitionRoute ? currentRoute : null)
?.transitionDuration ??
bookDetailRouteSettleFallbackDelay;
await Future.any([
completer.future,
Future<void>.delayed(
transitionDuration + const Duration(milliseconds: 80),
),
]);
if (mounted) {
await SchedulerBinding.instance.endOfFrame;
}
} finally {
animation.removeStatusListener(listener);
}
}

/// 仅刷新阅读进度(不请求书籍详情)
Future<void> _refreshReadingProgress() async {
try {
Expand Down
60 changes: 33 additions & 27 deletions lib/features/reader/reader_paged_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import 'package:novella/features/reader/shared/reader_battery_indicator.dart';
import 'package:novella/features/reader/shared/reader_chapter_sheet.dart';
import 'package:novella/features/reader/shared/reader_footnote_processor.dart';
import 'package:novella/features/reader/shared/reader_image_view.dart';
import 'package:novella/features/reader/shared/reader_preload_policy.dart';
import 'package:novella/features/reader/shared/reader_text_sanitizer.dart';
import 'package:novella/features/reader/shared/reader_title_sheet.dart';
import 'package:novella/features/reader/shared/reader_title_utils.dart';
Expand Down Expand Up @@ -620,16 +621,37 @@ class _ReaderPagedPageState extends ConsumerState<ReaderPagedPage>
}

Future<void> _preloadAdjacentChapters(int sortNum, int version) async {
final settings = ref.read(settingsProvider);
var settings = ref.read(settingsProvider);
_ensureChapterCacheForSettings(settings);
final targets =
readerAdjacentPreloadTargets(
sortNum: sortNum,
totalChapters: widget.totalChapters,
)
.where(
(target) =>
!_chapterDataCache.containsKey(target) &&
_preloadingSortNums.add(target),
)
.toList(growable: false);

if (targets.isEmpty) {
return;
}

await SchedulerBinding.instance.endOfFrame;
await Future<void>.delayed(readerAdjacentPreloadDelay);
if (!mounted || version != _loadVersion) {
_preloadingSortNums.removeAll(targets);
return;
}

settings = ref.read(settingsProvider);
_ensureChapterCacheForSettings(settings);
final targets = <int>[
if (sortNum > 1) sortNum - 1,
if (sortNum < widget.totalChapters) sortNum + 1,
];

for (final target in targets) {
if (_chapterDataCache.containsKey(target) ||
!_preloadingSortNums.add(target)) {
if (_chapterDataCache.containsKey(target)) {
_preloadingSortNums.remove(target);
continue;
}

Expand Down Expand Up @@ -710,21 +732,10 @@ class _ReaderPagedPageState extends ConsumerState<ReaderPagedPage>
await SchedulerBinding.instance.endOfFrame;
if (!mounted || version != _loadVersion) return;
try {
final currentData = await _fetchAndPrepareChapterData(sortNum, settings);
if (version != _loadVersion) return;

final adjacentSortNums = <int>[
if (sortNum > 1) sortNum - 1,
if (sortNum < widget.totalChapters) sortNum + 1,
];
final adjacentResults = await Future.wait(
adjacentSortNums.map((target) async {
try {
return await _fetchAndPrepareChapterData(target, settings);
} catch (_) {
return null;
}
}),
final initialSortNums = readerInitialDisplaySortNums(sortNum: sortNum);
final currentData = await _fetchAndPrepareChapterData(
initialSortNums.single,
settings,
);

if (version != _loadVersion) return;
Expand All @@ -741,11 +752,6 @@ class _ReaderPagedPageState extends ConsumerState<ReaderPagedPage>
);
setState(() {
_chapterDataCache[sortNum] = currentData;
for (final data in adjacentResults) {
if (data != null) {
_chapterDataCache[data.chapter.sortNum] = data;
}
}
_activateChapterData(currentData);
_indentedBlockHtmlCache.clear();
_loading = false;
Expand Down
7 changes: 7 additions & 0 deletions lib/features/reader/reader_scroll_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
import 'package:logging/logging.dart';
Expand Down Expand Up @@ -1897,6 +1898,9 @@ class _ReaderScrollPageState extends ConsumerState<ReaderScrollPage>
_readProgressNotifier.value = 0.0;
});

await SchedulerBinding.instance.endOfFrame;
if (!mounted || currentVersion != _loadVersion) return;

try {
final settings = ref.read(settingsProvider);

Expand Down Expand Up @@ -2016,6 +2020,9 @@ class _ReaderScrollPageState extends ConsumerState<ReaderScrollPage>
}

if (mounted && currentVersion == _loadVersion) {
await SchedulerBinding.instance.endOfFrame;
if (!mounted || currentVersion != _loadVersion) return;

// 3. 预处理脚注/注释(对标 Web:隐藏原注释节点 + 记录内容 + 禁用默认跳转)
final invisibleCodepoints = _fontManager.getInvisibleCodepoints(family);
final sanitizedContent = _stripInvisiblePlaceholderCodepoints(
Expand Down
23 changes: 23 additions & 0 deletions lib/features/reader/shared/reader_preload_policy.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
const readerAdjacentPreloadDelay = Duration(milliseconds: 180);

List<int> readerInitialDisplaySortNums({required int sortNum}) {
return [sortNum];
}

bool shouldAwaitAdjacentChaptersForInitialDisplay() {
return false;
}

List<int> readerAdjacentPreloadTargets({
required int sortNum,
required int totalChapters,
}) {
if (totalChapters <= 1) {
return const [];
}

return [
if (sortNum > 1) sortNum - 1,
if (sortNum < totalChapters) sortNum + 1,
];
}
45 changes: 45 additions & 0 deletions test/features/book/book_detail_loading_policy_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:novella/features/book/book_detail_loading_policy.dart';

void main() {
test(
'defers uncached initial content while the detail route is animating',
() {
expect(
shouldDeferBookDetailContentApply(
forceRefresh: false,
usedCache: false,
routeTransitionActive: true,
),
isTrue,
);
},
);

test('does not defer cached content or visible refreshes', () {
expect(
shouldDeferBookDetailContentApply(
forceRefresh: false,
usedCache: true,
routeTransitionActive: true,
),
isFalse,
);
expect(
shouldDeferBookDetailContentApply(
forceRefresh: true,
usedCache: false,
routeTransitionActive: true,
),
isFalse,
);
expect(
shouldDeferBookDetailContentApply(
forceRefresh: false,
usedCache: false,
routeTransitionActive: false,
),
isFalse,
);
});
}
20 changes: 20 additions & 0 deletions test/features/reader/reader_preload_policy_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:novella/features/reader/shared/reader_preload_policy.dart';

void main() {
test('initial paged reader display only waits for the requested chapter', () {
expect(readerInitialDisplaySortNums(sortNum: 12), [12]);
expect(shouldAwaitAdjacentChaptersForInitialDisplay(), isFalse);
});

test('adjacent preload targets stay bounded by available chapters', () {
expect(readerAdjacentPreloadTargets(sortNum: 1, totalChapters: 3), [2]);
expect(readerAdjacentPreloadTargets(sortNum: 2, totalChapters: 3), [1, 3]);
expect(readerAdjacentPreloadTargets(sortNum: 3, totalChapters: 3), [2]);
expect(readerAdjacentPreloadTargets(sortNum: 1, totalChapters: 1), isEmpty);
});

test('adjacent preload is delayed beyond the first rendered frame', () {
expect(readerAdjacentPreloadDelay, greaterThan(Duration.zero));
});
}