前言

你有没有过这种经历——刚复制了一段很长的验证码,还没来得及粘贴,又来了一条新短信,顺手一点,验证码没了。或者在微信里复制了地址,切到备忘录准备粘贴,结果发现复制的是上一条聊天记录。剪贴板这东西很实在,但也很“健忘”——它永远只记得最后一次复制的内容。

既然系统不给后悔药,那我们就自己做一个。今天用HarmonyOS的剪贴板API和Preferences存储,手搓一个“复制历史回溯器”。应用在后台默默记录每一次复制的内容,想看历史记录随时打开,点一下就能回填到输入框。边写边聊,把剪贴板监听、数据持久化、列表渲染这几个知识点串起来。

一、功能拆解

先搞清楚要做的东西长什么样。打开应用,顶部是一个文本输入框,下面是一个历史记录列表,底部有几个操作按钮。核心流程是这样:

  1. 后台监听:应用在前台运行时,系统剪贴板一旦有新内容(用户在任何地方复制了文本),应用立刻感知到。
  2. 自动记录:把复制的内容存到本地数据库,同时显示在历史记录列表里。相同的文本不会重复记录。
  3. 一键回填:点击历史记录中的任意一条,内容自动填到顶部的输入框里。也可以长按某条记录,直接把它写回系统剪贴板,让其他应用也能粘贴。
  4. 手动管理:支持手动添加当前输入框的内容到历史记录,也支持清空所有历史。

这个设计覆盖了剪贴板开发里三个最核心的能力:监听变化、读取内容、写入内容。同时用到了Preferences来做数据持久化——关掉应用再打开,历史记录还在。

二、剪贴板怎么玩?

HarmonyOS的剪贴板服务在@ohos.pasteboard模块里,系统剪贴板支持复制和粘贴纯文本、HTML、URI等多种类型的数据。开发之前先搞定权限。剪贴板的读写需要显式授权,在module.json5里声明两个权限:

{
  "requestPermissions": [
    { "name": "ohos.permission.READ_PASTEBOARD" },
    { "name": "ohos.permission.WRITE_PASTEBOARD" }
  ]
}

READ_PASTEBOARD用于读取剪贴板内容,WRITE_PASTEBOARD用于写入。这两个权限在HarmonyOS中属于敏感权限,运行时系统会自动弹窗询问用户是否授权,开发者不需要额外写动态申请代码——这是和Android不一样的地方,省了不少事。

权限配好之后,导入模块:

import { pasteboard } from '@kit.BasicServicesKit';

三、监听剪贴板

HarmonyOS提供了pasteboard.on('update', callback)接口来监听系统剪贴板内容变化。任何应用(包括系统应用)修改剪贴板内容,都会触发这个回调。

pasteboard.on('update', () => {
  console.info('剪贴板内容已更新');
  // 在这里读取新内容并保存到历史记录
});

每次收到update事件,调用pasteboard.getSystemPasteboard()获取当前的剪贴板数据,然后提取纯文本内容。写进历史记录之前要做两件事:一是检查内容是否为空,二是查重——相同的文本不重复记录,避免列表里全是重复项。

这里有个很重要的点:应用必须在最前台的时候才能监听到剪贴板变化。HarmonyOS出于隐私保护,后台应用无法访问剪贴板内容。所以监听逻辑放在aboutToAppear生命周期里注册,在aboutToDisappear里注销,保证只有应用在前台时才监听。

另外,由于监听的是系统全局剪贴板,用户在其他应用(比如微信、浏览器、短信)里复制内容,我们的应用也会收到通知。这正是“复制历史回溯器”的核心价值所在——跨应用追踪每一次复制操作。

四、数据存哪里?

历史记录不能每次启动都从头开始,需要持久化保存。HarmonyOS提供了Preferences用户首选项,专门用于存储键值对形式的轻量级数据。

Preferences适合保存配置类数据,比如字体大小、夜间模式开关、历史记录列表。它会将数据缓存在内存中,读取很快,通过flush接口可以将内存中的数据写入持久化文件。需要注意:Preferences不适合存放过多数据,官方建议不超过一万条,对剪贴板历史记录来说绰绰有余。

获取Preferences实例:

import { preferences } from '@kit.ArkData';

let dataPreferences = await preferences.getPreferences(context, 'clipboard_history');

由于历史记录是一个字符串列表(多条记录),而Preferences的值只支持基础类型(string、number、boolean等),不能直接存数组。解决方案是把数组转成JSON字符串存进去:

// 保存
let historyJson = JSON.stringify(this.historyList);
await dataPreferences.put('history', historyJson);
await dataPreferences.flush();

// 读取
let jsonStr = dataPreferences.getSync('history', '[]') as string;
this.historyList = JSON.parse(jsonStr);

应用启动时从Preferences加载历史数据,每次新增记录时更新并持久化,下次打开应用历史记录就还在。

五、界面怎么搭?

界面用Column垂直布局,分三个区域:顶部输入框(TextInput)、中间历史记录列表(List)、底部操作按钮(Row+Button)。

历史记录列表用List组件,配合ForEach循环渲染。每条记录用ListItem包裹,里面放文本内容和一个删除按钮。点击记录本身触发回填(把内容填到顶部输入框),点击删除按钮则从列表和存储中移除该条记录:

List() {
  ForEach(this.historyList, (item: string, index: number) => {
    ListItem() {
      Row() {
        Text(item)
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
          .layoutWeight(1)
          .onClick(() => {
            this.inputText = item;  // 回填到输入框
          })
        
        Button('×')
          .onClick(() => {
            this.deleteHistoryItem(index);
          })
      }
    }
  })
}

maxLines(1)textOverflow确保过长的文本显示为省略号,界面整洁。每条记录支持两种操作:点击回填,长按写回系统剪贴板(方便其他应用粘贴),删除按钮从历史中移除。

底部三个按钮分别实现:把当前输入框的内容手动添加到历史记录(并写入系统剪贴板),清空所有历史记录,以及显示一条提示信息告诉用户如何使用。

六、手动写入剪贴板

“回溯器”不仅要能读,还要能写。当用户长按某条历史记录时,把该条内容写回系统剪贴板,这样切换到其他应用就可以直接粘贴。写入剪贴板的代码很简单:

import { pasteboard } from '@kit.BasicServicesKit';

let pasteData = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, text);
let systemPasteboard = pasteboard.getSystemPasteboard();
systemPasteboard.setData(pasteData);

pasteboard.createData创建一个纯文本类型的剪贴板数据对象,第一个参数指定MIME类型。getSystemPasteboard获取系统剪贴板实例,然后调用setData把数据写进去。写入成功后,可以弹一个Toast提示用户“已复制到剪贴板”。

七、模拟器测试

开发过程中测试剪贴板功能,不用真机也能搞定。DevEco Studio的手机模拟器完全支持剪贴板操作:从模拟器6.0.1 Beta1版本开始,支持在计算机和模拟器之间互相复制粘贴。

具体来说,可以在电脑上复制一段文本,然后切换到模拟器窗口,在任意输入框内长按→粘贴,文本就会写入模拟器的系统剪贴板。此时我们的应用如果在前台,就会收到update事件,自动把内容记录到历史列表。

反过来,应用里长按历史记录调用setData后,切换到模拟器的其他应用(比如备忘录),长按粘贴,就能把文本贴出来。这个双向测试流程完全复现了真机的行为,开发调试非常方便。

八、完整代码

第一步:创建项目

DevEco Studio → Create ProjectEmpty Ability → Device Type选Phone → SDK选API 12或以上。

第二步:配置权限

打开entry/src/main/module.json5,在module内部添加权限声明:

{
  "module": {
    "name": "entry",
    "type": "entry",
    "description": "$string:module_desc",
    "mainElement": "EntryAbility",
    "deviceTypes": [
      "phone"
    ],
    "deliveryWithInstall": true,
    "installationFree": false,
    "pages": "$profile:main_pages",
    "abilities": [
      {
        "name": "EntryAbility",
        "srcEntry": "./ets/entryability/EntryAbility.ets",
        "description": "$string:EntryAbility_desc",
        "icon": "$media:layered_image",
        "label": "$string:EntryAbility_label",
        "startWindowIcon": "$media:startIcon",
        "startWindowBackground": "$color:start_window_background",
        "exported": true,
        "skills": [
          {
            "entities": [
              "entity.system.home"
            ],
            "actions": [
              "action.system.home"
            ]
          }
        ]
      }
    ],
    "requestPermissions": [
      {
        "name": "ohos.permission.READ_PASTEBOARD"
      },
      {
        "name": "ohos.permission.WRITE_PASTEBOARD"
      }
    ]
  }
}

第三步:替换页面代码

entry/src/main/ets/pages/Index.ets内容全部替换为以下代码:

import { pasteboard } from '@kit.BasicServicesKit';
import { preferences } from '@kit.ArkData';
import { promptAction } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';

@Entry
@Component
struct ClipboardHistory {
  @State inputText: string = '';
  @State historyList: string[] = [];
  
  private context: common.UIAbilityContext = getContext(this) as common.UIAbilityContext;
  private dataPreferences: preferences.Preferences | null = null;
  private readonly STORE_NAME: string = 'clipboard_history';
  private readonly KEY_HISTORY: string = 'history';
  private listenerStarted: boolean = false;
  
  aboutToAppear(): void {
    this.initPreferences();
    this.startClipboardListener();
  }
  
  aboutToDisappear(): void {
    this.stopClipboardListener();
  }
  
  async initPreferences(): Promise<void> {
    try {
      this.dataPreferences = await preferences.getPreferences(this.context, this.STORE_NAME);
      await this.loadHistory();
    } catch (error) {
      console.error('初始化Preferences失败:', JSON.stringify(error));
    }
  }
  
  async loadHistory(): Promise<void> {
    try {
      if (this.dataPreferences) {
        let jsonStr = await this.dataPreferences.get(this.KEY_HISTORY, '[]');
        this.historyList = JSON.parse(jsonStr as string);
      }
    } catch (error) {
      console.error('加载历史记录失败:', JSON.stringify(error));
      this.historyList = [];
    }
  }
  
  async saveHistory(): Promise<void> {
    try {
      if (this.dataPreferences) {
        let jsonStr = JSON.stringify(this.historyList);
        await this.dataPreferences.put(this.KEY_HISTORY, jsonStr);
        await this.dataPreferences.flush();
      }
    } catch (error) {
      console.error('保存历史记录失败:', JSON.stringify(error));
    }
  }
  
  startClipboardListener(): void {
    if (this.listenerStarted) return;
    
    try {
      pasteboard.on('update', () => {
        this.handleClipboardUpdate();
      });
      this.listenerStarted = true;
      console.info('剪贴板监听已开启');
    } catch (error) {
      console.error('开启剪贴板监听失败:', JSON.stringify(error));
    }
  }
  
  stopClipboardListener(): void {
    if (!this.listenerStarted) return;
    
    try {
      pasteboard.off('update');
      this.listenerStarted = false;
      console.info('剪贴板监听已停止');
    } catch (error) {
      console.error('停止剪贴板监听失败:', JSON.stringify(error));
    }
  }
  
  handleClipboardUpdate(): void {
    try {
      let systemPasteboard = pasteboard.getSystemPasteboard();
      let pasteData = systemPasteboard.getData();
      
      if (pasteData.hasMimeType(pasteboard.MIMETYPE_TEXT_PLAIN)) {
        let text = pasteData.getPrimaryText();
        if (text && text.trim().length > 0) {
          this.addToHistory(text);
        }
      }
    } catch (error) {
      console.error('处理剪贴板更新失败:', JSON.stringify(error));
    }
  }
  
  addToHistory(text: string): void {
    // 去重:如果已经存在相同文本,不重复添加
    if (this.historyList.includes(text)) {
      return;
    }
    
    // 新记录插到最前面
    this.historyList = [text, ...this.historyList];
    
    // 限制最多50条,避免占用太多存储
    if (this.historyList.length > 50) {
      this.historyList = this.historyList.slice(0, 50);
    }
    
    this.saveHistory();
    
    promptAction.showToast({
      message: '已记录到历史',
      duration: 1000
    });
  }
  
  manualAddToHistory(): void {
    if (!this.inputText || this.inputText.trim().length === 0) {
      promptAction.showToast({
        message: '输入框是空的',
        duration: 1500
      });
      return;
    }
    
    let text = this.inputText.trim();
    this.addToHistory(text);
    
    // 同时写入系统剪贴板
    this.writeToClipboard(text);
  }
  
  writeToClipboard(text: string): void {
    try {
      let pasteData = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, text);
      let systemPasteboard = pasteboard.getSystemPasteboard();
      systemPasteboard.setData(pasteData);
    } catch (error) {
      console.error('写入剪贴板失败:', JSON.stringify(error));
    }
  }
  
  deleteHistoryItem(index: number): void {
    this.historyList.splice(index, 1);
    this.saveHistory();
    
    promptAction.showToast({
      message: '已删除',
      duration: 1000
    });
  }
  
  clearAllHistory(): void {
    if (this.historyList.length === 0) {
      promptAction.showToast({
        message: '历史记录已经是空的',
        duration: 1500
      });
      return;
    }
    
    this.historyList = [];
    this.saveHistory();
    
    promptAction.showToast({
      message: '已清空所有历史',
      duration: 1500
    });
  }
  
  copyHistoryItemToClipboard(item: string): void {
    this.writeToClipboard(item);
    promptAction.showToast({
      message: '已复制到剪贴板',
      duration: 1500
    });
  }
  
  build() {
    Column() {
      // 顶部标题
      Text('📋 复制历史回溯器')
        .fontSize(22)
        .fontWeight(FontWeight.Bold)
        .fontColor('#1a1a1a')
        .margin({ top: 20, bottom: 8 })
      
      // 提示信息
      Text('在其他应用复制文本,这里自动记录')
        .fontSize(12)
        .fontColor('#888888')
        .margin({ bottom: 16 })
      
      // 输入框区域
      Row({ space: 8 }) {
        TextInput({ placeholder: '输入或粘贴文本...', text: this.inputText })
          .layoutWeight(1)
          .height(48)
          .backgroundColor('#f5f7fa')
          .borderRadius(8)
          .padding({ left: 16, right: 16 })
          .onChange((value: string) => {
            this.inputText = value;
          })
        
        Button('添加')
          .height(48)
          .fontSize(14)
          .backgroundColor('#007aff')
          .borderRadius(8)
          .padding({ left: 16, right: 16 })
          .onClick(() => {
            this.manualAddToHistory();
          })
      }
      .width('100%')
      .padding({ left: 16, right: 16 })
      .margin({ bottom: 16 })
      
      // 历史记录标题
      Row() {
        Text('历史记录')
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
          .fontColor('#333333')
        
        Blank()
        
        Text(`${this.historyList.length} 条`)
          .fontSize(12)
          .fontColor('#999999')
      }
      .width('100%')
      .padding({ left: 16, right: 16, bottom: 8 })
      
      // 历史记录列表
      if (this.historyList.length > 0) {
        List() {
          ForEach(this.historyList, (item: string, index: number) => {
            ListItem() {
              Row() {
                Text(item)
                  .fontSize(14)
                  .fontColor('#333333')
                  .maxLines(1)
                  .textOverflow({ overflow: TextOverflow.Ellipsis })
                  .layoutWeight(1)
                  .padding({ left: 16, top: 12, bottom: 12 })
                  .onClick(() => {
                    this.inputText = item;
                    promptAction.showToast({
                      message: '已填入输入框',
                      duration: 1000
                    });
                  })
                
                Button('复制')
                  .height(32)
                  .fontSize(12)
                  .backgroundColor('#f0f0f0')
                  .fontColor('#007aff')
                  .borderRadius(4)
                  .margin({ right: 4 })
                  .onClick(() => {
                    this.copyHistoryItemToClipboard(item);
                  })
                
                Button('×')
                  .height(32)
                  .width(40)
                  .fontSize(18)
                  .backgroundColor('#f0f0f0')
                  .fontColor('#ff4757')
                  .borderRadius(4)
                  .margin({ right: 16 })
                  .onClick(() => {
                    this.deleteHistoryItem(index);
                  })
              }
              .width('100%')
              .backgroundColor(Color.White)
            }
            .margin({ bottom: 1 })
          }, (item: string, index: number) => `${item}_${index}`)
        }
        .width('100%')
        .layoutWeight(1)
        .backgroundColor('#f5f7fa')
        .divider({
          strokeWidth: 0.5,
          color: '#e0e0e0',
          startMargin: 16,
          endMargin: 16
        })
      } else {
        Column() {
          Text('📭')
            .fontSize(48)
            .margin({ bottom: 8 })
          Text('还没有历史记录')
            .fontSize(14)
            .fontColor('#999999')
        }
        .width('100%')
        .layoutWeight(1)
        .justifyContent(FlexAlign.Center)
        .backgroundColor('#f5f7fa')
      }
      
      // 底部操作按钮
      Row({ space: 12 }) {
        Button('清空历史')
          .layoutWeight(1)
          .height(44)
          .fontSize(14)
          .backgroundColor('#f0f0f0')
          .fontColor('#ff4757')
          .borderRadius(8)
          .onClick(() => {
            this.clearAllHistory();
          })
        
        Button('使用说明')
          .layoutWeight(1)
          .height(44)
          .fontSize(14)
          .backgroundColor('#007aff')
          .fontColor(Color.White)
          .borderRadius(8)
          .onClick(() => {
            promptAction.showDialog({
              title: '使用说明',
              message: '在其他应用中复制文本,历史记录会自动出现在这里。\n\n点击记录→填入上方输入框\n点击复制按钮→写回系统剪贴板\n点击×按钮→删除记录',
              buttons: [{ text: '知道了', color: '#007aff' }]
            });
          })
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 12, bottom: 16 })
      .backgroundColor(Color.White)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#ffffff')
  }
}

九、运行

在Device Manager中启动手机模拟器,点击运行按钮安装应用。首次启动时会弹出权限请求弹窗,点击“允许”授权剪贴板读写权限。

应用打开后,模拟器桌面切换到“备忘录”应用,随便输入一段文字并复制。回到“复制历史回溯器”,历史记录列表里已经出现了刚才复制的内容。多复制几条,列表会按时间倒序排列,最新的在最上面。

点击任意一条历史记录,内容自动填入顶部的输入框。点击记录右侧的“复制”按钮,内容被写回系统剪贴板,切换到备忘录长按粘贴即可使用。点击“×”按钮可以删除单条记录,底部的“清空历史”按钮一键清空所有记录。

总结

这个“复制历史回溯器”虽然界面朴素,但把HarmonyOS剪贴板开发的核心流程完整走了一遍:pasteboard.on('update')监听全局剪贴板变化,getSystemPasteboard().getData()读取内容,createData+setData写回剪贴板。这三个接口掌握了,剪贴板相关的功能就基本打通了。

数据持久化这边,Preferences用户首选项的用法非常直接——getPreferences获取实例,put写入,flush持久化,get读取。虽然不能直接存数组,但JSON序列化一下就搞定了。

有一个细节值得留意:pasteboard.on监听的是系统全局剪贴板,理论上任何应用复制内容都会触发回调。但由于HarmonyOS的隐私保护机制,应用必须在前台时才能收到通知,后台应用无法访问剪贴板。这个设计保证了用户的剪贴板内容不会在不知情的情况下被后台应用偷偷读取。

在这个基础上,可以拓展的功能还有很多:加上时间戳记录、支持搜索过滤、导出历史记录到文件、甚至利用分布式能力实现跨设备剪贴板同步。把基础打牢了,这些进阶玩法都是水到渠成的事。

Logo

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

更多推荐