在开源鸿蒙跨平台生态中,Flutter 凭借 “一次编码、多端运行” 的特性成为开发者首选框架。而网络请求作为跨平台应用的核心能力,直接影响工具的实用性与用户体验。本文结合鸿蒙生态适配需求,以 “GitCode 口袋搜索工具” 为实战案例,从网络请求封装、鸿蒙特性适配、UI 组件设计到状态管理,完整实现一个可直接运行在鸿蒙设备上的跨平台工具,同时规避适配过程中的高频踩坑点。

一、项目核心目标与技术选型

1. 核心功能定位

  • 支持 GitCode 仓库关键词搜索、作者筛选;
  • 展示仓库基本信息(名称、描述、星标数、更新时间);
  • 适配鸿蒙手机、平板等多设备屏幕;
  • 支持离线缓存搜索历史,无网络时可查看。

2. 技术栈选型

技术模块 选型方案 选型理由
网络请求 Dio + 拦截器 鸿蒙生态兼容良好,支持请求拦截、响应解析、超时重试
状态管理 Provider 轻量易上手,适配鸿蒙设备性能,避免过度封装
本地缓存 Hive 高性能 NoSQL 数据库,鸿蒙平台适配成熟,比 SharedPreferences 更灵活
UI 适配 MediaQuery + LayoutBuilder 适配鸿蒙多设备屏幕尺寸,兼容手机 / 平板布局
下拉刷新 PullToRefresh 鸿蒙生态常用下拉刷新组件,交互体验贴合原生

二、基础环境搭建与鸿蒙适配配置

1. 工程创建与依赖引入

首先创建 Flutter 工程,在 pubspec.yaml 中添加核心依赖,同时配置鸿蒙平台所需权限:

yaml

name: gitcode_search_tool
description: 鸿蒙生态下的 GitCode 口袋搜索工具
environment:
  sdk: '>=3.0.0 <4.0.0'

dependencies:
  flutter:
    sdk: flutter
  dio: ^5.4.0 # 网络请求
  provider: ^6.1.1 # 状态管理
  hive: ^2.2.3 # 本地缓存
  hive_flutter: ^1.1.0 # Hive Flutter 适配
  pull_to_refresh: ^2.0.0 # 下拉刷新
  intl: ^0.19.0 # 时间格式化
  connectivity_plus: ^4.0.1 # 网络状态监听(鸿蒙适配)

dev_dependencies:
  flutter_test:
    sdk: flutter
  build_runner: ^2.4.6
  hive_generator: ^2.0.1

# 鸿蒙平台权限配置(关键)
flutter:
  uses-material-design: true
  assets:
    - assets/gitcode_logo.png # 应用图标
  # 鸿蒙平台特有配置
  plugin:
    platforms:
      openharmony:
        package: com.example.gitcode_search_tool
        pluginClass: GitcodeSearchToolPlugin

2. 鸿蒙权限申请

在 android/app/src/main/AndroidManifest.xml(鸿蒙兼容 Android 权限配置)中添加网络权限:

xml

<!-- 网络权限 -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- 网络状态权限 -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- 存储权限(缓存搜索历史) -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

三、核心模块实现:从网络请求到本地缓存

1. 网络请求封装(适配鸿蒙网络特性)

基于 Dio 封装通用网络工具类,针对鸿蒙平台优化超时时间、添加网络状态监听,避免无网络时频繁报错:

dart

import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:gitcode_search_tool/utils/constants.dart';

// 网络请求工具类(单例)
class HttpUtil {
  static final HttpUtil _instance = HttpUtil._internal();
  factory HttpUtil() => _instance;
  late Dio _dio;

