基于ARKit的iOS无限屏实现,还原锤子发布会效果

9,291 阅读11分钟

效果展示

通过在越狱环境下修改SpringBoard.app,实现了一个iOS桌面的无限屏模式,实拍效果如下:

背景

几天前锤子举行了夏季发布会,笔者抱着听相声的心态观看了发布会全程,在看到无限屏片段时不禁感叹老罗的脑洞之大,抛开其实用性不谈,笔者对无限屏的原理和实现进行了研究,并在越狱机上完美还原了这一功能。

原理

要实现无限屏,主要有两点,第一点是一个稳定的惯导算法来获取手机的相对位移,第二点是渲染一个远大于手机屏幕的虚拟空间,使得在视口发生位移时,产生在无限屏上游历的效果,本文将对这两点的具体实现进行讲解,并在文末开源整个无限屏的实现。

获取手机的相对位移

ARKit通过双摄像头配合或是单摄像头+陀螺仪配合可以实现较为稳定的视觉里程计,从而能够检测到手机在真实世界的姿态和位移,并将其映射到虚拟世界,为了获取手机的相对位移,我们可以在App中启动一个ARSession,并通过ARFrame更新的回调去获取虚拟世界摄像机的位置信息,从而计算出相对位移。

在ARKit的虚拟世界中,使用了和陀螺仪一致的右手系,如下图所示。

在老罗的发布会演示中我们看到无限屏功能主要包括沿着X轴左右移动视口和沿着Y轴上下移动视口两部分,因此我们需要通过ARFrame去获取X轴和Y轴的相对位移。

在ARSession启动后,会不断通过回调通知ARFrame的更新,在回调方法中我们可以拿到摄像机的transform矩阵,该矩阵的大小为4x4,经过查阅资料了解到,矩阵最后一行的前三个元素分别是x、y、z三轴相对AR原点的坐标,通过这三个坐标我们可以获取到三轴的相对位置,这一行也被称为相机的translate向量。

- (void)session:(ARSession *)session didUpdateFrame:(ARFrame *)frame {
    matrix_float4x4 mat33 = frame.camera.transform;
    simd_float4 pos = mat33.columns[3];
    float x = pos[0];
    float y = pos[1];
    float z = pos[2];
}

需要注意的是这三个坐标都是相对ARKit所确定的原点计算出来的,我们现在需要以当前位置为原点计算手机的相对移动,因此需要对数据的原点进行重新标定,一个简易的方法是在ARFrame初始化完成后将当前的x、y、z三轴位置记录下来作为标定点A(x0, y0, z0),后续在计算时都相对A点去计算。

ARKit在初始化阶段时translate向量将返回全0,因此我们将translate首次不为0作为初始化完成的标识,标定A点,并开始相对位置的输出,代码如下。

// 用于计算三轴数据的变量
@property (nonatomic, assign) float x_pre;
@property (nonatomic, assign) float x_base;
@property (nonatomic, assign) BOOL hasInitX;
@property (nonatomic, assign) BOOL findXBase;

@property (nonatomic, assign) float y_pre;
@property (nonatomic, assign) float y_base;
@property (nonatomic, assign) BOOL hasInitY;
@property (nonatomic, assign) BOOL findYBase;

@property (nonatomic, assign) float z_pre;
@property (nonatomic, assign) float z_base;
@property (nonatomic, assign) BOOL hasInitZ;
@property (nonatomic, assign) BOOL findZBase;

// val: camera某个轴向的实际坐标值
// pre: 上一个camera坐标值
// base: 标定后的原点
// hasInit: 是否完成了某轴向的初始化
// findBase: 是否完成了某轴向的标定
float calculateOffset(float val, float *pre, float *base, BOOL *hasInit, BOOL *findBase) {
    // 判断translate某轴向的值是否非0,非0说明ARKit完成了初始化
    if (!(*hasInit) && val < 0.0000001f) {
        NSLog(@"init");
        return 0;
    } else {
        *hasInit = YES;
    }
    // 判断ARKit某轴向的两次输出是否差值很小,差值很小时说明已经稳定,将当前位置标定为当前轴向的原点
    if (!(*findBase) && fabs(val - *pre) < 0.01f) {
        NSLog(@"value is stable at %f", val);
        *base = val;
        *findBase = YES;
        return 0;
    }
    // 计算实际translate和标定点之间的距离
    float offset = val - *base;
    *pre = val;
    return offset;
}

- (void)session:(ARSession *)session didUpdateFrame:(ARFrame *)frame {
    matrix_float4x4 mat33 = frame.camera.transform;
    simd_float4 pos = mat33.columns[3];
    // ARCamera的translate
    float x = pos[0];
    float y = pos[1];
    float z = pos[2];
    // 计算相对手机当前位置的偏移量
    float offsetX = calculateOffset(x, &_x_pre, &_x_base, &_hasInitX, &_findXBase);
    float offsetY = calculateOffset(y, &_y_pre, &_y_base, &_hasInitY, &_findYBase);
    float offsetZ = calculateOffset(z, &_z_pre, &_z_base, &_hasInitZ, &_findZBase);
    // 输出稳定的三轴偏移(offsetX, offsetY, offsetZ)
}

上面的代码由于需要在函数内修改全局变量而变得较为混乱,基本类型通过指针来回传递,不够优雅,总之每个轴向都有三个关键全局变量,hasInit用于表示ARKit是否完成初始化,findBase用于表示是否已经完成了标定,pre值用于记录上一次输出来检测ARKit输出稳定的时机,通过这三个变量配合即可完成原点标定,从而使得随后能够获取以手机当前位置为原点的三轴偏移量。

渲染虚拟空间

无限屏的实现类似于用手机浏览器查看电脑版网页的效果,以手机屏幕为尺寸作为一个视口,在一个大于手机屏幕的范围内进行浏览,实际上是视口的位置发生了变换,可以理解为一个垂直向下拍摄的摄像机在一个巨幅图片上进行移动。

对于SpringBoard.app,它实际上是一个巨幅的UIScrollView,因此它本身就是这个比屏幕尺寸大的虚拟空间,它包含了-1屏和多屏桌面,但是为了实现一些3D效果,笔者选择了对SpringBoard的ScrollView进行截图,在真实游历时,实际上是隐藏了真实的桌面,显示了一幅"假桌面",为了方便期间我们称其为FakeScrollView,FakeScrollView上添加的是经过处理后的真实桌面截图。

截取一个UIScrollView的全貌

通过Layer的渲染方法可以将UIScrollView的整个contentSize范围绘制到一个图形上下文中,代码如下。

// scrollView是SpringBoard.app的桌面SBIconScrollView
CGRect rect = (CGRect){0, 0, scrollView.contentSize};
UIGraphicsBeginImageContextWithOptions(rect.size, false, [UIScreen mainScreen].scale);
[scrollView.layer renderInContext:UIGraphicsGetCurrentContext()];
UIImage *desktopImage = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();

在桌面图片上下添加相机和地图区域

在发布会上,老罗演示了上移手机自拍和下移手机打开地图的功能,为了还原这一功能,笔者将上述操作获取的桌面截图desktopImage进行了二次处理,利用CoreGraphics在图片上方绘制一个topImage,下方绘制一个bottomImage,topImage的内容为一排相机Icon,bottomimage的内容为一排地球Icon,要实现图片拼接,需要开一个更大的图形上下文,然后依次将图片渲染到指定位置,完整代码如下。

