iOS:网络优化

  • Post author:
  • Post category:其他


iOS网络优化主要解决两点:

  1. 服务器压力
  2. 客户端网络优化、增加用户体验

优化的方向主要是:

  1. NSURLCache缓存、Last-Modified、ETag
  2. DNS解析
  3. 数据压缩:protobuf,WebP
  4. TCP对头阻塞



一、NSURLCache缓存和Last-Modified、ETag

NSURLCache可以完成大部分的缓存需求,NSURLCache使用前提

  1. 只能作用于get请求
  2. 设置NSURLCache:默认512kb的内存缓存空间,以及10MB的磁盘缓存空间
    NSURLCache *urlCache = [[NSURLCache alloc] initWithMemoryCapacity:1*1024*1024
                                                         diskCapacity:20*1024*1024
                                                             diskPath:nil];
    [NSURLCache setSharedURLCache:urlCache];

然后在get的request中选择默认的缓存策略

    [NSURLRequest requestWithURL:[NSURL new]
                     cachePolicy:NSURLRequestUseProtocolCachePolicy
                 timeoutInterval:10];

默认的缓存策略其实就是HTTP协议的中缓存策略:

  • 本地没有缓存数据,则进行网络请求。
  • 本地有缓存,并且缓存没有失效,则使用缓存。
  • 缓存已经失效,访问服务器数据是否改变,如果没有改变,使用缓存,如果改变,请求新数据(Last-Modified)


但是如何更新缓存?

如果我们本地有了缓存的数据。难道就不在拉取新数据了?当然不是。那么缓存如何更新呢?这就用到了ETag/Last-Modified



Last-Modified

Last-Modified:标记文件在服务端最后被修改的时间

客户端第一次请求服务器成功后,返回状态码200,内容是你请求的资源。同时返回一个Last-Modified字段

Last-Modified: Fri, 17 Jul 2019 17:24:26 GMT

过了一会儿。

客户端第二次请求此URL,客户端会向服务器传送 If-Modified-Since 报头,查询当前资源在该时间之后是否被修改

If-Modified-Since: Fri, 17 Jul 2019 17:24:26 GMT

服务端如果修改了改文件资源。那么同时服务端也会修改文件的时间戳。取出来的Modified将会改变。

  • 如果两个时间戳不相等,说明内容改变,不使用缓存,服务端response code:200,请求新数据,内容从新发送
  • 两个时间戳相等,说明没有改变,response code:304,内容不返还。客户端收到code:304就会去读取缓存

考虑到服务端可能出现bug,然后回滚使用旧版本,客户端也要使用旧版本的数据。所以比较两个时间戳是否相等,而不是比较大小



ETag

Etag 主要为了解决 Last-Modified 无法解决的一些问题。

  1. 一些文件也许会周期性的更改,但是他的内容并不改变(仅仅改变的修改时间),这个时候我们并不希望客户端认为这个文件被修改了,而重新GET;
  2. 某些文件修改非常频繁,比如在秒以下的时间内进行修改,(比方说1s内修改了N次),If-Modified-Since能检查到的粒度是s级的,这种修改无法判断(或者说UNIX记录MTIME只能精确到秒)

HTTP协议规格说明定义ETag为“被请求变量的实体值”。或者是与资源关联的记号。作用和Last-Modified一样。在HTTP响应头中将其传送到客户端,以下是服务器端返回的格式:

ETag:"50b1c1d4f775c61:df3"

客户端的查询更新格式是这样的:

If-None-Match : W / "50b1c1d4f775c61:df3"

如果ETag没改变,则返回状态304然后不返回内容。测试Etag主要在断点下载时比较有用。



断点下载

给request加Range头部,可以从offset位置继续下载,此时服务端返回code 206,需要服务器支持断点下载

"Range":"bytes=1024*1024"
"If-Range":"50b1c1d4f775c61"
"If-None-Match": "W / 50b1c1d4f775c61:df3"
 

If-Range必须依赖Range,如果Range没有设置。那么If-Range没有作用;


Range

要搭配

If-None-Match



If-Range



If-Modified-Since

中的一种。因为可以能本地下载的部分文件,由于网络原因,下次再断点下载,而此时服务端的文件已经修改,所以需要先查询

Modified

或者

ETag

,来保证继续下载还是重新下载



Expire

Expire 是 HttpHeader 中代表资源的过期时间,由服务器段设置。如果带有 Expire ,则在 Expire 过期前不会发生Http 请求,直接从缓存中读取。用户强制 F5 例外。

