iOS网络优化主要解决两点:
- 服务器压力
- 客户端网络优化、增加用户体验
优化的方向主要是:
- NSURLCache缓存、Last-Modified、ETag
- DNS解析
- 数据压缩:protobuf,WebP
- TCP对头阻塞
一、NSURLCache缓存和Last-Modified、ETag
NSURLCache可以完成大部分的缓存需求,NSURLCache使用前提
- 只能作用于get请求
- 设置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 无法解决的一些问题。
- 一些文件也许会周期性的更改,但是他的内容并不改变(仅仅改变的修改时间),这个时候我们并不希望客户端认为这个文件被修改了,而重新GET;
- 某些文件修改非常频繁,比如在秒以下的时间内进行修改,(比方说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");
需要注意的几点:
- host,拦截请求,在header中添加host域名
- 使用代理服务器时,httpDNS会失效:因为代理服务器会转发host中的域名,相当于没有效果
- cookie:如果使用了cookie,需要注意里面的域名是否是IP还是域名,如果用的是 URL 中的 host,也需要进行替换。
-
只能拦截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 里无法解决,只能缓解。
-
管线化
HTTP1.1默认支持长连接,connect:keep-live ,多个请求复用一个TCP连接,省去连接断开过程,提高使用效率。管线化技术可以解决请求的队头阻塞,因为请求是同时并发到服务端,但是返回也还是会发生队头阻塞。可以参考google提出的 HTTP3.0中的 quic协议,一种基于UDP的协议,对数据分帧和添加序号 -
并发连接
同时对一个域名发起多个长连接,用数量来解决质量的问题。但这种方式也存在缺陷。如果每个客户端都想自己快,建立很多个连接,服务器的资源有压力。