阅读 1262

WebView适配文章黑夜模式

为了能够让简书,掘金,CSDN,公众号的文章展示成黑夜模式,需要webview做相关适配。原理其实也比较简单,只要加载页面时替换相关的css样式做替换。实际实现效果每个站点各有不同,下面就介绍下每个站点是如何做实现的。

项目地址

github.com/iamyours/Wa…

简书

reader-night-mode

简书网站是有黑夜模式的,所以实现起来相对简单。但是默认用webview加载简书文章时,它显示的是日间模式效果。打开chrome调试器,然后再简书上切换黑夜模式,我们可以看到使用黑夜模式时,body会有一个reader-night-mode的class样式加进去。

1

猜测简书的黑夜模式和这个class样式有关,那我们可以通过

WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG)
复制代码

调试webview,在chrome浏览器上输入chrome://inspect,然后就可以调试web页面了。我们打开一篇简书文章,通过调试器我们将body的样式替换成reader-night-mode,就会发现当前文章已经变成黑夜模式的了。

2

展开全文,去导航,去广告

为了使阅读体验更好,我们在打开文章时直接展开全文,同时去掉导航还有广告等和文章内容无关的元素,我们先通过调试器做测试。

3
4

正则替换css

通过刚刚的调试,发现这些效果对应的css样式是在当前html页面的head标签下,并不是通过css文件形式。因此先通过OkHttp请求文章地址生成html字符串,然后通过正则替换相关css。 先创建一个Wget工具类,用于将网页转成字符串,这里注意请求头固定成移动设备。

object Wget {
    fun get(url: String): String {
        val client = OkHttpClient.Builder()
            .build()
        val request = Request.Builder()
            .url(url)
            .header(
                "user-agent",
                "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3887.7 Mobile Safari/537.36"
            )
            .build()
        val response = client.newCall(request).execute()
        return response.body()?.string() ?: ""
    }
}
复制代码

然后创建一个JianShuWebClient,适配简书css。那些写在head标签下的样式,通过观察发现统一写在了<style data-vue-ssr-id>下面,我们只需通过正则表达式找到它,然后replace替换我们放在assets下的css(拷贝自原style下的css,做了相关修改),然后将body的样式替换成reader-night-mode

class JianShuWebClient:WebViewClient(){

    override fun shouldInterceptRequest(view: WebView?, url: String?)
            : WebResourceResponse? {
        val urlStr = url ?: ""
        if (urlStr.startsWith("https://www.jianshu.com/p/")) {
            val response = Wget.get(url ?: "")
            val res = darkBody(replaceCss(response, view!!.context))
            val input = ByteArrayInputStream(res.toByteArray())
            return WebResourceResponse("text/html", "utf-8", input)
        }
        return super.shouldInterceptRequest(view, url)
    }

    private val rex = "(<style data-vue-ssr-id=[\\s\\S]*?>)([\\s\\S]*]?)(<\\/style>)"
    private val bodyRex = "<body class=\"([\\ss\\S]*?)\""
    private fun darkBody(res: String): String {
        val pattern = Pattern.compile(bodyRex)
        val m = pattern.matcher(res)
        return if (m.find()) {
            val s = "<body class=\"reader-night-mode normal-size\""
            res.replace(bodyRex.toRegex(), s)
        } else res
    }

    private fun replaceCss(res: String, context: Context): String {
        val pattern = Pattern.compile(rex)
        val m = pattern.matcher(res)
        return if (m.find()) {
            val css = StringUtil.getString(context.assets.open("jianshu/jianshu.css"))
            val sb = StringBuilder()
            sb.append(m.group(1))
            sb.append(css)
            sb.append(m.group(3))
            val res = res.replace(rex.toRegex(), sb.toString())
            Log.e("test", "$res")
            res
        } else {
            res
        }
    }
}
复制代码

效果

5

掘金

主css文件替换

掘金网站是没有黑夜模式的(Android上有),因此适配起来相比简书麻烦一些。与简书不同的是,掘金文章的样式是通过css文件外部引入的,所以就不需要OkHttp请求转换字符串了。我们直接找到对应的文件在shouldInterceptRequest方法中替换掉