  HttpUtil._internal() {
    // 初始化 Dio 配置
    _dio = Dio(BaseOptions(
      baseUrl: 'https://gitcode.net/api/v4', // GitCode 开放 API
      connectTimeout: const Duration(seconds: 15), // 鸿蒙设备网络波动大,延长超时
      receiveTimeout: const Duration(seconds: 10),
      headers: {
        'User-Agent': 'GitCodeSearchTool/1.0 (HarmonyOS)', // 标识鸿蒙设备
      },
    ));

    // 添加请求拦截器(日志打印、参数统一处理)
    _dio.interceptors.add(LogInterceptor(responseBody: true));

    // 添加鸿蒙网络状态拦截器
    _dio.interceptors.add(InterceptorsWrapper(
      onRequest: (options, handler) async {
        // 检查网络状态
        final connectivityResult = await (Connectivity().checkConnectivity());
        if (connectivityResult == ConnectivityResult.none) {
          handler.reject(DioException(
            requestOptions: options,
            message: '当前无网络,请检查鸿蒙设备网络设置',
          ));
          return;
        }
        handler.next(options);
      },
    ));
  }

  // 搜索仓库 API
  Future<List<RepoModel>> searchRepos({
    required String keyword,
    int page = 1,
    int perPage = 20,
  }) async {
    try {
      final response = await _dio.get(
        '/search/repositories',
        queryParameters: {
          'q': keyword,
          'page': page,
          'per_page': perPage,
        },
      );

      // 解析响应数据
      final List<dynamic> items = response.data['items'];
      return items.map((item) => RepoModel.fromJson(item)).toList();
    } on DioException catch (e) {
      throw Exception('搜索失败:${e.message ?? '未知错误'}');
    }
  }
}

// 仓库模型类
class RepoModel {
  final String name;
  final String description;
  final int stargazersCount;
  final String updatedAt;
  final String htmlUrl;
  final String ownerLogin;

  RepoModel({
    required this.name,
    required this.description,
    required this.stargazersCount,
    required this.updatedAt,
    required this.htmlUrl,
    required this.ownerLogin,
  });

  // 从 JSON 解析
  factory RepoModel.fromJson(Map<String, dynamic> json) {
    return RepoModel(
      name: json['name'] ?? '',
      description: json['description'] ?? '无描述',
      stargazersCount: json['stargazers_count'] ?? 0,
      updatedAt: json['updated_at'] ?? '',
      htmlUrl: json['html_url'] ?? '',
      ownerLogin: json['owner']['login'] ?? '未知作者',
    );
  }
}

2. 本地缓存实现(Hive 适配鸿蒙)

使用 Hive 缓存搜索历史,适配鸿蒙设备存储特性,避免频繁读写文件导致的性能问题:

dart

import 'package:hive/hive.dart';
import 'package:hive_flutter/hive_flutter.dart';

// 初始化 Hive(在 main 函数中调用)
Future<void> initHive() async {
  await Hive.initFlutter();
  // 注册适配器(如需存储自定义对象)
  Hive.registerAdapter(SearchHistoryAdapter());
  // 打开搜索历史盒子(鸿蒙设备存储路径自动适配)
  await Hive.openBox<SearchHistory>('search_history');
}

// 搜索历史模型
@HiveType(typeId: 0)
class SearchHistory extends HiveObject {
  @HiveField(0)
  String keyword; // 搜索关键词

  @HiveField(1)
  DateTime searchTime; // 搜索时间

  SearchHistory({required this.keyword, required this.searchTime});
}

// 搜索历史工具类
class SearchHistoryUtil {
  static Box<SearchHistory> get _box => Hive.box<SearchHistory>('search_history');

  // 添加搜索历史
  static void addHistory(String keyword) {
    // 去重:如果已存在相同关键词,先删除再添加
    _box.deleteAll(
      _box.values.where((item) => item.keyword == keyword).map((e) => e.key),
    );
    _box.add(SearchHistory(keyword: keyword, searchTime: DateTime.now()));
  }

  // 获取所有搜索历史(按时间倒序)
  static List<SearchHistory> getHistoryList() {
    final list = _box.values.toList();
    list.sort((a, b) => b.searchTime.compareTo(a.searchTime));
    return list;
  }

  // 清空搜索历史
  static void clearHistory() {
    _box.clear();
  }
}

3. 状态管理封装(Provider 适配鸿蒙性能)

使用 Provider 管理搜索状态、缓存状态,避免鸿蒙设备因过度重建导致的卡顿:

dart

import 'package:flutter/foundation.dart';
import 'package:gitcode_search_tool/utils/http_util.dart';
import 'package:gitcode_search_tool/utils/search_history_util.dart';
import 'package:gitcode_search_tool/models/repo_model.dart';

class SearchProvider extends ChangeNotifier {
  // 搜索状态
  bool _isLoading = false;
  List<RepoModel> _repoList = [];
  String? _errorMsg;
  int _currentPage = 1;
  String _currentKeyword = '';

  // 暴露只读状态
  bool get isLoading => _isLoading;
  List<RepoModel> get repoList => _repoList;
  String? get errorMsg => _errorMsg;

  // 搜索仓库
  Future<void> searchRepos(String keyword) async {
    if (keyword.trim().isEmpty) {
      _errorMsg = '请输入搜索关键词';
      notifyListeners();
      return;
    }

    // 更新状态
    _isLoading = true;
    _currentKeyword = keyword;
    _currentPage = 1;
    _errorMsg = null;
    notifyListeners();

    try {
      // 网络请求
      final repos = await HttpUtil().searchRepos(keyword: keyword);
      _repoList = repos;
      // 添加到搜索历史
      SearchHistoryUtil.addHistory(keyword);
    } catch (e) {
      _errorMsg = e.toString();
      _repoList = [];
    } finally {
      _isLoading = false;
      notifyListeners();
    }
  }

  // 加载更多
  Future<void> loadMore() async {
    if (_isLoading || _currentKeyword.isEmpty) return;

    _isLoading = true;
    _currentPage++;
    notifyListeners();

    try {
      final repos = await HttpUtil().searchRepos(
        keyword: _currentKeyword,
        page: _currentPage,
      );
      if (repos.isEmpty) {
        _errorMsg = '没有更多结果了';
      } else {
        _repoList.addAll(repos);
      }
    } catch (e) {
      _errorMsg = '加载更多失败:${e.toString()}';
      _currentPage--; // 加载失败回退页码
    } finally {
      _isLoading = false;
      notifyListeners();
    }
  }

  // 清空搜索结果
  void clearResult() {
    _repoList = [];
    _currentKeyword = '';
    _errorMsg = null;
    notifyListeners();
  }
}

四、UI 布局实现:适配鸿蒙多设备屏幕

1. 首页布局设计(兼容手机 / 平板)

使用 LayoutBuilder 和 MediaQuery 适配鸿蒙设备屏幕,平板端展示双列布局,手机端展示单列:

dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import 'package:intl/intl.dart';
import 'package:gitcode_search_tool/providers/search_provider.dart';
import 'package:gitcode_search_tool/utils/search_history_util.dart';
import 'package:gitcode_search_tool/models/search_history.dart';

class HomePage extends StatefulWidget {
  const HomePage({super.key});

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  final TextEditingController _searchController = TextEditingController();
  final RefreshController _refreshController = RefreshController(initialRefresh: false);

  // 判断是否为平板(鸿蒙设备屏幕宽度 >= 600 视为平板)
  bool get isTablet => MediaQuery.of(context).size.width >= 600;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('GitCode 口袋搜索'),
        centerTitle: true, // 鸿蒙设备标题居中更符合原生体验
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          children: [
            // 搜索框
            _buildSearchBar(),
            const SizedBox(height: 16),
            // 搜索历史(无搜索结果时显示)
            Consumer<SearchProvider>(
              builder: (context, provider, child) {
                if (provider.repoList.isEmpty && provider.currentKeyword.isEmpty) {
                  return _buildSearchHistory();
                }
                return const SizedBox.shrink();
              },
            ),
            const SizedBox(height: 16),
            // 搜索结果
            Expanded(
              child: Consumer<SearchProvider>(
                builder: (context, provider, child) {
                  if (provider.isLoading && provider.repoList.isEmpty) {
                    return const Center(child: CircularProgressIndicator());
                  }
                  if (provider.errorMsg != null && provider.repoList.isEmpty) {
                    return Center(child: Text(provider.errorMsg!));
                  }
                  return SmartRefresher(
                    controller: _refreshController,
                    enablePullDown: true,
                    enablePullUp: true,
                    onRefresh: () async {
                      await provider.searchRepos(provider.currentKeyword);
                      _refreshController.refreshCompleted();
                    },
                    onLoading: () async {
                      await provider.loadMore();
                      _refreshController.loadComplete();
                    },
                    child: GridView.builder(
                      gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
                        crossAxisCount: isTablet ? 2 : 1, // 平板双列,手机单列
                        crossAxisSpacing: 16,
                        mainAxisSpacing: 16,
                        childAspectRatio: isTablet ? 1.5 : 3, // 平板宽高比,手机长条形
                      ),
                      itemCount: provider.repoList.length,
                      itemBuilder: (context, index) {
                        final repo = provider.repoList[index];
                        return _buildRepoCard(repo);
                      },
                    ),
                  );
                },
              ),
            ),
          ],
        ),
      ),
    );
  }

  // 搜索框组件
  Widget _buildSearchBar() {
    return Row(
      children: [
        Expanded(
          child: TextField(
            controller: _searchController,
            decoration: InputDecoration(
              hintText: '搜索 GitCode 仓库、作者',
              border: OutlineInputBorder(
                borderRadius: BorderRadius.circular(8),
              ),
              prefixIcon: const Icon(Icons.search),
            ),
            onSubmitted: (value) {
              Provider.of<SearchProvider>(context, listen: false)
                  .searchRepos(value);
              // 隐藏鸿蒙设备键盘
              FocusScope.of(context).unfocus();
            },
          ),
        ),
        const SizedBox(width: 8),
        ElevatedButton(
          onPressed: () {
            final keyword = _searchController.text.trim();
            Provider.of<SearchProvider>(context, listen: false)
                .searchRepos(keyword);
            FocusScope.of(context).unfocus();
          },
          child: const Text('搜索'),
        ),
      ],
    );
  }

  // 搜索历史组件
  Widget _buildSearchHistory() {
    final historyList = SearchHistoryUtil.getHistoryList();
    if (historyList.isEmpty) {
      return const Center(child: Text('暂无搜索历史,开始你的第一次搜索吧~'));
    }
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: [
            const Text(
              '搜索历史',
              style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
            ),
            TextButton(
              onPressed: () {
                SearchHistoryUtil.clearHistory();
                setState(() {});
              },
              child: const Text('清空'),
            ),
          ],
        ),
        const SizedBox(height: 8),
        Wrap(
          spacing: 8,
          runSpacing: 8,
          children: historyList.map((history) {
            return Chip(
              label: Text(history.keyword),
              onDeleted: () {
                history.delete();
                setState(() {});
              },
            );
          }).toList(),
        ),
      ],
    );
  }

  // 仓库卡片组件
  Widget _buildRepoCard(RepoModel repo) {
    return Card(
      elevation: 2,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(8),
      ),
      child: Padding(
        padding: const EdgeInsets.all(12.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: [
            Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  repo.name,
                  style: const TextStyle(
                    fontSize: 16,
                    fontWeight: FontWeight.bold,
                  ),
                  maxLines: 1,
                  overflow: TextOverflow.ellipsis,
                ),
                const SizedBox(height: 4),
                Text(
                  repo.description,
                  style: TextStyle(
                    fontSize: 14,
                    color: Colors.grey[600],
                  ),
                  maxLines: 2,
                  overflow: TextOverflow.ellipsis,
                ),
              ],
            ),
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                Row(
                  children: [
                    const Icon(Icons.star, size: 14, color: Colors.amber),
                    const SizedBox(width: 4),
                    Text('${repo.stargazersCount}'),
                  ],
                ),
                Text(
                  '作者:${repo.ownerLogin}',
                  style: const TextStyle(fontSize: 12),
                ),
                Text(
                  DateFormat('yyyy-MM-dd').format(
                    DateTime.parse(repo.updatedAt),
                  ),
                  style: const TextStyle(fontSize: 12, color: Colors.grey),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }

  @override
  void dispose() {
    _searchController.dispose();
    _refreshController.dispose();
    super.dispose();
  }
}

