在这里插入图片描述

每日一句正能量

真正的乐观从不是否认困难的存在,而是相信挫折只是暂时的插曲,更相信自己有能力去面对、去解决。
假乐观是逃避和否认;真乐观是直面现实的勇气加上对未来的信心。它承认困难客观存在,但赋予其“插曲”的定位,并落脚于对“自己能解决”的自我效能感的信任。

摘要

摘要:应用内搜索是提升用户内容发现效率的核心能力。本文基于HarmonyOS 6(API 23),从系统架构、本地FTS全文索引构建、ArkUI Search组件实现、搜索结果排序优化到Intents Kit意图框架全局搜索集成,全方位解析应用内搜索的完整开发链路。通过「智能电商搜索」实战案例,深入讲解RelationalDatabase FTS5索引、中文分词处理、多维度结果排序、搜索历史与热门推荐、以及Intents Kit将应用内容共享到系统全局搜索的全流程,帮助开发者构建企业级搜索体验。


一、应用内搜索系统架构

HarmonyOS 6在API 23中强化了应用内搜索能力,形成了「本地搜索 + 云端搜索 + 意图框架」三位一体的搜索架构。

在这里插入图片描述

1.1 三层架构模型

用户输入层:提供多种搜索入口——

  • ArkUI Search组件:应用内标准搜索框,支持文本输入、清除按钮、搜索按钮
  • 语音输入:集成ASR Kit语音识别,支持语音转文字搜索
  • 全局搜索框:通过Intents Kit将应用内容注册到系统全局搜索(小艺搜索、负一屏、桌面下拉)
  • 扫码识图:通过Scan Kit识别二维码/条形码,转化为搜索关键词

搜索处理层:核心搜索引擎——

  • 本地索引引擎:基于RelationalDatabase FTS5实现全文索引,支持倒排索引查询
  • 意图解析器:Intents Kit NLP引擎解析用户查询意图(如"查订单"→订单查询意图)
  • 云端搜索API:Search Kit开放Petal Search云侧搜索能力,支持网页/图片/视频/新闻搜索
  • 结果排序器:基于TF-IDF、BM25等算法计算相关性,结合用户行为数据个性化排序
  • 辅助模块:分词器、同义词扩展、拼写纠错、搜索历史缓存、热门推荐预计算

数据层:多源异构数据——

  • 本地数据库:RelationalDatabase存储结构化数据(商品、订单、联系人)
  • 全文索引库:FTS5虚拟表存储倒排索引
  • 文件系统:PDF/Word/Excel文档内容提取
  • 用户偏好:Preferences存储搜索历史、用户偏好标签

1.2 与Android SearchManager的核心差异

维度 HarmonyOS 6 Search Android SearchManager
索引引擎 RelationalDatabase FTS5 SQLite FTS3/FTS4
中文分词 需自定义分词器 需依赖第三方库(如jieba)
全局搜索 Intents Kit原生支持 SearchableInfo配置
语音搜索 ASR Kit深度集成 依赖Google Voice Search
意图理解 NLP意图解析 不支持原生意图框架
分布式 支持跨设备搜索 需自行实现

二、开发环境准备

2.1 环境配置

  • DevEco Studio:4.1 Release 及以上
  • SDK版本:HarmonyOS 6.0.0 (API 23)
  • 依赖Kit:RelationalDatabase Kit、Intents Kit、Search Kit(可选)

2.2 模块依赖配置

oh-package.json5中添加依赖:

{
  "dependencies": {
    "@ohos.data.relationalStore": "^6.0.0",
    "@ohos.ai.intents": "^6.0.0",
    "@ohos.searchkit": "^6.0.0"
  }
}

module.json5中申请权限:

{
  "module": {
    "requestPermissions": [
      { "name": "ohos.permission.INTERNET" },
      { "name": "ohos.permission.READ_MEDIA" }
    ]
  }
}

三、本地FTS全文索引构建

全文搜索(Full-Text Search)是应用内搜索的核心技术。HarmonyOS 6的RelationalDatabase支持FTS5扩展,可高效处理大规模文本数据的搜索需求。

在这里插入图片描述

3.1 数据库与FTS表设计

// database/SearchDatabase.ets
import { relationalStore } from '@kit.ArkData';
import { BusinessError } from '@kit.BasicServicesKit';

export interface SearchableItem {
  id: string;
  type: string;        // 'product' | 'order' | 'note' | 'contact'
  title: string;
  content: string;
  tags: string;
  extraData: string;   // JSON序列化的附加数据
  timestamp: number;
  relevance: number;   // 手动设置的相关性权重
}

export class SearchDatabase {
  private static readonly DB_NAME = 'app_search.db';
  private static readonly DB_VERSION = 1;
  private rdbStore: relationalStore.RelationalStore | null = null;

  async init(context: Context): Promise<void> {
    const config: relationalStore.StoreConfig = {
      name: SearchDatabase.DB_NAME,
      securityLevel: relationalStore.SecurityLevel.S1,
      encrypt: false
    };

    try {
      this.rdbStore = await relationalStore.getRdbStore(context, config);
      await this.createTables();
      console.info('[SearchDatabase] Initialized');
    } catch (err) {
      console.error('[SearchDatabase] Init failed:', err);
      throw err;
    }
  }

