2017年10月28日 星期六

C# HttpClient WebAPI : 15. 呼叫 Web API 的處理進度事件回報與強制取消用法

通常,若我們需要從 Web API 取得大量的資料到用戶端的時候,或者因為網路品質不好,導致資料回傳時間過長,我們希望提供當時取得網路資源的進度資訊給使用者知道,在這個練習中,我們將會嘗試從網路上下載一個圖片檔案,並且顯示這個圖片的完整取得進度百分比。
另外,我們也提供了一項功能,那就是當使用者在取得資料的過程中,使用者可以決定是否要終止此次的網路存取行為,為了要能夠讓使用者可以操作這樣效果,所以,我們在用戶端這裡,會適時地暫停一小段時間,讓使用者可以決定是否有取消這次存取行為。

了解更多關於 [HttpClient Class] 的使用方式
了解更多關於 [使用 async 和 await 進行非同步程式設計] 的使用方式


呼叫 Web API 的處理進度事件回報與強制取消用法

這個 ReportProgress 方法,將是進行 HttpClient 讀取資料的時候,要回報進度的委派方法,您也可以視為他是的 Call Back 方法。
在 DownloadImageAsync 方法中,我們需要三個參數,第一個是下載圖片的檔案名稱,第二個是要回報進度的 IProgress,第三個則是用來取消 HttpClient 動作的 CancellationToken 類別物件。
在我們進行取得圖片的資源的時候,使用這個 await client.GetAsync(fooFullUrl, HttpCompletionOption.ResponseHeadersRead); 敘述,其中, HttpCompletionOption.ResponseHeadersRead列舉值,指示 HttpClient 應該儘快的回傳任何回應內容,而不是要把所有的內容都放置到緩衝區內,才結束這個方法執行。透過這個列舉值的使用,我們可以提早得到這個 Http 回應的 Header,並且可以知道檔案的全部大小。
在我們執行相關 Http 請求動作並且得到 HttpResponseMessage 物件之後,接著判斷此次的請求動作的狀態碼是成功的,我們就可以透過 var total = response.Content.Headers.ContentLength.HasValue ? response.Content.Headers.ContentLength.Value : -1L; 取得這個圖片檔案的全部檔案大小數值。
在進行將 Http 回傳的圖片內容,將其寫入到本機檔案中,我們使用了 await response.Content.ReadAsStreamAsync() 表示式,每次讀取一定大小的資料(我們這裡,每次讀取 4096 Bytes),接著寫入到檔案,然後反覆這樣的動作,直到所有的內容都成功寫入到檔案內。當每個小區塊資料寫入到檔案之後,我們就會執行傳入參數的IProgress物件,在這裡使用 progress.Report((totalRead * 1d) / (total * 1d) * 100); 表示式來執行,這樣,我們在呼叫這個方法的地方,就可以反映出現在下載完成百分比進度。
另外,在這個反覆讀取網路資源小區塊資料,且入到檔案的反覆過程中,我們看到了一個表示式 token.ThrowIfCancellationRequested();,當這個 token 物件所歸屬的 CancellationTokenSource 類別物件,執行了 cts.Cancel(); 方法,那麼,當執行 token.ThrowIfCancellationRequested(); 的時候,就會產生例外異常,當然,也就取消了這次的 Web API 呼叫。
因為我們下載的圖片檔案沒有很大,因此,每讀、寫一次 Stream 區塊,我們就執行 await Task.Delay(200); 敘述,暫停個 0.2 秒鐘。
private static void ReportProgress(double obj)
{
    Console.WriteLine($"下載完成進度 {obj}");
}

