diff --git a/lib/page/dynamic/dynamic_page.dart b/lib/page/dynamic/dynamic_page.dart index d2df70a0..9b2945b7 100644 --- a/lib/page/dynamic/dynamic_page.dart +++ b/lib/page/dynamic/dynamic_page.dart @@ -166,27 +166,22 @@ class DynamicPageState extends State @override Widget build(BuildContext context) { super.build(context); // See AutomaticKeepAliveClientMixin. - var content = GSYPullLoadWidget( - dynamicBloc.pullLoadWidgetControl, - (BuildContext context, int index) { - /// itemBuilder 会对每个可见 index 各调一次。 - /// 走 [EventGroupIndex.of]:同一 dataList 引用 + 同一 length 时命中 - /// [Expando] 缓存,一帧只扫一次;loadMore 后 length 变、cache miss, - /// 重扫代价仍是 O(N)。这里没有把 groupIndex 提到父级 build,是为了 - /// 不额外挂 [GSYPullLoadWidgetControl] 的 listener——加载更多 / - /// 刷新已经会让 [GSYPullLoadWidget] 内部 rebuild,itemBuilder 会重跑, - /// 从而拿到最新的 dataList。 - final List data = dynamicBloc.dataList; + // 2026-09-28:数据通知刷新分组,索引属于这次列表构建;滚动建行不重扫。 + final content = ListenableBuilder( + listenable: dynamicBloc.pullLoadWidgetControl, + builder: (context, _) { + final data = List.of(dynamicBloc.dataList); final groupIndex = EventGroupIndex.of(data); - return _renderItemWithGroup(index, groupIndex, data); + return GSYPullLoadWidget( + dynamicBloc.pullLoadWidgetControl, + (context, index) => _renderItemWithGroup(index, groupIndex, data), + requestRefresh, + requestLoadMore, + refreshKey: refreshIndicatorKey, + scrollController: scrollController, + userIos: true, + ); }, - requestRefresh, - requestLoadMore, - refreshKey: refreshIndicatorKey, - scrollController: scrollController, - - ///使用ios模式的下拉刷新 - userIos: true, ); return IgnorePointer( ignoring: _ignoring, diff --git a/lib/page/repos/repostory_detail_info_page.dart b/lib/page/repos/repostory_detail_info_page.dart index e75cf7d1..cc7da33e 100644 --- a/lib/page/repos/repostory_detail_info_page.dart +++ b/lib/page/repos/repostory_detail_info_page.dart @@ -63,15 +63,15 @@ class ReposDetailInfoPageState extends State } ///渲染时间Item或者提交Item - _renderEventItem(index) { + _renderEventItem(int index, List data, EventGroupIndex groupIndex) { var provider = context.read(); - var item = pullLoadWidgetControl.dataList[index]; + var item = data[index]; if (selectIndex == 1 && item is RepoCommit) { ///提交 return GSYEventItem( EventViewModel.fromCommitMap(item), onPressed: () { - RepoCommit model = pullLoadWidgetControl.dataList[index]; + RepoCommit model = data[index]; NavigatorUtils.goPushDetailPage( context, provider.userName, @@ -88,10 +88,7 @@ class ReposDetailInfoPageState extends State /// [EventGroupIndex.of] 判断当前 index 是不是某个 span 的 head, /// 是的话就渲染 [GSYEventGroupItem];被 head 吞掉的后续 index 返回 /// [SizedBox.shrink]。这样 [_getListCount] 语义不变,加载更多兼容。 - /// [EventGroupIndex.of] 内部走 [Expando] 缓存,同一 dataList 引用 + - /// 同一 length 时不重复扫描,避免长列表下的 O(N²) 退化。 - final List data = pullLoadWidgetControl.dataList; - final groupIndex = EventGroupIndex.of(data); + // 索引由父列表构建一次,避免对同长度刷新复用旧事件。 final span = groupIndex.headSpanAt(index); if (span != null) { return GSYEventGroupItem( @@ -103,11 +100,11 @@ class ReposDetailInfoPageState extends State return const SizedBox.shrink(); } return GSYEventItem( - EventViewModel.fromEventMap(context, pullLoadWidgetControl.dataList[index]), + EventViewModel.fromEventMap(context, data[index]), onPressed: () { EventUtils.ActionUtils( context, - pullLoadWidgetControl.dataList[index], + data[index], "${provider.userName}/${provider.reposName}", ); }, @@ -234,9 +231,13 @@ class ReposDetailInfoPageState extends State ///展示 select context.select((p) => p.repository); + // 2026-09-28:与本次渲染的数据快照共享索引,itemBuilder 不做全表扫描。 + final data = List.of(pullLoadWidgetControl.dataList); + final groupIndex = EventGroupIndex.of(data); return GSYNestedPullLoadWidget( pullLoadWidgetControl, - (BuildContext context, int index) => _renderEventItem(index), + (BuildContext context, int index) => + _renderEventItem(index, data, groupIndex), handleRefresh, onLoadMore, refreshKey: refreshIKey, diff --git a/lib/page/user/widget/user_header.dart b/lib/page/user/widget/user_header.dart index 5e5e039d..3f63c389 100644 --- a/lib/page/user/widget/user_header.dart +++ b/lib/page/user/widget/user_header.dart @@ -110,30 +110,20 @@ class UserHeaderItem extends StatelessWidget { } _renderImg(BuildContext context) { - return RawMaterialButton( + // 2026-09-28:头部使用统一的头像加载/失败状态与固定尺寸契约。 + return GSYUserIconWidget( + image: userInfo.avatar_url?.isNotEmpty == true + ? userInfo.avatar_url + : null, + width: 80, + height: 80, + padding: EdgeInsets.zero, onPressed: () { - if (userInfo.avatar_url != null) { + if (userInfo.avatar_url?.isNotEmpty == true) { NavigatorUtils.gotoPhotoViewPage(context, userInfo.avatar_url); } }, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - padding: const EdgeInsets.all(0.0), - constraints: const BoxConstraints(minWidth: 0.0, minHeight: 0.0), - child: ClipOval( - child: FadeInImage.assetNetwork( - placeholder: GSYICons.DEFAULT_USER_ICON, - key: (userInfo.avatar_url != null && userInfo.avatar_url!.isNotEmpty) - ? ValueKey(userInfo.avatar_url) - : null, - //预览图 - fit: BoxFit.fitWidth, - image: (userInfo.avatar_url != null && - userInfo.avatar_url!.isNotEmpty) - ? userInfo.avatar_url! - : "https://github.com/CarGuo/gsy_github_app_flutter/blob/master/logo.png?raw=true", - width: 80.0, - height: 80.0, - ))); + ); } _renderUserInfo(BuildContext context) { diff --git a/lib/widget/gsy_event_group_item.dart b/lib/widget/gsy_event_group_item.dart index 785752d0..aeb5257e 100644 --- a/lib/widget/gsy_event_group_item.dart +++ b/lib/widget/gsy_event_group_item.dart @@ -109,7 +109,7 @@ Map buildEventGroupSpans(List dataList) { /// /// 注意:这是 O(spans.length) 线性扫。itemBuilder 会对每一个 index 都调一次, /// 长列表下等于 O(N × spans) 每帧。渲染路径请优先用 [EventGroupIndex], -/// 那份是 O(1) hash-set 查询 + 单次 dataList 引用级缓存。 +/// 那份是 O(1) hash-set 查询;父列表每次构建只生成一次索引。 bool isConsumedGroupIndex(int index, Map spans) { for (final span in spans.values) { if (index > span.startIndex && index <= span.endIndex) { @@ -119,34 +119,12 @@ bool isConsumedGroupIndex(int index, Map spans) { return false; } -/// 分组扫描结果的缓存视图。 +/// 当前列表构建使用的分组索引。 /// -/// 存在的意义: -/// - [buildEventGroupSpans] 是 O(N) 全表扫。 -/// - [isConsumedGroupIndex] 是 O(spans) 线性扫。 -/// - `ListView.builder` 会**为每个可见 index 都调一次 itemBuilder**, -/// 如果直接在 itemBuilder 里 `buildEventGroupSpans(dataList)` 再 -/// `isConsumedGroupIndex(...)`,长列表 (N > 200) 会退化到 O(N²), -/// 真机上表现为滚动掉帧。 -/// -/// 用法(父组件): -/// ```dart -/// final index = EventGroupIndex.of(dataList); -/// // 在 itemBuilder 里 -/// final span = index.headSpanAt(i); // O(1) -/// if (span != null) return GSYEventGroupItem(span, ...); -/// if (index.isConsumed(i)) return const SizedBox.shrink(); -/// return normalItem(i); -/// ``` -/// -/// 缓存策略:以 dataList 引用为键([Expando])+ length 作副键。 -/// - loadMore 走 [List.addAll],引用同一 List 但 length 变 → miss,会重扫。 -/// - 未改数据的普通 rebuild:引用一致且 length 相同 → hit,0 次扫描。 -/// - refresh 换新 List 引用:Expando 找不到条目 → miss,重扫; -/// 旧 List 无强引用时 Expando 里的条目会随之被 GC 回收,无泄漏。 -/// -/// 多 tab 共存友好:动态 tab 和仓库详情 Activity tab 各自的 dataList 是 -/// 不同 List 引用,Expando 里天然各占一个槽,互不覆盖。 +/// 2026-09-28:pull control 刷新会原地 clear/addAll,同引用同长度也可能是 +/// 完全不同的数据。不能跨构建按 List identity/length 复用分组。 +/// 父列表在数据通知/自身 setState 后构建一次,itemBuilder 只做 O(1) 查询, +/// 滚动创建行时复用该次索引,不在每行扫描列表。 class EventGroupIndex { final Map _headSpans; final Set _consumedIndices; @@ -162,12 +140,8 @@ class EventGroupIndex { /// 供调试 / 单测用。 int get groupCount => _headSpans.length; - /// 取给定 dataList 的分组索引。命中缓存则直接返回,否则重扫并缓存。 + /// 扫描当前数据一次;由父列表构建持有结果,不缓存可变 List。 static EventGroupIndex of(List dataList) { - final cached = _cache[dataList]; - if (cached != null && cached.sourceLength == dataList.length) { - return cached.index; - } final Map headSpans = buildEventGroupSpans(dataList); final Set consumed = {}; for (final span in headSpans.values) { @@ -175,32 +149,8 @@ class EventGroupIndex { consumed.add(i); } } - final built = EventGroupIndex._(headSpans, consumed); - _cache[dataList] = _EventGroupIndexCacheEntry( - sourceLength: dataList.length, - index: built, - ); - return built; - } - - /// 手动清缓存。业务无需调用;仅测试 / 内存压力场景使用。 - /// - /// 注意:`Expando` 无法整体清空,需要提供想清的 dataList。 - static void debugClearCacheFor(List dataList) { - _cache[dataList] = null; + return EventGroupIndex._(headSpans, consumed); } - - static final Expando<_EventGroupIndexCacheEntry> _cache = - Expando<_EventGroupIndexCacheEntry>('EventGroupIndex'); -} - -class _EventGroupIndexCacheEntry { - final int sourceLength; - final EventGroupIndex index; - new({ - required this.sourceLength, - required this.index, - }); } /// 连续同用户事件折叠卡片。 diff --git a/lib/widget/gsy_user_icon_widget.dart b/lib/widget/gsy_user_icon_widget.dart index d3767418..60077080 100644 --- a/lib/widget/gsy_user_icon_widget.dart +++ b/lib/widget/gsy_user_icon_widget.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:gsy_github_app_flutter/common/logger.dart'; import 'package:gsy_github_app_flutter/common/style/gsy_style.dart'; /// 头像Icon @@ -60,6 +61,17 @@ class GSYUserIconWidget extends StatelessWidget { GSYICons.DEFAULT_USER_ICON, ), image: NetworkImage(url), + // 2026-09-28:网络头像失败是明确的图片状态,不能让 ErrorWidget 的 + // 异常文本挤破用户信息布局。保留结构化错误,使用原有本地头像资产。 + imageErrorBuilder: (context, error, stackTrace) { + printError('Avatar image load failed', error, stackTrace); + return Image.asset( + GSYICons.DEFAULT_USER_ICON, + width: width, + height: height, + fit: BoxFit.fitWidth, + ); + }, //预览图 fit: BoxFit.fitWidth, width: width, diff --git a/test/widget/event_group_refresh_test.dart b/test/widget/event_group_refresh_test.dart new file mode 100644 index 00000000..0560b4cf --- /dev/null +++ b/test/widget/event_group_refresh_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:gsy_github_app_flutter/model/event.dart'; +import 'package:gsy_github_app_flutter/widget/gsy_event_group_item.dart'; +import 'package:gsy_github_app_flutter/widget/pull/gsy_pull_new_load_widget.dart'; + +// Component regression only: synthetic events are not device business evidence. +Event event(String id, String login) => Event.fromJson({ + 'id': id, + 'type': 'PushEvent', + 'actor': {'login': login}, +}); + +void main() { + test('same-length in-place refresh replaces grouped events and grouping', () { + final control = GSYPullLoadWidgetControl(); + addTearDown(control.dispose); + control.dataList = [event('old-1', 'alice'), event('old-2', 'alice')]; + final data = control.dataList!; + expect(EventGroupIndex.of(data).headSpanAt(0)!.events.first.id, 'old-1'); + control.dataList = [event('new-1', 'bob'), event('new-2', 'bob')]; + expect(identical(control.dataList, data), isTrue); + final refreshed = EventGroupIndex.of(data); + expect(refreshed.headSpanAt(0)!.events.map((e) => e.id), [ + 'new-1', + 'new-2', + ]); + control.dataList = [event('new-1', 'bob'), event('new-2', 'carol')]; + expect(EventGroupIndex.of(data).groupCount, 0); + }); + + test('pagination extends a group while retaining its stable identity', () { + final data = [event('first', 'alice'), event('second', 'alice')]; + final before = EventGroupIndex.of(data).headSpanAt(0)!; + data.add(event('third', 'alice')); + final after = EventGroupIndex.of(data); + expect(after.headSpanAt(0)!.stableKey, before.stableKey); + expect(after.headSpanAt(0)!.events.map((e) => e.id), [ + 'first', + 'second', + 'third', + ]); + expect(after.isConsumed(2), isTrue); + }); +} diff --git a/test/widget/gsy_avatar_error_test.dart b/test/widget/gsy_avatar_error_test.dart new file mode 100644 index 00000000..fcf1e12d --- /dev/null +++ b/test/widget/gsy_avatar_error_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gsy_github_app_flutter/common/style/gsy_style.dart'; +import 'package:gsy_github_app_flutter/widget/gsy_user_icon_widget.dart'; + +void main() { + testWidgets( + 'failed avatar stays in its declared frame without a Flutter error', + (tester) async { + // flutter_test rejects HTTP images (400); this verifies the failure branch, + // not successful loading or real authenticated business data. + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Row( + children: [ + GSYUserIconWidget( + image: 'https://example.invalid/avatar.png', + width: 80, + height: 80, + padding: EdgeInsets.zero, + onPressed: () {}, + ), + const Expanded(child: Text('Account identity')), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect( + tester.getSize(find.byType(GSYUserIconWidget)), + const Size(80, 80), + ); + expect( + find.byWidgetPredicate( + (w) => + w is Image && + w.image is AssetImage && + (w.image as AssetImage).assetName == GSYICons.DEFAULT_USER_ICON, + ), + findsWidgets, + ); + expect(find.text('Account identity'), findsOneWidget); + }, + ); +}