鸿蒙分布式剪贴板同步器设计与实现

一、系统架构概述

基于HarmonyOS的分布式能力,我们设计了一个跨设备剪贴板同步系统,能够在手机、平板和PC之间实现无缝的复制粘贴体验。系统采用分布式数据管理和剪贴板服务,确保数据在不同设备间安全、高效地同步。

https://example.com/distributed-clipboard-arch.png

系统包含三个核心模块:

  1. ​本地剪贴板监听模块​​ - 使用@ohos.pasteboard监听本地剪贴板变化
  2. ​分布式数据同步模块​​ - 通过@ohos.distributedData实现跨设备数据同步
  3. ​剪贴板历史管理模块​​ - 提供剪贴板历史记录和选择性粘贴功能

二、核心代码实现

1. 分布式剪贴板服务(ArkTS)

// DistributedClipboard.ets
import pasteboard from '@ohos.pasteboard';
import distributedData from '@ohos.distributedData';
import deviceManager from '@ohos.distributedHardware.deviceManager';

const CLIPBOARD_SYNC_CHANNEL = 'dist_clipboard_sync';

class DistributedClipboard {
  private static instance: DistributedClipboard = null;
  private dataManager: distributedData.DataManager;
  private deviceManager: deviceManager.DeviceManager;
  private listeners: ClipboardListener[] = [];
  private lastSyncTime: number = 0;
  private syncThrottle: number = 1000; // 1秒同步节流
  
  private constructor() {
    this.initDataManager();
    this.initDeviceManager();
    this.startLocalClipboardMonitor();
  }
  
  public static getInstance(): DistributedClipboard {
    if (!DistributedClipboard.instance) {
      DistributedClipboard.instance = new DistributedClipboard();
    }
    return DistributedClipboard.instance;
  }
  
  private initDataManager(): void {
    this.dataManager = distributedData.createDataManager({
      bundleName: 'com.example.distclipboard',
      area: distributedData.Area.GLOBAL
    });
    
    this.dataManager.registerDataListener(CLIPBOARD_SYNC_CHANNEL, (data) => {
      this.handleSyncData(data);
    });
  }
  
  private initDeviceManager(): void {
    this.deviceManager = deviceManager.createDeviceManager('com.example.distclipboard');
    this.deviceManager.on('deviceOnline', (device) => {
      console.log(`设备上线: ${device.deviceName}`);
    });
  }
  
  private startLocalClipboardMonitor(): void {
    const systemPasteboard = pasteboard.getSystemPasteboard();
    
    systemPasteboard.on('update', () => {
      this.syncLocalClipboardToDevices();
    });
  }
  
  public async syncLocalClipboardToDevices(): Promise<void> {
    const now = Date.now();
    if (now - this.lastSyncTime < this.syncThrottle) {
      return;
    }
    this.lastSyncTime = now;
    
    const systemPasteboard = pasteboard.getSystemPasteboard();
    const pasteData = await systemPasteboard.getData();
    
    if (!pasteData) return;
    
    const devices = this.getAvailableDevices();
    if (devices.length === 0) return;
    
    const syncData = this.createSyncData(pasteData);
    
    devices.forEach(device => {
      this.dataManager.sendData(device.deviceId, CLIPBOARD_SYNC_CHANNEL, syncData);
    });
  }
  
  private createSyncData(pasteData: pasteboard.PasteData): ClipboardSyncData {
    const data: ClipboardSyncData = {
      type: 'clipboardData',
      dataType: pasteData.type,
      timestamp: Date.now(),
      sourceDeviceId: this.getLocalDeviceId(),
      attributes: {}
    };
    
    switch (pasteData.type) {
      case pasteboard.PasteDataType.TEXT:
        data.text = pasteData.text;
        data.attributes.mimeTypes = ['text/plain'];
        break;
      case pasteboard.PasteDataType.URI:
        data.uri = pasteData.uri;
        data.attributes.mimeTypes = ['text/uri-list'];
        break;
      case pasteboard.PasteDataType.PIXELMAP:
        data.pixelMap = pasteData.pixelMap;
        data.attributes.mimeTypes = ['image/*'];
        break;
      case pasteboard.PasteDataType.HTML:
        data.html = pasteData.htmlText;
        data.attributes.mimeTypes = ['text/html'];
        break;
    }
    
    return data;
  }
  