private static async Task<APIResult> DownloadImageAsync(string filename,
    IProgress<double> progress, CancellationToken token)
{
    string ImgFilePath = $"My_{filename}";
    ImgFilePath = Path.Combine(Environment.CurrentDirectory, ImgFilePath);
    APIResult fooAPIResult;
    using (HttpClientHandler handler = new HttpClientHandler())
    {
        using (HttpClient client = new HttpClient(handler))
        {
            try
            {
                #region 呼叫遠端 Web API
                string FooUrl = $"http://vulcanwebapi.azurewebsites.net/Datas/";
                HttpResponseMessage response = null;

                #region  設定相關網址內容
                var fooFullUrl = $"{FooUrl}{filename}";

                response = await client.GetAsync(fooFullUrl, 
                    HttpCompletionOption.ResponseHeadersRead);
                #endregion
                #endregion

                #region 處理呼叫完成 Web API 之後的回報結果
                if (response != null)
                {
                    if (response.IsSuccessStatusCode == true)
                    {
                        #region 狀態碼為 OK
                        var total = response.Content.Headers.ContentLength.HasValue ? response.Content.Headers.ContentLength.Value : -1L;
                        var canReportProgress = total != -1 && progress != null;
                        using (var filestream = File.Open(ImgFilePath, FileMode.OpenOrCreate, FileAccess.ReadWrite))
                        {
                            using (var stream = await response.Content.ReadAsStreamAsync())
                            {
                                var totalRead = 0L;
                                var buffer = new byte[4096];
                                var isMoreToRead = true;

                                do
                                {
                                    token.ThrowIfCancellationRequested();

                                    var read = await stream.ReadAsync(buffer, 0, buffer.Length);

                                    if (read == 0)
                                    {
                                        isMoreToRead = false;
                                    }
                                    else
                                    {
                                        await filestream.WriteAsync(buffer, 0, read);

                                        totalRead += read;

                                        if (canReportProgress)
                                        {
                                            progress.Report((totalRead * 1d) / (total * 1d) * 100);
                                        }
                                    }
                                    // 故意暫停,讓使用者可以取消下載
                                    await Task.Delay(200);
                                } while (isMoreToRead);
                            }
                        }
                        fooAPIResult = new APIResult
                        {
                            Success = true,
                            Message = string.Format("Error Code:{0}, Error Message:{1}", response.StatusCode, response.Content),
                            Payload = ImgFilePath,
                        };
                        #endregion
                    }
                    else
                    {
                        fooAPIResult = new APIResult
                        {
                            Success = false,
                            Message = string.Format("Error Code:{0}, Error Message:{1}", response.StatusCode, response.RequestMessage),
                            Payload = null,
                        };
                    }
                }
                else
                {
                    fooAPIResult = new APIResult
                    {
                        Success = false,
                        Message = "應用程式呼叫 API 發生異常",
                        Payload = null,
                    };
                }
                #endregion
            }
            catch (Exception ex)
            {
                fooAPIResult = new APIResult
                {
                    Success = false,
                    Message = ex.Message,
                    Payload = ex,
                };
            }
        }
    }

    return fooAPIResult;
}

觸發的 Web API 動作

這個範例中,將會指向 URL http://vulcanwebapi.azurewebsites.net/Datas/vulcan.png,此時,將會向 Web API 伺服器要求回傳該圖片回到用戶端上。

進行測試

在程式進入點函式,我們建立一個 CancellationTokenSource 型別的物件,接著,取得 CancellationTokenSource.Token,這個是屬於 CancellationToken 的物件,我們需要將這個 CancellationToken 物件傳送到下載圖片的 DownloadImageAsync 方法內。
此時,我們另外啟動一個新的執行緒,這個執行緒並不做任何事情,只是等候使用者按下任一按鍵,若使用者按下了 C 這個按鍵,則 cts.Cancel(); 將會執行,而當時的 HttpClient 抓取圖片動作,將會取消。
static async Task Main(string[] args)
{
    CancellationTokenSource cts = new CancellationTokenSource();
    CancellationToken token = cts.Token;

    Thread t1 = new Thread(() =>
    {
        if (Console.ReadKey(true).KeyChar.ToString().ToUpperInvariant() == "C")
            cts.Cancel();
    });

    t1.Start();

    var progressIndicator = new Progress<double>(ReportProgress);

    var fooResult = await DownloadImageAsync("vulcan.png", progressIndicator, token);
    if (fooResult.Success == true)
    {
        Process myProcess = new Process();
        try
        {
            // true is the default, but it is important not to set it to false
            myProcess.StartInfo.UseShellExecute = true;
            myProcess.StartInfo.FileName = fooResult.Payload.ToString();
            myProcess.Start();
        }
        catch (Exception e)
        {
            Console.WriteLine(e.Message);
        }
        Console.WriteLine($"Press any key to Exist...{Environment.NewLine}");
        Console.ReadKey();
    }
    else
    {
        Console.WriteLine($"使用者中斷下載作業 {fooResult.Message} {Environment.NewLine}"); 
        Console.WriteLine($"Press any key to Exist...{Environment.NewLine}");
        Console.ReadKey();
    }
}

