iOS app秒開H5實戰總結

《iOS app秒開H5優化探索》 一文中簡單介紹了優化的方案以及一些知識點,本文繼續介紹使用WKURLSchemeHandler攔截加載離線包優化打開速度的一些細節以及注意事項,閱讀本文前請先大概瞭解一下上篇文章的內容以及WKURLSchemeHandler的基本用法。

離線包下載優化

在上一篇《iOS app秒開H5優化探索》中,離線包下載處理有不少不合理的地方,如資源分散下載,不只增長後續更新邏輯的複雜度,並且會形成系統資源浪費。爲此能夠把全部資源文件(js/css/html等)整合成zip包,一次性下載至本地,使用SSZipArchive解壓到指定位置,更新version便可。 此外,下載時機在app啓動和先後臺切換都作一次檢查更新,效果更好。css

NSURLSession *session = [NSURLSession sharedSession];
NSURLSessionDownloadTask *downLoadTask = [session downloadTaskWithRequest:request completionHandler:^(NSURL * _Nullable location, NSURLResponse * _Nullable response, NSError * _Nullable error) {
  if (!location) {
      return ;
  }
  
  //下載成功,移除舊資源
  [fileManager removeFileAtPath:dirPath fileExtesion:nil];
  
  //腳本臨時存放路徑
  NSString *downloadTmpPath = [NSString stringWithFormat:@"%@pkgfile_%@.zip", NSTemporaryDirectory(), version];
  // 文件移動到指定目錄中
  NSError *saveError;
  [fileManager moveItemAtURL:location toURL:[NSURL fileURLWithPath:downloadTmpPath] error:&saveError];
  //解壓zip
  BOOL success = [SSZipArchive unzipFileAtPath:downloadTmpPath toDestination:dirPath];
  if (!success) {
      LogError(@"pkgfile: unzip file error");
      [fileManager removeItemAtPath:downloadTmpPath error:nil];
      [fileManager removeFileAtPath:dirPath fileExtesion:nil];
      return;
  }
  //更新版本號
  [[NSUserDefaults standardUserDefaults] setValue:version forKey:pkgfileVisionKey];
  [[NSUserDefaults standardUserDefaults] synchronize];
  //清除臨時文件和目錄
  [fileManager removeItemAtPath:downloadTmpPath error:nil];
}];
[downLoadTask resume];
[session finishTasksAndInvalidate];
複製代碼

WKWebView複用池

在調試過程當中,發現首次加載頁面時間比後續打開時間都慢不少,緣由預計是 webView 首次初始化時候須要啓動資源和服務較多,因而嘗試預先初始化 webView 複用方案,速度會快不少。html

WKWebView複用池原理:預選準備兩個NSMutableSet<WKWebView *>,一個正被使用visiableWebViewSet、一個空閒待用reusableWebViewSet,在+ (void)load初始化一個WKWebView,並加入reusableWebViewSet中,當H5頁面須要使用時,從reusableWebViewSet中取出並放入visiableWebViewSet中,使用完(dealloc)放回reusableWebViewSet中。若該WKWebView異常則拋棄從新建立WKWebView,以避免發生一些莫名其妙的問題。前端

一、初始化

+ (void)load {
    __block id observer = [[NSNotificationCenter defaultCenter] addObserverForName:UIApplicationDidFinishLaunchingNotification object:nil queue:nil usingBlock:^(NSNotification * _Nonnull note) {
        dispatch_async(dispatch_get_main_queue(), ^{
            [[YH_WKWebViewPool sharedInstance] _prepareReuseWebView];
        });

        [[NSNotificationCenter defaultCenter] removeObserver:observer];
    }];
}

//啓動初始化一個全局webView
- (void)_prepareReuseWebView {
    dispatch_async(dispatch_get_main_queue(), ^{
        WKWebView *webview = [[WKWebView alloc] init]; 
        [self.reusableWebViewSet addObject:webview];
    });
}複製代碼

二、獲取複用池中的webview

