diff --git a/lib/core/data/services/channel_feed_controller.dart b/lib/core/data/services/channel_feed_controller.dart new file mode 100644 index 0000000..d355f49 --- /dev/null +++ b/lib/core/data/services/channel_feed_controller.dart @@ -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 dramas = const []; + String? cursor; + bool hasMore = true; + bool loaded = false; + bool loading = false; + bool loadingMore = false; + String? lastError; + int requestSeq = 0; + Future? inFlight; + bool skipNextRefresh = false; + + /// 正在持有 [loading] 的那次刷新请求的序号;被抢占时据此只由原请求复位。 + int? loadingOwner; +} + +/// 按 key 分桶的游标分页控制器基类。 +/// +/// 首页频道与剧场分类的逻辑完全同构:缓存优先渲染、请求序号防串台、 +/// 分页去重追加。两者此前各写一份,改动容易只改一边,故收口到这里, +/// 子类只需提供分桶 key 与「如何发一次请求」。 +abstract class ChannelFeedController 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 _states = {}; + K _current; + bool _selected; + bool _disposed = false; + + K get currentKey => _current; + String? get lastError => _state(_current).lastError; + List 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 fetchPage(K key, {String? cursor}); + + /// 同步切换分桶;有缓存先渲染,无缓存时复用或等待在飞请求。 + @protected + Future 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 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 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 refreshCurrent() async { + final state = _state(_current); + if (state.loading) { + return await state.inFlight ?? false; + } + return _refresh(_current, keepData: true); + } + + /// 加载当前分桶下一页并去重追加。 + Future 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 _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 _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 _clearInFlight( + ChannelFeedState state, + Future 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(); + } +} diff --git a/lib/core/data/services/home_feed_controller.dart b/lib/core/data/services/home_feed_controller.dart index 7d31048..5ba6538 100644 --- a/lib/core/data/services/home_feed_controller.dart +++ b/lib/core/data/services/home_feed_controller.dart @@ -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 { + HomeFeedController(this._repository, {super.requestTimeout}) + : super(HomeFeedChannel.recommend); final CatalogRepository _repository; - final Duration requestTimeout; - final Map _states; - HomeFeedChannel _channel = HomeFeedChannel.recommend; - bool _disposed = false; - HomeFeedChannel get channel => _channel; - String? get lastError => _state(_channel).lastError; - List 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 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 dramas) => + seedKey(HomeFeedChannel.recommend, dramas); /// 后台预取漫剧与真人剧首屏,不切换频道,也不影响当前列表。 Future prefetchChannels() async { await Future.wait([ - _prefetchChannel(HomeFeedChannel.anime), - _prefetchChannel(HomeFeedChannel.live), + prefetchKey(HomeFeedChannel.anime), + prefetchKey(HomeFeedChannel.live), ]); } /// 同步切到目标频道;有缓存先渲染,无缓存时复用或等待在飞请求。 - Future 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 refreshCurrent() async { - final state = _state(_channel); - if (state.loading) { - return await state.inFlight ?? false; - } - return _refresh(_channel, keepData: true); - } - - /// 加载当前频道下一页并去重追加。 - Future 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 _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 _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 _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 _clearInFlight( - _ChannelFeedState state, - Future 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 selectChannel(HomeFeedChannel next) => selectKey(next); @override - void dispose() { - _disposed = true; - super.dispose(); + Future fetchPage(HomeFeedChannel key, {String? cursor}) { + return _repository.fetchFeed( + types: key.apiTypes, + cursor: cursor, + size: ChannelFeedController.pageSize, + ); } } - -class _ChannelFeedState { - List dramas = const []; - String? cursor; - bool hasMore = true; - bool loaded = false; - bool loading = false; - bool loadingMore = false; - String? lastError; - int requestSeq = 0; - Future? inFlight; - bool skipNextRefresh = false; -} diff --git a/lib/core/data/services/theater_feed_controller.dart b/lib/core/data/services/theater_feed_controller.dart index 0343bd6..563c469 100644 --- a/lib/core/data/services/theater_feed_controller.dart +++ b/lib/core/data/services/theater_feed_controller.dart @@ -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 { + TheaterFeedController(this._repository, {super.requestTimeout}) + : super(null, initiallySelected: false); final CatalogRepository _repository; - final Duration requestTimeout; - final Map _states = {}; - String? _categoryId; - bool _initialized = false; - bool _disposed = false; - String? get categoryId => _categoryId; - String? get lastError => _state(_categoryId).lastError; - List 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 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 refreshCurrent() async { - final state = _state(_categoryId); - if (state.loading) { - return await state.inFlight ?? false; - } - return _refresh(_categoryId, keepData: true); - } - - /// 加载当前分类下一页并去重追加。 - Future 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 _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 _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 _clearInFlight( - _CategoryFeedState state, - Future 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 select(String? next) => selectKey(next); @override - void dispose() { - _disposed = true; - super.dispose(); + Future fetchPage(String? key, {String? cursor}) { + return _repository.fetchFeed( + categoryId: key, + cursor: cursor, + size: ChannelFeedController.pageSize, + ); } } - -class _CategoryFeedState { - List dramas = const []; - String? cursor; - bool hasMore = true; - bool loaded = false; - bool loading = false; - bool loadingMore = false; - String? lastError; - int requestSeq = 0; - Future? inFlight; -} diff --git a/test/core/data/services/channel_feed_controller_test.dart b/test/core/data/services/channel_feed_controller_test.dart new file mode 100644 index 0000000..cbea9b3 --- /dev/null +++ b/test/core/data/services/channel_feed_controller_test.dart @@ -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(); + 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 { + _ScriptedFeedController(this._pages) : super('all'); + + final List Function()> _pages; + + @override + Future fetchPage(String key, {String? cursor}) => + _pages.removeAt(0)(); +} + +Drama _drama(String id) => + Drama(id: id, title: id, synopsis: id, posterUrl: '');