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

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

夏群林 原创 2025.7.18

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

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

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

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

/* by 01022.hk - online tools website : 01022.hk/zh/calcdata.html */
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 类型的值,你可以在这里放任何你想传递的数据。所以,简单,但强大。

/* by 01022.hk - online tools website : 01022.hk/zh/calcdata.html */
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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值