- (WKWebView *)getReusedWebViewForHolder:(id)holder {
    if (!holder) {
#if DEBUG
        NSLog(@"WKWebViewPool must have a holder");
#endif
        return nil;
    }
    
    WKWebView *webView;
    
    dispatch_semaphore_wait(_lock, DISPATCH_TIME_FOREVER);
    
    if (_reusableWebViewSet.count > 0) {
        webView = [_reusableWebViewSet anyObject];
        [_reusableWebViewSet removeObject:webView];
        [_visiableWebViewSet addObject:webView];
        
    } else {
        [_visiableWebViewSet removeAllObjects];
        webView = [[WKWebView alloc] init];
        [_visiableWebViewSet addObject:webView];
    }
    webView.holderObject = holder;
    
    dispatch_semaphore_signal(_lock);
    
    return webView;
}
複製代碼

其中holder使用runtime爲WKWebView添加的屬性,傳入使用複用池的當前VC便可,以供後續回收判斷複用池是否正在使用。java

三、用完回收

- (void)recycleReusedWebView:(WKWebView *)webView {
    if (!webView) {
        return;
    }
    
    dispatch_semaphore_wait(_lock, DISPATCH_TIME_FOREVER);
    
    if ([_visiableWebViewSet containsObject:webView]) {
        //將webView重置爲初始狀態
        [webView webViewEndReuse];
        
        [_visiableWebViewSet removeObject:webView];
        [_reusableWebViewSet addObject:webView];
        
    } else {
        if (![_reusableWebViewSet containsObject:webView]) {
#if DEBUG
            NSLog(@"Don't use the webView");
#endif
        }
    }
    dispatch_semaphore_signal(_lock);
}

其中webViewEndReuse爲WKWebView的擴展方法:
- (void)webViewEndReuse {
    self.holderObject = nil;
    
    if ([self isKindOfClass:[WKWebView class]]) {
        WKWebView *webView = (WKWebView *)self.webView;
        webView.delegate = nil;
        webView.scrollView.delegate = nil;
        [webView stopLoading];
        [webView setUIDelegate:nil];
        [webView loadHTMLString:@"" baseURL:nil];
    }
}
複製代碼

複用池原理很簡單,此外對收到內存警告後清除web緩存等回收處理等等,此處再也不贅述。ios

WebViewController改造

一般項目中處理H5頁面都會放在統一的WebViewController中,因此要結合開關、要優化的業務來分開復用池和普通webView的使用,以避免出問題。web

一、替換url scheme

NSString *urlString = @"https://www.test.com/abc?id=123456";
  if ([YH_Global sharedInstance].isGrassLocalOpen && SYSTEM_VERSION_GREATER_THAN_OR_EQUAL_TO(@"11.0")) {             
    urlString = [urlString stringByReplacingOccurrencesOfString:@"https" withString:@"customScheme"];
  }
  WebViewController *vc = [[WebViewController alloc] initWithUrl:urlString];
  [self.navigationController pushViewController:vc animated:YES];
複製代碼

此處替換url scheme http(s)爲自定義協議,使用攔截生效。 此處須要特別說明的是,前端H5請求的js、css等資源使用自適應的協議,如:src='//www.test.com/abc.js',這樣native端使用不一樣scheme請求,H5就會使用對應的scheme進行請求加載。另一個重要的點是,前端的ajax請求,像post請求,scheme使用http(s)不使用自定義協議,這樣native不會攔截,徹底交給H5與服務器交互,就不會發生髮送post請求,body丟失的狀況。ajax

二、初始化webView

- (instancetype)initWithUrl:(NSString *)url {
    if (self = [super init]) {
        if ([self checkMatchingWithUrl:url]) {//符合條件,使用複用池
            self.webView = [[WKWebViewPool sharedInstance] getReusedWebViewForHolder:self];
        }
        self.url = url;
    }
    return self;
}
複製代碼

此處在initWithUrl中,而不在viewDidLoad中獲取webView,是由於在init中,頁面打開速度會快不少。json

三、預先添加數據腳本,提高體驗

