原文: 【UWP】使用 Rx 改善 AutoSuggestBox 在 UWP 中,有一個控件叫 AutoSuggestBox,它的主要成分是一個 TextBox 和 ComboBox。使用它,我們可以做一些根據使用者輸入來顯示相關建議輸入的功能,例如百度首頁搜尋框那種效果:
,先對 AutoSuggestBox 有一個大體的印象,不然下面幹什麼都不知道了。
接下來開始我們的實驗,先準備好百度的接口(這個可以用浏覽器的開發者工具抓出來):
public class BaiduService
{
static BaiduService()
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
}
public async Task<IReadOnlyList<string>> GetSuggestionsAsync(string query)
{
using (var client = new HttpClient())
{
var url = $"http://www.baidu.com/su?wd={HttpUtility.UrlEncode(query)}";
var str = await client.GetStringAsync(url);
str = str.Substring(str.IndexOf('{'));
str = str.Substring(0, str.LastIndexOf('}') + 1);
var jObject = JObject.Parse(str);
return jObject["s"].ToObject<string[]>();
}
}
}
需要引用一下
Newtonsoft.Json這個包。
靜态構造函數裡我注冊了一下本機的 Encoding,不然會報錯(百度這厮用的是 gbk,而不是常見的 utf-8)。
然後開始編寫 Demo 頁面
XAML
<Grid>
<Grid Margin="20">
<StackPanel Orientation="Vertical">
<AutoSuggestBox x:Name="AutoSuggestBox"
TextChanged="AutoSuggestBox_TextChanged" />
</StackPanel>
</Grid>
</Grid>
這裡随便寫了下,反正就是弄了個 AutoSuggestBox,訂閱了一下它的 TextChanged 事件。
cs代碼:
private async void AutoSuggestBox_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args)
{
switch (args.Reason)
{
case AutoSuggestionBoxTextChangeReason.ProgrammaticChange:
case AutoSuggestionBoxTextChangeReason.SuggestionChosen:
sender.ItemsSource = null;
return;
}
// User input
var query = sender.Text;
Debug.WriteLine("get suggestion: " + query);
var suggestions = await _baiduService.GetSuggestionsAsync(query);
sender.ItemsSource = suggestions;
}
觸發的事件參數中有個 Reason 屬性,表面該次事件觸發的原因。
在這裡我如果是程式代碼修改或者使用者選擇了建議項的話,那麼就清除建議項清單。否則就去問百度要一下建議(順便輸出一下,說明觸發了)。
然後就把我們的 Demo 程式跑起來吧。
看上去工作得還是蠻正常的嘛。
但是,在這裡我要告訴你,這樣寫,是有一些坑的!
1、
全選,複制,再粘貼,我們的文字内容是沒有變化才對的,然而也觸發了一次請求。
2、
如果我的内容為空,那麼就不應該請求才對的。
3、
在上面的圖中,我 UWP 這三個字母的輸入速度應該是比較快的,那麼 U 那一次就不應該去請求才對。應該以停止輸入一段時間後,才去進行請求。AutoSuggestBox 控件應該是做了(不然在 UW 時也應該會觸發才對),但目測時間非常短(可能就 0.1 秒),而且也沒有相關的屬性能夠控制這個時長。
4、
因為這個請求是一個異步的網絡請求,是以說不好的話,後發起的請求有可能先傳回。按上面的代碼邏輯來說,這樣輸入和建議項就對不上了。
按傳統思路,第 1 點我們可以在請求前加個判斷,如果跟上一次相同就不請求。第 2 點加個空字元串判斷即可。第 3 點就麻煩了,真要實作我們得加個計時之類的方法來做。第 4 點也是很麻煩,我目前想到的是發起請求時給個 token 之類,接收到的時候再對比是否是最新的 token。
但說實話,這麼一整套下來,不麻煩麼?而且代碼量不是一點兩點。
在這裡,我要安利各位,隻要你使用 Rx,解決這點小問題完全不在話下。
Rx 的全稱是 Reactive Extensions,是一種針對異步程式設計的程式設計模型。Rx 不僅僅在 .Net 下有實作,在 JavaScript、Java 等等平台都有相關的實作。
概念說完了,繼續實驗。
引用 Rx 的 nuget 包,
System.Reactive。
在頁面的構造函數先編寫如下的代碼:
var changed =
Observable.FromEventPattern<TypedEventHandler<AutoSuggestBox, AutoSuggestBoxTextChangedEventArgs>, AutoSuggestBox, AutoSuggestBoxTextChangedEventArgs>(
handler => AutoSuggestBox.TextChanged += handler,
handler => AutoSuggestBox.TextChanged -= handler);
這段代碼以 AutoSuggestBox 的 TextChanged 事件建立一個可監聽的資料源 changed 對象。
接下來,我們處理第 1 點,需要忽略掉相同的文本内容。
var input = changed
.DistinctUntilChanged(temp => temp.Sender.Text);
DistinctUntilChanged 這個擴充方法是 Rx 提供的,如果資料源内容不變,則不會觸發。
然後我們處理第 3 點,隻有停止輸入一段時間後,我們再去發起請求。
var input = changed
.DistinctUntilChanged(temp => temp.Sender.Text)
.Throttle(TimeSpan.FromSeconds(1));
這個也很簡單,Rx 提供了 Throttle 方法,傳入需要的時間就可以了,這裡我設定成停止輸入 1 秒後才觸發。
然後接下來我們要區分兩種情況,一個是使用者輸入的,另一個是非使用者輸入的。
var notUserInput = input
.ObserveOnDispatcher()
.Where(temp => temp.EventArgs.Reason != AutoSuggestionBoxTextChangeReason.UserInput);
var userInput = input
.ObserveOnDispatcher()
.Where(temp => temp.EventArgs.Reason == AutoSuggestionBoxTextChangeReason.UserInput)
.Where(temp => !string.IsNullOrEmpty(temp.Sender.Text));
在使用者輸入的時候,輸入後文本框非空我們才觸發(第 2 點)。
這裡注意到還有 ObserveOnDispatcher 這個方法的調用,這個調用就是說,接下來我的操作需要在目前線程上進行。Rx 預設是會在另一個線程上的,在 Where 方法中我們引用到了 AutoSuggestBox 控件,是以需要調用到該方法。
接下來我們處理一下 userInput,有了輸入,我們自然需要輸出,輸出就是建議項:
var userInput = input
.ObserveOnDispatcher()
.Where(temp => temp.EventArgs.Reason == AutoSuggestionBoxTextChangeReason.UserInput)
.Where(temp => !string.IsNullOrEmpty(temp.Sender.Text))
.Select(temp => _baiduService.GetSuggestionsAsync(temp.Sender.Text));
調用百度接口,傳回 Task<IReadOnlyList<string>>。同時,我們對 notUserInput 也處理一下,傳回 null,但類型也是 Task<IReadOnlyList<string>>。
var notUserInput = input
.ObserveOnDispatcher()
.Where(temp => temp.EventArgs.Reason != AutoSuggestionBoxTextChangeReason.UserInput)
.Select(temp => Task.FromResult<IReadOnlyList<string>>(null));
現在,我們把這兩個重新合成為一個,因為我們資料源觸發的條件是 TextChanged,而不是因為上面這一大堆東西才進行觸發。
var merge = Observable
.Merge(notUserInput, userInput);
最後,我們可以監聽這個資料源了,調用 Subscribe 方法(當然還要再 ObserveOnDispatcher 一次):
merge
.ObserveOnDispatcher()
.Subscribe(suggestions =>
{
AutoSuggestBox.ItemsSource = suggestions;
});
這樣更新上去我們的 AutoSuggestBox 就行了。
慢着,我們的第 4 點還沒處理呢。這個隻需要稍微修改一下就可以了(Rx 真友善)。
var merge = Observable
.Merge(notUserInput, userInput)
.Switch();
Switch 方法會将輸出的順序按照輸入的順序來排序,這樣之後,我們的第 4 點就能解決掉了。
最終下來,我們解決這麼一系列問題隻是寫了這麼點的代碼,如果按傳統的寫法嘛,那不知道寫到什麼時候去了。Rx 萬歲!
雖然 Rx 學習起來難度曲線非常大,但是在解決某些場景,Rx 是非常的有效的。(順帶一提,Angular 就內建了 RxJS,可見 Rx 存在其優勢)
參考資料:
DevCamp 2010 Keynote - Rx: Curing your asynchronous programming blues