whinc.github.io icon indicating copy to clipboard operation
whinc.github.io copied to clipboard

H5 实现保存图片的采坑记录

Open whinc opened this issue 5 years ago • 16 comments

需求背景

一句话描述需求:答题并根据答案生成你是什么食物,可长按保存图片。

体验地址:https://personal.webank.com/s/hj/op-mall/food-festival/index.html

示例图片

问题1:字体文件太大

活动页用到了特殊的中文字体“汉仪小麦”,完整的字体文件约 6.9MB,而实际活动页只用到了很少的一部分字体,全部加载会增加整个页面的加载时间,需要对字体进行按需裁剪。

使用 fontmin 工具进行字体裁剪,将要裁剪的文字集合保存到文本文件keep.txt中(有重复文字没关系会自动去重),通过下面命令裁剪字体文件:

# input.ttf 输入的字体文件
# output 裁剪后的字体输出目录,包含了多种字体格式
fontmin -t "$(cat keep.txt)" input.ttf output

在 CSS 中引入裁剪后的字体文件,下面这段代码是 fontmin 自动生成的,其根据每个平台所支持的字体格式选择最合适的:

@font-face {
    font-family: "HYXiaoMaiTiJ";
    src: url("hyxm.eot"); /* IE9 */
    src: url("hyxm.eot?#iefix") format("embedded-opentype"), /* IE6-IE8 */
    url("hyxm.woff") format("woff"), /* chrome, firefox */
    url("hyxm.ttf") format("truetype"), /* chrome, firefox, opera, Safari, Android, iOS 4.2+ */
    url("hyxm.svg#HYXiaoMaiTiJ") format("svg"); /* iOS 4.1- */
    font-style: normal;
    font-weight: normal;
}

活动页中用到了约1100个不同汉字,裁剪后大小从 6.9MB 降为 284KB 左右,体积缩减了约 96.5%,从实际体验上看,可以接受了。

-rw-r--r-- 1 whincwu staff 284K Jul 24 15:57 hyxm.eot
-rw-r--r-- 1 whincwu staff 710K Jul 24 15:57 hyxm.svg
-rw-r--r-- 1 whincwu staff 284K Jul 24 15:57 hyxm.ttf
-rw-r--r-- 1 whincwu staff 284K Jul 24 15:57 hyxm.woff

问题2:保存网页快照到本地图片

如果可以调用原生的截图接口,实现这个需求应该是比较简单的。但这是一个纯 H5 页面,要求使用 web 技术实现,有两种思路: 方案一:调用后台接口,后台渲染网页成图片返回给前端 优点:前端不需要太多工作量;后台可以利用成熟的网页渲染库处理;不存在跨域等问题。 缺点:增加了后台工作量和复杂度;调用接口返回需要时间,前端会有一定的等待期,这会造成体验下降;前端页面发生需求变化时,需同时更新后台渲染模板,可能会造成更新不及时,增加了维护成本。

方案二:前端遍历 DOM 结构将页面绘制到画布上后,导出成 base64 图片 优点:不依赖后台;及时性好;体验好。 缺点:对前端技术挑战比较大(好在有现成的第三方库完成这件工作);图片跨域会有一些问题。

我选择了第二种方案,使用 html2canvas 库对网页的部分进行“快照”,html2canvas 这个库是一个纯前端的库,其原理是读取 DOM 上的样式信息,在 canvas 上绘制按照 W3C 的 CSS 规范渲染 DOM,只支持部分 CSS 属性,不过也足够一般的使用场合了。”截图“的代码如下:

const el = document.querySelector('#result')
html2canvas(el).then(canvas => {
  const img = new Image()
  img.style.display = "block"
  // 将 canvas 导出成 base64
  img.src = canvas.toDataURL('image/jpeg')
  // 添加图片到预览
  document.querySelector('#preview').appendChild(img)
})

