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>
<img src="//mc.vip.qq.com/img/img-1.png?max_age=2592000" alt="">
<!--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
,这两个函数除了对缓存的不同处理外,还有一个非常重要的区别:前者调用了 [selfdispatchProtocolAction:SonicURLProtocolActionDidFinishparam: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框架。
参考文献
-
WebView性能、体验分析与优化
-
URL Loading System