在浏览器中,阅读模式通常是一个很有用的功能,有人说这是读小说神器,有些人则认为阅读模式可以改善新闻网站的阅读体验,而有些广告商则对此抗议,认为阅读模式损害了广告主利益。当然,阅读模式对于普通用户来说是一种很方便实用的功能,这个无可厚非。
在手机上,阅读模式有两种实现方式,一种是和 Safari 的实现类似的,利用 js 去解析网页数据分析出文本,基本上手机浏览器的实现都和 Safari 类似,另外一种则是抓取网页对应的 RSS 源,解析 RSS 源中的数据格式,取出需要的文本来显示。接下来我要展开讲的就是和 Safari 类似的阅读模式的实现,且是基于 WKWebView 来实现的。
详细的项目代码在这: PFWebViewController
了解 Safari 阅读模式的实现 关于实现,你可以先参考下面这两篇博客:
一定要写这篇博客的原因就是,我能查到与此相关的中文博客有且仅有这两篇,我接下来讲的会更通俗易懂一些,毕竟最后模仿系统实现出来了。上面两篇的作者最后的结论和代码并不能正常运行,但是提供的信息和资源多多少少给了我很多参考。
其中,第二篇中提到的 JavaScriptCore,由于 WKWebView 不再支持 ,我们会用到 WebKit 支持的发送消息的传递信息模式。核心要用到的 JavaScript 文件在这两篇博客中也有给出。
首先,我们需要用两个 WKWebView 来实现整个流程,一个是主要的浏览窗口 ( 暂且命名为 ) MainWebView ,另一个则是用于阅读模式页面显示的 ReaderWebView 。参与整个阅读模式渲染流程的文件大致有这些:
index.html
safari-reader.js # 这个 js 负责的是内容的格式化和渲染,挂载在 ReaderWebView 上
safari-reader-check.js # 这个 js 负责的是内容的判断和提取,挂载在 MainWebView 上,(在最新的 Safari 中我一直没有截到这个脚本,从最后实现的效果来看,苹果可能将这部分放进原生实现了,所以这里我们就只能使用这个比较早期版本的 js 实现的内容判断,会出现一些网页最新版 Safari 会出现阅读模式的按钮而你使用它却判断无法进入阅读模式)
ReaderContext.h # 这就是原生实现的 C++ 代码头文件,应该是作为上面两个 js 之间的原生 Bridge,用指针插入的方法用 C++ 向 js 中插入可以直接调用的原生类和方法
Safari 的基本流程就是,当 MainWebView 上的网页的 HTML 加载完成后,执行 safari-reader-check (当然后来可能执行原生代码去判断了,或者使用了更新的 js 文件) 中的 FinderJS 的函数来判断当前网页是否存在合适的 Node 来作为 Article,判断的规则大致就是根据 H 标签的个数,文本的长度之类的参数计算出权重,最后获得一个权重最高的 Node 作为 Article 对象。如果存在这个对象,则会告知浏览器显示阅读模式的按钮,反之浏览器则不会显示这个按钮。
当用户点击了这个按钮之后,Article 对象会被提取出来,通过 ReaderContext 传递给 safari-reader.js 处理,然后加载到 ReaderWebView 里面 。在 ReaderWebView 中渲染的 HTML 是一个模板文件,阅读模式中用到的所有 CSS 样式都会在这个 HTML 里面预先定义好,我们可以通过 Safari 的控制台取到这个 HTML 文件:
在 macOS 的 Safari 中就可以取到,Safari 中在任意有阅读模式选项的页面下点击这个按钮,然后进入页面的检查模式,你会看到 HTML 代码如下:
当 body 加载的时候,它会调用 ReaderJS.loaded()
方法,这个方法负责处理 ReaderContext 传递过来的 Article 对象并将生成的 <div class="page">
这个标签添加到 id 为 article 的标签下面。 用到的两个 js 文件都是匿名脚本,其中主要的 safari_reader.js 脚本可以通过点击空白区域添加断点来截获的(因为他实现了一个监听鼠标点击的事件)。
我的实现 首先,初始化两个 webView,并在 MainWebView 上加载原网页,记得:
_webView.navigationDelegate = self _readerWebView.navigationDelegate = self
初始化的时候,我们需要为两个 MainWebView 挂载 safari-reader-check.js 的脚本,需要为 ReaderWebView 同时挂载 safari-reader-check.js 和 safari-reader.js 这两个脚本,初始化的时候赋给两个 webview 的 WKWebViewConfiguration
按照如下来定义:
- (WKWebViewConfiguration *)configuration { NSBundle *bundle = [NSBundle bundleForClass:[self class ]]; NSURL *url = [bundle URLForResource:@"PFWebViewController" withExtension:@"bundle" ]; NSBundle *scriptBundle = [NSBundle bundleWithURL:url]; NSString *readerScriptFilePath = [scriptBundle pathForResource:@"safari-reader" ofType:@"js" ]; NSString *readerCheckScriptFilePath = [scriptBundle pathForResource:@"safari-reader-check" ofType:@"js" ]; NSString *indexPageFilePath = [scriptBundle pathForResource:@"index" ofType:@"html" ]; readerHTMLString = [[NSString alloc] initWithContentsOfFile:indexPageFilePath encoding:NSUTF8StringEncoding error:nil ]; NSString *script = [[NSString alloc] initWithContentsOfFile:readerScriptFilePath encoding:NSUTF8StringEncoding error:nil ]; WKUserScript *userScript = [[WKUserScript alloc] initWithSource:script injectionTime:WKUserScriptInjectionTimeAtDocumentEnd forMainFrameOnly:NO ]; NSString *check_script = [[NSString alloc] initWithContentsOfFile:readerCheckScriptFilePath encoding:NSUTF8StringEncoding error:nil ]; WKUserScript *check_userScript = [[WKUserScript alloc] initWithSource:check_script injectionTime:WKUserScriptInjectionTimeAtDocumentEnd forMainFrameOnly:NO ]; WKUserContentController *userContentController = [[WKUserContentController alloc] init]; [userContentController addUserScript:userScript]; [userContentController addUserScript:check_userScript]; [userContentController addScriptMessageHandler:self name:@"JSController" ]; WKWebViewConfiguration *configuration = [[WKWebViewConfiguration alloc] init]; configuration.userContentController = userContentController; return configuration; }
这样我们就可以在 decidePolicyForNavigationResponse
这个回调中进行阅读模式的判断,这个回调发生的时机是在获得 HTML 响应但尚未根据 HTML 去加载的时候,所以是判断阅读模式的最佳时机:
- (void )webView:(WKWebView *)webView decidePolicyForNavigationResponse:(WKNavigationResponse *)navigationResponse decisionHandler:(void (^)(WKNavigationResponsePolicy ))decisionHandler { if ([webView isEqual:self .readerWebView]) { decisionHandler(WKNavigationResponsePolicyAllow ); return ; } [_webView evaluateJavaScript:@"var ReaderArticleFinderJS = new ReaderArticleFinder(document);" completionHandler:^(id _Nullable object, NSError * _Nullable error) { }]; [_webView evaluateJavaScript:@"ReaderArticleFinderJS.isReaderModeAvailable();" completionHandler:^(id _Nullable object, NSError * _Nullable error) { if ([object integerValue] == 1 ) { self .toolbar.readerModeBtn.enabled = YES ; } else { self .toolbar.readerModeBtn.enabled = NO ; } }]; decisionHandler(WKNavigationResponsePolicyAllow ); }
接下来就是点击阅读模式按钮的响应事件,这里我们可以不用 C++ 搭桥梁的方式传递对象指针,而直接用了一种更 tricky 的办法,将提取出来的 Article 对象以不可见的形式添加到目标的 ReaderWebView 中,然后修改获取到的 js 文件,让 safari-reader.js 在渲染完正文内容后将临时的这个不可见的节点删除。
[_webView evaluateJavaScript:@"var ReaderArticleFinderJS = new ReaderArticleFinder(document);" completionHandler:^(id _Nullable object, NSError * _Nullable error) { }] [_webView evaluateJavaScript:@"var article = ReaderArticleFinderJS.findArticle();" completionHandler:^(id _Nullable object, NSError * _Nullable error) { }] [_webView evaluateJavaScript:@"article.element.outerHTML" completionHandler:^(id _Nullable object, NSError * _Nullable error) { if ([object isKindOfClass:[NSString class]] && isReaderMode) { [_webView evaluateJavaScript:@"ReaderArticleFinderJS.articleTitle()" completionHandler:^(id _Nullable object_in, NSError * _Nullable error) { readerArticleTitle = object_in NSMutableString *mut_str = [readerHTMLString mutableCopy] // Replace page title with article title [mut_str replaceOccurrencesOfString:@"Reader" withString:readerArticleTitle options:NSLiteralSearch range:NSMakeRange(0 , 300 )] NSRange t = [mut_str rangeOfString:@"<div id=\"article\" role=\"article\">" ] NSInteger location = t.location + t.length NSString *t_object = [NSString stringWithFormat:@"<div style=\"position: absolute; top: -999em\">%@</div>" ,object] [mut_str insertString:t_object atIndex:location] [_readerWebView loadHTMLString:mut_str baseURL:self.url] _readerWebView.alpha = 0.0 f }] } }] [_webView evaluateJavaScript:@"ReaderArticleFinderJS.prepareToTransitionToReader();" completionHandler:^(id _Nullable object, NSError * _Nullable error) { }]
这里采用的使 div 不可见的方式比较特别,因为 visibilty 或者 display 或者 height 这些参数都会被 js 排除在计算的节点之外,所以用 top: -999em 的写法。
ReaderWebView 在 load 之后就会调用挂载在上面的 safari-reader.js 中的 loaded 方法:
loaded: function ( ) { if (!ReaderArticleFinderJS || this ._shouldSkipActivationWhenPageLoads()) return null ; if (this .loadArticle(), ReaderAppearanceJS.initialize(), ReadingPositionStabilizerJS.initialize(), this ._shouldRestoreScrollPositionFromOriginalPageAtActivation) { var e = 0 ; if (e > 0 ) document .body.scrollTop = e; else { var t = document .getElementById("safari-reader-element-marker" ); if (t) { var n = parseFloat (t.style.top) / 100 , i = t.parentElement, a = i.getBoundingClientRect(); document .body.scrollTop = window .scrollY + a.top + a.height * n, i.removeChild(t) } } } this ._clickingOutsideOfPaperRectangleDismissesReader && (document .documentElement.addEventListener("mousedown" , monitorMouseDownForPotentialDeactivation), document .documentElement.addEventListener("click" , deactivateIfEventIsOutsideOfPaperContainer)); var o = function ( ) { this .setUserVisibleWidth(this .lastKnownUserVisibleWidth) }.bind(this ); window .addEventListener("resize" , o, !1 ); var article_node = document .getElementById("article" ); article_node.firstChild.remove(); var message = { 'code' : 0 }; window .webkit.messageHandlers.JSController.postMessage(message); },
最后几行先是移除了 article 这个节点之下的第一个子节点,也就是我们添加上去的不可见的临时节点。然后通过 WKWebView
新的 js 交互方式发送消息,向原生的 Controller 发送一个加载完成的信号,我们可以在原生的 Controller 里面获取这个信息,然后随即开始阅读模式页面切换的动画:
- (void )userContentController :(WKUserContentController *)userContentController didReceiveScriptMessage :(WKScriptMessage *)message {}
除了 loaded 方法,safari-reader.js 还有许多代码需要修改,最终可用的版本请参考项目仓库中的文件。
一个多余的问题记录 - 「在微信中打开」按钮点击失效 在 WKWebView
中,比如微信网页有一个 「在微信中打开」的按钮会失效。这是因为微信网页的 HTML 写法是直接在 a 标签的 href 里面写上了 weixin://
这样开头的链接,对于 WKWebView
来说会作为一个普通的 URL 打开,从而无响应,不会有弹框。
解决办法 就是拦截非 Http:// 和 Https:// 开头的请求,转成应用内跳转:
- (void )webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy ))decisionHandler { if (![navigationAction.request.URL.absoluteString containsString:@"http://" ] && ![navigationAction.request.URL.absoluteString containsString:@"https://" ]) { UIApplication *application = [UIApplication sharedApplication]; #if __IPHONE_OS_VERSION_MAX_ALLOWED >= 100000 if ([application respondsToSelector:@selector (openURL:options:completionHandler:)]) { [application openURL:navigationAction.request.URL options:@{} completionHandler:nil ]; } else { [application openURL:navigationAction.request.URL]; } #else [application openURL:navigationAction.request.URL]; #endif decisionHandler(WKNavigationActionPolicyCancel ); } else { decisionHandler(WKNavigationActionPolicyAllow ); } }
PS. 最后说一句,当然,毕竟这样抓取苹果的脚本来做和系统一样的效果是很 tricky 的办法,但是: