WKWebView轻松访问本地资源

一、痛点以及背景

最早用的UIWebView,交互没有WKWebView方便但也还凑合,主要是太吃内存了,性能太差,而且现在最低支持iOS 8,所以决定换成WKWebView,由此就开启了踩坑之路。

在日常的开发中,我们经常会有这样的需求,在WKWebView或者UIWebView加载的H5页面里面去操作一些本地的资源信息.用UIWebView时只需要把文件的本地路径传给前端然后前端直接去访问文件的本地路径就可以了,但是换成WKWebView就没办法加载出来资源文件。

由于公司UI的强制要求,对于APP内部的H5页面的字体样式必须和Native保持一致,也需要使用自定义字体,目前H5的处理方式是在H5页面请求。能够解决UI问题,但是带来的流量的巨大消耗,每天都有好几百G的流量消耗,因此运维希望我们能够帮忙解决。

二、解决方法

1. 使用NSURLProtocol拦截

目前网上有好多文章介绍,这里不在讲解。对于使用使用NSURLProtocol带来的问题

  • 审核风险
  • 拦截http/https时,post请求body丢失
  • 如使用ajax hook方式,可能存在 post header字符长度限制 、Put类型请求异常 等

这种方案,我们已经运行了好多个版本,一直没有问题,直到有一天,H5页面内部post请求发送不出去,这种方案也就宣布扑街了。

由此看来,在 iOS11 WKURLSchemeHandler [探究] 到来之前,私有API并不那么完美。

这里借助一个第三方分类 NSURLProtocol+WebKitSupport

  • NSURLProtocol+WebKitSupport.h 实现

#import <Foundation/Foundation.h>
@interface NSURLProtocol (WebKitSupport)

+ (void)wk_registerScheme:(NSString*)scheme;
+ (void)wk_unregisterScheme:(NSString*)scheme;

@end

  • NSURLProtocol+WebKitSupport.m 实现
#import "NSURLProtocol+WebKitSupport.h"
#import <WebKit/WebKit.h>

/**
 * The functions below use some undocumented APIs, which may lead to rejection by Apple.
 */

FOUNDATION_STATIC_INLINE Class ContextControllerClass() {
    static Class cls;
    if (!cls) {
        cls = [[[WKWebView new] valueForKey:@"browsingContextController"] class];
    }
    return cls;
}

FOUNDATION_STATIC_INLINE SEL RegisterSchemeSelector() {
    return NSSelectorFromString(@"registerSchemeForCustomProtocol:");
}

FOUNDATION_STATIC_INLINE SEL UnregisterSchemeSelector() {
    return NSSelectorFromString(@"unregisterSchemeForCustomProtocol:");
}

@implementation NSURLProtocol (WebKitSupport)

+ (void)wk_registerScheme:(NSString *)scheme {
    Class cls = ContextControllerClass();
    SEL sel = RegisterSchemeSelector();
    if ([(id)cls respondsToSelector:sel]) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
        [(id)cls performSelector:sel withObject:scheme];
#pragma clang diagnostic pop
    }
}

+ (void)wk_unregisterScheme:(NSString *)scheme {
    Class cls = ContextControllerClass();
    SEL sel = UnregisterSchemeSelector();
    if ([(id)cls respondsToSelector:sel]) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
        [(id)cls performSelector:sel withObject:scheme];
#pragma clang diagnostic pop
    }
}

@end

  • 自定义URLProtocol
#import "YCFontReplaceURLProtocol.h"
#import "NSURLProtocol+WebKitSupport.h"

/** 为改造后的请求定义关键 key,防止重复改造请求 */
static NSString * const kFilteredKey = @"YCFontReplaceURLProtocolFilteredKey";
/** 字体后缀 */
static NSString * const kFontPathExtension = @"ttf";

@implementation YCFontReplaceURLProtocol

#pragma mark - public method

+ (void)registerSelf {
    [NSURLProtocol registerClass:[YCFontReplaceURLProtocol class]];
    for (NSString *scheme in @[@"http", @"https"]) {
        [NSURLProtocol wk_registerScheme:scheme];
    }
}

