

概述
搜索是代码托管平台最核心的功能之一。用户通过搜索发现感兴趣的开源项目、查找特定技术栈的代码库、或者评估技术方案的社区活跃度。AtomGit Flutter 客户端实现了全功能仓库搜索,包括关键词检索、排序筛选、无限滚动分页,以及多搜索入口的交互设计。
搜索入口的多场景设计
应用中有四个不同的搜索触发点,各有不同的交互行为和适用场景:
| 位置 | 触发方式 | 行为 | 适用场景 |
|---|---|---|---|
| 首页(未登录) | TextField.onSubmitted | 导航到 /search |
访客快速体验 |
| 首页(已登录) | TextField.onSubmitted | 导航到 /search |
认证用户搜索 |
| 发现 Tab | TextField.onSubmitted | Tab 内直接搜索 | 浏览发现场景 |
| 搜索页面 | AppBar TextField | 页面内搜索 | 精确搜索 |
首页搜索栏
首页搜索栏的位置设计考虑了两种用户状态:
未登录时,搜索栏位于欢迎页的引导按钮下方,访客无需登录即可搜索。这是一种低门槛设计------让用户先体验核心功能,再决定是否登录。
dart
// 首页搜索栏(未登录页面中)
TextField(
decoration: InputDecoration(
hintText: '搜索仓库...',
prefixIcon: const Icon(Icons.search),
),
onSubmitted: (value) {
if (value.trim().isNotEmpty) {
Navigator.pushNamed(
context,
'/search',
arguments: value.trim(),
);
}
},
)
已登录时,搜索栏位于 AppBar 的操作区,通过搜索图标按钮触发。这是为了在已登录首页中节省垂直空间(已登录首页需要展示用户仓库和热门仓库两个区域)。
发现 Tab 搜索
发现 Tab 的搜索栏设计为内嵌搜索------不需要导航到独立页面:
dart
// 发现 Tab 中的搜索(Tab 内直接搜索)
TextField(
onSubmitted: (value) {
if (value.trim().isNotEmpty) {
context.read<ExploreProvider>().search(value.trim());
}
},
)
这种就地搜索的体验更流畅------用户不需要离开当前 Tab,搜索结果直接在输入框下方展示。
搜索页面
搜索页面是最完整的搜索入口,拥有独立的页面空间和专有的 Provider:
dart
class SearchScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final query =
ModalRoute.of(context)!.settings.arguments as String? ?? '';
final isLoggedIn = context.read<AuthProvider>().isLoggedIn;
return ChangeNotifierProvider(
create: (_) => RepoSearchProvider(
context.read<AtomGitApiClient>(),
)..search(query),
child: _SearchBody(query: query, isLoggedIn: isLoggedIn),
);
}
}
搜索页面接收上一页传来的 query 参数,在创建 Provider 时立即通过 ..search(query) 触发首次搜索。如果 query 为空,Provider 不会发起 API 请求(search() 方法内部有空值检查)。
搜索 API 的设计
搜索 API 使用 AtomGit 的仓库搜索端点:
dart
final response = await _apiClient.get(
'/search/repositories',
queryParams: {
'q': query,
'sort': 'stars',
'order': 'desc',
'per_page': '30',
'page': page.toString(),
},
);
查询参数详解
q(查询关键词):这是核心参数。AtomGit 的搜索语法支持多种限定符:
- 关键词搜索:
flutter匹配仓库名和描述中的 flutter - 语言过滤:
language:dart只搜索 Dart 项目 - 组合搜索:
flutter language:dart stars:>100搜索星级超过 100 的 Dart Flutter 项目
当前的实现使用纯文本搜索(用户输入什么就发什么),但架构上支持未来扩展高级搜索语法。
sort(排序字段):支持三种排序依据:
| 值 | 排序依据 | 适用场景 |
|---|---|---|
stars |
按 Star 数量 | 查找热门项目 |
forks |
按 Fork 数量 | 查找活跃项目 |
updated |
按最近更新时间 | 查找活跃维护的项目 |
order(排序方向) :desc(降序)或 asc(升序)。默认使用降序,将最热/最新的仓库排在最前面。
per_page(每页数量):设置为 30。这是在性能和用户体验之间的平衡------太少会增加请求次数,太多会加长单次加载时间。
page(页码):从 1 开始计数,用于分页加载。
API 响应的数据提取
搜索 API 的响应结构具有特殊性------结果包裹在 items 数组中:
json
{
"data": {
"total_count": 150,
"incomplete_results": false,
"items": [
{
"id": 12345,
"full_name": "flutter/flutter",
"stargazers_count": "150000",
// ...
}
]
}
}
项目使用的 parseList 安全解析函数自动处理这种结构:
dart
final items = parseList<dynamic>(response.data, 'items') ?? [];
_repositories = items
.whereType<Map<String, dynamic>>()
.map(Repository.fromJson)
.toList();
parseList 在 response.data 这个 Map 中查找 items 键,提取列表。然后再通过 whereType 过滤掉非 Map 元素(防止 API 返回异常数据导致崩溃),最后用 Repository.fromJson 转换。
RepoSearchProvider 的完整实现
dart
class RepoSearchProvider extends ChangeNotifier {
final AtomGitApiClient _apiClient;
List<Repository> _repositories = [];
bool _isLoading = false;
String? _error;
String _currentQuery = '';
bool _hasMore = false;
int _page = 1;
// 公开 getter,使用 UnmodifiableListView 防止外部修改
List<Repository> get repositories =>
List.unmodifiable(_repositories);
bool get isLoading => _isLoading;
String? get error => _error;
String get currentQuery => _currentQuery;
bool get hasMore => _hasMore;
}
search 方法(首次搜索/重新搜索)
dart
Future<void> search(String query) async {
// 空查询不发起请求
if (query.trim().isEmpty) return;
_currentQuery = query.trim();
_page = 1;
_isLoading = true;
_error = null;
notifyListeners();
try {
final response = await _apiClient.get(
'/search/repositories',
queryParams: {
'q': _currentQuery,
'sort': 'stars',
'order': 'desc',
'per_page': '30',
'page': '1',
},
);
final items =
parseList<dynamic>(response.data, 'items') ?? [];
_repositories = items
.whereType<Map<String, dynamic>>()
.map(Repository.fromJson)
.toList();
// 判断是否还有更多数据
_hasMore = _repositories.length >= 30;
} on ApiException catch (e) {
_error = e.message;
} catch (e) {
_error = '搜索失败: $e';
} finally {
_isLoading = false;
notifyListeners();
}
}
执行顺序:
- 记录查询词(
_currentQuery)、重置页码(_page = 1) - 设置 loading 状态,清除旧错误
- 发起 API 请求
- 安全解析响应,替换结果列表
- 通过返回数量是否等于 per_page 来判断是否有下一页
loadMore 方法(无限滚动)
dart
Future<void> loadMore() async {
if (_isLoading || !_hasMore) return;
_page++;
_isLoading = true;
notifyListeners();
try {
final response = await _apiClient.get(
'/search/repositories',
queryParams: {
'q': _currentQuery,
'sort': 'stars',
'order': 'desc',
'per_page': '30',
'page': _page.toString(),
},
);
final items =
parseList<dynamic>(response.data, 'items') ?? [];
final newRepos = items
.whereType<Map<String, dynamic>>()
.map(Repository.fromJson)
.toList();
_repositories.addAll(newRepos);
_hasMore = newRepos.length >= 30;
} on ApiException catch (e) {
_error = e.message;
_page--; // 翻页失败时回退页码
} catch (e) {
_page--;
} finally {
_isLoading = false;
notifyListeners();
}
}
页码回退是 loadMore 最关键的设计细节。
假设没有回退:用户滚动到底部触发 loadMore → _page 从 2 变为 3 → API 请求失败(网络抖动)→ _page 停留在 3。网络恢复后用户再次滚动 → loadMore 请求第 3 页 → 第 2 页的数据永远丢失。
有回退时:API 请求失败 → _page-- 回到 2 → 下次重试从第 2 页开始 → 数据完整。
无限滚动的 ScrollController 实现
dart
class _SearchBodyState extends State<_SearchBody> {
final _scrollController = ScrollController();
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
final provider = context.read<RepoSearchProvider>();
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200 &&
provider.hasMore &&
!provider.isLoading) {
provider.loadMore();
}
}
}
触发条件有三个:
- 滚动到距底部 200px :
pixels >= maxScrollExtent - 200。200px 的预加载距离让用户感觉不到加载延迟 - 还有更多数据 :
provider.hasMore。没有更多数据时不发起无效请求 - 不在加载中 :
!provider.isLoading。防止重复触发(在加载完成前用户可能多次滚动到底部)
为什么需要 dispose ScrollController?
ScrollController 在 Widget 销毁后如果仍然存活,其 listener 可能会尝试访问已销毁的 Widget 的 context,导致内存泄漏或运行时错误。在 dispose 中移除 listener 并销毁 controller 是防止这类问题的标准做法。
搜索 UI 状态管理
dart
Widget _buildBody(
BuildContext context, RepoSearchProvider provider) {
// 状态 1:错误(无缓存数据)
if (provider.error != null && provider.repositories.isEmpty) {
return ErrorRetryWidget(
message: provider.error!,
onRetry: () => provider.search(provider.currentQuery),
);
}
// 状态 2:首次加载中
if (provider.isLoading && provider.repositories.isEmpty) {
return const LoadingIndicator(message: '搜索中...');
}
// 状态 3:空结果
if (provider.repositories.isEmpty && !provider.isLoading) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.search_off, size: 64, color: Colors.grey),
SizedBox(height: 16),
Text('未找到仓库'),
SizedBox(height: 8),
Text('试试其他关键词',
style: TextStyle(color: Colors.grey)),
],
),
);
}
// 状态 4:结果列表
return ListView.builder(
controller: _scrollController,
itemCount: provider.repositories.length +
(provider.hasMore ? 1 : 0),
itemBuilder: (context, index) {
if (index >= provider.repositories.length) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
final repo = provider.repositories[index];
return _buildRepoItem(repo);
},
);
}
四种状态的展示逻辑:
- 有错误且列表为空:展示 ErrorRetryWidget。但如果有旧数据(之前搜索成功),不展示错误------用户在旧结果上继续浏览比看错误页面好
- 加载中且列表为空:展示 LoadingIndicator。如果列表已有数据(loadMore 场景),不切换为全屏 loading
- 加载完成但列表为空:展示"未找到仓库",引导用户换关键词
- 有数据:展示结果列表,底部根据 hasMore 决定是否显示加载指示器
登录状态感知
搜索页面需要检测登录状态,未登录时引导登录:
dart
if (!widget.isLoggedIn && query.isEmpty) {
return _buildLoginPrompt(context);
}
这里的判断条件是 !isLoggedIn && query.isEmpty。如果用户从首页传入了一个搜索词(query 不为空),即使未登录也显示搜索结果------让访客体验搜索功能。
但如果 query 为空且未登录,展示登录引导而非空白页面:
dart
Widget _buildLoginPrompt(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.search, size: 64,
color: Colors.grey[400]),
const SizedBox(height: 16),
Text('搜索 AtomGit 仓库',
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Text('登录后可搜索并发现更多仓库',
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: Colors.grey)),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: () =>
Navigator.pushNamed(context, '/login'),
icon: const Icon(Icons.login),
label: const Text('立即登录'),
),
],
),
),
);
}
搜索流程的完整时序
用户输入关键词 → onSubmitted
→ (如果来自首页) Navigator.pushNamed('/search', query)
→ SearchScreen 构建
→ ChangeNotifierProvider 创建 RepoSearchProvider
→ provider.search(query)
→ notifyListeners() → UI 显示 loading
→ API 请求 /search/repositories?q=xxx
→ 解析响应 → 更新 _repositories
→ notifyListeners() → UI 显示结果列表
→ 用户滚动到底部
→ _onScroll 检测触发
→ provider.loadMore()
→ _page++ → API 请求第 N 页
→ 追加到 _repositories
→ notifyListeners() → ListView 追加新行
搜索性能考量
搜索的性能瓶颈主要在 API 请求延迟。客户端做了以下优化:
1. 防重复请求 :_isLoading 守卫防止用户快速滚动触发多次 loadMore
2. 预加载触发点 :距离底部 200px 触发,而非到底部才触发,用户感知延迟更小
3. 适中的 per_page:30 条一页,既能填满 3-5 屏,又不会因为单页数据过大而增加解析时间
与 ExploreTab 搜索的关系
ExploreTab 有自己的搜索实现,不使用 RepoSearchProvider。两者对比:
| 特性 | SearchScreen | ExploreTab |
|---|---|---|
| Provider | RepoSearchProvider(独立) | ExploreTab 方法(内嵌) |
| 分页 | 标准分页 _page |
total_count 判断 |
| 路由 | 独立页面(全屏) | Tab 内(部分区域) |
| 搜索历史 | 无 | 无 |
| 登录要求 | 可选(引导登录) | 必须登录 |
两种实现并存是因为它们的交互模式不同。独立的搜索页面更利于沉浸式搜索体验,Tab 内搜索则适合快速查找。