—— 再论为控件动态扩展 DragDrop 能力

夏群林 原创 2025.7.18

一、Drag / Drop 之间传递的参数

前文提到,拖放的实现需要 DragGestureRecognizer 与 DropGestureRecognizer 在不同的控件上相互配合,数据传输和配置复杂。主要有三个事件参数:DragStartingEventArgs,DragEventArgs 和 DropEventArgs。还有一个 DropCompletedEventArgs,不涉及实体数据传递,这里不讨论。

Drag / Drop 操作,本质上是把依存于源控件的数据,与依存于目标控件的数据,挑选出来,组合,供业务流程调用。

DragStartingEventArgs 是数据的起点,它打包了 DataPackage 类型的 Data,以及源控件的位置数据。位置数据暂且不论,我们聚焦在业务层面的实体数据上。去掉枝枝叶叶,在 Maui 中 DragStartingEventArgs 源代码是这样:

public class DragStartingEventArgs : EventArgs
{
public bool Handled { get; set; }
public bool Cancel { get; set; } public DataPackage Data { get; } = new DataPackage(); public virtual Point? GetPosition(Element? relativeTo) =>_getPosition?.Invoke(relativeTo);
}

展开 DataPackage,不神秘,就是一个在应用程序中封装和传递数据的容器,它的只读属性 Properties,一个键值对词典,Dictionary<string, object> _propertyBag,是载体,用 DataPackagePropertySet 类包装。object 类型的值,你可以在这里放任何你想传递的数据。所以,简单,但强大。

public class DataPackage
{
public DataPackagePropertySet Properties { get; } public ImageSource Image { get; set; }
public string Text { get; set; } public DataPackageView View => new DataPackageView(this.Clone());
} public class DataPackagePropertySet : IEnumerable
{
// 这里是数据保持处
Dictionary<string, object> _propertyBag; public IEnumerable<string> Keys => _propertyBag.Keys;
public IEnumerable<object> Values => _propertyBag.Values;
public void Add(string key, object value)=> _propertyBag.Add(key, value);
public bool ContainsKey(string key) => _propertyBag.ContainsKey(key);
public bool TryGetValue(string key, out object value) =>
_propertyBag.TryGetValue(key, out value);
}

DragEventArgs 是数据中间站,当拖动源控件经停目标控件时,平台会比对两个控件,是否有缘。属于 Drag / Drop 对相同阵营的,

public class DragEventArgs : EventArgs
{
public DragEventArgs(DataPackage dataPackage)
{
Data = dataPackage;
}
public DataPackage Data { get; }
public DataPackageOperation AcceptedOperation { get; set; } = DataPackageOperation.Copy;
}

就会允许 DataPackage 接收下来打包转发。 AcceptedOperation 决定要不要 Copy。事实上,枚举类型 DataPackageOperation 只有两个值:Copy / None 。同样,我们这里忽略了位置数据的讨论。

最后,DropEventArgs 将 DragStartingEventArgs 传来的 DataPackage 蒙上面纱,以 DataPackageView 面目示人。

public class DropEventArgs
{
public DataPackageView Data { get; }
public bool Handled { get; set; } public virtual Point? GetPosition(Element? relativeTo) =>
_getPosition?.Invoke(relativeTo);
} public class DataPackageView
{
public DataPackagePropertySetView Properties { get; } public Task<ImageSource> GetImageAsync()
{
return Task.FromResult(DataPackage.Image);
} public Task<string> GetTextAsync()
{
return Task.FromResult(DataPackage.Text);
}
}

DataPackageView 的数据载体是 DataPackagePropertySetView,后者是 DataPackagePropertySet 的只读包装:

public class DataPackagePropertySetView : IReadOnlyDictionary<string, object>
{
public DataPackagePropertySet _dataPackagePropertySet; public object this[string key] => _dataPackagePropertySet[key];
public IEnumerable<string> Keys => _dataPackagePropertySet.Keys;
public IEnumerable<object> Values => _dataPackagePropertySet.Values;
public int Count => _dataPackagePropertySet.Count;
public bool ContainsKey(string key) => _dataPackagePropertySet.ContainsKey(key);
public bool TryGetValue(string key, out object value) => _dataPackagePropertySet.TryGetValue(key, out value);
}

