Glide实现WebView离线图片的酷炫展示效果

4,718 阅读5分钟

前言

自从交房后,每天除了上班,大部分时间都是在地铁和公交上了。不过有了这些时间,可以好好看看文章打打基础,方便之后换新的环境。玩Android收录了很多值得阅读的文章,好的文章需要多读几次才有所收获。但收录但文章在手机上阅读有一些东西比较影响阅读体验,比如广告,比如要点击取消折叠展开文章。这两个已经在Wandroid客户端做了优化,同时将文章内容改成了深色模式,总的来说阅读体验提高了好多。 当在地铁中到了某些路段,网络信号很差,网页经常加载不出来。因此离线阅读对我来说变得很重要了。所以在端午期间,我新增了离线阅读功能,同时为了能更好的查看文章中的图片,加入了图片展示功能。具体可以看如下效果:

离线阅读和图片展示

WebView网页保存

为了能够实现文章离线阅读,需要将整个网页保存下来。我们主要关注的是html内容和相关的图片Gif资源。基于Chromium实现的WebView本身也会在网页加载时缓存网页的资源(css/js/图片等)。为了方便图片控制展示,这边选择通过Glide缓存WebView中的图片与Gif资源。

文本保存

通过document.documentElement.outerHTML可以获得网页的html内容,可以在webview中通过addJavascriptInterface方法传入用于js层调用java层的对象如android。于是我们可以通过如下方式保存网页内容:

 private fun downloadHtml() {
        val script = """
            javascript:(function(){
                var url = document.URL.toString();
                var html = document.documentElement.outerHTML;
                android.saveHtml(url,html);
            })();
        """.trimIndent()
        webView.loadUrl(script)
    }

特别注意的是js代码中不要写注释,否则会加载失败。WebView中加载js脚本比较难调试,我们可以在chrome://inspectConsole控制台下调试代码的正确性。

对应addJavascriptInterface对象需要有如下方法,我们可以将html内容保存到sd卡下或者/data/data/${application}目录下

/**
     * 离线保存html
     */
    @JavascriptInterface
    fun saveHtml(url: String, html: String) {
        loading.postValue(true) 
        Constants.IO.execute {
            FileUtil.saveHtml(url, html)
            msg.postValue("下载成功")
            loading.postValue(false)
        }
    }

图片缓存

为了方便控制webview中的图片,保证点击缩放展示功能中图片的流畅性,我们将图片资源放到Glide中缓存。这样webview中的图片使用Glide加载,点击图片展示再用Glide加载时可以共享缓存资源。 我们可以通过重写WebViewClient类的shouldInterceptRequest重定向一些资源请求。不过一些图片资源的url并不是严格按照.jpg/png/gif的格式,无法判断一些url是否是图片资源。因此需要通过head请求获取content-type。同时还需要将结果保存起来(用于离线情况,okhttp并不支持head请求的缓存)。

private val typeDao = AppDataBase.get().urlTypeDao()
fun head(url: String?): String {
	val md5 = MD5Utils.stringToMD5(url)
	val value = typeDao.getType(md5)
	if (value == null) {
	    val client = OkHttpClient.Builder()
	        .addNetworkInterceptor(CacheInterceptor())
	        .build()
	    val request = Request.Builder()
	        .url(url)
	        .head()
	        .build()
	    val res = client.newCall(request).execute()
	    val type = res.header("content-type")
	    val result = type ?: ""
	    typeDao.insert(UrlTypeVO(md5, result))
	    return result
	}
	return value
}

于是,shouldInterceptRequest方法中就可以重定向图片类型的请求了。

val head = Wget.head(url)
if (head.startsWith("image")) {
    val bytes = GlideUtil.syncLoad(url, head)
    if (bytes != null) {
        return WebResourceResponse(
            head,
            "utf-8",
            ByteArrayInputStream(bytes)
        )
    }
}

这里我们需要通过Glide同步获取图片的byte[]数据,还要区分图片gif