執行結果

這個測試將會輸出底下內容
下載完成進度 5.10764588152406
下載完成進度 7.6614688222861
下載完成進度 10.2152917630481
下載完成進度 12.7691147038102
下載完成進度 15.3229376445722
下載完成進度 17.8767605853342
下載完成進度 20.4305835260963
下載完成進度 22.9844064668583
下載完成進度 25.5382294076203
下載完成進度 28.0920523483824
下載完成進度 30.6458752891444
下載完成進度 33.1996982299064
下載完成進度 35.7535211706684
下載完成進度 38.3073441114305
下載完成進度 40.8611670521925
下載完成進度 43.4149899929545
下載完成進度 45.9688129337166
下載完成進度 48.5226358744786
下載完成進度 51.0764588152406
下載完成進度 53.6302817560027
下載完成進度 56.1841046967647
下載完成進度 58.7379276375267
下載完成進度 61.2917505782888
下載完成進度 63.8455735190508
下載完成進度 66.3993964598128
下載完成進度 68.9532194005749
下載完成進度 71.5070423413369
下載完成進度 74.0608652820989
下載完成進度 76.614688222861
下載完成進度 79.168511163623
下載完成進度 81.722334104385
下載完成進度 84.276157045147
下載完成進度 86.8299799859091
下載完成進度 89.3838029266711
下載完成進度 91.9376258674332
下載完成進度 94.4914488081952
下載完成進度 97.0452717489572
下載完成進度 99.5990946897192
下載完成進度 100
Press any key to Exist...

HTTP 傳送與接收原始封包

讓我們來看看,這個 Web API 的呼叫動作中,在請求 (Request) 與 反應 (Response) 這兩個階段,會在網路上傳送了那些 HTTP 資料
  • 請求 (Request)
GET http://vulcanwebapi.azurewebsites.net/Datas/vulcan.png HTTP/1.1
Host: vulcanwebapi.azurewebsites.net
Connection: Keep-Alive
  • 反應 (Response)
HTTP/1.1 200 OK
Content-Length: 160387
Content-Type: image/png
Last-Modified: Sun, 08 Oct 2017 16:09:22 GMT
Accept-Ranges: bytes
ETag: "1d3404fcd99d783"
Server: Kestrel
X-Powered-By: ASP.NET
Set-Cookie: ARRAffinity=9d3635139ab6649f453417d1e9047b7ed7a79b7bef031b04afeb6a2c58b33d4e;Path=/;HttpOnly;Domain=vulcanwebapi.azurewebsites.net
Date: Mon, 23 Oct 2017 02:22:35 GMT

 PNG
 

IHDR              u         pHYs          +      tIME         8ǫe     tEXtAuthor    H    tEXtDescription      !#   
tEXtCopyr

*** FIDDLER: RawDisplay truncated at 128 characters. Right-click to disable truncation. ***

相關文章索引

C# HttpClient WebAPI 系列文章索引

了解更多關於 [HttpClient Class] 的使用方式
了解更多關於 [使用 async 和 await 進行非同步程式設計] 的使用方式



關於 Xamarin 在台灣的學習技術資源

Xamarin 實驗室 粉絲團
歡迎加入 Xamarin 實驗室 粉絲團,在這裡,將會經常性的貼出各種關於 Xamarin / Visual Studio / .NET 的相關消息、文章、技術開發等文件,讓您可以隨時掌握第一手的 Xamarin 方面消息。
Xamarin.Forms @ Taiwan
歡迎加入 Xamarin.Forms @ Taiwan,這是台灣的 Xamarin User Group,若您有任何關於 Xamarin / Visual Studio / .NET 上的問題,都可以在這裡來與各方高手來進行討論、交流。
Xamarin 實驗室 部落格
Xamarin 實驗室 部落格 是作者本身的部落格,這個部落格將會專注於 Xamarin 之跨平台 (Android / iOS / UWP) 方面的各類開技術探討、研究與分享的文章,最重要的是,它是全繁體中文。
Xamarin.Forms 系列課程
Xamarin.Forms 系列課程 想要快速進入到 Xamarin.Forms 的開發領域,學會各種 Xamarin.Forms 跨平台開發技術,例如:MVVM、Prism、Data Binding、各種 頁面 Page / 版面配置 Layout / 控制項 Control 的用法等等,千萬不要錯過這些 Xamarin.Forms 課程


