Android WebView 长截图:从踩坑到稳定实现的完整指南

发布于 8 天前  29 次阅读




在开发 Android 浏览器应用时,实现“网页长截图”是一个常见需求。看似简单,但实际开发中却充满了深坑。本文将分享我在实现过程中遇到的种种问题,以及最终采用 PixelCopy 滚动截图方案 成功解决完整长截图的经验,希望能帮助同行少走弯路。

---

一、需求背景

我们的浏览器应用基于系统 WebView(Chromium 内核),需要实现将整个网页从上到下完整截取为一张 PNG 图片的功能,并支持分享。项目环境:minSdk 24,targetSdk 33,无第三方依赖。

---

二、失败尝试:那些不靠谱的方案

1. WebView.capturePicture()

最初的想法是直接调用 webView.capturePicture() 获取整个页面的 Picture 对象,然后绘制到 Bitmap。但实际测试发现:

· 在硬件加速下,该方法返回的 Picture 只包含当前可视区域;
· 有时甚至返回全黑或空白。

结论:废弃 API,不可用。

2. 滚动截图 + WebView.draw(canvas)

这是网上最常见的方案:循环滚动 WebView,每滚动一屏就调用 webView.draw(canvas) 截取当前视口,最后拼接。我们尝试了各种优化:

· 增加滚动后的延迟(50ms~300ms);
· 调用 invalidate() 强制重绘;
· 检查 scrollY 是否准确;
· 使用 Handler.postDelayed 等待渲染。

现象: 日志显示滚动位置正确(scrollY=2012、4024...),但最终拼接图片只有第一屏,或者后面全是黑色。原因是硬件加速下,WebView 的 GPU 渲染是异步的,draw() 拿到的永远是旧帧缓存,即使滚动位置变了,内容也没有重新光栅化。

结论:在 Chromium 内核下此方案天然不可靠。

3. 修改 LayoutParams 高度 + 一次性绘制

尝试将 WebView 的布局高度临时设置为内容总高度,强制 measure() 和 layout(),然后一次性 draw() 到全尺寸 Bitmap。结果依然只显示第一屏。因为 Chromium 内部渲染并不受 View 高度影响,它只光栅化视口内的内容。

4. PrintDocumentAdapter + PdfRenderer

利用系统打印框架生成 PDF,再渲染 PDF 为长图。理论上可行,但遇到两个大坑:

· WriteResultCallback 构造函数是包私有的,无法直接实例化;
· 反射实例化时发现该类是抽象类,无法 newInstance();
· 使用 PrintManager.print() 又无法静默输出到指定文件。

结论:系统 API 限制太死,不适合普通应用。

---

三、最终方案:PixelCopy 滚动截图

在几乎放弃的时候,我们想到了 PixelCopy。它可以直接从 Window 合成器拷贝屏幕上的真实像素,相当于系统截屏。我们只需要:

1. 滚动 WebView 到指定位置;
2. 等待片刻让屏幕刷新;
3. 用 PixelCopy.request() 截取 WebView 区域;
4. 循环截取每一屏;
5. 将截取的 Bitmap 纵向拼接。

为什么它能成功?

· PixelCopy 截取的是 屏幕上实际显示的画面,不受 WebView 内部渲染缓存影响;
· 每次滚动后,屏幕上显示的就是新的视口内容;
· 不需要修改 WebView 状态,不影响硬件加速。

核心代码

```kotlin
// 获取 WebView 在窗口中的位置
val location = IntArray(2)
webView.getLocationInWindow(location)
val left = location[0]
val top = location[1]
val width = webView.width
val height = webView.height

// 计算内容总高度
val density = resources.displayMetrics.density
val contentHeight = (webView.contentHeight * webView.scale * density).toInt()

var currentY = 0
val bitmaps = mutableListOf<Bitmap>()

fun captureNext() {
    if (currentY >= contentHeight) {
        mergeAndSave(bitmaps)  // 拼接保存
        return
    }
    webView.scrollTo(0, currentY)
    Handler(Looper.getMainLooper()).postDelayed({
        val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
        val rect = Rect(left, top, left + width, top + height)
        PixelCopy.request(activity.window, rect, bitmap, { result ->
            if (result == PixelCopy.SUCCESS) {
                bitmaps.add(bitmap)
            } else {
                bitmap.recycle()
            }
            currentY += height
            captureNext()
        }, Handler(Looper.getMainLooper()))
    }, 200)
}
captureNext()
```