2. 鸿蒙特性适配细节

  • 屏幕适配:通过 MediaQuery 判断屏幕宽度,自动切换单 / 双列布局;
  • 键盘交互:搜索完成后自动隐藏键盘,符合鸿蒙设备操作习惯;
  • 加载状态:使用原生 CircularProgressIndicator,避免自定义组件在鸿蒙上渲染异常;
  • 卡片样式:降低卡片阴影层级(elevation: 2),适配鸿蒙设备视觉风格。

五、鸿蒙适配避坑指南

雷区 表现 解决方案
网络请求超时频繁 鸿蒙设备网络波动时,请求经常超时 延长 Dio 超时时间(15 秒),添加网络状态监听,无网络时给出明确提示
存储权限申请失败 Hive 无法写入数据,缓存失效 在 AndroidManifest.xml 中添加存储权限,鸿蒙设备需手动授予权限
屏幕适配错乱 平板端布局挤压,手机端留白过多 使用 LayoutBuilder 和 MediaQuery 动态调整布局参数,避免固定宽高
组件渲染异常 部分自定义组件在鸿蒙上显示畸形 优先使用 Flutter 原生组件,避免复杂自定义绘制,测试时覆盖鸿蒙多设备
下拉刷新卡顿 鸿蒙设备下拉刷新时掉帧 使用 pull_to_refresh 等成熟组件,关闭过度动画,优化列表渲染性能

六、项目打包与鸿蒙设备测试

1. 打包鸿蒙应用

Flutter 打包鸿蒙应用需借助 DevEco Studio,步骤如下:

  1. 在 Flutter 工程中执行 flutter build appbundle 生成 Android App Bundle;
  2. 打开 DevEco Studio,导入生成的 App Bundle;
  3. 配置鸿蒙应用签名、包名,确保与 Flutter 工程一致;
  4. 编译生成鸿蒙安装包(.hap 文件),安装到鸿蒙设备或模拟器。

2. 测试要点

  • 测试网络请求:验证搜索、加载更多功能在鸿蒙网络环境下的稳定性;
  • 测试缓存功能:断网后查看搜索历史是否正常显示;
  • 测试屏幕适配:在鸿蒙手机、平板上分别测试布局效果;
  • 测试权限申请:首次打开时验证权限弹窗是否正常弹出。

七、总结与扩展方向

本文基于 Flutter 实现了鸿蒙生态下的 GitCode 口袋搜索工具,核心亮点在于:

  1. 针对鸿蒙设备特性优化网络请求、存储缓存,提升适配稳定性;
  2. 采用响应式布局,兼容鸿蒙多设备屏幕;
  3. 封装通用工具类,降低鸿蒙生态适配成本。

后续可扩展的方向:

  • 支持仓库详情查看、跳转 GitCode 官网;
  • 新增仓库收藏功能,同步到本地缓存;
  • 适配鸿蒙手表等更多设备形态;
  • 集成鸿蒙原生能力(如推送、分享)。

在开源鸿蒙跨平台生态中,Flutter 凭借强大的兼容性和开发效率,成为连接多端的重要桥梁。本文的实战案例可为开发者提供鸿蒙适配的完整思路,无论是工具类应用还是复杂业务应用,都可借鉴这套 “网络封装 + 状态管理 + 多端适配” 的架构模式。如果在适配过程中遇到问题,欢迎在评论区交流~

要不要我帮你整理一份鸿蒙设备 Flutter 适配 checklist,包含权限配置、网络优化、UI 适配等关键要点,方便你快速排查适配问题?

Logo

社区规范:仅讨论OpenHarmony相关问题。

更多推荐