预备材料: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的内置属性:injectJavaScript 和 onMessage 具体思路如下:
当用户点击按钮进行截图操作的时候,我们使用 injectJavaScript 给WebView的网页,注入一段代码,来获取网页的高度,然后将高度信息传递出来:
<Button
title="Cut Page"
onPress={() => {
const js = `
var height = document.body.scrollHeight;
window.ReactNativeWebView.postMessage(height);
true;`;
webRef.current.injectJavaScript(js);
}}
/>
这里webRef 为 WebView 组件的引用,其中注入的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' } />
</>
);
}
不过上述封装,需要针对自定义的模板网页,因为需要网页传递出,图片已经就绪的消息,好让截图按钮可点击。(针对任意网页的截图,只需要考虑注入给网页的代码能够监听出所有图片加载完毕)