// 截取桌面,作为大图的中间部分middleImage
CGRect rect = (CGRect){0, 0, scrollView.contentSize};
UIGraphicsBeginImageContextWithOptions(rect.size, false, [UIScreen mainScreen].scale);
[scrollView.layer renderInContext:UIGraphicsGetCurrentContext()];
UIImage *middleImage = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
// 从资源文件读取相机和地球,USBResource是一个资源获取的辅助类
UIImage *topImage = [USBResource imageNamed:@"camera.png"];
UIImage *bottomImage = [USBResource imageNamed:@"earth.png"];
// 上下视图的垂直间距
CGFloat imageMargin = 320;
// 相机和地球平铺的水平间距
CGFloat marginH = 80;
// 具体位置计算
CGFloat topImageW = 120;
CGFloat topImageH = 89;
CGFloat bottomImageW = 120;
CGFloat bottomImageH = 120;
// 用于渲染完整图片的上下文
CGSize ctxSize = CGSizeMake(middleImage.size.width, middleImage.size.height + topImageH + bottomImageH + imageMargin * 4);
UIGraphicsBeginImageContextWithOptions(ctxSize, NO, [UIScreen mainScreen].scale);
// add top image: camera
CGFloat topImageX = marginH;
CGFloat topImageY = topImageH + imageMargin;
NSInteger count = (ctxSize.width - marginH) / (topImageW + marginH);
for (NSInteger i = 0; i < count; i++) {
    [topImage drawInRect:CGRectMake(topImageX, topImageY, topImageW, topImageH)];
    topImageX += topImageW + marginH;
}
// add middle image: desktop
[middleImage drawInRect:CGRectMake(0, topImageH + imageMargin * 2, middleImage.size.width, middleImage.size.height)];
// add bottom image: earth
CGFloat bottomImageX = marginH;
CGFloat bottomImageY = ctxSize.height - imageMargin - bottomImageH;
count = (ctxSize.width - marginH) / (bottomImageW + marginH);
for (NSInteger i = 0; i < count; i++) {
    [bottomImage drawInRect:CGRectMake(bottomImageX, bottomImageY, bottomImageW, bottomImageH)];
    bottomImageX += bottomImageW + marginH;
}
// 获取到的"假桌面"图片
UIImage *snapshot = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();

随后只需要将snapshot图片添加到FakeScrollView,在开启无限屏模式时隐藏真实桌面SBIconScrollView,显示FakeScrollView即可,为了更好地效果,这里对FakeScrollView和snapshot图片都进行了一些3D的仿射变换,最终效果如下图所示。这部分代码可以在文末的源码中查看,这里不再赘述,

实现

由于需要修改SpringBoard.app,本文建立在越狱环境的基础之上,如果读者没有越狱环境也没有关系,可以将修改的目标变为自己所写的App,比如实现一个可以左右、上下翻阅的地图、PDF阅读器等,本文的实现部分主要介绍如何修改SpringBoard.app从而达到上述效果。

知识储备和环境

其中MonkeyDev是为了简化Theos的编译链接和部署流程,不是必须的环境,但是缺少该环境会导致无法正常运行文末的Xcode工程,需要手动去编译出deb并安装,MonkeyDev将整个过程变得自动化。

Hook SpringBoard

笔者通过Theos提供的Logos语言对SpringBoard的桌面视图SBIconScrollView进行了hook,由于桌面进行了分页(Paging),因此启动时一定会调用UIScrollView的- (void)setPagingEnabled:(BOOL)enabled方法,我们就以这个方法作为Hook的起点,注意以下代码都是Logos语言。

%hook SBIconScrollView

- (void)setPagingEnabled:(BOOL)enabled {
    static const void *key;
    // 利用关联对象实现防止重复调用
    if (objc_getAssociatedObject(self, key) != nil) {
        %orig(enabled);
        return;
    }
    // 在这里完成初始化
    // ...
    objc_setAssociatedObject(self, key, @"", OBJC_ASSOCIATION_RETAIN);
    %orig(enabled);
}

%end

上述代码为我们在SBIconScrollView上开辟了一个代码执行的入口,随后我们可以根据当前ScrollView去找到ViewController和Window,通过Reveal分析,桌面的根窗口为SBHomeScreenWindow,下面的代码演示了如何找到这个窗口并记录下来,方便后续操作。