override fun shouldInterceptRequest(view: WebView?, url: String?)
            : WebResourceResponse? {
        Log.i("掘金", "url:$url")
        val urlStr = url ?: ""
        if (urlStr.startsWith("https://b-gold-cdn.xitu.io/v3/static/css/0")
            && urlStr.endsWith(".css")
        ) {
            val stream = view!!.context.assets.open("juejin/css/juejin.css")
            return WebResourceResponse("text/css", "utf-8", stream)
        }

        return super.shouldInterceptRequest(view, url)
    }
复制代码

通过插件可以看到掘金前端是通过vue编写的,编译的css会自带[data-v-xxx]的信息,每次更新时的xxx号码会更高,我们需要将[data]信息去除。参照简书黑夜模式的样式,我们在juejin.css加入黑夜模式的样式。

.article-area{padding:0 8px;background:#3f3f3f;color:#969696;}//背景,字体颜色
blockquote{background:#555;border-left:3px solid #222;margin:0px;padding:5px 16px;}//引用
code{color:#c7254e;border-radius:4px;background-color:#282828;padding:2px 4px;font-size:12px;}//代码
.hljs {
	display: block;
	padding: 5px;
	color: #abb2bf;
	background: #282c34;
	border-radius:4px;
	font-size:12px;
}

 .hljs-comment, .hljs-quote {//代码关键字颜色
	color: #5c6370;
	font-style: italic
}
...还有很多,具体见项目
复制代码

具体要注意的是背景颜色,文字颜色,代码背景,颜色,引用,表格等等。

图片问题,头像问题

掘金文章的图片是通过懒加载,使用替换的css,发现里面的图片显示不了了。所以在页面加载完成时注入图片显示脚本具体如下

 val script = """
        javascript:(function(){
            var arr = document.getElementsByClassName("lazyload");
            for(var i=0;i<arr.length;i++){
                var img = arr[i];
                var src = img.getAttribute("data-src");
                img.src = src;
            }
        })();
    """
webview.loadUrl(script)
复制代码

头像则通过接口获取用户数据然后,通过javascript修改。

  private var head = ""
    private var username = ""

    private fun loadUser() {
        val client = OkHttpClient.Builder().build()
        val req = Request.Builder().url(detailApi).build()
        val call = client.newCall(req)
        call.enqueue(object : Callback {
            override fun onFailure(call: Call, e: IOException) {
            }

            override fun onResponse(call: Call, response: Response) {
                val res = response.body()?.string() ?: "{}"
                val obj =
                    Gson().fromJson<JsonObject>(res, JsonObject::class.java)
                obj?.getAsJsonObject("d")
                    ?.getAsJsonObject("user")?.run {
                        head = get("avatarLarge").asString
                        username = get("username").asString
                    }

            }
        })
    }


    private fun getDetailApi(postId: String): String {//头像没有加载,手动调用
        return "https://post-storage-api-ms.juejin" +
                ".im/v1/getDetailData?src=web&type=entry&postId=$postId"
    }

    fun loadUserScript() {
            val script = """
                javascript:(function(){
                    document.getElementsByClassName("author-info-block")[0].children[0].children[0].style.backgroundImage = "url('$head')";
                    document.getElementsByClassName("username")[0].innerHTML="$username";
                })();
            """.trimIndent()
            webView.loadUrl(script)
        }

复制代码

效果

6

同样的CSDN的适配也掘金差不多,也是通过替换css文件完成的,这里便不再讲述具体适配。

微信公众号

微信公众号文章的样式同简书一样也是放在当前html内部。正则表达式有所不同

val rex = "(<style>)([\\S ]*)(</style>)"
复制代码

具体的意思是匹配style标签,并且内容包含字符,或者空格(换行不算)。

important强制替换

有些微信公众号里的文字标签(如p标签)本身自带了style样式,不好通过正则替换。然而css3有一个!important可以提高优先级,强制设置相关标签的属性(即便它身设置了style样式)。

7

当然important也不能滥用,否则一些你并不想改的样式(如代码)也都修改了,所以css选择器要准确匹配才可设置!important