
简介这套安卓源码专注于增强WebView文字选择与交互体验适合需要自定义网页文本选取、复制、分享、搜索等操作的应用开发者。项目通过重写选择器与JavaScript桥接实现了多选模式、自定义操作菜单、高亮样式调整等功能可平滑集成到现有Android工程中。资源包共262个文件以java源码、class编译文件、xml布局与配置、png图标资源为主同时包含apk示例和js脚本压缩包仅676KB整体轻量、结构清晰。已有132人学习下载。开发者可依据源码快速理解实现思路在此基础上定制选择器样式、菜单项及交互逻辑减少从零开发WebView高级文本选择功能的工作量尤其适合中高级Android开发者在处理网页文本交互场景时参考复用。1. WebView 选择文字为什么需要一套原生方案接触过混合开发的人应该都碰到过这个场景H5 页面里明明可以长按选中一段文字但弹出来的是系统默认的选择菜单功能少、样式丑而且你根本拿不到用户到底选中了什么。更麻烦的是页面里的window.getSelection()在用户松手之后经常取不到内容或者取到的是夹杂 HTML 标签的脏数据。标题里的 BTAndroidWebViewSelection 这类源码包本质上就是在解决同一个问题把 WebView 里的文字选择从“浏览器默认行为”变成“App 可控能力”。这套东西不复杂但涉及原生 ActionMode、JS 桥接、触摸事件拦截和跨进程数据传递四条线任何一条没配合好功能就是残的。适合正在做阅读类 App、笔记工具、有复制收藏需求的企业应用或者是想给自己的 WebView 容器加上“划词”能力的开发人员。下面我按自己实际落地时的顺序把方案拆开讲清楚。2. 从长按手势到 ActionMode 的完整事件链2.1 WebView 的文字选择是怎么被触发的Android 里 WebView 不是一个普通控件它内部跑着 Chromium 内核文字的选中、拖拽、菜单弹出都是原生层在处理。用户长按一段文字后事件从前端页面传递到内核的SelectionController内核通知 WebView 进入选择模式然后 WebView 再回调给应用层。Android 系统在应用层暴露的关键接口是这两个WebViewClient的onSelectionChanged不过这个方法在老的 API 里并不好用和ActionMode.Callback。webView.setActionModeCallback(new ActionMode.Callback() { Override public boolean onCreateActionMode(ActionMode mode, Menu menu) { // 这里的 menu 就是用户长按选中文字后弹出的菜单 return true; } Override public boolean onPrepareActionMode(ActionMode mode, Menu menu) { return false; } Override public boolean onActionItemClicked(ActionMode mode, MenuItem item) { // 用户点了哪个菜单项在这里处理 return true; } Override public void onDestroyActionMode(ActionMode mode) { } });这段代码是整套方案的入口。setActionModeCallback接收一个ActionMode.Callback当用户长按选中文字时WebView 内部会启动一个 ActionMode并让你有机会修改它的菜单。注意一个问题这个方法只能拦截当前 WebView 的 ActionMode如果你的页面里还有 Input 框比如表单输入输入框的长按菜单也会走这个回调需要在onCreateActionMode里判断mode.getTag()或者通过 Menu 的 item id 区分来源否则你会把粘贴菜单也一起替换掉。2.2 不同 Android 版本下的选择菜单差异这里有个表格是我在不同系统 WebView 上实测时整理出来的行为差异做兼容判断时直接对着看Android 版本菜单呈现方式对自定义菜单的影响关键注意事项5.0 - 6.0底部 ActionBar 样式菜单项容易溢出注意 menu 的 showAsAction不要把太多项设成 always7.0 - 9.0悬浮工具栏样式菜单项显示不全时会被折叠折叠后用户要二次点击降低可见性10悬浮工具栏 手势导航工具栏可能被系统手势区域遮挡需要检查 Toolbar 的 margin 或让 WebView 预留底部空间各种国产内核X5、方舟各厂商自己实现部分系统菜单回调失效必须做容错不能用系统回调作为唯一拿文本途径对这个表格我一般建议的做法是不要依赖 ActionMode 的显示形式而是把它当成一个“触发点”只要 onCreateActionMode 被调用了就说明用户进入了选择态。拿文本这件事等用户点菜单项时再去 JS 里取。2.3 触摸事件与长按冲突在实际项目里最常见的坑是WebView 外层套了一个OnTouchListener或者在onTouchEvent里做了事件消费结果长按选择永远触发不了。原因很简单长按的判定归内核管你抢了事件内核根本没收到完整的事件序列。如果你确实需要在 WebView 外层监听触摸事件正确做法是在监听器里只读不消费webView.setOnTouchListener((v, event) - { // 只做记录不消耗事件 if (event.getAction() MotionEvent.ACTION_DOWN) { lastTouchX event.getRawX(); lastTouchY event.getRawY(); } return false; });这里return false是关键。返回 false 表示这个监听器不消费事件事件会继续传给 WebView 内部处理。很多人习惯随手return true或者return v.performClick()这会让长按手势直接废掉。另外要注意如果你用了ScrollView包着 WebView并且 WebView 高度是match_parent长按事件在某些机型上也会出现不触发的问题那是因为 ScrollView 在 dispatchTouchEvent 阶段做了拦截。这个问题的排查方式是用 adb 命令看事件分发adb shell getevent -lt /dev/input/event1在长按过程中观察事件流里有没有BTN_TOUCH和ABS_MT_POSITION_X的中断。如果事件流中断了基本可以断定是父容器拦截了长按。解决方式是在父容器的onInterceptTouchEvent里判断手势时长超过 500ms 后停止拦截。3. 用自定义 ActionMode 接管 WebView 选择菜单3.1 替换默认菜单项而不是另起一套很多刚上手的人会在onCreateActionMode里直接menu.clear()然后加自己的菜单。这个做法在简单场景下没问题但会丢掉系统的“全选”、“复制”、“搜索”等能力而且在不同内核上的表现不一致。我一般会保留系统的 copy 和 share只增加自己的两个菜单项。菜单项的 id 需要避开系统保留的 idprivate static final int MENU_ID_EXTRACT 0x1001; private static final int MENU_ID_SHARE 0x1002; Override public boolean onCreateActionMode(ActionMode mode, Menu menu) { MenuItem extract menu.add(Menu.NONE, MENU_ID_EXTRACT, 0, 提取内容); extract.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); MenuItem share menu.add(Menu.NONE, MENU_ID_SHARE, 1, 分享选中); share.setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM); return true; }setShowAsAction里的三个常量要分清SHOW_AS_ACTION_ALWAYS保证菜单项一定显示在工具栏上SHOW_AS_ACTION_IF_ROOM是在空间够时显示SHOW_AS_ACTION_NEVER是隐藏到溢出菜单。对于阅读类场景我建议最多放两个 ALWAYS超过两个在 360dp 宽的设备上就会折叠用户需要多点一次才能看到流失率明显上升。菜单项的 order第三个参数控制排序数字小的在前。3.2 在 JS 侧拿当前选中的文字用户点击菜单项后下一步是拿到选中的文字。常见的做法是evaluateJavascript执行一段 JS 代码Override public boolean onActionItemClicked(ActionMode mode, MenuItem item) { if (item.getItemId() MENU_ID_EXTRACT) { evaluateSelectionText(); mode.finish(); return true; } return false; } private void evaluateSelectionText() { String js (function(){ var sel window.getSelection(); if (sel sel.rangeCount 0) { var range sel.getRangeAt(0); var container range.commonAncestorContainer; var text container.textContent ! null ? container.textContent : container.data; return text.substring(range.startOffset, range.endOffset); } return ; })();; webView.evaluateJavascript(js, value - { // value 是 JSON 字符串形式返回比如 选中的文字 String selectedText value ! null ? value.replace(\, ) : ; handleSelectedText(selectedText); }); }这段 JS 和直接用sel.toString()的区别在于toString()拿到的内容有时会丢失跨节点的空白字符而用range.startOffset和endOffset精确切分textContent得到的文本更干净。注意evaluateJavascript的回调返回的是一个 JSON 字符串比如选中的内容是你好返回的 value 是你好带引号所以这里做了去掉首尾引号的处理。更稳妥的做法是用JSON.parse(value)来转避免内容里本身包含引号时被错误截断。3.3 选中文本为空时的兜底策略这里有个实际会踩的坑用户点菜单的时候手势已经结束但 JS 执行时机和当前选择状态可能不一致导致getSelection()返回空。我在生产环境里的兜底策略是如果 selection 为空返回 false 不关闭菜单然后重新触发一次选择。但这个策略有个副作用就是连续点击时菜单会闪烁。后来我换了一个方案在onCreateActionMode时立刻启动一个 300ms 的延迟任务把选中的内容先抓一次缓存起来用户真正点按钮时优先用缓存。Override public boolean onCreateActionMode(ActionMode mode, Menu menu) { cachedSelection null; webView.postDelayed(this::evaluateSelectionText, 300); // ... 添加菜单项 return true; } private void handleSelectedText(String text) { if (cachedSelection null) { cachedSelection text; } // 更新 UI 或做其他事 }为什么是 300ms因为 WebView 在长按后需要一小段时间完成选择高亮的绘制和 DOM 范围的计算太早取会拿到空。实测在 Snapdragon 7 系以上的机器上 150ms 就够但在低端机上 300ms 更稳妥。这个值我建议做成可配置的不同产品形态调法不一样。4. 提取选中文本并用 ContentProvider 分享给其他应用4.1 为什么不能直接用 Intent 传字符串拿到选中文本后最常见的需求是分享出去或者保存到笔记。直接intent.putExtra(Intent.EXTRA_TEXT, selectedText)的问题是当选中内容来自大网页时文本可能超过 1MB这时候会触发 Binder 事务超限直接抛TransactionTooLargeException。另外EXTRA_TEXT是纯文本如果用户选中的内容里包含图片引用或者超链接这个方案会丢失格式。正常做法是把选中内容写入一个临时文件然后把文件 URI 通过ClipData传给目标应用。这里就需要一个 ContentProvider 来提供访问入口。4.2 一个最小可用的 SelectionProvider注册 ContentProvider 需要在 Manifest 里声明并且设置grantUriPermissionprovider android:name.SelectionProvider android:authorities${applicationId}.selection android:exportedtrue android:grantUriPermissionstrue android:readPermissionandroid.permission.READ_EXTERNAL_STORAGE meta-data android:nameandroid.support.FILE_PROVIDER_PATHS android:resourcexml/selection_paths / /provider这里authorities建议用${applicationId}.selection这样不同渠道包之间不会冲突。grantUriPermissions必须为 true否则你intent.setClipData之后目标应用没有权限读取 URI。selection_paths.xml里配置你写入临时文件的目录paths cache-path nameselection pathselection/ / /paths使用cache-path而不是files-path的好处是临时文件在 App 被清除缓存时自动回收不需要额外维护清理任务。ContentProvider 侧只需要实现getType和openFile其他方法可以留空public class SelectionProvider extends ContentProvider { public static final String AUTHORITY BuildConfig.APPLICATION_ID .selection; Override public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException { File file new File(getContext().getCacheDir(), selection/ uri.getLastPathSegment()); if (!file.exists()) { throw new FileNotFoundException(); } return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY); } Nullable Override public String getType(Uri uri) { return text/plain; } // 其余方法返回 null / 0 即可 }openFile的mode参数要处理只读场景分享给其他应用时传MODE_READ_ONLY这样防止别的应用通过你的 URI 修改文件。getType使用text/plain如果是图文混排的内容可以返回text/html。但有些优化过的接收方比如剪贴板服务不支持text/html会拒绝读取所以默认 plain 更通用。调用分享时把文件写入 cache 后构造 URI 并授予临时权限static void shareSelectedText(Context context, String selectedText) { File dir new File(context.getCacheDir(), selection); if (!dir.exists()) dir.mkdirs(); File out new File(dir, selected_ System.currentTimeMillis() .txt); try (FileOutputStream fos new FileOutputStream(out)) { fos.write(selectedText.getBytes(StandardCharsets.UTF_8)); } catch (IOException ignored) { } Uri uri new Uri.Builder() .scheme(content) .authority(SelectionProvider.AUTHORITY) .path(selected_ out.getName()) .build(); Intent intent new Intent(Intent.ACTION_SEND); intent.setType(text/plain); intent.putExtra(Intent.EXTRA_STREAM, uri); intent.setClipData(ClipData.newRawUri(selection, uri)); intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); context.startActivity(Intent.createChooser(intent, 分享选中内容)); }setClipData是必须的只putExtra(EXTRA_STREAM)在某些国产 ROM 上不会传递权限。FLAG_GRANT_READ_URI_PERMISSION让接收方在自己的生命周期内可读这个 URI。注意文件名的规则我特意把保存时的文件名拼到了 path 里没有用嵌套路径这是因为getLastPathSegment()拿到的是最后一个/后的内容嵌套路径会导致取文件时出错。4.3 处理文本里的图片和链接如果业务需要保留图片不能只存纯文本。方案是把选中区域的 HTML 提取出来存成.html文件再把图片 base64 内嵌。这样拿到的是一个完整自包含的文件但体积会膨胀。我发现两全的方案是先用range.cloneContents()拿到 DocumentFragment再遍历里面的img标签把src相对路径转成绝对 URL存入一个 JSON 文件文本和 URL 分开存储。分享时默认发纯文本但会在 intent 的EXTRA_HTML_TEXT里带上富文本版本。对微信这类接收方EXTRA_TEXT优先对印象笔记这类工具EXTRA_HTML_TEXT效果更好。5. 验证与排错触摸、遮挡、内核差异5.1 用 adb 快速验证选中文本是否正确调试阶段我不想每次重新编译就用 adb 验证整个链路。先打开 WebView 页面然后在 adb shell 里模拟长按事件adb shell input touchscreen swipe 540 800 540 800 800这个命令在坐标 (540, 800) 处执行了 800ms 的按压模拟长按。如果选择高亮出现说明触摸事件正常。接着用 Chrome DevTools 的 remote debugging 检查 JS 里的getSelection()。开启方式chrome://inspect/#devices在 WebView 调试打开的情况下WebView.setWebContentsDebuggingEnabled(true)会看到当前页面直接切到 Console 执行window.getSelection().toString()看看选中的内容是不是符合预期。这步能快速区分问题出在原生层还是 JS 层。如果 Console 里能取到文本但 App 内取不到问题多半出在evaluateJavascript的返回值处理上去检查 JSON 字符串的转义。5.2 三个必调的边界参数WebView 选择文字的表现和排版参数强相关我至少会调这三个textZoom、fontScale和overScrollMode。textZoom要设为 100因为部分厂商 ROM 把 WebView 默认字体缩放调成了 85导致国内 H5 页面里的英文绑定文本位置漂移长按选不中。overScrollMode设为OVER_SCROLL_IF_CONTENT_SCROLLS否则在页面边缘长按会出现选择高亮跟着滚动一起被取消的诡异问题。还有一个是settings.setNeedInitialFocus(true)这个不设的话长按选中后键盘弹不出来输入框场景会受影响。5.3 不同内核的兼容处理不同内核的处理方式差异很大。系统 WebView 在 Android 10 之后已经强制走 Chromium 的 ActionMode 流程行为比较标准。但 X5 内核腾讯系应用常用和方舟内核部分鸿蒙应用中evaluateJavascript的回调时机不同甚至在选中文字后getSelection()已经被内部清理。我在这类环境里会在 JS 侧维护一个变量缓存 selectionvar lastSelectedText ; document.addEventListener(selectionchange, function() { lastSelectedText window.getSelection() ? window.getSelection().toString() : ; });selectionchange这个事件在用户拖动选择光标时多次触发而且触发时机早于菜单弹出所以不管你是什么内核只要事件能冒泡到 document就能提前拿到文本。对于不支持这个事件的旧内核比如 Android 5.0 系统 WebView兜底方案是使用getSelection() setTimeout 重试最多重试 5 次每次间隔 100ms。另外周期性地检查onSelectionChanged回调API 23 引入只适用于支持该方法的 WebView 实现如果两种方案都拿不到在日志里打 tagWebViewSelection并记录当时的document.readyState这个状态能帮你判断到底是页面没加载完还是选择事件没有触发。本文还有配套的精品资源点击获取