查看: 124|回复: 1

鸿蒙开发实战:从零构建全局搜索工具的架构与实现

[复制链接]
发表于 1 小时前 | 显示全部楼层 |阅读模式
在数字化办公场景中,快速定位文件、应用和系统设置是提升效率的关键。HarmonyOS 提供了融合搜索能力,支持应用内搜索和系统全局搜索,这为开发者打造跨设备、多模态的搜索工具奠定了基础。本文基于三层架构,使用 ArkUI 和 ArkTS 实现一个鸿蒙全局搜索工具,涵盖搜索界面、搜索引擎核心、索引管理及性能优化策略。

### 系统架构设计
全局搜索工具采用数据层、业务逻辑层和 UI 层的三层架构。数据层负责索引管理、搜索引擎和结果排序;业务逻辑层包含全局搜索调度器、跨设备协同引擎和意图识别模块;UI 层提供简洁直观的搜索界面,支持文本、语音、图像多模态输入。这种分层设计充分利用鸿蒙分布式特性,保证高性能和可扩展性。

### 核心功能实现
#### 搜索主界面
搜索界面使用 ArkUI 声明式组件构建,核心在于 `GlobalSearch` 结构体。它通过 `@State` 管理搜索关键词、历史记录和结果列表,采用 `@Builder` 分离搜索栏、历史记录和结果列表的布局。搜索栏使用 `Search` 组件,设置圆角样式和占位提示;历史记录区域渲染最近10条搜索记录,每条记录支持点击搜索和删除操作;结果列表采用 `List` 组件展示,每个结果项显示类型图标、标题和路径,点击后执行打开操作。
  1. // GlobalSearch.ets
  2. @Entry
  3. @Component
  4. struct GlobalSearch {
  5.   @State searchKeyword: string = ''
  6.   @State searchHistory: string[] = ['鸿蒙系统开发', '性能优化', '分布式架构']
  7.   @State searchResults: SearchResult[] = []
  8.   @State isSearching: boolean = false
  9.   build() {
  10.     Column() {
  11.       this.SearchBar()
  12.       if (this.searchKeyword === '') { this.SearchHistory() }
  13.       if (this.searchResults.length > 0) { this.SearchResults() }
  14.     }
  15.     .width('100%').height('100%').backgroundColor('#F5F5F5')
  16.   }
  17.   @Builder SearchBar() {
  18.     Search({ placeholder: '搜索文件、应用、设置...', value: this.searchKeyword })
  19.       .width('90%').height(50).margin({ top: 20, bottom: 10 })
  20.       .backgroundColor(Color.White).borderRadius(25)
  21.       .onChange((value: string) => {
  22.         this.searchKeyword = value
  23.         if (value.length > 0) { this.performSearch(value) }
  24.       })
  25.       .onSubmit(() => { this.addToHistory(this.searchKeyword) })
  26.   }
  27.   @Builder SearchHistory() {
  28.     Column() {
  29.       Text('搜索历史').fontSize(16).fontWeight(FontWeight.Bold).margin({ left: 20, top: 10 })
  30.       ForEach(this.searchHistory, (item: string) => {
  31.         Row() {
  32.           Text(item).fontSize(14).margin({ left: 20 })
  33.           Spacer()
  34.           Image($r('app.media.ic_delete')).width(20).height(20).margin({ right: 20 })
  35.             .onClick(() => { this.removeHistory(item) })
  36.         }
  37.         .width('100%').height(45).backgroundColor(Color.White).margin({ top: 5 })
  38.         .onClick(() => {
  39.           this.searchKeyword = item
  40.           this.performSearch(item)
  41.         })
  42.       })
  43.     }
  44.   }
  45.   @Builder SearchResults() {
  46.     List() {
  47.       ForEach(this.searchResults, (item: SearchResult) => {
  48.         Row() {
  49.           Image(this.getIconByType(item.type)).width(40).height(40).margin({ left: 15 })
  50.           Column() {
  51.             Text(item.title).fontSize(16).fontWeight(FontWeight.Medium)
  52.             Text(item.path).fontSize(12).fontColor('#999999').maxLines(1)
  53.           }.margin({ left: 15 }).alignItems(HorizontalAlign.Start)
  54.           Spacer()
  55.         }
  56.         .width('100%').height(70).backgroundColor(Color.White).margin({ top: 2 })
  57.         .onClick(() => { this.openItem(item) })
  58.       })
  59.     }
  60.     .width('100%').layoutWeight(1)
  61.   }
  62.   private performSearch(keyword: string) {
  63.     this.isSearching = true
  64.     setTimeout(() => {
  65.       this.searchResults = [
  66.         { title: '鸿蒙开发指南.pdf', path: '/文档/技术资料/鸿蒙开发指南.pdf', type: 'document', size: '2.3 MB' },
  67.         { title: '系统设置', path: '设置/系统/显示', type: 'setting', size: '' },
  68.         { title: 'DevEco Studio', path: '/应用/开发工具/DevEco Studio', type: 'application', size: '1.2 GB' }
  69.       ]
  70.       this.isSearching = false
  71.     }, 300)
  72.   }
  73.   private addToHistory(keyword: string) {
  74.     if (!this.searchHistory.includes(keyword)) {
  75.       this.searchHistory.unshift(keyword)
  76.       if (this.searchHistory.length > 10) { this.searchHistory.pop() }
  77.     }
  78.   }
  79.   private removeHistory(item: string) {
  80.     this.searchHistory = this.searchHistory.filter(h => h !== item)
  81.   }
  82.   private getIconByType(type: string): Resource {
  83.     switch(type) {
  84.       case 'document': return $r('app.media.ic_document')
  85.       case 'application': return $r('app.media.ic_app')
  86.       case 'setting': return $r('app.media.ic_setting')
  87.       default: return $r('app.media.ic_file')
  88.     }
  89.   }
  90.   private openItem(item: SearchResult) {
  91.     console.info(`Opening: ${item.title}`)
  92.   }
  93. }
  94. class SearchResult {
  95.   title: string = ''
  96.   path: string = ''
  97.   type: string = ''
  98.   size: string = ''
  99. }