观察 DataPackage / DataPackageView,会发现,除了核心的用户数据字典外,还有一个字符串数据,string Text,一个图像数据,ImageSource Image。拖放操作,在 DragStartingEventArgs 时准备数据。最后在 DropEventArgs 处获取数据:

public Task<ImageSource> GetImageAsync()
{
return Task.FromResult(DataPackage.Image);
} public Task<string> GetTextAsync()
{
return Task.FromResult(DataPackage.Text);
}

你可以把 Text / Image 看作常用数据快捷通道。我的实践,就是利用这个快捷通道。

二、DataPackagePropertySetView 的核心价值:不止于包装

我相信,初学者大多会有我当初那样的困惑:用 DataPackagePropertySetView 包装 DataPackagePropertySet,是否多此一举?既然底层实质数据一样,用同一个数据类型岂不方便?何必要加 DataPackagePropertySetView 这层皮?

原因是,Maui 为我们带来跨平台数据标准化便利的同时,也带来了跨平台数据传递打包解包的繁杂,以及额外开销。

由于 Maui 控件的实现,最终会转化成应用所在平台的本机实现,Drag / Drop 操作所携带的数据,会一层层转换为本机要求的结构,再一层层转换回 Maui。这里的事情,不简单。

在 MAUI 拖放机制中,DataPackagePropertySetView 绝非简单的字典包装,而是跨平台数据传输的核心枢纽,其设计蕴含三大关键价值:

  1. 跨平台数据标准化。MAUI需要将数据转换为不同平台的原生格式(如Android的ClipData、iOS的NSItemProvider),而DataPackagePropertySetView通过标准化属性(如Title、Description、Keywords)屏蔽了底层差异。

  2. 类型安全与延迟加载。强类型访问,避免通过字符串键强制转换类型的风险(如(string)properties["Title"]);仅在调用GetTextAsync()等方法时才实际传输数据,延迟加载,减少无效开销。

  3. 安全隔离机制。作为只读视图,DataPackagePropertySetView防止拖放目标意外修改源数据,同时通过平台适配器确保数据传输的安全性(如跨进程场景的序列化/反序列化)。

不需要更细节的理解,但是我做了决定,能绕开依赖本机数据转换而传递数据的,最好在 Maui 层面直接处理。这也是当初我开发 AsDroppable/ AsDraggable 扩展方法 (参阅: Maui 实践:为控件动态扩展 DragDrop 能力 )。

自定义数据传递,通常我们会建立全局缓存管理器,在拖放源缓存对象并传递 ID,然后在拖放目标通过 ID 获取对象。因为数据产生于拖放源,如果拖放源不再被引用,其所产生的数据也应该销毁,否则会造成内存泄漏。

问题在于,我们使用 AsDraggable 方法为拖放源配置拖放数据时,不知道该拖放源在程序逻辑中,是否要释放,何时会释放。于是想到用弱引用管理器避免内存泄漏。

ConditionalWeakTable<TKey, TValue> 是 .NET 框架中的一个特殊集合类,在两个对象之间建立弱关联关系,同时确保不会阻止垃圾回收(GC)对这些对象的回收。当 TKey 类型的对象(键)被垃圾回收时,对应的 TValue 类型的值也会被自动从表中移除,不会因为键值对的存在而延长对象的生命周期。这样,我们可以缓存与特定拖 / 放源关联的数据,同时不影响这些对象的垃圾回收。完美。

不过,针对我的应用情形,完美之中有瑕疵。我们的全局缓存管理器在拖放源缓存对象并传递 ID,这个 ID,我直接选用 Guid 类型,可以唯一性区别无限个数据,简洁。但 Guid 是值类型,不符合 ConditionalWeakTable<TKey, TValue> 对 TKey 为引用类型的要求。

我设计了一个包装类,把 Guid 包装成引用类:

public sealed class GuidToken
{
public Guid Id { get; } = Guid.NewGuid();
public string Token => Id.ToString();
}

然后用 GuidToken 作为键,欺骗 ConditionalWeakTable:

private static readonly ConditionalWeakTable<GestureRecognizer, GuidToken> guidTokens = [];
private static readonly ConditionalWeakTable<GuidToken, DragDropPayload> dragDropPayloads = [];

