前言
Android在悬浮窗中添加的WebView,默认是不能长按文字进行选择的, 我在去年年底注意到了这个问题,于是在Play商店找了一些在悬浮窗口中使用WebView的产品,发现它们大多都不能选中文本,唯独一个可以选中文本的竞品,也没有弹出“搜索/复制...”菜单。
于是花了些时间研究了一下,最后在自己项目勉强解决了这个问题。
本想着攻克了一个竞品们没做到的功能,就能获得大量用户和好评,但这都快1年了,也才大概23k+的累计下载次数,我也早已不想继续开发新功能。
上个月有位国外的开发者,通过我在GooglePlay留下的邮箱联系到了我,想知道我是如何实现的选中文本。我看了他的作品,下载量显示100k!(GooglePlay平台公开的下载量,100K~499999都会显示100K)。
回顾一下这个问题的分析思路。
问题分析
Layout Inspector
在Activity中的WebView可以长按文本,出现两个可以拖拽以选中文本的手柄。
这里可以直接使用AndroidStudio自带的Layout Inspector工具找找线索,
如图,通过工具可以看到PopupTouchHandleDrawable这个类名,接着去Chromium仓库搜索相关代码,下文chromium源码版本126.0.6429.1。
源码关键部分如下,可以确定,这个手柄本质是PopupWindow,而不是自己浏览器引擎自己渲染的什么东西,也不是和WebView同Window的View。
// 忽略可以忽略的代码
@JNINamespace("android_webview")
public class PopupTouchHandleDrawable extends View implements DisplayAndroidObserver {
private final PopupWindow mContainer;
private final long mNativeDrawable;
private PopupTouchHandleDrawable(
ObserverList<PopupTouchHandleDrawable> drawableObserverList,
WebContents webContents,
ViewGroup containerView) {
mContainer =
new PopupWindow(
windowAndroid.getContext().get(),
null,
android.R.attr.textSelectHandleWindowStyle);
mNativeDrawable =
PopupTouchHandleDrawableJni.get()
.init(
PopupTouchHandleDrawable.this,
HandleViewResources.getHandleHorizontalPaddingRatio());
}
@CalledByNative
private void show() {
try {
mContainer.showAtLocation(
mContainerView,
Gravity.NO_GRAVITY,
getContainerPositionX(),
getContainerPositionY());
} catch (WindowManager.BadTokenException e) {
hide();
}
}
@CalledByNative
private void hide() {
if (mContainer.isShowing()) {
try {
mContainer.dismiss();
} catch (IllegalArgumentException e) {
// Intentionally swallowed due to bad Android implemention. See crbug.com/633224.
}
}
}
}
从现象上来看,悬浮窗口中的WebView,尝试选中文本时的现象是,手柄出现一瞬间,然后立马消失。大概看看PopupWindow源码可以确定,WindowManager的addView和removeViewImmediate肯定是被调用了的。分析调用PopupTouchHandleDrawable#hide前后代码,就可以确定为什么手柄会消失。
代理WindowManager
WindowManager和前段时间介绍的AudioManager一样,是可以被继承的。WebView内部使用的context来自它构造方法的参数,重写getSystemService返回自己的WindowManager即可打印removeViewImmediate函数调用栈。
class DemoActivity : AppCompatActivity() {
// ...
override fun getSystemService(name: String): Any? {
val rawService = super.getSystemService(name)
if(name == Context.WINDOW_SERVICE){
rawService as WindowManager
val interfaces = arrayOf(WindowManager::class.java)
return Proxy.newProxyInstance(null, interfaces) { proxy, method, args ->
if(method.name.startsWith("remove")){
Log.e(TAG, "getSystemService: ", Throwable())
}
return@newProxyInstance if(args == null){
method.invoke(rawService)
}else{
method.invoke(rawService, *args)
}
}
}
return rawService
}
}
getSystemService:
java.lang.Throwable
at xx.DemoActivity.getSystemService$lambda$0(DemoActivity.kt:52)
at xx.DemoActivity.$r8$lambda$Wql5z_9ckChc_YtnOU9dZsSEtaM(Unknown Source:0)
at xx.DemoActivity$$ExternalSyntheticLambda0.invoke(Unknown Source:2)
at java.lang.reflect.Proxy.invoke(Proxy.java:1006)
at $Proxy4.removeViewImmediate(Unknown Source)
at android.widget.PopupWindow.dismissImmediate(PopupWindow.java:2147)
at android.widget.PopupWindow.dismiss(PopupWindow.java:2081)
at org.chromium.android_webview.PopupTouchHandleDrawable.hide(chromium-TrichromeWebViewGoogle6432.apk-stable-511209734
at android.os.MessageQueue.nativePollOnce(Native Method)
at android.os.MessageQueue.next(MessageQueue.java:341)
at android.os.Looper.loopOnce(Looper.java:169)
at android.os.Looper.loop(Looper.java:300)
at android.app.ActivityThread.main(ActivityThread.java:8289)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:559)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:954)
这样打印的调用栈并不包含Native层的调用,还是无法确定哪里调的PopupTouchHandleDrawable#hide,它与nativePollOnce中间不含任何内容。至少可以确定,不是Java代码。(这种情况,平时可以JNI调一下abort,从崩溃的调用栈看到native层调用。但我这里没有包含调试符号的webview环境,逆向角度看chromium还是很麻烦)。
在Native代码找线索
(失败了...)
webview使用jni_zero项目绑定java层和native层之间的调用,工具可以根据@JNINamespace("android_webview")、@CalledByNative注解生成Java代码对应的native层代码。具体文档在源码的third_party\jni_zero\README.md。
Native层调用hide的位置:android_webview\browser\popup_touch_handle_drawable.cc
void PopupTouchHandleDrawable::SetEnabled(bool enabled) {
JNIEnv* env = jni_zero::AttachCurrentThread();
ScopedJavaLocalRef<jobject> obj = java_ref_.get(env);
if (!obj)
return;
if (enabled)
Java_PopupTouchHandleDrawable_show(env, obj);
else
Java_PopupTouchHandleDrawable_hide(env, obj);
}
查找何处调用了PopupTouchHandleDrawable::SetEnabled,除了TouchHandle的构造函数,只有ui\touch_selection\touch_handle.cc:
void TouchHandle::SetEnabled(bool enabled) {
if (enabled_ == enabled)
return;
if (!enabled) {
SetVisible(false, ANIMATION_NONE);
EndDrag();
EndFade();
}
enabled_ = enabled;
drawable_->SetEnabled(enabled);
}
继续查找何处调用TouchHandle::SetEnabled,除了单元测试以外,都在ui\touch_selection\touch_selection_controller.cc,分别是TouchSelectionController::ActivateInsertionIfNecessary、TouchSelectionController::DeactivateInsertion、TouchSelectionController::ActivateSelectionIfNecessary、TouchSelectionController::DeactivateSelection。
再这样倒着找下去就很难了。blink模块负责渲染,大部分代码运行在单独的渲染进程,和浏览器进程通过mojo跨进程通信。很可能是浏览器进程(我们的App进程)调用某个函数向渲染进程通知"不要选中任何内容",然后渲染进程处理完毕后,再通知浏览器进程"选中的范围发生变化",然后浏览器进程才调用PopupTouchHandleDrawable#hide,甚至可能不只是两个调用栈,挺麻烦的...
不小心发现关键代码
再翻了翻chromium里面Java代码。发现这个问题相关代码在content\public\android\java\src\org\chromium\content\browser\selection\SelectionPopupControllerImpl.java,节选重要部分:
@Override
public boolean isActionModeValid() {
return mActionMode != null;
}
public void showActionModeOrClearOnFailure() {
ActionMode actionMode = mView.startActionMode(mCallback, ActionMode.TYPE_FLOATING);
setActionMode(actionMode);
if (!isActionModeValid()) clearSelection();
}
@Override
public void clearSelection() {
mWebContents.collapseSelection();
}
也就是说,如果startActionMode返回null,最终会调用collapseSelection清理选中了的文本。继承WebView可以确认,activity中的WebView没返回null,而悬浮窗口中的WebView的startActionMode确实返回了null。
WebView的startActionMode默认实现来自android.view.View:
public ActionMode startActionMode(ActionMode.Callback callback, int type) {
ViewParent parent = getParent();
if (parent == null) return null;
try {
return parent.startActionModeForChild(this, callback, type);
} catch (AbstractMethodError ame) {
// Older implementations of custom views might not implement this.
return parent.startActionModeForChild(this, callback);
}
}
ViewGroup中的startActionModeForChild:
@Override
public ActionMode startActionModeForChild(
View originalView, ActionMode.Callback callback, int type) {
if ((mGroupFlags & FLAG_START_ACTION_MODE_FOR_CHILD_IS_NOT_TYPED) == 0
&& type == ActionMode.TYPE_PRIMARY) {
// 此问题场景中,上文已确定type是ActionMode.TYPE_FLOATING, 所以这里直接忽略.
}
if (mParent != null) {
try {
return mParent.startActionModeForChild(originalView, callback, type);
} catch (AbstractMethodError ame) {
// Custom view parents might not implement this method.
return mParent.startActionModeForChild(originalView, callback);
}
}
return null;
}
可以看到,ViewGroup中的startActionModeForChild再次调用了父ViewGroup的同名函数。一层一层父View找下去,activity中的WebView如此找到的是DecorView。
@Override
public ActionMode startActionModeForChild(
View child, ActionMode.Callback callback, int type) {
return startActionMode(child, callback, type);
}
private ActionMode startActionMode(
View originatingView, ActionMode.Callback callback, int type) {
ActionMode.Callback2 wrappedCallback = new ActionModeCallback2Wrapper(callback);
//...
}
所以这就是原因,我们添加到悬浮窗口中的WebView,正是因为没有一个ViewGroup提供ActionMode,导致的无法选中文本。
解决无法选中的问题
原本我是在WindowManager#addView传入的ViewGroup重写startActionModeForChild,返回一个自己创建的空的ActionMode。但现在再来回顾这个问题,似乎重写WebView的startActionMode更简单直观。
class DemoActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val webView = object: WebView(this) {
override fun startActionMode(callback: ActionMode.Callback, type: Int): ActionMode {
return EmptyActionMode(this@DemoActivity)
}
}
val parentView = FrameLayout(this)
parentView.addView(webView)
//...
windowManager.addView(parentView, lp)
}
open class EmptyActionMode(val context: Context): ActionMode() {
override fun setTitle(title: CharSequence?) {
}
override fun setTitle(resId: Int) {
}
override fun setSubtitle(subtitle: CharSequence?) {
}
override fun setSubtitle(resId: Int) {
}
override fun setCustomView(view: View?) {
}
override fun invalidate() {
}
override fun finish() {
}
override fun getMenu(): Menu? = null
override fun getTitle(): CharSequence? {
return null
}
override fun getSubtitle(): CharSequence? {
return null
}
override fun getCustomView(): View? {
return null
}
override fun getMenuInflater(): MenuInflater {
return MenuInflater(context)
}
}
}
就这样,避免了ActionMode是null导致取消选中。
显示菜单
接下来可以参考DecorView和FloatingActionMode(com.android.internal.view)的流程,处理菜单逻辑。
为了简单,让菜单直接显示到自己悬浮窗口里面,而不是像Android系统自己那样创建一个独立的Window。同时,先只保留最重要的菜单项:剪切、复制、粘贴。
获取WebView向菜单添加的id
WebView内部调用我们返回的ActionMode的getMenu()方法,向菜单添加内容。所以接下来先重写getMenu()获取菜单项,为了简单,直接继承AndroidX的MenuBuilder,并且提供一个回调,我们通过这个回调确定WebView正在往菜单添加的按钮的id,以及MenuItem:
import android.annotation.SuppressLint
import android.content.Context
import android.view.MenuItem
import androidx.appcompat.view.menu.MenuBuilder
import androidx.core.util.Consumer
@SuppressLint("RestrictedApi")
class Menu(context: Context, private val onAdd : Consumer<Pair<Int, MenuItem>>): MenuBuilder(context) {
override fun addInternal(group: Int, id: Int, categoryOrder: Int, title: CharSequence?): MenuItem {
val menuItem = super.addInternal(group, id, categoryOrder, title)
onAdd.accept(id to menuItem)
return menuItem
}
}
这里回调id和MenuItem,是为了addInternal正在添加的菜单项,究竟是不是剪切、复制、粘贴中的其中一个。这里的title参数会被翻译为全球各地区的语言,只能通过id判断。
获取菜单项的id
content\public\android\java\src\org\chromium\content\browser\selection\SelectionPopupControllerImpl.java代码:
private void handleMenuItemClick(@IdRes final int id) {
if (id == R.id.select_action_menu_select_all) {
selectAll();
} else if (id == R.id.select_action_menu_cut) {
cut();
} else if (id == R.id.select_action_menu_copy) {
copy();
} else if (id == R.id.select_action_menu_paste) {
paste();
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
&& id == R.id.select_action_menu_paste_as_plain_text) {
pasteAsPlainText();
} else if (id == R.id.select_action_menu_share) {
share();
} else if (id == R.id.select_action_menu_web_search) {
search();
}
}
比如R.id.select_action_menu_cut,它定义在content\public\android\java\res\values\ids.xml:
<resources>
<!-- Selection Menu Group IDs -->
<item type="id" name="select_action_menu_assist_items" />
<item type="id" name="select_action_menu_default_items" />
<item type="id" name="select_action_menu_text_processing_items" />
<!-- Selection Menu Items -->
<item type="id" name="select_action_menu_cut" />
<item type="id" name="select_action_menu_copy" />
<item type="id" name="select_action_menu_paste" />
<item type="id" name="select_action_menu_share" />
<item type="id" name="select_action_menu_select_all" />
<item type="id" name="select_action_menu_paste_as_plain_text" />
<item type="id" name="select_action_menu_web_search" />
</resources>
所以我们可以这样获取到id:
private val webViewPkg = WebView.getCurrentWebViewPackage()!!.packageName
@SuppressLint("DiscouragedApi")
private fun getId(param: String): Int{
return try{
resources.getIdentifier(param, "id", webViewPkg)
}catch (e: Exception){
0
}
}
private val idCut = getId("select_action_menu_cut")
private val idCopy = getId("select_action_menu_copy")
private val idPaste = getId("select_action_menu_paste")
这里未来可能有风险,就是用户设备的WebView的实现把这些id的名字改了。这个地方可以做个事件上报。
最终代码
以下是为了写这篇文章临时整理的完整代码,我明白写法不优雅,存在各种风险,但是没时间完善了,仅供参考关键思路,不要直接用于项目中。
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Color
import android.graphics.Rect
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.ActionMode
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.WindowManager
import android.webkit.WebView
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.menu.MenuBuilder
import androidx.core.util.Consumer
import androidx.core.view.forEach
import androidx.core.view.setPadding
class DemoActivity : AppCompatActivity() {
private val mHandler = Handler(Looper.getMainLooper())
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val menuView = LinearLayout(this)
menuView.orientation = LinearLayout.HORIZONTAL
menuView.setBackgroundColor(Color.YELLOW)
menuView.visibility = View.INVISIBLE
val webView = object: WebView(this) {
private val webViewPkg = getCurrentWebViewPackage()!!.packageName
@SuppressLint("DiscouragedApi")
private fun getId(param: String): Int{
return try{
resources.getIdentifier(param, "id", webViewPkg)
}catch (e: Exception){
0
}
}
private val idCut = getId("select_action_menu_cut")
private val idCopy = getId("select_action_menu_copy")
private val idPaste = getId("select_action_menu_paste")
override fun startActionMode(callback: ActionMode.Callback, type: Int): ActionMode {
val webView = this
return object: EmptyActionMode(this@DemoActivity){
private var isFinished = false
private val delayShow = Runnable {
if(!isFinished){
menuView.visibility = View.VISIBLE
}
}
private val mMenu = CatchMenu(context) { idAndMenuItem ->
if (idAndMenuItem.first == 0) {
return@CatchMenu
}
if(idCut != idAndMenuItem.first &&
idCopy != idAndMenuItem.first &&
idPaste != idAndMenuItem.first){
return@CatchMenu
}
menuView.addView(TextView(context).also {
it.setPadding((resources.displayMetrics.density * 16).toInt())
it.text = idAndMenuItem.second.title
it.tag = idAndMenuItem.second
it.setOnClickListener {
callback.onActionItemClicked(this, idAndMenuItem.second)
}
})
}
init{
menuView.removeAllViews()
callback.onCreateActionMode(this, mMenu)
callback.onPrepareActionMode(this, mMenu)
mHandler.post {
invalidateContentRect()
}
}
override fun invalidate() {
invalidateContentRect()
}
@SuppressLint("RestrictedApi")
override fun invalidateContentRect() {
val visibleItems = menu.visibleItems
menuView.forEach {
val menuItem = it.tag as MenuItem
val item = visibleItems.firstOrNull{ v -> v.itemId == menuItem.itemId }
it.visibility = if(item != null && menuItem.isVisible && menuItem.isEnabled){
View.VISIBLE
}else{
View.GONE
}
}
menuView.visibility = View.INVISIBLE
mHandler.removeCallbacks(delayShow)
mHandler.postDelayed(delayShow, 200)
if(callback is Callback2){
val rect = Rect()
callback.onGetContentRect(this, webView, rect)
val lp = menuView.layoutParams as FrameLayout.LayoutParams
lp.leftMargin = (rect.left + rect.right) / 2
lp.topMargin = rect.top
if(lp.leftMargin + menuView.measuredWidth > rootView.measuredWidth){
lp.leftMargin = rootView.measuredWidth - menuView.measuredWidth
}
if(lp.topMargin + menuView.measuredHeight > rootView.measuredWidth){
lp.topMargin = rootView.measuredHeight - menuView.measuredHeight
}
if(lp.leftMargin < 0){
lp.leftMargin = 0
}
if(lp.topMargin < 0){
lp.topMargin = 0
}
menuView.layoutParams = lp
}else{
// 还能做什么? 只能说明WebView或AOSP更新了.
}
}
override fun finish() {
menuView.visibility = View.INVISIBLE
callback.onDestroyActionMode(this)
isFinished = true
}
override fun getMenu() = mMenu
}
}
}
val parentView = FrameLayout(this)
parentView.setBackgroundColor(Color.GREEN)
parentView.setPadding((resources.displayMetrics.density * 4).toInt())
parentView.addView(webView)
parentView.addView(menuView,
FrameLayout.LayoutParams(
FrameLayout.LayoutParams.WRAP_CONTENT,
FrameLayout.LayoutParams.WRAP_CONTENT))
webView.loadData("<textarea style=\"width: 100vw; height: 100vh; font-size: 24px;\">" +
"test1234, test1234, test1234, test1234, " +
"test1234, test4321, test4321, test1234</textarea>", "text/html", null)
val lp = WindowManager.LayoutParams()
val edge = (resources.displayMetrics.density * 300).toInt()
lp.width = edge
lp.height = edge
lp.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
lp.flags = WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
windowManager.addView(parentView, lp)
}
@SuppressLint("RestrictedApi")
class CatchMenu(context: Context, private val onAdd : Consumer<Pair<Int, MenuItem>>): MenuBuilder(context) {
override fun addInternal(group: Int, id: Int, categoryOrder: Int, title: CharSequence?): MenuItem {
val menuItem = super.addInternal(group, id, categoryOrder, title)
onAdd.accept(id to menuItem)
return menuItem
}
}
open class EmptyActionMode(val context: Context): ActionMode() {
override fun setTitle(title: CharSequence?) {
}
override fun setTitle(resId: Int) {
}
override fun setSubtitle(subtitle: CharSequence?) {
}
override fun setSubtitle(resId: Int) {
}
override fun setCustomView(view: View?) {
}
override fun invalidate() {
}
override fun finish() {
}
override fun getMenu(): Menu? = null
override fun getTitle(): CharSequence? {
return null
}
override fun getSubtitle(): CharSequence? {
return null
}
override fun getCustomView(): View? {
return null
}
override fun getMenuInflater(): MenuInflater {
return MenuInflater(context)
}
}
}