2017年10月27日 星期五

C# HttpClient WebAPI : 14. 呼叫 Web API 的逾時時間限制用法

當我們在進行電腦資源存取的時候,例如:檔案、資料庫、網路、記憶體、系統資源等等,需要特別小心操作,最保險的方式就是將這些存取資源的敘述,使用 try 將這些敘述包含在內,這樣,當有任何例外異常發生的時候,就可以捕捉到這些例外異常問題。
可是,有個問題是比較棘手的,那就是發生逾期的問題,在我們進行 HttpClient 的相關請求操作的時候,也許是因為網路品質、斷線、伺服器的效能或者後端程式碼撰寫品質不佳影響,會造成用戶端長時間無法得到結果。
若我們使用 HttpClient 的時候,若沒有特別指定 HttpClient.Timeout 屬性值, 他的預設逾期時間.aspx)為 100 秒,這樣的時間,對於一般的程式應用似乎有點過長,因此,在這篇文章中,我們將來學習 HttpClient 逾期的程式碼設計方式。

了解更多關於 [HttpClient Class] 的使用方式
了解更多關於 [使用 async 和 await 進行非同步程式設計] 的使用方式


呼叫 Web API 的逾時時間限制用法

在我們所設計的逾期 Web API,只要您呼叫這個 Web API,他將會延遲5秒鐘之後,才會將結果回傳到用戶端;因此,在 JsonPutAsync 方法中,我們將會接受到一個秒數參數,讓我們可以設定 HttpClient 的逾期時間。我們使用這樣的敘述 client.Timeout = TimeSpan.FromSeconds(sec); 來設定這個 HttpClient 各種操作的最大逾期時間。
若進行 HttpClient 的網路存取操作的時候,若得到回傳結果的時間,大於這裡設定的逾期時間,這個時候 HttpClient 物件將會丟出例外異常,底下是這時間逾期的呼叫堆疊範例。
   於 System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
   於 System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
   於 System.Net.Http.HttpClient.<FinishSendAsyncBuffered>d__58.MoveNext()
--- 先前擲回例外狀況之位置中的堆疊追蹤結尾 ---
   於 System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
   於 System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
   於 System.Runtime.CompilerServices.TaskAwaiter`1.GetResult()
   於 LongTimeAccess.Program.<JsonPutAsync>d__1.MoveNext() 於 D:\Vulcan\GitHub\CSharpNotes\WebAPI\LongTimeAccess\Program.cs: 行 77
而這個時間逾期的例外異常,其 Message 屬性將會得到 工作已取消。 的字串內容。
若您有參考這份 HttpClient 使用文章中的寫法,您將會發現到,我們有將 HttpClient 的所有存取動作,都使用 Try 包裹起來,也就是說,我們可以捕捉到任何 HttpClient 所發出的例外異常。
private static async Task<APIResult> JsonPutAsync(int sec = 4)
{
    APIResult fooAPIResult;
    using (HttpClientHandler handler = new HttpClientHandler())
    {
        using (HttpClient client = new HttpClient(handler))
        {
            try
            {
                client.Timeout = TimeSpan.FromSeconds(sec);
                #region 呼叫遠端 Web API
                string FooUrl = $"http://vulcanwebapi.azurewebsites.net/api/values";
                HttpResponseMessage response = null;

                #region  設定相關網址內容
                var fooFullUrl = $"{FooUrl}/LongTimeGet";

                // Accept 用於宣告客戶端要求服務端回應的文件型態 (底下兩種方法皆可任選其一來使用)
                //client.DefaultRequestHeaders.Accept.TryParseAdd("application/json");
                client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

                // Content-Type 用於宣告遞送給對方的文件型態
                //client.DefaultRequestHeaders.TryAddWithoutValidation("Content-Type", "application/json");

                response = await client.GetAsync(fooFullUrl);
                #endregion
                #endregion

                #region 處理呼叫完成 Web API 之後的回報結果
                if (response != null)
                {
                    if (response.IsSuccessStatusCode == true)
                    {
                        // 取得呼叫完成 API 後的回報內容
                        String strResult = await response.Content.ReadAsStringAsync();
                        fooAPIResult = JsonConvert.DeserializeObject<APIResult>(strResult, new JsonSerializerSettings { MetadataPropertyHandling = MetadataPropertyHandling.Ignore });
                    }
                    else
                    {
                        fooAPIResult = new APIResult
                        {
                            Success = false,
                            Message = string.Format("Error Code:{0}, Error Message:{1}", response.StatusCode, response.RequestMessage),
                            Payload = null,
                        };
                    }
                }
                else
                {
                    fooAPIResult = new APIResult
                    {
                        Success = false,
                        Message = "應用程式呼叫 API 發生異常",
                        Payload = null,
                    };
                }
                #endregion
            }
            catch (Exception ex)
            {
                fooAPIResult = new APIResult
                {
                    Success = false,
                    Message = ex.Message,
                    Payload = ex,
                };
            }
        }
    }

    return fooAPIResult;
}

觸發的 Web API 動作

這個範例中,將會指向 URL http://vulcanwebapi.azurewebsites.net/api/values/LongTimeGet ,此時,將會觸發 Web API 伺服器上的 Values 控制器(Controller)的 public async Task<APIResult> LongTimeGet()動作(Action),其該動作的原始碼如下所示。
這個 Web API 動作,將會回傳一個 APIData 的 JSON 資料。
[HttpGet("LongTimeGet")]
public async Task<APIResult> LongTimeGet()
{
    APIResult foo;

    await Task.Delay(5000);
    foo = new APIResult()
    {
        Success = true,
        Message = "透過 Get 方法",
        Payload = new APIData()
        {
            Id = 777,
            Name = "Vulcan01"
        }
    };
    return foo;
}

進行測試

在程式進入點函式,我們先設定要使用 4 秒的逾期時間設定(伺服器端所指定的 Web API 動作,需要花費至少 5 秒的時間,才會回傳到用戶端上),因此,我們將會得到這樣失敗的結果;接著,我們將用戶端的逾期時間設定為 6 秒鐘,結果是可以順利得到遠端 Web API 的回傳結果。。
static void Main(string[] args)
{
    Console.WriteLine("遠端 Web API 需要花費 5 秒鐘,才會回傳結果內容");
    Console.WriteLine($"使用 Get 方法呼叫 Web API ,並且會逾期的結果 ( HttpClient 限時 4 秒內要完成 )");
    var foo = JsonPutAsync(4).Result;
    Console.WriteLine($"結果狀態 : {foo.Success}");
    Console.WriteLine($"結果訊息 : {foo.Message}");
    if (foo.Success == true)
    {
        var item = JsonConvert.DeserializeObject<APIData>(foo.Payload.ToString());
        Console.WriteLine($"Id : {item.Id}");
        Console.WriteLine($"Name : {item.Name}");
        Console.WriteLine($"Filename : {item.Filename}");
    }
    Console.WriteLine($"");

    Console.WriteLine($"Press any key to Continue...{Environment.NewLine}");
    Console.ReadKey();

    Console.WriteLine($"使用 Get 方法呼叫 Web API ,並且不會逾期的結果 ( HttpClient 限時 6 秒內要完成 )");
    foo = JsonPutAsync(6).Result;
    Console.WriteLine($"結果狀態 : {foo.Success}");
    Console.WriteLine($"結果訊息 : {foo.Message}");
    if (foo.Success == true)
    {
        var item = JsonConvert.DeserializeObject<APIData>(foo.Payload.ToString());
        Console.WriteLine($"Id : {item.Id}");
        Console.WriteLine($"Name : {item.Name}");
        Console.WriteLine($"Filename : {item.Filename}");
    }
    Console.WriteLine($"");

    Console.WriteLine($"Press any key to Exist...{Environment.NewLine}");
    Console.ReadKey();

}

執行結果

這個測試將會輸出底下內容
遠端 Web API 需要花費 5 秒鐘,才會回傳結果內容
使用 Get 方法呼叫 Web API ,並且會逾期的結果 ( HttpClient 限時 4 秒內要完成 )
結果狀態 : False
結果訊息 : 工作已取消。

Press any key to Continue...

使用 Get 方法呼叫 Web API ,並且不會逾期的結果 ( HttpClient 限時 6 秒內要完成 )
結果狀態 : True
結果訊息 : 透過 Get 方法
Id : 777
Name : Vulcan01
Filename :

Press any key to Exist...

HTTP 傳送與接收原始封包

讓我們來看看,這個 Web API 的呼叫動作中,在請求 (Request) 與 反應 (Response) 這兩個階段,會在網路上傳送了那些 HTTP 資料
  • 請求 (Request)
    首先,我們發出一個 GET 請求,底下是一般的 Http GET 請求的封包內容。
GET http://vulcanwebapi.azurewebsites.net/api/values/LongTimeGet HTTP/1.1
Accept: application/json
Host: vulcanwebapi.azurewebsites.net
Connection: Keep-Alive
  • 反應 (Response)
    底下是使用 Fiddler 側錄下 Http 封包的內容,雖然 C# 用戶端因為逾期時間為 4 秒,沒有在 5 秒後收到回傳結果,而在 C# 用戶端產生了例外異常,不過,伺服器還是會在 5 秒後將結果回傳回來(此時,C# 用戶端程式已經終止執行了),不過, Fiddler 還是有側錄下來。
HTTP/1.1 200 OK
Transfer-Encoding: chunked
Content-Type: application/json; charset=utf-8
Server: Kestrel
X-Powered-By: ASP.NET
Set-Cookie: ARRAffinity=9d3635139ab6649f453417d1e9047b7ed7a79b7bef031b04afeb6a2c58b33d4e;Path=/;HttpOnly;Domain=vulcanwebapi.azurewebsites.net
Date: Mon, 23 Oct 2017 02:16:31 GMT

65
{"success":true,"message":"透過 Get 方法","payload":{"id":777,"name":"Vulcan01","filename":null}}
0
  • 請求 (Request)
    在這裡的第一行中,您將會看到了完整有查詢字串 (Query String) 的 URL,在問號之後的內容,將會傳送到後端 Web API 動作函式的參數內,此時的 Id值為 777。
GET http://vulcanwebapi.azurewebsites.net/api/values/LongTimeGet HTTP/1.1
Accept: application/json
Host: vulcanwebapi.azurewebsites.net
Connection: Keep-Alive
  • 反應 (Response)
HTTP/1.1 200 OK
Transfer-Encoding: chunked
Content-Type: application/json; charset=utf-8
Server: Kestrel
X-Powered-By: ASP.NET
Set-Cookie: ARRAffinity=9d3635139ab6649f453417d1e9047b7ed7a79b7bef031b04afeb6a2c58b33d4e;Path=/;HttpOnly;Domain=vulcanwebapi.azurewebsites.net
Date: Mon, 23 Oct 2017 02:16:40 GMT

65
{"success":true,"message":"透過 Get 方法","payload":{"id":777,"name":"Vulcan01","filename":null}}
0

相關文章索引

C# HttpClient WebAPI 系列文章索引


了解更多關於 [HttpClient Class] 的使用方式
了解更多關於 [使用 async 和 await 進行非同步程式設計] 的使用方式



關於 Xamarin 在台灣的學習技術資源

Xamarin 實驗室 粉絲團
歡迎加入 Xamarin 實驗室 粉絲團,在這裡,將會經常性的貼出各種關於 Xamarin / Visual Studio / .NET 的相關消息、文章、技術開發等文件,讓您可以隨時掌握第一手的 Xamarin 方面消息。
Xamarin.Forms @ Taiwan
歡迎加入 Xamarin.Forms @ Taiwan,這是台灣的 Xamarin User Group,若您有任何關於 Xamarin / Visual Studio / .NET 上的問題,都可以在這裡來與各方高手來進行討論、交流。
Xamarin 實驗室 部落格
Xamarin 實驗室 部落格 是作者本身的部落格,這個部落格將會專注於 Xamarin 之跨平台 (Android / iOS / UWP) 方面的各類開技術探討、研究與分享的文章,最重要的是,它是全繁體中文。
Xamarin.Forms 系列課程
Xamarin.Forms 系列課程 想要快速進入到 Xamarin.Forms 的開發領域,學會各種 Xamarin.Forms 跨平台開發技術,例如:MVVM、Prism、Data Binding、各種 頁面 Page / 版面配置 Layout / 控制項 Control 的用法等等,千萬不要錯過這些 Xamarin.Forms 課程