一、痛点以及背景
最早用的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
的时候,突然间发现字体没有生效。怎么都想不明白,突然间测试的一句话,是不是http
到https
的问题。就是因为跨域的问题,在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以及之后,才能用,因此对于需要兼容之前版本的,需要做一下处理。