  private async applySyncData(syncData: ClipboardSyncData): Promise<void> {
    if (syncData.sourceDeviceId === this.getLocalDeviceId()) {
      return; // 不处理来自本机的同步数据
    }
    
    const systemPasteboard = pasteboard.getSystemPasteboard();
    let pasteData: pasteboard.PasteData | null = null;
    
    switch (syncData.dataType) {
      case pasteboard.PasteDataType.TEXT:
        pasteData = pasteboard.createPlainTextData(syncData.text || '');
        break;
      case pasteboard.PasteDataType.URI:
        pasteData = pasteboard.createUriData(syncData.uri || '');
        break;
      case pasteboard.PasteDataType.PIXELMAP:
        if (syncData.pixelMap) {
          pasteData = pasteboard.createPixelMapData(syncData.pixelMap);
        }
        break;
      case pasteboard.PasteDataType.HTML:
        if (syncData.html) {
          pasteData = pasteboard.createHtmlData(syncData.html);
        }
        break;
    }
    
    if (pasteData) {
      await systemPasteboard.setData(pasteData);
      this.notifyClipboardUpdated(syncData);
    }
  }
  
  private handleSyncData(data: any): void {
    if (data?.type === 'clipboardData') {
      this.applySyncData(data as ClipboardSyncData);
    }
  }
  
  private notifyClipboardUpdated(syncData: ClipboardSyncData): void {
    this.listeners.forEach(listener => {
      listener.onClipboardUpdated(syncData);
    });
  }
  
  public addListener(listener: ClipboardListener): void {
    if (!this.listeners.includes(listener)) {
      this.listeners.push(listener);
    }
  }
  
  public removeListener(listener: ClipboardListener): void {
    this.listeners = this.listeners.filter(l => l !== listener);
  }
  
  public getAvailableDevices(): deviceManager.DeviceInfo[] {
    try {
      return this.deviceManager.getAvailableDeviceListSync();
    } catch (err) {
      console.error('获取可用设备列表失败:', JSON.stringify(err));
      return [];
    }
  }
  
  public getLocalDeviceId(): string {
    try {
      return this.deviceManager.getLocalDeviceInfoSync().deviceId;
    } catch (err) {
      console.error('获取本地设备ID失败:', JSON.stringify(err));
      return 'unknown_device';
    }
  }
}

interface ClipboardSyncData {
  type: string;
  dataType: pasteboard.PasteDataType;
  text?: string;
  uri?: string;
  pixelMap?: image.PixelMap;
  html?: string;
  timestamp: number;
  sourceDeviceId: string;
  attributes: {
    mimeTypes?: string[];
  };
}

interface ClipboardListener {
  onClipboardUpdated(data: ClipboardSyncData): void;
}

export const distClipboard = DistributedClipboard.getInstance();

2. 剪贴板历史管理(ArkTS)

// ClipboardHistory.ets
import pasteboard from '@ohos.pasteboard';

class ClipboardHistory {
  private static instance: ClipboardHistory = null;
  private history: ClipboardItem[] = [];
  private maxHistorySize: number = 20;
  
  private constructor() {
    this.loadHistory();
  }
  
  public static getInstance(): ClipboardHistory {
    if (!ClipboardHistory.instance) {
      ClipboardHistory.instance = new ClipboardHistory();
    }
    return ClipboardHistory.instance;
  }
  
  public async addItemFromPasteboard(): Promise<void> {
    const systemPasteboard = pasteboard.getSystemPasteboard();
    const pasteData = await systemPasteboard.getData();
    
    if (!pasteData) return;
    
    const newItem = this.createHistoryItem(pasteData);
    
    // 避免重复添加相同内容
    if (!this.isDuplicate(newItem)) {
      this.history.unshift(newItem);
      
      // 限制历史记录大小
      if (this.history.length > this.maxHistorySize) {
        this.history.pop();
      }
      
      this.saveHistory();
    }
  }
  
  public async addItemFromSync(data: ClipboardSyncData): Promise<void> {
    const newItem = this.createHistoryItemFromSync(data);
    
    if (!this.isDuplicate(newItem)) {
      this.history.unshift(newItem);
      
      if (this.history.length > this.maxHistorySize) {
        this.history.pop();
      }
      
      this.saveHistory();
    }
  }
  
