1. 长截图这件事,为什么在 WebView 里这么难搞
做过移动端 Hybrid 开发的人大概率都碰过这个需求:用户打开一个活动页、一篇长文或者一份订单详情,想一键把整个页面保存成一张长图分享出去。听起来简单,但真动手做的时候,你会发现 WebView 的长截图和普通 View 的长截图完全是两码事。
普通 View 的长截图,思路很直接:测量整个内容高度,创建一个同样高度的 Bitmap,然后调用view.draw(canvas)把内容画上去。但 WebView 不行,因为 WebView 的内容渲染是分层、分块、异步的,它内部维护着自己的渲染树和滚动容器,你直接draw出来的往往只有当前可视区域,或者干脆是一片空白。
更麻烦的是,国内大量 App 用的并不是系统原生 WebView,而是腾讯的 X5 内核(也就是大家常说的 TBS 浏览服务)。X5 在渲染机制、滚动容器结构、截图接口上和原生 WebView 有本质差异,同一套代码在原生上跑得好好的,换到 X5 上可能直接黑屏或者只截到一屏。
这篇文章就把这两条路线彻底拆开讲清楚:原生 WebView 怎么截长图,X5 WebView 又该怎么截,各自的坑在哪,参数怎么调,代码怎么写。不管你是刚接触 Hybrid 开发的新手,还是被这个问题折磨过的老手,都能从这里找到可以直接抄作业的方案。
2. 先搞懂原理:WebView 长截图的三种主流思路
在动手写代码之前,有必要把底层逻辑理清楚。WebView 长截图本质上要解决一个问题:如何拿到超出屏幕范围的那部分内容。围绕这个问题,业界主要有三种思路,各有适用场景。
2.1 思路一:滚动拼接法
这是最直观的做法。把 WebView 当成一个可以滚动的容器,每次滚动一屏,截一屏,最后把所有截图纵向拼成一张长图。
具体操作是:先记录 WebView 的总内容高度和当前可视高度,然后循环执行"滚动 → 截图 → 记录偏移量",直到滚到底部,最后用 Canvas 把所有分片按顺序画到一张大 Bitmap 上。
这个方法的优点是通用性强,原生和 X5 都能用,不依赖任何特殊接口。缺点是拼接处容易出现重复或缺失的横线,尤其是页面里有固定定位元素(比如吸顶导航栏)时,每一屏都会把导航栏截进去,拼出来就是一堆重复的导航条。另外滚动过程有动画延迟,需要等渲染稳定后再截,否则会截到半透明的中间态。
2.2 思路二:整页绘制法
这个思路是绕过滚动,直接让 WebView 把整个页面内容画到一张指定高度的 Canvas 上。核心是拿到页面的真实内容高度,然后创建一个对应高度的 Bitmap,通过WebView.draw()或者capturePicture()一次性绘制。
capturePicture()是原生 WebView 提供的一个接口,返回一个Picture对象,里面记录了整个页面的绘制指令,理论上可以还原出完整页面。但这个接口在硬件加速开启的情况下经常返回空,而且从某个系统版本开始被标记为废弃,兼容性越来越差。
draw()方法相对稳定,但前提是 WebView 的内容已经全部渲染完成,且你需要手动处理滚动偏移。实际用下来,整页绘制法在原生 WebView 上成功率尚可,但在 X5 上基本行不通,因为 X5 的内部渲染结构和原生差异太大。
2.3 思路三:内核接口法
这是最省事但也最挑环境的方法。原生 WebView 在较新版本上提供了WebView.capturePicture()的替代方案,而 X5 内核则提供了自己的长截图接口captureWholePicture()或者通过WebView.getX5WebViewExtension()拿到扩展能力。
X5 的扩展接口里有一个captureWholePicture()方法,可以直接返回整页的 Bitmap,不需要滚动拼接,也不会有重复元素的问题。但这个接口有前提:必须在 X5 内核加载完成之后调用,且部分老版本内核不支持,需要做版本判断和降级处理。
三种思路对比下来,我的建议是:优先用内核接口,接口不可用时降级到滚动拼接,整页绘制法作为最后兜底。下面这张表把三者的关键差异列清楚。
| 对比维度 | 滚动拼接法 | 整页绘制法 | 内核接口法 |
|---|---|---|---|
| 通用性 | 高,原生和X5都能用 | 中,X5上基本失效 | 低,依赖内核版本 |
| 实现难度 | 中,需要处理拼接 | 低,但坑多 | 低,调接口即可 |
| 固定元素处理 | 差,容易重复 | 好 | 好 |
| 内存占用 | 中,分片处理 | 高,一次性大Bitmap | 低,内核内部处理 |
| 推荐优先级 | 降级方案 | 兜底方案 | 首选方案 |
3. 原生 WebView 长截图完整实操
先讲原生 WebView。虽然现在很多 App 都切到了 X5,但原生方案是基础,理解了它,X5 的方案就好懂了。
3.1 获取页面真实内容高度
不管用哪种思路,第一步都是拿到页面的真实高度。原生 WebView 里可以通过注入 JavaScript 来获取:
javascript:(function() { return Math.max( document.body.scrollHeight, document.body.offsetHeight, document.documentElement.clientHeight, document.documentElement.scrollHeight, document.documentElement.offsetHeight ); })()这段脚本取了五个值里的最大值,因为不同页面结构下,这几个属性反映的高度不一样。比如有些页面body高度是 0,真正的高度在documentElement上;有些页面用了绝对定位,scrollHeight才是准的。取最大值是最稳妥的做法。
在 Android 侧通过evaluateJavascript拿到这个值:
webView.evaluateJavascript(jsCode, value -> { // value 是字符串形式的数字,可能带引号 float contentHeight = Float.parseFloat(value.replace("\"", "")); // 注意:这里拿到的是 CSS 像素,需要乘以 density 才是物理像素 float density = getResources().getDisplayMetrics().density; int realHeight = (int) (contentHeight * density); });这里有个关键点很多人会踩坑:JS 返回的高度是 CSS 像素,而 Bitmap 用的是物理像素。如果你不乘 density,截出来的图会只有实际内容的一小部分,或者被拉伸变形。比如一个页面 CSS 高度是 2000px,在 3 倍屏手机上,物理高度是 6000px,Bitmap 就得建 6000 高。
3.2 滚动拼接法的核心实现
拿到总高度后,就可以开始滚动截图了。核心逻辑是计算需要滚动的次数,然后逐屏截图。
public Bitmap captureByScroll(WebView webView, int totalHeight) { int viewHeight = webView.getHeight(); int scrollCount = (int) Math.ceil((float) totalHeight / viewHeight); Bitmap result = Bitmap.createBitmap( webView.getWidth(), totalHeight, Bitmap.Config.ARGB_8888); Canvas canvas = new Canvas(result); int currentScroll = 0; for (int i = 0; i < scrollCount; i++) { webView.scrollTo(0, currentScroll); // 关键:等待渲染完成 webView.postDelayed(() -> { Bitmap slice = Bitmap.createBitmap( webView.getWidth(), viewHeight, Bitmap.Config.ARGB_8888); webView.draw(new Canvas(slice)); canvas.drawBitmap(slice, 0, currentScroll, null); }, 100); currentScroll += viewHeight; } return result; }这段代码看起来简单,但实际跑起来问题不少。最大的问题是postDelayed是异步的,循环会瞬间跑完,而截图回调还没执行,最后返回的 result 是空的。正确的做法是用递归或者 Handler 串行执行,每次截完一屏再滚下一屏。
另外webView.draw()在硬件加速开启时可能截不到内容,需要在截图前临时关闭硬件加速,截完再恢复。这个开关的时机很讲究,关早了页面会闪烁,关晚了截到的是旧内容。
3.3 处理固定定位元素的重复问题
滚动拼接最烦人的就是吸顶导航栏。页面顶部有个固定导航,每滚一屏它就出现在截图里一次,拼出来就是好几条导航栏叠在一起。
解决办法有两个。第一个是在截图前用 JS 把固定定位元素临时改成绝对定位或者隐藏:
var fixedEls = document.querySelectorAll('*'); fixedEls.forEach(function(el) { var position = getComputedStyle(el).position; if (position === 'fixed') { el.style.position = 'absolute'; el.style.top = '0'; } });第二个办法是在拼接时跳过重复区域。比如导航栏高度是 100px,那么从第二屏开始,每屏只取从 100px 往下的部分。这个方法不需要改页面结构,但需要提前知道固定元素的高度,通用性差一些。
我个人的经验是,优先用 JS 改定位,因为这样截出来的图最干净。但要注意改完之后要等一帧再截图,否则样式还没生效。改完记得在截图完成后恢复原样,不然会影响用户后续操作。
3.4 内存控制与 OOM 防范
长截图最容易出的问题就是 OOM。一个 1080 宽、10000 高的 Bitmap,按 ARGB_8888 算就是 1080 × 10000 × 4 字节,约 41MB。如果页面再长一点,直接爆内存。
控制内存有几个手段。第一是降低 Bitmap 配置,如果不需要透明度,用Bitmap.Config.RGB_565,内存直接减半。第二是分段处理,不要一次性建一个巨大的 Bitmap,而是分片截取后写入文件,最后用图片库拼接。第三是限制最大高度,超过一定高度(比如 20000px)就截断或者提示用户。
// 用 RGB_565 省一半内存 Bitmap result = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565);还有一个细节:Bitmap.createBitmap在高度超过某个阈值时会抛异常,不同设备阈值不一样,一般在 32767 左右。所以如果页面特别长,必须分段创建再合并。
4. X5 WebView 长截图的差异化处理
X5 内核(TBS)是国内 App 用得最多的 WebView 替代方案,它在渲染性能、视频播放、文件能力上比原生强,但截图这块反而更麻烦,因为它的内部结构不对外暴露。
4.1 X5 和原生 WebView 的本质差异
要理解 X5 截图为什么难,得先知道它和原生的区别。原生 WebView 的渲染是交给系统 Chromium 内核做的,内容直接画在 WebView 这个 View 上,所以draw()能拿到内容。而 X5 是腾讯自己维护的一套内核,它内部有自己的渲染层和合成层,内容不一定画在 WebView 的 Canvas 上,所以直接draw()经常拿到空白。
另外 X5 的滚动容器结构和原生不同,scrollTo的行为也有差异。有些版本上scrollTo之后需要等更长时间才能渲染完成,有些版本上滚动事件被内部拦截,导致拼接法失效。
4.2 判断当前是否运行在 X5 内核
在做任何 X5 相关操作之前,必须先判断当前环境是不是 X5,否则在原生 WebView 上调用 X5 接口会直接崩溃。
public static boolean isX5WebView(WebView webView) { try { Class<?> clazz = webView.getClass(); Method method = clazz.getMethod("getX5WebViewExtension"); Object extension = method.invoke(webView); return extension != null; } catch (Exception e) { return false; } }这段代码通过反射调用getX5WebViewExtension,如果返回非空,说明当前是 X5 内核。用反射而不是直接调用,是为了避免在非 X5 环境下编译期就报错。
4.3 使用 X5 扩展接口截整页
X5 内核提供了一个扩展接口,可以直接截取整页内容。拿到扩展对象后,调用captureWholePicture():
public Bitmap captureX5WholePicture(WebView webView) { if (!isX5WebView(webView)) { return null; } try { Object extension = webView.getClass() .getMethod("getX5WebViewExtension") .invoke(webView); Method captureMethod = extension.getClass() .getMethod("captureWholePicture"); Object result = captureMethod.invoke(extension); if (result instanceof Bitmap) { return (Bitmap) result; } } catch (Exception e) { e.printStackTrace(); } return null; }这个接口的好处是一步到位,不需要滚动拼接,也不会有固定元素重复的问题。但它有几个前提条件必须满足。
第一,页面必须加载完成。如果页面还在加载中调用,返回的可能是半截图或者 null。建议在onPageFinished之后再延迟一小段时间调用,给渲染留出时间。
第二,部分老版本内核不支持。X5 内核版本迭代很快,早期版本没有这个接口,调用会抛NoSuchMethodException。所以必须做异常捕获和降级处理。
第三,返回的 Bitmap 可能很大,同样要注意 OOM。建议拿到之后立即压缩或者写入文件,不要长时间持有。
4.4 X5 下的滚动拼接降级方案
如果 X5 扩展接口不可用,就得降级到滚动拼接。但 X5 下的拼接和原生有几个不同点需要注意。
首先是滚动方式。X5 下scrollTo有时不生效,需要用scrollBy或者通过 JS 的window.scrollTo来滚动。实测下来,JS 滚动在 X5 上更可靠:
webView.evaluateJavascript("window.scrollTo(0, " + offset + ")", null);其次是等待时间。X5 的渲染比原生慢,截图前的等待时间要适当加长,建议 200ms 起步,页面复杂的话加到 300ms。
最后是截图方式。X5 下webView.draw()可能拿不到内容,可以尝试用webView.getDrawingCache()或者通过PixelCopy从 Surface 拷贝。不过PixelCopy需要 API 24 以上,且对 WebView 的支持不稳定,只能作为备选。
4.5 X5 长截图的版本兼容矩阵
X5 内核版本众多,不同版本对截图接口的支持情况不一样。下面这张表是我在实际项目中整理出来的经验值,供参考。
| X5 内核版本区间 | captureWholePicture | 滚动拼接 | 建议方案 |
|---|---|---|---|
| 较早版本 | 不支持 | 可用但慢 | 滚动拼接 + 长等待 |
| 中间版本 | 支持但偶发返回空 | 可用 | 接口优先,失败降级 |
| 较新版本 | 稳定支持 | 可用 | 直接用接口 |
| 最新版本 | 稳定支持 | 可用 | 接口 + 内存控制 |
需要说明的是,X5 内核是动态下发的,同一个 App 在不同用户手机上可能跑不同版本。所以代码里必须做运行时判断,不能假设用户一定是最新版本。
5. 两套方案的统一封装与调用时机
实际项目里不可能为原生和 X5 写两套完全独立的代码,最好是封装成一个统一的长截图工具类,内部自动判断环境并选择方案。
5.1 统一入口设计
工具类的对外接口应该只有一个方法,传入 WebView 返回 Bitmap:
public class LongScreenshotHelper { public static Bitmap capture(WebView webView) { if (isX5WebView(webView)) { Bitmap x5Result = captureX5WholePicture(webView); if (x5Result != null) { return x5Result; } } // 降级到滚动拼接 return captureByScroll(webView); } }这样调用方不需要关心底层用的是哪套方案,只管拿结果。内部先尝试 X5 接口,失败就降级到滚动拼接,滚动拼接在原生和 X5 上都能跑。
5.2 调用时机的选择
长截图最容易失败的原因不是代码写错了,而是调用时机不对。页面还没渲染完就截图,截出来的是空白或者半截。
正确的时机是:onPageFinished回调之后,再延迟 300-500ms 调用。为什么要延迟?因为onPageFinished只代表 HTML 加载完成,不代表图片、字体、CSS 动画都渲染完了。尤其是页面里有大图或者异步加载的内容时,延迟时间还要加长。
如果页面里有明确的加载完成标志(比如某个 JS 变量变成 true),最好监听这个标志再截图,比固定延迟更可靠。
webView.evaluateJavascript( "window.__pageReady === true", value -> { if ("true".equals(value)) { // 页面真正就绪,可以截图 } });5.3 截图后的图片处理
拿到 Bitmap 之后,通常还要做几件事:压缩、加水印、保存到相册或者分享。
压缩方面,如果只是分享到社交平台,宽度压到 1080 就够了,高度按比例缩放。用Bitmap.createScaledBitmap处理,注意缩放后要回收原图。
Bitmap scaled = Bitmap.createScaledBitmap( original, 1080, (int)(original.getHeight() * 1080f / original.getWidth()), true); if (scaled != original) { original.recycle(); }保存到相册需要处理权限,Android 10 以上用MediaStore,以下用File+MediaScanner。这部分不是长截图的核心,但实际项目里绕不开,建议单独封装一个图片保存工具。
6. 踩坑实录:那些文档里不会写的问题
这一节是我在实际项目中踩过的坑,每一条都是真金白银换来的经验。
6.1 截图黑屏的三种原因
黑屏是长截图最常见的问题,原因主要有三个。
第一个是硬件加速。WebView 默认开启硬件加速,内容渲染在 GPU 层,draw()拿不到。解决办法是截图前临时关闭:
webView.setLayerType(View.LAYER_TYPE_SOFTWARE, null); // 截图 webView.setLayerType(View.LAYER_TYPE_HARDWARE, null);第二个是截图时机太早。页面还在渲染,Canvas 上什么都没有。加延迟或者监听渲染完成事件。
第三个是X5 内核的合成层问题。X5 下即使关闭硬件加速,draw()也可能拿不到内容,因为 X5 有自己的合成机制。这种情况只能走 X5 扩展接口或者滚动拼接。
6.2 拼接处出现横线的处理
滚动拼接时,两屏交界处经常出现一条细横线,有时是重复的,有时是缺失的。原因是滚动偏移量计算有误差,或者截图时页面还在滚动动画中。
解决办法是滚动后等待动画完全停止再截图。可以用OnScrollChangedListener监听滚动停止,或者干脆用固定延迟。另外偏移量要用Math.round而不是直接取整,减少累积误差。
如果横线还是存在,可以在拼接时让相邻两屏重叠几个像素,然后取重叠区域的中间值,这样能掩盖接缝。
6.3 页面高度动态变化怎么办
有些页面有懒加载,滚动到底部才会加载更多内容,这时候一开始拿到的总高度是不准的。
处理办法是先滚动到底部触发所有懒加载,再滚回顶部开始截图。或者监听页面高度变化,动态调整截图范围。前者实现简单,后者更精确但复杂。
// 先滚到底触发懒加载 webView.evaluateJavascript("window.scrollTo(0, document.body.scrollHeight)", null); // 等待加载 // 再滚回顶部 webView.evaluateJavascript("window.scrollTo(0, 0)", null);6.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决方案 |
|---|---|---|---|
| 截图全黑 | 硬件加速未关 | 检查 layerType | 截图前关硬件加速 |
| 只截到一屏 | 高度获取错误 | 检查 JS 返回值 | 乘 density 换算物理像素 |
| 拼接有横线 | 滚动未稳定 | 加长等待时间 | 监听滚动停止 |
| X5 返回 null | 内核版本不支持 | 打印内核版本 | 降级滚动拼接 |
| OOM 崩溃 | Bitmap 太大 | 计算内存占用 | 用 RGB_565 或分段 |
| 固定元素重复 | 吸顶导航 | 检查 position | JS 临时改定位 |
7. 性能优化与体验提升的几个细节
长截图不只是"能截出来"就行,还要截得快、截得清晰、不卡顿。
7.1 截图速度优化
滚动拼接法最慢,因为每屏都要等待。优化思路是减少等待时间和减少截图次数。
减少等待时间可以通过监听渲染完成事件代替固定延迟。减少截图次数可以增大每屏的高度,比如一次截 1.5 屏,然后重叠拼接。但每屏太高会增加单次 Bitmap 的内存占用,需要权衡。
X5 接口法本身就很快,基本是毫秒级返回,不需要额外优化。
7.2 图片清晰度控制
截图的清晰度取决于 Bitmap 的密度。默认情况下,webView.draw()出来的图是按物理像素的,在 3 倍屏上会很清晰但文件很大。如果只是分享用,可以适当降低密度。
// 按 2 倍密度截图,平衡清晰度和文件大小 Bitmap result = Bitmap.createBitmap( width * 2 / 3, height * 2 / 3, Bitmap.Config.ARGB_8888);7.3 大图分享的兼容处理
截出来的长图如果特别长(比如超过 10000px),很多社交平台会压缩得看不清,甚至上传失败。这时候可以考虑分段分享或者生成缩略图 + 原图链接的方案。
分段分享就是把长图切成几张,每张控制在合理高度内。生成缩略图则是给用户看一个概览,需要原图时再下载。具体用哪种,看产品需求。
8. 我个人的一些实操体会
长截图这个需求,看起来是个小功能,但真要做好,涉及的东西不少:WebView 渲染机制、内存管理、异步时序、多内核兼容。我前后在几个项目里做过这个功能,最大的体会是不要追求一套代码通吃所有场景,而是要做好分层和降级。
X5 接口能用就用,用不了就滚动拼接,滚动拼接再出问题就提示用户手动截图。这种降级思路比死磕一个方案要靠谱得多。另外,测试一定要覆盖不同内核版本和不同页面类型,活动页、文章页、订单页的结构差异很大,在一个页面上跑通不代表在所有页面上都能跑通。
还有一点,长截图的内存问题在低端机上特别明显。我遇到过在高端机上跑得好好的,一到千元机就 OOM。后来统一改成 RGB_565 加分段处理,才稳定下来。所以如果你的 App 用户设备跨度大,内存这块一定要保守一点。
最后分享一个小技巧:如果只是想让用户分享页面,其实不一定非要截长图。可以用系统分享能力直接分享链接,或者用服务端渲染生成分享图。长截图更适合那些需要离线保存或者二次编辑的场景。想清楚需求再动手,能省不少事。