标题很长,但是总感觉如果删掉点,就缺了点啥,可能还是我文笔不好吧。
事情的起因是 我刷机升级了 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 站没有设置正确的 setSystemUiVisibility
的 Flag
值导致的。
对 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.bili
和 tv.danmaku.biliplayer
两个包内。
代码分析
通过暴力查找,不难发现,tv.danmaku.bili.ui.video.VideoDetailsActivity
类是包含了视频容器本身的视频详情页 Activity。该 Activity 有关导航栏的代码如下:
1 | 1376 private void h(int paramInt) { |
在后续通过 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 模板即可。
第二步填入应用的基本信息。语言我们选择 Java,由于本模组仅面向 MIUI 12,最低 SDK 版本号选择 29 或 30 即可。
进入 IDE 后,修改 app/src/main/build.gradle
,添加以下行来添加 Xposed 引用:
1 | repositories { |
随后修改 AndroidManifest.xml
,在 <application>
节点下添加以下三个元信息来声明这是一个 Xposed 模块:
1 | <meta-data android:name="xposedmodule" android:value="true" /> |
其中,xposeddescription
对应的值是模块的描述。您可以自己修改它。
创建一个 Hook 类
接下来我们创建 Hook 类,对目标应用的目标方法进行 Hook。在 app/src/main/java/<包名>/
下创建 Hooks 子包,然后创建 HideNavbarHook.java
文件,在其中编写 HideNavbarHook
类,并实现 IXposedHookLoadPackage
接口。
1 | package dev.luotianyi.blmiuifix.Hooks; |
然后使用 Alt+Enter
键,让 IDE 自动实现该接口的 handleLoadPackage
类。
1 | public void handleLoadPackage(XC_LoadPackage.LoadPackageParam loadPackageParam) |
最后,我们创建一个 Hook 的声明文件,声明这个 Hook 的存在。这个文件应位于 app/src/main/assets/xposed_init
。该文件的内容是所有 Hook 的完整包名与类名,每行一个。如果文件不存在,需要自己创建。
1 | dev.luotianyi.blmiuifix.Hooks.HideNavbarHook |
编写 Hook 的查找逻辑
首先,我们在类中定义四个静态字符串,分别代表需要 Hook 的包名、类名、方法名和 Layout 名。这些定义不是必须的,只是一种代码风格。
1 | public static String HOOKING_PACKAGE_NAME = "com.bilibili.app.in"; |
然后,我们在 IXposedHookLoadPackage
的实现方法 handleLoadPackage
中,编写处理已加载包的逻辑。如果是我们要 Hook 的对象,则继续运行,如果不是则停止执行。最后,我们找到序号 Hook 的函数,并设置一个 Hook。
1 | public void handleLoadPackage(XC_LoadPackage.LoadPackageParam loadPackageParam) throws Throwable { |
值得注意的是两点:
XpLogging
是作者编写的一个工具类,包含了用于日志的静态方法。其具体实现可以查看 GitHub 上的源代码(链接在本文文末)。您也可使用XposedBridge.log(string)
来代替它。XposedHelpers.findAndHookMethod
函数不仅需要传入函数的名称,还要依顺序传入函数的参数类。
随后,我们编写 getMethodHook()
函数——它将返回一个 XC_MethodHook
类。
1 | private XC_MethodHook getMethodHook() { |
可以看到,该类实现了两个方法,分别作用于被 Hook 方法开始前和开始后。我们忽略了被 Hook 方法执行前的逻辑,只关注其执行后。在 afterHookedMethod
方法中,我们获取被 Hook 方法所在的类——也就是我们需要的 Layout 的实例。接下来同前面一样,如果是我们需要的 Layout,则调用 doFrameLayoutChange
方法来修改 Layout,否则就停止执行。
编写 Hook 的逻辑
最后我们编写 doFrameLayoutChange
方法的逻辑。
1 | private void doFrameLayoutChange(FrameLayout layout) { |
我们通过比较屏幕的宽高,及被 Hook 的 Layout —— 也就是视频容器的大小,来判断是否出于全屏模式。下表列出了以 Redmi K30 Pro 为例,各种组合的值及其判断结果。
屏幕大小 | 容器大小 | Activity 状态 | 判断结果 |
---|---|---|---|
2270x1080 | 任何值 | 横屏全屏 | 隐藏虚拟按键 |
1080x2270 | 1080x607 | 横屏详情 | 显示虚拟按键 |
1080x2270 | 1080x2240 | 竖屏全屏 | 隐藏虚拟按键 |
1080x2270 | 1080x1417 | 竖屏详情 | 显示虚拟按键 |
最后我们通过调用 setSystemUiVisibility(int flag)
方法设置是否隐藏虚拟按键。
如果要隐藏虚拟按键,需要设置为如下值:
1 | View.SYSTEM_UI_FLAG_IMMERSIVE | |
如果需要显示虚拟按键,设置为 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 许可证发布。