前言
作为哔哩哔哩的重度用户,我一直期待官方支持 3 倍速播放,但该功能迟迟未上线。于是,我利用 iOS 逆向工程知识,为 B 站 App 添加这一功能。
修改前:最高仅支持 2.0 倍速。
修改后:成功添加 3.0 倍速选项
本系列分为多篇,本文聚焦 横屏视频 · 半屏播放 场景下的 3 倍速实现。
系列回顾:
场景
本文聚焦的具体使用场景为:横屏视频 · 半屏播放模式 下的播放速度设置页面。
开发环境
分析
1. 播放速度组件定位
首先通过 Lookin 对播放速度弹窗进行 UI 层级分析,可以确认当前播放速度列表对应的组件为:
VKSettingView.SelectContent
从 Mach-O 导出的 Swift 符号信息可以看到,该组件内部持有一个 VKSettingView.SelectModel,用于描述倍速数据模型。
class VKSettingView.SelectContent: VKSettingView.TitleBaseContent {
/* fields */
var model: VKSettingView.SelectModel ?
var lazy selecter: VKSettingView.VKSelectControl ?
}
Lookin:
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_$ *) 0x00000001179c8370
(lldb) expr -l Swift -- unsafeBitCast(0x00000001179c8370, to: Array<String>.self)
([String]) $R4 = 6 values {
[0] = "0.5"
[1] = "0.75"
[2] = "1.0"
[3] = "1.25"
[4] = "1.5"
[5] = "2.0"
}
3. 调用链与关键方法
- 我们打印方法的调用堆栈,发现是
sub_10A993E14修改了items的值
(lldb) bt
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 3.1
* frame #0: 0x000000010de78b88 bili-universal`sub_10D8ACB88
frame #1: 0x000000010af5fea0 bili-universal`sub_10A993E14 + 140
frame #2: 0x000000010af5f15c bili-universal`sub_10A992320 + 3644
frame #3: 0x000000010af5db20 bili-universal`sub_10A9916B4 + 1132
frame #4: 0x000000010af6714c bili-universal`sub_10A99B130 + 28
frame #5: 0x000000010af6859c bili-universal`sub_10A99C1A0 + 1020
frame #6: 0x000000010af67128 bili-universal`sub_10A99B118 + 16
...
- 我们从
IDA看下sub_10A993E14的伪代码实现
_QWORD *__fastcall sub_10A993E14(void *a1, id a2)
{
...
v3 = a2;
if ( a2 && (v4 = v2, v6 = type metadata accessor for SelectModel(0LL), (v7 = swift_dynamicCastClass(v3, v6)) != 0) )
{
v9 = (_QWORD *)v7;
v10 = sub_107C8B79C(&unk_116BB42E8, v8);
inited = swift_initStaticObject(v10, &unk_116E60370);
v12 = *(void (__fastcall **)(__int64))((swift_isaMask & *v9) + 0x1C0LL);
v13 = objc_retain(v3);
...
- 我们直接搜索
sub_10A993E14的伪代码,看是否有直接调用sub_10D8ACB88,很遗憾并没有 - 我们添加
sub_10A993E14符号断点,断点触发后打印方法的参数,发现x1的值是_TtC13VKSettingView11SelectModel,也就是VKSettingView.SelectModel
(lldb) p (id)$x0
(BAPIPlayersharedSettingItem *) 0x0000000282c0f880
(lldb) p (id)$x1
(_TtC13VKSettingView11SelectModel *) 0x0000000283b51790
- 我们打印
x1(VKSettingView.SelectModel)(0x0000000283b51790)的items的值,发现是个空数组
(lldb) p (id)$x1
(_TtC13VKSettingView11SelectModel *) 0x0000000283b51790
(lldb) p [(_TtC13VKSettingView11SelectModel *) 0x0000000283b51790 items]
(_TtCs19__EmptyArrayStorage *) 0x00000001dd92e978
(lldb) expr -l Swift -- unsafeBitCast(0x00000001dd92e978, to: Array<String>.self)
([String]) $R2 = 0 values {}
- 我们在
sub_10A993E14方法返回之前添加一个断点,看下x1(VKSettingView.SelectModel)(0x0000000283b51790)的items的值
(lldb) register read
General Purpose Registers:
x0 = 0x0000000283b51790
x1 = 0x00000002819eb700
x2 = 0x0000000000000003
...
x23 = 0x0000000283b51790
x24 = 0x0000000283b51790
x25 = 0x0000000116e17f28 (void *)0x00000001173e6b88: OBJC_METACLASS_$__TtC16BBUGCVideoDetail13VDUGCMoreBloc
x26 = 0x00000001142906d8 bili-universal`type_metadata_for_ToolCell + 784
x27 = 0x000000010a552534 bili-universal`sub_109F86534
x28 = 0x0000000116718000 "badge_control"
fp = 0x000000016f832610
lr = 0x000000010af5f15c bili-universal`sub_10A992320 + 3644
sp = 0x000000016f832510
pc = 0x000000010af5ffe4 bili-universal`sub_10A993E14 + 464
cpsr = 0x60000000
- 因为
x0的值是0x0000000283b51790,所以打印x0的值,看到x0(VKSettingView.SelectModel)(0x0000000283b51790)的items有值了,就是播放速度数组,这也证明sub_10A993E14修改了VKSettingView.SelectModel的items的值。x0通常拿来存放函数的返回值。
(lldb) p (id)$x0
(_TtC13VKSettingView11SelectModel *) 0x0000000283b51790
(lldb) p [(_TtC13VKSettingView11SelectModel *) 0x0000000283b51790 items]
(_TtCs22__SwiftDeferredNSArray *) 0x0000000280743120 6 values
(lldb) po 0x0000000280743120
<Swift.__SwiftDeferredNSArray 0x280743120>(
0.5,
0.75,
1.0,
1.25,
1.5,
2.0
)
- 我们将
sub_10A993E14的伪代码,参数a1的类型是BAPIPlayersharedSettingItem,a2的类型是VKSettingView.SelectModel一起给chatgpt分析,chatgpt叫我们查看swift_initStaticObject的参数&unk_116E60370的值是什么。- 如果
chatgpt的分析结果没用,我们就自己打断点,看是哪些汇编代码更改了items的值,再看汇编代码对应的伪代码是怎样的。
- 如果
检查 inited = swift_initStaticObject(...) 对象
inited 很可能是 SelectModel 或其内部配置对象(例如某个静态配置结构体或 Swift 字典/数组)被初始化。你可以在反汇编中查看 swift_initStaticObject 的参数 &unk_116E60370 看看该静态对象是什么,它可能携带 items 的初始数据。若你在数据段或只读段中找到与 “items” 相关的字符串数组、常量字符串列表、NSStringPointer 等,那可能就是 items 的来源。
- 查看
&unk_116E60370的值,发现是在数据段(__data)中
- 查看
&unk_116E60370的值的16进制视图,发现它旁边的地址存放着播放速度,所以&unk_116E60370保存着播放速度数组
- 我们知道数据段(
__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。
播放速度字符串数组的地址
0000000116E60390 30 2E 35 00 00 00 00 00 00 00 00 00 00 00 00 E3 0.5.............
0000000116E603A0 30 2E 37 35 00 00 00 00 00 00 00 00 00 00 00 E4 0.75............
0000000116E603B0 31 2E 30 00 00 00 00 00 00 00 00 00 00 00 00 E3 1.0.............
0000000116E603C0 31 2E 32 35 00 00 00 00 00 00 00 00 00 00 00 E4 1.25............
0000000116E603D0 31 2E 35 00 00 00 00 00 00 00 00 00 00 00 00 E3 1.5.............
0000000116E603E0 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_LandscapeVideo_HalfScreenPlayback() {
/*
0000000116E60390 30 2E 35 00 00 00 00 00 00 00 00 00 00 00 00 E3 0.5.............
0000000116E603A0 30 2E 37 35 00 00 00 00 00 00 00 00 00 00 00 E4 0.75............
0000000116E603B0 31 2E 30 00 00 00 00 00 00 00 00 00 00 00 00 E3 1.0.............
0000000116E603C0 31 2E 32 35 00 00 00 00 00 00 00 00 00 00 00 E4 1.25............
0000000116E603D0 31 2E 35 00 00 00 00 00 00 00 00 00 00 00 00 E3 1.5.............
0000000116E603E0 32 2E 30 00 00 00 00 00 00 00 00 00 00 00 00 E3 2.0.............
*/
uintptr_t baseAddress = g_slide + 0x116E60390;
write_rate_to_address(baseAddress);
}
非越狱解决方案
修改目标
非越狱环境下,需要直接对 Mach-O 文件 进行静态修改,即在 IDA 中对 __DATA 段对应地址进行 Patch。
播放速度字符串数组的地址
0000000116E60390 30 2E 35 00 00 00 00 00 00 00 00 00 00 00 00 E3 0.5.............
0000000116E603A0 30 2E 37 35 00 00 00 00 00 00 00 00 00 00 00 E4 0.75............
0000000116E603B0 31 2E 30 00 00 00 00 00 00 00 00 00 00 00 00 E3 1.0.............
0000000116E603C0 31 2E 32 35 00 00 00 00 00 00 00 00 00 00 00 E4 1.25............
0000000116E603D0 31 2E 35 00 00 00 00 00 00 00 00 00 00 00 00 E3 1.5.............
0000000116E603E0 32 2E 30 00 00 00 00 00 00 00 00 00 00 00 00 E3 2.0.............
操作流程
- 定位目标地址
- 使用
Edit → Patch program → Change byte - 按照字符串与长度规则修改字节
- 应用 Patch 并保存到原始文件
示例
比如修改0000000116E603A0
0000000116E603A0 30 2E 37 35 00 00 00 00 00 00 00 00 00 00 00 E4 0.75............
- 鼠标点击
0000000116E603A0 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
- 全部修改完后
0000000116E60390 30 2E 35 00 00 00 00 00 00 00 00 00 00 00 00 E3 0.5.............
0000000116E603A0 31 2E 30 00 00 00 00 00 00 00 00 00 00 00 00 E3 1.0.............
0000000116E603B0 31 2E 32 35 00 00 00 00 00 00 00 00 00 00 00 E4 1.25............
0000000116E603C0 31 2E 35 00 00 00 00 00 00 00 00 00 00 00 00 E3 1.5.............
0000000116E603D0 32 2E 30 00 00 00 00 00 00 00 00 00 00 00 00 E3 2.0.............
0000000116E603E0 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->OK
-
保存后,底部会显示
log:Patch successful: /Users/touchworld/Documents/iOSDisassembler/hook/bilibili/IDA_max_0/bili-universal
最终效果
完成修改后,横屏视频 · 半屏播放场景下即可正常显示并使用 3.0x 倍速播放:
总结
在本篇中,我们针对哔哩哔哩 App(8.41.0 版)横屏视频 · 半屏播放场景,成功添加了 3 倍速选项。核心是通过逆向定位到 __DATA 段的静态播放速度字符串数组(基地址偏移 0x116E60390),并理解其固定 16 字节块 + 末尾长度标记(E0+len)的内存布局。
实现方式:
- 越狱:运行时动态覆盖数组为
["0.5", "1.0", "1.25", "1.5", "2.0", "3.0"]。 - 非越狱:使用 IDA 静态 Patch Mach-O 文件,手动修改字节。
修改精准,仅影响当前场景,无副作用;3 倍速显示与播放均正常。本篇为系列第二部分,后续将覆盖全屏、竖屏等场景,最终实现全场景支持。
感谢阅读,希望帮助到期待 3 倍速的 B 站用户!