这里,拖 / 放源为键关联 GuidToken 值,再以 GuidToken 为键关联数据 DragDropPayload。这样,我们就实现了在拖放过程中,当平台与本机做着复杂的交互时,只需传递简单的 guid 字符串,还保证不会内存泄漏。

我们还要做点额外的工作:为 GuidToken 建立一个生命期可控的强引用:

private static readonly ConcurrentDictionary<string, WeakReference<GuidToken>> tokenCache = new();

否则,GuidToken 不知何时会被 GC,其代表的数据亦不知何时被 GC。手动控制 GuidToken 生命期的方式,结合在 AsDraggable / AsDroppable 扩展方法中,后面一并讲到。

三、进阶:DynamicGesturesExtension 改进

根据前面的讨论,我对自己先前开发的 AsDraggable / AsDroppable 扩展方法予以改进。AsDraggable / AsDroppable 是通用方法,本想通过泛型的方式,源控件类型/目标控件类型的组合,来区分应该采取的拖放后续操作。为此,还回避不了头疼的反射技术。这次我顺手把它去掉了,区分拖放后续操作,只需要通过拖/放控件关联的数据类型 DragDropPayload 的组合,即可确认。我也简化了 DragDropPayload 数据结构,消除协变和逆变的顾忌。

public class DragDropPayload
{
public required View View { get; init; } // 拖放源/目标控件
public object? Affix { get; init; } // 任意附加数据(如文本、对象)
public Action<View, object?>? Callback { get; init; } // 拖放完成后的回调
public View? Anchor { get; set; } = null; // 拖放源/目标控件的 recognizer 依附 View 组件 public SourceTypeEnum SourceType { get; set; } // 标识。源/目标之间标识有交集者才能交互
}

1. 注册数据

private static string RegisterPayload(this GestureRecognizer recognizer, DragDropPayload payload)
{
ArgumentNullException.ThrowIfNull(recognizer);
ArgumentNullException.ThrowIfNull(payload); var guidToken = guidTokens.GetOrCreateValue(recognizer); dragDropPayloads.AddOrUpdate(guidToken, payload); tokenCache[guidToken.Token] = new WeakReference<GuidToken>(guidToken); return guidToken.Token;
}

注册数据在指定源控件 AsDragble 时完成:

public static void AsDraggable<TSourceAnchor, TSource>(this TSourceAnchor anchor, TSource source, 	       Func<TSourceAnchor, TSource, DragDropPayload> payloadCreator)
where TSourceAnchor : View
where TSource : View
{
AttachDragGestureRecognizer(anchor, source, payloadCreator); // 覆盖现有 payload(如果存在)
} private static void AttachDragGestureRecognizer<TSourceAnchor, TSource>(TSourceAnchor anchor, TSource source, Func<TSourceAnchor, TSource, DragDropPayload> payloadCreator)
where TSourceAnchor : View
where TSource : View
{
anchor.Undraggable();
DragGestureRecognizer dragGesture = new() { CanDrag = true };
anchor.GestureRecognizers.Add(dragGesture); dragGesture.DragStarting += (sender, args) =>
{
DragDropPayload dragPayload = payloadCreator(anchor, source);
_ = dragGesture.RegisterPayload(dragPayload); args.Data.Text = guidTokens.GetOrCreateValue(dragGesture).Token;
anchor.Opacity = 0.5;
}; dragGesture.DropCompleted += (sender, args) =>
{
guidTokens.GetOrCreateValue(dragGesture).Token.RemovePayload();
};
}

2. 匹配数据,在 DragLeave 事件中处理

dropGesture.DragOver += (sender, e) =>
{
string token = e.Data.Text; if (token.TryAssociatedPayload(out DragDropPayload? dragPayload) &&
guidTokens.TryGetValue(dropGesture, out GuidToken? dropToken) && dropToken is not null &&
dropToken.Token.TryAssociatedPayload(out DragDropPayload? dropPayload) &&
(dragPayload.SourceType & dropPayload.SourceType) != 0)
{
e.AcceptedOperation = DataPackageOperation.Copy;
}
else
{
e.AcceptedOperation = DataPackageOperation.None;
}
}; public static bool TryAssociatedPayload(this string token, [NotNullWhen(true)] out DragDropPayload? payload)
{
payload = null;
if (!token.IsValidGuid())
{
return false;
} if (tokenCache.TryGetValue(token, out var weakGuidToken) &&
weakGuidToken.TryGetTarget(out var guidToken) &&
dragDropPayloads.TryGetValue(guidToken, out payload))
{
return true;
} _ = tokenCache.TryRemove(token, out _); // 尝试清理缓存
return false;
}

