找回密码
 立即注册
首页 业界区 业界 Maui 实践:不要把 DataPackagePropertySetView 看作 ...

Maui 实践:不要把 DataPackagePropertySetView 看作一层皮

殷罗绮 2025-7-18 11:24:38
—— 再论为控件动态扩展 DragDrop 能力
夏群林 原创 2025.7.18
一、Drag / Drop 之间传递的参数

前文提到,拖放的实现需要 DragGestureRecognizer 与 DropGestureRecognizer 在不同的控件上相互配合,数据传输和配置复杂。主要有三个事件参数:DragStartingEventArgs,DragEventArgs 和 DropEventArgs。还有一个 DropCompletedEventArgs,不涉及实体数据传递,这里不讨论。
Drag / Drop 操作,本质上是把依存于源控件的数据,与依存于目标控件的数据,挑选出来,组合,供业务流程调用。
DragStartingEventArgs 是数据的起点,它打包了 DataPackage 类型的 Data,以及源控件的位置数据。位置数据暂且不论,我们聚焦在业务层面的实体数据上。去掉枝枝叶叶,在 Maui 中 DragStartingEventArgs 源代码是这样:
  1. public class DragStartingEventArgs : EventArgs
  2. {
  3.     public bool Handled { get; set; }
  4.         public bool Cancel { get; set; }
  5.         public DataPackage Data { get; } = new DataPackage();
  6.         public virtual Point? GetPosition(Element? relativeTo) =>_getPosition?.Invoke(relativeTo);
  7. }
复制代码
展开 DataPackage,不神秘,就是一个在应用程序中封装和传递数据的容器,它的只读属性 Properties,一个键值对词典,Dictionary _propertyBag,是载体,用 DataPackagePropertySet 类包装。object 类型的值,你可以在这里放任何你想传递的数据。所以,简单,但强大。
  1. public class DataPackage
  2. {
  3.     public DataPackagePropertySet Properties { get; }
  4.     public ImageSource Image { get; set; }
  5.     public string Text { get; set; }
  6.    
  7.     public DataPackageView View => new DataPackageView(this.Clone());
  8. }
  9. public class DataPackagePropertySet : IEnumerable
  10. {
  11.     // 这里是数据保持处
  12.         Dictionary<string, object> _propertyBag;
  13.    
  14.         public IEnumerable<string> Keys => _propertyBag.Keys;
  15.         public IEnumerable<object> Values => _propertyBag.Values;
  16.         public void Add(string key, object value)=> _propertyBag.Add(key, value);
  17.         public bool ContainsKey(string key) => _propertyBag.ContainsKey(key);
  18.         public bool TryGetValue(string key, out object value) =>
  19.         _propertyBag.TryGetValue(key, out value);
  20. }
复制代码
DragEventArgs  是数据中间站,当拖动源控件经停目标控件时,平台会比对两个控件,是否有缘。属于 Drag / Drop 对相同阵营的,
  1. public class DragEventArgs : EventArgs
  2. {
  3.         public DragEventArgs(DataPackage dataPackage)
  4.         {
  5.                 Data = dataPackage;
  6.         }
  7.         public DataPackage Data { get; }
  8.         public DataPackageOperation AcceptedOperation { get; set; } = DataPackageOperation.Copy;
  9. }
复制代码
就会允许 DataPackage 接收下来打包转发。 AcceptedOperation 决定要不要 Copy。事实上,枚举类型 DataPackageOperation 只有两个值:Copy / None 。同样,我们这里忽略了位置数据的讨论。
最后,DropEventArgs 将 DragStartingEventArgs  传来的 DataPackage 蒙上面纱,以 DataPackageView 面目示人。
  1. public class DropEventArgs
  2. {
  3.         public DataPackageView Data { get; }
  4.         public bool Handled { get; set; }
  5.         public virtual Point? GetPosition(Element? relativeTo) =>
  6.                 _getPosition?.Invoke(relativeTo);
  7. }
  8. public class DataPackageView
  9. {
  10.     public DataPackagePropertySetView Properties { get; }
  11.    
  12.     public Task<ImageSource> GetImageAsync()
  13.     {
  14.         return Task.FromResult(DataPackage.Image);
  15.     }
  16.    
  17.     public Task<string> GetTextAsync()
  18.     {
  19.         return Task.FromResult(DataPackage.Text);
  20.     }
  21. }