for (UIWindow *window in [UIApplication sharedApplication].windows) {
        if ([window isKindOfClass:NSClassFromString(@"SBHomeScreenWindow")]) {
            // 找到关键的窗口和控制器
            UIWindow *mainWindow = window;
            UIViewController *mainVc = window.rootViewController;
            break;
        }
}

由于动态库并不能为Hook的类动态添加实例变量,因此这里只能通过Runtime的关联对象去记录这些关键信息,大量的关联对象将使得代码不够优雅,另一个更好地方案是使用一个全局的单例对象去维护这些信息。

进入和退出无限屏模式

进入无限屏模式,即将Hook的类直接隐藏,在Window上添加一个FakeScrollView,并开启ARSession进行位置追踪;反之,退出无限屏模式即是对关闭ARSession,还原现场。

动态库的资源访问

由于动态库以dylib的形式直接插入到Mach-O文件的LOAD_COMMANDS字段,所以在加载时无法携带资源,一个比较优雅的方式是将资源以bundle的形式放置在dylib的安装目录,并在dylib中以绝对路径进行访问,越狱环境下dylib的安装目录为/Library/MobileSubstrate/DynamicLibraries,在这里放置一个资源bundle,并且封装一个资源访问类,代码如下。

#import "USBResource.h"

#define BundlePath @"/Library/MobileSubstrate/DynamicLibraries/UltimateSpringBoard.bundle"

@implementation USBResource

+ (UIImage *)imageNamed:(NSString *)name {
    return [UIImage imageWithContentsOfFile:[BundlePath stringByAppendingPathComponent:name]];
}

@end

为SpringBoard添加权限

由于ARKit需要使用相机,需要为SpringBoard添加一条权限,这需要直接修改SpringBoard的Info.plist,不必担心,系统App和自己开发App的Info.plist并没有进行代码签名,直接修改即可,为了防止出现意外,建议备份一份Info.plist以防不测。

首先用SSH登录到iPhoen或iPad,用ps -ef | grep SpringBoard查询SpringBoard.app的路径,然后进入该路径,将Info.plist用scp命令或者SFTP客户端传输到电脑,通过Xcode为其添加NSCameraUsageDescription条目,然后利用scp回传后覆盖即可。

安全模式

由于直接修改了SpringBoard.app,如果出现严重bug但没有引起SpringBoard Crash,会导致无法进入越狱系统的SpringBoard安全模式,这会使得在脱离电脑的情况下无法重启SpringBoard,假如这时候SpringBoard无法正常点击,则会导致手机无法正常使用,因此需要设计一个"自杀"功能,来使得插件能够自动重启SpringBoard,笔者所用的方案是在SpringBoard上添加一个按钮,点击后执行exit(0),随后系统会自动重启SpringBoard,具体代码如下。

// 添加一个Respring按钮
UIButton *closeBtn = [UIButton new];
// ...省略配置过程
[closeBtn addTarget:self action:@selector(closeBtnClick) forControlEvents:UIControlEventTouchUpInside];
[window addSubview:closeBtn];

// 回调方法
%new
- (void)closeBtnClick {
    exit(0);
}

源码与运行

源码下载

github.com/Soulghost/I…

配置

  1. 打开Xcode工程
  2. 打开UltimateSpringBoard Target的Build Settings,配置User-Defined的Settings中的MonkeyDevDeviceIP、Port等信息,这些信息用于在Theos构建后自动将deb传输和安装到手机
  3. 将工程根目录下的arch/UltimateSpringBoard.bundle利用scp命令传输到/Library/MobileSubstrate/DynamicLibraries/目录,这些是插件需要访问的资源
  4. 为SpringBoard.app的Info.plist添加NSCameraUsageDescription权限
  5. Build工程即可完成安装

手动编译和安装

  • 工程的Packages目录中包含了编译好的deb包,可以直接体验
  • UltimateSpringBoard.xm是Logos主文件,可以用Theos手动编译

感想

也许无限屏并不能带来什么,但是这个探索过程是十分有趣的,希望本文能够帮助那些好奇无限屏实现原理和想要实践越狱插件开发的同学们。