复制代码

#### 搜索引擎核心模块
搜索引擎类 `SearchEngine` 封装了文件、应用、设置三类内容的并行搜索逻辑。它使用 `IndexManager` 管理索引,通过 `KeywordAnalyzer` 进行分词。搜索时根据 `SearchOptions` 分别调用 `searchFiles`、`searchApplications`、`searchSettings` 方法,最后对结果排序去重。
  1. // SearchEngine.ets
  2. export class SearchEngine {
  3.   private indexManager: IndexManager
  4.   private keywordAnalyzer: KeywordAnalyzer
  5.   constructor() {
  6.     this.indexManager = new IndexManager()
  7.     this.keywordAnalyzer = new KeywordAnalyzer()
  8.   }
  9.   async search(keyword: string, options: SearchOptions): Promise<SearchResult[]> {
  10.     const keywords = this.keywordAnalyzer.analyze(keyword)
  11.     const results: SearchResult[] = []
  12.     if (options.searchFiles) {
  13.       const fileResults = await this.searchFiles(keywords)
  14.       results.push(...fileResults)
  15.     }
  16.     if (options.searchApps) {
  17.       const appResults = await this.searchApplications(keywords)
  18.       results.push(...appResults)
  19.     }
  20.     if (options.searchSettings) {
  21.       const settingResults = await this.searchSettings(keywords)
  22.       results.push(...settingResults)
  23.     }
  24.     return this.rankAndDeduplicate(results)
  25.   }
  26.   private async searchFiles(keywords: string[]): Promise<SearchResult[]> {
  27.     try {
  28.       const files = await fileio.getDirEntries('/data')
  29.       const results: SearchResult[] = []
  30.       for (const file of files) {
  31.         if (this.matchKeywords(file.name, keywords)) {
  32.           results.push({ title: file.name, path: file.path, type: 'document', size: this.formatSize(file.size) })
  33.         }
  34.       }
  35.       return results
  36.     } catch (error) {
  37.       console.error('File search error:', error)
  38.       return []
  39.     }
  40.   }
  41.   private async searchApplications(keywords: string[]): Promise<SearchResult[]> {
  42.     const bundleManager = bundle.getBundleManager()
  43.     const bundles = await bundleManager.getInstalledBundles()
  44.     return bundles
  45.       .filter(bundle => this.matchKeywords(bundle.appName, keywords))
  46.       .map(bundle => ({
  47.         title: bundle.appName,
  48.         path: bundle.bundleName,
  49.         type: 'application',
  50.         size: this.formatSize(bundle.size)
  51.       }))
  52.   }
  53.   private async searchSettings(keywords: string[]): Promise<SearchResult[]> {
  54.     const settings = [
  55.       { name: '显示设置', path: '设置/系统/显示' },
  56.       { name: '网络设置', path: '设置/连接/网络' },
  57.       { name: '声音设置', path: '设置/声音/音量' }
  58.     ]
  59.     return settings
  60.       .filter(setting => this.matchKeywords(setting.name, keywords))
  61.       .map(setting => ({ title: setting.name, path: setting.path, type: 'setting', size: '' }))
  62.   }
  63.   private matchKeywords(text: string, keywords: string[]): boolean {
  64.     const lowerText = text.toLowerCase()
  65.     return keywords.some(keyword => lowerText.includes(keyword.toLowerCase()))
  66.   }
  67.   private rankAndDeduplicate(results: SearchResult[]): SearchResult[] {
  68.     const uniqueResults = Array.from(new Map(results.map(item => [item.path, item])).values())
  69.     return uniqueResults.sort((a, b) => a.title.localeCompare(b.title))
  70.   }
  71.   private formatSize(bytes: number): string {
  72.     if (bytes < 1024) return bytes + ' B'
  73.     if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB'
  74.     return (bytes / (1024 * 1024)).toFixed(2) + ' MB'
  75.   }
  76. }
  77. class SearchOptions {
  78.   searchFiles: boolean = true
  79.   searchApps: boolean = true
  80.   searchSettings: boolean = true
  81. }