通常 Last-Modified,Etag,Expire 是一起混合使用的,特别是 Last-Modified 和 Expire 经常一起使用,因为 Expire可以让浏览器完全不发起 Http 请求,而当浏览器强制 F5 的时候又有 Last-Modified ,这样就很好的达到了浏览器段缓存的效果。



二、DNS解析

DNS (Domain Name System 的缩写)的作用非常简单,就是根据域名查出IP地址。你可以把它想象成一本巨大的电话本。

虽然只需要返回一个IP地址,但是DNS的查询过程非常复杂,分成多个步骤。一般由运营商负责查询



域名解析流程

运营商的LocalDNS 负责域名解析和查询,请求一个地址,先发送到运营商,由运营商的LocalDNS域名解析,用解析后得到的IP去访问服务器,服务器响应数据,返回给运营商,然后运营商在返回给客户端。

那么这个过程可能会发生DNS 劫持。然后篡改了解析结果,使客户端访问错误 IP 地址,实现资料窃取或恶意访问,添加广告。

所以要怎么避免这种情况?



使用HttpDNS

由于运用商的LocalDNS不可控,那么我们就绕过它,然后自己进行解析。HttpDNS就是另外一个电话簿,我们把解析域名的过程交给HttpDNS来做,客户端直接访问 HttpDNS 接口,获取最优 IP 地址,然后请求服务端。

  • 因为不再由 LocalDNS 解析域名,所以从源头避免了DNS劫持
  • 直接通过 IP 访问,减少域名解析的过程,访问速度更快
  • 可在自己服务器通过算法对 IP 请求成功率高低的进行排序,筛选出优质 IP,增加了请求的成功率。


httpDNS使用

HTTP 标准中规定,服务器会将请求头中的 host 字段的值作为请求的域名。如果使用 IP 替换 URL 中的 host 进行访问,此时网络库会将 IP 当作 host,服务器就会解析异常。为了解决这个问题,要手动设置请求中的 host 字段:

[request setValue:originHostString forHTTPHeaderField:@"host"];
request.setValue(originHostString, forHTTPHeaderField: "host");

需要注意的几点:

  1. host,拦截请求,在header中添加host域名
  2. 使用代理服务器时,httpDNS会失效:因为代理服务器会转发host中的域名,相当于没有效果
  3. cookie:如果使用了cookie,需要注意里面的域名是否是IP还是域名,如果用的是 URL 中的 host,也需要进行替换。
  4. 只能拦截get,head方法

    NSURLProtocol在拦截NSURLSession的POST请求时不能获取到Request中的HTTPBody。据苹果官方的解释是Body是NSData类型,即可能为二进制内容,而且还没有大小限制,所以可能会很大,为了性能考虑,索性就拦截时就不拷贝了。为了解决这个问题,我们可以通过把Body数据放到Header中,不过Header的大小好像是有限制的。而且header只能是文本类型

HEAD请求允许客户端仅向服务器请求某个资源的响应头, 而不要真正的下载该资源本身, 省略了响应体



集成

项目启动后,适当的时机注册SDK

// 初始化HTTPDNS
HttpDnsService *httpdns = [HttpDnsService sharedInstance];
// 设置AccoutID,当您开通HTTPDNS服务时,可以获取到对应的Accout ID信息
[httpdns setAccountID:*****];

可以预先向HTTPDNS SDK中可能会使用到的域名,SDK可以提前解析,减少后续解析域名时请求的时延。

NSArray * hosts = [[NSArray alloc] initWithObjects:@"www.baidu.com", @"www.ob.com", nil];
[httpdns setPreResolveHosts:hosts];


使用

实现一个自定义的,继承自

NSURLProtocol

的协议的子类

OBURLProtocol

@interface OBURLProtocol : NSURLProtocol
@end

#import "OBURLProtocol.h"
@implementation OBURLProtocol

//1:筛选需要拦截request,需要返回YES;不需要返回NO
+ (BOOL)canInitWithRequest:(NSURLRequest *)request { //code }
//2:对拦截的request进行处理,修改host
+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request {//code }
//3:建立NSURLSesion对象,并发起请求
- (void)startLoading { //code }
//4:停止请求
- (void)stopLoading { //code}
@end



OBURLProtocol

这个协议添加到配置中,然后收到request时,就会回调到对应的拦截方法

    NSURLRequest *requset =[NSURLRequest requestWithURL:[NSURL URLWithString:@"http://www.baidu.com"]];
    NSURLSessionConfiguration *config = [NSURLSessionConfiguration defaultSessionConfiguration];
    [NSURLProtocol registerClass:[OBURLProtocol class]];
    config.protocolClasses = @[[OBURLProtocol class]];
    NSURLSession *session =[NSURLSession sessionWithConfiguration:config delegate:self delegateQueue:nil];
    NSURLSessionDataTask *dataTask = [session dataTaskWithRequest:requset];
    [dataTask resume];

打印如下:

2020-08-11 16:24:47.495636+0800 Test +[OBURLProtocol canInitWithRequest:]
2020-08-11 16:24:47.537896+0800 Test +[OBURLProtocol canInitWithRequest:]
2020-08-11 16:24:47.538413+0800 Test +[OBURLProtocol canonicalRequestForRequest:]
2020-08-11 16:24:47.557431+0800 Test -[OBURLProtocol startLoading]

如果使用AFN的话,需要注意

manager

方法不会执行注册的协议

AFHTTPSessionManager * sessionManager = [AFHTTPSessionManager manager];

应该使用这个方法实例化

sessionManager

- (instancetype)initWithBaseURL:(nullable NSURL *)url
           sessionConfiguration:(nullable NSURLSessionConfiguration *)configuration ;

以上都能处理大部分网络请求,以及UIWebView的请求,除了WKWebView



WKWebView如何使用HTTPDns

NSURLProtocol可以劫持系统所有基于C socket的网络请求。 而WKWebView基于Webkit,并不走底层的C socket,所以NSURLProtocol拦截不了WKWebView中的请求

利用私有 API:

WKBrowsingContextController



registerSchemeForCustomProtocol:

方法,完成NSURLProtocol的注册

- (void)viewDidLoad {
    [super viewDidLoad];
    
    WKWebView *webView = [[WKWebView alloc] initWithFrame:self.view.bounds];
    [self.view addSubview:webView];
    
    NSURLRequest *requset =[NSURLRequest requestWithURL:[NSURL URLWithString:@"http://www.baidu.com"]];
    NSURLSessionConfiguration *config = [NSURLSessionConfiguration defaultSessionConfiguration];

    SEL sel = NSSelectorFromString(@"registerSchemeForCustomProtocol:");
    Class cls = [[webView valueForKey:@"browsingContextController"] class];
    if ([(id)cls respondsToSelector:sel]) {
        // 把 http 和 https 请求交给 NSURLProtocol 处理
        [(id)cls performSelector:sel withObject:@"http"];
        [(id)cls performSelector:sel withObject:@"https"];
    }
    [NSURLProtocol registerClass:[OBURLProtocol class]];
    config.protocolClasses = @[[OBURLProtocol class]];
    
    [webView loadRequest:requset];
}

利用私有 API担心被拒的话,那这些字符串可以不明着写出来,只要运行时算出来就行,比如用 base64 编码,图片资源里藏一段啊,甚至通过服务器下发……既然到了这个程度,苹果的静态扫描就很难发现

替换IP的逻辑

//2:对拦截的request进行处理,修改host
+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request {
 	request = [request mutableCopy];
    if ([request.URL host].length == 0) {
        return request;
    }
    
    //从URL中获取域名
    NSString *originUrlString = [request.URL absoluteString];
    NSString *originHostString = [request.URL host];
    
    NSRange hostRange = [originUrlString rangeOfString:originHostString];
    if (hostRange.location == NSNotFound) {
        return request;
    }
    
    HttpDnsService *httpdns = [HttpDnsService sharedInstance];
    NSString *httpDnsIP = [httpdns getIpByHostAsync:originHostString];
    
    if (httpDnsIP) {
        //替换包头中的url开头的域名
        NSString *urlString = [originUrlString stringByReplacingCharactersInRange:hostRange withString:httpDnsIP];
        NSURL *url = [NSURL URLWithString:urlString];
        request.URL = url;
        
        //注意:若包头的host(key-value中的host)本身就是一个IP,则需要将这个IP替换成域名(该域名需要从referer中获取)
        if ([self isValidIP:originHostString]) {
          //....
        }
        //将从referer中取出的域名,放到请求包头的Host中
        [request setValue:originHostString forHTTPHeaderField:@"Host"];
        
        //设置http的header的cookie
        NSArray *cookiesArray = [[NSHTTPCookieStorage sharedHTTPCookieStorage] cookiesForURL:[NSURL URLWithString:originUrlString]];
        NSDictionary *cookieDict = [NSHTTPCookie requestHeaderFieldsWithCookies:cookiesArray];
        NSString *cookie = [cookieDict objectForKey:@"Cookie"];
        [request setValue:cookie forHTTPHeaderField:@"Cookie"];
    }
    return [request copy];

}

完整代码

//
//  OBURLProtocol.m
//  Test_queue
//
//  Created by uDoctor on 2020/8/11.
//  Copyright © 2020 OB. All rights reserved.
//

#import "OBURLProtocol.h"



@interface OBURLProtocol()<NSURLSessionDataDelegate>
@property (nonatomic, strong)NSURLSessionDataTask *dnstask;
@end

@implementation OBURLProtocol

//1:筛选需要拦截request,需要返回YES;不需要返回NO
+ (BOOL)canInitWithRequest:(NSURLRequest *)request { 
    NSString *scheme = [[request URL] scheme];
    if ( ([scheme caseInsensitiveCompare:@"http"] == NSOrderedSame || [scheme caseInsensitiveCompare:@"https"] == NSOrderedSame)) {
        //看看是否已经处理过了,防止无限循环
        if ([NSURLProtocol propertyForKey:@"doKey" inRequest:request]) {
            return NO;
        }
        //post请求不拦截
        if ([request.HTTPMethod isEqualToString:@"POST"]) {
            return NO;
        }
        return YES;
    }
    return NO;

}

//2:对拦截的request进行处理,修改host
+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request {
    request = [request mutableCopy];
	// 替换IP,修改host
	// code ...
    return [request copy];
}
//3:建立NSURLSesion对象,并发起请求
- (void)startLoading {
    NSMutableURLRequest *mutableReqeust = [[self request] mutableCopy];
    //标识该request已经处理过了,防止无限循环
    [NSURLProtocol setProperty:@YES forKey:@"doKey" inRequest:mutableReqeust];
    
    NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
    NSURLSession *session = [NSURLSession sessionWithConfiguration:configuration delegate:self delegateQueue:nil];
    self.dnstask = [session dataTaskWithRequest:mutableReqeust];
  
//    NSString *referStr = [mutableReqeust valueForHTTPHeaderField:@"Referer"];
//    NSLog(@"start loading httpDNS********************\nstart loading httpDNS \n *****url :%@\n *****host:%@ \n *****referer:%@\n", mutableReqeust.URL, [mutableReqeust valueForHTTPHeaderField:@"Host"], referStr);
    [self.dnstask resume];
}
//4:停止请求
- (void)stopLoading {
    [self.dnstask cancel];
}
//5:第五步:NSURLSessionDataTaskDelegate中,完成请求返回数据的回吐,即将请求返回的数据回吐给client(本来请求的发起者,如UIWebView)https证书的校验
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveResponse:(NSURLResponse *)response completionHandler:(void (^)(NSURLSessionResponseDisposition))completionHandler {
    [[self client] URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageAllowed];
    completionHandler(NSURLSessionResponseAllow);
}

- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data {
    [[self client] URLProtocol:self didLoadData:data];
}

- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error {
    // 请求完成,成功或者失败的处理
    if (!error) {
        //成功
        [self.client URLProtocolDidFinishLoading:self];
    } else {
        //失败
        [self.client URLProtocol:self didFailWithError:error];
    }
}

@end

其中,重新发起网络请求,数据转发的方法一个不能少,里面的内容也不能少,wkwebview性能比UIWebView好的多

- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveResponse:(NSURLResponse *)response completionHandler:(void (^)(NSURLSessionResponseDisposition))completionHandler;

- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data;

- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error;



四、TCP队头阻塞


队头阻塞


与短连接和长连接无关,而是由 HTTP 基本的“请求 – 应答”模型所导致的。

因为 HTTP 规定报文必须是“一发一收”,这就形成了一个先进先出的“串行”队列。队列里的请求没有轻重缓急的优先级,只有入队的先后顺序,排在最前面的请求被最优先处理。如果队首的请求因为处理的太慢耽误了时间,那么队列里后面的所有请求也不得不跟着一起等待。

只要是TCP协议,“队头阻塞”问题在 HTTP/1.1 里无法解决,只能缓解。


  1. 管线化


    HTTP1.1默认支持长连接,connect:keep-live ,多个请求复用一个TCP连接,省去连接断开过程,提高使用效率。管线化技术可以解决请求的队头阻塞,因为请求是同时并发到服务端,但是返回也还是会发生队头阻塞。可以参考google提出的 HTTP3.0中的 quic协议,一种基于UDP的协议,对数据分帧和添加序号
  2. 并发连接

    同时对一个域名发起多个长连接,用数量来解决质量的问题。但这种方式也存在缺陷。如果每个客户端都想自己快,建立很多个连接,服务器的资源有压力。



版权声明:本文为pk_sir原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。