改进Xamarin应用程序中的HTTP性能

Improving HTTP Performance in Xamarin Applications

Any mobile application that depends greatly on HTTP requests can be a source of frustration. Mobile devices are constantly running slow connections, losing connectivity entirely, or switching from Wi-Fi to cellular. As an app developer, you really don‘t want to mess with any of this. You want your app to *just work*, and your users to not even notice any of this stuff going on while using your app. Luckily, there are a few easy tricks that can be applied to Xamarin apps that will get you most of the way there without a lot of trouble.

Test with Apple‘s Network Link Conditioner

Before you do anything, install this thing right now and test your app on 3G with a 5% packet loss. If you have never done this before, be prepared for sparks and flames. This is the best way to develop for bad networking conditions in a predictable way, and since it is applied to your entire Mac OS you could also do things like run unit tests while the conditioner is turned on. Seeing your app fail spectularly, should give you (and maybe your manager) some enthusiasm to improve web requests in your app.

NOTE: at the time of this post, make sure you get Additional Tools for Xcode 8.2 (it includes the Network Link Conditioner) and are running at least OS X Sierra.

Use Xamarin‘s Native HttpMessageHandlers

Since Xamarin.iOS 9.8, Xamarin has added a feature for swapping in a native HttpMessageHandler for System.Net.Http.HttpClient. Instead of using the Mono networking stack, NSUrlSessionHandler uses Apple‘s NSUrlSession APIs. This gives you features like transparent connection switching from Wi-Fi to cellular, better performance, and a generally more reliable HTTP requests from your mobile client application.

So how do you make sure you are using it? Make sure you are using System.Net.HttpClient‘s empty constructor throughout your application:

 
var httpClient = new HttpClient();
string text = await httpClient.GetStringAsync("http://chucknorris.com/");

//NOTE: you can‘t do this, or you will not get an automatic NSUrlSessionHandler
var httpClient = new HttpClient(new HttpClientHandler
{
    //Some settings
});

From here, all you have to do is enable the NSUrlSession option in Project Options | iOS Build | Http client implementation. This is my favorite way to set this up, since it works well in PCLs, where your code does not even have to know it is running on iOS. You can also pass an NSUrlSessionHandler to your HttpClient in code, whichever you prefer. Xamarin also has an option for an HttpMessageHandler for Xamarin.Android, although the native AndroidClientHandler only works on Android 5.0 or higher. An alternative is to use ModernHttpClient for Android < 5.0, which makes it possible to use Square‘s OkHttp library for Android.

So what do I do for HttpWebRequest or System.Net.WebClient?

The original APIs from .NET are still available in Xamarin apps, but do not support HttpMessageHandler. If you are experiencing issues with the older .NET APIs, it may be best to rewrite this code and async all the things.

Reuse your HttpClient

System.Net.HttpClient is built for reuse. I take the approach of using a single HttpClient instance per server my client app is talking to. This will get you better performance and will segregate things each server may depend on such as cookies or DefaultRequestHeaders.

So for example:

//Use this class as a singleton in an IoC container
class MyAppClient
{
  readonly HttpClient _httpClient = new HttpClient();
  
  public async Task<string> GetChuck()
  {
    return await _httpClient.Get("http://chucknorris.com");
  }
}

//Although "using" is good in general, don‘t do this
using (var httpClient = new HttpClient())
{
  return await _httpClient.Get("http://chucknorris.com");
}

 

Note that even though you have a single HttpClient, you can make multiple requests in parallel before previous ones are completed. HttpClient is built to do this, so there is no need in the extra overhead of creating and disposing new HttpClients each time.

Read requests directly from a Stream, don‘t use strings

A common mistake I see people doing is to download json to a string, use JSON.Net‘s JsonConvert.DeserializeObject(), and then go about their business. The problem is that this creates a string of your entire JSON document needlessly. Imagine if you had a 10MB JSON document that you turned into a string, and you can see why this would be a bit slow.

It is a bit more verbose to use JSON.NET and read directly from a Stream, but not too difficult:

//reuse this
private JsonSerializer _serializer = new JsonSerializer();

var response = await _httpClient.GetAsync("http://chucknorris.com/api/dropkick");
response.EnsureSuccessStatusCode();

using (var stream = await response.Content.ReadAsStreamAsync())
using (var reader = new StreamReader(stream))
using (var json = new JsonTextReader(reader))
{
  return _serializer.Deserializer<YourClass>(json);
}

//If you need logging for development, use #if DEBUG and JsonConvert otherwise
#if DEBUG
using (var stream = await response.Content.ReadAsStreamAsync())
using (var reader = new StreamReader(stream))
{
    string text = reader.ReadToEnd();
    Debug.WriteLine("RECEIVED: " + text);
    return JsonConvert.DeserializeObject<YourClass>(text);
}
#else
using (var stream = await response.Content.ReadAsStreamAsync())
using (var reader = new StreamReader(stream))
using (var json = new JsonTextReader(reader))
{
  return _serializer.Deserializer<YourClass>(json);
}
#endif

 

Even though this is more complex (especially if you need logging in debug), it will significantly reduce memory overhead for each request. The larger your JSON payloads are, the more important this becomes.

Use GZIP (or deflate) where possible

If you are not doing this, this is also going to be an easy win for your app. Generally, all you have to do is:

var httpClient = new HttpClient();
httpClient.DefaultRequestHeaders.AcceptEncoding.Add(new StringWithQualityHeaderValue("gzip"));

//Nope, you don‘t have to even mess with GZipStream
string text = await httpClient.GetAsString("http://chucknorris.com");

//NOTE: but if you need this to work on Windows or non-Xamarin platforms, you will need this ctor for HttpClient
var httpClient = new HttpClient(new HttpClientHandler
{
  AutomaticDecompression = DecompressionMethods.GZip,
});

NOTE: If managing your HttpClientHandler started to get complicated, I would recommend implementing some kind of IHttpClientHandlerFactory to manage your handlers with dependency injection across different platforms.

Xamarin‘s native HttpMessageHandler implementations will transparently "un-gzip" requests for you, so your code stays simple while native platforms APIs decompress your data--a huge benefit. However, if your server doesn‘t support gzip, it is fairly easy to implement with ASP.NET MVC. Use a tool like Fiddler (Windows) or CharlesProxy (Mac) to verify your requests are really gzipped. But if you don‘t "own" the server-side code your app is talking to, this may not be an option.

Can I GZIP uploads?

Yes, but this is a bit more complicated. I would only recommend doing this if you are sending larger blocks of JSON to your server and you control your server as well, as there are some custom headers to implement.

I go about this by creating a custom JsonContent class that sends an HTTP Content-Type of application/gzip:

public class JsonContent : HttpContent
{
  private readonly JsonSerializer _serializer = new JsonSerializer();
  private readonly object _value;

  public JsonContent(object value)
  {
    _value = value;
    Headers.ContentType = new MediaTypeHeaderValue("application/gzip");
  }

  protected override bool TryComputeLength(out long length)
  {
    length = -1;
    return false;
  }

  protected override Task SerializeToStreamAsync(Stream stream, TransportContext context)
  {
    return Task.Factory.StartNew(() =>
    {
      using (var gzip = new GZipStream(stream, CompressionMode.Compress, true))
      using (var writer = new StreamWriter(gzip))
      {
        _serializer.Serialize(writer, _value);
      }
    });
  }
}

//To use it
var request = new HttpRequestMessage(HttpMethod.Post, "http://chucknorris.com/api/dropkick");
request.Content = new JsonContent(yourObjectToSerialize);
var response = await _httpClient.SendAsync(request);

 

Your server, will of course, need to handle the application/gzip content type and understand how to decompress the request. I used an example here with DelegatingHandler to make this work both for incoming and outgoing data, although this will change a bit if you are running ASP.NET Core.

Bundle your API calls

Assuming you have some control over your server-side application, let‘s assume you have the following usage pattern in your app on login:

  • Login with credentials - POST api/login
  • Download json document A - GET api/a
  • Download json document B - GET api/b

This could be making three web requests to your server. Instead consider something like the following:

  • One API call to rule them all - POST api/login
  • Reponse contains a single JSON payload with both documents

This will negate the overhead of multiple requests on both your server and client. On your server side, you could even make the database calls for A and B in parallel. Async/await makes this easy to accomplish.

SignalR on Xamarin

If you are looking for realtime communication in a Xamarin app, SignalR is the way to go. Keep in mind, however, that the web socket implementation is not quite up to par in Mono as of yet. SignalR also doesn‘t currently support web sockets with its PCL version of the SignalR .NET client library, due to System.Net.WebSockets being missing from all PCL profiles. However, the next best fallback, Server Sent Events, works great with Xamarin--and in some cases may work better than web sockets for larger JSON payloads.

First of all, make sure you are manually specifying SSE and register your HttpMessageHandler as such:

var connection = new HubConnection("http://yourserver.com/signalr");
await connection.Start(new ServerSentEventsTransport(new NativeHttpClient()));

//And NativeHttpClient
public class NativeHttpClient : DefaultHttpClient
{
  protected override HttpMessageHandler CreateHandler()
  {
    return new NSUrlSessionHandler();
  }
}

 

Note that SignalR is not using the empty constructor for HttpClient, so you must specify your HttpMessageHandler in code; an IoC container will help with this. Of course, make sure you are using the HubConnection as a singleton for your entire app.

