refactor(feed): 合并首页与剧场频道控制器的重复分桶逻辑

两个控制器的缓存优先、请求序号防串台、分页去重、预取与 seed 完全同构,
此前各写一份。抽出 ChannelFeedController 后,子类只保留分桶 key 与取页
实现;顺带修掉刷新被翻页抢占时 loading 永久为 true 的问题。
This commit is contained in:
Zhou Weidong authored and trae-solo-agent committed 2026-10-29 10:44:36 +08:00
1 parent 1be8ebf1f9
commit 9c9a277217
4 files changed
+313 -372

No files matched your search

@@ -0,0 +1,233 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../../data/models/drama.dart';
import '../../data/models/feed_page.dart';
/// 单个分桶(频道/分类)的游标分页状态。
class ChannelFeedState {
List<Drama> dramas = const [];
String? cursor;
bool hasMore = true;
bool loaded = false;
bool loading = false;
bool loadingMore = false;
String? lastError;
int requestSeq = 0;
Future<bool>? inFlight;
bool skipNextRefresh = false;
/// 正在持有 [loading] 的那次刷新请求的序号;被抢占时据此只由原请求复位。
int? loadingOwner;
}
/// 按 key 分桶的游标分页控制器基类。
///
/// 首页频道与剧场分类的逻辑完全同构:缓存优先渲染、请求序号防串台、
/// 分页去重追加。两者此前各写一份,改动容易只改一边,故收口到这里,
/// 子类只需提供分桶 key 与「如何发一次请求」。
abstract class ChannelFeedController<K> extends ChangeNotifier {
ChannelFeedController(
K initialKey, {
bool initiallySelected = true,
this.requestTimeout = defaultRequestTimeout,
}) : _current = initialKey,
_selected = initiallySelected;
static const int pageSize = 20;
static const Duration defaultRequestTimeout = Duration(seconds: 10);
final Duration requestTimeout;
final Map<K, ChannelFeedState> _states = {};
K _current;
bool _selected;
bool _disposed = false;
K get currentKey => _current;
String? get lastError => _state(_current).lastError;
List<Drama> get dramas => _state(_current).dramas;
String? get cursor => _state(_current).cursor;
bool get hasMore => _state(_current).hasMore;
bool get loaded => _state(_current).loaded;
bool get isLoading => _state(_current).loading;
bool get loadingMore => _state(_current).loadingMore;
bool get loadingFirstPage => !loaded && _state(_current).loading;
/// 子类实现:按 key 拉一页;[cursor] 为空表示首屏/刷新。
@protected
Future<FeedPage> fetchPage(K key, {String? cursor});
/// 同步切换分桶;有缓存先渲染,无缓存时复用或等待在飞请求。
@protected
Future<void> selectKey(K next) async {
if (_selected && next == _current) return;
_current = next;
_selected = true;
_notify();
final state = _state(next);
if (state.dramas.isNotEmpty) {
if (state.skipNextRefresh) {
state.skipNextRefresh = false;
} else if (!state.loading) {
unawaited(_refresh(next, keepData: true));
}
return;
}
if (state.loading) {
final inFlight = state.inFlight;
if (inFlight != null) await inFlight;
state.skipNextRefresh = false;
return;
}
await _refresh(next, keepData: false);
}
/// 用已有数据填充某个分桶,不发起网络请求。
@protected
void seedKey(K key, List<Drama> dramas) {
final state = _state(key);
state
..requestSeq = state.requestSeq + 1
..dramas = List.unmodifiable(dramas)
..cursor = null
..hasMore = false
..loaded = true
..loading = false
..loadingOwner = null
..loadingMore = false
..lastError = null
..inFlight = null;
_notifyIfCurrent(key);
}
/// 后台预取某个分桶首屏;成功后标记「切过去不必再刷一次」。
@protected
Future<void> prefetchKey(K key) async {
final state = _state(key);
if (state.loaded || state.loading) return;
final success = await _refresh(key, keepData: false);
if (success && !_disposed) state.skipNextRefresh = true;
}
/// 刷新当前分桶,失败时保留旧数据。
Future<bool> refreshCurrent() async {
final state = _state(_current);
if (state.loading) {
return await state.inFlight ?? false;
}
return _refresh(_current, keepData: true);
}
/// 加载当前分桶下一页并去重追加。
Future<void> loadMore() async {
final key = _current;
final state = _state(key);
if (!state.hasMore || state.loadingMore || state.cursor == null) return;
final seq = ++state.requestSeq;
state.loadingMore = true;
_notifyIfCurrent(key);
try {
final page = await fetchPage(
key,
cursor: state.cursor,
).timeout(requestTimeout);
if (!_acceptSequence(key, seq)) return;
final seen = state.dramas.map((drama) => drama.id).toSet();
final appended = page.dramas
.where((drama) => seen.add(drama.id))
.toList();
state
..dramas = List.unmodifiable([...state.dramas, ...appended])
..cursor = page.nextCursor
..hasMore = page.hasMore
..loaded = true
..lastError = null;
} catch (error) {
if (_acceptSequence(key, seq)) {
state.lastError = error.toString();
}
} finally {
state.loadingMore = false;
_notifyIfCurrent(key);
}
}
Future<bool> _refresh(K key, {required bool keepData}) {
final state = _state(key);
final operation = _performRefresh(key, keepData: keepData);
state.inFlight = operation;
unawaited(_clearInFlight(state, operation));
return operation;
}
Future<bool> _performRefresh(K key, {required bool keepData}) async {
final state = _state(key);
final seq = ++state.requestSeq;
state
..loading = true
..loadingOwner = seq
..lastError = null;
_notifyIfCurrent(key);
try {
final page = await fetchPage(key).timeout(requestTimeout);
if (!_acceptSequence(key, seq)) return false;
state
..dramas = List.unmodifiable(page.dramas)
..cursor = page.nextCursor
..hasMore = page.hasMore
..loaded = true
..lastError = null;
_notifyIfCurrent(key);
return true;
} catch (error) {
if (!_acceptSequence(key, seq)) return false;
if (!keepData) state.dramas = const [];
state
..loaded = true
..lastError = error.toString();
_notifyIfCurrent(key);
return false;
} finally {
// 只由「真正拥有 loading 的那次请求」复位:被 loadMore 抢占时它不再
// 更新数据,但必须收掉自己打开的加载态,否则该分桶会一直显示加载中,
// 之后 refreshCurrent 也只会读到已清空的 inFlight 而永远返回 false。
if (!_disposed && state.loadingOwner == seq) {
state
..loading = false
..loadingOwner = null;
_notifyIfCurrent(key);
}
}
}
Future<void> _clearInFlight(
ChannelFeedState state,
Future<bool> operation,
) async {
await operation;
if (identical(state.inFlight, operation)) state.inFlight = null;
}
ChannelFeedState _state(K key) =>
_states.putIfAbsent(key, ChannelFeedState.new);
bool _acceptSequence(K key, int seq) =>
!_disposed && _state(key).requestSeq == seq;
void _notifyIfCurrent(K key) {
if (!_disposed && key == _current) _notify();
}
void _notify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
+17 -199
View File
@@ -1,223 +1,41 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../../data/models/drama.dart';
import '../../data/models/feed_page.dart';
import '../../data/repositories/catalog_repository.dart';
import '../../../pages/home/home_feed_channel.dart';
import 'channel_feed_controller.dart';
/// 首页内容类型频道状态:每频道独立缓存、刷新和游标分页。
class HomeFeedController extends ChangeNotifier {
HomeFeedController(
this._repository, {
this.requestTimeout = defaultRequestTimeout,
}) : _states = {
for (final channel in HomeFeedChannel.values)
channel: _ChannelFeedState(),
};
static const int pageSize = 20;
static const Duration defaultRequestTimeout = Duration(seconds: 10);
class HomeFeedController extends ChannelFeedController<HomeFeedChannel> {
HomeFeedController(this._repository, {super.requestTimeout})
: super(HomeFeedChannel.recommend);
final CatalogRepository _repository;
final Duration requestTimeout;
final Map<HomeFeedChannel, _ChannelFeedState> _states;
HomeFeedChannel _channel = HomeFeedChannel.recommend;
bool _disposed = false;
HomeFeedChannel get channel => _channel;
String? get lastError => _state(_channel).lastError;
List<Drama> get dramas => _state(_channel).dramas;
String? get cursor => _state(_channel).cursor;
bool get hasMore => _state(_channel).hasMore;
bool get loaded => _state(_channel).loaded;
bool get isLoading => _state(_channel).loading;
bool get loadingMore => _state(_channel).loadingMore;
bool get loadingFirstPage => !loaded && _state(_channel).loading;
HomeFeedChannel get channel => currentKey;
/// 用首页聚合接口打平的数据填充推荐首屏,不发起网络请求。
void seedRecommend(List<Drama> dramas) {
final state = _state(HomeFeedChannel.recommend);
state
..requestSeq = state.requestSeq + 1
..dramas = List.unmodifiable(dramas)
..cursor = null
..hasMore = false
..loaded = true
..loading = false
..loadingMore = false
..lastError = null
..inFlight = null;
_notifyIfCurrent(HomeFeedChannel.recommend);
}
void seedRecommend(List<Drama> dramas) =>
seedKey(HomeFeedChannel.recommend, dramas);
/// 后台预取漫剧与真人剧首屏,不切换频道,也不影响当前列表。
Future<void> prefetchChannels() async {
await Future.wait([
_prefetchChannel(HomeFeedChannel.anime),
_prefetchChannel(HomeFeedChannel.live),
prefetchKey(HomeFeedChannel.anime),
prefetchKey(HomeFeedChannel.live),
]);
}
/// 同步切到目标频道;有缓存先渲染,无缓存时复用或等待在飞请求。
Future<void> selectChannel(HomeFeedChannel next) async {
if (next == _channel) return;
_channel = next;
_notify();
final state = _state(next);
if (state.dramas.isNotEmpty) {
if (state.skipNextRefresh) {
state.skipNextRefresh = false;
} else if (!state.loading) {
unawaited(_refresh(next, keepData: true));
}
return;
}
if (state.loading) {
final inFlight = state.inFlight;
if (inFlight != null) await inFlight;
state.skipNextRefresh = false;
return;
}
await _refresh(next, keepData: false);
}
/// 刷新当前频道,失败时保留旧数据。
Future<bool> refreshCurrent() async {
final state = _state(_channel);
if (state.loading) {
return await state.inFlight ?? false;
}
return _refresh(_channel, keepData: true);
}
/// 加载当前频道下一页并去重追加。
Future<void> loadMore() async {
final channel = _channel;
final state = _state(channel);
if (!state.hasMore || state.loadingMore || state.cursor == null) return;
final seq = ++state.requestSeq;
state.loadingMore = true;
_notifyIfCurrent(channel);
try {
final page = await _repository
.fetchFeed(
types: channel.apiTypes,
cursor: state.cursor,
size: pageSize,
)
.timeout(requestTimeout);
if (!_acceptSequence(channel, seq)) return;
final seen = state.dramas.map((drama) => drama.id).toSet();
final appended = page.dramas
.where((drama) => seen.add(drama.id))
.toList();
state
..dramas = List.unmodifiable([...state.dramas, ...appended])
..cursor = page.nextCursor
..hasMore = page.hasMore
..loaded = true
..lastError = null;
} catch (error) {
if (_acceptSequence(channel, seq)) {
state.lastError = error.toString();
}
} finally {
state.loadingMore = false;
_notifyIfCurrent(channel);
}
}
Future<bool> _refresh(HomeFeedChannel channel, {required bool keepData}) {
final state = _state(channel);
final operation = _performRefresh(channel, keepData: keepData);
state.inFlight = operation;
unawaited(_clearInFlight(state, operation));
return operation;
}
Future<void> _prefetchChannel(HomeFeedChannel channel) async {
final state = _state(channel);
if (state.loaded || state.loading) return;
final success = await _refresh(channel, keepData: false);
if (success && !_disposed) state.skipNextRefresh = true;
}
Future<bool> _performRefresh(
HomeFeedChannel channel, {
required bool keepData,
}) async {
final state = _state(channel);
final seq = ++state.requestSeq;
state
..loading = true
..lastError = null;
_notifyIfCurrent(channel);
try {
final page = await _repository
.fetchFeed(types: channel.apiTypes, size: pageSize)
.timeout(requestTimeout);
if (!_acceptSequence(channel, seq)) return false;
state
..dramas = List.unmodifiable(page.dramas)
..cursor = page.nextCursor
..hasMore = page.hasMore
..loaded = true
..loading = false
..lastError = null;
_notifyIfCurrent(channel);
return true;
} catch (error) {
if (!_acceptSequence(channel, seq)) return false;
if (!keepData) state.dramas = const [];
state
..loaded = true
..loading = false
..lastError = error.toString();
_notifyIfCurrent(channel);
return false;
}
}
Future<void> _clearInFlight(
_ChannelFeedState state,
Future<bool> operation,
) async {
await operation;
if (identical(state.inFlight, operation)) state.inFlight = null;
}
_ChannelFeedState _state(HomeFeedChannel channel) => _states[channel]!;
bool _acceptSequence(HomeFeedChannel channel, int seq) =>
!_disposed && _state(channel).requestSeq == seq;
void _notifyIfCurrent(HomeFeedChannel channel) {
if (!_disposed && channel == _channel) _notify();
}
void _notify() {
if (!_disposed) notifyListeners();
}
Future<void> selectChannel(HomeFeedChannel next) => selectKey(next);
@override
void dispose() {
_disposed = true;
super.dispose();
Future<FeedPage> fetchPage(HomeFeedChannel key, {String? cursor}) {
return _repository.fetchFeed(
types: key.apiTypes,
cursor: cursor,
size: ChannelFeedController.pageSize,
);
}
}
class _ChannelFeedState {
List<Drama> dramas = const [];
String? cursor;
bool hasMore = true;
bool loaded = false;
bool loading = false;
bool loadingMore = false;
String? lastError;
int requestSeq = 0;
Future<bool>? inFlight;
bool skipNextRefresh = false;
}
@@ -1,187 +1,27 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../../data/models/drama.dart';
import '../../data/models/feed_page.dart';
import '../../data/repositories/catalog_repository.dart';
import 'channel_feed_controller.dart';
/// 剧场页服务端分类列表:按 categoryId 独立缓存、刷新和游标分页。
class TheaterFeedController extends ChangeNotifier {
TheaterFeedController(
this._repository, {
this.requestTimeout = defaultRequestTimeout,
});
static const int pageSize = 20;
static const Duration defaultRequestTimeout = Duration(seconds: 10);
class TheaterFeedController extends ChannelFeedController<String?> {
TheaterFeedController(this._repository, {super.requestTimeout})
: super(null, initiallySelected: false);
final CatalogRepository _repository;
final Duration requestTimeout;
final Map<String?, _CategoryFeedState> _states = {};
String? _categoryId;
bool _initialized = false;
bool _disposed = false;
String? get categoryId => _categoryId;
String? get lastError => _state(_categoryId).lastError;
List<Drama> get dramas => _state(_categoryId).dramas;
String? get cursor => _state(_categoryId).cursor;
bool get hasMore => _state(_categoryId).hasMore;
bool get loaded => _state(_categoryId).loaded;
bool get loadingMore => _state(_categoryId).loadingMore;
bool get loadingFirstPage => !loaded && _state(_categoryId).loading;
String? get categoryId => currentKey;
/// 同步切换分类;有缓存先渲染,无缓存时等待或发起首屏请求。
Future<void> select(String? next) async {
if (_initialized && next == _categoryId) return;
_categoryId = next;
_initialized = true;
_notify();
final state = _state(next);
if (state.dramas.isNotEmpty) {
if (!state.loading) {
unawaited(_refresh(next, keepData: true));
}
return;
}
if (state.loading) {
final inFlight = state.inFlight;
if (inFlight != null) await inFlight;
return;
}
await _refresh(next, keepData: false);
}
/// 刷新当前分类;失败时保留已有列表。
Future<bool> refreshCurrent() async {
final state = _state(_categoryId);
if (state.loading) {
return await state.inFlight ?? false;
}
return _refresh(_categoryId, keepData: true);
}
/// 加载当前分类下一页并去重追加。
Future<void> loadMore() async {
final categoryId = _categoryId;
final state = _state(categoryId);
if (!state.hasMore || state.loadingMore || state.cursor == null) return;
final sequence = ++state.requestSeq;
state.loadingMore = true;
_notifyIfCurrent(categoryId);
try {
final page = await _repository
.fetchFeed(
categoryId: categoryId,
cursor: state.cursor,
size: pageSize,
)
.timeout(requestTimeout);
if (!_acceptSequence(categoryId, sequence)) return;
final seen = state.dramas.map((drama) => drama.id).toSet();
final appended = page.dramas
.where((drama) => seen.add(drama.id))
.toList();
state
..dramas = List.unmodifiable([...state.dramas, ...appended])
..cursor = page.nextCursor
..hasMore = page.hasMore
..loaded = true
..lastError = null;
} catch (error) {
if (_acceptSequence(categoryId, sequence)) {
state.lastError = error.toString();
}
} finally {
state.loadingMore = false;
_notifyIfCurrent(categoryId);
}
}
Future<bool> _refresh(String? categoryId, {required bool keepData}) {
final state = _state(categoryId);
final operation = _performRefresh(categoryId, keepData: keepData);
state.inFlight = operation;
unawaited(_clearInFlight(state, operation));
return operation;
}
Future<bool> _performRefresh(
String? categoryId, {
required bool keepData,
}) async {
final state = _state(categoryId);
final sequence = ++state.requestSeq;
state
..loading = true
..lastError = null;
_notifyIfCurrent(categoryId);
try {
final page = await _repository
.fetchFeed(categoryId: categoryId, size: pageSize)
.timeout(requestTimeout);
if (!_acceptSequence(categoryId, sequence)) return false;
state
..dramas = List.unmodifiable(page.dramas)
..cursor = page.nextCursor
..hasMore = page.hasMore
..loaded = true
..loading = false
..lastError = null;
_notifyIfCurrent(categoryId);
return true;
} catch (error) {
if (!_acceptSequence(categoryId, sequence)) return false;
if (!keepData) state.dramas = const [];
state
..loaded = true
..loading = false
..lastError = error.toString();
_notifyIfCurrent(categoryId);
return false;
}
}
Future<void> _clearInFlight(
_CategoryFeedState state,
Future<bool> operation,
) async {
await operation;
if (identical(state.inFlight, operation)) state.inFlight = null;
}
_CategoryFeedState _state(String? categoryId) =>
_states.putIfAbsent(categoryId, _CategoryFeedState.new);
bool _acceptSequence(String? categoryId, int sequence) =>
!_disposed && _state(categoryId).requestSeq == sequence;
void _notifyIfCurrent(String? categoryId) {
if (!_disposed && categoryId == _categoryId) _notify();
}
void _notify() {
if (!_disposed) notifyListeners();
}
Future<void> select(String? next) => selectKey(next);
@override
void dispose() {
_disposed = true;
super.dispose();
Future<FeedPage> fetchPage(String? key, {String? cursor}) {
return _repository.fetchFeed(
categoryId: key,
cursor: cursor,
size: ChannelFeedController.pageSize,
);
}
}
class _CategoryFeedState {
List<Drama> dramas = const [];
String? cursor;
bool hasMore = true;
bool loaded = false;
bool loading = false;
bool loadingMore = false;
String? lastError;
int requestSeq = 0;
Future<bool>? inFlight;
}
@@ -0,0 +1,50 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/feed_page.dart';
import 'package:fruitshorts/core/data/services/channel_feed_controller.dart';
void main() {
test('should_clear_loading_when_load_more_supersedes_refresh', () async {
final pendingRefresh = Completer<FeedPage>();
final controller = _ScriptedFeedController([
() async => FeedPage(
dramas: [_drama('a')],
nextCursor: 'cursor-1',
hasMore: true,
),
() => pendingRefresh.future,
() async => FeedPage(dramas: [_drama('b')], hasMore: false),
]);
await controller.refreshCurrent();
expect(controller.hasMore, isTrue);
final refresh = controller.refreshCurrent();
final more = controller.loadMore();
pendingRefresh.complete(FeedPage(dramas: [_drama('late')], hasMore: false));
await Future.wait([refresh, more]);
// 刷新被翻页抢占后,加载态必须收掉,否则该分桶永远显示加载中且无法再刷新。
expect(controller.isLoading, isFalse);
expect(controller.loadingMore, isFalse);
expect(controller.dramas.map((drama) => drama.id), contains('b'));
expect(controller.dramas.map((drama) => drama.id), isNot(contains('late')));
controller.dispose();
});
}
class _ScriptedFeedController extends ChannelFeedController<String> {
_ScriptedFeedController(this._pages) : super('all');
final List<Future<FeedPage> Function()> _pages;
@override
Future<FeedPage> fetchPage(String key, {String? cursor}) =>
_pages.removeAt(0)();
}
Drama _drama(String id) =>
Drama(id: id, title: id, synopsis: id, posterUrl: '');