拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Android WebView文字选择与ActionMode自定义菜单实现

简介一套面向 Android 开发者的 WebView 文字选择增强开源源码解决了系统默认 WebView 在文本选取范围、操作菜单方面功能受限的问题。项目通过自定义选择器、多选模式、可配置操作菜单以及 JavaScript 交互让用户能更流畅地完成复制、搜索、分享等网页文字操作适合需要深化文本交互体验的 App 开发人员也能提升应用的可用性。压缩包共 262 个文件约 676KB以 java 源码、class 编译产物、XML 资源布局和配置文件为主同时包含 PNG 图标、JS 脚本、APK 安装包以及工程说明文档还带有 svn 版本管理元数据便于查看原有工程结构整体体量轻量利于快速阅读与二次开发。目前已有 132 人学习下载。借助该源码开发者能直接安装演示 APK 感受交互效果并将自定义文字选择逻辑移植到实际项目中结合 dex、apk 与源码对照可快速定位核心实现减少从零研究 WebView 文本选择的成本。1. 先把结论放前面WebView的自带选择菜单“只可远观”先把结论放前面Android的WebView在文字选择这件事上原生方案永远差半步。长按能弹出菜单但菜单改不动选区能看得见但拿不到文本同一套代码在Android 8和Android 13上的回调顺序还会不一样。BTAndroidWebViewSelectionwebview选择文字这类安卓源码项目核心就是在补齐这半步接管ActionMode、控制菜单项、把选中文字传回原生侧。下面从系统长按链路讲起再给出一份可以落地的实现和排错路径适合正在用Android Studio做混合应用内“划词”能力、或者准备自己封装webview壳子的工程师。2. 长按→选区→ActionModeWebView文本选择的系统链路2.1 长按坐标到选区的转换HitTest先决定你有没有资格选择WebView从Android 5.0开始走Chromium内核长按事件的响应早于Activity的onLongClick。内部TouchSelectionController会先做一次hit test把手指坐标解析成WebHitTestResult再判断这一下到底是按在文本上、链接上还是图片上。这里的判断结果会直接分流后续行为落在链接上时系统优先走ContextMenu落在普通文本上才进入选区创建流程。很多人以为“长按就一定有选区”实际上第一步就被HitTest卡住了。给WebView挂一个OnLongClickListener可以观察到这个阶段webView.setOnLongClickListener(v - { WebHitTestResult result webView.getHitTestResult(); int type result.getType(); String extra result.getExtra(); // type取值: UNKNOWN_TYPE / SRC_ANCHOR_TYPE / SRC_IMAGE_TYPE / SRC_TEXT_TYPE Log.d(Selection, type type , extra extra); return false; // 返回false让系统继续走默认选择链路 });这段代码的要点是返回值。返回false时系统继续走自己的选择链路长按弹出手柄和菜单返回true则整个默认流程被中断之后要自己处理选区和高亮。调试阶段建议先不拦截把type打出来看UNKNOWN_TYPE常见于空白区域或iframe内部SRC_ANCHOR_TYPE是按到了链接SRC_IMAGE_TYPE是按到了图片。只有type能对应文本时后面的ActionMode菜单才有意义这个判断能省掉后面一大半“菜单为什么没弹”的排查。2.2 ActionMode启动链路与onCreateActionMode的注入点确认进入文本选择之后WebView会创建ActionMode。Android 6.0API 23开始系统优先走FloatingToolbar模式选中文字上方出现一排浮动小图标。WebView内部生成一个默认的Callback把复制、粘贴、全选、Web Search这些菜单项填进去。开发者想修改这套菜单需要在WebView初始化时设置自定义回调而不是在Activity层面监听ContextMenu。webView.setCustomSelectionActionModeCallback(new ActionMode.Callback() { Override public boolean onCreateActionMode(ActionMode mode, Menu menu) { // menu参数来自WebView内部的选区回调可以直接增删 return true; } Override public boolean onPrepareActionMode(ActionMode mode, Menu menu) { return true; } Override public boolean onActionItemClicked(ActionMode mode, MenuItem item) { return false; } Override public void onDestroyActionMode(ActionMode mode) { // 这里和下面的onActionItemClicked是排查重灾区 } });setCustomSelectionActionModeCallback是WebView专门为选区模式预留的入口它和Activity的startActionMode没有关系。onCreateActionMode返回true菜单才会继续填充返回false长按之后什么都不会弹。onActionItemClicked返回false时WebView会继续处理它内部认识的菜单ID比如copy、paste返回true则表示这个点击被消费掉。这个返回值在后续自定义菜单时经常被忽略表现是菜单项点了没反应或者点了之后系统又执行了一遍复制。2.3 Android版本差异WebView历史版本里的ActionMode行为变化做这个标题的需求时最常遇到的现象是同一个APK在不同手机上菜单列表不一样。原因在WebView本身Android 7.0开始WebView支持通过商店独立更新不再跟着系统版本走网上说的“Android WebView历史版本”其实是一个快速迭代的内核产物。菜单项里什么时候多了一项“翻译”什么时候少了一项“Web Search”都看内核版本脸色。Android系统版本WebView内核选择浮层表现4.xWebKit传统ActionBar模式菜单固定5.0 ~ 6.0Chromium浮动ActionMode起步菜单样式粗糙7.0独立更新菜单项由内核版本控制行为不再锁死10.0更新策略变化剪贴板读取规则收紧部分系统菜单被裁这张表不用背但能给出一个明确结论菜单项的差异主要来自内核版本而不是API level。适配的第一步永远是取当前WebView版本打印出来再讨论问题PackageInfo info WebView.getCurrentWebViewPackage(); if (info ! null) { Log.d(WebViewCore, info.packageName / info.versionName); }getCurrentWebViewPackage在部分定制ROM上可能返回null判空即可。很多项目里凭空出现的“某个版本选择弹窗异常”最后都归结到WebView内核变了而不是业务代码变了。排查这类问题时第一步先确认内核版本和Android系统版本不要在模糊的前提下改代码。3. 重写ActionMode菜单并拿到选区文本3.1 菜单定制onCreateActionMode里新增自己的菜单项系统链路摸清之后真正动手的第一步是改菜单。常见需求是把默认菜单替换成“复制、收藏、翻译”这个组合。复制保留系统能力收藏和翻译用自定义ID。private static final int MENU_ID_COLLECT 10001; private static final int MENU_ID_TRANSLATE 10002; Override public boolean onCreateActionMode(ActionMode mode, Menu menu) { menu.add(Menu.NONE, MENU_ID_COLLECT, 0, 收藏); menu.add(Menu.NONE, MENU_ID_TRANSLATE, 1, 翻译); return true; } Override public boolean onActionItemClicked(ActionMode mode, MenuItem item) { if (item.getItemId() MENU_ID_COLLECT) { // 在这里执行收藏逻辑 mode.finish(); return true; } return false; }参数说明Menu.NONE表示不归属任何分组MENU_ID_COLLECT的自定义ID从10001开始避开系统Menu.Category数值段和WebView内部菜单ID区间。排序参数0和1决定浮动菜单里“收藏”在“翻译”前面。onActionItemClicked里自定义菜单项处理完成要return true并主动mode.finish()否则菜单浮层会一直挂着。这里特别注意不要试图按ID去删除WebView内置菜单项。系统复制粘贴的ID可能复用android.R.id里的常量但Web Search、Translate这些菜单项的ID在内核里没有公开规律不同版本不相同。删不干净是常态所以常见做法是保留系统项把自己的项追加在后面再通过顺序参数控制位置。3.2 拿到选中文本evaluateJavascript和剪贴板监听两条路菜单有了接下来是核心拿到用户选中的文字。WebView没有公开的getSelectedText方法主流做法有两种。第一种是用evaluateJavascript读取DOM选区这是目前最干净的方式Override public boolean onActionItemClicked(ActionMode mode, MenuItem item) { if (item.getItemId() MENU_ID_COLLECT) { String js (function() { var s window.getSelection(); return JSON.stringify({ text: s s.rangeCount 0 ? s.toString() : }); })(); webView.evaluateJavascript(js, value - { // value是一个JSON字符串例如 {text:选中的内容} String selectedText ; try { selectedText new JSONObject(value).optString(text, ); } catch (JSONException ignored) { } Log.d(Selection, selected selectedText); }); mode.finish(); return true; } return false; }为什么让JS返回JSON.stringify而不是直接return文本因为evaluateJavascript的回调value永远是JSON编码的字符串。直接return文本时带换行和引号的内容会被转义得面目全非包一层JSON对象后用JSONObject解析能稳定拿到原始内容。这段JS在selectionchange之后任何时候调用都能拿到当前选区不依赖菜单点击时的DOM状态。第二种方案是监听剪贴板。当用户点击系统“复制”菜单时ClipboardManager会触发PrimaryClipChanged事件原生侧读取剪贴板拿到文本。优点是能拿到用户真正复制出来的结果缺点更明显复制动作会污染用户的剪贴板历史而且Android 10之后读取剪贴板会弹系统toast提示体验很差。方案优点缺点evaluateJavascript不碰剪贴板、可控性强依赖页面JS环境剪贴板监听能拿到系统复制的确切内容污染剪贴板、有系统提示实际项目里自定义的“收藏”“翻译”按钮用evaluateJavascript需要兼容系统“复制”按钮时再考虑剪贴板。二者不冲突可以同时存在。3.3 菜单项ID冲突与图标适配的两个细节第一个细节是ID冲突。WebView内部用View.generateId()生成菜单ID这个生成器保证进程内唯一但ID数值落在0x01000000以上的区间。项目里一个Activity如果同时管理多个WebView而且自己也在同一Menu上add项就可能出现自定义ID撞上WebView内部ID。规避方式是用10000以上固定常量并保证整个模块内唯一不要图省事用1、2、3这种数字。第二个细节是图标。FloatingToolbar模式在WebView上会强制走ActionMode的title/subtitle样式menuItem.setIcon()在部分设备上不生效表现为菜单只有文字没有图标。常见做法是彻底放弃图标只放文本如果一定要有视觉符号用SpannableString在title前面拼一个Unicode符号比如\u2605 收藏。想做到跟App内其他菜单完全一致的图标风格系统ActionMode是做不到的只能自绘整个选择浮层。4. 用JS注入补齐网页侧选择contenteditable与Shadow DOM场景4.1 网页的contextmenu拦截怎么绕过ActionMode链路在普通网页文本上没有大问题但很多富文本页面会自己监听contextmenu事件并preventDefault导致WebView长按后什么都不弹。如果页面是自己维护的直接在页面里放行该事件碰到第三方页面原生侧得做兜底。核心思路是判断HitTest确实没走通时用JS强制创建一个选区webView.setOnLongClickListener(v - { WebHitTestResult result webView.getHitTestResult(); if (result null || result.getType() WebView.HitTestResult.UNKNOWN_TYPE) { String js (function() { var r document.createRange(); r.selectNodeContents(document.body); var s window.getSelection(); s.removeAllRanges(); s.addRange(r); return true; })(); webView.evaluateJavascript(js, null); return true; } return false; });这里做了一个取舍直接选中文档body全部内容再让用户通过选区手柄缩小范围。比起自绘高仿选区这个方案代码量小很多用户能立刻看到选区和手柄。代价是长按位置离最终选区可能很远交互上不够精准。如果业务需要精确定位需要把长按坐标换算成页面内坐标传入JS通过document.elementFromPoint定位文本节点实现量会成倍增加。一般先跑通全选兜底确认页面确实需要局部精确选择时再升级。4.2 用JavascriptInterface回传选区桥接代码另一种常见需求是让网页前端感知到用户的划词操作比如H5自己弹出评论面板原生只负责把菜单按钮唤起。这时在WebView上注册一个JavascriptInterface更直接。public class SelectionBridge { JavascriptInterface public void onSelect(String text) { // 这个回调不在主线程需要post到主线程再更新UI new Handler(Looper.getMainLooper()).post(() - { selectionLiveData.setValue(text); }); } }需要拿选区时在原生侧注入一段JS调用这个桥(function() { var s window.getSelection(); if (s s.rangeCount 0) { window.AndroidBridge window.AndroidBridge.onSelect(s.toString()); } })();使用前记得在WebView初始化时执行webView.addJavascriptInterface(new SelectionBridge(), AndroidBridge)。这里的线程问题最容易踩JavascriptInterface方法执行在WebView的专属线程不是UI线程直接在回调里刷新View会抛CalledFromWrongThreadException。用Handler切回主线程是固定写法。另一个参数细节是文本长度。用户在长文中全选时选中文本可能几万字一次性通过桥传回来会让低端机卡顿。常见做法是在JS侧先截断比如超过8000字符只回传首尾各2000字中间用省略号占位。收藏这类场景需要完整文本时再让H5自己取DOM原生桥只传选区元信息。4.3 contenteditable和Shadow DOM下的选区边界阅读类页面的选择逻辑跑通后编辑器类页面会带来新问题。contenteditable里选中的文字用window.getSelection().toString()能拿到但想连格式一起复制需要从getRangeAt(0).cloneContents()重建DOM片段。这里有个已知行为cloneContents会保留节点结构和内联样式但遍历节点时br会被折叠成空格段间距信息容易丢失。所以复制纯文本用toString复制富文本要自己处理块级节点的换行。Shadow DOM是另一个容易翻车的地方。document.getSelection()拿不到Shadow root内部的选区长按元素在shadow树里时原生侧用getSelection().toString()返回空字符串。判断逻辑可以这样注入function hasShadowSelection() { if (document.getSelection().rangeCount 0) return false; var node document.getSelection().anchorNode; while (node) { if (node.host) return true; node node.parentNode; } return false; }这段JS注入后返回true说明选区藏在Shadow DOM内部。此时不要纠结用原生去解析直接降级处理要么整页复制要么让前端业务自行处理选区同步和复制。原生ActionMode方案要把自己的适用边界想清楚和网页抢选区控制权是不划算的这也是所有“webview选择文字”需求里最容易低估的一层。5. 多WebView复用下的选择状态清理与内核差异5.1 WebView复用导致ActionMode残留的清理顺序WebView的内存占用决定了大型应用里不会为每个页面都创建新实例四个五个复用是常态。但复用会带来一个隐蔽问题上一个页面里的选择状态没有清干净下一个页面可能自动弹出复制菜单或者点击菜单后回调了旧页面的JS。规范做法是在自定义WebView里维护ActionMode引用并在销毁回调里清理public class SelectionWebView extends WebView { private ActionMode customActionMode; Override public boolean onCreateActionMode(ActionMode mode, Menu menu) { customActionMode mode; return true; } Override public void onDestroyActionMode(ActionMode mode) { customActionMode null; } Override protected void onDetachedFromWindow() { if (customActionMode ! null) { customActionMode.finish(); customActionMode null; } super.onDetachedFromWindow(); } }onDetachedFromWindow是清理动作的兜底位置。有同学会偷懒用反射去读WebView内部的mActionMode字段不建议这么干一是反射代码在Android 14上可能被限制二是自己维护一个引用更直白。同时注意销毁顺序先loadUrl(about:blank)清空页面里的Selection状态再removeAllViews()最后才destroy()。如果复用前不清空页面旧页面的selectionchange事件会在新页面加载时被触发导致新页面刚打开就莫名其妙弹出了复制菜单。5.2 不同内核的Selection输出差异从系统WebView到X5国内很多客户端为了统一内核用的是X5或者自研WebView它们的选择浮层行为跟系统WebView差异比系统版本差异还要大。内核长按响应ActionMode支持常见问题Android系统WebViewChromium控制支持自定义Callback内核随商店更新需要版本适配X5内核自带内核逻辑部分版本onCreateActionMode的menu为null菜单可能走X5自己的浮层自研CK内核对齐Chromium需要逐版本回归机型与内核版本映射复杂X5的坑在实际项目里遇到过某些版本长按后根本不进入自定义ActionMode回调直接弹它自己的菜单浮层。这种场景下可以尝试在初始化时关闭X5的“智能选择”类开关或者放弃系统浮层用onLongClick自绘一个菜单窗口。适配多个内核时日志里永远要带WebView.getCurrentWebViewPackage()的版本信息不然两个同事的设备上出现相反表现会完全摸不到头绪。5.3 记录选区日志到应用目录用adb shell快速复盘多内核问题的排查依赖logcat容易迷失在噪音里。我一般会把选择链路的每一步写进应用私有目录的文件再用adb shell直接查看adb shell cat /storage/emulated/0/Android/data/com.example.app/files/selection.log代码侧只需要在关键节点追加一行File logFile new File(context.getExternalFilesDir(null), selection.log); try (FileWriter fw new FileWriter(logFile, true)) { fw.write(System.currentTimeMillis() | menuId itemId | selected len text.length() \n); } catch (IOException ignored) { }记录字段包含时间戳、菜单ID、选中文本长度和当前WebView版本。复现问题后直接看这个文件能够快速区分三类情况长按后ActionMode根本没启动、ActionMode启动了但菜单ID对不上、菜单点击了但JS返回空文本。这三类问题在日志里的特征完全不同配合content://与FileProvider在App内查看日志也可以但调试机上直接cat文件最省事。6. 快速验证方案和一条决定性的调试技巧6.1 用asset测试页搭最小复现环境先把页面因素都固定下来。在assets目录放一个selection_test.html里面是一段固定文本、一个链接和一个contenteditable区域不加载任何网络资源pWebView 文本选择测试段落用于验证 ActionMode 菜单与 JS 选区。/p a hrefhttps://example.com链接测试文本/a div contenteditabletrue可编辑区域文字/div用webView.loadUrl(file:///android_asset/selection_test.html)跑测试。分别长按三个区域验证纯文本、链接、可编辑区。每点一次自定义菜单把拿到的文本和长度打日志。这个动作能完成60%的问题归因如果纯文本正常而链接异常是HitTest分支的问题如果可编辑区异常才是JS选区的问题。6.2 开发期唯一能看穿JS选区的技巧开发阶段务必开启WebView远程调试if (BuildConfig.DEBUG) { WebView.setWebContentsDebuggingEnabled(true); }然后在电脑Chrome打开chrome://inspect选中对应的WebViewDevTools里可以直接执行document.getSelection().toString()。这个技巧比日志更进一步在Console里能看到JS侧的选区内容对比原生拿到的文本立刻判断问题在原生回调还是页面侧。如果DevTools里能取到文本而原生回调拿到空字符串问题锁死在evaluateJavascript的返回值解析上多半是JSON转义没处理干净如果DevTools里就取不到问题出在页面自己的选择逻辑被覆盖回到注入和降级那一章去处理。这一招能大幅压缩反复build和安装的调试周期是WebView选择文字调试里最实用的验证方式。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门