【HarmonyOS】RN_of_HarmonyOS实战项目_自动完成功能

【HarmonyOS】RN of HarmonyOS实战项目:TextInput自动完成功能实现

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

项目概述:本文详细介绍在HarmonyOS平台上使用React Native实现智能自动完成功能,涵盖本地数据源、远程API、模糊搜索、缓存策略等完整技术方案,提供从基础实现到生产级应用的完整解决方案。


一、项目背景

自动完成(Autocomplete)是提升用户体验的重要功能,广泛应用于搜索、表单填写、地址选择等场景。在HarmonyOS平台上实现高效的自动完成需要考虑:

  • 数据源管理:本地数据与远程API的混合使用
  • 搜索算法:模糊匹配、拼音搜索、智能排序
  • 性能优化:防抖处理、结果缓存、虚拟列表
  • 用户体验:加载状态、空状态、高亮显示

二、技术架构

2.1 自动完成流程

命中
未命中
本地数据
远程API
成功
失败
用户输入
防抖处理
检查缓存
显示缓存结果
检查数据源
本地搜索
发起请求
显示结果
响应
更新缓存
显示错误

2.2 数据结构定义

typescript 复制代码
/**
 * 自动完成项
 */
interface AutocompleteItem {
  id: string;
  title: string;
  subtitle?: string;
  icon?: string;
  image?: string;
  score?: number;        // 匹配分数
  highlight?: string[];  // 高亮字段
}

/**
 * 自动完成配置
 */
interface AutocompleteConfig {
  debounceMs?: number;        // 防抖延迟(毫秒)
  minChars?: number;          // 最小触发字符数
  maxResults?: number;        // 最大结果数
  cacheEnabled?: boolean;     // 是否启用缓存
  cacheTTL?: number;         // 缓存有效期(毫秒)
  highlightEnabled?: boolean; // 是否高亮匹配
}

三、核心实现代码

3.1 模糊搜索工具类

typescript 复制代码
/**
 * 模糊搜索工具类
 * 提供高效的字符串匹配和评分算法
 *
 * @platform HarmonyOS 2.0+
 * @react-native 0.72+
 */
export class FuzzySearch {
  /**
   * 模糊匹配计算分数
   * 返回0-1之间的分数,1表示完全匹配
   */
  static score(query: string, target: string): number {
    if (!query) return 0;
    if (!target) return 0;

    const q = query.toLowerCase();
    const t = target.toLowerCase();

    // 完全匹配
    if (t === q) return 1;

    // 前缀匹配
    if (t.startsWith(q)) return 0.8;

    // 包含匹配
    if (t.includes(q)) return 0.6;

    // 模糊匹配(字符顺序)
    let score = 0;
    let qIndex = 0;
    let consecutive = 0;

    for (let i = 0; i < t.length && qIndex < q.length; i++) {
      if (t[i] === q[qIndex]) {
        qIndex++;
        consecutive++;
        score += consecutive * 0.1;
      } else {
        consecutive = 0;
      }
    }

    // 如果所有字符都匹配上了
    if (qIndex === q.length) {
      // 归一化分数
      return Math.min(score / q.length, 0.5);
    }

    return 0;
  }

  /**
   * 高亮匹配文本
   */
  static highlight(query: string, target: string): {
    text: string;
    isMatch: boolean;
  }[] {
    const result: { text: string; isMatch: boolean }[] = [];
    const q = query.toLowerCase();
    const t = target.toLowerCase();

    let lastIndex = 0;
    let qIndex = 0;

    for (let i = 0; i < t.length && qIndex < q.length; i++) {
      if (t[i] === q[qIndex]) {
        // 添加匹配前的文本
        if (i > lastIndex) {
          result.push({
            text: target.substring(lastIndex, i),
            isMatch: false,
          });
        }

        // 添加匹配的文本
        result.push({
          text: target.substring(i, i + 1),
          isMatch: true,
        });

        lastIndex = i + 1;
        qIndex++;
      }
    }

    // 添加剩余文本
    if (lastIndex < target.length) {
      result.push({
        text: target.substring(lastIndex),
        isMatch: false,
      });
    }

    return result;
  }

  /**
   * 搜索并排序
   */
  static search<T extends AutocompleteItem>(
    items: T[],
    query: string,
    options: {
      threshold?: number;
      maxResults?: number;
      scoreField?: string;
    } = {}
  ): T[] {
    const { threshold = 0.1, maxResults = 10, scoreField = 'title' } = options;

    if (!query) {
      return items.slice(0, maxResults);
    }

    // 计算分数
    const scored = items.map(item => {
      const titleScore = this.score(query, item.title);
      const subtitleScore = item.subtitle
        ? this.score(query, item.subtitle)
        : 0;

      return {
        ...item,
        score: Math.max(titleScore, subtitleScore),
      };
    });

    // 过滤并排序
    return scored
      .filter(item => item.score! >= threshold)
      .sort((a, b) => (b.score || 0) - (a.score || 0))
      .slice(0, maxResults);
  }
}

3.2 缓存管理器

typescript 复制代码
/**
 * 自动完成缓存管理器
 * 管理搜索结果的缓存
 */
interface CacheEntry {
  results: AutocompleteItem[];
  timestamp: number;
}

export class AutocompleteCache {
  private cache: Map<string, CacheEntry> = new Map();
  private ttl: number;

  constructor(ttl: number = 5 * 60 * 1000) {
    this.ttl = ttl;
  }

  /**
   * 获取缓存
   */
  get(query: string): AutocompleteItem[] | null {
    const entry = this.cache.get(query);

    if (!entry) {
      return null;
    }

    // 检查是否过期
    const now = Date.now();
    if (now - entry.timestamp > this.ttl) {
      this.cache.delete(query);
      return null;
    }

    return entry.results;
  }

  /**
   * 设置缓存
   */
  set(query: string, results: AutocompleteItem[]): void {
    this.cache.set(query, {
      results,
      timestamp: Date.now(),
    });
  }

  /**
   * 清空缓存
   */
  clear(): void {
    this.cache.clear();
  }

  /**
   * 删除特定查询的缓存
   */
  delete(query: string): void {
    this.cache.delete(query);
  }

  /**
   * 清理过期缓存
   */
  cleanup(): void {
    const now = Date.now();

    for (const [key, entry] of this.cache.entries()) {
      if (now - entry.timestamp > this.ttl) {
        this.cache.delete(key);
      }
    }
  }
}

3.3 自动完成Hook

typescript 复制代码
/**
 * 自动完成Hook
 * 提供完整的自动完成功能
 */
import { useState, useCallback, useRef, useEffect } from 'react';
import { FuzzySearch } from './FuzzySearch';
import { AutocompleteCache } from './AutocompleteCache';

interface UseAutocompleteOptions {
  data?: AutocompleteItem[];
  fetchData?: (query: string) => Promise<AutocompleteItem[]>;
  config?: AutocompleteConfig;
}

export function useAutocomplete(options: UseAutocompleteOptions) {
  const {
    data: localData,
    fetchData,
    config = {},
  } = options;

  const {
    debounceMs = 300,
    minChars = 1,
    maxResults = 10,
    cacheEnabled = true,
    cacheTTL = 5 * 60 * 1000,
    highlightEnabled = true,
  } = config;

  const [query, setQuery] = useState('');
  const [results, setResults] = useState<AutocompleteItem[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const debounceTimer = useRef<NodeJS.Timeout>();
  const cache = useRef(new AutocompleteCache(cacheTTL));
  const abortController = useRef<AbortController | null>(null);

  /**
   * 执行搜索
   */
  const search = useCallback(async (searchQuery: string) => {
    if (searchQuery.length < minChars) {
      setResults([]);
      setError(null);
      return;
    }

    setIsLoading(true);
    setError(null);

    // 取消之前的请求
    if (abortController.current) {
      abortController.current.abort();
    }

    try {
      // 检查缓存
      if (cacheEnabled) {
        const cached = cache.current.get(searchQuery);
        if (cached) {
          setResults(cached);
          setIsLoading(false);
          return;
        }
      }

      let searchResults: AutocompleteItem[] = [];

      // 本地数据搜索
      if (localData) {
        searchResults = FuzzySearch.search(localData, searchQuery, {
          threshold: 0.1,
          maxResults,
        });
      }

      // 远程数据搜索
      if (fetchData) {
        abortController.current = new AbortController();

        try {
          const remoteResults = await fetchData(searchQuery);
          searchResults = [...searchResults, ...remoteResults]
            .sort((a, b) => (b.score || 0) - (a.score || 0))
            .slice(0, maxResults);
        } catch (err: any) {
          if (err.name !== 'AbortError') {
            throw err;
          }
        }
      }

      // 更新缓存
      if (cacheEnabled && searchResults.length > 0) {
        cache.current.set(searchQuery, searchResults);
      }

      setResults(searchResults);
    } catch (err: any) {
      setError(err.message || '搜索失败');
      setResults([]);
    } finally {
      setIsLoading(false);
    }
  }, [localData, fetchData, minChars, maxResults, cacheEnabled, cacheTTL]);

  /**
   * 处理输入变化
   */
  const handleQueryChange = useCallback((text: string) => {
    setQuery(text);

    if (debounceTimer.current) {
      clearTimeout(debounceTimer.current);
    }

    debounceTimer.current = setTimeout(() => {
      search(text);
    }, debounceMs);
  }, [debounceMs, search]);

  /**
   * 清空查询
   */
  const clearQuery = useCallback(() => {
    setQuery('');
    setResults([]);
    setError(null);

    if (debounceTimer.current) {
      clearTimeout(debounceTimer.current);
    }
  }, []);

  /**
   * 选择结果
   */
  const selectItem = useCallback((item: AutocompleteItem) => {
    setQuery(item.title);
    setResults([]);
  }, []);

  /**
   * 清理
   */
  useEffect(() => {
    return () => {
      if (debounceTimer.current) {
        clearTimeout(debounceTimer.current);
      }
      if (abortController.current) {
        abortController.current.abort();
      }
    };
  }, []);

  return {
    query,
    setQuery: handleQueryChange,
    results,
    isLoading,
    error,
    clearQuery,
    selectItem,
  };
}

3.4 自动完成组件

typescript 复制代码
/**
 * 自动完成输入组件
 * 提供完整的自动完成UI
 */
import React, { useRef, useEffect } from 'react';
import {
  View,
  TextInput,
  Text,
  TouchableOpacity,
  StyleSheet,
  FlatList,
  ActivityIndicator,
  ScrollView,
  KeyboardAvoidingView,
  Platform,
} from 'react-native';
import { useAutocomplete } from './useAutocomplete';
import { FuzzySearch } from './FuzzySearch';

interface AutocompleteInputProps {
  placeholder?: string;
  data?: AutocompleteItem[];
  fetchData?: (query: string) => Promise<AutocompleteItem[]>;
  config?: AutocompleteConfig;
  onSelect?: (item: AutocompleteItem) => void;
  renderItem?: (item: AutocompleteItem, highlight: boolean) => React.ReactNode;
}

export const AutocompleteInput: React.FC<AutocompleteInputProps> = ({
  placeholder = '搜索...',
  data,
  fetchData,
  config,
  onSelect,
  renderItem,
}) => {
  const inputRef = useRef<TextInput>(null);
  const listRef = useRef<FlatList>(null);

  const {
    query,
    setQuery,
    results,
    isLoading,
    error,
    clearQuery,
    selectItem,
  } = useAutocomplete({
    data,
    fetchData,
    config,
  });

  /**
   * 渲染结果项
   */
  const renderItemDefault = ({ item }: { item: AutocompleteItem }) => {
    if (renderItem) {
      return renderItem(item, true);
    }

    const highlighted = FuzzySearch.highlight(query, item.title);

    return (
      <TouchableOpacity
        style={autoStyles.item}
        onPress={() => {
          selectItem(item);
          onSelect?.(item);
        }}
      >
        <View style={autoStyles.itemContent}>
          {item.image && (
            <View style={autoStyles.imageContainer}>
              <Text style={autoStyles.imagePlaceholder}>{item.image}</Text>
            </View>
          )}

          {item.icon && !item.image && (
            <Text style={autoStyles.icon}>{item.icon}</Text>
          )}

          <View style={autoStyles.textContainer}>
            <View style={autoStyles.titleRow}>
              <FlatList
                horizontal
                data={highlighted}
                keyExtractor={(item, index) => `${index}-${item.text}`}
                renderItem={({ item: h }) => (
                  <Text
                    style={[
                      autoStyles.title,
                      h.isMatch && autoStyles.titleHighlight,
                    ]}
                  >
                    {h.text}
                  </Text>
                )}
              />
            </View>

            {item.subtitle && (
              <Text style={autoStyles.subtitle} numberOfLines={1}>
                {item.subtitle}
              </Text>
            )}
          </View>

          {item.score !== undefined && (
            <Text style={autoStyles.score}>
              {Math.round(item.score * 100)}%
            </Text>
          )}
        </View>
      </TouchableOpacity>
    );
  };

  /**
   * 渲染列表头部
   */
  const renderListHeader = () => {
    if (isLoading) {
      return (
        <View style={autoStyles.loadingContainer}>
          <ActivityIndicator size="small" color="#007AFF" />
          <Text style={autoStyles.loadingText}>搜索中...</Text>
        </View>
      );
    }

    if (error) {
      return (
        <View style={autoStyles.errorContainer}>
          <Text style={autoStyles.errorText}>{error}</Text>
        </View>
      );
    }

    if (query.length > 0 && results.length === 0) {
      return (
        <View style={autoStyles.emptyContainer}>
          <Text style={autoStyles.emptyText}>未找到结果</Text>
        </View>
      );
    }

    return null;
  };

  return (
    <KeyboardAvoidingView
      style={autoStyles.container}
      keyboardVerticalOffset={Platform.OS === 'ios' ? 100 : 0}
      behavior="padding"
    >
      {/* 输入框 */}
      <View style={autoStyles.inputContainer}>
        <Text style={autoStyles.searchIcon}>🔍</Text>

        <TextInput
          ref={inputRef}
          style={autoStyles.input}
          value={query}
          onChangeText={setQuery}
          placeholder={placeholder}
          placeholderTextColor="#999"
          returnKeyType="search"
        />

        {query.length > 0 && (
          <TouchableOpacity onPress={clearQuery} style={autoStyles.clearButton}>
            <Text style={autoStyles.clearIcon}>×</Text>
          </TouchableOpacity>
        )}
      </View>

      {/* 结果列表 */}
      {results.length > 0 && (
        <View style={autoStyles.resultsContainer}>
          <FlatList
            ref={listRef}
            data={results}
            renderItem={renderItemDefault}
            keyExtractor={(item) => item.id}
            ListHeaderComponent={renderListHeader}
            ItemSeparatorComponent={() => <View style={autoStyles.separator} />}
            keyboardShouldPersistTaps="handled"
          />
        </View>
      )}
    </KeyboardAvoidingView>
  );
};

const autoStyles = StyleSheet.create({
  container: {
    width: '100%',
  },
  inputContainer: {
    flexDirection: 'row',
    alignItems: 'center',
    backgroundColor: '#F5F5F5',
    borderRadius: 12,
    paddingHorizontal: 16,
    height: 48,
  },
  searchIcon: {
    fontSize: 16,
    marginRight: 10,
  },
  input: {
    flex: 1,
    fontSize: 15,
    color: '#333',
  },
  clearButton: {
    width: 28,
    height: 28,
    borderRadius: 14,
    backgroundColor: '#E0E0E0',
    justifyContent: 'center',
    alignItems: 'center',
  },
  clearIcon: {
    fontSize: 18,
    color: '#666',
    fontWeight: 'bold',
  },
  resultsContainer: {
    marginTop: 12,
    backgroundColor: '#fff',
    borderRadius: 12,
    maxHeight: 300,
    overflow: 'hidden',
    borderWidth: 1,
    borderColor: '#E5E5E5',
  },
  item: {
    paddingHorizontal: 16,
    paddingVertical: 12,
  },
  itemContent: {
    flexDirection: 'row',
    alignItems: 'center',
  },
  imageContainer: {
    width: 40,
    height: 40,
    borderRadius: 8,
    backgroundColor: '#F5F5F5',
    justifyContent: 'center',
    alignItems: 'center',
    marginRight: 12,
  },
  imagePlaceholder: {
    fontSize: 20,
  },
  icon: {
    fontSize: 24,
    marginRight: 12,
  },
  textContainer: {
    flex: 1,
  },
  titleRow: {
    flexDirection: 'row',
    alignItems: 'center',
  },
  title: {
    fontSize: 15,
    color: '#333',
  },
  titleHighlight: {
    color: '#007AFF',
    fontWeight: '600',
  },
  subtitle: {
    fontSize: 13,
    color: '#999',
    marginTop: 2,
  },
  score: {
    fontSize: 12,
    color: '#999',
    marginLeft: 8,
  },
  separator: {
    height: 1,
    backgroundColor: '#F5F5F5',
    marginLeft: 68,
  },
  loadingContainer: {
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'center',
    padding: 16,
  },
  loadingText: {
    marginLeft: 8,
    fontSize: 14,
    color: '#999',
  },
  errorContainer: {
    padding: 16,
    alignItems: 'center',
  },
  errorText: {
    fontSize: 14,
    color: '#FF3B30',
  },
  emptyContainer: {
    padding: 24,
    alignItems: 'center',
  },
  emptyText: {
    fontSize: 14,
    color: '#999',
  },
});

3.5 使用示例

typescript 复制代码
/**
 * 自动完成组件使用示例
 */
import React from 'react';
import {
  View,
  StyleSheet,
  ScrollView,
  SafeAreaView,
  Text,
} from 'react-native';
import { AutocompleteInput } from './AutocompleteInput';
import { AutocompleteItem } from './types';

// 模拟本地数据
const MOCK_DATA: AutocompleteItem[] = [
  { id: '1', title: 'React Native', subtitle: '跨平台移动应用开发框架', icon: '📱' },
  { id: '2', title: 'React', subtitle: '用于构建用户界面的JavaScript库', icon: '⚛️' },
  { id: '3', title: 'Redux', subtitle: 'JavaScript应用的可预测状态容器', icon: '🔄' },
  { id: '4', title: 'TypeScript', subtitle: 'JavaScript的超集', icon: '📘' },
  { id: '5', title: 'JavaScript', subtitle: 'Web编程语言', icon: '📜' },
  { id: '6', title: 'HarmonyOS', subtitle: '分布式操作系统', icon: '🌐' },
  { id: '7', title: 'OpenHarmony', subtitle: '开源鸿蒙系统', icon: '🔓' },
  { id: '8', title: 'ArkUI', subtitle: 'HarmonyOS声明式UI框架', icon: '🎨' },
];

// 模拟远程搜索API
const mockFetchData = async (query: string): Promise<AutocompleteItem[]> => {
  await new Promise(resolve => setTimeout(resolve, 500));

  return [
    { id: `remote-1`, title: `${query} 教程`, subtitle: '学习使用' },
    { id: `remote-2`, title: `${query} 文档`, subtitle: '官方文档' },
    { id: `remote-3`, title: `${query} 示例`, subtitle: '代码示例' },
  ];
};

const AutocompleteDemo: React.FC = () => {
  const handleSelect = (item: AutocompleteItem) => {
    console.log('Selected:', item);
  };

  return (
    <SafeAreaView style={demoStyles.container}>
      <ScrollView contentContainerStyle={demoStyles.content}>
        <Text style={demoStyles.title}>自动完成功能</Text>

        {/* 本地数据自动完成 */}
        <View style={demoStyles.section}>
          <Text style={demoStyles.sectionTitle}>本地数据搜索</Text>
          <AutocompleteInput
            placeholder="搜索技术栈..."
            data={MOCK_DATA}
            config={{
              debounceMs: 200,
              minChars: 1,
              maxResults: 5,
            }}
            onSelect={handleSelect}
          />
        </View>

        {/* 远程数据自动完成 */}
        <View style={demoStyles.section}>
          <Text style={demoStyles.sectionTitle}>远程数据搜索</Text>
          <AutocompleteInput
            placeholder="搜索教程..."
            fetchData={mockFetchData}
            config={{
              debounceMs: 500,
              minChars: 2,
              maxResults: 8,
              cacheEnabled: true,
            }}
            onSelect={handleSelect}
          />
        </View>

        {/* 混合数据源 */}
        <View style={demoStyles.section}>
          <Text style={demoStyles.sectionTitle}>混合数据源</Text>
          <AutocompleteInput
            placeholder="搜索任何内容..."
            data={MOCK_DATA}
            fetchData={mockFetchData}
            config={{
              debounceMs: 300,
              minChars: 1,
              maxResults: 10,
            }}
            onSelect={handleSelect}
          />
        </View>
      </ScrollView>
    </SafeAreaView>
  );
};

const demoStyles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#F5F5F5',
  },
  content: {
    padding: 16,
  },
  title: {
    fontSize: 24,
    fontWeight: 'bold',
    color: '#333',
    marginBottom: 30,
    textAlign: 'center',
  },
  section: {
    marginBottom: 30,
  },
  sectionTitle: {
    fontSize: 14,
    color: '#999',
    marginBottom: 8,
  },
});

export default AutocompleteDemo;

四、最佳实践

功能 实现方式 效果
模糊搜索 字符匹配评分算法 更智能的搜索结果
缓存策略 TTL缓存管理 减少重复请求
防抖处理 300ms延迟输入 优化性能
高亮显示 匹配字符高亮 清晰的视觉反馈
虚拟列表 FlatList渲染 高效的长列表显示

五、总结

本文详细介绍了在HarmonyOS平台上实现自动完成功能的完整方案,涵盖:

  1. 核心算法:模糊搜索、匹配评分
  2. 数据管理:本地/远程数据源、缓存策略
  3. 性能优化:防抖处理、结果缓存
  4. 用户体验:高亮显示、加载状态、空状态处理

完整项目代码AtomGitDemos

相关推荐
Betelgeuse762 小时前
【Flutter For OpenHarmony】 项目结项复盘
华为·交互·开源软件·鸿蒙
平安的平安2 小时前
【OpenHarmony】React Native鸿蒙实战:SecureStorage 安全存储详解
安全·react native·harmonyos
松叶似针2 小时前
Flutter三方库适配OpenHarmony【secure_application】— 错误处理与异常边界
flutter·harmonyos
果粒蹬i2 小时前
【HarmonyOS】RN_of_HarmonyOS实战项目_邮箱地址输入
华为·harmonyos
星空22232 小时前
【HarmonyOS】React Native 实战:原生手势交互开发
react native·交互·harmonyos
阿林来了2 小时前
Flutter三方库适配OpenHarmony【flutter_speech】— 错误处理与异常恢复
flutter·harmonyos
爱华晨宇2 小时前
快速清理C盘,释放10GB空间!
harmonyos
阿林来了3 小时前
Flutter三方库适配OpenHarmony【flutter_speech】— 调试技巧与日志分析
flutter·harmonyos
平安的平安3 小时前
【OpenHarmony】React Native鸿蒙实战:Camera 相机组件详解
数码相机·react native·harmonyos