iOS逆向-哔哩哔哩增加3倍速播放(2)-横屏视频·半屏播放场景

218 阅读14分钟

前言

作为哔哩哔哩的重度用户,我一直期待官方支持 3 倍速播放,但该功能迟迟未上线。于是,我利用 iOS 逆向工程知识,为 B 站 App 添加这一功能。

修改前:最高仅支持 2.0 倍速。

Screenshot 2025-12-11 at 07.26.05-6018307.png

修改后:成功添加 3.0 倍速选项

Screenshot 2025-12-11 at 07.22.57-6018307.png

本系列分为多篇,本文聚焦 横屏视频 · 半屏播放 场景下的 3 倍速实现。

系列回顾

场景

本文聚焦的具体使用场景为:横屏视频 · 半屏播放模式 下的播放速度设置页面。

CE1C32DB-8B78-4543-844C-5283FA858E86.png

开发环境

  • 哔哩哔哩版本:8.41.0
  • 逆向框架:MonkeyDev
  • 反汇编工具:IDA Professional 9.0
  • IDA插件:patching
  • UI 调试工具:Lookin

分析

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:

1D8085C7-9797-435A-A5E2-3D748FE9B097.png

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)
...
}

5C5C3435-8A3B-47BB-8689-E56D31E2617E.png

  • 在 Xcode 中对该方法(sub_10D8ACB88)添加符号断点后,结合 LLDB 打印参数,可以确认 items 的实际内容:

    ["0.5", "0.75", "1.0", "1.25", "1.5", "2.0"]
    

    这也验证了 items 确实对应播放速度列表。

添加符号断点:

E7D376B3-3226-4C6E-AFA4-F9F94058CE32.png

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的值

16093E5E-4BD2-4281-9945-297047044F27.png

(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.SelectModelitems的值。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的类型是BAPIPlayersharedSettingItema2的类型是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)中

AD08C629-FC16-405A-B8EC-D4F866C62775.png

  • 查看&unk_116E60370的值的16进制视图,发现它旁边的地址存放着播放速度,所以&unk_116E60370保存着播放速度数组

0DF1A4B8-7DCF-4F83-A4DB-6C484DF53598.png

  • 我们知道数据段(__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.250.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.............

操作流程

  1. 定位目标地址
  2. 使用 Edit → Patch program → Change byte
  3. 按照字符串与长度规则修改字节
  4. 应用 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

9FFF3DBB-69F3-49EB-9A32-8CA94207D159.png

  • 显示Patch Bytes弹框

D3949C2A-9560-4D76-A710-ADAAE4E75A81.png

  • 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.530 2E 35 00 00 00 00 00  00 00 00 00 00 00 00 E3
0.7530 2E 37 35 00 00 00 00  00 00 00 00 00 00 00 E4
1.031 2E 30 00 00 00 00 00  00 00 00 00 00 00 00 E3
1.2531 2E 32 35 00 00 00 00  00 00 00 00 00 00 00 E4
1.531 2E 35 00 00 00 00 00  00 00 00 00 00 00 00 E3
2.032 2E 30 00 00 00 00 00  00 00 00 00 00 00 00 E3
  • 新的播放速度对应的值:
0.530 2E 35 00 00 00 00 00  00 00 00 00 00 00 00 E3
1.031 2E 30 00 00 00 00 00  00 00 00 00 00 00 00 E3
1.2531 2E 32 35 00 00 00 00  00 00 00 00 00 00 00 E4
1.531 2E 35 00 00 00 00 00  00 00 00 00 00 00 00 E3
2.032 2E 30 00 00 00 00 00  00 00 00 00 00 00 00 E3
3.033 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

9D1A3899-9871-4DF3-BC43-6D3109085192.png

29737F6A-DF9E-48AB-A46A-77363CA8546B.png

  • 保存后,底部会显示log

    Patch successful: /Users/touchworld/Documents/iOSDisassembler/hook/bilibili/IDA_max_0/bili-universal
    

F1E26F23-6F74-4DEC-B1CD-1256372F5FBE.png

最终效果

完成修改后,横屏视频 · 半屏播放场景下即可正常显示并使用 3.0x 倍速播放

73D09925-1B81-42A9-8392-8EFB88B9884D.png

总结

在本篇中,我们针对哔哩哔哩 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 站用户!

代码

BiliBiliMApp-无广告版哔哩哔哩