Making SignalR more robust

In our testing, we found the DefaultHttpClient that ships with SignalR is not reliable enough to pass testing with Apple‘s Network Link Conditioner on iOS with a 5% packet loss. So I took a stab at implementing my own IHttpClient implementation for SignalR, to see if we could get any improvements.

Here is what I came up with:

public class NativeHttpClient : IHttpClient
{
  private HttpClient _client;
  private IConnection _connection;

  public void Initialize(IConnection connection)
  {
    _connection = connection;
    _client = new HttpClient();
    _client.Timeout = TimeSpan.FromMilliseconds(-1); //NOTE: infinite timeout because upper SignalR layer handles timeouts
  }

  public async Task<IResponse> Get(string url, Action<IRequest> prepareRequest, bool isLongRunning)
  {
    var request = new Request(HttpMethod.Get, url);
    prepareRequest(request);
    return await Send(request, isLongRunning);
  }

  public async Task<IResponse> Post(string url, Action<IRequest> prepareRequest, IDictionary<string, string> postData, bool isLongRunning)
  {
    var request = new Request(HttpMethod.Post, url);
    request.Content = new FormUrlEncodedContent(postData);
    prepareRequest(request);
    return await Send(request, isLongRunning);
  }

  private async Task<IResponse> Send(Request request, bool isLongRunning)
  {
    var option = isLongRunning ? HttpCompletionOption.ResponseHeadersRead : HttpCompletionOption.ResponseContentRead;
    var httpResponse = await _client.SendAsync(request, option, request.Cancellation.Token);
    var response = new Response(httpResponse);
    await response.ReadStream();
    return response;
  }

  private class Request : HttpRequestMessage, IRequest
  {
    public Request(HttpMethod method, string url) : base(method, url) { }

    public CancellationTokenSource Cancellation = new CancellationTokenSource();

    public string UserAgent { get; set; }

    public string Accept { get; set; }

    public void Abort()
    {
      Cancellation.Cancel();
    }

    public void SetRequestHeaders(IDictionary<string, string> headers)
    {
      if (UserAgent != null)
      {
        Headers.TryAddWithoutValidation("User-Agent", UserAgent);
      }
      if (Accept != null)
      {
        Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(Accept));
      }
      foreach (KeyValuePair<string, string> headerEntry in headers)
      {
        Headers.Add(headerEntry.Key, headerEntry.Value);
      }
    }
  }

  private class Response : IResponse
  {
    private HttpResponseMessage _response;
    private Stream _stream;

    public Response(HttpResponseMessage response)
    {
      _response = response;
    }

    public async Task ReadStream()
    {
      _stream = await _response.Content.ReadAsStreamAsync();
    }

    public Stream GetStream()
    {
      return _stream;
    }

    public void Dispose()
    {
      if (_stream != null)
      {
        _stream.Dispose();
        _stream = null;
      }
      if (_response != null)
      {
        _response.Dispose();
        _response = null;
      }
    }
  }
}

 

Bravo to the SignalR team for architecting their .NET client to where it is possible to plug in your own IHttpClient. For some reason, my naive implementation is more reliable in poor network conditions than SignalR‘s. To be honest, I really don‘t know why since their version looks great to me as of writing this.

A few reasons mine might be better:

  • My implementation is simpler: no cookies, no proxies, no credentials
  • I use a single HttpClient
  • I use HttpCompletionOption.ResponseContentRead on short-lived requests
  • I await HttpContent.ReadAsStreamAsync() instead of forcing it to be synchronous

We are testing this in production in our app as I write this, I would only recommend trying my version if you are running into trouble with SignalR‘s default implementation. In any case, I‘m sure the SignalR team is working on a new version for ASP.NET Core among other things. Hopefully this upgrade will include improvements to the .NET client, and perhaps we will get a web socket implementation for .NET standard clients.

Instrument your app

This may go without saying, but make sure you have analytics/error tracking inside your apps to know what your users are experiencing in the wild. Use your analytics platform of choice: HockeyApp, Raygun, Count.ly, Google Analytics, etc. and track the timing of each HTTP request along with the number of successes and failures. Knowing the percentage of users experiencing network related issues is important in knowing how your app is working in the real world.

Conclusion

I hope this information is helpful out there to someone, and if anything can be some easy changes on your part for huge gains in your app‘s reliability. These tips are the core of what I‘ve learned from my experience in building real-world, consumer facing, Xamarin apps.

PS - Don‘t be afraid correct me if there is a gap in my logic or something I‘m getting wrong.

改进Xamarin应用程序中的HTTP性能

上一篇:真-MVC控制器AJAS


下一篇:JS_对象原型链