React Native 针对WebView 截全屏长图

1,707 阅读5分钟

预备材料:WebView、captureRef、CameraRol

WebView 原先置在React Native中的,现在由社区托管。

WebView 库: react-native-webview

CameraRol 库:react-native-cameraroll

captureRef库:react-native-view-shot

背景:

来自团队的一次业务的技术方案。

我们需要为用户提供一份用于打印的信息模板,该模板是一份定义编排好的HTML,用于PC端和移动端进行输出打印。

但后来,由于追加产品需求,需要针对这份模板信息,为用户提供移动端的预览、保存和分享的功能。

由于最初的设计失误,导致移动端(RN侧)单独采用了RN组件绘制的模板。之后由于这份模板的改动逐渐复杂化,导致同时维护HTML和RN模板的成本随之变大。

因此后续的优化还是需要统一模板,取消

移动端(RN侧)的这一份模板,使其采用WebView方式加载HTML模板。

需要解决的问题:

在移动端加载HTML的方式是使用WebView,其能够直接满足用户的预览需求,但是对于用户将所预览到的模板保存下来,就会面临如下问题:

1. 在移动端将页面(组件)到保存为图片到本地,这里是基于RN侧去完成这件事,因此采用了截图方式,但是用过WebView组件截图的同学应该了解,如果网页很长,截图的方式只能截取WebView的视口区域,因此如何截取完整的网页是一个问题。

2. 针对WebView进行截图,还需考虑截取时机,原因在于,如果图片还未加载完毕,就进行截图操作,则未加载完的图片区域是空白的,这对于ToC业务需要为用户提供一份完整的模板截图是不合格的,所以正确的截图时机也是需要考虑的问题。

解决方案:

针对第一个问题——截取完整网页,我们已经知道对于WebView的截图,截取时只能Cut出视口区域的部分,那么只需要将WebView的高度设置的足够大即可,但是对于不同网页的高度是未知的,多大才合适是没办法提前就知道的。那么可以让网页自己告诉你它有多高,然后我们动态设置WebView的高度为网页高度,这样再截图的话,就是完整的网页了!

如何做到上述操作呢?

我们只需利用WebView的内置属性:injectJavaScriptonMessage  具体思路如下:

当用户点击按钮进行截图操作的时候,我们使用 injectJavaScript 给WebView的网页,注入一段代码,来获取网页的高度,然后将高度信息传递出来:

<Button  
    title="Cut Page"  
    onPress={() => {    
        const js = `      
            var height = document.body.scrollHeight;     
            window.ReactNativeWebView.postMessage(height);
            true;`;    
        webRef.current.injectJavaScript(js);
  }}
/>

这里webRefWebView 组件的引用,其中注入的JS代码,向RN发送消息的API,并非window.postMessage,而是 window.ReactNativeWebView.postMessage主要是第五版之后的变化

另一边,WebView 监听网页消息:

<View
 style={{ width: '100%', height: autoHeight }}>
  <WebView
    ref={webRef}
    source={{ uri: 'https://www.baidu.com/' }}
    onMessage={event => {
      let height = event.nativeEvent.data;
      setAutoHeight(Number(height));
      setTimeout(() => {
        captureRef(webRef, {
          format: 'jpg',
          quality: 0.8,
        })
          .then(uri => {
            CameraRol.save(uri, { type: 'photo' })
              .then(val => { 
               console.log('Image saved to', val);
              })
              .catch(err => { 
               console.log('save error', err);
              });
          })
          .catch(err => {
            console.log(err);
          });
      }, 100);
    }} />
</View>

在WebView的 onMessage 下,获取到高度后setAutoHeight WebView的高度,这里是给上级View 进行设置了,WebView会自己撑开高度。

效果如下:

完整代码如下:

function App() {
    const webRef = useRef(null);
    const[autoHeight, setAutoHeight] = useState(500);
    return ( 
        <>
            <Button 
                title = "Cut Page"
                onPress = { () = >{
                    const js = `
                        var height = document.body.scrollHeight;
                        window.ReactNativeWebView.postMessage(height);
                        true;`;
                    webRef.current.injectJavaScript(js);
                    }
                }
            />      
            <View style={{ width: '100%', height: autoHeight }}>
            <WebView               ref={webRef}               source={{ uri: 'https:/ / www.baidu.com / ' }}               onMessage={event => {                  let message = event.nativeEvent.data;                  setAutoHeight(Number(message));                  setTimeout(() => {                    captureRef(webRef, { format: 'jpg ', quality: 0.8, })                     .then(uri => {                        CameraRol.save(uri, { type: 'photo ' })                          .then(val => { console.log('Image saved to ', val); })                         .catch(err => { console.log('save error ', err); });                       })                    .catch(err => { console.log(err); });          }, 100);        }}      /> 
      </View> 
  </>); }

要解决第二个问题——图片全部加载完毕后,才能允许用户截图,这对于我们自定义HTML模板来说相对简单。(这里仅以截取自定义的模板为例,对于任意网页的截取,若需要监听全部图片加载完毕,也可以采用注入代码的形式)。

这里我们定义模板如下:

<body>
  <div>
    <img
      data-src="https://images.unsplash.com/photo-1611095973763-414019e72400?ixid=MXwxMjA3fDF8MHxwaG90by1wYWdlfHx8fGVufDB8fHw%3D&ixlib=rb-1.2.1&auto=format&fit=crop&w=1051&q=80"      style="width: 100%;height: 100%;">
  </div>
  <div>
    <img
      data-src="https://images.unsplash.com/photo-1617219474432-2e0b3ba569b8?ixid=MXwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHw%3D&ixlib=rb-1.2.1&auto=format&fit=crop&w=1050&q=80"      style="width:  100%;height: 100%;">
  </div>
  <div>
    <img
      data-src="https://images.unsplash.com/photo-1617167152423-61130d40b0fa?ixid=MXwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHw%3D&ixlib=rb-1.2.1&auto=format&fit=crop&w=1234&q=80"      style="width:  100%;height:  100%;">
  </div>
  <script>
    function loadImg() {
      const imgs = document.getElementsByTagName('img');
      const l = imgs.length
      if(l === 0) {window.ReactNativeWebView.postMessage('img'); return;}
      const imgPromises = []
      for (let i = 0; i < l; i++) {
        imgPromises.push(
          new Promise((resolve, reject) => { 
           imgs[i].onload = () => { resolve(i); imgs[i].removeAttribute('data-src') }
            imgs[i].onerror = () => { reject(i) }
            imgs[i].src = imgs[i].getAttribute('data-src')
          }))
      }      Promise.all(imgPromises).then((imgs) => {
        window.ReactNativeWebView.postMessage('img')
      }).catch(() =>{ 
       // 有图像挂了 
       window.ReactNativeWebView.postMessage('img-error')
      })
    }
    loadImg()
  </script>

然后,对于上面的RN代码进行简单的改造,只需增加一个控制按钮点击的状态,当所有图片成功加载后,设置即可保证只有所有的图片都加载完毕了,用户才能点击截图操作:

function App() {
  const webRef = useRef(null);
  const [btnDisabled, setBtnDisabled] = useState(true);
  const [autoHeight, setAutoHeight] = useState(500);
  return (
    <>
      <Button
        title="Cut Page"
        disabled={btnDisabled}
        onPress={() => {
          const js = `
            var height = document.body.scrollHeight;
            window.ReactNativeWebView.postMessage(height);
            true;`;
          webRef.current.injectJavaScript(js);
        }}
      />
      <View style={{width: '100%', height: autoHeight}}>
        <WebView
          ref={webRef}
          source={{ uri: '自定义模板'}}
          onMessage={event => {
            let message = event.nativeEvent.data;
            console.log(message);
            if (message === 'img') {
              setBtnDisabled(false);
              return;
            }
            setAutoHeight(Number(message));
            setTimeout(() => {
              captureRef(webRef, {
                format: 'jpg',
                quality: 0.8,
              }) 
               .then(uri => {
                  CameraRol.save(uri, {type: 'photo'})
                   .then(val => {
                      console.log('Image saved to', val);
                    })
                    .catch(err => {
                      console.log('save error', err);
                    });
                })
                .catch(err => {
                  console.log(err);
                });
            }, 50);
          }}
        />
      </View>
    </>
  );}

效果如下:(只有图片都加载完毕了,截图按钮才能变为可点击)

简单封装一下

上面我们成功解决了WebView截取完整网页的问题,但是为了更好的满足用户浏览和截取网页的需求,我们不能让用户操作截图的时候,突然放大WebView,这样就很奇怪了,一个投机取巧的方法是,让用于截图的WebView,不让用户感知(隐藏起来),也就是页面其实由两个WebView,用户最终截取全图的WebView被使用样式隐藏起来。

const WebCompoent = React.forwardRef((props, ref) => {
  const { setBtnDisabled, setAutoHeight, autoHeight, src } = props;
  const styles = StyleSheet.create({
    container:
      { alignSelf: 'center', width: '80%', height: 200, },
    hidden: 
     { position: 'absolute', left: 10000, width: '100%', height: autoHeight, },
   });
  return (
    <View style={autoHeight ? styles.hidden : styles.container}>
      <WebView
        ref={ref}
        source={{ uri: src }}
        onMessage={event => {
          let message = event.nativeEvent.data;
          console.log(message);
          if (/img/.test(message) && setBtnDisabled) {
            if (message === 'img') { setBtnDisabled(false); }
            else { ToastAndroid.show('图片有损坏!', ToastAndroid.SHORT); }
            return;
          }
          if (setAutoHeight) {
            setAutoHeight(Number(message));
            setTimeout(() => {
              captureRef(ref, { format: 'jpg', quality: 0.8, })
                .then(uri => {
                  CameraRol.save(uri, { type: 'photo' })
                    .then(val => { console.log('Image saved to', val); })
                    .catch(err => { console.log('save error', err); });
                })
                .catch(err => { console.log(err); });
            }, 100);
          }
        }} />
    </View>);
});
const SaveBtn = (props) => {
  cosnt { src } = props;
  const [btnDisabled, setBtnDisabled] = useState(true);
  const [autoHeight, setAutoHeight] = useState(100);
  const webRef = useRef(null);
  return (
    <>
      <Button
        title="do it"
        disabled={btnDisabled}
        onPress={() => {
          const js = `
                        var height = document.body.scrollHeight;
          window.ReactNativeWebView.postMessage(height);
          true;`;
          webRef.current.injectJavaScript(js);
        }}
      /> 
     <WebCompoent
        setBtnDisabled={setBtnDisabled}
        ref={webRef} autoHeight={autoHeight}
        setAutoHeight={setAutoHeight}
        src={src}
      />
    </>);
};

调用:

function App() {  
return (
    <>
      <WebCompoent src= { 'XX.html'} />
      <SaveBtn src={ 'XX.html' } />
       </>
    );
}

不过上述封装,需要针对自定义的模板网页,因为需要网页传递出,图片已经就绪的消息,好让截图按钮可点击。(针对任意网页的截图,只需要考虑注入给网页的代码能够监听出所有图片加载完毕)