HarmonyOS NEXT 最近访问记录开发:RecentFile 模型、时间分组与自动记录机制实战

前言

文件管理应用中,用户经常需要回顾最近打开过的文件。最近访问记录(Recent Files)功能能够自动追踪用户的文件访问行为,并按时间维度分组展示,帮助用户快速回到上次的工作状态。本文基于 HarmonyExplorer 项目,深入讲解最近访问记录功能的完整开发流程,涵盖 RecentFile 数据模型设计、访问记录自动记录、Preferences 持久化、时间分组展示、去重与数量限制、清除历史记录等核心技术与实现细节。通过本文的学习,开发者可以掌握鸿蒙原生访问记录系统的设计模式。

最近访问记录功能是文件管理应用的标配功能,涉及数据采集、持久化存储和分组展示三大核心环节,参考 HarmonyOS 数据管理开发指南

一、最近访问功能概述

1.1 功能背景

在日常文件管理场景中,用户可能同时处理多个文件,频繁在目录间切换。如果没有访问记录功能,用户需要记住文件路径并手动导航,效率低下。最近访问功能自动记录用户打开的文件,并提供按时间分组的快速访问入口。

1.2 设计目标

最近访问功能的设计目标如下:

设计目标 说明
自动记录 用户打开文件时自动记录,无需手动操作
时间分组 按今天、昨天、更早三个时间段分组展示
去重处理 同一文件多次打开只保留最近一次记录
数量限制 最多保留 100 条记录,防止无限增长
一键清除 支持清空全部访问历史记录

二、RecentFile 数据模型设计

2.1 数据结构定义

RecentFile 模型记录文件访问的核心信息,采用路径索引设计,确保同一文件路径只保留一条记录。

// model/RecentFile.ets
export interface RecentFile {
  id: string;
  path: string;
  lastOpenTime: number;
}

export interface RecentGroup {
  title: string;
  items: Array<RecentItem>;
}

export interface RecentItem {
  recent: RecentFile;
  fileInfo: FileInfo;
}

export enum TimeGroupType {
  TODAY = '今天',
  YESTERDAY = '昨天',
  EARLIER = '更早'
}

2.2 模型字段说明

RecentFile 模型字段设计如下:

字段 类型 说明
id string 访问记录唯一标识
path string 文件完整路径,作为去重依据
lastOpenTime number 最后访问时间戳,用于排序和分组

提示:RecentFile 模型仅存储路径和访问时间,文件详情通过 FileUtil 动态查询,保证数据一致性,参考 HarmonyOS 文件系统开发文档

三、Preferences 持久化方案

3.1 存储结构设计

最近访问记录使用 Preferences 进行持久化存储。与收藏夹类似,采用 JSON 序列化方式将记录列表存储为字符串值。不同之处在于最近记录需要频繁更新,因此引入内存缓存机制减少 IO 操作。

3.2 PreferenceUtil 扩展

// utils/PreferenceUtil.ets 中的最近记录相关方法
const RECENT_KEY: string = 'recent_file_list';
const MAX_RECENT_COUNT: number = 100;

export class PreferenceUtil {
  public static async saveRecentFiles(recentFiles: Array<RecentFile>): Promise<void> {
    if (this.preferenceInstance === null) {
      return;
    }
    const limited: Array<RecentFile> = recentFiles.slice(0, MAX_RECENT_COUNT);
    const jsonStr: string = JSON.stringify(limited);
    await this.preferenceInstance.put(RECENT_KEY, jsonStr);
    await this.preferenceInstance.flush();
  }

  public static async loadRecentFiles(): Promise<Array<RecentFile>> {
    if (this.preferenceInstance === null) {
      return [];
    }
    const value: dataPreferences.ValueType = await this.preferenceInstance.get(RECENT_KEY, '');
    if (typeof value !== 'string' || value.length === 0) {
      return [];
    }
    const parsed: Array<RecentFile> = JSON.parse(value);
    return parsed;
  }