+ (void)unregisterSelf {
    [NSURLProtocol unregisterClass:[YCFontReplaceURLProtocol class]];
    for (NSString *scheme in @[@"http", @"https"]) {
        [NSURLProtocol wk_unregisterScheme:scheme];
    }
}

#pragma mark - URLProtocol Must Implemention Method

/** 决定是否处理该请求 */
+ (BOOL)canInitWithRequest:(NSURLRequest *)request {
    return [self isHandleWithRequest:request];
}

+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request {
    return request;
}

+ (BOOL)requestIsCacheEquivalent:(NSURLRequest *)a
                       toRequest:(NSURLRequest *)b {
    return [super requestIsCacheEquivalent:a
                                 toRequest:b];
}

- (void)startLoading {
    NSMutableURLRequest* request = self.request.mutableCopy;
    [NSURLProtocol setProperty:@YES
                        forKey:kFilteredKey
                     inRequest:request];
    NSString *fontPath = [[NSBundle mainBundle] pathForResource:request.URL.lastPathComponent
                                                         ofType:nil];
    NSData* data = nil;
    if (fontPath) {
        data = [NSData dataWithContentsOfFile:fontPath];
    } else {
        NSAssert(!fontPath
                 || !data, @"字体路径未找到或字体加载失败");
    }
    NSURLResponse* response = [[NSURLResponse alloc] initWithURL:self.request.URL
                                                        MIMEType:@"application/x-font-ttf"
                                           expectedContentLength:data.length
                                                textEncodingName:nil];
    [self.client URLProtocol:self
          didReceiveResponse:response
          cacheStoragePolicy:NSURLCacheStorageAllowed];
    [self.client URLProtocol:self didLoadData:data];
    [self.client URLProtocolDidFinishLoading:self];
}

- (void)stopLoading {
}

#pragma mark - private method

+ (BOOL)isHandleWithRequest:(NSURLRequest *)request {
    // 判断该请求是否已被替换字体
    BOOL noFilterKey = ([NSURLProtocol propertyForKey:kFilteredKey
                                            inRequest:request] == nil);
    if (!noFilterKey) {
        return NO;
    }
    
    // 判断是否为字体格式
    NSString *lastPathComponent = request.URL.lastPathComponent;
    NSString *pathExtension = lastPathComponent.pathExtension;
    if (![pathExtension isEqualToString:kFontPathExtension]) {
        return NO;
    }
    
    // 是否是字体请求
    NSString *fontStringByDeletingPathExtension = lastPathComponent.stringByDeletingPathExtension;
    BOOL isTTF = [@[BOLD_FONT, CUSTOM_FONT] indexOfObjectPassingTest:^BOOL(id  _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
        return [fontStringByDeletingPathExtension compare:obj
                                                  options:NSCaseInsensitiveSearch] == NSOrderedSame;
    }] != NSNotFound;
    if (!isTTF) {
        return NO;
    }
    
    return YES;
}

@end

然后在需要使用的控制器里面,调用即可

- (void)viewWillAppear:(BOOL)animated {
    [super viewWillAppear:animated];
    [YCFontReplaceURLProtocol registerSelf];
}

- (void)viewWillDisappear:(BOOL)animated {
    [super viewWillDisappear:animated];
    [YCFontReplaceURLProtocol unregisterSelf];
}

