经过在越狱环境下修改SpringBoard.app,实现了一个iOS桌面的无限屏模式,实拍效果以下: git
几天前锤子举行了夏季发布会,笔者抱着听相声的心态观看了发布会全程,在看到无限屏片断时不由感叹老罗的脑洞之大,抛开其实用性不谈,笔者对无限屏的原理和实现进行了研究,并在越狱机上完美还原了这一功能。github
要实现无限屏,主要有两点,第一点是一个稳定的惯导算法来获取手机的相对位移,第二点是渲染一个远大于手机屏幕的虚拟空间,使得在视口发生位移时,产生在无限屏上游历的效果,本文将对这两点的具体实现进行讲解,并在文末开源整个无限屏的实现。算法
ARKit经过双摄像头配合或是单摄像头+陀螺仪配合能够实现较为稳定的视觉里程计,从而可以检测到手机在真实世界的姿态和位移,并将其映射到虚拟世界,为了获取手机的相对位移,咱们能够在App中启动一个ARSession,并经过ARFrame更新的回调去获取虚拟世界摄像机的位置信息,从而计算出相对位移。spring
在ARKit的虚拟世界中,使用了和陀螺仪一致的右手系,以下图所示。 windows
在老罗的发布会演示中咱们看到无限屏功能主要包括沿着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点去计算。session
ARKit在初始化阶段时translate向量将返回全0,所以咱们将translate首次不为0做为初始化完成的标识,标定A点,并开始相对位置的输出,代码以下。app
// 用于计算三轴数据的变量
@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输出稳定的时机,经过这三个变量配合便可完成原点标定,从而使得随后可以获取以手机当前位置为原点的三轴偏移量。ide
无限屏的实现相似于用手机浏览器查看电脑版网页的效果,以手机屏幕为尺寸做为一个视口,在一个大于手机屏幕的范围内进行浏览,其实是视口的位置发生了变换,能够理解为一个垂直向下拍摄的摄像机在一个巨幅图片上进行移动。
对于SpringBoard.app,它其实是一个巨幅的UIScrollView,所以它自己就是这个比屏幕尺寸大的虚拟空间,它包含了-1屏和多屏桌面,可是为了实现一些3D效果,笔者选择了对SpringBoard的ScrollView进行截图,在真实游历时,其实是隐藏了真实的桌面,显示了一幅"假桌面",为了方便期间咱们称其为FakeScrollView,FakeScrollView上添加的是通过处理后的真实桌面截图。
经过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将整个过程变得自动化。
笔者经过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
复制代码
因为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);
}
复制代码
arch/UltimateSpringBoard.bundle
利用scp命令传输到/Library/MobileSubstrate/DynamicLibraries/
目录,这些是插件须要访问的资源NSCameraUsageDescription
权限也许无限屏并不能带来什么,可是这个探索过程是十分有趣的,但愿本文可以帮助那些好奇无限屏实现原理和想要实践越狱插件开发的同窗们。