  public static async clearRecentFiles(): Promise<void> {
    if (this.preferenceInstance === null) {
      return;
    }
    await this.preferenceInstance.delete(RECENT_KEY);
    await this.preferenceInstance.flush();
  }
}

四、Recent Repository 层实现

4.1 Repository 接口设计

RecentRepository 负责访问记录的增删查改,核心逻辑包括去重、排序和数量限制。

// repository/RecentRepository.ets
export class RecentRepository {
  private recentFiles: Array<RecentFile> = [];
  private readonly maxCount: number = 100;

  public async loadAll(): Promise<Array<RecentFile>> {
    this.recentFiles = await PreferenceUtil.loadRecentFiles();
    return this.recentFiles;
  }

  public async addRecord(path: string): Promise<void> {
    this.removeDuplicate(path);
    const record: RecentFile = {
      id: this.generateId(),
      path: path,
      lastOpenTime: Date.now()
    };
    this.recentFiles.unshift(record);
    this.recentFiles = this.recentFiles.slice(0, this.maxCount);
    await PreferenceUtil.saveRecentFiles(this.recentFiles);
  }

  public async removeRecord(path: string): Promise<void> {
    this.recentFiles = this.recentFiles.filter((item: RecentFile): boolean => {
      return item.path !== path;
    });
    await PreferenceUtil.saveRecentFiles(this.recentFiles);
  }

  public async clearAll(): Promise<void> {
    this.recentFiles = [];
    await PreferenceUtil.clearRecentFiles();
  }

  private removeDuplicate(path: string): void {
    this.recentFiles = this.recentFiles.filter((item: RecentFile): boolean => {
      return item.path !== path;
    });
  }

  private generateId(): string {
    return 'recent_' + Date.now().toString() + Math.floor(Math.random() * 1000).toString();
  }
}

4.2 去重与数量限制逻辑

  1. 去重处理:每次添加记录前,先移除同路径的旧记录,再插入新记录到列表头部,确保最新访问排在最前
  2. 数量限制:添加记录后截取前 100 条,超出部分自动丢弃,防止 Preferences 存储无限增长

五、Recent ViewModel 实现

5.1 ViewModel 状态管理

RecentViewModel 管理 @Observed 响应式状态,包括分组列表、加载状态和清除操作。

// viewmodel/RecentViewModel.ets
@Observed
export class RecentViewModel extends ObservedObject {
  public groups: Array<RecentGroup> = [];
  public isLoading: boolean = false;
  public totalCount: number = 0;
  private repository: RecentRepository = new RecentRepository();

  public async loadRecentFiles(): Promise<void> {
    this.isLoading = true;
    const recentFiles: Array<RecentFile> = await this.repository.loadAll();
    const items: Array<RecentItem> = await this.buildRecentItems(recentFiles);
    this.groups = this.buildTimeGroups(items);
    this.totalCount = recentFiles.length;
    this.isLoading = false;
  }

  public async addRecentRecord(path: string): Promise<void> {
    await this.repository.addRecord(path);
    await this.loadRecentFiles();
  }

  public async clearAllRecords(): Promise<void> {
    await this.repository.clearAll();
    this.groups = [];
    this.totalCount = 0;
  }

  private async buildRecentItems(recentFiles: Array<RecentFile>): Promise<Array<RecentItem>> {
    const items: Array<RecentItem> = [];
    for (const recent of recentFiles) {
      const fileInfo: FileInfo | null = await FileUtil.getFileInfo(recent.path);
      if (fileInfo !== null) {
        items.push({ recent: recent, fileInfo: fileInfo });
      }
    }
    return items;
  }
}

5.2 时间分组逻辑

时间分组是最近访问功能的核心特色,将访问记录按时间维度分为三组展示:

  • 今天:当天 00:00 至当前时间访问的文件
  • 昨天:前一天 00:00 至当天 00:00 访问的文件
  • 更早:前一天 00:00 之前访问的文件
// viewmodel/RecentViewModel.ets 中的分组方法
private buildTimeGroups(items: Array<RecentItem>): Array<RecentGroup> {
  const groups: Array<RecentGroup> = [];
  const todayItems: Array<RecentItem> = [];
  const yesterdayItems: Array<RecentItem> = [];
  const earlierItems: Array<RecentItem> = [];

  const now: Date = new Date();
  const todayStart: number = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
  const yesterdayStart: number = todayStart - 86400000;

  for (const item of items) {
    if (item.recent.lastOpenTime >= todayStart) {
      todayItems.push(item);
    } else if (item.recent.lastOpenTime >= yesterdayStart) {
      yesterdayItems.push(item);
    } else {
      earlierItems.push(item);
    }
  }

  if (todayItems.length > 0) {
    groups.push({ title: TimeGroupType.TODAY, items: todayItems });
  }
  if (yesterdayItems.length > 0) {
    groups.push({ title: TimeGroupType.YESTERDAY, items: yesterdayItems });
  }
  if (earlierItems.length > 0) {
    groups.push({ title: TimeGroupType.EARLIER, items: earlierItems });
  }

  return groups;
}

六、访问记录自动记录机制

6.1 自动记录触发点

访问记录的自动记录需要在用户打开文件的关键节点触发。HarmonyExplorer 在以下场景自动记录:

  1. 用户在文件列表中点击文件项
  2. 用户在搜索结果中打开文件
  3. 用户在收藏列表中打开文件
  4. 用户通过外部链接打开文件

6.2 记录触发实现

// service/FileOpenService.ets
export class FileOpenService {
  private recentViewModel: RecentViewModel = new RecentViewModel();

  public async openFile(fileInfo: FileInfo): Promise<void> {
    await this.recentViewModel.addRecentRecord(fileInfo.path);
    await this.navigateToViewer(fileInfo);
    EventBus.emit('recent_file_opened', { path: fileInfo.path });
  }

  private async navigateToViewer(fileInfo: FileInfo): Promise<void> {
    const route: string = this.getRouteByType(fileInfo.type);
    await RouterUtil.push(route, { path: fileInfo.path });
  }

  private getRouteByType(type: FileType): string {
    switch (type) {
      case FileType.IMAGE:
        return 'pages/ImageViewerPage';
      case FileType.VIDEO:
        return 'pages/VideoPlayerPage';
      case FileType.AUDIO:
        return 'pages/AudioPlayerPage';
      case FileType.PDF:
        return 'pages/PdfViewerPage';
      case FileType.TEXT:
        return 'pages/TextViewerPage';
      default:
        return 'pages/FileDetailPage';
    }
  }
}

提示:自动记录逻辑应放在统一的 Service 层,而非分散在各个页面中,确保记录行为的一致性和可维护性,参考 HarmonyOS 架构设计指南

七、最近访问页面 UI 实现

7.1 页面布局设计

最近访问页面采用分组列表布局,每个时间分组使用 SectionTitle 组件作为标题,下方为文件列表。

在这里插入图片描述

图为最近访问页面 UI 布局效果,按时间分组展示文件列表,顶部导航栏包含清除按钮。

7.2 页面代码实现

// pages/RecentPage.ets
@Entry
@Component
struct RecentPage {
  @State viewModel: RecentViewModel = new RecentViewModel();
  @State showClearDialog: boolean = false;

  async aboutToAppear(): Promise<void> {
    await this.viewModel.loadRecentFiles();
    EventBus.on('recent_file_opened', (data: Object): void => {
      this.viewModel.loadRecentFiles();
    });
  }

  build(): void {
    Column() {
      AppNavigationBar({
        title: '最近访问',
        showBack: true,
        rightAction: (): void => { this.showClearDialog = true; }
      })

      if (this.viewModel.isLoading) {
        LoadingView()
      } else if (this.viewModel.totalCount === 0) {
        EmptyView({ message: '暂无访问记录', icon: $r('app.media.empty_recent') })
      } else {
        List({ space: 12 }) {
          ForEach(this.viewModel.groups, (group: RecentGroup): void => {
            ListItem() {
              Column() {
                SectionTitle({ title: group.title })
                ForEach(group.items, (item: RecentItem): void => {
                  FileCard({
                    fileInfo: item.fileInfo,
                    onItemClick: (): Promise<void> => this.openFile(item.fileInfo)
                  })
                }, (item: RecentItem): string => item.recent.id)
              }
            }
          }, (group: RecentGroup): string => group.title)
        }
        .layoutWeight(1)
      }
    }
  }

  private async openFile(fileInfo: FileInfo): Promise<void> {
    await FileOpenService.getInstance().openFile(fileInfo);
  }
}

八、时间分组展示组件

8.1 SectionTitle 组件

时间分组标题使用 SectionTitle 组件统一渲染,保持视觉风格一致性。

// components/SectionTitle.ets
@Component
export struct SectionTitle {
  private title: string = '';

  build(): void {
    Text(this.title)
      .fontSize(14)
      .fontWeight(FontWeight.Medium)
      .fontColor($r('app.color.text_secondary'))
      .margin({ top: 8, bottom: 4, left: 16 })
  }
}

8.2 分组展示效果

分组展示效果对比如下:

分组 时间范围 排序方式
今天 当天 00:00 至当前时间 按访问时间降序
昨天 前一天 00:00 至当天 00:00 按访问时间降序
更早 前一天 00:00 之前 按访问时间降序

九、清除历史记录功能

9.1 清除确认弹窗

清除历史记录是不可逆操作,需要通过 ConfirmDialog 组件进行二次确认,防止用户误操作。

// components/ConfirmDialog.ets
@Component
export struct ConfirmDialog {
  private title: string = '确认清除';
  private message: string = '确定要清除全部访问记录吗?此操作不可撤销。';
  private onConfirm: () => void = (): void => {};
  @State isVisible: boolean = false;

  build(): void {
    if (this.isVisible) {
      Column() {
        Text(this.title).fontSize(18).fontWeight(FontWeight.Bold).margin({ bottom: 12 })
        Text(this.message).fontSize(14).fontColor($r('app.color.text_secondary')).margin({ bottom: 20 })
        Row({ space: 12 }) {
          Button('取消').layoutWeight(1).onClick((): void => { this.isVisible = false; })
          Button('确认清除').layoutWeight(1).type(ButtonType.Capsule)
            .backgroundColor($r('app.color.danger'))
            .onClick((): void => { this.isVisible = false; this.onConfirm(); })
        }
      }.padding(24).backgroundColor(Color.White).borderRadius(16)
    }
  }
}

9.2 清除逻辑实现

// pages/RecentPage.ets 中的清除逻辑
private async handleClearConfirm(): Promise<void> {
  await this.viewModel.clearAllRecords();
  ToastUtil.show('已清除全部访问记录');
  this.showClearDialog = false;
}

十、最近访问与文件详情联动

10.1 联动设计方案

最近访问功能与文件详情页的联动通过 EventBus 事件机制实现。当用户从最近列表打开文件后,文件详情页的访问会反向通知最近列表刷新数据。

10.2 联动实现

// pages/RecentPage.ets 中的事件监听
interface FileOpenedEventData {
  path: string;
}

async aboutToAppear(): Promise<void> {
  EventBus.on('file_opened', (data: FileOpenedEventData): void => {
    this.viewModel.addRecentRecord(data.path);
  });
  await this.viewModel.loadRecentFiles();
}

async aboutToDisappear(): Promise<void> {
  EventBus.off('file_opened');
}

提示:在页面销毁时必须注销事件监听,否则会导致内存泄漏和重复触发问题,参考 HarmonyOS 页面生命周期管理

总结

本文详细介绍了 HarmonyExplorer 最近访问记录功能的完整实现方案,从 RecentFile 数据模型设计到 Preferences 持久化,从时间分组算法到自动记录机制,覆盖了访问记录系统的全流程开发。通过 Repository 分层、去重与数量限制策略、EventBus 联动机制,实现了高效可靠的访问记录管理。开发者可以将此设计模式应用于历史记录、操作日志等类似场景。

如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!

相关资源

Logo

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

更多推荐