Flutter for OpenHarmony搜索功能实战指南:从0到1打造高性能搜索体验

欢迎加入开源鸿蒙跨平台社区: https://openharmonycrossplatform.csdn.net

一、引言:搜索功能的重要性

在移动应用开发中,搜索功能是提升用户体验的关键组件。随着数据量的增长,用户需要更高效的方式找到所需信息。对于Flutter for OpenHarmony跨平台应用而言,一个优秀的搜索功能不仅要满足基本的查找需求,还要兼顾性能、美观和用户体验。

本文将详细介绍如何在Flutter for OpenHarmony应用中实现一个完整的搜索功能,包括UI设计、搜索逻辑、空状态提示和性能优化。我们将使用真实的网络数据,通过实战案例展示如何构建一个高性能、用户友好的搜索系统。

二、技术选型与架构设计

2.1 技术栈选择

  • Flutter for OpenHarmony:跨平台UI框架,支持一次开发多端部署
  • Dio:HTTP客户端库,用于网络请求
  • StatefulWidget:状态管理,实现搜索状态的实时更新
  • Timer:防抖处理,优化搜索性能

2.2 架构设计

我们将采用分层架构设计,将搜索功能分为以下几个模块:

  1. UI层:搜索框、列表展示、空状态提示
  2. 业务逻辑层:搜索逻辑、防抖处理、数据筛选
  3. 数据层:网络请求、数据缓存

三、实战开发:实现搜索功能

3.1 项目初始化

首先,我们需要创建一个Flutter for OpenHarmony项目,并添加必要的依赖。在pubspec.yaml文件中添加:

dependencies:
  flutter:
    sdk: flutter
  dio: ^5.4.0

3.2 数据模型与网络请求

我们将使用JSONPlaceholder提供的Todo API作为数据源。首先定义数据模型:

class TodoItem {
  final int userId;
  final int id;
  final String title;
  final bool completed;

  TodoItem({
    required this.userId,
    required this.id,
    required this.title,
    required this.completed,
  });

  factory TodoItem.fromJson(Map<String, dynamic> json) {
    return TodoItem(
      userId: json['userId'] as int,
      id: json['id'] as int,
      title: json['title'] as String,
      completed: json['completed'] as bool,
    );
  }

  Map<String, dynamic> toJson() {
    return {
      'userId': userId,
      'id': id,
      'title': title,
      'completed': completed,
    };
  }
}

接下来实现网络请求服务:

class TodoService {
  static const String _baseUrl = 'https://jsonplaceholder.typicode.com';
  final Dio _dio = Dio(
    BaseOptions(
      connectTimeout: const Duration(seconds: 30),
      receiveTimeout: const Duration(seconds: 30),
      sendTimeout: const Duration(seconds: 30),
      headers: {
        'Content-Type': 'application/json',
        'Accept': 'application/json',
      },
    ),
  );

  Future<List<TodoItem>> getTodos() async {
    try {
      final response = await _dio.get('$_baseUrl/todos');
      final List<dynamic> data = response.data;
      return data.map((json) => TodoItem.fromJson(json)).toList();
    } on DioException catch (e) {
      throw Exception(_handleError(e));
    }
  }

  Future<TodoItem> getTodoById(int id) async {
    try {
      final response = await _dio.get('$_baseUrl/todos/$id');
      return TodoItem.fromJson(response.data);
    } on DioException catch (e) {
      throw Exception(_handleError(e));
    }
  }

  String _handleError(DioException e) {
    switch (e.type) {
      case DioExceptionType.connectionTimeout:
        return 'Connection timeout';
      case DioExceptionType.sendTimeout:
        return 'Send timeout';
      case DioExceptionType.receiveTimeout:
        return 'Receive timeout';
      case DioExceptionType.badResponse:
        return 'Server error: ${e.response?.statusCode}';
      case DioExceptionType.cancel:
        return 'Request cancelled';
      case DioExceptionType.connectionError:
        return 'Connection error';
      default:
        return 'Unknown error: ${e.message}';
    }
  }
}

3.3 搜索框UI设计

搜索框是搜索功能的入口,需要设计得美观且易用。我们将实现一个带有圆角、边框高亮和清除按钮的搜索框:

Widget _buildSearchBar() {
  return Container(
    margin: const EdgeInsets.all(12),
    decoration: BoxDecoration(
      color: Colors.grey.shade100,
      borderRadius: BorderRadius.circular(12),
      border: Border.all(
        color: _isSearching ? Colors.blue.shade300 : Colors.transparent,
        width: 1.5,
      ),
    ),
    child: TextField(
      controller: _searchController,
      decoration: InputDecoration(
        hintText: 'Search todos...',
        hintStyle: TextStyle(color: Colors.grey.shade500),
        prefixIcon: Icon(
          Icons.search,
          color: _isSearching ? Colors.blue : Colors.grey,
        ),
        suffixIcon: _isSearching
            ? IconButton(
                icon: const Icon(Icons.clear, color: Colors.grey),
                onPressed: _clearSearch,
              )
            : null,
        border: InputBorder.none,
        contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
      ),
      style: const TextStyle(fontSize: 15),
    ),
  );
}

3.4 搜索逻辑实现

搜索逻辑是搜索功能的核心,需要实现本地筛选和多条件搜索:

List<TodoItem> get _filteredTodos {
  if (_searchQuery.isEmpty) {
    return _todos;
  }
  return _todos.where((todo) {
    final titleMatch = todo.title.toLowerCase().contains(_searchQuery);
    final idMatch = todo.id.toString().contains(_searchQuery);
    final userIdMatch = todo.userId.toString().contains(_searchQuery);
    return titleMatch || idMatch || userIdMatch;
  }).toList();
}

3.5 防抖处理

为了优化搜索性能,我们需要实现防抖处理,避免频繁触发搜索:

void _onSearchChanged() {
  if (_debounceTimer?.isActive ?? false) {
    _debounceTimer!.cancel();
  }
  
  _debounceTimer = Timer(const Duration(milliseconds: 300), () {
    setState(() {
      _searchQuery = _searchController.text.toLowerCase().trim();
      _isSearching = _searchQuery.isNotEmpty;
    });
  });
}

3.6 空状态提示

当搜索结果为空时,需要提供友好的提示信息:

if (_filteredTodos.isEmpty) {
  if (_isSearching) {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Icon(
            Icons.search_off,
            size: 64,
            color: Colors.grey.shade400,
          ),
          const SizedBox(height: 16),
          Text(
            'No results found',
            style: TextStyle(
              fontSize: 18,
              color: Colors.grey.shade600,
              fontWeight: FontWeight.w500,
            ),
          ),
          const SizedBox(height: 8),
          Text(
            'Try different keywords: "$_searchQuery"',
            style: TextStyle(
              fontSize: 14,
              color: Colors.grey.shade500,
            ),
          ),
          const SizedBox(height: 24),
          OutlinedButton.icon(
            onPressed: _clearSearch,
            icon: const Icon(Icons.clear),
            label: const Text('Clear Search'),
          ),
        ],
      ),
    );
  }
  return Center(
    child: Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Icon(
          Icons.inbox_outlined,
          size: 64,
          color: Colors.grey.shade400,
        ),
        const SizedBox(height: 16),
        Text(
          'No data available',
          style: TextStyle(
            fontSize: 18,
            color: Colors.grey.shade600,
          ),
        ),
      ],
    ),
  );
}

四、完整代码实现

以下是完整的搜索功能实现代码:

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:dio/dio.dart';

void main() {
  runApp(const MyApp());
}

class TodoItem {
  final int userId;
  final int id;
  final String title;
  final bool completed;

  TodoItem({
    required this.userId,
    required this.id,
    required this.title,
    required this.completed,
  });

  factory TodoItem.fromJson(Map<String, dynamic> json) {
    return TodoItem(
      userId: json['userId'] as int,
      id: json['id'] as int,
      title: json['title'] as String,
      completed: json['completed'] as bool,
    );
  }

  Map<String, dynamic> toJson() {
    return {
      'userId': userId,
      'id': id,
      'title': title,
      'completed': completed,
    };
  }
}

class TodoService {
  static const String _baseUrl = 'https://jsonplaceholder.typicode.com';
  final Dio _dio = Dio(
    BaseOptions(
      connectTimeout: const Duration(seconds: 30),
      receiveTimeout: const Duration(seconds: 30),
      sendTimeout: const Duration(seconds: 30),
      headers: {
        'Content-Type': 'application/json',
        'Accept': 'application/json',
      },
    ),
  );

  Future<List<TodoItem>> getTodos() async {
    try {
      final response = await _dio.get('$_baseUrl/todos');
      final List<dynamic> data = response.data;
      return data.map((json) => TodoItem.fromJson(json)).toList();
    } on DioException catch (e) {
      throw Exception(_handleError(e));
    }
  }