复制代码

#### 索引管理模块
索引管理器 `IndexManager` 在启动时构建全量索引,覆盖文件系统、已安装应用和系统设置。它使用 Map 存储索引条目,支持增删改操作。索引构建采用异步方式,分别调用 `indexFileSystem`、`indexApplications`、`indexSystemSettings`。文件路径包括 `/data`、`/storage`、`/cache`;应用通过 `bundle.getBundleManager()` 获取已安装包;设置项预定义了常用系统设置名称。
  1. // IndexManager.ets
  2. export class IndexManager {
  3.   private indexDB: Map<string, IndexEntry>
  4.   private isIndexing: boolean = false
  5.   constructor() {
  6.     this.indexDB = new Map()
  7.     this.startIndexing()
  8.   }
  9.   async startIndexing() {
  10.     if (this.isIndexing) return
  11.     this.isIndexing = true
  12.     try {
  13.       await this.indexFileSystem()
  14.       await this.indexApplications()
  15.       await this.indexSystemSettings()
  16.       console.info('Indexing completed')
  17.     } catch (error) {
  18.       console.error('Indexing error:', error)
  19.     } finally {
  20.       this.isIndexing = false
  21.     }
  22.   }
  23.   private async indexFileSystem() {
  24.     const directories = ['/data', '/storage', '/cache']
  25.     for (const dir of directories) {
  26.       try {
  27.         const entries = await fileio.getDirEntries(dir)
  28.         for (const entry of entries) {
  29.           this.indexDB.set(entry.path, {
  30.             name: entry.name,
  31.             path: entry.path,
  32.             type: this.getFileType(entry.name),
  33.             lastModified: entry.mtime,
  34.             size: entry.size
  35.           })
  36.         }
  37.       } catch (error) {
  38.         console.error(`Failed to index ${dir}:`, error)
  39.       }
  40.     }
  41.   }
  42.   private async indexApplications() {
  43.     const bundleManager = bundle.getBundleManager()
  44.     const bundles = await bundleManager.getInstalledBundles()
  45.     for (const bundle of bundles) {
  46.       this.indexDB.set(bundle.bundleName, {
  47.         name: bundle.appName,
  48.         path: bundle.bundleName,
  49.         type: 'application',
  50.         lastModified: bundle.installTime,
  51.         size: bundle.size
  52.       })
  53.     }
  54.   }
  55.   private indexSystemSettings() {
  56.     const settings = [
  57.       { name: '显示设置', path: 'settings/display' },
  58.       { name: '网络设置', path: 'settings/network' },
  59.       { name: '声音设置', path: 'settings/sound' }
  60.     ]
  61.     for (const setting of settings) {
  62.       this.indexDB.set(setting.path, {
  63.         name: setting.name,
  64.         path: setting.path,
  65.         type: 'setting',
  66.         lastModified: Date.now(),
  67.         size: 0
  68.       })
  69.     }
  70.   }
  71.   private getFileType(filename: string): string {
  72.     const ext = filename.split('.').pop()?.toLowerCase() || ''
  73.     const imageExts = ['jpg', 'png', 'gif', 'webp']
  74.     const docExts = ['pdf', 'doc', 'docx', 'txt']
  75.     const videoExts = ['mp4', 'avi', 'mkv']
  76.     if (imageExts.includes(ext)) return 'image'
  77.     if (docExts.includes(ext)) return 'document'
  78.     if (videoExts.includes(ext)) return 'video'
  79.     return 'file'
  80.   }
  81.   async updateIndex(path: string, operation: 'add' | 'update' | 'delete') {
  82.     if (operation === 'delete') {
  83.       this.indexDB.delete(path)
  84.     } else {
  85.       try {
  86.         const info = await fileio.getFileInfo(path)
  87.         this.indexDB.set(path, {
  88.           name: info.name,
  89.           path: path,
  90.           type: this.getFileType(info.name),
  91.           lastModified: info.mtime,
  92.           size: info.size
  93.         })
  94.       } catch (error) {
  95.         console.error('Update index error:', error)
  96.       }
  97.     }
  98.   }
  99. }
  100. class IndexEntry {
  101.   name: string = ''
  102.   path: string = ''
  103.   type: string = ''
  104.   lastModified: number = 0
  105.   size: number = 0
  106. }
复制代码

### 性能优化策略
#### 增量索引更新
鸿蒙系统支持实时监听文件系统变化(如 `FileWatcher`),我们采用增量索引策略,仅在文件创建、修改或删除时更新对应索引,避免全量重建导致的大量 I/O 和 CPU 开销。

#### 异步搜索与缓存
搜索结果使用 `SearchCache` 类缓存,默认缓存5分钟,减少重复搜索时的延迟。缓存以关键词为 key,存储结果和时间戳,在缓存有效期内直接返回。
  1. class SearchCache {
  2.   private cache: Map<string, { results: SearchResult[], timestamp: number }>
  3.   private readonly CACHE_TTL = 5 * 60 * 1000 // 5分钟
  4.   get(keyword: string): SearchResult[] | null {
  5.     const cached = this.cache.get(keyword)
  6.     if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) {
  7.       return cached.results
  8.     }
  9.     return null
  10.   }
  11.   set(keyword: string, results: SearchResult[]) {
  12.     this.cache.set(keyword, {
  13.       results,
  14.       timestamp: Date.now()
  15.     })
  16.   }
  17. }
复制代码

#### 多线程搜索
利用鸿蒙的并发能力(如 `TaskPool` 或 `Worker`),将文件搜索、应用搜索、设置搜索分配给不同线程并行执行,减少总搜索时间。上述 `SearchEngine.search` 中已使用 `async/await` 实现逻辑上的并发,实际部署时可创建独立线程以提升响应速度。

### 功能扩展与优化
#### 智能排序算法
排序时综合考虑搜索频率、最后访问时间、关键词匹配度(如标题优先、路径匹配次要),可参考 Lucene 的 TF-IDF 思想,在 `rankAndDeduplicate` 中实现加权评分。

#### 跨设备搜索
借助鸿蒙分布式能力(如 `DistributedDataManager` 或 `RemoteObject`),将索引和搜索请求同步到其他设备,实现手机、平板、PC 间无缝搜索。数据层需要支持跨设备索引同步。

#### 语音搜索集成
通过鸿蒙语音引擎 `@ohos.voiceEngine` 接收语音输入,转换为文本后再调用搜索接口。实现时需申请麦克风权限,并处理语音识别的回调结果。
  1. import voiceEngine from '@ohos.voiceEngine'
  2. async function startVoiceSearch(): Promise<string> {
  3.   return new Promise((resolve, reject) => {
  4.     voiceEngine.startListening({
  5.       onResult: (text: string) => resolve(text),
  6.       onError: (err: Error) => reject(err)
  7.     })
  8.   })
  9. }
复制代码

### 总结
本文基于 HarmonyOS 的三层架构和 ArkUI/ArkTS 技术,实现了一个支持文件、应用、设置搜索的全局搜索工具。通过索引管理、并行搜索、缓存和增量更新等策略保证了性能,并可扩展语音和跨设备搜索能力。开发者可根据实际需求调整搜索范围、排序算法和 UI 样式,打造个性化的桌面搜索体验。
回复

使用道具 举报

发表于 1 小时前 | 显示全部楼层

Re: 鸿蒙开发实战:从零构建全局搜索工具的架构与实现

感谢楼主的分享,这个三层架构设计思路清晰,把搜索界面、搜索引擎和索引管理分离开来,可扩展性确实强。代码中ArkUI的声明式写法也很直观,特别是通过@Builder拆分搜索栏和历史记录,代码复用度高。另外注意到示例代码末尾有个小截断,想请教一下,在多模态输入(比如语音搜索)这块,实际开发中你是如何与鸿蒙的语音识别服务对接的?期待后续关于索引更新和跨设备同步的更多细节。
回复 支持 反对

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-6-4 13:53 , Processed in 0.025170 second(s), 17 queries , Gzip On, Redis On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部