H5很重要,H5很重要,H5很重要,重要的事情要说三遍。VasSonic是腾讯开源的解决H5首屏渲染痛点的开源项目,本文通过解读代码来学习WebView的优化思路。
H5的优劣
H5的优势很明显,跨平台、迭代快、开发体验好。H5的劣势同样明显,加载慢,用户体验差。业内大牛想尽各种方法来弥补H5的劣势,初级使用缓存、预加载等常用方案,高级如Hybrid、ReactNative、Weex等H5的进阶解决方案。VasSonic专注于H5的秒开,使用的也是我们常见的性能优化方案。本文尝试了解VasSonic是如何用常见的手段将性能优化做到极致的。
VasSonic解决什么问题
关于WebView为什么打开慢、加载慢,业界已经有很多分析了,结论也是比较一致的,推荐美团点评技术团队的WebView性能、体验分析与优化,腾讯关于VasSonic的官方文章也有相关说明。
WebView加载慢的问题主要集中在如下三个阶段:
- WebView打开
- 页面资源加载
- 数据更新导致页面刷新
VasSonic的优化都是为了加速上述三个阶段,其经验可以总结为六个方面。
- WebView池:预先初始化WebView
- 静态直出:服务端拉取数据渲染完毕后,通过CDN加速访问
- 离线预推:离线包方案
- 并行加速:WebView的打开和资源的请求并行
- 动态缓存:动态页面缓存在客户端,用户下次打开的时候先打开缓存页面,然后再刷新
- 动静分离:为了提升体验,将页面分为静态模板和动态数据,实现局部刷新
- 预加载:在打开页面之前将资源数据都准备好,提升页面打开的速度
可以说是非常全面了,具体细节可以参考腾讯祭出大招VasSonic,让你的H5页面首屏秒开!。
上述优化的核心技术主要涉及几个方面:
- WebView池
- 缓存设计
- 资源请求和WebView分离设计
- 动静分离设计
下面结合代码来看看VasSonic是如何实现这些优化点的。
准备工作:
从github VasSonic clone最新代码,打开sonic-iOS目录下的SonicSample。
WebView池
UIWebView并不是开源的,想要通过修改源码来提升打开速度是不太现实的。VasSonic采用的方案是预先创建WebView池。在应用启动或者空闲的时候预先创建空的WebView,等真正要用的时候直接从池中获取WebView。
Demo中只是简单的预加载了一次WebView,通过创建空的WebView,可以预先启动Web线程,完成WebView的一些全局性的初始化工作,对二次创建WebView能有数百毫秒的提升。在实际应用中,我们可以采用WebView池的方式来进一步提升打开速度。
//start web thread
UIWebView *webPool = [[UIWebView alloc]initWithFrame:CGRectZero];
[webPool loadHTMLString:@"" baseURL:nil]; // 注意loadHTMLString是必须的
缓存设计
缓存类型
VasSonic将缓存的类型分成了四种,他们分别是模板、页面、数据和配置。
/*
* template
*/
SonicCacheTypeTemplate,
/*
* html
*/
SonicCacheTypeHtml,
/*
* dynamic data
*/
SonicCacheTypeData,
/*
* config
*/
SonicCacheTypeConfig,
将模板和数据分离是实现动静分离的核心技术,模板和数据是从页面数据中自动分离出来的,缓存页面数据的时候,SonicCache会调用splitTemplateAndDataFromHtmlData:
分割模板和数据,代码实现如下:
- (NSDictionary *)splitTemplateAndDataFromHtmlData:(NSString *)html
{
// 使用sonicdiff这个tag来将HTML分割成模板和数据
NSError *error = nil;
NSRegularExpression *reg = [NSRegularExpression regularExpressionWithPattern:@"<!--sonicdiff-?(\\w*)-->([\\s\\S]+?)<!--sonicdiff-?(\\w*)-end-->" options:NSRegularExpressionCaseInsensitive error:&error];
if (error) {
return nil;
}
// 分割出来的数据,以sonicdiff指定的名字key保存到数据字典中
NSArray *metchs = [reg matchesInString:html options:NSMatchingReportCompletion range:NSMakeRange(0, html.length)];
NSMutableDictionary *dataDict = [NSMutableDictionary dictionary];
[metchs enumerateObjectsUsingBlock:^(NSTextCheckingResult *obj, NSUInteger idx, BOOL * _Nonnull stop) {
NSString *matchStr = [html substringWithRange:obj.range];
NSArray *seprateArr = [matchStr componentsSeparatedByString:@"<!--sonicdiff-"];
NSString *itemName = [[[seprateArr lastObject]componentsSeparatedByString:@"-end-->"]firstObject];
NSString *formatKey = [NSString stringWithFormat:@"{%@}",itemName];
[dataDict setObject:matchStr forKey:formatKey];
}];
// 分割出来的模板,用key来替换动态数据的位置
NSMutableString *mResult = [NSMutableString stringWithString:html];
[dataDict enumerateKeysAndObjectsUsingBlock:^(NSString *key, NSString *value, BOOL * _Nonnull stop) {
[mResult replaceOccurrencesOfString:value withString:key options:NSCaseInsensitiveSearch range:NSMakeRange(0, mResult.length)];
}];
//if split HTML faild , we can return nothing ,it is not a validat sonic request.
if (dataDict.count == 0 || mResult.length == 0) {
return nil;
}
return @{@"data":dataDict,@"temp":mResult};
}
还是以Demo为例看split的结果。
// 原始页面数据
<span id="data1Content">
<!--sonicdiff-data1-->
<p>示例:</p>
![](//mc.vip.qq.com/img/img-1.png?max_age=2592000)
<!--sonicdiff-data1-end-->
</span>
// 分离之后的结果
// --模板
<span id="data1Content">
{data1}
</span>
// --数据
{
"{data1}" = "<!--sonicdiff-data1-->
\n <p>\U793a\U4f8b\Uff1a</p>
\n <img src=\"//mc.vip.qq.com/img/img-1.png?max_age=2592000\" alt=\"\">
\n <!--sonicdiff-data1-end-->";
}
除了页面、模板、数据类型的缓存外,还有一个非常重要的缓存是config。先看下config的生成。
- (NSDictionary *)createConfigFromResponseHeaders:(NSDictionary *)headers
{
//Etag,template-tag
NSString *eTag = headers[@"Etag"];
NSString *templateTag = headers[@"template-tag"];
NSString *csp = headers[SonicHeaderKeyCSPHeader];
NSTimeInterval timeNow = (long)[[NSDate date ]timeIntervalSince1970]*1000;
NSString *localRefresh = [@(timeNow) stringValue];
//save configs
eTag = eTag.length > 0? eTag:@"";
templateTag = templateTag.length > 0? templateTag:@"";
eTag = eTag.length > 0? eTag:@"";
csp = csp.length > 0? csp:@"";
NSDictionary *cfgDict = @{
SonicHeaderKeyETag:eTag,
SonicHeaderKeyTemplate:templateTag,
kSonicLocalRefreshTime:localRefresh,
kSonicCSP:csp
};
return cfgDict;
}
ETag大家应该是比较清楚的,在HTTP的缓存设计中有重要作用,当服务端发现客户端请求带的资源的ETag和服务端一样的话,就不会返回完整的资源内容了,节省时间和带宽,templateTag也是类似的,当templateTag不一样的时候,服务端才会更新模板。
简而言之,Config就是保存了这次请求头中的一些重要信息,留待下次请求的时候发还给服务端做优化。
缓存Key
说完缓存类型,必须要说一下缓存的key,这个非常重要。首次请求会调用saveFirstWithHtmlData:withResponseHeaders:withUrl
缓存数据。入参有htmlData、header和url,前面已经分析htmlData是需要缓存的页面数据,htmlData会被存成html、template和dynamicData三种类型,headers前面也提到了是缓存成config,那这个url的作用就是生成缓存的key。
- (SonicCacheItem *)saveFirstWithHtmlData:(NSData *)htmlData
withResponseHeaders:(NSDictionary *)headers
withUrl:(NSString *)url
{
NSString *sessionID = sonicSessionID(url);
if (!htmlData || headers.count == 0 || sessionID.length == 0) {
return nil;
}
SonicCacheItem *cacheItem = [self cacheForSession:sessionID];
......
}
首先根据url生成sessionID,然后再将sessionID和特定的SonicCacheItem
实例绑定。这里我们先说明每个固定url生成的sessionID是一样的,这才能让我们在相同的url请求的情况下使用缓存,具体的url生成sessionID的规则在SonicSession
章节详细说明。
SonicCacheItem
每个缓存Key,也就是根据url生成的sessionID都会对应一个SonicCacheItem的实例,用来缓存所有的数据。SonicCacheItem也就是一个缓存的数据结构,包含htmlData、templateString、dynamicData、diffData等等。
/**
* Memory cache item.
*/
@interface SonicCacheItem : NSObject
/** Html. */
@property (nonatomic,retain)NSData *htmlData;
/** Config. */
@property (nonatomic,retain)NSDictionary *config;
/** Session. */
@property (nonatomic,readonly)NSString *sessionID;
/** Template string. */
@property (nonatomic,copy) NSString *templateString;
/** Generated by local dynamic data and server dynamic data. */
@property (nonatomic,retain)NSDictionary *diffData;
/** Sonic divide HTML to tepmlate and dynamic data. */
@property (nonatomic,retain)NSDictionary *dynamicData;
/** Is there file cache exist. */
@property (nonatomic,readonly)BOOL hasLocalCache;
/** Last refresh time. */
@property (nonatomic,readonly)NSString *lastRefreshTime;
/** Cache some header fields which will be used later. */
@property (nonatomic,readonly)NSDictionary *cacheResponseHeaders;
/** Initialize an item with session id. */
- (instancetype)initWithSessionID:(NSString *)aSessionID;
@end
SonicSession
讲缓存的时候我们提到过作为缓存Key的sessionID,每个sessionID关联了一个缓存对象SonicCacheItem,同时也关联了一次URL请求,VasSonic将这个请求抽象为SonicSession。SonicSession在VasSonic的设计里面非常关键。其将资源的请求和WebView脱离开来,有了SonicSession,结合SonicCache,我们就可以不依赖WebView去做资源的请求,这样就可以实现WebView打开和资源加载并行、资源预加载等加速方案。
SessionID
每个sessionID唯一指定了一个SonicSession,sessionID的生成规则如下:
NSString *sonicSessionID(NSString *url)
{
if ([[SonicClient sharedClient].currentUserUniq length] > 0) {
return stringFromMD5([NSString stringWithFormat:@"%@_%@",[SonicClient sharedClient].currentUserUniq,sonicUrl(url)]);
}else{
return stringFromMD5([NSString stringWithFormat:@"%@",sonicUrl(url)]);
}
}
每个url都能唯一的确定一个sessionID,需要注意的是,算md5的时候并不是直接拿请求的url来算的,而是先经过了sonicUrl
的函数的处理。理解sonicUrl
对url的处理有助于我们了解VasSonic的session管理机制。
其实sonicUrl
做的事情比较简单。
- 对于一般的url来说,
sonicUrl
会只保留scheme、host和path,url其他部分的改变不会创建新的session - 新增了
sonic_remain_params
参数,sonic_remain_params
里面指定的query参数不同会创建新的session。
举栗说明:
// output: @"https://www.example.com"
sonicUrl(@"https://www.example.com")
// output: @"https://www.example.com"
sonicUrl(@"https://www.example.com:8080")
// output: @"https://www.example.com"
sonicUrl(@"https://www.example.com/?foo=foo")
// output: @"https://www.example.com/path"
sonicUrl(@"https://www.example.com/path?foo=foo")
// output @"https://www.example.com/path/foo=foo&"
sonicUrl(@"https://www.example.com/path?foo=foo&bar=bar&sonic_remain_params=foo")
sonicUrl
的代码也比较简单,这里就不贴了,有兴趣的同学可以参考这里sonicUrl实现。
自定义请求头
之前提到过SonicCache的一种缓存类型是Config,SonicSession在初始化时候会根据缓存的Config更新请求头,以便服务端根据这些信息做相应的优化。
- (void)setupData
{
// 根据sessionID获取缓存内容
SonicCacheItem *cacheItem = [[SonicCache shareCache] cacheForSession:_sessionID];
self.isFirstLoad = cacheItem.hasLocalCache;
if (!cacheItem.hasLocalCache) {
self.cacheFileData = cacheItem.htmlData;
self.cacheConfigHeaders = cacheItem.config;
self.cacheResponseHeaders = cacheItem.cacheResponseHeaders;
self.localRefreshTime = cacheItem.lastRefreshTime;
}
[self setupConfigRequestHeaders];
}
- (void)setupConfigRequestHeaders
{
NSMutableDictionary *mCfgDict = [NSMutableDictionary dictionaryWithDictionary:self.request.allHTTPHeaderFields];
// 根据缓存设置Etag、templateTag等
NSDictionary *cfgDict = [self getRequestParamsFromConfigHeaders];
if (cfgDict) {
[mCfgDict addEntriesFromDictionary:cfgDict];
}
// 添加一些自定义的缓存头
[mCfgDict setObject:@"true" forKey:@"accept-diff"];
[mCfgDict setObject:@"true" forKey:@"no-Chunked"];
[mCfgDict setObject:@"GET" forKey:@"method"];
[mCfgDict setObject:@"utf-8" forKey:@"accept-Encoding"];
[mCfgDict setObject:@"zh-CN,zh;" forKey:@"accept-Language"];
[mCfgDict setObject:@"gzip" forKey:@"accept-Encoding"];
[mCfgDict setObject:SonicHeaderValueSDKVersion forKey:SonicHeaderKeySDKVersion];
[mCfgDict setObject:SonicHeaderValueSonicLoad forKey:SonicHeaderKeyLoadType];
// 可以自定义UA,方便app判断
NSString *userAgent = [SonicClient sharedClient].userAgent.length > 0? [SonicClient sharedClient].userAgent:[[SonicClient sharedClient] sonicDefaultUserAgent];
[mCfgDict setObject:userAgent forKey:@"User-Agent"];
NSURL *cUrl = [NSURL URLWithString:self.url];
// 替换域名为ip,免去dns解析的耗时
if (self.serverIP.length > 0) {
NSString *host = [cUrl.scheme isEqualToString:@"https"]? [NSString stringWithFormat:@"%@:443",self.serverIP]:[NSString stringWithFormat:@"%@:80",self.serverIP];
NSString *newUrl = [self.url stringByReplacingOccurrencesOfString:cUrl.host withString:host];
cUrl = [NSURL URLWithString:newUrl];
[mCfgDict setObject:cUrl.host forKey:@"Host"];
}
[self.request setAllHTTPHeaderFields:mCfgDict];
}
- (NSDictionary *)getRequestParamsFromConfigHeaders
{
NSDictionary *cfgDict = self.cacheConfigHeaders;
NSMutableDictionary *mCfgDict = [NSMutableDictionary dictionary];
if (cfgDict) {
// 设置eTag信息
NSString *eTag = cfgDict[SonicHeaderKeyETag];
if (eTag.length > 0) {
[mCfgDict setObject:eTag forKey:@"If-None-Match"];
}
// 设置templateTag信息
NSString *tempTag = cfgDict[SonicHeaderKeyTemplate];
if (tempTag.length > 0 ) {
[mCfgDict setObject:tempTag forKey:@"template-tag"];
}
}else{
[mCfgDict setObject:@"" forKey:@"If-None-Match"];
[mCfgDict setObject:@"" forKey:@"template-tag"];
}
return mCfgDict;
}
除了会添加自定义的请求头参数,以及将缓存的config加到请求头里面外,在每次发起请求之前,都会同步cookies,这样就可以保持状态了,比如登陆状态等等。
- (void)start
{
dispatchToMain(^{
if (self.delegate && [self.delegate respondsToSelector:@selector(sessionWillRequest:)]) {
[self.delegate sessionWillRequest:self];
}
[self syncCookies];
});
[self requestStartInOperation];
}
- (void)syncCookies
{
NSURL *cUrl = [NSURL URLWithString:self.url];
// 从系统cookies中读取cookies信息,并添加到自定义请求头
NSHTTPCookieStorage *sharedHTTPCookieStorage = [NSHTTPCookieStorage sharedHTTPCookieStorage];
NSArray *cookies = [sharedHTTPCookieStorage cookiesForURL:cUrl];
NSDictionary *cookieHeader = [NSHTTPCookie requestHeaderFieldsWithCookies:cookies];
[self addCustomRequestHeaders:cookieHeader];
}
做了上面这些工作,我们可以抓包看最终一个请求会长成什么样子。通过对Demo中LOAD WITH SONIC抓包发现请求头中带了sonic-load-type、template-tag、sonic-sdk-version等等,服务端正是基于这些参数做了优化。
GET /demo/indexv3 HTTP/1.1
Host: mc.vip.qq.com
accept-diff: true
Accept: */*
sonic-load-type: __SONIC_HEADER_VALUE_SONIC_LOAD__
template-tag: 37141a61d0497851179bc4f27867290921e1367e
Accept-Encoding: gzip
If-None-Match: 9a498fe9148d127c8ebd970ebac425ba6e6532b3
Accept-Language: zh-CN,zh;
no-Chunked: true
User-Agent: Mozilla/5.0 (iPhone; U; CPU iPhone OS 2_2 like Mac OS X;en-us) AppleWebKit/525.181 (KHTML, like Gecko) Version/3.1.1 Mobile/5H11 Safari/525.20
sonic-sdk-version: Sonic/1.0
Connection: keep-alive
Cookie: dataImg=1; templateFlag=1
method: GET
网络连接
VasSonic默认提供了基于URLSession的SonicConnection
来发起请求和处理响应。SonicConnection
做的事情并不多,主要实现了两个接口,并提供SonicSessionProtocol
定义的网络回调接口供session处理。
- (void)startLoading; // 开始请求
- (void)stopLoading; // 取消请求
// SonicSessionProtocol
// 收到响应的时候回调
- (void)session:(SonicSession *)session didRecieveResponse:(NSHTTPURLResponse *)response;
// 加载数据之后回调
- (void)session:(SonicSession *)session didLoadData:(NSData *)data;
// 连接错误的时候回调
- (void)session:(SonicSession *)session didFaild:(NSError *)error;
// 结束加载的时候回调
- (void)sessionDidFinish:(SonicSession *)session;
如果需要在发起请求和处理响应阶段做一些自定义的动作的话,比如实现离线包方案等等,就可以自定义继承于SonicConnection的Connection对象,在回调SonicSessionProtocol
方法之前做些处理。
注册自定义的Connection对象使用如下的方法,可以同时注册多个,通过实现canInitWithRequest:
来决定使用哪个Connection。
+ (BOOL)registerSonicConnection:(Class)connectionClass;
+ (void)unregisterSonicConnection:(Class)connectionClass;
值得注意的是,SonicConnection的所有接口设计都类似NSURLProtocol协议,但他并不继承自NSURLProtocol
,原因在本文最后WebView请求拦截部分会有提到。
缓存处理
SonicSession根据请求响应头中cache-offline
返回的存储策略的不一样会有不同的处理,Sonic定义了如下几种离线存储的策略。
/**
* 存储但不刷新页面
*/
#define SonicHeaderValueCacheOfflineStore @"store"
/**
* 存储而且刷新页面
*/
#define SonicHeaderValueCacheOfflineStoreRefresh @"true"
/**
* 不存储但刷新页面
*/
#define SonicHeaderValueCacheOfflineRefresh @"false"
/**
* Sonic模式关闭,并在接下来6个小时内不再使用
*/
#define SonicHeaderValueCacheOfflineDisable @"http"
当SonicSession在发起请求之后需要处理本地有缓存和没有缓存两种情况。
没有缓存的情况
没有缓存,首次加载的情况下根据策略的处理方式也比较简单,没啥好说的,直接上代码。
- (void)firstLoadDidFinish
{
......
if ([policy isEqualToString:SonicHeaderValueCacheOfflineDisable]) {
[[SonicCache shareCache] saveServerDisableSonicTimeNow:self.sessionID];
self.isDataUpdated = YES;
break;
}
if ([policy isEqualToString:SonicHeaderValueCacheOfflineStoreRefresh] || [policy isEqualToString:SonicHeaderValueCacheOfflineStore] || [policy isEqualToString:SonicHeaderValueCacheOfflineRefresh]) {
SonicCacheItem *cacheItem = [[SonicCache shareCache] saveFirstWithHtmlData:self.responseData withResponseHeaders:self.response.allHeaderFields withUrl:self.url];
if (cacheItem) {
self.localRefreshTime = cacheItem.lastRefreshTime;
self.sonicStatusCode = SonicStatusCodeFirstLoad;
self.sonicStatusFinalCode = SonicStatusCodeFirstLoad;
}
if ([policy isEqualToString:SonicHeaderValueCacheOfflineRefresh]) {
[[SonicCache shareCache] removeCacheBySessionID:self.sessionID];
}
[[SonicCache shareCache] removeServerDisableSonic:self.sessionID];
}
......
}
有缓存的情况
有缓存的情况相对来说要复杂一些,需要处理模板更新和数据更新两种不同的情况。
- (void)updateDidSuccess
{
......
// 处理模板更新的情况,模板更新是大动作,跟首次加载已经区别不大,模板更新一定会导致数据更新
if ([self isTemplateChange]) {
self.cacheFileData = self.responseData;
[self dealWithTemplateChange];
// 模板不变,数据更新
}else{
[self dealWithDataUpdate];
}
// 处理其他离线缓存策略
NSString *policy = [self responseHeaderValueByIgnoreCaseKey:SonicHeaderKeyCacheOffline];
if ([policy isEqualToString:SonicHeaderValueCacheOfflineStore] || [policy isEqualToString:SonicHeaderValueCacheOfflineStoreRefresh] || [policy isEqualToString:SonicHeaderValueCacheOfflineRefresh]) {
[[SonicCache shareCache] removeServerDisableSonic:self.sessionID];
}
if ([policy isEqualToString:SonicHeaderValueCacheOfflineRefresh] || [policy isEqualToString:SonicHeaderValueCacheOfflineDisable]) {
if ([policy isEqualToString:SonicHeaderValueCacheOfflineRefresh]) {
[[SonicCache shareCache]removeCacheBySessionID:self.sessionID];
}
if ([policy isEqualToString:SonicHeaderValueCacheOfflineDisable]) {
[[SonicCache shareCache] saveServerDisableSonicTimeNow:self.sessionID];
}
}
......
}
模板变化是直接调用了saveFirstWithHtmlData:withResponseHeaders:withUrl:
来更新缓存,可见模板变化会导致之前的缓存都失效。
- (void)dealWithTemplateChange
{
SonicCacheItem *cacheItem = [[SonicCache shareCache] saveFirstWithHtmlData:self.responseData withResponseHeaders:self.response.allHeaderFields withUrl:self.url];
......
}
数据变化则是调用updateWithJsonData:withResponseHeaders:withUrl:
来更新缓存,该函数会将本地的缓存和服务端返回的数据做个diff,然后返回给前端更新界面。
- (void)dealWithDataUpdate
{
SonicCacheItem *cacheItem = [[SonicCache shareCache] updateWithJsonData:self.responseData withResponseHeaders:self.response.allHeaderFields withUrl:self.url];
......
}
拦截WebView请求
现在SonicSession结合SonicCache能独立高效处理URL请求,那么如何使用SonicSession来接管WebView的请求呢?iOS下所有的URL请求都是走URL Loading System的,拦截WebView的请求只需要自定义实现NSURLProtocol
协议就可以了。
因为NSURLProtocol会拦截所有的请求,那如何只针对Sonic WebView发起的请求实现拦截呢?可以通过canInitWithRequest:
来实现,只有请求头中带SonicHeaderValueWebviewLoad
的才会被拦截。
+ (BOOL)canInitWithRequest:(NSURLRequest *)request
{
NSString *value = [request.allHTTPHeaderFields objectForKey:SonicHeaderKeyLoadType];
if (value.length == 0) {
return NO;
}
if ([value isEqualToString:SonicHeaderValueSonicLoad]) {
return NO;
}else if([value isEqualToString:SonicHeaderValueWebviewLoad]) {
return YES;
}
return NO;
}
当系统发起请求的时候,Sonic并没有真正的发起请求,而是用SessionID注册了回调,让SonicSession在恰当的时候调动回调。
- (void)startLoading
{
NSThread *currentThread = [NSThread currentThread];
NSString *sessionID = [self.request valueForHTTPHeaderField:SonicHeaderKeySessionID];
__weak typeof(self) weakSelf = self;
// 在SonicSession中注册回调函数
[[SonicClient sharedClient] registerURLProtocolCallBackWithSessionID:sessionID completion:^(NSDictionary *param) {
[weakSelf performSelector:@selector(callClientActionWithParams:) onThread:currentThread withObject:param waitUntilDone:NO];
}];
}
接下来我们看看SonicSession都是在什么时机调用回调函数的,首次加载、预加载和完全缓存状态是不一样的。
首次加载的时候,根据网络的实际回调时机调用即可,代码如下:
- (void)firstLoadRecieveResponse:(NSHTTPURLResponse *)response
{
[self dispatchProtocolAction:SonicURLProtocolActionRecvResponse param:response];
}
- (void)firstLoadDidLoadData:(NSData *)data
{
[self dispatchProtocolAction:SonicURLProtocolActionLoadData param:data];
}
- (void)firstLoadDidFaild:(NSError *)error
{
[self dispatchProtocolAction:SonicURLProtocolActionDidFaild param:error];
......
}
- (void)firstLoadDidFinish
{
[self dispatchProtocolAction:SonicURLProtocolActionDidFinish param:nil];
......
}
有预加载的情况下,根据预加载的情况构造需要回调的动作,代码如下:
- (NSArray *)preloadRequestActions
{
NSMutableArray *actionItems = [NSMutableArray array];
if (self.response) {
NSDictionary *respItem = [self protocolActionItem:SonicURLProtocolActionRecvResponse param:self.response];
[actionItems addObject:respItem];
}
if (self.isCompletion) {
if (self.error) {
NSDictionary *failItem = [self protocolActionItem:SonicURLProtocolActionDidFaild param:self.error];
[actionItems addObject:failItem];
}else{
if (self.responseData.length > 0) {
NSData *recvCopyData = [[self.responseData copy]autorelease];
NSDictionary *recvItem = [self protocolActionItem:SonicURLProtocolActionLoadData param:recvCopyData];
[actionItems addObject:recvItem];
}
NSDictionary *finishItem = [self protocolActionItem:SonicURLProtocolActionDidFinish param:nil];
[actionItems addObject:finishItem];
}
}else{
if (self.responseData.length > 0) {
NSData *recvCopyData = [[self.responseData copy]autorelease];
NSDictionary *recvItem = [self protocolActionItem:SonicURLProtocolActionLoadData param:recvCopyData];
[actionItems addObject:recvItem];
}
}
return actionItems;
}
完全缓存的情况下,构造完整的回调动作,代码如下:
- (NSArray *)cacheFileActions
{
NSMutableArray *actionItems = [NSMutableArray array];
NSHTTPURLResponse *response = nil;
if (self.response && [self isCompletionWithOutError] && self.isDataUpdated) {
response = self.response;
}else{
NSDictionary *respHeader = self.cacheResponseHeaders;
response = [[[NSHTTPURLResponse alloc] initWithURL:[NSURL URLWithString:self.url] statusCode:200 HTTPVersion:@"1.1" headerFields:respHeader]autorelease];
}
NSMutableData *cacheData = [[self.cacheFileData mutableCopy] autorelease];
NSDictionary *respItem = [self protocolActionItem:SonicURLProtocolActionRecvResponse param:response];
NSDictionary *dataItem = [self protocolActionItem:SonicURLProtocolActionLoadData param:cacheData];
NSDictionary *finishItem = [self protocolActionItem:SonicURLProtocolActionDidFinish param:nil];
[actionItems addObject:respItem];
[actionItems addObject:dataItem];
[actionItems addObject:finishItem];
self.didFinishCacheRead = YES;
return actionItems;
}
这样业务使用者只需要正常的实现UIWebViewDelegate
的协议就可以了,不需要关心回调是来自真正的网络连接、还是来自预加载,或者是完全的缓存,所有的缓存优化就都能被封装在SonicSession里面了。
这里有一点需要说明的是SonicURLProtocol和SonicConnection是不一样的,虽然SonicConnection模仿了NSURLProtocol的接口,但是其父类是NSObject。SonicURLProtocol最大的功能是实现WebView的请求拦截,而SonicConnection则是SonicSession的网络请求处理类。
页面刷新
经过上面的描述,我们基本已经将整个流程都串起来了。
WebView发起请求 -> SonicURLProtocol实现请求拦截,将控制权交给SonicSession
-> SonicSession根据SessionID获取请求结果,回调请求过程,请求结果可能来自缓存(SonicCache),也可能来自网络请求(SonicConnection)
-> WebView根据结果展示页面
整个流程最后的WebView页面展示,也是非常重要的一块优化。
- (void)sessionDidFinish:(SonicSession *)session
{
dispatch_block_t opBlock = ^{
self.isCompletion = YES;
if (self.isFirstLoad) {
[self firstLoadDidFinish];
}else{
[self updateDidSuccess];
}
};
dispatchToSonicSessionQueue(opBlock);
}
当请求结束的时候,SonicSession会根据是否是首次加载分别调用firstLoadDidFinish
和updateDidSuccess
,这两个函数除了对缓存的不同处理外,还有一个非常重要的区别:前者调用了[self dispatchProtocolAction:SonicURLProtocolActionDidFinish param:nil];
,后者则不会。也就是说前者会将请求结束的结果告诉WebView,而后者不会,导致的结果就是前者会刷新页面,而后者不会。但是updateDidSuccess
中有这么一段代码。
- (void)updateDidSuccess
{
......
// 如果js注册了数据刷新的回调,就调用该回调
if (self.webviewCallBack) {
NSDictionary *resultDict = [self sonicDiffResult];
if (resultDict) {
self.webviewCallBack(resultDict);
}
}
......
}
如果有webviewCallBack
,那么这个回调是会被调用的,参数是经过diff之后的数据,看到这里应该同学都明白了,这就是局部刷新的实现机制。
Sonic给JS暴露一个方法叫getDiffDataCallback
,JS只要设置该回调,最终就是设置了self.webViewCallBack
。
JSExportAs(getDiffData,
- (void)getDiffData:(NSDictionary *)option withCallBack:(JSValue *)jscallback
);
- (void)getDiffData:(NSDictionary *)option withCallBack:(JSValue *)jscallback
{
JSValue *callback = self.owner.jscontext.globalObject;
[[SonicClient sharedClient] sonicUpdateDiffDataByWebDelegate:self.owner completion:^(NSDictionary *result) {
if (result) {
NSData *json = [NSJSONSerialization dataWithJSONObject:result options:NSJSONWritingPrettyPrinted error:nil];
NSString *jsonStr = [[NSString alloc]initWithData:json encoding:NSUTF8StringEncoding];
[callback invokeMethod:@"getDiffDataCallback" withArguments:@[jsonStr]];
}
}];
}
这部分的js相关实现在sonic.js中,有兴趣的同学可以自行翻看js源码。Demo中的更新逻辑如下:
//0-状态获取失败 1-sonic首次 2-页面刷新 3-局部刷新 4-完全cache
sonic.getSonicData(function(sonicStatus, reportSonicStatus, sonicUpdateData){
if(sonicStatus == 1){
//首次没有特殊的逻辑处理,直接执行sonic完成后的逻辑,比如上报等
}else if(sonicStatus == 2){
}else if(sonicStatus == 3){
//局部刷新的时候需要更新页面的数据块和一些JS操作
var html = '';
var id = '';
var elementObj = '';
for(var key in sonicUpdateData){
id = key.substring(1,key.length-1);
html = sonicUpdateData[key];
elementObj = document.getElementById(id+'Content');
elementObj.innerHTML = html;
}
}else if(sonicStatus == 4){
}
afterInit(reportSonicStatus);
});
结论
总结来看VasSonic并不是与众不同的新技术,但是其对HTML、客户端WebView有着深入的了解,通过司空见惯的一些技术的极致搭配和使用,极大的提升了WebView的性能。仔细研究SonicSession和SonicCache的实现对于了解VasSonic的设计思想非常重要。最后感谢腾讯团队给开源界带来这么优秀的WebView框架。