对 Play 版哔哩哔哩 4.2.x 在 MIUI 12 上的虚拟按键 bug 问题分析及通过编写 Xposed 模块解决该问题的记录

标题很长,但是总感觉如果删掉点,就缺了点啥,可能还是我文笔不好吧。

事情的起因是 我刷机升级了 MIUI 12.5。具体的起因也在该博文里阐述了:

我是一个十分怀旧的人——仍然在使用 2.4.2 版本的 BiliBili,因为这是以抽屉方式进行交互的最后一个版本。而同时,我也依然在坚持使用安卓的“三大金刚”虚拟按键。但是,在更新到 MIUI 12.5 后,该版本的 BiliBili 出现了问题,表现为在全屏状态下,点击屏幕,虚拟按键会遮挡视频播放器的 UI。

该故障具体表现为:在 B 站全屏模式下,播放器会隐藏标题栏、进度条等播放器 UI,同时系统也会隐藏虚拟按键(俗称“三大金刚”)。此时点击屏幕,系统会显示播放器 UI,而与此同时,系统也会显示虚拟按键。此时,系统的虚拟按键会遮挡播放器 UI 的“画质”、“更多”按钮。

故障及其修复后的截图如下:
修复前
修复后

本文描述了对该故障产生原因的猜测、定位及解决方式。但是,本文所述的解决方式存在一定问题,引发了一些副作用。

本文假定读者已经配置好 Android Studio 4.1、JDK、ADK 等 Android 开发所必需的、兼容 Android 11 的环境。如果没有特别指出,本文选择的目标 SDK 版本为 29。

顺便吐槽一下,现在 Android 环境搭建已经非常简单了——只需下载 Android Studio,然后安装,就自动将环境搭建好,无需再另行配置了。如果检测到无法访问 Google,还会自动弹出要求你设置代理的对话框。

对故障原因的猜测

简单的进行 Google 搜索,关键词 android navbar fullscreen,首页就有 StackOverflow 上的相关信息。

这些信息指出,应用可以调用 View.setSystemUiVisibility(int) 的 API 来更改系统的状态栏和导航栏行为。

结合 BiliBili 全屏时系统的行为,可以猜测是 B 站没有设置正确的 setSystemUiVisibilityFlag 值导致的。

对 BiliBili 应用进行反编译

产生猜测后,我们通过反编译 BiliBili 对猜测进行验证。

首先使用 APKPure 获得 apk 文件,然后使用 7-zip 解压获得其中的 dex 文件。

接着,使用 dex2jar 将其转换为 jar 文件。解压后运行命令如下:

1
dex2jar-2.0\d2j-dex2jar.bat *.dex

最后,使用 Java Decompiler (JD) 反编译 jar 文件即可。

本文使用的 BiliBili APK 版本为 2.4.2 的 arm64-v8a 架构版本。

经分析,相关代码位于 classes5.dex 所对应的 jar 文件中。相关代码位于 tv.danmaku.bilitv.danmaku.biliplayer 两个包内。

代码分析

通过暴力查找,不难发现,tv.danmaku.bili.ui.video.VideoDetailsActivity 类是包含了视频容器本身的视频详情页 Activity。该 Activity 有关导航栏的代码如下:

1
2
3
1376  private void h(int paramInt) {
1377 getWindow().getDecorView().setSystemUiVisibility(paramInt);
1378 }

在后续通过 Xposed Hook 对该函数的参数进行日志记录的分析中,发现 paramInt 值不是 0,就是 4,而这两个值都无法确保在全屏状态下隐藏虚拟按键。

随后我们通过运行 UIAutomatorViewer 工具查找相关 UI 元素。该工具随 Android SDK 安装,位于 %APPDATALOCAL%\Android\Sdk\tools\bin\uiautomatorviewer.bat。双击该 bat 文件即可启动 UIAutomatorViewer。

通过对 UI 进行 dump,发现视频容器 Layout com.bilibili.app.in:id/videoview_container 继承自安卓原生类 android.widget.FrameLayout

由于时间精力限制,难以进行深入分析。在经历数小时尝试后,仍然难以定位 VideoDetailsActivity 内部 ViewGroup 的动态结构及其内部代表全屏的值。因此,接下来对模块的编写是不完善且存在副作用的。