  Future<TodoItem> getTodoById(int id) async {
    try {
      final response = await _dio.get('$_baseUrl/todos/$id');
      return TodoItem.fromJson(response.data);
    } on DioException catch (e) {
      throw Exception(_handleError(e));
    }
  }

  String _handleError(DioException e) {
    switch (e.type) {
      case DioExceptionType.connectionTimeout:
        return 'Connection timeout';
      case DioExceptionType.sendTimeout:
        return 'Send timeout';
      case DioExceptionType.receiveTimeout:
        return 'Receive timeout';
      case DioExceptionType.badResponse:
        return 'Server error: ${e.response?.statusCode}';
      case DioExceptionType.cancel:
        return 'Request cancelled';
      case DioExceptionType.connectionError:
        return 'Connection error';
      default:
        return 'Unknown error: ${e.message}';
    }
  }
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'OpenHarmony Todo List',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
        useMaterial3: true,
      ),
      home: const TodoListPage(),
    );
  }
}

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

  
  State<TodoListPage> createState() => _TodoListPageState();
}

class _TodoListPageState extends State<TodoListPage> {
  final TodoService _todoService = TodoService();
  List<TodoItem> _todos = [];
  bool _isLoading = true;
  String? _errorMessage;
  
  final TextEditingController _searchController = TextEditingController();
  String _searchQuery = '';
  Timer? _debounceTimer;
  bool _isSearching = false;

  
  void initState() {
    super.initState();
    _loadTodos();
    _searchController.addListener(_onSearchChanged);
  }

  
  void dispose() {
    _searchController.removeListener(_onSearchChanged);
    _searchController.dispose();
    _debounceTimer?.cancel();
    super.dispose();
  }

  void _onSearchChanged() {
    if (_debounceTimer?.isActive ?? false) {
      _debounceTimer!.cancel();
    }
    
    _debounceTimer = Timer(const Duration(milliseconds: 300), () {
      setState(() {
        _searchQuery = _searchController.text.toLowerCase().trim();
        _isSearching = _searchQuery.isNotEmpty;
      });
    });
  }

  void _clearSearch() {
    _searchController.clear();
    setState(() {
      _searchQuery = '';
      _isSearching = false;
    });
  }

  List<TodoItem> get _filteredTodos {
    if (_searchQuery.isEmpty) {
      return _todos;
    }
    return _todos.where((todo) {
      final titleMatch = todo.title.toLowerCase().contains(_searchQuery);
      final idMatch = todo.id.toString().contains(_searchQuery);
      final userIdMatch = todo.userId.toString().contains(_searchQuery);
      return titleMatch || idMatch || userIdMatch;
    }).toList();
  }