  private async createTables(): Promise<void> {
    // 主数据表
    const createMainTable = `
      CREATE TABLE IF NOT EXISTS searchable_items (
        id TEXT PRIMARY KEY,
        type TEXT NOT NULL,
        title TEXT NOT NULL,
        content TEXT,
        tags TEXT,
        extra_data TEXT,
        timestamp INTEGER DEFAULT 0,
        relevance REAL DEFAULT 1.0
      )
    `;

    // FTS5虚拟表(全文索引)
    // 注意:FTS5支持中文需自定义tokenizer,默认simple tokenizer不支持中文分词
    const createFtsTable = `
      CREATE VIRTUAL TABLE IF NOT EXISTS searchable_items_fts USING fts5(
        id,
        title,
        content,
        tags,
        tokenize = 'porter unicode61 remove_diacritics 1'
      )
    `;

    // 触发器:主表插入时同步到FTS表
    const createInsertTrigger = `
      CREATE TRIGGER IF NOT EXISTS trg_items_insert
      AFTER INSERT ON searchable_items
      BEGIN
        INSERT INTO searchable_items_fts(id, title, content, tags)
        VALUES (NEW.id, NEW.title, NEW.content, NEW.tags);
      END
    `;

    // 触发器:主表更新时同步FTS表
    const createUpdateTrigger = `
      CREATE TRIGGER IF NOT EXISTS trg_items_update
      AFTER UPDATE ON searchable_items
      BEGIN
        UPDATE searchable_items_fts
        SET title = NEW.title, content = NEW.content, tags = NEW.tags
        WHERE id = NEW.id;
      END
    `;

    // 触发器:主表删除时同步FTS表
    const createDeleteTrigger = `
      CREATE TRIGGER IF NOT EXISTS trg_items_delete
      AFTER DELETE ON searchable_items
      BEGIN
        DELETE FROM searchable_items_fts WHERE id = OLD.id;
      END
    `;

    const statements = [
      createMainTable, createFtsTable,
      createInsertTrigger, createUpdateTrigger, createDeleteTrigger
    ];

    for (const sql of statements) {
      await this.rdbStore?.executeSql(sql);
    }
  }

  // 插入/更新索引数据
  async upsertItem(item: SearchableItem): Promise<void> {
    const values = new relationalStore.ValuesBucket();
    values.setString('id', item.id);
    values.setString('type', item.type);
    values.setString('title', item.title);
    values.setString('content', item.content || '');
    values.setString('tags', item.tags || '');
    values.setString('extra_data', item.extraData || '{}');
    values.setLong('timestamp', item.timestamp || Date.now());
    values.setDouble('relevance', item.relevance || 1.0);

    try {
      await this.rdbStore?.insert('searchable_items', values, relationalStore.ConflictResolution.ON_CONFLICT_REPLACE);
    } catch (err) {
      console.error('[SearchDatabase] upsert failed:', err);
      throw err;
    }
  }

  // 批量插入(用于初始化索引)
  async bulkInsert(items: SearchableItem[]): Promise<void> {
    // FTS5批量操作建议使用事务
    await this.rdbStore?.executeSql('BEGIN TRANSACTION');
    try {
      for (const item of items) {
        await this.upsertItem(item);
      }
      await this.rdbStore?.executeSql('COMMIT');
    } catch (err) {
      await this.rdbStore?.executeSql('ROLLBACK');
      throw err;
    }
  }

  // 全文搜索查询
  async search(query: string, options: SearchOptions = {}): Promise<SearchResult[]> {
    const {
      type,
      limit = 20,
      offset = 0,
      sortBy = 'relevance'
    } = options;

    // 预处理查询词:去除特殊字符,添加通配符
    const safeQuery = query.replace(/[^\w\s一-龥]/g, ' ').trim();
    if (!safeQuery) return [];

    // 构建FTS查询语句
    // 使用NEAR匹配提升短语搜索的相关性
    const ftsQuery = safeQuery.split(/\s+/).map(word => `${word}*`).join(' OR ');

    let sql = `
      SELECT 
        m.id, m.type, m.title, m.content, m.tags, m.extra_data, m.timestamp, m.relevance,
        snippet(searchable_items_fts, 0, '[', ']', '...', 32) as snippet,
        rank
      FROM searchable_items_fts f
      JOIN searchable_items m ON f.id = m.id
      WHERE searchable_items_fts MATCH ?
    `;

    const args: relationalStore.ValueType[] = [ftsQuery];

    if (type) {
      sql += ' AND m.type = ?';
      args.push(type);
    }

    // 排序策略
    if (sortBy === 'relevance') {
      sql += ' ORDER BY rank ASC, m.relevance DESC';
    } else if (sortBy === 'time') {
      sql += ' ORDER BY m.timestamp DESC';
    }

    sql += ' LIMIT ? OFFSET ?';
    args.push(limit, offset);

    const resultSet = await this.rdbStore?.querySql(sql, args);
    const results: SearchResult[] = [];

    if (resultSet) {
      while (resultSet.goToNextRow()) {
        results.push({
          id: resultSet.getString(resultSet.getColumnIndex('id')) || '',
          type: resultSet.getString(resultSet.getColumnIndex('type')) || '',
          title: resultSet.getString(resultSet.getColumnIndex('title')) || '',
          content: resultSet.getString(resultSet.getColumnIndex('content')) || '',
          tags: resultSet.getString(resultSet.getColumnIndex('tags')) || '',
          extraData: resultSet.getString(resultSet.getColumnIndex('extra_data')) || '{}',
          timestamp: resultSet.getLong(resultSet.getColumnIndex('timestamp')) || 0,
          relevance: resultSet.getDouble(resultSet.getColumnIndex('relevance')) || 0,
          snippet: resultSet.getString(resultSet.getColumnIndex('snippet')) || '',
          rank: resultSet.getDouble(resultSet.getColumnIndex('rank')) || 0
        });
      }
      resultSet.close();
    }

    return results;
  }

