对 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任何值横屏全屏隐藏虚拟按键
1080x22701080x607横屏详情显示虚拟按键
1080x22701080x2240竖屏全屏隐藏虚拟按键
1080x22701080x1417竖屏详情显示虚拟按键

最后我们通过调用 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模块编写教程