這一步根據筆者公司的app的業務特性全部:用戶社區帖子列表(native) => 帖子詳情(H5實現)=> 我的中心等(H5)。從列表點擊進入H5詳情時,預先將帖子的部分數據,如頭像、首圖縮略圖、內容等傳給前端(,前端拿到數據,預先加載這部分數據,同時對首圖縮略圖增長漸變出現的效果,這時打開H5,頁面從模糊的縮略圖漸變至高清大圖,以達到原生打開頁面的體驗(文末的最終效果圖)。注意,這裏圖片傳給前端的是url,並非圖片數據,下文會繼續說明如何使用圖片數據。緩存

native與H5交互的部分代碼:bash

Model *modelMake = model;//列表點擊的item數據
NSString *key = [NSString stringWithFormat:@"native_list_%@", modelMake.articleId];
NSData *data = [NSJSONSerialization dataWithJSONObject:[modelMake dictionaryValue] options:NSJSONWritingPrettyPrinted error:nil];
NSString *value = [[NSString alloc] initWithData:data?data:[NSData data] encoding:NSUTF8StringEncoding];
NSString *javaScript = [NSString stringWithFormat:@"!window.predatas && (window.predatas = []);predatas.push({key: \"%@\", value: %@ })", key, value];

WKUserContentController *userContentController = wkWebView.configuration.userContentController;
WKUserScript *userScript = [[WKUserScript alloc] initWithSource:javaScript injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:NO];
[userContentController addUserScript:userScript];

複製代碼

四、側滑返回

因爲業務使用H5開發,從列表到詳情再到我的中心,這時側滑會直接回到列表頁,並不像原生導航那樣一層層返回。解決這個問題,首先想到使用WKWebView的allowsBackForwardNavigationGestures屬性,結合webView的goBack方法,的確能夠層層側滑返回,可是最後出現會先回到第一次打開的詳情頁面,而後纔會回到列表的狀況以及一些其餘異常問題。嘗試了一些方案後,最終採用本身添加手勢實現側滑返回功能。

手勢建立:

self.leftSwipGes = [[UIScreenEdgePanGestureRecognizer alloc] initWithTarget:self action:@selector(leftSwipGesAction:)];
self.leftSwipGes.edges = UIRectEdgeLeft;
self.leftSwipGes.delegate = self;
[self.webView addGestureRecognizer:self.leftSwipGes];
        
複製代碼

實現:

- (void)leftSwipGesAction:(UIScreenEdgePanGestureRecognizer *)ges {
    if (UIGestureRecognizerStateEnded == ges.state) {
        if (self.webView.backForwardList.backList.count > 0) {
            WKBackForwardListItem *item = webView.backForwardList.backList.lastObject;
            if (![self.webView.URL.absoluteString isEqualToString:self.url]) {
                [webView goToBackForwardListItem:item];
            } else {
                [self nativeBack:nil];
                [webView goToBackForwardListItem:item];
            }
        } else {
            [self nativeBack:nil];
        }
    }
}
複製代碼

其中,nativeBack()爲native的返回方法。原理:側滑時,當前webview的url不是初始H5頁面的url時,webView的backForwardList退後一級,當退到初始頁面時,直接返回列表。此外,注意處理自定義手勢跟其餘手勢衝突的問題;同時還要禁用系統的側滑返回,以及禁用FDFullscreenPopGesture等第三方庫的側滑返回。

攔截加載離線包

前提建立WKWebview時註冊好自定義協議,具體結合本身項目實現,只要保證建立WKWebView時註冊便可:

WKWebViewConfiguration *configuration = [WKWebViewConfiguration new];  
[configuration setURLSchemeHandler:[CustomURLSchemeHandler new] forURLScheme: @"customScheme"];    
WKWebView *webView = [[WKWebView alloc] initWithFrame:self.view.bounds configuration:configuration];
複製代碼

攔截

上文也分析了,打開一個H5頁面會有一段時間白屏,是由於它作了不少事情: 

初始化 webview -> 請求頁面 -> 下載數據 -> 解析HTML -> 請求 js/css 資源 -> dom 渲染 -> 解析 JS 執行 -> JS 請求數據 -> 解析渲染 -> 下載渲染圖片

因此當打開以自定義協議customScheme爲scheme的H5頁面時,webview請求頁面,native會依次收到html、js、css、圖片類型的攔截響應:

- (void)webView:(WKWebView *)webView startURLSchemeTask:(id <WKURLSchemeTask>)urlSchemeTask
API_AVAILABLE(ios(11.0)){
    
    NSDictionary *headers = urlSchemeTask.request.allHTTPHeaderFields;
    NSString *accept = headers[@"Accept"];
    
    //當前的requestUrl的scheme都是customScheme
    NSString *requestUrl = urlSchemeTask.request.URL.absoluteString;
    NSString *fileName = [[requestUrl componentsSeparatedByString:@"?"].firstObject componentsSeparatedByString:@"/"].lastObject;

    //Intercept and load local resources.
    if ((accept.length >= @"text".length && [accept rangeOfString:@"text/html"].location != NSNotFound)) {//html 攔截
      [self loadLocalFile:fileName urlSchemeTask:urlSchemeTask];
    } else if ([self isMatchingRegularExpressionPattern:@"\\.(js|css)" text:requestUrl]) {//js、css
        [self loadLocalFile:fileName urlSchemeTask:urlSchemeTask];
    } else if (accept.length >= @"image".length && [accept rangeOfString:@"image"].location != NSNotFound) {//image
      NSString *replacedStr = [requestUrl stringByReplacingOccurrencesOfString:kUrlScheme withString:@"https"];
      NSString *key = [[SDWebImageManager sharedManager] cacheKeyForURL:[NSURL URLWithString:replacedStr]];
      [[SDWebImageManager sharedManager].imageCache queryCacheOperationForKey:key done:^(UIImage * _Nullable image, NSData * _Nullable data, SDImageCacheType cacheType) {
          if (image) {
              NSData *imgData = UIImageJPEGRepresentation(image, 1);
              NSString *mimeType = [self getMimeTypeWithFilePath:fileName] ?: @"image/jpeg";
              [self resendRequestWithUrlSchemeTask:urlSchemeTask mimeType:mimeType requestData:imgData];
          } else {
              [self loadLocalFile:fileName urlSchemeTask:urlSchemeTask];
          }
      }];
    } else {//return an empty json.
        NSData *data = [NSJSONSerialization dataWithJSONObject:@{ } options:NSJSONWritingPrettyPrinted error:nil];
        [self resendRequestWithUrlSchemeTask:urlSchemeTask mimeType:@"text/html" requestData:data];
    }
}
    
    //Load local resources, eg: html、js、css...
- (void)loadLocalFile:(NSString *)fileName urlSchemeTask:(id <WKURLSchemeTask>)urlSchemeTask API_AVAILABLE(ios(11.0)){
    if (fileName.length == 0 || !urlSchemeTask) {
        return;
    }
    
    //If the resource do not exist, re-send request by replacing to http(s).
    NSString *filePath = [kGrassH5ResourcesFiles stringByAppendingPathComponent:fileName];
    if (![[NSFileManager defaultManager] fileExistsAtPath:filePath]) {
        if ([replacedStr hasPrefix:kUrlScheme]) {
            replacedStr = [replacedStr stringByReplacingOccurrencesOfString:kUrlScheme withString:@"https"];
        }
        
        NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:replacedStr]];
        NSURLSession *session = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration]];
        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];
                
                NSString *accept = urlSchemeTask.request.allHTTPHeaderFields[@"Accept"];
                if (!(accept.length >= @"image".length && [accept rangeOfString:@"image"].location != NSNotFound)) { //圖片不下載
                    [data writeToFile:filePath atomically:YES];
                }
            }
        }];
        [dataTask resume];
        [session finishTasksAndInvalidate];
    } else {
        NSData *data = [NSData dataWithContentsOfFile:filePath options:NSDataReadingMappedIfSafe error:nil];
        [self resendRequestWithUrlSchemeTask:urlSchemeTask mimeType:[self getMimeTypeWithFilePath:filePath] requestData:data];
    }
}