去年在做 Webank App 存本取息存款心意卡时也用过这个库,当时是 0.5-beta 版,那个版本存在不少问题,例如不支持高清屏导致图片模糊,好在截止做这个活动页时,版本已经来到了 1.0-alpha 版,解决了那一版的很多问题,节省了不少时间。

问题3:微信分享网页无反应

开发完后给测试同学体验时,发现微信通过右上角分享出去时,没有任何反应或者发送失败。

以前听过微信分享图片有限制,怀疑是画布导出的图片过大,超出了微信分享缩略图大小限制。

控制台打印图片大小出来确实比较大,在我的小米 MIX2 上大概 300 KB 左右(这个值在不同分辨率手机上会不同,分辨率越高越大),解决办法就是降低图片大小,canvas 的 toDataURL 接口有两个参数,第一个参数是导出的图片类型,第二个参数是图片质量(仅当图片类型是 jpeg 时有效)。下调导出图片质量,再尝试分享是否成功,如此返回下调多次后,在图片尺寸降低到几十KB 后,图片终于可以分享成功了。

 img.src = canvas.toDataURL('image/jpeg', 0.92)

分享的问题虽然解决,但是图片清晰度严重不足,这个解决办法行不通。

在查阅部分网上资料后,发现微信分享网页时,自动提取页面内第一张可见图片作为缩略图,缩略图最大不能超过 32KB,否则发送失败。既然微信分享时的缩略图是取第一张图片,那就放一张小于32kb的图片在页面内最前面,作为分享时的缩略图,只要它不占用空间就不会影响布局,这个方法最终实践简单可行。

注意:这个图片不能设置为display: none,否则微信取不到该图片。

上面这种解决方法最后还是没有使用,原因是有更好的处理方式了。在同事的帮助下,使用还未被和谐的微信 JSAPI 设置分享缩略图,既解决了分享缩略图的问题,还可以定制分享的标题和描述内容。

问题4:资源预加载并展示进度

活动页中用到了大量的图片,网络慢时会出现图片加载不完全的情况,而且页面中有不少动画,如果图片没有加载完,动画看起来比较诡异。所以,图片预加载是非常必要,其原理是利用浏览器的缓存机制——优先使用已经下载过的图片。

常见实现是在页面中将一些要用到的图片以<img/>标签的形式放入页面中且使其不可见,浏览器自动解析下载这些图片,后面再用到相同的图片时,无需再次下载就立即可用。也可以通过 JS 动态创建HTMLImageElement实例来下载图片,这里我使用了第二种,主要是方便 JS 操作。

图片资源通过动态创建Image并设置其src属性触发浏览器加载,监听 load 和 error 事件来统计已完成的数量,代码如下:

// 待加载的资源路径
const preloadAssets = [ ... ]
const total = preloadAssets.length
let current = 0

const onLoad = (url) => {
  ++current
  const percent = parseInt(current / total * 100)
  updateProcess(percent)
  if (current >= total) {
    // 加载完成,跳转到活动首页
  }
}

preloadAssets.forEach(url => {
  const img = new Image ()
  img.src = url
  img.onload = () => onLoad(url)
  img.onerror = () => onLoad(url)
})

如果资源很多要加载比较久,可以加一个最大时长限制,超过时限后,即使没加载完也进去首页(资源依然在后台异步加载),这样可以让用户可以尽快与页面互动,减少用户在等待页的流失。

问题5:字体预加载及展示问题

字体文件和图片一样也需要预加载,不过是放在首页index.html中进行的。

这里有个坑,字体文件渲染时才会加载,如果页面预加载时没有地方用到,浏览器不会去下载,解决办法在页面看不见的地方放一个使用字体的标签,触发浏览器加载字体资源。

预加载字体后,发现 Loading 页使用了自定义字体的进度百分数一开始不显示,然后突然从大于 0 的某个百分数开始显示。

这里又是一个坑(与其说是坑,倒不如说是知识点的欠缺--!),自定义的字体在加载完成之前,字体如何展示取决于font-display属性(用法可参考这篇文章 font-display 的使用),这个属性支持 5 个值auto | block | swap | fallback | optional,大部分浏览器默认是block,表示在字体加载完成前,以一种”隐形“字体(或者说不带墨水的字体)显示文字,即文字占用空间但是不可见,这也是造成上面问题的源头。这个属性的另一个值swap,表示字体加载完成前,先以后备字体显示文字,待字体加载完后立即替换成自定义的字体。swap取值正好可以解决上面问题,代码如下:

<html>
  <head>
    <style>
      @font-face {
        font-family: "hyxm";
        src: url("./fonts/hyxm.eot"); /* IE9 */
        src: url("./fonts/hyxm.woff") format("woff"), /* chrome, firefox */
          url("./fonts/hyxm.ttf") format("truetype"), /* chrome, firefox, opera, Safari, Android, iOS 4.2+ */
          url("./fonts/hyxm.svg#HYXiaoMaiTiJ") format("svg"), /* iOS 4.1- */
          url("./fonts/hyxm.eot?#iefix") format("embedded-opentype"); /* IE6-IE8 */
        font-style: normal;
        font-weight: normal;
        /* 在自定义字体加载前,先使用后备字体 */
        font-display: swap;
      }
    </style>
  </head>
  <body>
    <!-- 浏览器只有渲染时才加载字体文件,所以这里放一个不可见的字体,触发浏览器加载字体资源 -->
    <span style="visibility: hidden; position: absolute; top: -10em; font-family: hyxm">hyxm</span>
  </body>
</html>

问题6:IOS 上不支持音频自动播放

<audio> ios上无法自动播放,设置autoplay属性或者调用play()都不行,网上查了下遇到类似问题的不少,原因是 IOS 对此进行了限制,必须在用户发生交互行为时才能播放(避免了页面打开各种广告声音~~),从 Chrome 66 开始也加入了这项限制,更多细节查阅这边文章Chrome 66禁止声音自动播放之后

比较常见的解决办法,是在用户触摸屏幕任何地方后开始播放音频,这样能在最早的时机开始音频的播放,而且兼容性好。

el.addEventListener('touchstart', function () {
  if (!hasInteracted) { // 只触发一次
    hasInteracted = true
    musicEl.play()
  }
})

问题7:长按图片A分享图片B

活动页答题完成后的结果页(下面左图),用户可以长按保存图片或分享图片(下面右图)。

根据前面的解释,在结果页会对网页进行快照得到一张 base64 图片,并替换网页原来的内容,这样用户可以长按图片触发微信的操作弹窗,这里的长按的图片和被分享图片必然是同一样的图片。但是,需求有要求看到的内容和分享的图片是不一致的,很矛盾。

确实很矛盾,但还是得想办法。对比上面两个图片发现只有底部不一样,所以思路是在分享图片(下面右图)上方盖一层(下面左图),这样就可以达到预期效果:看到的是左图,长按食物图片时分享的是右图。

但是使用这种遮罩的方式,又会产生一个新的问题,长按二维码时,分享出去的是二维码而不是上面右图,这是因为长按事件是传递到了遮罩层上的二维码图片,触发了图片默认的长按行为。

解决办法是将遮罩层的根元素加上pointer-event: none属性,关于该属性的用法可参考这篇文章 pointer-events,一个神奇的css属性。这个属性可以让事件忽略某个元素及其子节点,仿佛这个元素不存在一般。添加该属性后,长按事件可以透过遮罩层直达底层的分享图片,从而触发待分享图片的长按事件。经过这样处理后,无论长按界面哪个地方(按钮除外)都分享的是上面右图,问题解决。

问题8:微信长按图片无法识别其中的二维码

该情况出现在部分 iphone 手机上,安卓出现很少。起初怀疑是二维码图片太小导致的,更换一张大图就可以识别了,正准备更换大图,发现同事用一张同等尺寸的二维码替换后,之前不能识别的手机却可以识别了,这就纳闷了。。。

对比了下我们两个生成的二维码,发现我的二维码密度较低,看了下生成二维码的工具,还有一个容错率可选,容错率越高,二维码密度越高,我的二维码容错率选的是 7%,同事选的是 30%,提高容错率后重新生成二维码替换,问题解决。

总结

上面是以我的视角列举了一些开发遇到的问题,还有一些其他小问题在这里没有列举。活动中的动效是 UI 的同事精心调制的,其中也有不少值得学习的地方。

这次活动页的开发过程中所踩过的坑,也暴露出部分知识点的欠缺,这部分后面是需要“补课"的。

whinc avatar Aug 06 '18 12:08 whinc

学习了 谢谢!

wzc0x0 avatar Dec 11 '18 10:12 wzc0x0

点赞

jiaopianjun avatar Dec 12 '18 03:12 jiaopianjun

总结的非常好

fengxianqi avatar Dec 12 '18 05:12 fengxianqi

很好,后边可能需要 mark

allendongyx avatar Dec 12 '18 11:12 allendongyx

webpack有没有字体裁切这种插件?

codexu avatar Dec 13 '18 03:12 codexu

楼主楼主,可以看下您的源码吗?最近业务遇到长按保存,html2canvas的细节我想看看你怎么避坑

jonnzer avatar Dec 13 '18 06:12 jonnzer

你好。我想请问一下问题7是怎么盖上去的,通过z-index吗

hu-shuaishuai avatar Dec 14 '18 05:12 hu-shuaishuai

你好。我想请问一下问题7是怎么盖上去的,通过z-index吗

是的。

whinc avatar Dec 14 '18 06:12 whinc

楼主楼主,可以看下您的源码吗?最近业务遇到长按保存,html2canvas的细节我想看看你怎么避坑

完整源码属于公司不能公开,我摘取部分代码给你参考。

    // 提取快照
    takeSnapshot () {
      const el = document.querySelector('#result')
      html2canvas(el).then(canvas => {
        const img = new Image()
        img.style.display = "block"
        img.src = canvas.toDataURL('image/jpeg')
        document.querySelector('#preview').appendChild(img)
      })
    }

whinc avatar Dec 14 '18 06:12 whinc

谢谢你。我还想问一下 你是写了两个div吗 我看一个是id="result",用于生成快照,一个class="result-wrapper"用与用户显示,请问思路是这样的吗

hu-shuaishuai avatar Dec 18 '18 08:12 hu-shuaishuai

谢谢你。我还想问一下 你是写了两个div吗 我看一个是id="result",用于生成快照,一个class="result-wrapper"用与用户显示,请问思路是这样的吗

是的,因为用户看到的内容和实际分享的内容不同,所以使用了两个 DIV 容器。

whinc avatar Dec 18 '18 09:12 whinc

意外关闭了issue,重新打开。

whinc avatar Dec 18 '18 09:12 whinc

谢谢你。我还想问一下 你是写了两个div吗 我看一个是id="result",用于生成快照,一个class="result-wrapper"用与用户显示,请问思路是这样的吗

是的,因为用户看到的内容和实际分享的内容不同,所以使用了两个 DIV 容器。

好的 。谢谢楼主。功能是实现了,

hu-shuaishuai avatar Dec 18 '18 09:12 hu-shuaishuai

最近碰到iOS端 html2canvas生成的图片在微信端保存图片放大截取的问题

wzc0x0 avatar Dec 21 '18 15:12 wzc0x0

大部分安卓机对base64图片无法长按保存,楼主解决了吗?

sanyueyu avatar Oct 24 '19 03:10 sanyueyu

@sanyueyu 没有处理这种情况。一种方法是提示用户无法长按保存时,点开图片后再保存。另一种方法是将base64传给后端接口转成图片,再放入 img 标签内。

whinc avatar Oct 24 '19 04:10 whinc