复制代码
DataPackageView 的数据载体是 DataPackagePropertySetView,后者是 DataPackagePropertySet 的只读包装:
  1. public class DataPackagePropertySetView : IReadOnlyDictionary<string, object>
  2. {
  3.         public DataPackagePropertySet _dataPackagePropertySet;
  4.         public object this[string key] => _dataPackagePropertySet[key];
  5.         public IEnumerable<string> Keys => _dataPackagePropertySet.Keys;
  6.         public IEnumerable<object> Values => _dataPackagePropertySet.Values;
  7.         public int Count => _dataPackagePropertySet.Count;
  8.         public bool ContainsKey(string key) => _dataPackagePropertySet.ContainsKey(key);
  9.         public bool TryGetValue(string key, out object value) => _dataPackagePropertySet.TryGetValue(key, out value);
  10. }
复制代码
观察 DataPackage / DataPackageView,会发现,除了核心的用户数据字典外,还有一个字符串数据,string Text,一个图像数据,ImageSource Image。拖放操作,在 DragStartingEventArgs 时准备数据。最后在 DropEventArgs 处获取数据:
  1. public Task<ImageSource> GetImageAsync()
  2. {
  3.     return Task.FromResult(DataPackage.Image);
  4. }
  5. public Task<string> GetTextAsync()
  6. {
  7.     return Task.FromResult(DataPackage.Text);
  8. }
复制代码
你可以把 Text / Image 看作常用数据快捷通道。我的实践,就是利用这个快捷通道。
二、DataPackagePropertySetView 的核心价值:不止于包装

我相信,初学者大多会有我当初那样的困惑:用 DataPackagePropertySetView 包装 DataPackagePropertySet,是否多此一举?既然底层实质数据一样,用同一个数据类型岂不方便?何必要加 DataPackagePropertySetView  这层皮?
原因是,Maui 为我们带来跨平台数据标准化便利的同时,也带来了跨平台数据传递打包解包的繁杂,以及额外开销。
由于 Maui 控件的实现,最终会转化成应用所在平台的本机实现,Drag / Drop 操作所携带的数据,会一层层转换为本机要求的结构,再一层层转换回 Maui。这里的事情,不简单。
在 MAUI 拖放机制中,DataPackagePropertySetView 绝非简单的字典包装,而是跨平台数据传输的核心枢纽,其设计蕴含三大关键价值:

  • 跨平台数据标准化。MAUI需要将数据转换为不同平台的原生格式(如Android的ClipData、iOS的NSItemProvider),而DataPackagePropertySetView通过标准化属性(如Title、Description、Keywords)屏蔽了底层差异。
  • 类型安全与延迟加载。强类型访问,避免通过字符串键强制转换类型的风险(如(string)properties["Title"]);仅在调用GetTextAsync()等方法时才实际传输数据,延迟加载,减少无效开销。
  • 安全隔离机制。作为只读视图,DataPackagePropertySetView防止拖放目标意外修改源数据,同时通过平台适配器确保数据传输的安全性(如跨进程场景的序列化/反序列化)。
不需要更细节的理解,但是我做了决定,能绕开依赖本机数据转换而传递数据的,最好在 Maui 层面直接处理。这也是当初我开发 AsDroppable/ AsDraggable 扩展方法 (参阅: Maui 实践:为控件动态扩展 DragDrop 能力 )。
自定义数据传递,通常我们会建立全局缓存管理器,在拖放源缓存对象并传递 ID,然后在拖放目标通过 ID 获取对象。因为数据产生于拖放源,如果拖放源不再被引用,其所产生的数据也应该销毁,否则会造成内存泄漏。
问题在于,我们使用 AsDraggable 方法为拖放源配置拖放数据时,不知道该拖放源在程序逻辑中,是否要释放,何时会释放。于是想到用弱引用管理器避免内存泄漏。
ConditionalWeakTable 是 .NET 框架中的一个特殊集合类,在两个对象之间建立弱关联关系,同时确保不会阻止垃圾回收(GC)对这些对象的回收。当 TKey 类型的对象(键)被垃圾回收时,对应的 TValue 类型的值也会被自动从表中移除,不会因为键值对的存在而延长对象的生命周期。这样,我们可以缓存与特定拖 / 放源关联的数据,同时不影响这些对象的垃圾回收。完美。
不过,针对我的应用情形,完美之中有瑕疵。我们的全局缓存管理器在拖放源缓存对象并传递 ID,这个 ID,我直接选用 Guid 类型,可以唯一性区别无限个数据,简洁。但 Guid 是值类型,不符合 ConditionalWeakTable 对 TKey 为引用类型的要求。
我设计了一个包装类,把 Guid 包装成引用类:
  1. public sealed class GuidToken
  2. {
  3.     public Guid Id { get; } = Guid.NewGuid();
  4.     public string Token => Id.ToString();
  5. }