  public getHistory(): ClipboardItem[] {
    return [...this.history];
  }
  
  public clearHistory(): void {
    this.history = [];
    this.saveHistory();
  }
  
  public async pasteItem(item: ClipboardItem): Promise<void> {
    const systemPasteboard = pasteboard.getSystemPasteboard();
    let pasteData: pasteboard.PasteData | null = null;
    
    switch (item.type) {
      case 'text':
        pasteData = pasteboard.createPlainTextData(item.content);
        break;
      case 'uri':
        pasteData = pasteboard.createUriData(item.content);
        break;
      case 'html':
        pasteData = pasteboard.createHtmlData(item.content);
        break;
    }
    
    if (pasteData) {
      await systemPasteboard.setData(pasteData);
    }
  }
  
  private createHistoryItem(pasteData: pasteboard.PasteData): ClipboardItem {
    const item: ClipboardItem = {
      id: Date.now().toString(),
      timestamp: Date.now(),
      sourceDevice: 'local'
    };
    
    switch (pasteData.type) {
      case pasteboard.PasteDataType.TEXT:
        item.type = 'text';
        item.content = pasteData.text || '';
        item.preview = pasteData.text?.substring(0, 50) || '';
        break;
      case pasteboard.PasteDataType.URI:
        item.type = 'uri';
        item.content = pasteData.uri || '';
        item.preview = pasteData.uri || '';
        break;
      case pasteboard.PasteDataType.HTML:
        item.type = 'html';
        item.content = pasteData.htmlText || '';
        item.preview = this.extractTextFromHtml(pasteData.htmlText || '');
        break;
      default:
        item.type = 'unknown';
        item.content = '';
        item.preview = 'Unknown content type';
    }
    
    return item;
  }
  
  private createHistoryItemFromSync(data: ClipboardSyncData): ClipboardItem {
    const item: ClipboardItem = {
      id: `${data.sourceDeviceId}_${data.timestamp}`,
      timestamp: data.timestamp,
      sourceDevice: data.sourceDeviceId
    };
    
    switch (data.dataType) {
      case pasteboard.PasteDataType.TEXT:
        item.type = 'text';
        item.content = data.text || '';
        item.preview = data.text?.substring(0, 50) || '';
        break;
      case pasteboard.PasteDataType.URI:
        item.type = 'uri';
        item.content = data.uri || '';
        item.preview = data.uri || '';
        break;
      case pasteboard.PasteDataType.HTML:
        item.type = 'html';
        item.content = data.html || '';
        item.preview = this.extractTextFromHtml(data.html || '');
        break;
      default:
        item.type = 'unknown';
        item.content = '';
        item.preview = 'Unknown content type';
    }
    
    return item;
  }
  
  private isDuplicate(newItem: ClipboardItem): boolean {
    if (this.history.length === 0) return false;
    
    const lastItem = this.history[0];
    return lastItem.content === newItem.content && 
           lastItem.type === newItem.type;
  }
  
  private extractTextFromHtml(html: string): string {
    // 简单提取文本内容
    return html.replace(/<[^>]*>/g, '').substring(0, 50);
  }
  
  private saveHistory(): void {
    // 实际实现需要持久化存储
  }
  
  private loadHistory(): void {
    // 实际实现需要从存储加载
  }
}

interface ClipboardItem {
  id: string;
  type: 'text' | 'uri' | 'html' | 'unknown';
  content: string;
  preview: string;
  timestamp: number;
  sourceDevice: string;
}

export const clipboardHistory = ClipboardHistory.getInstance();

3. 剪贴板管理界面(ArkUI)

// ClipboardManagerUI.ets
import { distClipboard } from './DistributedClipboard';
import { clipboardHistory } from './ClipboardHistory';

@Entry
@Component
struct ClipboardManagerUI {
  @State connectedDevices: deviceManager.DeviceInfo[] = [];
  @State clipboardHistory: ClipboardItem[] = [];
  @State activeTab: 'local' | 'devices' | 'history' = 'local';
  @State currentClipboardContent: string = '';
  
  aboutToAppear() {
    this.loadConnectedDevices();
    this.loadClipboardHistory();
    this.loadCurrentClipboard();
    
    distClipboard.addListener({
      onClipboardUpdated: (data) => {
        clipboardHistory.addItemFromSync(data);
        this.loadClipboardHistory();
        this.loadCurrentClipboard();
      }
    });
  }
  
  build() {
    Column() {
      // 顶部选项卡
      this.buildTabs()
      
      // 主内容区
      this.buildContent()
    }
    .width('100%')
    .height('100%')
    .padding(12)
  }
  
  @Builder
  buildTabs() {
    Tabs({ barPosition: BarPosition.Start }) {
      TabContent() {
        this.buildLocalClipboard()
      }
      .tabBar('本地剪贴板')
      
      TabContent() {
        this.buildDevicesList()
      }
      .tabBar('设备列表')
      
      TabContent() {
        this.buildHistoryList()
      }
      .tabBar('历史记录')
    }
    .onChange((index: number) => {
      this.activeTab = index === 0 ? 'local' : index === 1 ? 'devices' : 'history';
    })
    .height('100%')
  }
  
  @Builder
  buildContent() {
    if (this.activeTab === 'local') {
      this.buildLocalClipboard();
    } else if (this.activeTab === 'devices') {
      this.buildDevicesList();
    } else {
      this.buildHistoryList();
    }
  }
  
  @Builder
  buildLocalClipboard() {
    Column() {
      Text('当前剪贴板内容')
        .fontSize(16)
        .margin({ bottom: 8 })
      
      TextInput({ text: this.currentClipboardContent })
        .width('100%')
        .height(120)
        .border({ width: 1, color: '#EEEEEE' })
        .margin({ bottom: 16 })
      
      Button('复制到所有设备')
        .width('100%')
        .height(40)
        .onClick(() => {
          distClipboard.syncLocalClipboardToDevices();
          promptAction.showToast({ message: '已同步到所有设备', duration: 2000 });
        })
    }
  }
  
  @Builder
  buildDevicesList() {
    Column() {
      Text('已连接设备')
        .fontSize(16)
        .margin({ bottom: 8 })
      
      if (this.connectedDevices.length === 0) {
        Text('没有可用的设备')
          .fontSize(14)
          .margin({ top: 20 })
      } else {
        List({ space: 8 }) {
          ForEach(this.connectedDevices, (device) => {
            ListItem() {
              Row() {
                Image($r('app.media.ic_device'))
                  .width(24)
                  .height(24)
                  .margin({ right: 12 })
                
                Column() {
                  Text(device.deviceName)
                    .fontSize(16)
                  
                  Text(device.deviceId)
                    .fontSize(12)
                    .fontColor('#888888')
                }
                .layoutWeight(1)
                
                Button('同步剪贴板')
                  .width(120)
                  .height(32)
                  .onClick(() => {
                    this.syncToDevice(device.deviceId);
                  })
              }
              .padding(12)
            }
          })
        }
        .width('100%')
        .layoutWeight(1)
      }
    }
  }
  
  @Builder
  buildHistoryList() {
    Column() {
      Text('剪贴板历史记录')
        .fontSize(16)
        .margin({ bottom: 8 })
      
      if (this.clipboardHistory.length === 0) {
        Text('没有历史记录')
          .fontSize(14)
          .margin({ top: 20 })
      } else {
        List({ space: 8 }) {
          ForEach(this.clipboardHistory, (item) => {
            ListItem() {
              Row() {
                Column() {
                  Text(item.preview)
                    .fontSize(14)
                    .maxLines(1)
                    .textOverflow({ overflow: TextOverflow.Ellipsis })
                  
                  Row() {
                    Text(this.formatTimestamp(item.timestamp))
                      .fontSize(12)
                      .fontColor('#888888')
                    
                    Text(`来自: ${this.getDeviceName(item.sourceDevice)}`)
                      .fontSize(12)
                      .fontColor('#888888')
                      .margin({ left: 12 })
                  }
                }
                .layoutWeight(1)
                
                Button('粘贴')
                  .width(80)
                  .height(28)
                  .onClick(() => {
                    this.pasteHistoryItem(item);
                  })
              }
              .padding(12)
            }
          })
        }
        .width('100%')
        .layoutWeight(1)
      }
    }
  }
  
  private loadConnectedDevices(): void {
    this.connectedDevices = distClipboard.getAvailableDevices();
  }
  
  private loadClipboardHistory(): void {
    this.clipboardHistory = clipboardHistory.getHistory();
  }
  
  private async loadCurrentClipboard(): Promise<void> {
    const systemPasteboard = pasteboard.getSystemPasteboard();
    const pasteData = await systemPasteboard.getData();
    
    if (pasteData?.type === pasteboard.PasteDataType.TEXT) {
      this.currentClipboardContent = pasteData.text || '';
    } else {
      this.currentClipboardContent = '[非文本内容]';
    }
  }
  
  private syncToDevice(deviceId: string): void {
    distClipboard.syncLocalClipboardToDevices();
    promptAction.showToast({ 
      message: `已同步到设备 ${this.getDeviceName(deviceId)}`, 
      duration: 2000 
    });
  }
  
  private async pasteHistoryItem(item: ClipboardItem): Promise<void> {
    await clipboardHistory.pasteItem(item);
    promptAction.showToast({ 
      message: '已粘贴到剪贴板', 
      duration: 1000 
    });
  }
  
  private formatTimestamp(timestamp: number): string {
    return new Date(timestamp).toLocaleTimeString();
  }
  
  private getDeviceName(deviceId: string): string {
    const device = this.connectedDevices.find(d => d.deviceId === deviceId);
    return device ? device.deviceName : deviceId;
  }
}

三、项目配置

1. 权限配置

// module.json5
{
  "module": {
    "requestPermissions": [
      {
        "name": "ohos.permission.DISTRIBUTED_DATASYNC",
        "reason": "跨设备数据同步"
      },
      {
        "name": "ohos.permission.PASTEBOARD_WRITE",
        "reason": "写入剪贴板"
      },
      {
        "name": "ohos.permission.PASTEBOARD_READ",
        "reason": "读取剪贴板"
      },
      {
        "name": "ohos.permission.DISTRIBUTED_DEVICE_STATE_CHANGE",
        "reason": "发现和管理分布式设备"
      }
    ],
    "abilities": [
      {
        "name": "MainAbility",
        "type": "page",
        "visible": true
      }
    ]
  }
}

2. 资源文件

<!-- resources/base/element/string.json -->
{
  "string": [
    {
      "name": "app_name",
      "value": "分布式剪贴板"
    },
    {
      "name": "local_clipboard",
      "value": "本地剪贴板"
    },
    {
      "name": "device_list",
      "value": "设备列表"
    },
    {
      "name": "history",
      "value": "历史记录"
    },
    {
      "name": "current_clipboard",
      "value": "当前剪贴板内容"
    },
    {
      "name": "copy_to_all_devices",
      "value": "复制到所有设备"
    },
    {
      "name": "connected_devices",
      "value": "已连接设备"
    },
    {
      "name": "no_devices",
      "value": "没有可用的设备"
    },
    {
      "name": "sync_clipboard",
      "value": "同步剪贴板"
    },
    {
      "name": "clipboard_history",
      "value": "剪贴板历史记录"
    },
    {
      "name": "no_history",
      "value": "没有历史记录"
    },
    {
      "name": "paste",
      "value": "粘贴"
    },
    {
      "name": "from",
      "value": "来自"
    },
    {
      "name": "synced_to_device",
      "value": "已同步到设备"
    },
    {
      "name": "pasted_to_clipboard",
      "value": "已粘贴到剪贴板"
    }
  ]
}

四、功能扩展

1. 内容类型过滤

// 在DistributedClipboard中添加内容过滤
class DistributedClipboard {
  private allowedTypes: pasteboard.PasteDataType[] = [
    pasteboard.PasteDataType.TEXT,
    pasteboard.PasteDataType.URI,
    pasteboard.PasteDataType.HTML
  ];
  
  private async syncLocalClipboardToDevices(): Promise<void> {
    // ...原有代码
    
    if (!pasteData || !this.allowedTypes.includes(pasteData.type)) {
      return;
    }
    
    // ...后续代码
  }
  
  public setAllowedTypes(types: pasteboard.PasteDataType[]): void {
    this.allowedTypes = types;
  }
}

2. 加密传输

// 添加加密模块
import cipher from '@ohos.security.crypto';

class DistributedClipboard {
  private async encryptData(data: string): Promise<string> {
    try {
      const cipherAlg = cipher.createCipher('AES128|ECB|PKCS7');
      const key = new Uint8Array(16); // 实际应用中应该使用安全的密钥
      const input: cipher.DataBlob = { data: new Uint8Array(Array.from(data).map(c => c.charCodeAt(0))) };
      const output = await cipherAlg.init(cipher.CryptoMode.ENCRYPT_MODE, key, null);
      const encrypted = await cipherAlg.doFinal(input);
      return Array.from(encrypted.data).map(b => String.fromCharCode(b)).join('');
    } catch (err) {
      console.error('加密失败:', JSON.stringify(err));
      return data;
    }
  }
  
  private async decryptData(data: string): Promise<string> {
    try {
      const cipherAlg = cipher.createCipher('AES128|ECB|PKCS7');
      const key = new Uint8Array(16); // 实际应用中应该使用安全的密钥
      const input: cipher.DataBlob = { data: new Uint8Array(Array.from(data).map(c => c.charCodeAt(0))) };
      const output = await cipherAlg.init(cipher.CryptoMode.DECRYPT_MODE, key, null);
      const decrypted = await cipherAlg.doFinal(input);
      return Array.from(decrypted.data).map(b => String.fromCharCode(b)).join('');
    } catch (err) {
      console.error('解密失败:', JSON.stringify(err));
      return data;
    }
  }
  
  private async createSyncData(pasteData: pasteboard.PasteData): Promise<ClipboardSyncData> {
    const data: ClipboardSyncData = {
      // ...其他字段
    };
    
    switch (pasteData.type) {
      case pasteboard.PasteDataType.TEXT:
        data.text = await this.encryptData(pasteData.text || '');
        break;
      // ...其他类型处理
    }
    
    return data;
  }
  
  private async applySyncData(syncData: ClipboardSyncData): Promise<void> {
    // ...原有代码
    
    switch (syncData.dataType) {
      case pasteboard.PasteDataType.TEXT:
        const decryptedText = await this.decryptData(syncData.text || '');
        pasteData = pasteboard.createPlainTextData(decryptedText);
        break;
      // ...其他类型处理
    }
    
    // ...后续代码
  }
}

3. 设备白名单管理

// 添加设备白名单功能
class DistributedClipboard {
  private deviceWhitelist: string[] = [];
  
  public addToWhitelist(deviceId: string): void {
    if (!this.deviceWhitelist.includes(deviceId)) {
      this.deviceWhitelist.push(deviceId);
    }
  }
  
  public removeFromWhitelist(deviceId: string): void {
    this.deviceWhitelist = this.deviceWhitelist.filter(id => id !== deviceId);
  }
  
  public getWhitelist(): string[] {
    return [...this.deviceWhitelist];
  }
  
  private getAvailableDevices(): deviceManager.DeviceInfo[] {
    try {
      const allDevices = this.deviceManager.getAvailableDeviceListSync();
      
      if (this.deviceWhitelist.length === 0) {
        return allDevices;
      }
      
      return allDevices.filter(device => 
        this.deviceWhitelist.includes(device.deviceId)
      );
    } catch (err) {
      console.error('获取可用设备列表失败:', JSON.stringify(err));
      return [];
    }
  }
}

五、总结

通过这个分布式剪贴板同步器的实现,我们学习了:

  1. 使用HarmonyOS剪贴板服务监听和操作剪贴板内容
  2. 利用分布式数据管理实现跨设备数据同步
  3. 设备管理和发现机制
  4. 剪贴板历史记录管理
  5. 数据安全和隐私保护策略

系统特点:

  • 支持多种数据类型(文本、URI、HTML等)
  • 实时同步剪贴板内容
  • 完善的剪贴板历史管理
  • 可配置的内容过滤和设备白名单
  • 可选的数据加密传输

这个系统可以进一步扩展为更强大的剪贴板管理工具,如:

  • 添加图片和富文本支持
  • 实现剪贴板内容搜索
  • 添加云同步功能
  • 支持自定义同步规则
  • 开发浏览器插件和桌面客户端
Logo

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

更多推荐