  Future<void> _loadTodos() async {
    setState(() {
      _isLoading = true;
      _errorMessage = null;
    });

    try {
      final todos = await _todoService.getTodos();
      setState(() {
        _todos = todos;
        _isLoading = false;
      });
    } catch (e) {
      setState(() {
        _errorMessage = e.toString();
        _isLoading = false;
      });
    }
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text('Todo List'),
        actions: [
          IconButton(
            icon: const Icon(Icons.refresh),
            onPressed: _loadTodos,
          ),
        ],
      ),
      body: Column(
        children: [
          _buildSearchBar(),
          Expanded(child: _buildBody()),
        ],
      ),
    );
  }

  Widget _buildSearchBar() {
    return Container(
      margin: const EdgeInsets.all(12),
      decoration: BoxDecoration(
        color: Colors.grey.shade100,
        borderRadius: BorderRadius.circular(12),
        border: Border.all(
          color: _isSearching ? Colors.blue.shade300 : Colors.transparent,
          width: 1.5,
        ),
      ),
      child: TextField(
        controller: _searchController,
        decoration: InputDecoration(
          hintText: 'Search todos...',
          hintStyle: TextStyle(color: Colors.grey.shade500),
          prefixIcon: Icon(
            Icons.search,
            color: _isSearching ? Colors.blue : Colors.grey,
          ),
          suffixIcon: _isSearching
              ? IconButton(
                  icon: const Icon(Icons.clear, color: Colors.grey),
                  onPressed: _clearSearch,
                )
              : null,
          border: InputBorder.none,
          contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
        ),
        style: const TextStyle(fontSize: 15),
      ),
    );
  }

  Widget _buildBody() {
    if (_isLoading) {
      return const Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            CircularProgressIndicator(),
            SizedBox(height: 16),
            Text('Loading data from network...'),
          ],
        ),
      );
    }

    if (_errorMessage != null) {
      return Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Icon(Icons.error_outline, size: 64, color: Colors.red),
            const SizedBox(height: 16),
            Text(
              'Error: $_errorMessage',
              textAlign: TextAlign.center,
              style: const TextStyle(color: Colors.red),
            ),
            const SizedBox(height: 16),
            ElevatedButton(
              onPressed: _loadTodos,
              child: const Text('Retry'),
            ),
          ],
        ),
      );
    }

    if (_filteredTodos.isEmpty) {
      if (_isSearching) {
        return Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Icon(
                Icons.search_off,
                size: 64,
                color: Colors.grey.shade400,
              ),
              const SizedBox(height: 16),
              Text(
                'No results found',
                style: TextStyle(
                  fontSize: 18,
                  color: Colors.grey.shade600,
                  fontWeight: FontWeight.w500,
                ),
              ),
              const SizedBox(height: 8),
              Text(
                'Try different keywords: "$_searchQuery"',
                style: TextStyle(
                  fontSize: 14,
                  color: Colors.grey.shade500,
                ),
              ),
              const SizedBox(height: 24),
              OutlinedButton.icon(
                onPressed: _clearSearch,
                icon: const Icon(Icons.clear),
                label: const Text('Clear Search'),
              ),
            ],
          ),
        );
      }
      return Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(
              Icons.inbox_outlined,
              size: 64,
              color: Colors.grey.shade400,
            ),
            const SizedBox(height: 16),
            Text(
              'No data available',
              style: TextStyle(
                fontSize: 18,
                color: Colors.grey.shade600,
              ),
            ),
          ],
        ),
      );
    }

    return ListView.builder(
      itemCount: _filteredTodos.length,
      itemBuilder: (context, index) {
        final todo = _filteredTodos[index];
        return Card(
          margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
          child: ListTile(
            leading: CircleAvatar(
              backgroundColor: todo.completed ? Colors.green : Colors.orange,
              child: Icon(
                todo.completed ? Icons.check : Icons.pending,
                color: Colors.white,
              ),
            ),
            title: Text(
              todo.title,
              style: TextStyle(
                decoration: todo.completed ? TextDecoration.lineThrough : null,
              ),
            ),
            subtitle: Text('User ID: ${todo.userId} | ID: ${todo.id}'),
            trailing: Checkbox(value: todo.completed, onChanged: null),
          ),
        );
      },
    );
  }
}

五、鸿蒙设备验证

5.1 编译运行

将代码部署到鸿蒙设备上,验证搜索功能是否正常工作。在DevEco Studio中,选择鸿蒙设备作为目标设备,点击运行按钮。

5.2 测试场景

  1. 正常搜索:输入关键词,验证搜索结果是否正确
  2. 空搜索:不输入关键词,验证是否显示全部数据
  3. 无结果搜索:输入不存在的关键词,验证空状态提示
  4. 性能测试:快速输入多个关键词,验证防抖效果

5.3 运行截图

以下是搜索功能在鸿蒙设备上的运行截图:
在这里插入图片描述

在这里插入图片描述

六、性能优化与最佳实践

6.1 性能优化

  1. 防抖处理:减少不必要的搜索请求
  2. 本地筛选:避免频繁网络请求
  3. 状态管理:优化UI更新时机

6.2 最佳实践

  1. 用户体验:提供清晰的搜索反馈
  2. 错误处理:完善的异常处理机制
  3. 可维护性:模块化设计,代码复用

七、总结与展望

本文详细介绍了如何在Flutter for OpenHarmony应用中实现一个完整的搜索功能,包括UI设计、搜索逻辑、空状态提示和性能优化。通过实战案例,我们展示了如何构建一个高性能、用户友好的搜索系统。

未来,我们可以进一步扩展搜索功能,例如添加历史记录、热门搜索、语音搜索等。同时,我们可以结合鸿蒙系统的特性,实现更多个性化的搜索体验。

八、社区资源

  • 开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
  • Flutter for OpenHarmony官方文档:https://developer.harmonyos.com/cn/docs/documentation/doc-guides/flutter-overview-0000001108767065
  • Dio官方文档:https://pub.dev/packages/dio

通过本文的学习,相信你已经掌握了在Flutter for OpenHarmony应用中实现搜索功能的方法。希望本文能对你的开发工作有所帮助,祝你在鸿蒙跨平台开发的道路上取得更大的成功!

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