复制代码
然后用 GuidToken 作为键,欺骗 ConditionalWeakTable:
  1. private static readonly ConditionalWeakTable<GestureRecognizer, GuidToken> guidTokens = [];
  2. private static readonly ConditionalWeakTable<GuidToken, DragDropPayload> dragDropPayloads = [];
复制代码
这里,拖 / 放源为键关联 GuidToken 值,再以 GuidToken 为键关联数据 DragDropPayload。这样,我们就实现了在拖放过程中,当平台与本机做着复杂的交互时,只需传递简单的 guid 字符串,还保证不会内存泄漏。
我们还要做点额外的工作:为 GuidToken 建立一个生命期可控的强引用:
  1. private static readonly ConcurrentDictionary<string, WeakReference<GuidToken>> tokenCache = new();
复制代码
否则,GuidToken 不知何时会被 GC,其代表的数据亦不知何时被 GC。手动控制 GuidToken  生命期的方式,结合在 AsDraggable / AsDroppable 扩展方法中,后面一并讲到。
三、进阶:DynamicGesturesExtension 改进

根据前面的讨论,我对自己先前开发的 AsDraggable / AsDroppable 扩展方法予以改进。AsDraggable / AsDroppable 是通用方法,本想通过泛型的方式,源控件类型/目标控件类型的组合,来区分应该采取的拖放后续操作。为此,还回避不了头疼的反射技术。这次我顺手把它去掉了,区分拖放后续操作,只需要通过拖/放控件关联的数据类型 DragDropPayload 的组合,即可确认。我也简化了 DragDropPayload 数据结构,消除协变和逆变的顾忌。
  1. public class DragDropPayload
  2. {
  3.     public required View View { get; init; }                        // 拖放源/目标控件
  4.     public object? Affix { get; init; }                             // 任意附加数据(如文本、对象)
  5.     public Action<View, object?>? Callback { get; init; }           // 拖放完成后的回调
  6.     public View? Anchor { get; set; } = null;                       // 拖放源/目标控件的 recognizer 依附 View 组件
  7.     public SourceTypeEnum SourceType { get; set; }                  // 标识。源/目标之间标识有交集者才能交互
  8. }
复制代码
1. 注册数据
  1. private static string RegisterPayload(this GestureRecognizer recognizer, DragDropPayload payload)
  2. {
  3.     ArgumentNullException.ThrowIfNull(recognizer);
  4.     ArgumentNullException.ThrowIfNull(payload);
  5.     var guidToken = guidTokens.GetOrCreateValue(recognizer);
  6.     dragDropPayloads.AddOrUpdate(guidToken, payload);
  7.     tokenCache[guidToken.Token] = new WeakReference<GuidToken>(guidToken);
  8.     return guidToken.Token;
  9. }
复制代码
注册数据在指定源控件 AsDragble 时完成:
  1. public static void AsDraggable<TSourceAnchor, TSource>(this TSourceAnchor anchor, TSource source,                Func<TSourceAnchor, TSource, DragDropPayload> payloadCreator)
  2.     where TSourceAnchor : View
  3.     where TSource : View
  4. {
  5.     AttachDragGestureRecognizer(anchor, source, payloadCreator); // 覆盖现有 payload(如果存在)
  6. }
  7. private static void AttachDragGestureRecognizer<TSourceAnchor, TSource>(TSourceAnchor anchor, TSource source, Func<TSourceAnchor, TSource, DragDropPayload> payloadCreator)
  8.     where TSourceAnchor : View
  9.     where TSource : View
  10. {
  11.     anchor.Undraggable();
  12.     DragGestureRecognizer dragGesture = new() { CanDrag = true };
  13.     anchor.GestureRecognizers.Add(dragGesture);
  14.     dragGesture.DragStarting += (sender, args) =>
  15.     {
  16.         DragDropPayload dragPayload = payloadCreator(anchor, source);
  17.         _ = dragGesture.RegisterPayload(dragPayload);
  18.         args.Data.Text = guidTokens.GetOrCreateValue(dragGesture).Token;
  19.         anchor.Opacity = 0.5;
  20.     };
  21.     dragGesture.DropCompleted += (sender, args) =>
  22.     {
  23.         guidTokens.GetOrCreateValue(dragGesture).Token.RemovePayload();
  24.     };
  25. }
复制代码
2. 匹配数据,在 DragLeave 事件中处理
  1. dropGesture.DragOver += (sender, e) =>
  2. {
  3.     string token = e.Data.Text;
  4.     if (token.TryAssociatedPayload(out DragDropPayload? dragPayload) &&
  5.         guidTokens.TryGetValue(dropGesture, out GuidToken? dropToken) && dropToken is not null &&
  6.         dropToken.Token.TryAssociatedPayload(out DragDropPayload? dropPayload) &&
  7.         (dragPayload.SourceType & dropPayload.SourceType) != 0)
  8.     {
  9.         e.AcceptedOperation = DataPackageOperation.Copy;
  10.     }
  11.     else
  12.     {
  13.         e.AcceptedOperation = DataPackageOperation.None;
  14.     }
  15. };
  16. public static bool TryAssociatedPayload(this string token, [NotNullWhen(true)] out DragDropPayload? payload)
  17. {
  18.     payload = null;
  19.     if (!token.IsValidGuid())
  20.     {
  21.         return false;
  22.     }
  23.     if (tokenCache.TryGetValue(token, out var weakGuidToken) &&
  24.         weakGuidToken.TryGetTarget(out var guidToken) &&
  25.         dragDropPayloads.TryGetValue(guidToken, out payload))
  26.     {
  27.         return true;
  28.     }
  29.     _ = tokenCache.TryRemove(token, out _);        // 尝试清理缓存
  30.     return false;
  31. }
复制代码
注意上面尝试清理缓存,顺手做的。在DropCompleted事件处理中,此时数据传递使命完成,会专门移除缓存数据:
  1. dragGesture.DropCompleted += (sender, args) =>
  2. {
  3.     guidTokens.GetOrCreateValue(dragGesture).Token.RemovePayload();
  4. };
  5. public static void RemovePayload(this string token)
  6. {
  7.     if (!token.IsValidGuid() || !tokenCache.TryGetValue(token, out var weakToken))
  8.     {
  9.         return;
  10.     }
  11.     if (weakToken.TryGetTarget(out var guidToken))
  12.     {
  13.         _ = dragDropPayloads.Remove(guidToken);
  14.     }
  15.     _ = tokenCache.TryRemove(token, out _);
  16. }
复制代码
3. 发送数据组合,OnDroppablesMessageAsync
  1. public static void AsDroppable<TTargetAnchor, TTarget>(this TTargetAnchor anchor, DragDropPayload payload)
  2.     where TTargetAnchor : View
  3.     where TTarget : View
  4. {
  5.     anchor.Undroppable();
  6.     DropGestureRecognizer dropGesture = new() { AllowDrop = true };
  7.     anchor.GestureRecognizers.Add(dropGesture);
  8.     _ = dropGesture.RegisterPayload(payload);
  9.     // ... ...
  10.     dropGesture.Drop += async (s, e) =>
  11.     {
  12.         await OnDroppablesMessageAsync<TTargetAnchor>(anchor, dropGesture, e);
  13.        // ... ...
  14.     };
  15. }
  16. private static async Task OnDroppablesMessageAsync<TTargetAnchor>(TTargetAnchor anchor, DropGestureRecognizer dropGesture, DropEventArgs e)
  17. where TTargetAnchor : View
  18. {
  19.     string token = await e.Data.GetTextAsync();
  20.         // ... ...
  21.     _ = WeakReferenceMessenger.Default.Send<DragDropMessage>(new DragDropMessage()
  22.     {
  23.         SourcePayload = sourcePayload,
  24.         TargetPayload = targetPayload
  25.     });       
  26.      // ... ...
  27. }
复制代码
五、总结:从"数据传输"到"对象生命周期管理"

MAUI拖放功能的核心挑战不仅在于数据传递,更在于对象生命周期的安全管理。DataPackagePropertySetView通过标准化接口屏蔽了平台差异,而ConditionalWeakTable则解决了复杂对象传输的性能与内存问题。
本 DynamicGesturesExtension 改进方案已在实际项目中验证,源代码开源,按照 MIT 协议许可。地址:xiaql/Zhally.Toolkit: Dynamically attach draggable and droppable capability to controls of View in MAUI

来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!
您需要登录后才可以回帖 登录 | 立即注册