2. WKWebView注入字体CSS
  • 代码如下
    NSMutableString *javascript = [NSMutableString string];
    [javascript appendString:@"document.documentElement.style.webkitTouchCallout='none';"];//禁止长按
    [javascript appendString:@"document.documentElement.style.webkitUserSelect='none';"];//禁止选择

    NSString *boldFont = [self getBase64FromFile:@"OnionMath_Bold-Bold" ofType:@"ttf"];
    [javascript appendString:[NSString stringWithFormat:@"\
                    var boldcss = '@font-face { font-family: \"OnionMath\"; src: url(data:font/ttf;base64,%@) format(\"truetype\");}'; \
                    var head = document.getElementsByTagName('head')[0], \
                    style = document.createElement('style'); \
                    style.type = 'text/css'; \
                    style.innerHtml = boldcss; \
                    head.appendChild(style);", boldFont]];
    
    WKUserScript *noneSelectScript = [[WKUserScript alloc] initWithSource:javascript
                                                            injectionTime:WKUserScriptInjectionTimeAtDocumentEnd
                                                         forMainFrameOnly:YES];
    
    self.vipWebView = [[WKWebView alloc] initWithFrame:CGRectMake(0, NAV_BAR_HEIGHT, SCREEN_SIZE_WIDTH, SCREEN_SIZE_HEIGHT - NAV_BAR_HEIGHT)];
    self.vipWebView.scrollView.decelerationRate = 0.998;
    [self.vipWebView .configuration.userContentController addUserScript:noneSelectScript];
    self.vipWebView.yc_navigationDelegate = self;
    self.vipWebView.scrollView.showsHorizontalScrollIndicator = NO;
    [self.vipWebView sizeToFit];
    [self.view addSubview:self.vipWebView];

  - (NSString*)getBase64FromFile:(NSString*)fileName ofType:(NSString*)type {
      NSString *filePath = [[NSBundle mainBundle] pathForResource:fileName ofType:type];
      NSData *nsdata = [NSData dataWithContentsOfFile:filePath];
      NSString *base64Encoded = [nsdata base64EncodedStringWithOptions:0];
      return filePath;
  }

能解决字体的问题,但是当字体资源较大的时候,存在性能问题,而且从扩展性来说,扩展性不是很高。

3. 使用GCDWebServer
(1) GCDWebServer 简介

GCDWebServer是一个现代化的轻量级的基于HTTP 1.1的GCD server,它主要用于嵌入OS X & iOS apps。它开始编写时考虑下面几个目标:

  • 优雅,易于使用的架构,只有4个核心类: server, connection, request and response (可以看下面的 “了解GCDWebServer的架构” )
  • 精心设计的API以及完整的文档让你方便集成和自定义
  • 为了更好的性能和并发通过Grand Central Dispatch完全建立在事件驱动设计之上
  • 没有依赖第三方库
  • New BSD License下是可用的

额外的内置功能:

  • 允许执行传入的HTTP请求完全异步处理程序
  • 尽量减少磁盘流大的HTTP请求或响应主体的内存使用
  • web forms提交的解析器用”application/x-www-form-urlencoded” or “multipart/form-data”编码(包括文件上传)
  • JSON 解析和序列化,主要是给 request and response HTTP bodies
  • Chunked transfer encoding for request and response HTTP bodies
  • HTTP compression with gzip for request and response HTTP bodies
  • HTTP range support for requests of local files
  • Basic and Digest Access authentications for password protection
  • 自动处理iOS apps中前台,后台,挂起模式之间的转换
  • 支持 IPv4 和 IPv6
  • NAT端口映射 (只有IPv4)

包括的扩展:

  • GCDWebUploader: GCDWebServer的子类 ,that implements an interface for uploading and downloading files using a web browser 用web浏览器实现了上传和下载文件的接口
  • GCDWebDAVServer: GCDWebServer的子类, 它实现了一个1级WebDAV服务器(与OS X的Finder部分2级支持)

不支持什么(但不是真正从一个嵌入式HTTP服务器所需的):

  • 保持连接
  • HTTPS
(2) 接入方式
  • iOS 代码如下:
- (void)startLocalServer {
    
    self.webServer = [[GCDWebServer alloc] init];
    NSString *bundlePath = [[NSBundle mainBundle] pathForResource:@"OnionMath_Bold-Bold" ofType:@"ttf"];
    [self.webServer addGETHandlerForBasePath:@"/"
                               directoryPath:bundlePath
                               indexFilename:nil
                                    cacheAge:0
                          allowRangeRequests:YES];
    
    NSMutableDictionary *options = [NSMutableDictionary dictionary];
    [options setObject:[NSNumber numberWithInteger:8848]
                forKey:GCDWebServerOption_Port];
    [options setObject:[NSNumber numberWithBool:YES]
                forKey:GCDWebServerOption_BindToLocalhost];
    [self.webServer startWithOptions:options error:nil];
}

对于本地服务,不需要的时候需要暂停服务,调用一下 [self.webServer stop] 即可。

对于H5页面,需要把方法字体的地址,比如 http://xxx.com/OnionMath_Bold-Bold.ttf 改成 http://127.0.0.0:8848/OnionMath_Bold-Bold.ttf 即可。

