前言
作为一名哔哩哔哩的重度用户,我一直希望官方能够支持 3 倍速播放,但该功能长期未上线。恰好自己对 iOS 逆向工程有所研究,于是尝试通过逆向的方式,为 B 站 App 补上这一“缺失”的能力。
修改前:最高仅支持 2.0 倍速。
修改后:成功添加 3.0 倍速选项
本系列文章将完整记录实现 3 倍速播放的全过程。
本文是最后一个篇章,重点分析 竖屏视频 · 全屏播放 · 使用竖屏模式播放 这一具体场景下的实现思路与修改方案。
系列回顾:
场景
本文讨论的具体场景如下:
- 开启竖屏模式 路径:我的 → 设置 → 播放设置 → 竖屏模式设置 → 竖屏视频全屏时使用竖屏模式播放
- 竖屏视频 · 全屏播放 · 竖屏模式下的倍速选择界面
该场景下的倍速面板与前几篇文章中的横屏场景存在一定差异,需要单独分析。
开发环境
分析
1. 播放速度组件定位
通过 Lookin 可以确认,倍速选择组件对应的类为:
VKSettingView.SelectContent
从 Mach-O 导出的 Swift 符号信息可以看到,该组件内部持有一个 VKSettingView.SelectModel,用于描述倍速数据模型。
class VKSettingView.SelectContent: VKSettingView.TitleBaseContent {
var model: VKSettingView.SelectModel?
var lazy selecter: VKSettingView.VKSelectControl?
}
2. 倍速数组来源分析
- 在
VKSettingView.SelectModel中,items属性用于保存所有可选的播放速度。通过分析其 setter 方法(sub_10D8ACB88),可以追踪到倍速数组的赋值逻辑。
import Foundation
class VKSettingView.SelectModel: VKSettingView.BaseModel {
/* fields */
var icon: String
var items: [String]
var reports: [String]
var selectedIndex: Int
...
/* methods */
func sub_10d8aca08 // getter (instance)
func sub_10d8acac4 // setter (instance)
func sub_10d8acb20 // modify (instance)
func sub_10d8acb70 // getter (instance)
func sub_10d8acb88 // setter (instance)
func sub_10d8acb94 // modify (instance)
...
}
-
在 Xcode 中对该方法(
sub_10D8ACB88)添加符号断点后,结合 LLDB 打印参数,可以确认items的实际内容:["0.5", "0.75", "1.0", "1.25", "1.5", "2.0"]这也验证了
items确实对应播放速度列表。
添加符号断点:
LLDB:
(lldb) p (id)$x0
(_TtGCs23_ContiguousArrayStorageSS_$ *) 0x0000000119c70980
(lldb) expr -l Swift -- unsafeBitCast(0x0000000119c70980, to: Array<String>.self)
([String]) $R0 = 6 values {
[0] = "0.5"
[1] = "0.75"
[2] = "1.0"
[3] = "1.25"
[4] = "1.5"
[5] = "2.0"
}
3. 调用链与关键方法
- 我们打印方法的调用堆栈,发现是
sub_10B2AC040修改了items的值
(lldb) bt
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 2.1
* frame #0: 0x00000001106a4b88 bili-universal`sub_10D8ACB88
frame #1: 0x000000010e0a40cc bili-universal`sub_10B2AC040 + 140
frame #2: 0x000000010e0a8390 bili-universal`sub_10B2B033C + 84
frame #3: 0x000000010e0a2680 bili-universal`sub_10B2AA5F4 + 140
frame #4: 0x000000010e0a8390 bili-universal`sub_10B2B033C + 84
...
- 我们从
IDA看下sub_10B2AC040的伪代码实现
_QWORD *__fastcall sub_10B2AC040(void *a1, id a2)
{
...
v3 = a2;
if ( a2 && (v4 = v2, v6 = type_metadata_accessor_for_SelectModel_0(0LL), (v7 = swift_dynamicCastClass(v3, v6)) != 0) )
{
v8 = v7;
v9 = sub_107C8B79C(&unk_116BB42E8);
inited = swift_initStaticObject(v9, &unk_116E78980);
v11 = *((swift_isaMask & *v8) + 0x1C0LL);
v12 = objc_retain(v3);
v11(inited);
(*((swift_isaMask & *v8) + 0x1D8LL))(inited);
v13 = objc_retain(v4);
v14 = sub_10B2B1D70(inited, v13);
LOBYTE(v11) = v15;
objc_release(v13);
...
- 我们直接搜索
sub_10B2AC040的伪代码,看是否有直接调用sub_10D8ACB88,很遗憾并没有 - 我们打印方法的参数,发现
x1的值是_TtC13VKSettingView11SelectModel,也就是VKSettingView.SelectModel
(lldb) p (id)$x0
(BAPIPlayersharedSettingItem *) 0x0000000283d34b00
(lldb) p (id)$x1
(_TtC13VKSettingView11SelectModel *) 0x0000000282925a00
- 我们打印
x1(VKSettingView.SelectModel)(0x0000000282925a00)的items的值,发现是个空数组
(lldb) p [(_TtC13VKSettingView11SelectModel *) 0x0000000282925a00 items]
(_TtCs19__EmptyArrayStorage *) 0x00000001dd92e978
(lldb) expr -l Swift -- unsafeBitCast(0x00000001dd92e978, to: Array<String>.self)
([String]) $R1 = 0 values {}
- 我们在
sub_10B2AC040方法返回之前添加一个断点,看下x1(VKSettingView.SelectModel)(0x0000000282925a00)的items的值
(lldb) register read
General Purpose Registers:
x0 = 0x0000000282925a00
x1 = 0x000000028147a8c0
x2 = 0x0000000000000001
x3 = 0x000000018fc88490 libsystem_malloc.dylib`nanov2_free_definite_size$VARIANT$armv81
x4 = 0x00000002800e40f0
x5 = 0xffffffff0085ca00
x6 = 0x0000000280b8ac80
x7 = 0xffffffff0085cb00
x8 = 0x00000000000001ff
...
- 因为
x0的值是0x0000000282925a00,所以打印x0的值,看到x0(VKSettingView.SelectModel)(0x0000000282925a00)的items有值了,就是播放速度数组,这也证明sub_10B2AC040修改了VKSettingView.SelectModel的items的值。x0通常拿来存放函数的返回值。
(lldb) p (id)$x0
(_TtC13VKSettingView11SelectModel *) 0x0000000282925a00
(lldb) p [(_TtC13VKSettingView11SelectModel *) 0x0000000282925a00 items]
(_TtCs22__SwiftDeferredNSArray *) 6 values
(lldb) po [(_TtC13VKSettingView11SelectModel *) 0x0000000282925a00 items]
<Swift.__SwiftDeferredNSArray 0x281408ca0>(
0.5,
0.75,
1.0,
1.25,
1.5,
2.0
)
-
我们将
sub_10B2AC040的伪代码,参数a1的类型是BAPIPlayersharedSettingItem,a2的类型是VKSettingView.SelectModel一起给chatgpt分析,叫我们查看swift_initStaticObject的参数&unk_116E78980的值是什么。- 如果
chatgpt的分析结果没用,我们就自己打断点,看是哪些汇编代码更改了items的值,再看汇编代码对应的伪代码是怎样的。
检查 inited = swift_initStaticObject(...) 对象 inited 很可能是 SelectModel 或其内部配置对象(例如某个静态配置结构体或 Swift 字典/数组)被初始化。你可以在反汇编中查看 swift_initStaticObject 的参数 &unk_116E78980 看看该静态对象是什么,它可能携带 items 的初始数据。若你在数据段或只读段中找到与 “items” 相关的字符串数组、常量字符串列表、NSStringPointer 等,那可能就是 items 的来源。 - 如果
-
查看
&unk_116E78980的值,发现是在数据段(__data)中
- 查看
&unk_116E78980的值的16进制视图,发现它旁边的地址存放着播放速度,所以&unk_116E78980保存着播放速度数组
- 我们知道数据段(
__data)存放着全局变量。所以播放速度数组应该是放在一个全局变量里面,类似:
var playbackRates = ["0.5", "0.75", "1.0", "1.25", "1.5", "2.0"]
数据结构说明
以地址 0x116E789B0 为例,其内存布局如下:
30 2E 37 35 00 00 00 00 00 00 00 00 00 00 00 E4
对应字符串为 "0.75",各字节含义如下:
30→'0'2E→'.'37→'7'35→'5'E4→ 表示读取 4 个有效字符
其中最后一个字节尤为关键:
E3表示长度为 3(如1.0)E4表示长度为 4(如1.25、0.75)
越狱解决方案
核心思路
在越狱环境下,可以直接修改内存中存储的播放速度字符串数组,比如将原有的 0.75 替换为 1.0。
播放速度字符串数组的地址
0000000116E789A0 30 2E 35 00 00 00 00 00 00 00 00 00 00 00 00 E3 0.5.............
0000000116E789B0 30 2E 37 35 00 00 00 00 00 00 00 00 00 00 00 E4 0.75............
0000000116E789C0 31 2E 30 00 00 00 00 00 00 00 00 00 00 00 00 E3 1.0.............
0000000116E789D0 31 2E 32 35 00 00 00 00 00 00 00 00 00 00 00 E4 1.25............
0000000116E789E0 31 2E 35 00 00 00 00 00 00 00 00 00 00 00 00 E3 1.5.............
0000000116E789F0 32 2E 30 00 00 00 00 00 00 00 00 00 00 00 00 E3 2.0.............
实现方式
- 根据基地址顺序写入新的倍速字符串
- 自动计算字符串长度并写入末尾标记位(
E0 + length)
代码实现中,通过封装写入方法,避免手动计算与出错。
/// 将速度写入到内存地址
/// - Parameters:
/// - dest_addr: 目标内存地址
/// - str: 速度字符串,比如"1.0"
static int write_rate_string_to_address(uintptr_t dest_addr, NSString *str) {
if (str == nil) {
return -1;
}
// UTF8 字符串
const char *utf8Str = [str UTF8String];
size_t strLength = strlen(utf8Str); // 字符数(不含 \0)
if (strLength > (NJ_RATE_BLOCK_SIZE - 1)) {
// 只能容纳前15字节 + 最后一字节用于 E0+strLength
strLength = NJ_RATE_BLOCK_SIZE - 1;
}
uint8_t block[NJ_RATE_BLOCK_SIZE];
memset(block, 0, NJ_RATE_BLOCK_SIZE);
// 前 strLength 字节写入字符串
memcpy(block, utf8Str, strLength);
// 最后一个字节写入:E0 + 长度
block[NJ_RATE_BLOCK_SIZE - 1] = 0xE0 + (uint8_t)strLength;
// 将 block 写到目标地址
memcpy((void *)dest_addr, block, NJ_RATE_BLOCK_SIZE);
return 0;
}
/// 将速度写入到内存地址
/// - Parameter baseAddress: 起始内存地址
static void write_rate_to_address(uintptr_t baseAddress) {
NSArray<NSString *> *playbackRates = @[@"0.5", @"1.0", @"1.25", @"1.5", @"2.0", @"3.0"];
NSInteger count = playbackRates.count;
for (NSInteger i = 0; i < count; i++) {
uintptr_t currentAddress = baseAddress + i * NJ_RATE_BLOCK_SIZE;
write_rate_string_to_address(currentAddress, playbackRates[i]);
}
}
// [竖屏视频-全屏播放-用竖屏模式播放]的播放速度
static void changePlaybackRates_VerticalVideo_FullScreenPlayback_VerticalModePlayback() {
/*
0000000116E789A0 30 2E 35 00 00 00 00 00 00 00 00 00 00 00 00 E3 0.5.............
0000000116E789B0 30 2E 37 35 00 00 00 00 00 00 00 00 00 00 00 E4 0.75............
0000000116E789C0 31 2E 30 00 00 00 00 00 00 00 00 00 00 00 00 E3 1.0.............
0000000116E789D0 31 2E 32 35 00 00 00 00 00 00 00 00 00 00 00 E4 1.25............
0000000116E789E0 31 2E 35 00 00 00 00 00 00 00 00 00 00 00 00 E3 1.5.............
0000000116E789F0 32 2E 30 00 00 00 00 00 00 00 00 00 00 00 00 E3 2.0.............
*/
uintptr_t baseAddress = g_slide+0x116E789A0;
write_rate_to_address(baseAddress);
}
非越狱解决方案
修改目标
非越狱环境下,需要直接对 Mach-O 文件 进行静态修改,即在 IDA 中对 __DATA 段对应地址进行 Patch。
播放速度字符串数组的地址
0000000116E789A0 30 2E 35 00 00 00 00 00 00 00 00 00 00 00 00 E3 0.5.............
0000000116E789B0 30 2E 37 35 00 00 00 00 00 00 00 00 00 00 00 E4 0.75............
0000000116E789C0 31 2E 30 00 00 00 00 00 00 00 00 00 00 00 00 E3 1.0.............
0000000116E789D0 31 2E 32 35 00 00 00 00 00 00 00 00 00 00 00 E4 1.25............
0000000116E789E0 31 2E 35 00 00 00 00 00 00 00 00 00 00 00 00 E3 1.5.............
0000000116E789F0 32 2E 30 00 00 00 00 00 00 00 00 00 00 00 00 E3 2.0.............
操作流程
- 定位目标地址
- 使用
Edit → Patch program → Change byte - 按照字符串与长度规则修改字节
- 应用 Patch 并保存到原始文件
示例
比如修改0000000116E789B0
0000000116E789B0 30 2E 37 35 00 00 00 00 00 00 00 00 00 00 00 E4 0.75............
- 鼠标点击
0000000116E789B0 IDA->Edit->Patch program->Change byte
- 显示
Patch Bytes弹框
- 从
Origin value:- 30 2E 37 35 00 00 00 00 00 00 00 00 00 00 00 E4
- 修改 Values 为:
- 31 2E 30 00 00 00 00 00 00 00 00 00 00 00 00 E3
- 点击
OK,真正修改
全部修改结果
- 当前的值:
0.5 → 30 2E 35 00 00 00 00 00 00 00 00 00 00 00 00 E3
0.75 → 30 2E 37 35 00 00 00 00 00 00 00 00 00 00 00 E4
1.0 → 31 2E 30 00 00 00 00 00 00 00 00 00 00 00 00 E3
1.25 → 31 2E 32 35 00 00 00 00 00 00 00 00 00 00 00 E4
1.5 → 31 2E 35 00 00 00 00 00 00 00 00 00 00 00 00 E3
2.0 → 32 2E 30 00 00 00 00 00 00 00 00 00 00 00 00 E3
- 新的播放速度对应的值:
0.5 → 30 2E 35 00 00 00 00 00 00 00 00 00 00 00 00 E3
1.0 → 31 2E 30 00 00 00 00 00 00 00 00 00 00 00 00 E3
1.25 → 31 2E 32 35 00 00 00 00 00 00 00 00 00 00 00 E4
1.5 → 31 2E 35 00 00 00 00 00 00 00 00 00 00 00 00 E3
2.0 → 32 2E 30 00 00 00 00 00 00 00 00 00 00 00 00 E3
3.0 → 33 2E 30 00 00 00 00 00 00 00 00 00 00 00 00 E3
- 全部修改完后
0000000116E789A0 30 2E 35 00 00 00 00 00 00 00 00 00 00 00 00 E3 0.5.............
0000000116E789B0 31 2E 30 00 00 00 00 00 00 00 00 00 00 00 00 E3 1.0.............
0000000116E789C0 31 2E 32 35 00 00 00 00 00 00 00 00 00 00 00 E4 1.25............
0000000116E789D0 31 2E 35 00 00 00 00 00 00 00 00 00 00 00 00 E3 1.5.............
0000000116E789E0 32 2E 30 00 00 00 00 00 00 00 00 00 00 00 00 E3 2.0.............
0000000116E789F0 33 2E 30 00 00 00 00 00 00 00 00 00 00 00 00 E3 3.0.............
保存
保存到Mach-O文件中
IDA->Edit->Patch program->Apply patches to input file->Apply patches
- 保存后,底部会显示
log:
Patch successful: /Users/touchworld/Documents/iOSDisassembler/hook/bilibili/IDA_max_0/bili-universal
最终效果
完成修改后,在 竖屏视频 · 全屏播放 · 竖屏模式 场景下,倍速面板中已成功显示 3.0 倍速,并且功能可正常使用。
总结
本文针对 竖屏视频 · 全屏播放 · 竖屏模式播放 场景,完整分析了哔哩哔哩播放倍速的生成与限制来源,并成功将该场景下的最高播放速度从 2.0 倍 扩展至 3.0 倍。
通过对 UI 组件、数据模型及调用链的逐层追踪,可以确认该场景下的倍速列表并非运行时动态计算,而是来源于一个位于 __DATA 段的 静态全局配置数据。该数据以固定长度结构存储字符串,并通过末尾字节描述有效字符长度,从而决定最终展示的播放速度选项。
基于这一结论,本文给出了两种稳定可行的修改方式:
- 越狱环境:运行时直接修改内存中的倍速数据,灵活且调试成本低
- 非越狱环境:通过
Mach-O静态Patch修改数据段,实现永久生效
至此,哔哩哔哩在所有播放场景下的 3 倍速限制已全部完成分析与适配,后续即便实现方式发生变化,也可以沿用本系列文章的分析思路快速重新定位。