阅读类APP涉及的技术
飞地是一款诗歌轻阅读
产品,在技术选型时内容的载体采用了HTML,这样内容可以适用于全平台显示。
轻阅读是从技术角度分析的,因为没有像微信读书这类应用有长篇文字的书籍,需要实现各种PDF和ePub格式解析以及排版,我们只需要用UIWebView即可解决。
首先内容
是body
中的一段HTML,通过接口拿到文章的内容
后替换到完整的HTML模板中
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" >
{css}
</head>
<body id='articleCon'>
{body}
</body>
</html>
{css}
是内容的样式,如标题、段落、脚注等,articleCon
是为了样式选择器。
css来源有二种情况,启动时加载服务器最新的,如果失败则使用本地的备份css
最后使用loadHTMLString
来加载替换后的HTML。
页面结构
飞地有几个内容模块都是基于HTML来做为内容的载体,但页面一般不只是纯内容,会有一些其它元数据,这些使用原生视图显示。
如上图文章详情页,整个页面的容器是UITableView
,封面图、作者、日期、内容WebView都是tableHeaderView,评论列表为Cell。
tableHeaderView的高度我们需要自己计算,而WebView的高度可以在webViewDidFinishLoad后获取,并重新设置tableHeaderView的高度。
//原生代码
var contentHeight = webView.scrollView.contentSize.height
let fitHeight = webView.sizeThatFits(CGSize(width: 1.0, height: 1.0)).height
if fitHeight > contentHeight {
contentHeight = fitHeight
}
if let documentHeight = jsBridge.getContentHeight(),
documentHeight > contentHeight {
contentHeight = documentHeight
}
jsBridge.getContentHeight()
是执行JS层的代码document.body.scrollHeight * window.scale
获取高度
Tip:直接设置
tableView.tableHeaderView.frame.height
时可能不会生效,需要重新tableView.tableHeaderView = tableHeaderView
渲染一次。
rem
文章有各种各样的样式,移动设备碎片化,使用px明显已经不满足需求了,所以我们使用rem。
//JS代码
window.scale = 1.0; //�标志当前viewport使用的scale
!function(e){function t(a){if(i[a])return i[a].exports;var n=i[a]={exports:{},id:a,loaded:!1};return e[a].call(n.exports,n,n.exports,t),n.loaded=!0,n.exports}var i={};return t.m=e,t.c=i,t.p="",t(0)}([function(e,t){"use strict";Object.defineProperty(t,"__esModule",{value:!0});var i=window;t["default"]=i.flex=function(normal,e,t){var a=e||100,n=t||1,r=i.document,o=navigator.userAgent,d=o.match(/Android[\S\s]+AppleWebkit\/(\d{3})/i),l=o.match(/U3\/((\d+|\.){5,})/i),c=l&&parseInt(l[1].split(".").join(""),10)>=80,p=navigator.appVersion.match(/(iphone|ipad|ipod)/gi),s=i.devicePixelRatio||1;p||d&&d[1]>534||c||(s=1);var u=normal?1:1/s,m=r.querySelector('meta[name="viewport"]');m||(m=r.createElement("meta"),m.setAttribute("name","viewport"),r.head.appendChild(m)),m.setAttribute("content","width=device-width,user-scalable=no,initial-scale="+u+",maximum-scale="+u+",minimum-scale="+u),i.scale=u,r.documentElement.style.fontSize=normal?"50px": a/2*s*n+"px"},e.exports=t["default"]}]); flex(false,100, 1);
html {
font-size: 62.5%;
}
上述代码分别加在文章内容HTML模板中与文章css中,而飞地的设计图输出是375pt * 667pt
,所以我们只需要把设计上的pt/50
转换成rem就行了(50是设备缩放基准值
),如设计图上的正文字体是17pt
,那么对应css的rem应该是 17pt /50 = 0.34rem
#articleCon n p {
font-size: 0.34rem;
}
缓存
由于有离线阅读需求,app启动时会提前缓存文章,其实也就是存储文章的封面图、内容HTML等,但html中也有图片,所以我们需要用正则
拿到所有img.src
,然后缓存在本地,并将文章标识为已缓存。
<img\\s[\\s\\S]*?src\\s*?=\\s*?['\"](.*?)['\"][\\s\\S]*?>*
前期我们采用的方式是将所有img.src
保持相对路径,loadHTMLString
时如果文章标识已缓存则baseURL使用本地Path,否则使用线上URL。
优化后统一换成URLProtocol
处理,提前缓存文章时用第三方图片加载库
下载好图片,等阅读文章时利用URLProtocol
机制拦截,如果是WebView的图片,判断该图片是否缓存在第三方图片加载库
中,否则手动加载图片Data并且保存在第三方图片加载库
,下次再拦截到此图片的请求直接从第三方图片加载库
缓存中取。
URLProtocol
是全局拦截,判断请求是否为WebView的图片可在shouldStartLoadWith
时附加自定义Header,在URLProtocol
识别Header就行
原生与JS交互
有二种方式,原生提供的JavaScriptCore、JS层通过iFrame加载URI(URI包括scheme与参数)原生在shouldStartLoadWith
中拦截,飞地使用了第一种。
//原生代码
/// 原生JavaScriptCore暴露给JS层的对象
@objc protocol ContentWebViewJavaScriptBridgeProtocol: JSExport {
/// 图片点击回调
func onImageClick(_ currentImageIndex: Int, _ images: [String])
}
/// 原生与JS桥接
class ContentWebViewJavaScriptBridge: NSObject, ContentWebViewJavaScriptBridgeProtocol {
//原生暴露给JS层的对象名
static let name = "EnclaveNative"
fileprivate var jsContext: JSContext?
fileprivate weak var webView: UIWebView?
var imageClickCallback: ((_ currentImageIndex: Int, _ images: [String])->())?
convenience init(webView: UIWebView) {
self.init()
guard let jsContext = webView.value(forKeyPath: "documentView.webView.mainFrame.javaScriptContext") as? JSContext else { return }
self.jsContext = jsContext
self.webView = webView
jsContext.setObject(self, forKeyedSubscript: ContentWebViewJavaScriptBridge.name as NSCopying & NSObjectProtocol)
jsContext.exceptionHandler = { (ctx, value) in
L.debug(value?.description ?? "exception")
}
}
func onImageClick(_ currentImageIndex: Int, _ images: [String]) {
//回调在UI线程
DispatchQueue.main.async {
self.imageClickCallback?(currentImageIndex, images)
}
}
}
//MARK: - Public
extension ContentWebViewJavaScriptBridge {
/// 获取html中所有图片地址
func getImages() -> [String]? {
guard let jsContext = jsContext else { return nil }
guard let jsValue = jsContext.evaluateScript("getImageSrcs()") else { return nil }
return jsValue.toArray() as? [String]
}
/// 获取内容高度
func getContentHeight() -> CGFloat? {
if let heightString = webView?.stringByEvaluatingJavaScript(from: "Enclave.getContentHeight()"),
let height = Float(heightString) {
return CGFloat(height)
}
return nil
}
/// 切换主题
func switchTheme() {
if ELThemeManager.shared.style == .night {
switchToNightMode()
} else {
switchToLightMode()
}
}
/// 切换至夜间模式
fileprivate func switchToNightMode() {
webView?.stringByEvaluatingJavaScript(from: "Enclave.switchToNightMode()")
}
/// 切换至日间模式
fileprivate func switchToLightMode() {
webView?.stringByEvaluatingJavaScript(from: "Enclave.switchToLightMode()")
}
}
- JS -> 原生
EnclaveNative.onImageClick(currentImageIndex, srcs)
- 原生 -> JS
webView.stringByEvaluatingJavaScript(from: "xxx()")
图片查看
点击内容HTML中的图片,需要在原生端显示查看。
首先在DOM加载完毕后为所有的有效img注册click事件,在事件触发时拿到所有img.src与当前img的index传到原生端并显示。
//JS代码
function getImageSrcs() {
var srcs = []
var imgs = document.getElementsByTagName('img')
for (var i = 0; i < imgs.length; i++) {
if(imgs[i].src.indexOf('data:') == 0 || imgs[i].parentNode.nodeName.toLocaleLowerCase() == 'a') {
continue
}
srcs.push(imgs[i].src)
}
return srcs
}
function onImageClick(currentImageIndex) {
var srcs = getImageSrcs()
//原生回调
EnclaveNative.onImageClick(currentImageIndex, srcs)
}
function didload() {
var imgs = document.getElementsByTagName('img')
//有效图片index,因为�可能会存在可跳转的图片
var index = 0
for (var i = 0; i < imgs.length; i++) {
//加载失败时默认图,且不可点击
if(imgs[i].naturalWidth == "undefined" || imgs[i].naturalWidth == 0) {
imgs[i].src = 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wCEAAYEBAQFBAYFBQYJBgUGCQsIBgYICwwKCgsKCgwQDAwMDAwMEAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwBBwcHDQwNGBAQGBQODg4UFA4ODg4UEQwMDAwMEREMDAwMDAwRDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDP/AABEIADIAZAMBEQACEQEDEQH/xABLAAEBAAAAAAAAAAAAAAAAAAAACBABAAAAAAAAAAAAAAAAAAAAAAEBAAAAAAAAAAAAAAAAAAAAABEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AoQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH//Z'
}
//并图片本身包含链接时也不可点击
if(imgs[i].parentNode.nodeName.toLocaleLowerCase() == 'a') {
continue
}
imgs[i].imageIndex = index++ //给img元素设置一个index
imgs[i].onclick = function(e) {
onImageClick(e.target.imageIndex) //拿当前事件的元素index然后回调
}
}
}
window.addEventListener('load', function() {
didload()
}, false)
夜间模式
关于原生iOS端实现夜间模式可查看这里,这里主要讲述web页面实现。
由于css样式优先级的机制,最新的样式可覆盖旧的样式,所以我们只需要为每种样式添加一种夜间模式样式就行。
/*夜间模式样式*/
.night-mode {
background-color: #333333;
}
.night-mode #articleCon p,
.night-mode #articleCon ol li,
.night-mode #articleCon ul li {
color: #CDCDCD;
}
在原生端切换样式时,通过JS函数把夜间模式的css附加上去就行了,切换回默认主题删除样式即可。
//JS代码
//切换至夜间模式
Enclave.switchToNightMode = function() {
document.querySelector('html').classList.add('night-mode')
}
//切换至白天模式
Enclave.switchToLightMode = function() {
document.querySelector('html').classList.remove('night-mode')
}
参考
文中有何错误还望指教~