ARKit从入门到精通(11)--实现锤子手机的无限屏效果
AIRX社区 2022-08-25

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



效果预览



Step 1:原理


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


获取手机的相对位移


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的桌面SBIconScrollViewCGRect 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,要实现图片拼接,需要开一个更大的图形上下文,然后依次将图片渲染到指定位置,完整代码如下:


// 截取桌面,作为大图的中间部分middleImageCGRect 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: cameraCGFloat 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: earthCGFloat 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的仿射变换,最终效果如下图所示。这部分代码可以在文末的源码中查看,这里不再赘述。



Step 2:方法实现


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


知识储备和环境


  • 越狱开发的基础知识,SSH、SCP、动态库加载实现Hook等

  • 支持ARKit的iPhone或iPad

  • 越狱的iPhone或iPad Electra Jailbreak

  • Theos开发环境 theos.github.io

  • MonkeyDev开发环境 github.com/AloneMonkey…


其中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);}


Step 3:源码与运行


源码下载:

https://github.com/Soulghost/InfiniteSpringBoard


配置


1. 打开Xcode工程

2.打开UltimateSpringBoard Target的Build Settings,配置User-Defined的Settings中的MonkeyDevDeviceIP、Port等信息,这些信息用于在Theos构建后自动将deb传输和安装到手机

4. 将工程根目录下的arch/UltimateSpringBoard.bundle利用scp命令传输到/Library/MobileSubstrate/DynamicLibraries/目录,这些是插件需要访问的资源

5. 为SpringBoard.app的Info.plist添加NSCameraUsageDescription权限

6. Build工程即可完成安装


手动编译和安装


  • 工程的Packages目录中包含了编译好的deb包,可以直接体验

  • UltimateSpringBoard.xm是Logos主文件,可以用Theos手动编译


欢迎点 “ 在看 ”哦
声明: 本文转载自其它媒体或授权刊载,目的在于信息传递,并不代表本站赞同其观点和对其真实性负责,如有新闻稿件和图片作品的内容、版权以及其它问题的,请联系我们及时删除。(联系我们,邮箱:evan.li@aspencore.com )
0
评论
  • 相关技术文库
  • 手机
  • 消费电子
  • 快充
  • USB
下载排行榜
更多
评测报告
更多
广告