- (void)resendRequestWithUrlSchemeTask:(id <WKURLSchemeTask>)urlSchemeTask
                              mimeType:(NSString *)mimeType
                           requestData:(NSData *)requestData  API_AVAILABLE(ios(11.0)) {
    if (!urlSchemeTask || !urlSchemeTask.request || !urlSchemeTask.request.URL) {
            return;
        }
        
        NSString *mimeType_local = mimeType ? mimeType : @"text/html";
        NSData *data = requestData ? requestData : [NSData data];
        NSURLResponse *response = [[NSURLResponse alloc] initWithURL:urlSchemeTask.request.URL
                                                            MIMEType:mimeType_local
                                               expectedContentLength:data.length
                                                    textEncodingName:nil];
        [urlSchemeTask didReceiveResponse:response];
        [urlSchemeTask didReceiveData:data];
        [urlSchemeTask didFinish];
    }
}

複製代碼

我這裏只簡單貼了部分攔截資源請求後的處理代碼:收到攔截請求後,先獲取本地資源包對應的資源,轉換成data回傳給webView進行渲染處理;若本地沒有,則customScheme替換成https的url重發請求通知webview,這就是基本流程。實際開發調試過程當中還有不少細節須要處理,如本地資源沒有時,根據服務器預先下發的匹配規則重發請求;又如加載替換使用不一樣的html,又如打開頁面一直白屏等等問題,這裏就不列出了。

