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
33 changes: 14 additions & 19 deletions lib/page/dynamic/dynamic_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -166,27 +166,22 @@ class DynamicPageState extends State<DynamicPage>
@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<dynamic>.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,
Expand Down
21 changes: 11 additions & 10 deletions lib/page/repos/repostory_detail_info_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -63,15 +63,15 @@ class ReposDetailInfoPageState extends State<ReposDetailInfoPage>
}

///渲染时间Item或者提交Item
_renderEventItem(index) {
_renderEventItem(int index, List<dynamic> data, EventGroupIndex groupIndex) {
var provider = context.read<ReposDetailProvider>();
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,
Expand All @@ -88,10 +88,7 @@ class ReposDetailInfoPageState extends State<ReposDetailInfoPage>
/// [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(
Expand All @@ -103,11 +100,11 @@ class ReposDetailInfoPageState extends State<ReposDetailInfoPage>
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}",
);
},
Expand Down Expand Up @@ -234,9 +231,13 @@ class ReposDetailInfoPageState extends State<ReposDetailInfoPage>
///展示 select
context.select<ReposDetailProvider, RepositoryQL?>((p) => p.repository);

// 2026-09-28:与本次渲染的数据快照共享索引,itemBuilder 不做全表扫描。
final data = List<dynamic>.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,
Expand Down
30 changes: 10 additions & 20 deletions lib/page/user/widget/user_header.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
66 changes: 8 additions & 58 deletions lib/widget/gsy_event_group_item.dart
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ Map<int, EventGroupSpan> buildEventGroupSpans(List<dynamic> dataList) {
///
/// 注意:这是 O(spans.length) 线性扫。itemBuilder 会对每一个 index 都调一次,
/// 长列表下等于 O(N × spans) 每帧。渲染路径请优先用 [EventGroupIndex],
/// 那份是 O(1) hash-set 查询 + 单次 dataList 引用级缓存。
/// 那份是 O(1) hash-set 查询;父列表每次构建只生成一次索引。
bool isConsumedGroupIndex(int index, Map<int, EventGroupSpan> spans) {
for (final span in spans.values) {
if (index > span.startIndex && index <= span.endIndex) {
Expand All @@ -119,34 +119,12 @@ bool isConsumedGroupIndex(int index, Map<int, EventGroupSpan> 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<int, EventGroupSpan> _headSpans;
final Set<int> _consumedIndices;
Expand All @@ -162,45 +140,17 @@ class EventGroupIndex {
/// 供调试 / 单测用。
int get groupCount => _headSpans.length;

/// 取给定 dataList 的分组索引。命中缓存则直接返回,否则重扫并缓存。
/// 扫描当前数据一次;由父列表构建持有结果,不缓存可变 List。
static EventGroupIndex of(List<dynamic> dataList) {
final cached = _cache[dataList];
if (cached != null && cached.sourceLength == dataList.length) {
return cached.index;
}
final Map<int, EventGroupSpan> headSpans = buildEventGroupSpans(dataList);
final Set<int> consumed = <int>{};
for (final span in headSpans.values) {
for (int i = span.startIndex + 1; i <= span.endIndex; i++) {
consumed.add(i);
}
}
final built = EventGroupIndex._(headSpans, consumed);
_cache[dataList] = _EventGroupIndexCacheEntry(
sourceLength: dataList.length,
index: built,
);
return built;
}

/// 手动清缓存。业务无需调用;仅测试 / 内存压力场景使用。
///
/// 注意:`Expando` 无法整体清空,需要提供想清的 dataList。
static void debugClearCacheFor(List<dynamic> 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,
});
}

/// 连续同用户事件折叠卡片。
Expand Down
12 changes: 12 additions & 0 deletions lib/widget/gsy_user_icon_widget.dart
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
Expand Down
44 changes: 44 additions & 0 deletions test/widget/event_group_refresh_test.dart
Original file line number Diff line number Diff line change
@@ -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);
});
}
48 changes: 48 additions & 0 deletions test/widget/gsy_avatar_error_test.dart
Original file line number Diff line number Diff line change
@@ -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);
},
);
}
Loading