Flutter 与 OpenHarmony 深度整合:构建跨设备统一剪贴板同步系统

引言

在多设备协同场景中,用户频繁遇到这样的"断点":

  • 手机上复制了一段文字,想粘贴到平板的文档里,却要重新输入;
  • 电脑上复制了一个链接,想在电视上打开,但无法传递;
  • 智能手表收到验证码,却无法复制到手机登录界面。

OpenHarmony 提供了 分布式剪贴板服务(Distributed Clipboard Service) ,支持文本、图片、URI 等内容在可信设备间自动同步 。而 Flutter 可作为统一交互层,构建一个 "全局剪贴板历史中心",实现跨设备无缝复制粘贴。

本文将带你从零开发一个 跨设备剪贴板同步与历史管理系统,具备以下能力:

  • 手机复制内容,平板/手表自动同步;
  • 支持文本、图片、链接三种格式;
  • 保留最近 50 条剪贴板历史,支持搜索与固定;
  • 自动过滤敏感内容(如银行卡号、密码);
  • 基于设备活跃状态智能推送剪贴板更新。

这是目前社区首篇完整实现 Flutter + OpenHarmony 分布式剪贴板协同的实战教程


一、技术原理:分布式剪贴板如何工作?

OpenHarmony 的剪贴板系统通过 @ohos.clipboard + 软总线(DSoftBus) 实现跨设备同步:

复制代码
+------------------+       +------------------+       +------------------+
|   手机           |       |   平板           |       |   手表           |
| - 复制 "Hello"   |<----->| - 自动可粘贴     |<----->| - 显示简略预览   |
+--------+---------+ DSoftBus +--------+---------+       +--------+---------+
         |                          |                          |
   [Clipboard.setData()]    [Clipboard.hasData()]        [Wearable UI]
         |                          |                          |
         +------------ 剪贴板内容加密同步 <---------------------+