注意事项

· API 限制:PixelCopy.request(Window, Rect, ...) 需要 API 26+,因此 minSdk 24 需要做版本判断,低于 API 26 可提示不支持或降级为截取第一屏。
· 屏幕位置:WebView 必须完全在屏幕内,不能被其他视图遮挡,否则 PixelCopy 会截取到错误内容。
· 延迟时间:滚动后需要给 Chromium 一点时间渲染新视口,200ms 较为稳定,可根据设备性能调整。
· 内存管理:长页面会产生很多临时 Bitmap,拼接时注意及时回收,限制最大高度(如 20000px)防止 OOM。
· 固定元素:如果页面有 fixed 头部/底部,滚动截图会导致它们重复出现在每一屏中。目前简单处理是接受这种重复,后续可考虑通过 JS 隐藏固定元素后再截图。

---

四、总结与展望

经过多轮尝试,我们最终确认 PixelCopy 滚动截图是 Android WebView 长截图的可行方案,它绕开了 Chromium 硬件加速的绘制限制,真正获取了屏幕上的最终画面。该方案已在我们的浏览器中稳定运行。

当然,它仍有一些局限(如 API 版本限制、固定元素重复),后续可以进一步优化。如果你也在实现类似功能,希望本文能给你一些启发。如果有更好的方案,欢迎交流讨论!

---

package com.example.myapplication.ui.webview

import android.app.ProgressDialog
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Rect
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.PixelCopy
import android.webkit.WebView
import android.widget.Toast
import androidx.core.content.FileProvider
import com.example.myapplication.MainActivity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import kotlin.math.min

object WebViewScreenshotHelper {private const val TAG = "WebViewScreenshot" private const val MAX_HEIGHT = 20000 private const val SCREENSHOT_DELAY_MS = 250L fun captureAndSave(activity: MainActivity, webView: WebView?) { if (webView == null) { Toast.makeText(activity, "当前没有可截图的网页", Toast.LENGTH_SHORT).show() return } if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { Toast.makeText(activity, "当前系统版本过低,暂不支持长截图", Toast.LENGTH_SHORT).show() return } val progressDialog = ProgressDialog(activity).apply { setMessage("正在生成长截图…") setCancelable(false) show() } val pageTitle = try { webView.title?.takeIf { it.isNotBlank() } ?.replace(Regex("[\\\\/:*?\"<>|]"), "_") ?.take(30) ?: "网页" } catch (e: Exception) { "网页" } val locationInWindow = IntArray(2) webView.getLocationInWindow(locationInWindow) val webViewLeft = locationInWindow[0] val webViewTop = locationInWindow[1] val webViewWidth = webView.width val webViewHeight = webView.height if (webViewWidth <= 0 || webViewHeight <= 0) { progressDialog.dismiss() Toast.makeText(activity, "页面尺寸无效", Toast.LENGTH_SHORT).show() return } val originalScrollY = webView.scrollY val originalScrollX = webView.scrollX val density = activity.resources.displayMetrics.density var contentHeightPx = (webView.contentHeight * webView.scale * density).toInt() var finalHeight = min(contentHeightPx, MAX_HEIGHT) if (finalHeight <= 0) { progressDialog.dismiss() Toast.makeText(activity, "内容高度无效", Toast.LENGTH_SHORT).show() return } Log.d(TAG, "WebView位置: ($webViewLeft, $webViewTop), 尺寸: ${webViewWidth}x${webViewHeight}, 内容高度: $finalHeight") val screenBitmaps = mutableListOf<Bitmap>() var currentY = 0 val mainHandler = Handler(Looper.getMainLooper()) fun captureNextScreen() { // 动态更新内容高度(如果页面在滚动过程中继续加载) val newContentHeightPx = (webView.contentHeight * webView.scale * density).toInt() if (newContentHeightPx > finalHeight) { finalHeight = min(newContentHeightPx, MAX_HEIGHT) Log.d(TAG, "内容高度更新为: $finalHeight") } // 检查是否还能继续向下滚动 if (!webView.canScrollVertically(1) || currentY >= finalHeight) { Log.d(TAG, "已到底部或达到最大高度,停止截图") mergeAndSave(activity, screenBitmaps, pageTitle, progressDialog, webView, originalScrollX, originalScrollY) return } webView.scrollTo(0, currentY) mainHandler.postDelayed({ // 检查实际滚动位置是否到达目标,若没有则说明到底了 if (webView.scrollY < currentY) { Log.d(TAG, "实际滚动位置 ${webView.scrollY} 小于目标 $currentY,已到底部,结束截图") mergeAndSave(activity, screenBitmaps, pageTitle, progressDialog, webView, originalScrollX, originalScrollY) return@postDelayed } val bitmap = Bitmap.createBitmap(webViewWidth, webViewHeight, Bitmap.Config.ARGB_8888) val rect = Rect(webViewLeft, webViewTop, webViewLeft + webViewWidth, webViewTop + webViewHeight) PixelCopy.request( activity.window, rect, bitmap, { copyResult -> if (copyResult == PixelCopy.SUCCESS) { Log.d(TAG, "截取第 ${screenBitmaps.size + 1} 屏成功,scrollY=$currentY") screenBitmaps.add(bitmap) } else { Log.e(TAG, "PixelCopy 失败: $copyResult") bitmap.recycle() } currentY += webViewHeight captureNextScreen() }, mainHandler ) }, SCREENSHOT_DELAY_MS) } captureNextScreen() } private fun mergeAndSave( activity: MainActivity, bitmaps: List<Bitmap>, pageTitle: String, progressDialog: ProgressDialog, webView: WebView, originalScrollX: Int, originalScrollY: Int ) { if (bitmaps.isEmpty()) { webView.scrollTo(originalScrollX, originalScrollY) progressDialog.dismiss() Toast.makeText(activity, "截图失败:没有截取到任何内容", Toast.LENGTH_SHORT).show() return } GlobalScope.launch(Dispatchers.IO) { try { val width = bitmaps[0].width val totalHeight = bitmaps.sumOf { it.height } val finalBitmap = Bitmap.createBitmap(width, totalHeight, Bitmap.Config.ARGB_8888) val canvas = Canvas(finalBitmap) var y = 0 for (bmp in bitmaps) { canvas.drawBitmap(bmp, 0f, y.toFloat(), null) y += bmp.height bmp.recycle() } val dir = File(activity.getExternalFilesDir(Environment.DIRECTORY_PICTURES), "长截图") if (!dir.exists()) dir.mkdirs() val file = File(dir, "${pageTitle}_${System.currentTimeMillis()}.png") FileOutputStream(file).use { out -> finalBitmap.compress(Bitmap.CompressFormat.PNG, 100, out) } finalBitmap.recycle() Log.d(TAG, "长截图保存成功: ${file.absolutePath}") withContext(Dispatchers.Main) { webView.scrollTo(originalScrollX, originalScrollY) progressDialog.dismiss() Toast.makeText(activity, "长截图已保存", Toast.LENGTH_SHORT).show() shareImage(activity, file) } } catch (e: IOException) { Log.e(TAG, "保存失败: ${e.message}", e) withContext(Dispatchers.Main) { webView.scrollTo(originalScrollX, originalScrollY) progressDialog.dismiss() Toast.makeText(activity, "保存失败:${e.message}", Toast.LENGTH_SHORT).show() } } catch (e: Exception) { Log.e(TAG, "合并或保存异常: ${e.message}", e) withContext(Dispatchers.Main) { webView.scrollTo(originalScrollX, originalScrollY) progressDialog.dismiss() Toast.makeText(activity, "截图失败:${e.message}", Toast.LENGTH_SHORT).show() } } } } private fun shareImage(activity: MainActivity, file: File) { try { val authority = "${activity.packageName}.fileprovider" val uri: Uri = FileProvider.getUriForFile(activity, authority, file) val shareIntent = Intent(Intent.ACTION_SEND).apply { type = "image/png" putExtra(Intent.EXTRA_STREAM, uri) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } activity.startActivity(Intent.createChooser(shareIntent, "分享截图")) } catch (e: Exception) { Log.e(TAG, "分享失败: ${e.message}", e) Toast.makeText(activity, "分享失败:${e.message}", Toast.LENGTH_SHORT).show() } }

}



本文首发于个人博客,转载请注明出处。