WebView文字选择改造:ActionMode拦截与JSBridge交互实践

发布时间:2026/9/16 17:39:07
WebView文字选择改造:ActionMode拦截与JSBridge交互实践 简介面向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系统菜单就会显示一个空壳视觉上等于隐藏了。此时原生选区还活着用户可以继续拖动选择框调整选区范围这是保留原生拖选能力的基础。如果你返回falseActionMode 立刻销毁选区光标也会消失用户就选不了字了。2.3 为什么不直接用 onSelectionChangedAndroid 13API 33开始WebView 提供了setOnSelectionListener可以直接监听选区变化。这个接口更干净不需要拦截 ActionMode但最低支持的版本决定了它不可能成为通用方案。方案最低 API优点痛点ActionMode.CallbackAPI 1全版本通用行为稳定需要自己处理菜单回调时序较绕OnSelectionListenerAPI 33直接拿到选区文本与坐标Android 13 以下无法使用需要 fallbackJSselectionchange事件任意能拿到选区内容能派发多次事件不在原生层无法控制 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 WeakReferenceWebView 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 脚本、注册 JSBridgeonProgressChanged加载中禁用文字选择加载完成恢复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;" />