  // 获取搜索建议(自动补全)
  async getSuggestions(prefix: string, limit: number = 5): Promise<string[]> {
    if (!prefix || prefix.length < 1) return [];

    const sql = `
      SELECT DISTINCT title FROM searchable_items
      WHERE title LIKE ?
      ORDER BY relevance DESC, timestamp DESC
      LIMIT ?
    `;

    const resultSet = await this.rdbStore?.querySql(sql, [`${prefix}%`, limit]);
    const suggestions: string[] = [];

    if (resultSet) {
      while (resultSet.goToNextRow()) {
        suggestions.push(resultSet.getString(resultSet.getColumnIndex('title')) || '');
      }
      resultSet.close();
    }

    return suggestions;
  }

  // 删除索引
  async deleteItem(id: string): Promise<void> {
    await this.rdbStore?.executeSql('DELETE FROM searchable_items WHERE id = ?', [id]);
  }

  // 清空索引
  async clearIndex(): Promise<void> {
    await this.rdbStore?.executeSql('DELETE FROM searchable_items');
    // FTS5虚拟表会自动同步清空
  }

  async close(): Promise<void> {
    await this.rdbStore?.close();
    this.rdbStore = null;
  }
}

export interface SearchOptions {
  type?: string;
  limit?: number;
  offset?: number;
  sortBy?: 'relevance' | 'time';
}

export interface SearchResult extends SearchableItem {
  snippet: string;
  rank: number;
}

3.2 中文分词处理

FTS5默认的unicode61分词器对中文支持有限(仅按字切分)。对于中文搜索场景,建议实现自定义分词或采用N-gram策略:

// utils/ChineseTokenizer.ets
export class ChineseTokenizer {
  /**
   * 简易中文分词:按字切分 + 二元组
   * 生产环境建议集成jieba-wasm或云端分词API
   */
  static tokenize(text: string): string[] {
    const tokens: string[] = [];
    const chars = text.split('');

    for (let i = 0; i < chars.length; i++) {
      const char = chars[i];

      // 中文字符:按字切分 + 二元组
      if (/[一-龥]/.test(char)) {
        tokens.push(char);
        if (i < chars.length - 1 && /[一-龥]/.test(chars[i + 1])) {
          tokens.push(char + chars[i + 1]);
        }
      }
      // 英文/数字:按单词切分
      else if (/[a-zA-Z0-9]/.test(char)) {
        let word = char;
        while (i + 1 < chars.length && /[a-zA-Z0-9]/.test(chars[i + 1])) {
          word += chars[++i];
        }
        tokens.push(word.toLowerCase());
      }
    }

    return [...new Set(tokens)]; // 去重
  }

  /**
   * 为FTS查询构建OR表达式
   */
  static buildFtsQuery(keywords: string): string {
    const tokens = this.tokenize(keywords);
    return tokens.map(t => `"${t}"`).join(' OR ');
  }
}

四、ArkUI Search组件与搜索页面实现

在这里插入图片描述

4.1 搜索页面完整实现

// pages/SearchPage.ets
import { router } from '@kit.ArkUI';
import { SearchDatabase, SearchResult } from '../database/SearchDatabase';
import { SearchHistoryManager } from '../utils/SearchHistoryManager';
import { ChineseTokenizer } from '../utils/ChineseTokenizer';

@Entry
@Component
struct SearchPage {
  @State searchKeyword: string = '';
  @State searchResults: SearchResult[] = [];
  @State searchSuggestions: string[] = [];
  @State searchHistory: string[] = [];
  @State hotKeywords: string[] = ['智能手表', '蓝牙耳机', '平板电脑', '智能家居'];
  @State isSearching: boolean = false;
  @State selectedFilter: string = 'all';
  @State hasMore: boolean = false;
  @State offset: number = 0;

  private readonly PAGE_SIZE = 20;
  private db: SearchDatabase = new SearchDatabase();
  private historyManager: SearchHistoryManager = new SearchHistoryManager();

  aboutToAppear(): void {
    this.db.init(getContext(this));
    this.loadSearchHistory();
  }

  aboutToDisappear(): void {
    this.db.close();
  }

  private async loadSearchHistory(): Promise<void> {
    this.searchHistory = await this.historyManager.getHistory();
  }

  private async performSearch(keyword: string, append: boolean = false): Promise<void> {
    if (!keyword.trim()) {
      this.searchResults = [];
      this.isSearching = false;
      return;
    }

    this.isSearching = true;

    try {
      const options = {
        type: this.selectedFilter === 'all' ? undefined : this.selectedFilter,
        limit: this.PAGE_SIZE,
        offset: append ? this.offset : 0,
        sortBy: 'relevance' as const
      };

      const results = await this.db.search(keyword, options);

      if (append) {
        this.searchResults = [...this.searchResults, ...results];
      } else {
        this.searchResults = results;
      }

      this.hasMore = results.length === this.PAGE_SIZE;
      this.offset = append ? this.offset + results.length : results.length;

      // 保存搜索历史
      await this.historyManager.addHistory(keyword);
      this.searchHistory = await this.historyManager.getHistory();

    } catch (err) {
      console.error('[SearchPage] Search failed:', err);
    } finally {
      this.isSearching = false;
    }
  }

  private async onKeywordChange(keyword: string): Promise<void> {
    this.searchKeyword = keyword;

    if (keyword.length >= 1) {
      // 实时搜索建议
      this.searchSuggestions = await this.db.getSuggestions(keyword, 5);
    } else {
      this.searchSuggestions = [];
      this.searchResults = [];
    }
  }

  private onSearchSubmit(): void {
    if (this.searchKeyword.trim()) {
      this.offset = 0;
      this.performSearch(this.searchKeyword);
      this.searchSuggestions = [];
    }
  }

  private onLoadMore(): void {
    if (this.hasMore && !this.isSearching) {
      this.performSearch(this.searchKeyword, true);
    }
  }

  private onResultClick(result: SearchResult): void {
    // 根据类型路由到不同页面
    const routeMap: Record<string, string> = {
      'product': 'pages/ProductDetail',
      'order': 'pages/OrderDetail',
      'note': 'pages/NoteDetail',
      'contact': 'pages/ContactDetail'
    };

    const pageUrl = routeMap[result.type] || 'pages/Detail';

    router.pushUrl({
      url: pageUrl,
      params: {
        id: result.id,
        type: result.type,
        title: result.title,
        extraData: result.extraData
      }
    }).catch(err => {
      console.error('[SearchPage] Navigation failed:', err);
    });
  }

  private onFilterChange(filter: string): void {
    this.selectedFilter = filter;
    this.offset = 0;
    if (this.searchKeyword.trim()) {
      this.performSearch(this.searchKeyword);
    }
  }

  build() {
    Column({ space: 0 }) {
      // 顶部搜索栏
      this.SearchHeader()

      // 搜索建议下拉
      if (this.searchSuggestions.length > 0 && this.searchResults.length === 0) {
        this.SuggestionPanel()
      }

      // 搜索结果为空时展示历史和热门
      if (this.searchResults.length === 0 && !this.isSearching && !this.searchKeyword) {
        this.HistoryAndHotPanel()
      }

      // 筛选标签栏
      if (this.searchResults.length > 0 || this.isSearching) {
        this.FilterBar()
      }

      // 搜索结果列表
      if (this.searchResults.length > 0) {
        this.ResultList()
      }

      // 加载状态
      if (this.isSearching) {
        LoadingProgress()
          .width(32)
          .height(32)
          .color('#1976D2')
          .margin(20)
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }

  @Builder
  SearchHeader() {
    Row({ space: 12 }) {
      Image($r('app.media.ic_back'))
        .width(24)
        .height(24)
        .fillColor('#424242')
        .onClick(() => {
          router.back();
        })

      Search({ value: this.searchKeyword, placeholder: '搜索商品、订单、笔记...' })
        .width('100%')
        .height(40)
        .backgroundColor('#FFFFFF')
        .borderRadius(20)
        .fontSize(14)
        .placeholderColor('#9E9E9E')
        .onChange((value: string) => {
          this.onKeywordChange(value);
        })
        .onSubmit(() => {
          this.onSearchSubmit();
        })

      if (this.searchKeyword) {
        Text('搜索')
          .fontSize(14)
          .fontColor('#1976D2')
          .fontWeight(FontWeight.Medium)
          .onClick(() => {
            this.onSearchSubmit();
          })
      }
    }
    .width('100%')
    .height(56)
    .padding({ left: 16, right: 16 })
    .backgroundColor('#FFFFFF')
  }

  @Builder
  SuggestionPanel() {
    Column({ space: 0 }) {
      ForEach(this.searchSuggestions, (suggestion: string, index: number) => {
        Row({ space: 8 }) {
          Image($r('app.media.ic_search'))
            .width(16)
            .height(16)
            .fillColor('#9E9E9E')
          Text(suggestion)
            .fontSize(14)
            .fontColor('#212121')
            .layoutWeight(1)
          Image($r('app.media.ic_arrow_up'))
            .width(16)
            .height(16)
            .fillColor('#BDBDBD')
            .rotate({ angle: 45 })
        }
        .width('100%')
        .height(44)
        .padding({ left: 16, right: 16 })
        .backgroundColor('#FFFFFF')
        .onClick(() => {
          this.searchKeyword = suggestion;
          this.onSearchSubmit();
        })

        if (index < this.searchSuggestions.length - 1) {
          Divider().height(0.5).color('#E0E0E0').margin({ left: 40 })
        }
      })
    }
    .width('100%')
    .backgroundColor('#FFFFFF')
    .borderRadius({ bottomLeft: 12, bottomRight: 12 })
    .shadow({ radius: 4, color: 'rgba(0,0,0,0.1)', offsetY: 2 })
  }

  @Builder
  HistoryAndHotPanel() {
    Scroll() {
      Column({ space: 16 }) {
        // 搜索历史
        if (this.searchHistory.length > 0) {
          Column({ space: 12 }) {
            Row() {
              Text('搜索历史')
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor('#212121')
              Blank()
              Text('清除')
                .fontSize(12)
                .fontColor('#9E9E9E')
                .onClick(() => {
                  this.historyManager.clearHistory();
                  this.searchHistory = [];
                })
            }
            .width('100%')

            Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Start }) {
              ForEach(this.searchHistory, (item: string) => {
                Text(item)
                  .fontSize(12)
                  .fontColor('#616161')
                  .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                  .backgroundColor('#F5F5F5')
                  .borderRadius(16)
                  .margin(4)
                  .onClick(() => {
                    this.searchKeyword = item;
                    this.onSearchSubmit();
                  })
              })
            }
            .width('100%')
          }
          .width('100%')
          .padding(16)
          .backgroundColor('#FFFFFF')
          .borderRadius(12)
        }

        // 热门搜索
        Column({ space: 12 }) {
          Text('热门搜索')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor('#212121')
            .width('100%')

          Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Start }) {
            ForEach(this.hotKeywords, (item: string, index: number) => {
              Row({ space: 4 }) {
                if (index < 3) {
                  Text(`${index + 1}`)
                    .fontSize(10)
                    .fontColor('#FFFFFF')
                    .width(16)
                    .height(16)
                    .textAlign(TextAlign.Center)
                    .backgroundColor(index === 0 ? '#C62828' : index === 1 ? '#F57C00' : '#FBC02D')
                    .borderRadius(4)
                }
                Text(item)
                  .fontSize(12)
                  .fontColor('#616161')
                  .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                  .backgroundColor('#F5F5F5')
                  .borderRadius(16)
                  .margin(4)
                  .onClick(() => {
                    this.searchKeyword = item;
                    this.onSearchSubmit();
                  })
              }
            })
          }
          .width('100%')
        }
        .width('100%')
        .padding(16)
        .backgroundColor('#FFFFFF')
        .borderRadius(12)
      }
      .width('100%')
      .padding(16)
    }
    .width('100%')
    .layoutWeight(1)
  }

  @Builder
  FilterBar() {
    Row({ space: 8 }) {
      const filters = [
        { key: 'all', label: '全部' },
        { key: 'product', label: '商品' },
        { key: 'order', label: '订单' },
        { key: 'note', label: '笔记' },
        { key: 'contact', label: '联系人' }
      ];

      ForEach(filters, (filter: { key: string, label: string }) => {
        Text(filter.label)
          .fontSize(13)
          .fontColor(this.selectedFilter === filter.key ? '#1976D2' : '#757575')
          .fontWeight(this.selectedFilter === filter.key ? FontWeight.Bold : FontWeight.Normal)
          .padding({ left: 12, right: 12, top: 6, bottom: 6 })
          .backgroundColor(this.selectedFilter === filter.key ? '#E3F2FD' : 'transparent')
          .borderRadius(16)
          .onClick(() => {
            this.onFilterChange(filter.key);
          })
      })
    }
    .width('100%')
    .height(44)
    .padding({ left: 16, right: 16 })
    .backgroundColor('#FFFFFF')
  }

  @Builder
  ResultList() {
    List({ space: 8 }) {
      ForEach(this.searchResults, (result: SearchResult, index: number) => {
        ListItem() {
          this.ResultCard(result)
        }
      })

      // 加载更多
      if (this.hasMore) {
        ListItem() {
          Row() {
            Text('加载更多')
              .fontSize(14)
              .fontColor('#1976D2')
              .onClick(() => {
                this.onLoadMore();
              })
          }
          .width('100%')
          .height(48)
          .justifyContent(FlexAlign.Center)
        }
      }
    }
    .width('100%')
    .layoutWeight(1)
    .padding({ left: 16, right: 16, top: 8 })
    .divider({ strokeWidth: 0.5, color: '#E0E0E0' })
  }

  @Builder
  ResultCard(result: SearchResult) {
    Row({ space: 12 }) {
      // 类型图标
      Column() {
        Image(this.getTypeIcon(result.type))
          .width(40)
          .height(40)
          .fillColor(this.getTypeColor(result.type))
      }
      .width(48)
      .height(48)
      .backgroundColor(this.getTypeBgColor(result.type))
      .borderRadius(8)
      .justifyContent(FlexAlign.Center)

      // 内容区
      Column({ space: 4 }) {
        // 标题(高亮匹配)
        Text(this.highlightText(result.title, this.searchKeyword))
          .fontSize(15)
          .fontWeight(FontWeight.Medium)
          .fontColor('#212121')
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })

        // 内容摘要
        if (result.snippet) {
          Text(result.snippet)
            .fontSize(12)
            .fontColor('#616161')
            .maxLines(2)
            .textOverflow({ overflow: TextOverflow.Ellipsis })
        }

        // 元信息
        Row({ space: 8 }) {
          Text(this.getTypeLabel(result.type))
            .fontSize(10)
            .fontColor('#FFFFFF')
            .padding({ left: 6, right: 6, top: 2, bottom: 2 })
            .backgroundColor(this.getTypeColor(result.type))
            .borderRadius(4)

          Text(this.formatTime(result.timestamp))
            .fontSize(10)
            .fontColor('#9E9E9E')

          Text(`匹配度: ${(100 - result.rank * 10).toFixed(0)}%`)
            .fontSize(10)
            .fontColor('#4CAF50')
        }
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%')
    .padding(12)
    .backgroundColor('#FFFFFF')
    .borderRadius(12)
    .onClick(() => {
      this.onResultClick(result);
    })
  }

  private getTypeIcon(type: string): Resource {
    const iconMap: Record<string, Resource> = {
      'product': $r('app.media.ic_product'),
      'order': $r('app.media.ic_order'),
      'note': $r('app.media.ic_note'),
      'contact': $r('app.media.ic_contact')
    };
    return iconMap[type] || $r('app.media.ic_default');
  }

  private getTypeColor(type: string): ResourceColor {
    const colorMap: Record<string, ResourceColor> = {
      'product': '#1976D2',
      'order': '#4CAF50',
      'note': '#F57C00',
      'contact': '#7B1FA2'
    };
    return colorMap[type] || '#757575';
  }

  private getTypeBgColor(type: string): ResourceColor {
    const colorMap: Record<string, ResourceColor> = {
      'product': '#E3F2FD',
      'order': '#E8F5E9',
      'note': '#FFF3E0',
      'contact': '#F3E5F5'
    };
    return colorMap[type] || '#F5F5F5';
  }

  private getTypeLabel(type: string): string {
    const labelMap: Record<string, string> = {
      'product': '商品',
      'order': '订单',
      'note': '笔记',
      'contact': '联系人'
    };
    return labelMap[type] || '其他';
  }

  private highlightText(text: string, keyword: string): string {
    // 实际项目中使用RichText或Span实现高亮
    // 此处简化处理
    return text;
  }

  private formatTime(timestamp: number): string {
    const date = new Date(timestamp);
    return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
  }
}