(3) 遇到的问题

通过上面的方法,和H5联调之后,发现是那么完美,字体能够正常加载,显示样式也是自定义字体的样式,但是当H5页面不知道到https的时候,突然间发现字体没有生效。怎么都想不明白,突然间测试的一句话,是不是httphttps的问题。就是因为跨域的问题,在https里面无法访问,http的资源路径。

  • 解决方法
    a. 跨域 (不安全)

对于GCDWebServer来说,他的功能比较强大, 如果这一套可以实现的话,就可以实现H5快速打开,视频缓存,图片缓存等很多功能,遗憾的是GCDWebServer不支持HTTPS,因此不是最佳方案。

4. 使用WKURLSchemeHandler

WKWebView在iOS11之后出来的,可以拦截通过定义Scheme来拦截H5内部的地址,但是遗憾的WKWebView 还不允许拦截 Scheme 为 “https”、“ftp”、“file” 的请求,具体可以通过新接口 + [WKWebView handlesURLScheme:] 判断Scheme是否已经被WKWebView默认处理了。

YCCustomURLSchemeHandler.h 内容


@interface YCCustomURLSchemeHandler : NSObject  <WKURLSchemeHandler>

@end

YCCustomURLSchemeHandler.m 内容

#import "YCCustomURLSchemeHandler.h"

// 粗体
static NSString *kBoldFontScheme = @"getboldfontscheme";
// 习题
static NSString *kRegularFontScheme = @"getregularfontscheme";

@implementation YCCustomURLSchemeHandler

- (void)webView:(WKWebView *)webView startURLSchemeTask:(id<WKURLSchemeTask>)urlSchemeTask API_AVAILABLE(ios(11.0)){
    
    NSString *urlString = urlSchemeTask.request.URL.relativeString;
    NSData *data = nil;
    
    if([urlString containsString:kBoldFontScheme]) {
        NSString *fontUrl = [[NSBundle mainBundle] pathForResource:@"OnionMath_Bold-Bold" ofType:@"ttf"];
        data = [NSData dataWithContentsOfFile:fontUrl];
    } else if([urlString containsString:kRegularFontScheme]) {
        NSString *fontUrl = [[NSBundle mainBundle] pathForResource:@"OnionMath_Regular-regular" ofType:@"ttf"];
        data = [NSData dataWithContentsOfFile:fontUrl];
    }

    NSURLResponse *response = [[NSURLResponse alloc] initWithURL:urlSchemeTask.request.URL
                                                        MIMEType:@"application/x-font-truetype"
                                           expectedContentLength:data.length
                                                textEncodingName:nil];
    [urlSchemeTask didReceiveResponse:response];
    [urlSchemeTask didReceiveData:data];
    [urlSchemeTask didFinish];
}

- (void)webView:(WKWebView *)webView stopURLSchemeTask:(id <WKURLSchemeTask>)urlSchemeTask  API_AVAILABLE(ios(11.0)){
    urlSchemeTask = nil;
}

@end

对于H5页面来说,写成下面样式就可以了

    <script>
        var fontStyle = document.createElement('style')
        var iOSFont = '@font-face { font-family: "OnionMath"; src: url("getRegularFontScheme://OnionMath_Regular-regular.ttf") format("truetype");}'
        var cssText = iOSFont + ' @font-face { font-family: "OnionMath"; src: url("getBoldFontScheme://OnionMath_Bold-Bold.ttf") format("truetype"); font-weight: bold; }'
        fontStyle.type = "text/css"
        fontStyle.innerHTML = cssText
        document.head.appendChild(fontStyle)
     </script>

三、总结

以上四种解决方法,可以根据自己的情况选择适合的解决方案
对于方案一,如果H5没有post请求的情况,可以采用这种方法
方案二,可能存在性能问题
方案三,如果H5是Http形式的。最为简单,可以写个工具类,全局处理本地服务的启动和结束,不需要再单独页面进行配置。
方案四,由于只在iOS11以及之后,才能用,因此对于需要兼容之前版本的,需要做一下处理。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,033评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,725评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,473评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,846评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,848评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,691评论 1 282
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,053评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,700评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,856评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,676评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,787评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,430评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,034评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,990评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,218评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,174评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,526评论 2 343