AtomGit Flutter鸿蒙客户端:仓库搜索

概述

搜索是代码托管平台最核心的功能之一。用户通过搜索发现感兴趣的开源项目、查找特定技术栈的代码库、或者评估技术方案的社区活跃度。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();

parseListresponse.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;
}
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();
  }
}

执行顺序:

  1. 记录查询词(_currentQuery)、重置页码(_page = 1
  2. 设置 loading 状态,清除旧错误
  3. 发起 API 请求
  4. 安全解析响应,替换结果列表
  5. 通过返回数量是否等于 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();
    }
  }
}

触发条件有三个:

  1. 滚动到距底部 200pxpixels >= maxScrollExtent - 200。200px 的预加载距离让用户感觉不到加载延迟
  2. 还有更多数据provider.hasMore。没有更多数据时不发起无效请求
  3. 不在加载中!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);
    },
  );
}

四种状态的展示逻辑:

  1. 有错误且列表为空:展示 ErrorRetryWidget。但如果有旧数据(之前搜索成功),不展示错误------用户在旧结果上继续浏览比看错误页面好
  2. 加载中且列表为空:展示 LoadingIndicator。如果列表已有数据(loadMore 场景),不切换为全屏 loading
  3. 加载完成但列表为空:展示"未找到仓库",引导用户换关键词
  4. 有数据:展示结果列表,底部根据 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 内搜索则适合快速查找。

相关推荐
GitCode官方1 小时前
开源鸿蒙跨平台直播|Flutter 鸿蒙化进阶:三方库适配与性能调优实战
flutter·华为·开源·harmonyos·atomgit
坚果派·白晓明1 小时前
鸿蒙PC三方库使用:使用 AtomCode + Skills 自动完成鸿蒙化三方库Protobuf集成
华为·harmonyos·c/c++三方库·c/c++三方库适配
互联网散修1 小时前
鸿蒙实战:图片编辑器——文字功能完全实现
华为·编辑器·harmonyos·图片编辑添加文字
小雨下雨的雨2 小时前
通过鸿蒙PC Electron框架技术完成-井字棋游戏 - 实现详解
前端·javascript·游戏·华为·electron·鸿蒙
zhangfeng11332 小时前
deepseek 适配了 华为升腾 是不是 用了类似Megatron-LM deepSpeed框架的??
人工智能·华为
AI_零食2 小时前
甄嬛人物日志-朗读升级 - 鸿蒙PC Electron框架完整技术实现指南
前端·学习·华为·electron·鸿蒙·鸿蒙系统
李二。2 小时前
AI翻译通(鸿蒙原生)—— 鸿蒙Next声明式UI翻译工具实战
人工智能·ui·harmonyos
Dream-Y.ocean2 小时前
[鸿蒙PC三方库适配实战] 跨平台媒体播放器 mpv 的 鸿蒙PC 平台迁移实践
华为·harmonyos
●VON2 小时前
AtomGit Flutter鸿蒙客户端:Issue管理
flutter·华为·架构·harmonyos·鸿蒙·issue