4.2 搜索历史管理器

// utils/SearchHistoryManager.ets
import { preferences } from '@kit.ArkData';

export class SearchHistoryManager {
  private static readonly PREF_NAME = 'search_history';
  private static readonly MAX_HISTORY = 20;
  private pref: preferences.Preferences | null = null;

  async init(context: Context): Promise<void> {
    this.pref = await preferences.getPreferences(context, SearchHistoryManager.PREF_NAME);
  }

  async addHistory(keyword: string): Promise<void> {
    if (!keyword.trim()) return;

    const history = await this.getHistory();

    // 去重并移到顶部
    const filtered = history.filter(item => item !== keyword);
    filtered.unshift(keyword);

    // 限制数量
    const trimmed = filtered.slice(0, SearchHistoryManager.MAX_HISTORY);

    await this.pref?.put('history', JSON.stringify(trimmed));
    await this.pref?.flush();
  }

  async getHistory(): Promise<string[]> {
    const raw = await this.pref?.get('history', '[]') as string;
    try {
      return JSON.parse(raw) as string[];
    } catch {
      return [];
    }
  }

  async clearHistory(): Promise<void> {
    await this.pref?.delete('history');
    await this.pref?.flush();
  }
}

五、Intents Kit 全局搜索集成

HarmonyOS 6新增的Intents Kit允许开发者将应用内的功能和内容通过意图框架共享到系统全局搜索,实现「一步搜索,内容直达」。

在这里插入图片描述

5.1 意图配置与注册

resources/base/profile/intents_config.json中声明可搜索意图:

{
  "intents": [
    {
      "intentName": "SearchProduct",
      "domain": "shopping",
      "entities": [
        {
          "entityName": "Product",
          "fields": [
            { "fieldName": "name", "fieldType": "string", "required": true },
            { "fieldName": "category", "fieldType": "string", "required": false },
            { "fieldName": "price", "fieldType": "number", "required": false }
          ]
        }
      ],
      "actions": [
        {
          "actionName": "viewProduct",
          "abilityName": "EntryAbility",
          "parameters": {
            "page": "ProductDetail",
            "productId": "${id}"
          }
        }
      ]
    },
    {
      "intentName": "SearchOrder",
      "domain": "shopping",
      "entities": [
        {
          "entityName": "Order",
          "fields": [
            { "fieldName": "orderId", "fieldType": "string", "required": true },
            { "fieldName": "status", "fieldType": "string", "required": false }
          ]
        }
      ],
      "actions": [
        {
          "actionName": "viewOrder",
          "abilityName": "EntryAbility",
          "parameters": {
            "page": "OrderDetail",
            "orderId": "${orderId}"
          }
        }
      ]
    }
  ]
}

module.json5中注册Intents配置:

{
  "module": {
    "extensionAbilities": [
      {
        "name": "IntentsExtensionAbility",
        "srcEntry": "./ets/intents/IntentsExtensionAbility.ets",
        "type": "intents",
        "metadata": [
          {
            "name": "ohos.intents.config",
            "resource": "$profile:intents_config"
          }
        ]
      }
    ]
  }
}

5.2 IntentsExtensionAbility 实现

// intents/IntentsExtensionAbility.ets
import { IntentsExtensionAbility, IntentRequest, IntentResponse } from '@kit.IntentsKit';
import { SearchDatabase } from '../database/SearchDatabase';

export default class AppIntentsExtensionAbility extends IntentsExtensionAbility {
  private db: SearchDatabase = new SearchDatabase();

  async onCreate(): Promise<void> {
    await this.db.init(this.context);
  }

  /**
   * 处理系统搜索请求
   * 当用户在全局搜索中输入关键词时触发
   */
  async onExecute(request: IntentRequest): Promise<IntentResponse> {
    const { intentName, parameters } = request;

    console.info(`[IntentsExtension] Execute: ${intentName}, params: ${JSON.stringify(parameters)}`);

    switch (intentName) {
      case 'SearchProduct':
        return await this.handleProductSearch(parameters);
      case 'SearchOrder':
        return await this.handleOrderSearch(parameters);
      default:
        return { code: -1, message: 'Unknown intent' };
    }
  }

  private async handleProductSearch(params: Record<string, Object>): Promise<IntentResponse> {
    const keyword = params['name'] as string || '';

    if (!keyword) {
      return { code: -1, message: 'Missing keyword' };
    }

    const results = await this.db.search(keyword, { type: 'product', limit: 5 });

    return {
      code: 0,
      message: 'success',
      data: {
        results: results.map(r => ({
          title: r.title,
          subtitle: r.content.substring(0, 50),
          icon: $r('app.media.ic_product'),
          action: {
            bundleName: 'com.example.smartshop',
            abilityName: 'EntryAbility',
            parameters: {
              page: 'ProductDetail',
              productId: r.id,
              title: r.title
            }
          }
        }))
      }
    };
  }

  private async handleOrderSearch(params: Record<string, Object>): Promise<IntentResponse> {
    const orderId = params['orderId'] as string || '';

    const results = await this.db.search(orderId, { type: 'order', limit: 5 });

    return {
      code: 0,
      message: 'success',
      data: {
        results: results.map(r => ({
          title: `订单 ${r.title}`,
          subtitle: r.content.substring(0, 50),
          icon: $r('app.media.ic_order'),
          action: {
            bundleName: 'com.example.smartshop',
            abilityName: 'EntryAbility',
            parameters: {
              page: 'OrderDetail',
              orderId: r.id
            }
          }
        }))
      }
    };
  }

  async onDestroy(): Promise<void> {
    await this.db.close();
  }
}

在这里插入图片描述

六、搜索性能优化

6.1 索引优化策略

优化项 问题描述 解决方案
索引体积 FTS5索引膨胀导致查询变慢 定期执行OPTIMIZE,合并倒排列表
增量更新 全量重建索引耗时 使用触发器实现主表与FTS表同步
中文分词 默认分词器对中文支持差 N-gram策略或集成云端分词API
查询缓存 重复查询浪费资源 LRU缓存高频查询结果
分页加载 大数据量一次性加载卡顿 LIMIT/OFFSET分页,配合虚拟列表

6.2 查询性能监控

// utils/SearchPerformanceMonitor.ets
export class SearchPerformanceMonitor {
  private static metrics: Map<string, number[]> = new Map();

  static async measure<T>(label: string, fn: () => Promise<T>): Promise<T> {
    const start = Date.now();
    const result = await fn();
    const duration = Date.now() - start;

    if (!this.metrics.has(label)) {
      this.metrics.set(label, []);
    }
    this.metrics.get(label)?.push(duration);

    console.info(`[SearchPerf] ${label}: ${duration}ms`);
    return result;
  }

  static getStats(label: string): { avg: number; max: number; min: number; count: number } {
    const data = this.metrics.get(label) || [];
    if (data.length === 0) return { avg: 0, max: 0, min: 0, count: 0 };

    return {
      avg: data.reduce((a, b) => a + b, 0) / data.length,
      max: Math.max(...data),
      min: Math.min(...data),
      count: data.length
    };
  }
}

// 使用示例
const results = await SearchPerformanceMonitor.measure('local_search', () =>
  db.search(keyword, options)
);

6.3 搜索延迟优化

// 防抖搜索(避免频繁触发)
private searchDebounceTimer: number = -1;

private debouncedSearch(keyword: string): void {
  if (this.searchDebounceTimer !== -1) {
    clearTimeout(this.searchDebounceTimer);
  }

  this.searchDebounceTimer = setTimeout(() => {
    this.performSearch(keyword);
  }, 300); // 300ms防抖
}

// 预加载热门搜索索引
async preloadHotSearchIndex(): Promise<void> {
  const hotKeywords = ['智能手表', '蓝牙耳机', '平板电脑'];
  for (const keyword of hotKeywords) {
    const results = await this.db.search(keyword, { limit: 5 });
    // 缓存到内存
    AppStorage.setOrCreate(`search_cache_${keyword}`, results);
  }
}