注意上面尝试清理缓存,顺手做的。在DropCompleted事件处理中,此时数据传递使命完成,会专门移除缓存数据:

dragGesture.DropCompleted += (sender, args) =>
{
guidTokens.GetOrCreateValue(dragGesture).Token.RemovePayload();
}; public static void RemovePayload(this string token)
{
if (!token.IsValidGuid() || !tokenCache.TryGetValue(token, out var weakToken))
{
return;
} if (weakToken.TryGetTarget(out var guidToken))
{
_ = dragDropPayloads.Remove(guidToken);
} _ = tokenCache.TryRemove(token, out _);
}

3. 发送数据组合,OnDroppablesMessageAsync

public static void AsDroppable<TTargetAnchor, TTarget>(this TTargetAnchor anchor, DragDropPayload payload)
where TTargetAnchor : View
where TTarget : View
{
anchor.Undroppable();
DropGestureRecognizer dropGesture = new() { AllowDrop = true };
anchor.GestureRecognizers.Add(dropGesture);
_ = dropGesture.RegisterPayload(payload); // ... ... dropGesture.Drop += async (s, e) =>
{
await OnDroppablesMessageAsync<TTargetAnchor>(anchor, dropGesture, e);
// ... ...
};
} private static async Task OnDroppablesMessageAsync<TTargetAnchor>(TTargetAnchor anchor, DropGestureRecognizer dropGesture, DropEventArgs e)
where TTargetAnchor : View
{
string token = await e.Data.GetTextAsync();
// ... ...
_ = WeakReferenceMessenger.Default.Send<DragDropMessage>(new DragDropMessage()
{
SourcePayload = sourcePayload,
TargetPayload = targetPayload
});
// ... ...
}

五、总结:从"数据传输"到"对象生命周期管理"

MAUI拖放功能的核心挑战不仅在于数据传递,更在于对象生命周期的安全管理。DataPackagePropertySetView通过标准化接口屏蔽了平台差异,而ConditionalWeakTable则解决了复杂对象传输的性能与内存问题。

本 DynamicGesturesExtension 改进方案已在实际项目中验证,源代码开源,按照 MIT 协议许可。地址:xiaql/Zhally.Toolkit: Dynamically attach draggable and droppable capability to controls of View in MAUI

Maui 实践:不要把 DataPackagePropertySetView 看作一层皮的更多相关文章

  1. 手工走一次OPENSTACK安装,掉一层皮啊

    掉皮也是值得的,对OS的了解慢慢加深. 最近加入CS的Q群也学到不少.

  2. 朱晔的互联网架构实践心得S1E2:屡试不爽的架构三马车

    朱晔的互联网架构实践心得S1E2:屡试不爽的架构三马车 [下载本文PDF进行阅读] 这里所说的三架马车是指微服务.消息队列和定时任务.如下图所示,这里是一个三驾马车共同驱动的一个立体的互联网项目的架构 ...

  3. LINUX内核分析第四周学习总结——扒开系统调用的“三层皮”

    LINUX内核分析第四周学习总结--扒开系统调用的"三层皮" 标签(空格分隔): 20135321余佳源 余佳源 原创作品转载请注明出处 <Linux内核分析>MOOC ...

  4. Linux内核分析——期末总结

    Linux内核学习总结 首先非常感谢网易云课堂这个平台,让我能够在课下学习,课上加强,体会翻转课堂的乐趣.孟宁老师的课程循序渐进,虽然偶尔我学习地不是很透彻,但能够在后续的课程中进一步巩固学习,更加深 ...

  5. 【转】opencv检测运动物体的基础_特征提取

    特征提取是计算机视觉和图像处理中的一个概念.它指的是使用计算机提取图像信息,决定每个图像的点是否属于一个图像特征.特征提取的结果是把图像上的点分为不同的子集,这些子集往往属于孤立的点.连续的曲线或者连 ...

  6. 《STL源码剖析》相关面试题总结

    原文链接:http://www.cnblogs.com/raichen/p/5817158.html 一.STL简介 STL提供六大组件,彼此可以组合套用: 容器容器就是各种数据结构,我就不多说,看看 ...

  7. 写书好累 <HTTP抓包实战>终于出版

    我的新书<HTTP抓包实战>终于开始在京东销售了.内容是关于HTTP包,Fiddler抓包,JMeter发包,适合任何IT工程师阅读.我将自己十年所学的知识,融会贯通总结为一本书.阅读后肯 ...

  8. 面试题总结(三)、《STL源码剖析》相关面试题总结

    声明:本文主要探讨与STL实现相关的面试题,主要参考侯捷的<STL源码剖析>,每一个知识点讨论力求简洁,便于记忆,但讨论深度有限,如要深入研究可点击参考链接,希望对正在找工作的同学有点帮助 ...

  9. STL笔试面试题总结(干货)(转)

    STL笔试面试题总结 一.STL有哪些组件? STL提供六大组件彼此此可以组合套用: 1.容器容器就是各种数据结构,我就不多说,看看下面这张图回忆一下就好了,从实现角度看,STL容器是一种class ...

  10. Java 异常面试题(2020 最新版)

    Java异常架构与异常关键字 Java异常简介 Java异常是Java提供的一种识别及响应错误的一致性机制. Java异常机制可以使程序中异常处理代码和正常业务代码分离,保证程序代码更加优雅,并提高程 ...