编写 Xposed 模块

创建 Android 工程及清单设置

我们使用 Android Studio 创建工程。本次选择 Empty Activity 模板即可。

创建工程第 1 步,选择 Empty Activity 模板

第二步填入应用的基本信息。语言我们选择 Java,由于本模组仅面向 MIUI 12,最低 SDK 版本号选择 29 或 30 即可。

创建工程第 2 步,选择 Java 和最低 SDK 版本 29

进入 IDE 后,修改 app/src/main/build.gradle,添加以下行来添加 Xposed 引用:

1
2
3
4
5
6
7
8
repositories {
jcenter()
}

dependencies {
compileOnly 'de.robv.android.xposed:api:82'
compileOnly 'de.robv.android.xposed:api:82:sources'
}

随后修改 AndroidManifest.xml,在 <application> 节点下添加以下三个元信息来声明这是一个 Xposed 模块:

1
2
3
<meta-data android:name="xposedmodule" android:value="true" />
<meta-data android:name="xposeddescription" android:value="BiliBili HK 2.4.x navbar fix on MIUI 12" />
<meta-data android:name="xposedminversion" android:value="53" />

其中,xposeddescription 对应的值是模块的描述。您可以自己修改它。

创建一个 Hook 类

接下来我们创建 Hook 类,对目标应用的目标方法进行 Hook。在 app/src/main/java/<包名>/ 下创建 Hooks 子包,然后创建 HideNavbarHook.java 文件,在其中编写 HideNavbarHook 类,并实现 IXposedHookLoadPackage 接口。

1
2
3
4
5
package dev.luotianyi.blmiuifix.Hooks;

public class HideNavbarHook implements IXposedHookLoadPackage {

}

然后使用 Alt+Enter 键,让 IDE 自动实现该接口的 handleLoadPackage 类。

1
2
3
4
public void handleLoadPackage(XC_LoadPackage.LoadPackageParam loadPackageParam)
throws Throwable {

}

最后,我们创建一个 Hook 的声明文件,声明这个 Hook 的存在。这个文件应位于 app/src/main/assets/xposed_init。该文件的内容是所有 Hook 的完整包名与类名,每行一个。如果文件不存在,需要自己创建。

1
dev.luotianyi.blmiuifix.Hooks.HideNavbarHook

编写 Hook 的查找逻辑

首先,我们在类中定义四个静态字符串,分别代表需要 Hook 的包名、类名、方法名和 Layout 名。这些定义不是必须的,只是一种代码风格。

1
2
3
4
public static String HOOKING_PACKAGE_NAME = "com.bilibili.app.in";
public static String HOOKING_CLASS_NAME = "android.widget.FrameLayout";
public static String HOOKING_METHOD_NAME = "onLayout";
public static String HOOKING_LAYOUT_NAME = "com.bilibili.app.in:id/videoview_container";

然后,我们在 IXposedHookLoadPackage 的实现方法 handleLoadPackage 中,编写处理已加载包的逻辑。如果是我们要 Hook 的对象,则继续运行,如果不是则停止执行。最后,我们找到序号 Hook 的函数,并设置一个 Hook。

1
2
3
4
5
6
7
8
9
public void handleLoadPackage(XC_LoadPackage.LoadPackageParam loadPackageParam) throws Throwable {
if (!loadPackageParam.packageName.equals(HOOKING_PACKAGE_NAME)) return;
Class hookingClass = loadPackageParam.classLoader.loadClass(HOOKING_CLASS_NAME);
XpLogging.injectClassSuccess(HOOKING_CLASS_NAME);

XposedHelpers.findAndHookMethod(hookingClass, HOOKING_METHOD_NAME,
/* Method Arg Types*/ boolean.class, int.class, int.class, int.class, int.class,
getMethodHook());
}

值得注意的是两点:

  1. XpLogging 是作者编写的一个工具类,包含了用于日志的静态方法。其具体实现可以查看 GitHub 上的源代码(链接在本文文末)。您也可使用 XposedBridge.log(string) 来代替它。
  2. XposedHelpers.findAndHookMethod 函数不仅需要传入函数的名称,还要依顺序传入函数的参数类。

随后,我们编写 getMethodHook() 函数——它将返回一个 XC_MethodHook 类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private XC_MethodHook getMethodHook() {
return new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
super.beforeHookedMethod(param);
}

@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable {
XpLogging.injectMethodSuccess("Method", HOOKING_METHOD_NAME);
FrameLayout layout = (FrameLayout) param.thisObject;
if (!getViewNameByItself(layout).equals(HOOKING_LAYOUT_NAME)) return;
XpLogging.injectLayoutSuccess(HOOKING_LAYOUT_NAME, HOOKING_METHOD_NAME);
doFrameLayoutChange(layout);
}
};
}

可以看到,该类实现了两个方法,分别作用于被 Hook 方法开始前和开始后。我们忽略了被 Hook 方法执行前的逻辑,只关注其执行后。在 afterHookedMethod 方法中,我们获取被 Hook 方法所在的类——也就是我们需要的 Layout 的实例。接下来同前面一样,如果是我们需要的 Layout,则调用 doFrameLayoutChange 方法来修改 Layout,否则就停止执行。

编写 Hook 的逻辑

最后我们编写 doFrameLayoutChange 方法的逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private void doFrameLayoutChange(FrameLayout layout) {
DisplayMetrics dm = layout.getContext().getResources().getDisplayMetrics();
XpLogging.log(String.format("屏幕的大小为 %sx%s", dm.widthPixels, dm.heightPixels));
int height = layout.getHeight();
int width = layout.getWidth();
int systemUiVisibilityFlag;
if ((dm.widthPixels <= dm.heightPixels) && (height < (dm.heightPixels * 0.8))) {
systemUiVisibilityFlag = View.SYSTEM_UI_FLAG_VISIBLE;
} else {
systemUiVisibilityFlag =
View.SYSTEM_UI_FLAG_IMMERSIVE |
View.SYSTEM_UI_FLAG_FULLSCREEN |
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION;
}

layout.setSystemUiVisibility(systemUiVisibilityFlag);
XpLogging.log(String.format("因为 Layout 大小为 %sx%s 所以调用 setSystemUiVisibility(%s)", width, height, systemUiVisibilityFlag));
}

我们通过比较屏幕的宽高,及被 Hook 的 Layout —— 也就是视频容器的大小,来判断是否出于全屏模式。下表列出了以 Redmi K30 Pro 为例,各种组合的值及其判断结果。

屏幕大小 容器大小 Activity 状态 判断结果
2270x1080 任何值 横屏全屏 隐藏虚拟按键
1080x2270 1080x607 横屏详情 显示虚拟按键
1080x2270 1080x2240 竖屏全屏 隐藏虚拟按键
1080x2270 1080x1417 竖屏详情 显示虚拟按键

最后我们通过调用 setSystemUiVisibility(int flag) 方法设置是否隐藏虚拟按键。

如果要隐藏虚拟按键,需要设置为如下值:

1
2
3
View.SYSTEM_UI_FLAG_IMMERSIVE |
View.SYSTEM_UI_FLAG_FULLSCREEN |
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION

如果需要显示虚拟按键,设置为 0 值即可:

1
View.SYSTEM_UI_FLAG_VISIBLE

SYSTEM_UI_* 的具体值和其含义可在 API 参考文档 中找到。

最终编写的整个 Java 类较长,因此不在此展示。您可以浏览 GitHub 仓库上的 HideNavbarHook.java 文件。

以上所列文件的源代码均可于 GitHub 仓库的 app/src/main 目录找到。

结论与成果

BiliBili 客户端使用的 API 在但是的环境下是可用的,但是对于全面屏时代来说,其使用的方式会导致问题。本文修复了该问题,但是引起了副作用:在退出全屏后,需要对 UI 进行任意操作,虚拟按键才会显示出来。初步判断这个问题是由于 onLayout 在退出全屏时未被触发而导致的。

本文所述研究的最终成果为一个 Xposed 模块,并以开放源代码方式发布于 GitHub 上。您也可以从 备用下载链接 获取其二进制副本。该模块以 WTFPL 许可证发布。

参考

  1. 万物皆可Hook!重新捡起Hook神器-Xposed框架
  2. 新手不要再被误导!这是一篇最新的Xposed模块编写教程