iOS app秒開H5優化探索

背景

爲了快遞迭代、更新,公司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

離線包的下載

  • 離線包內容:css,js,html,通用的圖片等
  • 下載時機:在app啓動的時候,開啓線程下載資源,注意不要影響app的啓動。
  • 存放位置:選用沙盒中的/Library/Caches。 由於資源會不定時更新,而/Library/Documents更適合存放一些重要的且不常常更新的數據。
  • 更新邏輯:請求CDN上的info.json資源,返回的version與本地保存的不一樣,則資源變化需更新下載。注:第一次運行時,須要在/Library/Caches中建立自定義文件夾,並全量下載資源。

一、獲取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包,還須要解壓處理。

攔截並加載本地資源包

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,資源定義成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

複製代碼

注意:

  1. setURLSchemeHandler註冊時機只能在WKWebView建立WKWebViewConfiguration時註冊。
  2. WKWebView 只容許開發者攔截自定義 Scheme 的請求,不容許攔截 「http」、「https」、「ftp」、「file」 等的請求,不然會crash。
  3. 【補充】WKWebView加載網頁前,要在user-agent添加個標誌,H5遇到這個標識就使用customScheme,不然就是用原來的http或https。

攔截

#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實戰總結》,兩篇結合一塊兒看下。

相關文章
相關標籤/搜索