随机推荐

  1. 开源项目YtyMark文本编辑器--UI界面相关功能(关于设计模式的实战运用)

    开源项目地址 GitHub 开源地址(YtyMark-java) 欢迎提交 PR.Issue.Star ️! 1. 简述 YtyMark-java项目分为两大模块: UI界面(ytyedit-mark ...

  2. Sentinel源码—9.限流算法的实现对比

    大纲 1.漏桶算法的实现对比 (1)普通思路的漏桶算法实现 (2)节省线程的漏桶算法实现 (3)Sentinel中的漏桶算法实现 (4)Sentinel中的漏桶算法与普通漏桶算法的区别 (5)Sent ...

  3. chrome “从 Google 获取图片说明”

    右键菜单"从 Google 获取图片说明"多余去掉. 设置-高级-使用硬件加速模式(如果可用)-关闭 在用户使用上firefox完胜chrome,但是firefox的开发人员工具相 ...

  4. Java的"伪泛型"变"真泛型"后,会对性能有帮助吗?

    泛型存在于Java源代码中,在编译为字节码文件之前都会进行泛型擦除(type erasure),因此,Java的泛型完全由Javac等编译器在编译期提供支持,可以理解为Java的一颗语法糖,这种方式实 ...

  5. Strands Agents(一)Strands Agents 介绍

    Strands Agent AWS 最新开源的 Strands Agents SDK 是一款采用模型驱动架构的 AI 代理开发框架,旨在通过极简开发方式,帮助开发者快速构建和部署 AI 代理.它将代理 ...

  6. python C3算法

    Python MRO C3算法是python当中计算类继承顺序的一个算法,从python2.3以后就一直使用此算法了. c3 linearization算法称为c3线性化算法 C3算法原理 首先定义几 ...

  7. mysql字符集插入中文报错

    org.apache.ibatis.exceptions.PersistenceException: ### Error updating database. Cause: java.sql.SQLE ...

  8. 你了解CAS吗?有什么问题吗?如何解决?

    什么是CAS? CAS全称Compare And Swap,比较与交换,是乐观锁的主要实现方式.CAS在不使用锁的情况下实现多线程之间的变量同步.ReentrantLock内部的AQS和原子类内部都使 ...

  9. 修改AndroidStudio的Boot Java Runtime for the IDE后,IDE打开报错无法运行

      修改AndroidStudio的Boot Java Runtime for the IDE后,IDE打开报错无法运行,解决方法 一.问题 我想在AndroidStudio里使用markdown支持 ...

  10. Visual Studio 2022 中的 EF Core 反向工程和模型可视化扩展插件

    前言 在 EF 6 及其之前的版本数据库优先模式(Database First)是可以在 Visual Studio 中通过可视化界面来操作完成的,但是到了 EF Core 之后就不再支持了(因为模型 ...