阅读类APP涉及的技术

2017/12/19 comments

飞地是一款诗歌轻阅读产品,在技术选型时内容的载体采用了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')
}

参考

使用Flexible实现手淘H5页面的终端适配

有何错误还望指教,谢谢~