通常來說,Http 通訊協議是無狀態協議 (Stateless Protocol),也就是說,每次呼叫 Web API 的時候,若有些持續性要保留下來的狀態,我們就需要自己將這些狀態保留在用戶端中;而當下次要進行新的 Web API 呼叫的時候,就需要把這些狀態內容,再度傳遞後端的 Web API 伺服器上,這樣,後端 Web API 的控制器,就會知道如何繼續進行該狀態下的操作。
依據客戶要求或者當初制定系統的規格,有些時候不想透過 Header 來與後端伺服器互相傳遞資訊,而是想要透過 Cookie (使用哪種方式比較好,這不在本專題文章的討論中,有需要,請參閱相關課程)。
在這裡,我們將會使用 POST 要求與 JSON 編碼內容,搭配 Cookie 來呼叫後端兩個 Web API 服務。
- LoginPostAsync這個方法,會傳送使用者的帳號與密碼到後端進行驗證,若驗證成功,將會透過 Cookie 回報給用戶端的程式。
- LoginCheckGetAsync若身分驗證成功,並且取得了 Cookie,可以將 Cookie 傳遞到這個方法內,此時,就可以取得後端 Web API 的資料。
了解更多關於 [HttpClient Class] 的使用方式
了解更多關於 [使用 async 和 await 進行非同步程式設計] 的使用方式
用 POST 要求與使用 Cookie 進行傳送與接收 呼叫 Web API
在
LoginPostAsync
方法中,就是一般的 POST 要求呼叫 Web API 的程式碼作法,在這裡會將 LoginInformation
類別的物件進行 JSON 序列化之後,傳送到後端相對應的 Web API 控制器之動作(Action)內,進行身分驗證。
當 Cookie 成功回傳到用戶端之後,我們需要透過
HttpClientHandler
所生成的物件來進行取回 Cookie 內容。
我們使用
IEnumerable<Cookie> responseCookies = handler.CookieContainer.GetCookies(BaseAddress).Cast<Cookie>();
敘述,取得 Http 回應的所有 Cookie 到 responseCookies 列舉集合物件內。
接著,我們搭配使用 LINQ 取出 Cookie 名稱為
.AspNetCore.Cookies
內容,所以,我們使用這樣的敘述來做到這樣需求:var fooresponseCookie = responseCookies.FirstOrDefault(x => x.Name == CookieName);
private static async Task<APIResult> LoginPostAsync(LoginInformation loginInformation)
{
APIResult fooAPIResult;
using (HttpClientHandler handler = new HttpClientHandler())
{
using (HttpClient client = new HttpClient(handler))
{
try
{
#region 呼叫遠端 Web API
string FooUrl = RemoteLoginUrl;
HttpResponseMessage response = null;
#region 設定相關網址內容
var fooFullUrl = $"{FooUrl}";
// Accept 用於宣告客戶端要求服務端回應的文件型態 (底下兩種方法皆可任選其一來使用)
//client.DefaultRequestHeaders.Accept.TryParseAdd("application/json");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
// Content-Type 用於宣告遞送給對方的文件型態
//client.DefaultRequestHeaders.TryAddWithoutValidation("Content-Type", "application/json");
var fooJSON = JsonConvert.SerializeObject(loginInformation);
using (var fooContent = new StringContent(fooJSON, Encoding.UTF8, "application/json"))
{
response = await client.PostAsync(fooFullUrl, fooContent);
}
#endregion
#endregion
#region 處理呼叫完成 Web API 之後的回報結果
if (response != null)
{
if (response.IsSuccessStatusCode == true)
{
#region 取得 Cookie
var ResponseCookie = "";
IEnumerable<Cookie> responseCookies = handler.CookieContainer.GetCookies(BaseAddress).Cast<Cookie>();
var fooresponseCookie = responseCookies.FirstOrDefault(x => x.Name == CookieName);
if (fooresponseCookie != null)
{
ResponseCookie = fooresponseCookie.Value.ToString();
}
else
{
ResponseCookie = "";
}
#endregion
// 取得呼叫完成 API 後的回報內容
String strResult = await response.Content.ReadAsStringAsync();
fooAPIResult = JsonConvert.DeserializeObject<APIResult>(strResult, new JsonSerializerSettings { MetadataPropertyHandling = MetadataPropertyHandling.Ignore });
fooAPIResult.Payload = ResponseCookie;
}
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;
}
在這個
LoginCheckGetAsync
方法中,需要將之前取得的 Cookie 傳遞到這個方法內;因為這個方法,需要將傳遞進來的 Cookie 內容,加入到 Http 封包內容中,所以,我們同樣的需要 HttpClientHandler
類別物件。
我們使用了敘述
handler.CookieContainer.Add(baseAddress, new Cookie(CookieName, responseCookie))
來完成這樣的需求,其中 CookieName 字串變數為 .AspNetCore.Cookies
,而 responseCookie 則是傳入該方法內的字串物件值。private static async Task<APIResult> LoginCheckGetAsync(string responseCookie)
{
APIResult fooAPIResult;
using (HttpClientHandler handler = new HttpClientHandler())
{
using (HttpClient client = new HttpClient(handler))
{
try
{
#region 呼叫遠端 Web API
string FooUrl = RemoteLoginCheckUrl;
HttpResponseMessage response = null;
#region 設定相關網址內容
var fooFullUrl = $"{FooUrl}";
// Accept 用於宣告客戶端要求服務端回應的文件型態 (底下兩種方法皆可任選其一來使用)
//client.DefaultRequestHeaders.Accept.TryParseAdd("application/json");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
// Content-Type 用於宣告遞送給對方的文件型態
//client.DefaultRequestHeaders.TryAddWithoutValidation("Content-Type", "application/json");
if (string.IsNullOrEmpty(responseCookie) == false)
{
var baseAddress = BaseAddress;
var cookieContainer = handler.CookieContainer;
cookieContainer.Add(baseAddress, new Cookie(CookieName, responseCookie));
}
response = await client.GetAsync(fooFullUrl);
#endregion
#endregion
#region 處理呼叫完成 Web API 之後的回報結果
if (response != null)
{
// Throws an exception if the System.Net.Http.HttpResponseMessage.IsSuccessStatusCode property
// for the HTTP response is false
// 請試著將底下程式碼解除註解,看看會有何結果
//response.EnsureSuccessStatusCode();
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 動作
這個範例中,若要進行身分驗證,並且取得合法身分授權的 Cookie,將會需要指向 URL
http://vulcanwebapi.azurewebsites.net/api/values/Login
,此時,將會觸發 Web API 伺服器上的 Values 控制器(Controller)的 public async Task<APIResult> Login([FromBody]LoginInformation loginInformation)
動作(Action),其該動作的原始碼如下所示。
這個 Web API 動作,將會回傳一個 APIData 的 JSON 資料。
[HttpPost("Login")]
public async Task<APIResult> Login([FromBody]LoginInformation loginInformation)
{
APIResult foo;
if (loginInformation.Account == "Vulcan" &&
loginInformation.Password == "123")
{
var claims = new List<Claim>() {
new Claim(ClaimTypes.Name, "Herry"),
new Claim(ClaimTypes.Role, "Users")
};
var claimsIdentity = new ClaimsIdentity(claims, "myTest");
var principal = new ClaimsPrincipal(claimsIdentity);
try
{
await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
principal,
new AuthenticationProperties
{
ExpiresUtc = DateTime.UtcNow.AddMinutes(20),
IsPersistent = true,
AllowRefresh = true
});
foo = new APIResult()
{
Success = true,
Message = "這個帳號與密碼正確無誤",
Payload = null
};
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
foo = new APIResult()
{
Success = false,
Message = "這個帳號與密碼不正確",
Payload = null
};
}
}
else
{
foo = new APIResult()
{
Success = false,
Message = "這個帳號與密碼不正確",
Payload = null
};
}
return foo;
}
一旦取得 Cookie之後,想要取得 Web API 伺服器須要有使用者通過身分驗證的,將會需要指向 URL
http://vulcanwebapi.azurewebsites.net/api/values/LoginCheck
,此時,將會觸發 Web API 伺服器上的 Values 控制器(Controller)的 public APIResult LoginCheck()
動作(Action),其該動作的原始碼如下所示。若沒有傳送過來通過身分驗證的 Cookie 內容,您是無法呼叫這個方法的。
這個 Web API 動作,將會回傳一個 APIData 的 JSON 資料。
[Authorize]
[HttpGet("LoginCheck")]
public APIResult LoginCheck()
{
APIResult foo = new APIResult()
{
Success = true,
Message = "成功得所有資料集合",
Payload = new List<APIData>()
{
new APIData()
{
Id =777,
Name = "Vulcan01"
},
new APIData()
{
Id =234,
Name ="Vulcan02"
}
}
};
return foo;
}
進行測試
在這裡,我們一共進行了四次 Web API 的呼叫:
- 第1次這裡將會呼叫
LoginPostAsync
方法,並且傳送一個正確的帳號與密碼到遠端 Web API 伺服器上,這樣就可以取得通過身分驗證的 Cookie。回傳的 Cookie 的內容,可以透過 APIResult.Payload 屬性來得到。 - 第2次這裡將會呼叫
LoginPostAsync
方法,並且傳送一個不正確的帳號與密碼到遠端 Web API 伺服器上,這樣就無法取得通過身分驗證,並且,也無法看到任何 Cookie。 - 第3次這裡將會呼叫
LoginCheckGetAsync
方法,並且將剛剛取得的 Cookie 傳遞到該方法內,此時,將可以取得後端 Web API 回傳的資料。 - 第4次這裡將會呼叫
LoginCheckGetAsync
方法,但是,並沒有傳送任何 Cookie 傳遞到該方法內,此時,將會看有例外異常訊息顯示出來。
static void Main(string[] args)
{
string ResponseCookie = "";
var fooLoginInformation = new LoginInformation()
{
Account = "Vulcan",
Password = "123",
};
var foo = LoginPostAsync(fooLoginInformation).Result;
ResponseCookie = foo.Payload.ToString();
Console.WriteLine($"使用 Post 方法,取得 Cookie,呼叫 Web API 的結果");
Console.WriteLine($"結果狀態 : {foo.Success}");
Console.WriteLine($"結果訊息 : {foo.Message}");
Console.WriteLine($"Payload : {foo.Payload}");
Console.WriteLine($"");
Console.WriteLine($"Press any key to Exist...{Environment.NewLine}");
Console.ReadKey();
fooLoginInformation = new LoginInformation()
{
Account = "Vuln",
Password = "13",
VerifyCode = "123"
};
foo = LoginPostAsync(fooLoginInformation).Result;
Console.WriteLine($"使用 Post 方法,取得 Cookie,呼叫 Web API 的結果");
Console.WriteLine($"結果狀態 : {foo.Success}");
Console.WriteLine($"結果訊息 : {foo.Message}");
Console.WriteLine($"Payload : {foo.Payload}");
Console.WriteLine($"");
Console.WriteLine($"Press any key to Exist...{Environment.NewLine}");
Console.ReadKey();
foo = LoginCheckGetAsync(ResponseCookie).Result;
Console.WriteLine($"使用 Get 方法,傳送 Cookie,呼叫 Web API 的結果");
Console.WriteLine($"結果狀態 : {foo.Success}");
Console.WriteLine($"結果訊息 : {foo.Message}");
Console.WriteLine($"Payload : {foo.Payload}");
Console.WriteLine($"");
Console.WriteLine($"Press any key to Exist...{Environment.NewLine}");
Console.ReadKey();
foo = LoginCheckGetAsync(null).Result;
Console.WriteLine($"使用 Get 方法,沒有傳送 Cookie,呼叫 Web API 的結果");
Console.WriteLine($"結果狀態 : {foo.Success}");
Console.WriteLine($"結果訊息 : {foo.Message}");
Console.WriteLine($"Payload : {foo.Payload}");
Console.WriteLine($"");
Console.WriteLine($"Press any key to Exist...{Environment.NewLine}");
Console.ReadKey();
}
執行結果
這個測試將會輸出底下內容
使用 Post 方法,取得 Cookie,呼叫 Web API 的結果
結果狀態 : True
結果訊息 : 這個帳號與密碼正確無誤
Payload : CfDJ8IH9Z5ddETZGrIcaxOsO-eXfEHmgF5yeQ8ppzfPM2rFnj4wA23jxcLtWZ7YVt6UfmodE_AZPiYkYnKSG47GckqU0wGW7PtvA05HQrbrtvjILdxih3gk3xReBVtFtul-kgnjLLmr-lmAhIhsDhlJrgqzjbp6ebfzBwqorkdx88CWlIyqgUdEiWGGTZzc15XHSHBJQNR1xW792STk5O6LGEraFAMMhqpVdrQNxg7dEpVBDjFUFmd5mXeoUURHhiq8_X-P0kT4e05zuWIN3SOGv6fuVkTu479w1IdmmiMq3k8RwSng0bHPxKjdLO_-L3XDa_HNuNXp-euzOOvMS3UHbldrA3md_lW6KvgXqQT23PLPsvBkrIcKoNNvt0G1_v5TwSWzzG88H3qEO1DdjH_2QObgjp_efuy7Oxk-KELhLJtn-ZcNEAKxOjiVARr7Euk1igA
Press any key to Exist...
使用 Post 方法,取得 Cookie,呼叫 Web API 的結果
結果狀態 : False
結果訊息 : 這個帳號與密碼不正確
Payload :
Press any key to Exist...
使用 Get 方法,傳送 Cookie,呼叫 Web API 的結果
結果狀態 : True
結果訊息 : 成功得所有資料集合
Payload : [
{
"id": 777,
"name": "Vulcan01",
"filename": null
},
{
"id": 234,
"name": "Vulcan02",
"filename": null
}
]
Press any key to Exist...
使用 Get 方法,沒有傳送 Cookie,呼叫 Web API 的結果
結果狀態 : False
結果訊息 : Error Code:NotFound, Error Message:Method: GET, RequestUri: 'http://vulcanwebapi.azurewebsites.net/auth/login?ReturnUrl=%2Fapi%2FMy%2FLoginCheck', Version: 1.1, Content: <null>, Headers:
{
Accept: application/json
}
Payload :
Press any key to Exist...
HTTP 傳送與接收原始封包
讓我們來看看,這個 Web API 的呼叫動作中,在請求 (Request) 與 反應 (Response) 這兩個階段,會在網路上傳送了那些 HTTP 資料
- 請求 (Request)這裡,我們使用 POST 要求,進行使用者身分的驗證,我們將使用者的帳號與密碼物件,使用 JSON 編碼技術進行編碼,並且傳送到遠端 Web API 伺服器上。
POST http://vulcanwebapi.azurewebsites.net/api/Values/Login HTTP/1.1
Accept: application/json
Content-Type: application/json; charset=utf-8
Host: vulcanwebapi.azurewebsites.net
Content-Length: 55
Expect: 100-continue
Connection: Keep-Alive
{"Account":"Vulcan","Password":"123","VerifyCode":null}
- 反應 (Response)這裡的回應將會是有通過身分驗證的結果,因此,您將會看到有
Set-Cookie
Http Header 出現在封包內,這個 Header 的,標示了,他的 Cookie名稱(.AspNetCore.Cookies)與實際內容。
HTTP/1.1 200 OK
Cache-Control: no-cache
Pragma: no-cache
Transfer-Encoding: chunked
Content-Type: application/json; charset=utf-8
Expires: -1
Server: Kestrel
Set-Cookie: .AspNetCore.Cookies=CfDJ8IH9Z5ddETZGrIcaxOsO-eXfEHmgF5yeQ8ppzfPM2rFnj4wA23jxcLtWZ7YVt6UfmodE_AZPiYkYnKSG47GckqU0wGW7PtvA05HQrbrtvjILdxih3gk3xReBVtFtul-kgnjLLmr-lmAhIhsDhlJrgqzjbp6ebfzBwqorkdx88CWlIyqgUdEiWGGTZzc15XHSHBJQNR1xW792STk5O6LGEraFAMMhqpVdrQNxg7dEpVBDjFUFmd5mXeoUURHhiq8_X-P0kT4e05zuWIN3SOGv6fuVkTu479w1IdmmiMq3k8RwSng0bHPxKjdLO_-L3XDa_HNuNXp-euzOOvMS3UHbldrA3md_lW6KvgXqQT23PLPsvBkrIcKoNNvt0G1_v5TwSWzzG88H3qEO1DdjH_2QObgjp_efuy7Oxk-KELhLJtn-ZcNEAKxOjiVARr7Euk1igA; expires=Sun, 22 Oct 2017 09:09:40 GMT; path=/; samesite=lax; httponly
X-Powered-By: ASP.NET
Set-Cookie: ARRAffinity=9d3635139ab6649f453417d1e9047b7ed7a79b7bef031b04afeb6a2c58b33d4e;Path=/;HttpOnly;Domain=vulcanwebapi.azurewebsites.net
Date: Sun, 22 Oct 2017 08:49:41 GMT
4d
{"success":true,"message":"這個帳號與密碼正確無誤","payload":null}
0
- 請求 (Request)這裡,我們使用 POST 要求,進行使用者身分的驗證,我們將使用者的帳號與密碼物件,使用 JSON 編碼技術進行編碼,並且傳送到遠端 Web API 伺服器上;不過,我們所傳送過的帳號與密碼,是不正確的。
POST http://vulcanwebapi.azurewebsites.net/api/Values/Login HTTP/1.1
Accept: application/json
Content-Type: application/json; charset=utf-8
Host: vulcanwebapi.azurewebsites.net
Content-Length: 53
Expect: 100-continue
{"Account":"Vuln","Password":"13","VerifyCode":"123"}
- 反應 (Response)這裡的回應將會是沒有通過身分驗證的結果,因此,您將不會看到有 Cookie名稱為 .AspNetCore.Cookies 的 Cookie 內容。
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: Sun, 22 Oct 2017 08:49:46 GMT
4b
{"success":false,"message":"這個帳號與密碼不正確","payload":null}
0
- 請求 (Request)這裡使用剛剛取得的 Cookie,也就是 Cookie 名稱為 .AspNetCore.Cookies 的內容,進行呼叫 Web API,我們看到了,在 Http Header 中,有個名稱為 Cookie Header 欄位存在。
GET http://vulcanwebapi.azurewebsites.net/api/My/LoginCheck HTTP/1.1
Accept: application/json
Host: vulcanwebapi.azurewebsites.net
Cookie: .AspNetCore.Cookies=CfDJ8IH9Z5ddETZGrIcaxOsO-eXfEHmgF5yeQ8ppzfPM2rFnj4wA23jxcLtWZ7YVt6UfmodE_AZPiYkYnKSG47GckqU0wGW7PtvA05HQrbrtvjILdxih3gk3xReBVtFtul-kgnjLLmr-lmAhIhsDhlJrgqzjbp6ebfzBwqorkdx88CWlIyqgUdEiWGGTZzc15XHSHBJQNR1xW792STk5O6LGEraFAMMhqpVdrQNxg7dEpVBDjFUFmd5mXeoUURHhiq8_X-P0kT4e05zuWIN3SOGv6fuVkTu479w1IdmmiMq3k8RwSng0bHPxKjdLO_-L3XDa_HNuNXp-euzOOvMS3UHbldrA3md_lW6KvgXqQT23PLPsvBkrIcKoNNvt0G1_v5TwSWzzG88H3qEO1DdjH_2QObgjp_efuy7Oxk-KELhLJtn-ZcNEAKxOjiVARr7Euk1igA
- 反應 (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: Sun, 22 Oct 2017 08:49:47 GMT
9e
{"success":true,"message":"成功得所有資料集合","payload":[{"id":777,"name":"Vulcan01","filename":null},{"id":234,"name":"Vulcan02","filename":null}]}
0
- 請求 (Request)這裡沒有傳送任何 Cookie 名稱為 .AspNetCore.Cookies 的內容到遠端 Web API 伺服器上。
GET http://vulcanwebapi.azurewebsites.net/api/My/LoginCheck HTTP/1.1
Accept: application/json
Host: vulcanwebapi.azurewebsites.net
- 反應 (Response)因為呼叫這個方法,使用者沒有通過身分驗證(也就是無法提供有效的 Cookie 內容),在我們的後端系統中,將會回傳狀態碼為 302,與
Location
這個 Http Header 欄位,這個欄位值標示了要重新導向到這個網址您可以嘗試將LoginCheckGetAsync
方法內的handler.AllowAutoRedirect = false;
敘述解除註解,看看會有甚麼不同結果。
HTTP/1.1 302 Found
Content-Length: 0
Location: http://vulcanwebapi.azurewebsites.net/auth/login?ReturnUrl=%2Fapi%2FMy%2FLoginCheck
Server: Kestrel
X-Powered-By: ASP.NET
Set-Cookie: ARRAffinity=9d3635139ab6649f453417d1e9047b7ed7a79b7bef031b04afeb6a2c58b33d4e;Path=/;HttpOnly;Domain=vulcanwebapi.azurewebsites.net
Date: Sun, 22 Oct 2017 08:49:50 GMT
- 請求 (Request)因為剛剛收到了要重新導向到
http://vulcanwebapi.azurewebsites.net/auth/login?ReturnUrl=%2Fapi%2FMy%2FLoginCheck
這個 URL,因此,就會產生這樣的呼叫。
GET http://vulcanwebapi.azurewebsites.net/auth/login?ReturnUrl=%2Fapi%2FMy%2FLoginCheck HTTP/1.1
Accept: application/json
Host: vulcanwebapi.azurewebsites.net
Cookie: ARRAffinity=9d3635139ab6649f453417d1e9047b7ed7a79b7bef031b04afeb6a2c58b33d4e
- 反應 (Response)
HTTP/1.1 404 Not Found
Content-Length: 0
Server: Kestrel
X-Powered-By: ASP.NET
Date: Sun, 22 Oct 2017 08:49:50 GMT
相關文章索引
C# HttpClient WebAPI 系列文章索引
了解更多關於 [HttpClient Class] 的使用方式
了解更多關於 [使用 async 和 await 進行非同步程式設計] 的使用方式
關於 Xamarin 在台灣的學習技術資源
歡迎加入 Xamarin 實驗室 粉絲團,在這裡,將會經常性的貼出各種關於 Xamarin / Visual Studio / .NET 的相關消息、文章、技術開發等文件,讓您可以隨時掌握第一手的 Xamarin 方面消息。
歡迎加入 Xamarin.Forms @ Taiwan,這是台灣的 Xamarin User Group,若您有任何關於 Xamarin / Visual Studio / .NET 上的問題,都可以在這裡來與各方高手來進行討論、交流。
Xamarin 實驗室 部落格 是作者本身的部落格,這個部落格將會專注於 Xamarin 之跨平台 (Android / iOS / UWP) 方面的各類開技術探討、研究與分享的文章,最重要的是,它是全繁體中文。
Xamarin.Forms 系列課程 想要快速進入到 Xamarin.Forms 的開發領域,學會各種 Xamarin.Forms 跨平台開發技術,例如:MVVM、Prism、Data Binding、各種 頁面 Page / 版面配置 Layout / 控制項 Control 的用法等等,千萬不要錯過這些 Xamarin.Forms 課程