public class GlideUtil {
    public static byte[] syncLoad(String url, String type) {
        boolean isGif = type.endsWith("gif");
        if (isGif) {
            try {
                FutureTarget<byte[]> target = Glide.with(App.instance)
                        .as(byte[].class)
                        .load(url)
                        .decode(GifDrawable.class).submit();
                return target.get();
            } catch (Exception e) {
                e.printStackTrace();
            }
            return null;
        }
        FutureTarget<Bitmap> target = Glide.with(App.instance)
                .asBitmap().load(url).submit();
        try {
            Bitmap bitmap = target.get();
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            bitmap.compress(Bitmap.CompressFormat.JPEG, 100, baos);
            return baos.toByteArray();
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }
}

至此webview中图片的加载最终通过的是Glide,图片也会通过它缓存到内存和磁盘中。

图片展示

为了顶部的效果图,需要添加图片的点击事件,还要知道图片所在屏幕中的位置和尺寸(用于转场效果)。

添加点击事件,获取图片位置

在webview加载网页结束后,我们给每个img添加onclick事件,获取图片地址,尺寸,位置信息。一些站点(如微信)图片是懒加载的,在离线模式下由于跨域问题最终导致图片无法加载。因此需要从dataset中取出url重新设置。还有一些站点(CSDN)本身有点击展示效果,需要stopPropagation阻止事件冒泡屏蔽。

var imgs = document.getElementsByTagName("img");
for(var i=0;i<imgs.length;i++){
    var dataset = imgs[i].dataset;
    if(dataset && dataset.src && dataset.src!=imgs[i].src){
        imgs[i].src = dataset.src;
    }
    imgs[i].onclick = function(e){
        var target = e.target;
        var rect = target.getBoundingClientRect();
        android.showImage(target.src,rect.x,rect.y,rect.width,rect.height,outerWidth);
        e.stopPropagation();
    };
}

这里为什么还要在传outerWidth(浏览器宽度)呢,在调试中(见下图),我们发现通过getBoundingClientRect获取的尺寸宽度和手机屏幕的宽度并不是一个单位。因此需要传outerWidth用于Android端ImageView实际尺寸的计算。

图片尺寸位置获取

图片共享元素转场效果

在页面加载完成后,我们手动注入设置图片点击事件的js代码。当点击图片时,就可以得到图片url,尺寸,位置信息。在Android端就可以通过共享元素实现转场效果了。再次之前我们需要在WebView所在的布局文件中加入ImageView

 <FrameLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

    <io.github.iamyours.wandroid.widget.WanWebView
            android:id="@+id/webView"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:overScrollMode="never" />
    <io.github.iamyours.wandroid.widget.TouchImageView
            android:id="@+id/showImage"
            android:layout_width="100dp"
            android:layout_height="100dp"
            android:visibility="invisible"
            app:showImage="@{vm.image}" />
</FrameLayout>

通过DataBinding中绑定自定义属性,实现共享元素转场效果。

@BindingAdapter(value = ["showImage"])
fun bindImage(iv: ImageView, showImage: PositionImage?) {
    showImage?.run {
        val lp = iv.layoutParams as ViewGroup.MarginLayoutParams
        val parentWidth = iv.context.resources.displayMetrics.widthPixels
        val scale = parentWidth / clientWidth
        lp.width = (width * scale).toInt()
        lp.height = (height * scale).toInt()
        lp.leftMargin = (x * scale).toInt()
        lp.topMargin = (y * scale).toInt()
        iv.layoutParams = lp
        iv.requestLayout()
        iv.displayWithUrl(url, lp.width, lp.height) {
            iv.postDelayed({
                val activity = iv.getActivity()
                activity?.let {
                    val pair: Pair<View, String> = Pair(iv, "image")
                    val option =
                        ActivityOptionsCompat.makeSceneTransitionAnimation(
                            it,
                            pair
                        )
                    val intent = Intent(it, ImageShowActivity::class.java)
                    intent.putExtra("url", url)
                    it.startActivityForResult(intent, 1, option.toBundle())
                }
            }, 200)
        }
    }
}

项目地址

github.com/iamyours/Wa…

  • 暗黑系列
  • 全网独一适配 掘金/简书/CSDN/公众号/玩Android文章黑夜模式
  • 无广告,无需点击展开
  • 图片显示,支持缩放,共享元素无缝转场
  • 支持离线阅读,地铁上阅读更方便

下载地址v1.1.0

后续功能

  • 代码图片展示(开发中,现支持掘金,简书)
  • 文章分类收藏