简介:面向Android开发者的一套开源源码包,针对WebView组件在网页浏览场景中文字选取范围受限、操作菜单单一的问题,给出了较为完整的增强方案。通过自定义选择器与JavaScript接口,开发者可实现连续或非连续文本的多选,在弹出菜单中加入搜索、复制、分享等操作,并支持选中背景色、高亮样式等视觉自定义,适合需要提升Web内容交互体验的各类应用场景。压缩包共262个文件,主体为java源码、class编译文件、xml资源配置、png图片素材和js脚本,附apk示例与工程配置文件,便于直接导入调试;包体约676KB,结构紧凑。目前已有132人学习下载。这套源码既可作为WebView功能扩展与JSBridge交互机制的参考实现,也可以作为基础框架快速集成,提供了一套可直接复用或二次定制的菜单与样式方案,对中高级Android开发者很有借鉴价值。
1. 为什么默认 WebView 的文字选择撑不起阅读场景
做阅读类、文档预览类或者笔记类应用时,最难处理的不是加载网页,而是长按选字之后那一瞬间的交互。系统默认的 WebView 在 Android 8 到 Android 13 上行为并不一致:有的机型长按弹的是 Chromium 的放大镜,有的直接弹系统 ActionMode,菜单项只有复制、搜索、分享,想加一个“查词”或者“收藏选中段落”,得自己去拦截系统菜单,拦截完之后还要处理选中文本丢失、菜单闪退、JS 注入时机不对等一系列连锁问题。这个开源项目做的事情,就是把 WebView 选区的底层行为拿过来重写,从 ActionMode 的接管到选中文本的获取、自定义操作菜单的弹出,以及选中样式的覆盖,全部交给开发者控制。适合已经在用 WebView 做内容展示、又对选中交互有明确要求的团队,也适合想理解 WebView 与 JavaScript 边界在哪里的 Android 开发者。
2. 选中文字背后的机制:ActionMode、SelectionListener 与主线程回调
2.1 WebView 里的文字选择不是 JS 先触发的
很多人以为网页里选文字是 JavaScript 的window.getSelection()在起作用,其实不是。用户在 WebView 里长按文字,先触发的是 Chromium 内核的文本选区逻辑,选区数据存在于 native 层,然后通过 Android 的ActionMode回调到 UI 线程,最后才轮到 JavaScript 参与。也就是说,选区在原生层已经存在了,JS 只是把结果读出来。
这个顺序决定了两个实现要点:第一,重写文字选择功能要从ActionMode下手,因为它是原生层与 UI 层之间的唯一通道;第二,JS 侧通过getSelection()拿到的选区范围和原生层是高概率同步的,但拿到的时机必须在ActionMode已经创建之后,否则返回空字符串。
// 在 Activity 或 Fragment 中,给 WebView 设置长按监听 webView.setOnLongClickListener(v -> { // 先让 WebView 自己处理长按,否则选区分明没产生,就弹出自定义菜单了 return false; });这里返回false是关键。如果自己接管长按并直接弹出菜单,此时原生选区还没有建立,getSelection()拿不到任何内容。我之前在这个位置踩过坑,以为长按第一时间去取选中文本是对的,结果在部分国产 ROM 上拿到的是上一次的选区残留。正确顺序是:先放行 WebView 的默认长按行为,等它触发ActionMode回调,在回调里再做文章。
2.2 startActionMode 与回调时序
WebView 内部的文字选区是通过startActionMode(Callback)启动的,开发者可以拦截这个回调,让系统菜单不出现。这个回调有明确的生命周期:onCreateActionMode、onPrepareActionMode、onActionItemClicked、onDestroyActionMode。
webView.customActionModeCallback = object : ActionMode.Callback { override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { // 不要 inflate 系统菜单,返回 false 表示菜单内容为空 // 此时 ActionMode 仍存活,但界面上不会显示系统菜单 return true } override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { return false } override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { return false } override fun onDestroyActionMode(mode: ActionMode) { // 在这里收起自定义菜单,并清理选区 dismissSelectionMenu() } }注意onCreateActionMode返回true但不在menu上添加任何 item,系统菜单就会显示一个空壳,视觉上等于隐藏了。此时原生选区还活着,用户可以继续拖动选择框调整选区范围,这是保留原生拖选能力的基础。如果你返回false,ActionMode 立刻销毁,选区光标也会消失,用户就选不了字了。
2.3 为什么不直接用 onSelectionChanged
Android 13(API 33)开始,WebView 提供了setOnSelectionListener,可以直接监听选区变化。这个接口更干净,不需要拦截 ActionMode,但最低支持的版本决定了它不可能成为通用方案。
| 方案 | 最低 API | 优点 | 痛点 |
|---|---|---|---|
ActionMode.Callback | API 1 | 全版本通用,行为稳定 | 需要自己处理菜单,回调时序较绕 |
OnSelectionListener | API 33 | 直接拿到选区文本与坐标 | Android 13 以下无法使用,需要 fallback |
JSselectionchange事件 | 任意 | 能拿到选区内容,能派发多次 | 事件不在原生层,无法控制 ActionMode |
项目源码里的做法是走ActionMode.Callback这一条路,原因很简单:要兼容 Android 5 到 Android 13 的设备,OnSelectionListener撑不起这个覆盖范围。如果你的应用只面向 Android 13+,可以考虑OnSelectionListener替代ActionMode拦截,但项目的设计对老版本兼容性更强。
3. 接管选区:从 ActionMode.Callback 到自定义菜单的落地方案
3.1 重写 ActionMode.Callback 之后弹什么
系统菜单隐藏之后,需要自己弹出一个可控的菜单。常见做法是PopupWindow或Dialog,我一般选PopupWindow,因为可以精确控制弹出位置。位置的计算依赖选区在屏幕上的坐标,这里有两个数据来源:一是 WebView 内部提供的Rect,二是自己通过 JS 计算选区的位置。
// 通过 evaluateJavascript 获取选中区域的位置信息 String js = "(function() {" + " var sel = window.getSelection();" + " if (sel.rangeCount === 0) { return '{}'; }" + " var rect = sel.getRangeAt(0).getBoundingClientRect();" + " return JSON.stringify({x: rect.left, y: rect.top, w: rect.width, h: rect.height});" + "})()"; webView.evaluateJavascript(js, value -> { // value 形如 {"x":10,"y":20,"w":100,"h":30} // 注意这个坐标是相对当前可视区的,需要加上 scrollY 才是页面绝对坐标 });这段 JS 拿的是选区第一行的包围矩形。如果用户跨行选中,getBoundingClientRect()返回的是整块区域的左上和右下,不是每一行的坐标。对于弹出菜单来说,这个精度已经够了。拿到坐标之后,加上webView.getScrollY(),再换算成屏幕坐标,就得到了PopupWindow的弹出锚点。
3.2 获取选区文本的两种方式
选中文本的获取有两种途径:webView.selectedText和 JS 注入。selectedText取到的是纯文本,去掉了 HTML 标签,但会保留换行符。JS 方式则是通过window.getSelection().toString()获取,两者结果基本一致,区别在来源上。
| 方式 | 优点 | 缺点 |
|---|---|---|
webView.selectedText | 同步获取,调完就有值 | 某些 WebView 历史版本中文乱码 |
window.getSelection().toString() | 与页面内容完全一致 | 必须等 ActionMode 回调完成后执行 |
我自己的项目里两个都有用到,主流程用selectedText拿文本用于即时展示,JS 方式用于拿文本的同时还取自定义属性,比如>public class BTSelectionBridge { private final WeakReference<WebView> webViewRef; public BTSelectionBridge(WebView webView) { this.webViewRef = new WeakReference<>(webView); } @JavascriptInterface public String getSelectionHtml() { WebView webView = webViewRef.get(); if (webView == null) return ""; // 在 Java 线程中调用 JS,必须切到主线程 final String[] result = new String[1]; webView.post(() -> { String js = "(function() { var sel = window.getSelection(); if (!sel.rangeCount) return ''; var div = document.createElement('div'); for (var i = 0; i < sel.rangeCount; i++) { div.appendChild(sel.getRangeAt(i).cloneContents()); } return div.innerHTML; })()"; webView.evaluateJavascript(js, value -> result[0] = value); }); return result[0]; } }
注意@JavascriptInterface方法运行在 WebView 的私有线程中,不能直接操作 UI,也不能直接调用evaluateJavascript的同步结果。上面用post切回主线程执行,但返回值已经来不及同步返回了,实际项目里应该把 HTML 内容通过回调传给 Java 层,而不是在接口方法里同步返回。这个桥的名字可以自己定义,建议取短一点,避免注入时 JS 侧写出超长调用。
4. 把选择变成操作:自定义菜单、样式覆盖与多选批量处理
4.1 操作菜单布局与实现
菜单的形态决定了用户的第一感受。项目里菜单项是通过PopupWindow承载的,每个 item 是一个TextView。菜单项一般包括复制、分享、搜索、全选,如果应用场景是笔记类,还要加收藏、摘录。
public void showSelectionMenu(int x, int y) { View menuView = LayoutInflater.from(context).inflate(R.layout.menu_selection, null); PopupWindow popup = new PopupWindow(menuView, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); popup.setOutsideTouchable(true); popup.setFocusable(false); popup.showAtLocation(webView, Gravity.NO_GRAVITY, x, y); menuView.findViewById(R.id.action_copy).setOnClickListener(v -> { copySelectedText(); popup.dismiss(); }); menuView.findViewById(R.id.action_share).setOnClickListener(v -> { shareSelectedText(); popup.dismiss(); }); }这里有两个注意点。第一,setFocusable(false)是必须的,否则PopupWindow抢焦点会导致 ActionMode 立即销毁,选区消失。第二,在 Android 12 以上,PopupWindow弹出位置的坐标如果超出屏幕边界,需要做偏移修正,常见做法是获取菜单宽度后判断x + menuWidth > screenWidth就左移。这个边界处理在横屏和平板上特别容易出问题。
4.2 自定义选中样式:CSS 注入覆盖 ::selection
默认选中文字的高亮色是 Chromium 的蓝色或者橙色,主题不搭。选区的视觉样式可以通过注入 CSS 覆盖:
String css = "body ::selection {" + " background-color: #FFEB3B;" + " color: #1A1A1A;" + "}"; // 先拼成完整的 style 标签 String js = "var style = document.createElement('style');" + "style.type = 'text/css';" + "style.appendChild(document.createTextNode('" + css + "'));" + "document.head.appendChild(style);"; webView.evaluateJavascript(js, null);注入时机需要特别注意。在onPageStarted时注入,页面内容还没渲染完成,document.head可能不存在。在onPageFinished时注入,文字已经可以选中了,但用户如果先操作再注入,会有一瞬间看到默认颜色。实际项目里,我在onPageStarted里通过loadUrl("javascript:...")提前准备一个全局函数,然后在onPageFinished里调用它,这样就覆盖了首屏可能出现的默认高亮。
4.3 多选与跨区域选择
多选不是在原生层实现的,而是在 JS 层做选区收集。用户点“多选”按钮后,菜单关闭,进入多选模式,每次长按选中一段,JS 侧把Range对象存入数组,最后一次性取出所有文本拼接。
window.BTMultiSelection = (function() { var ranges = []; function capture() { var sel = window.getSelection(); if (sel.rangeCount === 0) return; ranges.push(sel.getRangeAt(0).cloneRange()); sel.removeAllRanges(); } function dump() { var text = ''; for (var i = 0; i < ranges.length; i++) { text += ranges[i].toString() + '\n'; } return text; } return { capture: capture, dump: dump }; })();这段 JS 在多选模式下,由选区的ActionMode.Callback里的某个菜单项触发capture(),用户每次选择一段就点一下菜单项,最后点“复制全部”时调用dump()。每段的文本用\n分隔,dump()返回后再交给 Java 层做去重和拼接。这里有个隐性成本:如果ranges数组不清理,用户会越选越多,内存占用上升,需要在onDestroyActionMode里调用reset()清空。
5. 实战:把选区文本做成可复用的组件架构
5.1 从项目中抽出的接口设计
项目可以直接用,但如果想把它集成到自己的工程里,建议先抽出接口,不要让具体的菜单视图和 WebView 绑死。下面是我从项目里抽出来的一个最小组件结构:
public interface BTSelectionListener { // 选中文本发生变化 void onSelectionChanged(String text); // 菜单里的某个操作被点击,action 是自定义的字符串 void onMenuAction(String action, String selectedText); // 选区被取消 void onSelectionCleared(); }调用方只需要实现这个接口,就能拿到选中文本和操作事件。菜单的显示与隐藏逻辑封装在SelectionManager里,Activity不需要知道菜单是怎么弹出来的。这个设计让同一个SelectionManager可以复用在多个WebView上,甚至一个页面里的多个 WebView 实例。接口回调都发生在主线程,不需要额外做线程切换,因为ActionMode本身就是在主线程回调的。
5.2 与 WebChromeClient 和 WebViewClient 的配合
WebViewClient的onPageFinished是 JS 桥和 CSS 的注入点,WebChromeClient的onProgressChanged可以做加载进度与选区功能启用的联动。两者分工明确:
| 回调 | 负责事项 |
|---|---|
onPageStarted | 清理上次页面的选区状态,重置 JSBridge 标记 |
onPageFinished | 注入 CSS、注入 JS 脚本、注册 JSBridge |
onProgressChanged | 加载中禁用文字选择,加载完成恢复 |
onReceivedError | 错误页不注入任何脚本,避免 JS 异常 |
一个容易忽略的细节是onPageFinished里注入脚本如果失败,比如页面里跳转了一个 404 页面,WebView 仍然会回调onPageFinished,此时注入脚本会执行在错误页上。所以注入之前要判断当前 URL 是否合法,常见的做法是维护一个允许注入的域名白名单,不在白名单内就跳过 JS 注入。
6. 验证与排错:菜单不弹、选区消失、JS 不执行的真实场景
6.1 用本地页面快速复现问题
排查 WebView 问题时,我一般不用线上页面,因为在网速、重定向、登录态这些因素干扰下很难定位问题。先在assets目录放一个本地测试页,用file:///android_asset/selection_test.html加载,保证页面内容完全可控。注意 Android 10 以后不允许直接访问/storage/emulated/0/android/data/下的文件路径,所以不要用绝对路径指向应用私有目录之外的 HTML,直接放 assets 是最稳的。
测试页里放几段长短不一的中英文文本,加上几个style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />