Maui 實踐:再論為控件動態(tài)擴展 DragDrop 能力
—— 不要把 DataPackagePropertySetView 看作一層皮
夏群林 原創(chuàng) 2025.7.18
一、Drag / Drop 之間傳遞的參數(shù)
前文提到,拖放的實現(xiàn)需要 DragGestureRecognizer 與 DropGestureRecognizer 在不同的控件上相互配合,數(shù)據(jù)傳輸和配置復(fù)雜。主要有三個事件參數(shù):DragStartingEventArgs,DragEventArgs 和 DropEventArgs。還有一個 DropCompletedEventArgs,不涉及實體數(shù)據(jù)傳遞,這里不討論。
Drag / Drop 操作,本質(zhì)上是把依存于源控件的數(shù)據(jù),與依存于目標(biāo)控件的數(shù)據(jù),挑選出來,組合,供業(yè)務(wù)流程調(diào)用。
DragStartingEventArgs 是數(shù)據(jù)的起點,它打包了 DataPackage 類型的 Data,以及源控件的位置數(shù)據(jù)。位置數(shù)據(jù)暫且不論,我們聚焦在業(yè)務(wù)層面的實體數(shù)據(jù)上。去掉枝枝葉葉,在 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,不神秘,就是一個在應(yīng)用程序中封裝和傳遞數(shù)據(jù)的容器,它的只讀屬性 Properties,一個鍵值對詞典,Dictionary<string, object> _propertyBag,是載體,用 DataPackagePropertySet 類包裝。object 類型的值,你可以在這里放任何你想傳遞的數(shù)據(jù)。所以,簡單,但強大。
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
{
// 這里是數(shù)據(jù)保持處
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 是數(shù)據(jù)中間站,當(dāng)拖動源控件經(jīng)停目標(biāo)控件時,平臺會比對兩個控件,是否有緣。屬于 Drag / Drop 對相同陣營的,
public class DragEventArgs : EventArgs
{
public DragEventArgs(DataPackage dataPackage)
{
Data = dataPackage;
}
public DataPackage Data { get; }
public DataPackageOperation AcceptedOperation { get; set; } = DataPackageOperation.Copy;
}
就會允許 DataPackage 接收下來打包轉(zhuǎn)發(fā)。 AcceptedOperation 決定要不要 Copy。事實上,枚舉類型 DataPackageOperation 只有兩個值:Copy / None 。同樣,我們這里忽略了位置數(shù)據(jù)的討論。
最后,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 的數(shù)據(jù)載體是 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,會發(fā)現(xiàn),除了核心的用戶數(shù)據(jù)字典外,還有一個字符串?dāng)?shù)據(jù),string Text,一個圖像數(shù)據(jù),ImageSource Image。拖放操作,在 DragStartingEventArgs 時準(zhǔn)備數(shù)據(jù)。最后在 DropEventArgs 處獲取數(shù)據(jù):
public Task<ImageSource> GetImageAsync()
{
return Task.FromResult(DataPackage.Image);
}
public Task<string> GetTextAsync()
{
return Task.FromResult(DataPackage.Text);
}
你可以把 Text / Image 看作常用數(shù)據(jù)快捷通道。我的實踐,就是利用這個快捷通道。
二、DataPackagePropertySetView 的核心價值:不止于包裝
我相信,初學(xué)者大多會有我當(dāng)初那樣的困惑:用 DataPackagePropertySetView 包裝 DataPackagePropertySet,是否多此一舉?既然底層實質(zhì)數(shù)據(jù)一樣,用同一個數(shù)據(jù)類型豈不方便?何必要加 DataPackagePropertySetView 這層皮?
原因是,Maui 為我們帶來跨平臺數(shù)據(jù)標(biāo)準(zhǔn)化便利的同時,也帶來了跨平臺數(shù)據(jù)傳遞打包解包的繁雜,以及額外開銷。
由于 Maui 控件的實現(xiàn),最終會轉(zhuǎn)化成應(yīng)用所在平臺的本機實現(xiàn),Drag / Drop 操作所攜帶的數(shù)據(jù),會一層層轉(zhuǎn)換為本機要求的結(jié)構(gòu),再一層層轉(zhuǎn)換回 Maui。這里的事情,不簡單。
在 MAUI 拖放機制中,DataPackagePropertySetView 絕非簡單的字典包裝,而是跨平臺數(shù)據(jù)傳輸?shù)暮诵臉屑~,其設(shè)計蘊含三大關(guān)鍵價值:
-
跨平臺數(shù)據(jù)標(biāo)準(zhǔn)化。MAUI需要將數(shù)據(jù)轉(zhuǎn)換為不同平臺的原生格式(如Android的ClipData、iOS的NSItemProvider),而
DataPackagePropertySetView通過標(biāo)準(zhǔn)化屬性(如Title、Description、Keywords)屏蔽了底層差異。 -
類型安全與延遲加載。強類型訪問,避免通過字符串鍵強制轉(zhuǎn)換類型的風(fēng)險(如
(string)properties["Title"]);僅在調(diào)用GetTextAsync()等方法時才實際傳輸數(shù)據(jù),延遲加載,減少無效開銷。 -
安全隔離機制。作為只讀視圖,
DataPackagePropertySetView防止拖放目標(biāo)意外修改源數(shù)據(jù),同時通過平臺適配器確保數(shù)據(jù)傳輸?shù)陌踩裕ㄈ缈邕M(jìn)程場景的序列化/反序列化)。
不需要更細(xì)節(jié)的理解,但是我做了決定,能繞開依賴本機數(shù)據(jù)轉(zhuǎn)換而傳遞數(shù)據(jù)的,最好在 Maui 層面直接處理。這也是當(dāng)初我開發(fā) AsDroppable/ AsDraggable 擴展方法驅(qū)動力之一,但還不夠徹底。 (參閱: Maui 實踐:為控件動態(tài)擴展 DragDrop 能力 )。
自定義數(shù)據(jù)傳遞,通常我們會建立全局緩存管理器,在拖放源緩存對象并傳遞 ID,然后在拖放目標(biāo)通過 ID 獲取對象。因為數(shù)據(jù)產(chǎn)生于拖放源,如果拖放源不再被引用,其所產(chǎn)生的數(shù)據(jù)也應(yīng)該銷毀,否則會造成內(nèi)存泄漏。
問題在于,我們使用 AsDraggable 方法為拖放源配置拖放數(shù)據(jù)時,不知道該拖放源在程序邏輯中,是否要釋放,何時會釋放。于是想到用弱引用管理器避免內(nèi)存泄漏。
ConditionalWeakTable<TKey, TValue> 是 .NET 框架中的一個特殊集合類,在兩個對象之間建立弱關(guān)聯(lián)關(guān)系,同時確保不會阻止垃圾回收(GC)對這些對象的回收。當(dāng) TKey 類型的對象(鍵)被垃圾回收時,對應(yīng)的 TValue 類型的值也會被自動從表中移除,不會因為鍵值對的存在而延長對象的生命周期。這樣,我們可以緩存與特定拖 / 放源關(guān)聯(lián)的數(shù)據(jù),同時不影響這些對象的垃圾回收。完美。
不過,針對我的應(yīng)用情形,完美之中有瑕疵。我們的全局緩存管理器在拖放源緩存對象并傳遞 ID,這個 ID,我直接選用 Guid 類型,可以唯一性區(qū)別無限個數(shù)據(jù),簡潔。但 Guid 是值類型,不符合 ConditionalWeakTable<TKey, TValue> 對 TKey 為引用類型的要求。
我設(shè)計了一個包裝類,把 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 = [];
這里,拖 / 放源為鍵關(guān)聯(lián) GuidToken 值,再以 GuidToken 為鍵關(guān)聯(lián)數(shù)據(jù) DragDropPayload。這樣,我們就實現(xiàn)了在拖放過程中,當(dāng)平臺與本機做著復(fù)雜的交互時,只需傳遞簡單的 guid 字符串,還保證不會內(nèi)存泄漏。
我們還要做點額外的工作:為 GuidToken 建立一個生命期可控的強引用:
private static readonly ConcurrentDictionary<string, WeakReference<GuidToken>> tokenCache = new();
否則,GuidToken 不知何時會被 GC,其代表的數(shù)據(jù)亦不知何時被 GC。手動控制 GuidToken 生命期的方式,結(jié)合在 AsDraggable / AsDroppable 擴展方法中,后面一并講到。
三、進(jìn)階:DynamicGesturesExtension 改進(jìn)
根據(jù)前面的討論,我對自己先前開發(fā)的 AsDraggable / AsDroppable 擴展方法予以改進(jìn)。AsDraggable / AsDroppable 是通用方法,本想通過泛型的方式,源控件類型/目標(biāo)控件類型的組合,來區(qū)分應(yīng)該采取的拖放后續(xù)操作。為此,還回避不了頭疼的反射技術(shù)。這次我順手把它去掉了,區(qū)分拖放后續(xù)操作,只需要通過拖/放控件關(guān)聯(lián)的數(shù)據(jù)類型 DragDropPayload 的組合,即可確認(rèn)。我也簡化了 DragDropPayload 數(shù)據(jù)結(jié)構(gòu),消除協(xié)變和逆變的顧忌。
public class DragDropPayload
{
public required View View { get; init; } // 拖放源/目標(biāo)控件
public object? Affix { get; init; } // 任意附加數(shù)據(jù)(如文本、對象)
public Action<View, object?>? Callback { get; init; } // 拖放完成后的回調(diào)
public View? Anchor { get; set; } = null; // 拖放源/目標(biāo)控件的 recognizer 依附 View 組件
public SourceTypeEnum SourceType { get; set; } // 標(biāo)識。源/目標(biāo)之間標(biāo)識有交集者才能交互
}
1. 注冊數(shù)據(jù)
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;
}
注冊數(shù)據(jù)在指定源控件 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); // 覆蓋現(xiàn)有 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. 匹配數(shù)據(jù),在 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事件處理中,此時數(shù)據(jù)傳遞使命完成,會專門移除緩存數(shù)據(jù):
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. 發(fā)送數(shù)據(jù)組合,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拖放功能的核心挑戰(zhàn)不僅在于數(shù)據(jù)傳遞,更在于對象生命周期的安全管理。DataPackagePropertySetView通過標(biāo)準(zhǔn)化接口屏蔽了平臺差異,要謹(jǐn)慎對待其背后的復(fù)雜,而借助ConditionalWeakTable的個性化設(shè)計,則解決了復(fù)雜對象傳輸?shù)男阅芘c內(nèi)存問題。
本 DynamicGesturesExtension 改進(jìn)方案已在實際項目中驗證,源代碼開源,按照 MIT 協(xié)議許可。地址:地址 xiaql/Zhally.Toolkit: Practices on specific tool kit in MAUI

浙公網(wǎng)安備 33010602011771號