iOS app秒開H5優化總結
為了快遞迭代、更新,公司app的種草功能使用H5實現,但是體驗比原生差,這就衍生了如何提高H5載入速度,優化體驗的問題。此文,記錄一下自己的心路歷程。
騰訊bugly發表的一篇文章《移動端本地 H5 秒開方案探索與實現》 中分析,H5體驗糟糕,是因為它做了很多事:
初始化 webview -> 請求頁面 -> 下載資料 -> 解析HTML -> 請求 js/css 資源 -> dom 渲染 -> 解析 JS 執行 -> JS 請求資料 -> 解析渲染 -> 下載渲染圖片
一般頁面在 dom 渲染後才能展示,可以發現,H5 首屏渲染白屏問題的原因關鍵在於,如何優化減少從請求下載頁面到渲染之間這段時間的耗時。 所以,減少網路請求,採用載入離線資源載入方案來做優化。
離線包
離線包的分發
使用公司的CDN實現離線包的分發,在物件儲存中放置離線包檔案和一個額外的 info.json 檔案(例如:https://xxx/statics/info.json ):
{ "version":"4320573858a8fa3567a1", "files": [ "https://xxx/index.html", "https://xxx/logo.a0a62428.png", "https://xxx/main.8bfaf9c9.js", "https://xxx/vender.fd6a9d49.css", "https://xxx/manifest.json" ] } 複製程式碼
其中,app儲存當次的version,當下次請求時version變化,就說明資源有更新,需更新下載。
離線包的下載
- 離線包內容 :css,js,html,通用的圖片等
- 下載時機 :在app啟動的時候,開啟執行緒下載資源,注意不要影響app的啟動。
- 存放位置 :選用沙盒中的/Library/Caches。 因為資源會不定時更新,而/Library/Documents更適合存放一些重要的且不經常更新的資料。
- 更新邏輯 :請求CDN上的info.json資源,返回的version與本地儲存的不同,則資源變化需更新下載。注:第一次執行時,需要在/Library/Caches中建立自定義資料夾,並全量下載資源。
1、獲取CDN和沙盒中資源:
NSMutableArray *cdnFileNameArray = [NSMutableArray array]; //todo 獲取CDN資源 NSArray *localExistAarry = [[NSFileManager defaultManager] contentsOfDirectoryAtPath:dirPath error:nil]; 複製程式碼
2、本地沙盒有但cdn上沒有的資原始檔,需要刪除,以防檔案越積越多:
//過濾刪除操作 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]; } }]; } 複製程式碼
3、 已經下載過的檔案跳過,不需要重新下載浪費資源;
4、下載有變化的資原始檔,儲存至對應的沙盒資料夾中:
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包,還需要解壓處理。
攔截並載入本地資源包
NSURLProtocol
公司的專案從 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攔截的方案。
WKURLSchemeHandler
iOS 11上, WebKit 團隊終於開放了WKWebView載入自定義資源的API:WKURLSchemeHandler。
根據Apple 官方統計結果,目前iOS 11及以上的使用者佔比達95%。又結合自己公司的業務特性和麵向的使用者,決定使用WKURLSchemeHandler來實現攔截,而iOS 11以前的不做處理。
著手前,要與前端統一 URL-Scheme,如:customScheme,H5網頁的js、css等資源使用該scheme:customScheme://xxx/path/xxxx.css
。當WKWebView請求載入網頁,遇到該scheme的資源時,就用WKURLSchemeHandler 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:@"http://www.test.com"]]]; } @end 複製程式碼
注意:
- setURLSchemeHandler註冊時機只能在WKWebView建立WKWebViewConfiguration時註冊。
- WKWebView 只允許開發者攔截自定義 Scheme 的請求,不允許攔截 “http”、“https”、“ftp”、“file” 等的請求,否則會crash。
攔截
#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 那些坑》 以便開發時填坑。