核心特性:

  • 自动同步 :调用 setData() 后,内容自动推送到同账号、已配对设备;
  • 类型安全 :支持 text/plainimage/*text/uri-list
  • 权限隔离:仅同应用或系统级剪贴板可访问;
  • 端到端加密:传输过程使用设备证书加密,保障隐私。

✅ 开发者只需调用标准 API,即可实现基础同步。但若要构建历史记录、智能过滤、统一管理界面,需深度集成。


二、整体架构设计

MethodChannel DSoftBus Flutter 剪贴板中心 ClipboardPlugin DistributedClipboardManager 本地剪贴板 远程设备剪贴板 历史记录数据库 敏感内容检测 剪贴板类型识别

关键模块:

  • DistributedClipboardManager:封装原生剪贴板监听与同步;
  • ClipboardPlugin:桥接 Dart 与 ArkTS;
  • HistoryStore:使用 Hive 持久化剪贴板历史;
  • ContentSanitizer:基于正则过滤银行卡、密码等敏感信息。

三、原生侧:监听与同步剪贴板(ArkTS)

1. 权限配置

json 复制代码
// module.json5
{
  "module": {
    "requestPermissions": [
      { "name": "ohos.permission.DISTRIBUTED_DATASYNC" },
      { "name": "ohos.permission.GET_DISTRIBUTED_DEVICE_INFO" }
    ]
  }
}

2. 创建剪贴板管理器 DistributedClipboardManager.ets

ts 复制代码
// services/DistributedClipboardManager.ets
import clipboard from '@ohos.clipboard';
import deviceManager from '@ohos.distributedHardware.deviceManager';

type ClipItem = {
  id: string;          // 时间戳+设备ID
  deviceId: string;    // 源设备ID
  deviceName: string;  // 源设备名称
  type: 'text' | 'image' | 'uri';
  content: string;     // 文本或Base64图片或URI
  timestamp: number;
};

class DistributedClipboardManager {
  private callback: ((item: ClipItem) => void) | null = null;
  private isListening = false;

  async startListening(): Promise<void> {
    if (this.isListening) return;
    this.isListening = true;

    // 监听本地剪贴板变更
    clipboard.on('update', this.handleLocalUpdate.bind(this));

    // 主动拉取远程设备最新剪贴板(简化模型)
    this.pollRemoteClipboard();
  }

  stopListening(): void {
    clipboard.off('update');
    this.isListening = false;
  }

  // 本地剪贴板更新回调
  private async handleLocalUpdate(): Promise<void> {
    const data = await clipboard.getData();
    if (!data || !data.record) return;

    const item = await this.normalizeClipData(data, 'local', '本机');
    if (item) {
      this.callback?.(item);
      // 自动同步到其他设备(系统默认行为,此处仅为通知Flutter)
    }
  }

  // 拉取远程设备剪贴板(模拟:实际应由系统自动同步)
  private async pollRemoteClipboard(): Promise<void> {
    try {
      const devices = await deviceManager.getTrustedDeviceList();
      for (const device of devices) {
        // 注:OpenHarmony 分布式剪贴板为系统级自动同步
        // 此处仅用于触发Flutter侧更新(因系统不提供远程变更事件)
        setTimeout(() => this.checkAndNotifyRemote(device), 2000);
      }
    } catch (err) {
      console.warn('[Clipboard] Failed to get remote devices');
    }
  }

  // 检查远程剪贴板是否变化(简化:每次读取即视为新内容)
  private async checkAndNotifyRemote(device: any): Promise<void> {
    // 实际开发中,可通过软总线请求远程设备发送其最新剪贴板
    // 此处假设已同步,直接读取本地分布式剪贴板
    const data = await clipboard.getData();
    if (data?.record) {
      const item = await this.normalizeClipData(data, device.deviceId, device.deviceName);
      if (item) this.callback?.(item);
    }
  }

  // 标准化剪贴板内容
  private async normalizeClipData(
    data: any,
    deviceId: string,
    deviceName: string
  ): Promise<ClipItem | null> {
    const record = data.record;
    const timestamp = Date.now();
    const id = `${timestamp}-${deviceId}`;

    if (record.mimeTypes.includes('text/plain')) {
      const text = await clipboard.getText();
      if (text) {
        return { id, deviceId, deviceName, type: 'text', content: text, timestamp };
      }
    }

    if (record.mimeTypes.includes('text/uri-list')) {
      const uri = await clipboard.getUri();
      if (uri) {
        return { id, deviceId, deviceName, type: 'uri', content: uri, timestamp };
      }
    }

    // 图片暂不支持(需处理PixelMap转Base64,性能开销大)
    return null;
  }

  setCallback(cb: (item: ClipItem) => void): void {
    this.callback = cb;
  }

  // 主动设置剪贴板(用于跨设备粘贴)
  async setData(content: string, type: 'text' | 'uri'): Promise<boolean> {
    try {
      if (type === 'text') {
        await clipboard.setText(content);
      } else if (type === 'uri') {
        await clipboard.setUri(content);
      }
      return true;
    } catch (err) {
      console.error('[Clipboard] setData failed:', err);
      return false;
    }
  }
}

const clipboardManager = new DistributedClipboardManager();
export default clipboardManager;

💡 说明 :OpenHarmony 的分布式剪贴板默认开启自动同步 ,无需手动推送。但系统不提供远程变更事件 ,因此需定期检查或依赖本地 update 事件间接触发。

3. 插件层:暴露给 Flutter

ts 复制代码
// plugins/ClipboardPlugin.ets
import manager from '../services/DistributedClipboardManager';
import { MethodChannel, EventChannel } from '@flutter/engine';

const METHOD_CHANNEL = 'com.example.flutter/clipboard/method';
const EVENT_CHANNEL = 'com.example.flutter/clipboard/event';

export class ClipboardPlugin {
  private eventSink: any = null;

  init() {
    manager.setCallback((item) => {
      if (this.eventSink) {
        this.eventSink.success(item);
      }
    });

    const methodChannel = new MethodChannel(METHOD_CHANNEL);
    methodChannel.setMethodCallHandler(this.handleMethod.bind(this));

    const eventChannel = new EventChannel(EVENT_CHANNEL);
    eventChannel.setStreamHandler({
      onListen: (_, sink) => this.eventSink = sink,
      onCancel: () => this.eventSink = null
    });
  }

  private async handleMethod(call: any): Promise<any> {
    switch (call.method) {
      case 'startListening':
        await manager.startListening();
        return { success: true };

      case 'stopListening':
        manager.stopListening();
        return { success: true };

      case 'setData':
        const success = await manager.setData(
          call.arguments['content'],
          call.arguments['type']
        );
        return { success };
    }
    throw new Error('Unknown method');
  }
}

EntryAbility.ets 中初始化:

ts 复制代码
new ClipboardPlugin().init();

四、Flutter 侧:统一剪贴板中心实现

1. 数据模型

dart 复制代码
// lib/models/clip_item.dart
class ClipItem {
  final String id;
  final String deviceId;
  final String deviceName;
  final String type; // 'text', 'uri'
  final String content;
  final int timestamp;
  final bool isPinned;

  ClipItem({
    required this.id,
    required this.deviceId,
    required this.deviceName,
    required this.type,
    required this.content,
    required this.timestamp,
    this.isPinned = false,
  });

  factory ClipItem.fromJson(Map<dynamic, dynamic> json) {
    return ClipItem(
      id: json['id'] as String,
      deviceId: json['deviceId'] as String,
      deviceName: json['deviceName'] as String,
      type: json['type'] as String,
      content: json['content'] as String,
      timestamp: json['timestamp'] as int,
      isPinned: json['isPinned'] == true,
    );
  }

  Map<String, dynamic> toJson() => {
        'id': id,
        'deviceId': deviceId,
        'deviceName': deviceName,
        'type': type,
        'content': content,
        'timestamp': timestamp,
        'isPinned': isPinned,
      };
}

2. 敏感内容过滤器

dart 复制代码
// lib/utils/content_sanitizer.dart
class ContentSanitizer {
  static final _sensitivePatterns = [
    RegExp(r'\b\d{16,19}\b'), // 银行卡号
    RegExp(r'(?i)password|passwd|pwd'), // 密码关键词
    RegExp(r'\b\d{6}\b'), // 6位纯数字(可能为验证码,但需谨慎)
  ];

  static bool isSensitive(String text) {
    return _sensitivePatterns.any((pattern) => pattern.hasMatch(text));
  }

  static String maskSensitive(String text) {
    if (isSensitive(text)) {
      return '••••••(敏感内容已隐藏)';
    }
    return text;
  }
}

3. 剪贴板服务封装

dart 复制代码
// lib/services/clipboard_service.dart
import 'package:flutter/services.dart';
import '../models/clip_item.dart';

class ClipboardService {
  static const _method = MethodChannel('com.example.flutter/clipboard/method');
  static const _event = EventChannel('com.example.flutter/clipboard/event');

  static Future<void> startListening() async {
    await _method.invokeMethod('startListening');
  }

  static Future<void> stopListening() async {
    await _method.invokeMethod('stopListening');
  }

  static Future<bool> setData(String content, String type) async {
    final result = await _method.invokeMethod('setData', {
      'content': content,
      'type': type,
    });
    return result['success'] == true;
  }

  static Stream<ClipItem> onClipChanged() async* {
    await for (final event in _event.receiveBroadcastStream()) {
      yield ClipItem.fromJson(event as Map);
    }
  }
}

4. 使用 Hive 存储历史(支持固定项置顶)

dart 复制代码
// lib/models/clip_hive.dart
import 'package:hive/hive.dart';

part 'clip_hive.g.dart';

@HiveType(typeId: 2)
class HiveClip extends HiveObject {
  @HiveField(0)
  final String id;
  @HiveField(1)
  final String deviceId;
  @HiveField(2)
  final String deviceName;
  @HiveField(3)
  final String type;
  @HiveField(4)
  final String content;
  @HiveField(5)
  final int timestamp;
  @HiveField(6)
  bool isPinned;

  HiveClip({
    required this.id,
    required this.deviceId,
    required this.deviceName,
    required this.type,
    required this.content,
    required this.timestamp,
    this.isPinned = false,
  });

  factory HiveClip.fromItem(ClipItem item) {
    return HiveClip(
      id: item.id,
      deviceId: item.deviceId,
      deviceName: item.deviceName,
      type: item.type,
      content: item.content,
      timestamp: item.timestamp,
      isPinned: item.isPinned,
    );
  }
}

5. 状态管理:聚合实时 + 历史剪贴板

dart 复制代码
// lib/providers/clipboard_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive/hive.dart';
import '../services/clipboard_service.dart';
import '../models/clip_hive.dart';
import '../utils/content_sanitizer.dart';

final clipboardProvider = StateNotifierProvider<ClipboardManager, List<HiveClip>>((ref) {
  return ClipboardManager();
});

class ClipboardManager extends StateNotifier<List<HiveClip>> {
  late Box<HiveClip> _box;
  StreamSubscription? _subscription;

  ClipboardManager() : super([]) {
    _init();
  }

  Future<void> _init() async {
    _box = await Hive.openBox<HiveClip>('clips');
    state = _box.values.toList()
      ..sort((a, b) => (b.isPinned ? 1 : 0) - (a.isPinned ? 1 : 0) != 0
          ? (b.isPinned ? 1 : 0) - (a.isPinned ? 1 : 0)
          : b.timestamp.compareTo(a.timestamp));

    _subscription = ClipboardService.onClipChanged().listen((item) {
      if (!ContentSanitizer.isSensitive(item.content)) {
        _addOrUpdate(item);
      }
    });

    ClipboardService.startListening();
  }

  void _addOrUpdate(ClipItem item) {
    // 避免重复
    final existing = _box.values.firstWhereOrNull((c) => c.content == item.content);
    if (existing != null) return;

    // 限制历史数量(保留50条)
    if (_box.length >= 50 && !_box.values.any((c) => c.isPinned)) {
      final oldest = _box.values.reduce((a, b) => a.timestamp < b.timestamp ? a : b);
      _box.delete(oldest.key);
    }

    final hiveClip = HiveClip.fromItem(item);
    _box.add(hiveClip);
    _refreshState();
  }

  Future<void> pinClip(HiveClip clip) async {
    clip.isPinned = !clip.isPinned;
    await clip.save();
    _refreshState();
  }

  Future<void> copyToCurrentDevice(String content, String type) async {
    await ClipboardService.setData(content, type);
  }

  void _refreshState() {
    state = _box.values.toList()
      ..sort((a, b) => (b.isPinned ? 1 : 0) - (a.isPinned ? 1 : 0) != 0
          ? (b.isPinned ? 1 : 0) - (a.isPinned ? 1 : 0)
          : b.timestamp.compareTo(a.timestamp));
  }

  @override
  void dispose() {
    _subscription?.cancel();
    ClipboardService.stopListening();
    super.dispose();
  }
}

6. 构建剪贴板中心 UI

dart 复制代码
// lib/screens/clipboard_center.dart
class ClipboardCenterScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final clips = ref.watch(clipboardProvider);

    return Scaffold(
      appBar: AppBar(title: Text('跨设备剪贴板')),
      body: clips.isEmpty
          ? Center(child: Text('暂无剪贴内容'))
          : ListView.builder(
              itemCount: clips.length,
              itemBuilder: (context, index) {
                final clip = clips[index];
                final displayContent = ContentSanitizer.maskSensitive(clip.content);

                return Card(
                  margin: EdgeInsets.symmetric(horizontal: 16, vertical: 6),
                  child: ListTile(
                    leading: CircleAvatar(
                      child: Icon(
                        clip.type == 'uri' ? Icons.link : Icons.text_snippet,
                        size: 18,
                      ),
                      backgroundColor: clip.isPinned ? Colors.amber : Colors.grey,
                    ),
                    title: Text(
                      displayContent.length > 30
                          ? '${displayContent.substring(0, 30)}...'
                          : displayContent,
                      maxLines: 1,
                      overflow: TextOverflow.ellipsis,
                    ),
                    subtitle: Text('${clip.deviceName} · ${_formatTime(clip.timestamp)}'),
                    trailing: Row(
                      mainAxisSize: MainAxisSize.min,
                      children: [
                        IconButton(
                          icon: Icon(clip.isPinned ? Icons.push_pin : Icons.push_pin_outlined),
                          onPressed: () => ref.read(clipboardProvider.notifier).pinClip(clip),
                        ),
                        IconButton(
                          icon: Icon(Icons.copy),
                          onPressed: () => ref.read(clipboardProvider.notifier)
                              .copyToCurrentDevice(clip.content, clip.type),
                        ),
                      ],
                    ),
                  ),
                );
              },
            ),
    );
  }

  String _formatTime(int timestamp) {
    final now = DateTime.now();
    final then = DateTime.fromMillisecondsSinceEpoch(timestamp);
    if (now.difference(then).inHours > 24) {
      return '${then.month}/${then.day}';
    }
    return '${then.hour}:${then.minute.toString().padLeft(2, '0')}';
  }
}

五、关键问题与优化方向

问题 解决方案
图片同步性能差 暂不支持图片,或压缩后 Base64 传输(需权衡体验)
远程变更无事件 定期轮询 + 本地 update 事件兜底
敏感内容泄露 正则过滤 + 用户可配置白名单
历史记录膨胀 自动清理非固定项,上限 50 条

六、总结

本文实现了 Flutter + OpenHarmony 分布式剪贴板系统,解决了跨设备复制粘贴的割裂体验,核心价值包括:

  • 无缝同步:一处复制,处处可用;
  • 历史追溯:再也不怕误覆盖;
  • 隐私保护:自动屏蔽敏感信息;
  • 统一管理:固定常用内容,提升效率。

此系统可广泛应用于:

  • 办公场景:手机查资料 → 平板写报告;
  • 家庭共享:电视看视频 → 手机复制演员名搜索;
  • 开发调试:PC 复制日志 → 手机快速粘贴分析。

欢迎大家加入开源鸿蒙跨平台开发者社区,一起共建开源鸿蒙跨平台生态。

相关推荐
嗝o゚4 小时前
鸿蒙跨端协同与Flutter结合的远程办公轻应用开发
flutter·华为·wpf
豫狮恒4 小时前
OpenHarmony Flutter 分布式权限管理:跨设备可信访问与权限协同方案
分布式·flutter·wpf·openharmony
帅气马战的账号14 小时前
OpenHarmony 分布式数据同步:基于 ArkTS 与轻量级协议的全场景实践
flutter
He BianGu4 小时前
【笔记】在WPF中如何使用ContentPresenter 与 Generic.xaml 设置数据默认 DataTemplate
windows·笔记·wpf
ujainu5 小时前
Flutter+DevEco Studio实战:简易天气查询工具开发指南
flutter·deveco studio
小白|5 小时前
Flutter 与 OpenHarmony 深度融合:实现分布式文件共享与跨设备协同编辑系统
分布式·flutter·wpf
帅气马战的账号15 小时前
OpenHarmony与Flutter深度融合:分布式跨端开发全栈实践指南
flutter
遝靑5 小时前
Flutter 状态管理深度剖析:Provider/Bloc/GetX 原理 + 实战 + 选型(附避坑 & 性能对比)
flutter
豫狮恒5 小时前
OpenHarmony Flutter 分布式数据持久化:跨设备数据一致性与同步方案
分布式·安全·flutter·wpf·openharmony