七、完整项目结构

entry/src/main/
├── ets/
│   ├── entryability/
│   │   └── EntryAbility.ets          # 入口Ability,处理搜索跳转
│   ├── intents/
│   │   └── IntentsExtensionAbility.ets  # Intents Kit扩展Ability
│   ├── pages/
│   │   ├── Index.ets                 # 应用主页
│   │   ├── SearchPage.ets           # 搜索页面
│   │   ├── ProductDetail.ets        # 商品详情
│   │   ├── OrderDetail.ets          # 订单详情
│   │   └── NoteDetail.ets           # 笔记详情
│   ├── database/
│   │   └── SearchDatabase.ets       # FTS数据库管理
│   ├── utils/
│   │   ├── ChineseTokenizer.ets     # 中文分词
│   │   ├── SearchHistoryManager.ets # 搜索历史
│   │   └── SearchPerformanceMonitor.ets # 性能监控
│   └── model/
│       └── SearchResult.ets         # 搜索数据模型
├── resources/
│   └── base/
│       ├── media/                    # 图标资源
│       └── profile/
│           ├── main_pages.json        # 页面路由
│           ├── intents_config.json    # 意图配置
│           └── form_config.json       # 卡片配置(如有)
└── module.json5                     # 模块配置

八、踩坑总结与最佳实践

8.1 常见踩坑

坑1:FTS5表查询返回空结果
原因:默认tokenizer不支持中文分词,中文查询需逐字匹配
解决:使用N-gram策略或自定义tokenizer

坑2:触发器导致插入性能下降
原因:大量数据插入时触发器频繁触发
解决:批量插入时临时禁用触发器,完成后手动重建FTS索引

坑3:Intents Kit注册后全局搜索不显示结果
原因:intents_config.json格式错误或module.json5未正确注册
解决:严格遵循schema格式,metadata.name必须为ohos.intents.config

坑4:搜索页面卡顿
原因:大数据量一次性渲染
解决:使用List+LazyForEach实现虚拟列表,分页加载

坑5:搜索历史丢失
原因:Preferences未flush或key冲突
解决:每次写入后调用flush(),使用应用唯一前缀命名key

8.2 最佳实践清单

✅ 索引层
  • 使用FTS5虚拟表存储倒排索引,主表与FTS表通过触发器同步
  • 中文搜索采用N-gram(2元组)策略,平衡索引体积与召回率
  • 定期执行OPTIMIZE合并索引碎片
  • 为高频查询字段建立单独索引(如type、timestamp)

✅ 查询层
  • 搜索输入使用300ms防抖,避免频繁查询
  • 结果分页加载(LIMIT 20 OFFSET n)
  • 使用LRU缓存最近10次查询结果
  • 搜索建议基于前缀匹配(LIKE 'prefix%'),利用索引加速

✅ UI层
  • Search组件支持onChange实时建议 + onSubmit正式搜索
  • 结果列表使用虚拟滚动(LazyForEach)
  • 空状态时展示搜索历史 + 热门推荐
  • 结果项支持类型图标、高亮摘要、相关性标签

✅ 全局搜索
  • 通过Intents Kit将核心内容注册到系统全局搜索
  • 意图配置覆盖用户高频查询场景(查订单、搜商品、找笔记)
  • 全局搜索结果需包含直达Action,减少用户操作步骤

九、总结与展望

本文基于HarmonyOS 6(API 23),从系统架构到工程实践,完整解析了应用内搜索的开发全链路。通过RelationalDatabase FTS5全文索引、ArkUI Search组件、Intents Kit全局搜索集成三大核心技术,开发者可以构建从本地内容搜索到系统级全局搜索的完整搜索体验。

核心要点回顾

  1. 三层架构:用户输入层(Search/语音/全局搜索)→ 搜索处理层(FTS/意图/排序)→ 数据层(本地/云端/文件)
  2. FTS索引:RelationalDatabase FTS5虚拟表 + 触发器同步 + 中文N-gram分词策略
  3. 搜索UI:Search组件防抖查询 + 实时建议 + 分类筛选 + 虚拟列表分页
  4. 全局搜索:Intents Kit意图框架将应用内容共享到系统,实现「一步搜索,内容直达」
  5. 性能优化:索引碎片合并、查询防抖、LRU缓存、性能监控

未来演进方向

  • AI语义搜索:结合HarmonyOS 6端侧大模型,实现基于语义的向量搜索(Embedding Search),超越关键词匹配的局限
  • 跨设备搜索:利用分布式软总线,实现手机搜索平板笔记、车机搜索手机订单的跨设备内容发现
  • 多模态搜索:支持图片搜索(以图搜商品)、语音搜索(语音转文字+意图识别)、扫码搜索(条形码识别)
  • 个性化排序:基于用户行为数据(点击、收藏、购买)训练个性化排序模型,提升搜索结果相关性

搜索是用户与内容之间的桥梁,其体验质量直接影响应用的用户留存与活跃度。期待更多开发者善用HarmonyOS 6的搜索能力,为用户打造「所想即所得」的极致搜索体验。


转载自:https://blog.csdn.net/u014727709/article/details/163802284
欢迎 👍点赞✍评论⭐收藏,欢迎指正

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