但還要特別說明兩點:

一、代碼中替換圖片的邏輯,先查找本地圖片的目的是爲了實現上文所說的WebViewController改造第三條:預先添加數據腳本,提高體驗,獲取列表中已展現縮略圖的SDWebImage緩存傳給webView進行預加載,以實現漸變出現的效果。而後本地就重發請求通知webview。 到這裏,你應該明白,優化實現秒開,中心思想就是要減小資源的網絡請求,把第一頁要展現的原素儘可能預先加載。 

二、在測試過程當中,在一些機型較差的機器上,頻繁快速的打開H5頁面,會出現崩潰。查閱WKURLSchemeTask的官方解釋:

An exception will be thrown if you try to send a new response object after the task has already been completed.
An exception will be thrown if your app has been told to stop loading this task via the registered WKURLSchemeHandler object.

經分析,發如今處理本地不存在的圖片時,先判斷本地是否存在然後又發起請求,時間跨度比較長,當前urlSchemeTask因爲某些緣由提早結束了(會收到stopURLSchemeTask回調),這時重發的請求又訪問了WKURLSchemeTask的實例方法(didReceiveResponse等)就致使了崩潰。
解決辦法:新增NSMutableDictionary成員變量,以當前的urlSchemeTask作key,攔截開始時設置YES,收到中止通知時設置NO,每次通知webview前判斷當前的urlSchemeTask是否結束,提早結束了就不作處理。 這麼作,帶來的影響就是當前的圖片會不顯示,退出再次進來仍是會出現的,結合出現的異常場景以及發生崩潰,這點影響仍是能夠接受的。

- (void)webView:(WKWebView *)webView startURLSchemeTask:(id <WKURLSchemeTask>)urlSchemeTask
API_AVAILABLE(ios(11.0)){
    
    dispatch_sync(self.serialQueue, ^{
        [self.holderDicM setObject:@(YES) forKey:urlSchemeTask.description];
    });
}

- (void)webView:(WKWebView *)webView stopURLSchemeTask:(id <WKURLSchemeTask>)urlSchemeTask
API_AVAILABLE(ios(11.0)){
    dispatch_sync(self.serialQueue, ^{
        [self.holderDicM setObject:@(NO) forKey:urlSchemeTask.description];
    });
}
複製代碼

注意要添加串行隊列對數據進行保護,防止多線程同時訪問修改數據,形成數據異常。

總結

到這裏,優化基本上完成,打開H5頁面確實快了不少。咱們的方案大體就是這樣,這個確定不是最優的方案,多多少少會有些問題,我相信讀者會有更好的優化方案,或者遇到上述出現的問題有更合理的解決方法,歡迎你們一塊兒討論。

最後展現一下,咱們優化後的打開H5頁面的效果(iPhone 7):

相關文章
相關標籤/搜索