爲了快遞迭代、更新,公司app有一大模塊功能使用H5實現,可是體驗比原生差,這就衍生了如何提升H5加載速度,優化體驗的問題。此文,記錄一下本身的心路歷程。css
騰訊bugly發表的一篇文章《移動端本地 H5 秒開方案探索與實現》中分析,H5體驗糟糕,是由於它作了不少事:html
初始化 webview -> 請求頁面 -> 下載數據 -> 解析HTML -> 請求 js/css 資源 -> dom 渲染 -> 解析 JS 執行 -> JS 請求數據 -> 解析渲染 -> 下載渲染圖片前端
通常頁面在 dom 渲染後才能展現,能夠發現,H5 首屏渲染白屏問題的緣由關鍵在於,如何優化減小從請求下載頁面到渲染之間這段時間的耗時。 因此,減小網絡請求,採用加載離線資源加載方案來作優化。ios
使用公司的CDN實現離線包的分發,在對象存儲中放置離線包文件和一個額外的 info.json 文件(例如:https://xxx/statics/info.json):web
{
"version":"4320573858a8fa3567a1",
"files": [
"https://xxx/index.html",
"https://xxx/logo.add928b525.png",
"https://xxx/main.c609e010f4.js",
"https://xxx/vender.821f3aa0d2e606967ad3.css",
"https://xxx/manifest.json"
]
}
複製代碼
其中,app存儲當次的version,當下次請求時version變化,就說明資源有更新,需更新下載。json
一、獲取CDN和沙盒中資源:api
NSMutableArray *cdnFileNameArray = [NSMutableArray array];
//todo 獲取CDN資源
NSArray *localExistAarry = [[NSFileManager defaultManager] contentsOfDirectoryAtPath:dirPath error:nil];
複製代碼
二、本地沙盒有但cdn上沒有的資源文件,須要刪除,以防文件越積越多:bash
//過濾刪除操做
NSPredicate *predicate = [NSPredicate predicateWithFormat:@"NOT (SELF IN %@)", cdnFileNameArray];
NSArray *filter = [localExistAarry filteredArrayUsingPredicate:predicate];
if (filter.count > 0) {
[filter enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
NSString *toDeletePath = [dirPath stringByAppendingPathComponent:obj];
if ([fileManager fileExistsAtPath:toDeletePath]) {
[fileManager removeItemAtPath:toDeletePath error:nil];
}
}];
}
複製代碼
三、 已經下載過的文件跳過,不須要從新下載浪費資源;網絡
四、下載有變化的資源文件,存儲至對應的沙盒文件夾中:session
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:cssUrl]];
request.timeoutInterval = 60.0;
request.HTTPMethod = @"POST";
NSURLSession *session = [NSURLSession sharedSession];
NSURLSessionDownloadTask *downLoadTask = [session downloadTaskWithRequest:request completionHandler:^(NSURL * _Nullable location, NSURLResponse * _Nullable response, NSError * _Nullable error) {
if (!location) {
return ;
}
// 文件移動到documnet路徑中
NSError *saveError;
NSURL *saveURL = [NSURL fileURLWithPath:[dirPath stringByAppendingPathComponent:fileName]];
[[NSFileManager defaultManager] moveItemAtURL:location toURL:saveURL error:&saveError];
}];
[downLoadTask resume];
複製代碼
注:若是是zip包,還須要解壓處理。
公司的項目從 UIWebView 遷移到了 WKWebView。WKWebView性能更優,佔用內存更少。
對H5請求進行攔截並加載本地資源,天然想到NSURLProtocol這個神器了。
NSURLProtocol能攔截全部當前app下的網絡請求,而且能自定義地進行處理。使用時要建立一個繼承NSURLProtocol的子類,不該該直接實例化一個NSURLProtocol。
+ (BOOL)canInitWithRequest:(NSURLRequest *)request
判斷當前protocol是否要對這個request進行處理(全部的網絡請求都會走到這裏)。
+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request
可選方法,對於須要修改請求頭的請求在該方法中修改,通常直接返回request便可。
- (void)startLoading
重點是這個方法,攔截請求後在此處理加載本地的資源並返回給webview。
- (void)startLoading
{
//標示該request已經處理過了,防止無限循環
[NSURLProtocol setProperty:@YES forKey:URLProtocolHandledKey inRequest:self.request];
NSData *data = [NSData dataWithContentsOfFile:filePath];
NSURLResponse *response = [[NSURLResponse alloc] initWithURL:self.request.URL
MIMEType:mimeType
expectedContentLength:data.length
textEncodingName:nil];
//硬編碼 開始嵌入本地資源到web中
[[self client] URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed];
[[self client] URLProtocol:self didLoadData:data];
[[self client] URLProtocolDidFinishLoading:self];
}
複製代碼
- (void)stopLoading
對於攔截的請求,NSURLProtocol對象在中止加載時調用該方法。
[NSURLProtocol registerClass:[NSURLProtocolCustom class]];
其中NSURLProtocolCustom就是繼承NSURLProtocol的子類。
可是開發時發現NSURLProtocol核心的幾個方法並不執行,難道WKWebview不支持NSURLProtocol?
原來因爲網絡請求是在非主進程裏發起,因此 NSURLProtocol 沒法攔截到網絡請求。除非使用私有API來實現。使用WKBrowsingContextController和registerSchemeForCustomProtocol。 經過反射的方式拿到了私有的 class/selector。經過把註冊把 http 和 https 請求交給 NSURLProtocol 處理。
Class cls = NSClassFromString(@"WKBrowsingContextController");
SEL sel = NSSelectorFromString(@"registerSchemeForCustomProtocol:");
if ([(id)cls respondsToSelector:sel]) {
// 把 http 和 https 請求交給 NSURLProtocol 處理
[(id)cls performSelector:sel withObject:@"http"];
[(id)cls performSelector:sel withObject:@"https"];
}
// 這下 NSURLProtocolCustom 就能夠用啦
[NSURLProtocol registerClass:[NSURLProtocolCustom class]];
複製代碼
畢竟使用蘋果私有api,這是在玩火呀。這篇文章《讓 WKWebView 支持 NSURLProtocol》有很好的說明。好比我使用私有api字串拆分,運行時在組合,繞過審覈。還能夠對字符串加解密等等。。。
經過以上處理,能夠正常攔截處理,可是又發現攔截不了post請求(攔截到的post請求body體爲空),即便在canInitWithRequest:方法中設置對於POST請求的request不處理也不能解決問題。內流。。。
經瞭解,算是 WebKit 的一個缺陷吧。首先 WebKit 進程是獨立於 app 進程以外的,兩個進程之間使用消息隊列的方式進行進程間通訊。好比 app 想使用 WKWebView 加載一個請求,就要把請求的參數打包成一個 Message,而後經過 IPC 把 Message 交給 WebKit 去加載,反過來 WebKit 的請求想傳到 app 進程的話(好比 URLProtocol ),也要打包成 Message 走 IPC。出於性能的緣由,打包的時候 HTTPBody 和 HTTPBodyStream 這兩個字段被丟棄掉了,這個能夠參考 WebKit 的源碼,這就致使 -[WKWebView loadRequest:] 傳出的 HTTPBody 和 NSURLProtocol 傳回的 HTTPBody 全都被丟棄掉了。 因此若是經過 NSURLProtocol 註冊攔截 http scheme,那麼由 WebKit 發起的全部 http POST 請求就全都無效了,這個從原理上就是無解的。
固然網上也出現一些解決方案,可是本人嘗試沒有成功。同時攔截後對ATS支持很差。再結合又使用了蘋果私有API有被拒風險,最終決定棄用NSURLProtocol攔截的方案。
iOS 11上, WebKit 團隊終於開放了WKWebView加載自定義資源的API:WKURLSchemeHandler。
根據 Apple 官方統計結果,目前iOS 11及以上的用戶佔比達95%。又結合本身公司的業務特性和麪向的用戶,決定使用WKURLSchemeHandler來實現攔截,而iOS 11之前的不作處理。
着手前,要與前端統一URL-Scheme,如:customScheme,資源定義成customScheme://xxx/path/xxxx.css
。native端使用時,先註冊customScheme,WKWebView請求加載網頁,遇到customScheme的資源,就會被hock住,而後使用本地已下載好的資源進行加載。
客戶端使用直接上代碼:
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
WKWebViewConfiguration *configuration = [WKWebViewConfiguration new];
//設置URLSchemeHandler來處理特定URLScheme的請求,URLSchemeHandler須要實現WKURLSchemeHandler協議
//本例中WKWebView將把URLScheme爲customScheme的請求交由CustomURLSchemeHandler類的實例處理
[configuration setURLSchemeHandler:[CustomURLSchemeHandler new] forURLScheme: @"customScheme"];
WKWebView *webView = [[WKWebView alloc] initWithFrame:self.view.bounds configuration:configuration];
self.view = webView;
[webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"customScheme://www.test.com"]]];
}
@end
複製代碼
注意:
#import "ViewController.h"
#import <WebKit/WebKit.h>
@interface CustomURLSchemeHandler : NSObject<WKURLSchemeHandler>
@end
@implementation CustomURLSchemeHandler
//當 WKWebView 開始加載自定義scheme的資源時,會調用
- (void)webView:(WKWebView *)webView startURLSchemeTask:(id <WKURLSchemeTask>)urlSchemeTask
API_AVAILABLE(ios(11.0)){
//加載本地資源
NSString *fileName = [urlSchemeTask.request.URL.absoluteString componentsSeparatedByString:@"/"].lastObject;
fileName = [fileName componentsSeparatedByString:@"?"].firstObject;
NSString *dirPath = [kPathCache stringByAppendingPathComponent:kCssFiles];
NSString *filePath = [dirPath stringByAppendingPathComponent:fileName];
//文件不存在
if (![[NSFileManager defaultManager] fileExistsAtPath:filePath]) {
NSString *replacedStr = @"";
NSString *schemeUrl = urlSchemeTask.request.URL.absoluteString;
if ([schemeUrl hasPrefix:kUrlScheme]) {
replacedStr = [schemeUrl stringByReplacingOccurrencesOfString:kUrlScheme withString:@"http"];
}
NSMutableURLRequest * request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:replacedStr]];
NSURLSessionConfiguration *config = [NSURLSessionConfiguration defaultSessionConfiguration];
NSURLSession *session = [NSURLSession sessionWithConfiguration:config];
NSURLSessionDataTask *dataTask = [session dataTaskWithRequest:request completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
[urlSchemeTask didReceiveResponse:response];
[urlSchemeTask didReceiveData:data];
if (error) {
[urlSchemeTask didFailWithError:error];
} else {
[urlSchemeTask didFinish];
}
}];
[dataTask resume];
} else {
NSData *data = [NSData dataWithContentsOfFile:filePath];
NSURLResponse *response = [[NSURLResponse alloc] initWithURL:urlSchemeTask.request.URL
MIMEType:[self getMimeTypeWithFilePath:filePath]
expectedContentLength:data.length
textEncodingName:nil];
[urlSchemeTask didReceiveResponse:response];
[urlSchemeTask didReceiveData:data];
[urlSchemeTask didFinish];
}
}
- (void)webView:(WKWebView *)webVie stopURLSchemeTask:(id)urlSchemeTask {
}
//根據路徑獲取MIMEType
- (NSString *)getMimeTypeWithFilePath:(NSString *)filePath {
CFStringRef pathExtension = (__bridge_retained CFStringRef)[filePath pathExtension];
CFStringRef type = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, pathExtension, NULL);
CFRelease(pathExtension);
//The UTI can be converted to a mime type:
NSString *mimeType = (__bridge_transfer NSString *)UTTypeCopyPreferredTagWithClass(type, kUTTagClassMIMEType);
if (type != NULL)
CFRelease(type);
return mimeType;
}
@end
複製代碼
分析,這裏攔截到URLScheme爲customScheme的請求後,讀取本地資源,並返回給WKWebView顯示;若找不到本地資源,要將自定義 Scheme 的請求轉換成 http 或 https 請求用NSURLSession從新發出,收到回包後再將數據返回給WKWebView。
通過測試,加載速度快了很多,特別是弱網下,效果顯著,誰用誰知道!WKURLSchemeHandler相比於用 NSURLProtocol 攔截的方案更可靠。 因爲是優化功能,開發時也要注意添加開關,以防上線後出現問題,能夠及時關閉開關以避免產生不可估計的後果。
本文主要是總結本身在開發中遇到問題並解決的過程,同時也是學習NSURLProtocol和WKURLSchemeHandler的用法,加深理解,但願對你也有所幫助;同時有錯誤的地方也請你們及時指出,相互學習。但這並非最完美的方案,咱們也在不斷優化實踐中,我也會持續總結。。。
文章最後附帶 騰訊Bugly的 《WKWebView 那些坑》 以便開發時填坑。
補充後續的一篇《iOS app秒開H5實戰總結》,兩篇結合一塊兒看下。