歡迎光臨
每天分享高質量文章

iOS App秒開H5最佳化總結

來自:知識小集(ID:iOS-Tips)

連結:https://juejin.im/post/5c9c664ff265da611624764d

背景

為了快遞迭代、更新,公司app有一大模組功能使用H5實現,但是體驗比原生差,這就衍生瞭如何提高H5載入速度,最佳化體驗的問題。此文,記錄一下自己的心路歷程。
騰訊bugly發表的一篇文章《移動端本地 H5 秒開方案探索與實現》[1]中分析,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.add928b525.png”,
       “https://xxx/main.c609e010f4.js”,
       “https://xxx/vender.821f3aa0d2e606967ad3.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》[2]有很好的說明。比如我使用私有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。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:@”http://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 

@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 那些坑》[3]以便開發時填坑。中。

參考連結

  1. 移動端本地 H5 秒開方案探索與實現

  2. 讓 WKWebView 支援 NSURLProtocol  https://blog.moecoder.com/2016/10/26/support-nsurlprotocol-in-wkwebview/

  3. WKWebView 那些坑

已同步到看一看
贊(0)

分享創造快樂